feat(projects): add interactive AI app scaffold skill
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { copyFile, mkdir, mkdtemp, realpath, rename, rm, stat } from 'node:fs/promises';
|
||||
import { copyFile, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -45,6 +45,45 @@ function makeStore(localId = 'local-project-id') {
|
||||
}
|
||||
|
||||
describe('coding project durable identity', () => {
|
||||
it.each(['interactive_ai_app', 'custom'] as const)(
|
||||
'creates only project-owned metadata for %s projects',
|
||||
async (projectType) => {
|
||||
const projectPath = await makeRoot();
|
||||
const projects = new CodingProjectService(makeStore(), {
|
||||
createProjectId: () => PROJECT_ID,
|
||||
});
|
||||
|
||||
const created = await projects.createProject({
|
||||
projectPath,
|
||||
projectType,
|
||||
identity: { kind: 'create' },
|
||||
});
|
||||
|
||||
expect(created.config.projectType).toBe(projectType);
|
||||
expect((await readdir(projectPath)).sort()).toEqual(['.makelore', 'knowledge']);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['mini_game', 'mini_program'] as const)(
|
||||
'reads legacy %s metadata as the canonical interactive type without rewriting it',
|
||||
async (legacyProjectType) => {
|
||||
const projectPath = await makeRoot();
|
||||
await mkdir(path.join(projectPath, '.makelore'), { recursive: true });
|
||||
const configPath = path.join(projectPath, '.makelore', 'project.json');
|
||||
const rawConfig = `${JSON.stringify({
|
||||
...createCodingProjectConfigV2(CREATED, 'interactive_ai_app'),
|
||||
projectType: legacyProjectType,
|
||||
}, null, 2)}\n`;
|
||||
await writeFile(configPath, rawConfig, 'utf8');
|
||||
|
||||
await expect(readCodingProjectConfigV2(projectPath)).resolves.toMatchObject({
|
||||
status: 'valid',
|
||||
config: { projectType: 'interactive_ai_app' },
|
||||
});
|
||||
await expect(readFile(configPath, 'utf8')).resolves.toBe(rawConfig);
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves canonical identity and rejects noncanonical values without backfill', () => {
|
||||
const initialized = createCodingProjectConfigV2(CREATED, 'custom', PROJECT_ID);
|
||||
expect(initialized.projectId).toBe(PROJECT_ID);
|
||||
|
||||
@@ -85,6 +85,42 @@ describe('DevicePackageManager', () => {
|
||||
expect(changed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('discloses executable Skill scripts and preserves their bundled resources', async () => {
|
||||
const root = await temporaryRoot('script-skill');
|
||||
const source = await looseSkill(root, 'script-skill');
|
||||
await mkdir(path.join(source, 'scripts'), { recursive: true });
|
||||
await mkdir(path.join(source, 'assets'), { recursive: true });
|
||||
await mkdir(path.join(source, 'references'), { recursive: true });
|
||||
await writeFile(path.join(source, 'scripts/scaffold.mjs'), 'console.log("scaffold");\n');
|
||||
await writeFile(path.join(source, 'assets/template.txt'), 'template\n');
|
||||
await writeFile(path.join(source, 'references/submission.md'), 'requirements\n');
|
||||
const manager = new DevicePackageManager({
|
||||
rootDir: path.join(root, 'store'),
|
||||
now: () => NOW,
|
||||
createId: () => 'plan-script-skill',
|
||||
});
|
||||
|
||||
const preview = await manager.prepare(source, 'turn-prepare');
|
||||
|
||||
expect(preview).toMatchObject({
|
||||
kind: 'skill-only',
|
||||
includesExecutableCode: true,
|
||||
extensionEntries: [],
|
||||
});
|
||||
expect(preview.warnings.join(' ')).toContain('Skill 脚本');
|
||||
expect(preview.warnings.join(' ')).toContain('文件、网络和进程权限');
|
||||
|
||||
const installed = await manager.commit(preview.planId, true, 'turn-confirm');
|
||||
expect(installed.packages[0]).toMatchObject({ confirmedExecutableCode: true });
|
||||
const [resource] = (await manager.resolveEnabledResources()).skillEntries;
|
||||
expect(await readFile(path.join(resource!.packageRoot, 'scripts/scaffold.mjs'), 'utf8'))
|
||||
.toContain('scaffold');
|
||||
expect(await readFile(path.join(resource!.packageRoot, 'assets/template.txt'), 'utf8'))
|
||||
.toBe('template\n');
|
||||
expect(await readFile(path.join(resource!.packageRoot, 'references/submission.md'), 'utf8'))
|
||||
.toBe('requirements\n');
|
||||
});
|
||||
|
||||
it('recognizes Pi extension and mixed package manifests and shows the desktop-permission warning', async () => {
|
||||
const root = await temporaryRoot('pi-manifests');
|
||||
const source = path.join(root, 'mixed-package');
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createServer, type ServerResponse } from 'node:http';
|
||||
import { createRequire } from 'node:module';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { PiAgentServerProcess } from '../../electron/coding-runtime/pi/agent-server-process';
|
||||
import { PiManagedExtensionHost } from '../../electron/coding-runtime/pi/extension-host';
|
||||
@@ -196,6 +197,33 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe('Pi Agent Server real process', () => {
|
||||
it('exposes its executable to Skill scripts', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-agent-server-environment-'));
|
||||
roots.push(root);
|
||||
const configDir = path.join(root, 'config');
|
||||
const serverPath = path.join(root, 'pi-agent-server-environment.mjs');
|
||||
await mkdir(configDir, { recursive: true });
|
||||
await writeFile(serverPath, [
|
||||
"if (process.env.MAKELORE_NODE_EXECUTABLE !== process.execPath) {",
|
||||
" throw new Error('MAKELORE_NODE_EXECUTABLE does not match the Agent Server executable');",
|
||||
'}',
|
||||
`await import(${JSON.stringify(pathToFileURL(path.resolve('resources/pi-agent-server.mjs')).href)});`,
|
||||
'',
|
||||
].join('\n'));
|
||||
const server = new PiAgentServerProcess({
|
||||
executablePath: process.execPath,
|
||||
serverPath,
|
||||
runtimeRoot: path.resolve('node_modules/@earendil-works/pi-coding-agent'),
|
||||
configDir,
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(server.start()).resolves.toBeUndefined();
|
||||
} finally {
|
||||
await server.stop().catch(() => undefined);
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
it('resolves runtime packages when the server resource is outside the project module tree', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-agent-server-packaged-layout-'));
|
||||
roots.push(root);
|
||||
|
||||
@@ -469,7 +469,11 @@ describe('Pi worker process', () => {
|
||||
it('inherits only the worker-safe environment allowlist', () => {
|
||||
const env = buildPiWorkerEnvironment(
|
||||
'D:\\managed-pi',
|
||||
{ MAKELore_PI_SELECTED_API_KEY: 'selected-secret' },
|
||||
'D:\\Makelore App\\Makelore.exe',
|
||||
{
|
||||
MAKELore_PI_SELECTED_API_KEY: 'selected-secret',
|
||||
MAKELORE_NODE_EXECUTABLE: 'D:\\spoofed-node.exe',
|
||||
},
|
||||
{
|
||||
PATH: 'D:\\tools',
|
||||
OPENAI_API_KEY: 'unrelated-openai-secret',
|
||||
@@ -484,12 +488,24 @@ describe('Pi worker process', () => {
|
||||
PI_OFFLINE: '1',
|
||||
PI_TELEMETRY: '0',
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
MAKELORE_NODE_EXECUTABLE: 'D:\\Makelore App\\Makelore.exe',
|
||||
});
|
||||
expect(env).not.toHaveProperty('OPENAI_API_KEY');
|
||||
expect(env).not.toHaveProperty('ANTHROPIC_API_KEY');
|
||||
expect(env).not.toHaveProperty('CUSTOM_APPLICATION_SECRET');
|
||||
});
|
||||
|
||||
it('exposes the direct worker executable to Skill scripts', async () => {
|
||||
const worker = await makeWorker({
|
||||
env: { MAKELORE_NODE_EXECUTABLE: 'spoofed-node' },
|
||||
});
|
||||
|
||||
await expect(worker.request<{ makeloreNodeExecutable: string }>({ type: 'environment' }))
|
||||
.resolves.toMatchObject({
|
||||
data: { makeloreNodeExecutable: process.execPath },
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses to put a selected worker credential in argv', async () => {
|
||||
const secret = 'argv-secret-value';
|
||||
await expect(makeWorker({
|
||||
|
||||
@@ -471,6 +471,33 @@ describe('PluginsView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('describes executable Skill scripts without calling them Pi extensions', () => {
|
||||
const scriptSkill: PluginWorkspaceItem = {
|
||||
...localItem,
|
||||
key: 'local:makelore-project-scaffold',
|
||||
pluginId: 'makelore-project-scaffold',
|
||||
packageId: 'makelore-project-scaffold',
|
||||
title: 'MakeLore 项目初始化',
|
||||
local: {
|
||||
...localItem.local!,
|
||||
packageId: 'makelore-project-scaffold',
|
||||
displayName: 'MakeLore 项目初始化',
|
||||
kind: 'skill-only',
|
||||
skillEntries: [{
|
||||
id: 'makelore-project-scaffold',
|
||||
entryPath: 'skills/makelore-project-scaffold/SKILL.md',
|
||||
}],
|
||||
extensionEntries: [],
|
||||
},
|
||||
};
|
||||
|
||||
render(<MemoryRouter><PluginsView {...props(scriptSkill)} /></MemoryRouter>);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'MakeLore 项目初始化' });
|
||||
expect(dialog).toHaveTextContent('包含可执行 Skill 脚本');
|
||||
expect(dialog).not.toHaveTextContent('包含可执行 Pi extension');
|
||||
});
|
||||
|
||||
it('shows cached, suspended, retired, and retained-device reason copy with an explicit sign-in action', () => {
|
||||
const unavailableItem: PluginWorkspaceItem = {
|
||||
...officialItem,
|
||||
|
||||
@@ -42,7 +42,7 @@ const AdmZip = require('adm-zip') as typeof import('adm-zip');
|
||||
|
||||
const tempDirectories: string[] = [];
|
||||
|
||||
async function createProject(projectType: ProjectType = 'mini_game'): Promise<string> {
|
||||
async function createProject(projectType: ProjectType = 'interactive_ai_app'): Promise<string> {
|
||||
const projectPath = await mkdtemp(join(tmpdir(), 'makelore-project-packager-'));
|
||||
tempDirectories.push(projectPath);
|
||||
await mkdir(join(projectPath, 'src'), { recursive: true });
|
||||
@@ -180,7 +180,7 @@ describe('project packager', () => {
|
||||
]);
|
||||
expect(archive.readAsText('niancode.yml')).toBe(
|
||||
'schema_version: 1\n'
|
||||
+ 'project_type: mini_game\n'
|
||||
+ 'project_type: interactive_ai_app\n'
|
||||
+ 'kind: web\n'
|
||||
+ 'runtime: static\n'
|
||||
+ 'build:\n'
|
||||
@@ -188,22 +188,34 @@ describe('project packager', () => {
|
||||
+ ' package_manager: npm\n'
|
||||
+ ' entry: index.html\n',
|
||||
);
|
||||
expect(first.manifest.project_type).toBe('mini_game');
|
||||
expect(first.manifest.project_type).toBe('interactive_ai_app');
|
||||
expect(entries.every((entry) => entry.header.time.getFullYear() === 1980)).toBe(true);
|
||||
await expect(readFile(join(projectPath, 'niancode.yml'), 'utf8')).resolves.toBe('runtime: compose\n');
|
||||
});
|
||||
|
||||
it('writes the selected publishable project type into the generated manifest', async () => {
|
||||
const projectPath = await createProject('mini_program');
|
||||
const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-'));
|
||||
tempDirectories.push(outputDirectory);
|
||||
const archivePath = join(outputDirectory, 'project.zip');
|
||||
it.each(['mini_game', 'mini_program'] as const)(
|
||||
'normalizes a legacy %s project to the canonical manifest type',
|
||||
async (legacyProjectType) => {
|
||||
const projectPath = await createProject();
|
||||
await writeFile(
|
||||
join(projectPath, '.makelore', 'project.json'),
|
||||
JSON.stringify({
|
||||
...createCodingProjectConfigV2('2026-08-09T00:00:00.000Z', 'interactive_ai_app'),
|
||||
projectType: legacyProjectType,
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-'));
|
||||
tempDirectories.push(outputDirectory);
|
||||
const archivePath = join(outputDirectory, 'project.zip');
|
||||
|
||||
const summary = await createStaticProjectPackage({ projectPath, archivePath });
|
||||
const summary = await createStaticProjectPackage({ projectPath, archivePath });
|
||||
|
||||
expect(summary.manifest.project_type).toBe('mini_program');
|
||||
expect(new AdmZip(archivePath).readAsText('niancode.yml')).toContain('project_type: mini_program\n');
|
||||
});
|
||||
expect(summary.manifest.project_type).toBe('interactive_ai_app');
|
||||
expect(new AdmZip(archivePath).readAsText('niancode.yml'))
|
||||
.toContain('project_type: interactive_ai_app\n');
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects custom projects before validating Vite source files', async () => {
|
||||
const projectPath = await mkdtemp(join(tmpdir(), 'makelore-custom-packager-'));
|
||||
@@ -222,7 +234,7 @@ describe('project packager', () => {
|
||||
archivePath: join(outputDirectory, 'project.zip'),
|
||||
})).rejects.toMatchObject<ProjectPackageError>({
|
||||
code: 'PROJECT_TYPE_UNPUBLISHABLE',
|
||||
message: '自定义项目暂未配置发布方式,请新建小游戏或小程序项目',
|
||||
message: '自定义项目暂未配置发布方式,请新建交互式 AI 应用项目',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ describe('ProjectPublishAction', () => {
|
||||
|
||||
it('creates a new project with a cover DTO and locks after Builder succeeds', async () => {
|
||||
fetchCurrentWorksProjectStatusMock.mockResolvedValue(projectStatus('succeeded'));
|
||||
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
||||
render(<ProjectPublishAction project={project} projectType="interactive_ai_app" />);
|
||||
|
||||
await submitProjectMetadata();
|
||||
expect(screen.getByRole('button', { name: '正在等待云端检查…' })).toBeDisabled();
|
||||
@@ -130,7 +130,7 @@ describe('ProjectPublishAction', () => {
|
||||
cover_url: null,
|
||||
creator_name: '小明',
|
||||
creator_age: 12,
|
||||
category: 'mini_game',
|
||||
category: 'interactive_ai_app',
|
||||
age_band: null,
|
||||
difficulty: null,
|
||||
},
|
||||
@@ -153,7 +153,7 @@ describe('ProjectPublishAction', () => {
|
||||
});
|
||||
|
||||
it('blocks first submission until a cover is selected', async () => {
|
||||
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
||||
render(<ProjectPublishAction project={project} projectType="interactive_ai_app" />);
|
||||
|
||||
await submitProjectMetadata({ includeCover: false });
|
||||
|
||||
@@ -164,7 +164,7 @@ describe('ProjectPublishAction', () => {
|
||||
|
||||
it('rejects unsupported and oversized cover files before confirmation', async () => {
|
||||
fetchCurrentWorksProjectStatusMock.mockRejectedValue(Object.assign(new Error('missing'), { statusCode: 404 }));
|
||||
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
||||
render(<ProjectPublishAction project={project} projectType="interactive_ai_app" />);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
|
||||
await Promise.resolve();
|
||||
@@ -185,7 +185,7 @@ describe('ProjectPublishAction', () => {
|
||||
fetchCurrentWorksProjectStatusMock.mockResolvedValue(
|
||||
projectStatus('failed', 'BROWSER_SMOKE_FAILED'),
|
||||
);
|
||||
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
||||
render(<ProjectPublishAction project={project} projectType="interactive_ai_app" />);
|
||||
|
||||
await submitProjectMetadata();
|
||||
await flushSubmission();
|
||||
@@ -204,7 +204,7 @@ describe('ProjectPublishAction', () => {
|
||||
fetchCurrentWorksProjectStatusMock.mockRejectedValue(
|
||||
new Error('socket closed at /srv/private/status.py'),
|
||||
);
|
||||
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
||||
render(<ProjectPublishAction project={project} projectType="interactive_ai_app" />);
|
||||
|
||||
await submitProjectMetadata();
|
||||
await flushSubmission();
|
||||
@@ -227,7 +227,7 @@ describe('ProjectPublishAction', () => {
|
||||
},
|
||||
});
|
||||
fetchCurrentWorksProjectStatusMock.mockResolvedValue(projectStatus('succeeded'));
|
||||
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
||||
render(<ProjectPublishAction project={project} projectType="interactive_ai_app" />);
|
||||
|
||||
await submitProjectMetadata();
|
||||
await flushSubmission();
|
||||
@@ -248,7 +248,7 @@ describe('ProjectPublishAction', () => {
|
||||
const status = projectStatus('queued');
|
||||
status.latest_version.id = 'another-version';
|
||||
fetchCurrentWorksProjectStatusMock.mockResolvedValue(status);
|
||||
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
||||
render(<ProjectPublishAction project={project} projectType="interactive_ai_app" />);
|
||||
|
||||
await submitProjectMetadata();
|
||||
await flushSubmission();
|
||||
@@ -268,7 +268,7 @@ describe('ProjectPublishAction', () => {
|
||||
new Error('Traceback: failed at /srv/private/package.ts token=secret'),
|
||||
{ code: 'INTERNAL_DATABASE_FAILURE', statusCode: 503 },
|
||||
));
|
||||
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
||||
render(<ProjectPublishAction project={project} projectType="interactive_ai_app" />);
|
||||
|
||||
await submitProjectMetadata();
|
||||
await flushSubmission();
|
||||
@@ -285,7 +285,7 @@ describe('ProjectPublishAction', () => {
|
||||
new Error('private current metadata token=secret'),
|
||||
{ code: 'PROJECT_METADATA_CONFLICT', statusCode: 409 },
|
||||
));
|
||||
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
||||
render(<ProjectPublishAction project={project} projectType="interactive_ai_app" />);
|
||||
|
||||
await submitProjectMetadata();
|
||||
await flushSubmission();
|
||||
@@ -302,7 +302,7 @@ describe('ProjectPublishAction', () => {
|
||||
new Error('D:/repo/space-cleaner/package-lock.json does not exist'),
|
||||
{ code: 'PROJECT_FILE_MISSING' },
|
||||
));
|
||||
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
||||
render(<ProjectPublishAction project={project} projectType="interactive_ai_app" />);
|
||||
|
||||
await submitProjectMetadata();
|
||||
await flushSubmission();
|
||||
@@ -317,7 +317,7 @@ describe('ProjectPublishAction', () => {
|
||||
|
||||
it('keeps generated metadata within the server limits', async () => {
|
||||
const longName = `作品-${'x'.repeat(250)}`;
|
||||
render(<ProjectPublishAction project={{ ...project, name: longName }} projectType="mini_program" />);
|
||||
render(<ProjectPublishAction project={{ ...project, name: longName }} projectType="interactive_ai_app" />);
|
||||
|
||||
await submitProjectMetadata();
|
||||
await flushSubmission();
|
||||
@@ -328,7 +328,7 @@ describe('ProjectPublishAction', () => {
|
||||
expect(input.project.summary).toBe('这是一个太空清洁小游戏。');
|
||||
expect(input.project.creator_name).toBe('小明');
|
||||
expect(input.project.creator_age).toBe(12);
|
||||
expect(input.project.category).toBe('mini_program');
|
||||
expect(input.project.category).toBe('interactive_ai_app');
|
||||
});
|
||||
|
||||
it('submits an existing draft as version-only without editable metadata or replacement cover', async () => {
|
||||
@@ -339,10 +339,10 @@ describe('ProjectPublishAction', () => {
|
||||
cover_url: 'coverimage/draft.jpg',
|
||||
creator_name: '草稿作者',
|
||||
creator_age: 10,
|
||||
category: 'mini_game',
|
||||
category: 'interactive_ai_app',
|
||||
});
|
||||
fetchCurrentWorksProjectStatusMock.mockResolvedValue(draftStatus);
|
||||
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
||||
render(<ProjectPublishAction project={project} projectType="interactive_ai_app" />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
|
||||
@@ -384,12 +384,12 @@ describe('ProjectPublishAction', () => {
|
||||
cover_url: 'coverimage/existing.jpg',
|
||||
creator_name: '原作者',
|
||||
creator_age: 11,
|
||||
category: 'mini_game',
|
||||
category: 'interactive_ai_app',
|
||||
age_band: '6-12岁',
|
||||
difficulty: '入门',
|
||||
});
|
||||
fetchCurrentWorksProjectStatusMock.mockResolvedValue(publishedStatus);
|
||||
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
||||
render(<ProjectPublishAction project={project} projectType="interactive_ai_app" />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('project release builder', () => {
|
||||
it('builds from the exact source archive and returns the protocol-v1 canonical contract', async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'release-builder-test-'));
|
||||
await mkdir(join(root, '.makelore'), { recursive: true });
|
||||
await writeFile(join(root, '.makelore', 'project.json'), JSON.stringify(createCodingProjectConfigV2(new Date().toISOString(), 'mini_game')));
|
||||
await writeFile(join(root, '.makelore', 'project.json'), JSON.stringify(createCodingProjectConfigV2(new Date().toISOString(), 'interactive_ai_app')));
|
||||
await writeFile(join(root, 'package.json'), JSON.stringify({ name: 'demo', packageManager: 'npm@11.6.2', devDependencies: { vite: '7.3.1' } }));
|
||||
await writeFile(join(root, 'package-lock.json'), JSON.stringify({ name: 'demo', lockfileVersion: 3, requires: true, packages: {} }));
|
||||
await writeFile(join(root, 'index.html'), '<main>source</main>');
|
||||
|
||||
193
tests/unit/project-scaffold-plugin.test.ts
Normal file
193
tests/unit/project-scaffold-plugin.test.ts
Normal 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/']));
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ describe('works project publish guidance', () => {
|
||||
['PUBLISH_PREFLIGHT_RUNTIME_ERROR', '作品打开时发生错误', '项目预览'],
|
||||
['PUBLISH_PREFLIGHT_BLANK', '作品打开后没有内容', '移动端布局'],
|
||||
['PROJECT_FILE_MISSING', '项目文件不完整', '修复当前项目模板'],
|
||||
['PROJECT_TYPE_UNPUBLISHABLE', '这个项目没有配置发布方式', '新建小游戏或小程序项目'],
|
||||
['PROJECT_TYPE_UNPUBLISHABLE', '这个项目没有配置发布方式', '新建交互式 AI 应用项目'],
|
||||
['DEPENDENCY_PREFETCH_FAILED', '旧版提交无法继续处理', '升级 Makelore'],
|
||||
['OUTPUT_MISSING', '本次构建结果未通过平台校验', '本次构建结果'],
|
||||
['RELEASE_STORE_FAILED', '平台校验任务暂未完成', '构建异常'],
|
||||
|
||||
@@ -29,7 +29,7 @@ function preparedRelease(projectPath: string) {
|
||||
sourceArchive: { path: join(projectPath, 'private-source.zip'), name: 'project.zip', bytes, summary: {
|
||||
archivePath: join(projectPath, 'private-source.zip'), archiveName: 'project.zip', sha256: 'a'.repeat(64), fileCount: 5,
|
||||
sourceBytes: 10, archiveBytes: 3, excludedCount: 0, excludedPaths: [],
|
||||
manifest: { schema_version: 1, project_type: 'mini_game', kind: 'web', runtime: 'static', build: { preset: 'vite', package_manager: 'npm', entry: 'index.html' } },
|
||||
manifest: { schema_version: 1, project_type: 'interactive_ai_app', kind: 'web', runtime: 'static', build: { preset: 'vite', package_manager: 'npm', entry: 'index.html' } },
|
||||
} },
|
||||
builtArchive: { path: join(projectPath, 'private-built.zip'), name: 'built-project.zip', bytes: Buffer.from('built') },
|
||||
distRoot: join(projectPath, 'private-dist'),
|
||||
@@ -92,7 +92,7 @@ async function writePublishableProject(projectPath: string): Promise<void> {
|
||||
await mkdir(join(projectPath, '.makelore'), { recursive: true });
|
||||
await writeFile(
|
||||
join(projectPath, '.makelore', 'project.json'),
|
||||
JSON.stringify(createCodingProjectConfigV2('2026-08-09T00:00:00.000Z', 'mini_game')),
|
||||
JSON.stringify(createCodingProjectConfigV2('2026-08-09T00:00:00.000Z', 'interactive_ai_app')),
|
||||
'utf8',
|
||||
);
|
||||
await writeFile(join(projectPath, 'package.json'), JSON.stringify({
|
||||
|
||||
Reference in New Issue
Block a user