@specific.dev/spectest 0.58.0 → 0.59.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/coverage.d.ts +142 -0
- package/dist/coverage.js +314 -0
- package/dist/daemon.js +124 -71
- package/dist/harness/coverage.d.ts +23 -4
- package/dist/harness/coverage.js +50 -14
- package/dist/index.d.ts +18 -46
- package/dist/index.js +6 -32
- package/package.json +6 -1
- package/src/coverage.test.ts +233 -0
- package/src/coverage.ts +401 -0
- package/src/daemon.ts +136 -75
- package/src/harness/coverage.test.ts +46 -1
- package/src/harness/coverage.ts +53 -13
- package/src/index.ts +36 -71
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
NODE_COVERAGE_HOOK,
|
|
8
|
+
NODE_COVERAGE_HOOK_PATH,
|
|
9
|
+
NODE_COVERAGE_SOCKET,
|
|
10
|
+
applyCoverageAdapters,
|
|
11
|
+
appendEnvFlag,
|
|
12
|
+
browser,
|
|
13
|
+
command,
|
|
14
|
+
compactV8Document,
|
|
15
|
+
compactV8Reports,
|
|
16
|
+
node,
|
|
17
|
+
validateCoverage,
|
|
18
|
+
type CoverageCaptureContext,
|
|
19
|
+
} from "./coverage.js";
|
|
20
|
+
import type { ServiceConfig } from "./index.js";
|
|
21
|
+
|
|
22
|
+
const base: ServiceConfig = {
|
|
23
|
+
image: { type: "registry", reference: "node:20" },
|
|
24
|
+
command: "node server.js",
|
|
25
|
+
env: { NODE_OPTIONS: "--max-old-space-size=512" },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe("validateCoverage", () => {
|
|
29
|
+
test("accepts true, a list of adapters, nothing else", () => {
|
|
30
|
+
validateCoverage("a", undefined);
|
|
31
|
+
validateCoverage("a", true);
|
|
32
|
+
validateCoverage("a", { adapters: [node(), { name: "x" }] });
|
|
33
|
+
expect(() => validateCoverage("a", false)).toThrow(/invalid `coverage` value/);
|
|
34
|
+
expect(() => validateCoverage("a", { command: "x" })).toThrow(/invalid `coverage` value/);
|
|
35
|
+
expect(() => validateCoverage("a", [node()])).toThrow(/invalid `coverage` value/);
|
|
36
|
+
expect(() => validateCoverage("a", { adapters: [{ name: "" }] })).toThrow(/invalid coverage adapter/);
|
|
37
|
+
expect(() => validateCoverage("a", { adapters: [{ name: "x", capture: 1 }] })).toThrow(/invalid coverage adapter/);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("applyCoverageAdapters", () => {
|
|
42
|
+
test("node: appends NODE_OPTIONS, sets NODE_V8_COVERAGE, mounts the hook", () => {
|
|
43
|
+
const out = applyCoverageAdapters("api", { ...base, coverage: { adapters: [node()] } });
|
|
44
|
+
expect(out.env?.NODE_OPTIONS).toBe(`--max-old-space-size=512 --require ${NODE_COVERAGE_HOOK_PATH}`);
|
|
45
|
+
expect(out.env?.NODE_V8_COVERAGE).toBe("/spectest/coverage");
|
|
46
|
+
expect(out.files?.map((f) => f.path)).toEqual([NODE_COVERAGE_HOOK_PATH]);
|
|
47
|
+
expect(out.files?.[0]?.content).toBe(NODE_COVERAGE_HOOK);
|
|
48
|
+
});
|
|
49
|
+
test("the adapters stay on the config for the harness, and serialize as names", () => {
|
|
50
|
+
const out = applyCoverageAdapters("api", { ...base, coverage: { adapters: [node(), browser()] } });
|
|
51
|
+
const list = (out.coverage as { adapters: readonly { name: string; capture?: unknown; load?: unknown }[] }).adapters;
|
|
52
|
+
expect(list.map((a) => a.name)).toEqual(["node", "browser"]);
|
|
53
|
+
expect(typeof list[0]?.capture).toBe("function");
|
|
54
|
+
expect(typeof list[1]?.load).toBe("function");
|
|
55
|
+
expect(JSON.parse(JSON.stringify(out)).coverage).toEqual({
|
|
56
|
+
adapters: [{ adapter: "node" }, { adapter: "browser" }],
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
test("true and undefined pass through untouched", () => {
|
|
60
|
+
const t = { ...base, coverage: true as const };
|
|
61
|
+
expect(applyCoverageAdapters("api", t)).toBe(t);
|
|
62
|
+
expect(applyCoverageAdapters("api", base)).toBe(base);
|
|
63
|
+
});
|
|
64
|
+
test("appendEnvFlag sets when absent", () => {
|
|
65
|
+
expect(appendEnvFlag(undefined, "X", "a")).toEqual({ X: "a" });
|
|
66
|
+
expect(appendEnvFlag({ X: " b " }, "X", "a")).toEqual({ X: "b a" });
|
|
67
|
+
});
|
|
68
|
+
test("coverage.command refuses an empty command", () => {
|
|
69
|
+
expect(() => command(" ")).toThrow(/non-empty/);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("compactV8Document", () => {
|
|
74
|
+
const doc = {
|
|
75
|
+
result: [
|
|
76
|
+
{ scriptId: "1", url: "file:///app/src/a.ts", functions: [] },
|
|
77
|
+
{ scriptId: "2", url: "file:///app/node_modules/x/index.js", functions: [] },
|
|
78
|
+
{ scriptId: "3", url: "node:fs", functions: [] },
|
|
79
|
+
{ scriptId: "4", url: "", functions: [] },
|
|
80
|
+
],
|
|
81
|
+
"source-map-cache": {
|
|
82
|
+
"file:///app/src/a.ts": { lineLengths: [1], data: { mappings: "AAAA", sources: ["a.ts"], sourcesContent: ["x"] }, url: null },
|
|
83
|
+
"file:///app/node_modules/x/index.js": { lineLengths: [1], data: { mappings: "AAAA" }, url: null },
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
test("keeps app scripts and their maps, drops the rest and sourcesContent", () => {
|
|
87
|
+
const out = compactV8Document(doc) as typeof doc;
|
|
88
|
+
expect(out.result.map((s) => s.url)).toEqual(["file:///app/src/a.ts"]);
|
|
89
|
+
expect(Object.keys(out["source-map-cache"])).toEqual(["file:///app/src/a.ts"]);
|
|
90
|
+
expect(out["source-map-cache"]["file:///app/src/a.ts"].data).toEqual({ mappings: "AAAA", sources: ["a.ts"] });
|
|
91
|
+
});
|
|
92
|
+
test("a dump with no app script keeps an empty result and no cache", () => {
|
|
93
|
+
const out = compactV8Document({ result: [doc.result[2]], "source-map-cache": doc["source-map-cache"] });
|
|
94
|
+
expect(out.result).toEqual([]);
|
|
95
|
+
expect("source-map-cache" in out).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
test("compactV8Reports rewrites coverage-*.json once and leaves other files alone", async () => {
|
|
98
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-cov-"));
|
|
99
|
+
await fs.writeFile(path.join(dir, "coverage-1-2-0.json"), JSON.stringify(doc));
|
|
100
|
+
await fs.writeFile(path.join(dir, "browser.lcov"), "TN:\n");
|
|
101
|
+
await compactV8Reports(dir);
|
|
102
|
+
const back = JSON.parse(await fs.readFile(path.join(dir, "coverage-1-2-0.json"), "utf8")) as { result: unknown[] };
|
|
103
|
+
expect(back.result.length).toBe(1);
|
|
104
|
+
expect(await fs.readFile(path.join(dir, "browser.lcov"), "utf8")).toBe("TN:\n");
|
|
105
|
+
// Idempotent and cheap: a second pass is a no-op on the same file.
|
|
106
|
+
await fs.writeFile(path.join(dir, "coverage-1-2-0.json"), "not json");
|
|
107
|
+
await compactV8Reports(dir);
|
|
108
|
+
expect(await fs.readFile(path.join(dir, "coverage-1-2-0.json"), "utf8")).toBe("not json");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/** A capture context over a real directory, no container. */
|
|
113
|
+
function fakeCtx(dir: string, signal = new AbortController().signal): CoverageCaptureContext {
|
|
114
|
+
return {
|
|
115
|
+
service: "api",
|
|
116
|
+
reportDir: dir,
|
|
117
|
+
signal,
|
|
118
|
+
async exec() {
|
|
119
|
+
throw new Error("no container");
|
|
120
|
+
},
|
|
121
|
+
async writeReport(name, content) {
|
|
122
|
+
await fs.writeFile(path.join(dir, name), content);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
describe("browserCoverage", () => {
|
|
128
|
+
test("writes an empty lcov when nothing ran for the service", async () => {
|
|
129
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-cov-"));
|
|
130
|
+
await browser().capture!(fakeCtx(dir));
|
|
131
|
+
expect(await fs.readFile(path.join(dir, "browser.lcov"), "utf8")).toBe("TN:\n");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// The node hook against a real `node` (the box has one; skipped where it
|
|
136
|
+
// does not). A long-lived process armed with the hook must answer the
|
|
137
|
+
// socket with a fresh V8 report; a second process must stay a bystander
|
|
138
|
+
// and exit on its own; the socket path must work across a bind mount —
|
|
139
|
+
// which is what a plain directory path stands in for here.
|
|
140
|
+
const hasNode = await new Promise<boolean>((resolve) => {
|
|
141
|
+
const p = spawn("node", ["--version"]);
|
|
142
|
+
p.on("error", () => resolve(false));
|
|
143
|
+
p.on("exit", (code) => resolve(code === 0));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe.if(hasNode)("nodeCoverage hook (real node)", () => {
|
|
147
|
+
async function startServer(dir: string): Promise<ChildProcess> {
|
|
148
|
+
const hook = path.join(dir, "hook.cjs");
|
|
149
|
+
await fs.writeFile(hook, NODE_COVERAGE_HOOK);
|
|
150
|
+
const server = path.join(dir, "server.js");
|
|
151
|
+
await fs.writeFile(server, "setInterval(() => {}, 1000); process.stdout.write('up\\n');\n");
|
|
152
|
+
const child = spawn("node", ["--require", hook, server], {
|
|
153
|
+
env: { ...process.env, NODE_V8_COVERAGE: dir },
|
|
154
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
155
|
+
});
|
|
156
|
+
await new Promise<void>((resolve) => child.stdout!.once("data", () => resolve()));
|
|
157
|
+
// The socket is bound after the module graph runs; wait for it.
|
|
158
|
+
const sock = path.join(dir, NODE_COVERAGE_SOCKET);
|
|
159
|
+
for (let i = 0; i < 100; i++) {
|
|
160
|
+
try {
|
|
161
|
+
await fs.stat(sock);
|
|
162
|
+
break;
|
|
163
|
+
} catch {
|
|
164
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return child;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
test("capture asks the live server for a dump; a bystander exits on its own", async () => {
|
|
171
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
|
|
172
|
+
const server = await startServer(dir);
|
|
173
|
+
try {
|
|
174
|
+
const reportsBefore = (await fs.readdir(dir)).filter((n) => n.startsWith("coverage-"));
|
|
175
|
+
expect(reportsBefore).toEqual([]);
|
|
176
|
+
await node().capture!(fakeCtx(dir));
|
|
177
|
+
const reports = (await fs.readdir(dir)).filter((n) => n.startsWith("coverage-"));
|
|
178
|
+
expect(reports.length).toBe(1);
|
|
179
|
+
const doc = JSON.parse(await fs.readFile(path.join(dir, reports[0]!), "utf8")) as {
|
|
180
|
+
result: { url: string }[];
|
|
181
|
+
};
|
|
182
|
+
expect(doc.result.some((s) => s.url.endsWith("/server.js"))).toBe(true);
|
|
183
|
+
|
|
184
|
+
// A second process with the same env: the socket is held, so it must
|
|
185
|
+
// not try to serve, and it must exit (the server's socket is unref'd,
|
|
186
|
+
// so a bystander is never kept alive by it).
|
|
187
|
+
const bystander = spawn("node", ["--require", path.join(dir, "hook.cjs"), "-e", "1"], {
|
|
188
|
+
env: { ...process.env, NODE_V8_COVERAGE: dir },
|
|
189
|
+
stdio: "ignore",
|
|
190
|
+
});
|
|
191
|
+
const code = await new Promise<number | null>((resolve) => bystander.on("exit", resolve));
|
|
192
|
+
expect(code).toBe(0);
|
|
193
|
+
// …and it wrote its own exit-time report, as every node process does.
|
|
194
|
+
const after = (await fs.readdir(dir)).filter((n) => n.startsWith("coverage-"));
|
|
195
|
+
expect(after.length).toBe(2);
|
|
196
|
+
|
|
197
|
+
// The live server still answers.
|
|
198
|
+
await node().capture!(fakeCtx(dir));
|
|
199
|
+
expect((await fs.readdir(dir)).filter((n) => n.startsWith("coverage-")).length).toBe(3);
|
|
200
|
+
} finally {
|
|
201
|
+
server.kill("SIGKILL");
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("a stale socket left by a dead process is reclaimed", async () => {
|
|
206
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
|
|
207
|
+
const first = await startServer(dir);
|
|
208
|
+
first.kill("SIGKILL");
|
|
209
|
+
await new Promise<void>((resolve) => first.on("exit", () => resolve()));
|
|
210
|
+
expect(await fs.stat(path.join(dir, NODE_COVERAGE_SOCKET))).toBeTruthy(); // stale file
|
|
211
|
+
const second = await startServer(dir);
|
|
212
|
+
try {
|
|
213
|
+
// Give the EADDRINUSE → probe → unlink → listen dance a moment.
|
|
214
|
+
let ok = false;
|
|
215
|
+
for (let i = 0; i < 50 && !ok; i++) {
|
|
216
|
+
try {
|
|
217
|
+
await node().capture!(fakeCtx(dir));
|
|
218
|
+
ok = true;
|
|
219
|
+
} catch {
|
|
220
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
expect(ok).toBe(true);
|
|
224
|
+
} finally {
|
|
225
|
+
second.kill("SIGKILL");
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("no server: capture fails naming the socket", async () => {
|
|
230
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
|
|
231
|
+
await expect(node().capture!(fakeCtx(dir))).rejects.toThrow(/no node process is serving/);
|
|
232
|
+
});
|
|
233
|
+
});
|
package/src/coverage.ts
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
// Coverage adapters — how spectest gets a coverage report out of a service.
|
|
2
|
+
//
|
|
3
|
+
// A service opts in with `coverage: true` (the program writes its own
|
|
4
|
+
// reports into `/spectest/coverage/`) or `coverage: { adapters: [...] }`
|
|
5
|
+
// (spectest gets the reports out). An adapter has three optional moments:
|
|
6
|
+
//
|
|
7
|
+
// configure — config time, inside `defineEnvironment`. Rewrites the
|
|
8
|
+
// service (env, files, command). Pure: its output is part
|
|
9
|
+
// of the wire config and of the warm-template hash.
|
|
10
|
+
// load — harness, at /load. Asks the harness for capabilities a
|
|
11
|
+
// container cannot supply itself (browser scripts).
|
|
12
|
+
// capture — harness, after bring-up and after every test. Must leave
|
|
13
|
+
// a report in the directory.
|
|
14
|
+
//
|
|
15
|
+
// After every adapter ran, the harness reads the directory the same way
|
|
16
|
+
// it does for `coverage: true`: at least one well-formed report (lcov or
|
|
17
|
+
// V8 JSON), shipped verbatim. Adapters convert nothing — the control
|
|
18
|
+
// plane derives what it needs from the stored bytes.
|
|
19
|
+
//
|
|
20
|
+
// Imported from `@specific.dev/spectest/coverage`. The three shipped
|
|
21
|
+
// adapters: `node()` (V8 JSON through a hook spectest mounts), `browser()`
|
|
22
|
+
// (scripts the guest browser loads from the service), `command(cmd)` (run
|
|
23
|
+
// a command, then read). A project writes its own with `defineAdapter`.
|
|
24
|
+
//
|
|
25
|
+
// import * as coverage from "@specific.dev/spectest/coverage";
|
|
26
|
+
// coverage: { adapters: [coverage.node(), coverage.browser()] }
|
|
27
|
+
|
|
28
|
+
import type { ServiceConfig } from "./index.js";
|
|
29
|
+
import { browserCoverageReports } from "./browser-coverage.js";
|
|
30
|
+
|
|
31
|
+
import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
|
|
32
|
+
export { COVERAGE_CONTAINER_DIR };
|
|
33
|
+
|
|
34
|
+
/** Where `node()` mounts its hook inside the container. */
|
|
35
|
+
export const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
|
|
36
|
+
|
|
37
|
+
/** The socket the node hook answers on, relative to the coverage dir. */
|
|
38
|
+
export const NODE_COVERAGE_SOCKET = ".ctl";
|
|
39
|
+
|
|
40
|
+
/** What `configure` learns about the service it rewrites. */
|
|
41
|
+
export interface CoverageConfigureInfo {
|
|
42
|
+
/** The services-map key. */
|
|
43
|
+
key: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The harness capabilities an adapter can ask for at `/load`. */
|
|
47
|
+
export interface CoverageLoadContext {
|
|
48
|
+
/** The services-map key. */
|
|
49
|
+
service: string;
|
|
50
|
+
/**
|
|
51
|
+
* Collect V8 coverage for every script the guest browser
|
|
52
|
+
* (`ctx.browser()` / `ctx.mobile()`) loads from this service's origins:
|
|
53
|
+
* its key, `<key>.internal`, its `hostnames`/`dnsName` aliases, its
|
|
54
|
+
* `tls`/`proxy` hostnames and any service-targeted wildcard. The
|
|
55
|
+
* harness maps each script back to source through the served source
|
|
56
|
+
* map and keeps per-service totals in memory (they fork with the
|
|
57
|
+
* environment). `browser()` calls this and writes the totals
|
|
58
|
+
* at capture.
|
|
59
|
+
*/
|
|
60
|
+
collectBrowserScripts(): void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** What one adapter's `capture` runs against. */
|
|
64
|
+
export interface CoverageCaptureContext {
|
|
65
|
+
/** The services-map key. */
|
|
66
|
+
service: string;
|
|
67
|
+
/**
|
|
68
|
+
* The coverage directory, seen from the harness. It is the same
|
|
69
|
+
* directory the container sees at `/spectest/coverage/` (a bind
|
|
70
|
+
* mount), so a file written here is in the container and a socket the
|
|
71
|
+
* container bound here is reachable.
|
|
72
|
+
*/
|
|
73
|
+
reportDir: string;
|
|
74
|
+
/** Run a shell command (`sh -c`) inside the container. Rejects on a
|
|
75
|
+
* non-zero exit with the command's output in the message. */
|
|
76
|
+
exec(command: string): Promise<{ stdout: string; stderr: string }>;
|
|
77
|
+
/** Write one report file (name relative to the directory). */
|
|
78
|
+
writeReport(name: string, content: string): Promise<void>;
|
|
79
|
+
/** Aborts when the per-service capture budget runs out. */
|
|
80
|
+
signal: AbortSignal;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface CoverageAdapter {
|
|
84
|
+
/** Names the adapter in errors (`coverage adapter "node" on service …`). */
|
|
85
|
+
name: string;
|
|
86
|
+
/**
|
|
87
|
+
* Rewrite the service at config time. Must be pure and deterministic
|
|
88
|
+
* (the result is hashed into the warm-template key) and must **append**
|
|
89
|
+
* to `env` values like `NODE_OPTIONS` rather than replace them.
|
|
90
|
+
*/
|
|
91
|
+
configure?(service: ServiceConfig, info: CoverageConfigureInfo): ServiceConfig;
|
|
92
|
+
/** Ask the harness for capabilities, at `/load`. */
|
|
93
|
+
load?(ctx: CoverageLoadContext): void | Promise<void>;
|
|
94
|
+
/** Make the report appear in the directory. A throw fails the test. */
|
|
95
|
+
capture?(ctx: CoverageCaptureContext): void | Promise<void>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** `ServiceConfig.coverage`: the program writes its own reports, or a
|
|
99
|
+
* list of adapters gets them out. */
|
|
100
|
+
export type ServiceCoverage = true | { adapters: readonly CoverageAdapter[] };
|
|
101
|
+
|
|
102
|
+
/** Type an inline adapter. Identity at runtime. */
|
|
103
|
+
export function defineAdapter(adapter: CoverageAdapter): CoverageAdapter {
|
|
104
|
+
return adapter;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Check one service's `coverage` field. Exported so the daemon applies
|
|
108
|
+
* the same rule to a runtime `startService` spec. */
|
|
109
|
+
export function validateCoverage(service: string, cov: unknown): void {
|
|
110
|
+
if (cov === undefined || cov === true) return;
|
|
111
|
+
const adapters = (cov as { adapters?: unknown } | null)?.adapters;
|
|
112
|
+
if (typeof cov === "object" && cov !== null && Array.isArray(adapters)) {
|
|
113
|
+
for (const a of adapters) {
|
|
114
|
+
const ok =
|
|
115
|
+
typeof a === "object" &&
|
|
116
|
+
a !== null &&
|
|
117
|
+
typeof (a as CoverageAdapter).name === "string" &&
|
|
118
|
+
(a as CoverageAdapter).name.length > 0 &&
|
|
119
|
+
["configure", "load", "capture"].every(
|
|
120
|
+
(k) =>
|
|
121
|
+
(a as Record<string, unknown>)[k] === undefined ||
|
|
122
|
+
typeof (a as Record<string, unknown>)[k] === "function",
|
|
123
|
+
);
|
|
124
|
+
if (!ok) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`service "${service}" has an invalid coverage adapter — an adapter is \`{ name, configure?, load?, capture? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
throw new Error(
|
|
133
|
+
`service "${service}" has an invalid \`coverage\` value — use \`true\` (the program writes its own reports) or \`{ adapters: [...] }\` from @specific.dev/spectest/coverage (\`node()\`, \`browser()\`, \`command("…")\`)`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The adapters of a `coverage` value (`true` has none). */
|
|
138
|
+
export function coverageAdapters(cov: ServiceCoverage | undefined): readonly CoverageAdapter[] {
|
|
139
|
+
return typeof cov === "object" && cov !== null ? cov.adapters : [];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Apply every adapter's `configure` to a service, in list order. Called by
|
|
144
|
+
* `defineEnvironment` (after group expansion, before validation) and by
|
|
145
|
+
* the daemon for a runtime `startService` spec. The adapter list stays on
|
|
146
|
+
* the returned config for the harness; on the wire it serializes as the
|
|
147
|
+
* adapter names (`toJSON`), never as functions.
|
|
148
|
+
*/
|
|
149
|
+
export function applyCoverageAdapters<S extends ServiceConfig>(key: string, svc: S): S {
|
|
150
|
+
validateCoverage(key, svc.coverage);
|
|
151
|
+
const adapters = coverageAdapters(svc.coverage);
|
|
152
|
+
if (adapters.length === 0) return svc;
|
|
153
|
+
let out: ServiceConfig = svc;
|
|
154
|
+
for (const a of adapters) {
|
|
155
|
+
if (a.configure) out = a.configure(out, { key });
|
|
156
|
+
}
|
|
157
|
+
const wire = { adapters: adapters.map((a) => withWireName(a)) };
|
|
158
|
+
return { ...out, coverage: wire } as unknown as S;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function withWireName(a: CoverageAdapter): CoverageAdapter {
|
|
162
|
+
if (Object.prototype.hasOwnProperty.call(a, "toJSON")) return a;
|
|
163
|
+
return Object.assign(Object.create(Object.getPrototypeOf(a) as object | null), a, {
|
|
164
|
+
toJSON: () => ({ adapter: a.name }),
|
|
165
|
+
}) as CoverageAdapter;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** `env` with `value` appended to `name` (space-separated), or set. */
|
|
169
|
+
export function appendEnvFlag(
|
|
170
|
+
env: Readonly<Record<string, string>> | undefined,
|
|
171
|
+
name: string,
|
|
172
|
+
value: string,
|
|
173
|
+
): Record<string, string> {
|
|
174
|
+
const prior = env?.[name]?.trim();
|
|
175
|
+
return { ...env, [name]: prior ? `${prior} ${value}` : value };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── node ──────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The hook `node()` mounts and `--require`s into every node
|
|
182
|
+
* process of the container. Node writes V8 coverage JSON into
|
|
183
|
+
* `NODE_V8_COVERAGE` when a process exits; a long-lived server never
|
|
184
|
+
* exits, so the hook binds a Unix socket in the coverage directory and
|
|
185
|
+
* calls `v8.takeCoverage()` for each connection. The first process to
|
|
186
|
+
* start owns the socket (a stale socket left by a dead process is
|
|
187
|
+
* reclaimed); a later process — a CLI run by `ctx.exec` — leaves it
|
|
188
|
+
* alone and writes at its own exit. No signal is used: signals are
|
|
189
|
+
* claimed by frameworks (SIGUSR2 stops a Temporal worker, restarts
|
|
190
|
+
* nodemon), a socket is nobody's.
|
|
191
|
+
*/
|
|
192
|
+
export const NODE_COVERAGE_HOOK = `"use strict";
|
|
193
|
+
// spectest coverage hook (coverage.node() adapter). See \`spectest docs /services/coverage\`.
|
|
194
|
+
if (process.env.NODE_V8_COVERAGE) {
|
|
195
|
+
const net = require("node:net");
|
|
196
|
+
const fs = require("node:fs");
|
|
197
|
+
const v8 = require("node:v8");
|
|
198
|
+
const SOCK = require("node:path").join(process.env.NODE_V8_COVERAGE, ${JSON.stringify(NODE_COVERAGE_SOCKET)});
|
|
199
|
+
const server = net.createServer((conn) => {
|
|
200
|
+
// A dump is taken only on an explicit "dump" request: a bystander's
|
|
201
|
+
// liveness probe connects and closes without one.
|
|
202
|
+
let buf = "";
|
|
203
|
+
conn.on("data", (chunk) => {
|
|
204
|
+
buf += chunk;
|
|
205
|
+
if (!buf.includes("\\n")) return;
|
|
206
|
+
let reply;
|
|
207
|
+
try {
|
|
208
|
+
if (!buf.startsWith("dump")) throw new Error("unknown request");
|
|
209
|
+
v8.takeCoverage();
|
|
210
|
+
reply = "ok\\n";
|
|
211
|
+
} catch (err) {
|
|
212
|
+
reply = "error " + (err && err.message ? err.message : String(err)) + "\\n";
|
|
213
|
+
}
|
|
214
|
+
conn.end(reply);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
server.unref();
|
|
218
|
+
server.on("error", (err) => {
|
|
219
|
+
if (err.code !== "EADDRINUSE") return;
|
|
220
|
+
// Someone holds the socket. If it answers, it is the live server and
|
|
221
|
+
// this process is a bystander; if not, it is a stale file.
|
|
222
|
+
const probe = net.connect(SOCK);
|
|
223
|
+
probe.on("connect", () => probe.destroy());
|
|
224
|
+
probe.on("error", () => {
|
|
225
|
+
try { fs.unlinkSync(SOCK); } catch {}
|
|
226
|
+
server.listen(SOCK);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
server.listen(SOCK);
|
|
230
|
+
}
|
|
231
|
+
`;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Coverage for a Node service. Sets `NODE_V8_COVERAGE` to the coverage
|
|
235
|
+
* directory (every node process in the container then writes V8
|
|
236
|
+
* coverage JSON when it exits) and `--require`s a hook that lets spectest
|
|
237
|
+
* ask the long-lived server process for a dump at capture time. Nothing
|
|
238
|
+
* for the app to write. Each dump is compacted to the app's own scripts
|
|
239
|
+
* at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
|
|
240
|
+
* `sourceMappingURL` next to the file) Node records the map in the
|
|
241
|
+
* report, which is what maps a TypeScript service back to its sources.
|
|
242
|
+
*/
|
|
243
|
+
export function node(): CoverageAdapter {
|
|
244
|
+
return {
|
|
245
|
+
name: "node",
|
|
246
|
+
configure(svc) {
|
|
247
|
+
const env = appendEnvFlag(svc.env, "NODE_OPTIONS", `--require ${NODE_COVERAGE_HOOK_PATH}`);
|
|
248
|
+
env.NODE_V8_COVERAGE = COVERAGE_CONTAINER_DIR;
|
|
249
|
+
return {
|
|
250
|
+
...svc,
|
|
251
|
+
env,
|
|
252
|
+
files: [...(svc.files ?? []), { path: NODE_COVERAGE_HOOK_PATH, content: NODE_COVERAGE_HOOK }],
|
|
253
|
+
};
|
|
254
|
+
},
|
|
255
|
+
async capture(ctx) {
|
|
256
|
+
const { connect } = await import("node:net");
|
|
257
|
+
const path = await import("node:path");
|
|
258
|
+
const sock = path.join(ctx.reportDir, NODE_COVERAGE_SOCKET);
|
|
259
|
+
const reply = await new Promise<string>((resolve, reject) => {
|
|
260
|
+
const chunks: Buffer[] = [];
|
|
261
|
+
const c = connect(sock, () => c.write("dump\n"));
|
|
262
|
+
const onAbort = (): void => {
|
|
263
|
+
c.destroy();
|
|
264
|
+
reject(new Error("timed out waiting for the node hook to write a report"));
|
|
265
|
+
};
|
|
266
|
+
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
|
267
|
+
c.on("data", (b: Buffer) => chunks.push(b));
|
|
268
|
+
c.on("error", (err: NodeJS.ErrnoException) => {
|
|
269
|
+
ctx.signal.removeEventListener("abort", onAbort);
|
|
270
|
+
reject(
|
|
271
|
+
new Error(
|
|
272
|
+
err.code === "ENOENT" || err.code === "ECONNREFUSED"
|
|
273
|
+
? `no node process is serving ${COVERAGE_CONTAINER_DIR}/${NODE_COVERAGE_SOCKET} — is the service's main process node, and does it run with the service's env (NODE_OPTIONS)?`
|
|
274
|
+
: err.message,
|
|
275
|
+
),
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
c.on("close", () => {
|
|
279
|
+
ctx.signal.removeEventListener("abort", onAbort);
|
|
280
|
+
resolve(Buffer.concat(chunks).toString("utf8").trim());
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
if (reply !== "ok") throw new Error(`the node hook answered: ${reply || "(nothing)"}`);
|
|
284
|
+
await compactV8Reports(ctx.reportDir);
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** V8 files already compacted, by path. Module memory: forks with the
|
|
290
|
+
* environment, so a child never re-parses its ancestors' dumps. */
|
|
291
|
+
const COMPACTED_V8_REPORTS = new Set<string>();
|
|
292
|
+
|
|
293
|
+
/** A script is the app's own when it is a file outside node_modules. */
|
|
294
|
+
export function isAppScriptUrl(url: string): boolean {
|
|
295
|
+
return url.startsWith("file://") && !url.includes("/node_modules/");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Compact one V8 coverage document to the app's own scripts. What Node
|
|
300
|
+
* writes is everything the process loaded: `node:` internals, every
|
|
301
|
+
* `node_modules` file, and — under `--enable-source-maps` — a
|
|
302
|
+
* `source-map-cache` with each file's full map **and its sources**,
|
|
303
|
+
* repeated in every dump. Measured on a real project: a 12–20 MiB dump
|
|
304
|
+
* per capture, of which the app's own coverage was under 0.5 MiB, and a
|
|
305
|
+
* suite that hit the 64 MiB cap on its third test. Kept: `file://`
|
|
306
|
+
* scripts outside `node_modules`, the map entries of exactly those
|
|
307
|
+
* scripts, minus `sourcesContent` (the sources are the repo). Still a V8
|
|
308
|
+
* document — nothing is converted.
|
|
309
|
+
*/
|
|
310
|
+
export function compactV8Document(doc: Record<string, unknown>): Record<string, unknown> {
|
|
311
|
+
const result = Array.isArray(doc.result) ? (doc.result as { url?: unknown }[]) : [];
|
|
312
|
+
const kept = result.filter((s) => typeof s.url === "string" && isAppScriptUrl(s.url));
|
|
313
|
+
const out: Record<string, unknown> = { ...doc, result: kept };
|
|
314
|
+
const cache = doc["source-map-cache"];
|
|
315
|
+
if (cache && typeof cache === "object") {
|
|
316
|
+
const urls = new Set(kept.map((s) => s.url as string));
|
|
317
|
+
const slim: Record<string, unknown> = {};
|
|
318
|
+
for (const [url, entry] of Object.entries(cache as Record<string, unknown>)) {
|
|
319
|
+
if (!urls.has(url) || !entry || typeof entry !== "object") continue;
|
|
320
|
+
const e = { ...(entry as Record<string, unknown>) };
|
|
321
|
+
if (e.data && typeof e.data === "object") {
|
|
322
|
+
const { sourcesContent: _dropped, ...data } = e.data as Record<string, unknown>;
|
|
323
|
+
e.data = data;
|
|
324
|
+
}
|
|
325
|
+
slim[url] = e;
|
|
326
|
+
}
|
|
327
|
+
if (Object.keys(slim).length > 0) out["source-map-cache"] = slim;
|
|
328
|
+
else delete out["source-map-cache"];
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Compact every not-yet-compacted V8 document in `dir`, in place. */
|
|
334
|
+
export async function compactV8Reports(dir: string): Promise<void> {
|
|
335
|
+
const fs = await import("node:fs/promises");
|
|
336
|
+
const path = await import("node:path");
|
|
337
|
+
let names: string[];
|
|
338
|
+
try {
|
|
339
|
+
names = await fs.readdir(dir);
|
|
340
|
+
} catch {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
for (const name of names) {
|
|
344
|
+
if (!name.startsWith("coverage-") || !name.endsWith(".json")) continue;
|
|
345
|
+
const file = path.join(dir, name);
|
|
346
|
+
if (COMPACTED_V8_REPORTS.has(file)) continue;
|
|
347
|
+
let doc: Record<string, unknown>;
|
|
348
|
+
try {
|
|
349
|
+
doc = JSON.parse(await fs.readFile(file, "utf8")) as Record<string, unknown>;
|
|
350
|
+
} catch {
|
|
351
|
+
continue; // a dump mid-write, or not ours; the read path judges it
|
|
352
|
+
}
|
|
353
|
+
if (!Array.isArray(doc.result)) continue;
|
|
354
|
+
const tmp = path.join(dir, `.${name}.compact`);
|
|
355
|
+
await fs.writeFile(tmp, JSON.stringify(compactV8Document(doc)));
|
|
356
|
+
await fs.rename(tmp, file);
|
|
357
|
+
COMPACTED_V8_REPORTS.add(file);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ── browser ───────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Coverage for the frontend a service serves. The code runs in the guest
|
|
365
|
+
* browser — spectest's own process — so no report can be written by the
|
|
366
|
+
* container; spectest collects V8 coverage for every script the browser
|
|
367
|
+
* loads from this service's origins, maps it back to source through the
|
|
368
|
+
* served source map, and writes one lcov per capture. Nothing ran yet on
|
|
369
|
+
* this branch ⇒ an lcov with no records, which is a report saying so.
|
|
370
|
+
*/
|
|
371
|
+
export function browser(): CoverageAdapter {
|
|
372
|
+
return {
|
|
373
|
+
name: "browser",
|
|
374
|
+
load(ctx) {
|
|
375
|
+
ctx.collectBrowserScripts();
|
|
376
|
+
},
|
|
377
|
+
async capture(ctx) {
|
|
378
|
+
const lcov = browserCoverageReports().get(ctx.service) ?? "TN:\n";
|
|
379
|
+
await ctx.writeReport("browser.lcov", lcov);
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ── command ───────────────────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Run `command` (via `sh -c`) in the container at every capture, so the
|
|
388
|
+
* service writes a fresh report into `/spectest/coverage/`. The escape
|
|
389
|
+
* hatch for a runtime with no shipped adapter.
|
|
390
|
+
*/
|
|
391
|
+
export function command(command: string): CoverageAdapter {
|
|
392
|
+
if (typeof command !== "string" || command.trim().length === 0) {
|
|
393
|
+
throw new Error("coverage.command: `command` must be a non-empty shell command");
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
name: "command",
|
|
397
|
+
async capture(ctx) {
|
|
398
|
+
await ctx.exec(command);
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|