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.
- package/bin/explorbot-cli.ts +5 -1
- package/boat/doc-collector/bin/doc-collector-cli.ts +5 -0
- package/boat/doc-collector/package.json +24 -0
- package/boat/doc-collector/src/ai/documentarian.ts +184 -0
- package/boat/doc-collector/src/cli.ts +119 -0
- package/boat/doc-collector/src/config.ts +162 -0
- package/boat/doc-collector/src/docbot.ts +391 -0
- package/boat/doc-collector/src/docs-renderer.ts +187 -0
- package/boat/doc-collector/src/path-filter.ts +46 -0
- package/boat/doc-collector/src/research-navigation.ts +90 -0
- package/dist/bin/explorbot-cli.js +4 -1
- package/dist/boat/doc-collector/bin/doc-collector-cli.js +4 -0
- package/dist/boat/doc-collector/src/ai/documentarian.js +157 -0
- package/dist/boat/doc-collector/src/cli.js +104 -0
- package/dist/boat/doc-collector/src/config.js +129 -0
- package/dist/boat/doc-collector/src/docbot.js +326 -0
- package/dist/boat/doc-collector/src/docs-renderer.js +141 -0
- package/dist/boat/doc-collector/src/path-filter.js +35 -0
- package/dist/boat/doc-collector/src/research-navigation.js +71 -0
- package/dist/package.json +4 -1
- package/dist/src/action.js +8 -3
- package/dist/src/ai/driller.js +1 -1
- package/dist/src/ai/navigator.js +43 -2
- package/dist/src/ai/pilot.js +5 -0
- package/dist/src/ai/planner.js +21 -5
- package/dist/src/ai/rerunner.js +1 -1
- package/dist/src/ai/researcher/coordinates.js +1 -1
- package/dist/src/ai/researcher/deep-analysis.js +22 -7
- package/dist/src/ai/researcher/parser.js +3 -0
- package/dist/src/ai/researcher.js +12 -6
- package/dist/src/ai/session-analyst.js +24 -0
- package/dist/src/ai/tester.js +3 -3
- package/dist/src/ai/tools.js +3 -2
- package/dist/src/commands/explore-command.js +5 -1
- package/dist/src/components/LogPane.js +34 -4
- package/dist/src/config.js +10 -3
- package/dist/src/explorer.js +14 -1
- package/dist/src/state-manager.js +3 -0
- package/dist/src/utils/url-matcher.js +5 -3
- package/dist/src/utils/web-element.js +3 -2
- package/package.json +4 -1
- package/src/action.ts +8 -3
- package/src/ai/driller.ts +1 -1
- package/src/ai/navigator.ts +43 -2
- package/src/ai/pilot.ts +5 -0
- package/src/ai/planner.ts +22 -5
- package/src/ai/rerunner.ts +1 -1
- package/src/ai/researcher/coordinates.ts +1 -1
- package/src/ai/researcher/deep-analysis.ts +20 -7
- package/src/ai/researcher/parser.ts +3 -0
- package/src/ai/researcher.ts +11 -6
- package/src/ai/session-analyst.ts +24 -0
- package/src/ai/tester.ts +3 -3
- package/src/ai/tools.ts +3 -2
- package/src/commands/explore-command.ts +6 -1
- package/src/components/LogPane.tsx +42 -9
- package/src/config.ts +13 -3
- package/src/explorbot.ts +1 -0
- package/src/explorer.ts +12 -1
- package/src/state-manager.ts +4 -0
- package/src/utils/url-matcher.ts +5 -2
- package/src/utils/web-element.ts +3 -2
package/dist/src/ai/navigator.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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) {
|
package/dist/src/ai/pilot.js
CHANGED
|
@@ -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) {
|
package/dist/src/ai/planner.js
CHANGED
|
@@ -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
|
|
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
|
|
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 =
|
|
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
|
|
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:
|
package/dist/src/ai/rerunner.js
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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(
|
|
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('
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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>
|
package/dist/src/ai/tester.js
CHANGED
|
@@ -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
|
-
|
|
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!',
|
package/dist/src/ai/tools.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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 },
|
|
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
|
package/dist/src/config.js
CHANGED
|
@@ -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) {
|
package/dist/src/explorer.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
|