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.
Files changed (68) hide show
  1. package/README.md +83 -245
  2. package/bin/explorbot-cli.ts +1 -0
  3. package/dist/bin/explorbot-cli.js +1 -0
  4. package/dist/package.json +8 -6
  5. package/dist/rules/navigator/verification-actions.md +2 -0
  6. package/dist/src/action-result.js +2 -0
  7. package/dist/src/action.js +88 -7
  8. package/dist/src/ai/captain/file-tools.js +100 -0
  9. package/dist/src/ai/captain/idle-mode.js +70 -6
  10. package/dist/src/ai/captain/web-mode.js +36 -6
  11. package/dist/src/ai/captain.js +87 -19
  12. package/dist/src/ai/fisherman.js +14 -3
  13. package/dist/src/ai/historian/screencast.js +11 -2
  14. package/dist/src/ai/navigator.js +5 -2
  15. package/dist/src/ai/pilot.js +52 -9
  16. package/dist/src/ai/planner.js +16 -5
  17. package/dist/src/ai/provider.js +53 -18
  18. package/dist/src/ai/researcher/coordinates.js +2 -3
  19. package/dist/src/ai/researcher/deep-analysis.js +3 -4
  20. package/dist/src/ai/researcher/locators.js +1 -2
  21. package/dist/src/ai/researcher.js +24 -19
  22. package/dist/src/ai/rules.js +44 -0
  23. package/dist/src/ai/task-agent.js +1 -0
  24. package/dist/src/ai/tester.js +161 -46
  25. package/dist/src/ai/tools.js +84 -8
  26. package/dist/src/commands/explore-command.js +6 -1
  27. package/dist/src/components/LogPane.js +4 -3
  28. package/dist/src/explorbot.js +7 -2
  29. package/dist/src/explorer.js +270 -35
  30. package/dist/src/stats.js +16 -0
  31. package/dist/src/utils/aria.js +66 -6
  32. package/dist/src/utils/browser-errors.js +23 -0
  33. package/dist/src/utils/error-page.js +17 -2
  34. package/dist/src/utils/logger.js +2 -2
  35. package/package.json +8 -6
  36. package/rules/navigator/verification-actions.md +2 -0
  37. package/src/action-result.ts +2 -0
  38. package/src/action.ts +83 -7
  39. package/src/ai/captain/file-tools.ts +126 -0
  40. package/src/ai/captain/idle-mode.ts +72 -6
  41. package/src/ai/captain/mixin.ts +1 -1
  42. package/src/ai/captain/web-mode.ts +40 -5
  43. package/src/ai/captain.ts +94 -20
  44. package/src/ai/fisherman.ts +14 -3
  45. package/src/ai/historian/screencast.ts +11 -2
  46. package/src/ai/navigator.ts +6 -2
  47. package/src/ai/pilot.ts +53 -9
  48. package/src/ai/planner.ts +16 -5
  49. package/src/ai/provider.ts +51 -19
  50. package/src/ai/researcher/coordinates.ts +2 -3
  51. package/src/ai/researcher/deep-analysis.ts +3 -4
  52. package/src/ai/researcher/locators.ts +1 -2
  53. package/src/ai/researcher.ts +25 -19
  54. package/src/ai/rules.ts +46 -0
  55. package/src/ai/task-agent.ts +1 -1
  56. package/src/ai/tester.ts +175 -48
  57. package/src/ai/tools.ts +97 -8
  58. package/src/commands/explore-command.ts +6 -1
  59. package/src/components/LogPane.tsx +4 -3
  60. package/src/config.ts +1 -0
  61. package/src/explorbot.ts +6 -2
  62. package/src/explorer.ts +295 -38
  63. package/src/state-manager.ts +2 -0
  64. package/src/stats.ts +18 -0
  65. package/src/utils/aria.ts +63 -6
  66. package/src/utils/browser-errors.ts +25 -0
  67. package/src/utils/error-page.ts +16 -3
  68. package/src/utils/logger.ts +3 -3
@@ -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 = new RequestStore(outputDir);
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
- const helperOptions = this.playwrightHelper.options || {};
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
- const { elements } = await annotatePageElements(this.playwrightHelper.page);
279
- return elements;
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
- const msg = error instanceof Error ? error.message : String(error);
326
- return FATAL_BROWSER_ERRORS.test(msg);
395
+ return isFatalBrowserError(error);
327
396
  }
328
397
  async recoverFromBrowserError() {
329
398
  try {
330
- const url = this.stateManager.getCurrentState()?.url;
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 true;
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 true;
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
- const dialogHandler = (dialog) => {
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.playwrightHelper?.page?.off('dialog', dialogHandler);
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.page = newPage;
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.page = firstPage;
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;
@@ -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
- return ['ariaDiff:', ...formatDiffSection('added', added), ...formatDiffSection('removed', removed)].join('\n');
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 prev = flat(previous);
428
- const curr = flat(current);
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
- constructor(url, title) {
36
- super(`Error page detected at ${url}${title ? ` (${title})` : ''}`);
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
  }
@@ -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
  }