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.
Files changed (39) hide show
  1. package/boat/api-tester/src/apibot.ts +18 -2
  2. package/boat/api-tester/src/cli.ts +85 -274
  3. package/boat/api-tester/src/commands/api-command.ts +10 -0
  4. package/boat/api-tester/src/commands/explore-command.ts +52 -0
  5. package/boat/api-tester/src/commands/init-command.ts +119 -0
  6. package/boat/api-tester/src/commands/know-command.ts +44 -0
  7. package/boat/api-tester/src/commands/plan-command.ts +42 -0
  8. package/boat/api-tester/src/commands/test-command.ts +54 -0
  9. package/dist/boat/api-tester/src/apibot.js +14 -1
  10. package/dist/boat/api-tester/src/cli.js +87 -243
  11. package/dist/boat/api-tester/src/commands/api-command.js +7 -0
  12. package/dist/boat/api-tester/src/commands/explore-command.js +41 -0
  13. package/dist/boat/api-tester/src/commands/init-command.js +88 -0
  14. package/dist/boat/api-tester/src/commands/know-command.js +39 -0
  15. package/dist/boat/api-tester/src/commands/plan-command.js +37 -0
  16. package/dist/boat/api-tester/src/commands/test-command.js +45 -0
  17. package/dist/package.json +4 -4
  18. package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
  19. package/dist/src/ai/researcher/deep-analysis.js +14 -6
  20. package/dist/src/ai/tools.d.ts +1 -1
  21. package/dist/src/ai/tools.js +15 -10
  22. package/dist/src/api/spec-reader.d.ts +1 -0
  23. package/dist/src/api/spec-reader.js +93 -1
  24. package/dist/src/commands/base-command.d.ts +3 -3
  25. package/dist/src/commands/init-command.d.ts +3 -0
  26. package/dist/src/commands/init-command.js +6 -3
  27. package/dist/src/explorer.d.ts +1 -1
  28. package/dist/src/explorer.js +1 -1
  29. package/dist/src/utils/html-diff.js +4 -1
  30. package/docs/api-testing/basics.md +26 -2
  31. package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +317 -0
  32. package/package.json +4 -4
  33. package/src/ai/researcher/deep-analysis.ts +13 -6
  34. package/src/ai/tools.ts +15 -11
  35. package/src/api/spec-reader.ts +106 -1
  36. package/src/commands/base-command.ts +3 -3
  37. package/src/commands/init-command.ts +6 -3
  38. package/src/explorer.ts +1 -1
  39. 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.5",
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.0.0-rc.16",
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.62",
127
- "playwright-core": "^1.62",
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
- return sections[0].rawMarkdown;
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();
@@ -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';
@@ -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 { ariaRefSelector, describeRef, refIsGone } from "../utils/aria-ref.js";
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
- return successToolResult('click', { ...toolResult, attempts, code: command }, action);
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(matched);
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(matched) {
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.${onlyVisible}
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 = collectMatchingPaths(schema, basePath, (normalized) => matchesEndpoint(normalized, endpoint));
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: ExplorBot;
18
- constructor(explorBot: 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
- return roles.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`).join('\n');
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];
@@ -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: any;
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;
@@ -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 the base endpoint as its argument, so one line covers the whole run: it plans in every style, executes each plan, and reports the totals. The other commands take a path within the API and read the base from `--endpoint`:
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.ts` (with an `ai` and `api` section) plus `output/` and `knowledge/` directories. When both files exist, `apibot.config.*` takes precedence over `explorbot.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 |