Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, 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

Expand Down Expand Up @@ -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, 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.

---

Expand Down
66 changes: 54 additions & 12 deletions src/commands/quickActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } 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";

Expand All @@ -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;
Expand Down Expand Up @@ -314,13 +357,13 @@ export async function runQuickAction(): Promise<void> {
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 {
log?.show();
await vscode.window.showInformationMessage("Search results displayed in the Patchloom output channel.");
}
}
Expand Down Expand Up @@ -361,13 +404,13 @@ export async function runQuickAction(): Promise<void> {
});
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 {
log?.show();
await vscode.window.showInformationMessage(
"Files without matches are listed in the Patchloom output channel."
);
Expand Down Expand Up @@ -999,15 +1042,14 @@ export async function runQuickAction(): Promise<void> {
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 {
log?.show();
await vscode.window.showInformationMessage("Patch merged successfully.");
}
}
Expand Down Expand Up @@ -1047,7 +1089,7 @@ export async function runQuickAction(): Promise<void> {
}

const log = getPatchloomLog();
log?.show();
presentUndoSuccess(log, result);
await vscode.window.showInformationMessage("Patchloom undo complete. Restored files shown in the output channel.");
}
}
Expand Down
21 changes: 21 additions & 0 deletions src/logging/outputChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,27 @@ 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);
}

/** 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;
Expand Down
34 changes: 23 additions & 11 deletions test/ui/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
StatusBar,
InputBox,
EditorView,
SettingsEditor,
} from "vscode-extension-tester";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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<void> {
while (await findNotification(workbench, pattern)) {
// keep dismissing leftovers
}
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
Expand All @@ -84,27 +95,25 @@ 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 () {
it("should display a Patchloom status bar item", async 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");
});
});

Expand All @@ -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();
Expand All @@ -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();
Expand Down
20 changes: 17 additions & 3 deletions test/unit/initializeProject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "" };
}
Expand All @@ -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");
Expand All @@ -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);
Expand All @@ -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: "" };
}
Expand Down Expand Up @@ -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;
}
}),
Expand Down
54 changes: 53 additions & 1 deletion test/unit/outputChannel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import {
getPatchloomLog,
logCliCommand,
logCliResult,
setPatchloomLog
presentCliResultInOutput,
setPatchloomLog,
writeUserVisibleOutput,
type PatchloomLog
} from "../../src/logging/outputChannel.js";

test("createPatchloomLog lazily creates channel on first log call", () => {
Expand Down Expand Up @@ -239,3 +242,52 @@ 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[]; shown: boolean } {
const messages: string[] = [];
let shown = false;
const state = {
messages,
get shown() { return shown; },
log: {
log(message: string) { messages.push(message); },
logCommand() {},
logResult() {},
show() { shown = true; messages.push("SHOW"); },
dispose() {}
}
};
return state;
}

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");
});

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" });
});
Loading
Loading