From 48bcd7bf569d4b463185b52892e3d269bbcc7420 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Mon, 31 Aug 2026 00:16:07 +0300 Subject: [PATCH 1/4] Let the plan name the site an explorbot test run explores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `explorbot test ` built its ExplorBot with no target, so a global installation had nothing to resolve a site from and the run died in config loading with "No site to explore" before the plan was ever read. The plan already carries the URL: the CLI now reads it before the container is built, and passes it as the run target, which serves both the global installation and the EXPLORBOT_* variables. The expression for a plan's URL — its own, else the first test's — moves onto Plan as `startUrl`, replacing the four copies of it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013W3pakMDZuDEjzm6LCFMCB --- CHANGELOG.md | 12 ++++++++ bin/explorbot-cli.ts | 17 ++++++----- docs/reference/commands.md | 2 ++ src/ai/historian/codeceptjs.ts | 2 +- src/ai/historian/playwright.ts | 2 +- src/test-plan.ts | 4 +++ tests/unit/test-plan.test.ts | 55 ++++++++++++++++++++++++++++++++++ 7 files changed, 85 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1974879..f3bd66d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 2026-08-31 + +### Changes + +- `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. +- `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 ### Changes diff --git a/bin/explorbot-cli.ts b/bin/explorbot-cli.ts index 63732f3b..6604090b 100755 --- a/bin/explorbot-cli.ts +++ b/bin/explorbot-cli.ts @@ -276,7 +276,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) { @@ -293,7 +293,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`); @@ -325,9 +325,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) { @@ -335,11 +332,17 @@ addCommonOptions(program.command('test [index]').description('Execute indexArg = planfile; } + const planFile = [planfileArg, `${planfileArg}.md`].find((file) => fs.existsSync(file)); + const planTarget = planFile ? Plan.fromMarkdown(planFile).startUrl : undefined; + + 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.'); } @@ -347,7 +350,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 7b0fe905..8fb5b11e 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -431,6 +431,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//`. + ### 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/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/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(); + }); + }); }); From 7427b7c67e2198c52f3ea3313beba74ebe453ac7 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Mon, 31 Aug 2026 23:37:58 +0300 Subject: [PATCH 2/4] Resolve a plan file the same way before and after the container is built MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `explorbot test ` peeked at the plan for its URL with an existence check against the working directory alone, while `loadPlan()` resolves a name through the plans directory as well. In a global installation a saved plan named by its file name was therefore invisible to the peek, and the run died in config loading with "No site to explore" before `loadPlan()` could find it. Both paths now call one resolver, `resolvePlanPath()` in `src/utils/plan-path.ts`: a path or a name, `.md` optional, looked up in the working directory and then in the plans directory. The CLI runs before the config is loaded and has no plans directory to name, so it searches the plans directory of every registered site instead — enough to read the plan, take its URL, and let config loading resolve the site from it. `ExplorBot.resolvePlanPath()` keeps its signature and delegates. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VCCFv7WWR67iCLLkSnnMnS --- CHANGELOG.md | 2 + bin/explorbot-cli.ts | 6 ++- docs/reference/commands.md | 2 +- src/explorbot.ts | 19 +------- src/utils/plan-path.ts | 23 ++++++++++ tests/unit/plan-path.test.ts | 84 ++++++++++++++++++++++++++++++++++++ 6 files changed, 116 insertions(+), 20 deletions(-) create mode 100644 src/utils/plan-path.ts create mode 100644 tests/unit/plan-path.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f3bd66d7..3ee29e9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ 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. diff --git a/bin/explorbot-cli.ts b/bin/explorbot-cli.ts index 6604090b..b366eb9c 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(); @@ -332,8 +333,9 @@ addCommonOptions(program.command('test [index]').description('Execute indexArg = planfile; } - const planFile = [planfileArg, `${planfileArg}.md`].find((file) => fs.existsSync(file)); - const planTarget = planFile ? Plan.fromMarkdown(planFile).startUrl : undefined; + 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(); diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 8fb5b11e..1207c285 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -431,7 +431,7 @@ 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//`. +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 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/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')); + }); +}); From 525e20b61cce7f9b5285b14a24f758b3431b1760 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Tue, 1 Sep 2026 02:16:17 +0300 Subject: [PATCH 3/4] Resolve a plan file from Plan itself The resolver belongs with the class that already reads plan files, not in a file of its own: `Plan.resolvePath()` beside `Plan.fromMarkdown()`, with its tests in the Plan suite. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VCCFv7WWR67iCLLkSnnMnS --- bin/explorbot-cli.ts | 3 +- src/explorbot.ts | 3 +- src/test-plan.ts | 23 ++++++++++ src/utils/plan-path.ts | 23 ---------- tests/unit/plan-path.test.ts | 84 ------------------------------------ tests/unit/test-plan.test.ts | 81 +++++++++++++++++++++++++++++++++- 6 files changed, 104 insertions(+), 113 deletions(-) delete mode 100644 src/utils/plan-path.ts delete mode 100644 tests/unit/plan-path.test.ts diff --git a/bin/explorbot-cli.ts b/bin/explorbot-cli.ts index 55e481be..de373c43 100755 --- a/bin/explorbot-cli.ts +++ b/bin/explorbot-cli.ts @@ -20,7 +20,6 @@ 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(); @@ -336,7 +335,7 @@ addCommonOptions(program.command('test [index]').description('Execute indexArg = planfile; } - const planPath = resolvePlanPath(planfileArg); + const planPath = Plan.resolvePath(planfileArg); let planTarget: string | undefined; if (fs.existsSync(planPath)) planTarget = Plan.fromMarkdown(planPath).startUrl; diff --git a/src/explorbot.ts b/src/explorbot.ts index f483942e..24c30284 100644 --- a/src/explorbot.ts +++ b/src/explorbot.ts @@ -34,7 +34,6 @@ 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'; @@ -451,7 +450,7 @@ export class ExplorBot { } resolvePlanPath(filename: string): string { - return findPlanPath(filename, this.getPlansDir()); + return Plan.resolvePath(filename, this.getPlansDir()); } loadPlan(filename: string): Plan { diff --git a/src/test-plan.ts b/src/test-plan.ts index 35dce048..9cee1506 100644 --- a/src/test-plan.ts +++ b/src/test-plan.ts @@ -1,6 +1,9 @@ import { createHash } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; import figures from 'figures'; import type { ActionResult } from './action-result.ts'; +import { listSites } from './global-config.ts'; import { WebPageState } from './state-manager.ts'; import { tag } from './utils/logger.ts'; import { parsePlanFromMarkdown, planToAiContext, savePlanToMarkdown, savePlansToMarkdown } from './utils/test-plan-markdown.ts'; @@ -388,6 +391,8 @@ export class Test extends Task { } } +const SITE_PLANS_DIR = ['output', 'plans']; + type PlanChangeListener = (tests: Test[]) => void; export class Plan { @@ -473,6 +478,24 @@ export class Plan { updateStatus(): void {} + static resolvePath(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]); + } + static fromMarkdown(filePath: string): Plan { return parsePlanFromMarkdown(filePath); } diff --git a/src/utils/plan-path.ts b/src/utils/plan-path.ts deleted file mode 100644 index 681ccd73..00000000 --- a/src/utils/plan-path.ts +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 5cb74d4a..00000000 --- a/tests/unit/plan-path.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -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 d084fcdb..3a730790 100644 --- a/tests/unit/test-plan.test.ts +++ b/tests/unit/test-plan.test.ts @@ -1,6 +1,8 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import { unlinkSync, writeFileSync } from 'node:fs'; +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; +import os, { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { registerSite } from '../../src/global-config.ts'; import { Plan, Test } from '../../src/test-plan.ts'; describe('Plan', () => { @@ -395,4 +397,79 @@ priority: normal expect(new Plan('Test Suite').startUrl).toBeUndefined(); }); }); + + describe('resolvePath', () => { + let home: string; + let workDir: string; + let plansDir: string; + let originalCwd: string; + let homedirSpy: ReturnType; + + const writePlan = (dir: string, name: string): string => { + mkdirSync(dir, { recursive: true }); + const file = join(dir, name); + writeFileSync(file, '# Plan\n', 'utf-8'); + return file; + }; + + 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 }); + }); + + test('returns an absolute path as is', () => { + const file = writePlan(plansDir, 'saved.md'); + expect(Plan.resolvePath(file)).toBe(file); + }); + + test('appends .md to an absolute path', () => { + const file = writePlan(plansDir, 'saved.md'); + expect(Plan.resolvePath(join(plansDir, 'saved'))).toBe(file); + }); + + test('finds a plan in the working directory', () => { + writePlan(workDir, 'saved.md'); + expect(Plan.resolvePath('saved')).toBe(join(workDir, 'saved.md')); + expect(Plan.resolvePath('saved.md')).toBe(join(workDir, 'saved.md')); + }); + + test('finds a plan in the plans directory', () => { + const file = writePlan(plansDir, 'saved.md'); + expect(Plan.resolvePath('saved', plansDir)).toBe(file); + }); + + test('prefers the working directory over the plans directory', () => { + writePlan(plansDir, 'saved.md'); + writePlan(workDir, 'saved.md'); + expect(Plan.resolvePath('saved', plansDir)).toBe(join(workDir, 'saved.md')); + }); + + test('finds a plan in a registered site when no plans directory is known', () => { + const site = registerSite('https://app.example.com'); + const file = writePlan(join(site.dir, 'output', 'plans'), 'saved.md'); + expect(Plan.resolvePath('saved')).toBe(file); + }); + + test('ignores registered sites once a plans directory is known', () => { + const site = registerSite('https://app.example.com'); + writePlan(join(site.dir, 'output', 'plans'), 'saved.md'); + expect(Plan.resolvePath('saved', plansDir)).toBe(join(plansDir, 'saved.md')); + }); + + test('falls back to the plans directory when nothing is found', () => { + expect(Plan.resolvePath('missing', plansDir)).toBe(join(plansDir, 'missing.md')); + expect(Plan.resolvePath('missing')).toBe(join(workDir, 'missing.md')); + }); + }); }); From fd8202372675cfbbdcd0df94c7f42de3856079da Mon Sep 17 00:00:00 2001 From: DavertMik Date: Tue, 1 Sep 2026 02:35:51 +0300 Subject: [PATCH 4/4] Load a plan by name through Plan.loadFromFile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Plan.loadFromFile(file, plansDir?)` resolves a path or a saved-plan name and returns the plan it loaded, or null when there is none — no separate path resolver to call first. It records the file it read as `plan.filePath`, so a caller that wants the file (the `/plans` listing, the multi-suite read behind `explore --configure`) takes it from the plan instead of resolving twice. `ExplorBot.resolvePlanPath()` is gone; `loadPlan`, `loadPlans`, `PlansCommand` and both CLI plan commands go through the loader. `plan:load` resolves saved-plan names now too, as `test` does. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VCCFv7WWR67iCLLkSnnMnS --- bin/explorbot-cli.ts | 13 +++++-------- src/commands/plans-command.ts | 12 ++++++------ src/explorbot.ts | 22 +++++++--------------- src/test-plan.ts | 18 ++++++++++-------- tests/unit/plans-command.test.ts | 1 - tests/unit/test-plan.test.ts | 32 ++++++++++++++++---------------- 6 files changed, 44 insertions(+), 54 deletions(-) diff --git a/bin/explorbot-cli.ts b/bin/explorbot-cli.ts index de373c43..71a0226f 100755 --- a/bin/explorbot-cli.ts +++ b/bin/explorbot-cli.ts @@ -260,14 +260,13 @@ addCommonOptions(program.command('plan ').description('Generate test plan addCommonOptions(program.command('plan:load [index]').description('Load a plan file and display its tests. Pass index to see test details.')).action(async (planfile: string, index: string | undefined) => { try { - const resolvedPath = path.resolve(planfile); - if (!fs.existsSync(resolvedPath)) { - console.error(`Plan file not found: ${resolvedPath}`); + const plan = Plan.loadFromFile(planfile); + if (!plan?.filePath) { + console.error(`Plan file not found: ${planfile}`); process.exit(1); } - const plan = Plan.fromMarkdown(resolvedPath); - const planFile = path.basename(resolvedPath); + const planFile = path.basename(plan.filePath); if (index) { const idx = Number.parseInt(index, 10); @@ -335,9 +334,7 @@ addCommonOptions(program.command('test [index]').description('Execute indexArg = planfile; } - const planPath = Plan.resolvePath(planfileArg); - let planTarget: string | undefined; - if (fs.existsSync(planPath)) planTarget = Plan.fromMarkdown(planPath).startUrl; + const planTarget = Plan.loadFromFile(planfileArg)?.startUrl; const explorBot = new ExplorBot(buildExplorBotOptions(planTarget, options)); await explorBot.start(); diff --git a/src/commands/plans-command.ts b/src/commands/plans-command.ts index e5847e97..953068f7 100644 --- a/src/commands/plans-command.ts +++ b/src/commands/plans-command.ts @@ -79,15 +79,15 @@ export class PlansCommand extends BaseCommand { return file; } - const resolved = this.explorBot.resolvePlanPath(target); - if (!existsSync(resolved)) { - throw new Error(`Plan file not found: ${resolved}`); + const plan = Plan.loadFromFile(target, this.explorBot.getPlansDir()); + if (!plan?.filePath) { + throw new Error(`Plan file not found: ${target}`); } return { - name: path.basename(resolved), - path: resolved, - modifiedAt: statSync(resolved).mtimeMs, + name: path.basename(plan.filePath), + path: plan.filePath, + modifiedAt: statSync(plan.filePath).mtimeMs, }; } } diff --git a/src/explorbot.ts b/src/explorbot.ts index 24c30284..bb40d941 100644 --- a/src/explorbot.ts +++ b/src/explorbot.ts @@ -449,25 +449,17 @@ export class ExplorBot { return urlPart + featurePart.slice(0, maxFeatureLen) + suffix; } - resolvePlanPath(filename: string): string { - return Plan.resolvePath(filename, this.getPlansDir()); - } - loadPlan(filename: string): Plan { - const planPath = this.resolvePlanPath(filename); - if (!existsSync(planPath)) { - throw new Error(`Plan file not found: ${planPath}`); - } - this.setCurrentPlan(Plan.fromMarkdown(planPath)); - return this.currentPlan!; + const plan = Plan.loadFromFile(filename, this.getPlansDir()); + if (!plan) throw new Error(`Plan file not found: ${filename}`); + this.setCurrentPlan(plan); + return plan; } loadPlans(filename: string): Plan[] { - const planPath = this.resolvePlanPath(filename); - if (!existsSync(planPath)) { - throw new Error(`Plan file not found: ${planPath}`); - } - return parsePlansFromMarkdown(planPath); + const plan = Plan.loadFromFile(filename, this.getPlansDir()); + if (!plan?.filePath) throw new Error(`Plan file not found: ${filename}`); + return parsePlansFromMarkdown(plan.filePath); } setCurrentPlan(plan?: Plan): void { diff --git a/src/test-plan.ts b/src/test-plan.ts index 9cee1506..beeade70 100644 --- a/src/test-plan.ts +++ b/src/test-plan.ts @@ -399,6 +399,7 @@ export class Plan { title: string; tests: Test[] = []; url?: string; + filePath?: string; iteration = 0; parentPlan?: Plan; private changeListeners: PlanChangeListener[] = []; @@ -478,22 +479,23 @@ export class Plan { updateStatus(): void {} - static resolvePath(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; + static loadFromFile(file: string, plansDir?: string): Plan | null { + const names = [file]; + if (!file.endsWith('.md')) names.push(`${file}.md`); 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; + const filePath = names.map((name) => path.resolve(dir, name)).find(existsSync); + if (!filePath) continue; + const loaded = parsePlanFromMarkdown(filePath); + loaded.filePath = filePath; + return loaded; } - return path.join(plansDir || process.cwd(), names[names.length - 1]); + return null; } static fromMarkdown(filePath: string): Plan { diff --git a/tests/unit/plans-command.test.ts b/tests/unit/plans-command.test.ts index 34f2f3e2..4c2221ff 100644 --- a/tests/unit/plans-command.test.ts +++ b/tests/unit/plans-command.test.ts @@ -77,7 +77,6 @@ describe('TestCommand', () => { function createMockExplorBot(overrides: Partial = {}): ExplorBot { return { getPlansDir: () => tmpPath, - resolvePlanPath: (filename: string) => path.join(tmpPath, filename), ...overrides, } as unknown as ExplorBot; } diff --git a/tests/unit/test-plan.test.ts b/tests/unit/test-plan.test.ts index 3a730790..7233d66a 100644 --- a/tests/unit/test-plan.test.ts +++ b/tests/unit/test-plan.test.ts @@ -398,7 +398,7 @@ priority: normal }); }); - describe('resolvePath', () => { + describe('loadFromFile', () => { let home: string; let workDir: string; let plansDir: string; @@ -428,48 +428,48 @@ priority: normal rmSync(workDir, { recursive: true, force: true }); }); - test('returns an absolute path as is', () => { + test('loads an absolute path', () => { const file = writePlan(plansDir, 'saved.md'); - expect(Plan.resolvePath(file)).toBe(file); + expect(Plan.loadFromFile(file)?.filePath).toBe(file); }); test('appends .md to an absolute path', () => { const file = writePlan(plansDir, 'saved.md'); - expect(Plan.resolvePath(join(plansDir, 'saved'))).toBe(file); + expect(Plan.loadFromFile(join(plansDir, 'saved'))?.filePath).toBe(file); }); - test('finds a plan in the working directory', () => { + test('loads a plan named in the working directory', () => { writePlan(workDir, 'saved.md'); - expect(Plan.resolvePath('saved')).toBe(join(workDir, 'saved.md')); - expect(Plan.resolvePath('saved.md')).toBe(join(workDir, 'saved.md')); + expect(Plan.loadFromFile('saved')?.filePath).toBe(join(workDir, 'saved.md')); + expect(Plan.loadFromFile('saved.md')?.filePath).toBe(join(workDir, 'saved.md')); }); - test('finds a plan in the plans directory', () => { + test('loads a plan named in the plans directory', () => { const file = writePlan(plansDir, 'saved.md'); - expect(Plan.resolvePath('saved', plansDir)).toBe(file); + expect(Plan.loadFromFile('saved', plansDir)?.filePath).toBe(file); }); test('prefers the working directory over the plans directory', () => { writePlan(plansDir, 'saved.md'); writePlan(workDir, 'saved.md'); - expect(Plan.resolvePath('saved', plansDir)).toBe(join(workDir, 'saved.md')); + expect(Plan.loadFromFile('saved', plansDir)?.filePath).toBe(join(workDir, 'saved.md')); }); - test('finds a plan in a registered site when no plans directory is known', () => { + test('loads a plan saved for a registered site when no plans directory is known', () => { const site = registerSite('https://app.example.com'); const file = writePlan(join(site.dir, 'output', 'plans'), 'saved.md'); - expect(Plan.resolvePath('saved')).toBe(file); + expect(Plan.loadFromFile('saved')?.filePath).toBe(file); }); test('ignores registered sites once a plans directory is known', () => { const site = registerSite('https://app.example.com'); writePlan(join(site.dir, 'output', 'plans'), 'saved.md'); - expect(Plan.resolvePath('saved', plansDir)).toBe(join(plansDir, 'saved.md')); + expect(Plan.loadFromFile('saved', plansDir)).toBeNull(); }); - test('falls back to the plans directory when nothing is found', () => { - expect(Plan.resolvePath('missing', plansDir)).toBe(join(plansDir, 'missing.md')); - expect(Plan.resolvePath('missing')).toBe(join(workDir, 'missing.md')); + test('returns null when the plan is nowhere to be found', () => { + expect(Plan.loadFromFile('missing', plansDir)).toBeNull(); + expect(Plan.loadFromFile('missing')).toBeNull(); }); }); });