@specific.dev/spectest 0.83.0 → 0.85.1
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 +72 -0
- package/dist/coverage.js +232 -8
- 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 +15 -4
- package/dist/harness/coverage.js +48 -6
- 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 +78 -2
- package/src/coverage.ts +276 -8
- 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 +43 -6
- 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,
|
|
@@ -62,11 +65,18 @@ describe("validateCoverage", () => {
|
|
|
62
65
|
});
|
|
63
66
|
|
|
64
67
|
describe("applyCoverageAdapters", () => {
|
|
68
|
+
test("the applied (wire) form validates again, as defineEnvironment does after applying", () => {
|
|
69
|
+
const out = applyCoverageAdapters("api", { ...base, coverage: { adapters: [node()] } });
|
|
70
|
+
expect(() => validateCoverage("api", out.coverage)).not.toThrow();
|
|
71
|
+
expect(() => validateCoverage("api", JSON.parse(JSON.stringify(out.coverage)))).toThrow(); // names only: not a config
|
|
72
|
+
expect(() => validateCoverage("api", { adapters: [node()], explains: [1] })).toThrow();
|
|
73
|
+
});
|
|
65
74
|
test("node: appends NODE_OPTIONS, sets the coverage dir env, mounts the hook — never NODE_V8_COVERAGE", () => {
|
|
66
75
|
const out = applyCoverageAdapters("api", { ...base, coverage: { adapters: [node()] } });
|
|
67
76
|
expect(out.env?.NODE_OPTIONS).toBe(`--max-old-space-size=512 --require ${NODE_COVERAGE_HOOK_PATH}`);
|
|
68
77
|
expect(out.env?.[NODE_COVERAGE_DIR_ENV]).toBe("/spectest/coverage");
|
|
69
78
|
expect(out.env?.NODE_V8_COVERAGE).toBeUndefined();
|
|
79
|
+
expect((out.coverage as { explains?: string[] }).explains).toEqual([...NODE_EXPLAINS]);
|
|
70
80
|
expect(out.files?.map((f) => f.path)).toEqual([NODE_COVERAGE_HOOK_PATH]);
|
|
71
81
|
expect(out.files?.[0]?.content).toBe(NODE_COVERAGE_HOOK);
|
|
72
82
|
});
|
|
@@ -78,6 +88,7 @@ describe("applyCoverageAdapters", () => {
|
|
|
78
88
|
expect(typeof list[1]?.load).toBe("function");
|
|
79
89
|
expect(JSON.parse(JSON.stringify(out)).coverage).toEqual({
|
|
80
90
|
adapters: [{ adapter: "node" }, { adapter: "browser" }],
|
|
91
|
+
explains: [...NODE_EXPLAINS],
|
|
81
92
|
});
|
|
82
93
|
});
|
|
83
94
|
test("true and undefined pass through untouched", () => {
|
|
@@ -109,6 +120,20 @@ function fakeCtx(dir: string, signal = new AbortController().signal): CoverageCa
|
|
|
109
120
|
};
|
|
110
121
|
}
|
|
111
122
|
|
|
123
|
+
describe("units", () => {
|
|
124
|
+
test("npmManifestUnit ignores metadata keys and formatting, moves on scripts and dependencies", () => {
|
|
125
|
+
const a = npmManifestUnit("packages/cli/package.json", JSON.stringify({ name: "cli", version: "0.1.219", scripts: { build: "x" }, dependencies: { a: "1" } }));
|
|
126
|
+
const b = npmManifestUnit("packages/cli/package.json", JSON.stringify({ version: "0.1.220", name: "cli", dependencies: { a: "1" }, scripts: { build: "x" } }, null, 2));
|
|
127
|
+
expect(a).toBe(b);
|
|
128
|
+
expect(a).toMatch(/^manifest:packages\/cli\/package\.json#[0-9a-f]{16}$/);
|
|
129
|
+
const c = npmManifestUnit("packages/cli/package.json", JSON.stringify({ name: "cli", version: "0.1.220", scripts: { build: "y" }, dependencies: { a: "1" } }));
|
|
130
|
+
const d = npmManifestUnit("packages/cli/package.json", JSON.stringify({ name: "cli", version: "0.1.220", scripts: { build: "x" }, dependencies: { a: "2" } }));
|
|
131
|
+
expect(c).not.toBe(a);
|
|
132
|
+
expect(d).not.toBe(a);
|
|
133
|
+
expect(npmManifestUnit("x/package.json", "not json")).toMatch(/^manifest:x\/package\.json#/);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
112
137
|
describe("browserCoverage", () => {
|
|
113
138
|
test("writes an empty lcov when nothing ran for the service", async () => {
|
|
114
139
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-cov-"));
|
|
@@ -166,7 +191,7 @@ interface ScriptRecord {
|
|
|
166
191
|
url: string;
|
|
167
192
|
lineLengths: number[] | null;
|
|
168
193
|
functions: [string, number, number][];
|
|
169
|
-
sourceMap: { url: string | null; data: Record<string, unknown
|
|
194
|
+
sourceMap: { url: string | null; data: Record<string, unknown>; sourceUnits?: (string | null)[] } | null;
|
|
170
195
|
};
|
|
171
196
|
}
|
|
172
197
|
|
|
@@ -320,8 +345,9 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
|
|
|
320
345
|
expect(recs.get(`file://${path.join(dir, "a.js")}`)!.sourceMap).toEqual({
|
|
321
346
|
url: `file://${path.join(dir, "maps", "a.map")}`,
|
|
322
347
|
data: { version: 3, sources: ["../a.ts"], mappings: "AAAA" },
|
|
348
|
+
sourceUnits: [null],
|
|
323
349
|
});
|
|
324
|
-
expect(recs.get(`file://${path.join(dir, "b.js")}`)!.sourceMap).toEqual({ url: null, data: { version: 3, sources: ["b.ts"], mappings: "AACA" } });
|
|
350
|
+
expect(recs.get(`file://${path.join(dir, "b.js")}`)!.sourceMap).toEqual({ url: null, data: { version: 3, sources: ["b.ts"], mappings: "AACA" }, sourceUnits: [null] });
|
|
325
351
|
expect(recs.get(`file://${path.join(dir, "c.js")}`)!.sourceMap).toBeNull();
|
|
326
352
|
expect(recs.get(`file://${path.join(dir, "main.js")}`)!.sourceMap).toBeNull();
|
|
327
353
|
});
|
|
@@ -350,6 +376,56 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
|
|
|
350
376
|
expect(ranNames(main, rec)).not.toContain("inWorker");
|
|
351
377
|
});
|
|
352
378
|
|
|
379
|
+
test("a dependency that ran is a unit in the dump, never a script; a bundled dependency is a unit on the map", async () => {
|
|
380
|
+
const dir = await scratch();
|
|
381
|
+
// hoisted: node_modules/dep/index.js; pnpm store: node_modules/.pnpm/pkg@2.0.0/node_modules/pkg/index.js
|
|
382
|
+
await fs.mkdir(path.join(dir, "node_modules", "dep"), { recursive: true });
|
|
383
|
+
await fs.writeFile(path.join(dir, "node_modules", "dep", "package.json"), JSON.stringify({ name: "dep", version: "1.2.3" }));
|
|
384
|
+
await fs.writeFile(path.join(dir, "node_modules", "dep", "index.js"), "exports.f = function f() { return 1 };\n");
|
|
385
|
+
await fs.mkdir(path.join(dir, "node_modules", ".pnpm", "pkg@2.0.0", "node_modules", "pkg"), { recursive: true });
|
|
386
|
+
await fs.writeFile(path.join(dir, "node_modules", ".pnpm", "pkg@2.0.0", "node_modules", "pkg", "index.js"), "exports.g = () => 2;\n");
|
|
387
|
+
await fs.writeFile(
|
|
388
|
+
path.join(dir, "main.js"),
|
|
389
|
+
"require('./node_modules/dep/index.js').f(); require('./node_modules/.pnpm/pkg@2.0.0/node_modules/pkg/index.js').g();\n",
|
|
390
|
+
);
|
|
391
|
+
// A bundle whose map names a dependency source that is not on disk.
|
|
392
|
+
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" });
|
|
393
|
+
await fs.mkdir(path.join(dir, "dist"));
|
|
394
|
+
await fs.writeFile(path.join(dir, "dist", "bundle.js"), `x = 1;\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(map).toString("base64")}\n`);
|
|
395
|
+
await fs.writeFile(path.join(dir, "entry.js"), "require('./main.js'); require('./dist/bundle.js');\n");
|
|
396
|
+
expect(await runOnce(dir, "entry.js")).toBe(0);
|
|
397
|
+
await node().capture!(fakeCtx(dir));
|
|
398
|
+
const [name] = await dumpsIn(dir);
|
|
399
|
+
const dump = JSON.parse(await fs.readFile(path.join(dir, name!), "utf8")) as Dump & { units: string[] };
|
|
400
|
+
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
|
|
401
|
+
expect(dump.result.map((s) => s.url).every((u) => !u.includes("/node_modules/"))).toBe(true);
|
|
402
|
+
const rec = (await records(dir)).get(`file://${path.join(dir, "dist", "bundle.js")}`)!;
|
|
403
|
+
expect((rec.sourceMap as { sourceUnits?: (string | null)[] }).sourceUnits).toEqual([null, "npm:picocolors@1.1.1"]);
|
|
404
|
+
expect([...(await records(dir)).keys()].every((u) => !u.includes("/node_modules/"))).toBe(true);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("the inventory script lists hoisted, scoped, nested and pnpm-store packages from the cwd upward", async () => {
|
|
408
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-inv-"));
|
|
409
|
+
const w = async (rel: string, name: string, version?: string) => {
|
|
410
|
+
await fs.mkdir(path.join(root, rel), { recursive: true });
|
|
411
|
+
await fs.writeFile(path.join(root, rel, "package.json"), JSON.stringify(version ? { name, version } : { name }));
|
|
412
|
+
};
|
|
413
|
+
await w("node_modules/a", "a", "1.0.0");
|
|
414
|
+
await w("node_modules/@s/b", "@s/b", "2.0.0");
|
|
415
|
+
await w("node_modules/a/node_modules/c", "c", "3.0.0");
|
|
416
|
+
await w("node_modules/.pnpm/d@4.0.0/node_modules/d", "d", "4.0.0");
|
|
417
|
+
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
|
|
418
|
+
await fs.mkdir(path.join(root, "packages", "cli", "node_modules", "f"), { recursive: true });
|
|
419
|
+
await fs.writeFile(path.join(root, "packages", "cli", "node_modules", "f", "package.json"), JSON.stringify({ name: "f", version: "6.0.0" }));
|
|
420
|
+
await fs.symlink(path.join(root, "node_modules", ".pnpm", "d@4.0.0", "node_modules", "d"), path.join(root, "node_modules", "d"));
|
|
421
|
+
const p = spawn("node", ["-"], { cwd: path.join(root, "packages", "cli"), stdio: ["pipe", "pipe", "inherit"] });
|
|
422
|
+
p.stdin!.end(NODE_INVENTORY_SCRIPT);
|
|
423
|
+
let out = "";
|
|
424
|
+
p.stdout!.on("data", (b: Buffer) => (out += b));
|
|
425
|
+
expect(await new Promise<number | null>((r) => p.on("exit", r))).toBe(0);
|
|
426
|
+
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"]);
|
|
427
|
+
});
|
|
428
|
+
|
|
353
429
|
test("a stale socket left by a killed process is removed at capture", async () => {
|
|
354
430
|
const dir = await scratch();
|
|
355
431
|
const server = await start(dir, "server.js", 1);
|
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"`). */
|
|
@@ -136,10 +184,14 @@ export function validateCoverage(service: string, cov: unknown): void {
|
|
|
136
184
|
const obj = typeof cov === "object" && cov !== null ? (cov as Record<string, unknown>) : null;
|
|
137
185
|
const adapters = obj?.adapters;
|
|
138
186
|
const reports = obj?.reports;
|
|
139
|
-
|
|
187
|
+
// `explains` is what `applyCoverageAdapters` adds for the wire; the
|
|
188
|
+
// config is validated again after that, so it must pass here.
|
|
189
|
+
const explains = obj?.explains;
|
|
190
|
+
const knownKeys = obj !== null && Object.keys(obj).every((k) => k === "adapters" || k === "reports" || k === "explains");
|
|
140
191
|
const okReports = reports === undefined || reports === "delta" || reports === "cumulative";
|
|
141
192
|
const okAdapters = adapters === undefined || Array.isArray(adapters);
|
|
142
|
-
|
|
193
|
+
const okExplains = explains === undefined || (Array.isArray(explains) && explains.every((e) => typeof e === "string"));
|
|
194
|
+
if (obj !== null && knownKeys && okReports && okAdapters && okExplains && (adapters !== undefined || reports !== undefined)) {
|
|
143
195
|
for (const a of (adapters as unknown[] | undefined) ?? []) {
|
|
144
196
|
const ok =
|
|
145
197
|
typeof a === "object" &&
|
|
@@ -149,14 +201,17 @@ export function validateCoverage(service: string, cov: unknown): void {
|
|
|
149
201
|
((a as CoverageAdapter).reports === undefined ||
|
|
150
202
|
(a as CoverageAdapter).reports === "delta" ||
|
|
151
203
|
(a as CoverageAdapter).reports === "cumulative") &&
|
|
152
|
-
|
|
204
|
+
((a as CoverageAdapter).explains === undefined ||
|
|
205
|
+
(Array.isArray((a as CoverageAdapter).explains) &&
|
|
206
|
+
((a as CoverageAdapter).explains as unknown[]).every((e) => typeof e === "string"))) &&
|
|
207
|
+
["configure", "load", "capture", "inventory"].every(
|
|
153
208
|
(k) =>
|
|
154
209
|
(a as Record<string, unknown>)[k] === undefined ||
|
|
155
210
|
typeof (a as Record<string, unknown>)[k] === "function",
|
|
156
211
|
);
|
|
157
212
|
if (!ok) {
|
|
158
213
|
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)`,
|
|
214
|
+
`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
215
|
);
|
|
161
216
|
}
|
|
162
217
|
}
|
|
@@ -188,7 +243,14 @@ export function applyCoverageAdapters<S extends ServiceConfig>(key: string, svc:
|
|
|
188
243
|
if (a.configure) out = a.configure(out, { key });
|
|
189
244
|
}
|
|
190
245
|
const mode = (svc.coverage as { reports?: CoverageReportsMode }).reports;
|
|
191
|
-
|
|
246
|
+
// `explains` rides the wire config so the control plane's selector can
|
|
247
|
+
// read it without knowing any adapter.
|
|
248
|
+
const explains = [...new Set(adapters.flatMap((a) => a.explains ?? []))];
|
|
249
|
+
const wire = {
|
|
250
|
+
adapters: adapters.map((a) => withWireName(a)),
|
|
251
|
+
...(mode ? { reports: mode } : {}),
|
|
252
|
+
...(explains.length > 0 ? { explains } : {}),
|
|
253
|
+
};
|
|
192
254
|
return { ...out, coverage: wire } as unknown as S;
|
|
193
255
|
}
|
|
194
256
|
|
|
@@ -255,6 +317,12 @@ export const NODE_COVERAGE_DUMP_PREFIX = "node-";
|
|
|
255
317
|
* counters: every dump is the delta since the previous take. No
|
|
256
318
|
* signal is used: signals are claimed by frameworks (SIGUSR2 stops a
|
|
257
319
|
* Temporal worker, restarts nodemon), a socket is nobody's.
|
|
320
|
+
* - Reports **dependencies as units**: a ran script under `node_modules`
|
|
321
|
+
* becomes `npm:<name>@<version>` (from the nearest package.json, or the
|
|
322
|
+
* path's text) in the dump's `units`; a bundled script's map sources
|
|
323
|
+
* under `node_modules` become `sourceUnits` beside the map in its
|
|
324
|
+
* record. The control plane compares unit ids by equality and never
|
|
325
|
+
* learns what `node_modules` is.
|
|
258
326
|
* - Writes a **dump** `node-<pid>-<thread>-<ms>-<n>.json`: the app's own
|
|
259
327
|
* scripts (`file://`, outside `node_modules` and `.cache`, not the
|
|
260
328
|
* hook) in which some range count is above zero, and of each only the
|
|
@@ -326,6 +394,48 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
326
394
|
function isApp(u) {
|
|
327
395
|
return typeof u === "string" && u.startsWith("file://") && u.indexOf("/node_modules/") < 0 && u.indexOf("/.cache/") < 0 && u !== SELF;
|
|
328
396
|
}
|
|
397
|
+
function isDep(u) {
|
|
398
|
+
return typeof u === "string" && u.startsWith("file://") && u.indexOf("/node_modules/") >= 0 && u !== SELF;
|
|
399
|
+
}
|
|
400
|
+
// A dependency's unit id, npm:<name>@<version>, from the nearest
|
|
401
|
+
// package.json above a path inside node_modules (read once per
|
|
402
|
+
// directory); when the file is not on disk (a bundled dependency whose
|
|
403
|
+
// sources a multi-stage image dropped), from the path's text.
|
|
404
|
+
const UNIT_CACHE = new Map();
|
|
405
|
+
function unitFromText(p) {
|
|
406
|
+
let m = /\\/node_modules\\/\\.pnpm\\/((?:@[^/@+]+\\+)?[^/@]+)@([^/_]+)/.exec(p);
|
|
407
|
+
if (m) return "npm:" + m[1].replace("+", "/") + "@" + m[2];
|
|
408
|
+
m = /\\/node_modules\\/(@[^/]+\\/[^/]+|[^/@.][^/]*)(?:\\/|$)/.exec(p);
|
|
409
|
+
return m ? "npm:" + m[1] : null;
|
|
410
|
+
}
|
|
411
|
+
function unitOf(p) {
|
|
412
|
+
if (p.indexOf("/node_modules/") < 0) return null;
|
|
413
|
+
// Walk up to the innermost node_modules boundary and no further: the
|
|
414
|
+
// directories above it are shared with every other package, so a
|
|
415
|
+
// cached answer must never sit on them.
|
|
416
|
+
const visited = [];
|
|
417
|
+
let d = path.dirname(p);
|
|
418
|
+
while (d && d !== "/" && path.basename(d) !== "node_modules") {
|
|
419
|
+
if (UNIT_CACHE.has(d)) {
|
|
420
|
+
const hit = UNIT_CACHE.get(d);
|
|
421
|
+
for (const v of visited) UNIT_CACHE.set(v, hit);
|
|
422
|
+
return hit;
|
|
423
|
+
}
|
|
424
|
+
visited.push(d);
|
|
425
|
+
try {
|
|
426
|
+
const pj = JSON.parse(fs.readFileSync(path.join(d, "package.json"), "utf8"));
|
|
427
|
+
if (pj && typeof pj.name === "string" && pj.name) {
|
|
428
|
+
const u = "npm:" + pj.name + (typeof pj.version === "string" && pj.version ? "@" + pj.version : "");
|
|
429
|
+
for (const v of visited) UNIT_CACHE.set(v, u);
|
|
430
|
+
return u;
|
|
431
|
+
}
|
|
432
|
+
} catch (e) {}
|
|
433
|
+
d = path.dirname(d);
|
|
434
|
+
}
|
|
435
|
+
const u = unitFromText(p);
|
|
436
|
+
for (const v of visited) UNIT_CACHE.set(v, u);
|
|
437
|
+
return u;
|
|
438
|
+
}
|
|
329
439
|
function ran(f) {
|
|
330
440
|
return f.ranges.some(function (r) { return r.count > 0; });
|
|
331
441
|
}
|
|
@@ -381,7 +491,29 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
381
491
|
}
|
|
382
492
|
if (!data || typeof data !== "object" || !("mappings" in data)) return null;
|
|
383
493
|
delete data.sourcesContent;
|
|
384
|
-
|
|
494
|
+
// A source inside node_modules is a dependency: its unit id rides
|
|
495
|
+
// beside the map (\`sourceUnits[i]\`, null for the project's own), so
|
|
496
|
+
// the control plane never has to know what node_modules is.
|
|
497
|
+
const mapDir = mapUrl ? path.dirname(url.fileURLToPath(mapUrl)) : path.dirname(scriptPath);
|
|
498
|
+
const root = typeof data.sourceRoot === "string" && data.sourceRoot ? data.sourceRoot : "";
|
|
499
|
+
const sourceUnits = (Array.isArray(data.sources) ? data.sources : []).map(function (src) {
|
|
500
|
+
if (typeof src !== "string") return null;
|
|
501
|
+
let abs;
|
|
502
|
+
if (src.startsWith("file://")) {
|
|
503
|
+
try {
|
|
504
|
+
abs = url.fileURLToPath(src);
|
|
505
|
+
} catch (e) {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
} else if (/^[a-z][a-z0-9+.-]*:/i.test(src)) {
|
|
509
|
+
return src.indexOf("/node_modules/") >= 0 ? unitFromText(src) : null;
|
|
510
|
+
} else {
|
|
511
|
+
const rel = root && !src.startsWith("/") ? root.replace(/\\/$/, "") + "/" + src : src;
|
|
512
|
+
abs = path.resolve(mapDir, rel);
|
|
513
|
+
}
|
|
514
|
+
return abs.indexOf("/node_modules/") >= 0 ? unitOf(abs) : null;
|
|
515
|
+
});
|
|
516
|
+
return { url: mapUrl, data: data, sourceUnits: sourceUnits };
|
|
385
517
|
}
|
|
386
518
|
function writeAtomic(file, text) {
|
|
387
519
|
const tmp = file + "." + process.pid + "-" + wt.threadId + ".tmp";
|
|
@@ -412,10 +544,19 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
412
544
|
const res = post("Profiler.takePreciseCoverage");
|
|
413
545
|
if (!res || !Array.isArray(res.result)) return;
|
|
414
546
|
const kept = [];
|
|
547
|
+
const units = new Set();
|
|
415
548
|
for (const s of res.result) {
|
|
416
|
-
if (
|
|
549
|
+
if (!Array.isArray(s.functions) || !s.functions.some(ran)) continue;
|
|
550
|
+
if (isApp(s.url)) kept.push(s);
|
|
551
|
+
else if (isDep(s.url)) {
|
|
552
|
+
let u = null;
|
|
553
|
+
try {
|
|
554
|
+
u = unitOf(url.fileURLToPath(s.url));
|
|
555
|
+
} catch (e) {}
|
|
556
|
+
if (u) units.add(u);
|
|
557
|
+
}
|
|
417
558
|
}
|
|
418
|
-
if (kept.length === 0) return;
|
|
559
|
+
if (kept.length === 0 && units.size === 0) return;
|
|
419
560
|
const fresh = [];
|
|
420
561
|
for (const s of kept) {
|
|
421
562
|
if (known.has(s.url)) continue;
|
|
@@ -443,6 +584,7 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
443
584
|
// script record, joined on the extent.
|
|
444
585
|
const doc = {
|
|
445
586
|
spectestVersion: 2,
|
|
587
|
+
units: Array.from(units).sort(),
|
|
446
588
|
result: kept.map(function (s) {
|
|
447
589
|
return {
|
|
448
590
|
url: s.url,
|
|
@@ -516,10 +658,136 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
516
658
|
* only its own files: what an earlier capture on this branch shipped is
|
|
517
659
|
* removed first (module memory, forks with the environment).
|
|
518
660
|
*/
|
|
661
|
+
/** Manifests and lockfiles whose changes the node inventory accounts for. */
|
|
662
|
+
export const NODE_EXPLAINS: readonly string[] = [
|
|
663
|
+
"**/package.json",
|
|
664
|
+
"**/package-lock.json",
|
|
665
|
+
"**/npm-shrinkwrap.json",
|
|
666
|
+
"**/pnpm-lock.yaml",
|
|
667
|
+
"**/pnpm-workspace.yaml",
|
|
668
|
+
"**/yarn.lock",
|
|
669
|
+
"**/bun.lock",
|
|
670
|
+
"**/bun.lockb",
|
|
671
|
+
];
|
|
672
|
+
|
|
673
|
+
/** package.json keys that describe the package rather than shape what
|
|
674
|
+
* runs: a change confined to them does not move the manifest's unit. */
|
|
675
|
+
export const NPM_MANIFEST_METADATA_KEYS: readonly string[] = [
|
|
676
|
+
"version",
|
|
677
|
+
"description",
|
|
678
|
+
"keywords",
|
|
679
|
+
"author",
|
|
680
|
+
"contributors",
|
|
681
|
+
"maintainers",
|
|
682
|
+
"license",
|
|
683
|
+
"repository",
|
|
684
|
+
"homepage",
|
|
685
|
+
"bugs",
|
|
686
|
+
"funding",
|
|
687
|
+
"private",
|
|
688
|
+
"publishConfig",
|
|
689
|
+
"readme",
|
|
690
|
+
"readmeFilename",
|
|
691
|
+
"gitHead",
|
|
692
|
+
"_id",
|
|
693
|
+
];
|
|
694
|
+
|
|
695
|
+
/** JSON with every object's keys sorted, at every depth. */
|
|
696
|
+
function canonicalJson(v: unknown): string {
|
|
697
|
+
if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
|
|
698
|
+
if (v && typeof v === "object") {
|
|
699
|
+
const o = v as Record<string, unknown>;
|
|
700
|
+
return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`).join(",")}}`;
|
|
701
|
+
}
|
|
702
|
+
return JSON.stringify(v);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** `manifest:<path>#<hash>` over a package.json minus its metadata keys,
|
|
706
|
+
* keys sorted, so formatting and a version bump leave it unchanged and a
|
|
707
|
+
* `scripts`, `exports`, `bin` or dependency change moves it. A file that
|
|
708
|
+
* is not JSON hashes as text. */
|
|
709
|
+
export function npmManifestUnit(projectPath: string, text: string): string {
|
|
710
|
+
let canon = text;
|
|
711
|
+
try {
|
|
712
|
+
const doc = JSON.parse(text) as Record<string, unknown>;
|
|
713
|
+
if (doc && typeof doc === "object" && !Array.isArray(doc)) {
|
|
714
|
+
for (const k of NPM_MANIFEST_METADATA_KEYS) delete doc[k];
|
|
715
|
+
canon = canonicalJson(doc);
|
|
716
|
+
}
|
|
717
|
+
} catch {
|
|
718
|
+
// not JSON: the text is the fingerprint
|
|
719
|
+
}
|
|
720
|
+
const hash = createHash("sha256").update(canon).digest("hex").slice(0, 16);
|
|
721
|
+
return `manifest:${projectPath}#${hash}`;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* The node script the inventory runs inside the container: every package
|
|
726
|
+
* installed in a `node_modules` on the path from the working directory up
|
|
727
|
+
* to `/` — hoisted (`node_modules/<name>`, `@scope/<name>`), nested, and
|
|
728
|
+
* pnpm's store (`node_modules/.pnpm/<pkg>/node_modules/<name>`, the real
|
|
729
|
+
* directories only; the symlinks beside them point back into the store).
|
|
730
|
+
* Prints one `npm:<name>@<version>` per line.
|
|
731
|
+
*/
|
|
732
|
+
export const NODE_INVENTORY_SCRIPT = `"use strict";
|
|
733
|
+
const fs = require("node:fs"), path = require("node:path");
|
|
734
|
+
const out = new Set();
|
|
735
|
+
function isRealDir(p) { try { return fs.lstatSync(p).isDirectory(); } catch (e) { return false; } }
|
|
736
|
+
function readPkg(d) {
|
|
737
|
+
try {
|
|
738
|
+
const j = JSON.parse(fs.readFileSync(path.join(d, "package.json"), "utf8"));
|
|
739
|
+
if (j && typeof j.name === "string" && j.name) out.add("npm:" + j.name + (typeof j.version === "string" && j.version ? "@" + j.version : ""));
|
|
740
|
+
} catch (e) {}
|
|
741
|
+
}
|
|
742
|
+
function visit(d, depth) {
|
|
743
|
+
readPkg(d);
|
|
744
|
+
if (depth < 3) { const nested = path.join(d, "node_modules"); if (isRealDir(nested)) walkNm(nested, depth + 1); }
|
|
745
|
+
}
|
|
746
|
+
function walkNm(nm, depth) {
|
|
747
|
+
let names; try { names = fs.readdirSync(nm); } catch (e) { return; }
|
|
748
|
+
for (const n of names) {
|
|
749
|
+
if (n === ".bin" || n === ".cache" || n === ".modules.yaml") continue;
|
|
750
|
+
if (n === ".pnpm") {
|
|
751
|
+
let ps; try { ps = fs.readdirSync(path.join(nm, ".pnpm")); } catch (e) { continue; }
|
|
752
|
+
for (const p of ps) { const inner = path.join(nm, ".pnpm", p, "node_modules"); if (isRealDir(inner)) walkNm(inner, depth + 1); }
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
if (n.startsWith("@")) {
|
|
756
|
+
let ss; try { ss = fs.readdirSync(path.join(nm, n)); } catch (e) { continue; }
|
|
757
|
+
for (const s of ss) { const d = path.join(nm, n, s); if (isRealDir(d)) visit(d, depth); }
|
|
758
|
+
} else { const d = path.join(nm, n); if (isRealDir(d)) visit(d, depth); }
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
let d = process.cwd();
|
|
762
|
+
for (;;) {
|
|
763
|
+
const nm = path.join(d, "node_modules");
|
|
764
|
+
if (isRealDir(nm)) walkNm(nm, 0);
|
|
765
|
+
const up = path.dirname(d);
|
|
766
|
+
if (up === d) break;
|
|
767
|
+
d = up;
|
|
768
|
+
}
|
|
769
|
+
process.stdout.write(Array.from(out).sort().join("\\n") + (out.size ? "\\n" : ""));
|
|
770
|
+
`;
|
|
771
|
+
|
|
519
772
|
export function node(): CoverageAdapter {
|
|
520
773
|
return {
|
|
521
774
|
name: "node",
|
|
522
775
|
reports: "delta",
|
|
776
|
+
explains: NODE_EXPLAINS,
|
|
777
|
+
async inventory(ctx) {
|
|
778
|
+
// Installed packages, from inside the container (the script goes
|
|
779
|
+
// in through stdin so no quoting crosses the shell); manifest
|
|
780
|
+
// fingerprints from the project's own package.json files.
|
|
781
|
+
const b64 = Buffer.from(NODE_INVENTORY_SCRIPT).toString("base64");
|
|
782
|
+
const r = await ctx.exec(`echo ${b64} | base64 -d | node -`);
|
|
783
|
+
const units = new Set<string>(r.stdout.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("npm:")));
|
|
784
|
+
for (const f of await ctx.projectFiles()) {
|
|
785
|
+
if (f !== "package.json" && !f.endsWith("/package.json")) continue;
|
|
786
|
+
const text = await ctx.readProjectFile(f);
|
|
787
|
+
if (text !== null) units.add(npmManifestUnit(f, text));
|
|
788
|
+
}
|
|
789
|
+
return [...units].sort();
|
|
790
|
+
},
|
|
523
791
|
configure(svc) {
|
|
524
792
|
const env = appendEnvFlag(svc.env, "NODE_OPTIONS", `--require ${NODE_COVERAGE_HOOK_PATH}`);
|
|
525
793
|
env[NODE_COVERAGE_DIR_ENV] = COVERAGE_CONTAINER_DIR;
|
package/src/daemon.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { existsSync, promises as fs, readFileSync } from "node:fs";
|
|
|
23
23
|
import net from "node:net";
|
|
24
24
|
import path from "node:path";
|
|
25
25
|
import { pathToFileURL } from "node:url";
|
|
26
|
+
import { ENVIRONMENT_CA_PATH as CA_PATH } from "./environment-ca.js";
|
|
26
27
|
|
|
27
28
|
import {
|
|
28
29
|
assert,
|
|
@@ -43,12 +44,14 @@ import {
|
|
|
43
44
|
isEmptyReport,
|
|
44
45
|
readCoverageDir,
|
|
45
46
|
applyCoverageDelta,
|
|
47
|
+
walkProjectFiles,
|
|
46
48
|
type CoverageBundleRef,
|
|
47
49
|
type CoverageReport,
|
|
48
50
|
type ServiceCoverageCapture,
|
|
49
51
|
} from "./harness/coverage.js";
|
|
50
52
|
import { configureBrowserCoverage } from "./browser-coverage.js";
|
|
51
53
|
import {
|
|
54
|
+
INVENTORY_REPORT,
|
|
52
55
|
NODE_COVERAGE_MARKERS_SUBDIR,
|
|
53
56
|
NODE_COVERAGE_SCRIPTS_SUBDIR,
|
|
54
57
|
applyCoverageAdapters,
|
|
@@ -57,6 +60,7 @@ import {
|
|
|
57
60
|
validateCoverage,
|
|
58
61
|
type CoverageAdapter,
|
|
59
62
|
type CoverageCaptureContext,
|
|
63
|
+
type CoverageInventoryContext,
|
|
60
64
|
} from "./coverage.js";
|
|
61
65
|
import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
|
|
62
66
|
import { acquirePersistentBrowser, mobileKey } from "./browser.js";
|
|
@@ -300,7 +304,6 @@ const BUN_BIN = "/usr/local/bin/bun";
|
|
|
300
304
|
// (see base.rs::BASE_SETUP_SH). Bind-mounted into every service
|
|
301
305
|
// container so apps can verify HTTPS to the daemon's fakes, and
|
|
302
306
|
// referenced when we layer it into each image's system trust store.
|
|
303
|
-
const CA_PATH = process.env.SPECTEST_CA_PATH ?? "/etc/spectest/ca.crt";
|
|
304
307
|
const CA_KEY_PATH = process.env.SPECTEST_CA_KEY_PATH ?? "/etc/spectest/ca.key";
|
|
305
308
|
// Public roots ⧺ the spectest CA, materialized by `ensureCaBundle` and
|
|
306
309
|
// bind-mounted into every container. `SSL_CERT_FILE`/`REQUESTS_CA_BUNDLE`/
|
|
@@ -4124,7 +4127,62 @@ async function coverageRoot(service: string): Promise<string> {
|
|
|
4124
4127
|
return root;
|
|
4125
4128
|
}
|
|
4126
4129
|
|
|
4127
|
-
|
|
4130
|
+
/** Budget for one service's inventory adapters, all of them. */
|
|
4131
|
+
const COVERAGE_INVENTORY_TIMEOUT_MS = 120_000;
|
|
4132
|
+
|
|
4133
|
+
/**
|
|
4134
|
+
* Run one service's `inventory` adapters and write the union as the
|
|
4135
|
+
* `inventory.units` report — for the bring-up capture only: the
|
|
4136
|
+
* inventory is a property of the environment, not of a case. The file
|
|
4137
|
+
* is removed after the read so no later capture ships it again. A
|
|
4138
|
+
* failure is the capture's error, like a failing `capture`.
|
|
4139
|
+
*/
|
|
4140
|
+
async function writeCoverageInventory(svc: NamedService): Promise<string | undefined> {
|
|
4141
|
+
const adapters = coverageAdapters(svc.coverage).filter((a) => a.inventory);
|
|
4142
|
+
if (adapters.length === 0) return undefined;
|
|
4143
|
+
const ac = new AbortController();
|
|
4144
|
+
const timer = setTimeout(() => ac.abort(), COVERAGE_INVENTORY_TIMEOUT_MS);
|
|
4145
|
+
const reportDir = coverageHostDir(WORKSPACE, svc.name);
|
|
4146
|
+
const ctx: CoverageInventoryContext = {
|
|
4147
|
+
service: svc.name,
|
|
4148
|
+
signal: ac.signal,
|
|
4149
|
+
async exec(command: string) {
|
|
4150
|
+
const r = await docker(["exec", svc.name, "sh", "-c", command], COVERAGE_INVENTORY_TIMEOUT_MS);
|
|
4151
|
+
if (r.code !== 0) {
|
|
4152
|
+
const tail = capMiddle((r.stderr || r.stdout).trim(), 2000).value;
|
|
4153
|
+
throw new Error(`${JSON.stringify(command)} exited ${r.code}${tail ? `\n${tail}` : ""}`);
|
|
4154
|
+
}
|
|
4155
|
+
return { stdout: r.stdout, stderr: r.stderr };
|
|
4156
|
+
},
|
|
4157
|
+
projectFiles: () => walkProjectFiles(WORKSPACE),
|
|
4158
|
+
async readProjectFile(p: string) {
|
|
4159
|
+
try {
|
|
4160
|
+
return await fs.readFile(resolveProjectPath(p), "utf8");
|
|
4161
|
+
} catch {
|
|
4162
|
+
return null;
|
|
4163
|
+
}
|
|
4164
|
+
},
|
|
4165
|
+
};
|
|
4166
|
+
const units = new Set<string>();
|
|
4167
|
+
try {
|
|
4168
|
+
for (const a of adapters) {
|
|
4169
|
+
try {
|
|
4170
|
+
for (const u of await (a as Required<Pick<CoverageAdapter, "inventory">>).inventory(ctx)) units.add(u);
|
|
4171
|
+
} catch (err) {
|
|
4172
|
+
return `coverage adapter "${a.name}" on service "${svc.name}" inventory failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
4173
|
+
}
|
|
4174
|
+
}
|
|
4175
|
+
} finally {
|
|
4176
|
+
clearTimeout(timer);
|
|
4177
|
+
}
|
|
4178
|
+
const file = path.join(reportDir, INVENTORY_REPORT);
|
|
4179
|
+
await fs.mkdir(reportDir, { recursive: true });
|
|
4180
|
+
await fs.writeFile(file, [...units].sort().join("\n") + (units.size ? "\n" : ""));
|
|
4181
|
+
console.log(`[coverage] ${svc.name}: inventory of ${units.size} unit(s)`);
|
|
4182
|
+
return undefined;
|
|
4183
|
+
}
|
|
4184
|
+
|
|
4185
|
+
async function captureServiceCoverage(baseline = false): Promise<ServiceCoverageCapture[]> {
|
|
4128
4186
|
const l = loaded;
|
|
4129
4187
|
if (!l) return [];
|
|
4130
4188
|
const byName = new Map<string, NamedService>();
|
|
@@ -4135,7 +4193,14 @@ async function captureServiceCoverage(): Promise<ServiceCoverageCapture[]> {
|
|
|
4135
4193
|
services.map(async (svc): Promise<ServiceCoverageCapture> => {
|
|
4136
4194
|
const { error, deltaReports } = await runCoverageAdapters(svc);
|
|
4137
4195
|
if (error) return { service: svc.name, reports: [], error };
|
|
4196
|
+
if (baseline) {
|
|
4197
|
+
const invError = await writeCoverageInventory(svc);
|
|
4198
|
+
if (invError) return { service: svc.name, reports: [], error: invError };
|
|
4199
|
+
}
|
|
4138
4200
|
const read = await readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
|
|
4201
|
+
if (baseline) {
|
|
4202
|
+
await fs.rm(path.join(coverageHostDir(WORKSPACE, svc.name), INVENTORY_REPORT), { force: true }).catch(() => {});
|
|
4203
|
+
}
|
|
4139
4204
|
const root = await coverageRoot(svc.name);
|
|
4140
4205
|
if (root) read.root = root;
|
|
4141
4206
|
// What ships is what ran during this capture, whatever the tool
|
|
@@ -6516,7 +6581,7 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
6516
6581
|
// the control plane logs it.
|
|
6517
6582
|
let coverage: CoverageBundleRef | undefined;
|
|
6518
6583
|
try {
|
|
6519
|
-
const captures = await captureServiceCoverage();
|
|
6584
|
+
const captures = await captureServiceCoverage(true);
|
|
6520
6585
|
for (const c of captures) {
|
|
6521
6586
|
// After bring-up the code that started the service has run, so a
|
|
6522
6587
|
// report set that names no file at all is a misconfigured tool —
|