@specific.dev/spectest 0.119.4 → 0.119.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.
@@ -0,0 +1,132 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { next, NEXT_COVERAGE_HELPER, NEXT_COVERAGE_HELPER_PATH, type NextBuildMetadata } from "./coverage-next.js";
3
+ import { applyCoverageAdapters, resolveCoverageSourceOptions, attachCoverageSourceOptions, node, browser, type CoverageSourceContext } from "./coverage.js";
4
+ import { withCoverage } from "./next.js";
5
+ import { applyCoverageDelta, encodeCoverageBundle } from "./harness/coverage.js";
6
+ import { gunzipSync } from "node:zlib";
7
+ import { promises as fs } from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { execFileSync } from "node:child_process";
11
+
12
+ const metadata: NextBuildMetadata = {generated:"server.js",serverMaps:1,browserMaps:1};
13
+ const files = ["packages/dashboard/package.json", "packages/dashboard/app/page.tsx", "packages/shared/index.ts"];
14
+ const image = {type:"dockerfile" as const,context:"packages",path:"Dockerfile"};
15
+ function context(): CoverageSourceContext {
16
+ return {service:"web", signal:new AbortController().signal,
17
+ exec: async () => ({stdout:JSON.stringify(metadata),stderr:""}),
18
+ projectFiles:async()=>files, readProjectFile:async p=>p==="packages/dashboard/package.json" ? JSON.stringify({name:"fixture"}) : null};
19
+ }
20
+
21
+ describe("Next adapter", () => {
22
+ test("one adapter configures both collectors, uses its declared source root, and owns only its reports", async () => {
23
+ const adapter = next({sourceRoot:"packages"});
24
+ const configured = applyCoverageAdapters("web", {image,coverage:{adapters:[adapter]}});
25
+ expect(configured.env?.NODE_OPTIONS).toContain(`--require ${NEXT_COVERAGE_HELPER_PATH}`);
26
+ expect(configured.env?.NODE_OPTIONS).toContain("--require /spectest/coverage-hook.cjs");
27
+ expect(configured.files?.map(f=>f.path)).toEqual(["/spectest/coverage-hook.cjs",NEXT_COVERAGE_HELPER_PATH]);
28
+ let browserLoaded = false;
29
+ await adapter.load!({service:"web",collectBrowserScripts(){browserLoaded=true;}});
30
+ expect(browserLoaded).toBe(true);
31
+ const policies = await resolveCoverageSourceOptions([adapter],context());
32
+ expect(policies[0]!.options!.sourceRoot).toBe("packages");
33
+ const capture = attachCoverageSourceOptions({service:"web",reports:[
34
+ {name:"node-1.json",content:'{"result":[]}'}, {name:"browser.lcov",content:"TN:\n"},
35
+ {name:"other.lcov",content:"TN:\n"},
36
+ ]},policies);
37
+ expect(capture.reports[0]!.sourceOptions).toEqual(capture.reports[1]!.sourceOptions);
38
+ expect(capture.reports[2]!.sourceOptions).toBeUndefined();
39
+ const delta=applyCoverageDelta(capture,"cumulative",new Set());
40
+ expect(JSON.parse(gunzipSync(encodeCoverageBundle("case",[delta])).toString()).services[0].reports[1].sourceOptions.sourceRoot).toBe("packages");
41
+ });
42
+ test("sourceRoot defaults to the repository root and explicit roots are validated", async () => {
43
+ for (const sourceRoot of [null, "", "/app", "../app", "apps//web", "apps/./web", "C:\\app"]) {
44
+ expect(()=>next({sourceRoot} as any)).toThrow(/repository-relative/);
45
+ }
46
+ const ctx={...context(),image:{type:"registry" as const,reference:"web"}};
47
+ const policy=await resolveCoverageSourceOptions([next({sourceRoot:"apps/web"})],ctx);
48
+ expect(policy[0]!.options!.sourceRoot).toBe("apps/web");
49
+ for (const adapter of [next(), next({}), next({sourceRoot:undefined}), next({sourceRoot:"."})]) {
50
+ const policies = await resolveCoverageSourceOptions([adapter], context());
51
+ const capture = attachCoverageSourceOptions({reports:[{name:"node-1.json"},{name:"browser.lcov"}]}, policies);
52
+ expect(capture.reports.map(r => r.sourceOptions?.sourceRoot)).toEqual([".", "."]);
53
+ const configured = applyCoverageAdapters("web", {image, coverage:{adapters:[adapter]}});
54
+ expect(JSON.parse(configured.env!.SPECTEST_NEXT_COVERAGE_OPTIONS as string).sourceRoot).toBe(".");
55
+ }
56
+ });
57
+ test("node/browser options stay separate and duplicate report ownership is rejected", async () => {
58
+ const adapters=[node({sourceRoot:"server",excludeSources:["virtual://server/*"]}),browser({sourceRoot:"client"})];
59
+ const policies=await resolveCoverageSourceOptions(adapters,context());
60
+ const c=attachCoverageSourceOptions({reports:[{name:"node-1.json"},{name:"browser.lcov"}]},policies);
61
+ expect(c.reports[0]!.sourceOptions?.sourceRoot).toBe("server");
62
+ expect(c.reports[1]!.sourceOptions?.sourceRoot).toBe("client");
63
+ const duplicate=await resolveCoverageSourceOptions([next({sourceRoot:"packages"}),browser()],context());
64
+ expect(()=>attachCoverageSourceOptions({reports:[{name:"browser.lcov"}]},duplicate)).toThrow(/multiple adapters/);
65
+ expect(JSON.parse(JSON.stringify(applyCoverageAdapters("web",{image,coverage:{adapters}}))).coverage.adapters[1].sourceOptions.sourceRoot).toBe("client");
66
+ });
67
+ test("build helper preserves user settings and includes standalone maps", () => {
68
+ const c=withCoverage({output:"standalone",distDir:"build",experimental:{cpus:2},outputFileTracingIncludes:{"/*":["./assets/*"],"/api/*":["./data/*"]}}) as any;
69
+ expect(c.experimental).toEqual({cpus:2,serverSourceMaps:true});
70
+ expect(c.productionBrowserSourceMaps).toBe(true);
71
+ expect(c.outputFileTracingIncludes).toEqual({"/*":["./assets/*"],"/api/*":["./data/*"],"/**":["./build/server/**/*.map"]});
72
+ });
73
+ test("Webpack helper preserves hooks and disables dependency maps only", () => {
74
+ let called = false;
75
+ const config = withCoverage({ webpack(c: any) { called = true; return {...c, marker: true}; } });
76
+ const compiled = config.webpack({plugins: []});
77
+ expect(called).toBe(true);
78
+ expect(compiled.marker).toBe(true);
79
+ let loaderHook: (loader: any, module: any) => void;
80
+ compiled.plugins[0].apply({
81
+ hooks: {compilation: {tap(_name: string, f: Function) { f({}); }}},
82
+ webpack: {NormalModule: {getCompilationHooks() { return {
83
+ loader: {tap(_name: string, f: typeof loaderHook) { loaderHook = f; }},
84
+ }; }}},
85
+ });
86
+ for (const resource of ["/app/app/page.tsx", "/workspace/shared/index.ts", ""]) {
87
+ const loader = {sourceMap: true};
88
+ loaderHook!(loader, {resource});
89
+ expect(loader.sourceMap).toBe(true);
90
+ }
91
+ const dependency = {sourceMap: true};
92
+ loaderHook!(dependency, {resource: "/app/node_modules/next/dist/client/router.js"});
93
+ expect(dependency.sourceMap).toBe(false);
94
+ });
95
+ test("build hook retains traced maps and composes an existing compiler hook", async () => {
96
+ const dir=await fs.mkdtemp(path.join(os.tmpdir(),"next-trace-"));
97
+ try {
98
+ let called=false;
99
+ const config=withCoverage({compiler:{runAfterProductionCompile:async()=>{
100
+ called=true;
101
+ await fs.writeFile(path.join(dir,"page.js.map"),"{}");
102
+ await fs.writeFile(path.join(dir,"chunk.js.map"),"{}");
103
+ await fs.writeFile(path.join(dir,"page.js.nft.json"),JSON.stringify({version:1,files:["chunk.js","missing.js"]}));
104
+ }}});
105
+ await (config.compiler.runAfterProductionCompile as any)({projectDir:dir,distDir:dir});
106
+ expect(called).toBe(true);
107
+ expect(JSON.parse(await fs.readFile(path.join(dir,"page.js.nft.json"),"utf8")).files).toEqual(["chunk.js","missing.js","page.js.map","chunk.js.map"]);
108
+ } finally {await fs.rm(dir,{recursive:true,force:true});}
109
+ });
110
+ test("runtime probe distinguishes generated launcher from authored custom server and checks maps", async () => {
111
+ const dir=await fs.mkdtemp(path.join(os.tmpdir(),"next-cov-"));
112
+ const helper=path.join(dir,"helper.cjs");
113
+ try {
114
+ await fs.writeFile(helper,NEXT_COVERAGE_HELPER);
115
+ await fs.writeFile(path.join(dir,"package.json"),JSON.stringify({name:"fixture"}));
116
+ await fs.mkdir(path.join(dir,".next/server"),{recursive:true});
117
+ await fs.mkdir(path.join(dir,".next/static"),{recursive:true});
118
+ await fs.writeFile(path.join(dir,".next/required-server-files.json"),JSON.stringify({appDir:"/build",config:{output:"standalone",turbopack:{root:"/build"}}}));
119
+ await fs.writeFile(path.join(dir,".next/server/a.js.map"),JSON.stringify({sources:["turbopack:///[project]/app/page.tsx"]}));
120
+ await fs.writeFile(path.join(dir,".next/static/a.js.map"),JSON.stringify({sources:["app/client.tsx"]}));
121
+ await fs.writeFile(path.join(dir,"server.js"),"// authored custom server");
122
+ const probe=()=>JSON.parse(execFileSync("node",[helper],{cwd:dir,env:{...process.env,NODE_OPTIONS:"",SPECTEST_NEXT_COVERAGE_OPTIONS:"{}"},stdio:["ignore","pipe","pipe"]}).toString());
123
+ expect(probe().generated).toBeNull();
124
+ await fs.writeFile(path.join(dir,"server.js"),'process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = "{}"; require("next/dist/server/lib/start-server");');
125
+ expect(probe().generated).toBe("server.js");
126
+ expect(probe().serverMaps).toBe(1);
127
+ expect(probe().browserMaps).toBe(1);
128
+ await fs.rm(path.join(dir,".next/server/a.js.map"));
129
+ expect(probe).toThrow();
130
+ } finally {await fs.rm(dir,{recursive:true,force:true});}
131
+ });
132
+ });
@@ -0,0 +1,125 @@
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, type CoverageAdapter, type CoverageSourceContext, type CoverageSourceOptions } from "./coverage.js";
5
+
6
+ export interface NextCoverageOptions extends CoverageSourceOptions {
7
+ /** Repository-relative directory represented by the build's source-map root.
8
+ * Defaults to "." (repository root). Set explicitly for a nested build root. */
9
+ sourceRoot?: string;
10
+ /** Runtime app directory, relative to the container working directory. */
11
+ appDir?: string;
12
+ /** Next's build output directory, relative to appDir. Default: .next. */
13
+ distDir?: string;
14
+ }
15
+
16
+ export const NEXT_COVERAGE_HELPER_PATH = "/spectest/next-coverage.cjs";
17
+
18
+ // Also preloaded before the Node hook. Only an identified standalone launcher
19
+ // is generated: an authored server.js for a custom server remains application code.
20
+ export const NEXT_COVERAGE_HELPER = String.raw`"use strict";
21
+ const fs = require("node:fs"), path = require("node:path");
22
+ const options = JSON.parse(process.env.SPECTEST_NEXT_COVERAGE_OPTIONS || "{}");
23
+ const app = path.resolve(options.appDir || ".");
24
+ const dist = path.resolve(app, options.distDir || ".next");
25
+ function manifest() { return JSON.parse(fs.readFileSync(path.join(dist, "required-server-files.json"), "utf8")); }
26
+ function launcher(m) {
27
+ const file = path.join(app, "server.js");
28
+ if (m.config.output !== "standalone" || !fs.existsSync(file)) return null;
29
+ const text = fs.readFileSync(file, "utf8");
30
+ if (!text.includes("__NEXT_PRIVATE_STANDALONE_CONFIG") || !text.includes("next/dist/server/lib/start-server")) return null;
31
+ return path.relative(process.cwd(), file);
32
+ }
33
+ try {
34
+ const file = launcher(manifest());
35
+ if (file) process.env.SPECTEST_COVERAGE_GENERATED_FILES = JSON.stringify([
36
+ ...new Set([...JSON.parse(process.env.SPECTEST_COVERAGE_GENERATED_FILES || "[]"), file])
37
+ ]);
38
+ } catch {} // Capture reports a useful error; do not change application startup.
39
+ if (require.main === module) {
40
+ try {
41
+ const m = manifest();
42
+ let serverMaps = 0, browserMaps = 0;
43
+ function walk(dir, server) {
44
+ if (!fs.existsSync(dir)) return;
45
+ for (const e of fs.readdirSync(dir, {withFileTypes:true})) {
46
+ const file = path.join(dir, e.name);
47
+ if (e.isDirectory()) walk(file, server);
48
+ else if (e.name.endsWith(".js.map")) {
49
+ if (server) serverMaps++; else browserMaps++;
50
+ }
51
+ }
52
+ }
53
+ walk(path.join(dist, "server"), true);
54
+ walk(path.join(dist, "static"), false);
55
+ 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.");
56
+ process.stdout.write(JSON.stringify({generated:launcher(m), serverMaps, browserMaps}));
57
+ } catch (e) { console.error("Next coverage: " + e.message); process.exitCode = 1; }
58
+ }
59
+ `;
60
+
61
+ export interface NextBuildMetadata {
62
+ generated: string | null;
63
+ serverMaps: number;
64
+ browserMaps: number;
65
+ }
66
+
67
+ const GENERATED_SOURCES = [
68
+ "webpack://*/", // Loader-only modules with an empty source identity.
69
+ "webpack://*/?*", // Loader-only modules without a resource path.
70
+ "webpack://*/external *", // Webpack external-module wrappers.
71
+ "*/data:* <text/javascript>", // Turbopack virtual modules, including action proxies.
72
+ "*/.next-internal/server/app/*/actions.js (server actions loader)",
73
+ "*/app/*--route-entry.js",
74
+ "*/app/*.mjs (structured image object)",
75
+ ];
76
+
77
+ /** Server + browser coverage for a built Next app. Build-time maps are required;
78
+ * use withCoverage from @specific.dev/spectest/next in the Next configuration. */
79
+ export function next(options: NextCoverageOptions = {}): CoverageAdapter {
80
+ const sourceRoot = options.sourceRoot === undefined ? "." : options.sourceRoot;
81
+ if (typeof sourceRoot !== "string" || !sourceRoot || /[\\:\0\r\n]/.test(sourceRoot) || (sourceRoot !== "." && sourceRoot.split("/").some(p => !p || p === "." || p === ".."))) throw new Error("coverage.next sourceRoot must be repository-relative (use \".\" for the repository root)");
82
+ options = { ...options, sourceRoot };
83
+ for (const [key, value] of Object.entries({ appDir: options.appDir, distDir: options.distDir })) {
84
+ if (value !== undefined && (!value || path.posix.isAbsolute(value) || value.split("/").some(p => p === "..") || /[\\\0\r\n]/.test(value))) throw new Error(`coverage.next ${key} must be a relative runtime directory`);
85
+ }
86
+ const server = node(), client = browser();
87
+ const cache = new Map<string, Promise<NextBuildMetadata>>();
88
+ const metadata = (ctx: Pick<CoverageSourceContext, "service" | "exec">) => {
89
+ let value = cache.get(ctx.service);
90
+ if (!value) {
91
+ value = ctx.exec(`NODE_OPTIONS= node ${NEXT_COVERAGE_HELPER_PATH}`).then(r => JSON.parse(r.stdout) as NextBuildMetadata);
92
+ cache.set(ctx.service, value);
93
+ value.catch(() => cache.delete(ctx.service));
94
+ }
95
+ return value;
96
+ };
97
+ return {
98
+ name: "next", reports: "delta", explains: server.explains,
99
+ ownsReport: name => server.ownsReport!(name) || client.ownsReport!(name),
100
+ configure(svc, info) {
101
+ const configured = server.configure!(svc, info);
102
+ return { ...configured,
103
+ env: { ...configured.env,
104
+ NODE_OPTIONS: `--require ${NEXT_COVERAGE_HELPER_PATH} ${configured.env!.NODE_OPTIONS}`,
105
+ SPECTEST_NEXT_COVERAGE_OPTIONS: JSON.stringify(options),
106
+ SPECTEST_COVERAGE_SERVICE: info.key,
107
+ },
108
+ files: [...configured.files!, { path: NEXT_COVERAGE_HELPER_PATH, content: NEXT_COVERAGE_HELPER }],
109
+ };
110
+ },
111
+ load: client.load,
112
+ async sourceOptions(ctx) {
113
+ await metadata(ctx);
114
+ return { sourceRoot, excludeSources: [...GENERATED_SOURCES, ...(options.excludeSources ?? [])] };
115
+ },
116
+ async inventory(ctx) {
117
+ const m = await metadata(ctx);
118
+ return node({ generated: m.generated ? [m.generated] : [] }).inventory!(ctx);
119
+ },
120
+ async capture(ctx) {
121
+ await server.capture!(ctx);
122
+ await client.capture!(ctx);
123
+ },
124
+ };
125
+ }
@@ -59,6 +59,20 @@ describe("validateCoverage", () => {
59
59
  expect(() => validateCoverage("web", { adapters: [node()], sourceRoot })).toThrow();
60
60
  }
61
61
  });
62
+ test("source exclusions survive adapter configuration and wire serialization", () => {
63
+ const excludeSources = ["virtual:///compiler/*", "*/generated/[route]/loader.js"];
64
+ validateCoverage("web", { excludeSources });
65
+ const out = applyCoverageAdapters("web", { ...base, coverage: { adapters: [node(), browser()], excludeSources } });
66
+ expect(JSON.parse(JSON.stringify(out)).coverage.excludeSources).toEqual(excludeSources);
67
+ const configured = applyCoverageAdapters("web", { ...base, coverage: { adapters: [{
68
+ name: "compiler",
69
+ configure: service => ({ ...service, coverage: { ...service.coverage as object, excludeSources } }),
70
+ }] } });
71
+ expect(JSON.parse(JSON.stringify(configured)).coverage.excludeSources).toEqual(excludeSources);
72
+ for (const invalid of [null, "*", [1], [""], [" "], ["bad\npath"], ["bad\0path"]]) {
73
+ expect(() => validateCoverage("web", { excludeSources: invalid })).toThrow();
74
+ }
75
+ });
62
76
  test("reports mode: cumulative unless declared; the shipped adapters write deltas", () => {
63
77
  expect(coverageReportsMode(undefined)).toBe("cumulative");
64
78
  expect(coverageReportsMode(true)).toBe("cumulative");
@@ -440,13 +454,14 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
440
454
  const dir = await scratch();
441
455
  await fs.writeFile(path.join(dir, "main.js"), "globalThis.value = 1;\n");
442
456
  const leaf = { version: 3, sources: ["turbopack:///[project]/dashboard/page.ts", "webpack:///node_modules/pkg/a.js"],
443
- sourcesContent: ["secret application source", "dependency source"], mappings: "AAAA,CCAA" };
457
+ ignoreList: [1], sourcesContent: ["secret application source", "dependency source"], mappings: "AAAA,CCAA" };
444
458
  await fs.writeFile(path.join(dir, "main.js.map"), JSON.stringify({ version: 3, sections: [
445
459
  { offset: { line: 0, column: 0 }, map: { version: 3, sections: [{ offset: { line: 0, column: 0 }, map: leaf }] } }
446
460
  ] }));
447
461
  expect(await runOnce(dir, "main.js")).toBe(0);
448
462
  const rec = (await records(dir)).get(`file://${path.join(dir, "main.js")}`)!;
449
463
  const text = JSON.stringify(rec.sourceMap);
464
+ expect(text).toContain('"ignoreList":[1]');
450
465
  expect(text).not.toContain("sourcesContent");
451
466
  expect(text).not.toContain("secret application source");
452
467
  expect(text).toContain('"x_spectestSourceUnits":[null,"npm:pkg"]');
package/src/coverage.ts CHANGED
@@ -34,6 +34,7 @@ import { harvestAllBrowserCoverage, takeBrowserCoverageReport } from "./browser-
34
34
 
35
35
  import { COVERAGE_CONTAINER_DIR, type CoverageReportsMode } from "./harness/coverage.js";
36
36
  export { COVERAGE_CONTAINER_DIR, type CoverageReportsMode };
37
+ export { next, type NextCoverageOptions } from "./coverage-next.js";
37
38
 
38
39
  /** Where `node()` mounts its hook inside the container. */
39
40
  export const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
@@ -110,9 +111,23 @@ export interface CoverageCaptureContext {
110
111
  signal: AbortSignal;
111
112
  }
112
113
 
114
+ /** Source interpretation belongs to the adapter that produced each report. */
115
+ export interface CoverageSourceOptions {
116
+ /** Repository-relative root corresponding to the report's source workspace. */
117
+ sourceRoot?: string;
118
+ /** Whole source identities; only `*` is special, including `/`. */
119
+ excludeSources?: readonly string[];
120
+ }
121
+
122
+ export type CoverageSourceContext = CoverageInventoryContext;
123
+
113
124
  export interface CoverageAdapter {
114
125
  /** Names the adapter in errors (`coverage adapter "node" on service …`). */
115
126
  name: string;
127
+ /** Archived on this adapter's reports; other adapters retain their own policy. */
128
+ sourceOptions?: CoverageSourceOptions | ((ctx: CoverageSourceContext) => Promise<CoverageSourceOptions>);
129
+ /** Required with sourceOptions, including reports written directly by a runtime. */
130
+ ownsReport?(name: string): boolean;
116
131
  /**
117
132
  * How the reports this adapter writes (through `ctx.writeReport`)
118
133
  * count: `"delta"` — what ran since the previous capture, the tool
@@ -167,10 +182,17 @@ export type ServiceCoverage =
167
182
  | {
168
183
  adapters?: readonly CoverageAdapter[];
169
184
  reports?: CoverageReportsMode;
170
- /** Repository-relative root of this service's build/bundler workspace.
185
+ /** @deprecated Prefer adapter-owned sourceRoot.
186
+ * Repository-relative root of this service's build/bundler workspace.
171
187
  * For a monorepo built from packages/, set sourceRoot: "packages".
172
188
  * Paths are checked against the repository; no suffix guessing. */
173
189
  sourceRoot?: string;
190
+ /** @deprecated Prefer adapter-owned excludeSources.
191
+ * Reported source identities to omit from file coverage. `*` matches
192
+ * any characters, including `/`; everything else is literal. Use only
193
+ * for known generated/dependency sources, never missing application maps.
194
+ * Package unit coverage is retained. These rules are archived per capture. */
195
+ excludeSources?: readonly string[];
174
196
  };
175
197
 
176
198
  /** The `reports` mode of a `coverage` value, for the reports no adapter
@@ -192,10 +214,13 @@ export function validateCoverage(service: string, cov: unknown): void {
192
214
  const adapters = obj?.adapters;
193
215
  const reports = obj?.reports;
194
216
  const sourceRoot = obj?.sourceRoot;
217
+ const excludeSources = obj?.excludeSources;
195
218
  // `explains` is what `applyCoverageAdapters` adds for the wire; the
196
219
  // config is validated again after that, so it must pass here.
197
220
  const explains = obj?.explains;
198
- const knownKeys = obj !== null && Object.keys(obj).every((k) => k === "adapters" || k === "reports" || k === "explains" || k === "sourceRoot");
221
+ const knownKeys = obj !== null && Object.keys(obj).every((k) => k === "adapters" || k === "reports" || k === "explains" || k === "sourceRoot" || k === "excludeSources");
222
+ const okExcludes = excludeSources === undefined || (Array.isArray(excludeSources) &&
223
+ excludeSources.every(p => typeof p === "string" && p.trim().length > 0 && !/[\0\r\n]/.test(p)));
199
224
  const okRoot = sourceRoot === undefined || (typeof sourceRoot === "string" &&
200
225
  (sourceRoot === "." || (sourceRoot.length > 0 && !sourceRoot.includes("\\") &&
201
226
  !sourceRoot.includes(":") && !sourceRoot.includes("\0") &&
@@ -203,7 +228,8 @@ export function validateCoverage(service: string, cov: unknown): void {
203
228
  const okReports = reports === undefined || reports === "delta" || reports === "cumulative";
204
229
  const okAdapters = adapters === undefined || Array.isArray(adapters);
205
230
  const okExplains = explains === undefined || (Array.isArray(explains) && explains.every((e) => typeof e === "string"));
206
- if (obj !== null && knownKeys && okRoot && okReports && okAdapters && okExplains && (adapters !== undefined || reports !== undefined)) {
231
+ if (obj !== null && knownKeys && okRoot && okExcludes && okReports && okAdapters && okExplains &&
232
+ (adapters !== undefined || reports !== undefined || excludeSources !== undefined)) {
207
233
  for (const a of (adapters as unknown[] | undefined) ?? []) {
208
234
  const ok =
209
235
  typeof a === "object" &&
@@ -216,16 +242,20 @@ export function validateCoverage(service: string, cov: unknown): void {
216
242
  ((a as CoverageAdapter).explains === undefined ||
217
243
  (Array.isArray((a as CoverageAdapter).explains) &&
218
244
  ((a as CoverageAdapter).explains as unknown[]).every((e) => typeof e === "string"))) &&
219
- ["configure", "load", "capture", "inventory"].every(
245
+ ["configure", "load", "capture", "inventory", "ownsReport"].every(
220
246
  (k) =>
221
247
  (a as Record<string, unknown>)[k] === undefined ||
222
248
  typeof (a as Record<string, unknown>)[k] === "function",
223
249
  );
224
- if (!ok) {
250
+ const sourceOptions = (a as CoverageAdapter | null)?.sourceOptions;
251
+ const sourceShape = sourceOptions === undefined || (typeof (a as CoverageAdapter).ownsReport === "function" &&
252
+ (typeof sourceOptions === "function" || (typeof sourceOptions === "object" && sourceOptions !== null && !Array.isArray(sourceOptions))));
253
+ if (!ok || !sourceShape) {
225
254
  throw new Error(
226
255
  `service "${service}" has an invalid coverage adapter — an adapter is \`{ name, reports?, explains?, configure?, load?, capture?, inventory? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`,
227
256
  );
228
257
  }
258
+ if (sourceOptions !== undefined && typeof sourceOptions !== "function") validateCoverageSourceOptions(service, sourceOptions);
229
259
  }
230
260
  return;
231
261
  }
@@ -259,8 +289,10 @@ export function applyCoverageAdapters<S extends ServiceConfig>(key: string, svc:
259
289
  // read it without knowing any adapter.
260
290
  const explains = [...new Set(adapters.flatMap((a) => a.explains ?? []))];
261
291
  const sourceRoot = typeof svc.coverage === "object" ? svc.coverage.sourceRoot : undefined;
292
+ const excludeSources = typeof out.coverage === "object" ? out.coverage.excludeSources : undefined;
262
293
  const wire = {
263
294
  ...(sourceRoot !== undefined ? { sourceRoot } : {}),
295
+ ...(excludeSources !== undefined ? { excludeSources } : {}),
264
296
  adapters: adapters.map((a) => withWireName(a)),
265
297
  ...(mode ? { reports: mode } : {}),
266
298
  ...(explains.length > 0 ? { explains } : {}),
@@ -268,10 +300,40 @@ export function applyCoverageAdapters<S extends ServiceConfig>(key: string, svc:
268
300
  return { ...out, coverage: wire } as unknown as S;
269
301
  }
270
302
 
303
+ function validateCoverageSourceOptions(service: string, options: unknown): asserts options is CoverageSourceOptions {
304
+ if (!options || typeof options !== "object" || Array.isArray(options) ||
305
+ !Object.keys(options).every(k => k === "sourceRoot" || k === "excludeSources")) {
306
+ throw new Error(`service "${service}" has invalid adapter sourceOptions`);
307
+ }
308
+ validateCoverage(service, { adapters: [], ...options });
309
+ }
310
+
311
+ /** Resolve adapter policies once per capture. Invalid/overlapping ownership is
312
+ * an error, so one adapter cannot silently reinterpret another's reports. */
313
+ export async function resolveCoverageSourceOptions(adapters: readonly CoverageAdapter[], ctx: CoverageSourceContext) {
314
+ return Promise.all(adapters.filter(a => a.sourceOptions !== undefined || a.ownsReport).map(async a => {
315
+ if (!a.ownsReport) throw new Error(`coverage adapter "${a.name}" needs ownsReport with sourceOptions`);
316
+ const options = typeof a.sourceOptions === "function" ? await a.sourceOptions(ctx) : a.sourceOptions;
317
+ if (a.sourceOptions !== undefined) validateCoverageSourceOptions(ctx.service, options);
318
+ return { adapter: a.name, ownsReport: a.ownsReport, options };
319
+ }));
320
+ }
321
+
322
+ export function attachCoverageSourceOptions<T extends { reports: { name: string; sourceOptions?: CoverageSourceOptions }[] }>(
323
+ capture: T, policies: Awaited<ReturnType<typeof resolveCoverageSourceOptions>>,
324
+ ): T {
325
+ for (const report of capture.reports) {
326
+ const matches = policies.filter(p => p.ownsReport(report.name));
327
+ if (matches.length > 1) throw new Error(`coverage report "${report.name}" belongs to multiple adapters: ${matches.map(p => p.adapter).join(", ")}`);
328
+ if (matches.length === 1) report.sourceOptions = matches[0]!.options;
329
+ }
330
+ return capture;
331
+ }
332
+
271
333
  function withWireName(a: CoverageAdapter): CoverageAdapter {
272
334
  if (Object.prototype.hasOwnProperty.call(a, "toJSON")) return a;
273
335
  return Object.assign(Object.create(Object.getPrototypeOf(a) as object | null), a, {
274
- toJSON: () => ({ adapter: a.name }),
336
+ toJSON: () => ({ adapter: a.name, ...(a.sourceOptions && typeof a.sourceOptions !== "function" ? { sourceOptions: a.sourceOptions } : {}) }),
275
337
  }) as CoverageAdapter;
276
338
  }
277
339
 
@@ -829,7 +891,7 @@ for (;;) {
829
891
  process.stdout.write(Array.from(out).sort().join("\\n") + (out.size ? "\\n" : ""));
830
892
  `;
831
893
 
832
- export interface NodeCoverageOptions {
894
+ export interface NodeCoverageOptions extends CoverageSourceOptions {
833
895
  /** Generated runtime entrypoints, relative to the service working directory.
834
896
  * Track their exact contents as units rather than inventing repository lines.
835
897
  * A content change changes the inventory and invalidates dependent tests. */
@@ -847,6 +909,8 @@ export function node(options: NodeCoverageOptions = {}): CoverageAdapter {
847
909
  }
848
910
  return {
849
911
  name: "node",
912
+ ...(options.sourceRoot !== undefined || options.excludeSources !== undefined ? { sourceOptions: { sourceRoot: options.sourceRoot, excludeSources: options.excludeSources } } : {}),
913
+ ownsReport: name => isNodeDumpName(name) || name === NODE_COVERAGE_EMPTY_REPORT,
850
914
  reports: "delta",
851
915
  explains: NODE_EXPLAINS,
852
916
  async inventory(ctx) {
@@ -1054,9 +1118,11 @@ export async function collectNodeReports(dir: string): Promise<NodeReportStats>
1054
1118
  * harvested first, and only this service's totals are taken and cleared. Nothing ran ⇒
1055
1119
  * an lcov with no records, which is a report saying so.
1056
1120
  */
1057
- export function browser(): CoverageAdapter {
1121
+ export function browser(options: CoverageSourceOptions = {}): CoverageAdapter {
1058
1122
  return {
1059
1123
  name: "browser",
1124
+ ...(options.sourceRoot !== undefined || options.excludeSources !== undefined ? { sourceOptions: options } : {}),
1125
+ ownsReport: name => name === "browser.lcov",
1060
1126
  reports: "delta",
1061
1127
  load(ctx) {
1062
1128
  ctx.collectBrowserScripts();
package/src/daemon.ts CHANGED
@@ -69,6 +69,8 @@ import {
69
69
  NODE_COVERAGE_SCRIPTS_SUBDIR,
70
70
  applyCoverageAdapters,
71
71
  coverageAdapters,
72
+ resolveCoverageSourceOptions,
73
+ attachCoverageSourceOptions,
72
74
  coverageReportsMode,
73
75
  validateCoverage,
74
76
  type CoverageAdapter,
@@ -4044,6 +4046,19 @@ async function captureServiceCoverage(baseline = false): Promise<ServiceCoverage
4044
4046
  const environment = captureEnvironmentCoverage(all, baseline);
4045
4047
  const captures = await Promise.all(
4046
4048
  services.map(async (svc): Promise<ServiceCoverageCapture> => {
4049
+ let policies: Awaited<ReturnType<typeof resolveCoverageSourceOptions>>;
4050
+ try {
4051
+ policies = await resolveCoverageSourceOptions(coverageAdapters(svc.coverage), {
4052
+ ...coverageCaptureContext(svc, AbortSignal.timeout(COVERAGE_CAPTURE_TIMEOUT_MS), new Set()),
4053
+ projectFiles: () => walkProjectFiles(WORKSPACE),
4054
+ async readProjectFile(p) {
4055
+ try { return await fs.readFile(resolveProjectPath(p, { record: false }), "utf8"); }
4056
+ catch { return null; }
4057
+ },
4058
+ });
4059
+ } catch (err) {
4060
+ return { service: svc.name, reports: [], error: `coverage source metadata: ${err instanceof Error ? err.message : String(err)}` };
4061
+ }
4047
4062
  const { error, deltaReports } = await runCoverageAdapters(svc);
4048
4063
  if (error) return { service: svc.name, reports: [], error };
4049
4064
  if (baseline) {
@@ -4054,11 +4069,16 @@ async function captureServiceCoverage(baseline = false): Promise<ServiceCoverage
4054
4069
  if (baseline) {
4055
4070
  await fs.rm(path.join(coverageHostDir(WORKSPACE, svc.name), INVENTORY_REPORT), { force: true }).catch(() => {});
4056
4071
  }
4072
+ try { attachCoverageSourceOptions(read, policies); }
4073
+ catch (err) { read.error = `coverage source metadata: ${err instanceof Error ? err.message : String(err)}`; }
4057
4074
  const root = await coverageRoot(svc.name);
4058
4075
  if (root) read.root = root;
4059
4076
  if (typeof svc.coverage === "object" && svc.coverage.sourceRoot !== undefined) {
4060
4077
  read.sourceRoot = svc.coverage.sourceRoot;
4061
4078
  }
4079
+ if (typeof svc.coverage === "object" && svc.coverage.excludeSources !== undefined) {
4080
+ read.excludeSources = [...svc.coverage.excludeSources];
4081
+ }
4062
4082
  // What ships is what ran during this capture, whatever the tool
4063
4083
  // counts: cumulative lcov is subtracted against the previous
4064
4084
  // capture (module memory, forks with the env); a delta adapter's
@@ -48,6 +48,23 @@ describe("source maps", () => {
48
48
  expect(files.get("src/b.ts")!.get(5)).toBe(0);
49
49
  });
50
50
 
51
+ test("ignore metadata is local to each map section and standard metadata wins", () => {
52
+ const child = { version: 3, sources: ["src/a.ts", "virtual:///compiler/proxy"], mappings: "AAAA;ACAA", ignoreList: [1] };
53
+ const flat = decodeSourceMap(child)!;
54
+ expect([...mapToOriginal(flat, new Map([[1, 2], [2, 3]])).keys()]).toEqual(["src/a.ts"]);
55
+ const indexed = decodeSourceMap({ version: 3, sections: [
56
+ { offset: { line: 0, column: 0 }, map: child },
57
+ { offset: { line: 2, column: 0 }, map: { ...child, sources: ["virtual:///other", "src/b.ts"], ignoreList: [0] } },
58
+ ] })!;
59
+ expect([...mapToOriginal(indexed, new Map([[1, 2], [2, 3], [3, 4], [4, 5]])).keys()]).toEqual(["src/a.ts", "src/b.ts"]);
60
+ const legacy = { ...child, ignoreList: undefined, x_google_ignoreList: [1] };
61
+ expect([...mapToOriginal(decodeSourceMap(legacy)!, new Map([[2, 1]])).keys()]).toEqual([]);
62
+ expect([...mapToOriginal(decodeSourceMap({ ...legacy, ignoreList: [] })!, new Map([[2, 1]])).keys()]).toEqual(["virtual:///compiler/proxy"]);
63
+ for (const ignoreList of [null, [-1], [2], [0.5], ["1"]]) {
64
+ expect(decodeSourceMap({ ...child, ignoreList })).toBeNull();
65
+ }
66
+ });
67
+
51
68
  test("empty index maps are generated code; malformed maps remain unresolved", () => {
52
69
  expect(decodeSourceMap({ version: 3, sections: [] })).toEqual({ sources: [], lines: [] });
53
70
  expect(decodeSourceMap({ version: 3, sources: ["a"], mappings: "!!" })).toBeNull();
@@ -91,6 +91,8 @@ export interface DecodedSourceMap {
91
91
  sources: string[];
92
92
  /** Segments per generated line (0-based), sorted by column. */
93
93
  lines: Segment[][];
94
+ /** Compiler-declared third-party/generated source indices. */
95
+ ignored?: Set<number>;
94
96
  }
95
97
 
96
98
  const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
@@ -195,6 +197,8 @@ export function decodeSourceMap(doc: unknown, depth = 0): DecodedSourceMap | nul
195
197
  sourceRoot?: unknown;
196
198
  mappings?: unknown;
197
199
  sections?: unknown;
200
+ ignoreList?: unknown;
201
+ x_google_ignoreList?: unknown;
198
202
  };
199
203
  if (m.sections !== undefined) {
200
204
  if (!Array.isArray(m.sections) || m.mappings !== undefined) return null;
@@ -212,6 +216,7 @@ export function decodeSourceMap(doc: unknown, depth = 0): DecodedSourceMap | nul
212
216
  i === line && segments.some(s => s.col >= column))) return null;
213
217
  const base = out.sources.length;
214
218
  for (const source of child.sources) out.sources.push(source);
219
+ for (const index of child.ignored ?? []) (out.ignored ??= new Set()).add(base + index);
215
220
  for (let i = 0; i < child.lines.length; i++) {
216
221
  const target = line + i;
217
222
  if (target > 1_000_000) return null;
@@ -230,6 +235,9 @@ export function decodeSourceMap(doc: unknown, depth = 0): DecodedSourceMap | nul
230
235
  const sources = m.sources.map((s) =>
231
236
  typeof s === "string" ? normalizeSourcePath(s, sourceRoot) : "",
232
237
  );
238
+ const ignored = m.ignoreList !== undefined ? m.ignoreList : m.x_google_ignoreList;
239
+ if (ignored !== undefined && (!Array.isArray(ignored) || ignored.some(i =>
240
+ !Number.isSafeInteger(i) || i < 0 || i >= sources.length))) return null;
233
241
  const lines: Segment[][] = [];
234
242
  let src = 0;
235
243
  let line = 0;
@@ -257,7 +265,7 @@ export function decodeSourceMap(doc: unknown, depth = 0): DecodedSourceMap | nul
257
265
  } catch {
258
266
  return null;
259
267
  }
260
- return { sources, lines };
268
+ return { sources, lines, ...(ignored !== undefined ? { ignored: new Set(ignored as number[]) } : {}) };
261
269
  }
262
270
 
263
271
  /**
@@ -273,7 +281,7 @@ export function mapToOriginal(map: DecodedSourceMap, generated: LineCounts): Fil
273
281
  if (!segs || segs.length === 0) continue;
274
282
  for (const seg of segs) {
275
283
  const file = map.sources[seg.src];
276
- if (file === undefined || isForeignSource(file)) continue;
284
+ if (file === undefined || map.ignored?.has(seg.src) || isForeignSource(file)) continue;
277
285
  let lc = out.get(file);
278
286
  if (!lc) {
279
287
  lc = new Map();
@@ -114,7 +114,7 @@ describe("readCoverageDir", () => {
114
114
 
115
115
  test("bundle round-trips and the ref summarises it", () => {
116
116
  const services = [
117
- { service: "web", reports: [{ name: "lcov.info", content: "SF:/app/a.js\n" }] },
117
+ { service: "web", excludeSources: ["virtual:///compiler/*"], reports: [{ name: "lcov.info", content: "SF:/app/a.js\n" }] },
118
118
  { service: "api", reports: [], error: "no report" },
119
119
  ];
120
120
  const gz = encodeCoverageBundle("case-1", services);
@@ -193,10 +193,11 @@ describe("coverage delta", () => {
193
193
  test("applyCoverageDelta: cumulative reports are subtracted per service and name; delta and V8 reports pass", () => {
194
194
  resetCoverageDeltaMemory();
195
195
  const first = applyCoverageDelta(
196
- { service: "api", reports: [{ name: "cov.lcov", content: boot }, { name: "node.lcov", content: later }, { name: "coverage-1.json", content: '{"result":[]}' }] },
196
+ { service: "api", excludeSources: ["virtual:///compiler/*"], reports: [{ name: "cov.lcov", content: boot }, { name: "node.lcov", content: later }, { name: "coverage-1.json", content: '{"result":[]}' }] },
197
197
  "cumulative",
198
198
  new Set(["node.lcov"]),
199
199
  );
200
+ expect(first.excludeSources).toEqual(["virtual:///compiler/*"]);
200
201
  // First capture: no previous, the whole boot set.
201
202
  expect(first.reports[0]!.content).toContain("SF:/app/a.py");
202
203
  expect(first.reports[0]!.content).toContain("SF:/app/b.py");