Replace Playwright smoke tests with Selenium
This commit is contained in:
77
tests/e2e/dom-wrap-addon/content.js
Normal file
77
tests/e2e/dom-wrap-addon/content.js
Normal file
@@ -0,0 +1,77 @@
|
||||
(function injectDomWrapScript() {
|
||||
const script = document.createElement("script");
|
||||
script.textContent = `(() => {
|
||||
const wrappedMarker = "rrWrappedByTest";
|
||||
const errorPatterns = /error applying batch|unhandled exception on the current circuit/i;
|
||||
const errors = [];
|
||||
|
||||
window.__rrDomWrapTestErrors = errors;
|
||||
|
||||
const originalConsoleError = console.error.bind(console);
|
||||
console.error = (...args) => {
|
||||
const text = args.map((arg) => String(arg)).join(" ");
|
||||
if (errorPatterns.test(text)) {
|
||||
errors.push(text);
|
||||
}
|
||||
|
||||
originalConsoleError(...args);
|
||||
};
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
const text = [event.message, event.filename, event.lineno, event.colno].filter(Boolean).join(" ");
|
||||
if (errorPatterns.test(text)) {
|
||||
errors.push(text);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
const reason = event.reason ? String(event.reason) : "";
|
||||
if (errorPatterns.test(reason)) {
|
||||
errors.push(reason);
|
||||
}
|
||||
});
|
||||
|
||||
function wrapControl(element) {
|
||||
if (!(element instanceof HTMLElement) || !element.isConnected || element.dataset[wrappedMarker] === "1") {
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = element.parentNode;
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = document.createElement("span");
|
||||
wrapper.dataset[wrappedMarker] = "1";
|
||||
element.dataset[wrappedMarker] = "1";
|
||||
parent.insertBefore(wrapper, element);
|
||||
wrapper.appendChild(element);
|
||||
}
|
||||
|
||||
function queueWrap(node) {
|
||||
if (!(node instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.matches("input, select")) {
|
||||
queueMicrotask(() => wrapControl(node));
|
||||
}
|
||||
|
||||
node.querySelectorAll("input, select").forEach((element) => {
|
||||
queueMicrotask(() => wrapControl(element));
|
||||
});
|
||||
}
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach(queueWrap);
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||
document.querySelectorAll("input, select").forEach((element) => queueWrap(element));
|
||||
})();`;
|
||||
|
||||
(document.documentElement || document).appendChild(script);
|
||||
script.remove();
|
||||
})();
|
||||
17
tests/e2e/dom-wrap-addon/manifest.json
Normal file
17
tests/e2e/dom-wrap-addon/manifest.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "RpgRoller DOM Wrap Smoke",
|
||||
"version": "1.0",
|
||||
"description": "Wraps input controls at document start to mimic extension behavior during smoke tests.",
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"js": [
|
||||
"content.js"
|
||||
],
|
||||
"run_at": "document_start"
|
||||
}
|
||||
]
|
||||
}
|
||||
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
|
||||
};
|
||||
580
tests/e2e/smoke.js
Normal file
580
tests/e2e/smoke.js
Normal file
@@ -0,0 +1,580 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const {
|
||||
Key,
|
||||
absoluteUrl,
|
||||
allTexts,
|
||||
clickByTitle,
|
||||
clickLabel,
|
||||
clickSelector,
|
||||
clickText,
|
||||
elementText,
|
||||
fillInput,
|
||||
getAttribute,
|
||||
getClassName,
|
||||
getDomWrapErrors,
|
||||
getValue,
|
||||
hasSelector,
|
||||
isChecked,
|
||||
postJson,
|
||||
registerAndLoginApi,
|
||||
request,
|
||||
runSmokeTests,
|
||||
seedAuthenticatedBrowser,
|
||||
selectorCount,
|
||||
uniqueName,
|
||||
waitFor,
|
||||
waitForAbsent,
|
||||
waitForSelector,
|
||||
waitForText,
|
||||
waitForUrl,
|
||||
withDriver
|
||||
} = require("./lib/selenium-smoke");
|
||||
|
||||
const domWrapAddonPath = path.join(__dirname, "dom-wrap-addon");
|
||||
|
||||
async function openAuthenticatedPlay(driver, sessionCookie) {
|
||||
await seedAuthenticatedBrowser(driver, sessionCookie);
|
||||
await driver.get(absoluteUrl("/play"));
|
||||
await waitForText(driver, "Campaign Log");
|
||||
}
|
||||
|
||||
const tests = [
|
||||
{
|
||||
name: "home page loads auth entry points",
|
||||
run: async () => withDriver({}, async (driver) => {
|
||||
await driver.get(absoluteUrl("/"));
|
||||
await waitForUrl(driver, "/login");
|
||||
await waitForText(driver, "RpgRoller");
|
||||
assert.deepEqual(await allTexts(driver, "h2"), ["Register", "Login"]);
|
||||
assert.equal(await hasSelector(driver, "#register-username"), true);
|
||||
assert.equal(await hasSelector(driver, "#login-password"), true);
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "root document redirects anonymous users to login",
|
||||
run: async () => {
|
||||
const response = await request("/", { redirect: "manual" });
|
||||
assert.equal(response.status, 302);
|
||||
assert.equal(response.headers.get("location"), "/login");
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "login document renders static auth markup without bootstrapping blazor",
|
||||
run: async () => {
|
||||
const response = await request("/login");
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const html = await response.text();
|
||||
assert.ok(!html.includes("Connecting..."));
|
||||
assert.ok(html.includes("Register or log in to join a campaign session."));
|
||||
assert.ok(!html.includes("_framework/blazor.web.js"));
|
||||
assert.ok(!html.includes("<!--Blazor:"));
|
||||
assert.ok(html.includes("data-auth-page"));
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "authenticated root document redirects to play",
|
||||
run: async () => {
|
||||
const username = uniqueName("doc-auth");
|
||||
const { sessionCookie } = await registerAndLoginApi(username, "Document Auth");
|
||||
const response = await request("/", {
|
||||
cookie: sessionCookie,
|
||||
redirect: "manual"
|
||||
});
|
||||
|
||||
assert.equal(response.status, 302);
|
||||
assert.equal(response.headers.get("location"), "/play");
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "successful login transitions to play workspace",
|
||||
run: async () => withDriver({}, async (driver) => {
|
||||
const username = uniqueName("login");
|
||||
await postJson("/api/auth/register", {
|
||||
username,
|
||||
password: "Password123",
|
||||
displayName: "Login Flow"
|
||||
});
|
||||
|
||||
await driver.get(absoluteUrl("/login"));
|
||||
await fillInput(driver, "#login-username", username);
|
||||
await fillInput(driver, "#login-password", "Password123");
|
||||
await clickText(driver, "button", "Login");
|
||||
|
||||
await waitForUrl(driver, "/play");
|
||||
await waitForText(driver, "Campaign Log");
|
||||
assert.equal(await selectorCount(driver, "#login-username"), 0);
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "workspace stays usable when input controls are DOM-wrapped during mount",
|
||||
run: async () => withDriver({ addonPath: domWrapAddonPath }, async (driver) => {
|
||||
const username = uniqueName("wrapped");
|
||||
const { sessionCookie } = await registerAndLoginApi(username, "Wrapped Inputs");
|
||||
|
||||
const campaign = await postJson("/api/campaigns", {
|
||||
name: "Wrapped Inputs",
|
||||
rulesetId: "d6"
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
const character = await postJson("/api/characters", {
|
||||
name: "Wrapper Hero",
|
||||
campaignId: campaign.id
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
await postJson(`/api/characters/${character.id}/skills`, {
|
||||
name: "Stealth",
|
||||
diceRollDefinition: "2D+1",
|
||||
wildDice: 1,
|
||||
allowFumble: true
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
await openAuthenticatedPlay(driver, sessionCookie);
|
||||
await waitForSelector(driver, "#skill-filter-input");
|
||||
await waitForSelector(driver, "#custom-roll-expression");
|
||||
await waitFor(
|
||||
driver,
|
||||
() => driver.executeScript(() => [...document.querySelectorAll("button")].some((button) => button.textContent.includes("Roll Stealth"))),
|
||||
"Expected Roll Stealth button."
|
||||
);
|
||||
|
||||
assert.deepEqual(await getDomWrapErrors(driver), []);
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "Rolemaster open-ended roll detail renders specialized dice chips",
|
||||
run: async () => withDriver({}, async (driver) => {
|
||||
const username = uniqueName("rm");
|
||||
const { sessionCookie } = await registerAndLoginApi(username, "Rolemaster Smoke");
|
||||
|
||||
const campaign = await postJson("/api/campaigns", {
|
||||
name: "Rolemaster Smoke",
|
||||
rulesetId: "rolemaster"
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
const character = await postJson("/api/characters", {
|
||||
name: "Open Ender",
|
||||
campaignId: campaign.id
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
const skill = await postJson(`/api/characters/${character.id}/skills`, {
|
||||
name: "Open Sight",
|
||||
diceRollDefinition: "d100!+85",
|
||||
wildDice: 0,
|
||||
allowFumble: false,
|
||||
fumbleRange: 95
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
let qualifyingRoll = null;
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
const roll = await postJson(`/api/skills/${skill.id}/roll`, { visibility: "public" }, { cookie: sessionCookie });
|
||||
if (roll.dice.some((die) => die.kind === "rolemaster-open-ended-high" || die.kind === "rolemaster-open-ended-low-subtract")) {
|
||||
qualifyingRoll = roll;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert.notEqual(qualifyingRoll, null, "Expected an open-ended Rolemaster roll within 12 attempts.");
|
||||
|
||||
await openAuthenticatedPlay(driver, sessionCookie);
|
||||
await waitForSelector(driver, ".log-panel .log-entry");
|
||||
|
||||
await clickSelector(driver, ".log-panel .log-entry-toggle");
|
||||
await waitForSelector(driver, ".die-chip.rolemaster-open-ended-high, .die-chip.rolemaster-open-ended-low-subtract");
|
||||
assert.equal(await hasSelector(driver, ".log-detail .roll-dice-strip"), true);
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "Rolemaster automatic retry badge shows before detail expands",
|
||||
run: async () => withDriver({}, async (driver) => {
|
||||
const username = uniqueName("rm-retry");
|
||||
const { sessionCookie } = await registerAndLoginApi(username, "Rolemaster Retry Smoke");
|
||||
|
||||
const campaign = await postJson("/api/campaigns", {
|
||||
name: "Rolemaster Retry Smoke",
|
||||
rulesetId: "rolemaster"
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
const character = await postJson("/api/characters", {
|
||||
name: "Retry Hero",
|
||||
campaignId: campaign.id
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
const skill = await postJson(`/api/characters/${character.id}/skills`, {
|
||||
name: "Retry Sight",
|
||||
diceRollDefinition: "d100!+10",
|
||||
wildDice: 0,
|
||||
allowFumble: false,
|
||||
fumbleRange: 5,
|
||||
rolemasterAutoRetry: true
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
let retriedRoll = null;
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
const roll = await postJson(`/api/skills/${skill.id}/roll`, { visibility: "public" }, { cookie: sessionCookie });
|
||||
if (roll.breakdown.includes("retry(+")) {
|
||||
retriedRoll = roll;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert.notEqual(retriedRoll, null, "Expected a retry-enabled Rolemaster roll within 10 attempts.");
|
||||
|
||||
await openAuthenticatedPlay(driver, sessionCookie);
|
||||
await waitFor(
|
||||
driver,
|
||||
() => driver.executeScript(() => [...document.querySelectorAll(".log-panel .log-entry")].some((entry) => entry.textContent.includes("retry +"))),
|
||||
"Expected retry roll entry."
|
||||
);
|
||||
|
||||
const collapsedState = await driver.executeScript(() => {
|
||||
const entries = [...document.querySelectorAll(".log-panel .log-entry")].filter((entry) => {
|
||||
const text = entry.textContent || "";
|
||||
return text.includes("Retry Sight") && text.includes("retry +");
|
||||
});
|
||||
const retryEntry = entries.at(-1);
|
||||
if (!retryEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
badgeTexts: [...retryEntry.querySelectorAll(".log-event-badge")].map((element) => element.textContent || ""),
|
||||
summaryText: retryEntry.querySelector(".log-summary-text")?.textContent || "",
|
||||
detailCount: retryEntry.querySelectorAll(".log-detail").length
|
||||
};
|
||||
});
|
||||
|
||||
assert.ok(collapsedState);
|
||||
assert.ok(collapsedState.badgeTexts.some((badgeText) => /Retry \+(5|10)/.test(badgeText)));
|
||||
assert.match(collapsedState.summaryText, /retry \+(5|10)/i);
|
||||
assert.equal(collapsedState.detailCount, 0);
|
||||
|
||||
await clickText(driver, ".log-panel .log-entry-toggle", "Details", { contains: true, last: true }).catch(async () => {
|
||||
const toggled = await driver.executeScript(() => {
|
||||
const entries = [...document.querySelectorAll(".log-panel .log-entry")].filter((entry) => {
|
||||
const text = entry.textContent || "";
|
||||
return text.includes("Retry Sight") && text.includes("retry +");
|
||||
});
|
||||
const retryEntry = entries.at(-1);
|
||||
const toggle = retryEntry?.querySelector(".log-entry-toggle");
|
||||
if (!toggle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
toggle.click();
|
||||
return true;
|
||||
});
|
||||
|
||||
assert.ok(toggled, "Could not expand retry entry.");
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
driver,
|
||||
() => driver.executeScript(() => {
|
||||
const entries = [...document.querySelectorAll(".log-panel .log-entry")].filter((entry) => {
|
||||
const text = entry.textContent || "";
|
||||
return text.includes("Retry Sight") && text.includes("retry +");
|
||||
});
|
||||
return (entries.at(-1)?.querySelectorAll(".log-detail .die-chip").length || 0) === 2;
|
||||
}),
|
||||
"Expected two retry detail dice chips."
|
||||
);
|
||||
|
||||
const detailState = await driver.executeScript(() => {
|
||||
const entries = [...document.querySelectorAll(".log-panel .log-entry")].filter((entry) => {
|
||||
const text = entry.textContent || "";
|
||||
return text.includes("Retry Sight") && text.includes("retry +");
|
||||
});
|
||||
const retryEntry = entries.at(-1);
|
||||
const chips = [...(retryEntry?.querySelectorAll(".log-detail .die-chip") || [])];
|
||||
return chips.map((chip) => chip.getAttribute("title") || "");
|
||||
});
|
||||
|
||||
assert.equal(detailState.length, 2);
|
||||
assert.match(detailState[0], /attempt 1/i);
|
||||
assert.match(detailState[1], /retry attempt 2/i);
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "Rolemaster skill roll modal autofocuses, validates, and closes on escape or backdrop",
|
||||
run: async () => withDriver({}, async (driver) => {
|
||||
const username = uniqueName("rm-modal");
|
||||
const { sessionCookie } = await registerAndLoginApi(username, "Rolemaster Modal Smoke");
|
||||
|
||||
const campaign = await postJson("/api/campaigns", {
|
||||
name: "Rolemaster Modal Smoke",
|
||||
rulesetId: "rolemaster"
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
const character = await postJson("/api/characters", {
|
||||
name: "Observer",
|
||||
campaignId: campaign.id
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
await postJson(`/api/characters/${character.id}/skills`, {
|
||||
name: "Observation",
|
||||
diceRollDefinition: "d100!+50",
|
||||
wildDice: 0,
|
||||
allowFumble: false,
|
||||
fumbleRange: 5,
|
||||
rolemasterAutoRetry: true
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
await openAuthenticatedPlay(driver, sessionCookie);
|
||||
await waitFor(
|
||||
driver,
|
||||
() => driver.executeScript(() => [...document.querySelectorAll("button")].some((button) => button.textContent.includes("Roll Observation"))),
|
||||
"Expected Roll Observation button."
|
||||
);
|
||||
|
||||
await clickText(driver, "button", "Roll Observation", { contains: true });
|
||||
await waitForSelector(driver, ".rolemaster-roll-modal");
|
||||
await waitFor(
|
||||
driver,
|
||||
() => driver.executeScript(() => document.activeElement?.id === "rolemaster-situational-modifier"),
|
||||
"Expected modifier input to be focused."
|
||||
);
|
||||
|
||||
await (await waitForSelector(driver, "#rolemaster-situational-modifier")).sendKeys(Key.ESCAPE);
|
||||
await waitForAbsent(driver, ".rolemaster-roll-modal");
|
||||
|
||||
await clickText(driver, "button", "Roll Observation", { contains: true });
|
||||
await waitForSelector(driver, ".rolemaster-roll-modal");
|
||||
await driver.executeScript(() => {
|
||||
document.querySelector(".modal-overlay")?.click();
|
||||
});
|
||||
await waitForAbsent(driver, ".rolemaster-roll-modal");
|
||||
|
||||
await clickText(driver, "button", "Roll Observation", { contains: true });
|
||||
await waitForSelector(driver, ".rolemaster-roll-modal");
|
||||
await fillInput(driver, "#rolemaster-situational-modifier", "1001");
|
||||
await clickText(driver, ".rolemaster-roll-modal button", "Roll");
|
||||
await waitForText(driver, "Enter a whole number between -1000 and 1000.");
|
||||
assert.equal(await hasSelector(driver, ".rolemaster-roll-modal"), true);
|
||||
|
||||
await fillInput(driver, "#rolemaster-situational-modifier", "");
|
||||
await (await waitForSelector(driver, "#rolemaster-situational-modifier")).sendKeys(Key.ENTER);
|
||||
await waitForAbsent(driver, ".rolemaster-roll-modal");
|
||||
await waitFor(
|
||||
driver,
|
||||
() => driver.executeScript(() => document.querySelector(".log-panel .log-entry.expanded")?.textContent.includes("Observation") || false),
|
||||
"Expected expanded Observation log entry."
|
||||
);
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "newly rolled log entry auto-expands",
|
||||
run: async () => withDriver({}, async (driver) => {
|
||||
const username = uniqueName("d6-log");
|
||||
const { sessionCookie } = await registerAndLoginApi(username, "D6 Auto Expand");
|
||||
|
||||
const campaign = await postJson("/api/campaigns", {
|
||||
name: "D6 Auto Expand",
|
||||
rulesetId: "d6"
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
const character = await postJson("/api/characters", {
|
||||
name: "Auto Hero",
|
||||
campaignId: campaign.id
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
await postJson(`/api/characters/${character.id}/skills`, {
|
||||
name: "Stealth",
|
||||
diceRollDefinition: "2D+1",
|
||||
wildDice: 1,
|
||||
allowFumble: true
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
await openAuthenticatedPlay(driver, sessionCookie);
|
||||
await waitFor(
|
||||
driver,
|
||||
() => driver.executeScript(() => [...document.querySelectorAll("button")].some((button) => button.textContent.includes("Roll Stealth"))),
|
||||
"Expected Roll Stealth button."
|
||||
);
|
||||
await clickText(driver, "button", "Roll Stealth", { contains: true });
|
||||
await waitForSelector(driver, ".log-panel .log-entry.expanded");
|
||||
assert.equal(await hasSelector(driver, ".log-panel .log-entry.expanded .roll-dice-strip"), true);
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "custom roll composer keeps parse errors inline and records successful rolls",
|
||||
run: async () => withDriver({}, async (driver) => {
|
||||
const username = uniqueName("custom-roll");
|
||||
const { sessionCookie } = await registerAndLoginApi(username, "Custom Roller");
|
||||
|
||||
const campaign = await postJson("/api/campaigns", {
|
||||
name: "Custom Roll Campaign",
|
||||
rulesetId: "dnd5e"
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
await postJson("/api/characters", {
|
||||
name: "Improviser",
|
||||
campaignId: campaign.id
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
await openAuthenticatedPlay(driver, sessionCookie);
|
||||
await waitFor(
|
||||
driver,
|
||||
() => driver.executeScript(() => {
|
||||
const input = document.querySelector("#custom-roll-expression");
|
||||
const button = document.querySelector(".custom-roll-composer button");
|
||||
return Boolean(input && button && !input.disabled && !button.disabled);
|
||||
}),
|
||||
"Expected custom roll composer to be interactive."
|
||||
);
|
||||
await fillInput(driver, "#custom-roll-expression", "bad");
|
||||
await clickText(driver, ".custom-roll-composer button", "Roll");
|
||||
|
||||
await waitFor(
|
||||
driver,
|
||||
() => driver.executeScript(() => {
|
||||
const input = document.querySelector("#custom-roll-expression");
|
||||
return Boolean(input && /error/.test(input.className));
|
||||
}),
|
||||
"Expected custom roll input to show an inline validation error."
|
||||
);
|
||||
assert.match(await getClassName(driver, "#custom-roll-expression"), /error/);
|
||||
assert.match(await getAttribute(driver, "#custom-roll-expression", "title"), /Expected dnd5e format like 2d12\+2\./);
|
||||
assert.equal(await selectorCount(driver, ".toast.error"), 0);
|
||||
|
||||
await fillInput(driver, "#custom-roll-expression", "1d20+5");
|
||||
await clickText(driver, ".custom-roll-composer button", "Roll");
|
||||
await waitFor(
|
||||
driver,
|
||||
() => driver.executeScript(() => {
|
||||
const className = document.querySelector("#custom-roll-expression")?.className || "";
|
||||
const firstLogEntry = document.querySelector(".log-panel .log-entry");
|
||||
return !/error/.test(className) && Boolean(firstLogEntry?.textContent.includes("Custom roll"));
|
||||
}),
|
||||
"Expected successful custom roll entry."
|
||||
);
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "Rolemaster UI exposes conditional create and edit fields",
|
||||
run: async () => withDriver({}, async (driver) => {
|
||||
const username = uniqueName("rm-ui");
|
||||
const { sessionCookie } = await registerAndLoginApi(username, "Rolemaster UI");
|
||||
|
||||
const campaign = await postJson("/api/campaigns", {
|
||||
name: "Rolemaster UI Campaign",
|
||||
rulesetId: "rolemaster"
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
const character = await postJson("/api/characters", {
|
||||
name: "UI Character",
|
||||
campaignId: campaign.id
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
await postJson(`/api/characters/${character.id}/skill-groups`, {
|
||||
name: "Awareness",
|
||||
diceRollDefinition: "d100!+15",
|
||||
wildDice: 0,
|
||||
allowFumble: false,
|
||||
fumbleRange: 5
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
await postJson(`/api/characters/${character.id}/skills`, {
|
||||
name: "Perception",
|
||||
diceRollDefinition: "d100!+25",
|
||||
wildDice: 0,
|
||||
allowFumble: false,
|
||||
fumbleRange: 5,
|
||||
rolemasterAutoRetry: true
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
await openAuthenticatedPlay(driver, sessionCookie);
|
||||
await waitForSelector(driver, "#workspace-screen-menu-button");
|
||||
|
||||
await clickSelector(driver, "#workspace-screen-menu-button");
|
||||
await clickText(driver, ".screen-menu .menu-item", "Campaign Management");
|
||||
await waitFor(
|
||||
driver,
|
||||
() => driver.executeScript(() => [...document.querySelectorAll("button")].some((button) => button.textContent.includes("Add campaign"))),
|
||||
"Expected Campaign Management controls."
|
||||
);
|
||||
await clickText(driver, "button", "Add campaign", { contains: true });
|
||||
await waitForSelector(driver, "#campaign-ruleset");
|
||||
assert.equal(await elementText(driver, "#campaign-ruleset option[value='rolemaster']"), "Rolemaster");
|
||||
await clickText(driver, "button", "Cancel");
|
||||
|
||||
await clickSelector(driver, "#workspace-screen-menu-button");
|
||||
await clickText(driver, ".screen-menu .menu-item", "Play");
|
||||
await waitFor(
|
||||
driver,
|
||||
() => driver.executeScript(() => [...document.querySelectorAll("button")].some((button) => button.textContent.includes("Add group"))),
|
||||
"Expected Play controls after returning from Campaign Management."
|
||||
);
|
||||
|
||||
await clickText(driver, "button", "Add group", { contains: true });
|
||||
await waitForSelector(driver, "#skill-group-expression");
|
||||
assert.equal(await selectorCount(driver, "#skill-group-wild-dice"), 0);
|
||||
assert.equal(await getValue(driver, "#skill-group-expression"), "d100");
|
||||
await fillInput(driver, "#skill-group-expression", "d100!+15");
|
||||
await waitForSelector(driver, "#skill-group-fumble-range");
|
||||
await fillInput(driver, "#skill-group-fumble-range", "");
|
||||
await clickText(driver, "button", "Create Group");
|
||||
await waitForText(driver, "Open-ended Rolemaster groups require a fumble range.");
|
||||
await clickText(driver, "button", "Cancel");
|
||||
|
||||
await clickText(driver, "button", "Add skill", { contains: true });
|
||||
await waitForSelector(driver, "#skill-create-expression");
|
||||
assert.equal(await getValue(driver, "#skill-create-expression"), "d100!+15");
|
||||
await fillInput(driver, "#skill-create-expression", "15d10");
|
||||
await waitFor(
|
||||
driver,
|
||||
() => selectorCount(driver, "#skill-create-fumble-range").then((count) => count === 0),
|
||||
"Expected no create fumble range for non-open-ended expression."
|
||||
);
|
||||
await waitFor(
|
||||
driver,
|
||||
() => selectorCount(driver, "#skill-auto-retry").then((count) => count === 0),
|
||||
"Expected no auto retry checkbox for non-open-ended expression."
|
||||
);
|
||||
|
||||
await fillInput(driver, "#skill-create-expression", "d100!+25");
|
||||
await waitForSelector(driver, "#skill-create-fumble-range");
|
||||
await waitForSelector(driver, "#skill-auto-retry");
|
||||
await clickLabel(driver, "Automatic retry");
|
||||
await fillInput(driver, "#skill-create-expression", "d10");
|
||||
await waitFor(
|
||||
driver,
|
||||
() => selectorCount(driver, "#skill-auto-retry").then((count) => count === 0),
|
||||
"Expected create auto retry checkbox to disappear."
|
||||
);
|
||||
|
||||
await fillInput(driver, "#skill-create-expression", "d100!+25");
|
||||
await waitForSelector(driver, "#skill-auto-retry");
|
||||
assert.equal(await isChecked(driver, "#skill-auto-retry"), false);
|
||||
await clickText(driver, "button", "Cancel");
|
||||
|
||||
await clickByTitle(driver, "Edit skill");
|
||||
await waitForSelector(driver, "#skill-edit-expression");
|
||||
assert.equal(await getValue(driver, "#skill-edit-expression"), "d100!+25");
|
||||
assert.equal(await getValue(driver, "#skill-edit-fumble-range"), "5");
|
||||
assert.equal(await isChecked(driver, "#skill-auto-retry"), true);
|
||||
await fillInput(driver, "#skill-edit-expression", "d10");
|
||||
await waitFor(
|
||||
driver,
|
||||
() => selectorCount(driver, "#skill-edit-fumble-range").then((count) => count === 0),
|
||||
"Expected edit fumble range to disappear."
|
||||
);
|
||||
await waitFor(
|
||||
driver,
|
||||
() => selectorCount(driver, "#skill-auto-retry").then((count) => count === 0),
|
||||
"Expected edit auto retry checkbox to disappear."
|
||||
);
|
||||
|
||||
await fillInput(driver, "#skill-edit-expression", "d100!+25");
|
||||
await waitForSelector(driver, "#skill-auto-retry");
|
||||
assert.equal(await isChecked(driver, "#skill-auto-retry"), false);
|
||||
await clickText(driver, "button", "Cancel");
|
||||
})
|
||||
}
|
||||
];
|
||||
|
||||
runSmokeTests(tests).catch((error) => {
|
||||
console.error(error.stack || error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,457 +0,0 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
async function postJson(request, url, data) {
|
||||
const response = await request.post(url, { data });
|
||||
expect(response.ok()).toBeTruthy();
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async function registerAndLogin(request, username, displayName) {
|
||||
await postJson(request, "/api/auth/register", {
|
||||
username,
|
||||
password: "Password123",
|
||||
displayName
|
||||
});
|
||||
|
||||
const loginResponse = await request.post("/api/auth/login", {
|
||||
data: {
|
||||
username,
|
||||
password: "Password123"
|
||||
}
|
||||
});
|
||||
expect(loginResponse.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
test("home page loads auth entry points", async ({ page }) => {
|
||||
await page.goto("/play");
|
||||
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
await expect(page.locator("h1")).toContainText("RpgRoller");
|
||||
await expect(page.getByRole("heading", { name: "Register" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Login" })).toBeVisible();
|
||||
await expect(page.getByLabel("Username").first()).toBeVisible();
|
||||
await expect(page.getByLabel("Password").nth(1)).toBeVisible();
|
||||
});
|
||||
|
||||
test("root document redirects anonymous users to login", async ({ request }) => {
|
||||
const response = await request.get("/", { maxRedirects: 0 });
|
||||
expect(response.status()).toBe(302);
|
||||
expect(response.headers()["location"]).toBe("/login");
|
||||
});
|
||||
|
||||
test("login document renders static auth markup without bootstrapping blazor", async ({ request }) => {
|
||||
const response = await request.get("/login");
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
const html = await response.text();
|
||||
expect(html).not.toContain("Connecting...");
|
||||
expect(html).toContain("Register or log in to join a campaign session.");
|
||||
expect(html).not.toContain("_framework/blazor.web.js");
|
||||
expect(html).not.toContain("<!--Blazor:");
|
||||
expect(html).toContain("data-auth-page");
|
||||
});
|
||||
|
||||
test("authenticated root document redirects to play", async ({ request }) => {
|
||||
const username = `doc-auth-${Date.now()}`;
|
||||
const password = "Password123";
|
||||
|
||||
await postJson(request, "/api/auth/register", {
|
||||
username,
|
||||
password,
|
||||
displayName: "Document Auth"
|
||||
});
|
||||
|
||||
const loginResponse = await request.post("/api/auth/login", {
|
||||
data: {
|
||||
username,
|
||||
password
|
||||
}
|
||||
});
|
||||
expect(loginResponse.ok()).toBeTruthy();
|
||||
|
||||
const response = await request.get("/", { maxRedirects: 0 });
|
||||
expect(response.status()).toBe(302);
|
||||
expect(response.headers()["location"]).toBe("/play");
|
||||
});
|
||||
|
||||
test("successful login transitions to play workspace", async ({ page, context }) => {
|
||||
const username = `login-${Date.now()}`;
|
||||
const password = "Password123";
|
||||
|
||||
await postJson(context.request, "/api/auth/register", {
|
||||
username,
|
||||
password,
|
||||
displayName: "Login Flow"
|
||||
});
|
||||
|
||||
await page.goto("/login");
|
||||
await page.locator("#login-username").fill(username);
|
||||
await page.locator("#login-password").fill(password);
|
||||
await page.getByRole("button", { name: "Login" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/play$/);
|
||||
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||
await expect(page.locator("#login-username")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("workspace stays usable when input controls are DOM-wrapped during mount", async ({ page, context }) => {
|
||||
const username = `wrapped-${Date.now()}`;
|
||||
await registerAndLogin(context.request, username, "Wrapped Inputs");
|
||||
|
||||
const campaign = await postJson(context.request, "/api/campaigns", {
|
||||
name: "Wrapped Inputs",
|
||||
rulesetId: "d6"
|
||||
});
|
||||
const character = await postJson(context.request, "/api/characters", {
|
||||
name: "Wrapper Hero",
|
||||
campaignId: campaign.id
|
||||
});
|
||||
await postJson(context.request, `/api/characters/${character.id}/skills`, {
|
||||
name: "Stealth",
|
||||
diceRollDefinition: "2D+1",
|
||||
wildDice: 1,
|
||||
allowFumble: true
|
||||
});
|
||||
|
||||
await page.addInitScript(() => {
|
||||
const wrappedMarker = "rrWrappedByTest";
|
||||
|
||||
function wrapControl(element) {
|
||||
if (!(element instanceof HTMLElement) || !element.isConnected || element.dataset[wrappedMarker] === "1") {
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = element.parentNode;
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = document.createElement("span");
|
||||
wrapper.dataset[wrappedMarker] = "1";
|
||||
element.dataset[wrappedMarker] = "1";
|
||||
parent.insertBefore(wrapper, element);
|
||||
wrapper.appendChild(element);
|
||||
}
|
||||
|
||||
function queueWrap(node) {
|
||||
if (!(node instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.matches("input, select")) {
|
||||
queueMicrotask(() => wrapControl(node));
|
||||
}
|
||||
|
||||
node.querySelectorAll("input, select").forEach((element) => {
|
||||
queueMicrotask(() => wrapControl(element));
|
||||
});
|
||||
}
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach(queueWrap);
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||
document.querySelectorAll("input, select").forEach((element) => queueWrap(element));
|
||||
});
|
||||
|
||||
const blazorErrors = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.type() !== "error") {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = message.text();
|
||||
if (/error applying batch|unhandled exception on the current circuit/i.test(text)) {
|
||||
blazorErrors.push(text);
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/play");
|
||||
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||
await expect(page.locator("#skill-filter-input")).toBeVisible();
|
||||
await expect(page.locator("#custom-roll-expression")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Roll Stealth" })).toBeVisible();
|
||||
expect(blazorErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test("Rolemaster open-ended roll detail renders specialized dice chips", async ({ page, context }) => {
|
||||
const username = `rm-${Date.now()}`;
|
||||
const displayName = "Rolemaster Smoke";
|
||||
|
||||
await registerAndLogin(context.request, username, displayName);
|
||||
|
||||
const campaign = await postJson(context.request, "/api/campaigns", {
|
||||
name: "Rolemaster Smoke",
|
||||
rulesetId: "rolemaster"
|
||||
});
|
||||
const character = await postJson(context.request, "/api/characters", {
|
||||
name: "Open Ender",
|
||||
campaignId: campaign.id
|
||||
});
|
||||
const skill = await postJson(context.request, `/api/characters/${character.id}/skills`, {
|
||||
name: "Open Sight",
|
||||
diceRollDefinition: "d100!+85",
|
||||
wildDice: 0,
|
||||
allowFumble: false,
|
||||
fumbleRange: 95
|
||||
});
|
||||
|
||||
await postJson(context.request, `/api/skills/${skill.id}/roll`, { visibility: "public" });
|
||||
|
||||
await page.goto("/play");
|
||||
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||
await expect(page.locator(".log-panel .log-entry").first()).toBeVisible();
|
||||
await expect(page.locator(".log-panel .log-event-badge")).toContainText(["Fumble"]);
|
||||
|
||||
const logEntry = page.locator(".log-panel .log-entry-toggle").first();
|
||||
await expect(logEntry).toBeVisible();
|
||||
await logEntry.click();
|
||||
|
||||
const rolemasterFollowUpDice = page.locator(".die-chip.rolemaster-open-ended-high, .die-chip.rolemaster-open-ended-low-subtract");
|
||||
await expect(rolemasterFollowUpDice.first()).toBeVisible();
|
||||
await expect(page.locator(".log-detail .roll-dice-strip")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Rolemaster automatic retry badge shows before detail expands", async ({ page, context }) => {
|
||||
const username = `rm-retry-${Date.now()}`;
|
||||
await registerAndLogin(context.request, username, "Rolemaster Retry Smoke");
|
||||
|
||||
const campaign = await postJson(context.request, "/api/campaigns", {
|
||||
name: "Rolemaster Retry Smoke",
|
||||
rulesetId: "rolemaster"
|
||||
});
|
||||
const character = await postJson(context.request, "/api/characters", {
|
||||
name: "Retry Hero",
|
||||
campaignId: campaign.id
|
||||
});
|
||||
const skill = await postJson(context.request, `/api/characters/${character.id}/skills`, {
|
||||
name: "Retry Sight",
|
||||
diceRollDefinition: "d100!+10",
|
||||
wildDice: 0,
|
||||
allowFumble: false,
|
||||
fumbleRange: 5,
|
||||
rolemasterAutoRetry: true
|
||||
});
|
||||
|
||||
let retriedRoll = null;
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
const roll = await postJson(context.request, `/api/skills/${skill.id}/roll`, { visibility: "public" });
|
||||
if (roll.breakdown.includes("retry(+")) {
|
||||
retriedRoll = roll;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(retriedRoll, "expected a retry-enabled Rolemaster roll within 10 attempts").not.toBeNull();
|
||||
|
||||
await page.goto("/play");
|
||||
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||
|
||||
const retryEntry = page.locator(".log-panel .log-entry").filter({ hasText: "retry +" }).last();
|
||||
await expect(retryEntry).toBeVisible();
|
||||
await expect(retryEntry.locator(".log-event-badge")).toContainText([/Retry \+(5|10)/]);
|
||||
await expect(retryEntry.locator(".log-summary-text")).toContainText(/retry \+(5|10)/);
|
||||
await expect(retryEntry.locator(".log-detail")).toHaveCount(0);
|
||||
|
||||
await retryEntry.locator(".log-entry-toggle").click();
|
||||
const detailDice = retryEntry.locator(".log-detail .die-chip");
|
||||
await expect(detailDice).toHaveCount(2);
|
||||
await expect(detailDice.nth(0)).toHaveAttribute("title", /attempt 1/i);
|
||||
await expect(detailDice.nth(1)).toHaveAttribute("title", /retry attempt 2/i);
|
||||
});
|
||||
|
||||
test("Rolemaster skill roll modal autofocuses, validates, and closes on escape or backdrop", async ({ page, context }) => {
|
||||
const username = `rm-modal-${Date.now()}`;
|
||||
await registerAndLogin(context.request, username, "Rolemaster Modal Smoke");
|
||||
|
||||
const campaign = await postJson(context.request, "/api/campaigns", {
|
||||
name: "Rolemaster Modal Smoke",
|
||||
rulesetId: "rolemaster"
|
||||
});
|
||||
const character = await postJson(context.request, "/api/characters", {
|
||||
name: "Observer",
|
||||
campaignId: campaign.id
|
||||
});
|
||||
await postJson(context.request, `/api/characters/${character.id}/skills`, {
|
||||
name: "Observation",
|
||||
diceRollDefinition: "d100!+50",
|
||||
wildDice: 0,
|
||||
allowFumble: false,
|
||||
fumbleRange: 5,
|
||||
rolemasterAutoRetry: true
|
||||
});
|
||||
|
||||
await page.goto("/play");
|
||||
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||
|
||||
const rollButton = page.getByRole("button", { name: "Roll Observation" });
|
||||
const modal = page.getByRole("dialog", { name: "Rolemaster situational modifier" });
|
||||
const modifierInput = page.locator("#rolemaster-situational-modifier");
|
||||
|
||||
await rollButton.click();
|
||||
await expect(modal).toBeVisible();
|
||||
await expect(modifierInput).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(modal).toHaveCount(0);
|
||||
|
||||
await rollButton.click();
|
||||
await expect(modal).toBeVisible();
|
||||
await page.locator(".modal-overlay").click({ position: { x: 8, y: 8 } });
|
||||
await expect(modal).toHaveCount(0);
|
||||
|
||||
await rollButton.click();
|
||||
await expect(modal).toBeVisible();
|
||||
await modifierInput.fill("1001");
|
||||
await modal.getByRole("button", { name: "Roll" }).click();
|
||||
await expect(page.getByText("Enter a whole number between -1000 and 1000.")).toBeVisible();
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
await modifierInput.fill("");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(modal).toHaveCount(0);
|
||||
await expect(page.locator(".log-panel .log-entry.expanded").first()).toContainText("Observation");
|
||||
});
|
||||
|
||||
test("newly rolled log entry auto-expands", async ({ page, context }) => {
|
||||
const username = `d6-log-${Date.now()}`;
|
||||
await registerAndLogin(context.request, username, "D6 Auto Expand");
|
||||
|
||||
const campaign = await postJson(context.request, "/api/campaigns", {
|
||||
name: "D6 Auto Expand",
|
||||
rulesetId: "d6"
|
||||
});
|
||||
const character = await postJson(context.request, "/api/characters", {
|
||||
name: "Auto Hero",
|
||||
campaignId: campaign.id
|
||||
});
|
||||
await postJson(context.request, `/api/characters/${character.id}/skills`, {
|
||||
name: "Stealth",
|
||||
diceRollDefinition: "2D+1",
|
||||
wildDice: 1,
|
||||
allowFumble: true
|
||||
});
|
||||
|
||||
await page.goto("/play");
|
||||
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Roll Stealth" }).click();
|
||||
|
||||
const expandedEntry = page.locator(".log-panel .log-entry.expanded").first();
|
||||
await expect(expandedEntry).toBeVisible();
|
||||
await expect(expandedEntry.locator(".log-detail .roll-dice-strip")).toBeVisible();
|
||||
});
|
||||
|
||||
test("custom roll composer keeps parse errors inline and records successful rolls", async ({ page, context }) => {
|
||||
const username = `custom-roll-${Date.now()}`;
|
||||
await registerAndLogin(context.request, username, "Custom Roller");
|
||||
|
||||
const campaign = await postJson(context.request, "/api/campaigns", {
|
||||
name: "Custom Roll Campaign",
|
||||
rulesetId: "dnd5e"
|
||||
});
|
||||
await postJson(context.request, "/api/characters", {
|
||||
name: "Improviser",
|
||||
campaignId: campaign.id
|
||||
});
|
||||
|
||||
await page.goto("/play");
|
||||
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||
|
||||
const composer = page.locator(".custom-roll-composer");
|
||||
const input = page.locator("#custom-roll-expression");
|
||||
await input.fill("bad");
|
||||
await composer.getByRole("button", { name: "Roll" }).click();
|
||||
|
||||
await expect(input).toHaveClass(/error/);
|
||||
await expect(input).toHaveAttribute("title", /Expected dnd5e format like 2d12\+2\./);
|
||||
await expect(page.locator(".toast.error")).toHaveCount(0);
|
||||
|
||||
await input.fill("1d20+5");
|
||||
await composer.getByRole("button", { name: "Roll" }).click();
|
||||
|
||||
await expect(input).not.toHaveClass(/error/);
|
||||
await expect(page.locator(".log-panel .log-entry").first()).toContainText("Custom roll");
|
||||
});
|
||||
|
||||
test("Rolemaster UI exposes conditional create and edit fields", async ({ page, context }) => {
|
||||
const username = `rm-ui-${Date.now()}`;
|
||||
await registerAndLogin(context.request, username, "Rolemaster UI");
|
||||
|
||||
const campaign = await postJson(context.request, "/api/campaigns", {
|
||||
name: "Rolemaster UI Campaign",
|
||||
rulesetId: "rolemaster"
|
||||
});
|
||||
const character = await postJson(context.request, "/api/characters", {
|
||||
name: "UI Character",
|
||||
campaignId: campaign.id
|
||||
});
|
||||
await postJson(context.request, `/api/characters/${character.id}/skill-groups`, {
|
||||
name: "Awareness",
|
||||
diceRollDefinition: "d100!+15",
|
||||
wildDice: 0,
|
||||
allowFumble: false,
|
||||
fumbleRange: 5
|
||||
});
|
||||
await postJson(context.request, `/api/characters/${character.id}/skills`, {
|
||||
name: "Perception",
|
||||
diceRollDefinition: "d100!+25",
|
||||
wildDice: 0,
|
||||
allowFumble: false,
|
||||
fumbleRange: 5,
|
||||
rolemasterAutoRetry: true
|
||||
});
|
||||
|
||||
await page.goto("/play");
|
||||
await expect(page.locator("#workspace-screen-menu-button")).toBeVisible();
|
||||
|
||||
await page.locator("#workspace-screen-menu-button").click();
|
||||
await page.getByRole("menuitem", { name: "Campaign Management" }).click();
|
||||
await page.getByRole("button", { name: "Add campaign" }).click();
|
||||
await expect(page.locator("#campaign-ruleset option[value='rolemaster']")).toHaveText("Rolemaster");
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
|
||||
await page.locator("#workspace-screen-menu-button").click();
|
||||
await page.getByRole("menuitem", { name: "Play" }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Add group" }).click();
|
||||
await expect(page.locator("#skill-group-wild-dice")).toHaveCount(0);
|
||||
await expect(page.locator("#skill-group-expression")).toHaveValue("d100");
|
||||
await page.locator("#skill-group-expression").fill("d100!+15");
|
||||
await expect(page.locator("#skill-group-fumble-range")).toBeVisible();
|
||||
await page.locator("#skill-group-fumble-range").fill("");
|
||||
await page.getByRole("button", { name: "Create Group" }).click();
|
||||
await expect(page.getByText("Open-ended Rolemaster groups require a fumble range.")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Add skill" }).first().click();
|
||||
await expect(page.locator("#skill-create-expression")).toHaveValue("d100!+15");
|
||||
await page.locator("#skill-create-expression").fill("15d10");
|
||||
await expect(page.locator("#skill-create-fumble-range")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Automatic retry")).toHaveCount(0);
|
||||
await page.locator("#skill-create-expression").fill("d100!+25");
|
||||
await expect(page.locator("#skill-create-fumble-range")).toBeVisible();
|
||||
await expect(page.getByLabel("Automatic retry")).toBeVisible();
|
||||
await page.getByLabel("Automatic retry").check();
|
||||
await page.locator("#skill-create-expression").fill("d10");
|
||||
await expect(page.getByLabel("Automatic retry")).toHaveCount(0);
|
||||
await page.locator("#skill-create-expression").fill("d100!+25");
|
||||
await expect(page.getByLabel("Automatic retry")).toBeVisible();
|
||||
await expect(page.getByLabel("Automatic retry")).not.toBeChecked();
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
|
||||
await page.locator("button[title='Edit skill']").first().click();
|
||||
await expect(page.locator("#skill-edit-expression")).toHaveValue("d100!+25");
|
||||
await expect(page.locator("#skill-edit-fumble-range")).toHaveValue("5");
|
||||
await expect(page.getByLabel("Automatic retry")).toBeChecked();
|
||||
await page.locator("#skill-edit-expression").fill("d10");
|
||||
await expect(page.locator("#skill-edit-fumble-range")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Automatic retry")).toHaveCount(0);
|
||||
await page.locator("#skill-edit-expression").fill("d100!+25");
|
||||
await expect(page.getByLabel("Automatic retry")).toBeVisible();
|
||||
await expect(page.getByLabel("Automatic retry")).not.toBeChecked();
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
});
|
||||
Reference in New Issue
Block a user