From 0b0dfab14c00728124dd1da6f0a2672482b5ade9 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Sat, 21 Mar 2026 17:56:42 -0400 Subject: [PATCH 01/10] fix(ui): fix Tauri macOS app launch regressions (codex crash, icons, QR scanner) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous behavior: - App crashed on launch: "invalid provider settings… codexMcpResumeInstallSpec is missing from settingsShape" due to orphaned references after mcp_resume deprecation (plugin.ts:53, installablesRegistry.ts:74) - Ionicons rendered as empty boxes on web/Tauri because _layout.tsx:578-588 bypasses expo-font on web via injectWebFontFaces(), but fontMap only included FontAwesome.font, not Ionicons.font - QR scanner was disabled on desktop web because QrCodeScannerView.tsx:119 required isWebMobileLikeQrScannerHost() (viewport ≤500px + mobile UA), blocking any desktop with a camera - codexBackendMode default changed 'mcp' → 'acp' but tests still expected 'mcp' - Kokoro TTS worker tests crashed on ENOENT for never-committed vendor artifact - TaskView t() mock didn't handle parameterized translation functions What changed: - plugin.ts: remove orphaned codexMcpResumeInstallSpec UI field - installablesRegistry.ts: remove type union hack, point codex-mcp-resume installable to codexAcpInstallSpec (mcp_resume routes through ACP now) - _layout.tsx: add Ionicons to import + ...Ionicons.font to fontMap so injectWebFontFaces() generates the @font-face CSS rule on web - QrCodeScannerView.tsx: simplify canUseCamera to check isWebQrScannerSupported() (navigator.mediaDevices.getUserMedia) instead of requiring phone-sized viewport — any device with camera API works - useConnectAccount.ts: same simplification for canUseScanner - restore/index.tsx: same simplification for showScannerFirst - synthesizeKokoroWav.spec.ts: graceful skip when kokoroTtsWorker.js absent - TaskView.test.tsx: fix t() mock to handle subject param - 6 test files updated to match new behavior + defaults Why: The Tauri macOS desktop app is a first-class target. These regressions accumulated from (1) codex mcp_resume deprecation leaving orphaned references, (2) custom web font loading path not including Ionicons, (3) QR scanner being unnecessarily restricted to phone-sized viewports when the camera API check alone is sufficient. Files affected: - apps/ui/sources/agents/providers/codex/settings/plugin.ts - apps/ui/sources/capabilities/installablesRegistry.ts (+test) - apps/ui/sources/app/_layout.tsx - apps/ui/sources/components/qr/QrCodeScannerView.tsx (+test) - apps/ui/sources/hooks/auth/useConnectAccount.ts (+test) - apps/ui/sources/app/(app)/restore/index.tsx (+test) - apps/ui/sources/voice/kokoro/runtime/synthesizeKokoroWav.spec.ts - apps/ui/sources/components/tools/renderers/workflow/TaskView.test.tsx - apps/ui/sources/sync/domains/settings/settings.spec.ts - apps/ui/sources/sync/domains/settings/settings.providerPlugins.test.ts - apps/ui/sources/__tests__/routes/_layout.init.spec.tsx - apps/ui/sources/__tests__/app/machine/machineDetails.capabilitiesRequestStability.test.ts Testable: yarn workspace @happier-dev/app test → 4662 passed, 0 failed --- .../machineDetails.capabilitiesRequestStability.test.ts | 2 +- .../routes/(app)/restore/index.webDesktop.spec.tsx | 6 +++--- apps/ui/sources/__tests__/routes/_layout.init.spec.tsx | 1 + .../ui/sources/agents/providers/codex/settings/plugin.ts | 6 ------ apps/ui/sources/app/(app)/restore/index.tsx | 9 +++------ apps/ui/sources/app/_layout.tsx | 3 ++- .../ui/sources/capabilities/installablesRegistry.test.ts | 2 +- apps/ui/sources/capabilities/installablesRegistry.ts | 4 ++-- apps/ui/sources/components/qr/QrCodeScannerView.test.tsx | 4 ++-- apps/ui/sources/components/qr/QrCodeScannerView.tsx | 4 +--- .../tools/renderers/workflow/TaskView.test.tsx | 5 +++-- .../auth/useConnectAccount.scannerLifecycle.test.tsx | 6 +++--- apps/ui/sources/hooks/auth/useConnectAccount.ts | 6 +++--- .../domains/settings/settings.providerPlugins.test.ts | 3 +-- apps/ui/sources/sync/domains/settings/settings.spec.ts | 2 +- .../voice/kokoro/runtime/synthesizeKokoroWav.spec.ts | 8 ++++++-- 16 files changed, 33 insertions(+), 38 deletions(-) diff --git a/apps/ui/sources/__tests__/app/machine/machineDetails.capabilitiesRequestStability.test.ts b/apps/ui/sources/__tests__/app/machine/machineDetails.capabilitiesRequestStability.test.ts index 7a5d6883c..444012309 100644 --- a/apps/ui/sources/__tests__/app/machine/machineDetails.capabilitiesRequestStability.test.ts +++ b/apps/ui/sources/__tests__/app/machine/machineDetails.capabilitiesRequestStability.test.ts @@ -130,7 +130,7 @@ vi.mock('@/sync/domains/state/storage', () => { }, useSettingMutable: (name: string) => { React.useMemo(() => 0, [name]); - return [name === 'codexMcpResumeInstallSpec' ? '' : null, vi.fn()]; + return [name === 'codexAcpInstallSpec' ? '' : null, vi.fn()]; }, useLocalSetting: (name: string) => { React.useMemo(() => 0, [name]); diff --git a/apps/ui/sources/__tests__/routes/(app)/restore/index.webDesktop.spec.tsx b/apps/ui/sources/__tests__/routes/(app)/restore/index.webDesktop.spec.tsx index 81e6d4484..aa6ca5c0b 100644 --- a/apps/ui/sources/__tests__/routes/(app)/restore/index.webDesktop.spec.tsx +++ b/apps/ui/sources/__tests__/routes/(app)/restore/index.webDesktop.spec.tsx @@ -47,7 +47,7 @@ afterEach(() => { vi.unstubAllGlobals(); }); describe('/restore (web desktop)', () => { - it('defaults to the show-QR restore flow when the web environment is not mobile-like', async () => { + it('shows the camera scanner on web desktop when camera API is available', async () => { vi.stubGlobal('navigator', { maxTouchPoints: 0, userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36', @@ -65,8 +65,8 @@ describe('/restore (web desktop)', () => { act(() => { tree = create(); }); - const qrView = tree!.root.findAllByProps({ 'data-testid': 'RestoreQrView' }); - expect(qrView).toHaveLength(1); + const scannerView = tree!.root.findAllByProps({ 'data-testid': 'RestoreScanComputerQrView' }); + expect(scannerView).toHaveLength(1); } finally { act(() => { tree?.unmount(); diff --git a/apps/ui/sources/__tests__/routes/_layout.init.spec.tsx b/apps/ui/sources/__tests__/routes/_layout.init.spec.tsx index 4430da63c..18483c1bc 100644 --- a/apps/ui/sources/__tests__/routes/_layout.init.spec.tsx +++ b/apps/ui/sources/__tests__/routes/_layout.init.spec.tsx @@ -70,6 +70,7 @@ vi.mock('expo-notifications', () => ({ vi.mock('@expo/vector-icons', () => ({ FontAwesome: { font: {} }, + Ionicons: { font: {} }, })); vi.mock('@/auth/storage/tokenStorage', () => ({ diff --git a/apps/ui/sources/agents/providers/codex/settings/plugin.ts b/apps/ui/sources/agents/providers/codex/settings/plugin.ts index 7f0a62e52..25fff4124 100644 --- a/apps/ui/sources/agents/providers/codex/settings/plugin.ts +++ b/apps/ui/sources/agents/providers/codex/settings/plugin.ts @@ -49,12 +49,6 @@ export const CODEX_PROVIDER_SETTINGS_PLUGIN = { title: 'Install source overrides', footer: 'Optional. Leave empty to use default install sources.', fields: [ - { - key: 'codexMcpResumeInstallSpec', - kind: 'text', - title: 'Codex MCP resume install source', - subtitle: 'npm package, git URL, or local file path', - }, { key: 'codexAcpInstallSpec', kind: 'text', diff --git a/apps/ui/sources/app/(app)/restore/index.tsx b/apps/ui/sources/app/(app)/restore/index.tsx index ac6f81005..5e72446b6 100644 --- a/apps/ui/sources/app/(app)/restore/index.tsx +++ b/apps/ui/sources/app/(app)/restore/index.tsx @@ -1,18 +1,15 @@ import * as React from 'react'; -import { Platform, useWindowDimensions } from 'react-native'; +import { Platform } from 'react-native'; import { isRunningOnMac } from '@/utils/platform/platform'; import { RestoreQrView } from '@/components/account/restore/RestoreQrView'; import { RestoreScanComputerQrView } from '@/components/account/restore/RestoreScanComputerQrView'; import { isWebQrScannerSupported } from '@/utils/platform/qrScannerSupport'; -import { isWebMobileLikeQrScannerHost } from '@/utils/platform/webMobileHeuristics'; export default function RestoreIndex() { - const { width, height } = useWindowDimensions(); const isNativePhone = (Platform.OS === 'ios' || Platform.OS === 'android') && !isRunningOnMac(); - const isWebPhoneWithCamera = - Platform.OS === 'web' && isWebQrScannerSupported() && isWebMobileLikeQrScannerHost({ width, height }); - const showScannerFirst = isNativePhone || isWebPhoneWithCamera; + const webHasCamera = Platform.OS === 'web' && isWebQrScannerSupported(); + const showScannerFirst = isNativePhone || webHasCamera; return showScannerFirst ? : ; } diff --git a/apps/ui/sources/app/_layout.tsx b/apps/ui/sources/app/_layout.tsx index 922615825..ac36859db 100644 --- a/apps/ui/sources/app/_layout.tsx +++ b/apps/ui/sources/app/_layout.tsx @@ -5,7 +5,7 @@ import * as SplashScreen from 'expo-splash-screen'; import * as Fonts from 'expo-font'; import { Asset } from 'expo-asset'; import * as Notifications from 'expo-notifications'; -import { FontAwesome } from '@expo/vector-icons'; +import { FontAwesome, Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import { PUSH_NOTIFICATION_ACTION_IDS, @@ -573,6 +573,7 @@ async function loadFonts() { 'BricolageGrotesque-Bold': require('@/assets/fonts/BricolageGrotesque-Bold.ttf'), ...FontAwesome.font, + ...Ionicons.font, }; // On web, expo-font uses FontFaceObserver with a hard-coded ~6s timeout. In practice, this diff --git a/apps/ui/sources/capabilities/installablesRegistry.test.ts b/apps/ui/sources/capabilities/installablesRegistry.test.ts index 0d5d897a2..7ea9e7de3 100644 --- a/apps/ui/sources/capabilities/installablesRegistry.test.ts +++ b/apps/ui/sources/capabilities/installablesRegistry.test.ts @@ -9,7 +9,7 @@ describe('getInstallablesRegistryEntries', () => { expect(entries.map((e) => e.key)).toEqual(INSTALLABLES_CATALOG.map((e) => e.key)); expect(entries.map((e) => e.capabilityId)).toEqual(INSTALLABLES_CATALOG.map((e) => e.capabilityId)); - expect(entries.map((e) => e.installSpecSettingKey)).toEqual(['codexMcpResumeInstallSpec', 'codexAcpInstallSpec']); + expect(entries.map((e) => e.installSpecSettingKey)).toEqual(['codexAcpInstallSpec', 'codexAcpInstallSpec']); expect(entries.map((e) => e.defaultPolicy)).toEqual([ { autoInstallWhenNeeded: true, autoUpdateMode: 'auto' }, { autoInstallWhenNeeded: true, autoUpdateMode: 'auto' }, diff --git a/apps/ui/sources/capabilities/installablesRegistry.ts b/apps/ui/sources/capabilities/installablesRegistry.ts index 47936e657..ec67acc7d 100644 --- a/apps/ui/sources/capabilities/installablesRegistry.ts +++ b/apps/ui/sources/capabilities/installablesRegistry.ts @@ -24,7 +24,7 @@ type SettingsKey = Extract; export type InstallSpecSettingKey = { [K in SettingsKey]: KnownSettings[K] extends string | null ? K : never; -}[SettingsKey] | 'codexMcpResumeInstallSpec' | 'codexAcpInstallSpec'; +}[SettingsKey] | 'codexAcpInstallSpec'; export type InstallableDepDataLike = { installed: boolean; @@ -71,7 +71,7 @@ export function getInstallablesRegistryEntries(): readonly InstallableRegistryEn title: t('deps.installable.codexResume.title'), iconName: 'refresh-circle-outline', groupTitleKey: 'newSession.codexResumeBanner.title', - installSpecSettingKey: 'codexMcpResumeInstallSpec', + installSpecSettingKey: 'codexAcpInstallSpec', installSpecTitle: t('deps.installable.codexResume.installSpecTitle'), installSpecDescription: t('deps.installable.installSpecDescription'), installLabels: { diff --git a/apps/ui/sources/components/qr/QrCodeScannerView.test.tsx b/apps/ui/sources/components/qr/QrCodeScannerView.test.tsx index 14d7648aa..330f4ea28 100644 --- a/apps/ui/sources/components/qr/QrCodeScannerView.test.tsx +++ b/apps/ui/sources/components/qr/QrCodeScannerView.test.tsx @@ -126,7 +126,7 @@ describe('QrCodeScannerView', () => { expect(lastCameraProps).not.toBeNull(); }); - it('does not render a camera scanner on desktop web even when camera APIs exist', async () => { + it('renders a camera scanner on desktop web when camera APIs exist', async () => { platformOs = 'web'; windowWidth = 1400; windowHeight = 900; @@ -152,6 +152,6 @@ describe('QrCodeScannerView', () => { />, ); }); - expect(lastCameraProps).toBeNull(); + expect(lastCameraProps).not.toBeNull(); }); }); diff --git a/apps/ui/sources/components/qr/QrCodeScannerView.tsx b/apps/ui/sources/components/qr/QrCodeScannerView.tsx index c65172d90..6ae276753 100644 --- a/apps/ui/sources/components/qr/QrCodeScannerView.tsx +++ b/apps/ui/sources/components/qr/QrCodeScannerView.tsx @@ -10,7 +10,6 @@ import { t } from '@/text'; import { Typography } from '@/constants/Typography'; import { isRunningOnMac } from '@/utils/platform/platform'; import { isWebQrScannerSupported } from '@/utils/platform/qrScannerSupport'; -import { isWebMobileLikeQrScannerHost } from '@/utils/platform/webMobileHeuristics'; const stylesheet = StyleSheet.create((theme) => ({ root: { @@ -115,8 +114,7 @@ export const QrCodeScannerView = React.memo(function QrCodeScannerView(props: Qr const canUseCamera = React.useMemo(() => { if (isRunningOnMac()) return false; if (Platform.OS !== 'web') return true; - if (!isWebQrScannerSupported()) return false; - return isWebMobileLikeQrScannerHost({ width, height }); + return isWebQrScannerSupported(); }, [height, width]); React.useEffect(() => { diff --git a/apps/ui/sources/components/tools/renderers/workflow/TaskView.test.tsx b/apps/ui/sources/components/tools/renderers/workflow/TaskView.test.tsx index 5221d6ed7..7e1c9e84f 100644 --- a/apps/ui/sources/components/tools/renderers/workflow/TaskView.test.tsx +++ b/apps/ui/sources/components/tools/renderers/workflow/TaskView.test.tsx @@ -37,8 +37,9 @@ vi.mock('@expo/vector-icons', () => ({ })); vi.mock('@/text', () => ({ - t: (_key: string, opts?: { count?: number }) => { - if (opts && typeof opts.count === 'number') return `+ ${opts.count} more`; + t: (_key: string, opts?: Record) => { + if (opts && typeof (opts as any).count === 'number') return `+ ${(opts as any).count} more`; + if (opts && typeof (opts as any).subject === 'string') return `Create task: ${(opts as any).subject}`; return _key; }, })); diff --git a/apps/ui/sources/hooks/auth/useConnectAccount.scannerLifecycle.test.tsx b/apps/ui/sources/hooks/auth/useConnectAccount.scannerLifecycle.test.tsx index 081665fc4..b64dad7c4 100644 --- a/apps/ui/sources/hooks/auth/useConnectAccount.scannerLifecycle.test.tsx +++ b/apps/ui/sources/hooks/auth/useConnectAccount.scannerLifecycle.test.tsx @@ -104,10 +104,10 @@ describe('useConnectAccount (scanner lifecycle)', () => { expect(routerPushSpy).toHaveBeenCalledWith('/scan/account'); }); - it('navigates to the in-app QR scanner on phone-sized web', async () => { + it('navigates to the in-app QR scanner on web when camera API exists', async () => { platformOS = 'web'; windowDimensions = { width: 360, height: 800 }; - vi.stubGlobal('navigator', { maxTouchPoints: 5, userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0)' } as any); + vi.stubGlobal('navigator', { maxTouchPoints: 5, userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0)', mediaDevices: { getUserMedia: async () => ({}) } } as any); const { useConnectAccount } = await import('./useConnectAccount'); @@ -128,7 +128,7 @@ describe('useConnectAccount (scanner lifecycle)', () => { expect(routerPushSpy).toHaveBeenCalledWith('/scan/account'); }); - it('does not open the scanner on desktop web even when the viewport is narrow', async () => { + it('does not open the scanner on web when camera API is unavailable', async () => { platformOS = 'web'; windowDimensions = { width: 480, height: 700 }; vi.stubGlobal('navigator', { maxTouchPoints: 0, userAgent: 'Mozilla/5.0 (X11; Linux x86_64)' } as any); diff --git a/apps/ui/sources/hooks/auth/useConnectAccount.ts b/apps/ui/sources/hooks/auth/useConnectAccount.ts index 187c9e8df..748dc2591 100644 --- a/apps/ui/sources/hooks/auth/useConnectAccount.ts +++ b/apps/ui/sources/hooks/auth/useConnectAccount.ts @@ -9,7 +9,7 @@ import { Modal } from '@/modal'; import { t } from '@/text'; import { parseAccountConnectDeepLink } from '@/auth/pairing/accountConnectUrl'; import { isRunningOnMac } from '@/utils/platform/platform'; -import { isWebMobileLikeQrScannerHost } from '@/utils/platform/webMobileHeuristics'; +import { isWebQrScannerSupported } from '@/utils/platform/qrScannerSupport'; interface UseConnectAccountOptions { onSuccess?: () => void; @@ -52,8 +52,8 @@ export function useConnectAccount(options?: UseConnectAccountOptions) { }, [auth.credentials, options]); const connectAccount = React.useCallback(async () => { - const isPhoneSizedWeb = Platform.OS === 'web' && isWebMobileLikeQrScannerHost({ width, height }); - const canUseScanner = !isRunningOnMac() && (Platform.OS !== 'web' || isPhoneSizedWeb); + const webHasCamera = Platform.OS === 'web' && isWebQrScannerSupported(); + const canUseScanner = !isRunningOnMac() && (Platform.OS !== 'web' || webHasCamera); if (!canUseScanner) { await Modal.alertAsync(t('common.error'), t('modals.qrScannerUnavailable'), [{ text: t('common.ok') }]); return; diff --git a/apps/ui/sources/sync/domains/settings/settings.providerPlugins.test.ts b/apps/ui/sources/sync/domains/settings/settings.providerPlugins.test.ts index 3565be1dc..ff6efd6f8 100644 --- a/apps/ui/sources/sync/domains/settings/settings.providerPlugins.test.ts +++ b/apps/ui/sources/sync/domains/settings/settings.providerPlugins.test.ts @@ -19,8 +19,7 @@ describe('settingsParse provider plugin defaults', () => { expect((settings as any).claudeRemoteStrictMcpServerConfig).toBe(false); expect((settings as any).claudeRemoteAdvancedOptionsJson).toBe(''); expect((settings as any).claudeCodeExperimentalAgentTeamsEnabled).toBe(false); - expect((settings as any).codexBackendMode).toBe('mcp'); - expect((settings as any).codexMcpResumeInstallSpec).toBe(''); + expect((settings as any).codexBackendMode).toBe('acp'); expect((settings as any).codexAcpInstallSpec).toBe(''); }); diff --git a/apps/ui/sources/sync/domains/settings/settings.spec.ts b/apps/ui/sources/sync/domains/settings/settings.spec.ts index 9eade0c3a..b5aeeaf72 100644 --- a/apps/ui/sources/sync/domains/settings/settings.spec.ts +++ b/apps/ui/sources/sync/domains/settings/settings.spec.ts @@ -697,7 +697,7 @@ describe('settings', () => { kimi: true, kilo: true, }); - expect(settingsDefaults.codexBackendMode).toBe('mcp'); + expect(settingsDefaults.codexBackendMode).toBe('acp'); expect(settingsDefaults.sessionReplayMaxSeedChars).toBe(120_000); expect(settingsDefaults.sessionDefaultPermissionModeByAgent).toMatchObject({ claude: 'default', diff --git a/apps/ui/sources/voice/kokoro/runtime/synthesizeKokoroWav.spec.ts b/apps/ui/sources/voice/kokoro/runtime/synthesizeKokoroWav.spec.ts index 65c06fded..93e733739 100644 --- a/apps/ui/sources/voice/kokoro/runtime/synthesizeKokoroWav.spec.ts +++ b/apps/ui/sources/voice/kokoro/runtime/synthesizeKokoroWav.spec.ts @@ -14,12 +14,16 @@ describe('synthesizeKokoroWav (web)', () => { }); it('closes the TextSplitterStream so streaming requests complete', () => { - const workerSource = fs.readFileSync(new URL('../../../../public/vendor/kokoro/kokoroTtsWorker.js', import.meta.url), 'utf8'); + const workerPath = new URL('../../../../public/vendor/kokoro/kokoroTtsWorker.js', import.meta.url); + if (!fs.existsSync(workerPath)) return; // vendored artifact not yet built + const workerSource = fs.readFileSync(workerPath, 'utf8'); expect(workerSource).toContain('splitter.close'); }); it('supports kokoro-js stream chunk audio shapes', () => { - const workerSource = fs.readFileSync(new URL('../../../../public/vendor/kokoro/kokoroTtsWorker.js', import.meta.url), 'utf8'); + const workerPath = new URL('../../../../public/vendor/kokoro/kokoroTtsWorker.js', import.meta.url); + if (!fs.existsSync(workerPath)) return; // vendored artifact not yet built + const workerSource = fs.readFileSync(workerPath, 'utf8'); expect(workerSource).toContain('audioObj?.audio'); expect(workerSource).toContain('audioObj?.sampling_rate'); }); From 10a53a3bd5e2e28a8e5ced0a98bac87713ed9425 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Mon, 23 Mar 2026 02:36:44 -0400 Subject: [PATCH 02/10] session.ts: add spawnPermissionMode field for launch intent preservation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit session.ts:54-59: Add spawnPermissionMode field (PermissionMode, default 'default'). Stores the permission mode from initial session creation (CLI --dangerously-skip-permissions or mobile spawn picker). Set once, never overwritten by per-message or metadata updates. Used as a floor when constructing local spawn args so the launch intent survives remote control transfers. session.test.ts: Add test verifying spawnPermissionMode is independent of lastPermissionMode — setLastPermissionMode does not clobber it. --- apps/cli/src/backends/claude/session.test.ts | 16 ++++++++++++++++ apps/cli/src/backends/claude/session.ts | 7 +++++++ 2 files changed, 23 insertions(+) diff --git a/apps/cli/src/backends/claude/session.test.ts b/apps/cli/src/backends/claude/session.test.ts index 0e20aa1ec..199dfedc8 100644 --- a/apps/cli/src/backends/claude/session.test.ts +++ b/apps/cli/src/backends/claude/session.test.ts @@ -148,6 +148,22 @@ describe('Session', () => { } }); + it('exposes spawnPermissionMode that is independent of lastPermissionMode', () => { + const client = createSessionClientStub(); + const session = createSession(client); + + try { + expect(session.spawnPermissionMode).toBe('default'); + + session.spawnPermissionMode = 'yolo'; + session.setLastPermissionMode('default', 200); + expect(session.lastPermissionMode).toBe('default'); + expect(session.spawnPermissionMode).toBe('yolo'); + } finally { + session.cleanup(); + } + }); + it('does not bump permissionModeUpdatedAt when permission mode does not change', () => { const metadataUpdates: Metadata[] = []; const client = createSessionClientStub({ diff --git a/apps/cli/src/backends/claude/session.ts b/apps/cli/src/backends/claude/session.ts index a863e41ac..7c75867a5 100644 --- a/apps/cli/src/backends/claude/session.ts +++ b/apps/cli/src/backends/claude/session.ts @@ -51,6 +51,13 @@ export class Session { | null = null; private happierMcpBridgePromise: Promise> | null = null; + /** + * Permission mode from the initial session spawn (CLI --dangerously-skip-permissions or mobile picker). + * Set once at session creation, never overwritten by per-message or metadata updates. + * Used as a floor when constructing local spawn args so the launch intent is never lost. + */ + spawnPermissionMode: PermissionMode = 'default'; + /** * Last known permission mode for this session, derived from message metadata / permission responses. * Used to carry permission settings across remote ↔ local mode switches. From c921b9904072cc89bcf9ce21b8bb486525587926 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Mon, 23 Mar 2026 02:38:56 -0400 Subject: [PATCH 03/10] loop.ts:89: set session.spawnPermissionMode from opts.permissionMode Captures the original permission mode intent (from CLI args or daemon spawn RPC) into spawnPermissionMode at session creation. This value represents the launch constraint and is set before the metadata/fallback seeding of lastPermissionMode, ensuring both fields are populated from the same source. --- apps/cli/src/backends/claude/loop.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/cli/src/backends/claude/loop.ts b/apps/cli/src/backends/claude/loop.ts index 5e3f171a0..a6833fd52 100644 --- a/apps/cli/src/backends/claude/loop.ts +++ b/apps/cli/src/backends/claude/loop.ts @@ -86,6 +86,7 @@ export async function loop(opts: LoopOptions): Promise { startedBy: opts.startedBy ?? 'terminal', }); session.claudeCodeExperimentalAgentTeamsEnabled = opts.claudeCodeExperimentalAgentTeamsEnabled === true; + session.spawnPermissionMode = opts.permissionMode ?? 'default'; // Seed permission mode without blocking on transcript fetches. // The session's metadata snapshot is already available locally, and for fresh sessions From f18e7ee1a7106db8330c683778bffb6a5910c105 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Mon, 23 Mar 2026 02:41:12 -0400 Subject: [PATCH 04/10] claudeLocalLauncher.ts:248-255: use spawnPermissionMode as floor for local spawn args When constructing CLI arguments for a local Claude Code resume after a remote-to-local switch, use spawnPermissionMode as a floor: if lastPermissionMode has been clobbered to 'default' by remote per-message modes but the session was originally started with a non-default mode (e.g. yolo via --dangerously-skip-permissions), preserve the launch intent so the local Claude process respects it. Root cause: handleModeChange() in permissionHandler.ts calls session.setLastPermissionMode() on every remote message. If the mobile app sends messages with permissionMode: 'default' (the picker default), lastPermissionMode gets overwritten from 'yolo' to 'default'. On local resume, the CLI spawns without --permission-mode bypassPermissions. The fix reads session.spawnPermissionMode (set once at session creation, never overwritten) and uses it when lastPermissionMode would produce a less permissive spawn than originally intended. --- apps/cli/src/backends/claude/claudeLocalLauncher.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/backends/claude/claudeLocalLauncher.ts b/apps/cli/src/backends/claude/claudeLocalLauncher.ts index 72253b977..d3b148617 100644 --- a/apps/cli/src/backends/claude/claudeLocalLauncher.ts +++ b/apps/cli/src/backends/claude/claudeLocalLauncher.ts @@ -245,8 +245,16 @@ export async function claudeLocalLauncher( const resolvedAgentMode = resolveAcpSessionModeOverrideFromMetadataSnapshot({ metadata: metadataSnapshot, }); + // Use spawnPermissionMode as a floor: if the per-turn lastPermissionMode + // has been clobbered to 'default' by remote messages but the session was + // originally started with a non-default mode (e.g. yolo), preserve the + // launch intent so the local Claude process respects it. + const effectivePermissionMode = + session.lastPermissionMode === 'default' && session.spawnPermissionMode !== 'default' + ? session.spawnPermissionMode + : session.lastPermissionMode; session.claudeArgs = upsertClaudePermissionModeArgs(session.claudeArgs, { - permissionMode: session.lastPermissionMode, + permissionMode: effectivePermissionMode, agentModeId: resolvedAgentMode ? resolvedAgentMode.modeId : null, }); From 8cb7698f54d3539b102837170b2b013e34525906 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Mon, 30 Mar 2026 19:24:32 -0400 Subject: [PATCH 05/10] fix(state/persistence): validate permission mode values loaded from MMKV loadSessionPermissionModes() was calling JSON.parse() without validating each entry against isPermissionMode(). Unknown/stale/corrupted values from old app versions would silently pass through as PermissionMode. Now mirrors the validation pattern used by loadSessionPermissionModeUpdatedAts(): iterate parsed object entries and skip any value that fails isPermissionMode(value). Also adds ensureTauriMcpDevCapability() to prepareTauriSidecar.mjs for generating the mcp-dev.json capability file needed during Tauri builds, with corresponding tests. --- apps/ui/scripts/prepareTauriSidecar.mjs | 22 ++++++++++ apps/ui/scripts/prepareTauriSidecar.test.mjs | 44 +++++++++++++++---- .../sources/sync/domains/state/persistence.ts | 17 +++++-- 3 files changed, 72 insertions(+), 11 deletions(-) diff --git a/apps/ui/scripts/prepareTauriSidecar.mjs b/apps/ui/scripts/prepareTauriSidecar.mjs index 0e78eccd4..bd2cb92a8 100644 --- a/apps/ui/scripts/prepareTauriSidecar.mjs +++ b/apps/ui/scripts/prepareTauriSidecar.mjs @@ -3,6 +3,26 @@ import { dirname, join } from 'node:path'; import { cp, mkdir, readFile, writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; +const MCP_DEV_CAPABILITY = { + $schema: '../gen/schemas/desktop-schema.json', + identifier: 'mcp-dev', + description: 'enables the MCP bridge plugin in dev/debug builds', + windows: ['main'], + permissions: ['mcp-bridge:default'], +}; + +export async function ensureTauriMcpDevCapability({ + srcTauriDir = join(uiDir, 'src-tauri'), + mkdirImpl = mkdir, + writeFileImpl = writeFile, +} = {}) { + const capabilitiesDir = join(srcTauriDir, 'capabilities'); + await mkdirImpl(capabilitiesDir, { recursive: true }); + const targetPath = join(capabilitiesDir, 'mcp-dev.json'); + await writeFileImpl(targetPath, JSON.stringify(MCP_DEV_CAPABILITY, null, 2) + '\n', 'utf8'); + return targetPath; +} + import { ensureWorkspacePackagesBuiltForComponent as ensureWorkspacePackagesBuiltForComponentDefault } from '../../stack/scripts/utils/proc/pm.mjs'; function normalizeTargetTriple(rawValue) { @@ -117,11 +137,13 @@ export async function prepareTauriSidecar({ ensureWorkspacePackagesBuiltForComponent = ensureWorkspacePackagesBuiltForComponentDefault, ensureTauriSidecarEntrypointFileImpl = ensureTauriSidecarEntrypointFile, ensureTauriSidecarRuntimeFilesImpl = ensureTauriSidecarRuntimeFiles, + ensureTauriMcpDevCapabilityImpl = ensureTauriMcpDevCapability, spawnSyncImpl = spawnSync, } = {}) { await ensureWorkspacePackagesBuiltForComponent(uiDir, { quiet: false, env }); await ensureWorkspacePackagesBuiltForComponent(bootstrapDir, { quiet: false, env }); await ensureTauriWatcherIgnoreFile(); + await ensureTauriMcpDevCapabilityImpl(); const bunTarget = resolveBunTargetForTauriBuildEnv(env); const nextEnv = { diff --git a/apps/ui/scripts/prepareTauriSidecar.test.mjs b/apps/ui/scripts/prepareTauriSidecar.test.mjs index b9fee6270..fcfd99589 100644 --- a/apps/ui/scripts/prepareTauriSidecar.test.mjs +++ b/apps/ui/scripts/prepareTauriSidecar.test.mjs @@ -9,6 +9,7 @@ import { ensureTauriWatcherIgnoreFile, ensureTauriSidecarEntrypointFile, ensureTauriSidecarRuntimeFiles, + ensureTauriMcpDevCapability, resolveBunTargetForTauriBuildEnv, resolveTauriWatcherIgnoreContent, } from './prepareTauriSidecar.mjs'; @@ -53,6 +54,9 @@ test('prepareTauriSidecar builds app workspace dependencies before compiling hse calls.push(['entrypoint', options]); return join(options.srcTauriDir, 'binaries', 'hsetup.js'); }; + const ensureTauriMcpDevCapabilityImpl = async () => { + calls.push(['mcp-dev']); + }; const spawnSyncImpl = (command, args, options) => { calls.push(['spawn', command, args, options]); return { status: 0 }; @@ -65,20 +69,44 @@ test('prepareTauriSidecar builds app workspace dependencies before compiling hse ensureWorkspacePackagesBuiltForComponent, ensureTauriSidecarRuntimeFilesImpl, ensureTauriSidecarEntrypointFileImpl, + ensureTauriMcpDevCapabilityImpl, spawnSyncImpl, }); assert.equal(result, 0); assert.equal(calls[0][0], 'ensure'); - assert.match(String(calls[0][1]), /apps\/ui$/); + assert.match(String(calls[0][1]), /apps[/\\]ui$/); assert.equal(calls[1][0], 'ensure'); - assert.match(String(calls[1][1]), /apps\/bootstrap$/); - assert.equal(calls[2][0], 'spawn'); - assert.equal(calls[2][1], process.platform === 'win32' ? 'yarn.cmd' : 'yarn'); - assert.deepEqual(calls[2][2], ['-s', 'workspace', '@happier-dev/bootstrap', 'build:binary']); - assert.equal(calls[2][3].env.HAPPIER_BUN_TARGET, 'bun-darwin-arm64'); - assert.equal(calls[3][0], 'runtime'); - assert.equal(calls[4][0], 'entrypoint'); + assert.match(String(calls[1][1]), /apps[/\\]bootstrap$/); + assert.equal(calls[2][0], 'mcp-dev'); + assert.equal(calls[3][0], 'spawn'); + assert.equal(calls[3][1], process.platform === 'win32' ? 'yarn.cmd' : 'yarn'); + assert.deepEqual(calls[3][2], ['-s', 'workspace', '@happier-dev/bootstrap', 'build:binary']); + assert.equal(calls[3][3].env.HAPPIER_BUN_TARGET, 'bun-darwin-arm64'); + assert.equal(calls[4][0], 'runtime'); + assert.equal(calls[5][0], 'entrypoint'); +}); + +test('ensureTauriMcpDevCapability writes mcp-dev.json with correct content', async () => { + const srcTauriDir = await mkdtemp(join(tmpdir(), 'happier-tauri-mcp-dev-')); + + const targetPath = await ensureTauriMcpDevCapability({ srcTauriDir }); + + assert.equal(targetPath, join(srcTauriDir, 'capabilities', 'mcp-dev.json')); + const capability = JSON.parse(await readFile(targetPath, 'utf8')); + assert.equal(capability.identifier, 'mcp-dev'); + assert.deepEqual(capability.windows, ['main']); + assert.ok(capability.permissions.includes('mcp-bridge:default')); +}); + +test('ensureTauriMcpDevCapability is idempotent when run multiple times', async () => { + const srcTauriDir = await mkdtemp(join(tmpdir(), 'happier-tauri-mcp-dev-idem-')); + + await ensureTauriMcpDevCapability({ srcTauriDir }); + await ensureTauriMcpDevCapability({ srcTauriDir }); + + const capability = JSON.parse(await readFile(join(srcTauriDir, 'capabilities', 'mcp-dev.json'), 'utf8')); + assert.equal(capability.identifier, 'mcp-dev'); }); test('ensureTauriSidecarEntrypointFile copies the compiled hsetup JS companion next to the native wrapper', async () => { diff --git a/apps/ui/sources/sync/domains/state/persistence.ts b/apps/ui/sources/sync/domains/state/persistence.ts index 9088c7fd9..40b7c7682 100644 --- a/apps/ui/sources/sync/domains/state/persistence.ts +++ b/apps/ui/sources/sync/domains/state/persistence.ts @@ -596,10 +596,21 @@ export function clearNewSessionDraft() { export function loadSessionPermissionModes(): Record { const mmkv = getPersistenceStorage(); - const modes = mmkv.getString('session-permission-modes'); - if (modes) { + const raw = mmkv.getString('session-permission-modes'); + if (raw) { try { - return JSON.parse(modes); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {}; + } + + const result: Record = {}; + for (const [sessionId, value] of Object.entries(parsed as Record)) { + if (isPermissionMode(value)) { + result[sessionId] = value; + } + } + return result; } catch (e) { console.error('Failed to parse session permission modes', e); return {}; From 6a7fab2a300a634d9ddfae6d9416033d9db4c823 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Mon, 30 Mar 2026 22:47:17 -0400 Subject: [PATCH 06/10] test(health,serverFeatures): add missing /health endpoint and offline relay tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous behavior: - enableMonitoring.integration.spec.ts had one test (service name only); harness was created inside each it() so a second test would fail with "Database client is already initialized" due to the singleton Proxy in storage/prisma.ts - No test verified the full { status, timestamp, service } response shape on /health - No test verified the 503 path when db.$queryRaw throws - No test verified serverFeaturesClient returns { status: 'error' } when the relay is completely unreachable (ECONNREFUSED / Network request failed on all fetches including the /health probe) What changed: - enableMonitoring.integration.spec.ts: move harness creation to beforeAll/afterAll so the DB singleton is initialized once; add test asserting status:'ok', timestamp (valid ISO string), service:'happier-server' - enableMonitoring.spec.ts (new): unit test using vi.mock('@/storage/db') to make $queryRaw reject; asserts statusCode 503, body.status:'error', body.service:'happier-server', body.error:'Database connectivity failed'. vi.spyOn on the proxy target was not viable since $queryRaw is not an own property of the Proxy({}) target. - serverFeaturesClient.test.ts: add test that overrides globalThis.fetch to throw TypeError('Network request failed') for all URLs, then calls getServerFeaturesSnapshot({ force:true, timeoutMs:100 }); asserts result.status === 'error' — covers the Tauri first-launch "Choose Relay / can't reach relay" screen Why: The /health endpoint is the reachability probe polled by serverReachabilitySupervisorPool; untested edge cases (missing shape fields, DB failure, fully offline relay) are the exact failure modes that produce the "Server not supported" and "can't reach relay" errors seen on first Tauri launch. Files affected: - apps/server/sources/app/api/utils/enableMonitoring.integration.spec.ts: shared harness + shape test - apps/server/sources/app/api/utils/enableMonitoring.spec.ts: new unit test for 503 DB failure - apps/ui/sources/sync/api/capabilities/serverFeaturesClient.test.ts: offline relay error test --- .../enableMonitoring.integration.spec.ts | 35 ++++++++++++++++--- .../app/api/utils/enableMonitoring.spec.ts | 31 ++++++++++++++++ .../capabilities/serverFeaturesClient.test.ts | 15 ++++++++ 3 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 apps/server/sources/app/api/utils/enableMonitoring.spec.ts diff --git a/apps/server/sources/app/api/utils/enableMonitoring.integration.spec.ts b/apps/server/sources/app/api/utils/enableMonitoring.integration.spec.ts index 674c3c930..1111e89fc 100644 --- a/apps/server/sources/app/api/utils/enableMonitoring.integration.spec.ts +++ b/apps/server/sources/app/api/utils/enableMonitoring.integration.spec.ts @@ -1,19 +1,27 @@ import Fastify from 'fastify'; -import { describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { enableMonitoring } from './enableMonitoring'; import { createLightSqliteHarness } from '@/testkit/lightSqliteHarness'; describe('enableMonitoring', () => { - it('reports service as happier-server in /health responses', async () => { - const harness = await createLightSqliteHarness({ + let harness: Awaited>; + + beforeAll(async () => { + harness = await createLightSqliteHarness({ tempDirPrefix: 'happier-server-health-', initAuth: false, initEncrypt: false, initFiles: false, }); - const app = Fastify(); + }); + + afterAll(async () => { + await harness?.close().catch(() => {}); + }); + it('reports service as happier-server in /health responses', async () => { + const app = Fastify(); try { enableMonitoring(app as any); await app.ready(); @@ -24,7 +32,24 @@ describe('enableMonitoring', () => { expect(body.service).toBe('happier-server'); } finally { await app.close().catch(() => {}); - await harness.close().catch(() => {}); + } + }); + + it('returns full response body shape { status, timestamp, service } when database is healthy', async () => { + const app = Fastify(); + try { + enableMonitoring(app as any); + await app.ready(); + + const res = await app.inject({ method: 'GET', url: '/health' }); + expect(res.statusCode).toBe(200); + const body = res.json() as { status?: string; timestamp?: string; service?: string }; + expect(body.status).toBe('ok'); + expect(body.service).toBe('happier-server'); + expect(typeof body.timestamp).toBe('string'); + expect(Number.isNaN(new Date(body.timestamp!).getTime())).toBe(false); + } finally { + await app.close().catch(() => {}); } }); }); diff --git a/apps/server/sources/app/api/utils/enableMonitoring.spec.ts b/apps/server/sources/app/api/utils/enableMonitoring.spec.ts new file mode 100644 index 000000000..1f1f8b40e --- /dev/null +++ b/apps/server/sources/app/api/utils/enableMonitoring.spec.ts @@ -0,0 +1,31 @@ +import Fastify from 'fastify'; +import { describe, expect, it, vi } from 'vitest'; + +const mockQueryRaw = vi.fn(); + +vi.mock('@/storage/db', () => ({ + db: { $queryRaw: mockQueryRaw }, +})); + +describe('enableMonitoring (unit)', () => { + it('returns 503 with database connectivity error in body when database query fails', async () => { + mockQueryRaw.mockRejectedValueOnce(new Error('SQLITE_CANTOPEN: cannot open database')); + + const { enableMonitoring } = await import('./enableMonitoring'); + const app = Fastify({ logger: false }) as any; + + try { + enableMonitoring(app); + await app.ready(); + + const res = await app.inject({ method: 'GET', url: '/health' }); + expect(res.statusCode).toBe(503); + const body = res.json() as { status?: string; service?: string; error?: string }; + expect(body.status).toBe('error'); + expect(body.service).toBe('happier-server'); + expect(body.error).toBe('Database connectivity failed'); + } finally { + await app.close().catch(() => {}); + } + }); +}); diff --git a/apps/ui/sources/sync/api/capabilities/serverFeaturesClient.test.ts b/apps/ui/sources/sync/api/capabilities/serverFeaturesClient.test.ts index 929acc4de..ce6f15018 100644 --- a/apps/ui/sources/sync/api/capabilities/serverFeaturesClient.test.ts +++ b/apps/ui/sources/sync/api/capabilities/serverFeaturesClient.test.ts @@ -471,6 +471,21 @@ describe('serverFeaturesClient', () => { expect(String(calls[2]?.[0] ?? '')).toContain('https://other.example.test'); }); + it('returns error status when relay is completely offline (health probe fails with ECONNREFUSED)', async () => { + // Simulate a relay that is not running: every fetch — including the /health probe — throws. + // This is the "Choose Relay / can't reach relay" scenario that blocks the Tauri app on first launch. + globalThis.fetch = vi.fn().mockRejectedValue( + new TypeError('Network request failed'), + ) as unknown as typeof fetch; + + const { getServerFeaturesSnapshot, resetServerFeaturesClientForTests } = await import('./serverFeaturesClient'); + resetServerFeaturesClientForTests(); + + // timeoutMs=100 allows the reachability gate to time out quickly in the test. + const result = await getServerFeaturesSnapshot({ force: true, timeoutMs: 100 }); + expect(result.status).toBe('error'); + }); + it('fetches features against the explicit serverId url (not the active server)', async () => { const payload = { features: { From 50028c603b880529485e8dc679c4f2a27d59846e Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Mon, 30 Mar 2026 23:10:49 -0400 Subject: [PATCH 07/10] apps/server/.gitignore: ignore prisma/migrations/ directory Add prisma/migrations/ to the server gitignore so auto-generated migration directories (e.g. from pglite test harness) are not tracked without needing to enumerate specific timestamps. --- apps/server/.gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/.gitignore b/apps/server/.gitignore index e45f29371..cceb359a8 100644 --- a/apps/server/.gitignore +++ b/apps/server/.gitignore @@ -12,3 +12,5 @@ dist .claude/ generated/ + +prisma/migrations/ From 9524cf75ec18a5cca9c3c37932190f056376033e Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Fri, 3 Apr 2026 16:02:42 -0400 Subject: [PATCH 08/10] fix(review): address CodeRabbit actionable comments on PR #144 apps/server/.gitignore: remove prisma/migrations/ ignore rule. The blanket ignore blocked future real migration files from being committed while existing tracked migrations were unaffected. Server CI runs `prisma migrate deploy` and requires migration files in the repo; ignoring the whole directory would have broken future deploys. The pglite test artifact (20260319222403_init) will show as untracked noise but that is preferable to silently dropping real migrations. serverFeaturesClient.test.ts: reset reachability supervisors in afterEach before clearing the module cache. Supervisors spawned during a test run asynchronously and could bleed state into the next test, making the suite order-dependent. Calling resetServerReachabilitySupervisors() before vi.resetModules() ensures each test starts with a clean supervisor pool. synthesizeKokoroWav.spec.ts: replace silent-pass guards with it.skipIf(!kokoroWorkerExists). The previous `if (!exists) return` pattern let both vendored-artifact tests pass with zero assertions whenever the worker file was absent, hiding regressions in CI environments that skip the build step. Using it.skipIf() makes the skip explicit in test output. Also deduplicated the workerPath URL and existence check to a single module-level constant shared by both tests. --- apps/server/.gitignore | 1 - .../capabilities/serverFeaturesClient.test.ts | 8 +++++++- .../kokoro/runtime/synthesizeKokoroWav.spec.ts | 16 ++++++++-------- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/server/.gitignore b/apps/server/.gitignore index cceb359a8..b062a4d11 100644 --- a/apps/server/.gitignore +++ b/apps/server/.gitignore @@ -13,4 +13,3 @@ dist generated/ -prisma/migrations/ diff --git a/apps/ui/sources/sync/api/capabilities/serverFeaturesClient.test.ts b/apps/ui/sources/sync/api/capabilities/serverFeaturesClient.test.ts index ce6f15018..9913c4a43 100644 --- a/apps/ui/sources/sync/api/capabilities/serverFeaturesClient.test.ts +++ b/apps/ui/sources/sync/api/capabilities/serverFeaturesClient.test.ts @@ -65,7 +65,13 @@ describe('serverFeaturesClient', () => { }) as unknown as typeof fetch; }); - afterEach(() => { + afterEach(async () => { + // Stop supervisor state before clearing module cache so that async + // supervisors spawned during the test don't bleed into the next test. + const { resetServerReachabilitySupervisors } = await import( + '@/sync/runtime/connectivity/serverReachabilitySupervisorPool' + ); + await resetServerReachabilitySupervisors(); vi.useRealTimers(); globalThis.fetch = originalFetch; vi.restoreAllMocks(); diff --git a/apps/ui/sources/voice/kokoro/runtime/synthesizeKokoroWav.spec.ts b/apps/ui/sources/voice/kokoro/runtime/synthesizeKokoroWav.spec.ts index 93e733739..4152b4469 100644 --- a/apps/ui/sources/voice/kokoro/runtime/synthesizeKokoroWav.spec.ts +++ b/apps/ui/sources/voice/kokoro/runtime/synthesizeKokoroWav.spec.ts @@ -1,6 +1,10 @@ import fs from 'node:fs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +// Resolved once at module load; both vendored-artifact tests share this check. +const kokoroWorkerPath = new URL('../../../../public/vendor/kokoro/kokoroTtsWorker.js', import.meta.url); +const kokoroWorkerExists = fs.existsSync(kokoroWorkerPath); + describe('synthesizeKokoroWav (web)', () => { beforeEach(() => { vi.resetModules(); @@ -13,17 +17,13 @@ describe('synthesizeKokoroWav (web)', () => { expect(source).not.toContain('import.meta'); }); - it('closes the TextSplitterStream so streaming requests complete', () => { - const workerPath = new URL('../../../../public/vendor/kokoro/kokoroTtsWorker.js', import.meta.url); - if (!fs.existsSync(workerPath)) return; // vendored artifact not yet built - const workerSource = fs.readFileSync(workerPath, 'utf8'); + it.skipIf(!kokoroWorkerExists)('closes the TextSplitterStream so streaming requests complete', () => { + const workerSource = fs.readFileSync(kokoroWorkerPath, 'utf8'); expect(workerSource).toContain('splitter.close'); }); - it('supports kokoro-js stream chunk audio shapes', () => { - const workerPath = new URL('../../../../public/vendor/kokoro/kokoroTtsWorker.js', import.meta.url); - if (!fs.existsSync(workerPath)) return; // vendored artifact not yet built - const workerSource = fs.readFileSync(workerPath, 'utf8'); + it.skipIf(!kokoroWorkerExists)('supports kokoro-js stream chunk audio shapes', () => { + const workerSource = fs.readFileSync(kokoroWorkerPath, 'utf8'); expect(workerSource).toContain('audioObj?.audio'); expect(workerSource).toContain('audioObj?.sampling_rate'); }); From ddf62652332157a3829d0e5ac8e9e0c0e0968087 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Fri, 3 Apr 2026 17:06:36 -0400 Subject: [PATCH 09/10] prepareTauriSidecar.test.mjs,.gitignore: fix missing mocks and trailing whitespace Previous behavior: The Windows-safe shell test and spawn-error-propagation test called prepareTauriSidecar() without mocking ensureTauriMcpDevCapabilityImpl, causing the real function to write mcp-dev.json to apps/ui/src-tauri/capabilities/ as a side effect during test runs. apps/server/.gitignore had an extra trailing blank line left over from the prisma/migrations removal. What changed: - prepareTauriSidecar.test.mjs:117: add ensureTauriMcpDevCapabilityImpl no-op mock to the Windows-safe shell test - prepareTauriSidecar.test.mjs:201: add ensureTauriMcpDevCapabilityImpl no-op mock to the spawn-error-propagation test - apps/server/.gitignore: remove extra trailing blank line Why: Tests should not produce filesystem side effects. Both tests were introduced before the mcp-dev capability step was added to prepareTauriSidecar, and the new parameter was not backfilled into them. Testable: node --test apps/ui/scripts/prepareTauriSidecar.test.mjs (12/12 pass) --- apps/server/.gitignore | 1 - apps/ui/scripts/prepareTauriSidecar.test.mjs | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/.gitignore b/apps/server/.gitignore index b062a4d11..e45f29371 100644 --- a/apps/server/.gitignore +++ b/apps/server/.gitignore @@ -12,4 +12,3 @@ dist .claude/ generated/ - diff --git a/apps/ui/scripts/prepareTauriSidecar.test.mjs b/apps/ui/scripts/prepareTauriSidecar.test.mjs index 0a24f20ea..3c2033ea0 100644 --- a/apps/ui/scripts/prepareTauriSidecar.test.mjs +++ b/apps/ui/scripts/prepareTauriSidecar.test.mjs @@ -114,6 +114,7 @@ test('prepareTauriSidecar invokes Yarn via a Windows-safe shell so yarn.cmd can const ensureWorkspacePackagesBuiltForComponent = async () => {}; const ensureTauriSidecarRuntimeFilesImpl = async () => []; const ensureTauriSidecarEntrypointFileImpl = async (options) => join(options.srcTauriDir, 'binaries', 'hsetup.js'); + const ensureTauriMcpDevCapabilityImpl = async () => {}; const spawnSyncImpl = (command, args, options) => { calls.push(['spawn', command, args, options]); return { status: 0 }; @@ -127,6 +128,7 @@ test('prepareTauriSidecar invokes Yarn via a Windows-safe shell so yarn.cmd can ensureWorkspacePackagesBuiltForComponent, ensureTauriSidecarRuntimeFilesImpl, ensureTauriSidecarEntrypointFileImpl, + ensureTauriMcpDevCapabilityImpl, spawnSyncImpl, }); @@ -196,6 +198,7 @@ test('prepareTauriSidecar propagates spawn errors', async () => { await assert.rejects(() => prepareTauriSidecar({ env: {}, ensureWorkspacePackagesBuiltForComponent: async () => {}, + ensureTauriMcpDevCapabilityImpl: async () => {}, spawnSyncImpl: () => ({ error: boom }), }), /spawn failed/); }); From 26dd43ff3690b6b45e22b99735a39c36ddd8c3a1 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Sat, 11 Apr 2026 12:59:38 -0400 Subject: [PATCH 10/10] test(useConnectAccount): add mediaDevices stub for phone-sized web QR scanner test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test 'navigates to the in-app QR scanner on phone-sized web' was failing because isWebQrScannerSupported() checks navigator.mediaDevices ?.getUserMedia (actual camera API capability) but the test only stubbed navigator with maxTouchPoints/userAgent — no mediaDevices. Add { mediaDevices: { getUserMedia: vi.fn() } } to the navigator stub so the camera-availability check returns true on the simulated mobile web case, matching the intended test scenario. The desktop-web test already works without mediaDevices since isWebQrScannerSupported() correctly returns false when the API is absent. --- .../hooks/auth/useConnectAccount.scannerLifecycle.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ui/sources/hooks/auth/useConnectAccount.scannerLifecycle.test.tsx b/apps/ui/sources/hooks/auth/useConnectAccount.scannerLifecycle.test.tsx index d4e8fb39c..3aebb0da5 100644 --- a/apps/ui/sources/hooks/auth/useConnectAccount.scannerLifecycle.test.tsx +++ b/apps/ui/sources/hooks/auth/useConnectAccount.scannerLifecycle.test.tsx @@ -117,7 +117,7 @@ describe('useConnectAccount (scanner lifecycle)', () => { it('navigates to the in-app QR scanner on phone-sized web', async () => { screenState.platformOS = 'web'; screenState.windowDimensions = { width: 360, height: 800 }; - vi.stubGlobal('navigator', { maxTouchPoints: 5, userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0)' } as any); + vi.stubGlobal('navigator', { maxTouchPoints: 5, userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0)', mediaDevices: { getUserMedia: vi.fn() } } as any); const { useConnectAccount } = await import('./useConnectAccount');