完善 AI 设计资产预览与视频首帧选择
需求:生成图片需要支持大图查看和本地下载;制作视频时需要从当前项目作品选择首帧,或上传本地图片。 实现:新增首帧选择弹窗、JPEG/PNG/WebP 有界上传、附件 Asset ID 透传、Main 到服务端 multipart 转发,并保留上传失败重试与私有图片下载链路。 验证:相关 57 项单测、TypeScript 类型检查、目标 ESLint 和 Vite 生产构建全部通过。
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ImageWorkspaceApiError } from '@/lib/image-workspace';
|
||||
@@ -20,6 +20,8 @@ const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn());
|
||||
const confirmImageWorkspaceGenerationMock = vi.hoisted(() => vi.fn());
|
||||
const openImageWorkspaceTaskEventsMock = vi.hoisted(() => vi.fn());
|
||||
const resolveImageWorkspaceAssetUrlMock = vi.hoisted(() => vi.fn());
|
||||
const saveImageWorkspaceAssetMock = vi.hoisted(() => vi.fn());
|
||||
const uploadImageWorkspaceAssetMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
type EventListener = (event: MessageEvent<string>) => void;
|
||||
|
||||
@@ -66,6 +68,12 @@ vi.mock('@/lib/image-workspace', async (importOriginal) => {
|
||||
resolveImageWorkspaceAssetUrl: (...args: unknown[]) => (
|
||||
resolveImageWorkspaceAssetUrlMock(...args)
|
||||
),
|
||||
saveImageWorkspaceAsset: (...args: unknown[]) => (
|
||||
saveImageWorkspaceAssetMock(...args)
|
||||
),
|
||||
uploadImageWorkspaceAsset: (...args: unknown[]) => (
|
||||
uploadImageWorkspaceAssetMock(...args)
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -177,6 +185,11 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
resolveImageWorkspaceAssetUrlMock.mockResolvedValue(
|
||||
'http://127.0.0.1:13210/content?token=host',
|
||||
);
|
||||
saveImageWorkspaceAssetMock.mockResolvedValue({ status: 'saved' });
|
||||
uploadImageWorkspaceAssetMock.mockResolvedValue({
|
||||
...taskFixture.resultAssets[0],
|
||||
assetId: 'asset-uploaded',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an honest unavailable state without fabricating projects', async () => {
|
||||
@@ -260,6 +273,42 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
expect(screen.queryByText(/当前 Agent/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens a generated image at full size and saves it through Main', async () => {
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '放大查看生成图片' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '图片预览' });
|
||||
expect(dialog).toHaveTextContent('1024 × 1024');
|
||||
expect(screen.getByRole('img', { name: 'AI 设计大图预览' }))
|
||||
.toHaveAttribute('src', 'http://127.0.0.1:13210/content?token=host');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '下载原图' }));
|
||||
|
||||
await waitFor(() => expect(saveImageWorkspaceAssetMock).toHaveBeenCalledWith(
|
||||
taskFixture.resultAssets[0],
|
||||
));
|
||||
});
|
||||
|
||||
it('announces the image download progress while Main is saving', async () => {
|
||||
let finishSaving: ((value: { status: 'saved' }) => void) | undefined;
|
||||
saveImageWorkspaceAssetMock.mockReturnValue(new Promise((resolve) => {
|
||||
finishSaving = resolve;
|
||||
}));
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '下载生成图片' }));
|
||||
|
||||
const savingButton = screen.getByRole('button', { name: '正在保存生成图片' });
|
||||
expect(savingButton).toBeDisabled();
|
||||
expect(savingButton).toHaveAttribute('aria-busy', 'true');
|
||||
|
||||
finishSaving?.({ status: 'saved' });
|
||||
await waitFor(() => expect(
|
||||
screen.getByRole('button', { name: '下载生成图片' }),
|
||||
).toBeEnabled());
|
||||
});
|
||||
|
||||
it('keeps the task list visible in a 1404px desktop workspace', async () => {
|
||||
const previousWidth = window.innerWidth;
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1404 });
|
||||
@@ -482,6 +531,118 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens the successful work picker when the Agent asks for a video first frame', async () => {
|
||||
fetchImageWorkspaceProjectMock.mockResolvedValueOnce({
|
||||
...workspaceFixture(),
|
||||
phase: 'shaping',
|
||||
brief: {
|
||||
version: 2,
|
||||
status: 'draft',
|
||||
medium: 'video',
|
||||
summary: '让海洋公益海报动起来',
|
||||
ready: false,
|
||||
missingDecision: '请选择首帧来源',
|
||||
},
|
||||
messages: [
|
||||
...workspaceFixture().messages,
|
||||
{
|
||||
id: 'message-video-source',
|
||||
role: 'assistant',
|
||||
kind: 'choice',
|
||||
text: '请选择视频首帧来源。',
|
||||
quickReplies: ['先生成一张首帧图片(推荐)', '从作品列表选择图片'],
|
||||
generationQuote: null,
|
||||
turnRevision: 2,
|
||||
createdAt: '2026-08-06T09:00:00Z',
|
||||
},
|
||||
],
|
||||
} satisfies DesignWorkspace);
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' }));
|
||||
|
||||
const picker = screen.getByRole('dialog', { name: '选择视频首帧' });
|
||||
expect(await within(picker).findByRole('img', { name: '可选作品图片' }))
|
||||
.toHaveAttribute('src', 'http://127.0.0.1:13210/content?token=host');
|
||||
expect(within(picker).getByRole('button', { name: '选择此图片' })).toBeEnabled();
|
||||
expect(within(picker).getByLabelText('上传本地图片')).toBeInTheDocument();
|
||||
expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits a successful work Asset id when it is selected as the video first frame', async () => {
|
||||
fetchImageWorkspaceProjectMock.mockResolvedValueOnce({
|
||||
...workspaceFixture(),
|
||||
turnRevision: 2,
|
||||
messages: [
|
||||
...workspaceFixture().messages,
|
||||
{
|
||||
id: 'message-video-source',
|
||||
role: 'assistant',
|
||||
kind: 'choice',
|
||||
text: '请选择视频首帧来源。',
|
||||
quickReplies: ['从作品列表选择图片'],
|
||||
generationQuote: null,
|
||||
turnRevision: 2,
|
||||
createdAt: '2026-08-06T09:00:00Z',
|
||||
},
|
||||
],
|
||||
} satisfies DesignWorkspace);
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' }));
|
||||
|
||||
const selectButton = within(
|
||||
screen.getByRole('dialog', { name: '选择视频首帧' }),
|
||||
).getByRole('button', { name: '选择此图片' });
|
||||
await waitFor(() => expect(selectButton).toBeEnabled());
|
||||
fireEvent.click(selectButton);
|
||||
|
||||
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
|
||||
'workspace-cloud',
|
||||
2,
|
||||
'使用作品图片作为视频首帧',
|
||||
expect.stringMatching(/^turn-/),
|
||||
['asset-one'],
|
||||
));
|
||||
});
|
||||
|
||||
it('uploads a local first-frame image and submits its Asset id to the Agent', async () => {
|
||||
fetchImageWorkspaceProjectMock.mockResolvedValueOnce({
|
||||
...workspaceFixture(),
|
||||
messages: [
|
||||
...workspaceFixture().messages,
|
||||
{
|
||||
id: 'message-video-source',
|
||||
role: 'assistant',
|
||||
kind: 'choice',
|
||||
text: '请选择视频首帧来源。',
|
||||
quickReplies: ['从作品列表选择图片'],
|
||||
generationQuote: null,
|
||||
turnRevision: 2,
|
||||
createdAt: '2026-08-06T09:00:00Z',
|
||||
},
|
||||
],
|
||||
turnRevision: 2,
|
||||
} satisfies DesignWorkspace);
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' }));
|
||||
const picker = screen.getByRole('dialog', { name: '选择视频首帧' });
|
||||
const file = new File(['image-bytes'], 'ocean-poster.png', { type: 'image/png' });
|
||||
|
||||
fireEvent.change(within(picker).getByLabelText('上传本地图片'), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
|
||||
await waitFor(() => expect(uploadImageWorkspaceAssetMock)
|
||||
.toHaveBeenCalledWith('workspace-cloud', file));
|
||||
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
|
||||
'workspace-cloud',
|
||||
2,
|
||||
'使用上传的图片作为视频首帧',
|
||||
expect.stringMatching(/^turn-/),
|
||||
['asset-uploaded'],
|
||||
));
|
||||
});
|
||||
|
||||
it('renders policy-blocked generation tasks with a user-facing design message', async () => {
|
||||
fetchImageWorkspaceTasksMock.mockResolvedValue([{
|
||||
...taskFixture,
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
ImageWorkspaceApiError,
|
||||
openImageWorkspaceTaskEvents,
|
||||
resolveImageWorkspaceAssetUrl,
|
||||
saveImageWorkspaceAsset,
|
||||
sendImageWorkspaceMessage,
|
||||
uploadImageWorkspaceAsset,
|
||||
} from '@/lib/image-workspace';
|
||||
|
||||
vi.mock('@/lib/host-api', () => ({
|
||||
@@ -99,6 +101,52 @@ describe('AI design renderer API boundary', () => {
|
||||
expect(String(init?.body)).not.toMatch(/model|resolution|outputCount/);
|
||||
});
|
||||
|
||||
it('sends the selected first-frame Asset id with the conversation turn', async () => {
|
||||
await sendImageWorkspaceMessage(
|
||||
'workspace-one',
|
||||
5,
|
||||
'使用作品图片作为视频首帧',
|
||||
'turn-first-frame',
|
||||
['asset-first-frame'],
|
||||
);
|
||||
|
||||
const [, init] = hostApiFetchMock.mock.calls[0];
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
clientTurnId: 'turn-first-frame',
|
||||
expectedTurnRevision: 5,
|
||||
attachmentAssetIds: ['asset-first-frame'],
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads a supported local image through the authenticated Main boundary', async () => {
|
||||
const asset = {
|
||||
assetId: 'asset-uploaded',
|
||||
workspaceId: 'workspace-one',
|
||||
mediaType: 'image' as const,
|
||||
mimeType: 'image/png',
|
||||
width: 1200,
|
||||
height: 800,
|
||||
durationMilliseconds: null,
|
||||
createdAt: '2026-08-06T10:00:00Z',
|
||||
contentPath: '/api/works/image-workspace/workspaces/workspace-one/assets/asset-uploaded/content',
|
||||
};
|
||||
hostApiFetchMock.mockResolvedValueOnce({ success: true, status: 200, data: asset });
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'poster.webp', {
|
||||
type: 'image/webp',
|
||||
});
|
||||
|
||||
await expect(uploadImageWorkspaceAsset('workspace-one', file)).resolves.toEqual(asset);
|
||||
|
||||
const [path, init] = hostApiFetchMock.mock.calls[0];
|
||||
expect(path).toBe('/api/works/image-workspace/workspaces/workspace-one/assets');
|
||||
expect(init).toMatchObject({ method: 'POST' });
|
||||
expect(JSON.parse(String(init?.body))).toEqual({
|
||||
fileName: 'poster.webp',
|
||||
mimeType: 'image/webp',
|
||||
dataBase64: 'AQID',
|
||||
});
|
||||
});
|
||||
|
||||
it('confirms a Quote through a separate structured action route', async () => {
|
||||
await confirmImageWorkspaceGeneration('workspace-one', 5, 'quote/one');
|
||||
|
||||
@@ -132,4 +180,35 @@ describe('AI design renderer API boundary', () => {
|
||||
'http://127.0.0.1:13210/api/works/image-workspace/assets/one?token=host-token',
|
||||
);
|
||||
});
|
||||
|
||||
it('asks Main to save a private image asset without exposing its cloud URL', async () => {
|
||||
hostApiFetchMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
status: 200,
|
||||
data: { status: 'saved' },
|
||||
});
|
||||
|
||||
await expect(saveImageWorkspaceAsset({
|
||||
assetId: 'asset/one',
|
||||
workspaceId: 'workspace/one',
|
||||
mediaType: 'image',
|
||||
mimeType: 'image/png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
durationMilliseconds: null,
|
||||
createdAt: '2026-08-05T12:00:00Z',
|
||||
contentPath: '/api/works/image-workspace/private/content',
|
||||
})).resolves.toEqual({ status: 'saved' });
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/works/image-workspace/workspaces/workspace%2Fone/assets/asset%2Fone/download',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
defaultFileName: 'Makelore-AI-Design-asset-one.png',
|
||||
}),
|
||||
},
|
||||
);
|
||||
expect(JSON.stringify(hostApiFetchMock.mock.calls[0])).not.toContain('private/content');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { handleImageWorkspaceRoutes } from '@electron/api/routes/image-workspace';
|
||||
import type { HostApiContext } from '@electron/api/context';
|
||||
|
||||
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 = {
|
||||
@@ -68,6 +81,21 @@ const bootstrap = {
|
||||
};
|
||||
|
||||
describe('AI design Main route boundary', () => {
|
||||
let temporaryDirectory: string | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
electronMocks.getPath.mockReset();
|
||||
electronMocks.getPath.mockReturnValue(tmpdir());
|
||||
electronMocks.showSaveDialog.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (temporaryDirectory) {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
temporaryDirectory = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not claim unrelated routes', async () => {
|
||||
const response = createResponse();
|
||||
const handled = await handleImageWorkspaceRoutes(
|
||||
@@ -107,6 +135,7 @@ describe('AI design Main route boundary', () => {
|
||||
submitMessage: vi.fn().mockResolvedValue({ workspaceId: 'workspace/one' }),
|
||||
confirmGeneration: vi.fn().mockResolvedValue({ workspaceId: 'workspace/one' }),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
uploadAsset: vi.fn().mockResolvedValue({ assetId: 'asset-uploaded' }),
|
||||
openAssetContent: vi.fn(),
|
||||
getCapabilities: vi.fn(),
|
||||
reset: vi.fn().mockResolvedValue(bootstrap),
|
||||
@@ -160,6 +189,30 @@ describe('AI design Main route boundary', () => {
|
||||
attachmentAssetIds: [],
|
||||
});
|
||||
|
||||
const uploadResponse = createResponse();
|
||||
await handleImageWorkspaceRoutes(
|
||||
createRequest('POST', {
|
||||
fileName: 'poster.webp',
|
||||
mimeType: 'image/webp',
|
||||
dataBase64: 'AQID',
|
||||
}),
|
||||
uploadResponse.res,
|
||||
new URL(
|
||||
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/assets',
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
expect(workspace.uploadAsset).toHaveBeenCalledWith({
|
||||
workspaceId: 'workspace/one',
|
||||
fileName: 'poster.webp',
|
||||
mimeType: 'image/webp',
|
||||
bytes: Buffer.from([1, 2, 3]),
|
||||
});
|
||||
expect(uploadResponse.json()).toMatchObject({
|
||||
success: true,
|
||||
data: { assetId: 'asset-uploaded' },
|
||||
});
|
||||
|
||||
const confirmResponse = createResponse();
|
||||
await handleImageWorkspaceRoutes(
|
||||
createRequest('POST', {
|
||||
@@ -216,6 +269,116 @@ describe('AI design Main route boundary', () => {
|
||||
expect(Buffer.concat(response.chunks).toString()).toBe('partial');
|
||||
});
|
||||
|
||||
it('streams a private image asset into the native save location', async () => {
|
||||
temporaryDirectory = await mkdtemp(join(tmpdir(), 'makelore-design-download-'));
|
||||
const savedPath = join(temporaryDirectory, 'design.png');
|
||||
electronMocks.showSaveDialog.mockResolvedValue({ canceled: false, filePath: savedPath });
|
||||
const openAssetContent = vi.fn().mockResolvedValue(new Response('image-bytes', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'image/png' },
|
||||
}));
|
||||
const response = createResponse();
|
||||
|
||||
await handleImageWorkspaceRoutes(
|
||||
createRequest('POST', { defaultFileName: 'Ocean poster.png' }),
|
||||
response.res,
|
||||
new URL(
|
||||
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/assets/asset%2Fone/download',
|
||||
),
|
||||
{ imageWorkspace: { openAssetContent } } as unknown as HostApiContext,
|
||||
);
|
||||
|
||||
expect(electronMocks.showSaveDialog).toHaveBeenCalledWith(expect.objectContaining({
|
||||
defaultPath: expect.stringContaining('Ocean poster.png'),
|
||||
}));
|
||||
expect(openAssetContent).toHaveBeenCalledWith('workspace/one', 'asset/one');
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
data: { status: 'saved' },
|
||||
});
|
||||
await expect(readFile(savedPath, 'utf8')).resolves.toBe('image-bytes');
|
||||
});
|
||||
|
||||
it('preserves an existing destination when the private image stream fails', async () => {
|
||||
temporaryDirectory = await mkdtemp(join(tmpdir(), 'makelore-design-download-'));
|
||||
const savedPath = join(temporaryDirectory, 'existing.png');
|
||||
await writeFile(savedPath, 'original');
|
||||
electronMocks.showSaveDialog.mockResolvedValue({ canceled: false, filePath: savedPath });
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('partial'));
|
||||
controller.error(new Error('connection lost'));
|
||||
},
|
||||
});
|
||||
const response = createResponse();
|
||||
|
||||
await handleImageWorkspaceRoutes(
|
||||
createRequest('POST', { defaultFileName: 'Ocean poster.png' }),
|
||||
response.res,
|
||||
new URL(
|
||||
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace-one/assets/asset-one/download',
|
||||
),
|
||||
{
|
||||
imageWorkspace: {
|
||||
openAssetContent: vi.fn().mockResolvedValue(new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'image/png' },
|
||||
})),
|
||||
},
|
||||
} as unknown as HostApiContext,
|
||||
);
|
||||
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
code: 'DESIGN_ASSET_SAVE_FAILED',
|
||||
});
|
||||
await expect(readFile(savedPath, 'utf8')).resolves.toBe('original');
|
||||
});
|
||||
|
||||
it('does not fetch private bytes when the native save dialog is cancelled', async () => {
|
||||
electronMocks.showSaveDialog.mockResolvedValue({ canceled: true });
|
||||
const openAssetContent = vi.fn();
|
||||
const response = createResponse();
|
||||
|
||||
await handleImageWorkspaceRoutes(
|
||||
createRequest('POST', { defaultFileName: 'Ocean poster.png' }),
|
||||
response.res,
|
||||
new URL(
|
||||
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace-one/assets/asset-one/download',
|
||||
),
|
||||
{ imageWorkspace: { openAssetContent } } as unknown as HostApiContext,
|
||||
);
|
||||
|
||||
expect(openAssetContent).not.toHaveBeenCalled();
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
data: { status: 'cancelled' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an oversized image upload before reading it into the Main process', async () => {
|
||||
const uploadAsset = vi.fn();
|
||||
const request = createRequest('POST', {});
|
||||
request.headers['content-length'] = String(14 * 1024 * 1024);
|
||||
const response = createResponse();
|
||||
|
||||
await handleImageWorkspaceRoutes(
|
||||
request,
|
||||
response.res,
|
||||
new URL(
|
||||
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace-one/assets',
|
||||
),
|
||||
{ imageWorkspace: { uploadAsset } } as unknown as HostApiContext,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(413);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
code: 'design_asset_upload_too_large',
|
||||
});
|
||||
expect(uploadAsset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('relays normalized task events over local SSE and forwards the opaque resume cursor', async () => {
|
||||
const close = vi.fn();
|
||||
const openWorkspaceEvents = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -223,6 +223,46 @@ describe('Works Square AI design adapter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads a local reference image as multipart data and maps the returned Asset', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(jsonResponse({
|
||||
asset_id: 'asset-uploaded',
|
||||
media_type: 'image',
|
||||
mime_type: 'image/png',
|
||||
width: 1200,
|
||||
height: 800,
|
||||
duration_milliseconds: null,
|
||||
created_at: '2026-08-06T10:00:00Z',
|
||||
}));
|
||||
const adapter = new WorksSquareDesignWorkspace({
|
||||
apiBaseUrl: 'https://square.example',
|
||||
fetchImpl: fetchMock,
|
||||
});
|
||||
|
||||
await expect(adapter.uploadAsset({
|
||||
workspaceId: 'workspace-one',
|
||||
fileName: 'poster.webp',
|
||||
mimeType: 'image/webp',
|
||||
bytes: new Uint8Array([1, 2, 3]),
|
||||
})).resolves.toMatchObject({
|
||||
assetId: 'asset-uploaded',
|
||||
workspaceId: 'workspace-one',
|
||||
mimeType: 'image/png',
|
||||
contentPath: '/api/works/image-workspace/workspaces/workspace-one/assets/asset-uploaded/content',
|
||||
});
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://square.example/api/design/workspaces/workspace-one/assets');
|
||||
expect(init?.method).toBe('POST');
|
||||
expect((init?.headers as Record<string, string>).Authorization).toBe('Bearer access-one');
|
||||
expect((init?.headers as Record<string, string>)['Content-Type']).toBeUndefined();
|
||||
const form = init?.body as FormData;
|
||||
expect(form.get('file')).toBeInstanceOf(File);
|
||||
expect((form.get('file') as File).name).toBe('poster.webp');
|
||||
expect(await (form.get('file') as File).arrayBuffer()).toEqual(
|
||||
Uint8Array.from([1, 2, 3]).buffer,
|
||||
);
|
||||
});
|
||||
|
||||
it('completes a design turn from the connected WebSocket without polling the run endpoint', async () => {
|
||||
const { sockets, webSocketFactory } = scriptedSockets([{ open: true }]);
|
||||
const fetchMock = vi.fn<typeof fetch>(async (input) => {
|
||||
|
||||
Reference in New Issue
Block a user