tests/stress: add Node-based adversarial stress harness
Two suites under tests/stress/, plus a tmux-friendly run_loop.sh runner. Both boot a fresh uvicorn on an isolated DB per cycle and log JSON line summaries to runs/. api_stress.mjs covers WS-level scenarios that the existing pytest suite does not exercise: 20-student happy path, late joiners with correct remaining_ms, mid-question disconnect, browser-sleep + wake to a different question_idx, cookie tampering and cross-session cookie reuse, duplicate student_id, bad submit (out-of-order, wrong idx, resubmit no-op), close-boundary race with auto-close, malformed JSON fuzz, and flaky reconnect. ui_stress.mjs drives the same flows in a real Chromium context via playwright: happy UI, sleep/wake by closing+reopening a context with the persisted cookie, document.cookie tampering attempt, and two browser contexts joining with the same student_id. Findings will be summarised in runs/summary.jsonl over time. One known issue surfaces from the fuzz scenario: app/room.py student_ws's receive_json call propagates JSONDecodeError out of the only try/except (which catches WebSocketDisconnect), killing that client's WS handler. Other clients are unaffected.
This commit is contained in:
181
tests/stress/ui_stress.mjs
Normal file
181
tests/stress/ui_stress.mjs
Normal file
@@ -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);
|
||||
Reference in New Issue
Block a user