explorbot 0.4.5 → 0.4.7

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 (85) hide show
  1. package/boat/api-tester/src/ai/curler.ts +70 -66
  2. package/boat/api-tester/src/apibot.ts +19 -2
  3. package/boat/api-tester/src/cli.ts +87 -274
  4. package/boat/api-tester/src/commands/api-command.ts +10 -0
  5. package/boat/api-tester/src/commands/explore-command.ts +52 -0
  6. package/boat/api-tester/src/commands/init-command.ts +119 -0
  7. package/boat/api-tester/src/commands/know-command.ts +44 -0
  8. package/boat/api-tester/src/commands/plan-command.ts +42 -0
  9. package/boat/api-tester/src/commands/test-command.ts +54 -0
  10. package/boat/api-tester/src/config.ts +18 -1
  11. package/dist/boat/api-tester/src/ai/curler.js +55 -56
  12. package/dist/boat/api-tester/src/apibot.js +15 -1
  13. package/dist/boat/api-tester/src/cli.js +89 -243
  14. package/dist/boat/api-tester/src/commands/api-command.js +7 -0
  15. package/dist/boat/api-tester/src/commands/explore-command.js +41 -0
  16. package/dist/boat/api-tester/src/commands/init-command.js +88 -0
  17. package/dist/boat/api-tester/src/commands/know-command.js +39 -0
  18. package/dist/boat/api-tester/src/commands/plan-command.js +37 -0
  19. package/dist/boat/api-tester/src/commands/test-command.js +45 -0
  20. package/dist/boat/api-tester/src/config.js +3 -1
  21. package/dist/package.json +4 -4
  22. package/dist/rules/researcher/pagination.md +6 -0
  23. package/dist/src/action-result.d.ts +6 -0
  24. package/dist/src/action-result.js +12 -0
  25. package/dist/src/ai/planner.js +4 -0
  26. package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
  27. package/dist/src/ai/researcher/deep-analysis.js +14 -6
  28. package/dist/src/ai/researcher/locators.js +1 -1
  29. package/dist/src/ai/researcher/pagination.d.ts +16 -0
  30. package/dist/src/ai/researcher/pagination.js +62 -0
  31. package/dist/src/ai/researcher/parser.d.ts +3 -0
  32. package/dist/src/ai/researcher/parser.js +22 -6
  33. package/dist/src/ai/researcher/sections.js +1 -1
  34. package/dist/src/ai/researcher.js +7 -2
  35. package/dist/src/ai/rules.js +16 -0
  36. package/dist/src/ai/scout.js +8 -2
  37. package/dist/src/ai/tools.d.ts +1 -1
  38. package/dist/src/ai/tools.js +25 -13
  39. package/dist/src/api/spec-reader.d.ts +1 -0
  40. package/dist/src/api/spec-reader.js +93 -1
  41. package/dist/src/commands/base-command.d.ts +3 -3
  42. package/dist/src/commands/init-command.d.ts +3 -0
  43. package/dist/src/commands/init-command.js +6 -3
  44. package/dist/src/commands/options/ws-option.d.ts +7 -0
  45. package/dist/src/commands/options/ws-option.js +14 -0
  46. package/dist/src/config.d.ts +1 -0
  47. package/dist/src/config.js +14 -11
  48. package/dist/src/explorer.d.ts +1 -1
  49. package/dist/src/explorer.js +1 -1
  50. package/dist/src/remote.d.ts +2 -0
  51. package/dist/src/remote.js +23 -16
  52. package/dist/src/utils/aria.d.ts +2 -0
  53. package/dist/src/utils/aria.js +6 -1
  54. package/dist/src/utils/html-diff.js +4 -1
  55. package/dist/src/utils/markdown-query.d.ts +2 -0
  56. package/dist/src/utils/markdown-query.js +39 -0
  57. package/dist/src/utils/pagination.d.ts +16 -0
  58. package/dist/src/utils/pagination.js +20 -0
  59. package/docs/api-testing/basics.md +26 -2
  60. package/docs/superpowers/plans/2026-09-10-pagination.md +1420 -0
  61. package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +345 -0
  62. package/package.json +4 -4
  63. package/rules/researcher/pagination.md +6 -0
  64. package/src/action-result.ts +16 -0
  65. package/src/ai/planner.ts +4 -0
  66. package/src/ai/researcher/deep-analysis.ts +13 -6
  67. package/src/ai/researcher/locators.ts +1 -1
  68. package/src/ai/researcher/pagination.ts +68 -0
  69. package/src/ai/researcher/parser.ts +23 -5
  70. package/src/ai/researcher/sections.ts +1 -1
  71. package/src/ai/researcher.ts +9 -3
  72. package/src/ai/rules.ts +16 -0
  73. package/src/ai/scout.ts +9 -2
  74. package/src/ai/tools.ts +22 -14
  75. package/src/api/spec-reader.ts +106 -1
  76. package/src/commands/base-command.ts +3 -3
  77. package/src/commands/init-command.ts +6 -3
  78. package/src/commands/options/ws-option.ts +14 -0
  79. package/src/config.ts +15 -11
  80. package/src/explorer.ts +1 -1
  81. package/src/remote.ts +22 -15
  82. package/src/utils/aria.ts +8 -1
  83. package/src/utils/html-diff.ts +3 -1
  84. package/src/utils/markdown-query.ts +39 -0
  85. package/src/utils/pagination.ts +36 -0
@@ -24,6 +24,7 @@ import { type CoordinateMethods, WithCoordinates } from './researcher/coordinate
24
24
  import { type DeepAnalysisMethods, WithDeepAnalysis } from './researcher/deep-analysis.ts';
25
25
  import { detectFocusedSection, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from './researcher/focus.ts';
26
26
  import { type LocatorMethods, WithLocators } from './researcher/locators.ts';
27
+ import { type PaginationMethods, WithPagination } from './researcher/pagination.ts';
27
28
  import { extractValidContainers, formatResearchSummary, parseResearchSections } from './researcher/parser.ts';
28
29
  import { ResearchResult } from './researcher/research-result.ts';
29
30
  import { type SectionMethods, WithSections } from './researcher/sections.ts';
@@ -44,9 +45,9 @@ export const POSSIBLE_SECTIONS = {
44
45
  navigation: 'main navigation (top bar, sidebar, breadcrumbs)',
45
46
  };
46
47
 
47
- const ResearcherBase = WithSections(WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent as unknown as new (...args: any[]) => TaskAgent))));
48
+ const ResearcherBase = WithSections(WithPagination(WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent as unknown as new (...args: any[]) => TaskAgent)))));
48
49
 
49
- export interface Researcher extends LocatorMethods, CoordinateMethods, DeepAnalysisMethods, SectionMethods {}
50
+ export interface Researcher extends LocatorMethods, CoordinateMethods, DeepAnalysisMethods, SectionMethods, PaginationMethods {}
50
51
 
51
52
  export class Researcher extends ResearcherBase implements Agent {
52
53
  protected readonly ACTION_TOOLS = ['click'];
@@ -277,6 +278,10 @@ export class Researcher extends ResearcherBase implements Agent {
277
278
  await this.backfillBrokenLocators(result);
278
279
  }
279
280
 
281
+ if (!interrupted()) {
282
+ await this.detectPagination(result);
283
+ }
284
+
280
285
  // Focused section: final fallback (vision-only — without a screenshot we don't infer focus)
281
286
  if (this.hasScreenshotToAnalyze && !hasFocusedSection(result.text)) {
282
287
  const sections = parseResearchSections(result.text);
@@ -426,7 +431,7 @@ export class Researcher extends ResearcherBase implements Agent {
426
431
 
427
432
  ${generalLocatorRuleText}
428
433
 
429
- ${RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules'], currentUrl)}
434
+ ${RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules', 'pagination'], currentUrl)}
430
435
 
431
436
  <section_identification>
432
437
  Identify page sections in this priority order:
@@ -502,6 +507,7 @@ export class Researcher extends ResearcherBase implements Agent {
502
507
  - When a section contains a list of similar data items (records, entities, rows — content that varies by data, not by app UI), output it as a Data section with NO table.
503
508
  - Data section heading MUST be a level-2 heading (##) that starts exactly with "Data:" — for example: "## Data: Suites List". Do NOT use ### or add section numbers.
504
509
  - Data sections must NOT include a UI map table. Only include the container and a brief summary line.
510
+ - When the data list has controls that move between pages of the collection, add "> Pagination: controls" under its container.
505
511
  - Example data section:
506
512
 
507
513
  ## Data: Suites List
package/src/ai/rules.ts CHANGED
@@ -349,6 +349,22 @@ export const actionRule = dedent`
349
349
  For checkboxes, prefer I.checkOption/I.uncheckOption over I.click.
350
350
 
351
351
 
352
+ ### I.scrollTo
353
+
354
+ scrolls until the element is in view
355
+
356
+ I.scrollTo(<locator>)
357
+
358
+ Scrolls every scrollable ancestor of the target, so it reaches an element inside a container
359
+ that has its own scrollbar. I.scrollPageToBottom() moves only the page itself.
360
+
361
+ <example>
362
+ I.scrollTo('.rows > *:last-child');
363
+ I.scrollTo({ role: 'listitem', text: 'Last entry' });
364
+ I.scrollPageToBottom();
365
+ </example>
366
+
367
+
352
368
  ### I.fillField
353
369
 
354
370
  fills the field with the given value
package/src/ai/scout.ts CHANGED
@@ -56,13 +56,20 @@ export class Scout implements Agent {
56
56
  agentName: 'scout',
57
57
  });
58
58
 
59
+ const responseText = invokeResult?.response?.text;
60
+ if (responseText?.trim()) {
61
+ finishFromText(responseText);
62
+ stop();
63
+ return;
64
+ }
65
+
59
66
  if (!invokeResult?.toolExecutions?.length) {
60
- finishFromText(invokeResult?.response?.text);
61
67
  stop();
62
68
  return;
63
69
  }
64
70
 
65
- if (iteration >= MAX_ITERATIONS) {
71
+ if (iteration >= MAX_ITERATIONS - 1) {
72
+ conversation.addUserText('Exploration time is over. Report your findings now as your final message.');
66
73
  const final = await this.provider.invokeConversation(conversation, undefined, { agentName: 'scout' });
67
74
  finishFromText(final?.response?.text);
68
75
  stop();
package/src/ai/tools.ts CHANGED
@@ -1,18 +1,18 @@
1
1
  import { tool } from 'ai';
2
2
  import dedent from 'dedent';
3
3
  import { z } from 'zod';
4
- import type { ExecutedStep } from '../action.ts';
5
4
  import { ActionResult, type PageDiff, type ToolResultMetadata } from '../action-result.ts';
5
+ import type { ExecutedStep } from '../action.ts';
6
6
  import { type ExperienceTracker, renderExperienceRecipes } from '../experience-tracker.ts';
7
7
  import { Stats } from '../stats.ts';
8
8
  import { type Task, TestResult } from '../test-plan.js';
9
+ import { ariaRefSelector, describeRef, refIsGone } from '../utils/aria-ref.ts';
9
10
  import { LARGE_ARIA_CHANGE_THRESHOLD } from '../utils/aria.ts';
10
11
  import { isFatalBrowserError } from '../utils/browser-errors.ts';
11
12
  import { cleanHtmlSnippet } from '../utils/html.ts';
12
13
  import { createDebug, tag } from '../utils/logger.js';
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
+ import { compactErrorMessage, normalizeInlineText, truncate } from '../utils/strings.ts';
16
16
  import { WebElement } from '../utils/web-element.ts';
17
17
  import type { ToolDeps } from './agent.ts';
18
18
  import { Navigator } from './navigator.ts';
@@ -133,7 +133,13 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
133
133
  }
134
134
 
135
135
  await commitNote(activeNote, TestResult.PASSED, toolResult, action);
136
- return successToolResult('click', { ...toolResult, attempts, code: command }, action);
136
+ const data: Record<string, any> = { ...toolResult, attempts, code: command };
137
+ const notExecuted = commands.slice(i + 1);
138
+ if (notExecuted.length) {
139
+ data.notExecuted = notExecuted;
140
+ data.suggestion = `SKIPPED: ${notExecuted.join('; ')}`;
141
+ }
142
+ return successToolResult('click', data, action);
137
143
  }
138
144
  }
139
145
 
@@ -368,6 +374,7 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
368
374
  - Working with iframes (switch context with I.switchTo)
369
375
  - Performing multiple form actions in a single batch
370
376
  - Complex interactions requiring sequential commands
377
+ - Reaching items further down a list (I.scrollTo)
371
378
 
372
379
  Example - filling a form with context (PREFERRED):
373
380
  I.fillField('Username', 'John', '.login-form')
@@ -435,10 +442,10 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
435
442
 
436
443
  if (!hasObservablePageChange(toolResult)) {
437
444
  activeNote.commit(TestResult.FAILED);
438
- return failedToolResult('form', 'Form command executed, but no observable page or form-state change was captured.', {
445
+ return failedToolResult('form', 'Command executed, but nothing on the page changed: no navigation, no ARIA change, no HTML change and no request.', {
439
446
  ...toolResult,
440
447
  code: codeBlock,
441
- suggestion: 'Treat the field/form action as not completed. Re-locate the editable control, check whether another UI layer is active, then retry and verify the field value before submitting.',
448
+ suggestion: 'The command ran without reaching anything. Re-locate the target, check whether another UI layer is active, then retry. If the goal was to load more of a list, no change means the collection has ended.',
442
449
  });
443
450
  }
444
451
  await commitNote(activeNote, TestResult.PASSED, toolResult, action);
@@ -1232,7 +1239,10 @@ export function successToolResult(action: string, data?: Record<string, any>, so
1232
1239
  }
1233
1240
 
1234
1241
  export function isMajorPageChange(pageDiff: PageDiff): boolean {
1235
- return pageDiff.urlChanged !== true && (pageDiff.ariaChangeCount ?? 0) >= LARGE_ARIA_CHANGE_THRESHOLD;
1242
+ if (pageDiff.urlChanged === true) return false;
1243
+ if ((pageDiff.ariaChangeCount ?? 0) < LARGE_ARIA_CHANGE_THRESHOLD) return false;
1244
+ if (pageDiff.ariaRemoved === 0 && (pageDiff.ariaAdded ?? 0) > 0) return false;
1245
+ return true;
1236
1246
  }
1237
1247
 
1238
1248
  export function hasFailedRequest(pageDiff: PageDiff): boolean {
@@ -1265,7 +1275,7 @@ export async function failedToolResult(action: string, message: string, data?: R
1265
1275
  const errorTexts = [message, ...(data?.attempts?.map((a: any) => a.error || '') || [])];
1266
1276
  if (errorTexts.some((t: string) => t.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))) {
1267
1277
  const matched = await extractWebElements(error);
1268
- result.suggestion = getMultipleElementsSuggestion(matched);
1278
+ result.suggestion = getMultipleElementsSuggestion();
1269
1279
  result.multipleElementsDetected = true;
1270
1280
  result.elements = formatElementList(matched);
1271
1281
  return result;
@@ -1280,16 +1290,12 @@ export async function failedToolResult(action: string, message: string, data?: R
1280
1290
  return result;
1281
1291
  }
1282
1292
 
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
-
1293
+ function getMultipleElementsSuggestion(): string {
1288
1294
  return dedent`
1289
1295
  Multiple elements matched your locator, so that command did nothing — it selected no element and acted on none.
1290
1296
  Read the numbered elements list and act on the one you meant by its number:
1291
1297
  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}
1298
+ A match reported as not visible can never be acted on — pick one that is.
1293
1299
  If none of them is the element you want, narrow the locator with a container or its full unique text.
1294
1300
  If the list is missing, call xpathCheck() to see what the locator matches.
1295
1301
  `;
@@ -1365,6 +1371,8 @@ function formatElementList(matched: MatchedElement[] | null): string {
1365
1371
  .map((el, i) => {
1366
1372
  const lines = [`Element ${i + 1}:`, `Text: "${el.text}"`];
1367
1373
  if (el.visible !== undefined) lines.push(`Visible: ${el.visible}`);
1374
+ const wrapped = matched.map((_, j) => j).filter((j) => j !== i && matched[j].xpath.startsWith(`${el.xpath}/`));
1375
+ if (wrapped.length) lines.push(`Wraps: element ${wrapped.map((j) => j + 1).join(', ')}`);
1368
1376
  lines.push(`XPath: ${el.xpath}`, `HTML: ${el.html}`);
1369
1377
  return lines.join('\n');
1370
1378
  })
@@ -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;
@@ -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 {
@@ -6,6 +6,20 @@ export class WsOption extends BaseOption {
6
6
  flags = '--ws <url>';
7
7
  description = 'Stream this run to a remote UI over WebSocket';
8
8
 
9
+ /**
10
+ * An open socket holds the event loop, and `WebSocket` has no `unref` on
11
+ * either runtime — so a command that ends by returning rather than through
12
+ * `showStatsAndExit` would never exit once it is attached. The option that
13
+ * opened the connection is what closes it.
14
+ */
15
+ override register(command: Command): void {
16
+ super.register(command);
17
+ command.hook('postAction', async () => {
18
+ if (!remote.isAttached()) return;
19
+ await remote.close(0);
20
+ });
21
+ }
22
+
9
23
  protected apply(options: Record<string, any>, command: Command): void {
10
24
  const url = options.ws || process.env.EXPLORBOT_WS_URL;
11
25
  if (!url) return;
package/src/config.ts CHANGED
@@ -677,17 +677,7 @@ export class ConfigParser {
677
677
  config.playwright.url = options.baseUrl;
678
678
  }
679
679
 
680
- if (config.ai) {
681
- const langfuse = config.ai.langfuse;
682
- const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
683
- const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
684
- config.ai.langfuse = {
685
- enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
686
- publicKey,
687
- secretKey,
688
- baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
689
- };
690
- }
680
+ resolveLangfuse(config.ai);
691
681
 
692
682
  return config;
693
683
  }
@@ -857,6 +847,20 @@ export async function resolveConfigModels(ai?: AIConfig): Promise<void> {
857
847
  }
858
848
  }
859
849
 
850
+ export function resolveLangfuse(ai?: AIConfig): void {
851
+ if (!ai) return;
852
+
853
+ const langfuse = ai.langfuse;
854
+ const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
855
+ const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
856
+ ai.langfuse = {
857
+ enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
858
+ publicKey,
859
+ secretKey,
860
+ baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
861
+ };
862
+ }
863
+
860
864
  export function resolveOutputRoot(baseUrl?: string): string {
861
865
  if (cachedOutputRoot) return cachedOutputRoot;
862
866
 
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/remote.ts CHANGED
@@ -28,6 +28,7 @@ export class Remote implements LogDestination {
28
28
  private asks = new Map<string, (value: string | null) => void>();
29
29
  private askCounter = 0;
30
30
  private lastActivity: string | null = null;
31
+ private closing: Promise<void> | null = null;
31
32
 
32
33
  attach(url: string, command: string): void {
33
34
  if (this.url) return;
@@ -73,21 +74,10 @@ export class Remote implements LogDestination {
73
74
  });
74
75
  }
75
76
 
76
- async close(exitCode: number): Promise<void> {
77
- if (!this.url) return;
78
- this.send('result', { ok: exitCode === 0, exitCode });
79
- await this.flush();
80
-
81
- this.url = null;
82
- if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
83
- // Whoever asks next has nobody to ask — leaving the callback installed would
84
- // route them into a closed socket and park them until the ask times out.
85
- executionController.clearInputCallback();
86
- for (const resolve of this.asks.values()) resolve(null);
87
- this.asks.clear();
88
- this.queue = [];
89
- this.socket?.close();
90
- this.socket = null;
77
+ close(exitCode: number): Promise<void> {
78
+ if (!this.url) return Promise.resolve();
79
+ if (!this.closing) this.closing = this.shutdown(exitCode);
80
+ return this.closing;
91
81
  }
92
82
 
93
83
  isEnabled(): boolean {
@@ -116,6 +106,23 @@ export class Remote implements LogDestination {
116
106
  });
117
107
  }
118
108
 
109
+ private async shutdown(exitCode: number): Promise<void> {
110
+ this.send('result', { ok: exitCode === 0, exitCode });
111
+ await this.flush();
112
+
113
+ this.url = null;
114
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
115
+ // Whoever asks next has nobody to ask — leaving the callback installed would
116
+ // route them into a closed socket and park them until the ask times out.
117
+ executionController.clearInputCallback();
118
+ for (const resolve of this.asks.values()) resolve(null);
119
+ this.asks.clear();
120
+ this.queue = [];
121
+ this.socket?.close();
122
+ this.socket = null;
123
+ this.closing = null;
124
+ }
125
+
119
126
  private connect(): void {
120
127
  if (!this.url) return;
121
128
 
package/src/utils/aria.ts CHANGED
@@ -521,7 +521,12 @@ export const diffAriaSnapshots = (previous: string | null, current: string | nul
521
521
  const renames = detectRenames(prev, curr, prevTotals, currTotals);
522
522
  const added = [...byCount.added, ...renames.added];
523
523
  const removed = [...byCount.removed, ...renames.removed];
524
- return { text: formatDiff(added, removed, toggled, typed), count: added.length + removed.length + toggled.length + typed.length };
524
+ return {
525
+ text: formatDiff(added, removed, toggled, typed),
526
+ count: added.length + removed.length + toggled.length + typed.length,
527
+ added: added.length,
528
+ removed: removed.length,
529
+ };
525
530
  };
526
531
 
527
532
  export const detectFocusArea = (snapshot: string | null): FocusAreaResult => {
@@ -587,6 +592,8 @@ export const LARGE_ARIA_CHANGE_THRESHOLD = 50;
587
592
  export interface AriaDiff {
588
593
  text: string | null;
589
594
  count: number;
595
+ added: number;
596
+ removed: number;
590
597
  }
591
598
 
592
599
  type AriaNode = {
@@ -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;
@@ -162,6 +162,12 @@ function matchText(text: string, matcher: TextMatcher): boolean {
162
162
  return matcher.negated ? !result : result;
163
163
  }
164
164
 
165
+ function entryKey(line: string): string | null {
166
+ const separator = line.indexOf(':');
167
+ if (separator < 1) return null;
168
+ return line.slice(0, separator).trim().toLowerCase();
169
+ }
170
+
165
171
  function getTokenText(token: Token): string {
166
172
  const t = token as any;
167
173
  switch (token.type) {
@@ -407,6 +413,39 @@ export class MarkdownQuery {
407
413
  return results;
408
414
  }
409
415
 
416
+ keyValue(): Record<string, string> {
417
+ const entries: Record<string, string> = {};
418
+
419
+ for (const range of this.matches) {
420
+ for (const line of getTokenText(range.token).split('\n')) {
421
+ const key = entryKey(line);
422
+ if (!key) continue;
423
+ const value = line.slice(line.indexOf(':') + 1).trim();
424
+ if (value) entries[key] = value;
425
+ }
426
+ }
427
+
428
+ return entries;
429
+ }
430
+
431
+ setKeyValue(key: string, value: string | null): string {
432
+ return this.replaceEach((match) => {
433
+ const token = match.matches[0].token;
434
+ const lines = getTokenText(token)
435
+ .split('\n')
436
+ .map((line) => line.trim())
437
+ .filter(Boolean);
438
+
439
+ const index = lines.findIndex((line) => entryKey(line) === key.toLowerCase());
440
+ if (index < 0 && value) lines.push(`${key}: ${value}`);
441
+ if (index >= 0 && value) lines[index] = `${key}: ${value}`;
442
+ if (index >= 0 && !value) lines.splice(index, 1);
443
+
444
+ if (token.type !== 'blockquote') return lines.join('\n');
445
+ return lines.map((line) => `> ${line}`).join('\n');
446
+ });
447
+ }
448
+
410
449
  replace(content: string): string {
411
450
  return this.replaceEach(() => content);
412
451
  }
@@ -0,0 +1,36 @@
1
+ export function inspectList(css: string): ListMeasure | null {
2
+ const element = document.querySelector(css);
3
+ if (!element) return null;
4
+ const rect = element.getBoundingClientRect();
5
+ return {
6
+ hasPagingControls: !!element.querySelector('a[rel="next"], a[rel="prev"]'),
7
+ isFeed: element.matches('[role="feed"]') || !!element.querySelector('[role="feed"]'),
8
+ scrolls: element.scrollHeight > element.clientHeight + 1 || rect.bottom > window.innerHeight,
9
+ items: element.querySelectorAll(':scope > *').length,
10
+ scrollTop: element.scrollTop,
11
+ pageScrollY: window.scrollY,
12
+ };
13
+ }
14
+
15
+ export function restoreScroll({ css, scrollTop, pageScrollY }: ScrollPosition): void {
16
+ const element = document.querySelector(css);
17
+ if (element) element.scrollTop = scrollTop;
18
+ window.scrollTo(0, pageScrollY);
19
+ }
20
+
21
+ export type PaginationStrategy = 'controls' | 'infinite';
22
+
23
+ export interface ListMeasure {
24
+ hasPagingControls: boolean;
25
+ isFeed: boolean;
26
+ scrolls: boolean;
27
+ items: number;
28
+ scrollTop: number;
29
+ pageScrollY: number;
30
+ }
31
+
32
+ export interface ScrollPosition {
33
+ css: string;
34
+ scrollTop: number;
35
+ pageScrollY: number;
36
+ }