From dd36278af7a102ff65a2c960266d79e773ecf859 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 16 Jun 2026 14:29:07 -0400 Subject: [PATCH 1/2] test: cover mouse-driven diff selection and sidebar resize DiffPane and App had their largest uncovered clusters in mouse-drag interactions that no test exercised: diff-pane text selection (drag-extend, double/triple-click word/line expansion, OSC52 clipboard copy and its unsupported-terminal fallback) and sidebar drag-resize plus the edit-selected-file action. Drive these through the @opentui testRender mouse harness so the behavior users rely on is locked down. DiffPane.tsx rises from 91% to 97% and App.tsx from 90% to 97% line coverage; no production code changes. Co-Authored-By: Claude Opus 4.8 --- .changeset/ui-mouse-interaction-coverage.md | 4 + src/ui/AppHost.selection.test.tsx | 348 ++++++++++++++++++++ src/ui/AppHost.sidebar-resize.test.tsx | 178 ++++++++++ 3 files changed, 530 insertions(+) create mode 100644 .changeset/ui-mouse-interaction-coverage.md create mode 100644 src/ui/AppHost.selection.test.tsx create mode 100644 src/ui/AppHost.sidebar-resize.test.tsx diff --git a/.changeset/ui-mouse-interaction-coverage.md b/.changeset/ui-mouse-interaction-coverage.md new file mode 100644 index 00000000..7be3643f --- /dev/null +++ b/.changeset/ui-mouse-interaction-coverage.md @@ -0,0 +1,4 @@ +--- +--- + +Add render-level unit coverage for mouse-driven UI interactions: diff-pane text selection (drag-extend, double/triple-click word and line expansion, OSC52 clipboard copy and its unsupported-terminal fallback) and sidebar drag-resize plus the edit-selected-file action. Lifts `DiffPane.tsx` from 91% to 97% and `App.tsx` from 90% to 97% line coverage. Test-only; no user-visible change. diff --git a/src/ui/AppHost.selection.test.tsx b/src/ui/AppHost.selection.test.tsx new file mode 100644 index 00000000..754c8546 --- /dev/null +++ b/src/ui/AppHost.selection.test.tsx @@ -0,0 +1,348 @@ +import { describe, expect, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { MouseButtons } from "@opentui/core/testing"; +import { act } from "react"; +import type { AppBootstrap } from "../core/types"; +import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap"; +import { createTestDiffFile, lines } from "../../test/helpers/diff-helpers"; + +// These tests drive the DiffPane mouse-drag text-selection path end to end: begin/update/end +// copy-selection, double/triple-click word and line expansion, and the OSC 52 clipboard copy. +// They use the real @opentui render harness with synthetic mouse events, so the selection +// geometry runs against an actual rendered frame rather than a mocked layout. + +/** Build a diff with many distinct changed rows so drags can span several review rows. */ +function createSelectionBootstrap(): AppBootstrap { + const before = Array.from( + { length: 24 }, + (_, index) => `export const item${String(index + 1).padStart(2, "0")} = ${index + 1};`, + ); + // Change every row so the review stream is dense with selectable changed code. + const after = before.map((line, index) => line.replace(/= \d+;/, `= ${(index + 1) * 1000};`)); + + return createTestVcsAppBootstrap({ + changesetId: "changeset:copy-selection", + files: [ + createTestDiffFile({ + id: "selection", + path: "selection.ts", + before: lines(...before), + after: lines(...after), + context: 1, + }), + ], + initialMode: "stack", + initialCopyDecorations: true, + }); +} + +/** Build a split-layout diff so the copy can resolve an old/new side from the drag column. */ +function createSplitSelectionBootstrap(): AppBootstrap { + const bootstrap = createSelectionBootstrap(); + return { ...bootstrap, initialMode: "split", input: { ...bootstrap.input } }; +} + +type Harness = Awaited>; + +/** Settle pending renders so a frame reflects the latest interaction. */ +async function flush(setup: Harness) { + await act(async () => { + await setup.renderOnce(); + await Bun.sleep(0); + await setup.renderOnce(); + }); +} + +/** Poll rendered frames until `predicate` matches, resilient to async repaints. */ +async function waitForFrame(setup: Harness, predicate: (frame: string) => boolean, attempts = 10) { + let frame = setup.captureCharFrame(); + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (predicate(frame)) { + return frame; + } + await act(async () => { + await Bun.sleep(20); + await setup.renderOnce(); + }); + frame = setup.captureCharFrame(); + } + return frame; +} + +/** Find the screen position of the first occurrence of `needle` in the rendered frame. */ +function locateText(frame: string, needle: string) { + const rows = frame.split("\n"); + for (let y = 0; y < rows.length; y += 1) { + const x = rows[y]?.indexOf(needle) ?? -1; + if (x >= 0) { + return { x, y }; + } + } + return null; +} + +/** + * Render the selection app and capture every OSC 52 clipboard write. + * + * `useRenderer()` inside DiffPane returns this same renderer instance, so forcing OSC 52 support + * and spying on the copy method makes the clipboard side effect deterministic and observable. + */ +async function renderSelectionApp( + bootstrap: AppBootstrap, + { + width = 110, + height = 26, + osc52 = true, + }: { width?: number; height?: number; osc52?: boolean } = {}, +) { + const { AppHost } = await import("./AppHost"); + const setup = await testRender(, { width, height }); + + const copied: string[] = []; + setup.renderer.isOsc52Supported = () => osc52; + setup.renderer.copyToClipboardOSC52 = (text: string) => { + copied.push(text); + return true; + }; + + await flush(setup); + return { setup, copied }; +} + +describe("DiffPane copy selection", () => { + test("dragging across changed rows copies the rendered selection to the clipboard", async () => { + const { setup, copied } = await renderSelectionApp(createSelectionBootstrap()); + + try { + const frame = setup.captureCharFrame(); + const start = locateText(frame, "item01"); + const end = locateText(frame, "item05"); + expect(start).not.toBeNull(); + expect(end).not.toBeNull(); + + await act(async () => { + await setup.mockMouse.drag(start!.x + 2, start!.y, end!.x + 4, end!.y, MouseButtons.LEFT); + }); + await flush(setup); + + // The drag moved across rows, so release copies the rendered text and shows feedback. + expect(copied.length).toBeGreaterThan(0); + expect(copied.join("\n")).toContain("item"); + const noticeFrame = await waitForFrame(setup, (text) => + text.includes("Copied selection to clipboard"), + ); + expect(noticeFrame).toContain("Copied selection to clipboard"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("double-clicking a token selects and copies the word under the pointer", async () => { + const { setup, copied } = await renderSelectionApp(createSelectionBootstrap()); + + try { + const frame = setup.captureCharFrame(); + const target = locateText(frame, "item05"); + expect(target).not.toBeNull(); + + await act(async () => { + await setup.mockMouse.doubleClick(target!.x + 2, target!.y, MouseButtons.LEFT); + }); + await flush(setup); + + expect(copied.length).toBeGreaterThan(0); + // Word expansion copies a single contiguous token, not a whole multi-token line. + expect(copied.some((text) => text.includes("item") && !text.includes(" "))).toBe(true); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("triple-clicking a row selects and copies the whole line", async () => { + const { setup, copied } = await renderSelectionApp(createSelectionBootstrap()); + + try { + const frame = setup.captureCharFrame(); + const target = locateText(frame, "item06"); + expect(target).not.toBeNull(); + + // Three rapid clicks at the same point escalate to a full-line selection. + await act(async () => { + await setup.mockMouse.click(target!.x + 2, target!.y, MouseButtons.LEFT); + await setup.mockMouse.click(target!.x + 2, target!.y, MouseButtons.LEFT); + await setup.mockMouse.click(target!.x + 2, target!.y, MouseButtons.LEFT); + }); + await flush(setup); + + expect(copied.length).toBeGreaterThan(0); + // Line expansion copies the full token sequence including the assignment. + expect(copied.some((text) => text.includes("item06") && text.includes("="))).toBe(true); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("a press and release without movement does not copy anything", async () => { + const { setup, copied } = await renderSelectionApp(createSelectionBootstrap()); + + try { + const frame = setup.captureCharFrame(); + const target = locateText(frame, "item07"); + expect(target).not.toBeNull(); + + await act(async () => { + await setup.mockMouse.pressDown(target!.x + 2, target!.y, MouseButtons.LEFT); + }); + await act(async () => { + await setup.mockMouse.release(target!.x + 2, target!.y, MouseButtons.LEFT); + }); + await flush(setup); + + // endCopySelection returns early when the drag never moved, so nothing is copied. + expect(copied.length).toBe(0); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("pressing outside the review viewport clears any pending selection", async () => { + const { setup, copied } = await renderSelectionApp(createSelectionBootstrap()); + + try { + // Row 0 is the menu bar / top chrome, which resolves to no review-row point. + await act(async () => { + await setup.mockMouse.pressDown(40, 0, MouseButtons.LEFT); + }); + await act(async () => { + await setup.mockMouse.release(40, 0, MouseButtons.LEFT); + }); + await flush(setup); + + expect(copied.length).toBe(0); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("a non-left mouse button never starts a selection", async () => { + const { setup, copied } = await renderSelectionApp(createSelectionBootstrap()); + + try { + const frame = setup.captureCharFrame(); + const target = locateText(frame, "item04"); + expect(target).not.toBeNull(); + + // Right-button drags belong to other handlers, so the copy-selection path bails immediately. + await act(async () => { + await setup.mockMouse.drag( + target!.x + 2, + target!.y, + target!.x + 8, + target!.y + 2, + MouseButtons.RIGHT, + ); + }); + await flush(setup); + + expect(copied.length).toBe(0); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("a terminal without OSC 52 support reports that copying is unavailable", async () => { + const { setup, copied } = await renderSelectionApp(createSelectionBootstrap(), { + osc52: false, + }); + + try { + const frame = setup.captureCharFrame(); + const start = locateText(frame, "item02"); + const end = locateText(frame, "item06"); + expect(start).not.toBeNull(); + expect(end).not.toBeNull(); + + await act(async () => { + await setup.mockMouse.drag(start!.x + 2, start!.y, end!.x + 4, end!.y, MouseButtons.LEFT); + }); + await flush(setup); + + // The drag still resolves a selection, but the unsupported terminal falls back to a notice. + expect(copied.length).toBe(0); + const noticeFrame = await waitForFrame(setup, (text) => text.includes("Clipboard copy")); + expect(noticeFrame).toContain("Clipboard copy"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("a drag starting on the pinned file header resolves a header selection point", async () => { + const { setup, copied } = await renderSelectionApp(createSelectionBootstrap()); + + try { + const frame = setup.captureCharFrame(); + const header = locateText(frame, "selection.ts"); + const body = locateText(frame, "item06"); + expect(header).not.toBeNull(); + expect(body).not.toBeNull(); + + // Begin the drag on the always-pinned header row, then move down into the diff body. + await act(async () => { + await setup.mockMouse.drag( + header!.x + 2, + header!.y, + body!.x + 4, + body!.y, + MouseButtons.LEFT, + ); + }); + await flush(setup); + + // Dragging from the pinned header into the body still produces a copied selection. + expect(copied.length).toBeGreaterThan(0); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("dragging in split layout resolves a side and copies one column of code", async () => { + const { setup, copied } = await renderSelectionApp(createSplitSelectionBootstrap(), { + width: 160, + }); + + try { + const frame = setup.captureCharFrame(); + const start = locateText(frame, "item01"); + const end = locateText(frame, "item03"); + expect(start).not.toBeNull(); + expect(end).not.toBeNull(); + + await act(async () => { + await setup.mockMouse.drag(start!.x + 2, start!.y, end!.x + 2, end!.y, MouseButtons.LEFT); + }); + await flush(setup); + + expect(copied.length).toBeGreaterThan(0); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); +}); diff --git a/src/ui/AppHost.sidebar-resize.test.tsx b/src/ui/AppHost.sidebar-resize.test.tsx new file mode 100644 index 00000000..fc626f89 --- /dev/null +++ b/src/ui/AppHost.sidebar-resize.test.tsx @@ -0,0 +1,178 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act } from "react"; +import type { AppBootstrap } from "../core/types"; +import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap"; +import { createTestDiffFile as buildTestDiffFile, lines } from "../../test/helpers/diff-helpers"; + +const { AppHost } = await import("./AppHost"); + +/** A wide terminal so the responsive layout always shows the resizable sidebar. */ +const WIDE = { width: 240, height: 24 }; +// Default sidebar width (34) plus the body's 1-column left padding puts the divider at column 35. +const INITIAL_DIVIDER_COLUMN = 35; +// A stable mid-height row that always falls inside the sidebar/divider band. +const PROBE_ROW = 10; + +function createTestDiffFile(id: string, path: string, before: string, after: string) { + return buildTestDiffFile({ after, agent: false, before, context: 3, id, path }); +} + +/** Two-file split-view bootstrap whose sidebar is wide enough to drag. */ +function createResizeBootstrap(): AppBootstrap { + return createTestVcsAppBootstrap({ + changesetId: "changeset:sidebar-resize", + initialMode: "split", + files: [ + createTestDiffFile( + "alpha", + "src/alpha.ts", + lines("export const a = 1;", "export const b = 2;"), + lines("export const a = 10;", "export const b = 2;"), + ), + createTestDiffFile( + "beta", + "src/beta.ts", + lines("export const c = 3;"), + lines("export const c = 30;"), + ), + ], + }); +} + +/** Drive one or two render passes so pending state commits land before assertions. */ +async function flush(setup: Awaited>) { + await act(async () => { + await setup.renderOnce(); + await Bun.sleep(0); + await setup.renderOnce(); + }); +} + +/** Column of the vertical sidebar/diff divider on the probe row, or -1 when absent. */ +function dividerColumn(setup: Awaited>) { + const row = setup.captureCharFrame().split("\n")[PROBE_ROW] ?? ""; + return row.indexOf("│"); +} + +/** + * Press the divider, drag to a target x, then release. The resize handlers read React state + * (`isResizingSidebar`), so each phase needs its own commit before the next event's closure sees + * the updated state — hence the flush between press, drag, and release. + */ +async function dragDivider( + setup: Awaited>, + fromX: number, + toX: number, +) { + await act(async () => { + await setup.mockMouse.pressDown(fromX, PROBE_ROW); + }); + await flush(setup); + await act(async () => { + await setup.mockMouse.moveTo(toX, PROBE_ROW); + }); + await flush(setup); + await act(async () => { + await setup.mockMouse.release(toX, PROBE_ROW); + }); + await flush(setup); +} + +let setup: Awaited> | null = null; + +beforeEach(() => { + setup = null; +}); + +afterEach(() => { + setup?.renderer.destroy(); + setup = null; +}); + +describe("AppHost sidebar resize", () => { + test("dragging the divider rightward widens the sidebar", async () => { + setup = await testRender(, WIDE); + await flush(setup); + expect(dividerColumn(setup)).toBe(INITIAL_DIVIDER_COLUMN); + + await dragDivider(setup, INITIAL_DIVIDER_COLUMN, INITIAL_DIVIDER_COLUMN + 30); + + // The divider follows the new width: startWidth + (currentX - originX). + expect(dividerColumn(setup)).toBeGreaterThan(INITIAL_DIVIDER_COLUMN); + }); + + test("dragging the divider far left clamps the sidebar at its minimum width", async () => { + setup = await testRender(, WIDE); + await flush(setup); + + await dragDivider(setup, INITIAL_DIVIDER_COLUMN, 2); + + // SIDEBAR_MIN_WIDTH is 22, plus the 1-column body padding => divider clamps at column 23. + expect(dividerColumn(setup)).toBe(23); + }); + + test("a mouse release with no active drag leaves the layout unchanged", async () => { + setup = await testRender(, WIDE); + await flush(setup); + const before = setup.captureCharFrame(); + + await act(async () => { + await setup!.mockMouse.release(INITIAL_DIVIDER_COLUMN + 40, PROBE_ROW); + }); + await flush(setup); + + expect(setup.captureCharFrame()).toBe(before); + expect(dividerColumn(setup)).toBe(INITIAL_DIVIDER_COLUMN); + }); + + test("a non-left mouse button on the divider does not start a resize", async () => { + setup = await testRender(, WIDE); + await flush(setup); + + // Right button (2) should be ignored by beginSidebarResize. + await act(async () => { + await setup!.mockMouse.pressDown(INITIAL_DIVIDER_COLUMN, PROBE_ROW, 2); + }); + await flush(setup); + await act(async () => { + await setup!.mockMouse.moveTo(INITIAL_DIVIDER_COLUMN + 30, PROBE_ROW); + }); + await flush(setup); + await act(async () => { + await setup!.mockMouse.release(INITIAL_DIVIDER_COLUMN + 30, PROBE_ROW, 2); + }); + await flush(setup); + + expect(dividerColumn(setup)).toBe(INITIAL_DIVIDER_COLUMN); + }); +}); + +describe("AppHost edit-selected-file shortcut", () => { + const originalEditor = process.env.EDITOR; + + beforeEach(() => { + delete process.env.EDITOR; + }); + + afterEach(() => { + if (originalEditor === undefined) { + delete process.env.EDITOR; + } else { + process.env.EDITOR = originalEditor; + } + }); + + test("pressing e with no $EDITOR surfaces a notice instead of crashing", async () => { + setup = await testRender(, WIDE); + await flush(setup); + + await act(async () => { + await setup!.mockInput.typeText("e"); + }); + await flush(setup); + + // openSelectedFileInEditor returns "$EDITOR is not set." which shows as a session notice. + expect(setup.captureCharFrame()).toContain("EDITOR is not set"); + }); +}); From 82df4d70de4f54eee66a3d93cc12b980174303d2 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 16 Jun 2026 15:41:24 -0400 Subject: [PATCH 2/2] test: harden selection-test diagnostics and renderer cleanup Address review feedback on the new copy-selection tests: surface a warning when waitForFrame exhausts its retries so a follow-up assertion failure points at the unmet condition instead of a generic substring miss, and destroy the renderer if the initial settle throws before the caller's try/finally can take over. Co-Authored-By: Claude Opus 4.8 --- src/ui/AppHost.selection.test.tsx | 33 ++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/src/ui/AppHost.selection.test.tsx b/src/ui/AppHost.selection.test.tsx index 754c8546..a1a13552 100644 --- a/src/ui/AppHost.selection.test.tsx +++ b/src/ui/AppHost.selection.test.tsx @@ -54,7 +54,12 @@ async function flush(setup: Harness) { } /** Poll rendered frames until `predicate` matches, resilient to async repaints. */ -async function waitForFrame(setup: Harness, predicate: (frame: string) => boolean, attempts = 10) { +async function waitForFrame( + setup: Harness, + predicate: (frame: string) => boolean, + description = "frame predicate", + attempts = 10, +) { let frame = setup.captureCharFrame(); for (let attempt = 0; attempt < attempts; attempt += 1) { if (predicate(frame)) { @@ -66,6 +71,9 @@ async function waitForFrame(setup: Harness, predicate: (frame: string) => boolea }); frame = setup.captureCharFrame(); } + // Surface the timeout so a follow-up assertion failure points at the unmet condition + // rather than a generic "string does not contain" message during flake investigations. + console.warn(`waitForFrame: "${description}" never matched after ${attempts} attempts`); return frame; } @@ -105,7 +113,16 @@ async function renderSelectionApp( return true; }; - await flush(setup); + // Destroy the renderer if the initial settle throws, since the caller's try/finally only + // takes over once this helper has returned the harness. + try { + await flush(setup); + } catch (error) { + await act(async () => { + setup.renderer.destroy(); + }); + throw error; + } return { setup, copied }; } @@ -128,8 +145,10 @@ describe("DiffPane copy selection", () => { // The drag moved across rows, so release copies the rendered text and shows feedback. expect(copied.length).toBeGreaterThan(0); expect(copied.join("\n")).toContain("item"); - const noticeFrame = await waitForFrame(setup, (text) => - text.includes("Copied selection to clipboard"), + const noticeFrame = await waitForFrame( + setup, + (text) => text.includes("Copied selection to clipboard"), + "copied-selection notice", ); expect(noticeFrame).toContain("Copied selection to clipboard"); } finally { @@ -281,7 +300,11 @@ describe("DiffPane copy selection", () => { // The drag still resolves a selection, but the unsupported terminal falls back to a notice. expect(copied.length).toBe(0); - const noticeFrame = await waitForFrame(setup, (text) => text.includes("Clipboard copy")); + const noticeFrame = await waitForFrame( + setup, + (text) => text.includes("Clipboard copy"), + "clipboard-unsupported notice", + ); expect(noticeFrame).toContain("Clipboard copy"); } finally { await act(async () => {