dsh-browser-playwright-codex 0.5.1 → 0.5.3

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,65 @@
1
+ /**
2
+ * 页面诊断:把控制台消息、页面错误与网络请求按页收进有界记录,并把 Playwright 抛出的错误
3
+ * 归类成可读的原因(超时/中断/上下文销毁/浏览器崩溃)。
4
+ * @module dsh-browser-playwright-codex/page-diagnostics
5
+ */
6
+ import type { Page } from 'playwright-core';
7
+ import type { PlaywrightSession } from './session.ts';
8
+ import { BrowserError } from './errors.ts';
9
+ /** Serialize a Playwright timeout into a stable browser error. Code is
10
+ * prefixed on the message so the model sees a stable machine-readable label. */
11
+ export declare function asTimeoutError(kind: string, cause: unknown): BrowserError;
12
+ /** Whether an error is a caller-initiated abort, which must propagate untouched. */
13
+ export declare function isAbortError(error: unknown): boolean;
14
+ /** Whether a Playwright failure is a mid-evaluate navigation race. */
15
+ export declare function isContextDestroyed(error: unknown): boolean;
16
+ /** Whether a failure means the browser or its page died and must be relaunched. */
17
+ export declare function isCrashError(error: unknown): boolean;
18
+ /**
19
+ * Wire bounded console/network capture onto one page, exactly once. Capture is
20
+ * best-effort observation for the model: it must never throw into a page that is
21
+ * otherwise healthy, and it must never grow without a bound.
22
+ */
23
+ export declare function trackPage(session: PlaywrightSession, page: Page): void;
24
+ /** Recent console messages and uncaught page errors, newest last. */
25
+ export declare function consoleMessages(session: PlaywrightSession, args: {
26
+ level?: string;
27
+ limit?: number;
28
+ }): {
29
+ url: string | null;
30
+ level: string;
31
+ total: number;
32
+ returned: number;
33
+ dropped: number;
34
+ otherPages: number;
35
+ note: string;
36
+ entries: {
37
+ n: number;
38
+ level: string | undefined;
39
+ text: string;
40
+ source: string;
41
+ }[];
42
+ };
43
+ /** Recent network requests, newest last, with status and duration where known. */
44
+ export declare function networkRequests(session: PlaywrightSession, args: {
45
+ urlContains?: string;
46
+ failedOnly?: boolean;
47
+ limit?: number;
48
+ }): {
49
+ url: string | null;
50
+ total: number;
51
+ returned: number;
52
+ dropped: number;
53
+ otherPages: number;
54
+ note: string;
55
+ entries: {
56
+ n: number;
57
+ method: string | undefined;
58
+ url: string;
59
+ status: number | null | undefined;
60
+ kind: string | undefined;
61
+ ms: number | null | undefined;
62
+ failure: string | null | undefined;
63
+ }[];
64
+ };
65
+ export declare function pageData(session: PlaywrightSession): Promise<unknown>;
@@ -0,0 +1,181 @@
1
+ import { BrowserError } from "./errors.js";
2
+ /** Serialize a Playwright timeout into a stable browser error. Code is
3
+ * prefixed on the message so the model sees a stable machine-readable label. */
4
+ export function asTimeoutError(kind, cause) {
5
+ const detail = cause instanceof Error ? cause.message : String(cause);
6
+ if (kind === 'navigation')
7
+ return new BrowserError('NAVIGATION_TIMEOUT', '[NAVIGATION_TIMEOUT] the browser operation timed out: ' + detail);
8
+ return new BrowserError('ACTION_TIMEOUT', '[ACTION_TIMEOUT] the browser action timed out: ' + detail);
9
+ }
10
+ /** Whether an error is a caller-initiated abort, which must propagate untouched. */
11
+ export function isAbortError(error) {
12
+ return error instanceof DOMException && error.name === 'AbortError';
13
+ }
14
+ /** Whether a Playwright failure is a mid-evaluate navigation race. */
15
+ export function isContextDestroyed(error) {
16
+ return error instanceof Error && /Execution context was destroyed/.test(error.message);
17
+ }
18
+ /** Whether a failure means the browser or its page died and must be relaunched. */
19
+ export function isCrashError(error) {
20
+ if (!(error instanceof Error))
21
+ return false;
22
+ return /browser has disconnected|has been closed|Target closed|Target crashed|ProcessSingleton|Session closed|Cannot access object .* destroyed|The page .* was closed/i.test(error.message);
23
+ }
24
+ /**
25
+ * Wire bounded console/network capture onto one page, exactly once. Capture is
26
+ * best-effort observation for the model: it must never throw into a page that is
27
+ * otherwise healthy, and it must never grow without a bound.
28
+ */
29
+ export function trackPage(session, page) {
30
+ if (session.trackedPages.has(page))
31
+ return;
32
+ session.trackedPages.add(page);
33
+ const pageId = session.pageIdFor(page);
34
+ const record = (kind, entry) => {
35
+ session.diagSeq += 1;
36
+ entry.n = session.diagSeq;
37
+ entry.at = Date.now();
38
+ // Tag the owner: the rings are session-wide, the readers are per page.
39
+ entry.page = pageId;
40
+ const ring = kind === 'console' ? session.consoleLog : session.networkLog;
41
+ ring.push(entry);
42
+ while (ring.length > session.diagLimit) {
43
+ ring.shift();
44
+ session.diagDropped[kind] += 1;
45
+ }
46
+ };
47
+ try {
48
+ page.on('console', (msg) => {
49
+ record('console', { level: msg.type(), text: msg.text(), source: (msg.location() ?? {}).url ?? '' });
50
+ });
51
+ page.on('pageerror', (err) => {
52
+ record('console', { level: 'pageerror', text: err?.message ?? String(err), source: page.url() });
53
+ });
54
+ page.on('request', (request) => {
55
+ const entry = {
56
+ method: request.method(),
57
+ url: request.url(),
58
+ status: null,
59
+ kind: request.resourceType(),
60
+ ms: null,
61
+ failure: null,
62
+ };
63
+ session.requestEntries.set(request, { entry, startedAt: Date.now() });
64
+ record('network', entry);
65
+ });
66
+ page.on('response', (response) => {
67
+ const slot = session.requestEntries.get(response.request());
68
+ if (slot === undefined)
69
+ return;
70
+ slot.entry.status = response.status();
71
+ slot.entry.ms = Date.now() - slot.startedAt;
72
+ session.requestEntries.delete(response.request());
73
+ });
74
+ page.on('requestfailed', (request) => {
75
+ const slot = session.requestEntries.get(request);
76
+ if (slot === undefined)
77
+ return;
78
+ slot.entry.ms = Date.now() - slot.startedAt;
79
+ slot.entry.failure = request.failure()?.errorText ?? 'request failed';
80
+ session.requestEntries.delete(request);
81
+ });
82
+ }
83
+ catch {
84
+ // Diagnostics are optional: a page that refuses hooks still works.
85
+ }
86
+ }
87
+ /** Recent console messages and uncaught page errors, newest last. */
88
+ export function consoleMessages(session, args) {
89
+ const wanted = typeof args.level === 'string' && args.level.length > 0 ? args.level : 'all';
90
+ const limit = typeof args.limit === 'number' && Number.isFinite(args.limit)
91
+ ? Math.max(1, Math.min(session.diagLimit, args.limit))
92
+ : 50;
93
+ const matches = (entry) => {
94
+ if (wanted === 'all')
95
+ return true;
96
+ if (wanted === 'error')
97
+ return entry.level === 'error' || entry.level === 'pageerror';
98
+ if (wanted === 'warning')
99
+ return entry.level === 'warning' || entry.level === 'warn';
100
+ return entry.level === wanted;
101
+ };
102
+ const { kept, otherPages } = session.scopeToCurrentPage(session.consoleLog.filter(matches));
103
+ return {
104
+ url: session.currentUrl(),
105
+ level: wanted,
106
+ total: kept.length,
107
+ returned: Math.min(limit, kept.length),
108
+ dropped: session.diagDropped.console,
109
+ otherPages,
110
+ note: otherPages > 0
111
+ ? 'Only the tab session session is driving is reported; ' +
112
+ String(otherPages) +
113
+ ' matching entries from other tabs of the same window are not shown.'
114
+ : 'Capture starts when session session first drives a tab. In persistent mode every session shares one window; only the tab session session is driving is reported.',
115
+ entries: kept.slice(-limit).map((entry) => ({
116
+ n: entry.n,
117
+ level: entry.level,
118
+ text: (entry.text ?? '').slice(0, 400),
119
+ source: String(entry.source).slice(0, 200),
120
+ })),
121
+ };
122
+ }
123
+ /** Recent network requests, newest last, with status and duration where known. */
124
+ export function networkRequests(session, args) {
125
+ const limit = typeof args.limit === 'number' && Number.isFinite(args.limit)
126
+ ? Math.max(1, Math.min(session.diagLimit, args.limit))
127
+ : 50;
128
+ const needle = typeof args.urlContains === 'string' ? args.urlContains.toLowerCase() : '';
129
+ const failedOnly = args.failedOnly === true;
130
+ let matching = session.networkLog;
131
+ if (needle.length > 0)
132
+ matching = matching.filter((entry) => (entry.url ?? '').toLowerCase().includes(needle));
133
+ if (failedOnly)
134
+ matching = matching.filter((entry) => entry.failure !== null || ((entry.status ?? null) !== null && (entry.status ?? 0) >= 400));
135
+ const { kept, otherPages } = session.scopeToCurrentPage(matching);
136
+ return {
137
+ url: session.currentUrl(),
138
+ total: kept.length,
139
+ returned: Math.min(limit, kept.length),
140
+ dropped: session.diagDropped.network,
141
+ otherPages,
142
+ note: otherPages > 0
143
+ ? 'Only the tab session session is driving is reported; ' +
144
+ String(otherPages) +
145
+ ' matching requests from other tabs of the same window are not shown. Response bodies are not captured.'
146
+ : 'Capture starts when session session first drives a tab. In persistent mode every session shares one window; only the tab session session is driving is reported. Response bodies are not captured.',
147
+ entries: kept.slice(-limit).map((entry) => ({
148
+ n: entry.n,
149
+ method: entry.method,
150
+ url: (entry.url ?? '').slice(0, 300),
151
+ status: entry.status,
152
+ kind: entry.kind,
153
+ ms: entry.ms,
154
+ failure: entry.failure,
155
+ })),
156
+ };
157
+ }
158
+ export async function pageData(session) {
159
+ return session.run(async () => {
160
+ session.assertLive();
161
+ session.provider.touch(session.owner);
162
+ const page = await session.ensurePage();
163
+ const read = () => {
164
+ const fn = window.__dshPageData;
165
+ if (typeof fn !== 'function')
166
+ return { url: location.href, title: document.title, text: '', truncated: false, links: [], inputs: [] };
167
+ return fn({ maxTextChars: 30000, maxLinks: 300, maxInputs: 200 });
168
+ };
169
+ let data;
170
+ try {
171
+ data = await page.evaluate(read);
172
+ }
173
+ catch (error) {
174
+ if (!isContextDestroyed(error))
175
+ throw error;
176
+ await session.settleNavigation(page);
177
+ data = await page.evaluate(read);
178
+ }
179
+ return data;
180
+ });
181
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Page-side probe functions: every one of these is **serialized into the browser** by
3
+ * locator.evaluate()/page.evaluate(), so each must be self-contained.
4
+ *
5
+ * The rule, learned the hard way (2026-10-05): a transpiler that keeps function names — esbuild,
6
+ * through its \`__name\` helper — rewrites a named inner binding like \`const describe = (…) => …\`
7
+ * into a call to a helper that exists only in module scope. The page has no such helper, so the
8
+ * probe throws ReferenceError on every call. In this file that is not a style question: it silently
9
+ * disabled the whole click-geometry pipeline for weeks. Safe shapes are an array literal
10
+ * (\`const [a, b] = [() => …, () => …]\`) and an immediately-invoked arrow; both allow the two to
11
+ * call each other. tests/page-probes.test.ts enforces this by grepping the serialized source.
12
+ *
13
+ * @module dsh-browser-playwright-codex/page-probes
14
+ */
15
+ /** Remove browser label stamps. Used via page.evaluate(clearLabelTargets). */
16
+ export declare const clearLabelTargets: () => void;
17
+ /**
18
+ * Page-side resolution for browser click/hover label mode: find the visible element
19
+ * whose label (aria-label / alt / title / placeholder / own text) equals the
20
+ * prefer the deepest match, stamp it with data-dsh-label-target, and describe
21
+ * it so the landing note can name what was actually hovered. Runs inside the
22
+ * page; used via page.evaluate(labelTargetProbe, { text }).
23
+ */
24
+ export declare const labelTargetProbe: (arg: {
25
+ text: string;
26
+ }) => {
27
+ found: boolean;
28
+ total: number;
29
+ token?: never;
30
+ desc?: never;
31
+ exact?: never;
32
+ } | {
33
+ found: boolean;
34
+ token: string;
35
+ desc: string;
36
+ total: number;
37
+ exact: boolean;
38
+ };
39
+ /**
40
+ * Element-side bounded stability probe (Codex-style): samples the element's
41
+ * boundingClientRect every animation frame; resolves true once it repeats for
42
+ * `frames` consecutive frames, false after `timeoutMs`. Never hangs: the cap
43
+ * always resolves. Used via locator.evaluate(elementStabilityProbe, arg).
44
+ */
45
+ export declare const elementStabilityProbe: (el: Element, arg: {
46
+ frames: number;
47
+ timeoutMs: number;
48
+ }) => Promise<boolean>;
49
+ /**
50
+ * Page-side bounded stability probe: samples layout size + pending images +
51
+ * readyState. Used via page.evaluate(pageStabilityProbe, arg). The returned
52
+ * "false on timeout" is intentional: a busy page must not stall the agent.
53
+ */
54
+ export declare const pageStabilityProbe: (arg: {
55
+ frames: number;
56
+ timeoutMs: number;
57
+ }) => Promise<boolean>;
58
+ /**
59
+ * The click-point hit test, moved out of the session so it lives with the other page-side
60
+ * functions: it is serialized into the page by locator.evaluate(), so the same
61
+ * "no named inner function bindings" rule applies (see the module header, and
62
+ * tests/page-probes.test.ts which now enumerates this module's exports rather than a hand list).
63
+ */
64
+ export type ClickProbe = {
65
+ ok: true;
66
+ x: number;
67
+ y: number;
68
+ hitDesc: string;
69
+ hitSelf: boolean;
70
+ } | {
71
+ ok: false;
72
+ reason: 'zero-size' | 'off-screen' | 'nothing-at-point' | 'intercepted';
73
+ x: number;
74
+ y: number;
75
+ geometry: string;
76
+ by?: string;
77
+ };
78
+ export declare const hitTest: (el: Element) => ClickProbe;
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Page-side probe functions: every one of these is **serialized into the browser** by
3
+ * locator.evaluate()/page.evaluate(), so each must be self-contained.
4
+ *
5
+ * The rule, learned the hard way (2026-10-05): a transpiler that keeps function names — esbuild,
6
+ * through its \`__name\` helper — rewrites a named inner binding like \`const describe = (…) => …\`
7
+ * into a call to a helper that exists only in module scope. The page has no such helper, so the
8
+ * probe throws ReferenceError on every call. In this file that is not a style question: it silently
9
+ * disabled the whole click-geometry pipeline for weeks. Safe shapes are an array literal
10
+ * (\`const [a, b] = [() => …, () => …]\`) and an immediately-invoked arrow; both allow the two to
11
+ * call each other. tests/page-probes.test.ts enforces this by grepping the serialized source.
12
+ *
13
+ * @module dsh-browser-playwright-codex/page-probes
14
+ */
15
+ /** Remove browser label stamps. Used via page.evaluate(clearLabelTargets). */
16
+ export const clearLabelTargets = () => {
17
+ for (const el of document.querySelectorAll('[data-dsh-label-target]'))
18
+ el.removeAttribute('data-dsh-label-target');
19
+ };
20
+ /**
21
+ * Page-side resolution for browser click/hover label mode: find the visible element
22
+ * whose label (aria-label / alt / title / placeholder / own text) equals the
23
+ * prefer the deepest match, stamp it with data-dsh-label-target, and describe
24
+ * it so the landing note can name what was actually hovered. Runs inside the
25
+ * page; used via page.evaluate(labelTargetProbe, { text }).
26
+ */
27
+ export const labelTargetProbe = (arg) => {
28
+ const wanted = String(arg.text).replace(/\s+/g, ' ').trim();
29
+ // Array destructuring, not named bindings: this function is serialized into the page,
30
+ // and a transpiler that keeps function names (esbuild's `__name`) would rewrite a named
31
+ // inner binding into a call to a module-scope helper the page does not have. An array
32
+ // literal is not a named binding, so it survives (same rule as the probes below).
33
+ const [norm, ownText] = [
34
+ (s) => (s || '').replace(/\s+/g, ' ').trim(),
35
+ (el) => {
36
+ let out = '';
37
+ for (const node of el.childNodes) {
38
+ if (node.nodeType === 3)
39
+ out += node.textContent;
40
+ else if (node.nodeType === 1 && node.tagName.toLowerCase() === 'br')
41
+ out += ' ';
42
+ }
43
+ return norm(out);
44
+ },
45
+ ];
46
+ // Mirror the snapshot's name sources, so anything the page shows under a
47
+ // name can be addressed by that name: aria-label / alt / title / placeholder
48
+ // first, then the element's own visible text.
49
+ const [labelOf, visible, depthOf] = [
50
+ (el) =>
51
+ // Attribute reads are inlined, not wrapped in a local `attr` helper: any named
52
+ // function binding inside this serialized function would be rewritten to a call to
53
+ // a module-scope helper the page does not have. norm() already turns null/blank
54
+ // into '', which is what the helper did.
55
+ norm(el.getAttribute('aria-label')) ||
56
+ norm(el.getAttribute('alt')) ||
57
+ norm(el.getAttribute('title')) ||
58
+ norm(el.getAttribute('placeholder')) ||
59
+ ownText(el),
60
+ (el) => {
61
+ if (!(el instanceof Element))
62
+ return false;
63
+ if (el.getAttribute('hidden') !== null || el.getAttribute('aria-hidden') === 'true')
64
+ return false;
65
+ const style = getComputedStyle(el);
66
+ if (style.display === 'none' || style.visibility === 'hidden')
67
+ return false;
68
+ const rect = el.getBoundingClientRect();
69
+ return rect.width > 0 || rect.height > 0;
70
+ },
71
+ (el) => {
72
+ let depth = 0;
73
+ let cur = el;
74
+ while (cur !== null && cur.parentElement !== null) {
75
+ depth += 1;
76
+ cur = cur.parentElement;
77
+ }
78
+ return depth;
79
+ },
80
+ ];
81
+ const root = document.body !== null && document.body !== undefined ? document.body : document.documentElement;
82
+ const all = Array.from(root.querySelectorAll('*')).slice(0, 8000).filter(visible);
83
+ let pool = all.filter((el) => labelOf(el) === wanted);
84
+ let exact = true;
85
+ if (pool.length === 0) {
86
+ exact = false;
87
+ pool = all.filter((el) => ownText(el).indexOf(wanted) !== -1 || labelOf(el).indexOf(wanted) !== -1);
88
+ }
89
+ if (pool.length === 0)
90
+ return { found: false, total: 0 };
91
+ pool.sort((a, b) => depthOf(b) - depthOf(a));
92
+ const picked = pool[0];
93
+ if (picked === undefined)
94
+ return { found: false, total: pool.length };
95
+ for (const el of document.querySelectorAll('[data-dsh-label-target]'))
96
+ el.removeAttribute('data-dsh-label-target');
97
+ const token = 'ht' + Date.now().toString(36) + Math.floor(Math.random() * 1e6).toString(36);
98
+ try {
99
+ picked.setAttribute('data-dsh-label-target', token);
100
+ }
101
+ catch (_) {
102
+ return { found: false, total: pool.length };
103
+ }
104
+ const cls = typeof picked.className === 'string' && picked.className.trim() !== ''
105
+ ? '.' + picked.className.trim().split(/\s+/).slice(0, 2).join('.')
106
+ : '';
107
+ return {
108
+ found: true,
109
+ token,
110
+ desc: picked.tagName.toLowerCase() + (picked.id ? '#' + picked.id : '') + cls,
111
+ total: pool.length,
112
+ exact,
113
+ };
114
+ };
115
+ /**
116
+ * Element-side bounded stability probe (Codex-style): samples the element's
117
+ * boundingClientRect every animation frame; resolves true once it repeats for
118
+ * `frames` consecutive frames, false after `timeoutMs`. Never hangs: the cap
119
+ * always resolves. Used via locator.evaluate(elementStabilityProbe, arg).
120
+ */
121
+ export const elementStabilityProbe = (el, arg) => new Promise((resolve) => {
122
+ const frames = arg.frames;
123
+ const timeoutMs = arg.timeoutMs;
124
+ const deadline = performance.now() + timeoutMs;
125
+ let prev = null;
126
+ let stable = 0;
127
+ // Array destructuring, not `const sig = …`: these functions are serialized into
128
+ // the page, and a transpiler that keeps function names (esbuild's `__name`) rewrites
129
+ // a named inner binding into a *call* to a module-scope helper the page does not have
130
+ // (`ReferenceError: __name is not defined`). An array literal is not a named binding,
131
+ // so this shape survives — and the two can still call each other. Verified 2026-10-05
132
+ // with fn.toString() under tsx, and by running this suite under tsx again.
133
+ const [sig, tick] = [
134
+ () => {
135
+ const r = el.getBoundingClientRect();
136
+ return [r.left, r.top, r.width, r.height].join(',');
137
+ },
138
+ () => {
139
+ const cur = sig();
140
+ if (prev !== null && cur === prev) {
141
+ stable += 1;
142
+ if (stable >= frames) {
143
+ resolve(true);
144
+ return;
145
+ }
146
+ }
147
+ else {
148
+ stable = 0;
149
+ }
150
+ prev = cur;
151
+ if (performance.now() >= deadline) {
152
+ resolve(false);
153
+ return;
154
+ }
155
+ requestAnimationFrame(tick);
156
+ },
157
+ ];
158
+ requestAnimationFrame(tick);
159
+ });
160
+ /**
161
+ * Page-side bounded stability probe: samples layout size + pending images +
162
+ * readyState. Used via page.evaluate(pageStabilityProbe, arg). The returned
163
+ * "false on timeout" is intentional: a busy page must not stall the agent.
164
+ */
165
+ export const pageStabilityProbe = (arg) => new Promise((resolve) => {
166
+ const frames = arg.frames;
167
+ const timeoutMs = arg.timeoutMs;
168
+ const deadline = performance.now() + timeoutMs;
169
+ let prev = null;
170
+ let stable = 0;
171
+ // Array destructuring, not `const sig = …`: these functions are serialized into
172
+ // the page, and a transpiler that keeps function names (esbuild's `__name`) rewrites
173
+ // a named inner binding into a *call* to a module-scope helper the page does not have
174
+ // (`ReferenceError: __name is not defined`). An array literal is not a named binding,
175
+ // so this shape survives — and the two can still call each other. Verified 2026-10-05
176
+ // with fn.toString() under tsx, and by running this suite under tsx again.
177
+ const [sig, tick] = [
178
+ () => {
179
+ const de = document.documentElement;
180
+ return [de.scrollWidth, de.scrollHeight, document.querySelectorAll('img:not([complete])').length].join(',');
181
+ },
182
+ () => {
183
+ const cur = sig();
184
+ if (prev !== null && cur === prev) {
185
+ stable += 1;
186
+ if (stable >= frames) {
187
+ resolve(true);
188
+ return;
189
+ }
190
+ }
191
+ else {
192
+ stable = 0;
193
+ }
194
+ prev = cur;
195
+ if (performance.now() >= deadline) {
196
+ resolve(false);
197
+ return;
198
+ }
199
+ requestAnimationFrame(tick);
200
+ },
201
+ ];
202
+ requestAnimationFrame(tick);
203
+ });
204
+ export const hitTest = (el) => {
205
+ const r = el.getBoundingClientRect();
206
+ const w = r.width;
207
+ const h = r.height;
208
+ const geometry = 'rect ' + Math.round(r.left) + ',' + Math.round(r.top) + ' ' + Math.round(w) + 'x' + Math.round(h);
209
+ if (w <= 0 || h <= 0)
210
+ return { ok: false, reason: 'zero-size', x: r.left, y: r.top, geometry };
211
+ // The probe must sit inside the element AND inside the viewport:
212
+ // elementFromPoint() answers null for coordinates outside the viewport, so
213
+ // sampling the element's own centre missed any target taller than the viewport
214
+ // (or one whose centre was below the fold) even when most of it was plainly
215
+ // clickable. Aim at the centre of the visible intersection instead.
216
+ const view = (el.ownerDocument && el.ownerDocument.defaultView) || window;
217
+ const left = Math.max(r.left, 0);
218
+ const top = Math.max(r.top, 0);
219
+ const right = Math.min(r.right, view.innerWidth);
220
+ const bottom = Math.min(r.bottom, view.innerHeight);
221
+ if (right - left <= 0 || bottom - top <= 0) {
222
+ return {
223
+ ok: false,
224
+ reason: 'off-screen',
225
+ x: r.left,
226
+ y: r.top,
227
+ geometry: geometry + ' viewport ' + view.innerWidth + 'x' + view.innerHeight,
228
+ };
229
+ }
230
+ const x = (left + right) / 2;
231
+ const y = (top + bottom) / 2;
232
+ let hit = null;
233
+ try {
234
+ hit = document.elementFromPoint(x, y);
235
+ }
236
+ catch {
237
+ hit = null;
238
+ }
239
+ if (!hit)
240
+ return { ok: false, reason: 'nothing-at-point', x, y, geometry };
241
+ // Describe the hit inline, as a string: session function is serialized into the
242
+ // page, so it must be self-contained. A transpiler that keeps function names
243
+ // (esbuild/tsx, through its `__name` helper) rewrites a named inner binding
244
+ // like `const describe = (…) => …` into a call to a helper that exists only in
245
+ // module scope — and the page has no such helper, so the probe would throw
246
+ // ReferenceError and every click would silently fall back to Playwright's
247
+ // actionability click (3s cap). That is what made the Windows runner flaky
248
+ // until 2026-10-05; the test runner now strips types natively, and keeping the
249
+ // probe free of inner function bindings protects anyone who transpiles src/.
250
+ const hitDesc = (hit.tagName ? hit.tagName.toLowerCase() : hit.nodeName) +
251
+ (hit.id ? '#' + hit.id : '') +
252
+ (typeof hit.className === 'string' && hit.className.trim()
253
+ ? '.' + hit.className.trim().split(/\s+/).filter(Boolean).join('.')
254
+ : '') +
255
+ (hit.getAttribute && hit.getAttribute('role') ? '[role=' + hit.getAttribute('role') + ']' : '') +
256
+ (hit.childElementCount === 0 && hit.textContent ? ' "' + hit.textContent.trim().slice(0, 24) + '"' : '');
257
+ let belongs = false;
258
+ if (hit === el || el.contains(hit)) {
259
+ belongs = true;
260
+ }
261
+ else {
262
+ // Walk up from the hit; crossing a shadow boundary hops to the
263
+ // shadow host so targets inside shadow DOM still count.
264
+ let node = hit;
265
+ const seen = new Set();
266
+ while (node && !seen.has(node)) {
267
+ seen.add(node);
268
+ if (node === el) {
269
+ belongs = true;
270
+ break;
271
+ }
272
+ const root = node.getRootNode
273
+ ? node.getRootNode()
274
+ : null;
275
+ node = node.parentNode || (root && root.host ? root.host : null);
276
+ }
277
+ }
278
+ return belongs
279
+ ? { ok: true, x, y, hitDesc, hitSelf: hit === el }
280
+ : { ok: false, reason: 'intercepted', x, y, by: hitDesc, geometry };
281
+ };