freezmo/tests/lib.mjs
Martin Dvorak 8e1cd0855d Podcast voices: natural provider TTS, and system-voice hygiene
Two reports, one root: the platform's speech engine.

"Loud cracking at the end of every sentence" is the browser/OS TTS popping
between its internal sentence chunks (an idle cancel makes it worse). Hygiene
for that path: never cancel when nothing is pending, normalise the spoken text
(control characters, ellipses, dashes), and a 140ms beat between sections so
speech never runs into the engine's tail.

The real fix is a second voice source that does not use the platform engine:
- lib/providers carries TTS profiles (OpenAI gpt-4o-mini-tts, Groq
  playai-tts, any OpenAI-compatible /audio/speech behind the custom provider),
  male voices first.
- /api/tts proxies them with the same guards and the key still in headers.
- the picker gains System / Natural, with the provider's voice list.
- lib/podcast-audio plays one clip per section through a hidden <audio>
  element, measures the clip's real duration and gives each word a slice of it
  proportional to its spoken length - the highlight follows the audio exactly,
  and pause/seek/chapters become real audio operations. Clips cache per
  session, the next section generates while the current plays, and a voice
  change disposes the element and its blobs. Speed applies live (setRate)
  without dropping the cache.

podcast-test stubs /api/tts with a real silent WAV and pins the path: the
voice list, the request body, exactly one playing element, and the highlight
riding currentTime. 43 checks. tests/lib.mjs launches Chromium with the
autoplay policy relaxed (a real device has the listener's gesture).
Version 0.7.6, SW v98.
2026-10-16 16:34:46 +00:00

320 lines
11 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 roots = [
path.join(os.homedir(), ".cache", "ms-playwright"),
path.join(os.homedir(), "Library", "Caches", "ms-playwright"),
];
const binaries = [
"chrome-linux64/chrome",
"chrome-linux/chrome",
"chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
"chrome-mac/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
"chrome-mac/Chromium.app/Contents/MacOS/Chromium",
];
for (const root of roots.filter((dir) => existsSync(dir))) {
const dirs = 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 binaries) {
const candidate = path.join(root, dir, sub);
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,
/* The podcast player plays generated audio through an <audio> element;
headless Chromium would refuse it without this flag (a real device has
the listener's own gesture, which is the policy's real gate). */
args: ["--no-sandbox", "--autoplay-policy=no-user-gesture-required"],
});
}
/** 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");
}
},
};
}