Skip to content
Merged
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
79 changes: 54 additions & 25 deletions bin/explorbot-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,38 +305,47 @@ 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')).action(async (planfile, index, options) => {
try {
const explorBot = new ExplorBot(buildExplorBotOptions(undefined, options));
await explorBot.start();
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();

const plan = explorBot.loadPlan(planfile);
const pending = plan.getPendingTests();
log(`Plan loaded: "${plan.title}" (${plan.tests.length} tests, ${pending.length} pending)`);
let planfileArg = planfile;
let indexArg = index;
if (options.fromPlan) {
planfileArg = options.fromPlan;
indexArg = planfile;
}

const startUrl = plan.url || pending[0]?.startUrl;
if (!startUrl) {
throw new Error('No URL found in plan or tests. Cannot determine where to navigate.');
}
const plan = explorBot.loadPlan(planfileArg);
const pending = plan.getPendingTests();
log(`Plan loaded: "${plan.title}" (${plan.tests.length} tests, ${pending.length} pending)`);

log(`Navigating to ${startUrl}`);
await explorBot.visit(startUrl);
const startUrl = plan.url || pending[0]?.startUrl;
if (!startUrl) {
throw new Error('No URL found in plan or tests. Cannot determine where to navigate.');
}

let args = '';
if (index) args = index;
else if (options.grep) args = options.grep;
log(`Navigating to ${startUrl}`);
await explorBot.visit(startUrl);

const { TestCommand } = await import('../src/commands/test-command.js');
const cmd = new TestCommand(explorBot);
await cmd.execute(args);
let args = '';
if (indexArg) args = indexArg;
else if (options.grep) args = options.grep;

await explorBot.stop();
await showStatsAndExit(0);
} catch (error) {
console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
await showStatsAndExit(1);
const { TestCommand } = await import('../src/commands/test-command.js');
const cmd = new TestCommand(explorBot);
await cmd.execute(args);

await explorBot.stop();
await showStatsAndExit(0);
} catch (error) {
console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
await showStatsAndExit(1);
}
}
});
);

program
.command('runs [file]')
Expand All @@ -358,6 +367,26 @@ program
}
});

program
.command('plans [plan]')
.description('List saved plans, or show tests for a specific plan')
.option('-p, --path <path>', 'Working directory path')
.option('-c, --config <path>', 'Path to configuration file')
.action(async (plan, options) => {
try {
await ConfigParser.getInstance().loadConfig({
config: options.config,
path: options.path || process.cwd(),
});
const explorBot = new ExplorBot({ path: options.path });
const { PlansCommand } = await import('../src/commands/plans-command.js');
await new PlansCommand(explorBot).execute(plan || '');
} catch (error) {
console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
process.exit(1);
}
});

addCommonOptions(program.command('rerun <filename> [index]').description('Re-run generated tests with AI auto-healing')).action(async (filename, index, options) => {
try {
const explorBot = new ExplorBot(buildExplorBotOptions(undefined, options));
Expand Down
2 changes: 2 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { PlanEditCommand } from './plan-edit-command.js';
import { PlanLoadCommand } from './plan-load-command.js';
import { PlanReloadCommand } from './plan-reload-command.js';
import { PlanSaveCommand } from './plan-save-command.js';
import { PlansCommand } from './plans-command.js';
import { RerunCommand } from './rerun-command.js';
import { ResearchCommand } from './research-command.js';
import { RunsCommand } from './runs-command.js';
Expand All @@ -48,6 +49,7 @@ const commandClasses: CommandClass[] = [
PlanCommand,
PlanSaveCommand,
PlanLoadCommand,
PlansCommand,
PlanReloadCommand,
PlanClearCommand,
PlanEditCommand,
Expand Down
99 changes: 99 additions & 0 deletions src/commands/plans-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { existsSync, readdirSync, statSync } from 'node:fs';
import path from 'node:path';
import { Plan } from '../test-plan.js';
import { getCliName } from '../utils/cli-name.js';
import { tag } from '../utils/logger.js';
import { relativeToCwd } from '../utils/next-steps.js';
import { BaseCommand } from './base-command.js';

export class PlansCommand extends BaseCommand {
name = 'plans';
description = 'List saved plans and show their test scenarios';
options = [{ flags: '--from-plan <file>', description: 'Plan file to show' }];

async execute(args: string): Promise<void> {
const { opts, args: remaining } = this.parseArgs(args);
const files = this.getPlanFiles();
const target = String(opts.fromPlan || remaining[0] || '').trim();

if (!target) {
this.printPlans(files);
return;
}

const file = this.resolvePlanFile(target, files);
const plan = Plan.fromMarkdown(file.path);
this.printPlanDetails(plan, file);
}

private getPlanFiles(): PlanFile[] {
const plansDir = this.explorBot.getPlansDir();
if (!existsSync(plansDir)) return [];

return readdirSync(plansDir)
.filter((file) => file.endsWith('.md'))
.map((file) => {
const filePath = path.join(plansDir, file);
const stat = statSync(filePath);
return {
name: file,
path: filePath,
modifiedAt: stat.mtimeMs,
};
})
.sort((left, right) => right.modifiedAt - left.modifiedAt);
}

private printPlans(files: PlanFile[]): void {
if (files.length === 0) {
tag('info').log(`No saved plans found in ${relativeToCwd(this.explorBot.getPlansDir())}`);
return;
}

tag('info').log('Saved plans:');
for (let i = 0; i < files.length; i++) {
const file = files[i];
const plan = Plan.fromMarkdown(file.path);
tag('info').log(`${i + 1}. ${plan.title} (${plan.tests.length} tests) - ${file.name}`);
}
tag('info').log('');
tag('info').log(`View plan tests: ${getCliName()} plans <number>`);
}

private printPlanDetails(plan: Plan, file: PlanFile): void {
tag('info').log(`${plan.title} (${plan.tests.length} tests)`);
for (let i = 0; i < plan.tests.length; i++) {
const test = plan.tests[i];
tag('info').log(`${i + 1}. ${test.scenario}`);
}
tag('info').log('');
tag('info').log('Run test from this plan as:');
tag('info').log(`${getCliName()} test 1 --from-plan ${file.name}`);
}

private resolvePlanFile(target: string, files: PlanFile[]): 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;
}

const resolved = this.explorBot.resolvePlanPath(target);
if (!existsSync(resolved)) {
throw new Error(`Plan file not found: ${resolved}`);
}

return {
name: path.basename(resolved),
path: resolved,
modifiedAt: statSync(resolved).mtimeMs,
};
}
}

interface PlanFile {
name: string;
path: string;
modifiedAt: number;
}
23 changes: 15 additions & 8 deletions src/commands/test-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,20 @@ import { BaseCommand, type Suggestion } from './base-command.js';
export class TestCommand extends BaseCommand {
name = 'test';
description = 'Launch tester agent to execute test scenarios';
options = [{ flags: '--from-plan <file>', description: 'Load plan file before selecting tests' }];
suggestions: Suggestion[] = [
{ command: 'test', hint: 'run next test' },
{ command: 'plan', hint: 'create new plan' },
];

async execute(args: string): Promise<void> {
const { opts, args: remaining } = this.parseArgs(args);
if (opts.fromPlan) {
this.explorBot.loadPlan(String(opts.fromPlan));
}

const plan = this.explorBot.getCurrentPlan();
const selector = remaining.join(' ');
Stats.mode = 'test';
Stats.focus = plan?.title;
const toExecute: Test[] = [];
Expand All @@ -22,35 +29,35 @@ export class TestCommand extends BaseCommand {
return plan;
};

if (!args) {
if (!selector) {
const pending = requirePlan().getPendingTests();
if (pending.length === 0) {
throw new Error('All tests are already complete. Please run /plan to create new test scenarios.');
}
toExecute.push(pending[0]);
} else if (args === '*' || args === 'all') {
} else if (selector === '*' || selector === 'all') {
toExecute.push(...requirePlan().getPendingTests());
} else if (args.match(/^[\d,\-\s]+$/)) {
} else if (selector.match(/^[\d,\-\s]+$/)) {
const visible = requirePlan().tests.filter((t) => t.enabled);
const indices = parseTestIndices(args, visible.length);
const indices = parseTestIndices(selector, visible.length);
for (const idx of indices) {
toExecute.push(visible[idx]);
}
} else {
const matching = plan?.getPendingTests().filter((test) => test.scenario.toLowerCase().includes(args.toLowerCase())) || [];
const matching = plan?.getPendingTests().filter((test) => test.scenario.toLowerCase().includes(selector.toLowerCase())) || [];
if (matching.length > 0) {
toExecute.push(...matching);
} else {
const state = this.explorBot.getExplorer().getStateManager().getCurrentState();
if (!state) {
throw new Error('No page loaded. Please navigate to a page first.');
}
const newTest = new Test(args, 'unknown', [], state.url);
const newTest = new Test(selector, 'unknown', [], state.url);
if (plan) {
plan.addTest(newTest);
tag('info').log(`Created new test: "${args}" and added to current plan.`);
tag('info').log(`Created new test: "${selector}" and added to current plan.`);
} else {
tag('info').log(`Created ad-hoc test: "${args}"`);
tag('info').log(`Created ad-hoc test: "${selector}"`);
}
toExecute.push(newTest);
}
Expand Down
83 changes: 83 additions & 0 deletions tests/unit/plans-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import { PlansCommand } from '../../src/commands/plans-command.js';
import { TestCommand } from '../../src/commands/test-command.js';
import type { ExplorBot } from '../../src/explorbot.js';
import { Plan, Test } from '../../src/test-plan.js';

let tmpPath = '';
let logs: string[] = [];
let originalLog: typeof console.log;

beforeEach(() => {
tmpPath = mkdtempSync(path.join(tmpdir(), 'explorbot-plans-'));
logs = [];
originalLog = console.log;
console.log = (...args: any[]) => {
logs.push(args.join(' '));
};
});

afterEach(() => {
console.log = originalLog;
rmSync(tmpPath, { recursive: true, force: true });
});

describe('PlansCommand', () => {
it('lists saved plans', async () => {
const plan = new Plan('Checkout plan');
plan.addTest(new Test('Pay with card', 'high', ['Payment succeeds'], '/checkout'));
plan.saveToMarkdown(path.join(tmpPath, 'checkout.md'));

const cmd = new PlansCommand(createMockExplorBot());
await cmd.execute('');

expect(logs.join('\n')).toContain('1. Checkout plan (1 tests) - checkout.md');
});

it('shows tests for plan by index', async () => {
const plan = new Plan('Checkout plan');
plan.addTest(new Test('Pay with card', 'high', ['Payment succeeds'], '/checkout'));
plan.addTest(new Test('Apply coupon', 'normal', ['Discount is applied'], '/checkout'));
plan.saveToMarkdown(path.join(tmpPath, 'checkout.md'));

const cmd = new PlansCommand(createMockExplorBot());
await cmd.execute('1');

const output = logs.join('\n');
expect(output).toContain('Checkout plan (2 tests)');
expect(output).toContain('1. Pay with card');
expect(output).toContain('2. Apply coupon');
expect(output).toContain('test 1 --from-plan checkout.md');
});
});

describe('TestCommand', () => {
it('loads plan from --from-plan before selecting tests', async () => {
const plan = new Plan('Checkout plan');
plan.addTest(new Test('Pay with card', 'high', ['Payment succeeds'], '/checkout'));
const loadPlan = mock(() => plan);
const tester = { test: mock(async () => {}) };
const explorBot = createMockExplorBot({
loadPlan,
getCurrentPlan: mock(() => plan),
agentTester: mock(() => tester),
});

const cmd = new TestCommand(explorBot);
await cmd.execute('1 --from-plan checkout.md');

expect(loadPlan).toHaveBeenCalledWith('checkout.md');
expect(tester.test).toHaveBeenCalledWith(plan.tests[0]);
});
});

function createMockExplorBot(overrides: Partial<ExplorBot> = {}): ExplorBot {
return {
getPlansDir: () => tmpPath,
resolvePlanPath: (filename: string) => path.join(tmpPath, filename),
...overrides,
} as unknown as ExplorBot;
}
Loading