import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const clientMocks = vi.hoisted(() => ({ defaultBaseUrl: 'http://127.0.0.1:3456', explicitDefaultBaseUrl: false, setBaseUrl: vi.fn(), setAuthToken: vi.fn(), postVerify: vi.fn(), })) vi.mock('../api/client', () => ({ api: { post: clientMocks.postVerify, }, getDefaultBaseUrl: () => clientMocks.defaultBaseUrl, hasExplicitDefaultBaseUrl: () => clientMocks.explicitDefaultBaseUrl, setAuthToken: clientMocks.setAuthToken, setBaseUrl: clientMocks.setBaseUrl, })) import { H5ConnectionRequiredError, H5_SERVER_URL_STORAGE_KEY, H5_TOKEN_STORAGE_KEY, initializeDesktopServerUrl, isBrowserH5Runtime, isDesktopRuntime, isLoopbackHostname, requiresH5AuthForServerUrl, saveAndVerifyH5Connection, } from './desktopRuntime' import { browserHost } from './desktopHost/browserHost' function healthOkResponse() { return Response.json({ status: 'ok' }) } describe('desktopRuntime browser H5 bootstrap', () => { const originalFetch = globalThis.fetch beforeEach(() => { vi.clearAllMocks() clientMocks.defaultBaseUrl = 'http://127.0.0.1:3456' clientMocks.explicitDefaultBaseUrl = false vi.useRealTimers() window.localStorage.clear() window.history.pushState({}, '', '/') Reflect.deleteProperty(window, 'desktopHost') Reflect.deleteProperty(window, '__TAURI_INTERNALS__') Reflect.deleteProperty(window, '__TAURI__') globalThis.fetch = originalFetch }) afterEach(() => { vi.useRealTimers() globalThis.fetch = originalFetch }) it('treats IPv6 loopback as local', () => { expect(isLoopbackHostname('[::1]')).toBe(true) expect(isLoopbackHostname('::1')).toBe(true) expect(isLoopbackHostname('127.0.1.1')).toBe(true) expect(isLoopbackHostname('127.example.com')).toBe(false) expect(isLoopbackHostname('127.bad.0.1')).toBe(false) expect(requiresH5AuthForServerUrl('http://[::1]:3456')).toBe(false) expect(requiresH5AuthForServerUrl('http://127.0.0.1:3456')).toBe(false) expect(requiresH5AuthForServerUrl('http://127.0.1.1:3456')).toBe(false) expect(requiresH5AuthForServerUrl('http://127.example.com:3456')).toBe(true) expect(requiresH5AuthForServerUrl('http://localhost:3456')).toBe(false) expect(requiresH5AuthForServerUrl('https://public.example.com/app')).toBe(true) expect(requiresH5AuthForServerUrl('https://public.example.com/app', 'phone.example.test')).toBe(true) }) it('requires H5 auth for LAN and public browser URLs', () => { expect(requiresH5AuthForServerUrl('http://192.168.0.102:28670', '127.0.0.1')).toBe(true) expect(requiresH5AuthForServerUrl('http://10.0.0.5:28670', 'localhost')).toBe(true) expect(requiresH5AuthForServerUrl('http://172.20.1.8:28670', 'localhost')).toBe(true) expect(requiresH5AuthForServerUrl('https://public.example.com/app', 'localhost')).toBe(true) expect(requiresH5AuthForServerUrl('http://192.168.0.102:28670', 'phone.example.test')).toBe(true) }) it('clears an invalid token but preserves the remembered remote server URL', async () => { globalThis.fetch = vi.fn().mockResolvedValue( healthOkResponse(), ) as typeof fetch clientMocks.postVerify.mockRejectedValueOnce( Object.assign(new Error('Invalid or missing H5 access token'), { status: 401 }), ) await expect(saveAndVerifyH5Connection('https://public.example.com/app', 'stale-token')).rejects.toMatchObject({ name: 'H5ConnectionRequiredError', serverUrl: 'https://public.example.com/app', message: 'The saved H5 token is no longer valid.', } satisfies Partial) expect(window.localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)).toBe( 'https://public.example.com/app', ) expect(window.localStorage.getItem(H5_TOKEN_STORAGE_KEY)).toBeNull() }) it('does not reuse stored remote H5 tokens for loopback browser startup', async () => { window.history.pushState({}, '', '/?serverUrl=http%3A%2F%2F%5B%3A%3A1%5D%3A3456') window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'remote-token') globalThis.fetch = vi.fn().mockResolvedValue( healthOkResponse(), ) as typeof fetch await expect(initializeDesktopServerUrl()).resolves.toBe('http://[::1]:3456') expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith('http://[::1]:3456') expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null) expect(clientMocks.postVerify).not.toHaveBeenCalled() }) it('uses the current browser origin when the H5 shell is served by the desktop server', async () => { globalThis.fetch = vi.fn().mockResolvedValue( healthOkResponse(), ) as typeof fetch await expect(initializeDesktopServerUrl()).resolves.toBe(window.location.origin) expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith(window.location.origin) expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null) expect(globalThis.fetch).toHaveBeenCalledWith(`${window.location.origin}/health`, { cache: 'no-store', }) expect(globalThis.fetch).toHaveBeenCalledWith(`${window.location.origin}/api/status`, { cache: 'no-store', }) }) it('uses an injected desktop host server URL before browser fallback', async () => { const serverUrl = 'http://127.0.0.1:59231' window.desktopHost = { ...browserHost, kind: 'electron', isDesktop: true, runtime: { getServerUrl: vi.fn().mockResolvedValue(serverUrl), }, } globalThis.fetch = vi.fn().mockResolvedValue( healthOkResponse(), ) as typeof fetch await expect(initializeDesktopServerUrl()).resolves.toBe(serverUrl) expect(window.desktopHost.runtime.getServerUrl).toHaveBeenCalledTimes(1) expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith(serverUrl) expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null) expect(globalThis.fetch).toHaveBeenCalledWith(`${serverUrl}/health`, { cache: 'no-store', }) expect(globalThis.fetch).toHaveBeenCalledTimes(1) }) it('classifies browser H5 runtime using the desktop host boundary', () => { expect(isBrowserH5Runtime()).toBe(true) expect(isDesktopRuntime()).toBe(false) window.desktopHost = { ...browserHost, kind: 'electron', isDesktop: true, } expect(isBrowserH5Runtime()).toBe(false) expect(isDesktopRuntime()).toBe(true) }) it('normalizes injected desktop host startup failures', async () => { const error = new Error('electron sidecar failed') const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) window.desktopHost = { ...browserHost, kind: 'electron', isDesktop: true, runtime: { getServerUrl: vi.fn().mockRejectedValue(error), }, } await expect(initializeDesktopServerUrl()).rejects.toThrow('electron sidecar failed') expect(consoleError).toHaveBeenCalledWith( '[desktop] Failed to initialize desktop server URL', error, ) consoleError.mockRestore() }) it('falls back to the default backend when a loopback dev origin serves a Vite SPA fallback', async () => { vi.useFakeTimers() globalThis.fetch = vi.fn((input) => { if (String(input) === `${window.location.origin}/health`) { return Promise.resolve(new Response('', { status: 200, headers: { 'content-type': 'text/html' }, })) } return Promise.resolve(healthOkResponse()) }) as typeof fetch const startup = expect(initializeDesktopServerUrl()).resolves.toBe('http://127.0.0.1:3456') await vi.runAllTimersAsync() await startup expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith('http://127.0.0.1:3456') expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null) expect(globalThis.fetch).toHaveBeenCalledWith(`${window.location.origin}/health`, { cache: 'no-store', }) expect(globalThis.fetch).toHaveBeenCalledWith('http://127.0.0.1:3456/health', { cache: 'no-store', }) }) it('does not fall back when an explicit Vite desktop server URL returns a SPA fallback', async () => { vi.useFakeTimers() clientMocks.defaultBaseUrl = 'http://127.0.0.1:55189' clientMocks.explicitDefaultBaseUrl = true globalThis.fetch = vi.fn().mockResolvedValue( new Response('', { status: 200, headers: { 'content-type': 'text/html' }, }), ) as typeof fetch const startup = expect(initializeDesktopServerUrl()).rejects.toThrow( 'Server healthcheck failed: healthcheck returned non-JSON response from http://127.0.0.1:55189/health', ) await vi.runAllTimersAsync() await startup expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith('http://127.0.0.1:55189') }) it('does not fall back from a loopback dev origin to a non-loopback default backend', async () => { vi.useFakeTimers() clientMocks.defaultBaseUrl = 'https://public.example.com' globalThis.fetch = vi.fn().mockResolvedValue( new Response('', { status: 200, headers: { 'content-type': 'text/html' }, }), ) as typeof fetch const startup = expect(initializeDesktopServerUrl()).rejects.toThrow( `Server healthcheck failed: healthcheck returned non-JSON response from ${window.location.origin}/health`, ) await vi.runAllTimersAsync() await startup expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith(window.location.origin) }) it('does not fall back from a loopback dev origin to an invalid default backend', async () => { vi.useFakeTimers() clientMocks.defaultBaseUrl = 'not-a-url' globalThis.fetch = vi.fn().mockResolvedValue( new Response('', { status: 200, headers: { 'content-type': 'text/html' }, }), ) as typeof fetch const startup = expect(initializeDesktopServerUrl()).rejects.toThrow( `Server healthcheck failed: healthcheck returned non-JSON response from ${window.location.origin}/health`, ) await vi.runAllTimersAsync() await startup expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith(window.location.origin) }) it('prefers an explicit Vite desktop server URL over the dev server origin', async () => { clientMocks.defaultBaseUrl = 'http://127.0.0.1:55189' clientMocks.explicitDefaultBaseUrl = true window.history.pushState({}, '', '/') globalThis.fetch = vi.fn().mockResolvedValue( healthOkResponse(), ) as typeof fetch await expect(initializeDesktopServerUrl()).resolves.toBe('http://127.0.0.1:55189') expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith('http://127.0.0.1:55189') expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null) expect(globalThis.fetch).toHaveBeenCalledWith('http://127.0.0.1:55189/health', { cache: 'no-store', }) expect(globalThis.fetch).toHaveBeenCalledWith('http://127.0.0.1:55189/api/status', { cache: 'no-store', }) }) it('prefers an explicit Vite desktop server URL over a remembered H5 server URL', async () => { clientMocks.defaultBaseUrl = 'http://127.0.0.1:55189' clientMocks.explicitDefaultBaseUrl = true window.history.pushState({}, '', '/') window.localStorage.setItem(H5_SERVER_URL_STORAGE_KEY, 'http://192.168.0.102:3456') window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'stale-h5-token') globalThis.fetch = vi.fn().mockResolvedValue( healthOkResponse(), ) as typeof fetch await expect(initializeDesktopServerUrl()).resolves.toBe('http://127.0.0.1:55189') expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith('http://127.0.0.1:55189') expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null) expect(clientMocks.postVerify).not.toHaveBeenCalled() expect(globalThis.fetch).toHaveBeenCalledWith('http://127.0.0.1:55189/api/status', { cache: 'no-store', }) }) it('normalizes unreachable remote browser startup into a recoverable H5 error', async () => { vi.useFakeTimers() globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')) as typeof fetch const startup = expect(saveAndVerifyH5Connection('https://unreachable.example.com', 'h5_token')).rejects.toMatchObject({ name: 'H5ConnectionRequiredError', serverUrl: 'https://unreachable.example.com', message: 'Unable to reach https://unreachable.example.com. Check the server URL or network access.', } satisfies Partial) await vi.runAllTimersAsync() await startup expect(window.localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)).toBe( 'https://unreachable.example.com', ) }) it('normalizes remote verify failures like disabled H5 or CORS into recoverable H5 errors', async () => { globalThis.fetch = vi.fn().mockResolvedValue( healthOkResponse(), ) as typeof fetch clientMocks.postVerify.mockRejectedValueOnce(new TypeError('Failed to fetch')) await expect(saveAndVerifyH5Connection('https://public.example.com', 'h5_token')).rejects.toMatchObject({ name: 'H5ConnectionRequiredError', serverUrl: 'https://public.example.com', message: 'Unable to verify the H5 access token.', } satisfies Partial) expect(window.localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)).toBe( 'https://public.example.com', ) expect(window.localStorage.getItem(H5_TOKEN_STORAGE_KEY)).toBeNull() }) it('requires a token when browser WebUI connects to a LAN-bound server', async () => { window.history.pushState({}, '', '/?serverUrl=http%3A%2F%2F192.168.0.102%3A28670') globalThis.fetch = vi.fn().mockResolvedValue( healthOkResponse(), ) as typeof fetch await expect(initializeDesktopServerUrl()).rejects.toMatchObject({ name: 'H5ConnectionRequiredError', serverUrl: 'http://192.168.0.102:28670', reason: 'missing-token', } satisfies Partial) expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith('http://192.168.0.102:28670') expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null) expect(clientMocks.postVerify).not.toHaveBeenCalled() expect(window.localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)).toBe('http://192.168.0.102:28670') }) it('uses and persists an H5 token from the QR launch URL', async () => { window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Fpublic.example.com%2Fapp&h5Token=qr-token') globalThis.fetch = vi.fn().mockResolvedValue( healthOkResponse(), ) as typeof fetch clientMocks.postVerify.mockResolvedValueOnce({ ok: true }) await expect(initializeDesktopServerUrl()).resolves.toBe('https://public.example.com/app') expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith('https://public.example.com/app') expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith('qr-token') expect(clientMocks.postVerify).toHaveBeenCalledWith('/api/h5-access/verify') expect(window.localStorage.getItem(H5_TOKEN_STORAGE_KEY)).toBe('qr-token') }) it('shows the H5 token recovery view when a local browser connects to an auth-required LAN server', async () => { window.history.pushState({}, '', '/?serverUrl=http%3A%2F%2F192.168.0.102%3A28670') globalThis.fetch = vi.fn() .mockResolvedValueOnce(healthOkResponse()) .mockResolvedValueOnce(new Response(null, { status: 401 })) as typeof fetch await expect(initializeDesktopServerUrl()).rejects.toMatchObject({ name: 'H5ConnectionRequiredError', serverUrl: 'http://192.168.0.102:28670', reason: 'missing-token', } satisfies Partial) }) })