merge: integrate remote project scaffold updates
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
|
||||
const PACKAGE_ROOT = path.resolve('resources/coding-plugins/data-service');
|
||||
const GAME_RESOURCE_ROOT = path.resolve('resources/coding-plugins/game-resource');
|
||||
const PROJECT_SCAFFOLD_ROOT = path.resolve('resources/coding-plugins/project-scaffold');
|
||||
|
||||
async function packageManifests(): Promise<{ root: Record<string, unknown>; capability: Record<string, unknown> }> {
|
||||
return {
|
||||
@@ -33,12 +34,17 @@ describe('bundled coding plugin manifests', () => {
|
||||
it('loads the fixed Data Service package and immutable declarations', async () => {
|
||||
const definitions = await loadBundledCodingPluginDefinitions(path.resolve('resources/coding-plugins'));
|
||||
const startupDefinitions = loadBundledCodingPluginDefinitionsSync(path.resolve('resources/coding-plugins'));
|
||||
expect(BUNDLED_CODING_PLUGIN_ROOTS).toEqual(['data-service', 'game-resource']);
|
||||
expect(BUNDLED_CODING_PLUGIN_ROOTS).toEqual([
|
||||
'data-service',
|
||||
'game-resource',
|
||||
'project-scaffold',
|
||||
]);
|
||||
expect(resolveBundledCodingPluginRootPaths(path.resolve('resources/coding-plugins'))).toEqual([
|
||||
PACKAGE_ROOT,
|
||||
GAME_RESOURCE_ROOT,
|
||||
PROJECT_SCAFFOLD_ROOT,
|
||||
]);
|
||||
expect(definitions).toHaveLength(2);
|
||||
expect(definitions).toHaveLength(3);
|
||||
expect(definitions[0]).toMatchObject({
|
||||
id: 'makelore.data-service',
|
||||
adapterId: 'data-service',
|
||||
@@ -54,6 +60,17 @@ describe('bundled coding plugin manifests', () => {
|
||||
provenance: { source: 'bundled', packageRoot: 'game-resource' },
|
||||
skills: [{ id: 'game-resource', entryPath: 'skills/game-resource/SKILL.md' }],
|
||||
},
|
||||
{
|
||||
id: 'makelore.project-scaffold', version: '1.0.0', runtimeKind: 'skill_only',
|
||||
acquisitionMode: 'user_acquired', releaseId: '00000000-0000-4000-8000-000000000303',
|
||||
provenance: { source: 'bundled', packageRoot: 'project-scaffold' },
|
||||
skills: [{
|
||||
id: 'makelore-project-scaffold',
|
||||
entryPath: 'skills/makelore-project-scaffold/SKILL.md',
|
||||
grants: [],
|
||||
}],
|
||||
tools: [],
|
||||
},
|
||||
]);
|
||||
expect(startupDefinitions).toEqual(definitions);
|
||||
expect(Object.isFrozen(startupDefinitions)).toBe(true);
|
||||
@@ -128,6 +145,7 @@ describe('bundled coding plugin manifests', () => {
|
||||
expect(resolveBundledCodingPluginRootPaths(path.resolve('tmp'))).toEqual([
|
||||
path.resolve('tmp/data-service'),
|
||||
path.resolve('tmp/game-resource'),
|
||||
path.resolve('tmp/project-scaffold'),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1419,8 +1419,8 @@ describe('PluginPackageStore', () => {
|
||||
});
|
||||
const cases = [
|
||||
{
|
||||
name: 'unsupported asset extension',
|
||||
archive: buildSkillOnlyArchive({ 'skills/example-skill/assets/run.exe': Buffer.from('not executable') }),
|
||||
name: 'executable Skill script in a downloadable artifact',
|
||||
archive: buildSkillOnlyArchive({ 'skills/example-skill/scripts/run.mjs': Buffer.from('export {};') }),
|
||||
},
|
||||
{
|
||||
name: 'non-UTF-8 Skill text',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -261,40 +261,45 @@ describe('buildPluginWorkspaceProjection', () => {
|
||||
});
|
||||
|
||||
it('keeps bundled and downloadable official command sets inside their delivery boundaries', () => {
|
||||
const bundledCatalog = {
|
||||
...catalog.items[0]!,
|
||||
pluginId: 'makelore.game-resource',
|
||||
title: 'Game Resource',
|
||||
runtimeKind: 'bundled_typed' as const,
|
||||
};
|
||||
const bundledLibrary = {
|
||||
...library.items[0]!,
|
||||
pluginId: bundledCatalog.pluginId,
|
||||
title: bundledCatalog.title,
|
||||
};
|
||||
const bundledProject = {
|
||||
...project,
|
||||
items: [{
|
||||
...project.items[0]!,
|
||||
id: bundledCatalog.pluginId,
|
||||
displayName: bundledCatalog.title,
|
||||
enabled: false,
|
||||
state: 'disabled' as const,
|
||||
}],
|
||||
};
|
||||
for (const [pluginId, title, runtimeKind] of [
|
||||
['makelore.game-resource', 'Game Resource', 'platform_hosted'],
|
||||
['makelore.project-scaffold', 'Project Scaffold', 'skill_only'],
|
||||
] as const) {
|
||||
const bundledCatalog = {
|
||||
...catalog.items[0]!,
|
||||
pluginId,
|
||||
title,
|
||||
runtimeKind,
|
||||
};
|
||||
const bundledLibrary = {
|
||||
...library.items[0]!,
|
||||
pluginId: bundledCatalog.pluginId,
|
||||
title: bundledCatalog.title,
|
||||
};
|
||||
const bundledProject = {
|
||||
...project,
|
||||
items: [{
|
||||
...project.items[0]!,
|
||||
id: bundledCatalog.pluginId,
|
||||
displayName: bundledCatalog.title,
|
||||
enabled: false,
|
||||
state: 'disabled' as const,
|
||||
}],
|
||||
};
|
||||
|
||||
expect(commandKinds({
|
||||
catalog: { ...catalog, items: [bundledCatalog] },
|
||||
library: { ...library, items: [] },
|
||||
marketplaceInstallations: {},
|
||||
project: null,
|
||||
})).toEqual(['acquire']);
|
||||
expect(commandKinds({
|
||||
catalog: { ...catalog, items: [bundledCatalog] },
|
||||
library: { ...library, items: [bundledLibrary] },
|
||||
marketplaceInstallations: {},
|
||||
project: bundledProject,
|
||||
})).toEqual(['remove_from_library', 'enable_project', 'open_agent_assignment']);
|
||||
expect(commandKinds({
|
||||
catalog: { ...catalog, items: [bundledCatalog] },
|
||||
library: { ...library, items: [] },
|
||||
marketplaceInstallations: {},
|
||||
project: null,
|
||||
})).toEqual(['acquire']);
|
||||
expect(commandKinds({
|
||||
catalog: { ...catalog, items: [bundledCatalog] },
|
||||
library: { ...library, items: [bundledLibrary] },
|
||||
marketplaceInstallations: {},
|
||||
project: bundledProject,
|
||||
})).toEqual(['remove_from_library', 'enable_project', 'open_agent_assignment']);
|
||||
}
|
||||
|
||||
expect(commandKinds({ marketplaceInstallations: {} })).toEqual([
|
||||
'remove_from_library',
|
||||
|
||||
@@ -449,6 +449,45 @@ describe('PluginsView', () => {
|
||||
expect(view.onFiltersChange).toHaveBeenCalledWith(expect.objectContaining({ search: 'web' }));
|
||||
});
|
||||
|
||||
it('labels an unacquired code-owned bundled plugin as supplied by MakeLore instead of not downloaded', () => {
|
||||
const scaffoldItem: PluginWorkspaceItem = {
|
||||
...officialItem,
|
||||
key: 'official:makelore.project-scaffold',
|
||||
pluginId: 'makelore.project-scaffold',
|
||||
title: 'MakeLore 项目脚手架',
|
||||
delivery: 'not_acquired',
|
||||
projectState: 'disabled',
|
||||
assignedAgentIds: [],
|
||||
assignedAgentNames: [],
|
||||
commands: [],
|
||||
official: {
|
||||
catalog: null,
|
||||
detail: null,
|
||||
library: null,
|
||||
installation: null,
|
||||
project: {
|
||||
...projectPlugin,
|
||||
id: 'makelore.project-scaffold',
|
||||
displayName: 'MakeLore 项目脚手架',
|
||||
enabled: false,
|
||||
state: 'disabled',
|
||||
},
|
||||
},
|
||||
};
|
||||
const view = props(scaffoldItem);
|
||||
view.projection = {
|
||||
items: [scaffoldItem],
|
||||
selected: scaffoldItem,
|
||||
counts: { all: 1, available: 0, mine: 0, enabled: 0, update: 0, unavailable: 0 },
|
||||
notices: [],
|
||||
};
|
||||
|
||||
render(<MemoryRouter><PluginsView {...view} /></MemoryRouter>);
|
||||
|
||||
expect(screen.getAllByText('随应用提供')).toHaveLength(2);
|
||||
expect(screen.queryByText('未下载官方包')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows one dialog detail surface, confirms destructive commands, and embeds Data Service settings', () => {
|
||||
const view = props(officialItem);
|
||||
render(<MemoryRouter><PluginsView {...view} /></MemoryRouter>);
|
||||
@@ -471,6 +510,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>');
|
||||
|
||||
199
tests/unit/project-scaffold-plugin.test.ts
Normal file
199
tests/unit/project-scaffold-plugin.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
devicePackageKind,
|
||||
inspectDevicePackage,
|
||||
} from '@electron/coding-packages/device-package-format';
|
||||
import { loadBundledCodingPluginDefinitions } from '@electron/coding-plugins/manifest';
|
||||
import { createCodingProjectMetadata } from '@electron/coding-projects/project-config';
|
||||
import { createStaticProjectPackage } from '@electron/services/project-packager';
|
||||
import {
|
||||
PROJECT_SCAFFOLD_BUNDLED_RELEASE_ID,
|
||||
PROJECT_SCAFFOLD_PLUGIN_ID,
|
||||
isCodeOwnedOptionalBundledPluginId,
|
||||
} from '../../shared/coding-plugins';
|
||||
|
||||
const pluginRoot = resolve('resources/coding-plugins/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('loads as a fixed code-owned bundled Skill while retaining portable plugin metadata', async () => {
|
||||
const packageJson = JSON.parse(await readFile(join(pluginRoot, 'package.json'), 'utf8'));
|
||||
const portableManifest = JSON.parse(await readFile(
|
||||
join(pluginRoot, '.codex-plugin', 'plugin.json'),
|
||||
'utf8',
|
||||
));
|
||||
const marketplaceManifest = JSON.parse(await readFile(join(pluginRoot, 'plugin.json'), 'utf8'));
|
||||
const inspected = await inspectDevicePackage(pluginRoot);
|
||||
const definitions = await loadBundledCodingPluginDefinitions(resolve('resources/coding-plugins'));
|
||||
const definition = definitions.find(({ id }) => id === PROJECT_SCAFFOLD_PLUGIN_ID);
|
||||
|
||||
expect({ name: packageJson.name, version: packageJson.version }).toEqual({
|
||||
name: marketplaceManifest.name,
|
||||
version: marketplaceManifest.version,
|
||||
});
|
||||
expect({ name: portableManifest.name, version: portableManifest.version }).toEqual({
|
||||
name: marketplaceManifest.name,
|
||||
version: marketplaceManifest.version,
|
||||
});
|
||||
expect(portableManifest.skills).toBe('./skills/');
|
||||
expect(devicePackageKind(inspected)).toBe('skill-only');
|
||||
expect(inspected).toMatchObject({
|
||||
packageId: PROJECT_SCAFFOLD_PLUGIN_ID,
|
||||
resolvedVersion: '1.0.0',
|
||||
extensionEntries: [],
|
||||
hasSkillScripts: true,
|
||||
ignoredLifecycleScripts: [],
|
||||
skillEntries: [{
|
||||
id: 'makelore-project-scaffold',
|
||||
entryPath: 'skills/makelore-project-scaffold/SKILL.md',
|
||||
}],
|
||||
});
|
||||
expect(definition).toMatchObject({
|
||||
id: PROJECT_SCAFFOLD_PLUGIN_ID,
|
||||
version: '1.0.0',
|
||||
runtimeKind: 'skill_only',
|
||||
acquisitionMode: 'user_acquired',
|
||||
releaseId: PROJECT_SCAFFOLD_BUNDLED_RELEASE_ID,
|
||||
provenance: { source: 'bundled', packageRoot: 'project-scaffold' },
|
||||
skills: [{
|
||||
id: 'makelore-project-scaffold',
|
||||
entryPath: 'skills/makelore-project-scaffold/SKILL.md',
|
||||
grants: [],
|
||||
}],
|
||||
tools: [],
|
||||
});
|
||||
expect(isCodeOwnedOptionalBundledPluginId(PROJECT_SCAFFOLD_PLUGIN_ID)).toBe(true);
|
||||
});
|
||||
|
||||
it('ships the executable scaffold and every Skill resource in the bundled root', async () => {
|
||||
const skillRoot = join(pluginRoot, 'skills', 'makelore-project-scaffold');
|
||||
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 = 'interactive_ai_app' as const;
|
||||
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