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
|
@@ -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
|
+
}
|
package/dist/src/ai/tools.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ExecutedStep } from '../action.js';
|
|
2
1
|
import { ActionResult, type PageDiff } from '../action-result.js';
|
|
2
|
+
import type { ExecutedStep } from '../action.js';
|
|
3
3
|
import { type ExperienceTracker } from '../experience-tracker.js';
|
|
4
4
|
import { type Task } from '../test-plan.js';
|
|
5
5
|
import type { ToolDeps } from './agent.js';
|
package/dist/src/ai/tools.js
CHANGED
|
@@ -5,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
|
|
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()
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
1174
|
-
|
|
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
|
-
|
|
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 =
|
|
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;
|