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
package/src/ai/rules.ts CHANGED
@@ -8,8 +8,9 @@ const locatorPriorityRule = dedent`
8
8
 
9
9
  1. ARIA locators (first choice) - target browser's accessibility tree, most reliable
10
10
  Use JSON format: { "role": "button", "text": "Login" }
11
- Copy role and text VERBATIM from the ARIA snapshot or UI map never guess the pair.
12
- If the element is absent from the snapshot, do not invent one; use text or CSS instead.
11
+ Copy role and text VERBATIM from the ARIA snapshot, UI map, or the page diff that
12
+ reported the element never guess the pair; a guessed role can silently match a
13
+ different element with the same text. If named nowhere, use text or CSS instead.
13
14
 
14
15
  2. Text locators (second choice) - exact visible text, use only when unique on the page
15
16
  Example: 'Login', 'Submit', 'Username'
@@ -238,10 +239,10 @@ export const unexpectedPopupRule = dedent`
238
239
  If buttons are disabled unexpectedly, check if a popup is blocking interaction or if required form fields are empty.
239
240
 
240
241
  Dismiss strategy (try in order):
241
- 1. I.clickXY(0, 0) — click outside the popup to close it
242
- 2. I.pressKey('Escape') — press Escape to dismiss
243
- 3. I.click('Cancel') — click Cancel button if present
244
- 4. I.click({ role: 'button', text: 'Close' }) click X/close button if present
242
+ 1. I.pressKey('Escape') — press Escape to dismiss
243
+ 2. I.click('Cancel') — click Cancel button if present
244
+ 3. I.click({ role: 'button', text: 'Close' }) — click X/close button if present
245
+ 4. I.clickXY(0, 0) via form() tool and check if page diff changed
245
246
  </unexpected_popup_rule>
246
247
  `;
247
248
 
@@ -335,7 +336,7 @@ export const actionRule = dedent`
335
336
  Prefer text/ARIA locators with context over complex CSS/XPath selectors.
336
337
  For inline create/edit flows, after filling a field verify it contains the value, then confirm using the nearest explicit button/link, an adjacent icon-only confirm control in the same row/form, or Enter if the field remains focused.
337
338
  If locator doesn't work, try CSS or XPath locators.
338
- If nothing works, use I.clickXY(x, y) as last resort.
339
+ If nothing works, use visualClick() it locates the target in a screenshot before clicking it.
339
340
 
340
341
  When a click result reports several matches, pick one from its numbered list by position rather than guessing a new locator.
341
342
  Reuse the same locator with step.opts({ elementIndex: N }) as the LAST argument. N is the "Element N" number.
@@ -0,0 +1,150 @@
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.ts';
8
+ import { tag } from '../../utils/logger.ts';
9
+ import { loadMarkdownFiles } from '../../utils/markdown-files.ts';
10
+ import { readCaptainFile } from '../captain/file-tools.ts';
11
+
12
+ const MAX_FILES = 500;
13
+ const MAX_FINDINGS = 6000;
14
+
15
+ let cachedScanner: 'rg' | 'grep' | null = null;
16
+
17
+ export function loadScoutCorpus(dirs: string[]): ScoutCorpus {
18
+ const files: ScoutCorpusFile[] = [];
19
+ for (const dir of dirs) {
20
+ if (files.length >= MAX_FILES) {
21
+ tag('warning').log(`Scout corpus capped at ${MAX_FILES} files — remaining directories skipped`);
22
+ break;
23
+ }
24
+ for (const file of loadMarkdownFiles(dir, { recursive: true })) {
25
+ if (files.length >= MAX_FILES) break;
26
+ const entry: ScoutCorpusFile = { path: file.filePath };
27
+ if (typeof file.data.url === 'string') entry.url = file.data.url;
28
+ files.push(entry);
29
+ }
30
+ }
31
+ return { dirs, files, excludedPaths: [] };
32
+ }
33
+
34
+ export function excludeCorpusUrls(corpus: ScoutCorpus, urls: string[]): ScoutCorpus {
35
+ if (urls.length === 0) return corpus;
36
+
37
+ const excludedUrls = new Set(urls);
38
+ const files: ScoutCorpusFile[] = [];
39
+ const excludedPaths = [...corpus.excludedPaths];
40
+ for (const file of corpus.files) {
41
+ if (file.url && excludedUrls.has(file.url)) {
42
+ excludedPaths.push(file.path);
43
+ continue;
44
+ }
45
+ files.push(file);
46
+ }
47
+ return { dirs: corpus.dirs, files, excludedPaths };
48
+ }
49
+
50
+ export async function createScoutTools(corpus: ScoutCorpus) {
51
+ const scanner = await detectScanner();
52
+ const projectRoot = ConfigParser.getInstance().getProjectRoot();
53
+
54
+ let result = '';
55
+ let searchedOrRead = false;
56
+
57
+ const getResult = () => result;
58
+ const finishFromText = (text?: string) => {
59
+ if (text && searchedOrRead) result = text.slice(0, MAX_FINDINGS);
60
+ };
61
+
62
+ const files: Record<string, string> = {};
63
+ const readableFiles = new Set<string>();
64
+ for (const file of corpus.files) {
65
+ files[toPosix(file.path)] = readFileSync(file.path, 'utf8');
66
+ readableFiles.add(resolve(file.path));
67
+ }
68
+
69
+ const toolkit = await createBashTool({
70
+ destination: '/',
71
+ files,
72
+ maxOutputLength: 20000,
73
+ });
74
+
75
+ const bashExecute = toolkit.bash.execute;
76
+ const bash = {
77
+ ...toolkit.bash,
78
+ execute: async (input: { command: string }) => {
79
+ tag('step').log(`Scout: bash ${input.command}`);
80
+ searchedOrRead = true;
81
+ return bashExecute?.(input);
82
+ },
83
+ };
84
+
85
+ const tools: Record<string, any> = {
86
+ bash,
87
+ readFile: tool({
88
+ description: dedent`
89
+ Read one documentation file from the corpus.
90
+ Pass the exact path returned by a search result.
91
+ `,
92
+ inputSchema: z.object({
93
+ path: z.string().describe('File path from a search result'),
94
+ startLine: z.number().optional().describe('First line to read, 1-based. Negative values count from the end of the file'),
95
+ endLine: z.number().optional().describe('Last line to read, 1-based and inclusive. Negative values count from the end of the file'),
96
+ maxChars: z.number().optional().describe('Maximum characters to return, default 12000'),
97
+ }),
98
+ execute: async (input) => {
99
+ tag('step').log(`Scout: read ${input.path}`);
100
+ const output = readCaptainFile(projectRoot, input, corpus.dirs);
101
+ if (!output.success) return output;
102
+ const resolvedPath = resolve(projectRoot || process.cwd(), output.path);
103
+ if (!readableFiles.has(resolvedPath)) {
104
+ return { success: false, message: 'File is outside the Scout corpus' };
105
+ }
106
+ searchedOrRead = true;
107
+ return output;
108
+ },
109
+ }),
110
+ };
111
+
112
+ return { tools, scanner, getResult, finishFromText };
113
+ }
114
+
115
+ async function detectScanner(): Promise<'rg' | 'grep'> {
116
+ if (cachedScanner) return cachedScanner;
117
+ if (await binaryRuns('rg')) {
118
+ cachedScanner = 'rg';
119
+ return cachedScanner;
120
+ }
121
+ if (await binaryRuns('grep')) {
122
+ cachedScanner = 'grep';
123
+ return cachedScanner;
124
+ }
125
+ throw new Error('Scout requires ripgrep or grep on PATH — neither was found');
126
+ }
127
+
128
+ async function binaryRuns(binary: 'rg' | 'grep'): Promise<boolean> {
129
+ try {
130
+ const proc = Bun.spawn([binary, '--version'], { stdout: 'ignore', stderr: 'ignore', stdin: 'ignore' });
131
+ return (await proc.exited) === 0;
132
+ } catch {
133
+ return false;
134
+ }
135
+ }
136
+
137
+ function toPosix(path: string): string {
138
+ return path.split('\\').join('/');
139
+ }
140
+
141
+ export interface ScoutCorpus {
142
+ dirs: string[];
143
+ files: ScoutCorpusFile[];
144
+ excludedPaths: string[];
145
+ }
146
+
147
+ export interface ScoutCorpusFile {
148
+ path: string;
149
+ url?: string;
150
+ }
@@ -0,0 +1,173 @@
1
+ import dedent from 'dedent';
2
+ import { tag } from '../utils/logger.ts';
3
+ import { loop } from '../utils/loop.ts';
4
+ import type { Agent } from './agent.ts';
5
+ import type { Provider } from './provider.ts';
6
+ import { type ScoutCorpus, createScoutTools, excludeCorpusUrls } from './scout/tools.ts';
7
+
8
+ const MAX_ITERATIONS = 3;
9
+ const MAX_TOOL_ROUNDTRIPS = 5;
10
+ const CACHE_LIMIT = 40;
11
+ const URL_LISTING_LIMIT = 40;
12
+
13
+ export class Scout implements Agent {
14
+ emoji = '🔎';
15
+ private cache = new Map<string, string>();
16
+
17
+ constructor(
18
+ private provider: Provider,
19
+ private corpus: ScoutCorpus
20
+ ) {}
21
+
22
+ isAvailable(): boolean {
23
+ return this.corpus.files.length > 0;
24
+ }
25
+
26
+ async collectDocs(query: ScoutQuery): Promise<string> {
27
+ if (!this.isAvailable()) return '';
28
+
29
+ const cacheKey = `${query.url || ''}|${query.feature || ''}|${query.excludeUrls.join(',')}`;
30
+ const cached = this.cache.get(cacheKey);
31
+ if (cached !== undefined) return cached;
32
+
33
+ const corpus = excludeCorpusUrls(this.corpus, query.excludeUrls);
34
+ if (corpus.files.length === 0) return '';
35
+
36
+ const result = await this.runSession(corpus, query);
37
+ if (result === null) return '';
38
+
39
+ if (this.cache.size > CACHE_LIMIT) this.cache.clear();
40
+ this.cache.set(cacheKey, result);
41
+ return result;
42
+ }
43
+
44
+ private async runSession(corpus: ScoutCorpus, query: ScoutQuery): Promise<string | null> {
45
+ const { tools, scanner, getResult, finishFromText } = await createScoutTools(corpus);
46
+ const conversation = this.provider.startConversation(this.buildSystemPrompt(Object.keys(tools), corpus, query, scanner), 'scout', this.provider.getAgenticModel('scout'));
47
+ conversation.addUserText(this.buildTaskPrompt(query));
48
+
49
+ tag('info').log(`Scout: collecting documentation for ${query.feature || query.url || 'the current page'}`);
50
+
51
+ let failed = false;
52
+ await loop(
53
+ async ({ stop, iteration }) => {
54
+ const invokeResult = await this.provider.invokeConversation(conversation, tools, {
55
+ maxToolRoundtrips: MAX_TOOL_ROUNDTRIPS,
56
+ agentName: 'scout',
57
+ });
58
+
59
+ if (!invokeResult?.toolExecutions?.length) {
60
+ finishFromText(invokeResult?.response?.text);
61
+ stop();
62
+ return;
63
+ }
64
+
65
+ if (iteration >= MAX_ITERATIONS) {
66
+ const final = await this.provider.invokeConversation(conversation, undefined, { agentName: 'scout' });
67
+ finishFromText(final?.response?.text);
68
+ stop();
69
+ }
70
+ },
71
+ {
72
+ maxAttempts: MAX_ITERATIONS,
73
+ observability: { name: `scout: ${query.feature || query.url || 'docs'}`, agent: 'scout' },
74
+ catch: async ({ error, stop }) => {
75
+ failed = true;
76
+ tag('warning').log(`Scout error: ${(error as Error).message}`);
77
+ stop();
78
+ },
79
+ }
80
+ );
81
+
82
+ if (failed) return null;
83
+
84
+ const digest = getResult();
85
+ if (digest) {
86
+ const preview = digest.slice(0, 600);
87
+ const ellipsis = digest.length > 600 ? '…' : '';
88
+ tag('info').log(`Scout digest:\n${preview}${ellipsis}`);
89
+ }
90
+ return digest;
91
+ }
92
+
93
+ private buildSystemPrompt(toolNames: string[], corpus: ScoutCorpus, query: ScoutQuery, scanner: 'rg' | 'grep'): string {
94
+ const urls = corpus.files.map((file) => file.url).filter(Boolean) as string[];
95
+ const urlless = corpus.files.filter((file) => !file.url);
96
+ let pagesListing = '';
97
+ if (urls.length > 0) {
98
+ const listing = urls
99
+ .slice(0, URL_LISTING_LIMIT)
100
+ .map((url) => `- ${url}`)
101
+ .join('\n');
102
+ pagesListing = `Documented pages:\n${listing}`;
103
+ const remaining = urls.length - URL_LISTING_LIMIT;
104
+ if (remaining > 0) pagesListing += `\n…and ${remaining} more — find them with ${scanner}`;
105
+ }
106
+ if (urlless.length > 0) {
107
+ const listing = urlless
108
+ .slice(0, URL_LISTING_LIMIT)
109
+ .map((file) => `- ${toPosix(file.path)}`)
110
+ .join('\n');
111
+ pagesListing += `\nFiles with no page URL (hand-written docs):\n${listing}`;
112
+ }
113
+
114
+ const prompt = dedent`
115
+ You are Scout — a documentation retrieval agent. You find collected documentation relevant to a testing focus and report it for test planning.
116
+
117
+ You never see the application itself. The documentation corpus is your only source of truth.
118
+
119
+ CORPUS:
120
+ ${corpus.files.length} markdown files under:
121
+ - ${corpus.dirs.map(toPosix).join('\n- ')}
122
+ ${pagesListing}
123
+
124
+ These pages are already provided to the planner in full — do not re-report them:
125
+ ${query.excludeUrls.map((url) => `- ${url}`).join('\n') || '- none'}
126
+
127
+ AVAILABLE TOOLS:
128
+ ${toolNames.join(', ')}.
129
+ Use tool names exactly as listed. Do not invent aliases or combined names.
130
+ Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
131
+
132
+ SCANNER:
133
+ ${scanner} is the search command. Scan the working directory through bash() — explore freely, pipelines, globs and repeated searches are fine. Read files with readFile().
134
+
135
+ WORKFLOW:
136
+ 1. Scan with ${scanner} using plain prose words from the focus — feature names, page purposes, capabilities
137
+ 2. Read the files whose hits look most relevant
138
+ 3. Report the digest as your final message — no tool call is needed to finish
139
+
140
+ RULES:
141
+ - Report only what the documentation states. Never fill gaps with assumptions about the application
142
+ - Keep verified capabilities and unverified possibilities distinguishable, the way the documentation marks them
143
+ - Name the page URL each item belongs to, so scenarios anchor to real routes
144
+ - Explore briefly: a few scans and reads are enough, then report
145
+ - A short accurate digest beats a long loose one; reporting that nothing relevant exists is a valid answer
146
+ `;
147
+
148
+ const customPrompt = this.provider.getSystemPromptForAgent('scout');
149
+ if (customPrompt) return `${prompt}\n\n${customPrompt}`;
150
+ return prompt;
151
+ }
152
+
153
+ private buildTaskPrompt(query: ScoutQuery): string {
154
+ return dedent`
155
+ Page URL: ${query.url || 'Unknown'}
156
+ Page title: ${query.title || 'Unknown'}
157
+ Focus: ${query.feature || 'the page as a whole'}
158
+
159
+ Report the documented capabilities, states and transitions a test planner could turn into scenarios.
160
+ `;
161
+ }
162
+ }
163
+
164
+ function toPosix(path: string): string {
165
+ return path.split('\\').join('/');
166
+ }
167
+
168
+ export interface ScoutQuery {
169
+ url?: string;
170
+ title?: string;
171
+ feature?: string;
172
+ excludeUrls: string[];
173
+ }
package/src/ai/tools.ts CHANGED
@@ -1,17 +1,18 @@
1
1
  import { tool } from 'ai';
2
2
  import dedent from 'dedent';
3
3
  import { z } from 'zod';
4
- import type { ExecutedStep } from '../action.ts';
5
4
  import { ActionResult, type PageDiff, type ToolResultMetadata } from '../action-result.ts';
5
+ import type { ExecutedStep } from '../action.ts';
6
6
  import { type ExperienceTracker, renderExperienceRecipes } from '../experience-tracker.ts';
7
7
  import { Stats } from '../stats.ts';
8
8
  import { type Task, TestResult } from '../test-plan.js';
9
+ import { ariaRefSelector, describeRef, refIsGone } from '../utils/aria-ref.ts';
9
10
  import { LARGE_ARIA_CHANGE_THRESHOLD } from '../utils/aria.ts';
10
11
  import { isFatalBrowserError } from '../utils/browser-errors.ts';
11
12
  import { cleanHtmlSnippet } from '../utils/html.ts';
12
13
  import { createDebug, tag } from '../utils/logger.js';
13
- import { compactErrorMessage, normalizeInlineText, truncate } from '../utils/strings.ts';
14
14
  import { pause } from '../utils/loop.js';
15
+ import { compactErrorMessage, normalizeInlineText, truncate } from '../utils/strings.ts';
15
16
  import { WebElement } from '../utils/web-element.ts';
16
17
  import type { ToolDeps } from './agent.ts';
17
18
  import { Navigator } from './navigator.ts';
@@ -66,7 +67,6 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
66
67
  2. I.click(ARIA, container) - e.g. I.click({"role":"button","text":"Save"}, ".modal")
67
68
  3. I.click(CSS, container) - e.g. I.click("#btn", ".modal")
68
69
  4. I.click(CSS) or I.click(XPath) - when locator already includes context (ID, XPath)
69
- 5. I.clickXY(x, y) - coordinates fallback
70
70
  After a result reporting multiple matches, reuse that locator with step.opts({ elementIndex: N }) as the last argument.
71
71
  `),
72
72
  explanation: z.string().describe('Why you are clicking this element'),
@@ -79,11 +79,21 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
79
79
  return failedToolResult('click', 'No commands provided');
80
80
  }
81
81
 
82
- const invalidCommands = rawCommands.map((cmd) => cmd.trim()).filter((cmd) => cmd.startsWith('I.') && !cmd.startsWith('I.click'));
82
+ const trimmedCommands = rawCommands.map((cmd) => cmd.trim());
83
+ const coordinateCommands = trimmedCommands.filter((cmd) => cmd.startsWith('I.clickXY'));
84
+
85
+ if (coordinateCommands.length > 0) {
86
+ activeNote.commit(TestResult.FAILED);
87
+ 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.`, {
88
+ 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.',
89
+ });
90
+ }
91
+
92
+ const invalidCommands = trimmedCommands.filter((cmd) => cmd.startsWith('I.') && !cmd.startsWith('I.click'));
83
93
 
84
94
  if (invalidCommands.length > 0) {
85
95
  activeNote.commit(TestResult.FAILED);
86
- return failedToolResult('click', `Invalid commands: ${invalidCommands.join(', ')}. Click tool only accepts I.click() or I.clickXY() commands.`, {
96
+ return failedToolResult('click', `Invalid commands: ${invalidCommands.join(', ')}. Click tool only accepts I.click() commands.`, {
87
97
  suggestion: 'Use form() tool for typing text or multiple actions, or exitIframe() to leave iframe context.',
88
98
  });
89
99
  }
@@ -111,8 +121,25 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
111
121
 
112
122
  if (success) {
113
123
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, command);
124
+
125
+ if (!hasObservablePageChange(toolResult)) {
126
+ activeNote.commit(TestResult.FAILED);
127
+ return failedToolResult('click', 'Click executed, but no observable page change was captured.', {
128
+ ...toolResult,
129
+ attempts,
130
+ code: command,
131
+ 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.',
132
+ });
133
+ }
134
+
114
135
  await commitNote(activeNote, TestResult.PASSED, toolResult, action);
115
- return successToolResult('click', { ...toolResult, attempts, code: command }, action);
136
+ const data: Record<string, any> = { ...toolResult, attempts, code: command };
137
+ const notExecuted = commands.slice(i + 1);
138
+ if (notExecuted.length) {
139
+ data.notExecuted = notExecuted;
140
+ data.suggestion = `SKIPPED: ${notExecuted.join('; ')}`;
141
+ }
142
+ return successToolResult('click', data, action);
116
143
  }
117
144
  }
118
145
 
@@ -461,10 +488,18 @@ export function createRefTools({ explorer, stateManager }: ToolDeps, task: Task)
461
488
  }),
462
489
  execute: async ({ ref, element }) => {
463
490
  const activeNote = task.startNote(`Click ${element}`);
491
+
492
+ if (await refIsGone(explorer, ref)) {
493
+ activeNote.commit(TestResult.FAILED);
494
+ return failedToolResult('clickRef', `Ref ${ref} names no element on the page any more.`, {
495
+ 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.',
496
+ });
497
+ }
498
+
464
499
  const previousState = ActionResult.fromState(stateManager.getCurrentState()!);
465
500
  const action = explorer.action();
466
501
  const named = await describeRef(explorer, ref);
467
- const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(`aria-ref=${ref}`)}).click())`;
502
+ const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(ariaRefSelector(ref))}).click())`;
468
503
 
469
504
  if (!(await action.attempt(run, `Click ${element}`))) {
470
505
  activeNote.commit(TestResult.FAILED);
@@ -1169,21 +1204,6 @@ export async function commitNote(activeNote: any, result: TestResult, toolResult
1169
1204
  activeNote.commit(result);
1170
1205
  }
1171
1206
 
1172
- async function describeRef(explorer: any, ref: string): Promise<{ role: string; text: string } | null> {
1173
- return Promise.resolve(
1174
- explorer?.withPage?.((page: any) =>
1175
- page.locator(`aria-ref=${ref}`).evaluate((el: any) => {
1176
- const tag = el.tagName.toLowerCase();
1177
- const roles: Record<string, string> = { a: 'link', button: 'button', select: 'combobox', textarea: 'textbox' };
1178
- const role = el.getAttribute('role') || roles[tag] || tag;
1179
- const text = (el.getAttribute('aria-label') || el.innerText || el.value || '').trim().split('\n')[0];
1180
- if (!text) return null;
1181
- return { role, text };
1182
- })
1183
- )
1184
- ).catch(() => null);
1185
- }
1186
-
1187
1207
  async function hasFocusedElement(explorer: any): Promise<boolean> {
1188
1208
  return explorer.withPage((page: any) => page.evaluate(() => !!document.activeElement && document.activeElement !== document.body)).catch(() => true);
1189
1209
  }
@@ -1238,6 +1258,7 @@ function hasObservablePageChange(data?: Record<string, any>): boolean {
1238
1258
  if (data.pageDiff.urlChanged === true) return true;
1239
1259
  if (data.pageDiff.ariaChanges) return true;
1240
1260
  if (data.pageDiff.messages?.length) return true;
1261
+ if (data.pageDiff.requests?.length) return true;
1241
1262
  return Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
1242
1263
  }
1243
1264
 
@@ -1248,12 +1269,11 @@ export async function failedToolResult(action: string, message: string, data?: R
1248
1269
  }
1249
1270
 
1250
1271
  const errorTexts = [message, ...(data?.attempts?.map((a: any) => a.error || '') || [])];
1251
- const hasMultipleElements = errorTexts.some((t: string) => t.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN));
1252
- const multipleElementsSuggestion = hasMultipleElements ? getMultipleElementsSuggestion() : null;
1253
- if (multipleElementsSuggestion) {
1254
- result.suggestion = multipleElementsSuggestion;
1272
+ if (errorTexts.some((t: string) => t.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))) {
1273
+ const matched = await extractWebElements(error);
1274
+ result.suggestion = getMultipleElementsSuggestion();
1255
1275
  result.multipleElementsDetected = true;
1256
- result.elements = await formatMatchedElements(error);
1276
+ result.elements = formatElementList(matched);
1257
1277
  return result;
1258
1278
  }
1259
1279
 
@@ -1269,8 +1289,9 @@ export async function failedToolResult(action: string, message: string, data?: R
1269
1289
  function getMultipleElementsSuggestion(): string {
1270
1290
  return dedent`
1271
1291
  Multiple elements matched your locator, so that command did nothing — it selected no element and acted on none.
1272
- Read the numbered elements list and click the one you meant by its number:
1292
+ Read the numbered elements list and act on the one you meant by its number:
1273
1293
  reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
1294
+ A match reported as not visible can never be acted on — pick one that is.
1274
1295
  If none of them is the element you want, narrow the locator with a container or its full unique text.
1275
1296
  If the list is missing, call xpathCheck() to see what the locator matches.
1276
1297
  `;
@@ -1294,7 +1315,7 @@ export function clickFailureSuggestion(attempts: Array<{ error?: string }>): str
1294
1315
  }
1295
1316
 
1296
1317
  if (errors.some((e) => e.includes('is not visible'))) {
1297
- return 'Element is in the DOM but not visible. Reveal it first — scroll to it, expand its section, or open the panel holding it.';
1318
+ 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.';
1298
1319
  }
1299
1320
 
1300
1321
  if (errors.some((e) => e.includes('SyntaxError'))) {
@@ -1319,19 +1340,20 @@ const MAX_DISAMBIGUATE_TEXT = 80;
1319
1340
  const MAX_DISAMBIGUATE_HTML = 300;
1320
1341
  const MULTIPLE_ELEMENTS_PATTERN = 'multiple elements';
1321
1342
 
1322
- async function extractWebElements(error: Error | null | undefined): Promise<Array<{ xpath: string; html: string; text: string }> | null> {
1343
+ async function extractWebElements(error: Error | null | undefined): Promise<MatchedElement[] | null> {
1323
1344
  if (!error || error.name !== 'MultipleElementsFound') return null;
1324
1345
 
1325
- const elements = (error as any).webElements as Array<{ toAbsoluteXPath: () => Promise<string>; toOuterHTML: () => Promise<string>; getText: () => Promise<string | null> }> | undefined;
1346
+ const elements = (error as any).webElements as Array<{ toAbsoluteXPath: () => Promise<string>; toOuterHTML: () => Promise<string>; getText: () => Promise<string | null>; isVisible?: () => Promise<boolean> }> | undefined;
1326
1347
  if (!elements?.length) return null;
1327
1348
 
1328
- const result: Array<{ xpath: string; html: string; text: string }> = [];
1349
+ const result: MatchedElement[] = [];
1329
1350
  for (let i = 0; i < Math.min(elements.length, MAX_DISAMBIGUATE_ELEMENTS); i++) {
1330
1351
  try {
1331
1352
  const xpath = await elements[i].toAbsoluteXPath();
1332
1353
  const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
1333
1354
  const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
1334
- result.push({ xpath, html, text });
1355
+ const visible = await Promise.resolve(elements[i].isVisible?.()).catch(() => undefined);
1356
+ result.push({ xpath, html, text, visible });
1335
1357
  } catch (e) {
1336
1358
  debugLog('Failed to get details for element %d: %s', i, e);
1337
1359
  }
@@ -1339,14 +1361,22 @@ async function extractWebElements(error: Error | null | undefined): Promise<Arra
1339
1361
  return result.length > 0 ? result : null;
1340
1362
  }
1341
1363
 
1342
- function formatElementList(details: Array<{ xpath: string; html: string; text: string }>): string {
1343
- return details.map((el, i) => `Element ${i + 1}:\nText: "${el.text}"\nXPath: ${el.xpath}\nHTML: ${el.html}`).join('\n\n');
1364
+ function formatElementList(matched: MatchedElement[] | null): string {
1365
+ if (!matched) return 'Could not fetch element details. Repeat the action to get better info.';
1366
+ return matched
1367
+ .map((el, i) => {
1368
+ const lines = [`Element ${i + 1}:`, `Text: "${el.text}"`];
1369
+ if (el.visible !== undefined) lines.push(`Visible: ${el.visible}`);
1370
+ const wrapped = matched.map((_, j) => j).filter((j) => j !== i && matched[j].xpath.startsWith(`${el.xpath}/`));
1371
+ if (wrapped.length) lines.push(`Wraps: element ${wrapped.map((j) => j + 1).join(', ')}`);
1372
+ lines.push(`XPath: ${el.xpath}`, `HTML: ${el.html}`);
1373
+ return lines.join('\n');
1374
+ })
1375
+ .join('\n\n');
1344
1376
  }
1345
1377
 
1346
1378
  export async function formatMatchedElements(error: Error | null | undefined): Promise<string | null> {
1347
- const details = await extractWebElements(error);
1348
- if (!details) return 'Could not fetch element details. Repeat the action to get better info.';
1349
- return formatElementList(details);
1379
+ return formatElementList(await extractWebElements(error));
1350
1380
  }
1351
1381
 
1352
1382
  function getNotFoundSuggestion(errorMessage: string): string | null {
@@ -1362,3 +1392,10 @@ function getNotFoundSuggestion(errorMessage: string): string | null {
1362
1392
  4. Prefer ARIA locators: { "role": "button", "text": "visible text" }
1363
1393
  `;
1364
1394
  }
1395
+
1396
+ interface MatchedElement {
1397
+ xpath: string;
1398
+ html: string;
1399
+ text: string;
1400
+ visible?: boolean;
1401
+ }