A reproducible integration test harness that drives the realtime pipeline without touching a physical iPhone. Use it when you need to verify:
- iOS-side rename / delete / presence-track flows propagate to the dashboard
- The dashboard reacts to broadcast events without manual refresh
- A code change in either side hasn't regressed any of the lifecycle scenarios
Topology
Three observation channels run side-by-side:
- Supabase realtime probe — server-side authoritative truth (
devices:all,settings:global,settings_device:allchannels) emitted as one event per line. - Chrome DevTools MCP — the dashboard's actual rendered state (sidebar device list, console messages, DOM tree).
- iOS-mock script — drives the Supabase row + presence lifecycle on demand.
Files
| Path | Purpose |
|---|---|
web/proxy-server/scripts/ios-mock.ts | Mock iOS device. Subcommands: insert, subscribe, rename <new>, delete, cleanup. Talks to Supabase as service_role (bypasses RLS — see Auth below). |
web/proxy-server/scripts/realtime-probe.ts | Listener that subscribes to devices:all + settings channels and prints every broadcast / presence event as one line per event. |
docs/testing/end-to-end-mock.md | This file. |
Auth model
The mock uses the service-role key, not a device JWT. Trade-off:
- ✅ Frictionless — no need to run the
device-pairEdge Function flow each time - ✅ Same wire shape as the real iOS: same channel topic, same presence payload (
{ device_uuid, source }) - ⚠️ Bypasses RLS, so it can't be used to verify RLS policy correctness (e.g., that
devices_self_insertrejects writes outside the JWT'sdevice_uuid). For RLS-specific tests, mint a real device JWT via the pair Edge Function and pass it viaSUPABASE_AUTH_TOKEN(not currently supported by the mock — extend if needed).
The service-role key lives in /etc/systemd/system/busymate-proxy.service.d/env.conf on the VPS, mode 0600. Fetch it for the test:
export SUPABASE_SERVICE_ROLE_KEY="$(ssh ubuntu@busymate.net \
'sudo grep SERVICE_ROLE /etc/systemd/system/busymate-proxy.service.d/env.conf' \
| sed 's/^.*=//')"One-time setup
Chrome with CDP
Chrome must run with --remote-debugging-port=9222 + a non-default --user-data-dir for the chrome-devtools-mcp to attach:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--remote-debugging-port=9222 \
--user-data-dir=/tmp/chrome-cdp \
--no-first-run --no-default-browser-check \
https://dash.busymate.net/ &Then log in (Vk7nQ2pXfR9mDwBhYsLt via "Use password instead" — magic-link is rate-limited).
The MCP server is registered to attach to that endpoint:
claude mcp add chrome-devtools --scope user -- \
npx chrome-devtools-mcp@latest --browserUrl http://127.0.0.1:9222Supabase realtime probe
Run as a Claude Code Monitor so each event becomes a chat notification:
Monitor(
command: "cd web/proxy-server && export SUPABASE_SERVICE_ROLE_KEY=... \
&& npx tsx scripts/realtime-probe.ts",
persistent: true,
)Each line of the probe's stdout becomes one event:
[probe] 16:08:10.339 devices:all INSERT name=mock-ios-device uuid=4b4ac691-…
[probe] 16:08:19.129 devices:all PRESENCE-JOIN 4b4ac691-…=4b4ac691-…/mock
[probe] 16:08:59.685 devices:all UPDATE name=mock-ios-renamed (was mock-ios-device)
[probe] 16:09:58.472 devices:all PRESENCE-LEAVE 4b4ac691-…=4b4ac691-…/mock
[probe] 16:10:49.922 devices:all DELETEThe five canonical scenarios
These are the lifecycle transitions that have to keep working. Test them after any change to RealtimeSubscriber, the broadcast trigger, or the dashboard's devices:all handler.
T1 — Device joins → dashboard shows Online
cd web/proxy-server
npx tsx scripts/ios-mock.ts insert
nohup npx tsx scripts/ios-mock.ts subscribe > /tmp/ios-mock.log 2>&1 &Expected:
- Probe emits
INSERT name=mock-ios-devicethenPRESENCE-JOINthenPRESENCE-SYNCincluding the new uuid. - Dashboard sidebar adds
mock-ios-devicewith the Online pill. Verify viamcp__chrome-devtools__evaluate_script:jsArray.from(document.querySelectorAll('aside.sidebar div[role="button"]')) .map((r) => r.textContent?.replace(/\s+/g, ' ').trim())
T2 — Rename → dashboard shows new name only
npx tsx scripts/ios-mock.ts rename mock-ios-renamedExpected:
- Probe emits
UPDATE name=mock-ios-renamed (was mock-ios-device). - Dashboard renames the row in place. No duplicate; the old name does not survive in the sidebar.
T3 — Untrack (simulate bg) → dashboard flips Offline
The mock's subscribe daemon installs SIGUSR1 (retrack) + SIGUSR2 (untrack) handlers. Signal the node child, not the npx wrapper:
NODE_PID=$(pgrep -f 'tsx/dist/loader.*ios-mock.ts')
kill -USR2 "$NODE_PID"Expected:
- Probe emits
PRESENCE-LEAVE+ a sync without the mock uuid. - Dashboard flips the device's pill from Online to its
lastSeenAtrelative time (e.g. "1m ago").
T4 — Retrack (simulate fg) → dashboard flips back Online
kill -USR1 "$NODE_PID"Expected:
- Probe emits
PRESENCE-JOIN+ a sync with the mock back in. - Dashboard flips the pill back to Online within seconds.
T5 — Delete row → dashboard removes the entry
kill "$NODE_PID" # stop subscribe daemon first
npx tsx scripts/ios-mock.ts deleteExpected:
- Probe emits
PRESENCE-LEAVE(from the daemon exit) thenDELETE(from the row delete). - Dashboard's sidebar drops the row.
Cleanup
The mock writes state to /tmp/ios-mock-state.json. To wipe any leftover rows from prior runs:
npx tsx scripts/ios-mock.ts cleanupMatches WHERE name LIKE 'mock-ios%' so it won't touch real devices.
What this DOES NOT test
- RLS policy correctness — the mock uses service-role and bypasses RLS. Cover with a separate test harness using a device JWT.
- Cross-version SDK interop — both probe and mock are
supabase-js(vsn=1 by default). The real iOS app pinsvsn=.v1for the same reason. A v2-sender test would need a separate fixture. - The legacy WS path — that's gone post-rewrite (Build 24+ is Supabase only).
- Entries firehose — entries are written by the proxy-server's
PostgresStreamer, not iOS-mocked. To test entry flow, spin up a local proxy-server pointing at the same Supabase project.
Adding a new scenario
- Add a subcommand to
ios-mock.ts(e.g.wipe-entriesto testrealtime_broadcast). - Document the expected probe + dashboard reaction in this file.
- Run the harness through T1 → new scenario → T5 to confirm no regression.