@specific.dev/spectest 0.57.0 → 0.59.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/coverage.d.ts +124 -0
- package/dist/coverage.js +236 -0
- package/dist/daemon.js +138 -24
- package/dist/harness/browser-coverage.d.ts +95 -0
- package/dist/harness/browser-coverage.js +257 -0
- package/dist/harness/coverage.d.ts +23 -4
- package/dist/harness/coverage.js +50 -14
- package/dist/index.d.ts +18 -25
- package/dist/index.js +6 -16
- package/package.json +6 -1
- package/src/browser-coverage.ts +209 -0
- package/src/browser.ts +36 -0
- package/src/coverage.test.ts +192 -0
- package/src/coverage.ts +327 -0
- package/src/daemon.ts +152 -26
- package/src/harness/browser-coverage.test.ts +94 -0
- package/src/harness/browser-coverage.ts +310 -0
- package/src/harness/coverage.test.ts +46 -1
- package/src/harness/coverage.ts +53 -13
- package/src/index.ts +36 -38
package/dist/daemon.js
CHANGED
|
@@ -21,8 +21,11 @@ import { existsSync, promises as fs, readFileSync } from "node:fs";
|
|
|
21
21
|
import net from "node:net";
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
import { pathToFileURL } from "node:url";
|
|
24
|
-
import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, proxy as makeProxyDecl,
|
|
25
|
-
import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, readCoverageDir, } from "./harness/coverage.js";
|
|
24
|
+
import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, proxy as makeProxyDecl, } from "./index.js";
|
|
25
|
+
import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, } from "./harness/coverage.js";
|
|
26
|
+
import { configureBrowserCoverage } from "./browser-coverage.js";
|
|
27
|
+
import { applyCoverageAdapters, coverageAdapters, validateCoverage, } from "./coverage.js";
|
|
28
|
+
import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
|
|
26
29
|
import { acquirePersistentBrowser, mobileKey } from "./browser.js";
|
|
27
30
|
import { isMobileApp, openPersistentMobile } from "./mobile.js";
|
|
28
31
|
// Pure ingress hostname matching, ported out of this file (see
|
|
@@ -1422,6 +1425,11 @@ function buildIngress(project) {
|
|
|
1422
1425
|
// generic decl set the daemon executes. The special-casing lives in the
|
|
1423
1426
|
// SDK's lowerIngress, not here.
|
|
1424
1427
|
LOWERED = lowerIngress(project);
|
|
1428
|
+
BROWSER_COVERAGE_SERVICES.clear();
|
|
1429
|
+
void runCoverageLoadHooks(namedServices(project.environment)).catch((err) => {
|
|
1430
|
+
// eslint-disable-next-line no-console
|
|
1431
|
+
console.warn("[coverage] load hook failed:", err);
|
|
1432
|
+
});
|
|
1425
1433
|
if (!project.fakes)
|
|
1426
1434
|
return;
|
|
1427
1435
|
for (const [name, def] of Object.entries(project.fakes)) {
|
|
@@ -2306,7 +2314,9 @@ const RUNTIME_SERVICES = new Map();
|
|
|
2306
2314
|
// the orchestration helpers want a NamedService, which is the same shape.
|
|
2307
2315
|
function specToNamedService(spec) {
|
|
2308
2316
|
const { name, ...rest } = spec;
|
|
2309
|
-
|
|
2317
|
+
// A runtime spec skipped `defineEnvironment`, so its coverage adapters
|
|
2318
|
+
// get their config-time pass here.
|
|
2319
|
+
return { name, ...applyCoverageAdapters(name, rest) };
|
|
2310
2320
|
}
|
|
2311
2321
|
/** Implementation behind `ctx.startService` / a fake's `ctx.startService`.
|
|
2312
2322
|
* Prepares the image (pulling on first use through the host cache), runs
|
|
@@ -2319,6 +2329,7 @@ async function startRuntimeService(spec) {
|
|
|
2319
2329
|
const t0 = Date.now();
|
|
2320
2330
|
const resv = reserveEvent();
|
|
2321
2331
|
const svc = specToNamedService(spec);
|
|
2332
|
+
await runCoverageLoadHooks([svc]);
|
|
2322
2333
|
const aliases = (spec.hostnames ?? []).map((h) => h.toLowerCase());
|
|
2323
2334
|
const imageRef = svc.image.type === "registry" ? svc.image.reference : "(dockerfile)";
|
|
2324
2335
|
try {
|
|
@@ -2970,16 +2981,118 @@ async function captureServiceLogDeltas() {
|
|
|
2970
2981
|
}));
|
|
2971
2982
|
}
|
|
2972
2983
|
// ────────────────────────────────────────────────────────────────────────
|
|
2973
|
-
// Coverage capture (opt-in per service; see harness/coverage.ts)
|
|
2984
|
+
// Coverage capture (opt-in per service; see coverage.ts + harness/coverage.ts)
|
|
2974
2985
|
// ────────────────────────────────────────────────────────────────────────
|
|
2975
2986
|
//
|
|
2976
|
-
// Read after bring-up and after every test, on every case.
|
|
2977
|
-
//
|
|
2978
|
-
//
|
|
2979
|
-
//
|
|
2980
|
-
//
|
|
2981
|
-
//
|
|
2982
|
-
|
|
2987
|
+
// Read after bring-up and after every test, on every case. A service's
|
|
2988
|
+
// adapters run first (each makes its report appear in the directory —
|
|
2989
|
+
// `nodeCoverage` asks the hook for a dump, `browserCoverage` writes the
|
|
2990
|
+
// harness's totals, `commandCoverage` runs the command), then the
|
|
2991
|
+
// directory is read the same way for every service, `coverage: true`
|
|
2992
|
+
// included. The reports live on the rootfs, so what a child fork reads
|
|
2993
|
+
// already includes everything its ancestors ran — no markers, no deltas
|
|
2994
|
+
// (unlike the service logs above). The reports ship verbatim; the server
|
|
2995
|
+
// derives what it needs from the stored bytes.
|
|
2996
|
+
/** Budget for one service's adapters at one capture, all of them. */
|
|
2997
|
+
const COVERAGE_CAPTURE_TIMEOUT_MS = 60_000;
|
|
2998
|
+
/** Services whose adapters asked for browser scripts at /load
|
|
2999
|
+
* (`CoverageLoadContext.collectBrowserScripts`). Cleared on every /load;
|
|
3000
|
+
* a runtime service's adapters add to it when it starts. */
|
|
3001
|
+
const BROWSER_COVERAGE_SERVICES = new Set();
|
|
3002
|
+
/** Run every adapter's `load` hook for `services`, then rebuild the
|
|
3003
|
+
* browser-coverage attribution from whoever asked. The attribution is a
|
|
3004
|
+
* host → service resolver over the lowered ingress, restricted to those
|
|
3005
|
+
* services, so a script from an uncovered service is ignored; null when
|
|
3006
|
+
* nobody asked, which costs nothing. */
|
|
3007
|
+
async function runCoverageLoadHooks(services) {
|
|
3008
|
+
for (const svc of services) {
|
|
3009
|
+
for (const a of coverageAdapters(svc.coverage)) {
|
|
3010
|
+
if (!a.load)
|
|
3011
|
+
continue;
|
|
3012
|
+
await a.load({
|
|
3013
|
+
service: svc.name,
|
|
3014
|
+
collectBrowserScripts: () => {
|
|
3015
|
+
BROWSER_COVERAGE_SERVICES.add(svc.name);
|
|
3016
|
+
},
|
|
3017
|
+
});
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
rebuildBrowserCoverageResolver();
|
|
3021
|
+
}
|
|
3022
|
+
function rebuildBrowserCoverageResolver() {
|
|
3023
|
+
if (BROWSER_COVERAGE_SERVICES.size === 0 || !loaded) {
|
|
3024
|
+
configureBrowserCoverage(null);
|
|
3025
|
+
return;
|
|
3026
|
+
}
|
|
3027
|
+
const all = [...namedServices(loaded.project.environment).map((s) => s.name), ...RUNTIME_SERVICES.keys()];
|
|
3028
|
+
const table = {
|
|
3029
|
+
proxies: LOWERED.proxies,
|
|
3030
|
+
aliasesByService: LOWERED.aliasesByService,
|
|
3031
|
+
wildcards: LOWERED.wildcards,
|
|
3032
|
+
};
|
|
3033
|
+
configureBrowserCoverage({
|
|
3034
|
+
serviceForHost(host) {
|
|
3035
|
+
const svc = hostToService(host, all, table);
|
|
3036
|
+
return svc && BROWSER_COVERAGE_SERVICES.has(svc) ? svc : undefined;
|
|
3037
|
+
},
|
|
3038
|
+
});
|
|
3039
|
+
}
|
|
3040
|
+
/** The context one adapter's `capture` runs against. */
|
|
3041
|
+
function coverageCaptureContext(svc, signal) {
|
|
3042
|
+
const reportDir = coverageHostDir(WORKSPACE, svc.name);
|
|
3043
|
+
return {
|
|
3044
|
+
service: svc.name,
|
|
3045
|
+
reportDir,
|
|
3046
|
+
signal,
|
|
3047
|
+
async exec(command) {
|
|
3048
|
+
const r = await docker(["exec", svc.name, "sh", "-c", command], COVERAGE_CAPTURE_TIMEOUT_MS);
|
|
3049
|
+
if (r.code !== 0) {
|
|
3050
|
+
const tail = capMiddle((r.stderr || r.stdout).trim(), 2000).value;
|
|
3051
|
+
throw new Error(`${JSON.stringify(command)} exited ${r.code}${tail ? `\n${tail}` : ""}`);
|
|
3052
|
+
}
|
|
3053
|
+
return { stdout: r.stdout, stderr: r.stderr };
|
|
3054
|
+
},
|
|
3055
|
+
async writeReport(name, content) {
|
|
3056
|
+
const file = path.join(reportDir, name);
|
|
3057
|
+
if (path.relative(reportDir, file).startsWith("..")) {
|
|
3058
|
+
throw new Error(`writeReport: ${JSON.stringify(name)} leaves the coverage directory`);
|
|
3059
|
+
}
|
|
3060
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
3061
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.tmp`);
|
|
3062
|
+
await fs.writeFile(tmp, content);
|
|
3063
|
+
await fs.rename(tmp, file);
|
|
3064
|
+
},
|
|
3065
|
+
};
|
|
3066
|
+
}
|
|
3067
|
+
/** Run one service's adapters in order under one budget. Returns the
|
|
3068
|
+
* error to fail the capture with, naming the adapter. */
|
|
3069
|
+
async function runCoverageAdapters(svc) {
|
|
3070
|
+
const adapters = coverageAdapters(svc.coverage).filter((a) => a.capture);
|
|
3071
|
+
if (adapters.length === 0)
|
|
3072
|
+
return undefined;
|
|
3073
|
+
const ac = new AbortController();
|
|
3074
|
+
const timer = setTimeout(() => ac.abort(), COVERAGE_CAPTURE_TIMEOUT_MS);
|
|
3075
|
+
const ctx = coverageCaptureContext(svc, ac.signal);
|
|
3076
|
+
try {
|
|
3077
|
+
for (const a of adapters) {
|
|
3078
|
+
try {
|
|
3079
|
+
await Promise.race([
|
|
3080
|
+
a.capture(ctx),
|
|
3081
|
+
new Promise((_, reject) => {
|
|
3082
|
+
ac.signal.addEventListener("abort", () => reject(new Error(`no report within ${COVERAGE_CAPTURE_TIMEOUT_MS / 1000}s`)), { once: true });
|
|
3083
|
+
}),
|
|
3084
|
+
]);
|
|
3085
|
+
}
|
|
3086
|
+
catch (err) {
|
|
3087
|
+
return `coverage adapter "${a.name}" on service "${svc.name}" failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
3088
|
+
}
|
|
3089
|
+
}
|
|
3090
|
+
return undefined;
|
|
3091
|
+
}
|
|
3092
|
+
finally {
|
|
3093
|
+
clearTimeout(timer);
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
2983
3096
|
async function captureServiceCoverage() {
|
|
2984
3097
|
const l = loaded;
|
|
2985
3098
|
if (!l)
|
|
@@ -2991,18 +3104,9 @@ async function captureServiceCoverage() {
|
|
|
2991
3104
|
byName.set(name, s);
|
|
2992
3105
|
const services = [...byName.values()].filter((s) => s.coverage !== undefined);
|
|
2993
3106
|
return Promise.all(services.map(async (svc) => {
|
|
2994
|
-
const
|
|
2995
|
-
if (
|
|
2996
|
-
|
|
2997
|
-
if (r.code !== 0) {
|
|
2998
|
-
const tail = capMiddle((r.stderr || r.stdout).trim(), 2000).value;
|
|
2999
|
-
return {
|
|
3000
|
-
service: svc.name,
|
|
3001
|
-
reports: [],
|
|
3002
|
-
error: `coverage command for service "${svc.name}" failed (exit ${r.code}): ${JSON.stringify(cov.command)}${tail ? `\n${tail}` : ""}`,
|
|
3003
|
-
};
|
|
3004
|
-
}
|
|
3005
|
-
}
|
|
3107
|
+
const error = await runCoverageAdapters(svc);
|
|
3108
|
+
if (error)
|
|
3109
|
+
return { service: svc.name, reports: [], error };
|
|
3006
3110
|
return readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
|
|
3007
3111
|
}));
|
|
3008
3112
|
}
|
|
@@ -5009,7 +5113,17 @@ export function harnessMethods(state) {
|
|
|
5009
5113
|
// the control plane logs it.
|
|
5010
5114
|
let coverage;
|
|
5011
5115
|
try {
|
|
5012
|
-
|
|
5116
|
+
const captures = await captureServiceCoverage();
|
|
5117
|
+
for (const c of captures) {
|
|
5118
|
+
// After bring-up the code that started the service has run, so a
|
|
5119
|
+
// report set that names no file at all is a misconfigured tool —
|
|
5120
|
+
// visible in the boot log, never a failure (there is no case).
|
|
5121
|
+
if (!c.error && c.reports.length > 0 && c.reports.every((r) => isEmptyReport(r.content))) {
|
|
5122
|
+
// eslint-disable-next-line no-console
|
|
5123
|
+
console.warn(`[coverage] service "${c.service}": every report is empty after bring-up — is coverage enabled at process start?`);
|
|
5124
|
+
}
|
|
5125
|
+
}
|
|
5126
|
+
coverage = parkCoverageBundle(SETUP_CASE_ID, captures);
|
|
5013
5127
|
}
|
|
5014
5128
|
catch (err) {
|
|
5015
5129
|
// eslint-disable-next-line no-console
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** Per-line hit counts, 1-based line numbers. */
|
|
2
|
+
export type LineCounts = Map<number, number>;
|
|
3
|
+
/** Per-file line counts. Keys are source paths as the source map (or the
|
|
4
|
+
* script URL, when there is no map) reports them. */
|
|
5
|
+
export type FileCounts = Map<string, LineCounts>;
|
|
6
|
+
/** Byte offset at which each line starts. */
|
|
7
|
+
export declare function lineStartsOf(source: string): number[];
|
|
8
|
+
/** 1-based line holding `offset`. */
|
|
9
|
+
export declare function lineOf(starts: number[], offset: number): number;
|
|
10
|
+
/** One V8 `FunctionCoverage` as `Profiler.takePreciseCoverage` reports it. */
|
|
11
|
+
export interface V8FunctionCoverage {
|
|
12
|
+
functionName?: string;
|
|
13
|
+
ranges: {
|
|
14
|
+
startOffset: number;
|
|
15
|
+
endOffset: number;
|
|
16
|
+
count: number;
|
|
17
|
+
}[];
|
|
18
|
+
isBlockCoverage?: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Fold a script's block ranges onto its lines. Ranges come outer-first
|
|
22
|
+
* and a narrower range overrides the lines it spans, so a line inside an
|
|
23
|
+
* unexecuted branch ends at 0 even though its enclosing function ran.
|
|
24
|
+
* Blank lines are dropped. Approximate — a line holding two blocks gets
|
|
25
|
+
* the narrower one's count — which is exact at the file level, the only
|
|
26
|
+
* level anything reads today.
|
|
27
|
+
*/
|
|
28
|
+
export declare function rangesToLineCounts(source: string, functions: readonly V8FunctionCoverage[]): LineCounts;
|
|
29
|
+
/** One mapping segment: generated column → original (source, line). */
|
|
30
|
+
export interface Segment {
|
|
31
|
+
col: number;
|
|
32
|
+
src: number;
|
|
33
|
+
line: number;
|
|
34
|
+
}
|
|
35
|
+
export interface DecodedSourceMap {
|
|
36
|
+
/** Normalised source paths, indexed by segment `src`. */
|
|
37
|
+
sources: string[];
|
|
38
|
+
/** Segments per generated line (0-based), sorted by column. */
|
|
39
|
+
lines: Segment[][];
|
|
40
|
+
}
|
|
41
|
+
/** Decode one VLQ-encoded segment into its fields. */
|
|
42
|
+
export declare function decodeVlq(segment: string): number[];
|
|
43
|
+
/**
|
|
44
|
+
* Normalise a source-map `sources` entry to a plain path. Bundlers prefix
|
|
45
|
+
* their own scheme (`webpack:///./src/a.ts`), dev servers serve absolute
|
|
46
|
+
* URLs paths (`/src/a.ts`), builds write relative ones (`../src/a.ts`).
|
|
47
|
+
* The server maps these onto the repo by suffix, so all that matters here
|
|
48
|
+
* is stripping what is not a path.
|
|
49
|
+
*/
|
|
50
|
+
export declare function normalizeSourcePath(src: string, sourceRoot?: string): string;
|
|
51
|
+
/** Sources nobody's change can be attributed to: dependencies and bundler
|
|
52
|
+
* runtime shims. */
|
|
53
|
+
export declare function isForeignSource(path: string): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Decode a source map document. Index maps (`sections`) are not
|
|
56
|
+
* supported and decode to `null`, as does anything malformed — the
|
|
57
|
+
* caller then falls back to attributing the script to its own URL, which
|
|
58
|
+
* the repo mapping cannot resolve, which is the safe answer.
|
|
59
|
+
*/
|
|
60
|
+
export declare function decodeSourceMap(doc: unknown): DecodedSourceMap | null;
|
|
61
|
+
/**
|
|
62
|
+
* Attribute generated line counts to original (file, line)s through the
|
|
63
|
+
* map. Every original line a generated line's segments name gets that
|
|
64
|
+
* line's count (max over the generated lines that touch it). Foreign
|
|
65
|
+
* sources are dropped.
|
|
66
|
+
*/
|
|
67
|
+
export declare function mapToOriginal(map: DecodedSourceMap, generated: LineCounts): FileCounts;
|
|
68
|
+
/** Add `from` into `into` (sums — takes reset V8's counters). */
|
|
69
|
+
export declare function mergeLineCounts(into: LineCounts, from: LineCounts): void;
|
|
70
|
+
export declare function mergeFileCounts(into: FileCounts, from: FileCounts): void;
|
|
71
|
+
/** One lcov document for a set of files. */
|
|
72
|
+
export declare function lcovDocument(files: FileCounts): string;
|
|
73
|
+
/** The slice of the lowered ingress that names hosts. */
|
|
74
|
+
export interface HostTable {
|
|
75
|
+
proxies: readonly {
|
|
76
|
+
hostname: string;
|
|
77
|
+
service: string;
|
|
78
|
+
}[];
|
|
79
|
+
aliasesByService: Readonly<Record<string, readonly string[]>>;
|
|
80
|
+
wildcards: readonly {
|
|
81
|
+
pattern: string;
|
|
82
|
+
target: {
|
|
83
|
+
service?: string;
|
|
84
|
+
} | {
|
|
85
|
+
ingress?: boolean;
|
|
86
|
+
} | Record<string, unknown>;
|
|
87
|
+
}[];
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Which service a script URL's host belongs to: the service key itself,
|
|
91
|
+
* `<key>.internal`, its `hostnames`/`dnsName` aliases, a `tls`/`proxy`
|
|
92
|
+
* hostname routed to it, or a wildcard pointed at it. Exact first, then
|
|
93
|
+
* the longest wildcard suffix — the resolver's own precedence.
|
|
94
|
+
*/
|
|
95
|
+
export declare function serviceForHost(host: string, services: readonly string[], table: HostTable): string | undefined;
|
|
@@ -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
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Where the directory is mounted inside an opted-in container. */
|
|
2
2
|
export declare const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
|
|
3
3
|
/** Raw bytes of reports one service may ship per capture. Reports are
|
|
4
|
-
*
|
|
4
|
+
* bounded by code size times chain depth, so anything past this is a
|
|
5
5
|
* runaway (a tool writing a new file per request), not coverage. */
|
|
6
6
|
export declare const COVERAGE_MAX_BYTES_PER_SERVICE: number;
|
|
7
7
|
/** Root of the per-service host directories, under the workspace so the
|
|
@@ -44,14 +44,33 @@ export interface CoverageBundle {
|
|
|
44
44
|
}
|
|
45
45
|
/** True when `text` holds at least one lcov `SF:` record. */
|
|
46
46
|
export declare function hasLcovSourceFile(text: string): boolean;
|
|
47
|
+
/** True when `text` is lcov-shaped: at least one line, every non-blank
|
|
48
|
+
* line an `XX:` record or `end_of_record`. An lcov with no `SF:` record
|
|
49
|
+
* at all (`TN:` alone) is well-formed — it is a report that says
|
|
50
|
+
* nothing ran. */
|
|
51
|
+
export declare function isLcovShaped(text: string): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* True when `text` looks like a V8 coverage JSON document — what
|
|
54
|
+
* `NODE_V8_COVERAGE` / `v8.takeCoverage()` write: an object with a
|
|
55
|
+
* `result` array of scripts. A cheap shape check, not a parse (a report
|
|
56
|
+
* can be megabytes); the control plane parses for real.
|
|
57
|
+
*/
|
|
58
|
+
export declare function isV8CoverageJson(text: string): boolean;
|
|
59
|
+
/** True when `text` is a report spectest stores: lcov or V8 JSON. */
|
|
60
|
+
export declare function isWellFormedReport(text: string): boolean;
|
|
61
|
+
/** True when a well-formed report names no source file at all (an lcov
|
|
62
|
+
* with no `SF:`, a V8 document with an empty `result`). */
|
|
63
|
+
export declare function isEmptyReport(text: string): boolean;
|
|
47
64
|
/** Every regular file under `dir`, recursively, as paths relative to it.
|
|
48
65
|
* Hidden files are skipped (a tool's own bookkeeping — `.seq`, `.lock`,
|
|
49
66
|
* a tmp file mid-write — is not a report). */
|
|
50
67
|
export declare function listReportFiles(dir: string): Promise<string[]>;
|
|
51
68
|
/**
|
|
52
|
-
* Read every report under `dir`, verbatim. An empty directory, or
|
|
53
|
-
*
|
|
54
|
-
* contract is that an opted-in service always has a
|
|
69
|
+
* Read every report under `dir`, verbatim. An empty directory, or a file
|
|
70
|
+
* that is neither lcov nor V8 JSON, is an error rather than an empty
|
|
71
|
+
* capture: the contract is that an opted-in service always has a
|
|
72
|
+
* well-formed report to read. A report that names no file is fine — it
|
|
73
|
+
* says nothing ran.
|
|
55
74
|
*/
|
|
56
75
|
export declare function readCoverageDir(service: string, dir: string): Promise<ServiceCoverageCapture>;
|
|
57
76
|
/** Gzip a case's captures into the bundle `coverageChunk` serves. */
|