Testing harness

Team reference: the end-to-end mock harness and how to run it locally.

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

ios-mock.tsNode — mocks one iOS appinsert / rename / deleteSupabase Cloud ProPostgres + Realtimedevices:all · settings:* dashboard tabChrome withchrome-devtools-mcprealtime-probe.tsNode — subscribes todevices:all + settingsMonitor (Claude Code)stdout = event streamchrome-devtools-mcpDOM + console + networkwritefan-out

Three observation channels run side-by-side:

  1. Supabase realtime probe — server-side authoritative truth (devices:all, settings:global, settings_device:all channels) emitted as one event per line.
  2. Chrome DevTools MCP — the dashboard's actual rendered state (sidebar device list, console messages, DOM tree).
  3. iOS-mock script — drives the Supabase row + presence lifecycle on demand.

Files

PathPurpose
web/proxy-server/scripts/ios-mock.tsMock 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.tsListener that subscribes to devices:all + settings channels and prints every broadcast / presence event as one line per event.
docs/testing/end-to-end-mock.mdThis file.

Auth model

The mock uses the service-role key, not a device JWT. Trade-off:

  • ✅ Frictionless — no need to run the device-pair Edge 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_insert rejects writes outside the JWT's device_uuid). For RLS-specific tests, mint a real device JWT via the pair Edge Function and pass it via SUPABASE_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:

bash
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:

bash
"/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:

bash
claude mcp add chrome-devtools --scope user -- \
  npx chrome-devtools-mcp@latest --browserUrl http://127.0.0.1:9222

Supabase 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 DELETE

The 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

bash
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-device then PRESENCE-JOIN then PRESENCE-SYNC including the new uuid.
  • Dashboard sidebar adds mock-ios-device with the Online pill. Verify via mcp__chrome-devtools__evaluate_script:
    js
    Array.from(document.querySelectorAll('aside.sidebar div[role="button"]'))
      .map((r) => r.textContent?.replace(/\s+/g, ' ').trim())

T2 — Rename → dashboard shows new name only

bash
npx tsx scripts/ios-mock.ts rename mock-ios-renamed

Expected:

  • 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:

bash
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 lastSeenAt relative time (e.g. "1m ago").

T4 — Retrack (simulate fg) → dashboard flips back Online

bash
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

bash
kill "$NODE_PID"          # stop subscribe daemon first
npx tsx scripts/ios-mock.ts delete

Expected:

  • Probe emits PRESENCE-LEAVE (from the daemon exit) then DELETE (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:

bash
npx tsx scripts/ios-mock.ts cleanup

Matches 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 pins vsn=.v1 for 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

  1. Add a subcommand to ios-mock.ts (e.g. wipe-entries to test realtime_broadcast).
  2. Document the expected probe + dashboard reaction in this file.
  3. Run the harness through T1 → new scenario → T5 to confirm no regression.