import fs from "node:fs"; import { webcrypto } from "node:crypto"; import { JSDOM, VirtualConsole } from "jsdom"; const html = fs.readFileSync(new URL("../index.html", import.meta.url), "utf8") .replace('', ""); const bundle = fs.readFileSync(new URL("../app.js", import.meta.url), "utf8"); const failures = []; const virtualConsole = new VirtualConsole(); virtualConsole.on("jsdomError", (error) => failures.push(String(error))); const dom = new JSDOM(html, { url: "http://127.0.0.1:8899/play/", runScripts: "outside-only", pretendToBeVisual: true, virtualConsole, }); const { window } = dom; Object.defineProperty(window, "crypto", { value: webcrypto }); window.devicePixelRatio = 1; window.confirm = () => true; class MockWebSocket { static OPEN = 1; static instances = []; readyState = MockWebSocket.OPEN; sent = []; listeners = new Map(); constructor(url) { this.url = url; MockWebSocket.instances.push(this); queueMicrotask(() => this.emit("open", {})); } addEventListener(type, listener) { const list = this.listeners.get(type) ?? []; list.push(listener); this.listeners.set(type, list); } send(raw) { this.sent.push(JSON.parse(raw)); } close() { this.emit("close", {}); } emit(type, event) { for (const listener of this.listeners.get(type) ?? []) listener(event); } message(message) { this.emit("message", { data: JSON.stringify(message) }); } } window.WebSocket = MockWebSocket; window.ResizeObserver = class { observe() {} disconnect() {} }; window.requestAnimationFrame = (callback) => window.setTimeout(() => callback(Date.now()), 0); window.cancelAnimationFrame = (handle) => window.clearTimeout(handle); const context = new Proxy( {}, { get(target, key) { if (key === "measureText") return () => ({ width: 10 }); if (key in target) return target[key]; return () => undefined; }, set(target, key, value) { target[key] = value; return true; }, }, ); window.HTMLCanvasElement.prototype.getContext = function getContext() { return context; }; window.eval(bundle); const document = window.document; const byId = (id) => document.getElementById(id); const assert = (condition, message) => { if (!condition) failures.push(message); }; const socket = MockWebSocket.instances[0]; assert(socket, "the app opened a WebSocket"); assert(socket.url === "ws://127.0.0.1:8899/ws", `unexpected socket url ${socket.url}`); const hello = { type: "hello", version: "5.0.0", grace_seconds: 60, time: Date.now(), }; const lobby = { type: "view", view: { screen: "lobby", connected: true, user: null, account: null, balance: null, games: [ ["blackjack", "Blackjack"], ["crash", "Crash"], ["roulette", "Roulette"], ["slots", "Slots"], ], login_error: null, }, }; socket.message(hello); socket.message(lobby); assert(byId("auth-screen").hidden === false, "auth is shown before login"); assert(byId("session-bar").hidden === true, "no session bar before login"); byId("auth-user").value = "alice"; byId("auth-pass").value = "secret"; byId("auth-form").dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true })); await new Promise((resolve) => setTimeout(resolve, 10)); const login = socket.sent.find((message) => message.command?.Login); assert(login, "the login form sends a Login command"); assert(login.command.Login.user === "alice", "the username is sent"); assert( /^[0-9a-f]{64}$/.test(login.command.Login.credential), "the password is sent as a SHA-256 hex digest", ); socket.message({ type: "view", view: { screen: "lobby", connected: true, user: "alice", account: "1", balance: null, games: lobby.view.games, login_error: null, }, }); assert( socket.sent.some((message) => message.command === "Info"), "the app asks for the balance after login", ); socket.message({ type: "view", view: { screen: "lobby", connected: true, user: "alice", account: "1", balance: 1000, games: lobby.view.games, login_error: null, }, }); assert(byId("auth-screen").hidden === true, "auth is hidden after login"); assert(byId("lobby-screen").hidden === false, "the lobby is shown after login"); assert(byId("account-name").textContent === "alice", "the account name is shown"); assert(byId("account-balance").textContent.includes("1"), "the balance is shown"); assert(document.querySelectorAll(".game-card").length === 4, "all four game cards render"); socket.message({ type: "view", view: { screen: "slots", bet: 10, balance: 1000, reels: "CLOGDSCLOGDSLLO".split(""), win_cells: [0, 1, 2], win_lines: [1], win_type: "win", winnings: 20, phase: "result", error: null, }, }); assert(byId("game-screen").hidden === false, "the game screen is shown"); assert(document.querySelectorAll(".reel").length === 5, "five reels render"); assert(document.querySelectorAll(".tile").length === 15, "fifteen tiles render"); assert(byId("game-side").textContent.includes("SPIN"), "the slots sidebar has a spin action"); socket.message({ type: "view", view: { screen: "roulette", bet_amount: 10, balance: 1000, chip: 25, bets: { chips: [{ target: { bet: "straight", number: 5 }, amount: 25 }] }, cursor: { Lattice: { x: 1, y: 5 } }, phase: "result", number: 5, color: "red", winnings: 900, error: null, }, }); assert(document.querySelector(".roulette-canvas"), "the roulette canvas renders"); assert(document.querySelectorAll(".chip-btn").length === 4, "the chip selector renders"); assert(document.querySelector(".board-foot")?.textContent.includes("5"), "the result number renders"); socket.message({ type: "view", view: { screen: "blackjack", status: "player", balance: 990, bet: 10, hand: { session_id: "bj1", phase: "player", player: ["10S", "6H"], dealer: ["9D", "?"], player_total: 16, dealer_total: 9, bet: 10, base_bet: 10, balance: 990, actions: ["hit", "stand", "double"], result: "playing", winnings: 0, }, error: null, }, }); assert(document.querySelectorAll(".card").length === 4, "both blackjack hands render"); assert(byId("game-side").textContent.includes("HIT"), "blackjack actions render"); assert(byId("game-side").textContent.includes("FORFEIT") === false, "forfeit is a labelled button, not uppercase"); assert(byId("game-side").textContent.includes("Forfeit"), "blackjack forfeit renders"); socket.message({ type: "view", view: { screen: "crash", status: "running", balance: 990, bet: 10, multiplier: 1.2, potential: 12, crash_point: null, payout: 0, started_at: Date.now(), commit: "ab".repeat(32), seed: null, error: null, }, }); assert(document.querySelector(".crash-canvas"), "the crash canvas renders"); assert(document.querySelector(".crash-number")?.textContent.includes("×"), "the crash multiplier renders"); assert(byId("game-side").textContent.includes("CASH OUT"), "crash exposes cash out while running"); assert(document.querySelector(".crash-meta")?.textContent.includes("commit"), "crash shows the round commitment"); socket.message({ type: "view", view: { screen: "history", records: [ { game: "slots", bet: 10, winnings: 20, balance_after: 1010, at: "2026-11-08 03:00:00", details: { win_type: "win" }, }, ], }, }); assert(byId("history-screen").hidden === false, "the history screen renders"); assert(document.querySelector("table tbody tr"), "history rows render"); socket.message({ type: "replaced" }); assert(byId("replaced-overlay").hidden === false, "a takeover stops the old tab"); await new Promise((resolve) => setTimeout(resolve, 30)); dom.window.close(); if (failures.length > 0) { console.error("web smoke test failed:"); for (const failure of failures) console.error(` - ${failure}`); process.exit(1); } console.log("web smoke test passed");