@specific.dev/spectest 0.59.3 → 0.60.0

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/daemon.js CHANGED
@@ -22,9 +22,9 @@ import net from "node:net";
22
22
  import path from "node:path";
23
23
  import { pathToFileURL } from "node:url";
24
24
  import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, proxy as makeProxyDecl, } from "./index.js";
25
- import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, } from "./harness/coverage.js";
25
+ import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, applyCoverageDelta, } from "./harness/coverage.js";
26
26
  import { configureBrowserCoverage } from "./browser-coverage.js";
27
- import { applyCoverageAdapters, coverageAdapters, validateCoverage, } from "./coverage.js";
27
+ import { applyCoverageAdapters, coverageAdapters, coverageReportsMode, validateCoverage, nodeCoverageToolsAvailable, nodeCoverageToolsDir, NODE_COVERAGE_TOOLS_CONTAINER_DIR, } from "./coverage.js";
28
28
  import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
29
29
  import { acquirePersistentBrowser, mobileKey } from "./browser.js";
30
30
  import { isMobileApp, openPersistentMobile } from "./mobile.js";
@@ -529,7 +529,16 @@ async function ensureCoverage(svc) {
529
529
  const host = coverageHostDir(WORKSPACE, svc.name);
530
530
  await fs.mkdir(host, { recursive: true });
531
531
  await fs.chmod(host, 0o777);
532
- return [`--volume=${host}:${COVERAGE_CONTAINER_DIR}`];
532
+ const flags = [`--volume=${host}:${COVERAGE_CONTAINER_DIR}`];
533
+ // The conversion tools golden ships (`c8` for `coverage.node()`),
534
+ // read-only, on the same existence check the adapter makes at capture
535
+ // — so the VM and the container agree on whether they are there. A
536
+ // plain `--volume`, never a config `volumes` entry: an absolute-source
537
+ // volume dir is listed in the delta-restore manifest and wiped.
538
+ if (await nodeCoverageToolsAvailable()) {
539
+ flags.push(`--volume=${nodeCoverageToolsDir()}:${NODE_COVERAGE_TOOLS_CONTAINER_DIR}:ro`);
540
+ }
541
+ return flags;
533
542
  }
534
543
  /** Keyed by image ID, not tag: `spectest/<svc>:latest` is retagged onto
535
544
  * new content every rebuild, and a stale uid is silently wrong. */
@@ -3037,8 +3046,10 @@ function rebuildBrowserCoverageResolver() {
3037
3046
  },
3038
3047
  });
3039
3048
  }
3040
- /** The context one adapter's `capture` runs against. */
3041
- function coverageCaptureContext(svc, signal) {
3049
+ /** The context one adapter's `capture` runs against. Every report it
3050
+ * writes through `writeReport` is added to `written`, so the delta step
3051
+ * knows which files came from which adapter. */
3052
+ function coverageCaptureContext(svc, signal, written) {
3042
3053
  const reportDir = coverageHostDir(WORKSPACE, svc.name);
3043
3054
  return {
3044
3055
  service: svc.name,
@@ -3061,20 +3072,25 @@ function coverageCaptureContext(svc, signal) {
3061
3072
  const tmp = path.join(path.dirname(file), `.${path.basename(file)}.tmp`);
3062
3073
  await fs.writeFile(tmp, content);
3063
3074
  await fs.rename(tmp, file);
3075
+ written.add(path.relative(reportDir, file));
3064
3076
  },
3065
3077
  };
3066
3078
  }
3067
3079
  /** Run one service's adapters in order under one budget. Returns the
3068
- * error to fail the capture with, naming the adapter. */
3080
+ * error to fail the capture with, naming the adapter, and the reports
3081
+ * written by adapters that declare `reports: "delta"`. */
3069
3082
  async function runCoverageAdapters(svc) {
3083
+ const deltaReports = new Set();
3070
3084
  const adapters = coverageAdapters(svc.coverage).filter((a) => a.capture);
3071
3085
  if (adapters.length === 0)
3072
- return undefined;
3086
+ return { deltaReports };
3073
3087
  const ac = new AbortController();
3074
3088
  const timer = setTimeout(() => ac.abort(), COVERAGE_CAPTURE_TIMEOUT_MS);
3075
- const ctx = coverageCaptureContext(svc, ac.signal);
3089
+ const written = new Set();
3090
+ const ctx = coverageCaptureContext(svc, ac.signal, written);
3076
3091
  try {
3077
3092
  for (const a of adapters) {
3093
+ written.clear();
3078
3094
  try {
3079
3095
  await Promise.race([
3080
3096
  a.capture(ctx),
@@ -3084,10 +3100,16 @@ async function runCoverageAdapters(svc) {
3084
3100
  ]);
3085
3101
  }
3086
3102
  catch (err) {
3087
- return `coverage adapter "${a.name}" on service "${svc.name}" failed: ${err instanceof Error ? err.message : String(err)}`;
3103
+ return {
3104
+ error: `coverage adapter "${a.name}" on service "${svc.name}" failed: ${err instanceof Error ? err.message : String(err)}`,
3105
+ deltaReports,
3106
+ };
3088
3107
  }
3108
+ if (a.reports === "delta")
3109
+ for (const n of written)
3110
+ deltaReports.add(n);
3089
3111
  }
3090
- return undefined;
3112
+ return { deltaReports };
3091
3113
  }
3092
3114
  finally {
3093
3115
  clearTimeout(timer);
@@ -3104,10 +3126,15 @@ async function captureServiceCoverage() {
3104
3126
  byName.set(name, s);
3105
3127
  const services = [...byName.values()].filter((s) => s.coverage !== undefined);
3106
3128
  return Promise.all(services.map(async (svc) => {
3107
- const error = await runCoverageAdapters(svc);
3129
+ const { error, deltaReports } = await runCoverageAdapters(svc);
3108
3130
  if (error)
3109
3131
  return { service: svc.name, reports: [], error };
3110
- return readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
3132
+ const read = await readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
3133
+ // What ships is what ran during this capture, whatever the tool
3134
+ // counts: cumulative lcov is subtracted against the previous
3135
+ // capture (module memory, forks with the env); a delta adapter's
3136
+ // files and V8 JSON pass through.
3137
+ return applyCoverageDelta(read, coverageReportsMode(svc.coverage), deltaReports);
3111
3138
  }));
3112
3139
  }
3113
3140
  /**
@@ -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
@@ -77,3 +77,38 @@ export declare function readCoverageDir(service: string, dir: string): Promise<S
77
77
  export declare function encodeCoverageBundle(caseId: string, services: ServiceCoverageCapture[]): Buffer;
78
78
  /** The ref for a bundle: its size plus a per-service summary. */
79
79
  export declare function coverageBundleRef(gz: Buffer, services: ServiceCoverageCapture[]): CoverageBundleRef;
80
+ /** How a report's counters behave across captures. */
81
+ export type CoverageReportsMode = "delta" | "cumulative";
82
+ /** One lcov record's execution counts: `DA:` by line, `FNDA:` by function
83
+ * name. Branch data is dropped: file and line are what the derivation
84
+ * reads. */
85
+ export interface LcovRecordCounts {
86
+ lines: Map<number, number>;
87
+ functions: Map<string, number>;
88
+ /** `FN:<line>,<name>` declarations, kept so the delta names them. */
89
+ functionLines: Map<string, number>;
90
+ }
91
+ /** An lcov document parsed to counts, by `SF:` path. Records for one
92
+ * path are merged (a tool may split a file across records). */
93
+ export declare function parseLcovCounts(text: string): Map<string, LcovRecordCounts>;
94
+ /**
95
+ * `now` minus `prev`, per line and per function. A count that went
96
+ * *down* is a counter that restarted (the process was replaced): the
97
+ * new value stands on its own. A file not in `prev` is new: everything
98
+ * in it counts. Only records with at least one execution are kept — a
99
+ * file with nothing new in it did not run.
100
+ */
101
+ export declare function subtractLcovCounts(now: Map<string, LcovRecordCounts>, prev: Map<string, LcovRecordCounts>): Map<string, LcovRecordCounts>;
102
+ /** Counts rendered back to lcov, records in path order. */
103
+ export declare function renderLcovCounts(counts: Map<string, LcovRecordCounts>): string;
104
+ /** Forget every previous capture (tests). */
105
+ export declare function resetCoverageDeltaMemory(): void;
106
+ /**
107
+ * Turn a capture's cumulative lcov reports into deltas against the
108
+ * previous capture of the same service and report name, and remember
109
+ * this capture as the new previous. Reports named in `deltaReports`
110
+ * (written by an adapter that declares `reports: "delta"`) and
111
+ * non-lcov reports (V8 JSON is a delta by construction) pass through
112
+ * untouched. With `mode === "delta"` every report passes through.
113
+ */
114
+ export declare function applyCoverageDelta(capture: ServiceCoverageCapture, mode: CoverageReportsMode, deltaReports: ReadonlySet<string>): ServiceCoverageCapture;
@@ -15,6 +15,17 @@
15
15
  // stored bytes, so a change in that derivation is a re-read of stored
16
16
  // runs, never a hole in the history.
17
17
  //
18
+ // The one transformation done here is the **delta**: a stored report
19
+ // describes what ran between the previous capture and this one, for
20
+ // every source alike. A tool that resets its own counters (V8) writes
21
+ // deltas already; a cumulative tool (coverage.py, Go, JaCoCo — most)
22
+ // gets subtracted against the previous capture, which lives in module
23
+ // memory and so forks with the environment: a forked child's "previous"
24
+ // is its parent's last capture, exactly the state it started from. Which
25
+ // of the two a file is must be declared (an adapter's `reports`, or the
26
+ // service's) — nothing in the bytes tells a delta that happens to equal
27
+ // the previous one from a cumulative report with no new execution.
28
+ //
18
29
  // The container side (mount flag, `command` exec) is in `daemon.ts`.
19
30
  import { promises as fs } from "node:fs";
20
31
  import path from "node:path";
@@ -156,3 +167,149 @@ export function coverageBundleRef(gz, services) {
156
167
  })),
157
168
  };
158
169
  }
170
+ /** An lcov document parsed to counts, by `SF:` path. Records for one
171
+ * path are merged (a tool may split a file across records). */
172
+ export function parseLcovCounts(text) {
173
+ const out = new Map();
174
+ let cur = null;
175
+ for (const raw of text.split(/\r?\n/)) {
176
+ const line = raw.trim();
177
+ if (line.startsWith("SF:")) {
178
+ const file = line.slice(3).trim();
179
+ if (!file) {
180
+ cur = null;
181
+ continue;
182
+ }
183
+ cur = out.get(file) ?? { lines: new Map(), functions: new Map(), functionLines: new Map() };
184
+ out.set(file, cur);
185
+ }
186
+ else if (!cur) {
187
+ continue;
188
+ }
189
+ else if (line.startsWith("DA:")) {
190
+ const [ln, hits] = line.slice(3).split(",");
191
+ const l = Number(ln), h = Number(hits);
192
+ if (Number.isFinite(l) && Number.isFinite(h))
193
+ cur.lines.set(l, (cur.lines.get(l) ?? 0) + h);
194
+ }
195
+ else if (line.startsWith("FNDA:")) {
196
+ const i = line.indexOf(",", 5);
197
+ if (i < 0)
198
+ continue;
199
+ const h = Number(line.slice(5, i)), name = line.slice(i + 1);
200
+ if (Number.isFinite(h))
201
+ cur.functions.set(name, (cur.functions.get(name) ?? 0) + h);
202
+ }
203
+ else if (line.startsWith("FN:")) {
204
+ const i = line.indexOf(",", 3);
205
+ if (i < 0)
206
+ continue;
207
+ const l = Number(line.slice(3, i)), name = line.slice(i + 1);
208
+ if (Number.isFinite(l))
209
+ cur.functionLines.set(name, l);
210
+ }
211
+ else if (line === "end_of_record") {
212
+ cur = null;
213
+ }
214
+ }
215
+ return out;
216
+ }
217
+ /**
218
+ * `now` minus `prev`, per line and per function. A count that went
219
+ * *down* is a counter that restarted (the process was replaced): the
220
+ * new value stands on its own. A file not in `prev` is new: everything
221
+ * in it counts. Only records with at least one execution are kept — a
222
+ * file with nothing new in it did not run.
223
+ */
224
+ export function subtractLcovCounts(now, prev) {
225
+ const out = new Map();
226
+ for (const [file, rec] of now) {
227
+ const before = prev.get(file);
228
+ const lines = new Map();
229
+ const functions = new Map();
230
+ let executed = false;
231
+ for (const [l, h] of rec.lines) {
232
+ const b = before?.lines.get(l) ?? 0;
233
+ const d = h < b ? h : h - b;
234
+ lines.set(l, d);
235
+ if (d > 0)
236
+ executed = true;
237
+ }
238
+ for (const [name, h] of rec.functions) {
239
+ const b = before?.functions.get(name) ?? 0;
240
+ const d = h < b ? h : h - b;
241
+ functions.set(name, d);
242
+ if (d > 0)
243
+ executed = true;
244
+ }
245
+ // A record with no counts at all carries presence as its only
246
+ // signal; it is new only when it was not there before.
247
+ if (rec.lines.size === 0 && rec.functions.size === 0 && !before)
248
+ executed = true;
249
+ if (executed)
250
+ out.set(file, { lines, functions, functionLines: rec.functionLines });
251
+ }
252
+ return out;
253
+ }
254
+ /** Counts rendered back to lcov, records in path order. */
255
+ export function renderLcovCounts(counts) {
256
+ const parts = ["TN:"];
257
+ for (const file of [...counts.keys()].sort()) {
258
+ const rec = counts.get(file);
259
+ parts.push(`SF:${file}`);
260
+ for (const [name, l] of [...rec.functionLines].sort((a, b) => a[1] - b[1]))
261
+ parts.push(`FN:${l},${name}`);
262
+ let fnh = 0;
263
+ for (const [name, h] of rec.functions) {
264
+ parts.push(`FNDA:${h},${name}`);
265
+ if (h > 0)
266
+ fnh++;
267
+ }
268
+ parts.push(`FNF:${rec.functions.size}`, `FNH:${fnh}`);
269
+ let lh = 0;
270
+ for (const [l, h] of [...rec.lines].sort((a, b) => a[0] - b[0])) {
271
+ parts.push(`DA:${l},${h}`);
272
+ if (h > 0)
273
+ lh++;
274
+ }
275
+ parts.push(`LF:${rec.lines.size}`, `LH:${lh}`, "end_of_record");
276
+ }
277
+ return parts.join("\n") + "\n";
278
+ }
279
+ /** The previous capture's counts: service → report name → parsed lcov.
280
+ * Module memory, so it forks with the environment — a forked child's
281
+ * previous capture is its parent's last one. */
282
+ const PREVIOUS_CAPTURE = new Map();
283
+ /** Forget every previous capture (tests). */
284
+ export function resetCoverageDeltaMemory() {
285
+ PREVIOUS_CAPTURE.clear();
286
+ }
287
+ /**
288
+ * Turn a capture's cumulative lcov reports into deltas against the
289
+ * previous capture of the same service and report name, and remember
290
+ * this capture as the new previous. Reports named in `deltaReports`
291
+ * (written by an adapter that declares `reports: "delta"`) and
292
+ * non-lcov reports (V8 JSON is a delta by construction) pass through
293
+ * untouched. With `mode === "delta"` every report passes through.
294
+ */
295
+ export function applyCoverageDelta(capture, mode, deltaReports) {
296
+ if (capture.error)
297
+ return capture;
298
+ let prevByName = PREVIOUS_CAPTURE.get(capture.service);
299
+ if (!prevByName) {
300
+ prevByName = new Map();
301
+ PREVIOUS_CAPTURE.set(capture.service, prevByName);
302
+ }
303
+ const reports = [];
304
+ for (const r of capture.reports) {
305
+ if (mode === "delta" || deltaReports.has(r.name) || !isLcovShaped(r.content)) {
306
+ reports.push(r);
307
+ continue;
308
+ }
309
+ const now = parseLcovCounts(r.content);
310
+ const prev = prevByName.get(r.name) ?? new Map();
311
+ prevByName.set(r.name, now);
312
+ reports.push({ name: r.name, content: renderLcovCounts(subtractLcovCounts(now, prev)) });
313
+ }
314
+ return { ...capture, reports };
315
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.59.3",
3
+ "version": "0.60.0",
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,
@@ -67,6 +68,9 @@ interface Collector {
67
68
  }
68
69
 
69
70
  const COLLECTORS = new WeakMap<CDPSession, Collector>();
71
+ /** The collectors of every session attached so far, for the harvest at
72
+ * capture; a closed session's take fails and is dropped there. */
73
+ const LIVE_COLLECTORS = new Set<Collector>();
70
74
 
71
75
  /** Decoded maps by URL (or content hash for inline `data:` maps). */
72
76
  const SOURCE_MAPS = new Map<string, DecodedSourceMap | null>();
@@ -79,6 +83,7 @@ export async function attachBrowserCoverage(cdp: CDPSession): Promise<void> {
79
83
  if (!CONFIG || COLLECTORS.has(cdp)) return;
80
84
  const collector: Collector = { cdp, scripts: new Map(), inFlight: null };
81
85
  COLLECTORS.set(cdp, collector);
86
+ LIVE_COLLECTORS.add(collector);
82
87
  cdp.on("Debugger.scriptParsed", (ev: { scriptId: string; url: string; sourceMapURL?: string }) => {
83
88
  if (!ev.url) return;
84
89
  collector.scripts.set(ev.scriptId, {
@@ -108,9 +113,13 @@ export async function harvestBrowserCoverage(cdp: CDPSession): Promise<void> {
108
113
  }
109
114
 
110
115
  async function harvestInner(c: Collector, config: BrowserCoverageConfig): Promise<void> {
111
- const { result } = (await c.cdp.send("Profiler.takePreciseCoverage")) as {
112
- result: { scriptId: string; url: string; functions: V8FunctionCoverage[] }[];
113
- };
116
+ let result: { scriptId: string; url: string; functions: V8FunctionCoverage[] }[];
117
+ try {
118
+ ({ result } = (await c.cdp.send("Profiler.takePreciseCoverage")) as { result: typeof result });
119
+ } catch (err) {
120
+ LIVE_COLLECTORS.delete(c); // the session is gone
121
+ throw err;
122
+ }
114
123
  for (const script of result) {
115
124
  const info = c.scripts.get(script.scriptId) ?? (script.url ? { url: script.url } : undefined);
116
125
  if (!info) continue;
@@ -137,16 +146,22 @@ async function harvestInner(c: Collector, config: BrowserCoverageConfig): Promis
137
146
  const generated = rangesToLineCounts(info.source, script.functions);
138
147
  if (generated.size === 0) continue;
139
148
  if (info.map === undefined) info.map = await sourceMapFor(info, url);
149
+ if (!info.map && isDocumentScriptPath(url.pathname)) continue; // inline script in a page
140
150
  const files: FileCounts = info.map
141
151
  ? mapToOriginal(info.map, generated)
142
152
  : new Map([[url.pathname, generated]]);
143
- let totals = TOTALS.get(service);
144
- if (!totals) {
145
- totals = new Map();
146
- TOTALS.set(service, totals);
147
- }
148
- mergeFileCounts(totals, files);
153
+ foldBrowserCoverage(service, files);
154
+ }
155
+ }
156
+
157
+ /** Fold one script's per-file counts into a service's totals. */
158
+ export function foldBrowserCoverage(service: string, files: FileCounts): void {
159
+ let totals = TOTALS.get(service);
160
+ if (!totals) {
161
+ totals = new Map();
162
+ TOTALS.set(service, totals);
149
163
  }
164
+ mergeFileCounts(totals, files);
150
165
  }
151
166
 
152
167
  /** Resolve, fetch (or inline-decode) and decode a script's source map. */
@@ -199,7 +214,26 @@ function parseDataUrl(ref: string): unknown {
199
214
  }
200
215
  }
201
216
 
202
- /** One lcov document per service that has any browser coverage. */
217
+ /** Harvest every live session, so a capture sees the counters of code
218
+ * that ran since the last browser op. */
219
+ export async function harvestAllBrowserCoverage(): Promise<void> {
220
+ await Promise.all([...LIVE_COLLECTORS].map((c) => harvestBrowserCoverage(c.cdp)));
221
+ }
222
+
223
+ /** One lcov document per service that has any browser coverage — and
224
+ * the totals are cleared, so the next capture holds only what runs
225
+ * from now on. Module memory forks with the environment, so a forked
226
+ * child starts from its parent's last capture, empty. */
227
+ export function takeBrowserCoverageReports(): Map<string, string> {
228
+ const out = new Map<string, string>();
229
+ for (const [service, files] of TOTALS) {
230
+ if (files.size > 0) out.set(service, lcovDocument(files));
231
+ }
232
+ TOTALS.clear();
233
+ return out;
234
+ }
235
+
236
+ /** The current totals, without clearing them (tests). */
203
237
  export function browserCoverageReports(): Map<string, string> {
204
238
  const out = new Map<string, string>();
205
239
  for (const [service, files] of TOTALS) {