@specific.dev/spectest 0.57.0 → 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/src/daemon.ts CHANGED
@@ -33,6 +33,8 @@ import {
33
33
  isWildcard,
34
34
  proxy as makeProxyDecl,
35
35
  validateCoverage,
36
+ coverageUsesBrowser,
37
+ coverageUsesContainer,
36
38
  } from "./index.js";
37
39
  import type { DnsTarget, LoweredIngress } from "./index.js";
38
40
  import {
@@ -42,8 +44,14 @@ import {
42
44
  encodeCoverageBundle,
43
45
  readCoverageDir,
44
46
  type CoverageBundleRef,
47
+ type CoverageReport,
45
48
  type ServiceCoverageCapture,
46
49
  } from "./harness/coverage.js";
50
+ import {
51
+ browserCoverageReports,
52
+ configureBrowserCoverage,
53
+ } from "./browser-coverage.js";
54
+ import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
47
55
  import { acquirePersistentBrowser, mobileKey } from "./browser.js";
48
56
  import { isMobileApp, openPersistentMobile } from "./mobile.js";
49
57
  // Pure ingress hostname matching, ported out of this file (see
@@ -777,6 +785,9 @@ async function ensureVolumes(svc: NamedService): Promise<string[]> {
777
785
  async function ensureCoverage(svc: NamedService): Promise<string[]> {
778
786
  if (svc.coverage === undefined) return [];
779
787
  validateCoverage(svc.name, svc.coverage);
788
+ // A browser-only opt-in has no container process to write reports, so it
789
+ // gets no mount — its coverage comes from the guest browser.
790
+ if (!coverageUsesContainer(svc.coverage)) return [];
780
791
  const host = coverageHostDir(WORKSPACE, svc.name);
781
792
  await fs.mkdir(host, { recursive: true });
782
793
  await fs.chmod(host, 0o777);
@@ -1802,6 +1813,7 @@ function buildIngress(project: Project): void {
1802
1813
  // generic decl set the daemon executes. The special-casing lives in the
1803
1814
  // SDK's lowerIngress, not here.
1804
1815
  LOWERED = lowerIngress(project);
1816
+ configureCoverageAttribution(project);
1805
1817
  if (!project.fakes) return;
1806
1818
  for (const [name, def] of Object.entries(project.fakes)) {
1807
1819
  FAKES.set(name, {
@@ -3601,6 +3613,35 @@ async function captureServiceLogDeltas(): Promise<ServiceLogDelta[]> {
3601
3613
 
3602
3614
  const COVERAGE_COMMAND_TIMEOUT_MS = 60_000;
3603
3615
 
3616
+ /** Set the browser-coverage host→service attribution from the loaded
3617
+ * project: only services that opted in to `coverage: { browser: true }`
3618
+ * are attributed, so a script from an uncovered service is ignored. Null
3619
+ * when no service opted in — coverage then attaches nothing. Called on
3620
+ * every /load. */
3621
+ function configureCoverageAttribution(project: Project): void {
3622
+ const env = project.environment;
3623
+ const browserServices = new Set<string>();
3624
+ for (const svc of namedServices(env)) {
3625
+ if (coverageUsesBrowser(svc.coverage)) browserServices.add(svc.name);
3626
+ }
3627
+ if (browserServices.size === 0) {
3628
+ configureBrowserCoverage(null);
3629
+ return;
3630
+ }
3631
+ const all = namedServices(env).map((s) => s.name);
3632
+ const table = {
3633
+ proxies: LOWERED.proxies,
3634
+ aliasesByService: LOWERED.aliasesByService,
3635
+ wildcards: LOWERED.wildcards,
3636
+ };
3637
+ configureBrowserCoverage({
3638
+ serviceForHost(host: string): string | undefined {
3639
+ const svc = hostToService(host, all, table);
3640
+ return svc && browserServices.has(svc) ? svc : undefined;
3641
+ },
3642
+ });
3643
+ }
3644
+
3604
3645
  async function captureServiceCoverage(): Promise<ServiceCoverageCapture[]> {
3605
3646
  const l = loaded;
3606
3647
  if (!l) return [];
@@ -3608,24 +3649,48 @@ async function captureServiceCoverage(): Promise<ServiceCoverageCapture[]> {
3608
3649
  for (const s of namedServices(l.project.environment)) byName.set(s.name, s);
3609
3650
  for (const [name, s] of RUNTIME_SERVICES) byName.set(name, s);
3610
3651
  const services = [...byName.values()].filter((s) => s.coverage !== undefined);
3652
+ // Browser coverage is per-service lcov, accumulated in module memory
3653
+ // (browser-coverage.ts) across every harvest; read it once here.
3654
+ const browser = browserCoverageReports();
3611
3655
  return Promise.all(
3612
3656
  services.map(async (svc): Promise<ServiceCoverageCapture> => {
3613
3657
  const cov = svc.coverage;
3614
- if (typeof cov === "object" && cov !== null) {
3615
- const r = await docker(
3616
- ["exec", svc.name, "sh", "-c", cov.command],
3617
- COVERAGE_COMMAND_TIMEOUT_MS,
3618
- );
3619
- if (r.code !== 0) {
3620
- const tail = capMiddle((r.stderr || r.stdout).trim(), 2000).value;
3621
- return {
3622
- service: svc.name,
3623
- reports: [],
3624
- error: `coverage command for service "${svc.name}" failed (exit ${r.code}): ${JSON.stringify(cov.command)}${tail ? `\n${tail}` : ""}`,
3625
- };
3658
+ const reports: CoverageReport[] = [];
3659
+ // Container reports (a `command` runs first to flush a fresh dump).
3660
+ if (coverageUsesContainer(cov)) {
3661
+ if (typeof cov === "object" && cov !== null && cov.command) {
3662
+ const r = await docker(
3663
+ ["exec", svc.name, "sh", "-c", cov.command],
3664
+ COVERAGE_COMMAND_TIMEOUT_MS,
3665
+ );
3666
+ if (r.code !== 0) {
3667
+ const tail = capMiddle((r.stderr || r.stdout).trim(), 2000).value;
3668
+ return {
3669
+ service: svc.name,
3670
+ reports: [],
3671
+ error: `coverage command for service "${svc.name}" failed (exit ${r.code}): ${JSON.stringify(cov.command)}${tail ? `\n${tail}` : ""}`,
3672
+ };
3673
+ }
3626
3674
  }
3675
+ const dir = await readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
3676
+ // A service that ALSO does browser coverage may legitimately have
3677
+ // an empty container dir for a test — don't fail if the browser
3678
+ // side has something. Otherwise the container contract stands.
3679
+ if (dir.error && !(coverageUsesBrowser(cov) && browser.has(svc.name))) {
3680
+ return dir;
3681
+ }
3682
+ reports.push(...dir.reports);
3683
+ }
3684
+ // Browser report for this service, when any script it served ran.
3685
+ const lcov = browser.get(svc.name);
3686
+ if (lcov) reports.push({ name: "browser.lcov", content: lcov });
3687
+ if (reports.length === 0 && coverageUsesBrowser(cov) && !coverageUsesContainer(cov)) {
3688
+ // Browser-only service, no browser activity yet this branch. Not an
3689
+ // error — a test needn't open a browser; the report appears once
3690
+ // one does (and forks to descendants from there).
3691
+ return { service: svc.name, reports: [] };
3627
3692
  }
3628
- return readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
3693
+ return { service: svc.name, reports };
3629
3694
  }),
3630
3695
  );
3631
3696
  }
@@ -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
+ }
package/src/index.ts CHANGED
@@ -1043,7 +1043,26 @@ export type ServiceImage =
1043
1043
  * `true` when the service writes its own reports; `{ command }` when
1044
1044
  * spectest must run a command in the container to produce one.
1045
1045
  */
1046
- export type ServiceCoverage = true | { command: string };
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
+ };
1047
1066
 
1048
1067
  export interface VolumeMount {
1049
1068
  /**
@@ -1270,19 +1289,33 @@ export interface FakeContext {
1270
1289
  */
1271
1290
  export function validateCoverage(service: string, cov: unknown): void {
1272
1291
  if (cov === undefined || cov === true) return;
1273
- if (
1274
- typeof cov === "object" &&
1275
- cov !== null &&
1276
- typeof (cov as { command?: unknown }).command === "string" &&
1277
- (cov as { command: string }).command.trim().length > 0
1278
- ) {
1279
- 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;
1280
1299
  }
1281
1300
  throw new Error(
1282
- `service "${service}" has an invalid \`coverage\` value — use \`true\` or \`{ command: "<shell command>" }\``,
1301
+ `service "${service}" has an invalid \`coverage\` value — use \`true\`, \`{ command: "<shell command>" }\`, or \`{ browser: true }\``,
1283
1302
  );
1284
1303
  }
1285
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
+
1286
1319
  function validateEnvironmentConfig<S extends ServicesMap>(
1287
1320
  config: EnvironmentConfig<S>,
1288
1321
  ): void {