需求:服务端统一 Agent Gateway 将设计任务状态流切换为 WebSocket,客户端需要实时展示生成任务并支持断线恢复。 实现:Electron Main 管理 Session、一次性 Ticket、WebSocket 心跳与游标续传,按关闭码回收会话;Renderer 继续通过本机 Host API 的 SSE 投影接收任务事件,并保留 REST 降级同步。 验证:typecheck、变更文件 ESLint、37 个聚焦测试及 build:vite 通过。
394 lines
14 KiB
TypeScript
394 lines
14 KiB
TypeScript
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';
|
|
import { ImageCanvas } from '@/pages/ImageCanvas';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
|
import type {
|
|
DesignGenerationTask,
|
|
DesignGenerationTaskUpdatedEvent,
|
|
DesignWorkspace,
|
|
DesignWorkspaceBootstrap,
|
|
} from '../../shared/image-workspace';
|
|
|
|
const fetchImageWorkspaceMock = vi.hoisted(() => vi.fn());
|
|
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 {
|
|
...actual,
|
|
fetchImageWorkspace: (...args: unknown[]) => fetchImageWorkspaceMock(...args),
|
|
fetchImageWorkspaceProject: (...args: unknown[]) => fetchImageWorkspaceProjectMock(...args),
|
|
fetchImageWorkspaceTasks: (...args: unknown[]) => fetchImageWorkspaceTasksMock(...args),
|
|
sendImageWorkspaceMessage: (...args: unknown[]) => sendImageWorkspaceMessageMock(...args),
|
|
confirmImageWorkspaceGeneration: (...args: unknown[]) => (
|
|
confirmImageWorkspaceGenerationMock(...args)
|
|
),
|
|
openImageWorkspaceTaskEvents: (...args: unknown[]) => (
|
|
openImageWorkspaceTaskEventsMock(...args)
|
|
),
|
|
resolveImageWorkspaceAssetUrl: (...args: unknown[]) => (
|
|
resolveImageWorkspaceAssetUrlMock(...args)
|
|
),
|
|
};
|
|
});
|
|
|
|
const bootstrapFixture: DesignWorkspaceBootstrap = {
|
|
capabilities: {
|
|
conversation: true,
|
|
generation: true,
|
|
image: true,
|
|
video: true,
|
|
},
|
|
workspaces: [{
|
|
workspaceId: 'workspace-cloud',
|
|
title: '云端角色设定',
|
|
turnRevision: 1,
|
|
viewRevision: 1,
|
|
phase: 'awaiting_confirmation',
|
|
brief: {
|
|
version: 1,
|
|
status: 'ready',
|
|
medium: 'image',
|
|
summary: '夜色中的机械城堡角色海报',
|
|
ready: true,
|
|
missingDecision: null,
|
|
},
|
|
updatedAt: '2026-07-31T10:00:00Z',
|
|
}],
|
|
};
|
|
|
|
function workspaceFixture(): DesignWorkspace {
|
|
return {
|
|
...bootstrapFixture.workspaces[0],
|
|
messages: [
|
|
{
|
|
id: 'message-user',
|
|
role: 'user',
|
|
kind: 'user',
|
|
text: '做一张夜色中的机械城堡角色海报',
|
|
quickReplies: [],
|
|
generationQuote: null,
|
|
turnRevision: 1,
|
|
createdAt: '2026-07-31T10:00:00Z',
|
|
},
|
|
{
|
|
id: 'message-assistant',
|
|
role: 'assistant',
|
|
kind: 'confirmation',
|
|
text: '方向已经明确,确认后开始生成。',
|
|
quickReplies: ['确认生成', '继续调整'],
|
|
generationQuote: {
|
|
quoteId: 'quote-one',
|
|
status: 'active',
|
|
medium: 'image',
|
|
briefVersion: 1,
|
|
briefSummary: '夜色中的机械城堡角色海报',
|
|
quotedDesignPoints: 1,
|
|
expiresAt: '2026-07-31T11:00:00Z',
|
|
},
|
|
turnRevision: 1,
|
|
createdAt: '2026-07-31T10:00:01Z',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
const taskFixture: DesignGenerationTask = {
|
|
taskId: 'task-one',
|
|
workspaceId: 'workspace-cloud',
|
|
medium: 'image',
|
|
status: 'succeeded',
|
|
briefVersion: 1,
|
|
briefSummary: '夜色中的机械城堡角色海报',
|
|
quoteId: 'quote-old',
|
|
quotedDesignPoints: 1,
|
|
failureCode: null,
|
|
resultAssets: [{
|
|
assetId: 'asset-one',
|
|
workspaceId: 'workspace-cloud',
|
|
mediaType: 'image',
|
|
mimeType: 'image/png',
|
|
width: 1024,
|
|
height: 1024,
|
|
durationMilliseconds: null,
|
|
createdAt: '2026-07-31T09:00:00Z',
|
|
contentPath: '/api/works/image-workspace/workspaces/workspace-cloud/assets/asset-one/content',
|
|
}],
|
|
createdAt: '2026-07-31T09:00:00Z',
|
|
updatedAt: '2026-07-31T09:01:00Z',
|
|
};
|
|
|
|
describe('ImageCanvas Workspace-first design experience', () => {
|
|
let taskEventSource: MockEventSource;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
useImageWorkspaceStore.getState().reset();
|
|
fetchImageWorkspaceMock.mockResolvedValue(bootstrapFixture);
|
|
fetchImageWorkspaceProjectMock.mockResolvedValue(workspaceFixture());
|
|
fetchImageWorkspaceTasksMock.mockResolvedValue([taskFixture]);
|
|
sendImageWorkspaceMessageMock.mockResolvedValue(workspaceFixture());
|
|
confirmImageWorkspaceGenerationMock.mockResolvedValue({
|
|
...workspaceFixture(),
|
|
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',
|
|
);
|
|
});
|
|
|
|
it('shows an honest unavailable state without fabricating projects', async () => {
|
|
fetchImageWorkspaceMock.mockRejectedValueOnce(new ImageWorkspaceApiError(
|
|
501,
|
|
'IMAGE_WORKSPACE_UNAVAILABLE',
|
|
'AI 设计暂不可用',
|
|
));
|
|
|
|
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
|
|
|
expect(await screen.findByTestId('image-workspace-unavailable'))
|
|
.toHaveTextContent('AI 设计暂不可用');
|
|
expect(screen.getByText(/暂时无法连接设计服务/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('clears stale renderer auth when Main requires authentication', async () => {
|
|
useAuthStore.setState({
|
|
initialized: true,
|
|
loading: false,
|
|
error: null,
|
|
accessToken: 'expired-access-token',
|
|
refreshToken: 'invalid-refresh-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() - 1,
|
|
user: {
|
|
username: 'brother7',
|
|
userId: '1',
|
|
tenantId: null,
|
|
deptId: null,
|
|
authorities: [],
|
|
},
|
|
});
|
|
fetchImageWorkspaceMock.mockRejectedValueOnce(new ImageWorkspaceApiError(
|
|
401,
|
|
'AUTH_REQUIRED',
|
|
'请先登录后再使用 AI 设计',
|
|
));
|
|
|
|
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
|
|
|
await screen.findByTestId('image-workspace-unavailable');
|
|
expect(useAuthStore.getState()).toMatchObject({
|
|
accessToken: null,
|
|
refreshToken: null,
|
|
user: null,
|
|
});
|
|
expect(useImageWorkspaceStore.getState().status).toBe('auth-required');
|
|
|
|
useAuthStore.setState({
|
|
accessToken: 'fresh-access-token',
|
|
refreshToken: 'fresh-refresh-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
user: {
|
|
username: 'brother7',
|
|
userId: '1',
|
|
tenantId: null,
|
|
deptId: null,
|
|
authorities: [],
|
|
},
|
|
});
|
|
|
|
await waitFor(() => expect(fetchImageWorkspaceMock).toHaveBeenCalledTimes(2));
|
|
await waitFor(() => expect(useImageWorkspaceStore.getState().status).toBe('ready'));
|
|
});
|
|
|
|
it('renders one fixed design Agent, a Quote, and the unified image/video task list', async () => {
|
|
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
|
|
|
expect(await screen.findByText('云端角色设定')).toBeInTheDocument();
|
|
expect(screen.getByTestId('image-workspace-conversation'))
|
|
.toHaveTextContent('方向已经明确');
|
|
expect(screen.getByText('设计 Agent')).toBeInTheDocument();
|
|
expect(screen.getByTestId('design-quote-quote-one')).toHaveTextContent('1 设计点');
|
|
expect(screen.getByTestId('design-task-list')).toHaveTextContent('图片与视频任务统一展示');
|
|
expect(screen.getByTestId('design-task-task-one')).toHaveTextContent('已完成');
|
|
await waitFor(() => expect(screen.getByRole('img', { name: 'AI 设计生成结果' }))
|
|
.toHaveAttribute('src', 'http://127.0.0.1:13210/content?token=host'));
|
|
expect(screen.queryByText('生成设置')).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/当前 Agent/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('keeps the task list visible in a 1404px desktop workspace', async () => {
|
|
const previousWidth = window.innerWidth;
|
|
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1404 });
|
|
|
|
try {
|
|
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
|
await screen.findByTestId('image-workspace-conversation');
|
|
|
|
expect(screen.getByTestId('design-task-list')).toBeInTheDocument();
|
|
expect(screen.getByTestId('design-pane-divider')).toHaveClass('lg:block');
|
|
} finally {
|
|
Object.defineProperty(window, 'innerWidth', {
|
|
configurable: true,
|
|
value: previousWidth,
|
|
});
|
|
}
|
|
});
|
|
|
|
it('keeps conversation and task scrolling isolated', async () => {
|
|
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
|
await screen.findByTestId('image-workspace-conversation');
|
|
|
|
expect(screen.getByTestId('image-canvas-page'))
|
|
.toHaveClass('h-full', 'overflow-hidden');
|
|
expect(screen.getByTestId('image-workspace-conversation'))
|
|
.toHaveClass('overflow-y-auto', 'overscroll-contain');
|
|
expect(screen.getByTestId('design-task-list'))
|
|
.toHaveClass('lg:h-full', 'overflow-hidden');
|
|
expect(screen.getByTestId('design-task-scroll'))
|
|
.toHaveClass('lg:h-[calc(100%-57px)]', 'overflow-y-auto', 'overscroll-contain');
|
|
});
|
|
|
|
it('explains the project model before the first Workspace is created', async () => {
|
|
fetchImageWorkspaceMock.mockResolvedValueOnce({
|
|
...bootstrapFixture,
|
|
workspaces: [],
|
|
});
|
|
|
|
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
|
|
|
expect(await screen.findByText('创建第一个设计项目')).toBeInTheDocument();
|
|
expect(screen.getByText(/持续保留与设计 Agent 的对话/)).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: '新建设计项目' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('sends text to the Agent with the current Turn revision instead of generating directly', async () => {
|
|
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
|
await screen.findByText('云端角色设定');
|
|
|
|
fireEvent.change(screen.getByLabelText('设计需求'), {
|
|
target: { value: '把主角换成暖色轮廓光' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' }));
|
|
|
|
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
|
|
'workspace-cloud',
|
|
1,
|
|
'把主角换成暖色轮廓光',
|
|
));
|
|
expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refreshes tasks after an Agent turn creates a generation task', async () => {
|
|
const queuedTask = {
|
|
...taskFixture,
|
|
taskId: 'task-two',
|
|
status: 'queued' as const,
|
|
resultAssets: [],
|
|
};
|
|
fetchImageWorkspaceTasksMock
|
|
.mockResolvedValueOnce([])
|
|
.mockResolvedValueOnce([queuedTask]);
|
|
|
|
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
|
await screen.findByTestId('design-quote-quote-one');
|
|
|
|
const confirmationReply = workspaceFixture().messages[1].quickReplies[0];
|
|
fireEvent.change(screen.getByLabelText('设计需求'), {
|
|
target: { value: confirmationReply },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' }));
|
|
|
|
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
|
|
'workspace-cloud',
|
|
1,
|
|
confirmationReply,
|
|
));
|
|
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2));
|
|
expect(await screen.findByTestId('design-task-task-two')).toBeInTheDocument();
|
|
});
|
|
|
|
it('creates a generation task only through explicit Quote confirmation', async () => {
|
|
fetchImageWorkspaceTasksMock
|
|
.mockResolvedValueOnce([taskFixture])
|
|
.mockResolvedValueOnce([{
|
|
...taskFixture,
|
|
taskId: 'task-two',
|
|
status: 'queued',
|
|
resultAssets: [],
|
|
}]);
|
|
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
|
await screen.findByTestId('design-quote-quote-one');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '确认并开始生成' }));
|
|
|
|
await waitFor(() => expect(confirmImageWorkspaceGenerationMock)
|
|
.toHaveBeenCalledWith('workspace-cloud', 1, 'quote-one'));
|
|
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2));
|
|
});
|
|
|
|
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.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);
|
|
});
|
|
|
|
expect(await screen.findByTestId('design-task-task-delayed'))
|
|
.toBeInTheDocument();
|
|
expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledOnce();
|
|
});
|
|
});
|