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