// 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 };