explorbot 0.2.3 → 0.2.5

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 (174) hide show
  1. package/README.md +1 -1
  2. package/bin/explorbot-cli.ts +26 -8
  3. package/boat/api-tester/src/cli.ts +17 -0
  4. package/boat/api-tester/src/config.ts +4 -2
  5. package/boat/doc-collector/bin/doc-collector-cli.ts +2 -0
  6. package/boat/doc-collector/src/ai/documentarian.ts +61 -31
  7. package/boat/doc-collector/src/cli.ts +14 -1
  8. package/boat/doc-collector/src/config.ts +4 -2
  9. package/boat/prima/bin/prima-cli.ts +0 -0
  10. package/boat/prima/src/activity-line.ts +33 -0
  11. package/boat/prima/src/cli.ts +127 -86
  12. package/boat/prima/src/envelope.ts +102 -52
  13. package/boat/prima/src/prima.ts +567 -128
  14. package/boat/prima/src/pw-parser.ts +11 -1
  15. package/boat/prima/src/pw-registry.ts +4 -5
  16. package/boat/prima/src/session-log.ts +126 -0
  17. package/dist/bin/explorbot-cli.js +26 -8
  18. package/dist/boat/api-tester/bin/apibot-cli.js +2 -0
  19. package/dist/boat/api-tester/src/cli.js +17 -0
  20. package/dist/boat/api-tester/src/config.js +4 -2
  21. package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -0
  22. package/dist/boat/doc-collector/src/ai/documentarian.js +44 -19
  23. package/dist/boat/doc-collector/src/cli.js +14 -1
  24. package/dist/boat/doc-collector/src/config.js +4 -2
  25. package/dist/boat/prima/src/activity-line.js +30 -0
  26. package/dist/boat/prima/src/cli.js +109 -77
  27. package/dist/boat/prima/src/envelope.js +94 -44
  28. package/dist/boat/prima/src/prima.js +533 -119
  29. package/dist/boat/prima/src/pw-parser.js +13 -1
  30. package/dist/boat/prima/src/pw-registry.js +4 -5
  31. package/dist/boat/prima/src/session-log.js +108 -0
  32. package/dist/package.json +3 -2
  33. package/dist/rules/navigator/verification-actions.md +20 -0
  34. package/dist/src/action-result.d.ts +7 -0
  35. package/dist/src/action-result.js +4 -0
  36. package/dist/src/action.d.ts +2 -0
  37. package/dist/src/action.js +41 -2
  38. package/dist/src/ai/captain/web-mode.js +6 -3
  39. package/dist/src/ai/captain.js +2 -0
  40. package/dist/src/ai/navigator.d.ts +34 -0
  41. package/dist/src/ai/navigator.js +237 -181
  42. package/dist/src/ai/pilot.d.ts +7 -0
  43. package/dist/src/ai/pilot.js +90 -2
  44. package/dist/src/ai/provider.d.ts +2 -2
  45. package/dist/src/ai/provider.js +14 -23
  46. package/dist/src/ai/rerunner.js +2 -1
  47. package/dist/src/ai/researcher/cache.d.ts +2 -0
  48. package/dist/src/ai/researcher/cache.js +10 -2
  49. package/dist/src/ai/researcher.js +3 -2
  50. package/dist/src/ai/rules.js +17 -10
  51. package/dist/src/ai/session-analyst.js +2 -0
  52. package/dist/src/ai/task-agent.js +4 -1
  53. package/dist/src/ai/tester.d.ts +6 -3
  54. package/dist/src/ai/tester.js +50 -46
  55. package/dist/src/ai/tools.d.ts +14 -0
  56. package/dist/src/ai/tools.js +117 -37
  57. package/dist/src/commands/config-command.d.ts +51 -0
  58. package/dist/src/commands/config-command.js +117 -0
  59. package/dist/src/commands/index.js +2 -0
  60. package/dist/src/config.d.ts +9 -1
  61. package/dist/src/config.js +53 -4
  62. package/dist/src/execution-controller.d.ts +2 -0
  63. package/dist/src/execution-controller.js +6 -0
  64. package/dist/src/explorbot.d.ts +2 -1
  65. package/dist/src/explorbot.js +7 -2
  66. package/dist/src/explorer.js +2 -3
  67. package/dist/src/playwright-recorder.js +30 -0
  68. package/dist/src/remote.d.ts +55 -0
  69. package/dist/src/remote.js +235 -0
  70. package/dist/src/reporter.d.ts +1 -0
  71. package/dist/src/reporter.js +7 -1
  72. package/dist/src/state-manager.d.ts +2 -1
  73. package/dist/src/state-manager.js +3 -1
  74. package/dist/src/stats.d.ts +1 -0
  75. package/dist/src/stats.js +1 -0
  76. package/dist/src/test-plan.d.ts +3 -0
  77. package/dist/src/test-plan.js +26 -0
  78. package/dist/src/utils/aria.d.ts +2 -8
  79. package/dist/src/utils/aria.js +69 -40
  80. package/dist/src/utils/html.js +1 -0
  81. package/dist/src/utils/logger.d.ts +7 -1
  82. package/dist/src/utils/logger.js +32 -0
  83. package/dist/src/utils/page-readiness.js +18 -1
  84. package/dist/src/utils/url-matcher.js +3 -0
  85. package/dist/src/utils/web-element.d.ts +2 -0
  86. package/dist/src/utils/web-element.js +8 -0
  87. package/dist/src/utils/web-sandbox.d.ts +1 -1
  88. package/dist/src/utils/web-sandbox.js +2 -3
  89. package/docs/api-testing/basics.md +90 -0
  90. package/docs/api-testing/planning.md +57 -0
  91. package/docs/api-testing/running-tests.md +55 -0
  92. package/docs/assets/cloud-report.png +0 -0
  93. package/docs/assets/html-report.png +0 -0
  94. package/docs/assets/langfuse-trace.png +0 -0
  95. package/docs/assets/successful-explore-run.png +0 -0
  96. package/docs/basics/getting-started.md +140 -0
  97. package/docs/basics/prerequisites.md +63 -0
  98. package/docs/basics/providers.md +362 -0
  99. package/docs/basics/running.md +78 -0
  100. package/docs/contributing/ai-integration-tests.md +57 -0
  101. package/docs/contributing/contributing.md +90 -0
  102. package/docs/contributing/demo-videos.md +36 -0
  103. package/docs/contributing/npm-package.md +138 -0
  104. package/docs/contributing/observability.md +227 -0
  105. package/docs/contributing/regression-tests.md +103 -0
  106. package/docs/contributing/testing.md +95 -0
  107. package/docs/doc-collection/basics.md +128 -0
  108. package/docs/doc-collection/crawling.md +67 -0
  109. package/docs/doc-collection/interactive-mode.md +99 -0
  110. package/docs/index.json +87 -0
  111. package/docs/reference/commands.md +997 -0
  112. package/docs/reference/configuration.md +569 -0
  113. package/docs/reference/scripting.md +303 -0
  114. package/docs/reference/websocket.md +50 -0
  115. package/docs/superpowers/plans/2026-08-01-actor-boat.md +925 -0
  116. package/docs/superpowers/plans/2026-08-01-prima-boat.md +1120 -0
  117. package/docs/superpowers/reviews/2026-08-06-prima-vs-playwright-cli.md +268 -0
  118. package/docs/superpowers/specs/2026-08-01-actor-boat-design.md +204 -0
  119. package/docs/superpowers/specs/2026-08-01-prima-boat-design.md +242 -0
  120. package/docs/superpowers/specs/2026-08-03-global-config-design.md +138 -0
  121. package/docs/superpowers/specs/2026-08-07-prima-fixes-design.md +394 -0
  122. package/docs/superpowers/specs/2026-08-18-prima-false-verdicts.md +159 -0
  123. package/docs/web-testing/agents.md +158 -0
  124. package/docs/web-testing/automated-tests.md +134 -0
  125. package/docs/web-testing/basics.md +91 -0
  126. package/docs/web-testing/customization.md +131 -0
  127. package/docs/web-testing/hooks.md +238 -0
  128. package/docs/web-testing/page-interaction.md +84 -0
  129. package/docs/web-testing/planner.md +122 -0
  130. package/docs/web-testing/rerun.md +164 -0
  131. package/docs/web-testing/researcher.md +380 -0
  132. package/docs/workflow/agentic-usage.md +233 -0
  133. package/docs/workflow/application-spec.md +73 -0
  134. package/docs/workflow/ci.md +202 -0
  135. package/docs/workflow/knowledge.md +310 -0
  136. package/docs/workflow/planning-styles.md +67 -0
  137. package/docs/workflow/reporting.md +133 -0
  138. package/docs/workflow/test-plans.md +90 -0
  139. package/package.json +3 -2
  140. package/rules/navigator/verification-actions.md +20 -0
  141. package/src/action-result.ts +11 -0
  142. package/src/action.ts +43 -3
  143. package/src/ai/captain/web-mode.ts +6 -3
  144. package/src/ai/captain.ts +3 -0
  145. package/src/ai/navigator.ts +255 -186
  146. package/src/ai/pilot.ts +104 -2
  147. package/src/ai/provider.ts +14 -24
  148. package/src/ai/rerunner.ts +2 -1
  149. package/src/ai/researcher/cache.ts +12 -2
  150. package/src/ai/researcher.ts +3 -2
  151. package/src/ai/rules.ts +17 -10
  152. package/src/ai/session-analyst.ts +2 -0
  153. package/src/ai/task-agent.ts +3 -1
  154. package/src/ai/tester.ts +52 -45
  155. package/src/ai/tools.ts +136 -37
  156. package/src/commands/config-command.ts +146 -0
  157. package/src/commands/index.ts +2 -0
  158. package/src/config.ts +60 -5
  159. package/src/execution-controller.ts +8 -0
  160. package/src/explorbot.ts +7 -3
  161. package/src/explorer.ts +2 -2
  162. package/src/playwright-recorder.ts +23 -0
  163. package/src/remote.ts +244 -0
  164. package/src/reporter.ts +7 -1
  165. package/src/state-manager.ts +6 -2
  166. package/src/stats.ts +1 -0
  167. package/src/test-plan.ts +29 -0
  168. package/src/utils/aria.ts +65 -45
  169. package/src/utils/html.ts +1 -0
  170. package/src/utils/logger.ts +33 -2
  171. package/src/utils/page-readiness.ts +24 -1
  172. package/src/utils/url-matcher.ts +3 -0
  173. package/src/utils/web-element.ts +9 -0
  174. package/src/utils/web-sandbox.ts +3 -4
package/src/ai/tester.ts CHANGED
@@ -11,7 +11,7 @@ import { Observability } from '../observability.ts';
11
11
  import type { StateTransition } from '../state-manager.ts';
12
12
  import { Stats } from '../stats.ts';
13
13
  import { type Test, TestResult, type TestResultType } from '../test-plan.ts';
14
- import { detectFocusArea, extractFocusedElement } from '../utils/aria.ts';
14
+ import { compactAriaSnapshot, detectFocusArea } from '../utils/aria.ts';
15
15
  import { ErrorPageError, isErrorPage } from '../utils/error-page.ts';
16
16
  import { createDebug, tag } from '../utils/logger.ts';
17
17
  import { loop } from '../utils/loop.ts';
@@ -24,7 +24,7 @@ import { Provider } from './provider.ts';
24
24
  import { Researcher } from './researcher.ts';
25
25
  import { actionRule, capabilityGroundingRule, dataProtectionRules, focusedElementRule, formRequirementsRule, locatorRule, multipleTabsRule, sectionContextRule } from './rules.ts';
26
26
  import { TaskAgent } from './task-agent.ts';
27
- import { createCodeceptJSTools, createIframeTools } from './tools.ts';
27
+ import { createCodeceptJSTools, createIframeTools, withdrawVisionTools } from './tools.ts';
28
28
 
29
29
  const debugLog = createDebug('explorbot:tester');
30
30
 
@@ -62,7 +62,6 @@ export class Tester extends TaskAgent implements Agent {
62
62
  private seenUiMapUrls = new Set<string>();
63
63
  private lastAnalyzedStateHash: string | null = null;
64
64
  private stalledIterations = 0;
65
- private hasSuccessfulAssertion = false;
66
65
  private readonly MAX_STALLED_ITERATIONS = 3;
67
66
 
68
67
  constructor(deps: AgentDeps, researcher: Researcher, navigator: Navigator, agentTools?: any) {
@@ -97,7 +96,7 @@ export class Tester extends TaskAgent implements Agent {
97
96
  return this.currentConversation;
98
97
  }
99
98
 
100
- async test(task: Test): Promise<{ success: boolean }> {
99
+ async test(task: Test, opts: TestOptions = {}): Promise<{ success: boolean }> {
101
100
  Stats.tests++;
102
101
  const state = this.stateManager.getCurrentState();
103
102
  if (!state) throw new Error('No state found');
@@ -111,7 +110,6 @@ export class Tester extends TaskAgent implements Agent {
111
110
  this.seenUiMapUrls.clear();
112
111
  this.lastAnalyzedStateHash = null;
113
112
  this.stalledIterations = 0;
114
- this.hasSuccessfulAssertion = false;
115
113
  this.stateManager.clearHistory();
116
114
  this.resetFailureCount();
117
115
  this.pilot?.reset();
@@ -119,7 +117,7 @@ export class Tester extends TaskAgent implements Agent {
119
117
  const requestStore = this.requestStore;
120
118
  requestStore.clear();
121
119
  const offFailedRequest = requestStore.onFailedRequest((r) => {
122
- task.addNote(`Network error: ${r.method} ${r.path} → ${r.status}`, TestResult.FAILED);
120
+ task.addObservation(`Network error: ${r.method} ${r.path} → ${r.status}`);
123
121
  });
124
122
 
125
123
  const initialState = ActionResult.fromState(state);
@@ -153,11 +151,11 @@ export class Tester extends TaskAgent implements Agent {
153
151
  expected: task.expected,
154
152
  },
155
153
  },
156
- async () => this.runTestSession(task, initialState, conversation, { offFailedRequest })
154
+ async () => this.runTestSession(task, initialState, conversation, { offFailedRequest }, opts)
157
155
  );
158
156
  }
159
157
 
160
- private async runTestSession(task: Test, initialState: ActionResult, conversation: Conversation, handlers: TestSessionHandlers): Promise<{ success: boolean }> {
158
+ private async runTestSession(task: Test, initialState: ActionResult, conversation: Conversation, handlers: TestSessionHandlers, opts: TestOptions): Promise<{ success: boolean }> {
161
159
  const { offFailedRequest } = handlers;
162
160
 
163
161
  if (this.pilot) {
@@ -189,15 +187,19 @@ export class Tester extends TaskAgent implements Agent {
189
187
  return { success: task.isSuccessful };
190
188
  }
191
189
 
192
- debugLog(`Navigating to ${task.startUrl}`);
193
- try {
194
- await this.explorer.visit(task.startUrl!);
195
- } catch (error) {
196
- const result = await this.handleLoopError(task, error);
197
- if (result === 'stop') {
198
- offFailedRequest?.();
199
- await this.cleanupStartedTest(task);
200
- return { success: task.isSuccessful };
190
+ if (opts.startOnCurrentPage) debugLog(`Starting on the page already open at ${task.startUrl}`);
191
+
192
+ if (!opts.startOnCurrentPage) {
193
+ debugLog(`Navigating to ${task.startUrl}`);
194
+ try {
195
+ await this.explorer.visit(task.startUrl!);
196
+ } catch (error) {
197
+ const result = await this.handleLoopError(task, error);
198
+ if (result === 'stop') {
199
+ offFailedRequest?.();
200
+ await this.cleanupStartedTest(task);
201
+ return { success: task.isSuccessful };
202
+ }
201
203
  }
202
204
  }
203
205
 
@@ -247,6 +249,7 @@ export class Tester extends TaskAgent implements Agent {
247
249
  if (currentState.isInsideIframe) {
248
250
  Object.assign(tools, createIframeTools(this.toolDeps));
249
251
  }
252
+ withdrawVisionTools(tools);
250
253
 
251
254
  debugLog(`Test ${task.scenario} iteration ${iteration}`);
252
255
 
@@ -314,17 +317,9 @@ export class Tester extends TaskAgent implements Agent {
314
317
  const allToolNames = result?.toolExecutions?.map((execution: any) => execution.toolName) || [];
315
318
  const successfulToolNames = result?.toolExecutions?.filter((execution: any) => execution.wasSuccessful)?.map((execution: any) => execution.toolName) || [];
316
319
  const actionPerformed = !!allToolNames.find((toolName: string) => this.ACTION_TOOLS.includes(toolName));
317
- const successfulActionPerformed = !!successfulToolNames.find((toolName: string) => this.ACTION_TOOLS.includes(toolName));
318
320
  assertionPerformed = !!successfulToolNames.find((toolName: string) => this.ASSERTION_TOOLS.includes(toolName));
319
321
  const wasSuccessful = result?.toolExecutions?.every((execution: any) => execution.wasSuccessful);
320
322
 
321
- if (successfulActionPerformed) {
322
- this.hasSuccessfulAssertion = false;
323
- }
324
- if (assertionPerformed) {
325
- this.hasSuccessfulAssertion = true;
326
- }
327
-
328
323
  this.trackToolExecutions(result?.toolExecutions || []);
329
324
 
330
325
  if (this.consecutiveEmptyResults >= 5) {
@@ -416,6 +411,7 @@ export class Tester extends TaskAgent implements Agent {
416
411
  if (extensions >= this.MAX_EXTENSIONS) break;
417
412
 
418
413
  extensions++;
414
+ this.stalledIterations = 0;
419
415
  tag('info').log(`Pilot extending test (${extensions}/${this.MAX_EXTENSIONS})`);
420
416
  conversation.cleanupTag('page_aria', '...trimmed...', 1);
421
417
  conversation.cleanupTag('page_html', '...trimmed...', 0);
@@ -474,13 +470,7 @@ export class Tester extends TaskAgent implements Agent {
474
470
  this.stalledIterations++;
475
471
  if (this.stalledIterations < this.MAX_STALLED_ITERATIONS) return false;
476
472
 
477
- if (this.hasSuccessfulAssertion) {
478
- task.addNote('No further browser progress after successful verification; requesting final review');
479
- return true;
480
- }
481
-
482
- task.addNote('No browser progress after repeated attempts on unchanged page', TestResult.FAILED);
483
- task.finish(TestResult.FAILED);
473
+ task.addNote('No further browser progress on unchanged page; requesting final review');
484
474
  return true;
485
475
  }
486
476
 
@@ -500,7 +490,8 @@ export class Tester extends TaskAgent implements Agent {
500
490
  `;
501
491
 
502
492
  if (task.getPrintableNotes()) {
503
- outcomeStatus = dedent`
493
+ outcomeStatus += dedent`
494
+
504
495
  Your current log:
505
496
  <notes>
506
497
  ${task.notesToString()}
@@ -529,7 +520,7 @@ export class Tester extends TaskAgent implements Agent {
529
520
 
530
521
  const focusArea = detectFocusArea(currentState.ariaSnapshot);
531
522
 
532
- const focusedElement = extractFocusedElement(currentState.ariaSnapshot);
523
+ const focusedElement = currentState.focusedElement;
533
524
  if (focusedElement) {
534
525
  const isTextInput = ['textbox', 'combobox', 'searchbox'].includes(focusedElement.role);
535
526
  context += dedent`
@@ -611,7 +602,7 @@ export class Tester extends TaskAgent implements Agent {
611
602
  </page>
612
603
 
613
604
  <page_aria>
614
- ${currentState.getInteractiveARIA()}
605
+ ${await this.interactiveAriaWithRefs(currentState)}
615
606
  </page_aria>
616
607
  ${uiMapSection}
617
608
 
@@ -648,25 +639,32 @@ export class Tester extends TaskAgent implements Agent {
648
639
  </page>
649
640
 
650
641
  <page_aria>
651
- ${currentState.getInteractiveARIA()}
642
+ ${await this.interactiveAriaWithRefs(currentState)}
652
643
  </page_aria>
653
644
  `;
654
645
  }
655
646
 
647
+ private async interactiveAriaWithRefs(state: ActionResult): Promise<string> {
648
+ const withRefs = await Promise.resolve(this.explorer?.withPage?.((page: any) => page.locator('body').ariaSnapshot({ mode: 'ai' }))).catch(() => null);
649
+ if (!withRefs) return state.getInteractiveARIA();
650
+ return compactAriaSnapshot(withRefs, false);
651
+ }
652
+
656
653
  private finishTest(task: Test): void {
657
- if (!task.hasFinished) {
658
- task.finish(TestResult.FAILED);
654
+ if (!task.result) {
655
+ if (task.hasAchievedAll()) task.finish(TestResult.PASSED);
656
+ else task.finish(TestResult.FAILED);
659
657
  }
660
658
 
661
659
  if (task.isSuccessful) {
662
660
  tag('success').log(`Successful test: ${task.scenario}`);
663
- } else if (task.isSkipped) {
661
+ return;
662
+ }
663
+ if (task.isSkipped) {
664
664
  tag('warning').log(`Skipped test: ${task.scenario}`);
665
- } else if (task.hasFailed) {
666
- tag('error').log(`Failed test: ${task.scenario}`);
667
- } else {
668
- tag('warning').log(`Test with no result: ${task.scenario}`);
665
+ return;
669
666
  }
667
+ tag('error').log(`Failed test: ${task.scenario}`);
670
668
  }
671
669
 
672
670
  private async abortStartedTestOnErrorPage(task: Test, actionResult: ActionResult): Promise<{ success: boolean }> {
@@ -810,6 +808,9 @@ export class Tester extends TaskAgent implements Agent {
810
808
  ${task.expected.map((e) => `- ${e}`).join('\n')}
811
809
  </expected_results>
812
810
 
811
+ An expected result counts as settled only when you record it back word for word as it is written above.
812
+ A note in your own wording is a general note and leaves that result unsettled.
813
+
813
814
  Your goal is to perform actions on the web page and verify the expected outcomes.
814
815
  Try to achieve as many goals as possible.
815
816
  If goal is not achievable, log that and skip to next one.
@@ -1059,6 +1060,9 @@ export class Tester extends TaskAgent implements Agent {
1059
1060
  - You unsuccessfully tried multiple iterations and failed
1060
1061
  - If the expected result was expected to fail, use status="success" instead
1061
1062
 
1063
+ When a note settles one of the expected results, that note must repeat the expected result word
1064
+ for word. Paraphrasing it leaves the expected result unsettled and it is reported as unverified.
1065
+
1062
1066
  Example:
1063
1067
  - record({ notes: ["clicked login button", "login form appeared", "fill credentials"], status: "success" })
1064
1068
  `,
@@ -1143,8 +1147,7 @@ export class Tester extends TaskAgent implements Agent {
1143
1147
  this.stalledIterations++;
1144
1148
  if (this.stalledIterations < this.MAX_STALLED_ITERATIONS) return false;
1145
1149
 
1146
- task.addNote('No browser progress after repeated execution errors', TestResult.FAILED);
1147
- task.finish(TestResult.FAILED);
1150
+ task.addNote('No browser progress after repeated execution errors; requesting final review');
1148
1151
  return true;
1149
1152
  }
1150
1153
 
@@ -1161,3 +1164,7 @@ export class Tester extends TaskAgent implements Agent {
1161
1164
  interface TestSessionHandlers {
1162
1165
  offFailedRequest?: () => void;
1163
1166
  }
1167
+
1168
+ export interface TestOptions {
1169
+ startOnCurrentPage?: boolean;
1170
+ }
package/src/ai/tools.ts CHANGED
@@ -3,8 +3,9 @@ import dedent from 'dedent';
3
3
  import { z } from 'zod';
4
4
  import { ActionResult, type PageDiff, type ToolResultMetadata } from '../action-result.ts';
5
5
  import type { ExperienceTracker } from '../experience-tracker.ts';
6
+ import { Stats } from '../stats.ts';
6
7
  import { type Task, TestResult } from '../test-plan.js';
7
- import { LARGE_ARIA_CHANGE_THRESHOLD, extractFocusedElement } from '../utils/aria.ts';
8
+ import { LARGE_ARIA_CHANGE_THRESHOLD } from '../utils/aria.ts';
8
9
  import { isFatalBrowserError } from '../utils/browser-errors.ts';
9
10
  import { createDebug, tag } from '../utils/logger.js';
10
11
  import { pause } from '../utils/loop.js';
@@ -33,6 +34,10 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
33
34
  description: dedent`
34
35
  Click an element by trying multiple CodeceptJS commands in order until one succeeds.
35
36
 
37
+ Use this only for elements the page context gives you no ref for. When the element shows a ref such as [ref=e14],
38
+ call clickRef with that ref instead — composing a locator for an element that already has a ref is wasted work,
39
+ and a locator can match several elements where a ref cannot.
40
+
36
41
  Follow <locator_priority> from system prompt for locator selection.
37
42
 
38
43
  I.click(locator) - click element matching locator
@@ -51,14 +56,13 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
51
56
  commands: z.array(z.string()).describe(dedent`
52
57
  FALLBACK LOCATORS for ONE element. All commands must click the SAME element.
53
58
  Never mix different elements — use separate click() calls instead.
59
+ REQUIRED: include at least one command WITHOUT a container — a wrong or stale container always fails.
54
60
  Order by reliability:
55
- 1. I.click(text, container) - PREFERRED when container is known - e.g. I.click("Save", ".modal")
61
+ 1. I.click(text, container) - when the container is verified - e.g. I.click("Save", ".modal")
56
62
  2. I.click(ARIA, container) - e.g. I.click({"role":"button","text":"Save"}, ".modal")
57
63
  3. I.click(CSS, container) - e.g. I.click("#btn", ".modal")
58
64
  4. I.click(CSS) or I.click(XPath) - when locator already includes context (ID, XPath)
59
65
  5. I.clickXY(x, y) - coordinates fallback
60
- IMPORTANT: Always include at least one command WITHOUT a container as fallback,
61
- in case the element moved to a different section (e.g. I.click("Save") without container).
62
66
  `),
63
67
  explanation: z.string().describe('Why you are clicking this element'),
64
68
  }),
@@ -132,13 +136,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
132
136
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, commands[0]);
133
137
  await commitNote(activeNote, TestResult.FAILED, toolResult, action);
134
138
 
135
- let suggestion = "Try xpathCheck() to find the element's actual position, see() for visual analysis, or visualClick() to click by visual appearance.";
136
- const lastError = attempts[attempts.length - 1]?.error || '';
137
- if (lastError.includes('was not found') || lastError.includes('not found by text')) {
138
- suggestion = 'Element was not found in the DOM. Use xpathCheck() to locate it, context() to refresh snapshot, or visualClick() to click by visual appearance.';
139
- } else if (lastError.includes('Timeout') || lastError.includes('intercept')) {
140
- suggestion = 'Element exists but could not be clicked (possibly covered by overlay or not interactable). Try closing overlapping panels first, or use visualClick().';
141
- }
139
+ const suggestion = clickFailureSuggestion(attempts);
142
140
 
143
141
  return failedToolResult(
144
142
  'click',
@@ -153,6 +151,41 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
153
151
  },
154
152
  }),
155
153
 
154
+ clickRef: tool({
155
+ description: dedent`
156
+ Click an element by the ref the page context gave it, e.g. [ref=e14].
157
+
158
+ Prefer this over click() whenever the element you want carries a ref. A ref names one exact element, so it
159
+ cannot match several by mistake and never needs disambiguating — it is the fastest way to click.
160
+ Only pass a ref that appears in the page context you were given. Never invent or guess one.
161
+ If it reports the ref is gone, the page has been rebuilt: get fresh context and use the new ref.
162
+ `,
163
+ inputSchema: z.object({
164
+ ref: z.string().describe('The ref exactly as it appears in the page context, e.g. "e14"'),
165
+ element: z.string().describe('Role and name of the element you are clicking, for the record'),
166
+ }),
167
+ execute: async ({ ref, element }) => {
168
+ const activeNote = task.startNote(`Click ${element}`);
169
+ const previousState = ActionResult.fromState(stateManager.getCurrentState()!);
170
+ const action = explorer.action();
171
+ const named = await describeRef(explorer, ref);
172
+ const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(`aria-ref=${ref}`)}).click())`;
173
+
174
+ if (!(await action.attempt(run, `Click ${element}`))) {
175
+ activeNote.commit(TestResult.FAILED);
176
+ return failedToolResult('clickRef', `Ref ${ref} could not be clicked: ${errorText(action.lastError)}`, {
177
+ suggestion: 'The ref may belong to an older version of the page. Get fresh context and use the ref it gives, or fall back to click() with a locator.',
178
+ });
179
+ }
180
+
181
+ // a ref belongs to this session only, so the run is reported as the locator a later test can replay
182
+ const code = named ? `I.click(${JSON.stringify(named)})` : run;
183
+ const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, code);
184
+ await commitNote(activeNote, TestResult.PASSED, toolResult, action);
185
+ return successToolResult('clickRef', { ...toolResult, code }, action);
186
+ },
187
+ }),
188
+
156
189
  hover: tool({
157
190
  description: dedent`
158
191
  Move the mouse cursor to an element to reveal hover-only controls.
@@ -299,15 +332,11 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
299
332
  const focusFreeKeys = new Set(['Escape', 'Esc', 'Tab', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12']);
300
333
  const needsFocus = !focusFreeKeys.has(keyToUse) && !modifier;
301
334
 
302
- if (needsFocus) {
303
- const currentAriaState = stateManager.getCurrentState()?.ariaSnapshot;
304
- const focused = extractFocusedElement(currentAriaState ?? null);
305
- if (!focused) {
306
- activeNote.commit(TestResult.FAILED);
307
- return failedToolResult('pressKey', `No element is focused. Key '${keyToUse}' requires a focused element.`, {
308
- suggestion: 'Click the target element first, then press the key.',
309
- });
310
- }
335
+ if (needsFocus && !(await hasFocusedElement(explorer))) {
336
+ activeNote.commit(TestResult.FAILED);
337
+ return failedToolResult('pressKey', `No element is focused. Key '${keyToUse}' requires a focused element.`, {
338
+ suggestion: 'Click the target element first, then press the key.',
339
+ });
311
340
  }
312
341
 
313
342
  const previousState = ActionResult.fromState(stateManager.getCurrentState()!);
@@ -358,6 +387,8 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
358
387
  Execute raw CodeceptJS code block with multiple commands.
359
388
  USE THIS TOOL for typing text into fields: I.fillField, I.type
360
389
 
390
+ Do not put a click on a ref-bearing element in here — clickRef with its ref is cheaper and cannot mis-target.
391
+
361
392
  Follow <actions> from system prompt for available commands.
362
393
  Follow <locator_priority> from system prompt for locator selection.
363
394
 
@@ -539,26 +570,25 @@ export function createLearnExperienceTool({ getExperienceTracker, getState }: {
539
570
  }
540
571
 
541
572
  export function createAgentTools({ explorer, stateManager, ai, researcher, navigator, supervisor, withExperience }: AgentToolDeps): any {
542
- let visionDisabled = false;
543
-
544
573
  const tools: Record<string, any> = {
545
574
  see: tool({
546
575
  description: dedent`
547
- Check the page contents based on current page state and screenshot.
548
- This tool will trigger visual research to check the page contents on request.
549
- Use it to verify the actions were performed correctly and the page is in the expected state.
576
+ Answer a question about the page from a screenshot, for things its structure cannot express:
577
+ layout and position, what an image or canvas depicts, colour, and whether something is covered or cut off.
578
+ This runs a second model and is the slowest tool here, so reach for it only when the question is genuinely visual.
579
+ Do NOT use it to confirm an action landed — every action already reports what changed on the page.
550
580
  Input schema has exactly one field: request. Do not pass text, reason, assertion, or other fields.
551
581
 
552
582
  <example>
553
- request: "Check current state of the Login form"
554
- result: "Login form is visible with username and password fields, username is filled with 'testuser' and password is empty'
583
+ request: "Is the save button covered by anything, and does the chart show any plotted data?"
584
+ result: "The save button is partly behind a cookie banner at the bottom. The chart area is empty apart from its axes."
555
585
  </example>
556
586
  `,
557
587
  inputSchema: z.object({
558
588
  request: z.string().describe('LLM-friendly description of the page contents to look for. 1-3 sentences. No more than 100 words.'),
559
589
  }),
560
590
  execute: async ({ request }) => {
561
- if (visionDisabled) {
591
+ if (Stats.visionDisabled) {
562
592
  return failedToolResult('see', 'Vision tools are disabled for this session. Use context() to get fresh ARIA snapshot and analyze page state from ARIA data.');
563
593
  }
564
594
 
@@ -583,8 +613,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
583
613
  } catch (error) {
584
614
  throwIfFatalBrowserError(error);
585
615
  const errorMessage = errorText(error);
586
- visionDisabled = true;
587
- tag('warning').log('⚠️ Vision model is not available. Visual checks are disabled for this session.');
616
+ disableVision();
588
617
  return failedToolResult('see', `See tool failed: ${errorMessage}`, {
589
618
  suggestion: 'Vision is now disabled. Use context() to get fresh ARIA snapshot and analyze page state from ARIA data.',
590
619
  });
@@ -676,6 +705,13 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
676
705
  );
677
706
  }
678
707
 
708
+ if (result.inexpressible) {
709
+ return failedToolResult('verify', `No assertion could express this claim: ${assertion}`, {
710
+ inexpressible: true,
711
+ suggestion: 'This is not evidence the page is wrong — the claim could not be turned into an assertion. Restate it in terms of what is visible or of a control state, or check it with see().',
712
+ });
713
+ }
714
+
679
715
  return failedToolResult('verify', `Verification failed: ${assertion}`, {
680
716
  suggestion: 'The assertion could not be verified. Check if the condition is actually present on the page or try a different assertion.',
681
717
  });
@@ -774,7 +810,10 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
774
810
  });
775
811
  }
776
812
 
777
- return failedToolResult('interact', `Failed to execute: ${instruction}`, {
813
+ let reason = '';
814
+ if (navigator.lastFailureReason) reason = `: ${navigator.lastFailureReason}`;
815
+
816
+ return failedToolResult('interact', `Failed to execute: ${instruction}${reason}`, {
778
817
  ...toolResult,
779
818
  suggestion: 'The action could not be completed. Try a different instruction or use more specific element descriptions.',
780
819
  });
@@ -804,7 +843,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
804
843
  context: z.string().describe('What you already tried and why it failed - helps with accurate identification'),
805
844
  }),
806
845
  execute: async ({ element, context }) => {
807
- if (visionDisabled) {
846
+ if (Stats.visionDisabled) {
808
847
  return failedToolResult('visualClick', 'Vision tools are disabled for this session. Use xpathCheck() to find the element, then click() with the discovered locator.');
809
848
  }
810
849
 
@@ -860,8 +899,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
860
899
  } catch (error) {
861
900
  throwIfFatalBrowserError(error);
862
901
  const errorMessage = errorText(error);
863
- visionDisabled = true;
864
- tag('warning').log('⚠️ Vision model is not available. Visual clicks are disabled for this session.');
902
+ disableVision();
865
903
  return failedToolResult('visualClick', `visualClick tool failed: ${errorMessage}`, {
866
904
  suggestion: 'Vision is now disabled. Use xpathCheck() to find the element, then click() with the discovered locator.',
867
905
  });
@@ -1011,6 +1049,12 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
1011
1049
  }),
1012
1050
  };
1013
1051
 
1052
+ const disableVision = (): void => {
1053
+ Stats.visionDisabled = true;
1054
+ withdrawVisionTools(tools);
1055
+ tag('warning').log('⚠️ Vision model is not available. Visual tools are disabled for this session.');
1056
+ };
1057
+
1014
1058
  if (withExperience !== false) {
1015
1059
  tools.learnExperience = createLearnExperienceTool({
1016
1060
  getExperienceTracker: () => stateManager.getExperienceTracker(),
@@ -1063,6 +1107,8 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
1063
1107
  });
1064
1108
  }
1065
1109
 
1110
+ withdrawVisionTools(tools);
1111
+
1066
1112
  return tools;
1067
1113
  }
1068
1114
 
@@ -1112,14 +1158,33 @@ function errorText(error: unknown): string {
1112
1158
  return 'Unknown error occurred';
1113
1159
  }
1114
1160
 
1115
- async function commitNote(activeNote: any, result: TestResult, toolResult: any, action: any): Promise<void> {
1161
+ export async function commitNote(activeNote: any, result: TestResult, toolResult: any, action: any): Promise<void> {
1116
1162
  if (toolResult?.pageDiff?.ariaChanges || toolResult?.pageDiff?.urlChanged) {
1117
1163
  activeNote.screenshot = await action.saveScreenshot();
1118
1164
  }
1119
1165
  activeNote.commit(result);
1120
1166
  }
1121
1167
 
1122
- function successToolResult(action: string, data?: Record<string, any>, source?: { playwrightGroupId?: string | null; assertionSteps?: any[] }) {
1168
+ async function describeRef(explorer: any, ref: string): Promise<{ role: string; text: string } | null> {
1169
+ return Promise.resolve(
1170
+ explorer?.withPage?.((page: any) =>
1171
+ page.locator(`aria-ref=${ref}`).evaluate((el: any) => {
1172
+ const tag = el.tagName.toLowerCase();
1173
+ const roles: Record<string, string> = { a: 'link', button: 'button', select: 'combobox', textarea: 'textbox' };
1174
+ const role = el.getAttribute('role') || roles[tag] || tag;
1175
+ const text = (el.getAttribute('aria-label') || el.innerText || el.value || '').trim().split('\n')[0];
1176
+ if (!text) return null;
1177
+ return { role, text };
1178
+ })
1179
+ )
1180
+ ).catch(() => null);
1181
+ }
1182
+
1183
+ async function hasFocusedElement(explorer: any): Promise<boolean> {
1184
+ return explorer.withPage((page: any) => page.evaluate(() => !!document.activeElement && document.activeElement !== document.body)).catch(() => true);
1185
+ }
1186
+
1187
+ export function successToolResult(action: string, data?: Record<string, any>, source?: { playwrightGroupId?: string | null; assertionSteps?: any[] }) {
1123
1188
  const result: Record<string, any> = { success: true, action, ...data };
1124
1189
  if (source?.playwrightGroupId) {
1125
1190
  result.playwrightGroupId = source.playwrightGroupId;
@@ -1155,7 +1220,7 @@ function hasObservablePageChange(data?: Record<string, any>): boolean {
1155
1220
  return Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
1156
1221
  }
1157
1222
 
1158
- async function failedToolResult(action: string, message: string, data?: Record<string, any>, error?: Error | null) {
1223
+ export async function failedToolResult(action: string, message: string, data?: Record<string, any>, error?: Error | null) {
1159
1224
  const result: Record<string, any> = { success: false, action, message, ...data };
1160
1225
  if (data?.pageDiff) {
1161
1226
  result.suggestion = data.suggestion ? `${data.suggestion} ${PAGE_DIFF_SUGGESTION}` : PAGE_DIFF_SUGGESTION;
@@ -1192,6 +1257,40 @@ function getMultipleElementsSuggestion(): string {
1192
1257
  `;
1193
1258
  }
1194
1259
 
1260
+ export function withdrawVisionTools(tools: Record<string, any>): void {
1261
+ if (!Stats.visionDisabled) return;
1262
+ Reflect.deleteProperty(tools, 'see');
1263
+ Reflect.deleteProperty(tools, 'visualClick');
1264
+ }
1265
+
1266
+ export function clickFailureSuggestion(attempts: Array<{ error?: string }>): string {
1267
+ const errors = attempts.map((a) => a.error || '');
1268
+
1269
+ if (errors.some((e) => e.includes('not enabled'))) {
1270
+ return 'Element exists but is DISABLED — clicking it again cannot work. A precondition is unmet: a required field is empty, nothing is selected, or a dialog is blocking. Satisfy it, then retry.';
1271
+ }
1272
+
1273
+ if (errors.some((e) => e.includes('intercepts pointer events'))) {
1274
+ return 'Element exists but another element covers it. Close the overlapping panel or dialog, then retry.';
1275
+ }
1276
+
1277
+ if (errors.some((e) => e.includes('is not visible'))) {
1278
+ return 'Element is in the DOM but not visible. Reveal it first — scroll to it, expand its section, or open the panel holding it.';
1279
+ }
1280
+
1281
+ const notFound = errors.filter((e) => e.includes('was not found'));
1282
+
1283
+ if (notFound.length && notFound.every((e) => e.includes('was not found inside element'))) {
1284
+ return 'Element was not found inside that container — the container is wrong or stale, and the element may exist elsewhere on the page. Retry the same locator WITHOUT a container, or verify the container with xpathCheck().';
1285
+ }
1286
+
1287
+ if (notFound.length) {
1288
+ return 'Element was not found in the DOM. Use xpathCheck() to locate it, context() to refresh snapshot, or visualClick() to click by visual appearance.';
1289
+ }
1290
+
1291
+ return "Try xpathCheck() to find the element's actual position, see() for visual analysis, or visualClick() to click by visual appearance.";
1292
+ }
1293
+
1195
1294
  const MAX_DISAMBIGUATE_ELEMENTS = 10;
1196
1295
  const MULTIPLE_ELEMENTS_PATTERN = 'multiple elements';
1197
1296