explorbot 0.5.2 → 0.5.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 (70) hide show
  1. package/bin/explorbot-cli.ts +3 -0
  2. package/boat/doc-collector/src/config.ts +1 -0
  3. package/boat/doc-collector/src/docbot.ts +10 -1
  4. package/boat/prima/src/envelope.ts +12 -6
  5. package/boat/prima/src/help.ts +4 -2
  6. package/boat/prima/src/prima.ts +37 -4
  7. package/boat/prima/src/session-log.ts +3 -3
  8. package/dist/bin/explorbot-cli.js +2 -0
  9. package/dist/boat/doc-collector/src/docbot.js +11 -1
  10. package/dist/boat/prima/src/envelope.js +12 -3
  11. package/dist/boat/prima/src/help.js +4 -2
  12. package/dist/boat/prima/src/prima.js +37 -4
  13. package/dist/boat/prima/src/session-log.js +3 -3
  14. package/dist/package.json +1 -1
  15. package/dist/rules/navigator/output.md +3 -3
  16. package/dist/src/action.js +6 -0
  17. package/dist/src/ai/driller.js +2 -28
  18. package/dist/src/ai/historian/codeceptjs.js +2 -0
  19. package/dist/src/ai/judge-tool.js +5 -1
  20. package/dist/src/ai/navigator.d.ts +5 -2
  21. package/dist/src/ai/navigator.js +15 -10
  22. package/dist/src/ai/pilot.d.ts +2 -7
  23. package/dist/src/ai/pilot.js +37 -7
  24. package/dist/src/ai/rerunner.js +3 -1
  25. package/dist/src/ai/researcher.js +10 -8
  26. package/dist/src/ai/rules.js +1 -1
  27. package/dist/src/ai/tester.js +11 -0
  28. package/dist/src/ai/tools.js +23 -7
  29. package/dist/src/config.d.ts +10 -3
  30. package/dist/src/config.js +42 -6
  31. package/dist/src/explorbot.js +5 -1
  32. package/dist/src/explorer.d.ts +2 -0
  33. package/dist/src/explorer.js +33 -1
  34. package/dist/src/global-config.js +1 -1
  35. package/dist/src/test-plan.d.ts +11 -0
  36. package/dist/src/test-plan.js +21 -0
  37. package/dist/src/utils/code-extractor.d.ts +1 -0
  38. package/dist/src/utils/code-extractor.js +40 -0
  39. package/dist/src/utils/html.d.ts +2 -0
  40. package/dist/src/utils/html.js +36 -1
  41. package/dist/src/utils/mdq/cli.js +3 -1
  42. package/dist/src/utils/test-files.d.ts +5 -0
  43. package/dist/src/utils/test-files.js +28 -1
  44. package/dist/src/utils/web-sandbox.js +4 -4
  45. package/docs/doc-collection/basics.md +13 -0
  46. package/docs/reference/commands.md +12 -0
  47. package/docs/web-testing/researcher.md +8 -0
  48. package/package.json +1 -1
  49. package/rules/navigator/output.md +3 -3
  50. package/src/action.ts +6 -0
  51. package/src/ai/driller.ts +2 -33
  52. package/src/ai/historian/codeceptjs.ts +2 -0
  53. package/src/ai/judge-tool.ts +5 -1
  54. package/src/ai/navigator.ts +15 -11
  55. package/src/ai/pilot.ts +38 -15
  56. package/src/ai/rerunner.ts +3 -1
  57. package/src/ai/researcher.ts +10 -9
  58. package/src/ai/rules.ts +1 -1
  59. package/src/ai/tester.ts +12 -0
  60. package/src/ai/tools.ts +21 -12
  61. package/src/config.ts +48 -6
  62. package/src/explorbot.ts +3 -1
  63. package/src/explorer.ts +37 -2
  64. package/src/global-config.ts +1 -1
  65. package/src/test-plan.ts +23 -0
  66. package/src/utils/code-extractor.ts +41 -0
  67. package/src/utils/html.ts +33 -1
  68. package/src/utils/mdq/cli.ts +3 -2
  69. package/src/utils/test-files.ts +27 -1
  70. package/src/utils/web-sandbox.ts +4 -4
@@ -119,10 +119,10 @@ class Navigator {
119
119
  const currentUrl = this.getComparableCurrentUrl(stateManager, expectedUrl);
120
120
  return matchesNavigationUrl(expectedUrl, currentUrl);
121
121
  }
122
- async visit(url) {
123
- return this.visitOnce(url);
122
+ async visit(url, opts = {}) {
123
+ return this.visitOnce(url, opts.recover !== false);
124
124
  }
125
- async visitOnce(url) {
125
+ async visitOnce(url, recover) {
126
126
  try {
127
127
  const action = this.explorer.action();
128
128
  await action.execute(`I.amOnPage('${url}')`);
@@ -131,7 +131,10 @@ class Navigator {
131
131
  throw new Error(`Navigation to "${url}" opened an empty page. The target must be a valid URL path starting with "/".`);
132
132
  }
133
133
  await this.hooksRunner.runBeforeHook('navigator', url);
134
- if (!this.isOnExpectedPage(url, action.stateManager)) {
134
+ if (!recover && action.lastError)
135
+ throw new Error(`Navigation to ${url} failed: ${action.lastError.message}`);
136
+ const arrived = this.isOnExpectedPage(url, action.stateManager);
137
+ if (recover && !arrived) {
135
138
  const actualPath = action.stateManager.getCurrentState()?.url || '';
136
139
  const actionResult = action.actionResult || ActionResult.fromState(action.stateManager.getCurrentState());
137
140
  const originalMessage = `Navigate to: ${url}. Current page: ${actualPath}`;
@@ -154,6 +157,7 @@ class Navigator {
154
157
  }
155
158
  await this.explorer.capture({ screenshot: true });
156
159
  await this.hooksRunner.runAfterHook('navigator', url);
160
+ return arrived || recover;
157
161
  }
158
162
  catch (error) {
159
163
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -252,7 +256,7 @@ class Navigator {
252
256
  return;
253
257
  }
254
258
  tag('operation').log('Feeding failures back to AI for a new batch...');
255
- conversation.addUserText(await this.buildRetryFeedback(batchFailures, !htmlContextAdded, actionResult));
259
+ conversation.addUserText(await this.buildRetryFeedback(batchFailures, !htmlContextAdded, action.actionResult ?? actionResult));
256
260
  htmlContextAdded = true;
257
261
  codeBlocks = [];
258
262
  batchFailures.length = 0;
@@ -347,10 +351,6 @@ class Navigator {
347
351
 
348
352
  <page>
349
353
  ${actionResult.toAiContext()}
350
-
351
- <page_html>
352
- ${await actionResult.combinedHtml()}
353
- </page_html>
354
354
  </page>
355
355
 
356
356
  <task>
@@ -360,6 +360,8 @@ class Navigator {
360
360
  Propose different solutions to achieve the result.
361
361
  Solution should be valid CodeceptJS code.
362
362
  Use only data from the <page> context to plan the solution.
363
+ The page is given as an ARIA snapshot only: use ARIA and text locators from it.
364
+ Full HTML is provided if these solutions fail.
363
365
  Try various ways to achieve the result
364
366
  </task>
365
367
 
@@ -722,7 +724,10 @@ class Navigator {
722
724
  await action.exitIframe();
723
725
  const verified = await action.attempt(codeBlock, message);
724
726
  const proof = action.assertionSteps.map(renderAssertion).filter(Boolean);
725
- results.push({ code: codeBlock, passed: verified, proof });
727
+ const result = { code: codeBlock, passed: verified, proof };
728
+ if (!verified && action.lastError)
729
+ result.error = action.lastError.message;
730
+ results.push(result);
726
731
  if (verified) {
727
732
  tag('success').log('Verification passed');
728
733
  successfulCodes.push(codeBlock);
@@ -3,7 +3,7 @@ import type { RequestStore } from '../api/request-store.js';
3
3
  import type Explorer from '../explorer.js';
4
4
  import type { PlaywrightRecorder } from '../playwright-recorder.js';
5
5
  import type { StateManager } from '../state-manager.js';
6
- import { type Test } from '../test-plan.js';
6
+ import { SettledExpectation, type SettledStatus, type Test } from '../test-plan.js';
7
7
  import type { Agent, AgentDeps } from './agent.js';
8
8
  import type { Conversation } from './conversation.js';
9
9
  import type { Fisherman } from './fisherman.js';
@@ -44,6 +44,7 @@ export declare class Pilot implements Agent {
44
44
  periodicAnalyzeProgress(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string>;
45
45
  analyzeProgress(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string>;
46
46
  settleExpectations(task: Test, finalState?: ActionResult): Promise<SettledExpectation[]>;
47
+ resolveContradictions(outcomes: SettledExpectation[], finalState?: ActionResult): Promise<SettledExpectation[]>;
47
48
  settleByJudge(task: Test, expectations: string[]): Promise<Map<string, SettledStatus>>;
48
49
  formatExpectations(task: Test): string;
49
50
  sendToPilot(userText: string, functionId: string, opts: {
@@ -104,9 +105,3 @@ export declare class Pilot implements Agent {
104
105
  formatActions(toolCalls: any[]): string;
105
106
  getSystemPrompt(task: Test, initialState: ActionResult): string;
106
107
  }
107
- export type SettledStatus = 'passed' | 'failed' | 'unverified' | 'contradiction';
108
- export interface SettledExpectation {
109
- text: string;
110
- status: SettledStatus;
111
- evidence?: string;
112
- }
@@ -4,7 +4,7 @@ import { z } from 'zod';
4
4
  import { ActionResult } from "../action-result.js";
5
5
  import { ConfigParser } from "../config.js";
6
6
  import { Stats } from "../stats.js";
7
- import { TestResult, TestStatus } from "../test-plan.js";
7
+ import { SettledExpectation, TestResult, TestStatus } from "../test-plan.js";
8
8
  import { collectInteractiveNodes } from "../utils/aria.js";
9
9
  import { ErrorPageError } from "../utils/error-page.js";
10
10
  import { createDebug, tag } from "../utils/logger.js";
@@ -26,6 +26,11 @@ const OUTCOME_STATUS = {
26
26
  'The run shows this outcome happened.': 'passed',
27
27
  'The run shows this outcome did not happen.': 'failed',
28
28
  };
29
+ const CONTRADICTION_RESOLUTION = {
30
+ 'The page structure agrees with the screenshot, and the outcome happened.': { status: 'passed', resolution: 'the page structure agrees with the screenshot, so the run log was wrong' },
31
+ 'The page structure agrees with the screenshot, and the outcome did not happen.': { status: 'failed', resolution: 'the page structure agrees with the screenshot, so the run log was wrong' },
32
+ 'The page structure agrees with the run log: the page holds it, but the screenshot does not show it.': { status: 'contradiction', resolution: 'the page structure agrees with the run log: the page holds it but does not display it, a likely rendering bug' },
33
+ };
29
34
  export class Pilot {
30
35
  emoji = '🧭';
31
36
  provider;
@@ -544,7 +549,7 @@ export class Pilot {
544
549
  settledByJudge = await this.settleByJudge(task, undecided);
545
550
  undecided = undecided.filter((text) => !settledByJudge.has(text));
546
551
  if (!undecided.length)
547
- return task.expected.map((text) => ({ text, status: settledByJudge.get(text) || decided(text) }));
552
+ return task.expected.map((text) => new SettledExpectation(text, settledByJudge.get(text) || decided(text)));
548
553
  const schema = z.object({
549
554
  outcomes: z.array(z.object({
550
555
  expectation: z.string().describe('The expected outcome, repeated exactly as it was given'),
@@ -607,17 +612,39 @@ export class Pilot {
607
612
  if (!response)
608
613
  response = await settle(userContent, this.provider.getAgenticModel('pilot'));
609
614
  const judged = new Map((response?.object?.outcomes || []).map((outcome) => [outcome.expectation, outcome]));
610
- return task.expected.map((text) => {
615
+ const settled = task.expected.map((text) => {
611
616
  const byJudge = settledByJudge.get(text);
612
617
  if (byJudge)
613
- return { text, status: byJudge };
618
+ return new SettledExpectation(text, byJudge);
614
619
  if (!undecided.includes(text))
615
- return { text, status: decided(text) };
620
+ return new SettledExpectation(text, decided(text));
616
621
  const outcome = judged.get(text);
617
622
  if (!outcome)
618
- return { text, status: 'unverified' };
619
- return { text, status: outcome.status || 'unverified', evidence: outcome.evidence };
623
+ return new SettledExpectation(text, 'unverified');
624
+ return new SettledExpectation(text, outcome.status || 'unverified', outcome.evidence);
620
625
  });
626
+ return this.resolveContradictions(settled, finalState);
627
+ }
628
+ async resolveContradictions(outcomes, finalState) {
629
+ const judge = this.judge;
630
+ if (!judge || !finalState)
631
+ return outcomes;
632
+ if (!outcomes.some((outcome) => outcome.contradicted))
633
+ return outcomes;
634
+ const page = finalState.getCompactARIA().slice(0, JUDGE_PAGE_CAP);
635
+ return Promise.all(outcomes.map(async (outcome) => {
636
+ if (!outcome.contradicted)
637
+ return outcome;
638
+ const decision = await judge.decide(`The screenshot and the run log disagree about the expected outcome: ${outcome.text}. Which side does the page structure support?`, [...Object.keys(CONTRADICTION_RESOLUTION), UNDECIDED], {
639
+ expectation: outcome.text,
640
+ disagreement: outcome.evidence || '',
641
+ page,
642
+ });
643
+ const resolved = CONTRADICTION_RESOLUTION[decision.value ?? ''];
644
+ if (!resolved)
645
+ return outcome;
646
+ return outcome.resolve(resolved.status, resolved.resolution);
647
+ }));
621
648
  }
622
649
  async settleByJudge(task, expectations) {
623
650
  const settled = new Map();
@@ -1059,6 +1086,9 @@ export class Pilot {
1059
1086
  const consoleError = t.output?.pageDiff?.consoleErrors?.[0];
1060
1087
  if (consoleError)
1061
1088
  line += `\n console: ${consoleError.slice(0, PILOT_MESSAGE_MAX_LENGTH)}`;
1089
+ const disabledReason = t.output?.disabledReason?.[0];
1090
+ if (disabledReason)
1091
+ line += `\n shown on hover of disabled target: ${disabledReason.slice(0, PILOT_MESSAGE_MAX_LENGTH)}`;
1062
1092
  return line;
1063
1093
  })
1064
1094
  .join('\n\n');
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { relative, resolve } from 'node:path';
2
+ import { dirname, relative, resolve } from 'node:path';
3
3
  import { tool } from 'ai';
4
4
  import { createBashTool } from 'bash-tool';
5
5
  import chalk from 'chalk';
@@ -19,6 +19,7 @@ import { createDebug, tag } from "../utils/logger.js";
19
19
  import { loop } from "../utils/loop.js";
20
20
  import { RulesLoader } from "../utils/rules-loader.js";
21
21
  import { isInternalStep } from "../utils/step-analyzer.js";
22
+ import { ensureCodeceptResolvable } from "../utils/test-files.js";
22
23
  import { toolExecutionLabel } from "./conversation.js";
23
24
  import { actionRule, locatorRule, sectionContextRule } from "./rules.js";
24
25
  import { TaskAgent } from "./task-agent.js";
@@ -122,6 +123,7 @@ export class Rerunner extends TaskAgent {
122
123
  const mocha = codeceptjs.container.mocha();
123
124
  mocha.reporter(class {
124
125
  });
126
+ ensureCodeceptResolvable(dirname(absPath));
125
127
  mocha.files = [absPath];
126
128
  mocha.loadFiles();
127
129
  let testIndex = 0;
@@ -77,7 +77,7 @@ export class Researcher extends ResearcherBase {
77
77
  `;
78
78
  }
79
79
  async research(state, opts = {}) {
80
- const { screenshot = false, force = false, deep = false, data = false, fix = true } = opts;
80
+ const { screenshot = false, force = false, deep = false, data = false, fix = true, light = false } = opts;
81
81
  const maxRetries = this.settings.retries ?? 2;
82
82
  let retriesLeft = opts._retriesLeft ?? maxRetries;
83
83
  this.actionResult = ActionResult.fromState(state);
@@ -103,6 +103,8 @@ export class Researcher extends ResearcherBase {
103
103
  return Observability.run(sessionName, { tags: ['researcher'], sessionId: stateHash }, async () => {
104
104
  const displayUrl = state.fullUrl || state.url;
105
105
  tag('info').log(`Researching ${displayUrl} to understand the context...`);
106
+ if (light)
107
+ tag('info').log('Light research: single pass, locator verification skipped');
106
108
  setActivity(`${this.emoji} Researching...`, 'action');
107
109
  await this.ensureNavigated(displayUrl, screenshot && this.provider.hasVision());
108
110
  await this.hooksRunner.runBeforeHook('researcher', state.url);
@@ -135,7 +137,7 @@ export class Researcher extends ResearcherBase {
135
137
  }
136
138
  }
137
139
  const isOnCurrentState = this.actionResult.getStateHash() === this.stateManager.getCurrentState()?.hash;
138
- this.hasScreenshotToAnalyze = screenshot && this.provider.hasVision() && isOnCurrentState;
140
+ this.hasScreenshotToAnalyze = !light && screenshot && this.provider.hasVision() && isOnCurrentState;
139
141
  const conversation = this.provider.startConversation(this.getSystemMessage(), 'researcher');
140
142
  const prompt = await this.buildResearchPrompt();
141
143
  conversation.addUserText(prompt);
@@ -164,12 +166,12 @@ export class Researcher extends ResearcherBase {
164
166
  // Stage 2: Test containers + locators
165
167
  result.parseLocators();
166
168
  debugLog(`Extracted ${result.locators.length} locators from research`);
167
- if (!interrupted() && result.locators.length === 0 && retriesLeft > 0) {
169
+ if (!light && !interrupted() && result.locators.length === 0 && retriesLeft > 0) {
168
170
  tag('warning').log(`No locators parsed, retrying research (${maxRetries - retriesLeft + 1}/${maxRetries})...`);
169
171
  await new Promise((r) => setTimeout(r, 1000));
170
172
  return this.research(state, { ...opts, force: true, _retriesLeft: retriesLeft - 1 });
171
173
  }
172
- if (!interrupted()) {
174
+ if (!light && !interrupted()) {
173
175
  const brokenContainers = await this.resolveContainers(result);
174
176
  const containerCount = result.containers.length;
175
177
  if (containerCount > 0 && brokenContainers.length === containerCount && retriesLeft > 0) {
@@ -187,7 +189,7 @@ export class Researcher extends ResearcherBase {
187
189
  await this.testLocators(toTest);
188
190
  }
189
191
  // Stage 3: Fix broken sections via AI conversation continuation
190
- if (!interrupted() && fix && result.locators.some((l) => l.valid === false)) {
192
+ if (!light && !interrupted() && fix && result.locators.some((l) => l.valid === false)) {
191
193
  await this.fixBrokenSections(result, activeConversation);
192
194
  }
193
195
  // Focused section: unified Playwright probe (HTML+CSS+visibility).
@@ -227,11 +229,11 @@ export class Researcher extends ResearcherBase {
227
229
  }
228
230
  }
229
231
  // Stage 5: Backfill broken elements
230
- if (!interrupted()) {
232
+ if (!light && !interrupted()) {
231
233
  await this.backfillCoordinates(result);
232
234
  await this.backfillBrokenLocators(result);
233
235
  }
234
- if (!interrupted()) {
236
+ if (!light && !interrupted()) {
235
237
  await this.detectPagination(result);
236
238
  }
237
239
  // Focused section: final fallback (vision-only — without a screenshot we don't infer focus)
@@ -258,7 +260,7 @@ export class Researcher extends ResearcherBase {
258
260
  }
259
261
  result.cleanup();
260
262
  let researchFile = null;
261
- if (stateHash) {
263
+ if (stateHash && !light) {
262
264
  researchFile = saveResearch(researchState, result.text, combinedHtml);
263
265
  }
264
266
  const summaryText = mdq(result.text).query('section2(/^summary/i)').query('paragraph[0]').text().trim();
@@ -224,7 +224,7 @@ export const unexpectedPopupRule = dedent `
224
224
  If a modal/popup appeared that you didn't expect, dismiss it first before continuing with original task.
225
225
  If elements become hidden or unclickable (timeout errors on visible elements), a dialog or overlay may have appeared on top.
226
226
  If a click error mentions "intercepts pointer events", another element is covering the target — dismiss it first.
227
- If buttons are disabled unexpectedly, check if a popup is blocking interaction or if required form fields are empty.
227
+ If buttons are disabled unexpectedly, trust any explanation the app gives (tooltip, hint text) over your own guess; otherwise check for a blocking popup or empty required fields.
228
228
 
229
229
  Dismiss strategy (try in order):
230
230
  1. I.pressKey('Escape') — press Escape to dismiss
@@ -682,6 +682,15 @@ export class Tester extends TaskAgent {
682
682
  return meta;
683
683
  }
684
684
  getSystemMessage() {
685
+ let judgeRule = '';
686
+ if (this.agentTools?.judge) {
687
+ judgeRule = dedent `
688
+ <judge_rule>
689
+ For a quick judgement about the current page (did the last action already take effect, which element the scenario means), ask judge() before see().
690
+ Keep see() for what only a screenshot shows.
691
+ </judge_rule>
692
+ `;
693
+ }
685
694
  return dedent `
686
695
  <role>
687
696
  You are a senior test automation engineer with expertise in CodeceptJS and exploratory testing.
@@ -781,6 +790,8 @@ export class Tester extends TaskAgent {
781
790
 
782
791
  ${capabilityGroundingRule}
783
792
 
793
+ ${judgeRule}
794
+
784
795
  ${dataProtectionRules}
785
796
 
786
797
  ${this.provider.getSystemPromptForAgent('tester', this.stateManager.getCurrentState()?.url) || ''}
@@ -91,7 +91,7 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }, task) {
91
91
  const attempts = [];
92
92
  let ambiguityError = null;
93
93
  for (let i = 0; i < commands.length; i++) {
94
- const command = transformContainsCommand(commands[i]);
94
+ let command = transformContainsCommand(commands[i]);
95
95
  let success = await action.attempt(command, explanation);
96
96
  const attempt = { command, success };
97
97
  if (action.lastError)
@@ -104,9 +104,14 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }, task) {
104
104
  const index = await judge.pick(PICK_ELEMENT_QUESTION, labels, { intent: explanation, task: task.description });
105
105
  if (index)
106
106
  success = await action.attemptExactElementIndex(command, index, explanation);
107
+ if (success)
108
+ command = action.executedSteps.map((step) => step.command).join('\n') || command;
107
109
  }
108
110
  }
109
111
  if (success) {
112
+ attempt.command = command;
113
+ attempt.success = true;
114
+ attempt.error = undefined;
110
115
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, command);
111
116
  if (!hasObservablePageChange(toolResult)) {
112
117
  activeNote.commit(TestResult.FAILED);
@@ -130,11 +135,22 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }, task) {
130
135
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, commands[0]);
131
136
  await commitNote(activeNote, TestResult.FAILED, toolResult, action);
132
137
  const suggestion = clickFailureSuggestion(attempts);
133
- return failedToolResult('click', 'All click commands failed', {
134
- ...toolResult,
135
- attempts,
136
- suggestion,
137
- }, ambiguityError || action.lastError);
138
+ const data = { ...toolResult, attempts, suggestion };
139
+ const clickError = ambiguityError || action.lastError;
140
+ for (const disabled of attempts.filter((a) => a.error?.includes('not enabled'))) {
141
+ const hoverCommand = disabled.command.replace('I.click(', 'I.moveCursorTo(');
142
+ const beforeHover = ActionResult.fromState(stateManager.getCurrentState());
143
+ if (!(await action.attempt(hoverCommand, 'Hover the disabled element to reveal why it is disabled')))
144
+ continue;
145
+ const hoverResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(beforeHover, hoverCommand);
146
+ const shown = hoverResult.pageDiff?.messages;
147
+ if (!shown?.length)
148
+ break;
149
+ data.disabledReason = shown;
150
+ data.suggestion = `Element is DISABLED. Hovering it showed: "${shown.join(' | ')}". That is why — act on it or report it, do not guess another cause.`;
151
+ break;
152
+ }
153
+ return failedToolResult('click', 'All click commands failed', data, clickError);
138
154
  },
139
155
  }),
140
156
  hover: tool({
@@ -1190,7 +1206,7 @@ export function withdrawVisionTools(tools) {
1190
1206
  export function clickFailureSuggestion(attempts) {
1191
1207
  const errors = attempts.map((a) => a.error || '');
1192
1208
  if (errors.some((e) => e.includes('not enabled'))) {
1193
- 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.';
1209
+ return 'Element exists but is DISABLED — clicking it again cannot work. Find the precondition the page shows as unmet, then retry.';
1194
1210
  }
1195
1211
  if (errors.some((e) => e.includes('intercepts pointer events'))) {
1196
1212
  return 'Element exists but another element covers it. Close the overlapping panel or dialog, then retry.';
@@ -243,6 +243,7 @@ export declare class ConfigParser {
243
243
  site: SiteRecord | null;
244
244
  siteStartPath: string;
245
245
  siteConfigPath: string | null;
246
+ runBase: RunBase | null;
246
247
  constructor();
247
248
  static loadEnv(filePath: string, keepExisting?: boolean): void;
248
249
  static recommendedModels(): Record<string, Record<string, string>>;
@@ -261,6 +262,9 @@ export declare class ConfigParser {
261
262
  isGlobalMode(): boolean;
262
263
  getSite(): SiteRecord | null;
263
264
  getSiteConfigPath(): string | null;
265
+ applyBasePath(target: string): string;
266
+ getBaseQuery(): string;
267
+ getBasePath(): string;
264
268
  resolveTargetPath(target?: string): string;
265
269
  getStatesDir(): string;
266
270
  getPlansDir(): string;
@@ -275,9 +279,7 @@ export declare class ConfigParser {
275
279
  buildEnvConfig(baseUrl: string | undefined, outputRoot: string): Promise<ExplorbotConfig>;
276
280
  findConfigFile(): string | null;
277
281
  loadConfigModule(configPath: string): Promise<any>;
278
- resolveConfig(config: ExplorbotConfig, options?: {
279
- baseUrl?: string;
280
- }): ExplorbotConfig;
282
+ resolveConfig(config: ExplorbotConfig): ExplorbotConfig;
281
283
  validateConfig(config: ExplorbotConfig): void;
282
284
  getNestedValue(obj: any, path: string): any;
283
285
  mergeWithDefaults(config: Partial<ExplorbotConfig>): ExplorbotConfig;
@@ -302,6 +304,11 @@ export declare function resolveStateRoot(baseUrl: string, ephemeral?: boolean):
302
304
  export declare function materializeKnowledge(outputRoot: string): void;
303
305
  export declare function createModel(provider: string, modelId: string): Promise<any>;
304
306
  type ModelRole = 'model' | 'visionModel' | 'agenticModel';
307
+ interface RunBase {
308
+ origin: string;
309
+ path: string;
310
+ query: string;
311
+ }
305
312
  interface ConfiguredModel {
306
313
  name: string;
307
314
  provider: string;
@@ -65,6 +65,7 @@ export class ConfigParser {
65
65
  site = null;
66
66
  siteStartPath = '/';
67
67
  siteConfigPath = null;
68
+ runBase = null;
68
69
  constructor() { }
69
70
  static loadEnv(filePath, keepExisting = false) {
70
71
  const resolved = resolve(filePath);
@@ -96,6 +97,7 @@ export class ConfigParser {
96
97
  if (this.config && !options?.config && !options?.path && this.runtimeTarget === target) {
97
98
  return this.config;
98
99
  }
100
+ this.runBase = parseRunBase(options?.baseUrl);
99
101
  // Store the initial working directory for reference
100
102
  if (!process.env.INITIAL_CWD) {
101
103
  process.env.INITIAL_CWD = process.cwd();
@@ -123,12 +125,19 @@ export class ConfigParser {
123
125
  let envUrl = options?.baseUrl;
124
126
  if (!envUrl && target?.startsWith('http'))
125
127
  envUrl = target;
126
- const outputRoot = resolveOutputRoot(process.env.EXPLORBOT_URL || envUrl);
128
+ if (options?.baseUrl) {
129
+ const envOrigin = URL.parse(process.env.EXPLORBOT_URL || '')?.origin;
130
+ const baseOrigin = URL.parse(options.baseUrl)?.origin;
131
+ if (envOrigin && envOrigin !== baseOrigin) {
132
+ tag('warning').log(`--base-url ${baseOrigin} selects the site for this run — EXPLORBOT_URL ${envOrigin} is ignored`);
133
+ }
134
+ }
135
+ const outputRoot = resolveOutputRoot(options?.baseUrl || process.env.EXPLORBOT_URL || envUrl);
127
136
  loadedConfig = await this.buildEnvConfig(envUrl, outputRoot);
128
137
  sourcePath = join(outputRoot, 'explorbot.config.js');
129
138
  log(`Configuration built from EXPLORBOT_* environment variables. Output: ${outputRoot}`);
130
139
  }
131
- let config = this.resolveConfig(loadedConfig, options);
140
+ let config = this.resolveConfig(loadedConfig);
132
141
  await resolveConfigModels(config.ai);
133
142
  this.site = null;
134
143
  this.siteConfigPath = null;
@@ -195,6 +204,22 @@ export class ConfigParser {
195
204
  getSiteConfigPath() {
196
205
  return this.siteConfigPath;
197
206
  }
207
+ applyBasePath(target) {
208
+ const base = this.runBase?.path;
209
+ if (!base)
210
+ return target;
211
+ if (!target.startsWith('/'))
212
+ return target;
213
+ if (target === base || target.startsWith(`${base}/`) || target.startsWith(`${base}?`))
214
+ return target;
215
+ return `${base}${target}`;
216
+ }
217
+ getBaseQuery() {
218
+ return this.runBase?.query || '';
219
+ }
220
+ getBasePath() {
221
+ return this.runBase?.path || '';
222
+ }
198
223
  resolveTargetPath(target) {
199
224
  if (!this.site) {
200
225
  const configured = this.config?.playwright?.url || this.config?.web?.url;
@@ -203,13 +228,15 @@ export class ConfigParser {
203
228
  if (targetOrigin && baseOrigin && targetOrigin !== baseOrigin) {
204
229
  tag('warning').log(`Exploring ${targetOrigin} but base URL is ${baseOrigin}. Relative navigation resolves against the base URL — set web.url to ${targetOrigin} to avoid it.`);
205
230
  }
206
- return target || '/';
231
+ return this.applyBasePath(target || '/');
207
232
  }
208
233
  if (!target)
209
234
  return this.siteStartPath;
210
235
  const resolved = resolveSiteTarget(target, this.site.url);
211
236
  if (resolved.baseUrl !== this.site.url)
212
237
  return target;
238
+ if (target.startsWith('/'))
239
+ return this.applyBasePath(resolved.path);
213
240
  return resolved.path;
214
241
  }
215
242
  getStatesDir() {
@@ -232,6 +259,7 @@ export class ConfigParser {
232
259
  ConfigParser.instance.site = null;
233
260
  ConfigParser.instance.siteStartPath = '/';
234
261
  ConfigParser.instance.siteConfigPath = null;
262
+ ConfigParser.instance.runBase = null;
235
263
  }
236
264
  }
237
265
  // For testing purposes only - sets up minimal default config
@@ -393,14 +421,14 @@ export class ConfigParser {
393
421
  return JSON.parse(content);
394
422
  }
395
423
  }
396
- resolveConfig(config, options) {
424
+ resolveConfig(config) {
397
425
  if (config.web?.url && !config.playwright?.url) {
398
426
  config.playwright = config.playwright || { browser: 'chromium', url: '' };
399
427
  config.playwright.url = config.web.url;
400
428
  }
401
- if (options?.baseUrl) {
429
+ if (this.runBase) {
402
430
  config.playwright = config.playwright || { browser: 'chromium', url: '' };
403
- config.playwright.url = options.baseUrl;
431
+ config.playwright.url = this.runBase.origin;
404
432
  }
405
433
  resolveLangfuse(config.ai);
406
434
  return config;
@@ -609,3 +637,11 @@ export async function createModel(provider, modelId) {
609
637
  }
610
638
  return (await info.load())(modelId);
611
639
  }
640
+ function parseRunBase(baseUrl) {
641
+ if (!baseUrl)
642
+ return null;
643
+ const url = URL.parse(baseUrl);
644
+ if (!url)
645
+ throw new Error(`Base URL must be a full URL like https://app.example.com/team/acme, got "${baseUrl}"`);
646
+ return { origin: url.origin, path: url.pathname.replace(/\/+$/, ''), query: url.search };
647
+ }
@@ -125,7 +125,7 @@ export class ExplorBot {
125
125
  await this.visit(this.options.from || '/');
126
126
  }
127
127
  async visit(url) {
128
- return this.agentNavigator().visit(this.configParser.resolveTargetPath(url));
128
+ await this.agentNavigator().visit(this.configParser.resolveTargetPath(url));
129
129
  }
130
130
  async openTab() {
131
131
  await this.explorer.openTab();
@@ -449,6 +449,10 @@ export class ExplorBot {
449
449
  this.currentPlan = plan;
450
450
  if (!plan)
451
451
  return;
452
+ if (plan.url)
453
+ plan.url = this.configParser.applyBasePath(plan.url);
454
+ for (const test of plan.tests)
455
+ test.startUrl = this.configParser.applyBasePath(test.startUrl);
452
456
  if (!this.sessionPlans.includes(plan)) {
453
457
  this.sessionPlans.push(plan);
454
458
  }
@@ -57,6 +57,8 @@ declare class Explorer {
57
57
  convertToCodeceptConfig(config: ExplorbotConfig): any;
58
58
  connectOrLaunchBrowser(): Promise<void>;
59
59
  openContextPage(): Promise<void>;
60
+ applyBasePathToHelper(): void;
61
+ pinBaseQuery(): Promise<void>;
60
62
  createBrowserContextOptions(): BrowserContextOptions;
61
63
  attachXhrCapture(): void;
62
64
  runWithRecovery<T>(label: string, operation: () => Promise<T>): Promise<T>;
@@ -73,6 +73,7 @@ class Explorer {
73
73
  if (!this.playwrightHelper) {
74
74
  throw new Error('Playwright helper not available');
75
75
  }
76
+ this.applyBasePathToHelper();
76
77
  await this.connectOrLaunchBrowser();
77
78
  const hasSession = !this.options?.attachedBrowser && this.options?.session && existsSync(this.options.session);
78
79
  await this.openContextPage();
@@ -124,7 +125,7 @@ class Explorer {
124
125
  }
125
126
  async visit(url, opts = {}) {
126
127
  return this.runWithRecovery('visit', async () => {
127
- const action = await this.visitOnce(url);
128
+ const action = await this.visitOnce(ConfigParser.getInstance().applyBasePath(url));
128
129
  if (opts.screenshot)
129
130
  return action.capturePageState({ includeScreenshot: true });
130
131
  return action.getActionResult() ?? action.capturePageState();
@@ -279,6 +280,7 @@ class Explorer {
279
280
  const attached = this.options?.attachedBrowser;
280
281
  if (!attached) {
281
282
  await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
283
+ await this.pinBaseQuery();
282
284
  return;
283
285
  }
284
286
  const context = attached.contexts()[0] || (await attached.newContext());
@@ -287,6 +289,36 @@ class Explorer {
287
289
  this.playwrightHelper.browserContext = context;
288
290
  await this.playwrightHelper._setPage(page);
289
291
  debugLog(`Adopted attached browser page: ${page.url()}`);
292
+ await this.pinBaseQuery();
293
+ }
294
+ applyBasePathToHelper() {
295
+ if (!ConfigParser.getInstance().getBasePath())
296
+ return;
297
+ const helper = this.playwrightHelper;
298
+ const original = helper.amOnPage.bind(helper);
299
+ helper.amOnPage = (url) => original(ConfigParser.getInstance().applyBasePath(url));
300
+ }
301
+ async pinBaseQuery() {
302
+ const query = ConfigParser.getInstance().getBaseQuery();
303
+ if (!query)
304
+ return;
305
+ const pinned = new URLSearchParams(query);
306
+ const origin = URL.parse(this.config.playwright.url)?.origin;
307
+ await this.playwrightHelper.browserContext.route('**/*', async (route) => {
308
+ const request = route.request();
309
+ if (request.resourceType() !== 'document')
310
+ return route.continue();
311
+ const url = URL.parse(request.url());
312
+ if (!url || url.origin !== origin)
313
+ return route.continue();
314
+ const missing = [...pinned].filter(([key]) => !url.searchParams.has(key));
315
+ if (!missing.length)
316
+ return route.continue();
317
+ for (const [key, value] of missing)
318
+ url.searchParams.set(key, value);
319
+ return route.continue({ url: url.toString() });
320
+ });
321
+ tag('info').log(`Every page load of ${origin} carries ${query}`);
290
322
  }
291
323
  createBrowserContextOptions() {
292
324
  const helperOptions = this.playwrightHelper.options || {};
@@ -116,7 +116,7 @@ export function resolveSiteTarget(target, defaultBaseUrl) {
116
116
  return { baseUrl: url.origin, path: `${url.pathname}${url.search}${url.hash}` };
117
117
  }
118
118
  if (raw.startsWith('/')) {
119
- const base = defaultBaseUrl || process.env.EXPLORBOT_URL;
119
+ const base = process.env.EXPLORBOT_URL || defaultBaseUrl;
120
120
  if (!base) {
121
121
  throw new Error(withSites(`Cannot resolve path "${raw}" without a site.`));
122
122
  }
@@ -33,6 +33,17 @@ export declare class ActiveNote {
33
33
  getMessage(): string;
34
34
  getStartTime(): number;
35
35
  }
36
+ export type SettledStatus = 'passed' | 'failed' | 'unverified' | 'contradiction';
37
+ export declare class SettledExpectation {
38
+ readonly text: string;
39
+ readonly status: SettledStatus;
40
+ readonly evidence?: string;
41
+ readonly resolution?: string;
42
+ constructor(text: string, status: SettledStatus, evidence?: string, resolution?: string);
43
+ get failed(): boolean;
44
+ get contradicted(): boolean;
45
+ resolve(status: SettledStatus, resolution: string): SettledExpectation;
46
+ }
36
47
  export interface StepData {
37
48
  text: string;
38
49
  duration?: number;