@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,94 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
decodeSourceMap,
|
|
4
|
+
decodeVlq,
|
|
5
|
+
isForeignSource,
|
|
6
|
+
lcovDocument,
|
|
7
|
+
mapToOriginal,
|
|
8
|
+
mergeFileCounts,
|
|
9
|
+
normalizeSourcePath,
|
|
10
|
+
rangesToLineCounts,
|
|
11
|
+
serviceForHost,
|
|
12
|
+
} from "./browser-coverage";
|
|
13
|
+
|
|
14
|
+
describe("rangesToLineCounts", () => {
|
|
15
|
+
test("outer range sets lines, narrower ranges override, blanks dropped", () => {
|
|
16
|
+
const src = "function f() {\n if (x) {\n a();\n }\n\n b();\n}\n";
|
|
17
|
+
const fnRange = { startOffset: 0, endOffset: src.length, count: 3 };
|
|
18
|
+
const ifBlock = { startOffset: src.indexOf("{\n a"), endOffset: src.indexOf("}\n\n") + 1, count: 0 };
|
|
19
|
+
const counts = rangesToLineCounts(src, [{ ranges: [fnRange, ifBlock] }]);
|
|
20
|
+
expect(counts.get(1)).toBe(3);
|
|
21
|
+
expect(counts.get(3)).toBe(0);
|
|
22
|
+
expect(counts.get(6)).toBe(3);
|
|
23
|
+
expect(counts.has(5)).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("source maps", () => {
|
|
28
|
+
test("decodeVlq", () => {
|
|
29
|
+
expect(decodeVlq("AAAA")).toEqual([0, 0, 0, 0]);
|
|
30
|
+
expect(decodeVlq("IACC")).toEqual([4, 0, 1, 1]);
|
|
31
|
+
expect(decodeVlq("D")).toEqual([-1]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("decode + map generated lines onto original files", () => {
|
|
35
|
+
// Two generated lines: line 1 maps to src/a.ts:1 and node_modules/x.js:1,
|
|
36
|
+
// line 2 maps to src/b.ts:5 (columns irrelevant for line attribution).
|
|
37
|
+
const map = decodeSourceMap({
|
|
38
|
+
version: 3,
|
|
39
|
+
sources: ["webpack:///./src/a.ts", "webpack:///./node_modules/x.js", "./src/b.ts"],
|
|
40
|
+
mappings: "AAAA,ICAA;ACIA",
|
|
41
|
+
});
|
|
42
|
+
expect(map).not.toBeNull();
|
|
43
|
+
expect(map!.sources).toEqual(["src/a.ts", "node_modules/x.js", "src/b.ts"]);
|
|
44
|
+
const files = mapToOriginal(map!, new Map([[1, 2], [2, 0]]));
|
|
45
|
+
expect([...files.keys()]).toEqual(["src/a.ts", "src/b.ts"]);
|
|
46
|
+
expect(files.get("src/a.ts")!.get(1)).toBe(2);
|
|
47
|
+
expect(files.get("src/b.ts")!.get(5)).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("index maps and garbage decode to null", () => {
|
|
51
|
+
expect(decodeSourceMap({ version: 3, sections: [] })).toBeNull();
|
|
52
|
+
expect(decodeSourceMap({ version: 3, sources: ["a"], mappings: "!!" })).toBeNull();
|
|
53
|
+
expect(decodeSourceMap("nope")).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("normalizeSourcePath strips schemes, roots and queries", () => {
|
|
57
|
+
expect(normalizeSourcePath("webpack:///./src/a.ts")).toBe("src/a.ts");
|
|
58
|
+
expect(normalizeSourcePath("http://app.test/src/a.ts?t=1")).toBe("/src/a.ts");
|
|
59
|
+
expect(normalizeSourcePath("/@fs/repo/src/a.ts")).toBe("/repo/src/a.ts");
|
|
60
|
+
expect(normalizeSourcePath("a.ts", "/app/src")).toBe("/app/src/a.ts");
|
|
61
|
+
expect(normalizeSourcePath("file:///app/src/a.ts")).toBe("/app/src/a.ts");
|
|
62
|
+
expect(isForeignSource("webpack/bootstrap")).toBe(true);
|
|
63
|
+
expect(isForeignSource("src/a.ts")).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("lcovDocument is sorted, one record per file, merge sums", () => {
|
|
68
|
+
const files = new Map([["b.js", new Map([[2, 1]])], ["a.js", new Map([[1, 0], [3, 2]])]]);
|
|
69
|
+
mergeFileCounts(files, new Map([["a.js", new Map([[3, 1]])]]));
|
|
70
|
+
expect(lcovDocument(files)).toBe(
|
|
71
|
+
"TN:\nSF:a.js\nDA:1,0\nDA:3,3\nLF:2\nLH:1\nend_of_record\nSF:b.js\nDA:2,1\nLF:1\nLH:1\nend_of_record\n",
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("serviceForHost", () => {
|
|
76
|
+
const table = {
|
|
77
|
+
proxies: [{ hostname: "app.test", service: "web" }],
|
|
78
|
+
aliasesByService: { api: ["api.todos.local"] },
|
|
79
|
+
wildcards: [
|
|
80
|
+
{ pattern: "*.apps.test", target: { service: "k8s" } },
|
|
81
|
+
{ pattern: "*.fake.test", target: { ingress: true } },
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
const services = ["web", "api", "k8s"];
|
|
85
|
+
test("service key, .internal, alias, proxy, wildcard", () => {
|
|
86
|
+
expect(serviceForHost("web", services, table)).toBe("web");
|
|
87
|
+
expect(serviceForHost("WEB.internal", services, table)).toBe("web");
|
|
88
|
+
expect(serviceForHost("api.todos.local", services, table)).toBe("api");
|
|
89
|
+
expect(serviceForHost("app.test", services, table)).toBe("web");
|
|
90
|
+
expect(serviceForHost("a.b.apps.test", services, table)).toBe("k8s");
|
|
91
|
+
expect(serviceForHost("x.fake.test", services, table)).toBeUndefined();
|
|
92
|
+
expect(serviceForHost("example.com", services, table)).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,310 @@
|
|
|
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
|
+
|
|
15
|
+
/** Per-line hit counts, 1-based line numbers. */
|
|
16
|
+
export type LineCounts = Map<number, number>;
|
|
17
|
+
|
|
18
|
+
/** Per-file line counts. Keys are source paths as the source map (or the
|
|
19
|
+
* script URL, when there is no map) reports them. */
|
|
20
|
+
export type FileCounts = Map<string, LineCounts>;
|
|
21
|
+
|
|
22
|
+
// ── V8 precise coverage → generated line counts ─────────────────────────
|
|
23
|
+
|
|
24
|
+
/** Byte offset at which each line starts. */
|
|
25
|
+
export function lineStartsOf(source: string): number[] {
|
|
26
|
+
const starts = [0];
|
|
27
|
+
for (let i = 0; i < source.length; i++) {
|
|
28
|
+
if (source.charCodeAt(i) === 10) starts.push(i + 1);
|
|
29
|
+
}
|
|
30
|
+
return starts;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 1-based line holding `offset`. */
|
|
34
|
+
export function lineOf(starts: number[], offset: number): number {
|
|
35
|
+
let lo = 0;
|
|
36
|
+
let hi = starts.length - 1;
|
|
37
|
+
while (lo < hi) {
|
|
38
|
+
const mid = (lo + hi + 1) >> 1;
|
|
39
|
+
if (starts[mid] <= offset) lo = mid;
|
|
40
|
+
else hi = mid - 1;
|
|
41
|
+
}
|
|
42
|
+
return lo + 1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One V8 `FunctionCoverage` as `Profiler.takePreciseCoverage` reports it. */
|
|
46
|
+
export interface V8FunctionCoverage {
|
|
47
|
+
functionName?: string;
|
|
48
|
+
ranges: { startOffset: number; endOffset: number; count: number }[];
|
|
49
|
+
isBlockCoverage?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Fold a script's block ranges onto its lines. Ranges come outer-first
|
|
54
|
+
* and a narrower range overrides the lines it spans, so a line inside an
|
|
55
|
+
* unexecuted branch ends at 0 even though its enclosing function ran.
|
|
56
|
+
* Blank lines are dropped. Approximate — a line holding two blocks gets
|
|
57
|
+
* the narrower one's count — which is exact at the file level, the only
|
|
58
|
+
* level anything reads today.
|
|
59
|
+
*/
|
|
60
|
+
export function rangesToLineCounts(
|
|
61
|
+
source: string,
|
|
62
|
+
functions: readonly V8FunctionCoverage[],
|
|
63
|
+
): LineCounts {
|
|
64
|
+
const starts = lineStartsOf(source);
|
|
65
|
+
const counts: LineCounts = new Map();
|
|
66
|
+
for (const fn of functions) {
|
|
67
|
+
for (const r of fn.ranges) {
|
|
68
|
+
const a = lineOf(starts, r.startOffset);
|
|
69
|
+
const b = lineOf(starts, Math.max(r.startOffset, r.endOffset - 1));
|
|
70
|
+
for (let l = a; l <= b; l++) counts.set(l, r.count);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const l of [...counts.keys()]) {
|
|
74
|
+
const text = source.slice(starts[l - 1], starts[l] ?? source.length);
|
|
75
|
+
if (text.trim().length === 0) counts.delete(l);
|
|
76
|
+
}
|
|
77
|
+
return counts;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Source maps ─────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/** One mapping segment: generated column → original (source, line). */
|
|
83
|
+
export interface Segment {
|
|
84
|
+
col: number;
|
|
85
|
+
src: number;
|
|
86
|
+
line: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface DecodedSourceMap {
|
|
90
|
+
/** Normalised source paths, indexed by segment `src`. */
|
|
91
|
+
sources: string[];
|
|
92
|
+
/** Segments per generated line (0-based), sorted by column. */
|
|
93
|
+
lines: Segment[][];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
97
|
+
const B64_INDEX = new Map([...B64].map((c, i) => [c, i]));
|
|
98
|
+
|
|
99
|
+
/** Decode one VLQ-encoded segment into its fields. */
|
|
100
|
+
export function decodeVlq(segment: string): number[] {
|
|
101
|
+
const out: number[] = [];
|
|
102
|
+
let value = 0;
|
|
103
|
+
let shift = 0;
|
|
104
|
+
for (const ch of segment) {
|
|
105
|
+
const digit = B64_INDEX.get(ch);
|
|
106
|
+
if (digit === undefined) throw new Error(`bad VLQ char ${JSON.stringify(ch)}`);
|
|
107
|
+
value += (digit & 31) << shift;
|
|
108
|
+
if (digit & 32) {
|
|
109
|
+
shift += 5;
|
|
110
|
+
} else {
|
|
111
|
+
const negative = value & 1;
|
|
112
|
+
value >>>= 1;
|
|
113
|
+
out.push(negative ? -value : value);
|
|
114
|
+
value = 0;
|
|
115
|
+
shift = 0;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Normalise a source-map `sources` entry to a plain path. Bundlers prefix
|
|
123
|
+
* their own scheme (`webpack:///./src/a.ts`), dev servers serve absolute
|
|
124
|
+
* URLs paths (`/src/a.ts`), builds write relative ones (`../src/a.ts`).
|
|
125
|
+
* The server maps these onto the repo by suffix, so all that matters here
|
|
126
|
+
* is stripping what is not a path.
|
|
127
|
+
*/
|
|
128
|
+
export function normalizeSourcePath(src: string, sourceRoot?: string): string {
|
|
129
|
+
let s = src;
|
|
130
|
+
if (sourceRoot && !/^[a-z]+:/i.test(s)) {
|
|
131
|
+
s = sourceRoot.replace(/\/?$/, "/") + s;
|
|
132
|
+
}
|
|
133
|
+
s = s.replace(/^webpack:\/\/\/?/, "").replace(/^file:\/\//, "");
|
|
134
|
+
// A URL (`http://host/src/a.ts`, Vite's `/@fs/…`) keeps only its path.
|
|
135
|
+
const m = /^[a-z]+:\/\/[^/]*(\/.*)$/i.exec(s);
|
|
136
|
+
if (m) s = m[1];
|
|
137
|
+
s = s.replace(/^\/@fs\//, "/");
|
|
138
|
+
s = s.replace(/\?.*$/, "");
|
|
139
|
+
while (s.startsWith("./")) s = s.slice(2);
|
|
140
|
+
return s;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Sources nobody's change can be attributed to: dependencies and bundler
|
|
144
|
+
* runtime shims. */
|
|
145
|
+
export function isForeignSource(path: string): boolean {
|
|
146
|
+
return (
|
|
147
|
+
path.length === 0 ||
|
|
148
|
+
path.includes("node_modules/") ||
|
|
149
|
+
path.startsWith("webpack/") ||
|
|
150
|
+
path.startsWith("(webpack)") ||
|
|
151
|
+
path.startsWith("\0") ||
|
|
152
|
+
path.startsWith("vite/") ||
|
|
153
|
+
path.startsWith("@vite/")
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Decode a source map document. Index maps (`sections`) are not
|
|
159
|
+
* supported and decode to `null`, as does anything malformed — the
|
|
160
|
+
* caller then falls back to attributing the script to its own URL, which
|
|
161
|
+
* the repo mapping cannot resolve, which is the safe answer.
|
|
162
|
+
*/
|
|
163
|
+
export function decodeSourceMap(doc: unknown): DecodedSourceMap | null {
|
|
164
|
+
if (!doc || typeof doc !== "object") return null;
|
|
165
|
+
const m = doc as {
|
|
166
|
+
version?: number;
|
|
167
|
+
sources?: unknown;
|
|
168
|
+
sourceRoot?: unknown;
|
|
169
|
+
mappings?: unknown;
|
|
170
|
+
sections?: unknown;
|
|
171
|
+
};
|
|
172
|
+
if (m.sections || !Array.isArray(m.sources) || typeof m.mappings !== "string") {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const sourceRoot = typeof m.sourceRoot === "string" ? m.sourceRoot : undefined;
|
|
176
|
+
const sources = m.sources.map((s) =>
|
|
177
|
+
typeof s === "string" ? normalizeSourcePath(s, sourceRoot) : "",
|
|
178
|
+
);
|
|
179
|
+
const lines: Segment[][] = [];
|
|
180
|
+
let src = 0;
|
|
181
|
+
let line = 0;
|
|
182
|
+
try {
|
|
183
|
+
for (const group of m.mappings.split(";")) {
|
|
184
|
+
const segs: Segment[] = [];
|
|
185
|
+
let col = 0;
|
|
186
|
+
if (group.length > 0) {
|
|
187
|
+
for (const seg of group.split(",")) {
|
|
188
|
+
if (seg.length === 0) continue;
|
|
189
|
+
const f = decodeVlq(seg);
|
|
190
|
+
col += f[0];
|
|
191
|
+
if (f.length >= 4) {
|
|
192
|
+
src += f[1];
|
|
193
|
+
line += f[2];
|
|
194
|
+
segs.push({ col, src, line });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
lines.push(segs);
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
return { sources, lines };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Attribute generated line counts to original (file, line)s through the
|
|
208
|
+
* map. Every original line a generated line's segments name gets that
|
|
209
|
+
* line's count (max over the generated lines that touch it). Foreign
|
|
210
|
+
* sources are dropped.
|
|
211
|
+
*/
|
|
212
|
+
export function mapToOriginal(map: DecodedSourceMap, generated: LineCounts): FileCounts {
|
|
213
|
+
const out: FileCounts = new Map();
|
|
214
|
+
for (const [genLine, count] of generated) {
|
|
215
|
+
const segs = map.lines[genLine - 1];
|
|
216
|
+
if (!segs || segs.length === 0) continue;
|
|
217
|
+
for (const seg of segs) {
|
|
218
|
+
const file = map.sources[seg.src];
|
|
219
|
+
if (file === undefined || isForeignSource(file)) continue;
|
|
220
|
+
let lc = out.get(file);
|
|
221
|
+
if (!lc) {
|
|
222
|
+
lc = new Map();
|
|
223
|
+
out.set(file, lc);
|
|
224
|
+
}
|
|
225
|
+
const l = seg.line + 1;
|
|
226
|
+
lc.set(l, Math.max(lc.get(l) ?? 0, count));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── Accumulation + lcov ─────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
/** Add `from` into `into` (sums — takes reset V8's counters). */
|
|
235
|
+
export function mergeLineCounts(into: LineCounts, from: LineCounts): void {
|
|
236
|
+
for (const [l, c] of from) into.set(l, (into.get(l) ?? 0) + c);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function mergeFileCounts(into: FileCounts, from: FileCounts): void {
|
|
240
|
+
for (const [file, counts] of from) {
|
|
241
|
+
let target = into.get(file);
|
|
242
|
+
if (!target) {
|
|
243
|
+
target = new Map();
|
|
244
|
+
into.set(file, target);
|
|
245
|
+
}
|
|
246
|
+
mergeLineCounts(target, counts);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** One lcov document for a set of files. */
|
|
251
|
+
export function lcovDocument(files: FileCounts): string {
|
|
252
|
+
let out = "TN:\n";
|
|
253
|
+
for (const [file, counts] of [...files].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
254
|
+
out += `SF:${file}\n`;
|
|
255
|
+
let found = 0;
|
|
256
|
+
let hit = 0;
|
|
257
|
+
for (const [l, c] of [...counts].sort((a, b) => a[0] - b[0])) {
|
|
258
|
+
out += `DA:${l},${c}\n`;
|
|
259
|
+
found++;
|
|
260
|
+
if (c > 0) hit++;
|
|
261
|
+
}
|
|
262
|
+
out += `LF:${found}\nLH:${hit}\nend_of_record\n`;
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Host → service ──────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
/** The slice of the lowered ingress that names hosts. */
|
|
270
|
+
export interface HostTable {
|
|
271
|
+
proxies: readonly { hostname: string; service: string }[];
|
|
272
|
+
aliasesByService: Readonly<Record<string, readonly string[]>>;
|
|
273
|
+
wildcards: readonly {
|
|
274
|
+
pattern: string;
|
|
275
|
+
target: { service?: string } | { ingress?: boolean } | Record<string, unknown>;
|
|
276
|
+
}[];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Which service a script URL's host belongs to: the service key itself,
|
|
281
|
+
* `<key>.internal`, its `hostnames`/`dnsName` aliases, a `tls`/`proxy`
|
|
282
|
+
* hostname routed to it, or a wildcard pointed at it. Exact first, then
|
|
283
|
+
* the longest wildcard suffix — the resolver's own precedence.
|
|
284
|
+
*/
|
|
285
|
+
export function serviceForHost(
|
|
286
|
+
host: string,
|
|
287
|
+
services: readonly string[],
|
|
288
|
+
table: HostTable,
|
|
289
|
+
): string | undefined {
|
|
290
|
+
const h = host.toLowerCase().replace(/\.$/, "");
|
|
291
|
+
for (const s of services) {
|
|
292
|
+
if (h === s.toLowerCase() || h === `${s.toLowerCase()}.internal`) return s;
|
|
293
|
+
}
|
|
294
|
+
for (const [svc, aliases] of Object.entries(table.aliasesByService)) {
|
|
295
|
+
if (aliases.some((a) => a.toLowerCase() === h)) return svc;
|
|
296
|
+
}
|
|
297
|
+
for (const p of table.proxies) {
|
|
298
|
+
if (p.hostname.toLowerCase() === h) return p.service;
|
|
299
|
+
}
|
|
300
|
+
let best: { len: number; service: string } | undefined;
|
|
301
|
+
for (const w of table.wildcards) {
|
|
302
|
+
const service = (w.target as { service?: string }).service;
|
|
303
|
+
if (!service) continue;
|
|
304
|
+
const suffix = w.pattern.toLowerCase().replace(/^\*/, "");
|
|
305
|
+
if (h.endsWith(suffix) && h.length > suffix.length) {
|
|
306
|
+
if (!best || suffix.length > best.len) best = { len: suffix.length, service };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return best?.service;
|
|
310
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { gunzipSync } from "node:zlib";
|
|
6
|
+
import {
|
|
7
|
+
coverageBundleRef,
|
|
8
|
+
coverageHostDir,
|
|
9
|
+
encodeCoverageBundle,
|
|
10
|
+
hasLcovSourceFile,
|
|
11
|
+
listReportFiles,
|
|
12
|
+
readCoverageDir,
|
|
13
|
+
} from "./coverage";
|
|
14
|
+
|
|
15
|
+
async function tmp(): Promise<string> {
|
|
16
|
+
return fs.mkdtemp(path.join(os.tmpdir(), "spectest-cov-"));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("hasLcovSourceFile", () => {
|
|
20
|
+
test("finds an SF record anywhere, tolerating CRLF", () => {
|
|
21
|
+
expect(hasLcovSourceFile("TN:\r\nSF:/app/server.js\r\nDA:1,1\n")).toBe(true);
|
|
22
|
+
expect(hasLcovSourceFile("TN:\nSF:\nend_of_record\n")).toBe(false);
|
|
23
|
+
expect(hasLcovSourceFile("{}")).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("readCoverageDir", () => {
|
|
28
|
+
test("ships every report verbatim, nested, with relative names", async () => {
|
|
29
|
+
const dir = await tmp();
|
|
30
|
+
await fs.mkdir(path.join(dir, "sub"));
|
|
31
|
+
await fs.writeFile(path.join(dir, "lcov.info"), "SF:/app/b.js\nDA:1,1\nend_of_record\n");
|
|
32
|
+
await fs.writeFile(path.join(dir, "sub", "more.info"), "SF:/app/c.js\n");
|
|
33
|
+
await fs.writeFile(path.join(dir, ".seq"), "3");
|
|
34
|
+
const r = await readCoverageDir("web", dir);
|
|
35
|
+
expect(r.error).toBeUndefined();
|
|
36
|
+
expect(r.reports).toEqual([
|
|
37
|
+
{ name: "lcov.info", content: "SF:/app/b.js\nDA:1,1\nend_of_record\n" },
|
|
38
|
+
{ name: "sub/more.info", content: "SF:/app/c.js\n" },
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("an empty directory is an error, not an empty capture", async () => {
|
|
43
|
+
const dir = await tmp();
|
|
44
|
+
const r = await readCoverageDir("web", dir);
|
|
45
|
+
expect(r.reports).toEqual([]);
|
|
46
|
+
expect(r.error).toContain("holds no report");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("a missing directory is reported like an empty one", async () => {
|
|
50
|
+
const r = await readCoverageDir("web", "/nonexistent/spectest-cov");
|
|
51
|
+
expect(r.error).toContain("holds no report");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("files with no SF record are an error, but still shipped", async () => {
|
|
55
|
+
const dir = await tmp();
|
|
56
|
+
await fs.writeFile(path.join(dir, "coverage.json"), "{}");
|
|
57
|
+
const r = await readCoverageDir("web", dir);
|
|
58
|
+
expect(r.reports.length).toBe(1);
|
|
59
|
+
expect(r.error).toContain("SF:");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("listReportFiles skips dotfiles", async () => {
|
|
63
|
+
const dir = await tmp();
|
|
64
|
+
await fs.writeFile(path.join(dir, ".lock"), "");
|
|
65
|
+
await fs.writeFile(path.join(dir, "a.info"), "");
|
|
66
|
+
expect(await listReportFiles(dir)).toEqual(["a.info"]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("bundle round-trips and the ref summarises it", () => {
|
|
71
|
+
const services = [
|
|
72
|
+
{ service: "web", reports: [{ name: "lcov.info", content: "SF:/app/a.js\n" }] },
|
|
73
|
+
{ service: "api", reports: [], error: "no report" },
|
|
74
|
+
];
|
|
75
|
+
const gz = encodeCoverageBundle("case-1", services);
|
|
76
|
+
expect(JSON.parse(gunzipSync(gz).toString())).toEqual({ caseId: "case-1", services });
|
|
77
|
+
expect(coverageBundleRef(gz, services)).toEqual({
|
|
78
|
+
bytes: gz.length,
|
|
79
|
+
services: [
|
|
80
|
+
{ service: "web", reports: 1, bytes: 13 },
|
|
81
|
+
{ service: "api", reports: 0, bytes: 0, error: "no report" },
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("coverageHostDir sits under the workspace's .spectest", () => {
|
|
87
|
+
expect(coverageHostDir("/workspace", "api")).toBe("/workspace/.spectest/coverage/api");
|
|
88
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
|
|
17
|
+
import { promises as fs } from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { gzipSync } from "node:zlib";
|
|
20
|
+
|
|
21
|
+
/** Where the directory is mounted inside an opted-in container. */
|
|
22
|
+
export const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
|
|
23
|
+
|
|
24
|
+
/** 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
|
|
26
|
+
* runaway (a tool writing a new file per request), not coverage. */
|
|
27
|
+
export const COVERAGE_MAX_BYTES_PER_SERVICE = 64 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
/** Root of the per-service host directories, under the workspace so the
|
|
30
|
+
* delta-restore teardown wipes them with everything else. */
|
|
31
|
+
export function coverageHostDir(workspace: string, service: string): string {
|
|
32
|
+
return path.join(workspace, ".spectest", "coverage", service);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One report file, verbatim. `name` is its path relative to the
|
|
36
|
+
* coverage directory. */
|
|
37
|
+
export interface CoverageReport {
|
|
38
|
+
name: string;
|
|
39
|
+
content: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One service's capture: every report in its directory, verbatim. */
|
|
43
|
+
export interface ServiceCoverageCapture {
|
|
44
|
+
service: string;
|
|
45
|
+
reports: CoverageReport[];
|
|
46
|
+
/** Set when the capture failed: the command failed, the directory was
|
|
47
|
+
* empty, no file held an `SF:` record, or the reports were too large.
|
|
48
|
+
* A case with an error here fails — a hole in the coverage data must
|
|
49
|
+
* never be silent. */
|
|
50
|
+
error?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The small ref that rides the `run` reply in place of the bundle. */
|
|
54
|
+
export interface CoverageBundleRef {
|
|
55
|
+
/** Gzipped bundle size; the control plane pulls exactly this many bytes
|
|
56
|
+
* through `coverageChunk`. */
|
|
57
|
+
bytes: number;
|
|
58
|
+
services: {
|
|
59
|
+
service: string;
|
|
60
|
+
reports: number;
|
|
61
|
+
/** Raw (uncompressed) bytes of this service's reports. */
|
|
62
|
+
bytes: number;
|
|
63
|
+
error?: string;
|
|
64
|
+
}[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The gzipped JSON document `coverageChunk` serves. Mirrors
|
|
68
|
+
* `storage::CaseCoverageBundle` on the server. */
|
|
69
|
+
export interface CoverageBundle {
|
|
70
|
+
caseId: string;
|
|
71
|
+
services: ServiceCoverageCapture[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** True when `text` holds at least one lcov `SF:` record. */
|
|
75
|
+
export function hasLcovSourceFile(text: string): boolean {
|
|
76
|
+
return /^SF:\S/m.test(text);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Every regular file under `dir`, recursively, as paths relative to it.
|
|
80
|
+
* Hidden files are skipped (a tool's own bookkeeping — `.seq`, `.lock`,
|
|
81
|
+
* a tmp file mid-write — is not a report). */
|
|
82
|
+
export async function listReportFiles(dir: string): Promise<string[]> {
|
|
83
|
+
const out: string[] = [];
|
|
84
|
+
async function walk(d: string, rel: string): Promise<void> {
|
|
85
|
+
let entries;
|
|
86
|
+
try {
|
|
87
|
+
entries = await fs.readdir(d, { withFileTypes: true });
|
|
88
|
+
} catch {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
for (const e of entries) {
|
|
92
|
+
if (e.name.startsWith(".")) continue;
|
|
93
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
94
|
+
if (e.isDirectory()) await walk(path.join(d, e.name), r);
|
|
95
|
+
else if (e.isFile()) out.push(r);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
await walk(dir, "");
|
|
99
|
+
return out.sort();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
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.
|
|
106
|
+
*/
|
|
107
|
+
export async function readCoverageDir(
|
|
108
|
+
service: string,
|
|
109
|
+
dir: string,
|
|
110
|
+
): Promise<ServiceCoverageCapture> {
|
|
111
|
+
const names = await listReportFiles(dir);
|
|
112
|
+
if (names.length === 0) {
|
|
113
|
+
return {
|
|
114
|
+
service,
|
|
115
|
+
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`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const reports: CoverageReport[] = [];
|
|
120
|
+
let total = 0;
|
|
121
|
+
let anyLcov = false;
|
|
122
|
+
for (const name of names) {
|
|
123
|
+
let content: string;
|
|
124
|
+
try {
|
|
125
|
+
content = await fs.readFile(path.join(dir, name), "utf8");
|
|
126
|
+
} catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
total += content.length;
|
|
130
|
+
if (total > COVERAGE_MAX_BYTES_PER_SERVICE) {
|
|
131
|
+
return {
|
|
132
|
+
service,
|
|
133
|
+
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`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (hasLcovSourceFile(content)) anyLcov = true;
|
|
138
|
+
reports.push({ name, content });
|
|
139
|
+
}
|
|
140
|
+
if (!anyLcov) {
|
|
141
|
+
return {
|
|
142
|
+
service,
|
|
143
|
+
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`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return { service, reports };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Gzip a case's captures into the bundle `coverageChunk` serves. */
|
|
151
|
+
export function encodeCoverageBundle(
|
|
152
|
+
caseId: string,
|
|
153
|
+
services: ServiceCoverageCapture[],
|
|
154
|
+
): Buffer {
|
|
155
|
+
const doc: CoverageBundle = { caseId, services };
|
|
156
|
+
return gzipSync(Buffer.from(JSON.stringify(doc)));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The ref for a bundle: its size plus a per-service summary. */
|
|
160
|
+
export function coverageBundleRef(
|
|
161
|
+
gz: Buffer,
|
|
162
|
+
services: ServiceCoverageCapture[],
|
|
163
|
+
): CoverageBundleRef {
|
|
164
|
+
return {
|
|
165
|
+
bytes: gz.length,
|
|
166
|
+
services: services.map((s) => ({
|
|
167
|
+
service: s.service,
|
|
168
|
+
reports: s.reports.length,
|
|
169
|
+
bytes: s.reports.reduce((n, r) => n + r.content.length, 0),
|
|
170
|
+
...(s.error ? { error: s.error } : {}),
|
|
171
|
+
})),
|
|
172
|
+
};
|
|
173
|
+
}
|