From 57200462fed46d8f3fd9f606daefea3f78de1ace Mon Sep 17 00:00:00 2001 From: vriesd Date: Sat, 5 Sep 2026 04:23:14 +0200 Subject: [PATCH 1/8] test: reproduce recording controls and process sampling regressions --- frontend/e2e/home-processing.spec.ts | 21 +++++++ frontend/src/App.test.tsx | 47 +++++++++++++-- scripts/test_process_observation.py | 86 ++++++++++++++++++++++++++++ src-tauri/src/commands/devices.rs | 15 +++++ 4 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 frontend/e2e/home-processing.spec.ts create mode 100644 scripts/test_process_observation.py diff --git a/frontend/e2e/home-processing.spec.ts b/frontend/e2e/home-processing.spec.ts new file mode 100644 index 0000000..f283be5 --- /dev/null +++ b/frontend/e2e/home-processing.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from '@playwright/test' + +test('the preview blocks a second toggle while transcribing, then accepts the next recording', async ({ page }) => { + await page.goto('/') + + const start = page.getByRole('button', { name: 'Start recording' }) + await expect(start).toBeEnabled() + await start.click() + + const stop = page.getByRole('button', { name: 'Stop and transcribe' }) + await expect(stop).toBeEnabled() + await stop.click() + + const processing = page.getByRole('button', { name: 'Processing recording' }) + await expect(processing).toBeDisabled() + await expect(page.getByRole('heading', { name: 'Transcribing locally…' })).toBeVisible() + const retry = page.getByRole('button', { name: 'Start recording' }) + await expect(retry).toBeEnabled({ timeout: 2_000 }) + await retry.click() + await expect(page.getByRole('button', { name: 'Stop and transcribe' })).toBeEnabled() +}) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 1a1f1e5..92d31d6 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -299,6 +299,41 @@ describe('Echo desktop shell', () => { expect(screen.getByPlaceholderText('Search transcripts…')).toBeInTheDocument() }) + it.each([ + ['Transcribing', 'Transcribing locally…', 'Whisper · small · VAD on is turning your recording into text.'], + ['Injecting', 'Inserting transcript…', 'ydotool · Wayland is sending your transcript to the active app.'], + ] satisfies Array<[AppStatus['phase'], string, string]>)( + 'prevents a second recording toggle while %s', + async (phase, heading, description) => { + seedPreviewStatus({ phase, recordingInProcess: false }) + render() + + const orb = await screen.findByRole('button', { name: 'Processing recording' }) + expect(orb).toBeDisabled() + expect(screen.getByRole('heading', { name: heading })).toBeInTheDocument() + expect(screen.getByText(description)).toBeInTheDocument() + fireEvent.click(orb) + expect((await previewDesktopApi.getAppStatus()).phase).toBe(phase) + }, + ) + + it.each(['Idle', 'Failed'] as const)( + 'allows recording to start again from %s', + async (phase) => { + seedPreviewStatus({ + phase, + recordingInProcess: false, + }) + render() + + const orb = await screen.findByRole('button', { name: 'Start recording' }) + expect(orb).toBeEnabled() + fireEvent.click(orb) + fireEvent.click(orb) + expect(await screen.findByRole('button', { name: 'Stop and transcribe' })).toBeEnabled() + }, + ) + it('reports a rejected dictionary entry without clearing the form or leaving it busy', async () => { vi.mocked(addDictionaryEntry).mockRejectedValueOnce(new Error('could not add dictionary entry')) render() @@ -398,14 +433,15 @@ describe('Echo desktop shell', () => { it('warns when a stale install shadows the running binary', async () => { seedPreviewStatus({ currentExe: '/usr/bin/echo-desktop', - firstPathHit: '/home/user/.local/bin/echo-desktop', - staleInstalls: ['/home/user/.local/bin/echo-desktop'], + firstPathHit: '/home/user/.local/bin/echo desktop; keep-me', + staleInstalls: ['/home/user/.local/bin/echo desktop; keep-me'], }) render() await screen.findByRole('button', { name: 'Start recording' }) const warning = await screen.findByRole('alert') - expect(warning).toHaveTextContent('/home/user/.local/bin/echo-desktop') - expect(warning).toHaveTextContent('rm -f /home/user/.local/bin/echo-desktop') + expect(warning).toHaveTextContent('/home/user/.local/bin/echo desktop; keep-me') + expect(warning).not.toHaveTextContent('rm -f') + expect(within(warning).getByRole('button', { name: 'Remove old copies' })).toBeEnabled() }) it('shows no stale-install warning when PATH is clean', async () => { @@ -425,8 +461,7 @@ describe('Echo desktop shell', () => { await screen.findByRole('button', { name: 'Start recording' }) const warning = await screen.findByRole('alert') expect(warning).toHaveTextContent('/home/user/.local/bin/echo-desktop') - // The manual command stays visible as secondary text. - expect(warning).toHaveTextContent('rm -f /home/user/.local/bin/echo-desktop') + expect(warning).not.toHaveTextContent('rm -f') fireEvent.click(within(warning).getByRole('button', { name: 'Remove old copies' })) expect(vi.mocked(removeStaleInstalls)).toHaveBeenCalledTimes(1) diff --git a/scripts/test_process_observation.py b/scripts/test_process_observation.py new file mode 100644 index 0000000..402d132 --- /dev/null +++ b/scripts/test_process_observation.py @@ -0,0 +1,86 @@ +"""Focused regressions for process-tree sampling.""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path + +from process_observation import _read_children, _tree + + +class ProcessObservationTests(unittest.TestCase): + def test_tree_collects_children_from_every_thread(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "meminfo").write_text("MemAvailable: 1 kB\nSwapTotal: 1 kB\nSwapFree: 1 kB\n") + for pid, children in ((1, "2"), (2, ""), (3, "")): + (root / str(pid) / "task" / str(pid)).mkdir(parents=True) + (root / str(pid) / "status").write_text("State:\tS (sleeping)\n") + (root / str(pid) / "task" / str(pid) / "children").write_text(children) + (root / "1" / "task" / "10").mkdir() + (root / "1" / "task" / "10" / "children").write_text("3 2") + + self.assertEqual(_read_children(root, 1), [2, 3]) + self.assertEqual(sorted(_tree(root, 1)[0]), [1, 2, 3]) + self.assertFalse(_tree(root, 1)[1]) + + def test_unreadable_children_falls_back_or_marks_partial(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for pid, parent in ((1, 0), (2, 1)): + (root / str(pid) / "task" / str(pid)).mkdir(parents=True) + (root / str(pid) / "status").write_text(f"PPid:\t{parent}\n") + + self.assertEqual(_tree(root, 1), ([1, 2], False)) + + (root / "2" / "status").write_text("PPid:\tnot-a-pid\n") + self.assertEqual(_tree(root, 1), ([1], True)) + + def test_tree_finds_child_spawned_by_worker_thread(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + child_pid_file = Path(temporary) / "child.pid" + program = f"""import pathlib +import subprocess +import sys +import threading +import time + +ready = threading.Event() +release = threading.Event() +def worker(): + child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(10)"]) + target = pathlib.Path({str(child_pid_file)!r}) + temporary = target.with_name(target.name + ".tmp") + temporary.write_text(str(child.pid)) + temporary.replace(target) + ready.set() + release.wait() + +threading.Thread(target=worker).start() +ready.wait() +time.sleep(10) +""" + parent = subprocess.Popen([sys.executable, "-c", program], start_new_session=True) + try: + deadline = time.monotonic() + 2 + while not child_pid_file.exists() and time.monotonic() < deadline: + time.sleep(0.01) + self.assertTrue(child_pid_file.exists(), "worker did not spawn its child") + child_pid = int(child_pid_file.read_text()) + self.assertIn(child_pid, _tree(Path("/proc"), parent.pid)[0]) + finally: + try: + os.killpg(parent.pid, signal.SIGKILL) + except ProcessLookupError: + pass + parent.wait() + + +if __name__ == "__main__": + unittest.main() diff --git a/src-tauri/src/commands/devices.rs b/src-tauri/src/commands/devices.rs index b4a79a6..c9c3af2 100644 --- a/src-tauri/src/commands/devices.rs +++ b/src-tauri/src/commands/devices.rs @@ -181,11 +181,26 @@ mod tests { ) { } + fn assert_async_microphones( + _: impl Future>, + ) { + } + #[test] fn gpu_device_listing_yields_before_detection() { assert_async_gpu_devices(list_gpu_devices(false)); } + #[test] + fn microphone_listing_yields_before_detection() { + assert_async_microphones(get_microphones()); + } + + #[test] + fn microphone_selection_yields_before_detection_and_config_write() { + assert_async_microphones(set_microphone(None)); + } + #[test] fn language_command_projection_covers_every_support_mode() { let multilingual = From d1b6931ccc310efaabd51fe0f11900bd069fe0d7 Mon Sep 17 00:00:00 2001 From: vriesd Date: Sat, 5 Sep 2026 04:23:57 +0200 Subject: [PATCH 2/8] fix: protect dictation controls and complete process sampling --- frontend/src/app/useAppController.ts | 5 +++ frontend/src/home/HomeView.tsx | 28 +++++++------ scripts/process_observation.py | 18 ++++---- src-tauri/src/commands/devices.rs | 63 +++++++++++++++------------- 4 files changed, 66 insertions(+), 48 deletions(-) diff --git a/frontend/src/app/useAppController.ts b/frontend/src/app/useAppController.ts index 2a0b0e8..c8620b3 100644 --- a/frontend/src/app/useAppController.ts +++ b/frontend/src/app/useAppController.ts @@ -49,6 +49,7 @@ export function useAppController() { const [recordingStartedAt, setRecordingStartedAt] = useState(null) const previousPhase = useRef('Idle') const previousHistoryId = useRef(null) + const toggleInFlight = useRef(false) const recordingSeconds = useElapsedSeconds(recordingStartedAt) const reportError = useCallback((reason: unknown) => setError(messageFrom(reason)), []) const { @@ -107,11 +108,15 @@ export function useAppController() { }, [view]) const toggle = useCallback(async () => { + if (toggleInFlight.current) return + toggleInFlight.current = true try { await toggleRecording() await refreshStatus() } catch (reason) { reportError(reason) + } finally { + toggleInFlight.current = false } }, [refreshStatus, reportError]) diff --git a/frontend/src/home/HomeView.tsx b/frontend/src/home/HomeView.tsx index 3e577be..6ed5560 100644 --- a/frontend/src/home/HomeView.tsx +++ b/frontend/src/home/HomeView.tsx @@ -25,16 +25,19 @@ export function HomeView({ }) { const shortcut = presentShortcut(status.shortcut) const recording = status.phase === 'Recording' + const processing = status.phase === 'Transcribing' || status.phase === 'Injecting' const heroState = recording ? 'recording' - : status.phase === 'Transcribing' + : processing ? 'transcribing' : 'idle' - const stateCopy = recording - ? ['Listening…', 'Speak naturally, then press the shortcut again.'] + const [readout, title, description] = recording + ? ['Listening', 'Listening…', 'Speak naturally, then press the shortcut again.'] : status.phase === 'Transcribing' - ? ['Transcribing locally…', `${status.engineName} is turning your recording into text.`] - : ['Ready when you are', 'Your audio stays on this machine.'] + ? ['Processing', 'Transcribing locally…', `${status.engineName} is turning your recording into text.`] + : status.phase === 'Injecting' + ? ['Processing', 'Inserting transcript…', `${status.injectionName} is sending your transcript to the active app.`] + : ['Ready', 'Ready when you are', 'Your audio stays on this machine.'] return (
@@ -44,14 +47,16 @@ export function HomeView({ className="record-orb" type="button" onClick={() => void onToggleRecording()} - aria-label={recording ? 'Stop and transcribe' : 'Start recording'} + aria-label={recording ? 'Stop and transcribe' : processing ? 'Processing recording' : 'Start recording'} + aria-busy={processing || undefined} + disabled={processing} >