/** * 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, rmSync } from "node:fs"; import { spawn } from "node:child_process"; import path from "node:path"; import { BASE, DECK, REPO, dueSrs, launchBrowser, makeChecker, makePage, portInUse, readLearning, seed, seedLearning, spawnApp, waitForHttp, } from "./lib.mjs"; import { reminderPayload } from "../scripts/reminder-payload.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); /* 0. the study chrome is one pill that its controls fit -------------- */ const chrome = await page.evaluate(() => { const pill = document.querySelector("header .study-chrome"); if (!pill) return null; const box = pill.getBoundingClientRect(); const radius = Number.parseFloat(getComputedStyle(pill).borderTopLeftRadius) || 0; const control = pill.querySelector("a, button").getBoundingClientRect(); const centreY = box.top + box.height / 2; return { height: Math.round(box.height), fullyRounded: radius >= box.height / 2 - 1, /** The control's far corner stays inside the stadium curve. */ cornerInside: Math.hypot(control.left - (box.left + box.height / 2), control.top - centreY) < box.height / 2, counter: /\d+\s*\/\s*\d+/.test(pill.textContent ?? ""), }; }); check( "chrome: one rounded pill, controls inside its ends", chrome !== null && chrome.fullyRounded && chrome.cornerInside && chrome.counter && chrome.height <= 70, JSON.stringify(chrome), ); 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(); } /* 1c. skipping a card is not a mistake ------------------------------- */ { /* Swiping through a deck without answering used to record a 0% attempt and list every card under "Missed items" — "you can just scroll all the way down and it counts everything as incorrect". Skipped cards now wait under "Not answered", and the score counts the cards that were answered. */ const skipped = await makePage(browser); await seed(skipped.page, { decks: [DECK] }); await skipped.page.goto(`${BASE}/deck/seed_deck`, { waitUntil: "networkidle" }); await skipped.page.waitForTimeout(900); await skipped.page.keyboard.press("End"); await skipped.page.waitForTimeout(1000); check("skip: the all-skipped run offers the waiting cards", (await skipped.page.getByText("Not answered (2)").count()) === 1); check("skip: nothing is listed as a mistake", (await skipped.page.getByText(/Missed items/).count()) === 0); check( "skip: an empty run gets no score line", (await skipped.page.getByText("Nothing answered this time — pick up where you left off.").count()) === 1, ); const skippedDeck = await skipped.page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.decks.v1"))[0]); check( "skip: no attempt is recorded for an empty run", (skippedDeck.attempts ?? 0) === 0 && skippedDeck.bestScore === undefined, JSON.stringify({ attempts: skippedDeck.attempts, bestScore: skippedDeck.bestScore }), ); await skipped.context.close(); /* Answer one of two: the score counts the answered card only, and the waiting one can be picked up from the results screen. */ const partial = await makePage(browser); await seed(partial.page, { decks: [DECK] }); await partial.page.goto(`${BASE}/deck/seed_deck`, { waitUntil: "networkidle" }); await partial.page.waitForTimeout(900); await partial.page.getByRole("button", { name: "Blue" }).click(); await partial.page.waitForTimeout(500); await partial.page.keyboard.press("End"); await partial.page.waitForTimeout(1000); check("skip: the score counts answered cards only", (await partial.page.getByText("You got 1 of 1 right.").count()) === 1); check("skip: the unanswered card waits under its own heading", (await partial.page.getByText("Not answered (1)").count()) === 1); check("skip: it is not listed as missed", (await partial.page.getByText(/Missed items/).count()) === 0); const partialDeck = await partial.page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.decks.v1"))[0]); check( "skip: the attempt records the answered card", partialDeck.attempts === 1 && partialDeck.bestScore === 100, JSON.stringify({ attempts: partialDeck.attempts, bestScore: partialDeck.bestScore }), ); await partial.page.getByRole("button", { name: "Answer the rest (1)" }).click(); await partial.page.waitForTimeout(1000); check( "skip: catching up opens on the waiting card", await partial.page.getByRole("region", { name: /Activity 1 of/ }).getByText("What makes the sky blue?").isVisible(), ); await partial.page.getByRole("button", { name: "Scattering" }).click(); await partial.page.waitForTimeout(600); const caughtUp = await readLearning(partial.page); check( "skip: catching up records the second answer", caughtUp.answers.length === 2 && caughtUp.srs.length === 2, JSON.stringify({ answers: caughtUp.answers.length, srs: caughtUp.srs.length }), ); await partial.context.close(); } /* 1b. AI grading feedback carries the disclaimer (stubbed model call) - */ { const { context, page } = await makePage(browser); const shortDeck = { ...DECK, id: "seed_short", title: "Short answers", // Two activities on purpose: with one, grading ends the session and the // results screen replaces the graded view before the check runs. activities: [ { type: "short_answer", id: "s1", xp: 10, topic: "Sky", question: "Why is the sky blue?", referenceAnswer: "Rayleigh scattering favours short wavelengths.", keyPoints: ["scattering", "short wavelengths"], explanation: "Small molecules scatter blue light more than red.", }, { type: "multiple_choice", id: "s2", xp: 10, topic: "Sky", question: "Which colour scatters most?", options: ["Blue", "Red", "Green", "Yellow"], correctIndex: 0, explanation: "Short wavelengths scatter more.", }, ], }; await page.route("**/api/grade", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ correct: true, feedback: "Nice — scattering does the work.", missing: [] }), }), ); await seed(page, { decks: [shortDeck] }); await page.goto(`${BASE}/deck/seed_short`, { waitUntil: "networkidle" }); await page.waitForTimeout(1000); await page.locator("textarea").fill("Because small molecules scatter blue light more."); await page.getByRole("button", { name: "Submit answer" }).click(); await page.waitForTimeout(800); check("grading: AI feedback rendered", await page.getByText("Nice — scattering does the work.").isVisible()); // The disclaimer sits below the graded feedback (the feed's row carries it; // the activity no longer duplicates it right after the grading box). const feedbackDisclaimer = await page.evaluate(() => { const feedback = [...document.querySelectorAll("p")].find((el) => el.textContent.includes("Nice — scattering does the work.")); const note = [...document.querySelectorAll("p")].find((el) => el.textContent.includes("Freezmo uses AI")); if (!feedback || !note) return false; return note.getBoundingClientRect().top > feedback.getBoundingClientRect().bottom - 4; }); check("grading: AI disclaimer below the feedback", feedbackDisclaimer); await context.close(); } /* 1c. report question: skips, records, hides, undo ------------------- */ { const { context, page } = await makePage(browser); const posts = []; await page.route("**/api/reports", (route) => { posts.push(JSON.parse(route.request().postData() ?? "{}")); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true }) }); }); await seed(page, { decks: [DECK] }); await page.goto(`${BASE}/deck/seed_deck`, { waitUntil: "networkidle" }); await page.waitForTimeout(1000); const report = page.getByRole("button", { name: "Report question" }); check("report: button visible", await report.isVisible()); // It belongs under the card now: bottom-right corner of the exercise, not // in the top chrome where it was easy to mis-tap. const placement = await page.evaluate(() => { const prompt = [...document.querySelectorAll("p")].find( (el) => el.textContent === "What colour is a clear daytime sky?", ); const card = prompt?.closest(".glass-solid"); const button = document.querySelector('[aria-label="Report question"]'); if (!card || !button) return null; const c = card.getBoundingClientRect(); const b = button.getBoundingClientRect(); return { inChrome: Boolean(button.closest("header")), belowCard: b.top >= c.bottom - 6, rightAligned: Math.abs(b.right - c.right) <= 10, rightSide: b.left + b.width / 2 > c.left + c.width / 2, size: [Math.round(b.width), Math.round(b.height)], }; }); check( "report: sits bottom-right under the card, 44px", Boolean(placement) && !placement.inChrome && placement.belowCard && placement.rightAligned && placement.rightSide && placement.size[0] >= 43.5 && placement.size[1] >= 43.5, JSON.stringify(placement), ); await report.click(); await page.waitForTimeout(700); check("report: toast with Undo", await page.getByText("Question reported").isVisible()); check( "report: sent to the server", posts.length === 1 && posts[0].activityId === "a1" && posts[0].question.includes("What colour"), JSON.stringify(posts[0] ?? {}).slice(0, 80), ); const stored = await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.reports.v1") ?? "[]")); check("report: kept locally", stored.length === 1 && stored[0].activityId === "a1"); check("report: skips to the next activity", await page.getByText("2/2").isVisible()); // A fresh session leaves the reported question out. await page.goto(`${BASE}/deck/seed_deck`, { waitUntil: "networkidle" }); await page.waitForTimeout(1000); check( "report: hidden in the next session", (await page.getByText("What colour is a clear daytime sky?").count()) === 0 && (await page.getByText("What makes the sky blue?").count()) >= 1, ); // Undo takes a report back (the earlier one stays). await page.goto(`${BASE}/deck/seed_deck`, { waitUntil: "networkidle" }); await page.waitForTimeout(900); await page.getByRole("button", { name: "Report question" }).click(); await page.waitForTimeout(400); const before = await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.reports.v1") ?? "[]").map((report) => report.activityId), ); await page.getByRole("button", { name: "Undo" }).click(); await page.waitForTimeout(300); const after = await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.reports.v1") ?? "[]").map((report) => report.activityId), ); check("report: Undo takes the report back", before.includes("a2") && !after.includes("a2") && after.includes("a1"), JSON.stringify({ before, after })); await context.close(); } /* 1d. flashcard flip paints exactly one face ------------------------- */ { const { context, page } = await makePage(browser); const flashDeck = { ...DECK, id: "seed_flash", title: "Flashcards", activities: [ { type: "flashcard", id: "f1", xp: 10, topic: "Sky", front: "Why is the sky blue?", back: "Rayleigh scattering.", explanation: "e" }, ], }; await seed(page, { decks: [flashDeck] }); await page.goto(`${BASE}/deck/seed_flash`, { waitUntil: "networkidle" }); await page.waitForTimeout(1000); const faces = () => page.evaluate(() => { const motion = document.querySelector("button.group > div"); const [front, back] = motion ? [...motion.children] : []; const visibility = (element) => (element ? getComputedStyle(element).visibility : null); return { front: visibility(front), back: visibility(back) }; }); const atRest = await faces(); check("flashcard: front face painted at rest", atRest.front === "visible" && atRest.back === "hidden", JSON.stringify(atRest)); check( "feed: AI disclaimer under the card", (await page.getByText("Freezmo uses AI and can make mistakes").count()) >= 1, ); await page.locator("button.group").first().click(); await page.waitForTimeout(900); const flipped = await faces(); check( "flashcard: flip paints the back face only (never mirrored)", flipped.front === "hidden" && flipped.back === "visible", JSON.stringify(flipped), ); check("flashcard: the answer reads inside the card", await page.locator("button.group").getByText("Rayleigh scattering.").isVisible()); await page.locator("button.group").first().click(); await page.waitForTimeout(1000); const back = await faces(); check("flashcard: flipping back restores the question face", back.front === "visible" && back.back === "hidden", JSON.stringify(back)); // Answer it and check the results slide carries the disclaimer too. await page.locator("button.group").first().click(); await page.waitForTimeout(800); await page.getByRole("button", { name: "I knew it" }).click(); await page.waitForTimeout(500); await page.getByRole("button", { name: "See results" }).click(); await page.waitForTimeout(800); const resultsDisclaimer = await page.evaluate(() => { const slides = [...document.querySelectorAll("[data-testid='study-feed'] section")]; return slides[slides.length - 1]?.textContent?.includes("Freezmo uses AI") ?? false; }); check("results: AI disclaimer on the results slide", resultsDisclaimer); 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(); } /* 2b. review and exam leave reported questions out ------------------- */ { const { context, page } = await makePage(browser); await page.addInitScript(() => { localStorage.setItem( "freezmo.reports.v1", JSON.stringify([ { deckId: "seed_deck", activityId: "a1", type: "multiple_choice", question: "What colour is a clear daytime sky?", at: Date.now() }, ]), ); }); await seed(page, { decks: [DECK] }); await page.goto(`${BASE}/exam`, { waitUntil: "networkidle" }); await page.waitForTimeout(900); await page.getByRole("button", { name: /Sky deck/ }).click(); await page.waitForTimeout(200); await page.getByRole("button", { name: /Start exam/ }).click(); await page.waitForTimeout(1000); check( "exam: reported question is out of the pool", (await page.getByText("What colour is a clear daytime sky?").count()) === 0 && (await page.getByText("What makes the sky blue?").count()) >= 1, ); check("exam: AI disclaimer under the card", (await page.getByText("Freezmo uses AI and can make mistakes").count()) >= 1); 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()); /* The exam runs inside the app layout: the fixed feed (z-[45]) must sit over the sticky app header (z-40), not under it. */ const examChrome = await page.evaluate(() => { const pill = document.querySelector("header .study-chrome"); if (!pill) return null; const box = pill.getBoundingClientRect(); const atTop = document.elementFromPoint(Math.round(box.left + box.width / 2), Math.round(box.top + 4)); return Boolean(atTop?.closest(".study-chrome")); }); check("exam: chrome paints over the app header", examChrome === true, String(examChrome)); 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); /* The feedback shows, but the "from your notes" passage stays hidden in exam mode with the other reveals (the deck's notes do contain the answer). */ check( "exam: no notes source under the feedback", (await page.getByText("Why:").count()) >= 1 && (await page.locator("[data-notes-source]").count()) === 0, ); 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))); const pushDir = path.join(REPO, "tests", ".tmp", "push"); mkdirSync(pushDir, { recursive: true }); /* Start from an empty store: a previous run's subscriptions (and their reminder texts) must never be mistaken for this run's. */ rmSync(path.join(pushDir, "subscriptions.json"), { force: 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"), // The endpoint in this suite is a fake host: allow it explicitly. PUSH_ALLOWED_HOSTS: "push.example.invalid", }); 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: a subscription with no reminder text stores nothing about the notes", file.length === 1 && Object.keys(file[0]).sort().join(",") === "createdAt,endpoint,keys", JSON.stringify(Object.keys(file[0] ?? {})), ); 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 badMessage = 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, message: { title: "Hi", body: "There", url: "https://elsewhere.example/" } }), }); return response.status; }, subscription); check("push: a reminder link that leaves the app → 400", badMessage === 400, String(badMessage)); 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(); /* 5b. the reminder text is composed in the browser, from the notes --- The server never sees the notes, so the message each device wants travels with its subscription. The app's own background refresh is driven here with a faked browser subscription (the headless browser has no push service of its own) — the composer, the store and the read-back are all the real code. */ const subscriptionsFile = path.join(REPO, "tests", ".tmp", "push", "subscriptions.json"); const scenarios = [ { name: "en, two cards due in Sky deck", locale: "en", decks: [DECK], due: [dueSrs("seed_deck", "a1"), dueSrs("seed_deck", "a2")], endpoint: "https://push.example.invalid/personal-en", title: "Time to review", body: "2 cards are due in Sky deck", url: "/review", }, { name: "cs, one card due", locale: "cs", decks: [DECK], due: [dueSrs("seed_deck", "a2")], endpoint: "https://push.example.invalid/personal-cs", title: "Čas na opakování", body: "1 kartička čeká v sadě Sky deck", url: "/review", }, { name: "en, no notes at all", locale: "en", decks: [], due: [], endpoint: "https://push.example.invalid/generic-en", title: "Time to review", body: "Your decks are waiting — a few due cards keep the streak alive.", url: "/", }, { name: "cs, no notes at all", locale: "cs", decks: [], due: [], endpoint: "https://push.example.invalid/generic-cs", title: "Čas na opakování", body: "Sady na tebe čekají — pár kartiček udrží sérii.", url: "/", }, ]; for (const scenario of scenarios) { const personal = await browser.newContext({ viewport: { width: 390, height: 844 } }); const personalPage = await personal.newPage(); await seed(personalPage, { decks: scenario.decks, locale: scenario.locale }); await personalPage.goto("http://127.0.0.1:3209/", { waitUntil: "networkidle" }); await personalPage.waitForTimeout(700); if (scenario.due.length > 0) { /* The home screen reads the learning store on mount, so the mirror is already hydrated: the records written here are picked up by a reload, exactly like a browser that opened the app after studying. */ await seedLearning(personalPage, { srs: scenario.due }); await personalPage.reload({ waitUntil: "networkidle" }); await personalPage.waitForTimeout(700); } await personalPage.evaluate(async (endpoint) => { const registration = await navigator.serviceWorker.ready; Object.defineProperty(registration, "pushManager", { configurable: true, value: { getSubscription: async () => ({ toJSON: () => ({ endpoint, keys: { p256dh: "B".repeat(64), auth: "A".repeat(22) } }), }), }, }); /* The component refreshes on visibility: the first pass (no real subscription here) leaves it primed for exactly this event. */ document.dispatchEvent(new Event("visibilitychange")); }, scenario.endpoint); let stored = null; for (let attempt = 0; attempt < 40 && !stored; attempt += 1) { await personalPage.waitForTimeout(150); const entries = JSON.parse(readFileSync(subscriptionsFile, "utf8")); stored = entries.find((entry) => entry.endpoint === scenario.endpoint) ?? null; } check( `push: ${scenario.name} — the browser-composed reminder`, stored?.message?.title === scenario.title && stored?.message?.body === scenario.body && stored?.message?.url === scenario.url, JSON.stringify(stored?.message ?? null), ); await personal.close(); } /* The sender script's fallback: a device with no text of its own gets the operator's generic lines, and a link that leaves the app is never sent. */ check("push: the sender prefers the device's own reminder text", (() => { const message = { title: "Own title", body: "Own body", url: "/review" }; const payload = reminderPayload({ message }, {}); return payload.title === "Own title" && payload.body === "Own body" && payload.url === "/review"; })()); check("push: the sender falls back to the generic lines", (() => { const payload = reminderPayload({}, { title: "Fallback", body: "Fallback body" }); return payload.title === "Fallback" && payload.body === "Fallback body" && payload.url === "/"; })()); check("push: the sender never sends a reminder link off the app", (() => { const payload = reminderPayload({ message: { title: "T", body: "B", url: "https://elsewhere.example/" } }, {}); return payload.url === "/"; })()); } } finally { app.stop(); } } await browser.close(); finish();