feat(projects): add interactive AI app scaffold skill

This commit is contained in:
2026-09-04 20:36:28 +08:00
parent 336e0bb0ca
commit 959b6333b5
40 changed files with 2647 additions and 65 deletions

View File

@@ -0,0 +1,193 @@
// @vitest-environment node
import { spawnSync } from 'node:child_process';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
devicePackageKind,
inspectDevicePackage,
} from '@electron/coding-packages/device-package-format';
import { DevicePackageManager } from '@electron/coding-packages/device-package-manager';
import { createCodingProjectMetadata } from '@electron/coding-projects/project-config';
import { createStaticProjectPackage } from '@electron/services/project-packager';
import type { ProjectType } from '../../shared/project-config';
const pluginRoot = resolve('plugins/makelore-project-scaffold');
const scaffoldScript = join(
pluginRoot,
'skills',
'makelore-project-scaffold',
'scripts',
'scaffold.mjs',
);
const tempDirectories: string[] = [];
function extractStringList(source: string, declaration: string): string[] {
const declarationStart = source.indexOf(`const ${declaration} =`);
expect(declarationStart).toBeGreaterThanOrEqual(0);
const listStart = source.indexOf('[', declarationStart);
const listEnd = source.indexOf(']', listStart);
expect(listStart).toBeGreaterThanOrEqual(0);
expect(listEnd).toBeGreaterThan(listStart);
return [...source.slice(listStart + 1, listEnd).matchAll(/'([^']+)'/g)]
.map((match) => match[1]);
}
afterEach(async () => {
await Promise.all(
tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
);
});
describe('MakeLore project scaffold plugin', () => {
it('is discovered as a skill-only package with executable Skill scripts', async () => {
const packageJson = JSON.parse(await readFile(join(pluginRoot, 'package.json'), 'utf8'));
const pluginManifest = JSON.parse(await readFile(
join(pluginRoot, '.codex-plugin', 'plugin.json'),
'utf8',
));
const inspected = await inspectDevicePackage(pluginRoot);
expect({ name: packageJson.name, version: packageJson.version }).toEqual({
name: pluginManifest.name,
version: pluginManifest.version,
});
expect(pluginManifest.skills).toBe('./skills/');
expect(devicePackageKind(inspected)).toBe('skill-only');
expect(inspected).toMatchObject({
packageId: 'makelore-project-scaffold',
resolvedVersion: '0.1.0',
extensionEntries: [],
hasSkillScripts: true,
ignoredLifecycleScripts: [],
skillEntries: [{
id: 'makelore-project-scaffold',
entryPath: 'skills/makelore-project-scaffold/SKILL.md',
}],
});
});
it('prepares, confirms, and installs the real package with every Skill resource', async () => {
const root = await mkdtemp(join(tmpdir(), 'makelore-scaffold-device-package-'));
tempDirectories.push(root);
const manager = new DevicePackageManager({
rootDir: join(root, 'store'),
now: () => Date.parse('2026-09-04T00:00:00.000Z'),
createId: () => 'project-scaffold-plan',
});
const preview = await manager.prepare(pluginRoot, 'prepare-turn');
expect(preview).toMatchObject({
kind: 'skill-only',
includesExecutableCode: true,
skillEntries: [{ id: 'makelore-project-scaffold' }],
extensionEntries: [],
});
expect(preview.warnings.join(' ')).toContain('可执行 Skill 脚本');
await manager.commit(preview.planId, true, 'confirm-turn');
const [resource] = (await manager.resolveEnabledResources()).skillEntries;
expect(resource?.id).toBe('makelore-project-scaffold');
const skillRoot = dirname(join(resource!.packageRoot, resource!.entryPath));
expect(await readFile(join(skillRoot, 'scripts', 'scaffold.mjs'), 'utf8'))
.toContain('schemaVersion: 1');
expect(await readFile(join(skillRoot, 'assets', 'templates', 'common', 'package-lock.json'), 'utf8'))
.toContain('"lockfileVersion": 3');
expect(await readFile(join(skillRoot, 'references', 'submission-requirements.md'), 'utf8'))
.toContain('提交审批要求');
});
it('carries the complete categorized release-readiness contract', async () => {
const skill = await readFile(
join(pluginRoot, 'skills', 'makelore-project-scaffold', 'SKILL.md'),
'utf8',
);
const requirements = await readFile(
join(
pluginRoot,
'skills',
'makelore-project-scaffold',
'references',
'submission-requirements.md',
),
'utf8',
);
const packagerSource = await readFile('electron/services/project-packager.ts', 'utf8');
expect(skill).toContain('Release-readiness workflow');
expect(skill).toContain('不能进入一键提交');
expect(skill).toContain('可进入一键提交,仍需运行期验证');
expect(skill).toContain('Do not infer an external-network blocker');
for (const heading of ['必须具备', '直接阻断', '打包排除', '运行时或平台确认']) {
expect(requirements).toContain(`**${heading}**`);
}
for (const value of [
'.makelore/project.json',
'schema v2',
'interactive_ai_app',
'mini_game',
'mini_program',
'custom',
'npm ci --ignore-scripts',
'lockfileVersion',
'2,000',
'200 MiB',
'50 MiB',
'release.json',
'CON',
'1280×720',
'390×844',
'30 秒',
'HTTP/HTTPS',
'.env',
'serviceaccount',
'works-',
'PNG、JPEG 或 WebP',
'1–150',
'存在但不阻断',
]) {
expect(requirements).toContain(value);
}
const excludedValues = [
...extractStringList(packagerSource, 'EXCLUDED_DIRECTORY_NAMES'),
...extractStringList(packagerSource, 'EXCLUDED_FILE_NAMES'),
...extractStringList(packagerSource, 'EXCLUDED_FILE_SUFFIXES'),
];
expect(excludedValues.length).toBeGreaterThan(50);
for (const value of excludedValues) {
expect(requirements).toContain(value);
}
});
it('creates an interactive AI application accepted by the existing source packager', async () => {
const projectType: ProjectType = 'interactive_ai_app';
const projectPath = await mkdtemp(join(tmpdir(), `makelore-${projectType}-`));
const outputPath = await mkdtemp(join(tmpdir(), 'makelore-scaffold-package-'));
tempDirectories.push(projectPath, outputPath);
await createCodingProjectMetadata(projectPath, {
projectType,
now: '2026-09-04T00:00:00.000Z',
});
const scaffold = spawnSync(
process.execPath,
[scaffoldScript, '--project-root', projectPath],
{ encoding: 'utf8' },
);
expect(scaffold.status, scaffold.stderr).toBe(0);
const packaged = await createStaticProjectPackage({
projectPath,
archivePath: join(outputPath, 'project.zip'),
});
expect(packaged.manifest.project_type).toBe(projectType);
expect(packaged.archiveName).toBe('project.zip');
expect(packaged.fileCount).toBeGreaterThan(1);
expect(packaged.archiveBytes).toBeGreaterThan(0);
expect(packaged.excludedPaths).toEqual(expect.arrayContaining(['.makelore/', 'knowledge/']));
});
});