freezmo/tests/review-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

130 lines
5.5 KiB
JavaScript

/**
* Smart Review behaviour: the loading feedback (a stalled first request must
* never look like a dead tap), the session end-to-end, and the health-probe
* cache.
*/
import { BASE, DECK, dueSrs, launchBrowser, makeChecker, makePage, seed, seedLearning, wrongAnswer } from "./lib.mjs";
const { check, finish } = makeChecker();
const browser = await launchBrowser();
async function makeReviewPage(locale = "en") {
const { context, page } = await makePage(browser);
await seed(page, { locale, decks: [DECK] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(800);
await seedLearning(page, { answers: [wrongAnswer()], srs: [dueSrs()] });
await page.reload({ waitUntil: "networkidle" });
await page.waitForTimeout(900);
return { context, page };
}
/* 1. stalled navigation: loading screen appears at once ------------- */
{
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2 });
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
let stalled = 0;
await context.route("**/review*", async (route) => {
stalled += 1;
await gate;
await route.continue().catch(() => {});
});
const page = await context.newPage();
await seed(page, { decks: [DECK] });
await page.goto(BASE, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(800);
await seedLearning(page, { answers: [wrongAnswer()], srs: [dueSrs()] });
await page.reload({ waitUntil: "domcontentloaded" });
await page.waitForTimeout(900);
check("library shows the due count", await page.getByText(/Review due · 1 due/).isVisible());
const tClick = Date.now();
await page.getByText(/Review due · 1 due/).click();
let loadingAt = null;
for (let i = 0; i < 40; i++) {
if ((await page.getByText("Preparing your review…").count()) > 0) {
loadingAt = Date.now();
break;
}
await page.waitForTimeout(50);
}
check("loading screen appears immediately on tap", loadingAt !== null && loadingAt - tClick < 1000, `${loadingAt ? loadingAt - tClick : "never"} ms`);
check("still on the library while stalled", page.url().replace(BASE, "") === "/", page.url());
check("spinner is visible", (await page.getByRole("status").count()) >= 1);
release();
let sessionAt = null;
for (let i = 0; i < 200; i++) {
if ((await page.getByText("What colour is a clear daytime sky?").count()) > 0) {
sessionAt = Date.now();
break;
}
await page.waitForTimeout(50);
}
check("session arrives after the request releases", sessionAt !== null, `${sessionAt ? sessionAt - tClick : "never"} ms`);
check("loading screen is gone once the session is up", (await page.getByText("Preparing your review…").count()) === 0);
check("stalled requests were exercised", stalled > 0, String(stalled));
await context.close();
}
/* 2. normal flow stays fast; Czech copy; end-to-end grading ---------- */
{
const { context, page } = await makeReviewPage("cs");
const tClick = Date.now();
await page.getByText(/K opakování · 1 k opakování/).click();
let questionAt = null;
for (let i = 0; i < 200; i++) {
if ((await page.getByText("What colour is a clear daytime sky?").count()) > 0) {
questionAt = Date.now();
break;
}
await page.waitForTimeout(25);
}
check("cs: review opens fast without stalling", questionAt !== null && questionAt - tClick < 3000, `${questionAt ? questionAt - tClick : "never"} ms`);
check("cs: session feed present", (await page.locator('[data-testid="review-feed"]').count()) === 1);
await page.getByRole("button", { name: "Blue" }).click();
await page.waitForTimeout(500);
await page.getByRole("button", { name: "Zobrazit výsledky" }).click();
await page.waitForTimeout(700);
check("cs: results slide after answering", await page.getByText("100%").isVisible());
await page.locator('[data-testid="review-feed"]').getByRole("button", { name: "Zpět do knihovny" }).click();
await page.waitForTimeout(900);
check("cs: back in the library", await page.getByRole("heading", { name: "Tvoje mraznička" }).isVisible());
await context.close();
}
/* 3. mistakes path --------------------------------------------------- */
{
const { context, page } = await makeReviewPage("en");
check("mistakes link present", await page.getByText(/Mistakes · 1 mistake/).isVisible());
await page.getByText(/Mistakes · 1 mistake/).click();
await page.waitForTimeout(1200);
check("mistakes session opens", (await page.getByText("What colour is a clear daytime sky?").count()) > 0 && page.url().includes("mistakes=1"));
await context.close();
}
/* 4. health probes are deduplicated ---------------------------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [] });
let healthCount = 0;
page.on("request", (request) => {
if (new URL(request.url()).pathname === "/api/health") healthCount += 1;
});
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1500);
const afterLibrary = healthCount;
await page.locator("nav.tabbar").getByRole("link", { name: "Progress" }).click();
await page.waitForTimeout(1200);
await page.locator("nav.tabbar").getByRole("link", { name: "Library" }).click();
await page.waitForTimeout(1200);
check("library fires one health probe", afterLibrary <= 1, String(afterLibrary));
check("navigating reuses the cached probe", healthCount === afterLibrary, `${healthCount} total`);
await context.close();
}
await browser.close();
finish();