explorbot 0.5.1 → 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.
Files changed (46) hide show
  1. package/bin/explorbot-cli.ts +3 -0
  2. package/dist/bin/explorbot-cli.js +2 -0
  3. package/dist/package.json +1 -1
  4. package/dist/src/action.d.ts +1 -0
  5. package/dist/src/action.js +9 -0
  6. package/dist/src/ai/historian/codeceptjs.js +2 -0
  7. package/dist/src/ai/judge-provider.js +5 -0
  8. package/dist/src/ai/judge-tool.js +5 -1
  9. package/dist/src/ai/judge.d.ts +3 -1
  10. package/dist/src/ai/judge.js +29 -6
  11. package/dist/src/ai/pilot.d.ts +1 -0
  12. package/dist/src/ai/pilot.js +20 -4
  13. package/dist/src/ai/provider.js +14 -7
  14. package/dist/src/ai/rerunner.js +3 -1
  15. package/dist/src/ai/rules.js +1 -1
  16. package/dist/src/ai/tester.d.ts +1 -0
  17. package/dist/src/ai/tester.js +22 -4
  18. package/dist/src/ai/tools.js +34 -14
  19. package/dist/src/config.d.ts +11 -3
  20. package/dist/src/config.js +42 -6
  21. package/dist/src/explorbot.js +4 -0
  22. package/dist/src/explorer.d.ts +2 -0
  23. package/dist/src/explorer.js +33 -1
  24. package/dist/src/global-config.js +1 -1
  25. package/dist/src/utils/test-files.d.ts +5 -0
  26. package/dist/src/utils/test-files.js +28 -1
  27. package/docs/basics/providers.md +3 -2
  28. package/docs/reference/commands.md +12 -0
  29. package/docs/web-testing/decisions.md +18 -7
  30. package/package.json +1 -1
  31. package/src/action.ts +10 -0
  32. package/src/ai/historian/codeceptjs.ts +2 -0
  33. package/src/ai/judge-provider.ts +6 -0
  34. package/src/ai/judge-tool.ts +5 -1
  35. package/src/ai/judge.ts +25 -6
  36. package/src/ai/pilot.ts +20 -4
  37. package/src/ai/provider.ts +13 -7
  38. package/src/ai/rerunner.ts +3 -1
  39. package/src/ai/rules.ts +1 -1
  40. package/src/ai/tester.ts +23 -3
  41. package/src/ai/tools.ts +37 -21
  42. package/src/config.ts +49 -7
  43. package/src/explorbot.ts +2 -0
  44. package/src/explorer.ts +37 -2
  45. package/src/global-config.ts +1 -1
  46. package/src/utils/test-files.ts +27 -1
@@ -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
@@ -324,7 +324,12 @@ export class Tester extends TaskAgent implements Agent {
324
324
  const guidance = await this.pilot.reviewNewPage(task, currentState, conversation);
325
325
  if (guidance) nextStep += `\n\n${guidance}`;
326
326
  } else if (this.shouldAnalyzeProgress(iteration, currentState) && this.pilot) {
327
- const guidance = await this.pilot.analyzeProgress(task, currentState, conversation);
327
+ let guidance: string;
328
+ if (this.isStruggling) {
329
+ guidance = await this.pilot.analyzeProgress(task, currentState, conversation);
330
+ } else {
331
+ guidance = await this.pilot.periodicAnalyzeProgress(task, currentState, conversation);
332
+ }
328
333
  if (guidance) nextStep += `\n\n${guidance}`;
329
334
  this.consecutiveFailures = 0;
330
335
  this.lastAnalyzedStateHash = currentState.hash;
@@ -486,13 +491,16 @@ export class Tester extends TaskAgent implements Agent {
486
491
  this.regionTransitioned = false;
487
492
  return true;
488
493
  }
489
- if (this.consecutiveFailures >= 3) return true;
490
- if (this.consecutiveEmptyResults >= 2) return true;
494
+ if (this.isStruggling) return true;
491
495
  if (iteration % this.progressCheckInterval !== 0) return false;
492
496
  if (this.lastAnalyzedStateHash === currentState.hash) return false;
493
497
  return true;
494
498
  }
495
499
 
500
+ private get isStruggling(): boolean {
501
+ return this.consecutiveFailures >= 3 || this.consecutiveEmptyResults >= 2;
502
+ }
503
+
496
504
  private shouldStopForStalledExecution(task: Test, previousState: ActionResult, toolExecutions: any[]): boolean {
497
505
  if (task.hasFinished) return false;
498
506
 
@@ -744,6 +752,16 @@ export class Tester extends TaskAgent implements Agent {
744
752
  }
745
753
 
746
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
+
747
765
  return dedent`
748
766
  <role>
749
767
  You are a senior test automation engineer with expertise in CodeceptJS and exploratory testing.
@@ -843,6 +861,8 @@ export class Tester extends TaskAgent implements Agent {
843
861
 
844
862
  ${capabilityGroundingRule}
845
863
 
864
+ ${judgeRule}
865
+
846
866
  ${dataProtectionRules}
847
867
 
848
868
  ${this.provider.getSystemPromptForAgent('tester', this.stateManager.getCurrentState()?.url) || ''}
package/src/ai/tools.ts CHANGED
@@ -16,7 +16,7 @@ import { compactErrorMessage, normalizeInlineText, truncate } from '../utils/str
16
16
  import { WebElement } from '../utils/web-element.ts';
17
17
  import type { ToolDeps } from './agent.ts';
18
18
  import { createJudgeTool } from './judge-tool.ts';
19
- import { JUDGE_PAGE_CAP, type Judge, UNDECIDED } from './judge.ts';
19
+ import { JUDGE_PAGE_CAP, type Judge } from './judge.ts';
20
20
  import { Navigator } from './navigator.ts';
21
21
  import { Researcher } from './researcher.ts';
22
22
  import { sectionContextRule } from './rules.ts';
@@ -113,13 +113,20 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }: ToolDep
113
113
 
114
114
  for (let i = 0; i < commands.length; i++) {
115
115
  const command = transformContainsCommand(commands[i]);
116
- const success = await action.attempt(command, explanation);
116
+ let success = await action.attempt(command, explanation);
117
117
 
118
118
  const attempt: { command: string; success: boolean; error?: string } = { command, success };
119
119
  if (action.lastError) attempt.error = errorText(action.lastError);
120
120
  attempts.push(attempt);
121
121
 
122
- if (!ambiguityError && action.lastError?.name === 'MultipleElementsFound') ambiguityError = action.lastError;
122
+ if (!ambiguityError && action.lastError?.name === 'MultipleElementsFound') {
123
+ ambiguityError = action.lastError;
124
+ if (judge) {
125
+ const labels = (await extractWebElements(ambiguityError))?.map((el) => el.label) || [];
126
+ const index = await judge.pick(PICK_ELEMENT_QUESTION, labels, { intent: explanation, task: task.description });
127
+ if (index) success = await action.attemptExactElementIndex(command, index, explanation);
128
+ }
129
+ }
123
130
 
124
131
  if (success) {
125
132
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, command);
@@ -149,19 +156,22 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }: ToolDep
149
156
  await commitNote(activeNote, TestResult.FAILED, toolResult, action);
150
157
 
151
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
+ }
152
173
 
153
- return failedToolResult(
154
- 'click',
155
- 'All click commands failed',
156
- {
157
- ...toolResult,
158
- attempts,
159
- suggestion,
160
- },
161
- ambiguityError || action.lastError,
162
- judge,
163
- explanation
164
- );
174
+ return failedToolResult('click', 'All click commands failed', data, clickError);
165
175
  },
166
176
  }),
167
177
 
@@ -1309,10 +1319,13 @@ export async function failedToolResult(action: string, message: string, data?: R
1309
1319
  result.suggestion = getMultipleElementsSuggestion();
1310
1320
  result.multipleElementsDetected = true;
1311
1321
  result.elements = formatElementList(matched);
1312
- const labels = (matched || []).map((element) => `${element.text || 'no text'} — ${element.html}`);
1313
- const pick = await judge?.decide('Which listed element does the intent name?', [...labels, UNDECIDED], { intent });
1314
- if (!pick?.value) return result;
1315
- const index = labels.indexOf(pick.value) + 1;
1322
+ if (!judge || !matched) return result;
1323
+ const index = await judge.pick(
1324
+ PICK_ELEMENT_QUESTION,
1325
+ matched.map((el) => el.label),
1326
+ { intent }
1327
+ );
1328
+ if (!index) return result;
1316
1329
  result.suggestion = `Element ${index} is the one meant. Repeat the action with step.opts({ elementIndex: ${index} }) as the last argument.`;
1317
1330
  return result;
1318
1331
  }
@@ -1333,6 +1346,7 @@ function getMultipleElementsSuggestion(): string {
1333
1346
  reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
1334
1347
  A match reported as not visible can never be acted on — pick one that is.
1335
1348
  If none of them is the element you want, narrow the locator with a container or its full unique text.
1349
+ If the matches cannot be told apart, click the one you mean by appearance with visualClick().
1336
1350
  If the list is missing, call xpathCheck() to see what the locator matches.
1337
1351
  `;
1338
1352
  }
@@ -1347,7 +1361,7 @@ export function clickFailureSuggestion(attempts: Array<{ error?: string }>): str
1347
1361
  const errors = attempts.map((a) => a.error || '');
1348
1362
 
1349
1363
  if (errors.some((e) => e.includes('not enabled'))) {
1350
- 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.';
1351
1365
  }
1352
1366
 
1353
1367
  if (errors.some((e) => e.includes('intercepts pointer events'))) {
@@ -1379,6 +1393,7 @@ const MAX_DISAMBIGUATE_ELEMENTS = 10;
1379
1393
  const MAX_DISAMBIGUATE_TEXT = 80;
1380
1394
  const MAX_DISAMBIGUATE_HTML = 300;
1381
1395
  const MULTIPLE_ELEMENTS_PATTERN = 'multiple elements';
1396
+ const PICK_ELEMENT_QUESTION = 'Which listed element does the intent name?';
1382
1397
 
1383
1398
  async function extractWebElements(error: Error | null | undefined): Promise<MatchedElement[] | null> {
1384
1399
  if (!error || error.name !== 'MultipleElementsFound') return null;
@@ -1393,7 +1408,7 @@ async function extractWebElements(error: Error | null | undefined): Promise<Matc
1393
1408
  const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
1394
1409
  const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
1395
1410
  const visible = await Promise.resolve(elements[i].isVisible?.()).catch(() => undefined);
1396
- result.push({ xpath, html, text, visible });
1411
+ result.push({ xpath, html, text, visible, label: `${text || 'no text'} — ${html}` });
1397
1412
  } catch (e) {
1398
1413
  debugLog('Failed to get details for element %d: %s', i, e);
1399
1414
  }
@@ -1443,5 +1458,6 @@ interface MatchedElement {
1443
1458
  xpath: string;
1444
1459
  html: string;
1445
1460
  text: string;
1461
+ label: string;
1446
1462
  visible?: boolean;
1447
1463
  }
package/src/config.ts CHANGED
@@ -185,7 +185,7 @@ interface AIConfig {
185
185
  vision?: boolean;
186
186
  visionModel?: any;
187
187
  agenticModel?: any;
188
- decisionModel?: { provider: string; model: string; tool?: boolean; direct?: boolean };
188
+ decisionModel?: { provider: string; model: string; tool?: boolean; direct?: boolean; threshold?: number };
189
189
  retryAttempts?: number;
190
190
  retryDelay?: number;
191
191
  maxParallelRequests?: number;
@@ -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