@specific.dev/spectest 0.131.18 → 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.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Headless browser handle for tests. Thin wrapper around playwright-core
2
- // driving the guest's system Chromium (pipe transport — `chromium.launch`
2
+ // driving the guest's Headless Shell (pipe transport — `chromium.launch`
3
3
  // with an explicit `executablePath`). Electron attaches to the app-owned
4
4
  // Chromium over CDP instead; its client also lives inside the VM. The wrapper
5
5
  // keeps the public surface stable (we swapped from Bun.WebView to Playwright without
@@ -109,7 +109,7 @@ const NAVIGATION_TIMEOUT_MS = 30_000;
109
109
  // ────────────────────────────────────────────────────────────────────────
110
110
  // Shared Chromium (playwright-core)
111
111
  // ────────────────────────────────────────────────────────────────────────
112
- // Default sessions share one Chromium per daemon, launched lazily and kept
112
+ // Browser and mobile sessions share one Headless Shell per daemon, launched lazily and kept
113
113
  // for the daemon's lifetime. Fixture sessions own a process because the audio
114
114
  // source is a process-wide flag. Both ride snapshots: the process, playwright's
115
115
  // pipe connection to it, and every open page freeze into the VM snapshot
@@ -126,17 +126,18 @@ const VIEW_CLEANUPS = new WeakMap();
126
126
  // (mirroring the old one-Chrome-profile model), which left `close()` closing
127
127
  // the page only — cookies survived and the next session was still
128
128
  // authenticated.
129
- function chromiumPath() {
130
- return (Bun.which("chromium") ??
131
- Bun.which("chromium-browser") ??
132
- Bun.which("google-chrome") ??
133
- "/usr/bin/chromium");
129
+ function browserExecutable() {
130
+ const executable = Bun.which("chromium-headless-shell") ?? Bun.which("chrome-headless-shell");
131
+ if (!executable) {
132
+ throw new Error("Chromium Headless Shell is required by this SDK but is missing from the runtime image. Rebuild the environment with a current Spectest golden image; for local development, install chromium-headless-shell on PATH.");
133
+ }
134
+ return executable;
134
135
  }
135
136
  async function ensurePlaywrightBrowser() {
136
137
  if (PW_BROWSER?.isConnected())
137
138
  return PW_BROWSER;
138
139
  PW_BROWSER = await chromium.launch({
139
- executablePath: chromiumPath(),
140
+ executablePath: browserExecutable(),
140
141
  headless: true,
141
142
  args: CHROME_ARGV,
142
143
  });
@@ -167,7 +168,7 @@ async function newViewContext(width, height, device, opts = {}) {
167
168
  let browser;
168
169
  try {
169
170
  browser = fixture ? await chromium.launch({
170
- executablePath: chromiumPath(), headless: true, args: [...CHROME_ARGV, fixture.arg],
171
+ executablePath: browserExecutable(), headless: true, args: [...CHROME_ARGV, fixture.arg],
171
172
  }) : await ensurePlaywrightBrowser();
172
173
  const context = await browser.newContext({
173
174
  ...(device
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.131.18",
3
+ "version": "0.131.19",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,21 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ test("SDK refuses a Chromium-only runtime instead of silently falling back", async () => {
7
+ const dir = await mkdtemp(join(tmpdir(), "spectest-no-shell-"));
8
+ try {
9
+ await writeFile(join(dir, "chromium"), "#!/bin/sh\necho UNEXPECTED_CHROMIUM_LAUNCH >&2\nexit 1\n", { mode: 0o755 });
10
+ const browserModule = new URL("./browser.ts", import.meta.url).href;
11
+ const child = Bun.spawn([process.execPath, "-e", `
12
+ import { openBrowser } from ${JSON.stringify(browserModule)};
13
+ try { await openBrowser(); process.exit(2); }
14
+ catch (error) { console.error(error.message); process.exit(1); }
15
+ `], { env: { ...process.env, PATH: dir }, stdout: "pipe", stderr: "pipe" });
16
+ const stderr = await new Response(child.stderr).text();
17
+ expect(await child.exited).toBe(1);
18
+ expect(stderr).toContain("Headless Shell is required");
19
+ expect(stderr).not.toContain("UNEXPECTED_CHROMIUM_LAUNCH");
20
+ } finally { await rm(dir, { recursive: true, force: true }); }
21
+ });
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
2
2
  import { acquirePersistentBrowser } from "./browser.js";
3
3
  import { finalizeBrowserRecordings } from "./harness/deadline.js";
4
4
 
5
- const available = !!(Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome"));
5
+ const available = !!(Bun.which("chromium-headless-shell") ?? Bun.which("chrome-headless-shell"));
6
6
 
7
7
  test.skipIf(!available)("stuck replay drains are bounded warnings and late replies cannot change the recording or clear a later buffer", async () => {
8
8
  const chunks: unknown[] = [];
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
2
2
  import { openBrowser } from "./browser.js";
3
3
  import { startRecording, stopRecording } from "./recorder.js";
4
4
 
5
- const available = !!(Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome"));
5
+ const available = !!(Bun.which("chromium-headless-shell") ?? Bun.which("chrome-headless-shell"));
6
6
 
7
7
  test.skipIf(!available)("public navigation options control goto/reload and remain visible in recordings", async () => {
8
8
  // The document commits immediately but load waits for an image. This
@@ -0,0 +1,66 @@
1
+ import { expect, test } from "bun:test";
2
+ import { openBrowser, type BrowserSessionRecorder } from "./browser.js";
3
+ import { readRaw } from "./inspect.js";
4
+
5
+ const available =
6
+ Bun.which("chromium-headless-shell") ??
7
+ Bun.which("chrome-headless-shell");
8
+ for (const frame of ["browser", "mobile"] as const) {
9
+ test.skipIf(!available)(
10
+ `${frame} public screenshot exports full PNG pixels after DOM changes`,
11
+ async () => {
12
+ const artifacts: Parameters<
13
+ NonNullable<BrowserSessionRecorder["registerArtifact"]>
14
+ >[0][] = [];
15
+ const browser = await openBrowser({
16
+ frame,
17
+ recorder: {
18
+ sessionId: `screenshot-${frame}`,
19
+ recordStep() {},
20
+ registerArtifact: (artifact) => artifacts.push(artifact),
21
+ },
22
+ });
23
+ const width = frame === "mobile" ? 393 : 1280;
24
+ const height = frame === "mobile" ? 852 : 720;
25
+ const dpr = frame === "mobile" ? 3 : 1;
26
+ try {
27
+ await browser.goto(
28
+ `data:text/html,${encodeURIComponent('<meta name="viewport" content="width=device-width,initial-scale=1"><style>body{margin:0;background:rgb(255,0,0)}</style>')}`,
29
+ );
30
+ for (const color of ["rgb(255,0,0)", "rgb(0,255,0)"]) {
31
+ await browser.evaluate(
32
+ "change background",
33
+ `document.body.style.background = ${JSON.stringify(color)}`,
34
+ );
35
+ const id = await browser.screenshot();
36
+ const artifact = artifacts.at(-1)!;
37
+ expect(artifact.id).toBe(id);
38
+ expect(artifact.contentType).toBe("image/png");
39
+ const png = Buffer.from(artifact.bytesBase64, "base64");
40
+ expect(artifact.sizeBytes).toBe(png.length);
41
+ expect([png.readUInt32BE(16), png.readUInt32BE(20)]).toEqual([
42
+ width * dpr,
43
+ height * dpr,
44
+ ]);
45
+ const pixel = readRaw(
46
+ await browser.evaluate(
47
+ "read captured bottom-right pixel",
48
+ `(async function () {
49
+ const img = new Image(); img.src = 'data:image/png;base64,${artifact.bytesBase64}'; await img.decode();
50
+ const canvas = document.createElement('canvas'); canvas.width=img.width; canvas.height=img.height;
51
+ const ctx=canvas.getContext('2d');ctx.drawImage(img,0,0);
52
+ return [...ctx.getImageData(img.width-10,img.height-10,1,1).data];
53
+ })()`,
54
+ ),
55
+ );
56
+ expect(pixel).toEqual(
57
+ color.includes("255,0,0") ? [255, 0, 0, 255] : [0, 255, 0, 255],
58
+ );
59
+ }
60
+ } finally {
61
+ await browser.close();
62
+ }
63
+ },
64
+ 20000,
65
+ );
66
+ }
@@ -2,10 +2,13 @@ import { expect, test } from "bun:test";
2
2
  import { chromium } from "playwright-core";
3
3
  import { awaitBrowserSurface, captureBrowserScreenshot, reduceBrowserSurfaces } from "./browser-surface.js";
4
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"] });
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"] });
9
12
  const width = mobile ? 393 : 1280, height = mobile ? 852 : 720, dpr = mobile ? 3 : 1;
10
13
  try {
11
14
  const context = await browser.newContext({ viewport: { width, height }, isMobile: mobile, hasTouch: mobile, deviceScaleFactor: dpr });
@@ -21,7 +21,7 @@ async function settings(browser: Browser): Promise<Settings> {
21
21
  return readRaw(await browser.evaluate<Settings>("read initial regional settings", "window.settings")) as unknown as Settings;
22
22
  }
23
23
 
24
- describe.skipIf(!Bun.which("chromium") && !Bun.which("chromium-browser") && !Bun.which("google-chrome"))("browser timezone and locale", () => {
24
+ describe.skipIf(!Bun.which("chromium-headless-shell") && !Bun.which("chrome-headless-shell"))("browser timezone and locale", () => {
25
25
  let server: ReturnType<typeof Bun.serve>;
26
26
  beforeAll(() => {
27
27
  server = Bun.serve({
package/src/browser.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // Headless browser handle for tests. Thin wrapper around playwright-core
2
- // driving the guest's system Chromium (pipe transport — `chromium.launch`
2
+ // driving the guest's Headless Shell (pipe transport — `chromium.launch`
3
3
  // with an explicit `executablePath`). Electron attaches to the app-owned
4
4
  // Chromium over CDP instead; its client also lives inside the VM. The wrapper
5
5
  // keeps the public surface stable (we swapped from Bun.WebView to Playwright without
@@ -463,7 +463,7 @@ const NAVIGATION_TIMEOUT_MS = 30_000;
463
463
  // Shared Chromium (playwright-core)
464
464
  // ────────────────────────────────────────────────────────────────────────
465
465
 
466
- // Default sessions share one Chromium per daemon, launched lazily and kept
466
+ // Browser and mobile sessions share one Headless Shell per daemon, launched lazily and kept
467
467
  // for the daemon's lifetime. Fixture sessions own a process because the audio
468
468
  // source is a process-wide flag. Both ride snapshots: the process, playwright's
469
469
  // pipe connection to it, and every open page freeze into the VM snapshot
@@ -481,19 +481,18 @@ const VIEW_CLEANUPS = new WeakMap<BrowserContext, () => Promise<void>>();
481
481
  // the page only — cookies survived and the next session was still
482
482
  // authenticated.
483
483
 
484
- function chromiumPath(): string {
485
- return (
486
- Bun.which("chromium") ??
487
- Bun.which("chromium-browser") ??
488
- Bun.which("google-chrome") ??
489
- "/usr/bin/chromium"
490
- );
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;
491
490
  }
492
491
 
493
492
  async function ensurePlaywrightBrowser(): Promise<PlaywrightBrowser> {
494
493
  if (PW_BROWSER?.isConnected()) return PW_BROWSER;
495
494
  PW_BROWSER = await chromium.launch({
496
- executablePath: chromiumPath(),
495
+ executablePath: browserExecutable(),
497
496
  headless: true,
498
497
  args: CHROME_ARGV,
499
498
  });
@@ -531,7 +530,7 @@ async function newViewContext(
531
530
  let browser: PlaywrightBrowser | undefined;
532
531
  try {
533
532
  browser = fixture ? await chromium.launch({
534
- executablePath: chromiumPath(), headless: true, args: [...CHROME_ARGV, fixture.arg],
533
+ executablePath: browserExecutable(), headless: true, args: [...CHROME_ARGV, fixture.arg],
535
534
  }) : await ensurePlaywrightBrowser();
536
535
  const context = await browser.newContext({
537
536
  ...(device
@@ -77,7 +77,7 @@ async function peak(browser: MobileBackend): Promise<number> {
77
77
  }));
78
78
  }
79
79
 
80
- describe.skipIf(!Bun.which("chromium") && !Bun.which("chromium-browser") && !Bun.which("google-chrome"))("native Chromium microphone", () => {
80
+ describe.skipIf(!Bun.which("chromium-headless-shell") && !Bun.which("chrome-headless-shell"))("native Chromium microphone", () => {
81
81
  let server: ReturnType<typeof Bun.serve>;
82
82
  beforeAll(() => { server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("<html>microphone</html>", { headers: { "content-type": "text/html" } }) }); });
83
83
  afterAll(() => server.stop(true));
@@ -200,7 +200,7 @@ describe.skipIf(!available)("Spectest assertions backed by real Playwright/Chrom
200
200
 
201
201
  // Exercise the production session branding/recording as well as the real
202
202
  // native engine above. browser.ts intentionally uses system Chromium.
203
- test.skipIf(!executablePath)("browser backend preserves assertion provenance and failure page capture", async () => {
203
+ test.skipIf(!Bun.which("chromium-headless-shell") && !Bun.which("chrome-headless-shell"))("browser backend preserves assertion provenance and failure page capture", async () => {
204
204
  const { openBrowser } = await import("./browser.js");
205
205
  const replaySteps: unknown[] = [];
206
206
  const view = await openBrowser({