explorbot 0.1.17 → 0.1.19

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 (62) hide show
  1. package/bin/explorbot-cli.ts +5 -1
  2. package/boat/doc-collector/bin/doc-collector-cli.ts +5 -0
  3. package/boat/doc-collector/package.json +24 -0
  4. package/boat/doc-collector/src/ai/documentarian.ts +184 -0
  5. package/boat/doc-collector/src/cli.ts +119 -0
  6. package/boat/doc-collector/src/config.ts +162 -0
  7. package/boat/doc-collector/src/docbot.ts +391 -0
  8. package/boat/doc-collector/src/docs-renderer.ts +187 -0
  9. package/boat/doc-collector/src/path-filter.ts +46 -0
  10. package/boat/doc-collector/src/research-navigation.ts +90 -0
  11. package/dist/bin/explorbot-cli.js +4 -1
  12. package/dist/boat/doc-collector/bin/doc-collector-cli.js +4 -0
  13. package/dist/boat/doc-collector/src/ai/documentarian.js +157 -0
  14. package/dist/boat/doc-collector/src/cli.js +104 -0
  15. package/dist/boat/doc-collector/src/config.js +129 -0
  16. package/dist/boat/doc-collector/src/docbot.js +326 -0
  17. package/dist/boat/doc-collector/src/docs-renderer.js +141 -0
  18. package/dist/boat/doc-collector/src/path-filter.js +35 -0
  19. package/dist/boat/doc-collector/src/research-navigation.js +71 -0
  20. package/dist/package.json +4 -1
  21. package/dist/src/action.js +8 -3
  22. package/dist/src/ai/driller.js +1 -1
  23. package/dist/src/ai/navigator.js +43 -2
  24. package/dist/src/ai/pilot.js +5 -0
  25. package/dist/src/ai/planner.js +21 -5
  26. package/dist/src/ai/rerunner.js +1 -1
  27. package/dist/src/ai/researcher/coordinates.js +1 -1
  28. package/dist/src/ai/researcher/deep-analysis.js +22 -7
  29. package/dist/src/ai/researcher/parser.js +3 -0
  30. package/dist/src/ai/researcher.js +12 -6
  31. package/dist/src/ai/session-analyst.js +24 -0
  32. package/dist/src/ai/tester.js +3 -3
  33. package/dist/src/ai/tools.js +3 -2
  34. package/dist/src/commands/explore-command.js +5 -1
  35. package/dist/src/components/LogPane.js +34 -4
  36. package/dist/src/config.js +10 -3
  37. package/dist/src/explorer.js +14 -1
  38. package/dist/src/state-manager.js +3 -0
  39. package/dist/src/utils/url-matcher.js +5 -3
  40. package/dist/src/utils/web-element.js +3 -2
  41. package/package.json +4 -1
  42. package/src/action.ts +8 -3
  43. package/src/ai/driller.ts +1 -1
  44. package/src/ai/navigator.ts +43 -2
  45. package/src/ai/pilot.ts +5 -0
  46. package/src/ai/planner.ts +22 -5
  47. package/src/ai/rerunner.ts +1 -1
  48. package/src/ai/researcher/coordinates.ts +1 -1
  49. package/src/ai/researcher/deep-analysis.ts +20 -7
  50. package/src/ai/researcher/parser.ts +3 -0
  51. package/src/ai/researcher.ts +11 -6
  52. package/src/ai/session-analyst.ts +24 -0
  53. package/src/ai/tester.ts +3 -3
  54. package/src/ai/tools.ts +3 -2
  55. package/src/commands/explore-command.ts +6 -1
  56. package/src/components/LogPane.tsx +42 -9
  57. package/src/config.ts +13 -3
  58. package/src/explorbot.ts +1 -0
  59. package/src/explorer.ts +12 -1
  60. package/src/state-manager.ts +4 -0
  61. package/src/utils/url-matcher.ts +5 -2
  62. package/src/utils/web-element.ts +3 -2
package/src/ai/driller.ts CHANGED
@@ -168,7 +168,7 @@ export class Driller extends TaskAgent implements Agent {
168
168
  this.allResults = [];
169
169
 
170
170
  return Observability.run(`driller: ${currentState.url}`, { tags: ['driller'], sessionId: sessionName }, async () => {
171
- tag('info').log(`Driller starting on ${currentState.url}`);
171
+ tag('step').log(`Drilling page: ${currentState.url}`);
172
172
  await this.hooksRunner.runBeforeHook('driller', currentState.url);
173
173
 
174
174
  const originalState = await this.captureAnnotatedState();
@@ -80,8 +80,48 @@ class Navigator implements Agent {
80
80
  this.hooksRunner = new HooksRunner(explorer, explorer.getConfig());
81
81
  }
82
82
 
83
+ private getBaseOrigin(): string | null {
84
+ const baseUrl = this.explorer.getConfig().playwright.url;
85
+ try {
86
+ return new URL(baseUrl).origin;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ private getComparableCurrentUrl(stateManager: any, expectedUrl: string): string {
93
+ const currentState = stateManager.getCurrentState();
94
+ if (!currentState) return '';
95
+ const current = /^https?:\/\//i.test(expectedUrl) ? currentState.fullUrl || currentState.url || '' : currentState.url || '';
96
+ return current;
97
+ }
98
+
99
+ private isSameExpectedOrigin(expectedUrl: string, stateManager: any): boolean {
100
+ const currentState = stateManager.getCurrentState();
101
+ if (!currentState) return false;
102
+
103
+ const currentFullUrl = currentState.fullUrl || currentState.url || '';
104
+ if (!currentFullUrl) return false;
105
+
106
+ try {
107
+ const currentOrigin = new URL(currentFullUrl).origin;
108
+ if (/^https?:\/\//i.test(expectedUrl)) {
109
+ return currentOrigin === new URL(expectedUrl).origin;
110
+ }
111
+
112
+ const baseOrigin = this.getBaseOrigin();
113
+ if (!baseOrigin) return true;
114
+ return currentOrigin === baseOrigin;
115
+ } catch {
116
+ return !/^https?:\/\//i.test(expectedUrl);
117
+ }
118
+ }
119
+
83
120
  private isOnExpectedPage(expectedUrl: string, stateManager: any): boolean {
84
- const currentUrl = stateManager.getCurrentState()?.url || '';
121
+ if (!this.isSameExpectedOrigin(expectedUrl, stateManager)) {
122
+ return false;
123
+ }
124
+ const currentUrl = this.getComparableCurrentUrl(stateManager, expectedUrl);
85
125
  return normalizeUrl(currentUrl) === normalizeUrl(expectedUrl);
86
126
  }
87
127
 
@@ -282,7 +322,8 @@ class Navigator implements Agent {
282
322
  }
283
323
  }
284
324
  const freshState = await action.capturePageState();
285
- const urlMatches = normalizeUrl(freshState.url || '') === normalizeUrl(expectedUrl);
325
+ const currentUrl = /^https?:\/\//i.test(expectedUrl) ? freshState.fullUrl || freshState.url || '' : freshState.url || '';
326
+ const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && normalizeUrl(currentUrl) === normalizeUrl(expectedUrl);
286
327
  const stateChanged = freshState.getStateHash() !== actionResult.getStateHash();
287
328
  resolved = urlMatches && stateChanged;
288
329
 
package/src/ai/pilot.ts CHANGED
@@ -313,6 +313,9 @@ export class Pilot implements Agent {
313
313
  overrides the others — weigh them together. Tester's record() notes are the LEAST reliable; always
314
314
  cross-check against actual actions and state. Visual screenshot analysis is strong for UI state
315
315
  (active tabs, visible counts, colors).
316
+ If the final page clearly shows an equivalent success state in a different UI form, do not fail only
317
+ because one narrow assertion targeted a specific badge, count, toast, or wording that the product
318
+ represents differently.
316
319
 
317
320
  SCENARIO TITLE defines what must happen. Action verbs require persisted evidence:
318
321
  - "Create X" → X must exist (visible, redirected to its page, or success message). Opening a form is NOT enough.
@@ -355,6 +358,8 @@ export class Pilot implements Agent {
355
358
 
356
359
  GUIDANCE (required for "continue"): a specific next action on the current page — which tool, what
357
360
  to verify, how to record. Do not suggest repeating actions that already succeeded.
361
+ If progress is blocked only because the page lacks target data for the scenario, prefer precondition()
362
+ over repeated UI attempts.
358
363
  `;
359
364
  }
360
365
 
package/src/ai/planner.ts CHANGED
@@ -80,6 +80,10 @@ export class Planner extends PlannerBase implements Agent {
80
80
  return ConfigParser.getInstance().getConfig().ai?.agents?.researcher?.sections || Object.keys(POSSIBLE_SECTIONS);
81
81
  }
82
82
 
83
+ private getDefaultStartUrl(state: { url: string; fullUrl?: string }): string {
84
+ return state.fullUrl || state.url;
85
+ }
86
+
83
87
  getSystemMessage(feature?: string): string {
84
88
  const currentUrl = this.stateManager.getCurrentState()?.url;
85
89
  const customPrompt = this.provider.getSystemPromptForAgent('planner', currentUrl);
@@ -160,7 +164,6 @@ export class Planner extends PlannerBase implements Agent {
160
164
  this.freshStart = false;
161
165
 
162
166
  setActivity(`${this.emoji} Planning...`, 'action');
163
- tag('info').log(`Planning test scenarios for ${state.url}`);
164
167
  if (style) tag('info').log(`Planning style: ${style}`);
165
168
 
166
169
  const tags = ['planner'];
@@ -188,7 +191,8 @@ export class Planner extends PlannerBase implements Agent {
188
191
  throw new Error('No tasks were created successfully');
189
192
  }
190
193
 
191
- const fromPlanning = aiResult.object.scenarios.map((s: any) => new Test(s.scenario, s.priority, s.expectedOutcomes, s.startUrl || state.url, s.steps || []));
194
+ const defaultStartUrl = this.getDefaultStartUrl(state);
195
+ const fromPlanning = aiResult.object.scenarios.map((s: any) => new Test(s.scenario, s.priority, s.expectedOutcomes, s.startUrl || defaultStartUrl, s.steps || []));
192
196
 
193
197
  return { tests: fromPlanning, planName: aiResult.object.planName };
194
198
  });
@@ -200,7 +204,8 @@ export class Planner extends PlannerBase implements Agent {
200
204
  const cached = state.url ? getRegisteredPlan(state.url) : null;
201
205
  const planName = feature || cached?.plan.title || result.planName || state.url;
202
206
  this.currentPlan = new Plan(planName);
203
- this.currentPlan.url = state.url;
207
+ this.currentPlan.url = this.getDefaultStartUrl(state);
208
+ const defaultStartUrl = this.getDefaultStartUrl(state);
204
209
  if (parentPlan) this.currentPlan.parentPlan = parentPlan;
205
210
  const allPreviousScenarios = this.getPreviousSessionScenarios();
206
211
  const existingTestScenarios = this.getExistingTestFileScenarios(state.url);
@@ -208,13 +213,13 @@ export class Planner extends PlannerBase implements Agent {
208
213
  for (const t of tests) {
209
214
  if (allPreviousScenarios.has(t.scenario.toLowerCase())) continue;
210
215
  t.style = this.lastStyleName;
211
- t.startUrl = state.url;
216
+ t.startUrl = defaultStartUrl;
212
217
  this.currentPlan.addTest(t);
213
218
  }
214
219
  } else {
215
220
  tag('step').log(`Expanding plan: "${this.currentPlan.title}"`);
216
221
  this.currentPlan.nextIteration();
217
- const newTests = this.addNewTests(tests, state.url);
222
+ const newTests = this.addNewTests(tests, this.getDefaultStartUrl(state));
218
223
  if (newTests.length > 0) {
219
224
  const summary = `New scenarios:\n${newTests.map((t) => `+ [${t.priority}] ${t.scenario}`).join('\n')}`;
220
225
  tag('multiline').log(summary);
@@ -331,6 +336,13 @@ export class Planner extends PlannerBase implements Agent {
331
336
  Focus on URL page change or data persistency after page reload.
332
337
  If there are subpages (pages with same URL path) plan testing of those subpages as well
333
338
  If you plan to test CRUD operations, plan them in correct order: create, read, update.
339
+ Do not invent specific route names, success messages, validation texts, badge counts, or welcome messages unless they are visible in research, visited pages, or prior observed flows.
340
+ If exact wording is unknown, describe the expected result generically, for example "an authentication error is shown" or "the user stays on the login page" instead of guessing the literal text.
341
+ If exact redirect destination is unknown, describe the destination by visible page identity, for example "the dashboard page opens" or "the current workspace home page opens" instead of inventing a URL slug.
342
+ Only propose scenarios whose prerequisites are evident from page research, visited pages, or API data preparation context.
343
+ If a scenario needs existing records, recipients, results, notifications, or other target data, propose it only when that data is visible or API preconditions can create it.
344
+ If the page appears read-only, degraded, demo-limited, maintenance-like, or lacks write controls, prefer read-only scenarios such as opening panels, inspecting visible lists, filtering, searching, or verifying current state.
345
+ Do not assume hidden data exists just because a control is present.
334
346
  DO NOT propose "verification-only" tests that merely open a UI element (modal, dropdown, panel) and check it exists.
335
347
  Every test must complete a meaningful action that changes application state or produces a business outcome.
336
348
  Opening a modal is NOT a test — performing an action INSIDE the modal IS a test.
@@ -566,10 +578,15 @@ export class Planner extends PlannerBase implements Agent {
566
578
  - Good: "New suite 'My New Suite' appears in the suite list"
567
579
  - Good: "Suite appears under Starred filter tab"
568
580
  - Good: "Success message 'Suite created' is displayed"
581
+ - Good when wording is unknown: "An authentication error is displayed"
582
+ - Good when route is unknown: "The workspace home page is displayed"
569
583
  - Bad: "Modal is displayed" (just verifying existence, no business value)
570
584
  - Bad: "Dropdown menu is visible" (just verifying existence)
585
+ - Bad: "Welcome message is displayed" if no welcome message is visible in research
586
+ - Bad: "Redirected to /dashboard" if no such route was observed
571
587
  - Each outcome should be independently verifiable
572
588
  - Avoid combining multiple checks into one outcome
589
+ - Prefer durable user-facing results over fragile micro-signals
573
590
  - Expected outcomes describe WHAT TO VERIFY
574
591
 
575
592
  FORMATTING RULES:
@@ -87,7 +87,7 @@ export class Rerunner extends TaskAgent implements Agent {
87
87
  return { total: 0, passed: 0, failed: 0, healed: 0 };
88
88
  }
89
89
 
90
- tag('info').log(`Re-running tests from: ${relative(process.cwd(), absPath)}`);
90
+ tag('step').log(`Re-running tests from: ${relative(process.cwd(), absPath)}`);
91
91
  setActivity('🔄 Re-running tests...', 'action');
92
92
 
93
93
  this.healedSteps = [];
@@ -198,7 +198,7 @@ export function WithCoordinates<T extends Constructor>(Base: T) {
198
198
  const eidxWithoutCoords: string[] = [];
199
199
  for (const section of sections) {
200
200
  for (const el of section.elements) {
201
- if (el.eidx && !el.coordinates) eidxWithoutCoords.push(el.eidx);
201
+ if (el.eidx && /^e\d+$/i.test(el.eidx) && !el.coordinates) eidxWithoutCoords.push(el.eidx);
202
202
  }
203
203
  }
204
204
  if (eidxWithoutCoords.length === 0) return;
@@ -24,7 +24,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
24
24
 
25
25
  async performDeepAnalysis(state: WebPageState, result: ResearchResult): Promise<void> {
26
26
  tag('info').log('Starting deep analysis of expandable elements');
27
- await (this as any).navigateTo(state.url);
27
+ await (this as any).navigateTo(state.fullUrl || state.url);
28
28
 
29
29
  let expandables = await this._discoverExpandables(result.text);
30
30
  if (expandables.length === 0) {
@@ -35,7 +35,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
35
35
 
36
36
  const maxClicks = (this.explorer.getConfig().ai?.agents?.researcher as any)?.maxExpandableClicks ?? DEFAULT_MAX_EXPANDABLE_CLICKS;
37
37
  if (expandables.length > maxClicks) {
38
- expandables = await this._selectExpandables(expandables, state.url, maxClicks);
38
+ expandables = await this._selectExpandables(expandables, state.fullUrl || state.url, maxClicks);
39
39
  tag('substep').log(`Selected ${expandables.length} expandables to click (max: ${maxClicks})`);
40
40
  }
41
41
 
@@ -177,7 +177,14 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
177
177
  visionCall = this.provider.processImage(visionPrompt, screenshot.toString('base64'));
178
178
  }
179
179
 
180
- const [textRes, visionRes] = await Promise.all([textCall, visionCall]);
180
+ let textRes: { text?: string } | null = null;
181
+ let visionRes: { text?: string } | null = null;
182
+ try {
183
+ [textRes, visionRes] = await Promise.all([textCall, visionCall]);
184
+ } catch (err) {
185
+ tag('warning').log(`Expandable discovery failed, skipping deep analysis: ${err instanceof Error ? err.message : err}`);
186
+ return [];
187
+ }
181
188
 
182
189
  const eidxSet = new Set<string>();
183
190
  const parseRefs = (text: string | undefined) => {
@@ -244,10 +251,16 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
244
251
  `;
245
252
 
246
253
  const model = this.provider.getModelForAgent('researcher');
247
- const r = await this.provider.chat([{ role: 'user', content: prompt }], model, {
248
- agentName: 'researcher',
249
- telemetryFunctionId: 'researcher.selectExpandables',
250
- });
254
+ let r: { text?: string };
255
+ try {
256
+ r = await this.provider.chat([{ role: 'user', content: prompt }], model, {
257
+ agentName: 'researcher',
258
+ telemetryFunctionId: 'researcher.selectExpandables',
259
+ });
260
+ } catch (err) {
261
+ tag('warning').log(`Expandable selection failed, using first ${maxClicks}: ${err instanceof Error ? err.message : err}`);
262
+ return expandables.slice(0, maxClicks);
263
+ }
251
264
 
252
265
  const nums = (r.text || '').match(/\d+/g)?.map(Number) || [];
253
266
  const selected = expandables.filter((_, i) => nums.includes(i + 1));
@@ -64,6 +64,9 @@ export function mapRowToElement(row: Record<string, string>): ResearchElement |
64
64
 
65
65
  let eidxRaw = (colMap.eidx || '').trim();
66
66
  if (eidxRaw && /^\d+$/.test(eidxRaw)) eidxRaw = `e${eidxRaw}`;
67
+ if (eidxRaw && !/^e\d+$/i.test(eidxRaw)) {
68
+ eidxRaw = '';
69
+ }
67
70
 
68
71
  const aria = parseAriaLocator(colMap.aria || '-');
69
72
 
@@ -121,10 +121,11 @@ export class Researcher extends ResearcherBase implements Agent {
121
121
 
122
122
  const sessionName = `researcher: ${state.url}`;
123
123
  return Observability.run(sessionName, { tags: ['researcher'], sessionId: stateHash }, async () => {
124
- tag('info').log(`Researching ${state.url} to understand the context...`);
124
+ const displayUrl = state.fullUrl || state.url;
125
+ tag('info').log(`Researching ${displayUrl} to understand the context...`);
125
126
  setActivity(`${this.emoji} Researching...`, 'action');
126
127
 
127
- await this.ensureNavigated(state.url, screenshot && this.provider.hasVision());
128
+ await this.ensureNavigated(displayUrl, screenshot && this.provider.hasVision());
128
129
  await this.hooksRunner.runBeforeHook('researcher', state.url);
129
130
 
130
131
  const annotatedElements = await this.explorer.annotateElements();
@@ -150,10 +151,10 @@ export class Researcher extends ResearcherBase implements Agent {
150
151
  if (!deep && !force) {
151
152
  const similar = await findSimilarResearch(combinedHtml);
152
153
  if (similar) {
153
- tag('info').log('Similar research found, reusing cached result');
154
+ tag('substep').log('Similar research found, reusing cached result');
154
155
  if (stateHash) saveResearch(stateHash, similar, combinedHtml);
155
156
  tag('multiline').log(formatResearchSummary(similar));
156
- tag('success').log(`Research complete! ${similar.length} characters (reused)`);
157
+ tag('success').log('Research complete (reused)');
157
158
  await this.hooksRunner.runAfterHook('researcher', state.url);
158
159
  return similar;
159
160
  }
@@ -284,7 +285,11 @@ export class Researcher extends ResearcherBase implements Agent {
284
285
  }
285
286
 
286
287
  if (!interrupted() && deep) {
287
- await this.performDeepAnalysis(state, result);
288
+ try {
289
+ await this.performDeepAnalysis(state, result);
290
+ } catch (err) {
291
+ tag('warning').log(`Deep analysis failed, continuing with best-effort research: ${err instanceof Error ? err.message : err}`);
292
+ }
288
293
  }
289
294
 
290
295
  if (!interrupted() && data) {
@@ -310,7 +315,7 @@ export class Researcher extends ResearcherBase implements Agent {
310
315
  }
311
316
 
312
317
  tag('multiline').log(formatResearchSummary(result.text, { visionUsed: this.hasScreenshotToAnalyze }));
313
- tag('success').log(`Research complete! ${result.text.length} characters`);
318
+ tag('success').log('Research complete');
314
319
  if (researchFile) tag('substep').log(`Research file saved to: ${researchFile}`);
315
320
  if (this.actionResult?.screenshotFile) {
316
321
  const screenshotPath = outputPath('states', this.actionResult.screenshotFile);
@@ -120,6 +120,24 @@ export class SessionAnalyst implements Agent {
120
120
  .slice(-30)
121
121
  .map((entry) => ` - [${entry.type}] ${entry.content}`)
122
122
  .join('\n');
123
+ const checked = test.getCheckedExpectations().join(' | ') || '(none)';
124
+ const remaining = test.getRemainingExpectations().join(' | ') || '(none)';
125
+ const notes = test
126
+ .getPrintableNotes()
127
+ .slice(-12)
128
+ .map((note) => ` - ${note}`)
129
+ .join('\n');
130
+ const visitedUrls = test.getVisitedUrls({ localOnly: true }).join(' | ') || '(none)';
131
+ const verification = test.verification
132
+ ? dedent`
133
+ verification_status: ${test.verification.status || 'unknown'}
134
+ verification_message: ${test.verification.message || '(none)'}
135
+ verification_url: ${test.verification.url || '(none)'}
136
+ verification_page: ${test.verification.pageLabel || '(none)'}
137
+ verification_details:
138
+ ${(test.verification.details.length > 0 ? test.verification.details : ['(none)']).map((detail) => ` - ${detail}`).join('\n')}
139
+ `
140
+ : 'verification_status: none';
123
141
 
124
142
  return dedent`
125
143
  <test ref="#${ref}">
@@ -127,6 +145,12 @@ export class SessionAnalyst implements Agent {
127
145
  scenario: ${test.scenario}
128
146
  result: ${test.result || 'unknown'}
129
147
  expected: ${test.expected.join(' | ') || '(none)'}
148
+ checked_expectations: ${checked}
149
+ remaining_expectations: ${remaining}
150
+ visited_urls: ${visitedUrls}
151
+ ${verification}
152
+ notes:
153
+ ${notes || ' - (none)'}
130
154
  log:
131
155
  ${log}
132
156
  </test>
package/src/ai/tester.ts CHANGED
@@ -118,7 +118,6 @@ export class Tester extends TaskAgent implements Agent {
118
118
  const state = this.explorer.getStateManager().getCurrentState();
119
119
  if (!state) throw new Error('No state found');
120
120
 
121
- tag('info').log(`Testing scenario: ${task.scenario}`);
122
121
  setActivity(`🧪 Testing: ${task.scenario}`, 'action');
123
122
 
124
123
  this.previousUrl = null;
@@ -678,7 +677,6 @@ export class Tester extends TaskAgent implements Agent {
678
677
  if (!task.hasFinished) {
679
678
  task.finish(TestResult.FAILED);
680
679
  }
681
- tag('info').log(`Finished: ${task.scenario}`);
682
680
 
683
681
  if (task.isSuccessful) {
684
682
  tag('success').log(`Successful test: ${task.scenario}`);
@@ -882,7 +880,9 @@ export class Tester extends TaskAgent implements Agent {
882
880
  await this.explorer.switchToMainFrame();
883
881
  }
884
882
 
885
- if (this.explorer.getStateManager().getCurrentState()?.url === resetUrl!) {
883
+ const currentState = this.explorer.getStateManager().getCurrentState();
884
+ const currentUrl = currentState?.fullUrl || currentState?.url;
885
+ if (currentUrl === resetUrl!) {
886
886
  return {
887
887
  success: false,
888
888
  message: 'Reset failed - already on initial page!',
package/src/ai/tools.ts CHANGED
@@ -854,12 +854,13 @@ export function createAgentTools({
854
854
  }),
855
855
  execute: async ({ reason }) => {
856
856
  const stateManager = explorer.getStateManager();
857
- const currentUrl = stateManager.getCurrentState()?.url;
857
+ const currentState = stateManager.getCurrentState();
858
+ const currentUrl = currentState?.fullUrl || currentState?.url;
858
859
  const history = stateManager.getStateHistory();
859
860
 
860
861
  let targetUrl: string | null = null;
861
862
  for (let i = history.length - 1; i >= 0; i--) {
862
- const url = history[i].toState.url;
863
+ const url = history[i].toState.fullUrl || history[i].toState.url;
863
864
  if (url !== currentUrl) {
864
865
  targetUrl = url;
865
866
  break;
@@ -38,6 +38,11 @@ export class ExploreCommand extends BaseCommand {
38
38
  private oldTestRefs = new Set<Test>();
39
39
  private priorityFilter?: Set<string>;
40
40
 
41
+ private getCurrentPageUrl(): string | undefined {
42
+ const state = this.explorBot.getExplorer().getStateManager().getCurrentState();
43
+ return state?.fullUrl || state?.url;
44
+ }
45
+
41
46
  async execute(args: string): Promise<void> {
42
47
  const { opts, args: remaining } = this.parseArgs(args);
43
48
  if (opts.maxTests) {
@@ -51,7 +56,7 @@ export class ExploreCommand extends BaseCommand {
51
56
  if (this.dryRun) tag('info').log('Dry-run mode: planner runs to discover new tests; test execution is skipped');
52
57
  Stats.mode ??= 'explore';
53
58
  Stats.focus ??= feature;
54
- const mainUrl = this.explorBot.getExplorer().getStateManager().getCurrentState()?.url;
59
+ const mainUrl = this.getCurrentPageUrl();
55
60
 
56
61
  if (cfg.enabled) {
57
62
  await this.runReuseMode(mainUrl, feature, cfg);
@@ -7,21 +7,32 @@ import { parseMarkdownToTerminal } from '../utils/markdown-terminal.js';
7
7
 
8
8
  import { Box, Text } from 'ink';
9
9
  import type { LogType, TaggedLogEntry } from '../utils/logger.js';
10
- import { isDebugMode, registerLogPane, setVerboseMode, unregisterLogPane } from '../utils/logger.js';
10
+ import { isDebugMode, registerLogPane, unregisterLogPane } from '../utils/logger.js';
11
11
 
12
12
  // marked.use(new markedTerminal());
13
13
 
14
- type LogEntry = TaggedLogEntry;
14
+ type LogEntry = TaggedLogEntry & { collapsedCount?: number };
15
15
 
16
16
  interface LogPaneProps {
17
17
  verboseMode: boolean;
18
18
  }
19
19
 
20
20
  const LogPane: React.FC<LogPaneProps> = React.memo(({ verboseMode }) => {
21
- const [logs, setLogs] = useState<TaggedLogEntry[]>([]);
22
- const pendingLogsRef = React.useRef<TaggedLogEntry[]>([]);
21
+ const [logs, setLogs] = useState<LogEntry[]>([]);
22
+ const pendingLogsRef = React.useRef<LogEntry[]>([]);
23
23
  const flushTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
24
24
 
25
+ const MAX_MULTILINE_LINES = 16;
26
+ const MAX_STEP_LINES = 8;
27
+ const MAX_SUBSTEP_LINES = 6;
28
+
29
+ const formatCollapsedContent = useCallback((lines: string[], collapsedCount: number, label: string) => {
30
+ if (collapsedCount <= 0) {
31
+ return lines.join('\n');
32
+ }
33
+ return [`... ${collapsedCount} earlier ${label}`, ...lines].join('\n');
34
+ }, []);
35
+
25
36
  const flushLogs = useCallback(() => {
26
37
  if (pendingLogsRef.current.length === 0) return;
27
38
 
@@ -29,7 +40,7 @@ const LogPane: React.FC<LogPaneProps> = React.memo(({ verboseMode }) => {
29
40
  pendingLogsRef.current = [];
30
41
  flushTimeoutRef.current = null;
31
42
 
32
- setLogs((prevLogs: TaggedLogEntry[]) => {
43
+ setLogs((prevLogs: LogEntry[]) => {
33
44
  const result = [...prevLogs];
34
45
 
35
46
  for (const logEntry of newLogs) {
@@ -43,12 +54,35 @@ const LogPane: React.FC<LogPaneProps> = React.memo(({ verboseMode }) => {
43
54
  continue;
44
55
  }
45
56
 
57
+ if ((logEntry.type === 'step' || logEntry.type === 'substep') && lastLog.type === logEntry.type && Math.abs((lastLog.timestamp?.getTime() || 0) - (logEntry.timestamp?.getTime() || 0)) < 1500) {
58
+ const currentLines = String(logEntry.content)
59
+ .split('\n')
60
+ .filter((line) => line.length > 0);
61
+ const previousLines = String(lastLog.content)
62
+ .split('\n')
63
+ .filter((line) => line.length > 0);
64
+ const visiblePreviousLines = lastLog.collapsedCount ? previousLines.slice(1) : previousLines;
65
+ const maxLines = logEntry.type === 'step' ? MAX_STEP_LINES : MAX_SUBSTEP_LINES;
66
+ const mergedLines = [...visiblePreviousLines, ...currentLines];
67
+ const overflow = Math.max(0, mergedLines.length - maxLines);
68
+ const collapsedCount = (lastLog.collapsedCount || 0) + overflow;
69
+ const visibleLines = mergedLines.slice(-maxLines);
70
+ const label = logEntry.type === 'step' ? 'steps' : 'details';
71
+ result[result.length - 1] = {
72
+ ...lastLog,
73
+ content: formatCollapsedContent(visibleLines, collapsedCount, label),
74
+ timestamp: logEntry.timestamp,
75
+ collapsedCount,
76
+ };
77
+ continue;
78
+ }
79
+
46
80
  result.push(logEntry);
47
81
  }
48
82
 
49
83
  return result;
50
84
  });
51
- }, []);
85
+ }, [formatCollapsedContent]);
52
86
 
53
87
  const addLog = useCallback(
54
88
  (logEntry: TaggedLogEntry) => {
@@ -112,12 +146,11 @@ const LogPane: React.FC<LogPaneProps> = React.memo(({ verboseMode }) => {
112
146
  const cleaned = stripAnsi(dedent(log.content));
113
147
  const parsed = parseMarkdownToTerminal(cleaned);
114
148
  const lines = parsed.split('\n');
115
- const maxLines = 30;
116
- const truncated = lines.length > maxLines ? `${lines.slice(0, maxLines).join('\n')}\n... (${lines.length - maxLines} more lines)` : cleaned;
149
+ const truncated = lines.length > MAX_MULTILINE_LINES ? `${lines.slice(0, MAX_MULTILINE_LINES).join('\n')}\n... (${lines.length - MAX_MULTILINE_LINES} more lines)` : parsed;
117
150
  return (
118
151
  <Box key={index} borderStyle="classic" borderLeft={false} borderRight={false} marginY={1} padding={1} borderColor="dim" overflow="hidden">
119
152
  <Text color="gray" dimColor>
120
- {parsed}
153
+ {truncated}
121
154
  </Text>
122
155
  </Box>
123
156
  );
package/src/config.ts CHANGED
@@ -266,6 +266,7 @@ export class ConfigParser {
266
266
  private static instance: ConfigParser;
267
267
  private config: ExplorbotConfig | null = null;
268
268
  private configPath: string | null = null;
269
+ private runtimeBaseUrlOverride: string | null = null;
269
270
 
270
271
  private constructor() {}
271
272
 
@@ -285,8 +286,9 @@ export class ConfigParser {
285
286
  public async loadConfig(options?: {
286
287
  config?: string;
287
288
  path?: string;
289
+ baseUrl?: string;
288
290
  }): Promise<ExplorbotConfig> {
289
- if (this.config && !options?.config && !options?.path) {
291
+ if (this.config && !options?.config && !options?.path && this.runtimeBaseUrlOverride === (options?.baseUrl || null)) {
290
292
  return this.config;
291
293
  }
292
294
 
@@ -317,7 +319,8 @@ export class ConfigParser {
317
319
  throw new Error('Configuration file is empty or invalid');
318
320
  }
319
321
 
320
- this.config = this.resolveConfig(loadedConfig as ExplorbotConfig);
322
+ this.config = this.resolveConfig(loadedConfig as ExplorbotConfig, options);
323
+ this.runtimeBaseUrlOverride = options?.baseUrl || null;
321
324
  this.configPath = resolvedPath;
322
325
 
323
326
  log(`Configuration loaded from: ${resolvedPath}`);
@@ -372,6 +375,7 @@ export class ConfigParser {
372
375
  if (ConfigParser.instance) {
373
376
  ConfigParser.instance.config = null;
374
377
  ConfigParser.instance.configPath = null;
378
+ ConfigParser.instance.runtimeBaseUrlOverride = null;
375
379
  }
376
380
  }
377
381
 
@@ -455,11 +459,17 @@ export class ConfigParser {
455
459
  }
456
460
  }
457
461
 
458
- private resolveConfig(config: ExplorbotConfig): ExplorbotConfig {
462
+ private resolveConfig(config: ExplorbotConfig, options?: { baseUrl?: string }): ExplorbotConfig {
459
463
  if (config.web?.url && !config.playwright?.url) {
460
464
  config.playwright = config.playwright || { browser: 'chromium', url: '' };
461
465
  config.playwright.url = config.web.url;
462
466
  }
467
+
468
+ if (options?.baseUrl) {
469
+ config.playwright = config.playwright || { browser: 'chromium', url: '' };
470
+ config.playwright.url = options.baseUrl;
471
+ }
472
+
463
473
  return config;
464
474
  }
465
475
 
package/src/explorbot.ts CHANGED
@@ -34,6 +34,7 @@ import { sanitizeFilename } from './utils/strings.ts';
34
34
 
35
35
  export interface ExplorBotOptions {
36
36
  from?: string;
37
+ baseUrl?: string;
37
38
  verbose?: boolean;
38
39
  config?: string;
39
40
  path?: string;
package/src/explorer.ts CHANGED
@@ -8,6 +8,7 @@ import { createTest } from 'codeceptjs/lib/mocha/test';
8
8
  import { ActionResult } from './action-result.ts';
9
9
  import Action from './action.js';
10
10
  import { AIProvider } from './ai/provider.js';
11
+ import type { BrowserContextOptions } from 'playwright';
11
12
  import { visuallyAnnotateContainers } from './ai/researcher/coordinates.ts';
12
13
  import { RequestStore } from './api/request-store.ts';
13
14
  import { XhrCapture } from './api/xhr-capture.ts';
@@ -238,7 +239,17 @@ class Explorer {
238
239
  }
239
240
  await this.connectOrLaunchBrowser();
240
241
  const hasSession = this.options?.session && existsSync(this.options.session);
241
- const contextOptions = hasSession ? { storageState: this.options!.session } : undefined;
242
+ const helperOptions = this.playwrightHelper.options || {};
243
+ // CodeceptJS skips _createContextPage when sessions/storageState are involved, so we
244
+ // build contextOptions ourselves. Most keys share a name with Playwright's
245
+ // BrowserContextOptions and are copied as-is; `emulate` must be flattened, `basicAuth`
246
+ // renamed to `httpCredentials`, and `storageState` comes from the --session flag.
247
+ const contextOptions: BrowserContextOptions = {
248
+ ...helperOptions,
249
+ };
250
+ if (helperOptions.emulate) Object.assign(contextOptions, helperOptions.emulate);
251
+ if (helperOptions.basicAuth) contextOptions.httpCredentials = helperOptions.basicAuth;
252
+ if (hasSession) contextOptions.storageState = this.options!.session;
242
253
  await this.playwrightHelper._createContextPage(contextOptions);
243
254
  await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
244
255
  this.setupXhrCapture();
@@ -547,6 +547,10 @@ export class StateManager {
547
547
  }
548
548
 
549
549
  export function normalizeUrl(url: string): string {
550
+ if (url.startsWith('/')) {
551
+ return url.replace(/^\/+/, '').replace(/\/+$/g, '');
552
+ }
553
+
550
554
  try {
551
555
  const parsed = new URL(url, 'http://localhost');
552
556
  const path = parsed.pathname.replace(/^\/+|\/+$/g, '');
@@ -82,10 +82,13 @@ export function matchesUrl(pattern: string, path: string): boolean {
82
82
  }
83
83
 
84
84
  export function extractStatePath(url: string): string {
85
- if (url.startsWith('/')) return url;
85
+ if (url.startsWith('/')) {
86
+ return `/${url.replace(/^\/+/, '')}`;
87
+ }
86
88
  try {
87
89
  const urlObj = new URL(url);
88
- return `${urlObj.pathname}${urlObj.search}${urlObj.hash}`;
90
+ const normalizedPathname = `/${urlObj.pathname.replace(/^\/+/, '')}`;
91
+ return `${normalizedPathname}${urlObj.search}${urlObj.hash}`;
89
92
  } catch {
90
93
  return url;
91
94
  }
@@ -122,7 +122,8 @@ export class WebElement {
122
122
  }
123
123
 
124
124
  static async fromEidxList(page: any, eidxList: string[]): Promise<WebElement[]> {
125
- if (eidxList.length === 0) return [];
125
+ const validEidxList = eidxList.filter((eidx) => /^e\d+$/i.test(eidx));
126
+ if (validEidxList.length === 0) return [];
126
127
 
127
128
  const rawList: RawElementData[] = await page.evaluate(
128
129
  ([list, extractFnStr, config]: [string[], string, ElementExtractionConfig]) => {
@@ -136,7 +137,7 @@ export class WebElement {
136
137
  }
137
138
  return results;
138
139
  },
139
- [eidxList, getElementDataExtractorSource(), ELEMENT_EXTRACTION_CONFIG] as [string[], string, ElementExtractionConfig]
140
+ [validEidxList, getElementDataExtractorSource(), ELEMENT_EXTRACTION_CONFIG] as [string[], string, ElementExtractionConfig]
140
141
  );
141
142
 
142
143
  return rawList.map((d) => WebElement.fromRawData(d));