@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,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
+ }
@@ -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
- /** Repository-relative root of this service's build/bundler workspace.
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 && (adapters !== undefined || reports !== undefined)) {
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
- if (!ok) {
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
@@ -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({ name: r.name, content: renderLcovCounts(subtractLcovCounts(now, prev, restarted)) });
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,80 @@
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
+ async function walk(dir) {
8
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
9
+ const file = path.join(dir, entry.name);
10
+ if (entry.isDirectory() && entry.name !== "cache" && entry.name !== "standalone")
11
+ await walk(file);
12
+ else if (entry.isFile() && entry.name.endsWith(".nft.json")) {
13
+ const trace = JSON.parse(await fs.readFile(file, "utf8"));
14
+ if (!Array.isArray(trace.files))
15
+ throw new Error(`Invalid Next trace: ${file}`);
16
+ const scripts = [entry.name.slice(0, -".nft.json".length), ...trace.files];
17
+ const maps = [];
18
+ for (const script of scripts) {
19
+ if (typeof script !== "string" || !script.endsWith(".js"))
20
+ continue;
21
+ const map = script + ".map";
22
+ try {
23
+ if ((await fs.stat(path.resolve(dir, map))).isFile())
24
+ maps.push(map);
25
+ }
26
+ catch (e) {
27
+ if (e.code !== "ENOENT")
28
+ throw e;
29
+ }
30
+ }
31
+ trace.files = [...new Set([...trace.files, ...maps])];
32
+ await fs.writeFile(file, JSON.stringify(trace));
33
+ }
34
+ }
35
+ }
36
+ await walk(distDir);
37
+ }
38
+ /** Build-time companion for coverage.next(). Import this lightweight module in
39
+ * next.config; it does not load the Spectest harness or require Next at runtime.
40
+ * Apply only in the test build if production should not expose source maps. */
41
+ export function withCoverage(config) {
42
+ const c = config;
43
+ const distDir = c.distDir ?? ".next";
44
+ const includes = c.outputFileTracingIncludes ?? {};
45
+ return {
46
+ ...config,
47
+ productionBrowserSourceMaps: true,
48
+ turbopack: c.turbopack ?? {},
49
+ webpack(webpackConfig, context) {
50
+ const configured = c.webpack ? c.webpack(webpackConfig, context) : webpackConfig;
51
+ configured.plugins ??= [];
52
+ configured.plugins.push({
53
+ apply(compiler) {
54
+ compiler.hooks.compilation.tap("SpectestCoverage", (compilation) => {
55
+ compiler.webpack.NormalModule.getCompilationHooks(compilation).loader.tap("SpectestCoverage", (loader, module) => {
56
+ // Keep dependencies identifiable as dependencies. Chaining a
57
+ // package's published maps can replace node_modules provenance
58
+ // with ambiguous paths such as ../../src/client/router.ts.
59
+ if (/[\\/]node_modules[\\/]/.test(module.resource ?? ""))
60
+ loader.sourceMap = false;
61
+ });
62
+ });
63
+ },
64
+ });
65
+ return configured;
66
+ },
67
+ experimental: { ...c.experimental, serverSourceMaps: true },
68
+ compiler: {
69
+ ...c.compiler,
70
+ async runAfterProductionCompile(metadata) {
71
+ await c.compiler?.runAfterProductionCompile?.(metadata);
72
+ await includeTracedMaps(metadata.distDir);
73
+ },
74
+ },
75
+ outputFileTracingIncludes: {
76
+ ...includes,
77
+ "/**": [...(includes["/**"] ?? []), `./${distDir}/server/**/*.map`],
78
+ },
79
+ };
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.119.4",
3
+ "version": "0.119.5",
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",