@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 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
@@ -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
- if (!ev.url)
51
- return;
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
- const info = c.scripts.get(script.scriptId) ?? (script.url ? { url: script.url } : undefined);
123
- if (!info)
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(info.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 = (await holder.cdp.send("Page.captureScreenshot", {
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 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.16",
3
+ "version": "0.131.18",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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
- await expect(harvestAllBrowserCoverage()).rejects.toThrow("Browser coverage capture was interrupted; coverage is incomplete");
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);
@@ -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
- if (!ev.url) return;
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
- const info = c.scripts.get(script.scriptId) ?? (script.url ? { url: script.url } : undefined);
154
- if (!info) continue;
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(info.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 = (await holder.cdp.send("Page.captureScreenshot", {
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 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
+ }