@piwitests/instrumentation-nitro 0.59.0 → 0.61.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @piwitests/instrumentation-nitro
2
2
 
3
- Nitro / Nuxt server plugin for [Piwi Dashboard](https://piwitests.dev) — captures Warning and Error log entries per HTTP request and delivers them to the Piwi Dashboard reporter via the `X-Piwi-Logs` response header.
3
+ Nitro / Nuxt server plugin for [Piwi Dashboard](https://piwitests.dev) — captures Warning and Error log entries per HTTP request and delivers them to the Piwi Dashboard reporter via the `X-Piwi-Logs` response header, and, in a coverage build, the server code each test ran.
4
4
 
5
5
  During a Playwright test run, the reporter reads this header from every response and stores the entries alongside the network request. The entries are then available in the Piwi Dashboard test-case view and are included in the AI diagnosis context.
6
6
 
@@ -44,6 +44,44 @@ export default defineNuxtConfig({ nitro: { modules: ['@piwitests/instrumentation
44
44
 
45
45
  The manifest then lists every route Nitro scanned (`routes/`, `api/`) and every handler the config declares, middleware and a fallback catch-all (`/**`) left out, each with its method (`GET` for a route that answers any method), its pattern (`/api/orders/:id`) and its file relative to the git repository. Without the module, it lists only the routes the server has answered since it started (a route handler matched, so neither a 404 nor a public asset), which the reporter, reading it before any test runs, rarely sees. Nuxt pages are rendered by one catch-all route, so they are not listed: name them in a `piwi.manifest.json`.
46
46
 
47
+ ## Server coverage
48
+
49
+ A **coverage build** records, per Playwright test, the server files, functions and lines the test ran, even when the
50
+ tests run in parallel against one server. The Piwi reporter collects it with `coverage: { server: 'lines' }`
51
+ ([server coverage](https://piwitests.dev/features/server-coverage)). It needs the module above and the plugin, and
52
+ `PIWI_COVERAGE_BUILD=true` when the server is built:
53
+
54
+ ```bash
55
+ PIWI_COVERAGE_BUILD=true npx nitro build # or nitro dev, or nuxi build for a Nuxt app's server/ code
56
+ PIWI_TEST_LOGS_DISABLED=false node .output/server/index.mjs
57
+ ```
58
+
59
+ ```powershell
60
+ $env:PIWI_COVERAGE_BUILD='true'; npx nitro build
61
+ $env:PIWI_TEST_LOGS_DISABLED='false'; node .output/server/index.mjs
62
+ ```
63
+
64
+ - **What the build does:** after Nitro's own transforms, the module instruments the server's own modules (under the
65
+ repository, outside `node_modules`): each function looks up the request being served when it is called, and each
66
+ statement marks itself as run. The lines are mapped to your source files through the build's source maps, when the
67
+ server is built, so the server reads no source map while it runs.
68
+ - **Per request:** a response whose request ran instrumented code carries `X-Piwi-Coverage: <process key>.<sequence>.<secret>`;
69
+ the reporter asks `POST /__piwi/coverage/collect` for the coverage of the ids a test received, and
70
+ `GET /__piwi/coverage/run` for the run's. The answers are the ASP.NET Core package's. A request the server makes to
71
+ itself (`$fetch` while rendering) counts for the request that made it. A module's top-level code, which runs when the
72
+ module loads, counts for no test.
73
+ - **Which lines:** the statements and functions Istanbul counts, each statement at its first line, so a file's
74
+ executable lines are the ones Vitest's coverage counts for it, and the dashboard's
75
+ [combined coverage](https://piwitests.dev/features/combined-coverage) counts both suites on the same lines.
76
+ - **`PIWI_COVERAGE_MODE=run`** records the run level only, with no request named.
77
+ - **Guards:** a coverage build refuses to start where `NODE_ENV` is `production` (which `nitro build` sets in the
78
+ server) unless `PIWI_TEST_LOGS_DISABLED=false`, and warns where the plugin is otherwise off.
79
+ `PIWI_COVERAGE_FORBIDDEN=true` at build time makes a coverage build fail. Never ship a coverage build: it runs slower
80
+ code, and its endpoints serve source paths and line numbers.
81
+ - **Not covered:** dependencies Nitro keeps external, a Nuxt application's pages and components rendered on the
82
+ server, class field initializers, and callbacks a library runs outside the request's async context (they count for
83
+ no test).
84
+
47
85
  ## What gets captured
48
86
 
49
87
  | Source | What is captured |
@@ -0,0 +1,131 @@
1
+ export type CoverageGranularity = 'files' | 'functions' | 'lines';
2
+ export type LineRange = [number, number];
3
+ /**
4
+ * One instrumented file in the span table: its repository-relative path, the
5
+ * checksum of its source, and per function (0 is the module's top-level code)
6
+ * its line range and the line of each payload entry (entry 0 has none: 0).
7
+ */
8
+ export interface CoverageTableFile {
9
+ path: string;
10
+ checksums: string[];
11
+ /** Per function, its first and last line; null for the top-level code. */
12
+ f: Array<LineRange | null>;
13
+ /** Per function, the line of each entry; 0 when unknown, and for entry 0. */
14
+ s: number[][];
15
+ }
16
+ /** The span table the build writes: the instrumented files, by file id. */
17
+ export interface CoverageTable {
18
+ v: 1;
19
+ files: Record<string, CoverageTableFile>;
20
+ }
21
+ /** The coverage of one request: per registered module, per function, its payload. */
22
+ export interface RequestCoverage {
23
+ p: Array<Array<Uint8Array | undefined> | undefined>;
24
+ /** Instrumented code ran for the request: its response gets an id. */
25
+ ran: boolean;
26
+ /** Collected, evicted or never named: later writes go to the run level. */
27
+ closed: boolean;
28
+ /** The id its response carried, once named. */
29
+ id?: string;
30
+ /** About how many bytes its payloads hold. */
31
+ bytes: number;
32
+ }
33
+ export interface CoverageFile {
34
+ path: string;
35
+ runtime: 'server';
36
+ functions?: LineRange[];
37
+ lines?: LineRange[];
38
+ executable?: LineRange[];
39
+ allFunctions?: LineRange[];
40
+ checksums?: string[];
41
+ }
42
+ /** Ended requests kept until collected, at most. */
43
+ export declare const MAX_KEPT_REQUESTS = 5000;
44
+ /** Bytes of payloads kept, about, at most. */
45
+ export declare const MAX_KEPT_BYTES: number;
46
+ /** Request ids per collection call, at most. */
47
+ export declare const MAX_IDS_PER_CALL = 10000;
48
+ interface CoverageState {
49
+ run: boolean;
50
+ modules: CoverageModule[];
51
+ byId: Map<string, CoverageModule>;
52
+ table: CoverageTable | null;
53
+ tableSource: string | null;
54
+ enabled: boolean;
55
+ instanceId: string;
56
+ startedAt: string;
57
+ sequence: number;
58
+ kept: Map<string, RequestCoverage>;
59
+ keptBytes: number;
60
+ /**
61
+ * The payloads of the requests closed last, oldest first, read again by the run level: code a request started can
62
+ * still run once it is collected (a promise not awaited), and writes into the payloads it already holds.
63
+ */
64
+ closed: RequestCoverage['p'][];
65
+ closedBytes: number;
66
+ }
67
+ /** An instrumented module: its shared payloads, and the lookup its functions call on entry. */
68
+ export declare class CoverageModule {
69
+ readonly id: string;
70
+ /** Its index in the state, the index of its payloads in a request's coverage. */
71
+ readonly n: number;
72
+ /** The payload length of each function. */
73
+ readonly l: number[];
74
+ /** The shared payload of each function, the run level. */
75
+ readonly s: Uint8Array[];
76
+ private readonly state;
77
+ constructor(id: string,
78
+ /** Its index in the state, the index of its payloads in a request's coverage. */
79
+ n: number,
80
+ /** The payload length of each function. */
81
+ l: number[],
82
+ /** The shared payload of each function, the run level. */
83
+ s: Uint8Array[], state: CoverageState);
84
+ /** The payload function `i` records into: the request's being served, else the shared one. */
85
+ f(i: number): Uint8Array;
86
+ }
87
+ /** Called by each instrumented module when it loads: its file id and the payload length of each function. */
88
+ export declare function __piwiModule(id: string, lengths: number[]): CoverageModule;
89
+ /**
90
+ * Called once at startup by the plugin the module generates, with the span
91
+ * table as JSON. A coverage build refuses to start in production unless the
92
+ * plugin is explicitly enabled there, and warns in any other environment
93
+ * where it serves no coverage.
94
+ */
95
+ export declare function enableCoverage(table: string): void;
96
+ /** Whether this process runs a coverage build. */
97
+ export declare function coverageActive(): boolean;
98
+ /** A new request's coverage, or undefined when nothing records per request. */
99
+ export declare function beginRequestCoverage(): RequestCoverage | undefined;
100
+ /**
101
+ * The id a response names its request's coverage by, once instrumented code ran
102
+ * for it; the request is then kept until collected. Undefined when nothing ran.
103
+ */
104
+ export declare function nameRequest(coverage: RequestCoverage): string | undefined;
105
+ /** Called when the response is done: a request no response named goes to the run level. */
106
+ export declare function settleRequest(coverage: RequestCoverage): void;
107
+ export declare function instance(): {
108
+ id: string;
109
+ startedAt: string;
110
+ };
111
+ export declare function parseGranularity(value: unknown): CoverageGranularity;
112
+ export declare class CoverageRequestError extends Error {
113
+ readonly status: number;
114
+ constructor(status: number, message: string);
115
+ }
116
+ /**
117
+ * Answer `POST /__piwi/coverage/collect`: per group, the coverage of its
118
+ * requests merged, and the ids not held (`missing`); the requests answered are
119
+ * forgotten, their coverage kept at run level.
120
+ */
121
+ export declare function collectCoverage(body: unknown): object;
122
+ /**
123
+ * Answer `GET /__piwi/coverage/run`: the code that ran outside any request,
124
+ * every request collected or evicted, and the requests still kept; at `lines`,
125
+ * each file's executable lines and every function, for every file of the span
126
+ * table, loaded or not.
127
+ */
128
+ export declare function runCoverage(granularity: CoverageGranularity): object;
129
+ /** Forget every module and request: for tests. */
130
+ export declare function resetCoverageForTests(): void;
131
+ export {};
@@ -0,0 +1,377 @@
1
+ /**
2
+ * The coverage recorder of a coverage build (`PIWI_COVERAGE_BUILD=true`). The
3
+ * coverage transform (`coverage-transform.ts`) makes every instrumented module
4
+ * register here (`__piwiModule`) and every function ask its module for its
5
+ * payload when it is called: a `Uint8Array` where entry 0 marks the function as
6
+ * entered and each other entry one statement as run.
7
+ *
8
+ * - Inside a request, a function records into that request's payload, found
9
+ * through the request store the plugin sets (`requestScope`); outside one, or
10
+ * once its request is collected, into the shared payload, the run level.
11
+ * - A module's top-level code always records into the shared payload.
12
+ * - The plugin names a request on its response (`X-Piwi-Coverage`) when
13
+ * instrumented code ran for it, and keeps it until the reporter collects it
14
+ * (`POST /__piwi/coverage/collect`), up to `MAX_KEPT_REQUESTS` and
15
+ * `MAX_KEPT_BYTES`, the oldest going first. `GET /__piwi/coverage/run` answers
16
+ * the run level: the shared payloads and the requests still kept.
17
+ *
18
+ * The answers have the shape of the ASP.NET Core package's: the reporter reads
19
+ * them the same way. Line numbers come from the span table the build wrote
20
+ * (`enableCoverage`), never from a source map at run time.
21
+ *
22
+ * The state lives on a global, so a second copy of this module in the bundle
23
+ * records into the same one.
24
+ */
25
+ import { randomBytes } from 'node:crypto';
26
+ import { requestScope } from './request-scope.js';
27
+ /** Ended requests kept until collected, at most. */
28
+ export const MAX_KEPT_REQUESTS = 5000;
29
+ /** Bytes of payloads kept, about, at most. */
30
+ export const MAX_KEPT_BYTES = 64 * 1024 * 1024;
31
+ /** Request ids per collection call, at most. */
32
+ export const MAX_IDS_PER_CALL = 10_000;
33
+ const STATE_KEY = Symbol.for('piwitests.coverage');
34
+ const globals = globalThis;
35
+ function state() {
36
+ return (globals[STATE_KEY] ??= {
37
+ run: process.env.PIWI_COVERAGE_MODE === 'run',
38
+ modules: [],
39
+ byId: new Map(),
40
+ table: null,
41
+ tableSource: null,
42
+ enabled: false,
43
+ instanceId: randomBytes(6).toString('hex'),
44
+ startedAt: new Date().toISOString(),
45
+ sequence: 0,
46
+ kept: new Map(),
47
+ keptBytes: 0,
48
+ closed: [],
49
+ closedBytes: 0,
50
+ });
51
+ }
52
+ /** An instrumented module: its shared payloads, and the lookup its functions call on entry. */
53
+ export class CoverageModule {
54
+ constructor(id,
55
+ /** Its index in the state, the index of its payloads in a request's coverage. */
56
+ n,
57
+ /** The payload length of each function. */
58
+ l,
59
+ /** The shared payload of each function, the run level. */
60
+ s, state) {
61
+ this.id = id;
62
+ this.n = n;
63
+ this.l = l;
64
+ this.s = s;
65
+ this.state = state;
66
+ }
67
+ /** The payload function `i` records into: the request's being served, else the shared one. */
68
+ f(i) {
69
+ if (this.state.run)
70
+ return this.s[i];
71
+ const coverage = requestScope.getStore()?.coverage;
72
+ if (coverage === undefined || coverage.closed)
73
+ return this.s[i];
74
+ let functions = coverage.p[this.n];
75
+ if (functions === undefined)
76
+ functions = coverage.p[this.n] = [];
77
+ let payload = functions[i];
78
+ if (payload === undefined) {
79
+ const length = this.l[i];
80
+ payload = functions[i] = new Uint8Array(length);
81
+ coverage.bytes += length + 32;
82
+ // A request already named is kept: what it records from now on counts toward the kept bytes too.
83
+ if (coverage.id)
84
+ this.state.keptBytes += length + 32;
85
+ coverage.ran = true;
86
+ }
87
+ return payload;
88
+ }
89
+ }
90
+ /** Called by each instrumented module when it loads: its file id and the payload length of each function. */
91
+ export function __piwiModule(id, lengths) {
92
+ const current = state();
93
+ const known = current.byId.get(id);
94
+ if (known && known.l.length === lengths.length && known.l.every((length, i) => length === lengths[i]))
95
+ return known;
96
+ const module = new CoverageModule(id, current.modules.length, lengths, lengths.map((length) => new Uint8Array(length)), current);
97
+ current.modules.push(module);
98
+ current.byId.set(id, module);
99
+ return module;
100
+ }
101
+ /** Whether the guard the plugin applies lets it serve coverage in this environment. */
102
+ function servesCoverage() {
103
+ const env = process.env.NODE_ENV;
104
+ if (process.env.PIWI_TEST_LOGS_DISABLED === 'true')
105
+ return false;
106
+ return env === 'development' || env === 'test' || process.env.PIWI_TEST_LOGS_DISABLED === 'false';
107
+ }
108
+ /**
109
+ * Called once at startup by the plugin the module generates, with the span
110
+ * table as JSON. A coverage build refuses to start in production unless the
111
+ * plugin is explicitly enabled there, and warns in any other environment
112
+ * where it serves no coverage.
113
+ */
114
+ export function enableCoverage(table) {
115
+ const current = state();
116
+ current.enabled = true;
117
+ current.tableSource = table;
118
+ current.table = null;
119
+ if (servesCoverage())
120
+ return;
121
+ const message = 'this server was built with Piwi coverage (PIWI_COVERAGE_BUILD=true), which runs slower code and serves source paths and line numbers';
122
+ if (process.env.NODE_ENV === 'production') {
123
+ throw new Error(`[piwi] ${message}: refusing to start in production. Build without PIWI_COVERAGE_BUILD, or set PIWI_TEST_LOGS_DISABLED=false to serve it anyway.`);
124
+ }
125
+ console.warn(`[piwi] ${message}, and serves no coverage in this environment (NODE_ENV=${process.env.NODE_ENV ?? 'unset'}).`);
126
+ }
127
+ /** Whether this process runs a coverage build. */
128
+ export function coverageActive() {
129
+ const current = globals[STATE_KEY];
130
+ return current !== undefined && (current.enabled || current.modules.length > 0);
131
+ }
132
+ /** A new request's coverage, or undefined when nothing records per request. */
133
+ export function beginRequestCoverage() {
134
+ if (!coverageActive() || state().run)
135
+ return undefined;
136
+ return { p: [], ran: false, closed: false, bytes: 0 };
137
+ }
138
+ /** Add a request's payloads to the shared ones, the run level. */
139
+ function mergeShared(payloads) {
140
+ const { modules } = state();
141
+ payloads.forEach((functions, n) => {
142
+ const module = modules[n];
143
+ if (!functions || !module)
144
+ return;
145
+ functions.forEach((payload, i) => {
146
+ if (!payload)
147
+ return;
148
+ const shared = module.s[i];
149
+ if (!shared)
150
+ return;
151
+ for (let k = 0; k < payload.length; k++)
152
+ if (payload[k])
153
+ shared[k] = 1;
154
+ });
155
+ });
156
+ }
157
+ /**
158
+ * Add a request's payloads to the run level and stop handing them out. They stay among the closed ones until
159
+ * `MAX_KEPT_REQUESTS` or `MAX_KEPT_BYTES` pushes them out, merged once more then, so what a function already running
160
+ * still records is not lost.
161
+ */
162
+ function close(coverage) {
163
+ if (coverage.closed)
164
+ return;
165
+ coverage.closed = true;
166
+ mergeShared(coverage.p);
167
+ const current = state();
168
+ current.closed.push(coverage.p);
169
+ current.closedBytes += coverage.bytes;
170
+ while (current.closed.length > MAX_KEPT_REQUESTS || current.closedBytes > MAX_KEPT_BYTES) {
171
+ const oldest = current.closed.shift();
172
+ mergeShared(oldest);
173
+ current.closedBytes -= bytesOf(oldest);
174
+ }
175
+ coverage.p = [];
176
+ }
177
+ function bytesOf(payloads) {
178
+ let bytes = 0;
179
+ for (const functions of payloads)
180
+ for (const payload of functions ?? [])
181
+ if (payload)
182
+ bytes += payload.length + 32;
183
+ return bytes;
184
+ }
185
+ /**
186
+ * The id a response names its request's coverage by, once instrumented code ran
187
+ * for it; the request is then kept until collected. Undefined when nothing ran.
188
+ */
189
+ export function nameRequest(coverage) {
190
+ if (coverage.id)
191
+ return coverage.id;
192
+ if (!coverage.ran || coverage.closed)
193
+ return undefined;
194
+ const current = state();
195
+ coverage.id = `${current.instanceId}.${++current.sequence}.${randomBytes(16).toString('hex')}`;
196
+ current.kept.set(coverage.id, coverage);
197
+ current.keptBytes += coverage.bytes;
198
+ for (const [id, oldest] of current.kept) {
199
+ if (current.kept.size <= MAX_KEPT_REQUESTS && current.keptBytes <= MAX_KEPT_BYTES)
200
+ break;
201
+ if (id === coverage.id)
202
+ break;
203
+ forget(id, oldest);
204
+ }
205
+ return coverage.id;
206
+ }
207
+ /** Called when the response is done: a request no response named goes to the run level. */
208
+ export function settleRequest(coverage) {
209
+ if (!coverage.id)
210
+ close(coverage);
211
+ }
212
+ function forget(id, coverage) {
213
+ const current = state();
214
+ current.kept.delete(id);
215
+ current.keptBytes -= coverage.bytes;
216
+ close(coverage);
217
+ }
218
+ function table() {
219
+ const current = state();
220
+ if (!current.table) {
221
+ let parsed = null;
222
+ try {
223
+ parsed = current.tableSource ? JSON.parse(current.tableSource) : null;
224
+ }
225
+ catch {
226
+ parsed = null;
227
+ }
228
+ current.table = parsed?.v === 1 && parsed.files ? parsed : { v: 1, files: {} };
229
+ }
230
+ return current.table;
231
+ }
232
+ function toRanges(values) {
233
+ const sorted = [...new Set(values)].filter((value) => value > 0).sort((a, b) => a - b);
234
+ const ranges = [];
235
+ for (const value of sorted) {
236
+ const last = ranges[ranges.length - 1];
237
+ if (last && value === last[1] + 1)
238
+ last[1] = value;
239
+ else
240
+ ranges.push([value, value]);
241
+ }
242
+ return ranges;
243
+ }
244
+ function uniqueFunctions(ranges) {
245
+ const byKey = new Map();
246
+ for (const range of ranges)
247
+ byKey.set(`${range[0]}:${range[1]}`, range);
248
+ return [...byKey.values()].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
249
+ }
250
+ /** Add one function's payload to its file's accumulator. */
251
+ function addPayload(files, module, i, payload) {
252
+ const file = table().files[module.id];
253
+ if (!file)
254
+ return;
255
+ let ran = false;
256
+ for (let k = 0; k < payload.length; k++)
257
+ if (payload[k])
258
+ ran = true;
259
+ if (!ran)
260
+ return;
261
+ let accumulator = files.get(file.path);
262
+ if (!accumulator)
263
+ files.set(file.path, (accumulator = { file, functions: [], lines: [] }));
264
+ const range = file.f[i];
265
+ if (range && payload[0])
266
+ accumulator.functions.push(range);
267
+ const lines = file.s[i] ?? [];
268
+ for (let k = 1; k < payload.length; k++) {
269
+ const line = lines[k];
270
+ if (payload[k] && line)
271
+ accumulator.lines.push(line);
272
+ }
273
+ }
274
+ function addRequest(files, coverage) {
275
+ const { modules } = state();
276
+ coverage.p.forEach((functions, n) => {
277
+ const module = modules[n];
278
+ if (!functions || !module)
279
+ return;
280
+ functions.forEach((payload, i) => payload && addPayload(files, module, i, payload));
281
+ });
282
+ }
283
+ function fileOf(accumulator, granularity) {
284
+ const out = { path: accumulator.file.path, runtime: 'server' };
285
+ if (granularity === 'files')
286
+ return out;
287
+ out.functions = uniqueFunctions(accumulator.functions);
288
+ if (granularity === 'lines')
289
+ out.lines = toRanges(accumulator.lines);
290
+ out.checksums = accumulator.file.checksums;
291
+ return out;
292
+ }
293
+ export function instance() {
294
+ const current = state();
295
+ return { id: current.instanceId, startedAt: current.startedAt };
296
+ }
297
+ export function parseGranularity(value) {
298
+ return value === 'files' || value === 'functions' ? value : 'lines';
299
+ }
300
+ export class CoverageRequestError extends Error {
301
+ constructor(status, message) {
302
+ super(message);
303
+ this.status = status;
304
+ }
305
+ }
306
+ /**
307
+ * Answer `POST /__piwi/coverage/collect`: per group, the coverage of its
308
+ * requests merged, and the ids not held (`missing`); the requests answered are
309
+ * forgotten, their coverage kept at run level.
310
+ */
311
+ export function collectCoverage(body) {
312
+ const request = body;
313
+ if (!request || typeof request !== 'object' || !Array.isArray(request.groups)) {
314
+ throw new CoverageRequestError(400, 'expected { granularity, groups: [{ key, requests }] }');
315
+ }
316
+ const granularity = parseGranularity(request.granularity);
317
+ const groups = request.groups;
318
+ const ids = groups.reduce((count, group) => count + (Array.isArray(group?.requests) ? group.requests.length : 0), 0);
319
+ if (ids > MAX_IDS_PER_CALL)
320
+ throw new CoverageRequestError(413, `at most ${MAX_IDS_PER_CALL} request ids per call`);
321
+ const current = state();
322
+ const answered = [];
323
+ const out = groups.map((group) => {
324
+ const files = new Map();
325
+ const missing = [];
326
+ for (const id of Array.isArray(group?.requests) ? group.requests : []) {
327
+ const coverage = typeof id === 'string' ? current.kept.get(id) : undefined;
328
+ if (!coverage) {
329
+ if (typeof id === 'string')
330
+ missing.push(id);
331
+ continue;
332
+ }
333
+ addRequest(files, coverage);
334
+ answered.push([id, coverage]);
335
+ }
336
+ return { key: group?.key, files: [...files.values()].map((file) => fileOf(file, granularity)), missing };
337
+ });
338
+ for (const [id, coverage] of answered)
339
+ if (current.kept.has(id))
340
+ forget(id, coverage);
341
+ return { v: 1, instance: instance(), groups: out };
342
+ }
343
+ /**
344
+ * Answer `GET /__piwi/coverage/run`: the code that ran outside any request,
345
+ * every request collected or evicted, and the requests still kept; at `lines`,
346
+ * each file's executable lines and every function, for every file of the span
347
+ * table, loaded or not.
348
+ */
349
+ export function runCoverage(granularity) {
350
+ const current = state();
351
+ const files = new Map();
352
+ for (const payloads of current.closed)
353
+ mergeShared(payloads);
354
+ for (const module of current.modules)
355
+ module.s.forEach((payload, i) => addPayload(files, module, i, payload));
356
+ for (const coverage of current.kept.values())
357
+ addRequest(files, coverage);
358
+ const out = [...files.values()].map((file) => fileOf(file, granularity));
359
+ if (granularity === 'lines') {
360
+ const byPath = new Map(out.map((file) => [file.path, file]));
361
+ for (const file of Object.values(table().files)) {
362
+ let entry = byPath.get(file.path);
363
+ if (!entry) {
364
+ entry = { path: file.path, runtime: 'server', functions: [], lines: [], checksums: file.checksums };
365
+ byPath.set(file.path, entry);
366
+ out.push(entry);
367
+ }
368
+ entry.executable = toRanges(file.s.flatMap((lines) => lines.slice(1)));
369
+ entry.allFunctions = uniqueFunctions(file.f.filter((range) => range !== null));
370
+ }
371
+ }
372
+ return { v: 1, instance: instance(), files: out.sort((a, b) => a.path.localeCompare(b.path)) };
373
+ }
374
+ /** Forget every module and request: for tests. */
375
+ export function resetCoverageForTests() {
376
+ delete globals[STATE_KEY];
377
+ }
@@ -0,0 +1,68 @@
1
+ import MagicString from 'magic-string';
2
+ import { type SourceMapInput } from '@jridgewell/trace-mapping';
3
+ import type { CoverageTableFile } from './coverage-runtime.js';
4
+ /** The id of the plugin the module generates to hand the span table to the runtime. */
5
+ export declare const COVERAGE_PLUGIN_ID = "#piwi/coverage";
6
+ /** The source of the generated plugin: it hands the span table to the runtime at startup. */
7
+ export declare function coveragePluginSource(runtimePath: string): string;
8
+ /** An ESTree node as Rollup's parser returns it. */
9
+ interface Node {
10
+ type: string;
11
+ start: number;
12
+ end: number;
13
+ [key: string]: unknown;
14
+ }
15
+ /**
16
+ * The offset of an arrow function's `=>`: the first one from `from` (the end of its last parameter, else its start)
17
+ * that is not inside a comment.
18
+ */
19
+ export declare function arrowToken(code: string, from: number, to: number): number;
20
+ export interface InstrumentResult {
21
+ code: string;
22
+ map: ReturnType<MagicString['generateMap']>;
23
+ file: CoverageTableFile;
24
+ }
25
+ export interface InstrumentOptions {
26
+ /** The file's id in the span table. */
27
+ fileId: string;
28
+ /** The import specifier of the coverage runtime. */
29
+ runtime: string;
30
+ /** Its path relative to the repository root, `/`-separated. */
31
+ path: string;
32
+ /** The checksums of its source, `sha256:<hex>`. */
33
+ checksums: string[];
34
+ /** The original line of a 1-based line and 0-based column of `code`; null when unknown. */
35
+ originalLine: (line: number, column: number) => number | null;
36
+ }
37
+ /** Instrument one module's code, given its ESTree (`Program`). */
38
+ export declare function instrument(code: string, program: Node, options: InstrumentOptions): InstrumentResult;
39
+ /** The Rollup plugin context methods the transform uses. */
40
+ interface TransformContext {
41
+ parse(code: string): unknown;
42
+ getCombinedSourcemap(): SourceMapInput;
43
+ }
44
+ export interface CoveragePluginOptions {
45
+ /** The repository root: paths are relative to it, and only files under it are instrumented. */
46
+ root: string;
47
+ /** The coverage runtime's file, imported by every instrumented module. */
48
+ runtime: string;
49
+ /** Directories whose files are never instrumented: the integration's own. */
50
+ exclude: string[];
51
+ }
52
+ /** Whether a module id is application code the transform instruments. */
53
+ export declare function instrumentable(id: string, options: CoveragePluginOptions): boolean;
54
+ /** The Rollup plugin of a coverage build. */
55
+ export declare function coverageRollupPlugin(options: CoveragePluginOptions): {
56
+ name: string;
57
+ transform(this: TransformContext, code: string, id: string): {
58
+ code: string;
59
+ map: import("magic-string").SourceMap;
60
+ } | null;
61
+ renderChunk(this: {
62
+ getModuleIds(): IterableIterator<string>;
63
+ }, code: string): {
64
+ code: string;
65
+ map: import("magic-string").SourceMap;
66
+ } | null;
67
+ };
68
+ export {};
@@ -0,0 +1,306 @@
1
+ /**
2
+ * The coverage transform: the Rollup plugin the Nitro module adds to a coverage
3
+ * build (`PIWI_COVERAGE_BUILD=true`). It runs after Nitro's own transforms, so
4
+ * it reads JavaScript, and rewrites each application module so that:
5
+ *
6
+ * - the module registers with the coverage runtime (`__piwiModule`), with the
7
+ * payload length of each of its functions;
8
+ * - each function, on entry, asks for its payload (`__piwi_m.f(i)`: the request's
9
+ * being served, else the shared one) and marks entry 0;
10
+ * - each statement marks its own entry before it runs;
11
+ * - top-level statements mark the module's shared payload, the run level.
12
+ *
13
+ * The statements and functions are those `istanbul-lib-instrument` counts, and
14
+ * a statement's line is its first line (a variable's, its initializer's), as
15
+ * Istanbul and Vitest's coverage count them. Lines are mapped to the original
16
+ * file through the combined source map at build time and written into the span
17
+ * table the generated plugin hands to the runtime; a module carries only its
18
+ * file id and payload lengths.
19
+ */
20
+ import { createHash } from 'node:crypto';
21
+ import { readFileSync } from 'node:fs';
22
+ import MagicString from 'magic-string';
23
+ import { LEAST_UPPER_BOUND, TraceMap, originalPositionFor } from '@jridgewell/trace-mapping';
24
+ /** The id of the plugin the module generates to hand the span table to the runtime. */
25
+ export const COVERAGE_PLUGIN_ID = '#piwi/coverage';
26
+ /** What the generated plugin carries until the bundle is rendered, then the span table as JSON. */
27
+ const TABLE_PLACEHOLDER = '__PIWI_COVERAGE_TABLE__';
28
+ const TABLE_PLACEHOLDER_LITERAL = /(["'])__PIWI_COVERAGE_TABLE__\1/;
29
+ /** The source of the generated plugin: it hands the span table to the runtime at startup. */
30
+ export function coveragePluginSource(runtimePath) {
31
+ return [
32
+ `import { enableCoverage } from ${JSON.stringify(runtimePath)};`,
33
+ `export default function piwiCoverage() {`,
34
+ ` enableCoverage(${JSON.stringify(TABLE_PLACEHOLDER)});`,
35
+ `}`,
36
+ '',
37
+ ].join('\n');
38
+ }
39
+ const FUNCTIONS = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']);
40
+ /** The statements Istanbul counts, besides variable declarators with an initializer. */
41
+ const STATEMENTS = new Set([
42
+ 'ExpressionStatement',
43
+ 'BreakStatement',
44
+ 'ContinueStatement',
45
+ 'DebuggerStatement',
46
+ 'ReturnStatement',
47
+ 'ThrowStatement',
48
+ 'TryStatement',
49
+ 'IfStatement',
50
+ 'ForStatement',
51
+ 'ForInStatement',
52
+ 'ForOfStatement',
53
+ 'WhileStatement',
54
+ 'DoWhileStatement',
55
+ 'SwitchStatement',
56
+ 'WithStatement',
57
+ 'LabeledStatement',
58
+ ]);
59
+ /** The nodes whose `body` is a list of statements, each taking its own marker. */
60
+ const LISTS = new Set(['Program', 'BlockStatement', 'StaticBlock']);
61
+ /** The node keys a statement's own body sits under: a statement there, if not a block, is wrapped in one. */
62
+ const BODY_KEYS = new Set(['consequent', 'alternate', 'body']);
63
+ /**
64
+ * The offset of an arrow function's `=>`: the first one from `from` (the end of its last parameter, else its start)
65
+ * that is not inside a comment.
66
+ */
67
+ export function arrowToken(code, from, to) {
68
+ for (let i = from; i < to; i++) {
69
+ if (code.startsWith('/*', i)) {
70
+ const end = code.indexOf('*/', i + 2);
71
+ i = end < 0 ? to : end + 1;
72
+ }
73
+ else if (code.startsWith('//', i)) {
74
+ const end = code.indexOf('\n', i + 2);
75
+ i = end < 0 ? to : end;
76
+ }
77
+ else if (code.startsWith('=>', i)) {
78
+ return i;
79
+ }
80
+ }
81
+ return code.lastIndexOf('=>', to);
82
+ }
83
+ function isNode(value) {
84
+ return typeof value === 'object' && value !== null && typeof value.type === 'string';
85
+ }
86
+ /** Instrument one module's code, given its ESTree (`Program`). */
87
+ export function instrument(code, program, options) {
88
+ const out = new MagicString(code);
89
+ const functions = [];
90
+ const top = { index: 0, payload: '__piwi_t', entries: [] };
91
+ functions.push({ range: null, scope: top });
92
+ const lineStarts = [0];
93
+ for (let i = 0; i < code.length; i++)
94
+ if (code.charCodeAt(i) === 10)
95
+ lineStarts.push(i + 1);
96
+ const lineOf = (offset) => {
97
+ let low = 0;
98
+ let high = lineStarts.length - 1;
99
+ while (low < high) {
100
+ const middle = (low + high + 1) >> 1;
101
+ if (lineStarts[middle] <= offset)
102
+ low = middle;
103
+ else
104
+ high = middle - 1;
105
+ }
106
+ return options.originalLine(low + 1, offset - lineStarts[low]) ?? 0;
107
+ };
108
+ /** The markers a statement adds, or none: its entries are added to the scope. */
109
+ const markersFor = (statement, scope) => {
110
+ let target = statement;
111
+ if (statement.type === 'ExportNamedDeclaration' && isNode(statement.declaration))
112
+ target = statement.declaration;
113
+ const offsets = [];
114
+ if (target.type === 'VariableDeclaration') {
115
+ for (const declarator of target.declarations ?? []) {
116
+ if (isNode(declarator.init))
117
+ offsets.push(declarator.init.start);
118
+ }
119
+ }
120
+ else if (STATEMENTS.has(target.type)) {
121
+ offsets.push(target.start);
122
+ }
123
+ let markers = '';
124
+ for (const offset of offsets) {
125
+ scope.entries.push(offset);
126
+ markers += `${scope.payload}[${scope.entries.length}]=1;`;
127
+ }
128
+ return markers;
129
+ };
130
+ const visitList = (statements, scope) => {
131
+ for (const statement of statements) {
132
+ if (FUNCTIONS.has(statement.type)) {
133
+ visitFunction(statement, null, '');
134
+ continue;
135
+ }
136
+ if (statement.type === 'BlockStatement' || statement.type === 'StaticBlock') {
137
+ visitList(statement.body, scope);
138
+ continue;
139
+ }
140
+ const markers = markersFor(statement, scope);
141
+ if (markers)
142
+ out.appendLeft(statement.start, markers);
143
+ visitChildren(statement, scope);
144
+ }
145
+ };
146
+ const visitFunction = (fn, parent, key) => {
147
+ const isMethod = parent !== null &&
148
+ key === 'value' &&
149
+ (parent.type === 'MethodDefinition' || (parent.type === 'Property' && (parent.method || parent.kind !== 'init')));
150
+ const start = isMethod ? parent.start : fn.start;
151
+ const own = { index: functions.length, payload: '__piwi_p', entries: [] };
152
+ functions.push({ range: [start, Math.max(start, fn.end - 1)], scope: own });
153
+ const prologue = `const __piwi_p=(__piwi_m||__piwi_mi()).f(${own.index});__piwi_p[0]=1;`;
154
+ for (const param of fn.params ?? [])
155
+ visit(param, fn, 'params', own);
156
+ const body = fn.body;
157
+ if (body.type === 'BlockStatement') {
158
+ const statements = body.body;
159
+ let at = body.start + 1;
160
+ for (const statement of statements) {
161
+ if (statement.type !== 'ExpressionStatement' || typeof statement.directive !== 'string')
162
+ break;
163
+ at = statement.end;
164
+ }
165
+ // After a directive, whose semicolon may be missing (`"use strict"` then a new line).
166
+ out.appendLeft(at, at > body.start + 1 ? `;${prologue}` : prologue);
167
+ visitList(statements.filter((statement) => typeof statement.directive !== 'string'), own);
168
+ return;
169
+ }
170
+ // An expression body: `x => expr` becomes `x => {…; return (expr)}`.
171
+ own.entries.push(body.start);
172
+ const params = fn.params ?? [];
173
+ const last = params[params.length - 1];
174
+ const arrow = arrowToken(code, last ? last.end : fn.start, body.start);
175
+ out.appendLeft(arrow + 2, `{${prologue}__piwi_p[1]=1;return (`);
176
+ out.prependLeft(fn.end, ')}');
177
+ visit(body, fn, 'body', own);
178
+ };
179
+ const visitChildren = (node, scope) => {
180
+ for (const [key, value] of Object.entries(node)) {
181
+ if (key === 'type' || key === 'start' || key === 'end')
182
+ continue;
183
+ if (Array.isArray(value)) {
184
+ if ((node.type === 'SwitchCase' && key === 'consequent') || LISTS.has(node.type)) {
185
+ visitList(value.filter(isNode), scope);
186
+ continue;
187
+ }
188
+ for (const item of value)
189
+ if (isNode(item))
190
+ visit(item, node, key, scope);
191
+ }
192
+ else if (isNode(value)) {
193
+ visit(value, node, key, scope);
194
+ }
195
+ }
196
+ };
197
+ const visit = (node, parent, key, scope) => {
198
+ if (FUNCTIONS.has(node.type)) {
199
+ visitFunction(node, parent, key);
200
+ return;
201
+ }
202
+ if (node.type === 'BlockStatement' || node.type === 'StaticBlock') {
203
+ visitList(node.body, scope);
204
+ return;
205
+ }
206
+ // A statement standing as another's body (`if (a) b();`) is wrapped in a block to take its marker. A labeled
207
+ // statement's body is left as it is, so `continue label` keeps its loop.
208
+ if (parent !== null &&
209
+ parent.type !== 'LabeledStatement' &&
210
+ BODY_KEYS.has(key) &&
211
+ !Array.isArray(parent[key]) &&
212
+ (STATEMENTS.has(node.type) || node.type === 'VariableDeclaration') &&
213
+ !FUNCTIONS.has(parent.type)) {
214
+ const markers = markersFor(node, scope);
215
+ if (markers) {
216
+ out.appendLeft(node.start, `{${markers}`);
217
+ out.prependLeft(node.end, '}');
218
+ }
219
+ }
220
+ visitChildren(node, scope);
221
+ };
222
+ const body = program.body;
223
+ let at = 0;
224
+ for (const statement of body) {
225
+ if (statement.type !== 'ExpressionStatement' || typeof statement.directive !== 'string')
226
+ break;
227
+ at = statement.end;
228
+ }
229
+ visitList(body.filter((statement) => typeof statement.directive !== 'string'), top);
230
+ const lengths = functions.map(({ scope }) => scope.entries.length + 1);
231
+ // Before the top-level statements' markers, which may sit at the same offset.
232
+ out.prependLeft(at, (at > 0 ? ';' : '') +
233
+ `import { __piwiModule as __piwiM } from ${JSON.stringify(options.runtime)};` +
234
+ `var __piwi_m;function __piwi_mi(){return __piwi_m=__piwiM(${JSON.stringify(options.fileId)},${JSON.stringify(lengths)});}` +
235
+ `__piwi_mi();var __piwi_t=__piwi_m.s[0];`);
236
+ const file = {
237
+ path: options.path,
238
+ checksums: options.checksums,
239
+ f: functions.map(({ range }) => {
240
+ if (!range)
241
+ return null;
242
+ const first = lineOf(range[0]);
243
+ const last = lineOf(range[1]);
244
+ return first > 0 && last >= first ? [first, last] : null;
245
+ }),
246
+ s: functions.map(({ scope }) => [0, ...scope.entries.map(lineOf)]),
247
+ };
248
+ return { code: out.toString(), map: out.generateMap({ hires: true, includeContent: false }), file };
249
+ }
250
+ const SOURCE = /\.(?:[cm]?[jt]s|[jt]sx)$/;
251
+ const GENERATED = /\/(?:node_modules|\.nitro|\.nuxt|\.output)\//;
252
+ /** Whether a module id is application code the transform instruments. */
253
+ export function instrumentable(id, options) {
254
+ if (id.startsWith('\0') || id.includes('?'))
255
+ return false;
256
+ const path = id.replace(/\\/g, '/');
257
+ const root = `${options.root.replace(/\\/g, '/').replace(/\/$/, '')}/`;
258
+ if (!path.startsWith(root) || GENERATED.test(path) || !SOURCE.test(path) || path.endsWith('.d.ts'))
259
+ return false;
260
+ return !options.exclude.some((dir) => path.startsWith(`${dir.replace(/\\/g, '/').replace(/\/$/, '')}/`));
261
+ }
262
+ /** The Rollup plugin of a coverage build. */
263
+ export function coverageRollupPlugin(options) {
264
+ const files = new Map();
265
+ const root = `${options.root.replace(/\\/g, '/').replace(/\/$/, '')}/`;
266
+ return {
267
+ name: 'piwi-coverage',
268
+ transform(code, id) {
269
+ if (!instrumentable(id, options))
270
+ return null;
271
+ const path = id.replace(/\\/g, '/').slice(root.length);
272
+ let source;
273
+ try {
274
+ source = readFileSync(id);
275
+ }
276
+ catch {
277
+ return null;
278
+ }
279
+ const fileId = createHash('sha256').update(path).digest('hex').slice(0, 16);
280
+ const traced = new TraceMap(this.getCombinedSourcemap());
281
+ const result = instrument(code, this.parse(code), {
282
+ fileId,
283
+ runtime: options.runtime,
284
+ path,
285
+ checksums: [`sha256:${createHash('sha256').update(source).digest('hex')}`],
286
+ originalLine: (line, column) => originalPositionFor(traced, { line, column }).line ??
287
+ originalPositionFor(traced, { line, column, bias: LEAST_UPPER_BOUND }).line,
288
+ });
289
+ files.set(id, { fileId, file: result.file });
290
+ return { code: result.code, map: result.map };
291
+ },
292
+ renderChunk(code) {
293
+ const match = TABLE_PLACEHOLDER_LITERAL.exec(code);
294
+ if (!match)
295
+ return null;
296
+ const live = new Set(this.getModuleIds());
297
+ const table = { v: 1, files: {} };
298
+ for (const [id, { fileId, file }] of files)
299
+ if (live.has(id))
300
+ table.files[fileId] = file;
301
+ const out = new MagicString(code);
302
+ out.overwrite(match.index, match.index + match[0].length, JSON.stringify(JSON.stringify(table)));
303
+ return { code: out.toString(), map: out.generateMap({ hires: true }) };
304
+ },
305
+ };
306
+ }
package/dist/index.js CHANGED
@@ -1,4 +1,3 @@
1
- import { AsyncLocalStorage } from 'node:async_hooks';
2
1
  import { randomBytes } from 'node:crypto';
3
2
  import { gzipSync } from 'node:zlib';
4
3
  import { consola } from 'consola';
@@ -6,6 +5,8 @@ import { createError } from 'h3';
6
5
  import { verifyProbeHeader, ProbeNonceCache } from './probe.js';
7
6
  import { buildRouteManifest, recordObservedRoute, matchedRoutePattern, } from './manifest.js';
8
7
  import { declaredRoutes } from './route-table.js';
8
+ import { requestScope } from './request-scope.js';
9
+ import { beginRequestCoverage, collectCoverage, coverageActive, CoverageRequestError, nameRequest, parseGranularity, runCoverage, settleRequest, } from './coverage-runtime.js';
9
10
  import { routeMatchesRequest, faultStatus, faultDelayMs, isThrowFault, isExtremeFault, isDataFault, isDependencyFault, dependencyCallMatches, mutateResponseBody, appliedFaultLabel, } from './faults.js';
10
11
  export { verifyProbeHeader, signProbeMessage, probeSigningMessage, PROBE_TTL_MS, } from './probe.js';
11
12
  export { buildRouteManifest, recordObservedRoute, collapsePathPattern } from './manifest.js';
@@ -69,10 +70,11 @@ function pushSpan(store, span) {
69
70
  }
70
71
  /** Signed probe nonces already honored this process, so a header is single-use. */
71
72
  const probeNonces = new ProbeNonceCache();
72
- // Links consola calls and recorded spans to the request being handled. The store
73
- // is scoped with als.run() around the whole downstream handler chain: a binding
74
- // made with enterWith() from a request hook dies with the hook's own async scope.
75
- const als = new AsyncLocalStorage();
73
+ // Links consola calls, recorded spans and coverage to the request being handled.
74
+ // The store is scoped with als.run() around the whole downstream handler chain: a
75
+ // binding made with enterWith() from a request hook dies with the hook's own async
76
+ // scope. The coverage runtime reads the same store (`request-scope.ts`).
77
+ const als = requestScope;
76
78
  /**
77
79
  * Record a server-side span for the in-flight request. Shows up in the Piwi
78
80
  * Dashboard test-case view (and AI diagnosis) under the request's root span.
@@ -84,8 +86,9 @@ export function recordServerSpan(span) {
84
86
  if (store)
85
87
  pushSpan(store, span);
86
88
  }
87
- // The consola reporter is process-global — register it only once.
88
- let reporterAdded = false;
89
+ // The consola reporter is process-global, and so is the request store it writes into: register it once per
90
+ // process, even when the bundle holds two copies of this module.
91
+ const REPORTER_KEY = Symbol.for('piwitests.consolaReporter');
89
92
  // Capture and probe verification run only in development and test (the ASP.NET
90
93
  // package's default allow-list is Development and Test): NODE_ENV must be
91
94
  // `development` or `test`. Any other value, or none at all (`node
@@ -100,6 +103,69 @@ const TEST_LOGS_DISABLED = process.env.PIWI_TEST_LOGS_DISABLED === 'true' ||
100
103
  const PROBE_SECRET = process.env.PIWI_PROBE_SECRET || undefined;
101
104
  /** The declared-surface route path the plugin serves outside production. */
102
105
  const MANIFEST_PATH = '/__piwi/manifest';
106
+ /** The coverage endpoints a coverage build serves beside the manifest. */
107
+ const COVERAGE_COLLECT_PATH = '/__piwi/coverage/collect';
108
+ const COVERAGE_RUN_PATH = '/__piwi/coverage/run';
109
+ /** Largest collection body read, in bytes. */
110
+ const MAX_COVERAGE_BODY_BYTES = 1024 * 1024;
111
+ /** The body of a request as JSON, refused past `MAX_COVERAGE_BODY_BYTES`. */
112
+ async function readJsonBody(req) {
113
+ const chunks = [];
114
+ let size = 0;
115
+ for await (const chunk of req) {
116
+ size += chunk.length;
117
+ if (size > MAX_COVERAGE_BODY_BYTES)
118
+ throw new CoverageRequestError(413, 'the body is limited to 1 MB');
119
+ chunks.push(chunk);
120
+ }
121
+ try {
122
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
123
+ }
124
+ catch {
125
+ throw new CoverageRequestError(400, 'the body is not JSON');
126
+ }
127
+ }
128
+ /** Answer a coverage endpoint; false when the request is not one, so it reaches the application. */
129
+ function serveCoverage(event, requestPath) {
130
+ if ((requestPath !== COVERAGE_COLLECT_PATH && requestPath !== COVERAGE_RUN_PATH) || !coverageActive())
131
+ return false;
132
+ const res = event.node.res;
133
+ const method = String(event.method ?? event.node.req.method ?? 'GET').toUpperCase();
134
+ const answer = (status, body) => {
135
+ res.statusCode = status;
136
+ res.setHeader('Content-Type', 'application/json');
137
+ res.setHeader('Cache-Control', 'no-store');
138
+ res.end(JSON.stringify(body));
139
+ };
140
+ const fail = (error) => error instanceof CoverageRequestError
141
+ ? answer(error.status, { error: error.message })
142
+ : answer(500, { error: errorMessageOf(error) });
143
+ if (requestPath === COVERAGE_RUN_PATH) {
144
+ if (method !== 'GET') {
145
+ answer(405, { error: 'GET only' });
146
+ return true;
147
+ }
148
+ const query = new URL(String(event.node.req.url ?? ''), 'http://localhost').searchParams;
149
+ try {
150
+ answer(200, runCoverage(parseGranularity(query.get('granularity'))));
151
+ }
152
+ catch (error) {
153
+ fail(error);
154
+ }
155
+ return true;
156
+ }
157
+ if (method !== 'POST') {
158
+ answer(405, { error: 'POST only' });
159
+ return true;
160
+ }
161
+ readJsonBody(event.node.req)
162
+ .then((body) => answer(200, collectCoverage(body)))
163
+ .catch(fail);
164
+ return true;
165
+ }
166
+ function errorMessageOf(error) {
167
+ return error instanceof Error ? error.message : String(error);
168
+ }
103
169
  /**
104
170
  * Routes the server has matched this process, accumulated for `/__piwi/manifest`
105
171
  * next to the declared ones: Nitro exposes no runtime route table, so without the
@@ -126,8 +192,9 @@ function resolveHandlerFile(event) {
126
192
  const piwiTestLogs = (nitroApp) => {
127
193
  if (TEST_LOGS_DISABLED)
128
194
  return;
129
- if (!reporterAdded) {
130
- reporterAdded = true;
195
+ const globals = globalThis;
196
+ if (!globals[REPORTER_KEY]) {
197
+ globals[REPORTER_KEY] = true;
131
198
  consola.addReporter({
132
199
  log(logObj) {
133
200
  if (logObj.level > 1)
@@ -156,6 +223,8 @@ const piwiTestLogs = (nitroApp) => {
156
223
  // returned early when log capture is disabled): the routes the module declared,
157
224
  // then the ones observed.
158
225
  const requestPath = String(event.path ?? event.node.req.url ?? '').split('?')[0] ?? '';
226
+ if (serveCoverage(event, requestPath))
227
+ return;
159
228
  if (requestPath === MANIFEST_PATH) {
160
229
  const routes = new Map();
161
230
  for (const route of [...declaredRoutes(), ...observedRoutes.values()])
@@ -167,11 +236,14 @@ const piwiTestLogs = (nitroApp) => {
167
236
  return;
168
237
  }
169
238
  const enclosing = als.getStore();
239
+ const nested = enclosing !== undefined && !enclosing.ended;
240
+ const coverage = nested ? enclosing.coverage : beginRequestCoverage();
170
241
  const store = {
171
242
  logs: [],
172
243
  spans: [],
173
244
  startMs: Date.now(),
174
- ...(enclosing && !enclosing.ended ? { parent: enclosing } : {}),
245
+ ...(nested ? { parent: enclosing } : {}),
246
+ ...(coverage ? { coverage } : {}),
175
247
  };
176
248
  event.context._piwiLogs = store.logs;
177
249
  event.context._piwiSpans = store.spans;
@@ -210,6 +282,19 @@ const piwiTestLogs = (nitroApp) => {
210
282
  // 'beforeResponse' hook (h3 skips onBeforeResponse once the error handler
211
283
  // has called res.end).
212
284
  const res = event.node.res;
285
+ // Name the request's coverage on its response once instrumented code ran for it. Every way of sending headers
286
+ // goes through writeHead (`end` and `write` call it when nothing did), so a streamed response is named too. A
287
+ // request the server makes to itself is named by the outer one.
288
+ if (coverage && !nested) {
289
+ const originalWriteHead = res.writeHead;
290
+ res.writeHead = function (...args) {
291
+ const id = nameRequest(coverage);
292
+ if (id && !res.headersSent)
293
+ res.setHeader('X-Piwi-Coverage', id);
294
+ return originalWriteHead.apply(this, args);
295
+ };
296
+ res.once('close', () => settleRequest(coverage));
297
+ }
213
298
  const originalEnd = res.end.bind(res);
214
299
  res.end = (...args) => {
215
300
  if (!res.headersSent) {
package/dist/module.js CHANGED
@@ -6,13 +6,50 @@
6
6
  * ```
7
7
  *
8
8
  * In Nuxt, the same entry goes under `nitro.modules`. Without it, `/__piwi/manifest`
9
- * lists only the routes the server has answered. Modules run before Nitro scans its
9
+ * lists only the routes the server has answered. With `PIWI_COVERAGE_BUILD=true`
10
+ * set when the server is built, it also makes a coverage build. Modules run before Nitro scans its
10
11
  * route files, so the table is read when the server is bundled, again on each
11
12
  * rebuild in development.
12
13
  */
13
14
  import { existsSync } from 'node:fs';
14
15
  import { dirname, join } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { COVERAGE_PLUGIN_ID, coveragePluginSource, coverageRollupPlugin } from './coverage-transform.js';
15
18
  import { ROUTE_TABLE_ID, declaredRoutesOf, routeTablePlugin } from './route-table.js';
19
+ /** This package's own directory: its files are never instrumented. */
20
+ const packageDir = dirname(dirname(fileURLToPath(import.meta.url)));
21
+ /** The coverage runtime beside this file: the built one, or the source when the module runs from source. */
22
+ function coverageRuntimePath() {
23
+ const here = dirname(fileURLToPath(import.meta.url));
24
+ const built = join(here, 'coverage-runtime.js');
25
+ return existsSync(built) ? built : join(here, 'coverage-runtime.ts');
26
+ }
27
+ /**
28
+ * A coverage build (`PIWI_COVERAGE_BUILD=true` when the server is built): the
29
+ * coverage transform instruments the application's modules, Nitro writes the
30
+ * source maps it reads, and a generated plugin hands the span table to the
31
+ * coverage runtime at startup. `PIWI_COVERAGE_FORBIDDEN=true` makes such a
32
+ * build fail, for a pipeline whose output must never carry it.
33
+ */
34
+ function setUpCoverage(nitro, root) {
35
+ if (process.env.PIWI_COVERAGE_BUILD !== 'true')
36
+ return;
37
+ if (process.env.PIWI_COVERAGE_FORBIDDEN === 'true') {
38
+ throw new Error('[piwi] PIWI_COVERAGE_BUILD=true in a build where PIWI_COVERAGE_FORBIDDEN=true: a coverage build must not reach this output.');
39
+ }
40
+ const runtime = coverageRuntimePath();
41
+ nitro.options.sourceMap = true;
42
+ nitro.options.virtual[COVERAGE_PLUGIN_ID] = () => coveragePluginSource(runtime);
43
+ nitro.options.plugins.unshift(COVERAGE_PLUGIN_ID);
44
+ // Last, so it reads what Nitro's own transforms (esbuild) produce. Each build makes its config anew (a development
45
+ // server's full reload too), so each gets its own plugin.
46
+ nitro.hooks.hook('rollup:before', (_nitro, config) => {
47
+ const plugins = (config.plugins ??= []);
48
+ if (!plugins.some((plugin) => plugin?.name === 'piwi-coverage')) {
49
+ plugins.push(coverageRollupPlugin({ root, runtime, exclude: [packageDir] }));
50
+ }
51
+ });
52
+ }
16
53
  /** The repository root above `dir` (the closest folder holding `.git`), else `dir` itself. */
17
54
  function repositoryRoot(dir) {
18
55
  for (let current = dir;; current = dirname(current)) {
@@ -28,6 +65,7 @@ const piwiRouteTable = {
28
65
  const root = repositoryRoot(nitro.options.rootDir);
29
66
  nitro.options.virtual[ROUTE_TABLE_ID] = () => routeTablePlugin(declaredRoutesOf([...nitro.scannedHandlers, ...nitro.options.handlers], root));
30
67
  nitro.options.plugins.push(ROUTE_TABLE_ID);
68
+ setUpCoverage(nitro, root);
31
69
  },
32
70
  };
33
71
  export default piwiRouteTable;
@@ -0,0 +1,2 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ export declare const requestScope: AsyncLocalStorage<object>;
@@ -0,0 +1,10 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ /**
3
+ * The request being served, shared by the plugin (logs, spans, probes) and the
4
+ * coverage runtime. It lives on a global so both read the same store even when
5
+ * the server bundle holds two copies of this module: the plugin can load from
6
+ * `node_modules` while the coverage runtime is bundled, or the reverse.
7
+ */
8
+ const SCOPE_KEY = Symbol.for('piwitests.requestScope');
9
+ const globals = globalThis;
10
+ export const requestScope = (globals[SCOPE_KEY] ??= new AsyncLocalStorage());
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@piwitests/instrumentation-nitro",
3
- "version": "0.59.0",
4
- "description": "Nitro/Nuxt server plugin for sending backend logs to Piwi Dashboard via the X-Piwi-Logs response header",
3
+ "version": "0.61.0",
4
+ "description": "Nitro/Nuxt server plugin for Piwi Dashboard: backend logs (X-Piwi-Logs), server spans, probes, the route manifest and per-test server coverage",
5
5
  "homepage": "https://piwitests.dev",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "https://github.com/PiwiTests/platform"
8
+ "url": "https://github.com/PiwiTests/piwi"
9
9
  },
10
10
  "bugs": {
11
- "url": "https://github.com/PiwiTests/platform/issues"
11
+ "url": "https://github.com/PiwiTests/piwi/issues"
12
12
  },
13
13
  "type": "module",
14
14
  "main": "dist/index.js",
@@ -21,6 +21,10 @@
21
21
  "./module": {
22
22
  "types": "./dist/module.d.ts",
23
23
  "import": "./dist/module.js"
24
+ },
25
+ "./coverage-runtime": {
26
+ "types": "./dist/coverage-runtime.d.ts",
27
+ "import": "./dist/coverage-runtime.js"
24
28
  }
25
29
  },
26
30
  "keywords": [
@@ -29,7 +33,8 @@
29
33
  "playwright",
30
34
  "piwi-dashboard",
31
35
  "server-logs",
32
- "test-results"
36
+ "test-results",
37
+ "code-coverage"
33
38
  ],
34
39
  "author": "piwitests",
35
40
  "license": "MIT",
@@ -43,6 +48,13 @@
43
48
  "files": [
44
49
  "dist/"
45
50
  ],
51
+ "dependencies": {
52
+ "@jridgewell/trace-mapping": "^0.3.31",
53
+ "magic-string": "^0.30.21"
54
+ },
55
+ "devDependencies": {
56
+ "rollup": "^4.65.0"
57
+ },
46
58
  "peerDependencies": {
47
59
  "consola": ">=3.0.0",
48
60
  "h3": ">=1.0.0",