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.
Files changed (51) hide show
  1. package/boat/prima/src/prima.ts +8 -3
  2. package/dist/boat/prima/src/prima.js +10 -3
  3. package/dist/package.json +1 -1
  4. package/dist/src/ai/fisherman/tools.js +7 -1
  5. package/dist/src/ai/fisherman.js +2 -1
  6. package/dist/src/ai/pilot.d.ts +0 -1
  7. package/dist/src/ai/pilot.js +8 -24
  8. package/dist/src/ai/planner.d.ts +4 -0
  9. package/dist/src/ai/planner.js +28 -0
  10. package/dist/src/ai/provider.js +3 -1
  11. package/dist/src/ai/rules.js +8 -7
  12. package/dist/src/ai/scout/tools.d.ts +17 -0
  13. package/dist/src/ai/scout/tools.js +130 -0
  14. package/dist/src/ai/scout.d.ts +21 -0
  15. package/dist/src/ai/scout.js +150 -0
  16. package/dist/src/ai/tools.js +56 -30
  17. package/dist/src/application-spec.d.ts +3 -0
  18. package/dist/src/application-spec.js +21 -5
  19. package/dist/src/config.d.ts +6 -1
  20. package/dist/src/explorbot.d.ts +3 -0
  21. package/dist/src/explorbot.js +33 -0
  22. package/dist/src/knowledge-tracker.d.ts +1 -0
  23. package/dist/src/knowledge-tracker.js +3 -0
  24. package/dist/src/utils/aria-ref.d.ts +16 -0
  25. package/dist/src/utils/aria-ref.js +47 -0
  26. package/dist/src/utils/aria.js +3 -3
  27. package/dist/src/utils/web-annotate.js +3 -15
  28. package/dist/src/utils/web-element.d.ts +0 -2
  29. package/dist/src/utils/web-element.js +0 -8
  30. package/docs/reference/configuration.md +28 -1
  31. package/docs/web-testing/agents.md +9 -1
  32. package/docs/web-testing/planner.md +5 -0
  33. package/docs/workflow/application-spec.md +4 -0
  34. package/package.json +1 -1
  35. package/src/ai/fisherman/tools.ts +8 -1
  36. package/src/ai/fisherman.ts +2 -1
  37. package/src/ai/pilot.ts +8 -25
  38. package/src/ai/planner.ts +33 -0
  39. package/src/ai/provider.ts +2 -1
  40. package/src/ai/rules.ts +8 -7
  41. package/src/ai/scout/tools.ts +150 -0
  42. package/src/ai/scout.ts +173 -0
  43. package/src/ai/tools.ts +69 -36
  44. package/src/application-spec.ts +22 -4
  45. package/src/config.ts +7 -0
  46. package/src/explorbot.ts +36 -0
  47. package/src/knowledge-tracker.ts +4 -0
  48. package/src/utils/aria-ref.ts +61 -0
  49. package/src/utils/aria.ts +3 -3
  50. package/src/utils/web-annotate.ts +3 -15
  51. package/src/utils/web-element.ts +0 -9
package/src/ai/tools.ts CHANGED
@@ -12,6 +12,7 @@ import { cleanHtmlSnippet } from '../utils/html.ts';
12
12
  import { createDebug, tag } from '../utils/logger.js';
13
13
  import { compactErrorMessage, normalizeInlineText, truncate } from '../utils/strings.ts';
14
14
  import { pause } from '../utils/loop.js';
15
+ import { ariaRefSelector, describeRef, refIsGone } from '../utils/aria-ref.ts';
15
16
  import { WebElement } from '../utils/web-element.ts';
16
17
  import type { ToolDeps } from './agent.ts';
17
18
  import { Navigator } from './navigator.ts';
@@ -66,7 +67,6 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
66
67
  2. I.click(ARIA, container) - e.g. I.click({"role":"button","text":"Save"}, ".modal")
67
68
  3. I.click(CSS, container) - e.g. I.click("#btn", ".modal")
68
69
  4. I.click(CSS) or I.click(XPath) - when locator already includes context (ID, XPath)
69
- 5. I.clickXY(x, y) - coordinates fallback
70
70
  After a result reporting multiple matches, reuse that locator with step.opts({ elementIndex: N }) as the last argument.
71
71
  `),
72
72
  explanation: z.string().describe('Why you are clicking this element'),
@@ -79,11 +79,21 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
79
79
  return failedToolResult('click', 'No commands provided');
80
80
  }
81
81
 
82
- const invalidCommands = rawCommands.map((cmd) => cmd.trim()).filter((cmd) => cmd.startsWith('I.') && !cmd.startsWith('I.click'));
82
+ const trimmedCommands = rawCommands.map((cmd) => cmd.trim());
83
+ const coordinateCommands = trimmedCommands.filter((cmd) => cmd.startsWith('I.clickXY'));
84
+
85
+ if (coordinateCommands.length > 0) {
86
+ activeNote.commit(TestResult.FAILED);
87
+ return failedToolResult('click', `Coordinate commands are not locators: ${coordinateCommands.join(', ')}. A coordinate click always runs, so it cannot tell you whether the element was there.`, {
88
+ suggestion: 'Name the element instead. Use visualClick() when you can see the target but cannot address it, or form() for a deliberate coordinate click such as dismissing a layer.',
89
+ });
90
+ }
91
+
92
+ const invalidCommands = trimmedCommands.filter((cmd) => cmd.startsWith('I.') && !cmd.startsWith('I.click'));
83
93
 
84
94
  if (invalidCommands.length > 0) {
85
95
  activeNote.commit(TestResult.FAILED);
86
- return failedToolResult('click', `Invalid commands: ${invalidCommands.join(', ')}. Click tool only accepts I.click() or I.clickXY() commands.`, {
96
+ return failedToolResult('click', `Invalid commands: ${invalidCommands.join(', ')}. Click tool only accepts I.click() commands.`, {
87
97
  suggestion: 'Use form() tool for typing text or multiple actions, or exitIframe() to leave iframe context.',
88
98
  });
89
99
  }
@@ -111,6 +121,17 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
111
121
 
112
122
  if (success) {
113
123
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, command);
124
+
125
+ if (!hasObservablePageChange(toolResult)) {
126
+ activeNote.commit(TestResult.FAILED);
127
+ return failedToolResult('click', 'Click executed, but no observable page change was captured.', {
128
+ ...toolResult,
129
+ attempts,
130
+ code: command,
131
+ suggestion: 'Treat the element as not clicked. It may be covered by another layer, disabled, or the locator may have matched a non-interactive ancestor. Re-locate via xpathCheck(), which reports whether the element is covered or offscreen, before retrying.',
132
+ });
133
+ }
134
+
114
135
  await commitNote(activeNote, TestResult.PASSED, toolResult, action);
115
136
  return successToolResult('click', { ...toolResult, attempts, code: command }, action);
116
137
  }
@@ -461,10 +482,18 @@ export function createRefTools({ explorer, stateManager }: ToolDeps, task: Task)
461
482
  }),
462
483
  execute: async ({ ref, element }) => {
463
484
  const activeNote = task.startNote(`Click ${element}`);
485
+
486
+ if (await refIsGone(explorer, ref)) {
487
+ activeNote.commit(TestResult.FAILED);
488
+ return failedToolResult('clickRef', `Ref ${ref} names no element on the page any more.`, {
489
+ 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.',
490
+ });
491
+ }
492
+
464
493
  const previousState = ActionResult.fromState(stateManager.getCurrentState()!);
465
494
  const action = explorer.action();
466
495
  const named = await describeRef(explorer, ref);
467
- const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(`aria-ref=${ref}`)}).click())`;
496
+ const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(ariaRefSelector(ref))}).click())`;
468
497
 
469
498
  if (!(await action.attempt(run, `Click ${element}`))) {
470
499
  activeNote.commit(TestResult.FAILED);
@@ -1169,21 +1198,6 @@ export async function commitNote(activeNote: any, result: TestResult, toolResult
1169
1198
  activeNote.commit(result);
1170
1199
  }
1171
1200
 
1172
- async function describeRef(explorer: any, ref: string): Promise<{ role: string; text: string } | null> {
1173
- return Promise.resolve(
1174
- explorer?.withPage?.((page: any) =>
1175
- page.locator(`aria-ref=${ref}`).evaluate((el: any) => {
1176
- const tag = el.tagName.toLowerCase();
1177
- const roles: Record<string, string> = { a: 'link', button: 'button', select: 'combobox', textarea: 'textbox' };
1178
- const role = el.getAttribute('role') || roles[tag] || tag;
1179
- const text = (el.getAttribute('aria-label') || el.innerText || el.value || '').trim().split('\n')[0];
1180
- if (!text) return null;
1181
- return { role, text };
1182
- })
1183
- )
1184
- ).catch(() => null);
1185
- }
1186
-
1187
1201
  async function hasFocusedElement(explorer: any): Promise<boolean> {
1188
1202
  return explorer.withPage((page: any) => page.evaluate(() => !!document.activeElement && document.activeElement !== document.body)).catch(() => true);
1189
1203
  }
@@ -1238,6 +1252,7 @@ function hasObservablePageChange(data?: Record<string, any>): boolean {
1238
1252
  if (data.pageDiff.urlChanged === true) return true;
1239
1253
  if (data.pageDiff.ariaChanges) return true;
1240
1254
  if (data.pageDiff.messages?.length) return true;
1255
+ if (data.pageDiff.requests?.length) return true;
1241
1256
  return Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
1242
1257
  }
1243
1258
 
@@ -1248,12 +1263,11 @@ export async function failedToolResult(action: string, message: string, data?: R
1248
1263
  }
1249
1264
 
1250
1265
  const errorTexts = [message, ...(data?.attempts?.map((a: any) => a.error || '') || [])];
1251
- const hasMultipleElements = errorTexts.some((t: string) => t.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN));
1252
- const multipleElementsSuggestion = hasMultipleElements ? getMultipleElementsSuggestion() : null;
1253
- if (multipleElementsSuggestion) {
1254
- result.suggestion = multipleElementsSuggestion;
1266
+ if (errorTexts.some((t: string) => t.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))) {
1267
+ const matched = await extractWebElements(error);
1268
+ result.suggestion = getMultipleElementsSuggestion(matched);
1255
1269
  result.multipleElementsDetected = true;
1256
- result.elements = await formatMatchedElements(error);
1270
+ result.elements = formatElementList(matched);
1257
1271
  return result;
1258
1272
  }
1259
1273
 
@@ -1266,11 +1280,16 @@ export async function failedToolResult(action: string, message: string, data?: R
1266
1280
  return result;
1267
1281
  }
1268
1282
 
1269
- function getMultipleElementsSuggestion(): string {
1283
+ function getMultipleElementsSuggestion(matched: MatchedElement[] | null): string {
1284
+ const visible = (matched || []).filter((element) => element.visible !== false);
1285
+ let onlyVisible = '';
1286
+ if (matched && visible.length === 1) onlyVisible = `\nOnly element ${matched.indexOf(visible[0]) + 1} is on screen, so that is the one to act on.`;
1287
+
1270
1288
  return dedent`
1271
1289
  Multiple elements matched your locator, so that command did nothing — it selected no element and acted on none.
1272
- Read the numbered elements list and click the one you meant by its number:
1290
+ Read the numbered elements list and act on the one you meant by its number:
1273
1291
  reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
1292
+ A match reported as not visible can never be acted on — pick one that is.${onlyVisible}
1274
1293
  If none of them is the element you want, narrow the locator with a container or its full unique text.
1275
1294
  If the list is missing, call xpathCheck() to see what the locator matches.
1276
1295
  `;
@@ -1294,7 +1313,7 @@ export function clickFailureSuggestion(attempts: Array<{ error?: string }>): str
1294
1313
  }
1295
1314
 
1296
1315
  if (errors.some((e) => e.includes('is not visible'))) {
1297
- return 'Element is in the DOM but not visible. Reveal it first — scroll to it, expand its section, or open the panel holding it.';
1316
+ return 'Element is in the DOM but not visible. Reveal it — scroll to it, expand its section, open the panel holding it — or, when the page carries several copies of the same control, target the one that is on screen.';
1298
1317
  }
1299
1318
 
1300
1319
  if (errors.some((e) => e.includes('SyntaxError'))) {
@@ -1319,19 +1338,20 @@ const MAX_DISAMBIGUATE_TEXT = 80;
1319
1338
  const MAX_DISAMBIGUATE_HTML = 300;
1320
1339
  const MULTIPLE_ELEMENTS_PATTERN = 'multiple elements';
1321
1340
 
1322
- async function extractWebElements(error: Error | null | undefined): Promise<Array<{ xpath: string; html: string; text: string }> | null> {
1341
+ async function extractWebElements(error: Error | null | undefined): Promise<MatchedElement[] | null> {
1323
1342
  if (!error || error.name !== 'MultipleElementsFound') return null;
1324
1343
 
1325
- const elements = (error as any).webElements as Array<{ toAbsoluteXPath: () => Promise<string>; toOuterHTML: () => Promise<string>; getText: () => Promise<string | null> }> | undefined;
1344
+ const elements = (error as any).webElements as Array<{ toAbsoluteXPath: () => Promise<string>; toOuterHTML: () => Promise<string>; getText: () => Promise<string | null>; isVisible?: () => Promise<boolean> }> | undefined;
1326
1345
  if (!elements?.length) return null;
1327
1346
 
1328
- const result: Array<{ xpath: string; html: string; text: string }> = [];
1347
+ const result: MatchedElement[] = [];
1329
1348
  for (let i = 0; i < Math.min(elements.length, MAX_DISAMBIGUATE_ELEMENTS); i++) {
1330
1349
  try {
1331
1350
  const xpath = await elements[i].toAbsoluteXPath();
1332
1351
  const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
1333
1352
  const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
1334
- result.push({ xpath, html, text });
1353
+ const visible = await Promise.resolve(elements[i].isVisible?.()).catch(() => undefined);
1354
+ result.push({ xpath, html, text, visible });
1335
1355
  } catch (e) {
1336
1356
  debugLog('Failed to get details for element %d: %s', i, e);
1337
1357
  }
@@ -1339,14 +1359,20 @@ async function extractWebElements(error: Error | null | undefined): Promise<Arra
1339
1359
  return result.length > 0 ? result : null;
1340
1360
  }
1341
1361
 
1342
- function formatElementList(details: Array<{ xpath: string; html: string; text: string }>): string {
1343
- return details.map((el, i) => `Element ${i + 1}:\nText: "${el.text}"\nXPath: ${el.xpath}\nHTML: ${el.html}`).join('\n\n');
1362
+ function formatElementList(matched: MatchedElement[] | null): string {
1363
+ if (!matched) return 'Could not fetch element details. Repeat the action to get better info.';
1364
+ return matched
1365
+ .map((el, i) => {
1366
+ const lines = [`Element ${i + 1}:`, `Text: "${el.text}"`];
1367
+ if (el.visible !== undefined) lines.push(`Visible: ${el.visible}`);
1368
+ lines.push(`XPath: ${el.xpath}`, `HTML: ${el.html}`);
1369
+ return lines.join('\n');
1370
+ })
1371
+ .join('\n\n');
1344
1372
  }
1345
1373
 
1346
1374
  export async function formatMatchedElements(error: Error | null | undefined): Promise<string | null> {
1347
- const details = await extractWebElements(error);
1348
- if (!details) return 'Could not fetch element details. Repeat the action to get better info.';
1349
- return formatElementList(details);
1375
+ return formatElementList(await extractWebElements(error));
1350
1376
  }
1351
1377
 
1352
1378
  function getNotFoundSuggestion(errorMessage: string): string | null {
@@ -1362,3 +1388,10 @@ function getNotFoundSuggestion(errorMessage: string): string | null {
1362
1388
  4. Prefer ARIA locators: { "role": "button", "text": "visible text" }
1363
1389
  `;
1364
1390
  }
1391
+
1392
+ interface MatchedElement {
1393
+ xpath: string;
1394
+ html: string;
1395
+ text: string;
1396
+ visible?: boolean;
1397
+ }
@@ -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;
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
  }
@@ -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) {
@@ -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 [];