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 ;
}
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();
});
});