testronaut 1.0.23 → 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/.github/FUNDING.yml +15 -0
- package/bin/createWelcomeMission.js +5 -0
- package/package.json +1 -1
- package/tools/chromeBrowser.js +613 -15
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# These are supported funding model platforms
|
|
2
|
+
|
|
3
|
+
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
|
4
|
+
patreon: # Replace with a single Patreon username
|
|
5
|
+
open_collective: # Replace with a single Open Collective username
|
|
6
|
+
ko_fi: # Replace with a single Ko-fi username
|
|
7
|
+
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
|
8
|
+
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
|
9
|
+
liberapay: # Replace with a single Liberapay username
|
|
10
|
+
issuehunt: # Replace with a single IssueHunt username
|
|
11
|
+
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
|
12
|
+
polar: # Replace with a single Polar username
|
|
13
|
+
buy_me_a_coffee: testronaut
|
|
14
|
+
thanks_dev: # Replace with a single thanks.dev username
|
|
15
|
+
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
|
@@ -5,6 +5,11 @@ export const createWelcomeMission = async () => {
|
|
|
5
5
|
const missionsDir = path.join(process.cwd(), 'missions');
|
|
6
6
|
const welcomePath = path.join(missionsDir, 'welcome.mission.js');
|
|
7
7
|
|
|
8
|
+
// Ensure missions directory exists
|
|
9
|
+
if (!fs.existsSync(missionsDir)) {
|
|
10
|
+
fs.mkdirSync(missionsDir, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
|
|
8
13
|
if (fs.existsSync(welcomePath)) {
|
|
9
14
|
console.log('✅ welcome.mission.js already exists');
|
|
10
15
|
return;
|
package/package.json
CHANGED
package/tools/chromeBrowser.js
CHANGED
|
@@ -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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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 {
|