@specific.dev/spectest 0.131.14 → 0.131.15

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.
@@ -9,7 +9,11 @@ export const NEXT_COVERAGE_HELPER_PATH = "/spectest/next-coverage.cjs";
9
9
  export const NEXT_COVERAGE_HELPER = String.raw `"use strict";
10
10
  const fs = require("node:fs"), path = require("node:path");
11
11
  const options = JSON.parse(process.env.SPECTEST_NEXT_COVERAGE_OPTIONS || "{}");
12
- const app = path.resolve(options.appDir || ".");
12
+ // Preloads run again in spawned Node processes. Carry the original runtime
13
+ // root into children instead of interpreting appDir against each child's cwd.
14
+ const root = process.env.SPECTEST_COVERAGE_ROOT || process.cwd();
15
+ process.env.SPECTEST_COVERAGE_ROOT = root;
16
+ const app = path.resolve(root, options.appDir || ".");
13
17
  const dist = path.resolve(app, options.distDir || ".next");
14
18
  function manifest() { return JSON.parse(fs.readFileSync(path.join(dist, "required-server-files.json"), "utf8")); }
15
19
  function launcher(m) {
@@ -17,7 +21,7 @@ function launcher(m) {
17
21
  if (m.config.output !== "standalone" || !fs.existsSync(file)) return null;
18
22
  const text = fs.readFileSync(file, "utf8");
19
23
  if (!text.includes("__NEXT_PRIVATE_STANDALONE_CONFIG") || !text.includes("next/dist/server/lib/start-server")) return null;
20
- return path.relative(process.cwd(), file);
24
+ return path.relative(root, file);
21
25
  }
22
26
  try {
23
27
  const file = launcher(manifest());
@@ -50,7 +54,8 @@ const GENERATED_SOURCES = [
50
54
  "*/data:* <text/javascript>", // Turbopack virtual modules, including action proxies.
51
55
  "*/.next-internal/server/app/*/actions.js (server actions loader)",
52
56
  "*/app/*--route-entry.js",
53
- "*/app/*.mjs (structured image object)",
57
+ "*/*.mjs (structured image object)",
58
+ "*/*.mjs (structured image object with data url)",
54
59
  ];
55
60
  /** Server + browser coverage for a built Next app. Build-time maps are required;
56
61
  * enable native Next source maps and retain them in the runtime image.
package/dist/coverage.js CHANGED
@@ -339,9 +339,13 @@ export const NODE_COVERAGE_HOOK = `"use strict";
339
339
  if (!bunRuntime) post("Debugger.enable", { maxScriptsCacheSize: 16 * 1024 * 1024 });
340
340
 
341
341
  const GENERATED = new Map();
342
+ // Generated names are service-root-relative unit identities, not paths
343
+ // relative to a wrapper's child process. Preserve that root through execs.
344
+ const generatedRoot = process.env.SPECTEST_COVERAGE_ROOT || process.cwd();
345
+ process.env.SPECTEST_COVERAGE_ROOT = generatedRoot;
342
346
  try {
343
347
  for (const file of JSON.parse(process.env.SPECTEST_COVERAGE_GENERATED_FILES || "[]")) {
344
- GENERATED.set(url.pathToFileURL(path.resolve(file)).href, file);
348
+ GENERATED.set(url.pathToFileURL(path.resolve(generatedRoot, file)).href, file);
345
349
  }
346
350
  } catch (e) {}
347
351
  const generatedUnits = new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.131.14",
3
+ "version": "0.131.15",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,6 @@
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
- import { applyCoverageAdapters, resolveCoverageSourceOptions, attachCoverageSourceOptions, node, browser, type CoverageSourceContext } from "./coverage.js";
3
+ import { applyCoverageAdapters, resolveCoverageSourceOptions, attachCoverageSourceOptions, node, browser, NODE_COVERAGE_HOOK, NODE_COVERAGE_SCRIPTS_SUBDIR, NODE_COVERAGE_MARKERS_SUBDIR, type CoverageSourceContext } from "./coverage.js";
4
4
  import { applyCoverageDelta, encodeCoverageBundle } from "./harness/coverage.js";
5
5
  import { gunzipSync } from "node:zlib";
6
6
  import { promises as fs } from "node:fs";
@@ -18,6 +18,64 @@ function context(): CoverageSourceContext {
18
18
  }
19
19
 
20
20
  describe("Next adapter", () => {
21
+ test.each(["next", "node"])("%s preserves generated unit identity through children with different working directories", async (kind) => {
22
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "next-child-cov-"));
23
+ try {
24
+ const app = path.join(dir, "ops"), reports = path.join(dir, "reports");
25
+ const helper = path.join(dir, "next-coverage.cjs"), hook = path.join(dir, "coverage-hook.cjs");
26
+ for (const folder of ["ops/.next/server", "ops/.next/static", "elsewhere", `reports/${NODE_COVERAGE_SCRIPTS_SUBDIR}`, `reports/${NODE_COVERAGE_MARKERS_SUBDIR}`]) {
27
+ await fs.mkdir(path.join(dir, folder), {recursive:true});
28
+ }
29
+ await fs.writeFile(helper, NEXT_COVERAGE_HELPER);
30
+ await fs.writeFile(hook, NODE_COVERAGE_HOOK);
31
+ await fs.writeFile(path.join(app, ".next/required-server-files.json"), JSON.stringify({config:{output:"standalone"}}));
32
+ for (const folder of ["server", "static"]) await fs.writeFile(path.join(app, `.next/${folder}/a.js.map`), "{}");
33
+ const launcher = '// __NEXT_PRIVATE_STANDALONE_CONFIG next/dist/server/lib/start-server\nrequire("./authored.cjs");\n';
34
+ await fs.writeFile(path.join(app, "server.js"), launcher);
35
+ await fs.writeFile(path.join(app, "authored.cjs"), "globalThis.applicationRan = true;\n");
36
+ await fs.writeFile(path.join(app, "bridge.cjs"), `
37
+ const {execFileSync} = require("node:child_process");
38
+ execFileSync(process.execPath, [${JSON.stringify(path.join(app,"server.js"))}], {cwd:${JSON.stringify(path.join(dir,"elsewhere"))}, stdio:"inherit"});
39
+ `);
40
+ await fs.writeFile(path.join(dir, "wrapper.cjs"), `
41
+ const {execFileSync} = require("node:child_process");
42
+ execFileSync(process.execPath, [${JSON.stringify(path.join(app,"bridge.cjs"))}], {cwd:${JSON.stringify(app)}, stdio:"inherit"});
43
+ `);
44
+ const adapter = kind === "next" ? next({appDir:"ops"}) : node({generated:["ops/server.js"]});
45
+ const configured = applyCoverageAdapters("web", {image, coverage:{adapters:[adapter]}});
46
+ const env = {...process.env, ...configured.env,
47
+ SPECTEST_COVERAGE_DIR:reports,
48
+ NODE_OPTIONS: `${kind === "next" ? `--require ${helper} ` : ""}--require ${hook}`,
49
+ } as NodeJS.ProcessEnv;
50
+ const ctx: CoverageSourceContext = {
51
+ service:"web", signal:new AbortController().signal,
52
+ projectFiles:async()=>[], readProjectFile:async()=>null,
53
+ exec:async command=>({stdout:execFileSync("sh", ["-c", command.replaceAll(NEXT_COVERAGE_HELPER_PATH, helper)], {cwd:dir,env,stdio:["ignore","pipe","pipe"]}).toString(),stderr:""}),
54
+ };
55
+ const inventory = (await adapter.inventory!(ctx)).filter(unit=>unit.startsWith("generated:"));
56
+ expect(inventory).toHaveLength(1);
57
+ expect(inventory[0]).toMatch(/^generated:web:ops\/server.js#[a-f0-9]{64}$/);
58
+ execFileSync("node", [path.join(dir,"wrapper.cjs")], {cwd:dir,env,stdio:["ignore","pipe","pipe"]});
59
+ const dumps = await Promise.all((await fs.readdir(reports)).filter(n=>/^node-.*\.json$/.test(n)).map(async n=>JSON.parse(await fs.readFile(path.join(reports,n),"utf8"))));
60
+ const urls = dumps.flatMap(d=>d.result.map((s:{url:string})=>s.url));
61
+ const units = new Set(dumps.flatMap(d=>d.units ?? []).filter((unit:string)=>unit.startsWith("generated:")));
62
+ expect([...units]).toEqual(inventory);
63
+ expect(urls.some(url=>url.endsWith("/ops/server.js"))).toBe(false);
64
+ expect(urls.some(url=>url.endsWith("/ops/authored.cjs"))).toBe(true);
65
+ expect(urls.some(url=>url.endsWith("/wrapper.cjs"))).toBe(true);
66
+ } finally { await fs.rm(dir, {recursive:true,force:true}); }
67
+ });
68
+
69
+ test("image wrapper exclusions cover monorepo public assets and are archived on both report types", async () => {
70
+ const policies = await resolveCoverageSourceOptions([next({appDir:"ops"})], context());
71
+ const capture = attachCoverageSourceOptions({reports:[{name:"node-1.json"},{name:"browser.lcov"}]}, policies);
72
+ for (const report of capture.reports) {
73
+ expect(report.sourceOptions!.excludeSources).toContain("*/*.mjs (structured image object)");
74
+ expect(report.sourceOptions!.excludeSources).toContain("*/*.mjs (structured image object with data url)");
75
+ expect(report.sourceOptions!.excludeSources).not.toContain("*/public/*");
76
+ }
77
+ });
78
+
21
79
  test("one adapter configures both collectors, uses its declared source root, and owns only its reports", async () => {
22
80
  const adapter = next({sourceRoot:"packages"});
23
81
  const configured = applyCoverageAdapters("web", {image,coverage:{adapters:[adapter]}});
@@ -21,7 +21,11 @@ export const NEXT_COVERAGE_HELPER_PATH = "/spectest/next-coverage.cjs";
21
21
  export const NEXT_COVERAGE_HELPER = String.raw`"use strict";
22
22
  const fs = require("node:fs"), path = require("node:path");
23
23
  const options = JSON.parse(process.env.SPECTEST_NEXT_COVERAGE_OPTIONS || "{}");
24
- const app = path.resolve(options.appDir || ".");
24
+ // Preloads run again in spawned Node processes. Carry the original runtime
25
+ // root into children instead of interpreting appDir against each child's cwd.
26
+ const root = process.env.SPECTEST_COVERAGE_ROOT || process.cwd();
27
+ process.env.SPECTEST_COVERAGE_ROOT = root;
28
+ const app = path.resolve(root, options.appDir || ".");
25
29
  const dist = path.resolve(app, options.distDir || ".next");
26
30
  function manifest() { return JSON.parse(fs.readFileSync(path.join(dist, "required-server-files.json"), "utf8")); }
27
31
  function launcher(m) {
@@ -29,7 +33,7 @@ function launcher(m) {
29
33
  if (m.config.output !== "standalone" || !fs.existsSync(file)) return null;
30
34
  const text = fs.readFileSync(file, "utf8");
31
35
  if (!text.includes("__NEXT_PRIVATE_STANDALONE_CONFIG") || !text.includes("next/dist/server/lib/start-server")) return null;
32
- return path.relative(process.cwd(), file);
36
+ return path.relative(root, file);
33
37
  }
34
38
  try {
35
39
  const file = launcher(manifest());
@@ -69,7 +73,8 @@ const GENERATED_SOURCES = [
69
73
  "*/data:* <text/javascript>", // Turbopack virtual modules, including action proxies.
70
74
  "*/.next-internal/server/app/*/actions.js (server actions loader)",
71
75
  "*/app/*--route-entry.js",
72
- "*/app/*.mjs (structured image object)",
76
+ "*/*.mjs (structured image object)",
77
+ "*/*.mjs (structured image object with data url)",
73
78
  ];
74
79
 
75
80
  /** Server + browser coverage for a built Next app. Build-time maps are required;
@@ -246,7 +246,9 @@ const hasNode = await new Promise<boolean>((resolve) => {
246
246
  p.on("exit", (code) => resolve(code === 0));
247
247
  });
248
248
 
249
- const SERVER_JS = "function tick() { return 1 }\nsetInterval(tick, 5); process.stdout.write('up\\n');\n";
249
+ // Readiness must follow the first tick; otherwise a fast capture can win the
250
+ // race against the timer and correctly report that tick has not run yet.
251
+ const SERVER_JS = "function tick() { return 1 }\ntick(); setInterval(tick, 5); process.stdout.write('up\\n');\n";
250
252
  // A wrapper that spawns the real server as a child node process and
251
253
  // waits on it — what `pnpm exec`, `npm run` and the `tsx` binary do.
252
254
  // It takes its child down with it on SIGTERM, so the test leaves no
@@ -334,7 +336,7 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
334
336
  expect(fns.every((f) => f.some((_, i) => i % 3 === 2 && f[i]! > 0))).toBe(true);
335
337
  const rec = (await records(dir)).get(`file://${path.join(dir, "server.js")}`)!;
336
338
  expect(ranNames(dump, rec).sort()).toEqual(["", "tick"]);
337
- expect(rec.lineLengths).toEqual([28, 51, 0]);
339
+ expect(rec.lineLengths).toEqual([28, 59, 0]);
338
340
  expect(rec.functions.map((f) => f[0])).toEqual(["", "tick"]);
339
341
  expect(rec.sourceMap).toBeNull();
340
342
  expect(dump.result[0]!.spectestScript).toBeDefined();
package/src/coverage.ts CHANGED
@@ -517,9 +517,13 @@ export const NODE_COVERAGE_HOOK = `"use strict";
517
517
  if (!bunRuntime) post("Debugger.enable", { maxScriptsCacheSize: 16 * 1024 * 1024 });
518
518
 
519
519
  const GENERATED = new Map();
520
+ // Generated names are service-root-relative unit identities, not paths
521
+ // relative to a wrapper's child process. Preserve that root through execs.
522
+ const generatedRoot = process.env.SPECTEST_COVERAGE_ROOT || process.cwd();
523
+ process.env.SPECTEST_COVERAGE_ROOT = generatedRoot;
520
524
  try {
521
525
  for (const file of JSON.parse(process.env.SPECTEST_COVERAGE_GENERATED_FILES || "[]")) {
522
- GENERATED.set(url.pathToFileURL(path.resolve(file)).href, file);
526
+ GENERATED.set(url.pathToFileURL(path.resolve(generatedRoot, file)).href, file);
523
527
  }
524
528
  } catch (e) {}
525
529
  const generatedUnits = new Map();