diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6959eb9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + extension: + name: Extension and browser tests + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + - name: Install locked dependencies + run: npm ci + - name: Type check + run: npm run check + - name: Build extension + run: npm run build + - name: JavaScript unit tests + run: node --experimental-strip-types --test tests/*.test.mjs + - name: Browser playback tests + # Google Chrome is included in the GitHub-hosted Ubuntu runner image. + env: + CHROMIUM_PATH: /usr/bin/google-chrome + run: node tests/playback-browser.mjs + + bridge: + name: Python bridge tests + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + python-version: "3.12" + enable-cache: true + - name: Install locked dependencies + run: uv sync --locked + - name: Python unit tests + run: uv run --no-sync python -m unittest discover -s tests -p 'test_*.py' diff --git a/README.md b/README.md index 118ee2e..c8661e7 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,12 @@ Keep the local bridge running while listening. On an article page, choose **Listen to article** to read the article. Select text and choose **Listen** to read only that selection. Use **Back 15 seconds**, **Pause**, **Forward 15 seconds**, or **Stop** in the small player while audio is playing. +While listening to an article, double-click a word in the article to continue reading from that word, including while paused. +Words with cached audio seek immediately; words that have not been generated start a new stream from that point, skipping the intervening paragraphs. +You can still select a passage and choose **Listen** to read just that selection. +Paused listening sessions keep their background connection alive when you switch tabs. +If the background worker unexpectedly restarts, Readflow tries once to reconnect at the current word and preserves the pause state. +Reloading or updating the extension itself still requires refreshing an already-open article tab. While audio plays, Readflow smoothly brings the spoken line into view only when it leaves a comfortable reading area, including inside scrollable article panels. Scrolling or interacting with the page gives you four seconds before automatic following resumes; dragging or selecting text holds it until you release. Automatic following rests while audio is paused or buffering and uses instant scrolling when your system requests reduced motion. @@ -84,7 +90,7 @@ At 1× and speeds below 2×, Readflow requests one section at a time; at 2× it The extension limits all tabs together to three active Fish requests and starts more sections as the rolling buffer drains. Playback begins after three seconds of audio at the selected speed are ready, and waits if generation later falls behind. Audio sections are played in their original order, and their word timestamps are offset onto one continuous article timeline. -Changing speed or seeking reprocesses cached audio locally and does not request the same text again. +Changing speed or seeking within cached audio reprocesses it locally and does not request the same text again. Readflow converts inline ordinal math such as `$n^\text{th}$` to “nth” before sending it to Fish. Other formulas are left as written until Readflow has a reliable spoken form for them. @@ -133,6 +139,10 @@ Fish Audio events should arrive as the service generates audio and timestamp dat ## Run checks +GitHub Actions runs type checking, the production build, all JavaScript unit tests, the browser playback check, and Python bridge tests on every push and pull request. +The workflow can also be started manually from GitHub's **Actions** tab. +CI uses synthetic audio and mocked Fish connections, so it needs no Fish API key. + The TypeScript and DOM unit tests below require Node.js 22.13 or newer. ```sh @@ -140,3 +150,8 @@ npm run check npm run build node --experimental-strip-types --test tests/*.test.mjs ``` + +For a browser playback check, build first, then run `node tests/playback-browser.mjs` with Chromium installed (or set `CHROMIUM_PATH` to its executable). +This uses synthetic audio and a controlled extension port with real Web Audio to check a 45-second frozen-tab pause, word seeking, disconnect recovery, and selected-text listening. +It requires free local ports 4179, 4180, and 9224; stop the local bridge first. +It does not test Chrome's extension service-worker lifecycle or live Fish Audio. diff --git a/extension/background.ts b/extension/background.ts index 62c5bc1..92431bd 100644 --- a/extension/background.ts +++ b/extension/background.ts @@ -8,6 +8,7 @@ import { } from "./diagnostics-store"; import type { TextSection } from "./text-sections"; import { DEFAULT_VOICE, isFishVoice, type VoicePage } from "./voices"; +import { keepSessionConnected } from "./session-connection"; type StreamEvent = { event: "connected" | "audio" | "finish" | "error"; @@ -67,6 +68,7 @@ chrome.runtime.onConnect.addListener((port) => { } const controller = new AbortController(); + const stopHeartbeat = keepSessionConnected(chrome.runtime); let sessionController: AbortController | null = null; let disconnected = false; let session: TraceSession | null = null; @@ -90,6 +92,7 @@ chrome.runtime.onConnect.addListener((port) => { }; port.onDisconnect.addListener(() => { + stopHeartbeat(); disconnected = true; controller.abort(); sessionController?.abort(); @@ -156,6 +159,7 @@ chrome.runtime.onConnect.addListener((port) => { if (session && message.kind === "playback_started") { session.playback_started_ms = message.clientElapsedMs; } else if (session && message.kind === "playback_finished") { + stopHeartbeat(); finishSession(session, "finished"); } else if (session && message.kind === "stopped") { finishSession(session, "stopped"); diff --git a/extension/content.ts b/extension/content.ts index 37f3d7b..2faff7d 100644 --- a/extension/content.ts +++ b/extension/content.ts @@ -3,7 +3,7 @@ import { mapReadingOffsets } from "./reading-progress"; import { sentenceSpans } from "./text-map"; import { mapSentenceTimings, type SentenceTiming } from "./highlight-timeline"; import { createPageHighlighter, type PageSentenceHighlighter } from "./page-highlighter"; -import { createArticleSource, createSelectionSource, sliceReadingSource, sourceRanges, type ReadingSource } from "./reading-source"; +import { createArticleSource, createSelectionSource, selectedWordOffset, sliceReadingSource, sourceRanges, type ReadingSource } from "./reading-source"; import { createReadingAutoScroller } from "./auto-scroll"; import { bufferFramesForSpeed, isAudioAudible, playbackDuration, playedFrames } from "./playback-speed"; import { StreamingTimeStretch } from "./time-stretch"; @@ -11,7 +11,7 @@ import { resolveSeekTarget } from "./seek-target"; import { prepareSpokenSource } from "./spoken-text"; import { splitTextSections } from "./text-sections"; import { shortTapeTitle } from "./tape-label"; -import { extensionRuntime, RECONNECT_MESSAGE, sendExtensionMessage } from "./extension-runtime"; +import { connectionErrorMessage, extensionRuntime, RECONNECT_MESSAGE, sendExtensionMessage } from "./extension-runtime"; import { createVoicePicker } from "./voice-picker"; import type { FishVoice } from "./voices"; @@ -961,7 +961,7 @@ async function startPlayback( readingSource: ReadingSource, source: string, selectionRange?: Range, - options: { sourceOffset?: number; elapsedSeconds?: number; voice?: FishVoice; resumePaused?: boolean; startBufferSeconds?: number } = {}, + options: { sourceOffset?: number; elapsedSeconds?: number; voice?: FishVoice; resumePaused?: boolean; startBufferSeconds?: number; recoveryAttempts?: number } = {}, savedRead?: ReadingItem, ): Promise { const picker = voicePickers.get(controls.host); @@ -1002,7 +1002,7 @@ async function startPlayback( port = extensionRuntime().connect({ name: "readflow-tts" }); } catch { setTransportState(controls, "error"); - setPlayerStatus(controls, RECONNECT_MESSAGE); + setPlayerStatus(controls, connectionErrorMessage(typeof chrome === "undefined" ? undefined : chrome.runtime)); return; } @@ -1050,6 +1050,7 @@ async function startPlayback( const startupBufferFrames = (): number => Math.ceil((options.startBufferSeconds ?? START_BUFFER_SECONDS) * SAMPLE_RATE * playbackRate); let nextStart = 0; let streamFinished = false; + let portConnected = true; let playbackComplete = false; let stopped = false; let animationFrame = 0; @@ -1103,6 +1104,7 @@ async function startPlayback( if (!playbackComplete) persistProgress(); window.removeEventListener("pagehide", pageHide); stopped = true; + document.removeEventListener("dblclick", onArticleDoubleClick); setCurrentPlaybackSpeed = null; if (changePlaybackVoice === changeVoiceForSession) changePlaybackVoice = null; cancelAnimationFrame(animationFrame); @@ -1212,8 +1214,26 @@ async function startPlayback( changePlaybackVoice = changeVoiceForSession; port.onDisconnect.addListener(() => { - const error = chrome.runtime?.lastError; - if (!stopped && (!streamFinished || error)) reportError(RECONNECT_MESSAGE); + void chrome.runtime?.lastError; + portConnected = false; + if (stopped || streamFinished) return; + if (!chrome.runtime?.id || (options.recoveryAttempts ?? 0) >= 1) { + reportError(connectionErrorMessage(chrome.runtime)); + return; + } + + // Recover from an unexpected worker restart at the current word. Preserve + // pause state and the original source so later double-clicks can seek backward. + if (alignmentNeedsMapping) rebuildWordRanges(); + const seconds = getAudibleFrame() / SAMPLE_RATE; + const currentWord = [...locatedWords].reverse().find((word) => word.start <= seconds && word.sourceOffset !== null); + const sourceOffset = startOffset + (currentWord?.sourceOffset ?? 0); + const resumePaused = context.state === "suspended"; + cleanup(false); + void startPlayback(controls, readingSource, source, selectionRange, { + sourceOffset, resumePaused, voice, elapsedSeconds: elapsedBeforeSession + seconds, + recoveryAttempts: (options.recoveryAttempts ?? 0) + 1, + }); }); const finishIfReady = (): void => { @@ -1432,11 +1452,41 @@ async function startPlayback( seekTo((pendingSeekFrame ?? getAudibleFrame()) + seconds * SAMPLE_RATE); }; + const onArticleDoubleClick = (event: MouseEvent): void => { + if (stopped || source !== "Article") return; + const target = event.target instanceof Element ? event.target : null; + if (event.composedPath().includes(controls.host) || target?.closest( + "a, button, input, textarea, select, [contenteditable]:not([contenteditable='false']), [role='button'], [role='textbox']", + )) return; + const selection = window.getSelection(); + if (!selection?.rangeCount) return; + const offset = selectedWordOffset(readingSource, selection.getRangeAt(0)); + if (offset === null) return; + if (alignmentNeedsMapping) { + rebuildWordRanges(); + alignmentNeedsMapping = false; + } + const word = locatedWords.find((candidate) => candidate.sourceOffset !== null && startOffset + candidate.sourceOffset === offset); + selection.removeAllRanges(); + controls.selectionButton.hidden = true; + recordClientEvent("word_seek", word ? word.start * 1000 : undefined, { source_offset: offset }); + if (word) { + // Cached words use their exact audio timestamp. Double-click also resumes a pause. + seekTo(word.start * SAMPLE_RATE); + void context.resume().catch(() => reportError("Audio playback could not resume. Try again.")); + } else { + // Do not generate all skipped paragraphs just to reach a word with no audio yet. + cleanup(); + void startPlayback(controls, readingSource, source, undefined, { sourceOffset: offset, voice }); + } + }; + document.addEventListener("dblclick", onArticleDoubleClick); + setCurrentPlaybackSpeed = (rate) => { const frame = pendingSeekFrame ?? getAudibleFrame(); playbackRate = rate; - try { port.postMessage({ type: "set_speed", rate }); } catch { - reportError(RECONNECT_MESSAGE); + try { if (portConnected) port.postMessage({ type: "set_speed", rate }); } catch { + reportError(connectionErrorMessage(chrome.runtime)); return; } recordClientEvent("speed_changed", frame / SAMPLE_RATE * 1000, { rate }); @@ -1507,7 +1557,7 @@ async function startPlayback( lastProgressAt = now; persistProgress(); } - if (now - lastBufferStatusAt >= 250) { + if (portConnected && !streamFinished && now - lastBufferStatusAt >= 250) { lastBufferStatusAt = now; try { port.postMessage({ @@ -1518,7 +1568,7 @@ async function startPlayback( pendingSeek: pendingSeekFrame !== null, }); } catch { - reportError(RECONNECT_MESSAGE); + reportError(connectionErrorMessage(chrome.runtime)); return; } } @@ -1610,6 +1660,7 @@ async function startPlayback( try { if (options.resumePaused) await context.suspend(); else await context.resume(); + if (stopped) return; port.postMessage({ type: "start", sessionId, @@ -1622,11 +1673,14 @@ async function startPlayback( wordCount: spokenText.trim().split(/\s+/).length, startedAt: new Date().toISOString(), }); + if (options.resumePaused) { + port.postMessage({ type: "buffer_status", audibleFrame: 0, rate: playbackRate, paused: true, pendingSeek: false }); + } setPlayerStatus(controls, options.resumePaused ? `Paused · ${formatTime(elapsedBeforeSession)}` : "Buffering audio…"); setTransportState(controls, options.resumePaused ? "paused" : "buffering"); animationFrame = requestAnimationFrame(updatePlaybackTime); } catch { - reportError("Audio playback could not start. Try again."); + if (!stopped) reportError("Audio playback could not start. Try again."); } } diff --git a/extension/extension-runtime.ts b/extension/extension-runtime.ts index 472e2ea..c8f9de3 100644 --- a/extension/extension-runtime.ts +++ b/extension/extension-runtime.ts @@ -1,5 +1,9 @@ export const RECONNECT_MESSAGE = "Refresh this tab to reconnect Readflow."; +export function connectionErrorMessage(runtime: Pick | undefined): string { + return runtime?.id ? "Readflow lost its audio connection. Press Listen to try again." : RECONNECT_MESSAGE; +} + export function extensionRuntime(): typeof chrome.runtime { if (typeof chrome === "undefined" || !chrome.runtime?.id) { throw new Error(RECONNECT_MESSAGE); diff --git a/extension/highlight-timeline.ts b/extension/highlight-timeline.ts index fa4a1f2..4aec314 100644 --- a/extension/highlight-timeline.ts +++ b/extension/highlight-timeline.ts @@ -11,7 +11,7 @@ export function mapSentenceTimings( ): SentenceTiming[] { const offsets = alignSpokenWords(spoken.text, segments.map((segment) => segment.text)); const cachedRanges = new Map(); - return segments.flatMap((segment, index) => { + return segments.flatMap((segment, index) => { if (!Number.isFinite(segment.start) || !Number.isFinite(segment.end)) { return []; } diff --git a/extension/reading-source.ts b/extension/reading-source.ts index 54d069d..08e6a06 100644 --- a/extension/reading-source.ts +++ b/extension/reading-source.ts @@ -144,6 +144,27 @@ export function sourceRanges(source: ReadingSource, start: number, end: number): return ranges; } +// Resolve the actual DOM occurrence, including whitespace collapsed during extraction. +export function sourceOffsetAt(source: ReadingSource, node: Node, offset: number): number | null { + const span = source.spans.find((candidate) => candidate.node === node); + if (!span || !span.node.isConnected || span.node.data !== span.originalText) return null; + const index = span.offsets.indexOf(offset); + return index < 0 || span.start + index >= source.text.length ? null : span.start + index; +} + +export function selectedWordOffset(source: ReadingSource, range: Range): number | null { + if (range.collapsed) return null; + const offset = sourceOffsetAt(source, range.startContainer, range.startOffset); + if (offset === null) return null; + const words = new Intl.Segmenter(undefined, { granularity: "word" }); + for (const word of words.segment(source.text)) { + if (word.isWordLike && word.index <= offset && offset < word.index + word.segment.length) { + return word.index; + } + } + return null; +} + export function sliceReadingSource(source: ReadingSource, start: number, end = source.text.length): ReadingSource { return { text: source.text.slice(start, end), diff --git a/extension/session-connection.ts b/extension/session-connection.ts new file mode 100644 index 0000000..f42430f --- /dev/null +++ b/extension/session-connection.ts @@ -0,0 +1,11 @@ +// An open port alone does not keep a Manifest V3 worker alive. Run the heartbeat +// in the worker so hidden article tabs cannot throttle it while playback is paused. +export function keepSessionConnected( + runtime: Pick, + timers: Pick = globalThis, +): () => void { + const timer = timers.setInterval(() => { + runtime.getPlatformInfo(() => { void runtime.lastError; }); + }, 20_000); + return () => timers.clearInterval(timer); +} diff --git a/tests/PLAYBACK_VALIDATION.md b/tests/PLAYBACK_VALIDATION.md index 4bd11d2..b0a4998 100644 --- a/tests/PLAYBACK_VALIDATION.md +++ b/tests/PLAYBACK_VALIDATION.md @@ -23,3 +23,20 @@ The browser reported no console errors. The fixture does not establish Chrome extension installation, live Fish service behavior, or subjective listening quality through the user's output device. Load this worktree's `dist/` folder in Chrome, refresh the article tab, and compare the same voice at 1×, 1.2×, and 1.4× for a final listening check. + +## Reconnection and word seeking + +Validated on 2026-10-04 on branch `fix/reconnect-and-double-click-seek`. + +Type checking, the production build, all 62 JavaScript unit tests, both Python bridge tests, and `git diff --check` passed. +The new source-location tests cover repeated paragraphs, navigation exclusions, inline formatting, collapsed whitespace, stale nodes, selection boundaries, and original offsets after starting mid-article. +The session test simulates ten minutes without content-script messages and verifies worker API activity before Chrome's 30-second idle cutoff and cleanup on disconnect. + +`node tests/playback-browser.mjs` passed in Chromium with the built content script, real Web Audio, synthetic PCM, and a controlled extension port. +It verifies a 45-second frozen-tab pause, cached word seeking without a new speech request, an uncached word starting a request at that word, recovery preserving the current word and pause state, a bounded retry, selected-text listening, seek-listener cleanup, and cached playback after stream completion and disconnect. + +The environment's administrator policy blocks loading unpacked extensions, so these browser checks simulate the port rather than testing an installed extension's worker. +Actual worker lifecycle behavior and live Fish Audio still need a check in the user's installed Chrome extension. + +After merging the newer saved-reading, voice-switching, and auto-scroll changes from `main`, the JavaScript suite runs with Node's default isolation so Chrome mocks remain local to each test file. +Additional playback regressions cover cached seeking after a saved resume, seeking backward into earlier article text, and reconnecting at the absolute article offset while preserving pause state. diff --git a/tests/content-playback.test.mjs b/tests/content-playback.test.mjs index 20b613a..1fd125b 100644 --- a/tests/content-playback.test.mjs +++ b/tests/content-playback.test.mjs @@ -92,12 +92,17 @@ async function fixture({ resume = false } = {}) { }, connect() { const listeners = []; + const disconnectListeners = []; const port = { sent: [], disconnected: false, onMessage: { addListener(listener) { listeners.push(listener); } }, - onDisconnect: { addListener() {} }, + onDisconnect: { addListener(listener) { disconnectListeners.push(listener); } }, postMessage(message) { this.sent.push(structuredClone(message)); }, - disconnect() { this.disconnected = true; }, + disconnect() { + if (this.disconnected) return; + this.disconnected = true; + for (const listener of disconnectListeners) listener(); + }, emit(message) { for (const listener of listeners) listener(message); }, }; ports.push(port); @@ -244,3 +249,53 @@ test("selected text stays separate from the article and its highlights clear on assert.equal(f.window.CSS.highlights.get("readflow-selected-passage").size, 0); assert.equal(f.window.CSS.highlights.get("readflow-selected-sentence").size, 0); }); + +test("word seeking after a saved resume uses full article offsets and can seek backward", async t => { + const f = await fixture({ resume: true }); + t.after(f.close); + await f.click("article-button"); + f.audio(); + f.advance(0.25); + await f.click("pause-button"); + const selectWord = (paragraph, start, end) => { + const element = f.window.document.getElementById(paragraph); + const range = f.window.document.createRange(); + range.setStart(element.firstChild, start); + range.setEnd(element.firstChild, end); + f.window.getSelection().removeAllRanges(); + f.window.getSelection().addRange(range); + element.dispatchEvent(new f.window.MouseEvent("dblclick", { bubbles: true })); + }; + selectWord("second", 7, 14); + await tick(); + assert.equal(f.ports.length, 1, "cached word in resumed source reuses audio"); + assert.equal(f.contexts[0].state, "running"); + selectWord("first", 0, 5); + await tick(); + assert.equal(f.ports.length, 2); + assert.match(f.ports[1].sent.find(message => message.type === "start").text, /^First passage/); + f.audio(); + f.advance(0.25); + assert.equal(f.followed.at(-1).paragraph, "first"); + assert.equal(f.reads[0].text, f.source.text); +}); + +test("connection recovery after saved resume retains the absolute word offset and pause", async t => { + const f = await fixture({ resume: true }); + t.after(f.close); + await f.click("article-button"); + f.audio(); + f.advance(0.25); + await f.click("pause-button"); + const checkpoint = f.reads[0].offset; + f.ports[0].disconnect(); + await tick(); + assert.equal(f.ports.length, 2); + assert.equal(f.contexts[1].state, "suspended"); + assert.equal(f.ports[1].sent.find(message => message.type === "start").text, f.source.text.slice(checkpoint)); + assert.equal(f.reads[0].text, f.source.text); + f.audio(); + await f.click("pause-button"); + f.advance(0.25); + assert.equal(f.followed.at(-1).paragraph, "second"); +}); diff --git a/tests/highlight-timeline.test.mjs b/tests/highlight-timeline.test.mjs index 44c1a13..789da92 100644 --- a/tests/highlight-timeline.test.mjs +++ b/tests/highlight-timeline.test.mjs @@ -20,7 +20,20 @@ test("repeated sentences highlight their own paragraph throughout the timeline", assert.equal(timings[3].ranges.map(range => range.toString()).join(""), "Keep going."); assert.notEqual(timings[0].sentenceKey, timings[2].sentenceKey); assert.equal(timings[0].sourceOffset, 0); - assert.ok(timings[2].sourceOffset > timings[0].sourceOffset); + assert.equal(timings[2].sourceOffset, 12); +}); + +test("starting mid-article retains original DOM offsets for timestamps and highlights", () => { + const document = new JSDOM("

Keep going.

Keep going. Later words.

").window.document; + const range = document.createRange(); + range.selectNodeContents(document.querySelector("article")); + const source = createSelectionSource(range); + const spoken = prepareSpokenSource(source.text); + spoken.text = spoken.text.slice(17); + spoken.sourceOffsets = spoken.sourceOffsets.slice(17); + const timings = mapSentenceTimings(source, spoken, [{ text: "going", start: 0, end: 0.5 }]); + assert.equal(timings[0].sourceOffset, 17); + assert.equal(timings[0].ranges[0].startContainer.parentElement.id, "second"); }); test("spoken cleanup does not shift the second identical sentence to the first", () => { diff --git a/tests/playback-browser.mjs b/tests/playback-browser.mjs new file mode 100644 index 0000000..bf8b63e --- /dev/null +++ b/tests/playback-browser.mjs @@ -0,0 +1,161 @@ +import http from 'node:http'; +import { spawn } from 'node:child_process'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +const root = fileURLToPath(new URL('../', import.meta.url)); +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); +const requests = []; +const paragraph = 'Keep going. This article explains how a reader can follow words in different paragraphs and return to a paused reading session without losing the place. '.repeat(5); +const article = `Readflow browser validation

Reading connection test

${Array.from({length:8},(_,i)=>`

${paragraph}

`).join('')}
`; +const bridge = http.createServer(async (req,res) => { + res.setHeader('Access-Control-Allow-Origin','*'); res.setHeader('Access-Control-Allow-Headers','Content-Type'); if(req.method==='OPTIONS'){res.end();return;} + if (req.url.startsWith('/v1/voices')) { res.setHeader('Content-Type','application/json'); res.end(JSON.stringify({voices:[],hasMore:false})); return; } + let body = ''; for await (const chunk of req) body += chunk; + const data = JSON.parse(body); requests.push(data.text); + const words = data.text.trim().split(/\s+/); + const frames = Math.ceil(Math.max(4,words.length*0.35)*44100); + const audio = Buffer.alloc(frames*2); + res.writeHead(200, {'Content-Type':'text/event-stream'}); + res.write(`data: ${JSON.stringify({event:'audio',audio_base64:audio.toString('base64'),audio_byte_count:audio.length,chunk_seq:0,chunk_audio_offset_sec:0,alignment:{segments:words.map((text,i)=>({text,start:i*0.35,end:(i+1)*0.35}))}})}\n\n`); + res.end('data: {"event":"finish"}\n\n'); +}); +const page = http.createServer((req,res)=> { res.setHeader('Content-Type','text/html'); res.end(article); }); +await new Promise(resolve=>bridge.listen(4179,'127.0.0.1',resolve)); +await new Promise(resolve=>page.listen(4180,'127.0.0.1',resolve)); +const profile = await mkdtemp(join(tmpdir(), 'readflow-chrome-')); +const chrome = spawn(process.env.CHROMIUM_PATH || 'chromium',['--headless=new','--no-sandbox','--disable-gpu','--autoplay-policy=no-user-gesture-required','--remote-debugging-port=9224',`--user-data-dir=${profile}`,'about:blank'],{stdio:['ignore','ignore','pipe']}); +let browserLog = ''; +let launchError; +chrome.stderr.on('data', data => { browserLog = (browserLog + data.toString()).slice(-8000); }); +chrome.on('error', error => { launchError = error; }); +let ws; +try { + let version; + for(let i=0;i<150;i++) { + if (launchError || chrome.exitCode !== null) break; + try { version=await (await fetch('http://127.0.0.1:9224/json/version')).json(); break; } + catch { await wait(100); } + } + assert.ok(version, `Chromium starts: ${launchError?.message || browserLog}`); + ws = new WebSocket(version.webSocketDebuggerUrl); + await new Promise(resolve=>ws.addEventListener('open',resolve,{once:true})); + let seq=0; const pending=new Map(); + ws.addEventListener('message',event=> { const data=JSON.parse(event.data); if(data.id) {const item=pending.get(data.id);pending.delete(data.id);data.error?item.reject(new Error(JSON.stringify(data.error))):item.resolve(data.result);} }); + const send=(method,params={},sessionId)=>new Promise((resolve,reject)=>{const id=++seq;pending.set(id,{resolve,reject});ws.send(JSON.stringify({id,method,params,sessionId}));}); + console.log('Validating the built content script with real Web Audio and a controlled extension port.'); + const {targetId}=await send('Target.createTarget',{url:'http://127.0.0.1:4180'}); + const {sessionId}=await send('Target.attachToTarget',{targetId,flatten:true}); + const evaluate=async expression=> {const r=await send('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true},sessionId); if(r.exceptionDetails)throw new Error(JSON.stringify(r.exceptionDetails));return r.result.value;}; + const status=()=>evaluate(`document.querySelector('#readflow-controls')?.shadowRoot.querySelector('#status')?.textContent`); + await wait(500); + await evaluate(` + window.__ports = []; + window.chrome.runtime = { + id: 'fixture-extension', + onMessage: { addListener() {} }, + sendMessage: async message => { + if (message.type === 'voice_settings') return { selected: { id: 'fixture', name: 'Fixture', languages: ['en'] }, favorites: [] }; + if (message.type === 'reading_for_page') return {}; + if (message.type === 'reading_save') { + const item = { ...message.item, id: 'fixture-read', offset: 0, status: 'in-progress', sessionId: message.sessionId }; + return { item }; + } + return { ok: true }; + }, + connect: () => { + const handlers = [], disconnects = []; + let connected = true; + const port = { + onMessage: { addListener: fn => handlers.push(fn) }, + onDisconnect: { addListener: fn => disconnects.push(fn) }, + emit: event => handlers.forEach(fn => fn(event)), + disconnect: () => { + if (!connected) return; + connected = false; + disconnects.forEach(fn => fn()); + }, + postMessage: message => { + if (!connected) throw new Error('Port disconnected'); + if (message.type !== 'start') return; + // Only generate the first section: later article words remain uncached. + fetch('http://127.0.0.1:4179/v1/tts/stream/with-timestamp', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: message.sections[0].text }), + }).then(r => r.text()).then(text => { + for (const record of text.trim().split('\\n\\n')) { + const event = JSON.parse(record.slice(6)); + if (connected && event.event === 'audio') port.emit(event); + } + }); + }, + }; + window.__ports.push(port); + return port; + }, + }; + `); + await evaluate(`window.__contexts=[];const NativeContext=window.AudioContext;window.AudioContext=class extends NativeContext{constructor(options){super(options);window.__contexts.push(this);}};`); + await evaluate(await readFile(join(root, 'dist/content.js'),'utf8')); + await wait(300); + assert.equal(await evaluate(`!!document.querySelector('#readflow-controls')`),true,'built extension injects'); + const click=id=>evaluate(`document.querySelector('#readflow-controls').shadowRoot.querySelector('#${id}').click()`); + await click('article-button'); await wait(1000); + console.log('Initial playback:',await status()); + assert.ok(requests.length,'bridge receives requests'); + await click('pause-button'); await wait(200); + assert.match(await status(),/Paused/); + const beforeIdle=requests.length; + await send('Page.setWebLifecycleState',{state:'frozen'},sessionId); + const idleMs = Number(process.env.READFLOW_IDLE_MS) || 45000; + console.log(`Article frozen while paused for ${idleMs / 1000} seconds.`); + await wait(idleMs); + await send('Page.setWebLifecycleState',{state:'active'},sessionId); await wait(500); + assert.match(await status(),/Paused/); + assert.equal(requests.length,beforeIdle,'paused generation stays paused'); + console.log('After idle:',await status()); + const selectWord=async (id,offset,length)=>evaluate(`(()=>{const node=document.querySelector('#${id}').firstChild;const r=document.createRange();r.setStart(node,${offset});r.setEnd(node,${offset+length});getSelection().removeAllRanges();getSelection().addRange(r);node.parentElement.dispatchEvent(new MouseEvent('dblclick',{bubbles:true}));})()`); + const beforeCached=requests.length; + await selectWord('p0',5,5); await wait(150); + assert.equal(requests.length,beforeCached,'cached word seeks without another request'); + assert.doesNotMatch(await status(),/Paused|Refresh|lost/); + assert.equal(await evaluate('window.__contexts.at(-1).state'), 'running'); + console.log('Cached double-click resumed:',await status()); + await selectWord('p7',5,5); await wait(500); + assert.equal(requests[beforeCached].startsWith('going.'),true,'ungenerated word starts new request exactly there'); + console.log('Ungenerated double-click request:',requests[beforeCached].slice(0,70)); + await click('pause-button'); await wait(100); + // Simulate an unexpected worker restart through its disconnected port. + await evaluate('window.__ports.at(-1).disconnect()'); await wait(1200); + console.log('After simulated worker restart:',await status()); + assert.equal(await evaluate('window.__contexts.at(-1).state'), 'suspended'); + assert.match(await status(),/Paused/,'automatic recovery preserves pause'); + assert.equal(requests.at(-1).startsWith('going.'),true,'recovery starts at the current source word'); + await evaluate('window.__ports.at(-1).disconnect()'); await wait(200); + assert.match(await status(),/Press Listen/,'second disconnect ends bounded recovery'); + assert.doesNotMatch(await status(),/Refresh/); + await click('article-button'); await wait(400); + const selectPassage = `(()=>{const r=document.createRange();r.selectNodeContents(document.querySelector('#p1'));getSelection().removeAllRanges();getSelection().addRange(r);document.querySelector('#readflow-controls').shadowRoot.querySelector('#selection-button').click();})()`; + await evaluate(selectPassage); await wait(400); + const duringSelection=requests.length; + await selectWord('p7',5,5); await wait(100); + assert.equal(requests.length,duringSelection,'double-click does not hijack selected-text listening'); + await click('stop-button'); await wait(100); + const afterStop=requests.length; + await selectWord('p0',5,5); await wait(100); + assert.equal(requests.length,afterStop,'stopped playback removes seek listener'); + await click('article-button'); await wait(500); + await evaluate("window.__ports.at(-1).emit({event:'finish'}); window.__ports.at(-1).disconnect();"); + await wait(150); + assert.doesNotMatch(await status(),/Refresh|lost/,'completed audio remains usable after disconnect'); + await click('pause-button'); await wait(100); + await selectWord('p0',5,5); await wait(100); + assert.equal(await evaluate('window.__contexts.at(-1).state'), 'running'); + await click('stop-button'); + console.log('Browser checks passed.'); +} finally { + ws?.close(); chrome.kill('SIGKILL'); bridge.closeAllConnections(); page.closeAllConnections(); bridge.close();page.close(); + await wait(100); await rm(profile,{recursive:true,force:true}); +} diff --git a/tests/reading-source.test.mjs b/tests/reading-source.test.mjs index 1aec041..ef710a8 100644 --- a/tests/reading-source.test.mjs +++ b/tests/reading-source.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { JSDOM } from "jsdom"; -import { createArticleSource, createSelectionSource, sourceRanges } from "../extension/reading-source.ts"; +import { createArticleSource, createSelectionSource, selectedWordOffset, sourceOffsetAt, sourceRanges } from "../extension/reading-source.ts"; const paragraph = "This article describes a reader following the same words in separate paragraphs, with enough detail to identify the main content reliably. ".repeat(5); @@ -107,6 +107,46 @@ test("source extraction preserves main's sentence boundaries before case-insensi ]); }); +test("double-click resolves repeated words by DOM occurrence, excluding navigation", () => { + const document = articleDocument(); + const source = createArticleSource(document); + const range = document.createRange(); + range.setStart(document.getElementById("second").firstChild, 5); + range.setEnd(document.getElementById("second").firstChild, 10); + assert.equal(selectedWordOffset(source, range), source.text.indexOf("going", source.text.indexOf("going") + 1)); + range.selectNodeContents(document.querySelector("nav p").firstChild); + assert.equal(selectedWordOffset(source, range), null); +}); + +test("word seeking handles inline words and collapsed whitespace without accepting stale nodes", () => { + const document = new JSDOM("

Hello \n world. After.

").window.document; + const passage = document.createRange(); + passage.selectNodeContents(document.querySelector("p")); + const source = createSelectionSource(passage); + const word = document.createRange(); + const node = document.querySelector("strong").firstChild; + word.setStart(node, 1); + word.setEnd(node, 5); + assert.equal(selectedWordOffset(source, word), 6); + assert.equal(sourceOffsetAt(source, document.querySelector("p").firstChild, 2), 0); + word.collapse(true); + assert.equal(selectedWordOffset(source, word), null); + node.data = "other"; + assert.equal(sourceOffsetAt(source, node, 0), null); +}); + +test("seeking cannot escape a selected source through the same text node", () => { + const document = new JSDOM("

Before selected words after.

").window.document; + const node = document.querySelector("p").firstChild; + const passage = document.createRange(); + passage.setStart(node, 7); + passage.setEnd(node, 21); + const source = createSelectionSource(passage); + assert.equal(sourceOffsetAt(source, node, 0), null); + assert.equal(sourceOffsetAt(source, node, 7), 0); + assert.equal(sourceOffsetAt(source, node, 22), null); +}); + test("resuming a sliced source retains original DOM ranges across inline elements", async () => { const { sliceReadingSource } = await import("../extension/reading-source.ts"); const document = new JSDOM("

First sentence. Second sentence. Third sentence.

").window.document; diff --git a/tests/session-connection.test.mjs b/tests/session-connection.test.mjs new file mode 100644 index 0000000..12577e9 --- /dev/null +++ b/tests/session-connection.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { keepSessionConnected } from "../extension/session-connection.ts"; +import { connectionErrorMessage, RECONNECT_MESSAGE } from "../extension/extension-runtime.ts"; + +test("listening keeps an idle worker alive independently of article-tab activity", () => { + let now = 0; + let lastActivity = 0; + let heartbeat; + let interval; + const timer = {}; + const stop = keepSessionConnected({ + getPlatformInfo(callback) { lastActivity = now; callback({}); }, + lastError: undefined, + }, { + setInterval(callback, delay) { heartbeat = callback; interval = delay; return timer; }, + clearInterval(id) { assert.equal(id, timer); heartbeat = undefined; }, + }); + // No content-script messages arrive during this simulated ten-minute pause. + for (now = interval; now <= 600_000; now += interval) { + assert.ok(now - lastActivity < 30_000, "worker must receive activity before Chrome's idle cutoff"); + heartbeat(); + } + stop(); + assert.equal(heartbeat, undefined, "disconnect releases the keep-alive"); +}); + +test("only an invalidated extension context instructs the user to refresh", () => { + assert.equal(connectionErrorMessage(undefined), RECONNECT_MESSAGE); + assert.equal(connectionErrorMessage({ id: undefined }), RECONNECT_MESSAGE); + assert.match(connectionErrorMessage({ id: "installed-extension" }), /Press Listen/); + assert.doesNotMatch(connectionErrorMessage({ id: "installed-extension" }), /refresh/i); +});