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/api/spec-reader.ts
CHANGED
|
@@ -51,7 +51,7 @@ export function extractEndpointDefinition(schema: any, endpoint: string, baseEnd
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
const basePath = toBasePath(baseEndpoint);
|
|
54
|
-
const matched =
|
|
54
|
+
const matched = collectEndpointPaths(schema, basePath, endpoint);
|
|
55
55
|
|
|
56
56
|
if (!Object.keys(matched).length) {
|
|
57
57
|
const available = listNormalizedPaths(schema, basePath);
|
|
@@ -61,6 +61,35 @@ export function extractEndpointDefinition(schema: any, endpoint: string, baseEnd
|
|
|
61
61
|
return safeStringify(matched);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
export function resolveEndpoints(schema: any, pattern: string, baseEndpoint?: string): string[] {
|
|
65
|
+
if (!schema?.paths) {
|
|
66
|
+
throw new Error('OpenAPI spec has no paths defined');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const basePath = toBasePath(baseEndpoint);
|
|
70
|
+
const normalized = Object.keys(schema.paths).map((specPath) => stripBasePath(specPath, basePath));
|
|
71
|
+
const matched = normalized.filter((specPath) => matchesPattern(specPath, pattern));
|
|
72
|
+
|
|
73
|
+
if (!matched.length) {
|
|
74
|
+
throw new Error(`Endpoint "${pattern}" not found in spec. Available: ${listNormalizedPaths(schema, basePath)}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const roots = matched.map((specPath) => toCollection(specPath, normalized, pattern));
|
|
78
|
+
const resolved = [...new Set(roots.map((root) => fillParameters(root, pattern)))];
|
|
79
|
+
const endpoints = resolved.filter((specPath) => !specPath.includes('{'));
|
|
80
|
+
|
|
81
|
+
if (!endpoints.length) {
|
|
82
|
+
throw new Error(`Endpoint "${pattern}" leaves ${listParameters(resolved)} unresolved. Give the value in the endpoint or in the base endpoint.`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const skipped = resolved.filter((specPath) => specPath.includes('{'));
|
|
86
|
+
if (skipped.length) {
|
|
87
|
+
tag('warning').log(`Skipped, no value for their parameters: ${skipped.join(', ')}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return endpoints;
|
|
91
|
+
}
|
|
92
|
+
|
|
64
93
|
export function searchEndpoints(schema: any, query: string, baseEndpoint?: string): string {
|
|
65
94
|
if (!schema?.paths) return 'No endpoints available';
|
|
66
95
|
|
|
@@ -166,6 +195,82 @@ function stripBasePath(specPath: string, basePath: string): string {
|
|
|
166
195
|
return `/${specSegments.slice(i).join('/')}`;
|
|
167
196
|
}
|
|
168
197
|
|
|
198
|
+
function collectEndpointPaths(schema: any, basePath: string, endpoint: string): Record<string, any> {
|
|
199
|
+
const normalized = Object.keys(schema.paths).map((specPath) => stripBasePath(specPath, basePath));
|
|
200
|
+
const roots = resolveEndpoint(normalized, endpoint);
|
|
201
|
+
|
|
202
|
+
if (!roots.length) return collectMatchingPaths(schema, basePath, (path) => matchesEndpoint(path, endpoint));
|
|
203
|
+
|
|
204
|
+
return collectMatchingPaths(schema, basePath, (path) => roots.some((root) => path === root || path.startsWith(`${root}/`)));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function resolveEndpoint(specPaths: string[], endpoint: string): string[] {
|
|
208
|
+
const wanted = toSegments(endpoint);
|
|
209
|
+
if (!wanted.length) return [];
|
|
210
|
+
|
|
211
|
+
const matched = specPaths.filter((specPath) => {
|
|
212
|
+
const segments = toSegments(specPath);
|
|
213
|
+
if (segments.length !== wanted.length) return false;
|
|
214
|
+
return segmentsMatch(segments, wanted);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const literals = matched.map((specPath) => toSegments(specPath).filter((segment, i) => segment === wanted[i]).length);
|
|
218
|
+
const best = Math.max(0, ...literals);
|
|
219
|
+
return matched.filter((_, i) => literals[i] === best);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function matchesPattern(specPath: string, pattern: string): boolean {
|
|
223
|
+
const wanted = toSegments(pattern);
|
|
224
|
+
const segments = toSegments(specPath);
|
|
225
|
+
if (segments.length < wanted.length) return false;
|
|
226
|
+
return segmentsMatch(segments, wanted);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function segmentsMatch(segments: string[], wanted: string[]): boolean {
|
|
230
|
+
return wanted.every((want, i) => want === '*' || segments[i] === want || segments[i].startsWith('{'));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function toCollection(specPath: string, specPaths: string[], pattern: string): string {
|
|
234
|
+
const segments = toSegments(specPath);
|
|
235
|
+
const filled = toSegments(fillParameters(specPath, pattern));
|
|
236
|
+
|
|
237
|
+
let deepest = segments.length;
|
|
238
|
+
const unfilled = filled.findIndex((segment) => segment.startsWith('{'));
|
|
239
|
+
if (unfilled >= 0) deepest = unfilled;
|
|
240
|
+
|
|
241
|
+
for (let i = Math.max(1, Math.min(toSegments(pattern).length, deepest)); i <= deepest; i++) {
|
|
242
|
+
const prefix = `/${segments.slice(0, i).join('/')}`;
|
|
243
|
+
if (specPaths.includes(prefix)) return prefix;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return specPath;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function fillParameters(specPath: string, pattern: string): string {
|
|
250
|
+
const wanted = toSegments(pattern);
|
|
251
|
+
const segments = toSegments(specPath);
|
|
252
|
+
for (let i = 0; i < wanted.length && i < segments.length; i++) {
|
|
253
|
+
if (wanted[i] === '*') continue;
|
|
254
|
+
if (!segments[i].startsWith('{')) continue;
|
|
255
|
+
segments[i] = wanted[i];
|
|
256
|
+
}
|
|
257
|
+
return `/${segments.join('/')}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function listParameters(specPaths: string[]): string {
|
|
261
|
+
const found = new Set<string>();
|
|
262
|
+
for (const specPath of specPaths) {
|
|
263
|
+
for (const segment of toSegments(specPath)) {
|
|
264
|
+
if (segment.startsWith('{')) found.add(segment);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return [...found].join(', ');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function toSegments(path: string): string[] {
|
|
271
|
+
return path.split('/').filter(Boolean);
|
|
272
|
+
}
|
|
273
|
+
|
|
169
274
|
function matchesEndpoint(specPath: string, endpoint: string): boolean {
|
|
170
275
|
if (specPath === endpoint) return true;
|
|
171
276
|
if (specPath.startsWith(`${endpoint}/`)) return true;
|
package/src/application-spec.ts
CHANGED
|
@@ -17,7 +17,7 @@ export class ApplicationSpec {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
renderFor(state: ActionResult): string {
|
|
20
|
-
const relevant = this.
|
|
20
|
+
const relevant = this.relevantPages(state);
|
|
21
21
|
if (relevant.length === 0) return '';
|
|
22
22
|
|
|
23
23
|
tag('operation').log(`Found application specification for ${state.url}`);
|
|
@@ -30,6 +30,10 @@ export class ApplicationSpec {
|
|
|
30
30
|
`;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
matchedUrls(state: ActionResult): string[] {
|
|
34
|
+
return this.relevantPages(state).map((page) => page.url);
|
|
35
|
+
}
|
|
36
|
+
|
|
33
37
|
get pageCount(): number {
|
|
34
38
|
return this.pages.length;
|
|
35
39
|
}
|
|
@@ -75,12 +79,26 @@ export class ApplicationSpec {
|
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
private resolveSourcePath(sourcePath: string): string {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
82
|
+
return resolveSpecSource(sourcePath);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private relevantPages(state: ActionResult): ApplicationSpecPage[] {
|
|
86
|
+
return this.pages.filter((page) => state.isMatchedBy({ url: page.url }));
|
|
81
87
|
}
|
|
82
88
|
}
|
|
83
89
|
|
|
90
|
+
export function resolveSpecBundlePath(sourcePath: string): string | null {
|
|
91
|
+
const resolved = resolveSpecSource(sourcePath);
|
|
92
|
+
if (!existsSync(resolved)) return null;
|
|
93
|
+
if (statSync(resolved).isDirectory()) return resolved;
|
|
94
|
+
return path.dirname(resolved);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function resolveSpecSource(sourcePath: string): string {
|
|
98
|
+
if (path.isAbsolute(sourcePath)) return path.resolve(sourcePath);
|
|
99
|
+
return path.resolve(ConfigParser.getInstance().resolveProjectDir(sourcePath));
|
|
100
|
+
}
|
|
101
|
+
|
|
84
102
|
interface ApplicationSpecPage {
|
|
85
103
|
url: string;
|
|
86
104
|
content: string;
|
|
@@ -15,7 +15,7 @@ export interface Suggestion {
|
|
|
15
15
|
hint: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export abstract class BaseCommand {
|
|
18
|
+
export abstract class BaseCommand<T = ExplorBot> {
|
|
19
19
|
abstract name: string;
|
|
20
20
|
abstract description: string;
|
|
21
21
|
aliases: string[] = [];
|
|
@@ -23,9 +23,9 @@ export abstract class BaseCommand {
|
|
|
23
23
|
tuiEnabled = true;
|
|
24
24
|
suggestions: Suggestion[] = [];
|
|
25
25
|
|
|
26
|
-
protected explorBot:
|
|
26
|
+
protected explorBot: T;
|
|
27
27
|
|
|
28
|
-
constructor(explorBot:
|
|
28
|
+
constructor(explorBot: T) {
|
|
29
29
|
this.explorBot = explorBot;
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -39,7 +39,7 @@ ${moduleExport}
|
|
|
39
39
|
`;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
function envTemplate(provider: string): string {
|
|
42
|
+
export function envTemplate(provider: string): string {
|
|
43
43
|
const keyLines = Object.entries(PROVIDERS).map(([name, { envKey }]) => {
|
|
44
44
|
if (name === provider) return `${envKey}=`;
|
|
45
45
|
return `# ${envKey}=`;
|
|
@@ -253,7 +253,7 @@ async function renderLocalProviderWizard(): Promise<string | null> {
|
|
|
253
253
|
});
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
-
function modelLines(provider: string): string {
|
|
256
|
+
export function modelLines(provider: string, only?: ModelRole[]): string {
|
|
257
257
|
const recommended = ConfigParser.recommendedModels()[provider] || {};
|
|
258
258
|
const roles: Array<[ModelRole, string]> = [
|
|
259
259
|
['model', 'fast model with tool calling capabilities'],
|
|
@@ -261,7 +261,10 @@ function modelLines(provider: string): string {
|
|
|
261
261
|
['agenticModel', 'agentic model for decision making'],
|
|
262
262
|
];
|
|
263
263
|
|
|
264
|
-
|
|
264
|
+
let selected = roles;
|
|
265
|
+
if (only) selected = roles.filter(([role]) => only.includes(role));
|
|
266
|
+
|
|
267
|
+
return selected.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`).join('\n');
|
|
265
268
|
}
|
|
266
269
|
|
|
267
270
|
function globalConfigTemplate(provider: string): string {
|
package/src/config.ts
CHANGED
|
@@ -130,6 +130,11 @@ interface RerunnerAgentConfig extends AgentConfig {
|
|
|
130
130
|
interface PlannerAgentConfig extends AgentConfig {
|
|
131
131
|
styles?: string[];
|
|
132
132
|
stylesDir?: string;
|
|
133
|
+
docsWeight?: number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface ScoutAgentConfig extends AgentConfig {
|
|
137
|
+
dirs?: string[];
|
|
133
138
|
}
|
|
134
139
|
|
|
135
140
|
interface ScreencastConfig {
|
|
@@ -154,6 +159,7 @@ interface AgentsConfig {
|
|
|
154
159
|
quartermaster?: AgentConfig;
|
|
155
160
|
historian?: HistorianAgentConfig;
|
|
156
161
|
fisherman?: AgentConfig;
|
|
162
|
+
scout?: ScoutAgentConfig;
|
|
157
163
|
chief?: AgentConfig;
|
|
158
164
|
curler?: AgentConfig;
|
|
159
165
|
rerunner?: RerunnerAgentConfig;
|
|
@@ -293,6 +299,7 @@ export type {
|
|
|
293
299
|
ResearcherAgentConfig,
|
|
294
300
|
NavigatorAgentConfig,
|
|
295
301
|
PlannerAgentConfig,
|
|
302
|
+
ScoutAgentConfig,
|
|
296
303
|
RerunnerAgentConfig,
|
|
297
304
|
HealRecipe,
|
|
298
305
|
Hook,
|
package/src/explorbot.ts
CHANGED
|
@@ -14,12 +14,15 @@ import { AIProvider } from './ai/provider.ts';
|
|
|
14
14
|
import { Quartermaster } from './ai/quartermaster.ts';
|
|
15
15
|
import { Rerunner } from './ai/rerunner.ts';
|
|
16
16
|
import { Researcher } from './ai/researcher.ts';
|
|
17
|
+
import { Scout } from './ai/scout.ts';
|
|
18
|
+
import { loadScoutCorpus } from './ai/scout/tools.ts';
|
|
17
19
|
import { SessionAnalyst } from './ai/session-analyst.ts';
|
|
18
20
|
import { Tester } from './ai/tester.ts';
|
|
19
21
|
import { createAgentTools } from './ai/tools.ts';
|
|
20
22
|
import { ApiClient } from './api/api-client.ts';
|
|
21
23
|
import { RequestStore } from './api/request-store.ts';
|
|
22
24
|
import { loadSpec } from './api/spec-reader.ts';
|
|
25
|
+
import { resolveSpecBundlePath } from './application-spec.ts';
|
|
23
26
|
import type { ExplorbotConfig, ReporterConfig } from './config.js';
|
|
24
27
|
import { ConfigParser } from './config.ts';
|
|
25
28
|
import { ExperienceTracker } from './experience-tracker.ts';
|
|
@@ -231,6 +234,8 @@ export class ExplorBot {
|
|
|
231
234
|
this.agents.planner = this.createAgent((deps) => new Planner(deps, this.agentResearcher()));
|
|
232
235
|
const fisherman = this.agentFisherman();
|
|
233
236
|
if (fisherman) this.agents.planner.setFisherman(fisherman);
|
|
237
|
+
const scout = this.agentScout();
|
|
238
|
+
if (scout) this.agents.planner.setScout(scout);
|
|
234
239
|
}
|
|
235
240
|
return this.agents.planner;
|
|
236
241
|
}
|
|
@@ -367,6 +372,37 @@ export class ExplorBot {
|
|
|
367
372
|
return this.agents.fisherman;
|
|
368
373
|
}
|
|
369
374
|
|
|
375
|
+
agentScout(): Scout | null {
|
|
376
|
+
const scoutConfig = this.config.ai?.agents?.scout;
|
|
377
|
+
if (scoutConfig?.enabled !== true) return null;
|
|
378
|
+
|
|
379
|
+
const dirs = this.scoutCorpusDirs();
|
|
380
|
+
if (dirs.length === 0) {
|
|
381
|
+
tag('warning').log('Scout enabled but no documentation found — set --spec or ai.agents.scout.dirs');
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return (this.agents.scout ||= this.createAgent(({ ai }) => new Scout(ai, loadScoutCorpus(dirs))));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private scoutCorpusDirs(): string[] {
|
|
389
|
+
const dirs: string[] = [];
|
|
390
|
+
|
|
391
|
+
const specPath = this.options.applicationSpec || this.config.dirs?.spec;
|
|
392
|
+
if (specPath) {
|
|
393
|
+
const bundle = resolveSpecBundlePath(specPath);
|
|
394
|
+
const pagesDir = bundle && path.join(bundle, 'pages');
|
|
395
|
+
if (pagesDir && existsSync(pagesDir)) dirs.push(pagesDir);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
for (const dir of this.config.ai?.agents?.scout?.dirs || []) {
|
|
399
|
+
const resolved = this.configParser.resolveProjectDir(dir);
|
|
400
|
+
if (existsSync(resolved)) dirs.push(resolved);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return [...new Set(dirs)];
|
|
404
|
+
}
|
|
405
|
+
|
|
370
406
|
getCurrentPlan(): Plan | undefined {
|
|
371
407
|
return this.currentPlan;
|
|
372
408
|
}
|
package/src/explorer.ts
CHANGED
|
@@ -263,7 +263,7 @@ class Explorer {
|
|
|
263
263
|
}
|
|
264
264
|
|
|
265
265
|
private convertToCodeceptConfig(config: ExplorbotConfig): any {
|
|
266
|
-
const playwrightConfig = { ...config.playwright };
|
|
266
|
+
const playwrightConfig = { visibleLocator: true, ...config.playwright };
|
|
267
267
|
|
|
268
268
|
if (this.options?.show !== undefined) {
|
|
269
269
|
playwrightConfig.show = this.options.show;
|
package/src/knowledge-tracker.ts
CHANGED
|
@@ -102,6 +102,10 @@ export class KnowledgeTracker {
|
|
|
102
102
|
return this.applicationSpec?.renderFor(state) || '';
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
applicationSpecUrls(state: ActionResult): string[] {
|
|
106
|
+
return this.applicationSpec?.matchedUrls(state) || [];
|
|
107
|
+
}
|
|
108
|
+
|
|
105
109
|
addKnowledge(urlPattern: string, description: string, opts?: { replace?: boolean }): { filename: string; filePath: string; isNewFile: boolean } {
|
|
106
110
|
const configParser = ConfigParser.getInstance();
|
|
107
111
|
const configPath = configParser.getConfigPath();
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { WebElement } from './web-element.ts';
|
|
2
|
+
|
|
3
|
+
const REF_LINE_PATTERN = /^(\s*)-\s+(\w+)\s*(?:"([^"]*)")?.*?\[ref=(e\d+)\]/;
|
|
4
|
+
const ARIA_REF_PATTERN = /^(f\d+)?e\d+$/i;
|
|
5
|
+
|
|
6
|
+
const REF_ROLES: Record<string, string> = { a: 'link', button: 'button', select: 'combobox', textarea: 'textbox' };
|
|
7
|
+
|
|
8
|
+
export function ariaRefSelector(ref: string): string {
|
|
9
|
+
return `aria-ref=${ref}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isAriaRef(ref: string): boolean {
|
|
13
|
+
return ARIA_REF_PATTERN.test(ref);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function ariaRefSnapshot(page: any): Promise<string> {
|
|
17
|
+
return page.locator('body').ariaSnapshot({ mode: 'ai' });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function parseAriaRefs(ariaSnapshot: string): AriaRefEntry[] {
|
|
21
|
+
const entries: AriaRefEntry[] = [];
|
|
22
|
+
for (const line of ariaSnapshot.split('\n')) {
|
|
23
|
+
const match = line.match(REF_LINE_PATTERN);
|
|
24
|
+
if (!match) continue;
|
|
25
|
+
entries.push({ role: match[2], name: match[3] || '', ref: match[4] });
|
|
26
|
+
}
|
|
27
|
+
return entries;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function elementFromAriaRef(page: any, ref: string): Promise<WebElement | null> {
|
|
31
|
+
if (!isAriaRef(ref)) return null;
|
|
32
|
+
return WebElement.fromPlaywrightLocator(page.locator(ariaRefSelector(ref)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function refIsGone(explorer: any, ref: string): Promise<boolean> {
|
|
36
|
+
const count = () => Promise.resolve(explorer?.withPage?.((page: any) => page.locator(ariaRefSelector(ref)).count())).catch(() => undefined);
|
|
37
|
+
if ((await count()) !== 0) return false;
|
|
38
|
+
await Promise.resolve(explorer?.withPage?.(ariaRefSnapshot)).catch(() => null);
|
|
39
|
+
return (await count()) === 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function describeRef(explorer: any, ref: string): Promise<{ role: string; text: string } | null> {
|
|
43
|
+
return Promise.resolve(
|
|
44
|
+
explorer?.withPage?.((page: any) =>
|
|
45
|
+
page.locator(ariaRefSelector(ref)).evaluate((el: any, roles: Record<string, string>) => {
|
|
46
|
+
const tag = el.tagName.toLowerCase();
|
|
47
|
+
const role = el.getAttribute('role') || roles[tag];
|
|
48
|
+
if (!role) return null;
|
|
49
|
+
const text = (el.getAttribute('aria-label') || el.innerText || el.value || '').trim().split('\n')[0];
|
|
50
|
+
if (!text) return null;
|
|
51
|
+
return { role, text };
|
|
52
|
+
}, REF_ROLES)
|
|
53
|
+
)
|
|
54
|
+
).catch(() => null);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AriaRefEntry {
|
|
58
|
+
role: string;
|
|
59
|
+
name: string;
|
|
60
|
+
ref: string;
|
|
61
|
+
}
|
package/src/utils/aria.ts
CHANGED
|
@@ -431,9 +431,9 @@ const formatDiffSection = (label: string, items: string[]): string[] => {
|
|
|
431
431
|
const summary = countBy(items);
|
|
432
432
|
if (summary.size === 0) return [` ${label}: []`];
|
|
433
433
|
|
|
434
|
-
const
|
|
435
|
-
const top =
|
|
436
|
-
const rest =
|
|
434
|
+
const ordered = Array.from(summary.entries());
|
|
435
|
+
const top = ordered.slice(0, TOP_DIFF_ITEMS);
|
|
436
|
+
const rest = ordered.slice(TOP_DIFF_ITEMS);
|
|
437
437
|
|
|
438
438
|
const lines = [` ${label}:`];
|
|
439
439
|
for (const [item, count] of top) {
|
package/src/utils/html-diff.ts
CHANGED
|
@@ -37,7 +37,7 @@ const IGNORED_PATHS = new Set(['html[1]', 'html[1]/head[1]', 'html[1]/body[1]'])
|
|
|
37
37
|
const SHELL_RATIO = 0.8;
|
|
38
38
|
const ROOT_CONTENT_RATIO = 0.8;
|
|
39
39
|
|
|
40
|
-
const LIVE_REGION_ROLES = new Set(['alert', 'alertdialog', 'status', 'log']);
|
|
40
|
+
const LIVE_REGION_ROLES = new Set(['alert', 'alertdialog', 'status', 'log', 'tooltip']);
|
|
41
41
|
const TEXT_LINE_PREFIX = 'TEXT:';
|
|
42
42
|
const MESSAGE_MAX_LENGTH = 200;
|
|
43
43
|
const MESSAGE_LIMIT = 8;
|
|
@@ -549,6 +549,8 @@ function semanticSelectorFor(element: ElementNode, allElements: NodeMap): string
|
|
|
549
549
|
while (current) {
|
|
550
550
|
const selector = buildContainerSelector(current, allElements);
|
|
551
551
|
if (selector) return selector;
|
|
552
|
+
const classAttr = (current.attrs ?? []).find((a) => a.name === 'class')?.value;
|
|
553
|
+
if (classAttr && filterContainerClasses(classAttr.split(/\s+/).filter(Boolean)).length > 0) return undefined;
|
|
552
554
|
current = dominantChild(current);
|
|
553
555
|
}
|
|
554
556
|
return undefined;
|
|
@@ -1,27 +1,15 @@
|
|
|
1
|
+
import { ariaRefSnapshot, parseAriaRefs } from './aria-ref.ts';
|
|
1
2
|
import { ELEMENT_EXTRACTION_CONFIG, getElementDataExtractorSource } from './html.ts';
|
|
2
3
|
import { createDebug } from './logger.js';
|
|
3
4
|
import { WebElement } from './web-element.ts';
|
|
4
5
|
|
|
5
6
|
const debugLog = createDebug('explorbot:web-annotate');
|
|
6
7
|
|
|
7
|
-
const REF_LINE_PATTERN = /^(\s*)-\s+(\w+)\s*(?:"([^"]*)")?.*?\[ref=(e\d+)\]/;
|
|
8
|
-
|
|
9
8
|
const ANNOTATABLE_ROLES = new Set(['button', 'link', 'textbox', 'searchbox', 'checkbox', 'radio', 'switch', 'combobox', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'slider', 'spinbutton', 'treeitem']);
|
|
10
9
|
|
|
11
|
-
function parseAriaRefs(ariaSnapshot: string): Array<{ role: string; name: string; ref: string }> {
|
|
12
|
-
const entries: Array<{ role: string; name: string; ref: string }> = [];
|
|
13
|
-
for (const line of ariaSnapshot.split('\n')) {
|
|
14
|
-
const match = line.match(REF_LINE_PATTERN);
|
|
15
|
-
if (!match) continue;
|
|
16
|
-
if (!ANNOTATABLE_ROLES.has(match[2])) continue;
|
|
17
|
-
entries.push({ role: match[2], name: match[3] || '', ref: match[4] });
|
|
18
|
-
}
|
|
19
|
-
return entries;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
10
|
export async function annotatePageElements(page: any): Promise<{ ariaSnapshot: string; elements: WebElement[] }> {
|
|
23
|
-
const ariaSnapshot: string = await page
|
|
24
|
-
const refEntries = parseAriaRefs(ariaSnapshot);
|
|
11
|
+
const ariaSnapshot: string = await ariaRefSnapshot(page);
|
|
12
|
+
const refEntries = parseAriaRefs(ariaSnapshot).filter((entry) => ANNOTATABLE_ROLES.has(entry.role));
|
|
25
13
|
|
|
26
14
|
const byRole = new Map<string, Array<{ name: string; ref: string }>>();
|
|
27
15
|
for (const { role, name, ref } of refEntries) {
|
package/src/utils/web-element.ts
CHANGED
|
@@ -125,15 +125,6 @@ export class WebElement {
|
|
|
125
125
|
return WebElement.fromPlaywrightLocator(page.locator(`[${EXPLORBOT_ATTRS.eidx}="${eidx}"]`));
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
static isAriaRef(ref: string): boolean {
|
|
129
|
-
return /^(f\d+)?e\d+$/i.test(ref);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
static async fromAriaRef(page: any, ref: string): Promise<WebElement | null> {
|
|
133
|
-
if (!WebElement.isAriaRef(ref)) return null;
|
|
134
|
-
return WebElement.fromPlaywrightLocator(page.locator(`aria-ref=${ref}`));
|
|
135
|
-
}
|
|
136
|
-
|
|
137
128
|
static async fromEidxList(page: any, eidxList: string[]): Promise<WebElement[]> {
|
|
138
129
|
const validEidxList = eidxList.filter((eidx) => /^e\d+$/i.test(eidx));
|
|
139
130
|
if (validEidxList.length === 0) return [];
|