From 76df2447bdb11862ee82b8614029c0fc79424cb5 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 27 Aug 2026 17:07:34 -0700 Subject: [PATCH 1/3] test: make UI and agent-rules tests fail on leftover state Signed-off-by: Sebastien Tardif --- test/ui/extension.test.ts | 34 +++++++++++++++++++---------- test/unit/initializeProject.test.ts | 20 ++++++++++++++--- test/unit/patchloomCli.test.ts | 20 ++++++----------- 3 files changed, 47 insertions(+), 27 deletions(-) diff --git a/test/ui/extension.test.ts b/test/ui/extension.test.ts index 1af7823..a3f5024 100644 --- a/test/ui/extension.test.ts +++ b/test/ui/extension.test.ts @@ -14,6 +14,7 @@ import { StatusBar, InputBox, EditorView, + SettingsEditor, } from "vscode-extension-tester"; // --------------------------------------------------------------------------- @@ -66,6 +67,16 @@ async function findNotification( return undefined; } +/** Dismiss every currently visible notification matching the pattern. */ +async function dismissNotifications( + workbench: Workbench, + pattern: RegExp, +): Promise { + while (await findNotification(workbench, pattern)) { + // keep dismissing leftovers + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -84,6 +95,9 @@ describe("Patchloom Extension UI", function () { // status bar item exists before the suite runs. await workbench.executeCommand("Patchloom: Show Status"); await poll(findPatchloomStatus, 30_000); + // Drain leftover Show Status toasts so later tests do not match them. + await poll(() => findNotification(workbench, /[Pp]atchloom/), 10_000).catch(() => undefined); + await dismissNotifications(workbench, /[Pp]atchloom/); }); describe("Status bar", function () { @@ -91,20 +105,15 @@ describe("Patchloom Extension UI", function () { const text = await poll(findPatchloomStatus); assert.ok(text.includes("Patchloom"), "status bar should contain a Patchloom item"); }); - - it("should have non-empty status text", async function () { - const text = await poll(findPatchloomStatus); - assert.ok(text.length > 0, "Patchloom status text should not be empty"); - }); }); describe("Show Status command", function () { it("should display a notification when invoked", async function () { await workbench.executeCommand("Patchloom: Show Status"); const msg = await poll( - () => findNotification(workbench, /[Pp]atchloom/), + () => findNotification(workbench, /ready|missing|version|AGENTS|status|not found/i), ); - assert.ok(msg, "Show Status should display a notification mentioning Patchloom"); + assert.ok(msg, "Show Status should display a status notification"); }); }); @@ -115,18 +124,21 @@ describe("Patchloom Extension UI", function () { const titles = await new EditorView().getOpenEditorTitles(); return titles.some((t) => t.toLowerCase().includes("settings")) || undefined; }); + const setting = await new SettingsEditor().findSetting("Show Status Bar", "Patchloom"); + assert.ok(setting, "should find a Patchloom setting after Open Settings"); await new EditorView().closeAllEditors(); }); }); describe("Configure MCP command", function () { it("should show a notification or quick pick when invoked", async function () { + await dismissNotifications(workbench, /[Pp]atchloom/); await workbench.executeCommand("Patchloom: Configure MCP"); - // The command shows either a warning notification (binary not found) - // or a quick pick for target selection (binary found). + // The command shows either a warning (missing binary / MCP targets) + // or a multi-select quick pick. Do not treat leftover Show Status as success. const result = await poll<{ kind: string }>(async () => { - const msg = await findNotification(workbench, /[Pp]atchloom|MCP/); + const msg = await findNotification(workbench, /MCP|configure|target|binary/i); if (msg) return { kind: "notification" }; try { const input = new InputBox(); @@ -149,7 +161,7 @@ describe("Patchloom Extension UI", function () { await workbench.executeCommand("Patchloom: Quick Action"); const result = await poll<{ kind: string; labels?: string[] }>(async () => { - const msg = await findNotification(workbench, /[Pp]atchloom/); + const msg = await findNotification(workbench, /upgrade|install|ready|not found/i); if (msg) return { kind: "notification" }; try { const input = new InputBox(); diff --git a/test/unit/initializeProject.test.ts b/test/unit/initializeProject.test.ts index 723b03c..a2d5596 100644 --- a/test/unit/initializeProject.test.ts +++ b/test/unit/initializeProject.test.ts @@ -738,6 +738,8 @@ test("generateAgentRules merges extraEnv into execFile env", async () => { extraEnv: { PATCHLOOM_LOG: "debug" }, processEnv: { PATH: "/usr/bin", HOME: "/home/user" }, execFile: async (_file, _args, options) => { + assert.equal(_file, "/fake/patchloom"); + assert.deepEqual([..._args], buildAgentRulesArgs()); seenEnv = options.env; return { stdout: "# rules\n", stderr: "" }; } @@ -762,7 +764,11 @@ test("generateAgentRules logs command and streams when trace is verbose", async try { await generateAgentRules("/fake/patchloom", "/tmp", {}, { trace: "verbose", - execFile: async () => ({ stdout: "# rules\n", stderr: "note" }) + execFile: async (_file, _args) => { + assert.equal(_file, "/fake/patchloom"); + assert.deepEqual([..._args], buildAgentRulesArgs()); + return { stdout: "# rules\n", stderr: "note" }; + } }); assert.equal(commands.length, 1); assert.equal(commands[0].binary, "/fake/patchloom"); @@ -787,7 +793,11 @@ test("generateAgentRules skips CLI logs when trace is off", async () => { }); try { await generateAgentRules("/fake/patchloom", "/tmp", {}, { - execFile: async () => ({ stdout: "# rules\n", stderr: "" }) + execFile: async (_file, _args) => { + assert.equal(_file, "/fake/patchloom"); + assert.deepEqual([..._args], buildAgentRulesArgs()); + return { stdout: "# rules\n", stderr: "" }; + } }); assert.equal(commands, 0); assert.equal(results, 0); @@ -802,6 +812,8 @@ test("generateAgentRules extraEnv overwrites processEnv keys", async () => { extraEnv: { PATCHLOOM_LOG: "debug" }, processEnv: { PATH: "/usr/bin", PATCHLOOM_LOG: "info" }, execFile: async (_file, _args, options) => { + assert.equal(_file, "/fake/patchloom"); + assert.deepEqual([..._args], buildAgentRulesArgs()); seenEnv = options.env; return { stdout: "# rules\n", stderr: "" }; } @@ -867,7 +879,9 @@ test("generateAgentRules surfaces formatCliOutput envelope on CLI failure", asyn await assert.rejects( () => generateAgentRules("/fake/patchloom", "/tmp", {}, { trace: "verbose", - execFile: async () => { + execFile: async (_file, _args) => { + assert.equal(_file, "/fake/patchloom"); + assert.deepEqual([..._args], buildAgentRulesArgs()); throw execError; } }), diff --git a/test/unit/patchloomCli.test.ts b/test/unit/patchloomCli.test.ts index 2421485..1e97ef3 100644 --- a/test/unit/patchloomCli.test.ts +++ b/test/unit/patchloomCli.test.ts @@ -329,22 +329,16 @@ describe("patchloom CLI integration", async () => { test("agent-rules output classified as up_to_date after write", async () => { await withTempDir(async (dir) => { - // Generate agent rules - const { stdout: rules } = await execFileAsync(binaryPath, ["agent-rules"], { - cwd: dir, - timeout: 10000 - }); - - // Write it exactly as generated + const first = await generateAgentRules(binaryPath, dir); const agentsPath = path.join(dir, "AGENTS.md"); - const content = rules.endsWith("\n") ? rules : `${rules}\n`; - await fs.writeFile(agentsPath, content, "utf8"); + await fs.writeFile(agentsPath, first, "utf8"); - // Extension's classifier should see it as up_to_date - const existing = await fs.readFile(agentsPath, "utf8"); - const state = classifyAgentsFile(existing, content); + // Initialize Project generates again, then classifies disk vs that output. + const second = await generateAgentRules(binaryPath, dir); + const diskContent = await fs.readFile(agentsPath, "utf8"); + const state = classifyAgentsFile(diskContent, second); assert.equal(state, "up_to_date", - "freshly written agent-rules output should be classified as up_to_date"); + "second agent-rules generate should match the written AGENTS.md"); }); }); From 3f64323013637a9f89feaf48e52451c329480a4f Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 27 Aug 2026 17:15:02 -0700 Subject: [PATCH 2/3] fix: always show search and undo results in the Output channel Search, search -L, and undo toasts point at the Patchloom channel, but executePatchloom only writes stdout when patchloom.trace.server is verbose (default off). Always append those user-facing streams via writeUserVisibleOutput before show(). Other CLI I/O still follows trace.server. Signed-off-by: Sebastien Tardif --- README.md | 4 ++-- src/commands/quickActions.ts | 10 +++++++++- src/logging/outputChannel.ts | 11 +++++++++++ test/unit/outputChannel.test.ts | 34 ++++++++++++++++++++++++++++++++- 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 16b43fe..ac4f3f2 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ Workspace Quick Actions and Batch Apply pass `--contain` so CLI paths stay insid ### Output channel -All CLI invocations, arguments, and output are logged to the **Patchloom** output channel. Run `Patchloom: Show Output` to open it. Useful for debugging and copying full error messages for bug reports. +Search, search `-L`, and undo results always appear in the **Patchloom** output channel. Other CLI invocations, arguments, and I/O follow `patchloom.trace.server` (`off` by default; set `verbose` to dump every command). Run `Patchloom: Show Output` to open it. ### Compatibility diagnostics @@ -230,7 +230,7 @@ Run `Patchloom: Configure MCP` and select the target editor config. Run `Patchloom: Show Status` to see persisted diagnostic details. If the managed binary is present but not usable, choose **Reinstall Patchloom** (or the status-bar action) to re-download from GitHub Releases. **Debugging CLI errors** -Run `Patchloom: Show Output` to see full CLI invocations, arguments, stdout, and stderr in the output channel. +Run `Patchloom: Show Output` to inspect the channel. Search and undo results are always written there; set `patchloom.trace.server` to `verbose` for other CLI invocations, arguments, stdout, and stderr. --- diff --git a/src/commands/quickActions.ts b/src/commands/quickActions.ts index 7052a2f..ac7b22b 100644 --- a/src/commands/quickActions.ts +++ b/src/commands/quickActions.ts @@ -5,7 +5,7 @@ import * as path from "node:path"; import { promisify } from "node:util"; import type * as VSCode from "vscode"; import { ensurePatchloomReadyOrNotify } from "../binary/patchloom.js"; -import { getPatchloomLog, getPatchloomRuntimeConfig, logCliCommand, logCliResult } from "../logging/outputChannel.js"; +import { getPatchloomLog, getPatchloomRuntimeConfig, logCliCommand, logCliResult, writeUserVisibleOutput } from "../logging/outputChannel.js"; import { formatCliOutput, formatError, mergePatchloomEnv } from "../util.js"; import { activeWorkspaceFolder, describeWorkspaceEnvironment } from "../workspace/readiness.js"; @@ -320,6 +320,8 @@ export async function runQuickAction(): Promise { } else if (result.exitCode !== 0) { await vscode.window.showErrorMessage(`Patchloom search failed: ${formatCliOutput(result)}`); } else { + writeUserVisibleOutput(log, result.stdout); + writeUserVisibleOutput(log, result.stderr); log?.show(); await vscode.window.showInformationMessage("Search results displayed in the Patchloom output channel."); } @@ -367,6 +369,8 @@ export async function runQuickAction(): Promise { } else if (result.exitCode !== 0) { await vscode.window.showErrorMessage(`Patchloom search failed: ${formatCliOutput(result)}`); } else { + writeUserVisibleOutput(log, result.stdout); + writeUserVisibleOutput(log, result.stderr); log?.show(); await vscode.window.showInformationMessage( "Files without matches are listed in the Patchloom output channel." @@ -1007,6 +1011,8 @@ export async function runQuickAction(): Promise { log?.show(); await vscode.window.showErrorMessage(`Patch merge failed: ${formatCliOutput(result)}`); } else { + writeUserVisibleOutput(log, result.stdout); + writeUserVisibleOutput(log, result.stderr); log?.show(); await vscode.window.showInformationMessage("Patch merged successfully."); } @@ -1047,6 +1053,8 @@ export async function runQuickAction(): Promise { } const log = getPatchloomLog(); + writeUserVisibleOutput(log, result.stdout); + writeUserVisibleOutput(log, result.stderr); log?.show(); await vscode.window.showInformationMessage("Patchloom undo complete. Restored files shown in the output channel."); } diff --git a/src/logging/outputChannel.ts b/src/logging/outputChannel.ts index 0e08d4d..42dfd23 100644 --- a/src/logging/outputChannel.ts +++ b/src/logging/outputChannel.ts @@ -61,6 +61,17 @@ export function logCliResult( log?.logResult(exitCode, "", ""); } +/** Always write user-facing CLI output, independent of `patchloom.trace.server`. */ +export function writeUserVisibleOutput( + log: PatchloomLog | undefined, + text: string +): void { + if (log === undefined || text === "") { + return; + } + log.log(text); +} + export interface OutputChannelLike { appendLine(value: string): void; show(preserveFocus?: boolean): void; diff --git a/test/unit/outputChannel.test.ts b/test/unit/outputChannel.test.ts index 5a94a2e..a74ee9e 100644 --- a/test/unit/outputChannel.test.ts +++ b/test/unit/outputChannel.test.ts @@ -5,7 +5,9 @@ import { getPatchloomLog, logCliCommand, logCliResult, - setPatchloomLog + setPatchloomLog, + writeUserVisibleOutput, + type PatchloomLog } from "../../src/logging/outputChannel.js"; test("createPatchloomLog lazily creates channel on first log call", () => { @@ -239,3 +241,33 @@ test("logCliResult dumps streams when trace is verbose", () => { assert.ok(lines.some(l => l === "stderr: stderr-body")); assert.ok(lines.some(l => l.includes("Exit code: 0"))); }); + +function fakeLog(): { log: PatchloomLog; messages: string[] } { + const messages: string[] = []; + return { + messages, + log: { + log(message: string) { messages.push(message); }, + logCommand() {}, + logResult() {}, + show() {}, + dispose() {} + } + }; +} + +test("writeUserVisibleOutput no-ops on empty string", () => { + const { log, messages } = fakeLog(); + writeUserVisibleOutput(log, ""); + assert.deepEqual(messages, []); +}); + +test("writeUserVisibleOutput logs non-empty text", () => { + const { log, messages } = fakeLog(); + writeUserVisibleOutput(log, "file.ts:1:TODO"); + assert.deepEqual(messages, ["file.ts:1:TODO"]); +}); + +test("writeUserVisibleOutput no-ops when log is undefined", () => { + writeUserVisibleOutput(undefined, "hits"); +}); From e0eed1f53febb3c8a1329065f1a91f2cd2ae16f7 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 27 Aug 2026 17:38:24 -0700 Subject: [PATCH 3/3] fix: write patch-merge conflict details to the Output channel Present search, undo, and patch-merge CLI streams through a shared helper so default trace=off still shows results, including exit 8 conflicts. Unit tests lock the presenters, not only the log helper. Signed-off-by: Sebastien Tardif --- AGENTS.md | 4 +- README.md | 4 +- src/commands/quickActions.ts | 74 ++++++++++++++++++------- src/logging/outputChannel.ts | 10 ++++ test/unit/outputChannel.test.ts | 26 +++++++-- test/unit/quickActions.test.ts | 95 +++++++++++++++++++++++++++++++++ 6 files changed, 186 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9d5a159..567aa88 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,10 +54,10 @@ test/ initializeProject.test.ts Status display, agents file classification, formatError (61 tests) managedLifecycle.test.ts Managed install with real file I/O (22 tests) mcpConfig.test.ts MCP config with real temp directories (12 tests) - outputChannel.test.ts Output channel logging wrapper (15 tests) + outputChannel.test.ts Output channel logging wrapper (21 tests) patchloomCli.test.ts Patchloom CLI integration with real binary + managed install e2e MCP (46 tests incl. e2e) propertyBased.test.ts Property-based tests with fast-check (13 tests) - quickActions.test.ts Quick action command building, path containment, patch merge (64 tests) + quickActions.test.ts Quick action command building, path containment, patch merge (71 tests) verifyMcp.test.ts MCP server verify and JSON-RPC response parsing (15 tests) downloadIntegration.test.ts HTTP download, redirect, streaming SHA-256 (9 tests) suite/ diff --git a/README.md b/README.md index ac4f3f2..27a69b2 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ Workspace Quick Actions and Batch Apply pass `--contain` so CLI paths stay insid ### Output channel -Search, search `-L`, and undo results always appear in the **Patchloom** output channel. Other CLI invocations, arguments, and I/O follow `patchloom.trace.server` (`off` by default; set `verbose` to dump every command). Run `Patchloom: Show Output` to open it. +Search, search `-L`, undo, and patch-merge success or conflict details always appear in the **Patchloom** output channel. Other CLI invocations, arguments, and I/O follow `patchloom.trace.server` (`off` by default; set `verbose` to dump every command). Run `Patchloom: Show Output` to open it. ### Compatibility diagnostics @@ -230,7 +230,7 @@ Run `Patchloom: Configure MCP` and select the target editor config. Run `Patchloom: Show Status` to see persisted diagnostic details. If the managed binary is present but not usable, choose **Reinstall Patchloom** (or the status-bar action) to re-download from GitHub Releases. **Debugging CLI errors** -Run `Patchloom: Show Output` to inspect the channel. Search and undo results are always written there; set `patchloom.trace.server` to `verbose` for other CLI invocations, arguments, stdout, and stderr. +Run `Patchloom: Show Output` to inspect the channel. Search, undo, and patch-merge success or conflict details are always written there; set `patchloom.trace.server` to `verbose` for other CLI invocations, arguments, stdout, and stderr. --- diff --git a/src/commands/quickActions.ts b/src/commands/quickActions.ts index ac7b22b..82eec6f 100644 --- a/src/commands/quickActions.ts +++ b/src/commands/quickActions.ts @@ -5,7 +5,14 @@ import * as path from "node:path"; import { promisify } from "node:util"; import type * as VSCode from "vscode"; import { ensurePatchloomReadyOrNotify } from "../binary/patchloom.js"; -import { getPatchloomLog, getPatchloomRuntimeConfig, logCliCommand, logCliResult, writeUserVisibleOutput } from "../logging/outputChannel.js"; +import { + getPatchloomLog, + getPatchloomRuntimeConfig, + logCliCommand, + logCliResult, + presentCliResultInOutput, + type PatchloomLog +} from "../logging/outputChannel.js"; import { formatCliOutput, formatError, mergePatchloomEnv } from "../util.js"; import { activeWorkspaceFolder, describeWorkspaceEnvironment } from "../workspace/readiness.js"; @@ -22,6 +29,42 @@ export interface PlannedQuickAction { readonly args: readonly string[]; } +export function presentSearchOutcome( + log: PatchloomLog | undefined, + result: { exitCode: number; stdout: string; stderr: string } +): "hits" | "none" | "error" { + if (result.exitCode === 3) { + return "none"; + } + if (result.exitCode !== 0) { + return "error"; + } + presentCliResultInOutput(log, result); + return "hits"; +} + +export function presentPatchMergeOutcome( + log: PatchloomLog | undefined, + result: { exitCode: number; stdout: string; stderr: string } +): "ok" | "conflicts" | "error" { + if (result.exitCode === 8) { + presentCliResultInOutput(log, result); + return "conflicts"; + } + if (result.exitCode !== 0) { + return "error"; + } + presentCliResultInOutput(log, result); + return "ok"; +} + +export function presentUndoSuccess( + log: PatchloomLog | undefined, + result: { stdout: string; stderr: string } +): void { + presentCliResultInOutput(log, result); +} + interface WorkspaceFileTarget { readonly workspaceFolder: VSCode.WorkspaceFolder; readonly absolutePath: string; @@ -314,15 +357,13 @@ export async function runQuickAction(): Promise { const action = buildSearchQuickAction(folder.uri.fsPath, pattern, glob || undefined); const result = await executePatchloom(binaryPath, action.args, folder.uri.fsPath); const log = getPatchloomLog(); + const outcome = presentSearchOutcome(log, result); - if (result.exitCode === 3) { + if (outcome === "none") { await vscode.window.showInformationMessage(`No matches found for "${pattern}".`); - } else if (result.exitCode !== 0) { + } else if (outcome === "error") { await vscode.window.showErrorMessage(`Patchloom search failed: ${formatCliOutput(result)}`); } else { - writeUserVisibleOutput(log, result.stdout); - writeUserVisibleOutput(log, result.stderr); - log?.show(); await vscode.window.showInformationMessage("Search results displayed in the Patchloom output channel."); } } @@ -363,15 +404,13 @@ export async function runQuickAction(): Promise { }); const result = await executePatchloom(binaryPath, action.args, folder.uri.fsPath); const log = getPatchloomLog(); + const outcome = presentSearchOutcome(log, result); - if (result.exitCode === 3) { + if (outcome === "none") { await vscode.window.showInformationMessage(`Every scanned file contains "${pattern}".`); - } else if (result.exitCode !== 0) { + } else if (outcome === "error") { await vscode.window.showErrorMessage(`Patchloom search failed: ${formatCliOutput(result)}`); } else { - writeUserVisibleOutput(log, result.stdout); - writeUserVisibleOutput(log, result.stderr); - log?.show(); await vscode.window.showInformationMessage( "Files without matches are listed in the Patchloom output channel." ); @@ -1003,17 +1042,14 @@ export async function runQuickAction(): Promise { const contain = isPathInsideWorkspace(folder.uri.fsPath, patchUri[0].fsPath); const result = await executePatchloom(binaryPath, action.args, folder.uri.fsPath, { contain }); const log = getPatchloomLog(); + const outcome = presentPatchMergeOutcome(log, result); - if (result.exitCode === 8) { - log?.show(); + if (outcome === "conflicts") { await vscode.window.showWarningMessage("Patch merge completed with unresolved conflicts. Check the output for details."); - } else if (result.exitCode !== 0) { + } else if (outcome === "error") { log?.show(); await vscode.window.showErrorMessage(`Patch merge failed: ${formatCliOutput(result)}`); } else { - writeUserVisibleOutput(log, result.stdout); - writeUserVisibleOutput(log, result.stderr); - log?.show(); await vscode.window.showInformationMessage("Patch merged successfully."); } } @@ -1053,9 +1089,7 @@ export async function runQuickAction(): Promise { } const log = getPatchloomLog(); - writeUserVisibleOutput(log, result.stdout); - writeUserVisibleOutput(log, result.stderr); - log?.show(); + presentUndoSuccess(log, result); await vscode.window.showInformationMessage("Patchloom undo complete. Restored files shown in the output channel."); } } diff --git a/src/logging/outputChannel.ts b/src/logging/outputChannel.ts index 42dfd23..dc678d5 100644 --- a/src/logging/outputChannel.ts +++ b/src/logging/outputChannel.ts @@ -72,6 +72,16 @@ export function writeUserVisibleOutput( log.log(text); } +/** Write CLI streams then show the channel (trace-independent user-facing results). */ +export function presentCliResultInOutput( + log: PatchloomLog | undefined, + result: { stdout?: string; stderr?: string } +): void { + writeUserVisibleOutput(log, result.stdout ?? ""); + writeUserVisibleOutput(log, result.stderr ?? ""); + log?.show(); +} + export interface OutputChannelLike { appendLine(value: string): void; show(preserveFocus?: boolean): void; diff --git a/test/unit/outputChannel.test.ts b/test/unit/outputChannel.test.ts index a74ee9e..feaefe6 100644 --- a/test/unit/outputChannel.test.ts +++ b/test/unit/outputChannel.test.ts @@ -5,6 +5,7 @@ import { getPatchloomLog, logCliCommand, logCliResult, + presentCliResultInOutput, setPatchloomLog, writeUserVisibleOutput, type PatchloomLog @@ -242,18 +243,21 @@ test("logCliResult dumps streams when trace is verbose", () => { assert.ok(lines.some(l => l.includes("Exit code: 0"))); }); -function fakeLog(): { log: PatchloomLog; messages: string[] } { +function fakeLog(): { log: PatchloomLog; messages: string[]; shown: boolean } { const messages: string[] = []; - return { + let shown = false; + const state = { messages, + get shown() { return shown; }, log: { log(message: string) { messages.push(message); }, logCommand() {}, logResult() {}, - show() {}, + show() { shown = true; messages.push("SHOW"); }, dispose() {} } }; + return state; } test("writeUserVisibleOutput no-ops on empty string", () => { @@ -271,3 +275,19 @@ test("writeUserVisibleOutput logs non-empty text", () => { test("writeUserVisibleOutput no-ops when log is undefined", () => { writeUserVisibleOutput(undefined, "hits"); }); + +test("presentCliResultInOutput writes stdout then stderr and calls show", () => { + const { log, messages } = fakeLog(); + presentCliResultInOutput(log, { stdout: "out-line", stderr: "err-line" }); + assert.deepEqual(messages, ["out-line", "err-line", "SHOW"]); +}); + +test("presentCliResultInOutput skips empty streams", () => { + const { log, messages } = fakeLog(); + presentCliResultInOutput(log, { stdout: "", stderr: "" }); + assert.deepEqual(messages, ["SHOW"]); +}); + +test("presentCliResultInOutput no-ops when log is undefined", () => { + presentCliResultInOutput(undefined, { stdout: "out", stderr: "err" }); +}); diff --git a/test/unit/quickActions.test.ts b/test/unit/quickActions.test.ts index 823178d..8a8b2d5 100644 --- a/test/unit/quickActions.test.ts +++ b/test/unit/quickActions.test.ts @@ -29,11 +29,29 @@ import { isMarkdownPath, isStructuredDocumentPath, isPathInsideWorkspace, + presentPatchMergeOutcome, + presentSearchOutcome, + presentUndoSuccess, resolveWorkspaceRelativePath, retargetQuickAction, withApplyFlag, withContainFlag } from "../../src/commands/quickActions.js"; +import type { PatchloomLog } from "../../src/logging/outputChannel.js"; + +function fakeLog(): { log: PatchloomLog; messages: string[] } { + const messages: string[] = []; + return { + messages, + log: { + log(message: string) { messages.push(message); }, + logCommand() {}, + logResult() {}, + show() { messages.push("SHOW"); }, + dispose() {} + } + }; +} test("buildReplaceQuickAction builds a replace command for one file", () => { const action = buildReplaceQuickAction("/workspace/demo/README.md", "old", "new"); @@ -678,3 +696,80 @@ test("buildAppendQuickAction builds an append command", () => { assert.deepEqual(action.targetArgIndices, [1]); assert.deepEqual(action.args, ["append", "/workspace/demo/log.txt", "--content", "new log entry"]); }); + +// --- result presenters (search / patch-merge / undo) --- + +test("presentSearchOutcome exit 0 writes streams + show, returns hits", () => { + const { log, messages } = fakeLog(); + const kind = presentSearchOutcome(log, { + exitCode: 0, + stdout: "file.ts:1:hit", + stderr: "note" + }); + assert.equal(kind, "hits"); + assert.deepEqual(messages, ["file.ts:1:hit", "note", "SHOW"]); +}); + +test("presentSearchOutcome exit 3 writes nothing, returns none", () => { + const { log, messages } = fakeLog(); + const kind = presentSearchOutcome(log, { + exitCode: 3, + stdout: "should-not-write", + stderr: "also-not" + }); + assert.equal(kind, "none"); + assert.deepEqual(messages, []); +}); + +test("presentSearchOutcome exit 2 writes nothing, returns error", () => { + const { log, messages } = fakeLog(); + const kind = presentSearchOutcome(log, { + exitCode: 2, + stdout: "out", + stderr: "err" + }); + assert.equal(kind, "error"); + assert.deepEqual(messages, []); +}); + +test("presentPatchMergeOutcome exit 8 writes streams + show, returns conflicts", () => { + const { log, messages } = fakeLog(); + const kind = presentPatchMergeOutcome(log, { + exitCode: 8, + stdout: "<<<<<<< HEAD", + stderr: "unresolved conflicts" + }); + assert.equal(kind, "conflicts"); + assert.deepEqual(messages, ["<<<<<<< HEAD", "unresolved conflicts", "SHOW"]); +}); + +test("presentPatchMergeOutcome exit 0 writes streams + show, returns ok", () => { + const { log, messages } = fakeLog(); + const kind = presentPatchMergeOutcome(log, { + exitCode: 0, + stdout: "merged 2 hunks", + stderr: "" + }); + assert.equal(kind, "ok"); + assert.deepEqual(messages, ["merged 2 hunks", "SHOW"]); +}); + +test("presentPatchMergeOutcome exit 1 writes nothing, returns error", () => { + const { log, messages } = fakeLog(); + const kind = presentPatchMergeOutcome(log, { + exitCode: 1, + stdout: "out", + stderr: "failed" + }); + assert.equal(kind, "error"); + assert.deepEqual(messages, []); +}); + +test("presentUndoSuccess writes streams + show", () => { + const { log, messages } = fakeLog(); + presentUndoSuccess(log, { + stdout: "restored a.ts", + stderr: "info" + }); + assert.deepEqual(messages, ["restored a.ts", "info", "SHOW"]); +});