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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## 2026-08-31

### Changes

- `explorbot test <planfile>` 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 <planfile>` 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
Expand Down
17 changes: 10 additions & 7 deletions bin/explorbot-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ addCommonOptions(program.command('plan:load <planfile> [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) {
Expand All @@ -293,7 +293,7 @@ addCommonOptions(program.command('plan:load <planfile> [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`);
Expand Down Expand Up @@ -325,29 +325,32 @@ addCommonOptions(program.command('plan:load <planfile> [index]').description('Lo
addCommonOptions(program.command('test <planfile> [index]').description('Execute tests from a plan file. Index: 1, 1,3, 1-5, *, all').option('--grep <pattern>', 'Run tests matching pattern').option('--from-plan <file>', '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) {
planfileArg = options.fromPlan;
indexArg = planfile;
}

const planFile = [planfileArg, `${planfileArg}.md`].find((file) => fs.existsSync(file));

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 only checks the current directory, while loadPlan() also resolves names through getPlansDir(). In global mode, explorbot test saved-plan can therefore fail before loadPlan() even though the plan exists. Could both paths use the same resolver?

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.');
}

log(`Navigating to ${startUrl}`);
await explorBot.visit(startUrl);

let args = '';
let args = '*';
if (indexArg) args = indexArg;
else if (options.grep) args = options.grep;

Expand Down
2 changes: 2 additions & 0 deletions docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,8 @@ npx explorbot test 3 --from-plan output/plans/login.md # index first, plan via
| `--grep <pattern>` | Run only tests whose scenario matches the pattern |
| `--from-plan <file>` | 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/<host>/`.

### drill

Drill all components on a page to learn interactions.
Expand Down
2 changes: 1 addition & 1 deletion src/ai/historian/codeceptjs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export function WithCodeceptJS<T extends Constructor>(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)}');`);
Expand Down
2 changes: 1 addition & 1 deletion src/ai/historian/playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export function WithPlaywright<T extends Constructor>(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)}');`);
Expand Down
4 changes: 4 additions & 0 deletions src/test-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
55 changes: 55 additions & 0 deletions tests/unit/test-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,4 +340,59 @@ priority: low
expect(context).toContain('- Enter text');
});
});

describe('startUrl', () => {
test('should take the prerequisite URL of the suite', () => {
const markdown = `<!-- suite -->
# Test Suite

### Prerequisite

* URL: https://app.example.com/projects/demo/runs

<!-- test
priority: critical
-->
# 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 = `<!-- suite -->
# Test Suite

<!-- test
priority: normal
-->
# 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();
});
});
});
Loading