@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.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Unit tests for the host-side CSS selector parser + matcher used by
3
+ * `Page.querySelectorPiercing`. Covers the exact subset documented in
4
+ * `packages/core/src/page/selector.ts`:
5
+ *
6
+ * - tag / id / class / attribute / descendant combinator
7
+ * - comma-separated lists
8
+ * - quoted attribute values, attribute operators (`=`, `~=`, `|=`, `^=`,
9
+ * `$=`, `*=`, presence)
10
+ *
11
+ * NOT covered here (intentional out-of-scope per task 0253): `>`/`+`/`~`
12
+ * combinators, pseudo-classes / -elements, XPath. The matcher should reject
13
+ * those at parse time.
14
+ */
15
+
16
+ import { describe, expect, it } from "bun:test";
17
+ import type { PierceDomNode } from "../cdp/types";
18
+ import { matchSelector, parseSelector, readAttribute, SelectorParseError } from "../page/selector";
19
+
20
+ /** Build a minimal element node for matcher tests. */
21
+ function el(tag: string, attrs: Record<string, string> = {}): PierceDomNode {
22
+ const flat: string[] = [];
23
+ for (const [k, v] of Object.entries(attrs)) {
24
+ flat.push(k, v);
25
+ }
26
+ return {
27
+ nodeId: 1,
28
+ backendNodeId: 1,
29
+ nodeType: 1,
30
+ nodeName: tag.toUpperCase(),
31
+ localName: tag.toLowerCase(),
32
+ attributes: flat,
33
+ };
34
+ }
35
+
36
+ describe("parseSelector — accepted grammar", () => {
37
+ it("parses a bare tag", () => {
38
+ const p = parseSelector("div");
39
+ expect(p.chains).toHaveLength(1);
40
+ expect(p.chains[0]?.parts).toHaveLength(1);
41
+ expect(p.chains[0]?.parts[0]?.tag).toBe("div");
42
+ });
43
+
44
+ it("parses a class selector with no tag (universal)", () => {
45
+ const p = parseSelector(".btn");
46
+ expect(p.chains[0]?.parts[0]?.tag).toBe("*");
47
+ expect(p.chains[0]?.parts[0]?.classes).toEqual(["btn"]);
48
+ });
49
+
50
+ it("parses tag + id + multiple classes", () => {
51
+ const p = parseSelector("button#submit.primary.large");
52
+ const part = p.chains[0]?.parts[0];
53
+ expect(part?.tag).toBe("button");
54
+ expect(part?.id).toBe("submit");
55
+ expect(part?.classes).toEqual(["primary", "large"]);
56
+ });
57
+
58
+ it("parses an attribute selector with no value", () => {
59
+ const p = parseSelector("input[disabled]");
60
+ const part = p.chains[0]?.parts[0];
61
+ expect(part?.attrs[0]?.name).toBe("disabled");
62
+ expect(part?.attrs[0]?.op).toBe("exists");
63
+ });
64
+
65
+ it("parses every attribute operator", () => {
66
+ const ops: Array<["=" | "~=" | "|=" | "^=" | "$=" | "*=", string]> = [
67
+ ["=", "[a=x]"],
68
+ ["~=", '[a~="x"]'],
69
+ ["|=", '[a|="x"]'],
70
+ ["^=", '[a^="x"]'],
71
+ ["$=", '[a$="x"]'],
72
+ ["*=", '[a*="x"]'],
73
+ ];
74
+ for (const [op, src] of ops) {
75
+ const p = parseSelector(src);
76
+ expect(p.chains[0]?.parts[0]?.attrs[0]?.op).toBe(op);
77
+ expect(p.chains[0]?.parts[0]?.attrs[0]?.value).toBe("x");
78
+ }
79
+ });
80
+
81
+ it("parses a descendant chain", () => {
82
+ const p = parseSelector("section .btn");
83
+ expect(p.chains[0]?.parts).toHaveLength(2);
84
+ expect(p.chains[0]?.parts[0]?.tag).toBe("section");
85
+ expect(p.chains[0]?.parts[1]?.classes).toEqual(["btn"]);
86
+ });
87
+
88
+ it("parses a comma-separated list with attributes", () => {
89
+ const p = parseSelector('iframe[src*="cf"], a#x, .btn');
90
+ expect(p.chains).toHaveLength(3);
91
+ expect(p.chains[0]?.parts[0]?.tag).toBe("iframe");
92
+ expect(p.chains[0]?.parts[0]?.attrs[0]?.value).toBe("cf");
93
+ expect(p.chains[1]?.parts[0]?.id).toBe("x");
94
+ expect(p.chains[2]?.parts[0]?.classes).toEqual(["btn"]);
95
+ });
96
+
97
+ it("preserves whitespace inside quoted attribute values", () => {
98
+ const p = parseSelector('input[name="hello world"]');
99
+ expect(p.chains[0]?.parts[0]?.attrs[0]?.value).toBe("hello world");
100
+ });
101
+ });
102
+
103
+ describe("parseSelector — rejected input", () => {
104
+ it("rejects empty string", () => {
105
+ expect(() => parseSelector("")).toThrow(SelectorParseError);
106
+ });
107
+
108
+ it("rejects unterminated bracket", () => {
109
+ expect(() => parseSelector("input[name=")).toThrow(SelectorParseError);
110
+ });
111
+
112
+ it("rejects bad tag chars", () => {
113
+ // We only enforce on tag prefix when present — `.foo!` parses tag `*`
114
+ // then class. But a leading numeric tag like `9foo` is rejected.
115
+ expect(() => parseSelector("9foo")).toThrow(SelectorParseError);
116
+ });
117
+ });
118
+
119
+ describe("matchSelector — basic matchers", () => {
120
+ it("matches tag", () => {
121
+ const node = el("div");
122
+ expect(matchSelector(parseSelector("div"), node, [])).toBe(true);
123
+ expect(matchSelector(parseSelector("span"), node, [])).toBe(false);
124
+ });
125
+
126
+ it("matches universal", () => {
127
+ expect(matchSelector(parseSelector("*"), el("div"), [])).toBe(true);
128
+ expect(matchSelector(parseSelector("*"), el("img"), [])).toBe(true);
129
+ });
130
+
131
+ it("matches id", () => {
132
+ const node = el("div", { id: "main" });
133
+ expect(matchSelector(parseSelector("#main"), node, [])).toBe(true);
134
+ expect(matchSelector(parseSelector("#other"), node, [])).toBe(false);
135
+ });
136
+
137
+ it("matches class (single + multiple)", () => {
138
+ const node = el("button", { class: "btn primary large" });
139
+ expect(matchSelector(parseSelector(".btn"), node, [])).toBe(true);
140
+ expect(matchSelector(parseSelector(".btn.primary"), node, [])).toBe(true);
141
+ expect(matchSelector(parseSelector(".btn.primary.large"), node, [])).toBe(true);
142
+ expect(matchSelector(parseSelector(".btn.missing"), node, [])).toBe(false);
143
+ });
144
+
145
+ it("matches every attribute operator", () => {
146
+ const node = el("a", {
147
+ href: "https://example.com/foo/bar",
148
+ "data-tags": "alpha beta gamma",
149
+ lang: "en-US",
150
+ });
151
+ expect(matchSelector(parseSelector("a[href]"), node, [])).toBe(true);
152
+ expect(matchSelector(parseSelector('a[href="https://example.com/foo/bar"]'), node, [])).toBe(
153
+ true,
154
+ );
155
+ expect(matchSelector(parseSelector('a[href^="https://"]'), node, [])).toBe(true);
156
+ expect(matchSelector(parseSelector('a[href$="/bar"]'), node, [])).toBe(true);
157
+ expect(matchSelector(parseSelector('a[href*="example"]'), node, [])).toBe(true);
158
+ expect(matchSelector(parseSelector('a[data-tags~="beta"]'), node, [])).toBe(true);
159
+ expect(matchSelector(parseSelector('a[data-tags~="zeta"]'), node, [])).toBe(false);
160
+ expect(matchSelector(parseSelector('a[lang|="en"]'), node, [])).toBe(true);
161
+ expect(matchSelector(parseSelector('a[lang|="fr"]'), node, [])).toBe(false);
162
+ });
163
+
164
+ it("matches descendant chains", () => {
165
+ const section = el("section", { class: "panel" });
166
+ const wrapper = el("div", { class: "wrap" });
167
+ const button = el("button", { class: "btn" });
168
+ expect(matchSelector(parseSelector("section .btn"), button, [section, wrapper])).toBe(true);
169
+ expect(matchSelector(parseSelector("section button"), button, [section, wrapper])).toBe(true);
170
+ // Reject when the leftmost compound is missing from the ancestor chain.
171
+ expect(matchSelector(parseSelector("article button"), button, [section, wrapper])).toBe(false);
172
+ });
173
+
174
+ it("matches comma-separated branches", () => {
175
+ const node = el("button", { class: "btn" });
176
+ expect(matchSelector(parseSelector("a, button"), node, [])).toBe(true);
177
+ expect(matchSelector(parseSelector("a, span"), node, [])).toBe(false);
178
+ });
179
+ });
180
+
181
+ describe("readAttribute", () => {
182
+ it("returns the value if present (case-insensitive name)", () => {
183
+ const node = el("div", { id: "x", DataFoo: "yes" });
184
+ expect(readAttribute(node, "id")).toBe("x");
185
+ expect(readAttribute(node, "datafoo")).toBe("yes");
186
+ expect(readAttribute(node, "missing")).toBeUndefined();
187
+ });
188
+ });
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Task 0252 conformance E2E — verify the OS-level outer-window pin
3
+ * (`--window-size=<W>,<H>`, derived from `matrix.display.{width,height}`)
4
+ * is honored under `--headless=new` such that
5
+ * `window.outerWidth === matrix.display.width`.
6
+ *
7
+ * UDC issue #2242 documents that `--window-size` is honored at the OS
8
+ * level under headless, but the JS API surface (`window.outerWidth/Height`)
9
+ * historically did not reflect it without a CDP `Browser.setWindowBounds`
10
+ * follow-up. This test is the canonical check that the leak is closed
11
+ * end-to-end on the Chromium versions we care about. If `outerWidth`
12
+ * comes back as 800 (the legacy headless default) the test fails loudly
13
+ * and the orchestrator knows to layer in the CDP fix.
14
+ *
15
+ * Mochi's inject layer also defines `window.outerWidth/outerHeight` from
16
+ * `matrix.uaCh["window-viewport"]` (R-029). On macOS the R-029 outerWidth
17
+ * equals `display.width` exactly (OS_CHROME_WIDTH = 0), so the assertion
18
+ * holds regardless of whether the OS-level honoring works as promised.
19
+ * The OS-level fix is what hardens the surface against:
20
+ * - inject-bypassed flows (`bypassInject: true`, `mochi capture`)
21
+ * - cross-realm reads where the spoof hasn't installed yet
22
+ *
23
+ * Gated by `MOCHI_E2E=1`. Set `MOCHI_CHROMIUM_PATH` to a real binary.
24
+ *
25
+ * @see tasks/0252-window-size-flag-from-matrix.md
26
+ * @see UDC `__init__.py:410-411`, UDC issue #2242
27
+ */
28
+
29
+ import { describe, expect, it } from "bun:test";
30
+ import { mochi } from "../index";
31
+
32
+ const E2E_ENABLED = process.env.MOCHI_E2E === "1";
33
+ const TEST_TIMEOUT_MS = 15_000;
34
+
35
+ const describeOrSkip = E2E_ENABLED ? describe : describe.skip;
36
+
37
+ const PROBE_HTML = `<!doctype html><html><body><pre id="p"></pre><script>
38
+ document.getElementById("p").textContent = JSON.stringify({
39
+ outerWidth: window.outerWidth,
40
+ outerHeight: window.outerHeight,
41
+ screenWidth: screen.width,
42
+ screenHeight: screen.height,
43
+ });
44
+ </script></body></html>`;
45
+
46
+ const PROBE_DATA_URL = `data:text/html;charset=utf-8,${encodeURIComponent(PROBE_HTML)}`;
47
+
48
+ interface ProbeShape {
49
+ outerWidth: number;
50
+ outerHeight: number;
51
+ screenWidth: number;
52
+ screenHeight: number;
53
+ }
54
+
55
+ describeOrSkip("@mochi.js/core --window-size E2E (MOCHI_E2E=1) — task 0252", () => {
56
+ it(
57
+ "window.outerWidth matches matrix.display.width under --headless=new",
58
+ async () => {
59
+ const session = await mochi.launch({
60
+ seed: "task-0252-window-size",
61
+ headless: true,
62
+ profile: {
63
+ id: "window-size-e2e-fixture",
64
+ version: "0.0.0-e2e",
65
+ engine: "chromium",
66
+ browser: { name: "chrome", channel: "stable", minVersion: "131", maxVersion: "133" },
67
+ os: { name: "macos", version: "14", arch: "arm64" },
68
+ device: {
69
+ vendor: "Apple",
70
+ model: "Mac14,2",
71
+ cpuFamily: "apple-silicon-m2",
72
+ cores: 8,
73
+ memoryGB: 16,
74
+ },
75
+ // Distinctive non-default dimensions so an 800×600 leak is glaring.
76
+ display: { width: 1728, height: 1117, dpr: 2, colorDepth: 30, pixelDepth: 30 },
77
+ gpu: {
78
+ vendor: "Apple Inc.",
79
+ renderer: "Apple M2",
80
+ webglUnmaskedVendor: "Google Inc. (Apple)",
81
+ webglUnmaskedRenderer:
82
+ "ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version)",
83
+ webglMaxTextureSize: 16384,
84
+ webglMaxColorAttachments: 8,
85
+ webglExtensions: [],
86
+ },
87
+ audio: {
88
+ contextSampleRate: 48000,
89
+ audioWorkletLatency: 0.005,
90
+ destinationMaxChannelCount: 2,
91
+ },
92
+ fonts: { family: "macos-baseline", list: ["Helvetica"] },
93
+ timezone: "America/Los_Angeles",
94
+ locale: "en-US",
95
+ languages: ["en-US", "en"],
96
+ behavior: { hand: "right", tremor: 0.18, wpm: 60, scrollStyle: "smooth" },
97
+ wreqPreset: "chrome_131_macos",
98
+ userAgent:
99
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.86 Safari/537.36",
100
+ uaCh: {},
101
+ entropyBudget: { fixed: [], perSeed: [] },
102
+ },
103
+ });
104
+
105
+ try {
106
+ const matrix = session.profile;
107
+ const page = await session.newPage();
108
+ await page.goto(PROBE_DATA_URL);
109
+ const txt = await page.text("#p");
110
+ if (txt === null) throw new Error("[mochi e2e] probe element produced no textContent");
111
+ const probe = JSON.parse(txt) as ProbeShape;
112
+
113
+ // Task 0252 success criterion #4: probe-time conformance.
114
+ // The 800×600 leak under --headless=new manifests as outerWidth=800.
115
+ // Failing here means the OS-level pin is NOT honored AND the inject
116
+ // spoof did not install — orchestrator should layer in CDP
117
+ // `Browser.setWindowBounds` per UDC issue #2242 follow-up.
118
+ expect(probe.outerWidth).toBe(matrix.display.width);
119
+ expect(probe.outerWidth).not.toBe(800);
120
+
121
+ // screen.width must match too (separate path: inject layer R-010).
122
+ expect(probe.screenWidth).toBe(matrix.display.width);
123
+ expect(probe.screenHeight).toBe(matrix.display.height);
124
+ } finally {
125
+ await session.close();
126
+ }
127
+ },
128
+ TEST_TIMEOUT_MS,
129
+ );
130
+ });
package/src/cdp/types.ts CHANGED
@@ -68,6 +68,53 @@ export interface DomNode {
68
68
  nodeName: string;
69
69
  }
70
70
 
71
+ /**
72
+ * Wider subset of `DOM.Node` used by the closed-shadow piercing locator
73
+ * (`Page.querySelectorPiercing`).
74
+ *
75
+ * Returned by `DOM.getDocument({ depth: -1, pierce: true })` — `pierce: true`
76
+ * yields shadow descendants under `shadowRoots[]` for *both* open and closed
77
+ * roots, and iframe descendants under `contentDocument`. Element-node fields
78
+ * (`localName`, `attributes`) drive selector matching in JS without round-
79
+ * tripping each candidate through `DOM.querySelector` (which would not pierce
80
+ * closed shadows even when called against the parent's document node).
81
+ *
82
+ * Reference: <https://chromedevtools.github.io/devtools-protocol/tot/DOM/#type-Node>
83
+ *
84
+ * @see PLAN.md §8.2 — `DOM.getDocument` and `DOM.resolveNode` are not on the
85
+ * forbidden list; both are fine to use.
86
+ * @see tasks/0253-closed-shadow-piercing-locator.md
87
+ */
88
+ export interface PierceDomNode {
89
+ nodeId: number;
90
+ backendNodeId: number;
91
+ /** 1 = ELEMENT, 3 = TEXT, 9 = DOCUMENT, 11 = DOCUMENT_FRAGMENT, etc. */
92
+ nodeType: number;
93
+ /** Upper-case tag for element nodes (e.g. `"DIV"`); `"#document"` for the document. */
94
+ nodeName: string;
95
+ /** Lower-case tag (`"div"`) — only present on element nodes. */
96
+ localName?: string;
97
+ /** Flat `[name, value, name, value, ...]` array — only on element nodes. */
98
+ attributes?: string[];
99
+ /** Element / document children. */
100
+ children?: PierceDomNode[];
101
+ /**
102
+ * Shadow-root subtrees attached to this element. CDP yields BOTH open and
103
+ * closed shadows here when `pierce: true` is set; `shadowRootType` is
104
+ * `"open" | "closed" | "user-agent"`. The piercing walker traverses all of
105
+ * them — that's the whole point of this type vs. `DomNode`.
106
+ */
107
+ shadowRoots?: PierceDomNode[];
108
+ /** `"open" | "closed" | "user-agent"` — present on shadow-root nodes. */
109
+ shadowRootType?: "open" | "closed" | "user-agent";
110
+ /** iframe descendant tree. CDP yields it as a single-element array. */
111
+ contentDocument?: PierceDomNode;
112
+ /** Pseudo-element children (::before, ::after) — element nodes only. */
113
+ pseudoElements?: PierceDomNode[];
114
+ /** Template content fragment — present on `<template>` elements. */
115
+ templateContent?: PierceDomNode;
116
+ }
117
+
71
118
  /** Subset of `Page.Frame`. */
72
119
  export interface PageFrame {
73
120
  id: string;
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export {
23
23
  export { NotImplementedError } from "./errors";
24
24
  // Public surface — exported here so users only need `@mochi.js/core`.
25
25
  export {
26
+ type ChallengeLaunchOptions,
26
27
  type LaunchOptions,
27
28
  launch,
28
29
  type Mochi,
@@ -43,6 +44,7 @@ export {
43
44
  type WaitState,
44
45
  type WaitUntil,
45
46
  } from "./page";
47
+ export { ElementHandle, type ElementHandleInit } from "./page/element-handle";
46
48
  // Proxy URL parsing — exported so tests + downstream tools can normalize
47
49
  // proxy strings without going through `launch()`.
48
50
  export { type ParsedProxy, parseProxyUrl } from "./proxy-auth";
package/src/launch.ts CHANGED
@@ -27,6 +27,38 @@ export interface ProxyConfig {
27
27
  password?: string;
28
28
  }
29
29
 
30
+ /**
31
+ * Per-challenge convenience options surfaced via `LaunchOptions.challenges`.
32
+ *
33
+ * v0.2 implements `turnstile.autoClick` only. Other entries (hCaptcha,
34
+ * reCAPTCHA, etc.) are reserved for v0.3+ — see `@mochi.js/challenges`
35
+ * README.
36
+ *
37
+ * When `turnstile.autoClick: true`, the `Session` calls
38
+ * `installTurnstileAutoClick(page)` on every page returned by `newPage`.
39
+ * The handle is disposed automatically on page close.
40
+ *
41
+ * The full `TurnstileOptions` (timeout / humanize / onSolved / onEscalation)
42
+ * are passed through unchanged. See
43
+ * `@mochi.js/challenges#TurnstileOptions`.
44
+ */
45
+ export interface ChallengeLaunchOptions {
46
+ turnstile?: {
47
+ /** When `true`, auto-install Turnstile detection + click on every newPage. */
48
+ autoClick?: boolean;
49
+ /** Override the per-widget post-click timeout (ms). Default 30_000. */
50
+ timeout?: number;
51
+ /** When `false`, use a fast non-humanized click path. Default `true`. */
52
+ humanize?: boolean;
53
+ /** Fired when a widget reports a token. */
54
+ onSolved?: (token: string) => void;
55
+ /** Fired on image-challenge / managed-variant / timeout. */
56
+ onEscalation?: (reason: "image-challenge" | "managed" | "timeout") => void;
57
+ /** Override the DOM-poll cadence (ms). Default 500. */
58
+ pollIntervalMs?: number;
59
+ };
60
+ }
61
+
30
62
  /**
31
63
  * Options accepted by `mochi.launch`.
32
64
  *
@@ -59,6 +91,16 @@ export interface LaunchOptions {
59
91
  args?: string[];
60
92
  out?: { traceDir?: string };
61
93
  timeout?: number;
94
+ /**
95
+ * Opt out of mochi's "auto-add `--no-sandbox` when running as root on
96
+ * Linux" fallback. Default `false` (the fallback is on). When `true`,
97
+ * mochi will NOT inject `--no-sandbox` even under root + Linux — useful
98
+ * if you've configured a SUID `chrome-sandbox` helper and want to keep
99
+ * the user-namespace sandbox active. The launch will crash with EPIPE
100
+ * if the SUID setup is wrong, but you keep stealth posture intact
101
+ * (`--no-sandbox` is a fingerprint leak per PLAN.md §8.6).
102
+ */
103
+ allowRootWithSandbox?: boolean;
62
104
  /**
63
105
  * When `true`, the {@link Session} skips both `buildPayload` (no payload
64
106
  * is compiled) and `Page.addScriptToEvaluateOnNewDocument` on every new
@@ -75,6 +117,40 @@ export interface LaunchOptions {
75
117
  * Chromium); task 0040.
76
118
  */
77
119
  bypassInject?: boolean;
120
+ /**
121
+ * When `true`, re-applies the harness/CI-only Chromium flags
122
+ * (`--disable-component-update`, `--disable-default-apps`,
123
+ * `--disable-background-networking`, `--disable-sync`, plus a noise-
124
+ * reduction `--disable-features=` block) on top of the production
125
+ * default flag set. Used by `@mochi.js/harness`, CI runs, and
126
+ * `mochi capture` flows where update traffic, default-apps auto-install,
127
+ * sync, and feed prefetches would inject non-determinism into baseline
128
+ * collection or stealth conformance.
129
+ *
130
+ * Defaults to `false` — production users get a cleaner flag set without
131
+ * the passive command-line bot-tells that patchright explicitly removes
132
+ * from its Playwright fork (`chromiumSwitchesPatch.ts:20-34`) and that
133
+ * `puppeteer-real-browser` strips for the same reason
134
+ * (`lib/cjs/index.js:57-58`).
135
+ *
136
+ * Pairs with — but is independent of — {@link bypassInject}. Capture
137
+ * flows set both `true`; harness conformance runs set `hermetic: true`
138
+ * with full inject pipeline active. PLAN.md §8.6 + task 0256.
139
+ */
140
+ hermetic?: boolean;
141
+ /**
142
+ * Convenience layer toggles for common bot-defense widgets. When
143
+ * `challenges.turnstile.autoClick` is `true`, every page returned by
144
+ * `Session.newPage` has `installTurnstileAutoClick(page, opts)` wired
145
+ * automatically — the Bezier+Fitts behavioral synth handles the click,
146
+ * an optional `onSolved` callback fires when the response token appears,
147
+ * and `onEscalation` fires on image-challenge / managed-variant / timeout.
148
+ *
149
+ * See `@mochi.js/challenges` for the full surface and the limits page
150
+ * for the v0.2 scope (visible-checkbox variants only — image/audio
151
+ * solving is v0.3+).
152
+ */
153
+ challenges?: ChallengeLaunchOptions;
78
154
  }
79
155
 
80
156
  /**
@@ -84,23 +160,57 @@ export interface LaunchOptions {
84
160
  export async function launch(opts: LaunchOptions): Promise<Session> {
85
161
  const binary = await resolveBinary(opts.binary);
86
162
  const normalized = normalizeProxy(opts.proxy);
163
+
164
+ // Resolve the `MatrixV1` BEFORE spawning so matrix-derived values flow
165
+ // into both the `--lang` flag (task 0251) and `--window-size` flag
166
+ // (task 0252). The matrix is otherwise read post-spawn for inject;
167
+ // deriving early is cheap (~µs, pure function) and lets us close the
168
+ // I-5 leaks between Chromium's native network/OS-window state and the
169
+ // JS-layer spoof.
170
+ //
171
+ // Inline `ProfileV1` objects flow straight through; string profile ids
172
+ // are resolved against a placeholder profile until `@mochi.js/profiles`
173
+ // ships its first capture (phase 0.4). The matrix is bit-stable per
174
+ // `(profile, seed)` excluding the `derivedAt` timestamp.
175
+ const profile = resolveProfile(opts.profile);
176
+ const matrix = deriveMatrix(profile, opts.seed);
177
+
87
178
  const proc = await spawnChromium({
88
179
  binary,
89
180
  extraArgs: opts.args,
90
181
  headless: opts.headless ?? false,
182
+ // Opt-out for the auto-no-sandbox-as-root fallback (default: fallback
183
+ // is on so first-run on a Linux server box doesn't crash).
184
+ ...(opts.allowRootWithSandbox === true ? { allowRootWithSandbox: true } : {}),
91
185
  // Chromium rejects inline auth on `--proxy-server`; pass the
92
186
  // auth-stripped server URL.
93
187
  ...(normalized !== undefined ? { proxy: normalized.server } : {}),
188
+ // Primary BCP-47 locale → `--lang=<value>`. Locks the network-layer
189
+ // `Accept-Language` header to the JS spoof (PLAN.md I-5). The full
190
+ // multi-locale list still flows through `matrix.languages` to the
191
+ // inject layer's `navigator.languages` spoof; Chromium derives the
192
+ // q-weighted `Accept-Language` value from the single `--lang` primary
193
+ // automatically. Task 0251.
194
+ locale: matrix.locale,
195
+ // Pin OS-level outer window from the matrix's display geometry so
196
+ // `window.outerWidth/outerHeight` (which reads from the OS window,
197
+ // NOT the JS-spoofed `screen.*`) matches the spoof. Closes the
198
+ // `fingerprint-scan.com` 800×600 leak under `--headless=new`.
199
+ // UDC fixes the same issue at `__init__.py:410-411`. Task 0252.
200
+ ...(Number.isInteger(matrix.display.width) &&
201
+ Number.isInteger(matrix.display.height) &&
202
+ matrix.display.width > 0 &&
203
+ matrix.display.height > 0
204
+ ? { windowSize: { width: matrix.display.width, height: matrix.display.height } }
205
+ : {}),
206
+ // Hermetic harness/CI escape hatch — re-applies the patchright-trim
207
+ // flags (`--disable-component-update`, `--disable-default-apps`,
208
+ // `--disable-background-networking`, `--disable-sync`, hermetic
209
+ // `--disable-features=` extras). Default `false` keeps production users
210
+ // off the passive command-line bot-tell list. Task 0256, PLAN.md §8.6.
211
+ ...(opts.hermetic === true ? { hermetic: true } : {}),
94
212
  });
95
213
 
96
- // Resolve the `MatrixV1` for this session via the consistency engine.
97
- // Inline `ProfileV1` objects flow straight through; string profile ids
98
- // are resolved against a placeholder profile until `@mochi.js/profiles`
99
- // ships its first capture (phase 0.4). The matrix is bit-stable per
100
- // `(profile, seed)` excluding the `derivedAt` timestamp.
101
- const profile = resolveProfile(opts.profile);
102
- const matrix = deriveMatrix(profile, opts.seed);
103
-
104
214
  const session = new Session({
105
215
  proc,
106
216
  matrix,
@@ -112,6 +222,7 @@ export async function launch(opts: LaunchOptions): Promise<Session> {
112
222
  // `@mochi.js/net` (wreq) accepts the full `user:pass@host` URL form.
113
223
  ...(normalized !== undefined ? { netProxy: normalized.netProxy } : {}),
114
224
  ...(normalized?.auth !== undefined ? { proxyAuth: normalized.auth } : {}),
225
+ ...(opts.challenges !== undefined ? { challenges: opts.challenges } : {}),
115
226
  });
116
227
  return session;
117
228
  }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * `ElementHandle` — lightweight wrapper around a CDP `RemoteObject` that lets
3
+ * callers operate on an element resolved via the closed-shadow piercing
4
+ * locator (`Page.querySelectorPiercing`).
5
+ *
6
+ * The handle is intentionally minimal — Phase 0.2 only needs enough surface
7
+ * for the Turnstile auto-clicker to ask "is this an iframe whose src matches
8
+ * cf-turnstile?" and then position a click. Wider parity with Playwright's
9
+ * `ElementHandle` (waitFor, fill, hover, screenshot…) is deferred — those
10
+ * compose on top of the same primitives once they're needed.
11
+ *
12
+ * Lifecycle: the underlying `objectId` is bound to a CDP `Runtime` execution
13
+ * context. Closing the page invalidates every handle the page produced; we
14
+ * don't try to release them via `Runtime.releaseObject` because there's no
15
+ * `Runtime.enable` in this session (PLAN.md §8.2). Stale handles surface as
16
+ * `Cannot find context with specified id` errors from the next CDP call,
17
+ * which is fine for a v0.2 surface.
18
+ *
19
+ * @see PLAN.md §8.2 / §8.3
20
+ * @see tasks/0253-closed-shadow-piercing-locator.md
21
+ */
22
+
23
+ import type { MessageRouter } from "../cdp/router";
24
+ import type { CdpSessionId, RemoteObject } from "../cdp/types";
25
+
26
+ export interface ElementHandleInit {
27
+ router: MessageRouter;
28
+ sessionId: CdpSessionId;
29
+ objectId: string;
30
+ /** CDP `backendNodeId` — stable across DOM mutations. */
31
+ backendNodeId: number;
32
+ }
33
+
34
+ /**
35
+ * A handle to a single DOM element exposed to host-side automation. Issued
36
+ * by `Page.querySelectorPiercing` / `Page.querySelectorAllPiercing`.
37
+ */
38
+ export class ElementHandle {
39
+ private readonly router: MessageRouter;
40
+ private readonly sessionId: CdpSessionId;
41
+ private readonly objectId: string;
42
+ private readonly _backendNodeId: number;
43
+
44
+ constructor(init: ElementHandleInit) {
45
+ this.router = init.router;
46
+ this.sessionId = init.sessionId;
47
+ this.objectId = init.objectId;
48
+ this._backendNodeId = init.backendNodeId;
49
+ }
50
+
51
+ /** The CDP `backendNodeId` for the element — stable across DOM mutations. */
52
+ get backendNodeId(): number {
53
+ return this._backendNodeId;
54
+ }
55
+
56
+ /**
57
+ * Read a single attribute via `Runtime.callFunctionOn`. Returns `null` when
58
+ * the attribute is absent (mirrors `Element.getAttribute`).
59
+ */
60
+ async getAttribute(name: string): Promise<string | null> {
61
+ const r = await this.router.send<{ result: RemoteObject }>(
62
+ "Runtime.callFunctionOn",
63
+ {
64
+ objectId: this.objectId,
65
+ functionDeclaration:
66
+ "function(n) { var v = this.getAttribute(n); return v === null ? null : String(v); }",
67
+ arguments: [{ value: name }],
68
+ returnByValue: true,
69
+ },
70
+ { sessionId: this.sessionId },
71
+ );
72
+ const v = r.result.value;
73
+ return v === null || v === undefined ? null : String(v);
74
+ }
75
+
76
+ /**
77
+ * Get the element's text content via `Runtime.callFunctionOn`.
78
+ */
79
+ async textContent(): Promise<string | null> {
80
+ const r = await this.router.send<{ result: RemoteObject }>(
81
+ "Runtime.callFunctionOn",
82
+ {
83
+ objectId: this.objectId,
84
+ functionDeclaration: "function() { return this.textContent; }",
85
+ returnByValue: true,
86
+ },
87
+ { sessionId: this.sessionId },
88
+ );
89
+ const v = r.result.value;
90
+ return v === null || v === undefined ? null : String(v);
91
+ }
92
+
93
+ /**
94
+ * Evaluate a function bound to this element (the handle is `this`). Result
95
+ * is JSON-serialised via `returnByValue: true`. Same contract as
96
+ * `Page.evaluate` — no closures, no arguments, no DOM-node returns.
97
+ */
98
+ async evaluate<T>(fn: (this: Element) => T): Promise<T> {
99
+ const r = await this.router.send<{ result: RemoteObject }>(
100
+ "Runtime.callFunctionOn",
101
+ {
102
+ objectId: this.objectId,
103
+ functionDeclaration: fn.toString(),
104
+ returnByValue: true,
105
+ },
106
+ { sessionId: this.sessionId },
107
+ );
108
+ return r.result.value as T;
109
+ }
110
+ }