1
0

624 lines
24 KiB
JavaScript

const puppeteer = require("puppeteer-core");
const fs = require("fs");
(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");
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
: "<auto>",
})
);
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 {
fs.writeFileSync('/workspace/tmp-domcontent.html', domContent);
log('Saved domcontent HTML to /workspace/tmp-domcontent.html (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 = '/workspace/tmp-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 = `/workspace/tmp-house-rules-${firstSeen}.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 = `/workspace/tmp-domcontent-after-inputs-${firstSeen}.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 = `/workspace/tmp-ws-sends-${firstSeen}.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('/workspace/tmp-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 = '/workspace/tmp-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);
}
// 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('/workspace/tmp-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 = "/workspace/tmp-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);
}
})();