Files
makelore/tests/unit/device-preview-routes.test.ts
brother7 1a19ad9808 实现项目真机预览与待审版本扫码验收
需求:接管客户端未提交 WIP,保留真机预览,移除旧登录原型并维持 2.0.0。

实现:由 Main 核对项目与精确 Release,签发短时 Owner preview;补充一键提交映射能力及 Windows ZIP 预检兼容。
2026-08-08 17:57:36 +08:00

590 lines
19 KiB
TypeScript

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