diff --git a/.changeset/hip-moments-end.md b/.changeset/hip-moments-end.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/hip-moments-end.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/src/session/broker/brokerClient.test.ts b/src/session/broker/brokerClient.test.ts index f9f8cfae4..681d758e8 100644 --- a/src/session/broker/brokerClient.test.ts +++ b/src/session/broker/brokerClient.test.ts @@ -251,14 +251,16 @@ describe("Hunk session daemon client", () => { messages.push(args.map((value) => String(value)).join(" ")); }; - const client = new SessionBrokerClient(createRegistration(), createSnapshot()); + const client = new SessionBrokerClient(createRegistration(), createSnapshot(), { + daemonStartupTimeoutMs: 100, + reconnectDelayMs: 10_000, + }); try { - client.start(); - await waitUntil("initial session-daemon conflict warning", () => messages.length === 1); + await client.start(); + expect(messages).toHaveLength(1); - client.start(); - await Bun.sleep(2_000); + await client.start(); expect(messages).toHaveLength(1); expect(messages[0]).toContain( diff --git a/src/session/broker/brokerClient.ts b/src/session/broker/brokerClient.ts index 72fc8f504..5cba7b01f 100644 --- a/src/session/broker/brokerClient.ts +++ b/src/session/broker/brokerClient.ts @@ -37,6 +37,11 @@ type SessionAppBridge< Result = unknown, > = SessionBrokerConnectionBridge; +interface SessionBrokerClientTiming { + daemonStartupTimeoutMs?: number; + reconnectDelayMs?: number; +} + /** Keep one running app session registered with the local session broker daemon. */ export class SessionBrokerClient< Info = unknown, @@ -60,6 +65,7 @@ export class SessionBrokerClient< constructor( private registration: SessionRegistration, private snapshot: SessionSnapshot, + private timing: SessionBrokerClientTiming = {}, ) {} start() { @@ -68,7 +74,7 @@ export class SessionBrokerClient< } if (this.startupPromise) { - return; + return this.startupPromise; } this.startupPromise = this.ensureDaemonAndConnect() @@ -83,6 +89,8 @@ export class SessionBrokerClient< .finally(() => { this.startupPromise = null; }); + + return this.startupPromise; } stop() { @@ -119,7 +127,7 @@ export class SessionBrokerClient< private async ensureDaemonAvailable(config: ResolvedSessionBrokerConfig) { await ensureSessionBrokerAvailable({ config, - timeoutMs: DAEMON_STARTUP_TIMEOUT_MS, + timeoutMs: this.timing.daemonStartupTimeoutMs ?? DAEMON_STARTUP_TIMEOUT_MS, }); const capabilities = await readHunkSessionDaemonCapabilities(config); @@ -127,7 +135,7 @@ export class SessionBrokerClient< await this.restartIncompatibleDaemon(config); await ensureSessionBrokerAvailable({ config, - timeoutMs: DAEMON_STARTUP_TIMEOUT_MS, + timeoutMs: this.timing.daemonStartupTimeoutMs ?? DAEMON_STARTUP_TIMEOUT_MS, }); if (!(await readHunkSessionDaemonCapabilities(config))) { @@ -205,7 +213,7 @@ export class SessionBrokerClient< snapshot: this.snapshot, bridge: this.bridge, heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS, - reconnectDelayMs: RECONNECT_DELAY_MS, + reconnectDelayMs: this.timing.reconnectDelayMs ?? RECONNECT_DELAY_MS, resolveClose: (event) => this.isIncompatibleSessionClose(event) ? { reconnect: false, warning: INCOMPATIBLE_SESSION_CLOSE_MESSAGE } @@ -216,7 +224,7 @@ export class SessionBrokerClient< this.connection.start(); } - private scheduleReconnect(delayMs = RECONNECT_DELAY_MS) { + private scheduleReconnect(delayMs = this.timing.reconnectDelayMs ?? RECONNECT_DELAY_MS) { if (this.reconnectTimer || this.stopped) { return; } diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx index 777c2e3c1..6617323e4 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/src/ui/AppHost.interactions.test.tsx @@ -364,15 +364,15 @@ function createRapidViewportLoopBootstrap(): AppBootstrap { function createMouseScrollSelectionBootstrap(): AppBootstrap { const firstBeforeLines = createNumberedAssignmentLines(1, 12); const secondBeforeLines = Array.from( - { length: 90 }, + { length: 50 }, (_, index) => `export const line${String(index + 13).padStart(2, "0")} = ${index + 13};`, ); const secondAfterLines = [...secondBeforeLines]; secondAfterLines[0] = "export const line13 = 1300;"; - secondAfterLines[59] = "export const line72 = 7200;"; - secondAfterLines[60] = "export const line73 = 7300;"; - secondAfterLines[61] = "export const line74 = 7400;"; + secondAfterLines[29] = "export const line42 = 4200;"; + secondAfterLines[30] = "export const line43 = 4300;"; + secondAfterLines[31] = "export const line44 = 4400;"; return createTestVcsAppBootstrap({ changesetId: "changeset:mouse-scroll-selection", @@ -3220,25 +3220,26 @@ describe("App interactions", () => { }); let snapshot = getLatestSnapshot(); - for (let index = 0; index < 24; index += 1) { + for (let index = 0; index < 16; index += 1) { await act(async () => { await setup.mockMouse.scroll(120, 7, "down"); }); await flush(setup); - snapshot = await waitForSnapshot( - setup, - getLatestSnapshot, - (currentSnapshot) => - currentSnapshot.selectedFilePath === "second.ts" && - currentSnapshot.selectedHunkIndex === 1, - 4, - ); + snapshot = getLatestSnapshot(); if (snapshot?.selectedFilePath === "second.ts" && snapshot.selectedHunkIndex === 1) { break; } } + snapshot = await waitForSnapshot( + setup, + getLatestSnapshot, + (currentSnapshot) => + currentSnapshot.selectedFilePath === "second.ts" && + currentSnapshot.selectedHunkIndex === 1, + 4, + ); expect(snapshot).toMatchObject({ selectedFilePath: "second.ts", selectedHunkIndex: 1, @@ -3275,17 +3276,19 @@ describe("App interactions", () => { }); await flush(setup); - snapshot = await waitForSnapshot( - setup, - getLatestSnapshot, - (currentSnapshot) => currentSnapshot.selectedFilePath === "second.ts", - 4, - ); + snapshot = getLatestSnapshot(); if (snapshot?.selectedFilePath === "second.ts") { break; } } + snapshot = await waitForSnapshot( + setup, + getLatestSnapshot, + (currentSnapshot) => currentSnapshot.selectedFilePath === "second.ts", + 4, + ); + // Page-sized scrolling should move selection ownership into the later file. The exact hunk // can vary with viewport handoff timing because the page jump may land near either visible // hunk in second.ts on slower CI machines. @@ -3299,17 +3302,18 @@ describe("App interactions", () => { }); await flush(setup); - snapshot = await waitForSnapshot( - setup, - getLatestSnapshot, - (currentSnapshot) => currentSnapshot.selectedFilePath === "first.ts", - 4, - ); + snapshot = getLatestSnapshot(); if (snapshot?.selectedFilePath === "first.ts") { break; } } + snapshot = await waitForSnapshot( + setup, + getLatestSnapshot, + (currentSnapshot) => currentSnapshot.selectedFilePath === "first.ts", + 4, + ); expect(snapshot).toMatchObject({ selectedFilePath: "first.ts", selectedHunkIndex: 0, @@ -3340,25 +3344,26 @@ describe("App interactions", () => { }); let snapshot = getLatestSnapshot(); - for (let index = 0; index < 80; index += 1) { + for (let index = 0; index < 50; index += 1) { await act(async () => { await setup.mockInput.pressArrow("down"); }); await flush(setup); - snapshot = await waitForSnapshot( - setup, - getLatestSnapshot, - (currentSnapshot) => - currentSnapshot.selectedFilePath === "second.ts" && - currentSnapshot.selectedHunkIndex === 1, - 4, - ); + snapshot = getLatestSnapshot(); if (snapshot?.selectedFilePath === "second.ts" && snapshot.selectedHunkIndex === 1) { break; } } + snapshot = await waitForSnapshot( + setup, + getLatestSnapshot, + (currentSnapshot) => + currentSnapshot.selectedFilePath === "second.ts" && + currentSnapshot.selectedHunkIndex === 1, + 4, + ); expect(snapshot).toMatchObject({ selectedFilePath: "second.ts", selectedHunkIndex: 1, diff --git a/src/ui/components/scrollbar/VerticalScrollbar.test.tsx b/src/ui/components/scrollbar/VerticalScrollbar.test.tsx index 5c9b8f83b..2959671f0 100644 --- a/src/ui/components/scrollbar/VerticalScrollbar.test.tsx +++ b/src/ui/components/scrollbar/VerticalScrollbar.test.tsx @@ -1,8 +1,11 @@ import { describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { parseDiffFromFile } from "@pierre/diffs"; -import { act } from "react"; +import { act, createRef } from "react"; +import { capturedTestColorToHex } from "../../../../test/helpers/test-color-helpers"; import type { AppBootstrap, DiffFile } from "../../../core/types"; +import { resolveTheme } from "../../themes"; +import { VerticalScrollbar, type VerticalScrollbarHandle } from "./VerticalScrollbar"; const { AppHost } = await import("../../AppHost"); @@ -83,35 +86,94 @@ async function flush(setup: Awaited>) { }); } +/** Return whether the terminal frame contains the requested painted background. */ +function frameHasBackground( + setup: Awaited>, + backgroundColor: string, + column?: number, +) { + return setup.captureSpans().lines.some((line) => { + let spanStart = 0; + return line.spans.some((span) => { + const spanEnd = spanStart + span.width; + const includesColumn = column === undefined || (spanStart <= column && column < spanEnd); + spanStart = spanEnd; + return ( + includesColumn && + span.width > 0 && + capturedTestColorToHex(span.bg)?.toLowerCase() === backgroundColor.toLowerCase() + ); + }); + }); +} + +/** Create an observable scroll target for direct scrollbar interaction tests. */ +function createTestScrollRef(scrollTop = 0, height = 10) { + const positions: number[] = []; + const scrollRef = createRef<{ + scrollTop: number; + scrollTo: (y: number) => void; + viewport: { height: number }; + }>(); + scrollRef.current = { + scrollTop, + scrollTo: (y) => { + positions.push(y); + if (scrollRef.current) { + scrollRef.current.scrollTop = y; + } + }, + viewport: { height }, + }; + return { positions, scrollRef }; +} + +/** Wait until input produces a new rendered review frame. */ +async function waitForFrameChange( + setup: Awaited>, + previousFrame: string, +) { + for (let attempt = 0; attempt < 20; attempt += 1) { + const frame = setup.captureCharFrame(); + if (frame !== previousFrame) { + return frame; + } + + await act(async () => { + await Bun.sleep(5); + await setup.renderOnce(); + }); + } + + throw new Error("Timed out waiting for scroll input to change the rendered review frame."); +} + describe("Vertical scrollbar", () => { test("shows scrollbar when content exceeds viewport height", async () => { - const bootstrap = createScrollBootstrapWithManyFiles(5); - const setup = await testRender(, { - width: 160, - height: 20, - }); + const theme = resolveTheme("github-dark-default", null); + const handle = createRef(); + const { scrollRef } = createTestScrollRef(); + const setup = await testRender( + , + { width: 2, height: 10 }, + ); try { await flush(setup); + expect(frameHasBackground(setup, theme.accentMuted)).toBe(false); - // Trigger scroll activity to make scrollbar appear await act(async () => { - await setup.mockInput.pressArrow("down"); - await flush(setup); - }); - - // Wait for scrollbar to render - await act(async () => { - await Bun.sleep(100); - await setup.renderOnce(); + handle.current?.show(); }); + await flush(setup); - const frame = setup.captureCharFrame(); - // Look for scrollbar characters in the rightmost column - // The scrollbar renders as background-colored cells (spaces with ANSI color codes) - // which appear as regular spaces in captureCharFrame - // Instead, check that content is scrollable by verifying we can scroll down - expect(frame).toBeTruthy(); + expect(frameHasBackground(setup, theme.accentMuted)).toBe(true); } finally { await act(async () => { setup.renderer.destroy(); @@ -120,35 +182,41 @@ describe("Vertical scrollbar", () => { }); test("hides scrollbar after scroll activity stops", async () => { - const bootstrap = createScrollBootstrapWithManyFiles(5); - const setup = await testRender(, { - width: 160, - height: 20, - }); + const theme = resolveTheme("github-dark-default", null); + const handle = createRef(); + const scrollRef = createRef<{ + scrollTop: number; + scrollTo: (y: number) => void; + viewport: { height: number }; + }>(); + scrollRef.current = { scrollTop: 0, scrollTo: () => {}, viewport: { height: 10 } }; + const setup = await testRender( + , + { width: 2, height: 10 }, + ); try { await flush(setup); + expect(frameHasBackground(setup, theme.accentMuted)).toBe(false); - // Trigger scroll activity await act(async () => { - await setup.mockInput.pressArrow("down"); - await flush(setup); + handle.current?.show(); }); - - // Verify app is responsive - const frame = setup.captureCharFrame(); - expect(frame).toBeTruthy(); - - // Wait for auto-hide timeout (2 seconds + buffer) - await Bun.sleep(2500); + await flush(setup); + expect(frameHasBackground(setup, theme.accentMuted)).toBe(true); await act(async () => { - await setup.renderOnce(); + await Bun.sleep(10); }); - - // After auto-hide, the app should still be functional - const frameAfter = setup.captureCharFrame(); - expect(frameAfter).toBeTruthy(); + await flush(setup); + expect(frameHasBackground(setup, theme.accentMuted)).toBe(false); } finally { await act(async () => { setup.renderer.destroy(); @@ -156,7 +224,7 @@ describe("Vertical scrollbar", () => { } }); - test("scrollbar shows on mouse scroll wheel activity", async () => { + test("mouse wheel activity scrolls overflowing review content", async () => { const bootstrap = createScrollBootstrapWithManyFiles(5); const setup = await testRender(, { width: 160, @@ -165,23 +233,17 @@ describe("Vertical scrollbar", () => { try { await flush(setup); + const initialFrame = setup.captureCharFrame(); - // Wait for initial state to settle - await Bun.sleep(500); - await act(async () => { - await setup.renderOnce(); - }); - - // Trigger mouse scroll await act(async () => { await setup.mockMouse.scroll(50, 10, "down"); - await Bun.sleep(100); - await setup.renderOnce(); }); + await flush(setup); - // Verify scroll activity was processed - const frame = setup.captureCharFrame(); - expect(frame).toBeTruthy(); + const scrolledFrame = await waitForFrameChange(setup, initialFrame); + // Character frames omit background-only scrollbar cells, so changed review rows prove that + // the wheel moved visible content rather than merely revealing the scrollbar. + expect(scrolledFrame.split("\n").slice(2)).not.toEqual(initialFrame.split("\n").slice(2)); } finally { await act(async () => { setup.renderer.destroy(); @@ -189,68 +251,38 @@ describe("Vertical scrollbar", () => { } }); - test("up/down arrow keys enable scrolling", async () => { - // Create a file with enough content to scroll - const before = Array.from( - { length: 30 }, - (_, j) => `export const line${String(j + 1).padStart(2, "0")} = ${j + 1};`, - ).join("\n"); - const after = before.replace("line15 = 15", "line15 = 115 // modified"); - - const bootstrap: AppBootstrap = { - reloadContext: { cwd: process.cwd() }, - input: { - kind: "vcs", - staged: false, - options: { mode: "split" }, - }, - changeset: { - id: "scroll-test", - sourceLabel: "repo", - title: "scrollable test", - files: [createDiffFile("scroll", "src/scroll.ts", before, after)], - }, - initialMode: "split", - initialTheme: "github-dark-default", - }; - - const setup = await testRender(, { - width: 160, - height: 15, // Small viewport to force scrolling - }); + test("repeated activity restarts the auto-hide deadline", async () => { + const theme = resolveTheme("github-dark-default", null); + const handle = createRef(); + const { scrollRef } = createTestScrollRef(); + const setup = await testRender( + , + { width: 2, height: 10 }, + ); try { await flush(setup); await act(async () => { - await Bun.sleep(100); + handle.current?.show(); + await Bun.sleep(15); + handle.current?.show(); + await Bun.sleep(10); }); + await flush(setup); + expect(frameHasBackground(setup, theme.accentMuted)).toBe(true); - // Verify app renders and is responsive to scroll commands - const frame1 = setup.captureCharFrame(); - expect(frame1).toContain("line"); - - // Press down arrow multiple times to scroll - for (let i = 0; i < 5; i++) { - await act(async () => { - await setup.mockInput.pressArrow("down"); - await flush(setup); - }); - } - - // Verify content changed after scrolling - const frame2 = setup.captureCharFrame(); - expect(frame2).toContain("line"); - - // Press up arrow to scroll back - for (let i = 0; i < 5; i++) { - await act(async () => { - await setup.mockInput.pressArrow("up"); - await flush(setup); - }); - } - - const frame3 = setup.captureCharFrame(); - expect(frame3).toContain("line"); + await act(async () => { + await Bun.sleep(15); + }); + await flush(setup); + expect(frameHasBackground(setup, theme.accentMuted)).toBe(false); } finally { await act(async () => { setup.renderer.destroy(); @@ -259,43 +291,28 @@ describe("Vertical scrollbar", () => { }); test("scrollbar is hidden when content fits in viewport", async () => { - // Create bootstrap with just 1 small file - const before = "export const a = 1;\n"; - const after = "export const a = 2;\n"; - const bootstrap: AppBootstrap = { - reloadContext: { cwd: process.cwd() }, - input: { - kind: "vcs", - staged: false, - options: { - mode: "split", - }, - }, - changeset: { - id: "scroll-test-small", - sourceLabel: "repo", - title: "small test changeset", - files: [createDiffFile("small", "src/small.ts", before, after)], - }, - initialMode: "split", - initialTheme: "github-dark-default", - }; - - const setup = await testRender(, { - width: 160, - height: 60, // Large viewport - }); + const theme = resolveTheme("github-dark-default", null); + const handle = createRef(); + const { scrollRef } = createTestScrollRef(); + const setup = await testRender( + , + { width: 2, height: 10 }, + ); try { await flush(setup); await act(async () => { - await Bun.sleep(100); - await setup.renderOnce(); + handle.current?.show(); }); + await flush(setup); - const frame = setup.captureCharFrame(); - // Small content in large viewport should be fully visible - expect(frame).toContain("export const a ="); + expect(frameHasBackground(setup, theme.accentMuted)).toBe(false); } finally { await act(async () => { setup.renderer.destroy(); @@ -304,64 +321,33 @@ describe("Vertical scrollbar", () => { }); test("thumb drag scrolls content", async () => { - // Create a file with many lines to ensure scrolling - const before = Array.from({ length: 100 }, (_, j) => `line${j + 1}`).join("\n"); - const after = before.replace("line50", "line50modified"); - - const bootstrap: AppBootstrap = { - reloadContext: { cwd: process.cwd() }, - input: { - kind: "vcs", - staged: false, - options: { mode: "split" }, - }, - changeset: { - id: "drag-test", - sourceLabel: "repo", - title: "drag test", - files: [createDiffFile("drag", "src/drag.ts", before, after)], - }, - initialMode: "split", - initialTheme: "github-dark-default", - }; - - const setup = await testRender(, { - width: 160, - height: 20, // Small viewport to force scrolling - }); + const theme = resolveTheme("github-dark-default", null); + const handle = createRef(); + const { positions, scrollRef } = createTestScrollRef(); + const setup = await testRender( + , + { width: 2, height: 10 }, + ); try { await flush(setup); await act(async () => { - await Bun.sleep(100); + handle.current?.show(); }); + await flush(setup); - // Get initial frame - app centers on the hunk at line 50 - const frame1 = setup.captureCharFrame(); - expect(frame1).toContain("line50"); - - // Drag scrollbar thumb down (rightmost column is scrollbar at x=159, y ranges 0-19) - // Thumb should be at some position, drag it down to scroll - await act(async () => { - // Drag from top area of scrollbar down - await setup.mockMouse.drag(159, 2, 159, 10); - await flush(setup); - await Bun.sleep(100); - }); - - // After dragging down, we should see different content - const frame2 = setup.captureCharFrame(); - expect(frame2).toBeTruthy(); - - // Drag back up await act(async () => { - await setup.mockMouse.drag(159, 10, 159, 2); - await flush(setup); - await Bun.sleep(100); + await setup.mockMouse.drag(1, 0, 1, 4); }); + await flush(setup); - const frame3 = setup.captureCharFrame(); - expect(frame3).toBeTruthy(); + expect(positions.at(-1)).toBeCloseTo(45, 0); } finally { await act(async () => { setup.renderer.destroy(); @@ -370,74 +356,38 @@ describe("Vertical scrollbar", () => { }); test("track click scrolls by one viewport", async () => { - // Create a file with many lines to ensure scrolling - const lines = Array.from({ length: 80 }, (_, j) => `line${String(j + 1).padStart(3, "0")}`); - const before = lines.join("\n"); - const after = before.replace("line040", "line040modified"); - - const bootstrap: AppBootstrap = { - reloadContext: { cwd: process.cwd() }, - input: { - kind: "vcs", - staged: false, - options: { mode: "split" }, - }, - changeset: { - id: "track-click-test", - sourceLabel: "repo", - title: "track click test", - files: [createDiffFile("track", "src/track.ts", before, after)], - }, - initialMode: "split", - initialTheme: "github-dark-default", - }; - - const setup = await testRender(, { - width: 160, - height: 15, // Viewport of 15 lines - }); + const theme = resolveTheme("github-dark-default", null); + const handle = createRef(); + const { positions, scrollRef } = createTestScrollRef(20); + const setup = await testRender( + , + { width: 2, height: 10 }, + ); try { await flush(setup); await act(async () => { - await Bun.sleep(100); - }); - - // Get initial content - app centers on the hunk at line 40 - const frame1 = setup.captureCharFrame(); - expect(frame1).toContain("line040"); - - // First scroll down a bit to make scrollbar visible and move thumb down - await act(async () => { - for (let i = 0; i < 5; i++) { - await setup.mockInput.pressArrow("down"); - } - await flush(setup); - await Bun.sleep(100); + handle.current?.show(); }); + await flush(setup); - // Click on scrollbar track below thumb to page down - // Scrollbar is at rightmost column (x=159), click near bottom await act(async () => { - await setup.mockMouse.click(159, 12); - await flush(setup); - await Bun.sleep(100); + await setup.mockMouse.click(1, 8); }); + await flush(setup); + expect(positions.at(-1)).toBe(30); - const frame2 = setup.captureCharFrame(); - // Should have scrolled down further after track click - expect(frame2).toBeTruthy(); - - // Click on scrollbar track above thumb to page up await act(async () => { - await setup.mockMouse.click(159, 2); - await flush(setup); - await Bun.sleep(100); + await setup.mockMouse.click(1, 0); }); - - const frame3 = setup.captureCharFrame(); - // Should have scrolled back up - expect(frame3).toBeTruthy(); + await flush(setup); + expect(positions.at(-1)).toBe(10); } finally { await act(async () => { setup.renderer.destroy(); @@ -446,67 +396,33 @@ describe("Vertical scrollbar", () => { }); test("handles edge case when content barely exceeds viewport", async () => { - // Create content that's just slightly larger than viewport - // This tests the division-by-zero guard in drag calculations - // Use the same pattern as other tests which work correctly - const before = Array.from( - { length: 25 }, - (_, j) => `export const line${String(j + 1).padStart(2, "0")} = ${j + 1};`, - ).join("\n"); - const after = before.replace("line08 = 8;", "line08 = 999; // modified"); - - const bootstrap: AppBootstrap = { - reloadContext: { cwd: process.cwd() }, - input: { - kind: "vcs", - staged: false, - options: { mode: "split" }, - }, - changeset: { - id: "edge-case-test", - sourceLabel: "repo", - title: "edge case test", - files: [createDiffFile("edge", "src/edge.ts", before, after)], - }, - initialMode: "split", - initialTheme: "github-dark-default", - }; - - const setup = await testRender(, { - width: 160, - height: 15, // Small viewport to force scrolling (25 lines of content in 15-line viewport) - }); + const theme = resolveTheme("github-dark-default", null); + const handle = createRef(); + const { positions, scrollRef } = createTestScrollRef(); + const setup = await testRender( + , + { width: 2, height: 10 }, + ); try { await flush(setup); await act(async () => { - await Bun.sleep(100); + handle.current?.show(); }); + await flush(setup); - // Verify app renders with the hunk visible - look for the modified line - const frame1 = setup.captureCharFrame(); - expect(frame1).toContain("line08"); - - // Try to drag - should not crash with division by zero - await act(async () => { - await setup.mockMouse.drag(159, 0, 159, 5); - await flush(setup); - await Bun.sleep(100); - }); - - // App should still be responsive after drag attempt - const frame2 = setup.captureCharFrame(); - expect(frame2).toBeTruthy(); - - // Try track click - should not crash await act(async () => { - await setup.mockMouse.click(159, 10); - await flush(setup); - await Bun.sleep(100); + await setup.mockMouse.drag(1, 0, 1, 5); }); + await flush(setup); - const frame3 = setup.captureCharFrame(); - expect(frame3).toBeTruthy(); + expect(positions.at(-1)).toBe(1); } finally { await act(async () => { setup.renderer.destroy(); diff --git a/src/ui/components/scrollbar/VerticalScrollbar.tsx b/src/ui/components/scrollbar/VerticalScrollbar.tsx index 80817c5f0..ae0926834 100644 --- a/src/ui/components/scrollbar/VerticalScrollbar.tsx +++ b/src/ui/components/scrollbar/VerticalScrollbar.tsx @@ -28,10 +28,14 @@ interface VerticalScrollbarProps { theme: AppTheme; height: number; onActivity?: () => void; + hideDelayMs?: number; } export const VerticalScrollbar = forwardRef( - function VerticalScrollbar({ scrollRef, contentHeight, theme, height, onActivity }, ref) { + function VerticalScrollbar( + { scrollRef, contentHeight, theme, height, onActivity, hideDelayMs = HIDE_DELAY_MS }, + ref, + ) { const [isVisible, setIsVisible] = useState(false); const [isDraggingState, setIsDraggingState] = useState(false); const isDraggingRef = useRef(false); @@ -48,9 +52,9 @@ export const VerticalScrollbar = forwardRef ({ show }), [show]); @@ -143,7 +147,7 @@ export const VerticalScrollbar = forwardRef { setIsVisible(false); - }, HIDE_DELAY_MS); + }, hideDelayMs); event?.preventDefault(); event?.stopPropagation(); }; diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx index 68bbf8e6e..5200c3c45 100644 --- a/src/ui/components/ui-components.test.tsx +++ b/src/ui/components/ui-components.test.tsx @@ -1374,14 +1374,10 @@ describe("UI components", () => { ).bodyHeight; const secondHeaderTop = firstBodyHeight + 1; const separatorTop = firstBodyHeight; - const settleStickyScroll = async () => { - await act(async () => { - for (let iteration = 0; iteration < 6; iteration += 1) { - await Bun.sleep(60); - await setup.renderOnce(); - } + const renderStickyScroll = () => + act(async () => { + await setup.renderOnce(); }); - }; try { await settleDiffPane(setup); @@ -1392,7 +1388,7 @@ describe("UI components", () => { await act(async () => { scrollRef.current?.scrollTo(3); }); - await settleStickyScroll(); + await renderStickyScroll(); frame = await waitForFrame(setup, (nextFrame) => nextFrame.includes("first.ts")); expect(frame).toContain("first.ts"); @@ -1402,7 +1398,7 @@ describe("UI components", () => { await act(async () => { scrollRef.current?.scrollTo(separatorTop); }); - await settleStickyScroll(); + await renderStickyScroll(); frame = await waitForFrame( setup, @@ -1415,7 +1411,7 @@ describe("UI components", () => { await act(async () => { scrollRef.current?.scrollTo(secondHeaderTop); }); - await settleStickyScroll(); + await renderStickyScroll(); frame = await waitForFrame( setup, @@ -1428,7 +1424,7 @@ describe("UI components", () => { await act(async () => { scrollRef.current?.scrollTo(secondHeaderTop + 1); }); - await settleStickyScroll(); + await renderStickyScroll(); frame = await waitForFrame( setup, @@ -1442,7 +1438,7 @@ describe("UI components", () => { await act(async () => { scrollRef.current?.scrollTo(secondHeaderTop + 2); }); - await settleStickyScroll(); + await renderStickyScroll(); frame = await waitForFrame( setup, diff --git a/test/session/cli.test.ts b/test/session/cli.test.ts index 5f781d4de..2133d1199 100644 --- a/test/session/cli.test.ts +++ b/test/session/cli.test.ts @@ -11,12 +11,22 @@ const testConfigHome = createTestConfigHome(); afterAll(cleanupTestConfigHomes); const tempDirs: string[] = []; -const ttyToolsAvailable = - Bun.spawnSync(["bash", "-lc", "command -v script >/dev/null && command -v timeout >/dev/null"], { - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }).exitCode === 0; +/** Check for the util-linux `script` interface these Unix-only terminal tests require. */ +function supportsControllableScript() { + try { + return ( + Bun.spawnSync(["script", "-q", "-f", "-e", "-c", "exit 0", "/dev/null"], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }).exitCode === 0 + ); + } catch { + return false; + } +} + +const ttyToolsAvailable = supportsControllableScript(); interface SessionListJson { sessions: Array<{ @@ -81,38 +91,130 @@ function createFixtureFiles(name: string, beforeLines: string[], afterLines: str return { dir, before, after, transcript, afterName }; } -function spawnHunkSession( - fixture: ReturnType, - { - port, - quitAfterSeconds = 8, - timeoutSeconds = 10, - }: { - port: number; - quitAfterSeconds?: number; - timeoutSeconds?: number; - }, -) { +function spawnHunkSession(fixture: ReturnType, port: number) { const innerCommand = `bun run ${shellQuote(sourceEntrypoint)} diff ${shellQuote(fixture.before)} ${shellQuote(fixture.after)}`; - const hunkCommand = [ - `(sleep ${quitAfterSeconds}; printf q) | timeout ${timeoutSeconds} script -q -f -e -c`, - shellQuote(innerCommand), - shellQuote(fixture.transcript), - ].join(" "); - return Bun.spawn(["bash", "-lc", hunkCommand], { + return Bun.spawn(["script", "-q", "-f", "-e", "-c", innerCommand, fixture.transcript], { cwd: fixture.dir, - stdin: "ignore", - stdout: "pipe", + stdin: "pipe", + stdout: "ignore", stderr: "pipe", env: { ...process.env, XDG_CONFIG_HOME: testConfigHome, + TERM: "xterm-256color", + COLUMNS: "120", + LINES: "24", HUNK_MCP_PORT: `${port}`, }, }); } +type HunkSessionProcess = ReturnType; + +/** Strip terminal controls so prompts can be matched in flushed transcripts. */ +function stripTerminalControl(text: string) { + return text + .replace(/\x1bP[\s\S]*?\x1b\\/g, "") + .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, "") + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") + .replace(/\x1b[@-_]/g, ""); +} + +/** Ask a live test session to quit, discarding changed view preferences when prompted. */ +async function requestHunkSessionQuit( + proc: HunkSessionProcess, + fixture: ReturnType, + timeoutMs = 2_000, +) { + proc.stdin.write("q"); + await proc.stdin.flush(); + let quitAttempts = 1; + let lastQuitAttemptAt = Date.now(); + + const outcome = await waitUntil( + "Hunk session exit or save-preferences prompt", + async () => { + if (proc.exitCode !== null) { + return "exited" as const; + } + const file = Bun.file(fixture.transcript); + if (await file.exists()) { + const output = stripTerminalControl(await file.text()); + if (output.includes("Save view preferences?")) { + return "prompt" as const; + } + } + + // A command-triggered repaint can consume input sent during its handoff. Retry only while the + // app remains live and no prompt is visible, keeping teardown condition-driven and bounded. + if (quitAttempts < 3 && Date.now() - lastQuitAttemptAt >= 100) { + proc.stdin.write("q"); + await proc.stdin.flush(); + quitAttempts += 1; + lastQuitAttemptAt = Date.now(); + } + return null; + }, + timeoutMs, + 25, + ); + + if (outcome === "prompt") { + proc.stdin.write("q"); + await proc.stdin.flush(); + } + + const result = await Promise.race([ + proc.exited.then((exitCode) => ({ exitCode })), + Bun.sleep(timeoutMs).then(() => null), + ]); + if (!result) { + proc.kill(); + await proc.exited.catch(() => undefined); + throw new Error(`Timed out waiting ${timeoutMs}ms for the Hunk session to quit.`); + } + if (result.exitCode !== 0) { + throw new Error(`Hunk session exited with ${result.exitCode}.`); + } +} + +/** Guarantee process cleanup even when graceful terminal teardown fails. */ +async function quitHunkSession( + proc: HunkSessionProcess, + fixture: ReturnType, +) { + try { + await requestHunkSessionQuit(proc, fixture); + } catch (error) { + proc.kill(); + await proc.exited.catch(() => undefined); + throw error; + } +} + +/** Poll daemon health directly before exercising the CLI boundary once. */ +async function waitForRegisteredSessions(port: number) { + await waitUntil("registered live session", async () => { + try { + const response = await fetch(`http://127.0.0.1:${port}/health`); + if (!response.ok) { + return null; + } + const health = (await response.json()) as { sessions?: number }; + return (health.sessions ?? 0) > 0 ? true : null; + } catch { + return null; + } + }); + + const { proc, stdout, stderr } = runSessionCli(["list", "--json"], port); + if (proc.exitCode !== 0) { + throw new Error(stderr.trim() || "Failed to list the registered Hunk session."); + } + return (JSON.parse(stdout) as SessionListJson).sessions; +} + function runSessionCli(args: string[], port: number, stdinText?: string) { const proc = Bun.spawnSync(["bun", "run", "src/main.tsx", "session", ...args], { cwd: repoRoot, @@ -135,30 +237,20 @@ afterEach(() => { cleanupTempDirs(); }); -describe("session CLI integration", () => { - test("list/get/context expose live Hunk sessions through the daemon", async () => { - if (!ttyToolsAvailable) { - return; - } +const sessionDescribe = ttyToolsAvailable ? describe : describe.skip; +sessionDescribe("session CLI integration", () => { + test("list/get/context expose live Hunk sessions through the daemon", async () => { const port = 48961; const fixture = createFixtureFiles( "inspect", ["export const value = 1;", "console.log(value);"], ["export const value = 2;", "console.log(value * 2);"], ); - const session = spawnHunkSession(fixture, { port }); + const session = spawnHunkSession(fixture, port); try { - const listed = await waitUntil("registered live session", () => { - const { proc, stdout } = runSessionCli(["list", "--json"], port); - if (proc.exitCode !== 0) { - return null; - } - - const parsed = JSON.parse(stdout) as SessionListJson; - return parsed.sessions.length > 0 ? parsed.sessions : null; - }); + const listed = await waitForRegisteredSessions(port); const sessionId = listed[0]!.sessionId; const get = runSessionCli(["get", sessionId, "--json"], port); @@ -190,42 +282,29 @@ describe("session CLI integration", () => { }, }); } finally { - session.kill(); - await session.exited; + await quitHunkSession(session, fixture); } }); test("reload replaces what a live session is showing", async () => { - if (!ttyToolsAvailable) { - return; - } - const port = 48963; - const fixtureA = createFixtureFiles( + const fixture = createFixtureFiles( "reload-alpha", ["export const alpha = 1;"], ["export const alpha = 2;", "export const beta = true;"], ); - mkdirSync(join(fixtureA.dir, ".git")); - const session = spawnHunkSession(fixtureA, { port, quitAfterSeconds: 18, timeoutSeconds: 20 }); + mkdirSync(join(fixture.dir, ".git")); + const session = spawnHunkSession(fixture, port); try { - const listed = await waitUntil("registered live session", () => { - const { proc, stdout } = runSessionCli(["list", "--json"], port); - if (proc.exitCode !== 0) { - return null; - } - - const parsed = JSON.parse(stdout) as SessionListJson; - return parsed.sessions.length > 0 ? parsed.sessions : null; - }); + const listed = await waitForRegisteredSessions(port); const sessionId = listed[0]!.sessionId; - writeFileSync(fixtureA.before, "export const before = 10;\n"); - writeFileSync(fixtureA.after, "export const after = 20;\nexport const extra = 'yes';\n"); + writeFileSync(fixture.before, "export const before = 10;\n"); + writeFileSync(fixture.after, "export const after = 20;\nexport const extra = 'yes';\n"); const reload = runSessionCli( - ["reload", sessionId, "--json", "--", "diff", fixtureA.before, fixtureA.after], + ["reload", sessionId, "--json", "--", "diff", fixture.before, fixture.after], port, ); expect(reload.proc.exitCode).toBe(0); @@ -235,7 +314,7 @@ describe("session CLI integration", () => { sessionId, inputKind: "diff", fileCount: 1, - selectedFilePath: fixtureA.afterName, + selectedFilePath: fixture.afterName, selectedHunkIndex: 0, }, }); @@ -252,26 +331,21 @@ describe("session CLI integration", () => { files?: Array<{ path: string }>; }; }; - return parsed.session?.files?.[0]?.path === fixtureA.afterName ? parsed : null; + return parsed.session?.files?.[0]?.path === fixture.afterName ? parsed : null; }); expect(reloaded).toMatchObject({ session: { inputKind: "diff", - files: [{ path: fixtureA.afterName }], + files: [{ path: fixture.afterName }], }, }); } finally { - session.kill(); - await session.exited; + await quitHunkSession(session, fixture); } }, 20_000); test("reload refuses to read files outside the live session root", async () => { - if (!ttyToolsAvailable) { - return; - } - const port = 48966; const fixture = createFixtureFiles( "reload-denied", @@ -284,18 +358,10 @@ describe("session CLI integration", () => { ["export const secret = 2;"], ); mkdirSync(join(fixture.dir, ".git")); - const session = spawnHunkSession(fixture, { port, quitAfterSeconds: 18, timeoutSeconds: 20 }); + const session = spawnHunkSession(fixture, port); try { - const listed = await waitUntil("registered live session", () => { - const { proc, stdout } = runSessionCli(["list", "--json"], port); - if (proc.exitCode !== 0) { - return null; - } - - const parsed = JSON.parse(stdout) as SessionListJson; - return parsed.sessions.length > 0 ? parsed.sessions : null; - }); + const listed = await waitForRegisteredSessions(port); const sessionId = listed[0]!.sessionId; const reload = runSessionCli( @@ -323,16 +389,11 @@ describe("session CLI integration", () => { }, }); } finally { - session.kill(); - await session.exited; + await quitHunkSession(session, fixture); } }, 20_000); test("navigate works, and comment add only focuses the session when --focus is passed", async () => { - if (!ttyToolsAvailable) { - return; - } - const port = 48962; const fixture = createFixtureFiles( "mutate", @@ -367,18 +428,10 @@ describe("session CLI integration", () => { "export const thirteen = 130;", ], ); - const session = spawnHunkSession(fixture, { port, quitAfterSeconds: 18, timeoutSeconds: 20 }); + const session = spawnHunkSession(fixture, port); try { - const listed = await waitUntil("registered live session", () => { - const { proc, stdout } = runSessionCli(["list", "--json"], port); - if (proc.exitCode !== 0) { - return null; - } - - const parsed = JSON.parse(stdout) as SessionListJson; - return parsed.sessions.length > 0 ? parsed.sessions : null; - }); + const listed = await waitForRegisteredSessions(port); const sessionId = listed[0]!.sessionId; @@ -534,16 +587,11 @@ describe("session CLI integration", () => { : null; }); } finally { - session.kill(); - await session.exited; + await quitHunkSession(session, fixture); } }, 20_000); test("comment apply adds a batch from stdin without moving focus by default", async () => { - if (!ttyToolsAvailable) { - return; - } - const port = 48964; const fixture = createFixtureFiles( "apply-batch", @@ -578,18 +626,10 @@ describe("session CLI integration", () => { "export const thirteen = 130;", ], ); - const session = spawnHunkSession(fixture, { port, quitAfterSeconds: 18, timeoutSeconds: 20 }); + const session = spawnHunkSession(fixture, port); try { - const listed = await waitUntil("registered live session", () => { - const { proc, stdout } = runSessionCli(["list", "--json"], port); - if (proc.exitCode !== 0) { - return null; - } - - const parsed = JSON.parse(stdout) as SessionListJson; - return parsed.sessions.length > 0 ? parsed.sessions : null; - }); + const listed = await waitForRegisteredSessions(port); const sessionId = listed[0]!.sessionId; const apply = runSessionCli( @@ -651,16 +691,11 @@ describe("session CLI integration", () => { comments: [{ summary: "First hunk note" }, { summary: "Second hunk note" }], }); } finally { - session.kill(); - await session.exited; + await quitHunkSession(session, fixture); } }, 20_000); test("comment apply with --focus jumps to the first applied comment", async () => { - if (!ttyToolsAvailable) { - return; - } - const port = 48965; const fixture = createFixtureFiles( "apply-batch-focus", @@ -695,18 +730,10 @@ describe("session CLI integration", () => { "export const thirteen = 130;", ], ); - const session = spawnHunkSession(fixture, { port, quitAfterSeconds: 18, timeoutSeconds: 20 }); + const session = spawnHunkSession(fixture, port); try { - const listed = await waitUntil("registered live session", () => { - const { proc, stdout } = runSessionCli(["list", "--json"], port); - if (proc.exitCode !== 0) { - return null; - } - - const parsed = JSON.parse(stdout) as SessionListJson; - return parsed.sessions.length > 0 ? parsed.sessions : null; - }); + const listed = await waitForRegisteredSessions(port); const sessionId = listed[0]!.sessionId; const apply = runSessionCli( @@ -753,8 +780,7 @@ describe("session CLI integration", () => { : null; }); } finally { - session.kill(); - await session.exited; + await quitHunkSession(session, fixture); } }, 20_000); }); diff --git a/test/smoke/tty.test.ts b/test/smoke/tty.test.ts index ad9dbbc4a..40dfd134a 100644 --- a/test/smoke/tty.test.ts +++ b/test/smoke/tty.test.ts @@ -13,15 +13,25 @@ afterAll(cleanupTestConfigHomes); const tempDirs: string[] = []; const enableTtySmokeTests = process.env.HUNK_RUN_TTY_SMOKE === "1"; if (enableTtySmokeTests) { - setDefaultTimeout(15000); + setDefaultTimeout(40_000); } -const ttyToolsAvailable = - Bun.spawnSync(["bash", "-lc", "command -v script >/dev/null && command -v timeout >/dev/null"], { - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }).exitCode === 0; +/** Check for the util-linux `script` interface these Unix-only terminal tests require. */ +function supportsControllableScript() { + try { + return ( + Bun.spawnSync(["script", "-q", "-f", "-e", "-c", "exit 0", "/dev/null"], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }).exitCode === 0 + ); + } catch { + return false; + } +} + +const ttyToolsAvailable = supportsControllableScript(); function cleanupTempDirs() { while (tempDirs.length > 0) { @@ -141,11 +151,280 @@ function createLongWrapFixtureFiles() { return { dir, before, after }; } +type TtyInteraction = "quit" | "wrap" | "wrap-cycle" | "page"; + +type TtySmokeProcess = ReturnType; + +/** Poll observable terminal output until a state appears or the deadline expires. */ +async function waitUntil( + label: string, + poll: () => T | null | Promise, + timeoutMs = 5_000, + intervalMs = 25, +) { + const deadline = Date.now() + timeoutMs; + for (;;) { + const value = await poll(); + if (value !== null) { + return value; + } + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for ${label}.`); + } + await Bun.sleep(intervalMs); + } +} + +/** Start a real terminal command whose keyboard input the test controls directly. */ +function spawnTtySmokeProcess(command: string, cwd: string, transcript: string) { + return Bun.spawn(["script", "-q", "-f", "-e", "-c", command, transcript], { + cwd, + stdin: "pipe", + stdout: "ignore", + stderr: "pipe", + env: { + ...process.env, + XDG_CONFIG_HOME: testConfigHome, + TERM: "xterm-256color", + COLUMNS: "80", + LINES: "24", + HUNK_MCP_DISABLE: "1", + HUNK_DISABLE_UPDATE_NOTICE: "1", + }, + }); +} + +/** Read a terminal transcript if util-linux `script` has created it. */ +async function readTranscript(transcript: string) { + const file = Bun.file(transcript); + return (await file.exists()) ? await file.text() : ""; +} + +/** Wait for cumulative transcript output matching an observable app state. */ +async function waitForTranscript( + proc: TtySmokeProcess, + transcript: string, + label: string, + predicate: (output: string) => boolean, +) { + return waitUntil(label, async () => { + const raw = await readTranscript(transcript); + if (predicate(stripTerminalControl(raw))) { + return raw; + } + if (proc.exitCode !== null) { + throw new Error(`TTY process exited with ${proc.exitCode} before ${label}.`); + } + return null; + }); +} + +/** Wait for output appended after one keyboard action. */ +async function waitForTranscriptUpdate( + proc: TtySmokeProcess, + transcript: string, + offset: number, + label: string, + predicate: (output: string) => boolean, +) { + try { + return await waitUntil(label, async () => { + const raw = await readTranscript(transcript); + const update = stripTerminalControl(raw.slice(offset)); + if (raw.length > offset && predicate(update)) { + return raw; + } + if (proc.exitCode !== null) { + throw new Error(`TTY process exited with ${proc.exitCode} before ${label}.`); + } + return null; + }); + } catch (error) { + const raw = await readTranscript(transcript); + const update = stripTerminalControl(raw.slice(offset)); + throw new Error( + `${error instanceof Error ? error.message : String(error)} Appended transcript: ${JSON.stringify(update.slice(-1_000))}`, + ); + } +} + +/** Send one keyboard sequence to the controlled terminal. */ +async function writeTtyInput(proc: TtySmokeProcess, input: string) { + proc.stdin.write(input); + await proc.stdin.flush(); +} + +/** Retry a readiness key until its observable terminal state appears. */ +async function writeTtyInputUntil( + proc: TtySmokeProcess, + transcript: string, + offset: number, + input: string, + label: string, + predicate: (output: string) => boolean, +) { + let attempts = 0; + let lastAttemptAt = 0; + + try { + return await waitUntil( + label, + async () => { + const raw = await readTranscript(transcript); + const update = stripTerminalControl(raw.slice(offset)); + if (raw.length > offset && predicate(update)) { + return raw; + } + if (proc.exitCode !== null) { + throw new Error(`TTY process exited with ${proc.exitCode} before ${label}.`); + } + + if (attempts < 4 && (attempts === 0 || Date.now() - lastAttemptAt >= 150)) { + await writeTtyInput(proc, input); + attempts += 1; + lastAttemptAt = Date.now(); + } + return null; + }, + 2_000, + 25, + ); + } catch (error) { + const raw = await readTranscript(transcript); + const update = stripTerminalControl(raw.slice(offset)); + throw new Error( + `${error instanceof Error ? error.message : String(error)} Appended transcript: ${JSON.stringify(update.slice(-1_000))}`, + ); + } +} + +/** Require a controlled terminal process to exit cleanly within a bounded deadline. */ +async function waitForTtyExit(proc: TtySmokeProcess, timeoutMs = 3_000) { + const result = await Promise.race([ + proc.exited.then((exitCode) => ({ exitCode })), + Bun.sleep(timeoutMs).then(() => null), + ]); + if (!result) { + throw new Error(`Timed out waiting ${timeoutMs}ms for the TTY process to exit.`); + } + if (result.exitCode !== 0) { + const stderr = proc.stderr ? await new Response(proc.stderr).text() : ""; + throw new Error(stderr.trim() || `TTY smoke command failed with exit ${result.exitCode}.`); + } +} + +/** Drive one terminal interaction from rendered readiness through a clean quit. */ +async function driveTtySmoke(options: { + command: string; + cwd: string; + transcript: string; + initialPredicate: (output: string) => boolean; + interaction: TtyInteraction; + pager: boolean; +}) { + const proc = spawnTtySmokeProcess(options.command, options.cwd, options.transcript); + + try { + let raw = await waitForTranscript( + proc, + options.transcript, + "initial Hunk terminal render", + options.initialPredicate, + ); + + let offset = raw.length; + raw = await writeTtyInputUntil( + proc, + options.transcript, + offset, + "?", + "terminal keyboard readiness", + (output) => output.includes("help") && output.includes("[Esc]"), + ); + offset = raw.length; + raw = await writeTtyInputUntil( + proc, + options.transcript, + offset, + "\x1b", + "help dialog dismissal", + options.initialPredicate, + ); + + if (options.interaction === "wrap" || options.interaction === "wrap-cycle") { + const offset = raw.length; + await writeTtyInput(proc, "w"); + raw = await waitForTranscriptUpdate( + proc, + options.transcript, + offset, + "wrapped terminal row", + (output) => output.includes("smoke coverage';"), + ); + } + + if (options.interaction === "wrap-cycle") { + let offset = raw.length; + await writeTtyInput(proc, "w"); + raw = await waitForTranscriptUpdate( + proc, + options.transcript, + offset, + "second wrap-toggle repaint", + (output) => output.trim().length > 0, + ); + + offset = raw.length; + await writeTtyInput(proc, "w"); + raw = await waitForTranscriptUpdate( + proc, + options.transcript, + offset, + "rewrapped terminal row", + (output) => output.includes("smoke coverage';"), + ); + } + + if (options.interaction === "page") { + const offset = raw.length; + await writeTtyInput(proc, " "); + raw = await waitForTranscriptUpdate( + proc, + options.transcript, + offset, + "paged terminal viewport", + (output) => output.includes("before_23") && output.includes("after_05"), + ); + } + + await writeTtyInput(proc, "q"); + if (!options.pager && options.interaction !== "quit") { + const outcome = await waitUntil("TTY exit or save-preferences prompt", async () => { + if (proc.exitCode !== null) { + return "exited" as const; + } + const output = stripTerminalControl(await readTranscript(options.transcript)); + return output.includes("Save view preferences?") ? ("prompt" as const) : null; + }); + if (outcome === "prompt") { + await writeTtyInput(proc, "q"); + } + } + + await waitForTtyExit(proc); + return stripTerminalControl(await readTranscript(options.transcript)); + } catch (error) { + proc.kill(); + await proc.exited.catch(() => undefined); + throw error; + } +} + async function runTtySmoke(options: { mode?: "split" | "stack"; pager?: boolean; agentContext?: boolean; - inputCommand?: string; + interaction?: TtyInteraction; longWrapFixture?: boolean; }) { const fixture = options.longWrapFixture ? createLongWrapFixtureFiles() : createFixtureFiles(); @@ -155,73 +434,48 @@ async function runTtySmoke(options: { if (options.mode) { args.push("--mode", options.mode); } - if (options.pager) { args.push("--pager"); } - if (options.agentContext && !options.longWrapFixture) { args.push("--agent-context", (fixture as ReturnType).agent); } - const hunkCommand = `bun run ${shellQuote(sourceEntrypoint)} ${args.map(shellQuote).join(" ")}`; - const scriptCommand = `timeout 7 script -q -f -e -c ${shellQuote(hunkCommand)} ${shellQuote(transcript)}`; - const inputCommand = options.inputCommand ?? `(sleep 2; printf q)`; - const proc = Bun.spawnSync(["bash", "-lc", `${inputCommand} | ${scriptCommand}`], { + const command = `bun run ${shellQuote(sourceEntrypoint)} ${args.map(shellQuote).join(" ")}`; + return driveTtySmoke({ + command, cwd: fixture.dir, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - env: { - ...process.env, - XDG_CONFIG_HOME: testConfigHome, - TERM: "xterm-256color", - HUNK_MCP_DISABLE: "1", - HUNK_DISABLE_UPDATE_NOTICE: "1", - }, + transcript, + initialPredicate: (output) => + options.longWrapFixture + ? output.includes("export const message") + : output.includes("export const answer"), + interaction: options.interaction ?? "quit", + pager: options.pager ?? false, }); - - if (proc.exitCode !== 0) { - const stderr = Buffer.from(proc.stderr).toString("utf8"); - throw new Error(stderr.trim() || `tty smoke command failed with exit ${proc.exitCode}`); - } - - return stripTerminalControl(await Bun.file(transcript).text()); } async function runStdinPagerSmoke(options?: { - input?: string; - inputCommand?: string; + interaction?: TtyInteraction; lines?: number; command?: "patch" | "pager"; }) { const fixture = createFixtureFiles(options?.lines ?? 1); const transcript = join(fixture.dir, "stdin-pager-transcript.txt"); const subcommand = options?.command === "pager" ? "pager" : "patch -"; - const patchCommand = `cat ${shellQuote(fixture.coloredPatch)} | bun run ${shellQuote(sourceEntrypoint)} ${subcommand}`; - const scriptCommand = `timeout 7 script -q -f -e -c ${shellQuote(patchCommand)} ${shellQuote(transcript)}`; - const inputCommand = - options?.inputCommand ?? `(sleep 2; printf ${shellQuote(options?.input ?? "q")})`; - const proc = Bun.spawnSync(["bash", "-lc", `${inputCommand} | ${scriptCommand}`], { + const command = `cat ${shellQuote(fixture.coloredPatch)} | bun run ${shellQuote(sourceEntrypoint)} ${subcommand}`; + + return driveTtySmoke({ + command, cwd: fixture.dir, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - env: { - ...process.env, - XDG_CONFIG_HOME: testConfigHome, - TERM: "xterm-256color", - HUNK_MCP_DISABLE: "1", - HUNK_DISABLE_UPDATE_NOTICE: "1", - }, + transcript, + initialPredicate: (output) => + options?.lines && options.lines > 1 + ? output.includes("before_01") + : output.includes("export const answer"), + interaction: options?.interaction ?? "quit", + pager: true, }); - - if (proc.exitCode !== 0) { - const stderr = Buffer.from(proc.stderr).toString("utf8"); - throw new Error(stderr.trim() || `stdin pager smoke command failed with exit ${proc.exitCode}`); - } - - return stripTerminalControl(await Bun.file(transcript).text()); } afterEach(() => { @@ -229,13 +483,9 @@ afterEach(() => { }); describe("TTY render smoke", () => { - const ttyTest = enableTtySmokeTests ? test : test.skip; + const ttyTest = enableTtySmokeTests && ttyToolsAvailable ? test : test.skip; ttyTest("split mode renders chrome and rails in a terminal transcript", async () => { - if (!ttyToolsAvailable) { - return; - } - const output = await runTtySmoke({ mode: "split", agentContext: true }); expect(output).toContain("View Navigate Agent Help"); @@ -247,29 +497,21 @@ describe("TTY render smoke", () => { }); ttyTest("regular mode can toggle wrapped lines from terminal input", async () => { - if (!ttyToolsAvailable) { - return; - } - const output = await runTtySmoke({ mode: "split", longWrapFixture: true, - inputCommand: `(sleep 2; printf w; sleep 1; printf q; sleep 1; printf q)`, + interaction: "wrap", }); expect(output).toContain("wrapped line for"); expect(output).toContain("tty smoke coverage';"); }); - ttyTest("regular mode can toggle wrapped lines on, off, and on again", async () => { - if (!ttyToolsAvailable) { - return; - } - + ttyTest("regular mode accepts repeated wrap toggles and ends wrapped", async () => { const output = await runTtySmoke({ mode: "split", longWrapFixture: true, - inputCommand: `(sleep 2; printf www; sleep 1; printf q; sleep 1; printf q)`, + interaction: "wrap-cycle", }); expect(output).toContain("wrapped line for"); @@ -279,10 +521,6 @@ describe("TTY render smoke", () => { ttyTest( "stack mode keeps the terminal-native stacked rows without split separators", async () => { - if (!ttyToolsAvailable) { - return; - } - const output = await runTtySmoke({ mode: "stack" }); expect(output).toContain("View Navigate Agent Help"); @@ -293,10 +531,6 @@ describe("TTY render smoke", () => { ); ttyTest("pager mode hides chrome while still rendering the diff transcript", async () => { - if (!ttyToolsAvailable) { - return; - } - const output = await runTtySmoke({ pager: true }); expect(output).not.toContain("View Navigate Agent Help"); @@ -306,15 +540,11 @@ describe("TTY render smoke", () => { }); ttyTest("pager mode can toggle wrapped lines from terminal input", async () => { - if (!ttyToolsAvailable) { - return; - } - const output = await runTtySmoke({ mode: "split", pager: true, longWrapFixture: true, - inputCommand: `(sleep 2; printf w; sleep 1; printf q)`, + interaction: "wrap", }); expect(output).toContain("wrapped line for t"); @@ -322,10 +552,6 @@ describe("TTY render smoke", () => { }); ttyTest("stdin patch mode auto-enters pager mode and can quit from terminal input", async () => { - if (!ttyToolsAvailable) { - return; - } - const output = await runStdinPagerSmoke(); expect(output).not.toContain("View Navigate Agent Help"); @@ -336,13 +562,9 @@ describe("TTY render smoke", () => { }); ttyTest("stdin pager mode pages forward by a full viewport on space", async () => { - if (!ttyToolsAvailable) { - return; - } - const output = await runStdinPagerSmoke({ lines: 40, - inputCommand: `(sleep 2; printf ' '; sleep 2; printf q)`, + interaction: "page", }); expect(output).toContain("before_23"); @@ -350,10 +572,6 @@ describe("TTY render smoke", () => { }); ttyTest("general pager mode opens Hunk pager UI for diff-like stdin", async () => { - if (!ttyToolsAvailable) { - return; - } - const output = await runStdinPagerSmoke({ command: "pager" }); expect(output).not.toContain("View Navigate Agent Help");