diff --git a/app/components/SkribblGame.tsx b/app/components/SkribblGame.tsx index 1e9387b..41e6825 100644 --- a/app/components/SkribblGame.tsx +++ b/app/components/SkribblGame.tsx @@ -196,10 +196,14 @@ export default function SkribblGame() {
- {isDrawer ? "Your secret word" : `${drawerName} is drawing`} + {sk?.phase === "choosing" + ? (isDrawer ? "Pick a word!" : `${drawerName} is choosing a word…`) + : (isDrawer ? "Your secret word" : `${drawerName} is drawing`)}
- {isDrawer && sk?.phase === "drawing" ? (sk as any)?.word || wordMask : wordMask || "_____"} + {sk?.phase === "drawing" + ? (isDrawer ? ((sk as any)?.word || wordMask) : (wordMask || "_____")) + : ""}
diff --git a/app/room/[code]/page.tsx b/app/room/[code]/page.tsx index 55a6856..60856d4 100644 --- a/app/room/[code]/page.tsx +++ b/app/room/[code]/page.tsx @@ -24,6 +24,8 @@ export default function LobbyPage() { const isHost = room && myId && room.hostId === myId; const shareUrl = typeof window !== "undefined" ? `${window.location.origin}/join?code=${code}` : ""; + const playerCount = room?.players.filter(p => p.connected).length || 0; + const garticNeedsMore = room?.mode === "gartic" && playerCount < 3; const start = () => { setErr(""); @@ -105,8 +107,13 @@ export default function LobbyPage() { }
+ {garticNeedsMore && ( +
+ Gartic Phone is way more fun with friends! Grab at least 3 players to start. ({playerCount}/3) +
+ )} {isHost ? ( - diff --git a/server/socket-handlers.js b/server/socket-handlers.js index 2874c01..0e63c76 100644 --- a/server/socket-handlers.js +++ b/server/socket-handlers.js @@ -344,6 +344,9 @@ function registerHandlers(io) { if (room.players.filter((p) => p.connected).length < 2 && room.mode !== "color") { return ack && ack({ ok: false, error: "need 2+ players" }); } + if (room.mode === "gartic" && room.players.filter((p) => p.connected).length < 3) { + return ack && ack({ ok: false, error: "Gartic Phone needs at least 3 players" }); + } room.phase = "playing"; if (room.mode === "skribbl") { G.skribblInit(room); diff --git a/test-results/e2e-color-01-create.png b/test-results/e2e-color-01-create.png new file mode 100644 index 0000000..1e6e86d Binary files /dev/null and b/test-results/e2e-color-01-create.png differ diff --git a/test-results/e2e-color-02-lobby.png b/test-results/e2e-color-02-lobby.png new file mode 100644 index 0000000..df55e7d Binary files /dev/null and b/test-results/e2e-color-02-lobby.png differ diff --git a/test-results/e2e-color-03-play.png b/test-results/e2e-color-03-play.png new file mode 100644 index 0000000..884b996 Binary files /dev/null and b/test-results/e2e-color-03-play.png differ diff --git a/test-results/e2e-join-bad-01.png b/test-results/e2e-join-bad-01.png new file mode 100644 index 0000000..4078835 Binary files /dev/null and b/test-results/e2e-join-bad-01.png differ diff --git a/test-results/e2e-skribbl-01-landing.png b/test-results/e2e-skribbl-01-landing.png new file mode 100644 index 0000000..fb0a5a2 Binary files /dev/null and b/test-results/e2e-skribbl-01-landing.png differ diff --git a/test-results/e2e-skribbl-02-create-form.png b/test-results/e2e-skribbl-02-create-form.png new file mode 100644 index 0000000..d349dd5 Binary files /dev/null and b/test-results/e2e-skribbl-02-create-form.png differ diff --git a/test-results/e2e-skribbl-03-lobby-alice.png b/test-results/e2e-skribbl-03-lobby-alice.png new file mode 100644 index 0000000..15a4b32 Binary files /dev/null and b/test-results/e2e-skribbl-03-lobby-alice.png differ diff --git a/test-results/e2e-skribbl-04-lobby-bob.png b/test-results/e2e-skribbl-04-lobby-bob.png new file mode 100644 index 0000000..6591f5b Binary files /dev/null and b/test-results/e2e-skribbl-04-lobby-bob.png differ diff --git a/test-results/e2e-skribbl-05-play-alice.png b/test-results/e2e-skribbl-05-play-alice.png new file mode 100644 index 0000000..2761968 Binary files /dev/null and b/test-results/e2e-skribbl-05-play-alice.png differ diff --git a/test-results/e2e-skribbl-06-play-bob.png b/test-results/e2e-skribbl-06-play-bob.png new file mode 100644 index 0000000..2aaf0db Binary files /dev/null and b/test-results/e2e-skribbl-06-play-bob.png differ diff --git a/test-results/feature-test-report.json b/test-results/feature-test-report.json new file mode 100644 index 0000000..47d5040 --- /dev/null +++ b/test-results/feature-test-report.json @@ -0,0 +1,132 @@ +{ + "summary": { + "unit": { "passed": 13, "failed": 0 }, + "api": { "passed": 2, "failed": 0 }, + "socket": { "passed": 31, "failed": 0 }, + "e2e": { "passed": 3, "failed": 0 } + }, + "unit_tests": [ + { "name": "normalize: lowercases and trims", "status": "pass" }, + { "name": "maskWord: blanks letters but preserves spaces", "status": "pass" }, + { "name": "maskWord: with all indices revealed produces lowercased word", "status": "pass" }, + { "name": "maskWord: partial reveal mixes letters and underscores", "status": "pass" }, + { "name": "pickRevealIndex: picks unrevealed non-space index", "status": "pass" }, + { "name": "pickRevealIndex: returns null when all letters revealed", "status": "pass" }, + { "name": "revealHint flow: applying picked index reveals exactly N more letters", "status": "pass" }, + { "name": "revealHint idempotence: re-applying same revealed set yields same mask", "status": "pass" }, + { "name": "isCloseGuess: edit distance 1-2 returns true", "status": "pass" }, + { "name": "genCode: returns a unique 6-char alphanumeric code", "status": "pass" }, + { "name": "setRoom + getRoom: returns same instance, case-insensitive lookup", "status": "pass" }, + { "name": "makeId: returns a non-empty string id", "status": "pass" }, + { "name": "allRooms: exposes the underlying Map", "status": "pass" } + ], + "api_tests": [ + { + "endpoint": "GET /api/health", + "status": "pass", + "code": 200, + "notes": "Returns {status:'ok', uptime:N, service:'skribbl-gartic-color', ts:N}; matches PRD contract." + }, + { + "endpoint": "GET /api/room/NOPE12/exists", + "status": "pass", + "code": 200, + "notes": "Returns {exists:false, mode:null, playerCount:0} for non-existent room as required." + } + ], + "socket_tests": { + "skribbl": [ + { "name": "clients A and B connected", "status": "pass" }, + { "name": "A: room:create -> ok, returns code+sessionToken+playerId", "status": "pass" }, + { "name": "B: room:join -> ok", "status": "pass" }, + { "name": "Both clients receive room:state with both players", "status": "pass" }, + { "name": "chat:send broadcasts to both clients", "status": "pass" }, + { "name": "game:start ack ok (host)", "status": "pass" }, + { "name": "drawer received skribbl:wordChoices (3 choices)", "status": "pass" }, + { "name": "skribbl:pickWord ack ok with chosen word", "status": "pass" }, + { "name": "Both clients received skribbl:roundStart", "status": "pass" }, + { "name": "Non-drawer received skribbl:stroke from drawer", "status": "pass" }, + { "name": "Correct guess -> chat:msg kind=correct on both clients", "status": "pass" }, + { "name": "room:state shows guesser score > 0 after correct guess (149 pts)", "status": "pass" }, + { "name": "skribbl:roundEnd fires with the word", "status": "pass" } + ], + "gartic": [ + { "name": "Both clients connected", "status": "pass" }, + { "name": "room:create (mode=gartic) ok", "status": "pass" }, + { "name": "B joined", "status": "pass" }, + { "name": "game:start ok", "status": "pass" }, + { "name": "Both clients got gartic:turn task=prompt", "status": "pass" }, + { "name": "Both prompts submitted (acks ok)", "status": "pass" }, + { "name": "Both got next turn task=drawing with content from prior prompt", "status": "pass" }, + { "name": "Drawing turn includes prior prompt as content", "status": "pass" }, + { "name": "Drawings submitted (acks ok)", "status": "pass" }, + { "name": "gartic:bookComplete fired with 2 books (2-player chain)", "status": "pass" } + ], + "color": [ + { "name": "Clients A,B connected", "status": "pass" }, + { "name": "room:create (mode=color, canvasType=blank) ok", "status": "pass" }, + { "name": "B joined", "status": "pass" }, + { "name": "game:start ok", "status": "pass" }, + { "name": "room:state transitioned to phase=playing with color object", "status": "pass" }, + { "name": "B received color:strokeBroadcast with same payload", "status": "pass" }, + { "name": "C joined mid-game", "status": "pass" }, + { "name": "New joiner C received color:state with existing stroke", "status": "pass" } + ] + }, + "e2e_tests": [ + { + "flow": "A", + "status": "pass", + "screenshots": [ + "test-results/e2e-skribbl-01-landing.png", + "test-results/e2e-skribbl-02-create-form.png", + "test-results/e2e-skribbl-03-lobby-alice.png", + "test-results/e2e-skribbl-04-lobby-bob.png", + "test-results/e2e-skribbl-05-play-alice.png", + "test-results/e2e-skribbl-06-play-bob.png" + ], + "notes": "Landing renders DrawTogether brand. Create CTA navigates to /create. Skribbl mode + nickname submit creates room, lands on /room/{code} with [data-testid=room-code] visible. Bob joins via /join with code. Alice clicks start-game, both Alice and Bob auto-navigate to /play." + }, + { + "flow": "B", + "status": "pass", + "screenshots": ["test-results/e2e-join-bad-01.png"], + "notes": "Submitting code 'ZZZZZZ' from /join keeps user on /join and renders error message ('couldn't join'/'room not found'). No incorrect redirect to a room page." + }, + { + "flow": "C", + "status": "pass", + "screenshots": [ + "test-results/e2e-color-01-create.png", + "test-results/e2e-color-02-lobby.png", + "test-results/e2e-color-03-play.png" + ], + "notes": "Color mode room created (single-player allowed by server). Start-game transitions to /play; [data-testid=color-canvas] renders and 16 color palette swatches detected on the page." + } + ], + "issues_found": [ + { + "severity": "minor", + "area": "skribbl", + "description": "On the drawer's play screen during the 'choosing a word' phase, the secret-word area still shows underscores (`_ _ _ _ _`) of arbitrary length even though no word is yet picked. The drawer hasn't selected a word, so showing a placeholder mask of unrelated length is confusing. Likely the UI keeps a stale wordMask from an earlier state or shows a default placeholder.", + "reproduce": "Create a skribbl room with 2 players, start game, observe the drawer's play screen during the 15-second word-choice window — you'll see 5 underscores under 'YOUR SECRET WORD' before any word is chosen." + }, + { + "severity": "minor", + "area": "color", + "description": "On the color play screen at viewport 1280x800, the canvas occupies the full viewport so the color palette + tool buttons (brush/eraser/bucket) sit below the fold; the user has to scroll to access them. This is not a feature break (selectors exist in DOM and respond) but at common laptop viewports the toolbar is not immediately visible.", + "reproduce": "Open a color room as host, click Start Game, observe at 1280x800: header + canvas fill the screen, palette/tools require scrolling. See screenshot test-results/e2e-color-03-play.png." + }, + { + "severity": "minor", + "area": "gartic", + "description": "For 2-player gartic, the chain only goes prompt -> drawing -> done (totalTurns = players.length = 2). There's no 'guess' turn ever produced because turnIndex 1 is drawing and the loop ends. This is consistent with the algorithm (totalTurns = players.length) but means with 2 players you never see a guess phase. PRD/test spec mentioned a guess turn — verify if 3+ players is required for the full prompt->draw->guess cycle.", + "reproduce": "Run test/socket-gartic.js — totalTurns=2, after drawing submission, gartic:bookComplete fires immediately with no guess turn." + } + ], + "recommendations": [ + "Consider hiding/blanking the word-mask placeholder on the drawer's play screen during the 'choosing' phase so users don't see meaningless underscores before picking a word.", + "Color play screen: anchor the palette/toolbar to the bottom edge of the viewport (sticky/fixed) or shrink the canvas height so the tools are visible without scrolling on 1280x800 and smaller laptop viewports — this will likely come up again in Phase 7 (UI/UX) and Phase 8 (responsive).", + "Gartic: for 2-player rooms the book never reaches a guess phase. Either require >=3 players for gartic mode, or extend totalTurns to players.length+1 to ensure at least one guess turn even at 2 players. Document expected behaviour either way." + ] +} diff --git a/test/e2e.js b/test/e2e.js new file mode 100644 index 0000000..af26ea2 --- /dev/null +++ b/test/e2e.js @@ -0,0 +1,189 @@ +// E2E browser tests using playwright-core + system chromium. +// This is a fallback because the Playwright MCP server is configured for `chrome` +// (not present in this env). It produces equivalent screenshots and assertions. + +const path = require("path"); +const fs = require("fs"); +const { chromium } = require("/home/claude/.npm/_npx/e41f203b7505f1fb/node_modules/playwright-core"); + +const URL = "http://localhost:3000"; +const OUT = path.join(__dirname, "..", "test-results"); +if (!fs.existsSync(OUT)) fs.mkdirSync(OUT, { recursive: true }); + +const flows = []; +function pushResult(flow, status, screenshots, notes) { + flows.push({ flow, status, screenshots, notes }); + console.log(`${status === "pass" ? "PASS" : "FAIL"}: E2E ${flow} - ${notes}`); +} + +async function shot(page, name) { + const p = path.join(OUT, name); + await page.screenshot({ path: p, fullPage: false }); + return name; +} + +(async () => { + let browser; + try { + browser = await chromium.launch({ + headless: true, + executablePath: "/usr/bin/chromium", + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }); + console.log("PASS: chromium launched"); + + // -------------------- E2E A: Skribbl flow -------------------- + { + const screenshots = []; + let notes = []; + try { + const ctxA = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const pageA = await ctxA.newPage(); + + await pageA.goto(URL + "/", { waitUntil: "networkidle" }); + const title = await pageA.title(); + const html = await pageA.content(); + const hasBrand = /DrawTogether/i.test(html); + if (!hasBrand) throw new Error("brand 'DrawTogether' not found on landing"); + notes.push("landing has DrawTogether"); + screenshots.push(await shot(pageA, "e2e-skribbl-01-landing.png")); + + // Click create-room CTA + await pageA.click('[data-testid=create-room-cta]'); + await pageA.waitForURL("**/create", { timeout: 5000 }); + notes.push("navigated to /create"); + + // Fill nickname, click skribbl mode + await pageA.click('[data-testid=mode-skribbl]'); + await pageA.fill('[data-testid=nickname-input]', "Alice"); + screenshots.push(await shot(pageA, "e2e-skribbl-02-create-form.png")); + + await pageA.click('[data-testid=create-submit]'); + // wait for /room/XXXXXX + await pageA.waitForURL(/\/room\/[A-Z0-9]{6}$/, { timeout: 8000 }); + const lobbyUrl = pageA.url(); + const code = lobbyUrl.match(/\/room\/([A-Z0-9]{6})/)[1]; + notes.push("created room, code=" + code); + + // Wait for room-code element, ensure it shows the code + await pageA.waitForSelector('[data-testid=room-code]', { timeout: 5000 }); + const onPageCode = (await pageA.textContent('[data-testid=room-code]')).trim(); + if (onPageCode !== code) throw new Error("room-code mismatch on page: " + onPageCode + " vs URL " + code); + notes.push("room-code visible on page: " + onPageCode); + screenshots.push(await shot(pageA, "e2e-skribbl-03-lobby-alice.png")); + + // Open second context for Bob + const ctxB = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const pageB = await ctxB.newPage(); + await pageB.goto(URL + "/join", { waitUntil: "networkidle" }); + await pageB.fill('[data-testid=room-code-input]', code); + await pageB.fill('[data-testid=nickname-input]', "Bob"); + await pageB.click('[data-testid=join-submit]'); + await pageB.waitForURL(/\/room\/[A-Z0-9]{6}/, { timeout: 8000 }); + notes.push("Bob joined the room"); + // ensure player-list shows both + await pageB.waitForSelector('[data-testid=player-list]', { timeout: 5000 }); + screenshots.push(await shot(pageB, "e2e-skribbl-04-lobby-bob.png")); + + // Back to Alice — start the game + await pageA.waitForSelector('[data-testid=start-game]', { timeout: 5000 }); + // small wait to ensure both connected + await pageA.waitForTimeout(500); + await pageA.click('[data-testid=start-game]'); + // play screen has [data-testid=room-pill] + await pageA.waitForURL(/\/room\/[A-Z0-9]{6}\/play/, { timeout: 8000 }); + await pageA.waitForSelector('[data-testid=room-pill]', { timeout: 5000 }); + notes.push("Alice landed on /play"); + screenshots.push(await shot(pageA, "e2e-skribbl-05-play-alice.png")); + + // Bob should also navigate to play + try { + await pageB.waitForURL(/\/room\/[A-Z0-9]{6}\/play/, { timeout: 8000 }); + screenshots.push(await shot(pageB, "e2e-skribbl-06-play-bob.png")); + notes.push("Bob landed on /play"); + } catch (e) { + notes.push("Bob did NOT auto-navigate to /play within timeout (minor): " + e.message); + } + + await ctxA.close(); + await ctxB.close(); + pushResult("A", "pass", screenshots, notes.join("; ")); + } catch (e) { + pushResult("A", "fail", screenshots, "error: " + e.message + "; trace: " + (notes.join("; "))); + } + } + + // -------------------- E2E B: Join non-existent room -------------------- + { + const screenshots = []; + let notes = []; + try { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const page = await ctx.newPage(); + await page.goto(URL + "/join", { waitUntil: "networkidle" }); + await page.fill('[data-testid=room-code-input]', "ZZZZZZ"); + await page.fill('[data-testid=nickname-input]', "Ghost"); + await page.click('[data-testid=join-submit]'); + // Should NOT navigate to a /room/ URL successfully — wait briefly to see + await page.waitForTimeout(2000); + const finalUrl = page.url(); + const successful = /\/room\/[A-Z0-9]{6}$/.test(finalUrl); + if (successful) throw new Error("incorrectly redirected to a room: " + finalUrl); + // look for an error message + const text = await page.textContent("body"); + const hasError = /room not found|couldn[']t join|not found|error/i.test(text); + notes.push("stayed on /join, hasErrorMessage=" + hasError); + screenshots.push(await shot(page, "e2e-join-bad-01.png")); + if (!hasError) { + // not strictly fail — but flag + pushResult("B", "fail", screenshots, "no error message visible after invalid code; final URL=" + finalUrl); + } else { + pushResult("B", "pass", screenshots, notes.join("; ") + "; finalUrl=" + finalUrl); + } + await ctx.close(); + } catch (e) { + pushResult("B", "fail", screenshots, "error: " + e.message); + } + } + + // -------------------- E2E C: Color mode lobby start -------------------- + { + const screenshots = []; + let notes = []; + try { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const page = await ctx.newPage(); + await page.goto(URL + "/create", { waitUntil: "networkidle" }); + await page.click('[data-testid=mode-color]'); + await page.fill('[data-testid=nickname-input]', "Painter"); + screenshots.push(await shot(page, "e2e-color-01-create.png")); + await page.click('[data-testid=create-submit]'); + await page.waitForURL(/\/room\/[A-Z0-9]{6}$/, { timeout: 8000 }); + await page.waitForSelector('[data-testid=room-code]', { timeout: 5000 }); + notes.push("color room created"); + screenshots.push(await shot(page, "e2e-color-02-lobby.png")); + + // start game (color allows single player per server logic) + await page.click('[data-testid=start-game]'); + await page.waitForURL(/\/room\/[A-Z0-9]{6}\/play/, { timeout: 8000 }); + // wait for color-canvas + await page.waitForSelector('[data-testid=color-canvas]', { timeout: 8000 }); + // also color palette + const paletteCount = await page.locator('[data-testid^="color-#"]').count(); + notes.push("color-canvas + palette swatches=" + paletteCount); + screenshots.push(await shot(page, "e2e-color-03-play.png")); + if (paletteCount < 1) throw new Error("no color palette swatches found"); + pushResult("C", "pass", screenshots, notes.join("; ")); + await ctx.close(); + } catch (e) { + pushResult("C", "fail", screenshots, "error: " + e.message + "; trace: " + notes.join("; ")); + } + } + } catch (e) { + console.log("FAIL: e2e bootstrap - " + e.message); + } finally { + if (browser) try { await browser.close(); } catch (_) {} + console.log("__E2E_RESULTS__ " + JSON.stringify(flows)); + process.exit(flows.every((f) => f.status === "pass") ? 0 : 1); + } +})(); diff --git a/test/socket-color.js b/test/socket-color.js new file mode 100644 index 0000000..bc568ff --- /dev/null +++ b/test/socket-color.js @@ -0,0 +1,100 @@ +// Color Together mode socket test. +const { io } = require("socket.io-client"); +const URL = "http://localhost:3000"; +const TIMEOUT_MS = 20000; + +const results = []; +let exitCode = 0; +const pass = (n) => { results.push({ name: n, status: "pass" }); console.log("PASS:", n); }; +const fail = (n, e) => { results.push({ name: n, status: "fail", error: String(e) }); console.log("FAIL:", n, "-", e); exitCode = 1; }; + +function waitFor(socket, event, predicate = () => true, timeout = 6000) { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error(`timeout waiting for ${event}`)), timeout); + const handler = (data) => { + if (predicate(data)) { + clearTimeout(t); + socket.off(event, handler); + resolve(data); + } + }; + socket.on(event, handler); + }); +} +function emitAck(s, event, payload) { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error(`ack timeout for ${event}`)), 5000); + s.emit(event, payload, (resp) => { clearTimeout(t); resolve(resp); }); + }); +} + +const killer = setTimeout(() => { console.log("FAIL: Global timeout"); process.exit(1); }, TIMEOUT_MS); +killer.unref(); + +(async () => { + let a, b, c; + try { + a = io(URL, { transports: ["websocket"], forceNew: true }); + b = io(URL, { transports: ["websocket"], forceNew: true }); + await Promise.all([ + new Promise((r) => a.on("connect", r)), + new Promise((r) => b.on("connect", r)), + ]); + pass("color: clients A,B connected"); + + const create = await emitAck(a, "room:create", { + nickname: "Alice", avatar: "🐱", mode: "color", + settings: { canvasType: "blank" }, + }); + if (!create || !create.ok) throw new Error("create failed: " + JSON.stringify(create)); + const code = create.code; + pass("color: room created, code=" + code); + + const j = await emitAck(b, "room:join", { code, nickname: "Bob", avatar: "🐶" }); + if (!j || !j.ok) throw new Error("join failed"); + pass("color: B joined"); + + // listen FIRST, then trigger + const stateAfterStartP = waitFor(a, "room:state", (s) => s.color !== null && s.phase === "playing", 4000); + const start = await emitAck(a, "game:start", {}); + if (!start || !start.ok) throw new Error("game:start failed: " + JSON.stringify(start)); + pass("color: game:start ok"); + + const stateAfterStart = await stateAfterStartP; + pass("color: room transitioned to playing with color state"); + + // A emits color:stroke; B should receive color:strokeBroadcast + const strokePayload = { + points: [{ x: 10, y: 10 }, { x: 20, y: 20 }], + color: "#FF0000", + size: 5, + tool: "brush", + }; + const bRecv = waitFor(b, "color:strokeBroadcast", (d) => d && d.color === "#FF0000", 4000); + a.emit("color:stroke", strokePayload); + const got = await bRecv; + if (!got || !Array.isArray(got.points) || got.points.length !== 2) throw new Error("stroke broadcast malformed: " + JSON.stringify(got)); + pass("color: B received color:strokeBroadcast with same payload"); + + // C joins mid-game and should receive color:state with the existing stroke + c = io(URL, { transports: ["websocket"], forceNew: true }); + await new Promise((r) => c.on("connect", r)); + const cStateP = waitFor(c, "color:state", (s) => s && Array.isArray(s.strokes), 5000); + const j2 = await emitAck(c, "room:join", { code, nickname: "Carol", avatar: "🦊" }); + if (!j2 || !j2.ok) throw new Error("C join failed"); + pass("color: C joined mid-game"); + const cState = await cStateP; + if (!cState.strokes || cState.strokes.length < 1) throw new Error("color:state to new joiner missing strokes: " + JSON.stringify(cState)); + pass("color: new joiner C received color:state with " + cState.strokes.length + " stroke(s)"); + + } catch (e) { + fail("color flow", e && e.message ? e.message : String(e)); + } finally { + try { a && a.disconnect(); } catch (_) {} + try { b && b.disconnect(); } catch (_) {} + try { c && c.disconnect(); } catch (_) {} + clearTimeout(killer); + console.log("__RESULTS__ " + JSON.stringify(results)); + setTimeout(() => process.exit(exitCode), 200); + } +})(); diff --git a/test/socket-gartic-min-players.js b/test/socket-gartic-min-players.js new file mode 100644 index 0000000..49ab1f7 --- /dev/null +++ b/test/socket-gartic-min-players.js @@ -0,0 +1,62 @@ +// Verify Gartic mode requires at least 3 players to start. +const { io } = require("socket.io-client"); +const URL = "http://localhost:3000"; +const TIMEOUT_MS = 15000; + +const results = []; +let exitCode = 0; +const pass = (n) => { results.push({ name: n, status: "pass" }); console.log("PASS:", n); }; +const fail = (n, e) => { results.push({ name: n, status: "fail", error: String(e) }); console.log("FAIL:", n, "-", e); exitCode = 1; }; + +function emitAck(s, event, payload) { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error(`ack timeout for ${event}`)), 5000); + s.emit(event, payload, (resp) => { clearTimeout(t); resolve(resp); }); + }); +} + +const killer = setTimeout(() => { console.log("FAIL: Global timeout"); process.exit(1); }, TIMEOUT_MS); +killer.unref(); + +(async () => { + let a, b; + try { + a = io(URL, { transports: ["websocket"], forceNew: true }); + b = io(URL, { transports: ["websocket"], forceNew: true }); + await Promise.all([ + new Promise((r) => a.on("connect", r)), + new Promise((r) => b.on("connect", r)), + ]); + pass("min-players: two clients connected"); + + const create = await emitAck(a, "room:create", { + nickname: "Alice", avatar: "🐱", mode: "gartic", + settings: {}, + }); + if (!create || !create.ok) throw new Error("create failed: " + JSON.stringify(create)); + const code = create.code; + pass("min-players: room created, code=" + code); + + const j = await emitAck(b, "room:join", { code, nickname: "Bob", avatar: "🐶" }); + if (!j || !j.ok) throw new Error("B join failed: " + JSON.stringify(j)); + pass("min-players: B joined (2 players total)"); + + // game:start should fail with min-players error + const start = await emitAck(a, "game:start", {}); + if (!start) throw new Error("no ack from game:start"); + if (start.ok) throw new Error("expected game:start to fail with 2 players, got ok=true"); + if (!start.error || !/3 players|at least 3/i.test(start.error)) { + throw new Error("expected error mentioning '3 players', got: " + JSON.stringify(start)); + } + pass("min-players: game:start blocked with friendly error: " + start.error); + + } catch (e) { + fail("min-players flow", e && e.message ? e.message : String(e)); + } finally { + try { a && a.disconnect(); } catch (_) {} + try { b && b.disconnect(); } catch (_) {} + clearTimeout(killer); + console.log("__RESULTS__ " + JSON.stringify(results)); + setTimeout(() => process.exit(exitCode), 200); + } +})(); diff --git a/test/socket-gartic.js b/test/socket-gartic.js new file mode 100644 index 0000000..4aa6e62 --- /dev/null +++ b/test/socket-gartic.js @@ -0,0 +1,145 @@ +// Gartic mode socket flow test. +const { io } = require("socket.io-client"); +const URL = "http://localhost:3000"; +const TIMEOUT_MS = 30000; + +const results = []; +let exitCode = 0; +const pass = (n) => { results.push({ name: n, status: "pass" }); console.log("PASS:", n); }; +const fail = (n, e) => { results.push({ name: n, status: "fail", error: String(e) }); console.log("FAIL:", n, "-", e); exitCode = 1; }; + +function waitFor(socket, event, predicate = () => true, timeout = 8000) { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error(`timeout waiting for ${event}`)), timeout); + const handler = (data) => { + if (predicate(data)) { + clearTimeout(t); + socket.off(event, handler); + resolve(data); + } + }; + socket.on(event, handler); + }); +} +function emitAck(s, event, payload) { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error(`ack timeout for ${event}`)), 5000); + s.emit(event, payload, (resp) => { clearTimeout(t); resolve(resp); }); + }); +} + +const killer = setTimeout(() => { console.log("FAIL: Global timeout"); process.exit(1); }, TIMEOUT_MS); +killer.unref(); + +(async () => { + let a, b, c; + try { + a = io(URL, { transports: ["websocket"], forceNew: true }); + b = io(URL, { transports: ["websocket"], forceNew: true }); + c = io(URL, { transports: ["websocket"], forceNew: true }); + await Promise.all([ + new Promise((r) => a.on("connect", r)), + new Promise((r) => b.on("connect", r)), + new Promise((r) => c.on("connect", r)), + ]); + pass("gartic: three clients connected"); + + // A creates gartic room + const create = await emitAck(a, "room:create", { + nickname: "Alice", avatar: "🐱", mode: "gartic", + settings: {}, + }); + if (!create || !create.ok) throw new Error("create failed: " + JSON.stringify(create)); + const code = create.code; + pass("gartic: room:create ok, code=" + code); + + // B and C join + const j1 = await emitAck(b, "room:join", { code, nickname: "Bob", avatar: "🐶" }); + if (!j1 || !j1.ok) throw new Error("B join failed: " + JSON.stringify(j1)); + pass("gartic: B joined"); + const j2 = await emitAck(c, "room:join", { code, nickname: "Carol", avatar: "🦊" }); + if (!j2 || !j2.ok) throw new Error("C join failed: " + JSON.stringify(j2)); + pass("gartic: C joined"); + + // Each client should receive a "gartic:turn" with task=prompt after game:start + const aTurn0P = waitFor(a, "gartic:turn", () => true, 5000); + const bTurn0P = waitFor(b, "gartic:turn", () => true, 5000); + const cTurn0P = waitFor(c, "gartic:turn", () => true, 5000); + const start = await emitAck(a, "game:start", {}); + if (!start || !start.ok) throw new Error("game:start: " + JSON.stringify(start)); + pass("gartic: game:start ok"); + + const aT0 = await aTurn0P; + const bT0 = await bTurn0P; + const cT0 = await cTurn0P; + if (aT0.task !== "prompt" || bT0.task !== "prompt" || cT0.task !== "prompt") throw new Error("expected prompt task, got " + aT0.task + "/" + bT0.task + "/" + cT0.task); + pass("gartic: all three got gartic:turn task=prompt"); + + // All three submit prompts + const aTurn1P = waitFor(a, "gartic:turn", () => true, 8000); + const bTurn1P = waitFor(b, "gartic:turn", () => true, 8000); + const cTurn1P = waitFor(c, "gartic:turn", () => true, 8000); + const sub1 = await emitAck(a, "gartic:submit", { type: "prompt", data: "a cat" }); + if (!sub1 || !sub1.ok) throw new Error("A prompt submit failed: " + JSON.stringify(sub1)); + const sub2 = await emitAck(b, "gartic:submit", { type: "prompt", data: "a robot" }); + if (!sub2 || !sub2.ok) throw new Error("B prompt submit failed: " + JSON.stringify(sub2)); + const sub3 = await emitAck(c, "gartic:submit", { type: "prompt", data: "a tree" }); + if (!sub3 || !sub3.ok) throw new Error("C prompt submit failed: " + JSON.stringify(sub3)); + pass("gartic: prompts submitted ok"); + + const aT1 = await aTurn1P; + const bT1 = await bTurn1P; + const cT1 = await cTurn1P; + if (aT1.task !== "drawing" || bT1.task !== "drawing" || cT1.task !== "drawing") throw new Error("expected drawing task, got " + aT1.task); + pass("gartic: all three got next turn task=drawing with content"); + if (!aT1.content || !bT1.content || !cT1.content) throw new Error("drawing content missing"); + pass("gartic: drawing turn includes prior prompt as content"); + + // Submit drawings — should advance to guess phase (not done) for 3 players + const tinyImg = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="; + const aTurn2P = waitFor(a, "gartic:turn", () => true, 8000); + const bTurn2P = waitFor(b, "gartic:turn", () => true, 8000); + const cTurn2P = waitFor(c, "gartic:turn", () => true, 8000); + + const d1 = await emitAck(a, "gartic:submit", { type: "drawing", data: tinyImg }); + if (!d1 || !d1.ok) throw new Error("A drawing submit failed: " + JSON.stringify(d1)); + const d2 = await emitAck(b, "gartic:submit", { type: "drawing", data: tinyImg }); + if (!d2 || !d2.ok) throw new Error("B drawing submit failed: " + JSON.stringify(d2)); + const d3 = await emitAck(c, "gartic:submit", { type: "drawing", data: tinyImg }); + if (!d3 || !d3.ok) throw new Error("C drawing submit failed: " + JSON.stringify(d3)); + pass("gartic: drawings submitted ok"); + + // 3 players: totalTurns = 3 -> turnIndex 0 (prompt), 1 (drawing), 2 (guess) + const aT2 = await aTurn2P; + const bT2 = await bTurn2P; + const cT2 = await cTurn2P; + if (aT2.task !== "guess" || bT2.task !== "guess" || cT2.task !== "guess") throw new Error("expected guess task, got " + aT2.task + "/" + bT2.task + "/" + cT2.task); + pass("gartic: all three got next turn task=guess"); + + // Submit guesses — book should complete + const bookCompleteP = waitFor(a, "gartic:bookComplete", () => true, 10000); + const g1 = await emitAck(a, "gartic:submit", { type: "guess", data: "kitty" }); + if (!g1 || !g1.ok) throw new Error("A guess submit failed: " + JSON.stringify(g1)); + const g2 = await emitAck(b, "gartic:submit", { type: "guess", data: "metal man" }); + if (!g2 || !g2.ok) throw new Error("B guess submit failed: " + JSON.stringify(g2)); + const g3 = await emitAck(c, "gartic:submit", { type: "guess", data: "plant" }); + if (!g3 || !g3.ok) throw new Error("C guess submit failed: " + JSON.stringify(g3)); + pass("gartic: guesses submitted ok"); + + const bookComplete = await bookCompleteP; + if (!bookComplete || !Array.isArray(bookComplete.books)) { + throw new Error("expected gartic:bookComplete event"); + } + pass("gartic: gartic:bookComplete fired, books=" + bookComplete.books.length); + + } catch (e) { + fail("gartic flow", e && e.message ? e.message : String(e)); + } finally { + try { a && a.disconnect(); } catch (_) {} + try { b && b.disconnect(); } catch (_) {} + try { c && c.disconnect(); } catch (_) {} + clearTimeout(killer); + console.log("__RESULTS__ " + JSON.stringify(results)); + setTimeout(() => process.exit(exitCode), 200); + } +})(); diff --git a/test/socket-test.js b/test/socket-test.js new file mode 100644 index 0000000..36b48d4 --- /dev/null +++ b/test/socket-test.js @@ -0,0 +1,148 @@ +// E2E socket flow test for SKRIBBL mode. +const { io } = require("socket.io-client"); + +const URL = "http://localhost:3000"; +const TIMEOUT_MS = 25000; + +const results = []; +let exitCode = 0; +function pass(name) { results.push({ name, status: "pass" }); console.log("PASS:", name); } +function fail(name, err) { results.push({ name, status: "fail", error: String(err) }); console.log("FAIL:", name, "-", err); exitCode = 1; } + +const wait = (ms) => new Promise(r => setTimeout(r, ms)); +function waitFor(socket, event, predicate = () => true, timeout = 8000) { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error(`timeout waiting for ${event}`)), timeout); + const handler = (data) => { + if (predicate(data)) { + clearTimeout(t); + socket.off(event, handler); + resolve(data); + } + }; + socket.on(event, handler); + }); +} +function emitAck(socket, event, payload) { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error(`ack timeout for ${event}`)), 5000); + socket.emit(event, payload, (resp) => { + clearTimeout(t); + resolve(resp); + }); + }); +} + +const killer = setTimeout(() => { + console.log("FAIL: Global timeout reached"); + process.exit(1); +}, TIMEOUT_MS); +killer.unref(); + +(async () => { + let a, b; + try { + a = io(URL, { transports: ["websocket"], forceNew: true }); + b = io(URL, { transports: ["websocket"], forceNew: true }); + await Promise.all([ + new Promise((r) => a.on("connect", r)), + new Promise((r) => b.on("connect", r)), + ]); + pass("clients A and B connected"); + + // 1. A creates a skribbl room + const createResp = await emitAck(a, "room:create", { + nickname: "Alice", avatar: "🐱", mode: "skribbl", + settings: { rounds: 2, drawTimeSec: 60, language: "en" }, + }); + if (!createResp || !createResp.ok || !createResp.code) throw new Error("create failed: " + JSON.stringify(createResp)); + const code = createResp.code; + pass("A: room:create -> ok, code=" + code); + + // capture state from B's join + const aStateP = waitFor(a, "room:state", (s) => s.players.length >= 2, 5000); + const bStateP = waitFor(b, "room:state", (s) => s.players.length >= 2, 5000); + + // 2. B joins + const joinResp = await emitAck(b, "room:join", { code, nickname: "Bob", avatar: "🐶" }); + if (!joinResp || !joinResp.ok) throw new Error("join failed: " + JSON.stringify(joinResp)); + pass("B: room:join -> ok"); + + // 3. Both clients receive room:state with both players + const aState = await aStateP; + const bState = await bStateP; + const aHasBoth = aState.players.find(p => p.nickname === "Alice") && aState.players.find(p => p.nickname === "Bob"); + const bHasBoth = bState.players.find(p => p.nickname === "Alice") && bState.players.find(p => p.nickname === "Bob"); + if (!aHasBoth || !bHasBoth) throw new Error("room:state missing players"); + pass("Both clients receive room:state with both players"); + + // 4. Chat send/receive + const aChatP = waitFor(a, "chat:msg", (m) => m.text === "hello" && m.fromName === "Alice", 5000); + const bChatP = waitFor(b, "chat:msg", (m) => m.text === "hello" && m.fromName === "Alice", 5000); + a.emit("chat:send", { text: "hello" }); + await Promise.all([aChatP, bChatP]); + pass("chat:send broadcasts to both clients"); + + // 5. Start the game + // Drawer (A is host & first in order) gets skribbl:wordChoices + const choicesP = waitFor(a, "skribbl:wordChoices", () => true, 5000); + const startResp = await emitAck(a, "game:start", {}); + if (!startResp || !startResp.ok) throw new Error("start failed: " + JSON.stringify(startResp)); + pass("game:start ack ok"); + const choices = await choicesP; + if (!Array.isArray(choices) || choices.length < 1) throw new Error("invalid choices"); + pass("drawer received skribbl:wordChoices: " + JSON.stringify(choices)); + + // 6. Drawer picks a word + const pickedWord = choices[0]; + // Listen for roundStart on B + const bRoundStartP = waitFor(b, "skribbl:roundStart", () => true, 5000); + const aRoundStartP = waitFor(a, "skribbl:roundStart", () => true, 5000); + const pickResp = await emitAck(a, "skribbl:pickWord", { word: pickedWord }); + if (!pickResp || !pickResp.ok) throw new Error("pickWord failed: " + JSON.stringify(pickResp)); + pass("skribbl:pickWord ack ok, word=" + pickedWord); + const aRound = await aRoundStartP; + const bRound = await bRoundStartP; + if (!aRound || !bRound) throw new Error("roundStart missing"); + pass("both clients received skribbl:roundStart"); + + // 7. Drawer emits strokes — other client receives them + const strokeP = waitFor(b, "skribbl:stroke", (s) => s && s.color === "#000", 3000); + a.emit("skribbl:stroke", { x: 10, y: 10, prevX: 5, prevY: 5, color: "#000", size: 4 }); + a.emit("skribbl:stroke", { x: 20, y: 20, prevX: 10, prevY: 10, color: "#000", size: 4 }); + const strokeReceived = await strokeP; + if (!strokeReceived) throw new Error("no stroke received on B"); + pass("B received skribbl:stroke from A"); + + // 8. B guesses correctly + const aCorrectP = waitFor(a, "chat:msg", (m) => m.kind === "correct", 5000); + const bCorrectP = waitFor(b, "chat:msg", (m) => m.kind === "correct", 5000); + b.emit("chat:send", { text: pickedWord }); + await Promise.all([aCorrectP, bCorrectP]); + pass("correct guess produces 'correct' chat message on both clients"); + + // verify state shows score updated + const stateAfterP = waitFor(a, "room:state", (s) => { + const bob = s.players.find(p => p.nickname === "Bob"); + return bob && bob.score > 0; + }, 5000); + const stateAfter = await stateAfterP; + pass("room:state shows Bob score > 0 after correct guess: " + stateAfter.players.find(p=>p.nickname==="Bob").score); + + // 9. Wait for round end + const roundEndP = waitFor(b, "skribbl:roundEnd", () => true, 8000); + const roundEnd = await roundEndP; + if (!roundEnd || !roundEnd.word) throw new Error("roundEnd missing word"); + pass("skribbl:roundEnd fired, word=" + roundEnd.word); + + } catch (e) { + fail("flow", e && e.message ? e.message : String(e)); + } finally { + try { a && a.disconnect(); } catch (_) {} + try { b && b.disconnect(); } catch (_) {} + clearTimeout(killer); + // Print summary as JSON for parser + console.log("__RESULTS__ " + JSON.stringify(results)); + setTimeout(() => process.exit(exitCode), 200); + } +})(); diff --git a/test/unit.test.js b/test/unit.test.js new file mode 100644 index 0000000..5848ff8 --- /dev/null +++ b/test/unit.test.js @@ -0,0 +1,150 @@ +// Unit tests for server-side game logic. Uses node:test (no extra deps). +// Run with: node test/unit.test.js (NOT `node --test`) — see comment at bottom. +const test = require("node:test"); +const assert = require("node:assert"); + +const { + normalize, + maskWord, + pickRevealIndex, + isCloseGuess, +} = require("../server/word-utils.js"); +const roomStore = require("../server/room-store.js"); +const { genCode, getRoom, setRoom, deleteRoom, allRooms, makeId } = roomStore; +const { makeRoom, makePlayer } = require("../server/game-state.js"); + +// Unref the room-store cleanup interval so node exits cleanly after tests. +function unrefAllHandles() { + const handles = process._getActiveHandles ? process._getActiveHandles() : []; + for (const h of handles) { + if (h && typeof h.unref === "function") { + try { h.unref(); } catch (_) {} + } + } +} +unrefAllHandles(); + +// --- word-utils tests --- + +test("normalize: lowercases and trims", () => { + assert.strictEqual(normalize(" HELLO "), "hello"); + assert.strictEqual(normalize("Foo Bar"), "foo bar"); + assert.strictEqual(normalize("MULTI SPACE"), "multi space"); + assert.strictEqual(normalize(""), ""); + assert.strictEqual(normalize(undefined), ""); + assert.strictEqual(normalize(null), ""); +}); + +test("maskWord: blanks letters but preserves spaces", () => { + const out = maskWord("ice cream"); + // letters become "_", space stays + assert.strictEqual(out, "___ _____"); +}); + +test("maskWord: with all indices revealed produces lowercased word", () => { + const word = "Apple"; + const revealed = new Set([0, 1, 2, 3, 4]); + const out = maskWord(word, revealed); + assert.strictEqual(out, "apple"); +}); + +test("maskWord: partial reveal mixes letters and underscores", () => { + const out = maskWord("apple", new Set([0, 2])); + // a _ p _ _ + assert.strictEqual(out, "a_p__"); +}); + +test("pickRevealIndex: picks unrevealed non-space index", () => { + const word = "ab cd"; + const revealed = new Set([0]); + const idx = pickRevealIndex(word, revealed); + // valid candidate indices: 1, 3, 4 + assert.ok([1, 3, 4].includes(idx), `unexpected index ${idx}`); + assert.notStrictEqual(idx, 2); // never the space +}); + +test("pickRevealIndex: returns null when all letters revealed", () => { + const word = "ab"; + const revealed = new Set([0, 1]); + assert.strictEqual(pickRevealIndex(word, revealed), null); +}); + +test("revealHint flow: applying picked index reveals exactly N more letters", () => { + // simulate revealing: count underscores decreases by exactly 1 each step + let revealed = new Set(); + const word = "rocket"; + let prevMask = maskWord(word, revealed); + let prevUnderscores = (prevMask.match(/_/g) || []).length; + for (let i = 0; i < 3; i++) { + const idx = pickRevealIndex(word, revealed); + assert.notStrictEqual(idx, null); + revealed.add(idx); + const m = maskWord(word, revealed); + const newUnderscores = (m.match(/_/g) || []).length; + assert.strictEqual(newUnderscores, prevUnderscores - 1, "exactly 1 letter revealed"); + prevUnderscores = newUnderscores; + prevMask = m; + } +}); + +test("revealHint idempotence: re-applying same revealed set yields same mask", () => { + const word = "tree"; + const revealed = new Set([0, 2]); + const m1 = maskWord(word, revealed); + const m2 = maskWord(word, revealed); + assert.strictEqual(m1, m2); +}); + +test("isCloseGuess: edit distance 1-2 returns true", () => { + assert.strictEqual(isCloseGuess("aple", "apple"), true); // 1 edit + assert.strictEqual(isCloseGuess("aplee", "apple"), true); // 1 edit + assert.strictEqual(isCloseGuess("apple", "apple"), false); // exact match -> not "close" + assert.strictEqual(isCloseGuess("xyz", "apple"), false); // too far +}); + +// --- room-store tests --- + +test("genCode: returns a unique 6-char alphanumeric code", () => { + const seen = new Set(); + for (let i = 0; i < 10; i++) { + const c = genCode(); + assert.strictEqual(typeof c, "string"); + assert.strictEqual(c.length, 6); + assert.ok(/^[A-Z0-9]+$/.test(c), `code ${c} should be uppercase alnum`); + seen.add(c); + } + assert.strictEqual(seen.size, 10, "codes should be unique"); +}); + +test("setRoom + getRoom: returns same instance, case-insensitive lookup", () => { + const code = genCode(); + const player = makePlayer({ nickname: "U", avatar: "x" }); + const room = makeRoom({ code, hostId: player.id, mode: "skribbl", settings: {} }); + room.players.push(player); + setRoom(code, room); + const got = getRoom(code); + assert.strictEqual(got, room, "same instance"); + // lower-case lookup also works + const gotLower = getRoom(code.toLowerCase()); + assert.strictEqual(gotLower, room); + deleteRoom(code); + assert.strictEqual(getRoom(code), undefined); +}); + +test("makeId: returns a non-empty string id", () => { + const id1 = makeId(); + const id2 = makeId(); + assert.strictEqual(typeof id1, "string"); + assert.ok(id1.length > 0); + assert.notStrictEqual(id1, id2); +}); + +test("allRooms: exposes the underlying Map", () => { + const map = allRooms(); + assert.ok(map instanceof Map); +}); + +// Force exit after a short tick — room-store uses an unrefable setInterval +// that we already unrefed above, but be defensive. +process.on("beforeExit", () => process.exit(0)); +setTimeout(() => process.exit(0), 1500).unref();