293 lines
10 KiB
JavaScript
293 lines
10 KiB
JavaScript
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 <project-directory>。',
|
|
});
|
|
});
|
|
|
|
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 });
|
|
}
|
|
});
|