@mochi.js/core 0.1.0 → 0.2.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/package.json +6 -5
- package/src/__tests__/inject.test.ts +2 -0
- package/src/__tests__/piercing.test.ts +164 -0
- package/src/__tests__/proc.test.ts +383 -0
- package/src/__tests__/proxy-auth.test.ts +2 -2
- package/src/__tests__/selector.test.ts +188 -0
- package/src/__tests__/window-size.e2e.test.ts +130 -0
- package/src/cdp/types.ts +47 -0
- package/src/index.ts +2 -0
- package/src/launch.ts +119 -8
- package/src/page/element-handle.ts +110 -0
- package/src/page/piercing.ts +135 -0
- package/src/page/selector.ts +423 -0
- package/src/page.ts +191 -0
- package/src/proc.ts +386 -41
- package/src/proxy-auth.ts +36 -19
- package/src/session.ts +197 -12
package/src/page.ts
CHANGED
|
@@ -35,9 +35,13 @@ import type {
|
|
|
35
35
|
DispatchMouseEventParams,
|
|
36
36
|
DomNode,
|
|
37
37
|
FrameNavigatedEvent,
|
|
38
|
+
PierceDomNode,
|
|
38
39
|
RemoteObject,
|
|
39
40
|
} from "./cdp/types";
|
|
40
41
|
import { NotImplementedError } from "./errors";
|
|
42
|
+
import { ElementHandle } from "./page/element-handle";
|
|
43
|
+
import { findPiercingMatches } from "./page/piercing";
|
|
44
|
+
import { parseSelector } from "./page/selector";
|
|
41
45
|
|
|
42
46
|
/** Wait conditions for `Page.goto`. */
|
|
43
47
|
export type WaitUntil = "load" | "domcontentloaded" | "networkidle";
|
|
@@ -352,6 +356,55 @@ export class Page {
|
|
|
352
356
|
return result.cookies;
|
|
353
357
|
}
|
|
354
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Install an additional main-world script that runs on every new document
|
|
361
|
+
* via `Page.addScriptToEvaluateOnNewDocument({ runImmediately: true,
|
|
362
|
+
* worldName: "" })`. Returns the CDP identifier so callers can later
|
|
363
|
+
* remove it via {@link removeInitScript}.
|
|
364
|
+
*
|
|
365
|
+
* `worldName: ""` is critical — any non-empty string creates an isolated
|
|
366
|
+
* world (PLAN.md §8.4) which is detectable. `runImmediately: true` ensures
|
|
367
|
+
* the script also runs against the current document if one already exists,
|
|
368
|
+
* not just on the next navigation.
|
|
369
|
+
*
|
|
370
|
+
* Use cases:
|
|
371
|
+
* - The `@mochi.js/challenges` Turnstile detector (mounts a
|
|
372
|
+
* `MutationObserver` + Symbol-keyed reader on `document` in the page's
|
|
373
|
+
* main world, before any page script runs).
|
|
374
|
+
* - Any future per-page convenience layer that needs main-world
|
|
375
|
+
* mutation observation.
|
|
376
|
+
*
|
|
377
|
+
* The session-level inject payload is installed separately on every
|
|
378
|
+
* `newPage()` and is NOT routed through this method — convenience-layer
|
|
379
|
+
* scripts compose on top of it.
|
|
380
|
+
*/
|
|
381
|
+
async addInitScript(source: string): Promise<string> {
|
|
382
|
+
this.assertOpen();
|
|
383
|
+
const result = await this.send<{ identifier: string }>(
|
|
384
|
+
"Page.addScriptToEvaluateOnNewDocument",
|
|
385
|
+
{
|
|
386
|
+
source,
|
|
387
|
+
runImmediately: true,
|
|
388
|
+
worldName: "",
|
|
389
|
+
},
|
|
390
|
+
);
|
|
391
|
+
return result.identifier;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Remove a previously-installed init script by its identifier (returned
|
|
396
|
+
* from {@link addInitScript}). Best-effort — silently ignores failures
|
|
397
|
+
* (e.g. the target was already closed).
|
|
398
|
+
*/
|
|
399
|
+
async removeInitScript(identifier: string): Promise<void> {
|
|
400
|
+
if (this.closed) return;
|
|
401
|
+
try {
|
|
402
|
+
await this.send("Page.removeScriptToEvaluateOnNewDocument", { identifier });
|
|
403
|
+
} catch {
|
|
404
|
+
// Ignore — target might already be gone.
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
355
408
|
/** Tear down the page. Does not close the session's other pages. */
|
|
356
409
|
async close(): Promise<void> {
|
|
357
410
|
if (this.closed) return;
|
|
@@ -478,6 +531,39 @@ export class Page {
|
|
|
478
531
|
});
|
|
479
532
|
const targetBox = boxFromBorderQuad(box.model);
|
|
480
533
|
const callSeed = this.nextCallSeed();
|
|
534
|
+
// Trajectory synth lives here (not in `performClickAt`) so prototype
|
|
535
|
+
// inspection in conformance tests can see the synthesize / trajectory
|
|
536
|
+
// / cursor markers — they're a consumer-side smoke check that the
|
|
537
|
+
// behavioral synth is wired in.
|
|
538
|
+
const traj = synthesizeMouseTrajectory({
|
|
539
|
+
from: { x: this.cursor.x, y: this.cursor.y },
|
|
540
|
+
to: { x: targetBox.x + targetBox.width / 2, y: targetBox.y + targetBox.height / 2 },
|
|
541
|
+
box: targetBox,
|
|
542
|
+
profile: this.behavior,
|
|
543
|
+
seed: callSeed,
|
|
544
|
+
...(opts.duration !== undefined ? { durationMs: opts.duration } : {}),
|
|
545
|
+
});
|
|
546
|
+
await this.dispatchClickTrajectory(traj, callSeed, opts);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Variant of {@link humanClick} that operates on an {@link ElementHandle}
|
|
551
|
+
* resolved via {@link querySelectorPiercing} — required when the target
|
|
552
|
+
* element lives inside a closed shadow root (no CSS path can name it from
|
|
553
|
+
* the parent document, so the regular `humanClick(selector)` route fails).
|
|
554
|
+
*
|
|
555
|
+
* Pipeline differs from {@link humanClick} only in step 1: the box model
|
|
556
|
+
* is resolved via `DOM.getBoxModel({ backendNodeId })` instead of through a
|
|
557
|
+
* `DOM.querySelector`-resolved nodeId. Everything downstream (trajectory
|
|
558
|
+
* synth, dispatch loop, press/release) is identical.
|
|
559
|
+
*/
|
|
560
|
+
async humanClickHandle(handle: ElementHandle, opts: HumanClickOptions = {}): Promise<void> {
|
|
561
|
+
this.assertOpen();
|
|
562
|
+
const box = await this.send<{ model: BoxModel }>("DOM.getBoxModel", {
|
|
563
|
+
backendNodeId: handle.backendNodeId,
|
|
564
|
+
});
|
|
565
|
+
const targetBox = boxFromBorderQuad(box.model);
|
|
566
|
+
const callSeed = this.nextCallSeed();
|
|
481
567
|
const traj = synthesizeMouseTrajectory({
|
|
482
568
|
from: { x: this.cursor.x, y: this.cursor.y },
|
|
483
569
|
to: { x: targetBox.x + targetBox.width / 2, y: targetBox.y + targetBox.height / 2 },
|
|
@@ -486,6 +572,22 @@ export class Page {
|
|
|
486
572
|
seed: callSeed,
|
|
487
573
|
...(opts.duration !== undefined ? { durationMs: opts.duration } : {}),
|
|
488
574
|
});
|
|
575
|
+
await this.dispatchClickTrajectory(traj, callSeed, opts);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Inner dispatch loop shared by {@link humanClick} and
|
|
580
|
+
* {@link humanClickHandle}. Takes the synthesised trajectory, paces the
|
|
581
|
+
* `mouseMoved` events, then fires `mousePressed` + `mouseReleased` at the
|
|
582
|
+
* arrival point with realistic press duration. Trajectory synth itself
|
|
583
|
+
* stays inside the public methods so source-grep conformance checks can
|
|
584
|
+
* verify the synth is reachable from the public API.
|
|
585
|
+
*/
|
|
586
|
+
private async dispatchClickTrajectory(
|
|
587
|
+
traj: ReturnType<typeof synthesizeMouseTrajectory>,
|
|
588
|
+
callSeed: string,
|
|
589
|
+
opts: HumanClickOptions,
|
|
590
|
+
): Promise<void> {
|
|
489
591
|
if (traj.length === 0) return;
|
|
490
592
|
|
|
491
593
|
// Pre-move settle: Gaussian(150, 50) ms idle. Cheaply approximated via
|
|
@@ -675,6 +777,95 @@ export class Page {
|
|
|
675
777
|
}
|
|
676
778
|
}
|
|
677
779
|
|
|
780
|
+
/**
|
|
781
|
+
* Closed-shadow-root piercing locator — find the first element matching the
|
|
782
|
+
* CSS selector across the entire DOM tree, including elements nested inside
|
|
783
|
+
* **closed** shadow roots (which {@link text}, {@link humanClick}, etc. can
|
|
784
|
+
* NOT reach because `DOM.querySelector` does not traverse closed shadows
|
|
785
|
+
* even with `pierce: true` set on the parent `getDocument` call).
|
|
786
|
+
*
|
|
787
|
+
* Required for Cloudflare Turnstile auto-click on integrations where the
|
|
788
|
+
* widget iframe lives behind a closed shadow root (Cloudflare Challenge
|
|
789
|
+
* pages, Workers Static Assets, some CDN configs). Without this, task
|
|
790
|
+
* 0220's auto-click silently fails on those flows.
|
|
791
|
+
*
|
|
792
|
+
* Algorithm (port of patchright `framesPatch.ts:868-1012`
|
|
793
|
+
* `_customFindElementsByParsed`):
|
|
794
|
+
* 1. `DOM.getDocument({ depth: -1, pierce: true })` — yields the full
|
|
795
|
+
* tree, with shadow descendants under `shadowRoots[]` for both open
|
|
796
|
+
* AND closed roots.
|
|
797
|
+
* 2. Recursive walk in JS, matching against a parsed CSS selector. We
|
|
798
|
+
* can't `DOM.querySelector` per shadow because the per-shadow query
|
|
799
|
+
* itself doesn't pierce closed roots either.
|
|
800
|
+
* 3. For matches, `DOM.resolveNode({ backendNodeId })` to get a
|
|
801
|
+
* `RemoteObject.objectId`, wrapped in {@link ElementHandle}.
|
|
802
|
+
*
|
|
803
|
+
* Supported selectors (see `selector.ts`): tag / id / class / attribute /
|
|
804
|
+
* descendant combinator / comma-separated lists. **Not** supported:
|
|
805
|
+
* `>`/`+`/`~` combinators, `:pseudo-classes`, `::pseudo-elements`, XPath.
|
|
806
|
+
* XPath is a stretch goal per task 0253 brief — TODO if a future surface
|
|
807
|
+
* needs it (Turnstile detection only needs CSS).
|
|
808
|
+
*
|
|
809
|
+
* Performance: O(N) in DOM size per call. Acceptable for v0.2; a per-page
|
|
810
|
+
* cache layer is a v0.3+ concern (also called out in 0253).
|
|
811
|
+
*
|
|
812
|
+
* @see tasks/0253-closed-shadow-piercing-locator.md
|
|
813
|
+
* @see PLAN.md §8.2 (`DOM.getDocument` and `DOM.resolveNode` are not on the
|
|
814
|
+
* forbidden list — both fine to use here).
|
|
815
|
+
*/
|
|
816
|
+
async querySelectorPiercing(selector: string): Promise<ElementHandle | null> {
|
|
817
|
+
const handles = await this.queryPiercing(selector, 1);
|
|
818
|
+
return handles[0] ?? null;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* The "all matches" variant of {@link querySelectorPiercing}. Returns every
|
|
823
|
+
* element that satisfies the selector, in depth-first pre-order — same
|
|
824
|
+
* traversal a regular `querySelectorAll` produces, with closed-shadow
|
|
825
|
+
* descendants spliced in at the position they'd appear under the host.
|
|
826
|
+
*
|
|
827
|
+
* Returns an empty array when nothing matches.
|
|
828
|
+
*/
|
|
829
|
+
async querySelectorAllPiercing(selector: string): Promise<ElementHandle[]> {
|
|
830
|
+
return this.queryPiercing(selector);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** Shared implementation for the piercing locator. `limit` short-circuits the walk. */
|
|
834
|
+
private async queryPiercing(selector: string, limit?: number): Promise<ElementHandle[]> {
|
|
835
|
+
this.assertOpen();
|
|
836
|
+
const parsed = parseSelector(selector);
|
|
837
|
+
// depth: -1 + pierce: true is the magic combination patchright uses; CDP
|
|
838
|
+
// returns a fully-flattened tree including shadow descendants on both
|
|
839
|
+
// open and closed roots, AND iframe contentDocuments for same-origin
|
|
840
|
+
// children.
|
|
841
|
+
const root = await this.send<{ root: PierceDomNode }>("DOM.getDocument", {
|
|
842
|
+
depth: -1,
|
|
843
|
+
pierce: true,
|
|
844
|
+
});
|
|
845
|
+
const matches = findPiercingMatches(root.root, parsed, limit);
|
|
846
|
+
if (matches.length === 0) return [];
|
|
847
|
+
const handles: ElementHandle[] = [];
|
|
848
|
+
for (const m of matches) {
|
|
849
|
+
const resolved = await this.send<{ object: RemoteObject }>("DOM.resolveNode", {
|
|
850
|
+
backendNodeId: m.backendNodeId,
|
|
851
|
+
});
|
|
852
|
+
const objectId = resolved.object.objectId;
|
|
853
|
+
// Skip nodes the protocol couldn't bind to a RemoteObject (rare — e.g.
|
|
854
|
+
// detached subtree races). Surfacing a partial set is more useful than
|
|
855
|
+
// throwing for the Turnstile detector path.
|
|
856
|
+
if (objectId === undefined) continue;
|
|
857
|
+
handles.push(
|
|
858
|
+
new ElementHandle({
|
|
859
|
+
router: this.router,
|
|
860
|
+
sessionId: this.sessionId,
|
|
861
|
+
objectId,
|
|
862
|
+
backendNodeId: m.backendNodeId,
|
|
863
|
+
}),
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
return handles;
|
|
867
|
+
}
|
|
868
|
+
|
|
678
869
|
screenshot(_opts?: unknown): Promise<Uint8Array> {
|
|
679
870
|
return Promise.reject(new NotImplementedError("page.screenshot"));
|
|
680
871
|
}
|