explorbot 0.2.3 → 0.2.4

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 (156) hide show
  1. package/README.md +1 -1
  2. package/bin/explorbot-cli.ts +7 -1
  3. package/boat/api-tester/src/config.ts +4 -2
  4. package/boat/doc-collector/bin/doc-collector-cli.ts +2 -0
  5. package/boat/doc-collector/src/ai/documentarian.ts +61 -31
  6. package/boat/doc-collector/src/config.ts +4 -2
  7. package/boat/prima/bin/prima-cli.ts +0 -0
  8. package/boat/prima/src/activity-line.ts +33 -0
  9. package/boat/prima/src/cli.ts +115 -86
  10. package/boat/prima/src/envelope.ts +80 -52
  11. package/boat/prima/src/prima.ts +547 -128
  12. package/boat/prima/src/pw-parser.ts +11 -1
  13. package/boat/prima/src/pw-registry.ts +4 -5
  14. package/boat/prima/src/session-log.ts +126 -0
  15. package/dist/bin/explorbot-cli.js +7 -1
  16. package/dist/boat/api-tester/bin/apibot-cli.js +2 -0
  17. package/dist/boat/api-tester/src/config.js +4 -2
  18. package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -0
  19. package/dist/boat/doc-collector/src/ai/documentarian.js +44 -19
  20. package/dist/boat/doc-collector/src/config.js +4 -2
  21. package/dist/boat/prima/src/activity-line.js +30 -0
  22. package/dist/boat/prima/src/cli.js +97 -77
  23. package/dist/boat/prima/src/envelope.js +70 -44
  24. package/dist/boat/prima/src/prima.js +515 -119
  25. package/dist/boat/prima/src/pw-parser.js +13 -1
  26. package/dist/boat/prima/src/pw-registry.js +4 -5
  27. package/dist/boat/prima/src/session-log.js +108 -0
  28. package/dist/package.json +3 -2
  29. package/dist/rules/navigator/verification-actions.md +20 -0
  30. package/dist/src/action-result.d.ts +7 -0
  31. package/dist/src/action-result.js +4 -0
  32. package/dist/src/action.d.ts +2 -0
  33. package/dist/src/action.js +36 -1
  34. package/dist/src/ai/captain/web-mode.js +6 -3
  35. package/dist/src/ai/captain.js +2 -0
  36. package/dist/src/ai/navigator.d.ts +7 -0
  37. package/dist/src/ai/navigator.js +10 -6
  38. package/dist/src/ai/pilot.d.ts +4 -0
  39. package/dist/src/ai/pilot.js +48 -2
  40. package/dist/src/ai/provider.js +2 -2
  41. package/dist/src/ai/rerunner.js +2 -1
  42. package/dist/src/ai/researcher.js +1 -1
  43. package/dist/src/ai/rules.js +17 -10
  44. package/dist/src/ai/task-agent.js +4 -1
  45. package/dist/src/ai/tester.d.ts +1 -1
  46. package/dist/src/ai/tester.js +33 -33
  47. package/dist/src/ai/tools.d.ts +14 -0
  48. package/dist/src/ai/tools.js +113 -36
  49. package/dist/src/config.d.ts +1 -0
  50. package/dist/src/config.js +13 -4
  51. package/dist/src/execution-controller.d.ts +2 -0
  52. package/dist/src/execution-controller.js +6 -0
  53. package/dist/src/explorbot.d.ts +2 -1
  54. package/dist/src/explorbot.js +3 -1
  55. package/dist/src/explorer.js +2 -3
  56. package/dist/src/playwright-recorder.js +30 -0
  57. package/dist/src/remote.d.ts +54 -0
  58. package/dist/src/remote.js +229 -0
  59. package/dist/src/reporter.d.ts +1 -0
  60. package/dist/src/reporter.js +7 -1
  61. package/dist/src/state-manager.d.ts +2 -1
  62. package/dist/src/stats.d.ts +1 -0
  63. package/dist/src/stats.js +1 -0
  64. package/dist/src/test-plan.d.ts +2 -0
  65. package/dist/src/test-plan.js +7 -0
  66. package/dist/src/utils/aria.d.ts +2 -8
  67. package/dist/src/utils/aria.js +69 -40
  68. package/dist/src/utils/html.js +1 -0
  69. package/dist/src/utils/logger.d.ts +6 -0
  70. package/dist/src/utils/logger.js +24 -0
  71. package/dist/src/utils/page-readiness.js +18 -1
  72. package/dist/src/utils/url-matcher.js +3 -0
  73. package/dist/src/utils/web-element.d.ts +2 -0
  74. package/dist/src/utils/web-element.js +8 -0
  75. package/dist/src/utils/web-sandbox.d.ts +1 -1
  76. package/dist/src/utils/web-sandbox.js +2 -3
  77. package/docs/api-testing/basics.md +90 -0
  78. package/docs/api-testing/planning.md +57 -0
  79. package/docs/api-testing/running-tests.md +55 -0
  80. package/docs/assets/cloud-report.png +0 -0
  81. package/docs/assets/html-report.png +0 -0
  82. package/docs/assets/langfuse-trace.png +0 -0
  83. package/docs/assets/successful-explore-run.png +0 -0
  84. package/docs/basics/getting-started.md +140 -0
  85. package/docs/basics/prerequisites.md +63 -0
  86. package/docs/basics/providers.md +362 -0
  87. package/docs/basics/running.md +78 -0
  88. package/docs/contributing/ai-integration-tests.md +57 -0
  89. package/docs/contributing/contributing.md +90 -0
  90. package/docs/contributing/demo-videos.md +36 -0
  91. package/docs/contributing/npm-package.md +138 -0
  92. package/docs/contributing/observability.md +227 -0
  93. package/docs/contributing/regression-tests.md +103 -0
  94. package/docs/contributing/testing.md +95 -0
  95. package/docs/doc-collection/basics.md +128 -0
  96. package/docs/doc-collection/crawling.md +67 -0
  97. package/docs/doc-collection/interactive-mode.md +99 -0
  98. package/docs/index.json +86 -0
  99. package/docs/reference/commands.md +994 -0
  100. package/docs/reference/configuration.md +569 -0
  101. package/docs/reference/scripting.md +303 -0
  102. package/docs/superpowers/plans/2026-08-01-actor-boat.md +925 -0
  103. package/docs/superpowers/plans/2026-08-01-prima-boat.md +1120 -0
  104. package/docs/superpowers/reviews/2026-08-06-prima-vs-playwright-cli.md +268 -0
  105. package/docs/superpowers/specs/2026-08-01-actor-boat-design.md +204 -0
  106. package/docs/superpowers/specs/2026-08-01-prima-boat-design.md +242 -0
  107. package/docs/superpowers/specs/2026-08-03-global-config-design.md +138 -0
  108. package/docs/superpowers/specs/2026-08-07-prima-fixes-design.md +394 -0
  109. package/docs/web-testing/agents.md +158 -0
  110. package/docs/web-testing/automated-tests.md +134 -0
  111. package/docs/web-testing/basics.md +91 -0
  112. package/docs/web-testing/customization.md +131 -0
  113. package/docs/web-testing/hooks.md +238 -0
  114. package/docs/web-testing/page-interaction.md +84 -0
  115. package/docs/web-testing/planner.md +122 -0
  116. package/docs/web-testing/rerun.md +164 -0
  117. package/docs/web-testing/researcher.md +380 -0
  118. package/docs/workflow/agentic-usage.md +233 -0
  119. package/docs/workflow/application-spec.md +73 -0
  120. package/docs/workflow/ci.md +202 -0
  121. package/docs/workflow/knowledge.md +310 -0
  122. package/docs/workflow/planning-styles.md +67 -0
  123. package/docs/workflow/reporting.md +133 -0
  124. package/docs/workflow/test-plans.md +90 -0
  125. package/package.json +3 -2
  126. package/rules/navigator/verification-actions.md +20 -0
  127. package/src/action-result.ts +11 -0
  128. package/src/action.ts +38 -2
  129. package/src/ai/captain/web-mode.ts +6 -3
  130. package/src/ai/captain.ts +3 -0
  131. package/src/ai/navigator.ts +14 -8
  132. package/src/ai/pilot.ts +53 -2
  133. package/src/ai/provider.ts +2 -4
  134. package/src/ai/rerunner.ts +2 -1
  135. package/src/ai/researcher.ts +1 -1
  136. package/src/ai/rules.ts +17 -10
  137. package/src/ai/task-agent.ts +3 -1
  138. package/src/ai/tester.ts +32 -33
  139. package/src/ai/tools.ts +132 -36
  140. package/src/config.ts +15 -4
  141. package/src/execution-controller.ts +8 -0
  142. package/src/explorbot.ts +4 -2
  143. package/src/explorer.ts +2 -2
  144. package/src/playwright-recorder.ts +23 -0
  145. package/src/remote.ts +238 -0
  146. package/src/reporter.ts +7 -1
  147. package/src/state-manager.ts +2 -1
  148. package/src/stats.ts +1 -0
  149. package/src/test-plan.ts +9 -0
  150. package/src/utils/aria.ts +65 -45
  151. package/src/utils/html.ts +1 -0
  152. package/src/utils/logger.ts +24 -1
  153. package/src/utils/page-readiness.ts +24 -1
  154. package/src/utils/url-matcher.ts +3 -0
  155. package/src/utils/web-element.ts +9 -0
  156. package/src/utils/web-sandbox.ts +3 -4
@@ -2,13 +2,18 @@ import dedent from 'dedent';
2
2
  export const recommendedCodeceptCommands = ['I.click', 'I.type', 'I.fillField', 'I.see', 'I.seeElement'];
3
3
  const locatorPriorityRule = dedent `
4
4
  <locator_priority>
5
+ When the page context shows the element a ref, such as [ref=e14], there is no locator to select: click it with clickRef
6
+ and that ref. A ref names one exact element, so it never matches the wrong one and never has to be narrowed. Everything
7
+ below is for elements the context gives no ref for.
8
+
5
9
  Use the following priority when selecting locators:
6
10
 
7
11
  1. ARIA locators (first choice) - target browser's accessibility tree, most reliable
8
12
  Use JSON format: { "role": "button", "text": "Login" }
9
- Best for: buttons, links, inputs, form controls, dropdowns, checkboxes, radio buttons
13
+ Copy role and text VERBATIM from the ARIA snapshot or UI map — never guess the pair.
14
+ If the element is absent from the snapshot, do not invent one; use text or CSS instead.
10
15
 
11
- 2. Text locators (second choice) - use only when text is unique on the page
16
+ 2. Text locators (second choice) - exact visible text, use only when unique on the page
12
17
  Example: 'Login', 'Submit', 'Username'
13
18
  Skip if the same text appears multiple times on the page
14
19
 
@@ -23,10 +28,10 @@ const locatorPriorityRule = dedent `
23
28
  `;
24
29
  const contextSimplificationRule = dedent `
25
30
  <context_simplification>
26
- When container is available from UI map sections:
27
- - Text + container is simplest and PREFERRED: I.click('Save', '.modal')
28
- - ARIA + container for disambiguation: I.click({"role":"button","text":"Save"}, '.modal')
29
- - ALWAYS use context parameter unless locator is XPath or unique ID
31
+ - Add a container when the target may match several elements, or the UI map gives a verified
32
+ section container: I.click('Save', '.modal')
33
+ - Skip the container when the locator is already unique (XPath, unique ID, unique text)
34
+ - A wrong or stale container guarantees failure: always add one fallback command WITHOUT a container
30
35
  - No need for complex ARIA when container narrows scope sufficiently
31
36
  </context_simplification>
32
37
  `;
@@ -55,7 +60,7 @@ const locatorStrategyRule = dedent `
55
60
 
56
61
  <good_aria_locator_example>
57
62
  { "role": "button", "text": "Login" },
58
- { "role": "input", "text": "Name" },
63
+ { "role": "textbox", "text": "Name" },
59
64
  { "role": "link", "text": "Forgot your password?" },
60
65
  { "role": "link", "text": "Sign Up" },
61
66
  { "role": "button", "text": "Sign In" },
@@ -101,7 +106,7 @@ const locatorStrategyRule = dedent `
101
106
  </xpath_rules>
102
107
 
103
108
  <good locator example>
104
- 'div[role=input][placeholder="Name"]'
109
+ 'input[placeholder="Name"]'
105
110
  '[aria-label="Name"]'
106
111
  'form#user_form input[name="name"]'
107
112
  '#content-top #user_name'
@@ -231,7 +236,8 @@ export const unexpectedPopupRule = dedent `
231
236
  `;
232
237
  export const sectionContextRule = dedent `
233
238
  <section_context_rule>
234
- Context parameter is DEFAULT for all interactions. ALWAYS use container from UI map sections unless locator is XPath or unique ID.
239
+ Use a container from UI map sections when the target may match several elements. A container that is
240
+ wrong or stale guarantees failure, so prefer a verified one and keep a containerless fallback.
235
241
 
236
242
  1. Identify which section contains the target element
237
243
  2. Get the Context Locator from that section in the UI map
@@ -249,9 +255,10 @@ export const sectionContextRule = dedent `
249
255
  For CSS locators - prepend section context:
250
256
  - I.click('.main button.submit') // instead of I.click('button.submit')
251
257
 
252
- Only omit context when:
258
+ Omit context when the locator already resolves to one element:
253
259
  - Locator is XPath (already includes path context)
254
260
  - Locator is a unique ID (#specific-element)
261
+ - Text or ARIA that appears only once on the page
255
262
  </section_context_rule>
256
263
 
257
264
  ${unexpectedPopupRule}
@@ -1,6 +1,9 @@
1
+ import { executionController } from "../execution-controller.js";
1
2
  import { HooksRunner } from "../utils/hooks-runner.js";
2
3
  export function isInteractive() {
3
- return process.env.INK_RUNNING === 'true';
4
+ if (process.env.INK_RUNNING === 'true')
5
+ return true;
6
+ return executionController.hasInputCallback();
4
7
  }
5
8
  function createNullProxy() {
6
9
  return new Proxy({}, {
@@ -31,7 +31,6 @@ export declare class Tester extends TaskAgent implements Agent {
31
31
  seenUiMapUrls: Set<string>;
32
32
  lastAnalyzedStateHash: string | null;
33
33
  stalledIterations: number;
34
- hasSuccessfulAssertion: boolean;
35
34
  readonly MAX_STALLED_ITERATIONS = 3;
36
35
  constructor(deps: AgentDeps, researcher: Researcher, navigator: Navigator, agentTools?: any);
37
36
  getNavigator(): Navigator;
@@ -50,6 +49,7 @@ export declare class Tester extends TaskAgent implements Agent {
50
49
  shouldStopForStalledExecution(task: Test, previousState: ActionResult, toolExecutions: any[]): boolean;
51
50
  prepareInstructionsForNextStep(task: Test): Promise<string>;
52
51
  reinjectContextIfNeeded(iteration: number, currentState: ActionResult): Promise<string>;
52
+ interactiveAriaWithRefs(state: ActionResult): Promise<string>;
53
53
  finishTest(task: Test): void;
54
54
  abortStartedTestOnErrorPage(task: Test, actionResult: ActionResult): Promise<{
55
55
  success: boolean;
@@ -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);
@@ -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)) {
@@ -208,6 +206,7 @@ export class Tester extends TaskAgent {
208
206
  if (currentState.isInsideIframe) {
209
207
  Object.assign(tools, createIframeTools(this.toolDeps));
210
208
  }
209
+ withdrawVisionTools(tools);
211
210
  debugLog(`Test ${task.scenario} iteration ${iteration}`);
212
211
  if (this.stateManager.isInDeadLoop()) {
213
212
  task.addNote('Dead loop detected. Stopped');
@@ -268,15 +267,8 @@ export class Tester extends TaskAgent {
268
267
  const allToolNames = result?.toolExecutions?.map((execution) => execution.toolName) || [];
269
268
  const successfulToolNames = result?.toolExecutions?.filter((execution) => execution.wasSuccessful)?.map((execution) => execution.toolName) || [];
270
269
  const actionPerformed = !!allToolNames.find((toolName) => this.ACTION_TOOLS.includes(toolName));
271
- const successfulActionPerformed = !!successfulToolNames.find((toolName) => this.ACTION_TOOLS.includes(toolName));
272
270
  assertionPerformed = !!successfulToolNames.find((toolName) => this.ASSERTION_TOOLS.includes(toolName));
273
271
  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
272
  this.trackToolExecutions(result?.toolExecutions || []);
281
273
  if (this.consecutiveEmptyResults >= 5) {
282
274
  task.addNote('AI model is not responding with actions. Stopped');
@@ -360,6 +352,7 @@ export class Tester extends TaskAgent {
360
352
  if (extensions >= this.MAX_EXTENSIONS)
361
353
  break;
362
354
  extensions++;
355
+ this.stalledIterations = 0;
363
356
  tag('info').log(`Pilot extending test (${extensions}/${this.MAX_EXTENSIONS})`);
364
357
  conversation.cleanupTag('page_aria', '...trimmed...', 1);
365
358
  conversation.cleanupTag('page_html', '...trimmed...', 0);
@@ -414,12 +407,7 @@ export class Tester extends TaskAgent {
414
407
  this.stalledIterations++;
415
408
  if (this.stalledIterations < this.MAX_STALLED_ITERATIONS)
416
409
  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);
410
+ task.addNote('No further browser progress on unchanged page; requesting final review');
423
411
  return true;
424
412
  }
425
413
  async prepareInstructionsForNextStep(task) {
@@ -437,7 +425,8 @@ export class Tester extends TaskAgent {
437
425
  </rules>
438
426
  `;
439
427
  if (task.getPrintableNotes()) {
440
- outcomeStatus = dedent `
428
+ outcomeStatus += dedent `
429
+
441
430
  Your current log:
442
431
  <notes>
443
432
  ${task.notesToString()}
@@ -458,7 +447,7 @@ export class Tester extends TaskAgent {
458
447
  this.previousStateHash = currentStateHash;
459
448
  let context = '';
460
449
  const focusArea = detectFocusArea(currentState.ariaSnapshot);
461
- const focusedElement = extractFocusedElement(currentState.ariaSnapshot);
450
+ const focusedElement = currentState.focusedElement;
462
451
  if (focusedElement) {
463
452
  const isTextInput = ['textbox', 'combobox', 'searchbox'].includes(focusedElement.role);
464
453
  context += dedent `
@@ -539,7 +528,7 @@ export class Tester extends TaskAgent {
539
528
  </page>
540
529
 
541
530
  <page_aria>
542
- ${currentState.getInteractiveARIA()}
531
+ ${await this.interactiveAriaWithRefs(currentState)}
543
532
  </page_aria>
544
533
  ${uiMapSection}
545
534
 
@@ -574,26 +563,32 @@ export class Tester extends TaskAgent {
574
563
  </page>
575
564
 
576
565
  <page_aria>
577
- ${currentState.getInteractiveARIA()}
566
+ ${await this.interactiveAriaWithRefs(currentState)}
578
567
  </page_aria>
579
568
  `;
580
569
  }
570
+ async interactiveAriaWithRefs(state) {
571
+ const withRefs = await Promise.resolve(this.explorer?.withPage?.((page) => page.locator('body').ariaSnapshot({ mode: 'ai' }))).catch(() => null);
572
+ if (!withRefs)
573
+ return state.getInteractiveARIA();
574
+ return compactAriaSnapshot(withRefs, false);
575
+ }
581
576
  finishTest(task) {
582
- if (!task.hasFinished) {
583
- task.finish(TestResult.FAILED);
577
+ if (!task.result) {
578
+ if (task.hasAchievedAll())
579
+ task.finish(TestResult.PASSED);
580
+ else
581
+ task.finish(TestResult.FAILED);
584
582
  }
585
583
  if (task.isSuccessful) {
586
584
  tag('success').log(`Successful test: ${task.scenario}`);
585
+ return;
587
586
  }
588
- else if (task.isSkipped) {
587
+ if (task.isSkipped) {
589
588
  tag('warning').log(`Skipped test: ${task.scenario}`);
589
+ return;
590
590
  }
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
- }
591
+ tag('error').log(`Failed test: ${task.scenario}`);
597
592
  }
598
593
  async abortStartedTestOnErrorPage(task, actionResult) {
599
594
  const error = new ErrorPageError(actionResult.url || task.startUrl || '', actionResult.title, actionResult.httpStatus);
@@ -734,6 +729,9 @@ export class Tester extends TaskAgent {
734
729
  ${task.expected.map((e) => `- ${e}`).join('\n')}
735
730
  </expected_results>
736
731
 
732
+ An expected result counts as settled only when you record it back word for word as it is written above.
733
+ A note in your own wording is a general note and leaves that result unsettled.
734
+
737
735
  Your goal is to perform actions on the web page and verify the expected outcomes.
738
736
  Try to achieve as many goals as possible.
739
737
  If goal is not achievable, log that and skip to next one.
@@ -968,6 +966,9 @@ export class Tester extends TaskAgent {
968
966
  - You unsuccessfully tried multiple iterations and failed
969
967
  - If the expected result was expected to fail, use status="success" instead
970
968
 
969
+ When a note settles one of the expected results, that note must repeat the expected result word
970
+ for word. Paraphrasing it leaves the expected result unsettled and it is reported as unverified.
971
+
971
972
  Example:
972
973
  - record({ notes: ["clicked login button", "login form appeared", "fill credentials"], status: "success" })
973
974
  `,
@@ -1045,8 +1046,7 @@ export class Tester extends TaskAgent {
1045
1046
  this.stalledIterations++;
1046
1047
  if (this.stalledIterations < this.MAX_STALLED_ITERATIONS)
1047
1048
  return false;
1048
- task.addNote('No browser progress after repeated execution errors', TestResult.FAILED);
1049
- task.finish(TestResult.FAILED);
1049
+ task.addNote('No browser progress after repeated execution errors; requesting final review');
1050
1050
  return true;
1051
1051
  }
1052
1052
  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
  });
@@ -696,7 +728,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
696
728
  context: z.string().describe('What you already tried and why it failed - helps with accurate identification'),
697
729
  }),
698
730
  execute: async ({ element, context }) => {
699
- if (visionDisabled) {
731
+ if (Stats.visionDisabled) {
700
732
  return failedToolResult('visualClick', 'Vision tools are disabled for this session. Use xpathCheck() to find the element, then click() with the discovered locator.');
701
733
  }
702
734
  try {
@@ -741,8 +773,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
741
773
  catch (error) {
742
774
  throwIfFatalBrowserError(error);
743
775
  const errorMessage = errorText(error);
744
- visionDisabled = true;
745
- tag('warning').log('⚠️ Vision model is not available. Visual clicks are disabled for this session.');
776
+ disableVision();
746
777
  return failedToolResult('visualClick', `visualClick tool failed: ${errorMessage}`, {
747
778
  suggestion: 'Vision is now disabled. Use xpathCheck() to find the element, then click() with the discovered locator.',
748
779
  });
@@ -875,6 +906,11 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
875
906
  },
876
907
  }),
877
908
  };
909
+ const disableVision = () => {
910
+ Stats.visionDisabled = true;
911
+ withdrawVisionTools(tools);
912
+ tag('warning').log('⚠️ Vision model is not available. Visual tools are disabled for this session.');
913
+ };
878
914
  if (withExperience !== false) {
879
915
  tools.learnExperience = createLearnExperienceTool({
880
916
  getExperienceTracker: () => stateManager.getExperienceTracker(),
@@ -921,6 +957,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
921
957
  },
922
958
  });
923
959
  }
960
+ withdrawVisionTools(tools);
924
961
  return tools;
925
962
  }
926
963
  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 +1001,27 @@ function errorText(error) {
964
1001
  return error.toString();
965
1002
  return 'Unknown error occurred';
966
1003
  }
967
- async function commitNote(activeNote, result, toolResult, action) {
1004
+ export async function commitNote(activeNote, result, toolResult, action) {
968
1005
  if (toolResult?.pageDiff?.ariaChanges || toolResult?.pageDiff?.urlChanged) {
969
1006
  activeNote.screenshot = await action.saveScreenshot();
970
1007
  }
971
1008
  activeNote.commit(result);
972
1009
  }
973
- function successToolResult(action, data, source) {
1010
+ async function describeRef(explorer, ref) {
1011
+ return Promise.resolve(explorer?.withPage?.((page) => page.locator(`aria-ref=${ref}`).evaluate((el) => {
1012
+ const tag = el.tagName.toLowerCase();
1013
+ const roles = { a: 'link', button: 'button', select: 'combobox', textarea: 'textbox' };
1014
+ const role = el.getAttribute('role') || roles[tag] || tag;
1015
+ const text = (el.getAttribute('aria-label') || el.innerText || el.value || '').trim().split('\n')[0];
1016
+ if (!text)
1017
+ return null;
1018
+ return { role, text };
1019
+ }))).catch(() => null);
1020
+ }
1021
+ async function hasFocusedElement(explorer) {
1022
+ return explorer.withPage((page) => page.evaluate(() => !!document.activeElement && document.activeElement !== document.body)).catch(() => true);
1023
+ }
1024
+ export function successToolResult(action, data, source) {
974
1025
  const result = { success: true, action, ...data };
975
1026
  if (source?.playwrightGroupId) {
976
1027
  result.playwrightGroupId = source.playwrightGroupId;
@@ -1008,7 +1059,7 @@ function hasObservablePageChange(data) {
1008
1059
  return true;
1009
1060
  return Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
1010
1061
  }
1011
- async function failedToolResult(action, message, data, error) {
1062
+ export async function failedToolResult(action, message, data, error) {
1012
1063
  const result = { success: false, action, message, ...data };
1013
1064
  if (data?.pageDiff) {
1014
1065
  result.suggestion = data.suggestion ? `${data.suggestion} ${PAGE_DIFF_SUGGESTION}` : PAGE_DIFF_SUGGESTION;
@@ -1040,6 +1091,32 @@ function getMultipleElementsSuggestion() {
1040
1091
  6. Use visualClick() to click the right element by visual appearance
1041
1092
  `;
1042
1093
  }
1094
+ export function withdrawVisionTools(tools) {
1095
+ if (!Stats.visionDisabled)
1096
+ return;
1097
+ Reflect.deleteProperty(tools, 'see');
1098
+ Reflect.deleteProperty(tools, 'visualClick');
1099
+ }
1100
+ export function clickFailureSuggestion(attempts) {
1101
+ const errors = attempts.map((a) => a.error || '');
1102
+ if (errors.some((e) => e.includes('not enabled'))) {
1103
+ 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.';
1104
+ }
1105
+ if (errors.some((e) => e.includes('intercepts pointer events'))) {
1106
+ return 'Element exists but another element covers it. Close the overlapping panel or dialog, then retry.';
1107
+ }
1108
+ if (errors.some((e) => e.includes('is not visible'))) {
1109
+ return 'Element is in the DOM but not visible. Reveal it first — scroll to it, expand its section, or open the panel holding it.';
1110
+ }
1111
+ const notFound = errors.filter((e) => e.includes('was not found'));
1112
+ if (notFound.length && notFound.every((e) => e.includes('was not found inside element'))) {
1113
+ 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().';
1114
+ }
1115
+ if (notFound.length) {
1116
+ return 'Element was not found in the DOM. Use xpathCheck() to locate it, context() to refresh snapshot, or visualClick() to click by visual appearance.';
1117
+ }
1118
+ return "Try xpathCheck() to find the element's actual position, see() for visual analysis, or visualClick() to click by visual appearance.";
1119
+ }
1043
1120
  const MAX_DISAMBIGUATE_ELEMENTS = 10;
1044
1121
  const MULTIPLE_ELEMENTS_PATTERN = 'multiple elements';
1045
1122
  async function extractWebElements(error) {
@@ -172,6 +172,7 @@ interface HtmlConfig {
172
172
  interface ActionConfig {
173
173
  delay?: number;
174
174
  retries?: number;
175
+ timeout?: number;
175
176
  }
176
177
  interface ReporterConfig {
177
178
  enabled?: boolean;