@specific.dev/spectest 0.119.4 → 0.119.6
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.d.ts +20 -0
- package/dist/coverage-next.js +106 -0
- package/dist/coverage.d.ts +36 -3
- package/dist/coverage.js +51 -6
- package/dist/daemon.js +28 -1
- package/dist/harness/browser-coverage.d.ts +2 -0
- package/dist/harness/browser-coverage.js +7 -2
- package/dist/harness/coverage.d.ts +3 -0
- package/dist/harness/coverage.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/next.d.ts +4 -0
- package/dist/next.js +93 -0
- package/package.json +6 -1
- package/src/coverage-next.test.ts +136 -0
- package/src/coverage-next.ts +125 -0
- package/src/coverage.test.ts +16 -1
- package/src/coverage.ts +74 -8
- package/src/daemon.ts +20 -0
- package/src/harness/browser-coverage.test.ts +17 -0
- package/src/harness/browser-coverage.ts +10 -2
- package/src/harness/coverage.test.ts +3 -2
- package/src/harness/coverage.ts +4 -1
- package/src/index.ts +2 -0
- package/src/next.ts +85 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type CoverageAdapter, type CoverageSourceOptions } from "./coverage.js";
|
|
2
|
+
export interface NextCoverageOptions extends CoverageSourceOptions {
|
|
3
|
+
/** Repository-relative directory represented by the build's source-map root.
|
|
4
|
+
* Defaults to "." (repository root). Set explicitly for a nested build root. */
|
|
5
|
+
sourceRoot?: string;
|
|
6
|
+
/** Runtime app directory, relative to the container working directory. */
|
|
7
|
+
appDir?: string;
|
|
8
|
+
/** Next's build output directory, relative to appDir. Default: .next. */
|
|
9
|
+
distDir?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare const NEXT_COVERAGE_HELPER_PATH = "/spectest/next-coverage.cjs";
|
|
12
|
+
export declare const NEXT_COVERAGE_HELPER: string;
|
|
13
|
+
export interface NextBuildMetadata {
|
|
14
|
+
generated: string | null;
|
|
15
|
+
serverMaps: number;
|
|
16
|
+
browserMaps: number;
|
|
17
|
+
}
|
|
18
|
+
/** Server + browser coverage for a built Next app. Build-time maps are required;
|
|
19
|
+
* use withCoverage from @specific.dev/spectest/next in the Next configuration. */
|
|
20
|
+
export declare function next(options?: NextCoverageOptions): CoverageAdapter;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Next policy lives in the SDK. The collector/indexer only see ordinary
|
|
2
|
+
// Node/browser reports and generic, report-scoped source metadata.
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { node, browser } from "./coverage.js";
|
|
5
|
+
export const NEXT_COVERAGE_HELPER_PATH = "/spectest/next-coverage.cjs";
|
|
6
|
+
// Also preloaded before the Node hook. Only an identified standalone launcher
|
|
7
|
+
// is generated: an authored server.js for a custom server remains application code.
|
|
8
|
+
export const NEXT_COVERAGE_HELPER = String.raw `"use strict";
|
|
9
|
+
const fs = require("node:fs"), path = require("node:path");
|
|
10
|
+
const options = JSON.parse(process.env.SPECTEST_NEXT_COVERAGE_OPTIONS || "{}");
|
|
11
|
+
const app = path.resolve(options.appDir || ".");
|
|
12
|
+
const dist = path.resolve(app, options.distDir || ".next");
|
|
13
|
+
function manifest() { return JSON.parse(fs.readFileSync(path.join(dist, "required-server-files.json"), "utf8")); }
|
|
14
|
+
function launcher(m) {
|
|
15
|
+
const file = path.join(app, "server.js");
|
|
16
|
+
if (m.config.output !== "standalone" || !fs.existsSync(file)) return null;
|
|
17
|
+
const text = fs.readFileSync(file, "utf8");
|
|
18
|
+
if (!text.includes("__NEXT_PRIVATE_STANDALONE_CONFIG") || !text.includes("next/dist/server/lib/start-server")) return null;
|
|
19
|
+
return path.relative(process.cwd(), file);
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const file = launcher(manifest());
|
|
23
|
+
if (file) process.env.SPECTEST_COVERAGE_GENERATED_FILES = JSON.stringify([
|
|
24
|
+
...new Set([...JSON.parse(process.env.SPECTEST_COVERAGE_GENERATED_FILES || "[]"), file])
|
|
25
|
+
]);
|
|
26
|
+
} catch {} // Capture reports a useful error; do not change application startup.
|
|
27
|
+
if (require.main === module) {
|
|
28
|
+
try {
|
|
29
|
+
const m = manifest();
|
|
30
|
+
let serverMaps = 0, browserMaps = 0;
|
|
31
|
+
function walk(dir, server) {
|
|
32
|
+
if (!fs.existsSync(dir)) return;
|
|
33
|
+
for (const e of fs.readdirSync(dir, {withFileTypes:true})) {
|
|
34
|
+
const file = path.join(dir, e.name);
|
|
35
|
+
if (e.isDirectory()) walk(file, server);
|
|
36
|
+
else if (e.name.endsWith(".js.map")) {
|
|
37
|
+
if (server) serverMaps++; else browserMaps++;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
walk(path.join(dist, "server"), true);
|
|
42
|
+
walk(path.join(dist, "static"), false);
|
|
43
|
+
if (!serverMaps || !browserMaps) throw new Error("Next coverage needs server and browser .js.map files in the runtime image. Enable withCoverage from @specific.dev/spectest/next in the test build and retain .next/server and .next/static maps when copying standalone output.");
|
|
44
|
+
process.stdout.write(JSON.stringify({generated:launcher(m), serverMaps, browserMaps}));
|
|
45
|
+
} catch (e) { console.error("Next coverage: " + e.message); process.exitCode = 1; }
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
const GENERATED_SOURCES = [
|
|
49
|
+
"webpack://*/", // Loader-only modules with an empty source identity.
|
|
50
|
+
"webpack://*/?*", // Loader-only modules without a resource path.
|
|
51
|
+
"webpack://*/external *", // Webpack external-module wrappers.
|
|
52
|
+
"*/data:* <text/javascript>", // Turbopack virtual modules, including action proxies.
|
|
53
|
+
"*/.next-internal/server/app/*/actions.js (server actions loader)",
|
|
54
|
+
"*/app/*--route-entry.js",
|
|
55
|
+
"*/app/*.mjs (structured image object)",
|
|
56
|
+
];
|
|
57
|
+
/** Server + browser coverage for a built Next app. Build-time maps are required;
|
|
58
|
+
* use withCoverage from @specific.dev/spectest/next in the Next configuration. */
|
|
59
|
+
export function next(options = {}) {
|
|
60
|
+
const sourceRoot = options.sourceRoot === undefined ? "." : options.sourceRoot;
|
|
61
|
+
if (typeof sourceRoot !== "string" || !sourceRoot || /[\\:\0\r\n]/.test(sourceRoot) || (sourceRoot !== "." && sourceRoot.split("/").some(p => !p || p === "." || p === "..")))
|
|
62
|
+
throw new Error("coverage.next sourceRoot must be repository-relative (use \".\" for the repository root)");
|
|
63
|
+
options = { ...options, sourceRoot };
|
|
64
|
+
for (const [key, value] of Object.entries({ appDir: options.appDir, distDir: options.distDir })) {
|
|
65
|
+
if (value !== undefined && (!value || path.posix.isAbsolute(value) || value.split("/").some(p => p === "..") || /[\\\0\r\n]/.test(value)))
|
|
66
|
+
throw new Error(`coverage.next ${key} must be a relative runtime directory`);
|
|
67
|
+
}
|
|
68
|
+
const server = node(), client = browser();
|
|
69
|
+
const cache = new Map();
|
|
70
|
+
const metadata = (ctx) => {
|
|
71
|
+
let value = cache.get(ctx.service);
|
|
72
|
+
if (!value) {
|
|
73
|
+
value = ctx.exec(`NODE_OPTIONS= node ${NEXT_COVERAGE_HELPER_PATH}`).then(r => JSON.parse(r.stdout));
|
|
74
|
+
cache.set(ctx.service, value);
|
|
75
|
+
value.catch(() => cache.delete(ctx.service));
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
name: "next", reports: "delta", explains: server.explains,
|
|
81
|
+
ownsReport: name => server.ownsReport(name) || client.ownsReport(name),
|
|
82
|
+
configure(svc, info) {
|
|
83
|
+
const configured = server.configure(svc, info);
|
|
84
|
+
return { ...configured,
|
|
85
|
+
env: { ...configured.env,
|
|
86
|
+
NODE_OPTIONS: `--require ${NEXT_COVERAGE_HELPER_PATH} ${configured.env.NODE_OPTIONS}`,
|
|
87
|
+
SPECTEST_NEXT_COVERAGE_OPTIONS: JSON.stringify(options),
|
|
88
|
+
SPECTEST_COVERAGE_SERVICE: info.key, },
|
|
89
|
+
files: [...configured.files, { path: NEXT_COVERAGE_HELPER_PATH, content: NEXT_COVERAGE_HELPER }],
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
load: client.load,
|
|
93
|
+
async sourceOptions(ctx) {
|
|
94
|
+
await metadata(ctx);
|
|
95
|
+
return { sourceRoot, excludeSources: [...GENERATED_SOURCES, ...(options.excludeSources ?? [])] };
|
|
96
|
+
},
|
|
97
|
+
async inventory(ctx) {
|
|
98
|
+
const m = await metadata(ctx);
|
|
99
|
+
return node({ generated: m.generated ? [m.generated] : [] }).inventory(ctx);
|
|
100
|
+
},
|
|
101
|
+
async capture(ctx) {
|
|
102
|
+
await server.capture(ctx);
|
|
103
|
+
await client.capture(ctx);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
package/dist/coverage.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ServiceConfig } from "./index.js";
|
|
2
2
|
import { COVERAGE_CONTAINER_DIR, type CoverageReportsMode } from "./harness/coverage.js";
|
|
3
3
|
export { COVERAGE_CONTAINER_DIR, type CoverageReportsMode };
|
|
4
|
+
export { next, type NextCoverageOptions } from "./coverage-next.js";
|
|
4
5
|
/** Where `node()` mounts its hook inside the container. */
|
|
5
6
|
export declare const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
|
|
6
7
|
/** The report `node()` writes when nothing has run yet on a branch. */
|
|
@@ -74,9 +75,21 @@ export interface CoverageCaptureContext {
|
|
|
74
75
|
/** Aborts when the per-service capture budget runs out. */
|
|
75
76
|
signal: AbortSignal;
|
|
76
77
|
}
|
|
78
|
+
/** Source interpretation belongs to the adapter that produced each report. */
|
|
79
|
+
export interface CoverageSourceOptions {
|
|
80
|
+
/** Repository-relative root corresponding to the report's source workspace. */
|
|
81
|
+
sourceRoot?: string;
|
|
82
|
+
/** Whole source identities; only `*` is special, including `/`. */
|
|
83
|
+
excludeSources?: readonly string[];
|
|
84
|
+
}
|
|
85
|
+
export type CoverageSourceContext = CoverageInventoryContext;
|
|
77
86
|
export interface CoverageAdapter {
|
|
78
87
|
/** Names the adapter in errors (`coverage adapter "node" on service …`). */
|
|
79
88
|
name: string;
|
|
89
|
+
/** Archived on this adapter's reports; other adapters retain their own policy. */
|
|
90
|
+
sourceOptions?: CoverageSourceOptions | ((ctx: CoverageSourceContext) => Promise<CoverageSourceOptions>);
|
|
91
|
+
/** Required with sourceOptions, including reports written directly by a runtime. */
|
|
92
|
+
ownsReport?(name: string): boolean;
|
|
80
93
|
/**
|
|
81
94
|
* How the reports this adapter writes (through `ctx.writeReport`)
|
|
82
95
|
* count: `"delta"` — what ran since the previous capture, the tool
|
|
@@ -127,10 +140,17 @@ export declare const INVENTORY_REPORT = "inventory.units";
|
|
|
127
140
|
export type ServiceCoverage = true | {
|
|
128
141
|
adapters?: readonly CoverageAdapter[];
|
|
129
142
|
reports?: CoverageReportsMode;
|
|
130
|
-
/**
|
|
143
|
+
/** @deprecated Prefer adapter-owned sourceRoot.
|
|
144
|
+
* Repository-relative root of this service's build/bundler workspace.
|
|
131
145
|
* For a monorepo built from packages/, set sourceRoot: "packages".
|
|
132
146
|
* Paths are checked against the repository; no suffix guessing. */
|
|
133
147
|
sourceRoot?: string;
|
|
148
|
+
/** @deprecated Prefer adapter-owned excludeSources.
|
|
149
|
+
* Reported source identities to omit from file coverage. `*` matches
|
|
150
|
+
* any characters, including `/`; everything else is literal. Use only
|
|
151
|
+
* for known generated/dependency sources, never missing application maps.
|
|
152
|
+
* Package unit coverage is retained. These rules are archived per capture. */
|
|
153
|
+
excludeSources?: readonly string[];
|
|
134
154
|
};
|
|
135
155
|
/** The `reports` mode of a `coverage` value, for the reports no adapter
|
|
136
156
|
* wrote (the program's own, a `command`'s). */
|
|
@@ -150,6 +170,19 @@ export declare function coverageAdapters(cov: ServiceCoverage | undefined): read
|
|
|
150
170
|
* adapter names (`toJSON`), never as functions.
|
|
151
171
|
*/
|
|
152
172
|
export declare function applyCoverageAdapters<S extends ServiceConfig>(key: string, svc: S): S;
|
|
173
|
+
/** Resolve adapter policies once per capture. Invalid/overlapping ownership is
|
|
174
|
+
* an error, so one adapter cannot silently reinterpret another's reports. */
|
|
175
|
+
export declare function resolveCoverageSourceOptions(adapters: readonly CoverageAdapter[], ctx: CoverageSourceContext): Promise<{
|
|
176
|
+
adapter: string;
|
|
177
|
+
ownsReport: (name: string) => boolean;
|
|
178
|
+
options: CoverageSourceOptions | undefined;
|
|
179
|
+
}[]>;
|
|
180
|
+
export declare function attachCoverageSourceOptions<T extends {
|
|
181
|
+
reports: {
|
|
182
|
+
name: string;
|
|
183
|
+
sourceOptions?: CoverageSourceOptions;
|
|
184
|
+
}[];
|
|
185
|
+
}>(capture: T, policies: Awaited<ReturnType<typeof resolveCoverageSourceOptions>>): T;
|
|
153
186
|
/** `env` with `value` appended to `name` (space-separated), or set. */
|
|
154
187
|
export declare function appendEnvFlag(env: Readonly<Record<string, string>> | undefined, name: string, value: string): Record<string, string>;
|
|
155
188
|
/** Env var the hook reads: the coverage directory inside the container. */
|
|
@@ -263,7 +296,7 @@ export declare function npmManifestUnit(projectPath: string, text: string): stri
|
|
|
263
296
|
* Prints one `npm:<name>@<version>` per line.
|
|
264
297
|
*/
|
|
265
298
|
export declare const NODE_INVENTORY_SCRIPT = "\"use strict\";\nconst fs = require(\"node:fs\"), path = require(\"node:path\");\nconst out = new Set();\nfunction isRealDir(p) { try { return fs.lstatSync(p).isDirectory(); } catch (e) { return false; } }\nfunction readPkg(d) {\n try {\n const j = JSON.parse(fs.readFileSync(path.join(d, \"package.json\"), \"utf8\"));\n if (j && typeof j.name === \"string\" && j.name) out.add(\"npm:\" + j.name + (typeof j.version === \"string\" && j.version ? \"@\" + j.version : \"\"));\n } catch (e) {}\n}\nfunction visit(d, depth) {\n readPkg(d);\n if (depth < 3) { const nested = path.join(d, \"node_modules\"); if (isRealDir(nested)) walkNm(nested, depth + 1); }\n}\nfunction walkNm(nm, depth) {\n let names; try { names = fs.readdirSync(nm); } catch (e) { return; }\n for (const n of names) {\n if (n === \".bin\" || n === \".cache\" || n === \".modules.yaml\") continue;\n if (n === \".pnpm\") {\n let ps; try { ps = fs.readdirSync(path.join(nm, \".pnpm\")); } catch (e) { continue; }\n for (const p of ps) { const inner = path.join(nm, \".pnpm\", p, \"node_modules\"); if (isRealDir(inner)) walkNm(inner, depth + 1); }\n continue;\n }\n if (n.startsWith(\"@\")) {\n let ss; try { ss = fs.readdirSync(path.join(nm, n)); } catch (e) { continue; }\n for (const s of ss) { const d = path.join(nm, n, s); if (isRealDir(d)) visit(d, depth); }\n } else { const d = path.join(nm, n); if (isRealDir(d)) visit(d, depth); }\n }\n}\nlet d = process.cwd();\nfor (;;) {\n const nm = path.join(d, \"node_modules\");\n if (isRealDir(nm)) walkNm(nm, 0);\n const up = path.dirname(d);\n if (up === d) break;\n d = up;\n}\nprocess.stdout.write(Array.from(out).sort().join(\"\\n\") + (out.size ? \"\\n\" : \"\"));\n";
|
|
266
|
-
export interface NodeCoverageOptions {
|
|
299
|
+
export interface NodeCoverageOptions extends CoverageSourceOptions {
|
|
267
300
|
/** Generated runtime entrypoints, relative to the service working directory.
|
|
268
301
|
* Track their exact contents as units rather than inventing repository lines.
|
|
269
302
|
* A content change changes the inventory and invalidates dependent tests. */
|
|
@@ -307,7 +340,7 @@ export declare function collectNodeReports(dir: string): Promise<NodeReportStats
|
|
|
307
340
|
* harvested first, and only this service's totals are taken and cleared. Nothing ran ⇒
|
|
308
341
|
* an lcov with no records, which is a report saying so.
|
|
309
342
|
*/
|
|
310
|
-
export declare function browser(): CoverageAdapter;
|
|
343
|
+
export declare function browser(options?: CoverageSourceOptions): CoverageAdapter;
|
|
311
344
|
/**
|
|
312
345
|
* Run `command` (via `sh -c`) in the container at every capture, so the
|
|
313
346
|
* service writes a fresh report into `/spectest/coverage/`. The escape
|
package/dist/coverage.js
CHANGED
|
@@ -30,6 +30,7 @@ import { createHash } from "node:crypto";
|
|
|
30
30
|
import { harvestAllBrowserCoverage, takeBrowserCoverageReport } from "./browser-coverage.js";
|
|
31
31
|
import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
|
|
32
32
|
export { COVERAGE_CONTAINER_DIR };
|
|
33
|
+
export { next } from "./coverage-next.js";
|
|
33
34
|
/** Where `node()` mounts its hook inside the container. */
|
|
34
35
|
export const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
|
|
35
36
|
/** The report `node()` writes when nothing has run yet on a branch. */
|
|
@@ -58,10 +59,13 @@ export function validateCoverage(service, cov) {
|
|
|
58
59
|
const adapters = obj?.adapters;
|
|
59
60
|
const reports = obj?.reports;
|
|
60
61
|
const sourceRoot = obj?.sourceRoot;
|
|
62
|
+
const excludeSources = obj?.excludeSources;
|
|
61
63
|
// `explains` is what `applyCoverageAdapters` adds for the wire; the
|
|
62
64
|
// config is validated again after that, so it must pass here.
|
|
63
65
|
const explains = obj?.explains;
|
|
64
|
-
const knownKeys = obj !== null && Object.keys(obj).every((k) => k === "adapters" || k === "reports" || k === "explains" || k === "sourceRoot");
|
|
66
|
+
const knownKeys = obj !== null && Object.keys(obj).every((k) => k === "adapters" || k === "reports" || k === "explains" || k === "sourceRoot" || k === "excludeSources");
|
|
67
|
+
const okExcludes = excludeSources === undefined || (Array.isArray(excludeSources) &&
|
|
68
|
+
excludeSources.every(p => typeof p === "string" && p.trim().length > 0 && !/[\0\r\n]/.test(p)));
|
|
65
69
|
const okRoot = sourceRoot === undefined || (typeof sourceRoot === "string" &&
|
|
66
70
|
(sourceRoot === "." || (sourceRoot.length > 0 && !sourceRoot.includes("\\") &&
|
|
67
71
|
!sourceRoot.includes(":") && !sourceRoot.includes("\0") &&
|
|
@@ -69,7 +73,8 @@ export function validateCoverage(service, cov) {
|
|
|
69
73
|
const okReports = reports === undefined || reports === "delta" || reports === "cumulative";
|
|
70
74
|
const okAdapters = adapters === undefined || Array.isArray(adapters);
|
|
71
75
|
const okExplains = explains === undefined || (Array.isArray(explains) && explains.every((e) => typeof e === "string"));
|
|
72
|
-
if (obj !== null && knownKeys && okRoot && okReports && okAdapters && okExplains &&
|
|
76
|
+
if (obj !== null && knownKeys && okRoot && okExcludes && okReports && okAdapters && okExplains &&
|
|
77
|
+
(adapters !== undefined || reports !== undefined || excludeSources !== undefined)) {
|
|
73
78
|
for (const a of adapters ?? []) {
|
|
74
79
|
const ok = typeof a === "object" &&
|
|
75
80
|
a !== null &&
|
|
@@ -81,11 +86,16 @@ export function validateCoverage(service, cov) {
|
|
|
81
86
|
(a.explains === undefined ||
|
|
82
87
|
(Array.isArray(a.explains) &&
|
|
83
88
|
a.explains.every((e) => typeof e === "string"))) &&
|
|
84
|
-
["configure", "load", "capture", "inventory"].every((k) => a[k] === undefined ||
|
|
89
|
+
["configure", "load", "capture", "inventory", "ownsReport"].every((k) => a[k] === undefined ||
|
|
85
90
|
typeof a[k] === "function");
|
|
86
|
-
|
|
91
|
+
const sourceOptions = a?.sourceOptions;
|
|
92
|
+
const sourceShape = sourceOptions === undefined || (typeof a.ownsReport === "function" &&
|
|
93
|
+
(typeof sourceOptions === "function" || (typeof sourceOptions === "object" && sourceOptions !== null && !Array.isArray(sourceOptions))));
|
|
94
|
+
if (!ok || !sourceShape) {
|
|
87
95
|
throw new Error(`service "${service}" has an invalid coverage adapter — an adapter is \`{ name, reports?, explains?, configure?, load?, capture?, inventory? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`);
|
|
88
96
|
}
|
|
97
|
+
if (sourceOptions !== undefined && typeof sourceOptions !== "function")
|
|
98
|
+
validateCoverageSourceOptions(service, sourceOptions);
|
|
89
99
|
}
|
|
90
100
|
return;
|
|
91
101
|
}
|
|
@@ -117,19 +127,50 @@ export function applyCoverageAdapters(key, svc) {
|
|
|
117
127
|
// read it without knowing any adapter.
|
|
118
128
|
const explains = [...new Set(adapters.flatMap((a) => a.explains ?? []))];
|
|
119
129
|
const sourceRoot = typeof svc.coverage === "object" ? svc.coverage.sourceRoot : undefined;
|
|
130
|
+
const excludeSources = typeof out.coverage === "object" ? out.coverage.excludeSources : undefined;
|
|
120
131
|
const wire = {
|
|
121
132
|
...(sourceRoot !== undefined ? { sourceRoot } : {}),
|
|
133
|
+
...(excludeSources !== undefined ? { excludeSources } : {}),
|
|
122
134
|
adapters: adapters.map((a) => withWireName(a)),
|
|
123
135
|
...(mode ? { reports: mode } : {}),
|
|
124
136
|
...(explains.length > 0 ? { explains } : {}),
|
|
125
137
|
};
|
|
126
138
|
return { ...out, coverage: wire };
|
|
127
139
|
}
|
|
140
|
+
function validateCoverageSourceOptions(service, options) {
|
|
141
|
+
if (!options || typeof options !== "object" || Array.isArray(options) ||
|
|
142
|
+
!Object.keys(options).every(k => k === "sourceRoot" || k === "excludeSources")) {
|
|
143
|
+
throw new Error(`service "${service}" has invalid adapter sourceOptions`);
|
|
144
|
+
}
|
|
145
|
+
validateCoverage(service, { adapters: [], ...options });
|
|
146
|
+
}
|
|
147
|
+
/** Resolve adapter policies once per capture. Invalid/overlapping ownership is
|
|
148
|
+
* an error, so one adapter cannot silently reinterpret another's reports. */
|
|
149
|
+
export async function resolveCoverageSourceOptions(adapters, ctx) {
|
|
150
|
+
return Promise.all(adapters.filter(a => a.sourceOptions !== undefined || a.ownsReport).map(async (a) => {
|
|
151
|
+
if (!a.ownsReport)
|
|
152
|
+
throw new Error(`coverage adapter "${a.name}" needs ownsReport with sourceOptions`);
|
|
153
|
+
const options = typeof a.sourceOptions === "function" ? await a.sourceOptions(ctx) : a.sourceOptions;
|
|
154
|
+
if (a.sourceOptions !== undefined)
|
|
155
|
+
validateCoverageSourceOptions(ctx.service, options);
|
|
156
|
+
return { adapter: a.name, ownsReport: a.ownsReport, options };
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
export function attachCoverageSourceOptions(capture, policies) {
|
|
160
|
+
for (const report of capture.reports) {
|
|
161
|
+
const matches = policies.filter(p => p.ownsReport(report.name));
|
|
162
|
+
if (matches.length > 1)
|
|
163
|
+
throw new Error(`coverage report "${report.name}" belongs to multiple adapters: ${matches.map(p => p.adapter).join(", ")}`);
|
|
164
|
+
if (matches.length === 1)
|
|
165
|
+
report.sourceOptions = matches[0].options;
|
|
166
|
+
}
|
|
167
|
+
return capture;
|
|
168
|
+
}
|
|
128
169
|
function withWireName(a) {
|
|
129
170
|
if (Object.prototype.hasOwnProperty.call(a, "toJSON"))
|
|
130
171
|
return a;
|
|
131
172
|
return Object.assign(Object.create(Object.getPrototypeOf(a)), a, {
|
|
132
|
-
toJSON: () => ({ adapter: a.name }),
|
|
173
|
+
toJSON: () => ({ adapter: a.name, ...(a.sourceOptions && typeof a.sourceOptions !== "function" ? { sourceOptions: a.sourceOptions } : {}) }),
|
|
133
174
|
});
|
|
134
175
|
}
|
|
135
176
|
/** `env` with `value` appended to `name` (space-separated), or set. */
|
|
@@ -684,6 +725,8 @@ export function node(options = {}) {
|
|
|
684
725
|
}
|
|
685
726
|
return {
|
|
686
727
|
name: "node",
|
|
728
|
+
...(options.sourceRoot !== undefined || options.excludeSources !== undefined ? { sourceOptions: { sourceRoot: options.sourceRoot, excludeSources: options.excludeSources } } : {}),
|
|
729
|
+
ownsReport: name => isNodeDumpName(name) || name === NODE_COVERAGE_EMPTY_REPORT,
|
|
687
730
|
reports: "delta",
|
|
688
731
|
explains: NODE_EXPLAINS,
|
|
689
732
|
async inventory(ctx) {
|
|
@@ -883,9 +926,11 @@ export async function collectNodeReports(dir) {
|
|
|
883
926
|
* harvested first, and only this service's totals are taken and cleared. Nothing ran ⇒
|
|
884
927
|
* an lcov with no records, which is a report saying so.
|
|
885
928
|
*/
|
|
886
|
-
export function browser() {
|
|
929
|
+
export function browser(options = {}) {
|
|
887
930
|
return {
|
|
888
931
|
name: "browser",
|
|
932
|
+
...(options.sourceRoot !== undefined || options.excludeSources !== undefined ? { sourceOptions: options } : {}),
|
|
933
|
+
ownsReport: name => name === "browser.lcov",
|
|
889
934
|
reports: "delta",
|
|
890
935
|
load(ctx) {
|
|
891
936
|
ctx.collectBrowserScripts();
|
package/dist/daemon.js
CHANGED
|
@@ -34,7 +34,7 @@ import { ENVIRONMENT_CA_PATH as CA_PATH } from "./environment-ca.js";
|
|
|
34
34
|
import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, validateServiceImage, normalizeServiceImage, proxy as makeProxyDecl, } from "./index.js";
|
|
35
35
|
import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, applyCoverageDelta, walkProjectFiles, ENVIRONMENT_SERVICE, REACH_REPORT, READS_REPORT, } from "./harness/coverage.js";
|
|
36
36
|
import { configureBrowserCoverage } from "./browser-coverage.js";
|
|
37
|
-
import { INVENTORY_REPORT, NODE_COVERAGE_MARKERS_SUBDIR, NODE_COVERAGE_SCRIPTS_SUBDIR, applyCoverageAdapters, coverageAdapters, coverageReportsMode, validateCoverage, } from "./coverage.js";
|
|
37
|
+
import { INVENTORY_REPORT, NODE_COVERAGE_MARKERS_SUBDIR, NODE_COVERAGE_SCRIPTS_SUBDIR, applyCoverageAdapters, coverageAdapters, resolveCoverageSourceOptions, attachCoverageSourceOptions, coverageReportsMode, validateCoverage, } from "./coverage.js";
|
|
38
38
|
import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
|
|
39
39
|
import { acquirePersistentBrowser, acquirePersistentDesktop, mobileKey } from "./browser.js";
|
|
40
40
|
import { isMobileApp, openPersistentMobile } from "./mobile.js";
|
|
@@ -3427,6 +3427,24 @@ async function captureServiceCoverage(baseline = false) {
|
|
|
3427
3427
|
// services' come from the ones that opted in, none included.
|
|
3428
3428
|
const environment = captureEnvironmentCoverage(all, baseline);
|
|
3429
3429
|
const captures = await Promise.all(services.map(async (svc) => {
|
|
3430
|
+
let policies;
|
|
3431
|
+
try {
|
|
3432
|
+
policies = await resolveCoverageSourceOptions(coverageAdapters(svc.coverage), {
|
|
3433
|
+
...coverageCaptureContext(svc, AbortSignal.timeout(COVERAGE_CAPTURE_TIMEOUT_MS), new Set()),
|
|
3434
|
+
projectFiles: () => walkProjectFiles(WORKSPACE),
|
|
3435
|
+
async readProjectFile(p) {
|
|
3436
|
+
try {
|
|
3437
|
+
return await fs.readFile(resolveProjectPath(p, { record: false }), "utf8");
|
|
3438
|
+
}
|
|
3439
|
+
catch {
|
|
3440
|
+
return null;
|
|
3441
|
+
}
|
|
3442
|
+
},
|
|
3443
|
+
});
|
|
3444
|
+
}
|
|
3445
|
+
catch (err) {
|
|
3446
|
+
return { service: svc.name, reports: [], error: `coverage source metadata: ${err instanceof Error ? err.message : String(err)}` };
|
|
3447
|
+
}
|
|
3430
3448
|
const { error, deltaReports } = await runCoverageAdapters(svc);
|
|
3431
3449
|
if (error)
|
|
3432
3450
|
return { service: svc.name, reports: [], error };
|
|
@@ -3439,12 +3457,21 @@ async function captureServiceCoverage(baseline = false) {
|
|
|
3439
3457
|
if (baseline) {
|
|
3440
3458
|
await fs.rm(path.join(coverageHostDir(WORKSPACE, svc.name), INVENTORY_REPORT), { force: true }).catch(() => { });
|
|
3441
3459
|
}
|
|
3460
|
+
try {
|
|
3461
|
+
attachCoverageSourceOptions(read, policies);
|
|
3462
|
+
}
|
|
3463
|
+
catch (err) {
|
|
3464
|
+
read.error = `coverage source metadata: ${err instanceof Error ? err.message : String(err)}`;
|
|
3465
|
+
}
|
|
3442
3466
|
const root = await coverageRoot(svc.name);
|
|
3443
3467
|
if (root)
|
|
3444
3468
|
read.root = root;
|
|
3445
3469
|
if (typeof svc.coverage === "object" && svc.coverage.sourceRoot !== undefined) {
|
|
3446
3470
|
read.sourceRoot = svc.coverage.sourceRoot;
|
|
3447
3471
|
}
|
|
3472
|
+
if (typeof svc.coverage === "object" && svc.coverage.excludeSources !== undefined) {
|
|
3473
|
+
read.excludeSources = [...svc.coverage.excludeSources];
|
|
3474
|
+
}
|
|
3448
3475
|
// What ships is what ran during this capture, whatever the tool
|
|
3449
3476
|
// counts: cumulative lcov is subtracted against the previous
|
|
3450
3477
|
// capture (module memory, forks with the env); a delta adapter's
|
|
@@ -37,6 +37,8 @@ export interface DecodedSourceMap {
|
|
|
37
37
|
sources: string[];
|
|
38
38
|
/** Segments per generated line (0-based), sorted by column. */
|
|
39
39
|
lines: Segment[][];
|
|
40
|
+
/** Compiler-declared third-party/generated source indices. */
|
|
41
|
+
ignored?: Set<number>;
|
|
40
42
|
}
|
|
41
43
|
/** Decode one VLQ-encoded segment into its fields. */
|
|
42
44
|
export declare function decodeVlq(segment: string): number[];
|
|
@@ -180,6 +180,8 @@ export function decodeSourceMap(doc, depth = 0) {
|
|
|
180
180
|
const base = out.sources.length;
|
|
181
181
|
for (const source of child.sources)
|
|
182
182
|
out.sources.push(source);
|
|
183
|
+
for (const index of child.ignored ?? [])
|
|
184
|
+
(out.ignored ??= new Set()).add(base + index);
|
|
183
185
|
for (let i = 0; i < child.lines.length; i++) {
|
|
184
186
|
const target = line + i;
|
|
185
187
|
if (target > 1_000_000)
|
|
@@ -199,6 +201,9 @@ export function decodeSourceMap(doc, depth = 0) {
|
|
|
199
201
|
}
|
|
200
202
|
const sourceRoot = typeof m.sourceRoot === "string" ? m.sourceRoot : undefined;
|
|
201
203
|
const sources = m.sources.map((s) => typeof s === "string" ? normalizeSourcePath(s, sourceRoot) : "");
|
|
204
|
+
const ignored = m.ignoreList !== undefined ? m.ignoreList : m.x_google_ignoreList;
|
|
205
|
+
if (ignored !== undefined && (!Array.isArray(ignored) || ignored.some(i => !Number.isSafeInteger(i) || i < 0 || i >= sources.length)))
|
|
206
|
+
return null;
|
|
202
207
|
const lines = [];
|
|
203
208
|
let src = 0;
|
|
204
209
|
let line = 0;
|
|
@@ -231,7 +236,7 @@ export function decodeSourceMap(doc, depth = 0) {
|
|
|
231
236
|
catch {
|
|
232
237
|
return null;
|
|
233
238
|
}
|
|
234
|
-
return { sources, lines };
|
|
239
|
+
return { sources, lines, ...(ignored !== undefined ? { ignored: new Set(ignored) } : {}) };
|
|
235
240
|
}
|
|
236
241
|
/**
|
|
237
242
|
* Attribute generated line counts to original (file, line)s through the
|
|
@@ -247,7 +252,7 @@ export function mapToOriginal(map, generated) {
|
|
|
247
252
|
continue;
|
|
248
253
|
for (const seg of segs) {
|
|
249
254
|
const file = map.sources[seg.src];
|
|
250
|
-
if (file === undefined || isForeignSource(file))
|
|
255
|
+
if (file === undefined || map.ignored?.has(seg.src) || isForeignSource(file))
|
|
251
256
|
continue;
|
|
252
257
|
let lc = out.get(file);
|
|
253
258
|
if (!lc) {
|
|
@@ -21,6 +21,7 @@ export declare function coverageHostDir(workspace: string, service: string): str
|
|
|
21
21
|
export interface CoverageReport {
|
|
22
22
|
name: string;
|
|
23
23
|
content: string;
|
|
24
|
+
sourceOptions?: import("../coverage.js").CoverageSourceOptions;
|
|
24
25
|
}
|
|
25
26
|
/** One service's capture: every report in its directory, verbatim. */
|
|
26
27
|
export interface ServiceCoverageCapture {
|
|
@@ -34,6 +35,8 @@ export interface ServiceCoverageCapture {
|
|
|
34
35
|
root?: string;
|
|
35
36
|
/** Repository root of the service build/bundler workspace, when explicit. */
|
|
36
37
|
sourceRoot?: string;
|
|
38
|
+
/** SDK-declared source exclusions, preserved for server-side derivation. */
|
|
39
|
+
excludeSources?: readonly string[];
|
|
37
40
|
/** Set when the capture failed: the command failed, the directory was
|
|
38
41
|
* empty, no file held an `SF:` record, or the reports were too large.
|
|
39
42
|
* A case with an error here fails — a hole in the coverage data must
|
package/dist/harness/coverage.js
CHANGED
|
@@ -397,7 +397,7 @@ export function applyCoverageDelta(capture, mode, deltaReports) {
|
|
|
397
397
|
const prev = prevByName.get(r.name) ?? new Map();
|
|
398
398
|
prevByName.set(r.name, now);
|
|
399
399
|
const restarted = [];
|
|
400
|
-
reports.push({
|
|
400
|
+
reports.push({ ...r, content: renderLcovCounts(subtractLcovCounts(now, prev, restarted)) });
|
|
401
401
|
if (restarted.length > 0) {
|
|
402
402
|
console.warn(`[coverage] service "${capture.service}" report ${r.name}: counters dropped in ${restarted.length} file(s) (${restarted.slice(0, 3).join(", ")}) — a process restarted; those records count whole`);
|
|
403
403
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -34,7 +34,7 @@ export type { Desktop, DesktopApp, DesktopOptions } from "./desktop.js";
|
|
|
34
34
|
import type { Desktop, DesktopApp, DesktopOptions } from "./desktop.js";
|
|
35
35
|
export type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
|
|
36
36
|
import type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
|
|
37
|
-
export { type CoverageAdapter, type CoverageCaptureContext, type CoverageConfigureInfo, type CoverageLoadContext, type ServiceCoverage, } from "./coverage.js";
|
|
37
|
+
export { type CoverageAdapter, type CoverageCaptureContext, type CoverageConfigureInfo, type CoverageSourceOptions, type CoverageSourceContext, type CoverageLoadContext, type ServiceCoverage, } from "./coverage.js";
|
|
38
38
|
import { type ServiceCoverage } from "./coverage.js";
|
|
39
39
|
export { ca } from "./environment-ca.js";
|
|
40
40
|
export { certificate, dnsName, proxy, provides, lowerIngress, isWildcard, SELF_SERVICE_TOKEN, } from "./ingress.js";
|
package/dist/next.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Build-time companion for coverage.next(). Import this lightweight module in
|
|
2
|
+
* next.config; it does not load the Spectest harness or require Next at runtime.
|
|
3
|
+
* Apply only in the test build if production should not expose source maps. */
|
|
4
|
+
export declare function withCoverage<T extends object>(config: T): T;
|
package/dist/next.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/** Keep each traced script's map alongside it in standalone output. Turbopack
|
|
4
|
+
* versions that do not apply outputFileTracingIncludes still emit these traces
|
|
5
|
+
* before runAfterProductionCompile. No source files or generated code change. */
|
|
6
|
+
async function includeTracedMaps(distDir) {
|
|
7
|
+
const traces = [];
|
|
8
|
+
const serverMaps = [];
|
|
9
|
+
async function walk(dir) {
|
|
10
|
+
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
|
11
|
+
const file = path.join(dir, entry.name);
|
|
12
|
+
if (entry.isDirectory() && entry.name !== "cache" && entry.name !== "standalone")
|
|
13
|
+
await walk(file);
|
|
14
|
+
else if (entry.isFile()) {
|
|
15
|
+
if (entry.name.endsWith(".nft.json"))
|
|
16
|
+
traces.push(file);
|
|
17
|
+
if (entry.name.endsWith(".js.map") && file.startsWith(path.join(distDir, "server") + path.sep))
|
|
18
|
+
serverMaps.push(file);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
await walk(distDir);
|
|
23
|
+
for (const file of traces) {
|
|
24
|
+
const dir = path.dirname(file);
|
|
25
|
+
const trace = JSON.parse(await fs.readFile(file, "utf8"));
|
|
26
|
+
if (!Array.isArray(trace.files))
|
|
27
|
+
throw new Error(`Invalid Next trace: ${file}`);
|
|
28
|
+
const scripts = [path.basename(file).slice(0, -".nft.json".length), ...trace.files];
|
|
29
|
+
// Edge bundles are copied through middleware-manifest, not NFT traces.
|
|
30
|
+
// The server trace is always copied into standalone output; retain
|
|
31
|
+
// their maps there without changing the middleware's executable files.
|
|
32
|
+
const maps = file === path.join(distDir, "next-server.js.nft.json")
|
|
33
|
+
? serverMaps.map(map => path.relative(dir, map)) : [];
|
|
34
|
+
for (const script of scripts) {
|
|
35
|
+
if (typeof script !== "string" || !script.endsWith(".js"))
|
|
36
|
+
continue;
|
|
37
|
+
const map = script + ".map";
|
|
38
|
+
try {
|
|
39
|
+
if ((await fs.stat(path.resolve(dir, map))).isFile())
|
|
40
|
+
maps.push(map);
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
if (e.code !== "ENOENT")
|
|
44
|
+
throw e;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
trace.files = [...new Set([...trace.files, ...maps])];
|
|
48
|
+
await fs.writeFile(file, JSON.stringify(trace));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Build-time companion for coverage.next(). Import this lightweight module in
|
|
52
|
+
* next.config; it does not load the Spectest harness or require Next at runtime.
|
|
53
|
+
* Apply only in the test build if production should not expose source maps. */
|
|
54
|
+
export function withCoverage(config) {
|
|
55
|
+
const c = config;
|
|
56
|
+
const distDir = c.distDir ?? ".next";
|
|
57
|
+
const includes = c.outputFileTracingIncludes ?? {};
|
|
58
|
+
return {
|
|
59
|
+
...config,
|
|
60
|
+
productionBrowserSourceMaps: true,
|
|
61
|
+
turbopack: c.turbopack ?? {},
|
|
62
|
+
webpack(webpackConfig, context) {
|
|
63
|
+
const configured = c.webpack ? c.webpack(webpackConfig, context) : webpackConfig;
|
|
64
|
+
configured.plugins ??= [];
|
|
65
|
+
configured.plugins.push({
|
|
66
|
+
apply(compiler) {
|
|
67
|
+
compiler.hooks.compilation.tap("SpectestCoverage", (compilation) => {
|
|
68
|
+
compiler.webpack.NormalModule.getCompilationHooks(compilation).loader.tap("SpectestCoverage", (loader, module) => {
|
|
69
|
+
// Keep dependencies identifiable as dependencies. Chaining a
|
|
70
|
+
// package's published maps can replace node_modules provenance
|
|
71
|
+
// with ambiguous paths such as ../../src/client/router.ts.
|
|
72
|
+
if (/[\\/]node_modules[\\/]/.test(module.resource ?? ""))
|
|
73
|
+
loader.sourceMap = false;
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
return configured;
|
|
79
|
+
},
|
|
80
|
+
experimental: { ...c.experimental, serverSourceMaps: true },
|
|
81
|
+
compiler: {
|
|
82
|
+
...c.compiler,
|
|
83
|
+
async runAfterProductionCompile(metadata) {
|
|
84
|
+
await c.compiler?.runAfterProductionCompile?.(metadata);
|
|
85
|
+
await includeTracedMaps(metadata.distDir);
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
outputFileTracingIncludes: {
|
|
89
|
+
...includes,
|
|
90
|
+
"/**": [...(includes["/**"] ?? []), `./${distDir}/server/**/*.map`],
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@specific.dev/spectest",
|
|
3
|
-
"version": "0.119.
|
|
3
|
+
"version": "0.119.6",
|
|
4
4
|
"description": "Spectest SDK for defining test environments in TypeScript.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
"bun": "./src/coverage.ts",
|
|
22
22
|
"default": "./dist/coverage.js"
|
|
23
23
|
},
|
|
24
|
+
"./next": {
|
|
25
|
+
"types": "./dist/next.d.ts",
|
|
26
|
+
"bun": "./src/next.ts",
|
|
27
|
+
"default": "./dist/next.js"
|
|
28
|
+
},
|
|
24
29
|
"./browser": {
|
|
25
30
|
"types": "./dist/browser.d.ts",
|
|
26
31
|
"bun": "./src/browser.ts",
|