完善 AI 设计资产预览与视频首帧选择
需求:生成图片需要支持大图查看和本地下载;制作视频时需要从当前项目作品选择首帧,或上传本地图片。 实现:新增首帧选择弹窗、JPEG/PNG/WebP 有界上传、附件 Asset ID 透传、Main 到服务端 multipart 转发,并保留上传失败重试与私有图片下载链路。 验证:相关 57 项单测、TypeScript 类型检查、目标 ESLint 和 Vite 生产构建全部通过。
This commit is contained in:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user