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/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
- * Detect anti-bot / human-verification challenge pages (Cloudflare
3
- * interstitial, Turnstile, reCAPTCHA-style) from a snapshot's title, url and
4
- * visible text. We do NOT try to solve them: the correct move for an agent is
5
- * to hand the challenge to the human who can see the open window. Returns the
6
- * engine name when matched, else null. Matching is conservative to avoid
7
- * false positives on ordinary pages.
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 parts = [];
13
- const walk = (nodes, depth) => {
14
- for (const node of nodes) {
15
- if (parts.length >= 60)
16
- return;
17
- if (node.name !== undefined && node.name !== '')
18
- parts.push(node.name);
19
- if (depth < 2 && node.children !== undefined && node.children.length > 0)
20
- walk(node.children, depth + 1);
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
- walk(snapshot.nodes ?? [], 0);
24
- const text = [title, url, ...parts].join(' | ').slice(0, 900);
25
- const saidCaptcha = parts.some((name) => /(captcha|turnstile|recaptcha|hcaptcha)/i.test(name));
26
- if (/just a moment|attention required|checking (your )?browser|cf-chl-|请稍候|验证你的浏览器/i.test(text))
27
- return { engine: 'Cloudflare', hint: 'Cloudflare is challenging the browser before letting the page load' };
28
- if (/verify (you are|you're|that you are) (a )?human|人机验证|验证您(是|为)人类/i.test(text))
29
- return { engine: 'human-verification', hint: 'the page is asking to verify a human visitor' };
30
- if (/(captcha|turnstile|recaptcha|hcaptcha)/i.test(text) &&
31
- (saidCaptcha || /(captcha|turnstile|recaptcha)/i.test(title)))
32
- return { engine: 'captcha', hint: 'the page embedded a captcha-style challenge' };
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
@@ -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
- url: string;
61
- title: string;
62
- nodes: never[];
63
- totalRefs: number;
64
- truncated: boolean;
65
- dialogNote: string;
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
- url: string;
75
- title: string;
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>;
@@ -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.getAttribute('href'));
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
- session.assertClickTargetAllowed(await locator.getAttribute('href'));
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. Lenient match
496
- // so formatted/masked inputs do not false-positive, but a value
497
- // that never stuck gets one retry then a clear diagnostic.
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 === text;
519
- }
520
- const value = await locator.inputValue();
521
- if (value === text)
522
- return true;
523
- // Lenient: formatted inputs (masks, prefixes) rewrite the display
524
- // value; accept containment or a near-equal length as "written".
525
- return (text.length > 0 &&
526
- value.length > 0 &&
527
- (value.includes(text) || text.includes(value) || Math.abs(value.length - text.length) <= 2));
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>;