@specific.dev/spectest 0.131.15 → 0.131.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/dist/browser-coverage.js +24 -6
- package/dist/coverage-next.js +56 -4
- package/dist/coverage.js +7 -1
- package/package.json +1 -1
- package/src/browser-coverage-deadline.test.ts +4 -1
- package/src/browser-coverage-identity.test.ts +87 -0
- package/src/browser-coverage-navigation.test.ts +54 -0
- package/src/browser-coverage.ts +21 -5
- package/src/coverage-next.test.ts +164 -2
- package/src/coverage-next.ts +55 -3
- package/src/coverage.ts +7 -1
package/README.md
CHANGED
|
@@ -83,6 +83,36 @@ Pub/Sub integrations are project-owned as of SDK 0.98.0. The former
|
|
|
83
83
|
Use a local service definition or `serviceGroup()` wrapper; the repository’s
|
|
84
84
|
`examples/google-pubsub` demonstrates this using public SDK APIs.
|
|
85
85
|
|
|
86
|
+
## Browser coverage across navigation (0.131.17)
|
|
87
|
+
|
|
88
|
+
Browser coverage keeps script identities separate when Chromium changes renderers
|
|
89
|
+
during cross-site navigation, including login redirects. Anonymous browser code
|
|
90
|
+
cannot inherit an earlier script’s URL or source map. No application configuration
|
|
91
|
+
change is needed. A script identity change during capture is reported as incomplete
|
|
92
|
+
coverage instead of publishing uncertain attribution.
|
|
93
|
+
|
|
94
|
+
## Next.js coverage (0.131.16)
|
|
95
|
+
|
|
96
|
+
Use `coverage.next({ appDir: "ops", sourceRoot: "." })` in your Spectest
|
|
97
|
+
service configuration to collect server and browser coverage. Keep Next's
|
|
98
|
+
server and browser source maps in the runtime image. `appDir` is relative to
|
|
99
|
+
the container working directory; `sourceRoot` identifies the repository
|
|
100
|
+
directory represented by the build's source-map root.
|
|
101
|
+
|
|
102
|
+
Standalone servers launched through Node wrappers can change working directory:
|
|
103
|
+
the adapter preserves the launcher's generated dependency identity in children.
|
|
104
|
+
Turbopack's generated image modules, including SVG data-URL wrappers, are
|
|
105
|
+
excluded automatically wherever the asset lives. Authored asset and application
|
|
106
|
+
sources are not excluded.
|
|
107
|
+
|
|
108
|
+
For Next 16.4.3, the adapter also corrects a reproduced Turbopack Edge source-map
|
|
109
|
+
path defect using Next's resolved build manifest (`appDir`, `distDir`, and
|
|
110
|
+
`turbopack.root`). This happens while the collector reads a map, before it creates
|
|
111
|
+
the coverage record. No application build-script workaround is required. The
|
|
112
|
+
correction is scoped to that verified version and layout; correctly emitted maps
|
|
113
|
+
and other versions retain normal source-map interpretation. Unknown source paths
|
|
114
|
+
remain unresolved and cannot authorize subset selection.
|
|
115
|
+
|
|
86
116
|
## Supabase function coverage (0.122.0)
|
|
87
117
|
|
|
88
118
|
`supabase({ dir: "supabase" })` automatically collects coverage for its edge
|
package/dist/browser-coverage.js
CHANGED
|
@@ -47,8 +47,9 @@ export async function attachBrowserCoverage(cdp) {
|
|
|
47
47
|
COLLECTORS.set(cdp, collector);
|
|
48
48
|
LIVE_COLLECTORS.add(collector);
|
|
49
49
|
cdp.on("Debugger.scriptParsed", (ev) => {
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
// IDs belong to a renderer, not the lifetime of this CDP session. A
|
|
51
|
+
// cross-site navigation can reuse an old named ID for anonymous code.
|
|
52
|
+
// Replace anonymous entries too, invalidating the previous URL/source/map.
|
|
52
53
|
collector.scripts.set(ev.scriptId, {
|
|
53
54
|
url: ev.url,
|
|
54
55
|
sourceMapURL: ev.sourceMapURL || undefined,
|
|
@@ -108,6 +109,7 @@ export async function harvestBrowserCoverage(cdp, signal) {
|
|
|
108
109
|
}
|
|
109
110
|
}
|
|
110
111
|
async function harvestInner(c, config, signal) {
|
|
112
|
+
const scriptsAtTake = new Map(c.scripts);
|
|
111
113
|
let result;
|
|
112
114
|
try {
|
|
113
115
|
({ result } = (await c.cdp.send("Profiler.takePreciseCoverage")));
|
|
@@ -119,12 +121,13 @@ async function harvestInner(c, config, signal) {
|
|
|
119
121
|
throw err;
|
|
120
122
|
}
|
|
121
123
|
for (const script of result) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
+
// The profiler's URL identifies this execution. Never supply a URL from
|
|
125
|
+
// an earlier renderer to an anonymous result.
|
|
126
|
+
if (!script.url)
|
|
124
127
|
continue;
|
|
125
128
|
let url;
|
|
126
129
|
try {
|
|
127
|
-
url = new URL(
|
|
130
|
+
url = new URL(script.url);
|
|
128
131
|
}
|
|
129
132
|
catch {
|
|
130
133
|
continue;
|
|
@@ -134,19 +137,34 @@ async function harvestInner(c, config, signal) {
|
|
|
134
137
|
const service = config.serviceForHost(url.hostname);
|
|
135
138
|
if (!service)
|
|
136
139
|
continue;
|
|
140
|
+
let info = c.scripts.get(script.scriptId);
|
|
141
|
+
const before = scriptsAtTake.get(script.scriptId);
|
|
142
|
+
const changed = () => {
|
|
143
|
+
c.incomplete = new Error("Browser script identity changed during coverage capture; coverage is incomplete");
|
|
144
|
+
return c.incomplete;
|
|
145
|
+
};
|
|
146
|
+
if ((before && before !== info) || (info && info.url !== script.url))
|
|
147
|
+
throw changed();
|
|
148
|
+
if (!info) {
|
|
149
|
+
info = { url: script.url };
|
|
150
|
+
c.scripts.set(script.scriptId, info);
|
|
151
|
+
}
|
|
137
152
|
if (info.source === undefined) {
|
|
138
153
|
try {
|
|
139
154
|
const r = (await c.cdp.send("Debugger.getScriptSource", {
|
|
140
155
|
scriptId: script.scriptId,
|
|
141
156
|
}));
|
|
142
157
|
signal.throwIfAborted();
|
|
158
|
+
if (c.scripts.get(script.scriptId) !== info)
|
|
159
|
+
throw changed();
|
|
143
160
|
info.source = r.scriptSource;
|
|
144
161
|
}
|
|
145
162
|
catch {
|
|
146
163
|
signal.throwIfAborted();
|
|
164
|
+
if (c.incomplete)
|
|
165
|
+
throw c.incomplete;
|
|
147
166
|
continue; // script gone with its document
|
|
148
167
|
}
|
|
149
|
-
c.scripts.set(script.scriptId, info);
|
|
150
168
|
}
|
|
151
169
|
const generated = rangesToLineCounts(info.source, script.functions);
|
|
152
170
|
if (generated.size === 0)
|
package/dist/coverage-next.js
CHANGED
|
@@ -15,7 +15,8 @@ const root = process.env.SPECTEST_COVERAGE_ROOT || process.cwd();
|
|
|
15
15
|
process.env.SPECTEST_COVERAGE_ROOT = root;
|
|
16
16
|
const app = path.resolve(root, options.appDir || ".");
|
|
17
17
|
const dist = path.resolve(app, options.distDir || ".next");
|
|
18
|
-
|
|
18
|
+
let build;
|
|
19
|
+
function manifest() { return build ||= JSON.parse(fs.readFileSync(path.join(dist, "required-server-files.json"), "utf8")); }
|
|
19
20
|
function launcher(m) {
|
|
20
21
|
const file = path.join(app, "server.js");
|
|
21
22
|
if (m.config.output !== "standalone" || !fs.existsSync(file)) return null;
|
|
@@ -29,6 +30,55 @@ try {
|
|
|
29
30
|
...new Set([...JSON.parse(process.env.SPECTEST_COVERAGE_GENERATED_FILES || "[]"), file])
|
|
30
31
|
]);
|
|
31
32
|
} catch {} // Capture reports a useful error; do not change application startup.
|
|
33
|
+
|
|
34
|
+
// Next 16.4.3 gives its Edge chunker the offset from distDir to turbopack.root,
|
|
35
|
+
// although the chunker's output root is distDir/server/edge. Recover the exact
|
|
36
|
+
// serialized prefix from the build configuration, not from repository suffixes.
|
|
37
|
+
// See docs/validation/next-coverage-20261102.md for the unmodified-build repro
|
|
38
|
+
// and upstream implementation. Other versions/layouts keep normal map semantics.
|
|
39
|
+
let edgePolicy;
|
|
40
|
+
function edgeSourcePolicy() {
|
|
41
|
+
if (edgePolicy !== undefined) return edgePolicy;
|
|
42
|
+
edgePolicy = null;
|
|
43
|
+
try {
|
|
44
|
+
const version = JSON.parse(fs.readFileSync(require.resolve("next/package.json", {paths:[app]}), "utf8")).version;
|
|
45
|
+
if (version !== "16.4.3") return null;
|
|
46
|
+
const m = manifest(), config = m.config;
|
|
47
|
+
const buildRoot = config.turbopack && config.turbopack.root;
|
|
48
|
+
if (typeof m.appDir !== "string" || !path.isAbsolute(m.appDir) ||
|
|
49
|
+
typeof buildRoot !== "string" || !path.isAbsolute(buildRoot) ||
|
|
50
|
+
typeof config.distDir !== "string") return null;
|
|
51
|
+
const offset = path.relative(path.resolve(m.appDir, config.distDir), buildRoot);
|
|
52
|
+
if (!offset || !offset.split(path.sep).every(p => p === "..")) return null;
|
|
53
|
+
edgePolicy = {root:path.join(dist, "server/edge"), offset};
|
|
54
|
+
} catch {} // Unknown build metadata cannot authorize a source correction.
|
|
55
|
+
return edgePolicy;
|
|
56
|
+
}
|
|
57
|
+
exports.normalizeSourceMap = function(data, mapFile) {
|
|
58
|
+
const policy = edgeSourcePolicy();
|
|
59
|
+
if (!policy) return;
|
|
60
|
+
const relative = path.relative(policy.root, mapFile);
|
|
61
|
+
if (!relative.startsWith("chunks" + path.sep) || path.isAbsolute(relative) || relative.split(path.sep).includes("..")) return;
|
|
62
|
+
const prefix = path.relative(path.dirname(mapFile), policy.root).split(path.sep).join("/") + "/" + policy.offset.split(path.sep).join("/") + "/";
|
|
63
|
+
function visit(map, depth) {
|
|
64
|
+
if (!map || typeof map !== "object" || depth > 32 || map.sourceRoot) return;
|
|
65
|
+
if (Array.isArray(map.sections)) {
|
|
66
|
+
for (const section of map.sections) if (section && !section.url) visit(section.map, depth + 1);
|
|
67
|
+
}
|
|
68
|
+
if (!Array.isArray(map.sources)) return;
|
|
69
|
+
map.sources = map.sources.map(source => {
|
|
70
|
+
if (typeof source !== "string" || !source.startsWith(prefix)) return source;
|
|
71
|
+
const suffix = source.slice(prefix.length);
|
|
72
|
+
let decoded;
|
|
73
|
+
try { decoded = decodeURIComponent(suffix); } catch { return source; }
|
|
74
|
+
// Correctly emitted paths have two extra '..' components. Never rewrite
|
|
75
|
+
// those, absolute/virtual sources, escapes or another root's sources.
|
|
76
|
+
if (/[\\:\0?#]/.test(decoded) || decoded.split("/").some(p => !p || p === "." || p === "..")) return source;
|
|
77
|
+
return "turbopack:///[project]/" + suffix;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
visit(data, 0);
|
|
81
|
+
};
|
|
32
82
|
if (require.main === module) {
|
|
33
83
|
try {
|
|
34
84
|
const m = manifest();
|
|
@@ -54,8 +104,9 @@ const GENERATED_SOURCES = [
|
|
|
54
104
|
"*/data:* <text/javascript>", // Turbopack virtual modules, including action proxies.
|
|
55
105
|
"*/.next-internal/server/app/*/actions.js (server actions loader)",
|
|
56
106
|
"*/app/*--route-entry.js",
|
|
57
|
-
"
|
|
58
|
-
"
|
|
107
|
+
"turbopack:///[project]/*.mjs (structured image object)",
|
|
108
|
+
"turbopack:///[project]/*.mjs (structured image object with data url)",
|
|
109
|
+
"*/app/*.mjs (structured image object)", // Older Next map identities.
|
|
59
110
|
];
|
|
60
111
|
/** Server + browser coverage for a built Next app. Build-time maps are required;
|
|
61
112
|
* enable native Next source maps and retain them in the runtime image.
|
|
@@ -90,7 +141,8 @@ export function next(options = {}) {
|
|
|
90
141
|
env: { ...configured.env,
|
|
91
142
|
NODE_OPTIONS: `--require ${NEXT_COVERAGE_HELPER_PATH} ${configured.env.NODE_OPTIONS}`,
|
|
92
143
|
SPECTEST_NEXT_COVERAGE_OPTIONS: JSON.stringify(options),
|
|
93
|
-
SPECTEST_COVERAGE_SERVICE: info.key,
|
|
144
|
+
SPECTEST_COVERAGE_SERVICE: info.key,
|
|
145
|
+
SPECTEST_COVERAGE_SOURCE_MAP_ADAPTER: NEXT_COVERAGE_HELPER_PATH, },
|
|
94
146
|
files: [...configured.files, { path: NEXT_COVERAGE_HELPER_PATH, content: NEXT_COVERAGE_HELPER }],
|
|
95
147
|
};
|
|
96
148
|
},
|
package/dist/coverage.js
CHANGED
|
@@ -353,6 +353,8 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
353
353
|
return "generated:" + process.env.SPECTEST_COVERAGE_SERVICE + ":" + file + "#" + crypto.createHash("sha256").update(text).digest("hex");
|
|
354
354
|
}
|
|
355
355
|
const SELF = url.pathToFileURL(__filename).href;
|
|
356
|
+
const SOURCE_MAP_ADAPTER = process.env.SPECTEST_COVERAGE_SOURCE_MAP_ADAPTER;
|
|
357
|
+
const SOURCE_MAP_ADAPTER_URL = SOURCE_MAP_ADAPTER ? url.pathToFileURL(path.resolve(SOURCE_MAP_ADAPTER)).href : null;
|
|
356
358
|
const SCRIPTS = path.join(dir, ${JSON.stringify(NODE_COVERAGE_SCRIPTS_SUBDIR)});
|
|
357
359
|
const MARKERS = path.join(dir, ${JSON.stringify(NODE_COVERAGE_MARKERS_SUBDIR)});
|
|
358
360
|
// V8 script ids identify a compiled script within this process. A URL
|
|
@@ -365,7 +367,7 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
365
367
|
fs.writeFileSync(path.join(dir, ".attached-" + (bunRuntime ? "bun" : "node")), "");
|
|
366
368
|
|
|
367
369
|
function isApp(u) {
|
|
368
|
-
return typeof u === "string" && u.startsWith("file://") && u.indexOf("/node_modules/") < 0 && u.indexOf("/.cache/") < 0 && u !== SELF;
|
|
370
|
+
return typeof u === "string" && u.startsWith("file://") && u.indexOf("/node_modules/") < 0 && u.indexOf("/.cache/") < 0 && u !== SELF && u !== SOURCE_MAP_ADAPTER_URL;
|
|
369
371
|
}
|
|
370
372
|
function isDep(u) {
|
|
371
373
|
return typeof u === "string" && u.startsWith("file://") && u.indexOf("/node_modules/") >= 0 && u !== SELF;
|
|
@@ -462,6 +464,10 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
462
464
|
} catch (e) {
|
|
463
465
|
return null;
|
|
464
466
|
}
|
|
467
|
+
// Framework policy runs before dependency attribution and before the final
|
|
468
|
+
// descriptor is hashed. The collector remains the sole owner of record IDs,
|
|
469
|
+
// deduplication, dump references and cleanup (including exit-time captures).
|
|
470
|
+
if (SOURCE_MAP_ADAPTER) require(SOURCE_MAP_ADAPTER).normalizeSourceMap(data, mapUrl ? url.fileURLToPath(mapUrl) : scriptPath);
|
|
465
471
|
// A source inside node_modules is a dependency: its unit id rides
|
|
466
472
|
// beside the map (\`sourceUnits[i]\`, null for the project's own), so
|
|
467
473
|
// the control plane never has to know what node_modules is.
|
package/package.json
CHANGED
|
@@ -27,7 +27,10 @@ test("an ambiguous coverage timeout is retained and a late CDP result cannot pub
|
|
|
27
27
|
await Bun.sleep(0);
|
|
28
28
|
expect(sourceReads).toBe(0);
|
|
29
29
|
expect(browserCoverageReports().has("coverage-deadline-test")).toBe(false);
|
|
30
|
-
|
|
30
|
+
// Other tests may also retain incomplete collectors. Global harvest must
|
|
31
|
+
// reject; this session must retain its own interruption reason.
|
|
32
|
+
await expect(harvestAllBrowserCoverage()).rejects.toThrow("coverage is incomplete");
|
|
33
|
+
await expect(harvestBrowserCoverage(cdp)).rejects.toThrow("Browser coverage capture was interrupted; coverage is incomplete");
|
|
31
34
|
expect(takes).toBe(1);
|
|
32
35
|
} finally { configureBrowserCoverage(null); }
|
|
33
36
|
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import type { CDPSession } from "playwright-core";
|
|
4
|
+
import {
|
|
5
|
+
attachBrowserCoverage, configureBrowserCoverage, harvestBrowserCoverage,
|
|
6
|
+
takeBrowserCoverageReport,
|
|
7
|
+
} from "./browser-coverage.js";
|
|
8
|
+
|
|
9
|
+
function session() {
|
|
10
|
+
const events = new EventEmitter();
|
|
11
|
+
let source = "globalThis.ran = true;";
|
|
12
|
+
let url = "http://app.test/app.js";
|
|
13
|
+
const cdp = Object.assign(events, {
|
|
14
|
+
async send(method: string): Promise<any> {
|
|
15
|
+
if (method === "Profiler.takePreciseCoverage") return { result: [{ scriptId: "4", url,
|
|
16
|
+
functions: [{ ranges: [{ startOffset: 0, endOffset: source.length, count: 1 }] }] }] };
|
|
17
|
+
if (method === "Debugger.getScriptSource") return { scriptSource: source };
|
|
18
|
+
return {};
|
|
19
|
+
},
|
|
20
|
+
}) as unknown as CDPSession;
|
|
21
|
+
return { cdp, parsed(nextUrl = url, nextSource = source, sourceMapURL?: string) {
|
|
22
|
+
url = nextUrl; source = nextSource;
|
|
23
|
+
events.emit("Debugger.scriptParsed", { scriptId: "4", url, sourceMapURL });
|
|
24
|
+
} };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const inlineMap = (source: string) => "data:application/json;base64," + Buffer.from(JSON.stringify({
|
|
28
|
+
version: 3, sources: [source], names: [], mappings: "AAAA",
|
|
29
|
+
})).toString("base64");
|
|
30
|
+
|
|
31
|
+
test("a reused named ID replaces cached source maps even when its URL is unchanged", async () => {
|
|
32
|
+
const s = session();
|
|
33
|
+
configureBrowserCoverage({ serviceForHost: () => "identity-maps" });
|
|
34
|
+
try {
|
|
35
|
+
await attachBrowserCoverage(s.cdp);
|
|
36
|
+
s.parsed(undefined, undefined, inlineMap("src/old.ts"));
|
|
37
|
+
await harvestBrowserCoverage(s.cdp);
|
|
38
|
+
expect(takeBrowserCoverageReport("identity-maps")).toContain("SF:src/old.ts");
|
|
39
|
+
s.parsed(undefined, undefined, inlineMap("src/new.ts"));
|
|
40
|
+
await harvestBrowserCoverage(s.cdp);
|
|
41
|
+
const report = takeBrowserCoverageReport("identity-maps");
|
|
42
|
+
expect(report).toContain("SF:src/new.ts");
|
|
43
|
+
expect(report).not.toContain("src/old.ts");
|
|
44
|
+
} finally { configureBrowserCoverage(null); takeBrowserCoverageReport("identity-maps"); }
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("the profiler's anonymous URL cannot inherit a stale named URL", async () => {
|
|
48
|
+
const s = session();
|
|
49
|
+
configureBrowserCoverage({ serviceForHost: () => "identity-anonymous" });
|
|
50
|
+
try {
|
|
51
|
+
await attachBrowserCoverage(s.cdp);
|
|
52
|
+
s.parsed();
|
|
53
|
+
const send = s.cdp.send.bind(s.cdp);
|
|
54
|
+
s.cdp.send = (async (method: string) => {
|
|
55
|
+
if (method === "Profiler.takePreciseCoverage") {
|
|
56
|
+
const result = await send(method as any) as any;
|
|
57
|
+
result.result[0].url = "";
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
return send(method as any);
|
|
61
|
+
}) as CDPSession["send"];
|
|
62
|
+
await harvestBrowserCoverage(s.cdp);
|
|
63
|
+
expect(takeBrowserCoverageReport("identity-anonymous")).toBe("TN:\n");
|
|
64
|
+
} finally { configureBrowserCoverage(null); }
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
for (const phase of ["Profiler.takePreciseCoverage", "Debugger.getScriptSource"]) {
|
|
68
|
+
test(`script replacement during ${phase} retains an incomplete-capture error`, async () => {
|
|
69
|
+
const s = session();
|
|
70
|
+
const service = `identity-race-${phase}`;
|
|
71
|
+
configureBrowserCoverage({ serviceForHost: () => service });
|
|
72
|
+
try {
|
|
73
|
+
await attachBrowserCoverage(s.cdp);
|
|
74
|
+
s.parsed();
|
|
75
|
+
const send = s.cdp.send.bind(s.cdp);
|
|
76
|
+
s.cdp.send = (async (method: string) => {
|
|
77
|
+
const result = await send(method as any);
|
|
78
|
+
// The same URL and ID now refer to a different renderer's script.
|
|
79
|
+
if (method === phase) s.parsed(undefined, "globalThis.replacement = true;");
|
|
80
|
+
return result;
|
|
81
|
+
}) as CDPSession["send"];
|
|
82
|
+
await expect(harvestBrowserCoverage(s.cdp)).rejects.toThrow("identity changed during coverage capture");
|
|
83
|
+
expect(takeBrowserCoverageReport(service)).toBe("TN:\n");
|
|
84
|
+
await expect(harvestBrowserCoverage(s.cdp)).rejects.toThrow("coverage is incomplete");
|
|
85
|
+
} finally { configureBrowserCoverage(null); takeBrowserCoverageReport(service); }
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { chromium } from "playwright-core";
|
|
4
|
+
import {
|
|
5
|
+
attachBrowserCoverage, configureBrowserCoverage, harvestBrowserCoverage,
|
|
6
|
+
takeBrowserCoverageReport,
|
|
7
|
+
} from "./browser-coverage.js";
|
|
8
|
+
|
|
9
|
+
const executablePath = Bun.which("chromium") ?? Bun.which("chromium-browser") ??
|
|
10
|
+
Bun.which("google-chrome") ?? chromium.executablePath();
|
|
11
|
+
|
|
12
|
+
test.skipIf(!existsSync(executablePath))("cross-site script ID reuse cannot attribute anonymous code to an earlier empty script", async () => {
|
|
13
|
+
const emptyScript = "/ingest/static/1.438.19/exception-autocapture.js";
|
|
14
|
+
const map = Buffer.from(JSON.stringify({ version: 3, sources: ["src/customer.ts"], names: [], mappings: "AAAA" })).toString("base64");
|
|
15
|
+
const server = Bun.serve({ hostname: "0.0.0.0", port: 0, fetch(request) {
|
|
16
|
+
const url = new URL(request.url);
|
|
17
|
+
if (url.pathname === emptyScript) return new Response("", { headers: { "content-type": "application/javascript" } });
|
|
18
|
+
if (url.pathname === "/app.js") return new Response(`globalThis.appRan = true;\n//# sourceMappingURL=data:application/json;base64,${map}`, { headers: { "content-type": "application/javascript" } });
|
|
19
|
+
return new Response(url.hostname === "127.0.0.1"
|
|
20
|
+
? `<script src="${emptyScript}"></script><script src="/app.js"></script><p>Application</p>`
|
|
21
|
+
: "<p>External login</p>", { headers: { "content-type": "text/html", "cache-control": "no-store" } });
|
|
22
|
+
} });
|
|
23
|
+
const browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox", "--site-per-process"] });
|
|
24
|
+
try {
|
|
25
|
+
const page = await browser.newPage();
|
|
26
|
+
const cdp = await page.context().newCDPSession(page);
|
|
27
|
+
const urls = new Map<string, string>();
|
|
28
|
+
let anonymousReuse = false;
|
|
29
|
+
cdp.on("Debugger.scriptParsed", event => {
|
|
30
|
+
if (urls.get(event.scriptId)?.endsWith(emptyScript) && !event.url) anonymousReuse = true;
|
|
31
|
+
urls.set(event.scriptId, event.url);
|
|
32
|
+
});
|
|
33
|
+
configureBrowserCoverage({ serviceForHost: host => host === "127.0.0.1" ? "navigation-test" : undefined });
|
|
34
|
+
await attachBrowserCoverage(cdp);
|
|
35
|
+
// Use different sites to force a renderer swap, then return to the app.
|
|
36
|
+
// Keep collected application counts across navigation until service capture.
|
|
37
|
+
for (const host of ["127.0.0.1", "127.0.0.2", "127.0.0.1", "127.0.0.2"]) {
|
|
38
|
+
await page.goto(`http://${host}:${server.port}/`);
|
|
39
|
+
await page.evaluate("(() => { const value = 1; return value + 1; })()");
|
|
40
|
+
await harvestBrowserCoverage(cdp);
|
|
41
|
+
}
|
|
42
|
+
expect(anonymousReuse).toBe(true); // Prove the regression's trigger happened.
|
|
43
|
+
const report = takeBrowserCoverageReport("navigation-test");
|
|
44
|
+
expect(report).toContain("SF:src/customer.ts\nDA:1,2\n");
|
|
45
|
+
expect(report).not.toContain(emptyScript);
|
|
46
|
+
expect(report.match(/^SF:/gm)).toHaveLength(1);
|
|
47
|
+
await cdp.detach();
|
|
48
|
+
} finally {
|
|
49
|
+
configureBrowserCoverage(null);
|
|
50
|
+
takeBrowserCoverageReport("navigation-test");
|
|
51
|
+
await browser.close();
|
|
52
|
+
server.stop(true);
|
|
53
|
+
}
|
|
54
|
+
}, 15000);
|
package/src/browser-coverage.ts
CHANGED
|
@@ -87,7 +87,9 @@ export async function attachBrowserCoverage(cdp: CDPSession): Promise<void> {
|
|
|
87
87
|
COLLECTORS.set(cdp, collector);
|
|
88
88
|
LIVE_COLLECTORS.add(collector);
|
|
89
89
|
cdp.on("Debugger.scriptParsed", (ev: { scriptId: string; url: string; sourceMapURL?: string }) => {
|
|
90
|
-
|
|
90
|
+
// IDs belong to a renderer, not the lifetime of this CDP session. A
|
|
91
|
+
// cross-site navigation can reuse an old named ID for anonymous code.
|
|
92
|
+
// Replace anonymous entries too, invalidating the previous URL/source/map.
|
|
91
93
|
collector.scripts.set(ev.scriptId, {
|
|
92
94
|
url: ev.url,
|
|
93
95
|
sourceMapURL: ev.sourceMapURL || undefined,
|
|
@@ -141,6 +143,7 @@ export async function harvestBrowserCoverage(cdp: CDPSession, signal?: AbortSign
|
|
|
141
143
|
}
|
|
142
144
|
|
|
143
145
|
async function harvestInner(c: Collector, config: BrowserCoverageConfig, signal: AbortSignal): Promise<void> {
|
|
146
|
+
const scriptsAtTake = new Map(c.scripts);
|
|
144
147
|
let result: { scriptId: string; url: string; functions: V8FunctionCoverage[] }[];
|
|
145
148
|
try {
|
|
146
149
|
({ result } = (await c.cdp.send("Profiler.takePreciseCoverage")) as { result: typeof result });
|
|
@@ -150,29 +153,42 @@ async function harvestInner(c: Collector, config: BrowserCoverageConfig, signal:
|
|
|
150
153
|
throw err;
|
|
151
154
|
}
|
|
152
155
|
for (const script of result) {
|
|
153
|
-
|
|
154
|
-
|
|
156
|
+
// The profiler's URL identifies this execution. Never supply a URL from
|
|
157
|
+
// an earlier renderer to an anonymous result.
|
|
158
|
+
if (!script.url) continue;
|
|
155
159
|
let url: URL;
|
|
156
160
|
try {
|
|
157
|
-
url = new URL(
|
|
161
|
+
url = new URL(script.url);
|
|
158
162
|
} catch {
|
|
159
163
|
continue;
|
|
160
164
|
}
|
|
161
165
|
if (url.protocol !== "http:" && url.protocol !== "https:") continue;
|
|
162
166
|
const service = config.serviceForHost(url.hostname);
|
|
163
167
|
if (!service) continue;
|
|
168
|
+
let info = c.scripts.get(script.scriptId);
|
|
169
|
+
const before = scriptsAtTake.get(script.scriptId);
|
|
170
|
+
const changed = () => {
|
|
171
|
+
c.incomplete = new Error("Browser script identity changed during coverage capture; coverage is incomplete");
|
|
172
|
+
return c.incomplete;
|
|
173
|
+
};
|
|
174
|
+
if ((before && before !== info) || (info && info.url !== script.url)) throw changed();
|
|
175
|
+
if (!info) {
|
|
176
|
+
info = { url: script.url };
|
|
177
|
+
c.scripts.set(script.scriptId, info);
|
|
178
|
+
}
|
|
164
179
|
if (info.source === undefined) {
|
|
165
180
|
try {
|
|
166
181
|
const r = (await c.cdp.send("Debugger.getScriptSource", {
|
|
167
182
|
scriptId: script.scriptId,
|
|
168
183
|
})) as { scriptSource: string };
|
|
169
184
|
signal.throwIfAborted();
|
|
185
|
+
if (c.scripts.get(script.scriptId) !== info) throw changed();
|
|
170
186
|
info.source = r.scriptSource;
|
|
171
187
|
} catch {
|
|
172
188
|
signal.throwIfAborted();
|
|
189
|
+
if (c.incomplete) throw c.incomplete;
|
|
173
190
|
continue; // script gone with its document
|
|
174
191
|
}
|
|
175
|
-
c.scripts.set(script.scriptId, info);
|
|
176
192
|
}
|
|
177
193
|
const generated = rangesToLineCounts(info.source, script.functions);
|
|
178
194
|
if (generated.size === 0) continue;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, test, expect } from "bun:test";
|
|
2
2
|
import { next, NEXT_COVERAGE_HELPER, NEXT_COVERAGE_HELPER_PATH, type NextBuildMetadata } from "./coverage-next.js";
|
|
3
3
|
import { applyCoverageAdapters, resolveCoverageSourceOptions, attachCoverageSourceOptions, node, browser, NODE_COVERAGE_HOOK, NODE_COVERAGE_SCRIPTS_SUBDIR, NODE_COVERAGE_MARKERS_SUBDIR, type CoverageSourceContext } from "./coverage.js";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
4
5
|
import { applyCoverageDelta, encodeCoverageBundle } from "./harness/coverage.js";
|
|
5
6
|
import { gunzipSync } from "node:zlib";
|
|
6
7
|
import { promises as fs } from "node:fs";
|
|
@@ -45,6 +46,7 @@ describe("Next adapter", () => {
|
|
|
45
46
|
const configured = applyCoverageAdapters("web", {image, coverage:{adapters:[adapter]}});
|
|
46
47
|
const env = {...process.env, ...configured.env,
|
|
47
48
|
SPECTEST_COVERAGE_DIR:reports,
|
|
49
|
+
...(kind === "next" ? { SPECTEST_COVERAGE_SOURCE_MAP_ADAPTER: helper } : {}),
|
|
48
50
|
NODE_OPTIONS: `${kind === "next" ? `--require ${helper} ` : ""}--require ${hook}`,
|
|
49
51
|
} as NodeJS.ProcessEnv;
|
|
50
52
|
const ctx: CoverageSourceContext = {
|
|
@@ -70,8 +72,8 @@ describe("Next adapter", () => {
|
|
|
70
72
|
const policies = await resolveCoverageSourceOptions([next({appDir:"ops"})], context());
|
|
71
73
|
const capture = attachCoverageSourceOptions({reports:[{name:"node-1.json"},{name:"browser.lcov"}]}, policies);
|
|
72
74
|
for (const report of capture.reports) {
|
|
73
|
-
expect(report.sourceOptions!.excludeSources).toContain("
|
|
74
|
-
expect(report.sourceOptions!.excludeSources).toContain("
|
|
75
|
+
expect(report.sourceOptions!.excludeSources).toContain("turbopack:///[project]/*.mjs (structured image object)");
|
|
76
|
+
expect(report.sourceOptions!.excludeSources).toContain("turbopack:///[project]/*.mjs (structured image object with data url)");
|
|
75
77
|
expect(report.sourceOptions!.excludeSources).not.toContain("*/public/*");
|
|
76
78
|
}
|
|
77
79
|
});
|
|
@@ -146,4 +148,164 @@ describe("Next adapter", () => {
|
|
|
146
148
|
expect(probe).toThrow();
|
|
147
149
|
} finally {await fs.rm(dir,{recursive:true,force:true});}
|
|
148
150
|
});
|
|
151
|
+
|
|
152
|
+
test("generated image wrappers are excluded across asset locations without excluding authored modules", async () => {
|
|
153
|
+
const policies = await resolveCoverageSourceOptions([next()], context());
|
|
154
|
+
const patterns = policies[0]!.options!.excludeSources!;
|
|
155
|
+
// The wire contract treats only '*' as special (including '/').
|
|
156
|
+
const excluded = (source: string) => patterns.some(pattern => new RegExp("^" +
|
|
157
|
+
pattern.split("*").map(part => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$").test(source));
|
|
158
|
+
for (const folder of ["app", "ops/public", "packages/ui/assets", "src/images"]) {
|
|
159
|
+
for (const suffix of ["structured image object", "structured image object with data url"]) {
|
|
160
|
+
expect(excluded(`turbopack:///[project]/${folder}/invoice.svg.mjs (${suffix})`)).toBe(true);
|
|
161
|
+
}
|
|
162
|
+
expect(excluded(`turbopack:///[project]/${folder}/invoice.svg`)).toBe(false);
|
|
163
|
+
expect(excluded(`turbopack:///[project]/${folder}/image.mjs`)).toBe(false);
|
|
164
|
+
}
|
|
165
|
+
expect(excluded("turbopack:///[project]/ops/src/middleware.ts")).toBe(false);
|
|
166
|
+
expect(excluded("https://example.com/image.mjs (structured image object with data url)")).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("a cwd-changing wrapper keeps launcher units consistent and Edge source records valid across captures", async () => {
|
|
170
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "next-child-cov-"));
|
|
171
|
+
const reports = path.join(dir, "reports");
|
|
172
|
+
const helper = path.join(dir, "next-helper.cjs");
|
|
173
|
+
const hook = path.join(dir, "hook.cjs");
|
|
174
|
+
const adapter = next({ appDir: "ops" });
|
|
175
|
+
const config = adapter.configure!({ image }, { key: "web" });
|
|
176
|
+
const sourceText = "export function middleware() { return 42; }\n";
|
|
177
|
+
const source = "ops/src/middleware.ts";
|
|
178
|
+
const digest = (text: string) => createHash("sha256").update(text).digest("hex");
|
|
179
|
+
const env: NodeJS.ProcessEnv = { ...process.env, ...config.env, SPECTEST_COVERAGE_DIR: reports,
|
|
180
|
+
NODE_OPTIONS: `--require ${helper} --require ${hook}`,
|
|
181
|
+
SPECTEST_COVERAGE_SOURCE_MAP_ADAPTER: helper };
|
|
182
|
+
delete env.SPECTEST_COVERAGE_ROOT;
|
|
183
|
+
const ctx = {
|
|
184
|
+
service: "web", reportDir: reports, signal: new AbortController().signal,
|
|
185
|
+
projectFiles: async () => [source],
|
|
186
|
+
readProjectFile: async (file: string) => file === source ? sourceText : null,
|
|
187
|
+
writeReport: (name: string, content: string) => fs.writeFile(path.join(reports, name), content),
|
|
188
|
+
async exec(command: string) {
|
|
189
|
+
const proc = Bun.spawn(["sh", "-c", command.replace(NEXT_COVERAGE_HELPER_PATH, helper)],
|
|
190
|
+
{ cwd: dir, env: { ...env, NODE_OPTIONS: "" }, stdout: "pipe", stderr: "pipe" });
|
|
191
|
+
const stdout = await new Response(proc.stdout).text();
|
|
192
|
+
const stderr = await new Response(proc.stderr).text();
|
|
193
|
+
if (await proc.exited !== 0) throw new Error(stderr);
|
|
194
|
+
return { stdout, stderr };
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
try {
|
|
198
|
+
await fs.mkdir(path.join(dir, "ops/.next/server/edge/chunks"), { recursive: true });
|
|
199
|
+
await fs.mkdir(path.join(dir, "ops/.next/static"), { recursive: true });
|
|
200
|
+
await fs.mkdir(reports);
|
|
201
|
+
await fs.mkdir(path.join(dir, "node_modules/next"), { recursive: true });
|
|
202
|
+
await fs.writeFile(path.join(dir, "node_modules/next/package.json"), JSON.stringify({ version: "16.4.3" }));
|
|
203
|
+
await fs.writeFile(helper, NEXT_COVERAGE_HELPER);
|
|
204
|
+
await fs.writeFile(hook, NODE_COVERAGE_HOOK);
|
|
205
|
+
await fs.writeFile(path.join(dir, "ops/.next/required-server-files.json"), JSON.stringify({ appDir: "/build/ops", config: { output: "standalone", distDir: ".next", turbopack: { root: "/build" } } }));
|
|
206
|
+
await fs.writeFile(path.join(dir, "ops/.next/static/browser.js.map"), "{}");
|
|
207
|
+
const edge = path.join(dir, "ops/.next/server/edge/chunks/middleware.js");
|
|
208
|
+
await fs.writeFile(edge, "globalThis.edgeValue = 42;\n");
|
|
209
|
+
await fs.writeFile(edge + ".map", JSON.stringify({ version: 3, sections: [{ offset: { line: 0, column: 0 }, map: {
|
|
210
|
+
version: 3, sources: ["../../../ops/src/middleware.ts"], sourcesContent: [sourceText], mappings: "AAAA", names: [],
|
|
211
|
+
} }] }));
|
|
212
|
+
const launcher = '// __NEXT_PRIVATE_STANDALONE_CONFIG next/dist/server/lib/start-server\nrequire("./.next/server/edge/chunks/middleware.js");\n';
|
|
213
|
+
await fs.writeFile(path.join(dir, "ops/server.js"), launcher);
|
|
214
|
+
// Also exercise process.chdir before spawn, not just the spawn cwd option.
|
|
215
|
+
await fs.writeFile(path.join(dir, "wrapper.cjs"), `
|
|
216
|
+
process.chdir("ops");
|
|
217
|
+
require("node:child_process").execFileSync(process.execPath, ["server.js"], { cwd: process.cwd(), stdio: "inherit" });
|
|
218
|
+
`);
|
|
219
|
+
const run = () => execFileSync("node", ["wrapper.cjs"], { cwd: dir, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
220
|
+
const unit = `generated:web:ops/server.js#${digest(launcher)}`;
|
|
221
|
+
let correctedId: string | undefined;
|
|
222
|
+
for (let capture = 0; capture < 2; capture++) {
|
|
223
|
+
run();
|
|
224
|
+
// The exit hook must already have written the final descriptor and ID,
|
|
225
|
+
// before harness metadata/capture executes or reads project files.
|
|
226
|
+
const rawRecords = await Promise.all((await fs.readdir(path.join(reports, "scripts")))
|
|
227
|
+
.map(async name => [name, await fs.readFile(path.join(reports, "scripts", name), "utf8")] as const));
|
|
228
|
+
await resolveCoverageSourceOptions([adapter], ctx);
|
|
229
|
+
await adapter.capture!(ctx);
|
|
230
|
+
const dumps = await Promise.all((await fs.readdir(reports)).filter(n => /^node-.*\.json$/.test(n))
|
|
231
|
+
.map(async n => JSON.parse(await fs.readFile(path.join(reports, n), "utf8"))));
|
|
232
|
+
expect(dumps.flatMap(d => d.units ?? [])).toContain(unit);
|
|
233
|
+
expect(dumps.flatMap(d => d.result).some(s => s.url.endsWith("/ops/server.js"))).toBe(false);
|
|
234
|
+
const executed = dumps.flatMap(d => d.result).find(s => s.url.endsWith("/middleware.js"));
|
|
235
|
+
expect(executed).toBeDefined();
|
|
236
|
+
if (capture === 0) {
|
|
237
|
+
correctedId = executed.spectestScript;
|
|
238
|
+
const text = await fs.readFile(path.join(reports, "scripts", `${correctedId}.json`), "utf8");
|
|
239
|
+
expect(digest(text).slice(0, 32)).toBe(correctedId);
|
|
240
|
+
const map = JSON.parse(text).spectestScript.sourceMap.data.sections[0].map;
|
|
241
|
+
expect(map.sources).toEqual(["turbopack:///[project]/ops/src/middleware.ts"]);
|
|
242
|
+
expect(map.sourcesContent).toBeUndefined();
|
|
243
|
+
expect(map.x_spectestSourceHashes).toBeUndefined();
|
|
244
|
+
expect(rawRecords.find(([name]) => name === `${correctedId}.json`)?.[1]).toBe(text);
|
|
245
|
+
} else {
|
|
246
|
+
// Node deduplicates the original record; the second capture must
|
|
247
|
+
// reference the corrected record archived with the first capture.
|
|
248
|
+
expect(executed.spectestScript).toBe(correctedId);
|
|
249
|
+
expect(await fs.readdir(path.join(reports, "scripts"))).toEqual([]);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
expect(await adapter.inventory!(ctx)).toContain(unit);
|
|
253
|
+
} finally { await fs.rm(dir, { recursive: true, force: true }); }
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("Next Edge build configuration", () => {
|
|
258
|
+
async function normalize({ appDir = "ops", distDir = ".next", buildApp = "/build/ops", buildRoot = "/build",
|
|
259
|
+
version = "16.4.3", source, sourceRoot, outside = false, indexed = false,
|
|
260
|
+
}: { appDir?: string; distDir?: string; buildApp?: string; buildRoot?: string; version?: string;
|
|
261
|
+
source: string; sourceRoot?: string; outside?: boolean; indexed?: boolean }) {
|
|
262
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "next-map-config-"));
|
|
263
|
+
try {
|
|
264
|
+
const app = path.join(dir, appDir);
|
|
265
|
+
const dist = path.join(app, distDir);
|
|
266
|
+
const helper = path.join(dir, "helper.cjs");
|
|
267
|
+
await fs.mkdir(dist, { recursive: true });
|
|
268
|
+
await fs.mkdir(path.join(app, "node_modules/next"), { recursive: true });
|
|
269
|
+
await fs.writeFile(path.join(app, "node_modules/next/package.json"), JSON.stringify({ version }));
|
|
270
|
+
await fs.writeFile(path.join(dist, "required-server-files.json"), JSON.stringify({ appDir: buildApp,
|
|
271
|
+
config: { output: "standalone", distDir, turbopack: { root: buildRoot } } }));
|
|
272
|
+
await fs.writeFile(helper, NEXT_COVERAGE_HELPER);
|
|
273
|
+
const leaf = { version: 3, sources: [source], sourceRoot, names: [], mappings: "AAAA", ignoreList: [0] };
|
|
274
|
+
const map = indexed ? { version: 3, sections: [{ offset: { line: 2, column: 4 }, map: leaf }] } : leaf;
|
|
275
|
+
const script = `const map = ${JSON.stringify(map)};
|
|
276
|
+
require(${JSON.stringify(helper)}).normalizeSourceMap(map, ${JSON.stringify(path.join(dist, outside ? "server/chunks/a.js.map" : "server/edge/chunks/a.js.map"))});
|
|
277
|
+
process.stdout.write(JSON.stringify(map));`;
|
|
278
|
+
const result = JSON.parse(execFileSync("node", ["-e", script], { cwd: dir,
|
|
279
|
+
env: { ...process.env, NODE_OPTIONS: "", SPECTEST_COVERAGE_ROOT: dir,
|
|
280
|
+
SPECTEST_NEXT_COVERAGE_OPTIONS: JSON.stringify({ appDir, distDir }) } }).toString());
|
|
281
|
+
const resultLeaf = indexed ? result.sections[0].map : result;
|
|
282
|
+
expect(resultLeaf.ignoreList).toEqual([0]);
|
|
283
|
+
expect(resultLeaf.mappings).toBe("AAAA");
|
|
284
|
+
if (indexed) expect(result.sections[0].offset).toEqual({ line: 2, column: 4 });
|
|
285
|
+
return resultLeaf.sources[0];
|
|
286
|
+
} finally { await fs.rm(dir, { recursive: true, force: true }); }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
test("uses the recorded build root and distDir independently of runtime relocation", async () => {
|
|
290
|
+
expect(await normalize({ source: "../../../ops/src/middleware.ts" }))
|
|
291
|
+
.toBe("turbopack:///[project]/ops/src/middleware.ts");
|
|
292
|
+
expect(await normalize({ appDir: ".", buildApp: "/build", source: "../../src/middleware.ts" }))
|
|
293
|
+
.toBe("turbopack:///[project]/src/middleware.ts");
|
|
294
|
+
expect(await normalize({ appDir: "deployed/web", buildApp: "/builder/packages/apps/web", buildRoot: "/builder/packages",
|
|
295
|
+
distDir: ".output", source: "../../../../apps/web/src/my%20middleware.ts", indexed: true }))
|
|
296
|
+
.toBe("turbopack:///[project]/apps/web/src/my%20middleware.ts");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("preserves fixed maps, external sources, explicit roots and unknown producers", async () => {
|
|
300
|
+
for (const source of ["../../../../../ops/src/middleware.ts", "turbopack:///[project]/ops/src/middleware.ts",
|
|
301
|
+
"file:///build/ops/src/middleware.ts", "https://example.com/a.ts", "../../../%2e%2e/secret.ts",
|
|
302
|
+
"../../../ops/src/%ZZ.ts", "../../../C%3A/secret.ts", "../../../ops//middleware.ts"]) {
|
|
303
|
+
expect(await normalize({ source })).toBe(source);
|
|
304
|
+
}
|
|
305
|
+
const source = "../../../ops/src/middleware.ts";
|
|
306
|
+
for (const extra of [{ sourceRoot: "/build" }, { outside: true }, { version: "16.4.4" },
|
|
307
|
+
{ buildRoot: "/another-root" }, { buildApp: "relative/app" }]) {
|
|
308
|
+
expect(await normalize({ source, ...extra })).toBe(source);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
149
311
|
});
|
package/src/coverage-next.ts
CHANGED
|
@@ -27,7 +27,8 @@ const root = process.env.SPECTEST_COVERAGE_ROOT || process.cwd();
|
|
|
27
27
|
process.env.SPECTEST_COVERAGE_ROOT = root;
|
|
28
28
|
const app = path.resolve(root, options.appDir || ".");
|
|
29
29
|
const dist = path.resolve(app, options.distDir || ".next");
|
|
30
|
-
|
|
30
|
+
let build;
|
|
31
|
+
function manifest() { return build ||= JSON.parse(fs.readFileSync(path.join(dist, "required-server-files.json"), "utf8")); }
|
|
31
32
|
function launcher(m) {
|
|
32
33
|
const file = path.join(app, "server.js");
|
|
33
34
|
if (m.config.output !== "standalone" || !fs.existsSync(file)) return null;
|
|
@@ -41,6 +42,55 @@ try {
|
|
|
41
42
|
...new Set([...JSON.parse(process.env.SPECTEST_COVERAGE_GENERATED_FILES || "[]"), file])
|
|
42
43
|
]);
|
|
43
44
|
} catch {} // Capture reports a useful error; do not change application startup.
|
|
45
|
+
|
|
46
|
+
// Next 16.4.3 gives its Edge chunker the offset from distDir to turbopack.root,
|
|
47
|
+
// although the chunker's output root is distDir/server/edge. Recover the exact
|
|
48
|
+
// serialized prefix from the build configuration, not from repository suffixes.
|
|
49
|
+
// See docs/validation/next-coverage-20261102.md for the unmodified-build repro
|
|
50
|
+
// and upstream implementation. Other versions/layouts keep normal map semantics.
|
|
51
|
+
let edgePolicy;
|
|
52
|
+
function edgeSourcePolicy() {
|
|
53
|
+
if (edgePolicy !== undefined) return edgePolicy;
|
|
54
|
+
edgePolicy = null;
|
|
55
|
+
try {
|
|
56
|
+
const version = JSON.parse(fs.readFileSync(require.resolve("next/package.json", {paths:[app]}), "utf8")).version;
|
|
57
|
+
if (version !== "16.4.3") return null;
|
|
58
|
+
const m = manifest(), config = m.config;
|
|
59
|
+
const buildRoot = config.turbopack && config.turbopack.root;
|
|
60
|
+
if (typeof m.appDir !== "string" || !path.isAbsolute(m.appDir) ||
|
|
61
|
+
typeof buildRoot !== "string" || !path.isAbsolute(buildRoot) ||
|
|
62
|
+
typeof config.distDir !== "string") return null;
|
|
63
|
+
const offset = path.relative(path.resolve(m.appDir, config.distDir), buildRoot);
|
|
64
|
+
if (!offset || !offset.split(path.sep).every(p => p === "..")) return null;
|
|
65
|
+
edgePolicy = {root:path.join(dist, "server/edge"), offset};
|
|
66
|
+
} catch {} // Unknown build metadata cannot authorize a source correction.
|
|
67
|
+
return edgePolicy;
|
|
68
|
+
}
|
|
69
|
+
exports.normalizeSourceMap = function(data, mapFile) {
|
|
70
|
+
const policy = edgeSourcePolicy();
|
|
71
|
+
if (!policy) return;
|
|
72
|
+
const relative = path.relative(policy.root, mapFile);
|
|
73
|
+
if (!relative.startsWith("chunks" + path.sep) || path.isAbsolute(relative) || relative.split(path.sep).includes("..")) return;
|
|
74
|
+
const prefix = path.relative(path.dirname(mapFile), policy.root).split(path.sep).join("/") + "/" + policy.offset.split(path.sep).join("/") + "/";
|
|
75
|
+
function visit(map, depth) {
|
|
76
|
+
if (!map || typeof map !== "object" || depth > 32 || map.sourceRoot) return;
|
|
77
|
+
if (Array.isArray(map.sections)) {
|
|
78
|
+
for (const section of map.sections) if (section && !section.url) visit(section.map, depth + 1);
|
|
79
|
+
}
|
|
80
|
+
if (!Array.isArray(map.sources)) return;
|
|
81
|
+
map.sources = map.sources.map(source => {
|
|
82
|
+
if (typeof source !== "string" || !source.startsWith(prefix)) return source;
|
|
83
|
+
const suffix = source.slice(prefix.length);
|
|
84
|
+
let decoded;
|
|
85
|
+
try { decoded = decodeURIComponent(suffix); } catch { return source; }
|
|
86
|
+
// Correctly emitted paths have two extra '..' components. Never rewrite
|
|
87
|
+
// those, absolute/virtual sources, escapes or another root's sources.
|
|
88
|
+
if (/[\\:\0?#]/.test(decoded) || decoded.split("/").some(p => !p || p === "." || p === "..")) return source;
|
|
89
|
+
return "turbopack:///[project]/" + suffix;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
visit(data, 0);
|
|
93
|
+
};
|
|
44
94
|
if (require.main === module) {
|
|
45
95
|
try {
|
|
46
96
|
const m = manifest();
|
|
@@ -73,8 +123,9 @@ const GENERATED_SOURCES = [
|
|
|
73
123
|
"*/data:* <text/javascript>", // Turbopack virtual modules, including action proxies.
|
|
74
124
|
"*/.next-internal/server/app/*/actions.js (server actions loader)",
|
|
75
125
|
"*/app/*--route-entry.js",
|
|
76
|
-
"
|
|
77
|
-
"
|
|
126
|
+
"turbopack:///[project]/*.mjs (structured image object)",
|
|
127
|
+
"turbopack:///[project]/*.mjs (structured image object with data url)",
|
|
128
|
+
"*/app/*.mjs (structured image object)", // Older Next map identities.
|
|
78
129
|
];
|
|
79
130
|
|
|
80
131
|
/** Server + browser coverage for a built Next app. Build-time maps are required;
|
|
@@ -109,6 +160,7 @@ export function next(options: NextCoverageOptions = {}): CoverageAdapter {
|
|
|
109
160
|
NODE_OPTIONS: `--require ${NEXT_COVERAGE_HELPER_PATH} ${configured.env!.NODE_OPTIONS}`,
|
|
110
161
|
SPECTEST_NEXT_COVERAGE_OPTIONS: JSON.stringify(options),
|
|
111
162
|
SPECTEST_COVERAGE_SERVICE: info.key,
|
|
163
|
+
SPECTEST_COVERAGE_SOURCE_MAP_ADAPTER: NEXT_COVERAGE_HELPER_PATH,
|
|
112
164
|
},
|
|
113
165
|
files: [...configured.files!, { path: NEXT_COVERAGE_HELPER_PATH, content: NEXT_COVERAGE_HELPER }],
|
|
114
166
|
};
|
package/src/coverage.ts
CHANGED
|
@@ -531,6 +531,8 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
531
531
|
return "generated:" + process.env.SPECTEST_COVERAGE_SERVICE + ":" + file + "#" + crypto.createHash("sha256").update(text).digest("hex");
|
|
532
532
|
}
|
|
533
533
|
const SELF = url.pathToFileURL(__filename).href;
|
|
534
|
+
const SOURCE_MAP_ADAPTER = process.env.SPECTEST_COVERAGE_SOURCE_MAP_ADAPTER;
|
|
535
|
+
const SOURCE_MAP_ADAPTER_URL = SOURCE_MAP_ADAPTER ? url.pathToFileURL(path.resolve(SOURCE_MAP_ADAPTER)).href : null;
|
|
534
536
|
const SCRIPTS = path.join(dir, ${JSON.stringify(NODE_COVERAGE_SCRIPTS_SUBDIR)});
|
|
535
537
|
const MARKERS = path.join(dir, ${JSON.stringify(NODE_COVERAGE_MARKERS_SUBDIR)});
|
|
536
538
|
// V8 script ids identify a compiled script within this process. A URL
|
|
@@ -543,7 +545,7 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
543
545
|
fs.writeFileSync(path.join(dir, ".attached-" + (bunRuntime ? "bun" : "node")), "");
|
|
544
546
|
|
|
545
547
|
function isApp(u) {
|
|
546
|
-
return typeof u === "string" && u.startsWith("file://") && u.indexOf("/node_modules/") < 0 && u.indexOf("/.cache/") < 0 && u !== SELF;
|
|
548
|
+
return typeof u === "string" && u.startsWith("file://") && u.indexOf("/node_modules/") < 0 && u.indexOf("/.cache/") < 0 && u !== SELF && u !== SOURCE_MAP_ADAPTER_URL;
|
|
547
549
|
}
|
|
548
550
|
function isDep(u) {
|
|
549
551
|
return typeof u === "string" && u.startsWith("file://") && u.indexOf("/node_modules/") >= 0 && u !== SELF;
|
|
@@ -640,6 +642,10 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
640
642
|
} catch (e) {
|
|
641
643
|
return null;
|
|
642
644
|
}
|
|
645
|
+
// Framework policy runs before dependency attribution and before the final
|
|
646
|
+
// descriptor is hashed. The collector remains the sole owner of record IDs,
|
|
647
|
+
// deduplication, dump references and cleanup (including exit-time captures).
|
|
648
|
+
if (SOURCE_MAP_ADAPTER) require(SOURCE_MAP_ADAPTER).normalizeSourceMap(data, mapUrl ? url.fileURLToPath(mapUrl) : scriptPath);
|
|
643
649
|
// A source inside node_modules is a dependency: its unit id rides
|
|
644
650
|
// beside the map (\`sourceUnits[i]\`, null for the project's own), so
|
|
645
651
|
// the control plane never has to know what node_modules is.
|