dsh-browser-playwright-codex 0.6.1 → 0.6.6
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 +57 -0
- package/NOTICE.md +5 -6
- package/README.md +130 -38
- package/SECURITY.md +18 -11
- package/lib/challenge-signatures.d.ts +114 -0
- package/lib/challenge-signatures.js +157 -0
- package/lib/challenge.d.ts +68 -8
- package/lib/challenge.js +287 -26
- package/lib/injected.js +13 -0
- package/lib/page-actions.d.ts +27 -56
- package/lib/page-actions.js +50 -18
- package/lib/page-diagnostics.d.ts +5 -0
- package/lib/page-diagnostics.js +60 -6
- package/lib/page-probes.d.ts +12 -0
- package/lib/page-probes.js +33 -0
- package/lib/page-snapshot.d.ts +14 -8
- package/lib/page-snapshot.js +50 -3
- package/lib/session.d.ts +21 -82
- package/lib/session.js +15 -3
- package/lib/snapshot-aria.js +16 -0
- package/lib/tool-render.js +58 -3
- package/lib/tool-types.d.ts +16 -0
- package/lib/types.d.ts +23 -0
- package/package.json +8 -7
- package/scripts/dsh-browser-verify.mjs +24 -12
- package/scripts/analysis/hl-compare.mjs +0 -149
- package/scripts/analysis/live-handoff-probe.mjs +0 -97
- package/scripts/analysis/needs-human-probe.mjs +0 -83
- package/scripts/analysis/repro-minimize.mjs +0 -112
- package/scripts/autocommit.ps1 +0 -23
- package/scripts/check-doc-links.py +0 -112
- package/scripts/measure-prompt-cost.mjs +0 -57
- package/scripts/preflight.mjs +0 -192
- package/scripts/prompt-cost.mjs +0 -73
- package/scripts/shipped-gates.mjs +0 -43
package/lib/challenge.js
CHANGED
|
@@ -1,35 +1,211 @@
|
|
|
1
|
+
import { CAPTCHA_ROLES, CAPTCHA_WORD, CHALLENGE_SIGNATURES, WORDING_COMMAND, WORDING_EXCLUDE, WORDING_TITLE, } from "./challenge-signatures.js";
|
|
2
|
+
/** How many accessible names the wording tiers may look at. */
|
|
3
|
+
const MAX_NAMES = 60;
|
|
1
4
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
|
|
5
|
+
* The widget tier reads deeper than the wording tiers. Measured 2026-10-17 on
|
|
6
|
+
* nopecha.com/demo/hcaptcha: a real hCaptcha iframe named
|
|
7
|
+
* "包含 hCaptcha 安全挑战复选框的小部件" sits below depth 2, so a three-level scan
|
|
8
|
+
* never saw it and the real widget was reported as NO MATCH. Vendor widgets are
|
|
9
|
+
* usually mounted inside a wrapper stack; control labels are not.
|
|
10
|
+
*/
|
|
11
|
+
const WIDGET_DEPTH = 8;
|
|
12
|
+
/** Cap on names the widget tier walks (deeper scan, so a larger budget). */
|
|
13
|
+
const MAX_WIDGET_NAMES = 150;
|
|
14
|
+
/** A control label is short; a sentence that merely mentions captchas is not. */
|
|
15
|
+
const MAX_CONTROL_LABEL = 32;
|
|
16
|
+
/**
|
|
17
|
+
* A title that mentions verification only counts on a page with (almost) nothing
|
|
18
|
+
* to click. Measured on real pages 2026-10-17: 百度's actual 安全验证 page has
|
|
19
|
+
* **0** actionable elements, while a Baidu *search result* page titled
|
|
20
|
+
* "安全验证_百度搜索" has 57 and "滑块验证_百度搜索" has 81. Without this gate the
|
|
21
|
+
* title tier fires on any search for those words — which is exactly the query a
|
|
22
|
+
* person investigating captchas types.
|
|
23
|
+
*/
|
|
24
|
+
const TITLE_WORDING_MAX_REFS = 5;
|
|
25
|
+
/** Cap on names folded into one page-state fingerprint. */
|
|
26
|
+
const MAX_FINGERPRINT_NAMES = 200;
|
|
27
|
+
/**
|
|
28
|
+
* How deep the fingerprint reads. Deeper than the wording tiers on purpose: a
|
|
29
|
+
* fingerprint that only saw the top of the tree would call a page "unchanged"
|
|
30
|
+
* when a deep section revealed text without adding a new actionable element,
|
|
31
|
+
* and the backstop would then warn about a wall that is not there.
|
|
32
|
+
*/
|
|
33
|
+
const FINGERPRINT_DEPTH = 4;
|
|
34
|
+
/** Collect accessible names breadth-first, bounded by depth and count. */
|
|
35
|
+
function scan(nodes, depth, maxDepth, out, cap) {
|
|
36
|
+
if (nodes === undefined || out.length >= cap)
|
|
37
|
+
return;
|
|
38
|
+
for (const node of nodes) {
|
|
39
|
+
if (out.length >= cap)
|
|
40
|
+
return;
|
|
41
|
+
const name = node.name ?? '';
|
|
42
|
+
if (name !== '')
|
|
43
|
+
out.push({ role: (node.role ?? '').toLowerCase(), name });
|
|
44
|
+
if (depth < maxDepth)
|
|
45
|
+
scan(node.children, depth + 1, maxDepth, out, cap);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Evidence string when one header rule holds, else null. */
|
|
49
|
+
function headerEvidence(rule, headers) {
|
|
50
|
+
if (headers === undefined)
|
|
51
|
+
return null;
|
|
52
|
+
const wanted = rule.name.toLowerCase();
|
|
53
|
+
for (const key of Object.keys(headers)) {
|
|
54
|
+
if (key.toLowerCase() !== wanted)
|
|
55
|
+
continue;
|
|
56
|
+
const value = headers[key] ?? '';
|
|
57
|
+
const lower = value.toLowerCase();
|
|
58
|
+
if (rule.equals !== undefined && lower !== rule.equals.toLowerCase())
|
|
59
|
+
return null;
|
|
60
|
+
if (rule.contains !== undefined && !lower.includes(rule.contains.toLowerCase()))
|
|
61
|
+
return null;
|
|
62
|
+
return key + ': ' + value;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
/** Evidence string when any header name carries the prefix, else null. */
|
|
67
|
+
function prefixEvidence(prefix, headers) {
|
|
68
|
+
if (headers === undefined)
|
|
69
|
+
return null;
|
|
70
|
+
const wanted = prefix.toLowerCase();
|
|
71
|
+
for (const key of Object.keys(headers)) {
|
|
72
|
+
if (key.toLowerCase().startsWith(wanted))
|
|
73
|
+
return key + ': ' + (headers[key] ?? '');
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Detect an anti-bot / human-verification surface from the MAIN document
|
|
79
|
+
* response metadata, the page's accessible names and, as a last resort, its
|
|
80
|
+
* wording. We never try to solve one: the correct move for an agent is to hand
|
|
81
|
+
* it to the human who can see the open window.
|
|
82
|
+
* @param snapshot - title, url, accessible tree and (when available) the main document response.
|
|
83
|
+
* @returns the matched engine with its tier and the concrete evidence, else null.
|
|
8
84
|
*/
|
|
9
85
|
export function detectChallenge(snapshot) {
|
|
10
86
|
const title = snapshot.title ?? '';
|
|
11
87
|
const url = snapshot.url ?? '';
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
88
|
+
const names = [];
|
|
89
|
+
scan(snapshot.nodes, 0, 2, names, MAX_NAMES);
|
|
90
|
+
// The widget tier gets its own, deeper scan (see WIDGET_DEPTH).
|
|
91
|
+
const widgetNames = [];
|
|
92
|
+
scan(snapshot.nodes, 0, WIDGET_DEPTH, widgetNames, MAX_WIDGET_NAMES);
|
|
93
|
+
const headers = snapshot.response?.headers;
|
|
94
|
+
const status = snapshot.response?.status;
|
|
95
|
+
const refs = snapshot.refs;
|
|
96
|
+
/**
|
|
97
|
+
* Some vendors put their header on every response, so the header alone is not a
|
|
98
|
+
* block: DataDome attaches `x-datadome*` to everything (their docs) and Imperva
|
|
99
|
+
* attaches `x-iinfo` to everything. A signature that lists `statuses` must have
|
|
100
|
+
* the status agree, or a perfectly good page is reported as a wall.
|
|
101
|
+
*/
|
|
102
|
+
const statusAgrees = (required) => required === undefined || (status !== undefined && required.includes(status));
|
|
103
|
+
// Tier 1 — the vendor's own response metadata. This is their published API:
|
|
104
|
+
// Cloudflare documents cf-mitigated: challenge as the supported way to detect
|
|
105
|
+
// a challenge page, so a rename breaks every customer integration, not just us.
|
|
106
|
+
for (const signature of CHALLENGE_SIGNATURES) {
|
|
107
|
+
if (!statusAgrees(signature.statuses))
|
|
108
|
+
continue;
|
|
109
|
+
for (const rule of signature.headers ?? []) {
|
|
110
|
+
const evidence = headerEvidence(rule, headers);
|
|
111
|
+
if (evidence !== null) {
|
|
112
|
+
return { engine: signature.engine, hint: signature.hint, tier: 'response-header', evidence };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (const prefix of signature.headerPrefixes ?? []) {
|
|
116
|
+
const evidence = prefixEvidence(prefix, headers);
|
|
117
|
+
if (evidence !== null) {
|
|
118
|
+
return { engine: signature.engine, hint: signature.hint, tier: 'response-header', evidence };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Tier 2 — the vendor's own widget, by accessible name. An iframe's title is
|
|
123
|
+
// authored by the vendor, so it stands alone; the same word on an ordinary
|
|
124
|
+
// node needs corroboration from somewhere else on the page, because a page may
|
|
125
|
+
// simply discuss that vendor. Corroboration deliberately excludes the matched
|
|
126
|
+
// node itself: "reCAPTCHA" contains "captcha", and letting a token corroborate
|
|
127
|
+
// itself would make this guard vacuous.
|
|
128
|
+
for (const signature of CHALLENGE_SIGNATURES) {
|
|
129
|
+
const tokens = signature.widgetTokens;
|
|
130
|
+
if (tokens === undefined)
|
|
131
|
+
continue;
|
|
132
|
+
// No ref-count gate here on purpose. An earlier version required the page to be
|
|
133
|
+
// nearly unusable before a vendor iframe counted, which suppressed a genuine
|
|
134
|
+
// reCAPTCHA / hCaptcha / Turnstile simply because the login page around it had
|
|
135
|
+
// links (measured: a busy login page stopped being recognised at all). Login
|
|
136
|
+
// pages are the main case for this check, so the frame wins: a v3 badge on an
|
|
137
|
+
// ordinary page is reported as "a captcha is embedded here", which is true.
|
|
138
|
+
for (const node of widgetNames) {
|
|
139
|
+
// Frames only. The vendor's own widget is a frame in every real sample measured
|
|
140
|
+
// (reCAPTCHA, hCaptcha, Turnstile, Arkose): the deep scan exists to reach it.
|
|
141
|
+
// Accepting a token on an ordinary node was a regression — an article titled
|
|
142
|
+
// "How hCaptcha works" has h2/p nodes with the vendor word in them, and the
|
|
143
|
+
// wording elsewhere on the page ("verification", "challenge") then corroborated
|
|
144
|
+
// it, so a perfectly ordinary blog post rendered as a captcha wall.
|
|
145
|
+
if (node.role !== 'iframe' && node.role !== 'frame')
|
|
146
|
+
continue;
|
|
147
|
+
if (!tokens.some((token) => token.test(node.name)))
|
|
148
|
+
continue;
|
|
149
|
+
return {
|
|
150
|
+
engine: signature.engine,
|
|
151
|
+
hint: signature.hint,
|
|
152
|
+
tier: 'widget',
|
|
153
|
+
evidence: node.name.slice(0, 60),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Tier 3 — wording, weakest of the three. A title is short and written by the
|
|
158
|
+
// interceptor, so it stands almost alone: the one extra condition is that the
|
|
159
|
+
// page must also be unusable (see TITLE_WORDING_MAX_REFS). A caller that does
|
|
160
|
+
// not report a ref count is treated as "nothing to click", because the
|
|
161
|
+
// alternative is silently switching this tier off.
|
|
162
|
+
// Fail closed: a caller that does not report a ref count gets no title tier at all.
|
|
163
|
+
// Leaving the gate open when refs was missing is how the false positive came back
|
|
164
|
+
// (the tests themselves passed a BrowserSnapshot, which has totalRefs but no refs).
|
|
165
|
+
const unusablePage = refs !== undefined && refs <= TITLE_WORDING_MAX_REFS;
|
|
166
|
+
const head = title + ' ' + url;
|
|
167
|
+
if (unusablePage) {
|
|
168
|
+
for (const signature of CHALLENGE_SIGNATURES) {
|
|
169
|
+
for (const pattern of signature.titleWording ?? []) {
|
|
170
|
+
if (!pattern.test(head))
|
|
171
|
+
continue;
|
|
172
|
+
return {
|
|
173
|
+
engine: signature.engine,
|
|
174
|
+
hint: signature.hint,
|
|
175
|
+
tier: 'wording',
|
|
176
|
+
evidence: 'title: ' + title.slice(0, 60),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
for (const pattern of WORDING_TITLE) {
|
|
181
|
+
if (!pattern.test(head))
|
|
182
|
+
continue;
|
|
183
|
+
return {
|
|
184
|
+
engine: 'human-verification',
|
|
185
|
+
hint: 'the page is asking to verify a human visitor',
|
|
186
|
+
tier: 'wording',
|
|
187
|
+
evidence: 'title: ' + title.slice(0, 60),
|
|
188
|
+
};
|
|
21
189
|
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
190
|
+
}
|
|
191
|
+
// Control-level wording is not gated the same way: it needs a short label on a
|
|
192
|
+
// control role, which an ordinary results page does not carry.
|
|
193
|
+
for (const node of names) {
|
|
194
|
+
if (node.name.length > MAX_CONTROL_LABEL)
|
|
195
|
+
continue;
|
|
196
|
+
if (WORDING_EXCLUDE.some((pattern) => pattern.test(node.name)))
|
|
197
|
+
continue;
|
|
198
|
+
const captchaControl = CAPTCHA_WORD.test(node.name) && CAPTCHA_ROLES.has(node.role);
|
|
199
|
+
const command = WORDING_COMMAND.some((pattern) => pattern.test(node.name));
|
|
200
|
+
if (!captchaControl && !command)
|
|
201
|
+
continue;
|
|
202
|
+
return {
|
|
203
|
+
engine: 'human-verification',
|
|
204
|
+
hint: 'the page is asking to verify a human visitor',
|
|
205
|
+
tier: 'wording',
|
|
206
|
+
evidence: node.role + ': ' + node.name.slice(0, 60),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
33
209
|
return null;
|
|
34
210
|
}
|
|
35
211
|
/**
|
|
@@ -38,3 +214,88 @@ export function detectChallenge(snapshot) {
|
|
|
38
214
|
* should pause and ask the user to complete it by hand, then re-snapshot.
|
|
39
215
|
*/
|
|
40
216
|
export const CHALLENGE_NOTE = "[human verification] The page triggered an anti-bot challenge; it is NOT solvable by automation and must NOT be retried blindly. The browser window is open on the user's desktop — tell the user a verification appeared, wait for them to finish it (a few seconds), then call browser_snapshot again. The clearance cookie is saved to the profile automatically and keeps working for the session.";
|
|
217
|
+
/**
|
|
218
|
+
* Appended after an action that left the page byte-for-byte unchanged on a page
|
|
219
|
+
* with almost nothing else to interact with. Worded as a guess, because that is
|
|
220
|
+
* what it is: it is the shape an unrecognised wall has, and the whole point of
|
|
221
|
+
* this tier is to cover the vendors we have no signature for.
|
|
222
|
+
*/
|
|
223
|
+
export const NO_PROGRESS_NOTE = '[no visible change] that action left the page identical — same URL, same title, same elements — and the page offers almost nothing else to click. This is a guess, not a diagnosis: it is what an unrecognised anti-bot wall looks like. Take one browser_snapshot to confirm; if the page still looks unchanged, stop and tell the user what you were trying to do and ask them to look at the open window, instead of retrying the same action.';
|
|
224
|
+
/** A page with at most this many actionable elements counts as "nearly empty". */
|
|
225
|
+
export const NO_PROGRESS_MAX_REFS = 3;
|
|
226
|
+
/** Cheap, order-stable hash (djb2) so a fingerprint costs no prompt budget. */
|
|
227
|
+
function hash(text) {
|
|
228
|
+
let value = 5381;
|
|
229
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
230
|
+
value = ((value << 5) + value + text.charCodeAt(index)) >>> 0;
|
|
231
|
+
}
|
|
232
|
+
return value.toString(36);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Identity of one page state: URL, title, actionable-element count and the
|
|
236
|
+
* accessible labels. Two captures with the same fingerprint look the same to a
|
|
237
|
+
* human glancing at the window, which is exactly the question this tier asks.
|
|
238
|
+
* @param input - the slice of a snapshot the backstop compares.
|
|
239
|
+
* @returns an opaque fingerprint string.
|
|
240
|
+
*/
|
|
241
|
+
export function snapshotFingerprint(input) {
|
|
242
|
+
const names = [];
|
|
243
|
+
scan(input.nodes, 0, FINGERPRINT_DEPTH, names, MAX_FINGERPRINT_NAMES);
|
|
244
|
+
const material = [
|
|
245
|
+
input.url ?? '',
|
|
246
|
+
input.title ?? '',
|
|
247
|
+
String(input.refs ?? 0),
|
|
248
|
+
names.map((node) => node.role + '=' + node.name).join('\u0001'),
|
|
249
|
+
].join('\u0000');
|
|
250
|
+
return hash(material);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* The no-progress backstop: a vendor we have never seen leaves no signature, so
|
|
254
|
+
* we stop asking "which wall is this" and ask "did anything happen". Deliberately
|
|
255
|
+
* conservative — it needs an identical page AND (a nearly empty page, or a second
|
|
256
|
+
* identical result), so an ordinary click that is not meant to change anything
|
|
257
|
+
* stays quiet.
|
|
258
|
+
* @param input - whether an action just ran, the identical-capture streak, and the ref count.
|
|
259
|
+
* @returns the advisory note, or null when the page is still plausibly alive.
|
|
260
|
+
*/
|
|
261
|
+
export function detectNoProgress(input) {
|
|
262
|
+
if (!input.afterAction)
|
|
263
|
+
return null;
|
|
264
|
+
if (input.unchangedStreak < 1)
|
|
265
|
+
return null;
|
|
266
|
+
const nearlyEmpty = input.refs <= NO_PROGRESS_MAX_REFS;
|
|
267
|
+
if (!nearlyEmpty && input.unchangedStreak < 2)
|
|
268
|
+
return null;
|
|
269
|
+
return NO_PROGRESS_NOTE;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Appended when a page has nothing to click and almost nothing to read. This is
|
|
273
|
+
* the blind spot the no-progress backstop cannot cover: that tier needs an action
|
|
274
|
+
* to compare before and after, and on a page with zero actionable elements the
|
|
275
|
+
* agent has no action to take — so the emptiest, most wall-like pages used to
|
|
276
|
+
* produce no message at all. Measured 2026-10-17 on 微博's visitor gate: the model
|
|
277
|
+
* received 364 characters, all of them "URL / Title / Refs: 0 / (no visible
|
|
278
|
+
* elements)", and nothing said the page was a gate rather than an empty document.
|
|
279
|
+
*/
|
|
280
|
+
export const SILENT_PAGE_NOTE = '[nothing here] this page has no clickable elements and almost no text: it may be a gate or visitor check that resolves by itself, a page that is still loading, or a page that needs a person. Wait a moment and take one more browser_snapshot; if it still looks like this, tell the user what you were trying to open and ask them to look at the open window, instead of re-sending the same navigation.';
|
|
281
|
+
/** A page with no named node at all counts as "almost no text". Strict on purpose:
|
|
282
|
+
* one heading plus one paragraph is a normal page, not a silent one. */
|
|
283
|
+
const SILENT_PAGE_MAX_NAMES = 0;
|
|
284
|
+
/**
|
|
285
|
+
* Whether the page is a silent one: nothing to click and (almost) nothing to read.
|
|
286
|
+
* @param input - the action ref count and the accessible tree.
|
|
287
|
+
* @returns the advisory note, or null when the page has content worth reading.
|
|
288
|
+
*/
|
|
289
|
+
export function detectSilentPage(input) {
|
|
290
|
+
if (input.refs > 0)
|
|
291
|
+
return null;
|
|
292
|
+
// A page with no title at all is a bare document (about:blank, a raw image):
|
|
293
|
+
// calling that "maybe a gate" is noise, not a warning.
|
|
294
|
+
if (input.title !== undefined && input.title.trim() === '')
|
|
295
|
+
return null;
|
|
296
|
+
const names = [];
|
|
297
|
+
// Read as deep as the widget scan: a page whose only text sits six levels down is
|
|
298
|
+
// a normal page, and a depth-4 scan called it "almost no text" (review, P1-3).
|
|
299
|
+
scan(input.nodes, 0, WIDGET_DEPTH, names, SILENT_PAGE_MAX_NAMES + 1);
|
|
300
|
+
return names.length <= SILENT_PAGE_MAX_NAMES ? SILENT_PAGE_NOTE : null;
|
|
301
|
+
}
|
package/lib/injected.js
CHANGED
|
@@ -203,6 +203,19 @@ export const SNAPSHOT_SCRIPT = String.raw `
|
|
|
203
203
|
// Nearest ancestor name, used to fold a child that only repeats it.
|
|
204
204
|
const inherited = typeof inheritedName === 'string' ? inheritedName : ''
|
|
205
205
|
if (opts.interactiveOnly && !actionable) {
|
|
206
|
+
// Frames are the one non-actionable node kept. An iframe's accessible name
|
|
207
|
+
// is the embedded widget's own title ("reCAPTCHA", "Widget containing a
|
|
208
|
+
// Cloudflare security challenge"), and that title appears nowhere else in
|
|
209
|
+
// the tree — pruning it would hide a challenge widget from exactly the
|
|
210
|
+
// check that is supposed to recognise one.
|
|
211
|
+
if (role === 'iframe') {
|
|
212
|
+
state.count += 1
|
|
213
|
+
if (state.count > opts.maxNodes) {
|
|
214
|
+
state.truncated = true
|
|
215
|
+
return null
|
|
216
|
+
}
|
|
217
|
+
return { role, name: nameOf(el, opts.maxNameLength, opts.maxTextLength, false), children: [] }
|
|
218
|
+
}
|
|
206
219
|
const kids = []
|
|
207
220
|
for (const child of el.children) {
|
|
208
221
|
if (SKIP_TAGS.has(child.tagName.toLowerCase())) continue
|
package/lib/page-actions.d.ts
CHANGED
|
@@ -26,14 +26,7 @@ export declare function assertActionable(_session: PlaywrightSession, locator: L
|
|
|
26
26
|
* and hand the caller the pending state instead (Playwright-MCP pattern).
|
|
27
27
|
*/
|
|
28
28
|
export declare function runAction(session: PlaywrightSession, page: Page, action: () => Promise<unknown>): Promise<ActionResult>;
|
|
29
|
-
export declare function navigate(session: PlaywrightSession, url: string, waitUntil: LoadState, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot
|
|
30
|
-
url: string;
|
|
31
|
-
title: string;
|
|
32
|
-
nodes: never[];
|
|
33
|
-
totalRefs: number;
|
|
34
|
-
truncated: boolean;
|
|
35
|
-
dialogNote: string;
|
|
36
|
-
}>;
|
|
29
|
+
export declare function navigate(session: PlaywrightSession, url: string, waitUntil: LoadState, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
37
30
|
/**
|
|
38
31
|
* Codex-style click-point resolution. Tries a scroll-alignment ladder
|
|
39
32
|
* (centre, then end, then start). For each alignment: scroll the element
|
|
@@ -56,55 +49,40 @@ export declare function resolveClickPoint(session: PlaywrightSession, locator: L
|
|
|
56
49
|
* and the landing note names what was actually clicked plus how many
|
|
57
50
|
* elements shared the label.
|
|
58
51
|
*/
|
|
59
|
-
export declare function clickText(session: PlaywrightSession, text: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
52
|
+
export declare function clickText(session: PlaywrightSession, text: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
53
|
+
/**
|
|
54
|
+
* The href a click on this element would navigate to, for the URL policy.
|
|
55
|
+
* Its own href is not enough: a button or span nested inside an off-domain
|
|
56
|
+
* `<a href>` has no href itself yet the click bubbles into that link and
|
|
57
|
+
* navigates. The raw-coordinate path already checks the closest ancestor
|
|
58
|
+
* link; ref and text clicks must apply the same rule (B2 in the audit).
|
|
59
|
+
* Returns null when no link is involved, which the policy lets through.
|
|
60
|
+
*/
|
|
61
|
+
export declare function clickTargetHref(locator: Locator): Promise<string | null>;
|
|
67
62
|
/**
|
|
68
63
|
* Shared click core: enforce the URL policy for link targets, run the
|
|
69
64
|
* Codex-style geometry ladder with the Playwright actionability fallback,
|
|
70
65
|
* then report what actually received the click. The label names the target
|
|
71
66
|
* in the landing note; shared is how many elements matched it (0 for refs).
|
|
72
67
|
*/
|
|
73
|
-
export declare function clickLocator(session: PlaywrightSession, locator: Locator, signal: AbortSignal | undefined, label: string, shared: number): Promise<import("./types.ts").BrowserSnapshot
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
nodes: never[];
|
|
77
|
-
totalRefs: number;
|
|
78
|
-
truncated: boolean;
|
|
79
|
-
dialogNote: string;
|
|
80
|
-
}>;
|
|
81
|
-
export declare function clickAt(session: PlaywrightSession, x: number, y: number, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot | {
|
|
82
|
-
url: string;
|
|
83
|
-
title: string;
|
|
84
|
-
nodes: never[];
|
|
85
|
-
totalRefs: number;
|
|
86
|
-
truncated: boolean;
|
|
87
|
-
dialogNote: string;
|
|
88
|
-
}>;
|
|
89
|
-
export declare function clickAtRef(session: PlaywrightSession, ref: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot | {
|
|
90
|
-
url: string;
|
|
91
|
-
title: string;
|
|
92
|
-
nodes: never[];
|
|
93
|
-
totalRefs: number;
|
|
94
|
-
truncated: boolean;
|
|
95
|
-
dialogNote: string;
|
|
96
|
-
}>;
|
|
68
|
+
export declare function clickLocator(session: PlaywrightSession, locator: Locator, signal: AbortSignal | undefined, label: string, shared: number): Promise<import("./types.ts").BrowserSnapshot>;
|
|
69
|
+
export declare function clickAt(session: PlaywrightSession, x: number, y: number, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
70
|
+
export declare function clickAtRef(session: PlaywrightSession, ref: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
97
71
|
export declare function fill(session: PlaywrightSession, ref: string, text: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
72
|
+
/**
|
|
73
|
+
* Normalize a field value before comparing. Two levels of forgiveness, both of them
|
|
74
|
+
* things a page does to a value *after* we wrote it, not differences in what we wrote:
|
|
75
|
+
* whitespace is collapsed, and everything that is not a letter or a digit is dropped
|
|
76
|
+
* (`1,000` ≡ `1000`) with case folded (`abc` ≡ `ABC`). That covers input masks and
|
|
77
|
+
* currency fields — which `browser_fill`'s own description names — while still failing
|
|
78
|
+
* a genuinely different value: `ABCD`→`ZZZZ`, `John`→`Jane` and `1234`→`12345` all
|
|
79
|
+
* differ once normalized. The old readback accepted a length tolerance and containment,
|
|
80
|
+
* which is how those used to report success (audit B4).
|
|
81
|
+
*/
|
|
82
|
+
export declare function normalizeFillText(value: string): string;
|
|
98
83
|
/** Read the field back and decide whether the fill landed. */
|
|
99
84
|
export declare function verifyFill(_session: PlaywrightSession, locator: Locator, tag: string, text: string): Promise<boolean>;
|
|
100
|
-
export declare function press(session: PlaywrightSession, ref: string, key: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot
|
|
101
|
-
url: string;
|
|
102
|
-
title: string;
|
|
103
|
-
nodes: never[];
|
|
104
|
-
totalRefs: number;
|
|
105
|
-
truncated: boolean;
|
|
106
|
-
dialogNote: string;
|
|
107
|
-
}>;
|
|
85
|
+
export declare function press(session: PlaywrightSession, ref: string, key: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
108
86
|
/**
|
|
109
87
|
* Hover a trigger the snapshot gave no ref, addressed by its visible label
|
|
110
88
|
* (e.g. a plain span that reveals a menu on mouseenter). The label must
|
|
@@ -122,13 +100,6 @@ export declare function hoverLocator(session: PlaywrightSession, locator: Locato
|
|
|
122
100
|
export declare function scroll(session: PlaywrightSession, direction: 'up' | 'down', amount: number, ref: string | undefined, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
123
101
|
export declare function back(session: PlaywrightSession, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
124
102
|
export declare function forward(session: PlaywrightSession, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
125
|
-
export declare function switchTab(session: PlaywrightSession, index: number, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot
|
|
126
|
-
url: string;
|
|
127
|
-
title: string;
|
|
128
|
-
nodes: never[];
|
|
129
|
-
totalRefs: number;
|
|
130
|
-
truncated: boolean;
|
|
131
|
-
dialogNote: string;
|
|
132
|
-
}>;
|
|
103
|
+
export declare function switchTab(session: PlaywrightSession, index: number, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
133
104
|
export declare function openTab(session: PlaywrightSession, url: string, waitUntil: LoadState, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
134
105
|
export declare function closeTab(session: PlaywrightSession, index: number, signal: AbortSignal | undefined): Promise<void>;
|
package/lib/page-actions.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* 滚动、前进后退、标签切换,以及承载它们的可中断等待管道(withAbort)。
|
|
4
4
|
* @module dsh-browser-playwright-codex/page-actions
|
|
5
5
|
*/
|
|
6
|
-
import { hitTest } from "./page-probes.js";
|
|
6
|
+
import { clickTargetHrefProbe, hitTest } from "./page-probes.js";
|
|
7
7
|
import { BrowserError } from "./errors.js";
|
|
8
8
|
import { assertAllowedUrl } from "./url-policy.js";
|
|
9
9
|
import { asTimeoutError, isAbortError } from "./page-diagnostics.js";
|
|
@@ -257,6 +257,17 @@ export async function clickText(session, text, signal) {
|
|
|
257
257
|
}
|
|
258
258
|
});
|
|
259
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* The href a click on this element would navigate to, for the URL policy.
|
|
262
|
+
* Its own href is not enough: a button or span nested inside an off-domain
|
|
263
|
+
* `<a href>` has no href itself yet the click bubbles into that link and
|
|
264
|
+
* navigates. The raw-coordinate path already checks the closest ancestor
|
|
265
|
+
* link; ref and text clicks must apply the same rule (B2 in the audit).
|
|
266
|
+
* Returns null when no link is involved, which the policy lets through.
|
|
267
|
+
*/
|
|
268
|
+
export async function clickTargetHref(locator) {
|
|
269
|
+
return (await locator.evaluate(clickTargetHrefProbe)) ?? null;
|
|
270
|
+
}
|
|
260
271
|
/**
|
|
261
272
|
* Shared click core: enforce the URL policy for link targets, run the
|
|
262
273
|
* Codex-style geometry ladder with the Playwright actionability fallback,
|
|
@@ -264,7 +275,7 @@ export async function clickText(session, text, signal) {
|
|
|
264
275
|
* in the landing note; shared is how many elements matched it (0 for refs).
|
|
265
276
|
*/
|
|
266
277
|
export async function clickLocator(session, locator, signal, label, shared) {
|
|
267
|
-
session.assertClickTargetAllowed(await locator
|
|
278
|
+
session.assertClickTargetAllowed(await clickTargetHref(locator));
|
|
268
279
|
const page = session.currentPageSync();
|
|
269
280
|
const urlBefore = page.url();
|
|
270
281
|
let point;
|
|
@@ -396,8 +407,9 @@ export async function clickAtRef(session, ref, signal) {
|
|
|
396
407
|
const page = session.currentPageSync();
|
|
397
408
|
const urlBefore = page.url();
|
|
398
409
|
// Ref mode resolves the element before clicking, so the link policy can
|
|
399
|
-
// be enforced up front — the same rule browser_click applies
|
|
400
|
-
|
|
410
|
+
// be enforced up front — the same rule browser_click applies, including the
|
|
411
|
+
// ancestor link a nested control would navigate through.
|
|
412
|
+
session.assertClickTargetAllowed(await clickTargetHref(locator));
|
|
401
413
|
try {
|
|
402
414
|
// Codex-style geometry click with obstruction awareness:
|
|
403
415
|
// scroll (centre → end → start), wait for the bounding rect to
|
|
@@ -492,9 +504,9 @@ export async function fill(session, ref, text, signal) {
|
|
|
492
504
|
lastError = error;
|
|
493
505
|
continue;
|
|
494
506
|
}
|
|
495
|
-
// Post-action verification: read the value back
|
|
496
|
-
//
|
|
497
|
-
//
|
|
507
|
+
// Post-action verification: read the value back and require it to match
|
|
508
|
+
// what was typed (whitespace-normalized only), so a value the page ignored
|
|
509
|
+
// or rewrote gets one retry then a clear diagnostic.
|
|
498
510
|
written = await session.verifyFill(locator, tag, text);
|
|
499
511
|
if (written)
|
|
500
512
|
break;
|
|
@@ -507,24 +519,44 @@ export async function fill(session, ref, text, signal) {
|
|
|
507
519
|
return session.settleAndSnapshot(page, false);
|
|
508
520
|
});
|
|
509
521
|
}
|
|
522
|
+
/**
|
|
523
|
+
* Normalize a field value before comparing. Two levels of forgiveness, both of them
|
|
524
|
+
* things a page does to a value *after* we wrote it, not differences in what we wrote:
|
|
525
|
+
* whitespace is collapsed, and everything that is not a letter or a digit is dropped
|
|
526
|
+
* (`1,000` ≡ `1000`) with case folded (`abc` ≡ `ABC`). That covers input masks and
|
|
527
|
+
* currency fields — which `browser_fill`'s own description names — while still failing
|
|
528
|
+
* a genuinely different value: `ABCD`→`ZZZZ`, `John`→`Jane` and `1234`→`12345` all
|
|
529
|
+
* differ once normalized. The old readback accepted a length tolerance and containment,
|
|
530
|
+
* which is how those used to report success (audit B4).
|
|
531
|
+
*/
|
|
532
|
+
export function normalizeFillText(value) {
|
|
533
|
+
return String(value)
|
|
534
|
+
.normalize('NFKC')
|
|
535
|
+
.toLowerCase()
|
|
536
|
+
.replace(/[^\p{L}\p{N}]+/gu, '');
|
|
537
|
+
}
|
|
510
538
|
/** Read the field back and decide whether the fill landed. */
|
|
511
539
|
export async function verifyFill(_session, locator, tag, text) {
|
|
540
|
+
const expected = normalizeFillText(text);
|
|
512
541
|
try {
|
|
513
542
|
if (tag === 'select') {
|
|
514
543
|
const label = await locator.evaluate((el) => {
|
|
515
544
|
const o = el.selectedOptions[0];
|
|
516
545
|
return o === undefined ? '' : o.label;
|
|
517
546
|
});
|
|
518
|
-
return label ===
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
547
|
+
return normalizeFillText(label) === expected;
|
|
548
|
+
}
|
|
549
|
+
if (tag === 'input' || tag === 'textarea') {
|
|
550
|
+
return normalizeFillText(await locator.inputValue()) === expected;
|
|
551
|
+
}
|
|
552
|
+
// contenteditable / role=textbox elements have no inputValue(); calling it
|
|
553
|
+
// throws, which used to turn a fill that HAD landed into VALUE_NOT_SET.
|
|
554
|
+
// Judge the text the page would show instead.
|
|
555
|
+
const contents = await locator.evaluate((el) => {
|
|
556
|
+
const node = el;
|
|
557
|
+
return [el.textContent ?? '', typeof node.innerText === 'string' ? node.innerText : ''];
|
|
558
|
+
});
|
|
559
|
+
return contents.some((candidate) => normalizeFillText(candidate) === expected);
|
|
528
560
|
}
|
|
529
561
|
catch {
|
|
530
562
|
return false;
|
|
@@ -630,7 +662,7 @@ export async function scroll(session, direction, amount, ref, signal) {
|
|
|
630
662
|
// loaders a beat, then settle (bounded) before capturing.
|
|
631
663
|
await withAbort(new Promise((resolve) => setTimeout(resolve, 450)), signal);
|
|
632
664
|
await session.waitForPageStable(page, 1200);
|
|
633
|
-
return session.capture(page, undefined);
|
|
665
|
+
return session.capture(page, undefined, true);
|
|
634
666
|
});
|
|
635
667
|
}
|
|
636
668
|
export async function back(session, signal) {
|
|
@@ -62,4 +62,9 @@ export declare function networkRequests(session: PlaywrightSession, args: {
|
|
|
62
62
|
failure: string | null | undefined;
|
|
63
63
|
}[];
|
|
64
64
|
};
|
|
65
|
+
/**
|
|
66
|
+
* Bounded page data for browser_extract. Credential values (password fields and
|
|
67
|
+
* password-autocomplete fields) are blanked inside the page before the payload
|
|
68
|
+
* leaves the browser: the extract prompt is sent to an auxiliary model.
|
|
69
|
+
*/
|
|
65
70
|
export declare function pageData(session: PlaywrightSession): Promise<unknown>;
|