@specific.dev/spectest 0.131.17 → 0.131.19

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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  // Headless browser handle for tests. Thin wrapper around playwright-core
2
- // driving the guest's system Chromium (pipe transport — `chromium.launch`
2
+ // driving the guest's Headless Shell (pipe transport — `chromium.launch`
3
3
  // with an explicit `executablePath`). Electron attaches to the app-owned
4
4
  // Chromium over CDP instead; its client also lives inside the VM. The wrapper
5
5
  // keeps the public surface stable (we swapped from Bun.WebView to Playwright without
@@ -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;
@@ -108,7 +109,7 @@ const NAVIGATION_TIMEOUT_MS = 30_000;
108
109
  // ────────────────────────────────────────────────────────────────────────
109
110
  // Shared Chromium (playwright-core)
110
111
  // ────────────────────────────────────────────────────────────────────────
111
- // Default sessions share one Chromium per daemon, launched lazily and kept
112
+ // Browser and mobile sessions share one Headless Shell per daemon, launched lazily and kept
112
113
  // for the daemon's lifetime. Fixture sessions own a process because the audio
113
114
  // source is a process-wide flag. Both ride snapshots: the process, playwright's
114
115
  // pipe connection to it, and every open page freeze into the VM snapshot
@@ -125,17 +126,18 @@ const VIEW_CLEANUPS = new WeakMap();
125
126
  // (mirroring the old one-Chrome-profile model), which left `close()` closing
126
127
  // the page only — cookies survived and the next session was still
127
128
  // authenticated.
128
- function chromiumPath() {
129
- return (Bun.which("chromium") ??
130
- Bun.which("chromium-browser") ??
131
- Bun.which("google-chrome") ??
132
- "/usr/bin/chromium");
129
+ function browserExecutable() {
130
+ const executable = Bun.which("chromium-headless-shell") ?? Bun.which("chrome-headless-shell");
131
+ if (!executable) {
132
+ throw new Error("Chromium Headless Shell is required by this SDK but is missing from the runtime image. Rebuild the environment with a current Spectest golden image; for local development, install chromium-headless-shell on PATH.");
133
+ }
134
+ return executable;
133
135
  }
134
136
  async function ensurePlaywrightBrowser() {
135
137
  if (PW_BROWSER?.isConnected())
136
138
  return PW_BROWSER;
137
139
  PW_BROWSER = await chromium.launch({
138
- executablePath: chromiumPath(),
140
+ executablePath: browserExecutable(),
139
141
  headless: true,
140
142
  args: CHROME_ARGV,
141
143
  });
@@ -166,7 +168,7 @@ async function newViewContext(width, height, device, opts = {}) {
166
168
  let browser;
167
169
  try {
168
170
  browser = fixture ? await chromium.launch({
169
- executablePath: chromiumPath(), headless: true, args: [...CHROME_ARGV, fixture.arg],
171
+ executablePath: browserExecutable(), headless: true, args: [...CHROME_ARGV, fixture.arg],
170
172
  }) : await ensurePlaywrightBrowser();
171
173
  const context = await browser.newContext({
172
174
  ...(device
@@ -179,6 +181,7 @@ async function newViewContext(width, height, device, opts = {}) {
179
181
  timezoneId: opts.timezone,
180
182
  locale: opts.locale,
181
183
  });
184
+ reduceBrowserSurfaces(context, { mobile: !!device, deviceScaleFactor: device?.deviceScaleFactor ?? 1 });
182
185
  context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
183
186
  context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
184
187
  if (fixture) {
@@ -847,6 +850,7 @@ const VIEW_POOL = [];
847
850
  * slow part (renderer process spawn). */
848
851
  async function spawnPage(context) {
849
852
  const page = await context.newPage();
853
+ await awaitBrowserSurface(page);
850
854
  const cdp = await context.newCDPSession(page);
851
855
  // Without Page.enable the addScriptToEvaluateOnNewDocument registration
852
856
  // silently never fires on this session (verified in the fork spike).
@@ -1651,9 +1655,7 @@ function buildBackend(holder, recorder, buildOpts) {
1651
1655
  }
1652
1656
  // Raw CDP rather than page.screenshot(): captures the viewport
1653
1657
  // exactly like the pre-Playwright backend.
1654
- const res = (await holder.cdp.send("Page.captureScreenshot", {
1655
- format: "png",
1656
- }));
1658
+ const res = await captureBrowserScreenshot(holder.page, holder.cdp);
1657
1659
  const id = generateId("art");
1658
1660
  register({
1659
1661
  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 useLocal = await ensureLocalBuildkitd();
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
- if (useLocal) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.131.17",
3
+ "version": "0.131.19",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,21 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ test("SDK refuses a Chromium-only runtime instead of silently falling back", async () => {
7
+ const dir = await mkdtemp(join(tmpdir(), "spectest-no-shell-"));
8
+ try {
9
+ await writeFile(join(dir, "chromium"), "#!/bin/sh\necho UNEXPECTED_CHROMIUM_LAUNCH >&2\nexit 1\n", { mode: 0o755 });
10
+ const browserModule = new URL("./browser.ts", import.meta.url).href;
11
+ const child = Bun.spawn([process.execPath, "-e", `
12
+ import { openBrowser } from ${JSON.stringify(browserModule)};
13
+ try { await openBrowser(); process.exit(2); }
14
+ catch (error) { console.error(error.message); process.exit(1); }
15
+ `], { env: { ...process.env, PATH: dir }, stdout: "pipe", stderr: "pipe" });
16
+ const stderr = await new Response(child.stderr).text();
17
+ expect(await child.exited).toBe(1);
18
+ expect(stderr).toContain("Headless Shell is required");
19
+ expect(stderr).not.toContain("UNEXPECTED_CHROMIUM_LAUNCH");
20
+ } finally { await rm(dir, { recursive: true, force: true }); }
21
+ });
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
2
2
  import { acquirePersistentBrowser } from "./browser.js";
3
3
  import { finalizeBrowserRecordings } from "./harness/deadline.js";
4
4
 
5
- const available = !!(Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome"));
5
+ const available = !!(Bun.which("chromium-headless-shell") ?? Bun.which("chrome-headless-shell"));
6
6
 
7
7
  test.skipIf(!available)("stuck replay drains are bounded warnings and late replies cannot change the recording or clear a later buffer", async () => {
8
8
  const chunks: unknown[] = [];
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
2
2
  import { openBrowser } from "./browser.js";
3
3
  import { startRecording, stopRecording } from "./recorder.js";
4
4
 
5
- const available = !!(Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome"));
5
+ const available = !!(Bun.which("chromium-headless-shell") ?? Bun.which("chrome-headless-shell"));
6
6
 
7
7
  test.skipIf(!available)("public navigation options control goto/reload and remain visible in recordings", async () => {
8
8
  // The document commits immediately but load waits for an image. This
@@ -0,0 +1,66 @@
1
+ import { expect, test } from "bun:test";
2
+ import { openBrowser, type BrowserSessionRecorder } from "./browser.js";
3
+ import { readRaw } from "./inspect.js";
4
+
5
+ const available =
6
+ Bun.which("chromium-headless-shell") ??
7
+ Bun.which("chrome-headless-shell");
8
+ for (const frame of ["browser", "mobile"] as const) {
9
+ test.skipIf(!available)(
10
+ `${frame} public screenshot exports full PNG pixels after DOM changes`,
11
+ async () => {
12
+ const artifacts: Parameters<
13
+ NonNullable<BrowserSessionRecorder["registerArtifact"]>
14
+ >[0][] = [];
15
+ const browser = await openBrowser({
16
+ frame,
17
+ recorder: {
18
+ sessionId: `screenshot-${frame}`,
19
+ recordStep() {},
20
+ registerArtifact: (artifact) => artifacts.push(artifact),
21
+ },
22
+ });
23
+ const width = frame === "mobile" ? 393 : 1280;
24
+ const height = frame === "mobile" ? 852 : 720;
25
+ const dpr = frame === "mobile" ? 3 : 1;
26
+ try {
27
+ await browser.goto(
28
+ `data:text/html,${encodeURIComponent('<meta name="viewport" content="width=device-width,initial-scale=1"><style>body{margin:0;background:rgb(255,0,0)}</style>')}`,
29
+ );
30
+ for (const color of ["rgb(255,0,0)", "rgb(0,255,0)"]) {
31
+ await browser.evaluate(
32
+ "change background",
33
+ `document.body.style.background = ${JSON.stringify(color)}`,
34
+ );
35
+ const id = await browser.screenshot();
36
+ const artifact = artifacts.at(-1)!;
37
+ expect(artifact.id).toBe(id);
38
+ expect(artifact.contentType).toBe("image/png");
39
+ const png = Buffer.from(artifact.bytesBase64, "base64");
40
+ expect(artifact.sizeBytes).toBe(png.length);
41
+ expect([png.readUInt32BE(16), png.readUInt32BE(20)]).toEqual([
42
+ width * dpr,
43
+ height * dpr,
44
+ ]);
45
+ const pixel = readRaw(
46
+ await browser.evaluate(
47
+ "read captured bottom-right pixel",
48
+ `(async function () {
49
+ const img = new Image(); img.src = 'data:image/png;base64,${artifact.bytesBase64}'; await img.decode();
50
+ const canvas = document.createElement('canvas'); canvas.width=img.width; canvas.height=img.height;
51
+ const ctx=canvas.getContext('2d');ctx.drawImage(img,0,0);
52
+ return [...ctx.getImageData(img.width-10,img.height-10,1,1).data];
53
+ })()`,
54
+ ),
55
+ );
56
+ expect(pixel).toEqual(
57
+ color.includes("255,0,0") ? [255, 0, 0, 255] : [0, 255, 0, 255],
58
+ );
59
+ }
60
+ } finally {
61
+ await browser.close();
62
+ }
63
+ },
64
+ 20000,
65
+ );
66
+ }
@@ -0,0 +1,71 @@
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 engines = {
6
+ chromium: Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome"),
7
+ shell: Bun.which("chromium-headless-shell") ?? Bun.which("chrome-headless-shell"),
8
+ };
9
+ for (const [engine, executablePath] of Object.entries(engines)) for (const mobile of [false, true]) {
10
+ test.skipIf(!executablePath)(`${engine}: reduced surface preserves ${mobile ? "mobile" : "desktop"} behavior and screenshot pixels`, async () => {
11
+ const browser = await chromium.launch({ executablePath: executablePath!, headless: true, args: ["--no-sandbox"] });
12
+ const width = mobile ? 393 : 1280, height = mobile ? 852 : 720, dpr = mobile ? 3 : 1;
13
+ try {
14
+ const context = await browser.newContext({ viewport: { width, height }, isMobile: mobile, hasTouch: mobile, deviceScaleFactor: dpr });
15
+ reduceBrowserSurfaces(context, { mobile, deviceScaleFactor: dpr });
16
+ const page = await context.newPage();
17
+ await awaitBrowserSurface(page);
18
+ const cdp = await context.newCDPSession(page);
19
+ const html = `<meta name="viewport" content="width=device-width,initial-scale=1">
20
+ <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>
21
+ <button onclick="window.clicks++">Click</button><div id="below">Below</div>
22
+ <iframe srcdoc="<button onclick='this.textContent=123'>Frame</button>"></iframe>
23
+ <script>window.clicks=0;window.raf=0;window.resizes=0;window.visible=false;
24
+ onresize=()=>window.resizes++;new IntersectionObserver(e=>window.visible=e[0].isIntersecting).observe(document.querySelector('button'));
25
+ function tick(){window.raf++;requestAnimationFrame(tick)}requestAnimationFrame(tick)</script>`;
26
+ await page.goto(`data:text/html,${encodeURIComponent(html)}`);
27
+ await page.waitForFunction(() => (window as any).visible && (window as any).raf > 2);
28
+ await page.getByRole("button", {name:"Click",exact:true}).click();
29
+ await page.frameLocator("iframe").getByRole("button").click();
30
+ expect(await page.frameLocator("iframe").getByRole("button").textContent()).toBe("123");
31
+ const state = () => page.evaluate(() => ({ inner:[innerWidth,innerHeight], visual:[visualViewport!.width,visualViewport!.height], screen:[screen.width,screen.height], dpr:devicePixelRatio, resizes:(window as any).resizes }));
32
+ // Script-visible rendering APIs and animation completion still progress.
33
+ expect(await page.evaluate(async () => {
34
+ const canvas = document.createElement("canvas");
35
+ canvas.width = canvas.height = 2;
36
+ const ctx = canvas.getContext("2d")!;
37
+ ctx.fillStyle = "#00ff00";ctx.fillRect(0,0,2,2);
38
+ const button = document.querySelector("button")!;
39
+ await button.animate([{transform:"translateX(0px)"},{transform:"translateX(-1px)"}],{duration:40}).finished;
40
+ return [...ctx.getImageData(0,0,1,1).data];
41
+ })).toEqual([0,255,0,255]);
42
+ const before = await state();
43
+ expect(before).toMatchObject({inner:[width,height],visual:[width,height],screen:[width,height],dpr});
44
+ const captures = await Promise.all([captureBrowserScreenshot(page, cdp), captureBrowserScreenshot(page, cdp)]);
45
+ for (const capture of captures) {
46
+ const png = Buffer.from(capture.data, "base64");
47
+ expect([png.readUInt32BE(16),png.readUInt32BE(20)]).toEqual([width*dpr,height*dpr]);
48
+ const pixel = await page.evaluate(async ({data,x,y}) => {
49
+ const img = new Image();img.src=`data:image/png;base64,${data}`;await img.decode();
50
+ const canvas=document.createElement('canvas');canvas.width=img.width;canvas.height=img.height;
51
+ const ctx=canvas.getContext('2d')!;ctx.drawImage(img,0,0);return [...ctx.getImageData(x,y,1,1).data];
52
+ },{data:capture.data,x:(width-60)*dpr,y:620*dpr});
53
+ expect(pixel).toEqual([255,0,0,255]);
54
+ }
55
+ expect(await state()).toEqual(before);
56
+ await page.locator("#below").scrollIntoViewIfNeeded();
57
+ await page.getByRole("button",{name:"Click",exact:true}).click();
58
+ expect(await page.evaluate(() => (window as any).clicks)).toBe(2);
59
+ if (mobile) {
60
+ const box = await page.getByRole("button",{name:"Click",exact:true}).boundingBox();
61
+ await page.touchscreen.tap(box!.x+box!.width/2,box!.y+box!.height/2);
62
+ expect(await page.evaluate(() => (window as any).clicks)).toBe(3);
63
+ }
64
+ // A newly opened tab is configured too, without changing its layout.
65
+ const popup = await context.newPage();await awaitBrowserSurface(popup);
66
+ await popup.goto(`data:text/html,${encodeURIComponent(html)}`);
67
+ await popup.getByRole("button",{name:"Click",exact:true}).click();
68
+ expect(await popup.evaluate(() => [innerWidth,innerHeight,(window as any).clicks])).toEqual([width,height,1]);
69
+ } finally { await browser.close(); }
70
+ },30000);
71
+ }
@@ -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
+ }
@@ -21,7 +21,7 @@ async function settings(browser: Browser): Promise<Settings> {
21
21
  return readRaw(await browser.evaluate<Settings>("read initial regional settings", "window.settings")) as unknown as Settings;
22
22
  }
23
23
 
24
- describe.skipIf(!Bun.which("chromium") && !Bun.which("chromium-browser") && !Bun.which("google-chrome"))("browser timezone and locale", () => {
24
+ describe.skipIf(!Bun.which("chromium-headless-shell") && !Bun.which("chrome-headless-shell"))("browser timezone and locale", () => {
25
25
  let server: ReturnType<typeof Bun.serve>;
26
26
  beforeAll(() => {
27
27
  server = Bun.serve({
package/src/browser.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // Headless browser handle for tests. Thin wrapper around playwright-core
2
- // driving the guest's system Chromium (pipe transport — `chromium.launch`
2
+ // driving the guest's Headless Shell (pipe transport — `chromium.launch`
3
3
  // with an explicit `executablePath`). Electron attaches to the app-owned
4
4
  // Chromium over CDP instead; its client also lives inside the VM. The wrapper
5
5
  // keeps the public surface stable (we swapped from Bun.WebView to Playwright without
@@ -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,
@@ -462,7 +463,7 @@ const NAVIGATION_TIMEOUT_MS = 30_000;
462
463
  // Shared Chromium (playwright-core)
463
464
  // ────────────────────────────────────────────────────────────────────────
464
465
 
465
- // Default sessions share one Chromium per daemon, launched lazily and kept
466
+ // Browser and mobile sessions share one Headless Shell per daemon, launched lazily and kept
466
467
  // for the daemon's lifetime. Fixture sessions own a process because the audio
467
468
  // source is a process-wide flag. Both ride snapshots: the process, playwright's
468
469
  // pipe connection to it, and every open page freeze into the VM snapshot
@@ -480,19 +481,18 @@ const VIEW_CLEANUPS = new WeakMap<BrowserContext, () => Promise<void>>();
480
481
  // the page only — cookies survived and the next session was still
481
482
  // authenticated.
482
483
 
483
- function chromiumPath(): string {
484
- return (
485
- Bun.which("chromium") ??
486
- Bun.which("chromium-browser") ??
487
- Bun.which("google-chrome") ??
488
- "/usr/bin/chromium"
489
- );
484
+ function browserExecutable(): string {
485
+ const executable = Bun.which("chromium-headless-shell") ?? Bun.which("chrome-headless-shell");
486
+ if (!executable) {
487
+ throw new Error("Chromium Headless Shell is required by this SDK but is missing from the runtime image. Rebuild the environment with a current Spectest golden image; for local development, install chromium-headless-shell on PATH.");
488
+ }
489
+ return executable;
490
490
  }
491
491
 
492
492
  async function ensurePlaywrightBrowser(): Promise<PlaywrightBrowser> {
493
493
  if (PW_BROWSER?.isConnected()) return PW_BROWSER;
494
494
  PW_BROWSER = await chromium.launch({
495
- executablePath: chromiumPath(),
495
+ executablePath: browserExecutable(),
496
496
  headless: true,
497
497
  args: CHROME_ARGV,
498
498
  });
@@ -530,7 +530,7 @@ async function newViewContext(
530
530
  let browser: PlaywrightBrowser | undefined;
531
531
  try {
532
532
  browser = fixture ? await chromium.launch({
533
- executablePath: chromiumPath(), headless: true, args: [...CHROME_ARGV, fixture.arg],
533
+ executablePath: browserExecutable(), headless: true, args: [...CHROME_ARGV, fixture.arg],
534
534
  }) : await ensurePlaywrightBrowser();
535
535
  const context = await browser.newContext({
536
536
  ...(device
@@ -543,6 +543,7 @@ async function newViewContext(
543
543
  timezoneId: opts.timezone,
544
544
  locale: opts.locale,
545
545
  });
546
+ reduceBrowserSurfaces(context, { mobile: !!device, deviceScaleFactor: device?.deviceScaleFactor ?? 1 });
546
547
  context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
547
548
  context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
548
549
  if (fixture) {
@@ -1288,6 +1289,7 @@ const VIEW_POOL: PooledView[] = [];
1288
1289
  * slow part (renderer process spawn). */
1289
1290
  async function spawnPage(context: BrowserContext): Promise<SpawnedPage> {
1290
1291
  const page = await context.newPage();
1292
+ await awaitBrowserSurface(page);
1291
1293
  const cdp = await context.newCDPSession(page);
1292
1294
  // Without Page.enable the addScriptToEvaluateOnNewDocument registration
1293
1295
  // silently never fires on this session (verified in the fork spike).
@@ -2199,9 +2201,7 @@ function buildBackend(
2199
2201
  }
2200
2202
  // Raw CDP rather than page.screenshot(): captures the viewport
2201
2203
  // exactly like the pre-Playwright backend.
2202
- const res = (await holder.cdp.send("Page.captureScreenshot", {
2203
- format: "png",
2204
- } as never)) as { data: string };
2204
+ const res = await captureBrowserScreenshot(holder.page, holder.cdp);
2205
2205
  const id = generateId("art");
2206
2206
  register({
2207
2207
  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 useLocal = await ensureLocalBuildkitd();
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
- if (useLocal) {
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
+ }
@@ -77,7 +77,7 @@ async function peak(browser: MobileBackend): Promise<number> {
77
77
  }));
78
78
  }
79
79
 
80
- describe.skipIf(!Bun.which("chromium") && !Bun.which("chromium-browser") && !Bun.which("google-chrome"))("native Chromium microphone", () => {
80
+ describe.skipIf(!Bun.which("chromium-headless-shell") && !Bun.which("chrome-headless-shell"))("native Chromium microphone", () => {
81
81
  let server: ReturnType<typeof Bun.serve>;
82
82
  beforeAll(() => { server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("<html>microphone</html>", { headers: { "content-type": "text/html" } }) }); });
83
83
  afterAll(() => server.stop(true));
@@ -200,7 +200,7 @@ describe.skipIf(!available)("Spectest assertions backed by real Playwright/Chrom
200
200
 
201
201
  // Exercise the production session branding/recording as well as the real
202
202
  // native engine above. browser.ts intentionally uses system Chromium.
203
- test.skipIf(!executablePath)("browser backend preserves assertion provenance and failure page capture", async () => {
203
+ test.skipIf(!Bun.which("chromium-headless-shell") && !Bun.which("chrome-headless-shell"))("browser backend preserves assertion provenance and failure page capture", async () => {
204
204
  const { openBrowser } = await import("./browser.js");
205
205
  const replaySteps: unknown[] = [];
206
206
  const view = await openBrowser({