@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.
@@ -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]]);
@@ -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
- if (process.env.NODE_V8_COVERAGE) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.59.3",
3
+ "version": "0.59.4",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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]]);
@@ -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
- if (process.env.NODE_V8_COVERAGE) {
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