/** * Podcast mode: notes → a spoken episode with a synced transcript. * * Runs against a stub Ollama (no model needed) and a stubbed speech engine, so * the word highlight can be driven deterministically: the stub records what * was spoken and fires boundary events on demand. */ import http from "node:http"; import { mkdirSync } from "node:fs"; import { launchBrowser, makeChecker, makePage, portInUse, seed, spawnApp, waitForHttp, } from "./lib.mjs"; const { check, finish } = makeChecker(); const STUB_PORT = 3211; const APP_PORT = 3212; const STUB_BASE = `http://127.0.0.1:${STUB_PORT}`; const APP = `http://127.0.0.1:${APP_PORT}`; /** Two sections, a handful of words each — enough to watch the highlight walk. */ const EPISODE_REPLY = { title: "Why the sky is blue", language: "English", summary: "Rayleigh scattering, explained out loud.", sections: [ { heading: "Daylight", text: "Look up on a clear day. The sky is blue." }, { heading: "Why", text: "Tiny molecules scatter short wavelengths far more than long ones." }, ], }; /** An episode seeded straight into storage, for the voice/scroll sections. */ const EPISODE_RECORD = { id: "pod_local", title: "The sky, out loud", summary: "Rayleigh scattering.", language: "English", sourceText: "Rayleigh scattering.", createdAt: Date.now(), updatedAt: Date.now(), sections: [ { heading: "Daylight", text: "Look up on a clear day. The sky is blue. Every direction you look, the same blue." }, { heading: "Why", text: "Tiny molecules scatter short wavelengths far more than long ones, so blue reaches your eyes from everywhere at once." }, ], }; /** Long enough to scroll: eight paragraphs of a few lines each. */ const LONG_RECORD = { ...EPISODE_RECORD, id: "pod_long", title: "A long walk through the atmosphere", /* One long sentence per part on purpose: with the sentence clock a section is spoken in one utterance only while it has no sentence break, which is exactly the shape the scroll checks need (a boundary can land far away). */ sections: Array.from({ length: 8 }, (_, index) => ({ heading: `Part ${index + 1}`, text: `Part ${index + 1}, ${"the atmosphere scatters light in every direction, and the shorter wavelengths bounce far more often than the longer ones, ".repeat(3).trim()}`, })), }; /** Three sentences: the fallback clock must re-anchor at every one. */ const SENTENCES_RECORD = { ...EPISODE_RECORD, id: "pod_sentences", sections: [ { heading: "Daylight", text: "Look up on a clear day. The sky is blue. Molecules scatter short wavelengths far more than long ones. That is what reaches your eyes.", }, { heading: "Why", text: "That scattering is what colours the sky." }, ], }; /** A speech engine that never reports boundaries: the timer clock drives it. */ const WORD_TIMER_SPEECH = ` window.__utterances = []; class FakeUtterance { constructor(text) { this.text = String(text); this.rate = 1; this.lang = "en-US"; this.onstart = null; this.onend = null; this.onerror = null; this.onboundary = null; } } Object.defineProperty(window, "SpeechSynthesisUtterance", { configurable: true, value: FakeUtterance }); Object.defineProperty(window, "speechSynthesis", { configurable: true, value: { speak(utterance) { window.__utterances.push(utterance); queueMicrotask(() => utterance.onstart && utterance.onstart({ name: "start", charIndex: 0 })); }, cancel() {}, getVoices() { return [{ name: "Daniel", lang: "en-GB", voiceURI: "daniel", localService: true, default: true }]; }, addEventListener() {}, removeEventListener() {}, speaking: false, paused: false, }, }); `; const stub = http.createServer((request, response) => { if (request.url?.startsWith("/api/tags")) { response.writeHead(200, { "Content-Type": "application/json" }); response.end(JSON.stringify({ models: [{ name: "stub", model: "stub" }] })); return; } let raw = ""; request.on("data", (chunk) => (raw += chunk)); request.on("end", () => { let content = "{}"; try { const body = JSON.parse(raw); const system = String(body.messages?.[0]?.content ?? ""); /* The podcast writer answers slowly on purpose: a real script takes a model a while, and the loading screen must be observable. */ if (system.includes("podcast writer")) { content = JSON.stringify(EPISODE_REPLY); setTimeout(() => { response.writeHead(200, { "Content-Type": "application/json" }); response.end(JSON.stringify({ message: { content } })); }, 900); return; } if (system.includes("study-note transcriber")) { content = JSON.stringify({ title: "Sky notes", language: "English", notes: "Rayleigh scattering makes the sky blue.", keyConcepts: ["scattering"], }); } } catch { /* keep the default */ } response.writeHead(200, { "Content-Type": "application/json" }); response.end(JSON.stringify({ message: { content } })); }); }); await new Promise((resolve) => stub.listen(STUB_PORT, "127.0.0.1", resolve)); check("podcast app port is free", !(await portInUse(APP_PORT))); const syncDir = new URL("./.tmp/sync-podcast", import.meta.url).pathname; mkdirSync(syncDir, { recursive: true }); const app = spawnApp(APP_PORT, { OLLAMA_BASE_URL: STUB_BASE, OLLAMA_AUTH: "local", OLLAMA_VERIFY_KEY: "false", SYNC_DIR: syncDir, }); const browser = await launchBrowser(); /** Records utterances and lets the test fire boundaries/ends by hand. */ const SPEECH_STUB = ` window.__spoken = []; window.__cancelCount = 0; window.__droppedWhilePaused = 0; window.__utterances = []; class FakeUtterance { constructor(text) { this.text = String(text); this.rate = 1; this.lang = "en-US"; this.onstart = null; this.onend = null; this.onerror = null; this.onboundary = null; } } Object.defineProperty(window, "SpeechSynthesisUtterance", { configurable: true, value: FakeUtterance }); Object.defineProperty(window, "speechSynthesis", { configurable: true, value: { _speaking: false, _paused: false, get speaking() { return this._speaking; }, get pending() { return false; }, get paused() { return this._paused; }, speak(utterance) { /* WebKit's quirk, on purpose: a cancel leaves the synthesis paused and a speak() while paused is dropped on the floor. The app must resume before it speaks again. */ if (this._paused) { window.__droppedWhilePaused += 1; return; } window.__spoken.push(utterance.text); window.__utterances.push(utterance); this._speaking = true; queueMicrotask(() => utterance.onstart && utterance.onstart({ name: "start", charIndex: 0 })); }, cancel() { window.__cancelCount += 1; this._speaking = false; this._paused = true; }, resume() { this._paused = false; }, /* One installed voice: a working engine, which is what this stub is. */ getVoices() { return [{ name: "Daniel", lang: "en-GB", voiceURI: "daniel", localService: true, default: true }]; }, }, }); `; try { const up = await waitForHttp(`${APP}/api/health`); check("podcast app instance is up", up); if (up) { /* 1. the wizard: mode switch, intake, generation ---------------- */ const { context, page } = await makePage(browser, { width: 1280, height: 900, deviceScaleFactor: 1 }); await page.addInitScript(SPEECH_STUB); await seed(page, { decks: [], theme: "dark" }); await page.goto(`${APP}/create`, { waitUntil: "networkidle" }); await page.waitForTimeout(1000); const switchLink = page.getByRole("link", { name: "Podcast" }); check("wizard: the mode switch carries a podcast segment", (await switchLink.count()) === 1); await switchLink.click(); await page.waitForTimeout(1200); check("wizard: the podcast intake opens", page.url().endsWith("/podcast")); check("wizard: the length options are offered", (await page.getByRole("button", { name: "4 minutes" }).count()) === 1); await page.fill("#podcast-text", "Rayleigh scattering makes the sky blue: molecules scatter short wavelengths far more than long ones."); await page.waitForTimeout(200); await page.getByRole("button", { name: "Create the episode" }).first().click(); /* Loading screen while the stub answers. */ await page.waitForTimeout(600); const loading = (await page.getByText(/Writing the script|Reading your notes/).count()) >= 1; await page.waitForURL(/\/podcast\/pod_/, { timeout: 30_000 }).catch(() => {}); await page.waitForTimeout(1200); check("wizard: it lands on the player", /\/podcast\/pod_/.test(page.url()), page.url()); check("wizard: the loading screen showed a script stage", loading); const stored = await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.podcasts.v1") ?? "[]")); check("wizard: the episode is saved with both sections", stored.length === 1 && stored[0].sections.length === 2, JSON.stringify(stored[0]?.sections?.length)); check("wizard: the notes ride along for a rewrite", (stored[0]?.sourceText ?? "").includes("Rayleigh")); /* 2. the player: transcript, highlight, controls ----------------- */ const words = await page.locator("[data-word]").count(); /* "Look up on a clear day. The sky is blue." (10) + "Tiny molecules scatter short wavelengths far more than long ones." (10). */ check("player: the transcript is word-wrapped", words === 20, String(words)); check("player: nothing is highlighted before playing", (await page.locator("[data-word-active]").count()) === 0); await page.getByRole("button", { name: "Play the episode" }).click(); await page.waitForTimeout(400); const firstSpoken = await page.evaluate(() => window.__spoken[0]); check("player: the first section is what gets spoken", String(firstSpoken).startsWith("Look up on a clear day."), String(firstSpoken).slice(0, 40)); check("player: the first word is highlighted", (await page.locator("[data-word-active]").count()) === 1); /* The boundary events are the exact clock: jump to a later word. */ await page.evaluate(() => { const utterance = window.__utterances.at(-1); utterance.onboundary({ name: "word", charIndex: 12 }); }); await page.waitForTimeout(300); const active = await page.locator("[data-word-active]").first(); check("player: a boundary event moves the highlight", (await active.textContent()) === "clear", String(await active.textContent())); /* Pausing stops the voice and keeps the place. */ await page.getByRole("button", { name: "Pause the episode" }).click(); await page.waitForTimeout(300); const cancelled = await page.evaluate(() => window.__cancelCount); check("player: pause cancels the voice", cancelled >= 1, String(cancelled)); /* Resuming speaks from the highlighted word, not from the top. */ await page.getByRole("button", { name: "Play the episode" }).click(); await page.waitForTimeout(400); const resumed = await page.evaluate(() => window.__spoken.at(-1)); check("player: resume starts at the current word", String(resumed).startsWith("clear day."), String(resumed).slice(0, 30)); check( "player: nothing was spoken into a paused engine (the WebKit quirk)", (await page.evaluate(() => window.__droppedWhilePaused)) === 0, String(await page.evaluate(() => window.__droppedWhilePaused)), ); /* 3. chapters, speed, progress save ------------------------------ */ await page.getByRole("button", { name: "Why", exact: true }).click(); await page.waitForTimeout(500); const chapterSpoken = await page.evaluate(() => window.__spoken.at(-1)); check("player: a chapter jump speaks that section", String(chapterSpoken).startsWith("Tiny molecules"), String(chapterSpoken).slice(0, 30)); check( "player: the header names the part", (await page.getByText("Part 2 of 2").count()) === 1, ); await page.getByRole("button", { name: "Playback speed" }).click(); await page.waitForTimeout(500); const rate = await page.evaluate(() => { const utterance = window.__utterances.at(-1); return utterance?.rate; }); check("player: the speed applies to the voice", rate === 1.15, String(rate)); /* Speed changes rebuild the engine mid-play; it keeps speaking. */ const spokenCount = await page.evaluate(() => window.__spoken.length); check("player: changing speed keeps playing", spokenCount >= 4, String(spokenCount)); /* Leaving saves the position for the next visit. */ await page.goto(`${APP}/?tab=podcasts`, { waitUntil: "networkidle" }); await page.waitForTimeout(900); const saved = await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.podcasts.v1") ?? "[]")[0]?.progress); check("player: the position is saved on leave", Boolean(saved) && saved.section === 1, JSON.stringify(saved)); /* 4. the library tab -------------------------------------------- */ check("library: the podcasts tab lists the episode", (await page.getByText("Why the sky is blue").count()) === 1); await page.getByRole("link", { name: "Listen" }).click(); await page.waitForTimeout(1200); check("library: Listen opens the player", /\/podcast\/pod_/.test(page.url()), page.url()); check( "player: reopening resumes at the saved part", (await page.getByText("Part 2 of 2").count()) === 1, ); await context.close(); /* 4b. voices: the best installed one, and a picker that sticks ------ */ { const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1, isMobile: true, hasTouch: true }); /* A catalogue that looks like a bare Linux box next to Chrome: the robot must lose to the natural male voice. */ await page.addInitScript(() => { window.__spoken = []; const voices = [ { name: "eSpeak English", lang: "en-US", voiceURI: "espeak", localService: true, default: true }, { name: "Google US English", lang: "en-US", voiceURI: "g-us", localService: false, default: false }, { name: "Google UK English Male", lang: "en-GB", voiceURI: "g-uk-male", localService: false, default: false }, { name: "Czech (Czech Republic)", lang: "cs-CZ", voiceURI: "cs-cz", localService: true, default: false }, ]; class FakeUtterance { constructor(text) { this.text = String(text); this.rate = 1; this.lang = "en-US"; this.voice = null; this.onstart = null; this.onend = null; this.onerror = null; this.onboundary = null; } } Object.defineProperty(window, "SpeechSynthesisUtterance", { configurable: true, value: FakeUtterance }); Object.defineProperty(window, "speechSynthesis", { configurable: true, value: { speak(utterance) { window.__spoken.push({ text: utterance.text, voice: utterance.voice?.voiceURI ?? null }); queueMicrotask(() => utterance.onstart && utterance.onstart({ name: "start", charIndex: 0 })); }, cancel() {}, getVoices: () => voices, addEventListener() {}, removeEventListener() {}, speaking: false, paused: false, }, }); }); await seed(page, { decks: [], theme: "dark", extra: { "freezmo.podcasts.v1": JSON.stringify([EPISODE_RECORD]) } }); await page.goto(`${APP}/podcast/pod_local`, { waitUntil: "networkidle" }); await page.waitForTimeout(1000); await page.getByRole("button", { name: "Play the episode" }).click(); await page.waitForTimeout(400); const firstVoice = await page.evaluate(() => window.__spoken.at(-1)?.voice); check("voice: the natural male voice beats the robot", firstVoice === "g-uk-male", String(firstVoice)); await page.getByRole("button", { name: "Voice" }).click(); await page.waitForTimeout(300); const listed = await page.evaluate(() => [...document.querySelectorAll("[aria-pressed]")].map((el) => el.textContent?.trim() ?? ""), ); check("voice: the picker lists the voices for the language", listed.some((entry) => entry.includes("Google UK English Male")), JSON.stringify(listed.slice(0, 4))); check("voice: other languages are filtered out", !listed.some((entry) => entry.includes("Czech")), JSON.stringify(listed)); await page.getByRole("button", { name: /Google US English/ }).click(); await page.waitForTimeout(500); const chosen = await page.evaluate(() => window.__spoken.at(-1)?.voice); check("voice: choosing one speaks with it", chosen === "g-us", String(chosen)); const stored = await page.evaluate(() => localStorage.getItem("freezmo.podcast-voice.v1")); check("voice: the choice is remembered", String(stored).includes("g-us"), String(stored)); await context.close(); } /* 4b2. an iPhone gets told where the other voices live -------------- */ { const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1, isMobile: true, hasTouch: true }); /* Safari's shape: one voice for the language, whatever is installed. */ await page.addInitScript(() => { Object.defineProperty(navigator, "userAgent", { configurable: true, get: () => "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", }); window.__spoken = []; const voices = [{ name: "Daniel", lang: "en-GB", voiceURI: "daniel", localService: true, default: true }]; class FakeUtterance { constructor(text) { this.text = String(text); this.rate = 1; this.lang = "en-US"; this.voice = null; } } Object.defineProperty(window, "SpeechSynthesisUtterance", { configurable: true, value: FakeUtterance }); Object.defineProperty(window, "speechSynthesis", { configurable: true, value: { speak(u) { queueMicrotask(() => u.onstart?.({ name: "start", charIndex: 0 })); }, cancel() {}, getVoices: () => voices, addEventListener() {}, removeEventListener() {}, speaking: false, paused: false }, }); }); await seed(page, { decks: [], theme: "dark", extra: { "freezmo.podcasts.v1": JSON.stringify([EPISODE_RECORD]) } }); await page.goto(`${APP}/podcast/pod_local`, { waitUntil: "networkidle" }); await page.waitForTimeout(1000); const voiceButton = page.getByRole("button", { name: "Voice" }); const badge = await voiceButton.evaluate((element) => Boolean(element.querySelector("span[aria-hidden]"))); check("ios: the voice button carries a hint dot", badge); await voiceButton.click(); await page.waitForTimeout(300); const note = await page.getByText(/Spoken Content/).count(); check("ios: the picker explains where to add voices", note === 1, String(note)); const steps = await page.evaluate(() => { const text = [...document.querySelectorAll("p")].map((el) => el.textContent ?? "").find((entry) => entry.includes("Spoken Content")) ?? ""; return { settings: text.includes("Settings"), accessibility: text.includes("Accessibility"), premium: text.includes("Enhanced") || text.includes("Premium") }; }); check("ios: the note names the actual settings path", steps.settings && steps.accessibility && steps.premium, JSON.stringify(steps)); await context.close(); } /* 4b2b. no speech engine behind the browser (Firefox on Linux) ------- */ { const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1, isMobile: true, hasTouch: true }); /* speechSynthesis exists but is empty and silent — exactly what Firefox does on a Linux box without speech-dispatcher. */ await page.addInitScript(() => { class FakeUtterance { constructor(text) { this.text = String(text); this.rate = 1; this.lang = "en-US"; } } Object.defineProperty(window, "SpeechSynthesisUtterance", { configurable: true, value: FakeUtterance }); Object.defineProperty(window, "speechSynthesis", { configurable: true, value: { speak() { /* nothing at all: no engine, no events */ }, cancel() {}, getVoices: () => [], addEventListener() {}, removeEventListener() {}, speaking: false, paused: false, }, }); localStorage.setItem( "freezmo.providers.v1", JSON.stringify({ keys: { openai: "sk-test" }, models: {}, customBaseUrl: "", generation: null }), ); }); await seed(page, { decks: [], theme: "dark", extra: { "freezmo.podcasts.v1": JSON.stringify([EPISODE_RECORD]) } }); await page.goto(`${APP}/podcast/pod_local`, { waitUntil: "networkidle" }); await page.waitForTimeout(1000); await page.getByRole("button", { name: "Play the episode" }).click(); await page.waitForTimeout(400); const banner = await page.getByText(/speech engine behind it/).count(); check("no engine: the banner explains what is missing", banner === 1, String(banner)); check( "no engine: it names the Linux fix", (await page.getByText(/speech-dispatcher/).count()) === 1, ); const toNatural = page.getByRole("button", { name: "Use the natural voice" }); check("no engine: the one-tap way out is offered", (await toNatural.count()) === 1); await toNatural.click(); await page.waitForTimeout(400); check( "no engine: the switch sticks", (await page.evaluate(() => localStorage.getItem("freezmo.podcast-natural.v1")))?.includes("natural"), String(await page.evaluate(() => localStorage.getItem("freezmo.podcast-natural.v1"))), ); await context.close(); } /* 4b3. natural voices: the provider TTS behind the same player ------- */ { const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1, isMobile: true, hasTouch: true }); /* One second of silence as a real WAV — the player measures the clip and times its words against it, so the header alone is not enough. */ const wav = (() => { const sampleRate = 8000; const samples = sampleRate; const buffer = Buffer.alloc(44 + samples * 2); buffer.write("RIFF", 0); buffer.writeUInt32LE(36 + samples * 2, 4); buffer.write("WAVE", 8); buffer.write("fmt ", 12); buffer.writeUInt32LE(16, 16); buffer.writeUInt16LE(1, 20); buffer.writeUInt16LE(1, 22); buffer.writeUInt32LE(sampleRate, 24); buffer.writeUInt32LE(sampleRate * 2, 28); buffer.writeUInt16LE(2, 32); buffer.writeUInt16LE(16, 34); buffer.write("data", 36); buffer.writeUInt32LE(samples * 2, 40); return buffer; })(); const bodies = []; await page.route("**/api/tts", async (route) => { bodies.push(route.request().postDataJSON()); await route.fulfill({ status: 200, contentType: "audio/wav", body: wav }); }); await page.addInitScript(() => { localStorage.setItem( "freezmo.providers.v1", JSON.stringify({ keys: { openai: "sk-test" }, models: {}, customBaseUrl: "", generation: null }), ); }); await seed(page, { decks: [], theme: "dark", extra: { "freezmo.podcasts.v1": JSON.stringify([EPISODE_RECORD]) } }); await page.goto(`${APP}/podcast/pod_local`, { waitUntil: "networkidle" }); await page.waitForTimeout(1000); await page.getByRole("button", { name: "Voice" }).click(); await page.waitForTimeout(300); await page.getByRole("button", { name: "Natural voice" }).click(); await page.waitForTimeout(400); const voicesShown = await page.evaluate(() => [...document.querySelectorAll("button[aria-pressed]")].map((el) => el.textContent?.trim() ?? ""), ); check( "natural: the provider's voices are offered (male first)", voicesShown.some((entry) => entry.includes("Onyx")) && voicesShown.some((entry) => entry.includes("male")), JSON.stringify(voicesShown.slice(0, 5)), ); await page.getByRole("button", { name: /Onyx/ }).click(); await page.waitForTimeout(300); await page.getByRole("button", { name: "Close" }).click(); await page.waitForTimeout(200); await page.getByRole("button", { name: "Play the episode" }).click(); /* The stub clip is half a second of silence: check while it is playing. */ await page.waitForTimeout(320); const first = bodies[0]; check("natural: the voice is generated through the API", bodies.length >= 1, String(bodies.length)); check( "natural: the request carries the section text and the picked voice", Boolean(first) && String(first.text).startsWith("Daylight.") && first.voice === "onyx", JSON.stringify({ voice: first?.voice, head: String(first?.text).slice(0, 24) }), ); const audioState = await page.evaluate(() => { const elements = [...document.querySelectorAll("[data-podcast-audio]")]; const audio = elements.at(-1); return audio ? { count: elements.length, paused: audio.paused, blob: audio.src.startsWith("blob:") } : "no-audio-element"; }); check( "natural: one audio element plays the clip", typeof audioState === "object" && audioState.blob && audioState.paused === false && audioState.count === 1, JSON.stringify(audioState), ); check("natural: the position is remembered for later", (await page.evaluate(() => localStorage.getItem("freezmo.podcast-natural.v1")))?.includes("onyx"), ""); /* The word highlight rides the clip's real clock. */ const before = await page.evaluate(() => document.querySelector("[data-word-active]")?.textContent ?? ""); await page.waitForTimeout(1200); const after = await page.evaluate(() => document.querySelector("[data-word-active]")?.textContent ?? ""); check("natural: the highlight follows the audio", before !== "" && after !== "", JSON.stringify({ before, after })); await context.close(); } /* 4c. the transcript follows by line, and yields to a manual scroll --- */ { const { context, page } = await makePage(browser, { width: 390, height: 700, deviceScaleFactor: 1, isMobile: true, hasTouch: true }); await page.addInitScript(WORD_TIMER_SPEECH); await seed(page, { decks: [], theme: "dark", extra: { "freezmo.podcasts.v1": JSON.stringify([LONG_RECORD]) } }); await page.goto(`${APP}/podcast/pod_long`, { waitUntil: "networkidle" }); await page.waitForTimeout(1000); await page.getByRole("button", { name: "Play the episode" }).click(); await page.waitForTimeout(400); const scrollTop = () => page.evaluate(() => document.querySelector("[data-testid=podcast-transcript]")?.scrollTop ?? -1); const boundary = (charIndex) => page.evaluate((index) => { const utterance = window.__utterances.at(-1); utterance.onboundary({ name: "word", charIndex: index }); }, charIndex); /* Three words into the same line: the page must not move. */ const start = await scrollTop(); await boundary(3); await boundary(6); await boundary(9); await page.waitForTimeout(400); check("scroll: words on the same line do not move the page", (await scrollTop()) === start, `${start} -> ${await scrollTop()}`); /* A word far into the script: the page follows it. */ await boundary(900); await page.waitForTimeout(700); const followed = await scrollTop(); check("scroll: a new line is followed", followed > start, `${start} -> ${followed}`); /* The reader scrolls on purpose: following pauses instead of fighting. */ await page.evaluate(() => { const container = document.querySelector("[data-testid=podcast-transcript]"); container.scrollTop = 0; container.dispatchEvent(new WheelEvent("wheel", { bubbles: true })); }); await page.waitForTimeout(200); const manual = await scrollTop(); await boundary(1400); await page.waitForTimeout(500); check("scroll: a manual scroll is left alone", (await scrollTop()) === manual, `${manual} -> ${await scrollTop()}`); await context.close(); } /* 4d. no boundary events: sentences carry the clock ---------------- */ { const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1, isMobile: true, hasTouch: true }); /* An engine that never reports boundaries (Firefox + speech-dispatcher, iOS) and *finishes* every utterance, so the sentence clock can be watched sentence by sentence. */ await page.addInitScript(() => { window.__utterances = []; const SENTENCE_MS = 700; class FakeUtterance { constructor(text) { this.text = String(text); this.rate = 1; this.lang = "en-US"; this.voice = null; this.onstart = null; this.onend = null; this.onerror = null; this.onboundary = null; } } Object.defineProperty(window, "SpeechSynthesisUtterance", { configurable: true, value: FakeUtterance }); Object.defineProperty(window, "speechSynthesis", { configurable: true, value: { _speaking: false, get speaking() { return this._speaking; }, pending: false, paused: false, speak(utterance) { window.__utterances.push(utterance); window.speechSynthesis._speaking = true; queueMicrotask(() => utterance.onstart && utterance.onstart({ name: "start", charIndex: 0 })); setTimeout(() => { window.speechSynthesis._speaking = false; if (utterance.onend) utterance.onend(); }, SENTENCE_MS); }, cancel() { this._speaking = false; }, resume() {}, getVoices: () => [{ name: "Daniel", lang: "en-GB", voiceURI: "daniel", localService: true, default: true }], addEventListener() {}, removeEventListener() {}, }, }); }); await seed(page, { decks: [], theme: "dark", extra: { "freezmo.podcasts.v1": JSON.stringify([SENTENCES_RECORD]) } }); await page.goto(`${APP}/podcast/pod_sentences`, { waitUntil: "networkidle" }); await page.waitForTimeout(1000); await page.getByRole("button", { name: "Play the episode" }).click(); await page.waitForTimeout(500); const texts = () => page.evaluate(() => window.__utterances.map((u) => u.text)); /** Poll until at least `n` utterances have been spoken. */ const waitFor = async (n, timeout = 4000) => { const started = Date.now(); while (Date.now() - started < timeout) { if ((await page.evaluate(() => window.__utterances.length)) >= n) return true; await page.waitForTimeout(80); } return false; }; check("clock: without boundaries the first utterance is one sentence", (await waitFor(1)) && (await texts())[0] === "Look up on a clear day.", JSON.stringify(await texts())); check("clock: the next sentence follows on its own", (await waitFor(2)) && (await texts())[1] === "The sky is blue.", JSON.stringify(await texts())); const active = await page.evaluate(() => document.querySelector("[data-word-active]")?.textContent ?? ""); check("clock: the highlight is inside the spoken text", (await texts()).some((text) => text.includes(active)), JSON.stringify({ active })); /* A boundary event on the sentence that is playing hands the rest of the section to the exact clock: the next utterance is no longer one sentence. */ await page.evaluate(() => { const utterance = window.__utterances[1]; if (utterance && utterance.onboundary) utterance.onboundary({ name: "word", charIndex: 0 }); }); await waitFor(3); const spoken = await texts(); check( "clock: a boundary switches the rest of the section to one utterance", spoken.length >= 3 && (spoken[2].match(/\./g) ?? []).length >= 2, JSON.stringify(spoken), ); /* The highlight keeps moving with the voice instead of drifting away. */ const marks = []; for (let i = 0; i < 5; i += 1) { marks.push(await page.evaluate(() => document.querySelector("[data-word-active]")?.textContent ?? "")); await page.waitForTimeout(350); } check("clock: the highlight advances over time", new Set(marks.filter(Boolean)).size >= 2, JSON.stringify(marks)); await context.close();} /* 5. server guards ---------------------------------------------- */ const bad = await fetch(`${APP}/api/podcast`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "" }), }); check("api: empty notes are refused", bad.status === 400, String(bad.status)); const streamed = await fetch(`${APP}/api/podcast`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Some notes about the sky.", minutes: 3 }), }); const body = await streamed.text(); check("api: a script streams back with a podcast event", body.includes('"type":"podcast"') && body.includes('"sections"'), body.slice(0, 120)); } } finally { app.stop(); stub.close(); await browser.close(); } finish();