import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { getLocalImageWorkspaceDirectory, isLocalImageWorkspaceDevelopmentEnabled, LocalImageWorkspace, LocalImageWorkspaceError, } from '@electron/image-workspace/local-workspace'; const temporaryDirectories: string[] = []; function createTemporaryDirectory(): string { const directory = mkdtempSync(join(tmpdir(), 'niancode-image-workspace-')); temporaryDirectories.push(directory); return directory; } function createService(userDataDir: string) { let sequence = 0; let tick = 0; return new LocalImageWorkspace({ userDataDir, now: () => new Date(Date.parse('2026-07-31T10:00:00.000Z') + tick++ * 1_000), createId: () => String(++sequence), }); } afterEach(() => { for (const directory of temporaryDirectories.splice(0)) { rmSync(directory, { recursive: true, force: true }); } }); describe('local AI design workspace', () => { it('routes the default development command through the cloud adapter and keeps local mode explicit', () => { const packageJson = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')) as { scripts: Record; }; expect(packageJson.scripts.dev).toBe('node scripts/run-cloud-image-workspace-dev.mjs'); expect(packageJson.scripts['dev:image-workspace:local']) .toBe('node scripts/run-local-image-workspace-dev.mjs'); }); 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('persists titled Workspaces and keeps creation idempotent by client key', async () => { const userDataDir = createTemporaryDirectory(); const service = createService(userDataDir); await expect(service.bootstrap()).resolves.toMatchObject({ workspaces: [] }); const created = await service.createWorkspace({ clientWorkspaceId: 'client-one', title: ' 本地角色设计 ', }); const replay = await service.createWorkspace({ clientWorkspaceId: 'client-one', title: '不能覆盖标题', }); 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.bootstrap()).resolves.toMatchObject({ workspaces: [{ title: '角色设计第二版', conversationCount: 1, viewRevision: 1 }], }); }); it('migrates a legacy single conversation into schema v3 without losing project tasks', async () => { const userDataDir = createTemporaryDirectory(); const rootDirectory = getLocalImageWorkspaceDirectory(userDataDir); const timestamp = '2026-07-31T10:00:00.000Z'; mkdirSync(rootDirectory, { recursive: true }); writeFileSync(join(rootDirectory, 'design-workspace-v2.json'), JSON.stringify({ schemaVersion: 2, workspaces: [{ workspaceId: 'workspace-legacy', clientWorkspaceId: 'client-legacy', title: '历史设计项目', turnRevision: 2, viewRevision: 4, phase: 'awaiting_confirmation', brief: { version: 2, status: 'ready', medium: 'image', summary: '历史海报', ready: true, missingDecision: null, }, messages: [{ id: 'message-legacy', role: 'user', kind: 'user', text: '保留这条历史消息', quickReplies: [], generationQuote: null, turnRevision: 1, createdAt: timestamp, }], requestHashes: { 'turn-legacy': 'hash-legacy' }, updatedAt: timestamp, }], tasksByWorkspaceId: { 'workspace-legacy': [{ taskId: 'task-legacy', workspaceId: 'workspace-legacy', medium: 'image', status: 'succeeded', briefVersion: 2, briefSummary: '历史海报', quoteId: null, quotedDesignPoints: null, failureCode: null, resultAssets: [], createdAt: timestamp, updatedAt: timestamp, }], }, assetsById: {}, }), 'utf8'); const service = createService(userDataDir); const workspace = await service.getWorkspace('workspace-legacy'); const migratedConversation = workspace.conversations[0]; expect(workspace).toMatchObject({ workspaceId: 'workspace-legacy', conversationCount: 1, conversations: [{ title: '历史会话', turnRevision: 2, latestMessagePreview: '保留这条历史消息', }], }); await expect(service.getConversation( 'workspace-legacy', migratedConversation.conversationId, )).resolves.toMatchObject({ messages: [{ text: '保留这条历史消息' }], }); await expect(service.listTasks('workspace-legacy')).resolves.toMatchObject([{ taskId: 'task-legacy', conversationId: migratedConversation.conversationId, }]); expect(existsSync(join(rootDirectory, 'design-workspace-v3.json'))).toBe(true); }); 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: '海洋公益海报', }); const conversationId = created.conversations[0].conversationId; const discussed = await service.submitMessage({ workspaceId: created.workspaceId, conversationId, clientTurnId: 'turn-one', expectedTurnRevision: 0, message: '做一张保护海洋的竖版公益海报', }); const quote = discussed.messages.at(-1)?.generationQuote; expect(discussed.phase).toBe('awaiting_confirmation'); expect(discussed.latestMessagePreview).toContain('方向已整理为图像方案'); expect(quote).toMatchObject({ status: 'active', medium: 'image' }); await expect(service.listTasks(created.workspaceId)).resolves.toEqual([]); const confirmed = await service.confirmGeneration({ workspaceId: created.workspaceId, conversationId, clientTurnId: 'turn-two', expectedTurnRevision: 1, quoteId: quote!.quoteId, finalPrompt: quote!.finalPrompt, generationParameters: quote!.generationParameters, }); expect(confirmed.turnRevision).toBe(2); expect(confirmed.latestMessagePreview).toContain('图片任务已创建'); 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', resultAssets: [{ mimeType: 'image/svg+xml' }], }); 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('re-quotes local generation with the server-shaped options contract', async () => { const service = createService(createTemporaryDirectory()); const created = await service.createWorkspace({ clientWorkspaceId: 'client-requote', title: '校园动态海报', }); const conversationId = created.conversations[0].conversationId; const discussed = await service.submitMessage({ workspaceId: created.workspaceId, conversationId, clientTurnId: 'turn-requote-one', expectedTurnRevision: 0, message: '把校园海报转为 6 秒视频视觉', }); const originalQuote = discussed.messages.at(-1)!.generationQuote!; const updatedQuote = await service.updateGenerationQuote({ workspaceId: created.workspaceId, quoteId: originalQuote.quoteId, finalPrompt: '用户最新编辑后的完整提示词', generationParameters: { model: 'local-video-preview', resolution: '720P', aspectRatio: '16:9', durationSeconds: 6, }, }); expect(updatedQuote).toMatchObject({ finalPrompt: '用户最新编辑后的完整提示词', generationParameters: { model: 'local-video-preview', resolution: '720P', aspectRatio: '16:9', durationSeconds: 6, }, quotedDesignPoints: 2, }); expect(updatedQuote.generationOptions.resolutions).toEqual( expect.arrayContaining([expect.objectContaining({ value: '720P', disabled: false })]), ); expect(updatedQuote.generationOptions.durations).toEqual( expect.arrayContaining([expect.objectContaining({ value: 8, disabled: true })]), ); const refreshed = await service.getConversation(created.workspaceId, conversationId); const refreshedQuote = refreshed.messages.at(-1)!.generationQuote!; expect(refreshedQuote.finalPrompt).toBe('用户最新编辑后的完整提示词'); await expect(service.confirmGeneration({ workspaceId: created.workspaceId, conversationId, clientTurnId: 'turn-requote-two', expectedTurnRevision: 1, quoteId: updatedQuote.quoteId, finalPrompt: updatedQuote.finalPrompt, generationParameters: updatedQuote.generationParameters, })).resolves.toMatchObject({ turnRevision: 2 }); }); it('deletes a project while a task is queued and makes its task state inaccessible', async () => { const userDataDir = createTemporaryDirectory(); const service = createService(userDataDir); const created = await service.createWorkspace({ clientWorkspaceId: 'client-active', title: '生成中的项目', }); const conversationId = created.conversations[0].conversationId; const discussed = await service.submitMessage({ workspaceId: created.workspaceId, conversationId, clientTurnId: 'turn-active-one', expectedTurnRevision: 0, message: '生成一张海报', }); await service.confirmGeneration({ workspaceId: created.workspaceId, conversationId, clientTurnId: 'turn-active-two', expectedTurnRevision: 1, quoteId: discussed.messages.at(-1)!.generationQuote!.quoteId, finalPrompt: discussed.messages.at(-1)!.generationQuote!.finalPrompt, generationParameters: discussed.messages.at(-1)!.generationQuote!.generationParameters, }); const stateFile = join( getLocalImageWorkspaceDirectory(userDataDir), 'design-workspace-v3.json', ); await expect(service.deleteWorkspace(created.workspaceId)).resolves.toEqual({ workspaceId: created.workspaceId, deleted: true, }); expect(readFileSync(stateFile, 'utf8')).not.toContain(created.workspaceId); await expect(service.getWorkspace(created.workspaceId)).rejects.toMatchObject({ status: 404, code: 'workspace_not_found', }); }); it('atomically cascades conversations, completed tasks, and assets for only the deleted project', async () => { const userDataDir = createTemporaryDirectory(); const service = createService(userDataDir); const target = await service.createWorkspace({ clientWorkspaceId: 'client-delete', title: '待删除项目', }); const keeper = await service.createWorkspace({ clientWorkspaceId: 'client-keep', title: '保留项目', }); const conversationId = target.conversations[0].conversationId; const discussed = await service.submitMessage({ workspaceId: target.workspaceId, conversationId, clientTurnId: 'turn-delete-one', expectedTurnRevision: 0, message: '生成一张可删除的海报', }); await service.confirmGeneration({ workspaceId: target.workspaceId, conversationId, clientTurnId: 'turn-delete-two', expectedTurnRevision: 1, quoteId: discussed.messages.at(-1)!.generationQuote!.quoteId, finalPrompt: discussed.messages.at(-1)!.generationQuote!.finalPrompt, generationParameters: discussed.messages.at(-1)!.generationQuote!.generationParameters, }); await service.listTasks(target.workspaceId); await service.listTasks(target.workspaceId); const [completedTask] = await service.listTasks(target.workspaceId); const assetId = completedTask.resultAssets[0].assetId; await expect(service.deleteWorkspace(target.workspaceId)).resolves.toEqual({ workspaceId: target.workspaceId, deleted: true, }); await expect(service.deleteWorkspace(target.workspaceId)).rejects.toMatchObject< LocalImageWorkspaceError >({ status: 404, code: 'workspace_not_found', }); await expect(service.bootstrap()).resolves.toMatchObject({ workspaces: [{ workspaceId: keeper.workspaceId, title: '保留项目' }], }); const persisted = JSON.parse(readFileSync(join( getLocalImageWorkspaceDirectory(userDataDir), 'design-workspace-v3.json', ), 'utf8')) as { conversationsByWorkspaceId: Record; tasksByWorkspaceId: Record; assetsById: Record; }; expect(persisted.conversationsByWorkspaceId).not.toHaveProperty(target.workspaceId); expect(persisted.tasksByWorkspaceId).not.toHaveProperty(target.workspaceId); expect(persisted.assetsById).not.toHaveProperty(assetId); expect(Object.values(persisted.assetsById)).not.toContainEqual( expect.objectContaining({ workspaceId: target.workspaceId }), ); expect(persisted.conversationsByWorkspaceId).toHaveProperty(keeper.workspaceId); const reloaded = new LocalImageWorkspace({ userDataDir }); await expect(reloaded.bootstrap()).resolves.toMatchObject({ workspaces: [{ workspaceId: keeper.workspaceId }], }); }); 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.createWorkspace({ clientWorkspaceId: 'client-reset', title: '可重置项目', }); const conversationId = created.conversations[0].conversationId; await expect(service.submitMessage({ workspaceId: created.workspaceId, conversationId, clientTurnId: 'stale-turn', expectedTurnRevision: 3, message: '测试', })).rejects.toMatchObject({ status: 409, code: 'conversation_revision_conflict', }); expect(existsSync(getLocalImageWorkspaceDirectory(userDataDir))).toBe(true); await expect(service.reset()).resolves.toMatchObject({ workspaces: [] }); expect(existsSync(getLocalImageWorkspaceDirectory(userDataDir))).toBe(false); }); });