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

@@ -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);

View File

@@ -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');

View File

@@ -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);

View File

@@ -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({

View File

@@ -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,

View File

@@ -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 应用项目',
});
});

View File

@@ -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: '一键提交审核' }));

View File

@@ -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>');

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',
'1150',
'存在但不阻断',
]) {
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/']));
});
});

View File

@@ -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', '平台校验任务暂未完成', '构建异常'],

View File

@@ -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({