arise-browser 0.1.0

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.
Files changed (148) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +247 -0
  3. package/deploy/neko/CONTEXT.md +37 -0
  4. package/deploy/neko/arise-browser.service +13 -0
  5. package/deploy/neko/neko.yaml +12 -0
  6. package/deploy/neko/openbox.xml +763 -0
  7. package/deploy/neko/policies.json +28 -0
  8. package/deploy/neko/pulseaudio.pa +16 -0
  9. package/deploy/neko/setup.sh +308 -0
  10. package/deploy/neko/xorg.conf +118 -0
  11. package/dist/bin/arise-browser.d.ts +26 -0
  12. package/dist/bin/arise-browser.d.ts.map +1 -0
  13. package/dist/bin/arise-browser.js +224 -0
  14. package/dist/bin/arise-browser.js.map +1 -0
  15. package/dist/src/browser/action-executor.d.ts +98 -0
  16. package/dist/src/browser/action-executor.d.ts.map +1 -0
  17. package/dist/src/browser/action-executor.js +2726 -0
  18. package/dist/src/browser/action-executor.js.map +1 -0
  19. package/dist/src/browser/behavior-recorder.d.ts +61 -0
  20. package/dist/src/browser/behavior-recorder.d.ts.map +1 -0
  21. package/dist/src/browser/behavior-recorder.js +442 -0
  22. package/dist/src/browser/behavior-recorder.js.map +1 -0
  23. package/dist/src/browser/browser-session.d.ts +202 -0
  24. package/dist/src/browser/browser-session.d.ts.map +1 -0
  25. package/dist/src/browser/browser-session.js +1647 -0
  26. package/dist/src/browser/browser-session.js.map +1 -0
  27. package/dist/src/browser/config.d.ts +43 -0
  28. package/dist/src/browser/config.d.ts.map +1 -0
  29. package/dist/src/browser/config.js +59 -0
  30. package/dist/src/browser/config.js.map +1 -0
  31. package/dist/src/browser/page-snapshot.d.ts +38 -0
  32. package/dist/src/browser/page-snapshot.d.ts.map +1 -0
  33. package/dist/src/browser/page-snapshot.js +241 -0
  34. package/dist/src/browser/page-snapshot.js.map +1 -0
  35. package/dist/src/browser/scripts/behavior_tracker.js +424 -0
  36. package/dist/src/browser/scripts/unified_analyzer.js +1576 -0
  37. package/dist/src/index.d.ts +15 -0
  38. package/dist/src/index.d.ts.map +1 -0
  39. package/dist/src/index.js +15 -0
  40. package/dist/src/index.js.map +1 -0
  41. package/dist/src/lock.d.ts +11 -0
  42. package/dist/src/lock.d.ts.map +1 -0
  43. package/dist/src/lock.js +47 -0
  44. package/dist/src/lock.js.map +1 -0
  45. package/dist/src/logger.d.ts +17 -0
  46. package/dist/src/logger.d.ts.map +1 -0
  47. package/dist/src/logger.js +29 -0
  48. package/dist/src/logger.js.map +1 -0
  49. package/dist/src/server/middleware/auth.d.ts +6 -0
  50. package/dist/src/server/middleware/auth.d.ts.map +1 -0
  51. package/dist/src/server/middleware/auth.js +24 -0
  52. package/dist/src/server/middleware/auth.js.map +1 -0
  53. package/dist/src/server/route-utils.d.ts +15 -0
  54. package/dist/src/server/route-utils.d.ts.map +1 -0
  55. package/dist/src/server/route-utils.js +33 -0
  56. package/dist/src/server/route-utils.js.map +1 -0
  57. package/dist/src/server/routes/action.d.ts +5 -0
  58. package/dist/src/server/routes/action.d.ts.map +1 -0
  59. package/dist/src/server/routes/action.js +69 -0
  60. package/dist/src/server/routes/action.js.map +1 -0
  61. package/dist/src/server/routes/actions.d.ts +3 -0
  62. package/dist/src/server/routes/actions.d.ts.map +1 -0
  63. package/dist/src/server/routes/actions.js +53 -0
  64. package/dist/src/server/routes/actions.js.map +1 -0
  65. package/dist/src/server/routes/cookies.d.ts +3 -0
  66. package/dist/src/server/routes/cookies.d.ts.map +1 -0
  67. package/dist/src/server/routes/cookies.js +27 -0
  68. package/dist/src/server/routes/cookies.js.map +1 -0
  69. package/dist/src/server/routes/download.d.ts +3 -0
  70. package/dist/src/server/routes/download.d.ts.map +1 -0
  71. package/dist/src/server/routes/download.js +35 -0
  72. package/dist/src/server/routes/download.js.map +1 -0
  73. package/dist/src/server/routes/evaluate.d.ts +3 -0
  74. package/dist/src/server/routes/evaluate.d.ts.map +1 -0
  75. package/dist/src/server/routes/evaluate.js +27 -0
  76. package/dist/src/server/routes/evaluate.js.map +1 -0
  77. package/dist/src/server/routes/health.d.ts +3 -0
  78. package/dist/src/server/routes/health.d.ts.map +1 -0
  79. package/dist/src/server/routes/health.js +11 -0
  80. package/dist/src/server/routes/health.js.map +1 -0
  81. package/dist/src/server/routes/navigate.d.ts +3 -0
  82. package/dist/src/server/routes/navigate.d.ts.map +1 -0
  83. package/dist/src/server/routes/navigate.js +36 -0
  84. package/dist/src/server/routes/navigate.js.map +1 -0
  85. package/dist/src/server/routes/page-model.d.ts +3 -0
  86. package/dist/src/server/routes/page-model.d.ts.map +1 -0
  87. package/dist/src/server/routes/page-model.js +22 -0
  88. package/dist/src/server/routes/page-model.js.map +1 -0
  89. package/dist/src/server/routes/pdf.d.ts +3 -0
  90. package/dist/src/server/routes/pdf.d.ts.map +1 -0
  91. package/dist/src/server/routes/pdf.js +20 -0
  92. package/dist/src/server/routes/pdf.js.map +1 -0
  93. package/dist/src/server/routes/recording.d.ts +5 -0
  94. package/dist/src/server/routes/recording.d.ts.map +1 -0
  95. package/dist/src/server/routes/recording.js +217 -0
  96. package/dist/src/server/routes/recording.js.map +1 -0
  97. package/dist/src/server/routes/screenshot.d.ts +3 -0
  98. package/dist/src/server/routes/screenshot.d.ts.map +1 -0
  99. package/dist/src/server/routes/screenshot.js +32 -0
  100. package/dist/src/server/routes/screenshot.js.map +1 -0
  101. package/dist/src/server/routes/snapshot.d.ts +3 -0
  102. package/dist/src/server/routes/snapshot.d.ts.map +1 -0
  103. package/dist/src/server/routes/snapshot.js +454 -0
  104. package/dist/src/server/routes/snapshot.js.map +1 -0
  105. package/dist/src/server/routes/tab-lock.d.ts +3 -0
  106. package/dist/src/server/routes/tab-lock.d.ts.map +1 -0
  107. package/dist/src/server/routes/tab-lock.js +30 -0
  108. package/dist/src/server/routes/tab-lock.js.map +1 -0
  109. package/dist/src/server/routes/tab.d.ts +3 -0
  110. package/dist/src/server/routes/tab.d.ts.map +1 -0
  111. package/dist/src/server/routes/tab.js +47 -0
  112. package/dist/src/server/routes/tab.js.map +1 -0
  113. package/dist/src/server/routes/tabs.d.ts +3 -0
  114. package/dist/src/server/routes/tabs.d.ts.map +1 -0
  115. package/dist/src/server/routes/tabs.js +13 -0
  116. package/dist/src/server/routes/tabs.js.map +1 -0
  117. package/dist/src/server/routes/text.d.ts +3 -0
  118. package/dist/src/server/routes/text.d.ts.map +1 -0
  119. package/dist/src/server/routes/text.js +20 -0
  120. package/dist/src/server/routes/text.js.map +1 -0
  121. package/dist/src/server/routes/upload.d.ts +3 -0
  122. package/dist/src/server/routes/upload.d.ts.map +1 -0
  123. package/dist/src/server/routes/upload.js +38 -0
  124. package/dist/src/server/routes/upload.js.map +1 -0
  125. package/dist/src/server/server.d.ts +7 -0
  126. package/dist/src/server/server.d.ts.map +1 -0
  127. package/dist/src/server/server.js +69 -0
  128. package/dist/src/server/server.js.map +1 -0
  129. package/dist/src/types/index.d.ts +125 -0
  130. package/dist/src/types/index.d.ts.map +1 -0
  131. package/dist/src/types/index.js +5 -0
  132. package/dist/src/types/index.js.map +1 -0
  133. package/dist/src/virtual-display/manager.d.ts +37 -0
  134. package/dist/src/virtual-display/manager.d.ts.map +1 -0
  135. package/dist/src/virtual-display/manager.js +229 -0
  136. package/dist/src/virtual-display/manager.js.map +1 -0
  137. package/dist/src/virtual-display/process-runner.d.ts +43 -0
  138. package/dist/src/virtual-display/process-runner.d.ts.map +1 -0
  139. package/dist/src/virtual-display/process-runner.js +174 -0
  140. package/dist/src/virtual-display/process-runner.js.map +1 -0
  141. package/dist/tsconfig.tsbuildinfo +1 -0
  142. package/package.json +57 -0
  143. package/plugin/openclaw.plugin.json +148 -0
  144. package/skill/arise-browser/SKILL.md +275 -0
  145. package/skill/arise-browser/TRUST.md +42 -0
  146. package/skill/arise-browser/references/api.md +198 -0
  147. package/src/browser/scripts/behavior_tracker.js +424 -0
  148. package/src/browser/scripts/unified_analyzer.js +1576 -0
@@ -0,0 +1,2726 @@
1
+ /**
2
+ * ActionExecutor — Executes high-level actions on a Playwright Page.
3
+ *
4
+ * Action types: click, type, select, wait, extract, scroll, enter,
5
+ * mouse_control, mouse_drag, press_key, navigate, back, forward,
6
+ * hover, focus
7
+ *
8
+ * Click: supports aria-ref/CSS selector, prefers regular click,
9
+ * and validates observable state change to avoid false success.
10
+ * Mouse control: JS elementFromPoint + dispatchEvent.
11
+ */
12
+ import { BrowserConfig } from "./config.js";
13
+ import { createLogger } from "../logger.js";
14
+ const logger = createLogger("action-executor");
15
+ function escapeRef(ref) {
16
+ return ref.replace(/['"\\]/g, "");
17
+ }
18
+ function escapeRegex(value) {
19
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
20
+ }
21
+ export class ActionExecutor {
22
+ page;
23
+ session;
24
+ defaultTimeout;
25
+ shortTimeout;
26
+ maxScrollAmount;
27
+ constructor(page, session) {
28
+ this.page = page;
29
+ this.session = session;
30
+ this.defaultTimeout = BrowserConfig.actionTimeout;
31
+ this.shortTimeout = BrowserConfig.shortTimeout;
32
+ this.maxScrollAmount = BrowserConfig.maxScrollAmount;
33
+ }
34
+ async execute(action) {
35
+ if (!action) {
36
+ return { success: false, message: "No action to execute", details: {} };
37
+ }
38
+ const actionType = action.type;
39
+ if (!actionType) {
40
+ return { success: false, message: "Error: action has no type", details: {} };
41
+ }
42
+ try {
43
+ const handlers = {
44
+ click: (a) => this._click(a),
45
+ type: (a) => this._type(a),
46
+ select: (a) => this._select(a),
47
+ wait: (a) => this._wait(a),
48
+ extract: (a) => this._extract(a),
49
+ scroll: (a) => this._scroll(a),
50
+ enter: (a) => this._enter(a),
51
+ mouse_control: (a) => this._mouseControl(a),
52
+ mouse_drag: (a) => this._mouseDrag(a),
53
+ press_key: (a) => this._pressKey(a),
54
+ navigate: (a) => this._navigate(a),
55
+ back: (a) => this._back(a),
56
+ forward: (a) => this._forward(a),
57
+ hover: (a) => this._hover(a),
58
+ focus: (a) => this._focus(a),
59
+ };
60
+ const handler = handlers[actionType];
61
+ if (!handler) {
62
+ return {
63
+ success: false,
64
+ message: `Error: Unknown action type '${actionType}'`,
65
+ details: { action_type: actionType },
66
+ };
67
+ }
68
+ const result = await handler(action);
69
+ return { success: result.success, message: result.message, details: result.details };
70
+ }
71
+ catch (exc) {
72
+ logger.error({ actionType, err: exc }, "Action execution failed");
73
+ return {
74
+ success: false,
75
+ message: `Error executing ${actionType}: ${exc}`,
76
+ details: { action_type: actionType, error: String(exc) },
77
+ };
78
+ }
79
+ }
80
+ static shouldUpdateSnapshot(action) {
81
+ const changeTypes = new Set([
82
+ "click", "type", "select", "scroll", "navigate",
83
+ "enter", "back", "forward", "mouse_control", "mouse_drag", "press_key",
84
+ "hover", "focus",
85
+ ]);
86
+ return changeTypes.has(action.type);
87
+ }
88
+ // ===== Click =====
89
+ async _click(action) {
90
+ const ref = action.ref;
91
+ const selector = action.selector;
92
+ const requestedIntent = this._normalizeClickIntent(action.clickIntent);
93
+ const requestedExpectedEffect = this._normalizeClickExpectedEffect(action.expectedEffect);
94
+ const requestedExpectedHref = typeof action.expectedHref === "string" && action.expectedHref.trim()
95
+ ? action.expectedHref.trim()
96
+ : null;
97
+ const requestedExpectedText = typeof action.expectedText === "string" && action.expectedText.trim()
98
+ ? action.expectedText.trim()
99
+ : null;
100
+ if (!ref && !selector) {
101
+ return { success: false, message: "Error: click requires ref or selector", details: { error: "missing_target" } };
102
+ }
103
+ let target = ref ? `[aria-ref='${escapeRef(ref)}']` : selector;
104
+ const details = {
105
+ ref: ref ?? null,
106
+ selector: selector ?? null,
107
+ target,
108
+ strategies_tried: [],
109
+ successful_strategy: null,
110
+ click_method: null,
111
+ new_tab_created: false,
112
+ click_intent_requested: requestedIntent,
113
+ expected_effect_requested: requestedExpectedEffect,
114
+ expected_href: requestedExpectedHref,
115
+ expected_text: requestedExpectedText,
116
+ };
117
+ let cleanupMarker = null;
118
+ try {
119
+ let element = this.page.locator(target).first();
120
+ let count = await element.count();
121
+ if (count === 0 && ref && (requestedExpectedHref || requestedExpectedText)) {
122
+ details.recovery_attempted = true;
123
+ const recovered = await this._recoverClickTarget(requestedExpectedHref, requestedExpectedText);
124
+ details.recovery_result = recovered.reason;
125
+ if (recovered.candidateCount !== undefined) {
126
+ details.recovery_candidate_count = recovered.candidateCount;
127
+ }
128
+ if (recovered.matchedHref) {
129
+ details.recovered_href = recovered.matchedHref;
130
+ }
131
+ if (recovered.matchedText) {
132
+ details.recovered_text = recovered.matchedText;
133
+ }
134
+ if (!recovered.locator || !recovered.selector || !recovered.marker) {
135
+ details.error = recovered.reason;
136
+ return { success: false, message: `Error: Click failed, ${recovered.reason}`, details };
137
+ }
138
+ cleanupMarker = recovered.marker;
139
+ target = recovered.selector;
140
+ details.target = target;
141
+ details.recovered_target = target;
142
+ element = recovered.locator;
143
+ count = await element.count();
144
+ }
145
+ if (count === 0) {
146
+ details.error = "element_not_found";
147
+ return { success: false, message: "Error: Click failed, element not found", details };
148
+ }
149
+ details.successful_strategy = target;
150
+ let clickTarget = element;
151
+ // Collect element diagnostics
152
+ let elementDiag = null;
153
+ try {
154
+ const diag = await element.evaluate((el) => {
155
+ const text = (el.innerText || el.textContent || "").trim();
156
+ const rect = el.getBoundingClientRect();
157
+ const inViewport = rect.width > 0 &&
158
+ rect.height > 0 &&
159
+ rect.bottom >= 0 &&
160
+ rect.right >= 0 &&
161
+ rect.top <= (window.innerHeight || document.documentElement.clientHeight) &&
162
+ rect.left <= (window.innerWidth || document.documentElement.clientWidth);
163
+ const closestLink = el.closest("a");
164
+ const descendantLinks = el.querySelectorAll("a[href]");
165
+ const descendantLink = descendantLinks.length === 1 ? descendantLinks[0] : null;
166
+ const descendantText = descendantLink
167
+ ? (descendantLink.innerText || descendantLink.textContent || "").trim()
168
+ : "";
169
+ return {
170
+ tag: el.tagName,
171
+ href: el.getAttribute("href"),
172
+ closestHref: closestLink ? closestLink.getAttribute("href") : null,
173
+ role: el.getAttribute("role"),
174
+ text: text ? text.slice(0, 200) : "",
175
+ descendantHref: descendantLink ? descendantLink.getAttribute("href") : null,
176
+ descendantText: descendantText ? descendantText.slice(0, 200) : "",
177
+ descendantCount: descendantLinks.length,
178
+ onclick: !!el.getAttribute("onclick") || typeof el.onclick === "function",
179
+ inViewport,
180
+ };
181
+ });
182
+ elementDiag = diag;
183
+ logger.debug({ elementDiag }, "Click element diagnostics");
184
+ }
185
+ catch (e) {
186
+ logger.debug({ err: e }, "Click diagnostics failed");
187
+ }
188
+ // Conservative redirect: if container wraps a single link, prefer that link
189
+ try {
190
+ if (elementDiag) {
191
+ const tag = elementDiag.tag;
192
+ const href = elementDiag.href;
193
+ const closestHref = elementDiag.closestHref;
194
+ const hasOnclick = elementDiag.onclick;
195
+ const descendantCount = elementDiag.descendantCount;
196
+ const descendantHref = elementDiag.descendantHref;
197
+ const sourceText = (elementDiag.text || "").trim();
198
+ const descendantText = (elementDiag.descendantText || "").trim();
199
+ const role = elementDiag.role;
200
+ const roleIsLink = (role || "").toLowerCase() === "link";
201
+ if (["LI", "DIV", "SPAN"].includes(tag) &&
202
+ !href &&
203
+ !closestHref &&
204
+ !roleIsLink &&
205
+ !hasOnclick &&
206
+ descendantCount === 1 &&
207
+ descendantHref &&
208
+ sourceText &&
209
+ descendantText &&
210
+ (sourceText.toLowerCase().includes(descendantText.toLowerCase()) ||
211
+ descendantText.toLowerCase().includes(sourceText.toLowerCase()))) {
212
+ const descendantLocator = element.locator(":scope a[href]").first();
213
+ if ((await descendantLocator.count()) > 0 &&
214
+ (await descendantLocator.isVisible()) &&
215
+ (await descendantLocator.isEnabled())) {
216
+ clickTarget = descendantLocator;
217
+ details.redirected_click_target = "descendant_a";
218
+ details.descendant_href = descendantHref;
219
+ logger.debug({ descendantHref }, "Redirecting click to descendant <a>");
220
+ }
221
+ }
222
+ }
223
+ }
224
+ catch (e) {
225
+ logger.debug({ err: e }, "Descendant link check failed");
226
+ }
227
+ const beforeObservation = await this._captureClickObservation(clickTarget);
228
+ details.before_observation = beforeObservation;
229
+ const resolvedExpectedHref = this._resolveExpectedHref(requestedExpectedHref, elementDiag, beforeObservation.pageUrl);
230
+ const isLikelyLink = this._isLikelyLink(elementDiag) || !!resolvedExpectedHref;
231
+ const clickIntent = this._deriveClickIntent(requestedIntent, isLikelyLink, beforeObservation.pageUrl, resolvedExpectedHref);
232
+ details.click_intent = clickIntent;
233
+ details.expected_href = resolvedExpectedHref;
234
+ if (clickIntent === "auto") {
235
+ return await this._executeAutoClick(clickTarget, target, details, isLikelyLink, beforeObservation, requestedExpectedEffect);
236
+ }
237
+ if (clickIntent === "new_tab" && this.session) {
238
+ const newTabResult = await this._attemptCtrlClickNewTab(clickTarget, target, details, resolvedExpectedHref);
239
+ if (newTabResult) {
240
+ return newTabResult;
241
+ }
242
+ details.new_tab_fallback = "same_tab";
243
+ }
244
+ if (clickIntent === "same_tab" || (clickIntent === "new_tab" && isLikelyLink)) {
245
+ return await this._executeSameTabLinkClick(clickTarget, target, details, resolvedExpectedHref, beforeObservation.pageUrl);
246
+ }
247
+ return await this._executeUiClick(clickTarget, target, details, beforeObservation, requestedExpectedEffect);
248
+ }
249
+ finally {
250
+ if (cleanupMarker) {
251
+ await this._clearRecoveryMarker(cleanupMarker);
252
+ }
253
+ }
254
+ }
255
+ // ===== Type =====
256
+ async _type(action) {
257
+ const ref = action.ref;
258
+ const text = action.text || "";
259
+ if (!ref) {
260
+ return { success: false, message: "Error: type requires ref", details: { error: "missing_ref" } };
261
+ }
262
+ const target = `[aria-ref='${escapeRef(ref)}']`;
263
+ const details = { ref, target, text, text_length: text.length };
264
+ const control = this.page.locator(target).first();
265
+ try {
266
+ const count = await control.count();
267
+ if (count === 0) {
268
+ details.error = "element_not_found";
269
+ return { success: false, message: "Error: type failed, element not found", details };
270
+ }
271
+ const identity = await this._readTypeIdentity(control);
272
+ details.identity = identity;
273
+ const beforeState = await this._readTypeState(control);
274
+ details.before_state = beforeState;
275
+ const preferKeyboard = this._shouldPreferKeyboardTyping(beforeState);
276
+ details.prefer_keyboard = preferKeyboard;
277
+ const strategies = preferKeyboard
278
+ ? ["keyboard_type", "fill"]
279
+ : ["fill", "keyboard_type"];
280
+ for (const strategy of strategies) {
281
+ if (strategy === "fill") {
282
+ try {
283
+ await control.fill(text, { timeout: this.shortTimeout });
284
+ }
285
+ catch (exc) {
286
+ details.fill_error = String(exc);
287
+ continue;
288
+ }
289
+ }
290
+ else {
291
+ try {
292
+ try {
293
+ await control.click({ timeout: this.shortTimeout });
294
+ details.keyboard_focus_method = "click";
295
+ }
296
+ catch {
297
+ await control.focus();
298
+ details.keyboard_focus_method = "focus";
299
+ }
300
+ try {
301
+ await control.press("ControlOrMeta+A", { timeout: this.shortTimeout });
302
+ }
303
+ catch {
304
+ try {
305
+ await control.press("Control+A", { timeout: this.shortTimeout });
306
+ }
307
+ catch {
308
+ // Best-effort select-all; continue with backspace/delete fallback.
309
+ }
310
+ }
311
+ try {
312
+ await control.press("Backspace", { timeout: this.shortTimeout });
313
+ }
314
+ catch {
315
+ // Best effort.
316
+ }
317
+ try {
318
+ await control.press("Delete", { timeout: this.shortTimeout });
319
+ }
320
+ catch {
321
+ // Best effort.
322
+ }
323
+ await this.page.keyboard.type(text, { delay: 45 });
324
+ }
325
+ catch (exc) {
326
+ details.keyboard_type_error = String(exc);
327
+ continue;
328
+ }
329
+ }
330
+ await this.page.waitForTimeout(180);
331
+ const afterRead = await this._readTypeStateWithFallback(control, identity);
332
+ details[`${strategy}_state_read_mode`] = afterRead.mode;
333
+ if (afterRead.error) {
334
+ details[`${strategy}_state_read_error`] = afterRead.error;
335
+ }
336
+ if (!afterRead.state) {
337
+ continue;
338
+ }
339
+ const afterState = afterRead.state;
340
+ details[`${strategy}_after_state`] = afterState;
341
+ const verified = this._typeStateMatchesExpected(afterState, text);
342
+ details[`${strategy}_verified`] = verified;
343
+ if (verified) {
344
+ details.strategy = strategy;
345
+ return { success: true, message: `Typed '${text}' into ${target}`, details };
346
+ }
347
+ }
348
+ const finalRead = await this._readTypeStateWithFallback(control, identity);
349
+ details.after_state_read_mode = finalRead.mode;
350
+ if (finalRead.error) {
351
+ details.after_state_read_error = finalRead.error;
352
+ }
353
+ details.after_state = finalRead.state;
354
+ details.error = "value_not_verified";
355
+ return {
356
+ success: false,
357
+ message: `Type failed: could not verify text '${text}' in ${target}`,
358
+ details,
359
+ };
360
+ }
361
+ catch (exc) {
362
+ details.error = String(exc);
363
+ return { success: false, message: `Type failed: ${exc}`, details };
364
+ }
365
+ }
366
+ // ===== Select =====
367
+ async _select(action) {
368
+ const ref = action.ref;
369
+ const selector = action.selector;
370
+ const value = action.value || "";
371
+ if (!ref && !selector) {
372
+ return { success: false, message: "Error: select requires ref or selector", details: { error: "missing_target" } };
373
+ }
374
+ const target = ref ? `[aria-ref='${escapeRef(ref)}']` : selector;
375
+ const details = { ref: ref ?? null, selector: selector ?? null, target, value };
376
+ const control = this.page.locator(target).first();
377
+ try {
378
+ const beforeModel = await this._readSelectModel(control, this.defaultTimeout);
379
+ if (!beforeModel) {
380
+ details.error = "element_not_found";
381
+ return { success: false, message: "Error: Select failed, element not found", details };
382
+ }
383
+ details.before_state = this._toSelectStateDebug(beforeModel);
384
+ details.control_kind = beforeModel.kind;
385
+ if (beforeModel.kind === "native_select") {
386
+ const resolution = this._resolveSelectOption(beforeModel.options, value);
387
+ details.resolution = this._toSelectResolutionDebug(resolution);
388
+ if (!resolution) {
389
+ details.error = "option_not_found";
390
+ return {
391
+ success: false,
392
+ message: `Select failed: could not match option '${value}' in ${target}`,
393
+ details,
394
+ };
395
+ }
396
+ try {
397
+ if (resolution.option.value) {
398
+ details.native_selected_values = await control.selectOption({ value: resolution.option.value }, { timeout: this.defaultTimeout });
399
+ }
400
+ else if (resolution.option.label) {
401
+ details.native_selected_values = await control.selectOption({ label: resolution.option.label }, { timeout: this.defaultTimeout });
402
+ }
403
+ else {
404
+ details.native_selected_values = await control.selectOption({ index: beforeModel.options.indexOf(resolution.option) }, { timeout: this.defaultTimeout });
405
+ }
406
+ }
407
+ catch (nativeErr) {
408
+ details.native_error = String(nativeErr);
409
+ details.error = "native_select_failed";
410
+ return { success: false, message: `Select failed: ${nativeErr}`, details };
411
+ }
412
+ const afterModel = await this._reacquireSelectModel(target, beforeModel.identity);
413
+ details.after_state = this._toSelectStateDebug(afterModel);
414
+ if (afterModel && this._selectModelMatchesResolution(afterModel, resolution)) {
415
+ details.strategy = "native_select";
416
+ return { success: true, message: `Selected '${value}' in ${target}`, details };
417
+ }
418
+ details.error = "selection_not_verified";
419
+ return {
420
+ success: false,
421
+ message: `Select failed: could not verify selection '${value}' in ${target}`,
422
+ details,
423
+ };
424
+ }
425
+ const popup = await this._openCustomSelectScope(control, beforeModel, details);
426
+ if (!popup) {
427
+ details.error = "popup_scope_not_found";
428
+ return {
429
+ success: false,
430
+ message: `Select failed: could not determine option scope for ${target}`,
431
+ details,
432
+ };
433
+ }
434
+ const popupOptions = await this._readScopedSelectOptions(popup);
435
+ details.popup_options = popupOptions.map((option) => this._toSelectOptionDebug(option));
436
+ const resolution = this._resolveSelectOption(popupOptions, value);
437
+ details.resolution = this._toSelectResolutionDebug(resolution);
438
+ if (!resolution) {
439
+ details.error = "option_not_found";
440
+ return {
441
+ success: false,
442
+ message: `Select failed: could not match option '${value}' in ${target}`,
443
+ details,
444
+ };
445
+ }
446
+ const customClicked = await this._clickScopedSelectOption(popup, resolution.option, details);
447
+ const afterModel = await this._reacquireSelectModel(target, beforeModel.identity);
448
+ details.after_state = this._toSelectStateDebug(afterModel);
449
+ if (customClicked && afterModel && this._selectModelMatchesResolution(afterModel, resolution)) {
450
+ details.strategy = "custom_select";
451
+ return { success: true, message: `Selected '${value}' in ${target}`, details };
452
+ }
453
+ details.error = "selection_not_verified";
454
+ return { success: false, message: `Select failed: could not verify selection '${value}' in ${target}`, details };
455
+ }
456
+ catch (err) {
457
+ details.error = String(err);
458
+ return { success: false, message: `Select failed: ${err}`, details };
459
+ }
460
+ }
461
+ // ===== Wait =====
462
+ async _wait(action) {
463
+ const details = {
464
+ wait_type: null,
465
+ timeout: null,
466
+ selector: null,
467
+ };
468
+ if ("timeout" in action) {
469
+ const ms = Math.min(Math.max(0, Number(action.timeout) || 0), 30_000);
470
+ details.wait_type = "timeout";
471
+ details.timeout = ms;
472
+ await new Promise((resolve) => setTimeout(resolve, ms));
473
+ return { success: true, message: `Waited ${ms}ms`, details };
474
+ }
475
+ if ("selector" in action) {
476
+ const sel = action.selector;
477
+ details.wait_type = "selector";
478
+ details.selector = sel;
479
+ await this.page.waitForSelector(sel, { timeout: this.defaultTimeout });
480
+ return { success: true, message: `Waited for ${sel}`, details };
481
+ }
482
+ return { success: false, message: "Error: wait requires timeout/selector", details };
483
+ }
484
+ // ===== Extract =====
485
+ async _extract(action) {
486
+ const ref = action.ref;
487
+ if (!ref) {
488
+ return { success: false, message: "Error: extract requires ref", details: { error: "missing_ref" } };
489
+ }
490
+ const target = `[aria-ref='${escapeRef(ref)}']`;
491
+ const details = { ref, target };
492
+ try {
493
+ await this.page.waitForSelector(target, { timeout: this.defaultTimeout });
494
+ const txt = await this.page.textContent(target);
495
+ details.extracted_text = txt;
496
+ details.text_length = txt ? txt.length : 0;
497
+ return {
498
+ success: true,
499
+ message: `Extracted: ${txt ? txt.slice(0, 100) : "None"}`,
500
+ details,
501
+ };
502
+ }
503
+ catch (e) {
504
+ details.error = String(e);
505
+ return { success: false, message: `Error: extract failed: ${e}`, details };
506
+ }
507
+ }
508
+ // ===== Scroll =====
509
+ async _scroll(action) {
510
+ const direction = action.direction || "down";
511
+ const amount = action.amount !== undefined ? Number(action.amount) : 300;
512
+ const details = {
513
+ direction,
514
+ requested_amount: amount,
515
+ actual_amount: null,
516
+ scroll_offset: null,
517
+ };
518
+ if (direction !== "up" && direction !== "down") {
519
+ return { success: false, message: "Error: direction must be 'up' or 'down'", details };
520
+ }
521
+ let amountInt;
522
+ try {
523
+ amountInt = Math.round(amount);
524
+ amountInt = Math.max(-this.maxScrollAmount, Math.min(this.maxScrollAmount, amountInt));
525
+ details.actual_amount = amountInt;
526
+ }
527
+ catch {
528
+ return { success: false, message: "Error: amount must be a valid number", details };
529
+ }
530
+ const scrollOffset = direction === "down" ? amountInt : -amountInt;
531
+ details.scroll_offset = scrollOffset;
532
+ await this.page.evaluate((offset) => window.scrollBy(0, offset), scrollOffset);
533
+ await new Promise((resolve) => setTimeout(resolve, 500));
534
+ return { success: true, message: `Scrolled ${direction} by ${Math.abs(amountInt)}px`, details };
535
+ }
536
+ // ===== Enter =====
537
+ async _enter(_action) {
538
+ const details = { action_type: "enter", target: "focused_element" };
539
+ await this.page.keyboard.press("Enter");
540
+ return { success: true, message: "Pressed Enter on focused element", details };
541
+ }
542
+ // ===== Mouse Control =====
543
+ async _mouseControl(action) {
544
+ const control = action.control || "click";
545
+ const xCoord = Number(action.x) || 0;
546
+ const yCoord = Number(action.y) || 0;
547
+ const details = {
548
+ action_type: "mouse_control",
549
+ target: `coordinates : (${xCoord}, ${yCoord})`,
550
+ };
551
+ try {
552
+ if (!this._validCoordinates(xCoord, yCoord)) {
553
+ throw new Error(`Invalid coordinates, outside viewport bounds: (${xCoord}, ${yCoord})`);
554
+ }
555
+ if (control === "click") {
556
+ const found = await this.page.evaluate(([x, y]) => {
557
+ const el = document.elementFromPoint(x, y);
558
+ if (!el)
559
+ return false;
560
+ const opts = { bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0 };
561
+ el.dispatchEvent(new MouseEvent("mousedown", opts));
562
+ el.dispatchEvent(new MouseEvent("mouseup", opts));
563
+ el.dispatchEvent(new MouseEvent("click", opts));
564
+ if (el.tagName === "INPUT" ||
565
+ el.tagName === "TEXTAREA" ||
566
+ el.isContentEditable)
567
+ el.focus();
568
+ return true;
569
+ }, [xCoord, yCoord]);
570
+ if (!found)
571
+ throw new Error(`No element found at coordinates (${xCoord}, ${yCoord})`);
572
+ return { success: true, message: "Action 'click' performed on the target", details };
573
+ }
574
+ else if (control === "right_click") {
575
+ const found = await this.page.evaluate(([x, y]) => {
576
+ const el = document.elementFromPoint(x, y);
577
+ if (!el)
578
+ return false;
579
+ const opts = { bubbles: true, cancelable: true, clientX: x, clientY: y, button: 2 };
580
+ el.dispatchEvent(new MouseEvent("mousedown", opts));
581
+ el.dispatchEvent(new MouseEvent("mouseup", opts));
582
+ el.dispatchEvent(new MouseEvent("contextmenu", opts));
583
+ return true;
584
+ }, [xCoord, yCoord]);
585
+ if (!found)
586
+ throw new Error(`No element found at coordinates (${xCoord}, ${yCoord})`);
587
+ return { success: true, message: "Action 'right_click' performed on the target", details };
588
+ }
589
+ else if (control === "dblclick") {
590
+ const found = await this.page.evaluate(([x, y]) => {
591
+ const el = document.elementFromPoint(x, y);
592
+ if (!el)
593
+ return false;
594
+ const opts = { bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0 };
595
+ el.dispatchEvent(new MouseEvent("mousedown", opts));
596
+ el.dispatchEvent(new MouseEvent("mouseup", opts));
597
+ el.dispatchEvent(new MouseEvent("click", opts));
598
+ el.dispatchEvent(new MouseEvent("mousedown", opts));
599
+ el.dispatchEvent(new MouseEvent("mouseup", opts));
600
+ el.dispatchEvent(new MouseEvent("click", opts));
601
+ el.dispatchEvent(new MouseEvent("dblclick", opts));
602
+ if (el.tagName === "INPUT" ||
603
+ el.tagName === "TEXTAREA" ||
604
+ el.isContentEditable)
605
+ el.focus();
606
+ return true;
607
+ }, [xCoord, yCoord]);
608
+ if (!found)
609
+ throw new Error(`No element found at coordinates (${xCoord}, ${yCoord})`);
610
+ return { success: true, message: "Action 'dblclick' performed on the target", details };
611
+ }
612
+ else {
613
+ return { success: false, message: `Error: Invalid control action '${control}'`, details };
614
+ }
615
+ }
616
+ catch (e) {
617
+ return { success: false, message: `Action failed: ${e}`, details };
618
+ }
619
+ }
620
+ // ===== Mouse Drag =====
621
+ async _mouseDrag(action) {
622
+ const fromRef = action.from_ref;
623
+ const toRef = action.to_ref;
624
+ if (!fromRef || !toRef) {
625
+ return {
626
+ success: false,
627
+ message: "Error: mouse_drag requires from_ref and to_ref",
628
+ details: { error: "missing_refs" },
629
+ };
630
+ }
631
+ const fromSelector = `[aria-ref='${escapeRef(fromRef)}']`;
632
+ const toSelector = `[aria-ref='${escapeRef(toRef)}']`;
633
+ const details = {
634
+ action_type: "mouse_drag",
635
+ from_ref: fromRef,
636
+ to_ref: toRef,
637
+ from_selector: fromSelector,
638
+ to_selector: toSelector,
639
+ };
640
+ try {
641
+ const fromElement = this.page.locator(fromSelector);
642
+ if ((await fromElement.count()) === 0) {
643
+ throw new Error(`Source element with ref '${fromRef}' not found`);
644
+ }
645
+ const toElement = this.page.locator(toSelector);
646
+ if ((await toElement.count()) === 0) {
647
+ throw new Error(`Target element with ref '${toRef}' not found`);
648
+ }
649
+ const fromBox = await fromElement.first().boundingBox();
650
+ const toBox = await toElement.first().boundingBox();
651
+ if (!fromBox)
652
+ throw new Error(`Could not get bounding box for source element with ref '${fromRef}'`);
653
+ if (!toBox)
654
+ throw new Error(`Could not get bounding box for target element with ref '${toRef}'`);
655
+ const fromX = fromBox.x + fromBox.width / 2;
656
+ const fromY = fromBox.y + fromBox.height / 2;
657
+ const toX = toBox.x + toBox.width / 2;
658
+ const toY = toBox.y + toBox.height / 2;
659
+ details.from_coordinates = { x: fromX, y: fromY };
660
+ details.to_coordinates = { x: toX, y: toY };
661
+ const dragSuccess = await this.page.evaluate(([fX, fY, tX, tY]) => {
662
+ const fromEl = document.elementFromPoint(fX, fY);
663
+ const toEl = document.elementFromPoint(tX, tY);
664
+ if (!fromEl)
665
+ return false;
666
+ const dt = new DataTransfer();
667
+ const common = { bubbles: true, cancelable: true, button: 0, dataTransfer: dt };
668
+ fromEl.dispatchEvent(new MouseEvent("mousedown", { ...common, clientX: fX, clientY: fY }));
669
+ fromEl.dispatchEvent(new DragEvent("dragstart", { ...common, clientX: fX, clientY: fY }));
670
+ const moveTarget = toEl || fromEl;
671
+ moveTarget.dispatchEvent(new DragEvent("dragover", { ...common, clientX: tX, clientY: tY }));
672
+ moveTarget.dispatchEvent(new DragEvent("drop", { ...common, clientX: tX, clientY: tY }));
673
+ moveTarget.dispatchEvent(new MouseEvent("mouseup", { ...common, clientX: tX, clientY: tY }));
674
+ fromEl.dispatchEvent(new DragEvent("dragend", { ...common, clientX: tX, clientY: tY }));
675
+ return true;
676
+ }, [fromX, fromY, toX, toY]);
677
+ if (!dragSuccess) {
678
+ throw new Error(`No element found at source coordinates (${fromX}, ${fromY})`);
679
+ }
680
+ return {
681
+ success: true,
682
+ message: `Dragged from element [ref=${fromRef}] to element [ref=${toRef}]`,
683
+ details,
684
+ };
685
+ }
686
+ catch (e) {
687
+ return { success: false, message: `Action failed: ${e}`, details };
688
+ }
689
+ }
690
+ // ===== Press Key =====
691
+ async _pressKey(action) {
692
+ const keys = action.keys;
693
+ if (!keys || keys.length === 0) {
694
+ return {
695
+ success: false,
696
+ message: "Error: No keys specified",
697
+ details: { action_type: "press_key", keys: "" },
698
+ };
699
+ }
700
+ const combinedKeys = keys.join("+");
701
+ const details = { action_type: "press_key", keys: combinedKeys };
702
+ try {
703
+ await this.page.keyboard.press(combinedKeys);
704
+ return { success: true, message: "Pressed keys in the browser", details };
705
+ }
706
+ catch (e) {
707
+ return { success: false, message: `Action failed: ${e}`, details };
708
+ }
709
+ }
710
+ // ===== Navigate =====
711
+ async _navigate(action) {
712
+ const url = action.url;
713
+ if (!url) {
714
+ return { success: false, message: "Error: navigate requires url", details: { error: "missing_url" } };
715
+ }
716
+ const details = { action_type: "navigate", url };
717
+ try {
718
+ await this.page.goto(url, { timeout: BrowserConfig.navigationTimeout });
719
+ await this.page.waitForLoadState("domcontentloaded");
720
+ return { success: true, message: `Navigated to ${url}`, details };
721
+ }
722
+ catch (e) {
723
+ details.error = String(e);
724
+ return { success: false, message: `Navigation failed: ${e}`, details };
725
+ }
726
+ }
727
+ // ===== Back / Forward =====
728
+ async _back(_action) {
729
+ const details = { action_type: "back" };
730
+ try {
731
+ await this.page.goBack({ timeout: BrowserConfig.navigationTimeout });
732
+ return { success: true, message: "Navigated back", details };
733
+ }
734
+ catch (e) {
735
+ details.error = String(e);
736
+ return { success: false, message: `Back navigation failed: ${e}`, details };
737
+ }
738
+ }
739
+ async _forward(_action) {
740
+ const details = { action_type: "forward" };
741
+ try {
742
+ await this.page.goForward({ timeout: BrowserConfig.navigationTimeout });
743
+ return { success: true, message: "Navigated forward", details };
744
+ }
745
+ catch (e) {
746
+ details.error = String(e);
747
+ return { success: false, message: `Forward navigation failed: ${e}`, details };
748
+ }
749
+ }
750
+ // ===== Hover (new — Pinchtab compat) =====
751
+ async _hover(action) {
752
+ const ref = action.ref;
753
+ if (!ref) {
754
+ return { success: false, message: "Error: hover requires ref", details: { error: "missing_ref" } };
755
+ }
756
+ const target = `[aria-ref='${escapeRef(ref)}']`;
757
+ const details = { ref, target, action_type: "hover" };
758
+ try {
759
+ const count = await this.page.locator(target).count();
760
+ if (count === 0) {
761
+ details.error = "element_not_found";
762
+ return { success: false, message: "Error: Hover failed, element not found", details };
763
+ }
764
+ await this.page.locator(target).first().hover({ timeout: this.defaultTimeout });
765
+ return { success: true, message: `Hovered over ${target}`, details };
766
+ }
767
+ catch (e) {
768
+ details.error = String(e);
769
+ return { success: false, message: `Hover failed: ${e}`, details };
770
+ }
771
+ }
772
+ // ===== Focus (new — Pinchtab compat) =====
773
+ async _focus(action) {
774
+ const ref = action.ref;
775
+ if (!ref) {
776
+ return { success: false, message: "Error: focus requires ref", details: { error: "missing_ref" } };
777
+ }
778
+ const target = `[aria-ref='${escapeRef(ref)}']`;
779
+ const details = { ref, target, action_type: "focus" };
780
+ try {
781
+ const count = await this.page.locator(target).count();
782
+ if (count === 0) {
783
+ details.error = "element_not_found";
784
+ return { success: false, message: "Error: Focus failed, element not found", details };
785
+ }
786
+ await this.page.locator(target).first().focus({ timeout: this.defaultTimeout });
787
+ return { success: true, message: `Focused on ${target}`, details };
788
+ }
789
+ catch (e) {
790
+ details.error = String(e);
791
+ return { success: false, message: `Focus failed: ${e}`, details };
792
+ }
793
+ }
794
+ // ===== Utilities =====
795
+ _normalizeClickIntent(intent) {
796
+ const raw = typeof intent === "string" ? intent.trim().toLowerCase() : "";
797
+ if (raw === "same_tab" || raw === "new_tab" || raw === "ui") {
798
+ return raw;
799
+ }
800
+ return "auto";
801
+ }
802
+ _normalizeClickExpectedEffect(effect) {
803
+ const raw = typeof effect === "string" ? effect.trim().toLowerCase() : "";
804
+ if (raw === "focus" || raw === "ui_change" || raw === "navigation" || raw === "calendar_change") {
805
+ return raw;
806
+ }
807
+ return "any";
808
+ }
809
+ _deriveClickIntent(requestedIntent, isLikelyLink, baseUrl, expectedHref) {
810
+ if (requestedIntent === "auto") {
811
+ return "auto";
812
+ }
813
+ if (requestedIntent === "ui") {
814
+ return "ui";
815
+ }
816
+ if (!isLikelyLink && !expectedHref) {
817
+ return "ui";
818
+ }
819
+ const hrefKind = this._classifyExpectedHref(baseUrl, expectedHref);
820
+ if (hrefKind === "custom") {
821
+ return "ui";
822
+ }
823
+ return requestedIntent;
824
+ }
825
+ _resolveExpectedHref(explicitHref, diag, baseUrl) {
826
+ const rawHref = explicitHref || diag?.href || diag?.closestHref || diag?.descendantHref || null;
827
+ return this._resolveAbsoluteUrl(rawHref, baseUrl);
828
+ }
829
+ _resolveAbsoluteUrl(url, baseUrl) {
830
+ if (!url)
831
+ return null;
832
+ try {
833
+ return new URL(url, baseUrl).href;
834
+ }
835
+ catch {
836
+ return null;
837
+ }
838
+ }
839
+ _classifyExpectedHref(baseUrl, expectedHref) {
840
+ if (!expectedHref)
841
+ return "none";
842
+ const expected = this._tryParseUrl(expectedHref, baseUrl);
843
+ if (!expected)
844
+ return "custom";
845
+ if (!["http:", "https:"].includes(expected.protocol)) {
846
+ return "custom";
847
+ }
848
+ const base = this._tryParseUrl(baseUrl, expected.href);
849
+ if (base &&
850
+ base.origin === expected.origin &&
851
+ this._normalizePath(base.pathname) === this._normalizePath(expected.pathname) &&
852
+ base.search === expected.search &&
853
+ !!expected.hash) {
854
+ return "hash";
855
+ }
856
+ return "standard";
857
+ }
858
+ _tryParseUrl(url, baseUrl) {
859
+ if (!url)
860
+ return null;
861
+ try {
862
+ return new URL(url, baseUrl);
863
+ }
864
+ catch {
865
+ return null;
866
+ }
867
+ }
868
+ _normalizePath(pathname) {
869
+ const normalized = pathname.replace(/\/+$/, "");
870
+ return normalized || "/";
871
+ }
872
+ _urlMatchesExpected(currentUrl, expectedUrl) {
873
+ const current = this._tryParseUrl(currentUrl, expectedUrl);
874
+ const expected = this._tryParseUrl(expectedUrl, currentUrl);
875
+ if (!current || !expected)
876
+ return currentUrl === expectedUrl;
877
+ if (current.origin !== expected.origin)
878
+ return false;
879
+ if (this._normalizePath(current.pathname) !== this._normalizePath(expected.pathname))
880
+ return false;
881
+ if (expected.hash && current.hash !== expected.hash)
882
+ return false;
883
+ const expectedKeys = Array.from(new Set(expected.searchParams.keys()));
884
+ for (const key of expectedKeys) {
885
+ const currentValues = current.searchParams.getAll(key).sort();
886
+ const expectedValues = expected.searchParams.getAll(key).sort();
887
+ if (!this._arraysEqual(currentValues, expectedValues))
888
+ return false;
889
+ }
890
+ return true;
891
+ }
892
+ _arraysEqual(a, b) {
893
+ if (a.length !== b.length)
894
+ return false;
895
+ return a.every((value, index) => value === b[index]);
896
+ }
897
+ async _recoverClickTarget(expectedHref, expectedText) {
898
+ if (!expectedHref && !expectedText) {
899
+ return { locator: null, selector: null, reason: "element_not_found" };
900
+ }
901
+ const marker = {
902
+ attr: "data-arise-click-recovery",
903
+ value: `recovery-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
904
+ };
905
+ const recovered = await this.page.evaluate(({ expectedHref, expectedText, markerAttr, markerValue }) => {
906
+ const normalizeText = (value) => (value || "").replace(/\s+/g, " ").trim().toLowerCase();
907
+ const normalizeHref = (value) => {
908
+ if (!value)
909
+ return null;
910
+ try {
911
+ return new URL(value, window.location.href).href;
912
+ }
913
+ catch {
914
+ return null;
915
+ }
916
+ };
917
+ const expectedHrefAbs = normalizeHref(expectedHref);
918
+ const expectedTextNorm = normalizeText(expectedText);
919
+ const candidates = [];
920
+ const elements = Array.from(document.querySelectorAll("a[href], [role='link']"));
921
+ for (const element of elements) {
922
+ const anchorLike = element.tagName.toLowerCase() === "a"
923
+ ? element
924
+ : element.querySelector("a[href]");
925
+ const rawHref = element.getAttribute("href") ||
926
+ anchorLike?.getAttribute("href") ||
927
+ element.closest("a[href]")?.getAttribute("href") ||
928
+ null;
929
+ const href = normalizeHref(rawHref);
930
+ const text = (element.innerText || element.textContent || "")
931
+ .replace(/\s+/g, " ")
932
+ .trim()
933
+ .slice(0, 200);
934
+ const textNorm = normalizeText(text);
935
+ const hrefMatch = !!expectedHrefAbs && href === expectedHrefAbs;
936
+ const textMatch = !!expectedTextNorm &&
937
+ !!textNorm &&
938
+ (textNorm === expectedTextNorm ||
939
+ textNorm.includes(expectedTextNorm) ||
940
+ expectedTextNorm.includes(textNorm));
941
+ let score = 0;
942
+ if (hrefMatch)
943
+ score += 4;
944
+ if (textMatch)
945
+ score += 2;
946
+ if (element.tagName.toLowerCase() === "a")
947
+ score += 1;
948
+ const matches = (expectedHrefAbs && hrefMatch) ||
949
+ (!expectedHrefAbs && expectedTextNorm && textMatch);
950
+ if (matches && score > 0) {
951
+ candidates.push({ element, score, href, text });
952
+ }
953
+ }
954
+ if (candidates.length === 0) {
955
+ return {
956
+ status: "not_found",
957
+ reason: "stale_ref_unresolved",
958
+ candidateCount: 0,
959
+ };
960
+ }
961
+ candidates.sort((a, b) => b.score - a.score);
962
+ const bestScore = candidates[0].score;
963
+ const topCandidates = candidates.filter((candidate) => candidate.score === bestScore);
964
+ if (topCandidates.length !== 1) {
965
+ return {
966
+ status: "ambiguous",
967
+ reason: "stale_ref_ambiguous",
968
+ candidateCount: topCandidates.length,
969
+ };
970
+ }
971
+ const winner = topCandidates[0];
972
+ winner.element.setAttribute(markerAttr, markerValue);
973
+ return {
974
+ status: "ok",
975
+ reason: "recovered",
976
+ candidateCount: 1,
977
+ matchedHref: winner.href,
978
+ matchedText: winner.text,
979
+ };
980
+ }, {
981
+ expectedHref,
982
+ expectedText,
983
+ markerAttr: marker.attr,
984
+ markerValue: marker.value,
985
+ });
986
+ if (recovered.status !== "ok") {
987
+ return {
988
+ locator: null,
989
+ selector: null,
990
+ reason: recovered.reason,
991
+ candidateCount: recovered.candidateCount,
992
+ };
993
+ }
994
+ const selector = `[${marker.attr}='${marker.value}']`;
995
+ const locator = this.page.locator(selector).first();
996
+ if ((await locator.count()) === 0) {
997
+ return {
998
+ locator: null,
999
+ selector: null,
1000
+ reason: "stale_ref_unresolved",
1001
+ };
1002
+ }
1003
+ return {
1004
+ locator,
1005
+ selector,
1006
+ reason: recovered.reason,
1007
+ candidateCount: recovered.candidateCount,
1008
+ matchedHref: recovered.matchedHref ?? null,
1009
+ matchedText: recovered.matchedText ?? null,
1010
+ marker,
1011
+ };
1012
+ }
1013
+ async _clearRecoveryMarker(marker) {
1014
+ try {
1015
+ await this.page.evaluate(({ attr, value }) => {
1016
+ document
1017
+ .querySelectorAll(`[${attr}='${value}']`)
1018
+ .forEach((element) => element.removeAttribute(attr));
1019
+ }, marker);
1020
+ }
1021
+ catch {
1022
+ // Ignore cleanup failures after navigation.
1023
+ }
1024
+ }
1025
+ async _executeAutoClick(clickTarget, target, details, isLikelyLink, beforeObservation, expectedEffect) {
1026
+ let clickPerformed = false;
1027
+ if (isLikelyLink && this.session) {
1028
+ try {
1029
+ const context = this.page.context();
1030
+ const t0 = performance.now();
1031
+ const tabsBefore = new Set((await this.session.getTabInfo()).map((t) => t.tab_id));
1032
+ const newPagePromise = context.waitForEvent("page", {
1033
+ timeout: this.shortTimeout,
1034
+ });
1035
+ newPagePromise.catch(() => { });
1036
+ await clickTarget.click({ modifiers: ["ControlOrMeta"] });
1037
+ logger.debug("Click executed, waiting for page event...");
1038
+ const newPage = await newPagePromise;
1039
+ const elapsedMs = Math.round(performance.now() - t0);
1040
+ await newPage.waitForLoadState("domcontentloaded");
1041
+ const tabsAfter = await this.session.getTabInfo();
1042
+ const newTabInfo = tabsAfter.find((t) => !tabsBefore.has(t.tab_id) && t.url !== "(closed)" && t.url !== "(error)");
1043
+ const newTabId = newTabInfo?.tab_id;
1044
+ if (newTabId) {
1045
+ await this.session.switchToTab(newTabId);
1046
+ }
1047
+ details.click_method = "ctrl_click_new_tab";
1048
+ details.new_tab_created = true;
1049
+ details.new_tab_index = newTabId;
1050
+ details.ctrl_click_elapsed_ms = elapsedMs;
1051
+ return {
1052
+ success: true,
1053
+ message: `Clicked element, opened in new tab ${newTabId}`,
1054
+ details,
1055
+ };
1056
+ }
1057
+ catch (e) {
1058
+ const msg = e instanceof Error ? e.message : String(e);
1059
+ if (msg.includes("Timeout") || msg.includes("timeout")) {
1060
+ details.click_method = "ctrl_click_same_tab";
1061
+ clickPerformed = true;
1062
+ }
1063
+ else {
1064
+ details.strategies_tried.push({
1065
+ selector: target,
1066
+ method: "ctrl_click",
1067
+ error: msg,
1068
+ });
1069
+ }
1070
+ }
1071
+ }
1072
+ if (!clickPerformed) {
1073
+ try {
1074
+ await clickTarget.click({ timeout: this.defaultTimeout });
1075
+ details.click_method = "click";
1076
+ clickPerformed = true;
1077
+ }
1078
+ catch (e) {
1079
+ details.strategies_tried.push({
1080
+ selector: target,
1081
+ method: "click",
1082
+ error: String(e),
1083
+ });
1084
+ }
1085
+ }
1086
+ if (!clickPerformed) {
1087
+ logger.debug("Falling back to force click...");
1088
+ try {
1089
+ await clickTarget.click({ force: true, timeout: this.defaultTimeout });
1090
+ details.click_method = "force_click";
1091
+ clickPerformed = true;
1092
+ }
1093
+ catch (e) {
1094
+ logger.debug({ err: e }, "Force click also failed");
1095
+ details.click_method = "all_failed";
1096
+ details.error = String(e);
1097
+ return {
1098
+ success: false,
1099
+ message: `Error: All click strategies failed for ${target}`,
1100
+ details,
1101
+ };
1102
+ }
1103
+ }
1104
+ await this.page.waitForTimeout(180);
1105
+ const afterObservation = await this._captureClickObservation(clickTarget);
1106
+ details.after_observation = afterObservation;
1107
+ const observationDelta = this._getClickObservationDelta(beforeObservation, afterObservation);
1108
+ details.observation_delta = observationDelta;
1109
+ details.expected_effect = expectedEffect;
1110
+ const effectEvaluation = this._evaluateObservedClickEffect(expectedEffect, observationDelta);
1111
+ details.effect_satisfied = effectEvaluation.satisfied;
1112
+ if (effectEvaluation.satisfied && !observationDelta.changed) {
1113
+ details.no_state_change = true;
1114
+ details.warning = "no_state_change";
1115
+ return {
1116
+ success: true,
1117
+ message: `Clicked element (${details.click_method}): ${target} (no observable page state change)`,
1118
+ details,
1119
+ };
1120
+ }
1121
+ if (effectEvaluation.satisfied && observationDelta.focusOnly) {
1122
+ details.focus_only_change = true;
1123
+ details.warning = "focus_only_change";
1124
+ return {
1125
+ success: true,
1126
+ message: this._formatFocusOnlyClickMessage(String(details.click_method || "click"), target),
1127
+ details,
1128
+ };
1129
+ }
1130
+ if (!effectEvaluation.satisfied) {
1131
+ details.error = effectEvaluation.errorCode;
1132
+ details.expected_effect_failure = effectEvaluation.errorCode;
1133
+ return {
1134
+ success: false,
1135
+ message: effectEvaluation.message || `Error: Click did not satisfy expected effect for ${target}`,
1136
+ details,
1137
+ };
1138
+ }
1139
+ return { success: true, message: `Clicked element (${details.click_method}): ${target}`, details };
1140
+ }
1141
+ async _attemptCtrlClickNewTab(clickTarget, target, details, expectedHref) {
1142
+ if (!this.session)
1143
+ return null;
1144
+ try {
1145
+ const context = this.page.context();
1146
+ const t0 = performance.now();
1147
+ const tabsBefore = new Set((await this.session.getTabInfo()).map((t) => t.tab_id));
1148
+ const newPagePromise = context.waitForEvent("page", { timeout: this.shortTimeout });
1149
+ newPagePromise.catch(() => { });
1150
+ await clickTarget.click({ modifiers: ["ControlOrMeta"] });
1151
+ const newPage = await newPagePromise;
1152
+ const elapsedMs = Math.round(performance.now() - t0);
1153
+ await newPage.waitForLoadState("domcontentloaded").catch(() => { });
1154
+ const tabsAfter = await this.session.getTabInfo();
1155
+ const newTabInfo = tabsAfter.find((tab) => !tabsBefore.has(tab.tab_id) && tab.url !== "(closed)" && tab.url !== "(error)");
1156
+ const newTabId = newTabInfo?.tab_id ?? null;
1157
+ const newTabUrl = newPage.url();
1158
+ const matchedExpected = expectedHref ? this._urlMatchesExpected(newTabUrl, expectedHref) : true;
1159
+ details.click_method = "ctrl_click_new_tab";
1160
+ details.new_tab_created = true;
1161
+ details.new_tab_index = newTabId;
1162
+ details.ctrl_click_elapsed_ms = elapsedMs;
1163
+ details.link_verification = {
1164
+ mode: "new_tab",
1165
+ current_url: newTabUrl,
1166
+ expected_url: expectedHref,
1167
+ matched_expected: matchedExpected,
1168
+ };
1169
+ if (!matchedExpected) {
1170
+ details.error = "unexpected_new_tab_url";
1171
+ return {
1172
+ success: false,
1173
+ message: `Error: Click opened unexpected new tab for ${target}`,
1174
+ details,
1175
+ };
1176
+ }
1177
+ if (newTabId) {
1178
+ await this.session.switchToTab(newTabId);
1179
+ }
1180
+ return {
1181
+ success: true,
1182
+ message: `Clicked element, opened in new tab ${newTabId ?? "(untracked)"}`,
1183
+ details,
1184
+ };
1185
+ }
1186
+ catch (e) {
1187
+ const msg = e instanceof Error ? e.message : String(e);
1188
+ details.strategies_tried.push({
1189
+ selector: target,
1190
+ method: "ctrl_click",
1191
+ error: msg,
1192
+ });
1193
+ if (msg.includes("Timeout") || msg.includes("timeout")) {
1194
+ details.ctrl_click_timeout = true;
1195
+ }
1196
+ return null;
1197
+ }
1198
+ }
1199
+ async _executeSameTabLinkClick(clickTarget, target, details, expectedHref, beforeUrl) {
1200
+ const firstAttempt = await this._clickAndVerifyLinkAttempt(clickTarget, "click", expectedHref, beforeUrl);
1201
+ details.click_method = "click";
1202
+ if (firstAttempt.clickError) {
1203
+ details.strategies_tried.push({
1204
+ selector: target,
1205
+ method: "click",
1206
+ error: firstAttempt.clickError,
1207
+ });
1208
+ }
1209
+ if (firstAttempt.verification) {
1210
+ details.link_verification = firstAttempt.verification;
1211
+ }
1212
+ if (firstAttempt.ok && firstAttempt.verification) {
1213
+ if (firstAttempt.verification.newTabId) {
1214
+ details.new_tab_created = true;
1215
+ details.new_tab_index = firstAttempt.verification.newTabId;
1216
+ }
1217
+ return {
1218
+ success: true,
1219
+ message: `Clicked element (click): ${target}`,
1220
+ details,
1221
+ };
1222
+ }
1223
+ const canRetryWithForce = !!firstAttempt.clickError ||
1224
+ (!!firstAttempt.verification &&
1225
+ firstAttempt.verification.currentUrl === beforeUrl &&
1226
+ !firstAttempt.verification.newTabUrl &&
1227
+ !firstAttempt.verification.downloadTriggered);
1228
+ if (!canRetryWithForce) {
1229
+ details.error = firstAttempt.verification?.reason || firstAttempt.clickError || "link_click_no_navigation";
1230
+ return {
1231
+ success: false,
1232
+ message: `Error: Link click did not reach expected destination for ${target}`,
1233
+ details,
1234
+ };
1235
+ }
1236
+ logger.debug("Link click did not navigate, retrying with force click...");
1237
+ const secondAttempt = await this._clickAndVerifyLinkAttempt(clickTarget, "force_click", expectedHref, beforeUrl);
1238
+ details.click_method = "force_click";
1239
+ if (secondAttempt.clickError) {
1240
+ details.strategies_tried.push({
1241
+ selector: target,
1242
+ method: "force_click",
1243
+ error: secondAttempt.clickError,
1244
+ });
1245
+ }
1246
+ if (secondAttempt.verification) {
1247
+ details.link_verification = secondAttempt.verification;
1248
+ }
1249
+ if (secondAttempt.ok && secondAttempt.verification) {
1250
+ if (secondAttempt.verification.newTabId) {
1251
+ details.new_tab_created = true;
1252
+ details.new_tab_index = secondAttempt.verification.newTabId;
1253
+ }
1254
+ return {
1255
+ success: true,
1256
+ message: `Clicked element (force_click): ${target}`,
1257
+ details,
1258
+ };
1259
+ }
1260
+ details.error = secondAttempt.verification?.reason || secondAttempt.clickError || "link_click_no_navigation";
1261
+ return {
1262
+ success: false,
1263
+ message: `Error: Link click did not reach expected destination for ${target}`,
1264
+ details,
1265
+ };
1266
+ }
1267
+ async _clickAndVerifyLinkAttempt(clickTarget, method, expectedHref, beforeUrl) {
1268
+ const verificationTimeout = Math.min(this.shortTimeout, 2500);
1269
+ let observedNewPage = null;
1270
+ let downloadTriggered = false;
1271
+ const tabsBefore = this.session
1272
+ ? new Set((await this.session.getTabInfo()).map((tab) => tab.tab_id))
1273
+ : null;
1274
+ if (this.session) {
1275
+ void this.page.context()
1276
+ .waitForEvent("page", { timeout: verificationTimeout })
1277
+ .then(async (page) => {
1278
+ observedNewPage = page;
1279
+ await page.waitForLoadState("domcontentloaded").catch(() => { });
1280
+ })
1281
+ .catch(() => { });
1282
+ }
1283
+ void this.page
1284
+ .waitForEvent("download", { timeout: verificationTimeout })
1285
+ .then(() => {
1286
+ downloadTriggered = true;
1287
+ })
1288
+ .catch(() => { });
1289
+ try {
1290
+ if (method === "force_click") {
1291
+ await clickTarget.click({ force: true, timeout: this.defaultTimeout });
1292
+ }
1293
+ else {
1294
+ await clickTarget.click({ timeout: this.defaultTimeout });
1295
+ }
1296
+ }
1297
+ catch (e) {
1298
+ return { ok: false, clickError: String(e) };
1299
+ }
1300
+ const verification = await this._waitForLinkVerification({
1301
+ beforeUrl,
1302
+ expectedHref,
1303
+ timeoutMs: verificationTimeout,
1304
+ getObservedNewPage: () => observedNewPage,
1305
+ didDownload: () => downloadTriggered,
1306
+ tabsBefore,
1307
+ });
1308
+ return { ok: verification.ok, verification };
1309
+ }
1310
+ async _waitForLinkVerification(options) {
1311
+ const expectedUrl = options.expectedHref
1312
+ ? this._resolveAbsoluteUrl(options.expectedHref, options.beforeUrl)
1313
+ : null;
1314
+ const hrefKind = this._classifyExpectedHref(options.beforeUrl, expectedUrl);
1315
+ const deadline = Date.now() + options.timeoutMs;
1316
+ while (Date.now() < deadline) {
1317
+ const newPage = options.getObservedNewPage();
1318
+ if (newPage) {
1319
+ const newTabUrl = newPage.url();
1320
+ const matchedExpected = expectedUrl ? this._urlMatchesExpected(newTabUrl, expectedUrl) : true;
1321
+ let newTabId = null;
1322
+ if (this.session && options.tabsBefore) {
1323
+ const tabsAfter = await this.session.getTabInfo();
1324
+ const newTab = tabsAfter.find((tab) => !options.tabsBefore.has(tab.tab_id) && tab.url !== "(closed)" && tab.url !== "(error)");
1325
+ newTabId = newTab?.tab_id ?? null;
1326
+ if (newTabId) {
1327
+ await this.session.switchToTab(newTabId);
1328
+ }
1329
+ }
1330
+ if (matchedExpected) {
1331
+ return {
1332
+ ok: true,
1333
+ mode: "new_tab",
1334
+ currentUrl: newTabUrl,
1335
+ expectedUrl,
1336
+ matchedExpected,
1337
+ newTabId,
1338
+ newTabUrl,
1339
+ downloadTriggered: options.didDownload(),
1340
+ };
1341
+ }
1342
+ return {
1343
+ ok: false,
1344
+ mode: "new_tab",
1345
+ reason: "unexpected_new_tab_url",
1346
+ currentUrl: newTabUrl,
1347
+ expectedUrl,
1348
+ matchedExpected,
1349
+ newTabId,
1350
+ newTabUrl,
1351
+ downloadTriggered: options.didDownload(),
1352
+ };
1353
+ }
1354
+ if (options.didDownload()) {
1355
+ return {
1356
+ ok: true,
1357
+ mode: "download",
1358
+ currentUrl: this.page.url(),
1359
+ expectedUrl,
1360
+ matchedExpected: true,
1361
+ downloadTriggered: true,
1362
+ };
1363
+ }
1364
+ const currentUrl = this.page.url();
1365
+ const matchedExpected = expectedUrl
1366
+ ? this._urlMatchesExpected(currentUrl, expectedUrl)
1367
+ : currentUrl !== options.beforeUrl;
1368
+ if (matchedExpected) {
1369
+ return {
1370
+ ok: true,
1371
+ mode: hrefKind === "hash" ? "hash" : "same_tab",
1372
+ currentUrl,
1373
+ expectedUrl,
1374
+ matchedExpected,
1375
+ downloadTriggered: false,
1376
+ };
1377
+ }
1378
+ await this.page.waitForTimeout(100);
1379
+ }
1380
+ const currentUrl = this.page.url();
1381
+ return {
1382
+ ok: false,
1383
+ mode: "unknown",
1384
+ reason: expectedUrl ? "expected_navigation_not_observed" : "navigation_not_observed",
1385
+ currentUrl,
1386
+ expectedUrl,
1387
+ matchedExpected: expectedUrl ? this._urlMatchesExpected(currentUrl, expectedUrl) : currentUrl !== options.beforeUrl,
1388
+ downloadTriggered: options.didDownload(),
1389
+ };
1390
+ }
1391
+ async _executeUiClick(clickTarget, target, details, beforeObservation, expectedEffect) {
1392
+ if (expectedEffect === "calendar_change") {
1393
+ return await this._executeCalendarClick(clickTarget, target, details, beforeObservation);
1394
+ }
1395
+ const attempts = [
1396
+ { method: "click", force: false },
1397
+ { method: "force_click", force: true },
1398
+ ];
1399
+ for (const attempt of attempts) {
1400
+ try {
1401
+ await clickTarget.click({
1402
+ timeout: this.defaultTimeout,
1403
+ ...(attempt.force ? { force: true } : {}),
1404
+ });
1405
+ }
1406
+ catch (e) {
1407
+ details.strategies_tried.push({
1408
+ selector: target,
1409
+ method: attempt.method,
1410
+ error: String(e),
1411
+ });
1412
+ continue;
1413
+ }
1414
+ details.click_method = attempt.method;
1415
+ await this.page.waitForTimeout(180);
1416
+ const afterObservation = await this._captureClickObservation(clickTarget);
1417
+ details.after_observation = afterObservation;
1418
+ const observationDelta = this._getClickObservationDelta(beforeObservation, afterObservation);
1419
+ details.observation_delta = observationDelta;
1420
+ details.expected_effect = expectedEffect;
1421
+ if (expectedEffect === "any") {
1422
+ details.effect_satisfied = observationDelta.meaningful || observationDelta.focusOnly;
1423
+ if (observationDelta.meaningful) {
1424
+ return {
1425
+ success: true,
1426
+ message: `Clicked element (${attempt.method}): ${target}`,
1427
+ details,
1428
+ };
1429
+ }
1430
+ if (observationDelta.focusOnly) {
1431
+ details.focus_only_change = true;
1432
+ details.warning = "focus_only_change";
1433
+ return {
1434
+ success: true,
1435
+ message: this._formatFocusOnlyClickMessage(attempt.method, target),
1436
+ details,
1437
+ };
1438
+ }
1439
+ continue;
1440
+ }
1441
+ const effectEvaluation = this._evaluateObservedClickEffect(expectedEffect, observationDelta);
1442
+ details.effect_satisfied = effectEvaluation.satisfied;
1443
+ if (effectEvaluation.satisfied) {
1444
+ if (observationDelta.focusOnly) {
1445
+ details.focus_only_change = true;
1446
+ details.warning = "focus_only_change";
1447
+ return {
1448
+ success: true,
1449
+ message: this._formatFocusOnlyClickMessage(attempt.method, target),
1450
+ details,
1451
+ };
1452
+ }
1453
+ return {
1454
+ success: true,
1455
+ message: `Clicked element (${attempt.method}): ${target}`,
1456
+ details,
1457
+ };
1458
+ }
1459
+ }
1460
+ details.no_state_change = true;
1461
+ details.error =
1462
+ expectedEffect === "ui_change"
1463
+ ? "ui_effect_not_observed"
1464
+ : expectedEffect === "navigation"
1465
+ ? "navigation_effect_not_observed"
1466
+ : expectedEffect === "focus"
1467
+ ? "focus_effect_not_observed"
1468
+ : "no_meaningful_state_change";
1469
+ return {
1470
+ success: false,
1471
+ message: expectedEffect === "any"
1472
+ ? `Error: Click had no meaningful effect for ${target}`
1473
+ : `Error: Click did not satisfy expected effect '${expectedEffect}' for ${target}`,
1474
+ details,
1475
+ };
1476
+ }
1477
+ async _executeCalendarClick(clickTarget, target, details, beforeObservation) {
1478
+ const attempts = [
1479
+ { method: "click", force: false },
1480
+ { method: "force_click", force: true },
1481
+ ];
1482
+ const targetKind = this._classifyCalendarTarget(beforeObservation.targetState);
1483
+ const beforeCalendarObservation = await this._captureCalendarObservation();
1484
+ details.before_calendar_observation = beforeCalendarObservation;
1485
+ details.calendar_target_kind = targetKind;
1486
+ for (const attempt of attempts) {
1487
+ try {
1488
+ await clickTarget.click({
1489
+ timeout: this.defaultTimeout,
1490
+ ...(attempt.force ? { force: true } : {}),
1491
+ });
1492
+ }
1493
+ catch (e) {
1494
+ details.strategies_tried.push({
1495
+ selector: target,
1496
+ method: attempt.method,
1497
+ error: String(e),
1498
+ });
1499
+ continue;
1500
+ }
1501
+ details.click_method = attempt.method;
1502
+ let afterCalendarObservation = beforeCalendarObservation;
1503
+ for (let i = 0; i < 6; i += 1) {
1504
+ await this.page.waitForTimeout(i === 0 ? 120 : 100);
1505
+ afterCalendarObservation = await this._captureCalendarObservation();
1506
+ const evaluation = this._evaluateCalendarObservationChange(targetKind, beforeCalendarObservation, afterCalendarObservation);
1507
+ details.after_calendar_observation = afterCalendarObservation;
1508
+ details.calendar_effect_satisfied = evaluation.satisfied;
1509
+ details.calendar_effect_reason = evaluation.reason;
1510
+ if (evaluation.satisfied) {
1511
+ if (evaluation.focusOnly) {
1512
+ details.focus_only_change = true;
1513
+ details.warning = "focus_only_change";
1514
+ return {
1515
+ success: true,
1516
+ message: this._formatFocusOnlyClickMessage(attempt.method, target),
1517
+ details,
1518
+ };
1519
+ }
1520
+ return {
1521
+ success: true,
1522
+ message: `Clicked element (${attempt.method}): ${target}`,
1523
+ details,
1524
+ };
1525
+ }
1526
+ }
1527
+ }
1528
+ details.no_state_change = true;
1529
+ details.error = "calendar_effect_not_observed";
1530
+ details.expected_effect_failure = "calendar_effect_not_observed";
1531
+ return {
1532
+ success: false,
1533
+ message: `Error: Click did not satisfy expected effect 'calendar_change' for ${target}`,
1534
+ details,
1535
+ };
1536
+ }
1537
+ _classifyCalendarTarget(state) {
1538
+ if (!state)
1539
+ return "other";
1540
+ const combined = `${state.ariaLabel || ""} ${state.text || ""} ${state.value || ""}`
1541
+ .replace(/\s+/g, " ")
1542
+ .trim()
1543
+ .toLowerCase();
1544
+ if (/^next\b/.test(combined))
1545
+ return "next";
1546
+ if (/^previous\b/.test(combined))
1547
+ return "previous";
1548
+ if (/^done\b/.test(combined))
1549
+ return "done";
1550
+ if ((state.role === "textbox" || state.role === "combobox" || !!state.value) &&
1551
+ /\b(departure|return)\b/.test(combined)) {
1552
+ return "field";
1553
+ }
1554
+ const text = (state.text || "").trim();
1555
+ if (/^\d{1,2}(?:\b|[^0-9])/.test(text) || /^\d{1,2}(?:\b|[^0-9])/.test(combined)) {
1556
+ return "date";
1557
+ }
1558
+ return "other";
1559
+ }
1560
+ async _captureCalendarObservation() {
1561
+ const script = String.raw `(() => {
1562
+ function normalize(value) {
1563
+ return String(value ?? "").replace(/\s+/g, " ").trim();
1564
+ }
1565
+
1566
+ function isVisible(el) {
1567
+ if (!el) return false;
1568
+ const style = window.getComputedStyle(el);
1569
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") {
1570
+ return false;
1571
+ }
1572
+ const rect = el.getBoundingClientRect();
1573
+ return (
1574
+ rect.width > 0 &&
1575
+ rect.height > 0 &&
1576
+ rect.bottom >= 0 &&
1577
+ rect.right >= 0 &&
1578
+ rect.top <= window.innerHeight &&
1579
+ rect.left <= window.innerWidth
1580
+ );
1581
+ }
1582
+
1583
+ function readText(el) {
1584
+ if (!el) return "";
1585
+ return normalize(el.innerText || el.textContent || "");
1586
+ }
1587
+
1588
+ function readAttr(el, name) {
1589
+ if (!el || typeof el.getAttribute !== "function") return "";
1590
+ return normalize(el.getAttribute(name) || "");
1591
+ }
1592
+
1593
+ function collectVisibleDialogs() {
1594
+ const dialogs = [];
1595
+ const nodes = document.querySelectorAll("[role='dialog'], [aria-modal='true'], dialog");
1596
+ for (let i = 0; i < nodes.length; i += 1) {
1597
+ const el = nodes[i];
1598
+ if (isVisible(el)) dialogs.push(el);
1599
+ }
1600
+ return dialogs;
1601
+ }
1602
+
1603
+ function detectActiveField(active) {
1604
+ const activeText = normalize(
1605
+ readAttr(active, "aria-label") + " " + readAttr(active, "placeholder") + " " + readText(active),
1606
+ ).toLowerCase();
1607
+ if (/\bdeparture\b/.test(activeText)) return "departure";
1608
+ if (/\breturn\b/.test(activeText)) return "return";
1609
+ return "";
1610
+ }
1611
+
1612
+ const visibleDialogs = collectVisibleDialogs();
1613
+ const root = visibleDialogs.length > 0 ? visibleDialogs[0] : document.body;
1614
+ const active = document.activeElement instanceof Element ? document.activeElement : null;
1615
+ const activeField = detectActiveField(active);
1616
+ const monthRe =
1617
+ /\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{4}\b/i;
1618
+ const visibleMonths = [];
1619
+ const seenMonths = new Set();
1620
+ const monthNodes = root.querySelectorAll("h1, h2, h3, h4, h5, h6, [role='heading'], div, span");
1621
+ for (let i = 0; i < monthNodes.length; i += 1) {
1622
+ const el = monthNodes[i];
1623
+ if (!isVisible(el)) continue;
1624
+ const match = readText(el).match(monthRe);
1625
+ if (!match) continue;
1626
+ const label = normalize(match[0]);
1627
+ if (!label || seenMonths.has(label)) continue;
1628
+ seenMonths.add(label);
1629
+ visibleMonths.push(label);
1630
+ if (visibleMonths.length >= 6) break;
1631
+ }
1632
+
1633
+ const formValues = [];
1634
+ const seenValues = new Set();
1635
+ const formNodes = document.querySelectorAll(
1636
+ "input, textarea, select, [role='combobox'], [role='textbox']",
1637
+ );
1638
+ for (let i = 0; i < formNodes.length; i += 1) {
1639
+ const el = formNodes[i];
1640
+ if (!isVisible(el)) continue;
1641
+ const label = normalize(
1642
+ readAttr(el, "aria-label") + " " + readAttr(el, "placeholder") + " " + readAttr(el, "name"),
1643
+ );
1644
+ if (!/\b(departure|return)\b/.test(label.toLowerCase())) continue;
1645
+ const value = normalize((typeof el.value === "string" ? el.value : "") || readText(el));
1646
+ const token = (label || el.tagName.toLowerCase()) + "=" + value;
1647
+ if (!value || seenValues.has(token)) continue;
1648
+ seenValues.add(token);
1649
+ formValues.push(token);
1650
+ }
1651
+
1652
+ const selectedDates = [];
1653
+ const seenSelected = new Set();
1654
+ const selectedNodes = root.querySelectorAll(
1655
+ "[aria-selected='true'], [aria-pressed='true'], [aria-current='date'], [aria-current='true']",
1656
+ );
1657
+ for (let i = 0; i < selectedNodes.length; i += 1) {
1658
+ const el = selectedNodes[i];
1659
+ if (!isVisible(el)) continue;
1660
+ const token = normalize(readAttr(el, "aria-label") + " " + readText(el));
1661
+ if (!token || seenSelected.has(token)) continue;
1662
+ seenSelected.add(token);
1663
+ selectedDates.push(token);
1664
+ if (selectedDates.length >= 10) break;
1665
+ }
1666
+
1667
+ let summary = "";
1668
+ const controlNodes = root.querySelectorAll("button, [role='button']");
1669
+ for (let i = 0; i < controlNodes.length; i += 1) {
1670
+ const el = controlNodes[i];
1671
+ if (!isVisible(el)) continue;
1672
+ const label = normalize(readAttr(el, "aria-label") || readText(el));
1673
+ if (/^done\b/i.test(label)) {
1674
+ summary = label.slice(0, 220);
1675
+ break;
1676
+ }
1677
+ }
1678
+
1679
+ const activeTag = active?.tagName?.toLowerCase() || "";
1680
+ const activeId = active?.id || "";
1681
+ const activeRole = readAttr(active, "role");
1682
+ const activeRef = readAttr(active, "aria-ref");
1683
+ return {
1684
+ open: visibleDialogs.length > 0,
1685
+ dialogCount: visibleDialogs.length,
1686
+ activeElement: activeTag + "#" + activeId + "[role=" + activeRole + "][ref=" + activeRef + "]",
1687
+ activeField,
1688
+ visibleMonths,
1689
+ formValues,
1690
+ selectedDates,
1691
+ summary,
1692
+ };
1693
+ })()`;
1694
+ return await this.page.evaluate(script);
1695
+ }
1696
+ _evaluateCalendarObservationChange(targetKind, before, after) {
1697
+ const activeChanged = before.activeElement !== after.activeElement || before.activeField !== after.activeField;
1698
+ const monthsChanged = JSON.stringify(before.visibleMonths) !== JSON.stringify(after.visibleMonths);
1699
+ const valuesChanged = JSON.stringify(before.formValues) !== JSON.stringify(after.formValues);
1700
+ const selectedChanged = JSON.stringify(before.selectedDates) !== JSON.stringify(after.selectedDates);
1701
+ const summaryChanged = before.summary !== after.summary;
1702
+ const dialogChanged = before.dialogCount !== after.dialogCount || before.open !== after.open;
1703
+ if (targetKind === "field") {
1704
+ if (valuesChanged || selectedChanged || summaryChanged) {
1705
+ return { satisfied: true, focusOnly: false, reason: "calendar_field_state_changed" };
1706
+ }
1707
+ if (activeChanged) {
1708
+ return { satisfied: true, focusOnly: true, reason: "calendar_field_focus_changed" };
1709
+ }
1710
+ return { satisfied: false, focusOnly: false, reason: "calendar_field_no_change" };
1711
+ }
1712
+ if (targetKind === "next" || targetKind === "previous") {
1713
+ if (monthsChanged || valuesChanged || summaryChanged || selectedChanged) {
1714
+ return { satisfied: true, focusOnly: false, reason: "calendar_navigation_changed" };
1715
+ }
1716
+ return { satisfied: false, focusOnly: false, reason: "calendar_navigation_no_change" };
1717
+ }
1718
+ if (targetKind === "done") {
1719
+ if (dialogChanged || !after.open) {
1720
+ return { satisfied: true, focusOnly: false, reason: "calendar_dialog_closed" };
1721
+ }
1722
+ return { satisfied: false, focusOnly: false, reason: "calendar_done_no_change" };
1723
+ }
1724
+ if (targetKind === "date") {
1725
+ if (valuesChanged || selectedChanged || summaryChanged || activeChanged) {
1726
+ return { satisfied: true, focusOnly: false, reason: "calendar_date_changed" };
1727
+ }
1728
+ return { satisfied: false, focusOnly: false, reason: "calendar_date_no_change" };
1729
+ }
1730
+ if (monthsChanged || valuesChanged || selectedChanged || summaryChanged || dialogChanged) {
1731
+ return { satisfied: true, focusOnly: false, reason: "calendar_state_changed" };
1732
+ }
1733
+ if (activeChanged) {
1734
+ return { satisfied: true, focusOnly: true, reason: "calendar_focus_changed" };
1735
+ }
1736
+ return { satisfied: false, focusOnly: false, reason: "calendar_no_change" };
1737
+ }
1738
+ _didMeaningfulUiChange(before, after) {
1739
+ return this._getClickObservationDelta(before, after).meaningful;
1740
+ }
1741
+ _isLikelyLink(diag) {
1742
+ if (!diag)
1743
+ return false;
1744
+ const tag = (diag.tag || "").toLowerCase();
1745
+ const role = (diag.role || "").toLowerCase();
1746
+ return (tag === "a" ||
1747
+ role === "link" ||
1748
+ !!diag.href ||
1749
+ !!diag.closestHref ||
1750
+ !!diag.descendantHref);
1751
+ }
1752
+ async _captureClickObservation(target) {
1753
+ const pageUrl = this.page.url();
1754
+ const pageState = await this.page.evaluate(() => {
1755
+ const active = document.activeElement;
1756
+ const activeTag = active?.tagName?.toLowerCase() || "";
1757
+ const activeId = active?.id || "";
1758
+ const activeRole = active?.getAttribute?.("role") || "";
1759
+ const activeRef = active?.getAttribute?.("aria-ref") || "";
1760
+ const activeSig = `${activeTag}#${activeId}[role=${activeRole}][ref=${activeRef}]`;
1761
+ const dialogLabels = [];
1762
+ const seenDialogLabels = new Set();
1763
+ for (const element of Array.from(document.querySelectorAll("[role='dialog'], [aria-modal='true']"))) {
1764
+ const htmlElement = element;
1765
+ const style = window.getComputedStyle(htmlElement);
1766
+ if (style.display === "none" || style.visibility === "hidden")
1767
+ continue;
1768
+ const rect = htmlElement.getBoundingClientRect();
1769
+ if (rect.width <= 0 || rect.height <= 0)
1770
+ continue;
1771
+ if (rect.bottom < 0 || rect.right < 0 || rect.top > window.innerHeight || rect.left > window.innerWidth)
1772
+ continue;
1773
+ let label = (element.getAttribute("aria-label") || "").replace(/\s+/g, " ").trim().slice(0, 180);
1774
+ if (!label) {
1775
+ const labelledBy = (element.getAttribute("aria-labelledby") || "").trim();
1776
+ if (labelledBy) {
1777
+ const parts = [];
1778
+ for (const id of labelledBy.split(/\s+/)) {
1779
+ const labelledNode = document.getElementById(id);
1780
+ const text = (labelledNode?.innerText || labelledNode?.textContent || "").replace(/\s+/g, " ").trim().slice(0, 180);
1781
+ if (text)
1782
+ parts.push(text);
1783
+ }
1784
+ label = parts.join(" ").replace(/\s+/g, " ").trim().slice(0, 180);
1785
+ }
1786
+ }
1787
+ if (!label) {
1788
+ const heading = element.querySelector("h1, h2, h3, h4, h5, h6, [role='heading']");
1789
+ label = (heading?.innerText || heading?.textContent || "").replace(/\s+/g, " ").trim().slice(0, 180);
1790
+ }
1791
+ if (!label || seenDialogLabels.has(label))
1792
+ continue;
1793
+ seenDialogLabels.add(label);
1794
+ dialogLabels.push(label);
1795
+ if (dialogLabels.length >= 6)
1796
+ break;
1797
+ }
1798
+ const headingTexts = [];
1799
+ const seenHeadingTexts = new Set();
1800
+ for (const element of Array.from(document.querySelectorAll("h1, h2, h3, h4, h5, h6, [role='heading']"))) {
1801
+ const htmlElement = element;
1802
+ const style = window.getComputedStyle(htmlElement);
1803
+ if (style.display === "none" || style.visibility === "hidden")
1804
+ continue;
1805
+ const rect = htmlElement.getBoundingClientRect();
1806
+ if (rect.width <= 0 || rect.height <= 0)
1807
+ continue;
1808
+ if (rect.bottom < 0 || rect.right < 0 || rect.top > window.innerHeight || rect.left > window.innerWidth)
1809
+ continue;
1810
+ const text = (htmlElement.innerText || element.textContent || "").replace(/\s+/g, " ").trim().slice(0, 180);
1811
+ if (!text || seenHeadingTexts.has(text))
1812
+ continue;
1813
+ seenHeadingTexts.add(text);
1814
+ headingTexts.push(text);
1815
+ if (headingTexts.length >= 8)
1816
+ break;
1817
+ }
1818
+ const formValues = [];
1819
+ const seenFormValues = new Set();
1820
+ for (const element of Array.from(document.querySelectorAll("input, textarea, select, [role='combobox'], [role='textbox']"))) {
1821
+ const htmlElement = element;
1822
+ const style = window.getComputedStyle(htmlElement);
1823
+ if (style.display === "none" || style.visibility === "hidden")
1824
+ continue;
1825
+ const rect = htmlElement.getBoundingClientRect();
1826
+ if (rect.width <= 0 || rect.height <= 0)
1827
+ continue;
1828
+ if (rect.bottom < 0 || rect.right < 0 || rect.top > window.innerHeight || rect.left > window.innerWidth)
1829
+ continue;
1830
+ const inputLike = element;
1831
+ let label = (element.getAttribute("aria-label") || "").replace(/\s+/g, " ").trim().slice(0, 180);
1832
+ if (!label) {
1833
+ const labelledBy = (element.getAttribute("aria-labelledby") || "").trim();
1834
+ if (labelledBy) {
1835
+ const parts = [];
1836
+ for (const id of labelledBy.split(/\s+/)) {
1837
+ const labelledNode = document.getElementById(id);
1838
+ const text = (labelledNode?.innerText || labelledNode?.textContent || "").replace(/\s+/g, " ").trim().slice(0, 180);
1839
+ if (text)
1840
+ parts.push(text);
1841
+ }
1842
+ label = parts.join(" ").replace(/\s+/g, " ").trim().slice(0, 180);
1843
+ }
1844
+ }
1845
+ if (!label) {
1846
+ const labelNode = element.closest("label");
1847
+ label = (labelNode?.innerText || labelNode?.textContent || "").replace(/\s+/g, " ").trim().slice(0, 180);
1848
+ }
1849
+ if (!label) {
1850
+ label = (element.getAttribute("name")
1851
+ || element.getAttribute("placeholder")
1852
+ || htmlElement.id
1853
+ || element.tagName.toLowerCase()).replace(/\s+/g, " ").trim().slice(0, 180);
1854
+ }
1855
+ const value = (inputLike.value || htmlElement.innerText || element.textContent || "").replace(/\s+/g, " ").trim().slice(0, 180);
1856
+ if (!value)
1857
+ continue;
1858
+ const token = `${label}=${value}`.replace(/\s+/g, " ").trim().slice(0, 220);
1859
+ if (!token || seenFormValues.has(token))
1860
+ continue;
1861
+ seenFormValues.add(token);
1862
+ formValues.push(token);
1863
+ if (formValues.length >= 8)
1864
+ break;
1865
+ }
1866
+ const selectedStateTokens = [];
1867
+ const seenSelectedStateTokens = new Set();
1868
+ for (const element of Array.from(document.querySelectorAll("[aria-selected='true'], [aria-pressed='true'], [aria-current], [aria-checked='true']"))) {
1869
+ const htmlElement = element;
1870
+ const style = window.getComputedStyle(htmlElement);
1871
+ if (style.display === "none" || style.visibility === "hidden")
1872
+ continue;
1873
+ const rect = htmlElement.getBoundingClientRect();
1874
+ if (rect.width <= 0 || rect.height <= 0)
1875
+ continue;
1876
+ if (rect.bottom < 0 || rect.right < 0 || rect.top > window.innerHeight || rect.left > window.innerWidth)
1877
+ continue;
1878
+ let label = (element.getAttribute("aria-label") || "").replace(/\s+/g, " ").trim().slice(0, 180);
1879
+ if (!label) {
1880
+ const labelledBy = (element.getAttribute("aria-labelledby") || "").trim();
1881
+ if (labelledBy) {
1882
+ const parts = [];
1883
+ for (const id of labelledBy.split(/\s+/)) {
1884
+ const labelledNode = document.getElementById(id);
1885
+ const text = (labelledNode?.innerText || labelledNode?.textContent || "").replace(/\s+/g, " ").trim().slice(0, 180);
1886
+ if (text)
1887
+ parts.push(text);
1888
+ }
1889
+ label = parts.join(" ").replace(/\s+/g, " ").trim().slice(0, 180);
1890
+ }
1891
+ }
1892
+ if (!label) {
1893
+ label = (htmlElement.innerText || element.textContent || "").replace(/\s+/g, " ").trim().slice(0, 180);
1894
+ }
1895
+ const states = [
1896
+ element.getAttribute("aria-selected") === "true" ? "selected" : "",
1897
+ element.getAttribute("aria-pressed") === "true" ? "pressed" : "",
1898
+ element.getAttribute("aria-current") ? `current=${(element.getAttribute("aria-current") || "").replace(/\s+/g, " ").trim().slice(0, 60)}` : "",
1899
+ element.getAttribute("aria-checked") === "true" ? "checked" : "",
1900
+ ].filter(Boolean).join(",");
1901
+ const token = `${(element.getAttribute("role") || "").replace(/\s+/g, " ").trim().slice(0, 60)}:${label}:${states}`
1902
+ .replace(/\s+/g, " ")
1903
+ .trim()
1904
+ .slice(0, 220);
1905
+ if (!token || seenSelectedStateTokens.has(token))
1906
+ continue;
1907
+ seenSelectedStateTokens.add(token);
1908
+ selectedStateTokens.push(token);
1909
+ if (selectedStateTokens.length >= 10)
1910
+ break;
1911
+ }
1912
+ return {
1913
+ activeElement: activeSig,
1914
+ dialogCount: document.querySelectorAll("[role='dialog'], [aria-modal='true']").length,
1915
+ listboxCount: document.querySelectorAll("[role='listbox']").length,
1916
+ menuCount: document.querySelectorAll("[role='menu'], [role='menuitem'], [role='menuitemradio']").length,
1917
+ expandedCount: document.querySelectorAll("[aria-expanded='true']").length,
1918
+ dialogLabels,
1919
+ headingTexts,
1920
+ formValues,
1921
+ selectedStateTokens,
1922
+ };
1923
+ });
1924
+ let targetPresent = false;
1925
+ let targetState = null;
1926
+ try {
1927
+ const count = await target.count();
1928
+ targetPresent = count > 0;
1929
+ if (targetPresent) {
1930
+ targetState = await target.evaluate((node) => {
1931
+ const el = node;
1932
+ const inputLike = node;
1933
+ const labelledBy = (node.getAttribute("aria-labelledby") || "")
1934
+ .split(/\s+/)
1935
+ .map((id) => document.getElementById(id))
1936
+ .filter((item) => !!item)
1937
+ .map((item) => (item.innerText || item.textContent || "").replace(/\s+/g, " ").trim())
1938
+ .filter(Boolean)
1939
+ .join(" ");
1940
+ return {
1941
+ role: node.getAttribute("role") || "",
1942
+ ariaLabel: node.getAttribute("aria-label")
1943
+ || labelledBy
1944
+ || "",
1945
+ ariaExpanded: node.getAttribute("aria-expanded"),
1946
+ ariaSelected: node.getAttribute("aria-selected"),
1947
+ ariaPressed: node.getAttribute("aria-pressed"),
1948
+ ariaCurrent: node.getAttribute("aria-current"),
1949
+ value: inputLike.value || "",
1950
+ checked: typeof inputLike.checked === "boolean" ? inputLike.checked : null,
1951
+ text: (el.innerText || node.textContent || "").replace(/\s+/g, " ").trim().slice(0, 200),
1952
+ disabled: inputLike.disabled || node.getAttribute("aria-disabled") === "true",
1953
+ className: (el.className || "").toString().slice(0, 200),
1954
+ };
1955
+ });
1956
+ }
1957
+ }
1958
+ catch {
1959
+ targetPresent = false;
1960
+ targetState = null;
1961
+ }
1962
+ return {
1963
+ pageUrl,
1964
+ activeElement: pageState.activeElement,
1965
+ targetPresent,
1966
+ targetState,
1967
+ dialogCount: pageState.dialogCount,
1968
+ listboxCount: pageState.listboxCount,
1969
+ menuCount: pageState.menuCount,
1970
+ expandedCount: pageState.expandedCount,
1971
+ dialogLabels: pageState.dialogLabels,
1972
+ headingTexts: pageState.headingTexts,
1973
+ formValues: pageState.formValues,
1974
+ selectedStateTokens: pageState.selectedStateTokens,
1975
+ };
1976
+ }
1977
+ _didClickObservationChange(before, after) {
1978
+ return this._getClickObservationDelta(before, after).changed;
1979
+ }
1980
+ _toMeaningfulClickTargetState(state) {
1981
+ if (!state)
1982
+ return null;
1983
+ return {
1984
+ role: state.role,
1985
+ ariaLabel: state.ariaLabel,
1986
+ ariaExpanded: state.ariaExpanded,
1987
+ ariaSelected: state.ariaSelected,
1988
+ ariaPressed: state.ariaPressed,
1989
+ ariaCurrent: state.ariaCurrent,
1990
+ value: state.value,
1991
+ checked: state.checked,
1992
+ text: state.text,
1993
+ disabled: state.disabled,
1994
+ };
1995
+ }
1996
+ _toSemanticPageVector(observation) {
1997
+ return {
1998
+ pageUrl: observation.pageUrl,
1999
+ targetPresent: observation.targetPresent,
2000
+ dialogCount: observation.dialogCount,
2001
+ listboxCount: observation.listboxCount,
2002
+ menuCount: observation.menuCount,
2003
+ expandedCount: observation.expandedCount,
2004
+ dialogLabels: observation.dialogLabels,
2005
+ headingTexts: observation.headingTexts,
2006
+ formValues: observation.formValues,
2007
+ selectedStateTokens: observation.selectedStateTokens,
2008
+ };
2009
+ }
2010
+ _evaluateObservedClickEffect(expectedEffect, observationDelta) {
2011
+ if (expectedEffect === "any") {
2012
+ return { satisfied: true };
2013
+ }
2014
+ if (expectedEffect === "focus") {
2015
+ return observationDelta.changed
2016
+ ? { satisfied: true }
2017
+ : {
2018
+ satisfied: false,
2019
+ errorCode: "focus_effect_not_observed",
2020
+ message: "Error: Click did not move focus or change page state",
2021
+ };
2022
+ }
2023
+ if (expectedEffect === "navigation") {
2024
+ return observationDelta.urlChanged
2025
+ ? { satisfied: true }
2026
+ : {
2027
+ satisfied: false,
2028
+ errorCode: "navigation_effect_not_observed",
2029
+ message: "Error: Click did not trigger navigation",
2030
+ };
2031
+ }
2032
+ return observationDelta.meaningful
2033
+ ? { satisfied: true }
2034
+ : {
2035
+ satisfied: false,
2036
+ errorCode: "ui_effect_not_observed",
2037
+ message: "Error: Click did not cause a meaningful UI change",
2038
+ };
2039
+ }
2040
+ _getClickObservationDelta(before, after) {
2041
+ const activeChanged = before.activeElement !== after.activeElement;
2042
+ const urlChanged = before.pageUrl !== after.pageUrl;
2043
+ const targetChanged = JSON.stringify(this._toMeaningfulClickTargetState(before.targetState))
2044
+ !== JSON.stringify(this._toMeaningfulClickTargetState(after.targetState));
2045
+ const pageSemanticChanged = JSON.stringify(this._toSemanticPageVector(before))
2046
+ !== JSON.stringify(this._toSemanticPageVector(after));
2047
+ const meaningful = urlChanged || targetChanged || pageSemanticChanged;
2048
+ return {
2049
+ changed: meaningful || activeChanged,
2050
+ meaningful,
2051
+ focusOnly: !meaningful && activeChanged,
2052
+ urlChanged,
2053
+ targetChanged,
2054
+ pageSemanticChanged,
2055
+ };
2056
+ }
2057
+ _formatFocusOnlyClickMessage(method, target) {
2058
+ return `Clicked element (${method}): ${target} (focus changed only; no dialog/listbox/menu/url change observed)`;
2059
+ }
2060
+ async _readTypeIdentity(control, timeoutMs = this.shortTimeout) {
2061
+ const handle = await control.elementHandle({ timeout: timeoutMs });
2062
+ if (!handle) {
2063
+ throw new Error("type_target_not_found");
2064
+ }
2065
+ try {
2066
+ return await handle.evaluate((node) => {
2067
+ const inputLike = node;
2068
+ return {
2069
+ tagName: node.tagName.toLowerCase(),
2070
+ role: node.getAttribute("role") || "",
2071
+ type: inputLike.type || "",
2072
+ id: inputLike.id || "",
2073
+ name: inputLike.name || "",
2074
+ ariaLabel: node.getAttribute("aria-label") || "",
2075
+ placeholder: inputLike.placeholder || "",
2076
+ };
2077
+ });
2078
+ }
2079
+ finally {
2080
+ await handle.dispose().catch(() => undefined);
2081
+ }
2082
+ }
2083
+ async _readTypeState(control, timeoutMs = this.shortTimeout) {
2084
+ const handle = await control.elementHandle({ timeout: timeoutMs });
2085
+ if (!handle) {
2086
+ throw new Error("type_target_not_found");
2087
+ }
2088
+ try {
2089
+ return await handle.evaluate((node) => {
2090
+ const el = node;
2091
+ const inputLike = node;
2092
+ return {
2093
+ tagName: node.tagName.toLowerCase(),
2094
+ role: node.getAttribute("role") || "",
2095
+ type: inputLike.type || "",
2096
+ value: inputLike.value || "",
2097
+ text: (el.innerText || node.textContent || "").replace(/\s+/g, " ").trim().slice(0, 200),
2098
+ ariaAutocomplete: node.getAttribute("aria-autocomplete"),
2099
+ ariaExpanded: node.getAttribute("aria-expanded"),
2100
+ placeholder: inputLike.placeholder || "",
2101
+ active: document.activeElement === node,
2102
+ listboxCount: document.querySelectorAll("[role='listbox']").length,
2103
+ optionCount: document.querySelectorAll("[role='option']").length,
2104
+ };
2105
+ });
2106
+ }
2107
+ finally {
2108
+ await handle.dispose().catch(() => undefined);
2109
+ }
2110
+ }
2111
+ async _readTypeStateWithFallback(control, identity) {
2112
+ try {
2113
+ return { state: await this._readTypeState(control), mode: "ref" };
2114
+ }
2115
+ catch (exc) {
2116
+ const directError = String(exc);
2117
+ const reacquired = await this._reacquireTypeControl(identity);
2118
+ if (!reacquired) {
2119
+ return { state: null, mode: "ref_missing", error: directError };
2120
+ }
2121
+ try {
2122
+ return {
2123
+ state: await this._readTypeState(reacquired),
2124
+ mode: "identity_reacquired",
2125
+ };
2126
+ }
2127
+ catch (inner) {
2128
+ return {
2129
+ state: null,
2130
+ mode: "identity_reacquired_failed",
2131
+ error: `${directError} | ${String(inner)}`,
2132
+ };
2133
+ }
2134
+ }
2135
+ }
2136
+ async _reacquireTypeControl(identity) {
2137
+ for (const locator of this._buildTypeIdentityLocators(identity)) {
2138
+ try {
2139
+ await locator.elementHandle({ timeout: this.shortTimeout });
2140
+ return locator;
2141
+ }
2142
+ catch {
2143
+ // Try the next candidate.
2144
+ }
2145
+ }
2146
+ return null;
2147
+ }
2148
+ _buildTypeIdentityLocators(identity) {
2149
+ const locators = [];
2150
+ const tagName = identity.tagName || "*";
2151
+ const escapedRole = identity.role ? this._escapeAttributeValue(identity.role) : "";
2152
+ const escapedType = identity.type ? this._escapeAttributeValue(identity.type) : "";
2153
+ const escapedId = identity.id ? this._escapeAttributeValue(identity.id) : "";
2154
+ const escapedName = identity.name ? this._escapeAttributeValue(identity.name) : "";
2155
+ const escapedAriaLabel = identity.ariaLabel ? this._escapeAttributeValue(identity.ariaLabel) : "";
2156
+ const escapedPlaceholder = identity.placeholder ? this._escapeAttributeValue(identity.placeholder) : "";
2157
+ const exactParts = [];
2158
+ if (identity.id)
2159
+ exactParts.push(`[id="${escapedId}"]`);
2160
+ if (identity.name)
2161
+ exactParts.push(`[name="${escapedName}"]`);
2162
+ if (identity.ariaLabel)
2163
+ exactParts.push(`[aria-label="${escapedAriaLabel}"]`);
2164
+ if (identity.placeholder && ["input", "textarea"].includes(tagName)) {
2165
+ exactParts.push(`[placeholder="${escapedPlaceholder}"]`);
2166
+ }
2167
+ if (identity.role)
2168
+ exactParts.push(`[role="${escapedRole}"]`);
2169
+ if (identity.type && tagName === "input")
2170
+ exactParts.push(`[type="${escapedType}"]`);
2171
+ if (exactParts.length > 0) {
2172
+ locators.push(this.page.locator(`${tagName}${exactParts.join("")}`).first());
2173
+ }
2174
+ if (identity.id) {
2175
+ locators.push(this.page.locator(`${tagName}[id="${escapedId}"]`).first());
2176
+ }
2177
+ if (identity.name) {
2178
+ locators.push(this.page.locator(`${tagName}[name="${escapedName}"]`).first());
2179
+ }
2180
+ if (identity.ariaLabel) {
2181
+ locators.push(this.page.locator(`${tagName}[aria-label="${escapedAriaLabel}"]`).first());
2182
+ }
2183
+ if (identity.placeholder && ["input", "textarea"].includes(tagName)) {
2184
+ locators.push(this.page.getByPlaceholder(identity.placeholder, { exact: true }).first());
2185
+ }
2186
+ if (identity.role) {
2187
+ let roleSelector = `${tagName}[role="${escapedRole}"]`;
2188
+ if (identity.type && tagName === "input") {
2189
+ roleSelector += `[type="${escapedType}"]`;
2190
+ }
2191
+ locators.push(this.page.locator(roleSelector).first());
2192
+ }
2193
+ if (["input", "textarea"].includes(tagName)) {
2194
+ locators.push(this.page.locator(`${tagName}:focus`).first());
2195
+ }
2196
+ return locators;
2197
+ }
2198
+ _shouldPreferKeyboardTyping(state) {
2199
+ const role = state.role.toLowerCase();
2200
+ const type = state.type.toLowerCase();
2201
+ return (role === "combobox" ||
2202
+ role === "searchbox" ||
2203
+ type === "search" ||
2204
+ !!state.ariaAutocomplete);
2205
+ }
2206
+ _typeStateMatchesExpected(state, expected) {
2207
+ const normalizedExpected = this._normalizeTypeToken(expected);
2208
+ const normalizedValue = this._normalizeTypeToken(state.value);
2209
+ const normalizedText = this._normalizeTypeToken(state.text);
2210
+ if (!normalizedExpected) {
2211
+ return normalizedValue.length === 0 && normalizedText.length === 0;
2212
+ }
2213
+ if (normalizedValue === normalizedExpected)
2214
+ return true;
2215
+ if (normalizedText === normalizedExpected)
2216
+ return true;
2217
+ if (this._shouldPreferKeyboardTyping(state)) {
2218
+ if (normalizedValue && normalizedExpected.includes(normalizedValue))
2219
+ return true;
2220
+ if (normalizedValue && normalizedValue.includes(normalizedExpected))
2221
+ return true;
2222
+ }
2223
+ return false;
2224
+ }
2225
+ _normalizeTypeToken(value) {
2226
+ return (value || "").replace(/\s+/g, " ").trim().toLowerCase();
2227
+ }
2228
+ _normalizeSelectToken(value) {
2229
+ return (value || "").replace(/\s+/g, " ").trim().toLowerCase();
2230
+ }
2231
+ _escapeAttributeValue(value) {
2232
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
2233
+ }
2234
+ _parseTimeToken(value) {
2235
+ const raw = this._normalizeSelectToken(value);
2236
+ if (!raw)
2237
+ return null;
2238
+ const amPmMatch = raw.match(/^(\d{1,2})(?::(\d{2}))?\s*([ap])\.?m\.?$/i);
2239
+ if (amPmMatch) {
2240
+ let hours = Number(amPmMatch[1]);
2241
+ const minutes = Number(amPmMatch[2] || "0");
2242
+ const meridiem = amPmMatch[3].toLowerCase();
2243
+ if (hours > 12 || minutes > 59)
2244
+ return null;
2245
+ if (meridiem === "p" && hours < 12)
2246
+ hours += 12;
2247
+ if (meridiem === "a" && hours === 12)
2248
+ hours = 0;
2249
+ return hours * 60 + minutes;
2250
+ }
2251
+ const colonMatch = raw.match(/^(\d{1,2}):(\d{2})$/);
2252
+ if (colonMatch) {
2253
+ const hours = Number(colonMatch[1]);
2254
+ const minutes = Number(colonMatch[2]);
2255
+ if (hours > 23 || minutes > 59)
2256
+ return null;
2257
+ return hours * 60 + minutes;
2258
+ }
2259
+ const compactMatch = raw.match(/^(\d{3,4})$/);
2260
+ if (compactMatch) {
2261
+ const compact = compactMatch[1].padStart(4, "0");
2262
+ const hours = Number(compact.slice(0, 2));
2263
+ const minutes = Number(compact.slice(2, 4));
2264
+ if (hours > 23 || minutes > 59)
2265
+ return null;
2266
+ return hours * 60 + minutes;
2267
+ }
2268
+ return null;
2269
+ }
2270
+ _parseCountToken(value) {
2271
+ const raw = this._normalizeSelectToken(value);
2272
+ if (!raw || raw.includes(":") || /(?:a\.?m\.?|p\.?m\.?)/i.test(raw))
2273
+ return null;
2274
+ const match = raw.match(/(?:^|\b)(\d{1,3})(?:\b|$)/);
2275
+ return match ? Number(match[1]) : null;
2276
+ }
2277
+ _inferSelectDomain(options) {
2278
+ if (options.length === 0)
2279
+ return "generic";
2280
+ let timeLike = 0;
2281
+ let countLike = 0;
2282
+ for (const option of options) {
2283
+ if ([option.value, option.text, option.label, option.dataValue, option.ariaLabel]
2284
+ .some((token) => this._parseTimeToken(token) !== null)) {
2285
+ timeLike += 1;
2286
+ }
2287
+ if ([option.value, option.text, option.label, option.dataValue, option.ariaLabel]
2288
+ .some((token) => this._parseCountToken(token) !== null)) {
2289
+ countLike += 1;
2290
+ }
2291
+ }
2292
+ if (timeLike >= 2 && timeLike / options.length >= 0.5)
2293
+ return "time";
2294
+ if (countLike >= 2 && countLike / options.length >= 0.5)
2295
+ return "count";
2296
+ return "generic";
2297
+ }
2298
+ _resolveSelectOption(options, requestedValue) {
2299
+ const normalizedRequested = this._normalizeSelectToken(requestedValue);
2300
+ if (!normalizedRequested || options.length === 0)
2301
+ return null;
2302
+ const exactMatches = options.filter((option) => {
2303
+ const tokens = [option.value, option.text, option.label, option.dataValue, option.ariaLabel]
2304
+ .map((token) => this._normalizeSelectToken(token))
2305
+ .filter(Boolean);
2306
+ return tokens.includes(normalizedRequested);
2307
+ });
2308
+ if (exactMatches.length === 1) {
2309
+ return { domain: "generic", reason: "exact_token_match", option: exactMatches[0] };
2310
+ }
2311
+ const domain = this._inferSelectDomain(options);
2312
+ if (domain === "time") {
2313
+ const requestedMinutes = this._parseTimeToken(requestedValue);
2314
+ if (requestedMinutes !== null) {
2315
+ const matches = options.filter((option) => [option.value, option.text, option.label, option.dataValue, option.ariaLabel]
2316
+ .map((token) => this._parseTimeToken(token))
2317
+ .some((token) => token === requestedMinutes));
2318
+ if (matches.length === 1) {
2319
+ return { domain, reason: "semantic_time_match", option: matches[0] };
2320
+ }
2321
+ }
2322
+ }
2323
+ if (domain === "count") {
2324
+ const requestedCount = this._parseCountToken(requestedValue);
2325
+ if (requestedCount !== null) {
2326
+ const matches = options.filter((option) => [option.value, option.text, option.label, option.dataValue, option.ariaLabel]
2327
+ .map((token) => this._parseCountToken(token))
2328
+ .some((token) => token === requestedCount));
2329
+ if (matches.length === 1) {
2330
+ return { domain, reason: "semantic_count_match", option: matches[0] };
2331
+ }
2332
+ }
2333
+ }
2334
+ return null;
2335
+ }
2336
+ _selectStateMatchesOption(state, resolution) {
2337
+ if (!state)
2338
+ return false;
2339
+ const stateTokens = [
2340
+ state.value,
2341
+ state.selectedText,
2342
+ state.text,
2343
+ state.ariaLabel,
2344
+ state.ariaValueText,
2345
+ ];
2346
+ const optionTokens = [
2347
+ resolution.option.value,
2348
+ resolution.option.text,
2349
+ resolution.option.label,
2350
+ resolution.option.ariaLabel,
2351
+ resolution.option.dataValue,
2352
+ ];
2353
+ if (resolution.domain === "time") {
2354
+ const expected = optionTokens
2355
+ .map((token) => this._parseTimeToken(token))
2356
+ .find((token) => token !== null);
2357
+ if (expected === undefined)
2358
+ return false;
2359
+ return stateTokens
2360
+ .map((token) => this._parseTimeToken(token))
2361
+ .some((token) => token === expected);
2362
+ }
2363
+ if (resolution.domain === "count") {
2364
+ const expected = optionTokens
2365
+ .map((token) => this._parseCountToken(token))
2366
+ .find((token) => token !== null);
2367
+ if (expected === undefined)
2368
+ return false;
2369
+ return stateTokens
2370
+ .map((token) => this._parseCountToken(token))
2371
+ .some((token) => token === expected);
2372
+ }
2373
+ const normalizedOptionTokens = optionTokens
2374
+ .map((token) => this._normalizeSelectToken(token))
2375
+ .filter(Boolean);
2376
+ const normalizedStateTokens = stateTokens
2377
+ .map((token) => this._normalizeSelectToken(token))
2378
+ .filter(Boolean);
2379
+ return normalizedStateTokens.some((token) => normalizedOptionTokens.includes(token));
2380
+ }
2381
+ _selectModelMatchesResolution(model, resolution) {
2382
+ return this._selectStateMatchesOption(model, resolution);
2383
+ }
2384
+ async _readSelectModel(control, timeoutMs) {
2385
+ try {
2386
+ const handle = await control.elementHandle({ timeout: timeoutMs });
2387
+ if (!handle)
2388
+ return null;
2389
+ try {
2390
+ return await handle.evaluate((node) => {
2391
+ const el = node;
2392
+ const inputLike = node;
2393
+ const tagName = (node.tagName || "").toLowerCase();
2394
+ const role = node.getAttribute("role") || "";
2395
+ const labelTexts = [];
2396
+ if ("labels" in inputLike && inputLike.labels) {
2397
+ for (const labelNode of Array.from(inputLike.labels)) {
2398
+ const text = (labelNode.innerText || labelNode.textContent || "").replace(/\s+/g, " ").trim();
2399
+ if (text)
2400
+ labelTexts.push(text);
2401
+ }
2402
+ }
2403
+ if (labelTexts.length === 0) {
2404
+ const parentLabel = el.closest("label");
2405
+ const text = (parentLabel?.innerText || parentLabel?.textContent || "").replace(/\s+/g, " ").trim();
2406
+ if (text)
2407
+ labelTexts.push(text);
2408
+ }
2409
+ if (labelTexts.length === 0) {
2410
+ const id = inputLike.id || node.getAttribute("id") || "";
2411
+ if (id) {
2412
+ const externalLabel = document.querySelector(`label[for="${id.replace(/"/g, '\\"')}"]`);
2413
+ const text = (externalLabel?.textContent || "").replace(/\s+/g, " ").trim();
2414
+ if (text)
2415
+ labelTexts.push(text);
2416
+ }
2417
+ }
2418
+ const selectedText = tagName === "select"
2419
+ ? Array.from((node.selectedOptions || []))
2420
+ .map((opt) => (opt.textContent || "").trim())
2421
+ .filter((txt) => !!txt)
2422
+ .join(" ")
2423
+ : "";
2424
+ const options = tagName === "select"
2425
+ ? Array.from(node.options || []).map((opt) => ({
2426
+ value: opt.value || "",
2427
+ text: (opt.textContent || "").replace(/\s+/g, " ").trim(),
2428
+ label: opt.label || "",
2429
+ disabled: opt.disabled,
2430
+ selected: opt.selected,
2431
+ role: "option",
2432
+ dataValue: opt.getAttribute("data-value") || "",
2433
+ ariaLabel: opt.getAttribute("aria-label") || "",
2434
+ }))
2435
+ : [];
2436
+ return {
2437
+ kind: tagName === "select" ? "native_select" : "custom_select",
2438
+ tagName,
2439
+ role,
2440
+ value: inputLike.value || "",
2441
+ selectedText,
2442
+ text: (el.innerText || node.textContent || "").replace(/\s+/g, " ").trim(),
2443
+ ariaLabel: node.getAttribute("aria-label") || "",
2444
+ ariaValueText: node.getAttribute("aria-valuetext") || "",
2445
+ identity: {
2446
+ tagName,
2447
+ role,
2448
+ id: inputLike.id || node.getAttribute("id") || "",
2449
+ name: inputLike.name || node.getAttribute("name") || "",
2450
+ ariaLabel: node.getAttribute("aria-label") || "",
2451
+ labelText: labelTexts.join(" ").replace(/\s+/g, " ").trim().slice(0, 200),
2452
+ dataTestId: node.getAttribute("data-testid") || node.getAttribute("data-test-id") || "",
2453
+ },
2454
+ ariaControls: node.getAttribute("aria-controls") || "",
2455
+ ariaOwns: node.getAttribute("aria-owns") || "",
2456
+ ariaExpanded: node.getAttribute("aria-expanded"),
2457
+ options,
2458
+ };
2459
+ });
2460
+ }
2461
+ finally {
2462
+ await handle.dispose().catch(() => { });
2463
+ }
2464
+ }
2465
+ catch {
2466
+ return null;
2467
+ }
2468
+ }
2469
+ async _clickFirstVisible(locator, label, debugLog) {
2470
+ let count = 0;
2471
+ try {
2472
+ count = await locator.count();
2473
+ }
2474
+ catch (e) {
2475
+ debugLog.push({ label, count: 0, error: String(e) });
2476
+ return false;
2477
+ }
2478
+ const inspectCount = Math.min(count, 8);
2479
+ for (let i = 0; i < inspectCount; i++) {
2480
+ const candidate = locator.nth(i);
2481
+ const visible = await candidate.isVisible().catch(() => false);
2482
+ if (!visible)
2483
+ continue;
2484
+ const enabled = await candidate.isEnabled().catch(() => true);
2485
+ if (!enabled)
2486
+ continue;
2487
+ try {
2488
+ await candidate.click({ timeout: this.shortTimeout });
2489
+ debugLog.push({ label, index: i, clicked: true });
2490
+ await this.page.waitForTimeout(100);
2491
+ return true;
2492
+ }
2493
+ catch (e) {
2494
+ debugLog.push({ label, index: i, clicked: false, error: String(e) });
2495
+ }
2496
+ }
2497
+ return false;
2498
+ }
2499
+ async _readScopedSelectOptions(scope) {
2500
+ try {
2501
+ const handle = await scope.elementHandle({ timeout: this.defaultTimeout });
2502
+ if (!handle)
2503
+ return [];
2504
+ try {
2505
+ return await handle.evaluate((node) => {
2506
+ const seen = new Set();
2507
+ const options = [];
2508
+ const candidates = Array.from(node.querySelectorAll("[role='option'], [role='menuitem'], [role='menuitemradio'], option, [data-value]"));
2509
+ for (const candidate of candidates) {
2510
+ const html = candidate;
2511
+ const style = window.getComputedStyle(html);
2512
+ if (style.display === "none" || style.visibility === "hidden")
2513
+ continue;
2514
+ const rect = html.getBoundingClientRect();
2515
+ if (rect.width <= 0 || rect.height <= 0)
2516
+ continue;
2517
+ const text = (html.innerText || candidate.textContent || "").replace(/\s+/g, " ").trim();
2518
+ const value = candidate.value || candidate.getAttribute("value") || "";
2519
+ const label = candidate.getAttribute("label") || "";
2520
+ const ariaLabel = candidate.getAttribute("aria-label") || "";
2521
+ const dataValue = candidate.getAttribute("data-value") || "";
2522
+ const role = candidate.getAttribute("role") || candidate.tagName.toLowerCase();
2523
+ const key = [role, value, text, label, ariaLabel, dataValue].join("||");
2524
+ if (seen.has(key))
2525
+ continue;
2526
+ seen.add(key);
2527
+ options.push({
2528
+ value,
2529
+ text,
2530
+ label,
2531
+ disabled: candidate.disabled
2532
+ || candidate.getAttribute("aria-disabled") === "true",
2533
+ selected: candidate.selected
2534
+ || candidate.getAttribute("aria-selected") === "true"
2535
+ || candidate.getAttribute("aria-checked") === "true",
2536
+ role,
2537
+ dataValue,
2538
+ ariaLabel,
2539
+ });
2540
+ }
2541
+ return options;
2542
+ });
2543
+ }
2544
+ finally {
2545
+ await handle.dispose().catch(() => { });
2546
+ }
2547
+ }
2548
+ catch {
2549
+ return [];
2550
+ }
2551
+ }
2552
+ async _reacquireSelectModel(target, identity) {
2553
+ for (const locator of this._buildSelectIdentityLocators(target, identity)) {
2554
+ const model = await this._readSelectModel(locator, this.defaultTimeout);
2555
+ if (model)
2556
+ return model;
2557
+ }
2558
+ return null;
2559
+ }
2560
+ _buildSelectIdentityLocators(target, identity) {
2561
+ const locators = [];
2562
+ locators.push(this.page.locator(target).first());
2563
+ const tagName = identity.tagName || "*";
2564
+ if (identity.id) {
2565
+ const escaped = this._escapeAttributeValue(identity.id);
2566
+ locators.push(this.page.locator(`${tagName}[id="${escaped}"]`).first());
2567
+ }
2568
+ if (identity.dataTestId) {
2569
+ const escaped = this._escapeAttributeValue(identity.dataTestId);
2570
+ locators.push(this.page.locator(`[data-testid="${escaped}"]`).first());
2571
+ locators.push(this.page.locator(`[data-test-id="${escaped}"]`).first());
2572
+ }
2573
+ if (identity.name) {
2574
+ const escaped = this._escapeAttributeValue(identity.name);
2575
+ locators.push(this.page.locator(`${tagName}[name="${escaped}"]`).first());
2576
+ }
2577
+ if (identity.ariaLabel) {
2578
+ const escaped = this._escapeAttributeValue(identity.ariaLabel);
2579
+ locators.push(this.page.locator(`${tagName}[aria-label="${escaped}"]`).first());
2580
+ }
2581
+ if (identity.labelText && ["select", "input", "textarea"].includes(tagName)) {
2582
+ locators.push(this.page.getByLabel(identity.labelText, { exact: true }).first());
2583
+ }
2584
+ return locators;
2585
+ }
2586
+ async _openCustomSelectScope(control, beforeModel, details) {
2587
+ const debugLog = [];
2588
+ details.custom_attempts = debugLog;
2589
+ try {
2590
+ await control.click({ timeout: this.defaultTimeout });
2591
+ debugLog.push({ step: "open_control", success: true });
2592
+ await this.page.waitForTimeout(120);
2593
+ }
2594
+ catch (e) {
2595
+ debugLog.push({ step: "open_control", success: false, error: String(e) });
2596
+ return null;
2597
+ }
2598
+ const ownedIds = [beforeModel.ariaControls, beforeModel.ariaOwns]
2599
+ .flatMap((value) => value.split(/\s+/))
2600
+ .map((value) => value.trim())
2601
+ .filter(Boolean);
2602
+ for (const id of ownedIds) {
2603
+ const locator = this.page.locator(`[id="${this._escapeAttributeValue(id)}"]`).first();
2604
+ if (await locator.isVisible().catch(() => false)) {
2605
+ debugLog.push({ step: "use_owned_popup", id });
2606
+ return locator;
2607
+ }
2608
+ }
2609
+ const nearestScope = await this._findVisiblePopupScope(control);
2610
+ if (nearestScope) {
2611
+ debugLog.push({ step: "use_nearest_popup_scope", success: true });
2612
+ return nearestScope;
2613
+ }
2614
+ return null;
2615
+ }
2616
+ async _findVisiblePopupScope(control) {
2617
+ const popupLocator = this.page.locator("[role='listbox'], [role='menu'], [role='dialog']");
2618
+ const popupCount = await popupLocator.count().catch(() => 0);
2619
+ if (popupCount === 0)
2620
+ return null;
2621
+ const controlBox = await control.boundingBox().catch(() => null);
2622
+ let bestIndex = -1;
2623
+ let bestDistance = Number.POSITIVE_INFINITY;
2624
+ for (let i = 0; i < Math.min(popupCount, 8); i++) {
2625
+ const candidate = popupLocator.nth(i);
2626
+ const visible = await candidate.isVisible().catch(() => false);
2627
+ if (!visible)
2628
+ continue;
2629
+ if (!controlBox)
2630
+ return candidate;
2631
+ const box = await candidate.boundingBox().catch(() => null);
2632
+ if (!box)
2633
+ continue;
2634
+ const dx = box.x + box.width / 2 - (controlBox.x + controlBox.width / 2);
2635
+ const dy = box.y + box.height / 2 - (controlBox.y + controlBox.height / 2);
2636
+ const distance = Math.sqrt(dx * dx + dy * dy);
2637
+ if (distance < bestDistance) {
2638
+ bestDistance = distance;
2639
+ bestIndex = i;
2640
+ }
2641
+ }
2642
+ return bestIndex >= 0 ? popupLocator.nth(bestIndex) : null;
2643
+ }
2644
+ async _clickScopedSelectOption(scope, option, details) {
2645
+ const debugLog = Array.isArray(details.custom_attempts)
2646
+ ? details.custom_attempts
2647
+ : [];
2648
+ const attempts = [];
2649
+ if (option.value) {
2650
+ const escapedValue = this._escapeAttributeValue(option.value);
2651
+ attempts.push({
2652
+ label: `option[value="${option.value}"]`,
2653
+ locator: scope.locator(`option[value="${escapedValue}"]`),
2654
+ });
2655
+ }
2656
+ if (option.dataValue) {
2657
+ const escapedDataValue = this._escapeAttributeValue(option.dataValue);
2658
+ attempts.push({
2659
+ label: `[data-value="${option.dataValue}"]`,
2660
+ locator: scope.locator(`[data-value="${escapedDataValue}"]`),
2661
+ });
2662
+ }
2663
+ const displayName = option.text || option.label || option.ariaLabel;
2664
+ if (displayName) {
2665
+ attempts.push({
2666
+ label: `scope text exact "${displayName}"`,
2667
+ locator: scope.getByText(displayName, { exact: true }),
2668
+ });
2669
+ if (option.ariaLabel) {
2670
+ attempts.push({
2671
+ label: `scope aria-label "${option.ariaLabel}"`,
2672
+ locator: scope.locator(`[aria-label="${this._escapeAttributeValue(option.ariaLabel)}"]`),
2673
+ });
2674
+ }
2675
+ }
2676
+ for (const attempt of attempts) {
2677
+ const clicked = await this._clickFirstVisible(attempt.locator, attempt.label, debugLog);
2678
+ if (clicked)
2679
+ return true;
2680
+ }
2681
+ return false;
2682
+ }
2683
+ _toSelectStateDebug(model) {
2684
+ if (!model)
2685
+ return null;
2686
+ return {
2687
+ kind: model.kind,
2688
+ tagName: model.tagName,
2689
+ role: model.role,
2690
+ value: model.value,
2691
+ selectedText: model.selectedText,
2692
+ ariaValueText: model.ariaValueText,
2693
+ identity: model.identity,
2694
+ optionCount: model.options.length,
2695
+ };
2696
+ }
2697
+ _toSelectOptionDebug(option) {
2698
+ if (!option)
2699
+ return null;
2700
+ return {
2701
+ value: option.value,
2702
+ text: option.text,
2703
+ label: option.label,
2704
+ role: option.role,
2705
+ dataValue: option.dataValue,
2706
+ ariaLabel: option.ariaLabel,
2707
+ };
2708
+ }
2709
+ _toSelectResolutionDebug(resolution) {
2710
+ if (!resolution)
2711
+ return null;
2712
+ return {
2713
+ domain: resolution.domain,
2714
+ reason: resolution.reason,
2715
+ option: this._toSelectOptionDebug(resolution.option),
2716
+ };
2717
+ }
2718
+ _validCoordinates(xCoord, yCoord) {
2719
+ const viewport = this.page.viewportSize();
2720
+ if (!viewport) {
2721
+ throw new Error("Viewport size not available from current page.");
2722
+ }
2723
+ return xCoord >= 0 && xCoord <= viewport.width && yCoord >= 0 && yCoord <= viewport.height;
2724
+ }
2725
+ }
2726
+ //# sourceMappingURL=action-executor.js.map