@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.
- package/dist/browser-coverage.d.ts +20 -0
- package/dist/browser-coverage.js +185 -0
- package/dist/browser.js +36 -0
- package/dist/daemon.js +200 -11
- package/dist/harness/browser-coverage.d.ts +95 -0
- package/dist/harness/browser-coverage.js +257 -0
- package/dist/harness/coverage.d.ts +60 -0
- package/dist/harness/coverage.js +122 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.js +32 -0
- package/package.json +1 -1
- package/src/browser-coverage.ts +209 -0
- package/src/browser.ts +36 -0
- package/src/daemon.ts +230 -11
- package/src/harness/browser-coverage.test.ts +94 -0
- package/src/harness/browser-coverage.ts +310 -0
- package/src/harness/coverage.test.ts +88 -0
- package/src/harness/coverage.ts +173 -0
- package/src/index.ts +81 -0
|
@@ -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))) {
|