Files
voicebox/app/src/App.browser.test.tsx
T
Jamie Pine cd116bcfe7 test: App startup, ChordPicker, HistoryTable, and generate-flow suites
App: non-Tauri skips the startup gate, Tauri dev reaches the router
with close-handler/updater wiring, ?view=dictate renders the pill
without booting the main app (branches behind import.meta.env.PROD are
unreachable under vitest and stay uncovered). ChordPicker: chord
capture via raw KeyboardEvents with explicit codes, peak-set
semantics, save/cancel. HistoryTable: rows, empty state, player-store
autoplay intent, favorite/delete round trips, export through
platform.filesystem.saveFile. FloatingGenerateBox: mounted via the
real index route so submit → pending → SSE completion → history
refetch runs the actual RootLayout wiring.
2026-08-08 15:42:01 -07:00

135 lines
5.4 KiB
TypeScript

import { mockIPC } from '@tauri-apps/api/mocks';
import { afterEach, beforeEach, expect, it } from 'vitest';
import App from '@/App';
import { createMockPlatform } from '@/test/mockPlatform';
import { buildModelStatus, buildProfile } from '@/test/msw/fixtures';
import {
captureHandlers,
effectsHandlers,
historyHandlers,
modelHandlers,
profileHandlers,
settingsHandlers,
storyHandlers,
taskHandlers,
} from '@/test/msw/handlers';
import { worker } from '@/test/msw/worker';
import { renderWithProviders } from '@/test/render';
const originalUrl = window.location.href;
// useChordSync and the permission gates call the Tauri IPC modules directly,
// outside the Platform abstraction. There is no Tauri runtime in the test
// browser, so `invoke`/`listen` would reject with a TypeError that some
// callers (e.g. useChordSync's `listen('dictate:warm-request')`) never get a
// chance to handle, surfacing as unhandled rejections. mockIPC installs the
// official in-memory IPC shim; `shouldMockEvents` covers listen/emit too.
//
// Reinstalled per test for a fresh listener map, but never cleared: the
// harness unmounts components after this file's afterEach, and those unmount
// cleanups still `unlisten` through the shim. The per-file iframe throws the
// window state away anyway.
beforeEach(() => {
mockIPC(
(cmd) => {
// Permission checks treat the result as a trusted boolean — grant
// them so no permission banners pop over the UI under test.
if (cmd.startsWith('check_')) return true;
return null;
},
{ shouldMockEvents: true },
);
});
afterEach(() => {
window.history.replaceState(null, '', originalUrl);
delete window.__voiceboxServerStartedByApp;
});
/**
* Everything the index route (MainEditor + app chrome) fetches on mount.
* Unstubbed requests fail the test loudly, so this is the full route budget.
*/
function useHappyPathHandlers() {
worker.use(
...profileHandlers([buildProfile({ name: 'Ada Lovelace' })]),
...historyHandlers([]),
...captureHandlers([]),
...settingsHandlers(),
...modelHandlers([buildModelStatus()]),
...storyHandlers([]),
...effectsHandlers([]),
...taskHandlers(),
);
}
// App reads window.location at render time: `?view=dictate` picks the pill
// window, and the router matches the real browser path. Point the URL at the
// state under test before mounting; afterEach restores the runner's URL.
function setAppUrl(path: string) {
window.history.replaceState(null, '', path);
}
it('skips the startup gate outside Tauri and renders the router', async () => {
useHappyPathHandlers();
setAppUrl('/');
const screen = await renderWithProviders(<App />);
// Index route is MainEditor — the profile list proves the router mounted.
await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
// Web mode assumes an external server: no lifecycle management at all.
expect(screen.platform.lifecycle.startServer).not.toHaveBeenCalled();
expect(screen.platform.lifecycle.setupWindowCloseHandler).not.toHaveBeenCalled();
});
it('skips server auto-start in Tauri dev mode and still reaches the router', async () => {
// App gates auto-start on `import.meta.env.PROD`, which is false under
// vitest. The reachable Tauri branch is therefore the dev one: window
// close handler installed, auto-start skipped, serverReady forced true.
//
// The PROD-only branches — `lifecycle.startServer`, the health-check
// polling fallback, and the startup-error screen with its Retry button —
// are unreachable here without mocking import.meta.env, so they are
// intentionally not covered.
useHappyPathHandlers();
setAppUrl('/');
const platform = createMockPlatform({ metadata: { isTauri: true } });
const screen = await renderWithProviders(<App />, { platform });
await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
expect(platform.lifecycle.startServer).not.toHaveBeenCalled();
expect(platform.lifecycle.setupWindowCloseHandler).toHaveBeenCalled();
// Startup syncs the keep-server-running setting into Rust.
expect(platform.lifecycle.setKeepServerRunning).toHaveBeenCalledWith(expect.any(Boolean));
// Auto-updater runs its mount check in Tauri.
expect(platform.updater.checkForUpdates).toHaveBeenCalled();
// Dev mode records that the app does not own the server process.
expect(window.__voiceboxServerStartedByApp).toBe(false);
});
it('renders the dictate pill window for ?view=dictate without booting the main app', async () => {
// No route handlers on purpose: the dictate view must not touch any of the
// main app's endpoints, and an unhandled request would fail the test.
setAppUrl('/?view=dictate');
const screen = await renderWithProviders(<App />);
// DictateWindow forces the document transparent so the Tauri window takes
// the pill's shape — the observable signal that it mounted without
// throwing under the non-Tauri mock platform.
await expect.poll(() => document.body.style.background).toBe('transparent');
// The pill starts hidden: the wrapper renders but contains no CapturePill.
const wrapper = screen.container.firstElementChild as HTMLElement;
expect(wrapper.className).toContain('h-screen');
expect(wrapper.childElementCount).toBe(0);
// The startup gate never ran — no server lifecycle calls from this window.
expect(screen.platform.lifecycle.startServer).not.toHaveBeenCalled();
expect(screen.platform.lifecycle.setupWindowCloseHandler).not.toHaveBeenCalled();
});