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
@@ -68,8 +68,48 @@ class Navigator {
68
68
  this.experienceTracker = experienceTracker || new ExperienceTracker();
69
69
  this.hooksRunner = new HooksRunner(explorer, explorer.getConfig());
70
70
  }
71
+ getBaseOrigin() {
72
+ const baseUrl = this.explorer.getConfig().playwright.url;
73
+ try {
74
+ return new URL(baseUrl).origin;
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ getComparableCurrentUrl(stateManager, expectedUrl) {
81
+ const currentState = stateManager.getCurrentState();
82
+ if (!currentState)
83
+ return '';
84
+ const current = /^https?:\/\//i.test(expectedUrl) ? currentState.fullUrl || currentState.url || '' : currentState.url || '';
85
+ return current;
86
+ }
87
+ isSameExpectedOrigin(expectedUrl, stateManager) {
88
+ const currentState = stateManager.getCurrentState();
89
+ if (!currentState)
90
+ return false;
91
+ const currentFullUrl = currentState.fullUrl || currentState.url || '';
92
+ if (!currentFullUrl)
93
+ return false;
94
+ try {
95
+ const currentOrigin = new URL(currentFullUrl).origin;
96
+ if (/^https?:\/\//i.test(expectedUrl)) {
97
+ return currentOrigin === new URL(expectedUrl).origin;
98
+ }
99
+ const baseOrigin = this.getBaseOrigin();
100
+ if (!baseOrigin)
101
+ return true;
102
+ return currentOrigin === baseOrigin;
103
+ }
104
+ catch {
105
+ return !/^https?:\/\//i.test(expectedUrl);
106
+ }
107
+ }
71
108
  isOnExpectedPage(expectedUrl, stateManager) {
72
- const currentUrl = stateManager.getCurrentState()?.url || '';
109
+ if (!this.isSameExpectedOrigin(expectedUrl, stateManager)) {
110
+ return false;
111
+ }
112
+ const currentUrl = this.getComparableCurrentUrl(stateManager, expectedUrl);
73
113
  return normalizeUrl(currentUrl) === normalizeUrl(expectedUrl);
74
114
  }
75
115
  async visit(url) {
@@ -249,7 +289,8 @@ class Navigator {
249
289
  }
250
290
  }
251
291
  const freshState = await action.capturePageState();
252
- const urlMatches = normalizeUrl(freshState.url || '') === normalizeUrl(expectedUrl);
292
+ const currentUrl = /^https?:\/\//i.test(expectedUrl) ? freshState.fullUrl || freshState.url || '' : freshState.url || '';
293
+ const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && normalizeUrl(currentUrl) === normalizeUrl(expectedUrl);
253
294
  const stateChanged = freshState.getStateHash() !== actionResult.getStateHash();
254
295
  resolved = urlMatches && stateChanged;
255
296
  if (!resolved && attemptOk) {
@@ -270,6 +270,9 @@ export class Pilot {
270
270
  overrides the others — weigh them together. Tester's record() notes are the LEAST reliable; always
271
271
  cross-check against actual actions and state. Visual screenshot analysis is strong for UI state
272
272
  (active tabs, visible counts, colors).
273
+ If the final page clearly shows an equivalent success state in a different UI form, do not fail only
274
+ because one narrow assertion targeted a specific badge, count, toast, or wording that the product
275
+ represents differently.
273
276
 
274
277
  SCENARIO TITLE defines what must happen. Action verbs require persisted evidence:
275
278
  - "Create X" → X must exist (visible, redirected to its page, or success message). Opening a form is NOT enough.
@@ -311,6 +314,8 @@ export class Pilot {
311
314
 
312
315
  GUIDANCE (required for "continue"): a specific next action on the current page — which tool, what
313
316
  to verify, how to record. Do not suggest repeating actions that already succeeded.
317
+ If progress is blocked only because the page lacks target data for the scenario, prefer precondition()
318
+ over repeated UI attempts.
314
319
  `;
315
320
  }
316
321
  buildVerdictSystemPrompt(type, task) {
@@ -64,6 +64,9 @@ export class Planner extends PlannerBase {
64
64
  get sectionOrder() {
65
65
  return ConfigParser.getInstance().getConfig().ai?.agents?.researcher?.sections || Object.keys(POSSIBLE_SECTIONS);
66
66
  }
67
+ getDefaultStartUrl(state) {
68
+ return state.fullUrl || state.url;
69
+ }
67
70
  getSystemMessage(feature) {
68
71
  const currentUrl = this.stateManager.getCurrentState()?.url;
69
72
  const customPrompt = this.provider.getSystemPromptForAgent('planner', currentUrl);
@@ -138,7 +141,6 @@ export class Planner extends PlannerBase {
138
141
  }
139
142
  this.freshStart = false;
140
143
  setActivity(`${this.emoji} Planning...`, 'action');
141
- tag('info').log(`Planning test scenarios for ${state.url}`);
142
144
  if (style)
143
145
  tag('info').log(`Planning style: ${style}`);
144
146
  const tags = ['planner'];
@@ -162,7 +164,8 @@ export class Planner extends PlannerBase {
162
164
  if (aiResult.object.scenarios.length === 0 && !this.currentPlan) {
163
165
  throw new Error('No tasks were created successfully');
164
166
  }
165
- const fromPlanning = aiResult.object.scenarios.map((s) => new Test(s.scenario, s.priority, s.expectedOutcomes, s.startUrl || state.url, s.steps || []));
167
+ const defaultStartUrl = this.getDefaultStartUrl(state);
168
+ const fromPlanning = aiResult.object.scenarios.map((s) => new Test(s.scenario, s.priority, s.expectedOutcomes, s.startUrl || defaultStartUrl, s.steps || []));
166
169
  return { tests: fromPlanning, planName: aiResult.object.planName };
167
170
  });
168
171
  const tests = result.tests;
@@ -171,7 +174,8 @@ export class Planner extends PlannerBase {
171
174
  const cached = state.url ? getRegisteredPlan(state.url) : null;
172
175
  const planName = feature || cached?.plan.title || result.planName || state.url;
173
176
  this.currentPlan = new Plan(planName);
174
- this.currentPlan.url = state.url;
177
+ this.currentPlan.url = this.getDefaultStartUrl(state);
178
+ const defaultStartUrl = this.getDefaultStartUrl(state);
175
179
  if (parentPlan)
176
180
  this.currentPlan.parentPlan = parentPlan;
177
181
  const allPreviousScenarios = this.getPreviousSessionScenarios();
@@ -182,14 +186,14 @@ export class Planner extends PlannerBase {
182
186
  if (allPreviousScenarios.has(t.scenario.toLowerCase()))
183
187
  continue;
184
188
  t.style = this.lastStyleName;
185
- t.startUrl = state.url;
189
+ t.startUrl = defaultStartUrl;
186
190
  this.currentPlan.addTest(t);
187
191
  }
188
192
  }
189
193
  else {
190
194
  tag('step').log(`Expanding plan: "${this.currentPlan.title}"`);
191
195
  this.currentPlan.nextIteration();
192
- const newTests = this.addNewTests(tests, state.url);
196
+ const newTests = this.addNewTests(tests, this.getDefaultStartUrl(state));
193
197
  if (newTests.length > 0) {
194
198
  const summary = `New scenarios:\n${newTests.map((t) => `+ [${t.priority}] ${t.scenario}`).join('\n')}`;
195
199
  tag('multiline').log(summary);
@@ -292,6 +296,13 @@ export class Planner extends PlannerBase {
292
296
  Focus on URL page change or data persistency after page reload.
293
297
  If there are subpages (pages with same URL path) plan testing of those subpages as well
294
298
  If you plan to test CRUD operations, plan them in correct order: create, read, update.
299
+ 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.
300
+ 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.
301
+ 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.
302
+ Only propose scenarios whose prerequisites are evident from page research, visited pages, or API data preparation context.
303
+ 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.
304
+ 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.
305
+ Do not assume hidden data exists just because a control is present.
295
306
  DO NOT propose "verification-only" tests that merely open a UI element (modal, dropdown, panel) and check it exists.
296
307
  Every test must complete a meaningful action that changes application state or produces a business outcome.
297
308
  Opening a modal is NOT a test — performing an action INSIDE the modal IS a test.
@@ -516,10 +527,15 @@ export class Planner extends PlannerBase {
516
527
  - Good: "New suite 'My New Suite' appears in the suite list"
517
528
  - Good: "Suite appears under Starred filter tab"
518
529
  - Good: "Success message 'Suite created' is displayed"
530
+ - Good when wording is unknown: "An authentication error is displayed"
531
+ - Good when route is unknown: "The workspace home page is displayed"
519
532
  - Bad: "Modal is displayed" (just verifying existence, no business value)
520
533
  - Bad: "Dropdown menu is visible" (just verifying existence)
534
+ - Bad: "Welcome message is displayed" if no welcome message is visible in research
535
+ - Bad: "Redirected to /dashboard" if no such route was observed
521
536
  - Each outcome should be independently verifiable
522
537
  - Avoid combining multiple checks into one outcome
538
+ - Prefer durable user-facing results over fragile micro-signals
523
539
  - Expected outcomes describe WHAT TO VERIFY
524
540
 
525
541
  FORMATTING RULES:
@@ -67,7 +67,7 @@ export class Rerunner extends TaskAgent {
67
67
  tag('error').log(`Test file not found: ${absPath}`);
68
68
  return { total: 0, passed: 0, failed: 0, healed: 0 };
69
69
  }
70
- tag('info').log(`Re-running tests from: ${relative(process.cwd(), absPath)}`);
70
+ tag('step').log(`Re-running tests from: ${relative(process.cwd(), absPath)}`);
71
71
  setActivity('🔄 Re-running tests...', 'action');
72
72
  this.healedSteps = [];
73
73
  this.setupPlugins();
@@ -182,7 +182,7 @@ export function WithCoordinates(Base) {
182
182
  const eidxWithoutCoords = [];
183
183
  for (const section of sections) {
184
184
  for (const el of section.elements) {
185
- if (el.eidx && !el.coordinates)
185
+ if (el.eidx && /^e\d+$/i.test(el.eidx) && !el.coordinates)
186
186
  eidxWithoutCoords.push(el.eidx);
187
187
  }
188
188
  }
@@ -12,7 +12,7 @@ export function WithDeepAnalysis(Base) {
12
12
  return class extends Base {
13
13
  async performDeepAnalysis(state, result) {
14
14
  tag('info').log('Starting deep analysis of expandable elements');
15
- await this.navigateTo(state.url);
15
+ await this.navigateTo(state.fullUrl || state.url);
16
16
  let expandables = await this._discoverExpandables(result.text);
17
17
  if (expandables.length === 0) {
18
18
  tag('info').log('No expandable elements identified by AI');
@@ -21,7 +21,7 @@ export function WithDeepAnalysis(Base) {
21
21
  tag('substep').log(`Identified ${expandables.length} expandable elements`);
22
22
  const maxClicks = this.explorer.getConfig().ai?.agents?.researcher?.maxExpandableClicks ?? DEFAULT_MAX_EXPANDABLE_CLICKS;
23
23
  if (expandables.length > maxClicks) {
24
- expandables = await this._selectExpandables(expandables, state.url, maxClicks);
24
+ expandables = await this._selectExpandables(expandables, state.fullUrl || state.url, maxClicks);
25
25
  tag('substep').log(`Selected ${expandables.length} expandables to click (max: ${maxClicks})`);
26
26
  }
27
27
  const elements = expandables
@@ -144,7 +144,15 @@ export function WithDeepAnalysis(Base) {
144
144
  `;
145
145
  visionCall = this.provider.processImage(visionPrompt, screenshot.toString('base64'));
146
146
  }
147
- const [textRes, visionRes] = await Promise.all([textCall, visionCall]);
147
+ let textRes = null;
148
+ let visionRes = null;
149
+ try {
150
+ [textRes, visionRes] = await Promise.all([textCall, visionCall]);
151
+ }
152
+ catch (err) {
153
+ tag('warning').log(`Expandable discovery failed, skipping deep analysis: ${err instanceof Error ? err.message : err}`);
154
+ return [];
155
+ }
148
156
  const eidxSet = new Set();
149
157
  const parseRefs = (text) => {
150
158
  if (!text)
@@ -204,10 +212,17 @@ export function WithDeepAnalysis(Base) {
204
212
  - Respond with comma-separated numbers to keep, e.g.: 1, 3, 5
205
213
  `;
206
214
  const model = this.provider.getModelForAgent('researcher');
207
- const r = await this.provider.chat([{ role: 'user', content: prompt }], model, {
208
- agentName: 'researcher',
209
- telemetryFunctionId: 'researcher.selectExpandables',
210
- });
215
+ let r;
216
+ try {
217
+ r = await this.provider.chat([{ role: 'user', content: prompt }], model, {
218
+ agentName: 'researcher',
219
+ telemetryFunctionId: 'researcher.selectExpandables',
220
+ });
221
+ }
222
+ catch (err) {
223
+ tag('warning').log(`Expandable selection failed, using first ${maxClicks}: ${err instanceof Error ? err.message : err}`);
224
+ return expandables.slice(0, maxClicks);
225
+ }
211
226
  const nums = (r.text || '').match(/\d+/g)?.map(Number) || [];
212
227
  const selected = expandables.filter((_, i) => nums.includes(i + 1));
213
228
  return selected.length > 0 ? selected.slice(0, maxClicks) : expandables.slice(0, maxClicks);
@@ -40,6 +40,9 @@ export function mapRowToElement(row) {
40
40
  let eidxRaw = (colMap.eidx || '').trim();
41
41
  if (eidxRaw && /^\d+$/.test(eidxRaw))
42
42
  eidxRaw = `e${eidxRaw}`;
43
+ if (eidxRaw && !/^e\d+$/i.test(eidxRaw)) {
44
+ eidxRaw = '';
45
+ }
43
46
  const aria = parseAriaLocator(colMap.aria || '-');
44
47
  return {
45
48
  name,
@@ -90,9 +90,10 @@ export class Researcher extends ResearcherBase {
90
90
  Stats.researches++;
91
91
  const sessionName = `researcher: ${state.url}`;
92
92
  return Observability.run(sessionName, { tags: ['researcher'], sessionId: stateHash }, async () => {
93
- tag('info').log(`Researching ${state.url} to understand the context...`);
93
+ const displayUrl = state.fullUrl || state.url;
94
+ tag('info').log(`Researching ${displayUrl} to understand the context...`);
94
95
  setActivity(`${this.emoji} Researching...`, 'action');
95
- await this.ensureNavigated(state.url, screenshot && this.provider.hasVision());
96
+ await this.ensureNavigated(displayUrl, screenshot && this.provider.hasVision());
96
97
  await this.hooksRunner.runBeforeHook('researcher', state.url);
97
98
  const annotatedElements = await this.explorer.annotateElements();
98
99
  debugLog(`Annotated ${annotatedElements.length} interactive elements with eidx`);
@@ -113,11 +114,11 @@ export class Researcher extends ResearcherBase {
113
114
  if (!deep && !force) {
114
115
  const similar = await findSimilarResearch(combinedHtml);
115
116
  if (similar) {
116
- tag('info').log('Similar research found, reusing cached result');
117
+ tag('substep').log('Similar research found, reusing cached result');
117
118
  if (stateHash)
118
119
  saveResearch(stateHash, similar, combinedHtml);
119
120
  tag('multiline').log(formatResearchSummary(similar));
120
- tag('success').log(`Research complete! ${similar.length} characters (reused)`);
121
+ tag('success').log('Research complete (reused)');
121
122
  await this.hooksRunner.runAfterHook('researcher', state.url);
122
123
  return similar;
123
124
  }
@@ -235,7 +236,12 @@ export class Researcher extends ResearcherBase {
235
236
  markSectionAsFocused(result, fallback);
236
237
  }
237
238
  if (!interrupted() && deep) {
238
- await this.performDeepAnalysis(state, result);
239
+ try {
240
+ await this.performDeepAnalysis(state, result);
241
+ }
242
+ catch (err) {
243
+ tag('warning').log(`Deep analysis failed, continuing with best-effort research: ${err instanceof Error ? err.message : err}`);
244
+ }
239
245
  }
240
246
  if (!interrupted() && data) {
241
247
  const extractedData = await this.extractData(state);
@@ -256,7 +262,7 @@ export class Researcher extends ResearcherBase {
256
262
  this.experienceTracker.updateSummary(this.actionResult, summaryLine);
257
263
  }
258
264
  tag('multiline').log(formatResearchSummary(result.text, { visionUsed: this.hasScreenshotToAnalyze }));
259
- tag('success').log(`Research complete! ${result.text.length} characters`);
265
+ tag('success').log('Research complete');
260
266
  if (researchFile)
261
267
  tag('substep').log(`Research file saved to: ${researchFile}`);
262
268
  if (this.actionResult?.screenshotFile) {
@@ -105,12 +105,36 @@ export class SessionAnalyst {
105
105
  .slice(-30)
106
106
  .map((entry) => ` - [${entry.type}] ${entry.content}`)
107
107
  .join('\n');
108
+ const checked = test.getCheckedExpectations().join(' | ') || '(none)';
109
+ const remaining = test.getRemainingExpectations().join(' | ') || '(none)';
110
+ const notes = test
111
+ .getPrintableNotes()
112
+ .slice(-12)
113
+ .map((note) => ` - ${note}`)
114
+ .join('\n');
115
+ const visitedUrls = test.getVisitedUrls({ localOnly: true }).join(' | ') || '(none)';
116
+ const verification = test.verification
117
+ ? dedent `
118
+ verification_status: ${test.verification.status || 'unknown'}
119
+ verification_message: ${test.verification.message || '(none)'}
120
+ verification_url: ${test.verification.url || '(none)'}
121
+ verification_page: ${test.verification.pageLabel || '(none)'}
122
+ verification_details:
123
+ ${(test.verification.details.length > 0 ? test.verification.details : ['(none)']).map((detail) => ` - ${detail}`).join('\n')}
124
+ `
125
+ : 'verification_status: none';
108
126
  return dedent `
109
127
  <test ref="#${ref}">
110
128
  url: ${test.startUrl || '/'}
111
129
  scenario: ${test.scenario}
112
130
  result: ${test.result || 'unknown'}
113
131
  expected: ${test.expected.join(' | ') || '(none)'}
132
+ checked_expectations: ${checked}
133
+ remaining_expectations: ${remaining}
134
+ visited_urls: ${visitedUrls}
135
+ ${verification}
136
+ notes:
137
+ ${notes || ' - (none)'}
114
138
  log:
115
139
  ${log}
116
140
  </test>
@@ -92,7 +92,6 @@ export class Tester extends TaskAgent {
92
92
  const state = this.explorer.getStateManager().getCurrentState();
93
93
  if (!state)
94
94
  throw new Error('No state found');
95
- tag('info').log(`Testing scenario: ${task.scenario}`);
96
95
  setActivity(`🧪 Testing: ${task.scenario}`, 'action');
97
96
  this.previousUrl = null;
98
97
  this.previousStateHash = null;
@@ -595,7 +594,6 @@ export class Tester extends TaskAgent {
595
594
  if (!task.hasFinished) {
596
595
  task.finish(TestResult.FAILED);
597
596
  }
598
- tag('info').log(`Finished: ${task.scenario}`);
599
597
  if (task.isSuccessful) {
600
598
  tag('success').log(`Successful test: ${task.scenario}`);
601
599
  }
@@ -792,7 +790,9 @@ export class Tester extends TaskAgent {
792
790
  if (this.getCurrentState().isInsideIframe) {
793
791
  await this.explorer.switchToMainFrame();
794
792
  }
795
- if (this.explorer.getStateManager().getCurrentState()?.url === resetUrl) {
793
+ const currentState = this.explorer.getStateManager().getCurrentState();
794
+ const currentUrl = currentState?.fullUrl || currentState?.url;
795
+ if (currentUrl === resetUrl) {
796
796
  return {
797
797
  success: false,
798
798
  message: 'Reset failed - already on initial page!',
@@ -731,11 +731,12 @@ export function createAgentTools({ explorer, researcher, navigator, experienceTr
731
731
  }),
732
732
  execute: async ({ reason }) => {
733
733
  const stateManager = explorer.getStateManager();
734
- const currentUrl = stateManager.getCurrentState()?.url;
734
+ const currentState = stateManager.getCurrentState();
735
+ const currentUrl = currentState?.fullUrl || currentState?.url;
735
736
  const history = stateManager.getStateHistory();
736
737
  let targetUrl = null;
737
738
  for (let i = history.length - 1; i >= 0; i--) {
738
- const url = history[i].toState.url;
739
+ const url = history[i].toState.fullUrl || history[i].toState.url;
739
740
  if (url !== currentUrl) {
740
741
  targetUrl = url;
741
742
  break;
@@ -34,6 +34,10 @@ export class ExploreCommand extends BaseCommand {
34
34
  failedSubPages = new Set();
35
35
  oldTestRefs = new Set();
36
36
  priorityFilter;
37
+ getCurrentPageUrl() {
38
+ const state = this.explorBot.getExplorer().getStateManager().getCurrentState();
39
+ return state?.fullUrl || state?.url;
40
+ }
37
41
  async execute(args) {
38
42
  const { opts, args: remaining } = this.parseArgs(args);
39
43
  if (opts.maxTests) {
@@ -49,7 +53,7 @@ export class ExploreCommand extends BaseCommand {
49
53
  tag('info').log('Dry-run mode: planner runs to discover new tests; test execution is skipped');
50
54
  Stats.mode ??= 'explore';
51
55
  Stats.focus ??= feature;
52
- const mainUrl = this.explorBot.getExplorer().getStateManager().getCurrentState()?.url;
56
+ const mainUrl = this.getCurrentPageUrl();
53
57
  if (cfg.enabled) {
54
58
  await this.runReuseMode(mainUrl, feature, cfg);
55
59
  }
@@ -10,6 +10,15 @@ const LogPane = React.memo(({ verboseMode }) => {
10
10
  const [logs, setLogs] = useState([]);
11
11
  const pendingLogsRef = React.useRef([]);
12
12
  const flushTimeoutRef = React.useRef(null);
13
+ const MAX_MULTILINE_LINES = 16;
14
+ const MAX_STEP_LINES = 8;
15
+ const MAX_SUBSTEP_LINES = 6;
16
+ const formatCollapsedContent = useCallback((lines, collapsedCount, label) => {
17
+ if (collapsedCount <= 0) {
18
+ return lines.join('\n');
19
+ }
20
+ return [`... ${collapsedCount} earlier ${label}`, ...lines].join('\n');
21
+ }, []);
13
22
  const flushLogs = useCallback(() => {
14
23
  if (pendingLogsRef.current.length === 0)
15
24
  return;
@@ -27,11 +36,33 @@ const LogPane = React.memo(({ verboseMode }) => {
27
36
  if (lastLog.type === logEntry.type && lastLog.content === logEntry.content && Math.abs((lastLog.timestamp?.getTime() || 0) - (logEntry.timestamp?.getTime() || 0)) < 1000) {
28
37
  continue;
29
38
  }
39
+ if ((logEntry.type === 'step' || logEntry.type === 'substep') && lastLog.type === logEntry.type && Math.abs((lastLog.timestamp?.getTime() || 0) - (logEntry.timestamp?.getTime() || 0)) < 1500) {
40
+ const currentLines = String(logEntry.content)
41
+ .split('\n')
42
+ .filter((line) => line.length > 0);
43
+ const previousLines = String(lastLog.content)
44
+ .split('\n')
45
+ .filter((line) => line.length > 0);
46
+ const visiblePreviousLines = lastLog.collapsedCount ? previousLines.slice(1) : previousLines;
47
+ const maxLines = logEntry.type === 'step' ? MAX_STEP_LINES : MAX_SUBSTEP_LINES;
48
+ const mergedLines = [...visiblePreviousLines, ...currentLines];
49
+ const overflow = Math.max(0, mergedLines.length - maxLines);
50
+ const collapsedCount = (lastLog.collapsedCount || 0) + overflow;
51
+ const visibleLines = mergedLines.slice(-maxLines);
52
+ const label = logEntry.type === 'step' ? 'steps' : 'details';
53
+ result[result.length - 1] = {
54
+ ...lastLog,
55
+ content: formatCollapsedContent(visibleLines, collapsedCount, label),
56
+ timestamp: logEntry.timestamp,
57
+ collapsedCount,
58
+ };
59
+ continue;
60
+ }
30
61
  result.push(logEntry);
31
62
  }
32
63
  return result;
33
64
  });
34
- }, []);
65
+ }, [formatCollapsedContent]);
35
66
  const addLog = useCallback((logEntry) => {
36
67
  pendingLogsRef.current.push(logEntry);
37
68
  if (!flushTimeoutRef.current) {
@@ -85,10 +116,9 @@ const LogPane = React.memo(({ verboseMode }) => {
85
116
  const cleaned = stripAnsi(dedent(log.content));
86
117
  const parsed = parseMarkdownToTerminal(cleaned);
87
118
  const lines = parsed.split('\n');
88
- const maxLines = 30;
89
- const truncated = lines.length > maxLines ? `${lines.slice(0, maxLines).join('\n')}\n... (${lines.length - maxLines} more lines)` : cleaned;
119
+ const truncated = lines.length > MAX_MULTILINE_LINES ? `${lines.slice(0, MAX_MULTILINE_LINES).join('\n')}\n... (${lines.length - MAX_MULTILINE_LINES} more lines)` : parsed;
90
120
  return (React.createElement(Box, { key: index, borderStyle: "classic", borderLeft: false, borderRight: false, marginY: 1, padding: 1, borderColor: "dim", overflow: "hidden" },
91
- React.createElement(Text, { color: "gray", dimColor: true }, parsed)));
121
+ React.createElement(Text, { color: "gray", dimColor: true }, truncated)));
92
122
  }
93
123
  if (log.type === 'html') {
94
124
  // Convert HTML to markdown, then render as multiline
@@ -24,6 +24,7 @@ export class ConfigParser {
24
24
  static instance;
25
25
  config = null;
26
26
  configPath = null;
27
+ runtimeBaseUrlOverride = null;
27
28
  constructor() { }
28
29
  static loadEnv(filePath) {
29
30
  const resolved = resolve(filePath);
@@ -38,7 +39,7 @@ export class ConfigParser {
38
39
  return ConfigParser.instance;
39
40
  }
40
41
  async loadConfig(options) {
41
- if (this.config && !options?.config && !options?.path) {
42
+ if (this.config && !options?.config && !options?.path && this.runtimeBaseUrlOverride === (options?.baseUrl || null)) {
42
43
  return this.config;
43
44
  }
44
45
  // Store the initial working directory for reference
@@ -61,7 +62,8 @@ export class ConfigParser {
61
62
  if (!loadedConfig) {
62
63
  throw new Error('Configuration file is empty or invalid');
63
64
  }
64
- this.config = this.resolveConfig(loadedConfig);
65
+ this.config = this.resolveConfig(loadedConfig, options);
66
+ this.runtimeBaseUrlOverride = options?.baseUrl || null;
65
67
  this.configPath = resolvedPath;
66
68
  log(`Configuration loaded from: ${resolvedPath}`);
67
69
  // Restore original directory after successful config load
@@ -108,6 +110,7 @@ export class ConfigParser {
108
110
  if (ConfigParser.instance) {
109
111
  ConfigParser.instance.config = null;
110
112
  ConfigParser.instance.configPath = null;
113
+ ConfigParser.instance.runtimeBaseUrlOverride = null;
111
114
  }
112
115
  }
113
116
  // For testing purposes only - sets up minimal default config
@@ -185,11 +188,15 @@ export class ConfigParser {
185
188
  return JSON.parse(content);
186
189
  }
187
190
  }
188
- resolveConfig(config) {
191
+ resolveConfig(config, options) {
189
192
  if (config.web?.url && !config.playwright?.url) {
190
193
  config.playwright = config.playwright || { browser: 'chromium', url: '' };
191
194
  config.playwright.url = config.web.url;
192
195
  }
196
+ if (options?.baseUrl) {
197
+ config.playwright = config.playwright || { browser: 'chromium', url: '' };
198
+ config.playwright.url = options.baseUrl;
199
+ }
193
200
  return config;
194
201
  }
195
202
  validateConfig(config) {
@@ -189,7 +189,20 @@ class Explorer {
189
189
  }
190
190
  await this.connectOrLaunchBrowser();
191
191
  const hasSession = this.options?.session && existsSync(this.options.session);
192
- const contextOptions = hasSession ? { storageState: this.options.session } : undefined;
192
+ const helperOptions = this.playwrightHelper.options || {};
193
+ // CodeceptJS skips _createContextPage when sessions/storageState are involved, so we
194
+ // build contextOptions ourselves. Most keys share a name with Playwright's
195
+ // BrowserContextOptions and are copied as-is; `emulate` must be flattened, `basicAuth`
196
+ // renamed to `httpCredentials`, and `storageState` comes from the --session flag.
197
+ const contextOptions = {
198
+ ...helperOptions,
199
+ };
200
+ if (helperOptions.emulate)
201
+ Object.assign(contextOptions, helperOptions.emulate);
202
+ if (helperOptions.basicAuth)
203
+ contextOptions.httpCredentials = helperOptions.basicAuth;
204
+ if (hasSession)
205
+ contextOptions.storageState = this.options.session;
193
206
  await this.playwrightHelper._createContextPage(contextOptions);
194
207
  await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
195
208
  this.setupXhrCapture();
@@ -416,6 +416,9 @@ export class StateManager {
416
416
  }
417
417
  }
418
418
  export function normalizeUrl(url) {
419
+ if (url.startsWith('/')) {
420
+ return url.replace(/^\/+/, '').replace(/\/+$/g, '');
421
+ }
419
422
  try {
420
423
  const parsed = new URL(url, 'http://localhost');
421
424
  const path = parsed.pathname.replace(/^\/+|\/+$/g, '');
@@ -90,11 +90,13 @@ export function matchesUrl(pattern, path) {
90
90
  }
91
91
  }
92
92
  export function extractStatePath(url) {
93
- if (url.startsWith('/'))
94
- return url;
93
+ if (url.startsWith('/')) {
94
+ return `/${url.replace(/^\/+/, '')}`;
95
+ }
95
96
  try {
96
97
  const urlObj = new URL(url);
97
- return `${urlObj.pathname}${urlObj.search}${urlObj.hash}`;
98
+ const normalizedPathname = `/${urlObj.pathname.replace(/^\/+/, '')}`;
99
+ return `${normalizedPathname}${urlObj.search}${urlObj.hash}`;
98
100
  }
99
101
  catch {
100
102
  return url;
@@ -109,7 +109,8 @@ export class WebElement {
109
109
  return WebElement.fromPlaywrightLocator(page.locator(`[${EXPLORBOT_ATTRS.eidx}="${eidx}"]`));
110
110
  }
111
111
  static async fromEidxList(page, eidxList) {
112
- if (eidxList.length === 0)
112
+ const validEidxList = eidxList.filter((eidx) => /^e\d+$/i.test(eidx));
113
+ if (validEidxList.length === 0)
113
114
  return [];
114
115
  const rawList = await page.evaluate(([list, extractFnStr, config]) => {
115
116
  const extract = new Function(`return ${extractFnStr}`)();
@@ -123,7 +124,7 @@ export class WebElement {
123
124
  results.push(data);
124
125
  }
125
126
  return results;
126
- }, [eidxList, getElementDataExtractorSource(), ELEMENT_EXTRACTION_CONFIG]);
127
+ }, [validEidxList, getElementDataExtractorSource(), ELEMENT_EXTRACTION_CONFIG]);
127
128
  return rawList.map((d) => WebElement.fromRawData(d));
128
129
  }
129
130
  static async findByXPath(html, xpath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -20,6 +20,9 @@
20
20
  "src/**/*.tsx",
21
21
  "bin/**/*.ts",
22
22
  "boat/api-tester/src/**/*.ts",
23
+ "boat/doc-collector/src/**/*.ts",
24
+ "boat/doc-collector/bin/**/*.ts",
25
+ "boat/doc-collector/package.json",
23
26
  "rules/",
24
27
  "assets/sample-files/"
25
28
  ],
package/src/action.ts CHANGED
@@ -2,7 +2,6 @@ import fs from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { faker } from '@faker-js/faker';
4
4
  import { context, trace } from '@opentelemetry/api';
5
- import { highlight } from 'cli-highlight';
6
5
  import { container, recorder } from 'codeceptjs';
7
6
  import * as codeceptjs from 'codeceptjs';
8
7
  import { hopeThat, retryTo, tryTo, within } from 'codeceptjs/lib/effects';
@@ -21,7 +20,7 @@ import type { PlaywrightRecorder } from './playwright-recorder.ts';
21
20
  import type { StateManager } from './state-manager.js';
22
21
  import { extractCodeBlocks } from './utils/code-extractor.js';
23
22
  import { htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
24
- import { createDebug, log, setStepSpanParent, tag } from './utils/logger.js';
23
+ import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
25
24
  import { safeFilename } from './utils/strings.ts';
26
25
  import { throttle } from './utils/throttle.ts';
27
26
 
@@ -296,7 +295,13 @@ class Action {
296
295
  async expect(codeOrFunction: string | ((I: CodeceptJS.I) => void)): Promise<Action> {
297
296
  const codeString = typeof codeOrFunction === 'string' ? codeOrFunction : codeOrFunction.toString();
298
297
  this.expectation = codeString.toString();
299
- log('Expecting', highlight(codeString, { language: 'javascript' }));
298
+ const expectationPreview = sanitizeCodeBlock(codeString)
299
+ .split('\n')
300
+ .map((line) => line.trim())
301
+ .filter(Boolean)
302
+ .slice(0, 2)
303
+ .join(' ');
304
+ tag('step').log(`Expecting: ${expectationPreview || 'assertion'}`);
300
305
  try {
301
306
  debugLog('Executing expectation:', codeString);
302
307