@specific.dev/spectest 0.56.2 → 0.58.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.
@@ -0,0 +1,257 @@
1
+ // Browser coverage — the pure part.
2
+ //
3
+ // A frontend runs in the guest Chromium, which is spectest's own process,
4
+ // so the user cannot write a coverage report for it the way a service
5
+ // process can. A service declares `coverage: { browser: true }` and
6
+ // spectest collects it: V8 precise coverage over CDP for every script the
7
+ // browser loads from that service, mapped back to original sources
8
+ // through the served source maps, and written as one lcov report per
9
+ // service into the same `case-coverage` bundle the container path uses.
10
+ //
11
+ // This module has no CDP in it: source-map decoding, V8 range → line
12
+ // counts, host → service attribution, lcov output. The collector that
13
+ // talks to the page is `../browser-coverage.ts`.
14
+ // ── V8 precise coverage → generated line counts ─────────────────────────
15
+ /** Byte offset at which each line starts. */
16
+ export function lineStartsOf(source) {
17
+ const starts = [0];
18
+ for (let i = 0; i < source.length; i++) {
19
+ if (source.charCodeAt(i) === 10)
20
+ starts.push(i + 1);
21
+ }
22
+ return starts;
23
+ }
24
+ /** 1-based line holding `offset`. */
25
+ export function lineOf(starts, offset) {
26
+ let lo = 0;
27
+ let hi = starts.length - 1;
28
+ while (lo < hi) {
29
+ const mid = (lo + hi + 1) >> 1;
30
+ if (starts[mid] <= offset)
31
+ lo = mid;
32
+ else
33
+ hi = mid - 1;
34
+ }
35
+ return lo + 1;
36
+ }
37
+ /**
38
+ * Fold a script's block ranges onto its lines. Ranges come outer-first
39
+ * and a narrower range overrides the lines it spans, so a line inside an
40
+ * unexecuted branch ends at 0 even though its enclosing function ran.
41
+ * Blank lines are dropped. Approximate — a line holding two blocks gets
42
+ * the narrower one's count — which is exact at the file level, the only
43
+ * level anything reads today.
44
+ */
45
+ export function rangesToLineCounts(source, functions) {
46
+ const starts = lineStartsOf(source);
47
+ const counts = new Map();
48
+ for (const fn of functions) {
49
+ for (const r of fn.ranges) {
50
+ const a = lineOf(starts, r.startOffset);
51
+ const b = lineOf(starts, Math.max(r.startOffset, r.endOffset - 1));
52
+ for (let l = a; l <= b; l++)
53
+ counts.set(l, r.count);
54
+ }
55
+ }
56
+ for (const l of [...counts.keys()]) {
57
+ const text = source.slice(starts[l - 1], starts[l] ?? source.length);
58
+ if (text.trim().length === 0)
59
+ counts.delete(l);
60
+ }
61
+ return counts;
62
+ }
63
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
64
+ const B64_INDEX = new Map([...B64].map((c, i) => [c, i]));
65
+ /** Decode one VLQ-encoded segment into its fields. */
66
+ export function decodeVlq(segment) {
67
+ const out = [];
68
+ let value = 0;
69
+ let shift = 0;
70
+ for (const ch of segment) {
71
+ const digit = B64_INDEX.get(ch);
72
+ if (digit === undefined)
73
+ throw new Error(`bad VLQ char ${JSON.stringify(ch)}`);
74
+ value += (digit & 31) << shift;
75
+ if (digit & 32) {
76
+ shift += 5;
77
+ }
78
+ else {
79
+ const negative = value & 1;
80
+ value >>>= 1;
81
+ out.push(negative ? -value : value);
82
+ value = 0;
83
+ shift = 0;
84
+ }
85
+ }
86
+ return out;
87
+ }
88
+ /**
89
+ * Normalise a source-map `sources` entry to a plain path. Bundlers prefix
90
+ * their own scheme (`webpack:///./src/a.ts`), dev servers serve absolute
91
+ * URLs paths (`/src/a.ts`), builds write relative ones (`../src/a.ts`).
92
+ * The server maps these onto the repo by suffix, so all that matters here
93
+ * is stripping what is not a path.
94
+ */
95
+ export function normalizeSourcePath(src, sourceRoot) {
96
+ let s = src;
97
+ if (sourceRoot && !/^[a-z]+:/i.test(s)) {
98
+ s = sourceRoot.replace(/\/?$/, "/") + s;
99
+ }
100
+ s = s.replace(/^webpack:\/\/\/?/, "").replace(/^file:\/\//, "");
101
+ // A URL (`http://host/src/a.ts`, Vite's `/@fs/…`) keeps only its path.
102
+ const m = /^[a-z]+:\/\/[^/]*(\/.*)$/i.exec(s);
103
+ if (m)
104
+ s = m[1];
105
+ s = s.replace(/^\/@fs\//, "/");
106
+ s = s.replace(/\?.*$/, "");
107
+ while (s.startsWith("./"))
108
+ s = s.slice(2);
109
+ return s;
110
+ }
111
+ /** Sources nobody's change can be attributed to: dependencies and bundler
112
+ * runtime shims. */
113
+ export function isForeignSource(path) {
114
+ return (path.length === 0 ||
115
+ path.includes("node_modules/") ||
116
+ path.startsWith("webpack/") ||
117
+ path.startsWith("(webpack)") ||
118
+ path.startsWith("\0") ||
119
+ path.startsWith("vite/") ||
120
+ path.startsWith("@vite/"));
121
+ }
122
+ /**
123
+ * Decode a source map document. Index maps (`sections`) are not
124
+ * supported and decode to `null`, as does anything malformed — the
125
+ * caller then falls back to attributing the script to its own URL, which
126
+ * the repo mapping cannot resolve, which is the safe answer.
127
+ */
128
+ export function decodeSourceMap(doc) {
129
+ if (!doc || typeof doc !== "object")
130
+ return null;
131
+ const m = doc;
132
+ if (m.sections || !Array.isArray(m.sources) || typeof m.mappings !== "string") {
133
+ return null;
134
+ }
135
+ const sourceRoot = typeof m.sourceRoot === "string" ? m.sourceRoot : undefined;
136
+ const sources = m.sources.map((s) => typeof s === "string" ? normalizeSourcePath(s, sourceRoot) : "");
137
+ const lines = [];
138
+ let src = 0;
139
+ let line = 0;
140
+ try {
141
+ for (const group of m.mappings.split(";")) {
142
+ const segs = [];
143
+ let col = 0;
144
+ if (group.length > 0) {
145
+ for (const seg of group.split(",")) {
146
+ if (seg.length === 0)
147
+ continue;
148
+ const f = decodeVlq(seg);
149
+ col += f[0];
150
+ if (f.length >= 4) {
151
+ src += f[1];
152
+ line += f[2];
153
+ segs.push({ col, src, line });
154
+ }
155
+ }
156
+ }
157
+ lines.push(segs);
158
+ }
159
+ }
160
+ catch {
161
+ return null;
162
+ }
163
+ return { sources, lines };
164
+ }
165
+ /**
166
+ * Attribute generated line counts to original (file, line)s through the
167
+ * map. Every original line a generated line's segments name gets that
168
+ * line's count (max over the generated lines that touch it). Foreign
169
+ * sources are dropped.
170
+ */
171
+ export function mapToOriginal(map, generated) {
172
+ const out = new Map();
173
+ for (const [genLine, count] of generated) {
174
+ const segs = map.lines[genLine - 1];
175
+ if (!segs || segs.length === 0)
176
+ continue;
177
+ for (const seg of segs) {
178
+ const file = map.sources[seg.src];
179
+ if (file === undefined || isForeignSource(file))
180
+ continue;
181
+ let lc = out.get(file);
182
+ if (!lc) {
183
+ lc = new Map();
184
+ out.set(file, lc);
185
+ }
186
+ const l = seg.line + 1;
187
+ lc.set(l, Math.max(lc.get(l) ?? 0, count));
188
+ }
189
+ }
190
+ return out;
191
+ }
192
+ // ── Accumulation + lcov ─────────────────────────────────────────────────
193
+ /** Add `from` into `into` (sums — takes reset V8's counters). */
194
+ export function mergeLineCounts(into, from) {
195
+ for (const [l, c] of from)
196
+ into.set(l, (into.get(l) ?? 0) + c);
197
+ }
198
+ export function mergeFileCounts(into, from) {
199
+ for (const [file, counts] of from) {
200
+ let target = into.get(file);
201
+ if (!target) {
202
+ target = new Map();
203
+ into.set(file, target);
204
+ }
205
+ mergeLineCounts(target, counts);
206
+ }
207
+ }
208
+ /** One lcov document for a set of files. */
209
+ export function lcovDocument(files) {
210
+ let out = "TN:\n";
211
+ for (const [file, counts] of [...files].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
212
+ out += `SF:${file}\n`;
213
+ let found = 0;
214
+ let hit = 0;
215
+ for (const [l, c] of [...counts].sort((a, b) => a[0] - b[0])) {
216
+ out += `DA:${l},${c}\n`;
217
+ found++;
218
+ if (c > 0)
219
+ hit++;
220
+ }
221
+ out += `LF:${found}\nLH:${hit}\nend_of_record\n`;
222
+ }
223
+ return out;
224
+ }
225
+ /**
226
+ * Which service a script URL's host belongs to: the service key itself,
227
+ * `<key>.internal`, its `hostnames`/`dnsName` aliases, a `tls`/`proxy`
228
+ * hostname routed to it, or a wildcard pointed at it. Exact first, then
229
+ * the longest wildcard suffix — the resolver's own precedence.
230
+ */
231
+ export function serviceForHost(host, services, table) {
232
+ const h = host.toLowerCase().replace(/\.$/, "");
233
+ for (const s of services) {
234
+ if (h === s.toLowerCase() || h === `${s.toLowerCase()}.internal`)
235
+ return s;
236
+ }
237
+ for (const [svc, aliases] of Object.entries(table.aliasesByService)) {
238
+ if (aliases.some((a) => a.toLowerCase() === h))
239
+ return svc;
240
+ }
241
+ for (const p of table.proxies) {
242
+ if (p.hostname.toLowerCase() === h)
243
+ return p.service;
244
+ }
245
+ let best;
246
+ for (const w of table.wildcards) {
247
+ const service = w.target.service;
248
+ if (!service)
249
+ continue;
250
+ const suffix = w.pattern.toLowerCase().replace(/^\*/, "");
251
+ if (h.endsWith(suffix) && h.length > suffix.length) {
252
+ if (!best || suffix.length > best.len)
253
+ best = { len: suffix.length, service };
254
+ }
255
+ }
256
+ return best?.service;
257
+ }
@@ -0,0 +1,60 @@
1
+ /** Where the directory is mounted inside an opted-in container. */
2
+ export declare const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
3
+ /** Raw bytes of reports one service may ship per capture. Reports are
4
+ * cumulative and bounded by code size, so anything past this is a
5
+ * runaway (a tool writing a new file per request), not coverage. */
6
+ export declare const COVERAGE_MAX_BYTES_PER_SERVICE: number;
7
+ /** Root of the per-service host directories, under the workspace so the
8
+ * delta-restore teardown wipes them with everything else. */
9
+ export declare function coverageHostDir(workspace: string, service: string): string;
10
+ /** One report file, verbatim. `name` is its path relative to the
11
+ * coverage directory. */
12
+ export interface CoverageReport {
13
+ name: string;
14
+ content: string;
15
+ }
16
+ /** One service's capture: every report in its directory, verbatim. */
17
+ export interface ServiceCoverageCapture {
18
+ service: string;
19
+ reports: CoverageReport[];
20
+ /** Set when the capture failed: the command failed, the directory was
21
+ * empty, no file held an `SF:` record, or the reports were too large.
22
+ * A case with an error here fails — a hole in the coverage data must
23
+ * never be silent. */
24
+ error?: string;
25
+ }
26
+ /** The small ref that rides the `run` reply in place of the bundle. */
27
+ export interface CoverageBundleRef {
28
+ /** Gzipped bundle size; the control plane pulls exactly this many bytes
29
+ * through `coverageChunk`. */
30
+ bytes: number;
31
+ services: {
32
+ service: string;
33
+ reports: number;
34
+ /** Raw (uncompressed) bytes of this service's reports. */
35
+ bytes: number;
36
+ error?: string;
37
+ }[];
38
+ }
39
+ /** The gzipped JSON document `coverageChunk` serves. Mirrors
40
+ * `storage::CaseCoverageBundle` on the server. */
41
+ export interface CoverageBundle {
42
+ caseId: string;
43
+ services: ServiceCoverageCapture[];
44
+ }
45
+ /** True when `text` holds at least one lcov `SF:` record. */
46
+ export declare function hasLcovSourceFile(text: string): boolean;
47
+ /** Every regular file under `dir`, recursively, as paths relative to it.
48
+ * Hidden files are skipped (a tool's own bookkeeping — `.seq`, `.lock`,
49
+ * a tmp file mid-write — is not a report). */
50
+ export declare function listReportFiles(dir: string): Promise<string[]>;
51
+ /**
52
+ * Read every report under `dir`, verbatim. An empty directory, or one with
53
+ * no `SF:` record anywhere, is an error rather than an empty capture: the
54
+ * contract is that an opted-in service always has a report to read.
55
+ */
56
+ export declare function readCoverageDir(service: string, dir: string): Promise<ServiceCoverageCapture>;
57
+ /** Gzip a case's captures into the bundle `coverageChunk` serves. */
58
+ export declare function encodeCoverageBundle(caseId: string, services: ServiceCoverageCapture[]): Buffer;
59
+ /** The ref for a bundle: its size plus a per-service summary. */
60
+ export declare function coverageBundleRef(gz: Buffer, services: ServiceCoverageCapture[]): CoverageBundleRef;
@@ -0,0 +1,122 @@
1
+ // Coverage collection — the pure part.
2
+ //
3
+ // A service that opts in (`coverage: true | { command }`) gets a writable
4
+ // directory bind-mounted at `/spectest/coverage/` in its container. The
5
+ // service's own tool writes lcov reports there. The daemon reads the
6
+ // directory from the VM side (no `docker exec` to fetch) after bring-up
7
+ // and after each test, and ships the reports **verbatim** to the control
8
+ // 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
11
+ // needs (today the set of source files; later, finer things) from the
12
+ // stored bytes, so a change in that derivation is a re-read of stored
13
+ // runs, never a hole in the history.
14
+ //
15
+ // The container side (mount flag, `command` exec) is in `daemon.ts`.
16
+ import { promises as fs } from "node:fs";
17
+ import path from "node:path";
18
+ import { gzipSync } from "node:zlib";
19
+ /** Where the directory is mounted inside an opted-in container. */
20
+ export const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
21
+ /** Raw bytes of reports one service may ship per capture. Reports are
22
+ * cumulative and bounded by code size, so anything past this is a
23
+ * runaway (a tool writing a new file per request), not coverage. */
24
+ export const COVERAGE_MAX_BYTES_PER_SERVICE = 64 * 1024 * 1024;
25
+ /** Root of the per-service host directories, under the workspace so the
26
+ * delta-restore teardown wipes them with everything else. */
27
+ export function coverageHostDir(workspace, service) {
28
+ return path.join(workspace, ".spectest", "coverage", service);
29
+ }
30
+ /** True when `text` holds at least one lcov `SF:` record. */
31
+ export function hasLcovSourceFile(text) {
32
+ return /^SF:\S/m.test(text);
33
+ }
34
+ /** Every regular file under `dir`, recursively, as paths relative to it.
35
+ * Hidden files are skipped (a tool's own bookkeeping — `.seq`, `.lock`,
36
+ * a tmp file mid-write — is not a report). */
37
+ export async function listReportFiles(dir) {
38
+ const out = [];
39
+ async function walk(d, rel) {
40
+ let entries;
41
+ try {
42
+ entries = await fs.readdir(d, { withFileTypes: true });
43
+ }
44
+ catch {
45
+ return;
46
+ }
47
+ for (const e of entries) {
48
+ if (e.name.startsWith("."))
49
+ continue;
50
+ const r = rel ? `${rel}/${e.name}` : e.name;
51
+ if (e.isDirectory())
52
+ await walk(path.join(d, e.name), r);
53
+ else if (e.isFile())
54
+ out.push(r);
55
+ }
56
+ }
57
+ await walk(dir, "");
58
+ return out.sort();
59
+ }
60
+ /**
61
+ * Read every report under `dir`, verbatim. An empty directory, or one with
62
+ * no `SF:` record anywhere, is an error rather than an empty capture: the
63
+ * contract is that an opted-in service always has a report to read.
64
+ */
65
+ export async function readCoverageDir(service, dir) {
66
+ const names = await listReportFiles(dir);
67
+ if (names.length === 0) {
68
+ return {
69
+ service,
70
+ reports: [],
71
+ 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`,
72
+ };
73
+ }
74
+ const reports = [];
75
+ let total = 0;
76
+ let anyLcov = false;
77
+ for (const name of names) {
78
+ let content;
79
+ try {
80
+ content = await fs.readFile(path.join(dir, name), "utf8");
81
+ }
82
+ catch {
83
+ continue;
84
+ }
85
+ total += content.length;
86
+ if (total > COVERAGE_MAX_BYTES_PER_SERVICE) {
87
+ return {
88
+ service,
89
+ reports: [],
90
+ 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`,
91
+ };
92
+ }
93
+ if (hasLcovSourceFile(content))
94
+ anyLcov = true;
95
+ reports.push({ name, content });
96
+ }
97
+ if (!anyLcov) {
98
+ return {
99
+ service,
100
+ reports,
101
+ error: `service "${service}" wrote ${reports.length} file(s) to ${COVERAGE_CONTAINER_DIR}/ but none holds an lcov \`SF:\` record — only lcov reports are read`,
102
+ };
103
+ }
104
+ return { service, reports };
105
+ }
106
+ /** Gzip a case's captures into the bundle `coverageChunk` serves. */
107
+ export function encodeCoverageBundle(caseId, services) {
108
+ const doc = { caseId, services };
109
+ return gzipSync(Buffer.from(JSON.stringify(doc)));
110
+ }
111
+ /** The ref for a bundle: its size plus a per-service summary. */
112
+ export function coverageBundleRef(gz, services) {
113
+ return {
114
+ bytes: gz.length,
115
+ services: services.map((s) => ({
116
+ service: s.service,
117
+ reports: s.reports.length,
118
+ bytes: s.reports.reduce((n, r) => n + r.content.length, 0),
119
+ ...(s.error ? { error: s.error } : {}),
120
+ })),
121
+ };
122
+ }
package/dist/index.d.ts CHANGED
@@ -115,6 +115,27 @@ export interface ServiceConfig {
115
115
  tls?: readonly ServiceTls[];
116
116
  /** Bind-mounted volumes for state that survives snapshot/fork. */
117
117
  volumes?: readonly VolumeMount[];
118
+ /**
119
+ * Opt this service in to **code coverage collection** (experimental).
120
+ *
121
+ * The service gets a writable directory bind-mounted at
122
+ * `/spectest/coverage/`. The service's own coverage tool writes **lcov**
123
+ * reports there — cumulative from process start, never reset. After
124
+ * bring-up and after every test, spectest runs `command` (if given) inside
125
+ * the container, then reads every file in the directory and records the
126
+ * set of source files that ran. Only the `SF:` records are read.
127
+ *
128
+ * - `true`: the service writes its reports by itself (a dump on a timer,
129
+ * an exit-time dump for short-lived processes).
130
+ * - `{ command }`: run this (via `sh -c`) in the container to make the
131
+ * service write a report, before the directory is read.
132
+ *
133
+ * The directory must hold at least one lcov report each time it is read;
134
+ * an empty directory or a failed command fails the test with a clear
135
+ * error, so a hole in the coverage data is never silent. See
136
+ * `spectest docs /services/coverage`.
137
+ */
138
+ coverage?: ServiceCoverage;
118
139
  /**
119
140
  * Files seeded into the container's filesystem **before it starts**.
120
141
  * Each entry's `content` is written to a VM-host staging path and
@@ -642,6 +663,29 @@ export type ServiceImage = {
642
663
  /** Extra glob patterns to exclude from the build context. */
643
664
  exclude?: readonly string[];
644
665
  };
666
+ /**
667
+ * A service's opt-in to coverage collection — see `ServiceConfig.coverage`.
668
+ * `true` when the service writes its own reports; `{ command }` when
669
+ * spectest must run a command in the container to produce one.
670
+ */
671
+ export type ServiceCoverage = true | {
672
+ /** Run this (via `sh -c`) in the container before the coverage
673
+ * directory is read, so the service writes a fresh lcov report. */
674
+ command?: string;
675
+ /**
676
+ * Collect **frontend** coverage for the app this service serves.
677
+ * The code runs in the guest browser — spectest's own process — so
678
+ * the user cannot write a report for it: spectest turns on V8
679
+ * coverage for every script `ctx.browser()`/`ctx.mobile()` loads
680
+ * from this service's origin, maps it back to original sources
681
+ * through the served source maps, and adds one lcov report to this
682
+ * service's capture. Scripts a source map can't resolve are
683
+ * attributed to their served path (which the repo mapping then
684
+ * treats as unknown — the safe answer). Needs the app to serve
685
+ * source maps to the test environment.
686
+ */
687
+ browser?: boolean;
688
+ };
645
689
  export interface VolumeMount {
646
690
  /**
647
691
  * Named shared volume. Two services mounting the same `name` share one
@@ -854,6 +898,17 @@ export interface FakeContext {
854
898
  * for a TLS-provisioning provider hands back to the app under test. */
855
899
  certificate(hostnames: readonly string[]): Promise<CertificateMaterial>;
856
900
  }
901
+ /**
902
+ * Check one service's `coverage` field. Exported so the daemon applies the
903
+ * same rule to a runtime `startService` spec.
904
+ */
905
+ export declare function validateCoverage(service: string, cov: unknown): void;
906
+ /** Whether a `coverage` value collects reports from the container's own
907
+ * filesystem (`/spectest/coverage/`). True for `true`, `{ command }`, and
908
+ * any object that isn't browser-only. */
909
+ export declare function coverageUsesContainer(cov: ServiceCoverage | undefined): boolean;
910
+ /** Whether a `coverage` value collects frontend (browser) coverage. */
911
+ export declare function coverageUsesBrowser(cov: ServiceCoverage | undefined): boolean;
857
912
  /**
858
913
  * A single test step. Created via `test(...)` or `createTest(services)`.
859
914
  * Tests are referenced (not named by string) when one test depends on
package/dist/index.js CHANGED
@@ -215,6 +215,37 @@ function expandServiceGroups(input) {
215
215
  }
216
216
  return out;
217
217
  }
218
+ /**
219
+ * Check one service's `coverage` field. Exported so the daemon applies the
220
+ * same rule to a runtime `startService` spec.
221
+ */
222
+ export function validateCoverage(service, cov) {
223
+ if (cov === undefined || cov === true)
224
+ return;
225
+ if (typeof cov === "object" && cov !== null) {
226
+ const o = cov;
227
+ const cmdOk = o.command === undefined ||
228
+ (typeof o.command === "string" && o.command.trim().length > 0);
229
+ const browserOk = o.browser === undefined || typeof o.browser === "boolean";
230
+ if (cmdOk && browserOk && (o.command !== undefined || o.browser === true))
231
+ return;
232
+ }
233
+ throw new Error(`service "${service}" has an invalid \`coverage\` value — use \`true\`, \`{ command: "<shell command>" }\`, or \`{ browser: true }\``);
234
+ }
235
+ /** Whether a `coverage` value collects reports from the container's own
236
+ * filesystem (`/spectest/coverage/`). True for `true`, `{ command }`, and
237
+ * any object that isn't browser-only. */
238
+ export function coverageUsesContainer(cov) {
239
+ if (cov === undefined)
240
+ return false;
241
+ if (cov === true)
242
+ return true;
243
+ return cov.command !== undefined || cov.browser !== true;
244
+ }
245
+ /** Whether a `coverage` value collects frontend (browser) coverage. */
246
+ export function coverageUsesBrowser(cov) {
247
+ return typeof cov === "object" && cov.browser === true;
248
+ }
218
249
  function validateEnvironmentConfig(config) {
219
250
  const entries = Object.entries(config.services);
220
251
  const serviceNames = new Set(entries.map(([n]) => n));
@@ -246,6 +277,7 @@ function validateEnvironmentConfig(config) {
246
277
  throw new Error(`service "${name}" volume name ${JSON.stringify(vol.name)} must be alphanumeric plus [._-]`);
247
278
  }
248
279
  }
280
+ validateCoverage(name, svc.coverage);
249
281
  for (const raw of svc.hostnames ?? []) {
250
282
  const h = raw.toLowerCase();
251
283
  if (!HOSTNAME_RE.test(hostPatternBody(h))) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.56.2",
3
+ "version": "0.58.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",