explorbot 0.2.0 → 0.2.2

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 (145) hide show
  1. package/README.md +21 -5
  2. package/bin/explorbot-cli.ts +29 -7
  3. package/boat/api-tester/src/config.ts +45 -3
  4. package/boat/doc-collector/src/ai/documentarian.ts +8 -2
  5. package/boat/doc-collector/src/ai/tools.ts +29 -16
  6. package/boat/doc-collector/src/cli.ts +2 -6
  7. package/boat/doc-collector/src/config.ts +2 -0
  8. package/boat/doc-collector/src/docbot.ts +42 -14
  9. package/boat/doc-collector/src/interaction-screenshots.ts +160 -0
  10. package/boat/doc-collector/src/screenshots.ts +24 -16
  11. package/dist/bin/explorbot-cli.js +26 -6
  12. package/dist/boat/api-tester/src/config.js +43 -4
  13. package/dist/boat/doc-collector/src/ai/documentarian.js +7 -2
  14. package/dist/boat/doc-collector/src/ai/tools.js +21 -15
  15. package/dist/boat/doc-collector/src/cli.js +2 -5
  16. package/dist/boat/doc-collector/src/config.js +1 -0
  17. package/dist/boat/doc-collector/src/docbot.js +39 -13
  18. package/dist/boat/doc-collector/src/interaction-screenshots.js +156 -0
  19. package/dist/boat/doc-collector/src/screenshots.js +25 -16
  20. package/dist/models.json +30 -0
  21. package/dist/package.json +8 -2
  22. package/dist/src/action.d.ts +11 -3
  23. package/dist/src/action.js +22 -5
  24. package/dist/src/ai/agent.d.ts +17 -0
  25. package/dist/src/ai/captain/idle-mode.js +1 -1
  26. package/dist/src/ai/captain/test-mode.js +1 -1
  27. package/dist/src/ai/captain/web-mode.js +18 -22
  28. package/dist/src/ai/captain.js +13 -26
  29. package/dist/src/ai/driller.d.ts +2 -13
  30. package/dist/src/ai/driller.js +23 -38
  31. package/dist/src/ai/historian/codeceptjs.js +1 -1
  32. package/dist/src/ai/historian/playwright.d.ts +3 -2
  33. package/dist/src/ai/historian/playwright.js +1 -1
  34. package/dist/src/ai/historian/screencast.d.ts +2 -1
  35. package/dist/src/ai/historian/screencast.js +2 -2
  36. package/dist/src/ai/historian.d.ts +5 -4
  37. package/dist/src/ai/navigator.d.ts +6 -2
  38. package/dist/src/ai/navigator.js +26 -22
  39. package/dist/src/ai/pilot.d.ts +13 -6
  40. package/dist/src/ai/pilot.js +49 -44
  41. package/dist/src/ai/planner.d.ts +3 -2
  42. package/dist/src/ai/planner.js +19 -17
  43. package/dist/src/ai/provider.js +0 -1
  44. package/dist/src/ai/quartermaster.d.ts +3 -2
  45. package/dist/src/ai/quartermaster.js +4 -4
  46. package/dist/src/ai/rerunner.d.ts +2 -11
  47. package/dist/src/ai/rerunner.js +9 -22
  48. package/dist/src/ai/researcher/coordinates.js +4 -3
  49. package/dist/src/ai/researcher/deep-analysis.d.ts +2 -0
  50. package/dist/src/ai/researcher/deep-analysis.js +6 -6
  51. package/dist/src/ai/researcher/locators.d.ts +9 -2
  52. package/dist/src/ai/researcher/locators.js +65 -8
  53. package/dist/src/ai/researcher/sections.d.ts +2 -0
  54. package/dist/src/ai/researcher/sections.js +2 -2
  55. package/dist/src/ai/researcher.js +29 -54
  56. package/dist/src/ai/task-agent.d.ts +18 -5
  57. package/dist/src/ai/task-agent.js +35 -6
  58. package/dist/src/ai/tester.d.ts +6 -12
  59. package/dist/src/ai/tester.js +37 -51
  60. package/dist/src/ai/tools.d.ts +11 -10
  61. package/dist/src/ai/tools.js +24 -36
  62. package/dist/src/command-handler.js +1 -1
  63. package/dist/src/commands/context-aria-command.js +1 -1
  64. package/dist/src/commands/context-command.js +7 -5
  65. package/dist/src/commands/context-data-command.js +2 -2
  66. package/dist/src/commands/context-experience-command.js +2 -2
  67. package/dist/src/commands/context-html-command.js +2 -2
  68. package/dist/src/commands/context-knowledge-command.js +1 -1
  69. package/dist/src/commands/drill-command.js +1 -1
  70. package/dist/src/commands/explore-command.js +4 -3
  71. package/dist/src/commands/freesail-command.js +2 -2
  72. package/dist/src/commands/learn-command.js +4 -4
  73. package/dist/src/commands/path-command.js +1 -1
  74. package/dist/src/commands/research-command.js +1 -1
  75. package/dist/src/commands/test-command.js +1 -1
  76. package/dist/src/components/App.js +1 -1
  77. package/dist/src/config.d.ts +17 -0
  78. package/dist/src/config.js +144 -10
  79. package/dist/src/experience-tracker.js +1 -1
  80. package/dist/src/explorbot.d.ts +17 -8
  81. package/dist/src/explorbot.js +74 -36
  82. package/dist/src/explorer.d.ts +67 -91
  83. package/dist/src/explorer.js +327 -567
  84. package/dist/src/state-manager.d.ts +6 -1
  85. package/dist/src/state-manager.js +1 -0
  86. package/dist/src/utils/hooks-runner.js +2 -4
  87. package/dist/src/utils/test-files.js +1 -1
  88. package/dist/src/utils/web-annotate.d.ts +5 -0
  89. package/dist/src/utils/web-annotate.js +58 -0
  90. package/dist/src/utils/web-eidx.d.ts +2 -0
  91. package/dist/src/utils/web-eidx.js +20 -0
  92. package/dist/src/utils/web-element.d.ts +1 -0
  93. package/dist/src/utils/web-element.js +29 -0
  94. package/models.json +30 -0
  95. package/package.json +8 -2
  96. package/src/action.ts +25 -5
  97. package/src/ai/agent.ts +20 -0
  98. package/src/ai/captain/idle-mode.ts +1 -1
  99. package/src/ai/captain/test-mode.ts +1 -1
  100. package/src/ai/captain/web-mode.ts +18 -23
  101. package/src/ai/captain.ts +12 -25
  102. package/src/ai/driller.ts +50 -67
  103. package/src/ai/historian/codeceptjs.ts +1 -1
  104. package/src/ai/historian/playwright.ts +4 -3
  105. package/src/ai/historian/screencast.ts +4 -3
  106. package/src/ai/historian.ts +3 -2
  107. package/src/ai/navigator.ts +30 -25
  108. package/src/ai/pilot.ts +55 -47
  109. package/src/ai/planner.ts +22 -20
  110. package/src/ai/provider.ts +0 -1
  111. package/src/ai/quartermaster.ts +5 -4
  112. package/src/ai/rerunner.ts +10 -29
  113. package/src/ai/researcher/coordinates.ts +4 -3
  114. package/src/ai/researcher/deep-analysis.ts +8 -6
  115. package/src/ai/researcher/locators.ts +66 -9
  116. package/src/ai/researcher/sections.ts +4 -2
  117. package/src/ai/researcher.ts +32 -61
  118. package/src/ai/task-agent.ts +45 -11
  119. package/src/ai/tester.ts +40 -57
  120. package/src/ai/tools.ts +32 -53
  121. package/src/command-handler.ts +1 -1
  122. package/src/commands/context-aria-command.ts +1 -1
  123. package/src/commands/context-command.ts +7 -5
  124. package/src/commands/context-data-command.ts +2 -2
  125. package/src/commands/context-experience-command.ts +2 -2
  126. package/src/commands/context-html-command.ts +2 -2
  127. package/src/commands/context-knowledge-command.ts +1 -1
  128. package/src/commands/drill-command.ts +1 -1
  129. package/src/commands/explore-command.ts +4 -3
  130. package/src/commands/freesail-command.ts +2 -2
  131. package/src/commands/learn-command.ts +4 -4
  132. package/src/commands/path-command.ts +1 -1
  133. package/src/commands/research-command.ts +1 -1
  134. package/src/commands/test-command.ts +1 -1
  135. package/src/components/App.tsx +1 -1
  136. package/src/config.ts +173 -11
  137. package/src/experience-tracker.ts +1 -1
  138. package/src/explorbot.ts +78 -39
  139. package/src/explorer.ts +388 -626
  140. package/src/state-manager.ts +7 -1
  141. package/src/utils/hooks-runner.ts +2 -4
  142. package/src/utils/test-files.ts +1 -1
  143. package/src/utils/web-annotate.ts +64 -0
  144. package/src/utils/web-eidx.ts +21 -0
  145. package/src/utils/web-element.ts +30 -0
@@ -0,0 +1,160 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import pixelmatch from 'pixelmatch';
3
+ import type { Page } from 'playwright';
4
+ import { PNG } from 'pngjs';
5
+
6
+ const REGION_PADDING = 30;
7
+ const SCREENSHOT_OPTIONS = { animations: 'disabled', caret: 'hide' } as const;
8
+
9
+ export async function captureInteractionBefore(page: Page): Promise<Buffer | null> {
10
+ await removeVisualAnnotations(page);
11
+ try {
12
+ return await page.screenshot(SCREENSHOT_OPTIONS);
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ export async function captureInteractionAfter(page: Page, beforeScreenshot: Buffer | null, filePath: string, detectUnmarkedOverlay = false): Promise<InteractionCaptureResult> {
19
+ if (!beforeScreenshot) return 'failed';
20
+
21
+ await removeVisualAnnotations(page);
22
+ try {
23
+ const afterScreenshot = await page.screenshot(SCREENSHOT_OPTIONS);
24
+ const before = PNG.sync.read(beforeScreenshot);
25
+ const after = PNG.sync.read(afterScreenshot);
26
+ if (before.width !== after.width || before.height !== after.height) return 'failed';
27
+ const changedPixels = findChangedPixelBounds(before, after);
28
+ if (!changedPixels) return 'unchanged';
29
+ const fullViewportChanged = changedPixels.x === 0 && changedPixels.y === 0 && changedPixels.width === after.width && changedPixels.height === after.height;
30
+ const changedRegion = addPadding(changedPixels, after.width, after.height);
31
+ const overlayRegion = fullViewportChanged ? await findOverlayRegion(page, after, detectUnmarkedOverlay) : null;
32
+ saveRegion(after, overlayRegion || changedRegion, filePath);
33
+ return 'captured';
34
+ } catch {
35
+ return 'failed';
36
+ }
37
+ }
38
+
39
+ export function findChangedRegion(beforeScreenshot: Buffer, afterScreenshot: Buffer, padding = REGION_PADDING): ScreenshotRegion | null {
40
+ const before = PNG.sync.read(beforeScreenshot);
41
+ const after = PNG.sync.read(afterScreenshot);
42
+ if (before.width !== after.width || before.height !== after.height) return null;
43
+ const changedPixels = findChangedPixelBounds(before, after);
44
+ return changedPixels ? addPadding(changedPixels, before.width, before.height, padding) : null;
45
+ }
46
+
47
+ function saveRegion(after: PNG, region: ScreenshotRegion, filePath: string): void {
48
+ const cropped = new PNG({ width: region.width, height: region.height });
49
+ PNG.bitblt(after, cropped, region.x, region.y, region.width, region.height, 0, 0);
50
+ writeFileSync(filePath, PNG.sync.write(cropped));
51
+ }
52
+
53
+ function findChangedPixelBounds(before: PNG, after: PNG): ScreenshotRegion | null {
54
+ const diff = Buffer.alloc(before.width * before.height * 4);
55
+ const changedPixels = pixelmatch(before.data, after.data, diff, before.width, before.height, { diffMask: true });
56
+ if (changedPixels === 0) return null;
57
+
58
+ let left = before.width;
59
+ let top = before.height;
60
+ let right = 0;
61
+ let bottom = 0;
62
+
63
+ for (let y = 0; y < before.height; y++) {
64
+ for (let x = 0; x < before.width; x++) {
65
+ if (diff[(y * before.width + x) * 4 + 3] === 0) continue;
66
+ left = Math.min(left, x);
67
+ top = Math.min(top, y);
68
+ right = Math.max(right, x);
69
+ bottom = Math.max(bottom, y);
70
+ }
71
+ }
72
+
73
+ return { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
74
+ }
75
+
76
+ function addPadding(region: ScreenshotRegion, imageWidth: number, imageHeight: number, padding = REGION_PADDING): ScreenshotRegion {
77
+ const x = Math.max(0, region.x - padding);
78
+ const y = Math.max(0, region.y - padding);
79
+ const maxX = Math.min(imageWidth, region.x + region.width + padding);
80
+ const maxY = Math.min(imageHeight, region.y + region.height + padding);
81
+ return { x, y, width: maxX - x, height: maxY - y };
82
+ }
83
+
84
+ async function removeVisualAnnotations(page: Page): Promise<void> {
85
+ try {
86
+ await page.locator('[data-explorbot-annotation]').evaluateAll((elements) => {
87
+ for (const element of elements) element.remove();
88
+ });
89
+ } catch {}
90
+ }
91
+
92
+ async function findOverlayRegion(page: Page, image: PNG, detectUnmarkedOverlay: boolean): Promise<ScreenshotRegion | null> {
93
+ let box: { x: number; y: number; width: number; height: number } | null = null;
94
+ try {
95
+ const dialogs = page.locator('[role="dialog"]:visible, [role="alertdialog"]:visible, [aria-modal="true"]:visible');
96
+ if ((await dialogs.count()) > 0) box = await dialogs.last().boundingBox();
97
+ } catch {}
98
+
99
+ if (!box && detectUnmarkedOverlay) {
100
+ try {
101
+ box = await findUnmarkedOverlay(page);
102
+ } catch {}
103
+ }
104
+
105
+ try {
106
+ const viewport = page.viewportSize();
107
+ if (!box || !viewport) return null;
108
+
109
+ const scaleX = image.width / viewport.width;
110
+ const scaleY = image.height / viewport.height;
111
+ const x = Math.max(0, Math.floor(box.x * scaleX) - REGION_PADDING);
112
+ const y = Math.max(0, Math.floor(box.y * scaleY) - REGION_PADDING);
113
+ const maxX = Math.min(image.width, Math.ceil((box.x + box.width) * scaleX) + REGION_PADDING);
114
+ const maxY = Math.min(image.height, Math.ceil((box.y + box.height) * scaleY) + REGION_PADDING);
115
+ if (maxX <= x || maxY <= y) return null;
116
+ return { x, y, width: maxX - x, height: maxY - y };
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ async function findUnmarkedOverlay(page: Page): Promise<{ x: number; y: number; width: number; height: number } | null> {
123
+ return page.evaluate(() => {
124
+ const elements = [...document.body.querySelectorAll('*')].map((element) => {
125
+ const style = getComputedStyle(element);
126
+ const box = element.getBoundingClientRect();
127
+ const zIndex = Number.parseInt(style.zIndex, 10);
128
+ return { element, style, box, zIndex, area: box.width * box.height };
129
+ });
130
+ const isVisibleLayer = ({ style, box, zIndex }: (typeof elements)[number]) => {
131
+ if (style.visibility === 'hidden' || style.display === 'none' || Number(style.opacity) === 0) return false;
132
+ if (box.width <= 0 || box.height <= 0) return false;
133
+ if (style.position !== 'fixed' && style.position !== 'absolute') return false;
134
+ return Number.isFinite(zIndex);
135
+ };
136
+ const backdropZIndex = elements.filter((item) => isVisibleLayer(item) && item.box.width >= window.innerWidth && item.box.height >= window.innerHeight).reduce((highest, item) => Math.max(highest, item.zIndex), Number.NEGATIVE_INFINITY);
137
+ if (!Number.isFinite(backdropZIndex)) return null;
138
+
139
+ const candidates = elements
140
+ .filter((item) => {
141
+ if (!isVisibleLayer(item)) return false;
142
+ if (item.box.width >= window.innerWidth && item.box.height >= window.innerHeight) return false;
143
+ if (item.zIndex < backdropZIndex) return false;
144
+ return item.element.matches('button, input, select, textarea, a[href]') || !!item.element.querySelector('button, input, select, textarea, a[href]');
145
+ })
146
+ .sort((left, right) => right.zIndex - left.zIndex || left.area - right.area);
147
+ const box = candidates[0]?.box;
148
+ if (!box) return null;
149
+ return { x: box.x, y: box.y, width: box.width, height: box.height };
150
+ });
151
+ }
152
+
153
+ export interface ScreenshotRegion {
154
+ x: number;
155
+ y: number;
156
+ width: number;
157
+ height: number;
158
+ }
159
+
160
+ export type InteractionCaptureResult = 'captured' | 'unchanged' | 'failed';
@@ -3,15 +3,15 @@ import path from 'node:path';
3
3
  import { parseResearchSections } from '../../../src/ai/researcher/parser.ts';
4
4
  import type Explorer from '../../../src/explorer.ts';
5
5
  import type { WebPageState } from '../../../src/state-manager.ts';
6
- import { detectFocusArea } from '../../../src/utils/aria.ts';
7
6
  import { safeFilename, sanitizeFilename } from '../../../src/utils/strings.ts';
8
7
  import type { DocStateTransition } from './ai/tools.ts';
9
8
  import type { DocbotConfig } from './config.ts';
9
+ import { captureInteractionAfter, captureInteractionBefore } from './interaction-screenshots.ts';
10
10
 
11
11
  const DEFAULT_MAX_SECTION_SCREENSHOTS = 8;
12
12
 
13
13
  export async function captureDocumentationScreenshots(explorer: Explorer, state: WebPageState, research: string, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot[]> {
14
- const page = explorer.playwrightHelper?.page;
14
+ const page = explorer.page;
15
15
  if (!page) {
16
16
  return [];
17
17
  }
@@ -61,8 +61,8 @@ export function getScreenshotSections(research: string): ScreenshotSection[] {
61
61
  return sections;
62
62
  }
63
63
 
64
- export async function captureInteractionScreenshot(explorer: Explorer, state: WebPageState, transition: DocStateTransition, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot | null> {
65
- const page = explorer.playwrightHelper?.page;
64
+ export async function captureInteractionScreenshot(explorer: Explorer, beforeScreenshot: Buffer | null, state: WebPageState, transition: DocStateTransition, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot | null> {
65
+ const page = explorer.page;
66
66
  if (!page) {
67
67
  return null;
68
68
  }
@@ -70,18 +70,11 @@ export async function captureInteractionScreenshot(explorer: Explorer, state: We
70
70
  mkdirSync(options.screenshotsDir, { recursive: true });
71
71
  const pageName = sanitizeFilename(state.url || 'page') || 'page';
72
72
  const stateName = sanitizeFilename(transition.targetState?.label || transition.action) || 'state';
73
- const filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_${stateName}`, '.png'));
74
- const focus = detectFocusArea(state.ariaSnapshot || null);
75
-
76
- try {
77
- if (focus.detected) {
78
- await page.locator('[role="dialog"], [role="alertdialog"], [aria-modal="true"]').last().screenshot({ path: filePath });
79
- } else {
80
- await page.screenshot({ path: filePath });
81
- }
82
- } catch {
83
- return null;
84
- }
73
+ const stateId = state.id ? `_${state.id}` : '';
74
+ const filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_${stateName}${stateId}`, '.png'));
75
+ const result = await captureInteractionAfter(page, beforeScreenshot, filePath, transition.changes?.urlChanged !== true);
76
+ if (result === 'unchanged') return null;
77
+ if (result === 'failed' && !(await captureViewport(page, filePath))) return null;
85
78
 
86
79
  return {
87
80
  title: transition.targetState?.label || transition.action,
@@ -91,6 +84,21 @@ export async function captureInteractionScreenshot(explorer: Explorer, state: We
91
84
  };
92
85
  }
93
86
 
87
+ export async function captureBeforeInteraction(explorer: Explorer): Promise<Buffer | null> {
88
+ const page = explorer.page;
89
+ if (!page) return null;
90
+ return captureInteractionBefore(page);
91
+ }
92
+
93
+ async function captureViewport(page: any, filePath: string): Promise<boolean> {
94
+ try {
95
+ await page.screenshot({ path: filePath });
96
+ return true;
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
94
102
  async function captureFullPageScreenshot(page: any, pageName: string, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot | null> {
95
103
  const filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_page`, '.png'));
96
104
  try {
@@ -9,7 +9,7 @@ import { render } from 'ink';
9
9
  import React from 'react';
10
10
  import { App } from '../src/components/App.js';
11
11
  import { StatusPane } from '../src/components/StatusPane.js';
12
- import { ConfigParser } from '../src/config.js';
12
+ import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js';
13
13
  import { ExplorBot } from '../src/explorbot.js';
14
14
  import { Stats } from '../src/stats.js';
15
15
  import { Plan } from '../src/test-plan.js';
@@ -27,7 +27,6 @@ if (!process.env.EXPLORBOT_NO_BANNER) {
27
27
  console.log(`⛵ ${chalk.yellow.bold(`Explorbot v${pkgVersion}`)} ${chalk.dim('Autonomous Testing Agent')}`);
28
28
  }
29
29
  function buildExplorBotOptions(from, options) {
30
- const sessionFile = options.session === true ? path.join(path.resolve(options.path || process.cwd()), 'output', 'session.json') : options.session;
31
30
  return {
32
31
  from,
33
32
  verbose: options.verbose || options.debug,
@@ -36,7 +35,7 @@ function buildExplorBotOptions(from, options) {
36
35
  show: options.show,
37
36
  headless: options.headless,
38
37
  incognito: options.incognito,
39
- session: sessionFile,
38
+ session: options.session,
40
39
  };
41
40
  }
42
41
  function addCommonOptions(cmd) {
@@ -623,7 +622,7 @@ program
623
622
  path: options.path,
624
623
  config: options.config,
625
624
  headless: true,
626
- session: options.session === true ? 'output/session.json' : options.session,
625
+ session: options.session,
627
626
  };
628
627
  const explorBot = new ExplorBot(mainOptions);
629
628
  await explorBot.start();
@@ -652,10 +651,10 @@ addCommonOptions(program.command('shell <url> <command>').description('Execute a
652
651
  const explorBot = new ExplorBot(buildExplorBotOptions(url, options));
653
652
  await explorBot.start();
654
653
  await explorBot.agentNavigator().visit(url);
655
- const action = explorBot.getExplorer().createAction();
654
+ const action = explorBot.getExplorer().action();
656
655
  await action.execute(command);
657
656
  log('Command executed successfully');
658
- const state = explorBot.getExplorer().getStateManager().getCurrentState();
657
+ const state = explorBot.stateManager().getCurrentState();
659
658
  if (state)
660
659
  log(`URL: ${state.url}`);
661
660
  await explorBot.stop();
@@ -793,4 +792,25 @@ import { createApiCommands } from "../boat/api-tester/src/cli.js";
793
792
  import { createDocsCommands } from "../boat/doc-collector/src/cli.js";
794
793
  program.addCommand(createApiCommands('api'));
795
794
  program.addCommand(createDocsCommands('docs'));
795
+ const envHelp = () => {
796
+ const width = Math.max(...EXPLORBOT_ENV_VARS.map((v) => v.name.length));
797
+ const rows = EXPLORBOT_ENV_VARS.map((v) => ` ${v.name.padEnd(width)} ${v.description}`).join('\n');
798
+ return `
799
+ Environment variables (config-free one-liner mode):
800
+ Set EXPLORBOT_AI_PROVIDER to run without an explorbot.config.js. A config file always wins.
801
+
802
+ ${rows}
803
+
804
+ Providers: ${Object.keys(PROVIDERS).join(', ')}
805
+ Example:
806
+ EXPLORBOT_URL=https://app.example.com EXPLORBOT_AI_PROVIDER=openrouter \\
807
+ ${cli} explore /login --max-tests 3
808
+ `;
809
+ };
810
+ const addEnvHelp = (cmd) => {
811
+ cmd.addHelpText('after', envHelp);
812
+ for (const sub of cmd.commands)
813
+ addEnvHelp(sub);
814
+ };
815
+ addEnvHelp(program);
796
816
  program.parse();
@@ -9,7 +9,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  import { existsSync, mkdirSync, readFileSync } from 'node:fs';
10
10
  import path, { resolve } from 'node:path';
11
11
  import { parseEnv } from 'node:util';
12
- import { EXPLORBOT_CONFIG_PATHS } from "../../../src/config.js";
12
+ import { EXPLORBOT_CONFIG_PATHS, createModel, materializeKnowledge, resolveModel, resolveOutputRoot } from "../../../src/config.js";
13
13
  export class ApibotConfigParser {
14
14
  static instance;
15
15
  config = null;
@@ -37,9 +37,13 @@ export class ApibotConfigParser {
37
37
  ApibotConfigParser.loadEnv('.env');
38
38
  const resolvedPath = options?.config || this.findConfigFile();
39
39
  if (!resolvedPath) {
40
- if (options?.path)
41
- process.chdir(originalCwd);
42
- throw new Error('No configuration file found. Create apibot.config.js or apibot.config.ts');
40
+ try {
41
+ return await this.loadEnvConfig();
42
+ }
43
+ finally {
44
+ if (options?.path && originalCwd !== process.cwd())
45
+ process.chdir(originalCwd);
46
+ }
43
47
  }
44
48
  try {
45
49
  const configModule = await this.loadConfigModule(resolvedPath);
@@ -98,6 +102,41 @@ export class ApibotConfigParser {
98
102
  mkdirSync(dirPath, { recursive: true });
99
103
  }
100
104
  }
105
+ async loadEnvConfig() {
106
+ const provider = process.env.EXPLORBOT_AI_PROVIDER;
107
+ const modelSpec = process.env.EXPLORBOT_AI_MODEL;
108
+ if (!provider && !modelSpec) {
109
+ throw new Error('No configuration file found. Create apibot.config.js or set EXPLORBOT_URL and EXPLORBOT_AI_PROVIDER environment variables');
110
+ }
111
+ if (modelSpec && !provider && !modelSpec.includes('/')) {
112
+ throw new Error('EXPLORBOT_AI_MODEL needs a provider — set EXPLORBOT_AI_PROVIDER, or write it as "provider/model-id"');
113
+ }
114
+ const baseEndpoint = process.env.EXPLORBOT_URL;
115
+ if (!baseEndpoint) {
116
+ throw new Error('No API endpoint to test. Set EXPLORBOT_URL to the API base endpoint');
117
+ }
118
+ const outputRoot = resolveOutputRoot();
119
+ materializeKnowledge(outputRoot);
120
+ const api = { baseEndpoint };
121
+ if (process.env.EXPLORBOT_API_SPEC) {
122
+ api.spec = [process.env.EXPLORBOT_API_SPEC];
123
+ }
124
+ let model;
125
+ if (provider && modelSpec)
126
+ model = await createModel(provider, modelSpec);
127
+ if (provider && !modelSpec)
128
+ model = await resolveModel(provider, 'model');
129
+ if (!provider)
130
+ model = await resolveModel(modelSpec, 'model');
131
+ this.config = {
132
+ ai: { model },
133
+ api,
134
+ dirs: { output: '.', knowledge: 'knowledge' },
135
+ };
136
+ this.configPath = path.join(outputRoot, 'apibot.config.js');
137
+ this.validateConfig(this.config);
138
+ return this.config;
139
+ }
101
140
  findConfigFile() {
102
141
  const apibotPaths = ['apibot.config.js', 'apibot.config.mjs', 'apibot.config.ts'];
103
142
  for (const p of apibotPaths) {
@@ -6,10 +6,12 @@ class Documentarian {
6
6
  provider;
7
7
  config;
8
8
  explorer;
9
- constructor(provider, config = {}, explorer) {
9
+ stateManager;
10
+ constructor(provider, config = {}, explorer, stateManager) {
10
11
  this.provider = provider;
11
12
  this.config = config;
12
13
  this.explorer = explorer;
14
+ this.stateManager = stateManager;
13
15
  }
14
16
  async document(state, research, captureState) {
15
17
  const interactiveEnabled = this.config.docs?.interactive === true && this.explorer;
@@ -35,7 +37,7 @@ class Documentarian {
35
37
  let meaningfulInteractions = [];
36
38
  try {
37
39
  tag('info').log('Starting interactive exploration...');
38
- const deterministicInteractions = await collectDocInteractions(this.explorer, state, research, this.config, captureState);
40
+ const deterministicInteractions = await collectDocInteractions(this.explorer, this.stateManager, state, research, this.config, captureState);
39
41
  meaningfulInteractions = this.getMeaningfulInteractions(deterministicInteractions);
40
42
  if (meaningfulInteractions.length > 0) {
41
43
  tag('success').log(`Collected ${meaningfulInteractions.length} deterministic interactions`);
@@ -75,6 +77,9 @@ class Documentarian {
75
77
  if ((interaction.changes?.newElements || 0) > 0) {
76
78
  return true;
77
79
  }
80
+ if ((interaction.changes?.removedElements || 0) > 0) {
81
+ return true;
82
+ }
78
83
  return (interaction.discoveredUrls || []).length > 0;
79
84
  });
80
85
  }
@@ -6,19 +6,19 @@ const MAX_LINKS = 15;
6
6
  const DEFAULT_WAIT_MS = 700;
7
7
  const TAB_WAIT_MS = 500;
8
8
  const DEFAULT_DENIED_ACTION_LABELS = ['delete', 'remove', 'destroy', 'archive', 'discard', 'logout', 'sign out', 'signout', 'sign_out', 'erase', 'drop'];
9
- export async function collectDocInteractions(explorer, state, research, config = {}, captureState) {
9
+ export async function collectDocInteractions(explorer, stateManager, state, research, config = {}, captureState) {
10
10
  const sections = parseResearchSections(research);
11
11
  const transitions = [];
12
12
  const maxInteractions = getPositiveConfigNumber(config.docs?.maxInteractions, DEFAULT_MAX_INTERACTIONS);
13
13
  const tabGroup = findTabGroup(sections);
14
14
  if (tabGroup) {
15
- transitions.push(...(await exploreTabGroup(explorer, tabGroup, state.url, maxInteractions, captureState)));
15
+ transitions.push(...(await exploreTabGroup(explorer, stateManager, tabGroup, state.url, maxInteractions, captureState)));
16
16
  }
17
17
  for (const candidate of findActionCandidates(sections, config)) {
18
18
  if (transitions.length >= maxInteractions) {
19
19
  break;
20
20
  }
21
- const transition = await executeInteraction(explorer, candidate, state.url, DEFAULT_WAIT_MS, captureState);
21
+ const transition = await executeInteraction(explorer, stateManager, candidate, state.url, DEFAULT_WAIT_MS, captureState);
22
22
  if (!transition) {
23
23
  continue;
24
24
  }
@@ -33,13 +33,13 @@ export function pickDocActionCandidates(research, config = {}) {
33
33
  section: candidate.sectionName,
34
34
  }));
35
35
  }
36
- async function exploreTabGroup(explorer, tabGroup, restoreUrl, maxInteractions, captureState) {
36
+ async function exploreTabGroup(explorer, stateManager, tabGroup, restoreUrl, maxInteractions, captureState) {
37
37
  const transitions = [];
38
38
  for (const element of tabGroup.elements) {
39
39
  if (transitions.length >= maxInteractions) {
40
40
  break;
41
41
  }
42
- const transition = await executeInteraction(explorer, {
42
+ const transition = await executeInteraction(explorer, stateManager, {
43
43
  element,
44
44
  container: tabGroup.container,
45
45
  role: 'tab',
@@ -53,17 +53,18 @@ async function exploreTabGroup(explorer, tabGroup, restoreUrl, maxInteractions,
53
53
  await restoreInteractionState(explorer, restoreUrl, buildPrimaryCommand(tabGroup.elements[0], tabGroup.container));
54
54
  return transitions;
55
55
  }
56
- async function executeInteraction(explorer, candidate, restoreUrl, waitMs, captureState) {
57
- const beforeState = explorer.getStateManager().getCurrentState();
56
+ async function executeInteraction(explorer, stateManager, candidate, restoreUrl, waitMs, captureState) {
57
+ const beforeState = stateManager.getCurrentState();
58
58
  if (!beforeState) {
59
59
  return null;
60
60
  }
61
+ const beforeScreenshot = await captureState?.before();
61
62
  const executed = await attemptInteraction(explorer, candidate);
62
63
  if (!executed) {
63
64
  return null;
64
65
  }
65
66
  await wait(waitMs);
66
- const afterState = explorer.getStateManager().getCurrentState();
67
+ const afterState = stateManager.getCurrentState();
67
68
  if (!afterState) {
68
69
  return null;
69
70
  }
@@ -75,18 +76,18 @@ async function executeInteraction(explorer, candidate, restoreUrl, waitMs, captu
75
76
  removedElements: ariaChanges.removedCount,
76
77
  });
77
78
  if (captureState && isMeaningfulStateTransition(transition)) {
78
- const screenshot = await captureState(afterState, transition);
79
+ const screenshot = await captureState.after(beforeScreenshot ?? null, afterState, transition);
79
80
  if (screenshot) {
80
81
  transition.screenshot = screenshot;
81
82
  }
82
83
  }
83
- if (urlChanged || ariaChanges.newCount > 0) {
84
+ if (urlChanged || ariaChanges.newCount > 0 || ariaChanges.removedCount > 0) {
84
85
  await restoreInteractionState(explorer, restoreUrl);
85
86
  }
86
87
  return transition;
87
88
  }
88
89
  async function attemptInteraction(explorer, candidate) {
89
- const action = explorer.createAction();
90
+ const action = explorer.action();
90
91
  for (const command of buildClickCommands(candidate.element, candidate.container)) {
91
92
  const success = await action.attempt(command, buildPurpose(candidate));
92
93
  if (success) {
@@ -97,22 +98,27 @@ async function attemptInteraction(explorer, candidate) {
97
98
  }
98
99
  async function restoreInteractionState(explorer, restoreUrl, primaryCommand) {
99
100
  if (primaryCommand) {
100
- const action = explorer.createAction();
101
+ const action = explorer.action();
101
102
  const restored = await action.attempt(primaryCommand, `Restore initial state on ${restoreUrl}`);
102
103
  if (restored) {
103
104
  await wait(TAB_WAIT_MS);
104
105
  return;
105
106
  }
106
107
  }
107
- const action = explorer.createAction();
108
+ const action = explorer.action();
108
109
  await action.attempt(`I.amOnPage(${JSON.stringify(restoreUrl)})`, `Restore page ${restoreUrl}`);
109
110
  }
110
111
  function buildTransition(candidate, beforeState, afterState, changes) {
112
+ const existingUrls = new Set(collectLinks(beforeState).map((link) => link.url));
111
113
  const transition = {
112
114
  action: describeAction(candidate),
113
115
  before: summarizeInteractiveState(beforeState),
114
116
  after: summarizeInteractiveState(afterState),
115
- discoveredUrls: collectLinks(afterState).map((link) => link.url),
117
+ discoveredUrls: [
118
+ ...new Set(collectLinks(afterState)
119
+ .map((link) => link.url)
120
+ .filter((url) => !existingUrls.has(url))),
121
+ ],
116
122
  newCapabilities: collectDiscoveryNotes(afterState, changes),
117
123
  element: buildInteractionElement(candidate),
118
124
  changes,
@@ -150,7 +156,7 @@ function isMeaningfulStateTransition(transition) {
150
156
  if (transition.targetUrl || transition.changes?.urlChanged) {
151
157
  return true;
152
158
  }
153
- return (transition.changes?.newElements || 0) > 0;
159
+ return (transition.changes?.newElements || 0) > 0 || (transition.changes?.removedElements || 0) > 0;
154
160
  }
155
161
  function buildInteractionElement(candidate) {
156
162
  const element = {
@@ -4,10 +4,6 @@ import { Command } from 'commander';
4
4
  import { setPreserveConsoleLogs } from "../../../src/utils/logger.js";
5
5
  import { DocBot } from "./docbot.js";
6
6
  function buildOptions(options) {
7
- let session = options.session;
8
- if (options.session === true) {
9
- session = 'output/session.json';
10
- }
11
7
  return {
12
8
  verbose: options.verbose || options.debug,
13
9
  config: options.config,
@@ -15,7 +11,7 @@ function buildOptions(options) {
15
11
  show: options.show,
16
12
  headless: options.headless,
17
13
  incognito: options.incognito,
18
- session,
14
+ session: options.session,
19
15
  docsConfig: options.docsConfig,
20
16
  };
21
17
  }
@@ -84,6 +80,7 @@ export function createDocsCommands(name = 'docs') {
84
80
  output: 'docs',
85
81
  screenshot: true,
86
82
  interactive: false,
83
+ ignoreErrors: true,
87
84
  collapseDynamicPages: true,
88
85
  scope: 'site',
89
86
  includePaths: [],
@@ -105,6 +105,7 @@ class DocbotConfigParser {
105
105
  output: 'docs',
106
106
  screenshot: true,
107
107
  interactive: false,
108
+ ignoreErrors: true,
108
109
  collapseDynamicPages: true,
109
110
  scope: 'site',
110
111
  includePaths: [],
@@ -7,10 +7,10 @@ import { sanitizeFilename } from "../../../src/utils/strings.js";
7
7
  import { Documentarian } from "./ai/documentarian.js";
8
8
  import { DocbotConfigParser } from "./config.js";
9
9
  import { renderPageDocumentation, renderSpecIndex } from "./docs-renderer.js";
10
- import { renderMermaidBody } from "./state-diagram.js";
11
10
  import { getDocPageKey, shouldCrawlDocPath } from "./path-filter.js";
12
11
  import { extractResearchNavigationTargets } from "./research-navigation.js";
13
- import { captureDocumentationScreenshots, captureInteractionScreenshot } from "./screenshots.js";
12
+ import { captureBeforeInteraction, captureDocumentationScreenshots, captureInteractionScreenshot } from "./screenshots.js";
13
+ import { renderMermaidBody } from "./state-diagram.js";
14
14
  class DocBot {
15
15
  explorBot;
16
16
  configParser;
@@ -39,7 +39,7 @@ class DocBot {
39
39
  config: this.options.docsConfig,
40
40
  path: this.options.path,
41
41
  });
42
- this.documentarian = new Documentarian(this.explorBot.getProvider(), this.config, this.explorBot.getExplorer());
42
+ this.documentarian = new Documentarian(this.explorBot.getProvider(), this.config, this.explorBot.getExplorer(), this.explorBot.stateManager());
43
43
  this.ensureDirectory(this.configParser.getOutputDir());
44
44
  this.ensureDirectory(this.getPagesDir());
45
45
  }
@@ -66,7 +66,7 @@ class DocBot {
66
66
  if (documented.has(targetKey)) {
67
67
  continue;
68
68
  }
69
- const stateManager = this.explorBot.getExplorer().getStateManager();
69
+ const stateManager = this.explorBot.stateManager();
70
70
  if (stateManager.hasVisitedState(target)) {
71
71
  continue;
72
72
  }
@@ -98,16 +98,17 @@ class DocBot {
98
98
  force: true,
99
99
  });
100
100
  const pagePath = this.getPageFilePath(state.url);
101
- const documentation = await this.documentarian.document(state, research, async (interactionState, transition) => {
102
- if (!this.shouldUseScreenshots()) {
103
- return null;
101
+ const captureState = this.shouldUseScreenshots()
102
+ ? {
103
+ before: () => captureBeforeInteraction(this.explorBot.getExplorer()),
104
+ after: (beforeScreenshot, interactionState, transition) => captureInteractionScreenshot(this.explorBot.getExplorer(), beforeScreenshot, interactionState, transition, {
105
+ pageFilePath: pagePath,
106
+ screenshotsDir: this.getScreenshotsDir(),
107
+ config: this.config,
108
+ }),
104
109
  }
105
- return captureInteractionScreenshot(this.explorBot.getExplorer(), interactionState, transition, {
106
- pageFilePath: pagePath,
107
- screenshotsDir: this.getScreenshotsDir(),
108
- config: this.config,
109
- });
110
- });
110
+ : undefined;
111
+ const documentation = await this.documentarian.document(state, research, captureState);
111
112
  const lowSignalReason = this.getLowSignalReason(documentation, research);
112
113
  if (lowSignalReason) {
113
114
  skipped.push({
@@ -147,6 +148,9 @@ class DocBot {
147
148
  }
148
149
  catch (error) {
149
150
  const reason = error instanceof Error ? error.message : String(error);
151
+ if (!this.shouldIgnoreError(error)) {
152
+ throw error;
153
+ }
150
154
  tag('warning').log(`Skipping ${target}: ${reason}`);
151
155
  skipped.push({
152
156
  url: target,
@@ -360,6 +364,28 @@ class DocBot {
360
364
  }
361
365
  return `low-signal page: only ${documentation.can.length} proven actions and ${interactiveCount} interactive elements`;
362
366
  }
367
+ shouldIgnoreError(error) {
368
+ const ignoreErrors = this.config.docs?.ignoreErrors;
369
+ if (ignoreErrors === undefined || ignoreErrors === true)
370
+ return true;
371
+ if (ignoreErrors === false)
372
+ return false;
373
+ const details = [error instanceof Error ? error.name : '', error instanceof Error ? error.message : String(error)];
374
+ if (typeof error === 'object' && error && 'code' in error) {
375
+ details.push(String(error.code));
376
+ }
377
+ const normalized = details
378
+ .join(' ')
379
+ .toLowerCase()
380
+ .replaceAll(/[\W_]+/g, ' ');
381
+ return ignoreErrors.some((pattern) => {
382
+ const normalizedPattern = pattern
383
+ .trim()
384
+ .toLowerCase()
385
+ .replaceAll(/[\W_]+/g, ' ');
386
+ return normalizedPattern.length > 0 && normalized.includes(normalizedPattern);
387
+ });
388
+ }
363
389
  countInteractiveElements(research) {
364
390
  const matches = [...research.matchAll(/\((\d+) elements?\)/g)];
365
391
  return matches.reduce((sum, match) => sum + Number.parseInt(match[1], 10), 0);