Skip to main content

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 on window.__enodeSelfTest.last. Opening /debug/selftest?autorun=1 starts 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​

IdWhat it establishes
env.sessionSomebody is signed in; which backend; whether the run may write to it.
env.storageIndexedDB writes, reads and deletes; persistence flag and quota.
env.compressiongzip round-trips — a rep's raw data depends on it.
offline.cataloguesEvery catalogue needed to record after an offline launch is stored (ages and sizes in the data).
offline.scheduleThe offline copy of the schedule: how many workouts and athlete × exercise pairs, how old.
offline.queueUploads and videos waiting, uploads the server keeps refusing, unfinished trainings.
net.reachableThe server answers, and how fast.
net.deadlineA write that gets no answer ends as a timeout at its deadline.
net.deadline.nativeThe same with a real write into a silent address, through the device's own network stack.
net.reachabilityThe app notices losing the server and getting it back.
rec.subjectAn athlete, an exercise and the metrics to test with.
rec.start-without-serverA session can be created while the server is gone (bound from the stored copy, or unbound).
rec.crash-copyA training with raw sensor data is stored and restored identically.
rec.unsaved-repsReps not yet saved as a set are stored and found again.
upload.offline-finishFinishing without the server queues the training, with what it needs to be bound later.
upload.lost-responseAn upload whose answer is lost stays queued and is accepted when sent again.
upload.stored-onceThe server then holds that session exactly once, with its set, for the athlete.
upload.rejected-splitA training with one session the server rejects still delivers the others.
upload.cleanupThe test sessions are deleted from the server again.
live.sessionA live session of the run's own exists, with this device in it.
live.drop-mid-setA set completed after the connection dropped is kept, and is in the session once when the connection is back.
live.restart-two-waitingTwo sets that wait are in the device's storage with their reps, and go out in the order they were completed.
live.edit-before-createA 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-createA create whose answer is lost stays queued, is sent again, and is stored once.
live.sent-without-trainingTwo 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-setA set delivered while a standings read is on its way is in this device's standings afterwards.
live.same-setsThe session holds exactly the sets the device completed: each once, with its load, its reps and their sensor packages.
live.cleanupThe 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-sets compares that list with the session's complete read (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-waiting reads 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-training completes 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-set holds the first standings read back by 3 seconds (a slow fault on the reduced read), delivers a set meanwhile and waits for the set in the device's standings. The protocol says which way it got there: pushed by the server's set stream, or read again 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-create with "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-sets means 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 note enode-selftest for one athlete and delete them again; a session that could not be deleted is listed under LEFT 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-selftest with 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 under LEFT ON SERVER as live 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:

  1. Start a training and open an exercise.
  2. Take the connection away: airplane mode, or Offline under Network fault, by hand, which survives a restart.
  3. Complete two sets. The live status button in the training header shows a badge with 2: two sets wait.
  4. Kill the app and start it again. It asks "Resume interrupted training?". Leave the question open.
  5. 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.
  6. 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.