import type { IncomingMessage, ServerResponse } from 'node:http'; import { Readable, Writable } from 'node:stream'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { handleImageWorkspaceRoutes } from '@electron/api/routes/image-workspace'; import type { HostApiContext } from '@electron/api/context'; import { DesignWorkspaceModuleError, type DesignWorkspaceModule, } from '@electron/image-workspace/module'; import { designBootstrapFixture, designWorkspaceFixture, } from '../fixtures/design-workspace-v2'; const electronMocks = vi.hoisted(() => ({ getPath: vi.fn(), showSaveDialog: vi.fn(), })); vi.mock('electron', () => ({ app: { getPath: electronMocks.getPath }, dialog: { showSaveDialog: electronMocks.showSaveDialog }, })); function createResponse() { const chunks: string[] = []; const res = { statusCode: 0, headersSent: false, setHeader: vi.fn(), end: vi.fn((chunk?: string) => { if (chunk) chunks.push(chunk); }), destroy: vi.fn(), } as unknown as ServerResponse; return { res, get statusCode() { return res.statusCode; }, json: () => JSON.parse(chunks.join('')) as Record, }; } function createRequest(method: string, body?: unknown, headers: Record = {}): IncomingMessage { const request = Readable.from(body === undefined ? [] : [JSON.stringify(body)]) as IncomingMessage; request.method = method; request.headers = body === undefined ? headers : { 'content-type': 'application/json', ...headers }; return request; } function moduleFixture(overrides: Partial = {}): DesignWorkspaceModule { const workspace = designWorkspaceFixture(); return { bootstrap: vi.fn().mockResolvedValue(designBootstrapFixture()), createWorkspace: vi.fn().mockResolvedValue(workspace), deleteWorkspace: vi.fn().mockResolvedValue({ workspaceId: 'workspace-1', deleted: true }), renameWorkspace: vi.fn().mockResolvedValue(workspace), getWorkspace: vi.fn().mockResolvedValue(workspace), submitCommand: vi.fn().mockResolvedValue({ clientOperationId: 'operation-1', runId: 'run-1', workspace, }), uploadAsset: vi.fn().mockResolvedValue({ assetId: 'asset-1', workspaceId: 'workspace-1', role: 'uploaded', mediaType: 'image', mimeType: 'image/png', width: 1, height: 1, durationMilliseconds: null, generationTaskId: null, createdAt: '2026-08-30T10:00:00Z', contentPath: '/asset-1', }), openWorkspaceEvents: vi.fn(), closeEventSessions: vi.fn().mockResolvedValue(undefined), openAssetContent: vi.fn().mockResolvedValue(new Response(new Uint8Array([1, 2, 3]), { headers: { 'Content-Type': 'image/png', 'Content-Length': '3' }, })), ...overrides, }; } function context(module: DesignWorkspaceModule): HostApiContext { return { imageWorkspace: module } as HostApiContext; } class StreamingResponse extends Writable { statusCode = 0; readonly headers = new Map(); readonly chunks: Buffer[] = []; setHeader(name: string, value: string | number | readonly string[]): this { this.headers.set(name.toLowerCase(), String(value)); return this; } flushHeaders(): void {} _write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void { this.chunks.push(Buffer.from(chunk)); callback(); } } describe('AI design V2 Main route boundary', () => { beforeEach(() => { vi.clearAllMocks(); }); it('does not claim unrelated routes', async () => { const response = createResponse(); const handled = await handleImageWorkspaceRoutes( createRequest('GET'), response.res, new URL('http://127.0.0.1/api/works/projects'), {} as HostApiContext, ); expect(handled).toBe(false); expect(response.res.end).not.toHaveBeenCalled(); }); it('returns a stable unavailable response when Main has no module', async () => { const response = createResponse(); await handleImageWorkspaceRoutes( createRequest('GET'), response.res, new URL('http://127.0.0.1/api/works/image-workspace'), {} as HostApiContext, ); expect(response.statusCode).toBe(501); expect(response.json()).toMatchObject({ code: 'IMAGE_WORKSPACE_UNAVAILABLE' }); }); it('maps Workspace CRUD without exposing legacy Conversation routes', async () => { const module = moduleFixture(); const createResponseValue = createResponse(); await handleImageWorkspaceRoutes( createRequest('POST', { clientWorkspaceId: 'client-1', title: '主视觉' }), createResponseValue.res, new URL('http://127.0.0.1/api/works/image-workspace/workspaces'), context(module), ); expect(module.createWorkspace).toHaveBeenCalledWith({ clientWorkspaceId: 'client-1', title: '主视觉', }); expect(createResponseValue.statusCode).toBe(200); const legacyResponse = createResponse(); await handleImageWorkspaceRoutes( createRequest('POST', { clientConversationId: 'legacy' }), legacyResponse.res, new URL('http://127.0.0.1/api/works/image-workspace/workspaces/workspace-1/conversations'), context(module), ); expect(legacyResponse.statusCode).toBe(404); }); it.each([ { body: { kind: 'apply_input', sessionId: 'session-1', expectedDirectionRevision: 4, clientOperationId: 'operation-1', input: { kind: 'direct_edit', operations: [{ kind: 'set', path: 'output.aspect_ratio', value: '16:9' }], }, }, expected: { kind: 'apply_input', workspaceId: 'workspace-1', sessionId: 'session-1', expectedDirectionRevision: 4, clientOperationId: 'operation-1', input: { kind: 'direct_edit', operations: [{ kind: 'set', path: 'output.aspect_ratio', value: '16:9' }], }, }, }, { body: { kind: 'request_quote', sessionId: 'session-1', expectedDirectionRevision: 4, specificationRevision: 3, clientOperationId: 'operation-1', }, expected: { kind: 'request_quote', workspaceId: 'workspace-1', sessionId: 'session-1', expectedDirectionRevision: 4, specificationRevision: 3, clientOperationId: 'operation-1', }, }, { body: { kind: 'confirm_generation', sessionId: 'session-1', expectedDirectionRevision: 4, quoteId: 'quote-1', clientOperationId: 'operation-1', }, expected: { kind: 'confirm_generation', workspaceId: 'workspace-1', sessionId: 'session-1', expectedDirectionRevision: 4, quoteId: 'quote-1', clientOperationId: 'operation-1', }, }, ])('decodes $body.kind as one explicit domain command', async ({ body, expected }) => { const module = moduleFixture(); const response = createResponse(); await handleImageWorkspaceRoutes( createRequest('POST', body), response.res, new URL('http://127.0.0.1/api/works/image-workspace/workspaces/workspace-1/commands'), context(module), ); expect(module.submitCommand).toHaveBeenCalledWith(expected); expect(response.statusCode).toBe(200); }); it('rejects malformed commands before they reach the module', async () => { const module = moduleFixture(); const response = createResponse(); await handleImageWorkspaceRoutes( createRequest('POST', { kind: 'apply_input', sessionId: 'session-1', expectedDirectionRevision: 4, clientOperationId: 'operation-1', input: { kind: 'direct_edit', operations: [] }, }), response.res, new URL('http://127.0.0.1/api/works/image-workspace/workspaces/workspace-1/commands'), context(module), ); expect(response.statusCode).toBe(400); expect(response.json()).toMatchObject({ code: 'IMAGE_WORKSPACE_INVALID_COMMAND' }); expect(module.submitCommand).not.toHaveBeenCalled(); }); it('preserves command outcome certainty in the local error envelope', async () => { const module = moduleFixture({ submitCommand: vi.fn().mockRejectedValue(new DesignWorkspaceModuleError( 502, 'design_reasoner_invalid', 'AI 没有整理好这次想法,请再试一次', 'definitive_failure', )), }); const response = createResponse(); await handleImageWorkspaceRoutes( createRequest('POST', { kind: 'apply_input', sessionId: 'session-1', expectedDirectionRevision: 4, clientOperationId: 'operation-1', input: { kind: 'chat', message: '画一只会做饭的机器人' }, }), response.res, new URL('http://127.0.0.1/api/works/image-workspace/workspaces/workspace-1/commands'), context(module), ); expect(response.statusCode).toBe(502); expect(response.json()).toMatchObject({ code: 'design_reasoner_invalid', commandOutcome: 'definitive_failure', }); }); it('relays resumable normalized events over local SSE', async () => { const close = vi.fn(); const openWorkspaceEvents = vi.fn().mockResolvedValue({ events: { async *[Symbol.asyncIterator]() { yield { id: 'session-1:9', type: 'design.assistant.delta' as const, workspaceId: 'workspace-1', directionId: 'direction-1', clientOperationId: 'operation-1', directionRevision: 4, chunkIndex: 0, delta: '正在整理', }; }, }, close, }); const module = moduleFixture({ openWorkspaceEvents }); const response = new StreamingResponse(); await handleImageWorkspaceRoutes( createRequest('GET', undefined, { 'last-event-id': 'session-1:8' }), response as unknown as ServerResponse, new URL('http://127.0.0.1/api/works/image-workspace/workspaces/workspace-1/events?sessionId=session-1'), context(module), ); expect(openWorkspaceEvents).toHaveBeenCalledWith({ workspaceId: 'workspace-1', sessionId: 'session-1', afterEventId: 'session-1:8', }); expect(Buffer.concat(response.chunks).toString('utf8')).toContain('event: design.assistant.delta'); expect(close).toHaveBeenCalledOnce(); }); it('bounds and decodes image uploads before crossing Main', async () => { const module = moduleFixture(); const response = createResponse(); await handleImageWorkspaceRoutes( createRequest('POST', { fileName: '../reference.png', mimeType: 'image/png', dataBase64: 'AQID', }), response.res, new URL('http://127.0.0.1/api/works/image-workspace/workspaces/workspace-1/assets'), context(module), ); expect(module.uploadAsset).toHaveBeenCalledWith(expect.objectContaining({ workspaceId: 'workspace-1', fileName: 'reference.png', mimeType: 'image/png', bytes: Buffer.from([1, 2, 3]), })); expect(response.statusCode).toBe(200); }); it('streams private asset content and preserves media headers', async () => { const module = moduleFixture({ openAssetContent: vi.fn().mockResolvedValue(new Response(new Uint8Array([1, 2, 3]), { status: 206, headers: { 'Content-Type': 'image/png', 'Content-Length': '3', 'Content-Range': 'bytes 0-2/3', }, })), }); const response = new StreamingResponse(); await handleImageWorkspaceRoutes( createRequest('GET', undefined, { range: 'bytes=0-2' }), response as unknown as ServerResponse, new URL('http://127.0.0.1/api/works/image-workspace/workspaces/workspace-1/assets/asset-1/content'), context(module), ); expect(module.openAssetContent).toHaveBeenCalledWith('workspace-1', 'asset-1', 'bytes=0-2'); expect(response.statusCode).toBe(206); expect(response.headers.get('content-range')).toBe('bytes 0-2/3'); expect(Buffer.concat(response.chunks)).toEqual(Buffer.from([1, 2, 3])); }); });