dsh-browser-playwright-codex 0.5.2 → 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.
- package/CHANGELOG.md +30 -0
- package/lib/browser-lifecycle.d.ts +12 -0
- package/lib/browser-lifecycle.js +186 -0
- package/lib/challenge.d.ts +20 -0
- package/lib/challenge.js +40 -0
- package/lib/config.d.ts +81 -0
- package/lib/config.js +61 -0
- package/lib/extract.d.ts +13 -0
- package/lib/extract.js +34 -0
- package/lib/index.d.ts +4 -2
- package/lib/index.js +3 -1
- package/lib/login-state.d.ts +10 -0
- package/lib/login-state.js +83 -0
- package/lib/page-actions.d.ts +134 -0
- package/lib/page-actions.js +725 -0
- package/lib/page-diagnostics.d.ts +65 -0
- package/lib/page-diagnostics.js +181 -0
- package/lib/page-probes.d.ts +78 -0
- package/lib/page-probes.js +281 -0
- package/lib/page-snapshot.d.ts +96 -0
- package/lib/page-snapshot.js +280 -0
- package/lib/playwright.d.ts +5 -644
- package/lib/playwright.js +28 -2602
- package/lib/provider.d.ts +157 -0
- package/lib/provider.js +352 -0
- package/lib/session.d.ts +410 -0
- package/lib/session.js +633 -0
- package/lib/tool-render.d.ts +20 -0
- package/lib/tool-render.js +137 -0
- package/lib/tool-schemas.d.ts +76 -0
- package/lib/tool-schemas.js +46 -0
- package/lib/tool-types.d.ts +82 -0
- package/lib/tool-types.js +1 -0
- package/lib/tool.d.ts +2 -26
- package/lib/tool.js +5 -252
- package/lib/url-policy.d.ts +2 -0
- package/lib/url-policy.js +26 -0
- package/lib/window-focus.d.ts +27 -0
- package/lib/window-focus.js +54 -0
- package/package.json +1 -1
- package/scripts/dsh-browser-verify.mjs +2 -1
- package/scripts/prompt-cost.mjs +1 -1
|
@@ -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
|
+
};
|