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,88 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { envTemplate, modelLines } from "../../../../src/commands/init-command.js";
|
|
5
|
+
import { missingModelRoles } from "../../../../src/config.js";
|
|
6
|
+
import { log, tag } from "../../../../src/utils/logger.js";
|
|
7
|
+
export async function runInit(options) {
|
|
8
|
+
const provider = options.provider || 'openrouter';
|
|
9
|
+
const originalCwd = process.cwd();
|
|
10
|
+
if (options.path) {
|
|
11
|
+
const dir = path.resolve(options.path);
|
|
12
|
+
mkdirSync(dir, { recursive: true });
|
|
13
|
+
process.chdir(dir);
|
|
14
|
+
log(`Working in directory: ${dir}`);
|
|
15
|
+
}
|
|
16
|
+
const configPath = path.resolve('apibot.config.js');
|
|
17
|
+
if (existsSync(configPath) && !options.force) {
|
|
18
|
+
log(`Config file already exists: ${configPath}`);
|
|
19
|
+
log('Use --force to overwrite existing file');
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
const answers = await ask(options);
|
|
23
|
+
if (!answers.baseEndpoint) {
|
|
24
|
+
tag('error').log('Base endpoint is required.');
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
if (!answers.spec) {
|
|
28
|
+
tag('error').log('OpenAPI spec is required. Chief plans from it and Curler looks up schemas in it.');
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
writeFileSync(configPath, configTemplate(provider, answers.baseEndpoint, answers.spec), 'utf8');
|
|
32
|
+
log(`Created config file: ${configPath}`);
|
|
33
|
+
const envPath = path.resolve('.env');
|
|
34
|
+
if (!existsSync(envPath)) {
|
|
35
|
+
writeFileSync(envPath, `${envTemplate(provider)}\n`, 'utf8');
|
|
36
|
+
log(`Created env file: ${envPath}`);
|
|
37
|
+
}
|
|
38
|
+
mkdirSync('output', { recursive: true });
|
|
39
|
+
mkdirSync('knowledge', { recursive: true });
|
|
40
|
+
if (answers.knowledge) {
|
|
41
|
+
const knowledgePath = path.resolve('knowledge', 'general.md');
|
|
42
|
+
writeFileSync(knowledgePath, `---\nendpoint: "*"\n---\n${answers.knowledge}\n`, 'utf8');
|
|
43
|
+
log(`Created knowledge file: ${knowledgePath}`);
|
|
44
|
+
}
|
|
45
|
+
const missing = missingModelRoles(provider);
|
|
46
|
+
if (missing.length) {
|
|
47
|
+
tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${configPath}`);
|
|
48
|
+
}
|
|
49
|
+
log('');
|
|
50
|
+
log('Next steps:');
|
|
51
|
+
log('1. Add your provider API key to .env');
|
|
52
|
+
log('2. Describe the API so the plans match it');
|
|
53
|
+
tag('substep').log(chalk.yellow(`${options.prefix} know /users "CRUD endpoint for user management"`));
|
|
54
|
+
log('3. Plan and run tests for one endpoint');
|
|
55
|
+
tag('substep').log(chalk.yellow(`${options.prefix} explore /users`));
|
|
56
|
+
if (process.cwd() !== originalCwd)
|
|
57
|
+
process.chdir(originalCwd);
|
|
58
|
+
}
|
|
59
|
+
function configTemplate(provider, baseEndpoint, spec) {
|
|
60
|
+
return `// Models are written as 'provider/model-id' so they resolve without a local node_modules.
|
|
61
|
+
// https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
|
|
62
|
+
|
|
63
|
+
export default {
|
|
64
|
+
ai: {
|
|
65
|
+
${modelLines(provider, ['model', 'agenticModel'])}
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
api: {
|
|
69
|
+
baseEndpoint: '${baseEndpoint}',
|
|
70
|
+
spec: ['${spec}'],
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
`;
|
|
74
|
+
}
|
|
75
|
+
async function ask(options) {
|
|
76
|
+
if (options.baseEndpoint) {
|
|
77
|
+
return { baseEndpoint: options.baseEndpoint, spec: options.spec || '', knowledge: '' };
|
|
78
|
+
}
|
|
79
|
+
const rl = await import('node:readline');
|
|
80
|
+
const iface = rl.createInterface({ input: process.stdin, output: process.stdout });
|
|
81
|
+
const question = (text) => new Promise((resolve) => iface.question(text, (answer) => resolve(answer.trim())));
|
|
82
|
+
log('Apibot — API Testing Tool Setup\n');
|
|
83
|
+
const baseEndpoint = await question('Base API endpoint (e.g. https://api.example.com/v1): ');
|
|
84
|
+
const spec = await question('OpenAPI spec file or URL: ');
|
|
85
|
+
const knowledge = await question('Describe your API, its auth and its rules (Enter to skip): ');
|
|
86
|
+
iface.close();
|
|
87
|
+
return { baseEndpoint, spec, knowledge };
|
|
88
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { tag } from "../../../../src/utils/logger.js";
|
|
4
|
+
import { ApiCommand } from "./api-command.js";
|
|
5
|
+
export class KnowCommand extends ApiCommand {
|
|
6
|
+
name = 'know';
|
|
7
|
+
aliases = ['add-knowledge'];
|
|
8
|
+
description = 'Add API knowledge for an endpoint';
|
|
9
|
+
knowledge = '';
|
|
10
|
+
async execute(endpoint) {
|
|
11
|
+
if (!this.knowledge) {
|
|
12
|
+
throw new Error('Description is required.');
|
|
13
|
+
}
|
|
14
|
+
const knowledgeDir = await this.resolveKnowledgeDir();
|
|
15
|
+
fs.mkdirSync(knowledgeDir, { recursive: true });
|
|
16
|
+
const filename = endpoint.replace(/^\//, '').replace(/[^a-zA-Z0-9]/g, '_') || 'general';
|
|
17
|
+
const filePath = path.join(knowledgeDir, `${filename}.md`);
|
|
18
|
+
if (fs.existsSync(filePath)) {
|
|
19
|
+
fs.appendFileSync(filePath, `\n---\n${this.knowledge}\n`, 'utf8');
|
|
20
|
+
tag('success').log(`Updated: ${filePath}`);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
fs.writeFileSync(filePath, `---\nendpoint: "${endpoint}"\n---\n${this.knowledge}\n`, 'utf8');
|
|
24
|
+
tag('success').log(`Created: ${filePath}`);
|
|
25
|
+
}
|
|
26
|
+
async resolveKnowledgeDir() {
|
|
27
|
+
const parser = this.bot.getConfigParser();
|
|
28
|
+
const options = this.bot.getOptions();
|
|
29
|
+
try {
|
|
30
|
+
await parser.loadConfig(options);
|
|
31
|
+
return parser.getKnowledgeDir();
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
if (options.path)
|
|
35
|
+
return path.join(path.resolve(options.path), 'knowledge');
|
|
36
|
+
return 'knowledge';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { tag } from "../../../../src/utils/logger.js";
|
|
2
|
+
import { printNextSteps, relativeToCwd } from "../../../../src/utils/next-steps.js";
|
|
3
|
+
import { ApiCommand } from "./api-command.js";
|
|
4
|
+
export class PlanCommand extends ApiCommand {
|
|
5
|
+
name = 'plan';
|
|
6
|
+
description = 'Generate a test plan for an API endpoint';
|
|
7
|
+
style;
|
|
8
|
+
fresh = false;
|
|
9
|
+
async execute(endpoint) {
|
|
10
|
+
await this.bot.plan(endpoint, { style: this.style, fresh: this.fresh });
|
|
11
|
+
const plan = this.bot.getCurrentPlan();
|
|
12
|
+
if (!plan?.tests.length) {
|
|
13
|
+
throw new Error('No test scenarios generated.');
|
|
14
|
+
}
|
|
15
|
+
const lines = [`Plan: ${plan.title} (${plan.tests.length} tests)`];
|
|
16
|
+
for (const [i, test] of plan.tests.entries()) {
|
|
17
|
+
lines.push(` ${String(i + 1).padStart(2)}. [${test.priority}] ${test.scenario}`);
|
|
18
|
+
}
|
|
19
|
+
tag('multiline').log(lines.join('\n'), { maxLines: 24 });
|
|
20
|
+
const savedPath = this.bot.savePlan();
|
|
21
|
+
if (!savedPath)
|
|
22
|
+
return;
|
|
23
|
+
const relative = relativeToCwd(savedPath);
|
|
24
|
+
const sections = [
|
|
25
|
+
{
|
|
26
|
+
label: 'Plan',
|
|
27
|
+
path: savedPath,
|
|
28
|
+
commands: [
|
|
29
|
+
{ label: 'Run first', command: `${this.prefix} test ${relative} 1` },
|
|
30
|
+
{ label: 'Run all', command: `${this.prefix} test ${relative} *` },
|
|
31
|
+
{ label: 'Run range', command: `${this.prefix} test ${relative} 1-3` },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
printNextSteps(sections);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import figureSet from 'figures';
|
|
2
|
+
import { tag } from "../../../../src/utils/logger.js";
|
|
3
|
+
import { ApiCommand } from "./api-command.js";
|
|
4
|
+
export class TestCommand extends ApiCommand {
|
|
5
|
+
name = 'test';
|
|
6
|
+
description = 'Execute tests from a plan file. Index: 1, 1-3, *';
|
|
7
|
+
index;
|
|
8
|
+
failed = 0;
|
|
9
|
+
async execute(planfile) {
|
|
10
|
+
const plan = this.bot.loadPlan(planfile);
|
|
11
|
+
tag('info').log(`Plan loaded: "${plan.title}" (${plan.tests.length} tests)`);
|
|
12
|
+
const tests = selectTests(plan.tests, this.index);
|
|
13
|
+
tag('info').log(`Running ${tests.length} test(s)`);
|
|
14
|
+
let passed = 0;
|
|
15
|
+
for (const test of tests) {
|
|
16
|
+
const result = await this.bot.runTest(test);
|
|
17
|
+
if (result.success)
|
|
18
|
+
passed++;
|
|
19
|
+
else
|
|
20
|
+
this.failed++;
|
|
21
|
+
}
|
|
22
|
+
this.bot.savePlan();
|
|
23
|
+
tag('info').log(`${figureSet.tick} ${tests.length} tests completed: ${passed} passed, ${this.failed} failed`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function selectTests(tests, index) {
|
|
27
|
+
if (!index || index === '*' || index === 'all') {
|
|
28
|
+
return tests.filter((t) => t.status === 'pending');
|
|
29
|
+
}
|
|
30
|
+
const rangeMatch = index.match(/^(\d+)-(\d+)$/);
|
|
31
|
+
if (rangeMatch) {
|
|
32
|
+
const start = Number.parseInt(rangeMatch[1]) - 1;
|
|
33
|
+
const end = Number.parseInt(rangeMatch[2]);
|
|
34
|
+
return tests.slice(start, end);
|
|
35
|
+
}
|
|
36
|
+
if (index.includes(',')) {
|
|
37
|
+
const indices = index.split(',').map((i) => Number.parseInt(i.trim()) - 1);
|
|
38
|
+
return indices.map((i) => tests[i]).filter(Boolean);
|
|
39
|
+
}
|
|
40
|
+
const num = Number.parseInt(index);
|
|
41
|
+
if (!Number.isNaN(num) && tests[num - 1]) {
|
|
42
|
+
return [tests[num - 1]];
|
|
43
|
+
}
|
|
44
|
+
return tests.filter((t) => t.status === 'pending');
|
|
45
|
+
}
|
|
@@ -18,6 +18,7 @@ import { findSiteWith, listSites } from "../../../src/global-config.js";
|
|
|
18
18
|
import { Reporter } from "../../../src/reporter.js";
|
|
19
19
|
import { Stats } from "../../../src/stats.js";
|
|
20
20
|
import { Task, Test, TestResult } from "../../../src/test-plan.js";
|
|
21
|
+
import { ariaRefSnapshot } from "../../../src/utils/aria-ref.js";
|
|
21
22
|
import { compactAriaSnapshot } from "../../../src/utils/aria.js";
|
|
22
23
|
import { browserErrorMessage } from "../../../src/utils/browser-errors.js";
|
|
23
24
|
import { pluralize } from "../../../src/utils/logger.js";
|
|
@@ -295,11 +296,17 @@ export class Prima {
|
|
|
295
296
|
settleError = error;
|
|
296
297
|
return null;
|
|
297
298
|
});
|
|
298
|
-
|
|
299
|
-
trace.push({ label: 'settling which instructions were satisfied', ok: false, proof: browserErrorMessage(settleError) });
|
|
299
|
+
const stillOpen = ledger.filter((entry) => entry.status === 'open').length;
|
|
300
300
|
for (const execution of invoked?.toolExecutions || []) {
|
|
301
301
|
this.applyLedgerReport(execution, ledger, trace);
|
|
302
302
|
}
|
|
303
|
+
let unsettled = '';
|
|
304
|
+
if (settleError)
|
|
305
|
+
unsettled = browserErrorMessage(settleError);
|
|
306
|
+
if (invoked && ledger.filter((entry) => entry.status === 'open').length === stillOpen)
|
|
307
|
+
unsettled = 'the model was asked to report every remaining instruction and reported none';
|
|
308
|
+
if (unsettled)
|
|
309
|
+
trace.push({ label: 'settling which instructions were satisfied', ok: false, proof: unsettled });
|
|
303
310
|
}
|
|
304
311
|
ledgerProgress(ledger) {
|
|
305
312
|
return ledger
|
|
@@ -858,7 +865,7 @@ export class Prima {
|
|
|
858
865
|
return path.join(path.basename(dir), name);
|
|
859
866
|
}
|
|
860
867
|
async refAriaSnapshot(result) {
|
|
861
|
-
const snapshot = await Promise.resolve(this.bot.getExplorer()?.withPage?.(
|
|
868
|
+
const snapshot = await Promise.resolve(this.bot.getExplorer()?.withPage?.(ariaRefSnapshot)).catch(() => null);
|
|
862
869
|
return snapshot || result.ariaSnapshot;
|
|
863
870
|
}
|
|
864
871
|
executedCodes(code) {
|
package/dist/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "explorbot",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"description": "CLI app built with React Ink, CodeceptJS, and Playwright",
|
|
5
5
|
"license": "Elastic-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
"bash-tool": "^1.3.15",
|
|
102
102
|
"chalk": "^5.6.2",
|
|
103
103
|
"cli-highlight": "^2.1.11",
|
|
104
|
-
"codeceptjs": "4.
|
|
104
|
+
"codeceptjs": "4.2.0-beta.2",
|
|
105
105
|
"commander": "^14.0.1",
|
|
106
106
|
"debug": "^4.4.3",
|
|
107
107
|
"dedent": "^1.6.0",
|
|
@@ -123,8 +123,8 @@
|
|
|
123
123
|
"ora-classic": "^5.4.2",
|
|
124
124
|
"parse5": "^8.0.0",
|
|
125
125
|
"pixelmatch": "^7.2.0",
|
|
126
|
-
"playwright": "^1.
|
|
127
|
-
"playwright-core": "^1.
|
|
126
|
+
"playwright": "^1.63",
|
|
127
|
+
"playwright-core": "^1.63",
|
|
128
128
|
"pngjs": "^7.0.0",
|
|
129
129
|
"react": "^19.1.1",
|
|
130
130
|
"sambanova-ai-provider": "^1.2.2",
|
|
@@ -3,8 +3,10 @@ import dedent from 'dedent';
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { extractEndpointDefinition } from "../../api/spec-reader.js";
|
|
5
5
|
import { tag } from "../../utils/logger.js";
|
|
6
|
+
import { truncate } from "../../utils/strings.js";
|
|
6
7
|
import { isDynamicSegment } from "../../utils/url-matcher.js";
|
|
7
8
|
const BODY_PREVIEW_LIMIT = 2000;
|
|
9
|
+
const READS_IN_ANSWER = 3;
|
|
8
10
|
export function createFishermanTools(apiClient, requestStore, haul, opts) {
|
|
9
11
|
const readOnly = opts.readOnly === true;
|
|
10
12
|
let finished = false;
|
|
@@ -213,7 +215,7 @@ export function createAskApiTool(fisherman, task) {
|
|
|
213
215
|
return { answered: false, reason: result.summary || 'The API could not answer this question' };
|
|
214
216
|
}
|
|
215
217
|
task.addNote(`Asked API: ${question} — ${result.summary}`);
|
|
216
|
-
tag('success').log(`Ask API: ${result.summary}`);
|
|
218
|
+
tag('success').log(`Ask API: ${truncate(result.summary, 200)}`);
|
|
217
219
|
return { answered: true, answer: result.summary };
|
|
218
220
|
},
|
|
219
221
|
}),
|
|
@@ -252,6 +254,10 @@ function synthesizeResult(haul, declaredDone, readOnly) {
|
|
|
252
254
|
succeeded = haul.successfulReads();
|
|
253
255
|
successLabel = 'successful reads';
|
|
254
256
|
}
|
|
257
|
+
if (readOnly && succeeded.length > 0) {
|
|
258
|
+
const bodies = succeeded.slice(-READS_IN_ANSWER).map((read) => `${read.toEndpoint()} → ${read.rawResponseBody.substring(0, BODY_PREVIEW_LIMIT)}`);
|
|
259
|
+
return { success: true, summary: bodies.join('\n\n'), created: [], failed: [] };
|
|
260
|
+
}
|
|
255
261
|
let summary = `Stopped before finishing: ${made.length} requests, ${succeeded.length} ${successLabel}, ${failures.length} failed`;
|
|
256
262
|
const lastFailure = failures[failures.length - 1];
|
|
257
263
|
if (lastFailure)
|
package/dist/src/ai/fisherman.js
CHANGED
|
@@ -2,6 +2,7 @@ import dedent from 'dedent';
|
|
|
2
2
|
import { isFailedRequest } from "../api/request-store.js";
|
|
3
3
|
import { listAllEndpoints } from "../api/spec-reader.js";
|
|
4
4
|
import { createDebug, tag } from "../utils/logger.js";
|
|
5
|
+
import { truncate } from "../utils/strings.js";
|
|
5
6
|
const debugLog = createDebug('explorbot:fisherman');
|
|
6
7
|
import { loop } from "../utils/loop.js";
|
|
7
8
|
import { RequestHaul } from "./fisherman/request-haul.js";
|
|
@@ -106,7 +107,7 @@ export class Fisherman {
|
|
|
106
107
|
`);
|
|
107
108
|
await this.runSession(conversation, tools, { haul, isFinished, finishFromText, label: `fisherman lookup: ${question.slice(0, 50)}` });
|
|
108
109
|
const result = getResult();
|
|
109
|
-
tag('info').log(`Fisherman answer: ${result.summary}`);
|
|
110
|
+
tag('info').log(`Fisherman answer: ${truncate(result.summary, 200)}`);
|
|
110
111
|
return result;
|
|
111
112
|
}
|
|
112
113
|
async runSession(conversation, tools, opts) {
|
package/dist/src/ai/pilot.d.ts
CHANGED
|
@@ -97,7 +97,6 @@ export declare class Pilot implements Agent {
|
|
|
97
97
|
hasSuccessfulCheckEvidence(currentState: ActionResult, testerConversation: Conversation): boolean;
|
|
98
98
|
formatSuccessfulAssertions(currentState: ActionResult, testerConversation: Conversation): string;
|
|
99
99
|
formatActions(toolCalls: any[]): string;
|
|
100
|
-
buildDeletionScope(task: Test): string;
|
|
101
100
|
getSystemPrompt(task: Test, initialState: ActionResult): string;
|
|
102
101
|
}
|
|
103
102
|
export type SettledStatus = 'passed' | 'failed' | 'unverified' | 'contradiction';
|
package/dist/src/ai/pilot.js
CHANGED
|
@@ -316,28 +316,28 @@ export class Pilot {
|
|
|
316
316
|
return dedent `
|
|
317
317
|
SCENARIO: ${task.scenario}
|
|
318
318
|
|
|
319
|
-
${this.buildDeletionScope(task)}
|
|
320
|
-
|
|
321
319
|
EXPECTED RESULTS (milestones):
|
|
322
320
|
${task.expected.map((e) => `- ${e}`).join('\n')}
|
|
323
321
|
`;
|
|
324
322
|
}
|
|
325
323
|
buildResetSystemPrompt(task) {
|
|
326
324
|
return dedent `
|
|
327
|
-
You are Pilot — decide whether a reset is legitimate. Reset
|
|
328
|
-
iteration's work
|
|
329
|
-
|
|
325
|
+
You are Pilot — decide whether a reset is legitimate. Reset only re-navigates to the start URL:
|
|
326
|
+
it writes nothing, though it abandons this iteration's work and server-side side effects persist.
|
|
327
|
+
The hazard is the tester REDOING a completed flow afterwards — duplicate data and infinite loops.
|
|
330
328
|
|
|
331
329
|
${this.buildSharedEvidenceRules()}
|
|
332
330
|
|
|
333
331
|
DECISION:
|
|
334
|
-
- "allow": current page cannot host the scenario, irrecoverable error,
|
|
335
|
-
|
|
332
|
+
- "allow": current page cannot host the scenario, irrecoverable error, no path back, or an
|
|
333
|
+
expectation requires the outcome to survive a reload or a return to the start page and no
|
|
334
|
+
reset has been taken yet this run — there the reset IS the check, not a redo.
|
|
335
|
+
- "continue": the outcome the scenario needs is already observable on the CURRENT page — verify/finish instead. Provide guidance.
|
|
336
336
|
- "fail": resetCount >= 2 and underlying situation hasn't changed; same flow tried twice with same failure mode.
|
|
337
337
|
- "skipped": feature doesn't exist on this app or prerequisites can't be met.
|
|
338
338
|
|
|
339
339
|
PRIORITY:
|
|
340
|
-
1) Successful side effects in session_log →
|
|
340
|
+
1) Successful side effects in session_log → allow reset only to re-observe them, never to repeat them.
|
|
341
341
|
2) resetCount — each prior reset raises the bar.
|
|
342
342
|
3) Tester's stated reason — weigh against evidence, don't trust blindly.
|
|
343
343
|
|
|
@@ -999,22 +999,6 @@ export class Pilot {
|
|
|
999
999
|
})
|
|
1000
1000
|
.join('\n\n');
|
|
1001
1001
|
}
|
|
1002
|
-
buildDeletionScope(task) {
|
|
1003
|
-
const deletableItems = task.plan
|
|
1004
|
-
? task.plan
|
|
1005
|
-
.listTests()
|
|
1006
|
-
.filter((t) => t.isSuccessful && t.sessionName)
|
|
1007
|
-
.map((t) => t.sessionName)
|
|
1008
|
-
: [];
|
|
1009
|
-
const scenarioLower = task.scenario.toLowerCase();
|
|
1010
|
-
if (deletableItems.length > 0) {
|
|
1011
|
-
return `For deletion scenarios, items can only be deleted if their title contains: ${deletableItems.join(', ')}`;
|
|
1012
|
-
}
|
|
1013
|
-
if (scenarioLower.includes('delete') || scenarioLower.includes('remove')) {
|
|
1014
|
-
return 'No items available for deletion — test should create an item first';
|
|
1015
|
-
}
|
|
1016
|
-
return '';
|
|
1017
|
-
}
|
|
1018
1002
|
getSystemPrompt(task, initialState) {
|
|
1019
1003
|
const interactive = isInteractive();
|
|
1020
1004
|
const stepsText = task.plannedSteps.length > 0 ? task.plannedSteps.map((s, i) => `${i + 1}. ${s}`).join('\n') : 'No planned steps';
|
package/dist/src/ai/planner.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { Conversation } from './conversation.js';
|
|
|
9
9
|
import type { Fisherman } from './fisherman.js';
|
|
10
10
|
import type { Provider } from './provider.js';
|
|
11
11
|
import { type Researcher } from './researcher.js';
|
|
12
|
+
import type { Scout } from './scout.js';
|
|
12
13
|
declare const PlannerBase: {
|
|
13
14
|
new (...args: any[]): {
|
|
14
15
|
currentPlan: Plan | null;
|
|
@@ -59,9 +60,12 @@ export declare class Planner extends PlannerBase implements Agent {
|
|
|
59
60
|
lastSuite: Suite | null;
|
|
60
61
|
researcher: Researcher;
|
|
61
62
|
fisherman: Fisherman | null;
|
|
63
|
+
scout: Scout | null;
|
|
62
64
|
constructor(deps: AgentDeps, researcher: Researcher);
|
|
63
65
|
setFisherman(fisherman: Fisherman): void;
|
|
66
|
+
setScout(scout: Scout): void;
|
|
64
67
|
get sectionOrder(): string[];
|
|
68
|
+
get docsWeight(): number;
|
|
65
69
|
getDefaultStartUrl(state: {
|
|
66
70
|
url: string;
|
|
67
71
|
fullUrl?: string;
|
package/dist/src/ai/planner.js
CHANGED
|
@@ -50,6 +50,7 @@ export class Planner extends PlannerBase {
|
|
|
50
50
|
lastSuite = null;
|
|
51
51
|
researcher;
|
|
52
52
|
fisherman = null;
|
|
53
|
+
scout = null;
|
|
53
54
|
constructor(deps, researcher) {
|
|
54
55
|
super();
|
|
55
56
|
this.explorer = deps.explorer;
|
|
@@ -62,9 +63,16 @@ export class Planner extends PlannerBase {
|
|
|
62
63
|
setFisherman(fisherman) {
|
|
63
64
|
this.fisherman = fisherman;
|
|
64
65
|
}
|
|
66
|
+
setScout(scout) {
|
|
67
|
+
this.scout = scout;
|
|
68
|
+
}
|
|
65
69
|
get sectionOrder() {
|
|
66
70
|
return ConfigParser.getInstance().getConfig().ai?.agents?.researcher?.sections || Object.keys(POSSIBLE_SECTIONS);
|
|
67
71
|
}
|
|
72
|
+
get docsWeight() {
|
|
73
|
+
const value = ConfigParser.getInstance().getConfig().ai?.agents?.planner?.docsWeight ?? 70;
|
|
74
|
+
return Math.max(0, Math.min(100, value));
|
|
75
|
+
}
|
|
68
76
|
getDefaultStartUrl(state) {
|
|
69
77
|
return state.fullUrl || state.url;
|
|
70
78
|
}
|
|
@@ -291,6 +299,7 @@ export class Planner extends PlannerBase {
|
|
|
291
299
|
const conversation = new Conversation([], model);
|
|
292
300
|
conversation.autoTrimTag('page_research', 20000);
|
|
293
301
|
conversation.autoTrimTag('tested_scenarios', 10000);
|
|
302
|
+
conversation.autoTrimTag('docs_context', 8000);
|
|
294
303
|
conversation.addUserText(this.getSystemMessage(feature));
|
|
295
304
|
const planningPrompt = dedent `
|
|
296
305
|
<task>
|
|
@@ -345,6 +354,10 @@ export class Planner extends PlannerBase {
|
|
|
345
354
|
const research = await this.researcher.research(currentState || state, {
|
|
346
355
|
deep: true,
|
|
347
356
|
});
|
|
357
|
+
let docsPromise = null;
|
|
358
|
+
if (this.scout && this.docsWeight > 0) {
|
|
359
|
+
docsPromise = this.scout.collectDocs({ url: state.url, title: state.title, feature, excludeUrls: this.knowledgeTracker.applicationSpecUrls(state) });
|
|
360
|
+
}
|
|
348
361
|
let plannerResearch = mdq(research).query('code').replace('');
|
|
349
362
|
plannerResearch = mdq(plannerResearch)
|
|
350
363
|
.query('table')
|
|
@@ -376,6 +389,21 @@ export class Planner extends PlannerBase {
|
|
|
376
389
|
if (applicationContext) {
|
|
377
390
|
conversation.addUserText(applicationContext);
|
|
378
391
|
}
|
|
392
|
+
if (docsPromise) {
|
|
393
|
+
const docs = await docsPromise;
|
|
394
|
+
if (docs) {
|
|
395
|
+
conversation.addUserText(dedent `
|
|
396
|
+
<docs_context>
|
|
397
|
+
Documentation retrieved from the collected corpus by the Scout agent.
|
|
398
|
+
Ground scenarios in these documented capabilities where they apply; treat them as supporting context, not a script.
|
|
399
|
+
|
|
400
|
+
Aim for roughly ${this.docsWeight}% of the scenarios to exercise behavior documented above; the remainder may explore beyond the documentation.
|
|
401
|
+
|
|
402
|
+
${docs}
|
|
403
|
+
</docs_context>
|
|
404
|
+
`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
379
407
|
conversation.addUserText(dedent `
|
|
380
408
|
${this.buildApproach(style)}
|
|
381
409
|
|
package/dist/src/ai/provider.js
CHANGED
|
@@ -366,7 +366,9 @@ export class Provider {
|
|
|
366
366
|
const modelName = getModelName(model);
|
|
367
367
|
setActivity(`🤖 Asking ${modelName} with dynamic tools`, 'ai');
|
|
368
368
|
promptLog(`Using model: ${modelName}`);
|
|
369
|
-
|
|
369
|
+
let toolsWithCommentary = tools;
|
|
370
|
+
if (!tools?.commentary && options.toolChoice !== 'required')
|
|
371
|
+
toolsWithCommentary = { ...tools, commentary: createHarmonyChannelFallbackTool() };
|
|
370
372
|
const toolNames = Object.keys(toolsWithCommentary || {});
|
|
371
373
|
tag('debug').log(`Tools enabled: [${toolNames.join(', ')}]`);
|
|
372
374
|
promptLog('Available tools:', toolNames);
|
|
@@ -38,7 +38,7 @@ export declare function WithDeepAnalysis<T extends Constructor>(Base: T): {
|
|
|
38
38
|
}>): Promise<void>;
|
|
39
39
|
_executeAndAnalyze(commands: string[], description: string, state: WebPageState, originalAria: string, alreadyExpanded: string[]): Promise<ExpansionOutcome>;
|
|
40
40
|
_restorePageState(url: string, originalAria: string): Promise<void>;
|
|
41
|
-
_analyzeExpandedAction(code: string, description: string, diff: Diff, alreadyExpanded: string[]): Promise<string | null>;
|
|
41
|
+
_analyzeExpandedAction(code: string, description: string, diff: Diff, alreadyExpanded: string[], containerCss?: string | null): Promise<string | null>;
|
|
42
42
|
_deduplicateExpandedSections(sections: string[]): string[];
|
|
43
43
|
_summarizeExpanded(expandedSections: string[]): string[];
|
|
44
44
|
_sectionFingerprint(sectionMarkdown: string): string | null;
|
|
@@ -85,7 +85,7 @@ export function WithDeepAnalysis(Base) {
|
|
|
85
85
|
.filter((s) => s.elements.length > 0)
|
|
86
86
|
.map((s) => s.rawMarkdown));
|
|
87
87
|
tag('substep').log(`Researching overlay: ${region.name}`);
|
|
88
|
-
const sectionMarkdown = await this._analyzeExpandedAction('', region.name, diff, alreadyExpanded);
|
|
88
|
+
const sectionMarkdown = await this._analyzeExpandedAction('', region.name, diff, alreadyExpanded, region.root);
|
|
89
89
|
if (!sectionMarkdown) {
|
|
90
90
|
debugLog(`Overlay "${region.name}" produced no meaningful expansion`);
|
|
91
91
|
return null;
|
|
@@ -367,9 +367,9 @@ export function WithDeepAnalysis(Base) {
|
|
|
367
367
|
}
|
|
368
368
|
await new Promise((r) => setTimeout(r, 500));
|
|
369
369
|
let diff;
|
|
370
|
+
let currAR;
|
|
370
371
|
try {
|
|
371
|
-
await this.explorer.capture();
|
|
372
|
-
const currAR = ActionResult.fromState(this.stateManager.getCurrentState());
|
|
372
|
+
currAR = await this.explorer.capture();
|
|
373
373
|
diff = await currAR.diff(previousState);
|
|
374
374
|
}
|
|
375
375
|
catch (err) {
|
|
@@ -388,7 +388,7 @@ export function WithDeepAnalysis(Base) {
|
|
|
388
388
|
debugLog(`No changes from: ${description.slice(0, 80)}`);
|
|
389
389
|
return { status: 'none', code: clickCode };
|
|
390
390
|
}
|
|
391
|
-
const sectionMarkdown = await this._analyzeExpandedAction(clickCode, description, diff, alreadyExpanded);
|
|
391
|
+
const sectionMarkdown = await this._analyzeExpandedAction(clickCode, description, diff, alreadyExpanded, currAR.overlay.root);
|
|
392
392
|
await this._restorePageState(state.url, originalAria);
|
|
393
393
|
if (!sectionMarkdown)
|
|
394
394
|
return { status: 'none', code: clickCode };
|
|
@@ -413,7 +413,7 @@ export function WithDeepAnalysis(Base) {
|
|
|
413
413
|
tag('warning').log(`navigateTo failed during restore: ${err instanceof Error ? err.message : err}`);
|
|
414
414
|
}
|
|
415
415
|
}
|
|
416
|
-
async _analyzeExpandedAction(code, description, diff, alreadyExpanded) {
|
|
416
|
+
async _analyzeExpandedAction(code, description, diff, alreadyExpanded, containerCss = null) {
|
|
417
417
|
const alreadyHint = alreadyExpanded.length > 0 ? `\nAlready expanded sections:\n${alreadyExpanded.join('\n')}` : '';
|
|
418
418
|
let intro;
|
|
419
419
|
if (code) {
|
|
@@ -473,7 +473,15 @@ export function WithDeepAnalysis(Base) {
|
|
|
473
473
|
const sections = parseResearchSections(text);
|
|
474
474
|
if (sections.length === 0)
|
|
475
475
|
return null;
|
|
476
|
-
|
|
476
|
+
const sectionMarkdown = sections[0].rawMarkdown;
|
|
477
|
+
if (!containerCss)
|
|
478
|
+
return sectionMarkdown;
|
|
479
|
+
let heading = mdq(sectionMarkdown).query('h3[0]');
|
|
480
|
+
if (heading.count() === 0)
|
|
481
|
+
heading = mdq(sectionMarkdown).query('h2[0]');
|
|
482
|
+
if (heading.count() === 0)
|
|
483
|
+
return sectionMarkdown;
|
|
484
|
+
return heading.replace(`${heading.text().trimEnd()}\n\n> Container: '${containerCss}'\n\n`);
|
|
477
485
|
}
|
|
478
486
|
_deduplicateExpandedSections(sections) {
|
|
479
487
|
const seen = new Set();
|
package/dist/src/ai/rules.js
CHANGED
|
@@ -6,8 +6,9 @@ const locatorPriorityRule = dedent `
|
|
|
6
6
|
|
|
7
7
|
1. ARIA locators (first choice) - target browser's accessibility tree, most reliable
|
|
8
8
|
Use JSON format: { "role": "button", "text": "Login" }
|
|
9
|
-
Copy role and text VERBATIM from the ARIA snapshot
|
|
10
|
-
|
|
9
|
+
Copy role and text VERBATIM from the ARIA snapshot, UI map, or the page diff that
|
|
10
|
+
reported the element — never guess the pair; a guessed role can silently match a
|
|
11
|
+
different element with the same text. If named nowhere, use text or CSS instead.
|
|
11
12
|
|
|
12
13
|
2. Text locators (second choice) - exact visible text, use only when unique on the page
|
|
13
14
|
Example: 'Login', 'Submit', 'Username'
|
|
@@ -225,10 +226,10 @@ export const unexpectedPopupRule = dedent `
|
|
|
225
226
|
If buttons are disabled unexpectedly, check if a popup is blocking interaction or if required form fields are empty.
|
|
226
227
|
|
|
227
228
|
Dismiss strategy (try in order):
|
|
228
|
-
1. I.
|
|
229
|
-
2. I.
|
|
230
|
-
3. I.click('
|
|
231
|
-
4. I.
|
|
229
|
+
1. I.pressKey('Escape') — press Escape to dismiss
|
|
230
|
+
2. I.click('Cancel') — click Cancel button if present
|
|
231
|
+
3. I.click({ role: 'button', text: 'Close' }) — click X/close button if present
|
|
232
|
+
4. I.clickXY(0, 0) via form() tool and check if page diff changed
|
|
232
233
|
</unexpected_popup_rule>
|
|
233
234
|
`;
|
|
234
235
|
export const sectionContextRule = dedent `
|
|
@@ -318,7 +319,7 @@ export const actionRule = dedent `
|
|
|
318
319
|
Prefer text/ARIA locators with context over complex CSS/XPath selectors.
|
|
319
320
|
For inline create/edit flows, after filling a field verify it contains the value, then confirm using the nearest explicit button/link, an adjacent icon-only confirm control in the same row/form, or Enter if the field remains focused.
|
|
320
321
|
If locator doesn't work, try CSS or XPath locators.
|
|
321
|
-
If nothing works, use
|
|
322
|
+
If nothing works, use visualClick() — it locates the target in a screenshot before clicking it.
|
|
322
323
|
|
|
323
324
|
When a click result reports several matches, pick one from its numbered list by position rather than guessing a new locator.
|
|
324
325
|
Reuse the same locator with step.opts({ elementIndex: N }) as the LAST argument. N is the "Element N" number.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare function loadScoutCorpus(dirs: string[]): ScoutCorpus;
|
|
2
|
+
export declare function excludeCorpusUrls(corpus: ScoutCorpus, urls: string[]): ScoutCorpus;
|
|
3
|
+
export declare function createScoutTools(corpus: ScoutCorpus): Promise<{
|
|
4
|
+
tools: Record<string, any>;
|
|
5
|
+
scanner: "rg" | "grep";
|
|
6
|
+
getResult: () => string;
|
|
7
|
+
finishFromText: (text?: string) => void;
|
|
8
|
+
}>;
|
|
9
|
+
export interface ScoutCorpus {
|
|
10
|
+
dirs: string[];
|
|
11
|
+
files: ScoutCorpusFile[];
|
|
12
|
+
excludedPaths: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface ScoutCorpusFile {
|
|
15
|
+
path: string;
|
|
16
|
+
url?: string;
|
|
17
|
+
}
|