diff --git a/CHANGELOG.md b/CHANGELOG.md index 058487a6..241ed556 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## 2026-09-18 +### New CLI Options + +- **`--base-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()`, diff --git a/bin/explorbot-cli.ts b/bin/explorbot-cli.ts index 0ef34f0a..931e3957 100755 --- a/bin/explorbot-cli.ts +++ b/bin/explorbot-cli.ts @@ -55,6 +55,7 @@ interface CLIOptions { debug?: boolean; config?: string; path?: string; + baseUrl?: string; show?: boolean; headless?: boolean; incognito?: boolean; @@ -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, @@ -82,6 +84,7 @@ function addCommonOptions(cmd: Command): Command { .option('--debug', 'Enable debug logging (same as --verbose)') .option('-c, --config ', 'Path to configuration file') .option('-p, --path ', 'Working directory path') + .option('--base-url ', 'Run against this URL: its path scopes relative paths from plans, its query params ride along with every page load') .option('-s, --show', 'Show browser window') .option('--headless', 'Run browser in headless mode') .option('--incognito', 'Run without recording experiences') diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 778609b8..c7d584ef 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -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 to configuration file | | `-p, --path ` | Working directory path | +| `--base-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). diff --git a/src/config.ts b/src/config.ts index a3bd89ae..e490ae61 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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() {} @@ -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); // Store the initial working directory for reference if (!process.env.INITIAL_CWD) { @@ -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; @@ -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; @@ -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; } @@ -521,6 +536,7 @@ export class ConfigParser { ConfigParser.instance.site = null; ConfigParser.instance.siteStartPath = '/'; ConfigParser.instance.siteConfigPath = null; + ConfigParser.instance.runBase = null; } } @@ -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); @@ -925,8 +941,23 @@ export async function createModel(provider: string, modelId: string): Promise { 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(); }); @@ -347,6 +347,7 @@ class Explorer { const attached = this.options?.attachedBrowser; if (!attached) { await this.playwrightHelper._createContextPage(this.createBrowserContextOptions()); + await this.pinBaseQuery(); return; } @@ -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 { + 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 { diff --git a/tests/unit/global-config.test.ts b/tests/unit/global-config.test.ts index 844a362b..5e784138 100644 --- a/tests/unit/global-config.test.ts +++ b/tests/unit/global-config.test.ts @@ -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();