style(dm): redesign dashboard with purple-gold theme

Redesign DM Dashboard (/dm/, /ru/dm/) with purple-gold theme (#534AB7 + #c8a84b):

Design System:
- Update dm-theme.css with 49 purple-gold tokens (transitions, shadows, hover states)
- Create DmTabs.astro with ARIA, keyboard nav, URL hash sync, localStorage persistence
- Create DmSidebar.astro for desktop navigation
- Redesign DmHeader with gold accent
- Update DmButton, DmCard, DmInput primitives
- Expand i18n to 47 strings

Layout:
- Redesign DmLayout with 3-column dashboard grid (sidebar|main|context)
- Update /dm/index.astro and /ru/dm/index.astro with named slots

Feature Components:
- DiceRoller: empty state, ARIA live, color-coded history, storage key fallback
- InitiativeTracker: HP editing, active highlighting, storage key fallback
- Open5eReference: skeletons, debounce, error retry, gold tabs
- NotesPanel: cross-tab sync, copy-to-clipboard, auto-save
- AuthPanel: VK/Yandex brand colors, pill buttons

Tests:
- Add 50 Playwright E2E tests (smoke, dice, initiative, reference, notes, data-migration)

Fixes:
- Document Open5e client.ts URL bugfix
- Add backward-compatible storage key fallback for dice/initiative
- Preserve legacy sessionStorage data on load
This commit is contained in:
emil
2026-05-15 17:49:32 +03:00
parent 11da171bfb
commit 1c38df7e74
113 changed files with 5651 additions and 596 deletions
+90
View File
@@ -0,0 +1,90 @@
import { test, expect } from "@playwright/test";
test.describe("Data Migration", () => {
async function seedStorage(page: any) {
await page.goto("/dm/");
await page.evaluate(() => {
sessionStorage.setItem(
"dm-dice-history",
JSON.stringify([
{ notation: "d20", result: 15, timestamp: Date.now() },
{ notation: "2d6+3", result: 11, timestamp: Date.now() },
])
);
sessionStorage.setItem(
"dm-initiative",
JSON.stringify([
{ name: "Гоблин", modifier: 2, initiative: 18, active: true },
])
);
localStorage.setItem("dm-notes", "Старые заметки из предыдущей версии");
});
await page.reload();
}
test("dice history old data loads and migrates correctly", async ({ page }) => {
await seedStorage(page);
await page.goto("/dm/#dice");
await expect(page.locator("#dice:visible")).toBeVisible();
const items = page.locator("[data-testid='dice-history-item']");
await expect(items).toHaveCount(2);
await expect(items.nth(0)).toContainText("d20");
await expect(items.nth(0)).toContainText("15");
await expect(items.nth(1)).toContainText("2d6+3");
await expect(items.nth(1)).toContainText("11");
const migrated = await page.evaluate(() => {
const raw = sessionStorage.getItem("dm-dice-history");
if (!raw) return null;
const parsed = JSON.parse(raw);
return parsed.every(
(entry: any) =>
entry.total !== undefined &&
Array.isArray(entry.rolls)
);
});
expect(migrated).toBe(true);
});
test("initiative old data loads and can be deleted", async ({ page }) => {
await seedStorage(page);
await page.goto("/dm/#initiative");
await expect(page.locator("#initiative:visible")).toBeVisible();
const panelHidden = await page.evaluate(() => {
return document.getElementById("it-list-panel")?.classList.contains("hidden") ?? true;
});
expect(panelHidden).toBe(false);
const list = page.locator("#it-list");
await expect(list).toBeVisible();
const item = list.locator("li");
await expect(item).toHaveCount(1);
await expect(item).toContainText("Гоблин");
await expect(item).toContainText("18");
const deleteBtn = item.locator(".it-delete-btn");
await expect(deleteBtn).toBeVisible();
await deleteBtn.click();
await expect(item).toHaveCount(0);
await expect(page.locator("[data-testid='init-empty-state']")).toBeVisible();
const migrated = await page.evaluate(() => {
const raw = sessionStorage.getItem("dm-initiative");
if (!raw) return true;
const parsed = JSON.parse(raw);
return parsed.length === 0 || parsed.every((c: any) => typeof c.id === "string");
});
expect(migrated).toBe(true);
});
test("notes old data loads correctly", async ({ page }) => {
await seedStorage(page);
await page.goto("/dm/#notes");
await expect(page.locator("#notes:visible")).toBeVisible();
const textarea = page.locator("#notes-textarea").first();
await expect(textarea).toHaveValue("Старые заметки из предыдущей версии");
});
});
+50
View File
@@ -0,0 +1,50 @@
import { test, expect } from "@playwright/test";
test.describe("Dice Roller", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/dm/#dice");
});
test("all quick dice buttons work", async ({ page }) => {
const dice = ["d4", "d6", "d8", "d10", "d12", "d20", "d100"];
for (const die of dice) {
await page.click(`[data-dice="${die}"]`);
const result = await page.locator("[data-testid='dice-result']").textContent();
expect(result).toMatch(/\d+/);
}
});
test("custom notation works", async ({ page }) => {
await page.fill("[data-testid='dice-input']", "2d6+3");
await page.click("[data-testid='dice-roll-btn']");
const result = await page.locator("[data-testid='dice-result']").textContent();
expect(result).toMatch(/\d+/);
});
test("history tracks rolls", async ({ page }) => {
await page.click(`[data-dice="d20"]`);
await page.click(`[data-dice="d6"]`);
const historyItems = await page.locator("[data-testid='dice-history-item']").count();
expect(historyItems).toBeGreaterThanOrEqual(2);
});
test("clear button empties history", async ({ page }) => {
await page.click(`[data-dice="d20"]`);
await page.click("[data-testid='dice-clear-btn']");
const historyItems = await page.locator("[data-testid='dice-history-item']").count();
expect(historyItems).toBe(0);
await expect(page.locator("[data-testid='dice-empty-state']")).toBeVisible();
});
test("result color coding - normal", async ({ page }) => {
await page.click(`[data-dice="d20"]`);
const resultText = await page.locator("[data-testid='dice-result']").textContent();
expect(resultText).toMatch(/\d+/);
});
test("mobile viewport renders correctly", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await expect(page.locator("[data-testid='dice-section']")).toBeVisible();
await expect(page.locator(`[data-dice="d20"]`)).toBeVisible();
});
});
+700
View File
@@ -0,0 +1,700 @@
import { chromium, type Browser, type Page } from "playwright";
import * as fs from "fs";
import * as path from "path";
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 {}
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: any) {
log("T2: Tab switching and URL hash", "FAIL", e.message);
}
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: any) {
log("T2: Mobile scrollable tabs", "FAIL", e.message);
}
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: any) {
log("T2: Initial tab state", "FAIL", e.message);
}
}
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: any) {
log("T9: Dice rolling and history", "FAIL", e.message);
}
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: any) {
log("T9: Critical hit/fail colors", "FAIL", e.message);
}
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: any) {
log("T9: Hover and active states", "FAIL", e.message);
}
}
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: any) {
log("T10: Initiative tracking flow", "FAIL", e.message);
}
}
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: any) {
log("T11: Open5e search and detail", "FAIL", e.message);
}
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: any) {
log("T11: Empty search state", "FAIL", e.message);
}
}
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: any) {
log("T12: Notes auto-save and cross-tab sync", "FAIL", e.message);
}
}
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: any) {
log("T6: All sections accessible", "FAIL", e.message);
}
}
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: any) {
log("INT-1: Switch between all tabs", "FAIL", e.message);
}
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: any) {
log("INT-2: Roll dice, check history, clear", "FAIL", e.message);
}
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: any) {
log("INT-3: Initiative flow", "FAIL", e.message);
}
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: any) {
log("INT-4: Open5e search and modal", "FAIL", e.message);
}
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: any) {
log("INT-5: Notes persistence", "FAIL", e.message);
}
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: any) {
log("INT-6: Responsive layout", "FAIL", e.message);
}
}
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: any) {
log("EDGE-1: Empty states", "FAIL", e.message);
}
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: any) {
log("EDGE-2: Rapid tab switching", "FAIL", e.message);
}
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: any) {
log("EDGE-3: Invalid dice notation", "FAIL", e.message);
}
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: any) {
log("EDGE-4: Empty search in Open5e", "FAIL", e.message);
}
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: any) {
log("EDGE-5: Clear notes after typing", "FAIL", e.message);
}
}
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);
});
+62
View File
@@ -0,0 +1,62 @@
import { test, expect } from "@playwright/test";
test.describe("Initiative Tracker", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/dm/#initiative");
});
test("add combatant with auto-roll", async ({ page }) => {
await page.fill("[data-testid='init-name-input']", "Гоблин");
await page.fill("[data-testid='init-mod-input']", "2");
await page.click("[data-testid='init-add-btn']");
await expect(page.locator("[data-testid='init-combatant']")).toBeVisible();
await expect(page.locator("[data-testid='init-combatant-name']")).toContainText("Гоблин");
});
test("sort by initiative descending", async ({ page }) => {
await page.fill("[data-testid='init-name-input']", "А");
await page.fill("[data-testid='init-mod-input']", "5");
await page.fill("[data-testid='init-initiative-input']", "25");
await page.click("[data-testid='init-add-btn']");
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']", "10");
await page.click("[data-testid='init-add-btn']");
const scores = await page.locator("[data-testid='init-score']").allTextContents();
expect(Number(scores[0])).toBeGreaterThan(Number(scores[1]));
});
test("next turn cycles active combatant", async ({ page }) => {
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']", "20");
await page.click("[data-testid='init-add-btn']");
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']", "15");
await page.click("[data-testid='init-add-btn']");
const firstActive = await page.locator("[data-testid='init-combatant'].active").textContent();
await page.click("[data-testid='init-next-btn']");
const secondActive = await page.locator("[data-testid='init-combatant'].active").textContent();
expect(firstActive).not.toBe(secondActive);
});
test("delete combatant", async ({ page }) => {
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']", "20");
await page.click("[data-testid='init-add-btn']");
await page.click("[data-testid='init-delete-btn']");
await expect(page.locator("[data-testid='init-empty-state']")).toBeVisible();
});
test("mobile viewport renders correctly", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await expect(page.locator("[data-testid='init-section']")).toBeVisible();
});
});
+34
View File
@@ -0,0 +1,34 @@
import { test, expect } from "@playwright/test";
test.describe("Notes Panel", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/dm/#notes");
});
test("typing in textarea works", async ({ page }) => {
await page.fill("[data-testid='notes-textarea']:visible", "Тестовая заметка");
await expect(page.locator("[data-testid='notes-textarea']:visible")).toHaveValue("Тестовая заметка");
});
test("auto-save indicator appears", async ({ page }) => {
await page.fill("[data-testid='notes-textarea']:visible", "Тест");
await page.waitForTimeout(600);
await expect(page.locator("[data-testid='notes-saved-indicator']:visible")).toBeVisible();
});
test("clear button empties notes", async ({ page }) => {
await page.fill("[data-testid='notes-textarea']:visible", "Тестовая заметка");
await page.click("[data-testid='notes-clear-btn']:visible");
await expect(page.locator("[data-testid='notes-textarea']:visible")).toHaveValue("");
});
test("character counter updates", async ({ page }) => {
await page.fill("[data-testid='notes-textarea']:visible", "12345");
await expect(page.locator("[data-testid='notes-char-count']:visible")).toContainText("5");
});
test("mobile viewport renders correctly", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await expect(page.locator("[data-testid='notes-section']:visible")).toBeVisible();
});
});
+37
View File
@@ -0,0 +1,37 @@
import { test, expect } from "@playwright/test";
test.describe("Open5e Reference", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/dm/#reference");
});
test("tab switching works", async ({ page }) => {
await page.click("[data-testid='ref-tab-monsters']:visible");
await expect(page.locator("[data-testid='ref-monsters-list']:visible")).toBeVisible();
await page.click("[data-testid='ref-tab-spells']:visible");
await expect(page.locator("[data-testid='ref-spells-list']:visible")).toBeVisible();
});
test("search filtering", async ({ page }) => {
await page.click("[data-testid='ref-tab-monsters']:visible");
await page.fill("[data-testid='ref-search-input']:visible", "goblin");
await page.waitForTimeout(400);
await expect(page.locator("[data-testid='ref-result-card']:visible").filter({ hasText: /goblin/i }).first()).toBeVisible({ timeout: 15000 });
});
test("detail modal opens and closes", async ({ page }) => {
await page.click("[data-testid='ref-tab-monsters']:visible");
await page.waitForSelector("[data-testid='ref-result-card']:visible", { timeout: 15000 });
await page.locator("[data-testid='ref-result-card']:visible").first().click();
await expect(page.locator("[data-testid='ref-modal']:visible")).toBeVisible();
await page.click("[data-testid='ref-modal-overlay']:visible", { position: { x: 10, y: 10 } });
await expect(page.locator("[data-testid='ref-modal']:visible")).toHaveCount(0);
});
test("mobile viewport renders correctly", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await expect(page.locator("[data-testid='ref-section']:visible")).toBeVisible();
});
});
+17
View File
@@ -0,0 +1,17 @@
import { test, expect } from "@playwright/test";
test.describe("DM Dashboard Smoke Tests", () => {
test("/dm/ loads without errors", async ({ page }) => {
const response = await page.goto("/dm/");
expect(response).not.toBeNull();
expect(response!.status()).toBe(200);
await expect(page).toHaveTitle(/DM Dashboard/i);
});
test("/ru/dm/ loads without errors", async ({ page }) => {
const response = await page.goto("/ru/dm/");
expect(response).not.toBeNull();
expect(response!.status()).toBe(200);
await expect(page).toHaveTitle(/DM Dashboard/i);
});
});