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
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -101,7 +101,7 @@
101
101
  "bash-tool": "^1.3.15",
102
102
  "chalk": "^5.6.2",
103
103
  "cli-highlight": "^2.1.11",
104
- "codeceptjs": "4.0.0-rc.16",
104
+ "codeceptjs": "^4.2.0-beta.3",
105
105
  "commander": "^14.0.1",
106
106
  "debug": "^4.4.3",
107
107
  "dedent": "^1.6.0",
@@ -123,8 +123,8 @@
123
123
  "ora-classic": "^5.4.2",
124
124
  "parse5": "^8.0.0",
125
125
  "pixelmatch": "^7.2.0",
126
- "playwright": "^1.62",
127
- "playwright-core": "^1.62",
126
+ "playwright": "^1.63",
127
+ "playwright-core": "^1.63",
128
128
  "pngjs": "^7.0.0",
129
129
  "react": "^19.1.1",
130
130
  "sambanova-ai-provider": "^1.2.2",
@@ -0,0 +1,6 @@
1
+ <pagination>
2
+ When a section is a list that continues beyond what is shown, add one line under its `> Container:` line:
3
+ `> Pagination: controls` — it has page numbers (1, 2, 3), prev/next arrows, or a "load more" button.
4
+ `> Pagination: infinite` — it has none of those and loads more as it is scrolled.
5
+ Sorting, filtering and switching tabs are not pagination — omit the line then.
6
+ </pagination>
@@ -37,6 +37,8 @@ export interface PageDiff {
37
37
  currentUrl: string;
38
38
  ariaChanges?: string | null;
39
39
  ariaChangeCount?: number;
40
+ ariaAdded?: number;
41
+ ariaRemoved?: number;
40
42
  messages?: string[];
41
43
  requests?: NetworkCall[];
42
44
  consoleErrors?: string[];
@@ -137,6 +139,8 @@ export declare class Diff {
137
139
  _messages: string[];
138
140
  _ariaDiffResult: string | null;
139
141
  _ariaChangeCount: number;
142
+ _ariaAdded: number;
143
+ _ariaRemoved: number;
140
144
  _isSameUrl: boolean;
141
145
  constructor(current: ActionResult, previous: ActionResult | null);
142
146
  static create(current: ActionResult, previous: ActionResult | null): Promise<Diff>;
@@ -147,6 +151,8 @@ export declare class Diff {
147
151
  cleanedHtmlParts(): Promise<HtmlDiffPart[]>;
148
152
  get ariaChanged(): string | null;
149
153
  get ariaChangeCount(): number;
154
+ get ariaAdded(): number;
155
+ get ariaRemoved(): number;
150
156
  get htmlDiff(): HtmlDiffResult | null;
151
157
  get messages(): string[];
152
158
  get similarity(): number;
@@ -451,6 +451,8 @@ export class ActionResult {
451
451
  if (diff.ariaChanged) {
452
452
  pageDiff.ariaChanges = diff.ariaChanged;
453
453
  pageDiff.ariaChangeCount = diff.ariaChangeCount;
454
+ pageDiff.ariaAdded = diff.ariaAdded;
455
+ pageDiff.ariaRemoved = diff.ariaRemoved;
454
456
  }
455
457
  if (this.overlay.isOpen && (!previousState.overlay.isOpen || previousState.overlay.name !== this.overlay.name)) {
456
458
  pageDiff.areaOfInterest = this.overlay.describe();
@@ -542,6 +544,8 @@ export class Diff {
542
544
  _messages = [];
543
545
  _ariaDiffResult = null;
544
546
  _ariaChangeCount = 0;
547
+ _ariaAdded = 0;
548
+ _ariaRemoved = 0;
545
549
  _isSameUrl;
546
550
  constructor(current, previous) {
547
551
  this.current = current;
@@ -590,6 +594,12 @@ export class Diff {
590
594
  get ariaChangeCount() {
591
595
  return this._ariaChangeCount;
592
596
  }
597
+ get ariaAdded() {
598
+ return this._ariaAdded;
599
+ }
600
+ get ariaRemoved() {
601
+ return this._ariaRemoved;
602
+ }
593
603
  get htmlDiff() {
594
604
  return this._htmlDiffResult;
595
605
  }
@@ -614,5 +624,7 @@ export class Diff {
614
624
  const ariaDiff = diffAriaSnapshots(this.previous.ariaSnapshot, this.current.ariaSnapshot);
615
625
  this._ariaDiffResult = ariaDiff.text;
616
626
  this._ariaChangeCount = ariaDiff.count;
627
+ this._ariaAdded = ariaDiff.added;
628
+ this._ariaRemoved = ariaDiff.removed;
617
629
  }
618
630
  }
@@ -404,12 +404,16 @@ export class Planner extends PlannerBase {
404
404
  `);
405
405
  }
406
406
  }
407
+ let activeRegion = '';
408
+ if (state.overlay.isOpen)
409
+ activeRegion = `Active region: ${state.overlay.describe()} — the user's current focus area. Plan tests for the controls inside it first.`;
407
410
  conversation.addUserText(dedent `
408
411
  ${this.buildApproach(style)}
409
412
 
410
413
  <context>
411
414
  URL: ${state.url || 'Unknown'}
412
415
  Title: ${state.title || 'Unknown'}
416
+ ${activeRegion}
413
417
  </context>
414
418
  `);
415
419
  if (this.fisherman) {
@@ -38,7 +38,7 @@ export declare function WithDeepAnalysis<T extends Constructor>(Base: T): {
38
38
  }>): Promise<void>;
39
39
  _executeAndAnalyze(commands: string[], description: string, state: WebPageState, originalAria: string, alreadyExpanded: string[]): Promise<ExpansionOutcome>;
40
40
  _restorePageState(url: string, originalAria: string): Promise<void>;
41
- _analyzeExpandedAction(code: string, description: string, diff: Diff, alreadyExpanded: string[]): Promise<string | null>;
41
+ _analyzeExpandedAction(code: string, description: string, diff: Diff, alreadyExpanded: string[], containerCss?: string | null): Promise<string | null>;
42
42
  _deduplicateExpandedSections(sections: string[]): string[];
43
43
  _summarizeExpanded(expandedSections: string[]): string[];
44
44
  _sectionFingerprint(sectionMarkdown: string): string | null;
@@ -85,7 +85,7 @@ export function WithDeepAnalysis(Base) {
85
85
  .filter((s) => s.elements.length > 0)
86
86
  .map((s) => s.rawMarkdown));
87
87
  tag('substep').log(`Researching overlay: ${region.name}`);
88
- const sectionMarkdown = await this._analyzeExpandedAction('', region.name, diff, alreadyExpanded);
88
+ const sectionMarkdown = await this._analyzeExpandedAction('', region.name, diff, alreadyExpanded, region.root);
89
89
  if (!sectionMarkdown) {
90
90
  debugLog(`Overlay "${region.name}" produced no meaningful expansion`);
91
91
  return null;
@@ -367,9 +367,9 @@ export function WithDeepAnalysis(Base) {
367
367
  }
368
368
  await new Promise((r) => setTimeout(r, 500));
369
369
  let diff;
370
+ let currAR;
370
371
  try {
371
- await this.explorer.capture();
372
- const currAR = ActionResult.fromState(this.stateManager.getCurrentState());
372
+ currAR = await this.explorer.capture();
373
373
  diff = await currAR.diff(previousState);
374
374
  }
375
375
  catch (err) {
@@ -388,7 +388,7 @@ export function WithDeepAnalysis(Base) {
388
388
  debugLog(`No changes from: ${description.slice(0, 80)}`);
389
389
  return { status: 'none', code: clickCode };
390
390
  }
391
- const sectionMarkdown = await this._analyzeExpandedAction(clickCode, description, diff, alreadyExpanded);
391
+ const sectionMarkdown = await this._analyzeExpandedAction(clickCode, description, diff, alreadyExpanded, currAR.overlay.root);
392
392
  await this._restorePageState(state.url, originalAria);
393
393
  if (!sectionMarkdown)
394
394
  return { status: 'none', code: clickCode };
@@ -413,7 +413,7 @@ export function WithDeepAnalysis(Base) {
413
413
  tag('warning').log(`navigateTo failed during restore: ${err instanceof Error ? err.message : err}`);
414
414
  }
415
415
  }
416
- async _analyzeExpandedAction(code, description, diff, alreadyExpanded) {
416
+ async _analyzeExpandedAction(code, description, diff, alreadyExpanded, containerCss = null) {
417
417
  const alreadyHint = alreadyExpanded.length > 0 ? `\nAlready expanded sections:\n${alreadyExpanded.join('\n')}` : '';
418
418
  let intro;
419
419
  if (code) {
@@ -473,7 +473,15 @@ export function WithDeepAnalysis(Base) {
473
473
  const sections = parseResearchSections(text);
474
474
  if (sections.length === 0)
475
475
  return null;
476
- return sections[0].rawMarkdown;
476
+ const sectionMarkdown = sections[0].rawMarkdown;
477
+ if (!containerCss)
478
+ return sectionMarkdown;
479
+ let heading = mdq(sectionMarkdown).query('h3[0]');
480
+ if (heading.count() === 0)
481
+ heading = mdq(sectionMarkdown).query('h2[0]');
482
+ if (heading.count() === 0)
483
+ return sectionMarkdown;
484
+ return heading.replace(`${heading.text().trimEnd()}\n\n> Container: '${containerCss}'\n\n`);
477
485
  }
478
486
  _deduplicateExpandedSections(sections) {
479
487
  const seen = new Set();
@@ -281,7 +281,7 @@ export function WithLocators(Base) {
281
281
  if (sectionQuery.count() === 0)
282
282
  sectionQuery = mdq(result.text).query(`section3(~"${escaped}")`);
283
283
  if (newCss) {
284
- result.text = sectionQuery.query('blockquote[0]').replace(`Container: '${newCss}'`);
284
+ result.text = sectionQuery.query('blockquote[0]').setKeyValue('Container', `'${newCss}'`);
285
285
  }
286
286
  else {
287
287
  result.text = sectionQuery.query('blockquote[0]').replace('');
@@ -0,0 +1,16 @@
1
+ import type Explorer from '../../explorer.js';
2
+ import { type ListMeasure, type PaginationStrategy } from '../../utils/pagination.js';
3
+ import { type Constructor } from './mixin.js';
4
+ import type { ResearchResult } from './research-result.js';
5
+ export declare function WithPagination<T extends Constructor>(Base: T): {
6
+ new (...args: any[]): {
7
+ explorer: Explorer;
8
+ detectPagination(result: ResearchResult): Promise<void>;
9
+ probeSection(css: string): Promise<PaginationStrategy | null>;
10
+ measure(css: string): Promise<ListMeasure | null>;
11
+ recordPagination(result: ResearchResult, name: string, strategy: PaginationStrategy): void;
12
+ };
13
+ } & T;
14
+ export interface PaginationMethods {
15
+ detectPagination(result: ResearchResult): Promise<void>;
16
+ }
@@ -0,0 +1,62 @@
1
+ import { mdq } from "../../utils/markdown-query.js";
2
+ import { inspectList, restoreScroll } from "../../utils/pagination.js";
3
+ import { debugLog } from "./mixin.js";
4
+ import { extractPaginationFromBlockquote, parseDataSections, parseResearchSections } from "./parser.js";
5
+ export function WithPagination(Base) {
6
+ return class extends Base {
7
+ async detectPagination(result) {
8
+ const sections = [...parseResearchSections(result.text), ...parseDataSections(result.text)];
9
+ for (const section of sections) {
10
+ const css = section.containerCss;
11
+ if (!css)
12
+ continue;
13
+ if (extractPaginationFromBlockquote(section.rawMarkdown))
14
+ continue;
15
+ const strategy = await this.probeSection(css);
16
+ if (!strategy)
17
+ continue;
18
+ this.recordPagination(result, section.name, strategy);
19
+ debugLog(`Pagination in "${section.name}": ${strategy}`);
20
+ }
21
+ }
22
+ async probeSection(css) {
23
+ const before = await this.measure(css);
24
+ if (!before)
25
+ return null;
26
+ if (before.hasPagingControls)
27
+ return 'controls';
28
+ if (before.isFeed)
29
+ return 'infinite';
30
+ if (!before.scrolls)
31
+ return null;
32
+ const action = this.explorer.action();
33
+ const scrolled = await action.attempt(`I.scrollTo('${css} > *:last-child')`).catch(() => false);
34
+ if (!scrolled)
35
+ return null;
36
+ const after = await this.measure(css);
37
+ await this.explorer.withPage((page) => page.evaluate(restoreScroll, { css, scrollTop: before.scrollTop, pageScrollY: before.pageScrollY })).catch(() => { });
38
+ if (!after)
39
+ return null;
40
+ if (after.items > before.items)
41
+ return 'infinite';
42
+ return null;
43
+ }
44
+ measure(css) {
45
+ return this.explorer
46
+ .withPage((page) => page.evaluate(inspectList, css))
47
+ .catch((err) => {
48
+ debugLog(`List measurement failed for '${css}': ${err.message}`);
49
+ return null;
50
+ });
51
+ }
52
+ recordPagination(result, name, strategy) {
53
+ const escaped = name.replace(/"/g, '\\"');
54
+ let sectionQuery = mdq(result.text).query(`section2(~"${escaped}")`);
55
+ if (sectionQuery.count() === 0)
56
+ sectionQuery = mdq(result.text).query(`section3(~"${escaped}")`);
57
+ if (sectionQuery.count() === 0)
58
+ return;
59
+ result.text = sectionQuery.query('blockquote[0]').setKeyValue('Pagination', strategy);
60
+ }
61
+ };
62
+ }
@@ -1,3 +1,4 @@
1
+ import type { PaginationStrategy } from '../../utils/pagination.js';
1
2
  export interface ResearchElement {
2
3
  name: string;
3
4
  type: string | null;
@@ -23,6 +24,8 @@ export declare const RESEARCH_COLUMN_ORDER: string[];
23
24
  export declare function mapRowToElement(row: Record<string, string>): ResearchElement | null;
24
25
  export declare function extractContainerFromBlockquote(sectionMarkdown: string): string | null;
25
26
  export declare function parseResearchSections(markdown: string): ResearchSection[];
27
+ export declare function parseDataSections(markdown: string): ResearchSection[];
28
+ export declare function extractPaginationFromBlockquote(sectionMarkdown: string): PaginationStrategy | null;
26
29
  export declare function extractValidContainers(researchText: string, opts?: {
27
30
  exclude?: string[];
28
31
  }): Array<{
@@ -57,13 +57,10 @@ export function mapRowToElement(row) {
57
57
  };
58
58
  }
59
59
  export function extractContainerFromBlockquote(sectionMarkdown) {
60
- const bq = mdq(sectionMarkdown).query('blockquote[0]').text().trim();
61
- if (!bq)
60
+ const entry = mdq(sectionMarkdown).query('blockquote[0]').keyValue().container;
61
+ if (!entry)
62
62
  return null;
63
- const match = bq.match(/Container:\s*(.+)/i);
64
- if (!match)
65
- return null;
66
- const css = normalizeLocatorValue(match[1]);
63
+ const css = normalizeLocatorValue(entry);
67
64
  if (!css || !/^[.#\[\w]/.test(css))
68
65
  return null;
69
66
  return css;
@@ -80,6 +77,25 @@ export function parseResearchSections(markdown) {
80
77
  return { name: section.name, containerCss, elements, rawMarkdown: section.rawMarkdown, isExtended };
81
78
  });
82
79
  }
80
+ export function parseDataSections(markdown) {
81
+ return parseSections(markdown)
82
+ .filter((s) => s.name.toLowerCase().startsWith('data:'))
83
+ .map((section) => ({
84
+ name: section.name,
85
+ containerCss: extractContainerFromBlockquote(section.rawMarkdown),
86
+ elements: [],
87
+ rawMarkdown: section.rawMarkdown,
88
+ isExtended: false,
89
+ }));
90
+ }
91
+ export function extractPaginationFromBlockquote(sectionMarkdown) {
92
+ const value = mdq(sectionMarkdown).query('blockquote[0]').keyValue().pagination?.toLowerCase();
93
+ if (value === 'controls')
94
+ return 'controls';
95
+ if (value === 'infinite')
96
+ return 'infinite';
97
+ return null;
98
+ }
83
99
  export function extractValidContainers(researchText, opts) {
84
100
  const exclude = opts?.exclude || [];
85
101
  return parseResearchSections(researchText)
@@ -63,7 +63,7 @@ export function WithSections(Base) {
63
63
  }
64
64
  async _researchSingleSection(name, description, ariaSnapshot, focusCss) {
65
65
  const currentUrl = this.stateManager.getCurrentState()?.url || '';
66
- const rules = RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules'], currentUrl);
66
+ const rules = RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules', 'pagination'], currentUrl);
67
67
  const url = this.actionResult?.url || 'Unknown';
68
68
  const title = this.actionResult?.title || 'Unknown';
69
69
  let focusHint = '';
@@ -18,6 +18,7 @@ import { WithCoordinates } from "./researcher/coordinates.js";
18
18
  import { WithDeepAnalysis } from "./researcher/deep-analysis.js";
19
19
  import { detectFocusedSection, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from "./researcher/focus.js";
20
20
  import { WithLocators } from "./researcher/locators.js";
21
+ import { WithPagination } from "./researcher/pagination.js";
21
22
  import { extractValidContainers, formatResearchSummary, parseResearchSections } from "./researcher/parser.js";
22
23
  import { ResearchResult } from "./researcher/research-result.js";
23
24
  import { WithSections } from "./researcher/sections.js";
@@ -33,7 +34,7 @@ export const POSSIBLE_SECTIONS = {
33
34
  menu: 'page menu (toolbar, context actions, filters, dropdowns)',
34
35
  navigation: 'main navigation (top bar, sidebar, breadcrumbs)',
35
36
  };
36
- const ResearcherBase = WithSections(WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent))));
37
+ const ResearcherBase = WithSections(WithPagination(WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent)))));
37
38
  export class Researcher extends ResearcherBase {
38
39
  ACTION_TOOLS = ['click'];
39
40
  emoji = '🔍';
@@ -230,6 +231,9 @@ export class Researcher extends ResearcherBase {
230
231
  await this.backfillCoordinates(result);
231
232
  await this.backfillBrokenLocators(result);
232
233
  }
234
+ if (!interrupted()) {
235
+ await this.detectPagination(result);
236
+ }
233
237
  // Focused section: final fallback (vision-only — without a screenshot we don't infer focus)
234
238
  if (this.hasScreenshotToAnalyze && !hasFocusedSection(result.text)) {
235
239
  const sections = parseResearchSections(result.text);
@@ -366,7 +370,7 @@ export class Researcher extends ResearcherBase {
366
370
 
367
371
  ${generalLocatorRuleText}
368
372
 
369
- ${RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules'], currentUrl)}
373
+ ${RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules', 'pagination'], currentUrl)}
370
374
 
371
375
  <section_identification>
372
376
  Identify page sections in this priority order:
@@ -439,6 +443,7 @@ export class Researcher extends ResearcherBase {
439
443
  - 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.
440
444
  - 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.
441
445
  - Data sections must NOT include a UI map table. Only include the container and a brief summary line.
446
+ - When the data list has controls that move between pages of the collection, add "> Pagination: controls" under its container.
442
447
  - Example data section:
443
448
 
444
449
  ## Data: Suites List
@@ -332,6 +332,22 @@ export const actionRule = dedent `
332
332
  For checkboxes, prefer I.checkOption/I.uncheckOption over I.click.
333
333
 
334
334
 
335
+ ### I.scrollTo
336
+
337
+ scrolls until the element is in view
338
+
339
+ I.scrollTo(<locator>)
340
+
341
+ Scrolls every scrollable ancestor of the target, so it reaches an element inside a container
342
+ that has its own scrollbar. I.scrollPageToBottom() moves only the page itself.
343
+
344
+ <example>
345
+ I.scrollTo('.rows > *:last-child');
346
+ I.scrollTo({ role: 'listitem', text: 'Last entry' });
347
+ I.scrollPageToBottom();
348
+ </example>
349
+
350
+
335
351
  ### I.fillField
336
352
 
337
353
  fills the field with the given value
@@ -47,12 +47,18 @@ export class Scout {
47
47
  maxToolRoundtrips: MAX_TOOL_ROUNDTRIPS,
48
48
  agentName: 'scout',
49
49
  });
50
+ const responseText = invokeResult?.response?.text;
51
+ if (responseText?.trim()) {
52
+ finishFromText(responseText);
53
+ stop();
54
+ return;
55
+ }
50
56
  if (!invokeResult?.toolExecutions?.length) {
51
- finishFromText(invokeResult?.response?.text);
52
57
  stop();
53
58
  return;
54
59
  }
55
- if (iteration >= MAX_ITERATIONS) {
60
+ if (iteration >= MAX_ITERATIONS - 1) {
61
+ conversation.addUserText('Exploration time is over. Report your findings now as your final message.');
56
62
  const final = await this.provider.invokeConversation(conversation, undefined, { agentName: 'scout' });
57
63
  finishFromText(final?.response?.text);
58
64
  stop();
@@ -1,5 +1,5 @@
1
- import type { ExecutedStep } from '../action.js';
2
1
  import { ActionResult, type PageDiff } from '../action-result.js';
2
+ import type { ExecutedStep } from '../action.js';
3
3
  import { type ExperienceTracker } from '../experience-tracker.js';
4
4
  import { type Task } from '../test-plan.js';
5
5
  import type { ToolDeps } from './agent.js';
@@ -5,13 +5,13 @@ import { ActionResult } from "../action-result.js";
5
5
  import { renderExperienceRecipes } from "../experience-tracker.js";
6
6
  import { Stats } from "../stats.js";
7
7
  import { TestResult } from '../test-plan.js';
8
+ import { ariaRefSelector, describeRef, refIsGone } from "../utils/aria-ref.js";
8
9
  import { LARGE_ARIA_CHANGE_THRESHOLD } from "../utils/aria.js";
9
10
  import { isFatalBrowserError } from "../utils/browser-errors.js";
10
11
  import { cleanHtmlSnippet } from "../utils/html.js";
11
12
  import { createDebug, tag } from '../utils/logger.js';
12
- import { compactErrorMessage, normalizeInlineText, truncate } from "../utils/strings.js";
13
13
  import { pause } from '../utils/loop.js';
14
- import { ariaRefSelector, describeRef, refIsGone } from "../utils/aria-ref.js";
14
+ import { compactErrorMessage, normalizeInlineText, truncate } from "../utils/strings.js";
15
15
  import { WebElement } from "../utils/web-element.js";
16
16
  import { sectionContextRule } from "./rules.js";
17
17
  import { isInteractive } from "./task-agent.js";
@@ -109,7 +109,13 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
109
109
  });
110
110
  }
111
111
  await commitNote(activeNote, TestResult.PASSED, toolResult, action);
112
- return successToolResult('click', { ...toolResult, attempts, code: command }, action);
112
+ const data = { ...toolResult, attempts, code: command };
113
+ const notExecuted = commands.slice(i + 1);
114
+ if (notExecuted.length) {
115
+ data.notExecuted = notExecuted;
116
+ data.suggestion = `SKIPPED: ${notExecuted.join('; ')}`;
117
+ }
118
+ return successToolResult('click', data, action);
113
119
  }
114
120
  }
115
121
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, commands[0]);
@@ -305,6 +311,7 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
305
311
  - Working with iframes (switch context with I.switchTo)
306
312
  - Performing multiple form actions in a single batch
307
313
  - Complex interactions requiring sequential commands
314
+ - Reaching items further down a list (I.scrollTo)
308
315
 
309
316
  Example - filling a form with context (PREFERRED):
310
317
  I.fillField('Username', 'John', '.login-form')
@@ -359,10 +366,10 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
359
366
  }
360
367
  if (!hasObservablePageChange(toolResult)) {
361
368
  activeNote.commit(TestResult.FAILED);
362
- return failedToolResult('form', 'Form command executed, but no observable page or form-state change was captured.', {
369
+ return failedToolResult('form', 'Command executed, but nothing on the page changed: no navigation, no ARIA change, no HTML change and no request.', {
363
370
  ...toolResult,
364
371
  code: codeBlock,
365
- 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.',
372
+ 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.',
366
373
  });
367
374
  }
368
375
  await commitNote(activeNote, TestResult.PASSED, toolResult, action);
@@ -1074,7 +1081,13 @@ export function successToolResult(action, data, source) {
1074
1081
  return result;
1075
1082
  }
1076
1083
  export function isMajorPageChange(pageDiff) {
1077
- return pageDiff.urlChanged !== true && (pageDiff.ariaChangeCount ?? 0) >= LARGE_ARIA_CHANGE_THRESHOLD;
1084
+ if (pageDiff.urlChanged === true)
1085
+ return false;
1086
+ if ((pageDiff.ariaChangeCount ?? 0) < LARGE_ARIA_CHANGE_THRESHOLD)
1087
+ return false;
1088
+ if (pageDiff.ariaRemoved === 0 && (pageDiff.ariaAdded ?? 0) > 0)
1089
+ return false;
1090
+ return true;
1078
1091
  }
1079
1092
  export function hasFailedRequest(pageDiff) {
1080
1093
  return (pageDiff.requests ?? []).some((request) => request.status >= 400);
@@ -1109,7 +1122,7 @@ export async function failedToolResult(action, message, data, error) {
1109
1122
  const errorTexts = [message, ...(data?.attempts?.map((a) => a.error || '') || [])];
1110
1123
  if (errorTexts.some((t) => t.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))) {
1111
1124
  const matched = await extractWebElements(error);
1112
- result.suggestion = getMultipleElementsSuggestion(matched);
1125
+ result.suggestion = getMultipleElementsSuggestion();
1113
1126
  result.multipleElementsDetected = true;
1114
1127
  result.elements = formatElementList(matched);
1115
1128
  return result;
@@ -1121,16 +1134,12 @@ export async function failedToolResult(action, message, data, error) {
1121
1134
  }
1122
1135
  return result;
1123
1136
  }
1124
- function getMultipleElementsSuggestion(matched) {
1125
- const visible = (matched || []).filter((element) => element.visible !== false);
1126
- let onlyVisible = '';
1127
- if (matched && visible.length === 1)
1128
- onlyVisible = `\nOnly element ${matched.indexOf(visible[0]) + 1} is on screen, so that is the one to act on.`;
1137
+ function getMultipleElementsSuggestion() {
1129
1138
  return dedent `
1130
1139
  Multiple elements matched your locator, so that command did nothing — it selected no element and acted on none.
1131
1140
  Read the numbered elements list and act on the one you meant by its number:
1132
1141
  reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
1133
- A match reported as not visible can never be acted on — pick one that is.${onlyVisible}
1142
+ A match reported as not visible can never be acted on — pick one that is.
1134
1143
  If none of them is the element you want, narrow the locator with a container or its full unique text.
1135
1144
  If the list is missing, call xpathCheck() to see what the locator matches.
1136
1145
  `;
@@ -1197,6 +1206,9 @@ function formatElementList(matched) {
1197
1206
  const lines = [`Element ${i + 1}:`, `Text: "${el.text}"`];
1198
1207
  if (el.visible !== undefined)
1199
1208
  lines.push(`Visible: ${el.visible}`);
1209
+ const wrapped = matched.map((_, j) => j).filter((j) => j !== i && matched[j].xpath.startsWith(`${el.xpath}/`));
1210
+ if (wrapped.length)
1211
+ lines.push(`Wraps: element ${wrapped.map((j) => j + 1).join(', ')}`);
1200
1212
  lines.push(`XPath: ${el.xpath}`, `HTML: ${el.html}`);
1201
1213
  return lines.join('\n');
1202
1214
  })
@@ -1,5 +1,6 @@
1
1
  export declare function validateSpecs(specs?: string[]): void;
2
2
  export declare function loadSpec(specPaths: string[], outputDir: string): Promise<any>;
3
3
  export declare function extractEndpointDefinition(schema: any, endpoint: string, baseEndpoint?: string): string;
4
+ export declare function resolveEndpoints(schema: any, pattern: string, baseEndpoint?: string): string[];
4
5
  export declare function searchEndpoints(schema: any, query: string, baseEndpoint?: string): string;
5
6
  export declare function listAllEndpoints(schema: any, baseEndpoint?: string): string;