explorbot 0.1.28 → 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 (68) hide show
  1. package/README.md +83 -245
  2. package/bin/explorbot-cli.ts +1 -0
  3. package/boat/doc-collector/src/ai/documentarian.ts +37 -13
  4. package/boat/doc-collector/src/ai/tools.ts +60 -20
  5. package/boat/doc-collector/src/cli.ts +3 -0
  6. package/boat/doc-collector/src/config.ts +7 -0
  7. package/boat/doc-collector/src/docbot.ts +23 -5
  8. package/boat/doc-collector/src/docs-renderer.ts +14 -1
  9. package/boat/doc-collector/src/screenshots.ts +126 -0
  10. package/dist/bin/explorbot-cli.js +1 -0
  11. package/dist/boat/doc-collector/src/ai/documentarian.js +15 -11
  12. package/dist/boat/doc-collector/src/ai/tools.js +53 -20
  13. package/dist/boat/doc-collector/src/cli.js +3 -0
  14. package/dist/boat/doc-collector/src/config.js +3 -0
  15. package/dist/boat/doc-collector/src/docbot.js +19 -4
  16. package/dist/boat/doc-collector/src/docs-renderer.js +12 -1
  17. package/dist/boat/doc-collector/src/screenshots.js +90 -0
  18. package/dist/package.json +8 -6
  19. package/dist/rules/navigator/verification-actions.md +2 -0
  20. package/dist/src/action.js +26 -23
  21. package/dist/src/ai/fisherman.js +14 -3
  22. package/dist/src/ai/historian/codeceptjs.js +3 -2
  23. package/dist/src/ai/historian/experience.js +48 -6
  24. package/dist/src/ai/historian/playwright.js +2 -1
  25. package/dist/src/ai/historian/utils.js +1 -19
  26. package/dist/src/ai/historian.js +1 -1
  27. package/dist/src/ai/pilot.js +19 -4
  28. package/dist/src/ai/planner.js +16 -5
  29. package/dist/src/ai/provider.js +53 -18
  30. package/dist/src/ai/quartermaster.js +2 -2
  31. package/dist/src/ai/researcher.js +7 -1
  32. package/dist/src/ai/rules.js +44 -0
  33. package/dist/src/ai/tester.js +73 -7
  34. package/dist/src/ai/tools.js +66 -1
  35. package/dist/src/experience-tracker.js +1 -1
  36. package/dist/src/explorbot.js +14 -3
  37. package/dist/src/explorer.js +30 -27
  38. package/dist/src/stats.js +16 -0
  39. package/dist/src/utils/aria.js +66 -6
  40. package/dist/src/utils/browser-errors.js +5 -0
  41. package/dist/src/utils/page-readiness.js +48 -0
  42. package/dist/src/utils/step-analyzer.js +68 -0
  43. package/package.json +8 -6
  44. package/rules/navigator/verification-actions.md +2 -0
  45. package/src/action.ts +24 -26
  46. package/src/ai/fisherman.ts +14 -3
  47. package/src/ai/historian/codeceptjs.ts +3 -2
  48. package/src/ai/historian/experience.ts +51 -6
  49. package/src/ai/historian/playwright.ts +2 -1
  50. package/src/ai/historian/utils.ts +1 -21
  51. package/src/ai/historian.ts +1 -1
  52. package/src/ai/pilot.ts +19 -4
  53. package/src/ai/planner.ts +16 -5
  54. package/src/ai/provider.ts +51 -19
  55. package/src/ai/quartermaster.ts +2 -2
  56. package/src/ai/researcher.ts +8 -1
  57. package/src/ai/rules.ts +46 -0
  58. package/src/ai/tester.ts +77 -7
  59. package/src/ai/tools.ts +79 -1
  60. package/src/config.ts +2 -0
  61. package/src/experience-tracker.ts +1 -1
  62. package/src/explorbot.ts +13 -3
  63. package/src/explorer.ts +28 -27
  64. package/src/stats.ts +18 -0
  65. package/src/utils/aria.ts +63 -6
  66. package/src/utils/browser-errors.ts +6 -0
  67. package/src/utils/page-readiness.ts +59 -0
  68. 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;
@@ -23,11 +23,12 @@ import { ConfigParser } from "./config.js";
23
23
  import { ExperienceTracker } from "./experience-tracker.js";
24
24
  import Explorer from "./explorer.js";
25
25
  import { KnowledgeTracker } from "./knowledge-tracker.js";
26
+ import { Stats } from "./stats.js";
26
27
  import { Plan } from "./test-plan.js";
27
- import { parsePlansFromMarkdown } from "./utils/test-plan-markdown.js";
28
28
  import { setVerboseMode, tag } from "./utils/logger.js";
29
29
  import { relativeToCwd } from "./utils/next-steps.js";
30
30
  import { sanitizeFilename } from "./utils/strings.js";
31
+ import { parsePlansFromMarkdown } from "./utils/test-plan-markdown.js";
31
32
  export class ExplorBot {
32
33
  configParser;
33
34
  explorer;
@@ -179,7 +180,13 @@ export class ExplorBot {
179
180
  this.agents.tester = this.createAgent(({ ai, explorer }) => {
180
181
  const researcher = this.agentResearcher();
181
182
  const navigator = this.agentNavigator();
182
- 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 });
183
190
  return new Tester(explorer, ai, researcher, navigator, tools);
184
191
  });
185
192
  const qm = this.agentQuartermaster();
@@ -427,7 +434,11 @@ export class ExplorBot {
427
434
  tag('info').log(`Session report saved: ${relativeToCwd(filePath)}`);
428
435
  const reporter = this.explorer?.getReporter();
429
436
  if (reporter?.isEnabled()) {
430
- await reporter.setRunDescription(markdown);
437
+ let description = markdown;
438
+ const modelsTable = Stats.modelsTable(this.provider.getConfiguredModels());
439
+ if (modelsTable)
440
+ description = `${markdown}\n\n${modelsTable}`;
441
+ await reporter.setRunDescription(description);
431
442
  }
432
443
  this.lastReportedTestCount = tests.length;
433
444
  }
@@ -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() {
package/dist/src/stats.js CHANGED
@@ -39,6 +39,22 @@ export class Stats {
39
39
  }
40
40
  return String(num);
41
41
  }
42
+ static modelsTable(roleModels) {
43
+ const usedModels = Object.entries(Stats.models).filter(([, tokens]) => tokens.total > 0);
44
+ if (usedModels.length === 0)
45
+ return '';
46
+ const rolesByModel = {};
47
+ for (const [role, model] of Object.entries(roleModels)) {
48
+ if (!rolesByModel[model])
49
+ rolesByModel[model] = [];
50
+ rolesByModel[model].push(role);
51
+ }
52
+ const rows = usedModels.map(([model, tokens]) => {
53
+ const roles = rolesByModel[model]?.join(', ') || '-';
54
+ return `| ${roles} | ${model} | ${Stats.humanizeTokens(tokens.total)} |`;
55
+ });
56
+ return ['## Models', '', '| Role | Model | Tokens |', '| --- | --- | --- |', ...rows].join('\n');
57
+ }
42
58
  static hasActivity() {
43
59
  if (Stats.tests > 0 || Stats.plans > 0 || Stats.researches > 0)
44
60
  return true;
@@ -334,6 +334,56 @@ const detectRenames = (prev, curr, prevTotals, currTotals) => {
334
334
  }
335
335
  return { added, removed };
336
336
  };
337
+ // Interactive controls keep a stable role+name across a state flip; only an ARIA state
338
+ // attribute changes. Report those flips on their own line so the model always sees
339
+ // "now checked / now collapsed", in both directions, regardless of other page churn.
340
+ const STATE_WORDS = {
341
+ checked: { on: 'checked', off: 'unchecked' },
342
+ selected: { on: 'selected', off: 'unselected' },
343
+ pressed: { on: 'pressed', off: 'unpressed' },
344
+ expanded: { on: 'expanded', off: 'collapsed' },
345
+ };
346
+ const STATE_ATTRS = Object.keys(STATE_WORDS);
347
+ const stateWord = (attr, value) => {
348
+ if (attr === 'checked' && value === 'mixed')
349
+ return 'partially checked';
350
+ const words = STATE_WORDS[attr];
351
+ if (value === true || value === 'true')
352
+ return words.on;
353
+ return words.off;
354
+ };
355
+ // Pair entries by path; when role and name match but a state attr differs, it's a toggle.
356
+ const detectToggles = (prev, curr) => {
357
+ const toggled = [];
358
+ const togglePaths = new Set();
359
+ const currByPath = new Map(curr.map((e) => [e.path, e]));
360
+ for (const before of prev) {
361
+ const after = currByPath.get(before.path);
362
+ if (!after)
363
+ continue;
364
+ if (before.entry.role !== after.entry.role)
365
+ continue;
366
+ if (before.entry.name !== after.entry.name)
367
+ continue;
368
+ const transitions = [];
369
+ for (const attr of STATE_ATTRS) {
370
+ const was = stateWord(attr, before.entry[attr]);
371
+ const now = stateWord(attr, after.entry[attr]);
372
+ if (was === now)
373
+ continue;
374
+ transitions.push(`${was} -> ${now}`);
375
+ }
376
+ if (transitions.length === 0)
377
+ continue;
378
+ togglePaths.add(before.path);
379
+ let label = String(after.entry.role);
380
+ const name = after.entry.name;
381
+ if (typeof name === 'string' && name.trim())
382
+ label += ` "${name.trim()}"`;
383
+ toggled.push(`${label}: ${transitions.join(', ')}`);
384
+ }
385
+ return { toggled, togglePaths };
386
+ };
337
387
  const TOP_DIFF_ITEMS = 10;
338
388
  const formatDiffSection = (label, items) => {
339
389
  const summary = countBy(items);
@@ -357,10 +407,17 @@ const formatDiffSection = (label, items) => {
357
407
  }
358
408
  return lines;
359
409
  };
360
- const formatDiff = (added, removed) => {
361
- if (added.length === 0 && removed.length === 0)
410
+ const formatDiff = (added, removed, toggled) => {
411
+ if (added.length === 0 && removed.length === 0 && toggled.length === 0)
362
412
  return null;
363
- return ['ariaDiff:', ...formatDiffSection('added', added), ...formatDiffSection('removed', removed)].join('\n');
413
+ const sections = ['ariaDiff:'];
414
+ if (toggled.length > 0) {
415
+ sections.push(' toggled:');
416
+ for (const line of toggled)
417
+ sections.push(` - ${line}`);
418
+ }
419
+ sections.push(...formatDiffSection('added', added), ...formatDiffSection('removed', removed));
420
+ return sections.join('\n');
364
421
  };
365
422
  const CLOSE_OVERLAY_BUTTON_RE = /^close\s+(modal|dialog|popup|drawer|panel|sheet)\b/i;
366
423
  const findOverlayByCloseButton = (nodeList) => {
@@ -424,13 +481,16 @@ export const diffAriaSnapshots = (previous, current) => {
424
481
  tree = dropEmpty(tree);
425
482
  return flatten(tree);
426
483
  };
427
- const prev = flat(previous);
428
- const curr = flat(current);
484
+ const prevAll = flat(previous);
485
+ const currAll = flat(current);
486
+ const { toggled, togglePaths } = detectToggles(prevAll, currAll);
487
+ const prev = prevAll.filter((e) => !togglePaths.has(e.path));
488
+ const curr = currAll.filter((e) => !togglePaths.has(e.path));
429
489
  const prevTotals = countBy(prev.map((e) => e.summary));
430
490
  const currTotals = countBy(curr.map((e) => e.summary));
431
491
  const byCount = diffByCount(prevTotals, currTotals);
432
492
  const renames = detectRenames(prev, curr, prevTotals, currTotals);
433
- return formatDiff([...byCount.added, ...renames.added], [...byCount.removed, ...renames.removed]);
493
+ return formatDiff([...byCount.added, ...renames.added], [...byCount.removed, ...renames.removed], toggled);
434
494
  };
435
495
  export const detectFocusArea = (snapshot) => {
436
496
  let tree = parseSnapshot(snapshot);
@@ -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.28",
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",
@@ -65,9 +65,10 @@
65
65
  },
66
66
  "author": "",
67
67
  "dependencies": {
68
- "@ai-sdk/anthropic": "^3.0",
69
- "@ai-sdk/groq": "^3.0",
70
- "@ai-sdk/openai": "^3.0",
68
+ "@ai-sdk/anthropic": "^4.0",
69
+ "@ai-sdk/groq": "^4.0",
70
+ "@ai-sdk/openai": "^4.0",
71
+ "@ai-sdk/otel": "^1.0.2",
71
72
  "@axe-core/playwright": "^4.11.0",
72
73
  "@codeceptjs/reflection": "^0.5.2",
73
74
  "@faker-js/faker": "^10.4.0",
@@ -82,8 +83,8 @@
82
83
  "@opentelemetry/sdk-trace-base": "^2.2.0",
83
84
  "@opentelemetry/semantic-conventions": "^1.38.0",
84
85
  "@scalar/openapi-parser": "^0.25.6",
85
- "@testomatio/reporter": "^2.8.4",
86
- "ai": "^6.0.6",
86
+ "@testomatio/reporter": "^2.9.1",
87
+ "ai": "^7.0.2",
87
88
  "axe-core": "^4.11.1",
88
89
  "bash-tool": "^1.3.15",
89
90
  "cli-highlight": "^2.1.11",
@@ -110,6 +111,7 @@
110
111
  "parse5": "^8.0.0",
111
112
  "playwright": "^1.60",
112
113
  "react": "^19.1.1",
114
+ "sambanova-ai-provider": "^1.2.2",
113
115
  "strip-ansi": "^7.1.2",
114
116
  "turndown": "^7.2.1",
115
117
  "unique-names-generator": "^4.7.1",
@@ -113,6 +113,8 @@ For input field values, ALWAYS use I.seeInField() — never check value via CSS
113
113
  Prefer text locators (label, name, placeholder) for form fields: I.seeInField('Search', 'value') over I.seeInField('input[name="search"]', 'value').
114
114
  Only use locators that exist in the provided HTML or ARIA snapshot.
115
115
  Verify exact conditions, not approximate matches.
116
+ When the claim contains a concrete quoted value, generated assertion code MUST include that whole value. Do not shorten names, IDs, titles, emails, URLs, or other user-created values.
117
+ For exact visible text, prefer a text assertion scoped to a specific container or an ARIA locator with the complete text. Partial text is not valid evidence for a claim about the full value.
116
118
  NEVER use `:has-text(...)` inside a seeElement/dontSeeElement locator. Checking text inside an element is the job of I.see(text, context) — the `:has-text()` form duplicates that capability with a fragile selector.
117
119
  NEVER emit two assertions that check the same fact with different shapes. `I.see(text, locator)` and `I.seeElement("<locator>:has-text('text')")` verify the same thing — pick one (prefer I.see). One claim, one assertion.
118
120
  </verification_rules>
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();
@@ -9,6 +9,7 @@ import { loop } from '../utils/loop.ts';
9
9
  import type { Agent } from './agent.ts';
10
10
  import { type FishermanResult, createFishermanTools } from './fisherman-tools.ts';
11
11
  import type { Provider } from './provider.ts';
12
+ import { dataProtectionRules } from './rules.ts';
12
13
 
13
14
  const MAX_ITERATIONS = 15;
14
15
  const MAX_TOOL_ROUNDTRIPS = 5;
@@ -85,7 +86,7 @@ export class Fisherman implements Agent {
85
86
  baseEndpoint: this.baseEndpoint,
86
87
  });
87
88
 
88
- const conversation = this.provider.startConversation(this.buildSystemPrompt(endpointList, scopeUrl), 'fisherman');
89
+ const conversation = this.provider.startConversation(this.buildSystemPrompt(endpointList, Object.keys(tools), scopeUrl), 'fisherman');
89
90
  conversation.addUserText(this.buildTaskPrompt(instructions));
90
91
 
91
92
  await loop(
@@ -187,7 +188,7 @@ export class Fisherman implements Agent {
187
188
  return lines.join('\n');
188
189
  }
189
190
 
190
- private buildSystemPrompt(endpointList: string, scopeUrl?: string): string {
191
+ private buildSystemPrompt(endpointList: string, toolNames: string[], scopeUrl?: string): string {
191
192
  const scopeBlock = scopeUrl ? `\n\nSCOPE: You are operating within ${scopeUrl}.\nAll created items must belong to this scope.` : '';
192
193
 
193
194
  return dedent`
@@ -197,6 +198,11 @@ export class Fisherman implements Agent {
197
198
  ${endpointList}
198
199
  ${scopeBlock}
199
200
 
201
+ AVAILABLE TOOLS:
202
+ ${toolNames.join(', ')}.
203
+ Use tool names exactly as listed. Do not invent aliases, combined names, or names with channel markers such as "commentary".
204
+ Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
205
+
200
206
  WORKFLOW:
201
207
  1. Call getEndpointSpec to see the request body example for the endpoint
202
208
  2. Make requests — the response automatically extracts IDs, names, and status fields
@@ -208,6 +214,8 @@ export class Fisherman implements Agent {
208
214
  - Chain requests logically — create parent resources before children
209
215
  - If a request fails, try once more with adjusted data before reporting failure
210
216
  - Use realistic but unique data for each item (vary names, titles)
217
+
218
+ ${dataProtectionRules}
211
219
  `;
212
220
  }
213
221
 
@@ -217,7 +225,10 @@ export class Fisherman implements Agent {
217
225
 
218
226
  ${instructions}
219
227
 
220
- Execute the necessary API requests to create this data. When done, call finish with the summary.
228
+ ${dataProtectionRules}
229
+
230
+ If data preparation is allowed by these rules, execute the necessary API requests to create this data.
231
+ When done, call finish with the summary. If data preparation is forbidden, call stop with the reason.
221
232
  `;
222
233
  }
223
234
  }
@@ -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;