From f4880b613849bb1f577bbb45cddfc0a950c04256 Mon Sep 17 00:00:00 2001 From: JAI BHASIN Date: Sat, 3 Oct 2026 21:40:49 +0000 Subject: [PATCH 1/2] Fix paused session connections and add double-click article seeking with CI --- .github/workflows/ci.yml | 49 ++++++++++ README.md | 17 +++- extension/background.ts | 4 + extension/content.ts | 90 ++++++++++++++++--- extension/extension-runtime.ts | 4 + extension/highlight-timeline.ts | 6 +- extension/reading-source.ts | 21 +++++ extension/session-connection.ts | 11 +++ tests/PLAYBACK_VALIDATION.md | 14 +++ tests/highlight-timeline.test.mjs | 15 ++++ tests/playback-browser.mjs | 145 ++++++++++++++++++++++++++++++ tests/reading-source.test.mjs | 42 ++++++++- tests/session-connection.test.mjs | 33 +++++++ 13 files changed, 432 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 extension/session-connection.ts create mode 100644 tests/playback-browser.mjs create mode 100644 tests/session-connection.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..706303c --- /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 --test-isolation=none 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 4354838..f8a969f 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. The cassette label shows the article title and site, and its reels turn while audio is playing. Choose **−** to shrink the player to a compact view with status and play or pause, then **⌃** to expand it. The speed button cycles through 0.75×, 1×, 1.1×, 1.2×, 1.3×, 1.4×, 1.5×, 2×, and 3×. @@ -81,7 +87,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. @@ -108,6 +114,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 @@ -115,3 +125,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 20daddc..e584a91 100644 --- a/extension/background.ts +++ b/extension/background.ts @@ -7,6 +7,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"; @@ -61,6 +62,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; @@ -84,6 +86,7 @@ chrome.runtime.onConnect.addListener((port) => { }; port.onDisconnect.addListener(() => { + stopHeartbeat(); disconnected = true; controller.abort(); sessionController?.abort(); @@ -150,6 +153,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 fd3f446..85ecc88 100644 --- a/extension/content.ts +++ b/extension/content.ts @@ -1,12 +1,12 @@ import { mapSentenceTimings, type SentenceTiming } from "./highlight-timeline"; -import { createArticleSource, createSelectionSource, type ReadingSource } from "./reading-source"; +import { createArticleSource, createSelectionSource, selectedWordOffset, type ReadingSource } from "./reading-source"; import { bufferFramesForSpeed, isAudioAudible, playbackDuration, playedFrames } from "./playback-speed"; import { StreamingTimeStretch } from "./time-stretch"; 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"; @@ -992,13 +992,20 @@ async function startPlayback( readingSource: ReadingSource, source: string, selectionRange?: Range, + options: { startOffset?: number; initiallyPaused?: boolean; recoveryAttempts?: number; voice?: FishVoice } = {}, ): Promise { const picker = voicePickers.get(controls.host); await picker?.ready; - const voice: FishVoice = picker?.selectedVoice ?? DEFAULT_VOICE; + const voice: FishVoice = options.voice ?? picker?.selectedVoice ?? DEFAULT_VOICE; stopCurrentPlayback?.(); controls.playerControls.hidden = true; const spoken = prepareSpokenSource(readingSource.text); + if (options.startOffset) { + const start = spoken.sourceOffsets.findIndex((offset) => offset >= options.startOffset!); + if (start < 0) return; + spoken.text = spoken.text.slice(start); + spoken.sourceOffsets = spoken.sourceOffsets.slice(start); + } const spokenText = spoken.text; controls.tapeTitle.textContent = source === "Selected text" ? "Selected passage" : shortTapeTitle(document.title); controls.tapeTitle.title = controls.tapeTitle.textContent; @@ -1011,7 +1018,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; } @@ -1056,6 +1063,7 @@ async function startPlayback( const startupBufferFrames = (): number => Math.ceil(START_BUFFER_SECONDS * SAMPLE_RATE * playbackRate); let nextStart = 0; let streamFinished = false; + let portConnected = true; let playbackComplete = false; let stopped = false; let animationFrame = 0; @@ -1089,6 +1097,7 @@ async function startPlayback( } stopped = true; + document.removeEventListener("dblclick", onArticleDoubleClick); setCurrentPlaybackSpeed = null; cancelAnimationFrame(animationFrame); window.clearTimeout(processingTimer); @@ -1160,8 +1169,25 @@ async function startPlayback( }; 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 !== undefined); + const startOffset = currentWord?.sourceOffset ?? options.startOffset ?? 0; + const initiallyPaused = context.state === "suspended"; + cleanup(false); + void startPlayback(controls, readingSource, source, selectionRange, { + startOffset, initiallyPaused, voice, recoveryAttempts: (options.recoveryAttempts ?? 0) + 1, + }); }); const finishIfReady = (): void => { @@ -1378,11 +1404,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 === 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, { startOffset: 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 }); @@ -1444,7 +1500,7 @@ async function startPlayback( } const now = performance.now(); - if (now - lastBufferStatusAt >= 250) { + if (portConnected && !streamFinished && now - lastBufferStatusAt >= 250) { lastBufferStatusAt = now; try { port.postMessage({ @@ -1455,7 +1511,7 @@ async function startPlayback( pendingSeek: pendingSeekFrame !== null, }); } catch { - reportError(RECONNECT_MESSAGE); + reportError(connectionErrorMessage(chrome.runtime)); return; } } @@ -1540,7 +1596,9 @@ async function startPlayback( }); try { - await context.resume(); + if (options.initiallyPaused) await context.suspend(); + else await context.resume(); + if (stopped) return; port.postMessage({ type: "start", sessionId, @@ -1553,11 +1611,15 @@ async function startPlayback( wordCount: spokenText.trim().split(/\s+/).length, startedAt: new Date().toISOString(), }); - setPlayerStatus(controls, "Buffering audio…"); - setTransportState(controls, "buffering"); + if (options.initiallyPaused) { + port.postMessage({ type: "buffer_status", audibleFrame: 0, rate: playbackRate, paused: true, pendingSeek: false }); + controls.pauseButton.textContent = "Play"; + } + setPlayerStatus(controls, options.initiallyPaused ? "Paused · 0:00" : "Buffering audio…"); + setTransportState(controls, options.initiallyPaused ? "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 9c21c99..98c5cb8 100644 --- a/extension/highlight-timeline.ts +++ b/extension/highlight-timeline.ts @@ -2,7 +2,7 @@ import { sourceRanges, type ReadingSource } from "./reading-source.ts"; import type { prepareSpokenSource } from "./spoken-text.ts"; import { alignSpokenWords } from "./word-alignment.ts"; -export type SentenceTiming = { sentenceKey: string; start: number; ranges: Range[] }; +export type SentenceTiming = { sentenceKey: string; start: number; sourceOffset?: number; ranges: Range[] }; export function mapSentenceTimings( source: ReadingSource, @@ -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 []; } @@ -27,6 +27,6 @@ export function mapSentenceTimings( ranges = sourceRanges(source, sentence.start, sentence.end); cachedRanges.set(sentenceKey, ranges); } - return [{ sentenceKey: ranges.length ? sentenceKey : "", start: segment.start, ranges }]; + return [{ sentenceKey: ranges.length ? sentenceKey : "", start: segment.start, sourceOffset, ranges }]; }); } diff --git a/extension/reading-source.ts b/extension/reading-source.ts index 83978c3..ca01f3e 100644 --- a/extension/reading-source.ts +++ b/extension/reading-source.ts @@ -143,3 +143,24 @@ 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; +} 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..58e0d29 100644 --- a/tests/PLAYBACK_VALIDATION.md +++ b/tests/PLAYBACK_VALIDATION.md @@ -23,3 +23,17 @@ 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. diff --git a/tests/highlight-timeline.test.mjs b/tests/highlight-timeline.test.mjs index e05ffca..6fbbc5f 100644 --- a/tests/highlight-timeline.test.mjs +++ b/tests/highlight-timeline.test.mjs @@ -19,6 +19,21 @@ test("repeated sentences highlight their own paragraph throughout the timeline", assert.equal(timings[2].ranges[0].startContainer.parentElement.id, "second"); 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.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..e908b4a --- /dev/null +++ b/tests/playback-browser.mjs @@ -0,0 +1,145 @@ +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']}); +chrome.stderr.on('data',()=>{}); +let ws; +try { + let version; + for(let i=0;i<50;i++) { try { version=await (await fetch('http://127.0.0.1:9224/json/version')).json(); break; } catch { await wait(100); } } + assert.ok(version, 'Chromium starts'); + 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', + sendMessage: async () => ({ selected: { id: 'fixture', name: 'Fixture', languages: ['en'] }, favorites: [] }), + 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 329d0ef..7359157 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); @@ -106,3 +106,43 @@ test("source extraction preserves main's sentence boundaries before case-insensi "First sentence.", "Second sentence?", "Third sentence.", ]); }); + +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); +}); 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); +}); From ad5d33b7a745c3e47955bb84bb99b6a959e0d046 Mon Sep 17 00:00:00 2001 From: JAI BHASIN Date: Sat, 3 Oct 2026 21:43:33 +0000 Subject: [PATCH 2/2] Report browser startup errors and allow slower CI launches --- tests/playback-browser.mjs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/playback-browser.mjs b/tests/playback-browser.mjs index e908b4a..3f42d71 100644 --- a/tests/playback-browser.mjs +++ b/tests/playback-browser.mjs @@ -27,12 +27,19 @@ 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']}); -chrome.stderr.on('data',()=>{}); +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<50;i++) { try { version=await (await fetch('http://127.0.0.1:9224/json/version')).json(); break; } catch { await wait(100); } } - assert.ok(version, 'Chromium starts'); + 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();