Building a Proctored Test Runner as an Explicit State Machine
Camera checks, fullscreen enforcement, anti-cheat, and screen recording — coordinated by a typed lifecycle state machine instead of a tangle of React effects.
Building a Proctored Test Runner as an Explicit State Machine
Navero's assessment runner is the highest-stakes UI in the product. A candidate sits a timed, proctored test: camera and microphone permissions, screen recording, fullscreen enforcement, and anti-cheat monitoring all have to come up in the right order — and recover in the right order when any of them breaks.
The Problem
The first version grew organically: a controller component, a pile of useEffect hooks, and boolean flags like isReady, hasPermissions, and isRecording. It worked until it did not:
- Impossible states were representable: recording without permissions, running outside fullscreen
- Failures surfaced as generic errors, so support could not tell a denied camera from a dropped stream
- Every new module type (knockout questions, skill tests, video interviews) multiplied the flag combinations
The Bug That Made the Case
The failure that finally condemned the flag soup was a race on pause and resume. A candidate pauses; the pause path tears down listeners and flips the booleans. But an event already in flight fires one more time after its cleanup ran, sees a stale closure over the old flags, and writes state that no longer matches reality. The component believes two contradictory things at once, and the candidate is stuck.
The bug is invisible in the code review — every effect looks correct in isolation. It only exists in the ordering between them, and with booleans there is no place where that ordering is written down. You cannot fix a race in a design that cannot express "we are mid-transition." That reframing — the problem is not this handler, it is that the lifecycle itself is implicit — is what sent us to a state machine. (The eventual fix mirrors state into refs that handlers check at call time, and it only became findable once transitions were explicit enough to reason about.)
The Solution: Make the Lifecycle Explicit
We replaced the controller with a typed state machine. The entire test lifecycle is a set of named phases with legal transitions:
IDLE -> PERMISSIONS -> STREAMS -> READY -> RUNNING -> COMPLETED
|
+--> ABORTED | ERROR
Three contracts hang off it:
- A typed error taxonomy. Every failure is a named code —
PERMISSIONS_DENIED,STREAMS_NOT_ACTIVE,FULLSCREEN_REQUIRED,ANTI_CHEAT_REQUIRED,SECURITY_VIOLATION— not a string. The UI, the logs, and support all speak the same vocabulary. - Per-module capability requirements. Each module type declares what it needs. Knockout questions are never proctored; video interviews demand a minimum camera resolution. The machine reads the declaration and runs only the checks that apply.
- Policy-driven proctoring. Screenshot capture runs at randomized intervals scaled by strictness — roughly every 8 to 25 seconds at the light level, 3 to 8 seconds at the strictest. The jitter is deliberate: a predictable capture schedule is a schedule candidates can game.
We evaluated adopting XState on an experimental branch, and ended up keeping a purpose-built machine: our transition graph is small, but the surrounding contracts — error taxonomy, capability requirements, stream ownership — carried most of the value, and owning the machine kept them first-class. The trade would flip in a different context: with a large transition graph, nested or parallel states, or a team that benefits from XState's visualizer and ecosystem, the library earns its dependency. Ours was a seven-state list; the contracts around it were the hard part.
Key Lessons
Make illegal states unrepresentable. Once "recording without permissions" cannot be expressed, an entire class of bugs stops being written instead of being fixed one race at a time.
A typed error taxonomy is a support tool. "The candidate hit FULLSCREEN_REQUIRED twice, then SECURITY_VIOLATION" is a diagnosis. "Something went wrong" is a ticket.
Beware the last event after cleanup. The subtlest bug in the old design was a handler firing once more between pause and teardown. Mirroring state into refs that handlers check at call time closed it — the kind of fix you only find once the lifecycle is explicit enough to reason about.
Randomize what must not be gamed. Any anti-cheat mechanism with a predictable rhythm selects for candidates who learn the rhythm.
Results
- Pause/resume race conditions eliminated — transitions are serialized through the machine, with guards locked during transitions
- Every failure a candidate can hit maps to a named error code with a specific recovery path
- New module types plug in by declaring capabilities, without touching the lifecycle core