testronaut 1.0.24 → 1.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testronaut",
3
- "version": "1.0.24",
3
+ "version": "1.0.25",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "./bin/cli.js"
@@ -79,6 +79,432 @@ export class ChromeBrowser {
79
79
  return this.currentId;
80
80
  }
81
81
 
82
+ // ---- helpers: retries, wait, and locator resolution ----
83
+ async _withRetries(fn, { attempts = 3, baseMs = 250 } = {}) {
84
+ let lastErr;
85
+ for (let i = 0; i < attempts; i++) {
86
+ try {
87
+ return await fn();
88
+ } catch (err) {
89
+ lastErr = err;
90
+ // Exponential backoff: 250ms, 500ms, 1000ms...
91
+ await this.page.waitForTimeout(baseMs * Math.pow(2, i));
92
+ }
93
+ }
94
+ throw lastErr;
95
+ }
96
+
97
+ async _waitReadyForInteract(locator, { timeout = 3000 } = {}) {
98
+ // “Visible & enabled” is the sweet spot for flake reduction
99
+ await locator.first().waitFor({ state: 'visible', timeout });
100
+ // Extra: ensure it’s not disabled/covered
101
+ await locator.first().scrollIntoViewIfNeeded().catch(() => {});
102
+ }
103
+
104
+ _resolveLocator(selectorOrFn, fallbacks = []) {
105
+ // Accept: string CSS/XPath via page.locator, or a function (page) => Locator
106
+ const candidates = [
107
+ selectorOrFn,
108
+ ...fallbacks,
109
+ ].filter(Boolean);
110
+
111
+ const locators = candidates.map(c => {
112
+ if (typeof c === 'function') return c(this.page).first();
113
+ if (typeof c === 'string') return this.page.locator(c).first();
114
+ // Unsupported type → ignore
115
+ return null;
116
+ }).filter(Boolean);
117
+
118
+ return locators;
119
+ }
120
+
121
+ // Generic frame access
122
+ _allFrames({ includeMain = true } = {}) {
123
+ const frames = this.page.frames();
124
+ if (includeMain) {
125
+ const main = this.page.mainFrame();
126
+ return [main, ...frames.filter(f => f !== main)];
127
+ }
128
+ return frames;
129
+ }
130
+
131
+ // Build candidate locator MAKERS (Page|Frame-aware)
132
+ _buildClickCandidates({ selector, text, role, attrs, scope, searchAllFrames }) {
133
+ const frames = searchAllFrames ? this._allFrames({ includeMain: true }) : [this.page.mainFrame()];
134
+ const makers = [];
135
+ const add = (fn) => fn && makers.push(fn);
136
+
137
+ for (const f of frames) {
138
+ const ctx = f; // <-- IMPORTANT: use Frame directly (or this.page for main)
139
+
140
+ const locInScope = (loc) => (scope ? ctx.locator(scope).locator(loc) : ctx.locator(loc));
141
+
142
+ // 1) Explicit selector/function first
143
+ if (selector) {
144
+ add(() => typeof selector === 'function' ? selector(ctx).first() : locInScope(selector).first());
145
+ }
146
+
147
+ // 2) Hints (all generic)
148
+ if (role && text) add(() => ctx.getByRole(role, { name: text }).first());
149
+ if (role && !text) add(() => ctx.getByRole(role).first());
150
+ if (text && !role) {
151
+ add(() => ctx.getByRole('button', { name: text }).first());
152
+ add(() => ctx.getByRole('link', { name: text }).first());
153
+ add(() => ctx.getByText(text).locator('..').locator('button, a, [role="button"]').first());
154
+ }
155
+ if (attrs && Object.keys(attrs).length) {
156
+ const attrSel = Object.entries(attrs)
157
+ .map(([k,v]) => v === true ? `[${k}]` : `[${k}="${String(v)}"]`)
158
+ .join('');
159
+ if (attrSel) add(() => locInScope(attrSel).first());
160
+ }
161
+ }
162
+
163
+ const seen = new Set();
164
+ return makers.filter(fn => { const k = fn.toString(); if (seen.has(k)) return false; seen.add(k); return true; });
165
+ }
166
+
167
+
168
+ // Clickability check (visible, enabled, hit-testable)
169
+ async _isClickable(locator) {
170
+ try {
171
+ const vis = await locator.isVisible();
172
+ if (!vis) return false;
173
+ const [enabled, pe, display, visibility, bbox] = await Promise.all([
174
+ locator.isEnabled().catch(() => false),
175
+ locator.evaluate(el => getComputedStyle(el).pointerEvents).catch(() => 'auto'),
176
+ locator.evaluate(el => getComputedStyle(el).display).catch(() => 'block'),
177
+ locator.evaluate(el => getComputedStyle(el).visibility).catch(() => 'visible'),
178
+ locator.boundingBox().catch(() => null),
179
+ ]);
180
+ if (!enabled) return false;
181
+ if (pe === 'none' || display === 'none' || visibility === 'hidden') return false;
182
+ if (!bbox || bbox.width < 1 || bbox.height < 1) return false;
183
+ return true;
184
+ } catch { return false; }
185
+ }
186
+
187
+ async _withRetries(fn, { attempts = 3, baseMs = 250 } = {}) {
188
+ let lastErr;
189
+ for (let i = 0; i < attempts; i++) {
190
+ try { return await fn(); }
191
+ catch (err) {
192
+ lastErr = err;
193
+ await this.page.waitForTimeout(baseMs * Math.pow(2, i));
194
+ }
195
+ }
196
+ throw lastErr;
197
+ }
198
+
199
+ // Resolve a locator from string or function (page)=>Locator
200
+ _resolveAny(selectorOrFn, { page = this.page }) {
201
+ if (!selectorOrFn) return null;
202
+ return typeof selectorOrFn === 'function'
203
+ ? selectorOrFn(page).first()
204
+ : page.locator(String(selectorOrFn)).first();
205
+ }
206
+
207
+ // Wait for a single condition
208
+ async _waitOne(cond, { timeout = 8000, searchAllFrames = false } = {}) {
209
+ const page = this.page;
210
+ const frames = searchAllFrames ? [page.mainFrame(), ...page.frames().filter(f => f !== page.mainFrame())] : [page.mainFrame()];
211
+ const start = Date.now();
212
+
213
+ const left = () => Math.max(0, timeout - (Date.now() - start));
214
+
215
+ // URL-based
216
+ if (cond.urlIncludes) {
217
+ const re = cond.urlIncludes instanceof RegExp ? cond.urlIncludes : new RegExp(cond.urlIncludes.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
218
+ return page.waitForURL(u => re.test(String(u)), { timeout: left() });
219
+ }
220
+ if (cond.urlExcludes) {
221
+ const re = cond.urlExcludes instanceof RegExp ? cond.urlExcludes : new RegExp(cond.urlExcludes.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
222
+ return page.waitForURL(u => !re.test(String(u)), { timeout: left() });
223
+ }
224
+ if (cond.urlMatches) {
225
+ return page.waitForURL(cond.urlMatches, { timeout: left() });
226
+ }
227
+ if (cond.urlChangedFrom) {
228
+ const prev = cond.urlChangedFrom;
229
+ return page.waitForURL(u => String(u) !== String(prev), { timeout: left() });
230
+ }
231
+
232
+ // Function predicate
233
+ if (cond.predicate) {
234
+ return page.waitForFunction(cond.predicate, null, { timeout: left(), polling: 100 });
235
+ }
236
+
237
+ // Visibility/attachment on any frame
238
+ const stateMap = {
239
+ visible: 'visible',
240
+ hidden: 'hidden',
241
+ attached: 'attached',
242
+ detached: 'detached',
243
+ };
244
+ for (const key of Object.keys(stateMap)) {
245
+ if (cond[key]) {
246
+ const state = stateMap[key];
247
+ for (const f of frames) {
248
+ const p = f.page ? f.page() : page;
249
+ const loc = this._resolveAny(cond[key], { page: p });
250
+ if (!loc) continue;
251
+ try { await loc.waitFor({ state, timeout: left() }); return; } catch {/* try next frame */ }
252
+ }
253
+ // If we didn’t return from a frame, throw to trigger retry chain
254
+ throw new Error(`Timeout waiting for ${key}`);
255
+ }
256
+ }
257
+
258
+ // Fallback: no-op
259
+ return;
260
+ }
261
+
262
+ // Wait for composite conditions
263
+ async _waitUntil({ all = [], any = [], timeoutMs = 8000, searchAllFrames = false } = {}) {
264
+ if (!all.length && !any.length) return;
265
+
266
+ const deadline = Date.now() + timeoutMs;
267
+
268
+ // ALL: run sequentially (each with shared remainder timeout)
269
+ for (const c of all) {
270
+ const left = Math.max(0, deadline - Date.now());
271
+ await this._waitOne(c, { timeout: left, searchAllFrames });
272
+ }
273
+
274
+ // ANY: race the remaining time
275
+ if (any.length) {
276
+ const left = Math.max(0, deadline - Date.now());
277
+ await Promise.any(any.map(c => this._waitOne(c, { timeout: left, searchAllFrames })));
278
+ }
279
+ }
280
+
281
+ // Heuristics (override per call if you want)
282
+ DEFAULT_TRANSIENT_URL_RE = /(loading|redirect|callback|auth|sso|processing)/i;
283
+ DEFAULT_LOADER_SELECTORS = [
284
+ '[aria-busy="true"]',
285
+ '[role="progressbar"]',
286
+ '[aria-live="polite"][role="status"]',
287
+ '.loading,.loader,.spinner,.progress,.skeleton,.backdrop,.overlay',
288
+ '[data-loading="true"]',
289
+ '[data-busy="true"]',
290
+ '[data-state="loading"]',
291
+ ];
292
+
293
+ _allFrames({ includeMain = true } = {}) {
294
+ const frames = this.page.frames();
295
+ if (includeMain) {
296
+ const main = this.page.mainFrame();
297
+ return [main, ...frames.filter(f => f !== main)];
298
+ }
299
+ return frames;
300
+ }
301
+
302
+ async _frameHasVisible(frame, selector) {
303
+ // Visible = attached & not display:none/visibility:hidden & has size
304
+ return frame.evaluate((sel) => {
305
+ const els = Array.from(document.querySelectorAll(sel));
306
+ const isVisible = (el) => {
307
+ const cs = getComputedStyle(el);
308
+ if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
309
+ const r = el.getBoundingClientRect();
310
+ return r.width > 0 && r.height > 0;
311
+ };
312
+ return els.some(isVisible);
313
+ }, selector).catch(() => false);
314
+ }
315
+
316
+ /**
317
+ * Wait until no loader selectors are visibly present across frames
318
+ * for a continuous quiet window (quietMs). Generic & overrideable.
319
+ */
320
+ async _waitLoaderGone({
321
+ selectors = this.DEFAULT_LOADER_SELECTORS,
322
+ searchAllFrames = true,
323
+ timeoutMs = 12000,
324
+ quietMs = 700,
325
+ pollMs = 120,
326
+ } = {}) {
327
+ const frames = searchAllFrames ? this._allFrames({ includeMain: true }) : [this.page.mainFrame()];
328
+ const start = Date.now();
329
+ let quietStart = 0;
330
+
331
+ const joined = selectors.join(',');
332
+
333
+ while (Date.now() - start < timeoutMs) {
334
+ let visible = false;
335
+ for (const f of frames) {
336
+ const vis = await this._frameHasVisible(f, joined);
337
+ if (vis) { visible = true; break; }
338
+ }
339
+
340
+ if (!visible) {
341
+ if (quietStart === 0) quietStart = Date.now();
342
+ if (Date.now() - quietStart >= quietMs) return; // success: no loader for quiet window
343
+ } else {
344
+ quietStart = 0; // reset quiet window
345
+ }
346
+ await this.page.waitForTimeout(pollMs);
347
+ }
348
+ // If timeout, let caller decide (don’t throw by default to keep tests moving)
349
+ throw new Error('Loader still visible after timeout');
350
+ }
351
+
352
+ /**
353
+ * Wait until the URL is past transient/bounce routes (e.g., loading/callback).
354
+ * If you just want “leave current url”, pass urlChangedFrom = true.
355
+ */
356
+ async _waitPastTransientUrls({
357
+ urlChangedFrom, // string | true
358
+ transientUrlRe = this.DEFAULT_TRANSIENT_URL_RE,
359
+ timeoutMs = 12000,
360
+ quietMs = 400, // require the non-transient URL to stick briefly
361
+ } = {}) {
362
+ const page = this.page;
363
+ const startUrl = urlChangedFrom === true ? page.url() : (urlChangedFrom || null);
364
+ const start = Date.now();
365
+ let stableStart = 0;
366
+
367
+ while (Date.now() - start < timeoutMs) {
368
+ const u = page.url();
369
+ const leftStart = !startUrl || u !== String(startUrl);
370
+ const notTransient = !transientUrlRe || !transientUrlRe.test(u);
371
+
372
+ if (leftStart && notTransient) {
373
+ if (stableStart === 0) stableStart = Date.now();
374
+ if (Date.now() - stableStart >= quietMs) return; // success
375
+ } else {
376
+ stableStart = 0;
377
+ }
378
+ await this.page.waitForTimeout(100);
379
+ }
380
+ // Don’t throw; caller can decide to continue with other guards
381
+ throw new Error('URL did not move past transient routes in time');
382
+ }
383
+
384
+ async _waitEnabled(locator, { timeoutMs = 2500, pollMs = 120 } = {}) {
385
+ const start = Date.now();
386
+ while (Date.now() - start < timeoutMs) {
387
+ const enabled = await locator.isEnabled().catch(() => false);
388
+ if (enabled) return true;
389
+ await this.page.waitForTimeout(pollMs);
390
+ }
391
+ return false;
392
+ }
393
+
394
+ async _debugClickableSnapshot({ scope } = {}) {
395
+ const frames = this._allFrames({ includeMain: true });
396
+ const lines = [];
397
+ for (const [i,f] of frames.entries()) {
398
+ const ctx = f;
399
+ const root = scope ? ctx.locator(scope) : ctx;
400
+ const btns = await root.locator('button, [role="button"], a[href], input[type="submit"]').elementHandles();
401
+ const snippets = await Promise.all(btns.slice(0, 10).map(h => h.evaluate(el => {
402
+ const txt = (el.innerText || el.value || '').trim().slice(0,50);
403
+ return `<${el.tagName.toLowerCase()}> ${txt}`;
404
+ })));
405
+ lines.push(`frame[${i}] url=${f.url()} candidates=${snippets.join(' | ')}`);
406
+ }
407
+ console.log('[debug] clickables\n' + lines.join('\n'));
408
+ }
409
+
410
+ // Build candidates for text entry (Page|Frame-aware)
411
+ _buildFillCandidates({ selector, placeholder, role = 'textbox', attrs, scope, searchAllFrames }) {
412
+ const frames = searchAllFrames ? this._allFrames({ includeMain: true }) : [this.page.mainFrame()];
413
+ const makers = [];
414
+ const add = (fn) => fn && makers.push(fn);
415
+
416
+ for (const f of frames) {
417
+ const ctx = f; // Frame
418
+ const locInScope = (loc) => (scope ? ctx.locator(scope).locator(loc) : ctx.locator(loc));
419
+
420
+ // 1) Explicit selector/function first
421
+ if (selector) {
422
+ add(() => typeof selector === 'function' ? selector(ctx).first() : locInScope(selector).first());
423
+ }
424
+
425
+ // 2) Placeholder hint (semantic)
426
+ if (placeholder) {
427
+ add(() => ctx.getByPlaceholder(placeholder).first());
428
+ // common shapes if placeholder is on a different element than the input
429
+ add(() => locInScope(`[placeholder]`).filter({ hasText: placeholder }).first());
430
+ }
431
+
432
+ // 3) Role hint (textbox covers input/textarea/contenteditable)
433
+ if (role) {
434
+ add(() => ctx.getByRole(role).first());
435
+ if (placeholder) add(() => ctx.getByRole(role, { name: placeholder }).first());
436
+ }
437
+
438
+ // 4) Generic text-entry shapes
439
+ add(() => locInScope('input[type="text"], input[type="search"], input:not([type]), textarea').first());
440
+ add(() => locInScope('[contenteditable=""], [contenteditable="true"], [role="textbox"]').first());
441
+
442
+ // 5) Attribute filters
443
+ if (attrs && Object.keys(attrs).length) {
444
+ const attrSel = Object.entries(attrs)
445
+ .map(([k,v]) => v === true ? `[${k}]` : `[${k}="${String(v)}"]`)
446
+ .join('');
447
+ if (attrSel) add(() => locInScope(attrSel).first());
448
+ }
449
+ }
450
+
451
+ // de-dup
452
+ const seen = new Set();
453
+ return makers.filter(fn => { const k = fn.toString(); if (seen.has(k)) return false; seen.add(k); return true; });
454
+ }
455
+
456
+
457
+ async _waitEditable(locator, { timeoutMs = 2500, pollMs = 120 } = {}) {
458
+ const start = Date.now();
459
+ while (Date.now() - start < timeoutMs) {
460
+ if (await locator.isEditable().catch(() => false)) return true;
461
+ await this.page.waitForTimeout(pollMs);
462
+ }
463
+ return false;
464
+ }
465
+
466
+ async _smartClearAndType(locator, text) {
467
+ // Try native fill first (clears automatically on inputs/textarea/contenteditable)
468
+ try {
469
+ await locator.fill(text, { timeout: 3000 });
470
+ return 'fill';
471
+ } catch {}
472
+
473
+ // Fallback: focus + select all + type
474
+ try {
475
+ await locator.focus({ timeout: 1500 }).catch(() => {});
476
+ // Select all on mac/linux/windows
477
+ await this.page.keyboard.down('Control').catch(()=>{});
478
+ await this.page.keyboard.press('KeyA').catch(()=>{});
479
+ await this.page.keyboard.up('Control').catch(()=>{});
480
+ await this.page.keyboard.press('Backspace').catch(()=>{});
481
+ await locator.type(text, { delay: 10, timeout: 4000 }); // human-ish typing helps some SPAs
482
+ return 'type';
483
+ } catch {}
484
+
485
+ // Last resort: set value via JS + dispatch events
486
+ try {
487
+ await locator.evaluate((el, value) => {
488
+ const isCE = el.isContentEditable || el.getAttribute('contenteditable') === '' || el.getAttribute('contenteditable') === 'true';
489
+ if (isCE) {
490
+ el.innerText = value;
491
+ el.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true }));
492
+ el.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
493
+ } else {
494
+ const proto = Object.getPrototypeOf(el);
495
+ const desc = Object.getOwnPropertyDescriptor(proto, 'value');
496
+ if (desc?.set) desc.set.call(el, value); else el.value = value;
497
+ el.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true }));
498
+ el.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
499
+ }
500
+ }, text);
501
+ return 'js';
502
+ } catch {}
503
+
504
+ throw new Error('Could not input text with any strategy');
505
+ }
506
+
507
+
82
508
  async start() {
83
509
  try {
84
510
  const pw = await loadPlaywright();
@@ -114,34 +540,206 @@ export class ChromeBrowser {
114
540
  this.page = this._getCurrent()?.page ?? null;
115
541
  }
116
542
  });
543
+ await first.addStyleTag({ content: `*{animation-duration:.001s!important;transition-duration:.001s!important}` }).catch(() => {});
117
544
  }
118
545
 
119
- async navigate({ url }) {
120
- await this.page.goto(url, { timeout: 30_000 });
546
+ async navigate({ url, loaderAware = true, loaderSelectors, transientUrlRe }) {
547
+ await this.page.goto(url, { timeout: 30_000, waitUntil: 'domcontentloaded' });
548
+ // Cheap global stabilization: make sure DOM is there & form container attached if present
549
+ await this.page.waitForLoadState('domcontentloaded').catch(() => {});
550
+ // If you expect a specific route like /sign-in, you can keep this generic and fast:
551
+ // await this.page.waitForURL(/\/sign-in/).catch(() => {});
552
+ // Defensive: ensure *something* form-like is attached (doesn't need to be visible)
553
+ await this.page.locator('form').first().waitFor({ state: 'attached', timeout: 3000 }).catch(() => {});
554
+
555
+ if (loaderAware) {
556
+ try { await this._waitPastTransientUrls({ urlChangedFrom: url, transientUrlRe }); } catch {}
557
+ try { await this._waitLoaderGone({ selectors: loaderSelectors }); } catch {}
558
+ }
121
559
  return `navigated to ${this.page.url()}`;
122
560
  }
123
561
 
124
- async fill({ selector, text }) {
125
- await this.page.waitForSelector(selector, { timeout: 30_000 });
126
- await this.page.fill(selector, text);
127
- return `filled ${selector}`;
562
+
563
+ /**
564
+ * Generic fill with retries and broad textbox detection.
565
+ * Accepts your existing { selector, text } and optional:
566
+ * { placeholder, role, attrs, scope, searchAllFrames, strategy, loaderAware }
567
+ */
568
+ async fill({
569
+ selector,
570
+ text,
571
+ placeholder, // string | RegExp
572
+ role = 'textbox', // generic default
573
+ attrs,
574
+ scope,
575
+ searchAllFrames = true,
576
+ strategy = 'auto', // 'strict' -> selector only
577
+ loaderAware = true, // if you added the loader helpers earlier
578
+ }) {
579
+ if (loaderAware) {
580
+ // Best effort: avoid typing while loader overlays block focus
581
+ try { await this._waitLoaderGone({ timeoutMs: 6000 }); } catch {}
582
+ }
583
+
584
+ const makeCandidates = () => {
585
+ if (strategy === 'strict') {
586
+ return this._buildFillCandidates({ selector, scope, searchAllFrames });
587
+ }
588
+ return this._buildFillCandidates({ selector, placeholder, role, attrs, scope, searchAllFrames });
589
+ };
590
+
591
+ return this._withRetries(async () => {
592
+ const makers = makeCandidates();
593
+ let chosen = null;
594
+
595
+ // re-resolve each attempt (survive rerenders)
596
+ for (const make of makers) {
597
+ const loc = make(); if (!loc) continue;
598
+ try {
599
+ await loc.scrollIntoViewIfNeeded().catch(() => {});
600
+ await loc.waitFor({ state: 'visible', timeout: 2000 }); // visible on screen
601
+ const enabled = await this._waitEnabled(loc, { timeoutMs: 2500 });
602
+ if (!enabled) continue;
603
+ const editable = await this._waitEditable(loc, { timeoutMs: 2500 });
604
+ if (!editable) continue;
605
+ chosen = loc; break;
606
+ } catch {
607
+ // try next
608
+ }
609
+ }
610
+ if (!chosen) {
611
+ // quick debug snapshot (optional)
612
+ // const html = await this.page.content(); fs.writeFileSync('debug_fill.html', html);
613
+ throw new Error('No editable textbox became ready');
614
+ }
615
+
616
+ const method = await this._smartClearAndType(chosen, text);
617
+ return `filled via ${method}`;
618
+ }, { attempts: 3, baseMs: 250 });
128
619
  }
129
620
 
130
- async click({ selector, waitFor = 'domcontentloaded', delayMs = 2000 }) {
131
- await this.page.waitForSelector(selector, { timeout: 30_000 });
132
621
 
133
- const [res] = await Promise.all([
134
- this.page.waitForLoadState(waitFor, { timeout: 10_000 }).catch(() => null),
135
- this.page.click(selector),
136
- ]);
137
622
 
138
- if (delayMs) {
139
- await this.page.waitForTimeout(delayMs);
623
+ /**
624
+ * @param {object} opts
625
+ * ...
626
+ * @param {object} [opts.until] // declarative post-action waits
627
+ * @param {Array<object>} [opts.until.all] // all must pass
628
+ * @param {Array<object>} [opts.until.any] // any may pass
629
+ * @param {number} [opts.until.timeoutMs=8000]
630
+ * @param {boolean} [opts.until.searchAllFrames=true]
631
+ * @param {boolean} [opts.waitDomReady=true] // cheap settle
632
+ * @param {number} [opts.delayMs=200] // micro settle
633
+ */
634
+ async click({
635
+ selector, text, role, attrs, scope,
636
+ searchAllFrames = true,
637
+ strategy = 'auto',
638
+ waitFor,
639
+ waitDomReady = true,
640
+ delayMs = 200,
641
+ until,
642
+ loaderAware = true, // NEW
643
+ loaderSelectors, // NEW
644
+ transientUrlRe, // NEW
645
+ postClickTimeoutMs = 12000,
646
+ }) {
647
+ const prevUrl = this.page.url();
648
+
649
+ // Build your candidates the way you already do (generic/hint-driven)
650
+ const makers = (strategy === 'strict')
651
+ ? this._buildClickCandidates({ selector, scope, searchAllFrames })
652
+ : this._buildClickCandidates({ selector, text, role, attrs, scope, searchAllFrames });
653
+
654
+ // choose & click (same as your generic version)
655
+ let chosen = null;
656
+ for (const make of makers) {
657
+ const loc = make(); if (!loc) continue;
658
+ try {
659
+ await loc.scrollIntoViewIfNeeded().catch(() => {});
660
+ await loc.waitFor({ state: 'visible', timeout: 2000 });
661
+
662
+ await loc.scrollIntoViewIfNeeded().catch(() => {});
663
+ await loc.waitFor({ state: 'visible', timeout: 2000 });
664
+
665
+ const becameEnabled = await this._waitEnabled(loc, { timeoutMs: 2500 });
666
+ if (!becameEnabled) {
667
+ // try next candidate; this one is still disabled after ~2.5s
668
+ continue;
669
+ }
670
+ if (await this._isClickable(loc)) {
671
+ chosen = loc;
672
+ break;
673
+ }
674
+
675
+ if (await this._isClickable(loc)) { chosen = loc; break; }
676
+ } catch {}
677
+ }
678
+ if (!chosen) {
679
+ await this._debugClickableSnapshot({ scope }).catch(()=>{});
680
+ await this.page.screenshot({ path: 'missions/mission_reports/screenshots/click_fail.png' }).catch(()=>{});
681
+ throw new Error('No clickable locator became ready');
682
+ }
683
+ // do the click (standard → hover+click → JS click)
684
+ try { await chosen.click({ timeout: 3000 }); }
685
+ catch {
686
+ try { await chosen.hover({ timeout: 1200 }).catch(()=>{}); await chosen.click({ timeout: 3000 }); }
687
+ catch { await chosen.evaluate(el => el.click()); }
140
688
  }
141
689
 
142
- return `clicked ${selector} and waited ${delayMs}ms`;
690
+ // cheap settle so screenshots don't catch mid-paint
691
+ if (waitDomReady) await this.page.waitForLoadState('domcontentloaded', { timeout: 3000 }).catch(()=>{});
692
+ if (delayMs) await this.page.waitForTimeout(delayMs);
693
+
694
+ // --- Built-in stabilization for loader pages (generic) ---
695
+ if (loaderAware) {
696
+ const endBy = Date.now() + postClickTimeoutMs;
697
+
698
+ // 1) Move past transient URLs (loading/callback/redirect/etc.)
699
+ try {
700
+ await this._waitPastTransientUrls({
701
+ urlChangedFrom: true,
702
+ transientUrlRe: transientUrlRe ?? this.DEFAULT_TRANSIENT_URL_RE,
703
+ timeoutMs: Math.max(0, endBy - Date.now()),
704
+ });
705
+ } catch { /* best effort, don’t fail here */ }
706
+
707
+ // 2) Wait for loaders to vanish for a quiet window
708
+ try {
709
+ await this._waitLoaderGone({
710
+ selectors: loaderSelectors ?? this.DEFAULT_LOADER_SELECTORS,
711
+ searchAllFrames: true,
712
+ timeoutMs: Math.max(0, endBy - Date.now()),
713
+ quietMs: 700,
714
+ });
715
+ } catch { /* best effort, don’t fail here */ }
716
+ }
717
+
718
+ // 3) Your explicit declarative conditions (optional)
719
+
720
+
721
+ // declarative postconditions (e.g., loader disappears, route changes, dashboard visible)
722
+ if (until) {
723
+ // convenience: if they asked for urlChange without specifying from, use prevUrl
724
+ const normalized = {
725
+ ...until,
726
+ all: (until.all || []).map(c => c.urlChangedFrom === true ? { urlChangedFrom: prevUrl } : c),
727
+ };
728
+ await this._waitUntil({
729
+ all: normalized.all || [],
730
+ any: normalized.any || [],
731
+ timeoutMs: normalized.timeoutMs ?? 8000,
732
+ searchAllFrames: normalized.searchAllFrames ?? true,
733
+ });
734
+ }
735
+
736
+ const preview = await chosen.evaluate(el => el.outerHTML?.slice(0, 80)).catch(() => '<locator>');
737
+ return `clicked ${preview}`;
143
738
  }
144
739
 
740
+
741
+
742
+
145
743
  async get_dom({ limit = 100000, exclude = true, focus = [] }, agentMemory = {}) {
146
744
  let html;
147
745
  try {