369 lines
12 KiB
TypeScript
369 lines
12 KiB
TypeScript
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<string, unknown>,
|
|
};
|
|
}
|
|
|
|
function createRequest(method: string, body?: unknown, headers: Record<string, string> = {}): 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> = {}): 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<string, string>();
|
|
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]));
|
|
});
|
|
});
|