@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
package/README.md
CHANGED
|
@@ -48,6 +48,6 @@ MIT.
|
|
|
48
48
|
|
|
49
49
|
## See also
|
|
50
50
|
|
|
51
|
-
- [Repo](https://github.com/0xchasercat/mochi
|
|
52
|
-
- [PLAN.md](https://github.com/0xchasercat/mochi
|
|
53
|
-
- [Limits](https://github.com/0xchasercat/mochi
|
|
51
|
+
- [Repo](https://github.com/0xchasercat/mochi)
|
|
52
|
+
- [PLAN.md](https://github.com/0xchasercat/mochi/blob/main/PLAN.md) — the full design contract
|
|
53
|
+
- [Limits](https://github.com/0xchasercat/mochi/blob/main/docs/limits.md) — what the JS-only ceiling honestly does and doesn't cover
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mochi.js/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Bun-native browser automation framework — relational fingerprint locking, zero-jitter injection, behavioral playback. The primary entry point.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -21,14 +21,14 @@
|
|
|
21
21
|
"engines": {
|
|
22
22
|
"bun": ">=1.1"
|
|
23
23
|
},
|
|
24
|
-
"homepage": "https://github.com/0xchasercat/mochi
|
|
24
|
+
"homepage": "https://github.com/0xchasercat/mochi",
|
|
25
25
|
"repository": {
|
|
26
26
|
"type": "git",
|
|
27
|
-
"url": "git+https://github.com/0xchasercat/mochi.
|
|
27
|
+
"url": "git+https://github.com/0xchasercat/mochi.git",
|
|
28
28
|
"directory": "packages/core"
|
|
29
29
|
},
|
|
30
30
|
"bugs": {
|
|
31
|
-
"url": "https://github.com/0xchasercat/mochi
|
|
31
|
+
"url": "https://github.com/0xchasercat/mochi/issues"
|
|
32
32
|
},
|
|
33
33
|
"keywords": [
|
|
34
34
|
"browser",
|
|
@@ -48,6 +48,13 @@
|
|
|
48
48
|
"lint": "biome check src/",
|
|
49
49
|
"build": "echo 'no build step yet — Bun consumes src/ directly'"
|
|
50
50
|
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@mochi.js/behavioral": "^0.1.1",
|
|
53
|
+
"@mochi.js/challenges": "^0.2.0",
|
|
54
|
+
"@mochi.js/consistency": "^0.1.0",
|
|
55
|
+
"@mochi.js/inject": "^0.1.1",
|
|
56
|
+
"@mochi.js/net": "^0.1.1"
|
|
57
|
+
},
|
|
51
58
|
"publishConfig": {
|
|
52
59
|
"access": "public"
|
|
53
60
|
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E test for the behavioral surface against real Chromium.
|
|
3
|
+
*
|
|
4
|
+
* Gated by `MOCHI_E2E=1`. Set `MOCHI_CHROMIUM_PATH` to point at a Chrome /
|
|
5
|
+
* Chromium-for-Testing binary. Budget: < 15 seconds total.
|
|
6
|
+
*
|
|
7
|
+
* Asserts:
|
|
8
|
+
* - `humanClick` emits a sequence of `mousemove` events the page sees
|
|
9
|
+
* (count > 5 — confirms it's a trajectory, not a single warp).
|
|
10
|
+
* - `mousemove` timestamps are monotonic and span a non-trivial range
|
|
11
|
+
* (confirms the dispatch layer paces events, not all at t=0).
|
|
12
|
+
* - The final mousedown / mouseup land on the target element.
|
|
13
|
+
* - `humanType` produces `keydown`/`keyup` for each character (plus the
|
|
14
|
+
* mistake/correction events when forced).
|
|
15
|
+
* - `humanScroll` advances `window.scrollY` toward the target.
|
|
16
|
+
*
|
|
17
|
+
* @see PLAN.md §11
|
|
18
|
+
* @see tasks/0080-behavioral-engine-v0.md §"Tests"
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, expect, it } from "bun:test";
|
|
22
|
+
import { mochi } from "../index";
|
|
23
|
+
|
|
24
|
+
const E2E_ENABLED = process.env.MOCHI_E2E === "1";
|
|
25
|
+
const TEST_TIMEOUT_MS = 20_000;
|
|
26
|
+
|
|
27
|
+
const describeOrSkip = E2E_ENABLED ? describe : describe.skip;
|
|
28
|
+
|
|
29
|
+
const BUTTON_HARNESS_HTML =
|
|
30
|
+
"data:text/html," +
|
|
31
|
+
encodeURIComponent(`
|
|
32
|
+
<!doctype html>
|
|
33
|
+
<html><head><title>behavioral-e2e</title></head>
|
|
34
|
+
<body style="margin:0;padding:0;height:2400px;">
|
|
35
|
+
<div id="filler" style="height:1000px;background:#eef;"></div>
|
|
36
|
+
<button id="b" style="position:absolute;left:200px;top:150px;width:100px;height:40px;">click me</button>
|
|
37
|
+
<input id="i" style="position:absolute;left:50px;top:60px;width:200px;height:30px;font-size:16px;" />
|
|
38
|
+
<div id="footer" style="height:200px;background:#fee;"></div>
|
|
39
|
+
<script>
|
|
40
|
+
window.__events = { mousemove: [], mousedown: [], mouseup: [], keydown: [], keyup: [] };
|
|
41
|
+
for (const t of ["mousemove","mousedown","mouseup"]) {
|
|
42
|
+
document.addEventListener(t, (e) => {
|
|
43
|
+
window.__events[t].push({ t: performance.now(), x: e.clientX, y: e.clientY, target: e.target && e.target.id });
|
|
44
|
+
}, true);
|
|
45
|
+
}
|
|
46
|
+
for (const t of ["keydown","keyup"]) {
|
|
47
|
+
document.addEventListener(t, (e) => {
|
|
48
|
+
window.__events[t].push({ t: performance.now(), key: e.key });
|
|
49
|
+
}, true);
|
|
50
|
+
}
|
|
51
|
+
</script>
|
|
52
|
+
</body></html>
|
|
53
|
+
`);
|
|
54
|
+
|
|
55
|
+
describeOrSkip("@mochi.js/core — behavioral E2E (MOCHI_E2E=1)", () => {
|
|
56
|
+
it(
|
|
57
|
+
"humanClick produces a trajectory of >5 mousemoves and a final click on the target",
|
|
58
|
+
async () => {
|
|
59
|
+
const session = await mochi.launch({
|
|
60
|
+
profile: "test-behavioral",
|
|
61
|
+
seed: "e2e-click",
|
|
62
|
+
headless: true,
|
|
63
|
+
});
|
|
64
|
+
try {
|
|
65
|
+
const page = await session.newPage();
|
|
66
|
+
await page.goto(BUTTON_HARNESS_HTML);
|
|
67
|
+
await page.humanClick("#b", { preMoveSettle: false });
|
|
68
|
+
const events = (await page.evaluate(
|
|
69
|
+
// The function runs as a method on `document` (PLAN.md §8.3 path
|
|
70
|
+
// through `Runtime.callFunctionOn`); `this.defaultView` reaches
|
|
71
|
+
// window. We wrote the harness HTML to stash event records on
|
|
72
|
+
// `window.__events`. Cast via `unknown` so the `this` type fudge
|
|
73
|
+
// in the page-side closure doesn't trip the strict `any` check.
|
|
74
|
+
function (this: Document) {
|
|
75
|
+
const w = this.defaultView as unknown as { __events: unknown };
|
|
76
|
+
return w?.__events;
|
|
77
|
+
} as () => unknown,
|
|
78
|
+
)) as {
|
|
79
|
+
mousemove: { t: number; x: number; y: number; target: string }[];
|
|
80
|
+
mousedown: { t: number; x: number; y: number; target: string }[];
|
|
81
|
+
mouseup: { t: number; x: number; y: number; target: string }[];
|
|
82
|
+
keydown: { t: number; key: string }[];
|
|
83
|
+
keyup: { t: number; key: string }[];
|
|
84
|
+
};
|
|
85
|
+
expect(events).toBeDefined();
|
|
86
|
+
expect(events.mousemove.length).toBeGreaterThan(5);
|
|
87
|
+
|
|
88
|
+
// Monotonic timestamps; non-trivial timespan.
|
|
89
|
+
for (let i = 1; i < events.mousemove.length; i++) {
|
|
90
|
+
const prev = events.mousemove[i - 1];
|
|
91
|
+
const cur = events.mousemove[i];
|
|
92
|
+
if (prev !== undefined && cur !== undefined) {
|
|
93
|
+
expect(cur.t).toBeGreaterThanOrEqual(prev.t);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const first = events.mousemove[0];
|
|
97
|
+
const last = events.mousemove[events.mousemove.length - 1];
|
|
98
|
+
if (first !== undefined && last !== undefined) {
|
|
99
|
+
expect(last.t - first.t).toBeGreaterThan(50);
|
|
100
|
+
}
|
|
101
|
+
// Final mousedown and mouseup land on (or inside) the button.
|
|
102
|
+
expect(events.mousedown.length).toBe(1);
|
|
103
|
+
expect(events.mouseup.length).toBe(1);
|
|
104
|
+
const md = events.mousedown[0];
|
|
105
|
+
expect(md?.target).toBe("b");
|
|
106
|
+
|
|
107
|
+
// Surface event counts to the test runner so the orchestrator can
|
|
108
|
+
// confirm the trajectory shape from CI logs. `console.warn` rather
|
|
109
|
+
// than `console.log` because the project's biome rule forbids the
|
|
110
|
+
// latter.
|
|
111
|
+
console.warn(
|
|
112
|
+
`[e2e] humanClick: ${events.mousemove.length} moves; mousedown target=${md?.target}; ` +
|
|
113
|
+
`dt=${last && first ? Math.round(last.t - first.t) : 0} ms`,
|
|
114
|
+
);
|
|
115
|
+
} finally {
|
|
116
|
+
await session.close();
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
TEST_TIMEOUT_MS,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
it(
|
|
123
|
+
"humanType emits keydown/keyup pairs for each character",
|
|
124
|
+
async () => {
|
|
125
|
+
const session = await mochi.launch({
|
|
126
|
+
profile: "test-behavioral",
|
|
127
|
+
seed: "e2e-type",
|
|
128
|
+
headless: true,
|
|
129
|
+
});
|
|
130
|
+
try {
|
|
131
|
+
const page = await session.newPage();
|
|
132
|
+
await page.goto(BUTTON_HARNESS_HTML);
|
|
133
|
+
await page.humanType("#i", "hi", { mistakeRate: 0 });
|
|
134
|
+
const events = (await page.evaluate(function (this: Document) {
|
|
135
|
+
const w = this.defaultView as unknown as { __events: unknown };
|
|
136
|
+
return w?.__events;
|
|
137
|
+
} as () => unknown)) as {
|
|
138
|
+
mousemove: { t: number; x: number; y: number }[];
|
|
139
|
+
mousedown: { t: number; x: number; y: number }[];
|
|
140
|
+
mouseup: { t: number; x: number; y: number }[];
|
|
141
|
+
keydown: { t: number; key: string }[];
|
|
142
|
+
keyup: { t: number; key: string }[];
|
|
143
|
+
};
|
|
144
|
+
expect(events.keydown.length).toBe(2);
|
|
145
|
+
expect(events.keyup.length).toBe(2);
|
|
146
|
+
expect(events.keydown.map((e) => e.key)).toEqual(["h", "i"]);
|
|
147
|
+
// Inter-key timing > 0.
|
|
148
|
+
if (events.keydown.length === 2) {
|
|
149
|
+
const k0 = events.keydown[0];
|
|
150
|
+
const k1 = events.keydown[1];
|
|
151
|
+
if (k0 !== undefined && k1 !== undefined) {
|
|
152
|
+
expect(k1.t).toBeGreaterThan(k0.t);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Surface event counts to the test runner so the orchestrator can
|
|
156
|
+
// confirm the trajectory shape from CI logs. `console.warn` rather
|
|
157
|
+
// than `console.log` because the project's biome rule forbids the
|
|
158
|
+
// latter.
|
|
159
|
+
console.warn(
|
|
160
|
+
`[e2e] humanType: keydown.length=${events.keydown.length}, ` +
|
|
161
|
+
`keys=${events.keydown.map((e) => e.key).join(",")}`,
|
|
162
|
+
);
|
|
163
|
+
} finally {
|
|
164
|
+
await session.close();
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
TEST_TIMEOUT_MS,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
it(
|
|
171
|
+
"humanScroll moves window.scrollY toward the target",
|
|
172
|
+
async () => {
|
|
173
|
+
const session = await mochi.launch({
|
|
174
|
+
profile: "test-behavioral",
|
|
175
|
+
seed: "e2e-scroll",
|
|
176
|
+
headless: true,
|
|
177
|
+
});
|
|
178
|
+
try {
|
|
179
|
+
const page = await session.newPage();
|
|
180
|
+
await page.goto(BUTTON_HARNESS_HTML);
|
|
181
|
+
const before = (await page.evaluate(function (this: Document) {
|
|
182
|
+
return this.defaultView?.scrollY ?? 0;
|
|
183
|
+
} as () => unknown)) as number;
|
|
184
|
+
await page.humanScroll({ to: { x: 0, y: 500 } });
|
|
185
|
+
const after = (await page.evaluate(function (this: Document) {
|
|
186
|
+
return this.defaultView?.scrollY ?? 0;
|
|
187
|
+
} as () => unknown)) as number;
|
|
188
|
+
expect(after).toBeGreaterThan(before);
|
|
189
|
+
// Surface event counts to the test runner so the orchestrator can
|
|
190
|
+
// confirm the trajectory shape from CI logs. `console.warn` rather
|
|
191
|
+
// than `console.log` because the project's biome rule forbids the
|
|
192
|
+
// latter.
|
|
193
|
+
console.warn(`[e2e] humanScroll: scrollY ${before} → ${after}`);
|
|
194
|
+
} finally {
|
|
195
|
+
await session.close();
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
TEST_TIMEOUT_MS,
|
|
199
|
+
);
|
|
200
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for binary resolution. Pure (no Chromium / no spawn).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
6
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { ChromiumNotFoundError, resolveBinary } from "../binary";
|
|
10
|
+
|
|
11
|
+
let tmp: string;
|
|
12
|
+
let stubBinary: string;
|
|
13
|
+
const ORIGINAL_ENV = process.env.MOCHI_CHROMIUM_PATH;
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
tmp = await mkdtemp(join(tmpdir(), "mochi-binary-test-"));
|
|
17
|
+
stubBinary = join(tmp, "chrome-stub");
|
|
18
|
+
await writeFile(stubBinary, "#!/usr/bin/env false\n", { mode: 0o755 });
|
|
19
|
+
delete process.env.MOCHI_CHROMIUM_PATH;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await rm(tmp, { recursive: true, force: true });
|
|
24
|
+
if (ORIGINAL_ENV === undefined) {
|
|
25
|
+
delete process.env.MOCHI_CHROMIUM_PATH;
|
|
26
|
+
} else {
|
|
27
|
+
process.env.MOCHI_CHROMIUM_PATH = ORIGINAL_ENV;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("resolveBinary", () => {
|
|
32
|
+
it("prefers the explicit `binary` option when valid", async () => {
|
|
33
|
+
process.env.MOCHI_CHROMIUM_PATH = "/nonsense";
|
|
34
|
+
const out = await resolveBinary(stubBinary);
|
|
35
|
+
expect(out).toBe(stubBinary);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("rejects an explicit binary that does not exist", async () => {
|
|
39
|
+
let caught: unknown;
|
|
40
|
+
try {
|
|
41
|
+
await resolveBinary("/does/not/exist/chromium");
|
|
42
|
+
} catch (err) {
|
|
43
|
+
caught = err;
|
|
44
|
+
}
|
|
45
|
+
expect(caught).toBeInstanceOf(Error);
|
|
46
|
+
expect(String(caught)).toContain("non-existent");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("falls back to MOCHI_CHROMIUM_PATH when no explicit binary", async () => {
|
|
50
|
+
process.env.MOCHI_CHROMIUM_PATH = stubBinary;
|
|
51
|
+
const out = await resolveBinary();
|
|
52
|
+
expect(out).toBe(stubBinary);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("rejects MOCHI_CHROMIUM_PATH that does not exist", async () => {
|
|
56
|
+
process.env.MOCHI_CHROMIUM_PATH = "/missing/chrome";
|
|
57
|
+
let caught: unknown;
|
|
58
|
+
try {
|
|
59
|
+
await resolveBinary();
|
|
60
|
+
} catch (err) {
|
|
61
|
+
caught = err;
|
|
62
|
+
}
|
|
63
|
+
expect(caught).toBeInstanceOf(Error);
|
|
64
|
+
expect(String(caught)).toContain("non-existent");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("throws ChromiumNotFoundError when no source resolves", async () => {
|
|
68
|
+
let caught: unknown;
|
|
69
|
+
try {
|
|
70
|
+
await resolveBinary();
|
|
71
|
+
} catch (err) {
|
|
72
|
+
caught = err;
|
|
73
|
+
}
|
|
74
|
+
// Either ChromiumNotFoundError, or — if the cli happens to export a working
|
|
75
|
+
// resolveChromiumBinary in this environment — a working path. In CI/local
|
|
76
|
+
// dev we expect the no-cli path.
|
|
77
|
+
if (caught instanceof ChromiumNotFoundError) {
|
|
78
|
+
expect(caught.message).toContain("MOCHI_CHROMIUM_PATH");
|
|
79
|
+
expect(caught.message).toContain("mochi browsers install");
|
|
80
|
+
} else if (caught instanceof Error) {
|
|
81
|
+
// If something else fired (e.g. a future cli implementation returns a
|
|
82
|
+
// non-existent path), the assertion still verifies we surface a clear
|
|
83
|
+
// message. The brief allows "error friendly" — we satisfy it either way.
|
|
84
|
+
expect(String(caught)).toContain("[mochi]");
|
|
85
|
+
} else {
|
|
86
|
+
throw new Error(`expected an Error, got ${typeof caught}`);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the §8.2 forbidden-method runtime assertions.
|
|
3
|
+
*
|
|
4
|
+
* Each forbidden constraint gets its own test. These assertions are
|
|
5
|
+
* non-negotiable mochi stealth invariants — if any of these tests starts
|
|
6
|
+
* failing, the offending PR violates PLAN.md §8.2.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
assertNotForbidden,
|
|
12
|
+
FORBIDDEN_METHOD_NAMES,
|
|
13
|
+
ForbiddenCdpMethodError,
|
|
14
|
+
} from "../cdp/forbidden";
|
|
15
|
+
|
|
16
|
+
describe("ForbiddenCdpMethodError + assertNotForbidden (PLAN.md §8.2)", () => {
|
|
17
|
+
it("rejects Runtime.enable on any target", () => {
|
|
18
|
+
let thrown: unknown;
|
|
19
|
+
try {
|
|
20
|
+
assertNotForbidden("Runtime.enable", {});
|
|
21
|
+
} catch (err) {
|
|
22
|
+
thrown = err;
|
|
23
|
+
}
|
|
24
|
+
expect(thrown).toBeInstanceOf(ForbiddenCdpMethodError);
|
|
25
|
+
const e = thrown as ForbiddenCdpMethodError;
|
|
26
|
+
expect(e.method).toBe("Runtime.enable");
|
|
27
|
+
expect(e.reason).toContain("PLAN.md §8.2");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("rejects Page.createIsolatedWorld", () => {
|
|
31
|
+
let thrown: unknown;
|
|
32
|
+
try {
|
|
33
|
+
assertNotForbidden("Page.createIsolatedWorld", { frameId: "foo" });
|
|
34
|
+
} catch (err) {
|
|
35
|
+
thrown = err;
|
|
36
|
+
}
|
|
37
|
+
expect(thrown).toBeInstanceOf(ForbiddenCdpMethodError);
|
|
38
|
+
const e = thrown as ForbiddenCdpMethodError;
|
|
39
|
+
expect(e.method).toBe("Page.createIsolatedWorld");
|
|
40
|
+
expect(e.reason).toContain("PLAN.md §8.2");
|
|
41
|
+
expect(e.reason).toContain("Page.createIsolatedWorld");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("rejects Runtime.evaluate when includeCommandLineAPI === true", () => {
|
|
45
|
+
let thrown: unknown;
|
|
46
|
+
try {
|
|
47
|
+
assertNotForbidden("Runtime.evaluate", {
|
|
48
|
+
expression: "1+1",
|
|
49
|
+
includeCommandLineAPI: true,
|
|
50
|
+
});
|
|
51
|
+
} catch (err) {
|
|
52
|
+
thrown = err;
|
|
53
|
+
}
|
|
54
|
+
expect(thrown).toBeInstanceOf(ForbiddenCdpMethodError);
|
|
55
|
+
const e = thrown as ForbiddenCdpMethodError;
|
|
56
|
+
expect(e.method).toBe("Runtime.evaluate");
|
|
57
|
+
expect(e.reason).toContain("includeCommandLineAPI");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("permits Runtime.evaluate when includeCommandLineAPI is omitted or false", () => {
|
|
61
|
+
expect(() => assertNotForbidden("Runtime.evaluate", { expression: "1+1" })).not.toThrow();
|
|
62
|
+
expect(() =>
|
|
63
|
+
assertNotForbidden("Runtime.evaluate", {
|
|
64
|
+
expression: "1+1",
|
|
65
|
+
includeCommandLineAPI: false,
|
|
66
|
+
}),
|
|
67
|
+
).not.toThrow();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("permits unrelated CDP methods", () => {
|
|
71
|
+
expect(() => assertNotForbidden("Page.navigate", { url: "about:blank" })).not.toThrow();
|
|
72
|
+
expect(() => assertNotForbidden("DOM.getDocument")).not.toThrow();
|
|
73
|
+
expect(() => assertNotForbidden("Target.setAutoAttach", { autoAttach: true })).not.toThrow();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("FORBIDDEN_METHOD_NAMES includes both unconditional rejects", () => {
|
|
77
|
+
expect(FORBIDDEN_METHOD_NAMES).toContain("Runtime.enable");
|
|
78
|
+
expect(FORBIDDEN_METHOD_NAMES).toContain("Page.createIsolatedWorld");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the NUL-delimited CDP framer.
|
|
3
|
+
*
|
|
4
|
+
* Coverage targets:
|
|
5
|
+
* - single complete frame in one chunk
|
|
6
|
+
* - multiple frames in one chunk
|
|
7
|
+
* - one frame split across multiple chunks
|
|
8
|
+
* - mid-frame chunk boundary that lands exactly on a NUL
|
|
9
|
+
* - empty / NUL-only chunks
|
|
10
|
+
* - UTF-8 multi-byte characters spanning chunk boundaries
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it } from "bun:test";
|
|
14
|
+
import { CdpFramer, encodeFrame } from "../cdp/framer";
|
|
15
|
+
|
|
16
|
+
const ENC = new TextEncoder();
|
|
17
|
+
function bytes(str: string): Uint8Array {
|
|
18
|
+
return ENC.encode(str);
|
|
19
|
+
}
|
|
20
|
+
function withNul(str: string): Uint8Array {
|
|
21
|
+
const inner = bytes(str);
|
|
22
|
+
const out = new Uint8Array(inner.length + 1);
|
|
23
|
+
out.set(inner, 0);
|
|
24
|
+
out[inner.length] = 0;
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("CdpFramer", () => {
|
|
29
|
+
it("yields one frame from one complete chunk", () => {
|
|
30
|
+
const f = new CdpFramer();
|
|
31
|
+
const out = f.push(withNul('{"id":1}'));
|
|
32
|
+
expect(out).toEqual(['{"id":1}']);
|
|
33
|
+
expect(f.isEmpty).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("yields multiple frames from a single chunk", () => {
|
|
37
|
+
const f = new CdpFramer();
|
|
38
|
+
const a = withNul('{"id":1}');
|
|
39
|
+
const b = withNul('{"id":2}');
|
|
40
|
+
const merged = new Uint8Array(a.length + b.length);
|
|
41
|
+
merged.set(a, 0);
|
|
42
|
+
merged.set(b, a.length);
|
|
43
|
+
const out = f.push(merged);
|
|
44
|
+
expect(out).toEqual(['{"id":1}', '{"id":2}']);
|
|
45
|
+
expect(f.isEmpty).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("buffers a partial frame and emits on the chunk that completes it", () => {
|
|
49
|
+
const f = new CdpFramer();
|
|
50
|
+
expect(f.push(bytes('{"id":'))).toEqual([]);
|
|
51
|
+
expect(f.push(bytes('1,"method":"X"'))).toEqual([]);
|
|
52
|
+
expect(f.isEmpty).toBe(false);
|
|
53
|
+
expect(f.push(new Uint8Array([0x7d, 0x00]))).toEqual(['{"id":1,"method":"X"}']);
|
|
54
|
+
expect(f.isEmpty).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("handles a chunk that ends exactly on a NUL boundary", () => {
|
|
58
|
+
const f = new CdpFramer();
|
|
59
|
+
expect(f.push(withNul('{"a":1}'))).toEqual(['{"a":1}']);
|
|
60
|
+
expect(f.push(withNul('{"b":2}'))).toEqual(['{"b":2}']);
|
|
61
|
+
expect(f.isEmpty).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("ignores empty input", () => {
|
|
65
|
+
const f = new CdpFramer();
|
|
66
|
+
expect(f.push(new Uint8Array(0))).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("drops empty frames between consecutive NULs", () => {
|
|
70
|
+
const f = new CdpFramer();
|
|
71
|
+
// <NUL><NUL>{"x":1}<NUL>
|
|
72
|
+
const buf = new Uint8Array([0, 0, ...bytes('{"x":1}'), 0]);
|
|
73
|
+
expect(f.push(buf)).toEqual(['{"x":1}']);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("handles UTF-8 multi-byte chars split across chunks", () => {
|
|
77
|
+
const f = new CdpFramer();
|
|
78
|
+
// emoji U+1F600 = 4 bytes F0 9F 98 80
|
|
79
|
+
const full = withNul('{"emoji":"😀"}');
|
|
80
|
+
// Split mid-emoji.
|
|
81
|
+
const splitAt = full.length - 5;
|
|
82
|
+
expect(f.push(full.subarray(0, splitAt))).toEqual([]);
|
|
83
|
+
const out = f.push(full.subarray(splitAt));
|
|
84
|
+
expect(out).toEqual(['{"emoji":"😀"}']);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("encodeFrame appends exactly one NUL byte", () => {
|
|
88
|
+
const out = encodeFrame('{"a":1}');
|
|
89
|
+
expect(out[out.length - 1]).toBe(0);
|
|
90
|
+
expect(new TextDecoder().decode(out.subarray(0, out.length - 1))).toBe('{"a":1}');
|
|
91
|
+
});
|
|
92
|
+
});
|