@usezombie/zombiectl 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -0
- package/bin/zombiectl.js +11 -0
- package/bun.lock +29 -0
- package/package.json +28 -0
- package/scripts/run-tests.mjs +38 -0
- package/src/cli.js +275 -0
- package/src/commands/admin.js +39 -0
- package/src/commands/agent.js +98 -0
- package/src/commands/agent_harness.js +43 -0
- package/src/commands/agent_improvement_report.js +42 -0
- package/src/commands/agent_profile.js +39 -0
- package/src/commands/agent_proposals.js +158 -0
- package/src/commands/agent_scores.js +44 -0
- package/src/commands/core-ops.js +108 -0
- package/src/commands/core.js +537 -0
- package/src/commands/harness.js +35 -0
- package/src/commands/harness_activate.js +53 -0
- package/src/commands/harness_active.js +32 -0
- package/src/commands/harness_compile.js +40 -0
- package/src/commands/harness_source.js +72 -0
- package/src/commands/run_preview.js +212 -0
- package/src/commands/run_preview_walk.js +1 -0
- package/src/commands/runs.js +35 -0
- package/src/commands/spec_init.js +287 -0
- package/src/commands/workspace_billing.js +26 -0
- package/src/constants/error-codes.js +1 -0
- package/src/lib/agent-loop.js +106 -0
- package/src/lib/analytics.js +114 -0
- package/src/lib/api-paths.js +2 -0
- package/src/lib/browser.js +96 -0
- package/src/lib/http.js +149 -0
- package/src/lib/sse-parser.js +50 -0
- package/src/lib/state.js +67 -0
- package/src/lib/tool-executors.js +110 -0
- package/src/lib/walk-dir.js +41 -0
- package/src/program/args.js +95 -0
- package/src/program/auth-guard.js +12 -0
- package/src/program/auth-token.js +44 -0
- package/src/program/banner.js +46 -0
- package/src/program/command-registry.js +17 -0
- package/src/program/http-client.js +38 -0
- package/src/program/io.js +83 -0
- package/src/program/routes.js +20 -0
- package/src/program/suggest.js +76 -0
- package/src/program/validate.js +24 -0
- package/src/ui-progress.js +59 -0
- package/src/ui-theme.js +62 -0
- package/test/admin_config.unit.test.js +25 -0
- package/test/agent-loop.unit.test.js +497 -0
- package/test/agent_harness.unit.test.js +52 -0
- package/test/agent_improvement_report.unit.test.js +74 -0
- package/test/agent_profile.unit.test.js +156 -0
- package/test/agent_proposals.unit.test.js +167 -0
- package/test/agent_scores.unit.test.js +220 -0
- package/test/analytics.unit.test.js +41 -0
- package/test/args.unit.test.js +69 -0
- package/test/auth-guard.test.js +33 -0
- package/test/auth-token.unit.test.js +112 -0
- package/test/banner.unit.test.js +442 -0
- package/test/browser.unit.test.js +16 -0
- package/test/cli-analytics.unit.test.js +296 -0
- package/test/did-you-mean.integration.test.js +76 -0
- package/test/doctor-json.test.js +81 -0
- package/test/error-codes.unit.test.js +7 -0
- package/test/harness-command.unit.test.js +180 -0
- package/test/harness-compile.test.js +81 -0
- package/test/harness-lifecycle.integration.test.js +339 -0
- package/test/harness-source-put.test.js +72 -0
- package/test/harness_activate.unit.test.js +48 -0
- package/test/harness_active.unit.test.js +53 -0
- package/test/harness_compile.unit.test.js +54 -0
- package/test/harness_source.unit.test.js +59 -0
- package/test/help.test.js +276 -0
- package/test/helpers-fs.js +32 -0
- package/test/helpers.js +31 -0
- package/test/io.unit.test.js +57 -0
- package/test/login.unit.test.js +115 -0
- package/test/logout.unit.test.js +65 -0
- package/test/parse.test.js +16 -0
- package/test/run-preview.edge.test.js +422 -0
- package/test/run-preview.integration.test.js +135 -0
- package/test/run-preview.security.test.js +246 -0
- package/test/run-preview.unit.test.js +131 -0
- package/test/run.unit.test.js +149 -0
- package/test/runs-cancel.unit.test.js +288 -0
- package/test/runs-list.unit.test.js +105 -0
- package/test/skill-secret.unit.test.js +94 -0
- package/test/spec-init.edge.test.js +232 -0
- package/test/spec-init.integration.test.js +128 -0
- package/test/spec-init.security.test.js +285 -0
- package/test/spec-init.unit.test.js +160 -0
- package/test/specs-sync.unit.test.js +164 -0
- package/test/sse-parser.unit.test.js +54 -0
- package/test/state.unit.test.js +34 -0
- package/test/streamfetch.unit.test.js +211 -0
- package/test/suggest.test.js +75 -0
- package/test/tool-executors.unit.test.js +165 -0
- package/test/validate.test.js +81 -0
- package/test/workspace-add.test.js +106 -0
- package/test/workspace.unit.test.js +230 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run-preview happy path, edge cases, error paths, output fidelity — T1, T2, T3, T4
|
|
3
|
+
*/
|
|
4
|
+
import { describe, test, expect } from "bun:test";
|
|
5
|
+
import {
|
|
6
|
+
extractSpecRefs,
|
|
7
|
+
matchRefsToFiles,
|
|
8
|
+
printPreview,
|
|
9
|
+
confIndicator,
|
|
10
|
+
sanitizeDisplay,
|
|
11
|
+
runPreview,
|
|
12
|
+
} from "../src/commands/run_preview.js";
|
|
13
|
+
import { Writable } from "node:stream";
|
|
14
|
+
import { writeFileSync, chmodSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { makeNoop, makeBufferStream, ui } from "./helpers.js";
|
|
17
|
+
import { makeTmp, cleanup, writeLine } from "./helpers-fs.js";
|
|
18
|
+
|
|
19
|
+
// ── T1 Happy Path ─────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
describe("T1 printPreview happy path", () => {
|
|
22
|
+
test("prints heading and file count", () => {
|
|
23
|
+
const buf = makeBufferStream();
|
|
24
|
+
printPreview(buf.stream, [
|
|
25
|
+
{ file: "src/main.go", confidence: "high" },
|
|
26
|
+
{ file: "src/util.go", confidence: "medium" },
|
|
27
|
+
], { writeLine, ui });
|
|
28
|
+
expect(buf.read()).toContain("Predicted file impact");
|
|
29
|
+
expect(buf.read()).toContain("2 file(s)");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("prints all confidence levels", () => {
|
|
33
|
+
const buf = makeBufferStream();
|
|
34
|
+
printPreview(buf.stream, [
|
|
35
|
+
{ file: "a.go", confidence: "high" },
|
|
36
|
+
{ file: "b.go", confidence: "medium" },
|
|
37
|
+
{ file: "c.go", confidence: "low" },
|
|
38
|
+
], { writeLine, ui });
|
|
39
|
+
const out = buf.read();
|
|
40
|
+
expect(out).toContain("a.go");
|
|
41
|
+
expect(out).toContain("b.go");
|
|
42
|
+
expect(out).toContain("c.go");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("empty matches shows info message, not heading", () => {
|
|
46
|
+
const buf = makeBufferStream();
|
|
47
|
+
printPreview(buf.stream, [], { writeLine, ui });
|
|
48
|
+
expect(buf.read()).toContain("no file references detected");
|
|
49
|
+
expect(buf.read()).not.toContain("Predicted file impact");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ── T1 Happy Path (extended) ──────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
describe("T1 printPreview happy path (extended)", () => {
|
|
56
|
+
test("printPreview preserves match order passed to it (sorted input stays sorted)", () => {
|
|
57
|
+
const buf = makeBufferStream();
|
|
58
|
+
// Pass already-sorted data (a before z) — printPreview must preserve order
|
|
59
|
+
printPreview(buf.stream, [
|
|
60
|
+
{ file: "src/a.go", confidence: "high" },
|
|
61
|
+
{ file: "src/z.go", confidence: "high" },
|
|
62
|
+
], { writeLine, ui });
|
|
63
|
+
const out = buf.read();
|
|
64
|
+
expect(out.indexOf("src/a.go")).toBeLessThan(out.indexOf("src/z.go"));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("all-low matches renders with low indicator for each", () => {
|
|
68
|
+
const buf = makeBufferStream();
|
|
69
|
+
const matches = [
|
|
70
|
+
{ file: "src/x.go", confidence: "low" },
|
|
71
|
+
{ file: "src/y.go", confidence: "low" },
|
|
72
|
+
];
|
|
73
|
+
printPreview(buf.stream, matches, { writeLine, ui });
|
|
74
|
+
const out = buf.read();
|
|
75
|
+
expect(out).toContain("src/x.go");
|
|
76
|
+
expect(out).toContain("src/y.go");
|
|
77
|
+
expect(out).toContain("2 file(s)");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("20-file list renders all filenames without truncation", () => {
|
|
81
|
+
const buf = makeBufferStream();
|
|
82
|
+
const matches = Array.from({ length: 20 }, (_, i) => ({ file: `src/file${i}.go`, confidence: "low" }));
|
|
83
|
+
printPreview(buf.stream, matches, { writeLine, ui });
|
|
84
|
+
const out = buf.read();
|
|
85
|
+
for (let i = 0; i < 20; i++) expect(out).toContain(`file${i}.go`);
|
|
86
|
+
expect(out).toContain("20 file(s)");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("file path with spaces renders without crash", () => {
|
|
90
|
+
const buf = makeBufferStream();
|
|
91
|
+
printPreview(buf.stream, [{ file: "src/my file.go", confidence: "medium" }], { writeLine, ui });
|
|
92
|
+
expect(buf.read()).toContain("my file.go");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ── T2 Edge Cases — extractSpecRefs ──────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
describe("T2 extractSpecRefs edge cases", () => {
|
|
99
|
+
test("empty string returns empty array", () => {
|
|
100
|
+
expect(extractSpecRefs("")).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("whitespace-only returns empty array", () => {
|
|
104
|
+
expect(extractSpecRefs(" \n\t\n ")).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("only headings returns empty array", () => {
|
|
108
|
+
expect(extractSpecRefs("# Title\n## Section\n### Sub\n")).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("CRLF line endings handled", () => {
|
|
112
|
+
const refs = extractSpecRefs("Edit `src/foo.go`\r\nand `src/bar.go`\r\n");
|
|
113
|
+
expect(refs).toContain("src/foo.go");
|
|
114
|
+
expect(refs).toContain("src/bar.go");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("repeated reference is deduplicated to one entry", () => {
|
|
118
|
+
const md = Array.from({ length: 1000 }, () => "`src/core.go`").join(" ");
|
|
119
|
+
expect(extractSpecRefs(md).filter((r) => r === "src/core.go").length).toBe(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("single-quoted paths extracted", () => {
|
|
123
|
+
const refs = extractSpecRefs("Edit 'lib/utils.js' here.");
|
|
124
|
+
expect(refs.some((r) => r.includes("utils.js"))).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("double-quoted paths extracted", () => {
|
|
128
|
+
const refs = extractSpecRefs(`Fix "src/api/handler.go" now.`);
|
|
129
|
+
expect(refs.some((r) => r.includes("handler.go"))).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("binary content (null bytes) does not crash", () => {
|
|
133
|
+
expect(() => extractSpecRefs("src/foo.go\x00\x01binary\x00")).not.toThrow();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("ReDoS guard: 10,000-char line completes under 500ms", () => {
|
|
137
|
+
const start = performance.now();
|
|
138
|
+
extractSpecRefs("a".repeat(10000));
|
|
139
|
+
expect(performance.now() - start).toBeLessThan(500);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("1MB spec file processed under 3 seconds", () => {
|
|
143
|
+
const block = "Edit `src/file.go` and `lib/util.ts`.\n".repeat(20000);
|
|
144
|
+
const start = performance.now();
|
|
145
|
+
const refs = extractSpecRefs(block);
|
|
146
|
+
expect(performance.now() - start).toBeLessThan(3000);
|
|
147
|
+
expect(refs.length).toBeGreaterThan(0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("workers/ and scripts/ prefixes extracted", () => {
|
|
151
|
+
const refs = extractSpecRefs("Update `workers/processor.go` and `scripts/deploy.sh`.");
|
|
152
|
+
expect(refs.some((r) => r.includes("workers/"))).toBe(true);
|
|
153
|
+
expect(refs.some((r) => r.includes("scripts/"))).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("yaml and toml config file refs extracted", () => {
|
|
157
|
+
const refs = extractSpecRefs("Edit `config/app.yaml` and `Cargo.toml`.");
|
|
158
|
+
expect(refs.some((r) => r.includes(".yaml") || r.includes(".toml"))).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("markdown link format [text](path.go) extracts path", () => {
|
|
162
|
+
const refs = extractSpecRefs("See [the handler](src/api/handler.go) for details.");
|
|
163
|
+
expect(refs.some((r) => r.includes("handler.go"))).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("fenced code block containing file path is extracted", () => {
|
|
167
|
+
const md = "Edit this file:\n```\nsrc/commands/core.js\n```\n";
|
|
168
|
+
const refs = extractSpecRefs(md);
|
|
169
|
+
expect(refs.some((r) => r.includes("core.js"))).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("shell script extension .sh extracted", () => {
|
|
173
|
+
const refs = extractSpecRefs("Run `scripts/deploy.sh` to release.");
|
|
174
|
+
expect(refs.some((r) => r.includes(".sh"))).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("common English words without path separators or extensions not extracted", () => {
|
|
178
|
+
const refs = extractSpecRefs("Update the configuration to use the new service.");
|
|
179
|
+
expect(refs.every((r) => !r.includes("configuration"))).toBe(true);
|
|
180
|
+
expect(refs.every((r) => !r.includes("service"))).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("paths starting with ./ not extracted as refs", () => {
|
|
184
|
+
const refs = extractSpecRefs("Edit `./src/core.go`.");
|
|
185
|
+
// The quoted path with "./" prefix may or may not be captured; key: it must not crash
|
|
186
|
+
expect(() => extractSpecRefs("Edit `./src/core.go`.")).not.toThrow();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ── T2 Edge Cases — matchRefsToFiles ─────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
describe("T2 matchRefsToFiles edge cases", () => {
|
|
193
|
+
test("empty refs + empty files = empty", () => {
|
|
194
|
+
expect(matchRefsToFiles([], [])).toEqual([]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("empty refs with files = empty", () => {
|
|
198
|
+
expect(matchRefsToFiles([], ["src/foo.go"])).toEqual([]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("refs with no matching files = empty", () => {
|
|
202
|
+
expect(matchRefsToFiles(["zzz_nonexistent.go"], ["src/foo.go"])).toEqual([]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("backslash paths normalised before matching", () => {
|
|
206
|
+
const matches = matchRefsToFiles(["src/foo.go"], ["src\\foo.go"]);
|
|
207
|
+
expect(matches.length).toBe(1);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("each file appears at most once", () => {
|
|
211
|
+
const matches = matchRefsToFiles(["src/foo.go", "foo.go", "foo"], ["src/foo.go"]);
|
|
212
|
+
const paths = matches.map((m) => m.file);
|
|
213
|
+
expect(paths.length).toBe(new Set(paths).size);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("results sorted high → medium → low", () => {
|
|
217
|
+
const matches = matchRefsToFiles(
|
|
218
|
+
["src/commands/exact.js", "commands", "cmd"],
|
|
219
|
+
["src/commands/exact.js", "src/commands/other.js", "cmd/main.go"],
|
|
220
|
+
);
|
|
221
|
+
const order = { high: 0, medium: 1, low: 2 };
|
|
222
|
+
for (let i = 1; i < matches.length; i++) {
|
|
223
|
+
expect(order[matches[i].confidence]).toBeGreaterThanOrEqual(order[matches[i - 1].confidence]);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("300 refs × 500 files completes under 3s", () => {
|
|
228
|
+
const refs = Array.from({ length: 300 }, (_, i) => `nomatch${i}.xyz`);
|
|
229
|
+
const files = Array.from({ length: 500 }, (_, i) => `src/file${i}.go`);
|
|
230
|
+
const start = performance.now();
|
|
231
|
+
matchRefsToFiles(refs, files);
|
|
232
|
+
expect(performance.now() - start).toBeLessThan(3000);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("deeply nested path ref does not cause stack overflow", () => {
|
|
236
|
+
const deep = "src/" + "level/".repeat(100) + "file.go";
|
|
237
|
+
expect(() => matchRefsToFiles([deep], [deep])).not.toThrow();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("multiple refs that match the same file each yield exactly one match entry", () => {
|
|
241
|
+
const matches = matchRefsToFiles(
|
|
242
|
+
["src/core.go", "core.go", "core"],
|
|
243
|
+
["src/core.go"],
|
|
244
|
+
);
|
|
245
|
+
expect(matches.filter((m) => m.file === "src/core.go").length).toBe(1);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("ref matching multiple files returns all of them", () => {
|
|
249
|
+
const matches = matchRefsToFiles(
|
|
250
|
+
["handler"],
|
|
251
|
+
["src/api/handler.go", "src/ws/handler.go", "lib/handler.ts"],
|
|
252
|
+
);
|
|
253
|
+
expect(matches.length).toBeGreaterThanOrEqual(1);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("suffix match (file ends with ref) scores as high confidence", () => {
|
|
257
|
+
// "src/core.go" endsWith "core.go" → high
|
|
258
|
+
const matches = matchRefsToFiles(["core.go"], ["src/core.go"]);
|
|
259
|
+
const coreGo = matches.find((m) => m.file === "src/core.go");
|
|
260
|
+
expect(coreGo).toBeDefined();
|
|
261
|
+
expect(coreGo.confidence).toBe("high");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("ref with no extension still matches filename prefix", () => {
|
|
265
|
+
const matches = matchRefsToFiles(["main"], ["src/main.go", "src/main.rs"]);
|
|
266
|
+
expect(matches.length).toBeGreaterThan(0);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("very long ref string completes without error", () => {
|
|
270
|
+
const longRef = "src/" + "sub/".repeat(50) + "file.go";
|
|
271
|
+
expect(() => matchRefsToFiles([longRef], ["src/file.go"])).not.toThrow();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// ── T3 Error Paths — runPreview ───────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
describe("T3 runPreview error paths", () => {
|
|
278
|
+
test("nonexistent spec file returns null and error message", async () => {
|
|
279
|
+
const err = makeBufferStream();
|
|
280
|
+
const result = await runPreview("/no/such/file.md", ".", { stdout: makeNoop(), stderr: err.stream }, { writeLine, ui });
|
|
281
|
+
expect(result).toBeNull();
|
|
282
|
+
expect(err.read()).toContain("spec file not found");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("unreadable spec file returns null (non-root)", async () => {
|
|
286
|
+
if (process.getuid?.() === 0) return;
|
|
287
|
+
const tmp = makeTmp();
|
|
288
|
+
const f = join(tmp, "unreadable.md");
|
|
289
|
+
writeFileSync(f, "# spec");
|
|
290
|
+
chmodSync(f, 0o000);
|
|
291
|
+
try {
|
|
292
|
+
const err = makeBufferStream();
|
|
293
|
+
const result = await runPreview(f, tmp, { stdout: makeNoop(), stderr: err.stream }, { writeLine, ui });
|
|
294
|
+
expect(result).toBeNull();
|
|
295
|
+
expect(err.read()).toContain("failed to read");
|
|
296
|
+
} finally { chmodSync(f, 0o644); cleanup(tmp); }
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("empty spec file returns { matches: [] }, not null", async () => {
|
|
300
|
+
const tmp = makeTmp();
|
|
301
|
+
const f = join(tmp, "empty.md");
|
|
302
|
+
writeFileSync(f, "");
|
|
303
|
+
try {
|
|
304
|
+
const result = await runPreview(f, tmp, { stdout: makeNoop(), stderr: makeNoop() }, { writeLine, ui });
|
|
305
|
+
expect(result).not.toBeNull();
|
|
306
|
+
expect(result.matches).toEqual([]);
|
|
307
|
+
} finally { cleanup(tmp); }
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("spec file with only whitespace returns empty matches", async () => {
|
|
311
|
+
const tmp = makeTmp();
|
|
312
|
+
const f = join(tmp, "blank.md");
|
|
313
|
+
writeFileSync(f, " \n\t\n ");
|
|
314
|
+
try {
|
|
315
|
+
const result = await runPreview(f, tmp, { stdout: makeNoop(), stderr: makeNoop() }, { writeLine, ui });
|
|
316
|
+
expect(result.matches).toEqual([]);
|
|
317
|
+
} finally { cleanup(tmp); }
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("nonexistent repoPath does not crash — returns empty matches", async () => {
|
|
321
|
+
const tmp = makeTmp();
|
|
322
|
+
const f = join(tmp, "spec.md");
|
|
323
|
+
writeFileSync(f, "Edit `src/foo.go`.");
|
|
324
|
+
try {
|
|
325
|
+
const result = await runPreview(f, "/no/such/repo", { stdout: makeNoop(), stderr: makeNoop() }, { writeLine, ui });
|
|
326
|
+
expect(result).not.toBeNull();
|
|
327
|
+
expect(Array.isArray(result.matches)).toBe(true);
|
|
328
|
+
} finally { cleanup(tmp); }
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("spec with only path-traversal refs returns empty matches (no real files)", async () => {
|
|
332
|
+
const tmp = makeTmp();
|
|
333
|
+
const f = join(tmp, "spec.md");
|
|
334
|
+
writeFileSync(f, "Edit `../../etc/passwd` and `../secrets.env`.");
|
|
335
|
+
try {
|
|
336
|
+
const result = await runPreview(f, tmp, { stdout: makeNoop(), stderr: makeNoop() }, { writeLine, ui });
|
|
337
|
+
expect(result.matches.length).toBe(0);
|
|
338
|
+
} finally { cleanup(tmp); }
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("stderr is written to ctx.stderr, not stdout", async () => {
|
|
342
|
+
const out = makeBufferStream();
|
|
343
|
+
const err = makeBufferStream();
|
|
344
|
+
await runPreview("/no/file.md", ".", { stdout: out.stream, stderr: err.stream }, { writeLine, ui });
|
|
345
|
+
expect(err.read()).toContain("spec file not found");
|
|
346
|
+
expect(out.read()).toBe("");
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// ── T4 Output Fidelity ────────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
describe("T4 confIndicator fidelity", () => {
|
|
353
|
+
test("non-TTY returns bracket labels for all levels", () => {
|
|
354
|
+
const noTTY = { isTTY: false };
|
|
355
|
+
expect(confIndicator("high", noTTY)).toBe("[HIGH]");
|
|
356
|
+
expect(confIndicator("medium", noTTY)).toBe("[MED] ");
|
|
357
|
+
expect(confIndicator("low", noTTY)).toBe("[LOW] ");
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("TTY returns ANSI escape sequence", () => {
|
|
361
|
+
const saved = process.env.NO_COLOR;
|
|
362
|
+
delete process.env.NO_COLOR;
|
|
363
|
+
try {
|
|
364
|
+
expect(confIndicator("high", { isTTY: true })).toContain("\u001b[");
|
|
365
|
+
} finally {
|
|
366
|
+
if (saved !== undefined) process.env.NO_COLOR = saved;
|
|
367
|
+
else delete process.env.NO_COLOR;
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("NO_COLOR=1 suppresses ANSI even on TTY", () => {
|
|
372
|
+
const saved = process.env.NO_COLOR;
|
|
373
|
+
process.env.NO_COLOR = "1";
|
|
374
|
+
try {
|
|
375
|
+
expect(confIndicator("high", { isTTY: true })).not.toContain("\u001b[");
|
|
376
|
+
} finally {
|
|
377
|
+
if (saved !== undefined) process.env.NO_COLOR = saved;
|
|
378
|
+
else delete process.env.NO_COLOR;
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("unknown confidence value does not throw", () => {
|
|
383
|
+
expect(() => confIndicator("unknown", { isTTY: false })).not.toThrow();
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
describe("T4 sanitizeDisplay fidelity", () => {
|
|
388
|
+
test("strips ANSI codes from filenames", () => {
|
|
389
|
+
expect(sanitizeDisplay("\u001b[31mRED\u001b[0m")).toBe("RED");
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("strips null bytes", () => {
|
|
393
|
+
expect(sanitizeDisplay("foo\x00bar")).toBe("foobar");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("passes clean path unchanged", () => {
|
|
397
|
+
const p = "src/commands/core.js";
|
|
398
|
+
expect(sanitizeDisplay(p)).toBe(p);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("empty string returns empty string", () => {
|
|
402
|
+
expect(sanitizeDisplay("")).toBe("");
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("only escape codes returns empty string", () => {
|
|
406
|
+
expect(sanitizeDisplay("\u001b[1m\u001b[31m\u001b[0m")).toBe("");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("mixed escape + text strips only the escape portions", () => {
|
|
410
|
+
expect(sanitizeDisplay("a\u001b[32mb\u001b[0mc")).toBe("abc");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("tabs and spaces are preserved", () => {
|
|
414
|
+
const p = "src/cmd\t name.go";
|
|
415
|
+
expect(sanitizeDisplay(p)).toBe(p);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("very long filename (500 chars) does not crash", () => {
|
|
419
|
+
const p = "src/" + "a".repeat(495) + ".go";
|
|
420
|
+
expect(() => sanitizeDisplay(p)).not.toThrow();
|
|
421
|
+
});
|
|
422
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run-preview integration tests — T6
|
|
3
|
+
* End-to-end through the real filesystem stack: real spec file, real repo tree,
|
|
4
|
+
* real runPreview call. Asserts full output contract without mocking internals.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, test, expect } from "bun:test";
|
|
7
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { runPreview, printPreview, extractSpecRefs, matchRefsToFiles } from "../src/commands/run_preview.js";
|
|
10
|
+
import { makeNoop, makeBufferStream, ui } from "./helpers.js";
|
|
11
|
+
import { makeTmp, cleanup, writeLine } from "./helpers-fs.js";
|
|
12
|
+
|
|
13
|
+
// ── T6 Integration Verification ────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
describe("T6 integration — runPreview end-to-end", () => {
|
|
16
|
+
test("full pipeline: spec → refs → file walk → matches → printed output", async () => {
|
|
17
|
+
const tmp = makeTmp();
|
|
18
|
+
const specFile = join(tmp, "spec.md");
|
|
19
|
+
writeFileSync(specFile, "Edit `src/api/handler.go` and `lib/util.ts`.");
|
|
20
|
+
mkdirSync(join(tmp, "src", "api"), { recursive: true });
|
|
21
|
+
mkdirSync(join(tmp, "lib"), { recursive: true });
|
|
22
|
+
writeFileSync(join(tmp, "src", "api", "handler.go"), "package api");
|
|
23
|
+
writeFileSync(join(tmp, "lib", "util.ts"), "export {};");
|
|
24
|
+
const out = makeBufferStream();
|
|
25
|
+
try {
|
|
26
|
+
const result = await runPreview(specFile, tmp, { stdout: out.stream, stderr: makeNoop() }, { writeLine, ui });
|
|
27
|
+
expect(result).not.toBeNull();
|
|
28
|
+
expect(result.matches.length).toBeGreaterThanOrEqual(1);
|
|
29
|
+
const output = out.read();
|
|
30
|
+
expect(output).toContain("Predicted file impact");
|
|
31
|
+
expect(output).toContain("file(s)");
|
|
32
|
+
} finally { cleanup(tmp); }
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("spec referencing only one existing file returns exactly one high-confidence match", async () => {
|
|
36
|
+
const tmp = makeTmp();
|
|
37
|
+
const specFile = join(tmp, "spec.md");
|
|
38
|
+
mkdirSync(join(tmp, "src"), { recursive: true });
|
|
39
|
+
writeFileSync(join(tmp, "src", "exact.go"), "package main");
|
|
40
|
+
writeFileSync(specFile, "Update `src/exact.go` with the new logic.");
|
|
41
|
+
try {
|
|
42
|
+
const result = await runPreview(specFile, tmp, { stdout: makeNoop(), stderr: makeNoop() }, { writeLine, ui });
|
|
43
|
+
const highMatches = result.matches.filter((m) => m.confidence === "high");
|
|
44
|
+
expect(highMatches.some((m) => m.file.includes("exact.go"))).toBe(true);
|
|
45
|
+
} finally { cleanup(tmp); }
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("spec with no file refs prints info message, result has empty matches", async () => {
|
|
49
|
+
const tmp = makeTmp();
|
|
50
|
+
const specFile = join(tmp, "spec.md");
|
|
51
|
+
writeFileSync(specFile, "# Overview\nThis feature improves performance.");
|
|
52
|
+
const out = makeBufferStream();
|
|
53
|
+
try {
|
|
54
|
+
const result = await runPreview(specFile, tmp, { stdout: out.stream, stderr: makeNoop() }, { writeLine, ui });
|
|
55
|
+
expect(result.matches).toEqual([]);
|
|
56
|
+
expect(out.read()).toContain("no file references detected");
|
|
57
|
+
} finally { cleanup(tmp); }
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("spec referencing multiple confidence levels renders sorted output", async () => {
|
|
61
|
+
const tmp = makeTmp();
|
|
62
|
+
const specFile = join(tmp, "spec.md");
|
|
63
|
+
mkdirSync(join(tmp, "src", "commands"), { recursive: true });
|
|
64
|
+
mkdirSync(join(tmp, "tests"), { recursive: true });
|
|
65
|
+
writeFileSync(join(tmp, "src", "commands", "core.js"), "");
|
|
66
|
+
writeFileSync(join(tmp, "tests", "core.test.js"), "");
|
|
67
|
+
writeFileSync(specFile, "Edit `src/commands/core.js`. Also update tests.");
|
|
68
|
+
const out = makeBufferStream();
|
|
69
|
+
try {
|
|
70
|
+
const result = await runPreview(specFile, tmp, { stdout: out.stream, stderr: makeNoop() }, { writeLine, ui });
|
|
71
|
+
const confidences = result.matches.map((m) => m.confidence);
|
|
72
|
+
const order = { high: 0, medium: 1, low: 2 };
|
|
73
|
+
for (let i = 1; i < confidences.length; i++) {
|
|
74
|
+
expect(order[confidences[i]]).toBeGreaterThanOrEqual(order[confidences[i - 1]]);
|
|
75
|
+
}
|
|
76
|
+
} finally { cleanup(tmp); }
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("large monorepo spec (50 file refs, 200 real files) resolves without crash", async () => {
|
|
80
|
+
const tmp = makeTmp();
|
|
81
|
+
mkdirSync(join(tmp, "src"), { recursive: true });
|
|
82
|
+
for (let i = 0; i < 200; i++) writeFileSync(join(tmp, "src", `f${i}.go`), "");
|
|
83
|
+
const refs = Array.from({ length: 50 }, (_, i) => `\`src/f${i}.go\``).join(", ");
|
|
84
|
+
const specFile = join(tmp, "spec.md");
|
|
85
|
+
writeFileSync(specFile, `Edit ${refs}.`);
|
|
86
|
+
try {
|
|
87
|
+
const result = await runPreview(specFile, tmp, { stdout: makeNoop(), stderr: makeNoop() }, { writeLine, ui });
|
|
88
|
+
expect(result).not.toBeNull();
|
|
89
|
+
expect(result.matches.length).toBeGreaterThan(0);
|
|
90
|
+
} finally { cleanup(tmp); }
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("stdout and stderr are cleanly separated — success writes nothing to stderr", async () => {
|
|
94
|
+
const tmp = makeTmp();
|
|
95
|
+
mkdirSync(join(tmp, "src"), { recursive: true });
|
|
96
|
+
writeFileSync(join(tmp, "src", "ok.go"), "");
|
|
97
|
+
const specFile = join(tmp, "spec.md");
|
|
98
|
+
writeFileSync(specFile, "Edit `src/ok.go`.");
|
|
99
|
+
const errBuf = makeBufferStream();
|
|
100
|
+
try {
|
|
101
|
+
await runPreview(specFile, tmp, { stdout: makeNoop(), stderr: errBuf.stream }, { writeLine, ui });
|
|
102
|
+
expect(errBuf.read()).toBe("");
|
|
103
|
+
} finally { cleanup(tmp); }
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("output contains both indicator and filename on same line", async () => {
|
|
107
|
+
const tmp = makeTmp();
|
|
108
|
+
mkdirSync(join(tmp, "src"), { recursive: true });
|
|
109
|
+
writeFileSync(join(tmp, "src", "targeted.go"), "");
|
|
110
|
+
const specFile = join(tmp, "spec.md");
|
|
111
|
+
writeFileSync(specFile, "Edit `src/targeted.go` with new logic.");
|
|
112
|
+
const out = makeBufferStream();
|
|
113
|
+
try {
|
|
114
|
+
await runPreview(specFile, tmp, { stdout: out.stream, stderr: makeNoop() }, { writeLine, ui });
|
|
115
|
+
const lines = out.read().split("\n");
|
|
116
|
+
const fileLine = lines.find((l) => l.includes("targeted.go"));
|
|
117
|
+
expect(fileLine).toBeDefined();
|
|
118
|
+
// Line must also have an indicator — either bracket label or icon character
|
|
119
|
+
expect(fileLine).toMatch(/\[HIGH\]|\[MED\s*\]|\[LOW\s*\]|●|◆|○/);
|
|
120
|
+
} finally { cleanup(tmp); }
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("printPreview + extractSpecRefs + matchRefsToFiles compose correctly (no runPreview wrapper)", () => {
|
|
124
|
+
const markdown = "Fix `src/auth/login.go` and update `tests/auth_test.go`.";
|
|
125
|
+
const files = ["src/auth/login.go", "tests/auth_test.go", "src/util/helper.go"];
|
|
126
|
+
const refs = extractSpecRefs(markdown);
|
|
127
|
+
const matches = matchRefsToFiles(refs, files);
|
|
128
|
+
const buf = makeBufferStream();
|
|
129
|
+
printPreview(buf.stream, matches, { writeLine, ui });
|
|
130
|
+
const out = buf.read();
|
|
131
|
+
expect(out).toContain("login.go");
|
|
132
|
+
expect(out).toContain("auth_test.go");
|
|
133
|
+
expect(out).toContain("file(s)");
|
|
134
|
+
});
|
|
135
|
+
});
|