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
package/src/ai/tester.ts CHANGED
@@ -25,7 +25,7 @@ import { Navigator } from './navigator.ts';
25
25
  import type { Pilot } from './pilot.ts';
26
26
  import { Provider } from './provider.ts';
27
27
  import { Researcher } from './researcher.ts';
28
- import { actionRule, focusedElementRule, formRequirementsRule, locatorRule, multipleTabsRule, protectionRule, sectionContextRule } from './rules.ts';
28
+ import { actionRule, capabilityGroundingRule, dataProtectionRules, focusedElementRule, formRequirementsRule, locatorRule, multipleTabsRule, sectionContextRule } from './rules.ts';
29
29
  import { TaskAgent } from './task-agent.ts';
30
30
  import { createCodeceptJSTools, createSpecialContextTools } from './tools.ts';
31
31
 
@@ -43,7 +43,7 @@ const SAMPLE_FILES: Record<string, string> = {
43
43
  };
44
44
 
45
45
  export class Tester extends TaskAgent implements Agent {
46
- protected readonly ACTION_TOOLS = ['click', 'pressKey', 'form'];
46
+ protected readonly ACTION_TOOLS = ['click', 'hover', 'pressKey', 'form'];
47
47
  protected readonly SPECIAL_CONTEXT_ACTION_TOOLS = ['exitIframe'];
48
48
  emoji = '๐Ÿงช';
49
49
  private explorer: Explorer;
@@ -66,6 +66,8 @@ export class Tester extends TaskAgent implements Agent {
66
66
  private hooksRunner: HooksRunner;
67
67
  private seenUiMapUrls = new Set<string>();
68
68
  private lastAnalyzedStateHash: string | null = null;
69
+ private stalledIterations = 0;
70
+ private readonly MAX_STALLED_ITERATIONS = 3;
69
71
 
70
72
  constructor(explorer: Explorer, provider: Provider, researcher: Researcher, navigator: Navigator, agentTools?: any) {
71
73
  super();
@@ -126,6 +128,7 @@ export class Tester extends TaskAgent implements Agent {
126
128
  this.pageActionResult = null;
127
129
  this.seenUiMapUrls.clear();
128
130
  this.lastAnalyzedStateHash = null;
131
+ this.stalledIterations = 0;
129
132
  this.explorer.getStateManager().clearHistory();
130
133
  this.resetFailureCount();
131
134
  this.pilot?.reset();
@@ -348,6 +351,11 @@ export class Tester extends TaskAgent implements Agent {
348
351
  });
349
352
  }
350
353
 
354
+ if (this.shouldStopForStalledExecution(task, currentState, result?.toolExecutions || [])) {
355
+ stop();
356
+ return;
357
+ }
358
+
351
359
  if (assertionPerformed) {
352
360
  const message = result?.toolExecutions?.find((execution: any) => execution.toolName === 'verify')?.output?.message || '';
353
361
  task.addNote(message, wasSuccessful ? TestResult.PASSED : TestResult.FAILED);
@@ -456,6 +464,31 @@ export class Tester extends TaskAgent implements Agent {
456
464
  return true;
457
465
  }
458
466
 
467
+ private shouldStopForStalledExecution(task: Test, previousState: ActionResult, toolExecutions: any[]): boolean {
468
+ if (task.hasFinished) return false;
469
+
470
+ const currentState = this.getCurrentState();
471
+ const stateChanged = previousState.url !== currentState.url || previousState.hash !== currentState.hash;
472
+ const actionTools = [...this.ACTION_TOOLS, ...this.SPECIAL_CONTEXT_ACTION_TOOLS];
473
+ const hasSuccessfulAction = toolExecutions.some((execution) => execution.wasSuccessful && actionTools.includes(execution.toolName));
474
+ const hasSuccessfulAssertion = toolExecutions.some((execution) => execution.wasSuccessful && this.ASSERTION_TOOLS.includes(execution.toolName));
475
+
476
+ if (stateChanged || hasSuccessfulAction || hasSuccessfulAssertion) {
477
+ this.stalledIterations = 0;
478
+ return false;
479
+ }
480
+
481
+ const hasNoBrowserProgress = toolExecutions.length === 0 || toolExecutions.every((execution) => !actionTools.includes(execution.toolName) || !execution.wasSuccessful);
482
+ if (!hasNoBrowserProgress) return false;
483
+
484
+ this.stalledIterations++;
485
+ if (this.stalledIterations < this.MAX_STALLED_ITERATIONS) return false;
486
+
487
+ task.addNote('No browser progress after repeated attempts on unchanged page', TestResult.FAILED);
488
+ task.finish(TestResult.FAILED);
489
+ return true;
490
+ }
491
+
459
492
  private async prepareInstructionsForNextStep(task: Test): Promise<string> {
460
493
  let outcomeStatus = dedent`
461
494
  <task>
@@ -464,6 +497,8 @@ export class Tester extends TaskAgent implements Agent {
464
497
 
465
498
  <rules>
466
499
  Use tools ${this.ACTION_TOOLS.join(', ')} to interact with the page.
500
+ Use tool names exactly as listed in this prompt. Do not invent combined tool names, aliases, or names with channel markers such as "commentary".
501
+ Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
467
502
  Do not do unsuccesful clicks again.
468
503
  Do not run same tool calls with same parameters again.
469
504
  </rules>
@@ -746,6 +781,7 @@ export class Tester extends TaskAgent implements Agent {
746
781
  <rules>
747
782
  - Refer to UI Map from <page_ui_map> to understand the page structure and its main elements
748
783
  - Use only elements that exist in the provided ARIA tree or HTML, <page_aria> and <page_html>
784
+ - Use tool input schemas exactly as documented. Do not invent parameter names or add fields not listed by the tool schema.
749
785
  - Use click() for buttons, links, and clickable elements ONLY - do NOT include I.fillField() or I.type() commands in click() tool
750
786
  - click() commands array is for FALLBACK LOCATORS of the SAME element, NOT for clicking different elements in sequence. If you need to click two different elements, make two separate click() calls.
751
787
  - Use form() for text input (I.fillField, I.type), dropdown selection (I.selectOption), file uploads (I.attachFile), and multi-step form interactions
@@ -758,6 +794,7 @@ export class Tester extends TaskAgent implements Agent {
758
794
  - NEVER call record(status: "success") if your last verify() or see() call FAILED. A failed check means the outcome is NOT confirmed โ€” use record(status: "fail") instead, or retry with a different approach.
759
795
  - Use finish() to complete the test, not record(). record() is for intermediate notes.
760
796
  - Call finish(verify) when all goals are achieved โ€” provide an assertion to verify
797
+ - NEVER call finish() with a negative assertion that says the goal did NOT happen. If the goal cannot be achieved after real attempts, record the blocker and call stop().
761
798
  - ONLY call stop() if the scenario itself is completely irrelevant to this page and no expectations can be achieved
762
799
  - Use reset() ONLY as a last resort when the current page cannot host the scenario. Never reset after a successful flow just because an assertion or milestone did not match โ€” verify differently or record() the finding instead. Reset is destructive and does not undo server-side side effects.
763
800
  - Be precise with locators (CSS or XPath)
@@ -771,6 +808,13 @@ export class Tester extends TaskAgent implements Agent {
771
808
  - When you interact with form with inputs, ensure that you click corresponding button to save its data
772
809
  - Follow <locator_priority> rules when selecting locators for all tools
773
810
  - Before retrying your actions check maybe they already achived expected results. Use see() tool for that
811
+ - If the current URL is already a create/edit/new form and the scenario is about creating/editing that entity, fill and submit that form. Do not click the list-page "New" button again from inside the form.
812
+ - If the scenario is about search/filter/sort/tabs/list inspection and the current URL is a create/edit/new form, go back or reset to the stable list page before interacting with list controls.
813
+ - When selecting related entities from a list, do not choose rows/options/cards marked as "0 items", "0 tests", or otherwise empty if the scenario requires selecting real content.
814
+ - In selection pickers, counters such as "Selected 0", "Matched tests 0", or disabled Save/Apply mean the selection did not register. Choose a non-empty item or change filters before submitting.
815
+ - A passed form/click command only means the command executed. If a required field remains empty, submit stays disabled, or the expected text is not visible, treat the action as not completed and correct the missing field/state.
816
+ - For filter/tab scenarios, success requires BOTH: the requested filter/tab is visibly active/selected AND the list content matches that filter. Do not finish from only one of these signals.
817
+ - Empty-state text such as "No matched items" only proves a filter when the requested filter/tab is active and the empty state belongs to the filtered list.
774
818
  - When filling complex form with lot of actions performed, use see() to look which fields were filled and which are not
775
819
  - When verify() fails, use see() to visually confirm the result โ€” visual confirmation is equally valid evidence
776
820
  - For visual state verification (active tabs, selected items, counts, colors), prefer see() over DOM-based verify()
@@ -799,12 +843,17 @@ export class Tester extends TaskAgent implements Agent {
799
843
 
800
844
  ${formRequirementsRule}
801
845
 
846
+ ${capabilityGroundingRule}
847
+
848
+ ${dataProtectionRules}
849
+
802
850
  ${this.provider.getSystemPromptForAgent('tester', this.explorer.getStateManager().getCurrentState()?.url) || ''}
803
851
  `;
804
852
  }
805
853
 
806
854
  private buildScenarioBlock(task: Test, actionResult: ActionResult): string {
807
855
  const knowledge = this.getKnowledge(actionResult);
856
+ const experience = this.getExperience(actionResult);
808
857
 
809
858
  return dedent`
810
859
  <task>
@@ -822,16 +871,21 @@ export class Tester extends TaskAgent implements Agent {
822
871
  Try to achieve as many goals as possible.
823
872
  If goal is not achievable, log that and skip to next one.
824
873
  Do not hallucinate that goal was achieved when it was not.
874
+ If the scenario action could not be completed, do not finish with a verification of the failure state.
825
875
  When creating or editing items via form() or type() you should include ${task.sessionName} in the value (if it is not restricted by the application logic)
826
876
  Initial page URL: ${actionResult.url}
827
877
 
828
- ${protectionRule}
878
+ ${capabilityGroundingRule}
879
+
880
+ ${dataProtectionRules}
829
881
 
830
882
  ${this.buildDeletionScope(task)}
831
883
 
832
884
  ${this.buildAvailableFiles()}
833
885
 
834
886
  ${knowledge}
887
+
888
+ ${experience}
835
889
  `;
836
890
  }
837
891
 
@@ -963,12 +1017,13 @@ export class Tester extends TaskAgent implements Agent {
963
1017
  }),
964
1018
  stop: tool({
965
1019
  description: dedent`
966
- Stop the current test because the scenario is fundamentally incompatible with the page.
967
- Use this ONLY when the scenario cannot be executed on the current page or application.
968
- Do NOT use this for failures โ€” use reset() and retry instead.
1020
+ Stop the current test because it cannot be completed in the current session.
1021
+ Use this when the scenario is incompatible, required UI/data is absent, or repeated varied attempts
1022
+ show that automation cannot complete the workflow.
1023
+ Do NOT use this immediately after the first failed action โ€” retry with a materially different approach first.
969
1024
  `,
970
1025
  inputSchema: z.object({
971
- reason: z.string().describe('Explanation why the scenario is incompatible'),
1026
+ reason: z.string().describe('Explanation why the scenario cannot be completed'),
972
1027
  }),
973
1028
  execute: async ({ reason }) => {
974
1029
  task.addNote(`Stop requested: ${reason}`);
@@ -1003,6 +1058,7 @@ export class Tester extends TaskAgent implements Agent {
1003
1058
  Provide a specific assertion to verify the final state.
1004
1059
  The assertion MUST prove that YOUR ACTIONS changed the page state.
1005
1060
  Do NOT verify something that was already true before you started testing.
1061
+ Do NOT provide an assertion that verifies absence, failure, an empty state, or that the goal did not happen.
1006
1062
 
1007
1063
  Examples of good assertions:
1008
1064
  - "New user 'john@example.com' is visible in the users list"
@@ -1135,12 +1191,26 @@ export class Tester extends TaskAgent implements Agent {
1135
1191
  this.resetFailureCount();
1136
1192
  this.previousUrl = null;
1137
1193
  this.previousStateHash = null;
1194
+ this.stalledIterations = 0;
1195
+ } else if (this.shouldStopAfterStalledLoopError(task)) {
1196
+ return 'stop';
1138
1197
  }
1139
1198
 
1140
1199
  this.currentConversation?.addUserText(result.message);
1141
1200
  return 'continue';
1142
1201
  }
1143
1202
 
1203
+ private shouldStopAfterStalledLoopError(task: Test): boolean {
1204
+ if (task.hasFinished) return false;
1205
+
1206
+ this.stalledIterations++;
1207
+ if (this.stalledIterations < this.MAX_STALLED_ITERATIONS) return false;
1208
+
1209
+ task.addNote('No browser progress after repeated execution errors', TestResult.FAILED);
1210
+ task.finish(TestResult.FAILED);
1211
+ return true;
1212
+ }
1213
+
1144
1214
  private async cleanupStartedTest(task: Test): Promise<void> {
1145
1215
  await this.finishTest(task);
1146
1216
  await this.explorer.stopTest(task, {
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', 'pressKey', 'form'] as const;
22
21
  export const ASSERTION_TOOLS = ['verify'] as const;
23
22
 
24
23
  export function createCodeceptJSTools(explorer: Explorer, task: Task) {
@@ -160,6 +159,84 @@ export function createCodeceptJSTools(explorer: Explorer, task: Task) {
160
159
  },
161
160
  }),
162
161
 
162
+ hover: tool({
163
+ description: dedent`
164
+ Move the mouse cursor to an element to reveal hover-only controls.
165
+
166
+ Use this before clicking row actions, icon buttons, menus, or toolbars that appear only
167
+ when the user hovers a list item, table row, card, or tree node.
168
+
169
+ This tool ONLY accepts I.moveCursorTo(locator) commands. It does not click.
170
+ After hovering, use context(), see(), or click() the revealed control.
171
+ `,
172
+ inputSchema: z.object({
173
+ commands: z.array(z.string()).describe(dedent`
174
+ FALLBACK LOCATORS for ONE element to hover.
175
+ Order by reliability:
176
+ 1. I.moveCursorTo(text, container)
177
+ 2. I.moveCursorTo(ARIA, container)
178
+ 3. I.moveCursorTo(CSS, container)
179
+ 4. I.moveCursorTo(CSS) or I.moveCursorTo(XPath)
180
+ `),
181
+ explanation: z.string().describe('Why you are hovering this element'),
182
+ }),
183
+ execute: async ({ commands: rawCommands, explanation }) => {
184
+ const activeNote = task.startNote(explanation);
185
+
186
+ if (rawCommands.length === 0) {
187
+ activeNote.commit(TestResult.FAILED);
188
+ return failedToolResult('hover', 'No commands provided');
189
+ }
190
+
191
+ const invalidCommands = rawCommands.map((cmd) => cmd.trim()).filter((cmd) => cmd.startsWith('I.') && !cmd.startsWith('I.moveCursorTo'));
192
+
193
+ if (invalidCommands.length > 0) {
194
+ activeNote.commit(TestResult.FAILED);
195
+ return failedToolResult('hover', `Invalid commands: ${invalidCommands.join(', ')}. Hover tool only accepts I.moveCursorTo() commands.`, {
196
+ suggestion: 'Use click() to click elements, or form() for typing/selecting.',
197
+ });
198
+ }
199
+
200
+ const commands = rawCommands.map((cmd) => {
201
+ const trimmed = cmd.trim();
202
+ if (trimmed.startsWith('I.moveCursorTo')) return trimmed;
203
+ return `I.moveCursorTo(${JSON.stringify(trimmed)})`;
204
+ });
205
+
206
+ const previousState = ActionResult.fromState(stateManager.getCurrentState()!);
207
+ const action = explorer.createAction();
208
+ const attempts: Array<{ command: string; success: boolean; error?: string }> = [];
209
+
210
+ for (const command of commands) {
211
+ const success = await action.attempt(command, explanation, true);
212
+ attempts.push({
213
+ command,
214
+ success,
215
+ ...(action.lastError && { error: action.lastError.toString() }),
216
+ });
217
+
218
+ if (!success) continue;
219
+
220
+ const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, command);
221
+ activeNote.commit(TestResult.PASSED);
222
+ return successToolResult('hover', { ...toolResult, attempts, code: command }, action);
223
+ }
224
+
225
+ const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, commands[0]);
226
+ activeNote.commit(TestResult.FAILED);
227
+ return failedToolResult(
228
+ 'hover',
229
+ 'All hover commands failed',
230
+ {
231
+ ...toolResult,
232
+ attempts,
233
+ suggestion: 'Use xpathCheck() to locate the row/card/tree node, or visualClick() if the hover target is only visually identifiable.',
234
+ },
235
+ action.lastError
236
+ );
237
+ },
238
+ }),
239
+
163
240
  pressKey: tool({
164
241
  description: dedent`
165
242
  Press a keyboard key or key combination. Use this for special keys like Enter, Escape, Tab, Arrow keys, or key combinations with modifiers.
@@ -488,6 +565,7 @@ export function createAgentTools({
488
565
  Check the page contents based on current page state and screenshot.
489
566
  This tool will trigger visual research to check the page contents on request.
490
567
  Use it to verify the actions were performed correctly and the page is in the expected state.
568
+ Input schema has exactly one field: request. Do not pass text, reason, assertion, or other fields.
491
569
 
492
570
  <example>
493
571
  request: "Check current state of the Login form"
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?: {
@@ -59,6 +60,7 @@ interface AgentConfig extends HooksConfig {
59
60
  systemPrompt?: string;
60
61
  rules?: RuleEntry[];
61
62
  providerOptions?: Record<string, any>;
63
+ reasoning?: 'provider-default' | 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
62
64
  }
63
65
 
64
66
  interface ResearcherAgentConfig extends AgentConfig {
@@ -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
@@ -25,12 +25,13 @@ import { ExperienceTracker } from './experience-tracker.ts';
25
25
  import Explorer from './explorer.ts';
26
26
  import { KnowledgeTracker } from './knowledge-tracker.ts';
27
27
  import { WebPageState } from './state-manager.ts';
28
+ import { Stats } from './stats.ts';
28
29
  import type { Suite } from './suite.ts';
29
30
  import { Plan, type Test } from './test-plan.ts';
30
- import { parsePlansFromMarkdown } from './utils/test-plan-markdown.ts';
31
31
  import { setVerboseMode, tag } from './utils/logger.ts';
32
32
  import { relativeToCwd } from './utils/next-steps.ts';
33
33
  import { sanitizeFilename } from './utils/strings.ts';
34
+ import { parsePlansFromMarkdown } from './utils/test-plan-markdown.ts';
34
35
 
35
36
  export interface ExplorBotOptions {
36
37
  from?: string;
@@ -220,7 +221,13 @@ export class ExplorBot {
220
221
  this.agents.tester = this.createAgent(({ ai, explorer }) => {
221
222
  const researcher = this.agentResearcher();
222
223
  const navigator = this.agentNavigator();
223
- 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 });
224
231
  return new Tester(explorer, ai, researcher, navigator, tools);
225
232
  });
226
233
 
@@ -494,7 +501,10 @@ export class ExplorBot {
494
501
 
495
502
  const reporter = this.explorer?.getReporter();
496
503
  if (reporter?.isEnabled()) {
497
- await reporter.setRunDescription(markdown);
504
+ let description = markdown;
505
+ const modelsTable = Stats.modelsTable(this.provider.getConfiguredModels());
506
+ if (modelsTable) description = `${markdown}\n\n${modelsTable}`;
507
+ await reporter.setRunDescription(description);
498
508
  }
499
509
 
500
510
  this.lastReportedTestCount = tests.length;
package/src/explorer.ts CHANGED
@@ -2,10 +2,10 @@ import { existsSync, mkdirSync } from 'node:fs';
2
2
  import path, { join } from 'node:path';
3
3
  // @ts-ignore
4
4
  import * as codeceptjs from 'codeceptjs';
5
- import dedent from 'dedent';
6
5
  import stepsListener from 'codeceptjs/lib/listener/steps';
7
6
  import storeListener from 'codeceptjs/lib/listener/store';
8
7
  import { createTest } from 'codeceptjs/lib/mocha/test';
8
+ import dedent from 'dedent';
9
9
  import type { BrowserContextOptions } from 'playwright';
10
10
  import { ActionResult } from './action-result.ts';
11
11
  import Action from './action.js';
@@ -21,10 +21,11 @@ import { PlaywrightRecorder } from './playwright-recorder.ts';
21
21
  import { Reporter } from './reporter.ts';
22
22
  import { StateManager } from './state-manager.js';
23
23
  import { Test, TestResult } from './test-plan.ts';
24
+ import { BrowserRecoveryError, isFatalBrowserError, isNavigationTransitionError } from './utils/browser-errors.ts';
24
25
  import { ELEMENT_EXTRACTION_CONFIG, getElementDataExtractorSource } from './utils/html.ts';
25
26
  import { createDebug, log, tag } from './utils/logger.js';
27
+ import { waitForPageReadiness } from './utils/page-readiness.ts';
26
28
  import { WebElement } from './utils/web-element.ts';
27
- import { BrowserRecoveryError, isFatalBrowserError } from './utils/browser-errors.ts';
28
29
 
29
30
  declare global {
30
31
  namespace NodeJS {
@@ -314,13 +315,26 @@ class Explorer {
314
315
  try {
315
316
  return await operation();
316
317
  } catch (error) {
317
- if (!this.isFatalBrowserError(error)) throw error;
318
+ let recoveryError = error;
319
+
320
+ if (isNavigationTransitionError(error)) {
321
+ tag('warning').log(`${label}: page is still navigating, waiting before retry...`);
322
+ await this.waitForPageReadiness();
323
+ try {
324
+ return await operation();
325
+ } catch (retryError) {
326
+ if (!isNavigationTransitionError(retryError) && !this.isFatalBrowserError(retryError)) throw retryError;
327
+ recoveryError = retryError;
328
+ }
329
+ }
330
+
331
+ if (!this.isFatalBrowserError(recoveryError)) throw recoveryError;
318
332
 
319
333
  tag('warning').log(`${label}: browser page is unavailable, recovering...`);
320
334
  let recovered = await this.recoverFromBrowserError();
321
335
  if (!recovered) recovered = await this.restartBrowser();
322
- if (!recovered) throw new BrowserRecoveryError(label, error, false);
323
- if (!(await this.waitForUsablePageDom())) throw new BrowserRecoveryError(label, error, true);
336
+ if (!recovered) throw new BrowserRecoveryError(label, recoveryError, false);
337
+ if (!(await this.waitForPageReadiness())) throw new BrowserRecoveryError(label, recoveryError, true);
324
338
 
325
339
  try {
326
340
  return await operation();
@@ -377,7 +391,7 @@ class Explorer {
377
391
  const msg = err instanceof Error ? err.message : String(err);
378
392
  if (!RECOVERABLE_NAVIGATION_ERRORS.test(msg)) throw err;
379
393
  tag('warning').log(`Navigation warning (continuing after load): ${msg.split('\n')[0]}`);
380
- await this.playwrightHelper.page.waitForLoadState('domcontentloaded', { timeout: 10000 }).catch(() => {});
394
+ await this.waitForPageReadiness();
381
395
  await action.capturePageState();
382
396
  }
383
397
  }
@@ -488,11 +502,11 @@ class Explorer {
488
502
  if (url) {
489
503
  tag('warning').log(`Browser error detected, recovering by navigating to ${url}`);
490
504
  await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
491
- return this.waitForUsablePageDom();
505
+ return this.waitForPageReadiness();
492
506
  }
493
507
  tag('warning').log('Browser error detected, reloading page');
494
508
  await this.playwrightHelper.page.reload({ waitUntil: 'domcontentloaded', timeout: 10000 });
495
- return this.waitForUsablePageDom();
509
+ return this.waitForPageReadiness();
496
510
  } catch (err) {
497
511
  tag('error').log(`Browser recovery failed: ${err instanceof Error ? err.message : err}`);
498
512
  return false;
@@ -532,7 +546,7 @@ class Explorer {
532
546
 
533
547
  if (url) {
534
548
  await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
535
- if (!(await this.waitForUsablePageDom())) return false;
549
+ if (!(await this.waitForPageReadiness())) return false;
536
550
  }
537
551
 
538
552
  tag('success').log('Browser restarted');
@@ -550,27 +564,14 @@ class Explorer {
550
564
  }
551
565
  }
552
566
 
553
- private async waitForUsablePageDom(): Promise<boolean> {
567
+ private async waitForPageReadiness(): Promise<boolean> {
554
568
  const page = this.playwrightHelper?.page;
555
569
  if (!page) return false;
556
570
 
557
- await page.waitForLoadState?.('domcontentloaded', { timeout: 5000 }).catch(() => {});
558
- if (page.waitForFunction) {
559
- const hasUsableDom = await page
560
- .waitForFunction(
561
- () => {
562
- const body = document.body;
563
- if (!body) return false;
564
- return body.children.length > 0 || body.textContent?.trim().length > 0;
565
- },
566
- undefined,
567
- { timeout: 5000 }
568
- )
569
- .then(() => true)
570
- .catch(() => false);
571
- if (!hasUsableDom) return false;
572
- }
573
- await page.waitForLoadState?.('networkidle', { timeout: 3000 }).catch(() => {});
571
+ await waitForPageReadiness(page, {
572
+ timeout: this.config.playwright.waitForTimeout,
573
+ spinnerSelectors: this.config.playwright.spinnerSelectors,
574
+ });
574
575
  return true;
575
576
  }
576
577
 
package/src/stats.ts CHANGED
@@ -54,6 +54,24 @@ export class Stats {
54
54
  return String(num);
55
55
  }
56
56
 
57
+ static modelsTable(roleModels: Record<string, string>): string {
58
+ const usedModels = Object.entries(Stats.models).filter(([, tokens]) => tokens.total > 0);
59
+ if (usedModels.length === 0) return '';
60
+
61
+ const rolesByModel: Record<string, string[]> = {};
62
+ for (const [role, model] of Object.entries(roleModels)) {
63
+ if (!rolesByModel[model]) rolesByModel[model] = [];
64
+ rolesByModel[model].push(role);
65
+ }
66
+
67
+ const rows = usedModels.map(([model, tokens]) => {
68
+ const roles = rolesByModel[model]?.join(', ') || '-';
69
+ return `| ${roles} | ${model} | ${Stats.humanizeTokens(tokens.total)} |`;
70
+ });
71
+
72
+ return ['## Models', '', '| Role | Model | Tokens |', '| --- | --- | --- |', ...rows].join('\n');
73
+ }
74
+
57
75
  static hasActivity(): boolean {
58
76
  if (Stats.tests > 0 || Stats.plans > 0 || Stats.researches > 0) return true;
59
77
  const totalTokens = Object.values(Stats.models).reduce((sum, m) => sum + m.total, 0);
package/src/utils/aria.ts CHANGED
@@ -334,6 +334,54 @@ const detectRenames = (prev: FlatEntry[], curr: FlatEntry[], prevTotals: Map<str
334
334
  return { added, removed };
335
335
  };
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: Record<string, { on: string; off: string }> = {
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
+
348
+ const stateWord = (attr: string, value: unknown): string => {
349
+ if (attr === 'checked' && value === 'mixed') return 'partially checked';
350
+ const words = STATE_WORDS[attr];
351
+ if (value === true || value === 'true') return words.on;
352
+ return words.off;
353
+ };
354
+
355
+ // Pair entries by path; when role and name match but a state attr differs, it's a toggle.
356
+ const detectToggles = (prev: FlatEntry[], curr: FlatEntry[]): { toggled: string[]; togglePaths: Set<string> } => {
357
+ const toggled: string[] = [];
358
+ const togglePaths = new Set<string>();
359
+ const currByPath = new Map(curr.map((e) => [e.path, e]));
360
+
361
+ for (const before of prev) {
362
+ const after = currByPath.get(before.path);
363
+ if (!after) continue;
364
+ if (before.entry.role !== after.entry.role) continue;
365
+ if (before.entry.name !== after.entry.name) continue;
366
+
367
+ const transitions: string[] = [];
368
+ for (const attr of STATE_ATTRS) {
369
+ const was = stateWord(attr, before.entry[attr]);
370
+ const now = stateWord(attr, after.entry[attr]);
371
+ if (was === now) continue;
372
+ transitions.push(`${was} -> ${now}`);
373
+ }
374
+ if (transitions.length === 0) continue;
375
+
376
+ togglePaths.add(before.path);
377
+ let label = String(after.entry.role);
378
+ const name = after.entry.name;
379
+ if (typeof name === 'string' && name.trim()) label += ` "${name.trim()}"`;
380
+ toggled.push(`${label}: ${transitions.join(', ')}`);
381
+ }
382
+ return { toggled, togglePaths };
383
+ };
384
+
337
385
  const TOP_DIFF_ITEMS = 10;
338
386
 
339
387
  const formatDiffSection = (label: string, items: string[]): string[] => {
@@ -358,9 +406,15 @@ const formatDiffSection = (label: string, items: string[]): string[] => {
358
406
  return lines;
359
407
  };
360
408
 
361
- const formatDiff = (added: string[], removed: string[]): string | null => {
362
- if (added.length === 0 && removed.length === 0) return null;
363
- return ['ariaDiff:', ...formatDiffSection('added', added), ...formatDiffSection('removed', removed)].join('\n');
409
+ const formatDiff = (added: string[], removed: string[], toggled: string[]): string | null => {
410
+ if (added.length === 0 && removed.length === 0 && toggled.length === 0) return null;
411
+ const sections = ['ariaDiff:'];
412
+ if (toggled.length > 0) {
413
+ sections.push(' toggled:');
414
+ for (const line of toggled) sections.push(` - ${line}`);
415
+ }
416
+ sections.push(...formatDiffSection('added', added), ...formatDiffSection('removed', removed));
417
+ return sections.join('\n');
364
418
  };
365
419
 
366
420
  // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@@ -437,13 +491,16 @@ export const diffAriaSnapshots = (previous: string | null, current: string | nul
437
491
  tree = dropEmpty(tree);
438
492
  return flatten(tree);
439
493
  };
440
- const prev = flat(previous);
441
- const curr = flat(current);
494
+ const prevAll = flat(previous);
495
+ const currAll = flat(current);
496
+ const { toggled, togglePaths } = detectToggles(prevAll, currAll);
497
+ const prev = prevAll.filter((e) => !togglePaths.has(e.path));
498
+ const curr = currAll.filter((e) => !togglePaths.has(e.path));
442
499
  const prevTotals = countBy(prev.map((e) => e.summary));
443
500
  const currTotals = countBy(curr.map((e) => e.summary));
444
501
  const byCount = diffByCount(prevTotals, currTotals);
445
502
  const renames = detectRenames(prev, curr, prevTotals, currTotals);
446
- return formatDiff([...byCount.added, ...renames.added], [...byCount.removed, ...renames.removed]);
503
+ return formatDiff([...byCount.added, ...renames.added], [...byCount.removed, ...renames.removed], toggled);
447
504
  };
448
505
 
449
506
  export const detectFocusArea = (snapshot: string | null): FocusAreaResult => {
@@ -2,6 +2,7 @@
2
2
  // not typed exceptions. Keep those external message markers in one adapter so
3
3
  // recovery decisions are not duplicated across agents/actions.
4
4
  const FATAL_BROWSER_ERROR_MARKERS = ['Frame was detached', 'Target closed', 'Target page, context or browser has been closed', 'Execution context was destroyed', 'Protocol error', 'Session closed'];
5
+ const NAVIGATION_TRANSITION_ERROR_MARKERS = ['most likely because of a navigation', 'navigating and changing the content'];
5
6
 
6
7
  export class BrowserRecoveryError extends Error {
7
8
  constructor(
@@ -20,6 +21,11 @@ export function isFatalBrowserError(error: unknown): boolean {
20
21
  return FATAL_BROWSER_ERROR_MARKERS.some((marker) => message.includes(marker.toLowerCase()));
21
22
  }
22
23
 
24
+ export function isNavigationTransitionError(error: unknown): boolean {
25
+ const message = browserErrorMessage(error).toLowerCase();
26
+ return NAVIGATION_TRANSITION_ERROR_MARKERS.some((marker) => message.includes(marker.toLowerCase()));
27
+ }
28
+
23
29
  export function browserErrorMessage(error: unknown): string {
24
30
  return error instanceof Error ? error.message : String(error);
25
31
  }