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, }; } 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 | null, activeProjectId = 'project-1') { return { opencodeProjectStore: { getActiveProject: vi.fn(async () => ({ id: activeProjectId, name: '星球小游戏', path: 'D:/projects/planet-game', })), }, worksSubmissionBinding: { get: vi.fn(async () => deployment), }, } as never; } const submittedDeployment = { schema_version: 2, 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; latestVersion?: Record; } = {}) { return { project: { app_id: 'planet-game', playable: true, play_url: '/apps/planet-game/', runtime_url: '/apps/legacy-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('explains that a legacy automatic deployment must be resubmitted', 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({ schema_version: 2, project_id: 'project-1', status: 'legacy_retired', requested_at: '2026-08-01T00:00:00.000Z', updated_at: '2026-08-10T00:00:00.000Z', error_code: 'LEGACY_AUTO_DEPLOY_RETIRED', message: '旧版自动部署任务已停用,请在项目配置中点击“提交审核”重新提交。', }), ); expect(response.json()).toEqual({ success: true, preview: { state: 'unavailable', projectId: 'project-1', updatedAt: '2026-08-10T00:00:00.000Z', message: '旧版自动部署任务已停用,请在项目配置中点击“提交审核”重新提交。', }, }); expect(fetchMock).not.toHaveBeenCalled(); }); it('prefers the trusted static play URL over the legacy runtime alias', 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('keeps the legacy runtime alias as a one-release fallback', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response( JSON.stringify(matchingRemotePayload({ project: { play_url: null, runtime_url: '/apps/planet-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.json()).toMatchObject({ preview: { state: 'ready', launchUrl: 'https://square.nianxx.cn/apps/planet-game/', }, }); }); 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, play_url: null, 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, play_url: null, 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 play URL from %s', async (_case, playUrl) => { vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response( JSON.stringify(matchingRemotePayload({ project: { play_url: playUrl } })), { 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: { play_url: undefined, 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((_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, 'not json', 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', }); }); });