From af34092ecb959c101ffc5430e2cc8d11c8679c7a Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 10 Sep 2026 14:48:46 -0400 Subject: [PATCH 01/15] Synchronize 84 PTY key transitions directly on unique destination snapshots Result: {"status":"keep","pty_suite_ms":206306.11,"pty_reported_ms":206290,"pty_pass_count":174,"pty_skip_count":1,"slowest_test_ms":8707.58,"tests_over_3000ms":13} --- .changeset/faster-pty-transitions.md | 2 + test/pty/chrome.test.ts | 24 +++++---- test/pty/cursor-line.test.ts | 60 +++++++++++++++------ test/pty/extensions-integration.test.ts | 72 ++++++++++++++----------- test/pty/file-views-integration.test.ts | 7 ++- test/pty/filter-escape.test.ts | 8 +-- test/pty/harness.test.ts | 17 ++++-- test/pty/harness.ts | 9 +++- test/pty/key-routing.test.ts | 28 +++++----- test/pty/layout.test.ts | 40 +++++++------- test/pty/log-integration.test.ts | 20 ++++--- test/pty/nav.test.ts | 36 +++++++------ test/pty/notes.test.ts | 64 ++++++++++++---------- test/pty/pager.test.ts | 40 +++++++------- test/pty/scroll.test.ts | 4 +- 15 files changed, 255 insertions(+), 176 deletions(-) create mode 100644 .changeset/faster-pty-transitions.md diff --git a/.changeset/faster-pty-transitions.md b/.changeset/faster-pty-transitions.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/faster-pty-transitions.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/test/pty/chrome.test.ts b/test/pty/chrome.test.ts index dca3dfc05..df72ac088 100644 --- a/test/pty/chrome.test.ts +++ b/test/pty/chrome.test.ts @@ -138,8 +138,12 @@ describe("PTY chrome", () => { await session.waitForText(/Theme selector/, { timeout: 5_000 }); await session.press("down"); await session.waitForText(/›\s+github-dark-dimmed/, { timeout: 5_000 }); - await session.press("enter"); - await harness.waitForSnapshot(session, (text) => !text.includes("Theme selector"), 5_000); + await harness.pressAndWaitForSnapshot( + session, + "enter", + (text) => !text.includes("Theme selector"), + 5_000, + ); await session.press("q"); const prompt = await session.waitForText(/Save view preferences\?/, { timeout: 5_000 }); @@ -266,9 +270,9 @@ describe("PTY chrome", () => { timeout: 15_000, }); - await session.press("?"); - const help = await harness.waitForSnapshot( + const help = await harness.pressAndWaitForSnapshot( session, + "?", (text) => (text.includes("Keyboard help") || text.includes("Controls help")) && text.includes("move through lines and notes"), @@ -339,27 +343,27 @@ describe("PTY chrome", () => { expect(initial).toMatch(/▌.*▌/); - await session.press("f10"); - const fileMenu = await harness.waitForSnapshot( + const fileMenu = await harness.pressAndWaitForSnapshot( session, + "f10", (text) => text.includes("Toggle files/filter focus") && text.includes("Quit"), 5_000, ); expect(fileMenu).toContain("Reload"); - await session.press("right"); - const viewMenu = await harness.waitForSnapshot( + const viewMenu = await harness.pressAndWaitForSnapshot( session, + "right", (text) => text.includes("Split view") && text.includes("Unified view"), 5_000, ); expect(viewMenu).toContain("Auto layout"); - await session.press("enter"); - const unified = await harness.waitForSnapshot( + const unified = await harness.pressAndWaitForSnapshot( session, + "enter", (text) => !/▌.*▌/.test(text) && text.includes("1 - export const alpha = 1;"), 5_000, ); diff --git a/test/pty/cursor-line.test.ts b/test/pty/cursor-line.test.ts index 38d2bae30..8d0291a72 100644 --- a/test/pty/cursor-line.test.ts +++ b/test/pty/cursor-line.test.ts @@ -173,8 +173,12 @@ describe("PTY current line", () => { expect(splitLines[lensIndex + 1]).toContain("export const message = 'short';"); expect(splitLines[lensIndex + 2]).toContain("this is a very long wrapped line"); - await session.press("1"); - await harness.waitForSnapshot(session, (text) => !text.includes("Current line"), 5_000); + await harness.pressAndWaitForSnapshot( + session, + "1", + (text) => !text.includes("Current line"), + 5_000, + ); await session.press("2"); await session.waitForText(/Current line · old above, new below/, { timeout: 5_000 }); @@ -267,8 +271,12 @@ describe("PTY current line", () => { try { await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); - await session.press("z"); - await harness.waitForSnapshot(session, (text) => text.includes("hiddenLine01"), 5_000); + await harness.pressAndWaitForSnapshot( + session, + "z", + (text) => text.includes("hiddenLine01"), + 5_000, + ); // The revealed rows reach navigation one commit after they reach the screen. await session.waitIdle({ timeout: 500 }); @@ -298,22 +306,38 @@ describe("PTY current line", () => { const beforeExpand = await session.waitForText(/Draft note/, { timeout: 5_000 }); const startRow = /Draft note[^R]*R(\d+)/.exec(beforeExpand)?.[1]; expect(startRow).toBeDefined(); - await session.press("escape"); - await harness.waitForSnapshot(session, (text) => !text.includes("Draft note"), 5_000); - - await session.press("z"); - await harness.waitForSnapshot(session, (text) => text.includes("hiddenLine01"), 5_000); + await harness.pressAndWaitForSnapshot( + session, + "escape", + (text) => !text.includes("Draft note"), + 5_000, + ); + + await harness.pressAndWaitForSnapshot( + session, + "z", + (text) => text.includes("hiddenLine01"), + 5_000, + ); await session.waitIdle({ timeout: 500 }); await session.press("c"); const expanded = await session.waitForText(/Draft note/, { timeout: 5_000 }); expect(expanded).toContain("R1 "); expect(lineIndexOf(expanded, "Draft note")).toBe(lineIndexOf(expanded, "hiddenLine01") + 1); - await session.press("escape"); - await harness.waitForSnapshot(session, (text) => !text.includes("Draft note"), 5_000); - - await session.press("z"); - await harness.waitForSnapshot(session, (text) => !text.includes("hiddenLine01"), 5_000); + await harness.pressAndWaitForSnapshot( + session, + "escape", + (text) => !text.includes("Draft note"), + 5_000, + ); + + await harness.pressAndWaitForSnapshot( + session, + "z", + (text) => !text.includes("hiddenLine01"), + 5_000, + ); await session.waitIdle({ timeout: 500 }); await session.press("c"); const collapsed = await session.waitForText(/Draft note/, { timeout: 5_000 }); @@ -392,8 +416,12 @@ describe("PTY current line", () => { const draftRowAtTop = lineIndexOf(draftAtTop, "Draft note"); expect(draftRowAtTop).toBeGreaterThan(0); - await session.press("escape"); - await harness.waitForSnapshot(session, (text) => !text.includes("Draft note"), 5_000); + await harness.pressAndWaitForSnapshot( + session, + "escape", + (text) => !text.includes("Draft note"), + 5_000, + ); for (let step = 0; step < 4; step += 1) { await session.press("j"); diff --git a/test/pty/extensions-integration.test.ts b/test/pty/extensions-integration.test.ts index 8f08ff7b5..a0fa568d0 100644 --- a/test/pty/extensions-integration.test.ts +++ b/test/pty/extensions-integration.test.ts @@ -728,9 +728,9 @@ describe("PTY extensions", () => { ); await harness.ensureKeyboardIsLive(session); - await session.press("s"); - const closed = await harness.waitForSnapshot( + const closed = await harness.pressAndWaitForSnapshot( session, + "s", (text) => !text.includes("FILES SLOT RIGHT") && text.includes("AUX PANE LEFT"), 20_000, ); @@ -871,16 +871,16 @@ describe("PTY extensions", () => { await session.type("\x13"); await session.waitForText(/Your note/, { timeout: 5_000 }); - await session.press("]"); - await harness.waitForSnapshot( + await harness.pressAndWaitForSnapshot( session, + "]", (text) => text.includes("second.ts") && !text.includes("Navigate to this exact note."), 5_000, ); - await session.press("f8"); - const picker = await harness.waitForSnapshot( + const picker = await harness.pressAndWaitForSnapshot( session, + "f8", (text) => text.includes("Navigate saved review note") && text.includes("[active]") && @@ -890,9 +890,9 @@ describe("PTY extensions", () => { expect(picker).toContain("first.ts"); expect(picker).toMatch(/\((?:old|new)\)/); - await session.press("enter"); - const revealed = await harness.waitForSnapshot( + const revealed = await harness.pressAndWaitForSnapshot( session, + "enter", (text) => !text.includes("Navigate saved review note") && text.includes("first.ts") && @@ -1039,8 +1039,12 @@ describe("PTY extensions", () => { await session.press("c"); const initialDraft = await session.waitForText(/Draft note/, { timeout: 20_000 }); const initialDraftRow = lineIndexOf(initialDraft, "Draft note"); - await session.press("escape"); - await harness.waitForSnapshot(session, (text) => !text.includes("Draft note"), 20_000); + await harness.pressAndWaitForSnapshot( + session, + "escape", + (text) => !text.includes("Draft note"), + 20_000, + ); await session.press("1"); await session.press("0"); @@ -1048,8 +1052,12 @@ describe("PTY extensions", () => { await session.press("c"); const countedDraft = await session.waitForText(/Draft note/, { timeout: 20_000 }); expect(lineIndexOf(countedDraft, "Draft note")).toBeGreaterThan(initialDraftRow); - await session.press("escape"); - await harness.waitForSnapshot(session, (text) => !text.includes("Draft note"), 20_000); + await harness.pressAndWaitForSnapshot( + session, + "escape", + (text) => !text.includes("Draft note"), + 20_000, + ); await session.press("z"); await session.press("t"); @@ -1070,9 +1078,9 @@ describe("PTY extensions", () => { await session.waitForText(/: j-owned/, { timeout: 20_000 }); await session.press("escape"); await session.waitForText(/ext vim-navigation : top or bottom/, { timeout: 20_000 }); - await session.press("escape"); - await harness.waitForSnapshot( + await harness.pressAndWaitForSnapshot( session, + "escape", (text) => !text.includes("top or bottom") && /Vim navigation.*Esc exits/.test(text), 20_000, ); @@ -1080,9 +1088,9 @@ describe("PTY extensions", () => { await session.press(":"); await session.waitForText(/ext vim-navigation : top or bottom/, { timeout: 20_000 }); await session.type("bottom"); - await session.press("enter"); - const commandBottom = await harness.waitForSnapshot( + const commandBottom = await harness.pressAndWaitForSnapshot( session, + "enter", (text) => text.includes("second.ts") && !text.includes("first.ts"), 20_000, ); @@ -1091,9 +1099,9 @@ describe("PTY extensions", () => { await session.press(":"); await session.waitForText(/ext vim-navigation : top or bottom/, { timeout: 20_000 }); await session.type("top"); - await session.press("enter"); - const commandTop = await harness.waitForSnapshot( + const commandTop = await harness.pressAndWaitForSnapshot( session, + "enter", (text) => text.includes("first.ts") && text.includes("export const line01 = 1;") && @@ -1102,33 +1110,33 @@ describe("PTY extensions", () => { ); expect(commandTop).toContain("first.ts"); - await session.press(["ctrl", "d"]); - const controlDown = await harness.waitForSnapshot( + const controlDown = await harness.pressAndWaitForSnapshot( session, + ["ctrl", "d"], (text) => text.includes("first.ts") && !text.includes("export const line01 = 1;"), 20_000, ); expect(controlDown).toContain("first.ts"); - await session.press(["ctrl", "u"]); - await harness.waitForSnapshot( + await harness.pressAndWaitForSnapshot( session, + ["ctrl", "u"], (text) => text.includes("export const line01 = 1;"), 20_000, ); // Both normal-mode absolute forms visibly move between the two long files. - await session.press(["shift", "g"]); - const bottom = await harness.waitForSnapshot( + const bottom = await harness.pressAndWaitForSnapshot( session, + ["shift", "g"], (text) => text.includes("second.ts") && !text.includes("first.ts"), 20_000, ); expect(bottom).toContain("second.ts"); await session.press("g"); - await session.press("g"); - const top = await harness.waitForSnapshot( + const top = await harness.pressAndWaitForSnapshot( session, + "g", (text) => text.includes("first.ts") && !text.includes("second.ts"), 20_000, ); @@ -1138,9 +1146,9 @@ describe("PTY extensions", () => { }); expect(active).toContain("Vim navigation"); - await session.press("escape"); - await harness.waitForSnapshot( + await harness.pressAndWaitForSnapshot( session, + "escape", (text) => !/Vim navigation.*Esc exits/.test(text), 20_000, ); @@ -1225,9 +1233,9 @@ describe("PTY extensions", () => { expect(review).not.toContain(REVEAL_LINE_TOKEN); await harness.ensureKeyboardIsLive(session); - await session.press("f7"); - const revealed = await harness.waitForSnapshot( + const revealed = await harness.pressAndWaitForSnapshot( session, + "f7", (text) => text.includes(REVEAL_LINE_TOKEN), 20_000, ); @@ -1277,9 +1285,9 @@ describe("PTY extensions", () => { expect(review).not.toContain(REVEAL_LINE_TOKEN); await harness.ensureKeyboardIsLive(session); - await session.press("f7"); - const revealed = await harness.waitForSnapshot( + const revealed = await harness.pressAndWaitForSnapshot( session, + "f7", (text) => text.includes(REVEAL_LINE_TOKEN), 20_000, ); diff --git a/test/pty/file-views-integration.test.ts b/test/pty/file-views-integration.test.ts index 21f5b78be..74581e517 100644 --- a/test/pty/file-views-integration.test.ts +++ b/test/pty/file-views-integration.test.ts @@ -169,8 +169,11 @@ describe("PTY file views", () => { }); expect(toggled).not.toContain("# Heading"); - await session.press("escape"); - await harness.waitForSnapshot(session, (text) => !text.includes("File presentation:")); + await harness.pressAndWaitForSnapshot( + session, + "escape", + (text) => !text.includes("File presentation:"), + ); await session.press("]"); await session.waitIdle(); } finally { diff --git a/test/pty/filter-escape.test.ts b/test/pty/filter-escape.test.ts index b1575aefe..5fb031d1a 100644 --- a/test/pty/filter-escape.test.ts +++ b/test/pty/filter-escape.test.ts @@ -34,9 +34,9 @@ describe("filter escape clearing (PTY)", () => { await harness.waitForSnapshot(session, (t) => t.includes("No files match"), 5_000); // First Escape clears the text (keeps the input focused / placeholder shown). - await session.press("escape"); - await harness.waitForSnapshot( + await harness.pressAndWaitForSnapshot( session, + "escape", (t) => t.includes("filter: type to filter files"), 5_000, ); @@ -46,9 +46,9 @@ describe("filter escape clearing (PTY)", () => { await harness.waitForSnapshot(session, (t) => t.includes("No files match"), 5_000); // Second Escape must clear again, just like the first. - await session.press("escape"); - const cleared = await harness.waitForSnapshot( + const cleared = await harness.pressAndWaitForSnapshot( session, + "escape", (t) => t.includes("filter: type to filter files") && t.includes("alphaOnly = true"), 5_000, ); diff --git a/test/pty/harness.test.ts b/test/pty/harness.test.ts index c36339c7f..5b68d55d7 100644 --- a/test/pty/harness.test.ts +++ b/test/pty/harness.test.ts @@ -2,12 +2,12 @@ import { describe, expect, test } from "bun:test"; import type { Session } from "tuistory"; import { createPtyHarness } from "./harness"; -/** Simulate a key whose output-idle wait returns before its destination is painted. */ +/** Simulate a key whose first output-idle cycle ends before its destination is painted. */ function createTestTransitionSession(screens: string[]) { - const inputs: Parameters[0][] = []; + const inputs: Parameters[0][] = []; let frame = 0; - const session: Pick = { - async press(key) { + const session: Pick = { + sendKey(key) { inputs.push(key); }, async text() { @@ -47,4 +47,13 @@ describe("PTY transition synchronization", () => { ).rejects.toThrow("Last snapshot:\nDraft note — body"); expect(inputs).toEqual([["ctrl", "s"]]); }); + + test("rejects a predicate that cannot distinguish the destination", async () => { + const { session, inputs } = createTestTransitionSession(["Shared content"]); + const harness = createPtyHarness(); + await expect( + harness.pressAndWaitForSnapshot(session, "q", (text) => text.includes("Shared content")), + ).rejects.toThrow("destination was visible before the keypress"); + expect(inputs).toEqual([]); + }); }); diff --git a/test/pty/harness.ts b/test/pty/harness.ts index 2c5f8e6a1..eabedf70c 100644 --- a/test/pty/harness.ts +++ b/test/pty/harness.ts @@ -1132,12 +1132,17 @@ end * Never resend input on timeout; a dropped key must remain a test failure. */ async function pressAndWaitForSnapshot( - session: Pick, + session: Pick, key: Key | Key[], predicate: (text: string) => boolean, timeoutMs = 5_000, ) { - await session.press(key); + const before = await session.text({ immediate: true }); + if (predicate(before)) { + throw new Error("pressAndWaitForSnapshot: destination was visible before the keypress."); + } + + session.sendKey(key); return waitForSnapshot(session, predicate, timeoutMs); } diff --git a/test/pty/key-routing.test.ts b/test/pty/key-routing.test.ts index c573557ff..dd33de424 100644 --- a/test/pty/key-routing.test.ts +++ b/test/pty/key-routing.test.ts @@ -77,9 +77,9 @@ describe("PTY key routing", () => { await session.press("?"); await session.waitForText(/Controls help/, { timeout: 5_000 }); - await session.press("tab"); - await harness.waitForSnapshot( + await harness.pressAndWaitForSnapshot( session, + "tab", (text) => text.includes("filter: type to filter files"), 5_000, ); @@ -88,9 +88,9 @@ describe("PTY key routing", () => { // One Escape must close the overlay and do nothing else. The overlay // handler owns the key; the filter input must never see it. - await session.press("escape"); - const afterEscape = await harness.waitForSnapshot( + const afterEscape = await harness.pressAndWaitForSnapshot( session, + "escape", (text) => !text.includes("Controls help"), 5_000, ); @@ -118,9 +118,9 @@ describe("PTY key routing", () => { expect(initial).toMatch(/▌.*▌/); // Focus the filter and narrow to one file so the filter is visibly live. - await session.press("tab"); - await harness.waitForSnapshot( + await harness.pressAndWaitForSnapshot( session, + "tab", (text) => text.includes("filter: type to filter files"), 5_000, ); @@ -134,9 +134,9 @@ describe("PTY key routing", () => { // Open the menu bar and pick "Unified view" from the View menu with Enter. await session.press("f10"); await session.waitForText(/Reload/, { timeout: 5_000 }); - await session.press("right"); - await harness.waitForSnapshot( + await harness.pressAndWaitForSnapshot( session, + "right", (text) => text.includes("Split view") && text.includes("Unified view"), 5_000, ); @@ -203,9 +203,9 @@ describe("PTY key routing", () => { // Pager focuses the review scroll box, so every key the global chain // leaves unconsumed also reaches the scroll box's own arrow scrolling. - await session.press("f10"); - const menuOpen = await harness.waitForSnapshot( + const menuOpen = await harness.pressAndWaitForSnapshot( session, + "f10", (text) => text.includes("Quit"), 5_000, ); @@ -239,9 +239,9 @@ describe("PTY key routing", () => { await session.waitForText(/scroll\.ts/, { timeout: 15_000 }); await session.waitIdle({ timeout: 300 }); - await session.press("t"); - const selectorOpen = await harness.waitForSnapshot( + const selectorOpen = await harness.pressAndWaitForSnapshot( session, + "t", (text) => text.includes("Theme selector"), 5_000, ); @@ -283,9 +283,9 @@ describe("PTY key routing", () => { // An open menu is deliberately not fully modal: keys the menu does not // use keep falling through to the command table. - await session.press("?"); - const help = await harness.waitForSnapshot( + const help = await harness.pressAndWaitForSnapshot( session, + "?", (text) => text.includes("Controls help"), 5_000, ); diff --git a/test/pty/layout.test.ts b/test/pty/layout.test.ts index b1359a2ed..75c3b9232 100644 --- a/test/pty/layout.test.ts +++ b/test/pty/layout.test.ts @@ -289,18 +289,18 @@ describe("PTY layout", () => { expect(initial).toContain("this is a very long"); expect(initial).not.toContain("ge';"); - await session.press("w"); - const wrapped = await harness.waitForSnapshot( + const wrapped = await harness.pressAndWaitForSnapshot( session, + "w", (text) => text.includes("ge';"), 5_000, ); expect(wrapped).toContain("ge';"); - await session.press("w"); - const unwrapped = await harness.waitForSnapshot( + const unwrapped = await harness.pressAndWaitForSnapshot( session, + "w", (text) => !text.includes("ge';"), 5_000, ); @@ -327,18 +327,18 @@ describe("PTY layout", () => { expect(initial).toContain("▾ 1 unchanged line"); expect(initial).not.toContain("hiddenLine01"); - await session.press("z"); - const expanded = await harness.waitForSnapshot( + const expanded = await harness.pressAndWaitForSnapshot( session, + "z", (text) => text.includes("Hide 1 unchanged line") && text.includes("hiddenLine01"), 5_000, ); expect(expanded).toContain("hiddenLine01"); - await session.press("z"); - const collapsed = await harness.waitForSnapshot( + const collapsed = await harness.pressAndWaitForSnapshot( session, + "z", (text) => text.includes("▾ 1 unchanged line") && !text.includes("hiddenLine01"), 5_000, ); @@ -690,18 +690,18 @@ describe("PTY layout", () => { expect(initial).not.toMatch(/▌.*▌/); expect(initial).toContain("1 - export const alpha = 1;"); - await session.press("2"); - const split = await harness.waitForSnapshot( + const split = await harness.pressAndWaitForSnapshot( session, + "2", (text) => /▌.*▌/.test(text) && harness.countMatches(text, /alpha\.ts/g) >= 2, 5_000, ); expect(split).toMatch(/▌.*▌/); - await session.press("1"); - const unified = await harness.waitForSnapshot( + const unified = await harness.pressAndWaitForSnapshot( session, + "1", (text) => !/▌.*▌/.test(text) && text.includes("1 - export const alpha = 1;"), 5_000, ); @@ -709,9 +709,9 @@ describe("PTY layout", () => { expect(unified).not.toMatch(/▌.*▌/); expect(unified).toContain("1 - export const alpha = 1;"); - await session.press("0"); - const auto = await harness.waitForSnapshot( + const auto = await harness.pressAndWaitForSnapshot( session, + "0", (text) => /▌.*▌/.test(text) && harness.countMatches(text, /alpha\.ts/g) >= 2, 5_000, ); @@ -754,18 +754,18 @@ describe("PTY layout", () => { expect(anchored).not.toContain("line01 = 101"); expect(anchoredLineNumber).toBeDefined(); - await session.press("1"); - const unified = await harness.waitForSnapshot( + const unified = await harness.pressAndWaitForSnapshot( session, + "1", (text) => !/▌.*▌/.test(text) && text.includes(`line${anchoredLineNumber} =`), 5_000, ); expect(unified).toContain(`line${anchoredLineNumber} =`); - await session.press("2"); - const split = await harness.waitForSnapshot( + const split = await harness.pressAndWaitForSnapshot( session, + "2", (text) => /▌.*▌/.test(text) && text.includes(`line${anchoredLineNumber} =`), 5_000, ); @@ -896,9 +896,9 @@ describe("PTY layout", () => { expect(wrapped).toContain("wrapped line"); expect(wrapped).toContain("ge';"); - await session.press("w"); - const reset = await harness.waitForSnapshot( + const reset = await harness.pressAndWaitForSnapshot( session, + "w", (text) => text.includes("this is a very long") && !text.includes("ge';"), 5_000, ); diff --git a/test/pty/log-integration.test.ts b/test/pty/log-integration.test.ts index cc9de55e3..303a03a4e 100644 --- a/test/pty/log-integration.test.ts +++ b/test/pty/log-integration.test.ts @@ -210,8 +210,12 @@ describe("interactive hunk log", () => { await session.waitForText(/2 commits selected/, { timeout: 15_000 }); // Escape collapses visual mode before direct Shift selection starts another range. - await session.press("escape"); - await harness.waitForSnapshot(session, (text) => !text.includes("commits selected"), 5_000); + await harness.pressAndWaitForSnapshot( + session, + "escape", + (text) => !text.includes("commits selected"), + 5_000, + ); session.writeRaw("k"); // SGR mouse modifier bit 4 forwards Shift+click through capable terminals. @@ -271,9 +275,9 @@ describe("interactive hunk log", () => { expect(session.getRawOutput().slice(transitionOutputStart)).not.toContain("\x1b[?1049l"); const returnOutputStart = session.getRawOutput().length; - await session.press("q"); - const returned = await harness.waitForSnapshot( + const returned = await harness.pressAndWaitForSnapshot( session, + "q", (text) => text.includes("Second history commit") && text.includes("Enter open"), 15_000, ); @@ -480,9 +484,9 @@ describe("interactive hunk log", () => { 15_000, ); - await session.press("enter"); - const retained = await harness.waitForSnapshot( + const retained = await harness.pressAndWaitForSnapshot( session, + "enter", (text) => !/▌.*▌/.test(text) && text.includes("historyValue = 'second'"), 15_000, ); @@ -598,9 +602,9 @@ describe("interactive hunk log", () => { await session.press("f10"); await session.press("right"); await session.press("down"); - await session.press("enter"); - const graph = await harness.waitForSnapshot( + const graph = await harness.pressAndWaitForSnapshot( session, + "enter", (text) => text.includes("*") && !text.includes("Commits on Sep 6, 2026"), 5_000, ); diff --git a/test/pty/nav.test.ts b/test/pty/nav.test.ts index 250af9ee2..610d8eb86 100644 --- a/test/pty/nav.test.ts +++ b/test/pty/nav.test.ts @@ -26,21 +26,25 @@ describe("PTY navigation", () => { }); expect(initial).not.toContain("Maximum update depth exceeded"); - await session.press("}"); - const alphaNote = await harness.waitForSnapshot( + const alphaNote = await harness.pressAndWaitForSnapshot( session, + "}", (text) => text.includes("Alpha note for navigation."), 5_000, ); expect(alphaNote).toContain("Alpha note for navigation."); expect(alphaNote).not.toContain("Maximum update depth exceeded"); - await session.press("."); - await harness.waitForSnapshot(session, (text) => text.includes("line101 = 10100"), 5_000); + await harness.pressAndWaitForSnapshot( + session, + ".", + (text) => text.includes("line101 = 10100"), + 5_000, + ); - await session.press("}"); - const gammaNote = await harness.waitForSnapshot( + const gammaNote = await harness.pressAndWaitForSnapshot( session, + "}", (text) => text.includes("Gamma note for navigation."), 5_000, ); @@ -69,9 +73,9 @@ describe("PTY navigation", () => { expect(initial).toContain("line1 = 100"); expect(initial).not.toContain("line60 = 6000"); - await session.press("]"); - const secondHunk = await harness.waitForSnapshot( + const secondHunk = await harness.pressAndWaitForSnapshot( session, + "]", (text) => text.includes("line60 = 6000"), 5_000, ); @@ -117,9 +121,9 @@ describe("PTY navigation", () => { await session.press("["); await session.waitIdle({ timeout: 80 }); - await session.press("["); - const backward = await harness.waitForSnapshot( + const backward = await harness.pressAndWaitForSnapshot( session, + "[", (text) => text.includes("line 341 changed") || text.includes("line 002 changed"), 5_000, ); @@ -147,9 +151,9 @@ describe("PTY navigation", () => { expect(initial).toContain("line1 = 100"); expect(initial).not.toContain("line60 = 6000"); - await session.press("]"); - const secondHunk = await harness.waitForSnapshot( + const secondHunk = await harness.pressAndWaitForSnapshot( session, + "]", (text) => text.includes("line60 = 6000") && !text.includes("line1 = 100"), 5_000, ); @@ -157,9 +161,9 @@ describe("PTY navigation", () => { expect(secondHunk).toContain("line60 = 6000"); expect(secondHunk).not.toContain("line1 = 100"); - await session.press("["); - const firstHunk = await harness.waitForSnapshot( + const firstHunk = await harness.pressAndWaitForSnapshot( session, + "[", (text) => text.includes("line1 = 100") && !text.includes("line60 = 6000"), 5_000, ); @@ -199,9 +203,9 @@ describe("PTY navigation", () => { expect(collapsed).toContain("› src/"); expect(collapsed).toContain("2 files"); - await session.press("."); - const expanded = await harness.waitForSnapshot( + const expanded = await harness.pressAndWaitForSnapshot( session, + ".", (text) => text.includes("⌄ src/") && harness.countMatches(text, /alpha\.ts/g) === initialAlphaCount && diff --git a/test/pty/notes.test.ts b/test/pty/notes.test.ts index 0ab98199a..d7e2664c1 100644 --- a/test/pty/notes.test.ts +++ b/test/pty/notes.test.ts @@ -52,9 +52,9 @@ describe("PTY notes", () => { expect(withNotes).toContain("Highlights the follow-up addition for review."); expect(withNotes).not.toContain("STML ACTIVE"); - await session.press("a"); - const withoutNotes = await harness.waitForSnapshot( + const withoutNotes = await harness.pressAndWaitForSnapshot( session, + "a", (text) => !text.includes("Adds bonus export."), 5_000, ); @@ -158,8 +158,12 @@ describe("PTY notes", () => { expect(lineIndexOf(pushedDraft, "Draft note")).toBe(firstActiveRow + 1); expect(lineIndexOf(pushedDraft, followingLine)).toBeGreaterThan(followingRowBefore); - await session.press("escape"); - await harness.waitForSnapshot(session, (text) => !text.includes("Draft note"), 5_000); + await harness.pressAndWaitForSnapshot( + session, + "escape", + (text) => !text.includes("Draft note"), + 5_000, + ); for (let index = 0; index < 8; index += 1) { await session.press("down"); } @@ -320,9 +324,9 @@ describe("PTY notes", () => { expect(savedActive).toContain("D delete"); await session.press("k"); - await session.press("k"); - const firstActive = await harness.waitForSnapshot( + const firstActive = await harness.pressAndWaitForSnapshot( session, + "k", (text) => { const markerRow = lineIndexOf(text, "● Your note"); const firstBodyRow = lineIndexOf(text, "First keyboard note."); @@ -335,9 +339,9 @@ describe("PTY notes", () => { expect(firstActive).toContain("D delete"); await session.press("down"); - await session.press("down"); - const secondActive = await harness.waitForSnapshot( + const secondActive = await harness.pressAndWaitForSnapshot( session, + "down", (text) => { const markerRow = lineIndexOf(text, "● Your note"); const secondBodyRow = lineIndexOf(text, "Second keyboard note."); @@ -348,9 +352,9 @@ describe("PTY notes", () => { expect(secondActive).toContain("Second keyboard note."); await session.press("k"); - await session.press("k"); - await harness.waitForSnapshot( + await harness.pressAndWaitForSnapshot( session, + "k", (text) => { const markerRow = lineIndexOf(text, "● Your note"); const firstBodyRow = lineIndexOf(text, "First keyboard note."); @@ -419,9 +423,9 @@ describe("PTY notes", () => { lineIndexOf(text, "First note-only stop.") - lineIndexOf(text, "● Your note") === 2, 5_000, ); - await session.press("n"); - await harness.waitForSnapshot( + await harness.pressAndWaitForSnapshot( session, + "n", (text) => lineIndexOf(text, "Second note-only stop.") - lineIndexOf(text, "● Your note") === 2, 5_000, @@ -514,9 +518,9 @@ describe("PTY notes", () => { const firstReply = await session.waitForText(/First reply\./, { timeout: 5_000 }); expect(firstReply).toMatch(/╰─╭─ ● Your note/); - await session.press("k"); - await harness.waitForSnapshot( + await harness.pressAndWaitForSnapshot( session, + "k", (text) => { const markerRow = lineIndexOf(text, "● Your note"); return ( @@ -525,9 +529,9 @@ describe("PTY notes", () => { }, 5_000, ); - await session.press("j"); - await harness.waitForSnapshot( + await harness.pressAndWaitForSnapshot( session, + "j", (text) => { const markerRow = lineIndexOf(text, "● Your note"); return markerRow >= 0 && lineIndexOf(text, "First reply.") - markerRow === 2; @@ -752,9 +756,9 @@ describe("PTY notes", () => { await moveMouse(session, 9, 5); await session.waitForText(/\[\+\]/, { timeout: 5_000 }); - await session.press("down"); - const afterKeyboard = await harness.waitForSnapshot( + const afterKeyboard = await harness.pressAndWaitForSnapshot( session, + "down", (text) => !text.includes("[+]"), 5_000, ); @@ -787,9 +791,9 @@ describe("PTY notes", () => { await session.press("c"); await session.waitForText(/Draft note/, { timeout: 5_000 }); - await session.press("escape"); - const cancelled = await harness.waitForSnapshot( + const cancelled = await harness.pressAndWaitForSnapshot( session, + "escape", (text) => !text.includes("Draft note"), 5_000, ); @@ -950,9 +954,9 @@ describe("PTY notes", () => { await session.press("c"); await session.waitForText(/Draft note/, { timeout: 5_000 }); await session.type("Keep focus here"); - await session.press("]"); - const whileFocused = await harness.waitForSnapshot( + const whileFocused = await harness.pressAndWaitForSnapshot( session, + "]", (text) => text.includes("Keep focus here]") && !text.includes("line60 = 6000"), 5_000, ); @@ -960,11 +964,15 @@ describe("PTY notes", () => { // Cancel from the focused editor so the tight viewport can keep the target line fixed even // when the form's action row is intentionally below the visible bounds. - await session.press("escape"); - await harness.waitForSnapshot(session, (text) => !text.includes("Draft note"), 5_000); - await session.press("]"); - const afterCancel = await harness.waitForSnapshot( + await harness.pressAndWaitForSnapshot( + session, + "escape", + (text) => !text.includes("Draft note"), + 5_000, + ); + const afterCancel = await harness.pressAndWaitForSnapshot( session, + "]", (text) => text.includes("line60 = 6000"), 5_000, ); @@ -1005,9 +1013,9 @@ describe("PTY notes", () => { await session.click(/Esc cancel/); await harness.waitForSnapshot(session, (text) => !text.includes("Draft note"), 5_000); - await session.press("s"); - const afterCancel = await harness.waitForSnapshot( + const afterCancel = await harness.pressAndWaitForSnapshot( session, + "s", (text) => sidebarRow.test(text), 5_000, ); diff --git a/test/pty/pager.test.ts b/test/pty/pager.test.ts index b9f59f16f..2040fb099 100644 --- a/test/pty/pager.test.ts +++ b/test/pty/pager.test.ts @@ -82,36 +82,36 @@ describe("PTY pager", () => { expect(initial).toContain("before_01"); expect(initial).not.toContain("before_12"); - await session.press(["ctrl", "d"]); - const halfPaged = await harness.waitForSnapshot( + const halfPaged = await harness.pressAndWaitForSnapshot( session, + ["ctrl", "d"], (text) => !text.includes("before_01"), 5_000, ); expect(halfPaged).not.toContain("before_01"); - await session.press(["ctrl", "u"]); - const halfPageRestored = await harness.waitForSnapshot( + const halfPageRestored = await harness.pressAndWaitForSnapshot( session, + ["ctrl", "u"], (text) => text.includes("before_01"), 5_000, ); expect(halfPageRestored).toContain("before_01"); - await session.press("space"); - const paged = await harness.waitForSnapshot( + const paged = await harness.pressAndWaitForSnapshot( session, + "space", (text) => text.includes("before_18"), 5_000, ); expect(paged).toContain("before_18"); - await session.press("b"); - const pageRestored = await harness.waitForSnapshot( + const pageRestored = await harness.pressAndWaitForSnapshot( session, + "b", (text) => text.includes("before_01") && !text.includes("before_18"), 5_000, ); @@ -119,18 +119,18 @@ describe("PTY pager", () => { expect(pageRestored).toContain("before_01"); expect(pageRestored).not.toContain("before_18"); - await session.press("end"); - const bottom = await harness.waitForSnapshot( + const bottom = await harness.pressAndWaitForSnapshot( session, + "end", (text) => text.includes("after_60"), 5_000, ); expect(bottom).toContain("after_60"); - await session.press("home"); - const top = await harness.waitForSnapshot( + const top = await harness.pressAndWaitForSnapshot( session, + "home", (text) => text.includes("before_01") && !text.includes("after_60"), 5_000, ); @@ -336,9 +336,9 @@ describe("PTY pager", () => { expect(nextFile).toContain("secondValue = 2;"); expect(nextFile).not.toContain("line01 = 1;"); - await session.press(","); - const previousFile = await harness.waitForSnapshot( + const previousFile = await harness.pressAndWaitForSnapshot( session, + ",", (text) => text.includes("line01 = 1;") && !text.includes("secondValue = 2;"), 5_000, ); @@ -368,17 +368,21 @@ describe("PTY pager", () => { expect(initial).toMatch(/▌.*▌/); await session.waitIdle({ timeout: 200 }); - await session.press("1"); - const unified = await harness.waitForSnapshot( + const unified = await harness.pressAndWaitForSnapshot( session, + "1", (text) => !/▌.*▌/.test(text) && text.includes("line01 = 1;"), 5_000, ); expect(unified).not.toMatch(/▌.*▌/); - await session.press("2"); - const split = await harness.waitForSnapshot(session, (text) => /▌.*▌/.test(text), 5_000); + const split = await harness.pressAndWaitForSnapshot( + session, + "2", + (text) => /▌.*▌/.test(text), + 5_000, + ); expect(split).toMatch(/▌.*▌/); diff --git a/test/pty/scroll.test.ts b/test/pty/scroll.test.ts index 7d06810aa..6da024443 100644 --- a/test/pty/scroll.test.ts +++ b/test/pty/scroll.test.ts @@ -25,9 +25,9 @@ describe("PTY scrolling", () => { timeout: 15_000, }); - await session.press("]"); - const bottomAligned = await harness.waitForSnapshot( + const bottomAligned = await harness.pressAndWaitForSnapshot( session, + "]", (text) => text.includes("shortLine1 = 10;"), 5_000, ); From fa5824a82f0512b4c67fc747665b3415574e5339 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 10 Sep 2026 15:03:00 -0400 Subject: [PATCH 02/15] Synchronize 95 PTY key transitions directly on absent text destinations Result: {"status":"keep","pty_suite_ms":198799.95,"pty_reported_ms":198790,"pty_pass_count":176,"pty_skip_count":1,"slowest_test_ms":8630.48,"tests_over_3000ms":13} --- test/pty/chrome.test.ts | 16 ++--- test/pty/cursor-line.test.ts | 45 ++++++++------ test/pty/extensions-integration.test.ts | 76 ++++++++++++---------- test/pty/file-views-integration.test.ts | 74 ++++++++++------------ test/pty/harness.test.ts | 24 ++++++- test/pty/harness.ts | 21 +++++++ test/pty/key-routing.test.ts | 20 +++--- test/pty/lifecycle.test.ts | 3 +- test/pty/log-integration.test.ts | 76 +++++++++++----------- test/pty/notes.test.ts | 83 ++++++++++++------------- 10 files changed, 247 insertions(+), 191 deletions(-) diff --git a/test/pty/chrome.test.ts b/test/pty/chrome.test.ts index df72ac088..8bc2414e7 100644 --- a/test/pty/chrome.test.ts +++ b/test/pty/chrome.test.ts @@ -97,8 +97,7 @@ describe("PTY chrome", () => { try { await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); - await session.press("t"); - await session.waitForText(/Theme selector/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "t", /Theme selector/, { timeout: 5_000 }); // OS key repeat arrives as a rapid stream while React/OpenTUI drains each preview render. for (let index = 0; index < 100; index += 1) { @@ -134,10 +133,10 @@ describe("PTY chrome", () => { try { await session.waitForText(/line60/, { timeout: 15_000 }); - await session.press("t"); - await session.waitForText(/Theme selector/, { timeout: 5_000 }); - await session.press("down"); - await session.waitForText(/›\s+github-dark-dimmed/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "t", /Theme selector/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "down", /›\s+github-dark-dimmed/, { + timeout: 5_000, + }); await harness.pressAndWaitForSnapshot( session, "enter", @@ -145,8 +144,9 @@ describe("PTY chrome", () => { 5_000, ); - await session.press("q"); - const prompt = await session.waitForText(/Save view preferences\?/, { timeout: 5_000 }); + const prompt = await harness.pressAndWaitForText(session, "q", /Save view preferences\?/, { + timeout: 5_000, + }); expect(prompt).toContain('- theme = "github-dark-default"'); expect(prompt).toContain('+ theme = "github-dark-dimmed"'); expect(prompt).toContain("enter/s save"); diff --git a/test/pty/cursor-line.test.ts b/test/pty/cursor-line.test.ts index 8d0291a72..c1bf5a0fa 100644 --- a/test/pty/cursor-line.test.ts +++ b/test/pty/cursor-line.test.ts @@ -140,8 +140,9 @@ describe("PTY current line", () => { } session.writeRaw(`\x1b[<0;31;${endRow + 1}m`); - await session.press("y"); - await session.waitForText(/Copied selection to clipboard/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "y", /Copied selection to clipboard/, { + timeout: 5_000, + }); } finally { session.close(); } @@ -180,8 +181,9 @@ describe("PTY current line", () => { 5_000, ); - await session.press("2"); - await session.waitForText(/Current line · old above, new below/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "2", /Current line · old above, new below/, { + timeout: 5_000, + }); } finally { session.close(); } @@ -282,8 +284,9 @@ describe("PTY current line", () => { await session.press("k"); await session.waitIdle({ timeout: 200 }); - await session.press("c"); - const draft = await session.waitForText(/Draft note/, { timeout: 5_000 }); + const draft = await harness.pressAndWaitForText(session, "c", /Draft note/, { + timeout: 5_000, + }); expect(lineIndexOf(draft, "Draft note")).toBe(lineIndexOf(draft, "hiddenLine01") + 1); } finally { @@ -302,8 +305,9 @@ describe("PTY current line", () => { try { await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); await session.waitIdle({ timeout: 300 }); - await session.press("c"); - const beforeExpand = await session.waitForText(/Draft note/, { timeout: 5_000 }); + const beforeExpand = await harness.pressAndWaitForText(session, "c", /Draft note/, { + timeout: 5_000, + }); const startRow = /Draft note[^R]*R(\d+)/.exec(beforeExpand)?.[1]; expect(startRow).toBeDefined(); await harness.pressAndWaitForSnapshot( @@ -320,8 +324,9 @@ describe("PTY current line", () => { 5_000, ); await session.waitIdle({ timeout: 500 }); - await session.press("c"); - const expanded = await session.waitForText(/Draft note/, { timeout: 5_000 }); + const expanded = await harness.pressAndWaitForText(session, "c", /Draft note/, { + timeout: 5_000, + }); expect(expanded).toContain("R1 "); expect(lineIndexOf(expanded, "Draft note")).toBe(lineIndexOf(expanded, "hiddenLine01") + 1); @@ -339,8 +344,9 @@ describe("PTY current line", () => { 5_000, ); await session.waitIdle({ timeout: 500 }); - await session.press("c"); - const collapsed = await session.waitForText(/Draft note/, { timeout: 5_000 }); + const collapsed = await harness.pressAndWaitForText(session, "c", /Draft note/, { + timeout: 5_000, + }); expect(collapsed).toContain(`R${startRow} `); } finally { @@ -389,8 +395,9 @@ describe("PTY current line", () => { const anchor = paged[12]?.trim() ?? ""; expect(anchor.length).toBeGreaterThan(0); - await session.press("c"); - const draft = await session.waitForText(/Draft note/, { timeout: 5_000 }); + const draft = await harness.pressAndWaitForText(session, "c", /Draft note/, { + timeout: 5_000, + }); expect(draft).toContain(anchor); } finally { @@ -411,8 +418,9 @@ describe("PTY current line", () => { await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); await session.waitIdle({ timeout: 300 }); - await session.press("c"); - const draftAtTop = await session.waitForText(/Draft note/, { timeout: 5_000 }); + const draftAtTop = await harness.pressAndWaitForText(session, "c", /Draft note/, { + timeout: 5_000, + }); const draftRowAtTop = lineIndexOf(draftAtTop, "Draft note"); expect(draftRowAtTop).toBeGreaterThan(0); @@ -428,8 +436,9 @@ describe("PTY current line", () => { await session.waitIdle({ timeout: 200 }); } - await session.press("c"); - const draftAtCursor = await session.waitForText(/Draft note/, { timeout: 5_000 }); + const draftAtCursor = await harness.pressAndWaitForText(session, "c", /Draft note/, { + timeout: 5_000, + }); expect(lineIndexOf(draftAtCursor, "Draft note")).toBeGreaterThan(draftRowAtTop); } finally { diff --git a/test/pty/extensions-integration.test.ts b/test/pty/extensions-integration.test.ts index a0fa568d0..8d44c6c5f 100644 --- a/test/pty/extensions-integration.test.ts +++ b/test/pty/extensions-integration.test.ts @@ -490,8 +490,9 @@ describe("PTY extensions", () => { try { await session.waitForText(/Run this repository's extensions\?/, { timeout: 20_000 }); - await session.press("t"); - await session.waitForText(/INTERRUPT FIXTURE READY/, { timeout: 20_000 }); + await harness.pressAndWaitForText(session, "t", /INTERRUPT FIXTURE READY/, { + timeout: 20_000, + }); session.sendKey(["ctrl", "c"]); const deadline = Date.now() + 5_000; @@ -862,11 +863,11 @@ describe("PTY extensions", () => { try { await session.waitForText(/first\.ts/, { timeout: 20_000 }); await harness.ensureKeyboardIsLive(session); - await session.press("f8"); - await session.waitForText(/This review has no saved notes/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "f8", /This review has no saved notes/, { + timeout: 5_000, + }); - await session.press("c"); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "c", /Draft note/, { timeout: 5_000 }); await session.type("Navigate to this exact note."); await session.type("\x13"); await session.waitForText(/Your note/, { timeout: 5_000 }); @@ -920,17 +921,18 @@ describe("PTY extensions", () => { try { await session.waitForText(/alpha\.ts/, { timeout: 20_000 }); await harness.ensureKeyboardIsLive(session); - await session.press("c"); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "c", /Draft note/, { timeout: 5_000 }); await session.type("Publish this exact note."); await session.type("\x13"); await session.waitForText(/Your note/, { timeout: 5_000 }); - await session.press("f9"); - await session.waitForText(/Export review snapshot/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "f9", /Export review snapshot/, { + timeout: 5_000, + }); await session.type(outputPath); - await session.press("enter"); - await session.waitForText(/Exported 1 saved note/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "enter", /Exported 1 saved note/, { + timeout: 5_000, + }); const snapshot = JSON.parse(readFileSync(outputPath, "utf8")) as { generation: string; @@ -1019,8 +1021,9 @@ describe("PTY extensions", () => { 20_000, ); await harness.ensureKeyboardIsLive(session); - await session.press("f6"); - await session.waitForText(/Vim navigation.*Esc exits/, { timeout: 20_000 }); + await harness.pressAndWaitForText(session, "f6", /Vim navigation.*Esc exits/, { + timeout: 20_000, + }); // The host contributes a mouse-accessible exit independently of the extension command. await session.clickAt(33, 0); @@ -1031,13 +1034,15 @@ describe("PTY extensions", () => { (text) => !/Vim navigation.*Esc exits/.test(text), 20_000, ); - await session.press("f6"); - await session.waitForText(/Vim navigation.*Esc exits/, { timeout: 20_000 }); + await harness.pressAndWaitForText(session, "f6", /Vim navigation.*Esc exits/, { + timeout: 20_000, + }); // A passed `c` exposes the host-owned current line through note placement, // giving counted movement and alignment observable terminal effects. - await session.press("c"); - const initialDraft = await session.waitForText(/Draft note/, { timeout: 20_000 }); + const initialDraft = await harness.pressAndWaitForText(session, "c", /Draft note/, { + timeout: 20_000, + }); const initialDraftRow = lineIndexOf(initialDraft, "Draft note"); await harness.pressAndWaitForSnapshot( session, @@ -1049,8 +1054,9 @@ describe("PTY extensions", () => { await session.press("1"); await session.press("0"); await session.press("j"); - await session.press("c"); - const countedDraft = await session.waitForText(/Draft note/, { timeout: 20_000 }); + const countedDraft = await harness.pressAndWaitForText(session, "c", /Draft note/, { + timeout: 20_000, + }); expect(lineIndexOf(countedDraft, "Draft note")).toBeGreaterThan(initialDraftRow); await harness.pressAndWaitForSnapshot( session, @@ -1072,8 +1078,9 @@ describe("PTY extensions", () => { // `:` passes into the registered command, whose focused status-line prompt owns even // mode keys. Escape clears the typed buffer first and closes the prompt second, leaving // the mode itself running. - await session.press(":"); - await session.waitForText(/ext vim-navigation : top or bottom/, { timeout: 20_000 }); + await harness.pressAndWaitForText(session, ":", /ext vim-navigation : top or bottom/, { + timeout: 20_000, + }); await session.type("j-owned"); await session.waitForText(/: j-owned/, { timeout: 20_000 }); await session.press("escape"); @@ -1085,8 +1092,9 @@ describe("PTY extensions", () => { 20_000, ); - await session.press(":"); - await session.waitForText(/ext vim-navigation : top or bottom/, { timeout: 20_000 }); + await harness.pressAndWaitForText(session, ":", /ext vim-navigation : top or bottom/, { + timeout: 20_000, + }); await session.type("bottom"); const commandBottom = await harness.pressAndWaitForSnapshot( session, @@ -1096,8 +1104,9 @@ describe("PTY extensions", () => { ); expect(commandBottom).toContain("second.ts"); - await session.press(":"); - await session.waitForText(/ext vim-navigation : top or bottom/, { timeout: 20_000 }); + await harness.pressAndWaitForText(session, ":", /ext vim-navigation : top or bottom/, { + timeout: 20_000, + }); await session.type("top"); const commandTop = await harness.pressAndWaitForSnapshot( session, @@ -1189,15 +1198,17 @@ describe("PTY extensions", () => { // app subscribes its handler; prove the keyboard is live first. await harness.ensureKeyboardIsLive(session); - await session.press("f7"); - const refreshed = await session.waitForText(/marks refreshed/, { timeout: 20_000 }); + const refreshed = await harness.pressAndWaitForText(session, "f7", /marks refreshed/, { + timeout: 20_000, + }); // The valid refresh raised only the fixture's own toast, no host warning. expect(refreshed).not.toContain("unknown line highlighter"); // The re-derived marks still leave the reviewed text untouched. expect(refreshed).toContain("export const alphaValue = 2;"); - await session.press("f8"); - const warned = await session.waitForText(/unknown line highlighter/, { timeout: 20_000 }); + const warned = await harness.pressAndWaitForText(session, "f8", /unknown line highlighter/, { + timeout: 20_000, + }); expect(warned).toContain('Extension fixture targeted unknown line highlighter "nope"'); } finally { session.close(); @@ -1245,8 +1256,9 @@ describe("PTY extensions", () => { expect(row).toBeGreaterThan(0); expect(row).toBeLessThan(12); - await session.press("f8"); - const warned = await session.waitForText(/revealLine found no/, { timeout: 20_000 }); + const warned = await harness.pressAndWaitForText(session, "f8", /revealLine found no/, { + timeout: 20_000, + }); expect(warned).toContain("Extension fixture revealLine found no new line 9001"); } finally { session.close(); diff --git a/test/pty/file-views-integration.test.ts b/test/pty/file-views-integration.test.ts index 74581e517..0ce819dfc 100644 --- a/test/pty/file-views-integration.test.ts +++ b/test/pty/file-views-integration.test.ts @@ -161,8 +161,7 @@ describe("PTY file views", () => { // A short idle wait can finish while the terminal parser still holds a lone Escape. // Verify close before F8 so the two inputs cannot become an Alt-modified function key. await harness.waitForSnapshot(session, (text) => !text.includes("File presentation:")); - await session.press("f8"); - await session.waitForText(/• new item/); + await harness.pressAndWaitForText(session, "f8", /• new item/); await session.click(/View/); const toggled = await session.waitForText(/\[x\] File presentation: Rendered Markdown/, { timeout: 20_000, @@ -234,10 +233,8 @@ describe("PTY file views", () => { await session.click(/View/); await session.waitForText(demo.view, { timeout: 20_000 }); await session.press("escape"); - await session.press("f8"); - await session.waitForText(demo.first, { timeout: 20_000 }); - await session.press("]"); - await session.waitForText(demo.second, { timeout: 20_000 }); + await harness.pressAndWaitForText(session, "f8", demo.first, { timeout: 20_000 }); + await harness.pressAndWaitForText(session, "]", demo.second, { timeout: 20_000 }); await session.click(/View/); await session.waitForText(/File presentation: Raw diff/, { timeout: 20_000 }); await session.click(/File presentation: Raw diff/); @@ -260,12 +257,12 @@ describe("PTY file views", () => { await harness.ensureKeyboardIsLive(session); await session.click(/package\.json/, { first: true }); - await session.press("f8"); - await session.waitForText(/Package metadata hunk 1/, { timeout: 20_000 }); + await harness.pressAndWaitForText(session, "f8", /Package metadata hunk 1/, { + timeout: 20_000, + }); await session.click(/invoice\.ts/, { first: true }); - await session.press("f8"); - await session.waitForText(/CHANGE 01/, { timeout: 20_000 }); + await harness.pressAndWaitForText(session, "f8", /CHANGE 01/, { timeout: 20_000 }); await session.click(/theme\.css/, { first: true }); await session.press("f8"); @@ -330,12 +327,10 @@ describe("PTY file views", () => { custom = await session.waitForText(/lines 1–4 · @@ -1,4 \+1,4 @@/); expect(custom).not.toContain("row 0 · click for detail"); - await session.press("]"); - const secondHunk = await session.waitForText(/▶ Hunk 2/); + const secondHunk = await harness.pressAndWaitForText(session, "]", /▶ Hunk 2/); expect(secondHunk).not.toContain("▶ Hunk 1"); - await session.press("f8"); - const raw = await session.waitForText(/line60 = 6000/); + const raw = await harness.pressAndWaitForText(session, "f8", /line60 = 6000/); expect(raw).not.toContain("Hunk 1"); await session.click(/Extensions/); @@ -374,21 +369,17 @@ describe("PTY file views", () => { // One press from raw diff: entering the mode selects the view it takes // keys for, so the rows and the keyboard arrive together. - await session.press("f9"); - await session.waitForText(/CURSOR AT 0/, { timeout: 20_000 }); + await harness.pressAndWaitForText(session, "f9", /CURSOR AT 0/, { timeout: 20_000 }); await session.waitForText(/cursor-mode:cursor mode — Esc exits/, { timeout: 20_000 }); // Handled keys reach the extension, and the redraw it asks for is what // the terminal actually shows. - await session.press("j"); - await session.waitForText(/CURSOR AT 1/, { timeout: 20_000 }); - await session.press("j"); - await session.waitForText(/CURSOR AT 2/, { timeout: 20_000 }); + await harness.pressAndWaitForText(session, "j", /CURSOR AT 1/, { timeout: 20_000 }); + await harness.pressAndWaitForText(session, "j", /CURSOR AT 2/, { timeout: 20_000 }); // A declined key reaches Hunk's own commands, and the overlay it opens // outranks the mode: its Escape closes the overlay, not the mode. - await session.press("?"); - await session.waitForText(/Controls help/, { timeout: 20_000 }); + await harness.pressAndWaitForText(session, "?", /Controls help/, { timeout: 20_000 }); await session.press("escape"); const stillActive = await session.waitForText(/cursor-mode:cursor mode — Esc exits/, { timeout: 20_000, @@ -400,8 +391,9 @@ describe("PTY file views", () => { expect(exited).not.toContain("Esc exits"); // The command table owns the keyboard again. - await session.press("f8"); - const raw = await session.waitForText(/line60 = 6000/, { timeout: 20_000 }); + const raw = await harness.pressAndWaitForText(session, "f8", /line60 = 6000/, { + timeout: 20_000, + }); expect(raw).not.toContain("CURSOR AT"); } finally { session.close(); @@ -435,21 +427,22 @@ describe("PTY file views", () => { // each keystroke reaches the screen only through `fileViews.refresh`. await session.press("z"); await session.press("z"); - await session.press("z"); - const typed = await session.waitForText(/zzzexport const alpha = 2;/, { timeout: 20_000 }); + const typed = await harness.pressAndWaitForText(session, "z", /zzzexport const alpha = 2;/, { + timeout: 20_000, + }); expect(typed).toContain("MODIFIED"); // `?` is an explicitly host-owned printable key, so help remains // reachable and one Escape closes only the overlay, not the editor. - await session.press("?"); - await session.waitForText(/Controls help/, { timeout: 20_000 }); + await harness.pressAndWaitForText(session, "?", /Controls help/, { timeout: 20_000 }); await session.press("escape"); await session.waitForText(/inline-edit:inline-edit mode — Esc exits/, { timeout: 20_000 }); // The mode can only request the write; the command handler awaiting the // session performs it, and the host asks the user first. - await session.press(["ctrl", "s"]); - await session.waitForText(/Write alpha\.ts\?/, { timeout: 20_000 }); + await harness.pressAndWaitForText(session, ["ctrl", "s"], /Write alpha\.ts\?/, { + timeout: 20_000, + }); const prompt = await session.waitForText(/ext inline-edit/, { timeout: 20_000 }); expect(prompt).toContain("replace this file's contents on disk"); await session.press("enter"); @@ -508,8 +501,9 @@ describe("PTY file views", () => { try { await session.waitForText(/Keep this note visible\./, { timeout: 20_000 }); await harness.ensureKeyboardIsLive(session); - await session.press(["ctrl", "e"]); - await session.waitForText(/EDITING — Esc exits/, { timeout: 20_000 }); + await harness.pressAndWaitForText(session, ["ctrl", "e"], /EDITING — Esc exits/, { + timeout: 20_000, + }); await session.press("down"); await session.press("backspace"); @@ -543,12 +537,14 @@ describe("PTY file views", () => { try { await session.waitForText(/😀/, { timeout: 20_000 }); await harness.ensureKeyboardIsLive(session); - await session.press(["ctrl", "e"]); - await session.waitForText(/EDITING — Esc exits/, { timeout: 20_000 }); + await harness.pressAndWaitForText(session, ["ctrl", "e"], /EDITING — Esc exits/, { + timeout: 20_000, + }); await session.press("right"); await session.press("backspace"); - await session.press(["ctrl", "s"]); - await session.waitForText(/Write alpha\.ts\?/, { timeout: 20_000 }); + await harness.pressAndWaitForText(session, ["ctrl", "s"], /Write alpha\.ts\?/, { + timeout: 20_000, + }); await session.press("enter"); expect(await waitForWrittenFile(edited, "\n")).toBe("\n"); @@ -581,8 +577,7 @@ describe("PTY file views", () => { try { await session.waitForText(/before\.md/, { timeout: 20_000 }); await harness.ensureKeyboardIsLive(session); - await session.press("f8"); - const preview = await session.waitForText(/• new item/); + const preview = await harness.pressAndWaitForText(session, "f8", /• new item/); expect(preview).toContain("Review the new item."); expect(preview).not.toContain("old item"); await session.click(/View/); @@ -625,8 +620,7 @@ describe("PTY file views", () => { await session.waitForText(/\[x\] File presentation: Rendered Markdown/); await session.press("escape"); - await session.press("a"); - const restored = await session.waitForText(/• new item/); + const restored = await harness.pressAndWaitForText(session, "a", /• new item/); expect(restored).not.toContain("old item"); } finally { session.close(); diff --git a/test/pty/harness.test.ts b/test/pty/harness.test.ts index 5b68d55d7..a12081556 100644 --- a/test/pty/harness.test.ts +++ b/test/pty/harness.test.ts @@ -6,13 +6,17 @@ import { createPtyHarness } from "./harness"; function createTestTransitionSession(screens: string[]) { const inputs: Parameters[0][] = []; let frame = 0; - const session: Pick = { + const session: Pick = { sendKey(key) { inputs.push(key); }, async text() { return screens[frame]!; }, + async waitForText() { + frame = screens.length - 1; + return screens[frame]!; + }, async waitIdle() { frame = Math.min(frame + 1, screens.length - 1); }, @@ -56,4 +60,22 @@ describe("PTY transition synchronization", () => { ).rejects.toThrow("destination was visible before the keypress"); expect(inputs).toEqual([]); }); + + test("waits directly for text produced by a key transition", async () => { + const { session, inputs } = createTestTransitionSession(["Review", "Theme selector"]); + const harness = createPtyHarness(); + await expect(harness.pressAndWaitForText(session, "t", /Theme selector/)).resolves.toBe( + "Theme selector", + ); + expect(inputs).toEqual(["t"]); + }); + + test("rejects text already visible before the key transition", async () => { + const { session, inputs } = createTestTransitionSession(["Draft note — body"]); + const harness = createPtyHarness(); + await expect(harness.pressAndWaitForText(session, ["ctrl", "s"], /Draft note/)).rejects.toThrow( + "destination was visible before the keypress", + ); + expect(inputs).toEqual([]); + }); }); diff --git a/test/pty/harness.ts b/test/pty/harness.ts index eabedf70c..85bf046df 100644 --- a/test/pty/harness.ts +++ b/test/pty/harness.ts @@ -1146,6 +1146,26 @@ end return waitForSnapshot(session, predicate, timeoutMs); } + /** Send one key and wait for text that was absent before the transition. */ + async function pressAndWaitForText( + session: Pick, + key: Key | Key[], + pattern: Parameters[0], + options?: Parameters[1], + ) { + const before = await session.text({ immediate: true }); + const matchedBefore = + typeof pattern === "string" + ? before.includes(pattern) + : new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, "")).test(before); + if (matchedBefore) { + throw new Error("pressAndWaitForText: destination was visible before the keypress."); + } + + session.sendKey(key); + return session.waitForText(pattern, options); + } + function countMatches(text: string, pattern: RegExp) { return (text.match(pattern) ?? []).length; } @@ -1216,6 +1236,7 @@ end buildHunkCommand, shellQuote, pressAndWaitForSnapshot, + pressAndWaitForText, waitForSnapshot, }; } diff --git a/test/pty/key-routing.test.ts b/test/pty/key-routing.test.ts index dd33de424..96c97079f 100644 --- a/test/pty/key-routing.test.ts +++ b/test/pty/key-routing.test.ts @@ -47,8 +47,7 @@ describe("PTY key routing", () => { try { await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); - await session.press("f10"); - await session.waitForText(/Reload/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "f10", /Reload/, { timeout: 5_000 }); await session.press("escape"); // Keep the menu's lone Escape separate from the next Escape-prefixed input. await harness.waitForSnapshot(session, (text) => !text.includes("Toggle files/filter focus")); @@ -74,8 +73,7 @@ describe("PTY key routing", () => { // Open the help overlay first, then focus the filter and type into it // behind the overlay. - await session.press("?"); - await session.waitForText(/Controls help/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "?", /Controls help/, { timeout: 5_000 }); await harness.pressAndWaitForSnapshot( session, @@ -132,8 +130,7 @@ describe("PTY key routing", () => { ); // Open the menu bar and pick "Unified view" from the View menu with Enter. - await session.press("f10"); - await session.waitForText(/Reload/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "f10", /Reload/, { timeout: 5_000 }); await harness.pressAndWaitForSnapshot( session, "right", @@ -168,8 +165,7 @@ describe("PTY key routing", () => { try { await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); - await session.press("c"); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "c", /Draft note/, { timeout: 5_000 }); // The note composer owns the keyboard; F10 must not pop the menu bar // over an in-progress draft. @@ -278,8 +274,7 @@ describe("PTY key routing", () => { await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); await session.waitIdle({ timeout: 300 }); - await session.press("f10"); - await session.waitForText(/Reload/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "f10", /Reload/, { timeout: 5_000 }); // An open menu is deliberately not fully modal: keys the menu does not // use keep falling through to the command table. @@ -309,8 +304,9 @@ describe("PTY key routing", () => { await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); await session.waitIdle({ timeout: 300 }); - await session.press("f10"); - const menuOpen = await session.waitForText(/Reload/, { timeout: 5_000 }); + const menuOpen = await harness.pressAndWaitForText(session, "f10", /Reload/, { + timeout: 5_000, + }); const anchorRow = 20; const anchorText = menuOpen.split("\n")[anchorRow]?.trim() ?? ""; diff --git a/test/pty/lifecycle.test.ts b/test/pty/lifecycle.test.ts index 622a3a24c..98132e4ef 100644 --- a/test/pty/lifecycle.test.ts +++ b/test/pty/lifecycle.test.ts @@ -227,8 +227,7 @@ describe("PTY lifecycle", () => { session.writeRaw(`${hunkCommand}\r`); await session.waitForText(/before\.txt.*after\.txt/, { timeout: 15_000 }); await harness.ensureKeyboardIsLive(session); - await session.press("c"); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "c", /Draft note/, { timeout: 5_000 }); await session.type("Keep this note after resume."); await session.press(["ctrl", "s"]); await session.waitForText(/Keep this note after resume\./, { timeout: 5_000 }); diff --git a/test/pty/log-integration.test.ts b/test/pty/log-integration.test.ts index 303a03a4e..f7ec40aa2 100644 --- a/test/pty/log-integration.test.ts +++ b/test/pty/log-integration.test.ts @@ -164,8 +164,7 @@ describe("interactive hunk log", () => { try { await session.waitForText(/Second history commit/, { timeout: 15_000 }); - await session.press("enter"); - await session.waitForText(/Preparing review/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "enter", /Preparing review/, { timeout: 5_000 }); const cancelledAt = Date.now(); session.writeRaw("\x03"); while ( @@ -200,14 +199,14 @@ describe("interactive hunk log", () => { await session.waitForText(/1 commit selected/, { timeout: 5_000 }); session.writeRaw("j"); await session.waitForText(/2 commits selected/, { timeout: 5_000 }); - await session.press("enter"); - const review = await session.waitForText(/rootOnly = true/, { timeout: 15_000 }); + const review = await harness.pressAndWaitForText(session, "enter", /rootOnly = true/, { + timeout: 15_000, + }); expect(review).toContain("historyValue = 'second'"); expect(review).toContain("Second history commit"); expect(review).toContain("First history commit"); expect(review).toMatch(/history · .* ago\s+[0-9a-f]{8} ⧉/); - await session.press("q"); - await session.waitForText(/2 commits selected/, { timeout: 15_000 }); + await harness.pressAndWaitForText(session, "q", /2 commits selected/, { timeout: 15_000 }); // Escape collapses visual mode before direct Shift selection starts another range. await harness.pressAndWaitForSnapshot( @@ -248,11 +247,11 @@ describe("interactive hunk log", () => { session.writeRaw("\x1b[<0;2;1M\x1b[<0;2;1m"); await session.waitForText(/Open selection/, { timeout: 5_000 }); await session.press("right"); - await session.press("enter"); - await session.waitForText(/Theme selector/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "enter", /Theme selector/, { timeout: 5_000 }); await session.press("down"); - await session.press("enter"); - await session.waitForText(/Second history commit/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "enter", /Second history commit/, { + timeout: 5_000, + }); // The first row's right-aligned commit id opens immediately without a double-click. const firstRowIndex = history @@ -291,8 +290,7 @@ describe("interactive hunk log", () => { await session.waitForText(/Copied [0-9a-f]{8}/, { timeout: 5_000 }); // Scrolling the history body dismisses an open dropdown before moving selection. - await session.press("f10"); - await session.waitForText(/Open selection/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "f10", /Open selection/, { timeout: 5_000 }); session.writeRaw("\x1b[<65;50;5M"); await harness.waitForSnapshot(session, (text) => !text.includes("Open selection"), 5_000); @@ -306,8 +304,7 @@ describe("interactive hunk log", () => { await returnToHistory(session); // A command key closes an open menu and falls through to canonical dispatch. - await session.press("f10"); - await session.waitForText(/Open selection/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "f10", /Open selection/, { timeout: 5_000 }); session.writeRaw("k\r"); await session.waitForText(/historyValue = 'second'/, { timeout: 15_000 }); await returnToHistory(session); @@ -408,12 +405,14 @@ describe("interactive hunk log", () => { (text) => !text.includes("commit selected"), ); await session.press("down"); - await session.press("enter"); - await session.waitForText(/historyValue = 'second'/, { timeout: 15_000 }); + await harness.pressAndWaitForText(session, "enter", /historyValue = 'second'/, { + timeout: 15_000, + }); await returnToHistory(session); session.writeRaw("\x0e"); - await session.press("enter"); - await session.waitForText(/historyValue = 'first'/, { timeout: 15_000 }); + await harness.pressAndWaitForText(session, "enter", /historyValue = 'first'/, { + timeout: 15_000, + }); await returnToHistory(session); await session.press("q"); } finally { @@ -435,12 +434,12 @@ describe("interactive hunk log", () => { try { await session.waitForText(/Second history commit/, { timeout: 15_000 }); - await session.press("t"); - await session.waitForText(/Theme selector/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "t", /Theme selector/, { timeout: 5_000 }); await session.press("down"); await session.press("enter"); - await session.press("q"); - const prompt = await session.waitForText(/Save view preferences\?/, { timeout: 5_000 }); + const prompt = await harness.pressAndWaitForText(session, "q", /Save view preferences\?/, { + timeout: 5_000, + }); expect(prompt).toContain('- theme = "github-dark-default"'); expect(prompt).toContain('+ theme = "github-dark-dimmed"'); @@ -472,8 +471,9 @@ describe("interactive hunk log", () => { try { await session.waitForText(/Second history commit/, { timeout: 15_000 }); - await session.press("enter"); - const split = await session.waitForText(/historyValue = 'second'/, { timeout: 15_000 }); + const split = await harness.pressAndWaitForText(session, "enter", /historyValue = 'second'/, { + timeout: 15_000, + }); expect(split).toMatch(/▌.*▌/); // Preserve the coalesced layout-and-return input; only history chrome proves q committed. @@ -493,8 +493,9 @@ describe("interactive hunk log", () => { expect(retained).not.toMatch(/▌.*▌/); await returnToHistory(session); - await session.press("q"); - const prompt = await session.waitForText(/Save view preferences\?/, { timeout: 5_000 }); + const prompt = await harness.pressAndWaitForText(session, "q", /Save view preferences\?/, { + timeout: 5_000, + }); expect(prompt).toContain('- mode = "split"'); expect(prompt).toContain('+ mode = "unified"'); @@ -530,8 +531,9 @@ describe("interactive hunk log", () => { session.writeRaw("t"); await session.waitForText(/Theme selector/, { timeout: 5_000 }); - await session.press("escape"); - await session.waitForText(/Commits on Sep 6, 2026/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "escape", /Commits on Sep 6, 2026/, { + timeout: 5_000, + }); session.resize({ cols: 70, rows: 20 }); await harness.waitForSnapshot( @@ -627,8 +629,9 @@ describe("interactive hunk log", () => { await session.waitForText(/Second history commit/, { timeout: 15_000 }); writeFileSync(join(cwd, "history.ts"), "export const historyValue = 'third';\n"); git(cwd, ["commit", "-qam", "Third history commit"]); - await session.press("r"); - const refreshed = await session.waitForText(/Third history commit/, { timeout: 15_000 }); + const refreshed = await harness.pressAndWaitForText(session, "r", /Third history commit/, { + timeout: 15_000, + }); expect(refreshed).toContain("History refreshed"); await session.press("q"); } finally { @@ -649,15 +652,15 @@ describe("interactive hunk log", () => { await session.press("f10"); await session.press("right"); await session.press("down"); - await session.press("enter"); - const graph = await session.waitForText(/╯/, { timeout: 5_000 }); + const graph = await harness.pressAndWaitForText(session, "enter", /╯/, { timeout: 5_000 }); expect(graph).not.toContain("Commits on"); await session.press("f10"); await session.press("right"); await session.press("right"); - await session.press("right"); - await session.waitForText(/Compare with parent/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "right", /Compare with parent/, { + timeout: 5_000, + }); await session.press("down"); await session.press("down"); await session.press("down"); @@ -665,8 +668,9 @@ describe("interactive hunk log", () => { await session.waitForText(/Compare with parent/, { timeout: 5_000 }); session.writeRaw("\x1b[<65;50;10M"); await session.waitIdle(); - await session.press("enter"); - const review = await session.waitForText(/main\.ts/, { timeout: 15_000 }); + const review = await harness.pressAndWaitForText(session, "enter", /main\.ts/, { + timeout: 15_000, + }); expect(review).not.toContain("side.ts"); await returnToHistory(session); await session.press("q"); diff --git a/test/pty/notes.test.ts b/test/pty/notes.test.ts index d7e2664c1..47292a3c1 100644 --- a/test/pty/notes.test.ts +++ b/test/pty/notes.test.ts @@ -46,8 +46,9 @@ describe("PTY notes", () => { expect(initial).not.toContain("Adds bonus export."); - await session.press("a"); - const withNotes = await session.waitForText(/Adds bonus export\./, { timeout: 5_000 }); + const withNotes = await harness.pressAndWaitForText(session, "a", /Adds bonus export\./, { + timeout: 5_000, + }); expect(withNotes).toContain("Highlights the follow-up addition for review."); expect(withNotes).not.toContain("STML ACTIVE"); @@ -84,8 +85,9 @@ describe("PTY notes", () => { try { await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); - await session.press("a"); - const withNotes = await session.waitForText(/GAP NOTE/, { timeout: 5_000 }); + const withNotes = await harness.pressAndWaitForText(session, "a", /GAP NOTE/, { + timeout: 5_000, + }); // Lines 6-7 are collapsed away, so the note hangs from the hunk that owns the gap: // it lands just below that hunk's first row, not at the top of the file. @@ -117,8 +119,9 @@ describe("PTY notes", () => { try { await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); - await session.press("a"); - const withMarkup = await session.waitForText(/STML ACTIVE/, { timeout: 5_000 }); + const withMarkup = await harness.pressAndWaitForText(session, "a", /STML ACTIVE/, { + timeout: 5_000, + }); expect(withMarkup).not.toContain("Highlights the follow-up addition for review."); } finally { @@ -148,8 +151,9 @@ describe("PTY notes", () => { const firstActiveRow = lineIndexOf(beforePushedDraft, firstActiveLine); const followingRowBefore = lineIndexOf(beforePushedDraft, followingLine); - await session.press("c"); - await session.waitForText(/Draft note - before\.ts -> after\.ts L9/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "c", /Draft note - before\.ts -> after\.ts L9/, { + timeout: 5_000, + }); await sleep(100); const pushedDraft = await session.text({ immediate: true }); @@ -173,8 +177,7 @@ describe("PTY notes", () => { const bottomActiveRow = lineIndexOf(beforeBottomDraft, bottomActiveLine); expect(bottomActiveRow).toBeGreaterThan(0); - await session.press("c"); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "c", /Draft note/, { timeout: 5_000 }); await sleep(100); const bottomDraft = await session.text({ immediate: true }); @@ -213,8 +216,7 @@ describe("PTY notes", () => { const paged = await session.text({ immediate: true }); expect(paged).not.toContain("export const line01 = 1;"); - await session.press("c"); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "c", /Draft note/, { timeout: 5_000 }); await sleep(100); const draft = await session.text({ immediate: true }); @@ -238,8 +240,9 @@ describe("PTY notes", () => { timeout: 15_000, }); - await session.press("c"); - const freshDraft = await session.waitForText(/Draft note/, { timeout: 5_000 }); + const freshDraft = await harness.pressAndWaitForText(session, "c", /Draft note/, { + timeout: 5_000, + }); // The "c" that opened the note must not be inserted into the editor. A fresh, empty draft // shows its placeholder; if the opening keystroke leaked in, the editor would hold "c" and // the placeholder would be gone. @@ -297,8 +300,7 @@ describe("PTY notes", () => { try { await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); - await session.press("c"); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "c", /Draft note/, { timeout: 5_000 }); await session.type("First keyboard note."); await harness.pressAndWaitForSnapshot( session, @@ -309,8 +311,7 @@ describe("PTY notes", () => { text.includes("First keyboard note."), ); - await session.press("c"); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "c", /Draft note/, { timeout: 5_000 }); await session.type("Second keyboard note."); await session.type("\x13"); const savedActive = await harness.waitForSnapshot( @@ -405,8 +406,7 @@ describe("PTY notes", () => { try { await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); for (const body of ["First note-only stop.", "Second note-only stop."]) { - await session.press("c"); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "c", /Draft note/, { timeout: 5_000 }); await session.type(body); await harness.pressAndWaitForSnapshot( session, @@ -453,8 +453,7 @@ describe("PTY notes", () => { try { await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); - await session.press("c"); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "c", /Draft note/, { timeout: 5_000 }); await session.type("Root review note."); await session.waitForText(/Root review note\./, { timeout: 5_000 }); await session.type("\x13"); @@ -608,8 +607,9 @@ describe("PTY notes", () => { await dragMouse(session, 12, oldEndRow, 24, newStartRow); await session.waitForText(/c Comment\s+y Copy\s+Esc Clear/, { timeout: 5_000 }); - await session.press("c"); - const draft = await session.waitForText(/Draft note/, { timeout: 5_000 }); + const draft = await harness.pressAndWaitForText(session, "c", /Draft note/, { + timeout: 5_000, + }); expect(draft).toContain("L2 → R1"); await session.type("Mixed replacement feedback."); @@ -635,8 +635,7 @@ describe("PTY notes", () => { timeout: 15_000, }); - await session.press("c"); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "c", /Draft note/, { timeout: 5_000 }); // 48 characters, 86 cells: past the wrap point of any reasonable // composer width, and long enough that a code-unit row estimate would @@ -669,8 +668,7 @@ describe("PTY notes", () => { try { await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); - await session.press("c"); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "c", /Draft note/, { timeout: 5_000 }); await session.type("Save exactly one note."); await session.waitForText(/Save exactly one note\./, { timeout: 5_000 }); @@ -703,8 +701,7 @@ describe("PTY notes", () => { try { await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); - await session.press("c"); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "c", /Draft note/, { timeout: 5_000 }); await session.type("Fast sav"); await session.waitForText(/Fast sav/, { timeout: 5_000 }); @@ -788,8 +785,7 @@ describe("PTY notes", () => { // Open an empty draft via the keyboard and immediately cancel it. The very first Escape must // close it — a regression once required two presses because the focus area had not yet // settled to the note when the first Escape arrived. - await session.press("c"); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "c", /Draft note/, { timeout: 5_000 }); const cancelled = await harness.pressAndWaitForSnapshot( session, @@ -834,8 +830,9 @@ describe("PTY notes", () => { await session.click(/\[\+\]/); await session.waitForText(/Draft note/, { timeout: 5_000 }); await session.type("Save this shortcut draft."); - await session.press(["ctrl", "s"]); - const saved = await session.waitForText(/Your note/, { timeout: 5_000 }); + const saved = await harness.pressAndWaitForText(session, ["ctrl", "s"], /Your note/, { + timeout: 5_000, + }); expect(saved).toContain("Save this shortcut draft."); } finally { @@ -862,8 +859,9 @@ describe("PTY notes", () => { await session.click(/\[\+\]/); await session.waitForText(/Draft note/, { timeout: 5_000 }); await session.type("Save this unified draft."); - await session.press(["ctrl", "s"]); - const saved = await session.waitForText(/Your note/, { timeout: 5_000 }); + const saved = await harness.pressAndWaitForText(session, ["ctrl", "s"], /Your note/, { + timeout: 5_000, + }); expect(saved).toContain("Save this unified draft."); } finally { @@ -890,8 +888,9 @@ describe("PTY notes", () => { await session.click(/\[\+\]/); await session.waitForText(/Draft note/, { timeout: 5_000 }); await session.type("Save this deletion draft."); - await session.press(["ctrl", "s"]); - const saved = await session.waitForText(/Your note/, { timeout: 5_000 }); + const saved = await harness.pressAndWaitForText(session, ["ctrl", "s"], /Your note/, { + timeout: 5_000, + }); expect(saved).toContain("Save this deletion draft."); } finally { @@ -928,8 +927,9 @@ describe("PTY notes", () => { expect(lineIndexOf(withDraft, "Draft note")).toBeGreaterThan(clickedRowBefore); await session.type("Save this context draft."); - await session.press(["ctrl", "s"]); - const saved = await session.waitForText(/Your note/, { timeout: 5_000 }); + const saved = await harness.pressAndWaitForText(session, ["ctrl", "s"], /Your note/, { + timeout: 5_000, + }); expect(saved).toContain("Save this context draft."); } finally { @@ -951,8 +951,7 @@ describe("PTY notes", () => { }); expect(initial).not.toContain("line60 = 6000"); - await session.press("c"); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, "c", /Draft note/, { timeout: 5_000 }); await session.type("Keep focus here"); const whileFocused = await harness.pressAndWaitForSnapshot( session, From 54d8730a8f8318da5645e9b68257b1efa27c0b37 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 10 Sep 2026 15:11:11 -0400 Subject: [PATCH 03/15] Let add-note affordance text own mouse-move readiness Result: {"status":"keep","pty_suite_ms":191793.75,"pty_reported_ms":191780,"pty_pass_count":176,"pty_skip_count":1,"slowest_test_ms":8622.65,"tests_over_3000ms":12} --- test/pty/harness.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test/pty/harness.ts b/test/pty/harness.ts index 85bf046df..8ac68d37a 100644 --- a/test/pty/harness.ts +++ b/test/pty/harness.ts @@ -121,16 +121,21 @@ export async function measureMouseWheelScroll( return anchorRow - movedTo; } +/** Send an SGR mouse motion event without imposing a readiness policy on its caller. */ +function sendMouseMove(session: Session, x: number, y: number) { + session.writeRaw(`\x1b[<35;${x + 1};${y + 1}M`); +} + /** Send an SGR mouse motion event at zero-based terminal coordinates. */ export async function moveMouse(session: Session, x: number, y: number) { - session.writeRaw(`\x1b[<35;${x + 1};${y + 1}M`); + sendMouseMove(session, x, y); await session.waitIdle(); } /** Reveal the hover-only add-note badge across fixture-specific row offsets. */ export async function revealAddNoteAffordance(session: Session, x: number, yCandidates: number[]) { for (const y of yCandidates) { - await moveMouse(session, x, y); + sendMouseMove(session, x, y); try { return await session.waitForText(/\[\+\]/, { timeout: 1_000 }); } catch { @@ -201,7 +206,7 @@ export async function revealAddNoteNear(session: Session, row: number) { } for (const x of [8, 20, 60]) { - await moveMouse(session, x, y); + sendMouseMove(session, x, y); try { await session.waitForText(/\[\+\]/, { timeout: 200 }); return; @@ -217,7 +222,7 @@ export async function revealAddNoteNear(session: Session, row: number) { /** Reveal the add-note control without falling back to adjacent rows. */ export async function revealAddNoteOnRow(session: Session, row: number) { for (const x of [8, 20, 60]) { - await moveMouse(session, x, row); + sendMouseMove(session, x, row); try { await session.waitForText(/\[\+\]/, { timeout: 200 }); return; From 8f2cc918d5d43ebcf062d194c7b40e77c3f76b09 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 10 Sep 2026 15:22:46 -0400 Subject: [PATCH 04/15] Use guarded destination waits for remaining distinct editor and note-save transitions Result: {"status":"keep","pty_suite_ms":191780.54,"pty_reported_ms":191770,"pty_pass_count":176,"pty_skip_count":1,"slowest_test_ms":7932.46,"tests_over_3000ms":12} --- test/pty/extensions-integration.test.ts | 10 +++--- test/pty/file-views-integration.test.ts | 33 ++++++++++++------- test/pty/log-integration.test.ts | 10 +++--- test/pty/notes.test.ts | 43 ++++++++++++++++--------- 4 files changed, 61 insertions(+), 35 deletions(-) diff --git a/test/pty/extensions-integration.test.ts b/test/pty/extensions-integration.test.ts index 8d44c6c5f..bf440bb2c 100644 --- a/test/pty/extensions-integration.test.ts +++ b/test/pty/extensions-integration.test.ts @@ -869,8 +869,9 @@ describe("PTY extensions", () => { await harness.pressAndWaitForText(session, "c", /Draft note/, { timeout: 5_000 }); await session.type("Navigate to this exact note."); - await session.type("\x13"); - await session.waitForText(/Your note/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, ["ctrl", "s"], /Your note/, { + timeout: 5_000, + }); await harness.pressAndWaitForSnapshot( session, @@ -923,8 +924,9 @@ describe("PTY extensions", () => { await harness.ensureKeyboardIsLive(session); await harness.pressAndWaitForText(session, "c", /Draft note/, { timeout: 5_000 }); await session.type("Publish this exact note."); - await session.type("\x13"); - await session.waitForText(/Your note/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, ["ctrl", "s"], /Your note/, { + timeout: 5_000, + }); await harness.pressAndWaitForText(session, "f9", /Export review snapshot/, { timeout: 5_000, diff --git a/test/pty/file-views-integration.test.ts b/test/pty/file-views-integration.test.ts index 0ce819dfc..a34c34b50 100644 --- a/test/pty/file-views-integration.test.ts +++ b/test/pty/file-views-integration.test.ts @@ -315,8 +315,9 @@ describe("PTY file views", () => { try { await session.waitForText(/before\.ts/, { timeout: 20_000 }); await harness.ensureKeyboardIsLive(session); - await session.press("f8"); - let custom = await session.waitForText(/▶ Hunk 1/, { timeout: 20_000 }); + let custom = await harness.pressAndWaitForText(session, "f8", /▶ Hunk 1/, { + timeout: 20_000, + }); expect(custom).toContain("Hunk 2"); expect(custom).toContain("row 0 · click for detail"); expect(custom).not.toContain("invalid span"); @@ -416,10 +417,14 @@ describe("PTY file views", () => { // One press: `enterMode` selects the view for the file and takes the // keyboard together, so the editor opens without a second Ctrl-E. - await session.press(["ctrl", "e"]); // The view shows the new document alone, so the removed old-side line is // how the terminal reports that the presentation actually switched. - await harness.waitForSnapshot(session, (text) => !text.includes("alpha = 1"), 20_000); + await harness.pressAndWaitForSnapshot( + session, + ["ctrl", "e"], + (text) => !text.includes("alpha = 1"), + 20_000, + ); await session.waitForText(/EDITING — Esc exits · ctrl\+s writes/, { timeout: 20_000 }); await session.waitForText(/inline-edit:inline-edit mode — Esc exits/, { timeout: 20_000 }); @@ -506,17 +511,21 @@ describe("PTY file views", () => { }); await session.press("down"); - await session.press("backspace"); - const joined = await session.waitForText(/export const alpha = 2;export const add = true;/, { - timeout: 20_000, - }); + const joined = await harness.pressAndWaitForText( + session, + "backspace", + /export const alpha = 2;export const add = true;/, + { timeout: 20_000 }, + ); expect(joined).toContain("EDITING — Esc exits"); expect(joined).toContain("Keep this note visible."); - await session.press("z"); - await session.waitForText(/export const alpha = 2;zexport const add = true;/, { - timeout: 20_000, - }); + await harness.pressAndWaitForText( + session, + "z", + /export const alpha = 2;zexport const add = true;/, + { timeout: 20_000 }, + ); await session.press("escape"); } finally { session.close(); diff --git a/test/pty/log-integration.test.ts b/test/pty/log-integration.test.ts index f7ec40aa2..3afc7efa7 100644 --- a/test/pty/log-integration.test.ts +++ b/test/pty/log-integration.test.ts @@ -296,10 +296,12 @@ describe("interactive hunk log", () => { // Clicking outside the id selects the second row without opening it. session.writeRaw("\x1b[<0;50;5M\x1b[<0;50;5m"); - await session.press("enter"); - const rootReview = await session.waitForText(/historyValue = 'first'/, { - timeout: 15_000, - }); + const rootReview = await harness.pressAndWaitForText( + session, + "enter", + /historyValue = 'first'/, + { timeout: 15_000 }, + ); expect(rootReview).toContain("history.ts"); await returnToHistory(session); diff --git a/test/pty/notes.test.ts b/test/pty/notes.test.ts index 47292a3c1..ca1044160 100644 --- a/test/pty/notes.test.ts +++ b/test/pty/notes.test.ts @@ -279,9 +279,9 @@ describe("PTY notes", () => { ); await session.type("Second line."); - await session.type("\x13"); - - const savedNote = await session.waitForText(/Your note/, { timeout: 5_000 }); + const savedNote = await harness.pressAndWaitForText(session, ["ctrl", "s"], /Your note/, { + timeout: 5_000, + }); expect(savedNote).toContain("Please cover this edge case."); expect(savedNote).toContain("Second line."); } finally { @@ -313,9 +313,9 @@ describe("PTY notes", () => { await harness.pressAndWaitForText(session, "c", /Draft note/, { timeout: 5_000 }); await session.type("Second keyboard note."); - await session.type("\x13"); - const savedActive = await harness.waitForSnapshot( + const savedActive = await harness.pressAndWaitForSnapshot( session, + ["ctrl", "s"], (text) => lineIndexOf(text, "Second keyboard note.") - lineIndexOf(text, "● Your note") === 2, 5_000, @@ -456,8 +456,12 @@ describe("PTY notes", () => { await harness.pressAndWaitForText(session, "c", /Draft note/, { timeout: 5_000 }); await session.type("Root review note."); await session.waitForText(/Root review note\./, { timeout: 5_000 }); - await session.type("\x13"); - const root = await session.waitForText(/R reply E edit D delete/, { timeout: 5_000 }); + const root = await harness.pressAndWaitForText( + session, + ["ctrl", "s"], + /R reply E edit D delete/, + { timeout: 5_000 }, + ); expect(root).toMatch(/before\.ts -> after\.ts [LR]1/); const selectNoteByBody = async (body: string, assertHoverOnly = false) => { @@ -556,13 +560,20 @@ describe("PTY notes", () => { await harness.waitForSnapshot(session, (text) => !text.includes("╭─ Reply -"), 5_000); await session.press("j"); - await session.type("E"); - await session.waitForText(/╭─ Edit note -/, { timeout: 5_000 }); + await harness.pressAndWaitForText(session, ["shift", "e"], /╭─ Edit note -/, { + timeout: 5_000, + }); await session.click(/Esc cancel/); await harness.waitForSnapshot(session, (text) => !text.includes("╭─ Edit note -"), 5_000); await session.press("j"); - await session.type("R"); - const keyboardReply = await session.waitForText(/╭─ Reply -/, { timeout: 5_000 }); + const keyboardReply = await harness.pressAndWaitForText( + session, + ["shift", "r"], + /╭─ Reply -/, + { + timeout: 5_000, + }, + ); const threadedTitles = keyboardReply .split("\n") @@ -613,8 +624,9 @@ describe("PTY notes", () => { expect(draft).toContain("L2 → R1"); await session.type("Mixed replacement feedback."); - await session.type("\x13"); - const saved = await session.waitForText(/Your note/, { timeout: 5_000 }); + const saved = await harness.pressAndWaitForText(session, ["ctrl", "s"], /Your note/, { + timeout: 5_000, + }); expect(saved).toContain("L2 → R1"); expect(saved).toContain("Mixed replacement feedback."); } finally { @@ -648,8 +660,9 @@ describe("PTY notes", () => { expect(draft).toContain(body.slice(0, 10)); expect(draft).toContain(body.slice(-6)); - await session.type("\x13"); - const savedNote = await session.waitForText(/Your note/, { timeout: 5_000 }); + const savedNote = await harness.pressAndWaitForText(session, ["ctrl", "s"], /Your note/, { + timeout: 5_000, + }); expect(savedNote).toContain(body.slice(0, 10)); expect(savedNote).toContain(body.slice(-6)); } finally { From 5dfe5e5453a50d614c795005abac6326ee5f16f0 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 10 Sep 2026 15:32:34 -0400 Subject: [PATCH 05/15] Synchronize 33 PTY clicks directly on unique text destinations Result: {"status":"keep","pty_suite_ms":188144.25,"pty_reported_ms":188130,"pty_pass_count":176,"pty_skip_count":1,"slowest_test_ms":7716.47,"tests_over_3000ms":11} --- test/pty/chrome.test.ts | 38 +++++++----- test/pty/extensions-integration.test.ts | 13 +++-- test/pty/file-views-integration.test.ts | 60 ++++++++++++------- test/pty/harness.ts | 77 +++++++++++++++++++++++++ test/pty/notes.test.ts | 48 +++++++-------- 5 files changed, 170 insertions(+), 66 deletions(-) diff --git a/test/pty/chrome.test.ts b/test/pty/chrome.test.ts index 8bc2414e7..710113387 100644 --- a/test/pty/chrome.test.ts +++ b/test/pty/chrome.test.ts @@ -37,12 +37,17 @@ describe("PTY chrome", () => { const initial = await session.waitForText(/Adds bonus export\./, { timeout: 15_000 }); expect(initial).toContain("Highlights the follow-up addition for review."); - await session.click(/View/); - const viewMenu = await session.waitForText(/Themes…/, { timeout: 5_000 }); + const viewMenu = await harness.clickAndWaitForText(session, /View/, /Themes…/, { + timeout: 5_000, + }); expect(viewMenu).toContain("Themes…"); - await session.click(/Themes…/); - const themeSelector = await session.waitForText(/github-light-default/, { timeout: 5_000 }); + const themeSelector = await harness.clickAndWaitForText( + session, + /Themes…/, + /github-light-default/, + { timeout: 5_000 }, + ); expect(themeSelector).toContain("Theme selector"); await session.click(/github-light-default/); @@ -56,8 +61,10 @@ describe("PTY chrome", () => { ); expect(themeSelected).toContain("Adds bonus export."); - await session.click(/Agent/, { first: true }); - const agentMenu = await session.waitForText(/Next annotated file/, { timeout: 5_000 }); + const agentMenu = await harness.clickAndWaitForText(session, /Agent/, /Next annotated file/, { + first: true, + timeout: 5_000, + }); expect(agentMenu).toContain("Agent notes"); await session.click(/Agent notes/); @@ -67,15 +74,18 @@ describe("PTY chrome", () => { 5_000, ); - await session.click(/Agent/, { first: true }); - await session.waitForText(/Agent notes/, { timeout: 5_000 }); - await session.click(/Agent notes/); - await session.waitForText(/Adds bonus export\./, { timeout: 5_000 }); + await harness.clickAndWaitForText(session, /Agent/, /Agent notes/, { + first: true, + timeout: 5_000, + }); + await harness.clickAndWaitForText(session, /Agent notes/, /Adds bonus export\./, { + timeout: 5_000, + }); - await session.click(/Help/); - await session.waitForText(/Controls help/, { timeout: 5_000 }); - await session.click(/Controls help/); - const helpDialog = await session.waitForText(/Navigation/, { timeout: 5_000 }); + await harness.clickAndWaitForText(session, /Help/, /Controls help/, { timeout: 5_000 }); + const helpDialog = await harness.clickAndWaitForText(session, /Controls help/, /Navigation/, { + timeout: 5_000, + }); // The key column is rendered from the commands' resolved chords. expect(helpDialog).toContain("g / Home"); diff --git a/test/pty/extensions-integration.test.ts b/test/pty/extensions-integration.test.ts index bf440bb2c..9e4016546 100644 --- a/test/pty/extensions-integration.test.ts +++ b/test/pty/extensions-integration.test.ts @@ -615,8 +615,12 @@ describe("PTY extensions", () => { const menu = await session.waitForText(/Toggle fixture/, { timeout: 20_000 }); expect(menu).toMatch(/Toggle fixture\s+Y/); - await session.click(/Toggle fixture/); - const opened = await session.waitForText(/EXTSIDEBAR 2 FILES/, { timeout: 20_000 }); + const opened = await harness.clickAndWaitForText( + session, + /Toggle fixture/, + /EXTSIDEBAR 2 FILES/, + { timeout: 20_000 }, + ); expect(opened).toContain("alpha.ts"); } finally { session.close(); @@ -737,8 +741,9 @@ describe("PTY extensions", () => { ); expect(closed).toContain("alpha.ts"); - await session.click(/View/); - const menu = await session.waitForText(/Files pane/, { timeout: 20_000 }); + const menu = await harness.clickAndWaitForText(session, /View/, /Files pane/, { + timeout: 20_000, + }); expect(menu).toContain("[ ] Files pane"); await session.click(/Files pane/); diff --git a/test/pty/file-views-integration.test.ts b/test/pty/file-views-integration.test.ts index a34c34b50..2efc083d6 100644 --- a/test/pty/file-views-integration.test.ts +++ b/test/pty/file-views-integration.test.ts @@ -122,8 +122,11 @@ describe("PTY file views", () => { try { await session.waitForText(/before\.md/, { timeout: 20_000 }); - await session.click(/View/); - const menu = await session.waitForText(/File presentation: Raw diff/); + const menu = await harness.clickAndWaitForText( + session, + /View/, + /File presentation: Raw diff/, + ); expect(menu).not.toContain("File presentation: Rendered Markdown"); } finally { session.close(); @@ -230,15 +233,16 @@ describe("PTY file views", () => { try { await session.waitForText(/before\.|package\.json/, { timeout: 20_000 }); await harness.ensureKeyboardIsLive(session); - await session.click(/View/); - await session.waitForText(demo.view, { timeout: 20_000 }); + await harness.clickAndWaitForText(session, /View/, demo.view, { timeout: 20_000 }); await session.press("escape"); await harness.pressAndWaitForText(session, "f8", demo.first, { timeout: 20_000 }); await harness.pressAndWaitForText(session, "]", demo.second, { timeout: 20_000 }); - await session.click(/View/); - await session.waitForText(/File presentation: Raw diff/, { timeout: 20_000 }); - await session.click(/File presentation: Raw diff/); - await session.waitForText(demo.raw, { timeout: 20_000 }); + await harness.clickAndWaitForText(session, /View/, /File presentation: Raw diff/, { + timeout: 20_000, + }); + await harness.clickAndWaitForText(session, /File presentation: Raw diff/, demo.raw, { + timeout: 20_000, + }); } finally { session.close(); } @@ -268,10 +272,14 @@ describe("PTY file views", () => { await session.press("f8"); await session.waitForText(/--accent/, { timeout: 20_000 }); - await session.click(/package\.json/, { first: true }); - await session.waitForText(/@opentui\/core/, { timeout: 20_000 }); - await session.click(/README\.md/, { first: true }); - await session.waitForText(/understanding release changes/, { timeout: 20_000 }); + await harness.clickAndWaitForText(session, /package\.json/, /@opentui\/core/, { + first: true, + timeout: 20_000, + }); + await harness.clickAndWaitForText(session, /README\.md/, /understanding release changes/, { + first: true, + timeout: 20_000, + }); let reachedRetainedPreview = false; for (let step = 0; step < 10 && !reachedRetainedPreview; step += 1) { await session.scrollDown(8); @@ -334,11 +342,17 @@ describe("PTY file views", () => { const raw = await harness.pressAndWaitForText(session, "f8", /line60 = 6000/); expect(raw).not.toContain("Hunk 1"); - await session.click(/Extensions/); - const menu = await session.waitForText(/Toggle JSX hunk cards \(POC\)/); + const menu = await harness.clickAndWaitForText( + session, + /Extensions/, + /Toggle JSX hunk cards \(POC\)/, + ); expect(menu).toMatch(/Toggle JSX hunk cards \(POC\)\s+F8/); - await session.click(/Toggle JSX hunk cards \(POC\)/); - const menuDispatched = await session.waitForText(/▶ Hunk 2/); + const menuDispatched = await harness.clickAndWaitForText( + session, + /Toggle JSX hunk cards \(POC\)/, + /▶ Hunk 2/, + ); expect(menuDispatched).toContain("Hunk 1"); } finally { session.close(); @@ -589,8 +603,11 @@ describe("PTY file views", () => { const preview = await harness.pressAndWaitForText(session, "f8", /• new item/); expect(preview).toContain("Review the new item."); expect(preview).not.toContain("old item"); - await session.click(/View/); - const menu = await session.waitForText(/\[x\] File presentation: Rendered Markdown/); + const menu = await harness.clickAndWaitForText( + session, + /View/, + /\[x\] File presentation: Rendered Markdown/, + ); expect(menu).toContain("File presentation: Raw diff"); } finally { session.close(); @@ -625,8 +642,11 @@ describe("PTY file views", () => { await session.press("f8"); const raw = await session.waitForText(/old item/); expect(raw).not.toContain("• new item"); - await session.click(/View/); - await session.waitForText(/\[x\] File presentation: Rendered Markdown/); + await harness.clickAndWaitForText( + session, + /View/, + /\[x\] File presentation: Rendered Markdown/, + ); await session.press("escape"); const restored = await harness.pressAndWaitForText(session, "a", /• new item/); diff --git a/test/pty/harness.ts b/test/pty/harness.ts index 8ac68d37a..a78a28ca0 100644 --- a/test/pty/harness.ts +++ b/test/pty/harness.ts @@ -1151,6 +1151,81 @@ end return waitForSnapshot(session, predicate, timeoutMs); } + /** Send one click without waiting when a destination predicate will own readiness. */ + function sendClick( + session: Pick, + pattern: Parameters[0], + first = false, + ) { + const regex = + typeof pattern === "string" + ? new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g") + : new RegExp( + pattern.source, + pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`, + ); + const matches: { x: number; y: number }[] = []; + + for (const [y, line] of session.getTerminalData().lines.entries()) { + const text = line.spans.map((span) => span.text).join(""); + regex.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = regex.exec(text)) !== null) { + matches.push({ x: match.index, y }); + if (match[0].length === 0) regex.lastIndex += 1; + } + } + + if (matches.length === 0) { + throw new Error(`sendClick: ${String(pattern)} is not visible.`); + } + if (matches.length > 1 && !first) { + throw new Error(`sendClick: ${String(pattern)} has ${matches.length} visible matches.`); + } + + const target = matches[0]!; + const x = target.x + 1; + const y = target.y + 1; + session.writeRaw(`\x1b[<0;${x};${y}M`); + session.writeRaw(`\x1b[<0;${x};${y}m`); + } + + /** Click visible text and wait for destination text that was absent beforehand. */ + async function clickAndWaitForText( + session: Pick, + target: Parameters[0], + destination: Parameters[0], + options: { first?: boolean; timeout?: number } = {}, + ) { + const before = await session.text({ immediate: true }); + const matchedBefore = + typeof destination === "string" + ? before.includes(destination) + : new RegExp(destination.source, destination.flags.replace(/[gy]/g, "")).test(before); + if (matchedBefore) { + throw new Error("clickAndWaitForText: destination was visible before the click."); + } + + sendClick(session, target, options.first); + return session.waitForText(destination, { timeout: options.timeout }); + } + + /** Click visible text and wait for a unique destination snapshot. */ + async function clickAndWaitForSnapshot( + session: Pick, + target: Parameters[0], + predicate: (text: string) => boolean, + options: { first?: boolean; timeout?: number } = {}, + ) { + const before = await session.text({ immediate: true }); + if (predicate(before)) { + throw new Error("clickAndWaitForSnapshot: destination was visible before the click."); + } + + sendClick(session, target, options.first); + return waitForSnapshot(session, predicate, options.timeout); + } + /** Send one key and wait for text that was absent before the transition. */ async function pressAndWaitForText( session: Pick, @@ -1239,6 +1314,8 @@ end launchHunkWithFileBackedStdin, launchShellCommand, buildHunkCommand, + clickAndWaitForSnapshot, + clickAndWaitForText, shellQuote, pressAndWaitForSnapshot, pressAndWaitForText, diff --git a/test/pty/notes.test.ts b/test/pty/notes.test.ts index ca1044160..6431368e7 100644 --- a/test/pty/notes.test.ts +++ b/test/pty/notes.test.ts @@ -498,8 +498,9 @@ describe("PTY notes", () => { expect(rootBottomBorder?.trimEnd().endsWith("╯")).toBe(true); const rootRowBeforeEdit = lineIndexOf(hoveredRoot, "Root review note."); - await session.click(/edit/); - const editing = await session.waitForText(/Edit note/, { timeout: 5_000 }); + const editing = await harness.clickAndWaitForText(session, /edit/, /Edit note/, { + timeout: 5_000, + }); expect(editing).toContain("Root review note."); expect(lineIndexOf(editing, "Root review note.")).toBe(rootRowBeforeEdit); await session.type("Updated. "); @@ -542,8 +543,7 @@ describe("PTY notes", () => { 5_000, ); await selectNoteByBody("First reply."); - await session.click(/R reply/); - await session.waitForText(/╰─╭─ Reply -/, { timeout: 5_000 }); + await harness.clickAndWaitForText(session, /R reply/, /╰─╭─ Reply -/, { timeout: 5_000 }); await session.type("Nested reply."); await session.waitForText(/Nested reply\./, { timeout: 5_000 }); await session.type("\x13"); @@ -552,8 +552,9 @@ describe("PTY notes", () => { expect(nested).toMatch(/╰─╭─ ● Your note/); await selectNoteByBody("Updated. Root review note."); - await session.click(/R reply/); - const siblingDraft = await session.waitForText(/╰─╭─ Reply -/, { timeout: 5_000 }); + const siblingDraft = await harness.clickAndWaitForText(session, /R reply/, /╰─╭─ Reply -/, { + timeout: 5_000, + }); expect(siblingDraft).toMatch(/├─╭─ Your note/); expect(siblingDraft).toMatch(/│ ╰─╭─ Your note/); await session.click(/Esc cancel/); @@ -827,8 +828,7 @@ describe("PTY notes", () => { }); await revealAddNoteAffordance(session, 8, [4, 5]); - await session.click(/\[\+\]/); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.clickAndWaitForText(session, /\[\+\]/, /Draft note/, { timeout: 5_000 }); await session.type("Cancel this shortcut draft."); await session.type("\x1b"); const cancelled = await harness.waitForSnapshot( @@ -840,8 +840,7 @@ describe("PTY notes", () => { expect(cancelled).not.toContain("Your note"); await revealAddNoteAffordance(session, 8, [4, 5]); - await session.click(/\[\+\]/); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.clickAndWaitForText(session, /\[\+\]/, /Draft note/, { timeout: 5_000 }); await session.type("Save this shortcut draft."); const saved = await harness.pressAndWaitForText(session, ["ctrl", "s"], /Your note/, { timeout: 5_000, @@ -869,8 +868,7 @@ describe("PTY notes", () => { expect(targetRow).toBeGreaterThan(0); await revealAddNoteNear(session, targetRow); - await session.click(/\[\+\]/); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.clickAndWaitForText(session, /\[\+\]/, /Draft note/, { timeout: 5_000 }); await session.type("Save this unified draft."); const saved = await harness.pressAndWaitForText(session, ["ctrl", "s"], /Your note/, { timeout: 5_000, @@ -898,8 +896,7 @@ describe("PTY notes", () => { expect(targetRow).toBeGreaterThan(0); await revealAddNoteNear(session, targetRow); - await session.click(/\[\+\]/); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.clickAndWaitForText(session, /\[\+\]/, /Draft note/, { timeout: 5_000 }); await session.type("Save this deletion draft."); const saved = await harness.pressAndWaitForText(session, ["ctrl", "s"], /Your note/, { timeout: 5_000, @@ -932,8 +929,7 @@ describe("PTY notes", () => { const beforeDraft = await session.text({ immediate: true }); const clickedRowBefore = lineIndexOf(beforeDraft, "keep = true"); await revealAddNoteOnRow(session, clickedRowBefore); - await session.click(/\[\+\]/); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.clickAndWaitForText(session, /\[\+\]/, /Draft note/, { timeout: 5_000 }); await sleep(100); const withDraft = await session.text({ immediate: true }); expect(lineIndexOf(withDraft, "keep = true")).toBe(clickedRowBefore); @@ -1012,8 +1008,7 @@ describe("PTY notes", () => { expect(initial).not.toMatch(sidebarRow); await revealAddNoteNear(session, targetRow); - await session.click(/\[\+\]/); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.clickAndWaitForText(session, /\[\+\]/, /Draft note/, { timeout: 5_000 }); await session.type("sidebar-trigger text"); const whileFocused = await harness.waitForSnapshot( session, @@ -1054,8 +1049,7 @@ describe("PTY notes", () => { expect(contextRow).toBeGreaterThan(0); await revealAddNoteOnRow(session, contextRow); - await session.click(/\[\+\]/); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.clickAndWaitForText(session, /\[\+\]/, /Draft note/, { timeout: 5_000 }); await session.type("First note on the context row."); await session.press(["ctrl", "s"]); const firstSaved = await session.waitForText(/First note on the context row\./, { @@ -1065,8 +1059,7 @@ describe("PTY notes", () => { expect(deletionRow).toBeGreaterThan(0); await revealAddNoteNear(session, deletionRow); - await session.click(/\[\+\]/); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.clickAndWaitForText(session, /\[\+\]/, /Draft note/, { timeout: 5_000 }); await session.type("Second note on the deletion row."); await session.press(["ctrl", "s"]); const secondSaved = await session.waitForText(/Second note on the deletion row\./, { @@ -1093,8 +1086,7 @@ describe("PTY notes", () => { }); await revealAddNoteAffordance(session, 8, [4, 5]); - await session.click(/\[\+\]/); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.clickAndWaitForText(session, /\[\+\]/, /Draft note/, { timeout: 5_000 }); await session.type("Cancel this draft."); await session.click(/Esc cancel/); const cancelled = await harness.waitForSnapshot( @@ -1106,11 +1098,11 @@ describe("PTY notes", () => { expect(cancelled).not.toContain("Your note"); await revealAddNoteAffordance(session, 8, [4, 5]); - await session.click(/\[\+\]/); - await session.waitForText(/Draft note/, { timeout: 5_000 }); + await harness.clickAndWaitForText(session, /\[\+\]/, /Draft note/, { timeout: 5_000 }); await session.type("Save this clicked draft."); - await session.click(/\^S save/); - const saved = await session.waitForText(/Your note/, { timeout: 5_000 }); + const saved = await harness.clickAndWaitForText(session, /\^S save/, /Your note/, { + timeout: 5_000, + }); expect(saved).toContain("Save this clicked draft."); } finally { From 18c3c851b1d88f3457d86bb41f03b3d9168fac3d Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 10 Sep 2026 15:44:43 -0400 Subject: [PATCH 06/15] Remove redundant second idle wait from layout-position key loop Result: {"status":"keep","pty_suite_ms":185210.1,"pty_reported_ms":185200,"pty_pass_count":176,"pty_skip_count":1,"slowest_test_ms":7727.25,"tests_over_3000ms":10} --- test/pty/layout.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/pty/layout.test.ts b/test/pty/layout.test.ts index 75c3b9232..5bc4e0daf 100644 --- a/test/pty/layout.test.ts +++ b/test/pty/layout.test.ts @@ -741,7 +741,6 @@ describe("PTY layout", () => { let anchored = initial; for (let index = 0; index < 24; index += 1) { await session.press("down"); - await session.waitIdle({ timeout: 200 }); anchored = await session.text({ immediate: true }); if (anchored.includes("line08 = 108") && !anchored.includes("line01 = 101")) { break; From 2cfff49b43a0e2c70b7a1dcc5d061d223d30a7fc Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 10 Sep 2026 15:48:40 -0400 Subject: [PATCH 07/15] Send horizontal held-key input in bounded eight-key bursts Result: {"status":"keep","pty_suite_ms":178810.81,"pty_reported_ms":178800,"pty_pass_count":176,"pty_skip_count":1,"slowest_test_ms":7713.9,"tests_over_3000ms":8} --- test/pty/layout.test.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/test/pty/layout.test.ts b/test/pty/layout.test.ts index 5bc4e0daf..3792388af 100644 --- a/test/pty/layout.test.ts +++ b/test/pty/layout.test.ts @@ -11,6 +11,13 @@ afterEach(() => { harness.cleanup(); }); +const horizontalRepeatBatchSize = 8; + +/** Build one bounded burst matching repeated horizontal input from a held key. */ +function horizontalKeyRepeat(key: "left" | "right") { + return Array.from({ length: horizontalRepeatBatchSize }, () => key); +} + /** Locate the left pane divider from a rendered terminal frame. */ function sidebarDividerColumn(frame: string) { const columns = frame @@ -792,10 +799,9 @@ describe("PTY layout", () => { expect(initial).not.toContain("ge';"); let shifted = initial; - for (let index = 0; index < 96; index += 1) { - await session.press("right"); - // press() already waits for idle, so read the settled frame immediately rather than - // paying another render round-trip per column; the loop retries if a frame lags. + for (let index = 0; index < 96; index += horizontalRepeatBatchSize) { + await session.press(horizontalKeyRepeat("right")); + // Held keys arrive in bursts. Settle each bounded burst and retry if its frame lags. shifted = await session.text({ immediate: true }); if (shifted.includes("ge';")) { break; @@ -806,8 +812,8 @@ describe("PTY layout", () => { expect(shifted).not.toContain("this is a very long"); let restored = shifted; - for (let index = 0; index < 96; index += 1) { - await session.press("left"); + for (let index = 0; index < 96; index += horizontalRepeatBatchSize) { + await session.press(horizontalKeyRepeat("left")); restored = await session.text({ immediate: true }); if (restored.includes("this is a very long") && !restored.includes("ge';")) { break; @@ -872,9 +878,8 @@ describe("PTY layout", () => { expect(initial).not.toContain("ge';"); let shifted = initial; - for (let index = 0; index < 96; index += 1) { - await session.press("right"); - // press() already waits for idle; read immediately to avoid a redundant settle per column. + for (let index = 0; index < 96; index += horizontalRepeatBatchSize) { + await session.press(horizontalKeyRepeat("right")); shifted = await session.text({ immediate: true }); if (shifted.includes("ge';")) { break; From e824fa9baf9368172060ad9cde29755d9bf2ac82 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 10 Sep 2026 15:56:00 -0400 Subject: [PATCH 08/15] Remove redundant second idle waits from three repeated-key loops Result: {"status":"keep","pty_suite_ms":176492.9,"pty_reported_ms":176480,"pty_pass_count":176,"pty_skip_count":1,"slowest_test_ms":7723.4,"tests_over_3000ms":8} --- test/pty/cursor-line.test.ts | 1 - test/pty/key-routing.test.ts | 1 - test/pty/scroll.test.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/test/pty/cursor-line.test.ts b/test/pty/cursor-line.test.ts index c1bf5a0fa..a3a4209b0 100644 --- a/test/pty/cursor-line.test.ts +++ b/test/pty/cursor-line.test.ts @@ -433,7 +433,6 @@ describe("PTY current line", () => { for (let step = 0; step < 4; step += 1) { await session.press("j"); - await session.waitIdle({ timeout: 200 }); } const draftAtCursor = await harness.pressAndWaitForText(session, "c", /Draft note/, { diff --git a/test/pty/key-routing.test.ts b/test/pty/key-routing.test.ts index 96c97079f..3a82abe50 100644 --- a/test/pty/key-routing.test.ts +++ b/test/pty/key-routing.test.ts @@ -212,7 +212,6 @@ describe("PTY key routing", () => { for (let press = 0; press < 3; press += 1) { await session.press("down"); - await session.waitIdle({ timeout: 300 }); } const afterArrows = await session.text({ immediate: true }); diff --git a/test/pty/scroll.test.ts b/test/pty/scroll.test.ts index 6da024443..ac4999647 100644 --- a/test/pty/scroll.test.ts +++ b/test/pty/scroll.test.ts @@ -36,7 +36,6 @@ describe("PTY scrolling", () => { for (let iteration = 0; iteration < 4; iteration += 1) { await session.press("up"); - await session.waitIdle({ timeout: 200 }); } const movedUp = await harness.waitForSnapshot( From 3c861554381dc71343c293350617a76003e839ca Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 10 Sep 2026 16:00:14 -0400 Subject: [PATCH 09/15] Remove six remaining duplicate post-key idle waits Result: {"status":"keep","pty_suite_ms":174952.26,"pty_reported_ms":174940,"pty_pass_count":176,"pty_skip_count":1,"slowest_test_ms":8221.26,"tests_over_3000ms":8} --- test/pty/file-views-integration.test.ts | 4 +--- test/pty/key-routing.test.ts | 2 -- test/pty/nav.test.ts | 1 - test/pty/pager.test.ts | 1 - 4 files changed, 1 insertion(+), 7 deletions(-) diff --git a/test/pty/file-views-integration.test.ts b/test/pty/file-views-integration.test.ts index 2efc083d6..f74c42c28 100644 --- a/test/pty/file-views-integration.test.ts +++ b/test/pty/file-views-integration.test.ts @@ -177,7 +177,6 @@ describe("PTY file views", () => { (text) => !text.includes("File presentation:"), ); await session.press("]"); - await session.waitIdle(); } finally { session.close(); rmSync(pair.directory, { recursive: true, force: true }); @@ -476,8 +475,7 @@ describe("PTY file views", () => { // The command table owns the keyboard again: `z` no longer types. await session.press("z"); - await session.waitIdle(); - const afterExit = await session.text(); + const afterExit = await session.text({ immediate: true }); expect(afterExit).toContain("zzzexport const alpha = 2;"); expect(afterExit).not.toContain("zzzz"); } finally { diff --git a/test/pty/key-routing.test.ts b/test/pty/key-routing.test.ts index 3a82abe50..1c64024ea 100644 --- a/test/pty/key-routing.test.ts +++ b/test/pty/key-routing.test.ts @@ -247,7 +247,6 @@ describe("PTY key routing", () => { // The review's down key moves the selector and must not reach the focused scroll box. await session.press("j"); - await session.waitIdle({ timeout: 400 }); const selectedBefore = selectorOpen.split("\n").find((line) => line.includes("›")); const afterKey = await session.text({ immediate: true }); @@ -312,7 +311,6 @@ describe("PTY key routing", () => { expect(anchorText.length).toBeGreaterThan(0); await session.press("j"); - await session.waitIdle({ timeout: 300 }); const afterKey = await session.text({ immediate: true }); expect(lineIndexOf(afterKey, anchorText)).toBe(anchorRow); diff --git a/test/pty/nav.test.ts b/test/pty/nav.test.ts index 610d8eb86..c1abadb73 100644 --- a/test/pty/nav.test.ts +++ b/test/pty/nav.test.ts @@ -120,7 +120,6 @@ describe("PTY navigation", () => { } await session.press("["); - await session.waitIdle({ timeout: 80 }); const backward = await harness.pressAndWaitForSnapshot( session, "[", diff --git a/test/pty/pager.test.ts b/test/pty/pager.test.ts index 2040fb099..55c2cfeb0 100644 --- a/test/pty/pager.test.ts +++ b/test/pty/pager.test.ts @@ -158,7 +158,6 @@ describe("PTY pager", () => { expect(initial).toContain("alpha.ts"); await session.press("q"); - await session.waitIdle({ timeout: 500 }); } finally { session.close(); } From 072b82f5a25b1e52762bc6b0eaca577aaef84bae Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 10 Sep 2026 16:17:23 -0400 Subject: [PATCH 10/15] Send five assertion-free repeated-key loops as bounded bursts Result: {"status":"keep","pty_suite_ms":171895.54,"pty_reported_ms":171880,"pty_pass_count":176,"pty_skip_count":1,"slowest_test_ms":7721.06,"tests_over_3000ms":8} --- test/pty/cursor-line.test.ts | 5 ++--- test/pty/harness.ts | 5 +++++ test/pty/key-routing.test.ts | 6 ++---- test/pty/nav.test.ts | 6 ++---- test/pty/notes.test.ts | 9 +++------ test/pty/scroll.test.ts | 12 ++++++++---- 6 files changed, 22 insertions(+), 21 deletions(-) diff --git a/test/pty/cursor-line.test.ts b/test/pty/cursor-line.test.ts index a3a4209b0..69e1523dc 100644 --- a/test/pty/cursor-line.test.ts +++ b/test/pty/cursor-line.test.ts @@ -6,6 +6,7 @@ import { dragMouse, lineIndexOf, measureKeyScroll, + pressKeyRepeat, rowCellBackgrounds, sleep, } from "./harness"; @@ -431,9 +432,7 @@ describe("PTY current line", () => { 5_000, ); - for (let step = 0; step < 4; step += 1) { - await session.press("j"); - } + await pressKeyRepeat(session, "j", 4); const draftAtCursor = await harness.pressAndWaitForText(session, "c", /Draft note/, { timeout: 5_000, diff --git a/test/pty/harness.ts b/test/pty/harness.ts index a78a28ca0..fb1452200 100644 --- a/test/pty/harness.ts +++ b/test/pty/harness.ts @@ -64,6 +64,11 @@ export function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } +/** Send a bounded burst that models repeated delivery from one held key. */ +export function pressKeyRepeat(session: Pick, key: Key, count: number) { + return session.press(Array.from({ length: count }, () => key)); +} + /** * Count how many rows one keypress moved the stream by following the text that * sat on a fixed screen row. diff --git a/test/pty/key-routing.test.ts b/test/pty/key-routing.test.ts index 1c64024ea..7be532ecc 100644 --- a/test/pty/key-routing.test.ts +++ b/test/pty/key-routing.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { createPtyHarness, lineIndexOf, sleep } from "./harness"; +import { createPtyHarness, lineIndexOf, pressKeyRepeat, sleep } from "./harness"; const harness = createPtyHarness(); @@ -210,9 +210,7 @@ describe("PTY key routing", () => { const anchorText = menuOpen.split("\n")[anchorRow]?.trim() ?? ""; expect(anchorText.length).toBeGreaterThan(0); - for (let press = 0; press < 3; press += 1) { - await session.press("down"); - } + await pressKeyRepeat(session, "down", 3); const afterArrows = await session.text({ immediate: true }); expect(lineIndexOf(afterArrows, anchorText)).toBe(anchorRow); diff --git a/test/pty/nav.test.ts b/test/pty/nav.test.ts index c1abadb73..f7d468abd 100644 --- a/test/pty/nav.test.ts +++ b/test/pty/nav.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { createPtyHarness } from "./harness"; +import { createPtyHarness, pressKeyRepeat } from "./harness"; const harness = createPtyHarness(); @@ -271,9 +271,7 @@ describe("PTY navigation", () => { expect(initial).toContain("first.ts"); expect(initial).toContain("second.ts"); - for (let index = 0; index < 16; index += 1) { - await session.press("down"); - } + await pressKeyRepeat(session, "down", 16); const scrolled = await harness.waitForSnapshot( session, diff --git a/test/pty/notes.test.ts b/test/pty/notes.test.ts index 6431368e7..03377a231 100644 --- a/test/pty/notes.test.ts +++ b/test/pty/notes.test.ts @@ -6,6 +6,7 @@ import { dragMouse, lineIndexOf, moveMouse, + pressKeyRepeat, revealAddNoteAffordance, revealAddNoteNear, revealAddNoteOnRow, @@ -141,9 +142,7 @@ describe("PTY notes", () => { await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); await session.waitIdle({ timeout: 500 }); - for (let index = 0; index < 8; index += 1) { - await session.press("down"); - } + await pressKeyRepeat(session, "down", 8); const beforePushedDraft = await session.text({ immediate: true }); const firstActiveLine = "export const line09 = 9;"; @@ -168,9 +167,7 @@ describe("PTY notes", () => { (text) => !text.includes("Draft note"), 5_000, ); - for (let index = 0; index < 8; index += 1) { - await session.press("down"); - } + await pressKeyRepeat(session, "down", 8); const beforeBottomDraft = await session.text({ immediate: true }); const bottomActiveLine = "export const line17 = 17;"; diff --git a/test/pty/scroll.test.ts b/test/pty/scroll.test.ts index ac4999647..eaeffc6dc 100644 --- a/test/pty/scroll.test.ts +++ b/test/pty/scroll.test.ts @@ -1,5 +1,11 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { createPtyHarness, dragMouse, measureKeyScroll, measureMouseWheelScroll } from "./harness"; +import { + createPtyHarness, + dragMouse, + measureKeyScroll, + measureMouseWheelScroll, + pressKeyRepeat, +} from "./harness"; const harness = createPtyHarness(); @@ -34,9 +40,7 @@ describe("PTY scrolling", () => { expect(bottomAligned).not.toContain("line30 = 130"); - for (let iteration = 0; iteration < 4; iteration += 1) { - await session.press("up"); - } + await pressKeyRepeat(session, "up", 4); const movedUp = await harness.waitForSnapshot( session, From c7b49fe9cb4c95fff730fd4e14be4c5151a76712 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 10 Sep 2026 16:48:45 -0400 Subject: [PATCH 11/15] Confirm and retain bounded shifted-wheel event bursts Result: {"status":"keep","pty_suite_ms":171351.89,"pty_reported_ms":171340,"pty_pass_count":176,"pty_skip_count":1,"slowest_test_ms":8223.49,"tests_over_3000ms":8} --- test/pty/layout.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/pty/layout.test.ts b/test/pty/layout.test.ts index 3792388af..0d2b56303 100644 --- a/test/pty/layout.test.ts +++ b/test/pty/layout.test.ts @@ -844,9 +844,10 @@ describe("PTY layout", () => { expect(initial).not.toContain("ge';"); let shifted = initial; - for (let index = 0; index < 96; index += 1) { - // SGR button 69 is a wheel-down event with the Shift modifier. - session.writeRaw("\x1b[<69;61;11M"); + for (let index = 0; index < 96; index += horizontalRepeatBatchSize) { + // SGR button 69 is a wheel-down event with the Shift modifier. Real wheel input arrives + // in bursts, so settle the same bounded batch used for held horizontal arrow keys. + session.writeRaw("\x1b[<69;61;11M".repeat(horizontalRepeatBatchSize)); await session.waitIdle(); shifted = await session.text({ immediate: true }); if (shifted.includes("ge';")) { From 921c8206a3f1417b9b48b6babb4c6daa9631ef74 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 10 Sep 2026 16:52:40 -0400 Subject: [PATCH 12/15] Synchronize keyboard-live probes directly on help visibility Result: {"status":"keep","pty_suite_ms":168603.75,"pty_reported_ms":168590,"pty_pass_count":176,"pty_skip_count":1,"slowest_test_ms":7719.79,"tests_over_3000ms":8} --- test/pty/harness.ts | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/test/pty/harness.ts b/test/pty/harness.ts index fb1452200..bc61b7db4 100644 --- a/test/pty/harness.ts +++ b/test/pty/harness.ts @@ -1266,15 +1266,28 @@ end * test actually cares about — meaningful. */ async function ensureKeyboardIsLive(session: Session) { + const closeHelp = async () => { + session.sendKey("escape"); + await session.text({ + timeout: 5_000, + waitFor: (text) => !text.includes("Controls help"), + }); + }; + for (let attempt = 0; attempt < 5; attempt += 1) { - await session.press("?"); + const before = await session.text({ immediate: true }); + if (before.includes("Controls help")) { + await closeHelp(); + return; + } + + session.sendKey("?"); 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); + await session.waitForText(/Controls help/, { timeout: 2_000 }); + await closeHelp(); return; } catch { - // Dropped before the app was listening; the next press is the retry. + // Dropped before the app was listening; a delayed help frame is closed on the next pass. } } From e7c2e6b2778b7c49f467789d144c29a6a4dcbd78 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 10 Sep 2026 17:00:34 -0400 Subject: [PATCH 13/15] Probe retained file-view readiness only after its file enters the stream Result: {"status":"keep","pty_suite_ms":164754.34,"pty_reported_ms":164740,"pty_pass_count":176,"pty_skip_count":1,"slowest_test_ms":7727.59,"tests_over_3000ms":7} --- test/pty/file-views-integration.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/pty/file-views-integration.test.ts b/test/pty/file-views-integration.test.ts index f74c42c28..66bc276ca 100644 --- a/test/pty/file-views-integration.test.ts +++ b/test/pty/file-views-integration.test.ts @@ -282,11 +282,17 @@ describe("PTY file views", () => { let reachedRetainedPreview = false; for (let step = 0; step < 10 && !reachedRetainedPreview; step += 1) { await session.scrollDown(8); + const frame = await session.text({ immediate: true }); + // The sidebar always names package.json once. Wait for its retained preview only after a + // second occurrence proves the main stream has mounted that file below the tall README. + if (harness.countMatches(frame, /package\.json/g) < 2) { + continue; + } try { await session.waitForText(/Package metadata hunk 1/, { timeout: 750 }); reachedRetainedPreview = true; } catch { - // Continue through the intentionally tall raw README until the retained preview appears. + // The file header can enter first; keep scrolling until its retained preview is visible. } } expect(reachedRetainedPreview).toBe(true); From acb28f9ceb4dbe2b646c577f77ec12075938b202 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 10 Sep 2026 17:18:07 -0400 Subject: [PATCH 14/15] Stop checking for never-created daemon metadata after signal-test exit Result: {"status":"keep","pty_suite_ms":158876.9,"pty_reported_ms":158870,"pty_pass_count":176,"pty_skip_count":1,"slowest_test_ms":8221.56,"tests_over_3000ms":7} --- test/pty/lifecycle.test.ts | 39 ++++++++++++++++---------------------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/test/pty/lifecycle.test.ts b/test/pty/lifecycle.test.ts index 98132e4ef..dc00410be 100644 --- a/test/pty/lifecycle.test.ts +++ b/test/pty/lifecycle.test.ts @@ -57,32 +57,25 @@ async function waitForExitCode(path: string, timeoutMs = 2_000) { throw new Error(`Timed out waiting for an exit code in ${path}.`); } -async function stopDaemonsUnder(runtimeDir: string) { +/** Stop broker daemons already launched by an exited app in its isolated runtime. */ +function stopDaemonsUnder(runtimeDir: string) { const daemonDir = join(runtimeDir, "hunk-mcp"); - const deadline = Date.now() + 2_000; + if (!existsSync(daemonDir)) return; - while (Date.now() < deadline) { - if (existsSync(daemonDir)) { - const metadataFiles = readdirSync(daemonDir).filter( - (entry) => entry.startsWith("daemon-") && entry.endsWith(".json"), - ); - if (metadataFiles.length > 0) { - for (const entry of metadataFiles) { - try { - const { pid } = JSON.parse(readFileSync(join(daemonDir, entry), "utf8")) as { - pid?: number; - }; - if (pid && pid > 0) { - process.kill(pid, "SIGTERM"); - } - } catch { - // Partially written metadata, or a daemon that already exited. - } - } - return; + const metadataFiles = readdirSync(daemonDir).filter( + (entry) => entry.startsWith("daemon-") && entry.endsWith(".json"), + ); + for (const entry of metadataFiles) { + try { + const { pid } = JSON.parse(readFileSync(join(daemonDir, entry), "utf8")) as { + pid?: number; + }; + if (pid && pid > 0) { + process.kill(pid, "SIGTERM"); } + } catch { + // Ignore partially written metadata, or a daemon that already exited. } - await Bun.sleep(25); } } @@ -305,7 +298,7 @@ describe("PTY lifecycle", () => { } finally { closeMaster(); await stopChild(child); - await stopDaemonsUnder(runtimeDir); + stopDaemonsUnder(runtimeDir); } }); } From 9167bd4cf42ab95d1a64212cdde24d9f752b05c6 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 13 Sep 2026 09:49:17 -0400 Subject: [PATCH 15/15] test(pty): harden transition synchronization --- test/pty/file-views-integration.test.ts | 4 ++- test/pty/harness.test.ts | 37 ++++++++++++++++++++++++- test/pty/harness.ts | 27 +++++++++++++++--- test/pty/key-routing.test.ts | 10 +++++-- test/pty/layout.test.ts | 1 + test/pty/pager.test.ts | 1 + 6 files changed, 71 insertions(+), 9 deletions(-) diff --git a/test/pty/file-views-integration.test.ts b/test/pty/file-views-integration.test.ts index 66bc276ca..945f39d71 100644 --- a/test/pty/file-views-integration.test.ts +++ b/test/pty/file-views-integration.test.ts @@ -177,6 +177,7 @@ describe("PTY file views", () => { (text) => !text.includes("File presentation:"), ); await session.press("]"); + await session.waitIdle(); } finally { session.close(); rmSync(pair.directory, { recursive: true, force: true }); @@ -481,7 +482,8 @@ describe("PTY file views", () => { // The command table owns the keyboard again: `z` no longer types. await session.press("z"); - const afterExit = await session.text({ immediate: true }); + await session.waitIdle(); + const afterExit = await session.text(); expect(afterExit).toContain("zzzexport const alpha = 2;"); expect(afterExit).not.toContain("zzzz"); } finally { diff --git a/test/pty/harness.test.ts b/test/pty/harness.test.ts index a12081556..d33622b63 100644 --- a/test/pty/harness.test.ts +++ b/test/pty/harness.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { Session } from "tuistory"; -import { createPtyHarness } from "./harness"; +import { createPtyHarness, revealAddNoteOnRow } from "./harness"; /** Simulate a key whose first output-idle cycle ends before its destination is painted. */ function createTestTransitionSession(screens: string[]) { @@ -24,6 +24,35 @@ function createTestTransitionSession(screens: string[]) { return { inputs, session }; } +/** Simulate a stale badge disappearing only after the current mouse move produces output. */ +function createTestHoverSession() { + const inputs: string[] = []; + let screen = "stale [+]"; + let resolveData: (() => void) | undefined; + const session: Pick = { + waitForData() { + return new Promise((resolve) => { + resolveData = resolve; + }); + }, + writeRaw(input) { + inputs.push(input); + screen = inputs.length === 1 ? "current row without badge" : "current row [+]"; + resolveData?.(); + resolveData = undefined; + }, + async waitIdle() {}, + getTerminalData() { + const lines: Array<{ spans: Array<{ text: string }> }> = Array.from({ length: 7 }, () => ({ + spans: [], + })); + lines[6] = { spans: [{ text: screen }] }; + return { lines } as unknown as ReturnType; + }, + }; + return { inputs, session }; +} + describe("PTY transition synchronization", () => { test("waits past shared content until the destination is visible", async () => { const { session, inputs } = createTestTransitionSession([ @@ -78,4 +107,10 @@ describe("PTY transition synchronization", () => { ); expect(inputs).toEqual([]); }); + + test("does not accept a hover badge left by the preceding mouse position", async () => { + const { session, inputs } = createTestHoverSession(); + await revealAddNoteOnRow(session as Session, 6); + expect(inputs).toHaveLength(2); + }); }); diff --git a/test/pty/harness.ts b/test/pty/harness.ts index bc61b7db4..f565e23af 100644 --- a/test/pty/harness.ts +++ b/test/pty/harness.ts @@ -203,6 +203,17 @@ export function lineIndexOf(text: string, needle: string) { return text.split("\n").findIndex((line) => line.includes(needle)); } +/** Match text rendered on the terminal row targeted by a raw mouse event. */ +function terminalRowIncludes(session: Session, row: number, needle: string) { + const line = session.getTerminalData().lines[row]; + return ( + line?.spans + .map((span) => span.text) + .join("") + .includes(needle) === true + ); +} + /** Move near a rendered row until the hover-only add-note control appears. */ export async function revealAddNoteNear(session: Session, row: number) { for (const y of [row, row - 1, row + 1]) { @@ -211,10 +222,14 @@ export async function revealAddNoteNear(session: Session, row: number) { } for (const x of [8, 20, 60]) { + const rendered = session.waitForData({ timeout: 200 }); sendMouseMove(session, x, y); try { - await session.waitForText(/\[\+\]/, { timeout: 200 }); - return; + await rendered; + await session.waitIdle({ timeout: 200 }); + if (terminalRowIncludes(session, y, "[+]")) { + return; + } } catch { // Try nearby cells; PTY snapshots and wrapped rows can differ by a column or row. } @@ -227,10 +242,14 @@ export async function revealAddNoteNear(session: Session, row: number) { /** Reveal the add-note control without falling back to adjacent rows. */ export async function revealAddNoteOnRow(session: Session, row: number) { for (const x of [8, 20, 60]) { + const rendered = session.waitForData({ timeout: 200 }); sendMouseMove(session, x, row); try { - await session.waitForText(/\[\+\]/, { timeout: 200 }); - return; + await rendered; + await session.waitIdle({ timeout: 200 }); + if (terminalRowIncludes(session, row, "[+]")) { + return; + } } catch { // Try nearby columns on the same rendered row, but do not mask row-target regressions. } diff --git a/test/pty/key-routing.test.ts b/test/pty/key-routing.test.ts index 7be532ecc..0f381f1d3 100644 --- a/test/pty/key-routing.test.ts +++ b/test/pty/key-routing.test.ts @@ -244,10 +244,13 @@ describe("PTY key routing", () => { expect(anchorText.length).toBeGreaterThan(0); // The review's down key moves the selector and must not reach the focused scroll box. - await session.press("j"); - const selectedBefore = selectorOpen.split("\n").find((line) => line.includes("›")); - const afterKey = await session.text({ immediate: true }); + const afterKey = await harness.pressAndWaitForSnapshot( + session, + "j", + (text) => text.split("\n").find((line) => line.includes("›")) !== selectedBefore, + 5_000, + ); const selectedAfter = afterKey.split("\n").find((line) => line.includes("›")); expect(lineIndexOf(afterKey, anchorText)).toBe(anchorRow); expect(afterKey).toContain("Theme selector"); @@ -309,6 +312,7 @@ describe("PTY key routing", () => { expect(anchorText.length).toBeGreaterThan(0); await session.press("j"); + await session.waitIdle({ timeout: 300 }); const afterKey = await session.text({ immediate: true }); expect(lineIndexOf(afterKey, anchorText)).toBe(anchorRow); diff --git a/test/pty/layout.test.ts b/test/pty/layout.test.ts index 0d2b56303..6cde67a97 100644 --- a/test/pty/layout.test.ts +++ b/test/pty/layout.test.ts @@ -748,6 +748,7 @@ describe("PTY layout", () => { let anchored = initial; for (let index = 0; index < 24; index += 1) { await session.press("down"); + await session.waitIdle({ timeout: 200 }); anchored = await session.text({ immediate: true }); if (anchored.includes("line08 = 108") && !anchored.includes("line01 = 101")) { break; diff --git a/test/pty/pager.test.ts b/test/pty/pager.test.ts index 55c2cfeb0..2040fb099 100644 --- a/test/pty/pager.test.ts +++ b/test/pty/pager.test.ts @@ -158,6 +158,7 @@ describe("PTY pager", () => { expect(initial).toContain("alpha.ts"); await session.press("q"); + await session.waitIdle({ timeout: 500 }); } finally { session.close(); }