diff --git a/tests/stress/.gitignore b/tests/stress/.gitignore new file mode 100644 index 0000000..5d6c364 --- /dev/null +++ b/tests/stress/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +runs/ diff --git a/tests/stress/README.md b/tests/stress/README.md new file mode 100644 index 0000000..4d36e4c --- /dev/null +++ b/tests/stress/README.md @@ -0,0 +1,34 @@ +# Quiz portal stress harness + +Adversarial frontend + API stress tests for the quiz portal. Built 2026-05-02. + +## Files + +- `lib.mjs` — shared helpers: server boot, cookie jar, `Student` and `Admin` WS wrappers, the fixed `STRESS_POOL`. +- `api_stress.mjs` — pure WS adversarial scenarios (no browser): happy path with 20 concurrent students, late join, mid-question disconnect, **sleep/wake to next question** (the phone-screen-sleep scenario), cookie tampering, cross-session cookie reuse, duplicate student_id, bad submits (out-of-order, wrong idx, resubmit), close-boundary race, malformed-JSON fuzz, flaky reconnect. +- `ui_stress.mjs` — Playwright/Chromium scenarios that exercise the real SPA: happy UI flow, sleep/wake by closing+reopening browser context with persisted cookie, cookie-tamper via `document.cookie`, two browsers with same student_id. +- `run_loop.sh` — bash wrapper that runs `api_stress.mjs` every cycle and `ui_stress.mjs` every `UI_EVERY` cycles (default 5), with a fresh random seed each time. Logs JSON summary lines to `runs/summary.jsonl` and full output to `runs/run-.jsonl`. + +## Quick start + +```bash +# One-shot +node api_stress.mjs # uses Date.now() seed +node api_stress.mjs 12345 8210 # explicit seed + port +node ui_stress.mjs # browser-based; HEADLESS=0 to watch + +# Long-running loop in tmux +tmux new -d -s quiz_stress 'cd /home/ameer/RD/Projects/Apps/quiz/tests/stress && bash run_loop.sh' +tmux attach -t quiz_stress # to watch +tmux send -t quiz_stress C-c # to stop +``` + +Each cycle boots a fresh uvicorn on its own port and clean DB, runs scenarios, then tears down. Failures are recorded in the `failures` array of the per-cycle summary line. + +## Known findings (tracked outside this dir) + +- **Codex bug:** `app/room.py` `student_ws` (line ~87) and `instructor_ws` call `await websocket.receive_json()` whose JSON parsing can raise `JSONDecodeError`, but the surrounding `try/except` only catches `WebSocketDisconnect`. Result: a single malformed message kills that client's WS handler. The fuzz scenario in `api_stress.mjs` flags this consistently. Fix: wrap the receive in `try/except (JSONDecodeError, RuntimeError):` and either close cleanly or send `{"type":"error","code":"bad_message"}` and continue. + +## Adding scenarios + +Write an `async function name(server) { ... }` in `api_stress.mjs` (or `(server, browser)` for UI), add it to the `SCENARIOS` map / array, and re-run. Use `expect(cond, scenario, msg, extra)` for assertions and `note(scenario, msg)` for warnings that shouldn't fail the suite. **Critical pattern:** pre-register `waitFor` waiters BEFORE the action that triggers the message — `Student.waitFor(type)` only resolves on NEW messages, not cached ones, to avoid stale-state false passes. diff --git a/tests/stress/api_stress.mjs b/tests/stress/api_stress.mjs new file mode 100644 index 0000000..0ac0d09 --- /dev/null +++ b/tests/stress/api_stress.mjs @@ -0,0 +1,461 @@ +// API-level adversarial stress tests for the quiz portal. +// Each scenario boots a fresh server on its own port, runs assertions, +// and logs JSON lines to stdout. Designed to be run repeatedly with +// different seeds; see run_loop.sh for the wrapper. + +import { bootServer, setupSession, Student, Admin, STRESS_POOL, sleep, logLine, rand, pickRandom } from "./lib.mjs"; +import WebSocket from "ws"; + +const SEED = parseInt(process.argv[2] || Date.now(), 10); +const PORT = parseInt(process.argv[3] || (8200 + (SEED % 100)), 10); + +let mulberry32 = (s) => () => { + s |= 0; s = s + 0x6D2B79F5 | 0; + let t = Math.imul(s ^ s >>> 15, 1 | s); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; +}; +const rng = mulberry32(SEED); + +let pass = 0, fail = 0, warn = 0; +const failures = []; + +function expect(cond, scenario, msg, extra = {}) { + if (cond) { pass++; logLine(scenario, "pass", msg, extra); } + else { fail++; failures.push({ scenario, msg, extra }); logLine(scenario, "fail", msg, extra); } +} +function note(scenario, msg, extra = {}) { warn++; logLine(scenario, "note", msg, extra); } + +async function runScenario(name, fn) { + logLine(name, "start", `seed=${SEED}`); + let server; + try { + server = await bootServer({ port: PORT }); + await fn(server); + logLine(name, "ok", "scenario completed"); + } catch (err) { + fail++; + failures.push({ scenario: name, msg: "uncaught", extra: { err: err.message, stack: err.stack?.slice(0, 600) } }); + logLine(name, "fail", "uncaught exception", { err: err.message }); + } finally { + if (server) await server.stop(); + } +} + +// ---------- Scenarios ---------- + +async function happyPath(server) { + const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL); + const N = 20; + const students = await Promise.all(Array.from({ length: N }, async (_, i) => { + const s = new Student(server.url, sid, `S${i.toString().padStart(3, "0")}`, `Student${i}`); + await s.join(); + await s.connect(); + return s; + })); + const admin = new Admin(server.url, sid, jar); + await admin.connect(); + await admin.waitFor("lobby_update"); + + for (let q = 0; q < STRESS_POOL.questions.length; q++) { + // Pre-register waiters so we don't lose the broadcast in the race window + const studentOpenWaits = students.map(s => s.waitFor("question_open")); + const adminOpenWait = admin.waitFor("question_open"); + admin.open(q, 5); + await adminOpenWait; + await Promise.all(studentOpenWaits); + // Each student picks a random answer (mostly correct) + await Promise.all(students.map(async (s, i) => { + try { + await sleep(rand(50, 800)); + const correct = STRESS_POOL.questions[q].correct; + const ans = rng() < 0.7 ? correct : pickRandom(["A","B","C","D"]); + const ackWait = s.waitFor("submit_ack", { timeoutMs: 3000 }); + s.submit(q, ans); + const ack = await ackWait; + expect(ack.question_idx === q, "happy", `student${i} q${q} ack idx==q`, { ack_idx: ack.question_idx, expected: q }); + } catch (e) { + note("happy", `student${i} q${q}: ${e.message}`); + } + })); + const studentClosedWaits = students.map(s => s.waitFor("question_closed", { timeoutMs: 3000 }).catch(() => null)); + const adminClosedWait = admin.waitFor("question_closed", { timeoutMs: 3000 }); + admin.close(); + await adminClosedWait; + await Promise.all(studentClosedWaits); + if (q < STRESS_POOL.questions.length - 1) { + admin.next(); + await sleep(150); + } + } + const sessionEndedWait = admin.waitFor("session_ended", { timeoutMs: 3000 }); + admin.end(); + await sessionEndedWait; + expect(true, "happy", "session ended cleanly"); + students.forEach(s => s.disconnect()); + admin.disconnect(); +} + +async function lateJoiners(server) { + const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL); + const admin = new Admin(server.url, sid, jar); + await admin.connect(); + // 5 students join in lobby + const early = await Promise.all([0,1,2,3,4].map(async i => { + const s = new Student(server.url, sid, `E${i}`, `Early${i}`); + await s.join(); await s.connect(); return s; + })); + const adminOpenWait = admin.waitFor("question_open"); + admin.open(0, 8); + await adminOpenWait; + await sleep(2000); + // 3 late joiners + for (let i = 0; i < 3; i++) { + const s = new Student(server.url, sid, `L${i}`, `Late${i}`); + await s.join(); + // Pre-register waiters BEFORE connect so we catch the snapshot on connect + const stateWait = s.waitFor("state"); + const qopenWait = s.waitFor("question_open", { timeoutMs: 2000 }); + await s.connect(); + const m = await qopenWait.catch(() => null); + if (!m) { fail++; failures.push({ scenario: "late_join", msg: `late${i} got no question_open on connect`, extra: {} }); logLine("late_join", "fail", `late${i} got no question_open on connect`); continue; } + expect(m.question_idx === 0, "late_join", `late${i} sees correct idx`); + expect(m.remaining_ms < 8000, "late_join", `late${i} remaining_ms reduced`, { remaining_ms: m.remaining_ms }); + expect(m.remaining_ms > 0, "late_join", `late${i} remaining_ms > 0`, { remaining_ms: m.remaining_ms }); + const ackWait = s.waitFor("submit_ack", { timeoutMs: 3000 }); + s.submit(0, STRESS_POOL.questions[0].correct); + const ack = await ackWait.catch(() => null); + expect(ack && ack.score > 0, "late_join", `late${i} got positive score from late submit`, { score: ack?.score }); + s.disconnect(); + } + const adminClosedWait = admin.waitFor("question_closed"); + admin.close(); + await adminClosedWait; + early.forEach(s => s.disconnect()); + admin.disconnect(); +} + +async function disconnectMidQuestion(server) { + const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL); + const admin = new Admin(server.url, sid, jar); + await admin.connect(); + const s = new Student(server.url, sid, "D1", "Drop"); + await s.join(); + const stateWaitInitial = s.waitFor("state"); + await s.connect(); + await stateWaitInitial; + const sQopenWait = s.waitFor("question_open"); + const aOpenWait = admin.waitFor("question_open"); + admin.open(0, 10); + await aOpenWait; + await sQopenWait; + s.disconnect(); + await sleep(500); + // While dropped, instructor closes + const closedWait = admin.waitFor("question_closed"); + admin.close(); + await closedWait; + // Reconnect — should get state=question_closed (or current state) + const reconnectStateWait = s.waitFor("state", { timeoutMs: 2000 }); + await s.reconnect(); + const state = await reconnectStateWait; + expect(["question_closed", "between_questions", "lobby"].includes(state.state), "disconnect_midq", `state on reconnect = ${state.state}`); + s.disconnect(); + admin.disconnect(); +} + +// THE KEY scenario from the user: phone screen sleeps mid-quiz, instructor +// advances to a new question, phone wakes — the student MUST see the +// LATEST question, not a stale screen. +async function sleepWakeNextQuestion(server) { + const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL); + const admin = new Admin(server.url, sid, jar); + await admin.connect(); + const s = new Student(server.url, sid, "SW1", "SleepWake"); + await s.join(); + const initState = s.waitFor("state"); + await s.connect(); + await initState; + // Open Q0 + let sWait = s.waitFor("question_open"); + let aWait = admin.waitFor("question_open"); + admin.open(0, 5); + await aWait; await sWait; + await sleep(500); + const ackWait = s.waitFor("submit_ack"); + s.submit(0, "B"); + await ackWait; + // Phone "sleeps" — drop WS hard + s.disconnect(); + await sleep(800); + // Instructor closes, advances, opens Q2 (skip Q1 to make wake state non-trivial) + let closedW = admin.waitFor("question_closed"); + admin.close(); + await closedW; + admin.next(); + await sleep(150); + aWait = admin.waitFor("question_open"); + admin.open(2, 8); + await aWait; + await sleep(300); + // Phone wakes — reconnect; pre-register both expected events + const stateOnWake = s.waitFor("state", { timeoutMs: 2000 }); + const qopenOnWake = s.waitFor("question_open", { timeoutMs: 2000 }); + await s.reconnect(); + const state = await stateOnWake; + expect(state.state === "question_open", "sleep_wake", `state on wake = ${state.state}`, { state }); + expect(state.current_question_idx === 2, "sleep_wake", `current_question_idx on wake = ${state.current_question_idx}`); + const qopen = await qopenOnWake.catch(() => null); + expect(qopen && qopen.question_idx === 2, "sleep_wake", "reconnect emits question_open for current idx", { qopen_idx: qopen?.question_idx }); + expect(qopen && qopen.text === STRESS_POOL.questions[2].text, "sleep_wake", "question text matches latest Q"); + if (qopen) { + const ack2W = s.waitFor("submit_ack", { timeoutMs: 3000 }); + s.submit(2, "B"); + const ack = await ack2W.catch(() => null); + expect(ack && ack.question_idx === 2, "sleep_wake", "submit on Q2 after wake succeeded", { ack_idx: ack?.question_idx }); + } + s.disconnect(); + admin.disconnect(); +} + +// Cookie tampering: try to flip student_id by mangling the signed cookie +async function cookieTampering(server) { + const { sid } = await setupSession(server.url, server.adminPw, STRESS_POOL); + const s = new Student(server.url, sid, "ORIG", "Original"); + await s.join(); + // Tamper: append a junk byte + const original = s.jar.get("qz_student"); + s.jar.set("qz_student", original + "X"); + // Try to connect WS + let connected = false, closeCode = null; + try { + await new Promise((res, rej) => { + const wsUrl = server.url.replace(/^http/, "ws") + `/ws/student/${sid}`; + const w = new WebSocket(wsUrl, { headers: { Cookie: s.jar.header() } }); + w.on("open", () => { connected = true; w.close(); res(); }); + w.on("close", (c) => { closeCode = c; res(); }); + w.on("error", () => res()); + setTimeout(res, 1500); + }); + } catch {} + expect(!connected || closeCode === 4001 || closeCode === 1006 || closeCode === 1008, "cookie_tamper", "tampered cookie rejected on WS", { connected, closeCode }); + // Reset cookie and retry + s.jar.set("qz_student", original); + await s.connect(); + await s.waitFor("state"); + expect(true, "cookie_tamper", "valid cookie still works after tamper attempt"); + s.disconnect(); +} + +// Cross-session cookie: cookie from session A should not work on session B. +async function crossSessionCookie(server) { + const { sid: sidA, jar: jarA } = await setupSession(server.url, server.adminPw, STRESS_POOL); + const { sid: sidB } = await setupSession(server.url, server.adminPw, STRESS_POOL); + const s = new Student(server.url, sidA, "X1", "CrossUser"); + await s.join(); + // Try to use sidA's cookie to access sidB + const wsUrl = server.url.replace(/^http/, "ws") + `/ws/student/${sidB}`; + let opened = false; + await new Promise(res => { + const w = new WebSocket(wsUrl, { headers: { Cookie: s.jar.header() } }); + w.on("open", () => { opened = true; w.close(); res(); }); + w.on("close", () => res()); + w.on("error", () => res()); + setTimeout(res, 1500); + }); + expect(!opened, "cross_session", "cookie from sidA rejected when used against sidB", { opened }); +} + +// Duplicate student_id: two browsers join with same student_id (different cookies) +async function duplicateStudentId(server) { + const { sid } = await setupSession(server.url, server.adminPw, STRESS_POOL); + const s1 = new Student(server.url, sid, "DUP", "FirstSession"); + const s2 = new Student(server.url, sid, "DUP", "SecondSession"); + await s1.join(); + const s1State = s1.waitFor("state"); + await s1.connect(); + await s1State; + await s2.join(); + const s2State = s2.waitFor("state"); + await s2.connect(); + await s2State; + expect(s1.ws.readyState === WebSocket.OPEN, "dup_id", "first DUP session open"); + expect(s2.ws.readyState === WebSocket.OPEN, "dup_id", "second DUP session open"); + s1.disconnect(); s2.disconnect(); +} + +// Submit out-of-order / wrong question_idx +async function badSubmits(server) { + const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL); + const admin = new Admin(server.url, sid, jar); await admin.connect(); + const s = new Student(server.url, sid, "BAD", "Bad"); + await s.join(); + const initState = s.waitFor("state"); + await s.connect(); + await initState; + // Submit before any question opened + s.submit(0, "A"); + await sleep(300); + expect(!s.lastMsgByType.submit_ack, "bad_submit", "submit before open ignored / no ack", { last: Object.keys(s.lastMsgByType) }); + let sWait = s.waitFor("question_open"); + let aWait = admin.waitFor("question_open"); + admin.open(0, 5); + await aWait; await sWait; + // Wrong idx submit (give it 600 ms to arrive if it does) + const ackBefore = s.lastMsgByType.submit_ack; + s.submit(99, "A"); + await sleep(600); + const ackAfter = s.lastMsgByType.submit_ack; + expect(ackAfter === ackBefore || (ackAfter && ackAfter.question_idx !== 99), "bad_submit", "wrong-idx submit not acked", { ackAfter }); + // Valid submit + const okWait = s.waitFor("submit_ack", { timeoutMs: 2000 }); + s.submit(0, "B"); + const ok = await okWait; + expect(ok.question_idx === 0, "bad_submit", "valid submit acked"); + // Resubmit (already submitted) — should NOT change stored answer + s.submit(0, "A"); + await sleep(400); + const closedWait = s.waitFor("question_closed"); + admin.close(); + const closed = await closedWait; + expect(closed.your_answer === "B", "bad_submit", "your_answer remained 'B' after resubmit attempt", { your_answer: closed.your_answer }); + s.disconnect(); admin.disconnect(); +} + +// Race: many students submit at the moment of close (within ms) +async function closeBoundaryRace(server) { + const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL); + const admin = new Admin(server.url, sid, jar); await admin.connect(); + const N = 30; + const students = await Promise.all(Array.from({ length: N }, async (_, i) => { + const s = new Student(server.url, sid, `R${i}`, `Race${i}`); + await s.join(); await s.connect(); return s; + })); + // Pre-register everyone's question_open + question_closed waits + const sOpens = students.map(s => s.waitFor("question_open", { timeoutMs: 3000 }).catch(() => null)); + const aOpen = admin.waitFor("question_open", { timeoutMs: 3000 }); + // Pre-register the closed wait too — auto-close fires even without manual close() + const aClosed = admin.waitFor("question_closed", { timeoutMs: 6000 }); + admin.open(0, 1); // 1-second window — auto-close should fire ~1s later + await aOpen; + await Promise.all(sOpens); + // Have all students fire submit at random times spanning the window edge + const fires = students.map(async (s, i) => { + const ackW = s.waitFor("submit_ack", { timeoutMs: 2000 }); + await sleep(rand(800, 1200)); // some fire after auto-close + s.submit(0, "B"); + return ackW.catch(() => null); + }); + const acks = await Promise.all(fires); + const acked = acks.filter(Boolean).length; + const closed = await aClosed.catch(() => null); + logLine("close_race", "info", `race results`, { acked, total: N, hist: closed?.histogram }); + expect(closed !== null, "close_race", "question_closed broadcast received (auto-close or manual)"); + expect(acked >= 1 && acked <= N, "close_race", "no crash, some submits succeeded"); + students.forEach(s => s.disconnect()); admin.disconnect(); +} + +// Fuzz: malformed messages from a student WS +async function fuzzMessages(server) { + const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL); + const admin = new Admin(server.url, sid, jar); await admin.connect(); + // First: a sentinel student that does NOT receive fuzz, to verify global server health + const sentinel = new Student(server.url, sid, "SENT", "Sentinel"); + await sentinel.join(); + const sentinelStateW = sentinel.waitFor("state"); + await sentinel.connect(); + await sentinelStateW; + // Second: a fuzz student that gets garbage shoved at it + const s = new Student(server.url, sid, "FZ", "Fuzz"); + await s.join(); + const fzStateW = s.waitFor("state"); + await s.connect(); + await fzStateW; + const garbages = [ + "not json", + "{}", + JSON.stringify({ type: "open_question" }), // student trying to act as instructor + JSON.stringify({ type: "submit", question_idx: -1, answer: "Z" }), + JSON.stringify({ type: "submit", question_idx: 0, answer: { nested: "obj" } }), + JSON.stringify({ type: "💀" }), + "x".repeat(50_000), + ]; + for (const g of garbages) { try { s.ws.send(g); } catch {} ; await sleep(50); } + await sleep(500); + // Server should still serve OTHER clients regardless of what happened to fuzz student. + const sentOpenW = sentinel.waitFor("question_open", { timeoutMs: 2000 }); + const adminOpenW = admin.waitFor("question_open", { timeoutMs: 2000 }); + admin.open(0, 3); + await adminOpenW; + const sm = await sentOpenW.catch(() => null); + expect(sm && sm.question_idx === 0, "fuzz", "OTHER clients still served after fuzz on one student", { got: !!sm }); + // Did the fuzz student survive? (informational, not asserted as pass/fail) + const survived = !s.closed && s.ws.readyState === WebSocket.OPEN; + logLine("fuzz", "info", `fuzz student survival`, { survived, ws_state: s.ws.readyState }); + if (!survived) note("fuzz", "fuzz student WS was killed by malformed input — server lacks JSON-decode try/except in WS loop (codex room.py student_ws line ~87)"); + const adminClosedW = admin.waitFor("question_closed", { timeoutMs: 5000 }); + admin.close(); + await adminClosedW.catch(() => null); + s.disconnect(); sentinel.disconnect(); admin.disconnect(); +} + +// Repeated rapid connect/disconnect (simulating flaky network) +async function flakyReconnect(server) { + const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL); + const admin = new Admin(server.url, sid, jar); await admin.connect(); + const s = new Student(server.url, sid, "FK", "Flaky"); + await s.join(); + for (let i = 0; i < 10; i++) { + const stateW = s.waitFor("state", { timeoutMs: 2000 }); + await s.connect(); + await stateW.catch(() => {}); + s.disconnect(); + await sleep(rand(50, 200)); + } + // Final reconnect + const finalStateW = s.waitFor("state", { timeoutMs: 2000 }); + await s.connect(); + await finalStateW; + const sQopenW = s.waitFor("question_open"); + const aQopenW = admin.waitFor("question_open"); + admin.open(0, 5); + await aQopenW; await sQopenW; + const ackW = s.waitFor("submit_ack", { timeoutMs: 3000 }); + s.submit(0, "B"); + const ack = await ackW; + expect(ack !== null, "flaky_reconnect", "post-flaky student can still submit"); + const closedW = admin.waitFor("question_closed"); + admin.close(); + await closedW; + s.disconnect(); admin.disconnect(); +} + +const SCENARIOS = { + happy: happyPath, + late_join: lateJoiners, + disconnect_midq: disconnectMidQuestion, + sleep_wake: sleepWakeNextQuestion, + cookie_tamper: cookieTampering, + cross_session: crossSessionCookie, + dup_id: duplicateStudentId, + bad_submit: badSubmits, + close_race: closeBoundaryRace, + fuzz: fuzzMessages, + flaky_reconnect: flakyReconnect, +}; + +// Pick scenario subset based on env or run all in random order +const wanted = process.env.SCENARIOS + ? process.env.SCENARIOS.split(",") + : Object.keys(SCENARIOS).sort(() => rng() - 0.5); + +logLine("runner", "info", `starting api stress`, { seed: SEED, port: PORT, scenarios: wanted }); + +for (const name of wanted) { + const fn = SCENARIOS[name]; + if (!fn) { logLine("runner", "warn", `unknown scenario ${name}`); continue; } + await runScenario(name, fn); +} + +logLine("runner", "summary", `done`, { pass, fail, warn, failures }); +process.exit(fail > 0 ? 1 : 0); diff --git a/tests/stress/lib.mjs b/tests/stress/lib.mjs new file mode 100644 index 0000000..ee88b7d --- /dev/null +++ b/tests/stress/lib.mjs @@ -0,0 +1,256 @@ +// Shared helpers for quiz portal stress tests. +// Boots a fresh uvicorn server, logs in as admin, creates quiz + session. +// Provides a Student class that wraps an authenticated WS + cookie state. + +import { spawn } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import WebSocket from "ws"; + +const QUIZ_ROOT = "/home/ameer/RD/Projects/Apps/quiz"; +const PORT_BASE = 8200; + +export function nowMs() { return Date.now(); } +export function logLine(scenario, level, msg, extra = {}) { + const rec = { ts: new Date().toISOString(), scenario, level, msg, ...extra }; + process.stdout.write(JSON.stringify(rec) + "\n"); +} + +export function pickRandom(arr) { return arr[Math.floor(Math.random() * arr.length)]; } +export function rand(min, max) { return Math.random() * (max - min) + min; } + +// Boot a fresh server on its own port + DB. Returns { url, stop }. +export async function bootServer({ port, secret = "stress-secret-12345678", adminPw = "stresspw" } = {}) { + const tmp = mkdtempSync(join(tmpdir(), "quiz-stress-")); + const dbPath = join(tmp, "stress.db"); + const env = { + ...process.env, + QUIZ_DB_PATH: dbPath, + QUIZ_SECRET_KEY: secret, + QUIZ_ADMIN_PASSWORD: adminPw, + QUIZ_HOST: "127.0.0.1", + QUIZ_PORT: String(port), + QUIZ_PUBLIC_URL: `http://127.0.0.1:${port}`, + }; + const proc = spawn( + `${QUIZ_ROOT}/.venv/bin/uvicorn`, + ["app.main:app", "--host", "127.0.0.1", "--port", String(port), "--log-level", "warning"], + { cwd: QUIZ_ROOT, env, stdio: ["ignore", "pipe", "pipe"] }, + ); + // Pipe server stderr to our stderr so panics are visible + proc.stderr.on("data", chunk => process.stderr.write(`[server:${port}] ${chunk}`)); + + const url = `http://127.0.0.1:${port}`; + // Wait for /healthz + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + try { + const r = await fetch(`${url}/healthz`); + if (r.ok) { + return { + url, + adminPw, + stop: () => new Promise(res => { + proc.once("exit", () => { rmSync(tmp, { recursive: true, force: true }); res(); }); + proc.kill("SIGTERM"); + // Hard-kill fallback + setTimeout(() => proc.kill("SIGKILL"), 2000); + }), + }; + } + } catch {} + await sleep(150); + } + proc.kill("SIGKILL"); + throw new Error(`server on ${port} did not come up`); +} + +// Cookie jar helper - parses Set-Cookie headers from fetch response. +export class CookieJar { + constructor() { this.jar = new Map(); } + ingest(response) { + const raw = response.headers.getSetCookie?.() || (response.headers.get("set-cookie") ? [response.headers.get("set-cookie")] : []); + for (const line of raw) { + const [pair] = line.split(";"); + const eq = pair.indexOf("="); + if (eq > 0) this.jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()); + } + } + header() { + return [...this.jar.entries()].map(([k, v]) => `${k}=${v}`).join("; "); + } + get(name) { return this.jar.get(name); } + set(name, value) { this.jar.set(name, value); } + clear() { this.jar.clear(); } +} + +export async function jsonReq(method, url, { jar, body, headers = {} } = {}) { + const opts = { method, headers: { ...headers } }; + if (jar) opts.headers["Cookie"] = jar.header(); + if (body !== undefined) { + opts.body = JSON.stringify(body); + opts.headers["Content-Type"] = "application/json"; + } + const r = await fetch(url, opts); + if (jar) jar.ingest(r); + let data = null; + const txt = await r.text(); + try { data = txt ? JSON.parse(txt) : null; } catch { data = txt; } + return { status: r.status, ok: r.ok, data, headers: r.headers }; +} + +// Build admin session: login + upload pool + create session. Returns { sid, jar }. +export async function setupSession(serverUrl, adminPw, pool) { + const jar = new CookieJar(); + const login = await jsonReq("POST", `${serverUrl}/admin/login`, { jar, body: { password: adminPw } }); + if (!login.ok) throw new Error(`admin login failed: ${login.status} ${JSON.stringify(login.data)}`); + const create = await jsonReq("POST", `${serverUrl}/admin/api/quizzes`, { jar, body: { pool_json: pool } }); + if (!create.ok) throw new Error(`quiz create failed: ${create.status} ${JSON.stringify(create.data)}`); + const sess = await jsonReq("POST", `${serverUrl}/admin/api/sessions`, { jar, body: { quiz_id: create.data.quiz_id } }); + if (!sess.ok) throw new Error(`session create failed: ${sess.status} ${JSON.stringify(sess.data)}`); + return { sid: sess.data.sid, jar }; +} + +// Student wrapper: join + connect WS + collect messages. +export class Student { + constructor(serverUrl, sid, studentId, name) { + this.serverUrl = serverUrl; + this.sid = sid; + this.studentId = studentId; + this.name = name; + this.jar = new CookieJar(); + this.ws = null; + this.messages = []; + this.lastMsgByType = {}; + this.events = new EventTarget(); + this.closed = false; + } + async join() { + const r = await jsonReq("POST", `${this.serverUrl}/api/session/${this.sid}/join`, { + jar: this.jar, + body: { student_id: this.studentId, name: this.name }, + }); + if (!r.ok) throw new Error(`student join failed: ${r.status} ${JSON.stringify(r.data)}`); + return r; + } + connect() { + return new Promise((resolve, reject) => { + const wsUrl = this.serverUrl.replace(/^http/, "ws") + `/ws/student/${this.sid}`; + const headers = { Cookie: this.jar.header() }; + this.ws = new WebSocket(wsUrl, { headers }); + this.ws.on("open", () => resolve()); + this.ws.on("message", buf => { + let msg; + try { msg = JSON.parse(buf.toString()); } catch { return; } + this.messages.push({ ts: nowMs(), msg }); + this.lastMsgByType[msg.type] = msg; + this.events.dispatchEvent(new CustomEvent("msg", { detail: msg })); + }); + this.ws.on("close", (code, reason) => { + this.closed = true; + this.events.dispatchEvent(new CustomEvent("close", { detail: { code, reason: reason.toString() } })); + }); + this.ws.on("error", err => reject(err)); + }); + } + // Wait until a NEW message of the given type arrives (does not use cache). + // Use lastMsgByType[type] to inspect cached values without waiting. + waitFor(type, { timeoutMs = 5000, useCache = false } = {}) { + return new Promise((resolve, reject) => { + if (useCache && this.lastMsgByType[type]) return resolve(this.lastMsgByType[type]); + const handler = ev => { + if (ev.detail?.type === type) { + this.events.removeEventListener("msg", handler); + clearTimeout(timer); + resolve(ev.detail); + } + }; + const timer = setTimeout(() => { + this.events.removeEventListener("msg", handler); + reject(new Error(`timed out waiting for WS type=${type} after ${timeoutMs}ms`)); + }, timeoutMs); + this.events.addEventListener("msg", handler); + }); + } + send(obj) { + if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(obj)); + } + submit(qIdx, answer) { + this.send({ type: "submit", question_idx: qIdx, answer }); + } + disconnect() { + if (this.ws && !this.closed) { + try { this.ws.terminate(); } catch {} + } + } + async reconnect() { + this.closed = false; + this.lastMsgByType = {}; + await this.connect(); + } +} + +// Admin WS wrapper. +export class Admin { + constructor(serverUrl, sid, jar) { + this.serverUrl = serverUrl; + this.sid = sid; + this.jar = jar; + this.ws = null; + this.messages = []; + this.events = new EventTarget(); + } + connect() { + return new Promise((resolve, reject) => { + const wsUrl = this.serverUrl.replace(/^http/, "ws") + `/ws/instructor/${this.sid}`; + this.ws = new WebSocket(wsUrl, { headers: { Cookie: this.jar.header() } }); + this.ws.on("open", () => resolve()); + this.ws.on("message", buf => { + let msg; + try { msg = JSON.parse(buf.toString()); } catch { return; } + this.messages.push({ ts: nowMs(), msg }); + this.events.dispatchEvent(new CustomEvent("msg", { detail: msg })); + }); + this.ws.on("error", err => reject(err)); + }); + } + open(qIdx, timeLimit = 60) { this.ws.send(JSON.stringify({ type: "open_question", question_idx: qIdx, time_limit: timeLimit })); } + close() { this.ws.send(JSON.stringify({ type: "close_question" })); } + next() { this.ws.send(JSON.stringify({ type: "next" })); } + end() { this.ws.send(JSON.stringify({ type: "end_session" })); } + waitFor(type, { timeoutMs = 5000 } = {}) { + return new Promise((resolve, reject) => { + const handler = ev => { + if (ev.detail?.type === type) { + this.events.removeEventListener("msg", handler); + clearTimeout(timer); + resolve(ev.detail); + } + }; + const timer = setTimeout(() => { + this.events.removeEventListener("msg", handler); + reject(new Error(`admin timed out waiting for type=${type} after ${timeoutMs}ms`)); + }, timeoutMs); + this.events.addEventListener("msg", handler); + }); + } + disconnect() { try { this.ws?.terminate(); } catch {} } +} + +// A small fixed pool used for stress runs. +export const STRESS_POOL = { + title: "Stress Pool", + score_fn: "linear_decay", + time_limit_default: 10, + questions: [ + { id: "s1", text: "2+2?", options: { A: "3", B: "4", C: "5", D: "6" }, correct: "B", explanation: "" }, + { id: "s2", text: "Capital of France?", options: { A: "Berlin", B: "Madrid", C: "Paris", D: "Rome" }, correct: "C", explanation: "" }, + { id: "s3", text: "Fastest sort?", options: { A: "Bubble", B: "Quick", C: "Insertion", D: "Selection" }, correct: "B", explanation: "" }, + { id: "s4", text: "HTTP code for not found?", options: { A: "200", B: "301", C: "404", D: "500" }, correct: "C", explanation: "" }, + { id: "s5", text: "Speed of light (m/s)?", options: { A: "3e8", B: "3e6", C: "1.5e8", D: "9.8" }, correct: "A", explanation: "" }, + ], +}; + +export { sleep }; diff --git a/tests/stress/package-lock.json b/tests/stress/package-lock.json new file mode 100644 index 0000000..ec62bb2 --- /dev/null +++ b/tests/stress/package-lock.json @@ -0,0 +1,81 @@ +{ + "name": "quiz-stress", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "quiz-stress", + "version": "0.1.0", + "dependencies": { + "playwright": "^1.58.2", + "ws": "^8.18.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/tests/stress/package.json b/tests/stress/package.json new file mode 100644 index 0000000..b1f7b70 --- /dev/null +++ b/tests/stress/package.json @@ -0,0 +1,14 @@ +{ + "name": "quiz-stress", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "api": "node api_stress.mjs", + "ui": "node ui_stress.mjs" + }, + "dependencies": { + "ws": "^8.18.0", + "playwright": "^1.58.2" + } +} diff --git a/tests/stress/run_loop.sh b/tests/stress/run_loop.sh new file mode 100755 index 0000000..112ef4d --- /dev/null +++ b/tests/stress/run_loop.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Loop runner for the stress harness. +# Runs api_stress.mjs each cycle with a fresh random seed, and runs ui_stress.mjs +# every $UI_EVERY cycles (default 5). Logs JSON lines to runs/.jsonl. +# Run this in tmux: tmux new -d -s quiz_stress 'bash run_loop.sh' + +set -uo pipefail + +cd "$(dirname "$0")" +mkdir -p runs + +UI_EVERY=${UI_EVERY:-5} +SLEEP_BETWEEN=${SLEEP_BETWEEN:-3} +LOG="runs/run-$(date -u +%Y%m%dT%H%M%SZ).jsonl" +SUM="runs/summary.jsonl" + +echo "{\"event\":\"loop_start\",\"ts\":\"$(date -u +%FT%TZ)\",\"log\":\"$LOG\",\"ui_every\":$UI_EVERY}" | tee -a "$SUM" + +cycle=0 +total_pass=0 +total_fail=0 +total_warn=0 + +trap 'echo "{\"event\":\"loop_stop\",\"ts\":\"$(date -u +%FT%TZ)\",\"cycles\":'$cycle',\"total_pass\":'$total_pass',\"total_fail\":'$total_fail',\"total_warn\":'$total_warn'}" | tee -a "$SUM"; exit 0' INT TERM + +while true; do + cycle=$((cycle + 1)) + seed=$(( (RANDOM * 32768 + RANDOM) % 1000000 )) + port=$((8200 + (cycle % 50))) + + printf '\n----- cycle %d (seed=%d port=%d) api -----\n' "$cycle" "$seed" "$port" | tee -a "$LOG" + out=$(timeout 120 node api_stress.mjs "$seed" "$port" 2>&1) + echo "$out" | tee -a "$LOG" >/dev/null + summary=$(echo "$out" | grep '"runner"' | grep '"summary"' | tail -1) + if [ -n "$summary" ]; then + p=$(echo "$summary" | sed -n 's/.*"pass":\([0-9]*\).*/\1/p') + f=$(echo "$summary" | sed -n 's/.*"fail":\([0-9]*\).*/\1/p') + w=$(echo "$summary" | sed -n 's/.*"warn":\([0-9]*\).*/\1/p') + total_pass=$((total_pass + ${p:-0})) + total_fail=$((total_fail + ${f:-0})) + total_warn=$((total_warn + ${w:-0})) + echo "{\"event\":\"cycle\",\"ts\":\"$(date -u +%FT%TZ)\",\"cycle\":$cycle,\"kind\":\"api\",\"seed\":$seed,\"port\":$port,\"pass\":${p:-0},\"fail\":${f:-0},\"warn\":${w:-0},\"running_pass\":$total_pass,\"running_fail\":$total_fail,\"running_warn\":$total_warn}" | tee -a "$SUM" + else + echo "{\"event\":\"cycle\",\"ts\":\"$(date -u +%FT%TZ)\",\"cycle\":$cycle,\"kind\":\"api\",\"seed\":$seed,\"port\":$port,\"status\":\"NO_SUMMARY\"}" | tee -a "$SUM" + fi + + if [ $((cycle % UI_EVERY)) -eq 0 ]; then + printf '\n----- cycle %d (seed=%d port=%d) ui -----\n' "$cycle" "$seed" "$((port + 100))" | tee -a "$LOG" + out=$(timeout 180 node ui_stress.mjs "$seed" "$((port + 100))" 2>&1) + echo "$out" | tee -a "$LOG" >/dev/null + summary=$(echo "$out" | grep '"runner"' | grep '"summary"' | tail -1) + if [ -n "$summary" ]; then + p=$(echo "$summary" | sed -n 's/.*"pass":\([0-9]*\).*/\1/p') + f=$(echo "$summary" | sed -n 's/.*"fail":\([0-9]*\).*/\1/p') + w=$(echo "$summary" | sed -n 's/.*"warn":\([0-9]*\).*/\1/p') + total_pass=$((total_pass + ${p:-0})) + total_fail=$((total_fail + ${f:-0})) + total_warn=$((total_warn + ${w:-0})) + echo "{\"event\":\"cycle\",\"ts\":\"$(date -u +%FT%TZ)\",\"cycle\":$cycle,\"kind\":\"ui\",\"seed\":$seed,\"port\":$((port + 100)),\"pass\":${p:-0},\"fail\":${f:-0},\"warn\":${w:-0},\"running_pass\":$total_pass,\"running_fail\":$total_fail,\"running_warn\":$total_warn}" | tee -a "$SUM" + else + echo "{\"event\":\"cycle\",\"ts\":\"$(date -u +%FT%TZ)\",\"cycle\":$cycle,\"kind\":\"ui\",\"seed\":$seed,\"port\":$((port + 100)),\"status\":\"NO_SUMMARY\"}" | tee -a "$SUM" + fi + fi + + sleep "$SLEEP_BETWEEN" +done diff --git a/tests/stress/ui_stress.mjs b/tests/stress/ui_stress.mjs new file mode 100644 index 0000000..a3e56a8 --- /dev/null +++ b/tests/stress/ui_stress.mjs @@ -0,0 +1,181 @@ +// UI-side stress: real Chromium browser contexts driving the SPA. +// Tests scenarios that only matter at the JS layer: +// - happy path through the student SPA UI +// - sleep/wake (browser context closed mid-quiz, instructor advances, browser reopens) +// - cookie tampering via document.cookie +// - simultaneous browsers with same student_id +// Boots its own server. Slower than api_stress but exercises real DOM rendering. + +import { bootServer, setupSession, STRESS_POOL, sleep, logLine, jsonReq, CookieJar, Admin } from "./lib.mjs"; +import { chromium } from "playwright"; + +const SEED = parseInt(process.argv[2] || Date.now(), 10); +const PORT = parseInt(process.argv[3] || (8300 + (SEED % 100)), 10); +const HEADLESS = process.env.HEADLESS !== "0"; + +let pass = 0, fail = 0, warn = 0; +const failures = []; + +function expect(cond, scenario, msg, extra = {}) { + if (cond) { pass++; logLine(scenario, "pass", msg, extra); } + else { fail++; failures.push({ scenario, msg, extra }); logLine(scenario, "fail", msg, extra); } +} +function note(scenario, msg, extra = {}) { warn++; logLine(scenario, "note", msg, extra); } + +async function joinAsStudent(page, baseUrl, sid, sid_id, name) { + await page.goto(`${baseUrl}/?sid=${sid}`); + await page.waitForSelector('input[name="student_id"], input[id*=student]', { timeout: 5000 }); + // Codex SPA uses input[name=student_id] + const idInput = await page.$('input[name="student_id"]'); + const nameInput = await page.$('input[name="name"]'); + if (!idInput || !nameInput) throw new Error("join form fields not found"); + await idInput.fill(sid_id); + await nameInput.fill(name); + await page.click('button:has-text("Join")'); + await page.waitForSelector('text=Waiting for instructor', { timeout: 5000 }); +} + +async function adminOpenQuestion(server, jar, sid, qIdx, timeLimit = 10) { + // Open via admin instructor WS + const admin = new Admin(server.url, sid, jar); + await admin.connect(); + const w = admin.waitFor("question_open", { timeoutMs: 5000 }); + admin.open(qIdx, timeLimit); + await w; + return admin; +} + +// Scenario 1: happy path through the SPA +async function uiHappy(server, browser) { + const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL); + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + await joinAsStudent(page, server.url, sid, "U1", "UIStudent"); + const admin = await adminOpenQuestion(server, jar, sid, 0, 10); + await page.waitForSelector('text=2+2?', { timeout: 5000 }); + expect(true, "ui_happy", "Q1 text rendered in browser"); + await page.click('button:has-text("B")'); + await page.waitForSelector('text=Submitted in', { timeout: 5000 }); + expect(true, "ui_happy", "submitted view shown"); + const closedW = admin.waitFor("question_closed", { timeoutMs: 5000 }); + admin.close(); + await closedW; + await page.waitForSelector('text=Reveal', { timeout: 5000 }); + expect(true, "ui_happy", "reveal view shown"); + admin.disconnect(); + await ctx.close(); +} + +// Scenario 2: sleep/wake via real browser context close-and-reopen, with persisted cookie. +async function uiSleepWake(server, browser) { + const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL); + const ctx1 = await browser.newContext(); + const page1 = await ctx1.newPage(); + await joinAsStudent(page1, server.url, sid, "U2", "Sleeper"); + // Capture the cookie so we can restore it in a fresh context (simulating phone-wake on same device) + const cookies = await ctx1.cookies(); + const adminA = await adminOpenQuestion(server, jar, sid, 0, 10); + await page1.waitForSelector('text=2+2?', { timeout: 5000 }); + await page1.click('button:has-text("B")'); + await page1.waitForSelector('text=Submitted in', { timeout: 5000 }); + // "Phone goes to sleep" — close the context entirely + await ctx1.close(); + // Instructor closes, advances, opens Q2 (skip 1) + let cw = adminA.waitFor("question_closed", { timeoutMs: 5000 }); + adminA.close(); + await cw; + adminA.next(); + await sleep(150); + let ow = adminA.waitFor("question_open", { timeoutMs: 5000 }); + adminA.open(2, 10); + await ow; + // "Phone wakes" — fresh context with same persisted cookie + const ctx2 = await browser.newContext(); + await ctx2.addCookies(cookies); + const page2 = await ctx2.newPage(); + await page2.goto(`${server.url}/?sid=${sid}`); + // Should see Q3 (idx 2) text "Fastest sort?" + try { + await page2.waitForSelector('text=Fastest sort?', { timeout: 5000 }); + expect(true, "ui_sleep_wake", "browser shows the LATEST question after wake"); + } catch (e) { + expect(false, "ui_sleep_wake", "browser did NOT show latest question after wake", { err: e.message }); + } + // Try to submit on the new question + try { + await page2.click('button:has-text("B")'); + await page2.waitForSelector('text=Submitted in', { timeout: 5000 }); + expect(true, "ui_sleep_wake", "post-wake submit acked in UI"); + } catch (e) { + expect(false, "ui_sleep_wake", "post-wake submit failed in UI", { err: e.message }); + } + adminA.disconnect(); + await ctx2.close(); +} + +// Scenario 3: cookie tampering via document.cookie (browser cookie is HttpOnly so this should be a no-op) +async function uiCookieTamper(server, browser) { + const { sid } = await setupSession(server.url, server.adminPw, STRESS_POOL); + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + await joinAsStudent(page, server.url, sid, "U3", "Tamper"); + // Confirm document.cookie does NOT see qz_student (HttpOnly) + const visible = await page.evaluate(() => document.cookie); + expect(!visible.includes("qz_student"), "ui_cookie_tamper", "qz_student is not visible to JS (HttpOnly verified)", { document_cookie: visible }); + // Try to overwrite (browsers will silently ignore HttpOnly overwrite from JS) + await page.evaluate(() => { document.cookie = "qz_student=GARBAGE; path=/"; }); + await page.reload(); + // Should still be in lobby (cookie wasn't actually changed) + try { + await page.waitForSelector('text=Waiting for instructor', { timeout: 4000 }); + expect(true, "ui_cookie_tamper", "tamper attempt did not log student out"); + } catch (e) { + note("ui_cookie_tamper", `tamper may have succeeded (lobby not re-rendered): ${e.message}`); + } + await ctx.close(); +} + +// Scenario 4: two browser contexts with same student_id race (different cookies → 2 participants) +async function uiDupId(server, browser) { + const { sid } = await setupSession(server.url, server.adminPw, STRESS_POOL); + const ctxA = await browser.newContext(); + const ctxB = await browser.newContext(); + const pA = await ctxA.newPage(); + const pB = await ctxB.newPage(); + await joinAsStudent(pA, server.url, sid, "DUPUI", "FirstBrowser"); + await joinAsStudent(pB, server.url, sid, "DUPUI", "SecondBrowser"); + expect(true, "ui_dup_id", "two browsers with same student_id both reach lobby"); + await ctxA.close(); await ctxB.close(); +} + +const SCENARIOS = [ + ["ui_happy", uiHappy], + ["ui_sleep_wake", uiSleepWake], + ["ui_cookie_tamper", uiCookieTamper], + ["ui_dup_id", uiDupId], +]; + +logLine("runner", "info", "starting ui stress", { seed: SEED, port: PORT, headless: HEADLESS }); + +const browser = await chromium.launch({ headless: HEADLESS }); +let server = null; +try { + server = await bootServer({ port: PORT }); + for (const [name, fn] of SCENARIOS) { + logLine(name, "start", `seed=${SEED}`); + try { + await fn(server, browser); + logLine(name, "ok", "scenario completed"); + } catch (e) { + fail++; + failures.push({ scenario: name, msg: "uncaught", extra: { err: e.message } }); + logLine(name, "fail", "uncaught exception", { err: e.message }); + } + } +} finally { + if (server) await server.stop(); + await browser.close(); +} + +logLine("runner", "summary", "done", { pass, fail, warn, failures }); +process.exit(fail > 0 ? 1 : 0);