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