@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.
- package/README.md +19 -10
- package/package.json +4 -4
- package/src/__tests__/cookies-jar.test.ts +361 -0
- package/src/__tests__/default-profile.test.ts +181 -0
- package/src/__tests__/dx-cluster.e2e.test.ts +245 -0
- package/src/__tests__/init-injector.e2e.test.ts +144 -0
- package/src/__tests__/init-injector.test.ts +249 -0
- package/src/__tests__/inject.test.ts +80 -164
- package/src/__tests__/page-dx-cluster.test.ts +292 -0
- package/src/__tests__/proc-linux-server.test.ts +243 -0
- package/src/__tests__/proxy-auth.test.ts +22 -55
- package/src/__tests__/screenshot.e2e.test.ts +126 -0
- package/src/__tests__/screenshot.test.ts +363 -0
- package/src/cdp/init-injector.ts +644 -0
- package/src/default-profile.ts +112 -0
- package/src/index.ts +33 -1
- package/src/launch.ts +199 -10
- package/src/linux-server.ts +157 -0
- package/src/page.ts +410 -8
- package/src/proc.ts +48 -5
- package/src/proxy-auth.ts +26 -107
- package/src/session.ts +367 -68
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `Page.screenshot` (task 0265).
|
|
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
|
+
});
|