Skip to content

Commit d5ead62

Browse files
committed
test: add path containment, builder edge-case, and batch template tests
- Export resolveWorkspaceRelativePath for testable path validation - Add 11 tests for path containment: traversal, outside workspace, root (#33) - Add 6 edge-case builder tests: empty/regex/unicode/spaces (#32) - Add 3 batch template field-presence tests (#34) - Update AGENTS.md test counts (batchApply 7->10, quickActions 15->26) Tests: 140 -> 154 Closes #32 Closes #33 Closes #34 Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
1 parent d5f7474 commit d5ead62

4 files changed

Lines changed: 111 additions & 8 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,15 @@ src/
4343
workspace/readiness.ts Workspace readiness: environment detection, folder selection
4444
test/
4545
unit/ Unit tests (node:test, dependency-injected, no VS Code API)
46-
batchApply.test.ts Batch template and operation count parsing (7 tests)
46+
batchApply.test.ts Batch template and operation count parsing (10 tests)
4747
binary.test.ts Binary discovery, managed install, compatibility, workspace env (38 tests)
4848
binaryDiscovery.test.ts Real executable discovery on PATH (10 tests)
4949
initializeProject.test.ts Status display, agents file classification, formatError (18 tests)
5050
managedLifecycle.test.ts Managed install with real file I/O (12 tests)
5151
mcpConfig.test.ts MCP config with real temp directories (9 tests)
5252
outputChannel.test.ts Output channel logging wrapper (8 tests)
5353
patchloomCli.test.ts Patchloom CLI integration tests with real binary (23 tests)
54-
quickActions.test.ts Quick action command building (15 tests)
54+
quickActions.test.ts Quick action command building, path containment (26 tests)
5555
suite/
5656
index.ts VS Code extension integration tests
5757
runExtensionTests.ts Test runner using @vscode/test-electron

src/commands/quickActions.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -555,19 +555,24 @@ async function inputWorkspaceFileTarget(folder: VSCode.WorkspaceFolder): Promise
555555
}
556556
}
557557

558-
function toWorkspaceFileTarget(folder: VSCode.WorkspaceFolder, absolutePath: string): WorkspaceFileTarget {
559-
const workspaceRoot = path.resolve(folder.uri.fsPath);
558+
export function resolveWorkspaceRelativePath(workspaceRoot: string, absolutePath: string): string {
559+
const resolvedRoot = path.resolve(workspaceRoot);
560560
const resolvedPath = path.resolve(absolutePath);
561-
const relativePath = path.relative(workspaceRoot, resolvedPath);
561+
const relativePath = path.relative(resolvedRoot, resolvedPath);
562562
if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
563563
throw new Error("File path must stay inside the current workspace folder.");
564564
}
565+
return relativePath.split(path.sep).join("/");
566+
}
567+
568+
function toWorkspaceFileTarget(folder: VSCode.WorkspaceFolder, absolutePath: string): WorkspaceFileTarget {
569+
const relativePath = resolveWorkspaceRelativePath(folder.uri.fsPath, absolutePath);
565570

566571
return {
567572
workspaceFolder: folder,
568-
absolutePath: resolvedPath,
569-
relativePath: relativePath.split(path.sep).join("/"),
570-
uri: folder.uri.with({ path: path.posix.join(folder.uri.path, relativePath.split(path.sep).join("/")) })
573+
absolutePath: path.resolve(absolutePath),
574+
relativePath,
575+
uri: folder.uri.with({ path: path.posix.join(folder.uri.path, relativePath) })
571576
};
572577
}
573578

test/unit/batchApply.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,31 @@ test("parseBatchOperationCount returns 0 when operations is not an array", () =>
4646
assert.equal(parseBatchOperationCount('{"operations": 42}'), 0);
4747
assert.equal(parseBatchOperationCount('{"operations": null}'), 0);
4848
});
49+
50+
// --- #34: snapshot-style template tests ---
51+
52+
test("buildBatchTemplate replace operation has required fields", () => {
53+
const parsed = JSON.parse(buildBatchTemplate());
54+
const replace = parsed.operations[0];
55+
assert.equal(replace.op, "replace");
56+
assert.ok("file" in replace, "replace operation missing 'file'");
57+
assert.ok("from" in replace, "replace operation missing 'from'");
58+
assert.ok("to" in replace, "replace operation missing 'to'");
59+
});
60+
61+
test("buildBatchTemplate tidy operation has required fields", () => {
62+
const parsed = JSON.parse(buildBatchTemplate());
63+
const tidy = parsed.operations[1];
64+
assert.equal(tidy.op, "tidy");
65+
assert.ok("file" in tidy, "tidy operation missing 'file'");
66+
assert.ok(Array.isArray(tidy.fixes), "tidy operation 'fixes' should be an array");
67+
});
68+
69+
test("buildBatchTemplate doc-set operation has required fields", () => {
70+
const parsed = JSON.parse(buildBatchTemplate());
71+
const docSet = parsed.operations[2];
72+
assert.equal(docSet.op, "doc-set");
73+
assert.ok("file" in docSet, "doc-set operation missing 'file'");
74+
assert.ok("selector" in docSet, "doc-set operation missing 'selector'");
75+
assert.ok("value" in docSet, "doc-set operation missing 'value'");
76+
});

test/unit/quickActions.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import assert from "node:assert/strict";
22
import test from "node:test";
3+
import path from "node:path";
34
import {
45
buildCreateQuickAction,
56
buildDocGetQuickAction,
@@ -8,6 +9,7 @@ import {
89
buildSearchQuickAction,
910
buildTidyQuickAction,
1011
isStructuredDocumentPath,
12+
resolveWorkspaceRelativePath,
1113
retargetQuickAction,
1214
withApplyFlag
1315
} from "../../src/commands/quickActions.js";
@@ -157,3 +159,71 @@ test("buildDocGetQuickAction builds a doc get command", () => {
157159
assert.deepEqual(action.args, ["doc", "get", "/workspace/demo/package.json", "scripts.test"]);
158160
assert.deepEqual(action.targetArgIndices, [2]);
159161
});
162+
163+
// --- #33: resolveWorkspaceRelativePath path containment ---
164+
165+
test("resolveWorkspaceRelativePath accepts path inside workspace", () => {
166+
const rel = resolveWorkspaceRelativePath("/workspace/demo", "/workspace/demo/src/file.ts");
167+
assert.equal(rel, "src/file.ts");
168+
});
169+
170+
test("resolveWorkspaceRelativePath accepts nested subdirectory", () => {
171+
const rel = resolveWorkspaceRelativePath("/workspace/demo", "/workspace/demo/a/b/c/d.txt");
172+
assert.equal(rel, "a/b/c/d.txt");
173+
});
174+
175+
test("resolveWorkspaceRelativePath rejects traversal with ..", () => {
176+
assert.throws(
177+
() => resolveWorkspaceRelativePath("/workspace/demo", "/workspace/demo/../../etc/passwd"),
178+
{ message: "File path must stay inside the current workspace folder." }
179+
);
180+
});
181+
182+
test("resolveWorkspaceRelativePath rejects absolute path outside workspace", () => {
183+
assert.throws(
184+
() => resolveWorkspaceRelativePath("/workspace/demo", "/tmp/evil.txt"),
185+
{ message: "File path must stay inside the current workspace folder." }
186+
);
187+
});
188+
189+
test("resolveWorkspaceRelativePath rejects workspace root itself", () => {
190+
assert.throws(
191+
() => resolveWorkspaceRelativePath("/workspace/demo", "/workspace/demo"),
192+
{ message: "File path must stay inside the current workspace folder." }
193+
);
194+
});
195+
196+
// --- #32: edge-case builder tests ---
197+
198+
test("buildSearchQuickAction with empty pattern produces valid args", () => {
199+
const action = buildSearchQuickAction("/workspace/demo", "");
200+
assert.deepEqual(action.args, ["search", "", "/workspace/demo"]);
201+
});
202+
203+
test("buildSearchQuickAction with regex special characters", () => {
204+
const action = buildSearchQuickAction("/workspace/demo", "foo.*bar\\(baz\\)");
205+
assert.deepEqual(action.args, ["search", "foo.*bar\\(baz\\)", "/workspace/demo"]);
206+
});
207+
208+
test("buildCreateQuickAction with spaces in path", () => {
209+
const action = buildCreateQuickAction("/workspace/my project/src/new file.ts");
210+
assert.equal(action.title, "Create new file.ts");
211+
assert.deepEqual(action.args, ["create", "/workspace/my project/src/new file.ts"]);
212+
});
213+
214+
test("buildDocGetQuickAction with deeply nested selector", () => {
215+
const action = buildDocGetQuickAction("/workspace/demo/config.yaml", "a.b.c.d.e");
216+
assert.equal(action.title, "Get a.b.c.d.e from config.yaml");
217+
assert.deepEqual(action.args, ["doc", "get", "/workspace/demo/config.yaml", "a.b.c.d.e"]);
218+
});
219+
220+
test("buildCreateQuickAction with unicode filename", () => {
221+
const action = buildCreateQuickAction("/workspace/demo/docs/日本語.md");
222+
assert.equal(action.title, "Create 日本語.md");
223+
assert.deepEqual(action.args, ["create", "/workspace/demo/docs/日本語.md"]);
224+
});
225+
226+
test("buildSearchQuickAction with unicode pattern", () => {
227+
const action = buildSearchQuickAction("/workspace/demo", "café");
228+
assert.deepEqual(action.args, ["search", "café", "/workspace/demo"]);
229+
});

0 commit comments

Comments
 (0)