Mobile engines repaint background-attachment: fixed late (iOS treats it as scroll), so the baked glass showed a stale crop of the wallpaper until a tap forced a repaint - "some of the UI looks bugged until I click it". On touch devices (max-width 639px or pointer: coarse) the bake is dropped for a real backdrop-filter over the live scene, so the bubbles blur through the glass as they pass and the position is correct by construction. Hand-rolled frost fills keep their own tints there; desktop keeps the baked layer (measured cheaper and verified correct). Pinned with a touch-context check in the mobile suite (124 checks). Contrast 0 findings, axe clean, chat 101/101, regression 44/44, i18n 13/13, native 33/33. Version 0.3.8, SW -> freezmo-v67.
304 lines
10 KiB
JavaScript
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, isMobile, hasTouch } = {}) {
|
|
const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor, reducedMotion, colorScheme, isMobile, hasTouch });
|
|
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");
|
|
}
|
|
},
|
|
};
|
|
}
|