diff --git a/extensions/pi-ask-user-question/test/index.test.ts b/extensions/pi-ask-user-question/test/index.test.ts new file mode 100644 index 0000000..137a37a --- /dev/null +++ b/extensions/pi-ask-user-question/test/index.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from "vitest"; +import askUserQuestion from "../src/index.js"; +import { createMockContext, createMockPi, createMockUi } from "../../test-utils/pi.js"; + +function registerTool() { + const pi = createMockPi(); + askUserQuestion(pi as any); + return pi.tools[0]; +} + +describe("pi-ask-user-question", () => { + it("registers the ask_user_question tool", () => { + const tool = registerTool(); + + expect(tool.name).toBe("ask_user_question"); + expect(tool.label).toBe("Ask User Question"); + expect(tool.execute).toEqual(expect.any(Function)); + }); + + it("returns an error when UI is unavailable", async () => { + const tool = registerTool(); + const result = await tool.execute("id", { question: "Continue?" }, undefined, undefined, createMockContext({ hasUI: false })); + + expect(result.content[0].text).toContain("UI is not available"); + expect(result.details).toMatchObject({ question: "Continue?", answer: null, cancelled: true, mode: "input" }); + }); + + it.each([ + [true, "User confirmed: yes", "yes"], + [false, "User answered: no", "no"], + ])("handles confirm mode resolving %s", async (confirmed, text, answer) => { + const tool = registerTool(); + const ui = createMockUi({ confirm: vi.fn().mockResolvedValue(confirmed) }); + + const result = await tool.execute("id", { question: "Continue?", confirmOnly: true }, undefined, undefined, createMockContext({ ui })); + + expect(ui.confirm).toHaveBeenCalledWith("Continue?", "Confirm to continue"); + expect(result.content[0].text).toBe(text); + expect(result.details).toMatchObject({ answer, cancelled: false, mode: "confirm" }); + }); + + it("trims free-form input answers", async () => { + const tool = registerTool(); + const ui = createMockUi({ input: vi.fn().mockResolvedValue(" hello ") }); + + const result = await tool.execute("id", { question: "What?" }, undefined, undefined, createMockContext({ ui })); + + expect(result.content[0].text).toBe("User answered: hello"); + expect(result.details).toMatchObject({ answer: "hello", cancelled: false, mode: "input" }); + }); + + it("treats whitespace input as cancelled", async () => { + const tool = registerTool(); + const ui = createMockUi({ input: vi.fn().mockResolvedValue(" ") }); + + const result = await tool.execute("id", { question: "What?" }, undefined, undefined, createMockContext({ ui })); + + expect(result.details).toMatchObject({ answer: null, cancelled: true, mode: "input" }); + }); + + it("uses editor mode for multiline or initial values", async () => { + const tool = registerTool(); + const ui = createMockUi({ editor: vi.fn().mockResolvedValue(" edited ") }); + + const result = await tool.execute("id", { question: "Edit", multiline: true, initialValue: "draft" }, undefined, undefined, createMockContext({ ui })); + + expect(ui.editor).toHaveBeenCalledWith("Edit", "draft"); + expect(result.details).toMatchObject({ answer: "edited", cancelled: false, mode: "editor" }); + }); + + it("handles option selection", async () => { + const tool = registerTool(); + const ui = createMockUi({ custom: vi.fn().mockImplementation((_component) => Promise.resolve("B")) }); + + const result = await tool.execute("id", { question: "Pick", options: ["A", "B"] }, undefined, undefined, createMockContext({ ui })); + + expect(result.content[0].text).toBe("User selected: B"); + expect(result.details).toMatchObject({ answer: "B", cancelled: false, mode: "select", options: ["A", "B"] }); + }); + + it("handles cancelled option selection", async () => { + const tool = registerTool(); + const ui = createMockUi({ custom: vi.fn().mockResolvedValue(null) }); + + const result = await tool.execute("id", { question: "Pick", options: ["A"] }, undefined, undefined, createMockContext({ ui })); + + expect(result.details).toMatchObject({ answer: null, cancelled: true, mode: "select" }); + }); + + it("prompts for a custom answer when the custom option is selected", async () => { + const tool = registerTool(); + const ui = createMockUi({ + custom: vi.fn().mockResolvedValue("Type your own answer…"), + input: vi.fn().mockResolvedValue("custom"), + }); + + const result = await tool.execute("id", { question: "Pick", options: ["A"] }, undefined, undefined, createMockContext({ ui })); + + expect(ui.input).toHaveBeenCalledWith("Pick", undefined); + expect(result.details).toMatchObject({ answer: "custom", cancelled: false, mode: "input" }); + }); + + it("does not offer a custom answer when allowCustomAnswer is false", async () => { + const tool = registerTool(); + const ui = createMockUi({ custom: vi.fn().mockResolvedValue("A") }); + + await tool.execute("id", { question: "Pick", options: ["A"], allowCustomAnswer: false }, undefined, undefined, createMockContext({ ui })); + + expect(ui.input).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/pi-copy-code-block/src/index.ts b/extensions/pi-copy-code-block/src/index.ts index 8e4d070..b1b2629 100644 --- a/extensions/pi-copy-code-block/src/index.ts +++ b/extensions/pi-copy-code-block/src/index.ts @@ -96,7 +96,7 @@ function normalizeExtractedCode(code: string): string { return code.replace(/\n$/, ""); } -function extractCodeBlocks(text: string): CodeBlock[] { +export function extractCodeBlocks(text: string): CodeBlock[] { const extracted: Array> = []; const fencePattern = /^```([^\n`]*)\r?\n([\s\S]*?)^```[ \t]*$/gm; @@ -118,7 +118,7 @@ function extractCodeBlocks(text: string): CodeBlock[] { })); } -function parseCopyRequest(input?: string): { request?: ParsedCopyRequest; error?: string } { +export function parseCopyRequest(input?: string): { request?: ParsedCopyRequest; error?: string } { const tokens = (input ?? "") .trim() .split(/\s+/) @@ -156,7 +156,7 @@ function parseCopyRequest(input?: string): { request?: ParsedCopyRequest; error? return { request: { kind: "single", fenced, selector: token } }; } -function resolveRequestedBlock(selector: string | undefined, blocks: CodeBlock[]) { +export function resolveRequestedBlock(selector: string | undefined, blocks: CodeBlock[]) { const normalized = selector?.trim().toLowerCase(); if (!normalized) { @@ -177,7 +177,7 @@ function resolveRequestedBlock(selector: string | undefined, blocks: CodeBlock[] return { error: `Unknown code block selector "${selector}". Use a number, first/f, or last/l.` }; } -function formatSingleBlockForClipboard(block: CodeBlock, fenced: boolean): string { +export function formatSingleBlockForClipboard(block: CodeBlock, fenced: boolean): string { if (!fenced) return block.code; const body = block.code.endsWith("\n") ? block.code : `${block.code}\n`; @@ -185,7 +185,7 @@ function formatSingleBlockForClipboard(block: CodeBlock, fenced: boolean): strin return `\`\`\`${language}\n${body}\`\`\``; } -function formatAllBlocksForClipboard(blocks: CodeBlock[], fenced: boolean): string { +export function formatAllBlocksForClipboard(blocks: CodeBlock[], fenced: boolean): string { return blocks.map((block) => formatSingleBlockForClipboard(block, fenced)).join(extensionConfig.copyAllSeparator); } diff --git a/extensions/pi-copy-code-block/test/index.test.ts b/extensions/pi-copy-code-block/test/index.test.ts new file mode 100644 index 0000000..13ae2eb --- /dev/null +++ b/extensions/pi-copy-code-block/test/index.test.ts @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import clipboard from "clipboardy"; +import piCopyCodeBlock, { + extractCodeBlocks, + formatAllBlocksForClipboard, + formatSingleBlockForClipboard, + parseCopyRequest, + resolveRequestedBlock, +} from "../src/index.js"; +import { createMockContext, createMockPi, createMockUi } from "../../test-utils/pi.js"; + +vi.mock("clipboardy", () => ({ default: { write: vi.fn() } })); + +function assistant(text: string, stopReason = "stop") { + return { type: "message", message: { role: "assistant", stopReason, content: [{ type: "text", text }] } }; +} + +describe("pi-copy-code-block pure helpers", () => { + it("extracts fenced code blocks with languages and normalized code", () => { + const blocks = extractCodeBlocks("before\n```ts\r\nconst a = 1;\r\n```\nmid\n```\nplain\n```\n"); + + expect(blocks).toEqual([ + { index: 1, language: "text", code: "plain", preview: "plain" }, + { index: 2, language: "ts", code: "const a = 1;", preview: "const a = 1;" }, + ]); + }); + + it("returns no blocks when no fences exist", () => { + expect(extractCodeBlocks("no code here")).toEqual([]); + }); + + it.each([ + [undefined, { kind: "single", fenced: false }], + ["2", { kind: "single", fenced: false, selector: "2" }], + ["first", { kind: "single", fenced: false, selector: "first" }], + ["last", { kind: "single", fenced: false, selector: "last" }], + ["all", { kind: "all", fenced: false }], + ["fenced 2", { kind: "single", fenced: true, selector: "2" }], + ["fenced all", { kind: "all", fenced: true }], + ])("parses copy request %s", (input, request) => { + expect(parseCopyRequest(input as string | undefined).request).toEqual(request); + }); + + it("rejects copy requests with too many arguments", () => { + expect(parseCopyRequest("one two").error).toContain("Too many arguments"); + }); + + it("resolves requested blocks", () => { + const blocks = extractCodeBlocks("```a\none\n```\n```b\ntwo\n```"); + + expect(resolveRequestedBlock(undefined, [blocks[0]!])).toEqual({ block: blocks[0] }); + expect(resolveRequestedBlock(undefined, blocks)).toEqual({ requiresPicker: true }); + expect(resolveRequestedBlock("1", blocks)).toEqual({ block: blocks[0] }); + expect(resolveRequestedBlock("3", blocks).error).toContain("does not exist"); + expect(resolveRequestedBlock("first", blocks)).toEqual({ block: blocks[0] }); + expect(resolveRequestedBlock("f", blocks)).toEqual({ block: blocks[0] }); + expect(resolveRequestedBlock("last", blocks)).toEqual({ block: blocks[1] }); + expect(resolveRequestedBlock("l", blocks)).toEqual({ block: blocks[1] }); + }); + + it("formats clipboard content", () => { + const tsBlock = { index: 1, language: "ts", code: "const a = 1;", preview: "const a = 1;" }; + const textBlock = { index: 2, language: "text", code: "plain", preview: "plain" }; + + expect(formatSingleBlockForClipboard(tsBlock, false)).toBe("const a = 1;"); + expect(formatSingleBlockForClipboard(tsBlock, true)).toBe("```ts\nconst a = 1;\n```"); + expect(formatSingleBlockForClipboard(textBlock, true)).toBe("```\nplain\n```"); + expect(formatAllBlocksForClipboard([tsBlock, textBlock], false)).toBe("const a = 1;\n\nplain"); + }); +}); + +describe("pi-copy-code-block extension", () => { + beforeEach(() => { + vi.mocked(clipboard.write).mockReset(); + }); + + it("registers command, shortcut, and status refresh handlers", () => { + const pi = createMockPi(); + + piCopyCodeBlock(pi as any); + + expect(pi.commands.has("copy-code")).toBe(true); + expect(pi.shortcuts.size).toBe(1); + expect(pi.handlers.get("session_start")).toHaveLength(1); + expect(pi.handlers.get("turn_end")).toHaveLength(1); + expect(pi.handlers.get("session_tree")).toHaveLength(1); + }); + + it("copies the only code block from the latest completed assistant message", async () => { + const pi = createMockPi(); + const ctx = createMockContext(); + ctx.sessionManager.getBranch.mockReturnValue([assistant("```ts\nconst a = 1;\n```")]); + piCopyCodeBlock(pi as any); + + await pi.commands.get("copy-code").handler("", ctx); + + expect(clipboard.write).toHaveBeenCalledWith("const a = 1;"); + expect(ctx.ui.notify).toHaveBeenCalledWith("Copied code block.", "info"); + }); + + it("copies all blocks as fenced content", async () => { + const pi = createMockPi(); + const ctx = createMockContext(); + ctx.sessionManager.getBranch.mockReturnValue([assistant("```ts\none\n```\n```\ntwo\n```")]); + piCopyCodeBlock(pi as any); + + await pi.commands.get("copy-code").handler("fenced all", ctx); + + expect(clipboard.write).toHaveBeenCalledWith("```\ntwo\n```\n\n```ts\none\n```"); + expect(ctx.ui.notify).toHaveBeenCalledWith("Copied all 2 fenced code blocks.", "info"); + }); + + it("warns when multiple blocks require a selector without UI", async () => { + const pi = createMockPi(); + const ctx = createMockContext({ hasUI: false }); + ctx.sessionManager.getBranch.mockReturnValue([assistant("```\none\n```\n```\ntwo\n```")]); + piCopyCodeBlock(pi as any); + + await pi.commands.get("copy-code").handler("", ctx); + + expect(clipboard.write).not.toHaveBeenCalled(); + }); + + it("uses the picker when multiple blocks exist", async () => { + const pi = createMockPi(); + const ui = createMockUi({ custom: vi.fn().mockResolvedValue({ index: 2, language: "text", code: "one", preview: "one" }) }); + const ctx = createMockContext({ ui }); + ctx.sessionManager.getBranch.mockReturnValue([assistant("```\none\n```\n```\ntwo\n```")]); + piCopyCodeBlock(pi as any); + + await pi.commands.get("copy-code").handler("", ctx); + + expect(ui.custom).toHaveBeenCalled(); + expect(clipboard.write).toHaveBeenCalledWith("one"); + }); + + it("ignores incomplete assistant messages and warns for missing code", async () => { + const pi = createMockPi(); + const ctx = createMockContext(); + ctx.sessionManager.getBranch.mockReturnValue([assistant("```\nnope\n```", "length")]); + piCopyCodeBlock(pi as any); + + await pi.commands.get("copy-code").handler("", ctx); + + expect(ctx.ui.notify).toHaveBeenCalledWith("No completed assistant message found.", "warning"); + }); + + it("warns when completed assistant messages contain no code or selector is invalid", async () => { + const pi = createMockPi(); + const ctx = createMockContext(); + ctx.sessionManager.getBranch.mockReturnValue([assistant("no code")]); + piCopyCodeBlock(pi as any); + + await pi.commands.get("copy-code").handler("", ctx); + expect(ctx.ui.notify).toHaveBeenCalledWith("No code blocks found in the last 1 assistant message.", "warning"); + + ctx.ui.notify.mockClear(); + ctx.sessionManager.getBranch.mockReturnValue([assistant("```\none\n```")]); + await pi.commands.get("copy-code").handler("2", ctx); + expect(ctx.ui.notify).toHaveBeenCalledWith("Code block 2 does not exist. Found 1 block(s).", "warning"); + }); + + it("updates and clears the status hint", async () => { + const pi = createMockPi(); + const ctx = createMockContext(); + piCopyCodeBlock(pi as any); + + ctx.sessionManager.getBranch.mockReturnValue([assistant("```\none\n```")]); + await pi.handlers.get("turn_end")![0]!({}, ctx); + expect(ctx.ui.setStatus).toHaveBeenCalledWith("copy-code", expect.stringContaining("1 code block")); + + ctx.ui.setStatus.mockClear(); + ctx.sessionManager.getBranch.mockReturnValue([assistant("no code")]); + await pi.handlers.get("turn_end")![0]!({}, ctx); + expect(ctx.ui.setStatus).toHaveBeenCalledWith("copy-code", undefined); + }); +}); diff --git a/extensions/pi-vim-quit/test/index.test.ts b/extensions/pi-vim-quit/test/index.test.ts new file mode 100644 index 0000000..29e4479 --- /dev/null +++ b/extensions/pi-vim-quit/test/index.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import piVimQuit from "../src/index.js"; +import { createMockContext, createMockPi } from "../../test-utils/pi.js"; + +describe("pi-vim-quit", () => { + it("registers an input handler", () => { + const pi = createMockPi(); + + piVimQuit(pi as any); + + expect(pi.on).toHaveBeenCalledWith("input", expect.any(Function)); + expect(pi.handlers.get("input")).toHaveLength(1); + }); + + it.each([":q", ":qa", ":wq"])("handles %s by notifying and shutting down", async (text) => { + const pi = createMockPi(); + const ctx = createMockContext(); + piVimQuit(pi as any); + + const handler = pi.handlers.get("input")![0]!; + const result = await handler({ text, source: "user" }, ctx); + + expect(result).toEqual({ action: "handled" }); + expect(ctx.ui.notify).toHaveBeenCalledWith("Quitting pi…", "info"); + expect(ctx.shutdown).toHaveBeenCalledOnce(); + }); + + it("continues for non-quit input", async () => { + const pi = createMockPi(); + const ctx = createMockContext(); + piVimQuit(pi as any); + + const handler = pi.handlers.get("input")![0]!; + const result = await handler({ text: "hello", source: "user" }, ctx); + + expect(result).toEqual({ action: "continue" }); + expect(ctx.ui.notify).not.toHaveBeenCalled(); + expect(ctx.shutdown).not.toHaveBeenCalled(); + }); + + it("continues for extension input without shutting down", async () => { + const pi = createMockPi(); + const ctx = createMockContext(); + piVimQuit(pi as any); + + const handler = pi.handlers.get("input")![0]!; + const result = await handler({ text: ":q", source: "extension" }, ctx); + + expect(result).toEqual({ action: "continue" }); + expect(ctx.ui.notify).not.toHaveBeenCalled(); + expect(ctx.shutdown).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/test-utils/pi.ts b/extensions/test-utils/pi.ts new file mode 100644 index 0000000..13f1fec --- /dev/null +++ b/extensions/test-utils/pi.ts @@ -0,0 +1,58 @@ +import { vi } from "vitest"; + +export function createMockPi(): any { + const handlers = new Map any>>(); + const tools: any[] = []; + const commands = new Map(); + const shortcuts = new Map(); + + return { + handlers, + tools, + commands, + shortcuts, + on: vi.fn((event: string, handler: (event: any, ctx: any) => any) => { + const existing = handlers.get(event) ?? []; + existing.push(handler); + handlers.set(event, existing); + }), + registerTool: vi.fn((tool: any) => { + tools.push(tool); + }), + registerCommand: vi.fn((name: string, command: any) => { + commands.set(name, command); + }), + registerShortcut: vi.fn((shortcut: string, config: any) => { + shortcuts.set(shortcut, config); + }), + }; +} + +export function createMockUi(overrides: Record = {}): any { + return { + notify: vi.fn(), + confirm: vi.fn(), + input: vi.fn(), + editor: vi.fn(), + custom: vi.fn(), + setStatus: vi.fn(), + theme: { + fg: vi.fn((_color: string, text: string) => text), + }, + ...overrides, + }; +} + +export function createMockContext(overrides: Record = {}): any { + const ui = overrides.ui ?? createMockUi(); + + return { + hasUI: true, + ui, + shutdown: vi.fn(), + sessionManager: { + getBranch: vi.fn(() => []), + }, + ...overrides, + }; +} diff --git a/plans/testing-improvement.md b/plans/testing-improvement.md new file mode 100644 index 0000000..e17d757 --- /dev/null +++ b/plans/testing-improvement.md @@ -0,0 +1,199 @@ +# Testing improvement plan + +## Goal + +Add a small, useful Vitest test suite for every extension so that the core behaviors are protected by `npm run ci` / GitHub Actions before making future changes. + +## Current state + +- [x] Root `package.json` already has the right CI path: + - [x] `npm run test` runs `vitest run`. + - [x] `npm run ci` runs typecheck, tests, and package dry-runs. + - [x] `.github/workflows/ci.yml` runs `vp run ci`. +- [x] `extensions/pi-interactive-code-review` already has unit tests under `test/`. +- [x] These extensions currently need tests: + - [x] `extensions/pi-vim-quit` + - [x] `extensions/pi-ask-user-question` + - [x] `extensions/pi-copy-code-block` + +## Principles + +- [x] Prefer fast unit tests around pure logic and extension registration behavior. +- [x] Add only enough integration-style mocking to verify that each extension wires into pi correctly. +- [x] Keep tests deterministic: mock clipboard, UI methods, and session state. +- [x] Avoid testing pi internals; test the extension's contract with the pi API. +- [x] When useful, export small pure helpers from `src/index.ts` rather than reaching into private implementation details. + +## Step 1: Add a shared test helper for fake pi APIs + +- [x] Create `extensions/test-utils/pi.ts` or `test/helpers/pi.ts` with minimal mocks used by multiple extensions. +- [x] Add `createMockPi()` that records calls to: + - [x] `on(event, handler)` + - [x] `registerTool(tool)` + - [x] `registerCommand(name, command)` + - [x] `registerShortcut(shortcut, shortcut)` +- [x] Add `createMockUi()` with spies for: + - [x] `notify` + - [x] `confirm` + - [x] `input` + - [x] `editor` + - [x] `custom` + - [x] `setStatus` + - [x] `theme.fg` +- [x] Add `createMockContext()` that can be configured with: + - [x] `hasUI` + - [x] `ui` + - [x] `shutdown` + - [x] `sessionManager.getBranch()` +- [x] Keep these helpers deliberately small and typed loosely where necessary so tests do not become coupled to all pi API details. + +## Step 2: Add tests for `pi-vim-quit` + +- [x] Create `extensions/pi-vim-quit/test/index.test.ts`. +- [x] Test that it registers an `input` event handler. +- [x] Test `:q`, `:qa`, and `:wq` from a normal user input source: + - [x] returns `{ action: "handled" }` + - [x] calls `ctx.ui.notify("Quitting pi…", "info")` + - [x] calls `ctx.shutdown()` +- [x] Test non-quit input: + - [x] returns `{ action: "continue" }` + - [x] does not notify + - [x] does not call shutdown +- [x] Test input with `event.source === "extension"`: + - [x] returns `{ action: "continue" }` + - [x] does not call shutdown, even if the text is `:q` + +This gives high confidence for the entire extension because its behavior is intentionally tiny. + +## Step 3: Add tests for `pi-ask-user-question` + +- [x] Create `extensions/pi-ask-user-question/test/index.test.ts`. +- [x] Test that it registers the `ask_user_question` tool with the expected name and basic metadata. +- [x] Test no UI available: + - [x] execute with `ctx.hasUI = false` + - [x] returns an error text response + - [x] returns details with `cancelled: true`, `answer: null`, and `mode: "input"` +- [x] Test confirm mode: + - [x] when `ctx.ui.confirm` resolves `true`, returns `User confirmed: yes` and details answer `"yes"` + - [x] when it resolves `false`, returns `User answered: no` and details answer `"no"` +- [x] Test free-form input mode: + - [x] trims a non-empty answer and records `mode: "input"` + - [x] treats empty/whitespace answers as cancelled +- [x] Test editor mode: + - [x] uses `ctx.ui.editor` when `multiline` is true or `initialValue` is provided + - [x] trims a non-empty answer and records `mode: "editor"` +- [x] Test options mode: + - [x] uses `ctx.ui.custom` to choose an option + - [x] records `mode: "select"` and the selected answer + - [x] returns cancelled details when the custom picker returns `null` +- [x] Test custom option flow: + - [x] when `allowCustomAnswer` is not false and the custom label is selected, prompts for input/editor + - [x] when `allowCustomAnswer: false`, does not include the custom answer option + +Implementation note: `ctx.ui.custom` can call the component and simulate selecting by either invoking the returned `handleInput` with a numeric key / enter, or more simply by capturing the `done` callback and calling it with the desired choice. + +## Step 4: Add tests for `pi-copy-code-block` pure behavior + +- [x] Create `extensions/pi-copy-code-block/test/index.test.ts`, or split into focused files such as `parse.test.ts` and `command.test.ts`. +- [x] Refactor `src/index.ts` to export these helpers for direct unit tests: + - [x] `extractCodeBlocks` + - [x] `parseCopyRequest` + - [x] `resolveRequestedBlock` + - [x] `formatSingleBlockForClipboard` + - [x] `formatAllBlocksForClipboard` +- [x] Test code block extraction: + - [x] extracts fenced Markdown blocks and languages + - [x] normalizes CRLF to LF + - [x] removes the structural newline before the closing fence + - [x] returns blocks in the order shown by the picker/selector, including correct indexes + - [x] ignores text without fenced code blocks +- [x] Test request parsing: + - [x] empty input means single block, unfenced + - [x] numeric selector, `first`, `last`, `all` + - [x] `fenced 2` and `fenced all` + - [x] rejects too many arguments with the existing error message +- [x] Test block resolution: + - [x] one block and no selector picks the block + - [x] multiple blocks and no selector requires the picker + - [x] valid and invalid numeric selectors + - [x] `first`/`f`, `last`/`l` +- [x] Test clipboard formatting: + - [x] unfenced single block returns raw code + - [x] fenced single block includes language when present and omits `text` + - [x] all-block formatting joins with the configured blank-line separator + +These tests protect the most failure-prone logic without requiring the pi runtime. + +## Step 5: Add tests for `pi-copy-code-block` extension integration + +- [x] Continue in `extensions/pi-copy-code-block/test/index.test.ts` after mocking external effects. +- [x] Mock `clipboardy` with `vi.mock("clipboardy", () => ({ default: { write: vi.fn() } }))`. +- [x] Use `createMockPi()` and a fake context with `sessionManager.getBranch()` returning assistant/user messages. +- [x] Test that it registers the `copy-code` command. +- [x] Test that it registers the configured shortcut. +- [x] Test that it registers status refresh handlers for `session_start`, `turn_end`, and `session_tree`. +- [x] Test command copies the only code block from the latest completed assistant message: + - [x] calls `clipboard.write` with the raw code + - [x] notifies success +- [x] Test command copies `all` blocks and `fenced` blocks correctly. +- [x] Test if multiple blocks exist and no selector is provided: + - [x] with no UI, warns the user to pass a selector + - [x] with UI, uses the picker and copies the selected block +- [x] Test that it ignores assistant messages whose `stopReason` is not `stop`. +- [x] Test useful warnings when: + - [x] no completed assistant message exists + - [x] completed assistant messages contain no code blocks + - [x] selector is invalid +- [x] Test status hint: + - [x] sets a status message when recent assistant code blocks exist + - [x] clears the status when none exist + +## Step 6: Keep existing `pi-interactive-code-review` tests running + +- [x] Do not block this plan on adding more tests there because it already has coverage. +- [ ] After the untested extensions are covered, consider a later follow-up plan for: + - [ ] command registration smoke tests for the review extension + - [ ] git command integration tests with temporary repositories + - [ ] review state persistence tests + +## Step 7: Run and verify locally + +- [x] After adding each extension's tests, run targeted checks first: + +```sh +npx vitest run extensions/pi-vim-quit/test +npx vitest run extensions/pi-ask-user-question/test +npx vitest run extensions/pi-copy-code-block/test +``` + +- [x] Then run the full repository checks: + +```sh +npm run typecheck +npm run test +npm run ci +``` + +## Step 8: CI expectations + +- [x] No workflow change should be needed because `.github/workflows/ci.yml` already runs `vp run ci`, and the root `ci` script already includes `npm run test`. +- [ ] If tests are not discovered in CI, add a root `vitest.config.ts` with an explicit include pattern: + +```ts +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["extensions/**/test/**/*.test.ts"], + }, +}); +``` + +## Suggested implementation order + +- [x] Shared mock helpers. +- [x] `pi-vim-quit` tests. +- [x] `pi-ask-user-question` tests. +- [x] `pi-copy-code-block` pure-helper exports and unit tests. +- [x] `pi-copy-code-block` command/status integration tests. +- [x] Full `npm run ci` verification.