Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
707 lines
28 KiB
TypeScript
707 lines
28 KiB
TypeScript
import { chromium, type Browser, type Page } from "playwright";
|
|
import * as fs from "fs";
|
|
import * as path from "path";
|
|
|
|
function errMsg(e: unknown): string {
|
|
return e instanceof Error ? e.message : String(e);
|
|
}
|
|
|
|
const BASE_URL = "http://localhost:4322";
|
|
const EVIDENCE_DIR = ".sisyphus/evidence/final-qa";
|
|
|
|
fs.mkdirSync(EVIDENCE_DIR, { recursive: true });
|
|
|
|
let browser: Browser;
|
|
let page: Page;
|
|
|
|
const results: {
|
|
scenario: string;
|
|
status: "PASS" | "FAIL" | "SKIP";
|
|
details: string;
|
|
}[] = [];
|
|
|
|
function log(scenario: string, status: "PASS" | "FAIL" | "SKIP", details: string) {
|
|
results.push({ scenario, status, details });
|
|
const icon = status === "PASS" ? "✓" : status === "FAIL" ? "✗" : "○";
|
|
console.log(`[${icon}] ${scenario}: ${details}`);
|
|
}
|
|
|
|
async function screenshot(name: string) {
|
|
const filePath = path.join(EVIDENCE_DIR, `${name}.png`);
|
|
try {
|
|
await page.screenshot({ path: filePath, fullPage: true });
|
|
} catch {
|
|
// ignore screenshot errors (e.g., page not ready)
|
|
}
|
|
return filePath;
|
|
}
|
|
|
|
async function setup() {
|
|
browser = await chromium.launch();
|
|
}
|
|
|
|
async function newPage(viewport?: { width: number; height: number }) {
|
|
if (page) await page.close();
|
|
page = await browser.newPage(viewport ? { viewport } : undefined);
|
|
}
|
|
|
|
async function teardown() {
|
|
await browser.close();
|
|
}
|
|
|
|
async function safeGoto(url: string) {
|
|
try {
|
|
await page.goto(url, { waitUntil: "load", timeout: 15000 });
|
|
await page.waitForTimeout(500);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function testTabs() {
|
|
console.log("\n=== T2: Tabs ===");
|
|
|
|
try {
|
|
await newPage({ width: 375, height: 812 });
|
|
const ok = await safeGoto(`${BASE_URL}/dm/`);
|
|
if (!ok) throw new Error("Failed to load page");
|
|
|
|
const tab = page.locator('[data-tab="initiative"]:visible');
|
|
await tab.click();
|
|
await page.waitForTimeout(300);
|
|
|
|
const url = page.url();
|
|
const hasHash = url.includes("#initiative");
|
|
|
|
await page.locator('[data-tab="dice"]:visible').focus();
|
|
await page.keyboard.press("ArrowRight");
|
|
await page.waitForTimeout(200);
|
|
const focusedAfterArrow = await page.evaluate(() =>
|
|
document.activeElement?.getAttribute("data-tab")
|
|
);
|
|
|
|
const pass = hasHash && focusedAfterArrow === "initiative";
|
|
log("T2: Tab switching and URL hash", pass ? "PASS" : "FAIL", `hash=${hasHash}, keyboardFocused=${focusedAfterArrow}`);
|
|
await screenshot("t2-tabs-navigation");
|
|
} catch (e) {
|
|
log("T2: Tab switching and URL hash", "FAIL", errMsg(e));
|
|
}
|
|
|
|
try {
|
|
await newPage({ width: 375, height: 812 });
|
|
const ok = await safeGoto(`${BASE_URL}/dm/`);
|
|
if (!ok) throw new Error("Failed to load page");
|
|
|
|
const tabList = page.locator(".dm-tabs:visible");
|
|
const box = await tabList.boundingBox();
|
|
const tabs = await page.locator(".dm-tab:visible").all();
|
|
let allInRow = true;
|
|
let minHeightOk = true;
|
|
|
|
for (const t of tabs) {
|
|
const tbox = await t.boundingBox();
|
|
if (tbox && box && Math.abs(tbox.y - box.y) > 5) allInRow = false;
|
|
if (tbox && tbox.height < 44) minHeightOk = false;
|
|
}
|
|
|
|
log("T2: Mobile scrollable tabs", allInRow && minHeightOk ? "PASS" : "FAIL", `allInRow=${allInRow}, minHeightOk=${minHeightOk}, tabCount=${tabs.length}`);
|
|
await screenshot("t2-tabs-mobile");
|
|
} catch (e) {
|
|
log("T2: Mobile scrollable tabs", "FAIL", errMsg(e));
|
|
}
|
|
|
|
try {
|
|
await newPage({ width: 375, height: 812 });
|
|
const ok = await safeGoto(`${BASE_URL}/dm/#initiative`);
|
|
if (!ok) throw new Error("Failed to load page");
|
|
|
|
const ariaSelected = await page.locator('[data-tab="initiative"]:visible').getAttribute("aria-selected");
|
|
log("T2: Initial tab state", ariaSelected === "true" ? "PASS" : "FAIL", `aria-selected=${ariaSelected}`);
|
|
await screenshot("t2-tabs-initial");
|
|
} catch (e) {
|
|
log("T2: Initial tab state", "FAIL", errMsg(e));
|
|
}
|
|
}
|
|
|
|
async function testDice() {
|
|
console.log("\n=== T9: Dice ===");
|
|
|
|
try {
|
|
await newPage({ width: 1280, height: 720 });
|
|
const ok = await safeGoto(`${BASE_URL}/dm/#dice`);
|
|
if (!ok) throw new Error("Failed to load page");
|
|
|
|
await page.locator("[data-testid='dice-clear-btn']").first().click().catch(() => {});
|
|
await page.waitForTimeout(200);
|
|
|
|
await page.locator('[data-dice="d20"]').first().click();
|
|
await page.waitForTimeout(300);
|
|
|
|
const resultText = await page.locator("[data-testid='dice-result']").first().textContent();
|
|
const resultNum = parseInt(resultText || "0", 10);
|
|
const resultValid = resultNum >= 1 && resultNum <= 20;
|
|
|
|
await page.fill("[data-testid='dice-input']", "2d6+3");
|
|
await page.click("[data-testid='dice-roll-btn']");
|
|
await page.waitForTimeout(300);
|
|
|
|
const customResult = await page.locator("[data-testid='dice-result']").first().textContent();
|
|
const customNum = parseInt(customResult || "0", 10);
|
|
const customValid = customNum >= 5 && customNum <= 15;
|
|
|
|
const historyItems = await page.locator("[data-testid='dice-history-item']").count();
|
|
|
|
await page.locator("[data-testid='dice-clear-btn']").first().click();
|
|
await page.waitForTimeout(200);
|
|
const emptyVisible = await page.locator("[data-testid='dice-empty-state']").first().isVisible().catch(() => false);
|
|
|
|
const pass = resultValid && customValid && historyItems >= 2 && emptyVisible;
|
|
log("T9: Dice rolling and history", pass ? "PASS" : "FAIL", `d20=${resultNum}(valid=${resultValid}), 2d6+3=${customNum}(valid=${customValid}), history=${historyItems}, empty=${emptyVisible}`);
|
|
await screenshot("t9-dice-roller");
|
|
} catch (e) {
|
|
log("T9: Dice rolling and history", "FAIL", errMsg(e));
|
|
}
|
|
|
|
try {
|
|
await newPage({ width: 1280, height: 720 });
|
|
const ok = await safeGoto(`${BASE_URL}/dm/#dice`);
|
|
if (!ok) throw new Error("Failed to load page");
|
|
|
|
let foundCrit = false;
|
|
let foundFail = false;
|
|
|
|
for (let i = 0; i < 30; i++) {
|
|
await page.locator('[data-dice="d20"]').first().click();
|
|
await page.waitForTimeout(200);
|
|
const resultText = await page.locator("[data-testid='dice-result']").first().textContent();
|
|
const resultNum = parseInt(resultText || "0", 10);
|
|
if (resultNum === 20) foundCrit = true;
|
|
if (resultNum === 1) foundFail = true;
|
|
if (foundCrit && foundFail) break;
|
|
}
|
|
|
|
const historyItems = await page.locator("[data-testid='dice-history-item']").all();
|
|
let colorClassesFound = false;
|
|
for (const item of historyItems) {
|
|
const html = await item.innerHTML();
|
|
if (html.includes("text-[#6ab04c]") || html.includes("text-[#e06060]") || html.includes("text-[var(--gold)]")) {
|
|
colorClassesFound = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const pass = colorClassesFound;
|
|
log("T9: Critical hit/fail colors", pass ? "PASS" : "FAIL", `foundCrit=${foundCrit}, foundFail=${foundFail}, colorClasses=${colorClassesFound}`);
|
|
await screenshot("t9-dice-crit");
|
|
} catch (e) {
|
|
log("T9: Critical hit/fail colors", "FAIL", errMsg(e));
|
|
}
|
|
|
|
try {
|
|
await newPage({ width: 1280, height: 720 });
|
|
const ok = await safeGoto(`${BASE_URL}/dm/#dice`);
|
|
if (!ok) throw new Error("Failed to load page");
|
|
|
|
const d20Btn = page.locator('[data-dice="d20"]').first();
|
|
const hoverClassCheck = await d20Btn.evaluate((el) => el.classList.contains("hover:bg-[var(--gold)]/10"));
|
|
const activeClassCheck = await d20Btn.evaluate((el) => el.classList.contains("active:scale-[0.97]"));
|
|
|
|
const pass = hoverClassCheck && activeClassCheck;
|
|
log("T9: Hover and active states", pass ? "PASS" : "FAIL", `hoverClass=${hoverClassCheck}, activeClass=${activeClassCheck}`);
|
|
await screenshot("t9-dice-states");
|
|
} catch (e) {
|
|
log("T9: Hover and active states", "FAIL", errMsg(e));
|
|
}
|
|
}
|
|
|
|
async function testInitiative() {
|
|
console.log("\n=== T10: Initiative ===");
|
|
|
|
try {
|
|
await newPage({ width: 1280, height: 720 });
|
|
const ok = await safeGoto(`${BASE_URL}/dm/#initiative`);
|
|
if (!ok) throw new Error("Failed to load page");
|
|
|
|
await page.locator("#it-clear-btn").first().click().catch(() => {});
|
|
await page.waitForTimeout(200);
|
|
|
|
await page.fill("[data-testid='init-name-input']", "Гоблин");
|
|
await page.fill("[data-testid='init-mod-input']", "2");
|
|
await page.fill("[data-testid='init-initiative-input']", "18");
|
|
await page.click("[data-testid='init-add-btn']");
|
|
await page.waitForTimeout(300);
|
|
|
|
const goblinVisible = await page.locator("[data-testid='init-combatant']").first().isVisible().catch(() => false);
|
|
const goblinName = await page.locator("[data-testid='init-combatant-name']").first().textContent();
|
|
|
|
await page.fill("[data-testid='init-name-input']", "Орк");
|
|
await page.fill("[data-testid='init-mod-input']", "0");
|
|
await page.fill("[data-testid='init-initiative-input']", "12");
|
|
await page.click("[data-testid='init-add-btn']");
|
|
await page.waitForTimeout(300);
|
|
|
|
const scores = await page.locator("[data-testid='init-score']").allTextContents();
|
|
const sortedCorrectly = parseInt(scores[0]) > parseInt(scores[1]);
|
|
|
|
const firstActive = await page.locator("[data-testid='init-combatant'].active").first().textContent();
|
|
await page.click("[data-testid='init-next-btn']");
|
|
await page.waitForTimeout(200);
|
|
const secondActive = await page.locator("[data-testid='init-combatant'].active").first().textContent();
|
|
const turnChanged = firstActive !== secondActive;
|
|
|
|
await page.locator("[data-testid='init-delete-btn']").first().click();
|
|
await page.waitForTimeout(200);
|
|
const countAfterDelete = await page.locator("[data-testid='init-combatant']").count();
|
|
|
|
const pass = goblinVisible && goblinName?.includes("Гоблин") && sortedCorrectly && turnChanged && countAfterDelete === 1;
|
|
log("T10: Initiative tracking flow", pass ? "PASS" : "FAIL", `goblin=${goblinVisible}, sorted=${sortedCorrectly}, turnChanged=${turnChanged}, afterDelete=${countAfterDelete}`);
|
|
await screenshot("t10-initiative");
|
|
} catch (e) {
|
|
log("T10: Initiative tracking flow", "FAIL", errMsg(e));
|
|
}
|
|
}
|
|
|
|
async function testOpen5e() {
|
|
console.log("\n=== T11: Open5e ===");
|
|
|
|
try {
|
|
await newPage({ width: 1280, height: 720 });
|
|
const ok = await safeGoto(`${BASE_URL}/dm/#reference`);
|
|
if (!ok) throw new Error("Failed to load page");
|
|
await page.waitForTimeout(3000);
|
|
|
|
const resultsVisible = await page.locator("[data-testid='ref-result-card']:visible").first().isVisible().catch(() => false);
|
|
const emptyVisible = await page.locator("#o5-empty:visible").first().isVisible().catch(() => false);
|
|
|
|
await page.fill("[data-testid='ref-search-input']:visible", "goblin");
|
|
await page.waitForTimeout(800);
|
|
|
|
const searchResults = await page.locator("[data-testid='ref-result-card']:visible").count();
|
|
|
|
let modalOpened = false;
|
|
let modalClosed = false;
|
|
if (searchResults > 0) {
|
|
const firstCard = page.locator("[data-testid='ref-result-card']:visible").first();
|
|
await firstCard.scrollIntoViewIfNeeded();
|
|
await firstCard.click();
|
|
await page.waitForTimeout(1500);
|
|
modalOpened = await page.locator("[data-testid='ref-modal']:visible").first().isVisible().catch(() => false);
|
|
|
|
if (modalOpened) {
|
|
await page.keyboard.press("Escape");
|
|
await page.waitForTimeout(500);
|
|
modalClosed = !(await page.locator("[data-testid='ref-modal']:visible").first().isVisible().catch(() => false));
|
|
}
|
|
}
|
|
|
|
const pass = (resultsVisible || emptyVisible) && (searchResults > 0 || emptyVisible);
|
|
log("T11: Open5e search and detail", pass ? "PASS" : "FAIL", `results=${resultsVisible}, empty=${emptyVisible}, searchResults=${searchResults}, modalOpened=${modalOpened}, modalClosed=${modalClosed}`);
|
|
await screenshot("t11-open5e");
|
|
} catch (e) {
|
|
log("T11: Open5e search and detail", "FAIL", errMsg(e));
|
|
}
|
|
|
|
try {
|
|
await newPage({ width: 1280, height: 720 });
|
|
const ok = await safeGoto(`${BASE_URL}/dm/#reference`);
|
|
if (!ok) throw new Error("Failed to load page");
|
|
await page.waitForTimeout(3000);
|
|
|
|
await page.fill("[data-testid='ref-search-input']:visible", "xyznonexistent12345");
|
|
await page.waitForTimeout(1200);
|
|
|
|
const emptyVisible = await page.locator("#o5-empty:visible").first().isVisible().catch(() => false);
|
|
const noResults = await page.locator("[data-testid='ref-result-card']:visible").count() === 0;
|
|
log("T11: Empty search state", emptyVisible || noResults ? "PASS" : "FAIL", `emptyVisible=${emptyVisible}, noResults=${noResults}`);
|
|
await screenshot("t11-open5e-empty");
|
|
} catch (e) {
|
|
log("T11: Empty search state", "FAIL", errMsg(e));
|
|
}
|
|
}
|
|
|
|
async function testNotes() {
|
|
console.log("\n=== T12: Notes ===");
|
|
|
|
try {
|
|
await newPage({ width: 1280, height: 720 });
|
|
const ok = await safeGoto(`${BASE_URL}/dm/#notes`);
|
|
if (!ok) throw new Error("Failed to load page");
|
|
|
|
await page.fill("[data-testid='notes-textarea']:visible", "Тестовая заметка");
|
|
await page.waitForTimeout(1000);
|
|
|
|
const savedIndicator = page.locator("[data-testid='notes-saved-indicator']:visible").first();
|
|
const savedVisible = await savedIndicator.isVisible().catch(() => false);
|
|
const savedOpacity = await savedIndicator.evaluate((el) => {
|
|
const style = window.getComputedStyle(el);
|
|
return style.opacity;
|
|
});
|
|
|
|
const charCount = await page.locator("[data-testid='notes-char-count']:visible").first().textContent();
|
|
|
|
const page2 = await browser.newPage();
|
|
await page2.goto(`${BASE_URL}/dm/#notes`, { waitUntil: "load" });
|
|
await page2.waitForTimeout(1500);
|
|
|
|
const value2 = await page2.locator("[data-testid='notes-textarea']:visible").inputValue();
|
|
const syncWorked = value2 === "Тестовая заметка";
|
|
|
|
await page.click("[data-testid='notes-clear-btn']:visible");
|
|
await page.waitForTimeout(800);
|
|
|
|
const cleared = await page.locator("[data-testid='notes-textarea']:visible").inputValue() === "";
|
|
|
|
await page2.close();
|
|
|
|
const pass = savedVisible && parseFloat(savedOpacity) > 0.1 && charCount === "16" && syncWorked && cleared;
|
|
log("T12: Notes auto-save and cross-tab sync", pass ? "PASS" : "FAIL", `savedVisible=${savedVisible}, savedOpacity=${savedOpacity}, chars=${charCount}, sync=${syncWorked}, cleared=${cleared}`);
|
|
await screenshot("t12-notes-sync");
|
|
} catch (e) {
|
|
log("T12: Notes auto-save and cross-tab sync", "FAIL", errMsg(e));
|
|
}
|
|
}
|
|
|
|
async function testPage() {
|
|
console.log("\n=== T6: Page ===");
|
|
|
|
try {
|
|
await newPage({ width: 1280, height: 720 });
|
|
const ok = await safeGoto(`${BASE_URL}/dm/`);
|
|
if (!ok) throw new Error("Failed to load page");
|
|
|
|
const sections = ["dice", "initiative", "reference", "notes"];
|
|
let allAccessible = true;
|
|
|
|
for (const id of sections) {
|
|
const section = page.locator(`section#${id}:visible`).first();
|
|
const visible = await section.isVisible().catch(() => false);
|
|
if (!visible) {
|
|
allAccessible = false;
|
|
console.log(` Section #${id} not visible`);
|
|
}
|
|
}
|
|
|
|
log("T6: All sections accessible", allAccessible ? "PASS" : "FAIL", `allAccessible=${allAccessible}`);
|
|
await screenshot("t6-sections");
|
|
} catch (e) {
|
|
log("T6: All sections accessible", "FAIL", errMsg(e));
|
|
}
|
|
}
|
|
|
|
async function testIntegration() {
|
|
console.log("\n=== Integration Tests ===");
|
|
|
|
try {
|
|
await newPage({ width: 375, height: 812 });
|
|
const ok = await safeGoto(`${BASE_URL}/dm/`);
|
|
if (!ok) throw new Error("Failed to load page");
|
|
|
|
const tabs = ["dice", "initiative", "reference", "notes"];
|
|
let pass = true;
|
|
for (const tabId of tabs) {
|
|
await page.locator(`[data-tab="${tabId}"]:visible`).click();
|
|
await page.waitForTimeout(400);
|
|
const url = page.url();
|
|
if (!url.includes(`#${tabId}`)) pass = false;
|
|
}
|
|
log("INT-1: Switch between all tabs", pass ? "PASS" : "FAIL", `urlHashOk=${pass}`);
|
|
} catch (e) {
|
|
log("INT-1: Switch between all tabs", "FAIL", errMsg(e));
|
|
}
|
|
|
|
try {
|
|
await newPage({ width: 1280, height: 720 });
|
|
const ok = await safeGoto(`${BASE_URL}/dm/#dice`);
|
|
if (!ok) throw new Error("Failed to load page");
|
|
|
|
await page.locator("[data-testid='dice-clear-btn']").first().click().catch(() => {});
|
|
await page.waitForTimeout(200);
|
|
|
|
await page.locator('[data-dice="d20"]').first().click();
|
|
await page.waitForTimeout(200);
|
|
await page.locator('[data-dice="d6"]').first().click();
|
|
await page.waitForTimeout(200);
|
|
|
|
const historyCount = await page.locator("[data-testid='dice-history-item']").count();
|
|
await page.locator("[data-testid='dice-clear-btn']").first().click();
|
|
await page.waitForTimeout(200);
|
|
const emptyVisible = await page.locator("[data-testid='dice-empty-state']").first().isVisible().catch(() => false);
|
|
|
|
const pass = historyCount >= 2 && emptyVisible;
|
|
log("INT-2: Roll dice, check history, clear", pass ? "PASS" : "FAIL", `history=${historyCount}, empty=${emptyVisible}`);
|
|
} catch (e) {
|
|
log("INT-2: Roll dice, check history, clear", "FAIL", errMsg(e));
|
|
}
|
|
|
|
try {
|
|
await newPage({ width: 1280, height: 720 });
|
|
const ok = await safeGoto(`${BASE_URL}/dm/#initiative`);
|
|
if (!ok) throw new Error("Failed to load page");
|
|
|
|
await page.locator("#it-clear-btn").first().click().catch(() => {});
|
|
await page.waitForTimeout(200);
|
|
|
|
await page.fill("[data-testid='init-name-input']", "A");
|
|
await page.fill("[data-testid='init-initiative-input']", "25");
|
|
await page.click("[data-testid='init-add-btn']");
|
|
await page.waitForTimeout(200);
|
|
|
|
await page.fill("[data-testid='init-name-input']", "B");
|
|
await page.fill("[data-testid='init-initiative-input']", "10");
|
|
await page.click("[data-testid='init-add-btn']");
|
|
await page.waitForTimeout(200);
|
|
|
|
const scores = await page.locator("[data-testid='init-score']").allTextContents();
|
|
const sorted = parseInt(scores[0]) > parseInt(scores[1]);
|
|
|
|
const firstActive = await page.locator("[data-testid='init-combatant'].active").first().textContent();
|
|
await page.click("[data-testid='init-next-btn']");
|
|
await page.waitForTimeout(200);
|
|
const secondActive = await page.locator("[data-testid='init-combatant'].active").first().textContent();
|
|
const turnChanged = firstActive !== secondActive;
|
|
|
|
await page.locator("[data-testid='init-delete-btn']").first().click();
|
|
await page.waitForTimeout(200);
|
|
const countAfterDelete = await page.locator("[data-testid='init-combatant']").count();
|
|
|
|
const pass = sorted && turnChanged && countAfterDelete === 1;
|
|
log("INT-3: Initiative flow", pass ? "PASS" : "FAIL", `sorted=${sorted}, turnChanged=${turnChanged}, afterDelete=${countAfterDelete}`);
|
|
} catch (e) {
|
|
log("INT-3: Initiative flow", "FAIL", errMsg(e));
|
|
}
|
|
|
|
try {
|
|
await newPage({ width: 1280, height: 720 });
|
|
const ok = await safeGoto(`${BASE_URL}/dm/#reference`);
|
|
if (!ok) throw new Error("Failed to load page");
|
|
await page.waitForTimeout(3000);
|
|
|
|
await page.fill("[data-testid='ref-search-input']:visible", "dragon");
|
|
await page.waitForTimeout(600);
|
|
|
|
const cards = await page.locator("[data-testid='ref-result-card']:visible").count();
|
|
let modalOpened = false;
|
|
let modalClosed = false;
|
|
|
|
if (cards > 0) {
|
|
const firstCard = page.locator("[data-testid='ref-result-card']:visible").first();
|
|
await firstCard.scrollIntoViewIfNeeded();
|
|
await firstCard.click();
|
|
await page.waitForTimeout(1500);
|
|
modalOpened = await page.locator("[data-testid='ref-modal']:visible").first().isVisible().catch(() => false);
|
|
|
|
if (modalOpened) {
|
|
await page.keyboard.press("Escape");
|
|
await page.waitForTimeout(500);
|
|
modalClosed = !(await page.locator("[data-testid='ref-modal']:visible").first().isVisible().catch(() => false));
|
|
}
|
|
}
|
|
|
|
const pass = cards > 0 && modalOpened && modalClosed;
|
|
log("INT-4: Open5e search and modal", pass ? "PASS" : "FAIL", `cards=${cards}, modalOpened=${modalOpened}, modalClosed=${modalClosed}`);
|
|
} catch (e) {
|
|
log("INT-4: Open5e search and modal", "FAIL", errMsg(e));
|
|
}
|
|
|
|
try {
|
|
await newPage({ width: 1280, height: 720 });
|
|
const ok = await safeGoto(`${BASE_URL}/dm/#notes`);
|
|
if (!ok) throw new Error("Failed to load page");
|
|
|
|
await page.fill("[data-testid='notes-textarea']:visible", "Persistent note test");
|
|
await page.waitForTimeout(800);
|
|
|
|
await page.reload({ waitUntil: "load" });
|
|
await page.waitForTimeout(500);
|
|
|
|
const value = await page.locator("[data-testid='notes-textarea']:visible").inputValue();
|
|
const pass = value === "Persistent note test";
|
|
log("INT-5: Notes persistence", pass ? "PASS" : "FAIL", `value="${value}"`);
|
|
} catch (e) {
|
|
log("INT-5: Notes persistence", "FAIL", errMsg(e));
|
|
}
|
|
|
|
try {
|
|
await newPage({ width: 1440, height: 900 });
|
|
const ok = await safeGoto(`${BASE_URL}/dm/`);
|
|
if (!ok) throw new Error("Failed to load page");
|
|
await page.waitForTimeout(500);
|
|
|
|
const sidebarDesktop = await page.locator("aside >> visible=true").count() === 2;
|
|
|
|
await page.setViewportSize({ width: 375, height: 812 });
|
|
await page.waitForTimeout(500);
|
|
const sidebarMobile = await page.locator("aside >> visible=true").count() === 0;
|
|
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await page.waitForTimeout(500);
|
|
const sidebarBack = await page.locator("aside >> visible=true").count() === 2;
|
|
|
|
const pass = sidebarDesktop && sidebarMobile && sidebarBack;
|
|
log("INT-6: Responsive layout", pass ? "PASS" : "FAIL", `desktop=${sidebarDesktop}, mobile=${sidebarMobile}, back=${sidebarBack}`);
|
|
await screenshot("int6-responsive-desktop");
|
|
|
|
await page.setViewportSize({ width: 375, height: 812 });
|
|
await page.waitForTimeout(300);
|
|
await screenshot("int6-responsive-mobile");
|
|
} catch (e) {
|
|
log("INT-6: Responsive layout", "FAIL", errMsg(e));
|
|
}
|
|
}
|
|
|
|
async function testEdgeCases() {
|
|
console.log("\n=== Edge Cases ===");
|
|
|
|
try {
|
|
await newPage({ width: 1280, height: 720 });
|
|
let ok = await safeGoto(`${BASE_URL}/dm/#dice`);
|
|
if (!ok) throw new Error("Failed to load dice");
|
|
await page.locator("[data-testid='dice-clear-btn']").first().click().catch(() => {});
|
|
await page.waitForTimeout(200);
|
|
const diceEmpty = await page.locator("[data-testid='dice-empty-state']").first().isVisible().catch(() => false);
|
|
|
|
ok = await safeGoto(`${BASE_URL}/dm/#initiative`);
|
|
if (!ok) throw new Error("Failed to load initiative");
|
|
await page.locator("#it-clear-btn").first().click().catch(() => {});
|
|
await page.waitForTimeout(200);
|
|
const initEmpty = await page.locator("[data-testid='init-empty-state']").first().isVisible().catch(() => false);
|
|
|
|
ok = await safeGoto(`${BASE_URL}/dm/#notes`);
|
|
if (!ok) throw new Error("Failed to load notes");
|
|
await page.click("[data-testid='notes-clear-btn']:visible").catch(() => {});
|
|
await page.waitForTimeout(200);
|
|
const notesEmpty = await page.locator("[data-testid='notes-textarea']:visible").inputValue() === "";
|
|
|
|
const pass = diceEmpty && initEmpty && notesEmpty;
|
|
log("EDGE-1: Empty states", pass ? "PASS" : "FAIL", `dice=${diceEmpty}, init=${initEmpty}, notes=${notesEmpty}`);
|
|
await screenshot("edge1-empty-states");
|
|
} catch (e) {
|
|
log("EDGE-1: Empty states", "FAIL", errMsg(e));
|
|
}
|
|
|
|
try {
|
|
await newPage({ width: 375, height: 812 });
|
|
const ok = await safeGoto(`${BASE_URL}/dm/`);
|
|
if (!ok) throw new Error("Failed to load page");
|
|
|
|
const tabs = ["dice", "initiative", "reference", "notes"];
|
|
for (let i = 0; i < 10; i++) {
|
|
const tabId = tabs[i % tabs.length];
|
|
await page.locator(`[data-tab="${tabId}"]:visible`).click().catch(() => {});
|
|
}
|
|
await page.waitForTimeout(500);
|
|
|
|
const url = page.url();
|
|
const hasValidHash = tabs.some((t) => url.includes(`#${t}`));
|
|
log("EDGE-2: Rapid tab switching", hasValidHash ? "PASS" : "FAIL", `url=${url}`);
|
|
} catch (e) {
|
|
log("EDGE-2: Rapid tab switching", "FAIL", errMsg(e));
|
|
}
|
|
|
|
try {
|
|
await newPage({ width: 1280, height: 720 });
|
|
const ok = await safeGoto(`${BASE_URL}/dm/#dice`);
|
|
if (!ok) throw new Error("Failed to load page");
|
|
|
|
await page.fill("[data-testid='dice-input']", "invalid!!!");
|
|
await page.click("[data-testid='dice-roll-btn']");
|
|
await page.waitForTimeout(300);
|
|
|
|
const resultText = await page.locator("[data-testid='dice-result']").first().textContent();
|
|
const detailsText = await page.locator("#result-details").first().textContent();
|
|
const pass = resultText === "?" || detailsText?.includes("Invalid");
|
|
log("EDGE-3: Invalid dice notation", pass ? "PASS" : "FAIL", `result="${resultText}", details="${detailsText}"`);
|
|
} catch (e) {
|
|
log("EDGE-3: Invalid dice notation", "FAIL", errMsg(e));
|
|
}
|
|
|
|
try {
|
|
await newPage({ width: 1280, height: 720 });
|
|
const ok = await safeGoto(`${BASE_URL}/dm/#reference`);
|
|
if (!ok) throw new Error("Failed to load page");
|
|
await page.waitForTimeout(3000);
|
|
|
|
await page.fill("[data-testid='ref-search-input']:visible", "");
|
|
await page.waitForTimeout(500);
|
|
|
|
const hasResults = await page.locator("[data-testid='ref-result-card']:visible").count() > 0;
|
|
const hasEmptyState = await page.locator("#o5-empty:visible").first().isVisible().catch(() => false);
|
|
const noError = !(await page.locator("#o5-error:visible").first().isVisible().catch(() => false));
|
|
|
|
const pass = (hasResults || hasEmptyState) && noError;
|
|
log("EDGE-4: Empty search in Open5e", pass ? "PASS" : "FAIL", `results=${hasResults}, empty=${hasEmptyState}, noError=${noError}`);
|
|
} catch (e) {
|
|
log("EDGE-4: Empty search in Open5e", "FAIL", errMsg(e));
|
|
}
|
|
|
|
try {
|
|
await newPage({ width: 1280, height: 720 });
|
|
const ok = await safeGoto(`${BASE_URL}/dm/#notes`);
|
|
if (!ok) throw new Error("Failed to load page");
|
|
|
|
await page.fill("[data-testid='notes-textarea']:visible", "Note to clear");
|
|
await page.waitForTimeout(200);
|
|
await page.click("[data-testid='notes-clear-btn']:visible");
|
|
await page.waitForTimeout(200);
|
|
|
|
const value = await page.locator("[data-testid='notes-textarea']:visible").inputValue();
|
|
const charCount = await page.locator("[data-testid='notes-char-count']:visible").first().textContent();
|
|
const pass = value === "" && charCount === "0";
|
|
log("EDGE-5: Clear notes after typing", pass ? "PASS" : "FAIL", `value="${value}", chars=${charCount}`);
|
|
} catch (e) {
|
|
log("EDGE-5: Clear notes after typing", "FAIL", errMsg(e));
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
console.log("Starting Final QA Execution...");
|
|
console.log(`Base URL: ${BASE_URL}`);
|
|
|
|
await setup();
|
|
|
|
await testTabs();
|
|
await testDice();
|
|
await testInitiative();
|
|
await testOpen5e();
|
|
await testNotes();
|
|
await testPage();
|
|
await testIntegration();
|
|
await testEdgeCases();
|
|
|
|
await teardown();
|
|
|
|
console.log("\n========== FINAL QA REPORT ==========");
|
|
|
|
const scenarioResults = results.filter((r) => !r.scenario.startsWith("INT-") && !r.scenario.startsWith("EDGE-"));
|
|
const integrationResults = results.filter((r) => r.scenario.startsWith("INT-"));
|
|
const edgeResults = results.filter((r) => r.scenario.startsWith("EDGE-"));
|
|
|
|
console.log(`Scenarios: ${scenarioResults.filter((r) => r.status === "PASS").length}/${scenarioResults.length} pass`);
|
|
console.log(`Integration: ${integrationResults.filter((r) => r.status === "PASS").length}/${integrationResults.length}`);
|
|
console.log(`Edge Cases: ${edgeResults.filter((r) => r.status === "PASS").length}/${edgeResults.length}`);
|
|
|
|
for (const r of results) {
|
|
const icon = r.status === "PASS" ? "✓" : "✗";
|
|
console.log(` ${icon} ${r.scenario}: ${r.details}`);
|
|
}
|
|
|
|
const verdict = results.every((r) => r.status === "PASS") ? "APPROVE" : "REJECT";
|
|
console.log(`\nVERDICT: ${verdict}`);
|
|
|
|
const reportPath = path.join(EVIDENCE_DIR, "report.txt");
|
|
const reportLines = [
|
|
`Scenarios [${scenarioResults.filter((r) => r.status === "PASS").length}/${scenarioResults.length} pass] | Integration [${integrationResults.filter((r) => r.status === "PASS").length}/${integrationResults.length}] | Edge Cases [${edgeResults.length} tested] | VERDICT: ${verdict}`,
|
|
"",
|
|
"Details:",
|
|
...results.map((r) => `${r.status}: ${r.scenario} — ${r.details}`),
|
|
];
|
|
fs.writeFileSync(reportPath, reportLines.join("\n"));
|
|
console.log(`Report saved to: ${reportPath}`);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error("QA script failed:", err);
|
|
process.exit(1);
|
|
});
|