@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/src/page.ts ADDED
@@ -0,0 +1,979 @@
1
+ /**
2
+ * `Page` — public surface for one Chromium tab/target.
3
+ *
4
+ * v0.8 wires the behavioral surface — `humanClick`, `humanType`, `humanScroll`
5
+ * — onto the existing v0.1 base (`goto`, `content`, `text`, `evaluate`,
6
+ * `waitFor`, `cookies`, `close`). `screenshot` remains a placeholder.
7
+ *
8
+ * Critical §8.3 design: NO `Runtime.enable` is ever sent. Evaluation routes
9
+ * through `DOM.resolveNode` → `Runtime.callFunctionOn` against the document
10
+ * node's `objectId`. That implicitly runs in main world without naming a
11
+ * world (which would create a detectable isolated world; PLAN.md §8.4).
12
+ *
13
+ * Behavioral pipeline (PLAN.md §5.5 pure-data principle): trajectory /
14
+ * keystroke / scroll EVENTS are produced by `@mochi.js/behavioral` as plain
15
+ * data; this module is the side-effect layer that dispatches them via
16
+ * `Input.dispatchMouseEvent` / `Input.dispatchKeyEvent`. Per-frame pacing is
17
+ * realized with `setTimeout(0)` chained against the synthesized `tMs` so the
18
+ * realized cadence matches the synthesized cadence on a relaxed best-effort
19
+ * basis (Bun's setTimeout granularity is sub-ms; the model is at 60Hz).
20
+ *
21
+ * @see PLAN.md §5.1 / §5.5 / §7 / §8.3 / §8.4 / §11
22
+ */
23
+
24
+ import {
25
+ type BehaviorProfile,
26
+ DEFAULT_BEHAVIOR_PROFILE,
27
+ synthesizeKeystrokes,
28
+ synthesizeMouseTrajectory,
29
+ synthesizeScroll,
30
+ } from "@mochi.js/behavioral";
31
+ import type { MessageRouter } from "./cdp/router";
32
+ import type {
33
+ BoxModel,
34
+ DispatchKeyEventParams,
35
+ DispatchMouseEventParams,
36
+ DomNode,
37
+ FrameNavigatedEvent,
38
+ RemoteObject,
39
+ } from "./cdp/types";
40
+ import { NotImplementedError } from "./errors";
41
+
42
+ /** Wait conditions for `Page.goto`. */
43
+ export type WaitUntil = "load" | "domcontentloaded" | "networkidle";
44
+
45
+ /** Options for `Page.goto`. */
46
+ export interface GotoOptions {
47
+ waitUntil?: WaitUntil;
48
+ timeout?: number;
49
+ }
50
+
51
+ /** State predicates for `Page.waitFor`. */
52
+ export type WaitState = "attached" | "visible" | "hidden";
53
+
54
+ /** Options for `Page.waitFor`. */
55
+ export interface WaitForOptions {
56
+ timeout?: number;
57
+ state?: WaitState;
58
+ }
59
+
60
+ /** A CDP cookie shape. Matches `Network.Cookie` minus a few fields we don't surface. */
61
+ export interface Cookie {
62
+ name: string;
63
+ value: string;
64
+ domain: string;
65
+ path: string;
66
+ expires: number;
67
+ size: number;
68
+ httpOnly: boolean;
69
+ secure: boolean;
70
+ session: boolean;
71
+ sameSite?: "Strict" | "Lax" | "None";
72
+ }
73
+
74
+ /**
75
+ * Construct a `Page` against an existing CDP target. Used internally by
76
+ * `Session.newPage()`; not exported.
77
+ */
78
+ export interface PageInit {
79
+ router: MessageRouter;
80
+ /** The CDP target id this page wraps. */
81
+ targetId: string;
82
+ /**
83
+ * The flat-mode CDP session id obtained from `Target.attachToTarget`. All
84
+ * page-level CDP calls (`Page.enable`, `DOM.*`, `Runtime.callFunctionOn`,
85
+ * `Network.getCookies`, etc.) MUST be routed through this session.
86
+ */
87
+ sessionId: string;
88
+ /** Initial URL (typically "about:blank"). */
89
+ initialUrl: string;
90
+ /**
91
+ * Identifier returned by `Page.addScriptToEvaluateOnNewDocument` when the
92
+ * inject payload was installed at session-newPage time. Tracked here so
93
+ * `Page.close()` can call `Page.removeScriptToEvaluateOnNewDocument` —
94
+ * required by PLAN.md §8.4 to keep the per-target identifier list bounded.
95
+ *
96
+ * Optional: zero-spoofing test setups (or future no-inject paths) may omit.
97
+ */
98
+ injectScriptIdentifier?: string;
99
+ /**
100
+ * Behavioral profile for `humanClick`/`humanType`/`humanScroll`. Sourced
101
+ * from {@link MatrixV1.profile.behavior} (PLAN.md I-5: profile data is the
102
+ * single source of truth). Optional; defaults to
103
+ * `DEFAULT_BEHAVIOR_PROFILE` when absent.
104
+ */
105
+ behavior?: BehaviorProfile;
106
+ /**
107
+ * Per-session deterministic seed forwarded to behavioral synth. Combined
108
+ * with a per-call counter so back-to-back `humanClick` calls within the
109
+ * same session still produce divergent (but deterministic) trajectories.
110
+ */
111
+ seed?: string;
112
+ /**
113
+ * Initial cursor position. Real humans never start at viewport origin
114
+ * (0, 0); a sensible default is the viewport center. The session-level
115
+ * resolver picks this from the matrix's display dimensions; tests can
116
+ * override directly.
117
+ */
118
+ initialCursor?: { x: number; y: number };
119
+ }
120
+
121
+ /** Options for `Page.humanClick`. */
122
+ export interface HumanClickOptions {
123
+ button?: "left" | "right" | "middle";
124
+ /** Override movement duration (ms). Default = Fitts. */
125
+ duration?: number;
126
+ /**
127
+ * Add a Gaussian(150, 50) ms idle before movement. Default `true` — gives
128
+ * the page a moment to settle (a real human doesn't snap instantly).
129
+ */
130
+ preMoveSettle?: boolean;
131
+ }
132
+
133
+ /** Options for `Page.humanMove`. */
134
+ export interface HumanMoveOptions {
135
+ /** Override movement duration (ms). Default = Fitts. */
136
+ duration?: number;
137
+ }
138
+
139
+ /** Options for `Page.humanType`. */
140
+ export interface HumanTypeOptions {
141
+ /** Override profile WPM for this call. */
142
+ wpm?: number;
143
+ /** Override mistake rate (0..1). Default = 0.02. */
144
+ mistakeRate?: number;
145
+ }
146
+
147
+ /** Options for `Page.humanScroll`. */
148
+ export interface HumanScrollOptions {
149
+ /** Selector or absolute coords to scroll TO. */
150
+ to: string | { x: number; y: number };
151
+ /** Total time budget (ms). Default 500. */
152
+ duration?: number;
153
+ }
154
+
155
+ export class Page {
156
+ private readonly router: MessageRouter;
157
+ private readonly targetId: string;
158
+ private readonly sessionId: string;
159
+ private currentUrl: string;
160
+ private closed = false;
161
+ /**
162
+ * Most recently observed main-frame id (no `parentId`). Captured from
163
+ * `Page.frameNavigated` events. Exposed via `mainFrameId()` so it has at
164
+ * least one reader at v0.1 (future phases consume it for worker fan-out
165
+ * and OOPIF correlation).
166
+ */
167
+ private _mainFrameId: string | null = null;
168
+ /** Inject script identifier (see {@link PageInit.injectScriptIdentifier}). */
169
+ private readonly injectScriptIdentifier: string | null;
170
+ /**
171
+ * Behavioral profile for the human-input surface. Defaults are documented
172
+ * on {@link DEFAULT_BEHAVIOR_PROFILE}; the `MatrixV1.profile.behavior`
173
+ * block is the canonical source (PLAN.md I-5).
174
+ */
175
+ private readonly behavior: BehaviorProfile;
176
+ /** Per-session seed forwarded to behavioral synth (with a per-call counter mixed in). */
177
+ private readonly seed: string;
178
+ /**
179
+ * Per-call counter that disambiguates back-to-back `humanClick` /
180
+ * `humanType` / `humanScroll` calls within the same session. Without this
181
+ * the same `(seed, opts)` would always produce the same trajectory —
182
+ * deterministic but visibly mechanical.
183
+ */
184
+ private callCounter = 0;
185
+ /**
186
+ * Last cursor position. The `humanClick`/`humanMove` synth chains from this
187
+ * point so a sequence of moves and clicks produces a continuous trajectory
188
+ * (which is also what a real user does). Initialized from
189
+ * `PageInit.initialCursor` (the matrix-derived viewport center by default
190
+ * — see PLAN.md I-5: behavioral parameters come from MatrixV1.profile.behavior).
191
+ */
192
+ private cursor: { x: number; y: number };
193
+
194
+ constructor(init: PageInit) {
195
+ this.router = init.router;
196
+ this.targetId = init.targetId;
197
+ this.sessionId = init.sessionId;
198
+ this.currentUrl = init.initialUrl;
199
+ this.injectScriptIdentifier = init.injectScriptIdentifier ?? null;
200
+ this.behavior = init.behavior ?? DEFAULT_BEHAVIOR_PROFILE;
201
+ this.seed = init.seed ?? "default";
202
+ this.cursor = init.initialCursor ?? { x: 0, y: 0 };
203
+ this.subscribeFrameTopology();
204
+ }
205
+
206
+ /** The page's last-observed URL (updated on `Page.frameNavigated`). */
207
+ get url(): string {
208
+ return this.currentUrl;
209
+ }
210
+
211
+ /**
212
+ * The CDP frame id of the main frame, or `null` before the first navigation.
213
+ * Mostly diagnostic at v0.1 — future phases use it for worker fan-out and
214
+ * OOPIF correlation per PLAN.md §8.3.
215
+ */
216
+ mainFrameId(): string | null {
217
+ return this._mainFrameId;
218
+ }
219
+
220
+ /**
221
+ * Navigate to a URL. v0.1 supports `waitUntil: "load"` (the default) and
222
+ * `"domcontentloaded"`. `"networkidle"` requires Network-domain plumbing
223
+ * that lands later — for now we map it to `"load"` and document the limit.
224
+ */
225
+ async goto(url: string, opts: GotoOptions = {}): Promise<void> {
226
+ this.assertOpen();
227
+ const timeoutMs = opts.timeout ?? 30_000;
228
+ const waitUntil = opts.waitUntil ?? "load";
229
+ const targetEvent =
230
+ waitUntil === "domcontentloaded" ? "Page.domContentEventFired" : "Page.loadEventFired";
231
+
232
+ // Page.enable is *not* on the §8.2 forbidden list — it's required for
233
+ // lifecycle events. Only Runtime.enable is forbidden.
234
+ await this.send("Page.enable");
235
+
236
+ const settled = new Promise<void>((resolve) => {
237
+ const off = this.router.on(targetEvent, (_params, sessionId) => {
238
+ // Filter to events from our session (flat mode delivers all events
239
+ // to the root listener, tagged by sessionId).
240
+ if (sessionId !== this.sessionId) return;
241
+ off();
242
+ resolve();
243
+ });
244
+ });
245
+ await this.send("Page.navigate", { url });
246
+ await Promise.race([
247
+ settled,
248
+ new Promise<never>((_, reject) =>
249
+ setTimeout(
250
+ () => reject(new Error(`[mochi] page.goto(${url}) timed out after ${timeoutMs}ms`)),
251
+ timeoutMs,
252
+ ),
253
+ ),
254
+ ]);
255
+ this.currentUrl = url;
256
+ }
257
+
258
+ /** Return the full serialized HTML of the document. */
259
+ async content(): Promise<string> {
260
+ this.assertOpen();
261
+ const docId = await this.documentObjectId();
262
+ const result = await this.send<{ result: RemoteObject }>("Runtime.callFunctionOn", {
263
+ objectId: docId,
264
+ functionDeclaration: "function() { return this.documentElement.outerHTML; }",
265
+ returnByValue: true,
266
+ });
267
+ const value = result.result.value;
268
+ if (typeof value !== "string") {
269
+ throw new Error("[mochi] page.content(): expected string from documentElement.outerHTML");
270
+ }
271
+ return value;
272
+ }
273
+
274
+ /**
275
+ * Return the `textContent` of the first element matching the selector, or
276
+ * `null` if no match. Uses `DOM.querySelector` + `Runtime.callFunctionOn`
277
+ * exactly per PLAN.md §8.3.
278
+ */
279
+ async text(selector: string): Promise<string | null> {
280
+ this.assertOpen();
281
+ const root = await this.documentNode();
282
+ const result = await this.send<{ nodeId: number }>("DOM.querySelector", {
283
+ nodeId: root.nodeId,
284
+ selector,
285
+ });
286
+ if (result.nodeId === 0) return null;
287
+ const resolved = await this.send<{ object: RemoteObject }>("DOM.resolveNode", {
288
+ nodeId: result.nodeId,
289
+ });
290
+ if (resolved.object.objectId === undefined) return null;
291
+ const callResult = await this.send<{ result: RemoteObject }>("Runtime.callFunctionOn", {
292
+ objectId: resolved.object.objectId,
293
+ functionDeclaration: "function() { return this.textContent; }",
294
+ returnByValue: true,
295
+ });
296
+ const value = callResult.result.value;
297
+ if (value === null || value === undefined) return null;
298
+ if (typeof value !== "string") {
299
+ throw new Error("[mochi] page.text(): expected string textContent");
300
+ }
301
+ return value;
302
+ }
303
+
304
+ /**
305
+ * Evaluate a function in the page's main world via `Runtime.callFunctionOn`
306
+ * against the document's objectId. The function runs as a method on the
307
+ * document (so `this` === document). Result is JSON-serialized via
308
+ * `returnByValue: true`.
309
+ *
310
+ * Limitations (documented in docs/limits.md):
311
+ * - Non-JSON return values (functions, DOM nodes, undefined) are
312
+ * coerced/dropped per CDP semantics.
313
+ * - The function must be a syntactically valid `function() { ... }`
314
+ * expression (closures over outer scope are not supported — this is
315
+ * standard for any cross-process evaluator).
316
+ * - Arguments cannot be passed in v0.1; the function takes no args.
317
+ */
318
+ async evaluate<T>(fn: () => T): Promise<T> {
319
+ this.assertOpen();
320
+ const docId = await this.documentObjectId();
321
+ const result = await this.send<{ result: RemoteObject }>("Runtime.callFunctionOn", {
322
+ objectId: docId,
323
+ functionDeclaration: fn.toString(),
324
+ returnByValue: true,
325
+ });
326
+ return result.result.value as T;
327
+ }
328
+
329
+ /**
330
+ * Wait for a selector to satisfy the requested `state`. v0.1 supports
331
+ * `attached` (default) and `visible`/`hidden`. Polls every 50ms.
332
+ */
333
+ async waitFor(selector: string, opts: WaitForOptions = {}): Promise<void> {
334
+ this.assertOpen();
335
+ const timeoutMs = opts.timeout ?? 30_000;
336
+ const state = opts.state ?? "attached";
337
+ const deadline = Date.now() + timeoutMs;
338
+ while (Date.now() < deadline) {
339
+ const ok = await this.evaluateSelectorState(selector, state);
340
+ if (ok) return;
341
+ await new Promise<void>((resolve) => setTimeout(resolve, 50));
342
+ }
343
+ throw new Error(
344
+ `[mochi] page.waitFor("${selector}", state=${state}) timed out after ${timeoutMs}ms`,
345
+ );
346
+ }
347
+
348
+ /** All cookies visible to this page (no filter at v0.1). */
349
+ async cookies(): Promise<Cookie[]> {
350
+ this.assertOpen();
351
+ const result = await this.send<{ cookies: Cookie[] }>("Network.getCookies");
352
+ return result.cookies;
353
+ }
354
+
355
+ /**
356
+ * Install an additional main-world script that runs on every new document
357
+ * via `Page.addScriptToEvaluateOnNewDocument({ runImmediately: true,
358
+ * worldName: "" })`. Returns the CDP identifier so callers can later
359
+ * remove it via {@link removeInitScript}.
360
+ *
361
+ * `worldName: ""` is critical — any non-empty string creates an isolated
362
+ * world (PLAN.md §8.4) which is detectable. `runImmediately: true` ensures
363
+ * the script also runs against the current document if one already exists,
364
+ * not just on the next navigation.
365
+ *
366
+ * Use cases:
367
+ * - The `@mochi.js/challenges` Turnstile detector (mounts a
368
+ * `MutationObserver` + Symbol-keyed reader on `document` in the page's
369
+ * main world, before any page script runs).
370
+ * - Any future per-page convenience layer that needs main-world
371
+ * mutation observation.
372
+ *
373
+ * The session-level inject payload is installed separately on every
374
+ * `newPage()` and is NOT routed through this method — convenience-layer
375
+ * scripts compose on top of it.
376
+ */
377
+ async addInitScript(source: string): Promise<string> {
378
+ this.assertOpen();
379
+ const result = await this.send<{ identifier: string }>(
380
+ "Page.addScriptToEvaluateOnNewDocument",
381
+ {
382
+ source,
383
+ runImmediately: true,
384
+ worldName: "",
385
+ },
386
+ );
387
+ return result.identifier;
388
+ }
389
+
390
+ /**
391
+ * Remove a previously-installed init script by its identifier (returned
392
+ * from {@link addInitScript}). Best-effort — silently ignores failures
393
+ * (e.g. the target was already closed).
394
+ */
395
+ async removeInitScript(identifier: string): Promise<void> {
396
+ if (this.closed) return;
397
+ try {
398
+ await this.send("Page.removeScriptToEvaluateOnNewDocument", { identifier });
399
+ } catch {
400
+ // Ignore — target might already be gone.
401
+ }
402
+ }
403
+
404
+ /** Tear down the page. Does not close the session's other pages. */
405
+ async close(): Promise<void> {
406
+ if (this.closed) return;
407
+ this.closed = true;
408
+ // PLAN.md §8.4 — un-register the inject script so the per-target
409
+ // identifier list stays bounded. Best-effort: if the page is already
410
+ // gone the remove call will fail and we ignore it.
411
+ if (this.injectScriptIdentifier !== null) {
412
+ try {
413
+ await this.router.send(
414
+ "Page.removeScriptToEvaluateOnNewDocument",
415
+ { identifier: this.injectScriptIdentifier },
416
+ { sessionId: this.sessionId },
417
+ );
418
+ } catch {
419
+ // Ignore — target might already be gone.
420
+ }
421
+ }
422
+ try {
423
+ // Target.closeTarget runs on the *root* (browser) target, not the page
424
+ // session — it's how we tell the browser to kill that page.
425
+ await this.router.send("Target.closeTarget", { targetId: this.targetId });
426
+ } catch {
427
+ // Ignore — session may already be tearing down.
428
+ }
429
+ }
430
+
431
+ // ---- Phase 0.8 — behavioral surface ----------------------------------------
432
+
433
+ /**
434
+ * The current cursor position (viewport-relative pixels). Tracked across
435
+ * `humanClick`/`humanMove` so consecutive movements compose realistically
436
+ * (a real user doesn't teleport between every action).
437
+ */
438
+ cursorPosition(): { x: number; y: number } {
439
+ return { x: this.cursor.x, y: this.cursor.y };
440
+ }
441
+
442
+ /**
443
+ * Animate the cursor to `(x, y)` along a human-shaped Bezier trajectory,
444
+ * WITHOUT pressing any button. Same dispatch path as `humanClick` minus
445
+ * the final `mousePressed` + `mouseReleased`. The cursor's last position
446
+ * updates so a subsequent `humanClick` chains realistically from this
447
+ * arrival point.
448
+ *
449
+ * Pipeline (PLAN.md §11.1):
450
+ * 1. Synthesize the `TrajectoryEvent[]` via `@mochi.js/behavioral` from
451
+ * the page's current cursor to `(x, y)`, using the resolved `behavior`
452
+ * profile + a deterministic per-call seed.
453
+ * 2. Dispatch each event as `Input.dispatchMouseEvent` of type
454
+ * `mouseMoved`, paced via `setTimeout` to match the synthesized `tMs`
455
+ * cadence.
456
+ * 3. Update `cursor` to the final synthesized point.
457
+ *
458
+ * Use cases:
459
+ * - Hover over an element without clicking (pre-click positioning).
460
+ * - Plausible idle cursor activity between explicit interactions.
461
+ * - Composing fine-grained drag/drop sequences (v1.x).
462
+ */
463
+ async humanMove(x: number, y: number, opts: HumanMoveOptions = {}): Promise<void> {
464
+ this.assertOpen();
465
+ const callSeed = this.nextCallSeed();
466
+ const traj = synthesizeMouseTrajectory({
467
+ from: { x: this.cursor.x, y: this.cursor.y },
468
+ to: { x, y },
469
+ profile: this.behavior,
470
+ seed: callSeed,
471
+ ...(opts.duration !== undefined ? { durationMs: opts.duration } : {}),
472
+ });
473
+ if (traj.length === 0) {
474
+ // Degenerate: from === to. Still register the position.
475
+ this.cursor = { x, y };
476
+ return;
477
+ }
478
+
479
+ let prevT = 0;
480
+ for (let i = 0; i < traj.length; i++) {
481
+ const ev = traj[i];
482
+ if (ev === undefined) continue;
483
+ const dt = ev.tMs - prevT;
484
+ if (i > 0 && dt > 0) await sleep(dt);
485
+ prevT = ev.tMs;
486
+ await this.dispatchMouse({
487
+ type: "mouseMoved",
488
+ x: ev.x,
489
+ y: ev.y,
490
+ button: "none",
491
+ });
492
+ }
493
+ const last = traj[traj.length - 1];
494
+ if (last !== undefined) {
495
+ this.cursor = { x: last.x, y: last.y };
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Move the mouse to the matched element with a human-shaped Bezier
501
+ * trajectory, then dispatch a single `mousePressed` + `mouseReleased`.
502
+ *
503
+ * Pipeline (PLAN.md §11.1):
504
+ * 1. Resolve the selector via `DOM.querySelector` + `DOM.getBoxModel`.
505
+ * 2. Synthesize the `TrajectoryEvent[]` via `@mochi.js/behavioral`,
506
+ * passing the page's resolved `behavior` profile + a deterministic
507
+ * seed (per-call counter mixed in).
508
+ * 3. Sleep `preMoveSettle` (default Gaussian(150, 50) ms).
509
+ * 4. Dispatch each trajectory event via `Input.dispatchMouseEvent` of
510
+ * type `mouseMoved`, paced via `setTimeout` to match the synthesized
511
+ * `tMs` cadence.
512
+ * 5. Dispatch `mousePressed` then `mouseReleased` at the final point
513
+ * with a realistic press duration (~30..80 ms).
514
+ */
515
+ async humanClick(selector: string, opts: HumanClickOptions = {}): Promise<void> {
516
+ this.assertOpen();
517
+ const root = await this.documentNode();
518
+ const result = await this.send<{ nodeId: number }>("DOM.querySelector", {
519
+ nodeId: root.nodeId,
520
+ selector,
521
+ });
522
+ if (result.nodeId === 0) {
523
+ throw new Error(`[mochi] humanClick: selector "${selector}" matched no element`);
524
+ }
525
+ const box = await this.send<{ model: BoxModel }>("DOM.getBoxModel", {
526
+ nodeId: result.nodeId,
527
+ });
528
+ const targetBox = boxFromBorderQuad(box.model);
529
+ const callSeed = this.nextCallSeed();
530
+ const traj = synthesizeMouseTrajectory({
531
+ from: { x: this.cursor.x, y: this.cursor.y },
532
+ to: { x: targetBox.x + targetBox.width / 2, y: targetBox.y + targetBox.height / 2 },
533
+ box: targetBox,
534
+ profile: this.behavior,
535
+ seed: callSeed,
536
+ ...(opts.duration !== undefined ? { durationMs: opts.duration } : {}),
537
+ });
538
+ if (traj.length === 0) return;
539
+
540
+ // Pre-move settle: Gaussian(150, 50) ms idle. Cheaply approximated via
541
+ // the seed-derived Gaussian on a short range.
542
+ const settle = opts.preMoveSettle ?? true;
543
+ if (settle) {
544
+ // 50..300 ms uniform (no need for full Gaussian here — just realism).
545
+ await sleep(150 + (hash01(callSeed) - 0.5) * 100);
546
+ }
547
+
548
+ // Dispatch trajectory events at synthesized cadence.
549
+ let prevT = 0;
550
+ for (let i = 0; i < traj.length; i++) {
551
+ const ev = traj[i];
552
+ if (ev === undefined) continue;
553
+ const dt = ev.tMs - prevT;
554
+ if (i > 0 && dt > 0) await sleep(dt);
555
+ prevT = ev.tMs;
556
+ await this.dispatchMouse({
557
+ type: "mouseMoved",
558
+ x: ev.x,
559
+ y: ev.y,
560
+ button: "none",
561
+ });
562
+ }
563
+ const last = traj[traj.length - 1];
564
+ if (last === undefined) return;
565
+ this.cursor = { x: last.x, y: last.y };
566
+
567
+ // Press + release.
568
+ const button = opts.button ?? "left";
569
+ const pressMs = 30 + Math.floor(hash01(`${callSeed}:press`) * 50); // 30..80
570
+ await this.dispatchMouse({
571
+ type: "mousePressed",
572
+ x: last.x,
573
+ y: last.y,
574
+ button,
575
+ buttons: buttonsMaskFor(button),
576
+ clickCount: 1,
577
+ });
578
+ await sleep(pressMs);
579
+ await this.dispatchMouse({
580
+ type: "mouseReleased",
581
+ x: last.x,
582
+ y: last.y,
583
+ button,
584
+ buttons: 0,
585
+ clickCount: 1,
586
+ });
587
+ }
588
+
589
+ /**
590
+ * Type `text` into the matched input with human-shaped per-key timing,
591
+ * digraph-aware delays, and configurable mistake injection.
592
+ *
593
+ * Special case — `text === ""`: clears the field by sending Backspace ×
594
+ * `value.length` with realistic key timings. The keystroke synth is reused
595
+ * with a string of N space placeholders to derive realistic press
596
+ * durations + inter-key delays; only the `key` is rewritten to "Backspace"
597
+ * and `text` is emptied so the dispatch produces deletion events.
598
+ *
599
+ * Pipeline (PLAN.md §11.2):
600
+ * 1. Focus the matched node via `DOM.focus({nodeId})`.
601
+ * 2. If `text === ""`, read `element.value.length` and synthesize N
602
+ * Backspace keystrokes; otherwise synthesize the literal text.
603
+ * 3. Dispatch each event as `keyDown` (with `text` for printable keys)
604
+ * then `keyUp`, paced by the synthesized `tDownMs` cadence.
605
+ */
606
+ async humanType(selector: string, text: string, opts: HumanTypeOptions = {}): Promise<void> {
607
+ this.assertOpen();
608
+ const root = await this.documentNode();
609
+ const result = await this.send<{ nodeId: number }>("DOM.querySelector", {
610
+ nodeId: root.nodeId,
611
+ selector,
612
+ });
613
+ if (result.nodeId === 0) {
614
+ throw new Error(`[mochi] humanType: selector "${selector}" matched no element`);
615
+ }
616
+ await this.send("DOM.focus", { nodeId: result.nodeId });
617
+
618
+ const profile: BehaviorProfile = {
619
+ ...this.behavior,
620
+ ...(opts.wpm !== undefined ? { wpm: opts.wpm } : {}),
621
+ };
622
+ const callSeed = this.nextCallSeed();
623
+
624
+ let events: ReturnType<typeof synthesizeKeystrokes>;
625
+ if (text === "") {
626
+ // Clear flow: figure out current value length via the focused element,
627
+ // then synthesize that many Backspace events.
628
+ const valueLength = await this.focusedElementValueLength(result.nodeId);
629
+ if (valueLength === 0) return;
630
+ const placeholder = " ".repeat(valueLength);
631
+ const synth = synthesizeKeystrokes({
632
+ text: placeholder,
633
+ profile,
634
+ seed: callSeed,
635
+ // Mistakes don't make sense for a clear; force-disable.
636
+ mistakeRate: 0,
637
+ });
638
+ events = synth.map((ev) => ({
639
+ ...ev,
640
+ key: "Backspace",
641
+ text: "",
642
+ }));
643
+ } else {
644
+ events = synthesizeKeystrokes({
645
+ text,
646
+ profile,
647
+ seed: callSeed,
648
+ ...(opts.mistakeRate !== undefined ? { mistakeRate: opts.mistakeRate } : {}),
649
+ });
650
+ }
651
+
652
+ let prevT = 0;
653
+ for (const ev of events) {
654
+ const dt = ev.tDownMs - prevT;
655
+ if (dt > 0) await sleep(dt);
656
+ const downParams = buildKeyEventParams("keyDown", ev.key, ev.text);
657
+ await this.dispatchKey(downParams);
658
+ const downDur = ev.tUpMs - ev.tDownMs;
659
+ if (downDur > 0) await sleep(downDur);
660
+ const upParams = buildKeyEventParams("keyUp", ev.key, ev.text);
661
+ await this.dispatchKey(upParams);
662
+ prevT = ev.tUpMs;
663
+ }
664
+ }
665
+
666
+ /**
667
+ * Read `element.value.length` for the matched element via
668
+ * `Runtime.callFunctionOn` (PLAN.md §8.3 — no `Runtime.evaluate`). Used by
669
+ * the `humanType("", selector)` clear path. Falls back to `0` for elements
670
+ * without a `value` (non-input).
671
+ */
672
+ private async focusedElementValueLength(nodeId: number): Promise<number> {
673
+ const resolved = await this.send<{ object: RemoteObject }>("DOM.resolveNode", {
674
+ nodeId,
675
+ });
676
+ if (resolved.object.objectId === undefined) return 0;
677
+ const r = await this.send<{ result: RemoteObject }>("Runtime.callFunctionOn", {
678
+ objectId: resolved.object.objectId,
679
+ functionDeclaration:
680
+ "function() { return typeof this.value === 'string' ? this.value.length : 0; }",
681
+ returnByValue: true,
682
+ });
683
+ const v = r.result.value;
684
+ return typeof v === "number" ? v : 0;
685
+ }
686
+
687
+ /**
688
+ * Inertial-scroll the page to a target Y position (or DOM element).
689
+ *
690
+ * Pipeline (PLAN.md §11.3):
691
+ * 1. Resolve `to` to an absolute Y delta (selector → element top, coords
692
+ * → use the y component directly).
693
+ * 2. Synthesize a `ScrollEvent[]` via `@mochi.js/behavioral`.
694
+ * 3. Dispatch each frame as `Input.dispatchMouseEvent` of type
695
+ * `mouseWheel` with the synthesized `deltaY`, paced at 60Hz.
696
+ */
697
+ async humanScroll(opts: HumanScrollOptions): Promise<void> {
698
+ this.assertOpen();
699
+ const targetY = await this.resolveScrollTargetY(opts.to);
700
+ const fromY = await this.currentScrollY();
701
+ const callSeed = this.nextCallSeed();
702
+ const events = synthesizeScroll({
703
+ from: fromY,
704
+ to: targetY,
705
+ profile: this.behavior,
706
+ seed: callSeed,
707
+ ...(opts.duration !== undefined ? { duration: opts.duration } : {}),
708
+ });
709
+ let prevT = 0;
710
+ for (const ev of events) {
711
+ const dt = ev.tMs - prevT;
712
+ if (dt > 0) await sleep(dt);
713
+ prevT = ev.tMs;
714
+ // mouseWheel events are dispatched at the current cursor position;
715
+ // x/y here is the wheel point, not a target. Use cursor or viewport
716
+ // center as a sane default.
717
+ await this.dispatchMouse({
718
+ type: "mouseWheel",
719
+ x: this.cursor.x,
720
+ y: this.cursor.y,
721
+ deltaX: 0,
722
+ deltaY: ev.deltaY,
723
+ });
724
+ }
725
+ }
726
+
727
+ screenshot(_opts?: unknown): Promise<Uint8Array> {
728
+ return Promise.reject(new NotImplementedError("page.screenshot"));
729
+ }
730
+
731
+ // ---- internals --------------------------------------------------------------
732
+
733
+ /** Helper: send a CDP method routed to this page's flat-mode session. */
734
+ private send<T = unknown>(method: string, params?: unknown): Promise<T> {
735
+ return this.router.send<T>(method, params, { sessionId: this.sessionId });
736
+ }
737
+
738
+ /** Subscribe to frame events to keep `currentUrl` and `mainFrameId` fresh. */
739
+ private subscribeFrameTopology(): void {
740
+ this.router.on("Page.frameNavigated", (params, sessionId) => {
741
+ if (sessionId !== this.sessionId) return;
742
+ const ev = params as FrameNavigatedEvent;
743
+ // The main frame has no `parentId`. (For OOPIF subframes we ignore.)
744
+ if (ev.frame.parentId === undefined) {
745
+ this._mainFrameId = ev.frame.id;
746
+ this.currentUrl = ev.frame.url;
747
+ }
748
+ });
749
+ // Page.frameAttached is consumed for topology bookkeeping that grows in
750
+ // later phases (worker fan-out, OOPIF correlation). v0.1 just acknowledges.
751
+ this.router.on("Page.frameAttached", () => {});
752
+ }
753
+
754
+ private async documentNode(): Promise<DomNode> {
755
+ const result = await this.send<{ root: DomNode }>("DOM.getDocument", {
756
+ depth: 1,
757
+ });
758
+ return result.root;
759
+ }
760
+
761
+ private async documentObjectId(): Promise<string> {
762
+ const root = await this.documentNode();
763
+ const resolved = await this.send<{ object: RemoteObject }>("DOM.resolveNode", {
764
+ backendNodeId: root.backendNodeId,
765
+ });
766
+ if (resolved.object.objectId === undefined) {
767
+ throw new Error("[mochi] DOM.resolveNode returned no objectId for the document node");
768
+ }
769
+ return resolved.object.objectId;
770
+ }
771
+
772
+ private async evaluateSelectorState(selector: string, state: WaitState): Promise<boolean> {
773
+ const docId = await this.documentObjectId();
774
+ const fn =
775
+ state === "attached"
776
+ ? `function(sel) { return !!this.querySelector(sel); }`
777
+ : state === "visible"
778
+ ? `function(sel) {
779
+ const el = this.querySelector(sel);
780
+ if (!el) return false;
781
+ const cs = (this.defaultView || window).getComputedStyle(el);
782
+ const r = el.getBoundingClientRect();
783
+ return cs.visibility !== 'hidden' && cs.display !== 'none' && r.width > 0 && r.height > 0;
784
+ }`
785
+ : `function(sel) {
786
+ const el = this.querySelector(sel);
787
+ if (!el) return true;
788
+ const cs = (this.defaultView || window).getComputedStyle(el);
789
+ const r = el.getBoundingClientRect();
790
+ return cs.visibility === 'hidden' || cs.display === 'none' || r.width === 0 || r.height === 0;
791
+ }`;
792
+ const result = await this.send<{ result: RemoteObject }>("Runtime.callFunctionOn", {
793
+ objectId: docId,
794
+ functionDeclaration: fn,
795
+ arguments: [{ value: selector }],
796
+ returnByValue: true,
797
+ });
798
+ return result.result.value === true;
799
+ }
800
+
801
+ private assertOpen(): void {
802
+ if (this.closed) {
803
+ throw new Error("[mochi] page is closed");
804
+ }
805
+ }
806
+
807
+ /** Send `Input.dispatchMouseEvent` against this page session. */
808
+ private dispatchMouse(params: DispatchMouseEventParams): Promise<unknown> {
809
+ return this.send("Input.dispatchMouseEvent", params);
810
+ }
811
+
812
+ /** Send `Input.dispatchKeyEvent` against this page session. */
813
+ private dispatchKey(params: DispatchKeyEventParams): Promise<unknown> {
814
+ return this.send("Input.dispatchKeyEvent", params);
815
+ }
816
+
817
+ /** Compose a per-call deterministic seed; different across humanX calls. */
818
+ private nextCallSeed(): string {
819
+ const n = this.callCounter++;
820
+ return `${this.seed}:${this.targetId}:${n}`;
821
+ }
822
+
823
+ /** Read `window.scrollY` via the document objectId. */
824
+ private async currentScrollY(): Promise<number> {
825
+ const docId = await this.documentObjectId();
826
+ const r = await this.send<{ result: RemoteObject }>("Runtime.callFunctionOn", {
827
+ objectId: docId,
828
+ functionDeclaration: "function() { return (this.defaultView || window).scrollY; }",
829
+ returnByValue: true,
830
+ });
831
+ const v = r.result.value;
832
+ return typeof v === "number" ? v : 0;
833
+ }
834
+
835
+ /**
836
+ * Resolve a `humanScroll` target into an absolute scroll-Y. Selector → the
837
+ * element's `top` plus current scroll. Coords → the y component.
838
+ */
839
+ private async resolveScrollTargetY(to: HumanScrollOptions["to"]): Promise<number> {
840
+ if (typeof to !== "string") return to.y;
841
+ const root = await this.documentNode();
842
+ const found = await this.send<{ nodeId: number }>("DOM.querySelector", {
843
+ nodeId: root.nodeId,
844
+ selector: to,
845
+ });
846
+ if (found.nodeId === 0) {
847
+ throw new Error(`[mochi] humanScroll: selector "${to}" matched no element`);
848
+ }
849
+ const resolved = await this.send<{ object: RemoteObject }>("DOM.resolveNode", {
850
+ nodeId: found.nodeId,
851
+ });
852
+ if (resolved.object.objectId === undefined) {
853
+ throw new Error("[mochi] humanScroll: failed to resolve node objectId");
854
+ }
855
+ const r = await this.send<{ result: RemoteObject }>("Runtime.callFunctionOn", {
856
+ objectId: resolved.object.objectId,
857
+ functionDeclaration:
858
+ "function() { const r = this.getBoundingClientRect(); const w = this.ownerDocument.defaultView || window; return w.scrollY + r.top; }",
859
+ returnByValue: true,
860
+ });
861
+ const v = r.result.value;
862
+ return typeof v === "number" ? v : 0;
863
+ }
864
+ }
865
+
866
+ // ---- module-private helpers -------------------------------------------------
867
+
868
+ /** Promise wrapper around `setTimeout`. */
869
+ function sleep(ms: number): Promise<void> {
870
+ if (ms <= 0) return Promise.resolve();
871
+ return new Promise<void>((resolve) => setTimeout(resolve, ms));
872
+ }
873
+
874
+ /**
875
+ * Convert a CDP `BoxModel.border` quad into a {x, y, width, height} box.
876
+ * The quad walks corners in CCW order; we take min/max for a robust AABB.
877
+ */
878
+ function boxFromBorderQuad(model: BoxModel): {
879
+ x: number;
880
+ y: number;
881
+ width: number;
882
+ height: number;
883
+ } {
884
+ const q = model.border;
885
+ if (q.length < 8) {
886
+ return { x: 0, y: 0, width: model.width, height: model.height };
887
+ }
888
+ const xs = [q[0] ?? 0, q[2] ?? 0, q[4] ?? 0, q[6] ?? 0];
889
+ const ys = [q[1] ?? 0, q[3] ?? 0, q[5] ?? 0, q[7] ?? 0];
890
+ const x = Math.min(...xs);
891
+ const y = Math.min(...ys);
892
+ const width = Math.max(...xs) - x;
893
+ const height = Math.max(...ys) - y;
894
+ return { x, y, width, height };
895
+ }
896
+
897
+ /** CDP `buttons` mask for `Input.dispatchMouseEvent`. */
898
+ function buttonsMaskFor(button: "left" | "right" | "middle"): number {
899
+ switch (button) {
900
+ case "left":
901
+ return 1;
902
+ case "right":
903
+ return 2;
904
+ case "middle":
905
+ return 4;
906
+ }
907
+ }
908
+
909
+ /**
910
+ * Build CDP `Input.dispatchKeyEvent` params from a `KeystrokeEvent`. Control
911
+ * keys (Backspace, Enter, Tab) NEED `windowsVirtualKeyCode` + `code` for
912
+ * Chromium to fire the corresponding edit-action handler in the focused
913
+ * input; without those Chromium just delivers a `keydown` to JS listeners
914
+ * but doesn't mutate the field. The mapping table below covers the small
915
+ * set of control keys mochi's behavioral synth produces.
916
+ *
917
+ * @see https://chromedevtools.github.io/devtools-protocol/tot/Input/#method-dispatchKeyEvent
918
+ */
919
+ function buildKeyEventParams(
920
+ type: "keyDown" | "keyUp",
921
+ key: string,
922
+ text: string,
923
+ ): DispatchKeyEventParams {
924
+ const params: DispatchKeyEventParams = { type, key };
925
+ // Printable keys carry their literal as `text` on keydown so the page sees
926
+ // both the keyboard event AND the input event. (CDP requires `text`
927
+ // present, and an empty string is *not* the same as omitting.)
928
+ if (type === "keyDown" && text !== "") params.text = text;
929
+ const meta = CONTROL_KEY_META[key];
930
+ if (meta !== undefined) {
931
+ params.code = meta.code;
932
+ params.windowsVirtualKeyCode = meta.vk;
933
+ } else if (text !== "" && text.length === 1) {
934
+ // Printable single-character key: derive a plausible KeyboardEvent.code
935
+ // and the ASCII virtual key code so layout-aware page code can read
936
+ // event.code and event.keyCode. Chromium accepts either upper-case
937
+ // letter codes or `KeyA`-style; we use the latter for letters.
938
+ const ch = text;
939
+ const upper = ch.toUpperCase();
940
+ if (upper >= "A" && upper <= "Z") {
941
+ params.code = `Key${upper}`;
942
+ params.windowsVirtualKeyCode = upper.charCodeAt(0);
943
+ } else if (ch >= "0" && ch <= "9") {
944
+ params.code = `Digit${ch}`;
945
+ params.windowsVirtualKeyCode = ch.charCodeAt(0);
946
+ } else if (ch === " ") {
947
+ params.code = "Space";
948
+ params.windowsVirtualKeyCode = 32;
949
+ }
950
+ }
951
+ return params;
952
+ }
953
+
954
+ /**
955
+ * Mapping from CDP `key` (DOM `KeyboardEvent.key`) to its `KeyboardEvent.code`
956
+ * and Windows virtual-key code. Only the control keys the behavioral synth
957
+ * currently produces — extend as new control keys land.
958
+ */
959
+ const CONTROL_KEY_META: Record<string, { code: string; vk: number }> = {
960
+ Backspace: { code: "Backspace", vk: 8 },
961
+ Tab: { code: "Tab", vk: 9 },
962
+ Enter: { code: "Enter", vk: 13 },
963
+ Escape: { code: "Escape", vk: 27 },
964
+ Delete: { code: "Delete", vk: 46 },
965
+ };
966
+
967
+ /**
968
+ * Cheap deterministic 32-bit hash → [0, 1) of a string. Used for the small
969
+ * settle-delay and press-duration jitter in the dispatch layer (the major
970
+ * randomness lives inside the synthesized event arrays). Not cryptographic.
971
+ */
972
+ function hash01(s: string): number {
973
+ let h = 2166136261 >>> 0; // FNV-1a 32-bit
974
+ for (let i = 0; i < s.length; i++) {
975
+ h ^= s.charCodeAt(i);
976
+ h = Math.imul(h, 16777619) >>> 0;
977
+ }
978
+ return (h >>> 0) / 0x1_0000_0000;
979
+ }