Move the e2e suites into the repo (tests/), out of /tmp
The browser harness lived in /tmp/opencode and an overnight reboot wiped it. It now lives in tests/ and survives reboots, is versioned and runs with one command. - tests/lib.mjs: shared helpers (Chromium auto-detect, seeding, IndexedDB fixtures, stub/extra instance spawn with process-group cleanup, check/finish). - tests/run-all.mjs + npm run test:e2e [filter]: runs the ten suites in order with a summary; suites that need a stub Ollama or VAPID keys start their own next start instance on 3208/3209, verify the port is free and kill the group on exit. Test scratch in tests/.tmp/ (gitignored). - Suites rebuilt and green against the current build: regression 38, mobile 94 (tab matrix, ... sheet, settings sheet, axe), wizard 83, native 31 (PWA/offline/share/back/undo/haptics/draft), i18n 13, freeze-loading 6, review 15, phase1 36 (backup/sync/usage limits), phase2 42 (learning engine/reminders), phase3 21 (homework check). 379 checks, 10/10 suites in one full run. - playwright-core + axe-core added as devDependencies; CHROME env or the newest ~/.cache/ms-playwright chromium is used. - README: new decision 45 documenting the harness; decision 44's verification note updated. The older suites' exact sources were lost with the wipe, so the rebuilt versions reconstruct the same feature areas rather than being byte-identical.
This commit is contained in:
parent
2103f9fc69
commit
dbd5dfbc58
17 changed files with 2280 additions and 8 deletions
46
tests/README.md
Normal file
46
tests/README.md
Normal 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).
|
||||
45
tests/freeze-loading-test.mjs
Normal file
45
tests/freeze-loading-test.mjs
Normal 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
71
tests/i18n-test.mjs
Normal 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
294
tests/lib.mjs
Normal 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
256
tests/mobile-test.mjs
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
/**
|
||||
* Phone suite: five-tab bottom bar (labels at 360/390 in EN/CS, dark/light,
|
||||
* reduced motion), the library ⋯ sheet, the settings sheet (drag, touch
|
||||
* targets, key flow), and an axe-core contrast audit.
|
||||
*/
|
||||
import { BASE, DECK, axeSource, launchBrowser, makeChecker, makePage, seed } from "./lib.mjs";
|
||||
|
||||
const { check, finish } = makeChecker();
|
||||
const browser = await launchBrowser();
|
||||
|
||||
const box = (locator) => locator.boundingBox();
|
||||
const dragDown = async (page, locator, dy = 170, steps = 9) => {
|
||||
const bounds = await locator.boundingBox();
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
|
||||
await page.mouse.down();
|
||||
for (let index = 1; index <= steps; index++) {
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + (dy * index) / steps);
|
||||
await page.waitForTimeout(18);
|
||||
}
|
||||
await page.mouse.up();
|
||||
};
|
||||
|
||||
/* 1. empty library: header, tab bar, ⋯ sheet ------------------------- */
|
||||
{
|
||||
const { context, page } = await makePage(browser);
|
||||
await seed(page, { decks: [] });
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(1100);
|
||||
|
||||
check("no hamburger on phones", (await page.getByRole("button", { name: "Open menu" }).count()) === 0);
|
||||
check("no drawer on phones", (await page.getByRole("complementary", { name: "Menu" }).count()) === 0);
|
||||
const themeBtn = page.getByRole("button", { name: "Switch to light mode" });
|
||||
check("theme toggle present", await themeBtn.isVisible());
|
||||
const themeBox = await box(themeBtn);
|
||||
check("theme toggle >= 44px", themeBox.height >= 43.5 && themeBox.width >= 43.5, `${Math.round(themeBox.width)}x${Math.round(themeBox.height)}`);
|
||||
check("exactly one theme toggle on phones", (await page.getByRole("button", { name: /Switch to (light|dark) mode/ }).count()) === 1);
|
||||
|
||||
const tabs = page.locator("nav.tabbar");
|
||||
check("tab bar visible", await tabs.isVisible());
|
||||
const tabTexts = (await tabs.locator("a, button").allInnerTexts()).map((text) => text.trim());
|
||||
check(
|
||||
"five tabs in order",
|
||||
JSON.stringify(tabTexts) === JSON.stringify(["Library", "Progress", "New deck", "Exam", "Settings"]),
|
||||
JSON.stringify(tabTexts),
|
||||
);
|
||||
const tabBoxes = await tabs.locator("a, button").evaluateAll((els) => els.map((el) => el.getBoundingClientRect().height));
|
||||
check("every tab >= 44px tall", tabBoxes.every((height) => height >= 43.5), JSON.stringify(tabBoxes.map(Math.round)));
|
||||
|
||||
check("empty state hides the folder pills", (await page.locator(".folder-pill").count()) === 0);
|
||||
const moreBtn = page.getByRole("button", { name: "More actions" });
|
||||
check("⋯ button replaces the import icon", await moreBtn.isVisible());
|
||||
const moreBox = await box(moreBtn);
|
||||
check("⋯ >= 44px", moreBox.height >= 43.5 && moreBox.width >= 43.5, `${Math.round(moreBox.width)}x${Math.round(moreBox.height)}`);
|
||||
await moreBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
const sheet = page.getByRole("dialog", { name: "Library actions" });
|
||||
check(
|
||||
"⋯ sheet lists Import JSON + New folder",
|
||||
(await sheet.isVisible()) && (await sheet.getByText("Import JSON").isVisible()) && (await sheet.getByText("New folder").isVisible()),
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
let sheetClosed = true;
|
||||
try {
|
||||
await sheet.waitFor({ state: "detached", timeout: 2500 });
|
||||
} catch {
|
||||
sheetClosed = false;
|
||||
}
|
||||
check("⋯ sheet closes with Escape", sheetClosed);
|
||||
|
||||
// Empty state checks before any folder exists.
|
||||
const cta = page.getByRole("link", { name: "Create study deck", exact: true });
|
||||
check("dedicated primary CTA", await cta.isVisible());
|
||||
const ctaBox = await box(cta);
|
||||
check("CTA >= 44px tall", ctaBox.height >= 43.5, String(Math.round(ctaBox.height)));
|
||||
await page.getByRole("link", { name: "Homework", exact: true }).click();
|
||||
await page.waitForTimeout(700);
|
||||
const hwCta = page.getByRole("link", { name: "Make my homework", exact: true });
|
||||
check("homework quick action", await hwCta.isVisible());
|
||||
await page.getByRole("link", { name: "Study Decks", exact: true }).click();
|
||||
await page.waitForTimeout(700);
|
||||
|
||||
// Creating a folder from the ⋯ sheet brings the chips row in.
|
||||
await page.getByRole("button", { name: "More actions" }).click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByRole("dialog").getByText("New folder").click();
|
||||
await page.waitForTimeout(400);
|
||||
await page.getByRole("dialog").locator("input").fill("Biology");
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Add" }).click();
|
||||
await page.waitForTimeout(700);
|
||||
check(
|
||||
"⋯ sheet creates a folder",
|
||||
(await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.folders.v1")).map((f) => f.name))).includes("Biology"),
|
||||
);
|
||||
check("folder chips appear once folders exist", (await page.locator(".folder-pill").count()) >= 1);
|
||||
await context.close();
|
||||
}
|
||||
|
||||
/* 2. tab-bar matrix: 360/390 × EN/CS × dark/light × reduced motion --- */
|
||||
for (const locale of ["en", "cs"]) {
|
||||
for (const width of [360, 390]) {
|
||||
for (const theme of ["dark", "light"]) {
|
||||
for (const reducedMotion of ["no-preference", "reduce"]) {
|
||||
const label = `${locale}/${width}/${theme}/${reducedMotion === "reduce" ? "reduced" : "motion"}`;
|
||||
const { context, page } = await makePage(browser, { width, height: 800, reducedMotion });
|
||||
await seed(page, { locale, theme, decks: [] });
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(900);
|
||||
const tabs = page.locator("nav.tabbar");
|
||||
const visible = await tabs.isVisible();
|
||||
const overflowX = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
|
||||
const labels = await tabs.locator("a > span:last-child, button > span:last-child").evaluateAll((els) =>
|
||||
els.map((el) => ({ text: el.innerText, clipped: el.scrollWidth > el.clientWidth + 1 })),
|
||||
);
|
||||
check(`${label}: tab bar visible, no overflow`, visible && overflowX <= 1, String(overflowX));
|
||||
check(`${label}: five labels, none clipped`, labels.length === 5 && labels.every((entry) => !entry.clipped), JSON.stringify(labels.map((entry) => entry.text)));
|
||||
check(`${label}: active indicator present`, (await tabs.locator('[aria-current="page"]').count()) === 1);
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 3. Czech settings sheet: touch targets, key flow, drag -------------- */
|
||||
{
|
||||
const { context, page } = await makePage(browser);
|
||||
await seed(page, { locale: "cs", decks: [DECK], folders: [{ id: "f1", name: "Škola", createdAt: 1 }] });
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(1100);
|
||||
check("cs folder pills are 44px tall", (await box(page.locator(".folder-pill").first())).height >= 43.5);
|
||||
|
||||
await page.locator("nav.tabbar").getByRole("button").click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.waitFor({ timeout: 6000 });
|
||||
await page.waitForTimeout(700);
|
||||
check("cs sheet title", await dialog.getByRole("heading", { name: "Nastavení" }).isVisible());
|
||||
|
||||
const targets = [
|
||||
["close", dialog.getByRole("button", { name: "Zavřít nastavení" })],
|
||||
["Show", dialog.getByRole("button", { name: "Zobrazit" })],
|
||||
["Re-check", dialog.getByRole("button", { name: "Zkontrolovat připojení" })],
|
||||
["Remove key", dialog.getByRole("button", { name: "Odstranit klíč z prohlížeče" })],
|
||||
["segmented", dialog.locator(".segmented > button").first()],
|
||||
["privacy summary", dialog.locator("summary").first()],
|
||||
];
|
||||
for (const [name, locator] of targets) {
|
||||
const bounds = await box(locator);
|
||||
check(`${name} >= 44px`, bounds.width >= 43.5 && bounds.height >= 43.5, `${Math.round(bounds.width)}x${Math.round(bounds.height)}`);
|
||||
}
|
||||
|
||||
check("Save hidden until the key is edited", (await dialog.getByRole("button", { name: "Uložit" }).count()) === 0);
|
||||
const keyInput = dialog.locator("#ollama-api-key-phone");
|
||||
await keyInput.fill("test-key2");
|
||||
await page.waitForTimeout(200);
|
||||
const save = dialog.getByRole("button", { name: "Uložit" });
|
||||
check("Save appears after editing, >= 44px", (await save.isVisible()) && (await box(save)).height >= 43.5);
|
||||
await keyInput.fill("test-key");
|
||||
await page.waitForTimeout(200);
|
||||
check("Save hides again when unchanged", (await dialog.getByRole("button", { name: "Uložit" }).count()) === 0);
|
||||
|
||||
const tip = dialog.locator("details").first();
|
||||
check("privacy note starts collapsed", !(await tip.evaluate((el) => el.open)));
|
||||
await tip.locator("summary").click();
|
||||
await page.waitForTimeout(250);
|
||||
check("privacy expander opens", await tip.evaluate((el) => el.open));
|
||||
check("privacy expander mentions .env.local", await tip.getByText(".env.local").isVisible());
|
||||
await tip.locator("summary").click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// drag while scrolled won't dismiss, at the top it will
|
||||
await page.locator(".sheet-scroll").evaluate((el) => (el.scrollTop = 120));
|
||||
await page.waitForTimeout(250);
|
||||
await dragDown(page, page.locator(".sheet-scroll"));
|
||||
await page.waitForTimeout(700);
|
||||
check("scrolled: drag snaps back, sheet stays", (await dialog.count()) === 1);
|
||||
await page.locator(".sheet-scroll").evaluate((el) => (el.scrollTop = 0));
|
||||
await page.waitForTimeout(250);
|
||||
await dragDown(page, dialog.locator("h2"));
|
||||
await page.waitForTimeout(800);
|
||||
check("handle+header swipe closes the sheet", (await dialog.count()) === 0);
|
||||
|
||||
// backdrop tap
|
||||
await page.locator("nav.tabbar").getByRole("button").click();
|
||||
await dialog.waitFor({ timeout: 6000 });
|
||||
const sheetBox = await box(dialog);
|
||||
await page.mouse.click(195, Math.max(8, sheetBox.y - 24));
|
||||
await page.waitForTimeout(800);
|
||||
check("backdrop tap closes", (await dialog.count()) === 0);
|
||||
|
||||
// remove-key confirmation
|
||||
await page.locator("nav.tabbar").getByRole("button").click();
|
||||
await dialog.waitFor({ timeout: 6000 });
|
||||
await dialog.getByRole("button", { name: "Odstranit klíč z prohlížeče" }).click();
|
||||
await page.waitForTimeout(300);
|
||||
check("confirm step appears", await dialog.getByText(/vrátí se celoobrazovková/).isVisible());
|
||||
await dialog.getByRole("button", { name: "Zrušit" }).click();
|
||||
await page.waitForTimeout(300);
|
||||
check("cancel keeps the key", await dialog.getByRole("button", { name: "Odstranit klíč z prohlížeče" }).isVisible());
|
||||
await dialog.getByRole("button", { name: "Odstranit klíč z prohlížeče" }).click();
|
||||
await dialog.getByRole("button", { name: "Ano, odstranit" }).click();
|
||||
await page.waitForTimeout(1400);
|
||||
check("confirming brings the key gate back", await page.getByRole("heading", { name: "Připoj svůj klíč Ollama" }).isVisible());
|
||||
await context.close();
|
||||
}
|
||||
|
||||
/* 4. Czech at 360px: no overflow, key field fits --------------------- */
|
||||
{
|
||||
const { context, page } = await makePage(browser, { width: 360, height: 740 });
|
||||
await seed(page, { locale: "cs", decks: [DECK] });
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(1100);
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
|
||||
check("360px: library has no horizontal overflow", overflow <= 1, String(overflow));
|
||||
await page.locator("nav.tabbar").getByRole("button").click();
|
||||
await page.getByRole("dialog").waitFor({ timeout: 6000 });
|
||||
await page.waitForTimeout(600);
|
||||
const overflow2 = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
|
||||
check("360px: settings has no horizontal overflow", overflow2 <= 1, String(overflow2));
|
||||
const inputBox = await box(page.getByRole("dialog").locator("#ollama-api-key-phone"));
|
||||
const panel = await box(page.getByRole("dialog"));
|
||||
check("key field fits the sheet", inputBox.x >= panel.x && inputBox.x + inputBox.width <= panel.x + panel.width + 1);
|
||||
await context.close();
|
||||
}
|
||||
|
||||
/* 5. axe-core contrast audit (dark + light, en + cs) ----------------- */
|
||||
const AXE = axeSource();
|
||||
async function audit(label, { locale, theme, openSettingsNow }) {
|
||||
const { context, page } = await makePage(browser);
|
||||
await seed(page, { locale, theme, decks: [DECK] });
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(1200);
|
||||
if (openSettingsNow) {
|
||||
await page.locator("nav.tabbar").getByRole("button").click();
|
||||
await page.getByRole("dialog").waitFor({ timeout: 6000 });
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
await page.addScriptTag({ content: AXE });
|
||||
const report = await page.evaluate(async () => {
|
||||
const result = await window.axe.run(document, { runOnly: ["color-contrast"] });
|
||||
return {
|
||||
violations: result.violations.map((violation) => ({
|
||||
id: violation.id,
|
||||
nodes: violation.nodes.map((node) => ({ target: node.target.join(" "), summary: node.failureSummary?.split("\n")[0] })),
|
||||
})),
|
||||
};
|
||||
});
|
||||
check(`axe ${label}: no contrast violations`, report.violations.length === 0, JSON.stringify(report.violations).slice(0, 300));
|
||||
await context.close();
|
||||
}
|
||||
await audit("dark/en library", { locale: "en", theme: "dark" });
|
||||
await audit("dark/cs library", { locale: "cs", theme: "dark" });
|
||||
await audit("light/en library", { locale: "en", theme: "light" });
|
||||
await audit("dark/en settings", { locale: "en", theme: "dark", openSettingsNow: true });
|
||||
await audit("dark/cs settings", { locale: "cs", theme: "dark", openSettingsNow: true });
|
||||
|
||||
await browser.close();
|
||||
finish();
|
||||
190
tests/native-test.mjs
Normal file
190
tests/native-test.mjs
Normal 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
378
tests/phase1-test.mjs
Normal 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
262
tests/phase2-test.mjs
Normal 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
197
tests/phase3-test.mjs
Normal 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
115
tests/regression.mjs
Normal 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
130
tests/review-test.mjs
Normal 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
44
tests/run-all.mjs
Normal 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
213
tests/wizard-test.mjs
Normal 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue