@piwitests/instrumentation-nitro 0.60.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 +39 -1
- package/dist/coverage-runtime.d.ts +131 -0
- package/dist/coverage-runtime.js +377 -0
- package/dist/coverage-transform.d.ts +68 -0
- package/dist/coverage-transform.js +306 -0
- package/dist/index.js +95 -10
- package/dist/module.js +39 -1
- package/dist/request-scope.d.ts +2 -0
- package/dist/request-scope.js +10 -0
- package/package.json +15 -3
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
|
|
73
|
-
// is scoped with als.run() around the whole downstream handler chain: a
|
|
74
|
-
// made with enterWith() from a request hook dies with the hook's own async
|
|
75
|
-
|
|
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
|
|
88
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
...(
|
|
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.
|
|
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,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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@piwitests/instrumentation-nitro",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Nitro/Nuxt server plugin for
|
|
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",
|
|
@@ -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",
|