实现项目真机预览与待审版本扫码验收
需求:接管客户端未提交 WIP,保留真机预览,移除旧登录原型并维持 2.0.0。 实现:由 Main 核对项目与精确 Release,签发短时 Owner preview;补充一键提交映射能力及 Windows ZIP 预检兼容。
This commit is contained in:
233
tests/unit/device-preview-page.test.tsx
Normal file
233
tests/unit/device-preview-page.test.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user