@mochi.js/core 0.1.2 → 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 +5 -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__/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 +1 -0
- package/src/launch.ts +73 -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 +142 -0
- package/src/proc.ts +386 -41
- package/src/session.ts +140 -12
package/src/session.ts
CHANGED
|
@@ -184,6 +184,18 @@ export class Session {
|
|
|
184
184
|
*/
|
|
185
185
|
private readonly challengesOpts: SessionInit["challenges"] | undefined;
|
|
186
186
|
private readonly challengeHandles: ChallengeHandle[] = [];
|
|
187
|
+
/**
|
|
188
|
+
* Cache of resolved execution-context ids for worker-style targets,
|
|
189
|
+
* keyed by the worker session id. Populated by
|
|
190
|
+
* {@link extractWorkerExecutionContextId} on first attach and reused by
|
|
191
|
+
* any later worker CDP op that needs an `executionContextId`. Patchright
|
|
192
|
+
* keeps this on a per-target `CRExecutionContext`; mochi keeps the
|
|
193
|
+
* Session-local map until we grow a real worker-target abstraction.
|
|
194
|
+
*
|
|
195
|
+
* @see crServiceWorkerPatch.ts:32-43, crPagePatch.ts:404-417
|
|
196
|
+
* @internal
|
|
197
|
+
*/
|
|
198
|
+
private readonly workerExecutionContextIds = new Map<string, number>();
|
|
187
199
|
|
|
188
200
|
constructor(init: SessionInit) {
|
|
189
201
|
this.proc = init.proc;
|
|
@@ -254,6 +266,30 @@ export class Session {
|
|
|
254
266
|
// (only Runtime.enable is forbidden). We enable here so subsequent
|
|
255
267
|
// addScriptToEvaluateOnNewDocument is honoured by the page domain.
|
|
256
268
|
await this.router.send("Page.enable", undefined, { sessionId: attached.sessionId });
|
|
269
|
+
// Task 0255: defensive UA override at the network layer.
|
|
270
|
+
//
|
|
271
|
+
// The inject payload (Page.addScriptToEvaluateOnNewDocument) spoofs
|
|
272
|
+
// `navigator.userAgent` in the JS surface, but `Network.requestWillBeSent`
|
|
273
|
+
// events (and the request line itself) carry the BARE browser UA — which
|
|
274
|
+
// under `--headless=new` still contains the substring "HeadlessChrome".
|
|
275
|
+
// The inject can never reach those bytes because they're emitted before
|
|
276
|
+
// any document script runs.
|
|
277
|
+
//
|
|
278
|
+
// `Network.setUserAgentOverride` is a per-target setter that does NOT
|
|
279
|
+
// require `Network.enable` (it only stores override state); §8.2's ban
|
|
280
|
+
// on `Network.enable` is therefore unaffected. Sent immediately after
|
|
281
|
+
// attach and before any navigation so the very first request the page
|
|
282
|
+
// issues already carries the matrix UA.
|
|
283
|
+
//
|
|
284
|
+
// Skipped under `bypassInject:true` (PLAN.md §12.1) — capture flows must
|
|
285
|
+
// record the bare browser fingerprint, including its raw UA.
|
|
286
|
+
if (!this.bypassInject) {
|
|
287
|
+
await this.router.send(
|
|
288
|
+
"Network.setUserAgentOverride",
|
|
289
|
+
{ userAgent: this.profile.userAgent },
|
|
290
|
+
{ sessionId: attached.sessionId },
|
|
291
|
+
);
|
|
292
|
+
}
|
|
257
293
|
// PLAN.md §12.1 / task 0040 — capture flow short-circuits inject so the
|
|
258
294
|
// browser reports its bare fingerprint. Otherwise install the payload
|
|
259
295
|
// main-world via §8.4. worldName MUST be the empty string.
|
|
@@ -557,18 +593,35 @@ export class Session {
|
|
|
557
593
|
|
|
558
594
|
/**
|
|
559
595
|
* Inject the payload into a freshly-attached target if it's a worker-
|
|
560
|
-
* style target (dedicated worker, shared worker,
|
|
561
|
-
*
|
|
596
|
+
* style target (dedicated worker, shared worker, audio worklet — service
|
|
597
|
+
* workers go through the same path; see notes below), then resume it.
|
|
562
598
|
*
|
|
563
599
|
* Worker targets do NOT support `Page.addScriptToEvaluateOnNewDocument`
|
|
564
|
-
* (no Page domain). PLAN.md §8.4 calls out that
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
* `
|
|
600
|
+
* (no Page domain). PLAN.md §8.4 calls out that the worker target accepts
|
|
601
|
+
* `Runtime.evaluate` even though `Runtime.enable` is forbidden by §8.2.
|
|
602
|
+
*
|
|
603
|
+
* The Patchright-cited bootstrap (task 0254 — `crServiceWorkerPatch.ts:32-43`,
|
|
604
|
+
* `crPagePatch.ts:404-417`) tightens the inject race window:
|
|
605
|
+
* 1. `Runtime.evaluate("globalThis", { serialization: "idOnly" })` —
|
|
606
|
+
* returns a `RemoteObject` whose `objectId` carries the worker's
|
|
607
|
+
* execution-context id. `serialization: "idOnly"` skips the value
|
|
608
|
+
* preview round-trip we don't need.
|
|
609
|
+
* 2. Parse `objectId.split(".")[1]` for the contextId. The wire format
|
|
610
|
+
* is `"<runtimeAgentId>.<contextId>.<remoteObjectId>"`; we validate
|
|
611
|
+
* the split and fail loudly if Chromium has moved the goalposts.
|
|
612
|
+
* 3. Inject the payload via `Runtime.callFunctionOn({ functionDeclaration,
|
|
613
|
+
* executionContextId, returnByValue: true })`. This binds the call
|
|
614
|
+
* to the worker's own context rather than relying on
|
|
615
|
+
* `Runtime.evaluate`'s implicit context resolution, which is the
|
|
616
|
+
* coarser pattern v0.1.x used.
|
|
617
|
+
* 4. `Runtime.runIfWaitingForDebugger` to resume the target.
|
|
569
618
|
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
619
|
+
* We never send `Runtime.enable` — that's the whole point of extracting
|
|
620
|
+
* the contextId via the idOnly trick instead of waiting for an
|
|
621
|
+
* `Runtime.executionContextCreated` event.
|
|
622
|
+
*
|
|
623
|
+
* Caveat: worker injection has a smaller stealth ceiling than main-world
|
|
624
|
+
* Page injection. Documented in `docs/limits.md`.
|
|
572
625
|
*/
|
|
573
626
|
private async handleAttachedTarget(
|
|
574
627
|
ev: AttachedToTargetEvent,
|
|
@@ -584,12 +637,20 @@ export class Session {
|
|
|
584
637
|
// PLAN.md §12.1 / task 0040 — capture flow skips worker injection too.
|
|
585
638
|
if (isWorkerLike && !this.bypassInject && this._payload !== null) {
|
|
586
639
|
try {
|
|
640
|
+
const executionContextId = await this.extractWorkerExecutionContextId(childSessionId);
|
|
641
|
+
this.workerExecutionContextIds.set(childSessionId, executionContextId);
|
|
642
|
+
// `Runtime.callFunctionOn` requires either an `objectId` OR an
|
|
643
|
+
// `executionContextId`. We use the latter — patchright's pattern —
|
|
644
|
+
// so the call binds to the worker's own context, not whatever
|
|
645
|
+
// `Runtime.evaluate` happens to resolve. The payload IIFE is wrapped
|
|
646
|
+
// as a function declaration so `callFunctionOn` accepts it.
|
|
587
647
|
await this.router.send(
|
|
588
|
-
"Runtime.
|
|
648
|
+
"Runtime.callFunctionOn",
|
|
589
649
|
{
|
|
590
|
-
|
|
650
|
+
functionDeclaration: `function() { ${this._payload.code} }`,
|
|
651
|
+
executionContextId,
|
|
652
|
+
returnByValue: true,
|
|
591
653
|
awaitPromise: false,
|
|
592
|
-
returnByValue: false,
|
|
593
654
|
// includeCommandLineAPI must remain false (§8.2).
|
|
594
655
|
},
|
|
595
656
|
{ sessionId: childSessionId },
|
|
@@ -617,6 +678,73 @@ export class Session {
|
|
|
617
678
|
}
|
|
618
679
|
}
|
|
619
680
|
|
|
681
|
+
/**
|
|
682
|
+
* Resolve the worker target's execution-context id WITHOUT
|
|
683
|
+
* `Runtime.enable` — patchright's trick.
|
|
684
|
+
*
|
|
685
|
+
* Sends `Runtime.evaluate("globalThis", { serialization: "idOnly" })`
|
|
686
|
+
* against the paused worker session. The returned `RemoteObject.objectId`
|
|
687
|
+
* has the on-the-wire shape `"<runtimeAgentId>.<contextId>.<localId>"`
|
|
688
|
+
* (Chromium >= v131; verified against patchright's parser). We extract
|
|
689
|
+
* `split(".")[1]` and assert it's a positive integer.
|
|
690
|
+
*
|
|
691
|
+
* Throws with a precise diagnostic if Chromium changes the format —
|
|
692
|
+
* silent fallback would mask a real wire-protocol shift, which we want
|
|
693
|
+
* to catch in CI rather than ship as a degraded inject path.
|
|
694
|
+
*
|
|
695
|
+
* @see crServiceWorkerPatch.ts:32-43
|
|
696
|
+
*/
|
|
697
|
+
private async extractWorkerExecutionContextId(childSessionId: string): Promise<number> {
|
|
698
|
+
const evalRes = await this.router.send<{ result: { objectId?: string; type?: string } }>(
|
|
699
|
+
"Runtime.evaluate",
|
|
700
|
+
{
|
|
701
|
+
expression: "globalThis",
|
|
702
|
+
// idOnly skips full value serialisation — we want the objectId
|
|
703
|
+
// alone. Supported on Chromium >= v124 (chrome-for-testing v131+
|
|
704
|
+
// in the mochi profile floor).
|
|
705
|
+
serialization: "idOnly",
|
|
706
|
+
// includeCommandLineAPI must remain false (§8.2).
|
|
707
|
+
},
|
|
708
|
+
{ sessionId: childSessionId },
|
|
709
|
+
);
|
|
710
|
+
const objectId = evalRes.result.objectId;
|
|
711
|
+
if (typeof objectId !== "string" || objectId.length === 0) {
|
|
712
|
+
throw new Error(
|
|
713
|
+
`[mochi] worker idOnly bootstrap: Runtime.evaluate("globalThis") returned no objectId (got ${JSON.stringify(evalRes.result)})`,
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
const parts = objectId.split(".");
|
|
717
|
+
// Format: "<runtimeAgentId>.<contextId>.<localId>" — patchright also
|
|
718
|
+
// pulls index [1]. Refuse to guess if the segment count shifts.
|
|
719
|
+
if (parts.length < 2) {
|
|
720
|
+
throw new Error(
|
|
721
|
+
`[mochi] worker idOnly bootstrap: unexpected objectId shape "${objectId}" (expected dotted segments)`,
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
const ctxRaw = parts[1];
|
|
725
|
+
if (ctxRaw === undefined || ctxRaw.length === 0) {
|
|
726
|
+
throw new Error(
|
|
727
|
+
`[mochi] worker idOnly bootstrap: objectId "${objectId}" has empty contextId segment`,
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
const contextId = Number.parseInt(ctxRaw, 10);
|
|
731
|
+
if (!Number.isInteger(contextId) || contextId <= 0 || String(contextId) !== ctxRaw) {
|
|
732
|
+
throw new Error(
|
|
733
|
+
`[mochi] worker idOnly bootstrap: contextId segment "${ctxRaw}" of objectId "${objectId}" is not a positive integer`,
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
return contextId;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Snapshot of the worker → executionContextId cache. Test-only.
|
|
741
|
+
*
|
|
742
|
+
* @internal
|
|
743
|
+
*/
|
|
744
|
+
_internalWorkerExecutionContextIds(): ReadonlyMap<string, number> {
|
|
745
|
+
return new Map(this.workerExecutionContextIds);
|
|
746
|
+
}
|
|
747
|
+
|
|
620
748
|
private installCrashGuard(): void {
|
|
621
749
|
// If Chromium dies unexpectedly, we want to mark the session closed so
|
|
622
750
|
// pending and future calls reject cleanly.
|