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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -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
- Settle one judgement about the current page instead of guessing. Phrase it literally and concretely.
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'),
@@ -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');
@@ -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;
@@ -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 if a popup is blocking interaction or if required form fields are empty.
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
@@ -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) || ''}
@@ -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
- return failedToolResult('click', 'All click commands failed', {
134
- ...toolResult,
135
- attempts,
136
- suggestion,
137
- }, ambiguityError || action.lastError);
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. A precondition is unmet: a required field is empty, nothing is selected, or a dialog is blocking. Satisfy it, then retry.';
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.';
@@ -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, options?: {
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;
@@ -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
- const outputRoot = resolveOutputRoot(process.env.EXPLORBOT_URL || envUrl);
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, options);
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, options) {
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 (options?.baseUrl) {
429
+ if (this.runBase) {
402
430
  config.playwright = config.playwright || { browser: 'chromium', url: '' };
403
- config.playwright.url = options.baseUrl;
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
+ }
@@ -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
  }
@@ -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>;
@@ -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 = defaultBaseUrl || process.env.EXPLORBOT_URL;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
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'));
@@ -11,7 +11,11 @@ export function createJudgeTool(deps: ToolDeps, buildState: () => Promise<Record
11
11
  return {
12
12
  judge: tool({
13
13
  description: dedent`
14
- Settle one judgement about the current page instead of guessing. Phrase it literally and concretely.
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');
@@ -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 if a popup is blocking interaction or if required form fields are empty.
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. A precondition is unmet: a required field is empty, nothing is selected, or a dialog is blocking. Satisfy it, then retry.';
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
- const outputRoot = resolveOutputRoot(process.env.EXPLORBOT_URL || envUrl);
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, options);
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, options?: { baseUrl?: string }): 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 (options?.baseUrl) {
730
+ if (this.runBase) {
704
731
  config.playwright = config.playwright || { browser: 'chromium', url: '' };
705
- config.playwright.url = options.baseUrl;
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 {
@@ -131,7 +131,7 @@ export function resolveSiteTarget(target?: string, defaultBaseUrl?: string): Sit
131
131
  }
132
132
 
133
133
  if (raw.startsWith('/')) {
134
- const base = defaultBaseUrl || process.env.EXPLORBOT_URL;
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
  }
@@ -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