import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { afterEach, describe, expect, it } from 'vitest'; import { mkdtemp } from 'node:fs/promises'; import { createProjectTemplateSnapshot } from '../../shared/project-template'; import { maybeWriteProjectScaffold } from '@electron/opencode/project-scaffold'; import { PROJECT_TEMPLATE_SNAPSHOT_PATH, getProjectTemplateSnapshotPath, writeProjectTemplateSnapshot, } from '@electron/opencode/project-template-snapshot'; const tempDirs: string[] = []; afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); async function createTempProjectDir(prefix: string) { const dir = await mkdtemp(join(tmpdir(), prefix)); tempDirs.push(dir); return dir; } describe('project template snapshot helper', () => { it('rejects overwriting an existing valid snapshot', async () => { const projectPath = await createTempProjectDir('niancode-template-helper-valid-'); const snapshot = createProjectTemplateSnapshot('standard-dev', '2026-07-01T00:00:00.000Z'); await writeProjectTemplateSnapshot(projectPath, snapshot); await expect(writeProjectTemplateSnapshot(projectPath, snapshot)).rejects.toThrow(PROJECT_TEMPLATE_SNAPSHOT_PATH); await expect(writeProjectTemplateSnapshot(projectPath, snapshot)).rejects.toThrow(/already exists/i); }); it('rejects overwriting an existing invalid snapshot and preserves its contents', async () => { const projectPath = await createTempProjectDir('niancode-template-helper-invalid-'); const snapshotPath = getProjectTemplateSnapshotPath(projectPath); await mkdir(join(projectPath, '.niancode'), { recursive: true }); await writeFile(snapshotPath, '{"broken":true}\n', 'utf8'); const snapshot = createProjectTemplateSnapshot('standard-dev', '2026-07-01T00:00:00.000Z'); await expect(writeProjectTemplateSnapshot(projectPath, snapshot)).rejects.toThrow(/already exists/i); await expect(readFile(snapshotPath, 'utf8')).resolves.toBe('{"broken":true}\n'); }); it('does not overwrite an existing scaffold file and preserves its contents', async () => { const projectPath = await createTempProjectDir('niancode-template-helper-scaffold-'); const snapshot = createProjectTemplateSnapshot('web-mini-game-course', '2026-07-01T00:00:00.000Z'); await mkdir(join(projectPath, 'src'), { recursive: true }); await writeFile(join(projectPath, 'src', 'main.ts'), '// keep me\n', 'utf8'); const result = await maybeWriteProjectScaffold(projectPath, snapshot); expect(result).toEqual({ status: 'skipped_existing_files', files: ['src/main.ts'], }); await expect(readFile(join(projectPath, 'src', 'main.ts'), 'utf8')).resolves.toBe('// keep me\n'); }); it('allows scaffold generation when the project root only contains ignorable entries', async () => { const projectPath = await createTempProjectDir('niancode-template-helper-ignorable-'); const snapshot = createProjectTemplateSnapshot('web-mini-game-course', '2026-07-01T00:00:00.000Z'); await mkdir(join(projectPath, '.git'), { recursive: true }); await mkdir(join(projectPath, '.niancode'), { recursive: true }); await writeFile(join(projectPath, '.DS_Store'), '', 'utf8'); await writeFile(join(projectPath, 'Thumbs.db'), '', 'utf8'); const result = await maybeWriteProjectScaffold(projectPath, snapshot); expect(result.status).toBe('generated'); await expect(readFile(join(projectPath, 'package.json'), 'utf8')).resolves.toContain('"phaser"'); }); it('treats extra files inside an existing scaffold root directory as non-empty project content', async () => { const projectPath = await createTempProjectDir('niancode-template-helper-non-empty-src-'); const snapshot = createProjectTemplateSnapshot('web-mini-game-course', '2026-07-01T00:00:00.000Z'); await mkdir(join(projectPath, 'src'), { recursive: true }); await writeFile(join(projectPath, 'src', 'utils.ts'), 'export const noop = () => {};\n', 'utf8'); const result = await maybeWriteProjectScaffold(projectPath, snapshot); expect(result).toEqual({ status: 'skipped_non_empty', entries: ['src/utils.ts'], }); await expect(readFile(join(projectPath, 'src', 'utils.ts'), 'utf8')).resolves.toContain('noop'); }); it('treats nested extra files inside an existing scaffold root directory as non-empty project content', async () => { const projectPath = await createTempProjectDir('niancode-template-helper-nested-src-'); const snapshot = createProjectTemplateSnapshot('web-mini-game-course', '2026-07-01T00:00:00.000Z'); await mkdir(join(projectPath, 'src', 'assets'), { recursive: true }); await writeFile(join(projectPath, 'src', 'assets', 'existing.png'), 'fake-png', 'utf8'); const result = await maybeWriteProjectScaffold(projectPath, snapshot); expect(result).toEqual({ status: 'skipped_non_empty', entries: ['src/assets/existing.png'], }); }); it('treats a root file named src as non-empty project content', async () => { const projectPath = await createTempProjectDir('niancode-template-helper-src-file-'); const snapshot = createProjectTemplateSnapshot('web-mini-game-course', '2026-07-01T00:00:00.000Z'); await writeFile(join(projectPath, 'src'), 'not-a-directory\n', 'utf8'); const result = await maybeWriteProjectScaffold(projectPath, snapshot); expect(result).toEqual({ status: 'skipped_non_empty', entries: ['src'], }); }); it('writes the Phaser starter scaffold for an empty mini-game project', async () => { const projectPath = await createTempProjectDir('niancode-template-helper-generated-'); const snapshot = createProjectTemplateSnapshot('web-mini-game-course', '2026-07-01T00:00:00.000Z'); const result = await maybeWriteProjectScaffold(projectPath, snapshot); expect(result.status).toBe('generated'); if (result.status !== 'generated') { throw new Error(`Expected generated scaffold, received ${result.status}`); } expect(result.files).toEqual([ 'ASSET_PLAN.md', 'GDD.md', 'README.md', 'RELEASE_CHECKLIST.md', 'PRODUCT_OVERVIEW.md', 'TASKS.md', 'index.html', 'package.json', 'src/main.ts', 'src/styles.css', 'tsconfig.json', 'vite.config.ts', ]); const packageJson = JSON.parse(await readFile(join(projectPath, 'package.json'), 'utf8')) as { scripts: Record; dependencies: Record; devDependencies: Record; }; expect(packageJson.scripts).toEqual({ dev: 'vite --host 0.0.0.0', build: 'tsc && vite build', preview: 'vite preview --host 0.0.0.0', }); expect(packageJson.dependencies.phaser).toBe('3.90.0'); expect(packageJson.devDependencies.typescript).toMatch(/^\^[0-9]+\.[0-9]+\.[0-9]+$/); expect(packageJson.devDependencies.vite).toMatch(/^\^[0-9]+\.[0-9]+\.[0-9]+$/); await expect(readFile(join(projectPath, 'src', 'main.ts'), 'utf8')).resolves.toContain('GDD.md'); await expect(readFile(join(projectPath, 'src', 'main.ts'), 'utf8')).resolves.toContain('Phaser.Scale.FIT'); await expect(readFile(join(projectPath, 'GDD.md'), 'utf8')).resolves.toContain('## 5. 当前系统'); await expect(readFile(join(projectPath, 'TASKS.md'), 'utf8')).resolves.toContain('## Last verified'); await expect(readFile(join(projectPath, 'PRODUCT_OVERVIEW.md'), 'utf8')).resolves.toContain('## 5. 对外介绍与运营表达'); }); });