合并客户端真机预览链路

需求:将待审 Release 扫码验收与项目级真机入口并入登录、发布集成候选。

实现:合并 Main-owned 精确预览与提交映射能力,保留现有登录和一键发布边界。
This commit is contained in:
2026-08-08 17:59:42 +08:00
20 changed files with 1890 additions and 12 deletions

View File

@@ -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');
});

View 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');
});
});

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

View 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',
});
});
});

View File

@@ -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);