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 @@ -53,7 +53,7 @@ test/
batchApply.test.ts Batch template and operation count parsing (20 tests)
binary.test.ts Binary discovery, managed install, compatibility, workspace env (74 tests)
binaryDiscovery.test.ts Real executable discovery on PATH (13 tests)
initializeProject.test.ts Status display, agents file classification, formatError (68 tests)
initializeProject.test.ts Status display, agents file classification, formatError (69 tests)
managedLifecycle.test.ts Managed install with real file I/O (26 tests)
mcpConfig.test.ts MCP config with real temp directories (12 tests)
managedInstall.test.ts Managed Update compares latest vs managed binary (10 tests)
Expand All @@ -62,7 +62,7 @@ test/
outputChannel.test.ts Output channel logging wrapper (22 tests)
patchloomCli.test.ts Patchloom CLI integration with real binary + managed install e2e MCP (49 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 (89 tests)
quickActions.test.ts Quick action command building, path containment, patch merge (90 tests)
verifyMcp.test.ts MCP server verify and JSON-RPC response parsing (15 tests)
downloadIntegration.test.ts HTTP download, redirect, streaming SHA-256 (12 tests)
suite/
Expand Down
4 changes: 3 additions & 1 deletion src/commands/quickActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1808,7 +1808,9 @@ export async function stageExternalPatchInWorkspace(
workspaceRoot: string,
patchPath: string
): Promise<StagedExternalPatch> {
if (isPathInsideWorkspace(workspaceRoot, patchPath)) {
// Keep only patches whose real path is inside the workspace. A workspace
// symlink to an outside .patch must be copied so --contain stays on.
if (isRealPathInsideWorkspace(workspaceRoot, patchPath)) {
return { patchPath, cleanup: async () => {} };
}

Expand Down
4 changes: 2 additions & 2 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ const QUICK_ACTION_DELETE_WHERE_HINT =
* Quick Action toast wrapper around formatCliOutput. Agents still parse the
* CLI token from formatCliOutput; picker labels are for humans in the UI.
* Preview does not pass --json, so also scan human stderr for suggested_op
* tokens (doc.update, doc.delete_where, doc delete-where).
* tokens (doc.update, doc update, doc.delete_where, doc delete-where).
*/
export function formatQuickActionCliOutput(result: {
exitCode: number;
Expand All @@ -103,7 +103,7 @@ export function formatQuickActionCliOutput(result: {
.replaceAll("(try doc.delete_where)", QUICK_ACTION_DELETE_WHERE_HINT);

const raw = `${result.stderr}\n${result.stdout}`;
const mentionsUpdate = raw.includes("doc.update");
const mentionsUpdate = raw.includes("doc.update") || raw.includes("doc update");
const mentionsDelete =
raw.includes("doc.delete_where") || raw.includes("doc delete-where");
const mentionsSuggestedOp = raw.includes("suggested_op");
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 @@ -341,6 +341,17 @@ test("formatQuickActionCliOutput maps human stderr doc.update to the picker labe
);
});

test("formatQuickActionCliOutput maps human stderr doc update space form to the picker label", () => {
assert.equal(
formatQuickActionCliOutput({
exitCode: 1,
stdout: "",
stderr: "Use doc update for wildcard or predicate selectors"
}),
'Use doc update for wildcard or predicate selectors (try Quick Action "Update matching structured values" or CLI `doc update`)'
);
});

test("formatQuickActionCliOutput maps human stderr doc delete-where to the picker label", () => {
assert.equal(
formatQuickActionCliOutput({
Expand Down Expand Up @@ -969,13 +980,16 @@ test("generateAgentRules surfaces formatCliOutput envelope on CLI failure", asyn
}
}),
(err: Error) => {
assert.match(err.message, /invalid_input/);
assert.match(err.message, /try doc\.update/);
assert.equal(
err.message,
"invalid_input: selector uses wildcard/predicate, which is not valid for doc.set (single path only) (try doc.update)"
);
return true;
}
);
assert.equal(logged.length, 1, "logResult should be called once on failure");
assert.match(`${logged[0].stdout}\n${logged[0].stderr}`, /invalid_input|suggested_op/);
assert.equal(logged[0].stdout, stdout);
assert.equal(logged[0].stderr, "Command failed: patchloom agent-rules");
} finally {
setPatchloomLog(undefined);
}
Expand Down
26 changes: 17 additions & 9 deletions test/unit/managedLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,12 +320,20 @@ test("fetchLatestReleaseVersion throws on API failure", async () => {
);
});

function assertGitHubLatestReleaseFetch(url: string | URL | Request, init?: RequestInit): void {
assert.equal(String(url), "https://api.github.com/repos/patchloom/patchloom/releases/latest");
const headers = new Headers(init?.headers);
assert.equal(headers.get("Accept"), "application/vnd.github+json");
assert.equal(headers.get("User-Agent"), "patchloom-vscode");
assert.ok(init?.signal instanceof AbortSignal, "defaultFetchJson must pass an AbortSignal");
}

describe("defaultFetchJson via fetchLatestReleaseVersion", { concurrency: false }, () => {
test("returns the version from a 200 GitHub latest-release payload", async () => {
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
assert.ok(init?.signal instanceof AbortSignal, "defaultFetchJson must pass an AbortSignal");
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
assertGitHubLatestReleaseFetch(url, init);
return new Response(JSON.stringify({ tag_name: "v0.31.0" }), {
status: 200,
headers: { "Content-Type": "application/json" }
Expand All @@ -341,8 +349,8 @@ describe("defaultFetchJson via fetchLatestReleaseVersion", { concurrency: false
test("rejects on HTTP 404", async () => {
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
assert.ok(init?.signal instanceof AbortSignal, "defaultFetchJson must pass an AbortSignal");
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
assertGitHubLatestReleaseFetch(url, init);
return new Response("Not Found", { status: 404, statusText: "Not Found" });
}) as typeof fetch;
await assert.rejects(
Expand All @@ -357,8 +365,8 @@ describe("defaultFetchJson via fetchLatestReleaseVersion", { concurrency: false
test("rejects on HTTP 500", async () => {
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
assert.ok(init?.signal instanceof AbortSignal, "defaultFetchJson must pass an AbortSignal");
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
assertGitHubLatestReleaseFetch(url, init);
return new Response("error", { status: 500, statusText: "Internal Server Error" });
}) as typeof fetch;
await assert.rejects(
Expand All @@ -373,8 +381,8 @@ describe("defaultFetchJson via fetchLatestReleaseVersion", { concurrency: false
test("surfaces an aborted hung fetch", async () => {
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
assert.ok(init?.signal instanceof AbortSignal, "defaultFetchJson must pass an AbortSignal");
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
assertGitHubLatestReleaseFetch(url, init);
const err = new Error("The operation was aborted");
err.name = "AbortError";
throw err;
Expand Down Expand Up @@ -415,7 +423,7 @@ test("extractManagedInstallArchive invokes tar for zip format on Windows", async

assert.equal(calls.length, 1);
assert.equal(calls[0].cmd, "tar");
assert.ok(calls[0].args.includes("xf"));
assert.deepEqual(calls[0].args, ["xf", "C:\\tmp\\archive.zip", "-C", "C:\\tmp\\staging"]);
});

// --- performManagedInstall tests ---
Expand Down
30 changes: 30 additions & 0 deletions test/unit/quickActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -976,3 +976,33 @@ test("stageExternalPatchInWorkspace leaves an inside patch in place", async () =
await fs.rm(workspaceRoot, { recursive: true, force: true });
}
});

test("stageExternalPatchInWorkspace copies a workspace symlink to an outside patch", async (t) => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "patchloom-stage-symlink-ws-"));
const outsideRoot = await fs.mkdtemp(path.join(os.tmpdir(), "patchloom-stage-symlink-out-"));
try {
const outsidePatch = path.join(outsideRoot, "changes.patch");
await fs.writeFile(outsidePatch, "diff --git a/x b/x\n", "utf8");
const linkPath = path.join(workspaceRoot, "escape.patch");
try {
await fs.symlink(outsidePatch, linkPath);
} catch {
t.skip("fs.symlink is not available on this platform");
return;
}
const staged = await stageExternalPatchInWorkspace(workspaceRoot, linkPath);
try {
assert.notEqual(staged.patchPath, linkPath);
assert.equal(isPathInsideWorkspace(workspaceRoot, staged.patchPath), true);
assert.equal(isRealPathInsideWorkspace(workspaceRoot, staged.patchPath), true);
assert.equal(await fs.readFile(staged.patchPath, "utf8"), "diff --git a/x b/x\n");
} finally {
await staged.cleanup();
}
await assert.rejects(() => fs.access(staged.patchPath));
assert.equal(await fs.readFile(outsidePatch, "utf8"), "diff --git a/x b/x\n");
} finally {
await fs.rm(workspaceRoot, { recursive: true, force: true });
await fs.rm(outsideRoot, { recursive: true, force: true });
}
});
Loading