@specific.dev/spectest 0.58.0 → 0.59.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/coverage.d.ts +124 -0
- package/dist/coverage.js +236 -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 +192 -0
- package/src/coverage.ts +327 -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,124 @@
|
|
|
1
|
+
import type { ServiceConfig } from "./index.js";
|
|
2
|
+
import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
|
|
3
|
+
export { COVERAGE_CONTAINER_DIR };
|
|
4
|
+
/** Where `node()` mounts its hook inside the container. */
|
|
5
|
+
export declare const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
|
|
6
|
+
/** The socket the node hook answers on, relative to the coverage dir. */
|
|
7
|
+
export declare const NODE_COVERAGE_SOCKET = ".ctl";
|
|
8
|
+
/** What `configure` learns about the service it rewrites. */
|
|
9
|
+
export interface CoverageConfigureInfo {
|
|
10
|
+
/** The services-map key. */
|
|
11
|
+
key: string;
|
|
12
|
+
}
|
|
13
|
+
/** The harness capabilities an adapter can ask for at `/load`. */
|
|
14
|
+
export interface CoverageLoadContext {
|
|
15
|
+
/** The services-map key. */
|
|
16
|
+
service: string;
|
|
17
|
+
/**
|
|
18
|
+
* Collect V8 coverage for every script the guest browser
|
|
19
|
+
* (`ctx.browser()` / `ctx.mobile()`) loads from this service's origins:
|
|
20
|
+
* its key, `<key>.internal`, its `hostnames`/`dnsName` aliases, its
|
|
21
|
+
* `tls`/`proxy` hostnames and any service-targeted wildcard. The
|
|
22
|
+
* harness maps each script back to source through the served source
|
|
23
|
+
* map and keeps per-service totals in memory (they fork with the
|
|
24
|
+
* environment). `browser()` calls this and writes the totals
|
|
25
|
+
* at capture.
|
|
26
|
+
*/
|
|
27
|
+
collectBrowserScripts(): void;
|
|
28
|
+
}
|
|
29
|
+
/** What one adapter's `capture` runs against. */
|
|
30
|
+
export interface CoverageCaptureContext {
|
|
31
|
+
/** The services-map key. */
|
|
32
|
+
service: string;
|
|
33
|
+
/**
|
|
34
|
+
* The coverage directory, seen from the harness. It is the same
|
|
35
|
+
* directory the container sees at `/spectest/coverage/` (a bind
|
|
36
|
+
* mount), so a file written here is in the container and a socket the
|
|
37
|
+
* container bound here is reachable.
|
|
38
|
+
*/
|
|
39
|
+
reportDir: string;
|
|
40
|
+
/** Run a shell command (`sh -c`) inside the container. Rejects on a
|
|
41
|
+
* non-zero exit with the command's output in the message. */
|
|
42
|
+
exec(command: string): Promise<{
|
|
43
|
+
stdout: string;
|
|
44
|
+
stderr: string;
|
|
45
|
+
}>;
|
|
46
|
+
/** Write one report file (name relative to the directory). */
|
|
47
|
+
writeReport(name: string, content: string): Promise<void>;
|
|
48
|
+
/** Aborts when the per-service capture budget runs out. */
|
|
49
|
+
signal: AbortSignal;
|
|
50
|
+
}
|
|
51
|
+
export interface CoverageAdapter {
|
|
52
|
+
/** Names the adapter in errors (`coverage adapter "node" on service …`). */
|
|
53
|
+
name: string;
|
|
54
|
+
/**
|
|
55
|
+
* Rewrite the service at config time. Must be pure and deterministic
|
|
56
|
+
* (the result is hashed into the warm-template key) and must **append**
|
|
57
|
+
* to `env` values like `NODE_OPTIONS` rather than replace them.
|
|
58
|
+
*/
|
|
59
|
+
configure?(service: ServiceConfig, info: CoverageConfigureInfo): ServiceConfig;
|
|
60
|
+
/** Ask the harness for capabilities, at `/load`. */
|
|
61
|
+
load?(ctx: CoverageLoadContext): void | Promise<void>;
|
|
62
|
+
/** Make the report appear in the directory. A throw fails the test. */
|
|
63
|
+
capture?(ctx: CoverageCaptureContext): void | Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
/** `ServiceConfig.coverage`: the program writes its own reports, or a
|
|
66
|
+
* list of adapters gets them out. */
|
|
67
|
+
export type ServiceCoverage = true | {
|
|
68
|
+
adapters: readonly CoverageAdapter[];
|
|
69
|
+
};
|
|
70
|
+
/** Type an inline adapter. Identity at runtime. */
|
|
71
|
+
export declare function defineAdapter(adapter: CoverageAdapter): CoverageAdapter;
|
|
72
|
+
/** Check one service's `coverage` field. Exported so the daemon applies
|
|
73
|
+
* the same rule to a runtime `startService` spec. */
|
|
74
|
+
export declare function validateCoverage(service: string, cov: unknown): void;
|
|
75
|
+
/** The adapters of a `coverage` value (`true` has none). */
|
|
76
|
+
export declare function coverageAdapters(cov: ServiceCoverage | undefined): readonly CoverageAdapter[];
|
|
77
|
+
/**
|
|
78
|
+
* Apply every adapter's `configure` to a service, in list order. Called by
|
|
79
|
+
* `defineEnvironment` (after group expansion, before validation) and by
|
|
80
|
+
* the daemon for a runtime `startService` spec. The adapter list stays on
|
|
81
|
+
* the returned config for the harness; on the wire it serializes as the
|
|
82
|
+
* adapter names (`toJSON`), never as functions.
|
|
83
|
+
*/
|
|
84
|
+
export declare function applyCoverageAdapters<S extends ServiceConfig>(key: string, svc: S): S;
|
|
85
|
+
/** `env` with `value` appended to `name` (space-separated), or set. */
|
|
86
|
+
export declare function appendEnvFlag(env: Readonly<Record<string, string>> | undefined, name: string, value: string): Record<string, string>;
|
|
87
|
+
/**
|
|
88
|
+
* The hook `node()` mounts and `--require`s into every node
|
|
89
|
+
* process of the container. Node writes V8 coverage JSON into
|
|
90
|
+
* `NODE_V8_COVERAGE` when a process exits; a long-lived server never
|
|
91
|
+
* exits, so the hook binds a Unix socket in the coverage directory and
|
|
92
|
+
* calls `v8.takeCoverage()` for each connection. The first process to
|
|
93
|
+
* start owns the socket (a stale socket left by a dead process is
|
|
94
|
+
* reclaimed); a later process — a CLI run by `ctx.exec` — leaves it
|
|
95
|
+
* alone and writes at its own exit. No signal is used: signals are
|
|
96
|
+
* claimed by frameworks (SIGUSR2 stops a Temporal worker, restarts
|
|
97
|
+
* nodemon), a socket is nobody's.
|
|
98
|
+
*/
|
|
99
|
+
export declare const NODE_COVERAGE_HOOK: string;
|
|
100
|
+
/**
|
|
101
|
+
* Coverage for a Node service. Sets `NODE_V8_COVERAGE` to the coverage
|
|
102
|
+
* directory (every node process in the container then writes V8
|
|
103
|
+
* coverage JSON when it exits) and `--require`s a hook that lets spectest
|
|
104
|
+
* ask the long-lived server process for a dump at capture time. Nothing
|
|
105
|
+
* for the app to write. Source maps: with `--enable-source-maps` (or a
|
|
106
|
+
* `sourceMappingURL` next to the file) Node records the map in the
|
|
107
|
+
* report, which is what maps a TypeScript service back to its sources.
|
|
108
|
+
*/
|
|
109
|
+
export declare function node(): CoverageAdapter;
|
|
110
|
+
/**
|
|
111
|
+
* Coverage for the frontend a service serves. The code runs in the guest
|
|
112
|
+
* browser — spectest's own process — so no report can be written by the
|
|
113
|
+
* container; spectest collects V8 coverage for every script the browser
|
|
114
|
+
* loads from this service's origins, maps it back to source through the
|
|
115
|
+
* served source map, and writes one lcov per capture. Nothing ran yet on
|
|
116
|
+
* this branch ⇒ an lcov with no records, which is a report saying so.
|
|
117
|
+
*/
|
|
118
|
+
export declare function browser(): CoverageAdapter;
|
|
119
|
+
/**
|
|
120
|
+
* Run `command` (via `sh -c`) in the container at every capture, so the
|
|
121
|
+
* service writes a fresh report into `/spectest/coverage/`. The escape
|
|
122
|
+
* hatch for a runtime with no shipped adapter.
|
|
123
|
+
*/
|
|
124
|
+
export declare function command(command: string): CoverageAdapter;
|
package/dist/coverage.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
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
|
+
import { browserCoverageReports } from "./browser-coverage.js";
|
|
28
|
+
import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
|
|
29
|
+
export { COVERAGE_CONTAINER_DIR };
|
|
30
|
+
/** Where `node()` mounts its hook inside the container. */
|
|
31
|
+
export const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
|
|
32
|
+
/** The socket the node hook answers on, relative to the coverage dir. */
|
|
33
|
+
export const NODE_COVERAGE_SOCKET = ".ctl";
|
|
34
|
+
/** Type an inline adapter. Identity at runtime. */
|
|
35
|
+
export function defineAdapter(adapter) {
|
|
36
|
+
return adapter;
|
|
37
|
+
}
|
|
38
|
+
/** Check one service's `coverage` field. Exported so the daemon applies
|
|
39
|
+
* the same rule to a runtime `startService` spec. */
|
|
40
|
+
export function validateCoverage(service, cov) {
|
|
41
|
+
if (cov === undefined || cov === true)
|
|
42
|
+
return;
|
|
43
|
+
const adapters = cov?.adapters;
|
|
44
|
+
if (typeof cov === "object" && cov !== null && Array.isArray(adapters)) {
|
|
45
|
+
for (const a of adapters) {
|
|
46
|
+
const ok = typeof a === "object" &&
|
|
47
|
+
a !== null &&
|
|
48
|
+
typeof a.name === "string" &&
|
|
49
|
+
a.name.length > 0 &&
|
|
50
|
+
["configure", "load", "capture"].every((k) => a[k] === undefined ||
|
|
51
|
+
typeof a[k] === "function");
|
|
52
|
+
if (!ok) {
|
|
53
|
+
throw new Error(`service "${service}" has an invalid coverage adapter — an adapter is \`{ name, configure?, load?, capture? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
throw new Error(`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("…")\`)`);
|
|
59
|
+
}
|
|
60
|
+
/** The adapters of a `coverage` value (`true` has none). */
|
|
61
|
+
export function coverageAdapters(cov) {
|
|
62
|
+
return typeof cov === "object" && cov !== null ? cov.adapters : [];
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Apply every adapter's `configure` to a service, in list order. Called by
|
|
66
|
+
* `defineEnvironment` (after group expansion, before validation) and by
|
|
67
|
+
* the daemon for a runtime `startService` spec. The adapter list stays on
|
|
68
|
+
* the returned config for the harness; on the wire it serializes as the
|
|
69
|
+
* adapter names (`toJSON`), never as functions.
|
|
70
|
+
*/
|
|
71
|
+
export function applyCoverageAdapters(key, svc) {
|
|
72
|
+
validateCoverage(key, svc.coverage);
|
|
73
|
+
const adapters = coverageAdapters(svc.coverage);
|
|
74
|
+
if (adapters.length === 0)
|
|
75
|
+
return svc;
|
|
76
|
+
let out = svc;
|
|
77
|
+
for (const a of adapters) {
|
|
78
|
+
if (a.configure)
|
|
79
|
+
out = a.configure(out, { key });
|
|
80
|
+
}
|
|
81
|
+
const wire = { adapters: adapters.map((a) => withWireName(a)) };
|
|
82
|
+
return { ...out, coverage: wire };
|
|
83
|
+
}
|
|
84
|
+
function withWireName(a) {
|
|
85
|
+
if (Object.prototype.hasOwnProperty.call(a, "toJSON"))
|
|
86
|
+
return a;
|
|
87
|
+
return Object.assign(Object.create(Object.getPrototypeOf(a)), a, {
|
|
88
|
+
toJSON: () => ({ adapter: a.name }),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/** `env` with `value` appended to `name` (space-separated), or set. */
|
|
92
|
+
export function appendEnvFlag(env, name, value) {
|
|
93
|
+
const prior = env?.[name]?.trim();
|
|
94
|
+
return { ...env, [name]: prior ? `${prior} ${value}` : value };
|
|
95
|
+
}
|
|
96
|
+
// ── node ──────────────────────────────────────────────────────────
|
|
97
|
+
/**
|
|
98
|
+
* The hook `node()` mounts and `--require`s into every node
|
|
99
|
+
* process of the container. Node writes V8 coverage JSON into
|
|
100
|
+
* `NODE_V8_COVERAGE` when a process exits; a long-lived server never
|
|
101
|
+
* exits, so the hook binds a Unix socket in the coverage directory and
|
|
102
|
+
* calls `v8.takeCoverage()` for each connection. The first process to
|
|
103
|
+
* start owns the socket (a stale socket left by a dead process is
|
|
104
|
+
* reclaimed); a later process — a CLI run by `ctx.exec` — leaves it
|
|
105
|
+
* alone and writes at its own exit. No signal is used: signals are
|
|
106
|
+
* claimed by frameworks (SIGUSR2 stops a Temporal worker, restarts
|
|
107
|
+
* nodemon), a socket is nobody's.
|
|
108
|
+
*/
|
|
109
|
+
export const NODE_COVERAGE_HOOK = `"use strict";
|
|
110
|
+
// spectest coverage hook (coverage.node() adapter). See \`spectest docs /services/coverage\`.
|
|
111
|
+
if (process.env.NODE_V8_COVERAGE) {
|
|
112
|
+
const net = require("node:net");
|
|
113
|
+
const fs = require("node:fs");
|
|
114
|
+
const v8 = require("node:v8");
|
|
115
|
+
const SOCK = require("node:path").join(process.env.NODE_V8_COVERAGE, ${JSON.stringify(NODE_COVERAGE_SOCKET)});
|
|
116
|
+
const server = net.createServer((conn) => {
|
|
117
|
+
// A dump is taken only on an explicit "dump" request: a bystander's
|
|
118
|
+
// liveness probe connects and closes without one.
|
|
119
|
+
let buf = "";
|
|
120
|
+
conn.on("data", (chunk) => {
|
|
121
|
+
buf += chunk;
|
|
122
|
+
if (!buf.includes("\\n")) return;
|
|
123
|
+
let reply;
|
|
124
|
+
try {
|
|
125
|
+
if (!buf.startsWith("dump")) throw new Error("unknown request");
|
|
126
|
+
v8.takeCoverage();
|
|
127
|
+
reply = "ok\\n";
|
|
128
|
+
} catch (err) {
|
|
129
|
+
reply = "error " + (err && err.message ? err.message : String(err)) + "\\n";
|
|
130
|
+
}
|
|
131
|
+
conn.end(reply);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
server.unref();
|
|
135
|
+
server.on("error", (err) => {
|
|
136
|
+
if (err.code !== "EADDRINUSE") return;
|
|
137
|
+
// Someone holds the socket. If it answers, it is the live server and
|
|
138
|
+
// this process is a bystander; if not, it is a stale file.
|
|
139
|
+
const probe = net.connect(SOCK);
|
|
140
|
+
probe.on("connect", () => probe.destroy());
|
|
141
|
+
probe.on("error", () => {
|
|
142
|
+
try { fs.unlinkSync(SOCK); } catch {}
|
|
143
|
+
server.listen(SOCK);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
server.listen(SOCK);
|
|
147
|
+
}
|
|
148
|
+
`;
|
|
149
|
+
/**
|
|
150
|
+
* Coverage for a Node service. Sets `NODE_V8_COVERAGE` to the coverage
|
|
151
|
+
* directory (every node process in the container then writes V8
|
|
152
|
+
* coverage JSON when it exits) and `--require`s a hook that lets spectest
|
|
153
|
+
* ask the long-lived server process for a dump at capture time. Nothing
|
|
154
|
+
* for the app to write. Source maps: with `--enable-source-maps` (or a
|
|
155
|
+
* `sourceMappingURL` next to the file) Node records the map in the
|
|
156
|
+
* report, which is what maps a TypeScript service back to its sources.
|
|
157
|
+
*/
|
|
158
|
+
export function node() {
|
|
159
|
+
return {
|
|
160
|
+
name: "node",
|
|
161
|
+
configure(svc) {
|
|
162
|
+
const env = appendEnvFlag(svc.env, "NODE_OPTIONS", `--require ${NODE_COVERAGE_HOOK_PATH}`);
|
|
163
|
+
env.NODE_V8_COVERAGE = COVERAGE_CONTAINER_DIR;
|
|
164
|
+
return {
|
|
165
|
+
...svc,
|
|
166
|
+
env,
|
|
167
|
+
files: [...(svc.files ?? []), { path: NODE_COVERAGE_HOOK_PATH, content: NODE_COVERAGE_HOOK }],
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
async capture(ctx) {
|
|
171
|
+
const { connect } = await import("node:net");
|
|
172
|
+
const path = await import("node:path");
|
|
173
|
+
const sock = path.join(ctx.reportDir, NODE_COVERAGE_SOCKET);
|
|
174
|
+
const reply = await new Promise((resolve, reject) => {
|
|
175
|
+
const chunks = [];
|
|
176
|
+
const c = connect(sock, () => c.write("dump\n"));
|
|
177
|
+
const onAbort = () => {
|
|
178
|
+
c.destroy();
|
|
179
|
+
reject(new Error("timed out waiting for the node hook to write a report"));
|
|
180
|
+
};
|
|
181
|
+
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
|
182
|
+
c.on("data", (b) => chunks.push(b));
|
|
183
|
+
c.on("error", (err) => {
|
|
184
|
+
ctx.signal.removeEventListener("abort", onAbort);
|
|
185
|
+
reject(new Error(err.code === "ENOENT" || err.code === "ECONNREFUSED"
|
|
186
|
+
? `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)?`
|
|
187
|
+
: err.message));
|
|
188
|
+
});
|
|
189
|
+
c.on("close", () => {
|
|
190
|
+
ctx.signal.removeEventListener("abort", onAbort);
|
|
191
|
+
resolve(Buffer.concat(chunks).toString("utf8").trim());
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
if (reply !== "ok")
|
|
195
|
+
throw new Error(`the node hook answered: ${reply || "(nothing)"}`);
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
// ── browser ───────────────────────────────────────────────────────
|
|
200
|
+
/**
|
|
201
|
+
* Coverage for the frontend a service serves. The code runs in the guest
|
|
202
|
+
* browser — spectest's own process — so no report can be written by the
|
|
203
|
+
* container; spectest collects V8 coverage for every script the browser
|
|
204
|
+
* loads from this service's origins, maps it back to source through the
|
|
205
|
+
* served source map, and writes one lcov per capture. Nothing ran yet on
|
|
206
|
+
* this branch ⇒ an lcov with no records, which is a report saying so.
|
|
207
|
+
*/
|
|
208
|
+
export function browser() {
|
|
209
|
+
return {
|
|
210
|
+
name: "browser",
|
|
211
|
+
load(ctx) {
|
|
212
|
+
ctx.collectBrowserScripts();
|
|
213
|
+
},
|
|
214
|
+
async capture(ctx) {
|
|
215
|
+
const lcov = browserCoverageReports().get(ctx.service) ?? "TN:\n";
|
|
216
|
+
await ctx.writeReport("browser.lcov", lcov);
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
// ── command ───────────────────────────────────────────────────────
|
|
221
|
+
/**
|
|
222
|
+
* Run `command` (via `sh -c`) in the container at every capture, so the
|
|
223
|
+
* service writes a fresh report into `/spectest/coverage/`. The escape
|
|
224
|
+
* hatch for a runtime with no shipped adapter.
|
|
225
|
+
*/
|
|
226
|
+
export function command(command) {
|
|
227
|
+
if (typeof command !== "string" || command.trim().length === 0) {
|
|
228
|
+
throw new Error("coverage.command: `command` must be a non-empty shell command");
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
name: "command",
|
|
232
|
+
async capture(ctx) {
|
|
233
|
+
await ctx.exec(command);
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
package/dist/daemon.js
CHANGED
|
@@ -21,9 +21,10 @@ import { existsSync, promises as fs, readFileSync } from "node:fs";
|
|
|
21
21
|
import net from "node:net";
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
import { pathToFileURL } from "node:url";
|
|
24
|
-
import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, proxy as makeProxyDecl,
|
|
25
|
-
import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, readCoverageDir, } from "./harness/coverage.js";
|
|
26
|
-
import {
|
|
24
|
+
import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, proxy as makeProxyDecl, } from "./index.js";
|
|
25
|
+
import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, } from "./harness/coverage.js";
|
|
26
|
+
import { configureBrowserCoverage } from "./browser-coverage.js";
|
|
27
|
+
import { applyCoverageAdapters, coverageAdapters, validateCoverage, } from "./coverage.js";
|
|
27
28
|
import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
|
|
28
29
|
import { acquirePersistentBrowser, mobileKey } from "./browser.js";
|
|
29
30
|
import { isMobileApp, openPersistentMobile } from "./mobile.js";
|
|
@@ -525,10 +526,6 @@ async function ensureCoverage(svc) {
|
|
|
525
526
|
if (svc.coverage === undefined)
|
|
526
527
|
return [];
|
|
527
528
|
validateCoverage(svc.name, svc.coverage);
|
|
528
|
-
// A browser-only opt-in has no container process to write reports, so it
|
|
529
|
-
// gets no mount — its coverage comes from the guest browser.
|
|
530
|
-
if (!coverageUsesContainer(svc.coverage))
|
|
531
|
-
return [];
|
|
532
529
|
const host = coverageHostDir(WORKSPACE, svc.name);
|
|
533
530
|
await fs.mkdir(host, { recursive: true });
|
|
534
531
|
await fs.chmod(host, 0o777);
|
|
@@ -1428,7 +1425,11 @@ function buildIngress(project) {
|
|
|
1428
1425
|
// generic decl set the daemon executes. The special-casing lives in the
|
|
1429
1426
|
// SDK's lowerIngress, not here.
|
|
1430
1427
|
LOWERED = lowerIngress(project);
|
|
1431
|
-
|
|
1428
|
+
BROWSER_COVERAGE_SERVICES.clear();
|
|
1429
|
+
void runCoverageLoadHooks(namedServices(project.environment)).catch((err) => {
|
|
1430
|
+
// eslint-disable-next-line no-console
|
|
1431
|
+
console.warn("[coverage] load hook failed:", err);
|
|
1432
|
+
});
|
|
1432
1433
|
if (!project.fakes)
|
|
1433
1434
|
return;
|
|
1434
1435
|
for (const [name, def] of Object.entries(project.fakes)) {
|
|
@@ -2313,7 +2314,9 @@ const RUNTIME_SERVICES = new Map();
|
|
|
2313
2314
|
// the orchestration helpers want a NamedService, which is the same shape.
|
|
2314
2315
|
function specToNamedService(spec) {
|
|
2315
2316
|
const { name, ...rest } = spec;
|
|
2316
|
-
|
|
2317
|
+
// A runtime spec skipped `defineEnvironment`, so its coverage adapters
|
|
2318
|
+
// get their config-time pass here.
|
|
2319
|
+
return { name, ...applyCoverageAdapters(name, rest) };
|
|
2317
2320
|
}
|
|
2318
2321
|
/** Implementation behind `ctx.startService` / a fake's `ctx.startService`.
|
|
2319
2322
|
* Prepares the image (pulling on first use through the host cache), runs
|
|
@@ -2326,6 +2329,7 @@ async function startRuntimeService(spec) {
|
|
|
2326
2329
|
const t0 = Date.now();
|
|
2327
2330
|
const resv = reserveEvent();
|
|
2328
2331
|
const svc = specToNamedService(spec);
|
|
2332
|
+
await runCoverageLoadHooks([svc]);
|
|
2329
2333
|
const aliases = (spec.hostnames ?? []).map((h) => h.toLowerCase());
|
|
2330
2334
|
const imageRef = svc.image.type === "registry" ? svc.image.reference : "(dockerfile)";
|
|
2331
2335
|
try {
|
|
@@ -2977,33 +2981,50 @@ async function captureServiceLogDeltas() {
|
|
|
2977
2981
|
}));
|
|
2978
2982
|
}
|
|
2979
2983
|
// ────────────────────────────────────────────────────────────────────────
|
|
2980
|
-
// Coverage capture (opt-in per service; see harness/coverage.ts)
|
|
2984
|
+
// Coverage capture (opt-in per service; see coverage.ts + harness/coverage.ts)
|
|
2981
2985
|
// ────────────────────────────────────────────────────────────────────────
|
|
2982
2986
|
//
|
|
2983
|
-
// Read after bring-up and after every test, on every case.
|
|
2984
|
-
//
|
|
2985
|
-
//
|
|
2986
|
-
//
|
|
2987
|
-
//
|
|
2988
|
-
//
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
2987
|
+
// Read after bring-up and after every test, on every case. A service's
|
|
2988
|
+
// adapters run first (each makes its report appear in the directory —
|
|
2989
|
+
// `nodeCoverage` asks the hook for a dump, `browserCoverage` writes the
|
|
2990
|
+
// harness's totals, `commandCoverage` runs the command), then the
|
|
2991
|
+
// directory is read the same way for every service, `coverage: true`
|
|
2992
|
+
// included. The reports live on the rootfs, so what a child fork reads
|
|
2993
|
+
// already includes everything its ancestors ran — no markers, no deltas
|
|
2994
|
+
// (unlike the service logs above). The reports ship verbatim; the server
|
|
2995
|
+
// derives what it needs from the stored bytes.
|
|
2996
|
+
/** Budget for one service's adapters at one capture, all of them. */
|
|
2997
|
+
const COVERAGE_CAPTURE_TIMEOUT_MS = 60_000;
|
|
2998
|
+
/** Services whose adapters asked for browser scripts at /load
|
|
2999
|
+
* (`CoverageLoadContext.collectBrowserScripts`). Cleared on every /load;
|
|
3000
|
+
* a runtime service's adapters add to it when it starts. */
|
|
3001
|
+
const BROWSER_COVERAGE_SERVICES = new Set();
|
|
3002
|
+
/** Run every adapter's `load` hook for `services`, then rebuild the
|
|
3003
|
+
* browser-coverage attribution from whoever asked. The attribution is a
|
|
3004
|
+
* host → service resolver over the lowered ingress, restricted to those
|
|
3005
|
+
* services, so a script from an uncovered service is ignored; null when
|
|
3006
|
+
* nobody asked, which costs nothing. */
|
|
3007
|
+
async function runCoverageLoadHooks(services) {
|
|
3008
|
+
for (const svc of services) {
|
|
3009
|
+
for (const a of coverageAdapters(svc.coverage)) {
|
|
3010
|
+
if (!a.load)
|
|
3011
|
+
continue;
|
|
3012
|
+
await a.load({
|
|
3013
|
+
service: svc.name,
|
|
3014
|
+
collectBrowserScripts: () => {
|
|
3015
|
+
BROWSER_COVERAGE_SERVICES.add(svc.name);
|
|
3016
|
+
},
|
|
3017
|
+
});
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
rebuildBrowserCoverageResolver();
|
|
3021
|
+
}
|
|
3022
|
+
function rebuildBrowserCoverageResolver() {
|
|
3023
|
+
if (BROWSER_COVERAGE_SERVICES.size === 0 || !loaded) {
|
|
3003
3024
|
configureBrowserCoverage(null);
|
|
3004
3025
|
return;
|
|
3005
3026
|
}
|
|
3006
|
-
const all = namedServices(
|
|
3027
|
+
const all = [...namedServices(loaded.project.environment).map((s) => s.name), ...RUNTIME_SERVICES.keys()];
|
|
3007
3028
|
const table = {
|
|
3008
3029
|
proxies: LOWERED.proxies,
|
|
3009
3030
|
aliasesByService: LOWERED.aliasesByService,
|
|
@@ -3012,10 +3033,66 @@ function configureCoverageAttribution(project) {
|
|
|
3012
3033
|
configureBrowserCoverage({
|
|
3013
3034
|
serviceForHost(host) {
|
|
3014
3035
|
const svc = hostToService(host, all, table);
|
|
3015
|
-
return svc &&
|
|
3036
|
+
return svc && BROWSER_COVERAGE_SERVICES.has(svc) ? svc : undefined;
|
|
3016
3037
|
},
|
|
3017
3038
|
});
|
|
3018
3039
|
}
|
|
3040
|
+
/** The context one adapter's `capture` runs against. */
|
|
3041
|
+
function coverageCaptureContext(svc, signal) {
|
|
3042
|
+
const reportDir = coverageHostDir(WORKSPACE, svc.name);
|
|
3043
|
+
return {
|
|
3044
|
+
service: svc.name,
|
|
3045
|
+
reportDir,
|
|
3046
|
+
signal,
|
|
3047
|
+
async exec(command) {
|
|
3048
|
+
const r = await docker(["exec", svc.name, "sh", "-c", command], COVERAGE_CAPTURE_TIMEOUT_MS);
|
|
3049
|
+
if (r.code !== 0) {
|
|
3050
|
+
const tail = capMiddle((r.stderr || r.stdout).trim(), 2000).value;
|
|
3051
|
+
throw new Error(`${JSON.stringify(command)} exited ${r.code}${tail ? `\n${tail}` : ""}`);
|
|
3052
|
+
}
|
|
3053
|
+
return { stdout: r.stdout, stderr: r.stderr };
|
|
3054
|
+
},
|
|
3055
|
+
async writeReport(name, content) {
|
|
3056
|
+
const file = path.join(reportDir, name);
|
|
3057
|
+
if (path.relative(reportDir, file).startsWith("..")) {
|
|
3058
|
+
throw new Error(`writeReport: ${JSON.stringify(name)} leaves the coverage directory`);
|
|
3059
|
+
}
|
|
3060
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
3061
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.tmp`);
|
|
3062
|
+
await fs.writeFile(tmp, content);
|
|
3063
|
+
await fs.rename(tmp, file);
|
|
3064
|
+
},
|
|
3065
|
+
};
|
|
3066
|
+
}
|
|
3067
|
+
/** Run one service's adapters in order under one budget. Returns the
|
|
3068
|
+
* error to fail the capture with, naming the adapter. */
|
|
3069
|
+
async function runCoverageAdapters(svc) {
|
|
3070
|
+
const adapters = coverageAdapters(svc.coverage).filter((a) => a.capture);
|
|
3071
|
+
if (adapters.length === 0)
|
|
3072
|
+
return undefined;
|
|
3073
|
+
const ac = new AbortController();
|
|
3074
|
+
const timer = setTimeout(() => ac.abort(), COVERAGE_CAPTURE_TIMEOUT_MS);
|
|
3075
|
+
const ctx = coverageCaptureContext(svc, ac.signal);
|
|
3076
|
+
try {
|
|
3077
|
+
for (const a of adapters) {
|
|
3078
|
+
try {
|
|
3079
|
+
await Promise.race([
|
|
3080
|
+
a.capture(ctx),
|
|
3081
|
+
new Promise((_, reject) => {
|
|
3082
|
+
ac.signal.addEventListener("abort", () => reject(new Error(`no report within ${COVERAGE_CAPTURE_TIMEOUT_MS / 1000}s`)), { once: true });
|
|
3083
|
+
}),
|
|
3084
|
+
]);
|
|
3085
|
+
}
|
|
3086
|
+
catch (err) {
|
|
3087
|
+
return `coverage adapter "${a.name}" on service "${svc.name}" failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
3088
|
+
}
|
|
3089
|
+
}
|
|
3090
|
+
return undefined;
|
|
3091
|
+
}
|
|
3092
|
+
finally {
|
|
3093
|
+
clearTimeout(timer);
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3019
3096
|
async function captureServiceCoverage() {
|
|
3020
3097
|
const l = loaded;
|
|
3021
3098
|
if (!l)
|
|
@@ -3026,45 +3103,11 @@ async function captureServiceCoverage() {
|
|
|
3026
3103
|
for (const [name, s] of RUNTIME_SERVICES)
|
|
3027
3104
|
byName.set(name, s);
|
|
3028
3105
|
const services = [...byName.values()].filter((s) => s.coverage !== undefined);
|
|
3029
|
-
// Browser coverage is per-service lcov, accumulated in module memory
|
|
3030
|
-
// (browser-coverage.ts) across every harvest; read it once here.
|
|
3031
|
-
const browser = browserCoverageReports();
|
|
3032
3106
|
return Promise.all(services.map(async (svc) => {
|
|
3033
|
-
const
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
if (typeof cov === "object" && cov !== null && cov.command) {
|
|
3038
|
-
const r = await docker(["exec", svc.name, "sh", "-c", cov.command], COVERAGE_COMMAND_TIMEOUT_MS);
|
|
3039
|
-
if (r.code !== 0) {
|
|
3040
|
-
const tail = capMiddle((r.stderr || r.stdout).trim(), 2000).value;
|
|
3041
|
-
return {
|
|
3042
|
-
service: svc.name,
|
|
3043
|
-
reports: [],
|
|
3044
|
-
error: `coverage command for service "${svc.name}" failed (exit ${r.code}): ${JSON.stringify(cov.command)}${tail ? `\n${tail}` : ""}`,
|
|
3045
|
-
};
|
|
3046
|
-
}
|
|
3047
|
-
}
|
|
3048
|
-
const dir = await readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
|
|
3049
|
-
// A service that ALSO does browser coverage may legitimately have
|
|
3050
|
-
// an empty container dir for a test — don't fail if the browser
|
|
3051
|
-
// side has something. Otherwise the container contract stands.
|
|
3052
|
-
if (dir.error && !(coverageUsesBrowser(cov) && browser.has(svc.name))) {
|
|
3053
|
-
return dir;
|
|
3054
|
-
}
|
|
3055
|
-
reports.push(...dir.reports);
|
|
3056
|
-
}
|
|
3057
|
-
// Browser report for this service, when any script it served ran.
|
|
3058
|
-
const lcov = browser.get(svc.name);
|
|
3059
|
-
if (lcov)
|
|
3060
|
-
reports.push({ name: "browser.lcov", content: lcov });
|
|
3061
|
-
if (reports.length === 0 && coverageUsesBrowser(cov) && !coverageUsesContainer(cov)) {
|
|
3062
|
-
// Browser-only service, no browser activity yet this branch. Not an
|
|
3063
|
-
// error — a test needn't open a browser; the report appears once
|
|
3064
|
-
// one does (and forks to descendants from there).
|
|
3065
|
-
return { service: svc.name, reports: [] };
|
|
3066
|
-
}
|
|
3067
|
-
return { service: svc.name, reports };
|
|
3107
|
+
const error = await runCoverageAdapters(svc);
|
|
3108
|
+
if (error)
|
|
3109
|
+
return { service: svc.name, reports: [], error };
|
|
3110
|
+
return readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
|
|
3068
3111
|
}));
|
|
3069
3112
|
}
|
|
3070
3113
|
/**
|
|
@@ -5070,7 +5113,17 @@ export function harnessMethods(state) {
|
|
|
5070
5113
|
// the control plane logs it.
|
|
5071
5114
|
let coverage;
|
|
5072
5115
|
try {
|
|
5073
|
-
|
|
5116
|
+
const captures = await captureServiceCoverage();
|
|
5117
|
+
for (const c of captures) {
|
|
5118
|
+
// After bring-up the code that started the service has run, so a
|
|
5119
|
+
// report set that names no file at all is a misconfigured tool —
|
|
5120
|
+
// visible in the boot log, never a failure (there is no case).
|
|
5121
|
+
if (!c.error && c.reports.length > 0 && c.reports.every((r) => isEmptyReport(r.content))) {
|
|
5122
|
+
// eslint-disable-next-line no-console
|
|
5123
|
+
console.warn(`[coverage] service "${c.service}": every report is empty after bring-up — is coverage enabled at process start?`);
|
|
5124
|
+
}
|
|
5125
|
+
}
|
|
5126
|
+
coverage = parkCoverageBundle(SETUP_CASE_ID, captures);
|
|
5074
5127
|
}
|
|
5075
5128
|
catch (err) {
|
|
5076
5129
|
// eslint-disable-next-line no-console
|