explorbot 0.5.2 → 0.5.3
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/dist/bin/explorbot-cli.js +2 -0
- package/dist/package.json +1 -1
- package/dist/src/action.js +2 -0
- package/dist/src/ai/historian/codeceptjs.js +2 -0
- package/dist/src/ai/judge-tool.js +5 -1
- package/dist/src/ai/pilot.js +3 -0
- package/dist/src/ai/rerunner.js +3 -1
- package/dist/src/ai/rules.js +1 -1
- package/dist/src/ai/tester.js +11 -0
- package/dist/src/ai/tools.js +17 -6
- package/dist/src/config.d.ts +10 -3
- package/dist/src/config.js +42 -6
- package/dist/src/explorbot.js +4 -0
- 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/utils/test-files.d.ts +5 -0
- package/dist/src/utils/test-files.js +28 -1
- package/docs/reference/commands.md +12 -0
- package/package.json +1 -1
- package/src/action.ts +2 -0
- package/src/ai/historian/codeceptjs.ts +2 -0
- package/src/ai/judge-tool.ts +5 -1
- package/src/ai/pilot.ts +3 -0
- package/src/ai/rerunner.ts +3 -1
- package/src/ai/rules.ts +1 -1
- package/src/ai/tester.ts +12 -0
- package/src/ai/tools.ts +16 -11
- package/src/config.ts +48 -6
- package/src/explorbot.ts +2 -0
- package/src/explorer.ts +37 -2
- package/src/global-config.ts +1 -1
- package/src/utils/test-files.ts +27 -1
package/bin/explorbot-cli.ts
CHANGED
|
@@ -56,6 +56,7 @@ interface CLIOptions {
|
|
|
56
56
|
debug?: boolean;
|
|
57
57
|
config?: string;
|
|
58
58
|
path?: string;
|
|
59
|
+
baseUrl?: string;
|
|
59
60
|
show?: boolean;
|
|
60
61
|
headless?: boolean;
|
|
61
62
|
incognito?: boolean;
|
|
@@ -66,6 +67,7 @@ interface CLIOptions {
|
|
|
66
67
|
function buildExplorBotOptions(from: string | undefined, options: CLIOptions): ExplorBotOptions {
|
|
67
68
|
return {
|
|
68
69
|
from,
|
|
70
|
+
baseUrl: options.baseUrl,
|
|
69
71
|
verbose: options.verbose || options.debug,
|
|
70
72
|
config: options.config,
|
|
71
73
|
path: options.path,
|
|
@@ -83,6 +85,7 @@ function addCommonOptions(cmd: Command): Command {
|
|
|
83
85
|
.option('--debug', 'Enable debug logging (same as --verbose)')
|
|
84
86
|
.option('-c, --config <path>', 'Path to configuration file')
|
|
85
87
|
.option('-p, --path <path>', 'Working directory path')
|
|
88
|
+
.option('--base-url <url>', 'Run against this URL: its path scopes relative paths (plans, commands, saved tests on rerun), its query params ride along with every page load')
|
|
86
89
|
.option('-s, --show', 'Show browser window')
|
|
87
90
|
.option('--headless', 'Run browser in headless mode')
|
|
88
91
|
.option('--incognito', 'Run without recording experiences')
|
|
@@ -46,6 +46,7 @@ if (!process.env.EXPLORBOT_NO_BANNER && !process.argv.includes('prima') && !prin
|
|
|
46
46
|
function buildExplorBotOptions(from, options) {
|
|
47
47
|
return {
|
|
48
48
|
from,
|
|
49
|
+
baseUrl: options.baseUrl,
|
|
49
50
|
verbose: options.verbose || options.debug,
|
|
50
51
|
config: options.config,
|
|
51
52
|
path: options.path,
|
|
@@ -62,6 +63,7 @@ function addCommonOptions(cmd) {
|
|
|
62
63
|
.option('--debug', 'Enable debug logging (same as --verbose)')
|
|
63
64
|
.option('-c, --config <path>', 'Path to configuration file')
|
|
64
65
|
.option('-p, --path <path>', 'Working directory path')
|
|
66
|
+
.option('--base-url <url>', 'Run against this URL: its path scopes relative paths (plans, commands, saved tests on rerun), its query params ride along with every page load')
|
|
65
67
|
.option('-s, --show', 'Show browser window')
|
|
66
68
|
.option('--headless', 'Run browser in headless mode')
|
|
67
69
|
.option('--incognito', 'Run without recording experiences')
|
package/dist/package.json
CHANGED
package/dist/src/action.js
CHANGED
|
@@ -105,6 +105,7 @@ class Action {
|
|
|
105
105
|
if (includeScreenshot) {
|
|
106
106
|
const filename = safeFilename(`${stateHash}_${timestamp}`, '.png');
|
|
107
107
|
const screenshotPath = join(statesDir, filename);
|
|
108
|
+
await page?.screencast?.hideOverlays().catch(() => { });
|
|
108
109
|
screenshotFile = await page
|
|
109
110
|
?.screenshot({ path: screenshotPath, fullPage: true })
|
|
110
111
|
.then(() => {
|
|
@@ -115,6 +116,7 @@ class Action {
|
|
|
115
116
|
debugLog('Screenshot failed, continuing without it:', err);
|
|
116
117
|
return undefined;
|
|
117
118
|
});
|
|
119
|
+
await page?.screencast?.showOverlays().catch(() => { });
|
|
118
120
|
}
|
|
119
121
|
// Save HTML to file
|
|
120
122
|
const htmlFile = safeFilename(`${stateHash}_${timestamp}`, '.html');
|
|
@@ -6,6 +6,7 @@ import { tag } from "../../utils/logger.js";
|
|
|
6
6
|
import { relativeToCwd } from "../../utils/next-steps.js";
|
|
7
7
|
import { CODECEPT_TOOLS, isNonReusableCode, stripComments } from "../../utils/step-analyzer.js";
|
|
8
8
|
import { safeFilename } from "../../utils/strings.js";
|
|
9
|
+
import { ensureCodeceptResolvable } from "../../utils/test-files.js";
|
|
9
10
|
import { ASSERTION_TOOLS } from "../tools.js";
|
|
10
11
|
import { escapeString, getExecutionLabel } from "./utils.js";
|
|
11
12
|
export function WithCodeceptJS(Base) {
|
|
@@ -79,6 +80,7 @@ export function WithCodeceptJS(Base) {
|
|
|
79
80
|
}
|
|
80
81
|
const testsDir = ConfigParser.getInstance().getTestsDir();
|
|
81
82
|
mkdirSync(testsDir, { recursive: true });
|
|
83
|
+
ensureCodeceptResolvable(testsDir);
|
|
82
84
|
const filePath = join(testsDir, safeFilename(plan.title, '.js'));
|
|
83
85
|
writeFileSync(filePath, lines.join('\n'));
|
|
84
86
|
this.savedFiles.add(filePath);
|
|
@@ -9,7 +9,11 @@ export function createJudgeTool(deps, buildState) {
|
|
|
9
9
|
return {
|
|
10
10
|
judge: tool({
|
|
11
11
|
description: dedent `
|
|
12
|
-
|
|
12
|
+
Fast yes/no or pick-one decision about the current page, answered from <page_aria> and your recent steps.
|
|
13
|
+
No screenshot, no HTML, produces no test code.
|
|
14
|
+
Use for in-flow judgements you would otherwise guess: did the last action already take effect, which of several similar elements the scenario means, is this the expected page or dialog.
|
|
15
|
+
Not for final assertions (use verify/finish) and not for anything page_aria does not show, such as styling or visual state (use see).
|
|
16
|
+
Phrase the question literally and concretely.
|
|
13
17
|
`,
|
|
14
18
|
inputSchema: z.object({
|
|
15
19
|
question: z.string().describe('The statement to confirm, or the question the options answer'),
|
package/dist/src/ai/pilot.js
CHANGED
|
@@ -1059,6 +1059,9 @@ export class Pilot {
|
|
|
1059
1059
|
const consoleError = t.output?.pageDiff?.consoleErrors?.[0];
|
|
1060
1060
|
if (consoleError)
|
|
1061
1061
|
line += `\n console: ${consoleError.slice(0, PILOT_MESSAGE_MAX_LENGTH)}`;
|
|
1062
|
+
const disabledReason = t.output?.disabledReason?.[0];
|
|
1063
|
+
if (disabledReason)
|
|
1064
|
+
line += `\n shown on hover of disabled target: ${disabledReason.slice(0, PILOT_MESSAGE_MAX_LENGTH)}`;
|
|
1062
1065
|
return line;
|
|
1063
1066
|
})
|
|
1064
1067
|
.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;
|
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
|
@@ -130,11 +130,22 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }, task) {
|
|
|
130
130
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, commands[0]);
|
|
131
131
|
await commitNote(activeNote, TestResult.FAILED, toolResult, action);
|
|
132
132
|
const suggestion = clickFailureSuggestion(attempts);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
133
|
+
const data = { ...toolResult, attempts, suggestion };
|
|
134
|
+
const clickError = ambiguityError || action.lastError;
|
|
135
|
+
for (const disabled of attempts.filter((a) => a.error?.includes('not enabled'))) {
|
|
136
|
+
const hoverCommand = disabled.command.replace('I.click(', 'I.moveCursorTo(');
|
|
137
|
+
const beforeHover = ActionResult.fromState(stateManager.getCurrentState());
|
|
138
|
+
if (!(await action.attempt(hoverCommand, 'Hover the disabled element to reveal why it is disabled')))
|
|
139
|
+
continue;
|
|
140
|
+
const hoverResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(beforeHover, hoverCommand);
|
|
141
|
+
const shown = hoverResult.pageDiff?.messages;
|
|
142
|
+
if (!shown?.length)
|
|
143
|
+
break;
|
|
144
|
+
data.disabledReason = shown;
|
|
145
|
+
data.suggestion = `Element is DISABLED. Hovering it showed: "${shown.join(' | ')}". That is why — act on it or report it, do not guess another cause.`;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
return failedToolResult('click', 'All click commands failed', data, clickError);
|
|
138
149
|
},
|
|
139
150
|
}),
|
|
140
151
|
hover: tool({
|
|
@@ -1190,7 +1201,7 @@ export function withdrawVisionTools(tools) {
|
|
|
1190
1201
|
export function clickFailureSuggestion(attempts) {
|
|
1191
1202
|
const errors = attempts.map((a) => a.error || '');
|
|
1192
1203
|
if (errors.some((e) => e.includes('not enabled'))) {
|
|
1193
|
-
return 'Element exists but is DISABLED — clicking it again cannot work.
|
|
1204
|
+
return 'Element exists but is DISABLED — clicking it again cannot work. Find the precondition the page shows as unmet, then retry.';
|
|
1194
1205
|
}
|
|
1195
1206
|
if (errors.some((e) => e.includes('intercepts pointer events'))) {
|
|
1196
1207
|
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
|
@@ -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
|
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
export declare function listTestFiles(testsDir: string): string[];
|
|
2
|
+
/**
|
|
3
|
+
* Generated tests import `codeceptjs/steps`, but in global mode they lie in ~/.explorbot,
|
|
4
|
+
* far from any node_modules. Link the bot's own codeceptjs next to them so Node resolves it.
|
|
5
|
+
*/
|
|
6
|
+
export declare function ensureCodeceptResolvable(dir: string): void;
|
|
2
7
|
export declare function loadTestSuites(testsDir: string): any[];
|
|
3
8
|
export declare function printTestList(suites: any[]): void;
|
|
4
9
|
export declare function dryRunTestFile(filePath: string): Promise<void>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, symlinkSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import chalk from 'chalk';
|
|
4
5
|
import { highlight } from 'cli-highlight';
|
|
@@ -8,6 +9,7 @@ import storeListener from 'codeceptjs/lib/listener/store';
|
|
|
8
9
|
import store from 'codeceptjs/lib/store';
|
|
9
10
|
import figureSet from 'figures';
|
|
10
11
|
import { ConfigParser } from "../config.js";
|
|
12
|
+
const nodeRequire = createRequire(import.meta.url);
|
|
11
13
|
export function listTestFiles(testsDir) {
|
|
12
14
|
if (!existsSync(testsDir))
|
|
13
15
|
return [];
|
|
@@ -15,10 +17,34 @@ export function listTestFiles(testsDir) {
|
|
|
15
17
|
.filter((f) => f.endsWith('.js'))
|
|
16
18
|
.map((f) => path.resolve(testsDir, f));
|
|
17
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Generated tests import `codeceptjs/steps`, but in global mode they lie in ~/.explorbot,
|
|
22
|
+
* far from any node_modules. Link the bot's own codeceptjs next to them so Node resolves it.
|
|
23
|
+
*/
|
|
24
|
+
export function ensureCodeceptResolvable(dir) {
|
|
25
|
+
const link = path.join(dir, 'node_modules', 'codeceptjs');
|
|
26
|
+
if (existsSync(link))
|
|
27
|
+
return;
|
|
28
|
+
let target;
|
|
29
|
+
try {
|
|
30
|
+
target = path.dirname(nodeRequire.resolve('codeceptjs/package.json'));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
mkdirSync(path.dirname(link), { recursive: true });
|
|
37
|
+
symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Best effort: a project install already resolves codeceptjs without the link.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
18
43
|
export function loadTestSuites(testsDir) {
|
|
19
44
|
const jsFiles = listTestFiles(testsDir);
|
|
20
45
|
if (jsFiles.length === 0)
|
|
21
46
|
return [];
|
|
47
|
+
ensureCodeceptResolvable(testsDir);
|
|
22
48
|
codeceptjs.container.createMocha();
|
|
23
49
|
const mocha = codeceptjs.container.mocha();
|
|
24
50
|
mocha.files = jsFiles;
|
|
@@ -79,6 +105,7 @@ export async function dryRunTestFile(filePath) {
|
|
|
79
105
|
const mocha = codeceptjs.container.mocha();
|
|
80
106
|
mocha.reporter(class {
|
|
81
107
|
});
|
|
108
|
+
ensureCodeceptResolvable(path.dirname(absPath));
|
|
82
109
|
mocha.files = [absPath];
|
|
83
110
|
mocha.loadFiles();
|
|
84
111
|
let currentSuite = '';
|
|
@@ -66,11 +66,23 @@ Every CLI command that drives a browser accepts these options (`start`, `explore
|
|
|
66
66
|
| `--debug` | Enable debug logging (same as `--verbose`) |
|
|
67
67
|
| `-c, --config <path>` | Path to configuration file |
|
|
68
68
|
| `-p, --path <path>` | Working directory path |
|
|
69
|
+
| `--base-url <url>` | Run against this URL: its path scopes relative paths, its query params ride along with every page load |
|
|
69
70
|
| `-s, --show` | Show browser window |
|
|
70
71
|
| `--headless` | Run browser in headless mode |
|
|
71
72
|
| `--incognito` | Run without recording experiences |
|
|
72
73
|
| `--session [file]` | Save/restore browser session (cookies, localStorage) from file |
|
|
73
74
|
|
|
75
|
+
### `--base-url`
|
|
76
|
+
|
|
77
|
+
Points one run at one area of a site, leaving every config file alone. The origin selects the site, so the knowledge and experience already stored for that host still apply. The path prefixes every relative path a plan or a command gives, and the query params are added to each page load.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npx explorbot test tests/plans/runs.md '*' \
|
|
81
|
+
--base-url 'https://app.example.com/teams/acme/?preview=pr-42'
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
A plan whose tests start at `/runs` runs them at `/teams/acme/runs`, and every page load carries `?preview=pr-42` — enough to keep a preview deployment selected for a whole session. The param travels on the request rather than in the address bar, so Explorbot navigates to clean URLs, though an app that redirects can still echo the param back. Absolute URLs are never prefixed: `https://app.example.com/users/sign_in` still points outside the scoped area, as a login page usually must.
|
|
85
|
+
|
|
74
86
|
### `--knowledge`
|
|
75
87
|
|
|
76
88
|
Passes facts to the run without creating a file in `knowledge/`. Plain text applies everywhere; add frontmatter to scope it to a page or an API endpoint. Repeat the flag for several facts. See [Knowledge](../workflow/knowledge.md#per-session-knowledge).
|
package/package.json
CHANGED
package/src/action.ts
CHANGED
|
@@ -114,6 +114,7 @@ class Action {
|
|
|
114
114
|
if (includeScreenshot) {
|
|
115
115
|
const filename = safeFilename(`${stateHash}_${timestamp}`, '.png');
|
|
116
116
|
const screenshotPath = join(statesDir, filename);
|
|
117
|
+
await page?.screencast?.hideOverlays().catch(() => {});
|
|
117
118
|
screenshotFile = await page
|
|
118
119
|
?.screenshot({ path: screenshotPath, fullPage: true })
|
|
119
120
|
.then(() => {
|
|
@@ -124,6 +125,7 @@ class Action {
|
|
|
124
125
|
debugLog('Screenshot failed, continuing without it:', err);
|
|
125
126
|
return undefined;
|
|
126
127
|
});
|
|
128
|
+
await page?.screencast?.showOverlays().catch(() => {});
|
|
127
129
|
}
|
|
128
130
|
|
|
129
131
|
// Save HTML to file
|
|
@@ -8,6 +8,7 @@ import { tag } from '../../utils/logger.ts';
|
|
|
8
8
|
import { relativeToCwd } from '../../utils/next-steps.ts';
|
|
9
9
|
import { CODECEPT_TOOLS, isNonReusableCode, stripComments } from '../../utils/step-analyzer.ts';
|
|
10
10
|
import { safeFilename } from '../../utils/strings.ts';
|
|
11
|
+
import { ensureCodeceptResolvable } from '../../utils/test-files.ts';
|
|
11
12
|
import type { Conversation } from '../conversation.ts';
|
|
12
13
|
import { ASSERTION_TOOLS } from '../tools.ts';
|
|
13
14
|
import type { Constructor } from './mixin.ts';
|
|
@@ -99,6 +100,7 @@ export function WithCodeceptJS<T extends Constructor>(Base: T) {
|
|
|
99
100
|
|
|
100
101
|
const testsDir = ConfigParser.getInstance().getTestsDir();
|
|
101
102
|
mkdirSync(testsDir, { recursive: true });
|
|
103
|
+
ensureCodeceptResolvable(testsDir);
|
|
102
104
|
|
|
103
105
|
const filePath = join(testsDir, safeFilename(plan.title, '.js'));
|
|
104
106
|
writeFileSync(filePath, lines.join('\n'));
|
package/src/ai/judge-tool.ts
CHANGED
|
@@ -11,7 +11,11 @@ export function createJudgeTool(deps: ToolDeps, buildState: () => Promise<Record
|
|
|
11
11
|
return {
|
|
12
12
|
judge: tool({
|
|
13
13
|
description: dedent`
|
|
14
|
-
|
|
14
|
+
Fast yes/no or pick-one decision about the current page, answered from <page_aria> and your recent steps.
|
|
15
|
+
No screenshot, no HTML, produces no test code.
|
|
16
|
+
Use for in-flow judgements you would otherwise guess: did the last action already take effect, which of several similar elements the scenario means, is this the expected page or dialog.
|
|
17
|
+
Not for final assertions (use verify/finish) and not for anything page_aria does not show, such as styling or visual state (use see).
|
|
18
|
+
Phrase the question literally and concretely.
|
|
15
19
|
`,
|
|
16
20
|
inputSchema: z.object({
|
|
17
21
|
question: z.string().describe('The statement to confirm, or the question the options answer'),
|
package/src/ai/pilot.ts
CHANGED
|
@@ -1162,6 +1162,9 @@ export class Pilot implements Agent {
|
|
|
1162
1162
|
const consoleError = t.output?.pageDiff?.consoleErrors?.[0];
|
|
1163
1163
|
if (consoleError) line += `\n console: ${consoleError.slice(0, PILOT_MESSAGE_MAX_LENGTH)}`;
|
|
1164
1164
|
|
|
1165
|
+
const disabledReason = t.output?.disabledReason?.[0];
|
|
1166
|
+
if (disabledReason) line += `\n shown on hover of disabled target: ${disabledReason.slice(0, PILOT_MESSAGE_MAX_LENGTH)}`;
|
|
1167
|
+
|
|
1165
1168
|
return line;
|
|
1166
1169
|
})
|
|
1167
1170
|
.join('\n\n');
|
package/src/ai/rerunner.ts
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.ts';
|
|
|
19
19
|
import { loop } from '../utils/loop.ts';
|
|
20
20
|
import { RulesLoader } from '../utils/rules-loader.ts';
|
|
21
21
|
import { isInternalStep } from '../utils/step-analyzer.ts';
|
|
22
|
+
import { ensureCodeceptResolvable } from '../utils/test-files.ts';
|
|
22
23
|
import type { Agent, AgentDeps } from './agent.ts';
|
|
23
24
|
import { toolExecutionLabel } from './conversation.ts';
|
|
24
25
|
import type { Navigator } from './navigator.ts';
|
|
@@ -135,6 +136,7 @@ export class Rerunner extends TaskAgent implements Agent {
|
|
|
135
136
|
codeceptjs.container.createMocha();
|
|
136
137
|
const mocha = codeceptjs.container.mocha();
|
|
137
138
|
mocha.reporter(class {});
|
|
139
|
+
ensureCodeceptResolvable(dirname(absPath));
|
|
138
140
|
mocha.files = [absPath];
|
|
139
141
|
mocha.loadFiles();
|
|
140
142
|
|
package/src/ai/rules.ts
CHANGED
|
@@ -237,7 +237,7 @@ export const unexpectedPopupRule = dedent`
|
|
|
237
237
|
If a modal/popup appeared that you didn't expect, dismiss it first before continuing with original task.
|
|
238
238
|
If elements become hidden or unclickable (timeout errors on visible elements), a dialog or overlay may have appeared on top.
|
|
239
239
|
If a click error mentions "intercepts pointer events", another element is covering the target — dismiss it first.
|
|
240
|
-
If buttons are disabled unexpectedly, check
|
|
240
|
+
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.
|
|
241
241
|
|
|
242
242
|
Dismiss strategy (try in order):
|
|
243
243
|
1. I.pressKey('Escape') — press Escape to dismiss
|
package/src/ai/tester.ts
CHANGED
|
@@ -752,6 +752,16 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
752
752
|
}
|
|
753
753
|
|
|
754
754
|
getSystemMessage(): string {
|
|
755
|
+
let judgeRule = '';
|
|
756
|
+
if (this.agentTools?.judge) {
|
|
757
|
+
judgeRule = dedent`
|
|
758
|
+
<judge_rule>
|
|
759
|
+
For a quick judgement about the current page (did the last action already take effect, which element the scenario means), ask judge() before see().
|
|
760
|
+
Keep see() for what only a screenshot shows.
|
|
761
|
+
</judge_rule>
|
|
762
|
+
`;
|
|
763
|
+
}
|
|
764
|
+
|
|
755
765
|
return dedent`
|
|
756
766
|
<role>
|
|
757
767
|
You are a senior test automation engineer with expertise in CodeceptJS and exploratory testing.
|
|
@@ -851,6 +861,8 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
851
861
|
|
|
852
862
|
${capabilityGroundingRule}
|
|
853
863
|
|
|
864
|
+
${judgeRule}
|
|
865
|
+
|
|
854
866
|
${dataProtectionRules}
|
|
855
867
|
|
|
856
868
|
${this.provider.getSystemPromptForAgent('tester', this.stateManager.getCurrentState()?.url) || ''}
|
package/src/ai/tools.ts
CHANGED
|
@@ -156,17 +156,22 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }: ToolDep
|
|
|
156
156
|
await commitNote(activeNote, TestResult.FAILED, toolResult, action);
|
|
157
157
|
|
|
158
158
|
const suggestion = clickFailureSuggestion(attempts);
|
|
159
|
+
const data: Record<string, any> = { ...toolResult, attempts, suggestion };
|
|
160
|
+
const clickError = ambiguityError || action.lastError;
|
|
161
|
+
|
|
162
|
+
for (const disabled of attempts.filter((a) => a.error?.includes('not enabled'))) {
|
|
163
|
+
const hoverCommand = disabled.command.replace('I.click(', 'I.moveCursorTo(');
|
|
164
|
+
const beforeHover = ActionResult.fromState(stateManager.getCurrentState()!);
|
|
165
|
+
if (!(await action.attempt(hoverCommand, 'Hover the disabled element to reveal why it is disabled'))) continue;
|
|
166
|
+
const hoverResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(beforeHover, hoverCommand);
|
|
167
|
+
const shown = hoverResult.pageDiff?.messages;
|
|
168
|
+
if (!shown?.length) break;
|
|
169
|
+
data.disabledReason = shown;
|
|
170
|
+
data.suggestion = `Element is DISABLED. Hovering it showed: "${shown.join(' | ')}". That is why — act on it or report it, do not guess another cause.`;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
159
173
|
|
|
160
|
-
return failedToolResult(
|
|
161
|
-
'click',
|
|
162
|
-
'All click commands failed',
|
|
163
|
-
{
|
|
164
|
-
...toolResult,
|
|
165
|
-
attempts,
|
|
166
|
-
suggestion,
|
|
167
|
-
},
|
|
168
|
-
ambiguityError || action.lastError
|
|
169
|
-
);
|
|
174
|
+
return failedToolResult('click', 'All click commands failed', data, clickError);
|
|
170
175
|
},
|
|
171
176
|
}),
|
|
172
177
|
|
|
@@ -1356,7 +1361,7 @@ export function clickFailureSuggestion(attempts: Array<{ error?: string }>): str
|
|
|
1356
1361
|
const errors = attempts.map((a) => a.error || '');
|
|
1357
1362
|
|
|
1358
1363
|
if (errors.some((e) => e.includes('not enabled'))) {
|
|
1359
|
-
return 'Element exists but is DISABLED — clicking it again cannot work.
|
|
1364
|
+
return 'Element exists but is DISABLED — clicking it again cannot work. Find the precondition the page shows as unmet, then retry.';
|
|
1360
1365
|
}
|
|
1361
1366
|
|
|
1362
1367
|
if (errors.some((e) => e.includes('intercepts pointer events'))) {
|
package/src/config.ts
CHANGED
|
@@ -327,6 +327,7 @@ export class ConfigParser {
|
|
|
327
327
|
private site: SiteRecord | null = null;
|
|
328
328
|
private siteStartPath = '/';
|
|
329
329
|
private siteConfigPath: string | null = null;
|
|
330
|
+
private runBase: RunBase | null = null;
|
|
330
331
|
|
|
331
332
|
private constructor() {}
|
|
332
333
|
|
|
@@ -368,6 +369,7 @@ export class ConfigParser {
|
|
|
368
369
|
if (this.config && !options?.config && !options?.path && this.runtimeTarget === target) {
|
|
369
370
|
return this.config;
|
|
370
371
|
}
|
|
372
|
+
this.runBase = parseRunBase(options?.baseUrl);
|
|
371
373
|
|
|
372
374
|
// Store the initial working directory for reference
|
|
373
375
|
if (!process.env.INITIAL_CWD) {
|
|
@@ -404,14 +406,21 @@ export class ConfigParser {
|
|
|
404
406
|
let envUrl = options?.baseUrl;
|
|
405
407
|
if (!envUrl && target?.startsWith('http')) envUrl = target;
|
|
406
408
|
|
|
407
|
-
|
|
409
|
+
if (options?.baseUrl) {
|
|
410
|
+
const envOrigin = URL.parse(process.env.EXPLORBOT_URL || '')?.origin;
|
|
411
|
+
const baseOrigin = URL.parse(options.baseUrl)?.origin;
|
|
412
|
+
if (envOrigin && envOrigin !== baseOrigin) {
|
|
413
|
+
tag('warning').log(`--base-url ${baseOrigin} selects the site for this run — EXPLORBOT_URL ${envOrigin} is ignored`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const outputRoot = resolveOutputRoot(options?.baseUrl || process.env.EXPLORBOT_URL || envUrl);
|
|
408
417
|
loadedConfig = await this.buildEnvConfig(envUrl, outputRoot);
|
|
409
418
|
sourcePath = join(outputRoot, 'explorbot.config.js');
|
|
410
419
|
|
|
411
420
|
log(`Configuration built from EXPLORBOT_* environment variables. Output: ${outputRoot}`);
|
|
412
421
|
}
|
|
413
422
|
|
|
414
|
-
let config = this.resolveConfig(loadedConfig as ExplorbotConfig
|
|
423
|
+
let config = this.resolveConfig(loadedConfig as ExplorbotConfig);
|
|
415
424
|
await resolveConfigModels(config.ai);
|
|
416
425
|
this.site = null;
|
|
417
426
|
this.siteConfigPath = null;
|
|
@@ -484,6 +493,22 @@ export class ConfigParser {
|
|
|
484
493
|
return this.siteConfigPath;
|
|
485
494
|
}
|
|
486
495
|
|
|
496
|
+
public applyBasePath(target: string): string {
|
|
497
|
+
const base = this.runBase?.path;
|
|
498
|
+
if (!base) return target;
|
|
499
|
+
if (!target.startsWith('/')) return target;
|
|
500
|
+
if (target === base || target.startsWith(`${base}/`) || target.startsWith(`${base}?`)) return target;
|
|
501
|
+
return `${base}${target}`;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
public getBaseQuery(): string {
|
|
505
|
+
return this.runBase?.query || '';
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
public getBasePath(): string {
|
|
509
|
+
return this.runBase?.path || '';
|
|
510
|
+
}
|
|
511
|
+
|
|
487
512
|
public resolveTargetPath(target?: string): string {
|
|
488
513
|
if (!this.site) {
|
|
489
514
|
const configured = this.config?.playwright?.url || this.config?.web?.url;
|
|
@@ -492,12 +517,13 @@ export class ConfigParser {
|
|
|
492
517
|
if (targetOrigin && baseOrigin && targetOrigin !== baseOrigin) {
|
|
493
518
|
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.`);
|
|
494
519
|
}
|
|
495
|
-
return target || '/';
|
|
520
|
+
return this.applyBasePath(target || '/');
|
|
496
521
|
}
|
|
497
522
|
if (!target) return this.siteStartPath;
|
|
498
523
|
|
|
499
524
|
const resolved = resolveSiteTarget(target, this.site.url);
|
|
500
525
|
if (resolved.baseUrl !== this.site.url) return target;
|
|
526
|
+
if (target.startsWith('/')) return this.applyBasePath(resolved.path);
|
|
501
527
|
return resolved.path;
|
|
502
528
|
}
|
|
503
529
|
|
|
@@ -524,6 +550,7 @@ export class ConfigParser {
|
|
|
524
550
|
ConfigParser.instance.site = null;
|
|
525
551
|
ConfigParser.instance.siteStartPath = '/';
|
|
526
552
|
ConfigParser.instance.siteConfigPath = null;
|
|
553
|
+
ConfigParser.instance.runBase = null;
|
|
527
554
|
}
|
|
528
555
|
}
|
|
529
556
|
|
|
@@ -694,15 +721,15 @@ export class ConfigParser {
|
|
|
694
721
|
}
|
|
695
722
|
}
|
|
696
723
|
|
|
697
|
-
private resolveConfig(config: ExplorbotConfig
|
|
724
|
+
private resolveConfig(config: ExplorbotConfig): ExplorbotConfig {
|
|
698
725
|
if (config.web?.url && !config.playwright?.url) {
|
|
699
726
|
config.playwright = config.playwright || { browser: 'chromium', url: '' };
|
|
700
727
|
config.playwright.url = config.web.url;
|
|
701
728
|
}
|
|
702
729
|
|
|
703
|
-
if (
|
|
730
|
+
if (this.runBase) {
|
|
704
731
|
config.playwright = config.playwright || { browser: 'chromium', url: '' };
|
|
705
|
-
config.playwright.url =
|
|
732
|
+
config.playwright.url = this.runBase.origin;
|
|
706
733
|
}
|
|
707
734
|
|
|
708
735
|
resolveLangfuse(config.ai);
|
|
@@ -936,8 +963,23 @@ export async function createModel(provider: string, modelId: string): Promise<an
|
|
|
936
963
|
return (await info.load())(modelId);
|
|
937
964
|
}
|
|
938
965
|
|
|
966
|
+
function parseRunBase(baseUrl?: string): RunBase | null {
|
|
967
|
+
if (!baseUrl) return null;
|
|
968
|
+
|
|
969
|
+
const url = URL.parse(baseUrl);
|
|
970
|
+
if (!url) throw new Error(`Base URL must be a full URL like https://app.example.com/team/acme, got "${baseUrl}"`);
|
|
971
|
+
|
|
972
|
+
return { origin: url.origin, path: url.pathname.replace(/\/+$/, ''), query: url.search };
|
|
973
|
+
}
|
|
974
|
+
|
|
939
975
|
type ModelRole = 'model' | 'visionModel' | 'agenticModel';
|
|
940
976
|
|
|
977
|
+
interface RunBase {
|
|
978
|
+
origin: string;
|
|
979
|
+
path: string;
|
|
980
|
+
query: string;
|
|
981
|
+
}
|
|
982
|
+
|
|
941
983
|
interface ConfiguredModel {
|
|
942
984
|
name: string;
|
|
943
985
|
provider: string;
|
package/src/explorbot.ts
CHANGED
|
@@ -517,6 +517,8 @@ export class ExplorBot {
|
|
|
517
517
|
setCurrentPlan(plan?: Plan): void {
|
|
518
518
|
this.currentPlan = plan;
|
|
519
519
|
if (!plan) return;
|
|
520
|
+
if (plan.url) plan.url = this.configParser.applyBasePath(plan.url);
|
|
521
|
+
for (const test of plan.tests) test.startUrl = this.configParser.applyBasePath(test.startUrl);
|
|
520
522
|
if (!this.sessionPlans.includes(plan)) {
|
|
521
523
|
this.sessionPlans.push(plan);
|
|
522
524
|
}
|
package/src/explorer.ts
CHANGED
|
@@ -6,7 +6,7 @@ import stepsListener from 'codeceptjs/lib/listener/steps';
|
|
|
6
6
|
import storeListener from 'codeceptjs/lib/listener/store';
|
|
7
7
|
import { createTest } from 'codeceptjs/lib/mocha/test';
|
|
8
8
|
import dedent from 'dedent';
|
|
9
|
-
import type { Browser, BrowserContextOptions, Page } from 'playwright';
|
|
9
|
+
import type { Browser, BrowserContextOptions, Page, Route } from 'playwright';
|
|
10
10
|
import { ActionResult } from './action-result.ts';
|
|
11
11
|
import Action from './action.js';
|
|
12
12
|
import type { RequestStore } from './api/request-store.ts';
|
|
@@ -103,6 +103,7 @@ class Explorer {
|
|
|
103
103
|
if (!this.playwrightHelper) {
|
|
104
104
|
throw new Error('Playwright helper not available');
|
|
105
105
|
}
|
|
106
|
+
this.applyBasePathToHelper();
|
|
106
107
|
await this.connectOrLaunchBrowser();
|
|
107
108
|
const hasSession = !this.options?.attachedBrowser && this.options?.session && existsSync(this.options.session);
|
|
108
109
|
await this.openContextPage();
|
|
@@ -163,7 +164,7 @@ class Explorer {
|
|
|
163
164
|
|
|
164
165
|
async visit(url: string, opts: CaptureOpts = {}): Promise<ActionResult> {
|
|
165
166
|
return this.runWithRecovery('visit', async () => {
|
|
166
|
-
const action = await this.visitOnce(url);
|
|
167
|
+
const action = await this.visitOnce(ConfigParser.getInstance().applyBasePath(url));
|
|
167
168
|
if (opts.screenshot) return action.capturePageState({ includeScreenshot: true });
|
|
168
169
|
return action.getActionResult() ?? action.capturePageState();
|
|
169
170
|
});
|
|
@@ -347,6 +348,7 @@ class Explorer {
|
|
|
347
348
|
const attached = this.options?.attachedBrowser;
|
|
348
349
|
if (!attached) {
|
|
349
350
|
await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
|
|
351
|
+
await this.pinBaseQuery();
|
|
350
352
|
return;
|
|
351
353
|
}
|
|
352
354
|
|
|
@@ -356,6 +358,39 @@ class Explorer {
|
|
|
356
358
|
this.playwrightHelper.browserContext = context;
|
|
357
359
|
await this.playwrightHelper._setPage(page);
|
|
358
360
|
debugLog(`Adopted attached browser page: ${page.url()}`);
|
|
361
|
+
await this.pinBaseQuery();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private applyBasePathToHelper(): void {
|
|
365
|
+
if (!ConfigParser.getInstance().getBasePath()) return;
|
|
366
|
+
|
|
367
|
+
const helper = this.playwrightHelper;
|
|
368
|
+
const original = helper.amOnPage.bind(helper);
|
|
369
|
+
helper.amOnPage = (url: string) => original(ConfigParser.getInstance().applyBasePath(url));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private async pinBaseQuery(): Promise<void> {
|
|
373
|
+
const query = ConfigParser.getInstance().getBaseQuery();
|
|
374
|
+
if (!query) return;
|
|
375
|
+
|
|
376
|
+
const pinned = new URLSearchParams(query);
|
|
377
|
+
const origin = URL.parse(this.config.playwright.url)?.origin;
|
|
378
|
+
|
|
379
|
+
await this.playwrightHelper.browserContext.route('**/*', async (route: Route) => {
|
|
380
|
+
const request = route.request();
|
|
381
|
+
if (request.resourceType() !== 'document') return route.continue();
|
|
382
|
+
|
|
383
|
+
const url = URL.parse(request.url());
|
|
384
|
+
if (!url || url.origin !== origin) return route.continue();
|
|
385
|
+
|
|
386
|
+
const missing = [...pinned].filter(([key]) => !url.searchParams.has(key));
|
|
387
|
+
if (!missing.length) return route.continue();
|
|
388
|
+
|
|
389
|
+
for (const [key, value] of missing) url.searchParams.set(key, value);
|
|
390
|
+
return route.continue({ url: url.toString() });
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
tag('info').log(`Every page load of ${origin} carries ${query}`);
|
|
359
394
|
}
|
|
360
395
|
|
|
361
396
|
private createBrowserContextOptions(): BrowserContextOptions {
|
package/src/global-config.ts
CHANGED
|
@@ -131,7 +131,7 @@ export function resolveSiteTarget(target?: string, defaultBaseUrl?: string): Sit
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
if (raw.startsWith('/')) {
|
|
134
|
-
const base =
|
|
134
|
+
const base = process.env.EXPLORBOT_URL || defaultBaseUrl;
|
|
135
135
|
if (!base) {
|
|
136
136
|
throw new Error(withSites(`Cannot resolve path "${raw}" without a site.`));
|
|
137
137
|
}
|
package/src/utils/test-files.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, symlinkSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import chalk from 'chalk';
|
|
4
5
|
import { highlight } from 'cli-highlight';
|
|
@@ -9,6 +10,8 @@ import store from 'codeceptjs/lib/store';
|
|
|
9
10
|
import figureSet from 'figures';
|
|
10
11
|
import { ConfigParser } from '../config.ts';
|
|
11
12
|
|
|
13
|
+
const nodeRequire = createRequire(import.meta.url);
|
|
14
|
+
|
|
12
15
|
export function listTestFiles(testsDir: string): string[] {
|
|
13
16
|
if (!existsSync(testsDir)) return [];
|
|
14
17
|
|
|
@@ -17,10 +20,32 @@ export function listTestFiles(testsDir: string): string[] {
|
|
|
17
20
|
.map((f) => path.resolve(testsDir, f));
|
|
18
21
|
}
|
|
19
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Generated tests import `codeceptjs/steps`, but in global mode they lie in ~/.explorbot,
|
|
25
|
+
* far from any node_modules. Link the bot's own codeceptjs next to them so Node resolves it.
|
|
26
|
+
*/
|
|
27
|
+
export function ensureCodeceptResolvable(dir: string): void {
|
|
28
|
+
const link = path.join(dir, 'node_modules', 'codeceptjs');
|
|
29
|
+
if (existsSync(link)) return;
|
|
30
|
+
let target: string;
|
|
31
|
+
try {
|
|
32
|
+
target = path.dirname(nodeRequire.resolve('codeceptjs/package.json'));
|
|
33
|
+
} catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
mkdirSync(path.dirname(link), { recursive: true });
|
|
38
|
+
symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir');
|
|
39
|
+
} catch {
|
|
40
|
+
// Best effort: a project install already resolves codeceptjs without the link.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
20
44
|
export function loadTestSuites(testsDir: string): any[] {
|
|
21
45
|
const jsFiles = listTestFiles(testsDir);
|
|
22
46
|
if (jsFiles.length === 0) return [];
|
|
23
47
|
|
|
48
|
+
ensureCodeceptResolvable(testsDir);
|
|
24
49
|
codeceptjs.container.createMocha();
|
|
25
50
|
const mocha = codeceptjs.container.mocha();
|
|
26
51
|
mocha.files = jsFiles;
|
|
@@ -93,6 +118,7 @@ export async function dryRunTestFile(filePath: string): Promise<void> {
|
|
|
93
118
|
codeceptjs.container.createMocha();
|
|
94
119
|
const mocha = codeceptjs.container.mocha();
|
|
95
120
|
mocha.reporter(class {});
|
|
121
|
+
ensureCodeceptResolvable(path.dirname(absPath));
|
|
96
122
|
mocha.files = [absPath];
|
|
97
123
|
mocha.loadFiles();
|
|
98
124
|
|