feat: 统一 AI 设计 Workspace 与生成任务链路
需求:以设计项目组织固定设计 Agent 对话、方向确认和图片视频任务。 实现:新增 Works Square 云端适配与开发态本地适配,统一 Host API、Quote 确认、任务轮询及私有媒体 Range 代理。
This commit is contained in:
@@ -3,246 +3,217 @@ 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 { ImageWorkspaceSnapshot } from '../../shared/image-workspace';
|
||||
import type {
|
||||
DesignGenerationTask,
|
||||
DesignWorkspace,
|
||||
DesignWorkspaceBootstrap,
|
||||
} from '../../shared/image-workspace';
|
||||
|
||||
const fetchImageWorkspaceMock = vi.hoisted(() => vi.fn());
|
||||
const addImageWorkspaceAgentMock = vi.hoisted(() => vi.fn());
|
||||
const fetchImageWorkspaceProjectMock = vi.hoisted(() => vi.fn());
|
||||
const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn());
|
||||
const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn());
|
||||
const uploadImageWorkspaceReferenceMock = vi.hoisted(() => vi.fn());
|
||||
const confirmImageWorkspaceGenerationMock = vi.hoisted(() => vi.fn());
|
||||
const resolveImageWorkspaceAssetUrlMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/image-workspace', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/image-workspace')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchImageWorkspace: (...args: unknown[]) => fetchImageWorkspaceMock(...args),
|
||||
addImageWorkspaceAgent: (...args: unknown[]) => addImageWorkspaceAgentMock(...args),
|
||||
fetchImageWorkspaceProject: (...args: unknown[]) => fetchImageWorkspaceProjectMock(...args),
|
||||
fetchImageWorkspaceTasks: (...args: unknown[]) => fetchImageWorkspaceTasksMock(...args),
|
||||
sendImageWorkspaceMessage: (...args: unknown[]) => sendImageWorkspaceMessageMock(...args),
|
||||
uploadImageWorkspaceReference: (...args: unknown[]) => uploadImageWorkspaceReferenceMock(...args),
|
||||
confirmImageWorkspaceGeneration: (...args: unknown[]) => (
|
||||
confirmImageWorkspaceGenerationMock(...args)
|
||||
),
|
||||
resolveImageWorkspaceAssetUrl: (...args: unknown[]) => (
|
||||
resolveImageWorkspaceAssetUrlMock(...args)
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
function workspaceFixture(): ImageWorkspaceSnapshot {
|
||||
return {
|
||||
activeProjectId: 'project-cloud',
|
||||
capabilities: {
|
||||
modes: [{ id: 'generate', label: '图片生成' }],
|
||||
models: [{ id: 'cloud-model', label: '云端模型 A' }],
|
||||
aspectRatios: [
|
||||
{ id: 'square', label: '1:1' },
|
||||
{ id: 'wide', label: '16:9' },
|
||||
],
|
||||
resolutions: [{ id: '2k', label: '2K' }],
|
||||
outputCounts: [{ id: 'four', label: '4 张' }],
|
||||
defaultModeId: 'generate',
|
||||
defaultModelId: 'cloud-model',
|
||||
defaultAspectRatioId: 'square',
|
||||
defaultResolutionId: '2k',
|
||||
defaultOutputCountId: 'four',
|
||||
maxReferenceImages: 3,
|
||||
referenceUpload: {
|
||||
enabled: true,
|
||||
acceptedMimeTypes: ['image/png', 'image/jpeg'],
|
||||
maxBytes: 5 * 1024 * 1024,
|
||||
},
|
||||
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,
|
||||
},
|
||||
projects: [{
|
||||
id: 'project-cloud',
|
||||
name: '云端角色设定',
|
||||
activeAgentId: 'agent-manual',
|
||||
updatedAt: '2026-07-28T10:00:00Z',
|
||||
agents: [{ id: 'agent-manual', name: '视觉创作 Agent' }],
|
||||
messages: [{
|
||||
id: 'message-result',
|
||||
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',
|
||||
agentId: 'agent-manual',
|
||||
text: '已经为你生成一组角色图。',
|
||||
status: 'succeeded',
|
||||
createdAt: '2026-07-28T10:00:00Z',
|
||||
images: [{
|
||||
id: 'image-cloud-1',
|
||||
url: 'https://example.com/cloud-result.png',
|
||||
thumbnailUrl: 'https://example.com/cloud-result-thumb.png',
|
||||
alt: '云端角色结果',
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
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',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function resetAuthStore() {
|
||||
useAuthStore.setState({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
authBase: '',
|
||||
clientId: 'app',
|
||||
accessToken: 'access-token',
|
||||
refreshToken: null,
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
user: {
|
||||
username: 'creator',
|
||||
userId: 'user_1',
|
||||
tenantId: null,
|
||||
deptId: null,
|
||||
authorities: ['ROLE_USER'],
|
||||
},
|
||||
});
|
||||
}
|
||||
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 cloud conversation workspace', () => {
|
||||
describe('ImageCanvas Workspace-first design experience', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.electron.imageWorkspaceLocalDevelopment = false;
|
||||
useImageWorkspaceStore.getState().reset();
|
||||
resetAuthStore();
|
||||
const workspace = workspaceFixture();
|
||||
fetchImageWorkspaceMock.mockResolvedValue(workspace);
|
||||
addImageWorkspaceAgentMock.mockResolvedValue(workspace);
|
||||
sendImageWorkspaceMessageMock.mockResolvedValue(workspace);
|
||||
uploadImageWorkspaceReferenceMock.mockResolvedValue({
|
||||
workspace,
|
||||
reference: {
|
||||
id: 'uploaded-reference',
|
||||
url: 'https://example.com/uploaded.png',
|
||||
alt: '上传的参考图',
|
||||
},
|
||||
fetchImageWorkspaceMock.mockResolvedValue(bootstrapFixture);
|
||||
fetchImageWorkspaceProjectMock.mockResolvedValue(workspaceFixture());
|
||||
fetchImageWorkspaceTasksMock.mockResolvedValue([taskFixture]);
|
||||
sendImageWorkspaceMessageMock.mockResolvedValue(workspaceFixture());
|
||||
confirmImageWorkspaceGenerationMock.mockResolvedValue({
|
||||
...workspaceFixture(),
|
||||
turnRevision: 2,
|
||||
phase: 'shaping',
|
||||
});
|
||||
resolveImageWorkspaceAssetUrlMock.mockResolvedValue(
|
||||
'http://127.0.0.1:13210/content?token=host',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows an honest unavailable state instead of local or fabricated projects', async () => {
|
||||
it('shows an honest unavailable state without fabricating projects', async () => {
|
||||
fetchImageWorkspaceMock.mockRejectedValueOnce(new ImageWorkspaceApiError(
|
||||
501,
|
||||
'IMAGE_WORKSPACE_UNAVAILABLE',
|
||||
'创作空间暂不可用',
|
||||
'AI 设计暂不可用',
|
||||
));
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/image-canvas']}>
|
||||
<ImageCanvas />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
|
||||
expect(await screen.findByTestId('image-workspace-unavailable')).toHaveTextContent('创作空间暂不可用');
|
||||
expect(screen.getByText(/暂时无法连接创作空间/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('制作中心')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('任务模块')).not.toBeInTheDocument();
|
||||
expect(await screen.findByTestId('image-workspace-unavailable'))
|
||||
.toHaveTextContent('AI 设计暂不可用');
|
||||
expect(screen.getByText(/暂时无法连接设计服务/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders cloud projects, conversations, agents, and server-provided generation controls', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/image-canvas']}>
|
||||
<ImageCanvas />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
it('renders one fixed design Agent, a Quote, and the unified image/video task list', async () => {
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
|
||||
expect(await screen.findByTestId('image-workspace-page')).toHaveTextContent('云端角色设定');
|
||||
expect(screen.getByTestId('image-workspace-header')).toHaveTextContent('Canvas');
|
||||
expect(screen.getByTestId('image-workspace-composer')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('image-workspace-generation-controls')).toBeInTheDocument();
|
||||
expect(screen.getByText('当前 Agent:视觉创作 Agent')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('image-workspace-conversation')).toHaveTextContent('已经为你生成一组角色图');
|
||||
expect(screen.getByRole('img', { name: '云端角色结果' })).toHaveAttribute(
|
||||
'src',
|
||||
'https://example.com/cloud-result-thumb.png',
|
||||
);
|
||||
expect(screen.getByLabelText('创作模式')).toHaveValue('generate');
|
||||
expect(screen.getByLabelText('模型')).toHaveValue('cloud-model');
|
||||
expect(screen.getByLabelText('画幅')).toHaveValue('square');
|
||||
expect(screen.getByLabelText('分辨率')).toHaveValue('2k');
|
||||
expect(screen.getByLabelText('生成数量')).toHaveValue('four');
|
||||
expect(screen.queryByText('Image2')).not.toBeInTheDocument();
|
||||
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('renders the anonymous development adapter with the same production copy', async () => {
|
||||
useAuthStore.setState({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
});
|
||||
window.electron.imageWorkspaceLocalDevelopment = true;
|
||||
it('explains the project model before the first Workspace is created', async () => {
|
||||
fetchImageWorkspaceMock.mockResolvedValueOnce({
|
||||
...workspaceFixture(),
|
||||
activeProjectId: null,
|
||||
projects: [],
|
||||
...bootstrapFixture,
|
||||
workspaces: [],
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/image-canvas']}>
|
||||
<ImageCanvas />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
|
||||
expect(await screen.findByText('创建第一个项目')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '新建项目' })).toBeInTheDocument();
|
||||
expect(fetchImageWorkspaceMock).toHaveBeenCalledWith(null);
|
||||
expect(screen.queryByText('本地开发')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('创建第一个本地项目')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('创建第一个云端项目')).not.toBeInTheDocument();
|
||||
expect(await screen.findByText('创建第一个设计项目')).toBeInTheDocument();
|
||||
expect(screen.getByText(/持续保留与设计 Agent 的对话/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '新建设计项目' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds an output only after an explicit action and clears references after a successful turn', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/image-canvas']}>
|
||||
<ImageCanvas />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
it('sends text to the Agent with the current Turn revision instead of generating directly', async () => {
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
await screen.findByText('云端角色设定');
|
||||
|
||||
await screen.findByTestId('image-workspace-page');
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }));
|
||||
expect(screen.getByTestId('image-workspace-selected-references')).toHaveTextContent('云端角色结果');
|
||||
fireEvent.change(screen.getByLabelText('设计需求'), {
|
||||
target: { value: '把主角换成暖色轮廓光' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText('创作描述'), { target: { value: '把角色换成夜景光线' } });
|
||||
fireEvent.change(screen.getByLabelText('画幅'), { target: { value: 'wide' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
|
||||
|
||||
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith('access-token', {
|
||||
projectId: 'project-cloud',
|
||||
agentId: 'agent-manual',
|
||||
prompt: '把角色换成夜景光线',
|
||||
referenceImageIds: ['image-cloud-1'],
|
||||
settings: {
|
||||
modeId: 'generate',
|
||||
modelId: 'cloud-model',
|
||||
aspectRatioId: 'wide',
|
||||
resolutionId: '2k',
|
||||
outputCountId: 'four',
|
||||
},
|
||||
}));
|
||||
await waitFor(() => expect(screen.queryByTestId('image-workspace-selected-references')).not.toBeInTheDocument());
|
||||
|
||||
fireEvent.change(screen.getByLabelText('创作描述'), { target: { value: '另起一张纯文字创作' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
|
||||
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenLastCalledWith(
|
||||
'access-token',
|
||||
expect.objectContaining({
|
||||
prompt: '另起一张纯文字创作',
|
||||
referenceImageIds: [],
|
||||
}),
|
||||
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
|
||||
'workspace-cloud',
|
||||
1,
|
||||
'把主角换成暖色轮廓光',
|
||||
));
|
||||
expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads references through the cloud boundary and selects the returned cloud image', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/image-canvas']}>
|
||||
<ImageCanvas />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
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');
|
||||
|
||||
await screen.findByTestId('image-workspace-page');
|
||||
const file = new File(['image-bytes'], 'reference.png', { type: 'image/png' });
|
||||
fireEvent.change(screen.getByLabelText('选择参考图'), { target: { files: [file] } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认并开始生成' }));
|
||||
|
||||
await waitFor(() => expect(uploadImageWorkspaceReferenceMock).toHaveBeenCalledWith(
|
||||
'access-token',
|
||||
expect.objectContaining({
|
||||
projectId: 'project-cloud',
|
||||
fileName: 'reference.png',
|
||||
mimeType: 'image/png',
|
||||
}),
|
||||
));
|
||||
expect(await screen.findByTestId('image-workspace-selected-references')).toHaveTextContent('上传的参考图');
|
||||
await waitFor(() => expect(confirmImageWorkspaceGenerationMock)
|
||||
.toHaveBeenCalledWith('workspace-cloud', 1, 'quote-one'));
|
||||
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,110 +1,113 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AppError } from '@/lib/error-model';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import {
|
||||
ensureHostApiToken,
|
||||
getHostApiBase,
|
||||
hostApiFetch,
|
||||
} from '@/lib/host-api';
|
||||
import {
|
||||
confirmImageWorkspaceGeneration,
|
||||
createImageWorkspaceProject,
|
||||
fetchImageWorkspace,
|
||||
ImageWorkspaceApiError,
|
||||
resolveImageWorkspaceAssetUrl,
|
||||
sendImageWorkspaceMessage,
|
||||
uploadImageWorkspaceReference,
|
||||
} from '@/lib/image-workspace';
|
||||
|
||||
vi.mock('@/lib/host-api', () => ({ hostApiFetch: vi.fn() }));
|
||||
vi.mock('@/lib/host-api', () => ({
|
||||
hostApiFetch: vi.fn(),
|
||||
ensureHostApiToken: vi.fn(),
|
||||
getHostApiBase: vi.fn(),
|
||||
}));
|
||||
|
||||
const hostApiFetchMock = vi.mocked(hostApiFetch);
|
||||
const ensureHostApiTokenMock = vi.mocked(ensureHostApiToken);
|
||||
const getHostApiBaseMock = vi.mocked(getHostApiBase);
|
||||
|
||||
const workspace = {
|
||||
const bootstrap = {
|
||||
capabilities: {
|
||||
modes: [],
|
||||
models: [],
|
||||
aspectRatios: [],
|
||||
resolutions: [],
|
||||
outputCounts: [],
|
||||
maxReferenceImages: 0,
|
||||
referenceUpload: { enabled: false, acceptedMimeTypes: [] },
|
||||
conversation: true,
|
||||
generation: true,
|
||||
image: true,
|
||||
video: false,
|
||||
},
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
};
|
||||
|
||||
describe('image workspace renderer API boundary', () => {
|
||||
describe('AI design renderer API boundary', () => {
|
||||
beforeEach(() => {
|
||||
hostApiFetchMock.mockReset();
|
||||
hostApiFetchMock.mockResolvedValue({ success: true, workspace });
|
||||
hostApiFetchMock.mockResolvedValue({ success: true, status: 200, data: bootstrap });
|
||||
ensureHostApiTokenMock.mockReset();
|
||||
ensureHostApiTokenMock.mockResolvedValue('host-token');
|
||||
getHostApiBaseMock.mockReset();
|
||||
getHostApiBaseMock.mockReturnValue('http://127.0.0.1:13210');
|
||||
});
|
||||
|
||||
it('uses a dedicated Main-owned route and forwards the cloud access token', async () => {
|
||||
await fetchImageWorkspace('cloud-token');
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/image-workspace', {
|
||||
headers: { 'X-NianCode-Access-Token': 'cloud-token' },
|
||||
});
|
||||
});
|
||||
|
||||
it('allows an adapter-backed initial load without an access token', async () => {
|
||||
await fetchImageWorkspace(null);
|
||||
it('uses only the Main-owned route and never forwards a Works Square token', async () => {
|
||||
await fetchImageWorkspace();
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/image-workspace', {});
|
||||
});
|
||||
|
||||
it('creates a cloud project with only its trimmed name', async () => {
|
||||
await createImageWorkspaceProject('cloud-token', ' 角色设计 ');
|
||||
it('creates an idempotent Workspace with a trimmed user-visible title', async () => {
|
||||
await createImageWorkspaceProject(' 角色设计 ', 'workspace-client-1');
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/image-workspace/projects', {
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/image-workspace/workspaces', {
|
||||
method: 'POST',
|
||||
headers: { 'X-NianCode-Access-Token': 'cloud-token' },
|
||||
body: JSON.stringify({ name: '角色设计' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('sends explicit references and server option ids without local task semantics', async () => {
|
||||
await sendImageWorkspaceMessage('cloud-token', {
|
||||
projectId: 'project/one',
|
||||
agentId: 'agent-one',
|
||||
prompt: '继续编辑',
|
||||
referenceImageIds: ['image-one'],
|
||||
settings: { modelId: 'server-model' },
|
||||
});
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/image-workspace/projects/project%2Fone/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'X-NianCode-Access-Token': 'cloud-token' },
|
||||
body: JSON.stringify({
|
||||
projectId: 'project/one',
|
||||
agentId: 'agent-one',
|
||||
prompt: '继续编辑',
|
||||
referenceImageIds: ['image-one'],
|
||||
settings: { modelId: 'server-model' },
|
||||
clientWorkspaceId: 'workspace-client-1',
|
||||
title: '角色设计',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('requires the upload response to return a cloud reference id', async () => {
|
||||
hostApiFetchMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
workspace,
|
||||
reference: { id: 'reference-one', url: 'https://example.com/reference.png' },
|
||||
});
|
||||
it('sends conversation turns with the current turn revision and no provider settings', async () => {
|
||||
await sendImageWorkspaceMessage('workspace/one', 4, ' 继续调整构图 ');
|
||||
|
||||
await expect(uploadImageWorkspaceReference('cloud-token', {
|
||||
projectId: 'project-one',
|
||||
fileName: 'reference.png',
|
||||
mimeType: 'image/png',
|
||||
contentBase64: 'aW1hZ2U=',
|
||||
})).resolves.toMatchObject({ reference: { id: 'reference-one' } });
|
||||
const [path, init] = hostApiFetchMock.mock.calls[0];
|
||||
expect(path).toBe('/api/works/image-workspace/workspaces/workspace%2Fone/messages');
|
||||
expect(init).toMatchObject({ method: 'POST' });
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
expectedTurnRevision: 4,
|
||||
message: '继续调整构图',
|
||||
attachmentAssetIds: [],
|
||||
clientTurnId: expect.stringMatching(/^turn-/),
|
||||
});
|
||||
expect(String(init?.body)).not.toMatch(/model|resolution|outputCount/);
|
||||
});
|
||||
|
||||
it('maps the unimplemented Main route to a stable unavailable error', async () => {
|
||||
it('confirms a Quote through a separate structured action route', async () => {
|
||||
await confirmImageWorkspaceGeneration('workspace-one', 5, 'quote/one');
|
||||
|
||||
const [path, init] = hostApiFetchMock.mock.calls[0];
|
||||
expect(path).toBe(
|
||||
'/api/works/image-workspace/workspaces/workspace-one/quotes/quote%2Fone/confirm',
|
||||
);
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
expectedTurnRevision: 5,
|
||||
clientTurnId: expect.stringMatching(/^turn-/),
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves structured Main error codes for revision recovery', async () => {
|
||||
hostApiFetchMock.mockRejectedValueOnce(new AppError(
|
||||
'UNKNOWN',
|
||||
'创作空间暂不可用',
|
||||
'Design workspace revision has changed',
|
||||
undefined,
|
||||
{ status: 501 },
|
||||
{ status: 409, backendCode: 'workspace_revision_conflict' },
|
||||
));
|
||||
|
||||
await expect(fetchImageWorkspace('cloud-token')).rejects.toMatchObject<ImageWorkspaceApiError>({
|
||||
status: 501,
|
||||
code: 'IMAGE_WORKSPACE_UNAVAILABLE',
|
||||
message: '创作空间暂不可用',
|
||||
await expect(fetchImageWorkspace()).rejects.toMatchObject<ImageWorkspaceApiError>({
|
||||
status: 409,
|
||||
code: 'workspace_revision_conflict',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a loopback media URL with only the Host API session token', async () => {
|
||||
await expect(resolveImageWorkspaceAssetUrl('/api/works/image-workspace/assets/one'))
|
||||
.resolves.toBe(
|
||||
'http://127.0.0.1:13210/api/works/image-workspace/assets/one?token=host-token',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { Readable } from 'node:stream';
|
||||
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';
|
||||
import type { ImageWorkspaceSnapshot } from '../../shared/image-workspace';
|
||||
|
||||
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,
|
||||
@@ -24,27 +29,45 @@ function createResponse() {
|
||||
}
|
||||
|
||||
function createRequest(method: string, body?: unknown): IncomingMessage {
|
||||
const request = Readable.from(body === undefined ? [] : [JSON.stringify(body)]) as unknown as 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;
|
||||
}
|
||||
|
||||
const localSnapshot: ImageWorkspaceSnapshot = {
|
||||
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: {
|
||||
modes: [],
|
||||
models: [],
|
||||
aspectRatios: [],
|
||||
resolutions: [],
|
||||
outputCounts: [],
|
||||
maxReferenceImages: 0,
|
||||
referenceUpload: { enabled: false, acceptedMimeTypes: [] },
|
||||
conversation: true,
|
||||
generation: true,
|
||||
image: true,
|
||||
video: true,
|
||||
},
|
||||
projects: [],
|
||||
activeProjectId: null,
|
||||
workspaces: [],
|
||||
};
|
||||
|
||||
describe('image workspace Main route boundary', () => {
|
||||
describe('AI design Main route boundary', () => {
|
||||
it('does not claim unrelated routes', async () => {
|
||||
const response = createResponse();
|
||||
const handled = await handleImageWorkspaceRoutes(
|
||||
@@ -58,38 +81,37 @@ describe('image workspace Main route boundary', () => {
|
||||
expect(response.res.end).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a stable unavailable response without fabricating cloud state', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
it('returns a stable unavailable response only when Main has no module', async () => {
|
||||
const response = createResponse();
|
||||
const handled = await handleImageWorkspaceRoutes(
|
||||
await handleImageWorkspaceRoutes(
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/image-workspace'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(response.statusCode).toBe(501);
|
||||
expect(response.json()).toEqual({
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
status: 501,
|
||||
code: 'IMAGE_WORKSPACE_UNAVAILABLE',
|
||||
error: '创作空间暂不可用',
|
||||
error: 'AI 设计暂不可用',
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('serves the local workspace only when Main injects the development service', async () => {
|
||||
const imageWorkspace = {
|
||||
getSnapshot: vi.fn().mockResolvedValue(localSnapshot),
|
||||
createProject: vi.fn().mockResolvedValue(localSnapshot),
|
||||
addAgent: vi.fn().mockResolvedValue(localSnapshot),
|
||||
sendMessage: vi.fn().mockResolvedValue(localSnapshot),
|
||||
uploadReference: vi.fn(),
|
||||
reset: vi.fn().mockResolvedValue(localSnapshot),
|
||||
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 } as unknown as HostApiContext;
|
||||
const ctx = { imageWorkspace: workspace } as unknown as HostApiContext;
|
||||
|
||||
const getResponse = createResponse();
|
||||
await handleImageWorkspaceRoutes(
|
||||
@@ -98,47 +120,99 @@ describe('image workspace Main route boundary', () => {
|
||||
new URL('http://127.0.0.1/api/works/image-workspace'),
|
||||
ctx,
|
||||
);
|
||||
expect(getResponse.statusCode).toBe(200);
|
||||
expect(getResponse.json()).toMatchObject({ success: true, workspace: localSnapshot });
|
||||
expect(getResponse.json()).toMatchObject({ success: true, data: bootstrap });
|
||||
|
||||
const createResponseBody = createResponse();
|
||||
await handleImageWorkspaceRoutes(
|
||||
createRequest('POST', { name: ' 测试项目 ' }),
|
||||
createRequest('POST', {
|
||||
clientWorkspaceId: 'client-one',
|
||||
title: '角色设计',
|
||||
}),
|
||||
createResponseBody.res,
|
||||
new URL('http://127.0.0.1/api/works/image-workspace/projects'),
|
||||
new URL('http://127.0.0.1/api/works/image-workspace/workspaces'),
|
||||
ctx,
|
||||
);
|
||||
expect(imageWorkspace.createProject).toHaveBeenCalledWith(' 测试项目 ');
|
||||
expect(workspace.createWorkspace).toHaveBeenCalledWith({
|
||||
clientWorkspaceId: 'client-one',
|
||||
title: '角色设计',
|
||||
});
|
||||
|
||||
const messageResponse = createResponse();
|
||||
await handleImageWorkspaceRoutes(
|
||||
createRequest('POST', {
|
||||
projectId: 'spoofed-project',
|
||||
agentId: 'agent-one',
|
||||
prompt: '继续编辑',
|
||||
referenceImageIds: ['reference-one'],
|
||||
settings: { outputCountId: '2' },
|
||||
clientTurnId: 'turn-one',
|
||||
expectedTurnRevision: 3,
|
||||
message: '继续编辑',
|
||||
attachmentAssetIds: [],
|
||||
modelId: 'must-be-ignored',
|
||||
}),
|
||||
messageResponse.res,
|
||||
new URL('http://127.0.0.1/api/works/image-workspace/projects/project%2Fone/messages'),
|
||||
new URL(
|
||||
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/messages',
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
expect(imageWorkspace.sendMessage).toHaveBeenCalledWith({
|
||||
projectId: 'project/one',
|
||||
agentId: 'agent-one',
|
||||
prompt: '继续编辑',
|
||||
referenceImageIds: ['reference-one'],
|
||||
settings: { outputCountId: '2' },
|
||||
expect(workspace.submitMessage).toHaveBeenCalledWith({
|
||||
workspaceId: 'workspace/one',
|
||||
clientTurnId: 'turn-one',
|
||||
expectedTurnRevision: 3,
|
||||
message: '继续编辑',
|
||||
attachmentAssetIds: [],
|
||||
});
|
||||
|
||||
const resetResponse = createResponse();
|
||||
const confirmResponse = createResponse();
|
||||
await handleImageWorkspaceRoutes(
|
||||
createRequest('DELETE'),
|
||||
resetResponse.res,
|
||||
new URL('http://127.0.0.1/api/works/image-workspace/local-data'),
|
||||
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(imageWorkspace.reset).toHaveBeenCalledOnce();
|
||||
expect(resetResponse.json()).toMatchObject({ workspace: { projects: [] } });
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,9 +19,10 @@ function createTemporaryDirectory(): string {
|
||||
|
||||
function createService(userDataDir: string) {
|
||||
let sequence = 0;
|
||||
let tick = 0;
|
||||
return new LocalImageWorkspace({
|
||||
userDataDir,
|
||||
now: () => new Date('2026-07-29T10:00:00.000Z'),
|
||||
now: () => new Date(Date.parse('2026-07-31T10:00:00.000Z') + tick++ * 1_000),
|
||||
createId: () => String(++sequence),
|
||||
});
|
||||
}
|
||||
@@ -32,137 +33,127 @@ afterEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('local image workspace', () => {
|
||||
it('routes the default development command through the local workspace adapter', () => {
|
||||
describe('local AI design workspace', () => {
|
||||
it('routes the default development command through the local adapter', () => {
|
||||
const packageJson = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
|
||||
expect(packageJson.scripts.dev).toBe('node scripts/run-local-image-workspace-dev.mjs');
|
||||
expect(packageJson.scripts['dev:image-workspace:local']).toBe('node scripts/run-local-image-workspace-dev.mjs');
|
||||
expect(packageJson.scripts['dev:image-workspace:local'])
|
||||
.toBe('node scripts/run-local-image-workspace-dev.mjs');
|
||||
});
|
||||
|
||||
it('enables local mode for an unpackaged development server and never for packaged apps', () => {
|
||||
expect(isLocalImageWorkspaceDevelopmentEnabled({ isPackaged: false, configuredMode: 'local' })).toBe(true);
|
||||
expect(isLocalImageWorkspaceDevelopmentEnabled({ isPackaged: false, isDevelopmentServer: true })).toBe(true);
|
||||
expect(isLocalImageWorkspaceDevelopmentEnabled({ isPackaged: false, isDevelopmentServer: false })).toBe(false);
|
||||
expect(isLocalImageWorkspaceDevelopmentEnabled({ isPackaged: false, configuredMode: 'cloud', isDevelopmentServer: true })).toBe(false);
|
||||
expect(isLocalImageWorkspaceDevelopmentEnabled({ isPackaged: true, configuredMode: 'local' })).toBe(false);
|
||||
it('never enables the local adapter in packaged apps or explicit cloud mode', () => {
|
||||
expect(isLocalImageWorkspaceDevelopmentEnabled({
|
||||
isPackaged: false,
|
||||
configuredMode: 'local',
|
||||
})).toBe(true);
|
||||
expect(isLocalImageWorkspaceDevelopmentEnabled({
|
||||
isPackaged: false,
|
||||
isDevelopmentServer: true,
|
||||
})).toBe(true);
|
||||
expect(isLocalImageWorkspaceDevelopmentEnabled({
|
||||
isPackaged: false,
|
||||
configuredMode: 'cloud',
|
||||
isDevelopmentServer: true,
|
||||
})).toBe(false);
|
||||
expect(isLocalImageWorkspaceDevelopmentEnabled({
|
||||
isPackaged: true,
|
||||
configuredMode: 'local',
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('starts without Agents, persists manually added Agents, and survives a new service instance', async () => {
|
||||
it('persists titled Workspaces and keeps creation idempotent by client key', async () => {
|
||||
const userDataDir = createTemporaryDirectory();
|
||||
const service = createService(userDataDir);
|
||||
|
||||
const emptyWorkspace = await service.getSnapshot();
|
||||
expect(emptyWorkspace).toMatchObject({
|
||||
projects: [],
|
||||
activeProjectId: null,
|
||||
await expect(service.bootstrap()).resolves.toMatchObject({ workspaces: [] });
|
||||
const created = await service.createWorkspace({
|
||||
clientWorkspaceId: 'client-one',
|
||||
title: ' 本地角色设计 ',
|
||||
});
|
||||
expect(emptyWorkspace).not.toHaveProperty('runtimeMode');
|
||||
|
||||
const created = await service.createProject(' 本地角色设计 ');
|
||||
expect(created.projects).toHaveLength(1);
|
||||
expect(created.projects[0]).toMatchObject({
|
||||
name: '本地角色设计',
|
||||
agents: [],
|
||||
messages: [],
|
||||
const replay = await service.createWorkspace({
|
||||
clientWorkspaceId: 'client-one',
|
||||
title: '不能覆盖标题',
|
||||
});
|
||||
|
||||
const withAgent = await service.addAgent(created.projects[0].id);
|
||||
const withSecondAgent = await service.addAgent(withAgent.projects[0].id);
|
||||
expect(withSecondAgent.projects[0].agents.map((agent) => agent.name)).toEqual([
|
||||
'创作 Agent 1',
|
||||
'创作 Agent 2',
|
||||
]);
|
||||
expect(replay.workspaceId).toBe(created.workspaceId);
|
||||
expect(replay.title).toBe('本地角色设计');
|
||||
await service.renameWorkspace({
|
||||
workspaceId: created.workspaceId,
|
||||
title: '角色设计第二版',
|
||||
});
|
||||
|
||||
const reloaded = new LocalImageWorkspace({ userDataDir });
|
||||
await expect(reloaded.getSnapshot()).resolves.toMatchObject({
|
||||
activeProjectId: created.projects[0].id,
|
||||
projects: [{ name: '本地角色设计', agents: [{}, {}] }],
|
||||
await expect(reloaded.bootstrap()).resolves.toMatchObject({
|
||||
workspaces: [{ title: '角色设计第二版', turnRevision: 0, viewRevision: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('persists reference uploads and creates deterministic multi-image placeholder turns', async () => {
|
||||
const userDataDir = createTemporaryDirectory();
|
||||
const service = createService(userDataDir);
|
||||
const created = await service.createProject('连续创作');
|
||||
const withAgent = await service.addAgent(created.projects[0].id);
|
||||
const project = withAgent.projects[0];
|
||||
const agent = project.agents[0];
|
||||
const upload = await service.uploadReference({
|
||||
projectId: project.id,
|
||||
fileName: '参考图.png',
|
||||
mimeType: 'image/png',
|
||||
contentBase64: Buffer.from('fake-png-bytes').toString('base64'),
|
||||
it('creates a Quote first and a visible generation task only after confirmation', async () => {
|
||||
const service = createService(createTemporaryDirectory());
|
||||
const created = await service.createWorkspace({
|
||||
clientWorkspaceId: 'client-flow',
|
||||
title: '海洋公益海报',
|
||||
});
|
||||
|
||||
expect(upload.reference).toMatchObject({
|
||||
alt: '参考图.png',
|
||||
url: expect.stringMatching(/^data:image\/png;base64,/),
|
||||
const discussed = await service.submitMessage({
|
||||
workspaceId: created.workspaceId,
|
||||
clientTurnId: 'turn-one',
|
||||
expectedTurnRevision: 0,
|
||||
message: '做一张保护海洋的竖版公益海报',
|
||||
});
|
||||
const quote = discussed.messages.at(-1)?.generationQuote;
|
||||
|
||||
const input = {
|
||||
projectId: project.id,
|
||||
agentId: agent.id,
|
||||
prompt: '夜色中的机械城堡',
|
||||
referenceImageIds: [upload.reference.id],
|
||||
settings: {
|
||||
modeId: 'edit',
|
||||
modelId: 'local-placeholder-v1',
|
||||
aspectRatioId: '16:9',
|
||||
resolutionId: '1024',
|
||||
outputCountId: '2',
|
||||
},
|
||||
};
|
||||
const firstTurn = await service.sendMessage(input);
|
||||
const firstOutput = firstTurn.projects[0].messages.at(-1)!;
|
||||
expect(discussed.phase).toBe('awaiting_confirmation');
|
||||
expect(quote).toMatchObject({ status: 'active', medium: 'image' });
|
||||
await expect(service.listTasks(created.workspaceId)).resolves.toEqual([]);
|
||||
|
||||
expect(firstTurn.projects[0].messages).toHaveLength(2);
|
||||
expect(firstTurn.projects[0].messages[0].images).toEqual([upload.reference]);
|
||||
expect(firstOutput).toMatchObject({
|
||||
role: 'assistant',
|
||||
const confirmed = await service.confirmGeneration({
|
||||
workspaceId: created.workspaceId,
|
||||
clientTurnId: 'turn-two',
|
||||
expectedTurnRevision: 1,
|
||||
quoteId: quote!.quoteId,
|
||||
});
|
||||
expect(confirmed.turnRevision).toBe(2);
|
||||
|
||||
const queued = await service.listTasks(created.workspaceId);
|
||||
const running = await service.listTasks(created.workspaceId);
|
||||
const succeeded = await service.listTasks(created.workspaceId);
|
||||
expect(queued[0].status).toBe('queued');
|
||||
expect(running[0].status).toBe('running');
|
||||
expect(succeeded[0]).toMatchObject({
|
||||
status: 'succeeded',
|
||||
text: '已生成 2 张图片。',
|
||||
resultAssets: [{ mimeType: 'image/svg+xml' }],
|
||||
});
|
||||
expect(firstOutput.images).toHaveLength(2);
|
||||
expect(firstOutput.images[0].url).toMatch(/^data:image\/svg\+xml;base64,/);
|
||||
const placeholderSvg = Buffer.from(firstOutput.images[0].url.split(',')[1], 'base64').toString('utf8');
|
||||
expect(placeholderSvg).toContain('CREATIVE · 1');
|
||||
expect(placeholderSvg).toContain('标准创作模型');
|
||||
expect(placeholderSvg).not.toMatch(/LOCAL DEV|local-placeholder-v1|本地开发|本地占位/);
|
||||
|
||||
const secondTurn = await service.sendMessage(input);
|
||||
const secondOutput = secondTurn.projects[0].messages.at(-1)!;
|
||||
expect(secondOutput.images.map((image) => image.url)).toEqual(
|
||||
firstOutput.images.map((image) => image.url),
|
||||
);
|
||||
|
||||
const reloaded = new LocalImageWorkspace({ userDataDir });
|
||||
expect((await reloaded.getSnapshot()).projects[0].messages).toHaveLength(4);
|
||||
const asset = succeeded[0].resultAssets[0];
|
||||
const response = await service.openAssetContent(created.workspaceId, asset.assetId);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toBe('image/svg+xml');
|
||||
expect(await response.text()).toContain('AI Design Workspace');
|
||||
});
|
||||
|
||||
it('rejects unknown option ids and removes all persisted data on reset', async () => {
|
||||
it('enforces turn CAS and removes only the isolated development data on reset', async () => {
|
||||
const userDataDir = createTemporaryDirectory();
|
||||
const service = createService(userDataDir);
|
||||
const created = await service.createProject('可重置项目');
|
||||
const withAgent = await service.addAgent(created.projects[0].id);
|
||||
const project = withAgent.projects[0];
|
||||
const created = await service.createWorkspace({
|
||||
clientWorkspaceId: 'client-reset',
|
||||
title: '可重置项目',
|
||||
});
|
||||
|
||||
await expect(service.sendMessage({
|
||||
projectId: project.id,
|
||||
agentId: project.agents[0].id,
|
||||
prompt: '测试',
|
||||
referenceImageIds: [],
|
||||
settings: { modelId: 'unknown-model' },
|
||||
await expect(service.submitMessage({
|
||||
workspaceId: created.workspaceId,
|
||||
clientTurnId: 'stale-turn',
|
||||
expectedTurnRevision: 3,
|
||||
message: '测试',
|
||||
})).rejects.toMatchObject<LocalImageWorkspaceError>({
|
||||
status: 400,
|
||||
code: 'IMAGE_WORKSPACE_INVALID_SETTING',
|
||||
status: 409,
|
||||
code: 'workspace_revision_conflict',
|
||||
});
|
||||
|
||||
expect(existsSync(getLocalImageWorkspaceDirectory(userDataDir))).toBe(true);
|
||||
await expect(service.reset()).resolves.toMatchObject({ projects: [], activeProjectId: null });
|
||||
await expect(service.reset()).resolves.toMatchObject({ workspaces: [] });
|
||||
expect(existsSync(getLocalImageWorkspaceDirectory(userDataDir))).toBe(false);
|
||||
await expect(new LocalImageWorkspace({ userDataDir }).getSnapshot()).resolves.toMatchObject({ projects: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,18 +73,15 @@ describe('Login page', () => {
|
||||
if (path === '/api/works/image-workspace') {
|
||||
return {
|
||||
success: true,
|
||||
workspace: {
|
||||
activeProjectId: null,
|
||||
status: 200,
|
||||
data: {
|
||||
capabilities: {
|
||||
modes: [],
|
||||
models: [],
|
||||
aspectRatios: [],
|
||||
resolutions: [],
|
||||
outputCounts: [],
|
||||
maxReferenceImages: 0,
|
||||
referenceUpload: { enabled: false, acceptedMimeTypes: [] },
|
||||
conversation: true,
|
||||
generation: true,
|
||||
image: true,
|
||||
video: true,
|
||||
},
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -97,7 +94,7 @@ describe('Login page', () => {
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('创建第一个项目')).toBeInTheDocument();
|
||||
expect(await screen.findByText('创建第一个设计项目')).toBeInTheDocument();
|
||||
expect(screen.queryByText('本地开发')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Continue in browser' })).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
@@ -363,48 +363,50 @@ describe('Sidebar project initialization flow', () => {
|
||||
expect(screen.queryByRole('button', { name: `配置项目 ${project.name}` })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches the sidebar to cloud projects and Agents inside the AI painting module', async () => {
|
||||
it('switches the sidebar to titled Design Workspaces inside the AI painting module', async () => {
|
||||
useAuthStore.setState({
|
||||
accessToken: 'cloud-token',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
});
|
||||
const workspace = {
|
||||
activeProjectId: 'cloud-project',
|
||||
capabilities: {
|
||||
modes: [],
|
||||
models: [],
|
||||
aspectRatios: [],
|
||||
resolutions: [],
|
||||
outputCounts: [],
|
||||
maxReferenceImages: 0,
|
||||
referenceUpload: { enabled: false, acceptedMimeTypes: [] },
|
||||
const summary = {
|
||||
workspaceId: 'cloud-project',
|
||||
title: '云端概念设计',
|
||||
turnRevision: 0,
|
||||
viewRevision: 0,
|
||||
phase: 'shaping',
|
||||
brief: {
|
||||
version: 0,
|
||||
status: 'draft',
|
||||
medium: null,
|
||||
summary: '正在建立作品的视觉方向',
|
||||
ready: false,
|
||||
missingDecision: '作品形式',
|
||||
},
|
||||
projects: [{
|
||||
id: 'cloud-project',
|
||||
name: '云端概念设计',
|
||||
activeAgentId: 'cloud-agent',
|
||||
agents: [{ id: 'cloud-agent', name: '概念设计 Agent' }],
|
||||
messages: [],
|
||||
}],
|
||||
updatedAt: '2026-07-31T10:00:00Z',
|
||||
};
|
||||
const bootstrap = {
|
||||
capabilities: {
|
||||
conversation: true,
|
||||
generation: true,
|
||||
image: true,
|
||||
video: true,
|
||||
},
|
||||
workspaces: [summary],
|
||||
};
|
||||
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
|
||||
if (path === '/api/works/image-workspace') return { success: true, workspace };
|
||||
if (path === '/api/works/image-workspace/projects' && init?.method === 'POST') {
|
||||
return { success: true, workspace };
|
||||
if (path === '/api/works/image-workspace') {
|
||||
return { success: true, data: bootstrap };
|
||||
}
|
||||
if (path === '/api/works/image-workspace/projects/cloud-project/agents' && init?.method === 'POST') {
|
||||
if (path === '/api/works/image-workspace/workspaces/cloud-project' && !init?.method) {
|
||||
return { success: true, data: { ...summary, messages: [] } };
|
||||
}
|
||||
if (path === '/api/works/image-workspace/workspaces/cloud-project/tasks') {
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
if (path === '/api/works/image-workspace/workspaces' && init?.method === 'POST') {
|
||||
return {
|
||||
success: true,
|
||||
workspace: {
|
||||
...workspace,
|
||||
projects: [{
|
||||
...workspace.projects[0],
|
||||
agents: [
|
||||
...workspace.projects[0].agents,
|
||||
{ id: 'cloud-agent-two', name: '新 Agent' },
|
||||
],
|
||||
}],
|
||||
},
|
||||
data: { ...summary, workspaceId: 'cloud-project-two', title: '角色设定', messages: [] },
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected path ${path}`);
|
||||
@@ -413,7 +415,6 @@ describe('Sidebar project initialization flow', () => {
|
||||
render(<MemoryRouter initialEntries={['/image-canvas']}><Sidebar /></MemoryRouter>);
|
||||
|
||||
expect(await screen.findByTestId('sidebar-image-project-cloud-project')).toHaveTextContent('云端概念设计');
|
||||
expect(screen.getByText('概念设计 Agent')).toBeInTheDocument();
|
||||
const createImageProject = screen.getByTestId('sidebar-create-image-project');
|
||||
expect(createImageProject).toBeEnabled();
|
||||
expect(createImageProject).toHaveClass('bg-transparent', 'border-0', 'shadow-none');
|
||||
@@ -421,12 +422,10 @@ describe('Sidebar project initialization flow', () => {
|
||||
expect(screen.queryByText('项目路径')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('项目模板')).not.toBeInTheDocument();
|
||||
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/opencode/projects')).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '为 云端概念设计 添加 Agent' }));
|
||||
expect(await screen.findByText('新 Agent')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/添加 Agent/)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('sidebar-create-image-project'));
|
||||
expect(screen.getByRole('dialog', { name: '新建项目' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog', { name: '新建设计项目' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('项目名称')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('项目路径')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('项目模板')).not.toBeInTheDocument();
|
||||
@@ -434,55 +433,65 @@ describe('Sidebar project initialization flow', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建项目' }));
|
||||
|
||||
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/works/image-workspace/projects',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'X-NianCode-Access-Token': 'cloud-token' },
|
||||
body: JSON.stringify({ name: '角色设定' }),
|
||||
},
|
||||
'/api/works/image-workspace/workspaces',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
));
|
||||
const createCall = hostApiFetchMock.mock.calls.find(
|
||||
([path, init]) => path === '/api/works/image-workspace/workspaces'
|
||||
&& (init as RequestInit | undefined)?.method === 'POST',
|
||||
);
|
||||
expect(JSON.parse(String((createCall?.[1] as RequestInit).body))).toMatchObject({
|
||||
title: '角色设定',
|
||||
clientWorkspaceId: expect.stringMatching(/^workspace-/),
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps production project copy while the development adapter bypasses cloud login', async () => {
|
||||
it('keeps identical project copy when Main injects the local development adapter', async () => {
|
||||
window.electron.imageWorkspaceLocalDevelopment = true;
|
||||
const adapterProject = {
|
||||
id: 'local-project',
|
||||
name: '概念设计',
|
||||
activeAgentId: 'local-agent',
|
||||
agents: [{ id: 'local-agent', name: '视觉创作 Agent' }],
|
||||
messages: [],
|
||||
};
|
||||
let workspace = {
|
||||
activeProjectId: adapterProject.id as string | null,
|
||||
capabilities: {
|
||||
modes: [],
|
||||
models: [],
|
||||
aspectRatios: [],
|
||||
resolutions: [],
|
||||
outputCounts: [],
|
||||
maxReferenceImages: 0,
|
||||
referenceUpload: { enabled: false, acceptedMimeTypes: [] },
|
||||
const summary = {
|
||||
workspaceId: 'local-project',
|
||||
title: '概念设计',
|
||||
turnRevision: 0,
|
||||
viewRevision: 0,
|
||||
phase: 'shaping',
|
||||
brief: {
|
||||
version: 0,
|
||||
status: 'draft',
|
||||
medium: null,
|
||||
summary: '正在建立作品的视觉方向',
|
||||
ready: false,
|
||||
missingDecision: '作品形式',
|
||||
},
|
||||
projects: [adapterProject],
|
||||
updatedAt: '2026-07-31T10:00:00Z',
|
||||
};
|
||||
let bootstrap = {
|
||||
capabilities: {
|
||||
conversation: true,
|
||||
generation: true,
|
||||
image: true,
|
||||
video: true,
|
||||
},
|
||||
workspaces: [summary],
|
||||
};
|
||||
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
|
||||
if (path === '/api/works/image-workspace' && !init?.method) {
|
||||
return { success: true, workspace };
|
||||
return { success: true, data: bootstrap };
|
||||
}
|
||||
if (path === '/api/works/image-workspace/projects' && init?.method === 'POST') {
|
||||
const createdProject = {
|
||||
id: 'local-project-two',
|
||||
name: '角色设计',
|
||||
activeAgentId: 'local-agent-two',
|
||||
agents: [{ id: 'local-agent-two', name: '视觉创作 Agent' }],
|
||||
if (path === '/api/works/image-workspace/workspaces/local-project' && !init?.method) {
|
||||
return { success: true, data: { ...summary, messages: [] } };
|
||||
}
|
||||
if (path === '/api/works/image-workspace/workspaces/local-project/tasks') {
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
if (path === '/api/works/image-workspace/workspaces' && init?.method === 'POST') {
|
||||
const created = {
|
||||
...summary,
|
||||
workspaceId: 'local-project-two',
|
||||
title: '角色设计',
|
||||
messages: [],
|
||||
};
|
||||
workspace = {
|
||||
...workspace,
|
||||
activeProjectId: createdProject.id,
|
||||
projects: [...workspace.projects, createdProject],
|
||||
};
|
||||
return { success: true, workspace };
|
||||
bootstrap = { ...bootstrap, workspaces: [created, ...bootstrap.workspaces] };
|
||||
return { success: true, data: created };
|
||||
}
|
||||
throw new Error(`Unexpected path ${path}`);
|
||||
});
|
||||
@@ -490,7 +499,7 @@ describe('Sidebar project initialization flow', () => {
|
||||
const { container } = render(<MemoryRouter initialEntries={['/image-canvas']}><Sidebar /></MemoryRouter>);
|
||||
|
||||
expect(await screen.findByTestId('sidebar-image-project-local-project')).toHaveTextContent('概念设计');
|
||||
expect(screen.getByTestId('sidebar-create-image-project')).toHaveTextContent('新建项目');
|
||||
expect(screen.getByTestId('sidebar-create-image-project')).toHaveTextContent('新建设计项目');
|
||||
expect(screen.getByTestId('sidebar-image-projects')).toBeInTheDocument();
|
||||
expect(container).not.toHaveTextContent('本地开发');
|
||||
expect(container).not.toHaveTextContent('本地项目');
|
||||
@@ -498,15 +507,12 @@ describe('Sidebar project initialization flow', () => {
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/image-workspace', {});
|
||||
|
||||
fireEvent.click(screen.getByTestId('sidebar-create-image-project'));
|
||||
expect(screen.getByRole('dialog', { name: '新建项目' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog', { name: '新建设计项目' })).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '角色设计' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建项目' }));
|
||||
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/works/image-workspace/projects',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: '角色设计' }),
|
||||
},
|
||||
'/api/works/image-workspace/workspaces',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
));
|
||||
expect(await screen.findByText('角色设计')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
196
tests/unit/works-square-design-workspace.test.ts
Normal file
196
tests/unit/works-square-design-workspace.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { WorksSquareDesignWorkspace } from '@electron/image-workspace/works-square-workspace';
|
||||
import { getValidWorksSquareAccessToken } from '@electron/services/works-square-session';
|
||||
|
||||
vi.mock('@electron/services/works-square-session', () => ({
|
||||
getValidWorksSquareAccessToken: vi.fn(),
|
||||
}));
|
||||
|
||||
const getTokenMock = vi.mocked(getValidWorksSquareAccessToken);
|
||||
|
||||
const serverWorkspace = {
|
||||
workspace_id: 'workspace-one',
|
||||
title: '海洋公益海报',
|
||||
turn_revision: 1,
|
||||
view_revision: 2,
|
||||
phase: 'awaiting_confirmation',
|
||||
brief: {
|
||||
version: 1,
|
||||
status: 'ready',
|
||||
medium: 'image',
|
||||
summary: '保护海洋的竖版公益海报',
|
||||
ready: true,
|
||||
missing_decision: null,
|
||||
},
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
kind: 'confirmation',
|
||||
text: '方向已经明确,是否开始生成?',
|
||||
quick_replies: ['确认生成'],
|
||||
generation_quote: {
|
||||
quote_id: 'quote-one',
|
||||
status: 'active',
|
||||
medium: 'image',
|
||||
brief_version: 1,
|
||||
brief_summary: '保护海洋的竖版公益海报',
|
||||
quoted_design_points: 1,
|
||||
expires_at: '2026-07-31T11:00:00Z',
|
||||
},
|
||||
turn_revision: 1,
|
||||
created_at: '2026-07-31T10:00:00Z',
|
||||
}],
|
||||
updated_at: '2026-07-31T10:00:00Z',
|
||||
};
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('Works Square AI design adapter', () => {
|
||||
beforeEach(() => {
|
||||
getTokenMock.mockReset();
|
||||
getTokenMock.mockResolvedValue('access-one');
|
||||
});
|
||||
|
||||
it('maps the server snake_case Workspace contract into the shared client model', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
conversation: true,
|
||||
generation: true,
|
||||
image: true,
|
||||
video: false,
|
||||
}))
|
||||
.mockResolvedValueOnce(jsonResponse([serverWorkspace]));
|
||||
const adapter = new WorksSquareDesignWorkspace({
|
||||
apiBaseUrl: 'https://square.example',
|
||||
fetchImpl: fetchMock,
|
||||
});
|
||||
|
||||
await expect(adapter.bootstrap()).resolves.toMatchObject({
|
||||
capabilities: { conversation: true, image: true, video: false },
|
||||
workspaces: [{
|
||||
workspaceId: 'workspace-one',
|
||||
title: '海洋公益海报',
|
||||
turnRevision: 1,
|
||||
viewRevision: 2,
|
||||
brief: { missingDecision: null },
|
||||
}],
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls.every(([, init]) => (
|
||||
(init?.headers as Record<string, string>).Authorization === 'Bearer access-one'
|
||||
))).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps task creation behind structured Quote confirmation', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(serverWorkspace));
|
||||
const adapter = new WorksSquareDesignWorkspace({
|
||||
apiBaseUrl: 'https://square.example/',
|
||||
fetchImpl: fetchMock,
|
||||
});
|
||||
|
||||
const workspace = await adapter.confirmGeneration({
|
||||
workspaceId: 'workspace-one',
|
||||
clientTurnId: 'turn-two',
|
||||
expectedTurnRevision: 1,
|
||||
quoteId: 'quote-one',
|
||||
});
|
||||
|
||||
expect(workspace.workspaceId).toBe('workspace-one');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://square.example/api/design/workspaces/workspace-one/turns',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
client_turn_id: 'turn-two',
|
||||
expected_turn_revision: 1,
|
||||
message: '确认生成',
|
||||
attachment_asset_ids: [],
|
||||
action: { type: 'confirm_generation', quote_id: 'quote-one' },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('maps stable generation tasks and private asset relay paths', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse([{
|
||||
task_id: 'task-one',
|
||||
workspace_id: 'workspace-one',
|
||||
medium: 'image',
|
||||
status: 'succeeded',
|
||||
brief_version: 1,
|
||||
brief_summary: '海洋公益海报',
|
||||
quote_id: 'quote-one',
|
||||
quoted_design_points: 1,
|
||||
failure_code: null,
|
||||
result_assets: [{
|
||||
asset_id: 'asset-one',
|
||||
media_type: 'image',
|
||||
mime_type: 'image/png',
|
||||
width: 1024,
|
||||
height: 1280,
|
||||
duration_milliseconds: null,
|
||||
created_at: '2026-07-31T10:01:00Z',
|
||||
}],
|
||||
created_at: '2026-07-31T10:00:00Z',
|
||||
updated_at: '2026-07-31T10:01:00Z',
|
||||
}]));
|
||||
const adapter = new WorksSquareDesignWorkspace({
|
||||
apiBaseUrl: 'https://square.example',
|
||||
fetchImpl: fetchMock,
|
||||
});
|
||||
|
||||
await expect(adapter.listTasks('workspace-one')).resolves.toMatchObject([{
|
||||
taskId: 'task-one',
|
||||
status: 'succeeded',
|
||||
resultAssets: [{
|
||||
assetId: 'asset-one',
|
||||
contentPath: '/api/works/image-workspace/workspaces/workspace-one/assets/asset-one/content',
|
||||
}],
|
||||
}]);
|
||||
});
|
||||
|
||||
it('refreshes the Main-owned session once after an upstream 401 and preserves Range', async () => {
|
||||
getTokenMock
|
||||
.mockResolvedValueOnce('expired-token')
|
||||
.mockResolvedValueOnce('fresh-token');
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
||||
.mockResolvedValueOnce(new Response('partial', {
|
||||
status: 206,
|
||||
headers: {
|
||||
'Content-Type': 'video/mp4',
|
||||
'Content-Range': 'bytes 0-6/100',
|
||||
},
|
||||
}));
|
||||
const adapter = new WorksSquareDesignWorkspace({
|
||||
apiBaseUrl: 'https://square.example',
|
||||
fetchImpl: fetchMock,
|
||||
});
|
||||
|
||||
const response = await adapter.openAssetContent(
|
||||
'workspace-one',
|
||||
'asset-one',
|
||||
'bytes=0-6',
|
||||
);
|
||||
|
||||
expect(response.status).toBe(206);
|
||||
expect(getTokenMock).toHaveBeenNthCalledWith(2, {
|
||||
fetchImpl: fetchMock,
|
||||
forceRefresh: true,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.stringContaining('/assets/asset-one/content'),
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Range: 'bytes=0-6',
|
||||
Authorization: 'Bearer fresh-token',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user