freezmo/tests/mobile-test.mjs
Martin Dvorak dbd5dfbc58 Move the e2e suites into the repo (tests/), out of /tmp
The browser harness lived in /tmp/opencode and an overnight reboot
wiped it. It now lives in tests/ and survives reboots, is versioned
and runs with one command.

- tests/lib.mjs: shared helpers (Chromium auto-detect, seeding,
  IndexedDB fixtures, stub/extra instance spawn with process-group
  cleanup, check/finish).
- tests/run-all.mjs + npm run test:e2e [filter]: runs the ten suites
  in order with a summary; suites that need a stub Ollama or VAPID
  keys start their own next start instance on 3208/3209, verify the
  port is free and kill the group on exit. Test scratch in
  tests/.tmp/ (gitignored).
- Suites rebuilt and green against the current build: regression 38,
  mobile 94 (tab matrix, ... sheet, settings sheet, axe), wizard 83,
  native 31 (PWA/offline/share/back/undo/haptics/draft), i18n 13,
  freeze-loading 6, review 15, phase1 36 (backup/sync/usage limits),
  phase2 42 (learning engine/reminders), phase3 21 (homework check).
  379 checks, 10/10 suites in one full run.
- playwright-core + axe-core added as devDependencies; CHROME env or
  the newest ~/.cache/ms-playwright chromium is used.
- README: new decision 45 documenting the harness; decision 44's
  verification note updated.

The older suites' exact sources were lost with the wipe, so the
rebuilt versions reconstruct the same feature areas rather than
being byte-identical.
2026-10-11 15:11:06 +00:00

256 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Phone suite: five-tab bottom bar (labels at 360/390 in EN/CS, dark/light,
* reduced motion), the library ⋯ sheet, the settings sheet (drag, touch
* targets, key flow), and an axe-core contrast audit.
*/
import { BASE, DECK, axeSource, launchBrowser, makeChecker, makePage, seed } from "./lib.mjs";
const { check, finish } = makeChecker();
const browser = await launchBrowser();
const box = (locator) => locator.boundingBox();
const dragDown = async (page, locator, dy = 170, steps = 9) => {
const bounds = await locator.boundingBox();
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await page.mouse.down();
for (let index = 1; index <= steps; index++) {
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + (dy * index) / steps);
await page.waitForTimeout(18);
}
await page.mouse.up();
};
/* 1. empty library: header, tab bar, ⋯ sheet ------------------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1100);
check("no hamburger on phones", (await page.getByRole("button", { name: "Open menu" }).count()) === 0);
check("no drawer on phones", (await page.getByRole("complementary", { name: "Menu" }).count()) === 0);
const themeBtn = page.getByRole("button", { name: "Switch to light mode" });
check("theme toggle present", await themeBtn.isVisible());
const themeBox = await box(themeBtn);
check("theme toggle >= 44px", themeBox.height >= 43.5 && themeBox.width >= 43.5, `${Math.round(themeBox.width)}x${Math.round(themeBox.height)}`);
check("exactly one theme toggle on phones", (await page.getByRole("button", { name: /Switch to (light|dark) mode/ }).count()) === 1);
const tabs = page.locator("nav.tabbar");
check("tab bar visible", await tabs.isVisible());
const tabTexts = (await tabs.locator("a, button").allInnerTexts()).map((text) => text.trim());
check(
"five tabs in order",
JSON.stringify(tabTexts) === JSON.stringify(["Library", "Progress", "New deck", "Exam", "Settings"]),
JSON.stringify(tabTexts),
);
const tabBoxes = await tabs.locator("a, button").evaluateAll((els) => els.map((el) => el.getBoundingClientRect().height));
check("every tab >= 44px tall", tabBoxes.every((height) => height >= 43.5), JSON.stringify(tabBoxes.map(Math.round)));
check("empty state hides the folder pills", (await page.locator(".folder-pill").count()) === 0);
const moreBtn = page.getByRole("button", { name: "More actions" });
check("⋯ button replaces the import icon", await moreBtn.isVisible());
const moreBox = await box(moreBtn);
check("⋯ >= 44px", moreBox.height >= 43.5 && moreBox.width >= 43.5, `${Math.round(moreBox.width)}x${Math.round(moreBox.height)}`);
await moreBtn.click();
await page.waitForTimeout(500);
const sheet = page.getByRole("dialog", { name: "Library actions" });
check(
"⋯ sheet lists Import JSON + New folder",
(await sheet.isVisible()) && (await sheet.getByText("Import JSON").isVisible()) && (await sheet.getByText("New folder").isVisible()),
);
await page.keyboard.press("Escape");
let sheetClosed = true;
try {
await sheet.waitFor({ state: "detached", timeout: 2500 });
} catch {
sheetClosed = false;
}
check("⋯ sheet closes with Escape", sheetClosed);
// Empty state checks before any folder exists.
const cta = page.getByRole("link", { name: "Create study deck", exact: true });
check("dedicated primary CTA", await cta.isVisible());
const ctaBox = await box(cta);
check("CTA >= 44px tall", ctaBox.height >= 43.5, String(Math.round(ctaBox.height)));
await page.getByRole("link", { name: "Homework", exact: true }).click();
await page.waitForTimeout(700);
const hwCta = page.getByRole("link", { name: "Make my homework", exact: true });
check("homework quick action", await hwCta.isVisible());
await page.getByRole("link", { name: "Study Decks", exact: true }).click();
await page.waitForTimeout(700);
// Creating a folder from the ⋯ sheet brings the chips row in.
await page.getByRole("button", { name: "More actions" }).click();
await page.waitForTimeout(500);
await page.getByRole("dialog").getByText("New folder").click();
await page.waitForTimeout(400);
await page.getByRole("dialog").locator("input").fill("Biology");
await page.getByRole("dialog").getByRole("button", { name: "Add" }).click();
await page.waitForTimeout(700);
check(
"⋯ sheet creates a folder",
(await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.folders.v1")).map((f) => f.name))).includes("Biology"),
);
check("folder chips appear once folders exist", (await page.locator(".folder-pill").count()) >= 1);
await context.close();
}
/* 2. tab-bar matrix: 360/390 × EN/CS × dark/light × reduced motion --- */
for (const locale of ["en", "cs"]) {
for (const width of [360, 390]) {
for (const theme of ["dark", "light"]) {
for (const reducedMotion of ["no-preference", "reduce"]) {
const label = `${locale}/${width}/${theme}/${reducedMotion === "reduce" ? "reduced" : "motion"}`;
const { context, page } = await makePage(browser, { width, height: 800, reducedMotion });
await seed(page, { locale, theme, decks: [] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(900);
const tabs = page.locator("nav.tabbar");
const visible = await tabs.isVisible();
const overflowX = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
const labels = await tabs.locator("a > span:last-child, button > span:last-child").evaluateAll((els) =>
els.map((el) => ({ text: el.innerText, clipped: el.scrollWidth > el.clientWidth + 1 })),
);
check(`${label}: tab bar visible, no overflow`, visible && overflowX <= 1, String(overflowX));
check(`${label}: five labels, none clipped`, labels.length === 5 && labels.every((entry) => !entry.clipped), JSON.stringify(labels.map((entry) => entry.text)));
check(`${label}: active indicator present`, (await tabs.locator('[aria-current="page"]').count()) === 1);
await context.close();
}
}
}
}
/* 3. Czech settings sheet: touch targets, key flow, drag -------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { locale: "cs", decks: [DECK], folders: [{ id: "f1", name: "Škola", createdAt: 1 }] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1100);
check("cs folder pills are 44px tall", (await box(page.locator(".folder-pill").first())).height >= 43.5);
await page.locator("nav.tabbar").getByRole("button").click();
const dialog = page.getByRole("dialog");
await dialog.waitFor({ timeout: 6000 });
await page.waitForTimeout(700);
check("cs sheet title", await dialog.getByRole("heading", { name: "Nastavení" }).isVisible());
const targets = [
["close", dialog.getByRole("button", { name: "Zavřít nastavení" })],
["Show", dialog.getByRole("button", { name: "Zobrazit" })],
["Re-check", dialog.getByRole("button", { name: "Zkontrolovat připojení" })],
["Remove key", dialog.getByRole("button", { name: "Odstranit klíč z prohlížeče" })],
["segmented", dialog.locator(".segmented > button").first()],
["privacy summary", dialog.locator("summary").first()],
];
for (const [name, locator] of targets) {
const bounds = await box(locator);
check(`${name} >= 44px`, bounds.width >= 43.5 && bounds.height >= 43.5, `${Math.round(bounds.width)}x${Math.round(bounds.height)}`);
}
check("Save hidden until the key is edited", (await dialog.getByRole("button", { name: "Uložit" }).count()) === 0);
const keyInput = dialog.locator("#ollama-api-key-phone");
await keyInput.fill("test-key2");
await page.waitForTimeout(200);
const save = dialog.getByRole("button", { name: "Uložit" });
check("Save appears after editing, >= 44px", (await save.isVisible()) && (await box(save)).height >= 43.5);
await keyInput.fill("test-key");
await page.waitForTimeout(200);
check("Save hides again when unchanged", (await dialog.getByRole("button", { name: "Uložit" }).count()) === 0);
const tip = dialog.locator("details").first();
check("privacy note starts collapsed", !(await tip.evaluate((el) => el.open)));
await tip.locator("summary").click();
await page.waitForTimeout(250);
check("privacy expander opens", await tip.evaluate((el) => el.open));
check("privacy expander mentions .env.local", await tip.getByText(".env.local").isVisible());
await tip.locator("summary").click();
await page.waitForTimeout(200);
// drag while scrolled won't dismiss, at the top it will
await page.locator(".sheet-scroll").evaluate((el) => (el.scrollTop = 120));
await page.waitForTimeout(250);
await dragDown(page, page.locator(".sheet-scroll"));
await page.waitForTimeout(700);
check("scrolled: drag snaps back, sheet stays", (await dialog.count()) === 1);
await page.locator(".sheet-scroll").evaluate((el) => (el.scrollTop = 0));
await page.waitForTimeout(250);
await dragDown(page, dialog.locator("h2"));
await page.waitForTimeout(800);
check("handle+header swipe closes the sheet", (await dialog.count()) === 0);
// backdrop tap
await page.locator("nav.tabbar").getByRole("button").click();
await dialog.waitFor({ timeout: 6000 });
const sheetBox = await box(dialog);
await page.mouse.click(195, Math.max(8, sheetBox.y - 24));
await page.waitForTimeout(800);
check("backdrop tap closes", (await dialog.count()) === 0);
// remove-key confirmation
await page.locator("nav.tabbar").getByRole("button").click();
await dialog.waitFor({ timeout: 6000 });
await dialog.getByRole("button", { name: "Odstranit klíč z prohlížeče" }).click();
await page.waitForTimeout(300);
check("confirm step appears", await dialog.getByText(/vrátí se celoobrazovková/).isVisible());
await dialog.getByRole("button", { name: "Zrušit" }).click();
await page.waitForTimeout(300);
check("cancel keeps the key", await dialog.getByRole("button", { name: "Odstranit klíč z prohlížeče" }).isVisible());
await dialog.getByRole("button", { name: "Odstranit klíč z prohlížeče" }).click();
await dialog.getByRole("button", { name: "Ano, odstranit" }).click();
await page.waitForTimeout(1400);
check("confirming brings the key gate back", await page.getByRole("heading", { name: "Připoj svůj klíč Ollama" }).isVisible());
await context.close();
}
/* 4. Czech at 360px: no overflow, key field fits --------------------- */
{
const { context, page } = await makePage(browser, { width: 360, height: 740 });
await seed(page, { locale: "cs", decks: [DECK] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1100);
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
check("360px: library has no horizontal overflow", overflow <= 1, String(overflow));
await page.locator("nav.tabbar").getByRole("button").click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(600);
const overflow2 = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
check("360px: settings has no horizontal overflow", overflow2 <= 1, String(overflow2));
const inputBox = await box(page.getByRole("dialog").locator("#ollama-api-key-phone"));
const panel = await box(page.getByRole("dialog"));
check("key field fits the sheet", inputBox.x >= panel.x && inputBox.x + inputBox.width <= panel.x + panel.width + 1);
await context.close();
}
/* 5. axe-core contrast audit (dark + light, en + cs) ----------------- */
const AXE = axeSource();
async function audit(label, { locale, theme, openSettingsNow }) {
const { context, page } = await makePage(browser);
await seed(page, { locale, theme, decks: [DECK] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1200);
if (openSettingsNow) {
await page.locator("nav.tabbar").getByRole("button").click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(600);
}
await page.addScriptTag({ content: AXE });
const report = await page.evaluate(async () => {
const result = await window.axe.run(document, { runOnly: ["color-contrast"] });
return {
violations: result.violations.map((violation) => ({
id: violation.id,
nodes: violation.nodes.map((node) => ({ target: node.target.join(" "), summary: node.failureSummary?.split("\n")[0] })),
})),
};
});
check(`axe ${label}: no contrast violations`, report.violations.length === 0, JSON.stringify(report.violations).slice(0, 300));
await context.close();
}
await audit("dark/en library", { locale: "en", theme: "dark" });
await audit("dark/cs library", { locale: "cs", theme: "dark" });
await audit("light/en library", { locale: "en", theme: "light" });
await audit("dark/en settings", { locale: "en", theme: "dark", openSettingsNow: true });
await audit("dark/cs settings", { locale: "cs", theme: "dark", openSettingsNow: true });
await browser.close();
finish();