合并客户端真机预览链路
需求:将待审 Release 扫码验收与项目级真机入口并入登录、发布集成候选。 实现:合并 Main-owned 精确预览与提交映射能力,保留现有登录和一键发布边界。
This commit is contained in:
@@ -62,6 +62,7 @@ function renderSidebar(overrides: Partial<ComponentProps<typeof AgentConversatio
|
||||
onRestoreSession: vi.fn().mockResolvedValue(undefined),
|
||||
onDeleteSession: vi.fn().mockResolvedValue(undefined),
|
||||
onTogglePinAgent: vi.fn().mockResolvedValue(undefined),
|
||||
onOpenDevicePreview: vi.fn(),
|
||||
onOpenProjectSettings: vi.fn(),
|
||||
};
|
||||
render(
|
||||
@@ -101,13 +102,20 @@ describe('AgentConversationSidebar', () => {
|
||||
expect(callbacks.onOpenProjectSettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('opens the project device preview from the conversation header', () => {
|
||||
const callbacks = renderSidebar();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '真机预览' }));
|
||||
expect(callbacks.onOpenDevicePreview).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows only the centered project-partner heading in the conversation header', () => {
|
||||
renderSidebar();
|
||||
|
||||
expect(screen.queryByText('项目联系人')).not.toBeInTheDocument();
|
||||
const heading = screen.getByRole('heading', { name: '我的项目空间' });
|
||||
expect(heading).toHaveClass('text-left');
|
||||
expect(heading.parentElement).toHaveClass('relative', 'px-3', 'pr-24');
|
||||
expect(heading.parentElement).toHaveClass('relative', 'px-3', 'pr-28');
|
||||
expect(heading.parentElement).not.toHaveClass('justify-center');
|
||||
});
|
||||
|
||||
|
||||
34
tests/unit/chat-device-preview-navigation.test.tsx
Normal file
34
tests/unit/chat-device-preview-navigation.test.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Chat } from '@/pages/Chat';
|
||||
|
||||
vi.mock('@/pages/Chat/OpencodeChatPanel', () => ({
|
||||
OpencodeChatPanel: ({ onOpenDevicePreview }: { onOpenDevicePreview?: () => void }) => (
|
||||
<button type="button" onClick={onOpenDevicePreview}>
|
||||
打开真机预览
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <output data-testid="location-path">{location.pathname}</output>;
|
||||
}
|
||||
|
||||
describe('Chat 真机预览导航', () => {
|
||||
it('点击真机预览入口后导航到 /device-preview', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/opencode-chat']}>
|
||||
<Routes>
|
||||
<Route path="/opencode-chat" element={<Chat />} />
|
||||
<Route path="*" element={<LocationProbe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开真机预览' }));
|
||||
|
||||
expect(screen.getByTestId('location-path')).toHaveTextContent('/device-preview');
|
||||
});
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
589
tests/unit/device-preview-routes.test.ts
Normal file
589
tests/unit/device-preview-routes.test.ts
Normal file
@@ -0,0 +1,589 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { handleDevicePreviewRoutes } from '@electron/api/routes/device-preview';
|
||||
import {
|
||||
getRendererCapability,
|
||||
RENDERER_CAPABILITY_HEADER,
|
||||
rotateRendererCapability,
|
||||
} from '@electron/api/renderer-capability';
|
||||
|
||||
const getValidWorksSquareAccessTokenMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@electron/services/works-square-session', () => ({
|
||||
getValidWorksSquareAccessToken: (...args: unknown[]) => getValidWorksSquareAccessTokenMock(...args),
|
||||
}));
|
||||
|
||||
function createResponse() {
|
||||
const chunks: string[] = [];
|
||||
const res = {
|
||||
statusCode: 0,
|
||||
setHeader: vi.fn(),
|
||||
end: vi.fn((chunk?: string) => {
|
||||
if (chunk) chunks.push(chunk);
|
||||
}),
|
||||
} as unknown as ServerResponse;
|
||||
return {
|
||||
res,
|
||||
get statusCode() {
|
||||
return res.statusCode;
|
||||
},
|
||||
json: () => JSON.parse(chunks.join('')) as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
function createRequest(withRendererCapability = true): IncomingMessage {
|
||||
const req = new EventEmitter();
|
||||
Object.assign(req, {
|
||||
method: 'GET',
|
||||
headers: withRendererCapability
|
||||
? { [RENDERER_CAPABILITY_HEADER]: getRendererCapability() }
|
||||
: {},
|
||||
});
|
||||
return req as IncomingMessage;
|
||||
}
|
||||
|
||||
function createContext(deployment: Record<string, unknown> | null, activeProjectId = 'project-1') {
|
||||
return {
|
||||
opencodeProjectStore: {
|
||||
getActiveProject: vi.fn(async () => ({
|
||||
id: activeProjectId,
|
||||
name: '星球小游戏',
|
||||
path: 'D:/projects/planet-game',
|
||||
})),
|
||||
},
|
||||
worksCloudDeployment: {
|
||||
get: vi.fn(async () => deployment),
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
const submittedDeployment = {
|
||||
schema_version: 1,
|
||||
project_id: 'project-1',
|
||||
status: 'submitted',
|
||||
requested_at: '2026-08-03T01:00:00.000Z',
|
||||
updated_at: '2026-08-03T01:05:00.000Z',
|
||||
app_id: 'planet-game',
|
||||
version_id: 'version-7',
|
||||
version_name: 'v0.7.0',
|
||||
review_status: 'building',
|
||||
};
|
||||
|
||||
function matchingRemotePayload(overrides: {
|
||||
project?: Record<string, unknown>;
|
||||
latestVersion?: Record<string, unknown>;
|
||||
} = {}) {
|
||||
return {
|
||||
project: {
|
||||
app_id: 'planet-game',
|
||||
playable: true,
|
||||
runtime_url: '/apps/planet-game/',
|
||||
version_name: 'v0.7.0',
|
||||
...overrides.project,
|
||||
},
|
||||
latest_version: {
|
||||
id: 'version-7',
|
||||
version_name: 'v0.7.0',
|
||||
release_id: 'release-7',
|
||||
review_status: 'approved',
|
||||
...overrides.latestVersion,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('device preview Host API route', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
rotateRendererCapability();
|
||||
getValidWorksSquareAccessTokenMock.mockReset();
|
||||
getValidWorksSquareAccessTokenMock.mockResolvedValue('managed-secret-token');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('requires the Main-injected renderer capability', async () => {
|
||||
const response = createResponse();
|
||||
|
||||
const handled = await handleDevicePreviewRoutes(
|
||||
createRequest(false),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/opencode/projects/project-1/device-preview'),
|
||||
createContext(null),
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.json()).toEqual({ success: false, error: 'Renderer capability required' });
|
||||
});
|
||||
|
||||
it('returns a project-scoped not-deployed snapshot without contacting Works Square', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
|
||||
await handleDevicePreviewRoutes(
|
||||
createRequest(),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/opencode/projects/project-1/device-preview'),
|
||||
createContext(null),
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
success: true,
|
||||
preview: {
|
||||
state: 'not_deployed',
|
||||
projectId: 'project-1',
|
||||
message: '当前项目还没有与本机绑定的服务端预览版本。',
|
||||
},
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns only a trusted absolute HTTPS runtime URL for the submitted version', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(
|
||||
JSON.stringify(matchingRemotePayload()),
|
||||
{ status: 200 },
|
||||
));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
|
||||
await handleDevicePreviewRoutes(
|
||||
createRequest(),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/opencode/projects/project-1/device-preview'),
|
||||
createContext(submittedDeployment),
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://square.nianxx.cn/api/projects/mine/planet-game/status',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer managed-secret-token' },
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
success: true,
|
||||
preview: {
|
||||
state: 'ready',
|
||||
projectId: 'project-1',
|
||||
appId: 'planet-game',
|
||||
versionId: 'version-7',
|
||||
versionName: 'v0.7.0',
|
||||
reviewStatus: 'approved',
|
||||
launchUrl: 'https://square.nianxx.cn/apps/planet-game/',
|
||||
updatedAt: '2026-08-03T01:05:00.000Z',
|
||||
message: '真机预览已就绪。',
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(response.json())).not.toContain('managed-secret-token');
|
||||
});
|
||||
|
||||
it('creates an exact short-lived owner preview for a pending-review release', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(
|
||||
JSON.stringify(matchingRemotePayload({
|
||||
project: { playable: false, runtime_url: null },
|
||||
latestVersion: { review_status: 'pending_review' },
|
||||
})),
|
||||
{ status: 200 },
|
||||
))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
url: '/previews/release-7/signed-ticket/',
|
||||
expires_in_seconds: 300,
|
||||
}), { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
|
||||
await handleDevicePreviewRoutes(
|
||||
createRequest(),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/opencode/projects/project-1/device-preview'),
|
||||
createContext(submittedDeployment),
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://square.nianxx.cn/api/projects/planet-game/releases/release-7/preview-url',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer managed-secret-token' },
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
preview: {
|
||||
state: 'ready',
|
||||
reviewStatus: 'pending_review',
|
||||
launchUrl: 'https://square.nianxx.cn/previews/release-7/signed-ticket/',
|
||||
message: '审核前真机预览已就绪。',
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(response.json())).not.toContain('managed-secret-token');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a foreign origin', 'https://evil.example/previews/release-7/ticket/'],
|
||||
['another release', '/previews/release-8/ticket/'],
|
||||
])('rejects a pending-review preview for %s', async (_case, previewUrl) => {
|
||||
vi.stubGlobal('fetch', vi.fn()
|
||||
.mockResolvedValueOnce(new Response(
|
||||
JSON.stringify(matchingRemotePayload({
|
||||
project: { playable: false, runtime_url: null },
|
||||
latestVersion: { review_status: 'pending_review' },
|
||||
})),
|
||||
{ status: 200 },
|
||||
))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ url: previewUrl }), { status: 200 })));
|
||||
const response = createResponse();
|
||||
|
||||
await handleDevicePreviewRoutes(
|
||||
createRequest(),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/opencode/projects/project-1/device-preview'),
|
||||
createContext(submittedDeployment),
|
||||
);
|
||||
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
preview: { state: 'unavailable' },
|
||||
});
|
||||
expect(response.json()).not.toHaveProperty('preview.launchUrl');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an untrusted origin', 'https://evil.example/steal'],
|
||||
['an overlong URL', `https://square.nianxx.cn/apps/${'x'.repeat(1_100)}`],
|
||||
])('refuses a runtime URL from %s', async (_case, runtimeUrl) => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(
|
||||
JSON.stringify(matchingRemotePayload({ project: { runtime_url: runtimeUrl } })),
|
||||
{ status: 200 },
|
||||
)));
|
||||
const response = createResponse();
|
||||
|
||||
await handleDevicePreviewRoutes(
|
||||
createRequest(),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/opencode/projects/project-1/device-preview'),
|
||||
createContext(submittedDeployment),
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
preview: {
|
||||
state: 'unavailable',
|
||||
message: '服务端返回的预览地址未通过安全校验。',
|
||||
},
|
||||
});
|
||||
expect(response.json()).not.toHaveProperty('preview.launchUrl');
|
||||
});
|
||||
|
||||
it('does not expose an older published runtime while the requested version is building', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(
|
||||
JSON.stringify(matchingRemotePayload({
|
||||
project: { version_name: 'v0.6.0' },
|
||||
latestVersion: { review_status: 'building' },
|
||||
})),
|
||||
{ status: 200 },
|
||||
)));
|
||||
const response = createResponse();
|
||||
|
||||
await handleDevicePreviewRoutes(
|
||||
createRequest(),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/opencode/projects/project-1/device-preview'),
|
||||
createContext(submittedDeployment),
|
||||
);
|
||||
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
preview: {
|
||||
state: 'building',
|
||||
message: '本次预览版本仍在构建或审核,暂不展示旧版本。',
|
||||
},
|
||||
});
|
||||
expect(response.json()).not.toHaveProperty('preview.launchUrl');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['latest version id is missing', { latestVersion: { id: undefined } }, 'unavailable'],
|
||||
['latest version name is missing', { latestVersion: { version_name: undefined } }, 'unavailable'],
|
||||
['runtime version name is missing', { project: { version_name: undefined } }, 'building'],
|
||||
['latest version id differs', { latestVersion: { id: 'version-6' } }, 'unavailable'],
|
||||
['latest version name differs', { latestVersion: { version_name: 'v0.6.0' } }, 'unavailable'],
|
||||
])('does not expose a runtime when %s', async (_case, overrides, expectedState) => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(
|
||||
JSON.stringify(matchingRemotePayload(overrides)),
|
||||
{ status: 200 },
|
||||
)));
|
||||
const response = createResponse();
|
||||
|
||||
await handleDevicePreviewRoutes(
|
||||
createRequest(),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/opencode/projects/project-1/device-preview'),
|
||||
createContext(submittedDeployment),
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
preview: { state: expectedState },
|
||||
});
|
||||
expect(response.json()).not.toHaveProperty('preview.launchUrl');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['building', 'building'],
|
||||
['rejected', 'unavailable'],
|
||||
])('does not expose a runtime with a %s review', async (reviewStatus, expectedState) => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(
|
||||
JSON.stringify(matchingRemotePayload({
|
||||
latestVersion: { review_status: reviewStatus },
|
||||
})),
|
||||
{ status: 200 },
|
||||
)));
|
||||
const response = createResponse();
|
||||
|
||||
await handleDevicePreviewRoutes(
|
||||
createRequest(),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/opencode/projects/project-1/device-preview'),
|
||||
createContext(submittedDeployment),
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
preview: { state: expectedState },
|
||||
});
|
||||
expect(response.json()).not.toHaveProperty('preview.launchUrl');
|
||||
});
|
||||
|
||||
it('does not attribute a replacement version rejection to the locally bound version', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(
|
||||
JSON.stringify(matchingRemotePayload({
|
||||
latestVersion: {
|
||||
id: 'version-8',
|
||||
version_name: 'v0.8.0',
|
||||
review_status: 'rejected',
|
||||
},
|
||||
})),
|
||||
{ status: 200 },
|
||||
)));
|
||||
const response = createResponse();
|
||||
|
||||
await handleDevicePreviewRoutes(
|
||||
createRequest(),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/opencode/projects/project-1/device-preview'),
|
||||
createContext(submittedDeployment),
|
||||
);
|
||||
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
preview: {
|
||||
state: 'unavailable',
|
||||
message: '服务端最新版本已与本地绑定版本不一致,请重新同步项目状态。',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a deployment record owned by another local project without contacting Works Square', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
|
||||
await handleDevicePreviewRoutes(
|
||||
createRequest(),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/opencode/projects/project-1/device-preview'),
|
||||
createContext({ ...submittedDeployment, project_id: 'project-2' }),
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
preview: { state: 'unavailable', projectId: 'project-1' },
|
||||
});
|
||||
expect(response.json()).not.toHaveProperty('preview.launchUrl');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a remote project with a different app id', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(
|
||||
JSON.stringify(matchingRemotePayload({ project: { app_id: 'other-game' } })),
|
||||
{ status: 200 },
|
||||
)));
|
||||
const response = createResponse();
|
||||
|
||||
await handleDevicePreviewRoutes(
|
||||
createRequest(),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/opencode/projects/project-1/device-preview'),
|
||||
createContext(submittedDeployment),
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
preview: { state: 'unavailable' },
|
||||
});
|
||||
expect(response.json()).not.toHaveProperty('preview.launchUrl');
|
||||
});
|
||||
|
||||
it('does not treat preview_url as an executable runtime URL', async () => {
|
||||
const previewUrl = 'https://square.nianxx.cn/apps/planet-game/preview';
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(
|
||||
JSON.stringify(matchingRemotePayload({
|
||||
project: { runtime_url: undefined, preview_url: previewUrl },
|
||||
})),
|
||||
{ status: 200 },
|
||||
)));
|
||||
const response = createResponse();
|
||||
|
||||
await handleDevicePreviewRoutes(
|
||||
createRequest(),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/opencode/projects/project-1/device-preview'),
|
||||
createContext(submittedDeployment),
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
preview: { state: 'unavailable' },
|
||||
});
|
||||
expect(response.json()).not.toHaveProperty('preview.launchUrl');
|
||||
expect(JSON.stringify(response.json())).not.toContain(previewUrl);
|
||||
});
|
||||
|
||||
it('requires a managed owner session before querying a submitted preview', async () => {
|
||||
getValidWorksSquareAccessTokenMock.mockResolvedValue(null);
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
|
||||
await handleDevicePreviewRoutes(
|
||||
createRequest(),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/opencode/projects/project-1/device-preview'),
|
||||
createContext(submittedDeployment),
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(response.json()).toEqual({
|
||||
success: false,
|
||||
error: '请先登录,再核对当前项目的真机预览版本',
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps polling a submitted version when the owner endpoint has not indexed it yet', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(null, { status: 404 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
|
||||
await handleDevicePreviewRoutes(
|
||||
createRequest(),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/opencode/projects/project-1/device-preview'),
|
||||
createContext(submittedDeployment),
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
preview: { state: 'building' },
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('aborts a hanging owner status request instead of retaining a stale preview forever', async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.fn((_input: unknown, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
}, { once: true });
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
|
||||
const handled = handleDevicePreviewRoutes(
|
||||
createRequest(),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/opencode/projects/project-1/device-preview'),
|
||||
createContext(submittedDeployment),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await handled;
|
||||
|
||||
expect(response.statusCode).toBe(502);
|
||||
expect(response.json()).toEqual({
|
||||
success: false,
|
||||
error: '服务端预览状态读取超时,请稍后刷新',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an unauthorized owner response', 401, JSON.stringify({ detail: 'Unauthorized' }), 401],
|
||||
['an owner service failure', 500, JSON.stringify({ detail: 'Failed' }), 502],
|
||||
['a malformed owner payload', 200, '<html>not json</html>', 502],
|
||||
])('never falls back to the public project endpoint after %s', async (
|
||||
_case,
|
||||
ownerStatus,
|
||||
ownerBody,
|
||||
expectedStatus,
|
||||
) => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(ownerBody, { status: ownerStatus }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
|
||||
await handleDevicePreviewRoutes(
|
||||
createRequest(),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/opencode/projects/project-1/device-preview'),
|
||||
createContext(submittedDeployment),
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(expectedStatus);
|
||||
expect(response.json()).toMatchObject({ success: false });
|
||||
expect(response.json()).not.toHaveProperty('preview.launchUrl');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://square.nianxx.cn/api/projects/mine/planet-game/status',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer managed-secret-token' },
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a preview request for a project that is not active', async () => {
|
||||
const response = createResponse();
|
||||
|
||||
await handleDevicePreviewRoutes(
|
||||
createRequest(),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/opencode/projects/project-1/device-preview'),
|
||||
createContext(null, 'project-2'),
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json()).toEqual({
|
||||
success: false,
|
||||
error: 'Select this project before opening device preview',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,17 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { promisify } from 'node:util';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { promisify } from 'node:util';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createWorksCloudDeployment } from '@electron/services/works-cloud-deployment';
|
||||
import type { OpencodeProject } from '@electron/opencode/project-store';
|
||||
import { WORKS_CLOUD_DEPLOYMENT_FILE_NAME } from '../../shared/works-cloud-deployment';
|
||||
import { REQUIRED_DYNAMIC_WORKS_DEPLOY_CHECK_IDS } from '../../shared/works-square-deploy-check';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const tempDirectories: string[] = [];
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const project: OpencodeProject = {
|
||||
id: 'prj_cloud_deployment_test',
|
||||
@@ -77,7 +77,10 @@ async function createReadyProject(): Promise<{ projectPath: string; zipPath: str
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, content, 'utf8');
|
||||
}
|
||||
await execFileAsync('zip', ['-q', '-0', '-r', zipPath, '.'], { cwd: sourcePath });
|
||||
const zipCommand = process.platform === 'win32'
|
||||
? { file: 'tar', args: ['-a', '-cf', zipPath, ...Object.keys(files)] }
|
||||
: { file: 'zip', args: ['-q', '-0', '-r', zipPath, '.'] };
|
||||
await execFileAsync(zipCommand.file, zipCommand.args, { cwd: sourcePath });
|
||||
const zipSha256 = createHash('sha256').update(await readFile(zipPath)).digest('hex');
|
||||
const publish = {
|
||||
app_id: 'cloud-deployment-test',
|
||||
@@ -110,16 +113,21 @@ async function createReadyProject(): Promise<{ projectPath: string; zipPath: str
|
||||
}
|
||||
|
||||
async function waitForStatus(projectPath: string, expected: string): Promise<Record<string, unknown>> {
|
||||
let lastRecord: Record<string, unknown> | undefined;
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
try {
|
||||
const record = JSON.parse(await readFile(join(projectPath, WORKS_CLOUD_DEPLOYMENT_FILE_NAME), 'utf8')) as Record<string, unknown>;
|
||||
lastRecord = record;
|
||||
if (record.status === expected) return record;
|
||||
if (record.status === 'failed') {
|
||||
throw new Error(`Deployment failed while waiting for ${expected}: ${String(record.error)}`);
|
||||
}
|
||||
} catch {
|
||||
// The coordinator may not have written the first status yet.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${expected}`);
|
||||
throw new Error(`Timed out waiting for ${expected}; last record: ${JSON.stringify(lastRecord)}`);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -127,6 +135,42 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe('Main-owned Works Square cloud deployment', () => {
|
||||
it('records an already submitted one-click release without starting another upload', async () => {
|
||||
const projectPath = await mkdtemp(join(tmpdir(), 'niancode-cloud-record-'));
|
||||
tempDirectories.push(projectPath);
|
||||
const projectRecord = { ...project, path: projectPath };
|
||||
const fetchMock = vi.fn();
|
||||
const coordinator = createWorksCloudDeployment(createStore(projectRecord), {
|
||||
apiBaseUrl: 'https://square.test',
|
||||
fetchImpl: fetchMock,
|
||||
watchDirectory: () => ({ close: vi.fn() }),
|
||||
});
|
||||
|
||||
const record = await coordinator.recordSubmitted(projectRecord.id, {
|
||||
appId: 'makelore-project',
|
||||
versionId: 'version-remote-7',
|
||||
versionName: 'v2.0.0',
|
||||
reviewStatus: 'building',
|
||||
zipSha256: 'a'.repeat(64),
|
||||
});
|
||||
|
||||
expect(record).toMatchObject({
|
||||
project_id: projectRecord.id,
|
||||
status: 'submitted',
|
||||
app_id: 'makelore-project',
|
||||
version_id: 'version-remote-7',
|
||||
version_name: 'v2.0.0',
|
||||
review_status: 'building',
|
||||
zip_sha256: 'a'.repeat(64),
|
||||
});
|
||||
await expect(coordinator.get(projectRecord.id)).resolves.toEqual(record);
|
||||
expect(JSON.parse(await readFile(
|
||||
join(projectPath, WORKS_CLOUD_DEPLOYMENT_FILE_NAME),
|
||||
'utf8',
|
||||
))).toEqual(record);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps an armed task waiting for the package instead of asking for server SSH', async () => {
|
||||
const projectPath = await mkdtemp(join(tmpdir(), 'niancode-cloud-waiting-'));
|
||||
tempDirectories.push(projectPath);
|
||||
|
||||
Reference in New Issue
Block a user