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.
This commit is contained in:
parent
2103f9fc69
commit
dbd5dfbc58
17 changed files with 2280 additions and 8 deletions
256
tests/mobile-test.mjs
Normal file
256
tests/mobile-test.mjs
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
/**
|
||||
* 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue