fix: skribbl pre-pick label + gartic min 3 players + tests
@@ -196,10 +196,14 @@ export default function SkribblGame() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 text-center">
|
<div className="flex-1 text-center">
|
||||||
<div className="text-xs font-bold uppercase tracking-wider" style={{color:"rgba(45,45,45,0.55)"}}>
|
<div className="text-xs font-bold uppercase tracking-wider" style={{color:"rgba(45,45,45,0.55)"}}>
|
||||||
{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`)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xl font-bold tracking-[6px] mt-1" data-testid="word-mask">
|
<div className="text-xl font-bold tracking-[6px] mt-1" data-testid="word-mask">
|
||||||
{isDrawer && sk?.phase === "drawing" ? (sk as any)?.word || wordMask : wordMask || "_____"}
|
{sk?.phase === "drawing"
|
||||||
|
? (isDrawer ? ((sk as any)?.word || wordMask) : (wordMask || "_____"))
|
||||||
|
: ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pill" style={{background:"var(--lavender)", color:"white"}}>
|
<div className="pill" style={{background:"var(--lavender)", color:"white"}}>
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export default function LobbyPage() {
|
|||||||
|
|
||||||
const isHost = room && myId && room.hostId === myId;
|
const isHost = room && myId && room.hostId === myId;
|
||||||
const shareUrl = typeof window !== "undefined" ? `${window.location.origin}/join?code=${code}` : "";
|
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 = () => {
|
const start = () => {
|
||||||
setErr("");
|
setErr("");
|
||||||
@@ -105,8 +107,13 @@ export default function LobbyPage() {
|
|||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{garticNeedsMore && (
|
||||||
|
<div data-testid="gartic-min-players" className="mt-4 p-3 rounded-2xl border-[3px] border-dashed border-dark text-center text-sm font-semibold" style={{background:"var(--cream)", color:"rgba(45,45,45,0.75)"}}>
|
||||||
|
Gartic Phone is way more fun with friends! Grab at least <strong>3 players</strong> to start. ({playerCount}/3)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isHost ? (
|
{isHost ? (
|
||||||
<button onClick={start} data-testid="start-game" className="btn btn-primary w-full mt-4" style={{padding:"16px",fontSize:18}}>
|
<button onClick={start} disabled={!!garticNeedsMore} data-testid="start-game" className="btn btn-primary w-full mt-4" style={{padding:"16px",fontSize:18, opacity: garticNeedsMore ? 0.5 : 1, cursor: garticNeedsMore ? "not-allowed" : undefined}}>
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round"><polygon points="6 4 20 12 6 20 6 4"/></svg>
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round"><polygon points="6 4 20 12 6 20 6 4"/></svg>
|
||||||
Start Game
|
Start Game
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -344,6 +344,9 @@ function registerHandlers(io) {
|
|||||||
if (room.players.filter((p) => p.connected).length < 2 && room.mode !== "color") {
|
if (room.players.filter((p) => p.connected).length < 2 && room.mode !== "color") {
|
||||||
return ack && ack({ ok: false, error: "need 2+ players" });
|
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";
|
room.phase = "playing";
|
||||||
if (room.mode === "skribbl") {
|
if (room.mode === "skribbl") {
|
||||||
G.skribblInit(room);
|
G.skribblInit(room);
|
||||||
|
|||||||
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 50 KiB |
@@ -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."
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||