freezmo/tests/regression.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

115 lines
5.9 KiB
JavaScript

/**
* Core regression: library, study feed, homework entry, review card, and the
* desktop shell + settings panel.
*/
import { BASE, DECK, launchBrowser, makeChecker, makePage, seed } from "./lib.mjs";
const { check, finish } = makeChecker();
const browser = await launchBrowser();
/* 1. desktop shell --------------------------------------------------- */
{
const { context, page } = await makePage(browser, { width: 1280, height: 900, deviceScaleFactor: 1 });
await seed(page, { decks: [DECK], folders: [{ id: "fold_1", name: "Biology", createdAt: 1 }] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(900);
check("library title", await page.getByRole("heading", { name: "Your icebox" }).isVisible());
const sidebar = page.getByRole("complementary", { name: "Sidebar" });
for (const row of ["New deck", "Make my homework", "All decks", "Unorganized", "Biology", "Progress", "Exam mode", "Settings"]) {
check(`sidebar row: ${row}`, (await sidebar.getByText(row, { exact: true }).count()) >= 1);
}
check("desktop hides the tab bar", !(await page.locator("nav.tabbar").isVisible()));
check("desktop keeps the import button", await page.getByRole("button", { name: "Import JSON" }).isVisible());
await page.getByRole("button", { name: "Collapse sidebar" }).click();
await page.waitForTimeout(400);
check("sidebar collapses", await page.getByRole("button", { name: "Expand sidebar" }).isVisible());
await page.getByRole("button", { name: "Expand sidebar" }).click();
await page.waitForTimeout(400);
check("sidebar expands again", await page.getByRole("button", { name: "Collapse sidebar" }).isVisible());
// Settings from the sidebar footer.
await sidebar.getByRole("button", { name: "Settings" }).click();
const dialog = page.getByRole("dialog");
await dialog.waitFor({ timeout: 6000 });
await page.waitForTimeout(700);
check("settings: connection card", await dialog.getByText("Connection").isVisible());
check("settings: key field", await dialog.locator("#ollama-api-key").count() === 1);
check("settings: learning section", await dialog.getByText("Learning").isVisible());
check("settings: data section", await dialog.getByText("Data & backup").isVisible());
check("settings: sync section", await dialog.getByText("Sync").first().isVisible());
// Health panel: it can take a while while the Ollama probe answers.
const visionRow = dialog.getByText("Vision model");
await visionRow.waitFor({ timeout: 25_000 }).catch(() => {});
check("health panel present (vision row)", (await visionRow.count()) >= 1);
check("text model row", (await dialog.getByText("Text model").count()) >= 1);
await page.keyboard.press("Escape");
await page.waitForTimeout(500);
await context.close();
}
/* 2. library: tabs, deck card, menu, folders ------------------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [DECK], folders: [{ id: "fold_1", name: "Biology", createdAt: 1 }] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(900);
check("library tabs present", await page.getByRole("link", { name: "Study Decks", exact: true }).isVisible());
check("deck card title", await page.getByText("Sky deck").first().isVisible());
check("deck meta line", (await page.getByText(/2 activities/).count()) >= 1);
check("folder pills visible with folders", (await page.locator(".folder-pill").count()) >= 1);
check("review card has no exam link", (await page.getByRole("link", { name: /Exam mode/ }).count()) === 0);
check("review card keeps due + mistakes", (await page.getByText(/Review due ·/).count()) >= 1 && (await page.getByText(/Mistakes ·/).count()) >= 1);
await page.getByRole("button", { name: "Deck actions" }).click();
await page.waitForTimeout(300);
for (const item of ["Rename", "Export JSON", "Share", "Regenerate deck", "Delete deck"]) {
check(`deck menu: ${item}`, (await page.getByRole("button", { name: item, exact: true }).count()) >= 1);
}
await page.keyboard.press("Escape");
await page.waitForTimeout(400);
await page.getByRole("link", { name: "Homework", exact: true }).click();
await page.waitForTimeout(600);
check("homework tab empty state", await page.getByRole("link", { name: "Make my homework", exact: true }).isVisible());
await context.close();
}
/* 3. study feed ------------------------------------------------------ */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [DECK] });
await page.goto(`${BASE}/deck/seed_deck`, { waitUntil: "networkidle" });
await page.waitForTimeout(900);
const firstSlide = page.getByRole("region", { name: /Activity 1 of/ });
check("feed: first activity", await firstSlide.getByText("What colour is a clear daytime sky?").isVisible());
await page.getByRole("button", { name: "Green" }).click();
await page.waitForTimeout(500);
check("feed: wrong answer shows the correct one", await page.getByText(/Correct answer/i).first().isVisible());
await page.getByRole("button", { name: "Next" }).click();
await page.waitForTimeout(800);
await page.getByRole("button", { name: "Scattering" }).click();
await page.waitForTimeout(500);
await page.getByRole("button", { name: "Next" }).click();
await page.waitForTimeout(900);
check("feed: results appear", await page.getByText(/50%/).first().isVisible());
check("feed: results summary", (await page.getByText(/You got 1 of 2 right/).count()) >= 1);
await context.close();
}
/* 4. homework entry -------------------------------------------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [] });
await page.goto(`${BASE}/homework`, { waitUntil: "networkidle" });
await page.waitForTimeout(800);
check("homework: step headline", await page.getByRole("heading", { name: "The assignment" }).isVisible());
check("homework: empty placeholder", await page.getByText("Nothing added yet").isVisible());
await context.close();
}
await browser.close();
finish();