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.
262 lines
12 KiB
JavaScript
262 lines
12 KiB
JavaScript
/**
|
|
* 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();
|