@mochi.js/core 0.3.0 → 0.6.0

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,243 @@
1
+ /**
2
+ * Unit tests for the Linux-server environment auto-detection that drives
3
+ * `LaunchOptions.headlessMode` defaulting (task 0258).
4
+ *
5
+ * Two layers under test:
6
+ *
7
+ * 1. {@link detectLinuxServerEnv} — the pure classifier. Stub
8
+ * `(platform, env, uid, container probes)` and assert the
9
+ * `LinuxServerEnv` summary it returns.
10
+ * 2. {@link resolveHeadlessMode} — the resolution table that maps
11
+ * `(LaunchOptions, LinuxServerEnv)` → `"new" | "legacy" | "off"`. Order
12
+ * of precedence is load-bearing for the docs we ship; tests pin it.
13
+ *
14
+ * We DO NOT spawn Chromium here — the goal is to lock the decisions against
15
+ * regressions without taking the cost of a real launch. The flag-emit
16
+ * behaviour for the resolved mode is covered separately in
17
+ * `proc.test.ts` ("appends --headless=new when headless is true" et al.).
18
+ *
19
+ * @see packages/core/src/linux-server.ts
20
+ * @see packages/core/src/launch.ts (resolveHeadlessMode)
21
+ * @see tasks/0258 (Linux server env auto-detection)
22
+ */
23
+
24
+ import { describe, expect, it } from "bun:test";
25
+ import { resolveHeadlessMode } from "../launch";
26
+ import { detectLinuxServerEnv, type LinuxServerProbes } from "../linux-server";
27
+ import { buildChromiumArgs, type SpawnConfig } from "../proc";
28
+
29
+ const FAKE_BINARY = "/usr/bin/chromium-stub";
30
+ const FAKE_UDD = "/tmp/mochi-test-udd";
31
+
32
+ function probes(overrides: Partial<LinuxServerProbes> = {}): LinuxServerProbes {
33
+ return {
34
+ platform: "linux",
35
+ display: undefined,
36
+ waylandDisplay: undefined,
37
+ uid: 1000,
38
+ hasDockerEnvFile: false,
39
+ cgroup: undefined,
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ describe("detectLinuxServerEnv — server-no-display classifier (task 0258)", () => {
45
+ it("flags Linux + no DISPLAY + no WAYLAND_DISPLAY as serverNoDisplay=true", () => {
46
+ const env = detectLinuxServerEnv(probes());
47
+ expect(env.serverNoDisplay).toBe(true);
48
+ });
49
+
50
+ it("clears serverNoDisplay when DISPLAY is set (X11 dev workstation)", () => {
51
+ const env = detectLinuxServerEnv(probes({ display: ":0" }));
52
+ expect(env.serverNoDisplay).toBe(false);
53
+ });
54
+
55
+ it("clears serverNoDisplay when WAYLAND_DISPLAY is set (Wayland session)", () => {
56
+ const env = detectLinuxServerEnv(probes({ waylandDisplay: "wayland-0" }));
57
+ expect(env.serverNoDisplay).toBe(false);
58
+ });
59
+
60
+ it("treats empty-string DISPLAY as 'no display' (matches Chromium)", () => {
61
+ // An empty DISPLAY value means no usable X server; Chromium itself rejects
62
+ // the connection. Mirror that — an empty string must not gate us out of
63
+ // headless defaulting.
64
+ const env = detectLinuxServerEnv(probes({ display: "" }));
65
+ expect(env.serverNoDisplay).toBe(true);
66
+ });
67
+
68
+ it("never flags serverNoDisplay on darwin / win32 (rule is Linux-only)", () => {
69
+ expect(detectLinuxServerEnv(probes({ platform: "darwin" })).serverNoDisplay).toBe(false);
70
+ expect(detectLinuxServerEnv(probes({ platform: "win32" })).serverNoDisplay).toBe(false);
71
+ });
72
+
73
+ it("flags root=true when uid === 0 on Linux (orthogonal axis)", () => {
74
+ const env = detectLinuxServerEnv(probes({ uid: 0 }));
75
+ expect(env.root).toBe(true);
76
+ expect(env.serverNoDisplay).toBe(true);
77
+ });
78
+
79
+ it("clears root when uid !== 0", () => {
80
+ expect(detectLinuxServerEnv(probes({ uid: 1000 })).root).toBe(false);
81
+ expect(detectLinuxServerEnv(probes({ uid: undefined })).root).toBe(false);
82
+ });
83
+
84
+ it("never flags root on non-Linux (uid 0 on macOS root shell is not the same axis)", () => {
85
+ // The auto-`--no-sandbox` fallback in proc.ts is Linux-specific; the
86
+ // classifier mirrors that.
87
+ expect(detectLinuxServerEnv(probes({ platform: "darwin", uid: 0 })).root).toBe(false);
88
+ });
89
+
90
+ it("flags container=true when /.dockerenv is present", () => {
91
+ const env = detectLinuxServerEnv(probes({ hasDockerEnvFile: true }));
92
+ expect(env.container).toBe(true);
93
+ });
94
+
95
+ it("flags container=true when /proc/1/cgroup mentions docker", () => {
96
+ const env = detectLinuxServerEnv(
97
+ probes({ cgroup: "12:devices:/docker/abc123\n11:freezer:/docker/abc123\n" }),
98
+ );
99
+ expect(env.container).toBe(true);
100
+ });
101
+
102
+ it("flags container=true when cgroup mentions containerd", () => {
103
+ const env = detectLinuxServerEnv(probes({ cgroup: "0::/system.slice/containerd.service\n" }));
104
+ expect(env.container).toBe(true);
105
+ });
106
+
107
+ it("flags container=true when cgroup mentions kubepods (Kubernetes)", () => {
108
+ const env = detectLinuxServerEnv(
109
+ probes({ cgroup: "0::/kubepods.slice/kubepods-pod123.slice/\n" }),
110
+ );
111
+ expect(env.container).toBe(true);
112
+ });
113
+
114
+ it("clears container when neither indicator hits", () => {
115
+ expect(
116
+ detectLinuxServerEnv(probes({ cgroup: "0::/user.slice/user-1000.slice\n" })).container,
117
+ ).toBe(false);
118
+ expect(detectLinuxServerEnv(probes()).container).toBe(false);
119
+ });
120
+
121
+ it("rationale string surfaces every probed axis for debug logging", () => {
122
+ const env = detectLinuxServerEnv(
123
+ probes({ display: ":0", uid: 0, hasDockerEnvFile: true, waylandDisplay: undefined }),
124
+ );
125
+ expect(env.rationale).toContain("platform=linux");
126
+ expect(env.rationale).toContain("display=:0");
127
+ expect(env.rationale).toContain("uid=0");
128
+ expect(env.rationale).toContain("container=true");
129
+ expect(env.rationale).toContain("serverNoDisplay=false");
130
+ });
131
+ });
132
+
133
+ describe("resolveHeadlessMode — precedence table (task 0258)", () => {
134
+ const SERVER_ENV = detectLinuxServerEnv(probes());
135
+ const DEV_ENV = detectLinuxServerEnv(probes({ display: ":0" }));
136
+
137
+ it("explicit headlessMode='new' wins on a dev workstation", () => {
138
+ expect(resolveHeadlessMode({ headlessMode: "new" }, DEV_ENV)).toBe("new");
139
+ });
140
+
141
+ it("explicit headlessMode='legacy' wins on a server", () => {
142
+ expect(resolveHeadlessMode({ headlessMode: "legacy" }, SERVER_ENV)).toBe("legacy");
143
+ });
144
+
145
+ it("explicit headlessMode='off' wins on a server (caller knows what they want)", () => {
146
+ expect(resolveHeadlessMode({ headlessMode: "off" }, SERVER_ENV)).toBe("off");
147
+ });
148
+
149
+ it("legacy headless: true maps to 'new' regardless of env", () => {
150
+ expect(resolveHeadlessMode({ headless: true }, DEV_ENV)).toBe("new");
151
+ expect(resolveHeadlessMode({ headless: true }, SERVER_ENV)).toBe("new");
152
+ });
153
+
154
+ it("legacy headless: false maps to 'off' regardless of env", () => {
155
+ // Even on a server, an explicit `headless: false` must be honored —
156
+ // we are not in the business of ignoring user input. The user will
157
+ // crash, but they asked for it.
158
+ expect(resolveHeadlessMode({ headless: false }, SERVER_ENV)).toBe("off");
159
+ expect(resolveHeadlessMode({ headless: false }, DEV_ENV)).toBe("off");
160
+ });
161
+
162
+ it("env default: server-no-display → 'new' (the task 0258 fix)", () => {
163
+ expect(resolveHeadlessMode({}, SERVER_ENV)).toBe("new");
164
+ });
165
+
166
+ it("env default: dev workstation with DISPLAY → 'off' (run headful)", () => {
167
+ expect(resolveHeadlessMode({}, DEV_ENV)).toBe("off");
168
+ });
169
+
170
+ it("env default: macOS / Windows → 'off' (no Linux-only fallback)", () => {
171
+ const macEnv = detectLinuxServerEnv(probes({ platform: "darwin" }));
172
+ const winEnv = detectLinuxServerEnv(probes({ platform: "win32" }));
173
+ expect(resolveHeadlessMode({}, macEnv)).toBe("off");
174
+ expect(resolveHeadlessMode({}, winEnv)).toBe("off");
175
+ });
176
+
177
+ it("headlessMode supersedes a contradicting legacy headless flag", () => {
178
+ expect(resolveHeadlessMode({ headlessMode: "off", headless: true }, SERVER_ENV)).toBe("off");
179
+ expect(resolveHeadlessMode({ headlessMode: "new", headless: false }, DEV_ENV)).toBe("new");
180
+ });
181
+
182
+ it("server + root still resolves to 'new' (orthogonal axes — task 0258 §detection)", () => {
183
+ // The root/no-sandbox auto-flag is owned by proc.ts, not the headless
184
+ // resolver. This test pins that resolveHeadlessMode does NOT bake root
185
+ // into its decision — the user could be running the new-headless flow
186
+ // as root inside a container without that influencing the mode.
187
+ const rootServer = detectLinuxServerEnv(probes({ uid: 0 }));
188
+ expect(resolveHeadlessMode({}, rootServer)).toBe("new");
189
+ });
190
+
191
+ it("container + DISPLAY (rare but valid) → 'off' (developer in a devcontainer)", () => {
192
+ const devcontainer = detectLinuxServerEnv(
193
+ probes({ display: ":0", hasDockerEnvFile: true, cgroup: "0::/docker/abc\n" }),
194
+ );
195
+ expect(devcontainer.container).toBe(true);
196
+ expect(devcontainer.serverNoDisplay).toBe(false);
197
+ expect(resolveHeadlessMode({}, devcontainer)).toBe("off");
198
+ });
199
+ });
200
+
201
+ describe("buildChromiumArgs — headlessMode dispatch (task 0258)", () => {
202
+ function baseCfg(overrides: Partial<SpawnConfig> = {}): SpawnConfig {
203
+ return { binary: FAKE_BINARY, headless: false, ...overrides };
204
+ }
205
+
206
+ it("headlessMode='new' emits --headless=new", () => {
207
+ const args = buildChromiumArgs(baseCfg({ headlessMode: "new" }), FAKE_UDD, undefined);
208
+ expect(args).toContain("--headless=new");
209
+ expect(args).not.toContain("--headless");
210
+ });
211
+
212
+ it("headlessMode='legacy' emits bare --headless", () => {
213
+ const args = buildChromiumArgs(baseCfg({ headlessMode: "legacy" }), FAKE_UDD, undefined);
214
+ expect(args).toContain("--headless");
215
+ expect(args).not.toContain("--headless=new");
216
+ });
217
+
218
+ it("headlessMode='off' emits no headless flag at all", () => {
219
+ const args = buildChromiumArgs(baseCfg({ headlessMode: "off" }), FAKE_UDD, undefined);
220
+ expect(args.some((a) => a === "--headless" || a.startsWith("--headless="))).toBe(false);
221
+ });
222
+
223
+ it("headlessMode='new' supersedes headless: false", () => {
224
+ // The launcher resolves the mode; by the time we reach buildChromiumArgs,
225
+ // the resolved mode is canonical.
226
+ const args = buildChromiumArgs(
227
+ baseCfg({ headless: false, headlessMode: "new" }),
228
+ FAKE_UDD,
229
+ undefined,
230
+ );
231
+ expect(args).toContain("--headless=new");
232
+ });
233
+
234
+ it("legacy: headless: true with no headlessMode falls back to --headless=new", () => {
235
+ const args = buildChromiumArgs(baseCfg({ headless: true }), FAKE_UDD, undefined);
236
+ expect(args).toContain("--headless=new");
237
+ });
238
+
239
+ it("legacy: headless: false with no headlessMode emits no headless flag", () => {
240
+ const args = buildChromiumArgs(baseCfg({ headless: false }), FAKE_UDD, undefined);
241
+ expect(args.some((a) => a === "--headless" || a.startsWith("--headless="))).toBe(false);
242
+ });
243
+ });
@@ -5,7 +5,8 @@
5
5
  * with-auth/no-auth × edge cases (missing port, IPv6 host, percent-encoded
6
6
  * creds, empty password).
7
7
  *
8
- * `installProxyAuth`: drives a fake CDP router. Verifies:
8
+ * `installProxyAuth`: drives a fake CDP router via the shared
9
+ * `tests/helpers/cdp-fixture.ts` helper. Verifies:
9
10
  * - `Fetch.enable` is sent with `handleAuthRequests: true, patterns: [{ urlPattern: "*" }]`.
10
11
  * - `Fetch.authRequired` events trigger `Fetch.continueWithAuth` carrying
11
12
  * the configured creds.
@@ -16,8 +17,8 @@
16
17
  */
17
18
 
18
19
  import { describe, expect, it } from "bun:test";
20
+ import { type FakePipe, makeFakePipe } from "../../../../tests/helpers/cdp-fixture";
19
21
  import { MessageRouter } from "../cdp/router";
20
- import type { PipeReader, PipeWriter } from "../cdp/transport";
21
22
  import { installProxyAuth, parseProxyUrl } from "../proxy-auth";
22
23
 
23
24
  describe("parseProxyUrl", () => {
@@ -131,69 +132,35 @@ describe("parseProxyUrl", () => {
131
132
 
132
133
  interface FakeRouter {
133
134
  router: MessageRouter;
134
- written: { method: string; params?: unknown }[];
135
+ pipe: FakePipe;
135
136
  pushEvent(method: string, params: unknown): void;
136
137
  }
137
138
 
138
139
  function makeRouter(): FakeRouter {
139
- const written: { method: string; params?: unknown }[] = [];
140
- let pumpController: ReadableStreamDefaultController<Uint8Array> | null = null;
141
- const stream = new ReadableStream<Uint8Array>({
142
- start(c) {
143
- pumpController = c;
144
- },
145
- });
146
- const reader: PipeReader = {
147
- getReader: () => stream.getReader(),
148
- };
149
- const writer: PipeWriter = {
150
- write: (chunk: Uint8Array) => {
151
- const last = chunk[chunk.length - 1] === 0 ? chunk.length - 1 : chunk.length;
152
- const json = new TextDecoder().decode(chunk.subarray(0, last));
153
- try {
154
- const obj = JSON.parse(json) as { id?: number; method: string; params?: unknown };
155
- written.push({ method: obj.method, params: obj.params });
156
- // Auto-resolve the request immediately so the await in
157
- // `installProxyAuth` doesn't hang.
158
- if (typeof obj.id === "number") {
159
- const reply = JSON.stringify({ id: obj.id, result: {} });
160
- const enc = new TextEncoder().encode(reply);
161
- const out = new Uint8Array(enc.length + 1);
162
- out.set(enc, 0);
163
- out[enc.length] = 0;
164
- pumpController?.enqueue(out);
165
- }
166
- } catch {
167
- // ignore
168
- }
169
- return chunk.byteLength;
170
- },
171
- flush: () => undefined,
172
- end: () => undefined,
173
- };
174
- const router = new MessageRouter(reader, writer);
140
+ // Default responders auto-answer Fetch.enable / Fetch.disable / etc with
141
+ // `{}` that's everything `installProxyAuth` waits on.
142
+ const pipe = makeFakePipe();
143
+ const router = new MessageRouter(pipe.reader, pipe.writer);
175
144
  router.start();
176
- const enc = new TextEncoder();
177
145
  return {
178
146
  router,
179
- written,
147
+ pipe,
180
148
  pushEvent(method: string, params: unknown): void {
181
- const bytes = enc.encode(JSON.stringify({ method, params }));
182
- const out = new Uint8Array(bytes.length + 1);
183
- out.set(bytes, 0);
184
- out[bytes.length] = 0;
185
- pumpController?.enqueue(out);
149
+ pipe.inject({ method, params });
186
150
  },
187
151
  };
188
152
  }
189
153
 
190
154
  describe("installProxyAuth", () => {
191
- it("sends Fetch.enable with handleAuthRequests:true and empty patterns", async () => {
155
+ it("sends Fetch.enable with handleAuthRequests:true and Document-first patterns (task 0266)", async () => {
192
156
  const f = makeRouter();
193
157
  const handle = await installProxyAuth(f.router, { username: "u", password: "p" });
194
- const enable = f.written.find((c) => c.method === "Fetch.enable");
158
+ const enable = f.pipe.written.find((c) => c.parsed.method === "Fetch.enable");
195
159
  expect(enable).toBeDefined();
196
- expect(enable?.params).toEqual({ handleAuthRequests: true, patterns: [{ urlPattern: "*" }] });
160
+ expect(enable?.parsed.params).toEqual({
161
+ handleAuthRequests: true,
162
+ patterns: [{ urlPattern: "*", resourceType: "Document" }, { urlPattern: "*" }],
163
+ });
197
164
  await handle.dispose();
198
165
  await f.router.close();
199
166
  });
@@ -204,9 +171,9 @@ describe("installProxyAuth", () => {
204
171
  f.pushEvent("Fetch.authRequired", { requestId: "req-42", authChallenge: { source: "Proxy" } });
205
172
  // Allow microtasks + the writer push to flush.
206
173
  await new Promise((r) => setTimeout(r, 10));
207
- const reply = f.written.find((c) => c.method === "Fetch.continueWithAuth");
174
+ const reply = f.pipe.written.find((c) => c.parsed.method === "Fetch.continueWithAuth");
208
175
  expect(reply).toBeDefined();
209
- expect(reply?.params).toEqual({
176
+ expect(reply?.parsed.params).toEqual({
210
177
  requestId: "req-42",
211
178
  authChallengeResponse: {
212
179
  response: "ProvideCredentials",
@@ -223,9 +190,9 @@ describe("installProxyAuth", () => {
223
190
  const handle = await installProxyAuth(f.router, { username: "u", password: "p" });
224
191
  f.pushEvent("Fetch.requestPaused", { requestId: "rp-1" });
225
192
  await new Promise((r) => setTimeout(r, 10));
226
- const reply = f.written.find((c) => c.method === "Fetch.continueRequest");
193
+ const reply = f.pipe.written.find((c) => c.parsed.method === "Fetch.continueRequest");
227
194
  expect(reply).toBeDefined();
228
- expect(reply?.params).toEqual({ requestId: "rp-1" });
195
+ expect(reply?.parsed.params).toEqual({ requestId: "rp-1" });
229
196
  await handle.dispose();
230
197
  await f.router.close();
231
198
  });
@@ -235,7 +202,7 @@ describe("installProxyAuth", () => {
235
202
  const handle = await installProxyAuth(f.router, { username: "u", password: "p" });
236
203
  await handle.dispose();
237
204
  await handle.dispose();
238
- const disables = f.written.filter((c) => c.method === "Fetch.disable");
205
+ const disables = f.pipe.written.filter((c) => c.parsed.method === "Fetch.disable");
239
206
  expect(disables.length).toBe(1);
240
207
  await f.router.close();
241
208
  });
@@ -246,7 +213,7 @@ describe("installProxyAuth", () => {
246
213
  await handle.dispose();
247
214
  f.pushEvent("Fetch.authRequired", { requestId: "late" });
248
215
  await new Promise((r) => setTimeout(r, 10));
249
- const replies = f.written.filter((c) => c.method === "Fetch.continueWithAuth");
216
+ const replies = f.pipe.written.filter((c) => c.parsed.method === "Fetch.continueWithAuth");
250
217
  expect(replies.length).toBe(0);
251
218
  await f.router.close();
252
219
  });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Live conformance test for `Page.screenshot` (task 0265).
3
+ *
4
+ * Gated by `MOCHI_E2E=1` so the default `bun test` run stays fast and offline.
5
+ * Spawns a real Chromium-for-Testing instance, navigates to a tiny data URL,
6
+ * captures the viewport as PNG, and asserts the bytes start with the PNG
7
+ * magic signature.
8
+ *
9
+ * Budget: < 10 seconds.
10
+ */
11
+
12
+ import { describe, expect, it } from "bun:test";
13
+ import { mochi } from "../index";
14
+
15
+ const E2E_ENABLED = process.env.MOCHI_E2E === "1";
16
+ const TEST_TIMEOUT_MS = 10_000;
17
+
18
+ const describeOrSkip = E2E_ENABLED ? describe : describe.skip;
19
+
20
+ describeOrSkip("@mochi.js/core Page.screenshot E2E (MOCHI_E2E=1)", () => {
21
+ it(
22
+ "captures a PNG screenshot — Uint8Array starts with PNG magic bytes",
23
+ async () => {
24
+ const session = await mochi.launch({
25
+ profile: "test",
26
+ seed: "screenshot-e2e",
27
+ headless: true,
28
+ });
29
+ try {
30
+ const page = await session.newPage();
31
+ await page.goto("data:text/html,<title>shot</title><h1 style='color:red'>hello</h1>");
32
+ const png = await page.screenshot();
33
+ expect(png).toBeInstanceOf(Uint8Array);
34
+ // PNG signature: 89 50 4E 47 0D 0A 1A 0A
35
+ expect(png[0]).toBe(0x89);
36
+ expect(png[1]).toBe(0x50);
37
+ expect(png[2]).toBe(0x4e);
38
+ expect(png[3]).toBe(0x47);
39
+ expect(png[4]).toBe(0x0d);
40
+ expect(png[5]).toBe(0x0a);
41
+ expect(png[6]).toBe(0x1a);
42
+ expect(png[7]).toBe(0x0a);
43
+ // Sanity: a non-trivial image should be more than just the header.
44
+ expect(png.length).toBeGreaterThan(100);
45
+ } finally {
46
+ await session.close();
47
+ }
48
+ },
49
+ TEST_TIMEOUT_MS,
50
+ );
51
+
52
+ it(
53
+ "captures a JPEG screenshot when format: 'jpeg'",
54
+ async () => {
55
+ const session = await mochi.launch({
56
+ profile: "test",
57
+ seed: "screenshot-e2e-jpeg",
58
+ headless: true,
59
+ });
60
+ try {
61
+ const page = await session.newPage();
62
+ await page.goto("data:text/html,<h1>jpeg</h1>");
63
+ const jpeg = await page.screenshot({ format: "jpeg", quality: 70 });
64
+ expect(jpeg).toBeInstanceOf(Uint8Array);
65
+ // JPEG SOI marker: FF D8 FF
66
+ expect(jpeg[0]).toBe(0xff);
67
+ expect(jpeg[1]).toBe(0xd8);
68
+ expect(jpeg[2]).toBe(0xff);
69
+ } finally {
70
+ await session.close();
71
+ }
72
+ },
73
+ TEST_TIMEOUT_MS,
74
+ );
75
+
76
+ it(
77
+ "fullPage: true captures beyond the visible viewport",
78
+ async () => {
79
+ const session = await mochi.launch({
80
+ profile: "test",
81
+ seed: "screenshot-e2e-fullpage",
82
+ headless: true,
83
+ });
84
+ try {
85
+ const page = await session.newPage();
86
+ // A page taller than the default viewport so fullPage actually matters.
87
+ await page.goto(
88
+ "data:text/html,<style>body{margin:0}div{height:3000px;background:linear-gradient(red,blue)}</style><div></div>",
89
+ );
90
+ const viewportShot = await page.screenshot();
91
+ const fullShot = await page.screenshot({ fullPage: true });
92
+ expect(viewportShot[0]).toBe(0x89);
93
+ expect(fullShot[0]).toBe(0x89);
94
+ // Full-page bytes should be larger than viewport bytes for a 3000px page.
95
+ expect(fullShot.length).toBeGreaterThan(viewportShot.length);
96
+ } finally {
97
+ await session.close();
98
+ }
99
+ },
100
+ TEST_TIMEOUT_MS,
101
+ );
102
+
103
+ it(
104
+ "encoding: 'base64' returns a string instead of bytes",
105
+ async () => {
106
+ const session = await mochi.launch({
107
+ profile: "test",
108
+ seed: "screenshot-e2e-b64",
109
+ headless: true,
110
+ });
111
+ try {
112
+ const page = await session.newPage();
113
+ await page.goto("data:text/html,<h1>b64</h1>");
114
+ const b64 = await page.screenshot({ encoding: "base64" });
115
+ expect(typeof b64).toBe("string");
116
+ // Decoded base64 must start with the PNG magic.
117
+ const decoded = Buffer.from(b64, "base64");
118
+ expect(decoded[0]).toBe(0x89);
119
+ expect(decoded[1]).toBe(0x50);
120
+ } finally {
121
+ await session.close();
122
+ }
123
+ },
124
+ TEST_TIMEOUT_MS,
125
+ );
126
+ });