@mochi.js/core 0.3.0 → 0.8.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.
@@ -5,19 +5,19 @@
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.
12
13
  * - The defensive `Fetch.requestPaused` handler issues `Fetch.continueRequest`.
13
14
  * - `dispose()` sends `Fetch.disable` and is idempotent.
14
15
  *
15
- * @see tasks/0160-proxy-auth-and-ci-fix.md
16
16
  */
17
17
 
18
18
  import { describe, expect, it } from "bun:test";
19
+ import { type FakePipe, makeFakePipe } from "../../../../tests/helpers/cdp-fixture";
19
20
  import { MessageRouter } from "../cdp/router";
20
- import type { PipeReader, PipeWriter } from "../cdp/transport";
21
21
  import { installProxyAuth, parseProxyUrl } from "../proxy-auth";
22
22
 
23
23
  describe("parseProxyUrl", () => {
@@ -131,69 +131,35 @@ describe("parseProxyUrl", () => {
131
131
 
132
132
  interface FakeRouter {
133
133
  router: MessageRouter;
134
- written: { method: string; params?: unknown }[];
134
+ pipe: FakePipe;
135
135
  pushEvent(method: string, params: unknown): void;
136
136
  }
137
137
 
138
138
  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);
139
+ // Default responders auto-answer Fetch.enable / Fetch.disable / etc with
140
+ // `{}` that's everything `installProxyAuth` waits on.
141
+ const pipe = makeFakePipe();
142
+ const router = new MessageRouter(pipe.reader, pipe.writer);
175
143
  router.start();
176
- const enc = new TextEncoder();
177
144
  return {
178
145
  router,
179
- written,
146
+ pipe,
180
147
  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);
148
+ pipe.inject({ method, params });
186
149
  },
187
150
  };
188
151
  }
189
152
 
190
153
  describe("installProxyAuth", () => {
191
- it("sends Fetch.enable with handleAuthRequests:true and empty patterns", async () => {
154
+ it("sends Fetch.enable with handleAuthRequests:true and Document-first patterns", async () => {
192
155
  const f = makeRouter();
193
156
  const handle = await installProxyAuth(f.router, { username: "u", password: "p" });
194
- const enable = f.written.find((c) => c.method === "Fetch.enable");
157
+ const enable = f.pipe.written.find((c) => c.parsed.method === "Fetch.enable");
195
158
  expect(enable).toBeDefined();
196
- expect(enable?.params).toEqual({ handleAuthRequests: true, patterns: [{ urlPattern: "*" }] });
159
+ expect(enable?.parsed.params).toEqual({
160
+ handleAuthRequests: true,
161
+ patterns: [{ urlPattern: "*", resourceType: "Document" }, { urlPattern: "*" }],
162
+ });
197
163
  await handle.dispose();
198
164
  await f.router.close();
199
165
  });
@@ -204,9 +170,9 @@ describe("installProxyAuth", () => {
204
170
  f.pushEvent("Fetch.authRequired", { requestId: "req-42", authChallenge: { source: "Proxy" } });
205
171
  // Allow microtasks + the writer push to flush.
206
172
  await new Promise((r) => setTimeout(r, 10));
207
- const reply = f.written.find((c) => c.method === "Fetch.continueWithAuth");
173
+ const reply = f.pipe.written.find((c) => c.parsed.method === "Fetch.continueWithAuth");
208
174
  expect(reply).toBeDefined();
209
- expect(reply?.params).toEqual({
175
+ expect(reply?.parsed.params).toEqual({
210
176
  requestId: "req-42",
211
177
  authChallengeResponse: {
212
178
  response: "ProvideCredentials",
@@ -223,9 +189,9 @@ describe("installProxyAuth", () => {
223
189
  const handle = await installProxyAuth(f.router, { username: "u", password: "p" });
224
190
  f.pushEvent("Fetch.requestPaused", { requestId: "rp-1" });
225
191
  await new Promise((r) => setTimeout(r, 10));
226
- const reply = f.written.find((c) => c.method === "Fetch.continueRequest");
192
+ const reply = f.pipe.written.find((c) => c.parsed.method === "Fetch.continueRequest");
227
193
  expect(reply).toBeDefined();
228
- expect(reply?.params).toEqual({ requestId: "rp-1" });
194
+ expect(reply?.parsed.params).toEqual({ requestId: "rp-1" });
229
195
  await handle.dispose();
230
196
  await f.router.close();
231
197
  });
@@ -235,7 +201,7 @@ describe("installProxyAuth", () => {
235
201
  const handle = await installProxyAuth(f.router, { username: "u", password: "p" });
236
202
  await handle.dispose();
237
203
  await handle.dispose();
238
- const disables = f.written.filter((c) => c.method === "Fetch.disable");
204
+ const disables = f.pipe.written.filter((c) => c.parsed.method === "Fetch.disable");
239
205
  expect(disables.length).toBe(1);
240
206
  await f.router.close();
241
207
  });
@@ -246,7 +212,7 @@ describe("installProxyAuth", () => {
246
212
  await handle.dispose();
247
213
  f.pushEvent("Fetch.authRequired", { requestId: "late" });
248
214
  await new Promise((r) => setTimeout(r, 10));
249
- const replies = f.written.filter((c) => c.method === "Fetch.continueWithAuth");
215
+ const replies = f.pipe.written.filter((c) => c.parsed.method === "Fetch.continueWithAuth");
250
216
  expect(replies.length).toBe(0);
251
217
  await f.router.close();
252
218
  });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Live conformance test for `Page.screenshot`.
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
+ });
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Unit tests for `Page.screenshot`.
3
+ *
4
+ * Exercises the CDP wire shape against a `MessageRouter` driven over a fake
5
+ * pipe — no real Chromium spawn, no real `Session`. The router still applies
6
+ * the §8.2 forbidden-method assertion on every send, so this test also
7
+ * implicitly verifies that `Page.captureScreenshot` is NOT on the forbidden
8
+ * list.
9
+ *
10
+ * Coverage:
11
+ * - PNG/JPEG params (format, quality)
12
+ * - clip with default scale
13
+ * - omitBackground passthrough
14
+ * - encoding: "base64" returns the raw string
15
+ * - encoding: "binary" (default) decodes to a Uint8Array — verified by
16
+ * constructing a known byte sequence, base64-encoding it, and asserting
17
+ * round-trip equality including the PNG magic bytes
18
+ * - fullPage: drives `Page.getLayoutMetrics` + `setDeviceMetricsOverride`
19
+ * + capture + `clearDeviceMetricsOverride` (in that order)
20
+ * - fullPage cleanup: `clearDeviceMetricsOverride` is called even when the
21
+ * capture itself rejects
22
+ */
23
+
24
+ import { describe, expect, it } from "bun:test";
25
+ import { MessageRouter } from "../cdp/router";
26
+ import type { PipeReader, PipeWriter } from "../cdp/transport";
27
+ import { Page } from "../page";
28
+
29
+ interface RecordedRequest {
30
+ id: number;
31
+ method: string;
32
+ params?: unknown;
33
+ sessionId?: string;
34
+ }
35
+
36
+ interface FakeRouter {
37
+ router: MessageRouter;
38
+ requests: RecordedRequest[];
39
+ /** Auto-respond to a method with a result (or error). */
40
+ on(method: string, responder: (req: RecordedRequest) => unknown | { __error: string }): void;
41
+ close(): Promise<void>;
42
+ }
43
+
44
+ function makeFakeRouter(): FakeRouter {
45
+ const requests: RecordedRequest[] = [];
46
+ let pumpController: ReadableStreamDefaultController<Uint8Array> | null = null;
47
+ const stream = new ReadableStream<Uint8Array>({
48
+ start(c) {
49
+ pumpController = c;
50
+ },
51
+ });
52
+ const enc = new TextEncoder();
53
+ const dec = new TextDecoder();
54
+ const responders: Array<{
55
+ method: string;
56
+ fn: (req: RecordedRequest) => unknown | { __error: string };
57
+ }> = [];
58
+
59
+ const push = (obj: unknown): void => {
60
+ const bytes = enc.encode(JSON.stringify(obj));
61
+ const out = new Uint8Array(bytes.length + 1);
62
+ out.set(bytes, 0);
63
+ out[bytes.length] = 0;
64
+ pumpController?.enqueue(out);
65
+ };
66
+
67
+ const reader: PipeReader = {
68
+ getReader: () => stream.getReader(),
69
+ };
70
+ const writer: PipeWriter = {
71
+ write: (chunk) => {
72
+ const last = chunk[chunk.length - 1] === 0 ? chunk.length - 1 : chunk.length;
73
+ const json = dec.decode(chunk.subarray(0, last));
74
+ try {
75
+ const parsed = JSON.parse(json) as RecordedRequest;
76
+ requests.push(parsed);
77
+ const r = responders.find((x) => x.method === parsed.method);
78
+ if (r !== undefined && typeof parsed.id === "number") {
79
+ queueMicrotask(() => {
80
+ const result = r.fn(parsed);
81
+ if (
82
+ result !== null &&
83
+ typeof result === "object" &&
84
+ "__error" in result &&
85
+ typeof (result as { __error: string }).__error === "string"
86
+ ) {
87
+ push({
88
+ id: parsed.id,
89
+ error: { code: -32000, message: (result as { __error: string }).__error },
90
+ });
91
+ } else {
92
+ push({ id: parsed.id, result });
93
+ }
94
+ });
95
+ }
96
+ } catch {
97
+ // ignore
98
+ }
99
+ },
100
+ flush: () => undefined,
101
+ end: () => undefined,
102
+ };
103
+
104
+ const router = new MessageRouter(reader, writer);
105
+ router.start();
106
+
107
+ return {
108
+ router,
109
+ requests,
110
+ on(method, fn) {
111
+ responders.push({ method, fn });
112
+ },
113
+ async close() {
114
+ pumpController?.close();
115
+ await router.close();
116
+ },
117
+ };
118
+ }
119
+
120
+ /** Build a Page wired to the fake router, no inject script. */
121
+ function makePage(fake: FakeRouter): Page {
122
+ return new Page({
123
+ router: fake.router,
124
+ targetId: "target-1",
125
+ sessionId: "session-1",
126
+ initialUrl: "about:blank",
127
+ });
128
+ }
129
+
130
+ describe("Page.screenshot — CDP wire shape", () => {
131
+ it("default opts → Page.captureScreenshot { format: 'png' }, decodes base64 to Uint8Array", async () => {
132
+ const fake = makeFakeRouter();
133
+ try {
134
+ // Construct a known byte sequence (the PNG magic + a few payload bytes)
135
+ // and base64-encode it. The screenshot decoder should round-trip this
136
+ // exactly back to a Uint8Array.
137
+ const knownBytes = new Uint8Array([
138
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xde, 0xad,
139
+ ]);
140
+ const b64 = Buffer.from(knownBytes).toString("base64");
141
+ fake.on("Page.captureScreenshot", () => ({ data: b64 }));
142
+
143
+ const page = makePage(fake);
144
+ const result = await page.screenshot();
145
+
146
+ expect(result).toBeInstanceOf(Uint8Array);
147
+ expect(Array.from(result)).toEqual(Array.from(knownBytes));
148
+ // PNG magic bytes
149
+ expect(result[0]).toBe(0x89);
150
+ expect(result[1]).toBe(0x50);
151
+ expect(result[2]).toBe(0x4e);
152
+ expect(result[3]).toBe(0x47);
153
+
154
+ const captureReq = fake.requests.find((r) => r.method === "Page.captureScreenshot");
155
+ expect(captureReq).toBeDefined();
156
+ expect(captureReq?.params).toEqual({ format: "png" });
157
+ expect(captureReq?.sessionId).toBe("session-1");
158
+ } finally {
159
+ await fake.close();
160
+ }
161
+ });
162
+
163
+ it("encoding: 'base64' returns the raw CDP string (no decode)", async () => {
164
+ const fake = makeFakeRouter();
165
+ try {
166
+ const knownBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
167
+ const b64 = Buffer.from(knownBytes).toString("base64");
168
+ fake.on("Page.captureScreenshot", () => ({ data: b64 }));
169
+
170
+ const page = makePage(fake);
171
+ const result = await page.screenshot({ encoding: "base64" });
172
+ expect(typeof result).toBe("string");
173
+ expect(result).toBe(b64);
174
+ } finally {
175
+ await fake.close();
176
+ }
177
+ });
178
+
179
+ it("format: 'jpeg' + quality → params carry both", async () => {
180
+ const fake = makeFakeRouter();
181
+ try {
182
+ fake.on("Page.captureScreenshot", () => ({ data: "AA==" }));
183
+ const page = makePage(fake);
184
+ await page.screenshot({ format: "jpeg", quality: 80 });
185
+
186
+ const req = fake.requests.find((r) => r.method === "Page.captureScreenshot");
187
+ expect(req?.params).toEqual({ format: "jpeg", quality: 80 });
188
+ } finally {
189
+ await fake.close();
190
+ }
191
+ });
192
+
193
+ it("format: 'png' + quality → quality is dropped (PNG has no quality knob)", async () => {
194
+ const fake = makeFakeRouter();
195
+ try {
196
+ fake.on("Page.captureScreenshot", () => ({ data: "AA==" }));
197
+ const page = makePage(fake);
198
+ await page.screenshot({ format: "png", quality: 80 });
199
+
200
+ const req = fake.requests.find((r) => r.method === "Page.captureScreenshot");
201
+ expect(req?.params).toEqual({ format: "png" });
202
+ } finally {
203
+ await fake.close();
204
+ }
205
+ });
206
+
207
+ it("clip without scale → defaults scale to 1", async () => {
208
+ const fake = makeFakeRouter();
209
+ try {
210
+ fake.on("Page.captureScreenshot", () => ({ data: "AA==" }));
211
+ const page = makePage(fake);
212
+ await page.screenshot({ clip: { x: 10, y: 20, width: 100, height: 50 } });
213
+
214
+ const req = fake.requests.find((r) => r.method === "Page.captureScreenshot");
215
+ expect(req?.params).toEqual({
216
+ format: "png",
217
+ clip: { x: 10, y: 20, width: 100, height: 50, scale: 1 },
218
+ });
219
+ } finally {
220
+ await fake.close();
221
+ }
222
+ });
223
+
224
+ it("clip with explicit scale is preserved", async () => {
225
+ const fake = makeFakeRouter();
226
+ try {
227
+ fake.on("Page.captureScreenshot", () => ({ data: "AA==" }));
228
+ const page = makePage(fake);
229
+ await page.screenshot({ clip: { x: 0, y: 0, width: 50, height: 50, scale: 2 } });
230
+
231
+ const req = fake.requests.find((r) => r.method === "Page.captureScreenshot");
232
+ expect(req?.params).toEqual({
233
+ format: "png",
234
+ clip: { x: 0, y: 0, width: 50, height: 50, scale: 2 },
235
+ });
236
+ } finally {
237
+ await fake.close();
238
+ }
239
+ });
240
+
241
+ it("omitBackground passes through to params", async () => {
242
+ const fake = makeFakeRouter();
243
+ try {
244
+ fake.on("Page.captureScreenshot", () => ({ data: "AA==" }));
245
+ const page = makePage(fake);
246
+ await page.screenshot({ omitBackground: true });
247
+
248
+ const req = fake.requests.find((r) => r.method === "Page.captureScreenshot");
249
+ expect(req?.params).toEqual({ format: "png", omitBackground: true });
250
+ } finally {
251
+ await fake.close();
252
+ }
253
+ });
254
+
255
+ it("fullPage: true → getLayoutMetrics + setDeviceMetricsOverride + capture + clearDeviceMetricsOverride", async () => {
256
+ const fake = makeFakeRouter();
257
+ try {
258
+ fake.on("Page.getLayoutMetrics", () => ({
259
+ contentSize: { width: 1280, height: 4321 },
260
+ layoutViewport: { clientWidth: 1280, clientHeight: 800 },
261
+ }));
262
+ fake.on("Emulation.setDeviceMetricsOverride", () => ({}));
263
+ fake.on("Page.captureScreenshot", () => ({ data: "AA==" }));
264
+ fake.on("Emulation.clearDeviceMetricsOverride", () => ({}));
265
+
266
+ const page = makePage(fake);
267
+ await page.screenshot({ fullPage: true });
268
+
269
+ const methods = fake.requests.map((r) => r.method);
270
+ // The four CDP methods must appear in this exact relative order.
271
+ const idxMetrics = methods.indexOf("Page.getLayoutMetrics");
272
+ const idxOverride = methods.indexOf("Emulation.setDeviceMetricsOverride");
273
+ const idxCapture = methods.indexOf("Page.captureScreenshot");
274
+ const idxClear = methods.indexOf("Emulation.clearDeviceMetricsOverride");
275
+ expect(idxMetrics).toBeGreaterThanOrEqual(0);
276
+ expect(idxOverride).toBeGreaterThan(idxMetrics);
277
+ expect(idxCapture).toBeGreaterThan(idxOverride);
278
+ expect(idxClear).toBeGreaterThan(idxCapture);
279
+
280
+ const overrideReq = fake.requests[idxOverride];
281
+ expect(overrideReq?.params).toEqual({
282
+ width: 1280,
283
+ height: 4321,
284
+ deviceScaleFactor: 0,
285
+ mobile: false,
286
+ });
287
+
288
+ // captureBeyondViewport must be set on the capture call so the renderer
289
+ // paints past the visible area for the duration of the capture.
290
+ const captureReq = fake.requests[idxCapture];
291
+ expect(captureReq?.params).toEqual({
292
+ format: "png",
293
+ captureBeyondViewport: true,
294
+ });
295
+ } finally {
296
+ await fake.close();
297
+ }
298
+ });
299
+
300
+ it("fullPage cleanup: clearDeviceMetricsOverride runs even when capture rejects", async () => {
301
+ const fake = makeFakeRouter();
302
+ try {
303
+ fake.on("Page.getLayoutMetrics", () => ({
304
+ contentSize: { width: 1280, height: 4321 },
305
+ layoutViewport: { clientWidth: 1280, clientHeight: 800 },
306
+ }));
307
+ fake.on("Emulation.setDeviceMetricsOverride", () => ({}));
308
+ fake.on("Page.captureScreenshot", () => ({ __error: "Target detached mid-capture" }));
309
+ fake.on("Emulation.clearDeviceMetricsOverride", () => ({}));
310
+
311
+ const page = makePage(fake);
312
+ let caught: unknown;
313
+ try {
314
+ await page.screenshot({ fullPage: true });
315
+ } catch (err) {
316
+ caught = err;
317
+ }
318
+ expect(caught).toBeDefined();
319
+
320
+ const methods = fake.requests.map((r) => r.method);
321
+ // Despite the capture rejection, the clear must still have been sent.
322
+ expect(methods).toContain("Emulation.clearDeviceMetricsOverride");
323
+ } finally {
324
+ await fake.close();
325
+ }
326
+ });
327
+
328
+ it("fullPage + clip → clip wins, no device-metrics round-trip", async () => {
329
+ const fake = makeFakeRouter();
330
+ try {
331
+ fake.on("Page.captureScreenshot", () => ({ data: "AA==" }));
332
+ const page = makePage(fake);
333
+ await page.screenshot({ fullPage: true, clip: { x: 0, y: 0, width: 10, height: 10 } });
334
+
335
+ const methods = fake.requests.map((r) => r.method);
336
+ expect(methods).not.toContain("Page.getLayoutMetrics");
337
+ expect(methods).not.toContain("Emulation.setDeviceMetricsOverride");
338
+ expect(methods).toContain("Page.captureScreenshot");
339
+ } finally {
340
+ await fake.close();
341
+ }
342
+ });
343
+
344
+ it("rejects after the page has been closed", async () => {
345
+ const fake = makeFakeRouter();
346
+ try {
347
+ fake.on("Target.closeTarget", () => ({ success: true }));
348
+ const page = makePage(fake);
349
+ await page.close();
350
+
351
+ let caught: unknown;
352
+ try {
353
+ await page.screenshot();
354
+ } catch (err) {
355
+ caught = err;
356
+ }
357
+ expect(caught).toBeDefined();
358
+ expect((caught as Error).message).toMatch(/page is closed/);
359
+ } finally {
360
+ await fake.close();
361
+ }
362
+ });
363
+ });
@@ -22,7 +22,6 @@
22
22
  *
23
23
  * Gated by `MOCHI_E2E=1`. Set `MOCHI_CHROMIUM_PATH` to a real binary.
24
24
  *
25
- * @see tasks/0252-window-size-flag-from-matrix.md
26
25
  * @see UDC `__init__.py:410-411`, UDC issue #2242
27
26
  */
28
27