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 local adapter', () => { const packageJson = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')) as { scripts: Record; }; 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'); }); 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 }], }); 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(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, }); 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', 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('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); }); });