需求:接管客户端未提交 WIP,保留真机预览,移除旧登录原型并维持 2.0.0。 实现:由 Main 核对项目与精确 Release,签发短时 Owner preview;补充一键提交映射能力及 Windows ZIP 预检兼容。
234 lines
8.2 KiB
TypeScript
234 lines
8.2 KiB
TypeScript
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 <output data-testid="navigation-probe">{location.pathname}</output>;
|
|
}
|
|
|
|
function renderPage() {
|
|
return render(
|
|
<MemoryRouter initialEntries={['/device-preview']}>
|
|
<Routes>
|
|
<Route path="/device-preview" element={<DevicePreview />} />
|
|
<Route path="*" element={<NavigationProbe />} />
|
|
</Routes>
|
|
</MemoryRouter>,
|
|
);
|
|
}
|
|
|
|
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();
|
|
});
|
|
});
|