Move the e2e suites into the repo (tests/), out of /tmp
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.
This commit is contained in:
parent
2103f9fc69
commit
dbd5dfbc58
17 changed files with 2280 additions and 8 deletions
294
tests/lib.mjs
Normal file
294
tests/lib.mjs
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
/**
|
||||
* 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");
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue