diff --git a/apps/cli/src/backends/claude/claudeLocalLauncher.ts b/apps/cli/src/backends/claude/claudeLocalLauncher.ts index 093bb2a56..0a331bb7b 100644 --- a/apps/cli/src/backends/claude/claudeLocalLauncher.ts +++ b/apps/cli/src/backends/claude/claudeLocalLauncher.ts @@ -272,8 +272,16 @@ export async function claudeLocalLauncher( const resolvedAgentMode = resolveSessionModeOverrideFromMetadataSnapshot({ 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, }); diff --git a/apps/cli/src/backends/claude/loop.ts b/apps/cli/src/backends/claude/loop.ts index 94a9d3468..3669e84c4 100644 --- a/apps/cli/src/backends/claude/loop.ts +++ b/apps/cli/src/backends/claude/loop.ts @@ -101,6 +101,7 @@ export async function loop(opts: LoopOptions): Promise { precomputedMcpBridge: opts.precomputedMcpBridge ?? null, }); 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 diff --git a/apps/cli/src/backends/claude/session.test.ts b/apps/cli/src/backends/claude/session.test.ts index b8bccf3bf..6925a9416 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 584e3520b..dd7105a84 100644 --- a/apps/cli/src/backends/claude/session.ts +++ b/apps/cli/src/backends/claude/session.ts @@ -102,6 +102,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. 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/scripts/prepareTauriSidecar.mjs b/apps/ui/scripts/prepareTauriSidecar.mjs index e604c58a4..a75d07d92 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) { @@ -118,11 +138,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 6ef044494..3c2033ea0 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], '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], '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('prepareTauriSidecar invokes Yarn via a Windows-safe shell so yarn.cmd can be resolved', async () => { @@ -86,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 }; @@ -99,6 +128,7 @@ test('prepareTauriSidecar invokes Yarn via a Windows-safe shell so yarn.cmd can ensureWorkspacePackagesBuiltForComponent, ensureTauriSidecarRuntimeFilesImpl, ensureTauriSidecarEntrypointFileImpl, + ensureTauriMcpDevCapabilityImpl, spawnSyncImpl, }); @@ -168,6 +198,7 @@ test('prepareTauriSidecar propagates spawn errors', async () => { await assert.rejects(() => prepareTauriSidecar({ env: {}, ensureWorkspacePackagesBuiltForComponent: async () => {}, + ensureTauriMcpDevCapabilityImpl: async () => {}, spawnSyncImpl: () => ({ error: boom }), }), /spawn failed/); }); 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 faf96958d..6f97c45c0 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 @@ -55,7 +55,7 @@ afterEach(() => { resetRestoreRouteTestState(); }); 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', @@ -72,8 +72,8 @@ describe('/restore (web desktop)', () => { try { screen = await renderScreen(); await act(async () => {}); - const qrView = screen.findAllByType('div').filter((node) => node.props['data-testid'] === 'RestoreQrView'); - expect(qrView).toHaveLength(1); + const scannerView = screen.findAllByType('div').filter((node) => node.props['data-testid'] === 'RestoreScanComputerQrView'); + expect(scannerView).toHaveLength(1); } finally { await act(async () => { screen?.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 db6220a30..9c04ac6d1 100644 --- a/apps/ui/sources/__tests__/routes/_layout.init.spec.tsx +++ b/apps/ui/sources/__tests__/routes/_layout.init.spec.tsx @@ -74,6 +74,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/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 8574dcb91..f7da34525 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, @@ -570,6 +570,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/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/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'); 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/api/capabilities/serverFeaturesClient.test.ts b/apps/ui/sources/sync/api/capabilities/serverFeaturesClient.test.ts index 929acc4de..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(); @@ -471,6 +477,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: { 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 {}; diff --git a/apps/ui/sources/voice/kokoro/runtime/synthesizeKokoroWav.spec.ts b/apps/ui/sources/voice/kokoro/runtime/synthesizeKokoroWav.spec.ts index 65c06fded..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,13 +17,13 @@ describe('synthesizeKokoroWav (web)', () => { expect(source).not.toContain('import.meta'); }); - it('closes the TextSplitterStream so streaming requests complete', () => { - const workerSource = fs.readFileSync(new URL('../../../../public/vendor/kokoro/kokoroTtsWorker.js', import.meta.url), '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 workerSource = fs.readFileSync(new URL('../../../../public/vendor/kokoro/kokoroTtsWorker.js', import.meta.url), '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'); });