explorbot 0.4.4 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/boat/api-tester/src/apibot.ts +18 -2
- package/boat/api-tester/src/cli.ts +85 -274
- package/boat/api-tester/src/commands/api-command.ts +10 -0
- package/boat/api-tester/src/commands/explore-command.ts +52 -0
- package/boat/api-tester/src/commands/init-command.ts +119 -0
- package/boat/api-tester/src/commands/know-command.ts +44 -0
- package/boat/api-tester/src/commands/plan-command.ts +42 -0
- package/boat/api-tester/src/commands/test-command.ts +54 -0
- package/boat/prima/src/prima.ts +8 -3
- package/dist/boat/api-tester/src/apibot.js +14 -1
- package/dist/boat/api-tester/src/cli.js +87 -243
- package/dist/boat/api-tester/src/commands/api-command.js +7 -0
- package/dist/boat/api-tester/src/commands/explore-command.js +41 -0
- package/dist/boat/api-tester/src/commands/init-command.js +88 -0
- package/dist/boat/api-tester/src/commands/know-command.js +39 -0
- package/dist/boat/api-tester/src/commands/plan-command.js +37 -0
- package/dist/boat/api-tester/src/commands/test-command.js +45 -0
- package/dist/boat/prima/src/prima.js +10 -3
- package/dist/package.json +4 -4
- package/dist/src/ai/fisherman/tools.js +7 -1
- package/dist/src/ai/fisherman.js +2 -1
- package/dist/src/ai/pilot.d.ts +0 -1
- package/dist/src/ai/pilot.js +8 -24
- package/dist/src/ai/planner.d.ts +4 -0
- package/dist/src/ai/planner.js +28 -0
- package/dist/src/ai/provider.js +3 -1
- package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
- package/dist/src/ai/researcher/deep-analysis.js +14 -6
- package/dist/src/ai/rules.js +8 -7
- package/dist/src/ai/scout/tools.d.ts +17 -0
- package/dist/src/ai/scout/tools.js +130 -0
- package/dist/src/ai/scout.d.ts +21 -0
- package/dist/src/ai/scout.js +150 -0
- package/dist/src/ai/tools.d.ts +1 -1
- package/dist/src/ai/tools.js +62 -31
- package/dist/src/api/spec-reader.d.ts +1 -0
- package/dist/src/api/spec-reader.js +93 -1
- package/dist/src/application-spec.d.ts +3 -0
- package/dist/src/application-spec.js +21 -5
- package/dist/src/commands/base-command.d.ts +3 -3
- package/dist/src/commands/init-command.d.ts +3 -0
- package/dist/src/commands/init-command.js +6 -3
- package/dist/src/config.d.ts +6 -1
- package/dist/src/explorbot.d.ts +3 -0
- package/dist/src/explorbot.js +33 -0
- package/dist/src/explorer.d.ts +1 -1
- package/dist/src/explorer.js +1 -1
- package/dist/src/knowledge-tracker.d.ts +1 -0
- package/dist/src/knowledge-tracker.js +3 -0
- package/dist/src/utils/aria-ref.d.ts +16 -0
- package/dist/src/utils/aria-ref.js +47 -0
- package/dist/src/utils/aria.js +3 -3
- package/dist/src/utils/html-diff.js +4 -1
- package/dist/src/utils/web-annotate.js +3 -15
- package/dist/src/utils/web-element.d.ts +0 -2
- package/dist/src/utils/web-element.js +0 -8
- package/docs/api-testing/basics.md +26 -2
- package/docs/reference/configuration.md +28 -1
- package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +317 -0
- package/docs/web-testing/agents.md +9 -1
- package/docs/web-testing/planner.md +5 -0
- package/docs/workflow/application-spec.md +4 -0
- package/package.json +4 -4
- package/src/ai/fisherman/tools.ts +8 -1
- package/src/ai/fisherman.ts +2 -1
- package/src/ai/pilot.ts +8 -25
- package/src/ai/planner.ts +33 -0
- package/src/ai/provider.ts +2 -1
- package/src/ai/researcher/deep-analysis.ts +13 -6
- package/src/ai/rules.ts +8 -7
- package/src/ai/scout/tools.ts +150 -0
- package/src/ai/scout.ts +173 -0
- package/src/ai/tools.ts +75 -38
- package/src/api/spec-reader.ts +106 -1
- package/src/application-spec.ts +22 -4
- package/src/commands/base-command.ts +3 -3
- package/src/commands/init-command.ts +6 -3
- package/src/config.ts +7 -0
- package/src/explorbot.ts +36 -0
- package/src/explorer.ts +1 -1
- package/src/knowledge-tracker.ts +4 -0
- package/src/utils/aria-ref.ts +61 -0
- package/src/utils/aria.ts +3 -3
- package/src/utils/html-diff.ts +3 -1
- package/src/utils/web-annotate.ts +3 -15
- package/src/utils/web-element.ts +0 -9
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
|
|
12
|
-
|
|
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.
|
|
242
|
-
2. I.
|
|
243
|
-
3. I.click('
|
|
244
|
-
4. I.
|
|
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
|
|
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
|
+
}
|
package/src/ai/scout.ts
ADDED
|
@@ -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
|
|
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()
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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<
|
|
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:
|
|
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
|
-
|
|
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(
|
|
1343
|
-
|
|
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
|
-
|
|
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
|
+
}
|