softr-vibe-coding 2.15.2 → 2.16.0
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/CHANGELOG.md +8 -0
- package/README.md +31 -5
- package/SKILL.md +11 -4
- package/datasources/fields.md +5 -1
- package/datasources/multi-datasource.md +3 -1
- package/datasources/reading.md +26 -0
- package/datasources/softr-database.md +6 -0
- package/datasources/writing.md +34 -0
- package/package.json +1 -1
- package/references/anti-patterns.md +24 -4
- package/references/browser-checks.md +433 -19
- package/references/common-patterns.md +193 -15
- package/references/native-chrome-styling.md +1 -1
- package/references/printing.md +2 -0
- package/references/qa-playbook.md +370 -0
- package/references/quick-reference.md +2 -0
- package/references/searchable-dropdown.md +8 -3
- package/references/softr-mcp.md +167 -18
- package/ui-ux-guidelines.md +46 -6
|
@@ -5,7 +5,11 @@ How to check a deployed block's rendering and behaviour in a Softr preview with
|
|
|
5
5
|
agent-browser v0.38.1 on macOS (Node 22) against a real Softr preview; only the commands under
|
|
6
6
|
[Untested but promising](#untested-but-promising) were not run. [Testing Custom Code header
|
|
7
7
|
CSS](#testing-custom-code-header-css) was verified 2026-10-05, except where it says otherwise, and
|
|
8
|
-
[the client's time zone](#1-the-clients-time-zone) on 2026-10-08.
|
|
8
|
+
[the client's time zone](#1-the-clients-time-zone) on 2026-10-08. The served-build proof in step 2,
|
|
9
|
+
the measuring and sizes rules in step 4, the create-save guard in step 6, [Forcing
|
|
10
|
+
states](#forcing-states) and [Exports and printouts](#exports-and-printouts) come from one
|
|
11
|
+
end-to-end QA pass of a 15-block app on 2026-10-08 (LCDB QA pass); a snippet marked as a sketch was
|
|
12
|
+
run on a local test page, not on a Softr preview.
|
|
9
13
|
|
|
10
14
|
## When to use it
|
|
11
15
|
|
|
@@ -13,8 +17,11 @@ After a push has passed the hash check in
|
|
|
13
17
|
[softr-mcp.md → Verifying a push](softr-mcp.md#verifying-a-push--the-deployed-source-is-the-only-proof).
|
|
14
18
|
The hash proves what Softr stored; a browser shows what it does: the layout at a given width, what
|
|
15
19
|
a control does, what a Save would send. **Not for data checks** (read records through the MCP or the
|
|
16
|
-
API
|
|
17
|
-
|
|
20
|
+
API; to compare a page's figures with those reads, see
|
|
21
|
+
[qa-playbook.md → Checking numbers against the database](qa-playbook.md#checking-numbers-against-the-database)),
|
|
22
|
+
and **not for logged-in Studio or Airtable work**, which needs the user's own session and so
|
|
23
|
+
belongs to the user's own Chrome tool. For a whole QA pass, in the order to run it, start at
|
|
24
|
+
[qa-playbook.md](qa-playbook.md); this file holds the browser mechanics it links to.
|
|
18
25
|
|
|
19
26
|
## Tool choice and why
|
|
20
27
|
|
|
@@ -62,7 +69,7 @@ Run every check in the time zone of the app's users, never the machine's. A head
|
|
|
62
69
|
the zone of the computer it runs on, and a date-only value arrives as midnight UTC
|
|
63
70
|
([fields.md → Date-only fields arrive as midnight UTC](../datasources/fields.md#date-only-fields-arrive-as-midnight-utc)):
|
|
64
71
|
a block that parses it with `new Date()` or `parseISO` shows the right day east of UTC and the day
|
|
65
|
-
before west of it. On 2026-10-08 three blocks of an app for
|
|
72
|
+
before west of it. On 2026-10-08 three blocks of an app for Pacific-time users had passed checks run in
|
|
66
73
|
Europe/Athens (UTC+3) while showing their users in America/Los_Angeles every date-only value a day
|
|
67
74
|
early. A test-data load found it, not the checks.
|
|
68
75
|
|
|
@@ -107,7 +114,7 @@ ab() { TZ=America/Los_Angeles agent-browser --session softr-check "$@"; } # st
|
|
|
107
114
|
ab open '<previewUrl>' >/dev/null # once per session: sets the preview cookie
|
|
108
115
|
ab set viewport 1280 900
|
|
109
116
|
ab open 'https://<subdomain>.preview.softr.app/<page>?recordId=<recordId>&autoUser=true'
|
|
110
|
-
ab wait --load networkidle # works on Softr previews
|
|
117
|
+
ab wait --load networkidle # works on Softr previews, but not while a route aborts the block's reads (below)
|
|
111
118
|
ab wait 2500 # 2000–3000 ms more, so the block's data hooks can load
|
|
112
119
|
```
|
|
113
120
|
|
|
@@ -115,6 +122,39 @@ ab wait 2500 # 2000–3000 ms more, so the block's dat
|
|
|
115
122
|
this one carries a sign-in token, hence `/dev/null`. The direct URL then loads the app itself, not
|
|
116
123
|
the toolbar shell that frames it, so `document` in `eval` is the app's.
|
|
117
124
|
|
|
125
|
+
**Do not wait for `networkidle` while a route aborts the block's reads** ([Forcing
|
|
126
|
+
states](#forcing-states)). The data hooks keep retrying, and the call did not return before the
|
|
127
|
+
shell's 120 s timeout (LCDB QA pass, 2026-10-08). Poll instead: fixed `ab wait 3000` steps, each
|
|
128
|
+
followed by an `eval` that looks for the expected text inside the shadow root. A selector-based wait
|
|
129
|
+
does not cross the shadow root (see Gotchas), so it cannot be the fallback.
|
|
130
|
+
|
|
131
|
+
**Prove which build the page loaded before you judge a change.** A fresh preview link, the
|
|
132
|
+
`version=` number in its URL and a press of the refresh button are not proof. The block's script
|
|
133
|
+
loads from `https://assets.softr-files.com/applications/<app>/vibe-coding/<page>/<block>/<versionId>/index.js`:
|
|
134
|
+
the page id, then the block id, then the `versionId` the push returned. That is the id in the served
|
|
135
|
+
script URL, **not** the id `vibe_coding_block_list_versions` shows for the same save (that tool
|
|
136
|
+
answers NOT_FOUND for a build id): report both, and use the first to prove what is served. Find the
|
|
137
|
+
URL in the page's resource timing, fetch that `index.js`, and count a string only the old build has
|
|
138
|
+
and one only the new build has (save it as a file and run it with `ab eval --stdin <`, step 4):
|
|
139
|
+
|
|
140
|
+
```js
|
|
141
|
+
(async () => {
|
|
142
|
+
const u = performance.getEntriesByType('resource').map(r => r.name)
|
|
143
|
+
.find(n => n.includes('/vibe-coding/') && n.includes('<blockId>'));
|
|
144
|
+
if (!u) return 'script not found: the block has not loaded yet';
|
|
145
|
+
const t = await (await fetch(u)).text();
|
|
146
|
+
return JSON.stringify({ versionId: u.split('/').slice(-2, -1)[0],
|
|
147
|
+
old: t.split('<old string>').length - 1, now: t.split('<new string>').length - 1 });
|
|
148
|
+
})()
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`versionId` must equal the push result's, and `old` must be 0 and `now` at least 1 (the snippet ran
|
|
152
|
+
on a local test page with the same URL shape, not on a preview). **Never text-search the whole page
|
|
153
|
+
HTML for the string:** Softr also inlines the block's source, comments included, so an old string
|
|
154
|
+
can still match inside a comment that is never shown (LCDB QA pass, 2026-10-08: a search of the
|
|
155
|
+
page matched a code comment; the served `index.js` held "Reload the page and try again." once and
|
|
156
|
+
the removed "Refreshing" zero times).
|
|
157
|
+
|
|
118
158
|
### 3. Reaching into the block
|
|
119
159
|
|
|
120
160
|
A block renders inside a shadow root, which CSS selectors and `find` locators do not cross.
|
|
@@ -123,9 +163,26 @@ and checkboxes as `[ref=eN]`, and `ab fill @eN '…'` and `ab click @eN` act ins
|
|
|
123
163
|
the ref out in the same shell call, so the readout never enters your context:
|
|
124
164
|
|
|
125
165
|
```bash
|
|
126
|
-
REF=$(ab snapshot -i | grep -
|
|
166
|
+
REF=$(ab snapshot -i | grep -F 'textbox "Search by' | grep -o 'ref=e[0-9]*' | head -1 | cut -d= -f2); ab fill "@$REF" 'term'
|
|
127
167
|
```
|
|
128
168
|
|
|
169
|
+
Match the role and name with `grep -F`, then pull out `ref=e[0-9]*`: a readout line can carry other
|
|
170
|
+
attributes before the ref (`[expanded=false, ref=e12]`), and a pattern that expects `[ref=` right
|
|
171
|
+
after the name misses it.
|
|
172
|
+
|
|
173
|
+
- **Hit-test before a coordinate click, in two parts.** `document.elementFromPoint(x, y)` must
|
|
174
|
+
return the block's host: anything else means something of Softr's (the phone tab bar, the top
|
|
175
|
+
bar) covers the point. Then `root.elementFromPoint(x, y)`, with the root found in step 4, must
|
|
176
|
+
return the element you mean, such as the modal backdrop and not the panel. The second call alone
|
|
177
|
+
ignores a fixed light-DOM bar on top, so a phone-size test that uses only it passes under the tab
|
|
178
|
+
bar (LCDB QA pass, 2026-10-08).
|
|
179
|
+
- **A backdrop has no accessible name, so no ref.** Pick a point outside the panel that the hit test
|
|
180
|
+
shows is the backdrop, then send the whole press and release the backdrop's guards listen to:
|
|
181
|
+
`ab mouse move X Y`, `ab mouse down`, `ab mouse up` (LCDB QA pass, 2026-10-08: a backdrop click
|
|
182
|
+
with a calendar open closed only the calendar).
|
|
183
|
+
- **A removed control is proven by searching every shadow root** for its text, `aria-label`, `title`
|
|
184
|
+
and icon class, not `document`, which finds nothing inside a block.
|
|
185
|
+
|
|
129
186
|
### 4. Measuring with `eval`
|
|
130
187
|
|
|
131
188
|
`ab eval "<expr>"`, or `ab eval --stdin < check.js` for anything longer. An async IIFE is awaited,
|
|
@@ -157,6 +214,60 @@ A block that uses the brand `DatePicker` ([date-picker.md](date-picker.md)) has
|
|
|
157
214
|
field is a button with a ref in `-i`. Click it, then click the day by its ref; each day is a button
|
|
158
215
|
named like `Thursday, October 15, 2026`.
|
|
159
216
|
|
|
217
|
+
**Measuring rules.** Measure every claim instead of eyeballing it; each of these once turned a wrong
|
|
218
|
+
"pass" into a finding (LCDB QA pass, 2026-10-08).
|
|
219
|
+
|
|
220
|
+
- **Prove the console capture on every load.** Run `ab console --clear` and `ab errors --clear`
|
|
221
|
+
before each `open`, then log a probe after it (`ab eval "console.error('probe')"`) and see it
|
|
222
|
+
listed. An empty console proves nothing until the probe shows.
|
|
223
|
+
- **Numbers, not looks.** A label is on one line when its height equals one line. "No sideways
|
|
224
|
+
scroll" is `scrollWidth - clientWidth` of 0 on the document, the block and every scroller. Touch
|
|
225
|
+
targets are 44px or more on phones. Save a screenshot to disk at every size.
|
|
226
|
+
- **Open every expandable row first.** A card in a grid widened the whole page by 163px at 375, and
|
|
227
|
+
only after a history row was opened.
|
|
228
|
+
- **Past the edge is not always overflow.** Walk up to the element's clipping ancestor before
|
|
229
|
+
calling it a defect: a screen-reader-only table inside `overflow: hidden` is fine.
|
|
230
|
+
- **An error must sit inside its dialog scroller's edges.** Compare its box with the scroller's top
|
|
231
|
+
and bottom. A check that only looked for the text passed while the error sat 21px below the fold
|
|
232
|
+
at 1280×800.
|
|
233
|
+
- **Measure a toast itself**, not the toaster list that holds it.
|
|
234
|
+
- **A ShadowRoot has no `innerText`.** It threw a TypeError; use
|
|
235
|
+
`[...root.children].map(c => c.innerText).join('\n')`. `root.textContent` works but runs words
|
|
236
|
+
together.
|
|
237
|
+
- **Make every lookup require its match.** A loose `aria-label` pattern matched "Download monthly
|
|
238
|
+
trend as CSV" and compared nothing. Count rows by link `href`, not by a label that some widths
|
|
239
|
+
hide (a "Last pickup" label found 3 of 10 rows at 1280).
|
|
240
|
+
- **Read figures from the root's text with a regex** (`textContent` plus a regex or `lastIndexOf`),
|
|
241
|
+
not from a snapshot: far smaller on data pages.
|
|
242
|
+
- **Sample fast states with a 25 ms recorder.** An interval that logs a button's text and disabled
|
|
243
|
+
flag, started from an idle page: a snapshot or screenshot can trigger Softr's refetch on window
|
|
244
|
+
focus and show the busy state early.
|
|
245
|
+
- **To find what moved the page**, wrap `window.scrollBy` and log its arguments, `scrollY` and a
|
|
246
|
+
timestamp. To check Escape order, add a spy on the document's bubbling `keydown`.
|
|
247
|
+
- **Truncation, placeholders and text under a close button:** `scrollWidth > clientWidth` everywhere
|
|
248
|
+
a message shows (a 100-character message was cut to "This line was not saved: the app c…"), canvas
|
|
249
|
+
`measureText` for a placeholder, and `Range.getClientRects` for header text under an absolutely
|
|
250
|
+
placed close button.
|
|
251
|
+
- **Softr pages set `scroll-behavior: smooth` on `html`** (seen in the preview). A coordinate click
|
|
252
|
+
below the fold does nothing and raises no error, and a measurement taken during a scroll reads
|
|
253
|
+
mid-animation. Scroll with `behavior: 'instant'` in one `eval` and measure in the next.
|
|
254
|
+
- **A top-level `const` in `eval` stays in the page**, so a second run throws "already been
|
|
255
|
+
declared". Keep the IIFE, or assign helpers to `window`.
|
|
256
|
+
|
|
257
|
+
**Sizes.** Check blocks at 1280×900, 1024×768 and 375×812, and modals also at 1280×600 and a
|
|
258
|
+
568×320 landscape phone (a date picker lost 5–7px in dialogs at 1280×600 and a third of its day
|
|
259
|
+
buttons on the landscape phone; nothing at the usual sizes showed it). Beside a sidebar, a block in
|
|
260
|
+
a 1024 window is only about 730–745px wide (measure it), which is under 48rem: a block whose
|
|
261
|
+
container queries switch at 48rem shows its phone layout on a tablet. Loop in zsh with two
|
|
262
|
+
variables, and print `innerWidth` each time so a shot that did not resize is caught:
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
for W H in 1280 900 1024 768 375 812; do ab set viewport $W $H; ab wait 1200; ab eval "innerWidth + 'x' + innerHeight"; done
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
CSS and container queries follow `set viewport`. A window `resize` handler did not run after it
|
|
269
|
+
once, so dispatch `new Event('resize')` after each change to be sure.
|
|
270
|
+
|
|
160
271
|
### 5. Date-only values against the stored ones
|
|
161
272
|
|
|
162
273
|
On every block that shows a date-only field, compare a few shown days with the stored ones, in
|
|
@@ -192,7 +303,32 @@ the client's zone ([step 1](#1-the-clients-time-zone)):
|
|
|
192
303
|
another field.
|
|
193
304
|
- **A "today" taken as the UTC day is wrong only part of the day.**
|
|
194
305
|
`new Date().toISOString().slice(0, 10)` is tomorrow from 5 pm in Los Angeles (4 pm in winter), so
|
|
195
|
-
a morning check misses it: look for it in the source instead
|
|
306
|
+
a morning check misses it: look for it in the source instead, and run the "today" checks inside
|
|
307
|
+
that window on purpose.
|
|
308
|
+
- **When:** while UTC is already on another day than the client. West of UTC that is from the
|
|
309
|
+
zone's offset before midnight until midnight (17:00 to 24:00 in Los Angeles in summer, 16:00 in
|
|
310
|
+
winter). East of UTC it is from midnight until the offset (00:00 to 03:00 in Athens). Log the
|
|
311
|
+
client's local time with each check (`TZ=America/Los_Angeles date`). A check at 00:50 in Los
|
|
312
|
+
Angeles cannot show the bug, because both days agree (LCDB QA pass, 2026-10-08: two write-pass
|
|
313
|
+
runs at 00:50 saved the right dates and still proved nothing about this class).
|
|
314
|
+
- **What first:** form date defaults, "no later than today" limits, this-month figures, ages, and
|
|
315
|
+
stamps in printouts and exports. A CSV "Generated" line read 2026-10-08T05:49Z while the client's
|
|
316
|
+
clock said 22:49 on the 7th.
|
|
317
|
+
- **Work out the expected labels from the page's own clock**, never hard-code them: read
|
|
318
|
+
`new Date()` in the page and derive "Oct 1 to 7" from it. A label computed per render shows a
|
|
319
|
+
day rollover only on the next render, so a test across local midnight needs a second render.
|
|
320
|
+
- **Check rows dated the 1st of a month on purpose.** A one-day-early bug moves them into the
|
|
321
|
+
month before, in per-month charts, "this month" counts and intake buckets. Find them with the
|
|
322
|
+
MCP's `database_search_records` and OR conditions (`date IS 2026-10-01`, `IS 2026-09-01`, …), then
|
|
323
|
+
see which bar or count each lands in.
|
|
324
|
+
- **A block that shows no dates still gets a short date check.** Prove it from three sides: its
|
|
325
|
+
data sources have no DATETIME field, its code never reads the clock (`new Date`, `Date.now`) or
|
|
326
|
+
formats a date, and no rendered tab shows date-like text (a regex for month names,
|
|
327
|
+
`yyyy-mm-dd`, `m/d/yyyy`, "today", "ago" and four-digit years) or a native date input.
|
|
328
|
+
- **Get more rows on screen with a broad search.** A list that opens on 8 rows showed 25 with a
|
|
329
|
+
common 4-digit search (part of many phone numbers), so a date check compares more records.
|
|
330
|
+
- To reach a moment you cannot wait for (month end, year end, a clock change), fake the page clock:
|
|
331
|
+
[Forcing states](#forcing-states).
|
|
196
332
|
|
|
197
333
|
### 6. Block saves before any click, and prove it
|
|
198
334
|
|
|
@@ -214,9 +350,26 @@ A `useRecordUpdate` save goes out as a PATCH, with the record ID in the path (se
|
|
|
214
350
|
PATCH https://<subdomain>.preview.softr.app/v1/datasource/applications/<app>/pages/<page>/blocks/<block>/datasources/<ds>/records-trigger/<recordId>
|
|
215
351
|
```
|
|
216
352
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
353
|
+
A create goes out the same way as a POST to `…/records-trigger/new`, and the one `*records-trigger*`
|
|
354
|
+
route blocked updates and creates alike (a live write pass and a blocked-create check, 2026-10-08).
|
|
355
|
+
Deletes are still unverified: before clicking a delete control, learn its URL with
|
|
356
|
+
`ab network requests` where a write is harmless, never on client data, and block that pattern too.
|
|
357
|
+
Do not assume `*records-trigger*` covers it. The URL shapes are in
|
|
358
|
+
[softr-mcp.md → What the server enforces on a block's data endpoints](softr-mcp.md#what-the-server-enforces-on-a-blocks-data-endpoints).
|
|
359
|
+
|
|
360
|
+
- **Re-probe the guard after every page load**, with the two `fetch` lines above. A guard you
|
|
361
|
+
proved once is not one you proved for this page.
|
|
362
|
+
- **Clear the request log before a click**, so that the read after it belongs to that click:
|
|
363
|
+
`ab network requests --clear`, click, then `ab network requests --filter records-trigger`. The
|
|
364
|
+
log otherwise survives page loads and redirects (it outlived a create's redirect), so an old
|
|
365
|
+
entry is not a new save.
|
|
366
|
+
- **A run-wide proof needs the log kept.** If you never clear it, one filtered read at the end
|
|
367
|
+
shows that the whole run wrote nothing. Clearing per click loses that, so keep each per-click
|
|
368
|
+
read, or do not clear.
|
|
369
|
+
- **At the end, summarise every non-GET request by method and endpoint**, not only
|
|
370
|
+
`records-trigger`. A block's reads are POSTs to `…/datasources/<name>/records`, so count them by
|
|
371
|
+
URL with `records-trigger` left out, never by method. A clean run showed 332 POSTs, all reads,
|
|
372
|
+
and nothing on `records-trigger`.
|
|
220
373
|
|
|
221
374
|
### 7. Click, then read what it sent
|
|
222
375
|
|
|
@@ -225,21 +378,260 @@ again: `updatedAt` and the field should be unchanged. The aborted request is sti
|
|
|
225
378
|
see the payload without it reaching the server:
|
|
226
379
|
|
|
227
380
|
```bash
|
|
381
|
+
ab network requests --clear # before the click
|
|
228
382
|
ab network requests --filter records-trigger # lists the save, with its id
|
|
229
383
|
ab network request <id> --json # method: PATCH, postData: {"context":{…},"fields":{"<fieldId>":"2026-10-15"}}
|
|
230
384
|
```
|
|
231
385
|
|
|
386
|
+
Read the body and URL from `postData`. An aborted save has no response, because nothing reached the
|
|
387
|
+
server. For requests that complete, `--json` carries a `responseBody` field (reads, and saves in a
|
|
388
|
+
write pass), though one run saw none for a save. So prove every save by reading the row back through
|
|
389
|
+
the MCP, not from the logged response.
|
|
390
|
+
|
|
391
|
+
- **Test a double tap with a real double click**, not two `element.click()` calls in one task. Two
|
|
392
|
+
scripted clicks in one task slipped past a guard held only in React state and sent two writes
|
|
393
|
+
that no user can send, while a real double click sent one (LCDB QA pass, 2026-10-08). Send the
|
|
394
|
+
real thing at a point you have hit-tested ([step 3](#3-reaching-into-the-block)):
|
|
395
|
+
`ab mouse move X Y`, then `ab mouse down`, `ab mouse up`, `ab mouse down`, `ab mouse up`; it sent
|
|
396
|
+
one write on a preview. The shorter form is `ab dblclick @<ref>`, run on a preview dialog in the
|
|
397
|
+
second round. Then read the code for the guard: it is set before the first `await` and held in a
|
|
398
|
+
ref as well as state.
|
|
399
|
+
- **Force-click a control that should be disabled** and count the writes sent. A click that prints
|
|
400
|
+
"Done" proves nothing: `ab click` reports success on a disabled control and does nothing.
|
|
401
|
+
- **Prove a lock by the payload and by `el.matches(':disabled')`.** Inputs inside a disabled
|
|
402
|
+
fieldset still report `.disabled === false`, and `fill` on one changes only the page's copy.
|
|
403
|
+
- **After a failed save, change the form and press Retry**, then compare the retried request body
|
|
404
|
+
with what the screen shows. The retry sent the values from the first click while the form showed
|
|
405
|
+
new ones.
|
|
406
|
+
- **Prove a leave-the-page guard** by dispatching `new Event('beforeunload', { cancelable: true })`
|
|
407
|
+
and reading `defaultPrevented`; without `cancelable` it is always false. The event fires on tab
|
|
408
|
+
close, reload and outside links, **not** on an in-app link such as the sidebar, so a guard for
|
|
409
|
+
those needs `useNavigationBlocker` ([common-patterns.md](common-patterns.md#navigation-blocker-for-unsaved-changes)).
|
|
410
|
+
|
|
232
411
|
### 8. Screenshots and cleanup
|
|
233
412
|
|
|
234
413
|
```bash
|
|
235
|
-
ab screenshot
|
|
236
|
-
ab network unroute
|
|
414
|
+
ab screenshot "$PWD/empty-1280.png" # ✓ Screenshot saved to … (an absolute path: see below)
|
|
415
|
+
ab network unroute '<pattern>' # one pattern at a time; the bare form is the very last step
|
|
237
416
|
ab close # ✓ Browser closed (no process left behind)
|
|
238
417
|
```
|
|
239
418
|
|
|
240
419
|
Give a path; without one, it writes to a temp directory. A saved screenshot costs no tokens until
|
|
241
420
|
someone opens it; one shown inline costs about 1.5k. Left alone, the daemon exits after an hour idle.
|
|
242
421
|
|
|
422
|
+
- **Use an absolute path.** A relative path resolves against the daemon's working directory, not
|
|
423
|
+
your shell's, and failed with "No such file or directory". Pass a scratch folder, so that nothing
|
|
424
|
+
lands in the project root.
|
|
425
|
+
- **Avoid `--full` on app pages.** It drew the sticky sidebar in the middle of the page. Scroll the
|
|
426
|
+
part into view (`ab scrollintoview <ref>`), wait a beat for the smooth scroll to end, and take a
|
|
427
|
+
viewport shot.
|
|
428
|
+
- **Unroute by pattern, never bare, until the last click is done.** A bare `ab network unroute`
|
|
429
|
+
also removes the write guard. Remove a forced-state route with the same pattern you added it with.
|
|
430
|
+
|
|
431
|
+
## Forcing states
|
|
432
|
+
|
|
433
|
+
Failed reads, slow saves, odd values and month ends rarely show up in normal use, so put them on
|
|
434
|
+
screen on purpose. None of these changes data: each is a browser-only trick. But the write guard
|
|
435
|
+
([step 6](#6-block-saves-before-any-click-and-prove-it)) must be in place in every session, and a new
|
|
436
|
+
session starts without it. **Verified 2026-10-08** with agent-browser 0.38.1 against a real preview
|
|
437
|
+
(LCDB QA pass); the two scripts and the route patterns are the ones that pass used.
|
|
438
|
+
|
|
439
|
+
### A fake clock
|
|
440
|
+
|
|
441
|
+
`--init-script` runs a script before the page's own code. It goes on the command that starts a new
|
|
442
|
+
session, so use a fresh session name, keep the time zone from [step 1](#1-the-clients-time-zone), and
|
|
443
|
+
give the script path in full (an absolute path: [step 8](#8-screenshots-and-cleanup)):
|
|
444
|
+
|
|
445
|
+
```bash
|
|
446
|
+
TZ=America/Los_Angeles agent-browser --session clock-check --init-script "$PWD/clock.js" open 'about:blank' >/dev/null
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
A new session starts with no routes and no preview cookie. Point `ab` at it, open the preview link in
|
|
450
|
+
it ([step 2](#2-session-preview-cookie-page)), then add and prove the write guard (step 6) before
|
|
451
|
+
any click.
|
|
452
|
+
|
|
453
|
+
`clock.js` replaces `window.Date` with one shifted by a fixed offset. `Date.now()`, `new Date()` and
|
|
454
|
+
`Date()` move; `new Date(x)`, `Date.parse` and `Date.UTC` stay real:
|
|
455
|
+
|
|
456
|
+
```js
|
|
457
|
+
(() => { if (window.__fakeClock) return; window.__fakeClock = true;
|
|
458
|
+
const RealDate = Date, offset = RealDate.parse('2026-10-31T23:30:00-07:00') - RealDate.now();
|
|
459
|
+
function FakeDate(...a) {
|
|
460
|
+
if (!(this instanceof FakeDate)) return new RealDate(RealDate.now() + offset).toString();
|
|
461
|
+
return a.length === 0 ? new RealDate(RealDate.now() + offset) : new RealDate(...a);
|
|
462
|
+
}
|
|
463
|
+
FakeDate.prototype = RealDate.prototype; FakeDate.now = () => RealDate.now() + offset;
|
|
464
|
+
FakeDate.parse = RealDate.parse; FakeDate.UTC = RealDate.UTC;
|
|
465
|
+
Object.defineProperty(FakeDate, 'name', { value: 'Date' });
|
|
466
|
+
window.Date = FakeDate;
|
|
467
|
+
})();
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
The script runs again on every full page load, so the clock restarts at the target there and runs
|
|
471
|
+
on from it (read off the script, not measured). Moments worth testing in Los Angeles: Oct 31 at 23:30 (UTC is already Nov 1),
|
|
472
|
+
Dec 31 at 22:00 (UTC is already the new year), the first Monday of January, and the day after a
|
|
473
|
+
clock change. Every "this month" label held in the pass, and the year-end report showed "-0 items
|
|
474
|
+
out" for an empty year, a bug no real-time check would have reached.
|
|
475
|
+
|
|
476
|
+
**Reach a save-time check the UI blocks** by shifting the page clock just before Save. When a date
|
|
477
|
+
picker disables future days, pick today, set `window.Date` one day behind, click Save, then restore
|
|
478
|
+
it: the "no future date" guard fires and no request is sent.
|
|
479
|
+
|
|
480
|
+
### Hold a save in flight
|
|
481
|
+
|
|
482
|
+
To test double taps and "Saving" states, a script that wraps `window.fetch` can log every
|
|
483
|
+
`records-trigger` call and then reject it after 6 s, so nothing reaches the server (add and prove the
|
|
484
|
+
write guard in this session too). Start the session with it, as above:
|
|
485
|
+
|
|
486
|
+
```js
|
|
487
|
+
(() => { if (window.__hang) return; window.__hang = true; window.__writes = [];
|
|
488
|
+
const real = window.fetch.bind(window);
|
|
489
|
+
window.fetch = function (input, init) {
|
|
490
|
+
const url = typeof input === 'string' ? input : (input && input.url) || '';
|
|
491
|
+
if (/records-trigger/.test(url)) {
|
|
492
|
+
window.__writes.push({ m: (init && init.method) || 'GET', u: url.replace(/^.*\/datasources\//, ''),
|
|
493
|
+
b: String((init && init.body) || '').slice(0, 400) });
|
|
494
|
+
return new Promise((_, rej) => setTimeout(() => rej(new TypeError('Failed to fetch')), 6000));
|
|
495
|
+
}
|
|
496
|
+
return real(input, init);
|
|
497
|
+
};
|
|
498
|
+
})();
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
Read `window.__writes` afterwards. In the pass, five blocks each sent exactly one write on a double
|
|
502
|
+
tap, with the button disabled while it waited. Click as [step 7](#7-click-then-read-what-it-sent) says.
|
|
503
|
+
|
|
504
|
+
**Fail only some requests** with the same wrapper: reject from the Nth request to one read URL on
|
|
505
|
+
(the 4th and later reads of one table), and check that no page shows a figure built from half the
|
|
506
|
+
data. The same idea fails the Nth write.
|
|
507
|
+
|
|
508
|
+
### Fail reads by route
|
|
509
|
+
|
|
510
|
+
A block's reads are POSTs to `…/v1/datasource/applications/<app>/pages/<page>/blocks/<block>/datasources/<name>/records`,
|
|
511
|
+
where `<name>` is the name in `datasource.define`. None of these patterns matches the
|
|
512
|
+
`records-trigger` save URLs, so the write guard stays in place:
|
|
513
|
+
|
|
514
|
+
```bash
|
|
515
|
+
ab network route '*/blocks/<blockId>/datasources/*/records' --abort # every read of one block
|
|
516
|
+
ab network route '*/datasources/<name>/records' --abort # one read
|
|
517
|
+
ab network route '*/datasources/<name>/records/*' --abort # a single-record read (…/records/<id>)
|
|
518
|
+
ab network unroute '<the same pattern>' # never bare: see step 8
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
- **Say which reads you broke.** A detail page's single-record read ends in `/records/<id>`, which the
|
|
522
|
+
list pattern does not match: with the list pattern the lists failed while the profile still loaded.
|
|
523
|
+
- **Prove the route with a `fetch` to the exact URL** before you reload, as for the write guard.
|
|
524
|
+
- **The error state comes only after the hooks' retries**: 6 to 20 s in the pass. Hold the abort until
|
|
525
|
+
it shows, sampling at 3, 6, 9, 12 and 20 s; letting go early lets a retry succeed. Do
|
|
526
|
+
not wait for `networkidle` (step 2).
|
|
527
|
+
- **Find the block's root by text present in both states**, such as the h1: the normal text is gone
|
|
528
|
+
in the error state.
|
|
529
|
+
- **A busy "Try again" shows only after the data has loaded once.** With no data yet, the error
|
|
530
|
+
state disappears while Try again re-reads, because the data library resets the read to loading.
|
|
531
|
+
Load the page, add the route, then trigger a background re-read without a click:
|
|
532
|
+
`window.dispatchEvent(new Event('visibilitychange')); window.dispatchEvent(new Event('focus'))`.
|
|
533
|
+
It refetched on one block and not on another (probably only once the data is stale). If nothing
|
|
534
|
+
re-reads, abort the read, reload, wait for the error (about 11 s), then install the fetch patch,
|
|
535
|
+
unroute, and press Try again.
|
|
536
|
+
- **Test Try again by failing every read, restoring them, pressing it once, and then checking every
|
|
537
|
+
section.** A Try again that re-reads only the main list leaves the other failed reads failed:
|
|
538
|
+
after it, every row said "No children on record" and the duplicate check ran with no children.
|
|
539
|
+
Start the state recorder from an idle page ([step 4](#4-measuring-with-eval)).
|
|
540
|
+
|
|
541
|
+
### Serve a changed read
|
|
542
|
+
|
|
543
|
+
To put a state on screen that the data lacks, answer a read with an edited copy of a real one. Copy
|
|
544
|
+
a captured response with `ab network request <id> --json` (its `responseBody` field), edit it, and
|
|
545
|
+
serve it:
|
|
546
|
+
|
|
547
|
+
```bash
|
|
548
|
+
ab network route '*/datasources/<name>/records' --body "$(cat fake.json)"
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
- **Keep Softr's shape.** On Softr Database that is `{total, limit, offset, items, empty, complete: true}`;
|
|
552
|
+
`complete: true` stops the paging.
|
|
553
|
+
- **Serve every empty-like value:** `null`, `''`, `0`, and the field missing. A threshold served as
|
|
554
|
+
null showed "Threshold 2,000 (default)"; every threshold served as -999999 showed the new empty
|
|
555
|
+
text; one block got negative stock and an empty product list the same way. No data changed.
|
|
556
|
+
- **Pick values where the old and the new behaviour differ**, so the check proves the setting was
|
|
557
|
+
read. Remove the route with `unroute` on the same pattern, and prove it is gone by seeing real values.
|
|
558
|
+
- **The first route registered for a URL wins.** A mock added after an abort on the same URL never
|
|
559
|
+
answers.
|
|
560
|
+
- **Change a setting only in page memory or in a served read, never in the table.** A monthly cap of
|
|
561
|
+
12 was set to 2 in the page's memory to open its warning windows; the Settings table was never touched.
|
|
562
|
+
|
|
563
|
+
### "Saved, but a line failed"
|
|
564
|
+
|
|
565
|
+
To reach the state where a record saved and one of its lines did not, stub `window.fetch` for the one
|
|
566
|
+
create URL (a sketch, run on a local test page, not on a preview):
|
|
567
|
+
|
|
568
|
+
```js
|
|
569
|
+
(() => {
|
|
570
|
+
window.__realFetch = window.__realFetch || window.fetch.bind(window); // a second run would wrap the stub
|
|
571
|
+
window.fetch = (input, init) => {
|
|
572
|
+
const url = typeof input === 'string' ? input : (input && input.url) || '';
|
|
573
|
+
if (/\/records-trigger\/new$/.test(url) /* && the create you mean: match its body, or count calls */)
|
|
574
|
+
return Promise.resolve(new Response(JSON.stringify({ record: { id: 'qa-fake-1', fields: {} } }),
|
|
575
|
+
{ status: 200, headers: { 'content-type': 'application/json' } }));
|
|
576
|
+
return window.__realFetch(input, init);
|
|
577
|
+
};
|
|
578
|
+
})()
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
Return `{ record: { id, fields } }` with a JSON `content-type`. For the create, `network route --body`
|
|
582
|
+
was not enough: it sends no content type and the Softr SDK refused the response (reads served with
|
|
583
|
+
`--body` worked, above). Keep the original `fetch` in a global first, because a second run
|
|
584
|
+
otherwise leaves `window.fetch` undefined. Keep the `*records-trigger*` abort behind the stub, so the
|
|
585
|
+
stubbed create is the only one that answers and every other write is still blocked. The footer then
|
|
586
|
+
read "The allotment is saved, but 2 size lines are not", Close and Retry worked, and the
|
|
587
|
+
tables were unchanged afterwards.
|
|
588
|
+
|
|
589
|
+
## Exports and printouts
|
|
590
|
+
|
|
591
|
+
A headless run gets neither a download nor a pop-up window, so capture them in the page. Both
|
|
592
|
+
sketches ran on a local test page, not on a preview.
|
|
593
|
+
|
|
594
|
+
**A CSV.** Before the click, stub `URL.createObjectURL` to keep the Blob and
|
|
595
|
+
`HTMLAnchorElement.prototype.click` to do nothing, then click the export and read the Blob:
|
|
596
|
+
|
|
597
|
+
```js
|
|
598
|
+
window.__csvs = [];
|
|
599
|
+
URL.createObjectURL = b => { window.__csvs.push(b); return 'blob:stub'; };
|
|
600
|
+
HTMLAnchorElement.prototype.click = () => {};
|
|
601
|
+
// click Export, then read it in an async IIFE (a top-level `await` is a SyntaxError in `ab eval`):
|
|
602
|
+
(async () => JSON.stringify([await window.__csvs[0].text(),
|
|
603
|
+
[...new Uint8Array(await window.__csvs[0].arrayBuffer(), 0, 3)].map(x => x.toString(16))]))()
|
|
604
|
+
// ["<the CSV text>",["ef","bb","bf"]] ef,bb,bf = the BOM
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
`Blob.text()` drops a UTF-8 BOM, so check the BOM through `arrayBuffer()`. A "Generated" line read
|
|
608
|
+
UTC (05:49Z on the 8th while the client's clock said 22:49 on the 7th); after the fix it read the
|
|
609
|
+
local time with its offset.
|
|
610
|
+
|
|
611
|
+
**A printout.** The Print control opens a window of its own ([printing.md](printing.md)). Replace
|
|
612
|
+
`window.open` with a hidden iframe that stands in for the print window, count its `print()` calls,
|
|
613
|
+
and measure the printout's cells in it, 700px wide for roughly A4 or Letter at 14mm margins:
|
|
614
|
+
|
|
615
|
+
```js
|
|
616
|
+
window.__prints = 0;
|
|
617
|
+
window.open = () => {
|
|
618
|
+
const f = document.createElement('iframe');
|
|
619
|
+
f.style.cssText = 'position:fixed;top:0;left:0;width:700px;height:900px;border:0;visibility:hidden';
|
|
620
|
+
document.body.appendChild(f);
|
|
621
|
+
f.contentWindow.print = () => { window.__prints++; };
|
|
622
|
+
window.__printWin = f.contentWindow;
|
|
623
|
+
return f.contentWindow;
|
|
624
|
+
};
|
|
625
|
+
// click Print, wait about 5 s, then measure inside window.__printWin.document:
|
|
626
|
+
// [...window.__printWin.document.querySelectorAll('td')].map(td => [td.scrollWidth, td.clientWidth])
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
The wait is longer than a beat: [printing.md §3](printing.md#3-print-only-when-it-is-ready)
|
|
630
|
+
holds `print()` until the stylesheets, fonts and images are in (capped at about 4 s), plus 250 ms, so
|
|
631
|
+
an earlier read finds `__prints` still 0. A cell that never wraps overflows its column without any
|
|
632
|
+
error; one month label longer than its column was found this way. For the Playwright pop-up route,
|
|
633
|
+
see [printing.md → Gotchas and testing](printing.md#7-gotchas-and-testing).
|
|
634
|
+
|
|
243
635
|
## Testing Custom Code header CSS
|
|
244
636
|
|
|
245
637
|
CSS in **Settings → Custom Code → Code inside header** applies to every page of the app, and the
|
|
@@ -399,20 +791,41 @@ on the published app, logged out.
|
|
|
399
791
|
- **zsh does not word-split.** `AB="agent-browser --session x"; $AB open …` fails with "command not
|
|
400
792
|
found": use the function. Agent shells usually keep no functions or variables between calls, so
|
|
401
793
|
define `ab`, and grep any ref, in the call that uses them; the browser lives on in the daemon.
|
|
794
|
+
The same slip in a viewport loop (`set -- $vp`, or `ab set viewport $vp`) left every shot at 1280
|
|
795
|
+
in more than ten checks of one pass: see Sizes in step 4.
|
|
796
|
+
- **More shell slips that spoil a run.** zsh: `echo ====` fails with "== not found" and stops the
|
|
797
|
+
chain (quote it), a function named like an alias will not define, and `"$d[^[]…"` inside a
|
|
798
|
+
double-quoted pattern is read as an array subscript. macOS: there is no `timeout`, `sed -i` needs
|
|
799
|
+
`''` (patch with Python instead), and a stray `cat > file` inside a multi-line command waits for
|
|
800
|
+
input until the call times out at 120 s: run hash loops from a script file, with `< /dev/null`.
|
|
402
801
|
- **Selectors stop at the shadow root.** `ab fill 'input[placeholder^="…"]' 'x'` gives
|
|
403
802
|
`✗ Element not found`, and `find` locators fail the same way (upstream issue
|
|
404
803
|
vercel-labs/agent-browser#1266, open since April 2026). Use refs, or `eval`.
|
|
405
|
-
- **Refs change on every page load.** Grep again after each `open` or reload
|
|
804
|
+
- **Refs change on every page load, and on any DOM change.** Grep again after each `open` or reload,
|
|
805
|
+
take the ref from a snapshot in the same command as the click, and take a second snapshot after
|
|
806
|
+
opening a dialog or a list (the first can miss it). A new row's list may need a second open before
|
|
807
|
+
its option refs appear. While an `aria-modal` dialog is open, `snapshot -i` lists only the dialog.
|
|
808
|
+
- **Grep by the role the markup really has.** A segmented control built from radio inputs reads as
|
|
809
|
+
`radio "X"`, not `tab`.
|
|
810
|
+
- **`fill` has traps.** On a number input that holds text it appends ("-3" then "2.5" gave "-32.5").
|
|
811
|
+
`fill ''` empties the input but leaves React's state on the old value. `fill` with an empty ref
|
|
812
|
+
types into whatever has focus. Inside a disabled fieldset only the page's copy changes. A fill also
|
|
813
|
+
changed a field behind an open modal, so close dialogs before row checks. Set a value with the
|
|
814
|
+
native setter plus `input` and `change` events (step 4), then type with `ab keyboard type`.
|
|
815
|
+
- **Select-all is unreliable headless.** `Meta+a` failed in one run and `Control+a` in another: use
|
|
816
|
+
the native setter. An in-app browser pane's `type` action inserts text without key events, so it
|
|
817
|
+
can make a correct control look broken.
|
|
406
818
|
- **Readouts are huge on data pages.** A table-heavy page measured 268 KB in full, 197 KB with `-c`
|
|
407
819
|
and 164 KB with `-i` (or `-i -c`), about 40k tokens: `-i` keeps table cells as context for the
|
|
408
820
|
buttons in them. A small page was 5.8 KB, 2.9 KB with `-i`. Never print one on a data page: grep
|
|
409
821
|
it, or write it to a file.
|
|
410
|
-
- **
|
|
411
|
-
that looks for the record ID in the body; that one once let a
|
|
412
|
-
the method or the body.
|
|
822
|
+
- **Updates are PATCH, creates are POST, and an update's record ID is in the path.** A guard on POST
|
|
823
|
+
misses updates, and so does one that looks for the record ID in the body; that one once let a
|
|
824
|
+
write through. Match the URL, never the method or the body.
|
|
413
825
|
- **Plain `ab network request <id>` printed only the URL** of the blocked save. Add `--json`.
|
|
414
826
|
- **A preview serves the version it was minted on.** After every push, mint a fresh one with
|
|
415
|
-
`application_preview` and open it again before checking anything
|
|
827
|
+
`application_preview` and open it again before checking anything, then prove which build loaded
|
|
828
|
+
(step 2).
|
|
416
829
|
- **The preview URL is a sign-in token.** Never share it ([why](softr-mcp.md#application-management-tools)).
|
|
417
830
|
- **Attachment URLs are re-signed on every read:** compare by id, filename and size, never by URL.
|
|
418
831
|
|
|
@@ -429,8 +842,9 @@ In agent-browser's docs, **not yet tried against a Softr preview**. Try one befo
|
|
|
429
842
|
|
|
430
843
|
- `ab pdf <path>`: to check a print layout ([printing.md](printing.md#7-gotchas-and-testing) has
|
|
431
844
|
the verified Playwright route).
|
|
432
|
-
-
|
|
433
|
-
|
|
845
|
+
- `ab addinitscript <js>` (at runtime, on a session that is already open): for example, to stub
|
|
846
|
+
`window.print` before load. The `--init-script <path>` flag at session start is verified: see
|
|
847
|
+
[Forcing states](#forcing-states).
|
|
434
848
|
- `ab screenshot --if-changed`: skips a screenshot that matches the last one.
|
|
435
849
|
- `ab diff snapshot`: compares the current readout with the last one.
|
|
436
850
|
- `ab a11y`: the built-in axe-core accessibility audit.
|