@specific.dev/spectest 0.59.3 → 0.60.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/browser-coverage.d.ts +12 -1
- package/dist/browser-coverage.js +44 -9
- package/dist/coverage.d.ts +97 -16
- package/dist/coverage.js +193 -26
- package/dist/daemon.js +39 -12
- package/dist/harness/browser-coverage.d.ts +8 -0
- package/dist/harness/browser-coverage.js +18 -1
- package/dist/harness/coverage.d.ts +35 -0
- package/dist/harness/coverage.js +157 -0
- package/package.json +1 -1
- package/src/browser-coverage.ts +44 -10
- package/src/coverage.test.ts +183 -0
- package/src/coverage.ts +218 -31
- package/src/daemon.ts +49 -13
- package/src/harness/browser-coverage.test.ts +17 -0
- package/src/harness/browser-coverage.ts +19 -1
- package/src/harness/coverage.test.ts +72 -0
- package/src/harness/coverage.ts +167 -0
package/src/coverage.test.ts
CHANGED
|
@@ -4,21 +4,32 @@ import { promises as fs } from "node:fs";
|
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import {
|
|
7
|
+
NODE_COVERAGE_C8_BIN,
|
|
8
|
+
NODE_COVERAGE_DUMPS_SUBDIR,
|
|
7
9
|
NODE_COVERAGE_HOOK,
|
|
8
10
|
NODE_COVERAGE_EMPTY_REPORT,
|
|
11
|
+
NODE_COVERAGE_LCOV,
|
|
12
|
+
NODE_COVERAGE_TOOLS_CONTAINER_DIR,
|
|
9
13
|
NODE_COVERAGE_HOOK_PATH,
|
|
10
14
|
NODE_COVERAGE_SOCKET_PREFIX,
|
|
15
|
+
absoluteLcovPaths,
|
|
11
16
|
applyCoverageAdapters,
|
|
12
17
|
appendEnvFlag,
|
|
13
18
|
browser,
|
|
14
19
|
command,
|
|
20
|
+
coverageReportsMode,
|
|
15
21
|
compactV8Document,
|
|
16
22
|
compactV8Reports,
|
|
23
|
+
COVERAGE_CONTAINER_DIR,
|
|
17
24
|
node,
|
|
25
|
+
nodeCoverageConvertCommand,
|
|
26
|
+
nodeCoverageToolsAvailable,
|
|
27
|
+
nodeCoverageToolsDir,
|
|
18
28
|
validateCoverage,
|
|
19
29
|
type CoverageCaptureContext,
|
|
20
30
|
} from "./coverage.js";
|
|
21
31
|
import type { ServiceConfig } from "./index.js";
|
|
32
|
+
import { foldBrowserCoverage } from "./browser-coverage.js";
|
|
22
33
|
|
|
23
34
|
const base: ServiceConfig = {
|
|
24
35
|
image: { type: "registry", reference: "node:20" },
|
|
@@ -36,6 +47,23 @@ describe("validateCoverage", () => {
|
|
|
36
47
|
expect(() => validateCoverage("a", [node()])).toThrow(/invalid `coverage` value/);
|
|
37
48
|
expect(() => validateCoverage("a", { adapters: [{ name: "" }] })).toThrow(/invalid coverage adapter/);
|
|
38
49
|
expect(() => validateCoverage("a", { adapters: [{ name: "x", capture: 1 }] })).toThrow(/invalid coverage adapter/);
|
|
50
|
+
validateCoverage("a", { reports: "delta" });
|
|
51
|
+
validateCoverage("a", { reports: "cumulative", adapters: [] });
|
|
52
|
+
expect(() => validateCoverage("a", { reports: "sometimes" })).toThrow(/invalid `coverage` value/);
|
|
53
|
+
expect(() => validateCoverage("a", { adapters: [{ name: "x", reports: "no" }] })).toThrow(/invalid coverage adapter/);
|
|
54
|
+
expect(() => validateCoverage("a", { adapters: [], extra: 1 })).toThrow(/invalid `coverage` value/);
|
|
55
|
+
expect(() => validateCoverage("a", {})).toThrow(/invalid `coverage` value/);
|
|
56
|
+
});
|
|
57
|
+
test("reports mode: cumulative unless declared; the shipped adapters write deltas", () => {
|
|
58
|
+
expect(coverageReportsMode(undefined)).toBe("cumulative");
|
|
59
|
+
expect(coverageReportsMode(true)).toBe("cumulative");
|
|
60
|
+
expect(coverageReportsMode({ adapters: [] })).toBe("cumulative");
|
|
61
|
+
expect(coverageReportsMode({ reports: "delta" })).toBe("delta");
|
|
62
|
+
expect(node().reports).toBe("delta");
|
|
63
|
+
expect(browser().reports).toBe("delta");
|
|
64
|
+
expect(command("x").reports).toBeUndefined();
|
|
65
|
+
const out = applyCoverageAdapters("api", { ...base, coverage: { reports: "delta", adapters: [command("x")] } });
|
|
66
|
+
expect(JSON.parse(JSON.stringify(out)).coverage).toEqual({ adapters: [{ adapter: "command" }], reports: "delta" });
|
|
39
67
|
});
|
|
40
68
|
});
|
|
41
69
|
|
|
@@ -77,6 +105,7 @@ describe("compactV8Document", () => {
|
|
|
77
105
|
{ scriptId: "1", url: "file:///app/src/a.ts", functions: [] },
|
|
78
106
|
{ scriptId: "2", url: "file:///app/node_modules/x/index.js", functions: [] },
|
|
79
107
|
{ scriptId: "3", url: "node:fs", functions: [] },
|
|
108
|
+
{ scriptId: "5", url: "file:///root/.cache/node/corepack/v1/pnpm/10.0.0/dist/pnpm.cjs", functions: [] },
|
|
80
109
|
{ scriptId: "4", url: "", functions: [] },
|
|
81
110
|
],
|
|
82
111
|
"source-map-cache": {
|
|
@@ -90,6 +119,21 @@ describe("compactV8Document", () => {
|
|
|
90
119
|
expect(Object.keys(out["source-map-cache"])).toEqual(["file:///app/src/a.ts"]);
|
|
91
120
|
expect(out["source-map-cache"]["file:///app/src/a.ts"].data).toEqual({ mappings: "AAAA", sources: ["a.ts"] });
|
|
92
121
|
});
|
|
122
|
+
test("keepSourcesContent keeps the sources for the in-container conversion", () => {
|
|
123
|
+
const out = compactV8Document(doc, { keepSourcesContent: true }) as typeof doc;
|
|
124
|
+
expect(out["source-map-cache"]["file:///app/src/a.ts"].data).toEqual({
|
|
125
|
+
mappings: "AAAA",
|
|
126
|
+
sources: ["a.ts"],
|
|
127
|
+
sourcesContent: ["x"],
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
test("absoluteLcovPaths makes c8's root-relative SF records container paths", () => {
|
|
131
|
+
expect(absoluteLcovPaths("TN:\nSF:app/src/a.ts\nDA:1,1\nend_of_record\nSF:/abs.js\nend_of_record\n")).toBe(
|
|
132
|
+
"TN:\nSF:/app/src/a.ts\nDA:1,1\nend_of_record\nSF:/abs.js\nend_of_record\n",
|
|
133
|
+
);
|
|
134
|
+
expect(absoluteLcovPaths("")).toBe("TN:\n");
|
|
135
|
+
expect(absoluteLcovPaths(" \n")).toBe("TN:\n");
|
|
136
|
+
});
|
|
93
137
|
test("a dump with no app script keeps an empty result and no cache", () => {
|
|
94
138
|
const out = compactV8Document({ result: [doc.result[2]], "source-map-cache": doc["source-map-cache"] });
|
|
95
139
|
expect(out.result).toEqual([]);
|
|
@@ -131,6 +175,21 @@ describe("browserCoverage", () => {
|
|
|
131
175
|
await browser().capture!(fakeCtx(dir));
|
|
132
176
|
expect(await fs.readFile(path.join(dir, "browser.lcov"), "utf8")).toBe("TN:\n");
|
|
133
177
|
});
|
|
178
|
+
test("capture takes the totals and clears them: the next capture is a delta", async () => {
|
|
179
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-cov-"));
|
|
180
|
+
foldBrowserCoverage("api", new Map([["src/a.ts", new Map([[1, 2]])]]));
|
|
181
|
+
await browser().capture!(fakeCtx(dir));
|
|
182
|
+
const first = await fs.readFile(path.join(dir, "browser.lcov"), "utf8");
|
|
183
|
+
expect(first).toContain("SF:src/a.ts");
|
|
184
|
+
expect(first).toContain("DA:1,2");
|
|
185
|
+
await browser().capture!(fakeCtx(dir));
|
|
186
|
+
expect(await fs.readFile(path.join(dir, "browser.lcov"), "utf8")).toBe("TN:\n");
|
|
187
|
+
foldBrowserCoverage("api", new Map([["src/b.ts", new Map([[3, 1]])]]));
|
|
188
|
+
await browser().capture!(fakeCtx(dir));
|
|
189
|
+
const third = await fs.readFile(path.join(dir, "browser.lcov"), "utf8");
|
|
190
|
+
expect(third).toContain("SF:src/b.ts");
|
|
191
|
+
expect(third).not.toContain("SF:src/a.ts");
|
|
192
|
+
});
|
|
134
193
|
});
|
|
135
194
|
|
|
136
195
|
// The node hook against a real `node` (the box has one; skipped where it
|
|
@@ -256,6 +315,34 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
|
|
|
256
315
|
expect(doc.result.map((x) => x.url)).toEqual([`file://${path.join(dir, "cli.js")}`]); // compacted
|
|
257
316
|
});
|
|
258
317
|
|
|
318
|
+
test("a worker thread does not bind the socket; the main thread's dump is the app's", async () => {
|
|
319
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
|
|
320
|
+
const hook = path.join(dir, "coverage-hook.cjs");
|
|
321
|
+
await fs.writeFile(hook, NODE_COVERAGE_HOOK);
|
|
322
|
+
await fs.writeFile(
|
|
323
|
+
path.join(dir, "server.js"),
|
|
324
|
+
"function appWork() { return 1 } appWork();\n" +
|
|
325
|
+
"new (require('node:worker_threads').Worker)('setInterval(() => {}, 1000)', { eval: true });\n" +
|
|
326
|
+
SERVER_JS,
|
|
327
|
+
);
|
|
328
|
+
const child = spawn("node", [path.join(dir, "server.js")], {
|
|
329
|
+
env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
|
|
330
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
331
|
+
});
|
|
332
|
+
await new Promise<void>((resolve) => child.stdout!.once("data", () => resolve()));
|
|
333
|
+
try {
|
|
334
|
+
await new Promise((r) => setTimeout(r, 300)); // let the worker thread start
|
|
335
|
+
expect((await socketsIn(dir)).length).toBe(1);
|
|
336
|
+
await node().capture!(fakeCtx(dir));
|
|
337
|
+
const reports = (await reportsIn(dir)).filter((n) => n !== NODE_COVERAGE_EMPTY_REPORT);
|
|
338
|
+
expect(reports.length).toBe(1);
|
|
339
|
+
const doc = JSON.parse(await fs.readFile(path.join(dir, reports[0]!), "utf8")) as { result: { url: string }[] };
|
|
340
|
+
expect(doc.result.some((s) => s.url.endsWith("/server.js"))).toBe(true);
|
|
341
|
+
} finally {
|
|
342
|
+
child.kill("SIGKILL");
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
259
346
|
test("a stale socket left by a killed process is removed at capture", async () => {
|
|
260
347
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
|
|
261
348
|
const first = await start(dir, "server.js", 1);
|
|
@@ -280,3 +367,99 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
|
|
|
280
367
|
expect(await reportsIn(dir)).toEqual([NODE_COVERAGE_EMPTY_REPORT]);
|
|
281
368
|
});
|
|
282
369
|
});
|
|
370
|
+
|
|
371
|
+
// The conversion to lcov against a real `node` and a real `c8` tree.
|
|
372
|
+
// Golden ships the tree at /opt/spectest/coverage-tools; on a dev box
|
|
373
|
+
// point SPECTEST_C8_TEST_DIR at one (`bun install --linker=hoisted` of
|
|
374
|
+
// `c8` in a scratch dir). Skipped where either is missing. The exec here
|
|
375
|
+
// is the container's `sh -c` with the container paths rewritten to this
|
|
376
|
+
// directory: the command is exactly what a container runs.
|
|
377
|
+
const c8TestDir = process.env.SPECTEST_C8_TEST_DIR || "/opt/spectest/coverage-tools";
|
|
378
|
+
const hasC8 = await fs
|
|
379
|
+
.access(path.join(c8TestDir, NODE_COVERAGE_C8_BIN))
|
|
380
|
+
.then(() => true)
|
|
381
|
+
.catch(() => false);
|
|
382
|
+
|
|
383
|
+
describe.if(hasNode && hasC8)("nodeCoverage → lcov (real node + c8)", () => {
|
|
384
|
+
function c8Ctx(dir: string): CoverageCaptureContext {
|
|
385
|
+
return {
|
|
386
|
+
...fakeCtx(dir),
|
|
387
|
+
async exec(command: string) {
|
|
388
|
+
const local = command
|
|
389
|
+
.replaceAll(NODE_COVERAGE_TOOLS_CONTAINER_DIR, c8TestDir)
|
|
390
|
+
.replaceAll(COVERAGE_CONTAINER_DIR, dir);
|
|
391
|
+
return new Promise((resolve, reject) => {
|
|
392
|
+
const p = spawn("sh", ["-c", local], { stdio: ["ignore", "pipe", "pipe"] });
|
|
393
|
+
let out = "";
|
|
394
|
+
p.stdout!.on("data", (b: Buffer) => (out += b));
|
|
395
|
+
p.stderr!.on("data", (b: Buffer) => (out += b));
|
|
396
|
+
p.on("exit", (code) => (code === 0 ? resolve({ stdout: out, stderr: "" }) : reject(new Error(out))));
|
|
397
|
+
});
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
async function withTools<T>(f: () => Promise<T>): Promise<T> {
|
|
402
|
+
const prior = process.env.SPECTEST_COVERAGE_TOOLS_DIR;
|
|
403
|
+
process.env.SPECTEST_COVERAGE_TOOLS_DIR = c8TestDir;
|
|
404
|
+
try {
|
|
405
|
+
return await f();
|
|
406
|
+
} finally {
|
|
407
|
+
if (prior === undefined) delete process.env.SPECTEST_COVERAGE_TOOLS_DIR;
|
|
408
|
+
else process.env.SPECTEST_COVERAGE_TOOLS_DIR = prior;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
test("the tools dir is found through the override, and the command is the container's", async () => {
|
|
413
|
+
await withTools(async () => {
|
|
414
|
+
expect(nodeCoverageToolsDir()).toBe(c8TestDir);
|
|
415
|
+
expect(await nodeCoverageToolsAvailable()).toBe(true);
|
|
416
|
+
});
|
|
417
|
+
expect(nodeCoverageConvertCommand()).toBe(
|
|
418
|
+
`cd / && env -u NODE_V8_COVERAGE -u NODE_OPTIONS node ${NODE_COVERAGE_TOOLS_CONTAINER_DIR}/${NODE_COVERAGE_C8_BIN} report --temp-directory ${COVERAGE_CONTAINER_DIR}/${NODE_COVERAGE_DUMPS_SUBDIR} --reporter=lcovonly --reports-dir ${COVERAGE_CONTAINER_DIR}/.c8`,
|
|
419
|
+
);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("dumps are converted to node.lcov, the delta of this capture; the raw dumps never ship", async () => {
|
|
423
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covc8-"));
|
|
424
|
+
const hook = path.join(dir, "coverage-hook.cjs");
|
|
425
|
+
await fs.writeFile(hook, NODE_COVERAGE_HOOK);
|
|
426
|
+
await fs.writeFile(path.join(dir, "cli.js"), "function used() { return 1 }\nfunction unused() { return 2 }\nused();\n");
|
|
427
|
+
await withTools(async () => {
|
|
428
|
+
// Nothing ran yet: an empty lcov, no exec.
|
|
429
|
+
await node().capture!(c8Ctx(dir));
|
|
430
|
+
expect(await fs.readFile(path.join(dir, NODE_COVERAGE_LCOV), "utf8")).toBe("TN:\n");
|
|
431
|
+
const cli = spawn("node", [path.join(dir, "cli.js")], {
|
|
432
|
+
env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
|
|
433
|
+
stdio: "ignore",
|
|
434
|
+
});
|
|
435
|
+
expect(await new Promise<number | null>((r) => cli.on("exit", r))).toBe(0);
|
|
436
|
+
await node().capture!(c8Ctx(dir));
|
|
437
|
+
const lcov = await fs.readFile(path.join(dir, NODE_COVERAGE_LCOV), "utf8");
|
|
438
|
+
expect(lcov).toContain(`SF:${path.join(dir, "cli.js")}\n`);
|
|
439
|
+
expect(lcov).toMatch(/FNDA:1,used/);
|
|
440
|
+
expect(lcov).toMatch(/FNDA:0,unused/);
|
|
441
|
+
// The hook itself is not the app's code.
|
|
442
|
+
expect(lcov).not.toContain("coverage-hook.cjs");
|
|
443
|
+
// Raw dumps moved under the hidden .v8/ dir: the visible reports
|
|
444
|
+
// are the lcov alone.
|
|
445
|
+
const visible = (await fs.readdir(dir)).filter((n) => !n.startsWith(".") && n.startsWith("coverage-") && n.endsWith(".json"));
|
|
446
|
+
expect(visible).toEqual([]);
|
|
447
|
+
const dumps = (await fs.readdir(path.join(dir, NODE_COVERAGE_DUMPS_SUBDIR))).filter((n) => n.endsWith(".json"));
|
|
448
|
+
expect(dumps.length).toBe(1);
|
|
449
|
+
// Nothing new since: the next capture is an empty report, not the
|
|
450
|
+
// previous one again — the lcov is a delta.
|
|
451
|
+
await node().capture!(c8Ctx(dir));
|
|
452
|
+
expect(await fs.readFile(path.join(dir, NODE_COVERAGE_LCOV), "utf8")).toBe("TN:\n");
|
|
453
|
+
// A new run is a new delta on its own: counts of 1, not 2.
|
|
454
|
+
const again = spawn("node", [path.join(dir, "cli.js")], {
|
|
455
|
+
env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
|
|
456
|
+
stdio: "ignore",
|
|
457
|
+
});
|
|
458
|
+
await new Promise((r) => again.on("exit", r));
|
|
459
|
+
await node().capture!(c8Ctx(dir));
|
|
460
|
+
const lcov2 = await fs.readFile(path.join(dir, NODE_COVERAGE_LCOV), "utf8");
|
|
461
|
+
expect(lcov2).toMatch(/FNDA:1,used/);
|
|
462
|
+
expect((await fs.readdir(path.join(dir, NODE_COVERAGE_DUMPS_SUBDIR))).filter((n) => n.endsWith(".json")).length).toBe(1);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
});
|
package/src/coverage.ts
CHANGED
|
@@ -14,8 +14,11 @@
|
|
|
14
14
|
//
|
|
15
15
|
// After every adapter ran, the harness reads the directory the same way
|
|
16
16
|
// it does for `coverage: true`: at least one well-formed report (lcov or
|
|
17
|
-
// V8 JSON), shipped verbatim.
|
|
18
|
-
//
|
|
17
|
+
// V8 JSON), shipped verbatim. The control plane derives what it needs
|
|
18
|
+
// from the stored bytes. One adapter converts: `node()` turns the V8
|
|
19
|
+
// dumps into lcov inside the container (with `c8`, which golden ships),
|
|
20
|
+
// because the conversion needs the sources and the source maps, which
|
|
21
|
+
// only the container has.
|
|
19
22
|
//
|
|
20
23
|
// Imported from `@specific.dev/spectest/coverage`. The three shipped
|
|
21
24
|
// adapters: `node()` (V8 JSON through a hook spectest mounts), `browser()`
|
|
@@ -26,10 +29,10 @@
|
|
|
26
29
|
// coverage: { adapters: [coverage.node(), coverage.browser()] }
|
|
27
30
|
|
|
28
31
|
import type { ServiceConfig } from "./index.js";
|
|
29
|
-
import {
|
|
32
|
+
import { harvestAllBrowserCoverage, takeBrowserCoverageReports } from "./browser-coverage.js";
|
|
30
33
|
|
|
31
|
-
import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
|
|
32
|
-
export { COVERAGE_CONTAINER_DIR };
|
|
34
|
+
import { COVERAGE_CONTAINER_DIR, type CoverageReportsMode } from "./harness/coverage.js";
|
|
35
|
+
export { COVERAGE_CONTAINER_DIR, type CoverageReportsMode };
|
|
33
36
|
|
|
34
37
|
/** Where `node()` mounts its hook inside the container. */
|
|
35
38
|
export const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
|
|
@@ -41,6 +44,27 @@ export const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
|
|
|
41
44
|
* to the coverage dir: `.ctl-<pid>`. */
|
|
42
45
|
export const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
|
|
43
46
|
|
|
47
|
+
/** Where golden keeps the conversion tools (`c8` and its dependencies),
|
|
48
|
+
* seen from the VM. `SPECTEST_COVERAGE_TOOLS_DIR` overrides it (tests).
|
|
49
|
+
* The daemon bind-mounts it read-only into every covered container at
|
|
50
|
+
* {@link NODE_COVERAGE_TOOLS_CONTAINER_DIR} when it exists. */
|
|
51
|
+
export const NODE_COVERAGE_TOOLS_DIR = "/opt/spectest/coverage-tools";
|
|
52
|
+
|
|
53
|
+
/** Where the container sees {@link NODE_COVERAGE_TOOLS_DIR}. */
|
|
54
|
+
export const NODE_COVERAGE_TOOLS_CONTAINER_DIR = "/spectest/coverage-tools";
|
|
55
|
+
|
|
56
|
+
/** The `c8` entry point, relative to the tools dir. */
|
|
57
|
+
export const NODE_COVERAGE_C8_BIN = "node_modules/c8/bin/c8.js";
|
|
58
|
+
|
|
59
|
+
/** The hidden subdirectory of the coverage dir where `node()` parks the
|
|
60
|
+
* V8 dumps it converts at one capture. Hidden, so the harness never
|
|
61
|
+
* ships them: the lcov is the report. Emptied at every capture — the
|
|
62
|
+
* lcov is this capture's delta, see {@link convertV8ReportsToLcov}. */
|
|
63
|
+
export const NODE_COVERAGE_DUMPS_SUBDIR = ".v8";
|
|
64
|
+
|
|
65
|
+
/** The lcov `node()` writes: the dumps of this capture, merged. */
|
|
66
|
+
export const NODE_COVERAGE_LCOV = "node.lcov";
|
|
67
|
+
|
|
44
68
|
/** What `configure` learns about the service it rewrites. */
|
|
45
69
|
export interface CoverageConfigureInfo {
|
|
46
70
|
/** The services-map key. */
|
|
@@ -87,6 +111,15 @@ export interface CoverageCaptureContext {
|
|
|
87
111
|
export interface CoverageAdapter {
|
|
88
112
|
/** Names the adapter in errors (`coverage adapter "node" on service …`). */
|
|
89
113
|
name: string;
|
|
114
|
+
/**
|
|
115
|
+
* How the reports this adapter writes (through `ctx.writeReport`)
|
|
116
|
+
* count: `"delta"` — what ran since the previous capture, the tool
|
|
117
|
+
* resets its own counters; `"cumulative"` (the default) — since
|
|
118
|
+
* process start, and spectest subtracts the previous capture. Reports
|
|
119
|
+
* the adapter makes appear some other way (`command`) follow the
|
|
120
|
+
* service's own `reports`.
|
|
121
|
+
*/
|
|
122
|
+
reports?: CoverageReportsMode;
|
|
90
123
|
/**
|
|
91
124
|
* Rewrite the service at config time. Must be pure and deterministic
|
|
92
125
|
* (the result is hashed into the warm-template key) and must **append**
|
|
@@ -99,9 +132,18 @@ export interface CoverageAdapter {
|
|
|
99
132
|
capture?(ctx: CoverageCaptureContext): void | Promise<void>;
|
|
100
133
|
}
|
|
101
134
|
|
|
102
|
-
/** `ServiceConfig.coverage`: the program writes its own reports,
|
|
103
|
-
* list of adapters gets them out
|
|
104
|
-
|
|
135
|
+
/** `ServiceConfig.coverage`: the program writes its own reports (`true`),
|
|
136
|
+
* or a list of adapters gets them out, and/or how the reports the
|
|
137
|
+
* program itself writes count (`reports`, default `"cumulative"`). */
|
|
138
|
+
export type ServiceCoverage =
|
|
139
|
+
| true
|
|
140
|
+
| { adapters?: readonly CoverageAdapter[]; reports?: CoverageReportsMode };
|
|
141
|
+
|
|
142
|
+
/** The `reports` mode of a `coverage` value, for the reports no adapter
|
|
143
|
+
* wrote (the program's own, a `command`'s). */
|
|
144
|
+
export function coverageReportsMode(cov: ServiceCoverage | undefined): CoverageReportsMode {
|
|
145
|
+
return typeof cov === "object" && cov !== null && cov.reports ? cov.reports : "cumulative";
|
|
146
|
+
}
|
|
105
147
|
|
|
106
148
|
/** Type an inline adapter. Identity at runtime. */
|
|
107
149
|
export function defineAdapter(adapter: CoverageAdapter): CoverageAdapter {
|
|
@@ -112,14 +154,22 @@ export function defineAdapter(adapter: CoverageAdapter): CoverageAdapter {
|
|
|
112
154
|
* the same rule to a runtime `startService` spec. */
|
|
113
155
|
export function validateCoverage(service: string, cov: unknown): void {
|
|
114
156
|
if (cov === undefined || cov === true) return;
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
157
|
+
const obj = typeof cov === "object" && cov !== null ? (cov as Record<string, unknown>) : null;
|
|
158
|
+
const adapters = obj?.adapters;
|
|
159
|
+
const reports = obj?.reports;
|
|
160
|
+
const knownKeys = obj !== null && Object.keys(obj).every((k) => k === "adapters" || k === "reports");
|
|
161
|
+
const okReports = reports === undefined || reports === "delta" || reports === "cumulative";
|
|
162
|
+
const okAdapters = adapters === undefined || Array.isArray(adapters);
|
|
163
|
+
if (obj !== null && knownKeys && okReports && okAdapters && (adapters !== undefined || reports !== undefined)) {
|
|
164
|
+
for (const a of (adapters as unknown[] | undefined) ?? []) {
|
|
118
165
|
const ok =
|
|
119
166
|
typeof a === "object" &&
|
|
120
167
|
a !== null &&
|
|
121
168
|
typeof (a as CoverageAdapter).name === "string" &&
|
|
122
169
|
(a as CoverageAdapter).name.length > 0 &&
|
|
170
|
+
((a as CoverageAdapter).reports === undefined ||
|
|
171
|
+
(a as CoverageAdapter).reports === "delta" ||
|
|
172
|
+
(a as CoverageAdapter).reports === "cumulative") &&
|
|
123
173
|
["configure", "load", "capture"].every(
|
|
124
174
|
(k) =>
|
|
125
175
|
(a as Record<string, unknown>)[k] === undefined ||
|
|
@@ -127,20 +177,20 @@ export function validateCoverage(service: string, cov: unknown): void {
|
|
|
127
177
|
);
|
|
128
178
|
if (!ok) {
|
|
129
179
|
throw new Error(
|
|
130
|
-
`service "${service}" has an invalid coverage adapter — an adapter is \`{ name, configure?, load?, capture? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`,
|
|
180
|
+
`service "${service}" has an invalid coverage adapter — an adapter is \`{ name, reports?, configure?, load?, capture? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`,
|
|
131
181
|
);
|
|
132
182
|
}
|
|
133
183
|
}
|
|
134
184
|
return;
|
|
135
185
|
}
|
|
136
186
|
throw new Error(
|
|
137
|
-
`service "${service}" has an invalid \`coverage\` value — use \`true\` (the program writes its own reports)
|
|
187
|
+
`service "${service}" has an invalid \`coverage\` value — use \`true\` (the program writes its own reports), \`{ adapters: [...] }\` from @specific.dev/spectest/coverage (\`node()\`, \`browser()\`, \`command("…")\`), and/or \`{ reports: "delta" | "cumulative" }\``,
|
|
138
188
|
);
|
|
139
189
|
}
|
|
140
190
|
|
|
141
191
|
/** The adapters of a `coverage` value (`true` has none). */
|
|
142
192
|
export function coverageAdapters(cov: ServiceCoverage | undefined): readonly CoverageAdapter[] {
|
|
143
|
-
return typeof cov === "object" && cov !== null ? cov.adapters : [];
|
|
193
|
+
return typeof cov === "object" && cov !== null ? (cov.adapters ?? []) : [];
|
|
144
194
|
}
|
|
145
195
|
|
|
146
196
|
/**
|
|
@@ -158,7 +208,8 @@ export function applyCoverageAdapters<S extends ServiceConfig>(key: string, svc:
|
|
|
158
208
|
for (const a of adapters) {
|
|
159
209
|
if (a.configure) out = a.configure(out, { key });
|
|
160
210
|
}
|
|
161
|
-
const
|
|
211
|
+
const mode = (svc.coverage as { reports?: CoverageReportsMode }).reports;
|
|
212
|
+
const wire = { adapters: adapters.map((a) => withWireName(a)), ...(mode ? { reports: mode } : {}) };
|
|
162
213
|
return { ...out, coverage: wire } as unknown as S;
|
|
163
214
|
}
|
|
164
215
|
|
|
@@ -193,12 +244,20 @@ export function appendEnvFlag(
|
|
|
193
244
|
* wrapper dumped the wrapper and silently never the server (reported by
|
|
194
245
|
* a user 2026-08-27). At capture spectest asks every live socket and
|
|
195
246
|
* unlinks the stale ones. Unref'd, so a short-lived process still exits.
|
|
247
|
+
* Main thread only: a worker thread inherits `NODE_OPTIONS`, and tsx's
|
|
248
|
+
* ESM loader thread bound the shared per-pid path last, so every
|
|
249
|
+
* `--import tsx` server answered with the loader's coverage — the app's
|
|
250
|
+
* main thread was never dumped (found in the same user's first full run).
|
|
196
251
|
* No signal is used: signals are claimed by frameworks (SIGUSR2 stops a
|
|
197
252
|
* Temporal worker, restarts nodemon), a socket is nobody's.
|
|
198
253
|
*/
|
|
199
254
|
export const NODE_COVERAGE_HOOK = `"use strict";
|
|
200
255
|
// spectest coverage hook (coverage.node() adapter). See \`spectest docs /services/coverage\`.
|
|
201
|
-
|
|
256
|
+
// Main thread only: worker threads inherit NODE_OPTIONS and would bind
|
|
257
|
+
// the same per-pid socket last — tsx's ESM loader runs on one, and it
|
|
258
|
+
// stole the socket from every \`--import tsx\` server, so a dump was the
|
|
259
|
+
// loader's isolate and never the app's.
|
|
260
|
+
if (process.env.NODE_V8_COVERAGE && require("node:worker_threads").isMainThread) {
|
|
202
261
|
const net = require("node:net");
|
|
203
262
|
const fs = require("node:fs");
|
|
204
263
|
const v8 = require("node:v8");
|
|
@@ -235,15 +294,30 @@ if (process.env.NODE_V8_COVERAGE) {
|
|
|
235
294
|
* container then writes V8 coverage JSON when it exits) and `--require`s
|
|
236
295
|
* a hook that lets spectest ask every live node process for a dump at
|
|
237
296
|
* capture time — the server, and any wrapper it sits behind (`pnpm exec`,
|
|
238
|
-
* `tsx`). Nothing
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
* `
|
|
242
|
-
*
|
|
297
|
+
* `tsx`). Nothing for the app to write.
|
|
298
|
+
*
|
|
299
|
+
* At capture the dumps are **converted to lcov inside the container**
|
|
300
|
+
* with `c8` (`c8 report`), and `node.lcov` is the one report that
|
|
301
|
+
* ships. It holds **what ran since the previous capture**: V8 resets its
|
|
302
|
+
* counters at every `takeCoverage()`, so a live server's dump after a
|
|
303
|
+
* test is that test's own execution — the boot dump (everything loaded,
|
|
304
|
+
* which at file level is everything "ran") lands in the bring-up
|
|
305
|
+
* capture and nowhere else. A run's total is the union over its cases
|
|
306
|
+
* on the server; a test's set is the test's own. Merging every dump on
|
|
307
|
+
* the branch instead was tried first and would have made a long-lived
|
|
308
|
+
* server's per-test set the boot set, every time. The conversion runs
|
|
309
|
+
* in the container, not in the harness, because `v8-to-istanbul` needs
|
|
310
|
+
* the script text (for byte offsets → lines) and the source maps
|
|
311
|
+
* (`--enable-source-maps`, or a `sourceMappingURL` next to each file),
|
|
312
|
+
* and only the container has them. `c8` comes from golden
|
|
313
|
+
* ({@link NODE_COVERAGE_TOOLS_DIR}); on a guest without it the raw V8
|
|
314
|
+
* documents ship instead, compacted ({@link compactV8Document}), as they
|
|
315
|
+
* did before SDK 0.60.
|
|
243
316
|
*/
|
|
244
317
|
export function node(): CoverageAdapter {
|
|
245
318
|
return {
|
|
246
319
|
name: "node",
|
|
320
|
+
reports: "delta",
|
|
247
321
|
configure(svc) {
|
|
248
322
|
const env = appendEnvFlag(svc.env, "NODE_OPTIONS", `--require ${NODE_COVERAGE_HOOK_PATH}`);
|
|
249
323
|
env.NODE_V8_COVERAGE = COVERAGE_CONTAINER_DIR;
|
|
@@ -258,12 +332,16 @@ export function node(): CoverageAdapter {
|
|
|
258
332
|
// here; a service whose node processes are short-lived (a CLI run
|
|
259
333
|
// by `ctx.exec` from a `sleep infinity` container) has no socket to
|
|
260
334
|
// answer and its coverage is the exit-time dumps already on disk.
|
|
261
|
-
// So no live process is not an error. No
|
|
262
|
-
// has run yet on this branch — is recorded as an empty
|
|
335
|
+
// So no live process is not an error. No dump at all — nothing
|
|
336
|
+
// has run yet on this branch — is recorded as an empty report
|
|
263
337
|
// rather than failed: an empty report is a report. A server whose
|
|
264
338
|
// hook never loaded (NODE_OPTIONS not reaching it) then shows as
|
|
265
339
|
// empty reports after bring-up, which the boot log warns about.
|
|
266
340
|
await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
|
|
341
|
+
if (await nodeCoverageToolsAvailable()) {
|
|
342
|
+
await convertV8ReportsToLcov(ctx);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
267
345
|
await compactV8Reports(ctx.reportDir);
|
|
268
346
|
if (!(await hasV8Reports(ctx.reportDir))) {
|
|
269
347
|
await ctx.writeReport(NODE_COVERAGE_EMPTY_REPORT, '{"result":[]}\n');
|
|
@@ -272,6 +350,101 @@ export function node(): CoverageAdapter {
|
|
|
272
350
|
};
|
|
273
351
|
}
|
|
274
352
|
|
|
353
|
+
/** The VM-side tools dir, after the test override. */
|
|
354
|
+
export function nodeCoverageToolsDir(): string {
|
|
355
|
+
return process.env.SPECTEST_COVERAGE_TOOLS_DIR || NODE_COVERAGE_TOOLS_DIR;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** True when golden (or the override) ships `c8`. The daemon mounts the
|
|
359
|
+
* directory into covered containers on the same check, so what the VM
|
|
360
|
+
* has is what the container sees. */
|
|
361
|
+
export async function nodeCoverageToolsAvailable(): Promise<boolean> {
|
|
362
|
+
const fs = await import("node:fs/promises");
|
|
363
|
+
const path = await import("node:path");
|
|
364
|
+
try {
|
|
365
|
+
await fs.access(path.join(nodeCoverageToolsDir(), NODE_COVERAGE_C8_BIN));
|
|
366
|
+
return true;
|
|
367
|
+
} catch {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** The command `convertV8ReportsToLcov` runs in the container. From `/`,
|
|
373
|
+
* so lcov paths come out relative to the root (made absolute after). */
|
|
374
|
+
export function nodeCoverageConvertCommand(): string {
|
|
375
|
+
const c8 = `${NODE_COVERAGE_TOOLS_CONTAINER_DIR}/${NODE_COVERAGE_C8_BIN}`;
|
|
376
|
+
const dumps = `${COVERAGE_CONTAINER_DIR}/${NODE_COVERAGE_DUMPS_SUBDIR}`;
|
|
377
|
+
const out = `${COVERAGE_CONTAINER_DIR}/.c8`;
|
|
378
|
+
// `env -u`: the converter is a node process in a container whose env
|
|
379
|
+
// carries NODE_V8_COVERAGE and the `--require` hook, so without this it
|
|
380
|
+
// would load the hook and write a dump of itself at exit — a raw V8
|
|
381
|
+
// document at the root of the dir, shipped as a report (seen on the
|
|
382
|
+
// first run of hello-node under 0.60).
|
|
383
|
+
return `cd / && env -u NODE_V8_COVERAGE -u NODE_OPTIONS node ${c8} report --temp-directory ${dumps} --reporter=lcovonly --reports-dir ${out}`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Convert the V8 dumps written since the previous capture to one lcov,
|
|
388
|
+
* `node.lcov`. New dumps at the root of the dir are compacted to the
|
|
389
|
+
* app's scripts (their maps and `sourcesContent` kept — `v8-to-istanbul`
|
|
390
|
+
* reads the original sources from there when they are not on disk, the
|
|
391
|
+
* usual shape of a multi-stage image) and moved into `.v8/`, which is
|
|
392
|
+
* emptied first; `c8 report` merges that directory. So the lcov is this
|
|
393
|
+
* capture's delta: what the live processes ran since their last take,
|
|
394
|
+
* plus every process that exited since. No new dump ⇒ `TN:` alone,
|
|
395
|
+
* nothing ran. The `SF:` paths come out relative to `/` and are made
|
|
396
|
+
* absolute, so they read as container paths like every other tool's.
|
|
397
|
+
*/
|
|
398
|
+
export async function convertV8ReportsToLcov(ctx: CoverageCaptureContext): Promise<void> {
|
|
399
|
+
const fs = await import("node:fs/promises");
|
|
400
|
+
const path = await import("node:path");
|
|
401
|
+
const dumpsDir = path.join(ctx.reportDir, NODE_COVERAGE_DUMPS_SUBDIR);
|
|
402
|
+
await fs.rm(dumpsDir, { recursive: true, force: true });
|
|
403
|
+
await fs.mkdir(dumpsDir, { recursive: true });
|
|
404
|
+
await fs.chmod(dumpsDir, 0o755);
|
|
405
|
+
let dumps = 0;
|
|
406
|
+
for (const name of await fs.readdir(ctx.reportDir)) {
|
|
407
|
+
if (!name.startsWith("coverage-") || !name.endsWith(".json")) continue;
|
|
408
|
+
const file = path.join(ctx.reportDir, name);
|
|
409
|
+
if (name === NODE_COVERAGE_EMPTY_REPORT) {
|
|
410
|
+
await fs.unlink(file).catch(() => {}); // a pre-0.60 placeholder
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
let doc: Record<string, unknown>;
|
|
414
|
+
try {
|
|
415
|
+
doc = JSON.parse(await fs.readFile(file, "utf8")) as Record<string, unknown>;
|
|
416
|
+
} catch {
|
|
417
|
+
continue; // a dump mid-write; the next capture takes it
|
|
418
|
+
}
|
|
419
|
+
if (!Array.isArray(doc.result)) continue;
|
|
420
|
+
const target = path.join(dumpsDir, name);
|
|
421
|
+
await fs.writeFile(`${target}.tmp`, JSON.stringify(compactV8Document(doc, { keepSourcesContent: true })));
|
|
422
|
+
await fs.chmod(`${target}.tmp`, 0o644);
|
|
423
|
+
await fs.rename(`${target}.tmp`, target);
|
|
424
|
+
await fs.unlink(file);
|
|
425
|
+
dumps++;
|
|
426
|
+
}
|
|
427
|
+
if (dumps === 0) {
|
|
428
|
+
await ctx.writeReport(NODE_COVERAGE_LCOV, "TN:\n");
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
await ctx.exec(nodeCoverageConvertCommand());
|
|
432
|
+
let lcov: string;
|
|
433
|
+
try {
|
|
434
|
+
lcov = await fs.readFile(path.join(ctx.reportDir, ".c8", "lcov.info"), "utf8");
|
|
435
|
+
} catch (err) {
|
|
436
|
+
throw new Error(`c8 wrote no lcov.info: ${err instanceof Error ? err.message : String(err)}`);
|
|
437
|
+
}
|
|
438
|
+
await ctx.writeReport(NODE_COVERAGE_LCOV, absoluteLcovPaths(lcov));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** `SF:` records relative to `/` (what `c8` run from `/` writes) made
|
|
442
|
+
* absolute. An lcov with nothing in it becomes the empty report. */
|
|
443
|
+
export function absoluteLcovPaths(lcov: string): string {
|
|
444
|
+
if (lcov.trim().length === 0) return "TN:\n";
|
|
445
|
+
return lcov.replace(/^SF:(?!\/)/gm, "SF:/");
|
|
446
|
+
}
|
|
447
|
+
|
|
275
448
|
/**
|
|
276
449
|
* Ask every node process that holds a hook socket in `dir` for a dump.
|
|
277
450
|
* A socket nobody answers (its process died without unlinking — SIGKILL,
|
|
@@ -345,6 +518,9 @@ export function isAppScriptUrl(url: string): boolean {
|
|
|
345
518
|
return (
|
|
346
519
|
url.startsWith("file://") &&
|
|
347
520
|
!url.includes("/node_modules/") &&
|
|
521
|
+
// Package managers run from a cache, not node_modules: corepack's pnpm
|
|
522
|
+
// is `/root/.cache/node/corepack/…`, 2 MiB of dump per `pnpm run`.
|
|
523
|
+
!url.includes("/.cache/") &&
|
|
348
524
|
!url.endsWith("/" + NODE_COVERAGE_HOOK_PATH.split("/").pop())
|
|
349
525
|
);
|
|
350
526
|
}
|
|
@@ -358,10 +534,14 @@ export function isAppScriptUrl(url: string): boolean {
|
|
|
358
534
|
* per capture, of which the app's own coverage was under 0.5 MiB, and a
|
|
359
535
|
* suite that hit the 64 MiB cap on its third test. Kept: `file://`
|
|
360
536
|
* scripts outside `node_modules`, the map entries of exactly those
|
|
361
|
-
* scripts, minus `sourcesContent` (the sources are the repo)
|
|
362
|
-
*
|
|
537
|
+
* scripts, minus `sourcesContent` (the sources are the repo) unless
|
|
538
|
+
* `keepSourcesContent` — the lcov conversion reads original sources from
|
|
539
|
+
* there. Still a V8 document — nothing is converted here.
|
|
363
540
|
*/
|
|
364
|
-
export function compactV8Document(
|
|
541
|
+
export function compactV8Document(
|
|
542
|
+
doc: Record<string, unknown>,
|
|
543
|
+
opts: { keepSourcesContent?: boolean } = {},
|
|
544
|
+
): Record<string, unknown> {
|
|
365
545
|
const result = Array.isArray(doc.result) ? (doc.result as { url?: unknown }[]) : [];
|
|
366
546
|
const kept = result.filter((s) => typeof s.url === "string" && isAppScriptUrl(s.url));
|
|
367
547
|
const out: Record<string, unknown> = { ...doc, result: kept };
|
|
@@ -372,7 +552,7 @@ export function compactV8Document(doc: Record<string, unknown>): Record<string,
|
|
|
372
552
|
for (const [url, entry] of Object.entries(cache as Record<string, unknown>)) {
|
|
373
553
|
if (!urls.has(url) || !entry || typeof entry !== "object") continue;
|
|
374
554
|
const e = { ...(entry as Record<string, unknown>) };
|
|
375
|
-
if (e.data && typeof e.data === "object") {
|
|
555
|
+
if (!opts.keepSourcesContent && e.data && typeof e.data === "object") {
|
|
376
556
|
const { sourcesContent: _dropped, ...data } = e.data as Record<string, unknown>;
|
|
377
557
|
e.data = data;
|
|
378
558
|
}
|
|
@@ -419,17 +599,21 @@ export async function compactV8Reports(dir: string): Promise<void> {
|
|
|
419
599
|
* browser — spectest's own process — so no report can be written by the
|
|
420
600
|
* container; spectest collects V8 coverage for every script the browser
|
|
421
601
|
* loads from this service's origins, maps it back to source through the
|
|
422
|
-
* served source map, and writes one lcov per capture.
|
|
423
|
-
*
|
|
602
|
+
* served source map, and writes one lcov per capture (`browser.lcov`)
|
|
603
|
+
* holding what ran since the previous capture: every live session is
|
|
604
|
+
* harvested first, and the totals are taken and cleared. Nothing ran ⇒
|
|
605
|
+
* an lcov with no records, which is a report saying so.
|
|
424
606
|
*/
|
|
425
607
|
export function browser(): CoverageAdapter {
|
|
426
608
|
return {
|
|
427
609
|
name: "browser",
|
|
610
|
+
reports: "delta",
|
|
428
611
|
load(ctx) {
|
|
429
612
|
ctx.collectBrowserScripts();
|
|
430
613
|
},
|
|
431
614
|
async capture(ctx) {
|
|
432
|
-
|
|
615
|
+
await harvestAllBrowserCoverage();
|
|
616
|
+
const lcov = takeBrowserCoverageReports().get(ctx.service) ?? "TN:\n";
|
|
433
617
|
await ctx.writeReport("browser.lcov", lcov);
|
|
434
618
|
},
|
|
435
619
|
};
|
|
@@ -440,7 +624,10 @@ export function browser(): CoverageAdapter {
|
|
|
440
624
|
/**
|
|
441
625
|
* Run `command` (via `sh -c`) in the container at every capture, so the
|
|
442
626
|
* service writes a fresh report into `/spectest/coverage/`. The escape
|
|
443
|
-
* hatch for a runtime with no shipped adapter.
|
|
627
|
+
* hatch for a runtime with no shipped adapter. The reports it makes
|
|
628
|
+
* appear follow the service's `reports` mode (cumulative by default —
|
|
629
|
+
* spectest subtracts the previous capture; `{ reports: "delta" }` when
|
|
630
|
+
* the tool resets its own counters).
|
|
444
631
|
*/
|
|
445
632
|
export function command(command: string): CoverageAdapter {
|
|
446
633
|
if (typeof command !== "string" || command.trim().length === 0) {
|