explorbot 0.4.5 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/boat/api-tester/src/apibot.ts +18 -2
  2. package/boat/api-tester/src/cli.ts +85 -274
  3. package/boat/api-tester/src/commands/api-command.ts +10 -0
  4. package/boat/api-tester/src/commands/explore-command.ts +52 -0
  5. package/boat/api-tester/src/commands/init-command.ts +119 -0
  6. package/boat/api-tester/src/commands/know-command.ts +44 -0
  7. package/boat/api-tester/src/commands/plan-command.ts +42 -0
  8. package/boat/api-tester/src/commands/test-command.ts +54 -0
  9. package/dist/boat/api-tester/src/apibot.js +14 -1
  10. package/dist/boat/api-tester/src/cli.js +87 -243
  11. package/dist/boat/api-tester/src/commands/api-command.js +7 -0
  12. package/dist/boat/api-tester/src/commands/explore-command.js +41 -0
  13. package/dist/boat/api-tester/src/commands/init-command.js +88 -0
  14. package/dist/boat/api-tester/src/commands/know-command.js +39 -0
  15. package/dist/boat/api-tester/src/commands/plan-command.js +37 -0
  16. package/dist/boat/api-tester/src/commands/test-command.js +45 -0
  17. package/dist/package.json +4 -4
  18. package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
  19. package/dist/src/ai/researcher/deep-analysis.js +14 -6
  20. package/dist/src/ai/tools.d.ts +1 -1
  21. package/dist/src/ai/tools.js +15 -10
  22. package/dist/src/api/spec-reader.d.ts +1 -0
  23. package/dist/src/api/spec-reader.js +93 -1
  24. package/dist/src/commands/base-command.d.ts +3 -3
  25. package/dist/src/commands/init-command.d.ts +3 -0
  26. package/dist/src/commands/init-command.js +6 -3
  27. package/dist/src/explorer.d.ts +1 -1
  28. package/dist/src/explorer.js +1 -1
  29. package/dist/src/utils/html-diff.js +4 -1
  30. package/docs/api-testing/basics.md +26 -2
  31. package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +317 -0
  32. package/package.json +4 -4
  33. package/src/ai/researcher/deep-analysis.ts +13 -6
  34. package/src/ai/tools.ts +15 -11
  35. package/src/api/spec-reader.ts +106 -1
  36. package/src/commands/base-command.ts +3 -3
  37. package/src/commands/init-command.ts +6 -3
  38. package/src/explorer.ts +1 -1
  39. package/src/utils/html-diff.ts +3 -1
@@ -0,0 +1,317 @@
1
+ # Pagination rule: page numbers and infinite scroll
2
+
3
+ ## Problem
4
+
5
+ Explorbot cannot reach list content that is not already loaded. A list shows a window onto
6
+ a larger collection, and when the item under test is outside that window the tester concludes
7
+ it is absent.
8
+
9
+ Nothing in the repo scrolls anything. `<actions>` (`src/ai/rules.ts:307`) documents no scroll
10
+ command, so the model has no way to know scrolling is available. Where scrolling is mentioned
11
+ at all it is as a passing hint inside an unrelated suggestion string
12
+ (`src/ai/tools.ts:875`, `:989`, `:1297`).
13
+
14
+ Two strategies cover practically every paginated list on the web:
15
+
16
+ - **Controls that replace the window** — next, previous, page numbers, load more.
17
+ - **Appending on scroll** — new items are fetched and appended as the list is scrolled.
18
+
19
+ The second is the hard one, because the scroller is often a container with its own scrollbar
20
+ rather than the page. `I.scrollPageToBottom()` moves the window and leaves such a container
21
+ untouched.
22
+
23
+ ## Approach
24
+
25
+ Detection finds out which strategy a list uses; a rule tells the tester what to do about it,
26
+ and is injected only when there is something to say. No new tool: the gesture already exists in
27
+ CodeceptJS and is reachable through `form`.
28
+
29
+ Splitting it that way is what keeps the rule short. The tester never has to discover anything
30
+ at run time, and never carries guidance for a strategy this page does not use.
31
+
32
+ ### Why `I.scrollTo` is sufficient
33
+
34
+ `Playwright.scrollTo(locator)` calls `el.scrollIntoViewIfNeeded()`
35
+ (`node_modules/codeceptjs/lib/helper/Playwright.js:1679`), which scrolls **every scrollable
36
+ ancestor** of the target. Pointing it at the last item currently in a list therefore scrolls
37
+ that list's own scroller.
38
+
39
+ Verified against Chromium on a page with both a scrollable `div` and a scrollable window:
40
+
41
+ ```
42
+ before {"box":0, "win":0}
43
+ scrollIntoView {"box":2200, "win":1821}
44
+ after cjs tail {"box":2200, "win":1821}
45
+ ```
46
+
47
+ Both scrollers moved. The `window.scrollBy` call CodeceptJS runs afterwards is a no-op: it
48
+ passes one object to a two-positional-parameter function, so both deltas coerce to `NaN` and
49
+ normalize to zero. Nothing needs to be worked around.
50
+
51
+ The gesture is a single line beginning with `I.`, so it passes the `form` tool's line check
52
+ (`src/ai/tools.ts:384`) and needs no new tool.
53
+
54
+ ### Signals already on the wire
55
+
56
+ - **`pageDiff.ariaChanges` / `ariaChangeCount`** — `diffAriaSnapshots` (`src/utils/aria.ts:503`)
57
+ counts node summaries, so appended rows surface as counted additions.
58
+ - **`pageDiff.requests`** — `Action.recordNetworkCall` (`src/action.ts:314`) captures same-origin
59
+ xhr/fetch as `{method, path, status}`, deduped. It stores `url.pathname` only, so the query
60
+ string is dropped: presence of a call, never a page number.
61
+
62
+ ## Design
63
+
64
+ ### A. Researcher determines each list's pagination strategy
65
+
66
+ Four steps, cheapest first, stopping as soon as one answers. This is the escalation ladder from
67
+ CLAUDE.md end to end: a table lookup, then AI judgment, then a probe whose result converts
68
+ judgment back into a recorded fact.
69
+
70
+ **0. Do the ARIA/HTML conventions name it? (deterministic, no research, no AI)**
71
+
72
+ Some markup states the answer outright. These are spec-defined attributes and values, so this
73
+ tier is a lookup, not a guess — and it is the only step that works when research has not run.
74
+
75
+ | Marker | Means | Available in |
76
+ |---|---|---|
77
+ | `[aria-current="page"]` | current page of a pagination set | HTML only |
78
+ | `a[rel="next"]`, `a[rel="prev"]` | sequential document relations | HTML only |
79
+ | `[role="feed"]` | scrollable list that grows as it is scrolled | HTML and ARIA snapshot |
80
+ | `[aria-setsize="-1"]` | total count unknown, so the set loads lazily | HTML only |
81
+
82
+ The first two mean `controls`, the last two mean `infinite`.
83
+
84
+ Verified against Chromium: `ariaSnapshot()` does **not** emit `aria-current`, so a link marked
85
+ as the current page is indistinguishable from its neighbours in the ARIA path. Explorbot also
86
+ dissolves `navigation` wrappers (`src/utils/aria.ts:46`, `:147`) and treats the role as
87
+ template chrome (`src/utils/aria.ts:543`), so a `nav` labelled "Pagination" never reaches the
88
+ model either. **These markers must be read from HTML.** `role="feed"` is the one exception —
89
+ it survives as `- feed "…"` in the snapshot.
90
+
91
+ Two constraints that keep this a lookup rather than a heuristic:
92
+
93
+ - The value must be `aria-current="page"` exactly. `aria-current="true"` is what tabs and
94
+ breadcrumbs use and would over-match — confirmed in the same probe.
95
+ - A `nav` whose `aria-label` reads "Pagination" is author prose, not closed grammar. It is not
96
+ part of this tier.
97
+
98
+ **Absence proves nothing.** A pager built from plain buttons, and an infinite feed built from
99
+ plain divs, carry none of these. That is what steps 1–3 are for.
100
+
101
+ **1. Are there pagination controls? (AI, free)**
102
+
103
+ Only asked when step 0 found nothing. Controls are named in open-ended ways — words, arrows,
104
+ bare numbers — so this is AI judgment, not a pattern match. Researcher is already describing
105
+ the section, so it costs nothing extra:
106
+ a new `rules/researcher/pagination.md`, loaded alongside the existing three at
107
+ `src/ai/researcher/sections.ts:81`, asks it to note when a section contains controls that move
108
+ between pages of the same collection.
109
+
110
+ If found, the section records `> Pagination: controls` and the remaining steps are skipped.
111
+
112
+ **2. Can the section scroll at all? (deterministic gate)**
113
+
114
+ Only asked when no controls were found. One `page.evaluate` per container:
115
+
116
+ - `el.scrollHeight > el.clientHeight` → the container has its own scroller.
117
+ - `el.getBoundingClientRect().bottom > innerHeight` → the list continues below the fold.
118
+ - Neither → nothing more to do; no line recorded.
119
+
120
+ This gate exists to keep step 3 from running on every short list.
121
+
122
+ **3. Probe: does scrolling load more? (deterministic measurement)**
123
+
124
+ Scroll the container to its end, wait for readiness (`waitForPageReadiness`,
125
+ `src/utils/page-readiness.ts`), and compare. More descendant rows than before, or a same-origin
126
+ xhr/fetch fired during the scroll, means the list appends. Record `> Pagination: infinite`.
127
+
128
+ Then restore `scrollTop` to what it was, so screenshots, coordinates and later research see the
129
+ page as they found it. Scroll position is not app state, so this needs none of the modal
130
+ cleanup `_restorePageState` does in `deep-analysis.ts:453` — there is nothing to reuse there.
131
+
132
+ **Recorded vocabulary:** `controls` or `infinite`, as a line in the section's container
133
+ blockquote. Nothing is written when a list neither paginates nor grows, which is the common
134
+ case and should stay silent.
135
+
136
+ ```
137
+ > Container: '.semantic-container'
138
+ > Pagination: infinite
139
+ ```
140
+
141
+ **This line has a reader**, because section B injects the rule only when pagination was
142
+ detected, and that decision is code. `extractPaginationFromBlockquote` joins
143
+ `extractContainerFromBlockquote` (`src/ai/researcher/parser.ts:86`) and returns the recorded
144
+ value or null.
145
+
146
+ That makes `Pagination:` a closed vocabulary read deterministically by code, so the envelope
147
+ checklist from CLAUDE.md applies and holds: read by code, scoped to a section of a state,
148
+ optional with "absent" as the default, and written by one module.
149
+
150
+ ### A2. Where the code goes
151
+
152
+ New `src/ai/researcher/pagination.ts` mixin, composed into `ResearcherBase`
153
+ (`src/ai/researcher.ts:47`), owning steps 2 and 3. Step 1 is prompt text in
154
+ `rules/researcher/pagination.md` and needs no code.
155
+
156
+ It runs after `validateContainers` (`src/ai/researcher/locators.ts:268`), on containers that
157
+ survived validation, so a probe never targets a selector already known to be broken.
158
+
159
+ Not `deep-analysis.ts`: that mixin owns the same interact-measure-restore shape, but it is
160
+ gated behind `deep` (`src/ai/researcher.ts:287`), and infinite scroll has to be detected on
161
+ ordinary research runs too. It is also already 26k.
162
+
163
+ Not `locators.ts`: that mixin owns locator validity, not list behaviour.
164
+
165
+ **One writer for the blockquote.** `updateSectionContainer`
166
+ (`src/ai/researcher/locators.ts:300`) currently owns that `blockquote[0]` replace. The
167
+ pagination mixin must not write it independently. Extract the blockquote composition into one
168
+ helper both call, so `Container:` and `Pagination:` are always emitted by the same code.
169
+
170
+ A new `src/utils/pagination.ts` owns the two deterministic halves — the step 0 marker scan over
171
+ HTML, and the in-page evaluate functions for steps 2 and 3, self-contained with no outer-scope
172
+ references. One concern: how a list continues. `measureLayout` in `overlay.ts` is not reused:
173
+ it is xpath-based and returns a modal-scoring `RegionLayout`, while sections carry CSS
174
+ selectors and need neither.
175
+
176
+ ### B. The pagination rule, injected only when pagination was detected
177
+
178
+ Not part of the static system message. A page with no list should not carry list guidance, and
179
+ a page with page numbers should not be told how to scroll.
180
+
181
+ **Seam:** `reinjectContextIfNeeded` (`src/ai/tester.ts:554`), which already injects per-state
182
+ blocks conditionally — `focusedElementRule` when something is focused, an `<overlay>` block
183
+ when a region is open. A `<pagination>` block joins them. Navigator gets the same block where
184
+ it builds its own per-state context (`src/ai/navigator.ts:414`).
185
+
186
+ **Condition:** the state shows pagination if step 0's markers are present in the current HTML,
187
+ or `extractPaginationFromBlockquote` finds a recorded value for a section. Markers win when
188
+ both are available, since they describe the page as it is now rather than as research left it.
189
+
190
+ **Which text:** the strategy selects the fragment, so the model is never shown the other one.
191
+
192
+ `paginationControlsRule`:
193
+
194
+ ```
195
+ <pagination>
196
+ This list pages through a larger collection. If what you need is not on screen,
197
+ click next or the page number you need before concluding it is absent.
198
+ </pagination>
199
+ ```
200
+
201
+ `infiniteScrollRule`:
202
+
203
+ ```
204
+ <pagination>
205
+ This list grows as it is scrolled. If what you need is not on screen, scroll to
206
+ the last item in the list — every scrollable ancestor of that item scrolls, so
207
+ this reaches a list with its own scrollbar.
208
+
209
+ New rows in the aria changes mean more arrived; a request with none means nothing
210
+ was left. Stop on the first attempt that adds no rows: the end of a collection is
211
+ an answer, not a failure.
212
+ </pagination>
213
+ ```
214
+
215
+ Both live in `src/ai/rules.ts` as exports. Not `rules/*.md`: those load per agent, and these
216
+ have two consumers.
217
+
218
+ **Reach narrows from the earlier draft.** Composing into `actionRule` would have reached
219
+ Rerunner (`src/ai/rerunner.ts:450`) and Captain web-mode (`src/ai/captain/web-mode.ts:148`) too;
220
+ conditional injection reaches only agents that build per-state context, which is Tester and
221
+ Navigator. That is the cost of making it conditional, and it is the right trade: Rerunner heals
222
+ known steps rather than traversing lists.
223
+
224
+ Constraints both fragments keep: general phrasing, no selectors, no site names, no example
225
+ taken from a debug session, one to three lines per bullet.
226
+
227
+ ### C. `actionRule` documents the gesture
228
+
229
+ `<actions>` (`src/ai/rules.ts:307`) gains a scroll entry covering `I.scrollTo(<locator>)` —
230
+ stating that it scrolls every scrollable ancestor of the target — and
231
+ `I.scrollPageToBottom()` for the window. The `form` tool description
232
+ (`src/ai/tools.ts:354`) gains "reach items further down a list" as a use case, since it
233
+ currently reads as a typing tool.
234
+
235
+ ### D. Tool output that contradicts the rule
236
+
237
+ Both are wrong reporting on existing tools, not new behaviour.
238
+
239
+ **`src/ai/tools.ts:415`** — `hasObservablePageChange` (`src/ai/tools.ts:1236`) ignores
240
+ `pageDiff.requests`. A scroll that fires a fetch which has not yet rendered rows, and a scroll
241
+ at the true end of a list, both return `failedToolResult` carrying the suggestion "Treat the
242
+ field/form action as not completed. Re-locate the editable control…" and commit
243
+ `TestResult.FAILED` into the note. That note is read by final review and by Historian, so a
244
+ correct end-of-list check is recorded as a failed test step.
245
+
246
+ Fix: one line in `hasObservablePageChange` — `if (data.pageDiff.requests?.length) return true;`
247
+ — and a no-change message that states what was observed rather than prescribing a
248
+ form-specific recovery.
249
+
250
+ **`src/ai/tools.ts:1208`** — `isMajorPageChange` (`src/ai/tools.ts:1220`) fires at
251
+ `ariaChangeCount >= 50` with no URL change (`LARGE_ARIA_CHANGE_THRESHOLD`,
252
+ `src/utils/aria.ts:581`), producing "MAJOR PAGE CHANGE. Page entered a different mode."
253
+ `diffByCount` (`src/utils/aria.ts:309`) pushes one entry per surplus occurrence, so a batch of
254
+ appended rows clears 50 easily. The rule says growth is the same state; the tool says the mode
255
+ changed.
256
+
257
+ Fix: a diff consisting of additions with no corresponding removals is growth, not a mode
258
+ change. Mode changes churn — they remove as well as add.
259
+
260
+ The added/removed split exists inside `diffAriaSnapshots` but is flattened into a single
261
+ `count` before it reaches the check, so it has to be threaded through:
262
+
263
+ 1. `AriaDiff` (`src/utils/aria.ts:587`) gains `added: number; removed: number` — both arrays
264
+ are already computed at `src/utils/aria.ts:522`.
265
+ 2. `Diff` (`src/action-result.ts:650`) stores them alongside `_ariaChangeCount`, set where
266
+ `diffAriaSnapshots` is called (`src/action-result.ts:740`), and exposes them.
267
+ 3. `PageDiff` (`src/action-result.ts:46`) gains `ariaAdded` / `ariaRemoved`, populated next to
268
+ `ariaChanges` / `ariaChangeCount` (`src/action-result.ts:552`).
269
+ 4. `isMajorPageChange` then reads the split instead of the total.
270
+
271
+ ## Risks
272
+
273
+ - **The probe costs a scroll per candidate section.** Steps 1 and 2 narrow it to sections that
274
+ have no pagination controls and can actually scroll, which on most pages is zero or one. If
275
+ it still proves too slow, the gate to tighten is step 2, not the probe itself.
276
+ - **Region misclassification.** `OverlayPage.detectRegion` (`src/utils/overlay.ts:41`) accepts
277
+ in-flow added content that is `sizable` (≥5,000 chars, `src/utils/region.ts:99`), dominant
278
+ (≥70% of added raw size) and carries a name or root. A large appended batch in one list
279
+ container fits all three, which would fork the state hash with `region_<name>` and record a
280
+ transition. The probe can trigger this during research as well as the tester during a run.
281
+ Watch for it on the first real run; not changed here.
282
+ - **Virtualized lists.** Recycled nodes keep counts flat, so the probe sees no growth and the
283
+ aria diff shows renames (`src/utils/aria.ts:325`) rather than additions. Such a list records
284
+ nothing and the rule will not know to scroll it. Out of scope.
285
+ - **The stop condition lives only in the prompt.** No tool enforces it, so a model that keeps
286
+ scrolling past an attempt which added no rows will keep scrolling. Accepted: the tester's own
287
+ iteration cap is the only backstop.
288
+ - **A REST call alone no longer stops the loop, by design.** After the D fix an empty-payload
289
+ 200 counts as an observable change, so the tool reports success. The rule makes rows, not
290
+ calls, the evidence that more arrived — otherwise a list that answers every scroll with an
291
+ empty page would loop.
292
+
293
+ ## Testing
294
+
295
+ - Unit coverage for step 0's marker scan: `aria-current="page"` and `rel=next/prev` yield
296
+ `controls`; `role="feed"` and `aria-setsize="-1"` yield `infinite`; `aria-current="true"` on a
297
+ tab list yields nothing.
298
+ - `tests/integration/researcher-sections.test.ts` — `> Pagination: controls` appears for a
299
+ section whose UI map holds next/prev controls, and step 1 is not asked when step 0 already
300
+ answered.
301
+ - A browser test for steps 2 and 3, following `tests/integration/overlay-modal-browser.test.ts`:
302
+ a container with its own scroller that appends on scroll (`infinite`), one that does not
303
+ (silent), a list with pagination controls (`controls`, no probe runs), and confirmation that
304
+ `scrollTop` is restored afterwards.
305
+ - An aimock prompt-inspection test that the matching fragment reaches the tester on a paginated
306
+ state, the other fragment does not, and neither appears on a state with no list, per
307
+ `docs/contributing/ai-integration-tests.md`.
308
+ - Unit coverage for the two `tools.ts` corrections: requests-only diff counts as observable;
309
+ an additions-only diff over the threshold is not a major page change.
310
+
311
+ ## Out of scope
312
+
313
+ - Any new tool. The gesture is reachable through `form` today.
314
+ - Persisting the strategy across runs. Research recomputes it, and the research cache is
315
+ session-scoped.
316
+ - Virtualized list support.
317
+ - Changing `detectRegion` thresholds.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
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.2",
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",
@@ -116,7 +116,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
116
116
  );
117
117
 
118
118
  tag('substep').log(`Researching overlay: ${region.name}`);
119
- const sectionMarkdown = await this._analyzeExpandedAction('', region.name, diff, alreadyExpanded);
119
+ const sectionMarkdown = await this._analyzeExpandedAction('', region.name, diff, alreadyExpanded, region.root);
120
120
  if (!sectionMarkdown) {
121
121
  debugLog(`Overlay "${region.name}" produced no meaningful expansion`);
122
122
  return null;
@@ -421,9 +421,9 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
421
421
  await new Promise((r) => setTimeout(r, 500));
422
422
 
423
423
  let diff: Diff;
424
+ let currAR: ActionResult;
424
425
  try {
425
- await this.explorer.capture();
426
- const currAR = ActionResult.fromState(this.stateManager.getCurrentState()!);
426
+ currAR = await this.explorer.capture();
427
427
  diff = await currAR.diff(previousState);
428
428
  } catch (err) {
429
429
  tag('warning').log(`State capture failed after click: ${err instanceof Error ? err.message : err}`);
@@ -444,7 +444,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
444
444
  return { status: 'none', code: clickCode };
445
445
  }
446
446
 
447
- const sectionMarkdown = await this._analyzeExpandedAction(clickCode, description, diff, alreadyExpanded);
447
+ const sectionMarkdown = await this._analyzeExpandedAction(clickCode, description, diff, alreadyExpanded, currAR.overlay.root);
448
448
  await this._restorePageState(state.url, originalAria);
449
449
  if (!sectionMarkdown) return { status: 'none', code: clickCode };
450
450
  return { status: 'revealed', code: clickCode, sectionMarkdown };
@@ -467,7 +467,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
467
467
  }
468
468
  }
469
469
 
470
- private async _analyzeExpandedAction(code: string, description: string, diff: Diff, alreadyExpanded: string[]): Promise<string | null> {
470
+ private async _analyzeExpandedAction(code: string, description: string, diff: Diff, alreadyExpanded: string[], containerCss: string | null = null): Promise<string | null> {
471
471
  const alreadyHint = alreadyExpanded.length > 0 ? `\nAlready expanded sections:\n${alreadyExpanded.join('\n')}` : '';
472
472
 
473
473
  let intro: string;
@@ -532,7 +532,14 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
532
532
  const sections = parseResearchSections(text);
533
533
  if (sections.length === 0) return null;
534
534
 
535
- return sections[0].rawMarkdown;
535
+ const sectionMarkdown = sections[0].rawMarkdown;
536
+ if (!containerCss) return sectionMarkdown;
537
+
538
+ let heading = mdq(sectionMarkdown).query('h3[0]');
539
+ if (heading.count() === 0) heading = mdq(sectionMarkdown).query('h2[0]');
540
+ if (heading.count() === 0) return sectionMarkdown;
541
+
542
+ return heading.replace(`${heading.text().trimEnd()}\n\n> Container: '${containerCss}'\n\n`);
536
543
  }
537
544
 
538
545
  private _deduplicateExpandedSections(sections: string[]): string[] {
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
 
@@ -1265,7 +1271,7 @@ export async function failedToolResult(action: string, message: string, data?: R
1265
1271
  const errorTexts = [message, ...(data?.attempts?.map((a: any) => a.error || '') || [])];
1266
1272
  if (errorTexts.some((t: string) => t.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))) {
1267
1273
  const matched = await extractWebElements(error);
1268
- result.suggestion = getMultipleElementsSuggestion(matched);
1274
+ result.suggestion = getMultipleElementsSuggestion();
1269
1275
  result.multipleElementsDetected = true;
1270
1276
  result.elements = formatElementList(matched);
1271
1277
  return result;
@@ -1280,16 +1286,12 @@ export async function failedToolResult(action: string, message: string, data?: R
1280
1286
  return result;
1281
1287
  }
1282
1288
 
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
-
1289
+ function getMultipleElementsSuggestion(): string {
1288
1290
  return dedent`
1289
1291
  Multiple elements matched your locator, so that command did nothing — it selected no element and acted on none.
1290
1292
  Read the numbered elements list and act on the one you meant by its number:
1291
1293
  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}
1294
+ A match reported as not visible can never be acted on — pick one that is.
1293
1295
  If none of them is the element you want, narrow the locator with a container or its full unique text.
1294
1296
  If the list is missing, call xpathCheck() to see what the locator matches.
1295
1297
  `;
@@ -1365,6 +1367,8 @@ function formatElementList(matched: MatchedElement[] | null): string {
1365
1367
  .map((el, i) => {
1366
1368
  const lines = [`Element ${i + 1}:`, `Text: "${el.text}"`];
1367
1369
  if (el.visible !== undefined) lines.push(`Visible: ${el.visible}`);
1370
+ const wrapped = matched.map((_, j) => j).filter((j) => j !== i && matched[j].xpath.startsWith(`${el.xpath}/`));
1371
+ if (wrapped.length) lines.push(`Wraps: element ${wrapped.map((j) => j + 1).join(', ')}`);
1368
1372
  lines.push(`XPath: ${el.xpath}`, `HTML: ${el.html}`);
1369
1373
  return lines.join('\n');
1370
1374
  })
@@ -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 {
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;