@specific.dev/spectest 0.59.3 → 0.59.4
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/browser-coverage.js +3 -1
- package/dist/coverage.d.ts +4 -0
- package/dist/coverage.js +12 -1
- package/dist/harness/browser-coverage.d.ts +8 -0
- package/dist/harness/browser-coverage.js +18 -1
- package/package.json +1 -1
- package/src/browser-coverage.ts +2 -0
- package/src/coverage.test.ts +29 -0
- package/src/coverage.ts +12 -1
- package/src/harness/browser-coverage.test.ts +17 -0
- package/src/harness/browser-coverage.ts +19 -1
package/dist/browser-coverage.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// project with no `coverage: { browser: true }` service never attaches
|
|
20
20
|
// anything, so it costs nothing.
|
|
21
21
|
import { createHash } from "node:crypto";
|
|
22
|
-
import { decodeSourceMap, lcovDocument, mapToOriginal, mergeFileCounts, rangesToLineCounts, } from "./harness/browser-coverage.js";
|
|
22
|
+
import { decodeSourceMap, isDocumentScriptPath, lcovDocument, mapToOriginal, mergeFileCounts, rangesToLineCounts, } from "./harness/browser-coverage.js";
|
|
23
23
|
import { rawFetch } from "./harness/raw-fetch.js";
|
|
24
24
|
let CONFIG = null;
|
|
25
25
|
/** Set (or clear, with `null`) at /load. */
|
|
@@ -106,6 +106,8 @@ async function harvestInner(c, config) {
|
|
|
106
106
|
continue;
|
|
107
107
|
if (info.map === undefined)
|
|
108
108
|
info.map = await sourceMapFor(info, url);
|
|
109
|
+
if (!info.map && isDocumentScriptPath(url.pathname))
|
|
110
|
+
continue; // inline script in a page
|
|
109
111
|
const files = info.map
|
|
110
112
|
? mapToOriginal(info.map, generated)
|
|
111
113
|
: new Map([[url.pathname, generated]]);
|
package/dist/coverage.d.ts
CHANGED
|
@@ -99,6 +99,10 @@ export declare function appendEnvFlag(env: Readonly<Record<string, string>> | un
|
|
|
99
99
|
* wrapper dumped the wrapper and silently never the server (reported by
|
|
100
100
|
* a user 2026-08-27). At capture spectest asks every live socket and
|
|
101
101
|
* unlinks the stale ones. Unref'd, so a short-lived process still exits.
|
|
102
|
+
* Main thread only: a worker thread inherits `NODE_OPTIONS`, and tsx's
|
|
103
|
+
* ESM loader thread bound the shared per-pid path last, so every
|
|
104
|
+
* `--import tsx` server answered with the loader's coverage — the app's
|
|
105
|
+
* main thread was never dumped (found in the same user's first full run).
|
|
102
106
|
* No signal is used: signals are claimed by frameworks (SIGUSR2 stops a
|
|
103
107
|
* Temporal worker, restarts nodemon), a socket is nobody's.
|
|
104
108
|
*/
|
package/dist/coverage.js
CHANGED
|
@@ -109,12 +109,20 @@ export function appendEnvFlag(env, name, value) {
|
|
|
109
109
|
* wrapper dumped the wrapper and silently never the server (reported by
|
|
110
110
|
* a user 2026-08-27). At capture spectest asks every live socket and
|
|
111
111
|
* unlinks the stale ones. Unref'd, so a short-lived process still exits.
|
|
112
|
+
* Main thread only: a worker thread inherits `NODE_OPTIONS`, and tsx's
|
|
113
|
+
* ESM loader thread bound the shared per-pid path last, so every
|
|
114
|
+
* `--import tsx` server answered with the loader's coverage — the app's
|
|
115
|
+
* main thread was never dumped (found in the same user's first full run).
|
|
112
116
|
* No signal is used: signals are claimed by frameworks (SIGUSR2 stops a
|
|
113
117
|
* Temporal worker, restarts nodemon), a socket is nobody's.
|
|
114
118
|
*/
|
|
115
119
|
export const NODE_COVERAGE_HOOK = `"use strict";
|
|
116
120
|
// spectest coverage hook (coverage.node() adapter). See \`spectest docs /services/coverage\`.
|
|
117
|
-
|
|
121
|
+
// Main thread only: worker threads inherit NODE_OPTIONS and would bind
|
|
122
|
+
// the same per-pid socket last — tsx's ESM loader runs on one, and it
|
|
123
|
+
// stole the socket from every \`--import tsx\` server, so a dump was the
|
|
124
|
+
// loader's isolate and never the app's.
|
|
125
|
+
if (process.env.NODE_V8_COVERAGE && require("node:worker_threads").isMainThread) {
|
|
118
126
|
const net = require("node:net");
|
|
119
127
|
const fs = require("node:fs");
|
|
120
128
|
const v8 = require("node:v8");
|
|
@@ -259,6 +267,9 @@ const COMPACTED_V8_REPORTS = new Set();
|
|
|
259
267
|
export function isAppScriptUrl(url) {
|
|
260
268
|
return (url.startsWith("file://") &&
|
|
261
269
|
!url.includes("/node_modules/") &&
|
|
270
|
+
// Package managers run from a cache, not node_modules: corepack's pnpm
|
|
271
|
+
// is `/root/.cache/node/corepack/…`, 2 MiB of dump per `pnpm run`.
|
|
272
|
+
!url.includes("/.cache/") &&
|
|
262
273
|
!url.endsWith("/" + NODE_COVERAGE_HOOK_PATH.split("/").pop()));
|
|
263
274
|
}
|
|
264
275
|
/**
|
|
@@ -51,6 +51,14 @@ export declare function normalizeSourcePath(src: string, sourceRoot?: string): s
|
|
|
51
51
|
/** Sources nobody's change can be attributed to: dependencies and bundler
|
|
52
52
|
* runtime shims. */
|
|
53
53
|
export declare function isForeignSource(path: string): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* A script with no source map is attributed to its served path — unless
|
|
56
|
+
* that path is a page, not a file: an inline `<script>` in an HTML
|
|
57
|
+
* document reports the document's URL (`/`, `/projects/123`), and no
|
|
58
|
+
* repository file can ever be named by it. Extension-less paths are
|
|
59
|
+
* treated as pages.
|
|
60
|
+
*/
|
|
61
|
+
export declare function isDocumentScriptPath(pathname: string): boolean;
|
|
54
62
|
/**
|
|
55
63
|
* Decode a source map document. Index maps (`sections`) are not
|
|
56
64
|
* supported and decode to `null`, as does anything malformed — the
|
|
@@ -104,6 +104,10 @@ export function normalizeSourcePath(src, sourceRoot) {
|
|
|
104
104
|
s = m[1];
|
|
105
105
|
s = s.replace(/^\/@fs\//, "/");
|
|
106
106
|
s = s.replace(/\?.*$/, "");
|
|
107
|
+
// Turbopack (Next.js) roots every source at the `[project]` token —
|
|
108
|
+
// `/[project]/dashboard/app/page.tsx` — which is the workspace root,
|
|
109
|
+
// i.e. repo-relative once stripped.
|
|
110
|
+
s = s.replace(/^\/?\[project\]\//, "");
|
|
107
111
|
while (s.startsWith("./"))
|
|
108
112
|
s = s.slice(2);
|
|
109
113
|
return s;
|
|
@@ -117,7 +121,20 @@ export function isForeignSource(path) {
|
|
|
117
121
|
path.startsWith("(webpack)") ||
|
|
118
122
|
path.startsWith("\0") ||
|
|
119
123
|
path.startsWith("vite/") ||
|
|
120
|
-
path.startsWith("@vite/")
|
|
124
|
+
path.startsWith("@vite/") ||
|
|
125
|
+
path.startsWith("[turbopack]/") ||
|
|
126
|
+
path.startsWith("/[turbopack]/"));
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* A script with no source map is attributed to its served path — unless
|
|
130
|
+
* that path is a page, not a file: an inline `<script>` in an HTML
|
|
131
|
+
* document reports the document's URL (`/`, `/projects/123`), and no
|
|
132
|
+
* repository file can ever be named by it. Extension-less paths are
|
|
133
|
+
* treated as pages.
|
|
134
|
+
*/
|
|
135
|
+
export function isDocumentScriptPath(pathname) {
|
|
136
|
+
const last = pathname.split("/").pop() ?? "";
|
|
137
|
+
return !last.includes(".");
|
|
121
138
|
}
|
|
122
139
|
/**
|
|
123
140
|
* Decode a source map document. Index maps (`sections`) are not
|
package/package.json
CHANGED
package/src/browser-coverage.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type DecodedSourceMap,
|
|
26
26
|
type FileCounts,
|
|
27
27
|
decodeSourceMap,
|
|
28
|
+
isDocumentScriptPath,
|
|
28
29
|
lcovDocument,
|
|
29
30
|
mapToOriginal,
|
|
30
31
|
mergeFileCounts,
|
|
@@ -137,6 +138,7 @@ async function harvestInner(c: Collector, config: BrowserCoverageConfig): Promis
|
|
|
137
138
|
const generated = rangesToLineCounts(info.source, script.functions);
|
|
138
139
|
if (generated.size === 0) continue;
|
|
139
140
|
if (info.map === undefined) info.map = await sourceMapFor(info, url);
|
|
141
|
+
if (!info.map && isDocumentScriptPath(url.pathname)) continue; // inline script in a page
|
|
140
142
|
const files: FileCounts = info.map
|
|
141
143
|
? mapToOriginal(info.map, generated)
|
|
142
144
|
: new Map([[url.pathname, generated]]);
|
package/src/coverage.test.ts
CHANGED
|
@@ -77,6 +77,7 @@ describe("compactV8Document", () => {
|
|
|
77
77
|
{ scriptId: "1", url: "file:///app/src/a.ts", functions: [] },
|
|
78
78
|
{ scriptId: "2", url: "file:///app/node_modules/x/index.js", functions: [] },
|
|
79
79
|
{ scriptId: "3", url: "node:fs", functions: [] },
|
|
80
|
+
{ scriptId: "5", url: "file:///root/.cache/node/corepack/v1/pnpm/10.0.0/dist/pnpm.cjs", functions: [] },
|
|
80
81
|
{ scriptId: "4", url: "", functions: [] },
|
|
81
82
|
],
|
|
82
83
|
"source-map-cache": {
|
|
@@ -256,6 +257,34 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
|
|
|
256
257
|
expect(doc.result.map((x) => x.url)).toEqual([`file://${path.join(dir, "cli.js")}`]); // compacted
|
|
257
258
|
});
|
|
258
259
|
|
|
260
|
+
test("a worker thread does not bind the socket; the main thread's dump is the app's", async () => {
|
|
261
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
|
|
262
|
+
const hook = path.join(dir, "coverage-hook.cjs");
|
|
263
|
+
await fs.writeFile(hook, NODE_COVERAGE_HOOK);
|
|
264
|
+
await fs.writeFile(
|
|
265
|
+
path.join(dir, "server.js"),
|
|
266
|
+
"function appWork() { return 1 } appWork();\n" +
|
|
267
|
+
"new (require('node:worker_threads').Worker)('setInterval(() => {}, 1000)', { eval: true });\n" +
|
|
268
|
+
SERVER_JS,
|
|
269
|
+
);
|
|
270
|
+
const child = spawn("node", [path.join(dir, "server.js")], {
|
|
271
|
+
env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
|
|
272
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
273
|
+
});
|
|
274
|
+
await new Promise<void>((resolve) => child.stdout!.once("data", () => resolve()));
|
|
275
|
+
try {
|
|
276
|
+
await new Promise((r) => setTimeout(r, 300)); // let the worker thread start
|
|
277
|
+
expect((await socketsIn(dir)).length).toBe(1);
|
|
278
|
+
await node().capture!(fakeCtx(dir));
|
|
279
|
+
const reports = (await reportsIn(dir)).filter((n) => n !== NODE_COVERAGE_EMPTY_REPORT);
|
|
280
|
+
expect(reports.length).toBe(1);
|
|
281
|
+
const doc = JSON.parse(await fs.readFile(path.join(dir, reports[0]!), "utf8")) as { result: { url: string }[] };
|
|
282
|
+
expect(doc.result.some((s) => s.url.endsWith("/server.js"))).toBe(true);
|
|
283
|
+
} finally {
|
|
284
|
+
child.kill("SIGKILL");
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
259
288
|
test("a stale socket left by a killed process is removed at capture", async () => {
|
|
260
289
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
|
|
261
290
|
const first = await start(dir, "server.js", 1);
|
package/src/coverage.ts
CHANGED
|
@@ -193,12 +193,20 @@ export function appendEnvFlag(
|
|
|
193
193
|
* wrapper dumped the wrapper and silently never the server (reported by
|
|
194
194
|
* a user 2026-08-27). At capture spectest asks every live socket and
|
|
195
195
|
* unlinks the stale ones. Unref'd, so a short-lived process still exits.
|
|
196
|
+
* Main thread only: a worker thread inherits `NODE_OPTIONS`, and tsx's
|
|
197
|
+
* ESM loader thread bound the shared per-pid path last, so every
|
|
198
|
+
* `--import tsx` server answered with the loader's coverage — the app's
|
|
199
|
+
* main thread was never dumped (found in the same user's first full run).
|
|
196
200
|
* No signal is used: signals are claimed by frameworks (SIGUSR2 stops a
|
|
197
201
|
* Temporal worker, restarts nodemon), a socket is nobody's.
|
|
198
202
|
*/
|
|
199
203
|
export const NODE_COVERAGE_HOOK = `"use strict";
|
|
200
204
|
// spectest coverage hook (coverage.node() adapter). See \`spectest docs /services/coverage\`.
|
|
201
|
-
|
|
205
|
+
// Main thread only: worker threads inherit NODE_OPTIONS and would bind
|
|
206
|
+
// the same per-pid socket last — tsx's ESM loader runs on one, and it
|
|
207
|
+
// stole the socket from every \`--import tsx\` server, so a dump was the
|
|
208
|
+
// loader's isolate and never the app's.
|
|
209
|
+
if (process.env.NODE_V8_COVERAGE && require("node:worker_threads").isMainThread) {
|
|
202
210
|
const net = require("node:net");
|
|
203
211
|
const fs = require("node:fs");
|
|
204
212
|
const v8 = require("node:v8");
|
|
@@ -345,6 +353,9 @@ export function isAppScriptUrl(url: string): boolean {
|
|
|
345
353
|
return (
|
|
346
354
|
url.startsWith("file://") &&
|
|
347
355
|
!url.includes("/node_modules/") &&
|
|
356
|
+
// Package managers run from a cache, not node_modules: corepack's pnpm
|
|
357
|
+
// is `/root/.cache/node/corepack/…`, 2 MiB of dump per `pnpm run`.
|
|
358
|
+
!url.includes("/.cache/") &&
|
|
348
359
|
!url.endsWith("/" + NODE_COVERAGE_HOOK_PATH.split("/").pop())
|
|
349
360
|
);
|
|
350
361
|
}
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import {
|
|
3
3
|
decodeSourceMap,
|
|
4
4
|
decodeVlq,
|
|
5
|
+
isDocumentScriptPath,
|
|
5
6
|
isForeignSource,
|
|
6
7
|
lcovDocument,
|
|
7
8
|
mapToOriginal,
|
|
@@ -92,3 +93,19 @@ describe("serviceForHost", () => {
|
|
|
92
93
|
expect(serviceForHost("example.com", services, table)).toBeUndefined();
|
|
93
94
|
});
|
|
94
95
|
});
|
|
96
|
+
|
|
97
|
+
describe("turbopack + inline scripts", () => {
|
|
98
|
+
test("[project] root token is stripped, [turbopack] shims are foreign", () => {
|
|
99
|
+
expect(normalizeSourcePath("/[project]/dashboard/app/page.tsx")).toBe("dashboard/app/page.tsx");
|
|
100
|
+
expect(normalizeSourcePath("[project]/dashboard/app/page.tsx")).toBe("dashboard/app/page.tsx");
|
|
101
|
+
expect(isForeignSource("/[turbopack]/browser/runtime/base/build-base.ts")).toBe(true);
|
|
102
|
+
expect(isForeignSource("[turbopack]/shared/runtime-utils.ts")).toBe(true);
|
|
103
|
+
expect(isForeignSource("dashboard/app/page.tsx")).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
test("an unmapped script at a page URL is a document script, a .js file is not", () => {
|
|
106
|
+
expect(isDocumentScriptPath("/")).toBe(true);
|
|
107
|
+
expect(isDocumentScriptPath("/projects/proj_1")).toBe(true);
|
|
108
|
+
expect(isDocumentScriptPath("/_next/static/chunks/main.js")).toBe(false);
|
|
109
|
+
expect(isDocumentScriptPath("/app.js")).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -136,6 +136,10 @@ export function normalizeSourcePath(src: string, sourceRoot?: string): string {
|
|
|
136
136
|
if (m) s = m[1];
|
|
137
137
|
s = s.replace(/^\/@fs\//, "/");
|
|
138
138
|
s = s.replace(/\?.*$/, "");
|
|
139
|
+
// Turbopack (Next.js) roots every source at the `[project]` token —
|
|
140
|
+
// `/[project]/dashboard/app/page.tsx` — which is the workspace root,
|
|
141
|
+
// i.e. repo-relative once stripped.
|
|
142
|
+
s = s.replace(/^\/?\[project\]\//, "");
|
|
139
143
|
while (s.startsWith("./")) s = s.slice(2);
|
|
140
144
|
return s;
|
|
141
145
|
}
|
|
@@ -150,10 +154,24 @@ export function isForeignSource(path: string): boolean {
|
|
|
150
154
|
path.startsWith("(webpack)") ||
|
|
151
155
|
path.startsWith("\0") ||
|
|
152
156
|
path.startsWith("vite/") ||
|
|
153
|
-
path.startsWith("@vite/")
|
|
157
|
+
path.startsWith("@vite/") ||
|
|
158
|
+
path.startsWith("[turbopack]/") ||
|
|
159
|
+
path.startsWith("/[turbopack]/")
|
|
154
160
|
);
|
|
155
161
|
}
|
|
156
162
|
|
|
163
|
+
/**
|
|
164
|
+
* A script with no source map is attributed to its served path — unless
|
|
165
|
+
* that path is a page, not a file: an inline `<script>` in an HTML
|
|
166
|
+
* document reports the document's URL (`/`, `/projects/123`), and no
|
|
167
|
+
* repository file can ever be named by it. Extension-less paths are
|
|
168
|
+
* treated as pages.
|
|
169
|
+
*/
|
|
170
|
+
export function isDocumentScriptPath(pathname: string): boolean {
|
|
171
|
+
const last = pathname.split("/").pop() ?? "";
|
|
172
|
+
return !last.includes(".");
|
|
173
|
+
}
|
|
174
|
+
|
|
157
175
|
/**
|
|
158
176
|
* Decode a source map document. Index maps (`sections`) are not
|
|
159
177
|
* supported and decode to `null`, as does anything malformed — the
|