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