diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b0d3e34..954ef465 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,8 +22,6 @@ on the evidence it recorded — passed only if every expected outcome was already met — while its generated code, screencast, and Testomatio steps are still saved. The session report notes that the time budget was reached. -### Changes - - Prima: every command now prints `### Artifacts` naming the files it just wrote — the ARIA tree and the html always, plus the screenshot and network log when they were captured. Until now those files were written on every command but only named when a `check` came back with a CONTRADICTION, so the @@ -37,6 +35,15 @@ finished. It also looks the hash up across every recorded site instead of assuming the most recent one, and lists everything else kept under the hash. A hash that really is missing now reports the directory it looked in. +- `explorbot test ` takes the site to run against from the plan itself — the URL of its + `### Prerequisite` section, or the `## Requirements` URL of its first test. A plan is therefore + enough to run it from any directory with a global installation (`~/.explorbot/config.js`) or with + the `EXPLORBOT_*` variables, both of which used to refuse to start with "No site to explore" + because the command named no URL of its own. + The plan is looked up the same way whichever name it is given — a path, or the bare file name of a + saved plan, which is searched for in the plans directory of every registered site. +- `explorbot test ` without an index runs every enabled test in the plan, as the help and + the docs already described. It used to run only the first pending one. ## 2026-08-30 diff --git a/bin/explorbot-cli.ts b/bin/explorbot-cli.ts index 8827988c..55e481be 100755 --- a/bin/explorbot-cli.ts +++ b/bin/explorbot-cli.ts @@ -20,6 +20,7 @@ import { isVerboseMode, log, setPreserveConsoleLogs, setQuietMode, tag } from '. import { jsonToTable } from '../src/utils/markdown-parser.js'; import { parseMarkdownToTerminal } from '../src/utils/markdown-terminal.js'; import { type NextStepSection, printNextSteps, relativeToCwd } from '../src/utils/next-steps.ts'; +import { resolvePlanPath } from '../src/utils/plan-path.ts'; const program = new Command(); const cli = getCliName(); @@ -279,7 +280,7 @@ addCommonOptions(program.command('plan:load [index]').description('Lo const lines: string[] = []; lines.push(`## #${idx} ${test.scenario}\n`); lines.push(`**Priority:** ${test.priority}`); - const planUrl = plan.url || plan.tests[0]?.startUrl; + const planUrl = plan.startUrl; if (planUrl) lines.push(`**Plan URL:** ${planUrl}`); if (test.startUrl && test.startUrl !== planUrl) lines.push(`**Test URL:** ${test.startUrl}`); if (test.plannedSteps.length) { @@ -296,7 +297,7 @@ addCommonOptions(program.command('plan:load [index]').description('Lo return; } - const planUrl = plan.url || plan.tests[0]?.startUrl; + const planUrl = plan.startUrl; const lines: string[] = [`**${plan.title}** (${plan.tests.length} tests)\n`]; if (planUrl) { lines.push(`URL: ${planUrl}\n`); @@ -328,9 +329,6 @@ addCommonOptions(program.command('plan:load [index]').description('Lo addCommonOptions(program.command('test [index]').description('Execute tests from a plan file. Index: 1, 1,3, 1-5, *, all').option('--grep ', 'Run tests matching pattern').option('--from-plan ', 'Load plan file when the first argument is a test index')).action( async (planfile, index, options) => { try { - const explorBot = new ExplorBot(buildExplorBotOptions(undefined, options)); - await explorBot.start(); - let planfileArg = planfile; let indexArg = index; if (options.fromPlan) { @@ -338,11 +336,18 @@ addCommonOptions(program.command('test [index]').description('Execute indexArg = planfile; } + const planPath = resolvePlanPath(planfileArg); + let planTarget: string | undefined; + if (fs.existsSync(planPath)) planTarget = Plan.fromMarkdown(planPath).startUrl; + + const explorBot = new ExplorBot(buildExplorBotOptions(planTarget, options)); + await explorBot.start(); + const plan = explorBot.loadPlan(planfileArg); const pending = plan.getPendingTests(); log(`Plan loaded: "${plan.title}" (${plan.tests.length} tests, ${pending.length} pending)`); - const startUrl = plan.url || pending[0]?.startUrl; + const startUrl = plan.startUrl; if (!startUrl) { throw new Error('No URL found in plan or tests. Cannot determine where to navigate.'); } @@ -350,7 +355,7 @@ addCommonOptions(program.command('test [index]').description('Execute log(`Navigating to ${startUrl}`); await explorBot.visit(startUrl); - let args = ''; + let args = '*'; if (indexArg) args = indexArg; else if (options.grep) args = options.grep; diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 23aeefeb..12b91bdb 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -434,6 +434,8 @@ npx explorbot test 3 --from-plan output/plans/login.md # index first, plan via | `--grep ` | Run only tests whose scenario matches the pattern | | `--from-plan ` | Load this plan file when the first argument is a test index | +The plan names the site it runs against: the URL of its `### Prerequisite` section, or the `## Requirements` URL of its first test. With a [global installation](configuration.md#running-from-anywhere-the-global-installation) that is enough to run a plan from any directory without a project config — `npx explorbot test ~/plans/checkout.md` registers the site and stores its output under `~/.explorbot/sites//`. Naming a saved plan is enough too: `npx explorbot test checkout` looks for `checkout.md` in the current directory, then in the plans directory of every registered site. + ### drill Drill all components on a page to learn interactions. diff --git a/src/ai/historian/codeceptjs.ts b/src/ai/historian/codeceptjs.ts index b48e3401..b1d8f064 100644 --- a/src/ai/historian/codeceptjs.ts +++ b/src/ai/historian/codeceptjs.ts @@ -64,7 +64,7 @@ export function WithCodeceptJS(Base: T) { lines.push(`Feature('${escapeString(plan.title)}')`); lines.push(''); - const startUrl = plan.url || plan.tests[0]?.startUrl; + const startUrl = plan.startUrl; if (startUrl) { lines.push('Before(({ I }) => {'); lines.push(` I.amOnPage('${escapeString(startUrl)}');`); diff --git a/src/ai/historian/playwright.ts b/src/ai/historian/playwright.ts index df8b7e7b..9cffc5b4 100644 --- a/src/ai/historian/playwright.ts +++ b/src/ai/historian/playwright.ts @@ -98,7 +98,7 @@ export function WithPlaywright(Base: T) { lines.push(''); lines.push(`test.describe('${escapeString(plan.title)}', () => {`); - const startUrl = plan.url || plan.tests[0]?.startUrl; + const startUrl = plan.startUrl; if (startUrl) { lines.push(' test.beforeEach(async ({ page }) => {'); lines.push(` await page.goto('${escapeString(startUrl)}');`); diff --git a/src/explorbot.ts b/src/explorbot.ts index 5421ce7f..f483942e 100644 --- a/src/explorbot.ts +++ b/src/explorbot.ts @@ -34,6 +34,7 @@ import { Plan, type Test } from './test-plan.ts'; import { browserErrorMessage } from './utils/browser-errors.ts'; import { setVerboseMode, tag } from './utils/logger.ts'; import { relativeToCwd } from './utils/next-steps.ts'; +import { resolvePlanPath as findPlanPath } from './utils/plan-path.ts'; import { sanitizeFilename } from './utils/strings.ts'; import { parsePlansFromMarkdown } from './utils/test-plan-markdown.ts'; @@ -450,23 +451,7 @@ export class ExplorBot { } resolvePlanPath(filename: string): string { - let planPath = filename; - - if (path.isAbsolute(filename)) { - if (!existsSync(planPath) && !filename.endsWith('.md')) { - planPath = `${filename}.md`; - } - } else if (existsSync(filename) || existsSync(`${filename}.md`)) { - planPath = existsSync(filename) ? filename : `${filename}.md`; - } else { - const plansDir = this.getPlansDir(); - planPath = path.join(plansDir, filename); - if (!existsSync(planPath) && !filename.endsWith('.md')) { - planPath = path.join(plansDir, `${filename}.md`); - } - } - - return planPath; + return findPlanPath(filename, this.getPlansDir()); } loadPlan(filename: string): Plan { diff --git a/src/test-plan.ts b/src/test-plan.ts index 0d32f288..35dce048 100644 --- a/src/test-plan.ts +++ b/src/test-plan.ts @@ -455,6 +455,10 @@ export class Plan { return this.tests.filter((test) => test.status === 'pending' && test.enabled); } + get startUrl(): string | undefined { + return this.url || this.tests[0]?.startUrl; + } + get isComplete(): boolean { return this.tests.length > 0 && this.tests.every((test) => test.hasFinished); } diff --git a/src/utils/plan-path.ts b/src/utils/plan-path.ts new file mode 100644 index 00000000..681ccd73 --- /dev/null +++ b/src/utils/plan-path.ts @@ -0,0 +1,23 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { listSites } from '../global-config.ts'; + +const SITE_PLANS_DIR = ['output', 'plans']; + +export function resolvePlanPath(filename: string, plansDir?: string): string { + const names = [filename]; + if (!filename.endsWith('.md')) names.push(`${filename}.md`); + + if (path.isAbsolute(filename)) return names.find(existsSync) || filename; + + const dirs = [process.cwd()]; + if (plansDir) dirs.push(plansDir); + if (!plansDir) dirs.push(...listSites().map((site) => path.join(site.dir, ...SITE_PLANS_DIR))); + + for (const dir of dirs) { + const found = names.map((name) => path.join(dir, name)).find(existsSync); + if (found) return found; + } + + return path.join(plansDir || process.cwd(), names[names.length - 1]); +} diff --git a/tests/unit/plan-path.test.ts b/tests/unit/plan-path.test.ts new file mode 100644 index 00000000..5cb74d4a --- /dev/null +++ b/tests/unit/plan-path.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os, { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { registerSite } from '../../src/global-config.ts'; +import { resolvePlanPath } from '../../src/utils/plan-path.ts'; + +let home: string; +let workDir: string; +let plansDir: string; +let originalCwd: string; +let homedirSpy: ReturnType; + +function writePlan(dir: string, name: string): string { + mkdirSync(dir, { recursive: true }); + const file = join(dir, name); + writeFileSync(file, '# Plan\n', 'utf8'); + return file; +} + +function registerSitePlans(url: string, name: string): string { + const site = registerSite(url); + return writePlan(join(site.dir, 'output', 'plans'), name); +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'explorbot-home-')); + workDir = mkdtempSync(join(tmpdir(), 'explorbot-work-')); + plansDir = join(workDir, 'output', 'plans'); + homedirSpy = spyOn(os, 'homedir').mockReturnValue(home); + originalCwd = process.cwd(); + process.chdir(workDir); +}); + +afterEach(() => { + process.chdir(originalCwd); + homedirSpy.mockRestore(); + rmSync(home, { recursive: true, force: true }); + rmSync(workDir, { recursive: true, force: true }); +}); + +describe('resolvePlanPath', () => { + it('returns an absolute path as is', () => { + const file = writePlan(plansDir, 'saved.md'); + expect(resolvePlanPath(file)).toBe(file); + }); + + it('appends .md to an absolute path', () => { + const file = writePlan(plansDir, 'saved.md'); + expect(resolvePlanPath(join(plansDir, 'saved'))).toBe(file); + }); + + it('finds a plan in the working directory', () => { + writePlan(workDir, 'saved.md'); + expect(resolvePlanPath('saved')).toBe(join(workDir, 'saved.md')); + expect(resolvePlanPath('saved.md')).toBe(join(workDir, 'saved.md')); + }); + + it('finds a plan in the plans directory', () => { + const file = writePlan(plansDir, 'saved.md'); + expect(resolvePlanPath('saved', plansDir)).toBe(file); + }); + + it('prefers the working directory over the plans directory', () => { + writePlan(plansDir, 'saved.md'); + writePlan(workDir, 'saved.md'); + expect(resolvePlanPath('saved', plansDir)).toBe(join(workDir, 'saved.md')); + }); + + it('finds a plan in a registered site when no plans directory is known', () => { + const file = registerSitePlans('https://app.example.com', 'saved.md'); + expect(resolvePlanPath('saved')).toBe(file); + }); + + it('ignores registered sites once a plans directory is known', () => { + registerSitePlans('https://app.example.com', 'saved.md'); + expect(resolvePlanPath('saved', plansDir)).toBe(join(plansDir, 'saved.md')); + }); + + it('falls back to the plans directory when nothing is found', () => { + expect(resolvePlanPath('missing', plansDir)).toBe(join(plansDir, 'missing.md')); + expect(resolvePlanPath('missing')).toBe(join(workDir, 'missing.md')); + }); +}); diff --git a/tests/unit/test-plan.test.ts b/tests/unit/test-plan.test.ts index 03b7b23c..d084fcdb 100644 --- a/tests/unit/test-plan.test.ts +++ b/tests/unit/test-plan.test.ts @@ -340,4 +340,59 @@ priority: low expect(context).toContain('- Enter text'); }); }); + + describe('startUrl', () => { + test('should take the prerequisite URL of the suite', () => { + const markdown = ` +# Test Suite + +### Prerequisite + +* URL: https://app.example.com/projects/demo/runs + + +# Test Scenario + +## Requirements +https://app.example.com/projects/demo/runs + +## Expected +* Page is rendered +`; + + writeFileSync(testFilePath, markdown, 'utf-8'); + const plan = Plan.fromMarkdown(testFilePath); + + expect(plan.startUrl).toBe('https://app.example.com/projects/demo/runs'); + }); + + test('should fall back to the first test URL when suite has no prerequisite', () => { + const markdown = ` +# Test Suite + + +# Test Scenario + +## Requirements +/login + +## Expected +* Login form is shown +`; + + writeFileSync(testFilePath, markdown, 'utf-8'); + const plan = Plan.fromMarkdown(testFilePath); + + expect(plan.url).toBeUndefined(); + expect(plan.startUrl).toBe('/login'); + }); + + test('should be undefined when neither suite nor tests carry a URL', () => { + expect(new Plan('Test Suite').startUrl).toBeUndefined(); + }); + }); });