const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); const { Builder, By, Key, until } = require("selenium-webdriver"); const firefox = require("selenium-webdriver/firefox"); const baseUrl = process.env.SELENIUM_BASE_URL || "http://127.0.0.1:5000"; const defaultPassword = "Password123"; let uniqueSuffix = 0; function absoluteUrl(relativePath) { return new URL(relativePath, baseUrl).toString(); } function normalizeText(text) { return String(text || "").replace(/\s+/g, " ").trim(); } function uniqueName(prefix) { uniqueSuffix += 1; return `${prefix}-${Date.now()}-${uniqueSuffix}`; } function formatCookie(sessionCookie) { return `${sessionCookie.name}=${sessionCookie.value}`; } function parseSessionCookie(setCookieHeader) { assert.ok(setCookieHeader, "Missing Set-Cookie header for session login."); const match = setCookieHeader.match(/(?:^|,\s*)rpgroller_session=([^;]+)/); assert.ok(match, `Could not find rpgroller_session in Set-Cookie header: ${setCookieHeader}`); return { name: "rpgroller_session", value: match[1] }; } async function request(relativePath, options = {}) { const headers = new Headers(options.headers || {}); if (options.cookie) { headers.set("cookie", typeof options.cookie === "string" ? options.cookie : formatCookie(options.cookie)); } let body; if (options.json !== undefined) { headers.set("content-type", "application/json"); headers.set("accept", "application/json"); body = JSON.stringify(options.json); } return fetch(absoluteUrl(relativePath), { method: options.method || "GET", headers, body, redirect: options.redirect || "follow" }); } async function postJson(relativePath, payload, options = {}) { const response = await request(relativePath, { method: "POST", json: payload, cookie: options.cookie, redirect: options.redirect }); assert.equal(response.status, 200, `POST ${relativePath} failed with ${response.status}.`); return response.json(); } async function deleteJson(relativePath, options = {}) { const response = await request(relativePath, { method: "DELETE", cookie: options.cookie }); assert.equal(response.status, 200, `DELETE ${relativePath} failed with ${response.status}.`); return response.json(); } async function registerUser(username, displayName, password = defaultPassword) { return postJson("/api/auth/register", { username, password, displayName }); } async function loginUser(username, password = defaultPassword) { const response = await request("/api/auth/login", { method: "POST", json: { username, password }, redirect: "manual" }); assert.equal(response.status, 200, `Login for ${username} failed with ${response.status}.`); const sessionCookie = parseSessionCookie(response.headers.get("set-cookie")); const user = await response.json(); return { sessionCookie, user }; } async function registerAndLoginApi(username, displayName, password = defaultPassword) { await registerUser(username, displayName, password); return loginUser(username, password); } function resolveFirefoxBinary() { const candidates = [ process.env.FIREFOX_BINARY, "/snap/firefox/current/usr/lib/firefox/firefox", "/usr/bin/firefox" ]; return candidates.find((candidate) => candidate && fs.existsSync(candidate)) || null; } async function createDriver(options = {}) { const firefoxOptions = new firefox.Options().addArguments("-headless"); const binary = resolveFirefoxBinary(); if (binary) { firefoxOptions.setBinary(binary); } const driver = await new Builder() .forBrowser("firefox") .setFirefoxOptions(firefoxOptions) .build(); await driver.manage().setTimeouts({ implicit: 0, pageLoad: 30000, script: 30000 }); if (options.addonPath) { await driver.installAddon(path.resolve(options.addonPath), true); } return driver; } async function withDriver(options, callback) { const driver = await createDriver(options); try { return await callback(driver); } finally { await driver.quit(); } } async function seedAuthenticatedBrowser(driver, sessionCookie) { await driver.get(absoluteUrl("/login")); await driver.manage().addCookie({ name: sessionCookie.name, value: sessionCookie.value, path: "/" }); } async function waitFor(driver, predicate, message, timeout = 15000) { await driver.wait(async () => Boolean(await predicate()), timeout, message); } async function waitForUrl(driver, relativePath, timeout = 15000) { const expectedUrl = absoluteUrl(relativePath); await driver.wait(until.urlIs(expectedUrl), timeout, `Expected URL ${expectedUrl}.`); } async function waitForSelector(driver, selector, timeout = 15000) { const element = await driver.wait(until.elementLocated(By.css(selector)), timeout, `Expected selector ${selector}.`); await driver.wait(until.elementIsVisible(element), timeout, `Expected visible selector ${selector}.`); return element; } async function waitForText(driver, text, timeout = 15000) { await waitFor( driver, () => driver.executeScript((expected) => document.body.innerText.includes(expected), text), `Expected page text "${text}".`, timeout ); } async function waitForAbsent(driver, selector, timeout = 15000) { await waitFor( driver, () => driver.executeScript((css) => !document.querySelector(css), selector), `Expected selector ${selector} to be absent.`, timeout ); } async function selectorCount(driver, selector) { return driver.executeScript((css) => document.querySelectorAll(css).length, selector); } async function hasSelector(driver, selector) { return driver.executeScript((css) => Boolean(document.querySelector(css)), selector); } async function elementText(driver, selector) { const text = await driver.executeScript((css) => document.querySelector(css)?.textContent || "", selector); return normalizeText(text); } async function allTexts(driver, selector) { const texts = await driver.executeScript( (css) => [...document.querySelectorAll(css)].map((element) => element.textContent || ""), selector ); return texts.map(normalizeText); } async function getValue(driver, selector) { return driver.executeScript((css) => document.querySelector(css)?.value ?? null, selector); } async function getClassName(driver, selector) { return driver.executeScript((css) => document.querySelector(css)?.className ?? "", selector); } async function getAttribute(driver, selector, attributeName) { return driver.executeScript( (css, attribute) => document.querySelector(css)?.getAttribute(attribute) ?? null, selector, attributeName ); } async function isChecked(driver, selector) { return driver.executeScript((css) => Boolean(document.querySelector(css)?.checked), selector); } async function clickSelector(driver, selector) { const element = await waitForSelector(driver, selector); await element.click(); } async function clickText(driver, selector, text, options = {}) { const matched = await driver.executeScript( (css, expectedText, contains, last) => { const candidates = [...document.querySelectorAll(css)]; const normalized = expectedText.replace(/\s+/g, " ").trim(); const match = candidates.filter((candidate) => { const candidateText = (candidate.textContent || "").replace(/\s+/g, " ").trim(); return contains ? candidateText.includes(normalized) : candidateText === normalized; }); const element = last ? match.at(-1) : match[0]; if (!element) { return false; } element.click(); return true; }, selector, text, Boolean(options.contains), Boolean(options.last) ); assert.ok(matched, `Could not find ${selector} with text "${text}".`); } async function fillInput(driver, selector, value) { const updated = await driver.executeScript( (css, nextValue) => { const input = document.querySelector(css); if (!input) { return false; } input.focus(); input.value = nextValue; input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("change", { bubbles: true })); return true; }, selector, value ); assert.ok(updated, `Could not find input ${selector}.`); } async function clickLabel(driver, labelText) { const clicked = await driver.executeScript((text) => { const label = [...document.querySelectorAll("label")].find( (element) => (element.textContent || "").replace(/\s+/g, " ").trim() === text ); if (!label) { return false; } const targetId = label.getAttribute("for"); const target = targetId ? document.getElementById(targetId) : label.querySelector("input,select,textarea"); if (!target) { return false; } target.click(); target.dispatchEvent(new Event("change", { bubbles: true })); return true; }, labelText); assert.ok(clicked, `Could not find label "${labelText}".`); } async function clickByTitle(driver, title) { const clicked = await driver.executeScript((expectedTitle) => { const button = [...document.querySelectorAll("[title]")].find((element) => element.getAttribute("title") === expectedTitle); if (!button) { return false; } button.click(); return true; }, title); assert.ok(clicked, `Could not find element with title "${title}".`); } async function getDomWrapErrors(driver) { return driver.executeScript(() => window.__rrDomWrapTestErrors || []); } async function runSmokeTests(tests) { for (let index = 0; index < tests.length; index += 1) { const testCase = tests[index]; console.log(`[${index + 1}/${tests.length}] ${testCase.name}`); await testCase.run(); console.log(`PASS ${testCase.name}`); } } module.exports = { Key, absoluteUrl, allTexts, baseUrl, clickByTitle, clickLabel, clickSelector, clickText, defaultPassword, deleteJson, elementText, fillInput, formatCookie, getAttribute, getClassName, getDomWrapErrors, getValue, hasSelector, isChecked, postJson, registerAndLoginApi, request, runSmokeTests, seedAuthenticatedBrowser, selectorCount, uniqueName, waitFor, waitForAbsent, waitForSelector, waitForText, waitForUrl, withDriver };