@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/dist/browser-coverage.d.ts +20 -0
- package/dist/browser-coverage.js +185 -0
- package/dist/browser.js +36 -0
- package/dist/daemon.js +72 -11
- package/dist/harness/browser-coverage.d.ts +95 -0
- package/dist/harness/browser-coverage.js +257 -0
- package/dist/index.d.ts +22 -1
- package/dist/index.js +22 -6
- package/package.json +1 -1
- package/src/browser-coverage.ts +209 -0
- package/src/browser.ts +36 -0
- package/src/daemon.ts +78 -13
- package/src/harness/browser-coverage.test.ts +94 -0
- package/src/harness/browser-coverage.ts +310 -0
- package/src/index.ts +42 -9
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
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
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
|
|
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 {
|