@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.
- package/README.md +19 -10
- package/package.json +5 -6
- package/src/__tests__/cookies-jar.test.ts +360 -0
- package/src/__tests__/default-profile.test.ts +179 -0
- package/src/__tests__/dx-cluster.e2e.test.ts +244 -0
- package/src/__tests__/geo-consistency.test.ts +0 -1
- package/src/__tests__/geo-probe.test.ts +13 -13
- package/src/__tests__/init-injector.e2e.test.ts +143 -0
- package/src/__tests__/init-injector.test.ts +248 -0
- package/src/__tests__/inject.test.ts +80 -165
- package/src/__tests__/page-dx-cluster.test.ts +291 -0
- package/src/__tests__/piercing.test.ts +1 -1
- package/src/__tests__/proc-linux-server.test.ts +243 -0
- package/src/__tests__/proc.test.ts +3 -3
- package/src/__tests__/proxy-auth.test.ts +22 -56
- package/src/__tests__/screenshot.e2e.test.ts +126 -0
- package/src/__tests__/screenshot.test.ts +363 -0
- package/src/__tests__/window-size.e2e.test.ts +0 -1
- package/src/cdp/init-injector.ts +644 -0
- package/src/cdp/types.ts +0 -1
- package/src/default-profile.ts +110 -0
- package/src/geo-consistency.ts +0 -1
- package/src/geo-probe.ts +37 -32
- package/src/index.ts +33 -1
- package/src/launch.ts +225 -50
- package/src/linux-server.ts +157 -0
- package/src/page/element-handle.ts +0 -1
- package/src/page/piercing.ts +0 -1
- package/src/page/selector.ts +0 -1
- package/src/page.ts +429 -10
- package/src/proc.ts +52 -10
- package/src/proxy-auth.ts +25 -108
- package/src/session.ts +846 -182
- package/src/version.ts +1 -1
|
@@ -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
|
|
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
|
-
|
|
134
|
+
pipe: FakePipe;
|
|
135
135
|
pushEvent(method: string, params: unknown): void;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
function makeRouter(): FakeRouter {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
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
|
-
|
|
146
|
+
pipe,
|
|
180
147
|
pushEvent(method: string, params: unknown): void {
|
|
181
|
-
|
|
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
|
|
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({
|
|
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
|
+
});
|