@specific.dev/spectest 0.131.16 → 0.131.18
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/README.md +8 -0
- package/dist/browser-coverage.js +24 -6
- package/dist/browser-surface.d.ts +9 -0
- package/dist/browser-surface.js +71 -0
- package/dist/browser.js +4 -3
- package/dist/daemon.js +29 -4
- package/dist/harness/shadow-builder.d.ts +12 -0
- package/dist/harness/shadow-builder.js +36 -0
- package/package.json +1 -1
- package/src/browser-coverage-deadline.test.ts +4 -1
- package/src/browser-coverage-identity.test.ts +87 -0
- package/src/browser-coverage-navigation.test.ts +54 -0
- package/src/browser-coverage.ts +21 -5
- package/src/browser-surface.test.ts +68 -0
- package/src/browser-surface.ts +70 -0
- package/src/browser.ts +4 -3
- package/src/daemon.ts +25 -4
- package/src/harness/shadow-builder.test.ts +29 -0
- package/src/harness/shadow-builder.ts +41 -0
package/README.md
CHANGED
|
@@ -83,6 +83,14 @@ Pub/Sub integrations are project-owned as of SDK 0.98.0. The former
|
|
|
83
83
|
Use a local service definition or `serviceGroup()` wrapper; the repository’s
|
|
84
84
|
`examples/google-pubsub` demonstrates this using public SDK APIs.
|
|
85
85
|
|
|
86
|
+
## Browser coverage across navigation (0.131.17)
|
|
87
|
+
|
|
88
|
+
Browser coverage keeps script identities separate when Chromium changes renderers
|
|
89
|
+
during cross-site navigation, including login redirects. Anonymous browser code
|
|
90
|
+
cannot inherit an earlier script’s URL or source map. No application configuration
|
|
91
|
+
change is needed. A script identity change during capture is reported as incomplete
|
|
92
|
+
coverage instead of publishing uncertain attribution.
|
|
93
|
+
|
|
86
94
|
## Next.js coverage (0.131.16)
|
|
87
95
|
|
|
88
96
|
Use `coverage.next({ appDir: "ops", sourceRoot: "." })` in your Spectest
|
package/dist/browser-coverage.js
CHANGED
|
@@ -47,8 +47,9 @@ export async function attachBrowserCoverage(cdp) {
|
|
|
47
47
|
COLLECTORS.set(cdp, collector);
|
|
48
48
|
LIVE_COLLECTORS.add(collector);
|
|
49
49
|
cdp.on("Debugger.scriptParsed", (ev) => {
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
// IDs belong to a renderer, not the lifetime of this CDP session. A
|
|
51
|
+
// cross-site navigation can reuse an old named ID for anonymous code.
|
|
52
|
+
// Replace anonymous entries too, invalidating the previous URL/source/map.
|
|
52
53
|
collector.scripts.set(ev.scriptId, {
|
|
53
54
|
url: ev.url,
|
|
54
55
|
sourceMapURL: ev.sourceMapURL || undefined,
|
|
@@ -108,6 +109,7 @@ export async function harvestBrowserCoverage(cdp, signal) {
|
|
|
108
109
|
}
|
|
109
110
|
}
|
|
110
111
|
async function harvestInner(c, config, signal) {
|
|
112
|
+
const scriptsAtTake = new Map(c.scripts);
|
|
111
113
|
let result;
|
|
112
114
|
try {
|
|
113
115
|
({ result } = (await c.cdp.send("Profiler.takePreciseCoverage")));
|
|
@@ -119,12 +121,13 @@ async function harvestInner(c, config, signal) {
|
|
|
119
121
|
throw err;
|
|
120
122
|
}
|
|
121
123
|
for (const script of result) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
+
// The profiler's URL identifies this execution. Never supply a URL from
|
|
125
|
+
// an earlier renderer to an anonymous result.
|
|
126
|
+
if (!script.url)
|
|
124
127
|
continue;
|
|
125
128
|
let url;
|
|
126
129
|
try {
|
|
127
|
-
url = new URL(
|
|
130
|
+
url = new URL(script.url);
|
|
128
131
|
}
|
|
129
132
|
catch {
|
|
130
133
|
continue;
|
|
@@ -134,19 +137,34 @@ async function harvestInner(c, config, signal) {
|
|
|
134
137
|
const service = config.serviceForHost(url.hostname);
|
|
135
138
|
if (!service)
|
|
136
139
|
continue;
|
|
140
|
+
let info = c.scripts.get(script.scriptId);
|
|
141
|
+
const before = scriptsAtTake.get(script.scriptId);
|
|
142
|
+
const changed = () => {
|
|
143
|
+
c.incomplete = new Error("Browser script identity changed during coverage capture; coverage is incomplete");
|
|
144
|
+
return c.incomplete;
|
|
145
|
+
};
|
|
146
|
+
if ((before && before !== info) || (info && info.url !== script.url))
|
|
147
|
+
throw changed();
|
|
148
|
+
if (!info) {
|
|
149
|
+
info = { url: script.url };
|
|
150
|
+
c.scripts.set(script.scriptId, info);
|
|
151
|
+
}
|
|
137
152
|
if (info.source === undefined) {
|
|
138
153
|
try {
|
|
139
154
|
const r = (await c.cdp.send("Debugger.getScriptSource", {
|
|
140
155
|
scriptId: script.scriptId,
|
|
141
156
|
}));
|
|
142
157
|
signal.throwIfAborted();
|
|
158
|
+
if (c.scripts.get(script.scriptId) !== info)
|
|
159
|
+
throw changed();
|
|
143
160
|
info.source = r.scriptSource;
|
|
144
161
|
}
|
|
145
162
|
catch {
|
|
146
163
|
signal.throwIfAborted();
|
|
164
|
+
if (c.incomplete)
|
|
165
|
+
throw c.incomplete;
|
|
147
166
|
continue; // script gone with its document
|
|
148
167
|
}
|
|
149
|
-
c.scripts.set(script.scriptId, info);
|
|
150
168
|
}
|
|
151
169
|
const generated = rangesToLineCounts(info.source, script.functions);
|
|
152
170
|
if (generated.size === 0)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { BrowserContext, CDPSession, Page } from "playwright-core";
|
|
2
|
+
export declare function reduceBrowserSurfaces(context: BrowserContext, options: {
|
|
3
|
+
mobile: boolean;
|
|
4
|
+
deviceScaleFactor: number;
|
|
5
|
+
}): void;
|
|
6
|
+
export declare function awaitBrowserSurface(page: Page): Promise<void>;
|
|
7
|
+
export declare function captureBrowserScreenshot(page: Page, cdp: CDPSession): Promise<{
|
|
8
|
+
data: string;
|
|
9
|
+
}>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Layout, input coordinates and the visual viewport retain their real size.
|
|
2
|
+
// Only the compositor's output is clipped. Native frame scheduling (including
|
|
3
|
+
// rAF on otherwise quiet pages) remains Chromium's responsibility.
|
|
4
|
+
const SURFACES = new WeakMap();
|
|
5
|
+
class Surface {
|
|
6
|
+
page;
|
|
7
|
+
cdp;
|
|
8
|
+
options;
|
|
9
|
+
tail = Promise.resolve();
|
|
10
|
+
constructor(page, cdp, options) {
|
|
11
|
+
this.page = page;
|
|
12
|
+
this.cdp = cdp;
|
|
13
|
+
this.options = options;
|
|
14
|
+
}
|
|
15
|
+
async apply(reduced) {
|
|
16
|
+
const viewport = this.page.viewportSize();
|
|
17
|
+
if (!viewport)
|
|
18
|
+
throw new Error("Browser surface requires a fixed viewport");
|
|
19
|
+
const landscape = viewport.width > viewport.height;
|
|
20
|
+
await this.cdp.send("Emulation.setDeviceMetricsOverride", {
|
|
21
|
+
...viewport,
|
|
22
|
+
...this.options,
|
|
23
|
+
screenWidth: viewport.width,
|
|
24
|
+
screenHeight: viewport.height,
|
|
25
|
+
screenOrientation: this.options.mobile
|
|
26
|
+
? { angle: landscape ? 90 : 0, type: landscape ? "landscapePrimary" : "portraitPrimary" }
|
|
27
|
+
: { angle: 0, type: "landscapePrimary" },
|
|
28
|
+
...(reduced ? { viewport: { x: 0, y: 0, width: 1, height: 1, scale: 1 } } : {}),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
screenshot() {
|
|
32
|
+
// Concurrent screenshots must not shrink another capture's surface.
|
|
33
|
+
const result = this.tail.then(async () => {
|
|
34
|
+
try {
|
|
35
|
+
await this.apply(false);
|
|
36
|
+
// Capture through the same emulation agent: a different CDP session
|
|
37
|
+
// can read back mobile screenshots at the wrong device scale.
|
|
38
|
+
return await this.cdp.send("Page.captureScreenshot", { format: "png" });
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
if (!this.page.isClosed())
|
|
42
|
+
await this.apply(true);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
this.tail = result.catch(() => { });
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function reduceBrowserSurfaces(context, options) {
|
|
50
|
+
context.on("page", page => {
|
|
51
|
+
const pending = (async () => {
|
|
52
|
+
const surface = new Surface(page, await context.newCDPSession(page), options);
|
|
53
|
+
await surface.apply(true);
|
|
54
|
+
return surface;
|
|
55
|
+
})();
|
|
56
|
+
SURFACES.set(page, pending);
|
|
57
|
+
// spawnPage awaits this for SDK views. Popups have no spawnPage caller.
|
|
58
|
+
void pending.catch(error => {
|
|
59
|
+
if (!page.isClosed())
|
|
60
|
+
console.warn("[spectest] failed to reduce browser surface:", error);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
export async function awaitBrowserSurface(page) {
|
|
65
|
+
await SURFACES.get(page);
|
|
66
|
+
}
|
|
67
|
+
export async function captureBrowserScreenshot(page, cdp) {
|
|
68
|
+
const surface = await SURFACES.get(page);
|
|
69
|
+
// Externally attached browsers (e.g. Electron) retain their own viewport.
|
|
70
|
+
return surface ? surface.screenshot() : cdp.send("Page.captureScreenshot", { format: "png" });
|
|
71
|
+
}
|
package/dist/browser.js
CHANGED
|
@@ -43,6 +43,7 @@ import { describeUrlPattern, matchesUrl } from "./url-match.js";
|
|
|
43
43
|
import { attachBrowserCoverage, browserCoverageActive, harvestBrowserCoverage, } from "./browser-coverage.js";
|
|
44
44
|
import { attachBrowserProbe, DEFAULT_ACTION_TIMEOUT_MS, desktopStrategy, makeLocator, mobileStrategy, } from "./locator.js";
|
|
45
45
|
import { chromium } from "playwright-core";
|
|
46
|
+
import { reduceBrowserSurfaces, awaitBrowserSurface, captureBrowserScreenshot } from "./browser-surface.js";
|
|
46
47
|
/** Decoded byte count of a base64 string, without decoding it. */
|
|
47
48
|
function base64ByteLength(b64) {
|
|
48
49
|
let padding = 0;
|
|
@@ -179,6 +180,7 @@ async function newViewContext(width, height, device, opts = {}) {
|
|
|
179
180
|
timezoneId: opts.timezone,
|
|
180
181
|
locale: opts.locale,
|
|
181
182
|
});
|
|
183
|
+
reduceBrowserSurfaces(context, { mobile: !!device, deviceScaleFactor: device?.deviceScaleFactor ?? 1 });
|
|
182
184
|
context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
|
|
183
185
|
context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
|
|
184
186
|
if (fixture) {
|
|
@@ -847,6 +849,7 @@ const VIEW_POOL = [];
|
|
|
847
849
|
* slow part (renderer process spawn). */
|
|
848
850
|
async function spawnPage(context) {
|
|
849
851
|
const page = await context.newPage();
|
|
852
|
+
await awaitBrowserSurface(page);
|
|
850
853
|
const cdp = await context.newCDPSession(page);
|
|
851
854
|
// Without Page.enable the addScriptToEvaluateOnNewDocument registration
|
|
852
855
|
// silently never fires on this session (verified in the fork spike).
|
|
@@ -1651,9 +1654,7 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1651
1654
|
}
|
|
1652
1655
|
// Raw CDP rather than page.screenshot(): captures the viewport
|
|
1653
1656
|
// exactly like the pre-Playwright backend.
|
|
1654
|
-
const res =
|
|
1655
|
-
format: "png",
|
|
1656
|
-
}));
|
|
1657
|
+
const res = await captureBrowserScreenshot(holder.page, holder.cdp);
|
|
1657
1658
|
const id = generateId("art");
|
|
1658
1659
|
register({
|
|
1659
1660
|
id,
|
package/dist/daemon.js
CHANGED
|
@@ -52,6 +52,7 @@ import { ensureStickyBuildHeadroom } from "./harness/sticky-consumer.js";
|
|
|
52
52
|
import { fakeFunctions, fakeFunctionParameters, callFakeFunction } from "./harness/fake-functions.js";
|
|
53
53
|
import { dispatchFake } from "./harness/fake-websocket.js";
|
|
54
54
|
import { casesMetadata as catalogueCases, groupsMetadata as catalogueGroups, } from "./harness/catalogue.js";
|
|
55
|
+
import { shadowBuilder, shadowImage, shadowOutput, imageDigest, shadowTiming } from "./harness/shadow-builder.js";
|
|
55
56
|
import { summarizeBuildKit } from "./harness/buildkit-progress.js";
|
|
56
57
|
import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta, mergeServiceLogDeltas } from "./harness/log-delta.js";
|
|
57
58
|
import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./harness/volume-paths.js";
|
|
@@ -1039,18 +1040,28 @@ async function runServiceBuild(name, image, tag) {
|
|
|
1039
1040
|
// own layer cache, and the finished image is already in the store
|
|
1040
1041
|
// dockerd reads. dockerd's built-in BuildKit is the fallback (a guest
|
|
1041
1042
|
// with no store, or a daemon that would not start).
|
|
1042
|
-
const
|
|
1043
|
+
const remote = await shadowBuilder();
|
|
1044
|
+
const useLocal = !remote && await ensureLocalBuildkitd();
|
|
1043
1045
|
// Both the in-VM daemon and dockerd's buildx are BuildKit, so both
|
|
1044
1046
|
// emit per-step timing on stderr under `--progress=plain` (parsed
|
|
1045
1047
|
// below). Only the legacy builder takes no progress flag.
|
|
1046
|
-
const useBuildKit = useLocal || (await hasBuildx());
|
|
1048
|
+
const useBuildKit = !!remote || useLocal || (await hasBuildx());
|
|
1047
1049
|
const buildEnv = {};
|
|
1048
1050
|
let buildArgs;
|
|
1049
1051
|
// The user's `buildArgs`, as `--build-arg` flags; a plain client flag,
|
|
1050
1052
|
// so every builder — in-VM BuildKit, dockerd's own, legacy — takes it.
|
|
1051
1053
|
const argFlags = [...buildTargetFlags(image.target), ...buildArgFlags(image.buildArgs)];
|
|
1052
1054
|
const contextDir = image.contextDir;
|
|
1053
|
-
|
|
1055
|
+
const remoteReference = remote ? shadowImage(remote, name) : undefined;
|
|
1056
|
+
const metadataPath = path.join(dfDir, "shadow-result.json");
|
|
1057
|
+
const buildStarted = Date.now();
|
|
1058
|
+
if (remote) {
|
|
1059
|
+
buildArgs = ["buildx", "build", "--builder", remote.builder,
|
|
1060
|
+
"--progress=plain", "--metadata-file", metadataPath,
|
|
1061
|
+
"--output", shadowOutput(remoteReference), ...argFlags,
|
|
1062
|
+
"-f", dfPath, contextDir];
|
|
1063
|
+
}
|
|
1064
|
+
else if (useLocal) {
|
|
1054
1065
|
// The in-VM builder is BuildKit's containerd worker on this VM's
|
|
1055
1066
|
// own image store (CONTAINER_STORE.md): the output is an image
|
|
1056
1067
|
// record in dockerd's namespace, unpacked, so there is no `--load`
|
|
@@ -1129,9 +1140,23 @@ async function runServiceBuild(name, image, tag) {
|
|
|
1129
1140
|
}
|
|
1130
1141
|
});
|
|
1131
1142
|
const log = `${build.stderr.trim()}\n${build.stdout.trim()}`;
|
|
1143
|
+
if (remote)
|
|
1144
|
+
await shadowTiming(name, "solve-export-push", buildStarted, { ok: build.code === 0 });
|
|
1132
1145
|
if (build.code !== 0) {
|
|
1133
1146
|
return { ok: false, log };
|
|
1134
1147
|
}
|
|
1148
|
+
if (remote) {
|
|
1149
|
+
const digest = imageDigest(JSON.parse(await fs.readFile(metadataPath, "utf8")));
|
|
1150
|
+
const immutable = `${remoteReference.split(":result")[0]}@${digest}`;
|
|
1151
|
+
const pullStarted = Date.now();
|
|
1152
|
+
const pulled = await shxStream("docker", ["pull", immutable], undefined, undefined, () => { });
|
|
1153
|
+
await shadowTiming(name, "pull-unpack", pullStarted, { digest, ok: pulled.code === 0, lazy: false });
|
|
1154
|
+
if (pulled.code !== 0)
|
|
1155
|
+
return { ok: false, log: pulled.stderr };
|
|
1156
|
+
const tagged = await docker(["tag", immutable, tag]);
|
|
1157
|
+
if (tagged.code !== 0)
|
|
1158
|
+
return { ok: false, log: tagged.stderr };
|
|
1159
|
+
}
|
|
1135
1160
|
if (useBuildKit) {
|
|
1136
1161
|
// Keep only the slowest dozen steps ≥1s — enough to profile, small
|
|
1137
1162
|
// enough to ride back in the /bootstrap response and the journal.
|
|
@@ -5133,7 +5158,7 @@ export function harnessMethods(state) {
|
|
|
5133
5158
|
return {
|
|
5134
5159
|
health: async () => ({
|
|
5135
5160
|
ok: true,
|
|
5136
|
-
capabilities: ["prepared-images-v1"],
|
|
5161
|
+
capabilities: ["prepared-images-v1", "shadow-builder-v1"],
|
|
5137
5162
|
// Optional discovery: old SDKs omit this field and are never sent the
|
|
5138
5163
|
// new method. Check the loaded project, including after warm restore.
|
|
5139
5164
|
setupInteractive: typeof loaded?.project.interactive?.seed === "function",
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface ShadowBuilder {
|
|
2
|
+
version: 1;
|
|
3
|
+
builder: string;
|
|
4
|
+
registry: string;
|
|
5
|
+
experiment: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function validateShadowBuilder(value: unknown): ShadowBuilder;
|
|
8
|
+
export declare function shadowBuilder(): Promise<ShadowBuilder | undefined>;
|
|
9
|
+
export declare function shadowImage(c: ShadowBuilder, service: string): string;
|
|
10
|
+
export declare function shadowOutput(reference: string): string;
|
|
11
|
+
export declare function imageDigest(metadata: unknown): string;
|
|
12
|
+
export declare function shadowTiming(service: string, phase: string, started: number, extra?: {}): Promise<void>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Internal experiment configuration. Never selected by project configuration. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
export function validateShadowBuilder(value) {
|
|
5
|
+
const c = value;
|
|
6
|
+
if (!c || c.version !== 1 || !/^shadow-[a-z0-9-]+$/.test(c.builder)
|
|
7
|
+
|| !/^[a-z0-9.-]+(?::[0-9]+)?$/.test(c.registry)
|
|
8
|
+
|| !/^[a-z0-9-]{1,80}$/.test(c.experiment))
|
|
9
|
+
throw new Error("invalid shadow builder configuration");
|
|
10
|
+
return c;
|
|
11
|
+
}
|
|
12
|
+
export async function shadowBuilder() {
|
|
13
|
+
if (process.env.SPECTEST_SHADOW_BUILD !== "1")
|
|
14
|
+
return undefined;
|
|
15
|
+
// A missing configuration is an experiment failure, never a local build.
|
|
16
|
+
return validateShadowBuilder(JSON.parse(await fs.readFile("/run/spectest/shadow-builder.json", "utf8")));
|
|
17
|
+
}
|
|
18
|
+
export function shadowImage(c, service) {
|
|
19
|
+
const id = createHash("sha256").update(service).digest("hex").slice(0, 32);
|
|
20
|
+
return `${c.registry}/shadow/${c.experiment}/${id}:result`;
|
|
21
|
+
}
|
|
22
|
+
export function shadowOutput(reference) {
|
|
23
|
+
return `type=image,name=${reference},push=true,oci-mediatypes=true,compression=zstd`;
|
|
24
|
+
}
|
|
25
|
+
export function imageDigest(metadata) {
|
|
26
|
+
const digest = metadata?.["containerimage.digest"];
|
|
27
|
+
if (typeof digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(digest)) {
|
|
28
|
+
throw new Error("remote builder did not return an image digest");
|
|
29
|
+
}
|
|
30
|
+
return digest;
|
|
31
|
+
}
|
|
32
|
+
export async function shadowTiming(service, phase, started, extra = {}) {
|
|
33
|
+
await fs.appendFile("/run/spectest/shadow-timings.jsonl", JSON.stringify({
|
|
34
|
+
service, phase, startedMs: started, durationMs: Date.now() - started, ...extra,
|
|
35
|
+
}) + "\n");
|
|
36
|
+
}
|
package/package.json
CHANGED
|
@@ -27,7 +27,10 @@ test("an ambiguous coverage timeout is retained and a late CDP result cannot pub
|
|
|
27
27
|
await Bun.sleep(0);
|
|
28
28
|
expect(sourceReads).toBe(0);
|
|
29
29
|
expect(browserCoverageReports().has("coverage-deadline-test")).toBe(false);
|
|
30
|
-
|
|
30
|
+
// Other tests may also retain incomplete collectors. Global harvest must
|
|
31
|
+
// reject; this session must retain its own interruption reason.
|
|
32
|
+
await expect(harvestAllBrowserCoverage()).rejects.toThrow("coverage is incomplete");
|
|
33
|
+
await expect(harvestBrowserCoverage(cdp)).rejects.toThrow("Browser coverage capture was interrupted; coverage is incomplete");
|
|
31
34
|
expect(takes).toBe(1);
|
|
32
35
|
} finally { configureBrowserCoverage(null); }
|
|
33
36
|
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import type { CDPSession } from "playwright-core";
|
|
4
|
+
import {
|
|
5
|
+
attachBrowserCoverage, configureBrowserCoverage, harvestBrowserCoverage,
|
|
6
|
+
takeBrowserCoverageReport,
|
|
7
|
+
} from "./browser-coverage.js";
|
|
8
|
+
|
|
9
|
+
function session() {
|
|
10
|
+
const events = new EventEmitter();
|
|
11
|
+
let source = "globalThis.ran = true;";
|
|
12
|
+
let url = "http://app.test/app.js";
|
|
13
|
+
const cdp = Object.assign(events, {
|
|
14
|
+
async send(method: string): Promise<any> {
|
|
15
|
+
if (method === "Profiler.takePreciseCoverage") return { result: [{ scriptId: "4", url,
|
|
16
|
+
functions: [{ ranges: [{ startOffset: 0, endOffset: source.length, count: 1 }] }] }] };
|
|
17
|
+
if (method === "Debugger.getScriptSource") return { scriptSource: source };
|
|
18
|
+
return {};
|
|
19
|
+
},
|
|
20
|
+
}) as unknown as CDPSession;
|
|
21
|
+
return { cdp, parsed(nextUrl = url, nextSource = source, sourceMapURL?: string) {
|
|
22
|
+
url = nextUrl; source = nextSource;
|
|
23
|
+
events.emit("Debugger.scriptParsed", { scriptId: "4", url, sourceMapURL });
|
|
24
|
+
} };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const inlineMap = (source: string) => "data:application/json;base64," + Buffer.from(JSON.stringify({
|
|
28
|
+
version: 3, sources: [source], names: [], mappings: "AAAA",
|
|
29
|
+
})).toString("base64");
|
|
30
|
+
|
|
31
|
+
test("a reused named ID replaces cached source maps even when its URL is unchanged", async () => {
|
|
32
|
+
const s = session();
|
|
33
|
+
configureBrowserCoverage({ serviceForHost: () => "identity-maps" });
|
|
34
|
+
try {
|
|
35
|
+
await attachBrowserCoverage(s.cdp);
|
|
36
|
+
s.parsed(undefined, undefined, inlineMap("src/old.ts"));
|
|
37
|
+
await harvestBrowserCoverage(s.cdp);
|
|
38
|
+
expect(takeBrowserCoverageReport("identity-maps")).toContain("SF:src/old.ts");
|
|
39
|
+
s.parsed(undefined, undefined, inlineMap("src/new.ts"));
|
|
40
|
+
await harvestBrowserCoverage(s.cdp);
|
|
41
|
+
const report = takeBrowserCoverageReport("identity-maps");
|
|
42
|
+
expect(report).toContain("SF:src/new.ts");
|
|
43
|
+
expect(report).not.toContain("src/old.ts");
|
|
44
|
+
} finally { configureBrowserCoverage(null); takeBrowserCoverageReport("identity-maps"); }
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("the profiler's anonymous URL cannot inherit a stale named URL", async () => {
|
|
48
|
+
const s = session();
|
|
49
|
+
configureBrowserCoverage({ serviceForHost: () => "identity-anonymous" });
|
|
50
|
+
try {
|
|
51
|
+
await attachBrowserCoverage(s.cdp);
|
|
52
|
+
s.parsed();
|
|
53
|
+
const send = s.cdp.send.bind(s.cdp);
|
|
54
|
+
s.cdp.send = (async (method: string) => {
|
|
55
|
+
if (method === "Profiler.takePreciseCoverage") {
|
|
56
|
+
const result = await send(method as any) as any;
|
|
57
|
+
result.result[0].url = "";
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
return send(method as any);
|
|
61
|
+
}) as CDPSession["send"];
|
|
62
|
+
await harvestBrowserCoverage(s.cdp);
|
|
63
|
+
expect(takeBrowserCoverageReport("identity-anonymous")).toBe("TN:\n");
|
|
64
|
+
} finally { configureBrowserCoverage(null); }
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
for (const phase of ["Profiler.takePreciseCoverage", "Debugger.getScriptSource"]) {
|
|
68
|
+
test(`script replacement during ${phase} retains an incomplete-capture error`, async () => {
|
|
69
|
+
const s = session();
|
|
70
|
+
const service = `identity-race-${phase}`;
|
|
71
|
+
configureBrowserCoverage({ serviceForHost: () => service });
|
|
72
|
+
try {
|
|
73
|
+
await attachBrowserCoverage(s.cdp);
|
|
74
|
+
s.parsed();
|
|
75
|
+
const send = s.cdp.send.bind(s.cdp);
|
|
76
|
+
s.cdp.send = (async (method: string) => {
|
|
77
|
+
const result = await send(method as any);
|
|
78
|
+
// The same URL and ID now refer to a different renderer's script.
|
|
79
|
+
if (method === phase) s.parsed(undefined, "globalThis.replacement = true;");
|
|
80
|
+
return result;
|
|
81
|
+
}) as CDPSession["send"];
|
|
82
|
+
await expect(harvestBrowserCoverage(s.cdp)).rejects.toThrow("identity changed during coverage capture");
|
|
83
|
+
expect(takeBrowserCoverageReport(service)).toBe("TN:\n");
|
|
84
|
+
await expect(harvestBrowserCoverage(s.cdp)).rejects.toThrow("coverage is incomplete");
|
|
85
|
+
} finally { configureBrowserCoverage(null); takeBrowserCoverageReport(service); }
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { chromium } from "playwright-core";
|
|
4
|
+
import {
|
|
5
|
+
attachBrowserCoverage, configureBrowserCoverage, harvestBrowserCoverage,
|
|
6
|
+
takeBrowserCoverageReport,
|
|
7
|
+
} from "./browser-coverage.js";
|
|
8
|
+
|
|
9
|
+
const executablePath = Bun.which("chromium") ?? Bun.which("chromium-browser") ??
|
|
10
|
+
Bun.which("google-chrome") ?? chromium.executablePath();
|
|
11
|
+
|
|
12
|
+
test.skipIf(!existsSync(executablePath))("cross-site script ID reuse cannot attribute anonymous code to an earlier empty script", async () => {
|
|
13
|
+
const emptyScript = "/ingest/static/1.438.19/exception-autocapture.js";
|
|
14
|
+
const map = Buffer.from(JSON.stringify({ version: 3, sources: ["src/customer.ts"], names: [], mappings: "AAAA" })).toString("base64");
|
|
15
|
+
const server = Bun.serve({ hostname: "0.0.0.0", port: 0, fetch(request) {
|
|
16
|
+
const url = new URL(request.url);
|
|
17
|
+
if (url.pathname === emptyScript) return new Response("", { headers: { "content-type": "application/javascript" } });
|
|
18
|
+
if (url.pathname === "/app.js") return new Response(`globalThis.appRan = true;\n//# sourceMappingURL=data:application/json;base64,${map}`, { headers: { "content-type": "application/javascript" } });
|
|
19
|
+
return new Response(url.hostname === "127.0.0.1"
|
|
20
|
+
? `<script src="${emptyScript}"></script><script src="/app.js"></script><p>Application</p>`
|
|
21
|
+
: "<p>External login</p>", { headers: { "content-type": "text/html", "cache-control": "no-store" } });
|
|
22
|
+
} });
|
|
23
|
+
const browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox", "--site-per-process"] });
|
|
24
|
+
try {
|
|
25
|
+
const page = await browser.newPage();
|
|
26
|
+
const cdp = await page.context().newCDPSession(page);
|
|
27
|
+
const urls = new Map<string, string>();
|
|
28
|
+
let anonymousReuse = false;
|
|
29
|
+
cdp.on("Debugger.scriptParsed", event => {
|
|
30
|
+
if (urls.get(event.scriptId)?.endsWith(emptyScript) && !event.url) anonymousReuse = true;
|
|
31
|
+
urls.set(event.scriptId, event.url);
|
|
32
|
+
});
|
|
33
|
+
configureBrowserCoverage({ serviceForHost: host => host === "127.0.0.1" ? "navigation-test" : undefined });
|
|
34
|
+
await attachBrowserCoverage(cdp);
|
|
35
|
+
// Use different sites to force a renderer swap, then return to the app.
|
|
36
|
+
// Keep collected application counts across navigation until service capture.
|
|
37
|
+
for (const host of ["127.0.0.1", "127.0.0.2", "127.0.0.1", "127.0.0.2"]) {
|
|
38
|
+
await page.goto(`http://${host}:${server.port}/`);
|
|
39
|
+
await page.evaluate("(() => { const value = 1; return value + 1; })()");
|
|
40
|
+
await harvestBrowserCoverage(cdp);
|
|
41
|
+
}
|
|
42
|
+
expect(anonymousReuse).toBe(true); // Prove the regression's trigger happened.
|
|
43
|
+
const report = takeBrowserCoverageReport("navigation-test");
|
|
44
|
+
expect(report).toContain("SF:src/customer.ts\nDA:1,2\n");
|
|
45
|
+
expect(report).not.toContain(emptyScript);
|
|
46
|
+
expect(report.match(/^SF:/gm)).toHaveLength(1);
|
|
47
|
+
await cdp.detach();
|
|
48
|
+
} finally {
|
|
49
|
+
configureBrowserCoverage(null);
|
|
50
|
+
takeBrowserCoverageReport("navigation-test");
|
|
51
|
+
await browser.close();
|
|
52
|
+
server.stop(true);
|
|
53
|
+
}
|
|
54
|
+
}, 15000);
|
package/src/browser-coverage.ts
CHANGED
|
@@ -87,7 +87,9 @@ export async function attachBrowserCoverage(cdp: CDPSession): Promise<void> {
|
|
|
87
87
|
COLLECTORS.set(cdp, collector);
|
|
88
88
|
LIVE_COLLECTORS.add(collector);
|
|
89
89
|
cdp.on("Debugger.scriptParsed", (ev: { scriptId: string; url: string; sourceMapURL?: string }) => {
|
|
90
|
-
|
|
90
|
+
// IDs belong to a renderer, not the lifetime of this CDP session. A
|
|
91
|
+
// cross-site navigation can reuse an old named ID for anonymous code.
|
|
92
|
+
// Replace anonymous entries too, invalidating the previous URL/source/map.
|
|
91
93
|
collector.scripts.set(ev.scriptId, {
|
|
92
94
|
url: ev.url,
|
|
93
95
|
sourceMapURL: ev.sourceMapURL || undefined,
|
|
@@ -141,6 +143,7 @@ export async function harvestBrowserCoverage(cdp: CDPSession, signal?: AbortSign
|
|
|
141
143
|
}
|
|
142
144
|
|
|
143
145
|
async function harvestInner(c: Collector, config: BrowserCoverageConfig, signal: AbortSignal): Promise<void> {
|
|
146
|
+
const scriptsAtTake = new Map(c.scripts);
|
|
144
147
|
let result: { scriptId: string; url: string; functions: V8FunctionCoverage[] }[];
|
|
145
148
|
try {
|
|
146
149
|
({ result } = (await c.cdp.send("Profiler.takePreciseCoverage")) as { result: typeof result });
|
|
@@ -150,29 +153,42 @@ async function harvestInner(c: Collector, config: BrowserCoverageConfig, signal:
|
|
|
150
153
|
throw err;
|
|
151
154
|
}
|
|
152
155
|
for (const script of result) {
|
|
153
|
-
|
|
154
|
-
|
|
156
|
+
// The profiler's URL identifies this execution. Never supply a URL from
|
|
157
|
+
// an earlier renderer to an anonymous result.
|
|
158
|
+
if (!script.url) continue;
|
|
155
159
|
let url: URL;
|
|
156
160
|
try {
|
|
157
|
-
url = new URL(
|
|
161
|
+
url = new URL(script.url);
|
|
158
162
|
} catch {
|
|
159
163
|
continue;
|
|
160
164
|
}
|
|
161
165
|
if (url.protocol !== "http:" && url.protocol !== "https:") continue;
|
|
162
166
|
const service = config.serviceForHost(url.hostname);
|
|
163
167
|
if (!service) continue;
|
|
168
|
+
let info = c.scripts.get(script.scriptId);
|
|
169
|
+
const before = scriptsAtTake.get(script.scriptId);
|
|
170
|
+
const changed = () => {
|
|
171
|
+
c.incomplete = new Error("Browser script identity changed during coverage capture; coverage is incomplete");
|
|
172
|
+
return c.incomplete;
|
|
173
|
+
};
|
|
174
|
+
if ((before && before !== info) || (info && info.url !== script.url)) throw changed();
|
|
175
|
+
if (!info) {
|
|
176
|
+
info = { url: script.url };
|
|
177
|
+
c.scripts.set(script.scriptId, info);
|
|
178
|
+
}
|
|
164
179
|
if (info.source === undefined) {
|
|
165
180
|
try {
|
|
166
181
|
const r = (await c.cdp.send("Debugger.getScriptSource", {
|
|
167
182
|
scriptId: script.scriptId,
|
|
168
183
|
})) as { scriptSource: string };
|
|
169
184
|
signal.throwIfAborted();
|
|
185
|
+
if (c.scripts.get(script.scriptId) !== info) throw changed();
|
|
170
186
|
info.source = r.scriptSource;
|
|
171
187
|
} catch {
|
|
172
188
|
signal.throwIfAborted();
|
|
189
|
+
if (c.incomplete) throw c.incomplete;
|
|
173
190
|
continue; // script gone with its document
|
|
174
191
|
}
|
|
175
|
-
c.scripts.set(script.scriptId, info);
|
|
176
192
|
}
|
|
177
193
|
const generated = rangesToLineCounts(info.source, script.functions);
|
|
178
194
|
if (generated.size === 0) continue;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { chromium } from "playwright-core";
|
|
3
|
+
import { awaitBrowserSurface, captureBrowserScreenshot, reduceBrowserSurfaces } from "./browser-surface.js";
|
|
4
|
+
|
|
5
|
+
const executablePath = Bun.which("chromium") ?? Bun.which("chromium-browser") ?? undefined;
|
|
6
|
+
for (const mobile of [false, true]) {
|
|
7
|
+
test.skipIf(!executablePath)(`reduced surface preserves ${mobile ? "mobile" : "desktop"} behavior and screenshot pixels`, async () => {
|
|
8
|
+
const browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
|
|
9
|
+
const width = mobile ? 393 : 1280, height = mobile ? 852 : 720, dpr = mobile ? 3 : 1;
|
|
10
|
+
try {
|
|
11
|
+
const context = await browser.newContext({ viewport: { width, height }, isMobile: mobile, hasTouch: mobile, deviceScaleFactor: dpr });
|
|
12
|
+
reduceBrowserSurfaces(context, { mobile, deviceScaleFactor: dpr });
|
|
13
|
+
const page = await context.newPage();
|
|
14
|
+
await awaitBrowserSurface(page);
|
|
15
|
+
const cdp = await context.newCDPSession(page);
|
|
16
|
+
const html = `<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
17
|
+
<style>body{margin:0;background:white}button{position:absolute;right:10px;top:600px;width:100px;height:50px;background:red;border:0;color:red}#below{position:absolute;top:1600px}iframe{width:200px;height:60px}</style>
|
|
18
|
+
<button onclick="window.clicks++">Click</button><div id="below">Below</div>
|
|
19
|
+
<iframe srcdoc="<button onclick='this.textContent=123'>Frame</button>"></iframe>
|
|
20
|
+
<script>window.clicks=0;window.raf=0;window.resizes=0;window.visible=false;
|
|
21
|
+
onresize=()=>window.resizes++;new IntersectionObserver(e=>window.visible=e[0].isIntersecting).observe(document.querySelector('button'));
|
|
22
|
+
function tick(){window.raf++;requestAnimationFrame(tick)}requestAnimationFrame(tick)</script>`;
|
|
23
|
+
await page.goto(`data:text/html,${encodeURIComponent(html)}`);
|
|
24
|
+
await page.waitForFunction(() => (window as any).visible && (window as any).raf > 2);
|
|
25
|
+
await page.getByRole("button", {name:"Click",exact:true}).click();
|
|
26
|
+
await page.frameLocator("iframe").getByRole("button").click();
|
|
27
|
+
expect(await page.frameLocator("iframe").getByRole("button").textContent()).toBe("123");
|
|
28
|
+
const state = () => page.evaluate(() => ({ inner:[innerWidth,innerHeight], visual:[visualViewport!.width,visualViewport!.height], screen:[screen.width,screen.height], dpr:devicePixelRatio, resizes:(window as any).resizes }));
|
|
29
|
+
// Script-visible rendering APIs and animation completion still progress.
|
|
30
|
+
expect(await page.evaluate(async () => {
|
|
31
|
+
const canvas = document.createElement("canvas");
|
|
32
|
+
canvas.width = canvas.height = 2;
|
|
33
|
+
const ctx = canvas.getContext("2d")!;
|
|
34
|
+
ctx.fillStyle = "#00ff00";ctx.fillRect(0,0,2,2);
|
|
35
|
+
const button = document.querySelector("button")!;
|
|
36
|
+
await button.animate([{transform:"translateX(0px)"},{transform:"translateX(-1px)"}],{duration:40}).finished;
|
|
37
|
+
return [...ctx.getImageData(0,0,1,1).data];
|
|
38
|
+
})).toEqual([0,255,0,255]);
|
|
39
|
+
const before = await state();
|
|
40
|
+
expect(before).toMatchObject({inner:[width,height],visual:[width,height],screen:[width,height],dpr});
|
|
41
|
+
const captures = await Promise.all([captureBrowserScreenshot(page, cdp), captureBrowserScreenshot(page, cdp)]);
|
|
42
|
+
for (const capture of captures) {
|
|
43
|
+
const png = Buffer.from(capture.data, "base64");
|
|
44
|
+
expect([png.readUInt32BE(16),png.readUInt32BE(20)]).toEqual([width*dpr,height*dpr]);
|
|
45
|
+
const pixel = await page.evaluate(async ({data,x,y}) => {
|
|
46
|
+
const img = new Image();img.src=`data:image/png;base64,${data}`;await img.decode();
|
|
47
|
+
const canvas=document.createElement('canvas');canvas.width=img.width;canvas.height=img.height;
|
|
48
|
+
const ctx=canvas.getContext('2d')!;ctx.drawImage(img,0,0);return [...ctx.getImageData(x,y,1,1).data];
|
|
49
|
+
},{data:capture.data,x:(width-60)*dpr,y:620*dpr});
|
|
50
|
+
expect(pixel).toEqual([255,0,0,255]);
|
|
51
|
+
}
|
|
52
|
+
expect(await state()).toEqual(before);
|
|
53
|
+
await page.locator("#below").scrollIntoViewIfNeeded();
|
|
54
|
+
await page.getByRole("button",{name:"Click",exact:true}).click();
|
|
55
|
+
expect(await page.evaluate(() => (window as any).clicks)).toBe(2);
|
|
56
|
+
if (mobile) {
|
|
57
|
+
const box = await page.getByRole("button",{name:"Click",exact:true}).boundingBox();
|
|
58
|
+
await page.touchscreen.tap(box!.x+box!.width/2,box!.y+box!.height/2);
|
|
59
|
+
expect(await page.evaluate(() => (window as any).clicks)).toBe(3);
|
|
60
|
+
}
|
|
61
|
+
// A newly opened tab is configured too, without changing its layout.
|
|
62
|
+
const popup = await context.newPage();await awaitBrowserSurface(popup);
|
|
63
|
+
await popup.goto(`data:text/html,${encodeURIComponent(html)}`);
|
|
64
|
+
await popup.getByRole("button",{name:"Click",exact:true}).click();
|
|
65
|
+
expect(await popup.evaluate(() => [innerWidth,innerHeight,(window as any).clicks])).toEqual([width,height,1]);
|
|
66
|
+
} finally { await browser.close(); }
|
|
67
|
+
},30000);
|
|
68
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { BrowserContext, CDPSession, Page } from "playwright-core";
|
|
2
|
+
|
|
3
|
+
// Layout, input coordinates and the visual viewport retain their real size.
|
|
4
|
+
// Only the compositor's output is clipped. Native frame scheduling (including
|
|
5
|
+
// rAF on otherwise quiet pages) remains Chromium's responsibility.
|
|
6
|
+
const SURFACES = new WeakMap<Page, Promise<Surface>>();
|
|
7
|
+
|
|
8
|
+
class Surface {
|
|
9
|
+
private tail: Promise<unknown> = Promise.resolve();
|
|
10
|
+
constructor(private page: Page, private cdp: CDPSession,
|
|
11
|
+
private options: { mobile: boolean; deviceScaleFactor: number }) {}
|
|
12
|
+
|
|
13
|
+
async apply(reduced: boolean) {
|
|
14
|
+
const viewport = this.page.viewportSize();
|
|
15
|
+
if (!viewport) throw new Error("Browser surface requires a fixed viewport");
|
|
16
|
+
const landscape = viewport.width > viewport.height;
|
|
17
|
+
await this.cdp.send("Emulation.setDeviceMetricsOverride", {
|
|
18
|
+
...viewport,
|
|
19
|
+
...this.options,
|
|
20
|
+
screenWidth: viewport.width,
|
|
21
|
+
screenHeight: viewport.height,
|
|
22
|
+
screenOrientation: this.options.mobile
|
|
23
|
+
? { angle: landscape ? 90 : 0, type: landscape ? "landscapePrimary" : "portraitPrimary" }
|
|
24
|
+
: { angle: 0, type: "landscapePrimary" },
|
|
25
|
+
...(reduced ? { viewport: { x: 0, y: 0, width: 1, height: 1, scale: 1 } } : {}),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
screenshot(): Promise<{ data: string }> {
|
|
30
|
+
// Concurrent screenshots must not shrink another capture's surface.
|
|
31
|
+
const result = this.tail.then(async () => {
|
|
32
|
+
try {
|
|
33
|
+
await this.apply(false);
|
|
34
|
+
// Capture through the same emulation agent: a different CDP session
|
|
35
|
+
// can read back mobile screenshots at the wrong device scale.
|
|
36
|
+
return await this.cdp.send("Page.captureScreenshot", { format: "png" });
|
|
37
|
+
} finally {
|
|
38
|
+
if (!this.page.isClosed()) await this.apply(true);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
this.tail = result.catch(() => {});
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function reduceBrowserSurfaces(context: BrowserContext,
|
|
47
|
+
options: { mobile: boolean; deviceScaleFactor: number }) {
|
|
48
|
+
context.on("page", page => {
|
|
49
|
+
const pending = (async () => {
|
|
50
|
+
const surface = new Surface(page, await context.newCDPSession(page), options);
|
|
51
|
+
await surface.apply(true);
|
|
52
|
+
return surface;
|
|
53
|
+
})();
|
|
54
|
+
SURFACES.set(page, pending);
|
|
55
|
+
// spawnPage awaits this for SDK views. Popups have no spawnPage caller.
|
|
56
|
+
void pending.catch(error => {
|
|
57
|
+
if (!page.isClosed()) console.warn("[spectest] failed to reduce browser surface:", error);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function awaitBrowserSurface(page: Page): Promise<void> {
|
|
63
|
+
await SURFACES.get(page);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function captureBrowserScreenshot(page: Page, cdp: CDPSession): Promise<{ data: string }> {
|
|
67
|
+
const surface = await SURFACES.get(page);
|
|
68
|
+
// Externally attached browsers (e.g. Electron) retain their own viewport.
|
|
69
|
+
return surface ? surface.screenshot() : cdp.send("Page.captureScreenshot", { format: "png" });
|
|
70
|
+
}
|
package/src/browser.ts
CHANGED
|
@@ -67,6 +67,7 @@ import type {
|
|
|
67
67
|
} from "./locator.js";
|
|
68
68
|
|
|
69
69
|
import { chromium } from "playwright-core";
|
|
70
|
+
import { reduceBrowserSurfaces, awaitBrowserSurface, captureBrowserScreenshot } from "./browser-surface.js";
|
|
70
71
|
import type {
|
|
71
72
|
Browser as PlaywrightBrowser,
|
|
72
73
|
BrowserContext,
|
|
@@ -543,6 +544,7 @@ async function newViewContext(
|
|
|
543
544
|
timezoneId: opts.timezone,
|
|
544
545
|
locale: opts.locale,
|
|
545
546
|
});
|
|
547
|
+
reduceBrowserSurfaces(context, { mobile: !!device, deviceScaleFactor: device?.deviceScaleFactor ?? 1 });
|
|
546
548
|
context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
|
|
547
549
|
context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
|
|
548
550
|
if (fixture) {
|
|
@@ -1288,6 +1290,7 @@ const VIEW_POOL: PooledView[] = [];
|
|
|
1288
1290
|
* slow part (renderer process spawn). */
|
|
1289
1291
|
async function spawnPage(context: BrowserContext): Promise<SpawnedPage> {
|
|
1290
1292
|
const page = await context.newPage();
|
|
1293
|
+
await awaitBrowserSurface(page);
|
|
1291
1294
|
const cdp = await context.newCDPSession(page);
|
|
1292
1295
|
// Without Page.enable the addScriptToEvaluateOnNewDocument registration
|
|
1293
1296
|
// silently never fires on this session (verified in the fork spike).
|
|
@@ -2199,9 +2202,7 @@ function buildBackend(
|
|
|
2199
2202
|
}
|
|
2200
2203
|
// Raw CDP rather than page.screenshot(): captures the viewport
|
|
2201
2204
|
// exactly like the pre-Playwright backend.
|
|
2202
|
-
const res =
|
|
2203
|
-
format: "png",
|
|
2204
|
-
} as never)) as { data: string };
|
|
2205
|
+
const res = await captureBrowserScreenshot(holder.page, holder.cdp);
|
|
2205
2206
|
const id = generateId("art");
|
|
2206
2207
|
register({
|
|
2207
2208
|
id,
|
package/src/daemon.ts
CHANGED
|
@@ -109,6 +109,7 @@ import {
|
|
|
109
109
|
type CaseMeta,
|
|
110
110
|
type GroupMeta,
|
|
111
111
|
} from "./harness/catalogue.js";
|
|
112
|
+
import { shadowBuilder, shadowImage, shadowOutput, imageDigest, shadowTiming } from "./harness/shadow-builder.js";
|
|
112
113
|
import { summarizeBuildKit, type BuildStep } from "./harness/buildkit-progress.js";
|
|
113
114
|
import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta, mergeServiceLogDeltas } from "./harness/log-delta.js";
|
|
114
115
|
import {
|
|
@@ -1364,18 +1365,27 @@ async function runServiceBuild(
|
|
|
1364
1365
|
// own layer cache, and the finished image is already in the store
|
|
1365
1366
|
// dockerd reads. dockerd's built-in BuildKit is the fallback (a guest
|
|
1366
1367
|
// with no store, or a daemon that would not start).
|
|
1367
|
-
const
|
|
1368
|
+
const remote = await shadowBuilder();
|
|
1369
|
+
const useLocal = !remote && await ensureLocalBuildkitd();
|
|
1368
1370
|
// Both the in-VM daemon and dockerd's buildx are BuildKit, so both
|
|
1369
1371
|
// emit per-step timing on stderr under `--progress=plain` (parsed
|
|
1370
1372
|
// below). Only the legacy builder takes no progress flag.
|
|
1371
|
-
const useBuildKit = useLocal || (await hasBuildx());
|
|
1373
|
+
const useBuildKit = !!remote || useLocal || (await hasBuildx());
|
|
1372
1374
|
const buildEnv: Record<string, string> = {};
|
|
1373
1375
|
let buildArgs: string[];
|
|
1374
1376
|
// The user's `buildArgs`, as `--build-arg` flags; a plain client flag,
|
|
1375
1377
|
// so every builder — in-VM BuildKit, dockerd's own, legacy — takes it.
|
|
1376
1378
|
const argFlags = [...buildTargetFlags(image.target), ...buildArgFlags(image.buildArgs)];
|
|
1377
1379
|
const contextDir = image.contextDir;
|
|
1378
|
-
|
|
1380
|
+
const remoteReference = remote ? shadowImage(remote, name) : undefined;
|
|
1381
|
+
const metadataPath = path.join(dfDir, "shadow-result.json");
|
|
1382
|
+
const buildStarted = Date.now();
|
|
1383
|
+
if (remote) {
|
|
1384
|
+
buildArgs = ["buildx", "build", "--builder", remote.builder,
|
|
1385
|
+
"--progress=plain", "--metadata-file", metadataPath,
|
|
1386
|
+
"--output", shadowOutput(remoteReference!), ...argFlags,
|
|
1387
|
+
"-f", dfPath, contextDir];
|
|
1388
|
+
} else if (useLocal) {
|
|
1379
1389
|
// The in-VM builder is BuildKit's containerd worker on this VM's
|
|
1380
1390
|
// own image store (CONTAINER_STORE.md): the output is an image
|
|
1381
1391
|
// record in dockerd's namespace, unpacked, so there is no `--load`
|
|
@@ -1452,9 +1462,20 @@ async function runServiceBuild(
|
|
|
1452
1462
|
}
|
|
1453
1463
|
});
|
|
1454
1464
|
const log = `${build.stderr.trim()}\n${build.stdout.trim()}`;
|
|
1465
|
+
if (remote) await shadowTiming(name, "solve-export-push", buildStarted, { ok: build.code === 0 });
|
|
1455
1466
|
if (build.code !== 0) {
|
|
1456
1467
|
return { ok: false, log };
|
|
1457
1468
|
}
|
|
1469
|
+
if (remote) {
|
|
1470
|
+
const digest = imageDigest(JSON.parse(await fs.readFile(metadataPath, "utf8")));
|
|
1471
|
+
const immutable = `${remoteReference!.split(":result")[0]}@${digest}`;
|
|
1472
|
+
const pullStarted = Date.now();
|
|
1473
|
+
const pulled = await shxStream("docker", ["pull", immutable], undefined, undefined, () => {});
|
|
1474
|
+
await shadowTiming(name, "pull-unpack", pullStarted, { digest, ok: pulled.code === 0, lazy: false });
|
|
1475
|
+
if (pulled.code !== 0) return { ok: false, log: pulled.stderr };
|
|
1476
|
+
const tagged = await docker(["tag", immutable, tag]);
|
|
1477
|
+
if (tagged.code !== 0) return { ok: false, log: tagged.stderr };
|
|
1478
|
+
}
|
|
1458
1479
|
if (useBuildKit) {
|
|
1459
1480
|
// Keep only the slowest dozen steps ≥1s — enough to profile, small
|
|
1460
1481
|
// enough to ride back in the /bootstrap response and the journal.
|
|
@@ -6101,7 +6122,7 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
6101
6122
|
return {
|
|
6102
6123
|
health: async () => ({
|
|
6103
6124
|
ok: true,
|
|
6104
|
-
capabilities: ["prepared-images-v1"],
|
|
6125
|
+
capabilities: ["prepared-images-v1", "shadow-builder-v1"],
|
|
6105
6126
|
// Optional discovery: old SDKs omit this field and are never sent the
|
|
6106
6127
|
// new method. Check the loaded project, including after warm restore.
|
|
6107
6128
|
setupInteractive: typeof loaded?.project.interactive?.seed === "function",
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { imageDigest, shadowBuilder, shadowImage, shadowOutput, validateShadowBuilder } from "./shadow-builder";
|
|
3
|
+
const config = { version: 1, builder: "shadow-test", registry: "registry.example.com", experiment: "experiment-1" } as const;
|
|
4
|
+
test("shadow image namespaces isolate experiments and hash untrusted service names", () => {
|
|
5
|
+
const a = shadowImage(config, "../../other,compression=gzip");
|
|
6
|
+
expect(a).toMatch(/^registry.example.com\/shadow\/experiment-1\/[a-f0-9]{32}:result$/);
|
|
7
|
+
expect(shadowImage({ ...config, experiment: "experiment-2" }, "api")).not.toBe(shadowImage(config, "api"));
|
|
8
|
+
expect(shadowOutput(a)).toContain("compression=zstd");
|
|
9
|
+
});
|
|
10
|
+
test("configuration rejects exporter and endpoint injection", () => {
|
|
11
|
+
expect(validateShadowBuilder(config)).toEqual(config);
|
|
12
|
+
for (const registry of ["https://example.com", "example.com,push=false", "user:secret@example.com", "example.com/path"]) {
|
|
13
|
+
expect(() => validateShadowBuilder({ ...config, registry })).toThrow();
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
test("delivery requires a content digest, never a mutable tag", () => {
|
|
17
|
+
expect(imageDigest({ "containerimage.digest": `sha256:${"a".repeat(64)}` })).toBe(`sha256:${"a".repeat(64)}`);
|
|
18
|
+
for (const value of [null, {}, { "containerimage.digest": "latest" }]) expect(() => imageDigest(value)).toThrow();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("ordinary SDK execution never needs remote builder configuration", async () => {
|
|
22
|
+
const previous = process.env.SPECTEST_SHADOW_BUILD;
|
|
23
|
+
delete process.env.SPECTEST_SHADOW_BUILD;
|
|
24
|
+
try { expect(await shadowBuilder()).toBeUndefined(); }
|
|
25
|
+
finally {
|
|
26
|
+
if (previous === undefined) delete process.env.SPECTEST_SHADOW_BUILD;
|
|
27
|
+
else process.env.SPECTEST_SHADOW_BUILD = previous;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Internal experiment configuration. Never selected by project configuration. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
|
|
5
|
+
export interface ShadowBuilder {
|
|
6
|
+
version: 1;
|
|
7
|
+
builder: string;
|
|
8
|
+
registry: string;
|
|
9
|
+
experiment: string;
|
|
10
|
+
}
|
|
11
|
+
export function validateShadowBuilder(value: unknown): ShadowBuilder {
|
|
12
|
+
const c = value as ShadowBuilder;
|
|
13
|
+
if (!c || c.version !== 1 || !/^shadow-[a-z0-9-]+$/.test(c.builder)
|
|
14
|
+
|| !/^[a-z0-9.-]+(?::[0-9]+)?$/.test(c.registry)
|
|
15
|
+
|| !/^[a-z0-9-]{1,80}$/.test(c.experiment)) throw new Error("invalid shadow builder configuration");
|
|
16
|
+
return c;
|
|
17
|
+
}
|
|
18
|
+
export async function shadowBuilder(): Promise<ShadowBuilder | undefined> {
|
|
19
|
+
if (process.env.SPECTEST_SHADOW_BUILD !== "1") return undefined;
|
|
20
|
+
// A missing configuration is an experiment failure, never a local build.
|
|
21
|
+
return validateShadowBuilder(JSON.parse(await fs.readFile("/run/spectest/shadow-builder.json", "utf8")));
|
|
22
|
+
}
|
|
23
|
+
export function shadowImage(c: ShadowBuilder, service: string): string {
|
|
24
|
+
const id = createHash("sha256").update(service).digest("hex").slice(0, 32);
|
|
25
|
+
return `${c.registry}/shadow/${c.experiment}/${id}:result`;
|
|
26
|
+
}
|
|
27
|
+
export function shadowOutput(reference: string): string {
|
|
28
|
+
return `type=image,name=${reference},push=true,oci-mediatypes=true,compression=zstd`;
|
|
29
|
+
}
|
|
30
|
+
export function imageDigest(metadata: unknown): string {
|
|
31
|
+
const digest = (metadata as Record<string, unknown>)?.["containerimage.digest"];
|
|
32
|
+
if (typeof digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(digest)) {
|
|
33
|
+
throw new Error("remote builder did not return an image digest");
|
|
34
|
+
}
|
|
35
|
+
return digest;
|
|
36
|
+
}
|
|
37
|
+
export async function shadowTiming(service: string, phase: string, started: number, extra = {}) {
|
|
38
|
+
await fs.appendFile("/run/spectest/shadow-timings.jsonl", JSON.stringify({
|
|
39
|
+
service, phase, startedMs: started, durationMs: Date.now() - started, ...extra,
|
|
40
|
+
}) + "\n");
|
|
41
|
+
}
|