import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { initializeProjectDirectory } from '@electron/opencode/project-directory-initialization'; import type { ProjectType } from '../../shared/project-config'; const temporaryDirectories: string[] = []; afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((directory) => ( rm(directory, { recursive: true, force: true }) ))); }); describe('project directory initialization', () => { it.each([ ['mini_game', ['game.json', 'game.js']], ['mini_program', ['app.json', 'app.js', join('pages', 'index', 'index.js')]], ] satisfies Array<[ProjectType, string[]]>)('creates a complete %s Vite project template', async (projectType, typeFiles) => { const parentPath = await mkdtemp(join(tmpdir(), 'makelore-project-parent-')); temporaryDirectories.push(parentPath); const projectPath = join(parentPath, projectType); const result = await initializeProjectDirectory({ projectPath, projectType, allowExistingDirectory: false, }); expect(result).toMatchObject({ reusedExistingConfig: false, config: { projectType }, }); const packageJson = JSON.parse(await readFile(join(projectPath, 'package.json'), 'utf8')) as { scripts: Record; devDependencies: Record; }; const packageLock = JSON.parse(await readFile(join(projectPath, 'package-lock.json'), 'utf8')) as { lockfileVersion: number; packages: Record; }; expect(packageJson.scripts.build).toBe('vite build'); expect(packageJson.devDependencies.vite).toBe('7.3.1'); expect(packageLock.lockfileVersion).toBe(3); expect(packageLock.packages['node_modules/vite']).toBeTruthy(); await expect(readFile(join(projectPath, 'index.html'), 'utf8')).resolves.toContain('type="module"'); for (const relativePath of typeFiles) { await expect(readFile(join(projectPath, relativePath), 'utf8')).resolves.not.toBe(''); } }); it('keeps custom projects on the existing minimal project structure', async () => { const parentPath = await mkdtemp(join(tmpdir(), 'makelore-custom-parent-')); temporaryDirectories.push(parentPath); const projectPath = join(parentPath, 'custom-project'); const result = await initializeProjectDirectory({ projectPath, projectType: 'custom', allowExistingDirectory: false, }); expect(result.config.projectType).toBe('custom'); expect((await readdir(projectPath)).sort()).toEqual(['.niancode', 'TASKS.md', 'VERSION.md', 'knowledge']); }); it('reuses an existing project only when an explicitly selected type matches', async () => { const parentPath = await mkdtemp(join(tmpdir(), 'makelore-existing-parent-')); temporaryDirectories.push(parentPath); const projectPath = join(parentPath, 'existing-project'); await initializeProjectDirectory({ projectPath, projectType: 'mini_game', allowExistingDirectory: false, }); await expect(initializeProjectDirectory({ projectPath, projectType: 'mini_game', allowExistingDirectory: true, })).resolves.toMatchObject({ reusedExistingConfig: true, config: { projectType: 'mini_game' } }); await expect(initializeProjectDirectory({ projectPath, projectType: 'mini_program', allowExistingDirectory: true, })).rejects.toThrow('已有项目类型不可更改'); await expect(initializeProjectDirectory({ projectPath, allowExistingDirectory: true, })).resolves.toMatchObject({ reusedExistingConfig: true, config: { projectType: 'mini_game' } }); }); it('does not change an existing folder when a publishable template file conflicts', async () => { const projectPath = await mkdtemp(join(tmpdir(), 'makelore-template-conflict-')); temporaryDirectories.push(projectPath); await writeFile(join(projectPath, 'package.json'), '{"name":"user-project"}\n', 'utf8'); await expect(initializeProjectDirectory({ projectPath, projectType: 'mini_game', allowExistingDirectory: true, })).rejects.toThrow('Project initialization conflict: package.json already exists'); expect((await readdir(projectPath)).sort()).toEqual(['package.json']); await expect(readFile(join(projectPath, 'package.json'), 'utf8')).resolves.toBe('{"name":"user-project"}\n'); }); it('rolls back only files and directories created by a failed in-place merge', async () => { const projectPath = await mkdtemp(join(tmpdir(), 'niancode-in-place-rollback-')); temporaryDirectories.push(projectPath); await writeFile(join(projectPath, 'keep.txt'), 'user-owned\n', 'utf8'); await mkdir(join(projectPath, 'src')); await writeFile(join(projectPath, 'src', 'existing.ts'), 'export const existing = true;\n', 'utf8'); let copyCount = 0; await expect(initializeProjectDirectory({ projectPath, allowExistingDirectory: true, }, { copyFile: async (source, destination, mode) => { copyCount += 1; if (copyCount === 2) throw new Error('simulated copy failure'); await copyFile(source, destination, mode); }, })).rejects.toThrow('simulated copy failure'); expect((await readdir(projectPath)).sort()).toEqual(['keep.txt', 'src']); expect(await readFile(join(projectPath, 'keep.txt'), 'utf8')).toBe('user-owned\n'); expect(await readFile(join(projectPath, 'src', 'existing.ts'), 'utf8')).toContain('existing'); }); });