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.
213 lines
12 KiB
JavaScript
213 lines
12 KiB
JavaScript
/**
|
|
* The two wizards: 3-step deck flow and 2-step homework flow, the action bar,
|
|
* the in-page mode switch, and the loading lock (a generating run cannot be
|
|
* swapped mid-flight; leaving it aborts the request).
|
|
*/
|
|
import { BASE, DECK, launchBrowser, makeChecker, makePage, seed } from "./lib.mjs";
|
|
|
|
const { check, finish } = makeChecker();
|
|
const browser = await launchBrowser();
|
|
|
|
/** Samples the flat action bar's position across the entrance. */
|
|
async function sampleBar(page, label) {
|
|
const samples = [];
|
|
for (const delay of [0, 60, 140, 260, 450]) {
|
|
if (delay) await page.waitForTimeout(delay === 60 ? 60 : 80);
|
|
const state = await page.evaluate(() => {
|
|
const bar = document.querySelector("[data-wizard-bar]");
|
|
if (!bar) return null;
|
|
const box = bar.getBoundingClientRect();
|
|
return { bottom: Math.round(box.bottom), width: Math.round(box.width), viewport: window.innerHeight };
|
|
});
|
|
samples.push({ delay, state });
|
|
}
|
|
const present = samples.filter((sample) => sample.state);
|
|
const pinned = present.length >= 3 && present.every((sample) => Math.abs(sample.state.bottom - sample.state.viewport) <= 2);
|
|
check(`${label}: bar pinned to the bottom whenever visible`, pinned, JSON.stringify(present.map((s) => `${s.delay}ms:${s.state.bottom}/${s.state.viewport}`)));
|
|
const fullWidth = present.length >= 3 && present.every((sample) => sample.state.width >= 300);
|
|
check(`${label}: bar is a flat full-width bar`, fullWidth, JSON.stringify(present.map((s) => s.state.width)));
|
|
}
|
|
|
|
/* 1. deck wizard: entry, steps, summary, both sizes/languages/themes */
|
|
for (const viewport of [{ width: 390, height: 844 }, { width: 360, height: 740 }]) {
|
|
for (const [locale, theme] of [["en", "dark"], ["cs", "light"]]) {
|
|
const label = `${viewport.width}/${locale}/${theme}`;
|
|
const { context, page } = await makePage(browser, viewport);
|
|
await seed(page, { locale, theme, decks: [DECK] });
|
|
const cs = locale === "cs";
|
|
|
|
await page.goto(BASE, { waitUntil: "networkidle" });
|
|
await page.waitForTimeout(900);
|
|
await page.locator("nav.tabbar").getByRole("link", { name: cs ? "Nová sada" : "New deck" }).click();
|
|
await sampleBar(page, `${label} tab bar → create`);
|
|
check(`${label}: create headline is the step name`, await page.getByRole("heading", { name: cs ? "Tvoje poznámky" : "Your notes" }).isVisible());
|
|
check(`${label}: step 1 shows the accepted-formats hint`, await page.getByText(/(Fotky, PDF|Photos, PDF)/).first().isVisible());
|
|
check(`${label}: step 1 empty placeholder`, await page.getByText(cs ? "Zatím nic přidáno" : "Nothing added yet").isVisible());
|
|
check(`${label}: Next disabled with nothing added`, await page.getByRole("button", { name: cs ? "Další" : "Next" }).isDisabled());
|
|
check(`${label}: step 1 has Cancel, not Back`, (await page.getByRole("button", { name: cs ? "Zrušit" : "Cancel" }).count()) === 1);
|
|
check(`${label}: no tab bar inside the wizard`, (await page.locator("nav.tabbar").count()) === 0);
|
|
|
|
await page.getByRole("button", { name: cs ? "Místo toho vložit text" : "Paste text instead" }).click();
|
|
await page.locator("textarea").first().fill("Photosynthesis needs light and water.");
|
|
await page.waitForTimeout(200);
|
|
check(`${label}: Next enables after pasting`, await page.getByRole("button", { name: cs ? "Další" : "Next" }).isEnabled());
|
|
await page.getByRole("button", { name: cs ? "Další" : "Next" }).click();
|
|
await page.waitForTimeout(400);
|
|
check(`${label}: step 2 shows difficulty label`, await page.getByText(cs ? "Obtížnost" : "Difficulty").first().isVisible());
|
|
await page.getByRole("button", { name: cs ? "Další" : "Next" }).click();
|
|
await page.waitForTimeout(400);
|
|
check(`${label}: step 3 headline`, await page.getByRole("heading", { name: cs ? "Připraveno ke zmrazení" : "Ready to freeze" }).isVisible());
|
|
check(
|
|
`${label}: summary rows present`,
|
|
(await page.getByText(cs ? "Soubory" : "Files", { exact: true }).count()) >= 1 &&
|
|
(await page.getByText(cs ? "Složka" : "Folder", { exact: true }).count()) >= 1,
|
|
);
|
|
check(`${label}: Freeze enabled with content`, await page.getByRole("button", { name: cs ? "Zmrazit sadu" : "Freeze my deck" }).isEnabled());
|
|
await page.getByRole("button", { name: cs ? "Upravit" : "Edit" }).first().click();
|
|
await page.waitForTimeout(400);
|
|
check(`${label}: Files Edit jumps to step 1`, await page.getByText("1 / 3").isVisible());
|
|
await context.close();
|
|
}
|
|
}
|
|
|
|
/* 2. entry from the empty state and the mode switch ------------------ */
|
|
{
|
|
const { context, page } = await makePage(browser);
|
|
await seed(page, { decks: [] });
|
|
await page.goto(BASE, { waitUntil: "networkidle" });
|
|
await page.waitForTimeout(900);
|
|
|
|
await page.getByRole("link", { name: "Create study deck", exact: true }).click();
|
|
await sampleBar(page, "empty state → create");
|
|
await page.getByRole("button", { name: "Cancel" }).click();
|
|
await page.waitForTimeout(600);
|
|
check("Cancel returns to the library", await page.getByRole("heading", { name: "Your icebox" }).isVisible());
|
|
|
|
await page.getByRole("link", { name: "Homework", exact: true }).click();
|
|
await page.waitForTimeout(600);
|
|
await page.getByRole("link", { name: "Make my homework", exact: true }).click();
|
|
await sampleBar(page, "homework empty state → homework");
|
|
check("homework headline is the step name", await page.getByRole("heading", { name: "The assignment" }).isVisible());
|
|
check("homework Next disabled with nothing", await page.getByRole("button", { name: "Next" }).isDisabled());
|
|
check("homework has Cancel on step 1", (await page.getByRole("button", { name: "Cancel" }).count()) === 1);
|
|
check("homework shows the empty placeholder", await page.getByText("Nothing added yet").isVisible());
|
|
check("no tab bar on homework wizard", (await page.locator("nav.tabbar").count()) === 0);
|
|
|
|
await page.goto(`${BASE}/create`, { waitUntil: "networkidle" });
|
|
await page.waitForTimeout(800);
|
|
const switcher = page.getByRole("group", { name: "Study deck or homework" });
|
|
check("mode switch on the New deck screen", await switcher.isVisible());
|
|
check("mode switch marks the deck side active", (await switcher.locator('[aria-current="page"]').innerText()) === "Study deck");
|
|
await switcher.getByRole("link", { name: "Make my homework" }).click();
|
|
await page.waitForTimeout(900);
|
|
check("mode switch lands on the homework wizard", await page.getByRole("heading", { name: "The assignment" }).isVisible());
|
|
check(
|
|
"mode switch marks the homework side active",
|
|
(await page.getByRole("group", { name: "Study deck or homework" }).locator('[aria-current="page"]').innerText()) === "Make my homework",
|
|
);
|
|
await page.getByRole("group", { name: "Study deck or homework" }).getByRole("link", { name: "Study deck" }).click();
|
|
await page.waitForTimeout(900);
|
|
check("mode switch returns to the deck wizard", await page.getByRole("heading", { name: "Your notes" }).isVisible());
|
|
await context.close();
|
|
}
|
|
|
|
/* 3. reduced motion: bar still pinned, wizard usable ----------------- */
|
|
{
|
|
const { context, page } = await makePage(browser, { reducedMotion: "reduce" });
|
|
await seed(page, { decks: [] });
|
|
await page.goto(`${BASE}/create`, { waitUntil: "networkidle" });
|
|
await sampleBar(page, "reduced motion → create");
|
|
check("reduced: step 1 usable", await page.getByRole("heading", { name: "Your notes" }).isVisible());
|
|
await page.getByRole("button", { name: "Cancel" }).click();
|
|
await page.waitForTimeout(400);
|
|
check("reduced: cancel works", await page.getByRole("heading", { name: "Your icebox" }).isVisible());
|
|
await context.close();
|
|
}
|
|
|
|
/* 4. a loading run locks the mode switch ----------------------------- */
|
|
{
|
|
const { context, page } = await makePage(browser);
|
|
await seed(page, { decks: [] });
|
|
await page.route("**/api/generate", () => new Promise(() => {}));
|
|
await page.route("**/api/homework", () => new Promise(() => {}));
|
|
|
|
await page.goto(`${BASE}/create`, { waitUntil: "networkidle" });
|
|
await page.waitForTimeout(800);
|
|
await page.getByRole("button", { name: "Paste text instead" }).click();
|
|
await page.locator("textarea").first().fill("Photosynthesis needs light and water.");
|
|
await page.getByRole("button", { name: "Next" }).click();
|
|
await page.waitForTimeout(300);
|
|
await page.getByRole("button", { name: "Next" }).click();
|
|
await page.waitForTimeout(300);
|
|
check("loading: mode switch is there before the run", await page.getByRole("group", { name: "Study deck or homework" }).isVisible());
|
|
await page.getByRole("button", { name: "Freeze my deck" }).click();
|
|
await page.waitForTimeout(800);
|
|
check("loading: deck run reaches the loading screen", await page.getByRole("button", { name: "Cancel" }).isVisible());
|
|
check("loading: deck run hides the mode switch", (await page.getByRole("group", { name: "Study deck or homework" }).count()) === 0);
|
|
await page.getByRole("button", { name: "Cancel" }).click();
|
|
await page.waitForTimeout(900);
|
|
check(
|
|
"loading: deck Cancel returns to the wizard (notice + retry)",
|
|
(await page.getByRole("heading", { name: "Ready to freeze" }).isVisible()) && (await page.getByRole("button", { name: "Retry" }).isVisible()),
|
|
);
|
|
check("loading: mode switch returns after the cancel", await page.getByRole("group", { name: "Study deck or homework" }).isVisible());
|
|
|
|
await page.goto(`${BASE}/homework`, { waitUntil: "networkidle" });
|
|
await page.waitForTimeout(700);
|
|
await page.getByRole("button", { name: "Paste text instead" }).click();
|
|
await page.locator("textarea").first().fill("Solve x + 2 = 4");
|
|
await page.getByRole("button", { name: "Next" }).click();
|
|
await page.waitForTimeout(300);
|
|
await page.getByRole("button", { name: /Just make it/ }).click();
|
|
await page.waitForTimeout(800);
|
|
check("loading: homework run hides the mode switch", (await page.getByRole("group", { name: "Study deck or homework" }).count()) === 0);
|
|
await page.getByRole("button", { name: "Cancel" }).click();
|
|
await page.waitForTimeout(900);
|
|
check("loading: homework Cancel returns to the wizard", await page.getByRole("heading", { name: "The assignment" }).isVisible());
|
|
await context.close();
|
|
}
|
|
|
|
/* 5. leaving mid-run aborts: no surprise deck, no navigation --------- */
|
|
{
|
|
const { context, page } = await makePage(browser);
|
|
await seed(page, { decks: [] });
|
|
const deckReply = {
|
|
id: "stall",
|
|
title: "Aborted run",
|
|
subject: "Biology",
|
|
summary: "",
|
|
language: "English",
|
|
sourceText: "Photosynthesis needs light and water.",
|
|
difficulty: "medium",
|
|
activities: [{ type: "flashcard", id: "a1", xp: 10, front: "Q", back: "A", explanation: "E" }],
|
|
};
|
|
await page.route("**/api/generate", async (route) => {
|
|
await new Promise((resolve) => setTimeout(resolve, 2500));
|
|
await route
|
|
.fulfill({ status: 200, contentType: "application/x-ndjson", body: JSON.stringify({ type: "deck", deck: deckReply }) + "\n" })
|
|
.catch(() => {});
|
|
});
|
|
|
|
await page.goto(`${BASE}/create`, { waitUntil: "networkidle" });
|
|
await page.waitForTimeout(800);
|
|
await page.getByRole("button", { name: "Paste text instead" }).click();
|
|
await page.locator("textarea").first().fill("Photosynthesis needs light and water.");
|
|
await page.getByRole("button", { name: "Next" }).click();
|
|
await page.waitForTimeout(300);
|
|
await page.getByRole("button", { name: "Next" }).click();
|
|
await page.waitForTimeout(300);
|
|
await page.getByRole("button", { name: "Freeze my deck" }).click();
|
|
await page.waitForTimeout(600);
|
|
await page.locator("header").getByRole("link", { name: "Freezmo home" }).click();
|
|
await page.waitForTimeout(3500);
|
|
const decksAfter = await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.decks.v1") ?? "[]").length);
|
|
check(
|
|
"loading: leaving aborts the run (no deck, no navigation)",
|
|
page.url().replace(BASE, "") === "/" && decksAfter === 0,
|
|
`${page.url()} decks:${decksAfter}`,
|
|
);
|
|
await context.close();
|
|
}
|
|
|
|
await browser.close();
|
|
finish();
|