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