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:
Martin Dvořák 2026-10-11 15:11:06 +00:00
commit dbd5dfbc58
17 changed files with 2280 additions and 8 deletions

46
tests/README.md Normal file
View file

@ -0,0 +1,46 @@
# Browser suites
End-to-end checks for Freezmo, driven by Playwright against a **running
production build**. Everything lives in this repo (nothing in `/tmp`), so a
reboot cannot wipe the harness.
## Requirements
- The app is running on `http://127.0.0.1:3207` (`systemctl --user start freezmo`).
- A Chromium build. The suites auto-detect the newest Playwright Chromium in
`~/.cache/ms-playwright/`; override with `CHROME=/path/to/chrome`.
- `npm install` (dev deps: `playwright-core`, `axe-core`).
## Running
```bash
npm run build && systemctl --user restart freezmo # fresh build first
npm run test:e2e # every suite, in order
npm run test:e2e -- phase1 # only suites whose name contains "phase1"
```
Each suite prints `PASS`/`FAIL` lines and exits non-zero on failure; the runner
prints a per-suite summary at the end.
## Suites
| Suite | Covers |
| --- | --- |
| `regression.mjs` | Library, deck card + menu, study feed to results, homework entry, desktop shell + settings/health panel. |
| `mobile-test.mjs` | Five-tab bar at 360/390 × EN/CS × dark/light × reduced motion (labels, targets, no hamburger/drawer), the library ⋯ sheet (Import JSON / New folder), the settings sheet (touch targets, key flow, drag-to-close, backdrop), axe contrast audits. |
| `wizard-test.mjs` | Deck 3-step flow and homework flow, flat action bar, in-page mode switch, the loading lock, abort-on-leave. |
| `native-test.mjs` | Manifest/PWA head, offline banner + disabled generate, share target GET + POST, Back-gesture layers, undo toast, tab re-tap, haptics, draft restore. |
| `i18n-test.mjs` | EN/CS dictionary parity, locale switch, Czech desktop shell, Czech phone tab labels and plurals. |
| `freeze-loading-test.mjs` | The generation loading screen appears immediately, hides the action bar, Cancel returns with the notice. |
| `review-test.mjs` | Smart Review loading feedback under a stalled request, the session end-to-end, mistakes, and the health-probe cache. |
| `phase1-test.mjs` | Backup export/import, persistence row, nudge, encrypted sync (enable/join/push/delete + API validation), usage-limit handling (client + a stub Ollama). |
| `phase2-test.mjs` | Answer history + SRS from a real study run, review/mistakes, stats, exam mode, push reminders hidden without VAPID and validated with it. |
| `phase3-test.mjs` | Check my work (API + phone/desktop UI), follow-ups, guide step explain, turn-into-a-study-deck. |
## Extra instances
`phase1`, `phase2` and `phase3` start their own `next start` instance with a
stub Ollama (or VAPID keys) on a spare port (3208/3209). Each suite checks the
port is free first and kills the whole process group on exit, so ports do not
leak between suites. Scratch data for the VAPID test lives in `tests/.tmp/`
(gitignored).

View file

@ -0,0 +1,45 @@
/**
* Freeze loading screen: it must appear the moment generation starts, keep
* the wizard's action bar out of the way, and Cancel must return to the
* wizard with the cancelled notice (not a stuck spinner).
*/
import { BASE, launchBrowser, makeChecker, makePage, seed } from "./lib.mjs";
const { check, finish } = makeChecker();
const browser = await launchBrowser();
const { context, page } = await makePage(browser);
await seed(page, { decks: [] });
// Hold the generation so the loading screen stays up.
await page.route("**/api/generate", () => 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);
const tClick = Date.now();
await page.getByRole("button", { name: "Freeze my deck" }).click();
let appeared = true;
await page
.locator('img[src="/icons/icon-512.png"]')
.waitFor({ state: "visible", timeout: 2000 })
.catch(() => {
appeared = false;
});
check("loading screen appears immediately after Freeze", appeared, `${Date.now() - tClick} ms`);
check("stage message visible", await page.getByText(/Reading your notes|Finding the key ideas/).first().isVisible().catch(() => false));
check("cancel available", await page.getByRole("button", { name: "Cancel" }).isVisible().catch(() => false));
check("wizard bar is gone", (await page.locator("[data-wizard-bar]").count()) === 0);
await page.getByRole("button", { name: "Cancel" }).click();
await page.waitForTimeout(700);
check("cancel returns to the wizard", (await page.locator("[data-wizard-bar]").count()) === 1);
check("cancel shows the notice + retry", await page.getByRole("button", { name: "Retry" }).isVisible());
await context.close();
await browser.close();
finish();

71
tests/i18n-test.mjs Normal file
View file

@ -0,0 +1,71 @@
/**
* i18n suite: EN/CS dictionary parity, the locale switch, Czech copy on the
* desktop shell and the phone tab bar, and Czech plurals.
*/
import { readFileSync } from "node:fs";
import path from "node:path";
import { BASE, DECK, REPO, launchBrowser, makeChecker, makePage, seed } from "./lib.mjs";
const { check, finish } = makeChecker();
const browser = await launchBrowser();
/* 1. dictionary parity ----------------------------------------------- */
{
const source = readFileSync(path.join(REPO, "lib/i18n-dict.ts"), "utf8");
const split = source.indexOf("const cs");
const pattern = /^\s{2}"([^"]+)":/gm;
const en = [...source.slice(0, split).matchAll(pattern)].map((match) => match[1]);
const cs = [...source.slice(split).matchAll(pattern)].map((match) => match[1]);
const missingInCs = en.filter((key) => !cs.includes(key));
const missingInEn = cs.filter((key) => !en.includes(key));
check("key parity: same keys in both dictionaries", missingInCs.length === 0 && missingInEn.length === 0, JSON.stringify({ missingInCs, missingInEn }).slice(0, 200));
check("dictionary is substantial", en.length > 700, String(en.length));
}
/* 2. Czech desktop shell --------------------------------------------- */
{
const { context, page } = await makePage(browser, { width: 1280, height: 900, deviceScaleFactor: 1 });
await seed(page, { locale: "cs", decks: [DECK] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(900);
check("library title is Czech", await page.getByRole("heading", { name: "Tvoje mraznička" }).isVisible());
check("sidebar says Všechny sady", await page.getByRole("button", { name: /Všechny sady/ }).first().isVisible());
check("sidebar says Nezařazené", await page.getByRole("button", { name: /Nezařazené/ }).first().isVisible());
check("deck card CTA is Studovat", (await page.getByRole("link", { name: "Studovat" }).count()) === 1);
check("Czech plural on the card (2 aktivity)", (await page.getByText("2 aktivity").filter({ visible: true }).count()) >= 1);
check("review card is Czech", await page.getByText("Chytré opakování").isVisible());
check("desktop has no tab bar visible", !(await page.locator("nav.tabbar").isVisible()));
// Locale switch from the settings dialog (desktop footer).
await page.locator("aside").getByRole("button", { name: "Nastavení" }).click();
const dialog = page.getByRole("dialog");
await dialog.waitFor({ timeout: 6000 });
await page.waitForTimeout(600);
await dialog.locator(".segmented").first().getByRole("button", { name: /English/ }).click();
await page.waitForTimeout(500);
await page.keyboard.press("Escape");
await page.waitForTimeout(600);
check("locale switch to English updates the UI", await page.getByRole("heading", { name: "Your icebox" }).isVisible());
await context.close();
}
/* 3. Czech phone tab bar + review card ------------------------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { locale: "cs", decks: [DECK] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(900);
const tabTexts = (await page.locator("nav.tabbar").locator("a, button").allInnerTexts()).map((text) => text.trim());
check(
"cs tab labels are the short ones",
JSON.stringify(tabTexts) === JSON.stringify(["Knihovna", "Pokrok", "Nová sada", "Zkouška", "Nastavení"]),
JSON.stringify(tabTexts),
);
check("cs review card present", await page.getByText("Chytré opakování").isVisible());
check("cs exam tab label is not the long one", !tabTexts.includes("Zkouškový režim"));
await context.close();
}
await browser.close();
finish();

294
tests/lib.mjs Normal file
View file

@ -0,0 +1,294 @@
/**
* Shared helpers for the Freezmo browser suites.
*
* The suites run against a locally running production build:
* npm run build && systemctl --user restart freezmo
* npm run test:e2e
*
* The browser is the system Playwright Chromium (CHROME env var wins), so
* nothing is downloaded and nothing lives outside the repo.
*/
import { chromium } from "playwright-core";
import { spawn } from "node:child_process";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
export const BASE = process.env.FREEZMO_URL || "http://127.0.0.1:3207";
export const REPO = path.resolve(new URL("..", import.meta.url).pathname);
/* ------------------------------------------------------------------ */
/* Browser */
/* ------------------------------------------------------------------ */
export function resolveChrome() {
if (process.env.CHROME && existsSync(process.env.CHROME)) return process.env.CHROME;
const root = path.join(os.homedir(), ".cache", "ms-playwright");
const dirs = existsSync(root)
? readdirSync(root)
.filter((entry) => /^chromium-\d+$/.test(entry))
.sort((a, b) => Number(b.split("-")[1]) - Number(a.split("-")[1]))
: [];
for (const dir of dirs) {
for (const sub of ["chrome-linux64", "chrome-linux"]) {
const candidate = path.join(root, dir, sub, "chrome");
if (existsSync(candidate)) return candidate;
}
}
throw new Error(
"No Chromium found. Set CHROME=/path/to/chrome, or install one with `npx playwright install chromium`.",
);
}
export const CHROME = resolveChrome();
export async function launchBrowser() {
return chromium.launch({ executablePath: CHROME, args: ["--no-sandbox"] });
}
/** axe-core source, injected into the page for the contrast audit. */
export function axeSource() {
const require = createRequire(import.meta.url);
return readFileSync(require.resolve("axe-core/axe.min.js"), "utf8");
}
/* ------------------------------------------------------------------ */
/* Checks */
/* ------------------------------------------------------------------ */
export function makeChecker() {
const results = [];
const check = (name, ok, extra = "") => {
results.push({ name, ok: Boolean(ok) });
console.log(`${ok ? "PASS" : "FAIL"} ${name}${extra ? ` — ${extra}` : ""}`);
};
const finish = () => {
const failed = results.filter((entry) => !entry.ok);
console.log(`\n${results.length - failed.length}/${results.length} checks passed`);
if (failed.length) {
console.log("FAILURES:\n - " + failed.map((entry) => entry.name).join("\n - "));
process.exit(1);
}
};
return { check, finish };
}
/* ------------------------------------------------------------------ */
/* Seeding */
/* ------------------------------------------------------------------ */
/** Seeds localStorage before the page loads. */
export async function seed(
page,
{
locale = "en",
theme = "dark",
decks = [],
folders = [],
homework = [],
key = "test-key",
extra = {},
onlyIfMissing = false,
} = {},
) {
await page.addInitScript(
({ locale, theme, decks, folders, homework, key, extra, onlyIfMissing }) => {
const put = (name, value) => {
if (onlyIfMissing && localStorage.getItem(name) !== null) return;
localStorage.setItem(name, value);
};
put("freezmo.install.installed", "1");
put("freezmo.locale.v1", locale);
put("freezmo.theme", theme);
if (key) put("freezmo.settings.v1", JSON.stringify({ apiKey: key }));
put("freezmo.decks.v1", JSON.stringify(decks));
put("freezmo.folders.v1", JSON.stringify(folders));
put("freezmo.homework.v1", JSON.stringify(homework));
for (const [name, value] of Object.entries(extra)) put(name, value);
},
{ locale, theme, decks, folders, homework, key, extra, onlyIfMissing },
);
}
export async function makePage(browser, { width = 390, height = 844, deviceScaleFactor = 2, reducedMotion } = {}) {
const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor, reducedMotion });
const page = await context.newPage();
return { context, page };
}
/** Writes answers + SRS schedules straight into the IndexedDB store. */
export async function seedLearning(page, { answers = [], srs = [] }) {
await page.evaluate(
async ({ answers, srs }) => {
await new Promise((resolve, reject) => {
const request = indexedDB.open("freezmo-learning", 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains("answers")) {
const store = db.createObjectStore("answers", { keyPath: "id" });
store.createIndex("at", "at");
store.createIndex("deckId", "deckId");
}
if (!db.objectStoreNames.contains("srs")) {
const store = db.createObjectStore("srs", { keyPath: "key" });
store.createIndex("dueAt", "dueAt");
}
};
request.onsuccess = () => {
const db = request.result;
const tx = db.transaction(["answers", "srs"], "readwrite");
for (const answer of answers) tx.objectStore("answers").put(answer);
for (const record of srs) tx.objectStore("srs").put(record);
tx.oncomplete = () => {
db.close();
resolve();
};
tx.onerror = () => reject(tx.error);
};
request.onerror = () => reject(request.error);
});
},
{ answers, srs },
);
}
export async function readLearning(page) {
return page.evaluate(
async () =>
new Promise((resolve, reject) => {
const request = indexedDB.open("freezmo-learning", 1);
request.onsuccess = () => {
const db = request.result;
const tx = db.transaction(["answers", "srs"], "readonly");
const answers = tx.objectStore("answers").getAll();
const srs = tx.objectStore("srs").getAll();
tx.oncomplete = () => {
db.close();
resolve({ answers: answers.result, srs: srs.result });
};
tx.onerror = () => reject(tx.error);
};
request.onerror = () => reject(request.error);
}),
);
}
/* ------------------------------------------------------------------ */
/* Fixtures */
/* ------------------------------------------------------------------ */
export const DECK = {
id: "seed_deck",
title: "Sky deck",
subject: "Physics",
summary: "Light in the atmosphere.",
language: "English",
sourceText: "The sky is blue because of Rayleigh scattering. Molecules scatter short wavelengths.",
difficulty: "easy",
createdAt: 1,
updatedAt: 1,
attempts: 0,
activities: [
{
type: "multiple_choice",
id: "a1",
xp: 10,
topic: "Sky",
question: "What colour is a clear daytime sky?",
options: ["Blue", "Green", "Orange", "Pink"],
correctIndex: 0,
explanation: "Rayleigh scattering favours blue.",
},
{
type: "multiple_choice",
id: "a2",
xp: 10,
topic: "Sky",
question: "What makes the sky blue?",
options: ["Scattering", "Paint", "Reflection", "Wind"],
correctIndex: 0,
explanation: "Small molecules scatter short wavelengths.",
},
],
};
export function dueSrs(deckId = "seed_deck", activityId = "a1") {
const now = Date.now();
return {
key: `${deckId}:${activityId}`,
deckId,
activityId,
ease: 2.5,
reps: 1,
intervalDays: 1,
lapses: 0,
dueAt: now - 60_000,
lastAt: now - 120_000,
lastCorrect: true,
};
}
export function wrongAnswer(deckId = "seed_deck", activityId = "a1") {
return {
id: `h-${activityId}`,
deckId,
deckTitle: "Sky deck",
activityId,
type: "multiple_choice",
topic: "Sky",
correct: false,
at: Date.now() - 60_000,
};
}
/* ------------------------------------------------------------------ */
/* Extra app instances (stub Ollama, VAPID, ...) */
/* ------------------------------------------------------------------ */
/** Fails loudly instead of silently talking to somebody else's server. */
export async function portInUse(port) {
try {
const response = await fetch(`http://127.0.0.1:${port}/api/health`);
return response.status < 500;
} catch {
return false;
}
}
export async function waitForHttp(url, attempts = 60) {
for (let index = 0; index < attempts; index++) {
try {
const response = await fetch(url);
if (response.status < 500) return true;
} catch {
/* not up yet */
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
return false;
}
/**
* Starts a second production server on `port` with extra env (a stub Ollama,
* VAPID keys, ...). Returns a stop() that kills the whole process group so the
* port is really free for the next suite.
*/
export function spawnApp(port, env = {}) {
const server = spawn("npx", ["next", "start", "-p", String(port), "-H", "127.0.0.1"], {
cwd: REPO,
env: { ...process.env, ...env },
stdio: "ignore",
detached: true,
});
return {
server,
stop() {
try {
process.kill(-server.pid, "SIGTERM");
} catch {
server.kill("SIGTERM");
}
},
};
}

256
tests/mobile-test.mjs Normal file
View 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();

190
tests/native-test.mjs Normal file
View file

@ -0,0 +1,190 @@
/**
* Native-feel suite: PWA head/manifest, offline handling, the OS share
* target (GET + POST), the Back gesture layers, undo toasts, tab re-tap,
* haptics persistence and wizard draft restore.
*/
import { BASE, DECK, launchBrowser, makeChecker, makePage, seed } from "./lib.mjs";
const { check, finish } = makeChecker();
const browser = await launchBrowser();
/* 1. PWA head + manifest --------------------------------------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(600);
const head = await page.evaluate(() => ({
manifest: document.querySelector('link[rel="manifest"]')?.getAttribute("href") ?? "",
themeColor: document.querySelector('meta[name="theme-color"]')?.getAttribute("content") ?? "",
appleIcon: document.querySelector('link[rel="apple-touch-icon"]')?.getAttribute("href") ?? "",
viewport: document.querySelector('meta[name="viewport"]')?.getAttribute("content") ?? "",
}));
check("manifest linked", head.manifest.includes("manifest.webmanifest"), head.manifest);
check("theme-color present", head.themeColor.startsWith("#"), head.themeColor);
check("apple-touch-icon present", head.appleIcon.length > 0, head.appleIcon);
check("viewport allows zoom (no user-scalable=no)", !/user-scalable\s*=\s*no/i.test(head.viewport), head.viewport);
const manifest = await page.evaluate(async () => (await fetch("/manifest.webmanifest")).json());
check("manifest is standalone", manifest.display === "standalone", manifest.display);
check("manifest has icons", Array.isArray(manifest.icons) && manifest.icons.length >= 2, String(manifest.icons?.length));
await context.close();
}
/* 2. offline: banner + generation disabled --------------------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [] });
await page.goto(`${BASE}/create`, { waitUntil: "networkidle" });
await page.waitForTimeout(700);
await page.getByRole("button", { name: "Paste text instead" }).click();
await page.locator("textarea").first().fill("Offline test notes.");
await page.getByRole("button", { name: "Next" }).click();
await page.waitForTimeout(300);
await page.getByRole("button", { name: "Next" }).click();
await page.waitForTimeout(300);
check("online: Freeze enabled", await page.getByRole("button", { name: "Freeze my deck" }).isEnabled());
await context.setOffline(true);
await page.waitForTimeout(600);
check("offline: banner appears", await page.getByText(/You're offline/).first().isVisible().catch(() => false));
check("offline: Freeze disabled", await page.getByRole("button", { name: "Freeze my deck" }).isDisabled());
await context.setOffline(false);
await page.waitForTimeout(500);
check("online again: Freeze enabled", await page.getByRole("button", { name: "Freeze my deck" }).isEnabled());
await context.close();
}
/* 3. share target: text (GET) and file (POST) ------------------------ */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [] });
await page.goto(`${BASE}/share?text=Photosynthesis%20is%20cool`, { waitUntil: "networkidle" });
await page.waitForTimeout(600);
check("share: chooser shows the text", await page.getByText("Photosynthesis is cool").isVisible());
await page.getByRole("button", { name: "New deck" }).click();
await page.waitForTimeout(900);
check("share: deck wizard prefilled", (await page.locator("textarea").first().inputValue()).includes("Photosynthesis"));
const posted = await page.request.post(`${BASE}/share-target`, {
multipart: {
text: "Solve: 2x = 4",
files: { name: "shared.txt", mimeType: "text/plain", buffer: Buffer.from("Shared document text about photosynthesis.") },
},
maxRedirects: 0,
});
const location = posted.headers()["location"] ?? "";
check("share-target redirects to the chooser", location.includes("/share?id="), location);
// The server builds the Location from the request host (0.0.0.0 here).
const target = location.replace(/^https?:\/\/[^/]+/, BASE);
await page.goto(target, { waitUntil: "networkidle" });
await page.waitForTimeout(600);
check("share: chooser lists the file", await page.getByText("shared.txt").isVisible());
await page.getByRole("button", { name: "Make my homework" }).first().click();
await page.waitForTimeout(1200);
check("share: homework got the file", await page.getByText("shared.txt").first().isVisible());
await context.close();
}
/* 4. back gesture closes layers -------------------------------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [DECK] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(700);
check(
"back: no hamburger or drawer",
(await page.getByRole("button", { name: "Open menu" }).count()) === 0 &&
(await page.getByRole("complementary", { name: "Menu" }).count()) === 0,
);
check("back: tab bar present on the library", await page.locator("nav.tabbar").isVisible());
await page.locator("nav.tabbar").getByRole("link", { name: "Progress" }).click();
await page.waitForTimeout(700);
await page.goBack();
await page.waitForTimeout(600);
check("back: returns from the Progress tab", await page.getByRole("heading", { name: "Your icebox" }).isVisible());
await page.locator("nav.tabbar").getByRole("button").click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(400);
await page.goBack();
await page.waitForTimeout(600);
check("back: settings closed", (await page.getByRole("dialog").count()) === 0);
await page.goto(`${BASE}/create`, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "Paste text instead" }).click();
await page.locator("textarea").first().fill("Back gesture notes.");
await page.getByRole("button", { name: "Next" }).click();
await page.waitForTimeout(400);
check("back: on step 2", await page.getByText("2 / 3").isVisible());
await page.goBack();
await page.waitForTimeout(500);
check("back: returns to step 1", await page.getByText("1 / 3").isVisible());
check("back: still in the wizard", await page.getByRole("heading", { name: "Your notes" }).isVisible());
await context.close();
}
/* 5. undo toast on deck delete --------------------------------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [{ ...DECK, id: "native_deck", title: "Native feel deck" }] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(700);
await page.getByRole("button", { name: "Deck actions" }).click();
await page.waitForTimeout(300);
await page.getByRole("button", { name: "Delete deck" }).click();
await page.waitForTimeout(500);
check("undo: deck removed", (await page.getByText("Native feel deck").count()) === 0);
check("undo: toast shown", await page.getByText("Deck deleted").isVisible());
await page.getByRole("button", { name: "Undo" }).click();
await page.waitForTimeout(500);
check("undo: deck restored", await page.getByText("Native feel deck").first().isVisible());
await context.close();
}
/* 6. share fallback + tab re-tap + haptics toggle + draft ------------ */
{
const { context, page } = await makePage(browser);
await context.grantPermissions(["clipboard-read", "clipboard-write"], { origin: BASE });
await seed(page, { decks: [{ ...DECK, id: "native_deck", title: "Native feel deck" }] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(700);
await page.getByRole("button", { name: "Deck actions" }).click();
await page.waitForTimeout(300);
await page.getByRole("button", { name: "Share", exact: true }).click();
await page.waitForTimeout(600);
const shareToast = await page.getByText(/Copied to the clipboard|Sharing isn't available/).count();
check("share: fallback reports via toast", shareToast >= 1);
await page.evaluate(() => window.scrollTo(0, 1200));
await page.waitForTimeout(300);
await page.locator("nav.tabbar").getByRole("link", { name: "Library" }).click();
await page.waitForTimeout(700);
const scrolled = await page.evaluate(() => window.scrollY);
check("tab re-tap scrolls to the top", scrolled < 40, String(scrolled));
await page.locator("nav.tabbar").getByRole("button").click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(600);
const toggle = page.getByRole("switch", { name: "Haptics" });
check("haptics: toggle present and on", (await toggle.count()) === 1 && (await toggle.getAttribute("aria-checked")) === "true");
await toggle.click();
await page.waitForTimeout(300);
check("haptics: toggled off", (await toggle.getAttribute("aria-checked")) === "false");
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.settings.v1") ?? "{}").haptics);
check("haptics: persisted", stored === false, String(stored));
await page.keyboard.press("Escape");
await page.waitForTimeout(400);
await page.goto(`${BASE}/create`, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "Paste text instead" }).click();
await page.locator("textarea").first().fill("Draft survives a reload.");
await page.waitForTimeout(800);
await page.reload({ waitUntil: "networkidle" });
await page.waitForTimeout(900);
const restored = await page.locator("textarea").first().inputValue().catch(() => "");
check("draft: text restored after reload", restored.includes("Draft survives a reload."), restored.slice(0, 40));
await context.close();
}
await browser.close();
finish();

378
tests/phase1-test.mjs Normal file
View file

@ -0,0 +1,378 @@
/**
* Phase 1 verification: backup export/import, storage persistence row, backup
* nudge, opt-in encrypted sync (enable/join/push/delete + server validation),
* and usage-limit handling (client wiring + real server mapping against a stub
* Ollama).
*/
import { readFileSync } from "node:fs";
import http from "node:http";
import {
BASE,
DECK,
launchBrowser,
makeChecker,
makePage,
portInUse,
seed,
spawnApp,
waitForHttp,
} from "./lib.mjs";
const { check, finish } = makeChecker();
const DECK_A = { ...DECK, id: "deck_a", title: "Photosynthesis basics", folderId: "fold_bio", updatedAt: 1 };
const DECK_B = { ...DECK, id: "deck_b", title: "Trigonometry", folderId: undefined, updatedAt: 1 };
const FOLDER = { id: "fold_bio", name: "Biology", createdAt: 1 };
const HOMEWORK = {
id: "hw_1",
mode: "make",
title: "Quadratic equation",
language: "English",
createdAt: 1,
updatedAt: 1,
sourceText: "Solve x^2 - 4 = 0",
answer: "x = 2 or x = -2",
};
const browser = await launchBrowser();
async function openSettings(page) {
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(800);
await page.locator("nav.tabbar").getByRole("button").click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(500);
}
/* 1. export contains everything except the key --------------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [DECK_A, DECK_B], folders: [FOLDER], homework: [HOMEWORK] });
await openSettings(page);
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Export all data" }).click();
const download = await downloadPromise;
const raw = readFileSync(await download.path(), "utf8");
const backup = JSON.parse(raw);
check("backup: kind and version", backup.app === "freezmo" && backup.kind === "backup" && backup.version === 1);
check(
"backup: decks, folders and homework included",
backup.data.decks.length === 2 && backup.data.folders.length === 1 && backup.data.homework.length === 1,
);
check("backup: API key never in the file", !raw.includes("test-key") && !raw.includes("apiKey") && !raw.includes("x-ollama-key"));
check("backup: safe settings included", typeof backup.data.settings?.haptics === "boolean");
await context.close();
}
/* 2. import preview + merge + replace ------------------------------ */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [DECK_A, DECK_B], folders: [FOLDER], homework: [HOMEWORK] });
await openSettings(page);
const incoming = {
app: "freezmo",
kind: "backup",
version: 1,
exportedAt: new Date().toISOString(),
data: { decks: [{ ...DECK_A, id: "deck_new", title: "Imported deck", folderId: undefined }], folders: [], homework: [], settings: { haptics: true } },
};
await page.getByRole("dialog").locator('input[type="file"][accept*="json"]').setInputFiles({
name: "backup.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(incoming)),
});
await page.waitForTimeout(400);
check("import: preview shown", await page.getByText(/This backup has 1 decks/).isVisible());
await page.getByRole("button", { name: "Merge" }).click();
await page.waitForTimeout(400);
check("import: merge adds the deck", await page.getByText(/Imported 3 decks/).isVisible());
await page.keyboard.press("Escape");
await page.waitForTimeout(500);
check("import: deck visible in the library", await page.getByText("Imported deck").first().isVisible());
await openSettings(page);
const replace = {
app: "freezmo",
kind: "backup",
version: 1,
exportedAt: new Date().toISOString(),
data: { decks: [{ ...DECK_A, id: "only_deck", title: "Only deck", folderId: undefined }], folders: [], homework: [] },
};
await page.getByRole("dialog").locator('input[type="file"][accept*="json"]').setInputFiles({
name: "backup.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(replace)),
});
await page.waitForTimeout(400);
await page.getByRole("button", { name: "Replace" }).click();
await page.waitForTimeout(400);
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.decks.v1")).map((deck) => deck.title));
check("import: replace wipes and uses the backup", stored.length === 1 && stored[0] === "Only deck", JSON.stringify(stored));
await context.close();
}
/* 3. invalid backup + persistence row ------------------------------ */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [DECK_A, DECK_B] });
await openSettings(page);
await page.getByRole("dialog").locator('input[type="file"][accept*="json"]').setInputFiles({
name: "junk.json",
mimeType: "application/json",
buffer: Buffer.from("{not json"),
});
await page.waitForTimeout(300);
check("import: junk file gets a friendly error", await page.getByText("That file is not valid JSON.").isVisible());
check("storage: persistence row present", await page.getByText("Storage", { exact: true }).isVisible());
const storageText = await page.getByText(/Persistent —|Best-effort —/).first().innerText();
check("storage: state explained", /Persistent|Best-effort/.test(storageText), storageText.slice(0, 40));
await context.close();
}
/* 4. backup nudge --------------------------------------------------- */
{
const { context, page } = await makePage(browser);
await seed(page, {
decks: [DECK_A, DECK_B],
onlyIfMissing: true,
extra: { "freezmo.backup.v1": JSON.stringify({ lastBackupAt: Date.now() - 15 * 86_400_000, decksAtLastBackup: 0, snoozedUntil: null }) },
});
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(900);
check("nudge: appears after 14 days", await page.getByText("Back up your decks?").isVisible());
await page.getByRole("button", { name: "Not now" }).click();
await page.waitForTimeout(300);
check("nudge: dismissed", (await page.getByText("Back up your decks?").count()) === 0);
await page.reload({ waitUntil: "networkidle" });
await page.waitForTimeout(900);
check("nudge: stays dismissed after reload", (await page.getByText("Back up your decks?").count()) === 0);
await context.close();
}
/* 5. encrypted sync: enable, upload, push, join, delete ------------- */
{
const { context, page } = await makePage(browser);
// No folder references here: a deck pointing at a folder that is not in
// this browser's library is not rendered, which would hide the deck the
// suite then expects to find again on the joined device.
const syncA = { ...DECK, id: "deck_a", title: "Photosynthesis basics", updatedAt: 1 };
const syncB = { ...DECK, id: "deck_b", title: "Trigonometry", updatedAt: 1 };
await seed(page, { decks: [syncA, syncB] });
await openSettings(page);
check("sync: off by default", (await page.getByRole("button", { name: "Turn on sync" }).count()) === 1);
await page.getByRole("button", { name: "Turn on sync" }).click();
await page.waitForTimeout(200);
await page.getByPlaceholder("Sync passphrase").fill("correct horse battery");
await page.getByPlaceholder("Repeat the passphrase").fill("correct horse battery");
await page.getByRole("button", { name: "Start syncing" }).click();
await page.waitForTimeout(2500);
check("sync: enabled and uploaded", await page.getByText("Uploaded from this device.").isVisible());
const syncRecord = await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.sync.v1")));
check("sync: random id (>=32 hex)", /^[a-f0-9]{32}$/.test(syncRecord.id), syncRecord.id);
const envelope = await page.evaluate(async (id) => {
const response = await fetch(`/api/sync?id=${id}`, { headers: { "x-ollama-key": "test-key" } });
return { status: response.status, body: await response.text() };
}, syncRecord.id);
check("sync: server has the blob", envelope.status === 200);
check(
"sync: blob is opaque",
!envelope.body.includes("Photosynthesis") && !envelope.body.includes("test-key") && !envelope.body.includes("apiKey"),
);
// a real UI write marks dirty, then push
await page.keyboard.press("Escape");
await page.waitForTimeout(500);
await page.getByRole("button", { name: "Deck actions" }).first().click();
await page.waitForTimeout(300);
await page.getByRole("button", { name: "Delete deck" }).click();
await page.waitForTimeout(400);
const dirty = await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.sync.v1")).dirtyAt);
check("sync: local change marks dirty", typeof dirty === "number");
const remainingTitle = await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.decks.v1"))[0].title);
await openSettings(page);
await page.getByRole("button", { name: "Sync now" }).click();
await page.waitForTimeout(2500);
check("sync: push reports uploaded", await page.getByText("Uploaded from this device.").isVisible());
const id = syncRecord.id;
await context.close();
// second device joins
const second = await browser.newContext({ viewport: { width: 390, height: 844 } });
const page2 = await second.newPage();
await seed(page2, { decks: [], key: "other-key" });
await page2.goto(BASE, { waitUntil: "networkidle" });
await page2.waitForTimeout(800);
await page2.locator("nav.tabbar").getByRole("button").click();
await page2.getByRole("dialog").waitFor({ timeout: 6000 });
await page2.waitForTimeout(400);
await page2.getByRole("button", { name: "Join another sync" }).click();
await page2.waitForTimeout(200);
await page2.getByPlaceholder("Sync ID").fill(id);
await page2.getByPlaceholder("Sync passphrase").fill("wrong passphrase");
await page2.getByRole("button", { name: "Join" }).click();
await page2.waitForTimeout(2000);
check("sync: wrong passphrase rejected", await page2.getByText(/can't unlock the synced copy/).isVisible());
await page2.getByPlaceholder("Sync passphrase").fill("correct horse battery");
await page2.getByRole("button", { name: "Join" }).click();
await page2.waitForTimeout(2500);
await page2.keyboard.press("Escape");
await page2.waitForTimeout(600);
check("sync: joined device pulled the decks", await page2.getByText(remainingTitle).first().isVisible(), remainingTitle);
await second.close();
// delete synced data (a device without the stored key can still revoke)
const third = await makePage(browser);
await seed(third.page, {
decks: [],
extra: { "freezmo.sync.v1": JSON.stringify({ v: 1, enabled: true, id, salt: "", lastSyncedAt: null, dirtyAt: null, conflict: false }) },
});
await openSettings(third.page);
await third.page.getByRole("button", { name: "Delete synced data" }).click();
await third.page.waitForTimeout(200);
await third.page.getByRole("button", { name: "Delete", exact: true }).click();
await third.page.waitForTimeout(1500);
const after = await third.page.evaluate(async (syncId) => {
const response = await fetch(`/api/sync?id=${syncId}`, { headers: { "x-ollama-key": "test-key" } });
return response.status;
}, id);
check("sync: delete removes the server copy", after === 404, String(after));
await third.context.close();
}
/* 6. sync server validation ----------------------------------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [] });
await page.goto(BASE, { waitUntil: "networkidle" });
const badId = await page.evaluate(async () => {
const response = await fetch("/api/sync?id=short", { headers: { "x-ollama-key": "test-key" } });
return response.status;
});
check("sync api: bad id → 400", badId === 400, String(badId));
const badBody = await page.evaluate(async () => {
const response = await fetch("/api/sync", {
method: "POST",
headers: { "Content-Type": "application/json", "x-ollama-key": "test-key" },
body: JSON.stringify({ id: "a".repeat(32), envelope: { v: 2 } }),
});
return response.status;
});
check("sync api: invalid envelope → 400", badBody === 400, String(badBody));
const noAuth = await page.evaluate(async () => {
const response = await fetch(`/api/sync?id=${"a".repeat(32)}`);
return response.status;
});
check("sync api: no key → 401", noAuth === 401, String(noAuth));
await context.close();
}
/* 7. usage limit: client wiring (deck flow + settings banner) ------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [] });
await page.route("**/api/generate", (route) =>
route.fulfill({
status: 200,
contentType: "application/x-ndjson",
body: JSON.stringify({ type: "error", error: "monthly usage", kind: "usage_limit" }) + "\n",
}),
);
await page.route("**/api/health", (route) =>
route.fulfill({
json: {
ok: false,
reachable: true,
mode: "direct-cloud",
authMode: "user-key",
needsKey: false,
keyAccepted: true,
baseUrl: "https://ollama.com",
hasApiKey: true,
vision: { configured: "v", resolved: "v", available: true },
text: { configured: "t", resolved: "t", available: true },
latencyMs: 10,
modelCount: 5,
error: "monthly usage",
kind: "usage_limit",
checkedAt: new Date().toISOString(),
},
}),
);
await page.goto(`${BASE}/create`, { waitUntil: "networkidle" });
await page.waitForTimeout(900);
await page.getByRole("button", { name: "Paste text instead" }).click();
await page.locator("textarea").first().fill("Notes for the usage limit test.");
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(1200);
check("usage: clear title in the deck flow", await page.getByText("Free monthly usage used up").isVisible());
check("usage: upgrade/new-key message shown", await page.getByText(/Upgrade your Ollama account, or create a new one/).isVisible());
check("usage: Open Ollama button", await page.getByRole("button", { name: "Open Ollama" }).isVisible());
await page.getByRole("button", { name: "Open Settings" }).click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(600);
check("usage: settings banner shows the limit", await page.getByRole("dialog").getByText("Free monthly usage used up").isVisible());
const focused = await page.evaluate(() => document.activeElement?.id ?? "");
check("usage: key field focused", focused === "ollama-api-key-phone" || focused === "ollama-api-key", focused);
await context.close();
}
/* 8. usage limit: real server mapping against a stub Ollama --------- */
{
check("stub port is free", !(await portInUse(3208)));
let mode = "monthly";
const stub = http.createServer((request, response) => {
if (request.url?.startsWith("/api/tags")) {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ models: [{ name: "stub", model: "stub" }] }));
return;
}
response.writeHead(429, { "Content-Type": "application/json" });
response.end(
JSON.stringify({
error: mode === "monthly" ? "You have reached your monthly usage limit. Upgrade your plan." : "Too many requests, slow down.",
}),
);
});
await new Promise((resolve) => stub.listen(11999, "127.0.0.1", resolve));
const app = spawnApp(3208, {
OLLAMA_BASE_URL: "http://127.0.0.1:11999",
OLLAMA_AUTH: "local",
OLLAMA_VERIFY_KEY: "false",
});
try {
const up = await waitForHttp("http://127.0.0.1:3208/api/health");
check("stub app instance is up", up);
if (up) {
const monthly = await fetch("http://127.0.0.1:3208/api/grade", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question: "q", referenceAnswer: "a", keyPoints: ["k"], userAnswer: "x" }),
});
const monthlyBody = await monthly.json();
check("stub server: monthly 429 → usage_limit", monthlyBody.kind === "usage_limit", JSON.stringify(monthlyBody).slice(0, 80));
mode = "burst";
const burst = await fetch("http://127.0.0.1:3208/api/grade", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question: "q", referenceAnswer: "a", keyPoints: ["k"], userAnswer: "x" }),
});
const burstBody = await burst.json();
check("stub server: burst 429 → throttled", burstBody.kind === "throttled", JSON.stringify(burstBody).slice(0, 80));
}
} finally {
app.stop();
stub.close();
}
}
await browser.close();
finish();

262
tests/phase2-test.mjs Normal file
View file

@ -0,0 +1,262 @@
/**
* Phase 2 verification: answer history in IndexedDB, SRS review card and
* session, mistakes that clear themselves, the stats screen, exam mode with a
* timer, and the push-reminder plumbing (hidden without VAPID, validated with
* it).
*/
import { mkdirSync, readFileSync } from "node:fs";
import { spawn } from "node:child_process";
import path from "node:path";
import {
BASE,
DECK,
REPO,
launchBrowser,
makeChecker,
makePage,
portInUse,
readLearning,
seed,
seedLearning,
spawnApp,
waitForHttp,
} from "./lib.mjs";
const { check, finish } = makeChecker();
const browser = await launchBrowser();
/* 1. studying records history, mistakes and a schedule --------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [DECK] });
await page.goto(`${BASE}/deck/seed_deck`, { waitUntil: "networkidle" });
await page.waitForTimeout(900);
await page.getByRole("button", { name: "Green" }).click();
await page.waitForTimeout(500);
check("study: wrong answer shows feedback", await page.getByText(/Correct answer/i).first().isVisible());
await page.getByRole("button", { name: "Next" }).click();
await page.waitForTimeout(900);
await page.getByRole("button", { name: "Scattering" }).click();
await page.waitForTimeout(500);
await page.getByRole("button", { name: "Next" }).click();
await page.waitForTimeout(900);
const stored = await readLearning(page);
check("history: two answers recorded", stored.answers.length === 2, String(stored.answers.length));
check("history: correct and wrong kept", stored.answers.some((a) => a.correct) && stored.answers.some((a) => !a.correct));
check("history: topic kept", stored.answers.every((a) => a.topic === "Sky"));
check("srs: schedule created for both", stored.srs.length === 2, String(stored.srs.length));
const wrong = stored.srs.find((entry) => entry.activityId === "a1");
const right = stored.srs.find((entry) => entry.activityId === "a2");
check("srs: wrong comes back in ~10 min", wrong && wrong.dueAt - wrong.lastAt === 10 * 60_000);
check("srs: correct scheduled a day out", right && right.intervalDays === 1 && right.dueAt - right.lastAt === 86_400_000);
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(900);
check("library: learning card visible", await page.getByText("Smart review").isVisible());
check("library: one mistake", await page.getByText("Mistakes · 1 mistake").isVisible());
check("library: review due is zero", await page.getByText("Review due · 0 due").isVisible());
check("library: today's goal line", await page.getByText(/Today: 2 \/ 20/).isVisible());
await page.getByText("Mistakes · 1 mistake").click();
await page.waitForTimeout(1200);
check("mistakes: session opens on the wrong item", await page.getByText("What colour is a clear daytime sky?").isVisible());
await page.getByRole("button", { name: "Blue" }).click();
await page.waitForTimeout(400);
await page.getByRole("button", { name: "See results" }).click();
await page.waitForTimeout(600);
await page.getByRole("button", { name: "Back to the Library" }).click();
await page.waitForTimeout(1000);
check("mistakes: cleared after a correct answer", await page.getByText("Mistakes · 0 mistakes").isVisible());
await context.close();
}
/* 2. SRS due → review session; stats screen -------------------------- */
{
const now = Date.now();
const { context, page } = await makePage(browser);
await seed(page, { decks: [DECK] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(600);
await seedLearning(page, {
answers: [
{ id: "h1", deckId: "seed_deck", deckTitle: "Sky deck", activityId: "a1", type: "multiple_choice", topic: "Sky", correct: false, at: now - 3000 },
{ id: "h2", deckId: "seed_deck", deckTitle: "Sky deck", activityId: "a2", type: "multiple_choice", topic: "Sky", correct: true, at: now - 2000 },
{ id: "h3", deckId: "seed_deck", deckTitle: "Sky deck", activityId: "a1", type: "multiple_choice", topic: "Sky", correct: true, at: now - 1000 },
],
srs: [{ key: "seed_deck:a1", deckId: "seed_deck", activityId: "a1", ease: 2.5, reps: 1, intervalDays: 1, lapses: 0, dueAt: now - 60_000, lastAt: now - 1000, lastCorrect: true }],
});
await page.reload({ waitUntil: "networkidle" });
await page.waitForTimeout(1000);
check("review: due count from the schedule", await page.getByText("Review due · 1 due").isVisible());
await page.getByText("Review due · 1 due").click();
await page.waitForTimeout(1200);
check("review: session shows the due activity", await page.getByText("What colour is a clear daytime sky?").isVisible());
await page.getByRole("button", { name: "Blue" }).click();
await page.waitForTimeout(400);
await page.getByRole("button", { name: "See results" }).click();
await page.waitForTimeout(600);
check("review: results slide", await page.getByText("100%").isVisible());
await page.getByRole("button", { name: "Back to the Library" }).click();
await page.waitForTimeout(900);
await page.goto(`${BASE}/stats`, { waitUntil: "networkidle" });
await page.waitForTimeout(1200);
check("stats: title", await page.getByRole("heading", { name: "Your progress" }).isVisible());
const streakValue = await page.locator("section").filter({ hasText: "Day streak" }).first().innerText();
check("stats: streak counts today", /\b1\b/.test(streakValue), streakValue.replace(/\s+/g, " ").slice(0, 40));
check("stats: calendar rendered", (await page.locator('[title*="-"]').count()) >= 35);
check("stats: per deck row", await page.getByText("Sky deck").isVisible());
check("stats: per subject row", await page.getByText("Physics").isVisible());
check("stats: weakest topic", await page.getByText("Weakest topics").isVisible());
await context.close();
}
/* 3. exam mode: setup, no hints, timer, breakdown -------------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [DECK] });
await page.goto(`${BASE}/exam`, { waitUntil: "networkidle" });
await page.waitForTimeout(900);
check("exam: setup headline", await page.getByRole("heading", { name: "Exam mode" }).isVisible());
await page.getByRole("button", { name: /Sky deck/ }).click();
await page.waitForTimeout(200);
await page.getByRole("button", { name: "5 min", exact: true }).click();
await page.waitForTimeout(200);
await page.getByRole("button", { name: /Start exam/ }).click();
await page.waitForTimeout(1000);
check("exam: timer visible", await page.getByLabel(/left$/).isVisible());
check("exam: no hint rows", (await page.getByText(/Tap a term|Shortcuts|Use the arrow/i).count()) === 0);
// Order-independent: MC listens for number keys on the active slide and the
// feed for ArrowDown. Both activities have the correct answer at index 0.
await page.keyboard.press("1");
await page.waitForTimeout(500);
await page.keyboard.press("ArrowDown");
await page.waitForTimeout(800);
await page.keyboard.press("2");
await page.waitForTimeout(500);
await page.keyboard.press("ArrowDown");
await page.waitForTimeout(900);
check("exam: results show the score", await page.getByText("Exam results").isVisible());
check("exam: per-deck breakdown", await page.getByText("By deck").isVisible() && (await page.getByText("Sky deck").count()) >= 1);
check("exam: per-topic breakdown", await page.getByText("By topic").isVisible());
check("exam: wrong answers offered as mistakes", await page.getByRole("button", { name: "Study mistakes" }).isVisible());
const stored = await readLearning(page);
check("exam: answers recorded", stored.answers.length >= 2, String(stored.answers.length));
await context.close();
}
/* 4. reminders hidden without VAPID ---------------------------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(800);
const status = await page.evaluate(async () => {
const response = await fetch("/api/push", { headers: { "x-ollama-key": "test-key" } });
return { status: response.status, body: await response.json() };
});
check("push: status says disabled without VAPID", status.body.enabled === false, JSON.stringify(status.body));
await page.locator("nav.tabbar").getByRole("button").click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(500);
check("push: daily goal setting visible", await page.getByText("Daily goal").isVisible());
check("push: reminder UI hidden", (await page.getByText("Daily reminder").count()) === 0);
await context.close();
}
/* 5. reminders with VAPID: status, subscribe, delete, test send ------ */
{
check("push: port 3209 is free", !(await portInUse(3209)));
mkdirSync(path.join(REPO, "tests", ".tmp", "push"), { recursive: true });
const vapid = await new Promise((resolve) => {
const child = spawn("npx", ["web-push", "generate-vapid-keys", "--json"], { cwd: REPO });
let output = "";
child.stdout.on("data", (chunk) => (output += chunk));
child.on("close", () => {
try {
resolve(JSON.parse(output.match(/\{[\s\S]*\}/)[0]));
} catch {
resolve(null);
}
});
});
check("push: vapid keys generated", Boolean(vapid?.publicKey && vapid?.privateKey));
const app = spawnApp(3209, {
VAPID_PUBLIC_KEY: vapid.publicKey,
VAPID_PRIVATE_KEY: vapid.privateKey,
VAPID_SUBJECT: "mailto:test@example.com",
PUSH_DIR: path.join(REPO, "tests", ".tmp", "push"),
});
try {
const up = await waitForHttp("http://127.0.0.1:3209/api/health");
check("push: second instance is up", up);
if (up) {
const context = await browser.newContext({ viewport: { width: 390, height: 844 } });
const page = await context.newPage();
await seed(page, { decks: [] });
await page.goto("http://127.0.0.1:3209/", { waitUntil: "networkidle" });
await page.waitForTimeout(900);
await page.locator("nav.tabbar").getByRole("button").click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(700);
check("push: reminder UI appears with VAPID", await page.getByText("Daily reminder").isVisible());
const subscription = {
endpoint: "https://push.example.invalid/abc",
keys: { p256dh: "B".repeat(64), auth: "A".repeat(22) },
};
const subscribe = await page.evaluate(async (sub) => {
const response = await fetch("/api/push", {
method: "POST",
headers: { "Content-Type": "application/json", "x-ollama-key": "test-key" },
body: JSON.stringify(sub),
});
return { status: response.status, body: await response.json() };
}, subscription);
check("push: subscription stored", subscribe.status === 200 && subscribe.body.count === 1, JSON.stringify(subscribe.body));
const file = JSON.parse(readFileSync(path.join(REPO, "tests", ".tmp", "push", "subscriptions.json"), "utf8"));
check("push: stored without personal data", file.length === 1 && Object.keys(file[0]).sort().join(",") === "createdAt,endpoint,keys");
const badBody = await page.evaluate(async () => {
const response = await fetch("/api/push", {
method: "POST",
headers: { "Content-Type": "application/json", "x-ollama-key": "test-key" },
body: JSON.stringify({ endpoint: "not-a-url" }),
});
return response.status;
});
check("push: invalid subscription → 400", badBody === 400, String(badBody));
const testSend = await page.evaluate(async (sub) => {
const response = await fetch("/api/push/test", {
method: "POST",
headers: { "Content-Type": "application/json", "x-ollama-key": "test-key" },
body: JSON.stringify({ endpoint: sub.endpoint }),
});
return { status: response.status, body: await response.json() };
}, subscription);
check("push: test send reaches the sender (unreachable endpoint → 502)", testSend.status === 502, JSON.stringify(testSend.body));
const removed = await page.evaluate(async (sub) => {
const response = await fetch(`/api/push?endpoint=${encodeURIComponent(sub.endpoint)}`, {
method: "DELETE",
headers: { "x-ollama-key": "test-key" },
});
return { status: response.status, body: await response.json() };
}, subscription);
check("push: delete path works", removed.status === 200 && removed.body.count === 0, JSON.stringify(removed.body));
await context.close();
}
} finally {
app.stop();
}
}
await browser.close();
finish();

197
tests/phase3-test.mjs Normal file
View file

@ -0,0 +1,197 @@
/**
* Phase 3 verification: "Check my work" (server mapping + phone and desktop
* UI), follow-ups (shorter / more detail / explain a step differently) and
* "Turn into a study deck" pre-filling the New deck flow.
* Runs against a stub Ollama so no model is needed.
*/
import http from "node:http";
import { launchBrowser, makeChecker, makePage, portInUse, seed, spawnApp, waitForHttp } from "./lib.mjs";
const { check, finish } = makeChecker();
const STUB_PORT = 11998;
const APP_PORT = 3208;
const STUB_BASE = `http://127.0.0.1:${STUB_PORT}`;
const APP = `http://127.0.0.1:${APP_PORT}`;
const CHECK_REPLY = {
title: "Equation check",
language: "English",
verdict: "One mistake: the sign in the second line.",
correct: ["The setup is right."],
wrong: [{ issue: "x = 3", why: "4 - 2 is 2, not 3.", fix: "x = 2" }],
corrected: "x + 2 = 4\nx = 4 - 2\nx = 2",
};
const GUIDE_REPLY = {
title: "Equation guide",
language: "English",
steps: [
{
title: "Move the number",
explanation: "Subtract 2 from both sides so x stands alone.",
hints: ["Think about the opposite operation.", "Subtract 2 from both sides.", "x = 4 - 2"],
answer: "x = 4 - 2",
},
{
title: "Finish it",
explanation: "Now do the subtraction to get the value of x.",
hints: ["Just arithmetic now.", "4 minus 2.", "x = 2"],
answer: "x = 2",
},
],
};
const stub = http.createServer((request, response) => {
if (request.url?.startsWith("/api/tags")) {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ models: [{ name: "stub", model: "stub" }] }));
return;
}
let raw = "";
request.on("data", (chunk) => (raw += chunk));
request.on("end", () => {
let content = "{}";
try {
const body = JSON.parse(raw);
const system = String(body.messages?.[0]?.content ?? "");
const user = String(body.messages?.[1]?.content ?? "");
if (system.includes("homework checker")) content = JSON.stringify(CHECK_REPLY);
else if (system.includes("homework writer")) content = JSON.stringify({ title: "Equation", language: "English", answer: "x = 2" });
else if (system.includes("homework coach")) content = JSON.stringify(GUIDE_REPLY);
else if (system.includes("homework assistant")) {
content = user.includes("SHORTER")
? "SHORTER: x = 2"
: user.includes("MORE DETAIL")
? "MORE DETAIL: x + 2 = 4, so subtract 2: x = 2"
: "EXPLAINED DIFFERENTLY: picture a balance scale.";
}
} catch {
/* keep the default */
}
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ message: { content } }));
});
});
await new Promise((resolve) => stub.listen(STUB_PORT, "127.0.0.1", resolve));
check("stub app port is free", !(await portInUse(APP_PORT)));
const app = spawnApp(APP_PORT, { OLLAMA_BASE_URL: STUB_BASE, OLLAMA_AUTH: "local", OLLAMA_VERIFY_KEY: "false" });
const browser = await launchBrowser();
try {
const up = await waitForHttp(`${APP}/api/health`);
check("stub app instance is up", up);
if (up) {
/* 1. server: check mode ---------------------------------------- */
const apiCheck = await fetch(`${APP}/api/homework`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode: "check", text: "Solve x + 2 = 4", solution: "x = 3" }),
});
const events = (await apiCheck.text())
.trim()
.split("\n")
.map((line) => JSON.parse(line));
const homeworkEvent = events.find((event) => event.type === "homework");
check("api: check mode returns the feedback payload", homeworkEvent?.homework?.mode === "check");
check("api: verdict and corrected version", Boolean(homeworkEvent?.homework?.verdict) && homeworkEvent?.homework?.corrected.includes("x = 2"));
check("api: wrong entry has issue/why/fix", homeworkEvent?.homework?.wrong?.[0]?.issue === "x = 3" && Boolean(homeworkEvent?.homework?.wrong?.[0]?.fix));
const missingSolution = await fetch(`${APP}/api/homework`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode: "check", text: "Solve x + 2 = 4" }),
});
check("api: check without a solution → 400", missingSolution.status === 400, String(missingSolution.status));
/* 2. server: refine -------------------------------------------- */
const refine = await fetch(`${APP}/api/homework/refine`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ instruction: "shorter", text: "x + 2 = 4, therefore x = 2" }),
});
const refineBody = await refine.json();
check("api: refine returns rewritten text", refine.status === 200 && refineBody.text.includes("SHORTER"), JSON.stringify(refineBody).slice(0, 60));
const badRefine = await fetch(`${APP}/api/homework/refine`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ instruction: "wrong", text: "x" }),
});
check("api: refine validates the instruction", badRefine.status === 400, String(badRefine.status));
/* 3. phone UI: check my work + follow-ups + to-deck -------------- */
const phone = await makePage(browser, { width: 390 });
await seed(phone.page, { decks: [] });
await phone.page.goto(`${APP}/homework`, { waitUntil: "networkidle" });
await phone.page.waitForTimeout(900);
await phone.page.getByRole("button", { name: "Paste text instead" }).click();
await phone.page.locator("textarea").first().fill("Solve x + 2 = 4");
await phone.page.getByRole("button", { name: "Next" }).click();
await phone.page.waitForTimeout(400);
await phone.page.getByRole("button", { name: /Check my work/ }).click();
await phone.page.waitForTimeout(400);
check("phone: solution field revealed", await phone.page.locator("#homework-solution").isVisible());
await phone.page.locator("#homework-solution").fill("x = 3");
await phone.page.getByRole("button", { name: "Check my work", exact: true }).click();
await phone.page.waitForTimeout(2500);
check("phone: verdict shown", await phone.page.getByText("One mistake: the sign in the second line.").isVisible());
check("phone: what's right listed", await phone.page.getByText("The setup is right.").isVisible());
check("phone: what needs fixing listed", await phone.page.getByText("4 - 2 is 2, not 3.").isVisible());
check("phone: corrected version shown", await phone.page.getByText("x = 4 - 2").isVisible());
await phone.page.getByRole("button", { name: "Shorter" }).click();
await phone.page.waitForTimeout(1500);
check("phone: shorter follow-up replaces the text", await phone.page.getByText("SHORTER: x = 2").isVisible());
check("phone: original can be restored", await phone.page.getByRole("button", { name: "Original" }).isVisible());
await phone.page.getByRole("button", { name: "Turn into a study deck" }).click();
await phone.page.waitForTimeout(1500);
check("phone: landed on New deck", phone.page.url().includes("/create"));
const seededText = await phone.page.locator("textarea").first().inputValue().catch(() => "");
check("phone: deck wizard pre-filled with the content", seededText.includes("x = 4 - 2") || seededText.includes("SHORTER"), seededText.slice(0, 40));
await phone.context.close();
/* 4. desktop UI + guide explain ---------------------------------- */
const desktop = await makePage(browser, { width: 1024, height: 900 });
await seed(desktop.page, { decks: [] });
await desktop.page.goto(`${APP}/homework`, { waitUntil: "networkidle" });
await desktop.page.waitForTimeout(900);
await desktop.page.locator("textarea").first().fill("Solve x + 2 = 4");
await desktop.page.getByRole("button", { name: /Check my work/ }).click();
await desktop.page.waitForTimeout(300);
check("desktop: solution field appears with the mode", await desktop.page.locator("#homework-solution").isVisible());
await desktop.page.locator("#homework-solution").fill("x = 3");
await desktop.page.getByRole("button", { name: "Check my work", exact: true }).click();
await desktop.page.waitForTimeout(2500);
check("desktop: check result renders", await desktop.page.getByText("Corrected version").isVisible());
await desktop.context.close();
const guide = await makePage(browser, { width: 390 });
await seed(guide.page, { decks: [] });
await guide.page.goto(`${APP}/homework`, { waitUntil: "networkidle" });
await guide.page.waitForTimeout(900);
await guide.page.getByRole("button", { name: "Paste text instead" }).click();
await guide.page.locator("textarea").first().fill("Solve x + 2 = 4");
await guide.page.getByRole("button", { name: "Next" }).click();
await guide.page.waitForTimeout(400);
await guide.page.getByRole("button", { name: /Explain how to make this homework/ }).click();
await guide.page.waitForTimeout(2500);
const explainButton = guide.page.getByRole("button", { name: /Explain step 1 differently/ });
check("guide: explain action present on the step", await explainButton.isVisible().catch(() => false));
if (await explainButton.isVisible().catch(() => false)) {
await explainButton.click();
await guide.page.waitForTimeout(1500);
check("guide: explanation replaced", await guide.page.getByText("EXPLAINED DIFFERENTLY: picture a balance scale.").isVisible());
}
await guide.context.close();
}
} finally {
app.stop();
stub.close();
await browser.close();
}
finish();

115
tests/regression.mjs Normal file
View file

@ -0,0 +1,115 @@
/**
* 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();

130
tests/review-test.mjs Normal file
View file

@ -0,0 +1,130 @@
/**
* 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();

44
tests/run-all.mjs Normal file
View file

@ -0,0 +1,44 @@
/**
* Runs every Freezmo browser suite in order.
*
* npm run test:e2e # everything
* npm run test:e2e -- phase1 # only suites whose name contains "phase1"
*
* Each suite is a standalone script that talks to the running app on :3207
* (FREEZMO_URL overrides). Suites that need a stub Ollama or extra server
* instance start their own on their own port and clean up after themselves.
*/
import { spawn } from "node:child_process";
import path from "node:path";
const SUITES = [
"regression",
"mobile-test",
"wizard-test",
"native-test",
"i18n-test",
"freeze-loading-test",
"phase1-test",
"phase2-test",
"phase3-test",
"review-test",
];
const filters = process.argv.slice(2);
const list = filters.length ? SUITES.filter((name) => filters.some((f) => name.includes(f))) : SUITES;
const here = path.dirname(new URL(import.meta.url).pathname);
const repo = path.resolve(here, "..");
let failed = 0;
for (const suite of list) {
console.log(`\n===== ${suite} =====`);
const code = await new Promise((resolve) => {
const child = spawn(process.execPath, [path.join(here, `${suite}.mjs`)], { cwd: repo, stdio: "inherit" });
child.on("close", resolve);
});
if (code !== 0) failed += 1;
}
console.log(`\n${list.length - failed}/${list.length} suites passed`);
process.exit(failed ? 1 : 0);

213
tests/wizard-test.mjs Normal file
View file

@ -0,0 +1,213 @@
/**
* 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();