diff --git a/src/core/errors.test.ts b/src/core/errors.test.ts new file mode 100644 index 00000000..b5bb0cfe --- /dev/null +++ b/src/core/errors.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { formatCliError, HunkUserError } from "./errors"; + +const originalDebug = process.env.HUNK_DEBUG; + +afterEach(() => { + if (originalDebug === undefined) { + delete process.env.HUNK_DEBUG; + } else { + process.env.HUNK_DEBUG = originalDebug; + } +}); + +describe("formatCliError", () => { + test("formats expected user errors with optional details and no stack", () => { + expect(formatCliError(new HunkUserError("Not in a repo"))).toBe("hunk: Not in a repo\n"); + expect(formatCliError(new HunkUserError("Invalid ref", ["Try `HEAD~1`."]))).toBe( + "hunk: Invalid ref\n\nTry `HEAD~1`.\n", + ); + }); + + test("hides unexpected stacks unless debug output is explicitly enabled", () => { + const error = new Error("Boom"); + error.stack = "Error: Boom\n at internal"; + + delete process.env.HUNK_DEBUG; + expect(formatCliError(error)).toBe("hunk: Boom\n"); + + process.env.HUNK_DEBUG = "1"; + expect(formatCliError(error)).toBe("Error: Boom\n at internal\n"); + }); + + test("stringifies non-error thrown values", () => { + expect(formatCliError("plain failure")).toBe("hunk: plain failure\n"); + }); +}); diff --git a/src/hunk-session/cli.test.ts b/src/hunk-session/cli.test.ts new file mode 100644 index 00000000..bebd095d --- /dev/null +++ b/src/hunk-session/cli.test.ts @@ -0,0 +1,497 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + createTestListedSession, + createTestSelectedSessionContext, + createTestSessionFileSummary, + createTestSessionLiveComment, + createTestSessionReview, + createTestSessionReviewFile, + createTestSessionReviewHunk, + createTestSessionSnapshot, +} from "../../test/helpers/session-daemon-fixtures"; +import type { SessionSelectorInput } from "../core/types"; +import { + HUNK_SESSION_API_PATH, + HUNK_SESSION_API_VERSION, + HUNK_SESSION_DAEMON_VERSION, +} from "../session/protocol"; +import { + createHttpHunkSessionCliClient, + formatClearCommentsOutput, + formatCommentApplyOutput, + formatCommentListOutput, + formatCommentOutput, + formatContextOutput, + formatListOutput, + formatNavigationOutput, + formatReloadOutput, + formatRemoveCommentOutput, + formatReviewOutput, + formatSessionOutput, +} from "./cli"; + +const selector = { sessionId: "session-1" } satisfies SessionSelectorInput; +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("HTTP Hunk session CLI client", () => { + test("maps CLI methods onto the daemon session API envelope", async () => { + const requests: unknown[] = []; + const session = createTestListedSession(); + const context = createTestSelectedSessionContext(); + const review = createTestSessionReview(); + const comment = { + commentId: "comment-1", + fileId: "file-1", + filePath: "src/app.ts", + hunkIndex: 0, + side: "new" as const, + line: 12, + }; + const responses = { + list: { sessions: [session] }, + get: { session }, + context: { context }, + review: { review }, + navigate: { + result: { + fileId: "file-1", + filePath: "src/app.ts", + hunkIndex: 1, + }, + }, + reload: { + result: { + sessionId: "session-1", + inputKind: "vcs" as const, + title: "repo working tree", + sourceLabel: "/repo", + fileCount: 1, + selectedFilePath: "src/app.ts", + selectedHunkIndex: 0, + }, + }, + "comment-add": { result: comment }, + "comment-apply": { result: { applied: [comment] } }, + "comment-list": { comments: [createTestSessionLiveComment()] }, + "comment-rm": { + result: { + commentId: "comment-1", + removed: true, + remainingCommentCount: 0, + }, + }, + "comment-clear": { + result: { + removedCount: 1, + remainingCommentCount: 0, + filePath: "src/app.ts", + }, + }, + }; + + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url.endsWith(`${HUNK_SESSION_API_PATH}/capabilities`)) { + return Response.json({ + version: HUNK_SESSION_API_VERSION, + daemonVersion: HUNK_SESSION_DAEMON_VERSION, + actions: Object.keys(responses), + }); + } + + expect(url).toEndWith(HUNK_SESSION_API_PATH); + expect(init?.method).toBe("POST"); + const request = JSON.parse(String(init?.body)); + requests.push(request); + return Response.json(responses[request.action as keyof typeof responses]); + }) as typeof fetch; + + const client = createHttpHunkSessionCliClient(); + + expect(await client.getCapabilities()).toMatchObject({ version: HUNK_SESSION_API_VERSION }); + expect(await client.listSessions()).toEqual([session]); + expect(await client.getSession(selector)).toEqual(session); + expect(await client.getSelectedContext(selector)).toEqual(context); + expect( + await client.getSessionReview({ + kind: "session", + action: "review", + selector, + output: "json", + includePatch: true, + }), + ).toEqual(review); + expect( + await client.navigateToHunk({ + kind: "session", + action: "navigate", + selector, + filePath: "src/app.ts", + hunkNumber: 2, + side: "new", + line: 12, + commentDirection: "next", + output: "json", + }), + ).toEqual({ fileId: "file-1", filePath: "src/app.ts", hunkIndex: 1 }); + expect( + await client.reloadSession({ + kind: "session", + action: "reload", + selector, + nextInput: { kind: "vcs", staged: false, options: {} }, + sourcePath: "/repo", + output: "json", + }), + ).toMatchObject({ title: "repo working tree" }); + expect( + await client.addComment({ + kind: "session", + action: "comment-add", + selector, + filePath: "src/app.ts", + side: "new", + line: 12, + summary: "Check this", + rationale: "Preserve mapping", + author: "pi", + reveal: true, + output: "json", + }), + ).toEqual(comment); + expect( + await client.applyComments({ + kind: "session", + action: "comment-apply", + selector, + comments: [{ filePath: "src/app.ts", summary: "Check this" }], + revealMode: "first", + output: "json", + }), + ).toEqual({ applied: [comment] }); + expect( + await client.listComments({ + kind: "session", + action: "comment-list", + selector, + filePath: "src/app.ts", + output: "json", + }), + ).toEqual([createTestSessionLiveComment()]); + expect( + await client.removeComment({ + kind: "session", + action: "comment-rm", + selector, + commentId: "comment-1", + output: "json", + }), + ).toMatchObject({ removed: true }); + expect( + await client.clearComments({ + kind: "session", + action: "comment-clear", + selector, + filePath: "src/app.ts", + confirmed: true, + output: "json", + }), + ).toMatchObject({ removedCount: 1 }); + + expect(requests).toEqual([ + { action: "list" }, + { action: "get", selector }, + { action: "context", selector }, + { action: "review", selector, includePatch: true }, + { + action: "navigate", + selector, + filePath: "src/app.ts", + hunkNumber: 2, + side: "new", + line: 12, + commentDirection: "next", + }, + { + action: "reload", + selector, + nextInput: { kind: "vcs", staged: false, options: {} }, + sourcePath: "/repo", + }, + { + action: "comment-add", + selector, + filePath: "src/app.ts", + side: "new", + line: 12, + summary: "Check this", + rationale: "Preserve mapping", + author: "pi", + reveal: true, + }, + { + action: "comment-apply", + selector, + comments: [{ filePath: "src/app.ts", summary: "Check this" }], + revealMode: "first", + }, + { action: "comment-list", selector, filePath: "src/app.ts" }, + { action: "comment-rm", selector, commentId: "comment-1" }, + { action: "comment-clear", selector, filePath: "src/app.ts" }, + ]); + }); + + test("throws daemon response errors with JSON messages or status text fallbacks", async () => { + globalThis.fetch = (async () => + Response.json( + { error: "No matching session." }, + { status: 404, statusText: "Not Found" }, + )) as unknown as typeof fetch; + + const client = createHttpHunkSessionCliClient(); + await expect(client.listSessions()).rejects.toThrow("No matching session."); + + globalThis.fetch = (async () => + new Response("not json", { + status: 500, + statusText: "Daemon exploded", + })) as unknown as typeof fetch; + + await expect(client.listSessions()).rejects.toThrow("Daemon exploded"); + }); +}); + +describe("Hunk session CLI formatters", () => { + test("list and get output preserve terminal metadata and selected hunk summaries", () => { + const session = createTestListedSession({ + files: [createTestSessionFileSummary({ path: "src/app.ts", additions: 3, deletions: 1 })], + snapshot: createTestSessionSnapshot({ + selectedFilePath: "src/app.ts", + selectedHunkIndex: 2, + showAgentNotes: true, + liveCommentCount: 4, + }), + terminal: { + program: "ghostty", + locations: [ + { source: "tty", tty: "/dev/ttys005" }, + { source: "tmux", paneId: "%7", sessionId: "work" }, + { source: "iterm2", windowId: "1", tabId: "2", paneId: "3", terminalId: "abc" }, + { source: "unknown" }, + ], + }, + }); + + expect(formatListOutput([session])).toBe( + [ + "session-1 repo working tree", + " path: /repo", + " repo: /repo", + " terminal: ghostty", + " location[tty]: /dev/ttys005", + " location[tmux]: pane %7, session work", + " location[iterm2]: window 1, tab 2, pane 3, terminal abc", + " location[unknown]: present", + " focus: src/app.ts hunk 3", + " files: 1", + " comments: 4", + "", + ].join("\n"), + ); + + expect(formatSessionOutput(session)).toContain("Selected: src/app.ts hunk 3\n"); + expect(formatSessionOutput(session)).toContain("Agent notes visible: yes\n"); + expect(formatSessionOutput(session)).toContain("Live comments: 4\n"); + expect(formatSessionOutput(session)).toContain(" - src/app.ts (+3 -1, hunks: 1)"); + }); + + test("empty and unselected summaries stay explicit in human-readable output", () => { + const session = createTestListedSession({ + snapshot: createTestSessionSnapshot({ + selectedFileId: undefined, + selectedFilePath: undefined, + selectedHunkIndex: 0, + }), + }); + const context = createTestSelectedSessionContext({ + cwd: undefined, + repoRoot: undefined, + selectedFile: null, + selectedHunk: null, + showAgentNotes: true, + liveCommentCount: 2, + }); + + expect(formatListOutput([])).toBe("No active Hunk sessions.\n"); + expect(formatListOutput([session])).toContain(" focus: (none)\n"); + expect(formatContextOutput(context)).toBe( + [ + "Session: session-1", + "Title: repo diff", + "Path: -", + "Repo: -", + "File: (none)", + "Hunk: -", + "Old range: -", + "New range: -", + "Agent notes visible: yes", + "Live comments: 2", + "", + ].join("\n"), + ); + }); + + test("review output keeps file order, hunk headers, and no-selection fallbacks", () => { + const firstFile = createTestSessionReviewFile({ + id: "file-1", + path: "src/first.ts", + additions: 2, + deletions: 1, + hunkCount: 2, + hunks: [ + createTestSessionReviewHunk({ index: 0, header: "@@ -1,1 +1,2 @@" }), + createTestSessionReviewHunk({ index: 1, header: "@@ -10,1 +11,1 @@" }), + ], + }); + const secondFile = createTestSessionReviewFile({ + id: "file-2", + path: "src/second.ts", + additions: 0, + deletions: 1, + hunkCount: 1, + }); + + expect( + formatReviewOutput( + createTestSessionReview({ + files: [firstFile, secondFile], + selectedFile: null, + selectedHunk: null, + title: "repo diff", + inputKind: "diff", + liveCommentCount: 1, + }), + ), + ).toBe( + [ + "Session: session-1", + "Title: repo diff", + "Source: /repo", + "Path: -", + "Repo: /repo", + "Input: diff", + "Selected file: (none)", + "Selected hunk: -", + "Agent notes visible: no", + "Live comments: 1", + "Files:", + " - src/first.ts (+2 -1, hunks: 2)", + " hunk 1: @@ -1,1 +1,2 @@", + " hunk 2: @@ -10,1 +11,1 @@", + " - src/second.ts (+0 -1, hunks: 1)", + " hunk 1: @@ -1,1 +1,1 @@", + "", + ].join("\n"), + ); + }); + + test("command result formatters describe comment and navigation side effects", () => { + expect( + formatNavigationOutput(selector, { + fileId: "file-1", + filePath: "src/app.ts", + hunkIndex: 1, + }), + ).toBe("Focused src/app.ts hunk 2 in session session-1.\n"); + + expect( + formatReloadOutput(selector, { + sessionId: "session-1", + inputKind: "vcs", + title: "repo working tree", + sourceLabel: "/repo", + fileCount: 0, + selectedFilePath: undefined, + selectedHunkIndex: 0, + }), + ).toBe("Reloaded session session-1 with repo working tree (0 files). Selected: (no files).\n"); + + expect( + formatCommentOutput(selector, { + commentId: "comment-1", + fileId: "file-1", + filePath: "src/app.ts", + hunkIndex: 0, + side: "new", + line: 12, + }), + ).toBe( + "Added live comment comment-1 on src/app.ts:12 (new) in hunk 1 for session session-1.\n", + ); + + expect(formatCommentApplyOutput(selector, { applied: [] })).toBe( + "Applied 0 live comments to session session-1.\n", + ); + expect( + formatCommentApplyOutput(selector, { + applied: [ + { + commentId: "comment-2", + fileId: "file-1", + filePath: "src/app.ts", + hunkIndex: 2, + side: "old", + line: 8, + }, + ], + }), + ).toBe( + "Applied 1 live comments to session session-1:\n - comment-2 on src/app.ts:8 (old) hunk 3\n", + ); + + expect(formatCommentListOutput(selector, [])).toBe("No live comments for session session-1.\n"); + expect( + formatCommentListOutput(selector, [ + createTestSessionLiveComment({ + commentId: "comment-3", + filePath: "src/app.ts", + hunkIndex: 1, + side: "new", + line: 20, + summary: "Check this branch", + author: "pi", + }), + ]), + ).toBe( + "comment-3 src/app.ts:20 (new)\n hunk: 2\n summary: Check this branch\n author: pi\n", + ); + + expect( + formatRemoveCommentOutput(selector, { + commentId: "comment-3", + removed: true, + remainingCommentCount: 1, + }), + ).toBe("Removed live comment comment-3 from session session-1. Remaining comments: 1.\n"); + + expect( + formatClearCommentsOutput(selector, { + filePath: "src/app.ts", + removedCount: 2, + remainingCommentCount: 3, + }), + ).toBe( + "Cleared 2 live comments from src/app.ts in session session-1. Remaining comments: 3.\n", + ); + expect( + formatClearCommentsOutput(selector, { + removedCount: 5, + remainingCommentCount: 0, + }), + ).toBe("Cleared 5 live comments from session session-1. Remaining comments: 0.\n"); + }); +}); diff --git a/src/ui/diff/plannedReviewRows.test.ts b/src/ui/diff/plannedReviewRows.test.ts new file mode 100644 index 00000000..66205d04 --- /dev/null +++ b/src/ui/diff/plannedReviewRows.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from "bun:test"; +import { reviewRowId } from "../lib/ids"; +import type { PlannedReviewRow } from "./reviewRenderPlan"; +import { + measurePlannedSectionGeometry, + plannedReviewRowHeight, + plannedReviewRowVisible, +} from "./plannedReviewRows"; + +const baseOptions = { + showHunkHeaders: true, + layout: "split", + width: 100, +} as const; + +function hunkHeader(key: string, hunkIndex: number, anchorId?: string): PlannedReviewRow { + return { + kind: "diff-row", + key, + stableKey: key, + fileId: "file-1", + hunkIndex, + anchorId, + row: { + type: "hunk-header", + key, + fileId: "file-1", + hunkIndex, + text: "@@ -1,1 +1,1 @@", + }, + }; +} + +function collapsedRow(key: string, hunkIndex: number): PlannedReviewRow { + return { + kind: "diff-row", + key, + stableKey: key, + fileId: "file-1", + hunkIndex, + row: { + type: "collapsed", + key, + fileId: "file-1", + hunkIndex, + text: "⋯", + }, + }; +} + +function splitLine(key: string, hunkIndex: number, anchorId?: string): PlannedReviewRow { + return { + kind: "diff-row", + key, + stableKey: key, + fileId: "file-1", + hunkIndex, + anchorId, + row: { + type: "split-line", + key, + fileId: "file-1", + hunkIndex, + left: { + kind: "deletion", + sign: "-", + lineNumber: 1, + spans: [{ text: "old" }], + }, + right: { + kind: "addition", + sign: "+", + lineNumber: 1, + spans: [{ text: "new" }], + }, + }, + }; +} + +function inlineNote(key: string, hunkIndex: number): PlannedReviewRow { + return { + kind: "inline-note", + key, + stableKey: key, + fileId: "file-1", + hunkIndex, + annotationId: "note-1", + annotation: { + id: "note-1", + newRange: [1, 1], + summary: "Explain why this branch changed.", + rationale: "The note should reserve space in the hunk bounds.", + }, + anchorSide: "new", + noteCount: 1, + noteIndex: 0, + }; +} + +function guideCap(key: string, hunkIndex: number): PlannedReviewRow { + return { + kind: "note-guide-cap", + key, + stableKey: key, + fileId: "file-1", + hunkIndex, + side: "new", + }; +} + +describe("planned review row geometry", () => { + test("row height and visibility match the terminal rows each planned row renders", () => { + expect(plannedReviewRowHeight(hunkHeader("header", 0), baseOptions)).toBe(1); + expect( + plannedReviewRowHeight(hunkHeader("header", 0), { + ...baseOptions, + showHunkHeaders: false, + }), + ).toBe(0); + expect( + plannedReviewRowVisible(hunkHeader("header", 0), { + ...baseOptions, + showHunkHeaders: false, + }), + ).toBe(false); + expect(plannedReviewRowHeight(splitLine("line", 0), baseOptions)).toBe(1); + expect(plannedReviewRowHeight(guideCap("cap", 0), baseOptions)).toBe(1); + expect(plannedReviewRowHeight(inlineNote("note", 0), baseOptions)).toBeGreaterThan(3); + }); + + test("measured hunk bounds ignore collapsed gaps but include inline notes and guide caps", () => { + const rows = [ + hunkHeader("h0", 0, "hunk-0"), + splitLine("line-0", 0), + collapsedRow("gap", 0), + inlineNote("note", 0), + guideCap("cap", 0), + hunkHeader("h1", 1, "hunk-1"), + splitLine("line-1", 1), + ]; + + const measured = measurePlannedSectionGeometry(rows, baseOptions); + const noteHeight = plannedReviewRowHeight(rows[3]!, baseOptions); + + expect(measured.bodyHeight).toBe(6 + noteHeight); + expect(measured.hunkAnchorRows.get(0)).toBe(0); + expect(measured.hunkAnchorRows.get(1)).toBe(4 + noteHeight); + expect(measured.hunkBounds.get(0)).toEqual({ + top: 0, + height: 3 + noteHeight, + startRowId: reviewRowId("h0"), + endRowId: reviewRowId("cap"), + }); + expect(measured.hunkBounds.get(1)).toEqual({ + top: 4 + noteHeight, + height: 2, + startRowId: reviewRowId("h1"), + endRowId: reviewRowId("line-1"), + }); + }); + + test("hidden hunk headers can anchor navigation without widening visible hunk bounds", () => { + const rows = [hunkHeader("h0", 0, "hunk-0"), splitLine("line-0", 0)]; + + const measured = measurePlannedSectionGeometry(rows, { + ...baseOptions, + showHunkHeaders: false, + }); + + expect(measured.bodyHeight).toBe(1); + expect(measured.hunkAnchorRows.get(0)).toBe(0); + expect(measured.hunkBounds.get(0)).toEqual({ + top: 0, + height: 1, + startRowId: reviewRowId("line-0"), + endRowId: reviewRowId("line-0"), + }); + }); +});