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 +1 -1
- package/tools/chromeBrowser.js +717 -34
package/package.json
CHANGED
package/tools/chromeBrowser.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
758
|
+
const preview = await chosen.evaluate(el => el.outerHTML?.slice(0, 80)).catch(() => '<locator>');
|
|
759
|
+
return `clicked ${preview}`;
|
|
143
760
|
}
|
|
144
761
|
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
149
|
-
} catch
|
|
150
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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();
|