/** * 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"); } }, }; }