const puppeteer = require("puppeteer-core"); const fs = require("fs"); const path = require('path'); (async () => { const t0 = Date.now(); const ts = () => new Date().toISOString() + " +" + (Date.now() - t0) + "ms"; const log = (...args) => console.log(ts(), ...args); log("Puppeteer test starting"); // Create a per-run output directory under /workspace named YYMMDD-HHMMSS. const WORKSPACE_DIR = '/workspace'; const makeRunDirName = (d) => { const yy = String(d.getFullYear()).slice(-2); const mm = String(d.getMonth() + 1).padStart(2, '0'); const dd = String(d.getDate()).padStart(2, '0'); const hh = String(d.getHours()).padStart(2, '0'); const min = String(d.getMinutes()).padStart(2, '0'); const ss = String(d.getSeconds()).padStart(2, '0'); return `${yy}${mm}${dd}-${hh}${min}${ss}`; }; // Allow the caller to provide a run directory name so external wrappers // (e.g., run_with_server_logs.sh) can place related artifacts together. const runDirName = process.env.TEST_RUN_DIR_NAME || makeRunDirName(new Date()); // Place per-run outputs under /workspace/test-output/ so the // host-side wrapper (which creates ./test-output/) and the // container-mounted workspace line up exactly. const OUT_DIR = path.join(WORKSPACE_DIR, 'test-output', runDirName); try { fs.mkdirSync(OUT_DIR, { recursive: true }); log('Using output directory', OUT_DIR); } catch (e) { log('Failed to create output directory', OUT_DIR, e && e.message ? e.message : e); } let browser; // Global timeout (ms) for the whole test run to avoid infinite log floods. const MAX_TEST_MS = parseInt(process.env.TEST_MAX_MS || "60000", 10); let _globalTimeout = null; let _timedOut = false; const startGlobalTimeout = () => { if (_globalTimeout) return; _globalTimeout = setTimeout(async () => { _timedOut = true; try { log(`Global timeout of ${MAX_TEST_MS}ms reached — aborting test`); if (browser) { try { await browser.close(); log('Browser closed due to global timeout'); } catch (e) { log('Error while closing browser on timeout:', e && e.message ? e.message : e); } } } catch (e) { // swallow } // Exit with distinct code so CI can tell a timeout occurred. process.exit(124); }, MAX_TEST_MS); }; const clearGlobalTimeout = () => { try { if (_globalTimeout) clearTimeout(_globalTimeout); } catch (e) {} _globalTimeout = null; }; // Start the global timeout immediately so the whole test run (including // browser launch) is bounded by TEST_MAX_MS. This makes the timeout work // even when env vars are not propagated the way the caller expects. startGlobalTimeout(); try { // Try to use a system Chrome/Chromium binary if present. This lets // developers skip downloading Chromium during `npm install` by setting // PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 and supplying CHROME_PATH. const possiblePaths = []; if (process.env.CHROME_PATH) possiblePaths.push(process.env.CHROME_PATH); possiblePaths.push( "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/usr/bin/chromium-browser", "/usr/bin/chromium" ); let launchOptions = { args: ["--no-sandbox", "--disable-setuid-sandbox"], ignoreHTTPSErrors: true, }; for (const p of possiblePaths) { try { const stat = require("fs").statSync(p); if (stat && stat.isFile()) { log("Found system Chrome at", p, "— will use it"); launchOptions.executablePath = p; break; } } catch (e) { // ignore } } log( "Launching Puppeteer with options:", Object.assign({}, launchOptions, { executablePath: launchOptions.executablePath ? launchOptions.executablePath : "", }) ); const tLaunch = Date.now(); browser = await puppeteer.launch(launchOptions); log("Browser launched (took", Date.now() - tLaunch + "ms)"); // Start the global timeout after browser is launched so we can reliably // close the browser if we need to abort. startGlobalTimeout(); const page = await browser.newPage(); log("New page created"); // Intercept WebSocket.prototype.send as early as possible so we can // capture outgoing messages from the client (helps verify the name // submit handler triggered). Use evaluateOnNewDocument so interception // is in place before any page script runs. try { await page.evaluateOnNewDocument(() => { try { const orig = WebSocket.prototype.send; WebSocket.prototype.send = function (data) { try { window.__wsSends = window.__wsSends || []; let d = data; // Handle ArrayBuffer or typed arrays by attempting to decode if (d instanceof ArrayBuffer || ArrayBuffer.isView(d)) { try { d = new TextDecoder().decode(d); } catch (e) { d = '' + d; } } const entry = { ts: Date.now(), data: typeof d === 'string' ? d : (d && d.toString ? d.toString() : JSON.stringify(d)) }; window.__wsSends.push(entry); // Emit a console log so the host log captures the message body try { console.log('WS_SEND_INTERCEPT', entry.data); } catch (e) {} } catch (e) { // swallow } return orig.apply(this, arguments); }; } catch (e) { // swallow } }); log('Installed WebSocket send interceptor (evaluateOnNewDocument)'); } catch (e) { log('Could not install WS interceptor:', e && e.message ? e.message : e); } // forward page console messages to our logs (helpful to see runtime errors) page.on('console', msg => { try { const args = msg.args ? msg.args.map(a => a.toString()).join(' ') : msg.text(); log('PAGE_CONSOLE', msg.type(), args); } catch (e) { log('PAGE_CONSOLE', msg.type(), msg.text()); } }); page.on('requestfailed', req => { try { log('REQUEST_FAILED', req.url(), req.failure && req.failure().errorText); } catch (e) { log('REQUEST_FAILED', req.url()); } }); // track simple network counts so we can see if navigation triggers many requests let reqCount = 0, resCount = 0; page.on("request", (r) => { reqCount++; }); page.on("response", (r) => { resCount++; }); // small helper to replace the deprecated page.waitForTimeout const sleep = (ms) => new Promise((res) => setTimeout(res, ms)); // Navigate to the dev client; allow overriding via CLI or env const url = process.argv[2] || process.env.TEST_URL || "https://localhost:3001/ketr.ketran/"; log("Navigating to", url); try { log("Calling page.goto (domcontentloaded, 10s timeout)"); const tNavStart = Date.now(); await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 }); log( "Page load (domcontentloaded) finished (took", Date.now() - tNavStart + "ms)", "requests=", reqCount, "responses=", resCount ); // Diagnostic snapshot: save the full HTML and a screenshot immediately // after domcontentloaded so we can inspect the initial page state even // when the inputs appear much later. try { const domContent = await page.content(); try { const outPath = path.join(OUT_DIR, 'domcontent.html'); fs.writeFileSync(outPath, domContent); log('Saved domcontent HTML to', outPath, '(length', domContent.length + ')'); } catch (e) { log('Failed to write domcontent HTML:', e && e.message ? e.message : e); } } catch (e) { log('Could not read page content at domcontentloaded:', e && e.message ? e.message : e); } try { const shotNow = path.join(OUT_DIR, 'domcontent.png'); await page.screenshot({ path: shotNow, fullPage: true }); log('Saved domcontent screenshot to', shotNow); } catch (e) { log('Failed to save domcontent screenshot:', e && e.message ? e.message : e); } try { const immediateInputs = await page.evaluate(() => document.querySelectorAll('input').length); log('Immediate input count at domcontentloaded =', immediateInputs); } catch (e) { log('Could not evaluate immediate input count:', e && e.message ? e.message : e); } // Install a MutationObserver inside the page to record the first time any // input element appears. We store the epoch ms in window.__inputsFirstSeen // and also emit a console log so the test logs capture the exact instant. try { await page.evaluate(() => { try { window.__inputsFirstSeen = null; const check = () => { try { const count = document.querySelectorAll('input').length; if (count && window.__inputsFirstSeen === null) { window.__inputsFirstSeen = Date.now(); // eslint-disable-next-line no-console console.log('INPUTS_APPEARED', window.__inputsFirstSeen, 'count=', count); if (observer) observer.disconnect(); } } catch (e) { // swallow } }; const observer = new MutationObserver(check); observer.observe(document.body || document.documentElement, { childList: true, subtree: true, attributes: true, }); // Run an initial check in case inputs are already present check(); } catch (e) { // ignore page-side errors } }); log('MutationObserver probe installed to detect first input appearance'); } catch (e) { log('Could not install MutationObserver probe:', e && e.message ? e.message : e); } } catch (e) { console.error("Failed to load page:", e && e.message ? e.message : e); // Print a short sniff of the response HTML if available try { const content = await page.content(); console.log("Page content snippet:\n", content.slice(0, 1000)); } catch (err) { console.warn( "Could not read page content:", err && err.message ? err.message : err ); } throw e; } // Fast-path: wait for a visible input quickly. If that fails, fall back to polling. let inputsFound = 0; try { log('Fast-wait for visible input (5s)'); await page.waitForSelector('input', { visible: true, timeout: 5000 }); inputsFound = await page.evaluate(() => document.querySelectorAll('input').length); log('Fast-wait success, inputsFound=', inputsFound); } catch (e) { log('Fast-wait failed (no visible input within 5s), falling back to polling'); // poll for inputs so we know exactly when they appear rather than sleeping blindly const pollStart = Date.now(); for (let i = 0; i < 80; i++) { // 80 * 250ms = 20s fallback try { inputsFound = await page.evaluate(() => document.querySelectorAll('input').length); } catch (err) { log('evaluate error while polling inputs:', err && err.message ? err.message : err); } // Abort the polling if the global timeout has already fired or is about to. if (_timedOut) { log('Aborting input polling because global timeout flag is set'); break; } if (Date.now() - t0 > MAX_TEST_MS - 2000) { log('Approaching global timeout while polling inputs — aborting polling loop'); break; } if (inputsFound && inputsFound > 0) { log('Inputs appeared after', (Date.now() - pollStart) + 'ms; count=', inputsFound); break; } if (i % 20 === 0) log('still polling for inputs...', i, 'iterations, elapsed', (Date.now() - pollStart) + 'ms'); await sleep(250); } if (!inputsFound) log('No inputs found after fallback polling (~20s)'); } // If the app shows an "Enter your name" prompt, robustly try to fill it try { log('Attempting to detect name input by placeholder/aria/label/id or first visible input'); const candidates = [ "input[placeholder*='Enter your name']", "input[placeholder*='enter your name']", "input[aria-label*='name']", "input[name='name']", "input[id*='name']", ".nameInput input", // label-based XPath (will be handled specially below) "xpath://label[contains(normalize-space(.), 'Enter your name')]/following::input[1]", "xpath://*[contains(normalize-space(.), 'Enter your name')]/following::input[1]", "input" ]; let filled = false; for (const sel of candidates) { try { let el = null; if (sel.startsWith('xpath:')) { const path = sel.substring(6); const nodes = await page.$x(path); if (nodes && nodes.length) el = nodes[0]; } else { el = await page.$(sel); } if (el) { await el.focus(); const tTypeStart = Date.now(); await page.keyboard.type('Automaton', { delay: 50 }); log('Typed name via selector', sel, '(took', Date.now() - tTypeStart + 'ms)'); // Press Enter to submit if the UI responds to it await page.keyboard.press('Enter'); filled = true; await sleep(500); break; } } catch (inner) { // ignore selector-specific failures } } if (!filled) { // fallback: find the first visible input and type there const inputs = await page.$$('input'); for (const input of inputs) { try { const box = await input.boundingBox(); if (box) { await input.focus(); const tTypeStart = Date.now(); await page.keyboard.type('Automaton', { delay: 50 }); await page.keyboard.press('Enter'); log('Typed name into first visible input (took', Date.now() - tTypeStart + 'ms)'); filled = true; await sleep(500); break; } } catch (inner) {} } } // Try clicking a button to confirm/join. Match common labels case-insensitively. const clickTexts = ['set','join','ok','enter','start','confirm']; let clicked = false; for (const txt of clickTexts) { try { const xpath = `//button[contains(translate(normalize-space(.),'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'${txt}')]`; const btns = await page.$x(xpath); if (btns && btns.length) { await btns[0].click(); log('Clicked button matching text', txt); clicked = true; await sleep(500); break; } } catch (inner) {} } if (!clicked) { // fallback: click submit-type inputs/buttons const submit = await page.$("input[type=submit], button[type=submit]"); if (submit) { await submit.click(); log('Clicked submit control'); clicked = true; await sleep(500); } } if (!filled && !clicked) log('No name input or submit button found to set name'); } catch (e) { log('Could not auto-enter name:', e && e.message ? e.message : e); } // Read the MutationObserver probe timestamp (if it recorded when inputs first appeared) try { const firstSeen = await page.evaluate(() => window.__inputsFirstSeen || null); if (firstSeen) { const delta = Date.now() - firstSeen; log('Probe: inputs first seen at', new Date(firstSeen).toISOString(), `(ago ${delta}ms)`); // Save a screenshot named with the first-seen timestamp to make it easy to correlate const shotPath = path.join(OUT_DIR, 'house-rules.png'); try { await page.screenshot({ path: shotPath, fullPage: true }); log('Saved input-appearance screenshot to', shotPath); } catch (e) { log('Failed to save input-appearance screenshot:', e && e.message ? e.message : e); } // Also save the full HTML at the time inputs first appeared so we can // inspect the DOM (some UI frameworks render inputs late). try { const html = await page.content(); const outPath = path.join(OUT_DIR, 'domcontent-after-inputs.html'); try { fs.writeFileSync(outPath, html); log('Saved DOM HTML at input-appearance to', outPath, '(length', html.length + ')'); } catch (e) { log('Failed to write post-input DOM HTML:', e && e.message ? e.message : e); } // Also capture any intercepted WebSocket sends recorded by the // in-page interceptor and dump them to a JSON file for later // verification. try { const sends = await page.evaluate(() => window.__wsSends || []); const wsOut = path.join(OUT_DIR, 'ws-sends.json'); try { fs.writeFileSync(wsOut, JSON.stringify(sends, null, 2)); log('Saved intercepted WS sends to', wsOut, '(count', (sends && sends.length) + ')'); // QUICK ASSERTION: ensure a player-name send with Automaton was captured try { const parsed = (sends || []).map(s => { try { return JSON.parse(s.data); } catch (e) { return null; } }).filter(Boolean); const hasPlayerName = parsed.some(p => p.type === 'player-name' && ((p.data && p.data.name) === 'Automaton' || (p.name === 'Automaton'))); if (!hasPlayerName) { log('ASSERTION FAILED: No player-name send with name=Automaton found in intercepted WS sends'); // Write a failure marker file for CI to pick up try { fs.writeFileSync(path.join(OUT_DIR, 'assert-failed-player-name.txt'), 'Missing player-name Automaton'); } catch (e) {} process.exit(3); } else { log('Assertion passed: player-name Automaton was sent by client'); } } catch (e) { log('Error while asserting WS sends:', e && e.message ? e.message : e); } } catch (e) { log('Failed to write intercepted WS sends file:', e && e.message ? e.message : e); } } catch (e) { log('Could not read intercepted WS sends from page:', e && e.message ? e.message : e); } } catch (e) { log('Could not read page content at input-appearance:', e && e.message ? e.message : e); } } else { log('Probe did not record input first-seen timestamp (window.__inputsFirstSeen is null)'); } } catch (e) { log('Could not read window.__inputsFirstSeen:', e && e.message ? e.message : e); } // Ensure we still capture intercepted WS sends even if the probe didn't // record the input-appearance timestamp. Dump a latest copy and run the // same assertion so CI will fail fast when player-name is missing. try { const sends = await page.evaluate(() => window.__wsSends || []); const outLatest = path.join(OUT_DIR, 'ws-sends-latest.json'); try { fs.writeFileSync(outLatest, JSON.stringify(sends, null, 2)); log('Saved intercepted WS sends (latest) to', outLatest, '(count', (sends && sends.length) + ')'); } catch (e) { log('Failed to write latest intercepted WS sends file:', e && e.message ? e.message : e); } // Produce a small summary JSON for quick CI checks: total count, time range, // counts per message type. try { const parsed = (sends || []).map(s => { try { return JSON.parse(s.data); } catch (e) { return null; } }).filter(Boolean); const typesCount = {}; let minTs = null, maxTs = null; (sends || []).forEach(s => { if (s && s.ts) { if (minTs === null || s.ts < minTs) minTs = s.ts; if (maxTs === null || s.ts > maxTs) maxTs = s.ts; } }); parsed.forEach(p => { const t = p.type || 'unknown'; typesCount[t] = (typesCount[t] || 0) + 1; }); const summary = { runDir: runDirName, totalMessages: (sends || []).length, types: typesCount, tsRange: minTs !== null ? { min: minTs, max: maxTs } : null, generatedAt: Date.now() }; const summaryPath = path.join(OUT_DIR, 'summary.json'); try { fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2)); log('Wrote summary JSON to', summaryPath); } catch (e) { log('Failed to write summary JSON:', e && e.message ? e.message : e); } } catch (e) { log('Could not generate summary.json:', e && e.message ? e.message : e); } // Run the same assertion against the latest sends if a per-timestamp // file was not written earlier. try { const parsed = (sends || []).map(s => { try { return JSON.parse(s.data); } catch (e) { return null; } }).filter(Boolean); const hasPlayerName = parsed.some(p => p.type === 'player-name' && ((p.data && p.data.name) === 'Automaton' || (p.name === 'Automaton'))); if (!hasPlayerName) { log('ASSERTION FAILED: No player-name send with name=Automaton found in intercepted WS sends (latest)'); try { fs.writeFileSync(path.join(OUT_DIR, 'assert-failed-player-name.txt'), 'Missing player-name Automaton'); } catch (e) {} process.exit(3); } else { log('Assertion passed (latest): player-name Automaton was sent by client'); } } catch (e) { log('Error while asserting WS sends (latest):', e && e.message ? e.message : e); } } catch (e) { log('Could not read intercepted WS sends for latest dump:', e && e.message ? e.message : e); } // If the global timeout fired while we were running, abort now. if (_timedOut) { log('Test aborted due to global timeout flag set after probe check'); if (browser) try { await browser.close(); } catch (e) {} process.exit(124); } // Debug: list buttons with their text const tButtonScan = Date.now(); const buttons = await page .$$eval("button", (els) => els.map((b) => ({ text: b.innerText, id: b.id || null })) ) .catch(() => []); log( "Found buttons (first 20) (scan took", Date.now() - tButtonScan + "ms):", JSON.stringify(buttons.slice(0, 20), null, 2) ); // Try to open House Rules by clicking the relevant button if present const btn = await page.$x("//button[contains(., 'House Rules')]"); if (btn && btn.length) { log("Found House Rules button, clicking"); await btn[0].click(); await sleep(500); } else { log( "House Rules button not found by text; attempting fallback selectors" ); // fallback: try a few likely selectors const fallbacks = [ ".Actions button", "button[aria-label='House Rules']", "button[data-testid='house-rules']", ]; let clicked = false; for (const sel of fallbacks) { const tSelStart = Date.now(); const el = await page.$(sel); log( "Checked selector", sel, " (took", Date.now() - tSelStart + "ms) ->", !!el ); if (el) { log("Clicking fallback selector", sel); await el.click(); clicked = true; await sleep(500); break; } } if (!clicked) console.log("No fallback selector matched"); } // Wait for the HouseRules dialog to appear try { const tWaitStart = Date.now(); await page.waitForSelector(".HouseRules", { timeout: 2000 }); log( "HouseRules dialog is present (waited", Date.now() - tWaitStart + "ms)" ); } catch (e) { log("HouseRules dialog did not appear"); // Dump a small HTML snippet around where it might be try { const snippet = await page.$$eval("body *:nth-child(-n+60)", (els) => els.map((e) => e.outerHTML).join("\n") ); console.log( "Body snippet (first ~60 elements):\n", snippet.slice(0, 4000) ); } catch (err) { log( "Could not capture snippet:", err && err.message ? err.message : err ); } } // Evaluate whether switches are disabled and capture extra debug info const switches = await page .$$eval(".HouseRules .RuleSwitch", (els) => els.map((e) => ({ id: e.id || "", disabled: e.disabled, outer: e.outerHTML, })) ) .catch(() => []); log("Switches found:", switches.length); switches.forEach((s, i) => { log(`${i}: id='${s.id}', disabled=${s.disabled}`); if (s.outer && s.outer.length > 0) { log(` outerHTML (first 200 chars): ${s.outer.slice(0, 200)}`); } }); // Also attempt to log the current game state and name visible in DOM if present try { const stateText = await page.$eval(".Table, body", (el) => document.body.innerText.slice(0, 2000) ); log( "Page text snippet:\n", stateText.split("\n").slice(0, 40).join("\n") ); } catch (err) { log( "Could not extract page text snippet:", err && err.message ? err.message : err ); } // Save a screenshot for inspection (workspace is mounted when running container) const out = path.join(OUT_DIR, 'house-rules.png'); try { const tShot = Date.now(); await page.screenshot({ path: out, fullPage: true }); log("Screenshot saved to", out, "(took", Date.now() - tShot + "ms)"); } catch (e) { log("Screenshot failed:", e && e.message ? e.message : e); } await browser.close(); clearGlobalTimeout(); log("Puppeteer test finished successfully"); } catch (err) { log("Puppeteer test failed:", err && err.stack ? err.stack : err); if (browser) try { await browser.close(); } catch (e) {} process.exit(2); } })();