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/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
- * 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.
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 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);
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
- 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' };
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
@@ -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
- 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
- }>;
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>;
@@ -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) {
@@ -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 === undefined)
69
- return;
70
- slot.entry.status = response.status();
71
- slot.entry.ms = Date.now() - slot.startedAt;
72
- session.requestEntries.delete(response.request());
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);
@@ -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;
@@ -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();