@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 +10 -9
- 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 +7 -4
- package/src/browser-timezone.test.ts +1 -1
- package/src/browser.ts +10 -11
- package/src/microphone-fixture.test.ts +1 -1
- package/src/playwright-assertions.test.ts +1 -1
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
|
|
@@ -109,7 +109,7 @@ const NAVIGATION_TIMEOUT_MS = 30_000;
|
|
|
109
109
|
// ────────────────────────────────────────────────────────────────────────
|
|
110
110
|
// Shared Chromium (playwright-core)
|
|
111
111
|
// ────────────────────────────────────────────────────────────────────────
|
|
112
|
-
//
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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:
|
|
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:
|
|
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
|
@@ -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
|
+
}
|
|
@@ -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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
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
|
|
@@ -463,7 +463,7 @@ const NAVIGATION_TIMEOUT_MS = 30_000;
|
|
|
463
463
|
// Shared Chromium (playwright-core)
|
|
464
464
|
// ────────────────────────────────────────────────────────────────────────
|
|
465
465
|
|
|
466
|
-
//
|
|
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
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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({
|