The CodeTrial browser boundary
web/ is plain ES modules loaded by the page. There is no bundler, no
transpile step and no framework, which is what makes an edit in the checkout
visible on reload. Keep it that way: a build step here would turn every browser
fix into a build artifact somebody has to remember to regenerate.
How a file reaches the browser
rust-embed compiles web/ into the binary, so a release build serves a
complete application with no web/ directory beside it. The fallback is per
file rather than wholesale: a file present on disk wins, and one that is
missing is filled in from the embedded copy. That is why a partially populated
tree serves rather than 404s, and why running the binary from a checkout picks
up your edit with nothing set. --web-dir or CODETRIAL_WEB_DIR points that
root somewhere else.
The consequence worth remembering: a debug build that reads from disk and a
release build that serves embedded bytes can disagree, and only the second is
what ships. scripts/test.sh covers this, and CI proves the binary serves with
the web tree deleted.
Vendored assets
web/vendor/ holds third-party bytes that are downloaded rather than
committed: the LiveKit client, Pyodide, the MediaPipe face detector. They are
pinned by a SHA256SUMS beside each of them; scripts/fetch-vendor.sh fetches them and
scripts/verify-vendor.sh checks them, both wired into the gate. Never edit a
file under web/vendor/, never lint it, and never add a dependency by
dropping a file there by hand: the checksum is the whole mechanism. The avatar
model is not even vendored, the browser fetches it from a pinned upstream URL
against a pinned hash, and the interview falls back to a voice-only panel when
it is unreachable.
The data channel is two implementations
Every topic on the LiveKit data channel has a producer in web/lib.js and a
consumer in src/agent.rs. Each side has its own tests, and a change that
tests only its own side is exactly the shape that once let the integrity hash
diverge and silently empty the evidence section of every camera interview
while the gate stayed green.
tests/fixtures/*.json are the answer: generated by
node scripts/gen-wire-fixtures.mjs from the real producer, consumed by the
Rust tests, and held to --check in the gate. So when you change a message:
- Change the producer in
web/lib.js. - Regenerate the fixtures and commit them with the change.
- Change the consumer in
src/agent.rsagainst the new fixture.
Never hand-edit a fixture. Everything in the generator is deterministic, timestamps included, because a fixture that changes on every run cannot be checked.
Generated pages
web/problems/, web/judges/ and the problem cards inside web/index.html
are generated from problem-bank/. Edit the bank and rerun the generators; see
codetrial-verify.
The lint gate
eslint.config.mjs is narrow on purpose: every rule in it fails only where the
code cannot mean what it says, so the gate never becomes something people learn
to argue with. no-undef is the reason it exists, since a typo'd identifier in
web/interview.js reaches a candidate's browser while the same mistake in
src/web/mod.rs never leaves the terminal. Three recommended rules are excluded
with the reasoning written above them. Adding an eslint-disable is a stronger
claim than it looks: there are none today, and the Rust half carries four
#[allow]s in 45k lines, each with its reason on the same line.
Browser behavior gets a Node test in tests/browser/*.test.js and, where it
needs a real browser, scripts/browser-check.sh with Playwright and Chromium.