@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.
@@ -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
@@ -44,6 +44,7 @@ export {
44
44
  type WaitState,
45
45
  type WaitUntil,
46
46
  } from "./page";
47
+ export { ElementHandle, type ElementHandleInit } from "./page/element-handle";
47
48
  // Proxy URL parsing — exported so tests + downstream tools can normalize
48
49
  // proxy strings without going through `launch()`.
49
50
  export { type ParsedProxy, parseProxyUrl } from "./proxy-auth";
package/src/launch.ts CHANGED
@@ -91,6 +91,16 @@ export interface LaunchOptions {
91
91
  args?: string[];
92
92
  out?: { traceDir?: string };
93
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;
94
104
  /**
95
105
  * When `true`, the {@link Session} skips both `buildPayload` (no payload
96
106
  * is compiled) and `Page.addScriptToEvaluateOnNewDocument` on every new
@@ -107,6 +117,27 @@ export interface LaunchOptions {
107
117
  * Chromium); task 0040.
108
118
  */
109
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;
110
141
  /**
111
142
  * Convenience layer toggles for common bot-defense widgets. When
112
143
  * `challenges.turnstile.autoClick` is `true`, every page returned by
@@ -129,23 +160,57 @@ export interface LaunchOptions {
129
160
  export async function launch(opts: LaunchOptions): Promise<Session> {
130
161
  const binary = await resolveBinary(opts.binary);
131
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
+
132
178
  const proc = await spawnChromium({
133
179
  binary,
134
180
  extraArgs: opts.args,
135
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 } : {}),
136
185
  // Chromium rejects inline auth on `--proxy-server`; pass the
137
186
  // auth-stripped server URL.
138
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 } : {}),
139
212
  });
140
213
 
141
- // Resolve the `MatrixV1` for this session via the consistency engine.
142
- // Inline `ProfileV1` objects flow straight through; string profile ids
143
- // are resolved against a placeholder profile until `@mochi.js/profiles`
144
- // ships its first capture (phase 0.4). The matrix is bit-stable per
145
- // `(profile, seed)` excluding the `derivedAt` timestamp.
146
- const profile = resolveProfile(opts.profile);
147
- const matrix = deriveMatrix(profile, opts.seed);
148
-
149
214
  const session = new Session({
150
215
  proc,
151
216
  matrix,
@@ -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
+ }