explorbot 0.4.4 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/boat/api-tester/src/apibot.ts +18 -2
- package/boat/api-tester/src/cli.ts +85 -274
- package/boat/api-tester/src/commands/api-command.ts +10 -0
- package/boat/api-tester/src/commands/explore-command.ts +52 -0
- package/boat/api-tester/src/commands/init-command.ts +119 -0
- package/boat/api-tester/src/commands/know-command.ts +44 -0
- package/boat/api-tester/src/commands/plan-command.ts +42 -0
- package/boat/api-tester/src/commands/test-command.ts +54 -0
- package/boat/prima/src/prima.ts +8 -3
- package/dist/boat/api-tester/src/apibot.js +14 -1
- package/dist/boat/api-tester/src/cli.js +87 -243
- package/dist/boat/api-tester/src/commands/api-command.js +7 -0
- package/dist/boat/api-tester/src/commands/explore-command.js +41 -0
- package/dist/boat/api-tester/src/commands/init-command.js +88 -0
- package/dist/boat/api-tester/src/commands/know-command.js +39 -0
- package/dist/boat/api-tester/src/commands/plan-command.js +37 -0
- package/dist/boat/api-tester/src/commands/test-command.js +45 -0
- package/dist/boat/prima/src/prima.js +10 -3
- package/dist/package.json +4 -4
- package/dist/src/ai/fisherman/tools.js +7 -1
- package/dist/src/ai/fisherman.js +2 -1
- package/dist/src/ai/pilot.d.ts +0 -1
- package/dist/src/ai/pilot.js +8 -24
- package/dist/src/ai/planner.d.ts +4 -0
- package/dist/src/ai/planner.js +28 -0
- package/dist/src/ai/provider.js +3 -1
- package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
- package/dist/src/ai/researcher/deep-analysis.js +14 -6
- package/dist/src/ai/rules.js +8 -7
- package/dist/src/ai/scout/tools.d.ts +17 -0
- package/dist/src/ai/scout/tools.js +130 -0
- package/dist/src/ai/scout.d.ts +21 -0
- package/dist/src/ai/scout.js +150 -0
- package/dist/src/ai/tools.d.ts +1 -1
- package/dist/src/ai/tools.js +62 -31
- package/dist/src/api/spec-reader.d.ts +1 -0
- package/dist/src/api/spec-reader.js +93 -1
- package/dist/src/application-spec.d.ts +3 -0
- package/dist/src/application-spec.js +21 -5
- package/dist/src/commands/base-command.d.ts +3 -3
- package/dist/src/commands/init-command.d.ts +3 -0
- package/dist/src/commands/init-command.js +6 -3
- package/dist/src/config.d.ts +6 -1
- package/dist/src/explorbot.d.ts +3 -0
- package/dist/src/explorbot.js +33 -0
- package/dist/src/explorer.d.ts +1 -1
- package/dist/src/explorer.js +1 -1
- package/dist/src/knowledge-tracker.d.ts +1 -0
- package/dist/src/knowledge-tracker.js +3 -0
- package/dist/src/utils/aria-ref.d.ts +16 -0
- package/dist/src/utils/aria-ref.js +47 -0
- package/dist/src/utils/aria.js +3 -3
- package/dist/src/utils/html-diff.js +4 -1
- package/dist/src/utils/web-annotate.js +3 -15
- package/dist/src/utils/web-element.d.ts +0 -2
- package/dist/src/utils/web-element.js +0 -8
- package/docs/api-testing/basics.md +26 -2
- package/docs/reference/configuration.md +28 -1
- package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +317 -0
- package/docs/web-testing/agents.md +9 -1
- package/docs/web-testing/planner.md +5 -0
- package/docs/workflow/application-spec.md +4 -0
- package/package.json +4 -4
- package/src/ai/fisherman/tools.ts +8 -1
- package/src/ai/fisherman.ts +2 -1
- package/src/ai/pilot.ts +8 -25
- package/src/ai/planner.ts +33 -0
- package/src/ai/provider.ts +2 -1
- package/src/ai/researcher/deep-analysis.ts +13 -6
- package/src/ai/rules.ts +8 -7
- package/src/ai/scout/tools.ts +150 -0
- package/src/ai/scout.ts +173 -0
- package/src/ai/tools.ts +75 -38
- package/src/api/spec-reader.ts +106 -1
- package/src/application-spec.ts +22 -4
- package/src/commands/base-command.ts +3 -3
- package/src/commands/init-command.ts +6 -3
- package/src/config.ts +7 -0
- package/src/explorbot.ts +36 -0
- package/src/explorer.ts +1 -1
- package/src/knowledge-tracker.ts +4 -0
- package/src/utils/aria-ref.ts +61 -0
- package/src/utils/aria.ts +3 -3
- package/src/utils/html-diff.ts +3 -1
- package/src/utils/web-annotate.ts +3 -15
- package/src/utils/web-element.ts +0 -9
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { tag } from '../../../../src/utils/logger.ts';
|
|
2
|
+
import { type NextStepSection, printNextSteps, relativeToCwd } from '../../../../src/utils/next-steps.ts';
|
|
3
|
+
import { ApiCommand } from './api-command.ts';
|
|
4
|
+
|
|
5
|
+
export class PlanCommand extends ApiCommand {
|
|
6
|
+
name = 'plan';
|
|
7
|
+
description = 'Generate a test plan for an API endpoint';
|
|
8
|
+
style?: string;
|
|
9
|
+
fresh = false;
|
|
10
|
+
|
|
11
|
+
async execute(endpoint: string): Promise<void> {
|
|
12
|
+
await this.bot.plan(endpoint, { style: this.style, fresh: this.fresh });
|
|
13
|
+
|
|
14
|
+
const plan = this.bot.getCurrentPlan();
|
|
15
|
+
if (!plan?.tests.length) {
|
|
16
|
+
throw new Error('No test scenarios generated.');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const lines = [`Plan: ${plan.title} (${plan.tests.length} tests)`];
|
|
20
|
+
for (const [i, test] of plan.tests.entries()) {
|
|
21
|
+
lines.push(` ${String(i + 1).padStart(2)}. [${test.priority}] ${test.scenario}`);
|
|
22
|
+
}
|
|
23
|
+
tag('multiline').log(lines.join('\n'), { maxLines: 24 });
|
|
24
|
+
|
|
25
|
+
const savedPath = this.bot.savePlan();
|
|
26
|
+
if (!savedPath) return;
|
|
27
|
+
|
|
28
|
+
const relative = relativeToCwd(savedPath);
|
|
29
|
+
const sections: NextStepSection[] = [
|
|
30
|
+
{
|
|
31
|
+
label: 'Plan',
|
|
32
|
+
path: savedPath,
|
|
33
|
+
commands: [
|
|
34
|
+
{ label: 'Run first', command: `${this.prefix} test ${relative} 1` },
|
|
35
|
+
{ label: 'Run all', command: `${this.prefix} test ${relative} *` },
|
|
36
|
+
{ label: 'Run range', command: `${this.prefix} test ${relative} 1-3` },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
printNextSteps(sections);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import figureSet from 'figures';
|
|
2
|
+
import type { Test } from '../../../../src/test-plan.ts';
|
|
3
|
+
import { tag } from '../../../../src/utils/logger.ts';
|
|
4
|
+
import { ApiCommand } from './api-command.ts';
|
|
5
|
+
|
|
6
|
+
export class TestCommand extends ApiCommand {
|
|
7
|
+
name = 'test';
|
|
8
|
+
description = 'Execute tests from a plan file. Index: 1, 1-3, *';
|
|
9
|
+
index?: string;
|
|
10
|
+
failed = 0;
|
|
11
|
+
|
|
12
|
+
async execute(planfile: string): Promise<void> {
|
|
13
|
+
const plan = this.bot.loadPlan(planfile);
|
|
14
|
+
tag('info').log(`Plan loaded: "${plan.title}" (${plan.tests.length} tests)`);
|
|
15
|
+
|
|
16
|
+
const tests = selectTests(plan.tests, this.index);
|
|
17
|
+
tag('info').log(`Running ${tests.length} test(s)`);
|
|
18
|
+
|
|
19
|
+
let passed = 0;
|
|
20
|
+
for (const test of tests) {
|
|
21
|
+
const result = await this.bot.runTest(test);
|
|
22
|
+
if (result.success) passed++;
|
|
23
|
+
else this.failed++;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
this.bot.savePlan();
|
|
27
|
+
tag('info').log(`${figureSet.tick} ${tests.length} tests completed: ${passed} passed, ${this.failed} failed`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function selectTests(tests: Test[], index?: string): Test[] {
|
|
32
|
+
if (!index || index === '*' || index === 'all') {
|
|
33
|
+
return tests.filter((t) => t.status === 'pending');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const rangeMatch = index.match(/^(\d+)-(\d+)$/);
|
|
37
|
+
if (rangeMatch) {
|
|
38
|
+
const start = Number.parseInt(rangeMatch[1]) - 1;
|
|
39
|
+
const end = Number.parseInt(rangeMatch[2]);
|
|
40
|
+
return tests.slice(start, end);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (index.includes(',')) {
|
|
44
|
+
const indices = index.split(',').map((i) => Number.parseInt(i.trim()) - 1);
|
|
45
|
+
return indices.map((i) => tests[i]).filter(Boolean);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const num = Number.parseInt(index);
|
|
49
|
+
if (!Number.isNaN(num) && tests[num - 1]) {
|
|
50
|
+
return [tests[num - 1]];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return tests.filter((t) => t.status === 'pending');
|
|
54
|
+
}
|
package/boat/prima/src/prima.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { Reporter } from '../../../src/reporter.ts';
|
|
|
20
20
|
import type { WebPageState } from '../../../src/state-manager.ts';
|
|
21
21
|
import { Stats } from '../../../src/stats.ts';
|
|
22
22
|
import { Task, Test, TestResult } from '../../../src/test-plan.ts';
|
|
23
|
+
import { ariaRefSnapshot } from '../../../src/utils/aria-ref.ts';
|
|
23
24
|
import { compactAriaSnapshot } from '../../../src/utils/aria.ts';
|
|
24
25
|
import { browserErrorMessage } from '../../../src/utils/browser-errors.ts';
|
|
25
26
|
import { pluralize } from '../../../src/utils/logger.ts';
|
|
@@ -324,11 +325,15 @@ export class Prima {
|
|
|
324
325
|
return null;
|
|
325
326
|
});
|
|
326
327
|
|
|
327
|
-
|
|
328
|
-
|
|
328
|
+
const stillOpen = ledger.filter((entry) => entry.status === 'open').length;
|
|
329
329
|
for (const execution of invoked?.toolExecutions || []) {
|
|
330
330
|
this.applyLedgerReport(execution, ledger, trace);
|
|
331
331
|
}
|
|
332
|
+
|
|
333
|
+
let unsettled = '';
|
|
334
|
+
if (settleError) unsettled = browserErrorMessage(settleError);
|
|
335
|
+
if (invoked && ledger.filter((entry) => entry.status === 'open').length === stillOpen) unsettled = 'the model was asked to report every remaining instruction and reported none';
|
|
336
|
+
if (unsettled) trace.push({ label: 'settling which instructions were satisfied', ok: false, proof: unsettled });
|
|
332
337
|
}
|
|
333
338
|
|
|
334
339
|
private ledgerProgress(ledger: LedgerEntry[]): string {
|
|
@@ -916,7 +921,7 @@ export class Prima {
|
|
|
916
921
|
}
|
|
917
922
|
|
|
918
923
|
private async refAriaSnapshot(result: ActionResult): Promise<string | null> {
|
|
919
|
-
const snapshot = await Promise.resolve(this.bot.getExplorer()?.withPage?.(
|
|
924
|
+
const snapshot = await Promise.resolve(this.bot.getExplorer()?.withPage?.(ariaRefSnapshot)).catch(() => null);
|
|
920
925
|
return snapshot || result.ariaSnapshot;
|
|
921
926
|
}
|
|
922
927
|
|
|
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { AIProvider } from "../../../src/ai/provider.js";
|
|
4
4
|
import { RequestStore } from "../../../src/api/request-store.js";
|
|
5
|
-
import { extractEndpointDefinition, loadSpec, searchEndpoints, validateSpecs } from "../../../src/api/spec-reader.js";
|
|
5
|
+
import { extractEndpointDefinition, loadSpec, resolveEndpoints, searchEndpoints, validateSpecs } from "../../../src/api/spec-reader.js";
|
|
6
6
|
import { KnowledgeTracker } from "../../../src/knowledge-tracker.js";
|
|
7
7
|
import { Reporter } from "../../../src/reporter.js";
|
|
8
8
|
import { Plan } from "../../../src/test-plan.js";
|
|
@@ -143,12 +143,25 @@ export class ApiBot {
|
|
|
143
143
|
getConfigParser() {
|
|
144
144
|
return this.configParser;
|
|
145
145
|
}
|
|
146
|
+
getOptions() {
|
|
147
|
+
return this.options;
|
|
148
|
+
}
|
|
149
|
+
async runTest(test) {
|
|
150
|
+
return this.agentCurler().test(test, {
|
|
151
|
+
specDefinition: this.tryGetEndpointDefinition(test.startUrl),
|
|
152
|
+
baseEndpoint: this.config.api.baseEndpoint,
|
|
153
|
+
searchSpec: (query) => this.searchSpec(query),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
146
156
|
getRequestState() {
|
|
147
157
|
return this.requestState;
|
|
148
158
|
}
|
|
149
159
|
getEndpointDefinition(endpoint) {
|
|
150
160
|
return extractEndpointDefinition(this.apiSpec, endpoint, this.config.api.baseEndpoint);
|
|
151
161
|
}
|
|
162
|
+
expandEndpoints(pattern) {
|
|
163
|
+
return resolveEndpoints(this.apiSpec, this.configParser.resolveEndpointPath(pattern), this.config.api.baseEndpoint);
|
|
164
|
+
}
|
|
152
165
|
searchSpec(query) {
|
|
153
166
|
return searchEndpoints(this.apiSpec, query, this.config.api.baseEndpoint);
|
|
154
167
|
}
|
|
@@ -1,86 +1,30 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
1
|
import { Command } from 'commander';
|
|
4
2
|
import { ConfigCommand } from "../../../src/commands/config-command.js";
|
|
5
3
|
import { RecommendedModelsCommand } from "../../../src/commands/recommended-models-command.js";
|
|
6
4
|
import { listSites } from "../../../src/global-config.js";
|
|
7
5
|
import { setPreserveConsoleLogs } from "../../../src/utils/logger.js";
|
|
8
|
-
import { getStyles } from "./ai/chief/styles.js";
|
|
9
6
|
import { ApiBot } from "./apibot.js";
|
|
7
|
+
import { ExploreCommand } from "./commands/explore-command.js";
|
|
8
|
+
import { runInit } from "./commands/init-command.js";
|
|
9
|
+
import { KnowCommand } from "./commands/know-command.js";
|
|
10
|
+
import { PlanCommand } from "./commands/plan-command.js";
|
|
11
|
+
import { TestCommand } from "./commands/test-command.js";
|
|
10
12
|
import { ApibotConfigParser } from "./config.js";
|
|
11
|
-
function buildOptions(options) {
|
|
12
|
-
return {
|
|
13
|
-
verbose: options.verbose || options.debug,
|
|
14
|
-
config: options.config,
|
|
15
|
-
path: options.path,
|
|
16
|
-
baseEndpoint: options.endpoint,
|
|
17
|
-
spec: options.spec,
|
|
18
|
-
header: options.header,
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
function addCommonOptions(cmd) {
|
|
22
|
-
return cmd
|
|
23
|
-
.option('-v, --verbose', 'Enable verbose logging')
|
|
24
|
-
.option('--debug', 'Enable debug logging')
|
|
25
|
-
.option('-c, --config <path>', 'Path to configuration file')
|
|
26
|
-
.option('-p, --path <path>', 'Working directory path')
|
|
27
|
-
.option('--endpoint <url>', 'Base API endpoint to test (env: EXPLORBOT_URL)')
|
|
28
|
-
.option('--spec <path>', 'OpenAPI spec file or URL (env: EXPLORBOT_API_SPEC)')
|
|
29
|
-
.option('-H, --header <header>', 'Header sent with every request, as "Name: value". Repeatable (env: EXPLORBOT_API_HEADERS)', (value, previous = []) => [...previous, value]);
|
|
30
|
-
}
|
|
31
|
-
function selectTests(tests, index) {
|
|
32
|
-
if (!index || index === '*' || index === 'all') {
|
|
33
|
-
return tests.filter((t) => t.status === 'pending');
|
|
34
|
-
}
|
|
35
|
-
const rangeMatch = index.match(/^(\d+)-(\d+)$/);
|
|
36
|
-
if (rangeMatch) {
|
|
37
|
-
const start = Number.parseInt(rangeMatch[1]) - 1;
|
|
38
|
-
const end = Number.parseInt(rangeMatch[2]);
|
|
39
|
-
return tests.slice(start, end);
|
|
40
|
-
}
|
|
41
|
-
if (index.includes(',')) {
|
|
42
|
-
const indices = index.split(',').map((i) => Number.parseInt(i.trim()) - 1);
|
|
43
|
-
return indices.map((i) => tests[i]).filter(Boolean);
|
|
44
|
-
}
|
|
45
|
-
const num = Number.parseInt(index);
|
|
46
|
-
if (!Number.isNaN(num) && tests[num - 1]) {
|
|
47
|
-
return [tests[num - 1]];
|
|
48
|
-
}
|
|
49
|
-
return tests.filter((t) => t.status === 'pending');
|
|
50
|
-
}
|
|
51
13
|
export function createApiCommands(name = 'api') {
|
|
52
14
|
const cmd = new Command(name);
|
|
53
15
|
cmd.description('AI-powered API testing tool');
|
|
54
|
-
addCommonOptions(cmd.command('plan <endpoint>').description('Generate test plan for an API endpoint')
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
plan.tests.forEach((test, i) => {
|
|
67
|
-
console.log(` ${i + 1}. [${test.priority}] ${test.scenario}`);
|
|
68
|
-
});
|
|
69
|
-
const savedPath = bot.savePlan();
|
|
70
|
-
if (savedPath) {
|
|
71
|
-
console.log(`\nSaved to: ${savedPath}`);
|
|
72
|
-
console.log('\nRun tests:');
|
|
73
|
-
console.log(` ${name} test ${savedPath} 1 # run first test`);
|
|
74
|
-
console.log(` ${name} test ${savedPath} 1-3 # run tests 1 to 3`);
|
|
75
|
-
console.log(` ${name} test ${savedPath} * # run all tests`);
|
|
76
|
-
}
|
|
77
|
-
await bot.stop();
|
|
78
|
-
process.exit(0);
|
|
79
|
-
}
|
|
80
|
-
catch (error) {
|
|
81
|
-
console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
|
|
82
|
-
process.exit(1);
|
|
83
|
-
}
|
|
16
|
+
addCommonOptions(cmd.command('plan <endpoint>').description('Generate test plan for an API endpoint'))
|
|
17
|
+
.option('--style <style>', 'Planning style: basename of a file in rules/chief/styles/')
|
|
18
|
+
.option('--fresh', 'Start planning from scratch')
|
|
19
|
+
.action(async (endpoint, options) => {
|
|
20
|
+
await run(name, options, endpoint, async (bot) => {
|
|
21
|
+
const command = new PlanCommand(bot);
|
|
22
|
+
command.prefix = name;
|
|
23
|
+
command.style = options.style;
|
|
24
|
+
command.fresh = !!options.fresh;
|
|
25
|
+
await command.execute(endpoint);
|
|
26
|
+
return 0;
|
|
27
|
+
});
|
|
84
28
|
});
|
|
85
29
|
addCommonOptions(cmd.command('config [endpoint]').description('Show models, config file and paths used by this run'))
|
|
86
30
|
.option('--json', 'Print the resolved config as JSON')
|
|
@@ -102,155 +46,34 @@ export function createApiCommands(name = 'api') {
|
|
|
102
46
|
});
|
|
103
47
|
RecommendedModelsCommand.register(cmd);
|
|
104
48
|
addCommonOptions(cmd.command('test <planfile> [index]').description('Execute tests from a plan file. Index: 1, 1-3, *')).action(async (planfile, index, options) => {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
await
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
let passed = 0;
|
|
114
|
-
let failed = 0;
|
|
115
|
-
for (const test of tests) {
|
|
116
|
-
const specDefinition = bot.tryGetEndpointDefinition(test.startUrl);
|
|
117
|
-
const result = await bot.agentCurler().test(test, {
|
|
118
|
-
specDefinition,
|
|
119
|
-
baseEndpoint: bot.getConfig().api.baseEndpoint,
|
|
120
|
-
searchSpec: (query) => bot.searchSpec(query),
|
|
121
|
-
});
|
|
122
|
-
if (result.success)
|
|
123
|
-
passed++;
|
|
124
|
-
else
|
|
125
|
-
failed++;
|
|
126
|
-
}
|
|
127
|
-
bot.savePlan();
|
|
128
|
-
console.log(`\nResults: ${passed} passed, ${failed} failed out of ${tests.length}`);
|
|
129
|
-
await bot.stop();
|
|
130
|
-
process.exit(failed > 0 ? 1 : 0);
|
|
131
|
-
}
|
|
132
|
-
catch (error) {
|
|
133
|
-
console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
|
|
134
|
-
process.exit(1);
|
|
135
|
-
}
|
|
49
|
+
await run(name, options, undefined, async (bot) => {
|
|
50
|
+
const command = new TestCommand(bot);
|
|
51
|
+
command.index = index;
|
|
52
|
+
await command.execute(planfile);
|
|
53
|
+
if (command.failed)
|
|
54
|
+
return 1;
|
|
55
|
+
return 0;
|
|
56
|
+
});
|
|
136
57
|
});
|
|
137
|
-
addCommonOptions(cmd.command('explore <endpoint>').description('Full cycle: plan
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
let totalPassed = 0;
|
|
146
|
-
let totalFailed = 0;
|
|
147
|
-
let totalTests = 0;
|
|
148
|
-
for (const style of styles) {
|
|
149
|
-
console.log(`\n=== Style: ${style} ===\n`);
|
|
150
|
-
const plan = await bot.plan(endpoint, { style, fresh: true });
|
|
151
|
-
if (!plan?.tests.length) {
|
|
152
|
-
console.log(`No tests generated for style: ${style}`);
|
|
153
|
-
continue;
|
|
154
|
-
}
|
|
155
|
-
const pending = plan.getPendingTests();
|
|
156
|
-
for (const test of pending) {
|
|
157
|
-
const specDefinition = bot.tryGetEndpointDefinition(test.startUrl);
|
|
158
|
-
const result = await bot.agentCurler().test(test, {
|
|
159
|
-
specDefinition,
|
|
160
|
-
baseEndpoint: bot.getConfig().api.baseEndpoint,
|
|
161
|
-
searchSpec: (query) => bot.searchSpec(query),
|
|
162
|
-
});
|
|
163
|
-
totalTests++;
|
|
164
|
-
if (result.success)
|
|
165
|
-
totalPassed++;
|
|
166
|
-
else
|
|
167
|
-
totalFailed++;
|
|
168
|
-
}
|
|
169
|
-
bot.savePlan(style);
|
|
170
|
-
}
|
|
171
|
-
console.log('\n=== Final Results ===');
|
|
172
|
-
console.log(`Total: ${totalTests} tests, ${totalPassed} passed, ${totalFailed} failed`);
|
|
173
|
-
await bot.stop();
|
|
174
|
-
process.exit(totalFailed > 0 ? 1 : 0);
|
|
175
|
-
}
|
|
176
|
-
catch (error) {
|
|
177
|
-
console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
|
|
178
|
-
process.exit(1);
|
|
179
|
-
}
|
|
58
|
+
addCommonOptions(cmd.command('explore <endpoint>').description('Full cycle: plan, execute tests, re-plan. Use * to cover many endpoints, or the base endpoint for all of them')).action(async (endpoint, options) => {
|
|
59
|
+
await run(name, options, endpoint, async (bot) => {
|
|
60
|
+
const command = new ExploreCommand(bot);
|
|
61
|
+
await command.execute(endpoint);
|
|
62
|
+
if (command.result.failed)
|
|
63
|
+
return 1;
|
|
64
|
+
return 0;
|
|
65
|
+
});
|
|
180
66
|
});
|
|
181
67
|
cmd
|
|
182
68
|
.command('init')
|
|
183
69
|
.description('Initialize a new apibot project with configuration')
|
|
184
70
|
.option('-f, --force', 'Overwrite existing config file')
|
|
185
71
|
.option('-p, --path <path>', 'Working directory for initialization')
|
|
72
|
+
.option('--provider <name>', 'AI provider written into the config')
|
|
73
|
+
.option('--endpoint <url>', 'Base API endpoint, with --spec skips the questions')
|
|
74
|
+
.option('--spec <path>', 'OpenAPI spec file or URL')
|
|
186
75
|
.action(async (options) => {
|
|
187
|
-
|
|
188
|
-
if (options.path) {
|
|
189
|
-
const resolvedPath = path.resolve(options.path);
|
|
190
|
-
fs.mkdirSync(resolvedPath, { recursive: true });
|
|
191
|
-
process.chdir(resolvedPath);
|
|
192
|
-
console.log(`Working in: ${resolvedPath}`);
|
|
193
|
-
}
|
|
194
|
-
const configPath = path.resolve('apibot.config.ts');
|
|
195
|
-
if (fs.existsSync(configPath) && !options.force) {
|
|
196
|
-
console.log(`Config file already exists: ${configPath}`);
|
|
197
|
-
console.log('Use --force to overwrite.');
|
|
198
|
-
process.exit(1);
|
|
199
|
-
}
|
|
200
|
-
const rl = await import('node:readline');
|
|
201
|
-
const iface = rl.createInterface({ input: process.stdin, output: process.stdout });
|
|
202
|
-
const ask = (q, fallback = '') => new Promise((resolve) => iface.question(q, (a) => resolve(a.trim() || fallback)));
|
|
203
|
-
console.log('Apibot — API Testing Tool Setup\n');
|
|
204
|
-
const baseEndpoint = await ask('Base API endpoint (e.g., https://api.example.com/v1): ');
|
|
205
|
-
if (!baseEndpoint) {
|
|
206
|
-
console.error('Base endpoint is required.');
|
|
207
|
-
iface.close();
|
|
208
|
-
process.exit(1);
|
|
209
|
-
}
|
|
210
|
-
const spec = await ask('OpenAPI spec file or URL (e.g., openapi.yaml or https://.../ — or press Enter to skip): ');
|
|
211
|
-
const knowledge = await ask('Describe your API (auth method, data formats, special rules — or press Enter to skip): ');
|
|
212
|
-
iface.close();
|
|
213
|
-
const specLine = spec ? `\n spec: ['${spec}'],` : '';
|
|
214
|
-
const configContent = `import { openai } from '@ai-sdk/openai';
|
|
215
|
-
|
|
216
|
-
export default {
|
|
217
|
-
ai: {
|
|
218
|
-
model: openai('gpt-4o'),
|
|
219
|
-
},
|
|
220
|
-
api: {
|
|
221
|
-
baseEndpoint: '${baseEndpoint}',${specLine}
|
|
222
|
-
headers: {
|
|
223
|
-
// 'Authorization': 'Bearer <token>',
|
|
224
|
-
},
|
|
225
|
-
// bootstrap: async ({ headers, baseEndpoint }) => {
|
|
226
|
-
// // Run before tests — e.g. obtain auth token
|
|
227
|
-
// // Return headers to merge: { Authorization: 'Bearer ...' }
|
|
228
|
-
// },
|
|
229
|
-
// teardown: async ({ headers, baseEndpoint }) => {
|
|
230
|
-
// // Run after tests — e.g. cleanup test data
|
|
231
|
-
// },
|
|
232
|
-
},
|
|
233
|
-
dirs: {
|
|
234
|
-
output: 'output',
|
|
235
|
-
knowledge: 'knowledge',
|
|
236
|
-
},
|
|
237
|
-
};
|
|
238
|
-
`;
|
|
239
|
-
fs.writeFileSync(configPath, configContent, 'utf8');
|
|
240
|
-
console.log(`\nCreated: ${configPath}`);
|
|
241
|
-
fs.mkdirSync('output', { recursive: true });
|
|
242
|
-
fs.mkdirSync('knowledge', { recursive: true });
|
|
243
|
-
if (knowledge) {
|
|
244
|
-
const knowledgePath = path.resolve('knowledge', 'general.md');
|
|
245
|
-
fs.writeFileSync(knowledgePath, `---\nendpoint: "*"\n---\n${knowledge}\n`, 'utf8');
|
|
246
|
-
console.log(`Created: ${knowledgePath}`);
|
|
247
|
-
}
|
|
248
|
-
console.log('\nNext steps:');
|
|
249
|
-
console.log('1. Edit apibot.config.ts — set your AI provider and API headers');
|
|
250
|
-
console.log(`2. Add API knowledge: ${name} know /users "CRUD endpoint for user management"`);
|
|
251
|
-
console.log(`3. Plan tests: ${name} plan /users`);
|
|
252
|
-
if (process.cwd() !== originalCwd)
|
|
253
|
-
process.chdir(originalCwd);
|
|
76
|
+
await runInit({ ...options, baseEndpoint: options.endpoint, prefix: name });
|
|
254
77
|
});
|
|
255
78
|
cmd
|
|
256
79
|
.command('know <endpoint> [description]')
|
|
@@ -259,38 +82,59 @@ export default {
|
|
|
259
82
|
.option('-c, --config <path>', 'Path to configuration file')
|
|
260
83
|
.option('-p, --path <path>', 'Working directory path')
|
|
261
84
|
.action(async (endpoint, description, options) => {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
description = await new Promise((resolve) => iface.question(`Describe ${endpoint}: `, (a) => resolve(a.trim())));
|
|
266
|
-
iface.close();
|
|
267
|
-
}
|
|
268
|
-
if (!description) {
|
|
269
|
-
console.error('Description is required.');
|
|
270
|
-
process.exit(1);
|
|
271
|
-
}
|
|
272
|
-
let knowledgeDir = 'knowledge';
|
|
85
|
+
const command = new KnowCommand(new ApiBot(buildOptions(options)));
|
|
86
|
+
command.prefix = name;
|
|
87
|
+
command.knowledge = description || (await askDescription(endpoint));
|
|
273
88
|
try {
|
|
274
|
-
|
|
275
|
-
await ApibotConfigParser.getInstance().loadConfig({ config: options.config, path: options.path });
|
|
276
|
-
knowledgeDir = ApibotConfigParser.getInstance().getKnowledgeDir();
|
|
89
|
+
await command.execute(endpoint);
|
|
277
90
|
}
|
|
278
|
-
catch {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}
|
|
282
|
-
fs.mkdirSync(knowledgeDir, { recursive: true });
|
|
283
|
-
const filename = endpoint.replace(/^\//, '').replace(/[^a-zA-Z0-9]/g, '_') || 'general';
|
|
284
|
-
const filePath = path.join(knowledgeDir, `${filename}.md`);
|
|
285
|
-
const content = `---\nendpoint: "${endpoint}"\n---\n${description}\n`;
|
|
286
|
-
if (fs.existsSync(filePath)) {
|
|
287
|
-
fs.appendFileSync(filePath, `\n---\n${description}\n`, 'utf8');
|
|
288
|
-
console.log(`Updated: ${filePath}`);
|
|
289
|
-
}
|
|
290
|
-
else {
|
|
291
|
-
fs.writeFileSync(filePath, content, 'utf8');
|
|
292
|
-
console.log(`Created: ${filePath}`);
|
|
91
|
+
catch (error) {
|
|
92
|
+
console.error(error instanceof Error ? error.message : 'Unknown error');
|
|
93
|
+
process.exit(1);
|
|
293
94
|
}
|
|
294
95
|
});
|
|
295
96
|
return cmd;
|
|
296
97
|
}
|
|
98
|
+
async function run(name, options, endpoint, body) {
|
|
99
|
+
setPreserveConsoleLogs(true);
|
|
100
|
+
try {
|
|
101
|
+
if (endpoint && URL.canParse(endpoint))
|
|
102
|
+
options.endpoint ||= endpoint;
|
|
103
|
+
const bot = new ApiBot({ ...buildOptions(options), endpoint });
|
|
104
|
+
await bot.start();
|
|
105
|
+
const code = await body(bot);
|
|
106
|
+
await bot.stop();
|
|
107
|
+
process.exit(code);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function askDescription(endpoint) {
|
|
115
|
+
const rl = await import('node:readline');
|
|
116
|
+
const iface = rl.createInterface({ input: process.stdin, output: process.stdout });
|
|
117
|
+
const answer = await new Promise((resolve) => iface.question(`Describe ${endpoint}: `, (text) => resolve(text.trim())));
|
|
118
|
+
iface.close();
|
|
119
|
+
return answer;
|
|
120
|
+
}
|
|
121
|
+
function buildOptions(options) {
|
|
122
|
+
return {
|
|
123
|
+
verbose: options.verbose || options.debug,
|
|
124
|
+
config: options.config,
|
|
125
|
+
path: options.path,
|
|
126
|
+
baseEndpoint: options.endpoint,
|
|
127
|
+
spec: options.spec,
|
|
128
|
+
header: options.header,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function addCommonOptions(cmd) {
|
|
132
|
+
return cmd
|
|
133
|
+
.option('-v, --verbose', 'Enable verbose logging')
|
|
134
|
+
.option('--debug', 'Enable debug logging')
|
|
135
|
+
.option('-c, --config <path>', 'Path to configuration file')
|
|
136
|
+
.option('-p, --path <path>', 'Working directory path')
|
|
137
|
+
.option('--endpoint <url>', 'Base API endpoint to test (env: EXPLORBOT_URL)')
|
|
138
|
+
.option('--spec <path>', 'OpenAPI spec file or URL (env: EXPLORBOT_API_SPEC)')
|
|
139
|
+
.option('-H, --header <header>', 'Header sent with every request, as "Name: value". Repeatable (env: EXPLORBOT_API_HEADERS)', (value, previous = []) => [...previous, value]);
|
|
140
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import figureSet from 'figures';
|
|
2
|
+
import { tag } from "../../../../src/utils/logger.js";
|
|
3
|
+
import { getStyles } from "../ai/chief/styles.js";
|
|
4
|
+
import { ApiCommand } from "./api-command.js";
|
|
5
|
+
export class ExploreCommand extends ApiCommand {
|
|
6
|
+
name = 'explore';
|
|
7
|
+
description = 'Full cycle: plan, execute tests, re-plan. Use * to cover many endpoints, or the base endpoint for all of them';
|
|
8
|
+
result = { tests: 0, passed: 0, failed: 0 };
|
|
9
|
+
async execute(endpoint) {
|
|
10
|
+
const styles = Object.keys(getStyles());
|
|
11
|
+
const endpoints = this.bot.expandEndpoints(endpoint);
|
|
12
|
+
for (const [index, target] of endpoints.entries()) {
|
|
13
|
+
let runStyles = [styles[index % styles.length]];
|
|
14
|
+
if (endpoints.length === 1)
|
|
15
|
+
runStyles = styles;
|
|
16
|
+
if (endpoints.length > 1)
|
|
17
|
+
tag('info').log(`Endpoint ${index + 1}/${endpoints.length}: ${target}`);
|
|
18
|
+
for (const style of runStyles) {
|
|
19
|
+
await this.runStyle(target, style);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
tag('info').log(`${figureSet.tick} ${this.result.tests} tests completed: ${this.result.passed} passed, ${this.result.failed} failed`);
|
|
23
|
+
}
|
|
24
|
+
async runStyle(endpoint, style) {
|
|
25
|
+
tag('info').log(`Planning style: ${style}`);
|
|
26
|
+
const plan = await this.bot.plan(endpoint, { style, fresh: true });
|
|
27
|
+
if (!plan?.tests.length) {
|
|
28
|
+
tag('warning').log(`No tests generated for style: ${style}`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
for (const test of plan.getPendingTests()) {
|
|
32
|
+
const outcome = await this.bot.runTest(test);
|
|
33
|
+
this.result.tests++;
|
|
34
|
+
if (outcome.success)
|
|
35
|
+
this.result.passed++;
|
|
36
|
+
else
|
|
37
|
+
this.result.failed++;
|
|
38
|
+
}
|
|
39
|
+
this.bot.savePlan(style);
|
|
40
|
+
}
|
|
41
|
+
}
|