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