explorbot 0.1.27 → 0.1.29
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/README.md +83 -245
- package/bin/explorbot-cli.ts +1 -0
- package/dist/bin/explorbot-cli.js +1 -0
- package/dist/package.json +8 -6
- package/dist/rules/navigator/verification-actions.md +2 -0
- package/dist/src/action-result.js +2 -0
- package/dist/src/action.js +88 -7
- package/dist/src/ai/captain/file-tools.js +100 -0
- package/dist/src/ai/captain/idle-mode.js +70 -6
- package/dist/src/ai/captain/web-mode.js +36 -6
- package/dist/src/ai/captain.js +87 -19
- package/dist/src/ai/fisherman.js +14 -3
- package/dist/src/ai/historian/screencast.js +11 -2
- package/dist/src/ai/navigator.js +5 -2
- package/dist/src/ai/pilot.js +52 -9
- package/dist/src/ai/planner.js +16 -5
- package/dist/src/ai/provider.js +53 -18
- package/dist/src/ai/researcher/coordinates.js +2 -3
- package/dist/src/ai/researcher/deep-analysis.js +3 -4
- package/dist/src/ai/researcher/locators.js +1 -2
- package/dist/src/ai/researcher.js +24 -19
- package/dist/src/ai/rules.js +44 -0
- package/dist/src/ai/task-agent.js +1 -0
- package/dist/src/ai/tester.js +161 -46
- package/dist/src/ai/tools.js +84 -8
- package/dist/src/commands/explore-command.js +6 -1
- package/dist/src/components/LogPane.js +4 -3
- package/dist/src/explorbot.js +7 -2
- package/dist/src/explorer.js +270 -35
- package/dist/src/stats.js +16 -0
- package/dist/src/utils/aria.js +66 -6
- package/dist/src/utils/browser-errors.js +23 -0
- package/dist/src/utils/error-page.js +17 -2
- package/dist/src/utils/logger.js +2 -2
- package/package.json +8 -6
- package/rules/navigator/verification-actions.md +2 -0
- package/src/action-result.ts +2 -0
- package/src/action.ts +83 -7
- package/src/ai/captain/file-tools.ts +126 -0
- package/src/ai/captain/idle-mode.ts +72 -6
- package/src/ai/captain/mixin.ts +1 -1
- package/src/ai/captain/web-mode.ts +40 -5
- package/src/ai/captain.ts +94 -20
- package/src/ai/fisherman.ts +14 -3
- package/src/ai/historian/screencast.ts +11 -2
- package/src/ai/navigator.ts +6 -2
- package/src/ai/pilot.ts +53 -9
- package/src/ai/planner.ts +16 -5
- package/src/ai/provider.ts +51 -19
- package/src/ai/researcher/coordinates.ts +2 -3
- package/src/ai/researcher/deep-analysis.ts +3 -4
- package/src/ai/researcher/locators.ts +1 -2
- package/src/ai/researcher.ts +25 -19
- package/src/ai/rules.ts +46 -0
- package/src/ai/task-agent.ts +1 -1
- package/src/ai/tester.ts +175 -48
- package/src/ai/tools.ts +97 -8
- package/src/commands/explore-command.ts +6 -1
- package/src/components/LogPane.tsx +4 -3
- package/src/config.ts +1 -0
- package/src/explorbot.ts +6 -2
- package/src/explorer.ts +295 -38
- package/src/state-manager.ts +2 -0
- package/src/stats.ts +18 -0
- package/src/utils/aria.ts +63 -6
- package/src/utils/browser-errors.ts +25 -0
- package/src/utils/error-page.ts +16 -3
- package/src/utils/logger.ts +3 -3
package/dist/src/explorer.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
// @ts-ignore
|
|
4
4
|
import * as codeceptjs from 'codeceptjs';
|
|
5
|
+
import dedent from 'dedent';
|
|
5
6
|
import stepsListener from 'codeceptjs/lib/listener/steps';
|
|
6
7
|
import storeListener from 'codeceptjs/lib/listener/store';
|
|
7
8
|
import { createTest } from 'codeceptjs/lib/mocha/test';
|
|
@@ -15,11 +16,12 @@ import { KnowledgeTracker } from './knowledge-tracker.js';
|
|
|
15
16
|
import { PlaywrightRecorder } from "./playwright-recorder.js";
|
|
16
17
|
import { Reporter } from "./reporter.js";
|
|
17
18
|
import { StateManager } from './state-manager.js';
|
|
19
|
+
import { TestResult } from "./test-plan.js";
|
|
18
20
|
import { ELEMENT_EXTRACTION_CONFIG, getElementDataExtractorSource } from "./utils/html.js";
|
|
19
21
|
import { createDebug, log, tag } from './utils/logger.js';
|
|
20
22
|
import { WebElement } from "./utils/web-element.js";
|
|
23
|
+
import { BrowserRecoveryError, isFatalBrowserError } from "./utils/browser-errors.js";
|
|
21
24
|
const debugLog = createDebug('explorbot:explorer');
|
|
22
|
-
const FATAL_BROWSER_ERRORS = /Frame was detached|Target closed|Execution context was destroyed|Protocol error|Session closed/i;
|
|
23
25
|
const RECOVERABLE_NAVIGATION_ERRORS = /net::ERR_ABORTED|page\.screenshot.*Timeout|waiting for fonts to load/i;
|
|
24
26
|
class Explorer {
|
|
25
27
|
aiProvider;
|
|
@@ -38,6 +40,10 @@ class Explorer {
|
|
|
38
40
|
xhrCapture = null;
|
|
39
41
|
requestStore = null;
|
|
40
42
|
playwrightRecorder = new PlaywrightRecorder();
|
|
43
|
+
observedTestPages = new Set();
|
|
44
|
+
testPageErrorHandler = null;
|
|
45
|
+
testConsoleHandler = null;
|
|
46
|
+
testDialogHandler = null;
|
|
41
47
|
constructor(config, aiProvider, options) {
|
|
42
48
|
this.config = config;
|
|
43
49
|
this.aiProvider = aiProvider;
|
|
@@ -154,10 +160,12 @@ class Explorer {
|
|
|
154
160
|
return {};
|
|
155
161
|
}
|
|
156
162
|
}
|
|
157
|
-
setupXhrCapture() {
|
|
163
|
+
setupXhrCapture(reuseRequestStore = false) {
|
|
158
164
|
const configParser = ConfigParser.getInstance();
|
|
159
165
|
const outputDir = configParser.getOutputDir();
|
|
160
|
-
this.requestStore
|
|
166
|
+
if (!reuseRequestStore || !this.requestStore) {
|
|
167
|
+
this.requestStore = new RequestStore(outputDir);
|
|
168
|
+
}
|
|
161
169
|
const baseUrl = this.config.playwright.url;
|
|
162
170
|
this.xhrCapture = new XhrCapture(this.requestStore, baseUrl);
|
|
163
171
|
this.xhrCapture.attach(this.playwrightHelper.page);
|
|
@@ -189,21 +197,7 @@ class Explorer {
|
|
|
189
197
|
}
|
|
190
198
|
await this.connectOrLaunchBrowser();
|
|
191
199
|
const hasSession = this.options?.session && existsSync(this.options.session);
|
|
192
|
-
|
|
193
|
-
// CodeceptJS skips _createContextPage when sessions/storageState are involved, so we
|
|
194
|
-
// build contextOptions ourselves. Most keys share a name with Playwright's
|
|
195
|
-
// BrowserContextOptions and are copied as-is; `emulate` must be flattened, `basicAuth`
|
|
196
|
-
// renamed to `httpCredentials`, and `storageState` comes from the --session flag.
|
|
197
|
-
const contextOptions = {
|
|
198
|
-
...helperOptions,
|
|
199
|
-
};
|
|
200
|
-
if (helperOptions.emulate)
|
|
201
|
-
Object.assign(contextOptions, helperOptions.emulate);
|
|
202
|
-
if (helperOptions.basicAuth)
|
|
203
|
-
contextOptions.httpCredentials = helperOptions.basicAuth;
|
|
204
|
-
if (hasSession)
|
|
205
|
-
contextOptions.storageState = this.options.session;
|
|
206
|
-
await this.playwrightHelper._createContextPage(contextOptions);
|
|
200
|
+
await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
|
|
207
201
|
await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
|
|
208
202
|
this.setupXhrCapture();
|
|
209
203
|
if (hasSession) {
|
|
@@ -232,13 +226,70 @@ class Explorer {
|
|
|
232
226
|
}
|
|
233
227
|
await this.playwrightHelper._startBrowser();
|
|
234
228
|
}
|
|
229
|
+
createBrowserContextOptions() {
|
|
230
|
+
const helperOptions = this.playwrightHelper.options || {};
|
|
231
|
+
const contextOptions = {
|
|
232
|
+
...helperOptions,
|
|
233
|
+
};
|
|
234
|
+
if (helperOptions.emulate)
|
|
235
|
+
Object.assign(contextOptions, helperOptions.emulate);
|
|
236
|
+
if (helperOptions.basicAuth)
|
|
237
|
+
contextOptions.httpCredentials = helperOptions.basicAuth;
|
|
238
|
+
if (this.options?.session && existsSync(this.options.session))
|
|
239
|
+
contextOptions.storageState = this.options.session;
|
|
240
|
+
return contextOptions;
|
|
241
|
+
}
|
|
235
242
|
createAction() {
|
|
236
243
|
return new Action(this.actor, this.stateManager, this.playwrightRecorder);
|
|
237
244
|
}
|
|
245
|
+
async runWithBrowserRecovery(label, operation) {
|
|
246
|
+
if (!(await this.ensurePageAvailable())) {
|
|
247
|
+
throw new Error(`Browser page is unavailable before ${label}`);
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
return await operation();
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
if (!this.isFatalBrowserError(error))
|
|
254
|
+
throw error;
|
|
255
|
+
tag('warning').log(`${label}: browser page is unavailable, recovering...`);
|
|
256
|
+
let recovered = await this.recoverFromBrowserError();
|
|
257
|
+
if (!recovered)
|
|
258
|
+
recovered = await this.restartBrowser();
|
|
259
|
+
if (!recovered)
|
|
260
|
+
throw new BrowserRecoveryError(label, error, false);
|
|
261
|
+
if (!(await this.waitForUsablePageDom()))
|
|
262
|
+
throw new BrowserRecoveryError(label, error, true);
|
|
263
|
+
try {
|
|
264
|
+
return await operation();
|
|
265
|
+
}
|
|
266
|
+
catch (retryError) {
|
|
267
|
+
if (this.isFatalBrowserError(retryError)) {
|
|
268
|
+
throw new BrowserRecoveryError(label, retryError, true);
|
|
269
|
+
}
|
|
270
|
+
throw retryError;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
async capturePageState(opts = {}) {
|
|
275
|
+
return this.runWithBrowserRecovery('capturePageState', () => this.createAction().capturePageState(opts));
|
|
276
|
+
}
|
|
277
|
+
async capturePageWithScreenshot() {
|
|
278
|
+
return this.capturePageState({ includeScreenshot: true });
|
|
279
|
+
}
|
|
280
|
+
async executeAction(code) {
|
|
281
|
+
return this.runWithBrowserRecovery('executeAction', () => this.createAction().execute(code));
|
|
282
|
+
}
|
|
283
|
+
async attemptAction(code, originalMessage, experience = true) {
|
|
284
|
+
return this.runWithBrowserRecovery('attemptAction', () => this.createAction().attempt(code, originalMessage, experience));
|
|
285
|
+
}
|
|
238
286
|
getPlaywrightRecorder() {
|
|
239
287
|
return this.playwrightRecorder;
|
|
240
288
|
}
|
|
241
289
|
async visit(url) {
|
|
290
|
+
return this.runWithBrowserRecovery('visit', () => this.visitOnce(url));
|
|
291
|
+
}
|
|
292
|
+
async visitOnce(url) {
|
|
242
293
|
await this.closeOtherTabs();
|
|
243
294
|
const serializedUrl = JSON.stringify(url);
|
|
244
295
|
const currentState = this.stateManager.getCurrentState();
|
|
@@ -275,11 +326,13 @@ class Explorer {
|
|
|
275
326
|
return action;
|
|
276
327
|
}
|
|
277
328
|
async annotateElements() {
|
|
278
|
-
|
|
279
|
-
|
|
329
|
+
return this.runWithBrowserRecovery('annotateElements', async () => {
|
|
330
|
+
const { elements } = await annotatePageElements(this.playwrightHelper.page);
|
|
331
|
+
return elements;
|
|
332
|
+
});
|
|
280
333
|
}
|
|
281
334
|
async visuallyAnnotateElements(opts) {
|
|
282
|
-
return visuallyAnnotateContainers(this.playwrightHelper.page, opts?.containers || []);
|
|
335
|
+
return this.runWithBrowserRecovery('visuallyAnnotateElements', () => visuallyAnnotateContainers(this.playwrightHelper.page, opts?.containers || []));
|
|
283
336
|
}
|
|
284
337
|
async getEidxInContainer(containerCss) {
|
|
285
338
|
const page = this.playwrightHelper.page;
|
|
@@ -321,33 +374,120 @@ class Explorer {
|
|
|
321
374
|
await this.closeOtherTabs();
|
|
322
375
|
await this.playwrightHelper.page.reload();
|
|
323
376
|
}
|
|
377
|
+
resolveBrowserUrl(url) {
|
|
378
|
+
if (!url)
|
|
379
|
+
return null;
|
|
380
|
+
try {
|
|
381
|
+
return new URL(url).toString();
|
|
382
|
+
}
|
|
383
|
+
catch { }
|
|
384
|
+
const baseUrl = this.config.playwright?.url || this.config.web?.url;
|
|
385
|
+
if (!baseUrl)
|
|
386
|
+
return null;
|
|
387
|
+
try {
|
|
388
|
+
return new URL(url, baseUrl).toString();
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
324
394
|
isFatalBrowserError(error) {
|
|
325
|
-
|
|
326
|
-
return FATAL_BROWSER_ERRORS.test(msg);
|
|
395
|
+
return isFatalBrowserError(error);
|
|
327
396
|
}
|
|
328
397
|
async recoverFromBrowserError() {
|
|
329
398
|
try {
|
|
330
|
-
|
|
399
|
+
if (!this.playwrightHelper?.page || this.playwrightHelper.page.isClosed?.()) {
|
|
400
|
+
const context = this.playwrightHelper?.browserContext;
|
|
401
|
+
if (!context)
|
|
402
|
+
return await this.restartBrowser();
|
|
403
|
+
const page = await context.newPage();
|
|
404
|
+
await page.bringToFront();
|
|
405
|
+
await this.playwrightHelper._setPage(page);
|
|
406
|
+
this.bindFrameNavigated(page);
|
|
407
|
+
if (this.xhrCapture) {
|
|
408
|
+
this.xhrCapture.attach(this.playwrightHelper.page);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const url = this.resolveBrowserUrl(this.stateManager.getCurrentState()?.url);
|
|
331
412
|
if (url) {
|
|
332
413
|
tag('warning').log(`Browser error detected, recovering by navigating to ${url}`);
|
|
333
414
|
await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
334
|
-
return
|
|
415
|
+
return this.waitForUsablePageDom();
|
|
335
416
|
}
|
|
336
417
|
tag('warning').log('Browser error detected, reloading page');
|
|
337
418
|
await this.playwrightHelper.page.reload({ waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
338
|
-
return
|
|
419
|
+
return this.waitForUsablePageDom();
|
|
339
420
|
}
|
|
340
421
|
catch (err) {
|
|
341
422
|
tag('error').log(`Browser recovery failed: ${err instanceof Error ? err.message : err}`);
|
|
342
423
|
return false;
|
|
343
424
|
}
|
|
344
425
|
}
|
|
426
|
+
async restartBrowser() {
|
|
427
|
+
if (!this.playwrightHelper)
|
|
428
|
+
return false;
|
|
429
|
+
const url = this.resolveBrowserUrl(this.stateManager.getCurrentState()?.url);
|
|
430
|
+
try {
|
|
431
|
+
if (this.xhrCapture && this.playwrightHelper.page) {
|
|
432
|
+
this.xhrCapture.detach(this.playwrightHelper.page);
|
|
433
|
+
}
|
|
434
|
+
await this.playwrightRecorder.stop();
|
|
435
|
+
if (this.playwrightHelper.browserContext) {
|
|
436
|
+
await this.playwrightHelper.browserContext.close().catch((err) => {
|
|
437
|
+
debugLog('Failed to close browser context before restart:', err);
|
|
438
|
+
});
|
|
439
|
+
this.playwrightHelper.browserContext = null;
|
|
440
|
+
}
|
|
441
|
+
if (!this.isSharedBrowser) {
|
|
442
|
+
await this.playwrightHelper._stopBrowser().catch((err) => {
|
|
443
|
+
debugLog('Failed to stop browser before restart:', err);
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
await this.connectOrLaunchBrowser();
|
|
447
|
+
await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
|
|
448
|
+
await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
|
|
449
|
+
this.setupXhrCapture(true);
|
|
450
|
+
this.listenToStateChanged();
|
|
451
|
+
if (url) {
|
|
452
|
+
await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
453
|
+
if (!(await this.waitForUsablePageDom()))
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
tag('success').log('Browser restarted');
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
catch (err) {
|
|
460
|
+
tag('error').log(`Browser restart failed: ${err instanceof Error ? err.message : err}`);
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
345
464
|
async switchToMainFrame() {
|
|
346
465
|
if (this.playwrightHelper.frame) {
|
|
347
466
|
debugLog('Switching to main frame');
|
|
348
467
|
await this.playwrightHelper.switchTo();
|
|
349
468
|
}
|
|
350
469
|
}
|
|
470
|
+
async waitForUsablePageDom() {
|
|
471
|
+
const page = this.playwrightHelper?.page;
|
|
472
|
+
if (!page)
|
|
473
|
+
return false;
|
|
474
|
+
await page.waitForLoadState?.('domcontentloaded', { timeout: 5000 }).catch(() => { });
|
|
475
|
+
if (page.waitForFunction) {
|
|
476
|
+
const hasUsableDom = await page
|
|
477
|
+
.waitForFunction(() => {
|
|
478
|
+
const body = document.body;
|
|
479
|
+
if (!body)
|
|
480
|
+
return false;
|
|
481
|
+
return body.children.length > 0 || body.textContent?.trim().length > 0;
|
|
482
|
+
}, undefined, { timeout: 5000 })
|
|
483
|
+
.then(() => true)
|
|
484
|
+
.catch(() => false);
|
|
485
|
+
if (!hasUsableDom)
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
await page.waitForLoadState?.('networkidle', { timeout: 3000 }).catch(() => { });
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
351
491
|
async isInsideIframe() {
|
|
352
492
|
if (this.playwrightHelper.frame)
|
|
353
493
|
return true;
|
|
@@ -465,9 +605,12 @@ class Explorer {
|
|
|
465
605
|
}
|
|
466
606
|
async startTest(test) {
|
|
467
607
|
this._activeTest = test;
|
|
608
|
+
test.start();
|
|
468
609
|
await this.reporter.reportTestStart(test);
|
|
469
610
|
await this.closeOtherTabs();
|
|
470
611
|
this.otherTabs = [];
|
|
612
|
+
if (!(await this.ensurePageAvailable()))
|
|
613
|
+
return false;
|
|
471
614
|
const codeceptjsTest = toCodeceptjsTest(test);
|
|
472
615
|
const stepHandler = (step, status, error, log) => {
|
|
473
616
|
if (!step.toCode)
|
|
@@ -482,12 +625,7 @@ class Explorer {
|
|
|
482
625
|
const lastScreenshot = ActionResult.fromState(this.stateManager.getCurrentState()).screenshotFile;
|
|
483
626
|
test.setActiveNoteScreenshot(lastScreenshot);
|
|
484
627
|
};
|
|
485
|
-
|
|
486
|
-
const dialogType = dialog.type();
|
|
487
|
-
const dialogMessage = dialog.message();
|
|
488
|
-
test.addNote(`Native dialog ${dialogType} appeared: ${dialogMessage}. Accepted automatically`);
|
|
489
|
-
};
|
|
490
|
-
this.playwrightHelper?.page?.on('dialog', dialogHandler);
|
|
628
|
+
this.watchActiveTestPage();
|
|
491
629
|
codeceptjs.event.dispatcher.emit('test.before', codeceptjsTest);
|
|
492
630
|
codeceptjs.event.dispatcher.emit('test.start', codeceptjsTest);
|
|
493
631
|
codeceptjs.event.dispatcher.on('step.passed', (step) => stepHandler(step, 'passed'));
|
|
@@ -497,10 +635,96 @@ class Explorer {
|
|
|
497
635
|
codeceptjs.event.dispatcher.on('test.after', () => {
|
|
498
636
|
codeceptjs.event.dispatcher.off('step.passed', stepHandler);
|
|
499
637
|
codeceptjs.event.dispatcher.off('step.failed', stepHandler);
|
|
500
|
-
this.
|
|
638
|
+
this.unwatchActiveTestPages();
|
|
501
639
|
});
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
642
|
+
async ensurePageAvailable() {
|
|
643
|
+
const page = this.playwrightHelper?.page;
|
|
644
|
+
if (page && !page.isClosed?.()) {
|
|
645
|
+
this.watchActiveTestPage(page);
|
|
646
|
+
return true;
|
|
647
|
+
}
|
|
648
|
+
const recovered = await this.recoverFromBrowserError();
|
|
649
|
+
if (!recovered)
|
|
650
|
+
return false;
|
|
651
|
+
this.watchActiveTestPage();
|
|
652
|
+
return true;
|
|
653
|
+
}
|
|
654
|
+
async ensureActiveTestPageAvailable() {
|
|
655
|
+
return this.ensurePageAvailable();
|
|
656
|
+
}
|
|
657
|
+
async handleExecutionError(error) {
|
|
658
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
659
|
+
tag('error').log(`Browser execution error: ${message}`);
|
|
660
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
661
|
+
return {
|
|
662
|
+
action: 'stop',
|
|
663
|
+
message,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
if (error instanceof BrowserRecoveryError) {
|
|
667
|
+
return {
|
|
668
|
+
action: 'stop',
|
|
669
|
+
recovered: error.recovered,
|
|
670
|
+
message: error.message,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
if (!this.isFatalBrowserError(error)) {
|
|
674
|
+
return {
|
|
675
|
+
action: 'continue',
|
|
676
|
+
message: `Previous execution error: ${message}. Investigate the current state and choose a different approach.`,
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
let recovered = await this.recoverFromBrowserError();
|
|
680
|
+
if (!recovered)
|
|
681
|
+
recovered = await this.restartBrowser();
|
|
682
|
+
if (!recovered) {
|
|
683
|
+
return {
|
|
684
|
+
action: 'stop',
|
|
685
|
+
recovered: false,
|
|
686
|
+
message: `Browser could not be recovered after fatal error: ${message}`,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
this.watchActiveTestPage();
|
|
690
|
+
return {
|
|
691
|
+
action: 'continue',
|
|
692
|
+
recovered: true,
|
|
693
|
+
message: dedent `
|
|
694
|
+
Browser was recovered after a fatal page error.
|
|
695
|
+
Continue from the restored page.
|
|
696
|
+
The interrupted browser action is not product evidence.
|
|
697
|
+
Inspect the restored page and retry the current step when it is still required.
|
|
698
|
+
`,
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
watchActiveTestPage(page = this.playwrightHelper?.page) {
|
|
702
|
+
if (!this._activeTest)
|
|
703
|
+
return;
|
|
704
|
+
if (!page)
|
|
705
|
+
return;
|
|
706
|
+
if (this.observedTestPages.has(page))
|
|
707
|
+
return;
|
|
708
|
+
this.testPageErrorHandler ||= (err) => {
|
|
709
|
+
this._activeTest?.addNote(`Console error: ${err.message}`, TestResult.FAILED);
|
|
710
|
+
};
|
|
711
|
+
this.testConsoleHandler ||= (msg) => {
|
|
712
|
+
if (msg.type() !== 'error')
|
|
713
|
+
return;
|
|
714
|
+
this._activeTest?.addNote(`Console error: ${msg.text()}`, TestResult.FAILED);
|
|
715
|
+
};
|
|
716
|
+
this.testDialogHandler ||= (dialog) => {
|
|
717
|
+
const dialogType = dialog.type();
|
|
718
|
+
const dialogMessage = dialog.message();
|
|
719
|
+
this._activeTest?.addNote(`Native dialog ${dialogType} appeared: ${dialogMessage}. Accepted automatically`);
|
|
720
|
+
};
|
|
721
|
+
page.on('pageerror', this.testPageErrorHandler);
|
|
722
|
+
page.on('console', this.testConsoleHandler);
|
|
723
|
+
page.on('dialog', this.testDialogHandler);
|
|
724
|
+
this.observedTestPages.add(page);
|
|
502
725
|
}
|
|
503
726
|
async stopTest(test, meta) {
|
|
727
|
+
this.unwatchActiveTestPages();
|
|
504
728
|
this._activeTest = null;
|
|
505
729
|
const lastScreenshot = this.stateManager.getCurrentState()?.screenshotFile;
|
|
506
730
|
if (lastScreenshot) {
|
|
@@ -524,6 +748,17 @@ class Explorer {
|
|
|
524
748
|
codeceptjs.event.dispatcher.emit('test.finish', codeceptjsTest);
|
|
525
749
|
codeceptjs.event.dispatcher.emit('test.after', codeceptjsTest);
|
|
526
750
|
}
|
|
751
|
+
unwatchActiveTestPages() {
|
|
752
|
+
for (const page of this.observedTestPages) {
|
|
753
|
+
if (this.testPageErrorHandler)
|
|
754
|
+
page.off('pageerror', this.testPageErrorHandler);
|
|
755
|
+
if (this.testConsoleHandler)
|
|
756
|
+
page.off('console', this.testConsoleHandler);
|
|
757
|
+
if (this.testDialogHandler)
|
|
758
|
+
page.off('dialog', this.testDialogHandler);
|
|
759
|
+
}
|
|
760
|
+
this.observedTestPages.clear();
|
|
761
|
+
}
|
|
527
762
|
async hasPlaywrightLocator(locatorFn, opts = {}) {
|
|
528
763
|
try {
|
|
529
764
|
const pwLocator = locatorFn(this.playwrightHelper.page);
|
|
@@ -584,7 +819,7 @@ class Explorer {
|
|
|
584
819
|
const newPage = await context.newPage();
|
|
585
820
|
await oldPage.close();
|
|
586
821
|
await newPage.bringToFront();
|
|
587
|
-
this.playwrightHelper.
|
|
822
|
+
await this.playwrightHelper._setPage(newPage);
|
|
588
823
|
this.otherTabs = [];
|
|
589
824
|
this.bindFrameNavigated(newPage);
|
|
590
825
|
if (this.xhrCapture) {
|
|
@@ -609,7 +844,7 @@ class Explorer {
|
|
|
609
844
|
debugLog(`Closed extra tab: ${await page.url()}`);
|
|
610
845
|
}
|
|
611
846
|
await firstPage.bringToFront();
|
|
612
|
-
this.playwrightHelper.
|
|
847
|
+
await this.playwrightHelper._setPage(firstPage);
|
|
613
848
|
debugLog(`Cleaned up tabs, now focused on: ${await firstPage.url()}`);
|
|
614
849
|
}
|
|
615
850
|
}
|
package/dist/src/stats.js
CHANGED
|
@@ -39,6 +39,22 @@ export class Stats {
|
|
|
39
39
|
}
|
|
40
40
|
return String(num);
|
|
41
41
|
}
|
|
42
|
+
static modelsTable(roleModels) {
|
|
43
|
+
const usedModels = Object.entries(Stats.models).filter(([, tokens]) => tokens.total > 0);
|
|
44
|
+
if (usedModels.length === 0)
|
|
45
|
+
return '';
|
|
46
|
+
const rolesByModel = {};
|
|
47
|
+
for (const [role, model] of Object.entries(roleModels)) {
|
|
48
|
+
if (!rolesByModel[model])
|
|
49
|
+
rolesByModel[model] = [];
|
|
50
|
+
rolesByModel[model].push(role);
|
|
51
|
+
}
|
|
52
|
+
const rows = usedModels.map(([model, tokens]) => {
|
|
53
|
+
const roles = rolesByModel[model]?.join(', ') || '-';
|
|
54
|
+
return `| ${roles} | ${model} | ${Stats.humanizeTokens(tokens.total)} |`;
|
|
55
|
+
});
|
|
56
|
+
return ['## Models', '', '| Role | Model | Tokens |', '| --- | --- | --- |', ...rows].join('\n');
|
|
57
|
+
}
|
|
42
58
|
static hasActivity() {
|
|
43
59
|
if (Stats.tests > 0 || Stats.plans > 0 || Stats.researches > 0)
|
|
44
60
|
return true;
|
package/dist/src/utils/aria.js
CHANGED
|
@@ -334,6 +334,56 @@ const detectRenames = (prev, curr, prevTotals, currTotals) => {
|
|
|
334
334
|
}
|
|
335
335
|
return { added, removed };
|
|
336
336
|
};
|
|
337
|
+
// Interactive controls keep a stable role+name across a state flip; only an ARIA state
|
|
338
|
+
// attribute changes. Report those flips on their own line so the model always sees
|
|
339
|
+
// "now checked / now collapsed", in both directions, regardless of other page churn.
|
|
340
|
+
const STATE_WORDS = {
|
|
341
|
+
checked: { on: 'checked', off: 'unchecked' },
|
|
342
|
+
selected: { on: 'selected', off: 'unselected' },
|
|
343
|
+
pressed: { on: 'pressed', off: 'unpressed' },
|
|
344
|
+
expanded: { on: 'expanded', off: 'collapsed' },
|
|
345
|
+
};
|
|
346
|
+
const STATE_ATTRS = Object.keys(STATE_WORDS);
|
|
347
|
+
const stateWord = (attr, value) => {
|
|
348
|
+
if (attr === 'checked' && value === 'mixed')
|
|
349
|
+
return 'partially checked';
|
|
350
|
+
const words = STATE_WORDS[attr];
|
|
351
|
+
if (value === true || value === 'true')
|
|
352
|
+
return words.on;
|
|
353
|
+
return words.off;
|
|
354
|
+
};
|
|
355
|
+
// Pair entries by path; when role and name match but a state attr differs, it's a toggle.
|
|
356
|
+
const detectToggles = (prev, curr) => {
|
|
357
|
+
const toggled = [];
|
|
358
|
+
const togglePaths = new Set();
|
|
359
|
+
const currByPath = new Map(curr.map((e) => [e.path, e]));
|
|
360
|
+
for (const before of prev) {
|
|
361
|
+
const after = currByPath.get(before.path);
|
|
362
|
+
if (!after)
|
|
363
|
+
continue;
|
|
364
|
+
if (before.entry.role !== after.entry.role)
|
|
365
|
+
continue;
|
|
366
|
+
if (before.entry.name !== after.entry.name)
|
|
367
|
+
continue;
|
|
368
|
+
const transitions = [];
|
|
369
|
+
for (const attr of STATE_ATTRS) {
|
|
370
|
+
const was = stateWord(attr, before.entry[attr]);
|
|
371
|
+
const now = stateWord(attr, after.entry[attr]);
|
|
372
|
+
if (was === now)
|
|
373
|
+
continue;
|
|
374
|
+
transitions.push(`${was} -> ${now}`);
|
|
375
|
+
}
|
|
376
|
+
if (transitions.length === 0)
|
|
377
|
+
continue;
|
|
378
|
+
togglePaths.add(before.path);
|
|
379
|
+
let label = String(after.entry.role);
|
|
380
|
+
const name = after.entry.name;
|
|
381
|
+
if (typeof name === 'string' && name.trim())
|
|
382
|
+
label += ` "${name.trim()}"`;
|
|
383
|
+
toggled.push(`${label}: ${transitions.join(', ')}`);
|
|
384
|
+
}
|
|
385
|
+
return { toggled, togglePaths };
|
|
386
|
+
};
|
|
337
387
|
const TOP_DIFF_ITEMS = 10;
|
|
338
388
|
const formatDiffSection = (label, items) => {
|
|
339
389
|
const summary = countBy(items);
|
|
@@ -357,10 +407,17 @@ const formatDiffSection = (label, items) => {
|
|
|
357
407
|
}
|
|
358
408
|
return lines;
|
|
359
409
|
};
|
|
360
|
-
const formatDiff = (added, removed) => {
|
|
361
|
-
if (added.length === 0 && removed.length === 0)
|
|
410
|
+
const formatDiff = (added, removed, toggled) => {
|
|
411
|
+
if (added.length === 0 && removed.length === 0 && toggled.length === 0)
|
|
362
412
|
return null;
|
|
363
|
-
|
|
413
|
+
const sections = ['ariaDiff:'];
|
|
414
|
+
if (toggled.length > 0) {
|
|
415
|
+
sections.push(' toggled:');
|
|
416
|
+
for (const line of toggled)
|
|
417
|
+
sections.push(` - ${line}`);
|
|
418
|
+
}
|
|
419
|
+
sections.push(...formatDiffSection('added', added), ...formatDiffSection('removed', removed));
|
|
420
|
+
return sections.join('\n');
|
|
364
421
|
};
|
|
365
422
|
const CLOSE_OVERLAY_BUTTON_RE = /^close\s+(modal|dialog|popup|drawer|panel|sheet)\b/i;
|
|
366
423
|
const findOverlayByCloseButton = (nodeList) => {
|
|
@@ -424,13 +481,16 @@ export const diffAriaSnapshots = (previous, current) => {
|
|
|
424
481
|
tree = dropEmpty(tree);
|
|
425
482
|
return flatten(tree);
|
|
426
483
|
};
|
|
427
|
-
const
|
|
428
|
-
const
|
|
484
|
+
const prevAll = flat(previous);
|
|
485
|
+
const currAll = flat(current);
|
|
486
|
+
const { toggled, togglePaths } = detectToggles(prevAll, currAll);
|
|
487
|
+
const prev = prevAll.filter((e) => !togglePaths.has(e.path));
|
|
488
|
+
const curr = currAll.filter((e) => !togglePaths.has(e.path));
|
|
429
489
|
const prevTotals = countBy(prev.map((e) => e.summary));
|
|
430
490
|
const currTotals = countBy(curr.map((e) => e.summary));
|
|
431
491
|
const byCount = diffByCount(prevTotals, currTotals);
|
|
432
492
|
const renames = detectRenames(prev, curr, prevTotals, currTotals);
|
|
433
|
-
return formatDiff([...byCount.added, ...renames.added], [...byCount.removed, ...renames.removed]);
|
|
493
|
+
return formatDiff([...byCount.added, ...renames.added], [...byCount.removed, ...renames.removed], toggled);
|
|
434
494
|
};
|
|
435
495
|
export const detectFocusArea = (snapshot) => {
|
|
436
496
|
let tree = parseSnapshot(snapshot);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Playwright and CodeceptJS surface browser/page disposal as plain Error objects,
|
|
2
|
+
// not typed exceptions. Keep those external message markers in one adapter so
|
|
3
|
+
// recovery decisions are not duplicated across agents/actions.
|
|
4
|
+
const FATAL_BROWSER_ERROR_MARKERS = ['Frame was detached', 'Target closed', 'Target page, context or browser has been closed', 'Execution context was destroyed', 'Protocol error', 'Session closed'];
|
|
5
|
+
export class BrowserRecoveryError extends Error {
|
|
6
|
+
originalError;
|
|
7
|
+
recovered;
|
|
8
|
+
constructor(label, originalError, recovered) {
|
|
9
|
+
super(`${label} failed ${recovered ? 'after browser recovery' : 'because browser could not be recovered'}: ${browserErrorMessage(originalError)}`);
|
|
10
|
+
this.originalError = originalError;
|
|
11
|
+
this.recovered = recovered;
|
|
12
|
+
this.name = 'BrowserRecoveryError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function isFatalBrowserError(error) {
|
|
16
|
+
if (error instanceof BrowserRecoveryError)
|
|
17
|
+
return true;
|
|
18
|
+
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
19
|
+
return FATAL_BROWSER_ERROR_MARKERS.some((marker) => message.includes(marker.toLowerCase()));
|
|
20
|
+
}
|
|
21
|
+
export function browserErrorMessage(error) {
|
|
22
|
+
return error instanceof Error ? error.message : String(error);
|
|
23
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { ActionResult } from '../action-result.js';
|
|
1
2
|
import { isBodyEmpty } from './html.js';
|
|
2
3
|
const HTTP_ERRORS = ['400 Bad Request', '401 Unauthorized', '403 Forbidden', '404 Not Found', '405 Method Not Allowed', '408 Request Timeout', '500 Internal Server Error', '502 Bad Gateway', '503 Service Unavailable', '504 Gateway Timeout'];
|
|
3
4
|
const SMALL_PAGE_THRESHOLD = 500;
|
|
4
5
|
const LOADING_WORD = /\bloading\b/i;
|
|
5
6
|
export function detectPageCondition(actionResult) {
|
|
7
|
+
if (actionResult.httpStatus && actionResult.httpStatus >= 400)
|
|
8
|
+
return 'error';
|
|
6
9
|
const headingFields = [actionResult.title, actionResult.h1, actionResult.h2].filter(Boolean);
|
|
7
10
|
for (const field of headingFields) {
|
|
8
11
|
for (const error of HTTP_ERRORS) {
|
|
@@ -29,13 +32,25 @@ export function detectPageCondition(actionResult) {
|
|
|
29
32
|
export function isErrorPage(actionResult) {
|
|
30
33
|
return detectPageCondition(actionResult) === 'error';
|
|
31
34
|
}
|
|
35
|
+
export function getStateErrorPageError(state) {
|
|
36
|
+
if (!state)
|
|
37
|
+
return null;
|
|
38
|
+
const actionResult = ActionResult.fromState(state);
|
|
39
|
+
if (!isErrorPage(actionResult))
|
|
40
|
+
return null;
|
|
41
|
+
return new ErrorPageError(actionResult.url, actionResult.title, actionResult.httpStatus);
|
|
42
|
+
}
|
|
32
43
|
export class ErrorPageError extends Error {
|
|
33
44
|
url;
|
|
34
45
|
title;
|
|
35
|
-
|
|
36
|
-
|
|
46
|
+
httpStatus;
|
|
47
|
+
constructor(url, title, httpStatus) {
|
|
48
|
+
const status = httpStatus ? `HTTP ${httpStatus}` : '';
|
|
49
|
+
const details = [status, title].filter(Boolean).join(', ');
|
|
50
|
+
super(`Error page detected at ${url}${details ? ` (${details})` : ''}`);
|
|
37
51
|
this.url = url;
|
|
38
52
|
this.title = title;
|
|
53
|
+
this.httpStatus = httpStatus;
|
|
39
54
|
this.name = 'ErrorPageError';
|
|
40
55
|
}
|
|
41
56
|
}
|
package/dist/src/utils/logger.js
CHANGED
|
@@ -72,7 +72,7 @@ class ConsoleDestination {
|
|
|
72
72
|
if (entry.type === 'step' && !this.verboseMode && this.recentSteps.shouldSuppress(entry.content))
|
|
73
73
|
return;
|
|
74
74
|
let content = entry.content;
|
|
75
|
-
if (entry.type === 'multiline') {
|
|
75
|
+
if (entry.type === 'multiline' || entry.type === 'details') {
|
|
76
76
|
const cleaned = stripAnsi(dedent(entry.content));
|
|
77
77
|
const parsed = parseMarkdownToTerminal(cleaned);
|
|
78
78
|
content = parsed;
|
|
@@ -280,7 +280,7 @@ class CaptainDestination {
|
|
|
280
280
|
}
|
|
281
281
|
stopCapture() {
|
|
282
282
|
this.capturing = false;
|
|
283
|
-
const logs = this.entries.filter((e) => e.type !== 'debug' && e.type !== 'html' && e.type !== 'multiline' && e.type !== 'operation').map((e) => `[${e.type}] ${e.content}`);
|
|
283
|
+
const logs = this.entries.filter((e) => e.type !== 'debug' && e.type !== 'html' && e.type !== 'multiline' && e.type !== 'details' && e.type !== 'operation').map((e) => `[${e.type}] ${e.content}`);
|
|
284
284
|
this.entries = [];
|
|
285
285
|
return logs;
|
|
286
286
|
}
|