testronaut 1.0.24 → 1.0.26

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.26",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "./bin/cli.js"
@@ -79,10 +79,449 @@ 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
+ _chromiumLaunchArgsForCI() {
508
+ const inCI = !!(process.env.CI || process.env.GITHUB_ACTIONS || process.env.TESTRONAUT_CI_CHROMIUM);
509
+ return inCI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [];
510
+ }
511
+
512
+
82
513
  async start() {
83
514
  try {
84
515
  const pw = await loadPlaywright();
85
- this.browser = await pw.chromium.launch({ headless: true });
516
+ const launchArgs = [
517
+ '--disable-dev-shm-usage',
518
+ '--disable-gpu',
519
+ ...this._chromiumLaunchArgsForCI(),
520
+ ];
521
+ this.browser = await pw.chromium.launch({
522
+ headless: true,
523
+ args: launchArgs,
524
+ });
86
525
  } catch (err) {
87
526
  const msg = String(err?.message || err);
88
527
  const needsInstall =
@@ -99,7 +538,16 @@ export class ChromeBrowser {
99
538
 
100
539
  // Retry
101
540
  const pw = await loadPlaywright();
102
- return await pw.chromium.launch({ headless: true });
541
+ const launchArgs = [
542
+ '--disable-dev-shm-usage',
543
+ '--disable-gpu',
544
+ ...this._chromiumLaunchArgsForCI(),
545
+ ];
546
+ this.browser = await pw.chromium.launch({
547
+ headless: true,
548
+ args: launchArgs,
549
+ });
550
+ // return await pw.chromium.launch({ headless: true });
103
551
  }
104
552
  this.context = await this.browser.newContext();
105
553
  const first = await this.context.newPage();
@@ -114,62 +562,297 @@ export class ChromeBrowser {
114
562
  this.page = this._getCurrent()?.page ?? null;
115
563
  }
116
564
  });
565
+ await first.addStyleTag({ content: `*{animation-duration:.001s!important;transition-duration:.001s!important}` }).catch(() => {});
117
566
  }
118
567
 
119
- async navigate({ url }) {
120
- await this.page.goto(url, { timeout: 30_000 });
568
+ async navigate({ url, loaderAware = true, loaderSelectors, transientUrlRe }) {
569
+ await this.page.goto(url, { timeout: 30_000, waitUntil: 'domcontentloaded' });
570
+ // Cheap global stabilization: make sure DOM is there & form container attached if present
571
+ await this.page.waitForLoadState('domcontentloaded').catch(() => {});
572
+ // If you expect a specific route like /sign-in, you can keep this generic and fast:
573
+ // await this.page.waitForURL(/\/sign-in/).catch(() => {});
574
+ // Defensive: ensure *something* form-like is attached (doesn't need to be visible)
575
+ await this.page.locator('form').first().waitFor({ state: 'attached', timeout: 3000 }).catch(() => {});
576
+
577
+ if (loaderAware) {
578
+ try { await this._waitPastTransientUrls({ urlChangedFrom: url, transientUrlRe }); } catch {}
579
+ try { await this._waitLoaderGone({ selectors: loaderSelectors }); } catch {}
580
+ }
121
581
  return `navigated to ${this.page.url()}`;
122
582
  }
123
583
 
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}`;
584
+
585
+ /**
586
+ * Generic fill with retries and broad textbox detection.
587
+ * Accepts your existing { selector, text } and optional:
588
+ * { placeholder, role, attrs, scope, searchAllFrames, strategy, loaderAware }
589
+ */
590
+ async fill({
591
+ selector,
592
+ text,
593
+ placeholder, // string | RegExp
594
+ role = 'textbox', // generic default
595
+ attrs,
596
+ scope,
597
+ searchAllFrames = true,
598
+ strategy = 'auto', // 'strict' -> selector only
599
+ loaderAware = true, // if you added the loader helpers earlier
600
+ }) {
601
+ if (loaderAware) {
602
+ // Best effort: avoid typing while loader overlays block focus
603
+ try { await this._waitLoaderGone({ timeoutMs: 6000 }); } catch {}
604
+ }
605
+
606
+ const makeCandidates = () => {
607
+ if (strategy === 'strict') {
608
+ return this._buildFillCandidates({ selector, scope, searchAllFrames });
609
+ }
610
+ return this._buildFillCandidates({ selector, placeholder, role, attrs, scope, searchAllFrames });
611
+ };
612
+
613
+ return this._withRetries(async () => {
614
+ const makers = makeCandidates();
615
+ let chosen = null;
616
+
617
+ // re-resolve each attempt (survive rerenders)
618
+ for (const make of makers) {
619
+ const loc = make(); if (!loc) continue;
620
+ try {
621
+ await loc.scrollIntoViewIfNeeded().catch(() => {});
622
+ await loc.waitFor({ state: 'visible', timeout: 2000 }); // visible on screen
623
+ const enabled = await this._waitEnabled(loc, { timeoutMs: 2500 });
624
+ if (!enabled) continue;
625
+ const editable = await this._waitEditable(loc, { timeoutMs: 2500 });
626
+ if (!editable) continue;
627
+ chosen = loc; break;
628
+ } catch {
629
+ // try next
630
+ }
631
+ }
632
+ if (!chosen) {
633
+ // quick debug snapshot (optional)
634
+ // const html = await this.page.content(); fs.writeFileSync('debug_fill.html', html);
635
+ throw new Error('No editable textbox became ready');
636
+ }
637
+
638
+ const method = await this._smartClearAndType(chosen, text);
639
+ return `filled via ${method}`;
640
+ }, { attempts: 3, baseMs: 250 });
128
641
  }
129
642
 
130
- async click({ selector, waitFor = 'domcontentloaded', delayMs = 2000 }) {
131
- await this.page.waitForSelector(selector, { timeout: 30_000 });
132
643
 
133
- const [res] = await Promise.all([
134
- this.page.waitForLoadState(waitFor, { timeout: 10_000 }).catch(() => null),
135
- this.page.click(selector),
136
- ]);
137
644
 
138
- if (delayMs) {
139
- await this.page.waitForTimeout(delayMs);
645
+ /**
646
+ * @param {object} opts
647
+ * ...
648
+ * @param {object} [opts.until] // declarative post-action waits
649
+ * @param {Array<object>} [opts.until.all] // all must pass
650
+ * @param {Array<object>} [opts.until.any] // any may pass
651
+ * @param {number} [opts.until.timeoutMs=8000]
652
+ * @param {boolean} [opts.until.searchAllFrames=true]
653
+ * @param {boolean} [opts.waitDomReady=true] // cheap settle
654
+ * @param {number} [opts.delayMs=200] // micro settle
655
+ */
656
+ async click({
657
+ selector, text, role, attrs, scope,
658
+ searchAllFrames = true,
659
+ strategy = 'auto',
660
+ waitFor,
661
+ waitDomReady = true,
662
+ delayMs = 200,
663
+ until,
664
+ loaderAware = true, // NEW
665
+ loaderSelectors, // NEW
666
+ transientUrlRe, // NEW
667
+ postClickTimeoutMs = 12000,
668
+ }) {
669
+ const prevUrl = this.page.url();
670
+
671
+ // Build your candidates the way you already do (generic/hint-driven)
672
+ const makers = (strategy === 'strict')
673
+ ? this._buildClickCandidates({ selector, scope, searchAllFrames })
674
+ : this._buildClickCandidates({ selector, text, role, attrs, scope, searchAllFrames });
675
+
676
+ // choose & click (same as your generic version)
677
+ let chosen = null;
678
+ for (const make of makers) {
679
+ const loc = make(); if (!loc) continue;
680
+ try {
681
+ await loc.scrollIntoViewIfNeeded().catch(() => {});
682
+ await loc.waitFor({ state: 'visible', timeout: 2000 });
683
+
684
+ await loc.scrollIntoViewIfNeeded().catch(() => {});
685
+ await loc.waitFor({ state: 'visible', timeout: 2000 });
686
+
687
+ const becameEnabled = await this._waitEnabled(loc, { timeoutMs: 2500 });
688
+ if (!becameEnabled) {
689
+ // try next candidate; this one is still disabled after ~2.5s
690
+ continue;
691
+ }
692
+ if (await this._isClickable(loc)) {
693
+ chosen = loc;
694
+ break;
695
+ }
696
+
697
+ if (await this._isClickable(loc)) { chosen = loc; break; }
698
+ } catch {}
699
+ }
700
+ if (!chosen) {
701
+ await this._debugClickableSnapshot({ scope }).catch(()=>{});
702
+ await this.page.screenshot({ path: 'missions/mission_reports/screenshots/click_fail.png' }).catch(()=>{});
703
+ throw new Error('No clickable locator became ready');
704
+ }
705
+ // do the click (standard → hover+click → JS click)
706
+ try { await chosen.click({ timeout: 3000 }); }
707
+ catch {
708
+ try { await chosen.hover({ timeout: 1200 }).catch(()=>{}); await chosen.click({ timeout: 3000 }); }
709
+ catch { await chosen.evaluate(el => el.click()); }
710
+ }
711
+
712
+ // cheap settle so screenshots don't catch mid-paint
713
+ if (waitDomReady) await this.page.waitForLoadState('domcontentloaded', { timeout: 3000 }).catch(()=>{});
714
+ if (delayMs) await this.page.waitForTimeout(delayMs);
715
+
716
+ // --- Built-in stabilization for loader pages (generic) ---
717
+ if (loaderAware) {
718
+ const endBy = Date.now() + postClickTimeoutMs;
719
+
720
+ // 1) Move past transient URLs (loading/callback/redirect/etc.)
721
+ try {
722
+ await this._waitPastTransientUrls({
723
+ urlChangedFrom: true,
724
+ transientUrlRe: transientUrlRe ?? this.DEFAULT_TRANSIENT_URL_RE,
725
+ timeoutMs: Math.max(0, endBy - Date.now()),
726
+ });
727
+ } catch { /* best effort, don’t fail here */ }
728
+
729
+ // 2) Wait for loaders to vanish for a quiet window
730
+ try {
731
+ await this._waitLoaderGone({
732
+ selectors: loaderSelectors ?? this.DEFAULT_LOADER_SELECTORS,
733
+ searchAllFrames: true,
734
+ timeoutMs: Math.max(0, endBy - Date.now()),
735
+ quietMs: 700,
736
+ });
737
+ } catch { /* best effort, don’t fail here */ }
738
+ }
739
+
740
+ // 3) Your explicit declarative conditions (optional)
741
+
742
+
743
+ // declarative postconditions (e.g., loader disappears, route changes, dashboard visible)
744
+ if (until) {
745
+ // convenience: if they asked for urlChange without specifying from, use prevUrl
746
+ const normalized = {
747
+ ...until,
748
+ all: (until.all || []).map(c => c.urlChangedFrom === true ? { urlChangedFrom: prevUrl } : c),
749
+ };
750
+ await this._waitUntil({
751
+ all: normalized.all || [],
752
+ any: normalized.any || [],
753
+ timeoutMs: normalized.timeoutMs ?? 8000,
754
+ searchAllFrames: normalized.searchAllFrames ?? true,
755
+ });
140
756
  }
141
757
 
142
- return `clicked ${selector} and waited ${delayMs}ms`;
758
+ const preview = await chosen.evaluate(el => el.outerHTML?.slice(0, 80)).catch(() => '<locator>');
759
+ return `clicked ${preview}`;
143
760
  }
144
761
 
145
- async get_dom({ limit = 100000, exclude = true, focus = [] }, agentMemory = {}) {
146
- let html;
762
+
763
+
764
+
765
+ async get_dom(
766
+ { limit = 100000, exclude = true, focus = [], loadState = 'networkidle', timeout = 10000 } = {},
767
+ agentMemory = {}
768
+ ) {
769
+ // --- 1) Ensure we have a live page ---
770
+ const hasContext = !!this.context;
771
+
772
+ // Recover a page if missing/closed
773
+ const recoverPage = async () => {
774
+ if (hasContext) {
775
+ const pages = (this.context.pages?.() || []).filter(p => !p.isClosed?.());
776
+ if (pages.length) {
777
+ // Prefer the last page (often the active one / popup)
778
+ this.page = pages[pages.length - 1];
779
+ return;
780
+ }
781
+ // No pages at all in a live context -> make one
782
+ this.page = await this.context.newPage();
783
+ return;
784
+ }
785
+ // No context yet but a browser exists -> create context + page
786
+ if (this.browser?.newContext) {
787
+ this.context = await this.browser.newContext();
788
+ this.page = await this.context.newPage();
789
+ return;
790
+ }
791
+ throw new Error('No browser context/page available in get_dom().');
792
+ };
793
+
794
+ if (!this.page || this.page.isClosed?.()) {
795
+ await recoverPage();
796
+ }
797
+
798
+ // --- 2) Try to reach a usable load state (best-effort) ---
147
799
  try {
148
- html = await this.page.content();
149
- } catch (err) {
150
- console.warn('⚠️ get_dom retrying due to navigation:', err.message);
151
- // Wait for navigation to settle
152
- await this.page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
153
- html = await this.page.content();
800
+ await this.page.waitForLoadState(loadState, { timeout });
801
+ } catch {
802
+ // Non-fatal: some apps never reach 'load', we’ll still try to read the DOM.
154
803
  }
804
+
805
+ // If navigation changed active page (e.g., popup), reselect a live page
806
+ try {
807
+ const pages = this.context?.pages?.() || [];
808
+ if (pages.length && (this.page.isClosed?.() || !pages.includes(this.page))) {
809
+ this.page = pages[pages.length - 1];
810
+ }
811
+ } catch { /* ignore */ }
812
+
813
+ // --- 3) Get HTML content with a small retry on failure ---
814
+ let html = '';
815
+ for (let attempt = 0; attempt < 2; attempt++) {
816
+ try {
817
+ html = await this.page.content();
818
+ if (html && html.trim()) break;
819
+ } catch (err) {
820
+ // First failure: try waiting for network to settle, then retry
821
+ if (attempt === 0) {
822
+ try { await this.page.waitForLoadState('networkidle', { timeout: 5000 }); } catch {}
823
+ // Also re-check active page (popups can swap)
824
+ try {
825
+ const pages = this.context?.pages?.() || [];
826
+ if (pages.length) this.page = pages[pages.length - 1];
827
+ } catch {}
828
+ } else {
829
+ // Second failure: bubble up a clearer message
830
+ throw new Error(`get_dom() failed to read page content: ${err?.message || err}`);
831
+ }
832
+ }
833
+ }
834
+
835
+ // Fallback to empty string if still nothing (keeps caller logic simple)
836
+ if (!html) html = '';
837
+
838
+ // --- 4) Optional DOM reduction/cleanup (your existing steps) ---
155
839
  let $ = cheerio.load(html);
156
-
157
- // Remove non-content tags
840
+
158
841
  if (exclude) {
159
842
  $ = await removeNonContentTags($);
160
- // console.log('🔧 Non-content tags removed from DOM.');
161
843
  $ = await reduceRepeatedDivs($);
162
- // console.log('🔧 Repeated divs reduced in DOM.');
844
+ // If you later re-enable:
163
845
  // $ = await removeObfuscatedClassNames($);
164
- // console.log('🔧 Obfuscated class names removed from DOM.')
165
846
  // $ = await removeAutoGeneratedClasses($);
166
- // console.log('🔧 Auto-generated classes removed from DOM.');
167
847
  }
168
- // console.log('🔧 DOM cleaned up:', $);
848
+
849
+ // (Optional: If you'd like to use `focus`, you could filter here by selectors/keywords.)
850
+
851
+ // --- 5) Return trimmed HTML ---
169
852
  const finalHtml = $.html();
170
- const trimmed = finalHtml.slice(0, limit);
171
- return trimmed;
853
+ return finalHtml.slice(0, limit);
172
854
  }
855
+
173
856
 
174
857
  async check_text({ text }) {
175
858
  const html = await this.page.content();