Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 0 additions & 16 deletions webviews/scripts/fixed-waits-baseline.tsv
Original file line number Diff line number Diff line change
@@ -1,24 +1,8 @@
# Fixed waits per test file from before the rule (scripts/check-fixed-waits.ts). Only moves down.
test/agent-pane-permission-deny-feedback.test.ts 6
test/agent-pane-permission-queue.test.ts 5
test/agent-pane-queue-steer.test.ts 4
test/agent-pane-queue.test.ts 2
test/agent-pane-scrollbar-full-height.test.ts 19
test/agent-pane-transcript-spacing.test.ts 2
test/agent-pane-wheel-pass-through.test.ts 16
test/browser/ui-a11y.harness.tsx 4
test/composer-caret-focus.test.ts 21
test/composer-location-strip.test.ts 1
test/composer-menu-dismiss.test.ts 16
test/composer-quiet-chips.test.ts 14
test/hover-row-sweep.test.ts 6
test/latency/actions.ts 23
test/latency/mock-host.ts 3
test/latency/picker.harness.tsx 7
test/markdown-wide-table-scroll.test.ts 4
test/newtab-quiet-focus.test.ts 1
test/popup-reduce-motion.test.ts 2
test/settings-scroll.test.ts 9
test/settings-search-results.test.ts 1
test/transcript-follow.test.ts 3
test/transcript-switch-back.test.tsx 3
49 changes: 37 additions & 12 deletions webviews/test/agent-pane-permission-deny-feedback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,24 @@ await requireBrowserLane("agent-pane-permission-deny-feedback.test.ts", async ()
]
: [],
);
const settle = async (page: Page, count: number) => {
for (let tries = 0; tries < 40 && answers().length < count; tries++) await page.waitForTimeout(50);
await page.waitForTimeout(200);
};
/// Called each time the pane asks acpmux something.
let onAsk: (() => void) | undefined;
/// Resolves once acpmux has heard `count` answers.
const heard = (count: number) =>
new Promise<void>((resolve) => {
const check = () => {
if (answers().length < count) return;
onAsk = undefined;
resolve();
};
onAsk = check;
check();
});
/// Whether the keyboard is in the composer: a deny acpmux took has finished (sendFeedback).
const inComposer = (page: Page) =>
page.waitForFunction(() => Boolean(document.activeElement?.closest(".acpmux-composer")), undefined, {
timeout: 10_000,
});
beforeAll(async () => {
daemon = Bun.serve({
port: 0,
Expand All @@ -151,6 +165,7 @@ await requireBrowserLane("agent-pane-permission-deny-feedback.test.ts", async ()
message(ws, raw) {
const message = JSON.parse(String(raw)) as { id?: number; method: string; params?: Record<string, any> };
asked.push({ method: message.method, params: message.params ?? {} });
onAsk?.();
if (message.id !== undefined && refuseDeny && message.method === "_acpmux/permission_respond")
ws.send(
JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32000, message: "already answered" } }),
Expand Down Expand Up @@ -194,10 +209,9 @@ await requireBrowserLane("agent-pane-permission-deny-feedback.test.ts", async ()
await page.getByRole("button", { name: "No, tell Claude what to do differently" }).click();
expect(await field.inputValue()).toBe("Run only the unit tests");
await field.press("Enter");
await settle(page, 2);
// The keyboard goes back to the composer, as a plain deny's does, once the text has gone too.
await inComposer(page);
expect(answers()).toEqual(["respond reject_once", "prompt (steer) Run only the unit tests"]);
// The keyboard goes back to the composer, as a plain deny's does.
expect(await page.evaluate(() => Boolean(document.activeElement?.closest(".acpmux-composer")))).toBe(true);
await page.close();
});

Expand All @@ -209,9 +223,18 @@ await requireBrowserLane("agent-pane-permission-deny-feedback.test.ts", async ()
const field = page.getByRole("textbox", { name: "Tell the agent what to do instead" });
await field.fill("Use pnpm");
await field.press("Enter");
await settle(page, 1);
// The refusal is back: the field can be typed in again and has the keyboard.
await page.waitForFunction(
() => {
const active = document.activeElement;
return (
active instanceof HTMLTextAreaElement && active.closest(".acpmux-permission-feedback") && !active.readOnly
);
},
undefined,
{ timeout: 10_000 },
);
expect(answers()).toEqual(["respond reject_once"]);
await field.waitFor({ timeout: 5_000 });
expect(await field.inputValue()).toBe("Use pnpm");
} finally {
refuseDeny = false;
Expand All @@ -225,7 +248,7 @@ await requireBrowserLane("agent-pane-permission-deny-feedback.test.ts", async ()
await page.getByRole("button", { name: "Decline" }).waitFor({ timeout: 5_000 });
await page.getByRole("button", { name: "Deny and tell the agent what to do instead" }).click();
await page.getByRole("button", { name: "Deny and send" }).click();
await settle(page, 1);
await inComposer(page);
expect(answers()).toEqual(["respond denied"]);
await page.close();
});
Expand All @@ -241,8 +264,10 @@ await requireBrowserLane("agent-pane-permission-deny-feedback.test.ts", async ()
expect(await page.evaluate(() => document.activeElement?.textContent)).toBe(
"Deny and tell the agent what to do instead",
);
await page.waitForTimeout(200);
expect(answers()).toEqual([]);
// Cancel sent nothing: the next answer the card gives is the first acpmux hears.
await page.getByRole("button", { name: "Approve" }).click();
await heard(1);
expect(answers()).toEqual(["respond approved"]);
await page.close();
});
});
56 changes: 22 additions & 34 deletions webviews/test/agent-pane-permission-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,17 @@ await requireBrowserLane("agent-pane-permission-queue.test.ts", async () => {

/// The answers each page sent, as `_acpmux/permission_respond` params.
const responses: Record<string, unknown>[] = [];
/// Called each time the pane answers an ask.
let onResponse: (() => void) | undefined;
/// Resolves once acpmux has heard an answer.
const answered = () =>
new Promise<void>((resolve) => {
if (responses.length > 0) return resolve();
onResponse = () => {
onResponse = undefined;
resolve();
};
});
function answer(method: string, params: Record<string, any> = {}): unknown {
const id = params.sessionId === REOPENED ? REOPENED : LIVE;
switch (method) {
Expand All @@ -92,6 +103,7 @@ await requireBrowserLane("agent-pane-permission-queue.test.ts", async () => {
return { events: [], hasMore: false, lastSeq: steps.length };
case "_acpmux/permission_respond":
responses.push(params);
onResponse?.();
return {};
default:
return null;
Expand Down Expand Up @@ -122,20 +134,12 @@ await requireBrowserLane("agent-pane-permission-queue.test.ts", async () => {
return page;
};
const card = (page: Page) => page.locator(".acpmux-permission-ask");
/// Waits until `read` returns a value `ok` accepts, and returns it (fails after 10 s).
const until = async <T>(read: () => T | Promise<T>, ok: (value: T) => boolean): Promise<T> => {
const deadline = Date.now() + 10_000;
for (;;) {
const value = await read();
if (ok(value) || Date.now() > deadline) return value;
await new Promise((resolve) => setTimeout(resolve, 50));
}
/// The card's text once it shows `text` (fails after 10 s).
const cardShowing = async (page: Page, text: string) => {
const shown = card(page).filter({ hasText: text }).first();
await shown.waitFor({ timeout: 10_000 });
return shown.textContent();
};
const cardText = (page: Page) =>
card(page)
.first()
.textContent({ timeout: 1000 })
.catch(() => "");

beforeAll(async () => {
daemon = Bun.serve({
Expand Down Expand Up @@ -189,25 +193,13 @@ await requireBrowserLane("agent-pane-permission-queue.test.ts", async () => {
expect(await card(page).textContent()).toContain("1 more waiting");
responses.length = 0;
await card(page).getByRole("button", { name: "Yes" }).click();
const sent = await until(
() => responses.map((params) => params.permissionId),
(ids) => ids.length > 0,
);
expect(sent).toEqual(["p1"]);
await answered();
expect(responses.map((params) => params.permissionId)).toEqual(["p1"]);
push(LIVE, ["mux", "permission_decision", { permissionId: "p1", optionId: "allow" }]);
const next = await until(
() => cardText(page),
(text) => (text ?? "").includes("Run git push"),
);
expect(next).toContain("Run git push");
const next = await cardShowing(page, "Run git push");
expect(next).not.toContain("more waiting");
push(LIVE, ["mux", "permission_decision", { permissionId: "p2", optionId: "allow" }]);
expect(
await until(
() => card(page).count(),
(n) => n === 0,
),
).toBe(0);
await card(page).first().waitFor({ state: "detached", timeout: 10_000 });
await page.close();
});

Expand All @@ -217,11 +209,7 @@ await requireBrowserLane("agent-pane-permission-queue.test.ts", async () => {
expect(await card(page).textContent()).toContain("Run npm test");
expect(await card(page).textContent()).toContain("1 more waiting");
push(REOPENED, ["mux", "permission_decision", { permissionId: "p1", optionId: "allow" }]);
const next = await until(
() => cardText(page),
(text) => (text ?? "").includes("Run git push"),
);
expect(next).toContain("Run git push");
expect(await cardShowing(page, "Run git push")).toContain("Run git push");
await page.close();
});
});
22 changes: 16 additions & 6 deletions webviews/test/agent-pane-queue-steer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
import path from "node:path";
import { chromium, webkit, type Browser, type Page } from "playwright";
import { installPaintedFrames } from "./support/paintedFrames";
import { requireBrowserLane } from "./support/requireBrowserLane";
import { warmViteServer } from "./support/warmDevServer";

Expand Down Expand Up @@ -260,8 +261,15 @@ await requireBrowserLane("agent-pane-queue-steer.test.ts", async () => {
await page.goto(`http://127.0.0.1:${port}/#endpoint=${encodeURIComponent(endpoint)}&token=t&session=${SESSION}`);
// A cold dev server's first load can stall in CI: the first page waits longer.
await page.locator(".acpmux-queued", { hasText: "space thing" }).waitFor({ timeout: 60_000 });
await installPaintedFrames(page);
return page;
}
/// Streams `text` onto the reply (message m1) and waits for it to draw: the page has handled
/// every record sent before it.
const caughtUp = async (page: Page, text: string) => {
send(["in", "agent_message_chunk", say(text, "m1")]);
await page.locator(".acpmux-scroll", { hasText: text }).waitFor({ timeout: 10_000 });
};
/// The bubble's read receipt once it is `read` (two checks) or not yet (one).
const receipt = (page: Page, text: string, read: boolean) =>
page.locator(".cv-user", { hasText: text }).locator(`.cv-user__read[data-read="${read}"]`);
Expand All @@ -273,7 +281,7 @@ await requireBrowserLane("agent-pane-queue-steer.test.ts", async () => {
await dock.waitFor();
// acpmux's later listing carries only the preview.
changed();
await page.waitForTimeout(200);
await caughtUp(page, "queued prompts.");
const list = await page.locator(".acpmux-composer-queue").boundingBox();
const bar = await dock.boundingBox();
// A chat that has sent shows no location strip (#20869): the queue stands on the card.
Expand Down Expand Up @@ -343,7 +351,9 @@ await requireBrowserLane("agent-pane-queue-steer.test.ts", async () => {
await page.locator(".acpmux-pr-dock").waitFor();
await page.locator(".acpmux-permission-card").waitFor();
await page.locator(".acpmux-queued-text", { hasText: "the pr thing" }).click();
await page.waitForTimeout(300);
// The prompt has opened, and a frame has painted with the list fitted to it.
await page.locator('.acpmux-queued-text[aria-expanded="true"]', { hasText: "the pr thing" }).waitFor();
await page.evaluate(() => window.cmuxPaintFrame());
const box = async (selector: string) => (await page.locator(selector).first().boundingBox())!;
const list = await box(".acpmux-composer-queue");
const scroller = await box(".acpmux-scroll");
Expand Down Expand Up @@ -407,8 +417,9 @@ await requireBrowserLane("agent-pane-queue-steer.test.ts", async () => {
// A queued row shows Edit, Remove and Send now (#22805): click Send now by its name.
await row.getByRole("button", { name: "Send now", exact: true }).click();
await row.locator(".acpmux-queued-kind", { hasText: "Steering" }).waitFor({ timeout: 10_000 });
// The stopped turn has not ended yet: the row still shows.
await page.waitForTimeout(500);
// The stopped turn has not ended yet: once the page has handled what came after Send now, the
// row still shows.
await caughtUp(page, "queue order.");
expect(await row.isVisible()).toBe(true);
send(["mux", "turn_result", { status: "cancelled", turnId: "turn-1" }]);
send(["mux", "dequeued", { promptId: "q-2" }]);
Expand All @@ -427,8 +438,7 @@ await requireBrowserLane("agent-pane-queue-steer.test.ts", async () => {
send(["mux", "user_message", { text: "use the cache dir", promptId: "s-1", turnId: "turn-1", steer: true }]);
await receipt(page, "use the cache dir", false).waitFor({ state: "attached", timeout: 10_000 });
// The rest of the reply it went into still streams: not read yet.
send(["in", "agent_message_chunk", say("remapped paths.", "m1")]);
await page.waitForTimeout(200);
await caughtUp(page, "remapped paths.");
expect(await receipt(page, "use the cache dir", false).count()).toBe(1);
// The agent's next message answers the steer.
send(["in", "agent_message_chunk", say("Using the cache dir now.", "m2")]);
Expand Down
10 changes: 6 additions & 4 deletions webviews/test/agent-pane-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,8 +255,10 @@ await requireBrowserLane("agent-pane-queue.test.ts", async () => {
expect(await input.inputValue()).toBe("line one\nline two");
await page.keyboard.press("Enter");
await input.waitFor({ state: "detached" });
await page.waitForTimeout(300);
expect(edits).toEqual([]);
// The save sent nothing: the next change, a Delete, is the first acpmux hears.
await page.keyboard.press("Delete");
await page.waitForFunction(() => document.querySelectorAll(".acpmux-queued").length === 2);
expect(edits).toEqual([{ op: "remove", promptId: "q-1" }]);
await page.close();
});

Expand All @@ -267,8 +269,8 @@ await requireBrowserLane("agent-pane-queue.test.ts", async () => {
await page.keyboard.press("ArrowUp");
expect(await focused(page)).toBe("q-2");
await page.keyboard.press("Meta+Enter");
await page.waitForTimeout(300);
expect({ edits, focused: await focused(page) }).toEqual({ edits: [], focused: "q-2" });
expect(await focused(page)).toBe("q-2");
// ⌘Return sent nothing: the Delete after it is the first change acpmux hears.
await page.keyboard.press("Delete");
await page.waitForFunction(() => document.querySelectorAll(".acpmux-queued").length === 2);
expect({ edits, rows: await rows(page) }).toEqual({
Expand Down
20 changes: 19 additions & 1 deletion webviews/test/agent-pane-transcript-spacing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
import path from "node:path";
import { chromium, type Browser, type Page } from "playwright";
import { installPaintedFrames } from "./support/paintedFrames";
import { requireBrowserLane } from "./support/requireBrowserLane";
import { warmViteServer } from "./support/warmDevServer";

Expand Down Expand Up @@ -102,10 +103,27 @@ await requireBrowserLane("agent-pane-transcript-spacing.test.ts", async () => {
};
const open = async (): Promise<Page> => {
const page = await browser!.newPage({ viewport: { width: 900, height: 900 } });
await installPaintedFrames(page);
await page.goto(url());
await page.getByText("Meanwhile the PR checks").waitFor();
await page.mouse.move(0, 0);
await page.waitForTimeout(300);
// At rest: a frame has painted with no hover fade or other finite animation still running.
await page.evaluate(
() =>
new Promise<void>((resolve) =>
window.cmuxAfterPaint(() => {
const moving = document
.getAnimations()
.some(
(animation) =>
animation.playState === "running" && animation.effect?.getComputedTiming().endTime !== Infinity,
);
if (moving) return true;
resolve();
return false;
}),
),
);
return page;
};
beforeAll(async () => {
Expand Down
Loading