@specific.dev/spectest 0.81.0 → 0.82.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,86 @@ 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, ranges and counts as V8 reported them. Nothing
148
+ * when nothing ran. Still a V8 coverage document (`result`), plus
149
+ * `spectestScript` on each script naming its record.
150
+ * - Writes, **once per script per branch**, a **script record**
151
+ * `scripts/<id>.json` (`id` = sha256 of the URL): the length of every
152
+ * line of the source V8 compiled (`Debugger.getScriptSource`, so a
153
+ * transpiled module — tsx, ts-node, a bundler's register hook — is
154
+ * described by the text V8 ran, not the file on disk), every function
155
+ * with its extent (the skeleton: what a dump's "ran" functions are a
156
+ * subset of, so a never-run function is known to exist), and the
157
+ * source map: the inline `data:` map in the text, the file the last
158
+ * `sourceMappingURL` comment names, or `<script>.map` next to the
159
+ * script; `sourcesContent` dropped (the sources are the repository).
160
+ * "Once per branch" is a marker file under `.spectest/scripts/`: the
161
+ * directory is shared by every process of the service and forks with
162
+ * the environment, so a record written at bring-up or by an ancestor
163
+ * is never written again, whichever process comes next.
164
+ *
165
+ * The harness ships new dumps and new records as they are. It parses
166
+ * nothing and touches no container; the control plane derives lines
167
+ * from the stored documents (`SUBSET_RUNS.md`).
130
168
  */
131
169
  export declare const NODE_COVERAGE_HOOK: string;
132
170
  /**
133
171
  * Coverage for a Node service — a long-lived server, or a container whose
134
172
  * 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 resolveSourceMapFromContainer}). 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.
173
+ * {@link NODE_COVERAGE_DIR_ENV} to the coverage directory and
174
+ * `--require`s {@link NODE_COVERAGE_HOOK}, which collects everything in
175
+ * the process. Nothing for the app to write, and nothing the harness
176
+ * reads out of the container: at capture it asks every live process for
177
+ * a take over its socket and lists what appeared in the directory —
178
+ * dumps and script records — for the harness to ship as they are. A
179
+ * dump is a delta by construction (V8 resets its counters at every
180
+ * take), so the report after a test is that test's own execution and
181
+ * the boot dump lands in the bring-up capture alone. Each capture ships
182
+ * only its own files: what an earlier capture on this branch shipped is
183
+ * removed first (module memory, forks with the environment).
161
184
  */
162
185
  export declare function node(): CoverageAdapter;
163
186
  /**
@@ -169,97 +192,24 @@ export declare function node(): CoverageAdapter;
169
192
  export declare function dumpAllNodeProcesses(dir: string, signal: AbortSignal): Promise<number>;
170
193
  /** Forget the branch memory (tests). */
171
194
  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 {
195
+ /** Remove from `dir` the files an earlier capture shipped. */
196
+ export declare function removeShippedNodeReports(dir: string): Promise<void>;
197
+ /** What {@link collectNodeReports} found, for the capture's log line. */
198
+ export interface NodeReportStats {
212
199
  dumps: number;
200
+ dumpBytes: number;
213
201
  scripts: number;
214
- bytesIn: number;
215
- bytesOut: number;
216
- /** Source maps read out of the container this capture. */
217
- mapsFromContainer: number;
202
+ scriptBytes: number;
218
203
  }
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 map of a script the dump carries no map for.
228
- * `null` when there is none. */
229
- export type SourceMapResolver = (scriptUrl: string) => Promise<SourceMapEntry | null>;
230
- /** The length of every line of `text`, the way Node computes it for
231
- * `source-map-cache`: split on `\n` (and U+2028/2029), `\r` kept, the
232
- * last line included. Lengths in UTF-16 units, which is what V8's byte
233
- * offsets count in. */
234
- export declare function lineLengthsOf(text: string): number[];
235
- /** The last `sourceMappingURL` comment of a script, or `null`. */
236
- export declare function sourceMappingUrlOf(script: string): string | null;
237
- /**
238
- * The source map of `scriptUrl`, read out of the container: the script
239
- * itself (for its `sourceMappingURL` and its line lengths), then the map
240
- * — inline as a `data:` URL, a file the comment names, or, with no
241
- * comment, `<script>.map` next to it. `null` when the script is not a
242
- * `file://` URL, cannot be read, or the map is not there or not JSON; a
243
- * map's `sourcesContent` is dropped.
244
- * One read per script per branch: the caller remembers the outcome.
245
- */
246
- export declare function resolveSourceMapFromContainer(ctx: CoverageCaptureContext, scriptUrl: string): Promise<SourceMapEntry | null>;
247
- /**
248
- * Give every kept script of a compacted document a `source-map-cache`
249
- * entry it lacks: a stub when the map shipped earlier on this branch,
250
- * else the map `resolve` finds (shipped whole, with its hash, and
251
- * remembered), else nothing — and that outcome is remembered too, so a
252
- * script with no map is looked up once per branch. Returns how many maps
253
- * `resolve` supplied.
254
- */
255
- export declare function attachSourceMaps(doc: Record<string, unknown>, resolve: SourceMapResolver, shipped?: Map<string, string>, missing?: Set<string>): Promise<number>;
204
+ /** True for a dump the hook wrote (`node-*.json`). */
205
+ export declare function isNodeDumpName(name: string): boolean;
256
206
  /**
257
- * Compact, in place, every `coverage-*.json` in `dir` that no earlier
258
- * capture shipped, attach the maps the dump lacks through `resolve`
259
- * ({@link attachSourceMaps}), and mark it shipped. A dump mid-write (not
260
- * yet valid JSON) is left for the next capture.
207
+ * Mark shipped every dump at the root of `dir` and every script record
208
+ * under `scripts/` that no earlier capture shipped, and count them.
209
+ * Nothing is read: the hook wrote each file whole (write + rename), and
210
+ * the harness ships them as they are.
261
211
  */
262
- export declare function prepareV8Reports(dir: string, resolve?: SourceMapResolver): Promise<PrepareV8Stats>;
212
+ export declare function collectNodeReports(dir: string): Promise<NodeReportStats>;
263
213
  /**
264
214
  * Coverage for the frontend a service serves. The code runs in the guest
265
215
  * browser — spectest's own process — so no report can be written by the