explorbot 0.1.27 → 0.1.28

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 (48) hide show
  1. package/dist/package.json +1 -1
  2. package/dist/src/action-result.js +2 -0
  3. package/dist/src/action.js +88 -7
  4. package/dist/src/ai/captain/file-tools.js +100 -0
  5. package/dist/src/ai/captain/idle-mode.js +70 -6
  6. package/dist/src/ai/captain/web-mode.js +36 -6
  7. package/dist/src/ai/captain.js +87 -19
  8. package/dist/src/ai/historian/screencast.js +11 -2
  9. package/dist/src/ai/navigator.js +5 -2
  10. package/dist/src/ai/pilot.js +33 -5
  11. package/dist/src/ai/researcher/coordinates.js +2 -3
  12. package/dist/src/ai/researcher/deep-analysis.js +3 -4
  13. package/dist/src/ai/researcher/locators.js +1 -2
  14. package/dist/src/ai/researcher.js +17 -18
  15. package/dist/src/ai/task-agent.js +1 -0
  16. package/dist/src/ai/tester.js +91 -39
  17. package/dist/src/ai/tools.js +17 -7
  18. package/dist/src/commands/explore-command.js +6 -1
  19. package/dist/src/components/LogPane.js +4 -3
  20. package/dist/src/explorer.js +270 -35
  21. package/dist/src/utils/browser-errors.js +23 -0
  22. package/dist/src/utils/error-page.js +17 -2
  23. package/dist/src/utils/logger.js +2 -2
  24. package/package.json +1 -1
  25. package/src/action-result.ts +2 -0
  26. package/src/action.ts +83 -7
  27. package/src/ai/captain/file-tools.ts +126 -0
  28. package/src/ai/captain/idle-mode.ts +72 -6
  29. package/src/ai/captain/mixin.ts +1 -1
  30. package/src/ai/captain/web-mode.ts +40 -5
  31. package/src/ai/captain.ts +94 -20
  32. package/src/ai/historian/screencast.ts +11 -2
  33. package/src/ai/navigator.ts +6 -2
  34. package/src/ai/pilot.ts +34 -5
  35. package/src/ai/researcher/coordinates.ts +2 -3
  36. package/src/ai/researcher/deep-analysis.ts +3 -4
  37. package/src/ai/researcher/locators.ts +1 -2
  38. package/src/ai/researcher.ts +17 -18
  39. package/src/ai/task-agent.ts +1 -1
  40. package/src/ai/tester.ts +101 -41
  41. package/src/ai/tools.ts +17 -7
  42. package/src/commands/explore-command.ts +6 -1
  43. package/src/components/LogPane.tsx +4 -3
  44. package/src/explorer.ts +295 -38
  45. package/src/state-manager.ts +2 -0
  46. package/src/utils/browser-errors.ts +25 -0
  47. package/src/utils/error-page.ts +16 -3
  48. package/src/utils/logger.ts +3 -3
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 */
@@ -0,0 +1,25 @@
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
+
6
+ export class BrowserRecoveryError extends Error {
7
+ constructor(
8
+ label: string,
9
+ public originalError: unknown,
10
+ public recovered: boolean
11
+ ) {
12
+ super(`${label} failed ${recovered ? 'after browser recovery' : 'because browser could not be recovered'}: ${browserErrorMessage(originalError)}`);
13
+ this.name = 'BrowserRecoveryError';
14
+ }
15
+ }
16
+
17
+ export function isFatalBrowserError(error: unknown): boolean {
18
+ if (error instanceof BrowserRecoveryError) return true;
19
+ const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
20
+ return FATAL_BROWSER_ERROR_MARKERS.some((marker) => message.includes(marker.toLowerCase()));
21
+ }
22
+
23
+ export function browserErrorMessage(error: unknown): string {
24
+ return error instanceof Error ? error.message : String(error);
25
+ }
@@ -1,4 +1,5 @@
1
- import type { ActionResult } from '../action-result.js';
1
+ import { ActionResult } from '../action-result.js';
2
+ import type { WebPageState } from '../state-manager.js';
2
3
  import { isBodyEmpty } from './html.js';
3
4
 
4
5
  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'];
@@ -9,6 +10,8 @@ const LOADING_WORD = /\bloading\b/i;
9
10
  export type PageCondition = 'ok' | 'loading' | 'error';
10
11
 
11
12
  export function detectPageCondition(actionResult: ActionResult): PageCondition {
13
+ if (actionResult.httpStatus && actionResult.httpStatus >= 400) return 'error';
14
+
12
15
  const headingFields = [actionResult.title, actionResult.h1, actionResult.h2].filter(Boolean) as string[];
13
16
 
14
17
  for (const field of headingFields) {
@@ -37,12 +40,22 @@ export function isErrorPage(actionResult: ActionResult): boolean {
37
40
  return detectPageCondition(actionResult) === 'error';
38
41
  }
39
42
 
43
+ export function getStateErrorPageError(state: WebPageState | null | undefined): ErrorPageError | null {
44
+ if (!state) return null;
45
+ const actionResult = ActionResult.fromState(state);
46
+ if (!isErrorPage(actionResult)) return null;
47
+ return new ErrorPageError(actionResult.url, actionResult.title, actionResult.httpStatus);
48
+ }
49
+
40
50
  export class ErrorPageError extends Error {
41
51
  constructor(
42
52
  public readonly url: string,
43
- public readonly title?: string
53
+ public readonly title?: string,
54
+ public readonly httpStatus?: number
44
55
  ) {
45
- super(`Error page detected at ${url}${title ? ` (${title})` : ''}`);
56
+ const status = httpStatus ? `HTTP ${httpStatus}` : '';
57
+ const details = [status, title].filter(Boolean).join(', ');
58
+ super(`Error page detected at ${url}${details ? ` (${details})` : ''}`);
46
59
  this.name = 'ErrorPageError';
47
60
  }
48
61
  }
@@ -11,7 +11,7 @@ import { Observability } from '../observability.ts';
11
11
  import { RecentStepFilter } from './log-filters.ts';
12
12
  import { parseMarkdownToTerminal } from './markdown-terminal.ts';
13
13
 
14
- export type LogType = 'info' | 'success' | 'error' | 'warning' | 'debug' | 'substep' | 'operation' | 'step' | 'multiline' | 'html' | 'input';
14
+ export type LogType = 'info' | 'success' | 'error' | 'warning' | 'debug' | 'substep' | 'operation' | 'step' | 'multiline' | 'details' | 'html' | 'input';
15
15
 
16
16
  export interface TaggedLogEntry {
17
17
  type: LogType;
@@ -99,7 +99,7 @@ class ConsoleDestination implements LogDestination {
99
99
  if (entry.type === 'operation' && !this.verboseMode) return;
100
100
  if (entry.type === 'step' && !this.verboseMode && this.recentSteps.shouldSuppress(entry.content)) return;
101
101
  let content = entry.content;
102
- if (entry.type === 'multiline') {
102
+ if (entry.type === 'multiline' || entry.type === 'details') {
103
103
  const cleaned = stripAnsi(dedent(entry.content));
104
104
  const parsed = parseMarkdownToTerminal(cleaned);
105
105
  content = parsed;
@@ -314,7 +314,7 @@ class CaptainDestination implements LogDestination {
314
314
 
315
315
  stopCapture(): string[] {
316
316
  this.capturing = false;
317
- const logs = this.entries.filter((e) => e.type !== 'debug' && e.type !== 'html' && e.type !== 'multiline' && e.type !== 'operation').map((e) => `[${e.type}] ${e.content}`);
317
+ 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}`);
318
318
  this.entries = [];
319
319
  return logs;
320
320
  }