@mochi.js/core 0.0.1 → 0.1.2
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 +3 -3
- package/package.json +11 -4
- package/src/__tests__/behavioral.e2e.test.ts +200 -0
- package/src/__tests__/binary.test.ts +89 -0
- package/src/__tests__/forbidden.test.ts +80 -0
- package/src/__tests__/framer.test.ts +92 -0
- package/src/__tests__/inject.e2e.test.ts +253 -0
- package/src/__tests__/inject.test.ts +276 -0
- package/src/__tests__/integration.e2e.test.ts +60 -0
- package/src/__tests__/proxy-auth.test.ts +253 -0
- package/src/__tests__/router.test.ts +193 -0
- package/src/__tests__/smoke.test.ts +11 -5
- package/src/binary.ts +129 -0
- package/src/cdp/forbidden.ts +102 -0
- package/src/cdp/framer.ts +79 -0
- package/src/cdp/router.ts +240 -0
- package/src/cdp/transport.ts +167 -0
- package/src/cdp/types.ts +152 -0
- package/src/errors.ts +23 -0
- package/src/index.ts +46 -39
- package/src/launch.ts +282 -0
- package/src/page.ts +979 -0
- package/src/proc.ts +213 -0
- package/src/proxy-auth.ts +252 -0
- package/src/session.ts +638 -0
- package/src/version.ts +2 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `parseProxyUrl` + `installProxyAuth`.
|
|
3
|
+
*
|
|
4
|
+
* `parseProxyUrl`: pure function — exercises HTTP/HTTPS/SOCKS5/SOCKS4 ×
|
|
5
|
+
* with-auth/no-auth × edge cases (missing port, IPv6 host, percent-encoded
|
|
6
|
+
* creds, empty password).
|
|
7
|
+
*
|
|
8
|
+
* `installProxyAuth`: drives a fake CDP router. Verifies:
|
|
9
|
+
* - `Fetch.enable` is sent with `handleAuthRequests: true, patterns: [{ urlPattern: "*" }]`.
|
|
10
|
+
* - `Fetch.authRequired` events trigger `Fetch.continueWithAuth` carrying
|
|
11
|
+
* the configured creds.
|
|
12
|
+
* - The defensive `Fetch.requestPaused` handler issues `Fetch.continueRequest`.
|
|
13
|
+
* - `dispose()` sends `Fetch.disable` and is idempotent.
|
|
14
|
+
*
|
|
15
|
+
* @see tasks/0160-proxy-auth-and-ci-fix.md
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, expect, it } from "bun:test";
|
|
19
|
+
import { MessageRouter } from "../cdp/router";
|
|
20
|
+
import type { PipeReader, PipeWriter } from "../cdp/transport";
|
|
21
|
+
import { installProxyAuth, parseProxyUrl } from "../proxy-auth";
|
|
22
|
+
|
|
23
|
+
describe("parseProxyUrl", () => {
|
|
24
|
+
it("HTTP with user:pass — splits server + auth", () => {
|
|
25
|
+
expect(parseProxyUrl("http://user:pass@host.example:8080")).toEqual({
|
|
26
|
+
server: "http://host.example:8080",
|
|
27
|
+
auth: { username: "user", password: "pass" },
|
|
28
|
+
protocol: "http",
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("HTTPS with user:pass", () => {
|
|
33
|
+
expect(parseProxyUrl("https://u:p@proxy.tld:443")).toEqual({
|
|
34
|
+
server: "https://proxy.tld:443",
|
|
35
|
+
auth: { username: "u", password: "p" },
|
|
36
|
+
protocol: "https",
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("SOCKS5 with user:pass", () => {
|
|
41
|
+
expect(parseProxyUrl("socks5://u:p@socks.example:1080")).toEqual({
|
|
42
|
+
server: "socks5://socks.example:1080",
|
|
43
|
+
auth: { username: "u", password: "p" },
|
|
44
|
+
protocol: "socks5",
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("SOCKS4 with user (no pass) — password is empty string, not undefined", () => {
|
|
49
|
+
expect(parseProxyUrl("socks4://lone@socks.example:1080")).toEqual({
|
|
50
|
+
server: "socks4://socks.example:1080",
|
|
51
|
+
auth: { username: "lone", password: "" },
|
|
52
|
+
protocol: "socks4",
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("SOCKS5 with user only — empty password", () => {
|
|
57
|
+
expect(parseProxyUrl("socks5://lone@socks.example:1080")).toEqual({
|
|
58
|
+
server: "socks5://socks.example:1080",
|
|
59
|
+
auth: { username: "lone", password: "" },
|
|
60
|
+
protocol: "socks5",
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("HTTP with no auth — auth is undefined", () => {
|
|
65
|
+
const out = parseProxyUrl("http://host.example:8080");
|
|
66
|
+
expect(out.server).toBe("http://host.example:8080");
|
|
67
|
+
expect(out.auth).toBeUndefined();
|
|
68
|
+
expect(out.protocol).toBe("http");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("percent-encoded creds round-trip decoded", () => {
|
|
72
|
+
expect(parseProxyUrl("http://user%40domain:p%40ss@host.example:8080")).toEqual({
|
|
73
|
+
server: "http://host.example:8080",
|
|
74
|
+
auth: { username: "user@domain", password: "p@ss" },
|
|
75
|
+
protocol: "http",
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("colon in encoded password decodes", () => {
|
|
80
|
+
expect(parseProxyUrl("http://u:p%3Aass@host:8080")).toEqual({
|
|
81
|
+
server: "http://host:8080",
|
|
82
|
+
auth: { username: "u", password: "p:ass" },
|
|
83
|
+
protocol: "http",
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("IPv6 host with brackets — preserved in server URL", () => {
|
|
88
|
+
expect(parseProxyUrl("http://user:pass@[::1]:8080")).toEqual({
|
|
89
|
+
server: "http://[::1]:8080",
|
|
90
|
+
auth: { username: "user", password: "pass" },
|
|
91
|
+
protocol: "http",
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("IPv6 host without auth", () => {
|
|
96
|
+
const out = parseProxyUrl("http://[2001:db8::1]:8080");
|
|
97
|
+
expect(out.server).toBe("http://[2001:db8::1]:8080");
|
|
98
|
+
expect(out.auth).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("missing port — applies protocol default (HTTP=80)", () => {
|
|
102
|
+
expect(parseProxyUrl("http://host.example").server).toBe("http://host.example:80");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("missing port — applies protocol default (HTTPS=443)", () => {
|
|
106
|
+
expect(parseProxyUrl("https://host.example").server).toBe("https://host.example:443");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("missing port — applies protocol default (SOCKS5=1080)", () => {
|
|
110
|
+
expect(parseProxyUrl("socks5://host.example").server).toBe("socks5://host.example:1080");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("missing port — applies protocol default (SOCKS4=1080)", () => {
|
|
114
|
+
expect(parseProxyUrl("socks4://host.example").server).toBe("socks4://host.example:1080");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("rejects unsupported protocol", () => {
|
|
118
|
+
expect(() => parseProxyUrl("ftp://host:21")).toThrow(/unsupported proxy protocol/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("rejects malformed URL", () => {
|
|
122
|
+
expect(() => parseProxyUrl("not a url")).toThrow(/invalid proxy URL/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("uppercase protocol normalizes to lowercase", () => {
|
|
126
|
+
expect(parseProxyUrl("HTTP://host.example:8080").protocol).toBe("http");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ---- installProxyAuth -------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
interface FakeRouter {
|
|
133
|
+
router: MessageRouter;
|
|
134
|
+
written: { method: string; params?: unknown }[];
|
|
135
|
+
pushEvent(method: string, params: unknown): void;
|
|
136
|
+
}
|
|
137
|
+
|
|
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);
|
|
175
|
+
router.start();
|
|
176
|
+
const enc = new TextEncoder();
|
|
177
|
+
return {
|
|
178
|
+
router,
|
|
179
|
+
written,
|
|
180
|
+
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);
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
describe("installProxyAuth", () => {
|
|
191
|
+
it("sends Fetch.enable with handleAuthRequests:true and empty patterns", async () => {
|
|
192
|
+
const f = makeRouter();
|
|
193
|
+
const handle = await installProxyAuth(f.router, { username: "u", password: "p" });
|
|
194
|
+
const enable = f.written.find((c) => c.method === "Fetch.enable");
|
|
195
|
+
expect(enable).toBeDefined();
|
|
196
|
+
expect(enable?.params).toEqual({ handleAuthRequests: true, patterns: [{ urlPattern: "*" }] });
|
|
197
|
+
await handle.dispose();
|
|
198
|
+
await f.router.close();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("answers Fetch.authRequired with Fetch.continueWithAuth(ProvideCredentials)", async () => {
|
|
202
|
+
const f = makeRouter();
|
|
203
|
+
const handle = await installProxyAuth(f.router, { username: "alice", password: "s3cret" });
|
|
204
|
+
f.pushEvent("Fetch.authRequired", { requestId: "req-42", authChallenge: { source: "Proxy" } });
|
|
205
|
+
// Allow microtasks + the writer push to flush.
|
|
206
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
207
|
+
const reply = f.written.find((c) => c.method === "Fetch.continueWithAuth");
|
|
208
|
+
expect(reply).toBeDefined();
|
|
209
|
+
expect(reply?.params).toEqual({
|
|
210
|
+
requestId: "req-42",
|
|
211
|
+
authChallengeResponse: {
|
|
212
|
+
response: "ProvideCredentials",
|
|
213
|
+
username: "alice",
|
|
214
|
+
password: "s3cret",
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
await handle.dispose();
|
|
218
|
+
await f.router.close();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("forwards Fetch.requestPaused via Fetch.continueRequest (defensive handler)", async () => {
|
|
222
|
+
const f = makeRouter();
|
|
223
|
+
const handle = await installProxyAuth(f.router, { username: "u", password: "p" });
|
|
224
|
+
f.pushEvent("Fetch.requestPaused", { requestId: "rp-1" });
|
|
225
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
226
|
+
const reply = f.written.find((c) => c.method === "Fetch.continueRequest");
|
|
227
|
+
expect(reply).toBeDefined();
|
|
228
|
+
expect(reply?.params).toEqual({ requestId: "rp-1" });
|
|
229
|
+
await handle.dispose();
|
|
230
|
+
await f.router.close();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("dispose() sends Fetch.disable and is idempotent", async () => {
|
|
234
|
+
const f = makeRouter();
|
|
235
|
+
const handle = await installProxyAuth(f.router, { username: "u", password: "p" });
|
|
236
|
+
await handle.dispose();
|
|
237
|
+
await handle.dispose();
|
|
238
|
+
const disables = f.written.filter((c) => c.method === "Fetch.disable");
|
|
239
|
+
expect(disables.length).toBe(1);
|
|
240
|
+
await f.router.close();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("after dispose, further authRequired events do not produce continueWithAuth", async () => {
|
|
244
|
+
const f = makeRouter();
|
|
245
|
+
const handle = await installProxyAuth(f.router, { username: "u", password: "p" });
|
|
246
|
+
await handle.dispose();
|
|
247
|
+
f.pushEvent("Fetch.authRequired", { requestId: "late" });
|
|
248
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
249
|
+
const replies = f.written.filter((c) => c.method === "Fetch.continueWithAuth");
|
|
250
|
+
expect(replies.length).toBe(0);
|
|
251
|
+
await f.router.close();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `MessageRouter`. Use a fake transport (no Bun.spawn / no
|
|
3
|
+
* Chromium) — we simulate inbound frames by calling the listener directly.
|
|
4
|
+
*
|
|
5
|
+
* Coverage targets:
|
|
6
|
+
* - request/response correlation by id
|
|
7
|
+
* - error responses surface as CdpRemoteError
|
|
8
|
+
* - timeouts surface as CdpTimeoutError
|
|
9
|
+
* - event subscriptions: on, once, off
|
|
10
|
+
* - close rejects pending calls with BrowserCrashedError
|
|
11
|
+
* - ForbiddenCdpMethodError surfaces synchronously through `.send()`
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, it } from "bun:test";
|
|
15
|
+
import { ForbiddenCdpMethodError } from "../cdp/forbidden";
|
|
16
|
+
import { BrowserCrashedError, CdpRemoteError, CdpTimeoutError, MessageRouter } from "../cdp/router";
|
|
17
|
+
import type { PipeReader, PipeWriter } from "../cdp/transport";
|
|
18
|
+
|
|
19
|
+
interface FakeTransport {
|
|
20
|
+
router: MessageRouter;
|
|
21
|
+
/** All bytes the router has written, decoded as JSON-RPC objects. */
|
|
22
|
+
written: unknown[];
|
|
23
|
+
/** Inject one inbound JSON frame (router parses + dispatches). */
|
|
24
|
+
push(json: unknown): void;
|
|
25
|
+
/** Simulate browser exit / pipe close. */
|
|
26
|
+
closeFromBrowser(reason?: Error): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeRouter(opts: { defaultTimeoutMs?: number } = {}): FakeTransport {
|
|
30
|
+
const written: unknown[] = [];
|
|
31
|
+
// The fake reader never produces data on its own — we drive the router by
|
|
32
|
+
// calling the framer-bypass pathway via the transport's onFrame listener.
|
|
33
|
+
// To do that we re-construct: keep a reference to the listener, then create
|
|
34
|
+
// the router with a stream that we control.
|
|
35
|
+
let pumpController: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
36
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
37
|
+
start(controller) {
|
|
38
|
+
pumpController = controller;
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
const reader: PipeReader = {
|
|
42
|
+
getReader: () => stream.getReader(),
|
|
43
|
+
};
|
|
44
|
+
const writer: PipeWriter = {
|
|
45
|
+
write: (chunk) => {
|
|
46
|
+
// Strip trailing NUL and decode.
|
|
47
|
+
const u8 = chunk;
|
|
48
|
+
const last = u8[u8.length - 1] === 0 ? u8.length - 1 : u8.length;
|
|
49
|
+
const json = new TextDecoder().decode(u8.subarray(0, last));
|
|
50
|
+
try {
|
|
51
|
+
written.push(JSON.parse(json));
|
|
52
|
+
} catch {
|
|
53
|
+
written.push(json);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
flush: () => undefined,
|
|
57
|
+
end: () => undefined,
|
|
58
|
+
};
|
|
59
|
+
const router = new MessageRouter(reader, writer, opts);
|
|
60
|
+
router.start();
|
|
61
|
+
const enc = new TextEncoder();
|
|
62
|
+
return {
|
|
63
|
+
router,
|
|
64
|
+
written,
|
|
65
|
+
push(obj: unknown) {
|
|
66
|
+
const bytes = enc.encode(JSON.stringify(obj));
|
|
67
|
+
const out = new Uint8Array(bytes.length + 1);
|
|
68
|
+
out.set(bytes, 0);
|
|
69
|
+
out[bytes.length] = 0;
|
|
70
|
+
pumpController?.enqueue(out);
|
|
71
|
+
},
|
|
72
|
+
async closeFromBrowser(reason?: Error) {
|
|
73
|
+
pumpController?.close();
|
|
74
|
+
await router.close(reason);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("MessageRouter", () => {
|
|
80
|
+
it("correlates a response by id and resolves with `result`", async () => {
|
|
81
|
+
const t = makeRouter();
|
|
82
|
+
const p = t.router.send<{ ok: true }>("Page.enable");
|
|
83
|
+
// The router assigned id=1 (first send).
|
|
84
|
+
t.push({ id: 1, result: { ok: true } });
|
|
85
|
+
const result = await p;
|
|
86
|
+
expect(result).toEqual({ ok: true });
|
|
87
|
+
expect((t.written[0] as { method: string }).method).toBe("Page.enable");
|
|
88
|
+
await t.closeFromBrowser();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("rejects with CdpRemoteError on a CDP error response", async () => {
|
|
92
|
+
const t = makeRouter();
|
|
93
|
+
const p = t.router.send("Page.navigate", { url: "bad" });
|
|
94
|
+
t.push({ id: 1, error: { code: -32000, message: "Cannot navigate" } });
|
|
95
|
+
let caught: unknown;
|
|
96
|
+
try {
|
|
97
|
+
await p;
|
|
98
|
+
} catch (err) {
|
|
99
|
+
caught = err;
|
|
100
|
+
}
|
|
101
|
+
expect(caught).toBeInstanceOf(CdpRemoteError);
|
|
102
|
+
expect((caught as CdpRemoteError).method).toBe("Page.navigate");
|
|
103
|
+
expect((caught as CdpRemoteError).code).toBe(-32000);
|
|
104
|
+
await t.closeFromBrowser();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("rejects with CdpTimeoutError after the deadline", async () => {
|
|
108
|
+
const t = makeRouter({ defaultTimeoutMs: 50 });
|
|
109
|
+
let caught: unknown;
|
|
110
|
+
try {
|
|
111
|
+
await t.router.send("Slow.method");
|
|
112
|
+
} catch (err) {
|
|
113
|
+
caught = err;
|
|
114
|
+
}
|
|
115
|
+
expect(caught).toBeInstanceOf(CdpTimeoutError);
|
|
116
|
+
expect((caught as CdpTimeoutError).method).toBe("Slow.method");
|
|
117
|
+
await t.closeFromBrowser();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("dispatches events to on() handlers; supports unsubscribe", async () => {
|
|
121
|
+
const t = makeRouter();
|
|
122
|
+
const seen: unknown[] = [];
|
|
123
|
+
const off = t.router.on("Page.frameNavigated", (params) => seen.push(params));
|
|
124
|
+
t.push({ method: "Page.frameNavigated", params: { frame: { id: "f1" } } });
|
|
125
|
+
t.push({ method: "Page.frameNavigated", params: { frame: { id: "f2" } } });
|
|
126
|
+
await new Promise<void>((r) => setTimeout(r, 5));
|
|
127
|
+
off();
|
|
128
|
+
t.push({ method: "Page.frameNavigated", params: { frame: { id: "f3" } } });
|
|
129
|
+
await new Promise<void>((r) => setTimeout(r, 5));
|
|
130
|
+
expect(seen).toHaveLength(2);
|
|
131
|
+
await t.closeFromBrowser();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("once() handlers fire exactly once", async () => {
|
|
135
|
+
const t = makeRouter();
|
|
136
|
+
let count = 0;
|
|
137
|
+
t.router.once("Page.loadEventFired", () => {
|
|
138
|
+
count++;
|
|
139
|
+
});
|
|
140
|
+
t.push({ method: "Page.loadEventFired", params: {} });
|
|
141
|
+
t.push({ method: "Page.loadEventFired", params: {} });
|
|
142
|
+
await new Promise<void>((r) => setTimeout(r, 5));
|
|
143
|
+
expect(count).toBe(1);
|
|
144
|
+
await t.closeFromBrowser();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("rejects pending calls with BrowserCrashedError when transport closes", async () => {
|
|
148
|
+
const t = makeRouter();
|
|
149
|
+
const p = t.router.send("Page.enable");
|
|
150
|
+
await t.closeFromBrowser();
|
|
151
|
+
let caught: unknown;
|
|
152
|
+
try {
|
|
153
|
+
await p;
|
|
154
|
+
} catch (err) {
|
|
155
|
+
caught = err;
|
|
156
|
+
}
|
|
157
|
+
expect(caught).toBeInstanceOf(BrowserCrashedError);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("forbidden methods reject through .send() with ForbiddenCdpMethodError", async () => {
|
|
161
|
+
const t = makeRouter();
|
|
162
|
+
let caught: unknown;
|
|
163
|
+
try {
|
|
164
|
+
await t.router.send("Runtime.enable");
|
|
165
|
+
} catch (err) {
|
|
166
|
+
caught = err;
|
|
167
|
+
}
|
|
168
|
+
expect(caught).toBeInstanceOf(ForbiddenCdpMethodError);
|
|
169
|
+
|
|
170
|
+
let caught2: unknown;
|
|
171
|
+
try {
|
|
172
|
+
await t.router.send("Page.createIsolatedWorld", { frameId: "x" });
|
|
173
|
+
} catch (err) {
|
|
174
|
+
caught2 = err;
|
|
175
|
+
}
|
|
176
|
+
expect(caught2).toBeInstanceOf(ForbiddenCdpMethodError);
|
|
177
|
+
|
|
178
|
+
let caught3: unknown;
|
|
179
|
+
try {
|
|
180
|
+
await t.router.send("Runtime.evaluate", {
|
|
181
|
+
expression: "1+1",
|
|
182
|
+
includeCommandLineAPI: true,
|
|
183
|
+
});
|
|
184
|
+
} catch (err) {
|
|
185
|
+
caught3 = err;
|
|
186
|
+
}
|
|
187
|
+
expect(caught3).toBeInstanceOf(ForbiddenCdpMethodError);
|
|
188
|
+
|
|
189
|
+
// None of those should have actually written anything to the transport.
|
|
190
|
+
expect(t.written).toEqual([]);
|
|
191
|
+
await t.closeFromBrowser();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import { mochi, NotImplementedError, VERSION } from "../index";
|
|
2
|
+
import { mochi, NotImplementedError, Session, VERSION } from "../index";
|
|
3
3
|
|
|
4
|
-
describe("@mochi.js/core (
|
|
4
|
+
describe("@mochi.js/core (phase 0.1)", () => {
|
|
5
5
|
it("exports a VERSION string", () => {
|
|
6
6
|
expect(typeof VERSION).toBe("string");
|
|
7
7
|
expect(VERSION).toMatch(/^\d+\.\d+\.\d+/);
|
|
@@ -13,8 +13,14 @@ describe("@mochi.js/core (claim release)", () => {
|
|
|
13
13
|
expect(mochi.version).toBe(VERSION);
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
it("
|
|
17
|
-
expect(
|
|
18
|
-
|
|
16
|
+
it("re-exports the Session class as a constructor", () => {
|
|
17
|
+
expect(typeof Session).toBe("function");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("exports NotImplementedError for placeholder surfaces", () => {
|
|
21
|
+
const err = new NotImplementedError("page.humanClick");
|
|
22
|
+
expect(err).toBeInstanceOf(Error);
|
|
23
|
+
expect(err.name).toBe("NotImplementedError");
|
|
24
|
+
expect(err.api).toBe("page.humanClick");
|
|
19
25
|
});
|
|
20
26
|
});
|
package/src/binary.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chromium binary resolution.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order (per task brief 0011):
|
|
5
|
+
* 1. `LaunchOptions.binary` (explicit override)
|
|
6
|
+
* 2. `process.env.MOCHI_CHROMIUM_PATH`
|
|
7
|
+
* 3. `@mochi.js/cli`'s `resolveChromiumBinary()` if installed
|
|
8
|
+
* 4. error with actionable message
|
|
9
|
+
*
|
|
10
|
+
* The cli import is dynamic + lazy; absence of `@mochi.js/cli` (or absence of
|
|
11
|
+
* the `resolveChromiumBinary` export — it lands in task 0010) is non-fatal.
|
|
12
|
+
*
|
|
13
|
+
* @see PLAN.md §5.1 / §8 / §15
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Thrown when no Chromium binary can be found via any resolution path.
|
|
18
|
+
* The message names the exact remediation steps.
|
|
19
|
+
*/
|
|
20
|
+
export class ChromiumNotFoundError extends Error {
|
|
21
|
+
constructor() {
|
|
22
|
+
super(
|
|
23
|
+
"[mochi] could not locate a Chromium binary.\n" +
|
|
24
|
+
" Resolution order: LaunchOptions.binary > MOCHI_CHROMIUM_PATH env > @mochi.js/cli " +
|
|
25
|
+
"resolveChromiumBinary().\n" +
|
|
26
|
+
" Fix: either\n" +
|
|
27
|
+
" • install Chromium-for-Testing via `mochi browsers install` (lands in phase 0.11), or\n" +
|
|
28
|
+
" • set MOCHI_CHROMIUM_PATH to a stock Chromium binary, or\n" +
|
|
29
|
+
' • pass `binary: "/path/to/chromium"` to mochi.launch().',
|
|
30
|
+
);
|
|
31
|
+
this.name = "ChromiumNotFoundError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The shape `@mochi.js/cli`'s `resolveChromiumBinary` returns (per task 0010).
|
|
37
|
+
* Defined inline so we don't take a hard dep on the cli package's type surface.
|
|
38
|
+
*
|
|
39
|
+
* The function returns `{ path, channel, version, platform }` on success; it
|
|
40
|
+
* THROWS when no install is found (with a friendly "run `mochi browsers
|
|
41
|
+
* install`" message). We wrap in try/catch and treat both throw + missing
|
|
42
|
+
* symbol as "not resolvable from cli", letting the caller surface the
|
|
43
|
+
* canonical {@link ChromiumNotFoundError}.
|
|
44
|
+
*
|
|
45
|
+
* Earlier versions of this binding incorrectly typed the return as
|
|
46
|
+
* `string | null` and discarded the resolved object — the integration broke
|
|
47
|
+
* silently on every host where MOCHI_CHROMIUM_PATH wasn't set (local M4 with
|
|
48
|
+
* the env var set masked it; CI without it caught it). Pinned by
|
|
49
|
+
* `tests/contract/core-cli-binary-resolution.contract.test.ts`.
|
|
50
|
+
*/
|
|
51
|
+
type CliResolveResult = {
|
|
52
|
+
readonly path: string;
|
|
53
|
+
readonly channel?: string;
|
|
54
|
+
readonly version?: string;
|
|
55
|
+
readonly platform?: string;
|
|
56
|
+
};
|
|
57
|
+
type CliResolveFn = (
|
|
58
|
+
opts?: Record<string, unknown>,
|
|
59
|
+
) => Promise<CliResolveResult> | CliResolveResult;
|
|
60
|
+
|
|
61
|
+
async function tryCliResolve(): Promise<string | null> {
|
|
62
|
+
try {
|
|
63
|
+
// @mochi.js/cli is a lazy, optional dependency at v0.1: task 0010 hasn't
|
|
64
|
+
// necessarily landed in every consumer's lockfile, and `resolveChromiumBinary`
|
|
65
|
+
// is a forward-looking export. Importing dynamically + catching keeps the
|
|
66
|
+
// env-var path working when the cli isn't installed.
|
|
67
|
+
// @ts-expect-error — optional peer; resolved at runtime if present.
|
|
68
|
+
const mod = (await import("@mochi.js/cli")) as Record<string, unknown>;
|
|
69
|
+
const fn = mod.resolveChromiumBinary;
|
|
70
|
+
if (typeof fn !== "function") return null;
|
|
71
|
+
const result = await (fn as CliResolveFn)();
|
|
72
|
+
if (
|
|
73
|
+
result !== null &&
|
|
74
|
+
typeof result === "object" &&
|
|
75
|
+
typeof result.path === "string" &&
|
|
76
|
+
result.path.length > 0
|
|
77
|
+
) {
|
|
78
|
+
return result.path;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
} catch {
|
|
82
|
+
// cli not installed, the symbol's not exported yet, OR resolveChromiumBinary
|
|
83
|
+
// threw because no install is registered — all flow to the canonical
|
|
84
|
+
// ChromiumNotFoundError below. The thrown error from the cli is
|
|
85
|
+
// intentionally NOT propagated — the core's error message is more
|
|
86
|
+
// actionable in this layer.
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
92
|
+
try {
|
|
93
|
+
return await Bun.file(path).exists();
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve a Chromium binary path. Returns the first working candidate; throws
|
|
101
|
+
* {@link ChromiumNotFoundError} if every option is empty.
|
|
102
|
+
*
|
|
103
|
+
* @param explicit `LaunchOptions.binary` from the user, if any.
|
|
104
|
+
*/
|
|
105
|
+
export async function resolveBinary(explicit?: string): Promise<string> {
|
|
106
|
+
if (explicit !== undefined && explicit.length > 0) {
|
|
107
|
+
if (!(await pathExists(explicit))) {
|
|
108
|
+
throw new Error(`[mochi] LaunchOptions.binary points to a non-existent path: ${explicit}`);
|
|
109
|
+
}
|
|
110
|
+
return explicit;
|
|
111
|
+
}
|
|
112
|
+
const fromEnv = process.env.MOCHI_CHROMIUM_PATH;
|
|
113
|
+
if (typeof fromEnv === "string" && fromEnv.length > 0) {
|
|
114
|
+
if (!(await pathExists(fromEnv))) {
|
|
115
|
+
throw new Error(`[mochi] MOCHI_CHROMIUM_PATH points to a non-existent path: ${fromEnv}`);
|
|
116
|
+
}
|
|
117
|
+
return fromEnv;
|
|
118
|
+
}
|
|
119
|
+
const fromCli = await tryCliResolve();
|
|
120
|
+
if (fromCli !== null) {
|
|
121
|
+
if (!(await pathExists(fromCli))) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`[mochi] @mochi.js/cli resolveChromiumBinary returned a non-existent path: ${fromCli}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return fromCli;
|
|
127
|
+
}
|
|
128
|
+
throw new ChromiumNotFoundError();
|
|
129
|
+
}
|