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.
- package/boat/api-tester/src/ai/curler.ts +70 -66
- package/boat/api-tester/src/apibot.ts +19 -2
- package/boat/api-tester/src/cli.ts +87 -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/boat/api-tester/src/config.ts +18 -1
- package/dist/boat/api-tester/src/ai/curler.js +55 -56
- package/dist/boat/api-tester/src/apibot.js +15 -1
- package/dist/boat/api-tester/src/cli.js +89 -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/boat/api-tester/src/config.js +3 -1
- package/dist/package.json +4 -4
- package/dist/rules/researcher/pagination.md +6 -0
- package/dist/src/action-result.d.ts +6 -0
- package/dist/src/action-result.js +12 -0
- package/dist/src/ai/planner.js +4 -0
- 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/researcher/locators.js +1 -1
- package/dist/src/ai/researcher/pagination.d.ts +16 -0
- package/dist/src/ai/researcher/pagination.js +62 -0
- package/dist/src/ai/researcher/parser.d.ts +3 -0
- package/dist/src/ai/researcher/parser.js +22 -6
- package/dist/src/ai/researcher/sections.js +1 -1
- package/dist/src/ai/researcher.js +7 -2
- package/dist/src/ai/rules.js +16 -0
- package/dist/src/ai/scout.js +8 -2
- package/dist/src/ai/tools.d.ts +1 -1
- package/dist/src/ai/tools.js +25 -13
- 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/commands/options/ws-option.d.ts +7 -0
- package/dist/src/commands/options/ws-option.js +14 -0
- package/dist/src/config.d.ts +1 -0
- package/dist/src/config.js +14 -11
- package/dist/src/explorer.d.ts +1 -1
- package/dist/src/explorer.js +1 -1
- package/dist/src/remote.d.ts +2 -0
- package/dist/src/remote.js +23 -16
- package/dist/src/utils/aria.d.ts +2 -0
- package/dist/src/utils/aria.js +6 -1
- package/dist/src/utils/html-diff.js +4 -1
- package/dist/src/utils/markdown-query.d.ts +2 -0
- package/dist/src/utils/markdown-query.js +39 -0
- package/dist/src/utils/pagination.d.ts +16 -0
- package/dist/src/utils/pagination.js +20 -0
- package/docs/api-testing/basics.md +26 -2
- package/docs/superpowers/plans/2026-09-10-pagination.md +1420 -0
- package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +345 -0
- package/package.json +4 -4
- package/rules/researcher/pagination.md +6 -0
- package/src/action-result.ts +16 -0
- package/src/ai/planner.ts +4 -0
- package/src/ai/researcher/deep-analysis.ts +13 -6
- package/src/ai/researcher/locators.ts +1 -1
- package/src/ai/researcher/pagination.ts +68 -0
- package/src/ai/researcher/parser.ts +23 -5
- package/src/ai/researcher/sections.ts +1 -1
- package/src/ai/researcher.ts +9 -3
- package/src/ai/rules.ts +16 -0
- package/src/ai/scout.ts +9 -2
- package/src/ai/tools.ts +22 -14
- 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/commands/options/ws-option.ts +14 -0
- package/src/config.ts +15 -11
- package/src/explorer.ts +1 -1
- package/src/remote.ts +22 -15
- package/src/utils/aria.ts +8 -1
- package/src/utils/html-diff.ts +3 -1
- package/src/utils/markdown-query.ts +39 -0
- package/src/utils/pagination.ts +36 -0
|
@@ -0,0 +1,345 @@
|
|
|
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
|
+
Research finds out which strategy each list uses and records it in the UI map, beside that
|
|
26
|
+
list's own container. The tester already reads the UI map, so the fact arrives attached to the
|
|
27
|
+
list it describes. No new tool: the gesture already exists in CodeceptJS and is reachable
|
|
28
|
+
through `form`.
|
|
29
|
+
|
|
30
|
+
Nothing is injected per page — see section B for why the first attempt at that was wrong.
|
|
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
|
+
**1. Are there pagination controls? (AI, free)**
|
|
71
|
+
|
|
72
|
+
Controls are named in open-ended ways — words, arrows, bare numbers — so this is AI judgment,
|
|
73
|
+
not a pattern match. Researcher is already describing
|
|
74
|
+
the section, so it costs nothing extra:
|
|
75
|
+
a new `rules/researcher/pagination.md`, loaded alongside the existing three at
|
|
76
|
+
`src/ai/researcher/sections.ts:81`, asks it to note when a section contains controls that move
|
|
77
|
+
between pages of the same collection.
|
|
78
|
+
|
|
79
|
+
If found, the section records `> Pagination: controls` and the remaining steps are skipped.
|
|
80
|
+
|
|
81
|
+
**2. Markers and the scroll gate — one `page.evaluate` per container (`inspectList`)**
|
|
82
|
+
|
|
83
|
+
Only reached when research recorded nothing. Everything the probe needs comes back in one call,
|
|
84
|
+
**scoped to the container**, never to the page:
|
|
85
|
+
|
|
86
|
+
- `a[rel="next"]`/`a[rel="prev"]` inside it → `controls`, recorded without scrolling.
|
|
87
|
+
- `[role="feed"]` → `infinite`, recorded without scrolling. These are spec-defined relations, so
|
|
88
|
+
this tier is a lookup, not a heuristic.
|
|
89
|
+
- `scrollHeight > clientHeight`, or the container's bottom below the fold → it can scroll, so
|
|
90
|
+
step 3 may run. Neither → nothing recorded.
|
|
91
|
+
|
|
92
|
+
`aria-current` is excluded in every value: its primary spec use is a site-navigation link
|
|
93
|
+
marking the page you are on, and `aria-current="true"` is what tabs and breadcrumbs use.
|
|
94
|
+
`rel="next"`/`rel="prev"` carry the sequential meaning unambiguously.
|
|
95
|
+
|
|
96
|
+
The scoping is the point. Asking the same question of the whole page is what section B removed.
|
|
97
|
+
|
|
98
|
+
**3. Probe: does scrolling load more? (deterministic measurement)**
|
|
99
|
+
|
|
100
|
+
Scroll the container to its end, wait for readiness (`waitForPageReadiness`,
|
|
101
|
+
`src/utils/page-readiness.ts`), and compare descendant counts. More than before means the list
|
|
102
|
+
appends. Record `> Pagination: infinite`.
|
|
103
|
+
|
|
104
|
+
**Rows are the evidence, not requests.** The same rule the tester follows: a request that
|
|
105
|
+
brings no rows tells you nothing arrived. A page also fires telemetry and prefetches while
|
|
106
|
+
scrolling, so a bare request count would report growth where there is none. `networkRequests`
|
|
107
|
+
is private to `Action` (`src/action.ts:46`) and stays that way — nothing here needs widening.
|
|
108
|
+
|
|
109
|
+
**The scroll goes through `Action`, not through `page.evaluate`.** `deep-analysis.ts` sets the
|
|
110
|
+
precedent at `:405` — `this.explorer.action()`, then `action.attempt(cmd)` per command. Action
|
|
111
|
+
is the only thing that moves the browser (CLAUDE.md glue tiers), and going around it would
|
|
112
|
+
bypass the recorder and state updates. Measurement (row counts, scroll offsets) still uses
|
|
113
|
+
`withPage`, which reads without moving.
|
|
114
|
+
|
|
115
|
+
Then restore `scrollTop` to what it was, so screenshots, coordinates and later research see the
|
|
116
|
+
page as they found it. Scroll position is not app state, so this needs none of the modal
|
|
117
|
+
cleanup `_restorePageState` does in `deep-analysis.ts:453` — there is nothing to reuse there.
|
|
118
|
+
|
|
119
|
+
**Which sections get probed — `Data:` sections are the point.**
|
|
120
|
+
|
|
121
|
+
Researcher is instructed to emit a list of similar data items as a `## Data: <name>` section
|
|
122
|
+
holding a container and a summary line, no table (`src/ai/researcher.ts:502-509`). That is
|
|
123
|
+
precisely where a paginated list lands.
|
|
124
|
+
|
|
125
|
+
But `parseResearchSections` (`src/ai/researcher/parser.ts:100`) filters those out:
|
|
126
|
+
|
|
127
|
+
```js
|
|
128
|
+
.filter((s) => !SKIP_SECTIONS.has(s.name.toLowerCase()) && !s.name.toLowerCase().includes('data:'))
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`SKIP_SECTIONS` (`:27`) also drops a section literally named `data`. So iterating
|
|
132
|
+
`parseResearchSections` — as `validateContainers` does — would probe every section **except**
|
|
133
|
+
the lists. Nothing else in the codebase parses `Data:` sections today.
|
|
134
|
+
|
|
135
|
+
A new `parseDataSections(markdown): ResearchSection[]` in `parser.ts` returns them: the same
|
|
136
|
+
`parseSections` call, filtered to names beginning with `data:`, reusing
|
|
137
|
+
`extractContainerFromBlockquote` and yielding an empty `elements` array (Data sections carry no
|
|
138
|
+
table by construction). Both parsers stay single-purpose.
|
|
139
|
+
|
|
140
|
+
Steps 1–3 then run over `[...parseResearchSections(text), ...parseDataSections(text)]`. A
|
|
141
|
+
non-Data section can hold a list too, and it costs nothing to include it: step 2 gates it out
|
|
142
|
+
when it does not scroll.
|
|
143
|
+
|
|
144
|
+
**Recorded vocabulary:** `controls` or `infinite`, as a line in the section's container
|
|
145
|
+
blockquote. Nothing is written when a list neither paginates nor grows, which is the common
|
|
146
|
+
case and should stay silent.
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
> Container: '.semantic-container'
|
|
150
|
+
> Pagination: infinite
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**This line has a reader**, because section B injects the rule only when pagination was
|
|
154
|
+
detected, and that decision is code. `extractPaginationFromBlockquote(sectionMarkdown)` joins
|
|
155
|
+
`extractContainerFromBlockquote` (`src/ai/researcher/parser.ts:86`) and returns `'controls'`,
|
|
156
|
+
`'infinite'`, or null — anything else in the line is ignored, keeping the vocabulary closed.
|
|
157
|
+
|
|
158
|
+
That makes `Pagination:` a closed vocabulary read deterministically by code, so the envelope
|
|
159
|
+
checklist from CLAUDE.md applies and holds: read by code, scoped to a section of a state,
|
|
160
|
+
optional with "absent" as the default, and written by one module.
|
|
161
|
+
|
|
162
|
+
### A2. Where the code goes
|
|
163
|
+
|
|
164
|
+
New `src/ai/researcher/pagination.ts` mixin, composed into `ResearcherBase`
|
|
165
|
+
(`src/ai/researcher.ts:47`), owning steps 2 and 3. Step 1 is prompt text in
|
|
166
|
+
`rules/researcher/pagination.md` and needs no code.
|
|
167
|
+
|
|
168
|
+
It runs after `validateContainers` (`src/ai/researcher/locators.ts:268`), on containers that
|
|
169
|
+
survived validation, so a probe never targets a selector already known to be broken.
|
|
170
|
+
|
|
171
|
+
Not `deep-analysis.ts`: that mixin owns the same interact-measure-restore shape, but it is
|
|
172
|
+
gated behind `deep` (`src/ai/researcher.ts:287`), and infinite scroll has to be detected on
|
|
173
|
+
ordinary research runs too. It is also already 26k.
|
|
174
|
+
|
|
175
|
+
Not `locators.ts`: that mixin owns locator validity, not list behaviour.
|
|
176
|
+
|
|
177
|
+
**One writer for the blockquote.** `updateSectionContainer`
|
|
178
|
+
(`src/ai/researcher/locators.ts:300`) currently owns that `blockquote[0]` replace. The
|
|
179
|
+
pagination mixin must not write it independently. Extract the blockquote composition into one
|
|
180
|
+
helper both call, so `Container:` and `Pagination:` are always emitted by the same code.
|
|
181
|
+
|
|
182
|
+
A new `src/utils/pagination.ts` owns the two deterministic halves — the step 0 marker scan over
|
|
183
|
+
HTML, and the in-page evaluate functions for steps 2 and 3, self-contained with no outer-scope
|
|
184
|
+
references. One concern: how a list continues. `measureLayout` in `overlay.ts` is not reused:
|
|
185
|
+
it is xpath-based and returns a modal-scoring `RegionLayout`, while sections carry CSS
|
|
186
|
+
selectors and need neither.
|
|
187
|
+
|
|
188
|
+
### B. The UI map carries it — nothing is injected per page
|
|
189
|
+
|
|
190
|
+
**Superseded during implementation.** The original design injected a `<pagination>` block into
|
|
191
|
+
Tester and Navigator whenever a strategy was detected. That was wrong and is removed.
|
|
192
|
+
|
|
193
|
+
The condition was computed from the whole page's HTML, which carries no context. A pager
|
|
194
|
+
anywhere on the page — a sidebar list, a widget behind an open modal, a drawer — told the
|
|
195
|
+
tester "this list pages through a larger collection" whatever it was actually looking at. A
|
|
196
|
+
page-level answer cannot address a question about one list among several.
|
|
197
|
+
|
|
198
|
+
The UI map already solves this. Research records `> Pagination:` under the section's own
|
|
199
|
+
container, and the tester already reads the UI map. The fact arrives attached to the list it
|
|
200
|
+
describes, and says nothing about any other list on the page. `actionRule` documents
|
|
201
|
+
`I.scrollTo` for the capability itself (section C), which is genuinely page-independent.
|
|
202
|
+
|
|
203
|
+
Consequences:
|
|
204
|
+
|
|
205
|
+
- `paginationRuleFor` and `paginationFromResearch` are gone; `src/ai/rules.ts` gains only the
|
|
206
|
+
scroll commands in section C.
|
|
207
|
+
- `Pagination:` has no code reader, so the envelope checklist does not apply to it — it is
|
|
208
|
+
prompt context, like every other line in the UI map.
|
|
209
|
+
- Marker detection moves in-page, scoped to the container, inside `inspectList`. jsdom is no
|
|
210
|
+
longer used: it was pulled in only to parse whole-page HTML for the injection, and eagerly at
|
|
211
|
+
that, while the single existing use in `src/utils/xpath.ts:94` imports it lazily.
|
|
212
|
+
|
|
213
|
+
### C. `actionRule` documents the gesture
|
|
214
|
+
|
|
215
|
+
`<actions>` (`src/ai/rules.ts:307`) gains a scroll entry covering `I.scrollTo(<locator>)` —
|
|
216
|
+
stating that it scrolls every scrollable ancestor of the target — and
|
|
217
|
+
`I.scrollPageToBottom()` for the window. The `form` tool description
|
|
218
|
+
(`src/ai/tools.ts:354`) gains "reach items further down a list" as a use case, since it
|
|
219
|
+
currently reads as a typing tool.
|
|
220
|
+
|
|
221
|
+
### D. Tool output that contradicts the rule
|
|
222
|
+
|
|
223
|
+
Both are wrong reporting on existing tools, not new behaviour.
|
|
224
|
+
|
|
225
|
+
**`src/ai/tools.ts:415`** — `hasObservablePageChange` (`src/ai/tools.ts:1236`) ignores
|
|
226
|
+
`pageDiff.requests`. A scroll that fires a fetch which has not yet rendered rows, and a scroll
|
|
227
|
+
at the true end of a list, both return `failedToolResult` carrying the suggestion "Treat the
|
|
228
|
+
field/form action as not completed. Re-locate the editable control…" and commit
|
|
229
|
+
`TestResult.FAILED` into the note. That note is read by final review and by Historian, so a
|
|
230
|
+
correct end-of-list check is recorded as a failed test step.
|
|
231
|
+
|
|
232
|
+
Fix: one line in `hasObservablePageChange` — `if (data.pageDiff.requests?.length) return true;`
|
|
233
|
+
— and a no-change message that states what was observed rather than prescribing a
|
|
234
|
+
form-specific recovery.
|
|
235
|
+
|
|
236
|
+
**`src/ai/tools.ts:1208`** — `isMajorPageChange` (`src/ai/tools.ts:1220`) fires at
|
|
237
|
+
`ariaChangeCount >= 50` with no URL change (`LARGE_ARIA_CHANGE_THRESHOLD`,
|
|
238
|
+
`src/utils/aria.ts:581`), producing "MAJOR PAGE CHANGE. Page entered a different mode."
|
|
239
|
+
`diffByCount` (`src/utils/aria.ts:309`) pushes one entry per surplus occurrence, so a batch of
|
|
240
|
+
appended rows clears 50 easily. The rule says growth is the same state; the tool says the mode
|
|
241
|
+
changed.
|
|
242
|
+
|
|
243
|
+
Fix: a diff consisting of additions with no corresponding removals is growth, not a mode
|
|
244
|
+
change. Mode changes churn — they remove as well as add.
|
|
245
|
+
|
|
246
|
+
The added/removed split exists inside `diffAriaSnapshots` but is flattened into a single
|
|
247
|
+
`count` before it reaches the check, so it has to be threaded through:
|
|
248
|
+
|
|
249
|
+
1. `AriaDiff` (`src/utils/aria.ts:587`) gains `added: number; removed: number` — both arrays
|
|
250
|
+
are already computed at `src/utils/aria.ts:522`.
|
|
251
|
+
2. `Diff` (`src/action-result.ts:650`) stores them alongside `_ariaChangeCount`, set where
|
|
252
|
+
`diffAriaSnapshots` is called (`src/action-result.ts:740`), and exposes them.
|
|
253
|
+
3. `PageDiff` (`src/action-result.ts:46`) gains `ariaAdded` / `ariaRemoved`, populated next to
|
|
254
|
+
`ariaChanges` / `ariaChangeCount` (`src/action-result.ts:552`).
|
|
255
|
+
4. `isMajorPageChange` then reads the split instead of the total.
|
|
256
|
+
|
|
257
|
+
## Risks
|
|
258
|
+
|
|
259
|
+
- **The probe costs a scroll per candidate section.** Steps 1 and 2 narrow it to sections that
|
|
260
|
+
have no pagination controls and can actually scroll, which on most pages is zero or one. If
|
|
261
|
+
it still proves too slow, the gate to tighten is step 2, not the probe itself.
|
|
262
|
+
- **Region misclassification.** `OverlayPage.detectRegion` (`src/utils/overlay.ts:41`) accepts
|
|
263
|
+
in-flow added content that is `sizable` (≥5,000 chars, `src/utils/region.ts:99`), dominant
|
|
264
|
+
(≥70% of added raw size) and carries a name or root. A large appended batch in one list
|
|
265
|
+
container fits all three, which would fork the state hash with `region_<name>` and record a
|
|
266
|
+
transition. The probe can trigger this during research as well as the tester during a run.
|
|
267
|
+
Watch for it on the first real run; not changed here.
|
|
268
|
+
- **Virtualized lists.** Recycled nodes keep counts flat, so the probe sees no growth and the
|
|
269
|
+
aria diff shows renames (`src/utils/aria.ts:325`) rather than additions. Such a list records
|
|
270
|
+
nothing and the rule will not know to scroll it. Out of scope.
|
|
271
|
+
- **The stop condition lives only in the prompt.** No tool enforces it, so a model that keeps
|
|
272
|
+
scrolling past an attempt which added no rows will keep scrolling. Accepted: the tester's own
|
|
273
|
+
iteration cap is the only backstop.
|
|
274
|
+
- **A REST call alone no longer stops the loop, by design.** After the D fix an empty-payload
|
|
275
|
+
200 counts as an observable change, so the tool reports success. The rule makes rows, not
|
|
276
|
+
calls, the evidence that more arrived — otherwise a list that answers every scroll with an
|
|
277
|
+
empty page would loop.
|
|
278
|
+
|
|
279
|
+
## Testing
|
|
280
|
+
|
|
281
|
+
- Unit coverage for step 0's marker scan: `rel=next/prev` yields `controls`; `role="feed"` and
|
|
282
|
+
`aria-setsize="-1"` yield `infinite`; `aria-current` in any value yields nothing.
|
|
283
|
+
- Unit coverage for `parseDataSections`: a `## Data: Suites List` section with a container is
|
|
284
|
+
returned with its `containerCss`, and `parseResearchSections` still excludes it.
|
|
285
|
+
- Unit coverage for `extractPaginationFromBlockquote`: reads `controls` and `infinite`, returns
|
|
286
|
+
null for an absent line and for any other value.
|
|
287
|
+
- `tests/integration/researcher-sections.test.ts` — `> Pagination: controls` appears for a
|
|
288
|
+
section whose UI map holds next/prev controls, and step 1 is not asked when step 0 already
|
|
289
|
+
answered.
|
|
290
|
+
- A browser test for steps 2 and 3, following `tests/integration/overlay-modal-browser.test.ts`:
|
|
291
|
+
a container with its own scroller that appends on scroll (`infinite`), one that does not
|
|
292
|
+
(silent), a list with pagination controls (`controls`, no probe runs), and confirmation that
|
|
293
|
+
`scrollTop` is restored afterwards.
|
|
294
|
+
- An aimock prompt-inspection test that the matching fragment reaches the tester on a paginated
|
|
295
|
+
state, the other fragment does not, and neither appears on a state with no list, per
|
|
296
|
+
`docs/contributing/ai-integration-tests.md`.
|
|
297
|
+
- Unit coverage for the two `tools.ts` corrections: requests-only diff counts as observable;
|
|
298
|
+
an additions-only diff over the threshold is not a major page change.
|
|
299
|
+
|
|
300
|
+
## Out of scope
|
|
301
|
+
|
|
302
|
+
- Any new tool. The gesture is reachable through `form` today.
|
|
303
|
+
- Persisting the strategy across runs. Research recomputes it, and the research cache is
|
|
304
|
+
session-scoped.
|
|
305
|
+
- Virtualized list support.
|
|
306
|
+
- Changing `detectRegion` thresholds.
|
|
307
|
+
- The API signal below — a follow-up branch, not this one.
|
|
308
|
+
|
|
309
|
+
## Follow-up: pagination from the API
|
|
310
|
+
|
|
311
|
+
The DOM says a list continues; the API says **how far**. That number decides whether paging on
|
|
312
|
+
is worth it or the item is not in the collection at all, and nothing in this design can supply
|
|
313
|
+
it. Deferred to its own branch because it depends on the API boat being configured and lands on
|
|
314
|
+
Pilot rather than on Researcher or Tester.
|
|
315
|
+
|
|
316
|
+
**Already in place, verified:**
|
|
317
|
+
|
|
318
|
+
- `XhrCapture.captureReadEndpoint` (`src/api/xhr-capture.ts`) stores GETs as
|
|
319
|
+
`fullUrl = pathname + search`, so the **query string is already captured**.
|
|
320
|
+
- `queryParamNames()` extracts the names and `queryParamHint()` renders them into
|
|
321
|
+
`toEndpointList()` (`src/api/request-store.ts:78`), so fisherman already sees
|
|
322
|
+
`GET /api/items ?page,per_page` in its endpoint list.
|
|
323
|
+
- `askApi` (`src/ai/fisherman/tools.ts:220`) is wired to **Pilot alone**
|
|
324
|
+
(`src/ai/pilot.ts:786`), and `fisherman.lookupData()` issues live read-only requests, reading
|
|
325
|
+
full responses. Totals are reachable today without touching capture.
|
|
326
|
+
|
|
327
|
+
**The gap:** GET response bodies are deliberately dropped (`rawResponseBodyValue = ''`,
|
|
328
|
+
`responseHeaders: {}`), so `total`, `X-Total-Count` and `Link: rel="next"` are not passively
|
|
329
|
+
available. `askApi` re-requests instead, which is why that gap does not block this.
|
|
330
|
+
|
|
331
|
+
**Shape of the follow-up:**
|
|
332
|
+
|
|
333
|
+
1. A Pilot rule saying **when** asking is worth it — after repeated paging or scrolling has not
|
|
334
|
+
produced the target — never "ask about every list". Pilot *guides* while Tester *executes*,
|
|
335
|
+
and judging whether to keep paging is guidance; Pilot's conversation is also the light one,
|
|
336
|
+
so API reasoning belongs there rather than in Tester's ARIA-heavy loop.
|
|
337
|
+
2. A deterministic signal from captured read requests whose query params name a pagination
|
|
338
|
+
scheme (`page`, `offset`, `cursor`, `limit`, `per_page`). Free — the data is already stored,
|
|
339
|
+
and `queryParamNames` already isolates it.
|
|
340
|
+
|
|
341
|
+
**Rejected:** capturing GET response bodies to read totals passively. Bodies are large, reads
|
|
342
|
+
are discarded on purpose, and `askApi` already obtains them on demand.
|
|
343
|
+
|
|
344
|
+
Both must stay optional: `fisherman?.isAvailable()` already returns a clean "no API access,
|
|
345
|
+
judge from the page instead", and a run without an API boat must behave exactly as it does now.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "explorbot",
|
|
3
|
-
"version": "0.4.
|
|
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.
|
|
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.
|
|
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",
|
|
@@ -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>
|
package/src/action-result.ts
CHANGED
|
@@ -45,6 +45,8 @@ export interface PageDiff {
|
|
|
45
45
|
currentUrl: string;
|
|
46
46
|
ariaChanges?: string | null;
|
|
47
47
|
ariaChangeCount?: number;
|
|
48
|
+
ariaAdded?: number;
|
|
49
|
+
ariaRemoved?: number;
|
|
48
50
|
messages?: string[];
|
|
49
51
|
requests?: NetworkCall[];
|
|
50
52
|
consoleErrors?: string[];
|
|
@@ -551,6 +553,8 @@ export class ActionResult implements ActionResultData {
|
|
|
551
553
|
if (diff.ariaChanged) {
|
|
552
554
|
pageDiff.ariaChanges = diff.ariaChanged;
|
|
553
555
|
pageDiff.ariaChangeCount = diff.ariaChangeCount;
|
|
556
|
+
pageDiff.ariaAdded = diff.ariaAdded;
|
|
557
|
+
pageDiff.ariaRemoved = diff.ariaRemoved;
|
|
554
558
|
}
|
|
555
559
|
|
|
556
560
|
if (this.overlay.isOpen && (!previousState.overlay.isOpen || previousState.overlay.name !== this.overlay.name)) {
|
|
@@ -652,6 +656,8 @@ export class Diff {
|
|
|
652
656
|
private _messages: string[] = [];
|
|
653
657
|
private _ariaDiffResult: string | null = null;
|
|
654
658
|
private _ariaChangeCount = 0;
|
|
659
|
+
private _ariaAdded = 0;
|
|
660
|
+
private _ariaRemoved = 0;
|
|
655
661
|
private _isSameUrl: boolean;
|
|
656
662
|
|
|
657
663
|
constructor(
|
|
@@ -709,6 +715,14 @@ export class Diff {
|
|
|
709
715
|
return this._ariaChangeCount;
|
|
710
716
|
}
|
|
711
717
|
|
|
718
|
+
get ariaAdded(): number {
|
|
719
|
+
return this._ariaAdded;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
get ariaRemoved(): number {
|
|
723
|
+
return this._ariaRemoved;
|
|
724
|
+
}
|
|
725
|
+
|
|
712
726
|
get htmlDiff(): HtmlDiffResult | null {
|
|
713
727
|
return this._htmlDiffResult;
|
|
714
728
|
}
|
|
@@ -740,6 +754,8 @@ export class Diff {
|
|
|
740
754
|
const ariaDiff = diffAriaSnapshots(this.previous.ariaSnapshot, this.current.ariaSnapshot);
|
|
741
755
|
this._ariaDiffResult = ariaDiff.text;
|
|
742
756
|
this._ariaChangeCount = ariaDiff.count;
|
|
757
|
+
this._ariaAdded = ariaDiff.added;
|
|
758
|
+
this._ariaRemoved = ariaDiff.removed;
|
|
743
759
|
}
|
|
744
760
|
}
|
|
745
761
|
|
package/src/ai/planner.ts
CHANGED
|
@@ -452,12 +452,16 @@ export class Planner extends PlannerBase implements Agent {
|
|
|
452
452
|
}
|
|
453
453
|
}
|
|
454
454
|
|
|
455
|
+
let activeRegion = '';
|
|
456
|
+
if (state.overlay.isOpen) activeRegion = `Active region: ${state.overlay.describe()} — the user's current focus area. Plan tests for the controls inside it first.`;
|
|
457
|
+
|
|
455
458
|
conversation.addUserText(dedent`
|
|
456
459
|
${this.buildApproach(style)}
|
|
457
460
|
|
|
458
461
|
<context>
|
|
459
462
|
URL: ${state.url || 'Unknown'}
|
|
460
463
|
Title: ${state.title || 'Unknown'}
|
|
464
|
+
${activeRegion}
|
|
461
465
|
</context>
|
|
462
466
|
`);
|
|
463
467
|
|
|
@@ -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[] {
|
|
@@ -304,7 +304,7 @@ export function WithLocators<T extends Constructor>(Base: T) {
|
|
|
304
304
|
if (sectionQuery.count() === 0) sectionQuery = mdq(result.text).query(`section3(~"${escaped}")`);
|
|
305
305
|
|
|
306
306
|
if (newCss) {
|
|
307
|
-
result.text = sectionQuery.query('blockquote[0]').
|
|
307
|
+
result.text = sectionQuery.query('blockquote[0]').setKeyValue('Container', `'${newCss}'`);
|
|
308
308
|
} else {
|
|
309
309
|
result.text = sectionQuery.query('blockquote[0]').replace('');
|
|
310
310
|
result.text = result.text.replace(`${FOCUSED_MARKER}\n`, '');
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type Explorer from '../../explorer.ts';
|
|
2
|
+
import { mdq } from '../../utils/markdown-query.ts';
|
|
3
|
+
import { type ListMeasure, type PaginationStrategy, inspectList, restoreScroll } from '../../utils/pagination.ts';
|
|
4
|
+
import { type Constructor, debugLog } from './mixin.ts';
|
|
5
|
+
import { extractPaginationFromBlockquote, parseDataSections, parseResearchSections } from './parser.ts';
|
|
6
|
+
import type { ResearchResult } from './research-result.ts';
|
|
7
|
+
|
|
8
|
+
export function WithPagination<T extends Constructor>(Base: T) {
|
|
9
|
+
return class extends Base {
|
|
10
|
+
declare explorer: Explorer;
|
|
11
|
+
|
|
12
|
+
async detectPagination(result: ResearchResult): Promise<void> {
|
|
13
|
+
const sections = [...parseResearchSections(result.text), ...parseDataSections(result.text)];
|
|
14
|
+
|
|
15
|
+
for (const section of sections) {
|
|
16
|
+
const css = section.containerCss;
|
|
17
|
+
if (!css) continue;
|
|
18
|
+
if (extractPaginationFromBlockquote(section.rawMarkdown)) continue;
|
|
19
|
+
|
|
20
|
+
const strategy = await this.probeSection(css);
|
|
21
|
+
if (!strategy) continue;
|
|
22
|
+
|
|
23
|
+
this.recordPagination(result, section.name, strategy);
|
|
24
|
+
debugLog(`Pagination in "${section.name}": ${strategy}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private async probeSection(css: string): Promise<PaginationStrategy | null> {
|
|
29
|
+
const before = await this.measure(css);
|
|
30
|
+
if (!before) return null;
|
|
31
|
+
if (before.hasPagingControls) return 'controls';
|
|
32
|
+
if (before.isFeed) return 'infinite';
|
|
33
|
+
if (!before.scrolls) return null;
|
|
34
|
+
|
|
35
|
+
const action = this.explorer.action();
|
|
36
|
+
const scrolled = await action.attempt(`I.scrollTo('${css} > *:last-child')`).catch(() => false);
|
|
37
|
+
if (!scrolled) return null;
|
|
38
|
+
|
|
39
|
+
const after = await this.measure(css);
|
|
40
|
+
await this.explorer.withPage((page) => page.evaluate(restoreScroll, { css, scrollTop: before.scrollTop, pageScrollY: before.pageScrollY })).catch(() => {});
|
|
41
|
+
|
|
42
|
+
if (!after) return null;
|
|
43
|
+
if (after.items > before.items) return 'infinite';
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private measure(css: string): Promise<ListMeasure | null> {
|
|
48
|
+
return this.explorer
|
|
49
|
+
.withPage((page) => page.evaluate(inspectList, css))
|
|
50
|
+
.catch((err: Error) => {
|
|
51
|
+
debugLog(`List measurement failed for '${css}': ${err.message}`);
|
|
52
|
+
return null;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private recordPagination(result: ResearchResult, name: string, strategy: PaginationStrategy): void {
|
|
57
|
+
const escaped = name.replace(/"/g, '\\"');
|
|
58
|
+
let sectionQuery = mdq(result.text).query(`section2(~"${escaped}")`);
|
|
59
|
+
if (sectionQuery.count() === 0) sectionQuery = mdq(result.text).query(`section3(~"${escaped}")`);
|
|
60
|
+
if (sectionQuery.count() === 0) return;
|
|
61
|
+
result.text = sectionQuery.query('blockquote[0]').setKeyValue('Pagination', strategy);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface PaginationMethods {
|
|
67
|
+
detectPagination(result: ResearchResult): Promise<void>;
|
|
68
|
+
}
|
|
@@ -2,6 +2,7 @@ import { parseAriaLocator } from '../../utils/aria.ts';
|
|
|
2
2
|
import { pluralize } from '../../utils/logger.ts';
|
|
3
3
|
import { jsonToTable, parseSections, tableToJson } from '../../utils/markdown-parser.ts';
|
|
4
4
|
import { mdq } from '../../utils/markdown-query.ts';
|
|
5
|
+
import type { PaginationStrategy } from '../../utils/pagination.ts';
|
|
5
6
|
import { FOCUSED_MARKER } from './focus.ts';
|
|
6
7
|
|
|
7
8
|
export interface ResearchElement {
|
|
@@ -84,11 +85,9 @@ export function mapRowToElement(row: Record<string, string>): ResearchElement |
|
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
export function extractContainerFromBlockquote(sectionMarkdown: string): string | null {
|
|
87
|
-
const
|
|
88
|
-
if (!
|
|
89
|
-
const
|
|
90
|
-
if (!match) return null;
|
|
91
|
-
const css = normalizeLocatorValue(match[1]);
|
|
88
|
+
const entry = mdq(sectionMarkdown).query('blockquote[0]').keyValue().container;
|
|
89
|
+
if (!entry) return null;
|
|
90
|
+
const css = normalizeLocatorValue(entry);
|
|
92
91
|
if (!css || !/^[.#\[\w]/.test(css)) return null;
|
|
93
92
|
return css;
|
|
94
93
|
}
|
|
@@ -108,6 +107,25 @@ export function parseResearchSections(markdown: string): ResearchSection[] {
|
|
|
108
107
|
});
|
|
109
108
|
}
|
|
110
109
|
|
|
110
|
+
export function parseDataSections(markdown: string): ResearchSection[] {
|
|
111
|
+
return parseSections(markdown)
|
|
112
|
+
.filter((s) => s.name.toLowerCase().startsWith('data:'))
|
|
113
|
+
.map((section) => ({
|
|
114
|
+
name: section.name,
|
|
115
|
+
containerCss: extractContainerFromBlockquote(section.rawMarkdown),
|
|
116
|
+
elements: [],
|
|
117
|
+
rawMarkdown: section.rawMarkdown,
|
|
118
|
+
isExtended: false,
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function extractPaginationFromBlockquote(sectionMarkdown: string): PaginationStrategy | null {
|
|
123
|
+
const value = mdq(sectionMarkdown).query('blockquote[0]').keyValue().pagination?.toLowerCase();
|
|
124
|
+
if (value === 'controls') return 'controls';
|
|
125
|
+
if (value === 'infinite') return 'infinite';
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
111
129
|
export function extractValidContainers(researchText: string, opts?: { exclude?: string[] }): Array<{ css: string; label: string }> {
|
|
112
130
|
const exclude = opts?.exclude || [];
|
|
113
131
|
return parseResearchSections(researchText)
|
|
@@ -78,7 +78,7 @@ export function WithSections<T extends Constructor>(Base: T) {
|
|
|
78
78
|
|
|
79
79
|
private async _researchSingleSection(name: string, description: string, ariaSnapshot: string, focusCss: string | null): Promise<string> {
|
|
80
80
|
const currentUrl = this.stateManager.getCurrentState()?.url || '';
|
|
81
|
-
const rules = RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules'], currentUrl);
|
|
81
|
+
const rules = RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules', 'pagination'], currentUrl);
|
|
82
82
|
const url = this.actionResult?.url || 'Unknown';
|
|
83
83
|
const title = this.actionResult?.title || 'Unknown';
|
|
84
84
|
|