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
@@ -121,6 +121,7 @@ const LogPane: React.FC<LogPaneProps> = React.memo(({ verboseMode }) => {
121
121
  case 'step':
122
122
  return { color: 'cyan' as const, dimColor: true };
123
123
  case 'multiline':
124
+ case 'details':
124
125
  return { color: 'gray' as const, dimColor: true };
125
126
  case 'html':
126
127
  return { color: 'gray' as const };
@@ -143,16 +144,16 @@ const LogPane: React.FC<LogPaneProps> = React.memo(({ verboseMode }) => {
143
144
  }
144
145
  const styles = getLogStyles(log.type);
145
146
 
146
- if (log.type === 'multiline') {
147
+ if (log.type === 'multiline' || log.type === 'details') {
147
148
  const cleaned = stripAnsi(dedent(log.content));
148
149
  const parsed = parseMarkdownToTerminal(cleaned);
149
150
  const lines = parsed.split('\n');
150
151
  const maxLines = log.maxLines || 16;
151
- const truncated = lines.length > maxLines ? `${lines.slice(0, maxLines).join('\n')}\n... (${lines.length - maxLines} more lines)` : parsed;
152
+ const content = log.type === 'details' ? parsed : lines.length > maxLines ? `${lines.slice(0, maxLines).join('\n')}\n... (${lines.length - maxLines} more lines)` : parsed;
152
153
  return (
153
154
  <Box key={index} borderStyle="classic" borderLeft={false} borderRight={false} marginY={1} padding={1} borderColor="dim" overflow="hidden">
154
155
  <Text color="gray" dimColor>
155
- {truncated}
156
+ {content}
156
157
  </Text>
157
158
  </Box>
158
159
  );
package/src/config.ts CHANGED
@@ -59,6 +59,7 @@ interface AgentConfig extends HooksConfig {
59
59
  systemPrompt?: string;
60
60
  rules?: RuleEntry[];
61
61
  providerOptions?: Record<string, any>;
62
+ reasoning?: 'provider-default' | 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
62
63
  }
63
64
 
64
65
  interface ResearcherAgentConfig extends AgentConfig {
package/src/explorbot.ts CHANGED
@@ -25,12 +25,13 @@ import { ExperienceTracker } from './experience-tracker.ts';
25
25
  import Explorer from './explorer.ts';
26
26
  import { KnowledgeTracker } from './knowledge-tracker.ts';
27
27
  import { WebPageState } from './state-manager.ts';
28
+ import { Stats } from './stats.ts';
28
29
  import type { Suite } from './suite.ts';
29
30
  import { Plan, type Test } from './test-plan.ts';
30
- import { parsePlansFromMarkdown } from './utils/test-plan-markdown.ts';
31
31
  import { setVerboseMode, tag } from './utils/logger.ts';
32
32
  import { relativeToCwd } from './utils/next-steps.ts';
33
33
  import { sanitizeFilename } from './utils/strings.ts';
34
+ import { parsePlansFromMarkdown } from './utils/test-plan-markdown.ts';
34
35
 
35
36
  export interface ExplorBotOptions {
36
37
  from?: string;
@@ -494,7 +495,10 @@ export class ExplorBot {
494
495
 
495
496
  const reporter = this.explorer?.getReporter();
496
497
  if (reporter?.isEnabled()) {
497
- await reporter.setRunDescription(markdown);
498
+ let description = markdown;
499
+ const modelsTable = Stats.modelsTable(this.provider.getConfiguredModels());
500
+ if (modelsTable) description = `${markdown}\n\n${modelsTable}`;
501
+ await reporter.setRunDescription(description);
498
502
  }
499
503
 
500
504
  this.lastReportedTestCount = tests.length;
package/src/explorer.ts CHANGED
@@ -2,13 +2,14 @@ import { existsSync, mkdirSync } from 'node:fs';
2
2
  import path, { join } 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';
9
+ import type { BrowserContextOptions } from 'playwright';
8
10
  import { ActionResult } from './action-result.ts';
9
11
  import Action from './action.js';
10
12
  import { AIProvider } from './ai/provider.js';
11
- import type { BrowserContextOptions } from 'playwright';
12
13
  import { visuallyAnnotateContainers } from './ai/researcher/coordinates.ts';
13
14
  import { RequestStore } from './api/request-store.ts';
14
15
  import { XhrCapture } from './api/xhr-capture.ts';
@@ -19,10 +20,11 @@ import { KnowledgeTracker } from './knowledge-tracker.js';
19
20
  import { PlaywrightRecorder } from './playwright-recorder.ts';
20
21
  import { Reporter } from './reporter.ts';
21
22
  import { StateManager } from './state-manager.js';
22
- import { Test } from './test-plan.ts';
23
+ import { Test, TestResult } from './test-plan.ts';
23
24
  import { ELEMENT_EXTRACTION_CONFIG, getElementDataExtractorSource } from './utils/html.ts';
24
25
  import { createDebug, log, tag } from './utils/logger.js';
25
26
  import { WebElement } from './utils/web-element.ts';
27
+ import { BrowserRecoveryError, isFatalBrowserError } from './utils/browser-errors.ts';
26
28
 
27
29
  declare global {
28
30
  namespace NodeJS {
@@ -39,7 +41,6 @@ declare namespace CodeceptJS {
39
41
  }
40
42
 
41
43
  const debugLog = createDebug('explorbot:explorer');
42
- const FATAL_BROWSER_ERRORS = /Frame was detached|Target closed|Execution context was destroyed|Protocol error|Session closed/i;
43
44
  const RECOVERABLE_NAVIGATION_ERRORS = /net::ERR_ABORTED|page\.screenshot.*Timeout|waiting for fonts to load/i;
44
45
 
45
46
  interface TabInfo {
@@ -47,6 +48,12 @@ interface TabInfo {
47
48
  title: string;
48
49
  }
49
50
 
51
+ interface BrowserExecutionErrorResult {
52
+ action: 'continue' | 'stop';
53
+ message: string;
54
+ recovered?: boolean;
55
+ }
56
+
50
57
  class Explorer {
51
58
  private aiProvider: AIProvider;
52
59
  playwrightHelper: any;
@@ -64,6 +71,10 @@ class Explorer {
64
71
  private xhrCapture: XhrCapture | null = null;
65
72
  private requestStore: RequestStore | null = null;
66
73
  private playwrightRecorder: PlaywrightRecorder = new PlaywrightRecorder();
74
+ private observedTestPages = new Set<any>();
75
+ private testPageErrorHandler: ((error: Error) => void) | null = null;
76
+ private testConsoleHandler: ((message: any) => void) | null = null;
77
+ private testDialogHandler: ((dialog: any) => void) | null = null;
67
78
 
68
79
  constructor(config: ExplorbotConfig, aiProvider: AIProvider, options?: { show?: boolean; headless?: boolean; incognito?: boolean; session?: string }) {
69
80
  this.config = config;
@@ -199,12 +210,14 @@ class Explorer {
199
210
  }
200
211
  }
201
212
 
202
- private setupXhrCapture(): void {
213
+ private setupXhrCapture(reuseRequestStore = false): void {
203
214
  const configParser = ConfigParser.getInstance();
204
215
  const outputDir = configParser.getOutputDir();
205
- this.requestStore = new RequestStore(outputDir);
216
+ if (!reuseRequestStore || !this.requestStore) {
217
+ this.requestStore = new RequestStore(outputDir);
218
+ }
206
219
  const baseUrl = this.config.playwright.url;
207
- this.xhrCapture = new XhrCapture(this.requestStore, baseUrl);
220
+ this.xhrCapture = new XhrCapture(this.requestStore!, baseUrl);
208
221
  this.xhrCapture.attach(this.playwrightHelper.page);
209
222
  }
210
223
 
@@ -239,18 +252,7 @@ class Explorer {
239
252
  }
240
253
  await this.connectOrLaunchBrowser();
241
254
  const hasSession = this.options?.session && existsSync(this.options.session);
242
- const helperOptions = this.playwrightHelper.options || {};
243
- // CodeceptJS skips _createContextPage when sessions/storageState are involved, so we
244
- // build contextOptions ourselves. Most keys share a name with Playwright's
245
- // BrowserContextOptions and are copied as-is; `emulate` must be flattened, `basicAuth`
246
- // renamed to `httpCredentials`, and `storageState` comes from the --session flag.
247
- const contextOptions: BrowserContextOptions = {
248
- ...helperOptions,
249
- };
250
- if (helperOptions.emulate) Object.assign(contextOptions, helperOptions.emulate);
251
- if (helperOptions.basicAuth) contextOptions.httpCredentials = helperOptions.basicAuth;
252
- if (hasSession) contextOptions.storageState = this.options!.session;
253
- await this.playwrightHelper._createContextPage(contextOptions);
255
+ await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
254
256
  await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
255
257
  this.setupXhrCapture();
256
258
  if (hasSession) {
@@ -287,15 +289,75 @@ class Explorer {
287
289
  await this.playwrightHelper._startBrowser();
288
290
  }
289
291
 
292
+ private createBrowserContextOptions(): BrowserContextOptions {
293
+ const helperOptions = this.playwrightHelper.options || {};
294
+ const contextOptions: BrowserContextOptions = {
295
+ ...helperOptions,
296
+ };
297
+
298
+ if (helperOptions.emulate) Object.assign(contextOptions, helperOptions.emulate);
299
+ if (helperOptions.basicAuth) contextOptions.httpCredentials = helperOptions.basicAuth;
300
+ if (this.options?.session && existsSync(this.options.session)) contextOptions.storageState = this.options.session;
301
+
302
+ return contextOptions;
303
+ }
304
+
290
305
  createAction() {
291
306
  return new Action(this.actor, this.stateManager, this.playwrightRecorder);
292
307
  }
293
308
 
309
+ async runWithBrowserRecovery<T>(label: string, operation: () => Promise<T>): Promise<T> {
310
+ if (!(await this.ensurePageAvailable())) {
311
+ throw new Error(`Browser page is unavailable before ${label}`);
312
+ }
313
+
314
+ try {
315
+ return await operation();
316
+ } catch (error) {
317
+ if (!this.isFatalBrowserError(error)) throw error;
318
+
319
+ tag('warning').log(`${label}: browser page is unavailable, recovering...`);
320
+ let recovered = await this.recoverFromBrowserError();
321
+ if (!recovered) recovered = await this.restartBrowser();
322
+ if (!recovered) throw new BrowserRecoveryError(label, error, false);
323
+ if (!(await this.waitForUsablePageDom())) throw new BrowserRecoveryError(label, error, true);
324
+
325
+ try {
326
+ return await operation();
327
+ } catch (retryError) {
328
+ if (this.isFatalBrowserError(retryError)) {
329
+ throw new BrowserRecoveryError(label, retryError, true);
330
+ }
331
+ throw retryError;
332
+ }
333
+ }
334
+ }
335
+
336
+ async capturePageState(opts: { includeScreenshot?: boolean } = {}): Promise<ActionResult> {
337
+ return this.runWithBrowserRecovery('capturePageState', () => this.createAction().capturePageState(opts));
338
+ }
339
+
340
+ async capturePageWithScreenshot(): Promise<ActionResult> {
341
+ return this.capturePageState({ includeScreenshot: true });
342
+ }
343
+
344
+ async executeAction(code: string): Promise<Action> {
345
+ return this.runWithBrowserRecovery('executeAction', () => this.createAction().execute(code));
346
+ }
347
+
348
+ async attemptAction(code: string, originalMessage?: string, experience = true): Promise<boolean> {
349
+ return this.runWithBrowserRecovery('attemptAction', () => this.createAction().attempt(code, originalMessage, experience));
350
+ }
351
+
294
352
  getPlaywrightRecorder(): PlaywrightRecorder {
295
353
  return this.playwrightRecorder;
296
354
  }
297
355
 
298
356
  async visit(url: string) {
357
+ return this.runWithBrowserRecovery('visit', () => this.visitOnce(url));
358
+ }
359
+
360
+ private async visitOnce(url: string) {
299
361
  await this.closeOtherTabs();
300
362
 
301
363
  const serializedUrl = JSON.stringify(url);
@@ -338,12 +400,14 @@ class Explorer {
338
400
  }
339
401
 
340
402
  async annotateElements(): Promise<WebElement[]> {
341
- const { elements } = await annotatePageElements(this.playwrightHelper.page);
342
- return elements;
403
+ return this.runWithBrowserRecovery('annotateElements', async () => {
404
+ const { elements } = await annotatePageElements(this.playwrightHelper.page);
405
+ return elements;
406
+ });
343
407
  }
344
408
 
345
409
  async visuallyAnnotateElements(opts?: { containers?: Array<{ css: string; label: string }> }): Promise<number> {
346
- return visuallyAnnotateContainers(this.playwrightHelper.page, opts?.containers || []);
410
+ return this.runWithBrowserRecovery('visuallyAnnotateElements', () => visuallyAnnotateContainers(this.playwrightHelper.page, opts?.containers || []));
347
411
  }
348
412
 
349
413
  async getEidxInContainer(containerCss: string | null): Promise<string[]> {
@@ -386,28 +450,99 @@ class Explorer {
386
450
  await this.playwrightHelper.page.reload();
387
451
  }
388
452
 
453
+ private resolveBrowserUrl(url?: string): string | null {
454
+ if (!url) return null;
455
+ try {
456
+ return new URL(url).toString();
457
+ } catch {}
458
+
459
+ const baseUrl = this.config.playwright?.url || this.config.web?.url;
460
+ if (!baseUrl) return null;
461
+
462
+ try {
463
+ return new URL(url, baseUrl).toString();
464
+ } catch {
465
+ return null;
466
+ }
467
+ }
468
+
389
469
  isFatalBrowserError(error: unknown): boolean {
390
- const msg = error instanceof Error ? error.message : String(error);
391
- return FATAL_BROWSER_ERRORS.test(msg);
470
+ return isFatalBrowserError(error);
392
471
  }
393
472
 
394
473
  async recoverFromBrowserError(): Promise<boolean> {
395
474
  try {
396
- const url = this.stateManager.getCurrentState()?.url;
475
+ if (!this.playwrightHelper?.page || this.playwrightHelper.page.isClosed?.()) {
476
+ const context = this.playwrightHelper?.browserContext;
477
+ if (!context) return await this.restartBrowser();
478
+ const page = await context.newPage();
479
+ await page.bringToFront();
480
+ await this.playwrightHelper._setPage(page);
481
+ this.bindFrameNavigated(page);
482
+ if (this.xhrCapture) {
483
+ this.xhrCapture.attach(this.playwrightHelper.page);
484
+ }
485
+ }
486
+
487
+ const url = this.resolveBrowserUrl(this.stateManager.getCurrentState()?.url);
397
488
  if (url) {
398
489
  tag('warning').log(`Browser error detected, recovering by navigating to ${url}`);
399
490
  await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
400
- return true;
491
+ return this.waitForUsablePageDom();
401
492
  }
402
493
  tag('warning').log('Browser error detected, reloading page');
403
494
  await this.playwrightHelper.page.reload({ waitUntil: 'domcontentloaded', timeout: 10000 });
404
- return true;
495
+ return this.waitForUsablePageDom();
405
496
  } catch (err) {
406
497
  tag('error').log(`Browser recovery failed: ${err instanceof Error ? err.message : err}`);
407
498
  return false;
408
499
  }
409
500
  }
410
501
 
502
+ async restartBrowser(): Promise<boolean> {
503
+ if (!this.playwrightHelper) return false;
504
+
505
+ const url = this.resolveBrowserUrl(this.stateManager.getCurrentState()?.url);
506
+
507
+ try {
508
+ if (this.xhrCapture && this.playwrightHelper.page) {
509
+ this.xhrCapture.detach(this.playwrightHelper.page);
510
+ }
511
+
512
+ await this.playwrightRecorder.stop();
513
+
514
+ if (this.playwrightHelper.browserContext) {
515
+ await this.playwrightHelper.browserContext.close().catch((err: unknown) => {
516
+ debugLog('Failed to close browser context before restart:', err);
517
+ });
518
+ this.playwrightHelper.browserContext = null;
519
+ }
520
+
521
+ if (!this.isSharedBrowser) {
522
+ await this.playwrightHelper._stopBrowser().catch((err: unknown) => {
523
+ debugLog('Failed to stop browser before restart:', err);
524
+ });
525
+ }
526
+
527
+ await this.connectOrLaunchBrowser();
528
+ await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
529
+ await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
530
+ this.setupXhrCapture(true);
531
+ this.listenToStateChanged();
532
+
533
+ if (url) {
534
+ await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
535
+ if (!(await this.waitForUsablePageDom())) return false;
536
+ }
537
+
538
+ tag('success').log('Browser restarted');
539
+ return true;
540
+ } catch (err) {
541
+ tag('error').log(`Browser restart failed: ${err instanceof Error ? err.message : err}`);
542
+ return false;
543
+ }
544
+ }
545
+
411
546
  async switchToMainFrame() {
412
547
  if (this.playwrightHelper.frame) {
413
548
  debugLog('Switching to main frame');
@@ -415,6 +550,30 @@ class Explorer {
415
550
  }
416
551
  }
417
552
 
553
+ private async waitForUsablePageDom(): Promise<boolean> {
554
+ const page = this.playwrightHelper?.page;
555
+ if (!page) return false;
556
+
557
+ await page.waitForLoadState?.('domcontentloaded', { timeout: 5000 }).catch(() => {});
558
+ if (page.waitForFunction) {
559
+ const hasUsableDom = await page
560
+ .waitForFunction(
561
+ () => {
562
+ const body = document.body;
563
+ if (!body) return false;
564
+ return body.children.length > 0 || body.textContent?.trim().length > 0;
565
+ },
566
+ undefined,
567
+ { timeout: 5000 }
568
+ )
569
+ .then(() => true)
570
+ .catch(() => false);
571
+ if (!hasUsableDom) return false;
572
+ }
573
+ await page.waitForLoadState?.('networkidle', { timeout: 3000 }).catch(() => {});
574
+ return true;
575
+ }
576
+
418
577
  async isInsideIframe(): Promise<boolean> {
419
578
  if (this.playwrightHelper.frame) return true;
420
579
 
@@ -542,11 +701,13 @@ class Explorer {
542
701
  return this._activeTest;
543
702
  }
544
703
 
545
- async startTest(test: Test) {
704
+ async startTest(test: Test): Promise<boolean> {
546
705
  this._activeTest = test;
706
+ test.start();
547
707
  await this.reporter.reportTestStart(test);
548
708
  await this.closeOtherTabs();
549
709
  this.otherTabs = [];
710
+ if (!(await this.ensurePageAvailable())) return false;
550
711
 
551
712
  const codeceptjsTest = toCodeceptjsTest(test);
552
713
 
@@ -563,13 +724,7 @@ class Explorer {
563
724
  test.setActiveNoteScreenshot(lastScreenshot);
564
725
  };
565
726
 
566
- const dialogHandler = (dialog: any) => {
567
- const dialogType = dialog.type();
568
- const dialogMessage = dialog.message();
569
- test.addNote(`Native dialog ${dialogType} appeared: ${dialogMessage}. Accepted automatically`);
570
- };
571
-
572
- this.playwrightHelper?.page?.on('dialog', dialogHandler);
727
+ this.watchActiveTestPage();
573
728
 
574
729
  codeceptjs.event.dispatcher.emit('test.before', codeceptjsTest);
575
730
  codeceptjs.event.dispatcher.emit('test.start', codeceptjsTest);
@@ -580,11 +735,105 @@ class Explorer {
580
735
  codeceptjs.event.dispatcher.on('test.after', () => {
581
736
  codeceptjs.event.dispatcher.off('step.passed', stepHandler);
582
737
  codeceptjs.event.dispatcher.off('step.failed', stepHandler);
583
- this.playwrightHelper?.page?.off('dialog', dialogHandler);
738
+ this.unwatchActiveTestPages();
584
739
  });
740
+
741
+ return true;
742
+ }
743
+
744
+ async ensurePageAvailable(): Promise<boolean> {
745
+ const page = this.playwrightHelper?.page;
746
+ if (page && !page.isClosed?.()) {
747
+ this.watchActiveTestPage(page);
748
+ return true;
749
+ }
750
+
751
+ const recovered = await this.recoverFromBrowserError();
752
+ if (!recovered) return false;
753
+ this.watchActiveTestPage();
754
+ return true;
755
+ }
756
+
757
+ async ensureActiveTestPageAvailable(): Promise<boolean> {
758
+ return this.ensurePageAvailable();
759
+ }
760
+
761
+ async handleExecutionError(error: unknown): Promise<BrowserExecutionErrorResult> {
762
+ const message = error instanceof Error ? error.message : String(error);
763
+ tag('error').log(`Browser execution error: ${message}`);
764
+
765
+ if (error instanceof Error && error.name === 'AbortError') {
766
+ return {
767
+ action: 'stop',
768
+ message,
769
+ };
770
+ }
771
+
772
+ if (error instanceof BrowserRecoveryError) {
773
+ return {
774
+ action: 'stop',
775
+ recovered: error.recovered,
776
+ message: error.message,
777
+ };
778
+ }
779
+
780
+ if (!this.isFatalBrowserError(error)) {
781
+ return {
782
+ action: 'continue',
783
+ message: `Previous execution error: ${message}. Investigate the current state and choose a different approach.`,
784
+ };
785
+ }
786
+
787
+ let recovered = await this.recoverFromBrowserError();
788
+ if (!recovered) recovered = await this.restartBrowser();
789
+
790
+ if (!recovered) {
791
+ return {
792
+ action: 'stop',
793
+ recovered: false,
794
+ message: `Browser could not be recovered after fatal error: ${message}`,
795
+ };
796
+ }
797
+
798
+ this.watchActiveTestPage();
799
+ return {
800
+ action: 'continue',
801
+ recovered: true,
802
+ message: dedent`
803
+ Browser was recovered after a fatal page error.
804
+ Continue from the restored page.
805
+ The interrupted browser action is not product evidence.
806
+ Inspect the restored page and retry the current step when it is still required.
807
+ `,
808
+ };
809
+ }
810
+
811
+ watchActiveTestPage(page = this.playwrightHelper?.page): void {
812
+ if (!this._activeTest) return;
813
+ if (!page) return;
814
+ if (this.observedTestPages.has(page)) return;
815
+
816
+ this.testPageErrorHandler ||= (err: Error) => {
817
+ this._activeTest?.addNote(`Console error: ${err.message}`, TestResult.FAILED);
818
+ };
819
+ this.testConsoleHandler ||= (msg: any) => {
820
+ if (msg.type() !== 'error') return;
821
+ this._activeTest?.addNote(`Console error: ${msg.text()}`, TestResult.FAILED);
822
+ };
823
+ this.testDialogHandler ||= (dialog: any) => {
824
+ const dialogType = dialog.type();
825
+ const dialogMessage = dialog.message();
826
+ this._activeTest?.addNote(`Native dialog ${dialogType} appeared: ${dialogMessage}. Accepted automatically`);
827
+ };
828
+
829
+ page.on('pageerror', this.testPageErrorHandler);
830
+ page.on('console', this.testConsoleHandler);
831
+ page.on('dialog', this.testDialogHandler);
832
+ this.observedTestPages.add(page);
585
833
  }
586
834
 
587
835
  async stopTest(test: Test, meta?: Record<string, string>) {
836
+ this.unwatchActiveTestPages();
588
837
  this._activeTest = null;
589
838
  const lastScreenshot = this.stateManager.getCurrentState()?.screenshotFile;
590
839
  if (lastScreenshot) {
@@ -609,6 +858,15 @@ class Explorer {
609
858
  codeceptjs.event.dispatcher.emit('test.after', codeceptjsTest);
610
859
  }
611
860
 
861
+ private unwatchActiveTestPages(): void {
862
+ for (const page of this.observedTestPages) {
863
+ if (this.testPageErrorHandler) page.off('pageerror', this.testPageErrorHandler);
864
+ if (this.testConsoleHandler) page.off('console', this.testConsoleHandler);
865
+ if (this.testDialogHandler) page.off('dialog', this.testDialogHandler);
866
+ }
867
+ this.observedTestPages.clear();
868
+ }
869
+
612
870
  async hasPlaywrightLocator(locatorFn: (page: any) => any, opts: { multiple?: boolean; contents?: boolean; success?: (locator: any) => Promise<void> | void } = {}): Promise<boolean> {
613
871
  try {
614
872
  const pwLocator = locatorFn(this.playwrightHelper.page);
@@ -671,7 +929,7 @@ class Explorer {
671
929
  await oldPage.close();
672
930
  await newPage.bringToFront();
673
931
 
674
- this.playwrightHelper.page = newPage;
932
+ await this.playwrightHelper._setPage(newPage);
675
933
  this.otherTabs = [];
676
934
 
677
935
  this.bindFrameNavigated(newPage);
@@ -705,8 +963,7 @@ class Explorer {
705
963
  }
706
964
 
707
965
  await firstPage.bringToFront();
708
-
709
- this.playwrightHelper.page = firstPage;
966
+ await this.playwrightHelper._setPage(firstPage);
710
967
 
711
968
  debugLog(`Cleaned up tabs, now focused on: ${await firstPage.url()}`);
712
969
  }
@@ -24,6 +24,8 @@ export interface WebPageState {
24
24
  url: string;
25
25
  /** Page title */
26
26
  title?: string;
27
+ /** HTTP status of the main document navigation */
28
+ httpStatus?: number;
27
29
  /** Full URL for reference */
28
30
  fullUrl?: string;
29
31
  /** Timestamp when state was captured */
package/src/stats.ts CHANGED
@@ -54,6 +54,24 @@ export class Stats {
54
54
  return String(num);
55
55
  }
56
56
 
57
+ static modelsTable(roleModels: Record<string, string>): string {
58
+ const usedModels = Object.entries(Stats.models).filter(([, tokens]) => tokens.total > 0);
59
+ if (usedModels.length === 0) return '';
60
+
61
+ const rolesByModel: Record<string, string[]> = {};
62
+ for (const [role, model] of Object.entries(roleModels)) {
63
+ if (!rolesByModel[model]) rolesByModel[model] = [];
64
+ rolesByModel[model].push(role);
65
+ }
66
+
67
+ const rows = usedModels.map(([model, tokens]) => {
68
+ const roles = rolesByModel[model]?.join(', ') || '-';
69
+ return `| ${roles} | ${model} | ${Stats.humanizeTokens(tokens.total)} |`;
70
+ });
71
+
72
+ return ['## Models', '', '| Role | Model | Tokens |', '| --- | --- | --- |', ...rows].join('\n');
73
+ }
74
+
57
75
  static hasActivity(): boolean {
58
76
  if (Stats.tests > 0 || Stats.plans > 0 || Stats.researches > 0) return true;
59
77
  const totalTokens = Object.values(Stats.models).reduce((sum, m) => sum + m.total, 0);
package/src/utils/aria.ts CHANGED
@@ -334,6 +334,54 @@ const detectRenames = (prev: FlatEntry[], curr: FlatEntry[], prevTotals: Map<str
334
334
  return { added, removed };
335
335
  };
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: Record<string, { on: string; off: string }> = {
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
+
348
+ const stateWord = (attr: string, value: unknown): string => {
349
+ if (attr === 'checked' && value === 'mixed') return 'partially checked';
350
+ const words = STATE_WORDS[attr];
351
+ if (value === true || value === 'true') return words.on;
352
+ return words.off;
353
+ };
354
+
355
+ // Pair entries by path; when role and name match but a state attr differs, it's a toggle.
356
+ const detectToggles = (prev: FlatEntry[], curr: FlatEntry[]): { toggled: string[]; togglePaths: Set<string> } => {
357
+ const toggled: string[] = [];
358
+ const togglePaths = new Set<string>();
359
+ const currByPath = new Map(curr.map((e) => [e.path, e]));
360
+
361
+ for (const before of prev) {
362
+ const after = currByPath.get(before.path);
363
+ if (!after) continue;
364
+ if (before.entry.role !== after.entry.role) continue;
365
+ if (before.entry.name !== after.entry.name) continue;
366
+
367
+ const transitions: string[] = [];
368
+ for (const attr of STATE_ATTRS) {
369
+ const was = stateWord(attr, before.entry[attr]);
370
+ const now = stateWord(attr, after.entry[attr]);
371
+ if (was === now) continue;
372
+ transitions.push(`${was} -> ${now}`);
373
+ }
374
+ if (transitions.length === 0) continue;
375
+
376
+ togglePaths.add(before.path);
377
+ let label = String(after.entry.role);
378
+ const name = after.entry.name;
379
+ if (typeof name === 'string' && name.trim()) label += ` "${name.trim()}"`;
380
+ toggled.push(`${label}: ${transitions.join(', ')}`);
381
+ }
382
+ return { toggled, togglePaths };
383
+ };
384
+
337
385
  const TOP_DIFF_ITEMS = 10;
338
386
 
339
387
  const formatDiffSection = (label: string, items: string[]): string[] => {
@@ -358,9 +406,15 @@ const formatDiffSection = (label: string, items: string[]): string[] => {
358
406
  return lines;
359
407
  };
360
408
 
361
- const formatDiff = (added: string[], removed: string[]): string | null => {
362
- if (added.length === 0 && removed.length === 0) return null;
363
- return ['ariaDiff:', ...formatDiffSection('added', added), ...formatDiffSection('removed', removed)].join('\n');
409
+ const formatDiff = (added: string[], removed: string[], toggled: string[]): string | null => {
410
+ if (added.length === 0 && removed.length === 0 && toggled.length === 0) return null;
411
+ const sections = ['ariaDiff:'];
412
+ if (toggled.length > 0) {
413
+ sections.push(' toggled:');
414
+ for (const line of toggled) sections.push(` - ${line}`);
415
+ }
416
+ sections.push(...formatDiffSection('added', added), ...formatDiffSection('removed', removed));
417
+ return sections.join('\n');
364
418
  };
365
419
 
366
420
  // ─────────────────────────────────────────────────────────────────
@@ -437,13 +491,16 @@ export const diffAriaSnapshots = (previous: string | null, current: string | nul
437
491
  tree = dropEmpty(tree);
438
492
  return flatten(tree);
439
493
  };
440
- const prev = flat(previous);
441
- const curr = flat(current);
494
+ const prevAll = flat(previous);
495
+ const currAll = flat(current);
496
+ const { toggled, togglePaths } = detectToggles(prevAll, currAll);
497
+ const prev = prevAll.filter((e) => !togglePaths.has(e.path));
498
+ const curr = currAll.filter((e) => !togglePaths.has(e.path));
442
499
  const prevTotals = countBy(prev.map((e) => e.summary));
443
500
  const currTotals = countBy(curr.map((e) => e.summary));
444
501
  const byCount = diffByCount(prevTotals, currTotals);
445
502
  const renames = detectRenames(prev, curr, prevTotals, currTotals);
446
- return formatDiff([...byCount.added, ...renames.added], [...byCount.removed, ...renames.removed]);
503
+ return formatDiff([...byCount.added, ...renames.added], [...byCount.removed, ...renames.removed], toggled);
447
504
  };
448
505
 
449
506
  export const detectFocusArea = (snapshot: string | null): FocusAreaResult => {