import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { registerProjectTestRunnerExtension, type ProjectTestRunnerCommandContext, type ProjectTestRunnerPiApi, } from "./project-test-runner-extension.ts"; type RegisteredCommand = Parameters< ProjectTestRunnerPiApi["registerCommand"] >[1]; type PiExecOptions = Parameters[2]; type ExecCall = { command: string; args: string[]; options: PiExecOptions; }; type Notification = { message: string; type?: "info" | "warning" | "error"; }; type TestRunnerMessage = Parameters[0]; type TestAutocompleteItem = { value: string; label: string; description?: string; }; type TestAutocompleteResult = { prefix: string; items: TestAutocompleteItem[]; } | null; type TestAutocompleteApplyResult = { lines: string[]; cursorLine: number; cursorCol: number; }; type TestAutocompleteProvider = { getSuggestions( lines: string[], cursorLine: number, cursorCol: number, options?: unknown, ): TestAutocompleteResult | Promise; applyCompletion( lines: string[], cursorLine: number, cursorCol: number, item: TestAutocompleteItem, prefix: string, ): TestAutocompleteApplyResult; shouldTriggerFileCompletion?( lines: string[], cursorLine: number, cursorCol: number, ): boolean; }; type TestAutocompleteProviderFactory = ( current: TestAutocompleteProvider, ) => TestAutocompleteProvider; type TestSessionStartContext = { ui: { addAutocompleteProvider(factory: TestAutocompleteProviderFactory): void; }; }; type TestSessionStartHandler = ( event: unknown, ctx: TestSessionStartContext, ) => void | Promise; type TestAutocompletePiApi = { on(event: "session_start", handler: TestSessionStartHandler): void; }; const tempDirs: string[] = []; async function createTempProject(files: string[] = []): Promise { const dir = await mkdtemp(join(tmpdir(), "pi-test-extension-")); tempDirs.push(dir); await Promise.all( files.map((fileName) => writeFile(join(dir, fileName), "module example.com/service\n", "utf8"), ), ); return dir; } afterEach(async () => { await Promise.all( tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), ); }); type TestSendUserMessageOptions = { deliverAs?: "steer" | "followUp" }; function fakePi( execResult = { stdout: "PASS\n", stderr: "", code: 0, killed: false }, ): { pi: ProjectTestRunnerPiApi & TestAutocompletePiApi & { sendUserMessage( content: string, options?: TestSendUserMessageOptions, ): void; }; commands: Map; execCalls: ExecCall[]; messages: TestRunnerMessage[]; userMessages: string[]; sessionStartHandlers: TestSessionStartHandler[]; } { const commands = new Map(); const execCalls: ExecCall[] = []; const messages: TestRunnerMessage[] = []; const userMessages: string[] = []; const sessionStartHandlers: TestSessionStartHandler[] = []; return { commands, execCalls, messages, userMessages, sessionStartHandlers, pi: { on(event, handler) { if (event === "session_start") sessionStartHandlers.push(handler); }, registerCommand(name, command) { commands.set(name, command); }, async exec(command, args, options) { execCalls.push({ command, args, options }); return execResult; }, sendMessage(message) { messages.push(message); }, sendUserMessage(content) { userMessages.push(content); }, }, }; } function commandContext(cwd: string): { ctx: ProjectTestRunnerCommandContext; notifications: Notification[]; } { const notifications: Notification[] = []; return { notifications, ctx: { cwd, isIdle: () => true, ui: { notify(message, type) { notifications.push({ message, type }); }, }, }, }; } async function runRegisteredTestCommand( command: RegisteredCommand | undefined, args: string, ctx: ProjectTestRunnerCommandContext, ): Promise { if (command === undefined) { throw new Error("Expected /test command to be registered"); } await command.handler(args, ctx); } async function installRegisteredAutocompleteProvider( handlers: TestSessionStartHandler[], current: TestAutocompleteProvider = delegatedAutocompleteProvider(), ): Promise { expect(handlers).toHaveLength(1); const factories: TestAutocompleteProviderFactory[] = []; await handlers[0]?.( {}, { ui: { addAutocompleteProvider(factory) { factories.push(factory); }, }, }, ); expect(factories).toHaveLength(1); return factories[0]?.(current) ?? current; } function delegatedAutocompleteProvider( result: TestAutocompleteResult = { prefix: "delegated", items: [{ value: "delegated", label: "delegated" }], }, ): TestAutocompleteProvider { return { getSuggestions() { return result; }, applyCompletion(lines, cursorLine, cursorCol, item, prefix) { const currentLine = lines[cursorLine] ?? ""; const newLines = [...lines]; const prefixStart = Math.max(0, cursorCol - prefix.length); newLines[cursorLine] = `${currentLine.slice(0, prefixStart)}${item.value}${currentLine.slice(cursorCol)}`; return { lines: newLines, cursorLine, cursorCol: prefixStart + item.value.length, }; }, }; } async function suggestionsFor( provider: TestAutocompleteProvider, text: string, ): Promise { return provider.getSuggestions([text], 0, text.length, {}); } describe("registerProjectTestRunnerExtension", () => { it("registers the /test slash command", () => { const { pi, commands } = fakePi(); registerProjectTestRunnerExtension(pi); expect(commands.get("test")).toMatchObject({ description: expect.stringMatching(/run project tests/i), }); }); it("registers argument completions for supported flags", () => { const { pi, commands } = fakePi(); registerProjectTestRunnerExtension(pi); expect(commands.get("test")?.getArgumentCompletions?.("--")).toEqual([ { value: "--info", label: "--info", description: "Show full command output", }, { value: "--tags", label: "--tags", description: "Run with Go build tags", }, { value: "--comment", label: "--comment", description: "Ask the model to analyze the test results", }, ]); }); it("registers context-aware autocomplete for /test flags", async () => { const { pi, sessionStartHandlers } = fakePi(); registerProjectTestRunnerExtension(pi); const provider = await installRegisteredAutocompleteProvider(sessionStartHandlers); await expect(suggestionsFor(provider, "/test ")).resolves.toEqual({ prefix: "", items: [ { value: "--info", label: "--info", description: "Show full command output", }, { value: "--tags", label: "--tags", description: "Run with Go build tags", }, { value: "--comment", label: "--comment", description: "Ask the model to analyze the test results", }, ], }); }); it("context-aware autocomplete only suggests remaining /test flags", async () => { const { pi, sessionStartHandlers } = fakePi(); registerProjectTestRunnerExtension(pi); const provider = await installRegisteredAutocompleteProvider(sessionStartHandlers); await expect(suggestionsFor(provider, "/test --info ")).resolves.toEqual({ prefix: "", items: [ { value: "--tags", label: "--tags", description: "Run with Go build tags", }, { value: "--comment", label: "--comment", description: "Ask the model to analyze the test results", }, ], }); await expect( suggestionsFor(provider, "/test --tags contract,e2e "), ).resolves.toEqual({ prefix: "", items: [ { value: "--info", label: "--info", description: "Show full command output", }, { value: "--comment", label: "--comment", description: "Ask the model to analyze the test results", }, ], }); await expect( suggestionsFor(provider, "/test --tags contract,e2e --"), ).resolves.toEqual({ prefix: "--", items: [ { value: "--info", label: "--info", description: "Show full command output", }, { value: "--comment", label: "--comment", description: "Ask the model to analyze the test results", }, ], }); }); it("context-aware autocomplete delegates non-/test input", async () => { const { pi, sessionStartHandlers } = fakePi(); const delegated = delegatedAutocompleteProvider({ prefix: "delegated", items: [{ value: "delegated-value", label: "delegated-value" }], }); registerProjectTestRunnerExtension(pi); const provider = await installRegisteredAutocompleteProvider( sessionStartHandlers, delegated, ); await expect(suggestionsFor(provider, "/other ")).resolves.toEqual({ prefix: "delegated", items: [{ value: "delegated-value", label: "delegated-value" }], }); }); it("delegates applyCompletion to the current provider", async () => { const { pi, sessionStartHandlers } = fakePi(); const delegated = delegatedAutocompleteProvider(); registerProjectTestRunnerExtension(pi); const provider = await installRegisteredAutocompleteProvider( sessionStartHandlers, delegated, ); // Non-/test input: applyCompletion must reach the delegated provider // (this is the call the TUI editor makes on Tab with a selection). const args: [string[], number, number, TestAutocompleteItem, string] = [ ["/other --"], 0, 8, { value: "delegated-value", label: "delegated-value" }, "--", ]; expect(provider.applyCompletion(...args)).toEqual( delegated.applyCompletion(...args), ); // Extension-owned items still apply: the prefix is replaced by item.value. expect( provider.applyCompletion( ["/test --"], 0, 8, { value: "--info", label: "--info" }, "--", ), ).toEqual({ lines: ["/test --info"], cursorLine: 0, cursorCol: 12, }); }); it("completes the /test command without a trailing space", async () => { const { pi, sessionStartHandlers } = fakePi(); registerProjectTestRunnerExtension(pi); const provider = await installRegisteredAutocompleteProvider(sessionStartHandlers); // Selecting /test from the slash-command list must not leave the cursor // one space away from the command (built-in behavior for other commands). expect( provider.applyCompletion( ["/te"], 0, 3, { value: "test", label: "test" }, "/te", ), ).toEqual({ lines: ["/test"], cursorLine: 0, cursorCol: 5, }); }); it("inserts a separator when a flag is applied to a bare /test", async () => { const { pi, sessionStartHandlers } = fakePi(); registerProjectTestRunnerExtension(pi); const provider = await installRegisteredAutocompleteProvider(sessionStartHandlers); // Applying a flag with the cursor right after /test (no space yet) must // insert the command/argument separator instead of /test--info. expect( provider.applyCompletion( ["/test"], 0, 5, { value: "--info", label: "--info" }, "", ), ).toEqual({ lines: ["/test --info"], cursorLine: 0, cursorCol: 12, }); }); it("runs the default Go test command for a detected Go module without showing command output", async () => { const cwd = await createTempProject(["go.mod"]); const { pi, commands, execCalls, messages } = fakePi(); const { ctx, notifications } = commandContext(cwd); registerProjectTestRunnerExtension(pi); await runRegisteredTestCommand(commands.get("test"), "", ctx); expect(execCalls).toHaveLength(1); expect(execCalls[0]).toMatchObject({ command: "go", args: ["test", "-race", "./..."], options: { cwd }, }); expect(notifications).toEqual([ { message: expect.stringContaining("go test -race ./...") as string, type: "info", }, ]); expect(messages).toEqual([]); }); it("shows command output when --info is present without passing it to Go", async () => { const cwd = await createTempProject(["go.mod"]); const { pi, commands, execCalls, messages } = fakePi({ stdout: "PASS\n", stderr: "race detector warning\n", code: 0, killed: false, }); const { ctx } = commandContext(cwd); registerProjectTestRunnerExtension(pi); await runRegisteredTestCommand(commands.get("test"), "--info", ctx); expect(execCalls).toHaveLength(1); expect(execCalls[0]).toMatchObject({ command: "go", args: ["test", "-race", "./..."], options: { cwd }, }); expect(messages).toHaveLength(1); expect(messages[0]).toMatchObject({ customType: "project-test-runner-output", display: true, details: { displayCommand: "go test -race ./...", exitCode: 0, killed: false, }, }); expect(messages[0]?.content).toContain("PASS\n"); expect(messages[0]?.content).toContain("race detector warning\n"); }); it("asks the model to analyze the output when --comment is present", async () => { const cwd = await createTempProject(["go.mod"]); const { pi, commands, execCalls, userMessages } = fakePi({ stdout: "PASS\n", stderr: "FAIL: TestBroken\n", code: 1, killed: false, }); const { ctx } = commandContext(cwd); registerProjectTestRunnerExtension(pi); await runRegisteredTestCommand(commands.get("test"), "--comment", ctx); expect(execCalls).toHaveLength(1); expect(execCalls[0]).toMatchObject({ command: "go", args: ["test", "-race", "./..."], options: { cwd }, }); expect(userMessages).toHaveLength(1); expect(userMessages[0]).toContain("go test -race ./..."); expect(userMessages[0]).toContain("exit code 1"); expect(userMessages[0]).toContain("FAIL: TestBroken"); expect(userMessages[0]).toContain( "Summarize the test results and identify what the problem is", ); }); it("passes slash command arguments to the Go test planner", async () => { const cwd = await createTempProject(["go.work"]); const { pi, commands, execCalls } = fakePi(); const { ctx } = commandContext(cwd); registerProjectTestRunnerExtension(pi); await runRegisteredTestCommand( commands.get("test"), "--tags contract,e2e", ctx, ); expect(execCalls).toHaveLength(1); expect(execCalls[0]).toMatchObject({ command: "go", args: ["test", "-race", "-tags=contract,e2e", "./..."], options: { cwd }, }); }); it("passes tags while removing --info from slash command arguments", async () => { const cwd = await createTempProject(["go.work"]); const { pi, commands, execCalls, messages } = fakePi(); const { ctx } = commandContext(cwd); registerProjectTestRunnerExtension(pi); await runRegisteredTestCommand( commands.get("test"), "--tags contract,e2e --info", ctx, ); expect(execCalls).toHaveLength(1); expect(execCalls[0]).toMatchObject({ command: "go", args: ["test", "-race", "-tags=contract,e2e", "./..."], options: { cwd }, }); expect(messages).toHaveLength(1); }); it("notifies failed tests as an error without command output", async () => { const cwd = await createTempProject(["go.mod"]); const { pi, commands, messages } = fakePi({ stdout: "full stdout should not appear", stderr: "full stderr should not appear", code: 1, killed: false, }); const { ctx, notifications } = commandContext(cwd); registerProjectTestRunnerExtension(pi); await runRegisteredTestCommand(commands.get("test"), "", ctx); expect(notifications).toEqual([ { message: "go test -race ./... failed", type: "error", }, ]); expect(messages).toEqual([]); }); it("notifies killed executions as an error without command output", async () => { const cwd = await createTempProject(["go.mod"]); const { pi, commands, messages } = fakePi({ stdout: "full stdout should not appear", stderr: "full stderr should not appear", code: 0, killed: true, }); const { ctx, notifications } = commandContext(cwd); registerProjectTestRunnerExtension(pi); await runRegisteredTestCommand(commands.get("test"), "", ctx); expect(notifications).toEqual([ { message: "go test -race ./... killed or timeout", type: "error", }, ]); expect(messages).toEqual([]); }); it("notifies an error without executing when no supported project is detected", async () => { const cwd = await createTempProject(); const { pi, commands, execCalls } = fakePi(); const { ctx, notifications } = commandContext(cwd); registerProjectTestRunnerExtension(pi); await runRegisteredTestCommand(commands.get("test"), "", ctx); expect(execCalls).toEqual([]); expect(notifications).toEqual([ { message: expect.stringMatching( /unsupported project/i, ) as unknown as string, type: "error", }, ]); }); });