diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b4ed87..e04da54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ - Experience Tracker: An unnamed panel no longer marks a page's own experience file as panel-scoped. When it did, that page's entire experience became invisible whenever no panel was open. +- Test Plan: A plan file passed to `explorbot test` is now resolved once — the pre-config peek and + the run itself share the same parsed file, instead of being scanned and parsed twice with two + different search orders. Cross-site directory scanning (looking a path up across every registered + site, used by plan loading and by prima's `status`) now has a single owner in `global-config.ts`. - [Planner] A test now covers one operation instead of a record's whole lifecycle. Creating a record, renaming it and deleting it used to land in a single scenario, because the planner was told to merge any scenarios that depended on each other. Those chains were the longest tests of a diff --git a/bin/explorbot-cli.ts b/bin/explorbot-cli.ts index bc5a37a..d3cfea7 100755 --- a/bin/explorbot-cli.ts +++ b/bin/explorbot-cli.ts @@ -336,12 +336,14 @@ addCommonOptions(program.command('test [index]').description('Execute indexArg = planfile; } - const planTarget = Plan.loadFromFile(planfileArg)?.startUrl; + const peeked = Plan.loadFromFile(planfileArg); - const explorBot = new ExplorBot(buildExplorBotOptions(planTarget, options)); + const explorBot = new ExplorBot(buildExplorBotOptions(peeked?.startUrl, options)); await explorBot.start(); - const plan = explorBot.loadPlan(planfileArg); + let plan = peeked; + if (plan) explorBot.setCurrentPlan(plan); + if (!plan) plan = explorBot.loadPlan(planfileArg); const pending = plan.getPendingTests(); log(`Plan loaded: "${plan.title}" (${plan.tests.length} tests, ${pending.length} pending)`); diff --git a/boat/prima/src/prima.ts b/boat/prima/src/prima.ts index c5cfdbd..392c13f 100644 --- a/boat/prima/src/prima.ts +++ b/boat/prima/src/prima.ts @@ -15,7 +15,7 @@ import { getAliveEndpoint, launchServer, listInstances, stopServer } from '../.. import { ConfigCommand } from '../../../src/commands/config-command.ts'; import { ConfigMissingError, ConfigParser, EXPLORBOT_ENV_VARS, type ExplorbotConfig, outputPath } from '../../../src/config.ts'; import { ExplorBot } from '../../../src/explorbot.ts'; -import { listSites } from '../../../src/global-config.ts'; +import { findSiteWith, listSites } from '../../../src/global-config.ts'; import { Reporter } from '../../../src/reporter.ts'; import type { WebPageState } from '../../../src/state-manager.ts'; import { Stats } from '../../../src/stats.ts'; @@ -1067,8 +1067,7 @@ export class Prima { async status(hash: string): Promise { if (!this.artifactsDir) { - const sites = listSites(); - const site = sites.find((candidate) => existsSync(path.join(candidate.dir, 'output', 'prima', hash))) || sites[0]; + const site = findSiteWith(path.join('output', 'prima', hash)) || listSites()[0]; if (site && !this.configBaseUrl()) this.sessionUrl = site.url; await this.loadConfig(); } diff --git a/src/commands/plans-command.ts b/src/commands/plans-command.ts index 953068f..e98247c 100644 --- a/src/commands/plans-command.ts +++ b/src/commands/plans-command.ts @@ -21,8 +21,7 @@ export class PlansCommand extends BaseCommand { return; } - const file = this.resolvePlanFile(target, files); - const plan = Plan.fromMarkdown(file.path); + const { plan, file } = this.resolvePlanFile(target, files); this.printPlanDetails(plan, file); } @@ -71,12 +70,12 @@ export class PlansCommand extends BaseCommand { tag('info').log(`${getCliName()} test 1 --from-plan ${file.name}`); } - private resolvePlanFile(target: string, files: PlanFile[]): PlanFile { + private resolvePlanFile(target: string, files: PlanFile[]): { plan: Plan; file: PlanFile } { const index = Number.parseInt(target, 10); if (!Number.isNaN(index) && String(index) === target) { const file = files[index - 1]; if (!file) throw new Error(`Plan #${target} not found. Available: 1-${files.length}`); - return file; + return { plan: Plan.fromMarkdown(file.path), file }; } const plan = Plan.loadFromFile(target, this.explorBot.getPlansDir()); @@ -84,11 +83,12 @@ export class PlansCommand extends BaseCommand { throw new Error(`Plan file not found: ${target}`); } - return { + const file = { name: path.basename(plan.filePath), path: plan.filePath, modifiedAt: statSync(plan.filePath).mtimeMs, }; + return { plan, file }; } } diff --git a/src/explorbot.ts b/src/explorbot.ts index fe23e7e..c9891f3 100644 --- a/src/explorbot.ts +++ b/src/explorbot.ts @@ -465,9 +465,9 @@ export class ExplorBot { } loadPlans(filename: string): Plan[] { - const plan = Plan.loadFromFile(filename, this.getPlansDir()); - if (!plan?.filePath) throw new Error(`Plan file not found: ${filename}`); - return parsePlansFromMarkdown(plan.filePath); + const filePath = Plan.resolveFile(filename, this.getPlansDir()); + if (!filePath) throw new Error(`Plan file not found: ${filename}`); + return parsePlansFromMarkdown(filePath); } setCurrentPlan(plan?: Plan): void { diff --git a/src/global-config.ts b/src/global-config.ts index 6c3786d..1caba58 100644 --- a/src/global-config.ts +++ b/src/global-config.ts @@ -47,6 +47,14 @@ export function listSites(): SiteRecord[] { .sort((a, b) => b.lastRunAt.localeCompare(a.lastRunAt)); } +export function findSiteWith(subpath: string): SiteRecord | undefined { + return listSites().find((site) => existsSync(join(site.dir, subpath))); +} + +export function listSitePlanDirs(): string[] { + return listSites().map((site) => join(site.dir, 'output', 'plans')); +} + export function registerSite(baseUrl: string): SiteRecord { const folder = siteFolderName(baseUrl); const dir = join(sitesDir(), folder); diff --git a/src/test-plan.ts b/src/test-plan.ts index beeade7..29be0df 100644 --- a/src/test-plan.ts +++ b/src/test-plan.ts @@ -3,7 +3,7 @@ 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 { listSitePlanDirs } 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'; @@ -391,8 +391,6 @@ export class Test extends Task { } } -const SITE_PLANS_DIR = ['output', 'plans']; - type PlanChangeListener = (tests: Test[]) => void; export class Plan { @@ -479,25 +477,29 @@ export class Plan { updateStatus(): void {} - static loadFromFile(file: string, plansDir?: string): Plan | null { + static resolveFile(file: string, plansDir?: string): string | 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))); + if (!plansDir) dirs.push(...listSitePlanDirs()); for (const dir of dirs) { const filePath = names.map((name) => path.resolve(dir, name)).find(existsSync); - if (!filePath) continue; - const loaded = parsePlanFromMarkdown(filePath); - loaded.filePath = filePath; - return loaded; + if (filePath) return filePath; } - return null; } + static loadFromFile(file: string, plansDir?: string): Plan | null { + const filePath = Plan.resolveFile(file, plansDir); + if (!filePath) return null; + const loaded = parsePlanFromMarkdown(filePath); + loaded.filePath = filePath; + return loaded; + } + static fromMarkdown(filePath: string): Plan { return parsePlanFromMarkdown(filePath); } diff --git a/tests/unit/test-plan.test.ts b/tests/unit/test-plan.test.ts index 7233d66..95fa86c 100644 --- a/tests/unit/test-plan.test.ts +++ b/tests/unit/test-plan.test.ts @@ -472,4 +472,42 @@ priority: normal expect(Plan.loadFromFile('missing')).toBeNull(); }); }); + + describe('resolveFile', () => { + let home: string; + let workDir: 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-')); + 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 the path for a name without .md', () => { + const file = writePlan(workDir, 'saved.md'); + expect(Plan.resolveFile('saved')).toBe(file); + }); + + test('returns null for a missing name', () => { + expect(Plan.resolveFile('missing')).toBeNull(); + }); + }); });