@specific.dev/spectest 0.131.17 → 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.
@@ -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.17",
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",
@@ -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
+ }