From d876a365093afbc72af85f8dc925b94edc913c43 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 6 Aug 2026 11:16:32 -0400 Subject: [PATCH 1/2] test(pty): wait for settled interactive frames --- .changeset/cyan-taxes-live.md | 2 ++ test/pty/chrome.test.ts | 3 +++ test/pty/filter-escape.test.ts | 1 + test/pty/harness.ts | 21 ++++++++++++++------- test/pty/layout.test.ts | 6 +++++- 5 files changed, 25 insertions(+), 8 deletions(-) create mode 100644 .changeset/cyan-taxes-live.md diff --git a/.changeset/cyan-taxes-live.md b/.changeset/cyan-taxes-live.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/cyan-taxes-live.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/test/pty/chrome.test.ts b/test/pty/chrome.test.ts index 54505a601..82f4e430a 100644 --- a/test/pty/chrome.test.ts +++ b/test/pty/chrome.test.ts @@ -143,6 +143,8 @@ describe("PTY chrome", () => { expect(initial).toContain("add = true"); expect(initial).toContain("betaValue"); + // First paint can precede keyboard subscription under CI load; prove input is live first. + await harness.ensureKeyboardIsLive(session); await session.press("tab"); await session.type("beta"); const filtered = await harness.waitForSnapshot( @@ -178,6 +180,7 @@ describe("PTY chrome", () => { expect(initial).toContain("alphaOnly = true"); expect(initial).toContain("betaValue = 2"); + await harness.ensureKeyboardIsLive(session); await session.type("/"); await harness.waitForSnapshot( session, diff --git a/test/pty/filter-escape.test.ts b/test/pty/filter-escape.test.ts index b386d05a6..928a7ac91 100644 --- a/test/pty/filter-escape.test.ts +++ b/test/pty/filter-escape.test.ts @@ -22,6 +22,7 @@ describe("filter escape clearing (PTY)", () => { try { await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); + await harness.ensureKeyboardIsLive(session); // Open filter, type a no-match query. await session.type("/"); diff --git a/test/pty/harness.ts b/test/pty/harness.ts index 2efa130a2..419e5fe33 100644 --- a/test/pty/harness.ts +++ b/test/pty/harness.ts @@ -902,13 +902,16 @@ end let snapshot = await session.text({ immediate: true }); while (Date.now() - start < timeoutMs) { - if (predicate(snapshot)) { - return snapshot; - } - + // A resize can make structural markers visible before their row text finishes painting. + // Only evaluate frames after the PTY has had a quiet period instead of accepting that + // transitional first capture. await session.waitIdle({ timeout: 50 }); await sleep(30); snapshot = await session.text({ immediate: true }); + + if (predicate(snapshot)) { + return snapshot; + } } throw new Error( @@ -935,12 +938,16 @@ end await session.press("?"); try { await waitForSnapshot(session, (text) => text.includes("Controls help"), 2_000); - await session.press("escape"); - await waitForSnapshot(session, (text) => !text.includes("Controls help"), 5_000); - return; } catch { // Dropped before the app was listening; the next press is the retry. + continue; } + + // Once the probe opens, closing it is part of the test contract. Surface a dropped Escape + // rather than sending another probe that could hide which transition failed. + await session.press("escape"); + await waitForSnapshot(session, (text) => !text.includes("Controls help"), 5_000); + return; } throw new Error("The app never reacted to a keypress."); diff --git a/test/pty/layout.test.ts b/test/pty/layout.test.ts index 48e43c97d..f2f2dc85a 100644 --- a/test/pty/layout.test.ts +++ b/test/pty/layout.test.ts @@ -314,7 +314,11 @@ describe("PTY layout", () => { session.resize({ cols: 140, rows: 24 }); const tight = await harness.waitForSnapshot( session, - (text) => /▌.*▌/.test(text) && harness.countMatches(text, /alpha\.ts/g) === 1, + // Structural resize markers can appear before the row text finishes painting. + (text) => + /▌.*▌/.test(text) && + harness.countMatches(text, /alpha\.ts/g) === 1 && + text.includes("betaValue = 1"), 5_000, ); From c40da15c5d415594aefa0d43f11cebc5f87da07a Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 6 Aug 2026 11:29:54 -0400 Subject: [PATCH 2/2] test(workspace): await reload before teardown --- src/ui/AppHost.workspace.test.tsx | 68 +++++++++++-------------------- 1 file changed, 23 insertions(+), 45 deletions(-) diff --git a/src/ui/AppHost.workspace.test.tsx b/src/ui/AppHost.workspace.test.tsx index 0a58e3001..d00eaaf2c 100644 --- a/src/ui/AppHost.workspace.test.tsx +++ b/src/ui/AppHost.workspace.test.tsx @@ -121,6 +121,12 @@ async function flushUntil( } } +/** Require both review chrome and row content so lazy Git source reads have completed. */ +function hasRenderedAlphaReview(setup: Awaited>) { + const frame = setup.captureCharFrame(); + return frame.includes("alpha.txt") && frame.includes("one"); +} + /** * Write the fixture whose `y` command probes the affordance and then asks for a * whole-document replacement, logging both answers. @@ -239,11 +245,7 @@ describe("extension workspace reads", () => { options: { mode: "stack", extensionPaths: [extPath] }, }); await withAppHost(bootstrap, async (setup) => { - await flushUntil( - setup, - () => setup.captureCharFrame().includes("alpha.txt"), - "the review to render", - ); + await flushUntil(setup, () => hasRenderedAlphaReview(setup), "the review to render"); await act(async () => { await setup.mockInput.typeText("y"); @@ -277,11 +279,7 @@ describe("extension workspace reads", () => { options: { mode: "stack", extensionPaths: [extPath] }, }); await withAppHost(bootstrap, async (setup) => { - await flushUntil( - setup, - () => setup.captureCharFrame().includes("alpha.txt"), - "the review to render", - ); + await flushUntil(setup, () => hasRenderedAlphaReview(setup), "the review to render"); await act(async () => { await setup.mockInput.typeText("y"); @@ -315,11 +313,7 @@ describe("extension workspace reads", () => { options: { mode: "stack", extensionPaths: [extPath] }, }); await withAppHost(bootstrap, async (setup) => { - await flushUntil( - setup, - () => setup.captureCharFrame().includes("alpha.txt"), - "the review to render", - ); + await flushUntil(setup, () => hasRenderedAlphaReview(setup), "the review to render"); await act(async () => { await setup.mockInput.typeText("y"); @@ -348,11 +342,7 @@ describe("extension workspace reads", () => { options: { mode: "stack", extensionPaths: [extPath] }, }); await withAppHost(bootstrap, async (setup) => { - await flushUntil( - setup, - () => setup.captureCharFrame().includes("alpha.txt"), - "the review to render", - ); + await flushUntil(setup, () => hasRenderedAlphaReview(setup), "the review to render"); await act(async () => { await setup.mockInput.typeText("y"); @@ -374,6 +364,14 @@ describe("extension workspace reads", () => { // The written text is the read text transformed, so the read reached the // whole document rather than the patch the review was built from. expect(readFileSync(join(repo, "alpha.txt"), "utf8")).toBe("ONE\nTWO\n"); + + // The successful write starts a review reload. Wait for it to finish before teardown so a + // Git child process cannot retain the temporary checkout on Windows. + await flushUntil( + setup, + () => setup.captureCharFrame().includes("ONE"), + "the reloaded review to show the transformed content", + ); }); }); }); @@ -393,11 +391,7 @@ describe("extension workspace writes", () => { options: { mode: "stack", extensionPaths: [extPath] }, }); await withAppHost(bootstrap, async (setup) => { - await flushUntil( - setup, - () => setup.captureCharFrame().includes("alpha.txt"), - "the review to render", - ); + await flushUntil(setup, () => hasRenderedAlphaReview(setup), "the review to render"); await act(async () => { await setup.mockInput.typeText("y"); @@ -451,11 +445,7 @@ describe("extension workspace writes", () => { options: { mode: "stack", extensionPaths: [extPath] }, }); await withAppHost(bootstrap, async (setup) => { - await flushUntil( - setup, - () => setup.captureCharFrame().includes("alpha.txt"), - "the review to render", - ); + await flushUntil(setup, () => hasRenderedAlphaReview(setup), "the review to render"); await act(async () => { await setup.mockInput.typeText("y"); }); @@ -499,11 +489,7 @@ describe("extension workspace writes", () => { options: { mode: "stack", extensionPaths: [extPath] }, }); await withAppHost(bootstrap, async (setup) => { - await flushUntil( - setup, - () => setup.captureCharFrame().includes("alpha.txt"), - "the review to render", - ); + await flushUntil(setup, () => hasRenderedAlphaReview(setup), "the review to render"); await act(async () => { await setup.mockInput.typeText("y"); }); @@ -543,11 +529,7 @@ describe("extension workspace writes", () => { options: { mode: "stack", extensionPaths: [extPath] }, }); await withAppHost(bootstrap, async (setup) => { - await flushUntil( - setup, - () => setup.captureCharFrame().includes("alpha.txt"), - "the review to render", - ); + await flushUntil(setup, () => hasRenderedAlphaReview(setup), "the review to render"); await act(async () => { await setup.mockInput.typeText("y"); @@ -628,11 +610,7 @@ describe("extension workspace writes", () => { options: { mode: "stack", extensionPaths: [extPath] }, }); await withAppHost(bootstrap, async (setup) => { - await flushUntil( - setup, - () => setup.captureCharFrame().includes("alpha.txt"), - "the review to render", - ); + await flushUntil(setup, () => hasRenderedAlphaReview(setup), "the review to render"); await act(async () => { await setup.mockInput.typeText("y");