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.
- package/boat/api-tester/src/apibot.ts +18 -2
- package/boat/api-tester/src/cli.ts +85 -274
- package/boat/api-tester/src/commands/api-command.ts +10 -0
- package/boat/api-tester/src/commands/explore-command.ts +52 -0
- package/boat/api-tester/src/commands/init-command.ts +119 -0
- package/boat/api-tester/src/commands/know-command.ts +44 -0
- package/boat/api-tester/src/commands/plan-command.ts +42 -0
- package/boat/api-tester/src/commands/test-command.ts +54 -0
- package/dist/boat/api-tester/src/apibot.js +14 -1
- package/dist/boat/api-tester/src/cli.js +87 -243
- package/dist/boat/api-tester/src/commands/api-command.js +7 -0
- package/dist/boat/api-tester/src/commands/explore-command.js +41 -0
- package/dist/boat/api-tester/src/commands/init-command.js +88 -0
- package/dist/boat/api-tester/src/commands/know-command.js +39 -0
- package/dist/boat/api-tester/src/commands/plan-command.js +37 -0
- package/dist/boat/api-tester/src/commands/test-command.js +45 -0
- package/dist/package.json +4 -4
- package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
- package/dist/src/ai/researcher/deep-analysis.js +14 -6
- package/dist/src/ai/tools.d.ts +1 -1
- package/dist/src/ai/tools.js +15 -10
- package/dist/src/api/spec-reader.d.ts +1 -0
- package/dist/src/api/spec-reader.js +93 -1
- package/dist/src/commands/base-command.d.ts +3 -3
- package/dist/src/commands/init-command.d.ts +3 -0
- package/dist/src/commands/init-command.js +6 -3
- package/dist/src/explorer.d.ts +1 -1
- package/dist/src/explorer.js +1 -1
- package/dist/src/utils/html-diff.js +4 -1
- package/docs/api-testing/basics.md +26 -2
- package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +317 -0
- package/package.json +4 -4
- package/src/ai/researcher/deep-analysis.ts +13 -6
- package/src/ai/tools.ts +15 -11
- package/src/api/spec-reader.ts +106 -1
- package/src/commands/base-command.ts +3 -3
- package/src/commands/init-command.ts +6 -3
- package/src/explorer.ts +1 -1
- 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.
|
|
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.
|
|
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.
|
|
127
|
-
"playwright-core": "^1.
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
})
|
package/src/api/spec-reader.ts
CHANGED
|
@@ -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 =
|
|
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:
|
|
26
|
+
protected explorBot: T;
|
|
27
27
|
|
|
28
|
-
constructor(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
|
-
|
|
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;
|