diff --git a/AGENTS.md b/AGENTS.md index 4b7b39f..b0c168e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) @@ -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/ diff --git a/src/commands/quickActions.ts b/src/commands/quickActions.ts index 1c18c1a..4c401b7 100644 --- a/src/commands/quickActions.ts +++ b/src/commands/quickActions.ts @@ -1808,7 +1808,9 @@ export async function stageExternalPatchInWorkspace( workspaceRoot: string, patchPath: string ): Promise { - 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 () => {} }; } diff --git a/src/util.ts b/src/util.ts index 9938691..8d95098 100644 --- a/src/util.ts +++ b/src/util.ts @@ -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; @@ -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"); diff --git a/test/unit/initializeProject.test.ts b/test/unit/initializeProject.test.ts index d7e319d..6e6f8aa 100644 --- a/test/unit/initializeProject.test.ts +++ b/test/unit/initializeProject.test.ts @@ -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({ @@ -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); } diff --git a/test/unit/managedLifecycle.test.ts b/test/unit/managedLifecycle.test.ts index f5404e6..37bcc1b 100644 --- a/test/unit/managedLifecycle.test.ts +++ b/test/unit/managedLifecycle.test.ts @@ -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" } @@ -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( @@ -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( @@ -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; @@ -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 --- diff --git a/test/unit/quickActions.test.ts b/test/unit/quickActions.test.ts index 502d4ee..9632f1a 100644 --- a/test/unit/quickActions.test.ts +++ b/test/unit/quickActions.test.ts @@ -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 }); + } +});