ccqa-tools 1.37.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 +177 -0
- package/dist/coverage/collector.cjs +229 -0
- package/dist/coverage/collector.d.cts +183 -0
- package/dist/coverage/collector.d.ts +183 -0
- package/dist/coverage/collector.js +225 -0
- package/dist/coverage/core.cjs +176 -0
- package/dist/coverage/core.d.cts +157 -0
- package/dist/coverage/core.d.ts +157 -0
- package/dist/coverage/core.js +163 -0
- package/dist/coverage/middleware.cjs +161 -0
- package/dist/coverage/middleware.d.cts +23 -0
- package/dist/coverage/middleware.d.ts +23 -0
- package/dist/coverage/middleware.js +158 -0
- package/dist/coverage/next-loader.cjs +158 -0
- package/dist/coverage/next-loader.d.cts +18 -0
- package/dist/coverage/next-loader.d.ts +19 -0
- package/dist/coverage/next-loader.js +158 -0
- package/dist/coverage/next.cjs +101 -0
- package/dist/coverage/next.d.cts +33 -0
- package/dist/coverage/next.d.ts +33 -0
- package/dist/coverage/next.js +100 -0
- package/dist/coverage/register.cjs +741 -0
- package/dist/coverage/register.d.cts +1 -0
- package/dist/coverage/register.d.ts +1 -0
- package/dist/coverage/register.js +716 -0
- package/dist/coverage/slack.cjs +228 -0
- package/dist/coverage/slack.d.cts +47 -0
- package/dist/coverage/slack.d.ts +47 -0
- package/dist/coverage/slack.js +225 -0
- package/dist/coverage/temporal-workflow.cjs +154 -0
- package/dist/coverage/temporal-workflow.d.cts +28 -0
- package/dist/coverage/temporal-workflow.d.ts +28 -0
- package/dist/coverage/temporal-workflow.js +153 -0
- package/dist/coverage/temporal.cjs +253 -0
- package/dist/coverage/temporal.d.cts +40 -0
- package/dist/coverage/temporal.d.ts +40 -0
- package/dist/coverage/temporal.js +250 -0
- package/package.json +95 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
//#region src/coverage/core.ts
|
|
2
|
+
const RUNTIME_KEY = Symbol.for("ccqa.coverage.runtime");
|
|
3
|
+
function globals() {
|
|
4
|
+
return globalThis;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Memoized so the hot path skips the `globalThis` read. `installRuntime`
|
|
8
|
+
* primes it and never replaces an installed runtime, so once set it never
|
|
9
|
+
* goes stale — but it must stay undefined (and keep re-reading `globalThis`)
|
|
10
|
+
* until then, or a module that finished loading before `register` installs
|
|
11
|
+
* the runtime would be stuck uninstrumented for the rest of the process.
|
|
12
|
+
*/
|
|
13
|
+
let cachedRuntime;
|
|
14
|
+
function runtime() {
|
|
15
|
+
if (cachedRuntime === void 0) cachedRuntime = globals()[RUNTIME_KEY];
|
|
16
|
+
return cachedRuntime;
|
|
17
|
+
}
|
|
18
|
+
/** The spec the current async context belongs to, if any. */
|
|
19
|
+
function currentSpecId() {
|
|
20
|
+
return runtime()?.als.getStore()?.specId;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Opens `specId`'s bucket and runs `fn` inside its context. Every entry point —
|
|
24
|
+
* HTTP, Temporal activity, manual — funnels through here.
|
|
25
|
+
*
|
|
26
|
+
* The bucket outlives `fn`: work a request schedules and does not await still
|
|
27
|
+
* belongs to the spec that caused it.
|
|
28
|
+
*/
|
|
29
|
+
function runInSpec(specId, fn) {
|
|
30
|
+
const rt = runtime();
|
|
31
|
+
if (rt === void 0) return fn();
|
|
32
|
+
return rt.als.run({
|
|
33
|
+
specId,
|
|
34
|
+
files: openBucket(rt, specId)
|
|
35
|
+
}, fn);
|
|
36
|
+
}
|
|
37
|
+
/** Returns `specId`'s file set, creating it — and arming the gate — if new. */
|
|
38
|
+
function openBucket(runtime, specId) {
|
|
39
|
+
let files = runtime.buckets.get(specId);
|
|
40
|
+
if (files === void 0) {
|
|
41
|
+
files = /* @__PURE__ */ new Set();
|
|
42
|
+
runtime.buckets.set(specId, files);
|
|
43
|
+
armGate(runtime);
|
|
44
|
+
}
|
|
45
|
+
return files;
|
|
46
|
+
}
|
|
47
|
+
function armGate(runtime) {
|
|
48
|
+
runtime.active = runtime.buckets.size + runtime.actors.size;
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
51
|
+
//#region src/coverage/wire.ts
|
|
52
|
+
/**
|
|
53
|
+
* The names a spec id travels under. Every carrier holds the same value,
|
|
54
|
+
* `<runId>.<specId>`, so a hop between them is a copy and never a translation.
|
|
55
|
+
*
|
|
56
|
+
* Like `core.ts` this file imports nothing: the Temporal workflow sandbox reads
|
|
57
|
+
* it too.
|
|
58
|
+
*/
|
|
59
|
+
/** Set on the browser by ccqa at spec start, scoped to the target origin. */
|
|
60
|
+
const COOKIE_NAME = "__ccqa_coverage";
|
|
61
|
+
/** OTel baggage key, for the hop from the first service to downstream ones. */
|
|
62
|
+
const BAGGAGE_KEY = "ccqa.coverage";
|
|
63
|
+
const SPEC_ID = /^[A-Za-z0-9._\-/]{1,200}$/;
|
|
64
|
+
/**
|
|
65
|
+
* Accepts a carrier value only if it looks like an id we wrote.
|
|
66
|
+
*
|
|
67
|
+
* The cookie is client-controlled, so this is the first of two gates: the
|
|
68
|
+
* second is the hub refusing runs it never started.
|
|
69
|
+
*/
|
|
70
|
+
function parseSpecId(raw) {
|
|
71
|
+
if (!raw) return void 0;
|
|
72
|
+
const value = raw.trim();
|
|
73
|
+
if (!SPEC_ID.test(value)) return void 0;
|
|
74
|
+
if (value === "1" || value === "true") return void 0;
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
function readCookie(header) {
|
|
78
|
+
return readKeyed(header, COOKIE_NAME, ";");
|
|
79
|
+
}
|
|
80
|
+
/** Pulls our key out of a `baggage` header (W3C: `k=v;props,k2=v2`). */
|
|
81
|
+
function readBaggage(header) {
|
|
82
|
+
return readKeyed(header, BAGGAGE_KEY, ",", ";");
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Both carriers are `key=value` lists; they differ only in what separates the
|
|
86
|
+
* entries, and baggage allowing properties after each value.
|
|
87
|
+
*
|
|
88
|
+
* One function because the decode-and-validate step is the part that matters,
|
|
89
|
+
* and two copies of it would be free to drift into accepting different things.
|
|
90
|
+
*/
|
|
91
|
+
function readKeyed(header, key, between, propertiesAfter) {
|
|
92
|
+
if (!header) return void 0;
|
|
93
|
+
if (header.indexOf(key) < 0) return void 0;
|
|
94
|
+
for (const raw of header.split(between)) {
|
|
95
|
+
const entry = propertiesAfter === void 0 ? raw : raw.split(propertiesAfter)[0] ?? "";
|
|
96
|
+
const eq = entry.indexOf("=");
|
|
97
|
+
if (eq < 0) continue;
|
|
98
|
+
if (entry.slice(0, eq).trim() !== key) continue;
|
|
99
|
+
try {
|
|
100
|
+
return parseSpecId(decodeURIComponent(entry.slice(eq + 1).trim()));
|
|
101
|
+
} catch {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Adds our key to an existing `baggage` header value, replacing any old one. */
|
|
107
|
+
function writeBaggage(existing, specId) {
|
|
108
|
+
const kept = (existing ?? "").split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0 && !entry.startsWith(`ccqa.coverage=`));
|
|
109
|
+
kept.push(`${BAGGAGE_KEY}=${encodeURIComponent(specId)}`);
|
|
110
|
+
return kept.join(",");
|
|
111
|
+
}
|
|
112
|
+
//#endregion
|
|
113
|
+
//#region src/coverage/middleware.ts
|
|
114
|
+
/**
|
|
115
|
+
* Entry points for servers `ccqa-tools/coverage/register` cannot wrap on its own:
|
|
116
|
+
* anything that does not receive its requests from `node:http`.
|
|
117
|
+
*
|
|
118
|
+
* A framework running on plain Node needs none of this — the register hook
|
|
119
|
+
* already opened the context before the framework saw the request.
|
|
120
|
+
*/
|
|
121
|
+
/** connect / express / fastify-compat middleware. */
|
|
122
|
+
function coverageMiddleware() {
|
|
123
|
+
return function coverage(request, _response, next) {
|
|
124
|
+
const specId = specIdFromNodeHeaders(request.headers);
|
|
125
|
+
if (specId === void 0) {
|
|
126
|
+
next();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
runInSpec(specId, next);
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** Wraps a `Request` -> `Response` handler (Hono, Next.js route handlers, workerd). */
|
|
133
|
+
function withCoverage(handler) {
|
|
134
|
+
return function covered(request, ...rest) {
|
|
135
|
+
const specId = readCookie(request.headers.get("cookie")) ?? readBaggage(request.headers.get("baggage"));
|
|
136
|
+
if (specId === void 0) return handler(request, ...rest);
|
|
137
|
+
return runInSpec(specId, () => handler(request, ...rest));
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Converts the cookie into a `baggage` header so downstream services — which
|
|
142
|
+
* never see the browser's cookie jar — inherit the attribution. Call it where
|
|
143
|
+
* the first service fans out, or in an edge middleware that forwards to one.
|
|
144
|
+
*/
|
|
145
|
+
function forwardHeaders(incoming, outgoing) {
|
|
146
|
+
const headers = outgoing ?? new Headers();
|
|
147
|
+
const specId = currentSpecId() ?? readCookie(incoming.get("cookie")) ?? readBaggage(incoming.get("baggage"));
|
|
148
|
+
if (specId !== void 0) headers.set("baggage", writeBaggage(headers.get("baggage") ?? incoming.get("baggage"), specId));
|
|
149
|
+
return headers;
|
|
150
|
+
}
|
|
151
|
+
function specIdFromNodeHeaders(headers) {
|
|
152
|
+
return readCookie(single(headers.cookie)) ?? readBaggage(single(headers.baggage));
|
|
153
|
+
}
|
|
154
|
+
function single(value) {
|
|
155
|
+
return Array.isArray(value) ? value[0] : value;
|
|
156
|
+
}
|
|
157
|
+
//#endregion
|
|
158
|
+
export { coverageMiddleware, forwardHeaders, withCoverage };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
let node_path = require("node:path");
|
|
2
|
+
let acorn = require("acorn");
|
|
3
|
+
//#region src/coverage/instrument/select.ts
|
|
4
|
+
/** Path relative to the project root, in posix form so ids match across hosts. */
|
|
5
|
+
function fileIdFor(filename, root) {
|
|
6
|
+
const rel = (0, node_path.relative)((0, node_path.resolve)(root), filename);
|
|
7
|
+
if (rel.startsWith("..") || rel === "") return void 0;
|
|
8
|
+
return rel.split(node_path.sep).join("/");
|
|
9
|
+
}
|
|
10
|
+
//#endregion
|
|
11
|
+
//#region src/coverage/instrument/transform.ts
|
|
12
|
+
/**
|
|
13
|
+
* Rewrites a module so that entering it, or entering one of its functions,
|
|
14
|
+
* calls `globalThis.__ccqaCoverage`.
|
|
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.
|
|
25
|
+
*/
|
|
26
|
+
const DEFAULT_MAX_DEPTH = 2;
|
|
27
|
+
function transform(code, options) {
|
|
28
|
+
const program = parseProgram(code);
|
|
29
|
+
if (program === void 0) return void 0;
|
|
30
|
+
const local = `__ccqa_${hash(options.fileId)}`;
|
|
31
|
+
const literal = JSON.stringify(options.fileId);
|
|
32
|
+
const enter = `${local}&&${local}(${literal});`;
|
|
33
|
+
const points = [];
|
|
34
|
+
collect(program, options.maxDepth ?? DEFAULT_MAX_DEPTH, points);
|
|
35
|
+
if (code.length === 0) return void 0;
|
|
36
|
+
const prologueAt = afterDirectives(code, program);
|
|
37
|
+
const edits = points.filter((offset) => offset > prologueAt).map((offset) => ({
|
|
38
|
+
offset,
|
|
39
|
+
text: enter
|
|
40
|
+
}));
|
|
41
|
+
edits.push({
|
|
42
|
+
offset: prologueAt,
|
|
43
|
+
text: `var ${local}=globalThis.__ccqaCoverage;${local}&&${local}(${literal},true);`
|
|
44
|
+
});
|
|
45
|
+
edits.sort((a, b) => a.offset - b.offset);
|
|
46
|
+
const parts = [];
|
|
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("");
|
|
54
|
+
}
|
|
55
|
+
function parseProgram(code) {
|
|
56
|
+
for (const sourceType of ["module", "script"]) try {
|
|
57
|
+
return (0, acorn.parse)(code, {
|
|
58
|
+
ecmaVersion: "latest",
|
|
59
|
+
sourceType,
|
|
60
|
+
allowHashBang: true,
|
|
61
|
+
allowAwaitOutsideFunction: true,
|
|
62
|
+
allowReturnOutsideFunction: sourceType === "script"
|
|
63
|
+
});
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A directive prologue only counts while it is still the first thing in its
|
|
68
|
+
* scope. Inserting ahead of `"use strict"` demotes it to an ordinary string
|
|
69
|
+
* expression, and the code it governed silently starts running sloppy — the
|
|
70
|
+
* instrumentation would be changing the behaviour it is supposed to observe.
|
|
71
|
+
* Applies to a function body as much as to the module.
|
|
72
|
+
*/
|
|
73
|
+
function afterDirectives(code, program) {
|
|
74
|
+
let offset = program.start;
|
|
75
|
+
if (code.startsWith("#!")) {
|
|
76
|
+
const newline = code.indexOf("\n");
|
|
77
|
+
offset = newline < 0 ? code.length : newline + 1;
|
|
78
|
+
}
|
|
79
|
+
return skipDirectives(program.body, offset);
|
|
80
|
+
}
|
|
81
|
+
function skipDirectives(statements, from) {
|
|
82
|
+
let offset = from;
|
|
83
|
+
for (const statement of statements) {
|
|
84
|
+
if (statement.type !== "ExpressionStatement") break;
|
|
85
|
+
const expression = statement.expression;
|
|
86
|
+
if (expression.type !== "Literal" || typeof expression.value !== "string") break;
|
|
87
|
+
offset = statement.end;
|
|
88
|
+
}
|
|
89
|
+
return offset;
|
|
90
|
+
}
|
|
91
|
+
const FUNCTION_TYPES = new Set([
|
|
92
|
+
"FunctionDeclaration",
|
|
93
|
+
"FunctionExpression",
|
|
94
|
+
"ArrowFunctionExpression"
|
|
95
|
+
]);
|
|
96
|
+
function collect(root, maxDepth, points) {
|
|
97
|
+
walk(root, 0, false);
|
|
98
|
+
function walk(node, depth, inClass) {
|
|
99
|
+
const isFunction = FUNCTION_TYPES.has(node.type);
|
|
100
|
+
const nextDepth = isFunction ? depth + 1 : depth;
|
|
101
|
+
if (isFunction) {
|
|
102
|
+
const wanted = inClass || nextDepth <= maxDepth;
|
|
103
|
+
const body = node.body;
|
|
104
|
+
if (wanted && body && body.type === "BlockStatement") {
|
|
105
|
+
const statements = body.body;
|
|
106
|
+
points.push(skipDirectives(statements, body.start + 1));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const key in node) {
|
|
110
|
+
if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
|
|
111
|
+
const value = node[key];
|
|
112
|
+
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));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** True while walking the value of a class member, false again inside its body. */
|
|
119
|
+
function childInClass(parent, key, inherited) {
|
|
120
|
+
if (parent.type === "MethodDefinition" || parent.type === "PropertyDefinition") return key === "value";
|
|
121
|
+
if (FUNCTION_TYPES.has(parent.type)) return false;
|
|
122
|
+
return inherited;
|
|
123
|
+
}
|
|
124
|
+
function isNode(value) {
|
|
125
|
+
return typeof value === "object" && value !== null && typeof value.type === "string";
|
|
126
|
+
}
|
|
127
|
+
/** Short, collision-resistant suffix so bundlers can hoist several modules into one scope. */
|
|
128
|
+
function hash(value) {
|
|
129
|
+
let h = 2166136261;
|
|
130
|
+
for (let i = 0; i < value.length; i++) {
|
|
131
|
+
h ^= value.charCodeAt(i);
|
|
132
|
+
h = Math.imul(h, 16777619);
|
|
133
|
+
}
|
|
134
|
+
return (h >>> 0).toString(36);
|
|
135
|
+
}
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region src/coverage/next/loader.ts
|
|
138
|
+
/**
|
|
139
|
+
* webpack loader form of the instrumenter, for code that reaches the runtime as
|
|
140
|
+
* part of a bundle and so is invisible to the load hooks.
|
|
141
|
+
*
|
|
142
|
+
* It is registered with `enforce: "post"` so it sees JavaScript: webpack runs
|
|
143
|
+
* post loaders last, after the framework's own TypeScript/JSX transform, which
|
|
144
|
+
* keeps this file free of a TypeScript parser.
|
|
145
|
+
*/
|
|
146
|
+
function ccqaCoverageLoader(source) {
|
|
147
|
+
const root = this.getOptions?.().root ?? process.cwd();
|
|
148
|
+
const fileId = fileIdFor(this.resourcePath, root);
|
|
149
|
+
if (fileId === void 0) return source;
|
|
150
|
+
const instrumented = transform(source, { fileId });
|
|
151
|
+
if (instrumented === void 0) {
|
|
152
|
+
this.emitWarning?.(/* @__PURE__ */ new Error(`ccqa-tools could not parse ${fileId}; left uninstrumented`));
|
|
153
|
+
return source;
|
|
154
|
+
}
|
|
155
|
+
return instrumented;
|
|
156
|
+
}
|
|
157
|
+
//#endregion
|
|
158
|
+
module.exports = ccqaCoverageLoader;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//#region src/coverage/next/loader.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* webpack 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.
|
|
5
|
+
*
|
|
6
|
+
* It is registered with `enforce: "post"` so it sees JavaScript: webpack runs
|
|
7
|
+
* post loaders last, after the framework's own TypeScript/JSX transform, which
|
|
8
|
+
* keeps this file free of a TypeScript parser.
|
|
9
|
+
*/
|
|
10
|
+
interface LoaderContext {
|
|
11
|
+
resourcePath: string;
|
|
12
|
+
getOptions?: () => {
|
|
13
|
+
root?: string;
|
|
14
|
+
};
|
|
15
|
+
emitWarning?: (warning: Error) => void;
|
|
16
|
+
}
|
|
17
|
+
declare function ccqaCoverageLoader(this: LoaderContext, source: string): string;
|
|
18
|
+
export = ccqaCoverageLoader;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
//#region src/coverage/next/loader.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* webpack 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.
|
|
5
|
+
*
|
|
6
|
+
* It is registered with `enforce: "post"` so it sees JavaScript: webpack runs
|
|
7
|
+
* post loaders last, after the framework's own TypeScript/JSX transform, which
|
|
8
|
+
* keeps this file free of a TypeScript parser.
|
|
9
|
+
*/
|
|
10
|
+
interface LoaderContext {
|
|
11
|
+
resourcePath: string;
|
|
12
|
+
getOptions?: () => {
|
|
13
|
+
root?: string;
|
|
14
|
+
};
|
|
15
|
+
emitWarning?: (warning: Error) => void;
|
|
16
|
+
}
|
|
17
|
+
declare function ccqaCoverageLoader(this: LoaderContext, source: string): string;
|
|
18
|
+
//#endregion
|
|
19
|
+
export { ccqaCoverageLoader as default };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { relative, resolve, sep } from "node:path";
|
|
2
|
+
import { parse } from "acorn";
|
|
3
|
+
//#region src/coverage/instrument/select.ts
|
|
4
|
+
/** Path relative to the project root, in posix form so ids match across hosts. */
|
|
5
|
+
function fileIdFor(filename, root) {
|
|
6
|
+
const rel = relative(resolve(root), filename);
|
|
7
|
+
if (rel.startsWith("..") || rel === "") return void 0;
|
|
8
|
+
return rel.split(sep).join("/");
|
|
9
|
+
}
|
|
10
|
+
//#endregion
|
|
11
|
+
//#region src/coverage/instrument/transform.ts
|
|
12
|
+
/**
|
|
13
|
+
* Rewrites a module so that entering it, or entering one of its functions,
|
|
14
|
+
* calls `globalThis.__ccqaCoverage`.
|
|
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.
|
|
25
|
+
*/
|
|
26
|
+
const DEFAULT_MAX_DEPTH = 2;
|
|
27
|
+
function transform(code, options) {
|
|
28
|
+
const program = parseProgram(code);
|
|
29
|
+
if (program === void 0) return void 0;
|
|
30
|
+
const local = `__ccqa_${hash(options.fileId)}`;
|
|
31
|
+
const literal = JSON.stringify(options.fileId);
|
|
32
|
+
const enter = `${local}&&${local}(${literal});`;
|
|
33
|
+
const points = [];
|
|
34
|
+
collect(program, options.maxDepth ?? DEFAULT_MAX_DEPTH, points);
|
|
35
|
+
if (code.length === 0) return void 0;
|
|
36
|
+
const prologueAt = afterDirectives(code, program);
|
|
37
|
+
const edits = points.filter((offset) => offset > prologueAt).map((offset) => ({
|
|
38
|
+
offset,
|
|
39
|
+
text: enter
|
|
40
|
+
}));
|
|
41
|
+
edits.push({
|
|
42
|
+
offset: prologueAt,
|
|
43
|
+
text: `var ${local}=globalThis.__ccqaCoverage;${local}&&${local}(${literal},true);`
|
|
44
|
+
});
|
|
45
|
+
edits.sort((a, b) => a.offset - b.offset);
|
|
46
|
+
const parts = [];
|
|
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("");
|
|
54
|
+
}
|
|
55
|
+
function parseProgram(code) {
|
|
56
|
+
for (const sourceType of ["module", "script"]) try {
|
|
57
|
+
return parse(code, {
|
|
58
|
+
ecmaVersion: "latest",
|
|
59
|
+
sourceType,
|
|
60
|
+
allowHashBang: true,
|
|
61
|
+
allowAwaitOutsideFunction: true,
|
|
62
|
+
allowReturnOutsideFunction: sourceType === "script"
|
|
63
|
+
});
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A directive prologue only counts while it is still the first thing in its
|
|
68
|
+
* scope. Inserting ahead of `"use strict"` demotes it to an ordinary string
|
|
69
|
+
* expression, and the code it governed silently starts running sloppy — the
|
|
70
|
+
* instrumentation would be changing the behaviour it is supposed to observe.
|
|
71
|
+
* Applies to a function body as much as to the module.
|
|
72
|
+
*/
|
|
73
|
+
function afterDirectives(code, program) {
|
|
74
|
+
let offset = program.start;
|
|
75
|
+
if (code.startsWith("#!")) {
|
|
76
|
+
const newline = code.indexOf("\n");
|
|
77
|
+
offset = newline < 0 ? code.length : newline + 1;
|
|
78
|
+
}
|
|
79
|
+
return skipDirectives(program.body, offset);
|
|
80
|
+
}
|
|
81
|
+
function skipDirectives(statements, from) {
|
|
82
|
+
let offset = from;
|
|
83
|
+
for (const statement of statements) {
|
|
84
|
+
if (statement.type !== "ExpressionStatement") break;
|
|
85
|
+
const expression = statement.expression;
|
|
86
|
+
if (expression.type !== "Literal" || typeof expression.value !== "string") break;
|
|
87
|
+
offset = statement.end;
|
|
88
|
+
}
|
|
89
|
+
return offset;
|
|
90
|
+
}
|
|
91
|
+
const FUNCTION_TYPES = new Set([
|
|
92
|
+
"FunctionDeclaration",
|
|
93
|
+
"FunctionExpression",
|
|
94
|
+
"ArrowFunctionExpression"
|
|
95
|
+
]);
|
|
96
|
+
function collect(root, maxDepth, points) {
|
|
97
|
+
walk(root, 0, false);
|
|
98
|
+
function walk(node, depth, inClass) {
|
|
99
|
+
const isFunction = FUNCTION_TYPES.has(node.type);
|
|
100
|
+
const nextDepth = isFunction ? depth + 1 : depth;
|
|
101
|
+
if (isFunction) {
|
|
102
|
+
const wanted = inClass || nextDepth <= maxDepth;
|
|
103
|
+
const body = node.body;
|
|
104
|
+
if (wanted && body && body.type === "BlockStatement") {
|
|
105
|
+
const statements = body.body;
|
|
106
|
+
points.push(skipDirectives(statements, body.start + 1));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const key in node) {
|
|
110
|
+
if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
|
|
111
|
+
const value = node[key];
|
|
112
|
+
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));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** True while walking the value of a class member, false again inside its body. */
|
|
119
|
+
function childInClass(parent, key, inherited) {
|
|
120
|
+
if (parent.type === "MethodDefinition" || parent.type === "PropertyDefinition") return key === "value";
|
|
121
|
+
if (FUNCTION_TYPES.has(parent.type)) return false;
|
|
122
|
+
return inherited;
|
|
123
|
+
}
|
|
124
|
+
function isNode(value) {
|
|
125
|
+
return typeof value === "object" && value !== null && typeof value.type === "string";
|
|
126
|
+
}
|
|
127
|
+
/** Short, collision-resistant suffix so bundlers can hoist several modules into one scope. */
|
|
128
|
+
function hash(value) {
|
|
129
|
+
let h = 2166136261;
|
|
130
|
+
for (let i = 0; i < value.length; i++) {
|
|
131
|
+
h ^= value.charCodeAt(i);
|
|
132
|
+
h = Math.imul(h, 16777619);
|
|
133
|
+
}
|
|
134
|
+
return (h >>> 0).toString(36);
|
|
135
|
+
}
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region src/coverage/next/loader.ts
|
|
138
|
+
/**
|
|
139
|
+
* webpack loader form of the instrumenter, for code that reaches the runtime as
|
|
140
|
+
* part of a bundle and so is invisible to the load hooks.
|
|
141
|
+
*
|
|
142
|
+
* It is registered with `enforce: "post"` so it sees JavaScript: webpack runs
|
|
143
|
+
* post loaders last, after the framework's own TypeScript/JSX transform, which
|
|
144
|
+
* keeps this file free of a TypeScript parser.
|
|
145
|
+
*/
|
|
146
|
+
function ccqaCoverageLoader(source) {
|
|
147
|
+
const root = this.getOptions?.().root ?? process.cwd();
|
|
148
|
+
const fileId = fileIdFor(this.resourcePath, root);
|
|
149
|
+
if (fileId === void 0) return source;
|
|
150
|
+
const instrumented = transform(source, { fileId });
|
|
151
|
+
if (instrumented === void 0) {
|
|
152
|
+
this.emitWarning?.(/* @__PURE__ */ new Error(`ccqa-tools could not parse ${fileId}; left uninstrumented`));
|
|
153
|
+
return source;
|
|
154
|
+
}
|
|
155
|
+
return instrumented;
|
|
156
|
+
}
|
|
157
|
+
//#endregion
|
|
158
|
+
export { ccqaCoverageLoader as default };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let node_module = require("node:module");
|
|
3
|
+
let node_path = require("node:path");
|
|
4
|
+
//#region src/coverage/wire.ts
|
|
5
|
+
/**
|
|
6
|
+
* Enables the instrumentation. Unset means the register hook is never loaded
|
|
7
|
+
* and the application pays nothing at all.
|
|
8
|
+
*
|
|
9
|
+
* `1` / `true` turns it on and leaves attribution to the incoming carrier.
|
|
10
|
+
* Any other value is itself a `<runId>.<specId>` and becomes the ambient spec
|
|
11
|
+
* for the process — the only way to attribute an entry point that has no
|
|
12
|
+
* inbound request to read, such as a worker started per spec.
|
|
13
|
+
*/
|
|
14
|
+
const ENV_NAME = "CCQA_COVERAGE";
|
|
15
|
+
const SPEC_ID = /^[A-Za-z0-9._\-/]{1,200}$/;
|
|
16
|
+
/**
|
|
17
|
+
* Accepts a carrier value only if it looks like an id we wrote.
|
|
18
|
+
*
|
|
19
|
+
* The cookie is client-controlled, so this is the first of two gates: the
|
|
20
|
+
* second is the hub refusing runs it never started.
|
|
21
|
+
*/
|
|
22
|
+
function parseSpecId(raw) {
|
|
23
|
+
if (!raw) return void 0;
|
|
24
|
+
const value = raw.trim();
|
|
25
|
+
if (!SPEC_ID.test(value)) return void 0;
|
|
26
|
+
if (value === "1" || value === "true") return void 0;
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/coverage/runtime-env.ts
|
|
31
|
+
function readConfig(env = process.env) {
|
|
32
|
+
const raw = env[ENV_NAME];
|
|
33
|
+
const include = (env["CCQA_COVERAGE_INCLUDE"] ?? "src").split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
|
34
|
+
return {
|
|
35
|
+
enabled: raw !== void 0 && raw !== "" && raw !== "0" && raw !== "false",
|
|
36
|
+
ambientSpecId: parseSpecId(raw),
|
|
37
|
+
root: env["CCQA_COVERAGE_ROOT"] ?? process.cwd(),
|
|
38
|
+
include,
|
|
39
|
+
debug: env["CCQA_COVERAGE_DEBUG"] === "1" || env["CCQA_COVERAGE_DEBUG"] === "true"
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Diagnostics go to stderr and nowhere else. A `--import` preload is inherited
|
|
44
|
+
* by every child node process, and writing to stdout corrupts whatever the host
|
|
45
|
+
* was parsing there — enough to make a framework's own toolchain fail to start.
|
|
46
|
+
*/
|
|
47
|
+
function debugLog(config, message) {
|
|
48
|
+
if (!config.debug) return;
|
|
49
|
+
process.stderr.write(`[ccqa-tools] ${message}\n`);
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/coverage/next/index.ts
|
|
53
|
+
/**
|
|
54
|
+
* Next.js integration.
|
|
55
|
+
*
|
|
56
|
+
* Next bundles its server code, so the load hooks in
|
|
57
|
+
* `ccqa-tools/coverage/register` never see the application's own modules — only the
|
|
58
|
+
* bundle. The instrumentation therefore has to happen at build time, while the
|
|
59
|
+
* context that attributes it still comes from the register hook wrapping
|
|
60
|
+
* `node:http`. Both halves are required:
|
|
61
|
+
*
|
|
62
|
+
* // next.config.ts
|
|
63
|
+
* export default withCoverage({ ...yourConfig })
|
|
64
|
+
*
|
|
65
|
+
* NODE_OPTIONS='--import ccqa-tools/coverage/register' CCQA_COVERAGE=1 next start
|
|
66
|
+
*
|
|
67
|
+
* Only server bundles are instrumented. Instrumenting the client would ship
|
|
68
|
+
* `__ccqaCoverage` calls to browsers, where the front-end side of ccqa's
|
|
69
|
+
* coverage already reads V8's own counters and needs nothing injected.
|
|
70
|
+
*/
|
|
71
|
+
const require$1 = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
|
|
72
|
+
/** Wraps a Next config, preserving any `webpack` hook it already has. */
|
|
73
|
+
function withCoverage(config, options = {}) {
|
|
74
|
+
if (!(options.enabled ?? process.env["CCQA_COVERAGE"] !== void 0)) return config;
|
|
75
|
+
const root = (0, node_path.resolve)(options.root ?? readConfig().root);
|
|
76
|
+
const include = (options.include ?? ["src"]).map((dir) => (0, node_path.resolve)(root, dir));
|
|
77
|
+
const previous = config.webpack;
|
|
78
|
+
return {
|
|
79
|
+
...config,
|
|
80
|
+
webpack(webpackConfig, context) {
|
|
81
|
+
const next = previous ? previous(webpackConfig, context) : webpackConfig;
|
|
82
|
+
if (!context.isServer) return next;
|
|
83
|
+
next.module ??= {};
|
|
84
|
+
next.module.rules ??= [];
|
|
85
|
+
next.module.rules.push({
|
|
86
|
+
enforce: "post",
|
|
87
|
+
test: /\.(?:[cm]?js|jsx|tsx?)$/,
|
|
88
|
+
include,
|
|
89
|
+
exclude: /[\\/]node_modules[\\/]/,
|
|
90
|
+
use: [{
|
|
91
|
+
loader: require$1.resolve("./next-loader.cjs"),
|
|
92
|
+
options: { root }
|
|
93
|
+
}]
|
|
94
|
+
});
|
|
95
|
+
debugLog(readConfig(), `instrumenting server bundles under ${include.join(", ")}`);
|
|
96
|
+
return next;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
//#endregion
|
|
101
|
+
exports.withCoverage = withCoverage;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
//#region src/coverage/next/index.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Next.js integration.
|
|
4
|
+
*
|
|
5
|
+
* Next bundles its server code, so the load hooks in
|
|
6
|
+
* `ccqa-tools/coverage/register` never see the application's own modules — only the
|
|
7
|
+
* bundle. The instrumentation therefore has to happen at build time, while the
|
|
8
|
+
* context that attributes it still comes from the register hook wrapping
|
|
9
|
+
* `node:http`. Both halves are required:
|
|
10
|
+
*
|
|
11
|
+
* // next.config.ts
|
|
12
|
+
* export default withCoverage({ ...yourConfig })
|
|
13
|
+
*
|
|
14
|
+
* NODE_OPTIONS='--import ccqa-tools/coverage/register' CCQA_COVERAGE=1 next start
|
|
15
|
+
*
|
|
16
|
+
* Only server bundles are instrumented. Instrumenting the client would ship
|
|
17
|
+
* `__ccqaCoverage` calls to browsers, where the front-end side of ccqa's
|
|
18
|
+
* coverage already reads V8's own counters and needs nothing injected.
|
|
19
|
+
*/
|
|
20
|
+
interface CoverageNextOptions {
|
|
21
|
+
/** Project root that file ids are relative to. Defaults to `process.cwd()`. */
|
|
22
|
+
root?: string;
|
|
23
|
+
/** Directories, relative to the root, to instrument. Defaults to `["src"]`. */
|
|
24
|
+
include?: string[];
|
|
25
|
+
/** Set false to build without instrumentation while keeping the config in place. */
|
|
26
|
+
enabled?: boolean;
|
|
27
|
+
}
|
|
28
|
+
/** Wraps a Next config, preserving any `webpack` hook it already has. */
|
|
29
|
+
declare function withCoverage<T extends {
|
|
30
|
+
webpack?: unknown;
|
|
31
|
+
}>(config: T, options?: CoverageNextOptions): T;
|
|
32
|
+
//#endregion
|
|
33
|
+
export { CoverageNextOptions, withCoverage };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
//#region src/coverage/next/index.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Next.js integration.
|
|
4
|
+
*
|
|
5
|
+
* Next bundles its server code, so the load hooks in
|
|
6
|
+
* `ccqa-tools/coverage/register` never see the application's own modules — only the
|
|
7
|
+
* bundle. The instrumentation therefore has to happen at build time, while the
|
|
8
|
+
* context that attributes it still comes from the register hook wrapping
|
|
9
|
+
* `node:http`. Both halves are required:
|
|
10
|
+
*
|
|
11
|
+
* // next.config.ts
|
|
12
|
+
* export default withCoverage({ ...yourConfig })
|
|
13
|
+
*
|
|
14
|
+
* NODE_OPTIONS='--import ccqa-tools/coverage/register' CCQA_COVERAGE=1 next start
|
|
15
|
+
*
|
|
16
|
+
* Only server bundles are instrumented. Instrumenting the client would ship
|
|
17
|
+
* `__ccqaCoverage` calls to browsers, where the front-end side of ccqa's
|
|
18
|
+
* coverage already reads V8's own counters and needs nothing injected.
|
|
19
|
+
*/
|
|
20
|
+
interface CoverageNextOptions {
|
|
21
|
+
/** Project root that file ids are relative to. Defaults to `process.cwd()`. */
|
|
22
|
+
root?: string;
|
|
23
|
+
/** Directories, relative to the root, to instrument. Defaults to `["src"]`. */
|
|
24
|
+
include?: string[];
|
|
25
|
+
/** Set false to build without instrumentation while keeping the config in place. */
|
|
26
|
+
enabled?: boolean;
|
|
27
|
+
}
|
|
28
|
+
/** Wraps a Next config, preserving any `webpack` hook it already has. */
|
|
29
|
+
declare function withCoverage<T extends {
|
|
30
|
+
webpack?: unknown;
|
|
31
|
+
}>(config: T, options?: CoverageNextOptions): T;
|
|
32
|
+
//#endregion
|
|
33
|
+
export { CoverageNextOptions, withCoverage };
|