需求:服务端统一 Agent Gateway 将设计任务状态流切换为 WebSocket,客户端需要实时展示生成任务并支持断线恢复。 实现:Electron Main 管理 Session、一次性 Ticket、WebSocket 心跳与游标续传,按关闭码回收会话;Renderer 继续通过本机 Host API 的 SSE 投影接收任务事件,并保留 REST 降级同步。 验证:typecheck、变更文件 ESLint、37 个聚焦测试及 build:vite 通过。
275 lines
8.5 KiB
TypeScript
275 lines
8.5 KiB
TypeScript
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
import { Readable, Writable } from 'node:stream';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import { handleImageWorkspaceRoutes } from '@electron/api/routes/image-workspace';
|
|
import type { HostApiContext } from '@electron/api/context';
|
|
|
|
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(),
|
|
on: vi.fn(),
|
|
once: vi.fn(),
|
|
emit: vi.fn(),
|
|
write: 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): IncomingMessage {
|
|
const request = Readable.from(
|
|
body === undefined ? [] : [JSON.stringify(body)],
|
|
) as unknown as IncomingMessage;
|
|
request.method = method;
|
|
request.headers = body === undefined ? {} : { 'content-type': 'application/json' };
|
|
return request;
|
|
}
|
|
|
|
class MediaResponse extends Writable {
|
|
statusCode = 0;
|
|
readonly chunks: Buffer[] = [];
|
|
readonly headers = new Map<string, string>();
|
|
|
|
setHeader(name: string, value: string | number | readonly string[]): this {
|
|
this.headers.set(name.toLowerCase(), String(value));
|
|
return this;
|
|
}
|
|
|
|
_write(
|
|
chunk: Buffer,
|
|
_encoding: BufferEncoding,
|
|
callback: (error?: Error | null) => void,
|
|
): void {
|
|
this.chunks.push(Buffer.from(chunk));
|
|
callback();
|
|
}
|
|
}
|
|
|
|
const bootstrap = {
|
|
capabilities: {
|
|
conversation: true,
|
|
generation: true,
|
|
image: true,
|
|
video: true,
|
|
},
|
|
workspaces: [],
|
|
};
|
|
|
|
describe('AI design Main route boundary', () => {
|
|
it('does not claim unrelated routes', async () => {
|
|
const response = createResponse();
|
|
const handled = await handleImageWorkspaceRoutes(
|
|
{ method: 'GET' } as IncomingMessage,
|
|
response.res,
|
|
new URL('http://127.0.0.1/api/works/projects'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(handled).toBe(false);
|
|
expect(response.res.end).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns a stable unavailable response only when Main has no module', async () => {
|
|
const response = createResponse();
|
|
await handleImageWorkspaceRoutes(
|
|
{ method: 'GET' } as IncomingMessage,
|
|
response.res,
|
|
new URL('http://127.0.0.1/api/works/image-workspace'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(response.statusCode).toBe(501);
|
|
expect(response.json()).toMatchObject({
|
|
success: false,
|
|
code: 'IMAGE_WORKSPACE_UNAVAILABLE',
|
|
error: 'AI 设计暂不可用',
|
|
});
|
|
});
|
|
|
|
it('maps Host routes to the deep Workspace module instead of provider-shaped calls', async () => {
|
|
const workspace = {
|
|
bootstrap: vi.fn().mockResolvedValue(bootstrap),
|
|
createWorkspace: vi.fn().mockResolvedValue({ workspaceId: 'workspace-one' }),
|
|
renameWorkspace: vi.fn(),
|
|
getWorkspace: vi.fn(),
|
|
submitMessage: vi.fn().mockResolvedValue({ workspaceId: 'workspace/one' }),
|
|
confirmGeneration: vi.fn().mockResolvedValue({ workspaceId: 'workspace/one' }),
|
|
listTasks: vi.fn().mockResolvedValue([]),
|
|
openAssetContent: vi.fn(),
|
|
getCapabilities: vi.fn(),
|
|
reset: vi.fn().mockResolvedValue(bootstrap),
|
|
};
|
|
const ctx = { imageWorkspace: workspace } as unknown as HostApiContext;
|
|
|
|
const getResponse = createResponse();
|
|
await handleImageWorkspaceRoutes(
|
|
createRequest('GET'),
|
|
getResponse.res,
|
|
new URL('http://127.0.0.1/api/works/image-workspace'),
|
|
ctx,
|
|
);
|
|
expect(getResponse.json()).toMatchObject({ success: true, data: bootstrap });
|
|
|
|
const createResponseBody = createResponse();
|
|
await handleImageWorkspaceRoutes(
|
|
createRequest('POST', {
|
|
clientWorkspaceId: 'client-one',
|
|
title: '角色设计',
|
|
}),
|
|
createResponseBody.res,
|
|
new URL('http://127.0.0.1/api/works/image-workspace/workspaces'),
|
|
ctx,
|
|
);
|
|
expect(workspace.createWorkspace).toHaveBeenCalledWith({
|
|
clientWorkspaceId: 'client-one',
|
|
title: '角色设计',
|
|
});
|
|
|
|
const messageResponse = createResponse();
|
|
await handleImageWorkspaceRoutes(
|
|
createRequest('POST', {
|
|
clientTurnId: 'turn-one',
|
|
expectedTurnRevision: 3,
|
|
message: '继续编辑',
|
|
attachmentAssetIds: [],
|
|
modelId: 'must-be-ignored',
|
|
}),
|
|
messageResponse.res,
|
|
new URL(
|
|
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/messages',
|
|
),
|
|
ctx,
|
|
);
|
|
expect(workspace.submitMessage).toHaveBeenCalledWith({
|
|
workspaceId: 'workspace/one',
|
|
clientTurnId: 'turn-one',
|
|
expectedTurnRevision: 3,
|
|
message: '继续编辑',
|
|
attachmentAssetIds: [],
|
|
});
|
|
|
|
const confirmResponse = createResponse();
|
|
await handleImageWorkspaceRoutes(
|
|
createRequest('POST', {
|
|
clientTurnId: 'turn-two',
|
|
expectedTurnRevision: 4,
|
|
}),
|
|
confirmResponse.res,
|
|
new URL(
|
|
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/quotes/quote%2Fone/confirm',
|
|
),
|
|
ctx,
|
|
);
|
|
expect(workspace.confirmGeneration).toHaveBeenCalledWith({
|
|
workspaceId: 'workspace/one',
|
|
quoteId: 'quote/one',
|
|
clientTurnId: 'turn-two',
|
|
expectedTurnRevision: 4,
|
|
});
|
|
});
|
|
|
|
it('streams private asset bytes and preserves Range response headers', async () => {
|
|
const openAssetContent = vi.fn().mockResolvedValue(new Response('partial', {
|
|
status: 206,
|
|
headers: {
|
|
'Accept-Ranges': 'bytes',
|
|
'Content-Range': 'bytes 0-6/100',
|
|
'Content-Type': 'video/mp4',
|
|
},
|
|
}));
|
|
const ctx = {
|
|
imageWorkspace: { openAssetContent },
|
|
} as unknown as HostApiContext;
|
|
const request = createRequest('GET');
|
|
request.headers = { range: 'bytes=0-6' };
|
|
const response = new MediaResponse();
|
|
|
|
const handled = await handleImageWorkspaceRoutes(
|
|
request,
|
|
response as unknown as ServerResponse,
|
|
new URL(
|
|
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace-one/assets/asset-one/content',
|
|
),
|
|
ctx,
|
|
);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(openAssetContent).toHaveBeenCalledWith(
|
|
'workspace-one',
|
|
'asset-one',
|
|
'bytes=0-6',
|
|
);
|
|
expect(response.statusCode).toBe(206);
|
|
expect(response.headers.get('content-range')).toBe('bytes 0-6/100');
|
|
expect(Buffer.concat(response.chunks).toString()).toBe('partial');
|
|
});
|
|
|
|
it('relays normalized task events over local SSE and forwards the opaque resume cursor', async () => {
|
|
const close = vi.fn();
|
|
const openWorkspaceEvents = vi.fn().mockResolvedValue({
|
|
events: (async function* () {
|
|
yield {
|
|
id: 'session-one:7',
|
|
type: 'design.generation_task.updated' as const,
|
|
workspaceId: 'workspace/one',
|
|
workspaceViewRevision: 8,
|
|
generationTask: {
|
|
taskId: 'task-live',
|
|
workspaceId: 'workspace/one',
|
|
medium: 'image' as const,
|
|
status: 'succeeded' as const,
|
|
briefVersion: 1,
|
|
briefSummary: '海洋公益海报',
|
|
quoteId: 'quote-live',
|
|
quotedDesignPoints: 1,
|
|
failureCode: null,
|
|
resultAssets: [],
|
|
createdAt: '2026-08-02T10:00:00Z',
|
|
updatedAt: '2026-08-02T10:02:00Z',
|
|
},
|
|
};
|
|
})(),
|
|
close,
|
|
});
|
|
const request = createRequest('GET');
|
|
request.headers = { 'last-event-id': 'session-one:6' };
|
|
const response = new MediaResponse();
|
|
|
|
const handled = await handleImageWorkspaceRoutes(
|
|
request,
|
|
response as unknown as ServerResponse,
|
|
new URL(
|
|
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/events',
|
|
),
|
|
{ imageWorkspace: { openWorkspaceEvents } } as unknown as HostApiContext,
|
|
);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(openWorkspaceEvents).toHaveBeenCalledWith({
|
|
workspaceId: 'workspace/one',
|
|
afterEventId: 'session-one:6',
|
|
});
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.headers.get('content-type')).toBe('text/event-stream; charset=utf-8');
|
|
expect(response.headers.get('x-accel-buffering')).toBe('no');
|
|
expect(Buffer.concat(response.chunks).toString()).toContain([
|
|
'id: session-one:7',
|
|
'event: design.generation_task.updated',
|
|
'data: {"id":"session-one:7"',
|
|
].join('\n'));
|
|
expect(close).toHaveBeenCalledOnce();
|
|
});
|
|
});
|