import assert from 'node:assert/strict'; import { cp, mkdtemp, mkdir, open, readFile, readdir, rm, rmdir, unlink, writeFile, } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath, pathToFileURL } from 'node:url'; import test from 'node:test'; const pluginRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '../../resources/coding-plugins/project-scaffold', ); const scaffoldScript = path.join( pluginRoot, 'skills', 'makelore-project-scaffold', 'scripts', 'scaffold.mjs', ); const { writeScaffold } = await import(pathToFileURL(scaffoldScript).href); const expectedFiles = [ 'index.html', 'package-lock.json', 'package.json', 'src/main.js', 'src/style.css', 'vite.config.js', ]; async function initializeProject(root, projectType) { await mkdir(path.join(root, '.makelore')); await mkdir(path.join(root, 'knowledge')); const config = `${JSON.stringify({ schemaVersion: 2, projectId: 'scaffold-test', projectType, }, null, 2)}\n`; await writeFile(path.join(root, '.makelore', 'project.json'), config, 'utf8'); return config; } async function createProject(projectType) { const root = await mkdtemp(path.join(tmpdir(), 'makelore-scaffold-')); return { root, config: await initializeProject(root, projectType) }; } function runScaffold(projectRoot, scriptPath = scaffoldScript) { const result = spawnSync( process.execPath, [scriptPath, '--project-root', projectRoot], { encoding: 'utf8' }, ); return { ...result, payload: JSON.parse((result.status === 0 ? result.stdout : result.stderr).trim()), }; } async function listTree(root, directory = root) { const result = []; for (const entry of await readdir(directory, { withFileTypes: true })) { const absolutePath = path.join(directory, entry.name); const relativePath = path.relative(root, absolutePath).split(path.sep).join('/'); if (entry.isDirectory()) { result.push(`${relativePath}/`, ...await listTree(root, absolutePath)); } else { result.push(relativePath); } } return result.sort(); } for (const sourceProjectType of ['interactive_ai_app', 'mini_game', 'mini_program']) { test(`creates the unified scaffold for ${sourceProjectType} without changing project metadata`, async () => { const { root, config } = await createProject(sourceProjectType); try { await writeFile(path.join(root, 'notes.txt'), 'keep me', 'utf8'); const result = runScaffold(root); assert.equal(result.status, 0, result.stderr); assert.equal(result.stderr, ''); assert.deepEqual(result.payload, { schemaVersion: 1, ok: true, status: 'created', projectType: 'interactive_ai_app', createdFiles: expectedFiles, }); assert.equal( await readFile(path.join(root, '.makelore', 'project.json'), 'utf8'), config, ); const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); const packageLock = JSON.parse(await readFile(path.join(root, 'package-lock.json'), 'utf8')); assert.equal(packageJson.packageManager, 'npm@11.6.2'); assert.equal(packageJson.devDependencies.vite, '7.3.1'); assert.equal(packageLock.lockfileVersion, 3); assert.match(await readFile(path.join(root, 'vite.config.js'), 'utf8'), /base:\s*['"]\.\/['"]/u); assert.equal(await readFile(path.join(root, 'notes.txt'), 'utf8'), 'keep me'); for (const relativePath of expectedFiles) { assert.ok((await readFile(path.join(root, ...relativePath.split('/')))).length > 0); } assert.match(await readFile(path.join(root, 'index.html'), 'utf8'), /src="\.\/src\/main\.js"/u); assert.match(await readFile(path.join(root, 'src', 'main.js'), 'utf8'), /import '\.\/style\.css'/u); assert.deepEqual(await listTree(root), [ '.makelore/', '.makelore/project.json', 'knowledge/', 'notes.txt', 'src/', ...expectedFiles, ].sort()); } finally { await rm(root, { recursive: true, force: true }); } }); } test('reports every existing target conflict and writes nothing else', async () => { const { root } = await createProject('interactive_ai_app'); try { await writeFile(path.join(root, 'index.html'), 'mine', 'utf8'); await writeFile(path.join(root, 'package.json'), 'mine', 'utf8'); const result = runScaffold(root); assert.equal(result.status, 1); assert.equal(result.stdout, ''); assert.deepEqual(result.payload, { schemaVersion: 1, ok: false, code: 'TARGET_CONFLICT', message: '目标路径已存在或必需的父路径不是目录,未写入任何文件。', paths: ['index.html', 'package.json'], }); assert.equal(await readFile(path.join(root, 'index.html'), 'utf8'), 'mine'); assert.equal(await readFile(path.join(root, 'package.json'), 'utf8'), 'mine'); assert.deepEqual((await readdir(root)).sort(), ['.makelore', 'index.html', 'knowledge', 'package.json']); } finally { await rm(root, { recursive: true, force: true }); } }); test('reports a blocking parent path as a target conflict', async () => { const { root } = await createProject('interactive_ai_app'); try { await writeFile(path.join(root, 'src'), 'mine', 'utf8'); const result = runScaffold(root); assert.equal(result.status, 1); assert.equal(result.payload.code, 'TARGET_CONFLICT'); assert.deepEqual(result.payload.paths, ['src']); assert.deepEqual((await readdir(root)).sort(), ['.makelore', 'knowledge', 'src']); } finally { await rm(root, { recursive: true, force: true }); } }); test('rejects custom projects without creating scaffold files', async () => { const { root } = await createProject('custom'); try { const result = runScaffold(root); assert.equal(result.status, 1); assert.equal(result.payload.code, 'PROJECT_TYPE_UNSUPPORTED'); assert.deepEqual((await readdir(root)).sort(), ['.makelore', 'knowledge']); } finally { await rm(root, { recursive: true, force: true }); } }); test('rejects a missing project config without creating scaffold files', async () => { const root = await mkdtemp(path.join(tmpdir(), 'makelore-scaffold-')); try { const result = runScaffold(root); assert.equal(result.status, 1); assert.equal(result.payload.code, 'PROJECT_CONFIG_MISSING'); assert.deepEqual(await readdir(root), []); } finally { await rm(root, { recursive: true, force: true }); } }); test('rejects invalid project metadata without creating scaffold files', async () => { const { root } = await createProject('interactive_ai_app'); try { await writeFile(path.join(root, '.makelore', 'project.json'), '{broken', 'utf8'); const result = runScaffold(root); assert.equal(result.status, 1); assert.equal(result.payload.code, 'PROJECT_CONFIG_INVALID'); assert.deepEqual((await readdir(root)).sort(), ['.makelore', 'knowledge']); } finally { await rm(root, { recursive: true, force: true }); } }); test('rejects invalid CLI arguments with a single stderr JSON object', () => { const result = spawnSync(process.execPath, [scaffoldScript], { encoding: 'utf8' }); assert.equal(result.status, 1); assert.equal(result.stdout, ''); assert.deepEqual(JSON.parse(result.stderr.trim()), { schemaVersion: 1, ok: false, code: 'USAGE_INVALID', message: '脚本参数无效,必须提供 --project-root 。', }); }); test('reports a missing template before writing any target', async () => { const outer = await mkdtemp(path.join(tmpdir(), 'makelore-scaffold-template-')); try { const projectRoot = path.join(outer, 'project'); const copiedSkill = path.join(outer, 'skill'); await mkdir(projectRoot); await initializeProject(projectRoot, 'interactive_ai_app'); await cp(path.dirname(path.dirname(scaffoldScript)), copiedSkill, { recursive: true }); await rm(path.join(copiedSkill, 'assets', 'templates', 'interactive_ai_app', 'main.js')); const result = runScaffold(projectRoot, path.join(copiedSkill, 'scripts', 'scaffold.mjs')); assert.equal(result.status, 1); assert.equal(result.payload.code, 'TEMPLATE_UNAVAILABLE'); assert.deepEqual(result.payload.paths, ['src/main.js']); assert.deepEqual(await listTree(projectRoot), [ '.makelore/', '.makelore/project.json', 'knowledge/', ]); } finally { await rm(outer, { recursive: true, force: true }); } }); test('runs when both the installed Skill path and project path contain spaces and Chinese', async () => { const outer = await mkdtemp(path.join(tmpdir(), 'makelore-scaffold-paths-')); try { const projectRoot = path.join(outer, '项目 空格'); const copiedSkill = path.join(outer, '插件 安装', '项目 初始化'); await mkdir(projectRoot); await mkdir(path.dirname(copiedSkill), { recursive: true }); await initializeProject(projectRoot, 'interactive_ai_app'); await cp(path.dirname(path.dirname(scaffoldScript)), copiedSkill, { recursive: true }); const result = runScaffold(projectRoot, path.join(copiedSkill, 'scripts', 'scaffold.mjs')); assert.equal(result.status, 0, result.stderr); assert.deepEqual(result.payload.createdFiles, expectedFiles); } finally { await rm(outer, { recursive: true, force: true }); } }); test('rolls back files and directories created before a handled write failure', async () => { const root = await mkdtemp(path.join(tmpdir(), 'makelore-scaffold-rollback-')); let openCalls = 0; try { await assert.rejects( writeScaffold( root, [ { targetPath: 'first.txt', contents: Buffer.from('first') }, { targetPath: 'nested/second.txt', contents: Buffer.from('second') }, ], ['nested'], { mkdir, open: async (...args) => { openCalls += 1; if (openCalls === 2) throw new Error('injected write failure'); return await open(...args); }, rmdir, unlink, }, ), (error) => { assert.equal(error.code, 'WRITE_FAILED'); assert.equal(error.details.paths, undefined); return true; }, ); assert.deepEqual(await readdir(root), []); } finally { await rm(root, { recursive: true, force: true }); } });