@specific.dev/spectest 0.58.0 → 0.59.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,6 +8,10 @@ import {
8
8
  coverageHostDir,
9
9
  encodeCoverageBundle,
10
10
  hasLcovSourceFile,
11
+ isEmptyReport,
12
+ isLcovShaped,
13
+ isV8CoverageJson,
14
+ isWellFormedReport,
11
15
  listReportFiles,
12
16
  readCoverageDir,
13
17
  } from "./coverage";
@@ -24,7 +28,48 @@ describe("hasLcovSourceFile", () => {
24
28
  });
25
29
  });
26
30
 
31
+ describe("report shapes", () => {
32
+ test("lcov: every non-blank line a record; an SF-less report is well-formed and empty", () => {
33
+ expect(isLcovShaped("TN:\nSF:/a.js\nDA:1,1\nend_of_record\n")).toBe(true);
34
+ expect(isLcovShaped("TN:\n")).toBe(true);
35
+ expect(isEmptyReport("TN:\n")).toBe(true);
36
+ expect(isEmptyReport("TN:\nSF:/a.js\n")).toBe(false);
37
+ expect(isLcovShaped("")).toBe(false);
38
+ expect(isLcovShaped("hello world\n")).toBe(false);
39
+ expect(isLcovShaped("SF:/a.js\ngarbage\n")).toBe(false);
40
+ });
41
+ test("V8 JSON: shape check only; an empty result is empty", () => {
42
+ const v8 = '{"result":[{"scriptId":"1","url":"file:///app/a.js","functions":[]}]}';
43
+ expect(isV8CoverageJson(v8)).toBe(true);
44
+ expect(isEmptyReport(v8)).toBe(false);
45
+ expect(isEmptyReport('{"result":[]}')).toBe(true);
46
+ expect(isV8CoverageJson("{}")).toBe(false);
47
+ expect(isWellFormedReport(v8)).toBe(true);
48
+ expect(isWellFormedReport("TN:\n")).toBe(true);
49
+ expect(isWellFormedReport("nope")).toBe(false);
50
+ });
51
+ });
52
+
27
53
  describe("readCoverageDir", () => {
54
+ test("a V8 coverage JSON file satisfies the contract", async () => {
55
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-cov-"));
56
+ await fs.writeFile(
57
+ path.join(dir, "coverage-1-2-3.json"),
58
+ '{"result":[{"scriptId":"1","url":"file:///app/a.js","functions":[]}]}',
59
+ );
60
+ const r = await readCoverageDir("web", dir);
61
+ expect(r.error).toBeUndefined();
62
+ expect(r.reports.length).toBe(1);
63
+ });
64
+ test("a malformed file next to a good one is an error naming the file", async () => {
65
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-cov-"));
66
+ await fs.writeFile(path.join(dir, "lcov.info"), "TN:\nSF:/a.js\nend_of_record\n");
67
+ await fs.writeFile(path.join(dir, "junk.txt"), "not a report\n");
68
+ const r = await readCoverageDir("web", dir);
69
+ expect(r.error).toContain("junk.txt");
70
+ expect(r.reports.length).toBe(2);
71
+ });
72
+
28
73
  test("ships every report verbatim, nested, with relative names", async () => {
29
74
  const dir = await tmp();
30
75
  await fs.mkdir(path.join(dir, "sub"));
@@ -56,7 +101,7 @@ describe("readCoverageDir", () => {
56
101
  await fs.writeFile(path.join(dir, "coverage.json"), "{}");
57
102
  const r = await readCoverageDir("web", dir);
58
103
  expect(r.reports.length).toBe(1);
59
- expect(r.error).toContain("SF:");
104
+ expect(r.error).toContain("none is an lcov report or a V8 coverage JSON document");
60
105
  });
61
106
 
62
107
  test("listReportFiles skips dotfiles", async () => {
@@ -2,12 +2,15 @@
2
2
  //
3
3
  // A service that opts in (`coverage: true | { command }`) gets a writable
4
4
  // directory bind-mounted at `/spectest/coverage/` in its container. The
5
- // service's own tool writes lcov reports there. The daemon reads the
5
+ // service's own tool, or its coverage adapters, write reports there —
6
+ // lcov, or the V8 coverage JSON that `NODE_V8_COVERAGE` writes. The daemon reads the
6
7
  // directory from the VM side (no `docker exec` to fetch) after bring-up
7
8
  // and after each test, and ships the reports **verbatim** to the control
8
9
  // plane as a gzipped bundle. Nothing is interpreted here beyond the one
9
- // contract the harness must fail a case on: at least one report, and at
10
- // least one lcov `SF:` record among them. The server derives whatever it
10
+ // contract the harness must fail a case on: at least one report, every
11
+ // one well-formed. Counters may reset between dumps and a dump may be a
12
+ // new file: the directory forks with the environment, so it accumulates
13
+ // along the ancestor chain by itself. The server derives whatever it
11
14
  // needs (today the set of source files; later, finer things) from the
12
15
  // stored bytes, so a change in that derivation is a re-read of stored
13
16
  // runs, never a hole in the history.
@@ -22,7 +25,7 @@ import { gzipSync } from "node:zlib";
22
25
  export const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
23
26
 
24
27
  /** Raw bytes of reports one service may ship per capture. Reports are
25
- * cumulative and bounded by code size, so anything past this is a
28
+ * bounded by code size times chain depth, so anything past this is a
26
29
  * runaway (a tool writing a new file per request), not coverage. */
27
30
  export const COVERAGE_MAX_BYTES_PER_SERVICE = 64 * 1024 * 1024;
28
31
 
@@ -76,6 +79,37 @@ export function hasLcovSourceFile(text: string): boolean {
76
79
  return /^SF:\S/m.test(text);
77
80
  }
78
81
 
82
+ /** True when `text` is lcov-shaped: at least one line, every non-blank
83
+ * line an `XX:` record or `end_of_record`. An lcov with no `SF:` record
84
+ * at all (`TN:` alone) is well-formed — it is a report that says
85
+ * nothing ran. */
86
+ export function isLcovShaped(text: string): boolean {
87
+ const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
88
+ return lines.length > 0 && lines.every((l) => /^[A-Z]{2,5}:/.test(l) || l.trim() === "end_of_record");
89
+ }
90
+
91
+ /**
92
+ * True when `text` looks like a V8 coverage JSON document — what
93
+ * `NODE_V8_COVERAGE` / `v8.takeCoverage()` write: an object with a
94
+ * `result` array of scripts. A cheap shape check, not a parse (a report
95
+ * can be megabytes); the control plane parses for real.
96
+ */
97
+ export function isV8CoverageJson(text: string): boolean {
98
+ return /^\s*\{/.test(text) && /"result"\s*:\s*\[/.test(text);
99
+ }
100
+
101
+ /** True when `text` is a report spectest stores: lcov or V8 JSON. */
102
+ export function isWellFormedReport(text: string): boolean {
103
+ return isLcovShaped(text) || isV8CoverageJson(text);
104
+ }
105
+
106
+ /** True when a well-formed report names no source file at all (an lcov
107
+ * with no `SF:`, a V8 document with an empty `result`). */
108
+ export function isEmptyReport(text: string): boolean {
109
+ if (isV8CoverageJson(text)) return /"result"\s*:\s*\[\s*\]/.test(text);
110
+ return !hasLcovSourceFile(text);
111
+ }
112
+
79
113
  /** Every regular file under `dir`, recursively, as paths relative to it.
80
114
  * Hidden files are skipped (a tool's own bookkeeping — `.seq`, `.lock`,
81
115
  * a tmp file mid-write — is not a report). */
@@ -100,9 +134,11 @@ export async function listReportFiles(dir: string): Promise<string[]> {
100
134
  }
101
135
 
102
136
  /**
103
- * Read every report under `dir`, verbatim. An empty directory, or one with
104
- * no `SF:` record anywhere, is an error rather than an empty capture: the
105
- * contract is that an opted-in service always has a report to read.
137
+ * Read every report under `dir`, verbatim. An empty directory, or a file
138
+ * that is neither lcov nor V8 JSON, is an error rather than an empty
139
+ * capture: the contract is that an opted-in service always has a
140
+ * well-formed report to read. A report that names no file is fine — it
141
+ * says nothing ran.
106
142
  */
107
143
  export async function readCoverageDir(
108
144
  service: string,
@@ -113,12 +149,13 @@ export async function readCoverageDir(
113
149
  return {
114
150
  service,
115
151
  reports: [],
116
- error: `service "${service}" opted in to coverage but ${COVERAGE_CONTAINER_DIR}/ holds no report — the service (or its coverage command) must write an lcov file there`,
152
+ error: `service "${service}" opted in to coverage but ${COVERAGE_CONTAINER_DIR}/ holds no report — the service (or its coverage command) must write an lcov or V8 coverage JSON file there`,
117
153
  };
118
154
  }
119
155
  const reports: CoverageReport[] = [];
120
156
  let total = 0;
121
- let anyLcov = false;
157
+ let anyReport = false;
158
+ const malformed: string[] = [];
122
159
  for (const name of names) {
123
160
  let content: string;
124
161
  try {
@@ -131,17 +168,20 @@ export async function readCoverageDir(
131
168
  return {
132
169
  service,
133
170
  reports: [],
134
- error: `service "${service}" has more than ${COVERAGE_MAX_BYTES_PER_SERVICE >> 20} MiB of reports in ${COVERAGE_CONTAINER_DIR}/ — reports must be cumulative, not one file per dump`,
171
+ error: `service "${service}" has more than ${COVERAGE_MAX_BYTES_PER_SERVICE >> 20} MiB of reports in ${COVERAGE_CONTAINER_DIR}/ — that is a runaway, not coverage`,
135
172
  };
136
173
  }
137
- if (hasLcovSourceFile(content)) anyLcov = true;
174
+ if (isWellFormedReport(content)) anyReport = true;
175
+ else malformed.push(name);
138
176
  reports.push({ name, content });
139
177
  }
140
- if (!anyLcov) {
178
+ if (!anyReport || malformed.length > 0) {
141
179
  return {
142
180
  service,
143
181
  reports,
144
- error: `service "${service}" wrote ${reports.length} file(s) to ${COVERAGE_CONTAINER_DIR}/ but none holds an lcov \`SF:\` record — only lcov reports are read`,
182
+ error: `service "${service}" wrote ${reports.length} file(s) to ${COVERAGE_CONTAINER_DIR}/ but ${
183
+ anyReport ? `${malformed.slice(0, 5).join(", ")} ${malformed.length === 1 ? "is" : "are"} neither` : "none is"
184
+ } an lcov report or a V8 coverage JSON document — only those two formats are read`,
145
185
  };
146
186
  }
147
187
  return { service, reports };
package/src/index.ts CHANGED
@@ -132,6 +132,19 @@ export type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
132
132
 
133
133
  import type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
134
134
 
135
+ // Coverage adapter types. The adapters themselves (`node()`, `browser()`,
136
+ // `command()`, `defineAdapter`) are imported from
137
+ // `@specific.dev/spectest/coverage` — see `coverage.ts` and
138
+ // `spectest docs /services/coverage`.
139
+ export {
140
+ type CoverageAdapter,
141
+ type CoverageCaptureContext,
142
+ type CoverageConfigureInfo,
143
+ type CoverageLoadContext,
144
+ type ServiceCoverage,
145
+ } from "./coverage.js";
146
+ import { applyCoverageAdapters, validateCoverage, type ServiceCoverage } from "./coverage.js";
147
+
135
148
  // Low-level ingress primitives + the framework lowering that the friendly
136
149
  // `tls` / `hostnames` fields and `defineFake(...)` are built on. See
137
150
  // `ingress.ts`.
@@ -256,20 +269,24 @@ export interface ServiceConfig {
256
269
  * Opt this service in to **code coverage collection** (experimental).
257
270
  *
258
271
  * The service gets a writable directory bind-mounted at
259
- * `/spectest/coverage/`. The service's own coverage tool writes **lcov**
260
- * reports there — cumulative from process start, never reset. After
261
- * bring-up and after every test, spectest runs `command` (if given) inside
262
- * the container, then reads every file in the directory and records the
263
- * set of source files that ran. Only the `SF:` records are read.
272
+ * `/spectest/coverage/`. After bring-up and after every test, spectest
273
+ * reads every file in it — **lcov** or **V8 coverage JSON**, verbatim —
274
+ * and stores them with the test; the control plane derives what ran.
264
275
  *
265
- * - `true`: the service writes its reports by itself (a dump on a timer,
266
- * an exit-time dump for short-lived processes).
267
- * - `{ command }`: run this (via `sh -c`) in the container to make the
268
- * service write a report, before the directory is read.
276
+ * - `true`: the program writes its own reports there (an exit-time dump
277
+ * for a short-lived process, a dump on a timer, its own hook).
278
+ * - `{ adapters: [...] }`: spectest gets the reports out. From
279
+ * `@specific.dev/spectest/coverage`: `node()` (V8 JSON from a node
280
+ * service, through a hook spectest mounts), `browser()` (the frontend
281
+ * this service serves, collected from the guest browser),
282
+ * `command(cmd)` (run a command in the container first), or your own
283
+ * via `defineAdapter`.
269
284
  *
270
- * The directory must hold at least one lcov report each time it is read;
271
- * an empty directory or a failed command fails the test with a clear
272
- * error, so a hole in the coverage data is never silent. See
285
+ * The directory must hold at least one well-formed report each time it
286
+ * is read; an empty directory or a failing adapter fails the test with a
287
+ * clear error, so a hole in the coverage data is never silent. Reports
288
+ * accumulate along the test's ancestor chain by themselves (the
289
+ * directory forks with the environment). See
273
290
  * `spectest docs /services/coverage`.
274
291
  */
275
292
  coverage?: ServiceCoverage;
@@ -1038,31 +1055,8 @@ export type ServiceImage =
1038
1055
  exclude?: readonly string[];
1039
1056
  };
1040
1057
 
1041
- /**
1042
- * A service's opt-in to coverage collection — see `ServiceConfig.coverage`.
1043
- * `true` when the service writes its own reports; `{ command }` when
1044
- * spectest must run a command in the container to produce one.
1045
- */
1046
- export type ServiceCoverage =
1047
- | true
1048
- | {
1049
- /** Run this (via `sh -c`) in the container before the coverage
1050
- * directory is read, so the service writes a fresh lcov report. */
1051
- command?: string;
1052
- /**
1053
- * Collect **frontend** coverage for the app this service serves.
1054
- * The code runs in the guest browser — spectest's own process — so
1055
- * the user cannot write a report for it: spectest turns on V8
1056
- * coverage for every script `ctx.browser()`/`ctx.mobile()` loads
1057
- * from this service's origin, maps it back to original sources
1058
- * through the served source maps, and adds one lcov report to this
1059
- * service's capture. Scripts a source map can't resolve are
1060
- * attributed to their served path (which the repo mapping then
1061
- * treats as unknown — the safe answer). Needs the app to serve
1062
- * source maps to the test environment.
1063
- */
1064
- browser?: boolean;
1065
- };
1058
+ // Coverage adapters live in `coverage.ts`; the type is re-declared here
1059
+ // through the import below so `ServiceConfig.coverage` reads in one place.
1066
1060
 
1067
1061
  export interface VolumeMount {
1068
1062
  /**
@@ -1283,39 +1277,6 @@ export interface FakeContext {
1283
1277
  certificate(hostnames: readonly string[]): Promise<CertificateMaterial>;
1284
1278
  }
1285
1279
 
1286
- /**
1287
- * Check one service's `coverage` field. Exported so the daemon applies the
1288
- * same rule to a runtime `startService` spec.
1289
- */
1290
- export function validateCoverage(service: string, cov: unknown): void {
1291
- if (cov === undefined || cov === true) return;
1292
- if (typeof cov === "object" && cov !== null) {
1293
- const o = cov as { command?: unknown; browser?: unknown };
1294
- const cmdOk =
1295
- o.command === undefined ||
1296
- (typeof o.command === "string" && o.command.trim().length > 0);
1297
- const browserOk = o.browser === undefined || typeof o.browser === "boolean";
1298
- if (cmdOk && browserOk && (o.command !== undefined || o.browser === true)) return;
1299
- }
1300
- throw new Error(
1301
- `service "${service}" has an invalid \`coverage\` value — use \`true\`, \`{ command: "<shell command>" }\`, or \`{ browser: true }\``,
1302
- );
1303
- }
1304
-
1305
- /** Whether a `coverage` value collects reports from the container's own
1306
- * filesystem (`/spectest/coverage/`). True for `true`, `{ command }`, and
1307
- * any object that isn't browser-only. */
1308
- export function coverageUsesContainer(cov: ServiceCoverage | undefined): boolean {
1309
- if (cov === undefined) return false;
1310
- if (cov === true) return true;
1311
- return cov.command !== undefined || cov.browser !== true;
1312
- }
1313
-
1314
- /** Whether a `coverage` value collects frontend (browser) coverage. */
1315
- export function coverageUsesBrowser(cov: ServiceCoverage | undefined): boolean {
1316
- return typeof cov === "object" && cov.browser === true;
1317
- }
1318
-
1319
1280
  function validateEnvironmentConfig<S extends ServicesMap>(
1320
1281
  config: EnvironmentConfig<S>,
1321
1282
  ): void {
@@ -2236,9 +2197,13 @@ export function defineEnvironment<
2236
2197
  // { name, services, timeoutSecs } — with plain, fully-expanded services —
2237
2198
  // is stored as `config` and shipped to the control plane.
2238
2199
  const { fakes, services: rawServices, ...rest } = input;
2200
+ const expanded = expandServiceGroups(rawServices);
2201
+ for (const [key, svc] of Object.entries(expanded)) {
2202
+ expanded[key] = applyCoverageAdapters(key, svc);
2203
+ }
2239
2204
  const config: EnvironmentConfig<S> = {
2240
2205
  ...rest,
2241
- services: expandServiceGroups(rawServices) as S,
2206
+ services: expanded as S,
2242
2207
  };
2243
2208
  validateEnvironmentConfig(config);
2244
2209
  if (fakes) validateFakes(config, fakes);