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.
294 lines
9.5 KiB
JavaScript
294 lines
9.5 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);
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 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");
|
|
}
|
|
},
|
|
};
|
|
}
|