@specific.dev/spectest 0.82.0 → 0.85.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/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/postgres-tls.d.ts +10 -0
- package/dist/components/postgres-tls.js +9 -0
- package/dist/components/postgres.d.ts +59 -1
- package/dist/components/postgres.js +5 -2
- package/dist/components/supabase-config.d.ts +5 -0
- package/dist/components/supabase-config.js +12 -4
- package/dist/components/supabase-webhooks.d.ts +1 -0
- package/dist/components/supabase-webhooks.js +262 -0
- package/dist/components/supabase.d.ts +36 -11
- package/dist/components/supabase.js +75 -18
- package/dist/components/temporal.d.ts +89 -0
- package/dist/components/temporal.js +185 -0
- package/dist/coverage.d.ts +78 -3
- package/dist/coverage.js +251 -11
- package/dist/daemon.js +71 -5
- package/dist/environment-ca.d.ts +8 -0
- package/dist/environment-ca.js +16 -0
- package/dist/harness/container-run.js +3 -0
- package/dist/harness/coverage.d.ts +19 -7
- package/dist/harness/coverage.js +52 -9
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
- package/src/components/index.ts +3 -0
- package/src/components/postgres-tls.test.ts +23 -0
- package/src/components/postgres-tls.ts +11 -0
- package/src/components/postgres.ts +8 -2
- package/src/components/supabase-config.ts +11 -2
- package/src/components/supabase-db.test.ts +64 -0
- package/src/components/supabase-dir.test.ts +71 -0
- package/src/components/supabase-webhooks.ts +263 -0
- package/src/components/supabase.ts +108 -28
- package/src/components/temporal.test.ts +86 -0
- package/src/components/temporal.ts +188 -0
- package/src/coverage.test.ts +93 -13
- package/src/coverage.ts +295 -11
- package/src/daemon.ts +68 -3
- package/src/environment-ca.test.ts +44 -0
- package/src/environment-ca.ts +20 -0
- package/src/harness/container-run.test.ts +14 -2
- package/src/harness/container-run.ts +3 -0
- package/src/harness/coverage.test.ts +32 -1
- package/src/harness/coverage.ts +47 -9
- package/src/index.ts +1 -0
package/src/coverage.test.ts
CHANGED
|
@@ -11,6 +11,9 @@ import {
|
|
|
11
11
|
NODE_COVERAGE_SCRIPTS_SUBDIR,
|
|
12
12
|
NODE_COVERAGE_MARKERS_SUBDIR,
|
|
13
13
|
NODE_COVERAGE_SOCKET_PREFIX,
|
|
14
|
+
NODE_EXPLAINS,
|
|
15
|
+
NODE_INVENTORY_SCRIPT,
|
|
16
|
+
npmManifestUnit,
|
|
14
17
|
applyCoverageAdapters,
|
|
15
18
|
appendEnvFlag,
|
|
16
19
|
browser,
|
|
@@ -67,6 +70,7 @@ describe("applyCoverageAdapters", () => {
|
|
|
67
70
|
expect(out.env?.NODE_OPTIONS).toBe(`--max-old-space-size=512 --require ${NODE_COVERAGE_HOOK_PATH}`);
|
|
68
71
|
expect(out.env?.[NODE_COVERAGE_DIR_ENV]).toBe("/spectest/coverage");
|
|
69
72
|
expect(out.env?.NODE_V8_COVERAGE).toBeUndefined();
|
|
73
|
+
expect((out.coverage as { explains?: string[] }).explains).toEqual([...NODE_EXPLAINS]);
|
|
70
74
|
expect(out.files?.map((f) => f.path)).toEqual([NODE_COVERAGE_HOOK_PATH]);
|
|
71
75
|
expect(out.files?.[0]?.content).toBe(NODE_COVERAGE_HOOK);
|
|
72
76
|
});
|
|
@@ -78,6 +82,7 @@ describe("applyCoverageAdapters", () => {
|
|
|
78
82
|
expect(typeof list[1]?.load).toBe("function");
|
|
79
83
|
expect(JSON.parse(JSON.stringify(out)).coverage).toEqual({
|
|
80
84
|
adapters: [{ adapter: "node" }, { adapter: "browser" }],
|
|
85
|
+
explains: [...NODE_EXPLAINS],
|
|
81
86
|
});
|
|
82
87
|
});
|
|
83
88
|
test("true and undefined pass through untouched", () => {
|
|
@@ -109,6 +114,20 @@ function fakeCtx(dir: string, signal = new AbortController().signal): CoverageCa
|
|
|
109
114
|
};
|
|
110
115
|
}
|
|
111
116
|
|
|
117
|
+
describe("units", () => {
|
|
118
|
+
test("npmManifestUnit ignores metadata keys and formatting, moves on scripts and dependencies", () => {
|
|
119
|
+
const a = npmManifestUnit("packages/cli/package.json", JSON.stringify({ name: "cli", version: "0.1.219", scripts: { build: "x" }, dependencies: { a: "1" } }));
|
|
120
|
+
const b = npmManifestUnit("packages/cli/package.json", JSON.stringify({ version: "0.1.220", name: "cli", dependencies: { a: "1" }, scripts: { build: "x" } }, null, 2));
|
|
121
|
+
expect(a).toBe(b);
|
|
122
|
+
expect(a).toMatch(/^manifest:packages\/cli\/package\.json#[0-9a-f]{16}$/);
|
|
123
|
+
const c = npmManifestUnit("packages/cli/package.json", JSON.stringify({ name: "cli", version: "0.1.220", scripts: { build: "y" }, dependencies: { a: "1" } }));
|
|
124
|
+
const d = npmManifestUnit("packages/cli/package.json", JSON.stringify({ name: "cli", version: "0.1.220", scripts: { build: "x" }, dependencies: { a: "2" } }));
|
|
125
|
+
expect(c).not.toBe(a);
|
|
126
|
+
expect(d).not.toBe(a);
|
|
127
|
+
expect(npmManifestUnit("x/package.json", "not json")).toMatch(/^manifest:x\/package\.json#/);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
112
131
|
describe("browserCoverage", () => {
|
|
113
132
|
test("writes an empty lcov when nothing ran for the service", async () => {
|
|
114
133
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-cov-"));
|
|
@@ -159,14 +178,14 @@ const WRAPPER_JS =
|
|
|
159
178
|
|
|
160
179
|
interface Dump {
|
|
161
180
|
spectestVersion: number;
|
|
162
|
-
result: { url: string; spectestScript: string; functions:
|
|
181
|
+
result: { url: string; spectestScript: string; functions: number[][] }[];
|
|
163
182
|
}
|
|
164
183
|
interface ScriptRecord {
|
|
165
184
|
spectestScript: {
|
|
166
185
|
url: string;
|
|
167
186
|
lineLengths: number[] | null;
|
|
168
187
|
functions: [string, number, number][];
|
|
169
|
-
sourceMap: { url: string | null; data: Record<string, unknown
|
|
188
|
+
sourceMap: { url: string | null; data: Record<string, unknown>; sourceUnits?: (string | null)[] } | null;
|
|
170
189
|
};
|
|
171
190
|
}
|
|
172
191
|
|
|
@@ -190,6 +209,11 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
|
|
|
190
209
|
}
|
|
191
210
|
return out;
|
|
192
211
|
}
|
|
212
|
+
/** The names of a dump's ran functions, joined to the record on the extent. */
|
|
213
|
+
function ranNames(dump: Dump, rec: ScriptRecord["spectestScript"]): string[] {
|
|
214
|
+
const byExtent = new Map(rec.functions.map(([name, start, end]) => [`${start}-${end}`, name]));
|
|
215
|
+
return dump.result[0]!.functions.map((f) => byExtent.get(`${f[0]}-${f[0]! + f[1]!}`) ?? "?");
|
|
216
|
+
}
|
|
193
217
|
async function scratch(): Promise<string> {
|
|
194
218
|
resetNodeCoverageMemory();
|
|
195
219
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
|
|
@@ -221,11 +245,14 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
|
|
|
221
245
|
await node().capture!(fakeCtx(dir));
|
|
222
246
|
const [name] = await dumpsIn(dir);
|
|
223
247
|
const dump = await readDump(dir, name!);
|
|
224
|
-
expect(dump.spectestVersion).toBe(
|
|
248
|
+
expect(dump.spectestVersion).toBe(2);
|
|
225
249
|
expect(dump.result.map((s) => s.url)).toEqual([`file://${path.join(dir, "server.js")}`]);
|
|
226
250
|
const fns = dump.result[0]!.functions;
|
|
227
|
-
|
|
251
|
+
// Triples: the extent, then blocks relative to it; some count > 0.
|
|
252
|
+
expect(fns.every((f) => f.length >= 3 && f.length % 3 === 0)).toBe(true);
|
|
253
|
+
expect(fns.every((f) => f.some((_, i) => i % 3 === 2 && f[i]! > 0))).toBe(true);
|
|
228
254
|
const rec = (await records(dir)).get(`file://${path.join(dir, "server.js")}`)!;
|
|
255
|
+
expect(ranNames(dump, rec).sort()).toEqual(["", "tick"]);
|
|
229
256
|
expect(rec.lineLengths).toEqual([28, 51, 0]);
|
|
230
257
|
expect(rec.functions.map((f) => f[0])).toEqual(["", "tick"]);
|
|
231
258
|
expect(rec.sourceMap).toBeNull();
|
|
@@ -241,7 +268,7 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
|
|
|
241
268
|
expect(names.length).toBe(1);
|
|
242
269
|
expect(names[0]).not.toBe(name);
|
|
243
270
|
const second = await readDump(dir, names[0]!);
|
|
244
|
-
expect(second
|
|
271
|
+
expect(ranNames(second, rec)).toEqual(["tick"]);
|
|
245
272
|
expect((await fs.readdir(path.join(dir, NODE_COVERAGE_SCRIPTS_SUBDIR))).length).toBe(0);
|
|
246
273
|
} finally {
|
|
247
274
|
server.kill("SIGKILL");
|
|
@@ -277,14 +304,14 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
|
|
|
277
304
|
await node().capture!(fakeCtx(dir));
|
|
278
305
|
const names = await dumpsIn(dir);
|
|
279
306
|
expect(names.length).toBe(2);
|
|
307
|
+
const recs = await records(dir);
|
|
308
|
+
expect(recs.size).toBe(1);
|
|
309
|
+
const rec = recs.get(`file://${path.join(dir, "cli.js")}`)!;
|
|
280
310
|
for (const n of names) {
|
|
281
311
|
const d = await readDump(dir, n);
|
|
282
312
|
expect(d.result.map((s) => s.url)).toEqual([`file://${path.join(dir, "cli.js")}`]);
|
|
283
|
-
expect(d
|
|
313
|
+
expect(ranNames(d, rec).sort()).toEqual(["", "used"]);
|
|
284
314
|
}
|
|
285
|
-
const recs = await records(dir);
|
|
286
|
-
expect(recs.size).toBe(1);
|
|
287
|
-
const rec = recs.get(`file://${path.join(dir, "cli.js")}`)!;
|
|
288
315
|
expect(rec.functions.map((f) => f[0])).toEqual(["", "used", "unused"]);
|
|
289
316
|
expect(rec.lineLengths).toEqual([28, 30, 7, 0]);
|
|
290
317
|
expect(rec.sourceMap!.url).toBe(`file://${path.join(dir, "cli.js.map")}`);
|
|
@@ -312,8 +339,9 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
|
|
|
312
339
|
expect(recs.get(`file://${path.join(dir, "a.js")}`)!.sourceMap).toEqual({
|
|
313
340
|
url: `file://${path.join(dir, "maps", "a.map")}`,
|
|
314
341
|
data: { version: 3, sources: ["../a.ts"], mappings: "AAAA" },
|
|
342
|
+
sourceUnits: [null],
|
|
315
343
|
});
|
|
316
|
-
expect(recs.get(`file://${path.join(dir, "b.js")}`)!.sourceMap).toEqual({ url: null, data: { version: 3, sources: ["b.ts"], mappings: "AACA" } });
|
|
344
|
+
expect(recs.get(`file://${path.join(dir, "b.js")}`)!.sourceMap).toEqual({ url: null, data: { version: 3, sources: ["b.ts"], mappings: "AACA" }, sourceUnits: [null] });
|
|
317
345
|
expect(recs.get(`file://${path.join(dir, "c.js")}`)!.sourceMap).toBeNull();
|
|
318
346
|
expect(recs.get(`file://${path.join(dir, "main.js")}`)!.sourceMap).toBeNull();
|
|
319
347
|
});
|
|
@@ -333,11 +361,63 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
|
|
|
333
361
|
expect(names.length).toBe(2);
|
|
334
362
|
const byThread = new Map<string, Dump>();
|
|
335
363
|
for (const n of names) byThread.set(n.split("-")[2]!, await readDump(dir, n));
|
|
364
|
+
const recs = await records(dir);
|
|
365
|
+
expect(recs.size).toBe(1); // one script, one record, whichever thread came first
|
|
366
|
+
const rec = recs.get(`file://${path.join(dir, "threads.js")}`)!;
|
|
336
367
|
const worker = [...byThread.entries()].find(([t]) => t !== "0")![1];
|
|
337
|
-
expect(worker.
|
|
368
|
+
expect(ranNames(worker, rec)).toContain("inWorker");
|
|
338
369
|
const main = byThread.get("0")!;
|
|
339
|
-
expect(main.
|
|
340
|
-
|
|
370
|
+
expect(ranNames(main, rec)).not.toContain("inWorker");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("a dependency that ran is a unit in the dump, never a script; a bundled dependency is a unit on the map", async () => {
|
|
374
|
+
const dir = await scratch();
|
|
375
|
+
// hoisted: node_modules/dep/index.js; pnpm store: node_modules/.pnpm/pkg@2.0.0/node_modules/pkg/index.js
|
|
376
|
+
await fs.mkdir(path.join(dir, "node_modules", "dep"), { recursive: true });
|
|
377
|
+
await fs.writeFile(path.join(dir, "node_modules", "dep", "package.json"), JSON.stringify({ name: "dep", version: "1.2.3" }));
|
|
378
|
+
await fs.writeFile(path.join(dir, "node_modules", "dep", "index.js"), "exports.f = function f() { return 1 };\n");
|
|
379
|
+
await fs.mkdir(path.join(dir, "node_modules", ".pnpm", "pkg@2.0.0", "node_modules", "pkg"), { recursive: true });
|
|
380
|
+
await fs.writeFile(path.join(dir, "node_modules", ".pnpm", "pkg@2.0.0", "node_modules", "pkg", "index.js"), "exports.g = () => 2;\n");
|
|
381
|
+
await fs.writeFile(
|
|
382
|
+
path.join(dir, "main.js"),
|
|
383
|
+
"require('./node_modules/dep/index.js').f(); require('./node_modules/.pnpm/pkg@2.0.0/node_modules/pkg/index.js').g();\n",
|
|
384
|
+
);
|
|
385
|
+
// A bundle whose map names a dependency source that is not on disk.
|
|
386
|
+
const map = JSON.stringify({ version: 3, sources: ["../src/app.ts", "../node_modules/.pnpm/picocolors@1.1.1/node_modules/picocolors/picocolors.js"], mappings: "AAAA;AACA" });
|
|
387
|
+
await fs.mkdir(path.join(dir, "dist"));
|
|
388
|
+
await fs.writeFile(path.join(dir, "dist", "bundle.js"), `x = 1;\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(map).toString("base64")}\n`);
|
|
389
|
+
await fs.writeFile(path.join(dir, "entry.js"), "require('./main.js'); require('./dist/bundle.js');\n");
|
|
390
|
+
expect(await runOnce(dir, "entry.js")).toBe(0);
|
|
391
|
+
await node().capture!(fakeCtx(dir));
|
|
392
|
+
const [name] = await dumpsIn(dir);
|
|
393
|
+
const dump = JSON.parse(await fs.readFile(path.join(dir, name!), "utf8")) as Dump & { units: string[] };
|
|
394
|
+
expect(dump.units).toEqual(["npm:dep@1.2.3", "npm:pkg@2.0.0"]); // pkg has no package.json: name and version from the pnpm path
|
|
395
|
+
expect(dump.result.map((s) => s.url).every((u) => !u.includes("/node_modules/"))).toBe(true);
|
|
396
|
+
const rec = (await records(dir)).get(`file://${path.join(dir, "dist", "bundle.js")}`)!;
|
|
397
|
+
expect((rec.sourceMap as { sourceUnits?: (string | null)[] }).sourceUnits).toEqual([null, "npm:picocolors@1.1.1"]);
|
|
398
|
+
expect([...(await records(dir)).keys()].every((u) => !u.includes("/node_modules/"))).toBe(true);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("the inventory script lists hoisted, scoped, nested and pnpm-store packages from the cwd upward", async () => {
|
|
402
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-inv-"));
|
|
403
|
+
const w = async (rel: string, name: string, version?: string) => {
|
|
404
|
+
await fs.mkdir(path.join(root, rel), { recursive: true });
|
|
405
|
+
await fs.writeFile(path.join(root, rel, "package.json"), JSON.stringify(version ? { name, version } : { name }));
|
|
406
|
+
};
|
|
407
|
+
await w("node_modules/a", "a", "1.0.0");
|
|
408
|
+
await w("node_modules/@s/b", "@s/b", "2.0.0");
|
|
409
|
+
await w("node_modules/a/node_modules/c", "c", "3.0.0");
|
|
410
|
+
await w("node_modules/.pnpm/d@4.0.0/node_modules/d", "d", "4.0.0");
|
|
411
|
+
await w("node_modules/.pnpm/d@4.0.0/node_modules/e", "e"); // a symlink target in real pnpm; a real dir here, so listed
|
|
412
|
+
await fs.mkdir(path.join(root, "packages", "cli", "node_modules", "f"), { recursive: true });
|
|
413
|
+
await fs.writeFile(path.join(root, "packages", "cli", "node_modules", "f", "package.json"), JSON.stringify({ name: "f", version: "6.0.0" }));
|
|
414
|
+
await fs.symlink(path.join(root, "node_modules", ".pnpm", "d@4.0.0", "node_modules", "d"), path.join(root, "node_modules", "d"));
|
|
415
|
+
const p = spawn("node", ["-"], { cwd: path.join(root, "packages", "cli"), stdio: ["pipe", "pipe", "inherit"] });
|
|
416
|
+
p.stdin!.end(NODE_INVENTORY_SCRIPT);
|
|
417
|
+
let out = "";
|
|
418
|
+
p.stdout!.on("data", (b: Buffer) => (out += b));
|
|
419
|
+
expect(await new Promise<number | null>((r) => p.on("exit", r))).toBe(0);
|
|
420
|
+
expect(out.trim().split("\n")).toEqual(["npm:@s/b@2.0.0", "npm:a@1.0.0", "npm:c@3.0.0", "npm:d@4.0.0", "npm:e", "npm:f@6.0.0"]);
|
|
341
421
|
});
|
|
342
422
|
|
|
343
423
|
test("a stale socket left by a killed process is removed at capture", async () => {
|
package/src/coverage.ts
CHANGED
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
// import * as coverage from "@specific.dev/spectest/coverage";
|
|
28
28
|
// coverage: { adapters: [coverage.node(), coverage.browser()] }
|
|
29
29
|
|
|
30
|
+
import { createHash } from "node:crypto";
|
|
31
|
+
|
|
30
32
|
import type { ServiceConfig } from "./index.js";
|
|
31
33
|
import { harvestAllBrowserCoverage, takeBrowserCoverageReports } from "./browser-coverage.js";
|
|
32
34
|
|
|
@@ -67,6 +69,27 @@ export interface CoverageLoadContext {
|
|
|
67
69
|
collectBrowserScripts(): void;
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
/**
|
|
73
|
+
* What one adapter's `inventory` runs against, once per environment, at
|
|
74
|
+
* the bring-up capture. The inventory is the set of **units** the
|
|
75
|
+
* environment resolved for this service — dependencies, and fingerprints
|
|
76
|
+
* of the manifests that declare them (see {@link CoverageAdapter.inventory}).
|
|
77
|
+
*/
|
|
78
|
+
export interface CoverageInventoryContext {
|
|
79
|
+
/** The services-map key. */
|
|
80
|
+
service: string;
|
|
81
|
+
/** Run a shell command (`sh -c`) inside the container. Rejects on a
|
|
82
|
+
* non-zero exit with the command's output in the message. */
|
|
83
|
+
exec(command: string): Promise<{ stdout: string; stderr: string }>;
|
|
84
|
+
/** Every file of the uploaded project, project-relative, `/`-separated,
|
|
85
|
+
* `node_modules`, `.git` and the like skipped. */
|
|
86
|
+
projectFiles(): Promise<string[]>;
|
|
87
|
+
/** One project file's text (project-relative path); `null` when absent. */
|
|
88
|
+
readProjectFile(path: string): Promise<string | null>;
|
|
89
|
+
/** Aborts when the inventory budget runs out. */
|
|
90
|
+
signal: AbortSignal;
|
|
91
|
+
}
|
|
92
|
+
|
|
70
93
|
/** What one adapter's `capture` runs against. */
|
|
71
94
|
export interface CoverageCaptureContext {
|
|
72
95
|
/** The services-map key. */
|
|
@@ -109,8 +132,33 @@ export interface CoverageAdapter {
|
|
|
109
132
|
load?(ctx: CoverageLoadContext): void | Promise<void>;
|
|
110
133
|
/** Make the report appear in the directory. A throw fails the test. */
|
|
111
134
|
capture?(ctx: CoverageCaptureContext): void | Promise<void>;
|
|
135
|
+
/**
|
|
136
|
+
* Project paths (gitignore-style patterns, project-relative) whose
|
|
137
|
+
* changes this adapter's **inventory** accounts for — a package
|
|
138
|
+
* manifest, a lockfile. The subset selector sets such a changed file
|
|
139
|
+
* aside instead of treating it as a file no coverage names (which is a
|
|
140
|
+
* full run), and judges the change by the inventory instead: a unit
|
|
141
|
+
* that is gone selects the tests that ran it; a gone unit no test ran
|
|
142
|
+
* is a full run. Part of the wire config (`coverage.explains`).
|
|
143
|
+
*/
|
|
144
|
+
explains?: readonly string[];
|
|
145
|
+
/**
|
|
146
|
+
* The **units** the environment resolved for this service, once per
|
|
147
|
+
* environment at the bring-up capture: one opaque id per line of the
|
|
148
|
+
* `inventory.units` report. Dependencies as `<scheme>:<name>@<version>`
|
|
149
|
+
* (`npm:picocolors@1.1.1`), and a fingerprint per manifest as
|
|
150
|
+
* `manifest:<project path>#<hash>` over the manifest minus its metadata
|
|
151
|
+
* keys, so a version bump does not move it and a `scripts` change does.
|
|
152
|
+
* The control plane compares inventories across runs by string
|
|
153
|
+
* equality and knows no scheme; what a unit is, is the adapter's.
|
|
154
|
+
*/
|
|
155
|
+
inventory?(ctx: CoverageInventoryContext): Promise<readonly string[]>;
|
|
112
156
|
}
|
|
113
157
|
|
|
158
|
+
/** The report name the harness writes an adapter's inventory to, in the
|
|
159
|
+
* bring-up capture only. */
|
|
160
|
+
export const INVENTORY_REPORT = "inventory.units";
|
|
161
|
+
|
|
114
162
|
/** `ServiceConfig.coverage`: the program writes its own reports (`true`),
|
|
115
163
|
* or a list of adapters gets them out, and/or how the reports the
|
|
116
164
|
* program itself writes count (`reports`, default `"cumulative"`). */
|
|
@@ -149,14 +197,17 @@ export function validateCoverage(service: string, cov: unknown): void {
|
|
|
149
197
|
((a as CoverageAdapter).reports === undefined ||
|
|
150
198
|
(a as CoverageAdapter).reports === "delta" ||
|
|
151
199
|
(a as CoverageAdapter).reports === "cumulative") &&
|
|
152
|
-
|
|
200
|
+
((a as CoverageAdapter).explains === undefined ||
|
|
201
|
+
(Array.isArray((a as CoverageAdapter).explains) &&
|
|
202
|
+
((a as CoverageAdapter).explains as unknown[]).every((e) => typeof e === "string"))) &&
|
|
203
|
+
["configure", "load", "capture", "inventory"].every(
|
|
153
204
|
(k) =>
|
|
154
205
|
(a as Record<string, unknown>)[k] === undefined ||
|
|
155
206
|
typeof (a as Record<string, unknown>)[k] === "function",
|
|
156
207
|
);
|
|
157
208
|
if (!ok) {
|
|
158
209
|
throw new Error(
|
|
159
|
-
`service "${service}" has an invalid coverage adapter — an adapter is \`{ name, reports?, configure?, load?, capture? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`,
|
|
210
|
+
`service "${service}" has an invalid coverage adapter — an adapter is \`{ name, reports?, explains?, configure?, load?, capture?, inventory? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`,
|
|
160
211
|
);
|
|
161
212
|
}
|
|
162
213
|
}
|
|
@@ -188,7 +239,14 @@ export function applyCoverageAdapters<S extends ServiceConfig>(key: string, svc:
|
|
|
188
239
|
if (a.configure) out = a.configure(out, { key });
|
|
189
240
|
}
|
|
190
241
|
const mode = (svc.coverage as { reports?: CoverageReportsMode }).reports;
|
|
191
|
-
|
|
242
|
+
// `explains` rides the wire config so the control plane's selector can
|
|
243
|
+
// read it without knowing any adapter.
|
|
244
|
+
const explains = [...new Set(adapters.flatMap((a) => a.explains ?? []))];
|
|
245
|
+
const wire = {
|
|
246
|
+
adapters: adapters.map((a) => withWireName(a)),
|
|
247
|
+
...(mode ? { reports: mode } : {}),
|
|
248
|
+
...(explains.length > 0 ? { explains } : {}),
|
|
249
|
+
};
|
|
192
250
|
return { ...out, coverage: wire } as unknown as S;
|
|
193
251
|
}
|
|
194
252
|
|
|
@@ -255,12 +313,21 @@ export const NODE_COVERAGE_DUMP_PREFIX = "node-";
|
|
|
255
313
|
* counters: every dump is the delta since the previous take. No
|
|
256
314
|
* signal is used: signals are claimed by frameworks (SIGUSR2 stops a
|
|
257
315
|
* Temporal worker, restarts nodemon), a socket is nobody's.
|
|
316
|
+
* - Reports **dependencies as units**: a ran script under `node_modules`
|
|
317
|
+
* becomes `npm:<name>@<version>` (from the nearest package.json, or the
|
|
318
|
+
* path's text) in the dump's `units`; a bundled script's map sources
|
|
319
|
+
* under `node_modules` become `sourceUnits` beside the map in its
|
|
320
|
+
* record. The control plane compares unit ids by equality and never
|
|
321
|
+
* learns what `node_modules` is.
|
|
258
322
|
* - Writes a **dump** `node-<pid>-<thread>-<ms>-<n>.json`: the app's own
|
|
259
323
|
* scripts (`file://`, outside `node_modules` and `.cache`, not the
|
|
260
324
|
* hook) in which some range count is above zero, and of each only the
|
|
261
|
-
* functions that ran,
|
|
262
|
-
*
|
|
263
|
-
* `
|
|
325
|
+
* functions that ran, each one flat array: `[start, length, count]`
|
|
326
|
+
* for its extent (V8's first range), then `[offset from start,
|
|
327
|
+
* length, count]` per block range in V8's order. Name and block flag
|
|
328
|
+
* are in the record, joined on the extent. Nothing when nothing ran.
|
|
329
|
+
* `{ spectestVersion: 2, result: [{ url, spectestScript, functions:
|
|
330
|
+
* [[s, l, c, o, l, c, …], …] }] }`.
|
|
264
331
|
* - Writes, **once per script per branch**, a **script record**
|
|
265
332
|
* `scripts/<id>.json` (`id` = sha256 of the URL): the length of every
|
|
266
333
|
* line of the source V8 compiled (`Debugger.getScriptSource`, so a
|
|
@@ -323,6 +390,48 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
323
390
|
function isApp(u) {
|
|
324
391
|
return typeof u === "string" && u.startsWith("file://") && u.indexOf("/node_modules/") < 0 && u.indexOf("/.cache/") < 0 && u !== SELF;
|
|
325
392
|
}
|
|
393
|
+
function isDep(u) {
|
|
394
|
+
return typeof u === "string" && u.startsWith("file://") && u.indexOf("/node_modules/") >= 0 && u !== SELF;
|
|
395
|
+
}
|
|
396
|
+
// A dependency's unit id, npm:<name>@<version>, from the nearest
|
|
397
|
+
// package.json above a path inside node_modules (read once per
|
|
398
|
+
// directory); when the file is not on disk (a bundled dependency whose
|
|
399
|
+
// sources a multi-stage image dropped), from the path's text.
|
|
400
|
+
const UNIT_CACHE = new Map();
|
|
401
|
+
function unitFromText(p) {
|
|
402
|
+
let m = /\\/node_modules\\/\\.pnpm\\/((?:@[^/@+]+\\+)?[^/@]+)@([^/_]+)/.exec(p);
|
|
403
|
+
if (m) return "npm:" + m[1].replace("+", "/") + "@" + m[2];
|
|
404
|
+
m = /\\/node_modules\\/(@[^/]+\\/[^/]+|[^/@.][^/]*)(?:\\/|$)/.exec(p);
|
|
405
|
+
return m ? "npm:" + m[1] : null;
|
|
406
|
+
}
|
|
407
|
+
function unitOf(p) {
|
|
408
|
+
if (p.indexOf("/node_modules/") < 0) return null;
|
|
409
|
+
// Walk up to the innermost node_modules boundary and no further: the
|
|
410
|
+
// directories above it are shared with every other package, so a
|
|
411
|
+
// cached answer must never sit on them.
|
|
412
|
+
const visited = [];
|
|
413
|
+
let d = path.dirname(p);
|
|
414
|
+
while (d && d !== "/" && path.basename(d) !== "node_modules") {
|
|
415
|
+
if (UNIT_CACHE.has(d)) {
|
|
416
|
+
const hit = UNIT_CACHE.get(d);
|
|
417
|
+
for (const v of visited) UNIT_CACHE.set(v, hit);
|
|
418
|
+
return hit;
|
|
419
|
+
}
|
|
420
|
+
visited.push(d);
|
|
421
|
+
try {
|
|
422
|
+
const pj = JSON.parse(fs.readFileSync(path.join(d, "package.json"), "utf8"));
|
|
423
|
+
if (pj && typeof pj.name === "string" && pj.name) {
|
|
424
|
+
const u = "npm:" + pj.name + (typeof pj.version === "string" && pj.version ? "@" + pj.version : "");
|
|
425
|
+
for (const v of visited) UNIT_CACHE.set(v, u);
|
|
426
|
+
return u;
|
|
427
|
+
}
|
|
428
|
+
} catch (e) {}
|
|
429
|
+
d = path.dirname(d);
|
|
430
|
+
}
|
|
431
|
+
const u = unitFromText(p);
|
|
432
|
+
for (const v of visited) UNIT_CACHE.set(v, u);
|
|
433
|
+
return u;
|
|
434
|
+
}
|
|
326
435
|
function ran(f) {
|
|
327
436
|
return f.ranges.some(function (r) { return r.count > 0; });
|
|
328
437
|
}
|
|
@@ -378,7 +487,29 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
378
487
|
}
|
|
379
488
|
if (!data || typeof data !== "object" || !("mappings" in data)) return null;
|
|
380
489
|
delete data.sourcesContent;
|
|
381
|
-
|
|
490
|
+
// A source inside node_modules is a dependency: its unit id rides
|
|
491
|
+
// beside the map (\`sourceUnits[i]\`, null for the project's own), so
|
|
492
|
+
// the control plane never has to know what node_modules is.
|
|
493
|
+
const mapDir = mapUrl ? path.dirname(url.fileURLToPath(mapUrl)) : path.dirname(scriptPath);
|
|
494
|
+
const root = typeof data.sourceRoot === "string" && data.sourceRoot ? data.sourceRoot : "";
|
|
495
|
+
const sourceUnits = (Array.isArray(data.sources) ? data.sources : []).map(function (src) {
|
|
496
|
+
if (typeof src !== "string") return null;
|
|
497
|
+
let abs;
|
|
498
|
+
if (src.startsWith("file://")) {
|
|
499
|
+
try {
|
|
500
|
+
abs = url.fileURLToPath(src);
|
|
501
|
+
} catch (e) {
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
} else if (/^[a-z][a-z0-9+.-]*:/i.test(src)) {
|
|
505
|
+
return src.indexOf("/node_modules/") >= 0 ? unitFromText(src) : null;
|
|
506
|
+
} else {
|
|
507
|
+
const rel = root && !src.startsWith("/") ? root.replace(/\\/$/, "") + "/" + src : src;
|
|
508
|
+
abs = path.resolve(mapDir, rel);
|
|
509
|
+
}
|
|
510
|
+
return abs.indexOf("/node_modules/") >= 0 ? unitOf(abs) : null;
|
|
511
|
+
});
|
|
512
|
+
return { url: mapUrl, data: data, sourceUnits: sourceUnits };
|
|
382
513
|
}
|
|
383
514
|
function writeAtomic(file, text) {
|
|
384
515
|
const tmp = file + "." + process.pid + "-" + wt.threadId + ".tmp";
|
|
@@ -409,10 +540,19 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
409
540
|
const res = post("Profiler.takePreciseCoverage");
|
|
410
541
|
if (!res || !Array.isArray(res.result)) return;
|
|
411
542
|
const kept = [];
|
|
543
|
+
const units = new Set();
|
|
412
544
|
for (const s of res.result) {
|
|
413
|
-
if (
|
|
545
|
+
if (!Array.isArray(s.functions) || !s.functions.some(ran)) continue;
|
|
546
|
+
if (isApp(s.url)) kept.push(s);
|
|
547
|
+
else if (isDep(s.url)) {
|
|
548
|
+
let u = null;
|
|
549
|
+
try {
|
|
550
|
+
u = unitOf(url.fileURLToPath(s.url));
|
|
551
|
+
} catch (e) {}
|
|
552
|
+
if (u) units.add(u);
|
|
553
|
+
}
|
|
414
554
|
}
|
|
415
|
-
if (kept.length === 0) return;
|
|
555
|
+
if (kept.length === 0 && units.size === 0) return;
|
|
416
556
|
const fresh = [];
|
|
417
557
|
for (const s of kept) {
|
|
418
558
|
if (known.has(s.url)) continue;
|
|
@@ -433,10 +573,28 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
433
573
|
}
|
|
434
574
|
post("Debugger.disable");
|
|
435
575
|
}
|
|
576
|
+
// A ran function is one flat array: [start, length, count] for its
|
|
577
|
+
// extent (V8's first range), then [offset from start, length, count]
|
|
578
|
+
// per block range in V8's order. Small numbers, so the dump gzips to
|
|
579
|
+
// less than half of V8's own JSON; name and block flag live in the
|
|
580
|
+
// script record, joined on the extent.
|
|
436
581
|
const doc = {
|
|
437
|
-
spectestVersion:
|
|
582
|
+
spectestVersion: 2,
|
|
583
|
+
units: Array.from(units).sort(),
|
|
438
584
|
result: kept.map(function (s) {
|
|
439
|
-
return {
|
|
585
|
+
return {
|
|
586
|
+
url: s.url,
|
|
587
|
+
spectestScript: idOf(s.url),
|
|
588
|
+
functions: s.functions.filter(ran).map(function (f) {
|
|
589
|
+
const first = f.ranges[0];
|
|
590
|
+
const out = [first.startOffset, first.endOffset - first.startOffset, first.count];
|
|
591
|
+
for (let i = 1; i < f.ranges.length; i++) {
|
|
592
|
+
const r = f.ranges[i];
|
|
593
|
+
out.push(r.startOffset - first.startOffset, r.endOffset - r.startOffset, r.count);
|
|
594
|
+
}
|
|
595
|
+
return out;
|
|
596
|
+
}),
|
|
597
|
+
};
|
|
440
598
|
}),
|
|
441
599
|
};
|
|
442
600
|
const name = ${JSON.stringify(NODE_COVERAGE_DUMP_PREFIX)} + process.pid + "-" + wt.threadId + "-" + Date.now() + "-" + seq++ + ".json";
|
|
@@ -496,10 +654,136 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
496
654
|
* only its own files: what an earlier capture on this branch shipped is
|
|
497
655
|
* removed first (module memory, forks with the environment).
|
|
498
656
|
*/
|
|
657
|
+
/** Manifests and lockfiles whose changes the node inventory accounts for. */
|
|
658
|
+
export const NODE_EXPLAINS: readonly string[] = [
|
|
659
|
+
"**/package.json",
|
|
660
|
+
"**/package-lock.json",
|
|
661
|
+
"**/npm-shrinkwrap.json",
|
|
662
|
+
"**/pnpm-lock.yaml",
|
|
663
|
+
"**/pnpm-workspace.yaml",
|
|
664
|
+
"**/yarn.lock",
|
|
665
|
+
"**/bun.lock",
|
|
666
|
+
"**/bun.lockb",
|
|
667
|
+
];
|
|
668
|
+
|
|
669
|
+
/** package.json keys that describe the package rather than shape what
|
|
670
|
+
* runs: a change confined to them does not move the manifest's unit. */
|
|
671
|
+
export const NPM_MANIFEST_METADATA_KEYS: readonly string[] = [
|
|
672
|
+
"version",
|
|
673
|
+
"description",
|
|
674
|
+
"keywords",
|
|
675
|
+
"author",
|
|
676
|
+
"contributors",
|
|
677
|
+
"maintainers",
|
|
678
|
+
"license",
|
|
679
|
+
"repository",
|
|
680
|
+
"homepage",
|
|
681
|
+
"bugs",
|
|
682
|
+
"funding",
|
|
683
|
+
"private",
|
|
684
|
+
"publishConfig",
|
|
685
|
+
"readme",
|
|
686
|
+
"readmeFilename",
|
|
687
|
+
"gitHead",
|
|
688
|
+
"_id",
|
|
689
|
+
];
|
|
690
|
+
|
|
691
|
+
/** JSON with every object's keys sorted, at every depth. */
|
|
692
|
+
function canonicalJson(v: unknown): string {
|
|
693
|
+
if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
|
|
694
|
+
if (v && typeof v === "object") {
|
|
695
|
+
const o = v as Record<string, unknown>;
|
|
696
|
+
return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`).join(",")}}`;
|
|
697
|
+
}
|
|
698
|
+
return JSON.stringify(v);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** `manifest:<path>#<hash>` over a package.json minus its metadata keys,
|
|
702
|
+
* keys sorted, so formatting and a version bump leave it unchanged and a
|
|
703
|
+
* `scripts`, `exports`, `bin` or dependency change moves it. A file that
|
|
704
|
+
* is not JSON hashes as text. */
|
|
705
|
+
export function npmManifestUnit(projectPath: string, text: string): string {
|
|
706
|
+
let canon = text;
|
|
707
|
+
try {
|
|
708
|
+
const doc = JSON.parse(text) as Record<string, unknown>;
|
|
709
|
+
if (doc && typeof doc === "object" && !Array.isArray(doc)) {
|
|
710
|
+
for (const k of NPM_MANIFEST_METADATA_KEYS) delete doc[k];
|
|
711
|
+
canon = canonicalJson(doc);
|
|
712
|
+
}
|
|
713
|
+
} catch {
|
|
714
|
+
// not JSON: the text is the fingerprint
|
|
715
|
+
}
|
|
716
|
+
const hash = createHash("sha256").update(canon).digest("hex").slice(0, 16);
|
|
717
|
+
return `manifest:${projectPath}#${hash}`;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* The node script the inventory runs inside the container: every package
|
|
722
|
+
* installed in a `node_modules` on the path from the working directory up
|
|
723
|
+
* to `/` — hoisted (`node_modules/<name>`, `@scope/<name>`), nested, and
|
|
724
|
+
* pnpm's store (`node_modules/.pnpm/<pkg>/node_modules/<name>`, the real
|
|
725
|
+
* directories only; the symlinks beside them point back into the store).
|
|
726
|
+
* Prints one `npm:<name>@<version>` per line.
|
|
727
|
+
*/
|
|
728
|
+
export const NODE_INVENTORY_SCRIPT = `"use strict";
|
|
729
|
+
const fs = require("node:fs"), path = require("node:path");
|
|
730
|
+
const out = new Set();
|
|
731
|
+
function isRealDir(p) { try { return fs.lstatSync(p).isDirectory(); } catch (e) { return false; } }
|
|
732
|
+
function readPkg(d) {
|
|
733
|
+
try {
|
|
734
|
+
const j = JSON.parse(fs.readFileSync(path.join(d, "package.json"), "utf8"));
|
|
735
|
+
if (j && typeof j.name === "string" && j.name) out.add("npm:" + j.name + (typeof j.version === "string" && j.version ? "@" + j.version : ""));
|
|
736
|
+
} catch (e) {}
|
|
737
|
+
}
|
|
738
|
+
function visit(d, depth) {
|
|
739
|
+
readPkg(d);
|
|
740
|
+
if (depth < 3) { const nested = path.join(d, "node_modules"); if (isRealDir(nested)) walkNm(nested, depth + 1); }
|
|
741
|
+
}
|
|
742
|
+
function walkNm(nm, depth) {
|
|
743
|
+
let names; try { names = fs.readdirSync(nm); } catch (e) { return; }
|
|
744
|
+
for (const n of names) {
|
|
745
|
+
if (n === ".bin" || n === ".cache" || n === ".modules.yaml") continue;
|
|
746
|
+
if (n === ".pnpm") {
|
|
747
|
+
let ps; try { ps = fs.readdirSync(path.join(nm, ".pnpm")); } catch (e) { continue; }
|
|
748
|
+
for (const p of ps) { const inner = path.join(nm, ".pnpm", p, "node_modules"); if (isRealDir(inner)) walkNm(inner, depth + 1); }
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
if (n.startsWith("@")) {
|
|
752
|
+
let ss; try { ss = fs.readdirSync(path.join(nm, n)); } catch (e) { continue; }
|
|
753
|
+
for (const s of ss) { const d = path.join(nm, n, s); if (isRealDir(d)) visit(d, depth); }
|
|
754
|
+
} else { const d = path.join(nm, n); if (isRealDir(d)) visit(d, depth); }
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
let d = process.cwd();
|
|
758
|
+
for (;;) {
|
|
759
|
+
const nm = path.join(d, "node_modules");
|
|
760
|
+
if (isRealDir(nm)) walkNm(nm, 0);
|
|
761
|
+
const up = path.dirname(d);
|
|
762
|
+
if (up === d) break;
|
|
763
|
+
d = up;
|
|
764
|
+
}
|
|
765
|
+
process.stdout.write(Array.from(out).sort().join("\\n") + (out.size ? "\\n" : ""));
|
|
766
|
+
`;
|
|
767
|
+
|
|
499
768
|
export function node(): CoverageAdapter {
|
|
500
769
|
return {
|
|
501
770
|
name: "node",
|
|
502
771
|
reports: "delta",
|
|
772
|
+
explains: NODE_EXPLAINS,
|
|
773
|
+
async inventory(ctx) {
|
|
774
|
+
// Installed packages, from inside the container (the script goes
|
|
775
|
+
// in through stdin so no quoting crosses the shell); manifest
|
|
776
|
+
// fingerprints from the project's own package.json files.
|
|
777
|
+
const b64 = Buffer.from(NODE_INVENTORY_SCRIPT).toString("base64");
|
|
778
|
+
const r = await ctx.exec(`echo ${b64} | base64 -d | node -`);
|
|
779
|
+
const units = new Set<string>(r.stdout.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("npm:")));
|
|
780
|
+
for (const f of await ctx.projectFiles()) {
|
|
781
|
+
if (f !== "package.json" && !f.endsWith("/package.json")) continue;
|
|
782
|
+
const text = await ctx.readProjectFile(f);
|
|
783
|
+
if (text !== null) units.add(npmManifestUnit(f, text));
|
|
784
|
+
}
|
|
785
|
+
return [...units].sort();
|
|
786
|
+
},
|
|
503
787
|
configure(svc) {
|
|
504
788
|
const env = appendEnvFlag(svc.env, "NODE_OPTIONS", `--require ${NODE_COVERAGE_HOOK_PATH}`);
|
|
505
789
|
env[NODE_COVERAGE_DIR_ENV] = COVERAGE_CONTAINER_DIR;
|