dsh-browser-playwright-codex 0.6.1 → 0.6.5
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 +29 -0
- package/README.md +35 -2
- package/lib/challenge-signatures.d.ts +105 -0
- package/lib/challenge-signatures.js +154 -0
- package/lib/challenge.d.ts +48 -8
- package/lib/challenge.js +211 -26
- package/lib/injected.js +13 -0
- package/lib/page-actions.d.ts +7 -56
- package/lib/page-actions.js +1 -1
- package/lib/page-diagnostics.js +23 -5
- package/lib/page-snapshot.d.ts +14 -8
- package/lib/page-snapshot.js +48 -1
- 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 +21 -1
- package/lib/tool-types.d.ts +10 -0
- package/lib/types.d.ts +23 -0
- package/package.json +4 -3
- 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,166 @@
|
|
|
1
|
+
import { CAPTCHA_ROLES, CAPTCHA_WORD, CHALLENGE_SIGNATURES, WIDGET_CORROBORATION, 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;
|
|
4
|
+
/** Cap on the joined text handed to the wording tiers. */
|
|
5
|
+
const MAX_TEXT = 900;
|
|
6
|
+
/** A control label is short; a sentence that merely mentions captchas is not. */
|
|
7
|
+
const MAX_CONTROL_LABEL = 32;
|
|
8
|
+
/** Cap on names folded into one page-state fingerprint. */
|
|
9
|
+
const MAX_FINGERPRINT_NAMES = 200;
|
|
1
10
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
11
|
+
* How deep the fingerprint reads. Deeper than the wording tiers on purpose: a
|
|
12
|
+
* fingerprint that only saw the top of the tree would call a page "unchanged"
|
|
13
|
+
* when a deep section revealed text without adding a new actionable element,
|
|
14
|
+
* and the backstop would then warn about a wall that is not there.
|
|
15
|
+
*/
|
|
16
|
+
const FINGERPRINT_DEPTH = 4;
|
|
17
|
+
/** Collect accessible names breadth-first, bounded by depth and count. */
|
|
18
|
+
function scan(nodes, depth, maxDepth, out, cap) {
|
|
19
|
+
if (nodes === undefined || out.length >= cap)
|
|
20
|
+
return;
|
|
21
|
+
for (const node of nodes) {
|
|
22
|
+
if (out.length >= cap)
|
|
23
|
+
return;
|
|
24
|
+
const name = node.name ?? '';
|
|
25
|
+
if (name !== '')
|
|
26
|
+
out.push({ role: (node.role ?? '').toLowerCase(), name });
|
|
27
|
+
if (depth < maxDepth)
|
|
28
|
+
scan(node.children, depth + 1, maxDepth, out, cap);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Evidence string when one header rule holds, else null. */
|
|
32
|
+
function headerEvidence(rule, headers) {
|
|
33
|
+
if (headers === undefined)
|
|
34
|
+
return null;
|
|
35
|
+
const wanted = rule.name.toLowerCase();
|
|
36
|
+
for (const key of Object.keys(headers)) {
|
|
37
|
+
if (key.toLowerCase() !== wanted)
|
|
38
|
+
continue;
|
|
39
|
+
const value = headers[key] ?? '';
|
|
40
|
+
const lower = value.toLowerCase();
|
|
41
|
+
if (rule.equals !== undefined && lower !== rule.equals.toLowerCase())
|
|
42
|
+
return null;
|
|
43
|
+
if (rule.contains !== undefined && !lower.includes(rule.contains.toLowerCase()))
|
|
44
|
+
return null;
|
|
45
|
+
return key + ': ' + value;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
/** Evidence string when any header name carries the prefix, else null. */
|
|
50
|
+
function prefixEvidence(prefix, headers) {
|
|
51
|
+
if (headers === undefined)
|
|
52
|
+
return null;
|
|
53
|
+
const wanted = prefix.toLowerCase();
|
|
54
|
+
for (const key of Object.keys(headers)) {
|
|
55
|
+
if (key.toLowerCase().startsWith(wanted))
|
|
56
|
+
return key + ': ' + (headers[key] ?? '');
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Detect an anti-bot / human-verification surface from the MAIN document
|
|
62
|
+
* response metadata, the page's accessible names and, as a last resort, its
|
|
63
|
+
* wording. We never try to solve one: the correct move for an agent is to hand
|
|
64
|
+
* it to the human who can see the open window.
|
|
65
|
+
* @param snapshot - title, url, accessible tree and (when available) the main document response.
|
|
66
|
+
* @returns the matched engine with its tier and the concrete evidence, else null.
|
|
8
67
|
*/
|
|
9
68
|
export function detectChallenge(snapshot) {
|
|
10
69
|
const title = snapshot.title ?? '';
|
|
11
70
|
const url = snapshot.url ?? '';
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
71
|
+
const names = [];
|
|
72
|
+
scan(snapshot.nodes, 0, 2, names, MAX_NAMES);
|
|
73
|
+
const headers = snapshot.response?.headers;
|
|
74
|
+
// Tier 1 — the vendor's own response metadata. This is their published API:
|
|
75
|
+
// Cloudflare documents cf-mitigated: challenge as the supported way to detect
|
|
76
|
+
// a challenge page, so a rename breaks every customer integration, not just us.
|
|
77
|
+
for (const signature of CHALLENGE_SIGNATURES) {
|
|
78
|
+
for (const rule of signature.headers ?? []) {
|
|
79
|
+
const evidence = headerEvidence(rule, headers);
|
|
80
|
+
if (evidence !== null) {
|
|
81
|
+
return { engine: signature.engine, hint: signature.hint, tier: 'response-header', evidence };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const prefix of signature.headerPrefixes ?? []) {
|
|
85
|
+
const evidence = prefixEvidence(prefix, headers);
|
|
86
|
+
if (evidence !== null) {
|
|
87
|
+
return { engine: signature.engine, hint: signature.hint, tier: 'response-header', evidence };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Tier 2 — the vendor's own widget, by accessible name. An iframe's title is
|
|
92
|
+
// authored by the vendor, so it stands alone; the same word on an ordinary
|
|
93
|
+
// node needs corroboration from somewhere else on the page, because a page may
|
|
94
|
+
// simply discuss that vendor. Corroboration deliberately excludes the matched
|
|
95
|
+
// node itself: "reCAPTCHA" contains "captcha", and letting a token corroborate
|
|
96
|
+
// itself would make this guard vacuous.
|
|
97
|
+
for (const signature of CHALLENGE_SIGNATURES) {
|
|
98
|
+
const tokens = signature.widgetTokens;
|
|
99
|
+
if (tokens === undefined)
|
|
100
|
+
continue;
|
|
101
|
+
for (const node of names) {
|
|
102
|
+
if (!tokens.some((token) => token.test(node.name)))
|
|
103
|
+
continue;
|
|
104
|
+
const onFrame = node.role === 'iframe' || node.role === 'frame';
|
|
105
|
+
if (!onFrame) {
|
|
106
|
+
const others = [title, url, ...names.filter((other) => other !== node).map((other) => other.name)]
|
|
107
|
+
.join(' | ')
|
|
108
|
+
.slice(0, MAX_TEXT);
|
|
109
|
+
const corroborated = WIDGET_CORROBORATION.some((token) => token.test(others));
|
|
110
|
+
if (!corroborated)
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
engine: signature.engine,
|
|
115
|
+
hint: signature.hint,
|
|
116
|
+
tier: 'widget',
|
|
117
|
+
evidence: node.name.slice(0, 60),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Tier 3 — wording, weakest of the three. A title is short and written by the
|
|
122
|
+
// interceptor, so it stands alone; on a node we require a command form (or a
|
|
123
|
+
// captcha control), which is the difference between a challenge and a help
|
|
124
|
+
// page about captchas.
|
|
125
|
+
const head = title + ' ' + url;
|
|
126
|
+
for (const signature of CHALLENGE_SIGNATURES) {
|
|
127
|
+
for (const pattern of signature.titleWording ?? []) {
|
|
128
|
+
if (!pattern.test(head))
|
|
129
|
+
continue;
|
|
130
|
+
return {
|
|
131
|
+
engine: signature.engine,
|
|
132
|
+
hint: signature.hint,
|
|
133
|
+
tier: 'wording',
|
|
134
|
+
evidence: 'title: ' + title.slice(0, 60),
|
|
135
|
+
};
|
|
21
136
|
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
137
|
+
}
|
|
138
|
+
for (const pattern of WORDING_TITLE) {
|
|
139
|
+
if (!pattern.test(head))
|
|
140
|
+
continue;
|
|
141
|
+
return {
|
|
142
|
+
engine: 'human-verification',
|
|
143
|
+
hint: 'the page is asking to verify a human visitor',
|
|
144
|
+
tier: 'wording',
|
|
145
|
+
evidence: 'title: ' + title.slice(0, 60),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
for (const node of names) {
|
|
149
|
+
if (node.name.length > MAX_CONTROL_LABEL)
|
|
150
|
+
continue;
|
|
151
|
+
if (WORDING_EXCLUDE.some((pattern) => pattern.test(node.name)))
|
|
152
|
+
continue;
|
|
153
|
+
const captchaControl = CAPTCHA_WORD.test(node.name) && CAPTCHA_ROLES.has(node.role);
|
|
154
|
+
const command = WORDING_COMMAND.some((pattern) => pattern.test(node.name));
|
|
155
|
+
if (!captchaControl && !command)
|
|
156
|
+
continue;
|
|
157
|
+
return {
|
|
158
|
+
engine: 'human-verification',
|
|
159
|
+
hint: 'the page is asking to verify a human visitor',
|
|
160
|
+
tier: 'wording',
|
|
161
|
+
evidence: node.role + ': ' + node.name.slice(0, 60),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
33
164
|
return null;
|
|
34
165
|
}
|
|
35
166
|
/**
|
|
@@ -38,3 +169,57 @@ export function detectChallenge(snapshot) {
|
|
|
38
169
|
* should pause and ask the user to complete it by hand, then re-snapshot.
|
|
39
170
|
*/
|
|
40
171
|
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.";
|
|
172
|
+
/**
|
|
173
|
+
* Appended after an action that left the page byte-for-byte unchanged on a page
|
|
174
|
+
* with almost nothing else to interact with. Worded as a guess, because that is
|
|
175
|
+
* what it is: it is the shape an unrecognised wall has, and the whole point of
|
|
176
|
+
* this tier is to cover the vendors we have no signature for.
|
|
177
|
+
*/
|
|
178
|
+
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.';
|
|
179
|
+
/** A page with at most this many actionable elements counts as "nearly empty". */
|
|
180
|
+
export const NO_PROGRESS_MAX_REFS = 3;
|
|
181
|
+
/** Cheap, order-stable hash (djb2) so a fingerprint costs no prompt budget. */
|
|
182
|
+
function hash(text) {
|
|
183
|
+
let value = 5381;
|
|
184
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
185
|
+
value = ((value << 5) + value + text.charCodeAt(index)) >>> 0;
|
|
186
|
+
}
|
|
187
|
+
return value.toString(36);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Identity of one page state: URL, title, actionable-element count and the
|
|
191
|
+
* accessible labels. Two captures with the same fingerprint look the same to a
|
|
192
|
+
* human glancing at the window, which is exactly the question this tier asks.
|
|
193
|
+
* @param input - the slice of a snapshot the backstop compares.
|
|
194
|
+
* @returns an opaque fingerprint string.
|
|
195
|
+
*/
|
|
196
|
+
export function snapshotFingerprint(input) {
|
|
197
|
+
const names = [];
|
|
198
|
+
scan(input.nodes, 0, FINGERPRINT_DEPTH, names, MAX_FINGERPRINT_NAMES);
|
|
199
|
+
const material = [
|
|
200
|
+
input.url ?? '',
|
|
201
|
+
input.title ?? '',
|
|
202
|
+
String(input.refs ?? 0),
|
|
203
|
+
names.map((node) => node.role + '=' + node.name).join('\u0001'),
|
|
204
|
+
].join('\u0000');
|
|
205
|
+
return hash(material);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The no-progress backstop: a vendor we have never seen leaves no signature, so
|
|
209
|
+
* we stop asking "which wall is this" and ask "did anything happen". Deliberately
|
|
210
|
+
* conservative — it needs an identical page AND (a nearly empty page, or a second
|
|
211
|
+
* identical result), so an ordinary click that is not meant to change anything
|
|
212
|
+
* stays quiet.
|
|
213
|
+
* @param input - whether an action just ran, the identical-capture streak, and the ref count.
|
|
214
|
+
* @returns the advisory note, or null when the page is still plausibly alive.
|
|
215
|
+
*/
|
|
216
|
+
export function detectNoProgress(input) {
|
|
217
|
+
if (!input.afterAction)
|
|
218
|
+
return null;
|
|
219
|
+
if (input.unchangedStreak < 1)
|
|
220
|
+
return null;
|
|
221
|
+
const nearlyEmpty = input.refs <= NO_PROGRESS_MAX_REFS;
|
|
222
|
+
if (!nearlyEmpty && input.unchangedStreak < 2)
|
|
223
|
+
return null;
|
|
224
|
+
return NO_PROGRESS_NOTE;
|
|
225
|
+
}
|
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,20 @@ 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>;
|
|
67
53
|
/**
|
|
68
54
|
* Shared click core: enforce the URL policy for link targets, run the
|
|
69
55
|
* Codex-style geometry ladder with the Playwright actionability fallback,
|
|
70
56
|
* then report what actually received the click. The label names the target
|
|
71
57
|
* in the landing note; shared is how many elements matched it (0 for refs).
|
|
72
58
|
*/
|
|
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
|
-
}>;
|
|
59
|
+
export declare function clickLocator(session: PlaywrightSession, locator: Locator, signal: AbortSignal | undefined, label: string, shared: number): Promise<import("./types.ts").BrowserSnapshot>;
|
|
60
|
+
export declare function clickAt(session: PlaywrightSession, x: number, y: number, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
61
|
+
export declare function clickAtRef(session: PlaywrightSession, ref: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
97
62
|
export declare function fill(session: PlaywrightSession, ref: string, text: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
98
63
|
/** Read the field back and decide whether the fill landed. */
|
|
99
64
|
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
|
-
}>;
|
|
65
|
+
export declare function press(session: PlaywrightSession, ref: string, key: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
108
66
|
/**
|
|
109
67
|
* Hover a trigger the snapshot gave no ref, addressed by its visible label
|
|
110
68
|
* (e.g. a plain span that reveals a menu on mouseenter). The label must
|
|
@@ -122,13 +80,6 @@ export declare function hoverLocator(session: PlaywrightSession, locator: Locato
|
|
|
122
80
|
export declare function scroll(session: PlaywrightSession, direction: 'up' | 'down', amount: number, ref: string | undefined, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
123
81
|
export declare function back(session: PlaywrightSession, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
124
82
|
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
|
-
}>;
|
|
83
|
+
export declare function switchTab(session: PlaywrightSession, index: number, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
133
84
|
export declare function openTab(session: PlaywrightSession, url: string, waitUntil: LoadState, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
134
85
|
export declare function closeTab(session: PlaywrightSession, index: number, signal: AbortSignal | undefined): Promise<void>;
|
package/lib/page-actions.js
CHANGED
|
@@ -630,7 +630,7 @@ export async function scroll(session, direction, amount, ref, signal) {
|
|
|
630
630
|
// loaders a beat, then settle (bounded) before capturing.
|
|
631
631
|
await withAbort(new Promise((resolve) => setTimeout(resolve, 450)), signal);
|
|
632
632
|
await session.waitForPageStable(page, 1200);
|
|
633
|
-
return session.capture(page, undefined);
|
|
633
|
+
return session.capture(page, undefined, true);
|
|
634
634
|
});
|
|
635
635
|
}
|
|
636
636
|
export async function back(session, signal) {
|
package/lib/page-diagnostics.js
CHANGED
|
@@ -65,11 +65,29 @@ export function trackPage(session, page) {
|
|
|
65
65
|
});
|
|
66
66
|
page.on('response', (response) => {
|
|
67
67
|
const slot = session.requestEntries.get(response.request());
|
|
68
|
-
if (slot
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
if (slot !== undefined) {
|
|
69
|
+
slot.entry.status = response.status();
|
|
70
|
+
slot.entry.ms = Date.now() - slot.startedAt;
|
|
71
|
+
session.requestEntries.delete(response.request());
|
|
72
|
+
}
|
|
73
|
+
// The main document response carries whatever the interceptor put on the
|
|
74
|
+
// wire. Challenge detection reads this before it reads a single word of the
|
|
75
|
+
// page: `cf-mitigated: challenge` is Cloudflare's published detection
|
|
76
|
+
// contract, and unlike page wording it cannot be reworded by accident.
|
|
77
|
+
try {
|
|
78
|
+
if (!response.request().isNavigationRequest())
|
|
79
|
+
return;
|
|
80
|
+
if (response.frame() !== page.mainFrame())
|
|
81
|
+
return;
|
|
82
|
+
session.docResponses.set(page, {
|
|
83
|
+
status: response.status(),
|
|
84
|
+
url: response.url(),
|
|
85
|
+
headers: { ...response.headers() },
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// A response that detached mid-flight must never break capture.
|
|
90
|
+
}
|
|
73
91
|
});
|
|
74
92
|
page.on('requestfailed', (request) => {
|
|
75
93
|
const slot = session.requestEntries.get(request);
|
package/lib/page-snapshot.d.ts
CHANGED
|
@@ -80,17 +80,23 @@ export declare function captureLegacy(session: PlaywrightSession, page: Page, op
|
|
|
80
80
|
* data-dsh-ref, so legacy and aria engines share the same contract.
|
|
81
81
|
*/
|
|
82
82
|
export declare function attachDiff(session: PlaywrightSession, snap: BrowserSnapshot): BrowserSnapshot;
|
|
83
|
+
/**
|
|
84
|
+
* Attach the main document response metadata observed for this page, when there
|
|
85
|
+
* is one. Challenge detection reads it before it reads a word of the page: a
|
|
86
|
+
* response header is the interceptor's own published API, unlike its prose.
|
|
87
|
+
*/
|
|
88
|
+
export declare function attachDocResponse(session: PlaywrightSession, page: Page, snap: BrowserSnapshot): BrowserSnapshot;
|
|
89
|
+
/**
|
|
90
|
+
* The no-progress backstop — the tier that needs no vendor signature at all. It
|
|
91
|
+
* compares this capture's page-state fingerprint with the previous one and, for a
|
|
92
|
+
* post-action capture, appends an advisory note when nothing moved. The note says
|
|
93
|
+
* it is a guess on purpose: covering walls we have no signature for is the point.
|
|
94
|
+
*/
|
|
95
|
+
export declare function attachNoProgress(session: PlaywrightSession, snap: BrowserSnapshot, afterAction: boolean): BrowserSnapshot;
|
|
83
96
|
/** Build ref -> feature signature map from a captured tree, with parent refs. */
|
|
84
97
|
export declare function buildRefSignatures(_session: PlaywrightSession, nodes: readonly BrowserNode[]): Map<string, RefSignature>;
|
|
85
98
|
export declare function snapshot(session: PlaywrightSession, opts?: {
|
|
86
99
|
interactiveOnly?: boolean;
|
|
87
|
-
}): Promise<BrowserSnapshot
|
|
88
|
-
url: string;
|
|
89
|
-
title: string;
|
|
90
|
-
nodes: never[];
|
|
91
|
-
totalRefs: number;
|
|
92
|
-
truncated: boolean;
|
|
93
|
-
dialogNote: string;
|
|
94
|
-
}>;
|
|
100
|
+
}): Promise<BrowserSnapshot>;
|
|
95
101
|
export declare function refLocator(session: PlaywrightSession, ref: string): Promise<import("playwright-core").Locator>;
|
|
96
102
|
export declare function assertLive(session: PlaywrightSession): void;
|
package/lib/page-snapshot.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { captureAriaSnapshot } from "./snapshot-aria.js";
|
|
2
|
+
import { detectNoProgress, snapshotFingerprint } from "./challenge.js";
|
|
2
3
|
import { isContextDestroyed } from "./page-diagnostics.js";
|
|
3
4
|
import { REF_PATTERN } from "./config.js";
|
|
4
5
|
import { BrowserError } from "./errors.js";
|
|
@@ -72,7 +73,7 @@ export async function settleAndSnapshot(session, page, navChanged) {
|
|
|
72
73
|
await page.waitForLoadState('load', { timeout: session.timeoutMs() }).catch(() => { });
|
|
73
74
|
}
|
|
74
75
|
await session.waitForPageStable(page, navChanged ? 1500 : 800);
|
|
75
|
-
return await session.capture(page, undefined);
|
|
76
|
+
return await session.capture(page, undefined, true);
|
|
76
77
|
});
|
|
77
78
|
if (raced.blocked)
|
|
78
79
|
return session.blockedSnapshot(page, raced.pending);
|
|
@@ -206,6 +207,52 @@ export function attachDiff(session, snap) {
|
|
|
206
207
|
}
|
|
207
208
|
return { ...snap, diff: { added, removed, changed, same, navigationReset: false } };
|
|
208
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* Attach the main document response metadata observed for this page, when there
|
|
212
|
+
* is one. Challenge detection reads it before it reads a word of the page: a
|
|
213
|
+
* response header is the interceptor's own published API, unlike its prose.
|
|
214
|
+
*/
|
|
215
|
+
export function attachDocResponse(session, page, snap) {
|
|
216
|
+
const response = session.docResponses.get(page);
|
|
217
|
+
if (response === undefined)
|
|
218
|
+
return snap;
|
|
219
|
+
// Only the response that produced the document we are actually looking at
|
|
220
|
+
// counts. A single-page app changes the URL with no document load, and reusing
|
|
221
|
+
// the previous document's headers there would report a wall on a page that is
|
|
222
|
+
// fine. Falling back to the lower tiers is the fail-safe direction: they still
|
|
223
|
+
// run, and so does the no-progress backstop.
|
|
224
|
+
if (withoutHash(response.url) !== withoutHash(snap.url))
|
|
225
|
+
return snap;
|
|
226
|
+
return { ...snap, response };
|
|
227
|
+
}
|
|
228
|
+
/** URL without its fragment, for comparing a page against the response it came from. */
|
|
229
|
+
function withoutHash(url) {
|
|
230
|
+
const hash = url.indexOf('#');
|
|
231
|
+
return hash === -1 ? url : url.slice(0, hash);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The no-progress backstop — the tier that needs no vendor signature at all. It
|
|
235
|
+
* compares this capture's page-state fingerprint with the previous one and, for a
|
|
236
|
+
* post-action capture, appends an advisory note when nothing moved. The note says
|
|
237
|
+
* it is a guess on purpose: covering walls we have no signature for is the point.
|
|
238
|
+
*/
|
|
239
|
+
export function attachNoProgress(session, snap, afterAction) {
|
|
240
|
+
const fingerprint = snapshotFingerprint({
|
|
241
|
+
url: snap.url,
|
|
242
|
+
title: snap.title,
|
|
243
|
+
refs: snap.totalRefs,
|
|
244
|
+
nodes: snap.nodes,
|
|
245
|
+
});
|
|
246
|
+
const identical = session.lastFingerprint !== undefined && session.lastFingerprint === fingerprint;
|
|
247
|
+
session.unchangedStreak = identical ? session.unchangedStreak + 1 : 0;
|
|
248
|
+
session.lastFingerprint = fingerprint;
|
|
249
|
+
const note = detectNoProgress({
|
|
250
|
+
afterAction,
|
|
251
|
+
unchangedStreak: session.unchangedStreak,
|
|
252
|
+
refs: snap.totalRefs,
|
|
253
|
+
});
|
|
254
|
+
return note === null ? snap : { ...snap, stallNote: note };
|
|
255
|
+
}
|
|
209
256
|
/** Build ref -> feature signature map from a captured tree, with parent refs. */
|
|
210
257
|
export function buildRefSignatures(_session, nodes) {
|
|
211
258
|
const out = new Map();
|