Offline self-test
The tracking app has to keep recording in a gym without signal. Whether it does cannot be read off the code, and walking a device in and out of a basement is not a repeatable test. The self-test puts the app's own offline machinery through those situations on the device it runs on — its storage, its network stack, its backend — and writes a protocol that carries everything needed to judge the run.
Its last chapter does the same for the Live Hub: it sends sets into a live session through the situations that used to lose them, and passes when the session holds exactly the sets the device holds. See The Live chapter.
It lives in the debug area: Debug → Self-test (/debug/selftest), so it
exists wherever the debug area does (dev builds, or
NEXT_PUBLIC_ENABLE_DEBUG_VIEW=true).
Running it
- By hand: open the screen, press Run self-test, then Copy protocol (or Share). A run takes about half a minute.
- From a script:
await window.__enodeSelfTest.run()resolves with the protocol object ({ serverWrites: false }leaves the server untouched); the last protocol stays onwindow.__enodeSelfTest.last. Opening/debug/selftest?autorun=1starts a run on arrival, and the finished protocol text is in[data-testid="selftest-protocol"].
Run it on a device that has been used normally at least once while online — the first checks report what the device has stored for a launch without a connection, and a device that has never loaded anything has nothing.
What it checks
| Id | What it establishes |
|---|---|
env.session | Somebody is signed in; which backend; whether the run may write to it. |
env.storage | IndexedDB writes, reads and deletes; persistence flag and quota. |
env.compression | gzip round-trips — a rep's raw data depends on it. |
offline.catalogues | Every catalogue needed to record after an offline launch is stored (ages and sizes in the data). |
offline.schedule | The offline copy of the schedule: how many workouts and athlete × exercise pairs, how old. |
offline.queue | Uploads and videos waiting, uploads the server keeps refusing, unfinished trainings. |
net.reachable | The server answers, and how fast. |
net.deadline | A write that gets no answer ends as a timeout at its deadline. |
net.deadline.native | The same with a real write into a silent address, through the device's own network stack. |
net.reachability | The app notices losing the server and getting it back. |
rec.subject | An athlete, an exercise and the metrics to test with. |
rec.start-without-server | A session can be created while the server is gone (bound from the stored copy, or unbound). |
rec.crash-copy | A training with raw sensor data is stored and restored identically. |
rec.unsaved-reps | Reps not yet saved as a set are stored and found again. |
upload.offline-finish | Finishing without the server queues the training, with what it needs to be bound later. |
upload.lost-response | An upload whose answer is lost stays queued and is accepted when sent again. |
upload.stored-once | The server then holds that session exactly once, with its set, for the athlete. |
upload.rejected-split | A training with one session the server rejects still delivers the others. |
upload.cleanup | The test sessions are deleted from the server again. |
live.session | A live session of the run's own exists, with this device in it. |
live.drop-mid-set | A set completed after the connection dropped is kept, and is in the session once when the connection is back. |
live.restart-two-waiting | Two sets that wait are in the device's storage with their reps, and go out in the order they were completed. |
live.edit-before-create | A set edited while its create still waits goes out as one set, edited; an edit that reaches the session before the set's create leaves one set as well. |
live.repeat-create | A create whose answer is lost stays queued, is sent again, and is stored once. |
live.sent-without-training | Two sets that wait go out by themselves once the server can be reached, with no training on screen and without the check sending them. |
live.standings-after-set | A set delivered while a standings read is on its way is in this device's standings afterwards. |
live.same-sets | The session holds exactly the sets the device completed: each once, with its load, its reps and their sensor packages. |
live.cleanup | The test live session is deleted and the device is in no live session. |
The checks are in apps/tracking/src/app/debug/selftest/checks.ts and, for the
Live chapter, live-checks.ts; each one drives the real modules
(resolveUserExercises, finishAndUpload, drainUploads, pushLiveSet,
drainLiveQueue, the stores), never a copy of their logic.
The Live chapter
The promise of the Live Hub is that every completed set reaches the board once.
The chapter makes that checkable on a device. It creates a live session named
enode-selftest with this device in it, completes nine sets for the test
athlete through the app's own producer and live outbox
(packages/core/src/live-session/producer.ts,
packages/core/src/offline/live-queue.ts), and reads the session back from the
server.
- What "the device holds" means. Every set a check completes is noted with
its tracking set id, its load, its rep count and the sensor packages the
app's mapper puts on the wire. Each set has a load of its own (41 to 51 kg),
so a set can be told apart on a server that does not report tracking ids.
live.same-setscompares that list with the session'scompleteread (live-compare.ts, unit-tested). - Verdicts come from state, not from a drain's report. Anything in the app may drain the live outbox at the same moment. A check therefore looks at what the outbox and the server hold afterwards.
- The restart is simulated.
live.restart-two-waitingreads the outbox over a database connection of its own — what a newly started app finds, with nothing of the run's memory — and then sends it with the call the app makes at start (drainLiveQueue). The app is not killed; see A real restart, by hand for that. - The app sends by itself.
live.sent-without-trainingcompletes two sets without a connection, brings the connection back and then sends nothing: it only watches the outbox. What arrives was sent by the app's own sender (startLiveOutboxDrain, mounted in the root layout), which tries again every 5 seconds while something waits — so the check passes after about 5 seconds and fails after 15. The self-test's page is not the training screen, which is the point: a FAIL saying "the app is not sending its live outbox on this page" means the sender is no longer mounted app-wide. - The standings are followed as the training screen follows them.
live.standings-after-setholds the first standings read back by 3 seconds (aslowfault on thereducedread), delivers a set meanwhile and waits for the set in the device's standings. The protocol says which way it got there:pushedby the server's set stream, orreadagain after the delivery. A WARN means the set was delivered after the slowed read had returned, so the case did not occur in that run. - A FAIL of
live.repeat-createwith "in the session 2 times" means the backend stores a repeated create again. That is a property of the server (one without idempotent upload), not of the device. - A WARN of
live.same-setsmeans the sets arrived without sensor packages: the data-package types are not loaded on the device, and an untagged package is left out of a live set.
The chapter is skipped, with the reason on every one of its lines, when the run may not write to the server, when live sets of a real training still wait on the device, when the device is in a live session already, or when the account may not create one.
The same checks run from Node against backdev, without a device:
npx vitest run --config tests/live/vitest.config.ts \
tests/live/offline/18-selftest-live-chapter
That file also checks that the chapter leaves a device in a real session and
waiting live sets alone, and that live.same-sets fails when a set is taken
out of the session or a foreign one is put in.
Reading the protocol
The copied text starts with a summary — verdict, build, device, backend, one
line per check — and ends with the complete protocol as one line of JSON
between --- json --- and === END ===. The JSON holds each check's
measurements (data) and the app's request trail during the run
(environment.breadcrumbs).
- PASS — behaved as required.
- FAIL — did not; the detail says what was observed instead.
- WARN — worked, but something on this device deserves a look (storage nearly full, uploads the server keeps refusing, an incomplete schedule copy).
- SKIP — not run, with the reason. Never a silent omission.
net.deadline.native reports SKIP on a network that refuses the silent test
address at once: the silence could not be reproduced there, which says nothing
about the app.
What it may touch
- Faults are simulated at the transport seam
(
packages/core/src/api/fault-injection.ts): offline, a server that never answers, an answer lost on the way back, an error status, a slow answer. The realtime stream goes through the same switch. A run switches every fault off again, whatever happens. - Local test data uses its own station id (
enode-selftest-…) and is removed by the check that created it. Real unfinished trainings and their recovery state are not touched. - The server is written to only on a dev backend (
backdev,develop, a local one) — never on production or on a backend the run cannot identify — and only when the box on the screen is ticked. The upload checks create two sessions with the noteenode-selftestfor one athlete and delete them again; a session that could not be deleted is listed underLEFT ON SERVER. The athlete's exercise instance the upload resolves stays. - The upload checks are skipped while real uploads are waiting on the device: the queue is shared, and a run must not send a real training under a simulated fault. The Live chapter still runs then; it has its own outbox.
- The Live chapter writes to a live session of its own, never to a real
one. It creates one session named
enode-selftestwith this device in it and deletes it again, which also releases the device. A device that is in a live session when the run starts skips the chapter, and so does a device on which live sets of a real training still wait. Should the run be cut off, the session ends by itself after 30 minutes; a session that could not be deleted is listed underLEFT ON SERVERaslive session <id>.
A real restart, by hand
live.restart-two-waiting does not kill the app. To see the same with a real
restart, on a device that is in a live session:
- Start a training and open an exercise.
- Take the connection away: airplane mode, or Offline under Network fault, by hand, which survives a restart.
- Complete two sets. The live status button in the training header shows a badge with 2: two sets wait.
- Kill the app and start it again. It asks "Resume interrupted training?". Leave the question open.
- Bring the connection back. Within ten seconds the session holds both sets,
once each — the app sends its live outbox on every screen
(
startLiveOutboxDrain), not only in a training. - Tap Resume: the live status button reads "Live" with nothing waiting. Or tap Discard in step 4 while the connection is still away: the two sets are taken out of the outbox and never reach the session.
A headless browser does steps 1 to 6 against backdev in
tests/live/offline/51-live-outbox-restart (it runs only with
ENODE_TRACKING_URL; its head says what it needs). There the page is reloaded
in step 4 and the API is made unreachable at the network level in step 2.
Until 2026-10-07 the two sets of step 5 kept waiting for as long as the training was not resumed (25 s in the run of that morning): the live outbox was sent from the training screen only. See What a tablet does by itself.
The fault switch, by hand
Below the run button the same faults can be switched on by hand, to use the app itself under them. A fault set there survives a reload — so the app can be launched into it — switches itself off after 30 minutes, and shows a badge on every screen while it is on; tapping the badge switches it off.