freezmo/tests/lib.mjs
Martin Dvorak c88ad2865a Thin frosted Aero glass, boot-theme fix, latest background revision
The white surfaces were reading as a milky white panel (a card top was
~71% white). The frosted recipes now use a 14% base veil plus the gloss
over the pre-blurred wallpaper, small chrome (rows, chips, segments,
buttons) keeps a touch more veil, and nothing nested inside a sheet or
dialog uses the viewport-aligned layer (it showed the raw dark water
under the settings theme labels at 1.5:1 — nested chrome falls back to
the flat fills over the genuinely blurred sheet).

The first-load text bug was the boot script: its dark test was
`s && !a ? s === 'dark' : matchMedia(...)`, which for a stored aero fell
through to the OS preference, so a dark-preferring machine loaded with
both `dark` and `aero` classes and every `.dark` rule painted over the
wallpaper until a theme toggle re-applied the classes. The stored value
now decides alone; a dark-colorScheme check pins it in the mobile suite
(123 checks).

Background: the user's latest Gemini scene (1280x714) re-encoded to
public/aero/background.webp (206 kB) with a fresh pre-blurred sibling.

Contrast: 0 below-minimum findings, 0 axe violations, all three themes.
Version 0.3.1, SW -> freezmo-v62.
2026-10-13 09:38:29 +00:00

304 lines
10 KiB
JavaScript

/**
* 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);
/**
* Where suites that spawn their own `next start` instances run from. Defaults
* to the repo the tests live in; scripts/deploy.sh points it at the release
* checkout so the pre-switch e2e run is hermetic.
*/
export const SPAWN_CWD = process.env.FREEZMO_SPAWN_CWD || REPO;
/* ------------------------------------------------------------------ */
/* 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,
/** Suites run as a returning user: the cookie notice is already dismissed.
* The regression suite passes false to test the notice itself. */
consent = true,
} = {},
) {
await page.addInitScript(
({ locale, theme, decks, folders, homework, key, extra, onlyIfMissing, consent }) => {
const put = (name, value) => {
if (onlyIfMissing && localStorage.getItem(name) !== null) return;
localStorage.setItem(name, value);
};
put("freezmo.install.installed", "1");
if (consent) put("freezmo.consent.v1", JSON.stringify({ at: 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, consent },
);
}
export async function makePage(browser, { width = 390, height = 844, deviceScaleFactor = 2, reducedMotion, colorScheme } = {}) {
const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor, reducedMotion, colorScheme });
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: SPAWN_CWD,
env: { ...process.env, ...env },
stdio: "ignore",
detached: true,
});
return {
server,
stop() {
try {
process.kill(-server.pid, "SIGTERM");
} catch {
server.kill("SIGTERM");
}
},
};
}