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.
Files changed (86) 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/boat/prima/src/prima.ts +8 -3
  10. package/dist/boat/api-tester/src/apibot.js +14 -1
  11. package/dist/boat/api-tester/src/cli.js +87 -243
  12. package/dist/boat/api-tester/src/commands/api-command.js +7 -0
  13. package/dist/boat/api-tester/src/commands/explore-command.js +41 -0
  14. package/dist/boat/api-tester/src/commands/init-command.js +88 -0
  15. package/dist/boat/api-tester/src/commands/know-command.js +39 -0
  16. package/dist/boat/api-tester/src/commands/plan-command.js +37 -0
  17. package/dist/boat/api-tester/src/commands/test-command.js +45 -0
  18. package/dist/boat/prima/src/prima.js +10 -3
  19. package/dist/package.json +4 -4
  20. package/dist/src/ai/fisherman/tools.js +7 -1
  21. package/dist/src/ai/fisherman.js +2 -1
  22. package/dist/src/ai/pilot.d.ts +0 -1
  23. package/dist/src/ai/pilot.js +8 -24
  24. package/dist/src/ai/planner.d.ts +4 -0
  25. package/dist/src/ai/planner.js +28 -0
  26. package/dist/src/ai/provider.js +3 -1
  27. package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
  28. package/dist/src/ai/researcher/deep-analysis.js +14 -6
  29. package/dist/src/ai/rules.js +8 -7
  30. package/dist/src/ai/scout/tools.d.ts +17 -0
  31. package/dist/src/ai/scout/tools.js +130 -0
  32. package/dist/src/ai/scout.d.ts +21 -0
  33. package/dist/src/ai/scout.js +150 -0
  34. package/dist/src/ai/tools.d.ts +1 -1
  35. package/dist/src/ai/tools.js +62 -31
  36. package/dist/src/api/spec-reader.d.ts +1 -0
  37. package/dist/src/api/spec-reader.js +93 -1
  38. package/dist/src/application-spec.d.ts +3 -0
  39. package/dist/src/application-spec.js +21 -5
  40. package/dist/src/commands/base-command.d.ts +3 -3
  41. package/dist/src/commands/init-command.d.ts +3 -0
  42. package/dist/src/commands/init-command.js +6 -3
  43. package/dist/src/config.d.ts +6 -1
  44. package/dist/src/explorbot.d.ts +3 -0
  45. package/dist/src/explorbot.js +33 -0
  46. package/dist/src/explorer.d.ts +1 -1
  47. package/dist/src/explorer.js +1 -1
  48. package/dist/src/knowledge-tracker.d.ts +1 -0
  49. package/dist/src/knowledge-tracker.js +3 -0
  50. package/dist/src/utils/aria-ref.d.ts +16 -0
  51. package/dist/src/utils/aria-ref.js +47 -0
  52. package/dist/src/utils/aria.js +3 -3
  53. package/dist/src/utils/html-diff.js +4 -1
  54. package/dist/src/utils/web-annotate.js +3 -15
  55. package/dist/src/utils/web-element.d.ts +0 -2
  56. package/dist/src/utils/web-element.js +0 -8
  57. package/docs/api-testing/basics.md +26 -2
  58. package/docs/reference/configuration.md +28 -1
  59. package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +317 -0
  60. package/docs/web-testing/agents.md +9 -1
  61. package/docs/web-testing/planner.md +5 -0
  62. package/docs/workflow/application-spec.md +4 -0
  63. package/package.json +4 -4
  64. package/src/ai/fisherman/tools.ts +8 -1
  65. package/src/ai/fisherman.ts +2 -1
  66. package/src/ai/pilot.ts +8 -25
  67. package/src/ai/planner.ts +33 -0
  68. package/src/ai/provider.ts +2 -1
  69. package/src/ai/researcher/deep-analysis.ts +13 -6
  70. package/src/ai/rules.ts +8 -7
  71. package/src/ai/scout/tools.ts +150 -0
  72. package/src/ai/scout.ts +173 -0
  73. package/src/ai/tools.ts +75 -38
  74. package/src/api/spec-reader.ts +106 -1
  75. package/src/application-spec.ts +22 -4
  76. package/src/commands/base-command.ts +3 -3
  77. package/src/commands/init-command.ts +6 -3
  78. package/src/config.ts +7 -0
  79. package/src/explorbot.ts +36 -0
  80. package/src/explorer.ts +1 -1
  81. package/src/knowledge-tracker.ts +4 -0
  82. package/src/utils/aria-ref.ts +61 -0
  83. package/src/utils/aria.ts +3 -3
  84. package/src/utils/html-diff.ts +3 -1
  85. package/src/utils/web-annotate.ts +3 -15
  86. package/src/utils/web-element.ts +0 -9
@@ -0,0 +1,130 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { tool } from 'ai';
4
+ import { createBashTool } from 'bash-tool';
5
+ import dedent from 'dedent';
6
+ import { z } from 'zod';
7
+ import { ConfigParser } from "../../config.js";
8
+ import { tag } from "../../utils/logger.js";
9
+ import { loadMarkdownFiles } from "../../utils/markdown-files.js";
10
+ import { readCaptainFile } from "../captain/file-tools.js";
11
+ const MAX_FILES = 500;
12
+ const MAX_FINDINGS = 6000;
13
+ let cachedScanner = null;
14
+ export function loadScoutCorpus(dirs) {
15
+ const files = [];
16
+ for (const dir of dirs) {
17
+ if (files.length >= MAX_FILES) {
18
+ tag('warning').log(`Scout corpus capped at ${MAX_FILES} files — remaining directories skipped`);
19
+ break;
20
+ }
21
+ for (const file of loadMarkdownFiles(dir, { recursive: true })) {
22
+ if (files.length >= MAX_FILES)
23
+ break;
24
+ const entry = { path: file.filePath };
25
+ if (typeof file.data.url === 'string')
26
+ entry.url = file.data.url;
27
+ files.push(entry);
28
+ }
29
+ }
30
+ return { dirs, files, excludedPaths: [] };
31
+ }
32
+ export function excludeCorpusUrls(corpus, urls) {
33
+ if (urls.length === 0)
34
+ return corpus;
35
+ const excludedUrls = new Set(urls);
36
+ const files = [];
37
+ const excludedPaths = [...corpus.excludedPaths];
38
+ for (const file of corpus.files) {
39
+ if (file.url && excludedUrls.has(file.url)) {
40
+ excludedPaths.push(file.path);
41
+ continue;
42
+ }
43
+ files.push(file);
44
+ }
45
+ return { dirs: corpus.dirs, files, excludedPaths };
46
+ }
47
+ export async function createScoutTools(corpus) {
48
+ const scanner = await detectScanner();
49
+ const projectRoot = ConfigParser.getInstance().getProjectRoot();
50
+ let result = '';
51
+ let searchedOrRead = false;
52
+ const getResult = () => result;
53
+ const finishFromText = (text) => {
54
+ if (text && searchedOrRead)
55
+ result = text.slice(0, MAX_FINDINGS);
56
+ };
57
+ const files = {};
58
+ const readableFiles = new Set();
59
+ for (const file of corpus.files) {
60
+ files[toPosix(file.path)] = readFileSync(file.path, 'utf8');
61
+ readableFiles.add(resolve(file.path));
62
+ }
63
+ const toolkit = await createBashTool({
64
+ destination: '/',
65
+ files,
66
+ maxOutputLength: 20000,
67
+ });
68
+ const bashExecute = toolkit.bash.execute;
69
+ const bash = {
70
+ ...toolkit.bash,
71
+ execute: async (input) => {
72
+ tag('step').log(`Scout: bash ${input.command}`);
73
+ searchedOrRead = true;
74
+ return bashExecute?.(input);
75
+ },
76
+ };
77
+ const tools = {
78
+ bash,
79
+ readFile: tool({
80
+ description: dedent `
81
+ Read one documentation file from the corpus.
82
+ Pass the exact path returned by a search result.
83
+ `,
84
+ inputSchema: z.object({
85
+ path: z.string().describe('File path from a search result'),
86
+ startLine: z.number().optional().describe('First line to read, 1-based. Negative values count from the end of the file'),
87
+ endLine: z.number().optional().describe('Last line to read, 1-based and inclusive. Negative values count from the end of the file'),
88
+ maxChars: z.number().optional().describe('Maximum characters to return, default 12000'),
89
+ }),
90
+ execute: async (input) => {
91
+ tag('step').log(`Scout: read ${input.path}`);
92
+ const output = readCaptainFile(projectRoot, input, corpus.dirs);
93
+ if (!output.success)
94
+ return output;
95
+ const resolvedPath = resolve(projectRoot || process.cwd(), output.path);
96
+ if (!readableFiles.has(resolvedPath)) {
97
+ return { success: false, message: 'File is outside the Scout corpus' };
98
+ }
99
+ searchedOrRead = true;
100
+ return output;
101
+ },
102
+ }),
103
+ };
104
+ return { tools, scanner, getResult, finishFromText };
105
+ }
106
+ async function detectScanner() {
107
+ if (cachedScanner)
108
+ return cachedScanner;
109
+ if (await binaryRuns('rg')) {
110
+ cachedScanner = 'rg';
111
+ return cachedScanner;
112
+ }
113
+ if (await binaryRuns('grep')) {
114
+ cachedScanner = 'grep';
115
+ return cachedScanner;
116
+ }
117
+ throw new Error('Scout requires ripgrep or grep on PATH — neither was found');
118
+ }
119
+ async function binaryRuns(binary) {
120
+ try {
121
+ const proc = Bun.spawn([binary, '--version'], { stdout: 'ignore', stderr: 'ignore', stdin: 'ignore' });
122
+ return (await proc.exited) === 0;
123
+ }
124
+ catch {
125
+ return false;
126
+ }
127
+ }
128
+ function toPosix(path) {
129
+ return path.split('\\').join('/');
130
+ }
@@ -0,0 +1,21 @@
1
+ import type { Agent } from './agent.js';
2
+ import type { Provider } from './provider.js';
3
+ import { type ScoutCorpus } from './scout/tools.js';
4
+ export declare class Scout implements Agent {
5
+ provider: Provider;
6
+ corpus: ScoutCorpus;
7
+ emoji: string;
8
+ cache: Map<string, string>;
9
+ constructor(provider: Provider, corpus: ScoutCorpus);
10
+ isAvailable(): boolean;
11
+ collectDocs(query: ScoutQuery): Promise<string>;
12
+ runSession(corpus: ScoutCorpus, query: ScoutQuery): Promise<string | null>;
13
+ buildSystemPrompt(toolNames: string[], corpus: ScoutCorpus, query: ScoutQuery, scanner: 'rg' | 'grep'): string;
14
+ buildTaskPrompt(query: ScoutQuery): string;
15
+ }
16
+ export interface ScoutQuery {
17
+ url?: string;
18
+ title?: string;
19
+ feature?: string;
20
+ excludeUrls: string[];
21
+ }
@@ -0,0 +1,150 @@
1
+ import dedent from 'dedent';
2
+ import { tag } from "../utils/logger.js";
3
+ import { loop } from "../utils/loop.js";
4
+ import { createScoutTools, excludeCorpusUrls } from "./scout/tools.js";
5
+ const MAX_ITERATIONS = 3;
6
+ const MAX_TOOL_ROUNDTRIPS = 5;
7
+ const CACHE_LIMIT = 40;
8
+ const URL_LISTING_LIMIT = 40;
9
+ export class Scout {
10
+ provider;
11
+ corpus;
12
+ emoji = '🔎';
13
+ cache = new Map();
14
+ constructor(provider, corpus) {
15
+ this.provider = provider;
16
+ this.corpus = corpus;
17
+ }
18
+ isAvailable() {
19
+ return this.corpus.files.length > 0;
20
+ }
21
+ async collectDocs(query) {
22
+ if (!this.isAvailable())
23
+ return '';
24
+ const cacheKey = `${query.url || ''}|${query.feature || ''}|${query.excludeUrls.join(',')}`;
25
+ const cached = this.cache.get(cacheKey);
26
+ if (cached !== undefined)
27
+ return cached;
28
+ const corpus = excludeCorpusUrls(this.corpus, query.excludeUrls);
29
+ if (corpus.files.length === 0)
30
+ return '';
31
+ const result = await this.runSession(corpus, query);
32
+ if (result === null)
33
+ return '';
34
+ if (this.cache.size > CACHE_LIMIT)
35
+ this.cache.clear();
36
+ this.cache.set(cacheKey, result);
37
+ return result;
38
+ }
39
+ async runSession(corpus, query) {
40
+ const { tools, scanner, getResult, finishFromText } = await createScoutTools(corpus);
41
+ const conversation = this.provider.startConversation(this.buildSystemPrompt(Object.keys(tools), corpus, query, scanner), 'scout', this.provider.getAgenticModel('scout'));
42
+ conversation.addUserText(this.buildTaskPrompt(query));
43
+ tag('info').log(`Scout: collecting documentation for ${query.feature || query.url || 'the current page'}`);
44
+ let failed = false;
45
+ await loop(async ({ stop, iteration }) => {
46
+ const invokeResult = await this.provider.invokeConversation(conversation, tools, {
47
+ maxToolRoundtrips: MAX_TOOL_ROUNDTRIPS,
48
+ agentName: 'scout',
49
+ });
50
+ if (!invokeResult?.toolExecutions?.length) {
51
+ finishFromText(invokeResult?.response?.text);
52
+ stop();
53
+ return;
54
+ }
55
+ if (iteration >= MAX_ITERATIONS) {
56
+ const final = await this.provider.invokeConversation(conversation, undefined, { agentName: 'scout' });
57
+ finishFromText(final?.response?.text);
58
+ stop();
59
+ }
60
+ }, {
61
+ maxAttempts: MAX_ITERATIONS,
62
+ observability: { name: `scout: ${query.feature || query.url || 'docs'}`, agent: 'scout' },
63
+ catch: async ({ error, stop }) => {
64
+ failed = true;
65
+ tag('warning').log(`Scout error: ${error.message}`);
66
+ stop();
67
+ },
68
+ });
69
+ if (failed)
70
+ return null;
71
+ const digest = getResult();
72
+ if (digest) {
73
+ const preview = digest.slice(0, 600);
74
+ const ellipsis = digest.length > 600 ? '…' : '';
75
+ tag('info').log(`Scout digest:\n${preview}${ellipsis}`);
76
+ }
77
+ return digest;
78
+ }
79
+ buildSystemPrompt(toolNames, corpus, query, scanner) {
80
+ const urls = corpus.files.map((file) => file.url).filter(Boolean);
81
+ const urlless = corpus.files.filter((file) => !file.url);
82
+ let pagesListing = '';
83
+ if (urls.length > 0) {
84
+ const listing = urls
85
+ .slice(0, URL_LISTING_LIMIT)
86
+ .map((url) => `- ${url}`)
87
+ .join('\n');
88
+ pagesListing = `Documented pages:\n${listing}`;
89
+ const remaining = urls.length - URL_LISTING_LIMIT;
90
+ if (remaining > 0)
91
+ pagesListing += `\n…and ${remaining} more — find them with ${scanner}`;
92
+ }
93
+ if (urlless.length > 0) {
94
+ const listing = urlless
95
+ .slice(0, URL_LISTING_LIMIT)
96
+ .map((file) => `- ${toPosix(file.path)}`)
97
+ .join('\n');
98
+ pagesListing += `\nFiles with no page URL (hand-written docs):\n${listing}`;
99
+ }
100
+ const prompt = dedent `
101
+ You are Scout — a documentation retrieval agent. You find collected documentation relevant to a testing focus and report it for test planning.
102
+
103
+ You never see the application itself. The documentation corpus is your only source of truth.
104
+
105
+ CORPUS:
106
+ ${corpus.files.length} markdown files under:
107
+ - ${corpus.dirs.map(toPosix).join('\n- ')}
108
+ ${pagesListing}
109
+
110
+ These pages are already provided to the planner in full — do not re-report them:
111
+ ${query.excludeUrls.map((url) => `- ${url}`).join('\n') || '- none'}
112
+
113
+ AVAILABLE TOOLS:
114
+ ${toolNames.join(', ')}.
115
+ Use tool names exactly as listed. Do not invent aliases or combined names.
116
+ Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
117
+
118
+ SCANNER:
119
+ ${scanner} is the search command. Scan the working directory through bash() — explore freely, pipelines, globs and repeated searches are fine. Read files with readFile().
120
+
121
+ WORKFLOW:
122
+ 1. Scan with ${scanner} using plain prose words from the focus — feature names, page purposes, capabilities
123
+ 2. Read the files whose hits look most relevant
124
+ 3. Report the digest as your final message — no tool call is needed to finish
125
+
126
+ RULES:
127
+ - Report only what the documentation states. Never fill gaps with assumptions about the application
128
+ - Keep verified capabilities and unverified possibilities distinguishable, the way the documentation marks them
129
+ - Name the page URL each item belongs to, so scenarios anchor to real routes
130
+ - Explore briefly: a few scans and reads are enough, then report
131
+ - A short accurate digest beats a long loose one; reporting that nothing relevant exists is a valid answer
132
+ `;
133
+ const customPrompt = this.provider.getSystemPromptForAgent('scout');
134
+ if (customPrompt)
135
+ return `${prompt}\n\n${customPrompt}`;
136
+ return prompt;
137
+ }
138
+ buildTaskPrompt(query) {
139
+ return dedent `
140
+ Page URL: ${query.url || 'Unknown'}
141
+ Page title: ${query.title || 'Unknown'}
142
+ Focus: ${query.feature || 'the page as a whole'}
143
+
144
+ Report the documented capabilities, states and transitions a test planner could turn into scenarios.
145
+ `;
146
+ }
147
+ }
148
+ function toPosix(path) {
149
+ return path.split('\\').join('/');
150
+ }
@@ -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,12 +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 { compactErrorMessage, normalizeInlineText, truncate } from "../utils/strings.js";
14
15
  import { WebElement } from "../utils/web-element.js";
15
16
  import { sectionContextRule } from "./rules.js";
16
17
  import { isInteractive } from "./task-agent.js";
@@ -52,7 +53,6 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
52
53
  2. I.click(ARIA, container) - e.g. I.click({"role":"button","text":"Save"}, ".modal")
53
54
  3. I.click(CSS, container) - e.g. I.click("#btn", ".modal")
54
55
  4. I.click(CSS) or I.click(XPath) - when locator already includes context (ID, XPath)
55
- 5. I.clickXY(x, y) - coordinates fallback
56
56
  After a result reporting multiple matches, reuse that locator with step.opts({ elementIndex: N }) as the last argument.
57
57
  `),
58
58
  explanation: z.string().describe('Why you are clicking this element'),
@@ -63,10 +63,18 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
63
63
  activeNote.commit(TestResult.FAILED);
64
64
  return failedToolResult('click', 'No commands provided');
65
65
  }
66
- const invalidCommands = rawCommands.map((cmd) => cmd.trim()).filter((cmd) => cmd.startsWith('I.') && !cmd.startsWith('I.click'));
66
+ const trimmedCommands = rawCommands.map((cmd) => cmd.trim());
67
+ const coordinateCommands = trimmedCommands.filter((cmd) => cmd.startsWith('I.clickXY'));
68
+ if (coordinateCommands.length > 0) {
69
+ activeNote.commit(TestResult.FAILED);
70
+ return failedToolResult('click', `Coordinate commands are not locators: ${coordinateCommands.join(', ')}. A coordinate click always runs, so it cannot tell you whether the element was there.`, {
71
+ suggestion: 'Name the element instead. Use visualClick() when you can see the target but cannot address it, or form() for a deliberate coordinate click such as dismissing a layer.',
72
+ });
73
+ }
74
+ const invalidCommands = trimmedCommands.filter((cmd) => cmd.startsWith('I.') && !cmd.startsWith('I.click'));
67
75
  if (invalidCommands.length > 0) {
68
76
  activeNote.commit(TestResult.FAILED);
69
- return failedToolResult('click', `Invalid commands: ${invalidCommands.join(', ')}. Click tool only accepts I.click() or I.clickXY() commands.`, {
77
+ return failedToolResult('click', `Invalid commands: ${invalidCommands.join(', ')}. Click tool only accepts I.click() commands.`, {
70
78
  suggestion: 'Use form() tool for typing text or multiple actions, or exitIframe() to leave iframe context.',
71
79
  });
72
80
  }
@@ -91,8 +99,23 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
91
99
  ambiguityError = action.lastError;
92
100
  if (success) {
93
101
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, command);
102
+ if (!hasObservablePageChange(toolResult)) {
103
+ activeNote.commit(TestResult.FAILED);
104
+ return failedToolResult('click', 'Click executed, but no observable page change was captured.', {
105
+ ...toolResult,
106
+ attempts,
107
+ code: command,
108
+ suggestion: 'Treat the element as not clicked. It may be covered by another layer, disabled, or the locator may have matched a non-interactive ancestor. Re-locate via xpathCheck(), which reports whether the element is covered or offscreen, before retrying.',
109
+ });
110
+ }
94
111
  await commitNote(activeNote, TestResult.PASSED, toolResult, action);
95
- 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);
96
119
  }
97
120
  }
98
121
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, commands[0]);
@@ -385,10 +408,16 @@ export function createRefTools({ explorer, stateManager }, task) {
385
408
  }),
386
409
  execute: async ({ ref, element }) => {
387
410
  const activeNote = task.startNote(`Click ${element}`);
411
+ if (await refIsGone(explorer, ref)) {
412
+ activeNote.commit(TestResult.FAILED);
413
+ return failedToolResult('clickRef', `Ref ${ref} names no element on the page any more.`, {
414
+ suggestion: 'The page has been rebuilt since you were given that ref. Call context() and use the ref it gives, or fall back to click() with a locator.',
415
+ });
416
+ }
388
417
  const previousState = ActionResult.fromState(stateManager.getCurrentState());
389
418
  const action = explorer.action();
390
419
  const named = await describeRef(explorer, ref);
391
- const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(`aria-ref=${ref}`)}).click())`;
420
+ const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(ariaRefSelector(ref))}).click())`;
392
421
  if (!(await action.attempt(run, `Click ${element}`))) {
393
422
  activeNote.commit(TestResult.FAILED);
394
423
  return failedToolResult('clickRef', `Ref ${ref} could not be clicked: ${errorText(action.lastError)}`, {
@@ -1015,17 +1044,6 @@ export async function commitNote(activeNote, result, toolResult, action) {
1015
1044
  }
1016
1045
  activeNote.commit(result);
1017
1046
  }
1018
- async function describeRef(explorer, ref) {
1019
- return Promise.resolve(explorer?.withPage?.((page) => page.locator(`aria-ref=${ref}`).evaluate((el) => {
1020
- const tag = el.tagName.toLowerCase();
1021
- const roles = { a: 'link', button: 'button', select: 'combobox', textarea: 'textbox' };
1022
- const role = el.getAttribute('role') || roles[tag] || tag;
1023
- const text = (el.getAttribute('aria-label') || el.innerText || el.value || '').trim().split('\n')[0];
1024
- if (!text)
1025
- return null;
1026
- return { role, text };
1027
- }))).catch(() => null);
1028
- }
1029
1047
  async function hasFocusedElement(explorer) {
1030
1048
  return explorer.withPage((page) => page.evaluate(() => !!document.activeElement && document.activeElement !== document.body)).catch(() => true);
1031
1049
  }
@@ -1085,6 +1103,8 @@ function hasObservablePageChange(data) {
1085
1103
  return true;
1086
1104
  if (data.pageDiff.messages?.length)
1087
1105
  return true;
1106
+ if (data.pageDiff.requests?.length)
1107
+ return true;
1088
1108
  return Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
1089
1109
  }
1090
1110
  export async function failedToolResult(action, message, data, error) {
@@ -1093,12 +1113,11 @@ export async function failedToolResult(action, message, data, error) {
1093
1113
  result.suggestion = data.suggestion ? `${data.suggestion} ${PAGE_DIFF_SUGGESTION}` : PAGE_DIFF_SUGGESTION;
1094
1114
  }
1095
1115
  const errorTexts = [message, ...(data?.attempts?.map((a) => a.error || '') || [])];
1096
- const hasMultipleElements = errorTexts.some((t) => t.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN));
1097
- const multipleElementsSuggestion = hasMultipleElements ? getMultipleElementsSuggestion() : null;
1098
- if (multipleElementsSuggestion) {
1099
- result.suggestion = multipleElementsSuggestion;
1116
+ if (errorTexts.some((t) => t.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))) {
1117
+ const matched = await extractWebElements(error);
1118
+ result.suggestion = getMultipleElementsSuggestion();
1100
1119
  result.multipleElementsDetected = true;
1101
- result.elements = await formatMatchedElements(error);
1120
+ result.elements = formatElementList(matched);
1102
1121
  return result;
1103
1122
  }
1104
1123
  const notFoundSuggestion = getNotFoundSuggestion(message);
@@ -1111,8 +1130,9 @@ export async function failedToolResult(action, message, data, error) {
1111
1130
  function getMultipleElementsSuggestion() {
1112
1131
  return dedent `
1113
1132
  Multiple elements matched your locator, so that command did nothing — it selected no element and acted on none.
1114
- Read the numbered elements list and click the one you meant by its number:
1133
+ Read the numbered elements list and act on the one you meant by its number:
1115
1134
  reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
1135
+ A match reported as not visible can never be acted on — pick one that is.
1116
1136
  If none of them is the element you want, narrow the locator with a container or its full unique text.
1117
1137
  If the list is missing, call xpathCheck() to see what the locator matches.
1118
1138
  `;
@@ -1132,7 +1152,7 @@ export function clickFailureSuggestion(attempts) {
1132
1152
  return 'Element exists but another element covers it. Close the overlapping panel or dialog, then retry.';
1133
1153
  }
1134
1154
  if (errors.some((e) => e.includes('is not visible'))) {
1135
- return 'Element is in the DOM but not visible. Reveal it first — scroll to it, expand its section, or open the panel holding it.';
1155
+ return 'Element is in the DOM but not visible. Reveal it — scroll to it, expand its section, open the panel holding it — or, when the page carries several copies of the same control, target the one that is on screen.';
1136
1156
  }
1137
1157
  if (errors.some((e) => e.includes('SyntaxError'))) {
1138
1158
  return 'The command string never parsed as JavaScript — quotes or brackets do not match. No element was looked up, so this tells you nothing about the page. Re-emit the same intent as valid CodeceptJS.';
@@ -1162,7 +1182,8 @@ async function extractWebElements(error) {
1162
1182
  const xpath = await elements[i].toAbsoluteXPath();
1163
1183
  const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
1164
1184
  const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
1165
- result.push({ xpath, html, text });
1185
+ const visible = await Promise.resolve(elements[i].isVisible?.()).catch(() => undefined);
1186
+ result.push({ xpath, html, text, visible });
1166
1187
  }
1167
1188
  catch (e) {
1168
1189
  debugLog('Failed to get details for element %d: %s', i, e);
@@ -1170,14 +1191,24 @@ async function extractWebElements(error) {
1170
1191
  }
1171
1192
  return result.length > 0 ? result : null;
1172
1193
  }
1173
- function formatElementList(details) {
1174
- return details.map((el, i) => `Element ${i + 1}:\nText: "${el.text}"\nXPath: ${el.xpath}\nHTML: ${el.html}`).join('\n\n');
1194
+ function formatElementList(matched) {
1195
+ if (!matched)
1196
+ return 'Could not fetch element details. Repeat the action to get better info.';
1197
+ return matched
1198
+ .map((el, i) => {
1199
+ const lines = [`Element ${i + 1}:`, `Text: "${el.text}"`];
1200
+ if (el.visible !== undefined)
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(', ')}`);
1205
+ lines.push(`XPath: ${el.xpath}`, `HTML: ${el.html}`);
1206
+ return lines.join('\n');
1207
+ })
1208
+ .join('\n\n');
1175
1209
  }
1176
1210
  export async function formatMatchedElements(error) {
1177
- const details = await extractWebElements(error);
1178
- if (!details)
1179
- return 'Could not fetch element details. Repeat the action to get better info.';
1180
- return formatElementList(details);
1211
+ return formatElementList(await extractWebElements(error));
1181
1212
  }
1182
1213
  function getNotFoundSuggestion(errorMessage) {
1183
1214
  if (!errorMessage.includes('not found')) {
@@ -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;
@@ -4,10 +4,13 @@ export declare class ApplicationSpec {
4
4
  readonly sourcePath: string;
5
5
  constructor(sourcePath: string);
6
6
  renderFor(state: ActionResult): string;
7
+ matchedUrls(state: ActionResult): string[];
7
8
  get pageCount(): number;
8
9
  load(): void;
9
10
  resolveSourcePath(sourcePath: string): string;
11
+ relevantPages(state: ActionResult): ApplicationSpecPage[];
10
12
  }
13
+ export declare function resolveSpecBundlePath(sourcePath: string): string | null;
11
14
  interface ApplicationSpecPage {
12
15
  url: string;
13
16
  content: string;