@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.
- package/dist/browser-surface.d.ts +9 -0
- package/dist/browser-surface.js +71 -0
- package/dist/browser.js +14 -12
- 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-executable.test.ts +21 -0
- package/src/browser-finalization.test.ts +1 -1
- package/src/browser-navigation.test.ts +1 -1
- package/src/browser-screenshot.test.ts +66 -0
- package/src/browser-surface.test.ts +71 -0
- package/src/browser-surface.ts +70 -0
- package/src/browser-timezone.test.ts +1 -1
- package/src/browser.ts +14 -14
- package/src/daemon.ts +25 -4
- package/src/harness/shadow-builder.test.ts +29 -0
- package/src/harness/shadow-builder.ts +41 -0
- package/src/microphone-fixture.test.ts +1 -1
- package/src/playwright-assertions.test.ts +1 -1
|
@@ -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
|
|
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
|
-
//
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
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
|
|
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
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
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
|
|
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
|
+
}
|
|
@@ -77,7 +77,7 @@ async function peak(browser: MobileBackend): Promise<number> {
|
|
|
77
77
|
}));
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
describe.skipIf(!Bun.which("chromium
|
|
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(!
|
|
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({
|