explorbot 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/boat/api-tester/src/apibot.ts +18 -2
  2. package/boat/api-tester/src/cli.ts +85 -274
  3. package/boat/api-tester/src/commands/api-command.ts +10 -0
  4. package/boat/api-tester/src/commands/explore-command.ts +52 -0
  5. package/boat/api-tester/src/commands/init-command.ts +119 -0
  6. package/boat/api-tester/src/commands/know-command.ts +44 -0
  7. package/boat/api-tester/src/commands/plan-command.ts +42 -0
  8. package/boat/api-tester/src/commands/test-command.ts +54 -0
  9. package/boat/prima/src/prima.ts +8 -3
  10. package/dist/boat/api-tester/src/apibot.js +14 -1
  11. package/dist/boat/api-tester/src/cli.js +87 -243
  12. package/dist/boat/api-tester/src/commands/api-command.js +7 -0
  13. package/dist/boat/api-tester/src/commands/explore-command.js +41 -0
  14. package/dist/boat/api-tester/src/commands/init-command.js +88 -0
  15. package/dist/boat/api-tester/src/commands/know-command.js +39 -0
  16. package/dist/boat/api-tester/src/commands/plan-command.js +37 -0
  17. package/dist/boat/api-tester/src/commands/test-command.js +45 -0
  18. package/dist/boat/prima/src/prima.js +10 -3
  19. package/dist/package.json +4 -4
  20. package/dist/src/ai/fisherman/tools.js +7 -1
  21. package/dist/src/ai/fisherman.js +2 -1
  22. package/dist/src/ai/pilot.d.ts +0 -1
  23. package/dist/src/ai/pilot.js +8 -24
  24. package/dist/src/ai/planner.d.ts +4 -0
  25. package/dist/src/ai/planner.js +28 -0
  26. package/dist/src/ai/provider.js +3 -1
  27. package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
  28. package/dist/src/ai/researcher/deep-analysis.js +14 -6
  29. package/dist/src/ai/rules.js +8 -7
  30. package/dist/src/ai/scout/tools.d.ts +17 -0
  31. package/dist/src/ai/scout/tools.js +130 -0
  32. package/dist/src/ai/scout.d.ts +21 -0
  33. package/dist/src/ai/scout.js +150 -0
  34. package/dist/src/ai/tools.d.ts +1 -1
  35. package/dist/src/ai/tools.js +62 -31
  36. package/dist/src/api/spec-reader.d.ts +1 -0
  37. package/dist/src/api/spec-reader.js +93 -1
  38. package/dist/src/application-spec.d.ts +3 -0
  39. package/dist/src/application-spec.js +21 -5
  40. package/dist/src/commands/base-command.d.ts +3 -3
  41. package/dist/src/commands/init-command.d.ts +3 -0
  42. package/dist/src/commands/init-command.js +6 -3
  43. package/dist/src/config.d.ts +6 -1
  44. package/dist/src/explorbot.d.ts +3 -0
  45. package/dist/src/explorbot.js +33 -0
  46. package/dist/src/explorer.d.ts +1 -1
  47. package/dist/src/explorer.js +1 -1
  48. package/dist/src/knowledge-tracker.d.ts +1 -0
  49. package/dist/src/knowledge-tracker.js +3 -0
  50. package/dist/src/utils/aria-ref.d.ts +16 -0
  51. package/dist/src/utils/aria-ref.js +47 -0
  52. package/dist/src/utils/aria.js +3 -3
  53. package/dist/src/utils/html-diff.js +4 -1
  54. package/dist/src/utils/web-annotate.js +3 -15
  55. package/dist/src/utils/web-element.d.ts +0 -2
  56. package/dist/src/utils/web-element.js +0 -8
  57. package/docs/api-testing/basics.md +26 -2
  58. package/docs/reference/configuration.md +28 -1
  59. package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +317 -0
  60. package/docs/web-testing/agents.md +9 -1
  61. package/docs/web-testing/planner.md +5 -0
  62. package/docs/workflow/application-spec.md +4 -0
  63. package/package.json +4 -4
  64. package/src/ai/fisherman/tools.ts +8 -1
  65. package/src/ai/fisherman.ts +2 -1
  66. package/src/ai/pilot.ts +8 -25
  67. package/src/ai/planner.ts +33 -0
  68. package/src/ai/provider.ts +2 -1
  69. package/src/ai/researcher/deep-analysis.ts +13 -6
  70. package/src/ai/rules.ts +8 -7
  71. package/src/ai/scout/tools.ts +150 -0
  72. package/src/ai/scout.ts +173 -0
  73. package/src/ai/tools.ts +75 -38
  74. package/src/api/spec-reader.ts +106 -1
  75. package/src/application-spec.ts +22 -4
  76. package/src/commands/base-command.ts +3 -3
  77. package/src/commands/init-command.ts +6 -3
  78. package/src/config.ts +7 -0
  79. package/src/explorbot.ts +36 -0
  80. package/src/explorer.ts +1 -1
  81. package/src/knowledge-tracker.ts +4 -0
  82. package/src/utils/aria-ref.ts +61 -0
  83. package/src/utils/aria.ts +3 -3
  84. package/src/utils/html-diff.ts +3 -1
  85. package/src/utils/web-annotate.ts +3 -15
  86. package/src/utils/web-element.ts +0 -9
@@ -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 = collectMatchingPaths(schema, basePath, (normalized) => matchesEndpoint(normalized, endpoint));
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;
@@ -17,7 +17,7 @@ export class ApplicationSpec {
17
17
  }
18
18
 
19
19
  renderFor(state: ActionResult): string {
20
- const relevant = this.pages.filter((page) => state.isMatchedBy({ url: page.url }));
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
- if (path.isAbsolute(sourcePath)) return path.resolve(sourcePath);
79
- const configParser = ConfigParser.getInstance();
80
- return path.resolve(configParser.resolveProjectDir(sourcePath));
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: ExplorBot;
26
+ protected explorBot: T;
27
27
 
28
- constructor(explorBot: 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
- return roles.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`).join('\n');
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;
@@ -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 sorted = Array.from(summary.entries()).sort(([aItem, aCount], [bItem, bCount]) => bCount - aCount || aItem.localeCompare(bItem));
435
- const top = sorted.slice(0, TOP_DIFF_ITEMS);
436
- const rest = sorted.slice(TOP_DIFF_ITEMS);
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) {
@@ -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.locator('body').ariaSnapshot({ mode: 'ai' });
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) {
@@ -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 [];