diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..aeb7bbc --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ +start: + ./bin/explorbot-cli.ts start + +init: + ./bin/explorbot-cli.ts init + \ No newline at end of file diff --git a/bin/explorbot-cli.ts b/bin/explorbot-cli.ts index 8dc75fa..f5c575e 100755 --- a/bin/explorbot-cli.ts +++ b/bin/explorbot-cli.ts @@ -144,7 +144,10 @@ addCommonOptions(program.command('plan [feature]').description('Generate } } - await explorBot.plan(feature || undefined, { fresh: !options.append, style: options.style }); + await explorBot.plan(feature || undefined, { + fresh: !options.append, + style: options.style, + }); const plan = explorBot.getCurrentPlan(); if (!plan?.tests.length) { @@ -320,7 +323,13 @@ program log(`Working in directory: ${resolvedPath}`); } - const defaultConfig = `import { } from 'ai'; + const defaultConfig = `import { '' } from ''; + +// This example uses OpenRouter (one API key, many providers). Any Vercel AI SDK provider works; see +// https://github.com/testomatio/explorbot/blob/main/docs/providers.md +const openrouter = createOpenRouter({ + apiKey: process.env.OPENROUTER_API_KEY, +}); const config = { playwright: { @@ -330,9 +339,9 @@ const config = { }, ai: { - provider: , model: '', - apiKey: '', + visionModel: '', + agenticModel: '', }, reporter: { @@ -470,7 +479,9 @@ program .option('-p, --path ', 'Working directory path') .action(async (url, description, options) => { try { - await ConfigParser.getInstance().loadConfig({ path: options.path || process.cwd() }); + await ConfigParser.getInstance().loadConfig({ + path: options.path || process.cwd(), + }); if (url && description) { const { KnowledgeTracker } = await import('../src/knowledge-tracker.js'); @@ -498,7 +509,9 @@ program .option('-p, --path ', 'Working directory path') .action(async (url, options) => { try { - await ConfigParser.getInstance().loadConfig({ path: options.path || process.cwd() }); + await ConfigParser.getInstance().loadConfig({ + path: options.path || process.cwd(), + }); const { KnowsCommand } = await import('../src/commands/knows-command.js'); const explorBot = new ExplorBot({ path: options.path }); const command = new KnowsCommand(explorBot); @@ -648,14 +661,20 @@ browserCmd .option('-p, --path ', 'Working directory path') .action(async (options) => { const { launchServer, removeEndpointFile } = await import('../src/browser-server.js'); - await ConfigParser.getInstance().loadConfig({ config: options.config, path: options.path }); + await ConfigParser.getInstance().loadConfig({ + config: options.config, + path: options.path, + }); const config = ConfigParser.getInstance().getConfig(); let show = config.playwright.show || false; if (options.show !== undefined) show = true; if (options.headless !== undefined) show = false; - const server = await launchServer({ browser: config.playwright.browser, show }); + const server = await launchServer({ + browser: config.playwright.browser, + show, + }); console.log('Browser server is running. Press Ctrl+C to stop.'); @@ -677,7 +696,10 @@ browserCmd .option('-p, --path ', 'Working directory path') .action(async (options) => { const { getAliveEndpoint, removeEndpointFile } = await import('../src/browser-server.js'); - await ConfigParser.getInstance().loadConfig({ config: options.config, path: options.path }); + await ConfigParser.getInstance().loadConfig({ + config: options.config, + path: options.path, + }); const endpoint = await getAliveEndpoint(); if (!endpoint) { @@ -702,7 +724,10 @@ browserCmd .option('-p, --path ', 'Working directory path') .action(async (options) => { const { getAliveEndpoint } = await import('../src/browser-server.js'); - await ConfigParser.getInstance().loadConfig({ config: options.config, path: options.path }); + await ConfigParser.getInstance().loadConfig({ + config: options.config, + path: options.path, + }); const endpoint = await getAliveEndpoint(); if (endpoint) { @@ -743,15 +768,23 @@ program if (agent && name) { const { AddRuleCommand } = await import('../src/commands/add-rule-command.js'); - const result = AddRuleCommand.createRuleFile(agent, name, { urlPattern: options.url }); + const result = AddRuleCommand.createRuleFile(agent, name, { + urlPattern: options.url, + }); process.exit(result ? 0 : 1); } const AddRule = (await import('../src/components/AddRule.js')).default; - render(React.createElement(AddRule, { initialAgent: agent || '', initialName: name || '' }), { - exitOnCtrlC: false, - patchConsole: false, - }); + render( + React.createElement(AddRule, { + initialAgent: agent || '', + initialName: name || '', + }), + { + exitOnCtrlC: false, + patchConsole: false, + } + ); }); import { createApiCommands } from '../boat/api-tester/src/cli.ts'; diff --git a/bun.lock b/bun.lock index 7c83b2a..fc675d5 100644 --- a/bun.lock +++ b/bun.lock @@ -83,7 +83,7 @@ "@ai-sdk/groq": ["@ai-sdk/groq@3.0.2", "", { "dependencies": { "@ai-sdk/provider": "3.0.1", "@ai-sdk/provider-utils": "4.0.2" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Gs7Ir9cUSYlbDIArNMt3+0Ql+OrEKELQhYfji5CCxQ8MdcJGbhbyPf9AQralu9PMxq/QEy2JSOgYW5zOnHDd2g=="], - "@ai-sdk/openai": ["@ai-sdk/openai@3.0.2", "", { "dependencies": { "@ai-sdk/provider": "3.0.1", "@ai-sdk/provider-utils": "4.0.2" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-GONwavgSWtcWO+t9+GpGK8l7nIYh+zNtCL/NYDSeHxHiw6ksQS9XMRWrZyE5NpJ0EXNxSAWCHIDmb1WvTqhq9Q=="], + "@ai-sdk/openai": ["@ai-sdk/openai@3.0.49", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-U2f0pCyNn/jQH3wjgxr8o9VvCkuDFTtXbIhbFFtgXqCzMbed6rBnvzQcAMEK0/Pa44byL9zfcvCOFOflvkRA8w=="], "@ai-sdk/provider": ["@ai-sdk/provider@3.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-2lR4w7mr9XrydzxBSjir4N6YMGdXD+Np1Sh0RXABh7tWdNFFwIeRI1Q+SaYZMbfL8Pg8RRLcrxQm51yxTLhokg=="], @@ -2653,6 +2653,10 @@ "zone.js": ["zone.js@0.15.1", "", {}, "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w=="], + "@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], + "@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], diff --git a/src/ai/pilot.ts b/src/ai/pilot.ts index 8f815a2..c223767 100644 --- a/src/ai/pilot.ts +++ b/src/ai/pilot.ts @@ -85,7 +85,7 @@ export class Pilot implements Agent { const schema = z.object({ decision: z.enum(['pass', 'fail', 'continue', 'skipped']).describe('pass = test succeeded, fail = test failed, continue = tester should keep going, skipped = scenario is irrelevant OR systematic execution failures prevented testing'), reason: z.string().describe('What happened and why (1-2 sentences). Do NOT repeat the decision status (e.g. "scenario goal achieved/not achieved") — just explain the evidence. For continue: explain why rejected and suggest alternatives.'), - guidance: z.string().nullish().describe('Required for "continue": specific actionable instruction for the tester — what exactly to verify, retry differently, or complete next. Be concrete.'), + guidance: z.string().nullable().describe('Required for "continue": specific actionable instruction for the tester — what exactly to verify, retry differently, or complete next. Be concrete.'), }); const userContent = dedent` @@ -117,12 +117,18 @@ export class Pilot implements Agent { `; const messages = [ - { role: 'system' as const, content: this.buildVerdictSystemPrompt(type, task) }, + { + role: 'system' as const, + content: this.buildVerdictSystemPrompt(type, task), + }, { role: 'user' as const, content: userContent }, ]; try { - const response = await this.provider.generateObject(messages, schema, this.provider.getAgenticModel('pilot'), { agentName: 'pilot', experimental_telemetry: { functionId: 'pilot.reviewVerdict' } }); + const response = await this.provider.generateObject(messages, schema, this.provider.getAgenticModel('pilot'), { + agentName: 'pilot', + experimental_telemetry: { functionId: 'pilot.reviewVerdict' }, + }); const result = response?.object; if (!result) { @@ -222,7 +228,9 @@ export class Pilot implements Agent { async planTest(task: Test, currentState: ActionResult): Promise { tag('substep').log('Pilot planning test...'); - const pageSummary = await this.researcher.summary(currentState, { allowNewResearch: false }); + const pageSummary = await this.researcher.summary(currentState, { + allowNewResearch: false, + }); const agenticModel = this.provider.getAgenticModel('pilot'); this.conversation = this.provider.startConversation(this.getSystemPrompt(task, currentState, pageSummary), 'pilot', agenticModel); @@ -257,7 +265,9 @@ export class Pilot implements Agent { tag('substep').log('Pilot reviewing new page...'); - const pageSummary = await this.researcher.summary(currentState, { allowNewResearch: false }); + const pageSummary = await this.researcher.summary(currentState, { + allowNewResearch: false, + }); if (!pageSummary) return ''; const stateContext = this.buildStateContext(currentState); @@ -289,7 +299,9 @@ export class Pilot implements Agent { tag('substep').log('Pilot analyzing progress...'); if (!this.conversation) { - const pageSummary = await this.researcher.summary(currentState, { allowNewResearch: false }); + const pageSummary = await this.researcher.summary(currentState, { + allowNewResearch: false, + }); const agenticModel = this.provider.getAgenticModel('pilot'); this.conversation = this.provider.startConversation(this.getSystemPrompt(task, currentState, pageSummary), 'pilot', agenticModel); } diff --git a/src/ai/planner.ts b/src/ai/planner.ts index 78f92e4..db912fd 100644 --- a/src/ai/planner.ts +++ b/src/ai/planner.ts @@ -32,7 +32,7 @@ const TasksSchema = z.object({ z.object({ scenario: z.string().describe('A single sentence describing what to test'), priority: z.enum(['critical', 'important', 'high', 'normal', 'low']).describe('Priority of the task based on business importance'), - startUrl: z.string().optional().describe('Start URL for the test if different from plan URL (only for tests on visited subpages)'), + startUrl: z.string().nullable().describe('Start URL for the test if different from plan URL (only for tests on visited subpages)'), steps: z.array(z.string()).describe('List of steps to perform for this scenario. Each step should be a specific action (e.g., "Click on Login button", "Enter username in email field", "Submit the form"). Keep steps atomic and actionable.'), expectedOutcomes: z .array(z.string()) @@ -325,13 +325,18 @@ export class Planner extends PlannerBase implements Agent { conversation.addUserText(planningPrompt); const currentState = this.stateManager.getCurrentState(); - const research = await this.researcher.research(currentState || state, { deep: true }); + const research = await this.researcher.research(currentState || state, { + deep: true, + }); let plannerResearch = mdq(research).query('code').replace(''); for (const table of mdq(plannerResearch).query('table').each()) { const rawTable = table.text(); const rows = table.toJson(); if (rows.length === 0 || !rows[0].Element) continue; - const elementWithType = rows.map((r) => ({ Element: r.Element, Type: r.Type || '' })); + const elementWithType = rows.map((r) => ({ + Element: r.Element, + Type: r.Type || '', + })); plannerResearch = plannerResearch.replace(rawTable, jsonToTable(elementWithType, ['Element', 'Type'])); } @@ -349,7 +354,10 @@ export class Planner extends PlannerBase implements Agent { `); - const rawFlows = this.experienceTracker.getSuccessfulExperience(state, { includeDescendants: true, stripCode: true }); + const rawFlows = this.experienceTracker.getSuccessfulExperience(state, { + includeDescendants: true, + stripCode: true, + }); const flows = rawFlows.map((f) => this.cleanExperienceFlows(f)).filter(Boolean) as string[]; if (flows.length > 0) { conversation.addUserText(dedent`