@specific.dev/spectest 0.81.1 → 0.83.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.
@@ -8,11 +8,6 @@ export declare const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json"
8
8
  /** Prefix of the per-process sockets the node hook answers on, relative
9
9
  * to the coverage dir: `.ctl-<pid>`. */
10
10
  export declare const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
11
- /** Key of the content hash (`sha256:<hex>`) a shipped `source-map-cache`
12
- * entry carries, and key of the stub that stands in for an entry that
13
- * shipped earlier on the same branch. See {@link compactV8Document}. */
14
- export declare const SOURCE_MAP_HASH_KEY = "spectestHash";
15
- export declare const SOURCE_MAP_REF_KEY = "spectestRef";
16
11
  /** What `configure` learns about the service it rewrites. */
17
12
  export interface CoverageConfigureInfo {
18
13
  /** The services-map key. */
@@ -53,9 +48,6 @@ export interface CoverageCaptureContext {
53
48
  }>;
54
49
  /** Write one report file (name relative to the directory). */
55
50
  writeReport(name: string, content: string): Promise<void>;
56
- /** Read one file out of the container (`docker cp`). `null` when it
57
- * does not exist. Absent on a context that has no container. */
58
- readContainerFile?(containerPath: string): Promise<Buffer | null>;
59
51
  /** Aborts when the per-service capture budget runs out. */
60
52
  signal: AbortSignal;
61
53
  }
@@ -109,55 +101,89 @@ export declare function coverageAdapters(cov: ServiceCoverage | undefined): read
109
101
  export declare function applyCoverageAdapters<S extends ServiceConfig>(key: string, svc: S): S;
110
102
  /** `env` with `value` appended to `name` (space-separated), or set. */
111
103
  export declare function appendEnvFlag(env: Readonly<Record<string, string>> | undefined, name: string, value: string): Record<string, string>;
104
+ /** Env var the hook reads: the coverage directory inside the container. */
105
+ export declare const NODE_COVERAGE_DIR_ENV = "SPECTEST_COVERAGE_DIR";
106
+ /** Subdirectory of the coverage dir where the hook writes one record per
107
+ * script (`scripts/<id>.json`, {@link NODE_COVERAGE_HOOK}). Shipped
108
+ * once, like a dump. */
109
+ export declare const NODE_COVERAGE_SCRIPTS_SUBDIR = "scripts";
110
+ /** Hidden subdirectory holding one empty marker per script record ever
111
+ * written on this branch. The hook's "written already?" check, which
112
+ * must survive the harness shipping and removing the record; hidden,
113
+ * so the harness never reads it. */
114
+ export declare const NODE_COVERAGE_MARKERS_SUBDIR = ".spectest/scripts";
115
+ /** Name prefix of the dumps the hook writes: `node-<pid>-<thread>-<ms>-<n>.json`. */
116
+ export declare const NODE_COVERAGE_DUMP_PREFIX = "node-";
112
117
  /**
113
- * The hook `node()` mounts and `--require`s into every node process of
114
- * the container. Node writes V8 coverage JSON into `NODE_V8_COVERAGE`
115
- * when a process exits; a long-lived server never exits, so the hook
116
- * binds a Unix socket in the coverage directory and calls
117
- * `v8.takeCoverage()` on request. **Every** process binds its own socket
118
- * (`.ctl-<pid>`) — there is no "first process owns it" rule, because the
119
- * first node process is often a wrapper (`pnpm exec`, the `tsx` binary,
120
- * `npm run`) whose child is the real server; a single socket on the
121
- * wrapper dumped the wrapper and silently never the server (reported by
122
- * a user 2026-08-27). At capture spectest asks every live socket and
123
- * unlinks the stale ones. Unref'd, so a short-lived process still exits.
124
- * Main thread only: a worker thread inherits `NODE_OPTIONS`, and tsx's
125
- * ESM loader thread bound the shared per-pid path last, so every
126
- * `--import tsx` server answered with the loader's coverage — the app's
127
- * main thread was never dumped (found in the same user's first full run).
128
- * No signal is used: signals are claimed by frameworks (SIGUSR2 stops a
129
- * Temporal worker, restarts nodemon), a socket is nobody's.
118
+ * The hook `node()` mounts at {@link NODE_COVERAGE_HOOK_PATH} and
119
+ * `--require`s into every node process of the service. **It is the
120
+ * collector**: nothing outside the process reads, converts or looks
121
+ * anything up. Runs in every thread (a worker thread inherits
122
+ * `NODE_OPTIONS`); reads one env var, {@link NODE_COVERAGE_DIR_ENV}; uses
123
+ * node built-ins only, so it runs in any image that runs node.
124
+ *
125
+ * What it does:
126
+ *
127
+ * - Starts V8 precise (block, counted) coverage through the in-process
128
+ * inspector session. `NODE_V8_COVERAGE` is **not** set: Node would then
129
+ * write everything the process loaded at every exit — `node:`
130
+ * internals, every `node_modules` file, and the whole source map of
131
+ * every script that carries a `sourceMappingURL` comment, with or
132
+ * without `--enable-source-maps`. For a bundled CLI that was 12 MB per
133
+ * invocation, inside the test's own time, parsed again at capture.
134
+ * - **Takes** on request — the main thread binds a Unix socket
135
+ * `.ctl-<pid>` in the coverage directory and takes on `dump\n`; every
136
+ * process binds its own (the first node process is often a wrapper,
137
+ * `pnpm exec`, `tsx`, `npm run`) — and in every thread's `exit`
138
+ * handler, which is how a short-lived CLI and a worker thread report.
139
+ * The in-process session answers synchronously, so the exit handler
140
+ * has the result before the process is gone. A take resets V8's
141
+ * counters: every dump is the delta since the previous take. No
142
+ * signal is used: signals are claimed by frameworks (SIGUSR2 stops a
143
+ * Temporal worker, restarts nodemon), a socket is nobody's.
144
+ * - Writes a **dump** `node-<pid>-<thread>-<ms>-<n>.json`: the app's own
145
+ * scripts (`file://`, outside `node_modules` and `.cache`, not the
146
+ * hook) in which some range count is above zero, and of each only the
147
+ * functions that ran, each one flat array: `[start, length, count]`
148
+ * for its extent (V8's first range), then `[offset from start,
149
+ * length, count]` per block range in V8's order. Name and block flag
150
+ * are in the record, joined on the extent. Nothing when nothing ran.
151
+ * `{ spectestVersion: 2, result: [{ url, spectestScript, functions:
152
+ * [[s, l, c, o, l, c, …], …] }] }`.
153
+ * - Writes, **once per script per branch**, a **script record**
154
+ * `scripts/<id>.json` (`id` = sha256 of the URL): the length of every
155
+ * line of the source V8 compiled (`Debugger.getScriptSource`, so a
156
+ * transpiled module — tsx, ts-node, a bundler's register hook — is
157
+ * described by the text V8 ran, not the file on disk), every function
158
+ * with its extent (the skeleton: what a dump's "ran" functions are a
159
+ * subset of, so a never-run function is known to exist), and the
160
+ * source map: the inline `data:` map in the text, the file the last
161
+ * `sourceMappingURL` comment names, or `<script>.map` next to the
162
+ * script; `sourcesContent` dropped (the sources are the repository).
163
+ * "Once per branch" is a marker file under `.spectest/scripts/`: the
164
+ * directory is shared by every process of the service and forks with
165
+ * the environment, so a record written at bring-up or by an ancestor
166
+ * is never written again, whichever process comes next.
167
+ *
168
+ * The harness ships new dumps and new records as they are. It parses
169
+ * nothing and touches no container; the control plane derives lines
170
+ * from the stored documents (`SUBSET_RUNS.md`).
130
171
  */
131
172
  export declare const NODE_COVERAGE_HOOK: string;
132
173
  /**
133
174
  * Coverage for a Node service — a long-lived server, or a container whose
134
175
  * node processes are short-lived CLIs run by `ctx.exec`. Sets
135
- * `NODE_V8_COVERAGE` to the coverage directory (every node process in the
136
- * container then writes V8 coverage JSON when it exits) and `--require`s
137
- * a hook that lets spectest ask every live node process for a dump at
138
- * capture time — the server, and any wrapper it sits behind (`pnpm exec`,
139
- * `tsx`). Nothing for the app to write.
140
- *
141
- * What ships is the V8 documents themselves, compacted
142
- * ({@link compactV8Document}): the app's own scripts that ran, and each
143
- * script's source map once per branch. A map comes from Node's own
144
- * `source-map-cache` when Node found one through the script's
145
- * `sourceMappingURL` (it caches maps under NODE_V8_COVERAGE with or
146
- * without `--enable-source-maps`), else from `<script>.map` next to the
147
- * script, read out of the container once
148
- * ({@link resolveSourceMapsFromContainer}). The second way is the one to
149
- * build a short-lived bundled process in: a map Node finds is serialized
150
- * whole into every exit dump — 12 MB per invocation for a bundled CLI,
151
- * inside the test's own time — and parsed again by the harness; a map
152
- * with no comment costs nothing there. Nothing is converted in the VM.
153
- * A dump is a **delta** by construction: V8 resets its counters at every
154
- * `takeCoverage()`, so a live server's dump after a test is that test's
155
- * own execution, and the boot dump (everything loaded) lands in the
156
- * bring-up capture and nowhere else. The control plane derives lines
157
- * from the stored documents, off the test run. Converting to lcov in
158
- * the container (`c8`, SDK 0.60 to 0.79) took seconds per capture per
159
- * service on a real project, inside the test run; a real project turned
160
- * coverage off because of it, and the conversion went.
176
+ * {@link NODE_COVERAGE_DIR_ENV} to the coverage directory and
177
+ * `--require`s {@link NODE_COVERAGE_HOOK}, which collects everything in
178
+ * the process. Nothing for the app to write, and nothing the harness
179
+ * reads out of the container: at capture it asks every live process for
180
+ * a take over its socket and lists what appeared in the directory —
181
+ * dumps and script records — for the harness to ship as they are. A
182
+ * dump is a delta by construction (V8 resets its counters at every
183
+ * take), so the report after a test is that test's own execution and
184
+ * the boot dump lands in the bring-up capture alone. Each capture ships
185
+ * only its own files: what an earlier capture on this branch shipped is
186
+ * removed first (module memory, forks with the environment).
161
187
  */
162
188
  export declare function node(): CoverageAdapter;
163
189
  /**
@@ -169,113 +195,24 @@ export declare function node(): CoverageAdapter;
169
195
  export declare function dumpAllNodeProcesses(dir: string, signal: AbortSignal): Promise<number>;
170
196
  /** Forget the branch memory (tests). */
171
197
  export declare function resetNodeCoverageMemory(): void;
172
- /** Remove from `dir` the reports an earlier capture shipped. */
173
- export declare function removeShippedV8Reports(dir: string): Promise<void>;
174
- /** A script is the app's own when it is a file outside node_modules —
175
- * and not our hook, which every dump would otherwise carry. */
176
- export declare function isAppScriptUrl(url: string): boolean;
177
- interface V8Range {
178
- count?: unknown;
179
- }
180
- interface V8Function {
181
- ranges?: V8Range[];
182
- }
183
- interface V8Script {
184
- url?: unknown;
185
- functions?: V8Function[];
186
- }
187
- /** True when any range of any function of the script has a count above
188
- * zero: something in it ran since the previous take. A script whose
189
- * every count is zero is one V8 still lists after a reset; it says
190
- * nothing and is dropped. */
191
- export declare function scriptExecuted(script: V8Script): boolean;
192
- /**
193
- * Compact one V8 coverage document to what the derivation needs. What
194
- * Node writes is everything the process loaded: `node:` internals, every
195
- * `node_modules` file, and — under `--enable-source-maps` — a
196
- * `source-map-cache` with each file's full map **and its sources**,
197
- * repeated in every dump. Measured on a real project: a 12–20 MiB dump
198
- * per capture, of which the app's own coverage was under 0.5 MiB, and a
199
- * suite that hit the 64 MiB cap on its third test. Kept: `file://`
200
- * scripts outside `node_modules` in which something ran, and the map
201
- * entries of exactly those scripts, minus `sourcesContent` (the sources
202
- * are the repo). Each kept map entry carries its content hash under
203
- * {@link SOURCE_MAP_HASH_KEY}; when `shipped` holds the same hash for the
204
- * URL — the map went out with an earlier dump on this branch — a stub
205
- * `{ [SOURCE_MAP_REF_KEY]: hash }` stands in for it, and the reader finds
206
- * the map in an earlier capture of the branch. `shipped` is updated in
207
- * place. Still a V8 document — nothing is converted here.
208
- */
209
- export declare function compactV8Document(doc: Record<string, unknown>, shipped?: Map<string, string>): Record<string, unknown>;
210
- /** What {@link prepareV8Reports} did, for the capture's log line. */
211
- export interface PrepareV8Stats {
198
+ /** Remove from `dir` the files an earlier capture shipped. */
199
+ export declare function removeShippedNodeReports(dir: string): Promise<void>;
200
+ /** What {@link collectNodeReports} found, for the capture's log line. */
201
+ export interface NodeReportStats {
212
202
  dumps: number;
203
+ dumpBytes: number;
213
204
  scripts: number;
214
- bytesIn: number;
215
- bytesOut: number;
216
- /** Source maps read out of the container this capture. */
217
- mapsFromContainer: number;
205
+ scriptBytes: number;
218
206
  }
219
- /** One `source-map-cache` entry in the shape Node writes: the map (its
220
- * `sourcesContent` dropped), the generated script's line lengths, and
221
- * the map's own URL. */
222
- export interface SourceMapEntry {
223
- url: string | null;
224
- data: Record<string, unknown>;
225
- lineLengths: number[];
226
- }
227
- /** Finds the source maps of the scripts a dump carries no map for, in
228
- * one go. A script absent from the result has none. */
229
- export type SourceMapResolver = (scriptUrls: string[]) => Promise<Map<string, SourceMapEntry>>;
230
- /**
231
- * The probe {@link resolveSourceMapsFromContainer} runs in the container:
232
- * one `sh` loop over every script path, printing `<path>\t<ref>` — the
233
- * last `sourceMappingURL` value in the file, else `<path>.map` when that
234
- * file exists, else `-`; `!` for a script that is not there. One exec per
235
- * capture per service, whatever the number of scripts: a Next.js server
236
- * loads hundreds of chunk scripts with no map, and a `docker cp` per
237
- * script (the first cut) took a capture past its budget on a busy host.
238
- */
239
- export declare function sourceMapProbeCommand(scriptPaths: string[]): string;
240
- /** The length of every line of `text`, the way Node computes it for
241
- * `source-map-cache`: split on `\n` (and U+2028/2029), `\r` kept, the
242
- * last line included. Lengths in UTF-16 units, which is what V8's byte
243
- * offsets count in. */
244
- export declare function lineLengthsOf(text: string): number[];
245
- /**
246
- * The source maps of `scriptUrls`, read out of the container. One probe
247
- * exec ({@link sourceMapProbeCommand}) finds, per script, the map a
248
- * `sourceMappingURL` comment names or `<script>.map` next to it; only a
249
- * script that has one is then read (`docker cp`, the script for its line
250
- * lengths and the map file, or the inline `data:` map decoded). A map's
251
- * `sourcesContent` is dropped. Scripts that are not `file://` URLs, have
252
- * no map, or whose map is not there or not JSON are absent from the
253
- * result. The caller remembers every outcome, so this runs once per
254
- * script per branch.
255
- *
256
- * With no comment the map next to the script is the shape to build a
257
- * short-lived bundled process in: Node caches a map it finds through a
258
- * comment into every dump it writes under NODE_V8_COVERAGE, flag or no
259
- * flag, and a bundled CLI's map is tens of MB per exit; a map it does not
260
- * find costs nothing there.
261
- */
262
- export declare function resolveSourceMapsFromContainer(ctx: CoverageCaptureContext, scriptUrls: string[]): Promise<Map<string, SourceMapEntry>>;
263
- /**
264
- * Give every kept script of a compacted document a `source-map-cache`
265
- * entry it lacks: a stub when the map shipped earlier on this branch,
266
- * else the map `resolve` finds (asked once for all such scripts; shipped
267
- * whole, with its hash, and remembered), else nothing — and that outcome
268
- * is remembered too, so a script with no map is looked up once per
269
- * branch. Returns how many maps `resolve` supplied.
270
- */
271
- export declare function attachSourceMaps(doc: Record<string, unknown>, resolve: SourceMapResolver, shipped?: Map<string, string>, missing?: Set<string>): Promise<number>;
207
+ /** True for a dump the hook wrote (`node-*.json`). */
208
+ export declare function isNodeDumpName(name: string): boolean;
272
209
  /**
273
- * Compact, in place, every `coverage-*.json` in `dir` that no earlier
274
- * capture shipped, attach the maps the dump lacks through `resolve`
275
- * ({@link attachSourceMaps}), and mark it shipped. A dump mid-write (not
276
- * yet valid JSON) is left for the next capture.
210
+ * Mark shipped every dump at the root of `dir` and every script record
211
+ * under `scripts/` that no earlier capture shipped, and count them.
212
+ * Nothing is read: the hook wrote each file whole (write + rename), and
213
+ * the harness ships them as they are.
277
214
  */
278
- export declare function prepareV8Reports(dir: string, resolve?: SourceMapResolver): Promise<PrepareV8Stats>;
215
+ export declare function collectNodeReports(dir: string): Promise<NodeReportStats>;
279
216
  /**
280
217
  * Coverage for the frontend a service serves. The code runs in the guest
281
218
  * browser — spectest's own process — so no report can be written by the