@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
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CDPSession } from "playwright-core";
|
|
2
|
+
export interface BrowserCoverageConfig {
|
|
3
|
+
/** Which covered service (if any) serves scripts from this host. */
|
|
4
|
+
serviceForHost(host: string): string | undefined;
|
|
5
|
+
}
|
|
6
|
+
/** Set (or clear, with `null`) at /load. */
|
|
7
|
+
export declare function configureBrowserCoverage(config: BrowserCoverageConfig | null): void;
|
|
8
|
+
export declare function browserCoverageActive(): boolean;
|
|
9
|
+
/** Turn coverage on for a page's CDP session. Idempotent; a no-op when no
|
|
10
|
+
* service opted in. */
|
|
11
|
+
export declare function attachBrowserCoverage(cdp: CDPSession): Promise<void>;
|
|
12
|
+
/**
|
|
13
|
+
* Take the session's counters and fold them into the totals. Concurrent
|
|
14
|
+
* calls coalesce onto the in-flight one. Errors (a page mid-navigation,
|
|
15
|
+
* a closed view) are swallowed: the counters then stay in V8 for the next
|
|
16
|
+
* take, nothing is lost.
|
|
17
|
+
*/
|
|
18
|
+
export declare function harvestBrowserCoverage(cdp: CDPSession): Promise<void>;
|
|
19
|
+
/** One lcov document per service that has any browser coverage. */
|
|
20
|
+
export declare function browserCoverageReports(): Map<string, string>;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Browser coverage — the collector.
|
|
2
|
+
//
|
|
3
|
+
// One collector per CDP session (i.e. per page view). `attach` turns on
|
|
4
|
+
// V8 precise coverage and tracks parsed scripts; `harvest` takes the
|
|
5
|
+
// counters (which RESETS them — V8 reports nothing for a script that ran
|
|
6
|
+
// nothing since the last take, so the counts are summed here across
|
|
7
|
+
// takes), maps each script the browser loaded from a covered service back
|
|
8
|
+
// to its original sources through the served source map, and folds the
|
|
9
|
+
// result into a per-service total in module memory — which forks with the
|
|
10
|
+
// daemon, so a child test inherits its ancestors' browser coverage exactly
|
|
11
|
+
// as a container's counters are inherited.
|
|
12
|
+
//
|
|
13
|
+
// Harvest runs before every browser step (a click that navigates
|
|
14
|
+
// cross-site replaces the renderer, and the old counters with it) and at
|
|
15
|
+
// every coverage capture. `browserCoverageReports` renders the totals as
|
|
16
|
+
// one lcov per service for the `case-coverage` bundle.
|
|
17
|
+
//
|
|
18
|
+
// Configured by the daemon at /load with the host → service table; a
|
|
19
|
+
// project with no `coverage: { browser: true }` service never attaches
|
|
20
|
+
// anything, so it costs nothing.
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { decodeSourceMap, lcovDocument, mapToOriginal, mergeFileCounts, rangesToLineCounts, } from "./harness/browser-coverage.js";
|
|
23
|
+
import { rawFetch } from "./harness/raw-fetch.js";
|
|
24
|
+
let CONFIG = null;
|
|
25
|
+
/** Set (or clear, with `null`) at /load. */
|
|
26
|
+
export function configureBrowserCoverage(config) {
|
|
27
|
+
CONFIG = config;
|
|
28
|
+
}
|
|
29
|
+
export function browserCoverageActive() {
|
|
30
|
+
return CONFIG !== null;
|
|
31
|
+
}
|
|
32
|
+
/** service → file → line → count, summed over every harvest. */
|
|
33
|
+
const TOTALS = new Map();
|
|
34
|
+
const COLLECTORS = new WeakMap();
|
|
35
|
+
/** Decoded maps by URL (or content hash for inline `data:` maps). */
|
|
36
|
+
const SOURCE_MAPS = new Map();
|
|
37
|
+
const SOURCE_MAP_FETCH_TIMEOUT_MS = 10_000;
|
|
38
|
+
/** Turn coverage on for a page's CDP session. Idempotent; a no-op when no
|
|
39
|
+
* service opted in. */
|
|
40
|
+
export async function attachBrowserCoverage(cdp) {
|
|
41
|
+
if (!CONFIG || COLLECTORS.has(cdp))
|
|
42
|
+
return;
|
|
43
|
+
const collector = { cdp, scripts: new Map(), inFlight: null };
|
|
44
|
+
COLLECTORS.set(cdp, collector);
|
|
45
|
+
cdp.on("Debugger.scriptParsed", (ev) => {
|
|
46
|
+
if (!ev.url)
|
|
47
|
+
return;
|
|
48
|
+
collector.scripts.set(ev.scriptId, {
|
|
49
|
+
url: ev.url,
|
|
50
|
+
sourceMapURL: ev.sourceMapURL || undefined,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
await cdp.send("Debugger.enable");
|
|
54
|
+
await cdp.send("Profiler.enable");
|
|
55
|
+
await cdp.send("Profiler.startPreciseCoverage", { callCount: true, detailed: true });
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Take the session's counters and fold them into the totals. Concurrent
|
|
59
|
+
* calls coalesce onto the in-flight one. Errors (a page mid-navigation,
|
|
60
|
+
* a closed view) are swallowed: the counters then stay in V8 for the next
|
|
61
|
+
* take, nothing is lost.
|
|
62
|
+
*/
|
|
63
|
+
export async function harvestBrowserCoverage(cdp) {
|
|
64
|
+
const c = COLLECTORS.get(cdp);
|
|
65
|
+
if (!c || !CONFIG)
|
|
66
|
+
return;
|
|
67
|
+
if (c.inFlight)
|
|
68
|
+
return c.inFlight;
|
|
69
|
+
c.inFlight = harvestInner(c, CONFIG).catch(() => { }).finally(() => {
|
|
70
|
+
c.inFlight = null;
|
|
71
|
+
});
|
|
72
|
+
return c.inFlight;
|
|
73
|
+
}
|
|
74
|
+
async function harvestInner(c, config) {
|
|
75
|
+
const { result } = (await c.cdp.send("Profiler.takePreciseCoverage"));
|
|
76
|
+
for (const script of result) {
|
|
77
|
+
const info = c.scripts.get(script.scriptId) ?? (script.url ? { url: script.url } : undefined);
|
|
78
|
+
if (!info)
|
|
79
|
+
continue;
|
|
80
|
+
let url;
|
|
81
|
+
try {
|
|
82
|
+
url = new URL(info.url);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
88
|
+
continue;
|
|
89
|
+
const service = config.serviceForHost(url.hostname);
|
|
90
|
+
if (!service)
|
|
91
|
+
continue;
|
|
92
|
+
if (info.source === undefined) {
|
|
93
|
+
try {
|
|
94
|
+
const r = (await c.cdp.send("Debugger.getScriptSource", {
|
|
95
|
+
scriptId: script.scriptId,
|
|
96
|
+
}));
|
|
97
|
+
info.source = r.scriptSource;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
continue; // script gone with its document
|
|
101
|
+
}
|
|
102
|
+
c.scripts.set(script.scriptId, info);
|
|
103
|
+
}
|
|
104
|
+
const generated = rangesToLineCounts(info.source, script.functions);
|
|
105
|
+
if (generated.size === 0)
|
|
106
|
+
continue;
|
|
107
|
+
if (info.map === undefined)
|
|
108
|
+
info.map = await sourceMapFor(info, url);
|
|
109
|
+
const files = info.map
|
|
110
|
+
? mapToOriginal(info.map, generated)
|
|
111
|
+
: new Map([[url.pathname, generated]]);
|
|
112
|
+
let totals = TOTALS.get(service);
|
|
113
|
+
if (!totals) {
|
|
114
|
+
totals = new Map();
|
|
115
|
+
TOTALS.set(service, totals);
|
|
116
|
+
}
|
|
117
|
+
mergeFileCounts(totals, files);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Resolve, fetch (or inline-decode) and decode a script's source map. */
|
|
121
|
+
async function sourceMapFor(info, scriptUrl) {
|
|
122
|
+
let ref = info.sourceMapURL;
|
|
123
|
+
if (!ref && info.source) {
|
|
124
|
+
const m = /\/\/[#@]\s*sourceMappingURL=(\S+)\s*$/.exec(info.source.slice(-4096));
|
|
125
|
+
if (m)
|
|
126
|
+
ref = m[1];
|
|
127
|
+
}
|
|
128
|
+
if (!ref)
|
|
129
|
+
return null;
|
|
130
|
+
if (ref.startsWith("data:")) {
|
|
131
|
+
const key = "data:" + createHash("sha1").update(ref).digest("hex");
|
|
132
|
+
const cached = SOURCE_MAPS.get(key);
|
|
133
|
+
if (cached !== undefined)
|
|
134
|
+
return cached;
|
|
135
|
+
const map = decodeSourceMap(parseDataUrl(ref));
|
|
136
|
+
SOURCE_MAPS.set(key, map);
|
|
137
|
+
return map;
|
|
138
|
+
}
|
|
139
|
+
let mapUrl;
|
|
140
|
+
try {
|
|
141
|
+
mapUrl = new URL(ref, scriptUrl).toString();
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
const cached = SOURCE_MAPS.get(mapUrl);
|
|
147
|
+
if (cached !== undefined)
|
|
148
|
+
return cached;
|
|
149
|
+
let map = null;
|
|
150
|
+
try {
|
|
151
|
+
const res = await rawFetch(mapUrl, { signal: AbortSignal.timeout(SOURCE_MAP_FETCH_TIMEOUT_MS) });
|
|
152
|
+
if (res.ok)
|
|
153
|
+
map = decodeSourceMap(await res.json());
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
map = null;
|
|
157
|
+
}
|
|
158
|
+
SOURCE_MAPS.set(mapUrl, map);
|
|
159
|
+
return map;
|
|
160
|
+
}
|
|
161
|
+
function parseDataUrl(ref) {
|
|
162
|
+
const comma = ref.indexOf(",");
|
|
163
|
+
if (comma < 0)
|
|
164
|
+
return null;
|
|
165
|
+
const meta = ref.slice(5, comma);
|
|
166
|
+
const payload = ref.slice(comma + 1);
|
|
167
|
+
try {
|
|
168
|
+
const text = /;base64$/i.test(meta)
|
|
169
|
+
? Buffer.from(payload, "base64").toString("utf8")
|
|
170
|
+
: decodeURIComponent(payload);
|
|
171
|
+
return JSON.parse(text);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** One lcov document per service that has any browser coverage. */
|
|
178
|
+
export function browserCoverageReports() {
|
|
179
|
+
const out = new Map();
|
|
180
|
+
for (const [service, files] of TOTALS) {
|
|
181
|
+
if (files.size > 0)
|
|
182
|
+
out.set(service, lcovDocument(files));
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
package/dist/browser.js
CHANGED
|
@@ -35,6 +35,7 @@ import { generateId } from "./ids.js";
|
|
|
35
35
|
import { recordBrowser, reserveBackdated, reserveEvent, truncateUtf8 } from "./recorder.js";
|
|
36
36
|
import { wrap } from "./inspect.js";
|
|
37
37
|
import { describeUrlPattern, matchesUrl } from "./url-match.js";
|
|
38
|
+
import { attachBrowserCoverage, browserCoverageActive, harvestBrowserCoverage, } from "./browser-coverage.js";
|
|
38
39
|
import { attachBrowserProbe, DEFAULT_ACTION_TIMEOUT_MS, desktopStrategy, makeLocator, mobileStrategy, } from "./locator.js";
|
|
39
40
|
import { chromium } from "playwright-core";
|
|
40
41
|
/** Decoded byte count of a base64 string, without decoding it. */
|
|
@@ -792,6 +793,14 @@ async function spawnPage(context) {
|
|
|
792
793
|
console.warn("[spectest] failed to install rrweb recorder:", err);
|
|
793
794
|
}
|
|
794
795
|
}
|
|
796
|
+
// Frontend coverage (opt-in): turn on V8 precise coverage for this page.
|
|
797
|
+
// A no-op unless a service declared `coverage: { browser: true }`.
|
|
798
|
+
try {
|
|
799
|
+
await attachBrowserCoverage(cdp);
|
|
800
|
+
}
|
|
801
|
+
catch {
|
|
802
|
+
/* coverage is best-effort; a page without it still works */
|
|
803
|
+
}
|
|
795
804
|
return { page, cdp, recordingInstalled };
|
|
796
805
|
}
|
|
797
806
|
/** Create a default-desktop view (plus its context) for the pool. */
|
|
@@ -1080,6 +1089,14 @@ async function rebuildView(holder) {
|
|
|
1080
1089
|
catch {
|
|
1081
1090
|
/* page may already be gone */
|
|
1082
1091
|
}
|
|
1092
|
+
// The old renderer's coverage counters die with its page; harvest what
|
|
1093
|
+
// it had before the swap (rebuild is triggered on a fresh navigation).
|
|
1094
|
+
try {
|
|
1095
|
+
await harvestBrowserCoverage(holder.cdp);
|
|
1096
|
+
}
|
|
1097
|
+
catch {
|
|
1098
|
+
/* best-effort */
|
|
1099
|
+
}
|
|
1083
1100
|
const fresh = await spawnPage(holder.context);
|
|
1084
1101
|
holder.page = fresh.page;
|
|
1085
1102
|
holder.cdp = fresh.cdp;
|
|
@@ -1164,6 +1181,17 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1164
1181
|
durationMs: endT - t,
|
|
1165
1182
|
}, resv);
|
|
1166
1183
|
await drain(action);
|
|
1184
|
+
// Fold this page's V8 counters into the per-service totals now — a
|
|
1185
|
+
// navigation to another origin replaces the renderer and its
|
|
1186
|
+
// counters, so harvesting per op is what keeps them.
|
|
1187
|
+
if (browserCoverageActive()) {
|
|
1188
|
+
try {
|
|
1189
|
+
await harvestBrowserCoverage(holder.cdp);
|
|
1190
|
+
}
|
|
1191
|
+
catch {
|
|
1192
|
+
/* best-effort */
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1167
1195
|
// Reads (`opts.wrap`) return user-visible JS values someone is likely to
|
|
1168
1196
|
// assert on — provenance-wrap so `expect(...)` nests under this step.
|
|
1169
1197
|
// Actions return void/internals; leave them raw to avoid Proxy surprises.
|
|
@@ -1193,6 +1221,14 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1193
1221
|
return;
|
|
1194
1222
|
// Final drain before we stop writing to this recorder.
|
|
1195
1223
|
await drain("close");
|
|
1224
|
+
if (browserCoverageActive()) {
|
|
1225
|
+
try {
|
|
1226
|
+
await harvestBrowserCoverage(holder.cdp);
|
|
1227
|
+
}
|
|
1228
|
+
catch {
|
|
1229
|
+
/* best-effort */
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1196
1232
|
recordingEnded = true;
|
|
1197
1233
|
}
|
|
1198
1234
|
const strategy = holder.device ? mobileStrategy : desktopStrategy;
|
package/dist/daemon.js
CHANGED
|
@@ -21,8 +21,10 @@ 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, validateCoverage, } from "./index.js";
|
|
24
|
+
import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, proxy as makeProxyDecl, validateCoverage, coverageUsesBrowser, coverageUsesContainer, } from "./index.js";
|
|
25
25
|
import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, readCoverageDir, } from "./harness/coverage.js";
|
|
26
|
+
import { browserCoverageReports, configureBrowserCoverage, } from "./browser-coverage.js";
|
|
27
|
+
import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
|
|
26
28
|
import { acquirePersistentBrowser, mobileKey } from "./browser.js";
|
|
27
29
|
import { isMobileApp, openPersistentMobile } from "./mobile.js";
|
|
28
30
|
// Pure ingress hostname matching, ported out of this file (see
|
|
@@ -523,6 +525,10 @@ async function ensureCoverage(svc) {
|
|
|
523
525
|
if (svc.coverage === undefined)
|
|
524
526
|
return [];
|
|
525
527
|
validateCoverage(svc.name, svc.coverage);
|
|
528
|
+
// A browser-only opt-in has no container process to write reports, so it
|
|
529
|
+
// gets no mount — its coverage comes from the guest browser.
|
|
530
|
+
if (!coverageUsesContainer(svc.coverage))
|
|
531
|
+
return [];
|
|
526
532
|
const host = coverageHostDir(WORKSPACE, svc.name);
|
|
527
533
|
await fs.mkdir(host, { recursive: true });
|
|
528
534
|
await fs.chmod(host, 0o777);
|
|
@@ -1422,6 +1428,7 @@ function buildIngress(project) {
|
|
|
1422
1428
|
// generic decl set the daemon executes. The special-casing lives in the
|
|
1423
1429
|
// SDK's lowerIngress, not here.
|
|
1424
1430
|
LOWERED = lowerIngress(project);
|
|
1431
|
+
configureCoverageAttribution(project);
|
|
1425
1432
|
if (!project.fakes)
|
|
1426
1433
|
return;
|
|
1427
1434
|
for (const [name, def] of Object.entries(project.fakes)) {
|
|
@@ -2980,6 +2987,35 @@ async function captureServiceLogDeltas() {
|
|
|
2980
2987
|
// container first so the service writes a fresh report. The reports ship
|
|
2981
2988
|
// verbatim; the server derives what it needs from the stored bytes.
|
|
2982
2989
|
const COVERAGE_COMMAND_TIMEOUT_MS = 60_000;
|
|
2990
|
+
/** Set the browser-coverage host→service attribution from the loaded
|
|
2991
|
+
* project: only services that opted in to `coverage: { browser: true }`
|
|
2992
|
+
* are attributed, so a script from an uncovered service is ignored. Null
|
|
2993
|
+
* when no service opted in — coverage then attaches nothing. Called on
|
|
2994
|
+
* every /load. */
|
|
2995
|
+
function configureCoverageAttribution(project) {
|
|
2996
|
+
const env = project.environment;
|
|
2997
|
+
const browserServices = new Set();
|
|
2998
|
+
for (const svc of namedServices(env)) {
|
|
2999
|
+
if (coverageUsesBrowser(svc.coverage))
|
|
3000
|
+
browserServices.add(svc.name);
|
|
3001
|
+
}
|
|
3002
|
+
if (browserServices.size === 0) {
|
|
3003
|
+
configureBrowserCoverage(null);
|
|
3004
|
+
return;
|
|
3005
|
+
}
|
|
3006
|
+
const all = namedServices(env).map((s) => s.name);
|
|
3007
|
+
const table = {
|
|
3008
|
+
proxies: LOWERED.proxies,
|
|
3009
|
+
aliasesByService: LOWERED.aliasesByService,
|
|
3010
|
+
wildcards: LOWERED.wildcards,
|
|
3011
|
+
};
|
|
3012
|
+
configureBrowserCoverage({
|
|
3013
|
+
serviceForHost(host) {
|
|
3014
|
+
const svc = hostToService(host, all, table);
|
|
3015
|
+
return svc && browserServices.has(svc) ? svc : undefined;
|
|
3016
|
+
},
|
|
3017
|
+
});
|
|
3018
|
+
}
|
|
2983
3019
|
async function captureServiceCoverage() {
|
|
2984
3020
|
const l = loaded;
|
|
2985
3021
|
if (!l)
|
|
@@ -2990,20 +3026,45 @@ async function captureServiceCoverage() {
|
|
|
2990
3026
|
for (const [name, s] of RUNTIME_SERVICES)
|
|
2991
3027
|
byName.set(name, s);
|
|
2992
3028
|
const services = [...byName.values()].filter((s) => s.coverage !== undefined);
|
|
3029
|
+
// Browser coverage is per-service lcov, accumulated in module memory
|
|
3030
|
+
// (browser-coverage.ts) across every harvest; read it once here.
|
|
3031
|
+
const browser = browserCoverageReports();
|
|
2993
3032
|
return Promise.all(services.map(async (svc) => {
|
|
2994
3033
|
const cov = svc.coverage;
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3034
|
+
const reports = [];
|
|
3035
|
+
// Container reports (a `command` runs first to flush a fresh dump).
|
|
3036
|
+
if (coverageUsesContainer(cov)) {
|
|
3037
|
+
if (typeof cov === "object" && cov !== null && cov.command) {
|
|
3038
|
+
const r = await docker(["exec", svc.name, "sh", "-c", cov.command], COVERAGE_COMMAND_TIMEOUT_MS);
|
|
3039
|
+
if (r.code !== 0) {
|
|
3040
|
+
const tail = capMiddle((r.stderr || r.stdout).trim(), 2000).value;
|
|
3041
|
+
return {
|
|
3042
|
+
service: svc.name,
|
|
3043
|
+
reports: [],
|
|
3044
|
+
error: `coverage command for service "${svc.name}" failed (exit ${r.code}): ${JSON.stringify(cov.command)}${tail ? `\n${tail}` : ""}`,
|
|
3045
|
+
};
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
const dir = await readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
|
|
3049
|
+
// A service that ALSO does browser coverage may legitimately have
|
|
3050
|
+
// an empty container dir for a test — don't fail if the browser
|
|
3051
|
+
// side has something. Otherwise the container contract stands.
|
|
3052
|
+
if (dir.error && !(coverageUsesBrowser(cov) && browser.has(svc.name))) {
|
|
3053
|
+
return dir;
|
|
3004
3054
|
}
|
|
3055
|
+
reports.push(...dir.reports);
|
|
3056
|
+
}
|
|
3057
|
+
// Browser report for this service, when any script it served ran.
|
|
3058
|
+
const lcov = browser.get(svc.name);
|
|
3059
|
+
if (lcov)
|
|
3060
|
+
reports.push({ name: "browser.lcov", content: lcov });
|
|
3061
|
+
if (reports.length === 0 && coverageUsesBrowser(cov) && !coverageUsesContainer(cov)) {
|
|
3062
|
+
// Browser-only service, no browser activity yet this branch. Not an
|
|
3063
|
+
// error — a test needn't open a browser; the report appears once
|
|
3064
|
+
// one does (and forks to descendants from there).
|
|
3065
|
+
return { service: svc.name, reports: [] };
|
|
3005
3066
|
}
|
|
3006
|
-
return
|
|
3067
|
+
return { service: svc.name, reports };
|
|
3007
3068
|
}));
|
|
3008
3069
|
}
|
|
3009
3070
|
/**
|
|
@@ -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;
|