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
@@ -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
  }
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -52,6 +52,7 @@ export interface ToolResultMetadata {
52
52
  export class ActionResult implements ActionResultData {
53
53
  public id?: number;
54
54
  public title = '';
55
+ public httpStatus: number | undefined = undefined;
55
56
  public error: string | null = null;
56
57
  public timestamp: Date = new Date();
57
58
  public h1: string | undefined = undefined;
@@ -82,6 +83,7 @@ export class ActionResult implements ActionResultData {
82
83
  this.url = data.url ?? '';
83
84
  this.fullUrl = data.fullUrl;
84
85
  this.title = data.title ?? '';
86
+ this.httpStatus = data.httpStatus;
85
87
  this.error = data.error ?? null;
86
88
  this.browserLogs = data.browserLogs ?? [];
87
89
  this.iframeSnapshots = data.iframeSnapshots ?? [];