explorbot 0.1.29 → 0.1.31

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 (51) hide show
  1. package/boat/doc-collector/src/ai/documentarian.ts +37 -13
  2. package/boat/doc-collector/src/ai/tools.ts +60 -20
  3. package/boat/doc-collector/src/cli.ts +3 -0
  4. package/boat/doc-collector/src/config.ts +7 -0
  5. package/boat/doc-collector/src/docbot.ts +23 -5
  6. package/boat/doc-collector/src/docs-renderer.ts +14 -1
  7. package/boat/doc-collector/src/screenshots.ts +126 -0
  8. package/dist/boat/doc-collector/src/ai/documentarian.js +15 -11
  9. package/dist/boat/doc-collector/src/ai/tools.js +53 -20
  10. package/dist/boat/doc-collector/src/cli.js +3 -0
  11. package/dist/boat/doc-collector/src/config.js +3 -0
  12. package/dist/boat/doc-collector/src/docbot.js +19 -4
  13. package/dist/boat/doc-collector/src/docs-renderer.js +12 -1
  14. package/dist/boat/doc-collector/src/screenshots.js +90 -0
  15. package/dist/package.json +2 -2
  16. package/dist/src/action.js +26 -23
  17. package/dist/src/ai/conversation.js +1 -1
  18. package/dist/src/ai/historian/codeceptjs.js +3 -2
  19. package/dist/src/ai/historian/experience.js +48 -6
  20. package/dist/src/ai/historian/playwright.js +2 -1
  21. package/dist/src/ai/historian/utils.js +1 -19
  22. package/dist/src/ai/historian.js +1 -1
  23. package/dist/src/ai/provider.js +3 -2
  24. package/dist/src/ai/quartermaster.js +2 -2
  25. package/dist/src/ai/tester.js +3 -0
  26. package/dist/src/ai/tools.js +0 -1
  27. package/dist/src/experience-tracker.js +1 -1
  28. package/dist/src/explorbot.js +7 -1
  29. package/dist/src/explorer.js +30 -27
  30. package/dist/src/utils/browser-errors.js +5 -0
  31. package/dist/src/utils/page-readiness.js +48 -0
  32. package/dist/src/utils/step-analyzer.js +68 -0
  33. package/package.json +2 -2
  34. package/src/action.ts +24 -26
  35. package/src/ai/conversation.ts +1 -1
  36. package/src/ai/historian/codeceptjs.ts +3 -2
  37. package/src/ai/historian/experience.ts +51 -6
  38. package/src/ai/historian/playwright.ts +2 -1
  39. package/src/ai/historian/utils.ts +1 -21
  40. package/src/ai/historian.ts +1 -1
  41. package/src/ai/provider.ts +3 -2
  42. package/src/ai/quartermaster.ts +2 -2
  43. package/src/ai/tester.ts +3 -0
  44. package/src/ai/tools.ts +0 -1
  45. package/src/config.ts +1 -0
  46. package/src/experience-tracker.ts +1 -1
  47. package/src/explorbot.ts +7 -1
  48. package/src/explorer.ts +28 -27
  49. package/src/utils/browser-errors.ts +6 -0
  50. package/src/utils/page-readiness.ts +59 -0
  51. package/src/utils/step-analyzer.ts +73 -0
package/src/ai/tools.ts CHANGED
@@ -18,7 +18,6 @@ import { isInteractive } from './task-agent.ts';
18
18
 
19
19
  const debugLog = createDebug('explorbot:tools');
20
20
 
21
- export const CODECEPT_TOOLS = ['click', 'hover', 'pressKey', 'form'] as const;
22
21
  export const ASSERTION_TOOLS = ['verify'] as const;
23
22
 
24
23
  export function createCodeceptJSTools(explorer: Explorer, task: Task) {
package/src/config.ts CHANGED
@@ -22,6 +22,7 @@ interface PlaywrightConfig {
22
22
  waitForAction?: number;
23
23
  waitForNavigation?: 'load' | 'domcontentloaded' | 'networkidle';
24
24
  waitForTimeout?: number;
25
+ spinnerSelectors?: string[];
25
26
  ignoreHTTPSErrors?: boolean;
26
27
  userAgent?: string;
27
28
  viewport?: {
@@ -3,12 +3,12 @@ import { basename, dirname, join } from 'node:path';
3
3
  import matter from 'gray-matter';
4
4
  import { type Tokens, marked } from 'marked';
5
5
  import type { ActionResult } from './action-result.js';
6
- import { isNonReusableCode } from './ai/historian/utils.ts';
7
6
  import { ConfigParser } from './config.js';
8
7
  import { KnowledgeTracker } from './knowledge-tracker.js';
9
8
  import type { WebPageState } from './state-manager.js';
10
9
  import { createDebug, tag } from './utils/logger.js';
11
10
  import { mdq } from './utils/markdown-query.js';
11
+ import { isNonReusableCode } from './utils/step-analyzer.ts';
12
12
  import { extractStatePath } from './utils/url-matcher.js';
13
13
 
14
14
  const debugLog = createDebug('explorbot:experience');
package/src/explorbot.ts CHANGED
@@ -221,7 +221,13 @@ export class ExplorBot {
221
221
  this.agents.tester = this.createAgent(({ ai, explorer }) => {
222
222
  const researcher = this.agentResearcher();
223
223
  const navigator = this.agentNavigator();
224
- const tools = createAgentTools({ explorer, researcher, navigator });
224
+ const stateManager = explorer.getStateManager();
225
+ const experienceTracker = stateManager.getExperienceTracker();
226
+ const getState = () => {
227
+ const state = stateManager.getCurrentState();
228
+ return state ? ActionResult.fromState(state) : null;
229
+ };
230
+ const tools = createAgentTools({ explorer, researcher, navigator, experienceTracker, getState });
225
231
  return new Tester(explorer, ai, researcher, navigator, tools);
226
232
  });
227
233
 
package/src/explorer.ts CHANGED
@@ -2,10 +2,10 @@ 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';
6
5
  import stepsListener from 'codeceptjs/lib/listener/steps';
7
6
  import storeListener from 'codeceptjs/lib/listener/store';
8
7
  import { createTest } from 'codeceptjs/lib/mocha/test';
8
+ import dedent from 'dedent';
9
9
  import type { BrowserContextOptions } from 'playwright';
10
10
  import { ActionResult } from './action-result.ts';
11
11
  import Action from './action.js';
@@ -21,10 +21,11 @@ import { PlaywrightRecorder } from './playwright-recorder.ts';
21
21
  import { Reporter } from './reporter.ts';
22
22
  import { StateManager } from './state-manager.js';
23
23
  import { Test, TestResult } from './test-plan.ts';
24
+ import { BrowserRecoveryError, isFatalBrowserError, isNavigationTransitionError } from './utils/browser-errors.ts';
24
25
  import { ELEMENT_EXTRACTION_CONFIG, getElementDataExtractorSource } from './utils/html.ts';
25
26
  import { createDebug, log, tag } from './utils/logger.js';
27
+ import { waitForPageReadiness } from './utils/page-readiness.ts';
26
28
  import { WebElement } from './utils/web-element.ts';
27
- import { BrowserRecoveryError, isFatalBrowserError } from './utils/browser-errors.ts';
28
29
 
29
30
  declare global {
30
31
  namespace NodeJS {
@@ -314,13 +315,26 @@ class Explorer {
314
315
  try {
315
316
  return await operation();
316
317
  } catch (error) {
317
- if (!this.isFatalBrowserError(error)) throw error;
318
+ let recoveryError = error;
319
+
320
+ if (isNavigationTransitionError(error)) {
321
+ tag('warning').log(`${label}: page is still navigating, waiting before retry...`);
322
+ await this.waitForPageReadiness();
323
+ try {
324
+ return await operation();
325
+ } catch (retryError) {
326
+ if (!isNavigationTransitionError(retryError) && !this.isFatalBrowserError(retryError)) throw retryError;
327
+ recoveryError = retryError;
328
+ }
329
+ }
330
+
331
+ if (!this.isFatalBrowserError(recoveryError)) throw recoveryError;
318
332
 
319
333
  tag('warning').log(`${label}: browser page is unavailable, recovering...`);
320
334
  let recovered = await this.recoverFromBrowserError();
321
335
  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);
336
+ if (!recovered) throw new BrowserRecoveryError(label, recoveryError, false);
337
+ if (!(await this.waitForPageReadiness())) throw new BrowserRecoveryError(label, recoveryError, true);
324
338
 
325
339
  try {
326
340
  return await operation();
@@ -377,7 +391,7 @@ class Explorer {
377
391
  const msg = err instanceof Error ? err.message : String(err);
378
392
  if (!RECOVERABLE_NAVIGATION_ERRORS.test(msg)) throw err;
379
393
  tag('warning').log(`Navigation warning (continuing after load): ${msg.split('\n')[0]}`);
380
- await this.playwrightHelper.page.waitForLoadState('domcontentloaded', { timeout: 10000 }).catch(() => {});
394
+ await this.waitForPageReadiness();
381
395
  await action.capturePageState();
382
396
  }
383
397
  }
@@ -488,11 +502,11 @@ class Explorer {
488
502
  if (url) {
489
503
  tag('warning').log(`Browser error detected, recovering by navigating to ${url}`);
490
504
  await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
491
- return this.waitForUsablePageDom();
505
+ return this.waitForPageReadiness();
492
506
  }
493
507
  tag('warning').log('Browser error detected, reloading page');
494
508
  await this.playwrightHelper.page.reload({ waitUntil: 'domcontentloaded', timeout: 10000 });
495
- return this.waitForUsablePageDom();
509
+ return this.waitForPageReadiness();
496
510
  } catch (err) {
497
511
  tag('error').log(`Browser recovery failed: ${err instanceof Error ? err.message : err}`);
498
512
  return false;
@@ -532,7 +546,7 @@ class Explorer {
532
546
 
533
547
  if (url) {
534
548
  await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
535
- if (!(await this.waitForUsablePageDom())) return false;
549
+ if (!(await this.waitForPageReadiness())) return false;
536
550
  }
537
551
 
538
552
  tag('success').log('Browser restarted');
@@ -550,27 +564,14 @@ class Explorer {
550
564
  }
551
565
  }
552
566
 
553
- private async waitForUsablePageDom(): Promise<boolean> {
567
+ private async waitForPageReadiness(): Promise<boolean> {
554
568
  const page = this.playwrightHelper?.page;
555
569
  if (!page) return false;
556
570
 
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(() => {});
571
+ await waitForPageReadiness(page, {
572
+ timeout: this.config.playwright.waitForTimeout,
573
+ spinnerSelectors: this.config.playwright.spinnerSelectors,
574
+ });
574
575
  return true;
575
576
  }
576
577
 
@@ -2,6 +2,7 @@
2
2
  // not typed exceptions. Keep those external message markers in one adapter so
3
3
  // recovery decisions are not duplicated across agents/actions.
4
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
+ const NAVIGATION_TRANSITION_ERROR_MARKERS = ['most likely because of a navigation', 'navigating and changing the content'];
5
6
 
6
7
  export class BrowserRecoveryError extends Error {
7
8
  constructor(
@@ -20,6 +21,11 @@ export function isFatalBrowserError(error: unknown): boolean {
20
21
  return FATAL_BROWSER_ERROR_MARKERS.some((marker) => message.includes(marker.toLowerCase()));
21
22
  }
22
23
 
24
+ export function isNavigationTransitionError(error: unknown): boolean {
25
+ const message = browserErrorMessage(error).toLowerCase();
26
+ return NAVIGATION_TRANSITION_ERROR_MARKERS.some((marker) => message.includes(marker.toLowerCase()));
27
+ }
28
+
23
29
  export function browserErrorMessage(error: unknown): string {
24
30
  return error instanceof Error ? error.message : String(error);
25
31
  }
@@ -0,0 +1,59 @@
1
+ export async function waitForPageReadiness(page: any, options: PageReadinessOptions = {}): Promise<void> {
2
+ if (!page) return;
3
+
4
+ const timeout = options.timeout ?? 6000;
5
+ await page.waitForLoadState?.('domcontentloaded', { timeout })?.catch(() => {});
6
+
7
+ await Promise.race([waitForNetworkIdle(page, timeout), waitForVisibleSpinnersHidden(page, options.spinnerSelectors || [], timeout), sleep(timeout)]).catch(() => {});
8
+ await waitForPageBodyContent(page, timeout);
9
+ }
10
+
11
+ function waitForNetworkIdle(page: any, timeout: number): Promise<void> {
12
+ if (!page?.waitForLoadState) return Promise.resolve();
13
+ return page.waitForLoadState('networkidle', { timeout }).catch(() => {});
14
+ }
15
+
16
+ async function waitForVisibleSpinnersHidden(page: any, selectors: string[], timeout: number): Promise<void> {
17
+ if (!selectors.length) return new Promise(() => {});
18
+ if (!page?.locator) return new Promise(() => {});
19
+
20
+ const visibleSpinners = [];
21
+ for (const selector of selectors) {
22
+ const locator = page.locator(selector);
23
+ const isVisible = await locator
24
+ .first()
25
+ .isVisible({ timeout: 100 })
26
+ .catch(() => false);
27
+ if (!isVisible) continue;
28
+ visibleSpinners.push(locator);
29
+ }
30
+
31
+ if (visibleSpinners.length === 0) return new Promise(() => {});
32
+
33
+ return Promise.all(visibleSpinners.map((locator) => locator.waitFor({ state: 'hidden', timeout }).catch(() => {}))).then(() => {});
34
+ }
35
+
36
+ function waitForPageBodyContent(page: any, timeout: number): Promise<void> {
37
+ if (!page?.waitForFunction) return Promise.resolve();
38
+
39
+ return page
40
+ .waitForFunction(
41
+ () => {
42
+ const body = document.body;
43
+ if (!body) return false;
44
+ return body.children.length > 0 || body.textContent?.trim().length > 0;
45
+ },
46
+ undefined,
47
+ { timeout }
48
+ )
49
+ .catch(() => {});
50
+ }
51
+
52
+ function sleep(ms: number): Promise<void> {
53
+ return new Promise((resolve) => setTimeout(resolve, ms));
54
+ }
55
+
56
+ interface PageReadinessOptions {
57
+ timeout?: number;
58
+ spinnerSelectors?: string[];
59
+ }
@@ -0,0 +1,73 @@
1
+ import type { SessionStep } from '../experience-tracker.ts';
2
+ import type { StepData } from '../test-plan.ts';
3
+ import { isDynamicId } from './xpath.ts';
4
+
5
+ export const CODECEPT_TOOLS = ['click', 'hover', 'pressKey', 'form'] as const;
6
+ export type CodeceptToolName = (typeof CODECEPT_TOOLS)[number];
7
+
8
+ const CODECEPT_FORM_COMMANDS: readonly string[] = ['I.fillField', 'I.type', 'I.selectOption', 'I.attachFile', 'I.checkOption', 'I.uncheckOption'];
9
+
10
+ export function isCodeceptToolName(toolName: string): toolName is CodeceptToolName {
11
+ return CODECEPT_TOOLS.includes(toolName as CodeceptToolName);
12
+ }
13
+
14
+ export function getCodeceptToolName(commandName: string): CodeceptToolName | null {
15
+ const toolName = CODECEPT_TOOLS.find((name) => commandName === `I.${name}`);
16
+ if (toolName) return toolName;
17
+ if (CODECEPT_FORM_COMMANDS.includes(commandName)) return 'form';
18
+ return null;
19
+ }
20
+
21
+ export function getCodeceptToolNameFromCode(code: string): CodeceptToolName | null {
22
+ const parenIndex = code.trim().indexOf('(');
23
+ if (parenIndex < 1) return null;
24
+ return getCodeceptToolName(code.trim().slice(0, parenIndex));
25
+ }
26
+
27
+ export function stripComments(code: string): string {
28
+ return code
29
+ .split('\n')
30
+ .filter((line) => {
31
+ const trimmed = line.trim();
32
+ return trimmed && !trimmed.startsWith('//') && !trimmed.startsWith('/*') && !trimmed.startsWith('*');
33
+ })
34
+ .join('\n');
35
+ }
36
+
37
+ export function isNonReusableCode(code: string): boolean {
38
+ if (/\bI\.clickXY\s*\(/.test(code)) return true;
39
+
40
+ for (const m of code.matchAll(/#([A-Za-z_][\w-]*)/g)) {
41
+ if (isDynamicId(m[1])) return true;
42
+ }
43
+
44
+ return false;
45
+ }
46
+
47
+ export function toReusableSessionStep(step: StepData): SessionStep | null {
48
+ if (step.status !== 'passed') return null;
49
+ const code = stripComments(step.text);
50
+ if (!code || isNonReusableCode(code)) return null;
51
+ const toolName = getCodeceptToolNameFromCode(code);
52
+ if (!toolName) return null;
53
+
54
+ return {
55
+ message: step.text,
56
+ status: 'passed',
57
+ tool: toolName,
58
+ code,
59
+ };
60
+ }
61
+
62
+ export function mergeUniqueStepsByCode(primary: SessionStep[], secondary: SessionStep[]): SessionStep[] {
63
+ const merged: SessionStep[] = [];
64
+ const seen = new Set<string>();
65
+ for (const step of [...primary, ...secondary]) {
66
+ const identity = stripComments(step.code || '').trim();
67
+ if (!identity) continue;
68
+ if (seen.has(identity)) continue;
69
+ seen.add(identity);
70
+ merged.push(step);
71
+ }
72
+ return merged;
73
+ }