@specific.dev/spectest 0.131.20 → 0.132.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.js +4 -2
- package/dist/change-rules.d.ts +45 -0
- package/dist/change-rules.js +179 -0
- package/dist/components/k3s-coverage.js +4 -4
- package/dist/components/supabase-coverage.js +12 -0
- package/dist/components/supabase.js +25 -12
- package/dist/coverage-bun.js +2 -1
- package/dist/coverage-next.d.ts +5 -0
- package/dist/coverage-next.js +27 -3
- package/dist/coverage-rust.d.ts +2 -0
- package/dist/coverage-rust.js +20 -0
- package/dist/coverage.d.ts +27 -3
- package/dist/coverage.js +68 -15
- package/dist/daemon.js +13 -10
- package/dist/harness/component-images.d.ts +5 -0
- package/dist/harness/component-images.js +15 -2
- package/dist/harness/coverage-target.d.ts +2 -0
- package/dist/harness/coverage-target.js +3 -2
- package/dist/harness/reach.d.ts +6 -1
- package/dist/harness/reach.js +8 -34
- package/dist/harness/shadow-builder.d.ts +2 -1
- package/dist/harness/shadow-builder.js +9 -3
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -0
- package/dist/vendor/rrweb-record.min.js +2 -17
- package/package.json +1 -1
- package/src/browser-coverage-navigation.test.ts +10 -2
- package/src/browser-image-loading.test.ts +51 -0
- package/src/browser.ts +4 -2
- package/src/change-rules.fixtures.json +35 -0
- package/src/change-rules.test.ts +125 -0
- package/src/change-rules.ts +147 -0
- package/src/components/k3s-coverage.test.ts +32 -0
- package/src/components/k3s-coverage.ts +4 -4
- package/src/components/supabase-coverage.ts +12 -0
- package/src/components/supabase-dir.test.ts +26 -0
- package/src/components/supabase-functions-reach.test.ts +41 -1
- package/src/components/supabase-migrations.test.ts +1 -1
- package/src/components/supabase.ts +26 -12
- package/src/coverage-bun.ts +2 -1
- package/src/coverage-next.ts +29 -3
- package/src/coverage-rust.test.ts +34 -0
- package/src/coverage-rust.ts +22 -0
- package/src/coverage.test.ts +2 -1
- package/src/coverage.ts +76 -7
- package/src/daemon.ts +14 -9
- package/src/harness/component-images.test.ts +42 -0
- package/src/harness/component-images.ts +20 -3
- package/src/harness/coverage-target.ts +5 -2
- package/src/harness/reach.test.ts +40 -6
- package/src/harness/reach.ts +13 -3
- package/src/harness/shadow-builder.test.ts +12 -1
- package/src/harness/shadow-builder.ts +10 -3
- package/src/index.ts +11 -0
- package/src/vendor/rrweb-record.min.js +2 -17
package/dist/coverage.js
CHANGED
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
// coverage: { adapters: [coverage.node(), coverage.browser()] }
|
|
29
29
|
import { createHash } from "node:crypto";
|
|
30
30
|
import { JAVASCRIPT_GENERATED_SOURCES } from "./coverage-javascript.js";
|
|
31
|
+
import { NODE_CHANGE_RULES, BROWSER_CHANGE_RULES, resolveChangePolicy, validateChangeRules, compileChangePolicy } from "./change-rules.js";
|
|
32
|
+
export * from "./change-rules.js";
|
|
31
33
|
import { harvestAllBrowserCoverage, takeBrowserCoverageReport } from "./browser-coverage.js";
|
|
32
34
|
import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
|
|
33
35
|
export { COVERAGE_CONTAINER_DIR };
|
|
@@ -89,15 +91,25 @@ export function validateCoverage(service, cov) {
|
|
|
89
91
|
(a.explains === undefined ||
|
|
90
92
|
(Array.isArray(a.explains) &&
|
|
91
93
|
a.explains.every((e) => typeof e === "string"))) &&
|
|
92
|
-
["configure", "load", "capture", "collect", "inventory", "ownsReport"].every((k) => a[k] === undefined ||
|
|
94
|
+
["configure", "load", "capture", "collect", "inventory", "ownsReport", "resolveChangeRules"].every((k) => a[k] === undefined ||
|
|
93
95
|
typeof a[k] === "function");
|
|
94
96
|
const sourceOptions = a?.sourceOptions;
|
|
97
|
+
const changeRules = a?.changeRules;
|
|
98
|
+
const groups = a?.changeRuleGroups;
|
|
99
|
+
if (changeRules !== undefined)
|
|
100
|
+
validateChangeRules(changeRules);
|
|
101
|
+
if (groups !== undefined) {
|
|
102
|
+
if (!Array.isArray(groups))
|
|
103
|
+
throw new Error("invalid changeRuleGroups");
|
|
104
|
+
for (const group of groups)
|
|
105
|
+
validateChangeRules(group);
|
|
106
|
+
}
|
|
95
107
|
const defaults = a?.defaultExcludeSources;
|
|
96
108
|
const defaultShape = defaults === undefined || typeof a.ownsReport === "function";
|
|
97
109
|
const sourceShape = sourceOptions === undefined || (typeof a.ownsReport === "function" &&
|
|
98
110
|
(typeof sourceOptions === "function" || (typeof sourceOptions === "object" && sourceOptions !== null && !Array.isArray(sourceOptions))));
|
|
99
111
|
if (!ok || !sourceShape || !defaultShape) {
|
|
100
|
-
throw new Error(`service "${service}" has an invalid coverage adapter — an adapter is \`{ name, reports?, explains?, configure?, load?, capture?, inventory? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`);
|
|
112
|
+
throw new Error(`service "${service}" has an invalid coverage adapter — an adapter is \`{ name, reports?, explains?, changeRules?, configure?, load?, capture?, inventory? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`);
|
|
101
113
|
}
|
|
102
114
|
if (sourceOptions !== undefined && typeof sourceOptions !== "function")
|
|
103
115
|
validateCoverageSourceOptions(service, sourceOptions);
|
|
@@ -122,6 +134,7 @@ export function coverageAdapters(cov) {
|
|
|
122
134
|
export function applyCoverageAdapters(key, svc) {
|
|
123
135
|
validateCoverage(key, svc.coverage);
|
|
124
136
|
const adapters = coverageAdapters(svc.coverage);
|
|
137
|
+
serviceChangePolicy(svc); // Validate even uncovered services; never enables collection.
|
|
125
138
|
if (adapters.length === 0)
|
|
126
139
|
return svc;
|
|
127
140
|
let out = svc;
|
|
@@ -144,6 +157,43 @@ export function applyCoverageAdapters(key, svc) {
|
|
|
144
157
|
};
|
|
145
158
|
return { ...out, coverage: wire };
|
|
146
159
|
}
|
|
160
|
+
/** Resolve service policy without requiring coverage to be enabled. */
|
|
161
|
+
const RESOLVED_CHANGE_RULES = new Map();
|
|
162
|
+
export async function resolveAdapterChangeRules(adapter, ctx) {
|
|
163
|
+
if (!adapter.resolveChangeRules)
|
|
164
|
+
return;
|
|
165
|
+
const rules = await adapter.resolveChangeRules(ctx);
|
|
166
|
+
validateChangeRules(rules);
|
|
167
|
+
const before = RESOLVED_CHANGE_RULES.get(adapter.resolveChangeRules);
|
|
168
|
+
if (before && JSON.stringify(before) !== JSON.stringify(rules))
|
|
169
|
+
throw new Error(`adapter ${adapter.name} resolved inconsistent change rules across runtimes`);
|
|
170
|
+
RESOLVED_CHANGE_RULES.set(adapter.resolveChangeRules, rules.map(rule => ({ ...rule })));
|
|
171
|
+
}
|
|
172
|
+
export function serviceChangePolicy(svc, options = {}) {
|
|
173
|
+
const adapters = coverageAdapters(svc.coverage).map(adapter => {
|
|
174
|
+
if (!adapter.resolveChangeRules || svc.changeRules === false)
|
|
175
|
+
return adapter;
|
|
176
|
+
const rules = RESOLVED_CHANGE_RULES.get(adapter.resolveChangeRules);
|
|
177
|
+
if (!rules && options.requireResolved) {
|
|
178
|
+
// An unused declared image has no runtime metadata. Archive a conservative
|
|
179
|
+
// fallback, so its source cannot gain ignore/coverage exceptions.
|
|
180
|
+
const fallbackRoot = typeof adapter.sourceOptions === "object" ? adapter.sourceOptions.sourceRoot : undefined;
|
|
181
|
+
const serviceRoot = typeof svc.coverage === "object" ? svc.coverage.sourceRoot : undefined;
|
|
182
|
+
const roots = new Set([...(adapter.changeRules ?? []), ...(adapter.changeRuleGroups ?? []).flat()].map(rule => rule.root ?? fallbackRoot ?? serviceRoot ?? "."));
|
|
183
|
+
if (!roots.size)
|
|
184
|
+
roots.add(fallbackRoot ?? serviceRoot ?? ".");
|
|
185
|
+
return { ...adapter, changeRuleGroups: [], changeRules: [...roots].map(root => ({ root, pattern: "**", action: "full" })) };
|
|
186
|
+
}
|
|
187
|
+
return rules ? { ...adapter, changeRules: rules } : adapter;
|
|
188
|
+
});
|
|
189
|
+
return resolveChangePolicy(svc, adapters);
|
|
190
|
+
}
|
|
191
|
+
/** Inventory fingerprints must not turn ignored fixture manifests into changes.
|
|
192
|
+
* Installed runtime dependency inventories remain independent of project files. */
|
|
193
|
+
export function inventoryProjectFiles(svc, files) {
|
|
194
|
+
const action = compileChangePolicy(serviceChangePolicy(svc, { requireResolved: true }));
|
|
195
|
+
return files.filter(file => action(file) !== "ignore");
|
|
196
|
+
}
|
|
147
197
|
function validateCoverageSourceOptions(service, options) {
|
|
148
198
|
if (!options || typeof options !== "object" || Array.isArray(options) ||
|
|
149
199
|
!Object.keys(options).every(k => k === "sourceRoot" || k === "excludeSources")) {
|
|
@@ -154,6 +204,9 @@ function validateCoverageSourceOptions(service, options) {
|
|
|
154
204
|
/** Resolve adapter policies once per capture. Invalid/overlapping ownership is
|
|
155
205
|
* an error, so one adapter cannot silently reinterpret another's reports. */
|
|
156
206
|
export async function resolveCoverageSourceOptions(adapters, ctx, fallback) {
|
|
207
|
+
await Promise.all(adapters.map(a => resolveAdapterChangeRules(a, {
|
|
208
|
+
...ctx, sourceRoot: (typeof a.sourceOptions === "object" ? a.sourceOptions.sourceRoot : undefined) ?? fallback?.sourceRoot ?? ".",
|
|
209
|
+
})));
|
|
157
210
|
return Promise.all(adapters.filter(a => a.sourceOptions !== undefined || a.defaultExcludeSources !== undefined || a.ownsReport).map(async (a) => {
|
|
158
211
|
if (!a.ownsReport)
|
|
159
212
|
throw new Error(`coverage adapter "${a.name}" needs ownsReport with sourceOptions`);
|
|
@@ -186,7 +239,7 @@ function withWireName(a) {
|
|
|
186
239
|
if (Object.prototype.hasOwnProperty.call(a, "toJSON"))
|
|
187
240
|
return a;
|
|
188
241
|
return Object.assign(Object.create(Object.getPrototypeOf(a)), a, {
|
|
189
|
-
toJSON: () => ({ adapter: a.name, ...(a.defaultExcludeSources ? { defaultExcludeSources: a.defaultExcludeSources } : {}), ...(a.sourceOptions && typeof a.sourceOptions !== "function" ? { sourceOptions: a.sourceOptions } : {}) }),
|
|
242
|
+
toJSON: () => ({ adapter: a.name, ...(a.changeRules ? { changeRules: a.changeRules } : {}), ...(a.changeRuleGroups ? { changeRuleGroups: a.changeRuleGroups } : {}), ...(a.defaultExcludeSources ? { defaultExcludeSources: a.defaultExcludeSources } : {}), ...(a.sourceOptions && typeof a.sourceOptions !== "function" ? { sourceOptions: a.sourceOptions } : {}) }),
|
|
190
243
|
});
|
|
191
244
|
}
|
|
192
245
|
/** `env` with `value` appended to `name` (space-separated), or set. */
|
|
@@ -792,6 +845,12 @@ for (;;) {
|
|
|
792
845
|
process.stdout.write(Array.from(out).sort().join("\\n") + (out.size ? "\\n" : ""));
|
|
793
846
|
`;
|
|
794
847
|
export function node(options = {}) {
|
|
848
|
+
const policy = options.changeRules === undefined ? NODE_CHANGE_RULES : options.changeRules;
|
|
849
|
+
if (policy !== false)
|
|
850
|
+
validateChangeRules(policy);
|
|
851
|
+
if (options.sourceRoot !== undefined || options.excludeSources !== undefined) {
|
|
852
|
+
validateCoverageSourceOptions("node", { sourceRoot: options.sourceRoot, excludeSources: options.excludeSources });
|
|
853
|
+
}
|
|
795
854
|
if (options.generated !== undefined && (!Array.isArray(options.generated) || options.generated.some(p => typeof p !== "string"))) {
|
|
796
855
|
throw new Error("coverage.node generated must be a list of relative files");
|
|
797
856
|
}
|
|
@@ -802,6 +861,7 @@ export function node(options = {}) {
|
|
|
802
861
|
}
|
|
803
862
|
return {
|
|
804
863
|
name: "node",
|
|
864
|
+
changeRules: policy === false ? [] : policy.map(rule => ({ ...rule })),
|
|
805
865
|
defaultExcludeSources: JAVASCRIPT_GENERATED_SOURCES,
|
|
806
866
|
...(options.sourceRoot !== undefined || options.excludeSources !== undefined ? { sourceOptions: { sourceRoot: options.sourceRoot, excludeSources: options.excludeSources } } : {}),
|
|
807
867
|
ownsReport: name => isNodeDumpName(name) || name === NODE_COVERAGE_EMPTY_REPORT,
|
|
@@ -997,22 +1057,15 @@ export async function collectNodeReports(dir) {
|
|
|
997
1057
|
});
|
|
998
1058
|
return stats;
|
|
999
1059
|
}
|
|
1000
|
-
// ── browser ───────────────────────────────────────────────────────
|
|
1001
|
-
/**
|
|
1002
|
-
* Coverage for the frontend a service serves. The code runs in the guest
|
|
1003
|
-
* browser — spectest's own process — so no report can be written by the
|
|
1004
|
-
* container; spectest collects V8 coverage for every script the browser
|
|
1005
|
-
* loads from this service's origins, maps it back to source through the
|
|
1006
|
-
* served source map, and writes one lcov per capture (`browser.lcov`)
|
|
1007
|
-
* holding what ran since the previous capture: every live session is
|
|
1008
|
-
* harvested first, and only this service's totals are taken and cleared. Nothing ran ⇒
|
|
1009
|
-
* an lcov with no records, which is a report saying so.
|
|
1010
|
-
*/
|
|
1011
1060
|
export function browser(options = {}) {
|
|
1061
|
+
const rules = options.changeRules === undefined ? BROWSER_CHANGE_RULES : options.changeRules;
|
|
1062
|
+
if (rules !== false)
|
|
1063
|
+
validateChangeRules(rules);
|
|
1012
1064
|
return {
|
|
1013
1065
|
name: "browser",
|
|
1066
|
+
changeRules: rules === false ? [] : rules.map(rule => ({ ...rule })),
|
|
1014
1067
|
defaultExcludeSources: JAVASCRIPT_GENERATED_SOURCES,
|
|
1015
|
-
...(options.sourceRoot !== undefined || options.excludeSources !== undefined ? { sourceOptions: options } : {}),
|
|
1068
|
+
...(options.sourceRoot !== undefined || options.excludeSources !== undefined ? { sourceOptions: { sourceRoot: options.sourceRoot, excludeSources: options.excludeSources } } : {}),
|
|
1016
1069
|
ownsReport: name => name === "browser.lcov",
|
|
1017
1070
|
reports: "delta",
|
|
1018
1071
|
load(ctx) {
|
package/dist/daemon.js
CHANGED
|
@@ -37,7 +37,7 @@ import { ENVIRONMENT_CA_PATH as CA_PATH } from "./environment-ca.js";
|
|
|
37
37
|
import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, validateServiceImage, normalizeServiceImage, proxy as makeProxyDecl, } from "./index.js";
|
|
38
38
|
import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, applyCoverageDelta, walkProjectFiles, ENVIRONMENT_SERVICE, REACH_REPORT, READS_REPORT, } from "./harness/coverage.js";
|
|
39
39
|
import { configureBrowserCoverage } from "./browser-coverage.js";
|
|
40
|
-
import { INVENTORY_REPORT, NODE_COVERAGE_MARKERS_SUBDIR, NODE_COVERAGE_SCRIPTS_SUBDIR, applyCoverageAdapters, coverageAdapters, resolveCoverageSourceOptions, attachCoverageSourceOptions, coverageReportsMode, validateCoverage, } from "./coverage.js";
|
|
40
|
+
import { INVENTORY_REPORT, NODE_COVERAGE_MARKERS_SUBDIR, NODE_COVERAGE_SCRIPTS_SUBDIR, applyCoverageAdapters, coverageAdapters, serviceChangePolicy, inventoryProjectFiles, resolveCoverageSourceOptions, attachCoverageSourceOptions, coverageReportsMode, validateCoverage, } from "./coverage.js";
|
|
41
41
|
import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
|
|
42
42
|
import { acquirePersistentBrowser, acquirePersistentDesktop, mobileKey } from "./browser.js";
|
|
43
43
|
import { isMobileApp, openPersistentMobile } from "./mobile.js";
|
|
@@ -62,7 +62,7 @@ import { runTypecheck } from "./harness/typecheck.js";
|
|
|
62
62
|
import { evaluateChecked } from "./harness/eval-typecheck.js";
|
|
63
63
|
import { cpus } from "node:os";
|
|
64
64
|
import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath, takeProjectReads } from "./project-files.js";
|
|
65
|
-
import { compactReach, projectReach, renderReachRules, renderReadsLcov } from "./harness/reach.js";
|
|
65
|
+
import { compactReach, environmentReachSource, projectReach, renderReachRules, renderReadsLcov } from "./harness/reach.js";
|
|
66
66
|
import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
|
|
67
67
|
import { bindInstrumentationScope, createInstrumentationScope, runInstrumented, runUninstrumented, } from "./harness/instrumentation-scope.js";
|
|
68
68
|
import { ServiceDns, ServiceOperations } from "./harness/service-dns.js";
|
|
@@ -1058,7 +1058,7 @@ async function runServiceBuild(name, image, tag) {
|
|
|
1058
1058
|
if (remote) {
|
|
1059
1059
|
buildArgs = ["buildx", "build", "--builder", remote.builder,
|
|
1060
1060
|
"--progress=plain", "--metadata-file", metadataPath,
|
|
1061
|
-
"--output", shadowOutput(remoteReference), ...argFlags,
|
|
1061
|
+
"--output", shadowOutput(remoteReference, remote), ...argFlags,
|
|
1062
1062
|
"-f", dfPath, contextDir];
|
|
1063
1063
|
}
|
|
1064
1064
|
else if (useLocal) {
|
|
@@ -1150,7 +1150,7 @@ async function runServiceBuild(name, image, tag) {
|
|
|
1150
1150
|
const immutable = `${remoteReference.split(":result")[0]}@${digest}`;
|
|
1151
1151
|
const pullStarted = Date.now();
|
|
1152
1152
|
const pulled = await shxStream("docker", ["pull", immutable], undefined, undefined, () => { });
|
|
1153
|
-
await shadowTiming(name, "pull-unpack", pullStarted, { digest, ok: pulled.code === 0, lazy:
|
|
1153
|
+
await shadowTiming(name, "pull-unpack", pullStarted, { digest, ok: pulled.code === 0, lazy: remote.imageFormat === "nydus-v6", imageFormat: remote.imageFormat ?? "oci-zstd" });
|
|
1154
1154
|
if (pulled.code !== 0)
|
|
1155
1155
|
return { ok: false, log: pulled.stderr };
|
|
1156
1156
|
const tagged = await docker(["tag", immutable, tag]);
|
|
@@ -3196,7 +3196,7 @@ async function writeCoverageInventory(svc) {
|
|
|
3196
3196
|
}
|
|
3197
3197
|
return { stdout: r.stdout, stderr: r.stderr };
|
|
3198
3198
|
},
|
|
3199
|
-
projectFiles: () => walkProjectFiles(WORKSPACE),
|
|
3199
|
+
projectFiles: async () => inventoryProjectFiles(svc, await walkProjectFiles(WORKSPACE)),
|
|
3200
3200
|
async readProjectFile(p) {
|
|
3201
3201
|
// Not recorded as a read: an inventory fingerprinting every
|
|
3202
3202
|
// manifest is bookkeeping, not the suite depending on the files.
|
|
@@ -3253,11 +3253,13 @@ async function computeProjectReach(all) {
|
|
|
3253
3253
|
}
|
|
3254
3254
|
async function computeProjectReachInner(all) {
|
|
3255
3255
|
const files = await walkProjectFiles(WORKSPACE, [".git"]);
|
|
3256
|
-
const sources = [];
|
|
3256
|
+
const sources = [environmentReachSource(ENVIRONMENT_SERVICE)];
|
|
3257
3257
|
const owners = new Map(componentBuildTasks(all).map(t => [t.name, t.owner]));
|
|
3258
3258
|
for (const svc of imageTasks(all)) {
|
|
3259
|
+
const changePolicy = serviceChangePolicy(svc, { requireResolved: true });
|
|
3259
3260
|
const src = {
|
|
3260
3261
|
service: owners.get(svc.name) ?? svc.name,
|
|
3262
|
+
changePolicy,
|
|
3261
3263
|
volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source && !v.reachExempt ? [v.source] : [])),
|
|
3262
3264
|
...(svc.inputs && svc.inputs.length > 0 ? { inputs: [...svc.inputs] } : {}),
|
|
3263
3265
|
};
|
|
@@ -3279,7 +3281,7 @@ async function computeProjectReachInner(all) {
|
|
|
3279
3281
|
sources.push(src);
|
|
3280
3282
|
}
|
|
3281
3283
|
const reach = projectReach(files, sources);
|
|
3282
|
-
return { version:
|
|
3284
|
+
return { version: 2, sources, prefixes: compactReach(files, reach) };
|
|
3283
3285
|
}
|
|
3284
3286
|
/**
|
|
3285
3287
|
* The synthetic `__environment__` capture: what the environment itself
|
|
@@ -3324,7 +3326,6 @@ async function captureServiceCoverage(baseline = false) {
|
|
|
3324
3326
|
});
|
|
3325
3327
|
// The environment's own capture is unconditional (see above); the
|
|
3326
3328
|
// services' come from the ones that opted in, none included.
|
|
3327
|
-
const environment = captureEnvironmentCoverage(all, baseline);
|
|
3328
3329
|
const captures = await Promise.all(services.map(async (svc) => {
|
|
3329
3330
|
let policies;
|
|
3330
3331
|
try {
|
|
@@ -3386,6 +3387,7 @@ async function captureServiceCoverage(baseline = false) {
|
|
|
3386
3387
|
try {
|
|
3387
3388
|
return await adapter.collect({
|
|
3388
3389
|
...coverageCaptureContext(svc, signal, new Set()),
|
|
3390
|
+
changeRules: svc.changeRules,
|
|
3389
3391
|
projectFiles: () => walkProjectFiles(WORKSPACE),
|
|
3390
3392
|
async readProjectFile(p) {
|
|
3391
3393
|
try {
|
|
@@ -3401,7 +3403,8 @@ async function captureServiceCoverage(baseline = false) {
|
|
|
3401
3403
|
return [{ service: `${svc.name}/${adapter.name}`, reports: [], error: String(error) }];
|
|
3402
3404
|
}
|
|
3403
3405
|
})));
|
|
3404
|
-
|
|
3406
|
+
// Freeze build-dependent policy only after all direct and nested adapters resolved it.
|
|
3407
|
+
return [...captures, ...components.flat(), await captureEnvironmentCoverage(all, baseline)];
|
|
3405
3408
|
}
|
|
3406
3409
|
/**
|
|
3407
3410
|
* Mint a session id. `idScope` (the running test's case id; `"eval"`
|
|
@@ -5109,7 +5112,7 @@ export function harnessMethods(state) {
|
|
|
5109
5112
|
return {
|
|
5110
5113
|
health: async () => ({
|
|
5111
5114
|
ok: true,
|
|
5112
|
-
capabilities: ["prepared-images-v1", "shadow-builder-v1"],
|
|
5115
|
+
capabilities: ["prepared-images-v1", "shadow-builder-v1", "shadow-nydus-v1"],
|
|
5113
5116
|
// Optional discovery: old SDKs omit this field and are never sent the
|
|
5114
5117
|
// new method. Check the loaded project, including after warm restore.
|
|
5115
5118
|
setupInteractive: typeof loaded?.project.interactive?.seed === "function",
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { BuildImage, ComponentImageBuild, ServiceConfig } from "../index.js";
|
|
2
|
+
import { type ChangeRule } from "../change-rules.js";
|
|
2
3
|
export type KubernetesImage = string | (BuildImage & {
|
|
3
4
|
reference: string;
|
|
4
5
|
coverage?: import("../coverage.js").ServiceCoverage;
|
|
6
|
+
/** Override change-selection defaults for this image's own build context. */
|
|
7
|
+
changeRules?: readonly ChangeRule[] | false;
|
|
5
8
|
});
|
|
6
9
|
/** containerd/CRI use fully qualified names, including the default tag. */
|
|
7
10
|
export declare function canonicalImageReference(reference: string): string;
|
|
@@ -20,6 +23,8 @@ export declare function isComponentImageTask(name: string): boolean;
|
|
|
20
23
|
export declare function componentBuildTasks<T extends ServiceConfig & {
|
|
21
24
|
name: string;
|
|
22
25
|
}>(services: readonly T[]): {
|
|
26
|
+
changeRules?: false | readonly ChangeRule[] | undefined;
|
|
27
|
+
coverage?: import("../coverage.js").ServiceCoverage | undefined;
|
|
23
28
|
name: string;
|
|
24
29
|
owner: string;
|
|
25
30
|
reference: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { validateChangeRules } from "../change-rules.js";
|
|
2
3
|
/** containerd/CRI use fully qualified names, including the default tag. */
|
|
3
4
|
export function canonicalImageReference(reference) {
|
|
4
5
|
if (typeof reference !== "string" || !reference || reference.startsWith("-") || /[\s\x00]/.test(reference)) {
|
|
@@ -52,8 +53,14 @@ export function kubernetesImages(images) {
|
|
|
52
53
|
if (typeof image === "string")
|
|
53
54
|
pulls.push(image);
|
|
54
55
|
else {
|
|
55
|
-
const { coverage
|
|
56
|
-
|
|
56
|
+
const { coverage, changeRules, ...build } = image;
|
|
57
|
+
if (changeRules !== undefined && changeRules !== false)
|
|
58
|
+
validateChangeRules(changeRules);
|
|
59
|
+
builds.push({ reference,
|
|
60
|
+
image: coverage === undefined && changeRules === undefined ? image : build,
|
|
61
|
+
...(coverage === undefined ? {} : { coverage }),
|
|
62
|
+
...(changeRules === undefined ? {} : { changeRules }),
|
|
63
|
+
});
|
|
57
64
|
}
|
|
58
65
|
}
|
|
59
66
|
return { pulls, builds };
|
|
@@ -72,6 +79,12 @@ export function componentBuildTasks(services) {
|
|
|
72
79
|
owner: svc.name,
|
|
73
80
|
reference: build.reference,
|
|
74
81
|
image: build.image,
|
|
82
|
+
...(build.coverage === undefined ? {} : { coverage: build.coverage }),
|
|
83
|
+
// An image may specialize the cluster's explicit overrides. Each task
|
|
84
|
+
// keeps its own context and adapter source root; policies never leak to
|
|
85
|
+
// another image merely because the same cluster runs both.
|
|
86
|
+
...(build.changeRules !== undefined || svc.changeRules !== undefined
|
|
87
|
+
? { changeRules: build.changeRules ?? svc.changeRules } : {}),
|
|
75
88
|
})));
|
|
76
89
|
}
|
|
77
90
|
/** Private handoff to a component's setup hook. Builds stay generic; the
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { type CoverageCaptureContext, type CoverageInventoryContext, type ServiceCoverage } from "../coverage.js";
|
|
2
|
+
import type { ChangeRule } from "../change-rules.js";
|
|
2
3
|
import { type ServiceCoverageCapture } from "./coverage.js";
|
|
3
4
|
export declare function captureCoverageTarget(options: {
|
|
4
5
|
context: CoverageCaptureContext & CoverageInventoryContext;
|
|
5
6
|
coverage: ServiceCoverage;
|
|
7
|
+
changeRules?: readonly ChangeRule[] | false;
|
|
6
8
|
root: string;
|
|
7
9
|
baseline: boolean;
|
|
8
10
|
inventoryAvailable?: boolean;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Counter baselines belong to an instance; source identity belongs to its image.
|
|
3
3
|
import { promises as fs } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { attachCoverageSourceOptions, coverageAdapters, coverageReportsMode, resolveCoverageSourceOptions } from "../coverage.js";
|
|
5
|
+
import { attachCoverageSourceOptions, coverageAdapters, coverageReportsMode, resolveCoverageSourceOptions, inventoryProjectFiles } from "../coverage.js";
|
|
6
6
|
import { applyCoverageDelta, readCoverageDir } from "./coverage.js";
|
|
7
7
|
export async function captureCoverageTarget(options) {
|
|
8
8
|
const { context: ctx, coverage, root, baseline } = options;
|
|
@@ -34,8 +34,9 @@ export async function captureCoverageTarget(options) {
|
|
|
34
34
|
}
|
|
35
35
|
if (baseline && options.inventoryAvailable !== false) {
|
|
36
36
|
const units = new Set();
|
|
37
|
+
const inventoryContext = { ...context, projectFiles: async () => inventoryProjectFiles({ coverage, changeRules: options.changeRules }, await context.projectFiles()) };
|
|
37
38
|
for (const adapter of adapters)
|
|
38
|
-
for (const unit of await adapter.inventory?.(
|
|
39
|
+
for (const unit of await adapter.inventory?.(inventoryContext) ?? [])
|
|
39
40
|
units.add(unit);
|
|
40
41
|
await context.writeReport("inventory.units", [...units].sort().join("\n"));
|
|
41
42
|
}
|
package/dist/harness/reach.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export declare function isExcluded(patterns: Pattern[], rel: string): boolean;
|
|
|
14
14
|
export interface ReachSource {
|
|
15
15
|
/** The service the source belongs to (for the shadow page's verdicts). */
|
|
16
16
|
service?: string;
|
|
17
|
+
/** Frozen source-change policy, including adapter defaults and service overrides. */
|
|
18
|
+
changePolicy?: import("../change-rules.js").ChangePolicy;
|
|
17
19
|
/** Build context directory, project-relative (`.` for the root); absent
|
|
18
20
|
* for a registry image. */
|
|
19
21
|
context?: string;
|
|
@@ -30,6 +32,9 @@ export interface ReachSource {
|
|
|
30
32
|
* directory covers its subtree. */
|
|
31
33
|
inputs?: string[];
|
|
32
34
|
}
|
|
35
|
+
/** Environment/test sources remain owned even when every container has a narrow
|
|
36
|
+
* build context. Their common policy ignores nearby documentation. */
|
|
37
|
+
export declare function environmentReachSource(service: string): ReachSource;
|
|
33
38
|
/**
|
|
34
39
|
* The project's reach: every project file (`files`, project-relative)
|
|
35
40
|
* some service's build context includes or some volume mounts, plus the
|
|
@@ -38,7 +43,7 @@ export interface ReachSource {
|
|
|
38
43
|
export declare function projectReach(files: readonly string[], sources: readonly ReachSource[]): string[];
|
|
39
44
|
/** The `reach.json` report. Mirrors `reach::ReachRules` on the server. */
|
|
40
45
|
export interface ReachRules {
|
|
41
|
-
version:
|
|
46
|
+
version: 2;
|
|
42
47
|
/** Every service's contribution, as rules. */
|
|
43
48
|
sources: ReachSource[];
|
|
44
49
|
/** The current file reach, compacted: `dir/` for a directory every
|
package/dist/harness/reach.js
CHANGED
|
@@ -1,35 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// A project file affects a test only through one of three doors: it is in
|
|
4
|
-
// a service's **build context** (so an image may carry it), it is the
|
|
5
|
-
// **source of a volume** a service mounts, or a test **reads** it through
|
|
6
|
-
// the SDK. The first two are known from the environment config before any
|
|
7
|
-
// test runs; the harness ships them with the bring-up capture as
|
|
8
|
-
// **rules** (`reach.json`, under the synthetic `__environment__`
|
|
9
|
-
// service): per service its context directory, the composed
|
|
10
|
-
// `.dockerignore` text, the Dockerfile it reads and the volume sources it
|
|
11
|
-
// mounts. Rules, not a file list: the control plane evaluates any changed
|
|
12
|
-
// path against them, a file that did not exist at the base included, and
|
|
13
|
-
// a project of ten thousand files ships a few lines. Beside the rules
|
|
14
|
-
// rides a **compacted** view of the current file reach — a directory
|
|
15
|
-
// every file of which is in the reach as one `dir/` prefix, the odd file
|
|
16
|
-
// alone — which the control plane uses as a fast path and the shadow page
|
|
17
|
-
// as the summary. The subset selector then treats a changed file outside
|
|
18
|
-
// the reach as one that cannot affect the suite: `site/**` behind a
|
|
19
|
-
// `.dockerignore`, `.github/**` outside every context. The third door is
|
|
20
|
-
// recorded as it happens (`project-files.ts`, `takeProjectReads`).
|
|
21
|
-
//
|
|
22
|
-
// The rules are shipped for every project, coverage adapters or not: the
|
|
23
|
-
// reach is a property of the environment config alone.
|
|
24
|
-
//
|
|
25
|
-
// The context of a build is what `docker build` would send: every file
|
|
26
|
-
// under the context directory minus what the composed `.dockerignore`
|
|
27
|
-
// (the project's own rules plus the service's `exclude`) matches, with
|
|
28
|
-
// Docker's semantics — a pattern is matched against the path relative to
|
|
29
|
-
// the context root, `*` and `?` stay inside one segment, `**` spans
|
|
30
|
-
// segments, `!` re-includes, and a pattern that matches a directory
|
|
31
|
-
// excludes everything below it. When in doubt the answer is "reachable":
|
|
32
|
-
// over-approximating the reach only costs a full run.
|
|
1
|
+
import { resolveChangePolicy } from "../change-rules.js";
|
|
33
2
|
/** Docker's `.dockerignore` pattern → a regex over a `/`-separated path,
|
|
34
3
|
* after moby's `patternmatcher`. */
|
|
35
4
|
function compilePattern(raw) {
|
|
@@ -116,6 +85,11 @@ function normRel(p) {
|
|
|
116
85
|
s = s.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
117
86
|
return s === "." ? "" : s;
|
|
118
87
|
}
|
|
88
|
+
/** Environment/test sources remain owned even when every container has a narrow
|
|
89
|
+
* build context. Their common policy ignores nearby documentation. */
|
|
90
|
+
export function environmentReachSource(service) {
|
|
91
|
+
return { service, inputs: ["spectest"], changePolicy: resolveChangePolicy({}) };
|
|
92
|
+
}
|
|
119
93
|
/**
|
|
120
94
|
* The project's reach: every project file (`files`, project-relative)
|
|
121
95
|
* some service's build context includes or some volume mounts, plus the
|
|
@@ -147,7 +121,7 @@ export function projectReach(files, sources) {
|
|
|
147
121
|
continue;
|
|
148
122
|
const src = normRel(v);
|
|
149
123
|
for (const f of files) {
|
|
150
|
-
if (f === src || f.startsWith(`${src}/`))
|
|
124
|
+
if (src === "" || f === src || f.startsWith(`${src}/`))
|
|
151
125
|
out.add(f);
|
|
152
126
|
}
|
|
153
127
|
}
|
|
@@ -240,7 +214,7 @@ export function sourceOf(sources, path) {
|
|
|
240
214
|
if (v.startsWith("/"))
|
|
241
215
|
continue;
|
|
242
216
|
const src = normRel(v);
|
|
243
|
-
if (p === src || p.startsWith(`${src}/`))
|
|
217
|
+
if (src === "" || p === src || p.startsWith(`${src}/`))
|
|
244
218
|
return s;
|
|
245
219
|
}
|
|
246
220
|
}
|
|
@@ -3,10 +3,11 @@ export interface ShadowBuilder {
|
|
|
3
3
|
builder: string;
|
|
4
4
|
registry: string;
|
|
5
5
|
experiment: string;
|
|
6
|
+
imageFormat?: "nydus-v6";
|
|
6
7
|
}
|
|
7
8
|
export declare function validateShadowBuilder(value: unknown): ShadowBuilder;
|
|
8
9
|
export declare function shadowBuilder(): Promise<ShadowBuilder | undefined>;
|
|
9
10
|
export declare function shadowImage(c: ShadowBuilder, service: string): string;
|
|
10
|
-
export declare function shadowOutput(reference: string): string;
|
|
11
|
+
export declare function shadowOutput(reference: string, config?: ShadowBuilder): string;
|
|
11
12
|
export declare function imageDigest(metadata: unknown): string;
|
|
12
13
|
export declare function shadowTiming(service: string, phase: string, started: number, extra?: {}): Promise<void>;
|
|
@@ -5,7 +5,8 @@ export function validateShadowBuilder(value) {
|
|
|
5
5
|
const c = value;
|
|
6
6
|
if (!c || c.version !== 1 || !/^shadow-[a-z0-9-]+$/.test(c.builder)
|
|
7
7
|
|| !/^[a-z0-9.-]+(?::[0-9]+)?$/.test(c.registry)
|
|
8
|
-
|| !/^[a-z0-9-]{1,80}$/.test(c.experiment)
|
|
8
|
+
|| !/^[a-z0-9-]{1,80}$/.test(c.experiment)
|
|
9
|
+
|| (c.imageFormat !== undefined && c.imageFormat !== "nydus-v6"))
|
|
9
10
|
throw new Error("invalid shadow builder configuration");
|
|
10
11
|
return c;
|
|
11
12
|
}
|
|
@@ -19,8 +20,13 @@ export function shadowImage(c, service) {
|
|
|
19
20
|
const id = createHash("sha256").update(service).digest("hex").slice(0, 32);
|
|
20
21
|
return `${c.registry}/shadow/${c.experiment}/${id}:result`;
|
|
21
22
|
}
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
// Convert inherited gzip layers too; the persistent builder caches the conversion.
|
|
24
|
+
// Level 1 favors export latency over a small additional size reduction.
|
|
25
|
+
export function shadowOutput(reference, config) {
|
|
26
|
+
if (config?.imageFormat === "nydus-v6") {
|
|
27
|
+
return `type=image,name=${reference},push=true,oci-mediatypes=true,compression=nydus,force-compression=true`;
|
|
28
|
+
}
|
|
29
|
+
return `type=image,name=${reference},push=true,oci-mediatypes=true,compression=zstd,compression-level=1,force-compression=true`;
|
|
24
30
|
}
|
|
25
31
|
export function imageDigest(metadata) {
|
|
26
32
|
const digest = metadata?.["containerimage.digest"];
|
package/dist/index.d.ts
CHANGED
|
@@ -38,6 +38,8 @@ export type { Terminal, TerminalOpts, TerminalResult, TerminalWaitOptions, Termi
|
|
|
38
38
|
import type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
|
|
39
39
|
export { type CoverageAdapter, type CoverageCaptureContext, type CoverageConfigureInfo, type CoverageSourceOptions, type CoverageSourceContext, type CoverageLoadContext, type ServiceCoverage, } from "./coverage.js";
|
|
40
40
|
import { type ServiceCoverage } from "./coverage.js";
|
|
41
|
+
export { type ChangeRule, type ChangeAction, COMMON_CHANGE_RULES } from "./change-rules.js";
|
|
42
|
+
import type { ChangeRule } from "./change-rules.js";
|
|
41
43
|
export { ca } from "./environment-ca.js";
|
|
42
44
|
export { certificate, dnsName, proxy, provides, lowerIngress, isWildcard, SELF_SERVICE_TOKEN, } from "./ingress.js";
|
|
43
45
|
export type { CorsMode, CertificateDecl, DnsDecl, ProxyDecl, IngressDecl, DnsTarget, LoweredIngress, } from "./ingress.js";
|
|
@@ -197,6 +199,9 @@ export interface ServiceConfig {
|
|
|
197
199
|
* `spectest docs /services/coverage`.
|
|
198
200
|
*/
|
|
199
201
|
coverage?: ServiceCoverage;
|
|
202
|
+
/** Ordered source-change overrides, independent of coverage collection.
|
|
203
|
+
* Last matching rule wins. false disables inherited defaults. */
|
|
204
|
+
changeRules?: readonly ChangeRule[] | false;
|
|
200
205
|
/**
|
|
201
206
|
* Files seeded into the container's filesystem **before it starts**.
|
|
202
207
|
* Each entry's `content` is written to a VM-host staging path and
|
|
@@ -880,6 +885,9 @@ export type ServiceImage = string | BuildImage | NormalizedServiceImage;
|
|
|
880
885
|
export declare function normalizeServiceImage(image: ServiceImage): NormalizedServiceImage;
|
|
881
886
|
/** @internal Serializes build-only work with the owning component. */
|
|
882
887
|
export interface ComponentImageBuild {
|
|
888
|
+
/** Coverage and selection policy of the built application image. */
|
|
889
|
+
coverage?: ServiceCoverage;
|
|
890
|
+
changeRules?: readonly ChangeRule[] | false;
|
|
883
891
|
reference: string;
|
|
884
892
|
image: BuildImage;
|
|
885
893
|
}
|
package/dist/index.js
CHANGED
|
@@ -42,6 +42,7 @@ import { isLocator, getLocatorProbe, isBrowserSession, getBrowserProbe, } from "
|
|
|
42
42
|
import { escapeInvisible, textDifferenceNote, } from "./text-match.js";
|
|
43
43
|
import { describeUrlPattern } from "./url-match.js";
|
|
44
44
|
import { applyCoverageAdapters, coverageAdapters, validateCoverage } from "./coverage.js";
|
|
45
|
+
export { COMMON_CHANGE_RULES } from "./change-rules.js";
|
|
45
46
|
import { resolveExistingProjectPath } from "./project-files.js";
|
|
46
47
|
export { ca } from "./environment-ca.js";
|
|
47
48
|
// Low-level ingress primitives + the framework lowering that the friendly
|
|
@@ -185,6 +186,10 @@ function expandServiceGroups(input) {
|
|
|
185
186
|
// Rewrite relative dependsOn to final keys; a spread keeps function
|
|
186
187
|
// fields (helpers/setup) and the enumerable-symbol ingress decls.
|
|
187
188
|
const final = { ...svc, ...(part === entry.primary ? primaryOverrides : {}) };
|
|
189
|
+
// A group-level selection override applies to every generated owner.
|
|
190
|
+
if (primaryOverrides.changeRules !== undefined)
|
|
191
|
+
final.changeRules = primaryOverrides.changeRules === false
|
|
192
|
+
? false : [...(svc.changeRules || []), ...primaryOverrides.changeRules];
|
|
188
193
|
// Component-owned nested containers keep their collection when callers
|
|
189
194
|
// add ordinary service coverage (for example browser coverage on k3s).
|
|
190
195
|
const collectors = coverageAdapters(svc.coverage).filter(adapter => adapter.collect);
|
|
@@ -1163,8 +1163,6 @@ function serializeElementNode(n, options) {
|
|
|
1163
1163
|
canvasCtx = canvasService.getContext("2d");
|
|
1164
1164
|
}
|
|
1165
1165
|
const image = n;
|
|
1166
|
-
const imageSrc = image.currentSrc || image.getAttribute("src") || "<unknown-src>";
|
|
1167
|
-
const priorCrossOrigin = image.crossOrigin;
|
|
1168
1166
|
const recordInlineImage = () => {
|
|
1169
1167
|
image.removeEventListener("load", recordInlineImage);
|
|
1170
1168
|
try {
|
|
@@ -1175,21 +1173,8 @@ function serializeElementNode(n, options) {
|
|
|
1175
1173
|
dataURLOptions.type,
|
|
1176
1174
|
dataURLOptions.quality
|
|
1177
1175
|
);
|
|
1178
|
-
} catch (
|
|
1179
|
-
|
|
1180
|
-
image.crossOrigin = "anonymous";
|
|
1181
|
-
if (image.complete && image.naturalWidth !== 0)
|
|
1182
|
-
recordInlineImage();
|
|
1183
|
-
else image.addEventListener("load", recordInlineImage);
|
|
1184
|
-
return;
|
|
1185
|
-
} else {
|
|
1186
|
-
console.warn(
|
|
1187
|
-
`Cannot inline img src=${imageSrc}! Error: ${err}`
|
|
1188
|
-
);
|
|
1189
|
-
}
|
|
1190
|
-
}
|
|
1191
|
-
if (image.crossOrigin === "anonymous") {
|
|
1192
|
-
priorCrossOrigin ? attributes.crossOrigin = priorCrossOrigin : image.removeAttribute("crossorigin");
|
|
1176
|
+
} catch (e) {
|
|
1177
|
+
return;
|
|
1193
1178
|
}
|
|
1194
1179
|
};
|
|
1195
1180
|
if (image.complete && image.naturalWidth !== 0) recordInlineImage();
|
package/package.json
CHANGED
|
@@ -14,10 +14,13 @@ test.skipIf(!existsSync(executablePath))("cross-site script ID reuse cannot attr
|
|
|
14
14
|
const map = Buffer.from(JSON.stringify({ version: 3, sources: ["src/customer.ts"], names: [], mappings: "AAAA" })).toString("base64");
|
|
15
15
|
const server = Bun.serve({ hostname: "0.0.0.0", port: 0, fetch(request) {
|
|
16
16
|
const url = new URL(request.url);
|
|
17
|
+
if (url.pathname === "/padding.js") return new Response("void 0;", { headers: { "content-type": "application/javascript" } });
|
|
17
18
|
if (url.pathname === emptyScript) return new Response("", { headers: { "content-type": "application/javascript" } });
|
|
18
19
|
if (url.pathname === "/app.js") return new Response(`globalThis.appRan = true;\n//# sourceMappingURL=data:application/json;base64,${map}`, { headers: { "content-type": "application/javascript" } });
|
|
19
20
|
return new Response(url.hostname === "127.0.0.1"
|
|
20
|
-
|
|
21
|
+
// Advance the application's script IDs past browser-injected scripts.
|
|
22
|
+
// Padding is served outside the covered service and has no source map.
|
|
23
|
+
? `${Array.from({ length: 32 }, (_, i) => `<script src="http://127.0.0.2:${url.port}/padding.js?${i}"></script>`).join("")}<script src="${emptyScript}"></script><script src="/app.js"></script><p>Application</p>`
|
|
21
24
|
: "<p>External login</p>", { headers: { "content-type": "text/html", "cache-control": "no-store" } });
|
|
22
25
|
} });
|
|
23
26
|
const browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox", "--site-per-process"] });
|
|
@@ -36,7 +39,12 @@ test.skipIf(!existsSync(executablePath))("cross-site script ID reuse cannot attr
|
|
|
36
39
|
// Keep collected application counts across navigation until service capture.
|
|
37
40
|
for (const host of ["127.0.0.1", "127.0.0.2", "127.0.0.1", "127.0.0.2"]) {
|
|
38
41
|
await page.goto(`http://${host}:${server.port}/`);
|
|
39
|
-
|
|
42
|
+
// V8's first evaluation ID varies across browser versions and runners.
|
|
43
|
+
// Generate distinct anonymous scripts until the old named ID is reused.
|
|
44
|
+
for (let i = 0; i < 128; i++) {
|
|
45
|
+
await cdp.send("Runtime.evaluate", { expression: `(() => { const value = ${i}; return value + 1; })()` });
|
|
46
|
+
if (host === "127.0.0.1" || anonymousReuse) break;
|
|
47
|
+
}
|
|
40
48
|
await harvestBrowserCoverage(cdp);
|
|
41
49
|
}
|
|
42
50
|
expect(anonymousReuse).toBe(true); // Prove the regression's trigger happened.
|