explorbot 0.1.29 → 0.1.30

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 (47) 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 +1 -1
  16. package/dist/src/action.js +26 -23
  17. package/dist/src/ai/historian/codeceptjs.js +3 -2
  18. package/dist/src/ai/historian/experience.js +48 -6
  19. package/dist/src/ai/historian/playwright.js +2 -1
  20. package/dist/src/ai/historian/utils.js +1 -19
  21. package/dist/src/ai/historian.js +1 -1
  22. package/dist/src/ai/quartermaster.js +2 -2
  23. package/dist/src/ai/tester.js +3 -0
  24. package/dist/src/ai/tools.js +0 -1
  25. package/dist/src/experience-tracker.js +1 -1
  26. package/dist/src/explorbot.js +7 -1
  27. package/dist/src/explorer.js +30 -27
  28. package/dist/src/utils/browser-errors.js +5 -0
  29. package/dist/src/utils/page-readiness.js +48 -0
  30. package/dist/src/utils/step-analyzer.js +68 -0
  31. package/package.json +1 -1
  32. package/src/action.ts +24 -26
  33. package/src/ai/historian/codeceptjs.ts +3 -2
  34. package/src/ai/historian/experience.ts +51 -6
  35. package/src/ai/historian/playwright.ts +2 -1
  36. package/src/ai/historian/utils.ts +1 -21
  37. package/src/ai/historian.ts +1 -1
  38. package/src/ai/quartermaster.ts +2 -2
  39. package/src/ai/tester.ts +3 -0
  40. package/src/ai/tools.ts +0 -1
  41. package/src/config.ts +1 -0
  42. package/src/experience-tracker.ts +1 -1
  43. package/src/explorbot.ts +7 -1
  44. package/src/explorer.ts +28 -27
  45. package/src/utils/browser-errors.ts +6 -0
  46. package/src/utils/page-readiness.ts +59 -0
  47. package/src/utils/step-analyzer.ts +73 -0
@@ -2,11 +2,11 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSy
2
2
  import { basename, dirname, join } from 'node:path';
3
3
  import matter from 'gray-matter';
4
4
  import { marked } from 'marked';
5
- import { isNonReusableCode } from "./ai/historian/utils.js";
6
5
  import { ConfigParser } from './config.js';
7
6
  import { KnowledgeTracker } from './knowledge-tracker.js';
8
7
  import { createDebug, tag } from './utils/logger.js';
9
8
  import { mdq } from './utils/markdown-query.js';
9
+ import { isNonReusableCode } from "./utils/step-analyzer.js";
10
10
  import { extractStatePath } from './utils/url-matcher.js';
11
11
  const debugLog = createDebug('explorbot:experience');
12
12
  const DEFAULT_MAX_EXPERIENCE_LINES = 100;
@@ -180,7 +180,13 @@ export class ExplorBot {
180
180
  this.agents.tester = this.createAgent(({ ai, explorer }) => {
181
181
  const researcher = this.agentResearcher();
182
182
  const navigator = this.agentNavigator();
183
- const tools = createAgentTools({ explorer, researcher, navigator });
183
+ const stateManager = explorer.getStateManager();
184
+ const experienceTracker = stateManager.getExperienceTracker();
185
+ const getState = () => {
186
+ const state = stateManager.getCurrentState();
187
+ return state ? ActionResult.fromState(state) : null;
188
+ };
189
+ const tools = createAgentTools({ explorer, researcher, navigator, experienceTracker, getState });
184
190
  return new Tester(explorer, ai, researcher, navigator, tools);
185
191
  });
186
192
  const qm = this.agentQuartermaster();
@@ -2,10 +2,10 @@ 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';
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 { ActionResult } from "./action-result.js";
10
10
  import Action from './action.js';
11
11
  import { visuallyAnnotateContainers } from "./ai/researcher/coordinates.js";
@@ -17,10 +17,11 @@ import { PlaywrightRecorder } from "./playwright-recorder.js";
17
17
  import { Reporter } from "./reporter.js";
18
18
  import { StateManager } from './state-manager.js';
19
19
  import { TestResult } from "./test-plan.js";
20
+ import { BrowserRecoveryError, isFatalBrowserError, isNavigationTransitionError } from "./utils/browser-errors.js";
20
21
  import { ELEMENT_EXTRACTION_CONFIG, getElementDataExtractorSource } from "./utils/html.js";
21
22
  import { createDebug, log, tag } from './utils/logger.js';
23
+ import { waitForPageReadiness } from "./utils/page-readiness.js";
22
24
  import { WebElement } from "./utils/web-element.js";
23
- import { BrowserRecoveryError, isFatalBrowserError } from "./utils/browser-errors.js";
24
25
  const debugLog = createDebug('explorbot:explorer');
25
26
  const RECOVERABLE_NAVIGATION_ERRORS = /net::ERR_ABORTED|page\.screenshot.*Timeout|waiting for fonts to load/i;
26
27
  class Explorer {
@@ -250,16 +251,29 @@ class Explorer {
250
251
  return await operation();
251
252
  }
252
253
  catch (error) {
253
- if (!this.isFatalBrowserError(error))
254
- throw error;
254
+ let recoveryError = error;
255
+ if (isNavigationTransitionError(error)) {
256
+ tag('warning').log(`${label}: page is still navigating, waiting before retry...`);
257
+ await this.waitForPageReadiness();
258
+ try {
259
+ return await operation();
260
+ }
261
+ catch (retryError) {
262
+ if (!isNavigationTransitionError(retryError) && !this.isFatalBrowserError(retryError))
263
+ throw retryError;
264
+ recoveryError = retryError;
265
+ }
266
+ }
267
+ if (!this.isFatalBrowserError(recoveryError))
268
+ throw recoveryError;
255
269
  tag('warning').log(`${label}: browser page is unavailable, recovering...`);
256
270
  let recovered = await this.recoverFromBrowserError();
257
271
  if (!recovered)
258
272
  recovered = await this.restartBrowser();
259
273
  if (!recovered)
260
- throw new BrowserRecoveryError(label, error, false);
261
- if (!(await this.waitForUsablePageDom()))
262
- throw new BrowserRecoveryError(label, error, true);
274
+ throw new BrowserRecoveryError(label, recoveryError, false);
275
+ if (!(await this.waitForPageReadiness()))
276
+ throw new BrowserRecoveryError(label, recoveryError, true);
263
277
  try {
264
278
  return await operation();
265
279
  }
@@ -308,7 +322,7 @@ class Explorer {
308
322
  if (!RECOVERABLE_NAVIGATION_ERRORS.test(msg))
309
323
  throw err;
310
324
  tag('warning').log(`Navigation warning (continuing after load): ${msg.split('\n')[0]}`);
311
- await this.playwrightHelper.page.waitForLoadState('domcontentloaded', { timeout: 10000 }).catch(() => { });
325
+ await this.waitForPageReadiness();
312
326
  await action.capturePageState();
313
327
  }
314
328
  }
@@ -412,11 +426,11 @@ class Explorer {
412
426
  if (url) {
413
427
  tag('warning').log(`Browser error detected, recovering by navigating to ${url}`);
414
428
  await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
415
- return this.waitForUsablePageDom();
429
+ return this.waitForPageReadiness();
416
430
  }
417
431
  tag('warning').log('Browser error detected, reloading page');
418
432
  await this.playwrightHelper.page.reload({ waitUntil: 'domcontentloaded', timeout: 10000 });
419
- return this.waitForUsablePageDom();
433
+ return this.waitForPageReadiness();
420
434
  }
421
435
  catch (err) {
422
436
  tag('error').log(`Browser recovery failed: ${err instanceof Error ? err.message : err}`);
@@ -450,7 +464,7 @@ class Explorer {
450
464
  this.listenToStateChanged();
451
465
  if (url) {
452
466
  await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
453
- if (!(await this.waitForUsablePageDom()))
467
+ if (!(await this.waitForPageReadiness()))
454
468
  return false;
455
469
  }
456
470
  tag('success').log('Browser restarted');
@@ -467,25 +481,14 @@ class Explorer {
467
481
  await this.playwrightHelper.switchTo();
468
482
  }
469
483
  }
470
- async waitForUsablePageDom() {
484
+ async waitForPageReadiness() {
471
485
  const page = this.playwrightHelper?.page;
472
486
  if (!page)
473
487
  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(() => { });
488
+ await waitForPageReadiness(page, {
489
+ timeout: this.config.playwright.waitForTimeout,
490
+ spinnerSelectors: this.config.playwright.spinnerSelectors,
491
+ });
489
492
  return true;
490
493
  }
491
494
  async isInsideIframe() {
@@ -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
  export class BrowserRecoveryError extends Error {
6
7
  originalError;
7
8
  recovered;
@@ -18,6 +19,10 @@ export function isFatalBrowserError(error) {
18
19
  const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
19
20
  return FATAL_BROWSER_ERROR_MARKERS.some((marker) => message.includes(marker.toLowerCase()));
20
21
  }
22
+ export function isNavigationTransitionError(error) {
23
+ const message = browserErrorMessage(error).toLowerCase();
24
+ return NAVIGATION_TRANSITION_ERROR_MARKERS.some((marker) => message.includes(marker.toLowerCase()));
25
+ }
21
26
  export function browserErrorMessage(error) {
22
27
  return error instanceof Error ? error.message : String(error);
23
28
  }
@@ -0,0 +1,48 @@
1
+ export async function waitForPageReadiness(page, options = {}) {
2
+ if (!page)
3
+ return;
4
+ const timeout = options.timeout ?? 6000;
5
+ await page.waitForLoadState?.('domcontentloaded', { timeout })?.catch(() => { });
6
+ await Promise.race([waitForNetworkIdle(page, timeout), waitForVisibleSpinnersHidden(page, options.spinnerSelectors || [], timeout), sleep(timeout)]).catch(() => { });
7
+ await waitForPageBodyContent(page, timeout);
8
+ }
9
+ function waitForNetworkIdle(page, timeout) {
10
+ if (!page?.waitForLoadState)
11
+ return Promise.resolve();
12
+ return page.waitForLoadState('networkidle', { timeout }).catch(() => { });
13
+ }
14
+ async function waitForVisibleSpinnersHidden(page, selectors, timeout) {
15
+ if (!selectors.length)
16
+ return new Promise(() => { });
17
+ if (!page?.locator)
18
+ return new Promise(() => { });
19
+ const visibleSpinners = [];
20
+ for (const selector of selectors) {
21
+ const locator = page.locator(selector);
22
+ const isVisible = await locator
23
+ .first()
24
+ .isVisible({ timeout: 100 })
25
+ .catch(() => false);
26
+ if (!isVisible)
27
+ continue;
28
+ visibleSpinners.push(locator);
29
+ }
30
+ if (visibleSpinners.length === 0)
31
+ return new Promise(() => { });
32
+ return Promise.all(visibleSpinners.map((locator) => locator.waitFor({ state: 'hidden', timeout }).catch(() => { }))).then(() => { });
33
+ }
34
+ function waitForPageBodyContent(page, timeout) {
35
+ if (!page?.waitForFunction)
36
+ return Promise.resolve();
37
+ return page
38
+ .waitForFunction(() => {
39
+ const body = document.body;
40
+ if (!body)
41
+ return false;
42
+ return body.children.length > 0 || body.textContent?.trim().length > 0;
43
+ }, undefined, { timeout })
44
+ .catch(() => { });
45
+ }
46
+ function sleep(ms) {
47
+ return new Promise((resolve) => setTimeout(resolve, ms));
48
+ }
@@ -0,0 +1,68 @@
1
+ import { isDynamicId } from "./xpath.js";
2
+ export const CODECEPT_TOOLS = ['click', 'hover', 'pressKey', 'form'];
3
+ const CODECEPT_FORM_COMMANDS = ['I.fillField', 'I.type', 'I.selectOption', 'I.attachFile', 'I.checkOption', 'I.uncheckOption'];
4
+ export function isCodeceptToolName(toolName) {
5
+ return CODECEPT_TOOLS.includes(toolName);
6
+ }
7
+ export function getCodeceptToolName(commandName) {
8
+ const toolName = CODECEPT_TOOLS.find((name) => commandName === `I.${name}`);
9
+ if (toolName)
10
+ return toolName;
11
+ if (CODECEPT_FORM_COMMANDS.includes(commandName))
12
+ return 'form';
13
+ return null;
14
+ }
15
+ export function getCodeceptToolNameFromCode(code) {
16
+ const parenIndex = code.trim().indexOf('(');
17
+ if (parenIndex < 1)
18
+ return null;
19
+ return getCodeceptToolName(code.trim().slice(0, parenIndex));
20
+ }
21
+ export function stripComments(code) {
22
+ return code
23
+ .split('\n')
24
+ .filter((line) => {
25
+ const trimmed = line.trim();
26
+ return trimmed && !trimmed.startsWith('//') && !trimmed.startsWith('/*') && !trimmed.startsWith('*');
27
+ })
28
+ .join('\n');
29
+ }
30
+ export function isNonReusableCode(code) {
31
+ if (/\bI\.clickXY\s*\(/.test(code))
32
+ return true;
33
+ for (const m of code.matchAll(/#([A-Za-z_][\w-]*)/g)) {
34
+ if (isDynamicId(m[1]))
35
+ return true;
36
+ }
37
+ return false;
38
+ }
39
+ export function toReusableSessionStep(step) {
40
+ if (step.status !== 'passed')
41
+ return null;
42
+ const code = stripComments(step.text);
43
+ if (!code || isNonReusableCode(code))
44
+ return null;
45
+ const toolName = getCodeceptToolNameFromCode(code);
46
+ if (!toolName)
47
+ return null;
48
+ return {
49
+ message: step.text,
50
+ status: 'passed',
51
+ tool: toolName,
52
+ code,
53
+ };
54
+ }
55
+ export function mergeUniqueStepsByCode(primary, secondary) {
56
+ const merged = [];
57
+ const seen = new Set();
58
+ for (const step of [...primary, ...secondary]) {
59
+ const identity = stripComments(step.code || '').trim();
60
+ if (!identity)
61
+ continue;
62
+ if (seen.has(identity))
63
+ continue;
64
+ seen.add(identity);
65
+ merged.push(step);
66
+ }
67
+ return merged;
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
package/src/action.ts CHANGED
@@ -18,14 +18,16 @@ import type { UserResolveFunction } from './explorbot.ts';
18
18
  import { Observability } from './observability.ts';
19
19
  import type { PlaywrightRecorder } from './playwright-recorder.ts';
20
20
  import type { StateManager } from './state-manager.js';
21
+ import { isFatalBrowserError, isNavigationTransitionError } from './utils/browser-errors.ts';
21
22
  import { extractCodeBlocks } from './utils/code-extractor.js';
22
23
  import { htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
23
24
  import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
25
+ import { waitForPageReadiness } from './utils/page-readiness.ts';
24
26
  import { safeFilename } from './utils/strings.ts';
25
27
  import { throttle } from './utils/throttle.ts';
26
- import { isFatalBrowserError } from './utils/browser-errors.ts';
27
28
 
28
29
  const debugLog = createDebug('explorbot:action');
30
+ const CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS = 3;
29
31
 
30
32
  class Action {
31
33
  private actor: CodeceptJS.I;
@@ -78,16 +80,21 @@ class Action {
78
80
  const timestamp = Date.now();
79
81
  const page = this.playwrightHelper.page;
80
82
  const frame = this.playwrightHelper.frame;
81
- await page?.waitForLoadState('domcontentloaded', { timeout: 10000 })?.catch(() => {});
82
- await waitForUsablePageDom(page);
83
+ await this.waitForPageReadiness(page);
83
84
  const grabAll = () => Promise.all([captureHtml(page, frame, this.actor), captureTitle(page, this.actor), this.captureBrowserLogs()]);
84
- const [html, title, browserLogs] = await grabAll().catch(async (err: Error) => {
85
- const msg = err instanceof Error ? err.message : String(err);
86
- if (!/navigating and changing the content/i.test(msg)) throw err;
87
- await page?.waitForLoadState('domcontentloaded', { timeout: 10000 })?.catch(() => {});
88
- await waitForUsablePageDom(page);
89
- return grabAll();
90
- });
85
+ let html = '';
86
+ let title = '';
87
+ let browserLogs: any[] = [];
88
+ for (let attempt = 1; attempt <= CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS; attempt++) {
89
+ try {
90
+ [html, title, browserLogs] = await grabAll();
91
+ break;
92
+ } catch (err) {
93
+ if (!isNavigationTransitionError(err)) throw err;
94
+ if (attempt === CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS) throw err;
95
+ await this.waitForPageReadiness(page);
96
+ }
97
+ }
91
98
  const url = page?.url() || (await (this.actor as any).grabCurrentUrl?.());
92
99
 
93
100
  let screenshotFile: string | undefined = undefined;
@@ -442,6 +449,13 @@ class Action {
442
449
  getActionResult(): ActionResult | null {
443
450
  return this.actionResult;
444
451
  }
452
+
453
+ private async waitForPageReadiness(page: any): Promise<void> {
454
+ await waitForPageReadiness(page, {
455
+ timeout: this.config.playwright.waitForTimeout,
456
+ spinnerSelectors: this.config.playwright.spinnerSelectors,
457
+ });
458
+ }
445
459
  }
446
460
 
447
461
  export default Action;
@@ -453,22 +467,6 @@ function errorToString(error: any): string {
453
467
  return error.message || error.toString();
454
468
  }
455
469
 
456
- async function waitForUsablePageDom(page: any): Promise<void> {
457
- if (!page?.waitForFunction) return;
458
-
459
- await page
460
- .waitForFunction(
461
- () => {
462
- const body = document.body;
463
- if (!body) return false;
464
- return body.children.length > 0 || body.textContent?.trim().length > 0;
465
- },
466
- undefined,
467
- { timeout: 5000 }
468
- )
469
- .catch(() => {});
470
- }
471
-
472
470
  async function captureHtml(page: any, frame: any, actor: any): Promise<string> {
473
471
  if (frame?.content) return frame.content();
474
472
  if (page?.content) return page.content();
@@ -7,10 +7,11 @@ import type { Plan } from '../../test-plan.ts';
7
7
  import { tag } from '../../utils/logger.ts';
8
8
  import { relativeToCwd } from '../../utils/next-steps.ts';
9
9
  import { safeFilename } from '../../utils/strings.ts';
10
+ import { CODECEPT_TOOLS, isNonReusableCode, stripComments } from '../../utils/step-analyzer.ts';
10
11
  import type { Conversation } from '../conversation.ts';
11
- import { ASSERTION_TOOLS, CODECEPT_TOOLS } from '../tools.ts';
12
+ import { ASSERTION_TOOLS } from '../tools.ts';
12
13
  import type { Constructor } from './mixin.ts';
13
- import { escapeString, getExecutionLabel, isNonReusableCode, stripComments } from './utils.ts';
14
+ import { escapeString, getExecutionLabel } from './utils.ts';
14
15
 
15
16
  export interface CodeceptJSMethods {
16
17
  toCode(conversation: Conversation, scenario: string): string;
@@ -6,12 +6,12 @@ import type { Reporter, ReporterStep } from '../../reporter.ts';
6
6
  import type { StateManager } from '../../state-manager.ts';
7
7
  import { type Task, Test } from '../../test-plan.ts';
8
8
  import { tag } from '../../utils/logger.ts';
9
+ import { isCodeceptToolName, isNonReusableCode, mergeUniqueStepsByCode, stripComments, toReusableSessionStep } from '../../utils/step-analyzer.ts';
9
10
  import { extractStatePath } from '../../utils/url-matcher.ts';
10
11
  import type { Conversation, ToolExecution } from '../conversation.ts';
11
12
  import type { Provider } from '../provider.ts';
12
- import { CODECEPT_TOOLS } from '../tools.ts';
13
13
  import { type Constructor, debugLog } from './mixin.ts';
14
- import { getExecutionLabel, isNonReusableCode, stripComments } from './utils.ts';
14
+ import { getExecutionLabel } from './utils.ts';
15
15
 
16
16
  export interface ExperienceMethods {
17
17
  saveSession(task: Task, initialState: ActionResult, conversation: Conversation): Promise<void>;
@@ -38,12 +38,18 @@ export function WithExperience<T extends Constructor>(Base: T) {
38
38
  task.generatedCode = this.isPlaywrightFramework() ? await this.toPlaywrightCode(conversation, task.description) : this.toCode(conversation, task.description);
39
39
  }
40
40
 
41
- const steps = await this.extractSteps(toolExecutions);
41
+ const conversationSteps = await this.extractSteps(toolExecutions);
42
+ const taskSteps = this.extractPassedCodeceptSteps(task);
43
+ const steps = mergeUniqueStepsByCode(conversationSteps, taskSteps);
42
44
 
43
45
  const skipExperience = result === 'failed' || (task instanceof Test && (task.hasFailed || task.isSkipped));
44
46
  if (!skipExperience) {
47
+ const hasExistingFlow = this.hasRelevantFlowExperience(initialState);
45
48
  await this.detectRetryPatterns(toolExecutions, initialState);
46
- const body = await this.curateFlow(steps, task, initialState);
49
+ let body = await this.curateFlow(steps, task, initialState);
50
+ if (!body.trim() && !hasExistingFlow) {
51
+ body = this.renderFlowFromSuccessfulSteps(steps, task);
52
+ }
47
53
  if (body.trim()) {
48
54
  const relatedUrls = this.extractVisitedUrls(toolExecutions, initialState.url || '');
49
55
  this.experienceTracker.writeFlow(initialState, body, relatedUrls);
@@ -76,7 +82,7 @@ export function WithExperience<T extends Constructor>(Base: T) {
76
82
  const stepsWithDiffs: Array<{ step: SessionStep; ariaDiff: string | null }> = [];
77
83
 
78
84
  for (const exec of toolExecutions) {
79
- if (!CODECEPT_TOOLS.includes(exec.toolName as any)) continue;
85
+ if (!isCodeceptToolName(exec.toolName)) continue;
80
86
  if (!exec.output?.code) continue;
81
87
  if (!exec.wasSuccessful) continue;
82
88
  if (isNonReusableCode(exec.output.code)) continue;
@@ -96,6 +102,19 @@ export function WithExperience<T extends Constructor>(Base: T) {
96
102
  return stepsWithDiffs.map((s) => s.step);
97
103
  }
98
104
 
105
+ private hasRelevantFlowExperience(state: ActionResult): boolean {
106
+ return this.experienceTracker.getRelevantExperience(state).some((experience) => experience.content.includes('## FLOW:'));
107
+ }
108
+
109
+ private extractPassedCodeceptSteps(task: Task): SessionStep[] {
110
+ const steps: SessionStep[] = [];
111
+ for (const step of Object.values(task.steps)) {
112
+ const sessionStep = toReusableSessionStep(step);
113
+ if (sessionStep) steps.push(sessionStep);
114
+ }
115
+ return steps;
116
+ }
117
+
99
118
  private async curateFlow(steps: SessionStep[], task: Task, initialState: ActionResult): Promise<string> {
100
119
  if (steps.length === 0) return '';
101
120
 
@@ -207,6 +226,32 @@ export function WithExperience<T extends Constructor>(Base: T) {
207
226
  }
208
227
  }
209
228
 
229
+ private renderFlowFromSuccessfulSteps(steps: SessionStep[], task: Task): string {
230
+ if (steps.length === 0) return '';
231
+
232
+ const title = task.description.charAt(0).toLowerCase() + task.description.slice(1);
233
+ const blocks = steps
234
+ .filter((step) => step.code)
235
+ .map((step) => {
236
+ const lines = [`* ${step.message}`];
237
+ lines.push('');
238
+ lines.push('```js');
239
+ lines.push(stripComments(step.code || ''));
240
+ lines.push('```');
241
+ if (step.discovery) {
242
+ lines.push('');
243
+ for (const discovery of step.discovery.split('\n').filter((line) => line.trim())) {
244
+ lines.push(`> ${discovery.trim()}`);
245
+ }
246
+ }
247
+ return lines.join('\n');
248
+ });
249
+
250
+ if (blocks.length === 0) return '';
251
+
252
+ return `## FLOW: ${title}\n\n${blocks.join('\n\n')}\n\n---\n`;
253
+ }
254
+
210
255
  private async detectRetryPatterns(toolExecutions: ToolExecution[], initialState: ActionResult): Promise<void> {
211
256
  if (!this.experienceTracker || !this.stateManager) return;
212
257
 
@@ -214,7 +259,7 @@ export function WithExperience<T extends Constructor>(Base: T) {
214
259
  const candidates: Array<{ failed: ToolExecution[]; success: ToolExecution }> = [];
215
260
 
216
261
  for (const exec of toolExecutions) {
217
- if (!CODECEPT_TOOLS.includes(exec.toolName as any)) continue;
262
+ if (!isCodeceptToolName(exec.toolName)) continue;
218
263
  if (!exec.output?.code) continue;
219
264
 
220
265
  if (!exec.wasSuccessful) {
@@ -8,8 +8,9 @@ import type { Plan } from '../../test-plan.ts';
8
8
  import { tag } from '../../utils/logger.ts';
9
9
  import { relativeToCwd } from '../../utils/next-steps.ts';
10
10
  import { safeFilename } from '../../utils/strings.ts';
11
+ import { CODECEPT_TOOLS } from '../../utils/step-analyzer.ts';
11
12
  import type { Conversation } from '../conversation.ts';
12
- import { ASSERTION_TOOLS, CODECEPT_TOOLS } from '../tools.ts';
13
+ import { ASSERTION_TOOLS } from '../tools.ts';
13
14
  import type { Constructor } from './mixin.ts';
14
15
  import { escapeString, getExecutionLabel } from './utils.ts';
15
16
 
@@ -1,30 +1,10 @@
1
- import { isDynamicId } from '../../utils/xpath.ts';
2
1
  import type { ToolExecution } from '../conversation.ts';
3
-
4
- export function isNonReusableCode(code: string): boolean {
5
- if (/\bI\.clickXY\s*\(/.test(code)) return true;
6
-
7
- for (const m of code.matchAll(/#([A-Za-z_][\w-]*)/g)) {
8
- if (isDynamicId(m[1])) return true;
9
- }
10
-
11
- return false;
12
- }
2
+ export { isNonReusableCode, stripComments } from '../../utils/step-analyzer.ts';
13
3
 
14
4
  export function escapeString(str: string): string {
15
5
  return str.replace(/'/g, "\\'").replace(/\n/g, ' ');
16
6
  }
17
7
 
18
- export function stripComments(code: string): string {
19
- return code
20
- .split('\n')
21
- .filter((line) => {
22
- const trimmed = line.trim();
23
- return trimmed && !trimmed.startsWith('//') && !trimmed.startsWith('/*') && !trimmed.startsWith('*');
24
- })
25
- .join('\n');
26
- }
27
-
28
8
  export function getExecutionLabel(exec: ToolExecution, fallback?: string): string {
29
9
  return exec.input?.explanation || exec.input?.assertion || exec.input?.note || fallback || '';
30
10
  }
@@ -13,7 +13,7 @@ import { type PlaywrightMethods, WithPlaywright } from './historian/playwright.t
13
13
  import { type ScreencastMethods, WithScreencast } from './historian/screencast.ts';
14
14
  import type { Provider } from './provider.ts';
15
15
 
16
- export { isNonReusableCode } from './historian/utils.ts';
16
+ export { isNonReusableCode } from '../utils/step-analyzer.ts';
17
17
 
18
18
  const HistorianBase = WithScreencast(WithPlaywright(WithCodeceptJS(WithExperience(Object as unknown as new (...args: any[]) => object))));
19
19
 
@@ -6,9 +6,9 @@ import { ConfigParser } from '../config.ts';
6
6
  import type { StateManager, StateTransition, WebPageState } from '../state-manager.ts';
7
7
  import type { Task } from '../test-plan.ts';
8
8
  import { createDebug, tag } from '../utils/logger.ts';
9
+ import { isCodeceptToolName } from '../utils/step-analyzer.ts';
9
10
  import type { Conversation, ToolExecution } from './conversation.ts';
10
11
  import type { Provider } from './provider.ts';
11
- import { CODECEPT_TOOLS } from './tools.ts';
12
12
 
13
13
  const debugLog = createDebug('explorbot:quartermaster');
14
14
 
@@ -140,7 +140,7 @@ export class Quartermaster {
140
140
  const pageAnalysis = this.pageAnalyses.get(stateHash);
141
141
 
142
142
  const toolExecutions = conversation.getToolExecutions();
143
- const codeceptExecutions = toolExecutions.filter((e) => CODECEPT_TOOLS.includes(e.toolName as any));
143
+ const codeceptExecutions = toolExecutions.filter((e) => isCodeceptToolName(e.toolName));
144
144
 
145
145
  if (codeceptExecutions.length === 0 && !pageAnalysis?.axeViolations.length) {
146
146
  debugLog('No interactions or violations to analyze');
package/src/ai/tester.ts CHANGED
@@ -853,6 +853,7 @@ export class Tester extends TaskAgent implements Agent {
853
853
 
854
854
  private buildScenarioBlock(task: Test, actionResult: ActionResult): string {
855
855
  const knowledge = this.getKnowledge(actionResult);
856
+ const experience = this.getExperience(actionResult);
856
857
 
857
858
  return dedent`
858
859
  <task>
@@ -883,6 +884,8 @@ export class Tester extends TaskAgent implements Agent {
883
884
  ${this.buildAvailableFiles()}
884
885
 
885
886
  ${knowledge}
887
+
888
+ ${experience}
886
889
  `;
887
890
  }
888
891
 
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