Development¶
This page gets the platform running on a laptop and explains what you are
looking at once it runs. The repository layout describes
the code, deployment the two production machines,
installing on a new Ubuntu server a new instance from
scratch, Quiz's GitHub App each environment's App (and an
optional development one), and writing the documentation
this site. The first three sit under the Operations tab of this site; the
specification and the decision records
are the collapsed groups of this one. The classroom merge
record (strategy, decisions, task cards, cutover runbook, progress) is
archived since the cutover of 2026-10-11: built, listed in no tab, reached
from here, from CLAUDE.md and from the pages that cite it.
Prerequisites¶
Node 24 and pnpm, nothing else. pnpm comes through corepack, which ships with Node:
No container engine, no local PostgreSQL and no identity provider are needed for the ordinary development loop. The database is an embedded PostgreSQL (PGlite) persisted on disk, and the sign-in screen offers a persona picker instead of Switch edu-ID. Docker and Podman only matter for the two optional paths described further down: the real OIDC login and the code runner.
Install, build, seed, run¶
pnpm install
pnpm build # the workspace packages: the apps import their dist/
cp .env.example .env # pglite:// database, AUTH_DEV_LOGIN=1
pnpm seed # the demo world, see below
pnpm dev # API on :3000, web on :5173
pnpm build is not optional the first time: the apps resolve
@quiz/core, @quiz/domain, @quiz/contracts and the question-type
packages through their dist/ directories, so an unbuilt package is a
missing module. Rebuild a package after changing it.
pnpm dev runs three dev scripts in parallel:
| Process | Port | What it is |
|---|---|---|
@quiz/api |
3000 | Fastify, restarted by tsx watch; applies the Drizzle migrations at startup (MIGRATE_ON_START=1) |
@quiz/web |
5173 | Vite with HMR; proxies /app and /healthz to :3000 |
@quiz/runner |
3200 | the code execution service, started only when the machine has a Podman socket; otherwise it prints one line and exits |
Open http://localhost:5173, click Dev login and pick a persona.
The web dev and build scripts first run scripts/fetch-runtimes.mjs,
which downloads the WebAssembly language runtimes the browser needs to run a
student's trial (about 77 MB, pinned by SHA-256, kept out of git in
apps/web/public/runtimes/). A second run costs nothing. If the download
fails, for instance offline, the script warns and the app still works: the
browser runner refuses to load and the server-side runner takes over.
The demo world¶
pnpm seed builds a complete, coherent dataset through the ordinary
services of the API, never by raw inserts: every row it writes is a row the
application would have written itself, invariants included. It is
idempotent, keyed on internal names and titles, so a second run changes
nothing.
The personas are the ones offered by the dev login:
| Persona | Role | What they see |
|---|---|---|
Prof Démo (teacher@heig-vd.ch) |
teacher | the course PRG1, its two pools, its four evaluations |
Admin Démo (admin@heig-vd.ch) |
admin | the same, plus the administration screen; a contributor on the Électronique pool |
| Léa Rochat | student | a roster seat with a 25 % extra-time accommodation |
| Noah Bovet, Emma Favre, Louis Perrin, Chloé Monnier, Gabriel Dubois | student | ordinary roster seats |
What gets built:
- the course PRG1 with the classroom PRG1-2026, a roster of six students already claimed, and Léa's time accommodation so the extra-time path is always exercised;
- the pool Programmation C, attached to PRG1, three categories, sixteen published questions (multiple choice, short answer, cloze, code, code image, essay, categorize, diagram, blocks), among them the Parsons problem of Teaching recipes;
- the pool Électronique, attached to PRG1 too, seven published questions (a circuit and two math expressions among them), shared with the admin persona as a contributor so the demo shows a pool from the colleague's side too;
- four evaluations in PRG1-2026:
| Evaluation | Mode | State after the seed |
|---|---|---|
| Test 1 — pointeurs | exam, 45 min | draft |
| Test 2 — chaînes | exam, 90 min window | scheduled, opens in two days |
| Quiz d'entraînement | exercise | lobby, waiting for students |
| Test 0 — bases du C | exam, 45 min | closed, graded, not released |
Test 0 has already run: eleven questions, one or more of every type (multiple
choice, short answer, cloze, code, categorize, code image, circuit, essay,
diagram, blocks),
answered by five of the six students (Gabriel is absent, Chloé never handed
in, so closing the evaluation expired one attempt), right, partly right,
wrong or left empty. The real grading pass graded every answer: the
deterministic types (categorize and blocks included) are validated; the code, code image
and circuit answers are proposals with the reason runner_unavailable, since
the seed never has a runner; the essays are AI proposals with a confidence
(high, medium, low) when LLM_PROVIDER=stub, as .env.example sets it, and
0-point placeholders to grade by hand otherwise; the diagrams are 0-point
proposals to grade by hand, except a starter handed back untouched, which is
a validated 0. The results are
deliberately left unreleased so that the grading panel has proposals to
validate the moment you open it, which is the screen a teacher spends the
most time on.
The seed content lives in apps/api/src/seed/content.ts; the way it is
built through the services is apps/api/src/seed/demo.ts.
The embedded database¶
With DATABASE_URL=pglite://.data/pglite, the API runs an embedded
PostgreSQL whose data is the directory apps/api/.data/pglite (gitignored).
The same Drizzle migrations that production applies run against it at
startup, so what you develop on is the real schema.
Two consequences:
- It is single-process. Stop the API before running
pnpm seed; two processes cannot open the directory at once. - There is no pg-boss. The job queue needs a real PostgreSQL, so on
PGlite the API swaps in a minimal in-process runner behind the same
send/worksurface (apps/api/src/jobs.ts). It is not durable: a job that has not run yet dies with the process. Acceptable in development, and one of the reasonsconfig.tsrefuses apglite://URL in production. With no queue at all (JOBS_DISABLED=1), GitHub follows a project's deadline only when a staff action asks for it: the ticker never runs the deadline job itself (ADR-064), and no review is ever dispatched — the final reviews and the checkpoints wait for a process with a queue (project.dispatch, M3-05b).
To start over, delete the directory. That is also how an existing database gets a newer demo world, such as the eleven-question "Test 0": the seed only adds what is missing and never rewrites an evaluation that already exists.
The development login¶
AUTH_DEV_LOGIN=1 makes GET /app/auth/dev serve a persona picker that
opens a normal session without any identity provider, and adds the Dev
login button to the sign-in screen. The session it opens is a real one,
stored in the database like an OIDC session, which is why the seed and the
smoke test can use it.
It never exists in production. Under NODE_ENV=production, config.ts
throws on AUTH_DEV_LOGIN=1 exactly as it throws on a pglite:// database,
on the stub LLM provider (LLM_PROVIDER=stub, below),
on the placeholder COOKIE_SECRET of .env.example, or on its placeholder
OIDC_CLIENT_SECRET when no private key is configured (with private_key_jwt
the secret is never sent, so it is not checked): the process refuses to
start rather than serve an open door.
The OIDC path (Keycloak in development, Switch edu-ID in production) is the
real one and stays intact whether or not the shortcut is on.
The interface alone: pnpm dev:mock¶
VITE_MOCK=1 makes main.tsx import apps/web/src/mock/index.ts, an
in-browser mock of the whole API served from in-memory state. Mutations edit
that state so the flows feel real, and a reload starts over. The mock is a
design tool: it is how a screen is looked at in every one of its states
before it is declared finished (apps/web/DESIGN.md).
The persona comes from ?as=teacher|student|admin, remembered in the
browser. Scene flags, remembered the same way, force the states that are
hard to reach with real data: ?empty=1 for every empty state, ?fail=1
for the error states (every GET answers 500), ?slow=1 for the loading
states (2.5 s of latency), ?many=1 for long lists and the 120 × 10 live
grid, and ?scene=lobby|running|paused|closed|extend for the student
player.
The mock is never part of a production build: Vite drops the branch when the flag is unset.
Optional services: PostgreSQL and Keycloak¶
To exercise the real paths, a true PostgreSQL (and therefore pg-boss) and a true OIDC login, bring up the optional compose file:
docker compose -f docker-compose.dev.yml up -d
DATABASE_URL=postgres://quiz:quiz@localhost:5432/quiz AUTH_DEV_LOGIN=0 pnpm dev
docker-compose.dev.yml starts PostgreSQL 17 on 127.0.0.1:5432 and
Keycloak 26 on 127.0.0.1:8080, importing the development realm from
infra/keycloak/quiz-dev-realm.json. The realm quiz-dev holds a client
quiz whose secret is the one in .env.example, and three users:
teacher, student and student2, each with a password equal to the
username. With AUTH_DEV_LOGIN=0 the persona picker does not exist and the
sign-in screen only offers the OIDC button.
The same PostgreSQL URL is what drizzle-kit migrate and the smoke test
expect when you want them against a real server.
The code runner¶
A code question is graded by running the student's program in a hardened
container. The API reaches that service in one of two modes, chosen by
RUNNER_MODE in .env:
| Mode | When | Behaviour |
|---|---|---|
stub (default) |
no container engine on the machine | a code question stays authorable, playable and releasable; POST /attempts/:id/run answers 503 runner_unavailable; grading writes a proposed grade with the comment runner_unavailable for the teacher to settle (decision D14); /healthz reports the runner as disabled |
http |
a Podman socket exists | apps/runner executes the code; RUNNER_URL is then required, and config.ts refuses http without it |
To run it locally you need Podman with its user socket
(/run/user/<uid>/podman/podman.sock; systemctl --user enable --now
podman.socket on most distributions). Then build the language images once
and point the API at the runner:
pnpm --filter @quiz/runner images # apps/runner/images/build.sh: c cpp python js spice rust
# in .env
RUNNER_MODE=http
RUNNER_URL=http://localhost:3200
RUNNER_TOKEN stays empty on a workstation: the runner is on localhost and
checks no bearer when no token is configured. Production requires the same
token on both sides (ADR-016).
pnpm dev detects the socket and starts the runner on :3200 (RUNNER_PORT
moves it). On a rootless workstation --userns=auto is probed once at
startup and dropped with a log line when /etc/subuid gives the user no
range; in production the socket is the rootful one and the flag is always
on. The images are Alpine-based, one toolchain each, no network client;
images/build.sh c python builds only the ones named (rust, ~820 MB, is the
large one a workstation may leave out: GET /health then simply omits it).
The hardening flags, the closed environment list and the request lifecycle
are in apps/runner/README.md; the invariants they implement are
invariants 10 to 14 of
CLAUDE.md.
pnpm --filter @quiz/runner test # unit: a fake engine, no container
pnpm --filter @quiz/runner test:integration # real containers; skips itself without Podman
LLM grading¶
Real grading uses the institutional gateway configured by an administrator
(ADR-058); essays and diagrams receive
proposals through grading.llm after closing, never during a live evaluation
(ADR-063). The teacher validates the
proposal. The provider key, model, cap and call log belong to the gateway;
LLM_PROVIDER is not the production provider selector.
For a deterministic local demo, .env.example sets LLM_PROVIDER=stub.
The stub is refused in production. Without a configured real gateway or the
local stub, grading leaves a placeholder for the teacher. See
apps/api/src/modules/llm/service.ts, llm/gateway.ts and
grading/jobs.ts for configuration and dispatch, and ADR-045 for stub history.
A teacher may explicitly copy an AI justification into a comment; validation
alone does not expose it to students.
The smoke test¶
scripts/smoke.sh needs a running API, a seeded database,
AUTH_DEV_LOGIN=1, curl and jq. It walks one whole life of an
evaluation over HTTP only, nothing mocked and nothing read from the
database, and exits non-zero on the first failed expectation:
GET /healthz, the database must be up;- a teacher session through the dev login;
- a fresh question authored and published;
- an evaluation built, opened, and a student attempt answered and handed in, including a second revision of an answer;
- close, grade, release;
- the CSV export;
- the student's feedback, which must reflect the last revision.
When /healthz reports the runner as up, the walk also compiles and
runs the seeded C exercise once with the reference solution and once with
a wrong answer, through the real container. disabled and down are
reported and skipped, not failed.
What CI runs¶
On a workstation, run the suites one package at a time
(VITEST_MAX_WORKERS=4 pnpm -r --workspace-concurrency=1 test, see
the repository rules). .github/workflows/ci.yml runs the
same three steps on every push and pull request, on Node 24 with a frozen
lockfile, in parallel jobs under one checks status, then builds and
deploys the images on a push to main. Its jobs and stages are described
once, in deployment, §4. The
runner's integration suite has its own workflow.
Coverage¶
runs every package's suite with V8 coverage, one package at a time, then
scripts/coverage-summary.mjs, which prints one table and fails when a
package falls under its floor in coverage.floors.json. Each package also
keeps an HTML report in its coverage/ directory. The floors only go up:
after adding tests, node scripts/coverage-summary.mjs --ratchet raises
each floor to one point under the whole percentage just reached; commit the
file with the tests. A floor is a regression guard, not a target: the point
of margin is what lets a new, slightly less covered module in without a red
report, while a real regression still fails (a floor set at the figure
itself left packages/domain 0.3 point of margin, and one new module put
it under). Every floor was lowered by that one point once, on 2026-10-13,
so that each package starts with its margin; none is lowered again. The floors are checked by the coverage command; a plain test run does not
measure coverage (vitest.shared.ts), except in packages/domain, whose own
config requires 100 % of its lines on every plain pnpm test.
.github/workflows/coverage.yml does the same on every pull request, on
every push to main and nightly, apart from checks. The API's and the
SPA's suites run sharded as in ci.yml, each shard writing a blob report
(--reporter=blob) that one report job merges per package
(vitest run --merge-reports --coverage) before the single floors check;
the table goes to the run's summary and the HTML reports to an artifact.
checks is the only status the main ruleset requires: Coverage is
informative and never blocks a merge nor a deploy (see
deployment, §4).
The test layout, including the database tests that run on PGlite against the real migrations, is described on the repository page.
Other commands¶
| Command | What it does |
|---|---|
pnpm db:generate |
drizzle-kit generate in apps/api: a new migration under apps/api/drizzle/ after a schema change |
pnpm --filter @quiz/web screenshots |
screenshots of the mocked web app with Playwright, for the visual check of a screen; needs pnpm dev:mock in another terminal |
pnpm docs:serve, pnpm docs:build |
this site, see writing the documentation |