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/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('
|
|
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();
|
package/src/ai/navigator.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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:
|
package/src/ai/rerunner.ts
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
package/src/ai/researcher.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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('
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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.
|
|
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,
|
|
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<
|
|
22
|
-
const pendingLogsRef = React.useRef<
|
|
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:
|
|
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
|
|
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
|
-
{
|
|
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
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
|
|
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();
|
package/src/state-manager.ts
CHANGED
|
@@ -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, '');
|
package/src/utils/url-matcher.ts
CHANGED
|
@@ -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('/'))
|
|
85
|
+
if (url.startsWith('/')) {
|
|
86
|
+
return `/${url.replace(/^\/+/, '')}`;
|
|
87
|
+
}
|
|
86
88
|
try {
|
|
87
89
|
const urlObj = new URL(url);
|
|
88
|
-
|
|
90
|
+
const normalizedPathname = `/${urlObj.pathname.replace(/^\/+/, '')}`;
|
|
91
|
+
return `${normalizedPathname}${urlObj.search}${urlObj.hash}`;
|
|
89
92
|
} catch {
|
|
90
93
|
return url;
|
|
91
94
|
}
|
package/src/utils/web-element.ts
CHANGED
|
@@ -122,7 +122,8 @@ export class WebElement {
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
static async fromEidxList(page: any, eidxList: string[]): Promise<WebElement[]> {
|
|
125
|
-
|
|
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
|
-
[
|
|
140
|
+
[validEidxList, getElementDataExtractorSource(), ELEMENT_EXTRACTION_CONFIG] as [string[], string, ElementExtractionConfig]
|
|
140
141
|
);
|
|
141
142
|
|
|
142
143
|
return rawList.map((d) => WebElement.fromRawData(d));
|