Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 112 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ on:
- main

jobs:
frontend-quality:
quality:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

Expand All @@ -22,5 +21,115 @@ jobs:
- name: Typecheck app + web
run: bun run typecheck

- name: Build web smoke test
- name: Build web
run: bun run build:web

- name: Upload web build
uses: actions/upload-artifact@v4
with:
name: web-dist
path: web/dist
retention-days: 1

unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22

- name: Setup Bun
uses: oven-sh/setup-bun@v2

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}

- name: Install Chromium
run: bunx playwright install chromium --with-deps

- name: Vitest (unit + browser)
run: bunx vitest run

e2e:
# Informational while the suite beds in; flip to blocking once it has
# a sustained green run.
continue-on-error: true
needs: quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22

- name: Setup Bun
uses: oven-sh/setup-bun@v2

- name: Install dependencies
run: bun install --frozen-lockfile

- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
cache-dependency-path: backend/requirements-ci.txt

- name: Install backend (CPU)
run: |
pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install -r backend/requirements-ci.txt

- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}

- name: Install Chromium
run: bunx playwright install chromium --with-deps

- name: Playwright E2E
run: bunx playwright test -c e2e
env:
VOICEBOX_PYTHON: python

- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: |
playwright-report
test-results
retention-days: 7

backend-tests:
# Informational: 30 pre-existing pytest files that have never run in CI.
continue-on-error: true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
cache-dependency-path: backend/requirements-ci.txt

- name: Install backend (CPU)
run: |
pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install -r backend/requirements-ci.txt
pip install pytest pytest-asyncio

- name: Pytest
run: python -m pytest backend/tests -v --ignore=backend/tests/test_all_models_e2e.py
Binary file modified .gitignore
Binary file not shown.
1 change: 1 addition & 0 deletions .node-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 0 additions & 29 deletions app/index.html

This file was deleted.

3 changes: 0 additions & 3 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc -p tsconfig.json --noEmit",
"preview": "vite preview",
"lint": "biome lint src",
"lint:fix": "biome lint --write src",
"format": "biome format --write src",
Expand Down
134 changes: 134 additions & 0 deletions app/src/App.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
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();
});
Loading
Loading