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
@@ -8,13 +8,13 @@ import { clearActivity, setActivity } from "../activity.js";
8
8
  import { Observability } from "../observability.js";
9
9
  import { Stats } from "../stats.js";
10
10
  import { TestResult } from "../test-plan.js";
11
- import { detectFocusArea, extractFocusedElement } from "../utils/aria.js";
11
+ import { compactAriaSnapshot, detectFocusArea } from "../utils/aria.js";
12
12
  import { ErrorPageError, isErrorPage } from "../utils/error-page.js";
13
13
  import { createDebug, tag } from "../utils/logger.js";
14
14
  import { loop } from "../utils/loop.js";
15
15
  import { actionRule, capabilityGroundingRule, dataProtectionRules, focusedElementRule, formRequirementsRule, locatorRule, multipleTabsRule, sectionContextRule } from "./rules.js";
16
16
  import { TaskAgent } from "./task-agent.js";
17
- import { createCodeceptJSTools, createIframeTools } from "./tools.js";
17
+ import { createCodeceptJSTools, createIframeTools, withdrawVisionTools } from "./tools.js";
18
18
  const debugLog = createDebug('explorbot:tester');
19
19
  const SAMPLE_FILES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../../assets/sample-files');
20
20
  const SAMPLE_FILES = {
@@ -48,7 +48,6 @@ export class Tester extends TaskAgent {
48
48
  seenUiMapUrls = new Set();
49
49
  lastAnalyzedStateHash = null;
50
50
  stalledIterations = 0;
51
- hasSuccessfulAssertion = false;
52
51
  MAX_STALLED_ITERATIONS = 3;
53
52
  constructor(deps, researcher, navigator, agentTools) {
54
53
  super(deps);
@@ -75,7 +74,7 @@ export class Tester extends TaskAgent {
75
74
  getConversation() {
76
75
  return this.currentConversation;
77
76
  }
78
- async test(task) {
77
+ async test(task, opts = {}) {
79
78
  Stats.tests++;
80
79
  const state = this.stateManager.getCurrentState();
81
80
  if (!state)
@@ -88,14 +87,13 @@ export class Tester extends TaskAgent {
88
87
  this.seenUiMapUrls.clear();
89
88
  this.lastAnalyzedStateHash = null;
90
89
  this.stalledIterations = 0;
91
- this.hasSuccessfulAssertion = false;
92
90
  this.stateManager.clearHistory();
93
91
  this.resetFailureCount();
94
92
  this.pilot?.reset();
95
93
  const requestStore = this.requestStore;
96
94
  requestStore.clear();
97
95
  const offFailedRequest = requestStore.onFailedRequest((r) => {
98
- task.addNote(`Network error: ${r.method} ${r.path} → ${r.status}`, TestResult.FAILED);
96
+ task.addObservation(`Network error: ${r.method} ${r.path} → ${r.status}`);
99
97
  });
100
98
  const initialState = ActionResult.fromState(state);
101
99
  if (isErrorPage(initialState)) {
@@ -122,9 +120,9 @@ export class Tester extends TaskAgent {
122
120
  startUrl: task.startUrl,
123
121
  expected: task.expected,
124
122
  },
125
- }, async () => this.runTestSession(task, initialState, conversation, { offFailedRequest }));
123
+ }, async () => this.runTestSession(task, initialState, conversation, { offFailedRequest }, opts));
126
124
  }
127
- async runTestSession(task, initialState, conversation, handlers) {
125
+ async runTestSession(task, initialState, conversation, handlers, opts) {
128
126
  const { offFailedRequest } = handlers;
129
127
  if (this.pilot) {
130
128
  try {
@@ -153,16 +151,20 @@ export class Tester extends TaskAgent {
153
151
  await this.cleanupStartedTest(task);
154
152
  return { success: task.isSuccessful };
155
153
  }
156
- debugLog(`Navigating to ${task.startUrl}`);
157
- try {
158
- await this.explorer.visit(task.startUrl);
159
- }
160
- catch (error) {
161
- const result = await this.handleLoopError(task, error);
162
- if (result === 'stop') {
163
- offFailedRequest?.();
164
- await this.cleanupStartedTest(task);
165
- return { success: task.isSuccessful };
154
+ if (opts.startOnCurrentPage)
155
+ debugLog(`Starting on the page already open at ${task.startUrl}`);
156
+ if (!opts.startOnCurrentPage) {
157
+ debugLog(`Navigating to ${task.startUrl}`);
158
+ try {
159
+ await this.explorer.visit(task.startUrl);
160
+ }
161
+ catch (error) {
162
+ const result = await this.handleLoopError(task, error);
163
+ if (result === 'stop') {
164
+ offFailedRequest?.();
165
+ await this.cleanupStartedTest(task);
166
+ return { success: task.isSuccessful };
167
+ }
166
168
  }
167
169
  }
168
170
  const startState = this.stateManager.getCurrentState();
@@ -208,6 +210,7 @@ export class Tester extends TaskAgent {
208
210
  if (currentState.isInsideIframe) {
209
211
  Object.assign(tools, createIframeTools(this.toolDeps));
210
212
  }
213
+ withdrawVisionTools(tools);
211
214
  debugLog(`Test ${task.scenario} iteration ${iteration}`);
212
215
  if (this.stateManager.isInDeadLoop()) {
213
216
  task.addNote('Dead loop detected. Stopped');
@@ -268,15 +271,8 @@ export class Tester extends TaskAgent {
268
271
  const allToolNames = result?.toolExecutions?.map((execution) => execution.toolName) || [];
269
272
  const successfulToolNames = result?.toolExecutions?.filter((execution) => execution.wasSuccessful)?.map((execution) => execution.toolName) || [];
270
273
  const actionPerformed = !!allToolNames.find((toolName) => this.ACTION_TOOLS.includes(toolName));
271
- const successfulActionPerformed = !!successfulToolNames.find((toolName) => this.ACTION_TOOLS.includes(toolName));
272
274
  assertionPerformed = !!successfulToolNames.find((toolName) => this.ASSERTION_TOOLS.includes(toolName));
273
275
  const wasSuccessful = result?.toolExecutions?.every((execution) => execution.wasSuccessful);
274
- if (successfulActionPerformed) {
275
- this.hasSuccessfulAssertion = false;
276
- }
277
- if (assertionPerformed) {
278
- this.hasSuccessfulAssertion = true;
279
- }
280
276
  this.trackToolExecutions(result?.toolExecutions || []);
281
277
  if (this.consecutiveEmptyResults >= 5) {
282
278
  task.addNote('AI model is not responding with actions. Stopped');
@@ -360,6 +356,7 @@ export class Tester extends TaskAgent {
360
356
  if (extensions >= this.MAX_EXTENSIONS)
361
357
  break;
362
358
  extensions++;
359
+ this.stalledIterations = 0;
363
360
  tag('info').log(`Pilot extending test (${extensions}/${this.MAX_EXTENSIONS})`);
364
361
  conversation.cleanupTag('page_aria', '...trimmed...', 1);
365
362
  conversation.cleanupTag('page_html', '...trimmed...', 0);
@@ -414,12 +411,7 @@ export class Tester extends TaskAgent {
414
411
  this.stalledIterations++;
415
412
  if (this.stalledIterations < this.MAX_STALLED_ITERATIONS)
416
413
  return false;
417
- if (this.hasSuccessfulAssertion) {
418
- task.addNote('No further browser progress after successful verification; requesting final review');
419
- return true;
420
- }
421
- task.addNote('No browser progress after repeated attempts on unchanged page', TestResult.FAILED);
422
- task.finish(TestResult.FAILED);
414
+ task.addNote('No further browser progress on unchanged page; requesting final review');
423
415
  return true;
424
416
  }
425
417
  async prepareInstructionsForNextStep(task) {
@@ -437,7 +429,8 @@ export class Tester extends TaskAgent {
437
429
  </rules>
438
430
  `;
439
431
  if (task.getPrintableNotes()) {
440
- outcomeStatus = dedent `
432
+ outcomeStatus += dedent `
433
+
441
434
  Your current log:
442
435
  <notes>
443
436
  ${task.notesToString()}
@@ -458,7 +451,7 @@ export class Tester extends TaskAgent {
458
451
  this.previousStateHash = currentStateHash;
459
452
  let context = '';
460
453
  const focusArea = detectFocusArea(currentState.ariaSnapshot);
461
- const focusedElement = extractFocusedElement(currentState.ariaSnapshot);
454
+ const focusedElement = currentState.focusedElement;
462
455
  if (focusedElement) {
463
456
  const isTextInput = ['textbox', 'combobox', 'searchbox'].includes(focusedElement.role);
464
457
  context += dedent `
@@ -539,7 +532,7 @@ export class Tester extends TaskAgent {
539
532
  </page>
540
533
 
541
534
  <page_aria>
542
- ${currentState.getInteractiveARIA()}
535
+ ${await this.interactiveAriaWithRefs(currentState)}
543
536
  </page_aria>
544
537
  ${uiMapSection}
545
538
 
@@ -574,26 +567,32 @@ export class Tester extends TaskAgent {
574
567
  </page>
575
568
 
576
569
  <page_aria>
577
- ${currentState.getInteractiveARIA()}
570
+ ${await this.interactiveAriaWithRefs(currentState)}
578
571
  </page_aria>
579
572
  `;
580
573
  }
574
+ async interactiveAriaWithRefs(state) {
575
+ const withRefs = await Promise.resolve(this.explorer?.withPage?.((page) => page.locator('body').ariaSnapshot({ mode: 'ai' }))).catch(() => null);
576
+ if (!withRefs)
577
+ return state.getInteractiveARIA();
578
+ return compactAriaSnapshot(withRefs, false);
579
+ }
581
580
  finishTest(task) {
582
- if (!task.hasFinished) {
583
- task.finish(TestResult.FAILED);
581
+ if (!task.result) {
582
+ if (task.hasAchievedAll())
583
+ task.finish(TestResult.PASSED);
584
+ else
585
+ task.finish(TestResult.FAILED);
584
586
  }
585
587
  if (task.isSuccessful) {
586
588
  tag('success').log(`Successful test: ${task.scenario}`);
589
+ return;
587
590
  }
588
- else if (task.isSkipped) {
591
+ if (task.isSkipped) {
589
592
  tag('warning').log(`Skipped test: ${task.scenario}`);
593
+ return;
590
594
  }
591
- else if (task.hasFailed) {
592
- tag('error').log(`Failed test: ${task.scenario}`);
593
- }
594
- else {
595
- tag('warning').log(`Test with no result: ${task.scenario}`);
596
- }
595
+ tag('error').log(`Failed test: ${task.scenario}`);
597
596
  }
598
597
  async abortStartedTestOnErrorPage(task, actionResult) {
599
598
  const error = new ErrorPageError(actionResult.url || task.startUrl || '', actionResult.title, actionResult.httpStatus);
@@ -734,6 +733,9 @@ export class Tester extends TaskAgent {
734
733
  ${task.expected.map((e) => `- ${e}`).join('\n')}
735
734
  </expected_results>
736
735
 
736
+ An expected result counts as settled only when you record it back word for word as it is written above.
737
+ A note in your own wording is a general note and leaves that result unsettled.
738
+
737
739
  Your goal is to perform actions on the web page and verify the expected outcomes.
738
740
  Try to achieve as many goals as possible.
739
741
  If goal is not achievable, log that and skip to next one.
@@ -968,6 +970,9 @@ export class Tester extends TaskAgent {
968
970
  - You unsuccessfully tried multiple iterations and failed
969
971
  - If the expected result was expected to fail, use status="success" instead
970
972
 
973
+ When a note settles one of the expected results, that note must repeat the expected result word
974
+ for word. Paraphrasing it leaves the expected result unsettled and it is reported as unverified.
975
+
971
976
  Example:
972
977
  - record({ notes: ["clicked login button", "login form appeared", "fill credentials"], status: "success" })
973
978
  `,
@@ -1045,8 +1050,7 @@ export class Tester extends TaskAgent {
1045
1050
  this.stalledIterations++;
1046
1051
  if (this.stalledIterations < this.MAX_STALLED_ITERATIONS)
1047
1052
  return false;
1048
- task.addNote('No browser progress after repeated execution errors', TestResult.FAILED);
1049
- task.finish(TestResult.FAILED);
1053
+ task.addNote('No browser progress after repeated execution errors; requesting final review');
1050
1054
  return true;
1051
1055
  }
1052
1056
  async cleanupStartedTest(task) {
@@ -16,6 +16,10 @@ export declare function createCodeceptJSTools({ explorer, stateManager, ai }: To
16
16
  commands: any;
17
17
  explanation: any;
18
18
  }, Record<string, any>, import("@ai-sdk/provider-utils").Context>>;
19
+ clickRef: import("@ai-sdk/provider-utils").ExecutableTool<import("ai").Tool<{
20
+ ref: any;
21
+ element: any;
22
+ }, Record<string, any>, import("@ai-sdk/provider-utils").Context>>;
19
23
  hover: import("@ai-sdk/provider-utils").ExecutableTool<import("ai").Tool<{
20
24
  commands: any;
21
25
  explanation: any;
@@ -49,5 +53,15 @@ export declare function createLearnExperienceTool({ getExperienceTracker, getSta
49
53
  error: string;
50
54
  }, import("@ai-sdk/provider-utils").Context>>;
51
55
  export declare function createAgentTools({ explorer, stateManager, ai, researcher, navigator, supervisor, withExperience }: AgentToolDeps): any;
56
+ export declare function commitNote(activeNote: any, result: TestResult, toolResult: any, action: any): Promise<void>;
57
+ export declare function successToolResult(action: string, data?: Record<string, any>, source?: {
58
+ playwrightGroupId?: string | null;
59
+ assertionSteps?: any[];
60
+ }): Record<string, any>;
52
61
  export declare function isMajorPageChange(pageDiff: PageDiff): boolean;
62
+ export declare function failedToolResult(action: string, message: string, data?: Record<string, any>, error?: Error | null): Promise<Record<string, any>>;
63
+ export declare function withdrawVisionTools(tools: Record<string, any>): void;
64
+ export declare function clickFailureSuggestion(attempts: Array<{
65
+ error?: string;
66
+ }>): string;
53
67
  export {};
@@ -2,8 +2,9 @@ import { tool } from 'ai';
2
2
  import dedent from 'dedent';
3
3
  import { z } from 'zod';
4
4
  import { ActionResult } from "../action-result.js";
5
+ import { Stats } from "../stats.js";
5
6
  import { TestResult } from '../test-plan.js';
6
- import { LARGE_ARIA_CHANGE_THRESHOLD, extractFocusedElement } from "../utils/aria.js";
7
+ import { LARGE_ARIA_CHANGE_THRESHOLD } from "../utils/aria.js";
7
8
  import { isFatalBrowserError } from "../utils/browser-errors.js";
8
9
  import { createDebug, tag } from '../utils/logger.js';
9
10
  import { pause } from '../utils/loop.js';
@@ -18,6 +19,10 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
18
19
  description: dedent `
19
20
  Click an element by trying multiple CodeceptJS commands in order until one succeeds.
20
21
 
22
+ Use this only for elements the page context gives you no ref for. When the element shows a ref such as [ref=e14],
23
+ call clickRef with that ref instead — composing a locator for an element that already has a ref is wasted work,
24
+ and a locator can match several elements where a ref cannot.
25
+
21
26
  Follow <locator_priority> from system prompt for locator selection.
22
27
 
23
28
  I.click(locator) - click element matching locator
@@ -36,14 +41,13 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
36
41
  commands: z.array(z.string()).describe(dedent `
37
42
  FALLBACK LOCATORS for ONE element. All commands must click the SAME element.
38
43
  Never mix different elements — use separate click() calls instead.
44
+ REQUIRED: include at least one command WITHOUT a container — a wrong or stale container always fails.
39
45
  Order by reliability:
40
- 1. I.click(text, container) - PREFERRED when container is known - e.g. I.click("Save", ".modal")
46
+ 1. I.click(text, container) - when the container is verified - e.g. I.click("Save", ".modal")
41
47
  2. I.click(ARIA, container) - e.g. I.click({"role":"button","text":"Save"}, ".modal")
42
48
  3. I.click(CSS, container) - e.g. I.click("#btn", ".modal")
43
49
  4. I.click(CSS) or I.click(XPath) - when locator already includes context (ID, XPath)
44
50
  5. I.clickXY(x, y) - coordinates fallback
45
- IMPORTANT: Always include at least one command WITHOUT a container as fallback,
46
- in case the element moved to a different section (e.g. I.click("Save") without container).
47
51
  `),
48
52
  explanation: z.string().describe('Why you are clicking this element'),
49
53
  }),
@@ -106,14 +110,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
106
110
  }
107
111
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, commands[0]);
108
112
  await commitNote(activeNote, TestResult.FAILED, toolResult, action);
109
- let suggestion = "Try xpathCheck() to find the element's actual position, see() for visual analysis, or visualClick() to click by visual appearance.";
110
- const lastError = attempts[attempts.length - 1]?.error || '';
111
- if (lastError.includes('was not found') || lastError.includes('not found by text')) {
112
- suggestion = 'Element was not found in the DOM. Use xpathCheck() to locate it, context() to refresh snapshot, or visualClick() to click by visual appearance.';
113
- }
114
- else if (lastError.includes('Timeout') || lastError.includes('intercept')) {
115
- suggestion = 'Element exists but could not be clicked (possibly covered by overlay or not interactable). Try closing overlapping panels first, or use visualClick().';
116
- }
113
+ const suggestion = clickFailureSuggestion(attempts);
117
114
  return failedToolResult('click', 'All click commands failed', {
118
115
  ...toolResult,
119
116
  attempts,
@@ -121,6 +118,38 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
121
118
  }, action.lastError);
122
119
  },
123
120
  }),
121
+ clickRef: tool({
122
+ description: dedent `
123
+ Click an element by the ref the page context gave it, e.g. [ref=e14].
124
+
125
+ Prefer this over click() whenever the element you want carries a ref. A ref names one exact element, so it
126
+ cannot match several by mistake and never needs disambiguating — it is the fastest way to click.
127
+ Only pass a ref that appears in the page context you were given. Never invent or guess one.
128
+ If it reports the ref is gone, the page has been rebuilt: get fresh context and use the new ref.
129
+ `,
130
+ inputSchema: z.object({
131
+ ref: z.string().describe('The ref exactly as it appears in the page context, e.g. "e14"'),
132
+ element: z.string().describe('Role and name of the element you are clicking, for the record'),
133
+ }),
134
+ execute: async ({ ref, element }) => {
135
+ const activeNote = task.startNote(`Click ${element}`);
136
+ const previousState = ActionResult.fromState(stateManager.getCurrentState());
137
+ const action = explorer.action();
138
+ const named = await describeRef(explorer, ref);
139
+ const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(`aria-ref=${ref}`)}).click())`;
140
+ if (!(await action.attempt(run, `Click ${element}`))) {
141
+ activeNote.commit(TestResult.FAILED);
142
+ return failedToolResult('clickRef', `Ref ${ref} could not be clicked: ${errorText(action.lastError)}`, {
143
+ 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.',
144
+ });
145
+ }
146
+ // a ref belongs to this session only, so the run is reported as the locator a later test can replay
147
+ const code = named ? `I.click(${JSON.stringify(named)})` : run;
148
+ const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, code);
149
+ await commitNote(activeNote, TestResult.PASSED, toolResult, action);
150
+ return successToolResult('clickRef', { ...toolResult, code }, action);
151
+ },
152
+ }),
124
153
  hover: tool({
125
154
  description: dedent `
126
155
  Move the mouse cursor to an element to reveal hover-only controls.
@@ -245,15 +274,11 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
245
274
  }
246
275
  const focusFreeKeys = new Set(['Escape', 'Esc', 'Tab', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12']);
247
276
  const needsFocus = !focusFreeKeys.has(keyToUse) && !modifier;
248
- if (needsFocus) {
249
- const currentAriaState = stateManager.getCurrentState()?.ariaSnapshot;
250
- const focused = extractFocusedElement(currentAriaState ?? null);
251
- if (!focused) {
252
- activeNote.commit(TestResult.FAILED);
253
- return failedToolResult('pressKey', `No element is focused. Key '${keyToUse}' requires a focused element.`, {
254
- suggestion: 'Click the target element first, then press the key.',
255
- });
256
- }
277
+ if (needsFocus && !(await hasFocusedElement(explorer))) {
278
+ activeNote.commit(TestResult.FAILED);
279
+ return failedToolResult('pressKey', `No element is focused. Key '${keyToUse}' requires a focused element.`, {
280
+ suggestion: 'Click the target element first, then press the key.',
281
+ });
257
282
  }
258
283
  const previousState = ActionResult.fromState(stateManager.getCurrentState());
259
284
  const action = explorer.action();
@@ -296,6 +321,8 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
296
321
  Execute raw CodeceptJS code block with multiple commands.
297
322
  USE THIS TOOL for typing text into fields: I.fillField, I.type
298
323
 
324
+ Do not put a click on a ref-bearing element in here — clickRef with its ref is cheaper and cannot mis-target.
325
+
299
326
  Follow <actions> from system prompt for available commands.
300
327
  Follow <locator_priority> from system prompt for locator selection.
301
328
 
@@ -456,25 +483,25 @@ export function createLearnExperienceTool({ getExperienceTracker, getState }) {
456
483
  });
457
484
  }
458
485
  export function createAgentTools({ explorer, stateManager, ai, researcher, navigator, supervisor, withExperience }) {
459
- let visionDisabled = false;
460
486
  const tools = {
461
487
  see: tool({
462
488
  description: dedent `
463
- Check the page contents based on current page state and screenshot.
464
- This tool will trigger visual research to check the page contents on request.
465
- Use it to verify the actions were performed correctly and the page is in the expected state.
489
+ Answer a question about the page from a screenshot, for things its structure cannot express:
490
+ layout and position, what an image or canvas depicts, colour, and whether something is covered or cut off.
491
+ This runs a second model and is the slowest tool here, so reach for it only when the question is genuinely visual.
492
+ Do NOT use it to confirm an action landed — every action already reports what changed on the page.
466
493
  Input schema has exactly one field: request. Do not pass text, reason, assertion, or other fields.
467
494
 
468
495
  <example>
469
- request: "Check current state of the Login form"
470
- result: "Login form is visible with username and password fields, username is filled with 'testuser' and password is empty'
496
+ request: "Is the save button covered by anything, and does the chart show any plotted data?"
497
+ result: "The save button is partly behind a cookie banner at the bottom. The chart area is empty apart from its axes."
471
498
  </example>
472
499
  `,
473
500
  inputSchema: z.object({
474
501
  request: z.string().describe('LLM-friendly description of the page contents to look for. 1-3 sentences. No more than 100 words.'),
475
502
  }),
476
503
  execute: async ({ request }) => {
477
- if (visionDisabled) {
504
+ if (Stats.visionDisabled) {
478
505
  return failedToolResult('see', 'Vision tools are disabled for this session. Use context() to get fresh ARIA snapshot and analyze page state from ARIA data.');
479
506
  }
480
507
  try {
@@ -495,8 +522,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
495
522
  catch (error) {
496
523
  throwIfFatalBrowserError(error);
497
524
  const errorMessage = errorText(error);
498
- visionDisabled = true;
499
- tag('warning').log('⚠️ Vision model is not available. Visual checks are disabled for this session.');
525
+ disableVision();
500
526
  return failedToolResult('see', `See tool failed: ${errorMessage}`, {
501
527
  suggestion: 'Vision is now disabled. Use context() to get fresh ARIA snapshot and analyze page state from ARIA data.',
502
528
  });
@@ -576,6 +602,12 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
576
602
  code: result.successfulCodes.join('\n'),
577
603
  }, { assertionSteps: result.assertionSteps });
578
604
  }
605
+ if (result.inexpressible) {
606
+ return failedToolResult('verify', `No assertion could express this claim: ${assertion}`, {
607
+ inexpressible: true,
608
+ 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().',
609
+ });
610
+ }
579
611
  return failedToolResult('verify', `Verification failed: ${assertion}`, {
580
612
  suggestion: 'The assertion could not be verified. Check if the condition is actually present on the page or try a different assertion.',
581
613
  });
@@ -666,7 +698,10 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
666
698
  message: `Successfully executed: ${instruction}`,
667
699
  });
668
700
  }
669
- return failedToolResult('interact', `Failed to execute: ${instruction}`, {
701
+ let reason = '';
702
+ if (navigator.lastFailureReason)
703
+ reason = `: ${navigator.lastFailureReason}`;
704
+ return failedToolResult('interact', `Failed to execute: ${instruction}${reason}`, {
670
705
  ...toolResult,
671
706
  suggestion: 'The action could not be completed. Try a different instruction or use more specific element descriptions.',
672
707
  });
@@ -696,7 +731,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
696
731
  context: z.string().describe('What you already tried and why it failed - helps with accurate identification'),
697
732
  }),
698
733
  execute: async ({ element, context }) => {
699
- if (visionDisabled) {
734
+ if (Stats.visionDisabled) {
700
735
  return failedToolResult('visualClick', 'Vision tools are disabled for this session. Use xpathCheck() to find the element, then click() with the discovered locator.');
701
736
  }
702
737
  try {
@@ -741,8 +776,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
741
776
  catch (error) {
742
777
  throwIfFatalBrowserError(error);
743
778
  const errorMessage = errorText(error);
744
- visionDisabled = true;
745
- tag('warning').log('⚠️ Vision model is not available. Visual clicks are disabled for this session.');
779
+ disableVision();
746
780
  return failedToolResult('visualClick', `visualClick tool failed: ${errorMessage}`, {
747
781
  suggestion: 'Vision is now disabled. Use xpathCheck() to find the element, then click() with the discovered locator.',
748
782
  });
@@ -875,6 +909,11 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
875
909
  },
876
910
  }),
877
911
  };
912
+ const disableVision = () => {
913
+ Stats.visionDisabled = true;
914
+ withdrawVisionTools(tools);
915
+ tag('warning').log('⚠️ Vision model is not available. Visual tools are disabled for this session.');
916
+ };
878
917
  if (withExperience !== false) {
879
918
  tools.learnExperience = createLearnExperienceTool({
880
919
  getExperienceTracker: () => stateManager.getExperienceTracker(),
@@ -921,6 +960,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
921
960
  },
922
961
  });
923
962
  }
963
+ withdrawVisionTools(tools);
924
964
  return tools;
925
965
  }
926
966
  const PAGE_DIFF_SUGGESTION = 'Analyze page diff. htmlParts shows what changed and WHERE — each part has a container selector. Use the container as context when clicking elements from the diff.';
@@ -964,13 +1004,27 @@ function errorText(error) {
964
1004
  return error.toString();
965
1005
  return 'Unknown error occurred';
966
1006
  }
967
- async function commitNote(activeNote, result, toolResult, action) {
1007
+ export async function commitNote(activeNote, result, toolResult, action) {
968
1008
  if (toolResult?.pageDiff?.ariaChanges || toolResult?.pageDiff?.urlChanged) {
969
1009
  activeNote.screenshot = await action.saveScreenshot();
970
1010
  }
971
1011
  activeNote.commit(result);
972
1012
  }
973
- function successToolResult(action, data, source) {
1013
+ async function describeRef(explorer, ref) {
1014
+ return Promise.resolve(explorer?.withPage?.((page) => page.locator(`aria-ref=${ref}`).evaluate((el) => {
1015
+ const tag = el.tagName.toLowerCase();
1016
+ const roles = { a: 'link', button: 'button', select: 'combobox', textarea: 'textbox' };
1017
+ const role = el.getAttribute('role') || roles[tag] || tag;
1018
+ const text = (el.getAttribute('aria-label') || el.innerText || el.value || '').trim().split('\n')[0];
1019
+ if (!text)
1020
+ return null;
1021
+ return { role, text };
1022
+ }))).catch(() => null);
1023
+ }
1024
+ async function hasFocusedElement(explorer) {
1025
+ return explorer.withPage((page) => page.evaluate(() => !!document.activeElement && document.activeElement !== document.body)).catch(() => true);
1026
+ }
1027
+ export function successToolResult(action, data, source) {
974
1028
  const result = { success: true, action, ...data };
975
1029
  if (source?.playwrightGroupId) {
976
1030
  result.playwrightGroupId = source.playwrightGroupId;
@@ -1008,7 +1062,7 @@ function hasObservablePageChange(data) {
1008
1062
  return true;
1009
1063
  return Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
1010
1064
  }
1011
- async function failedToolResult(action, message, data, error) {
1065
+ export async function failedToolResult(action, message, data, error) {
1012
1066
  const result = { success: false, action, message, ...data };
1013
1067
  if (data?.pageDiff) {
1014
1068
  result.suggestion = data.suggestion ? `${data.suggestion} ${PAGE_DIFF_SUGGESTION}` : PAGE_DIFF_SUGGESTION;
@@ -1040,6 +1094,32 @@ function getMultipleElementsSuggestion() {
1040
1094
  6. Use visualClick() to click the right element by visual appearance
1041
1095
  `;
1042
1096
  }
1097
+ export function withdrawVisionTools(tools) {
1098
+ if (!Stats.visionDisabled)
1099
+ return;
1100
+ Reflect.deleteProperty(tools, 'see');
1101
+ Reflect.deleteProperty(tools, 'visualClick');
1102
+ }
1103
+ export function clickFailureSuggestion(attempts) {
1104
+ const errors = attempts.map((a) => a.error || '');
1105
+ if (errors.some((e) => e.includes('not enabled'))) {
1106
+ 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.';
1107
+ }
1108
+ if (errors.some((e) => e.includes('intercepts pointer events'))) {
1109
+ return 'Element exists but another element covers it. Close the overlapping panel or dialog, then retry.';
1110
+ }
1111
+ if (errors.some((e) => e.includes('is not visible'))) {
1112
+ return 'Element is in the DOM but not visible. Reveal it first — scroll to it, expand its section, or open the panel holding it.';
1113
+ }
1114
+ const notFound = errors.filter((e) => e.includes('was not found'));
1115
+ if (notFound.length && notFound.every((e) => e.includes('was not found inside element'))) {
1116
+ 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().';
1117
+ }
1118
+ if (notFound.length) {
1119
+ return 'Element was not found in the DOM. Use xpathCheck() to locate it, context() to refresh snapshot, or visualClick() to click by visual appearance.';
1120
+ }
1121
+ return "Try xpathCheck() to find the element's actual position, see() for visual analysis, or visualClick() to click by visual appearance.";
1122
+ }
1043
1123
  const MAX_DISAMBIGUATE_ELEMENTS = 10;
1044
1124
  const MULTIPLE_ELEMENTS_PATTERN = 'multiple elements';
1045
1125
  async function extractWebElements(error) {
@@ -0,0 +1,51 @@
1
+ import { type AIConfig, type ReporterConfig } from '../config.js';
2
+ import { BaseCommand } from './base-command.js';
3
+ export declare class ConfigCommand extends BaseCommand {
4
+ name: string;
5
+ description: string;
6
+ execute(): Promise<void>;
7
+ static summary(options?: {
8
+ config?: string;
9
+ path?: string;
10
+ url?: string;
11
+ json?: boolean;
12
+ }): Promise<string>;
13
+ static data(config: SummarizedConfig, options?: ConfigSummaryOptions): ConfigData;
14
+ static render(config: SummarizedConfig, options?: ConfigSummaryOptions): string;
15
+ }
16
+ interface ConfigSummaryOptions {
17
+ configPath?: string | null;
18
+ root?: string;
19
+ json?: boolean;
20
+ }
21
+ export interface ConfigData {
22
+ config: string;
23
+ url: string;
24
+ browser: string;
25
+ headless: boolean;
26
+ dirs: Record<string, string>;
27
+ models: Record<string, string>;
28
+ providers: Record<string, string>;
29
+ integrations: {
30
+ langfuse: boolean;
31
+ testomatio: boolean;
32
+ };
33
+ env: Record<string, string>;
34
+ }
35
+ interface SummarizedConfig {
36
+ ai?: AIConfig;
37
+ playwright?: {
38
+ url?: string;
39
+ browser?: string;
40
+ show?: boolean;
41
+ };
42
+ web?: {
43
+ url?: string;
44
+ };
45
+ api?: {
46
+ baseEndpoint?: string;
47
+ };
48
+ dirs?: Record<string, string>;
49
+ reporter?: ReporterConfig;
50
+ }
51
+ export {};