softr-vibe-coding 2.15.3 → 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.
@@ -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), and **not for logged-in Studio or Airtable work**, which needs the user's own session and so
17
- belongs to the user's own Chrome tool.
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 Oregon had passed checks run in
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 -o 'textbox "Search by[^[]*\[ref=e[0-9]*' | head -1 | grep -o 'e[0-9]*$'); ab fill "@$REF" 'term'
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
- Only this update endpoint is verified. Before clicking a create or delete control, learn its URL
218
- with `ab network requests` where a write is harmless, never on client data, and block that pattern
219
- too. Do not assume `*records-trigger*` covers it.
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 ./empty-1280.png # ✓ Screenshot saved to … (--full for the whole page)
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
- - **Saves are PATCH, and the record ID is in the path.** A guard on POST misses them, and so does one
411
- that looks for the record ID in the body; that one once let a write through. Match the URL, never
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
- - `--init-script <path>` (before the first navigation) or `ab addinitscript <js>` (at runtime): for
433
- example, to stub `window.print` before load.
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.