@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,136 @@
|
|
|
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 and untraced Edge 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.mkdir(path.join(dir,"server/edge"),{recursive:true});
|
|
102
|
+
await fs.writeFile(path.join(dir,"server/edge/middleware.js.map"),"{}");
|
|
103
|
+
await fs.writeFile(path.join(dir,"next-server.js.nft.json"),JSON.stringify({version:1,files:["runtime.js"]}));
|
|
104
|
+
await fs.writeFile(path.join(dir,"page.js.map"),"{}");
|
|
105
|
+
await fs.writeFile(path.join(dir,"chunk.js.map"),"{}");
|
|
106
|
+
await fs.writeFile(path.join(dir,"page.js.nft.json"),JSON.stringify({version:1,files:["chunk.js","missing.js"]}));
|
|
107
|
+
}}});
|
|
108
|
+
await (config.compiler.runAfterProductionCompile as any)({projectDir:dir,distDir:dir});
|
|
109
|
+
expect(called).toBe(true);
|
|
110
|
+
expect(JSON.parse(await fs.readFile(path.join(dir,"next-server.js.nft.json"),"utf8")).files).toEqual(["runtime.js","server/edge/middleware.js.map"]);
|
|
111
|
+
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"]);
|
|
112
|
+
} finally {await fs.rm(dir,{recursive:true,force:true});}
|
|
113
|
+
});
|
|
114
|
+
test("runtime probe distinguishes generated launcher from authored custom server and checks maps", async () => {
|
|
115
|
+
const dir=await fs.mkdtemp(path.join(os.tmpdir(),"next-cov-"));
|
|
116
|
+
const helper=path.join(dir,"helper.cjs");
|
|
117
|
+
try {
|
|
118
|
+
await fs.writeFile(helper,NEXT_COVERAGE_HELPER);
|
|
119
|
+
await fs.writeFile(path.join(dir,"package.json"),JSON.stringify({name:"fixture"}));
|
|
120
|
+
await fs.mkdir(path.join(dir,".next/server"),{recursive:true});
|
|
121
|
+
await fs.mkdir(path.join(dir,".next/static"),{recursive:true});
|
|
122
|
+
await fs.writeFile(path.join(dir,".next/required-server-files.json"),JSON.stringify({appDir:"/build",config:{output:"standalone",turbopack:{root:"/build"}}}));
|
|
123
|
+
await fs.writeFile(path.join(dir,".next/server/a.js.map"),JSON.stringify({sources:["turbopack:///[project]/app/page.tsx"]}));
|
|
124
|
+
await fs.writeFile(path.join(dir,".next/static/a.js.map"),JSON.stringify({sources:["app/client.tsx"]}));
|
|
125
|
+
await fs.writeFile(path.join(dir,"server.js"),"// authored custom server");
|
|
126
|
+
const probe=()=>JSON.parse(execFileSync("node",[helper],{cwd:dir,env:{...process.env,NODE_OPTIONS:"",SPECTEST_NEXT_COVERAGE_OPTIONS:"{}"},stdio:["ignore","pipe","pipe"]}).toString());
|
|
127
|
+
expect(probe().generated).toBeNull();
|
|
128
|
+
await fs.writeFile(path.join(dir,"server.js"),'process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = "{}"; require("next/dist/server/lib/start-server");');
|
|
129
|
+
expect(probe().generated).toBe("server.js");
|
|
130
|
+
expect(probe().serverMaps).toBe(1);
|
|
131
|
+
expect(probe().browserMaps).toBe(1);
|
|
132
|
+
await fs.rm(path.join(dir,".next/server/a.js.map"));
|
|
133
|
+
expect(probe).toThrow();
|
|
134
|
+
} finally {await fs.rm(dir,{recursive:true,force:true});}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -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
|
+
}
|
package/src/coverage.test.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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 &&
|
|
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
|
-
|
|
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");
|