ccqa-tools 1.40.3 → 1.40.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/coverage/next-loader.cjs +209 -42
- package/dist/coverage/next-loader.d.cts +21 -8
- package/dist/coverage/next-loader.d.ts +21 -8
- package/dist/coverage/next-loader.js +210 -43
- package/dist/coverage/next.cjs +66 -5
- package/dist/coverage/next.d.cts +14 -1
- package/dist/coverage/next.d.ts +14 -1
- package/dist/coverage/next.js +66 -5
- package/dist/coverage/register.cjs +26 -28
- package/dist/coverage/register.js +26 -28
- package/package.json +1 -1
|
@@ -1,56 +1,81 @@
|
|
|
1
1
|
let node_path = require("node:path");
|
|
2
2
|
let acorn = require("acorn");
|
|
3
|
+
let node_module = require("node:module");
|
|
3
4
|
//#region src/coverage/instrument/select.ts
|
|
5
|
+
const SOURCE_EXTENSIONS = [
|
|
6
|
+
".js",
|
|
7
|
+
".mjs",
|
|
8
|
+
".cjs",
|
|
9
|
+
".jsx",
|
|
10
|
+
".ts",
|
|
11
|
+
".mts",
|
|
12
|
+
".cts",
|
|
13
|
+
".tsx"
|
|
14
|
+
];
|
|
15
|
+
/**
|
|
16
|
+
* Decides whether a file is part of the project under test, returning its id
|
|
17
|
+
* if so. Returning the id here — rather than a plain boolean — spares the
|
|
18
|
+
* caller a second `fileIdFor` pass over the same path.
|
|
19
|
+
*
|
|
20
|
+
* `node_modules` is excluded unconditionally: instrumenting dependencies costs
|
|
21
|
+
* the most and answers the least — nobody adds a test because a library file
|
|
22
|
+
* went unreached.
|
|
23
|
+
*/
|
|
24
|
+
function shouldInstrument(filename, config) {
|
|
25
|
+
if (filename.includes(`${node_path.sep}node_modules${node_path.sep}`)) return void 0;
|
|
26
|
+
if (!SOURCE_EXTENSIONS.some((extension) => filename.endsWith(extension))) return void 0;
|
|
27
|
+
const id = fileIdFor(filename, config.root);
|
|
28
|
+
if (id === void 0) return void 0;
|
|
29
|
+
return config.include.some((prefix) => id === prefix || id.startsWith(`${prefix}/`)) ? id : void 0;
|
|
30
|
+
}
|
|
4
31
|
/** Path relative to the project root, in posix form so ids match across hosts. */
|
|
5
32
|
function fileIdFor(filename, root) {
|
|
6
33
|
const rel = (0, node_path.relative)((0, node_path.resolve)(root), filename);
|
|
7
34
|
if (rel.startsWith("..") || rel === "") return void 0;
|
|
8
35
|
return rel.split(node_path.sep).join("/");
|
|
9
36
|
}
|
|
10
|
-
//#endregion
|
|
11
|
-
//#region src/coverage/instrument/transform.ts
|
|
12
37
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* Two properties drive the whole shape:
|
|
17
|
-
*
|
|
18
|
-
* - **Insertions only, never a newline.** Line numbers survive untouched, so
|
|
19
|
-
* the source map the application already ships keeps pointing at the right
|
|
20
|
-
* lines and stack traces stay readable. A codegen round-trip would have
|
|
21
|
-
* forced us to produce and merge maps of our own.
|
|
22
|
-
* - **File granularity.** The record is "this file ran", so there is no need to
|
|
23
|
-
* track statements or branches, and the whole class of line/branch
|
|
24
|
-
* normalisation bugs that follow V8-to-istanbul conversion never appears.
|
|
38
|
+
* The exact texts both instrumenter dialects splice in, built in one place so
|
|
39
|
+
* a file instrumented from TypeScript and one instrumented from compiled
|
|
40
|
+
* JavaScript are byte-identical where it matters.
|
|
25
41
|
*/
|
|
26
|
-
|
|
42
|
+
function probeTexts(fileId) {
|
|
43
|
+
const local = `__ccqa_${hash(fileId)}`;
|
|
44
|
+
const literal = JSON.stringify(fileId);
|
|
45
|
+
return {
|
|
46
|
+
enter: `;${local}&&${local}(${literal});`,
|
|
47
|
+
prologue: `;var ${local}=globalThis.__ccqaCoverage;${local}&&${local}(${literal},true);`
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Applies insert-only edits (no newlines) so line numbers survive untouched. */
|
|
51
|
+
function splice(code, edits) {
|
|
52
|
+
const parts = [];
|
|
53
|
+
let last = 0;
|
|
54
|
+
for (const edit of edits) {
|
|
55
|
+
parts.push(code.slice(last, edit.offset), edit.text);
|
|
56
|
+
last = edit.offset;
|
|
57
|
+
}
|
|
58
|
+
parts.push(code.slice(last));
|
|
59
|
+
return parts.join("");
|
|
60
|
+
}
|
|
27
61
|
function transform(code, options) {
|
|
28
62
|
const program = parseProgram(code);
|
|
29
63
|
if (program === void 0) return void 0;
|
|
30
|
-
const
|
|
31
|
-
const literal = JSON.stringify(options.fileId);
|
|
32
|
-
const enter = `${local}&&${local}(${literal});`;
|
|
64
|
+
const { enter, prologue } = probeTexts(options.fileId);
|
|
33
65
|
const points = [];
|
|
34
|
-
collect(program, options.maxDepth ??
|
|
66
|
+
collect$1(program, options.maxDepth ?? 2, points);
|
|
35
67
|
if (code.length === 0) return void 0;
|
|
36
|
-
const prologueAt = afterDirectives(code, program);
|
|
68
|
+
const prologueAt = afterDirectives$1(code, program);
|
|
37
69
|
const edits = points.filter((offset) => offset > prologueAt).map((offset) => ({
|
|
38
70
|
offset,
|
|
39
71
|
text: enter
|
|
40
72
|
}));
|
|
41
73
|
edits.push({
|
|
42
74
|
offset: prologueAt,
|
|
43
|
-
text:
|
|
75
|
+
text: prologue
|
|
44
76
|
});
|
|
45
77
|
edits.sort((a, b) => a.offset - b.offset);
|
|
46
|
-
|
|
47
|
-
let last = 0;
|
|
48
|
-
for (const edit of edits) {
|
|
49
|
-
parts.push(code.slice(last, edit.offset), edit.text);
|
|
50
|
-
last = edit.offset;
|
|
51
|
-
}
|
|
52
|
-
parts.push(code.slice(last));
|
|
53
|
-
return parts.join("");
|
|
78
|
+
return splice(code, edits);
|
|
54
79
|
}
|
|
55
80
|
function parseProgram(code) {
|
|
56
81
|
for (const sourceType of ["module", "script"]) try {
|
|
@@ -70,7 +95,7 @@ function parseProgram(code) {
|
|
|
70
95
|
* instrumentation would be changing the behaviour it is supposed to observe.
|
|
71
96
|
* Applies to a function body as much as to the module.
|
|
72
97
|
*/
|
|
73
|
-
function afterDirectives(code, program) {
|
|
98
|
+
function afterDirectives$1(code, program) {
|
|
74
99
|
let offset = program.start;
|
|
75
100
|
if (code.startsWith("#!")) {
|
|
76
101
|
const newline = code.indexOf("\n");
|
|
@@ -93,7 +118,7 @@ const FUNCTION_TYPES = new Set([
|
|
|
93
118
|
"FunctionExpression",
|
|
94
119
|
"ArrowFunctionExpression"
|
|
95
120
|
]);
|
|
96
|
-
function collect(root, maxDepth, points) {
|
|
121
|
+
function collect$1(root, maxDepth, points) {
|
|
97
122
|
walk(root, 0, false);
|
|
98
123
|
function walk(node, depth, inClass) {
|
|
99
124
|
const isFunction = FUNCTION_TYPES.has(node.type);
|
|
@@ -110,13 +135,13 @@ function collect(root, maxDepth, points) {
|
|
|
110
135
|
if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
|
|
111
136
|
const value = node[key];
|
|
112
137
|
if (Array.isArray(value)) {
|
|
113
|
-
for (const item of value) if (isNode(item)) walk(item, nextDepth, childInClass(node, key, inClass));
|
|
114
|
-
} else if (isNode(value)) walk(value, nextDepth, childInClass(node, key, inClass));
|
|
138
|
+
for (const item of value) if (isNode(item)) walk(item, nextDepth, childInClass$1(node, key, inClass));
|
|
139
|
+
} else if (isNode(value)) walk(value, nextDepth, childInClass$1(node, key, inClass));
|
|
115
140
|
}
|
|
116
141
|
}
|
|
117
142
|
}
|
|
118
143
|
/** True while walking the value of a class member, false again inside its body. */
|
|
119
|
-
function childInClass(parent, key, inherited) {
|
|
144
|
+
function childInClass$1(parent, key, inherited) {
|
|
120
145
|
if (parent.type === "MethodDefinition" || parent.type === "PropertyDefinition") return key === "value";
|
|
121
146
|
if (FUNCTION_TYPES.has(parent.type)) return false;
|
|
122
147
|
return inherited;
|
|
@@ -134,20 +159,162 @@ function hash(value) {
|
|
|
134
159
|
return (h >>> 0).toString(36);
|
|
135
160
|
}
|
|
136
161
|
//#endregion
|
|
162
|
+
//#region src/coverage/instrument/transform-ts.ts
|
|
163
|
+
/**
|
|
164
|
+
* TypeScript/TSX form of the instrumenter.
|
|
165
|
+
*
|
|
166
|
+
* The webpack post-loader sees compiled JavaScript and parses with acorn;
|
|
167
|
+
* Turbopack hands loaders the *original* source, before its own TypeScript
|
|
168
|
+
* and JSX transforms, so this dialect parses with the `typescript` compiler
|
|
169
|
+
* API instead and splices the same probes into the untranspiled text. The
|
|
170
|
+
* output stays TypeScript — the bundler's own pipeline compiles it after us —
|
|
171
|
+
* which is what keeps this file free of any JSX/downlevel emit of its own.
|
|
172
|
+
*
|
|
173
|
+
* Same contract as `transform`: insertions only, never a newline, so line
|
|
174
|
+
* numbers and the framework's source maps survive untouched.
|
|
175
|
+
*
|
|
176
|
+
* `typescript` is loaded lazily and is not a dependency of this package: this
|
|
177
|
+
* path only runs at build time inside a project that compiles TypeScript,
|
|
178
|
+
* where the compiler is present by definition. It is resolved from the
|
|
179
|
+
* *project* first — resolving from this package's own position would lean on
|
|
180
|
+
* the package manager's hoisting, and could pick a different compiler than
|
|
181
|
+
* the one the project builds with. When it is somehow absent the file is
|
|
182
|
+
* left uninstrumented and the loader warns.
|
|
183
|
+
*/
|
|
184
|
+
const ownRequire = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
|
|
185
|
+
let tsModule;
|
|
186
|
+
function loadTypescript(resolveFrom) {
|
|
187
|
+
if (tsModule !== void 0) return tsModule;
|
|
188
|
+
if (resolveFrom !== void 0) try {
|
|
189
|
+
tsModule = (0, node_module.createRequire)((0, node_path.join)(resolveFrom, "noop.js"))("typescript");
|
|
190
|
+
return tsModule;
|
|
191
|
+
} catch {}
|
|
192
|
+
try {
|
|
193
|
+
tsModule = ownRequire("typescript");
|
|
194
|
+
} catch {
|
|
195
|
+
tsModule = null;
|
|
196
|
+
}
|
|
197
|
+
return tsModule;
|
|
198
|
+
}
|
|
199
|
+
/** Whether the TypeScript dialect can run at all in this process. */
|
|
200
|
+
function typescriptAvailable(resolveFrom) {
|
|
201
|
+
return loadTypescript(resolveFrom) !== null;
|
|
202
|
+
}
|
|
203
|
+
function transformTs(code, options) {
|
|
204
|
+
const ts = loadTypescript(options.resolveFrom);
|
|
205
|
+
if (ts === null || code.length === 0) return void 0;
|
|
206
|
+
const kind = scriptKindFor(ts, options.extension ?? ".tsx");
|
|
207
|
+
const sourceFile = ts.createSourceFile("module.tsx", code, ts.ScriptTarget.Latest, false, kind);
|
|
208
|
+
const diagnostics = sourceFile.parseDiagnostics;
|
|
209
|
+
if (Array.isArray(diagnostics) && diagnostics.length > 0) return void 0;
|
|
210
|
+
const { enter, prologue } = probeTexts(options.fileId);
|
|
211
|
+
const points = [];
|
|
212
|
+
collect(ts, sourceFile, options.maxDepth ?? 2, points);
|
|
213
|
+
const first = sourceFile.statements[0];
|
|
214
|
+
const base = first === void 0 ? code.length : first.getStart(sourceFile);
|
|
215
|
+
const prologueAt = afterDirectives(ts, sourceFile.statements, base);
|
|
216
|
+
const edits = points.filter((offset) => offset > prologueAt).map((offset) => ({
|
|
217
|
+
offset,
|
|
218
|
+
text: enter
|
|
219
|
+
}));
|
|
220
|
+
edits.push({
|
|
221
|
+
offset: prologueAt,
|
|
222
|
+
text: prologue
|
|
223
|
+
});
|
|
224
|
+
edits.sort((a, b) => a.offset - b.offset);
|
|
225
|
+
return splice(code, edits);
|
|
226
|
+
}
|
|
227
|
+
function scriptKindFor(ts, extension) {
|
|
228
|
+
switch (extension) {
|
|
229
|
+
case ".ts":
|
|
230
|
+
case ".mts":
|
|
231
|
+
case ".cts": return ts.ScriptKind.TS;
|
|
232
|
+
case ".jsx": return ts.ScriptKind.JSX;
|
|
233
|
+
case ".js":
|
|
234
|
+
case ".mjs":
|
|
235
|
+
case ".cjs": return ts.ScriptKind.JSX;
|
|
236
|
+
default: return ts.ScriptKind.TSX;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function collect(ts, sourceFile, maxDepth, points) {
|
|
240
|
+
walk(sourceFile, 0, false);
|
|
241
|
+
function walk(node, depth, inClass) {
|
|
242
|
+
const isFunction = isFunctionLike(ts, node);
|
|
243
|
+
const nextDepth = isFunction ? depth + 1 : depth;
|
|
244
|
+
if (isFunction) {
|
|
245
|
+
const wanted = inClass || nextDepth <= maxDepth;
|
|
246
|
+
const body = node.body;
|
|
247
|
+
if (wanted && body !== void 0 && ts.isBlock(body)) points.push(afterDirectives(ts, body.statements, body.getStart(sourceFile) + 1));
|
|
248
|
+
}
|
|
249
|
+
ts.forEachChild(node, (child) => walk(child, nextDepth, childInClass(ts, node, child, inClass)));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function isFunctionLike(ts, node) {
|
|
253
|
+
return ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isMethodDeclaration(node) || ts.isConstructorDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Mirrors the acorn walk's rule: true for the function a class member *is* or
|
|
257
|
+
* holds, false again once inside any function body. In this AST a class
|
|
258
|
+
* method is itself the function-like — there is no separate `value` node —
|
|
259
|
+
* so membership is decided when the class hands its members down.
|
|
260
|
+
*/
|
|
261
|
+
function childInClass(ts, parent, child, inherited) {
|
|
262
|
+
if (ts.isClassDeclaration(parent) || ts.isClassExpression(parent)) return ts.isMethodDeclaration(child) || ts.isConstructorDeclaration(child) || ts.isGetAccessorDeclaration(child) || ts.isSetAccessorDeclaration(child) || ts.isPropertyDeclaration(child);
|
|
263
|
+
if (ts.isPropertyDeclaration(parent)) return child === parent.initializer;
|
|
264
|
+
if (isFunctionLike(ts, parent)) return false;
|
|
265
|
+
return inherited;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Same rule as the acorn dialect: a directive prologue only counts while it
|
|
269
|
+
* is still the first thing in its scope, and `"use client"` / `"use server"`
|
|
270
|
+
* are directives to Next, so nothing may be inserted ahead of them.
|
|
271
|
+
*/
|
|
272
|
+
function afterDirectives(ts, statements, from) {
|
|
273
|
+
let offset = from;
|
|
274
|
+
for (const statement of statements) {
|
|
275
|
+
if (!ts.isExpressionStatement(statement)) break;
|
|
276
|
+
if (!ts.isStringLiteral(statement.expression)) break;
|
|
277
|
+
offset = statement.end;
|
|
278
|
+
}
|
|
279
|
+
return offset;
|
|
280
|
+
}
|
|
281
|
+
//#endregion
|
|
137
282
|
//#region src/coverage/next/loader.ts
|
|
138
283
|
/**
|
|
139
|
-
*
|
|
140
|
-
* part of a bundle and so is invisible to the load hooks.
|
|
284
|
+
* The loader form of the instrumenter, for code that reaches the runtime as
|
|
285
|
+
* part of a bundle and so is invisible to the load hooks. One loader, two
|
|
286
|
+
* dialects, because the two bundlers hand it different inputs:
|
|
287
|
+
*
|
|
288
|
+
* - **webpack** registers it with `enforce: "post"`, so it sees compiled
|
|
289
|
+
* JavaScript and parses with acorn (`dialect: "compiled"`, the default).
|
|
290
|
+
* - **Turbopack** has no post phase — rule loaders run on the *original*
|
|
291
|
+
* TypeScript/TSX — so that registration passes `dialect: "source"` and the
|
|
292
|
+
* file is parsed with the `typescript` compiler API instead.
|
|
141
293
|
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
294
|
+
* Scoping also differs: webpack's rule carries `include`/`exclude` matchers,
|
|
295
|
+
* while Turbopack rules are extension globs, so the source dialect gets the
|
|
296
|
+
* include prefixes as options and filters through `shouldInstrument` — the
|
|
297
|
+
* same decision the runtime load hooks use.
|
|
145
298
|
*/
|
|
146
299
|
function ccqaCoverageLoader(source) {
|
|
147
|
-
const
|
|
148
|
-
const
|
|
300
|
+
const options = this.getOptions?.() ?? {};
|
|
301
|
+
const root = options.root ?? process.cwd();
|
|
302
|
+
const dialect = options.dialect ?? "compiled";
|
|
303
|
+
const fileId = dialect === "source" ? shouldInstrument(this.resourcePath, {
|
|
304
|
+
root,
|
|
305
|
+
include: options.include ?? []
|
|
306
|
+
}) : fileIdFor(this.resourcePath, root);
|
|
149
307
|
if (fileId === void 0) return source;
|
|
150
|
-
|
|
308
|
+
if (source.trim().length === 0) return source;
|
|
309
|
+
if (dialect === "source" && !typescriptAvailable(root)) {
|
|
310
|
+
this.emitWarning?.(/* @__PURE__ */ new Error(`ccqa-tools needs the \`typescript\` package to instrument Turbopack builds; ${fileId} left uninstrumented`));
|
|
311
|
+
return source;
|
|
312
|
+
}
|
|
313
|
+
const instrumented = dialect === "source" ? transformTs(source, {
|
|
314
|
+
fileId,
|
|
315
|
+
extension: (0, node_path.extname)(this.resourcePath),
|
|
316
|
+
resolveFrom: root
|
|
317
|
+
}) : transform(source, { fileId });
|
|
151
318
|
if (instrumented === void 0) {
|
|
152
319
|
this.emitWarning?.(/* @__PURE__ */ new Error(`ccqa-tools could not parse ${fileId}; left uninstrumented`));
|
|
153
320
|
return source;
|
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
//#region src/coverage/next/loader.d.ts
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
* part of a bundle and so is invisible to the load hooks.
|
|
3
|
+
* The loader form of the instrumenter, for code that reaches the runtime as
|
|
4
|
+
* part of a bundle and so is invisible to the load hooks. One loader, two
|
|
5
|
+
* dialects, because the two bundlers hand it different inputs:
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* - **webpack** registers it with `enforce: "post"`, so it sees compiled
|
|
8
|
+
* JavaScript and parses with acorn (`dialect: "compiled"`, the default).
|
|
9
|
+
* - **Turbopack** has no post phase — rule loaders run on the *original*
|
|
10
|
+
* TypeScript/TSX — so that registration passes `dialect: "source"` and the
|
|
11
|
+
* file is parsed with the `typescript` compiler API instead.
|
|
12
|
+
*
|
|
13
|
+
* Scoping also differs: webpack's rule carries `include`/`exclude` matchers,
|
|
14
|
+
* while Turbopack rules are extension globs, so the source dialect gets the
|
|
15
|
+
* include prefixes as options and filters through `shouldInstrument` — the
|
|
16
|
+
* same decision the runtime load hooks use.
|
|
9
17
|
*/
|
|
18
|
+
interface LoaderOptions {
|
|
19
|
+
root?: string;
|
|
20
|
+
/** How the input is parsed; see the module comment. */
|
|
21
|
+
dialect?: "compiled" | "source";
|
|
22
|
+
/** Root-relative directory prefixes to instrument (source dialect only). */
|
|
23
|
+
include?: string[];
|
|
24
|
+
}
|
|
10
25
|
interface LoaderContext {
|
|
11
26
|
resourcePath: string;
|
|
12
|
-
getOptions?: () =>
|
|
13
|
-
root?: string;
|
|
14
|
-
};
|
|
27
|
+
getOptions?: () => LoaderOptions;
|
|
15
28
|
emitWarning?: (warning: Error) => void;
|
|
16
29
|
}
|
|
17
30
|
declare function ccqaCoverageLoader(this: LoaderContext, source: string): string;
|
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
//#region src/coverage/next/loader.d.ts
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
* part of a bundle and so is invisible to the load hooks.
|
|
3
|
+
* The loader form of the instrumenter, for code that reaches the runtime as
|
|
4
|
+
* part of a bundle and so is invisible to the load hooks. One loader, two
|
|
5
|
+
* dialects, because the two bundlers hand it different inputs:
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* - **webpack** registers it with `enforce: "post"`, so it sees compiled
|
|
8
|
+
* JavaScript and parses with acorn (`dialect: "compiled"`, the default).
|
|
9
|
+
* - **Turbopack** has no post phase — rule loaders run on the *original*
|
|
10
|
+
* TypeScript/TSX — so that registration passes `dialect: "source"` and the
|
|
11
|
+
* file is parsed with the `typescript` compiler API instead.
|
|
12
|
+
*
|
|
13
|
+
* Scoping also differs: webpack's rule carries `include`/`exclude` matchers,
|
|
14
|
+
* while Turbopack rules are extension globs, so the source dialect gets the
|
|
15
|
+
* include prefixes as options and filters through `shouldInstrument` — the
|
|
16
|
+
* same decision the runtime load hooks use.
|
|
9
17
|
*/
|
|
18
|
+
interface LoaderOptions {
|
|
19
|
+
root?: string;
|
|
20
|
+
/** How the input is parsed; see the module comment. */
|
|
21
|
+
dialect?: "compiled" | "source";
|
|
22
|
+
/** Root-relative directory prefixes to instrument (source dialect only). */
|
|
23
|
+
include?: string[];
|
|
24
|
+
}
|
|
10
25
|
interface LoaderContext {
|
|
11
26
|
resourcePath: string;
|
|
12
|
-
getOptions?: () =>
|
|
13
|
-
root?: string;
|
|
14
|
-
};
|
|
27
|
+
getOptions?: () => LoaderOptions;
|
|
15
28
|
emitWarning?: (warning: Error) => void;
|
|
16
29
|
}
|
|
17
30
|
declare function ccqaCoverageLoader(this: LoaderContext, source: string): string;
|
|
@@ -1,56 +1,81 @@
|
|
|
1
|
-
import { relative, resolve, sep } from "node:path";
|
|
1
|
+
import { extname, join, relative, resolve, sep } from "node:path";
|
|
2
2
|
import { parse } from "acorn";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
3
4
|
//#region src/coverage/instrument/select.ts
|
|
5
|
+
const SOURCE_EXTENSIONS = [
|
|
6
|
+
".js",
|
|
7
|
+
".mjs",
|
|
8
|
+
".cjs",
|
|
9
|
+
".jsx",
|
|
10
|
+
".ts",
|
|
11
|
+
".mts",
|
|
12
|
+
".cts",
|
|
13
|
+
".tsx"
|
|
14
|
+
];
|
|
15
|
+
/**
|
|
16
|
+
* Decides whether a file is part of the project under test, returning its id
|
|
17
|
+
* if so. Returning the id here — rather than a plain boolean — spares the
|
|
18
|
+
* caller a second `fileIdFor` pass over the same path.
|
|
19
|
+
*
|
|
20
|
+
* `node_modules` is excluded unconditionally: instrumenting dependencies costs
|
|
21
|
+
* the most and answers the least — nobody adds a test because a library file
|
|
22
|
+
* went unreached.
|
|
23
|
+
*/
|
|
24
|
+
function shouldInstrument(filename, config) {
|
|
25
|
+
if (filename.includes(`${sep}node_modules${sep}`)) return void 0;
|
|
26
|
+
if (!SOURCE_EXTENSIONS.some((extension) => filename.endsWith(extension))) return void 0;
|
|
27
|
+
const id = fileIdFor(filename, config.root);
|
|
28
|
+
if (id === void 0) return void 0;
|
|
29
|
+
return config.include.some((prefix) => id === prefix || id.startsWith(`${prefix}/`)) ? id : void 0;
|
|
30
|
+
}
|
|
4
31
|
/** Path relative to the project root, in posix form so ids match across hosts. */
|
|
5
32
|
function fileIdFor(filename, root) {
|
|
6
33
|
const rel = relative(resolve(root), filename);
|
|
7
34
|
if (rel.startsWith("..") || rel === "") return void 0;
|
|
8
35
|
return rel.split(sep).join("/");
|
|
9
36
|
}
|
|
10
|
-
//#endregion
|
|
11
|
-
//#region src/coverage/instrument/transform.ts
|
|
12
37
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* Two properties drive the whole shape:
|
|
17
|
-
*
|
|
18
|
-
* - **Insertions only, never a newline.** Line numbers survive untouched, so
|
|
19
|
-
* the source map the application already ships keeps pointing at the right
|
|
20
|
-
* lines and stack traces stay readable. A codegen round-trip would have
|
|
21
|
-
* forced us to produce and merge maps of our own.
|
|
22
|
-
* - **File granularity.** The record is "this file ran", so there is no need to
|
|
23
|
-
* track statements or branches, and the whole class of line/branch
|
|
24
|
-
* normalisation bugs that follow V8-to-istanbul conversion never appears.
|
|
38
|
+
* The exact texts both instrumenter dialects splice in, built in one place so
|
|
39
|
+
* a file instrumented from TypeScript and one instrumented from compiled
|
|
40
|
+
* JavaScript are byte-identical where it matters.
|
|
25
41
|
*/
|
|
26
|
-
|
|
42
|
+
function probeTexts(fileId) {
|
|
43
|
+
const local = `__ccqa_${hash(fileId)}`;
|
|
44
|
+
const literal = JSON.stringify(fileId);
|
|
45
|
+
return {
|
|
46
|
+
enter: `;${local}&&${local}(${literal});`,
|
|
47
|
+
prologue: `;var ${local}=globalThis.__ccqaCoverage;${local}&&${local}(${literal},true);`
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Applies insert-only edits (no newlines) so line numbers survive untouched. */
|
|
51
|
+
function splice(code, edits) {
|
|
52
|
+
const parts = [];
|
|
53
|
+
let last = 0;
|
|
54
|
+
for (const edit of edits) {
|
|
55
|
+
parts.push(code.slice(last, edit.offset), edit.text);
|
|
56
|
+
last = edit.offset;
|
|
57
|
+
}
|
|
58
|
+
parts.push(code.slice(last));
|
|
59
|
+
return parts.join("");
|
|
60
|
+
}
|
|
27
61
|
function transform(code, options) {
|
|
28
62
|
const program = parseProgram(code);
|
|
29
63
|
if (program === void 0) return void 0;
|
|
30
|
-
const
|
|
31
|
-
const literal = JSON.stringify(options.fileId);
|
|
32
|
-
const enter = `${local}&&${local}(${literal});`;
|
|
64
|
+
const { enter, prologue } = probeTexts(options.fileId);
|
|
33
65
|
const points = [];
|
|
34
|
-
collect(program, options.maxDepth ??
|
|
66
|
+
collect$1(program, options.maxDepth ?? 2, points);
|
|
35
67
|
if (code.length === 0) return void 0;
|
|
36
|
-
const prologueAt = afterDirectives(code, program);
|
|
68
|
+
const prologueAt = afterDirectives$1(code, program);
|
|
37
69
|
const edits = points.filter((offset) => offset > prologueAt).map((offset) => ({
|
|
38
70
|
offset,
|
|
39
71
|
text: enter
|
|
40
72
|
}));
|
|
41
73
|
edits.push({
|
|
42
74
|
offset: prologueAt,
|
|
43
|
-
text:
|
|
75
|
+
text: prologue
|
|
44
76
|
});
|
|
45
77
|
edits.sort((a, b) => a.offset - b.offset);
|
|
46
|
-
|
|
47
|
-
let last = 0;
|
|
48
|
-
for (const edit of edits) {
|
|
49
|
-
parts.push(code.slice(last, edit.offset), edit.text);
|
|
50
|
-
last = edit.offset;
|
|
51
|
-
}
|
|
52
|
-
parts.push(code.slice(last));
|
|
53
|
-
return parts.join("");
|
|
78
|
+
return splice(code, edits);
|
|
54
79
|
}
|
|
55
80
|
function parseProgram(code) {
|
|
56
81
|
for (const sourceType of ["module", "script"]) try {
|
|
@@ -70,7 +95,7 @@ function parseProgram(code) {
|
|
|
70
95
|
* instrumentation would be changing the behaviour it is supposed to observe.
|
|
71
96
|
* Applies to a function body as much as to the module.
|
|
72
97
|
*/
|
|
73
|
-
function afterDirectives(code, program) {
|
|
98
|
+
function afterDirectives$1(code, program) {
|
|
74
99
|
let offset = program.start;
|
|
75
100
|
if (code.startsWith("#!")) {
|
|
76
101
|
const newline = code.indexOf("\n");
|
|
@@ -93,7 +118,7 @@ const FUNCTION_TYPES = new Set([
|
|
|
93
118
|
"FunctionExpression",
|
|
94
119
|
"ArrowFunctionExpression"
|
|
95
120
|
]);
|
|
96
|
-
function collect(root, maxDepth, points) {
|
|
121
|
+
function collect$1(root, maxDepth, points) {
|
|
97
122
|
walk(root, 0, false);
|
|
98
123
|
function walk(node, depth, inClass) {
|
|
99
124
|
const isFunction = FUNCTION_TYPES.has(node.type);
|
|
@@ -110,13 +135,13 @@ function collect(root, maxDepth, points) {
|
|
|
110
135
|
if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
|
|
111
136
|
const value = node[key];
|
|
112
137
|
if (Array.isArray(value)) {
|
|
113
|
-
for (const item of value) if (isNode(item)) walk(item, nextDepth, childInClass(node, key, inClass));
|
|
114
|
-
} else if (isNode(value)) walk(value, nextDepth, childInClass(node, key, inClass));
|
|
138
|
+
for (const item of value) if (isNode(item)) walk(item, nextDepth, childInClass$1(node, key, inClass));
|
|
139
|
+
} else if (isNode(value)) walk(value, nextDepth, childInClass$1(node, key, inClass));
|
|
115
140
|
}
|
|
116
141
|
}
|
|
117
142
|
}
|
|
118
143
|
/** True while walking the value of a class member, false again inside its body. */
|
|
119
|
-
function childInClass(parent, key, inherited) {
|
|
144
|
+
function childInClass$1(parent, key, inherited) {
|
|
120
145
|
if (parent.type === "MethodDefinition" || parent.type === "PropertyDefinition") return key === "value";
|
|
121
146
|
if (FUNCTION_TYPES.has(parent.type)) return false;
|
|
122
147
|
return inherited;
|
|
@@ -134,20 +159,162 @@ function hash(value) {
|
|
|
134
159
|
return (h >>> 0).toString(36);
|
|
135
160
|
}
|
|
136
161
|
//#endregion
|
|
162
|
+
//#region src/coverage/instrument/transform-ts.ts
|
|
163
|
+
/**
|
|
164
|
+
* TypeScript/TSX form of the instrumenter.
|
|
165
|
+
*
|
|
166
|
+
* The webpack post-loader sees compiled JavaScript and parses with acorn;
|
|
167
|
+
* Turbopack hands loaders the *original* source, before its own TypeScript
|
|
168
|
+
* and JSX transforms, so this dialect parses with the `typescript` compiler
|
|
169
|
+
* API instead and splices the same probes into the untranspiled text. The
|
|
170
|
+
* output stays TypeScript — the bundler's own pipeline compiles it after us —
|
|
171
|
+
* which is what keeps this file free of any JSX/downlevel emit of its own.
|
|
172
|
+
*
|
|
173
|
+
* Same contract as `transform`: insertions only, never a newline, so line
|
|
174
|
+
* numbers and the framework's source maps survive untouched.
|
|
175
|
+
*
|
|
176
|
+
* `typescript` is loaded lazily and is not a dependency of this package: this
|
|
177
|
+
* path only runs at build time inside a project that compiles TypeScript,
|
|
178
|
+
* where the compiler is present by definition. It is resolved from the
|
|
179
|
+
* *project* first — resolving from this package's own position would lean on
|
|
180
|
+
* the package manager's hoisting, and could pick a different compiler than
|
|
181
|
+
* the one the project builds with. When it is somehow absent the file is
|
|
182
|
+
* left uninstrumented and the loader warns.
|
|
183
|
+
*/
|
|
184
|
+
const ownRequire = createRequire(import.meta.url);
|
|
185
|
+
let tsModule;
|
|
186
|
+
function loadTypescript(resolveFrom) {
|
|
187
|
+
if (tsModule !== void 0) return tsModule;
|
|
188
|
+
if (resolveFrom !== void 0) try {
|
|
189
|
+
tsModule = createRequire(join(resolveFrom, "noop.js"))("typescript");
|
|
190
|
+
return tsModule;
|
|
191
|
+
} catch {}
|
|
192
|
+
try {
|
|
193
|
+
tsModule = ownRequire("typescript");
|
|
194
|
+
} catch {
|
|
195
|
+
tsModule = null;
|
|
196
|
+
}
|
|
197
|
+
return tsModule;
|
|
198
|
+
}
|
|
199
|
+
/** Whether the TypeScript dialect can run at all in this process. */
|
|
200
|
+
function typescriptAvailable(resolveFrom) {
|
|
201
|
+
return loadTypescript(resolveFrom) !== null;
|
|
202
|
+
}
|
|
203
|
+
function transformTs(code, options) {
|
|
204
|
+
const ts = loadTypescript(options.resolveFrom);
|
|
205
|
+
if (ts === null || code.length === 0) return void 0;
|
|
206
|
+
const kind = scriptKindFor(ts, options.extension ?? ".tsx");
|
|
207
|
+
const sourceFile = ts.createSourceFile("module.tsx", code, ts.ScriptTarget.Latest, false, kind);
|
|
208
|
+
const diagnostics = sourceFile.parseDiagnostics;
|
|
209
|
+
if (Array.isArray(diagnostics) && diagnostics.length > 0) return void 0;
|
|
210
|
+
const { enter, prologue } = probeTexts(options.fileId);
|
|
211
|
+
const points = [];
|
|
212
|
+
collect(ts, sourceFile, options.maxDepth ?? 2, points);
|
|
213
|
+
const first = sourceFile.statements[0];
|
|
214
|
+
const base = first === void 0 ? code.length : first.getStart(sourceFile);
|
|
215
|
+
const prologueAt = afterDirectives(ts, sourceFile.statements, base);
|
|
216
|
+
const edits = points.filter((offset) => offset > prologueAt).map((offset) => ({
|
|
217
|
+
offset,
|
|
218
|
+
text: enter
|
|
219
|
+
}));
|
|
220
|
+
edits.push({
|
|
221
|
+
offset: prologueAt,
|
|
222
|
+
text: prologue
|
|
223
|
+
});
|
|
224
|
+
edits.sort((a, b) => a.offset - b.offset);
|
|
225
|
+
return splice(code, edits);
|
|
226
|
+
}
|
|
227
|
+
function scriptKindFor(ts, extension) {
|
|
228
|
+
switch (extension) {
|
|
229
|
+
case ".ts":
|
|
230
|
+
case ".mts":
|
|
231
|
+
case ".cts": return ts.ScriptKind.TS;
|
|
232
|
+
case ".jsx": return ts.ScriptKind.JSX;
|
|
233
|
+
case ".js":
|
|
234
|
+
case ".mjs":
|
|
235
|
+
case ".cjs": return ts.ScriptKind.JSX;
|
|
236
|
+
default: return ts.ScriptKind.TSX;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function collect(ts, sourceFile, maxDepth, points) {
|
|
240
|
+
walk(sourceFile, 0, false);
|
|
241
|
+
function walk(node, depth, inClass) {
|
|
242
|
+
const isFunction = isFunctionLike(ts, node);
|
|
243
|
+
const nextDepth = isFunction ? depth + 1 : depth;
|
|
244
|
+
if (isFunction) {
|
|
245
|
+
const wanted = inClass || nextDepth <= maxDepth;
|
|
246
|
+
const body = node.body;
|
|
247
|
+
if (wanted && body !== void 0 && ts.isBlock(body)) points.push(afterDirectives(ts, body.statements, body.getStart(sourceFile) + 1));
|
|
248
|
+
}
|
|
249
|
+
ts.forEachChild(node, (child) => walk(child, nextDepth, childInClass(ts, node, child, inClass)));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function isFunctionLike(ts, node) {
|
|
253
|
+
return ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isMethodDeclaration(node) || ts.isConstructorDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Mirrors the acorn walk's rule: true for the function a class member *is* or
|
|
257
|
+
* holds, false again once inside any function body. In this AST a class
|
|
258
|
+
* method is itself the function-like — there is no separate `value` node —
|
|
259
|
+
* so membership is decided when the class hands its members down.
|
|
260
|
+
*/
|
|
261
|
+
function childInClass(ts, parent, child, inherited) {
|
|
262
|
+
if (ts.isClassDeclaration(parent) || ts.isClassExpression(parent)) return ts.isMethodDeclaration(child) || ts.isConstructorDeclaration(child) || ts.isGetAccessorDeclaration(child) || ts.isSetAccessorDeclaration(child) || ts.isPropertyDeclaration(child);
|
|
263
|
+
if (ts.isPropertyDeclaration(parent)) return child === parent.initializer;
|
|
264
|
+
if (isFunctionLike(ts, parent)) return false;
|
|
265
|
+
return inherited;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Same rule as the acorn dialect: a directive prologue only counts while it
|
|
269
|
+
* is still the first thing in its scope, and `"use client"` / `"use server"`
|
|
270
|
+
* are directives to Next, so nothing may be inserted ahead of them.
|
|
271
|
+
*/
|
|
272
|
+
function afterDirectives(ts, statements, from) {
|
|
273
|
+
let offset = from;
|
|
274
|
+
for (const statement of statements) {
|
|
275
|
+
if (!ts.isExpressionStatement(statement)) break;
|
|
276
|
+
if (!ts.isStringLiteral(statement.expression)) break;
|
|
277
|
+
offset = statement.end;
|
|
278
|
+
}
|
|
279
|
+
return offset;
|
|
280
|
+
}
|
|
281
|
+
//#endregion
|
|
137
282
|
//#region src/coverage/next/loader.ts
|
|
138
283
|
/**
|
|
139
|
-
*
|
|
140
|
-
* part of a bundle and so is invisible to the load hooks.
|
|
284
|
+
* The loader form of the instrumenter, for code that reaches the runtime as
|
|
285
|
+
* part of a bundle and so is invisible to the load hooks. One loader, two
|
|
286
|
+
* dialects, because the two bundlers hand it different inputs:
|
|
287
|
+
*
|
|
288
|
+
* - **webpack** registers it with `enforce: "post"`, so it sees compiled
|
|
289
|
+
* JavaScript and parses with acorn (`dialect: "compiled"`, the default).
|
|
290
|
+
* - **Turbopack** has no post phase — rule loaders run on the *original*
|
|
291
|
+
* TypeScript/TSX — so that registration passes `dialect: "source"` and the
|
|
292
|
+
* file is parsed with the `typescript` compiler API instead.
|
|
141
293
|
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
294
|
+
* Scoping also differs: webpack's rule carries `include`/`exclude` matchers,
|
|
295
|
+
* while Turbopack rules are extension globs, so the source dialect gets the
|
|
296
|
+
* include prefixes as options and filters through `shouldInstrument` — the
|
|
297
|
+
* same decision the runtime load hooks use.
|
|
145
298
|
*/
|
|
146
299
|
function ccqaCoverageLoader(source) {
|
|
147
|
-
const
|
|
148
|
-
const
|
|
300
|
+
const options = this.getOptions?.() ?? {};
|
|
301
|
+
const root = options.root ?? process.cwd();
|
|
302
|
+
const dialect = options.dialect ?? "compiled";
|
|
303
|
+
const fileId = dialect === "source" ? shouldInstrument(this.resourcePath, {
|
|
304
|
+
root,
|
|
305
|
+
include: options.include ?? []
|
|
306
|
+
}) : fileIdFor(this.resourcePath, root);
|
|
149
307
|
if (fileId === void 0) return source;
|
|
150
|
-
|
|
308
|
+
if (source.trim().length === 0) return source;
|
|
309
|
+
if (dialect === "source" && !typescriptAvailable(root)) {
|
|
310
|
+
this.emitWarning?.(/* @__PURE__ */ new Error(`ccqa-tools needs the \`typescript\` package to instrument Turbopack builds; ${fileId} left uninstrumented`));
|
|
311
|
+
return source;
|
|
312
|
+
}
|
|
313
|
+
const instrumented = dialect === "source" ? transformTs(source, {
|
|
314
|
+
fileId,
|
|
315
|
+
extension: extname(this.resourcePath),
|
|
316
|
+
resolveFrom: root
|
|
317
|
+
}) : transform(source, { fileId });
|
|
151
318
|
if (instrumented === void 0) {
|
|
152
319
|
this.emitWarning?.(/* @__PURE__ */ new Error(`ccqa-tools could not parse ${fileId}; left uninstrumented`));
|
|
153
320
|
return source;
|
package/dist/coverage/next.cjs
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
let node_module = require("node:module");
|
|
3
3
|
let node_path = require("node:path");
|
|
4
|
+
//#region src/coverage/instrument/select.ts
|
|
5
|
+
const SOURCE_EXTENSIONS = [
|
|
6
|
+
".js",
|
|
7
|
+
".mjs",
|
|
8
|
+
".cjs",
|
|
9
|
+
".jsx",
|
|
10
|
+
".ts",
|
|
11
|
+
".mts",
|
|
12
|
+
".cts",
|
|
13
|
+
".tsx"
|
|
14
|
+
];
|
|
15
|
+
//#endregion
|
|
4
16
|
//#region src/coverage/wire.ts
|
|
5
17
|
/**
|
|
6
18
|
* Enables the instrumentation. Unset means the register hook is never loaded
|
|
@@ -69,12 +81,22 @@ function debugLog(config, message) {
|
|
|
69
81
|
* coverage already reads V8's own counters and needs nothing injected.
|
|
70
82
|
*/
|
|
71
83
|
const require$1 = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
|
|
72
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* Wraps a Next config, preserving any `webpack` hook and `turbopack` rules it
|
|
86
|
+
* already has. Both bundlers are wired because the config does not know which
|
|
87
|
+
* one will build it: webpack gets a post-loader over compiled JavaScript,
|
|
88
|
+
* Turbopack — which has no post phase and never calls the `webpack` hook —
|
|
89
|
+
* gets rule loaders that instrument the original TypeScript instead. Missing
|
|
90
|
+
* either half means a Turbopack (or webpack) build silently ships
|
|
91
|
+
* uninstrumented server code whose only coverage is the module-load boot set.
|
|
92
|
+
*/
|
|
73
93
|
function withCoverage(config, options = {}) {
|
|
74
94
|
if (!(options.enabled ?? process.env["CCQA_COVERAGE"] !== void 0)) return config;
|
|
75
95
|
const root = (0, node_path.resolve)(options.root ?? readConfig().root);
|
|
76
|
-
const
|
|
96
|
+
const relativeInclude = (options.include ?? readConfig().include).map((dir) => dir.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, ""));
|
|
97
|
+
const include = relativeInclude.map((dir) => (0, node_path.resolve)(root, dir));
|
|
77
98
|
const previous = config.webpack;
|
|
99
|
+
debugLog(readConfig(), `instrumenting server bundles under ${include.join(", ")}`);
|
|
78
100
|
return {
|
|
79
101
|
...config,
|
|
80
102
|
webpack(webpackConfig, context) {
|
|
@@ -84,7 +106,7 @@ function withCoverage(config, options = {}) {
|
|
|
84
106
|
next.module.rules ??= [];
|
|
85
107
|
next.module.rules.push({
|
|
86
108
|
enforce: "post",
|
|
87
|
-
test: /\.(?:[cm]?
|
|
109
|
+
test: /\.(?:[cm]?[jt]s|jsx|tsx)$/,
|
|
88
110
|
include,
|
|
89
111
|
exclude: /[\\/]node_modules[\\/]/,
|
|
90
112
|
use: [{
|
|
@@ -92,10 +114,49 @@ function withCoverage(config, options = {}) {
|
|
|
92
114
|
options: { root }
|
|
93
115
|
}]
|
|
94
116
|
});
|
|
95
|
-
debugLog(readConfig(), `instrumenting server bundles under ${include.join(", ")}`);
|
|
96
117
|
return next;
|
|
97
|
-
}
|
|
118
|
+
},
|
|
119
|
+
turbopack: withTurbopackRules(config.turbopack, root, relativeInclude)
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Turbopack rule globs match by extension, everywhere — there is no
|
|
124
|
+
* `include` matcher — so project scoping happens inside the loader through
|
|
125
|
+
* `shouldInstrument`. The rule's condition does what the webpack half's
|
|
126
|
+
* `isServer` gate and `exclude` matcher do: `not browser` keeps probes out
|
|
127
|
+
* of client bundles, `not foreign` keeps dependencies out of the loader
|
|
128
|
+
* entirely. No `as` is set, so the instrumented output stays the same module
|
|
129
|
+
* type and flows through the framework's own TypeScript pipeline.
|
|
130
|
+
*
|
|
131
|
+
* One measured trap: Turbopack serves unchanged files from its persistent
|
|
132
|
+
* cache without consulting rules again, so judging a rule change by an
|
|
133
|
+
* incremental build lies — a rule "not firing" may just be a warm cache.
|
|
134
|
+
*/
|
|
135
|
+
function withTurbopackRules(existing, root, include) {
|
|
136
|
+
const rule = {
|
|
137
|
+
condition: { all: [{ not: "browser" }, { not: "foreign" }] },
|
|
138
|
+
loaders: [{
|
|
139
|
+
loader: require$1.resolve("./next-loader.cjs"),
|
|
140
|
+
options: {
|
|
141
|
+
root,
|
|
142
|
+
include,
|
|
143
|
+
dialect: "source"
|
|
144
|
+
}
|
|
145
|
+
}]
|
|
98
146
|
};
|
|
147
|
+
const rules = { ...existing?.rules };
|
|
148
|
+
for (const extension of SOURCE_EXTENSIONS) {
|
|
149
|
+
const glob = `*${extension}`;
|
|
150
|
+
rules[glob] = [...toArray(rules[glob]), rule];
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
...existing,
|
|
154
|
+
rules
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function toArray(value) {
|
|
158
|
+
if (value === void 0) return [];
|
|
159
|
+
return Array.isArray(value) ? value : [value];
|
|
99
160
|
}
|
|
100
161
|
//#endregion
|
|
101
162
|
exports.withCoverage = withCoverage;
|
package/dist/coverage/next.d.cts
CHANGED
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
* `__ccqaCoverage` calls to browsers, where the front-end side of ccqa's
|
|
18
18
|
* coverage already reads V8's own counters and needs nothing injected.
|
|
19
19
|
*/
|
|
20
|
+
type TurbopackRules = Record<string, unknown>;
|
|
21
|
+
interface TurbopackConfig {
|
|
22
|
+
rules?: TurbopackRules;
|
|
23
|
+
}
|
|
20
24
|
interface CoverageNextOptions {
|
|
21
25
|
/** Project root that file ids are relative to. Defaults to `process.cwd()`. */
|
|
22
26
|
root?: string;
|
|
@@ -25,9 +29,18 @@ interface CoverageNextOptions {
|
|
|
25
29
|
/** Set false to build without instrumentation while keeping the config in place. */
|
|
26
30
|
enabled?: boolean;
|
|
27
31
|
}
|
|
28
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Wraps a Next config, preserving any `webpack` hook and `turbopack` rules it
|
|
34
|
+
* already has. Both bundlers are wired because the config does not know which
|
|
35
|
+
* one will build it: webpack gets a post-loader over compiled JavaScript,
|
|
36
|
+
* Turbopack — which has no post phase and never calls the `webpack` hook —
|
|
37
|
+
* gets rule loaders that instrument the original TypeScript instead. Missing
|
|
38
|
+
* either half means a Turbopack (or webpack) build silently ships
|
|
39
|
+
* uninstrumented server code whose only coverage is the module-load boot set.
|
|
40
|
+
*/
|
|
29
41
|
declare function withCoverage<T extends {
|
|
30
42
|
webpack?: unknown;
|
|
43
|
+
turbopack?: TurbopackConfig;
|
|
31
44
|
}>(config: T, options?: CoverageNextOptions): T;
|
|
32
45
|
//#endregion
|
|
33
46
|
export { CoverageNextOptions, withCoverage };
|
package/dist/coverage/next.d.ts
CHANGED
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
* `__ccqaCoverage` calls to browsers, where the front-end side of ccqa's
|
|
18
18
|
* coverage already reads V8's own counters and needs nothing injected.
|
|
19
19
|
*/
|
|
20
|
+
type TurbopackRules = Record<string, unknown>;
|
|
21
|
+
interface TurbopackConfig {
|
|
22
|
+
rules?: TurbopackRules;
|
|
23
|
+
}
|
|
20
24
|
interface CoverageNextOptions {
|
|
21
25
|
/** Project root that file ids are relative to. Defaults to `process.cwd()`. */
|
|
22
26
|
root?: string;
|
|
@@ -25,9 +29,18 @@ interface CoverageNextOptions {
|
|
|
25
29
|
/** Set false to build without instrumentation while keeping the config in place. */
|
|
26
30
|
enabled?: boolean;
|
|
27
31
|
}
|
|
28
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Wraps a Next config, preserving any `webpack` hook and `turbopack` rules it
|
|
34
|
+
* already has. Both bundlers are wired because the config does not know which
|
|
35
|
+
* one will build it: webpack gets a post-loader over compiled JavaScript,
|
|
36
|
+
* Turbopack — which has no post phase and never calls the `webpack` hook —
|
|
37
|
+
* gets rule loaders that instrument the original TypeScript instead. Missing
|
|
38
|
+
* either half means a Turbopack (or webpack) build silently ships
|
|
39
|
+
* uninstrumented server code whose only coverage is the module-load boot set.
|
|
40
|
+
*/
|
|
29
41
|
declare function withCoverage<T extends {
|
|
30
42
|
webpack?: unknown;
|
|
43
|
+
turbopack?: TurbopackConfig;
|
|
31
44
|
}>(config: T, options?: CoverageNextOptions): T;
|
|
32
45
|
//#endregion
|
|
33
46
|
export { CoverageNextOptions, withCoverage };
|
package/dist/coverage/next.js
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
+
//#region src/coverage/instrument/select.ts
|
|
4
|
+
const SOURCE_EXTENSIONS = [
|
|
5
|
+
".js",
|
|
6
|
+
".mjs",
|
|
7
|
+
".cjs",
|
|
8
|
+
".jsx",
|
|
9
|
+
".ts",
|
|
10
|
+
".mts",
|
|
11
|
+
".cts",
|
|
12
|
+
".tsx"
|
|
13
|
+
];
|
|
14
|
+
//#endregion
|
|
3
15
|
//#region src/coverage/wire.ts
|
|
4
16
|
/**
|
|
5
17
|
* Enables the instrumentation. Unset means the register hook is never loaded
|
|
@@ -68,12 +80,22 @@ function debugLog(config, message) {
|
|
|
68
80
|
* coverage already reads V8's own counters and needs nothing injected.
|
|
69
81
|
*/
|
|
70
82
|
const require = createRequire(import.meta.url);
|
|
71
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Wraps a Next config, preserving any `webpack` hook and `turbopack` rules it
|
|
85
|
+
* already has. Both bundlers are wired because the config does not know which
|
|
86
|
+
* one will build it: webpack gets a post-loader over compiled JavaScript,
|
|
87
|
+
* Turbopack — which has no post phase and never calls the `webpack` hook —
|
|
88
|
+
* gets rule loaders that instrument the original TypeScript instead. Missing
|
|
89
|
+
* either half means a Turbopack (or webpack) build silently ships
|
|
90
|
+
* uninstrumented server code whose only coverage is the module-load boot set.
|
|
91
|
+
*/
|
|
72
92
|
function withCoverage(config, options = {}) {
|
|
73
93
|
if (!(options.enabled ?? process.env["CCQA_COVERAGE"] !== void 0)) return config;
|
|
74
94
|
const root = resolve(options.root ?? readConfig().root);
|
|
75
|
-
const
|
|
95
|
+
const relativeInclude = (options.include ?? readConfig().include).map((dir) => dir.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, ""));
|
|
96
|
+
const include = relativeInclude.map((dir) => resolve(root, dir));
|
|
76
97
|
const previous = config.webpack;
|
|
98
|
+
debugLog(readConfig(), `instrumenting server bundles under ${include.join(", ")}`);
|
|
77
99
|
return {
|
|
78
100
|
...config,
|
|
79
101
|
webpack(webpackConfig, context) {
|
|
@@ -83,7 +105,7 @@ function withCoverage(config, options = {}) {
|
|
|
83
105
|
next.module.rules ??= [];
|
|
84
106
|
next.module.rules.push({
|
|
85
107
|
enforce: "post",
|
|
86
|
-
test: /\.(?:[cm]?
|
|
108
|
+
test: /\.(?:[cm]?[jt]s|jsx|tsx)$/,
|
|
87
109
|
include,
|
|
88
110
|
exclude: /[\\/]node_modules[\\/]/,
|
|
89
111
|
use: [{
|
|
@@ -91,10 +113,49 @@ function withCoverage(config, options = {}) {
|
|
|
91
113
|
options: { root }
|
|
92
114
|
}]
|
|
93
115
|
});
|
|
94
|
-
debugLog(readConfig(), `instrumenting server bundles under ${include.join(", ")}`);
|
|
95
116
|
return next;
|
|
96
|
-
}
|
|
117
|
+
},
|
|
118
|
+
turbopack: withTurbopackRules(config.turbopack, root, relativeInclude)
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Turbopack rule globs match by extension, everywhere — there is no
|
|
123
|
+
* `include` matcher — so project scoping happens inside the loader through
|
|
124
|
+
* `shouldInstrument`. The rule's condition does what the webpack half's
|
|
125
|
+
* `isServer` gate and `exclude` matcher do: `not browser` keeps probes out
|
|
126
|
+
* of client bundles, `not foreign` keeps dependencies out of the loader
|
|
127
|
+
* entirely. No `as` is set, so the instrumented output stays the same module
|
|
128
|
+
* type and flows through the framework's own TypeScript pipeline.
|
|
129
|
+
*
|
|
130
|
+
* One measured trap: Turbopack serves unchanged files from its persistent
|
|
131
|
+
* cache without consulting rules again, so judging a rule change by an
|
|
132
|
+
* incremental build lies — a rule "not firing" may just be a warm cache.
|
|
133
|
+
*/
|
|
134
|
+
function withTurbopackRules(existing, root, include) {
|
|
135
|
+
const rule = {
|
|
136
|
+
condition: { all: [{ not: "browser" }, { not: "foreign" }] },
|
|
137
|
+
loaders: [{
|
|
138
|
+
loader: require.resolve("./next-loader.cjs"),
|
|
139
|
+
options: {
|
|
140
|
+
root,
|
|
141
|
+
include,
|
|
142
|
+
dialect: "source"
|
|
143
|
+
}
|
|
144
|
+
}]
|
|
97
145
|
};
|
|
146
|
+
const rules = { ...existing?.rules };
|
|
147
|
+
for (const extension of SOURCE_EXTENSIONS) {
|
|
148
|
+
const glob = `*${extension}`;
|
|
149
|
+
rules[glob] = [...toArray(rules[glob]), rule];
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
...existing,
|
|
153
|
+
rules
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function toArray(value) {
|
|
157
|
+
if (value === void 0) return [];
|
|
158
|
+
return Array.isArray(value) ? value : [value];
|
|
98
159
|
}
|
|
99
160
|
//#endregion
|
|
100
161
|
export { withCoverage };
|
|
@@ -557,31 +557,36 @@ function readSourceMappingUrl(code) {
|
|
|
557
557
|
for (const match of code.matchAll(SOURCE_MAPPING_URL)) found = match[1];
|
|
558
558
|
return found;
|
|
559
559
|
}
|
|
560
|
-
//#endregion
|
|
561
|
-
//#region src/coverage/instrument/transform.ts
|
|
562
560
|
/**
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
* Two properties drive the whole shape:
|
|
567
|
-
*
|
|
568
|
-
* - **Insertions only, never a newline.** Line numbers survive untouched, so
|
|
569
|
-
* the source map the application already ships keeps pointing at the right
|
|
570
|
-
* lines and stack traces stay readable. A codegen round-trip would have
|
|
571
|
-
* forced us to produce and merge maps of our own.
|
|
572
|
-
* - **File granularity.** The record is "this file ran", so there is no need to
|
|
573
|
-
* track statements or branches, and the whole class of line/branch
|
|
574
|
-
* normalisation bugs that follow V8-to-istanbul conversion never appears.
|
|
561
|
+
* The exact texts both instrumenter dialects splice in, built in one place so
|
|
562
|
+
* a file instrumented from TypeScript and one instrumented from compiled
|
|
563
|
+
* JavaScript are byte-identical where it matters.
|
|
575
564
|
*/
|
|
576
|
-
|
|
565
|
+
function probeTexts(fileId) {
|
|
566
|
+
const local = `__ccqa_${hash(fileId)}`;
|
|
567
|
+
const literal = JSON.stringify(fileId);
|
|
568
|
+
return {
|
|
569
|
+
enter: `;${local}&&${local}(${literal});`,
|
|
570
|
+
prologue: `;var ${local}=globalThis.__ccqaCoverage;${local}&&${local}(${literal},true);`
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
/** Applies insert-only edits (no newlines) so line numbers survive untouched. */
|
|
574
|
+
function splice(code, edits) {
|
|
575
|
+
const parts = [];
|
|
576
|
+
let last = 0;
|
|
577
|
+
for (const edit of edits) {
|
|
578
|
+
parts.push(code.slice(last, edit.offset), edit.text);
|
|
579
|
+
last = edit.offset;
|
|
580
|
+
}
|
|
581
|
+
parts.push(code.slice(last));
|
|
582
|
+
return parts.join("");
|
|
583
|
+
}
|
|
577
584
|
function transform(code, options) {
|
|
578
585
|
const program = parseProgram(code);
|
|
579
586
|
if (program === void 0) return void 0;
|
|
580
|
-
const
|
|
581
|
-
const literal = JSON.stringify(options.fileId);
|
|
582
|
-
const enter = `${local}&&${local}(${literal});`;
|
|
587
|
+
const { enter, prologue } = probeTexts(options.fileId);
|
|
583
588
|
const points = [];
|
|
584
|
-
collect(program, options.maxDepth ??
|
|
589
|
+
collect(program, options.maxDepth ?? 2, points);
|
|
585
590
|
if (code.length === 0) return void 0;
|
|
586
591
|
const prologueAt = afterDirectives(code, program);
|
|
587
592
|
const edits = points.filter((offset) => offset > prologueAt).map((offset) => ({
|
|
@@ -590,17 +595,10 @@ function transform(code, options) {
|
|
|
590
595
|
}));
|
|
591
596
|
edits.push({
|
|
592
597
|
offset: prologueAt,
|
|
593
|
-
text:
|
|
598
|
+
text: prologue
|
|
594
599
|
});
|
|
595
600
|
edits.sort((a, b) => a.offset - b.offset);
|
|
596
|
-
|
|
597
|
-
let last = 0;
|
|
598
|
-
for (const edit of edits) {
|
|
599
|
-
parts.push(code.slice(last, edit.offset), edit.text);
|
|
600
|
-
last = edit.offset;
|
|
601
|
-
}
|
|
602
|
-
parts.push(code.slice(last));
|
|
603
|
-
return parts.join("");
|
|
601
|
+
return splice(code, edits);
|
|
604
602
|
}
|
|
605
603
|
function parseProgram(code) {
|
|
606
604
|
for (const sourceType of ["module", "script"]) try {
|
|
@@ -532,31 +532,36 @@ function readSourceMappingUrl(code) {
|
|
|
532
532
|
for (const match of code.matchAll(SOURCE_MAPPING_URL)) found = match[1];
|
|
533
533
|
return found;
|
|
534
534
|
}
|
|
535
|
-
//#endregion
|
|
536
|
-
//#region src/coverage/instrument/transform.ts
|
|
537
535
|
/**
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
* Two properties drive the whole shape:
|
|
542
|
-
*
|
|
543
|
-
* - **Insertions only, never a newline.** Line numbers survive untouched, so
|
|
544
|
-
* the source map the application already ships keeps pointing at the right
|
|
545
|
-
* lines and stack traces stay readable. A codegen round-trip would have
|
|
546
|
-
* forced us to produce and merge maps of our own.
|
|
547
|
-
* - **File granularity.** The record is "this file ran", so there is no need to
|
|
548
|
-
* track statements or branches, and the whole class of line/branch
|
|
549
|
-
* normalisation bugs that follow V8-to-istanbul conversion never appears.
|
|
536
|
+
* The exact texts both instrumenter dialects splice in, built in one place so
|
|
537
|
+
* a file instrumented from TypeScript and one instrumented from compiled
|
|
538
|
+
* JavaScript are byte-identical where it matters.
|
|
550
539
|
*/
|
|
551
|
-
|
|
540
|
+
function probeTexts(fileId) {
|
|
541
|
+
const local = `__ccqa_${hash(fileId)}`;
|
|
542
|
+
const literal = JSON.stringify(fileId);
|
|
543
|
+
return {
|
|
544
|
+
enter: `;${local}&&${local}(${literal});`,
|
|
545
|
+
prologue: `;var ${local}=globalThis.__ccqaCoverage;${local}&&${local}(${literal},true);`
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
/** Applies insert-only edits (no newlines) so line numbers survive untouched. */
|
|
549
|
+
function splice(code, edits) {
|
|
550
|
+
const parts = [];
|
|
551
|
+
let last = 0;
|
|
552
|
+
for (const edit of edits) {
|
|
553
|
+
parts.push(code.slice(last, edit.offset), edit.text);
|
|
554
|
+
last = edit.offset;
|
|
555
|
+
}
|
|
556
|
+
parts.push(code.slice(last));
|
|
557
|
+
return parts.join("");
|
|
558
|
+
}
|
|
552
559
|
function transform(code, options) {
|
|
553
560
|
const program = parseProgram(code);
|
|
554
561
|
if (program === void 0) return void 0;
|
|
555
|
-
const
|
|
556
|
-
const literal = JSON.stringify(options.fileId);
|
|
557
|
-
const enter = `${local}&&${local}(${literal});`;
|
|
562
|
+
const { enter, prologue } = probeTexts(options.fileId);
|
|
558
563
|
const points = [];
|
|
559
|
-
collect(program, options.maxDepth ??
|
|
564
|
+
collect(program, options.maxDepth ?? 2, points);
|
|
560
565
|
if (code.length === 0) return void 0;
|
|
561
566
|
const prologueAt = afterDirectives(code, program);
|
|
562
567
|
const edits = points.filter((offset) => offset > prologueAt).map((offset) => ({
|
|
@@ -565,17 +570,10 @@ function transform(code, options) {
|
|
|
565
570
|
}));
|
|
566
571
|
edits.push({
|
|
567
572
|
offset: prologueAt,
|
|
568
|
-
text:
|
|
573
|
+
text: prologue
|
|
569
574
|
});
|
|
570
575
|
edits.sort((a, b) => a.offset - b.offset);
|
|
571
|
-
|
|
572
|
-
let last = 0;
|
|
573
|
-
for (const edit of edits) {
|
|
574
|
-
parts.push(code.slice(last, edit.offset), edit.text);
|
|
575
|
-
last = edit.offset;
|
|
576
|
-
}
|
|
577
|
-
parts.push(code.slice(last));
|
|
578
|
-
return parts.join("");
|
|
576
|
+
return splice(code, edits);
|
|
579
577
|
}
|
|
580
578
|
function parseProgram(code) {
|
|
581
579
|
for (const sourceType of ["module", "script"]) try {
|