@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/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
|
+
}
|