import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import { DevicePreview } from '@/pages/DevicePreview'; import { useOpencodeStore } from '@/stores/opencode'; const fetchProjectDevicePreviewMock = vi.hoisted(() => vi.fn()); const invokeIpcMock = vi.hoisted(() => vi.fn()); vi.mock('@/lib/device-preview', () => ({ fetchProjectDevicePreview: (...args: unknown[]) => fetchProjectDevicePreviewMock(...args), })); vi.mock('@/lib/api-client', () => ({ invokeIpc: (...args: unknown[]) => invokeIpcMock(...args), })); vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() }, })); const project = { id: 'project-1', name: '星球小游戏', path: 'D:/projects/planet-game', createdAt: '2026-08-03T00:00:00.000Z', updatedAt: '2026-08-03T00:00:00.000Z', lastOpenedAt: '2026-08-03T00:00:00.000Z', }; function NavigationProbe() { const location = useLocation(); return {location.pathname}; } function renderPage() { return render( } /> } /> , ); } describe('DevicePreview page', () => { beforeEach(() => { vi.clearAllMocks(); useOpencodeStore.setState({ activeProject: project }); invokeIpcMock.mockResolvedValue(undefined); Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: vi.fn().mockResolvedValue(undefined) }, }); }); afterEach(() => { vi.useRealTimers(); }); it('shows a scannable QR code and uses the same trusted URL for copy and external open', async () => { fetchProjectDevicePreviewMock.mockResolvedValue({ state: 'ready', projectId: project.id, appId: 'planet-game', versionName: 'v0.7.0', reviewStatus: 'approved', launchUrl: 'https://square.nianxx.cn/apps/planet-game/', updatedAt: '2026-08-03T01:05:00.000Z', message: '真机预览已就绪。', }); renderPage(); expect(await screen.findByRole('img', { name: '星球小游戏 真机预览二维码' })).toBeInTheDocument(); expect(screen.getByTestId('device-preview-status')).toHaveTextContent('可预览'); expect(screen.getByText('https://square.nianxx.cn/apps/planet-game/')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '复制预览链接' })); await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith( 'https://square.nianxx.cn/apps/planet-game/', )); fireEvent.click(screen.getByRole('button', { name: '在系统浏览器打开' })); await waitFor(() => expect(invokeIpcMock).toHaveBeenCalledWith( 'shell:openExternal', 'https://square.nianxx.cn/apps/planet-game/', )); }); it('keeps the page read-only when no deployed runtime exists', async () => { fetchProjectDevicePreviewMock.mockResolvedValue({ state: 'not_deployed', projectId: project.id, message: '当前项目还没有与本机绑定的服务端预览版本。', }); renderPage(); expect(await screen.findByText('当前项目还没有与本机绑定的服务端预览版本。')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /生成|部署|上传/ })).not.toBeInTheDocument(); fireEvent.click(screen.getAllByRole('button', { name: '返回 AI 对话' })[1]!); const probe = await screen.findByTestId('navigation-probe'); expect(probe).toHaveTextContent('/opencode-chat'); }); it('keeps a building version unscannable and offers a return to the AI conversation', async () => { fetchProjectDevicePreviewMock.mockResolvedValue({ state: 'building', projectId: project.id, appId: 'planet-game', versionName: 'v0.7.0', message: '服务端正在构建或审核预览版本。', }); renderPage(); expect(await screen.findByTestId('device-preview-status')).toHaveTextContent('生成中'); expect(screen.queryByTestId('device-preview-qr')).not.toBeInTheDocument(); expect(screen.getAllByRole('button', { name: '返回 AI 对话' })).toHaveLength(2); expect(screen.queryByRole('button', { name: '让 AI 生成预览' })).not.toBeInTheDocument(); }); it('explains that device preview needs an active project', () => { useOpencodeStore.setState({ activeProject: null }); renderPage(); expect(screen.getByRole('heading', { name: '先选择一个项目' })).toBeInTheDocument(); expect(fetchProjectDevicePreviewMock).not.toHaveBeenCalled(); }); it('waits for a slow poll to finish before scheduling another request', async () => { let resolveSlowPoll: ((value: unknown) => void) | undefined; fetchProjectDevicePreviewMock .mockResolvedValueOnce({ state: 'building', projectId: project.id, message: '服务端正在构建本次预览版本。', }) .mockImplementationOnce(() => new Promise((resolve) => { resolveSlowPoll = resolve; })) .mockResolvedValue({ state: 'building', projectId: project.id, message: '服务端正在构建本次预览版本。', }); vi.useFakeTimers(); renderPage(); await act(async () => undefined); expect(screen.getByTestId('device-preview-status')).toHaveTextContent('生成中'); await act(async () => { await vi.advanceTimersByTimeAsync(4_000); }); expect(fetchProjectDevicePreviewMock).toHaveBeenCalledTimes(2); await act(async () => { await vi.advanceTimersByTimeAsync(12_000); }); expect(fetchProjectDevicePreviewMock).toHaveBeenCalledTimes(2); await act(async () => { resolveSlowPoll?.({ state: 'building', projectId: project.id, message: '服务端正在构建本次预览版本。', }); await Promise.resolve(); }); await act(async () => { await vi.advanceTimersByTimeAsync(4_000); }); expect(fetchProjectDevicePreviewMock).toHaveBeenCalledTimes(3); }); it('removes a previously verified QR code when revalidation fails', async () => { fetchProjectDevicePreviewMock .mockResolvedValueOnce({ state: 'ready', projectId: project.id, launchUrl: 'https://square.nianxx.cn/apps/planet-game/', message: '真机预览已就绪。', }) .mockRejectedValueOnce(new Error('登录状态已失效')); renderPage(); expect(await screen.findByTestId('device-preview-qr')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '刷新状态' })); expect(await screen.findByRole('heading', { name: '无法核验预览状态' })).toBeInTheDocument(); expect(screen.queryByTestId('device-preview-qr')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '复制预览链接' })).not.toBeInTheDocument(); }); it('ignores a stale response after the active project changes', async () => { let resolveFirst: ((value: unknown) => void) | undefined; fetchProjectDevicePreviewMock .mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; })) .mockResolvedValueOnce({ state: 'not_deployed', projectId: 'project-2', message: '新项目没有预览版本。', }); renderPage(); act(() => { useOpencodeStore.setState({ activeProject: { ...project, id: 'project-2', name: '新项目', path: 'D:/projects/new' }, }); }); expect(await screen.findByText('新项目没有预览版本。')).toBeInTheDocument(); await act(async () => { resolveFirst?.({ state: 'ready', projectId: project.id, launchUrl: 'https://square.nianxx.cn/apps/old-project/', message: '旧项目预览。', }); await Promise.resolve(); }); expect(screen.queryByTestId('device-preview-qr')).not.toBeInTheDocument(); expect(screen.getByText('新项目没有预览版本。')).toBeInTheDocument(); }); });