explorbot 0.4.5 → 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/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/package.json +4 -4
- 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/tools.d.ts +1 -1
- package/dist/src/ai/tools.js +15 -10
- package/dist/src/api/spec-reader.d.ts +1 -0
- package/dist/src/api/spec-reader.js +93 -1
- 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/explorer.d.ts +1 -1
- package/dist/src/explorer.js +1 -1
- package/dist/src/utils/html-diff.js +4 -1
- package/docs/api-testing/basics.md +26 -2
- package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +317 -0
- package/package.json +4 -4
- package/src/ai/researcher/deep-analysis.ts +13 -6
- package/src/ai/tools.ts +15 -11
- package/src/api/spec-reader.ts +106 -1
- package/src/commands/base-command.ts +3 -3
- package/src/commands/init-command.ts +6 -3
- package/src/explorer.ts +1 -1
- package/src/utils/html-diff.ts +3 -1
|
@@ -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
|
+
}
|
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",
|
|
@@ -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/tools.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ExecutedStep } from '../action.js';
|
|
2
1
|
import { ActionResult, type PageDiff } from '../action-result.js';
|
|
2
|
+
import type { ExecutedStep } from '../action.js';
|
|
3
3
|
import { type ExperienceTracker } from '../experience-tracker.js';
|
|
4
4
|
import { type Task } from '../test-plan.js';
|
|
5
5
|
import type { ToolDeps } from './agent.js';
|
package/dist/src/ai/tools.js
CHANGED
|
@@ -5,13 +5,13 @@ import { ActionResult } from "../action-result.js";
|
|
|
5
5
|
import { renderExperienceRecipes } from "../experience-tracker.js";
|
|
6
6
|
import { Stats } from "../stats.js";
|
|
7
7
|
import { TestResult } from '../test-plan.js';
|
|
8
|
+
import { ariaRefSelector, describeRef, refIsGone } from "../utils/aria-ref.js";
|
|
8
9
|
import { LARGE_ARIA_CHANGE_THRESHOLD } from "../utils/aria.js";
|
|
9
10
|
import { isFatalBrowserError } from "../utils/browser-errors.js";
|
|
10
11
|
import { cleanHtmlSnippet } from "../utils/html.js";
|
|
11
12
|
import { createDebug, tag } from '../utils/logger.js';
|
|
12
|
-
import { compactErrorMessage, normalizeInlineText, truncate } from "../utils/strings.js";
|
|
13
13
|
import { pause } from '../utils/loop.js';
|
|
14
|
-
import {
|
|
14
|
+
import { compactErrorMessage, normalizeInlineText, truncate } from "../utils/strings.js";
|
|
15
15
|
import { WebElement } from "../utils/web-element.js";
|
|
16
16
|
import { sectionContextRule } from "./rules.js";
|
|
17
17
|
import { isInteractive } from "./task-agent.js";
|
|
@@ -109,7 +109,13 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
|
|
|
109
109
|
});
|
|
110
110
|
}
|
|
111
111
|
await commitNote(activeNote, TestResult.PASSED, toolResult, action);
|
|
112
|
-
|
|
112
|
+
const data = { ...toolResult, attempts, code: command };
|
|
113
|
+
const notExecuted = commands.slice(i + 1);
|
|
114
|
+
if (notExecuted.length) {
|
|
115
|
+
data.notExecuted = notExecuted;
|
|
116
|
+
data.suggestion = `SKIPPED: ${notExecuted.join('; ')}`;
|
|
117
|
+
}
|
|
118
|
+
return successToolResult('click', data, action);
|
|
113
119
|
}
|
|
114
120
|
}
|
|
115
121
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, commands[0]);
|
|
@@ -1109,7 +1115,7 @@ export async function failedToolResult(action, message, data, error) {
|
|
|
1109
1115
|
const errorTexts = [message, ...(data?.attempts?.map((a) => a.error || '') || [])];
|
|
1110
1116
|
if (errorTexts.some((t) => t.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))) {
|
|
1111
1117
|
const matched = await extractWebElements(error);
|
|
1112
|
-
result.suggestion = getMultipleElementsSuggestion(
|
|
1118
|
+
result.suggestion = getMultipleElementsSuggestion();
|
|
1113
1119
|
result.multipleElementsDetected = true;
|
|
1114
1120
|
result.elements = formatElementList(matched);
|
|
1115
1121
|
return result;
|
|
@@ -1121,16 +1127,12 @@ export async function failedToolResult(action, message, data, error) {
|
|
|
1121
1127
|
}
|
|
1122
1128
|
return result;
|
|
1123
1129
|
}
|
|
1124
|
-
function getMultipleElementsSuggestion(
|
|
1125
|
-
const visible = (matched || []).filter((element) => element.visible !== false);
|
|
1126
|
-
let onlyVisible = '';
|
|
1127
|
-
if (matched && visible.length === 1)
|
|
1128
|
-
onlyVisible = `\nOnly element ${matched.indexOf(visible[0]) + 1} is on screen, so that is the one to act on.`;
|
|
1130
|
+
function getMultipleElementsSuggestion() {
|
|
1129
1131
|
return dedent `
|
|
1130
1132
|
Multiple elements matched your locator, so that command did nothing — it selected no element and acted on none.
|
|
1131
1133
|
Read the numbered elements list and act on the one you meant by its number:
|
|
1132
1134
|
reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
|
|
1133
|
-
A match reported as not visible can never be acted on — pick one that is
|
|
1135
|
+
A match reported as not visible can never be acted on — pick one that is.
|
|
1134
1136
|
If none of them is the element you want, narrow the locator with a container or its full unique text.
|
|
1135
1137
|
If the list is missing, call xpathCheck() to see what the locator matches.
|
|
1136
1138
|
`;
|
|
@@ -1197,6 +1199,9 @@ function formatElementList(matched) {
|
|
|
1197
1199
|
const lines = [`Element ${i + 1}:`, `Text: "${el.text}"`];
|
|
1198
1200
|
if (el.visible !== undefined)
|
|
1199
1201
|
lines.push(`Visible: ${el.visible}`);
|
|
1202
|
+
const wrapped = matched.map((_, j) => j).filter((j) => j !== i && matched[j].xpath.startsWith(`${el.xpath}/`));
|
|
1203
|
+
if (wrapped.length)
|
|
1204
|
+
lines.push(`Wraps: element ${wrapped.map((j) => j + 1).join(', ')}`);
|
|
1200
1205
|
lines.push(`XPath: ${el.xpath}`, `HTML: ${el.html}`);
|
|
1201
1206
|
return lines.join('\n');
|
|
1202
1207
|
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export declare function validateSpecs(specs?: string[]): void;
|
|
2
2
|
export declare function loadSpec(specPaths: string[], outputDir: string): Promise<any>;
|
|
3
3
|
export declare function extractEndpointDefinition(schema: any, endpoint: string, baseEndpoint?: string): string;
|
|
4
|
+
export declare function resolveEndpoints(schema: any, pattern: string, baseEndpoint?: string): string[];
|
|
4
5
|
export declare function searchEndpoints(schema: any, query: string, baseEndpoint?: string): string;
|
|
5
6
|
export declare function listAllEndpoints(schema: any, baseEndpoint?: string): string;
|
|
@@ -42,13 +42,35 @@ export function extractEndpointDefinition(schema, endpoint, baseEndpoint) {
|
|
|
42
42
|
throw new Error('OpenAPI spec has no paths defined');
|
|
43
43
|
}
|
|
44
44
|
const basePath = toBasePath(baseEndpoint);
|
|
45
|
-
const matched =
|
|
45
|
+
const matched = collectEndpointPaths(schema, basePath, endpoint);
|
|
46
46
|
if (!Object.keys(matched).length) {
|
|
47
47
|
const available = listNormalizedPaths(schema, basePath);
|
|
48
48
|
throw new Error(`Endpoint "${endpoint}" not found in spec. Available: ${available}`);
|
|
49
49
|
}
|
|
50
50
|
return safeStringify(matched);
|
|
51
51
|
}
|
|
52
|
+
export function resolveEndpoints(schema, pattern, baseEndpoint) {
|
|
53
|
+
if (!schema?.paths) {
|
|
54
|
+
throw new Error('OpenAPI spec has no paths defined');
|
|
55
|
+
}
|
|
56
|
+
const basePath = toBasePath(baseEndpoint);
|
|
57
|
+
const normalized = Object.keys(schema.paths).map((specPath) => stripBasePath(specPath, basePath));
|
|
58
|
+
const matched = normalized.filter((specPath) => matchesPattern(specPath, pattern));
|
|
59
|
+
if (!matched.length) {
|
|
60
|
+
throw new Error(`Endpoint "${pattern}" not found in spec. Available: ${listNormalizedPaths(schema, basePath)}`);
|
|
61
|
+
}
|
|
62
|
+
const roots = matched.map((specPath) => toCollection(specPath, normalized, pattern));
|
|
63
|
+
const resolved = [...new Set(roots.map((root) => fillParameters(root, pattern)))];
|
|
64
|
+
const endpoints = resolved.filter((specPath) => !specPath.includes('{'));
|
|
65
|
+
if (!endpoints.length) {
|
|
66
|
+
throw new Error(`Endpoint "${pattern}" leaves ${listParameters(resolved)} unresolved. Give the value in the endpoint or in the base endpoint.`);
|
|
67
|
+
}
|
|
68
|
+
const skipped = resolved.filter((specPath) => specPath.includes('{'));
|
|
69
|
+
if (skipped.length) {
|
|
70
|
+
tag('warning').log(`Skipped, no value for their parameters: ${skipped.join(', ')}`);
|
|
71
|
+
}
|
|
72
|
+
return endpoints;
|
|
73
|
+
}
|
|
52
74
|
export function searchEndpoints(schema, query, baseEndpoint) {
|
|
53
75
|
if (!schema?.paths)
|
|
54
76
|
return 'No endpoints available';
|
|
@@ -136,6 +158,76 @@ function stripBasePath(specPath, basePath) {
|
|
|
136
158
|
}
|
|
137
159
|
return `/${specSegments.slice(i).join('/')}`;
|
|
138
160
|
}
|
|
161
|
+
function collectEndpointPaths(schema, basePath, endpoint) {
|
|
162
|
+
const normalized = Object.keys(schema.paths).map((specPath) => stripBasePath(specPath, basePath));
|
|
163
|
+
const roots = resolveEndpoint(normalized, endpoint);
|
|
164
|
+
if (!roots.length)
|
|
165
|
+
return collectMatchingPaths(schema, basePath, (path) => matchesEndpoint(path, endpoint));
|
|
166
|
+
return collectMatchingPaths(schema, basePath, (path) => roots.some((root) => path === root || path.startsWith(`${root}/`)));
|
|
167
|
+
}
|
|
168
|
+
function resolveEndpoint(specPaths, endpoint) {
|
|
169
|
+
const wanted = toSegments(endpoint);
|
|
170
|
+
if (!wanted.length)
|
|
171
|
+
return [];
|
|
172
|
+
const matched = specPaths.filter((specPath) => {
|
|
173
|
+
const segments = toSegments(specPath);
|
|
174
|
+
if (segments.length !== wanted.length)
|
|
175
|
+
return false;
|
|
176
|
+
return segmentsMatch(segments, wanted);
|
|
177
|
+
});
|
|
178
|
+
const literals = matched.map((specPath) => toSegments(specPath).filter((segment, i) => segment === wanted[i]).length);
|
|
179
|
+
const best = Math.max(0, ...literals);
|
|
180
|
+
return matched.filter((_, i) => literals[i] === best);
|
|
181
|
+
}
|
|
182
|
+
function matchesPattern(specPath, pattern) {
|
|
183
|
+
const wanted = toSegments(pattern);
|
|
184
|
+
const segments = toSegments(specPath);
|
|
185
|
+
if (segments.length < wanted.length)
|
|
186
|
+
return false;
|
|
187
|
+
return segmentsMatch(segments, wanted);
|
|
188
|
+
}
|
|
189
|
+
function segmentsMatch(segments, wanted) {
|
|
190
|
+
return wanted.every((want, i) => want === '*' || segments[i] === want || segments[i].startsWith('{'));
|
|
191
|
+
}
|
|
192
|
+
function toCollection(specPath, specPaths, pattern) {
|
|
193
|
+
const segments = toSegments(specPath);
|
|
194
|
+
const filled = toSegments(fillParameters(specPath, pattern));
|
|
195
|
+
let deepest = segments.length;
|
|
196
|
+
const unfilled = filled.findIndex((segment) => segment.startsWith('{'));
|
|
197
|
+
if (unfilled >= 0)
|
|
198
|
+
deepest = unfilled;
|
|
199
|
+
for (let i = Math.max(1, Math.min(toSegments(pattern).length, deepest)); i <= deepest; i++) {
|
|
200
|
+
const prefix = `/${segments.slice(0, i).join('/')}`;
|
|
201
|
+
if (specPaths.includes(prefix))
|
|
202
|
+
return prefix;
|
|
203
|
+
}
|
|
204
|
+
return specPath;
|
|
205
|
+
}
|
|
206
|
+
function fillParameters(specPath, pattern) {
|
|
207
|
+
const wanted = toSegments(pattern);
|
|
208
|
+
const segments = toSegments(specPath);
|
|
209
|
+
for (let i = 0; i < wanted.length && i < segments.length; i++) {
|
|
210
|
+
if (wanted[i] === '*')
|
|
211
|
+
continue;
|
|
212
|
+
if (!segments[i].startsWith('{'))
|
|
213
|
+
continue;
|
|
214
|
+
segments[i] = wanted[i];
|
|
215
|
+
}
|
|
216
|
+
return `/${segments.join('/')}`;
|
|
217
|
+
}
|
|
218
|
+
function listParameters(specPaths) {
|
|
219
|
+
const found = new Set();
|
|
220
|
+
for (const specPath of specPaths) {
|
|
221
|
+
for (const segment of toSegments(specPath)) {
|
|
222
|
+
if (segment.startsWith('{'))
|
|
223
|
+
found.add(segment);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return [...found].join(', ');
|
|
227
|
+
}
|
|
228
|
+
function toSegments(path) {
|
|
229
|
+
return path.split('/').filter(Boolean);
|
|
230
|
+
}
|
|
139
231
|
function matchesEndpoint(specPath, endpoint) {
|
|
140
232
|
if (specPath === endpoint)
|
|
141
233
|
return true;
|
|
@@ -7,15 +7,15 @@ export interface Suggestion {
|
|
|
7
7
|
command?: string;
|
|
8
8
|
hint: string;
|
|
9
9
|
}
|
|
10
|
-
export declare abstract class BaseCommand {
|
|
10
|
+
export declare abstract class BaseCommand<T = ExplorBot> {
|
|
11
11
|
abstract name: string;
|
|
12
12
|
abstract description: string;
|
|
13
13
|
aliases: string[];
|
|
14
14
|
options: CommandOption[];
|
|
15
15
|
tuiEnabled: boolean;
|
|
16
16
|
suggestions: Suggestion[];
|
|
17
|
-
explorBot:
|
|
18
|
-
constructor(explorBot:
|
|
17
|
+
explorBot: T;
|
|
18
|
+
constructor(explorBot: T);
|
|
19
19
|
abstract execute(args: string): Promise<void>;
|
|
20
20
|
matches(commandName: string): boolean;
|
|
21
21
|
printSuggestions(): void;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { type ModelRole } from '../config.js';
|
|
2
|
+
export declare function envTemplate(provider: string): string;
|
|
1
3
|
export declare function runInit(options: InitCommandOptions): Promise<void>;
|
|
2
4
|
export declare function writeGlobalConfig(provider: string, apiKey?: string): void;
|
|
3
5
|
export declare function runInitCommand(options: InitCommandOptions): void;
|
|
6
|
+
export declare function modelLines(provider: string, only?: ModelRole[]): string;
|
|
4
7
|
type InitCommandOptions = {
|
|
5
8
|
configPath?: string;
|
|
6
9
|
force?: boolean;
|
|
@@ -37,7 +37,7 @@ ${modelLines(provider)}
|
|
|
37
37
|
${moduleExport}
|
|
38
38
|
`;
|
|
39
39
|
}
|
|
40
|
-
function envTemplate(provider) {
|
|
40
|
+
export function envTemplate(provider) {
|
|
41
41
|
const keyLines = Object.entries(PROVIDERS).map(([name, { envKey }]) => {
|
|
42
42
|
if (name === provider)
|
|
43
43
|
return `${envKey}=`;
|
|
@@ -219,14 +219,17 @@ async function renderLocalProviderWizard() {
|
|
|
219
219
|
}), { exitOnCtrlC: false, patchConsole: false });
|
|
220
220
|
});
|
|
221
221
|
}
|
|
222
|
-
function modelLines(provider) {
|
|
222
|
+
export function modelLines(provider, only) {
|
|
223
223
|
const recommended = ConfigParser.recommendedModels()[provider] || {};
|
|
224
224
|
const roles = [
|
|
225
225
|
['model', 'fast model with tool calling capabilities'],
|
|
226
226
|
['visionModel', 'vision model for screenshot analysis'],
|
|
227
227
|
['agenticModel', 'agentic model for decision making'],
|
|
228
228
|
];
|
|
229
|
-
|
|
229
|
+
let selected = roles;
|
|
230
|
+
if (only)
|
|
231
|
+
selected = roles.filter(([role]) => only.includes(role));
|
|
232
|
+
return selected.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`).join('\n');
|
|
230
233
|
}
|
|
231
234
|
function globalConfigTemplate(provider) {
|
|
232
235
|
const { envKey } = PROVIDERS[provider];
|
package/dist/src/explorer.d.ts
CHANGED
|
@@ -39,7 +39,7 @@ declare class Explorer {
|
|
|
39
39
|
testPageErrorHandler: ((error: Error) => void) | null;
|
|
40
40
|
testConsoleHandler: ((message: any) => void) | null;
|
|
41
41
|
testDialogHandler: ((dialog: any) => void) | null;
|
|
42
|
-
eventDispatcher:
|
|
42
|
+
eventDispatcher: NodeJS.EventEmitter<[never]>;
|
|
43
43
|
constructor(config: ExplorbotConfig, options: ExplorerOptions | undefined, deps: ExplorerDeps);
|
|
44
44
|
get actor(): CodeceptJS.I;
|
|
45
45
|
get page(): Page | null;
|
package/dist/src/explorer.js
CHANGED
|
@@ -208,7 +208,7 @@ class Explorer {
|
|
|
208
208
|
codeceptjs.container.create(this.convertToCodeceptConfig(this.config), {});
|
|
209
209
|
}
|
|
210
210
|
convertToCodeceptConfig(config) {
|
|
211
|
-
const playwrightConfig = { ...config.playwright };
|
|
211
|
+
const playwrightConfig = { visibleLocator: true, ...config.playwright };
|
|
212
212
|
if (this.options?.show !== undefined) {
|
|
213
213
|
playwrightConfig.show = this.options.show;
|
|
214
214
|
}
|
|
@@ -4,7 +4,7 @@ import { isDynamicId, isGenericClass } from "./xpath.js";
|
|
|
4
4
|
const IGNORED_PATHS = new Set(['html[1]', 'html[1]/head[1]', 'html[1]/body[1]']);
|
|
5
5
|
const SHELL_RATIO = 0.8;
|
|
6
6
|
const ROOT_CONTENT_RATIO = 0.8;
|
|
7
|
-
const LIVE_REGION_ROLES = new Set(['alert', 'alertdialog', 'status', 'log']);
|
|
7
|
+
const LIVE_REGION_ROLES = new Set(['alert', 'alertdialog', 'status', 'log', 'tooltip']);
|
|
8
8
|
const TEXT_LINE_PREFIX = 'TEXT:';
|
|
9
9
|
const MESSAGE_MAX_LENGTH = 200;
|
|
10
10
|
const MESSAGE_LIMIT = 8;
|
|
@@ -447,6 +447,9 @@ function semanticSelectorFor(element, allElements) {
|
|
|
447
447
|
const selector = buildContainerSelector(current, allElements);
|
|
448
448
|
if (selector)
|
|
449
449
|
return selector;
|
|
450
|
+
const classAttr = (current.attrs ?? []).find((a) => a.name === 'class')?.value;
|
|
451
|
+
if (classAttr && filterContainerClasses(classAttr.split(/\s+/).filter(Boolean)).length > 0)
|
|
452
|
+
return undefined;
|
|
450
453
|
current = dominantChild(current);
|
|
451
454
|
}
|
|
452
455
|
return undefined;
|
|
@@ -66,7 +66,7 @@ npx explorbot api explore https://api.example.com/v1 \
|
|
|
66
66
|
-H "Authorization: Bearer $TOKEN"
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
`api explore` takes
|
|
69
|
+
`api explore` takes an endpoint or a pattern as its argument, so one line covers the whole run: it plans, executes each plan, and reports the totals. Passing the base endpoint covers every collection the spec describes. The other commands take a path within the API and read the base from `--endpoint`:
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
72
|
npx explorbot api plan /users \
|
|
@@ -81,7 +81,7 @@ The base endpoint keeps its path prefix: given `https://api.example.com/v1`, ste
|
|
|
81
81
|
|
|
82
82
|
### A dedicated API project
|
|
83
83
|
|
|
84
|
-
If you don't have a web `explorbot.config.js`, run `npx explorbot api init`. It asks for your base endpoint, spec, and a one-line description of the API, then writes a standalone `apibot.config.
|
|
84
|
+
If you don't have a web `explorbot.config.js`, run `npx explorbot api init`. It asks for your base endpoint, spec, and a one-line description of the API, then writes a standalone `apibot.config.js` (with an `ai` and `api` section) plus `output/` and `knowledge/` directories. When both files exist, `apibot.config.*` takes precedence over `explorbot.config.*`.
|
|
85
85
|
|
|
86
86
|
## Your first run
|
|
87
87
|
|
|
@@ -99,6 +99,30 @@ npx explorbot api test output/plans/users.md
|
|
|
99
99
|
|
|
100
100
|
Curler runs the scenarios and prints how many passed and failed.
|
|
101
101
|
|
|
102
|
+
### Covering many endpoints
|
|
103
|
+
|
|
104
|
+
`api explore` runs the whole loop for you: plan, test, re-plan. Given one endpoint it plans in every style.
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
npx explorbot api explore /users
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The endpoint may be a pattern. `*` stands for one path segment, and a pattern also covers the paths below it, so `/users` and `/users/*` both cover `/users/{id}`. Quote it, or your shell will try to expand it first.
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
npx explorbot api explore '/projects/acme/*'
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Explorbot explores collections, not raw paths: `/users/{id}` and `/users/{id}/posts` fold into `/users`, whose spec lookup brings them along anyway. When a pattern matches several collections the planning styles spread across them, one style per collection, so covering a whole API stays one plan per collection rather than one per style.
|
|
117
|
+
|
|
118
|
+
Pass `/` to take every collection in the spec. The path parameters have to come from somewhere, so put them in the base endpoint.
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
npx explorbot api explore / --endpoint https://api.example.com/v2/acme
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
If a parameter is left with no value the run stops and names it, rather than sending requests to a literal `{project_id}`. Collections whose own parameters no pattern can fill, like `/analytics/stats/{kind}`, are listed and skipped.
|
|
125
|
+
|
|
102
126
|
## Output files
|
|
103
127
|
|
|
104
128
|
| Output | Location | What it is |
|