feat: 对接设计 Agent Gateway WebSocket
需求:服务端统一 Agent Gateway 将设计任务状态流切换为 WebSocket,客户端需要实时展示生成任务并支持断线恢复。 实现:Electron Main 管理 Session、一次性 Ticket、WebSocket 心跳与游标续传,按关闭码回收会话;Renderer 继续通过本机 Host API 的 SSE 投影接收任务事件,并保留 REST 降级同步。 验证:typecheck、变更文件 ESLint、37 个聚焦测试及 build:vite 通过。
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ImageWorkspaceApiError } from '@/lib/image-workspace';
|
||||
@@ -7,6 +7,7 @@ import { useAuthStore } from '@/stores/auth';
|
||||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||||
import type {
|
||||
DesignGenerationTask,
|
||||
DesignGenerationTaskUpdatedEvent,
|
||||
DesignWorkspace,
|
||||
DesignWorkspaceBootstrap,
|
||||
} from '../../shared/image-workspace';
|
||||
@@ -16,8 +17,29 @@ const fetchImageWorkspaceProjectMock = vi.hoisted(() => vi.fn());
|
||||
const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn());
|
||||
const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn());
|
||||
const confirmImageWorkspaceGenerationMock = vi.hoisted(() => vi.fn());
|
||||
const openImageWorkspaceTaskEventsMock = vi.hoisted(() => vi.fn());
|
||||
const resolveImageWorkspaceAssetUrlMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
type EventListener = (event: MessageEvent<string>) => void;
|
||||
|
||||
class MockEventSource {
|
||||
onopen: ((event: Event) => void) | null = null;
|
||||
onerror: ((event: Event) => void) | null = null;
|
||||
readonly close = vi.fn();
|
||||
private readonly listeners = new Map<string, Set<EventListener>>();
|
||||
|
||||
addEventListener(type: string, listener: EventListener): void {
|
||||
const listeners = this.listeners.get(type) ?? new Set<EventListener>();
|
||||
listeners.add(listener);
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
emit(type: string, payload: unknown): void {
|
||||
const event = { data: JSON.stringify(payload) } as MessageEvent<string>;
|
||||
for (const listener of this.listeners.get(type) ?? []) listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/lib/image-workspace', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/image-workspace')>();
|
||||
return {
|
||||
@@ -29,6 +51,9 @@ vi.mock('@/lib/image-workspace', async (importOriginal) => {
|
||||
confirmImageWorkspaceGeneration: (...args: unknown[]) => (
|
||||
confirmImageWorkspaceGenerationMock(...args)
|
||||
),
|
||||
openImageWorkspaceTaskEvents: (...args: unknown[]) => (
|
||||
openImageWorkspaceTaskEventsMock(...args)
|
||||
),
|
||||
resolveImageWorkspaceAssetUrl: (...args: unknown[]) => (
|
||||
resolveImageWorkspaceAssetUrlMock(...args)
|
||||
),
|
||||
@@ -122,6 +147,8 @@ const taskFixture: DesignGenerationTask = {
|
||||
};
|
||||
|
||||
describe('ImageCanvas Workspace-first design experience', () => {
|
||||
let taskEventSource: MockEventSource;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useImageWorkspaceStore.getState().reset();
|
||||
@@ -134,6 +161,10 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
turnRevision: 2,
|
||||
phase: 'shaping',
|
||||
});
|
||||
taskEventSource = new MockEventSource();
|
||||
openImageWorkspaceTaskEventsMock.mockResolvedValue(
|
||||
taskEventSource as unknown as EventSource,
|
||||
);
|
||||
resolveImageWorkspaceAssetUrlMock.mockResolvedValue(
|
||||
'http://127.0.0.1:13210/content?token=host',
|
||||
);
|
||||
@@ -330,27 +361,33 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('keeps checking after Quote confirmation until the new task becomes visible', async () => {
|
||||
it('renders a new task pushed by the design event stream without repeated polling', async () => {
|
||||
const queuedTask = {
|
||||
...taskFixture,
|
||||
taskId: 'task-delayed',
|
||||
status: 'queued' as const,
|
||||
resultAssets: [],
|
||||
};
|
||||
fetchImageWorkspaceTasksMock
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([queuedTask]);
|
||||
fetchImageWorkspaceTasksMock.mockResolvedValue([]);
|
||||
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
await screen.findByTestId('design-quote-quote-one');
|
||||
await waitFor(() => expect(openImageWorkspaceTaskEventsMock)
|
||||
.toHaveBeenCalledWith('workspace-cloud'));
|
||||
await waitFor(() => expect(taskEventSource.onopen).not.toBeNull());
|
||||
act(() => {
|
||||
taskEventSource.onopen?.(new Event('open'));
|
||||
taskEventSource.emit('design.generation_task.updated', {
|
||||
id: 'session-one:2',
|
||||
type: 'design.generation_task.updated',
|
||||
workspaceId: 'workspace-cloud',
|
||||
workspaceViewRevision: 2,
|
||||
generationTask: queuedTask,
|
||||
} satisfies DesignGenerationTaskUpdatedEvent);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认并开始生成' }));
|
||||
|
||||
await waitFor(() => expect(confirmImageWorkspaceGenerationMock)
|
||||
.toHaveBeenCalledWith('workspace-cloud', 1, 'quote-one'));
|
||||
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(3));
|
||||
expect(await screen.findByTestId('design-task-task-delayed'))
|
||||
.toBeInTheDocument();
|
||||
expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user