explorbot 0.4.4 → 0.4.5
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/prima/src/prima.ts +8 -3
- package/dist/boat/prima/src/prima.js +10 -3
- package/dist/package.json +1 -1
- 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/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.js +56 -30
- package/dist/src/application-spec.d.ts +3 -0
- package/dist/src/application-spec.js +21 -5
- 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/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/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/reference/configuration.md +28 -1
- 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 +1 -1
- 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/rules.ts +8 -7
- package/src/ai/scout/tools.ts +150 -0
- package/src/ai/scout.ts +173 -0
- package/src/ai/tools.ts +69 -36
- package/src/application-spec.ts +22 -4
- package/src/config.ts +7 -0
- package/src/explorbot.ts +36 -0
- 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/web-annotate.ts +3 -15
- package/src/utils/web-element.ts +0 -9
package/dist/src/ai/tools.js
CHANGED
|
@@ -11,6 +11,7 @@ import { cleanHtmlSnippet } from "../utils/html.js";
|
|
|
11
11
|
import { createDebug, tag } from '../utils/logger.js';
|
|
12
12
|
import { compactErrorMessage, normalizeInlineText, truncate } from "../utils/strings.js";
|
|
13
13
|
import { pause } from '../utils/loop.js';
|
|
14
|
+
import { ariaRefSelector, describeRef, refIsGone } from "../utils/aria-ref.js";
|
|
14
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,6 +99,15 @@ 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
|
return successToolResult('click', { ...toolResult, attempts, code: command }, action);
|
|
96
113
|
}
|
|
@@ -385,10 +402,16 @@ export function createRefTools({ explorer, stateManager }, task) {
|
|
|
385
402
|
}),
|
|
386
403
|
execute: async ({ ref, element }) => {
|
|
387
404
|
const activeNote = task.startNote(`Click ${element}`);
|
|
405
|
+
if (await refIsGone(explorer, ref)) {
|
|
406
|
+
activeNote.commit(TestResult.FAILED);
|
|
407
|
+
return failedToolResult('clickRef', `Ref ${ref} names no element on the page any more.`, {
|
|
408
|
+
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.',
|
|
409
|
+
});
|
|
410
|
+
}
|
|
388
411
|
const previousState = ActionResult.fromState(stateManager.getCurrentState());
|
|
389
412
|
const action = explorer.action();
|
|
390
413
|
const named = await describeRef(explorer, ref);
|
|
391
|
-
const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(
|
|
414
|
+
const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(ariaRefSelector(ref))}).click())`;
|
|
392
415
|
if (!(await action.attempt(run, `Click ${element}`))) {
|
|
393
416
|
activeNote.commit(TestResult.FAILED);
|
|
394
417
|
return failedToolResult('clickRef', `Ref ${ref} could not be clicked: ${errorText(action.lastError)}`, {
|
|
@@ -1015,17 +1038,6 @@ export async function commitNote(activeNote, result, toolResult, action) {
|
|
|
1015
1038
|
}
|
|
1016
1039
|
activeNote.commit(result);
|
|
1017
1040
|
}
|
|
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
1041
|
async function hasFocusedElement(explorer) {
|
|
1030
1042
|
return explorer.withPage((page) => page.evaluate(() => !!document.activeElement && document.activeElement !== document.body)).catch(() => true);
|
|
1031
1043
|
}
|
|
@@ -1085,6 +1097,8 @@ function hasObservablePageChange(data) {
|
|
|
1085
1097
|
return true;
|
|
1086
1098
|
if (data.pageDiff.messages?.length)
|
|
1087
1099
|
return true;
|
|
1100
|
+
if (data.pageDiff.requests?.length)
|
|
1101
|
+
return true;
|
|
1088
1102
|
return Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
|
|
1089
1103
|
}
|
|
1090
1104
|
export async function failedToolResult(action, message, data, error) {
|
|
@@ -1093,12 +1107,11 @@ export async function failedToolResult(action, message, data, error) {
|
|
|
1093
1107
|
result.suggestion = data.suggestion ? `${data.suggestion} ${PAGE_DIFF_SUGGESTION}` : PAGE_DIFF_SUGGESTION;
|
|
1094
1108
|
}
|
|
1095
1109
|
const errorTexts = [message, ...(data?.attempts?.map((a) => a.error || '') || [])];
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
result.suggestion = multipleElementsSuggestion;
|
|
1110
|
+
if (errorTexts.some((t) => t.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))) {
|
|
1111
|
+
const matched = await extractWebElements(error);
|
|
1112
|
+
result.suggestion = getMultipleElementsSuggestion(matched);
|
|
1100
1113
|
result.multipleElementsDetected = true;
|
|
1101
|
-
result.elements =
|
|
1114
|
+
result.elements = formatElementList(matched);
|
|
1102
1115
|
return result;
|
|
1103
1116
|
}
|
|
1104
1117
|
const notFoundSuggestion = getNotFoundSuggestion(message);
|
|
@@ -1108,11 +1121,16 @@ export async function failedToolResult(action, message, data, error) {
|
|
|
1108
1121
|
}
|
|
1109
1122
|
return result;
|
|
1110
1123
|
}
|
|
1111
|
-
function getMultipleElementsSuggestion() {
|
|
1124
|
+
function getMultipleElementsSuggestion(matched) {
|
|
1125
|
+
const visible = (matched || []).filter((element) => element.visible !== false);
|
|
1126
|
+
let onlyVisible = '';
|
|
1127
|
+
if (matched && visible.length === 1)
|
|
1128
|
+
onlyVisible = `\nOnly element ${matched.indexOf(visible[0]) + 1} is on screen, so that is the one to act on.`;
|
|
1112
1129
|
return dedent `
|
|
1113
1130
|
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
|
|
1131
|
+
Read the numbered elements list and act on the one you meant by its number:
|
|
1115
1132
|
reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
|
|
1133
|
+
A match reported as not visible can never be acted on — pick one that is.${onlyVisible}
|
|
1116
1134
|
If none of them is the element you want, narrow the locator with a container or its full unique text.
|
|
1117
1135
|
If the list is missing, call xpathCheck() to see what the locator matches.
|
|
1118
1136
|
`;
|
|
@@ -1132,7 +1150,7 @@ export function clickFailureSuggestion(attempts) {
|
|
|
1132
1150
|
return 'Element exists but another element covers it. Close the overlapping panel or dialog, then retry.';
|
|
1133
1151
|
}
|
|
1134
1152
|
if (errors.some((e) => e.includes('is not visible'))) {
|
|
1135
|
-
return 'Element is in the DOM but not visible. Reveal it
|
|
1153
|
+
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
1154
|
}
|
|
1137
1155
|
if (errors.some((e) => e.includes('SyntaxError'))) {
|
|
1138
1156
|
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 +1180,8 @@ async function extractWebElements(error) {
|
|
|
1162
1180
|
const xpath = await elements[i].toAbsoluteXPath();
|
|
1163
1181
|
const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
|
|
1164
1182
|
const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
|
|
1165
|
-
|
|
1183
|
+
const visible = await Promise.resolve(elements[i].isVisible?.()).catch(() => undefined);
|
|
1184
|
+
result.push({ xpath, html, text, visible });
|
|
1166
1185
|
}
|
|
1167
1186
|
catch (e) {
|
|
1168
1187
|
debugLog('Failed to get details for element %d: %s', i, e);
|
|
@@ -1170,14 +1189,21 @@ async function extractWebElements(error) {
|
|
|
1170
1189
|
}
|
|
1171
1190
|
return result.length > 0 ? result : null;
|
|
1172
1191
|
}
|
|
1173
|
-
function formatElementList(
|
|
1174
|
-
|
|
1192
|
+
function formatElementList(matched) {
|
|
1193
|
+
if (!matched)
|
|
1194
|
+
return 'Could not fetch element details. Repeat the action to get better info.';
|
|
1195
|
+
return matched
|
|
1196
|
+
.map((el, i) => {
|
|
1197
|
+
const lines = [`Element ${i + 1}:`, `Text: "${el.text}"`];
|
|
1198
|
+
if (el.visible !== undefined)
|
|
1199
|
+
lines.push(`Visible: ${el.visible}`);
|
|
1200
|
+
lines.push(`XPath: ${el.xpath}`, `HTML: ${el.html}`);
|
|
1201
|
+
return lines.join('\n');
|
|
1202
|
+
})
|
|
1203
|
+
.join('\n\n');
|
|
1175
1204
|
}
|
|
1176
1205
|
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);
|
|
1206
|
+
return formatElementList(await extractWebElements(error));
|
|
1181
1207
|
}
|
|
1182
1208
|
function getNotFoundSuggestion(errorMessage) {
|
|
1183
1209
|
if (!errorMessage.includes('not found')) {
|
|
@@ -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;
|
|
@@ -13,7 +13,7 @@ export class ApplicationSpec {
|
|
|
13
13
|
this.load();
|
|
14
14
|
}
|
|
15
15
|
renderFor(state) {
|
|
16
|
-
const relevant = this.
|
|
16
|
+
const relevant = this.relevantPages(state);
|
|
17
17
|
if (relevant.length === 0)
|
|
18
18
|
return '';
|
|
19
19
|
tag('operation').log(`Found application specification for ${state.url}`);
|
|
@@ -25,6 +25,9 @@ export class ApplicationSpec {
|
|
|
25
25
|
</application_spec>
|
|
26
26
|
`;
|
|
27
27
|
}
|
|
28
|
+
matchedUrls(state) {
|
|
29
|
+
return this.relevantPages(state).map((page) => page.url);
|
|
30
|
+
}
|
|
28
31
|
get pageCount() {
|
|
29
32
|
return this.pages.length;
|
|
30
33
|
}
|
|
@@ -63,9 +66,22 @@ export class ApplicationSpec {
|
|
|
63
66
|
}
|
|
64
67
|
}
|
|
65
68
|
resolveSourcePath(sourcePath) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return
|
|
69
|
+
return resolveSpecSource(sourcePath);
|
|
70
|
+
}
|
|
71
|
+
relevantPages(state) {
|
|
72
|
+
return this.pages.filter((page) => state.isMatchedBy({ url: page.url }));
|
|
70
73
|
}
|
|
71
74
|
}
|
|
75
|
+
export function resolveSpecBundlePath(sourcePath) {
|
|
76
|
+
const resolved = resolveSpecSource(sourcePath);
|
|
77
|
+
if (!existsSync(resolved))
|
|
78
|
+
return null;
|
|
79
|
+
if (statSync(resolved).isDirectory())
|
|
80
|
+
return resolved;
|
|
81
|
+
return path.dirname(resolved);
|
|
82
|
+
}
|
|
83
|
+
function resolveSpecSource(sourcePath) {
|
|
84
|
+
if (path.isAbsolute(sourcePath))
|
|
85
|
+
return path.resolve(sourcePath);
|
|
86
|
+
return path.resolve(ConfigParser.getInstance().resolveProjectDir(sourcePath));
|
|
87
|
+
}
|
package/dist/src/config.d.ts
CHANGED
|
@@ -105,6 +105,10 @@ interface RerunnerAgentConfig extends AgentConfig {
|
|
|
105
105
|
interface PlannerAgentConfig extends AgentConfig {
|
|
106
106
|
styles?: string[];
|
|
107
107
|
stylesDir?: string;
|
|
108
|
+
docsWeight?: number;
|
|
109
|
+
}
|
|
110
|
+
interface ScoutAgentConfig extends AgentConfig {
|
|
111
|
+
dirs?: string[];
|
|
108
112
|
}
|
|
109
113
|
interface ScreencastConfig {
|
|
110
114
|
size?: {
|
|
@@ -129,6 +133,7 @@ interface AgentsConfig {
|
|
|
129
133
|
quartermaster?: AgentConfig;
|
|
130
134
|
historian?: HistorianAgentConfig;
|
|
131
135
|
fisherman?: AgentConfig;
|
|
136
|
+
scout?: ScoutAgentConfig;
|
|
132
137
|
chief?: AgentConfig;
|
|
133
138
|
curler?: AgentConfig;
|
|
134
139
|
rerunner?: RerunnerAgentConfig;
|
|
@@ -221,7 +226,7 @@ interface ExplorbotConfig {
|
|
|
221
226
|
type RuleEntry = string | Record<string, string>;
|
|
222
227
|
export declare const EXPLORBOT_CONFIG_PATHS: string[];
|
|
223
228
|
export declare const EXPLORBOT_ENV_VARS: EnvVar[];
|
|
224
|
-
export type { ExplorbotConfig, PlaywrightConfig, AIConfig, HtmlConfig, ActionConfig, AgentConfig, AgentsConfig, HistorianAgentConfig, ResearcherAgentConfig, NavigatorAgentConfig, PlannerAgentConfig, RerunnerAgentConfig, HealRecipe, Hook, HookConfig, HooksConfig, PlaywrightHook, CodeceptJSHook, HookPatternMap, RuleEntry, ReporterConfig, ApiConfig, WebConfig, ApiHookFn, };
|
|
229
|
+
export type { ExplorbotConfig, PlaywrightConfig, AIConfig, HtmlConfig, ActionConfig, AgentConfig, AgentsConfig, HistorianAgentConfig, ResearcherAgentConfig, NavigatorAgentConfig, PlannerAgentConfig, ScoutAgentConfig, RerunnerAgentConfig, HealRecipe, Hook, HookConfig, HooksConfig, PlaywrightHook, CodeceptJSHook, HookPatternMap, RuleEntry, ReporterConfig, ApiConfig, WebConfig, ApiHookFn, };
|
|
225
230
|
export declare class ConfigParser {
|
|
226
231
|
static instance: ConfigParser;
|
|
227
232
|
static recommended: Record<string, Record<string, string>> | null;
|
package/dist/src/explorbot.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { AIProvider } from './ai/provider.js';
|
|
|
12
12
|
import { Quartermaster } from './ai/quartermaster.js';
|
|
13
13
|
import { Rerunner } from './ai/rerunner.js';
|
|
14
14
|
import { Researcher } from './ai/researcher.js';
|
|
15
|
+
import { Scout } from './ai/scout.js';
|
|
15
16
|
import { SessionAnalyst } from './ai/session-analyst.js';
|
|
16
17
|
import { Tester } from './ai/tester.js';
|
|
17
18
|
import { RequestStore } from './api/request-store.js';
|
|
@@ -99,6 +100,8 @@ export declare class ExplorBot {
|
|
|
99
100
|
agentDriller(): Driller;
|
|
100
101
|
agentSessionAnalyst(): SessionAnalyst;
|
|
101
102
|
agentFisherman(): Fisherman | null;
|
|
103
|
+
agentScout(): Scout | null;
|
|
104
|
+
scoutCorpusDirs(): string[];
|
|
102
105
|
getCurrentPlan(): Plan | undefined;
|
|
103
106
|
getSuite(): Suite | null;
|
|
104
107
|
getPlanFeature(): string | undefined;
|
package/dist/src/explorbot.js
CHANGED
|
@@ -12,12 +12,15 @@ import { AIProvider } from "./ai/provider.js";
|
|
|
12
12
|
import { Quartermaster } from "./ai/quartermaster.js";
|
|
13
13
|
import { Rerunner } from "./ai/rerunner.js";
|
|
14
14
|
import { Researcher } from "./ai/researcher.js";
|
|
15
|
+
import { Scout } from "./ai/scout.js";
|
|
16
|
+
import { loadScoutCorpus } from "./ai/scout/tools.js";
|
|
15
17
|
import { SessionAnalyst } from "./ai/session-analyst.js";
|
|
16
18
|
import { Tester } from "./ai/tester.js";
|
|
17
19
|
import { createAgentTools } from "./ai/tools.js";
|
|
18
20
|
import { ApiClient } from "./api/api-client.js";
|
|
19
21
|
import { RequestStore } from "./api/request-store.js";
|
|
20
22
|
import { loadSpec } from "./api/spec-reader.js";
|
|
23
|
+
import { resolveSpecBundlePath } from "./application-spec.js";
|
|
21
24
|
import { ConfigParser } from "./config.js";
|
|
22
25
|
import { ExperienceTracker } from "./experience-tracker.js";
|
|
23
26
|
import Explorer from "./explorer.js";
|
|
@@ -184,6 +187,9 @@ export class ExplorBot {
|
|
|
184
187
|
const fisherman = this.agentFisherman();
|
|
185
188
|
if (fisherman)
|
|
186
189
|
this.agents.planner.setFisherman(fisherman);
|
|
190
|
+
const scout = this.agentScout();
|
|
191
|
+
if (scout)
|
|
192
|
+
this.agents.planner.setScout(scout);
|
|
187
193
|
}
|
|
188
194
|
return this.agents.planner;
|
|
189
195
|
}
|
|
@@ -310,6 +316,33 @@ export class ExplorBot {
|
|
|
310
316
|
}
|
|
311
317
|
return this.agents.fisherman;
|
|
312
318
|
}
|
|
319
|
+
agentScout() {
|
|
320
|
+
const scoutConfig = this.config.ai?.agents?.scout;
|
|
321
|
+
if (scoutConfig?.enabled !== true)
|
|
322
|
+
return null;
|
|
323
|
+
const dirs = this.scoutCorpusDirs();
|
|
324
|
+
if (dirs.length === 0) {
|
|
325
|
+
tag('warning').log('Scout enabled but no documentation found — set --spec or ai.agents.scout.dirs');
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
return (this.agents.scout ||= this.createAgent(({ ai }) => new Scout(ai, loadScoutCorpus(dirs))));
|
|
329
|
+
}
|
|
330
|
+
scoutCorpusDirs() {
|
|
331
|
+
const dirs = [];
|
|
332
|
+
const specPath = this.options.applicationSpec || this.config.dirs?.spec;
|
|
333
|
+
if (specPath) {
|
|
334
|
+
const bundle = resolveSpecBundlePath(specPath);
|
|
335
|
+
const pagesDir = bundle && path.join(bundle, 'pages');
|
|
336
|
+
if (pagesDir && existsSync(pagesDir))
|
|
337
|
+
dirs.push(pagesDir);
|
|
338
|
+
}
|
|
339
|
+
for (const dir of this.config.ai?.agents?.scout?.dirs || []) {
|
|
340
|
+
const resolved = this.configParser.resolveProjectDir(dir);
|
|
341
|
+
if (existsSync(resolved))
|
|
342
|
+
dirs.push(resolved);
|
|
343
|
+
}
|
|
344
|
+
return [...new Set(dirs)];
|
|
345
|
+
}
|
|
313
346
|
getCurrentPlan() {
|
|
314
347
|
return this.currentPlan;
|
|
315
348
|
}
|
|
@@ -16,6 +16,7 @@ export declare class KnowledgeTracker {
|
|
|
16
16
|
renderEndpointKnowledge(endpoint: string): string;
|
|
17
17
|
renderRelevantContext(state: ActionResult): string;
|
|
18
18
|
renderApplicationSpec(state: ActionResult): string;
|
|
19
|
+
applicationSpecUrls(state: ActionResult): string[];
|
|
19
20
|
addKnowledge(urlPattern: string, description: string, opts?: {
|
|
20
21
|
replace?: boolean;
|
|
21
22
|
}): {
|
|
@@ -79,6 +79,9 @@ export class KnowledgeTracker {
|
|
|
79
79
|
renderApplicationSpec(state) {
|
|
80
80
|
return this.applicationSpec?.renderFor(state) || '';
|
|
81
81
|
}
|
|
82
|
+
applicationSpecUrls(state) {
|
|
83
|
+
return this.applicationSpec?.matchedUrls(state) || [];
|
|
84
|
+
}
|
|
82
85
|
addKnowledge(urlPattern, description, opts) {
|
|
83
86
|
const configParser = ConfigParser.getInstance();
|
|
84
87
|
const configPath = configParser.getConfigPath();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { WebElement } from './web-element.js';
|
|
2
|
+
export declare function ariaRefSelector(ref: string): string;
|
|
3
|
+
export declare function isAriaRef(ref: string): boolean;
|
|
4
|
+
export declare function ariaRefSnapshot(page: any): Promise<string>;
|
|
5
|
+
export declare function parseAriaRefs(ariaSnapshot: string): AriaRefEntry[];
|
|
6
|
+
export declare function elementFromAriaRef(page: any, ref: string): Promise<WebElement | null>;
|
|
7
|
+
export declare function refIsGone(explorer: any, ref: string): Promise<boolean>;
|
|
8
|
+
export declare function describeRef(explorer: any, ref: string): Promise<{
|
|
9
|
+
role: string;
|
|
10
|
+
text: string;
|
|
11
|
+
} | null>;
|
|
12
|
+
export interface AriaRefEntry {
|
|
13
|
+
role: string;
|
|
14
|
+
name: string;
|
|
15
|
+
ref: string;
|
|
16
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { WebElement } from "./web-element.js";
|
|
2
|
+
const REF_LINE_PATTERN = /^(\s*)-\s+(\w+)\s*(?:"([^"]*)")?.*?\[ref=(e\d+)\]/;
|
|
3
|
+
const ARIA_REF_PATTERN = /^(f\d+)?e\d+$/i;
|
|
4
|
+
const REF_ROLES = { a: 'link', button: 'button', select: 'combobox', textarea: 'textbox' };
|
|
5
|
+
export function ariaRefSelector(ref) {
|
|
6
|
+
return `aria-ref=${ref}`;
|
|
7
|
+
}
|
|
8
|
+
export function isAriaRef(ref) {
|
|
9
|
+
return ARIA_REF_PATTERN.test(ref);
|
|
10
|
+
}
|
|
11
|
+
export function ariaRefSnapshot(page) {
|
|
12
|
+
return page.locator('body').ariaSnapshot({ mode: 'ai' });
|
|
13
|
+
}
|
|
14
|
+
export function parseAriaRefs(ariaSnapshot) {
|
|
15
|
+
const entries = [];
|
|
16
|
+
for (const line of ariaSnapshot.split('\n')) {
|
|
17
|
+
const match = line.match(REF_LINE_PATTERN);
|
|
18
|
+
if (!match)
|
|
19
|
+
continue;
|
|
20
|
+
entries.push({ role: match[2], name: match[3] || '', ref: match[4] });
|
|
21
|
+
}
|
|
22
|
+
return entries;
|
|
23
|
+
}
|
|
24
|
+
export async function elementFromAriaRef(page, ref) {
|
|
25
|
+
if (!isAriaRef(ref))
|
|
26
|
+
return null;
|
|
27
|
+
return WebElement.fromPlaywrightLocator(page.locator(ariaRefSelector(ref)));
|
|
28
|
+
}
|
|
29
|
+
export async function refIsGone(explorer, ref) {
|
|
30
|
+
const count = () => Promise.resolve(explorer?.withPage?.((page) => page.locator(ariaRefSelector(ref)).count())).catch(() => undefined);
|
|
31
|
+
if ((await count()) !== 0)
|
|
32
|
+
return false;
|
|
33
|
+
await Promise.resolve(explorer?.withPage?.(ariaRefSnapshot)).catch(() => null);
|
|
34
|
+
return (await count()) === 0;
|
|
35
|
+
}
|
|
36
|
+
export async function describeRef(explorer, ref) {
|
|
37
|
+
return Promise.resolve(explorer?.withPage?.((page) => page.locator(ariaRefSelector(ref)).evaluate((el, roles) => {
|
|
38
|
+
const tag = el.tagName.toLowerCase();
|
|
39
|
+
const role = el.getAttribute('role') || roles[tag];
|
|
40
|
+
if (!role)
|
|
41
|
+
return null;
|
|
42
|
+
const text = (el.getAttribute('aria-label') || el.innerText || el.value || '').trim().split('\n')[0];
|
|
43
|
+
if (!text)
|
|
44
|
+
return null;
|
|
45
|
+
return { role, text };
|
|
46
|
+
}, REF_ROLES))).catch(() => null);
|
|
47
|
+
}
|
package/dist/src/utils/aria.js
CHANGED
|
@@ -434,9 +434,9 @@ const formatDiffSection = (label, items) => {
|
|
|
434
434
|
const summary = countBy(items);
|
|
435
435
|
if (summary.size === 0)
|
|
436
436
|
return [` ${label}: []`];
|
|
437
|
-
const
|
|
438
|
-
const top =
|
|
439
|
-
const rest =
|
|
437
|
+
const ordered = Array.from(summary.entries());
|
|
438
|
+
const top = ordered.slice(0, TOP_DIFF_ITEMS);
|
|
439
|
+
const rest = ordered.slice(TOP_DIFF_ITEMS);
|
|
440
440
|
const lines = [` ${label}:`];
|
|
441
441
|
for (const [item, count] of top) {
|
|
442
442
|
let suffix = '';
|
|
@@ -1,24 +1,12 @@
|
|
|
1
|
+
import { ariaRefSnapshot, parseAriaRefs } from "./aria-ref.js";
|
|
1
2
|
import { ELEMENT_EXTRACTION_CONFIG, getElementDataExtractorSource } from "./html.js";
|
|
2
3
|
import { createDebug } from './logger.js';
|
|
3
4
|
import { WebElement } from "./web-element.js";
|
|
4
5
|
const debugLog = createDebug('explorbot:web-annotate');
|
|
5
|
-
const REF_LINE_PATTERN = /^(\s*)-\s+(\w+)\s*(?:"([^"]*)")?.*?\[ref=(e\d+)\]/;
|
|
6
6
|
const ANNOTATABLE_ROLES = new Set(['button', 'link', 'textbox', 'searchbox', 'checkbox', 'radio', 'switch', 'combobox', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'slider', 'spinbutton', 'treeitem']);
|
|
7
|
-
function parseAriaRefs(ariaSnapshot) {
|
|
8
|
-
const entries = [];
|
|
9
|
-
for (const line of ariaSnapshot.split('\n')) {
|
|
10
|
-
const match = line.match(REF_LINE_PATTERN);
|
|
11
|
-
if (!match)
|
|
12
|
-
continue;
|
|
13
|
-
if (!ANNOTATABLE_ROLES.has(match[2]))
|
|
14
|
-
continue;
|
|
15
|
-
entries.push({ role: match[2], name: match[3] || '', ref: match[4] });
|
|
16
|
-
}
|
|
17
|
-
return entries;
|
|
18
|
-
}
|
|
19
7
|
export async function annotatePageElements(page) {
|
|
20
|
-
const ariaSnapshot = await page
|
|
21
|
-
const refEntries = parseAriaRefs(ariaSnapshot);
|
|
8
|
+
const ariaSnapshot = await ariaRefSnapshot(page);
|
|
9
|
+
const refEntries = parseAriaRefs(ariaSnapshot).filter((entry) => ANNOTATABLE_ROLES.has(entry.role));
|
|
22
10
|
const byRole = new Map();
|
|
23
11
|
for (const { role, name, ref } of refEntries) {
|
|
24
12
|
let list = byRole.get(role);
|
|
@@ -36,8 +36,6 @@ export declare class WebElement {
|
|
|
36
36
|
static fromXPathMatch(m: XPathMatch): WebElement;
|
|
37
37
|
static fromPlaywrightLocator(locator: any): Promise<WebElement | null>;
|
|
38
38
|
static fromEidx(page: any, eidx: string): Promise<WebElement | null>;
|
|
39
|
-
static isAriaRef(ref: string): boolean;
|
|
40
|
-
static fromAriaRef(page: any, ref: string): Promise<WebElement | null>;
|
|
41
39
|
static fromEidxList(page: any, eidxList: string[]): Promise<WebElement[]>;
|
|
42
40
|
static commonAncestor(page: any, eidxList: string[]): Promise<WebElement | null>;
|
|
43
41
|
static findByXPath(html: string, xpath: string): Promise<{
|
|
@@ -111,14 +111,6 @@ export class WebElement {
|
|
|
111
111
|
static async fromEidx(page, eidx) {
|
|
112
112
|
return WebElement.fromPlaywrightLocator(page.locator(`[${EXPLORBOT_ATTRS.eidx}="${eidx}"]`));
|
|
113
113
|
}
|
|
114
|
-
static isAriaRef(ref) {
|
|
115
|
-
return /^(f\d+)?e\d+$/i.test(ref);
|
|
116
|
-
}
|
|
117
|
-
static async fromAriaRef(page, ref) {
|
|
118
|
-
if (!WebElement.isAriaRef(ref))
|
|
119
|
-
return null;
|
|
120
|
-
return WebElement.fromPlaywrightLocator(page.locator(`aria-ref=${ref}`));
|
|
121
|
-
}
|
|
122
114
|
static async fromEidxList(page, eidxList) {
|
|
123
115
|
const validEidxList = eidxList.filter((eidx) => /^e\d+$/i.test(eidx));
|
|
124
116
|
if (validEidxList.length === 0)
|
|
@@ -179,6 +179,7 @@ Each agent takes its own model and system prompt.
|
|
|
179
179
|
| `rerunner` | Heals failing steps when re-running generated tests |
|
|
180
180
|
| `analyst` | Writes the end-of-session markdown report |
|
|
181
181
|
| `fisherman` | Prepares test data through API requests |
|
|
182
|
+
| `scout` | Retrieves relevant documentation for the Planner |
|
|
182
183
|
| `chief` | API test planning |
|
|
183
184
|
| `curler` | API test execution |
|
|
184
185
|
|
|
@@ -206,7 +207,7 @@ agents: {
|
|
|
206
207
|
| `beforeHook` | `Hook \| HookPatternMap` | Code to run before agent execution |
|
|
207
208
|
| `afterHook` | `Hook \| HookPatternMap` | Code to run after agent execution |
|
|
208
209
|
|
|
209
|
-
Some agents take extra options: `pilot` accepts `stepsToReview` (recent steps reviewed per check, default 5); `planner` accepts `styles` (see [Planning styles](#planning-styles)); `rerunner` accepts `healLimit` (max heal attempts, default 3) and `recipes` (custom heal recipes, see [Rerunning Tests](../web-testing/rerun.md)). Researcher and Historian options are documented below.
|
|
210
|
+
Some agents take extra options: `pilot` accepts `stepsToReview` (recent steps reviewed per check, default 5); `planner` accepts `styles` (see [Planning styles](#planning-styles)) and `docsWeight` (share of scenarios grounded in documentation when Scout is enabled, default 70); `scout` accepts `dirs` (see [Scout agent](#scout-agent)); `rerunner` accepts `healLimit` (max heal attempts, default 3) and `recipes` (custom heal recipes, see [Rerunning Tests](../web-testing/rerun.md)). Researcher and Historian options are documented below.
|
|
210
211
|
|
|
211
212
|
See [Agent hooks](../web-testing/hooks.md) for hook configuration.
|
|
212
213
|
|
|
@@ -257,6 +258,31 @@ See [AI providers](../basics/providers.md) for recommended models and provider s
|
|
|
257
258
|
|
|
258
259
|
Fisherman prepares test data over the API before a scenario runs, and can also answer questions about data that already exists without creating or changing anything. Pilot reaches this read-only capability through its `askApi(question)` tool, calling it to check whether suitable data is already there — or to get the exact name or id of an existing record — before deciding whether to create anything through `precondition()`. In replicate mode, where Fisherman learns the API by watching browser traffic instead of reading a spec, the read endpoints it can query come from successful GET requests observed in the browser, alongside the write endpoints already captured from XHR traffic. The endpoint list shown to the model names only the path and its query-parameter names, never their values; the underlying capture on disk holds the full request URL and headers — what write captures already hold — but no response body.
|
|
259
260
|
|
|
261
|
+
### Scout agent
|
|
262
|
+
|
|
263
|
+
Scout retrieves documentation relevant to the page being planned and hands it to the Planner as a `<docs_context>` block, so scenarios can be grounded in what the application documents say. It is opt-in and needs documentation collected beforehand:
|
|
264
|
+
|
|
265
|
+
```javascript
|
|
266
|
+
ai: {
|
|
267
|
+
agents: {
|
|
268
|
+
scout: {
|
|
269
|
+
enabled: true, // Opt in — Scout never runs without this
|
|
270
|
+
dirs: ['docs'], // Extra markdown directories to search, beyond the spec bundle
|
|
271
|
+
},
|
|
272
|
+
planner: {
|
|
273
|
+
docsWeight: 70, // Roughly 70% of scenarios exercise documented behavior, the rest explore beyond it
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
| Option | Type | Description |
|
|
280
|
+
|--------|------|-------------|
|
|
281
|
+
| `enabled` | `boolean` | Turn Scout on. Default: off. |
|
|
282
|
+
| `dirs` | `string[]` | Markdown directories added to the corpus, resolved relative to the project |
|
|
283
|
+
|
|
284
|
+
The corpus combines the [application spec](../workflow/application-spec.md) bundle (`--spec` / `EXPLORBOT_SPEC` / `dirs.spec`, set by `explorbot docs collect`) with the `dirs` above. Scout scans it with the same `bash` + `readFile` tools Captain uses: the corpus is loaded into an in-memory sandbox and the model itself runs `rg` (or `grep` — whichever is installed) to explore it. One of the two must be on PATH — Scout fails loudly when neither is found. Pages already injected for the current URL as `<application_spec>` are excluded from the Scout corpus, so the two blocks never duplicate each other. Files under `dirs` that carry no page URL are listed as hand-written notes for Scout to inspect when they are relevant.
|
|
285
|
+
|
|
260
286
|
## Playwright settings
|
|
261
287
|
|
|
262
288
|
### Browser selection
|
|
@@ -512,6 +538,7 @@ export default {
|
|
|
512
538
|
quartermaster: { /* ... */ },
|
|
513
539
|
historian: { /* ... */ },
|
|
514
540
|
fisherman: { /* ... */ },
|
|
541
|
+
scout: { enabled: true, dirs: ['docs'] }, // Documentation retrieval for the Planner
|
|
515
542
|
rerunner: { /* ... */ },
|
|
516
543
|
analyst: { /* ... */ },
|
|
517
544
|
},
|
|
@@ -44,7 +44,7 @@ See [Researcher Agent](./researcher.md) for configuration and usage.
|
|
|
44
44
|
|
|
45
45
|
Generates test scenarios from research findings.
|
|
46
46
|
|
|
47
|
-
The Planner writes business-focused scenarios with priority levels (critical/important/high/normal/low) and expected outcomes for verification. It balances positive and negative cases, skips scenarios you already have, and cycles through planning styles (normal, psycho, curious) to broaden coverage across iterations. You can add your own styles and page-specific rules.
|
|
47
|
+
The Planner writes business-focused scenarios with priority levels (critical/important/high/normal/low) and expected outcomes for verification. It balances positive and negative cases, skips scenarios you already have, and cycles through planning styles (normal, psycho, curious) to broaden coverage across iterations. You can add your own styles and page-specific rules. With the Scout agent enabled, it also plans from collected documentation, weighted by `docsWeight`.
|
|
48
48
|
|
|
49
49
|
Commands that use Planner:
|
|
50
50
|
- `/plan [--focus <feature>]`
|
|
@@ -52,6 +52,14 @@ Commands that use Planner:
|
|
|
52
52
|
|
|
53
53
|
See [Planner Agent](./planner.md) for planning styles, customization, and configuration.
|
|
54
54
|
|
|
55
|
+
## Scout Agent
|
|
56
|
+
|
|
57
|
+
Retrieves documentation relevant to the page being planned.
|
|
58
|
+
|
|
59
|
+
Scout searches the collected documentation corpus — the [application spec](../workflow/application-spec.md) from `explorbot docs collect` plus any extra markdown directories you configure — and reports the documented capabilities, states and transitions that matter for the current page and focus. The Planner receives them as a `<docs_context>` block and grounds part of its scenarios in them. Pages already injected as `<application_spec>` for the current URL are not repeated. Scout is opt-in (`ai.agents.scout.enabled`) and searches with ripgrep or grep, falling back to an in-process scan when neither is installed.
|
|
60
|
+
|
|
61
|
+
See [Configuration: Scout agent](../reference/configuration.md#scout-agent).
|
|
62
|
+
|
|
55
63
|
## Tester Agent
|
|
56
64
|
|
|
57
65
|
Runs the planned scenarios.
|
|
@@ -53,6 +53,11 @@ ai: {
|
|
|
53
53
|
| `styles` | `string[]` | `['normal', 'curious', 'psycho']` | Style names and cycling order |
|
|
54
54
|
| `rules` | `RuleEntry[]` | `[]` | URL-aware rule files from `rules/planner/` |
|
|
55
55
|
| `systemPrompt` | `string` | - | Inline instructions appended to the prompt |
|
|
56
|
+
| `docsWeight` | `number` | `70` | With Scout enabled, the rough share of scenarios exercising documented behavior; the rest explore beyond the documentation |
|
|
57
|
+
|
|
58
|
+
## Planning from documentation
|
|
59
|
+
|
|
60
|
+
With the [Scout agent](../reference/configuration.md#scout-agent) enabled, the Planner also receives a `<docs_context>` block — capabilities, states and transitions retrieved from collected documentation that are relevant to the current page and focus. `docsWeight` steers the mix: at `70` roughly seven of ten scenarios exercise documented behavior and three explore what the documentation does not cover. Set it to `100` for documentation-only planning, or lower it to lean on the Planner's own reading of the page. Pages whose documentation is already injected as `<application_spec>` are not repeated in `<docs_context>`.
|
|
56
61
|
|
|
57
62
|
## Planning Styles
|
|
58
63
|
|
|
@@ -75,3 +75,7 @@ Screenshots and other relative links may be included for readers, but Explorbot
|
|
|
75
75
|
## Validation
|
|
76
76
|
|
|
77
77
|
Explorbot rejects a bundle when `index.md` or `pages/` is missing, when it contains no page files, or when a page has an unsupported format, version, or missing URL.
|
|
78
|
+
|
|
79
|
+
## Scout
|
|
80
|
+
|
|
81
|
+
Beyond the per-URL injection, the same bundle feeds the [Scout agent](../reference/configuration.md#scout-agent): when Scout is enabled, it searches `pages/` (and any extra `ai.agents.scout.dirs`) for documentation relevant to the page being planned and reports it to the Planner. Pages already injected for the current URL are excluded from scouting, so the two channels never duplicate each other.
|