explorbot 0.2.5 → 0.3.0

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 (82) hide show
  1. package/boat/prima/README.md +96 -0
  2. package/boat/prima/package.json +14 -10
  3. package/boat/prima/src/cli.ts +5 -0
  4. package/boat/prima/src/prima.ts +17 -4
  5. package/dist/boat/prima/src/cli.js +7 -0
  6. package/dist/boat/prima/src/prima.js +18 -4
  7. package/dist/models.json +4 -4
  8. package/dist/package.json +6 -2
  9. package/dist/src/action-result.d.ts +13 -0
  10. package/dist/src/action-result.js +46 -15
  11. package/dist/src/action.d.ts +5 -2
  12. package/dist/src/action.js +48 -17
  13. package/dist/src/ai/captain/web-mode.js +1 -2
  14. package/dist/src/ai/captain.d.ts +20 -0
  15. package/dist/src/ai/captain.js +10 -1
  16. package/dist/src/ai/driller.js +6 -2
  17. package/dist/src/ai/fisherman-tools.d.ts +40 -1
  18. package/dist/src/ai/fisherman-tools.js +39 -0
  19. package/dist/src/ai/fisherman.js +2 -1
  20. package/dist/src/ai/navigator.d.ts +2 -1
  21. package/dist/src/ai/navigator.js +5 -9
  22. package/dist/src/ai/pilot.js +39 -22
  23. package/dist/src/ai/planner/subpages.js +2 -16
  24. package/dist/src/ai/planner.js +1 -1
  25. package/dist/src/ai/provider.js +16 -1
  26. package/dist/src/ai/researcher/cache.d.ts +8 -3
  27. package/dist/src/ai/researcher/cache.js +13 -8
  28. package/dist/src/ai/researcher/deep-analysis.js +1 -1
  29. package/dist/src/ai/researcher/fingerprint-worker.js +21 -4
  30. package/dist/src/ai/researcher.js +4 -3
  31. package/dist/src/ai/rules.js +1 -5
  32. package/dist/src/ai/tester.d.ts +1 -1
  33. package/dist/src/ai/tester.js +13 -22
  34. package/dist/src/ai/tools.d.ts +8 -5
  35. package/dist/src/ai/tools.js +79 -56
  36. package/dist/src/commands/init-command.js +13 -20
  37. package/dist/src/config.js +3 -1
  38. package/dist/src/experience-tracker.d.ts +2 -0
  39. package/dist/src/experience-tracker.js +12 -0
  40. package/dist/src/explorbot.js +1 -1
  41. package/dist/src/playwright-recorder.js +6 -12
  42. package/dist/src/test-plan.d.ts +8 -0
  43. package/dist/src/test-plan.js +11 -0
  44. package/dist/src/utils/html-diff.d.ts +5 -0
  45. package/dist/src/utils/html-diff.js +65 -6
  46. package/dist/src/utils/strings.d.ts +2 -0
  47. package/dist/src/utils/strings.js +32 -0
  48. package/dist/src/utils/url-matcher.d.ts +1 -0
  49. package/dist/src/utils/url-matcher.js +31 -2
  50. package/docs/basics/getting-started.md +33 -10
  51. package/docs/basics/providers.md +6 -4
  52. package/docs/contributing/npm-package.md +73 -4
  53. package/models.json +4 -4
  54. package/package.json +6 -2
  55. package/src/action-result.ts +61 -16
  56. package/src/action.ts +51 -17
  57. package/src/ai/captain/web-mode.ts +1 -2
  58. package/src/ai/captain.ts +9 -1
  59. package/src/ai/driller.ts +6 -2
  60. package/src/ai/fisherman-tools.ts +35 -0
  61. package/src/ai/fisherman.ts +2 -1
  62. package/src/ai/navigator.ts +6 -10
  63. package/src/ai/pilot.ts +41 -24
  64. package/src/ai/planner/subpages.ts +2 -13
  65. package/src/ai/planner.ts +1 -1
  66. package/src/ai/provider.ts +17 -1
  67. package/src/ai/researcher/cache.ts +17 -9
  68. package/src/ai/researcher/deep-analysis.ts +1 -1
  69. package/src/ai/researcher/fingerprint-worker.ts +23 -5
  70. package/src/ai/researcher.ts +4 -3
  71. package/src/ai/rules.ts +1 -5
  72. package/src/ai/tester.ts +13 -22
  73. package/src/ai/tools.ts +84 -60
  74. package/src/commands/init-command.ts +14 -20
  75. package/src/config.ts +2 -1
  76. package/src/experience-tracker.ts +13 -0
  77. package/src/explorbot.ts +1 -1
  78. package/src/playwright-recorder.ts +6 -11
  79. package/src/test-plan.ts +18 -0
  80. package/src/utils/html-diff.ts +72 -7
  81. package/src/utils/strings.ts +36 -0
  82. package/src/utils/url-matcher.ts +27 -2
@@ -2,11 +2,13 @@ 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 { renderExperienceRecipes } from "../experience-tracker.js";
5
6
  import { Stats } from "../stats.js";
6
7
  import { TestResult } from '../test-plan.js';
7
8
  import { LARGE_ARIA_CHANGE_THRESHOLD } from "../utils/aria.js";
8
9
  import { isFatalBrowserError } from "../utils/browser-errors.js";
9
10
  import { createDebug, tag } from '../utils/logger.js';
11
+ import { compactErrorMessage } from "../utils/strings.js";
10
12
  import { pause } from '../utils/loop.js';
11
13
  import { WebElement } from "../utils/web-element.js";
12
14
  import { sectionContextRule } from "./rules.js";
@@ -19,10 +21,6 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
19
21
  description: dedent `
20
22
  Click an element by trying multiple CodeceptJS commands in order until one succeeds.
21
23
 
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
-
26
24
  Follow <locator_priority> from system prompt for locator selection.
27
25
 
28
26
  I.click(locator) - click element matching locator
@@ -78,7 +76,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
78
76
  const success = await action.attempt(command, explanation);
79
77
  const attempt = { command, success };
80
78
  if (action.lastError)
81
- attempt.error = action.lastError.toString();
79
+ attempt.error = errorText(action.lastError);
82
80
  attempts.push(attempt);
83
81
  if (success) {
84
82
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, command);
@@ -100,7 +98,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
100
98
  retryCommands.push(`I.click('${disambiguated.xpath.replace(/'/g, "\\'")}')`);
101
99
  for (const retryCmd of retryCommands) {
102
100
  if (!(await action.attempt(retryCmd, explanation))) {
103
- attempts.push({ command: retryCmd, success: false, error: action.lastError?.toString() });
101
+ attempts.push({ command: retryCmd, success: false, error: errorText(action.lastError) });
104
102
  continue;
105
103
  }
106
104
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, retryCmd);
@@ -118,38 +116,6 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
118
116
  }, action.lastError);
119
117
  },
120
118
  }),
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
- }),
153
119
  hover: tool({
154
120
  description: dedent `
155
121
  Move the mouse cursor to an element to reveal hover-only controls.
@@ -197,7 +163,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
197
163
  const success = await action.attempt(command, explanation);
198
164
  const attempt = { command, success };
199
165
  if (action.lastError)
200
- attempt.error = action.lastError.toString();
166
+ attempt.error = errorText(action.lastError);
201
167
  attempts.push(attempt);
202
168
  if (!success)
203
169
  continue;
@@ -264,7 +230,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
264
230
  fallback: true,
265
231
  }, action);
266
232
  }
267
- const errorMsg = `pressKey fallback to type() failed: ${action.lastError?.toString()}`;
233
+ const errorMsg = `pressKey fallback to type() failed: ${errorText(action.lastError)}`;
268
234
  await commitNote(activeNote, TestResult.FAILED, toolResult, action);
269
235
  return failedToolResult('pressKey', errorMsg, {
270
236
  ...toolResult,
@@ -300,7 +266,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
300
266
  code: pressKeyCommand,
301
267
  }, action);
302
268
  }
303
- const errorMsg = `pressKey() failed: ${action.lastError?.toString()}`;
269
+ const errorMsg = `pressKey() failed: ${errorText(action.lastError)}`;
304
270
  await commitNote(activeNote, TestResult.FAILED, toolResult, action);
305
271
  return failedToolResult('pressKey', errorMsg, {
306
272
  ...toolResult,
@@ -321,8 +287,6 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
321
287
  Execute raw CodeceptJS code block with multiple commands.
322
288
  USE THIS TOOL for typing text into fields: I.fillField, I.type
323
289
 
324
- Do not put a click on a ref-bearing element in here — clickRef with its ref is cheaper and cannot mis-target.
325
-
326
290
  Follow <actions> from system prompt for available commands.
327
291
  Follow <locator_priority> from system prompt for locator selection.
328
292
 
@@ -377,7 +341,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
377
341
  await action.attempt(codeBlock, explanation);
378
342
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, formLocator);
379
343
  if (action.lastError) {
380
- const message = action.lastError ? String(action.lastError) : 'Unknown error';
344
+ const message = errorText(action.lastError);
381
345
  await commitNote(activeNote, TestResult.FAILED, toolResult, action);
382
346
  let formSuggestion = 'Look into error message and identify which commands passed and which failed. Continue execution using step-by-step approach using click() and form() tools.';
383
347
  if (message.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN)) {
@@ -419,6 +383,42 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
419
383
  }),
420
384
  };
421
385
  }
386
+ export function createRefTools({ explorer, stateManager }, task) {
387
+ return {
388
+ clickRef: tool({
389
+ description: dedent `
390
+ Click an element by the ref the page context gave it, e.g. [ref=e14].
391
+
392
+ Prefer this over click() whenever the element you want carries a ref. A ref names one exact element, so it
393
+ cannot match several by mistake and never needs disambiguating — it is the fastest way to click.
394
+ Only pass a ref that appears in the page context you were given. Never invent or guess one.
395
+ If it reports the ref is gone, the page has been rebuilt: get fresh context and use the new ref.
396
+ `,
397
+ inputSchema: z.object({
398
+ ref: z.string().describe('The ref exactly as it appears in the page context, e.g. "e14"'),
399
+ element: z.string().describe('Role and name of the element you are clicking, for the record'),
400
+ }),
401
+ execute: async ({ ref, element }) => {
402
+ const activeNote = task.startNote(`Click ${element}`);
403
+ const previousState = ActionResult.fromState(stateManager.getCurrentState());
404
+ const action = explorer.action();
405
+ const named = await describeRef(explorer, ref);
406
+ const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(`aria-ref=${ref}`)}).click())`;
407
+ if (!(await action.attempt(run, `Click ${element}`))) {
408
+ activeNote.commit(TestResult.FAILED);
409
+ return failedToolResult('clickRef', `Ref ${ref} could not be clicked: ${errorText(action.lastError)}`, {
410
+ 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.',
411
+ });
412
+ }
413
+ // a ref belongs to this session only, so the run is reported as the locator a later test can replay
414
+ const code = named ? `I.click(${JSON.stringify(named)})` : run;
415
+ const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, code);
416
+ await commitNote(activeNote, TestResult.PASSED, toolResult, action);
417
+ return successToolResult('clickRef', { ...toolResult, code }, action);
418
+ },
419
+ }),
420
+ };
421
+ }
422
422
  export function createIframeTools({ explorer, stateManager }) {
423
423
  return {
424
424
  exitIframe: tool({
@@ -535,7 +535,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
535
535
 
536
536
  DO NOT call this if:
537
537
  - You just performed an action (pageDiff already provided in response)
538
- - You already have recent <page_html>/<page_aria> in context
538
+ - You already have a recent <page_aria> snapshot in context
539
539
  - You're about to perform an action (you'll get pageDiff after)
540
540
 
541
541
  Call ONLY when:
@@ -675,12 +675,18 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
675
675
  }),
676
676
  interact: tool({
677
677
  description: dedent `
678
- Execute an action on the current page using AI-powered interaction.
679
- Use this to perform actions like clicking buttons, selecting options, filling forms, etc.
680
- The AI will generate and try multiple CodeceptJS code strategies to accomplish the instruction.
678
+ Delegate one step to the Navigator, which reads the full page HTML and tries multiple CodeceptJS strategies.
679
+ Slower than the direct action tools use it as a fallback, not as the default.
680
+
681
+ Use when:
682
+ - direct action tools failed and you have no better locator to try
683
+ - the step needs a sequence of actions to complete
684
+ - the element is not in the context you have
685
+
686
+ Describe the outcome to reach, not the locator to use.
681
687
  `,
682
688
  inputSchema: z.object({
683
- instruction: z.string().describe('What action to perform on the page, e.g. "select new suite option", "click the Submit button"'),
689
+ instruction: z.string().describe('The step to perform on the page, described by its intent'),
684
690
  }),
685
691
  execute: async ({ instruction }) => {
686
692
  try {
@@ -690,7 +696,8 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
690
696
  }
691
697
  const previousState = ActionResult.fromState(currentState);
692
698
  const actionResult = ActionResult.fromState(currentState);
693
- const success = await navigator.resolveState(instruction, actionResult);
699
+ const experience = renderExperienceRecipes(explorer.activeTest?.getAppliedExperience(actionResult) ?? []);
700
+ const success = await navigator.resolveState(instruction, actionResult, { experience });
694
701
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, instruction);
695
702
  if (success) {
696
703
  return successToolResult('interact', {
@@ -824,7 +831,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
824
831
  }
825
832
  const failData = { suggestion: 'Try reset() to return to the starting page.' };
826
833
  if (action.lastError)
827
- failData.error = action.lastError.toString();
834
+ failData.error = errorText(action.lastError);
828
835
  return failedToolResult('back', `Failed to navigate back to ${targetUrl}`, failData);
829
836
  },
830
837
  }),
@@ -881,7 +888,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
881
888
  }
882
889
  if (result.totalFound === 0) {
883
890
  return failedToolResult('xpathCheck', `No elements matched XPath: ${xpath}`, {
884
- suggestion: 'Try a broader expression. Examples: //*[contains(@class, "btn")], //button, //*[contains(text(), "keyword")]',
891
+ suggestion: 'Do not guess another expression. Narrow down from what you know about the target: its role, its visible text, its nearest labelled ancestor. Add one constraint at a time.',
885
892
  });
886
893
  }
887
894
  const action = explorer.action();
@@ -963,7 +970,9 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
963
970
  withdrawVisionTools(tools);
964
971
  return tools;
965
972
  }
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.';
973
+ 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. messages holds text the app showed in response, requests the calls it made and consoleErrors what it logged.';
974
+ const FAILED_REQUEST_SUGGESTION = 'The server rejected a request made by this action (see requests). The UI accepted the interaction but the operation did not complete — read messages and consoleErrors for the reason and report it instead of repeating the action.';
975
+ const NAVIGATED_SUGGESTION = 'The action left the page. Elements are never compared across pages, so this diff carries the move itself and what the app announced in transit — an empty element diff does not mean nothing happened.';
967
976
  const ARIA_OUTPUT_CAP = 4000;
968
977
  const HTML_OUTPUT_CAP = 6000;
969
978
  const ANALYSIS_OUTPUT_CAP = 2000;
@@ -1001,7 +1010,7 @@ function transformContainsCommand(command) {
1001
1010
  }
1002
1011
  function errorText(error) {
1003
1012
  if (error instanceof Error)
1004
- return error.toString();
1013
+ return compactErrorMessage(error);
1005
1014
  return 'Unknown error occurred';
1006
1015
  }
1007
1016
  export async function commitNote(activeNote, result, toolResult, action) {
@@ -1037,7 +1046,13 @@ export function successToolResult(action, data, source) {
1037
1046
  const ariaChanges = data.pageDiff.ariaChanges || '';
1038
1047
  const urlChanged = data.pageDiff.urlChanged === true;
1039
1048
  const hasHtmlParts = Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
1040
- if (isMajorPageChange(data.pageDiff)) {
1049
+ if (hasFailedRequest(data.pageDiff)) {
1050
+ suggestion = `${FAILED_REQUEST_SUGGESTION} ${suggestion}`;
1051
+ }
1052
+ else if (urlChanged) {
1053
+ suggestion = `${NAVIGATED_SUGGESTION} ${suggestion}`;
1054
+ }
1055
+ else if (isMajorPageChange(data.pageDiff)) {
1041
1056
  suggestion = `MAJOR PAGE CHANGE. Page entered a different mode. Check htmlParts and iframes in pageDiff before next action. ${suggestion}`;
1042
1057
  }
1043
1058
  else if (!urlChanged && !ariaChanges && !hasHtmlParts) {
@@ -1053,6 +1068,9 @@ export function successToolResult(action, data, source) {
1053
1068
  export function isMajorPageChange(pageDiff) {
1054
1069
  return pageDiff.urlChanged !== true && (pageDiff.ariaChangeCount ?? 0) >= LARGE_ARIA_CHANGE_THRESHOLD;
1055
1070
  }
1071
+ export function hasFailedRequest(pageDiff) {
1072
+ return (pageDiff.requests ?? []).some((request) => request.status >= 400);
1073
+ }
1056
1074
  function hasObservablePageChange(data) {
1057
1075
  if (!data?.pageDiff)
1058
1076
  return false;
@@ -1060,6 +1078,8 @@ function hasObservablePageChange(data) {
1060
1078
  return true;
1061
1079
  if (data.pageDiff.ariaChanges)
1062
1080
  return true;
1081
+ if (data.pageDiff.messages?.length)
1082
+ return true;
1063
1083
  return Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
1064
1084
  }
1065
1085
  export async function failedToolResult(action, message, data, error) {
@@ -1111,6 +1131,9 @@ export function clickFailureSuggestion(attempts) {
1111
1131
  if (errors.some((e) => e.includes('is not visible'))) {
1112
1132
  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
1133
  }
1134
+ if (errors.some((e) => e.includes('SyntaxError'))) {
1135
+ return 'The command string never parsed as JavaScript — quotes or brackets do not match. No element was looked up, so this tells you nothing about the page. Re-emit the same intent as valid CodeceptJS.';
1136
+ }
1114
1137
  const notFound = errors.filter((e) => e.includes('was not found'));
1115
1138
  if (notFound.length && notFound.every((e) => e.includes('was not found inside element'))) {
1116
1139
  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().';
@@ -1191,7 +1214,7 @@ function getNotFoundSuggestion(errorMessage) {
1191
1214
  Element was not found. The locator does not exist on this page.
1192
1215
  1. Use see() to visually analyze what elements are actually on the page
1193
1216
  2. Use context() to get fresh HTML and ARIA snapshot
1194
- 3. Use ONLY locators from <page_aria> or <page_html>
1217
+ 3. Use ONLY locators from <page_aria> or from HTML returned by context()
1195
1218
  4. Prefer ARIA locators: { "role": "button", "text": "visible text" }
1196
1219
  `;
1197
1220
  }
@@ -7,15 +7,10 @@ import { findGlobalConfig, globalConfigPath, globalDir, globalEnvPath } from "..
7
7
  import { getCliName } from "../utils/cli-name.js";
8
8
  import { log, tag } from '../utils/logger.js';
9
9
  import { relativeToCwd } from "../utils/next-steps.js";
10
- const DEFAULT_CONFIG_TEMPLATE = `import { createOpenRouter } from '@openrouter/ai-sdk-provider';
11
- // import { '<your provider here>' } from '<your provider package here>';
12
-
13
- // Vercel AI SDK is used to connect to AI providers.
14
- // Bring your own provider or use OpenRouter (one API key, many providers).
15
- // https://github.com/testomatio/explorbot/blob/main/docs/providers.md
16
- const openrouter = createOpenRouter({
17
- apiKey: process.env.OPENROUTER_API_KEY,
18
- });
10
+ function defaultConfigTemplate() {
11
+ return `// 'provider/model-id' uses a bundled provider.
12
+ // It is also possible to import provider as a module from Vercel AI SDK.
13
+ // https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
19
14
 
20
15
  const config = {
21
16
  web: {
@@ -24,12 +19,7 @@ const config = {
24
19
  },
25
20
 
26
21
  ai: {
27
- // fast model with tool calling capabilities
28
- model: openrouter('openai/gpt-oss-20b:nitro'),
29
- // vision model for screenshot analysis
30
- visionModel: openrouter('meta-llama/llama-4-scout-17b-16e-instruct'),
31
- // agentic model for decision making
32
- agenticModel: openrouter('minimax/minimax-m2.5:nitro'),
22
+ ${modelLines('openrouter')}
33
23
  },
34
24
 
35
25
  reporter: {
@@ -44,6 +34,7 @@ const config = {
44
34
 
45
35
  export default config;
46
36
  `;
37
+ }
47
38
  const DEFAULT_ENV_TEMPLATE = dedent `
48
39
  # AI provider API keys
49
40
  OPENROUTER_API_KEY=
@@ -127,7 +118,7 @@ export function runInitCommand(options) {
127
118
  log('Use --force to overwrite existing file');
128
119
  process.exit(1);
129
120
  }
130
- writeFileSync(outPath, DEFAULT_CONFIG_TEMPLATE, 'utf8');
121
+ writeFileSync(outPath, defaultConfigTemplate(), 'utf8');
131
122
  log(`Created config file: ${relativeToCwd(outPath)}`);
132
123
  const envPath = resolve(process.cwd(), '.env');
133
124
  if (!existsSync(envPath)) {
@@ -196,15 +187,17 @@ async function renderInitWizard(mode) {
196
187
  }), { exitOnCtrlC: false, patchConsole: false });
197
188
  });
198
189
  }
199
- function globalConfigTemplate(provider) {
200
- const { envKey } = PROVIDERS[provider];
190
+ function modelLines(provider) {
201
191
  const recommended = ConfigParser.recommendedModels()[provider] || {};
202
192
  const roles = [
203
193
  ['model', 'fast model with tool calling capabilities'],
204
194
  ['visionModel', 'vision model for screenshot analysis'],
205
195
  ['agenticModel', 'agentic model for decision making'],
206
196
  ];
207
- const models = roles.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`).join('\n');
197
+ return roles.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`).join('\n');
198
+ }
199
+ function globalConfigTemplate(provider) {
200
+ const { envKey } = PROVIDERS[provider];
208
201
  return `// Global Explorbot configuration — used by every directory without its own explorbot.config.js.
209
202
  // Models are written as 'provider/model-id' so they resolve without a local node_modules.
210
203
  // The key is read from ${envKey} in ~/.explorbot/.env
@@ -212,7 +205,7 @@ function globalConfigTemplate(provider) {
212
205
  // https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
213
206
  const config = {
214
207
  ai: {
215
- ${models}
208
+ ${modelLines(provider)}
216
209
  },
217
210
 
218
211
  reporter: {
@@ -157,7 +157,7 @@ export class ConfigParser {
157
157
  const config = this.getConfig();
158
158
  if (!this.configPath)
159
159
  throw new Error('Config path not found');
160
- return path.join(this.getProjectRoot(), config.dirs?.output || 'output');
160
+ return this.resolveProjectDir(config.dirs?.output || 'output');
161
161
  }
162
162
  getProjectRoot() {
163
163
  if (this.site)
@@ -168,6 +168,8 @@ export class ConfigParser {
168
168
  return process.cwd();
169
169
  }
170
170
  resolveProjectDir(relativeDir) {
171
+ if (path.isAbsolute(relativeDir))
172
+ return relativeDir;
171
173
  if (!this.configPath)
172
174
  return relativeDir;
173
175
  return path.join(this.getProjectRoot(), relativeDir);
@@ -53,6 +53,7 @@ export declare class ExperienceTracker {
53
53
  getExperienceTableOfContents(state: ActionResult, options?: {
54
54
  includeDescendantExperience?: boolean;
55
55
  }): ExperienceTocEntry[];
56
+ renderExperienceFor(state: ActionResult): string;
56
57
  renderExperienceTocFor(state: ActionResult): string;
57
58
  getExperienceSection(fileTag: string, sectionIndex: number, state: ActionResult, options?: {
58
59
  includeDescendantExperience?: boolean;
@@ -77,6 +78,7 @@ export declare class ExperienceTracker {
77
78
  fileHash: string;
78
79
  } | null;
79
80
  }
81
+ export declare function renderExperienceRecipes(recipes: string[]): string;
80
82
  export declare function renderExperienceToc(toc: ExperienceTocEntry[]): string;
81
83
  export interface ExperienceFile {
82
84
  filePath: string;
@@ -257,6 +257,13 @@ export class ExperienceTracker {
257
257
  });
258
258
  return this.buildToc(sorted);
259
259
  }
260
+ renderExperienceFor(state) {
261
+ const successful = this.getSuccessfulExperience(state);
262
+ if (!successful.length)
263
+ return '';
264
+ tag('operation').log(`Found ${successful.length} experience ${pluralize(successful.length, 'file')} for: ${state.url}`);
265
+ return renderExperienceRecipes(successful);
266
+ }
260
267
  renderExperienceTocFor(state) {
261
268
  const toc = this.getExperienceTableOfContents(state);
262
269
  if (toc.length === 0)
@@ -381,6 +388,11 @@ function indexToLetters(index) {
381
388
  }
382
389
  return result;
383
390
  }
391
+ export function renderExperienceRecipes(recipes) {
392
+ if (recipes.length === 0)
393
+ return '';
394
+ return `<experience>\nPast successful recipes recorded from prior runs for this page. Prefer these solutions first if they match the goal.\n\n${recipes.join('\n\n')}\n</experience>`;
395
+ }
384
396
  export function renderExperienceToc(toc) {
385
397
  if (toc.length === 0)
386
398
  return '';
@@ -199,7 +199,7 @@ export class ExplorBot {
199
199
  this.agents.tester = this.createAgent((deps) => {
200
200
  const researcher = this.agentResearcher();
201
201
  const navigator = this.agentNavigator();
202
- const tools = createAgentTools({ ...deps, researcher, navigator });
202
+ const tools = createAgentTools({ ...deps, researcher, navigator, withExperience: false });
203
203
  return new Tester(deps, researcher, navigator, tools);
204
204
  });
205
205
  const qm = this.agentQuartermaster();
@@ -6,7 +6,7 @@ const RECORDABLE = {
6
6
  Frame: new Set(['click', 'dblclick', 'fill', 'selectOption', 'press', 'type', 'check', 'uncheck', 'hover', 'tap', 'focus', 'setInputFiles', 'scrollIntoViewIfNeeded', 'dragTo', 'goto', 'setContent']),
7
7
  Page: new Set(['goBack', 'goForward', 'reload', 'keyboardPress', 'keyboardType', 'keyboardDown', 'keyboardUp', 'keyboardInsertText', 'mouseClick', 'mouseDblclick', 'mouseMove', 'mouseDown', 'mouseUp', 'mouseWheel']),
8
8
  };
9
- const PLAYWRIGHT_INCOMPATIBLE = "Playwright output is not compatible with this Playwright version (playwright-core/lib/utils does not expose asLocator). Use output.framework: 'codeceptjs' instead, or pin Playwright to a version shipping lib/utils/isomorphic/locatorGenerators.js.";
9
+ const PLAYWRIGHT_INCOMPATIBLE = "Playwright output requires playwright-core 1.62 or newer (lib/coreBundle does not expose iso.asLocator). Use output.framework: 'codeceptjs' instead.";
10
10
  let cachedAsLocator = null;
11
11
  let asLocatorLoadAttempted = false;
12
12
  const nodeRequire = typeof require === 'function' ? require : createRequire(import.meta.url);
@@ -16,17 +16,11 @@ function getAsLocator() {
16
16
  if (asLocatorLoadAttempted)
17
17
  throw new Error(PLAYWRIGHT_INCOMPATIBLE);
18
18
  asLocatorLoadAttempted = true;
19
- try {
20
- const mod = nodeRequire('playwright-core/lib/utils');
21
- if (typeof mod?.asLocator === 'function') {
22
- cachedAsLocator = mod.asLocator;
23
- return cachedAsLocator;
24
- }
25
- }
26
- catch {
27
- // Module not exported or not found
28
- }
29
- throw new Error(PLAYWRIGHT_INCOMPATIBLE);
19
+ const asLocator = nodeRequire('playwright-core/lib/coreBundle')?.iso?.asLocator;
20
+ if (typeof asLocator !== 'function')
21
+ throw new Error(PLAYWRIGHT_INCOMPATIBLE);
22
+ cachedAsLocator = asLocator;
23
+ return cachedAsLocator;
30
24
  }
31
25
  export class PlaywrightRecorder {
32
26
  context = null;
@@ -1,3 +1,4 @@
1
+ import type { ActionResult } from './action-result.js';
1
2
  import { WebPageState } from './state-manager.js';
2
3
  export declare const TestResult: {
3
4
  readonly PASSED: "passed";
@@ -94,10 +95,13 @@ export declare class Test extends Task {
94
95
  startTime?: number;
95
96
  endTime?: number;
96
97
  resetCount: number;
98
+ appliedExperience: AppliedExperience[];
97
99
  constructor(scenario: string, priority: 'critical' | 'important' | 'high' | 'normal' | 'low', expectedOutcome: string | string[], startUrl: string, plannedSteps?: string[]);
98
100
  getVisitedUrls({ localOnly }?: {
99
101
  localOnly?: boolean;
100
102
  }): string[];
103
+ applyExperience(recipes: AppliedExperience[]): void;
104
+ getAppliedExperience(state: ActionResult): string[];
101
105
  addArtifact(artifact?: string): void;
102
106
  get hasFinished(): boolean;
103
107
  get isSuccessful(): boolean;
@@ -166,4 +170,8 @@ interface UrlNoteState {
166
170
  h2?: string;
167
171
  screenshotFile?: string;
168
172
  }
173
+ interface AppliedExperience {
174
+ url: string;
175
+ content: string;
176
+ }
169
177
  export {};
@@ -190,6 +190,7 @@ export class Test extends Task {
190
190
  startTime;
191
191
  endTime;
192
192
  resetCount = 0;
193
+ appliedExperience = [];
193
194
  constructor(scenario, priority, expectedOutcome, startUrl, plannedSteps = []) {
194
195
  super(scenario, startUrl);
195
196
  this.scenario = scenario;
@@ -208,6 +209,16 @@ export class Test extends Task {
208
209
  }
209
210
  return [...new Set([this.startUrl, ...this.states.map((s) => s.url)].filter((value) => Boolean(value) && value.trim() !== ''))];
210
211
  }
212
+ applyExperience(recipes) {
213
+ for (const recipe of recipes) {
214
+ if (this.appliedExperience.some((applied) => applied.content === recipe.content))
215
+ continue;
216
+ this.appliedExperience.push(recipe);
217
+ }
218
+ }
219
+ getAppliedExperience(state) {
220
+ return this.appliedExperience.filter((recipe) => state.isRelevantExperienceRecord({ url: recipe.url })).map((recipe) => recipe.content);
221
+ }
211
222
  addArtifact(artifact) {
212
223
  if (!artifact)
213
224
  return;
@@ -11,9 +11,14 @@ export interface HtmlDiffResult {
11
11
  removed: string[];
12
12
  similarity: number;
13
13
  summary: string;
14
+ messages: string[];
14
15
  }
15
16
  export declare function computeHtmlFingerprint(html: string): string[];
16
17
  /**
17
18
  * Compares two HTML documents and returns differences along with a diff subtree.
18
19
  */
19
20
  export declare function htmlDiff(originalHtml: string, modifiedHtml: string, htmlConfig?: HtmlConfig): Promise<HtmlDiffResult>;
21
+ /**
22
+ * Text the app announced across a navigation. Only live regions: everything else on a new page is its content, not a message.
23
+ */
24
+ export declare function liveRegionMessages(originalHtml: string, modifiedHtml: string): string[];