Replace Playwright smoke tests with Selenium
This commit is contained in:
364
tests/e2e/lib/selenium-smoke.js
Normal file
364
tests/e2e/lib/selenium-smoke.js
Normal file
@@ -0,0 +1,364 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { Builder, By, 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
|
||||
};
|
||||
Reference in New Issue
Block a user