import { existsSync, mkdtempSync, readFileSync, rmSync } 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; return new LocalImageWorkspace({ userDataDir, now: () => new Date('2026-07-29T10:00:00.000Z'), createId: () => String(++sequence), }); } afterEach(() => { for (const directory of temporaryDirectories.splice(0)) { rmSync(directory, { recursive: true, force: true }); } }); describe('local image workspace', () => { it('routes the default development command through the local workspace 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('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('starts without Agents, persists manually added Agents, and survives a new service instance', async () => { const userDataDir = createTemporaryDirectory(); const service = createService(userDataDir); const emptyWorkspace = await service.getSnapshot(); expect(emptyWorkspace).toMatchObject({ projects: [], activeProjectId: null, }); expect(emptyWorkspace).not.toHaveProperty('runtimeMode'); const created = await service.createProject(' 本地角色设计 '); expect(created.projects).toHaveLength(1); expect(created.projects[0]).toMatchObject({ name: '本地角色设计', agents: [], messages: [], }); 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', ]); const reloaded = new LocalImageWorkspace({ userDataDir }); await expect(reloaded.getSnapshot()).resolves.toMatchObject({ activeProjectId: created.projects[0].id, projects: [{ name: '本地角色设计', agents: [{}, {}] }], }); }); 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'), }); expect(upload.reference).toMatchObject({ alt: '参考图.png', url: expect.stringMatching(/^data:image\/png;base64,/), }); 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(firstTurn.projects[0].messages).toHaveLength(2); expect(firstTurn.projects[0].messages[0].images).toEqual([upload.reference]); expect(firstOutput).toMatchObject({ role: 'assistant', status: 'succeeded', text: '已生成 2 张图片。', }); 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); }); it('rejects unknown option ids and removes all persisted 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]; await expect(service.sendMessage({ projectId: project.id, agentId: project.agents[0].id, prompt: '测试', referenceImageIds: [], settings: { modelId: 'unknown-model' }, })).rejects.toMatchObject({ status: 400, code: 'IMAGE_WORKSPACE_INVALID_SETTING', }); expect(existsSync(getLocalImageWorkspaceDirectory(userDataDir))).toBe(true); await expect(service.reset()).resolves.toMatchObject({ projects: [], activeProjectId: null }); expect(existsSync(getLocalImageWorkspaceDirectory(userDataDir))).toBe(false); await expect(new LocalImageWorkspace({ userDataDir }).getSnapshot()).resolves.toMatchObject({ projects: [] }); }); });