Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

## 2026-09-18

### New CLI Options

- **`--base-url <url>`** — Point one run at one area of a site without editing any config file. The origin selects the
site, so the knowledge and experience already stored for that host still apply. The path is prepended to relative
paths coming from plans and commands, so a plan written against `/runs` runs inside the area you name. The query
parameters are added to every page load, which keeps a preview deployment selected for the whole run. Absolute URLs
are left alone, so a login page outside the area still works.

```bash
explorbot test plans/runs.md '*' --base-url 'https://app.example.com/teams/acme/'
explorbot test plans/runs.md '*' --base-url 'https://app.example.com/teams/acme/?preview=pr-42'
explorbot explore /settings --base-url 'https://app.example.com/teams/acme/'
```

### Changes

- Reporter: internal page-inspection calls Explorbot makes on its own — `I.grabBrowserLogs()`, `I.grabSource()`,
Expand Down
3 changes: 3 additions & 0 deletions bin/explorbot-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ interface CLIOptions {
debug?: boolean;
config?: string;
path?: string;
baseUrl?: string;
show?: boolean;
headless?: boolean;
incognito?: boolean;
Expand All @@ -65,6 +66,7 @@ interface CLIOptions {
function buildExplorBotOptions(from: string | undefined, options: CLIOptions): ExplorBotOptions {
return {
from,
baseUrl: options.baseUrl,
verbose: options.verbose || options.debug,
config: options.config,
path: options.path,
Expand All @@ -82,6 +84,7 @@ function addCommonOptions(cmd: Command): Command {
.option('--debug', 'Enable debug logging (same as --verbose)')
.option('-c, --config <path>', 'Path to configuration file')
.option('-p, --path <path>', 'Working directory path')
.option('--base-url <url>', 'Run against this URL: its path scopes relative paths from plans, its query params ride along with every page load')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also exposes --base-url to rerun, but rerun executes saved I.amOnPage() calls directly through Mocha, bypassing the new path-scoping logic

.option('-s, --show', 'Show browser window')
.option('--headless', 'Run browser in headless mode')
.option('--incognito', 'Run without recording experiences')
Expand Down
12 changes: 12 additions & 0 deletions docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,23 @@ Every CLI command that drives a browser accepts these options (`start`, `explore
| `--debug` | Enable debug logging (same as `--verbose`) |
| `-c, --config <path>` | Path to configuration file |
| `-p, --path <path>` | Working directory path |
| `--base-url <url>` | Run against this URL: its path scopes relative paths, its query params ride along with every page load |
| `-s, --show` | Show browser window |
| `--headless` | Run browser in headless mode |
| `--incognito` | Run without recording experiences |
| `--session [file]` | Save/restore browser session (cookies, localStorage) from file |

### `--base-url`

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.

```bash
npx explorbot test tests/plans/runs.md '*' \
--base-url 'https://app.example.com/teams/acme/?preview=pr-42'
```

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.

### `--knowledge`

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).
Expand Down
41 changes: 36 additions & 5 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ export class ConfigParser {
private site: SiteRecord | null = null;
private siteStartPath = '/';
private siteConfigPath: string | null = null;
private runBase: RunBase | null = null;

private constructor() {}

Expand Down Expand Up @@ -366,6 +367,7 @@ export class ConfigParser {
if (this.config && !options?.config && !options?.path && this.runtimeTarget === target) {
return this.config;
}
this.runBase = parseRunBase(options?.baseUrl);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In config-free mode, EXPLORBOT_URL still wins when selecting the output root. With both values set, --base-url changes the browser origin but knowledge, experience, and output can come from the wrong site


// Store the initial working directory for reference
if (!process.env.INITIAL_CWD) {
Expand Down Expand Up @@ -409,7 +411,7 @@ export class ConfigParser {
log(`Configuration built from EXPLORBOT_* environment variables. Output: ${outputRoot}`);
}

let config = this.resolveConfig(loadedConfig as ExplorbotConfig, options);
let config = this.resolveConfig(loadedConfig as ExplorbotConfig);
await resolveConfigModels(config.ai);
this.site = null;
this.siteConfigPath = null;
Expand Down Expand Up @@ -481,6 +483,18 @@ export class ConfigParser {
return this.siteConfigPath;
}

public applyBasePath(target: string): string {
const base = this.runBase?.path;
if (!base) return target;
if (!target.startsWith('/')) return target;
if (target === base || target.startsWith(`${base}/`) || target.startsWith(`${base}?`)) return target;
return `${base}${target}`;
}

public getBaseQuery(): string {
return this.runBase?.query || '';
}

public resolveTargetPath(target?: string): string {
if (!this.site) {
const configured = this.config?.playwright?.url || this.config?.web?.url;
Expand All @@ -489,12 +503,13 @@ export class ConfigParser {
if (targetOrigin && baseOrigin && targetOrigin !== baseOrigin) {
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.`);
}
return target || '/';
return this.applyBasePath(target || '/');
}
if (!target) return this.siteStartPath;

const resolved = resolveSiteTarget(target, this.site.url);
if (resolved.baseUrl !== this.site.url) return target;
if (target.startsWith('/')) return this.applyBasePath(resolved.path);
return resolved.path;
}

Expand All @@ -521,6 +536,7 @@ export class ConfigParser {
ConfigParser.instance.site = null;
ConfigParser.instance.siteStartPath = '/';
ConfigParser.instance.siteConfigPath = null;
ConfigParser.instance.runBase = null;
}
}

Expand Down Expand Up @@ -683,15 +699,15 @@ export class ConfigParser {
}
}

private resolveConfig(config: ExplorbotConfig, options?: { baseUrl?: string }): ExplorbotConfig {
private resolveConfig(config: ExplorbotConfig): ExplorbotConfig {
if (config.web?.url && !config.playwright?.url) {
config.playwright = config.playwright || { browser: 'chromium', url: '' };
config.playwright.url = config.web.url;
}

if (options?.baseUrl) {
if (this.runBase) {
config.playwright = config.playwright || { browser: 'chromium', url: '' };
config.playwright.url = options.baseUrl;
config.playwright.url = this.runBase.origin;
}

resolveLangfuse(config.ai);
Expand Down Expand Up @@ -925,8 +941,23 @@ export async function createModel(provider: string, modelId: string): Promise<an
return (await info.load())(modelId);
}

function parseRunBase(baseUrl?: string): RunBase | null {
if (!baseUrl) return null;

const url = URL.parse(baseUrl);
if (!url) throw new Error(`Base URL must be a full URL like https://app.example.com/team/acme, got "${baseUrl}"`);

return { origin: url.origin, path: url.pathname.replace(/\/+$/, ''), query: url.search };
}

type ModelRole = 'model' | 'visionModel' | 'agenticModel';

interface RunBase {
origin: string;
path: string;
query: string;
}

interface ConfiguredModel {
name: string;
provider: string;
Expand Down
2 changes: 2 additions & 0 deletions src/explorbot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,8 @@ export class ExplorBot {
setCurrentPlan(plan?: Plan): void {
this.currentPlan = plan;
if (!plan) return;
if (plan.url) plan.url = this.configParser.applyBasePath(plan.url);
for (const test of plan.tests) test.startUrl = this.configParser.applyBasePath(test.startUrl);
if (!this.sessionPlans.includes(plan)) {
this.sessionPlans.push(plan);
}
Expand Down
30 changes: 28 additions & 2 deletions src/explorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import stepsListener from 'codeceptjs/lib/listener/steps';
import storeListener from 'codeceptjs/lib/listener/store';
import { createTest } from 'codeceptjs/lib/mocha/test';
import dedent from 'dedent';
import type { Browser, BrowserContextOptions, Page } from 'playwright';
import type { Browser, BrowserContextOptions, Page, Route } from 'playwright';
import { ActionResult } from './action-result.ts';
import Action from './action.js';
import type { RequestStore } from './api/request-store.ts';
Expand Down Expand Up @@ -163,7 +163,7 @@ class Explorer {

async visit(url: string, opts: CaptureOpts = {}): Promise<ActionResult> {
return this.runWithRecovery('visit', async () => {
const action = await this.visitOnce(url);
const action = await this.visitOnce(ConfigParser.getInstance().applyBasePath(url));
if (opts.screenshot) return action.capturePageState({ includeScreenshot: true });
return action.getActionResult() ?? action.capturePageState();
});
Expand Down Expand Up @@ -347,6 +347,7 @@ class Explorer {
const attached = this.options?.attachedBrowser;
if (!attached) {
await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
await this.pinBaseQuery();
return;
}

Expand All @@ -356,6 +357,31 @@ class Explorer {
this.playwrightHelper.browserContext = context;
await this.playwrightHelper._setPage(page);
debugLog(`Adopted attached browser page: ${page.url()}`);
await this.pinBaseQuery();
}

private async pinBaseQuery(): Promise<void> {
const query = ConfigParser.getInstance().getBaseQuery();
if (!query) return;

const pinned = new URLSearchParams(query);
const origin = URL.parse(this.config.playwright.url)?.origin;

await this.playwrightHelper.browserContext.route('**/*', async (route: Route) => {
const request = route.request();
if (request.resourceType() !== 'document') return route.continue();

const url = URL.parse(request.url());
if (!url || url.origin !== origin) return route.continue();

const missing = [...pinned].filter(([key]) => !url.searchParams.has(key));
if (!missing.length) return route.continue();

for (const [key, value] of missing) url.searchParams.set(key, value);
return route.continue({ url: url.toString() });
});

tag('info').log(`Every page load of ${origin} carries ${query}`);
}

private createBrowserContextOptions(): BrowserContextOptions {
Expand Down
13 changes: 13 additions & 0 deletions tests/unit/global-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,19 @@ describe('global mode', () => {
expect(parser.resolveTargetPath('app.example.com/users')).toBe('/users');
});

it('scopes relative targets to the path of the base URL', async () => {
const parser = ConfigParser.getInstance();
writeGlobalConfig();

const config = await parser.loadConfig({ path: workDir, baseUrl: 'https://app.example.com/teams/acme/?preview=pr-42' });

expect(config.playwright.url).toBe('https://app.example.com');
expect(parser.getBaseQuery()).toBe('?preview=pr-42');
expect(parser.resolveTargetPath('/runs')).toBe('/teams/acme/runs');
expect(parser.resolveTargetPath('/teams/acme/runs')).toBe('/teams/acme/runs');
expect(parser.resolveTargetPath('https://app.example.com/users/sign_in')).toBe('/users/sign_in');
});

it('keeps targets outside the site untouched', async () => {
const parser = ConfigParser.getInstance();
writeGlobalConfig();
Expand Down
Loading