@mochi.js/core 0.2.2 → 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__/geo-consistency.test.ts +277 -0
- package/src/__tests__/geo-probe.test.ts +415 -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 -162
- package/src/__tests__/integration.e2e.test.ts +24 -0
- 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/geo-consistency.ts +343 -0
- package/src/geo-probe.ts +603 -0
- package/src/index.ts +43 -1
- package/src/launch.ts +277 -17
- package/src/linux-server.ts +157 -0
- package/src/page.ts +420 -9
- package/src/proc.ts +48 -5
- package/src/proxy-auth.ts +26 -107
- package/src/session.ts +595 -78
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the init-injector building blocks (task 0266).
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - {@link rewriteCsp}: no-nonce / nonce / strict-dynamic / unsafe-inline
|
|
6
|
+
* idempotence / multiple directives.
|
|
7
|
+
* - {@link rewriteHeaders}: header-name case insensitive, content-length
|
|
8
|
+
* stripped, CSP and CSP-Report-Only both rewritten.
|
|
9
|
+
* - {@link rewriteMetaCsp}: HTML meta-tag rewriting + entity round-trip.
|
|
10
|
+
* - {@link injectIntoHead}: script splice ahead of first non-comment
|
|
11
|
+
* `<script>`; script-tag attributes (no defer/async/module).
|
|
12
|
+
* - {@link wrapSelfRemovingPayload}: first statement is the self-remove +
|
|
13
|
+
* marker; the inner payload is left intact.
|
|
14
|
+
*
|
|
15
|
+
* @see tasks/0266-fetch-fulfill-init-script.md
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, expect, it } from "bun:test";
|
|
19
|
+
import {
|
|
20
|
+
injectIntoHead,
|
|
21
|
+
MOCHI_INIT_MARKER,
|
|
22
|
+
MOCHI_INIT_SCRIPT_CLASS,
|
|
23
|
+
rewriteCsp,
|
|
24
|
+
rewriteHeaders,
|
|
25
|
+
rewriteMetaCsp,
|
|
26
|
+
wrapSelfRemovingPayload,
|
|
27
|
+
} from "../cdp/init-injector";
|
|
28
|
+
|
|
29
|
+
describe("rewriteCsp", () => {
|
|
30
|
+
it("no nonce, no unsafe-inline → adds 'unsafe-inline' to script-src", () => {
|
|
31
|
+
const out = rewriteCsp("script-src 'self' https://cdn.example.com");
|
|
32
|
+
expect(out.value).toContain("script-src 'self' https://cdn.example.com 'unsafe-inline'");
|
|
33
|
+
expect(out.nonce).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("with-nonce → leaves directive intact and returns nonce string", () => {
|
|
37
|
+
const out = rewriteCsp("script-src 'self' 'nonce-abc123XYZ'");
|
|
38
|
+
expect(out.value).toBe("script-src 'self' 'nonce-abc123XYZ'");
|
|
39
|
+
expect(out.nonce).toBe("abc123XYZ");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("strict-dynamic + nonce → leaves directive intact and surfaces nonce", () => {
|
|
43
|
+
const out = rewriteCsp("script-src 'strict-dynamic' 'nonce-XYZ' 'unsafe-eval'");
|
|
44
|
+
expect(out.value).toBe("script-src 'strict-dynamic' 'nonce-XYZ' 'unsafe-eval'");
|
|
45
|
+
expect(out.nonce).toBe("XYZ");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("strict-dynamic without nonce → falls through to unsafe-inline (best-effort)", () => {
|
|
49
|
+
const out = rewriteCsp("script-src 'strict-dynamic'");
|
|
50
|
+
expect(out.value).toContain("'unsafe-inline'");
|
|
51
|
+
expect(out.nonce).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("already has 'unsafe-inline' → idempotent (does not double-add)", () => {
|
|
55
|
+
const out = rewriteCsp("script-src 'self' 'unsafe-inline'");
|
|
56
|
+
expect(out.value).toBe("script-src 'self' 'unsafe-inline'");
|
|
57
|
+
expect(out.value.match(/'unsafe-inline'/g)?.length).toBe(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("multiple directives → only script-src/script-src-elem/default-src mutated", () => {
|
|
61
|
+
const out = rewriteCsp(
|
|
62
|
+
"default-src 'self'; img-src https:; script-src 'self'; style-src 'self'",
|
|
63
|
+
);
|
|
64
|
+
expect(out.value).toContain("script-src 'self' 'unsafe-inline'");
|
|
65
|
+
expect(out.value).toContain("default-src 'self' 'unsafe-inline'");
|
|
66
|
+
expect(out.value).toContain("img-src https:");
|
|
67
|
+
expect(out.value).toContain("style-src 'self'");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("script-src-elem also gets relaxed", () => {
|
|
71
|
+
const out = rewriteCsp("script-src-elem 'self'");
|
|
72
|
+
expect(out.value).toContain("script-src-elem 'self' 'unsafe-inline'");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("rewriteHeaders", () => {
|
|
77
|
+
it("rewrites Content-Security-Policy and surfaces nonce", () => {
|
|
78
|
+
const out = rewriteHeaders([
|
|
79
|
+
{ name: "Content-Security-Policy", value: "script-src 'nonce-NN'" },
|
|
80
|
+
{ name: "X-Frame-Options", value: "DENY" },
|
|
81
|
+
]);
|
|
82
|
+
expect(out.scriptNonce).toBe("NN");
|
|
83
|
+
const csp = out.headers.find((h) => h.name === "Content-Security-Policy");
|
|
84
|
+
expect(csp?.value).toBe("script-src 'nonce-NN'");
|
|
85
|
+
expect(out.headers.find((h) => h.name === "X-Frame-Options")?.value).toBe("DENY");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("rewrites Content-Security-Policy-Report-Only too", () => {
|
|
89
|
+
const out = rewriteHeaders([
|
|
90
|
+
{ name: "content-security-policy-report-only", value: "script-src 'self'" },
|
|
91
|
+
]);
|
|
92
|
+
const csp = out.headers.find(
|
|
93
|
+
(h) => h.name.toLowerCase() === "content-security-policy-report-only",
|
|
94
|
+
);
|
|
95
|
+
expect(csp?.value).toContain("'unsafe-inline'");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("strips Content-Length so fulfillRequest recomputes", () => {
|
|
99
|
+
const out = rewriteHeaders([
|
|
100
|
+
{ name: "Content-Length", value: "1234" },
|
|
101
|
+
{ name: "Content-Type", value: "text/html" },
|
|
102
|
+
]);
|
|
103
|
+
expect(out.headers.some((h) => h.name.toLowerCase() === "content-length")).toBe(false);
|
|
104
|
+
expect(out.headers.some((h) => h.name === "Content-Type")).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("adopts the first nonce when multiple CSPs are present", () => {
|
|
108
|
+
const out = rewriteHeaders([
|
|
109
|
+
{ name: "Content-Security-Policy", value: "script-src 'nonce-aaa'" },
|
|
110
|
+
{ name: "Content-Security-Policy", value: "script-src 'nonce-bbb'" },
|
|
111
|
+
]);
|
|
112
|
+
expect(out.scriptNonce).toBe("aaa");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("rewriteMetaCsp", () => {
|
|
117
|
+
it("rewrites a meta tag's CSP content attribute (encoded on the wire)", () => {
|
|
118
|
+
const html = `<head><meta http-equiv="Content-Security-Policy" content="script-src 'self'"></head>`;
|
|
119
|
+
const out = rewriteMetaCsp(html);
|
|
120
|
+
// Apostrophes in attribute values round-trip through entity encoding;
|
|
121
|
+
// assert the encoded form so we capture what Chromium will actually
|
|
122
|
+
// parse back into the document.
|
|
123
|
+
expect(out.html).toContain("'unsafe-inline'");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("preserves other attribute order and unrelated meta tags", () => {
|
|
127
|
+
const html = `<head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="script-src 'self'"><meta name="viewport" content="width=device-width"></head>`;
|
|
128
|
+
const out = rewriteMetaCsp(html);
|
|
129
|
+
expect(out.html).toContain('<meta charset="utf-8">');
|
|
130
|
+
expect(out.html).toContain('<meta name="viewport"');
|
|
131
|
+
expect(out.html).toContain("'unsafe-inline'");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("extracts nonce from a meta-tag CSP", () => {
|
|
135
|
+
const html = `<meta http-equiv="Content-Security-Policy" content="script-src 'nonce-MMM'">`;
|
|
136
|
+
const out = rewriteMetaCsp(html);
|
|
137
|
+
expect(out.firstNonce).toBe("MMM");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("ignores meta tags that are NOT CSP", () => {
|
|
141
|
+
const html = `<meta http-equiv="X-UA-Compatible" content="IE=edge">`;
|
|
142
|
+
const out = rewriteMetaCsp(html);
|
|
143
|
+
expect(out.html).toBe(html);
|
|
144
|
+
expect(out.firstNonce).toBeUndefined();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("handles single-quoted attribute values too", () => {
|
|
148
|
+
const html = `<meta http-equiv='Content-Security-Policy' content='script-src \\'self\\''>`;
|
|
149
|
+
// Bun's regex is fine with the structure even though we don't decode \'
|
|
150
|
+
// here — the input shape is contrived; the production path always sees
|
|
151
|
+
// properly-escaped HTML from Chromium.
|
|
152
|
+
const out = rewriteMetaCsp(html);
|
|
153
|
+
expect(out.html).toContain("Content-Security-Policy");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("injectIntoHead", () => {
|
|
158
|
+
const SCRIPT = "console.log(1)";
|
|
159
|
+
|
|
160
|
+
it("inserts BEFORE the first non-comment <script> in head", () => {
|
|
161
|
+
const html = `<!doctype html><html><head><meta charset="utf-8"><script>window.first=true</script></head><body></body></html>`;
|
|
162
|
+
const out = injectIntoHead(html, SCRIPT, undefined);
|
|
163
|
+
const idxOurs = out.indexOf(`class="${MOCHI_INIT_SCRIPT_CLASS}"`);
|
|
164
|
+
const idxFirst = out.indexOf("window.first=true");
|
|
165
|
+
expect(idxOurs).toBeGreaterThan(-1);
|
|
166
|
+
expect(idxFirst).toBeGreaterThan(-1);
|
|
167
|
+
expect(idxOurs).toBeLessThan(idxFirst);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("ignores HTML comments — does not splice before commented-out <script>", () => {
|
|
171
|
+
const html = `<head><!-- <script>window.fake=1</script> --><script>window.real=1</script></head>`;
|
|
172
|
+
const out = injectIntoHead(html, SCRIPT, undefined);
|
|
173
|
+
const idxOurs = out.indexOf(`class="${MOCHI_INIT_SCRIPT_CLASS}"`);
|
|
174
|
+
const idxReal = out.indexOf("window.real=1");
|
|
175
|
+
const idxFake = out.indexOf("window.fake=1");
|
|
176
|
+
expect(idxOurs).toBeLessThan(idxReal);
|
|
177
|
+
// Our script must also be after the comment block — splicing in the
|
|
178
|
+
// middle of a comment would be wrong.
|
|
179
|
+
expect(idxOurs).toBeGreaterThan(idxFake);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("inserts at end-of-head when no <script> exists in head", () => {
|
|
183
|
+
const html = `<head><meta charset="utf-8"></head><body><script>window.bodyScript=1</script></body>`;
|
|
184
|
+
const out = injectIntoHead(html, SCRIPT, undefined);
|
|
185
|
+
const idxOurs = out.indexOf(`class="${MOCHI_INIT_SCRIPT_CLASS}"`);
|
|
186
|
+
const idxBody = out.indexOf("window.bodyScript=1");
|
|
187
|
+
expect(idxOurs).toBeGreaterThan(-1);
|
|
188
|
+
expect(idxOurs).toBeLessThan(idxBody);
|
|
189
|
+
// Script lands inside the head — i.e. before </head>.
|
|
190
|
+
const idxClose = out.indexOf("</head>");
|
|
191
|
+
expect(idxOurs).toBeLessThan(idxClose);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("creates a <head> when missing", () => {
|
|
195
|
+
const html = `<html><body><h1>hi</h1></body></html>`;
|
|
196
|
+
const out = injectIntoHead(html, SCRIPT, undefined);
|
|
197
|
+
expect(out).toContain("<head>");
|
|
198
|
+
expect(out).toContain(`class="${MOCHI_INIT_SCRIPT_CLASS}"`);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("does NOT add defer / async / type=module attributes (timing-critical)", () => {
|
|
202
|
+
const html = `<head></head>`;
|
|
203
|
+
const out = injectIntoHead(html, SCRIPT, undefined);
|
|
204
|
+
// The injected tag for timing-critical inject MUST be a parser-blocking
|
|
205
|
+
// classic script. The patchright finding hinges on this.
|
|
206
|
+
const tag = out.match(/<script[^>]*class="__mochi_init_script__"[^>]*>/);
|
|
207
|
+
expect(tag).not.toBeNull();
|
|
208
|
+
const tagSrc = tag?.[0] ?? "";
|
|
209
|
+
expect(tagSrc).not.toMatch(/\bdefer\b/);
|
|
210
|
+
expect(tagSrc).not.toMatch(/\basync\b/);
|
|
211
|
+
expect(tagSrc).not.toMatch(/type\s*=\s*"module"/);
|
|
212
|
+
expect(tagSrc).not.toMatch(/type\s*=\s*'module'/);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("attaches nonce attribute when supplied", () => {
|
|
216
|
+
const html = `<head></head>`;
|
|
217
|
+
const out = injectIntoHead(html, SCRIPT, "abc123");
|
|
218
|
+
expect(out).toMatch(/<script[^>]+nonce="abc123"/);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("wrapSelfRemovingPayload", () => {
|
|
223
|
+
it("first statement removes document.currentScript", () => {
|
|
224
|
+
const wrapped = wrapSelfRemovingPayload("/* payload */");
|
|
225
|
+
// Self-remove must come BEFORE the marker assignment AND before the
|
|
226
|
+
// payload — otherwise a script that throws synchronously could leave a
|
|
227
|
+
// detectable orphan node in the DOM.
|
|
228
|
+
const idxSelfRemove = wrapped.indexOf("document.currentScript");
|
|
229
|
+
const idxMarker = wrapped.indexOf(MOCHI_INIT_MARKER);
|
|
230
|
+
const idxPayload = wrapped.indexOf("/* payload */");
|
|
231
|
+
expect(idxSelfRemove).toBeGreaterThan(-1);
|
|
232
|
+
expect(idxMarker).toBeGreaterThan(-1);
|
|
233
|
+
expect(idxPayload).toBeGreaterThan(-1);
|
|
234
|
+
expect(idxSelfRemove).toBeLessThan(idxMarker);
|
|
235
|
+
expect(idxMarker).toBeLessThan(idxPayload);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("contains the post-load DOM walk (belt-and-suspenders)", () => {
|
|
239
|
+
const wrapped = wrapSelfRemovingPayload("0");
|
|
240
|
+
expect(wrapped).toContain(MOCHI_INIT_SCRIPT_CLASS);
|
|
241
|
+
expect(wrapped).toMatch(/load|complete/);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("preserves the original payload bytes intact", () => {
|
|
245
|
+
const orig = "(function(){window.x=42;})();";
|
|
246
|
+
const wrapped = wrapSelfRemovingPayload(orig);
|
|
247
|
+
expect(wrapped).toContain(orig);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -5,132 +5,24 @@
|
|
|
5
5
|
* browser reports its bare, un-spoofed fingerprint.
|
|
6
6
|
*
|
|
7
7
|
* No real Chromium process is spawned; we drive `Session` against a fake
|
|
8
|
-
* `ChromiumProcess`
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* implicitly enforces those too.
|
|
8
|
+
* `ChromiumProcess` via the shared `tests/helpers/cdp-fixture.ts` helper.
|
|
9
|
+
* The §8.2 forbidden-method assertions still gate every send through
|
|
10
|
+
* `MessageRouter`, so the test implicitly enforces those too.
|
|
12
11
|
*
|
|
13
12
|
* @see PLAN.md §12.1 — capture must run against bare Chromium.
|
|
14
13
|
* @see tasks/0040-mochi-capture.md — `bypassInject: true` requirement.
|
|
14
|
+
* @see tests/helpers/cdp-fixture.ts — shared helper consolidating fake-pipe boilerplate.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
18
18
|
import { deriveMatrix, type ProfileV1 } from "@mochi.js/consistency";
|
|
19
|
-
import
|
|
20
|
-
|
|
19
|
+
import {
|
|
20
|
+
type FakePipe,
|
|
21
|
+
fakeChromiumProcess,
|
|
22
|
+
makeFakePipe,
|
|
23
|
+
} from "../../../../tests/helpers/cdp-fixture";
|
|
21
24
|
import { Session } from "../session";
|
|
22
25
|
|
|
23
|
-
interface FakeBrowser {
|
|
24
|
-
process: ChromiumProcess;
|
|
25
|
-
/** All CDP requests written to the pipe, decoded as JSON-RPC objects. */
|
|
26
|
-
written: Array<{ id?: number; method?: string; params?: unknown; sessionId?: string }>;
|
|
27
|
-
/** Inject one inbound JSON frame (CDP response or event). */
|
|
28
|
-
push(obj: unknown): void;
|
|
29
|
-
/** Auto-respond to any request matching `methodPredicate`. Returns the unsubscribe. */
|
|
30
|
-
autoRespond(methodPredicate: (m: string) => boolean, result: unknown): void;
|
|
31
|
-
/** Resolve when the next `n` writes have completed. */
|
|
32
|
-
waitForWrites(n: number): Promise<void>;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function makeFakeBrowser(): FakeBrowser {
|
|
36
|
-
const written: FakeBrowser["written"] = [];
|
|
37
|
-
let pumpController: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
38
|
-
const stream = new ReadableStream<Uint8Array>({
|
|
39
|
-
start(c) {
|
|
40
|
-
pumpController = c;
|
|
41
|
-
},
|
|
42
|
-
});
|
|
43
|
-
const enc = new TextEncoder();
|
|
44
|
-
const dec = new TextDecoder();
|
|
45
|
-
const autoResponders: Array<{ pred: (m: string) => boolean; result: unknown }> = [];
|
|
46
|
-
const writeListeners: Array<() => void> = [];
|
|
47
|
-
|
|
48
|
-
const reader: PipeReader = {
|
|
49
|
-
getReader: () => stream.getReader(),
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
const push = (obj: unknown): void => {
|
|
53
|
-
const bytes = enc.encode(JSON.stringify(obj));
|
|
54
|
-
const out = new Uint8Array(bytes.length + 1);
|
|
55
|
-
out.set(bytes, 0);
|
|
56
|
-
out[bytes.length] = 0;
|
|
57
|
-
pumpController?.enqueue(out);
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const writer: PipeWriter = {
|
|
61
|
-
write: (chunk) => {
|
|
62
|
-
const last = chunk[chunk.length - 1] === 0 ? chunk.length - 1 : chunk.length;
|
|
63
|
-
const json = dec.decode(chunk.subarray(0, last));
|
|
64
|
-
try {
|
|
65
|
-
const parsed = JSON.parse(json) as {
|
|
66
|
-
id?: number;
|
|
67
|
-
method?: string;
|
|
68
|
-
params?: unknown;
|
|
69
|
-
sessionId?: string;
|
|
70
|
-
};
|
|
71
|
-
written.push(parsed);
|
|
72
|
-
// Notify listeners.
|
|
73
|
-
const ls = writeListeners.splice(0, writeListeners.length);
|
|
74
|
-
for (const fn of ls) fn();
|
|
75
|
-
// Auto-respond if matched.
|
|
76
|
-
if (typeof parsed.method === "string" && typeof parsed.id === "number") {
|
|
77
|
-
const r = autoResponders.find((a) => a.pred(parsed.method ?? ""));
|
|
78
|
-
if (r) {
|
|
79
|
-
queueMicrotask(() => push({ id: parsed.id, result: r.result }));
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
} catch {
|
|
83
|
-
// ignore malformed
|
|
84
|
-
}
|
|
85
|
-
},
|
|
86
|
-
flush: () => undefined,
|
|
87
|
-
end: () => undefined,
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
let resolveExit: ((code: number) => void) | undefined;
|
|
91
|
-
const exited = new Promise<number>((res) => {
|
|
92
|
-
resolveExit = res;
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
let killed = false;
|
|
96
|
-
const proc: ChromiumProcess = {
|
|
97
|
-
userDataDir: "/tmp/fake-mochi-test",
|
|
98
|
-
pid: 0,
|
|
99
|
-
exited,
|
|
100
|
-
reader,
|
|
101
|
-
writer,
|
|
102
|
-
close: async () => {
|
|
103
|
-
if (killed) return;
|
|
104
|
-
killed = true;
|
|
105
|
-
try {
|
|
106
|
-
pumpController?.close();
|
|
107
|
-
} catch {
|
|
108
|
-
// ignore
|
|
109
|
-
}
|
|
110
|
-
resolveExit?.(0);
|
|
111
|
-
},
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
return {
|
|
115
|
-
process: proc,
|
|
116
|
-
written,
|
|
117
|
-
push,
|
|
118
|
-
autoRespond(pred, result) {
|
|
119
|
-
autoResponders.push({ pred, result });
|
|
120
|
-
},
|
|
121
|
-
waitForWrites(n) {
|
|
122
|
-
if (written.length >= n) return Promise.resolve();
|
|
123
|
-
return new Promise<void>((resolve) => {
|
|
124
|
-
const check = (): void => {
|
|
125
|
-
if (written.length >= n) resolve();
|
|
126
|
-
else writeListeners.push(check);
|
|
127
|
-
};
|
|
128
|
-
writeListeners.push(check);
|
|
129
|
-
});
|
|
130
|
-
},
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
26
|
const TEST_PROFILE: ProfileV1 = {
|
|
135
27
|
id: "bypass-inject-fixture",
|
|
136
28
|
version: "0.0.0-test",
|
|
@@ -168,11 +60,18 @@ const TEST_PROFILE: ProfileV1 = {
|
|
|
168
60
|
};
|
|
169
61
|
|
|
170
62
|
describe("Session.bypassInject (PLAN.md §12.1, task 0040)", () => {
|
|
171
|
-
let
|
|
63
|
+
let pipe: FakePipe;
|
|
172
64
|
let session: Session | undefined;
|
|
173
65
|
|
|
174
66
|
beforeEach(() => {
|
|
175
|
-
|
|
67
|
+
pipe = makeFakePipe({
|
|
68
|
+
responders: {
|
|
69
|
+
// Tests below assert on identifier shape — keep these stable.
|
|
70
|
+
"Target.createTarget": () => ({ targetId: "page-target-1" }),
|
|
71
|
+
"Target.attachToTarget": () => ({ sessionId: "session-1" }),
|
|
72
|
+
"Page.addScriptToEvaluateOnNewDocument": () => ({ identifier: "should-never-fire" }),
|
|
73
|
+
},
|
|
74
|
+
});
|
|
176
75
|
session = undefined;
|
|
177
76
|
});
|
|
178
77
|
|
|
@@ -186,47 +85,42 @@ describe("Session.bypassInject (PLAN.md §12.1, task 0040)", () => {
|
|
|
186
85
|
}
|
|
187
86
|
});
|
|
188
87
|
|
|
189
|
-
it("with bypassInject:true — newPage() never sends Page.addScriptToEvaluateOnNewDocument", async () => {
|
|
88
|
+
it("with bypassInject:true — newPage() never sends Page.addScriptToEvaluateOnNewDocument and no Fetch.enable for inject", async () => {
|
|
190
89
|
const matrix = deriveMatrix(TEST_PROFILE, "bypass-test");
|
|
191
90
|
session = new Session({
|
|
192
|
-
proc: fake
|
|
91
|
+
proc: fakeChromiumProcess(pipe, { userDataDir: "/tmp/fake-mochi-test" }),
|
|
193
92
|
matrix,
|
|
194
93
|
seed: "bypass-test",
|
|
195
94
|
bypassInject: true,
|
|
196
95
|
});
|
|
197
96
|
|
|
198
|
-
// Auto-respond to the small set of CDP calls Session/newPage drives:
|
|
199
|
-
// Target.setAutoAttach (constructor), Target.createTarget, Target.attachToTarget,
|
|
200
|
-
// and Page.enable. These are the *only* writes we expect.
|
|
201
|
-
fake.autoRespond((m) => m === "Target.setAutoAttach", {});
|
|
202
|
-
fake.autoRespond((m) => m === "Target.createTarget", { targetId: "page-target-1" });
|
|
203
|
-
fake.autoRespond((m) => m === "Target.attachToTarget", { sessionId: "session-1" });
|
|
204
|
-
fake.autoRespond((m) => m === "Page.enable", {});
|
|
205
|
-
fake.autoRespond((m) => m === "Target.closeTarget", { success: true });
|
|
206
|
-
fake.autoRespond((m) => m === "Page.removeScriptToEvaluateOnNewDocument", {});
|
|
207
|
-
fake.autoRespond((m) => m === "Page.addScriptToEvaluateOnNewDocument", {
|
|
208
|
-
identifier: "should-never-fire",
|
|
209
|
-
});
|
|
210
|
-
|
|
211
97
|
const page = await session.newPage();
|
|
212
98
|
expect(page).toBeDefined();
|
|
99
|
+
// Allow the constructor's deferred init-injector promise to settle (it's
|
|
100
|
+
// a no-op in this case but the rejection-handler microtasks still queue).
|
|
101
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
213
102
|
|
|
214
|
-
const methods =
|
|
215
|
-
.map((w) => w.method)
|
|
103
|
+
const methods = pipe.written
|
|
104
|
+
.map((w) => w.parsed.method)
|
|
216
105
|
.filter((m): m is string => typeof m === "string");
|
|
217
106
|
expect(methods).toContain("Target.createTarget");
|
|
218
107
|
expect(methods).toContain("Target.attachToTarget");
|
|
219
108
|
expect(methods).toContain("Page.enable");
|
|
220
|
-
//
|
|
109
|
+
// Task 0266 contract: no Page.addScriptToEvaluateOnNewDocument under
|
|
110
|
+
// bypassInject — the session-level injector is also short-circuited.
|
|
221
111
|
expect(methods).not.toContain("Page.addScriptToEvaluateOnNewDocument");
|
|
222
|
-
//
|
|
112
|
+
// No Runtime.evaluate — worker injection is also bypassed.
|
|
223
113
|
expect(methods).not.toContain("Runtime.evaluate");
|
|
114
|
+
// No proxy creds, no payload to deliver — the unified injector
|
|
115
|
+
// short-circuits and does NOT send Fetch.enable. Capture flow keeps a
|
|
116
|
+
// zero-extra-protocol-surface posture.
|
|
117
|
+
expect(methods).not.toContain("Fetch.enable");
|
|
224
118
|
});
|
|
225
119
|
|
|
226
120
|
it("with bypassInject:true — _internalPayload() is null", () => {
|
|
227
121
|
const matrix = deriveMatrix(TEST_PROFILE, "null-payload");
|
|
228
122
|
session = new Session({
|
|
229
|
-
proc: fake
|
|
123
|
+
proc: fakeChromiumProcess(pipe, { userDataDir: "/tmp/fake-mochi-test" }),
|
|
230
124
|
matrix,
|
|
231
125
|
seed: "null-payload",
|
|
232
126
|
bypassInject: true,
|
|
@@ -235,10 +129,20 @@ describe("Session.bypassInject (PLAN.md §12.1, task 0040)", () => {
|
|
|
235
129
|
expect(session._internalBypassInject()).toBe(true);
|
|
236
130
|
});
|
|
237
131
|
|
|
238
|
-
it("with bypassInject omitted —
|
|
132
|
+
it("with bypassInject omitted — Session installs the unified Fetch-domain injector instead of Page.addScriptToEvaluateOnNewDocument (task 0266)", async () => {
|
|
133
|
+
// Override the script identifier for this test — it asserts that the
|
|
134
|
+
// dual-mechanism `addScriptToEvaluateOnNewDocument` call (commit 2 of
|
|
135
|
+
// 0266) carries the wrapped matrix payload.
|
|
136
|
+
const localPipe = makeFakePipe({
|
|
137
|
+
responders: {
|
|
138
|
+
"Target.createTarget": () => ({ targetId: "page-target-2" }),
|
|
139
|
+
"Target.attachToTarget": () => ({ sessionId: "session-2" }),
|
|
140
|
+
"Page.addScriptToEvaluateOnNewDocument": () => ({ identifier: "inj-1" }),
|
|
141
|
+
},
|
|
142
|
+
});
|
|
239
143
|
const matrix = deriveMatrix(TEST_PROFILE, "default-inject");
|
|
240
144
|
session = new Session({
|
|
241
|
-
proc: fake
|
|
145
|
+
proc: fakeChromiumProcess(localPipe, { userDataDir: "/tmp/fake-mochi-test" }),
|
|
242
146
|
matrix,
|
|
243
147
|
seed: "default-inject",
|
|
244
148
|
});
|
|
@@ -247,32 +151,46 @@ describe("Session.bypassInject (PLAN.md §12.1, task 0040)", () => {
|
|
|
247
151
|
expect(payload).not.toBeNull();
|
|
248
152
|
expect(payload?.code.length ?? 0).toBeGreaterThan(0);
|
|
249
153
|
|
|
250
|
-
fake.autoRespond((m) => m === "Target.setAutoAttach", {});
|
|
251
|
-
fake.autoRespond((m) => m === "Target.createTarget", { targetId: "page-target-2" });
|
|
252
|
-
fake.autoRespond((m) => m === "Target.attachToTarget", { sessionId: "session-2" });
|
|
253
|
-
fake.autoRespond((m) => m === "Page.enable", {});
|
|
254
|
-
// Task 0255: Session now sends Network.setUserAgentOverride per page.
|
|
255
|
-
fake.autoRespond((m) => m === "Network.setUserAgentOverride", {});
|
|
256
|
-
fake.autoRespond((m) => m === "Target.closeTarget", { success: true });
|
|
257
|
-
fake.autoRespond((m) => m === "Page.removeScriptToEvaluateOnNewDocument", {});
|
|
258
|
-
fake.autoRespond((m) => m === "Page.addScriptToEvaluateOnNewDocument", {
|
|
259
|
-
identifier: "inj-1",
|
|
260
|
-
});
|
|
261
|
-
|
|
262
154
|
const page = await session.newPage();
|
|
263
155
|
expect(page).toBeDefined();
|
|
156
|
+
// Yield once so the deferred installInitInjector promise settles.
|
|
157
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
264
158
|
|
|
265
|
-
const methods =
|
|
266
|
-
.map((w) => w.method)
|
|
159
|
+
const methods = localPipe.written
|
|
160
|
+
.map((w) => w.parsed.method)
|
|
267
161
|
.filter((m): m is string => typeof m === "string");
|
|
268
|
-
//
|
|
162
|
+
// Task 0266 dual-mechanism: Session uses BOTH Fetch.fulfillRequest body
|
|
163
|
+
// splice (HTTP/HTTPS Document responses — closes source-attribution
|
|
164
|
+
// leak) AND Page.addScriptToEvaluateOnNewDocument (per-page fallback for
|
|
165
|
+
// about:blank / data: / blob: where Fetch domain can't intercept). The
|
|
166
|
+
// wrapped payload's `__mochi_inject_marker` early-return prevents
|
|
167
|
+
// double-execution when both fire on the same realm.
|
|
269
168
|
expect(methods).toContain("Page.addScriptToEvaluateOnNewDocument");
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
(w) => w.method === "Page.addScriptToEvaluateOnNewDocument",
|
|
169
|
+
const addScriptCall = localPipe.written.find(
|
|
170
|
+
(w) => w.parsed.method === "Page.addScriptToEvaluateOnNewDocument",
|
|
273
171
|
);
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
172
|
+
const addScriptParams = addScriptCall?.parsed.params as
|
|
173
|
+
| { source?: string; runImmediately?: boolean; worldName?: string }
|
|
174
|
+
| undefined;
|
|
175
|
+
expect(addScriptParams?.runImmediately).toBe(true);
|
|
176
|
+
expect(addScriptParams?.worldName).toBe(""); // PLAN.md §8.4 — main world
|
|
177
|
+
expect(addScriptParams?.source).toContain("__mochi_inject_marker"); // idempotency guard
|
|
178
|
+
// Fetch.enable is sent ONCE on session construction with the
|
|
179
|
+
// Document-first patterns. Auth is off because no proxyAuth was set.
|
|
180
|
+
expect(methods).toContain("Fetch.enable");
|
|
181
|
+
const enableCall = localPipe.written.find((w) => w.parsed.method === "Fetch.enable");
|
|
182
|
+
const enableParams = enableCall?.parsed.params as
|
|
183
|
+
| {
|
|
184
|
+
handleAuthRequests?: boolean;
|
|
185
|
+
patterns?: { urlPattern?: string; resourceType?: string }[];
|
|
186
|
+
}
|
|
187
|
+
| undefined;
|
|
188
|
+
expect(enableParams?.handleAuthRequests).toBe(false);
|
|
189
|
+
expect(enableParams?.patterns).toBeDefined();
|
|
190
|
+
expect(enableParams?.patterns?.[0]).toEqual({
|
|
191
|
+
urlPattern: "*",
|
|
192
|
+
resourceType: "Document",
|
|
193
|
+
});
|
|
194
|
+
expect(enableParams?.patterns?.[1]).toEqual({ urlPattern: "*" });
|
|
277
195
|
});
|
|
278
196
|
});
|
|
@@ -57,4 +57,28 @@ describeOrSkip("@mochi.js/core E2E (MOCHI_E2E=1)", () => {
|
|
|
57
57
|
},
|
|
58
58
|
TEST_TIMEOUT_MS,
|
|
59
59
|
);
|
|
60
|
+
|
|
61
|
+
it(
|
|
62
|
+
"page.evaluate awaits page-side Promises (awaitPromise:true) — task 0263",
|
|
63
|
+
async () => {
|
|
64
|
+
// Closes the regression that left 0261 + 0262 live tests skipped: an
|
|
65
|
+
// `async () => …` page function used to round-trip its returned
|
|
66
|
+
// Promise as `undefined`. The `awaitPromise:true` flag on
|
|
67
|
+
// Runtime.callFunctionOn makes Chromium wait for the Promise and
|
|
68
|
+
// serialize the resolved value instead.
|
|
69
|
+
const session = await mochi.launch({ profile: "test", seed: "0263", headless: true });
|
|
70
|
+
try {
|
|
71
|
+
const page = await session.newPage();
|
|
72
|
+
await page.goto("data:text/html,<title>0263</title>");
|
|
73
|
+
const v = await page.evaluate(async () => {
|
|
74
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
75
|
+
return 42;
|
|
76
|
+
});
|
|
77
|
+
expect(v).toBe(42);
|
|
78
|
+
} finally {
|
|
79
|
+
await session.close();
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
TEST_TIMEOUT_MS,
|
|
83
|
+
);
|
|
60
84
|
});
|