Files
Genarrative/plugins/agc-cocos-editor/src/entry.test.mjs
T
kdletters 4a46f89c9b
Project CI / Repository checks (push) Successful in 2m45s
Project CI / Frontend tests (push) Successful in 3m27s
Project CI / Backend tests (push) Successful in 6m18s
Project CI / Native shell tests (push) Failing after 13m52s
接入 AGC 内置插件宿主并补齐 Cocos 编辑器能力 (#338)
客户端新增随包提供的插件宿主和 Cocos Creator 集成:识别并导入 Cocos 项目,通过内置桥接操作已打开的编辑器,无需安装项目 MCP 扩展。DirectProject 现在公开 36 个独立 cocos_* 工具,保留通用 JavaScript 执行入口。

- 通用插件 SDK、命令/能力/面板注册、编辑器适配器和跨进程内置插件开关。
- Cocos 场景、节点、组件、Prefab、UI、Layout/Widget、资源、保存、撤销、日志与预览调试;目录和实现由 JS/native 共用。
- 编辑事务回读、失败回滚、后续手动修改保护及不确定结果禁止重放;预览截图通过 MCP image 返回。
- DirectProject 跳过无关专业 Agent 历史,将项目打开和历史读取中的同步 I/O 移出窗口线程,消除 Cocos 执行与项目文件锁的错误耦合。

验证:
- 合并 master 后:类型/配置检查、编码检查、Rust 格式检查和提交钩子通过。
- 合并 master 后:Cocos 项目打开、插件面板和开发启动定向测试 10 通过、2 跳过;DirectProject MCP 测试 17 通过、1 项真实 Creator opt-in 忽略;插件宿主测试 9/9。
- 插件行为测试 17/17;native 测试 20/20,4 项 opt-in 测试默认忽略。
- 真实 Creator 3.8.8 的 36/36 操作 smoke,以及客户端 MCP tools/list、tools/call、UI/撤销和预览截图,在功能实现阶段已验证通过;本次 master 合并后未重复真实 GUI smoke。

验证边界:发行安装包和远端 CI 尚未验收。

Reviewed-on: #338
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
2026-09-13 14:48:55 +08:00

417 lines
13 KiB
JavaScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import {
buildEditorRpcRequest,
COCOS_EDITOR_OPERATIONS,
validateExecuteCode,
} from './cocos-editor-adapter.mjs';
import {
COCOS_CONNECTION_CAPABILITY_ID,
COCOS_EDITOR_PANEL,
COCOS_EXECUTE_COMMAND_ID,
COCOS_OPERATION_COMMAND_ID,
COCOS_PLUGIN_PROTOCOL_VERSION,
createCocosEditorPlugin,
PROJECT_CHANGED_EVENT,
} from './entry.mjs';
const here = path.dirname(fileURLToPath(import.meta.url));
const pluginRoot = path.join(here, '..');
const tick = () => new Promise((resolve) => setImmediate(resolve));
function createHarness(timeoutMs) {
const outbound = [];
const plugin = createCocosEditorPlugin({
timeoutMs,
send: (message) => outbound.push(structuredClone(message)),
});
const respond = (id, result) =>
plugin.handleMessage({ jsonrpc: '2.0', id, result });
return { plugin, outbound, respond };
}
async function startPlugin(harness, projectPath = 'C:\\demo') {
const started = harness.plugin.start();
await tick();
harness.respond(harness.outbound.at(-1).id, { registered: true });
await tick();
harness.respond(harness.outbound.at(-1).id, { registered: true });
await tick();
harness.respond(harness.outbound.at(-1).id, { registered: true });
await tick();
harness.respond(harness.outbound.at(-1).id, { registered: true });
await tick();
harness.respond(harness.outbound.at(-1).id, {
subscriptionId: 'sub-1',
projectPath,
});
return started;
}
test('runtime entry registers command, capability, panel and project event', async () => {
const harness = createHarness();
const result = await startPlugin(harness);
const methods = harness.outbound.map((message) => message.method);
assert.deepEqual(methods, [
'host.registerCommand',
'host.registerCapability',
'host.registerCommand',
'host.registerPanel',
'host.events.subscribe',
]);
assert.equal(harness.outbound[0].params.id, COCOS_EXECUTE_COMMAND_ID);
assert.equal(harness.outbound[1].params.id, COCOS_CONNECTION_CAPABILITY_ID);
assert.equal(harness.outbound[2].params.id, COCOS_OPERATION_COMMAND_ID);
assert.deepEqual(harness.outbound[3].params, { ...COCOS_EDITOR_PANEL });
assert.equal(harness.outbound[4].params.type, PROJECT_CHANGED_EVENT);
assert.equal(result.subscriptionId, 'sub-1');
assert.equal(harness.plugin.activeProjectPath, 'C:\\demo');
});
test('operation command validates and routes a named Cocos operation', async () => {
const harness = createHarness();
await startPlugin(harness);
const handling = harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 78,
method: COCOS_OPERATION_COMMAND_ID,
params: { operation: 'cocos_get_hierarchy', args: { maxNodes: 10 } },
});
await tick();
const rpc = harness.outbound.at(-1);
assert.equal(rpc.method, 'host.rpc');
assert.equal(rpc.params.method, 'editor.execute');
assert.match(rpc.params.params.code, /cocos_get_hierarchy/);
assert.equal(rpc.params.params.timeoutMs, 60_000);
harness.respond(rpc.id, { ok: true });
await handling;
assert.equal(harness.outbound.at(-1).result.status, 'completed');
});
test('semantic reconciliation from an operation blocks later execute', async () => {
const harness = createHarness();
await startPlugin(harness);
const handling = harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 178,
method: COCOS_OPERATION_COMMAND_ID,
params: { operation: 'cocos_get_hierarchy', args: {} },
});
await tick();
await harness.respond(harness.outbound.at(-1).id, {
ok: true,
result: { status: 'needs-reconciliation', rollbackError: 'restore failed' },
});
await handling;
assert.equal(harness.outbound.at(-1).result.status, 'needs-reconciliation');
const count = harness.outbound.length;
await harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 179,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: 'return 1;' },
});
assert.equal(harness.outbound.length, count + 1);
assert.equal(harness.outbound.at(-1).result.retryAllowed, false);
harness.plugin.dispose();
});
test('execute command routes through host.rpc with the active project', async () => {
const harness = createHarness();
await startPlugin(harness);
const handling = harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 77,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: 'return Editor.Project.path;' },
});
await tick();
const rpc = harness.outbound.at(-1);
assert.equal(rpc.method, 'host.rpc');
assert.deepEqual(rpc.params, {
method: 'editor.execute',
params: {
projectPath: 'C:\\demo',
code: 'return Editor.Project.path;',
},
});
harness.respond(rpc.id, { ok: true, requestId: 'cocos-1' });
await handling;
const reply = harness.outbound.at(-1);
assert.equal(reply.id, 77);
assert.equal(reply.result.status, 'completed');
});
test('execute command fails closed without a project or with invalid code', async () => {
const harness = createHarness();
await startPlugin(harness, null);
await harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 1,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: 'return 1;' },
});
assert.match(harness.outbound.at(-1).error.message, /项目路径/);
await harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 2,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: ' ', projectPath: 'C:\\demo' },
});
assert.match(harness.outbound.at(-1).error.message, /不能为空/);
});
test('connection capability dispatches adapter operations', async () => {
const harness = createHarness();
await startPlugin(harness);
const handling = harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 5,
method: COCOS_CONNECTION_CAPABILITY_ID,
params: { operation: 'ping', processId: 42 },
});
await tick();
const rpc = harness.outbound.at(-1);
assert.deepEqual(rpc.params, {
method: 'editor.ping',
params: { processId: 42, projectPath: 'C:\\demo' },
});
harness.respond(rpc.id, { ok: true });
await handling;
assert.equal(harness.outbound.at(-1).result.ok, true);
await harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 6,
method: COCOS_CONNECTION_CAPABILITY_ID,
params: { operation: 'eval' },
});
assert.match(harness.outbound.at(-1).error.message, /不支持的能力操作/);
});
test('project.changed event updates the cached project path', async () => {
const harness = createHarness();
await startPlugin(harness);
await harness.plugin.handleMessage({
jsonrpc: '2.0',
method: 'host.event',
params: {
type: PROJECT_CHANGED_EVENT,
payload: { projectPath: 'D:\\other' },
},
});
assert.equal(harness.plugin.activeProjectPath, 'D:\\other');
await harness.plugin.handleMessage({
jsonrpc: '2.0',
method: 'host.event',
params: { type: PROJECT_CHANGED_EVENT, payload: { projectPath: null } },
});
assert.equal(harness.plugin.activeProjectPath, null);
});
test('execute rejects concurrent requests and blocks later requests after uncertainty', async () => {
const harness = createHarness();
await startPlugin(harness);
const first = harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 71,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: 'return 1;' },
});
const second = harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 72,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: 'return 2;' },
});
await tick();
const requests = harness.outbound.filter(
(item) => item.method === 'host.rpc',
);
assert.equal(requests.length, 1);
await harness.respond(requests[0].id, {
ok: false,
status: 'needs-reconciliation',
retryAllowed: false,
});
await Promise.all([first, second]);
await harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 73,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: 'return 3;' },
});
assert.equal(
harness.outbound.filter((item) => item.method === 'host.rpc').length,
1,
);
assert.equal(
harness.outbound.find((item) => item.id === 72 && item.result).result
.retryAllowed,
false,
);
for (const id of [71, 73]) {
const reply = harness.outbound.find(
(item) => item.id === id && item.result,
);
assert.equal(reply.result.status, 'needs-reconciliation');
assert.equal(reply.result.retryAllowed, false);
}
});
test('host RPC failure blocks later execute without resending', async () => {
const harness = createHarness();
await startPlugin(harness);
const first = harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 81,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: 'return 1;' },
});
await tick();
const rpc = harness.outbound.at(-1);
await harness.plugin.handleMessage({
jsonrpc: '2.0',
id: rpc.id,
error: { message: 'connection closed' },
});
await first;
await harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 82,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: 'return 2;' },
});
assert.equal(
harness.outbound.filter((item) => item.method === 'host.rpc').length,
1,
);
assert.equal(harness.outbound.at(-1).result.retryAllowed, false);
});
test('execute timeout keeps later requests blocked even after a late success', async () => {
const harness = createHarness(100);
await startPlugin(harness);
await harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 91,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: 'return 1;' },
});
const rpc = harness.outbound.find((item) => item.method === 'host.rpc');
assert.equal(harness.outbound.at(-1).result.status, 'needs-reconciliation');
await harness.respond(rpc.id, { ok: true });
await harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 92,
method: COCOS_CONNECTION_CAPABILITY_ID,
params: { operation: 'execute', code: 'return 2;' },
});
assert.equal(
harness.outbound.filter((item) => item.method === 'host.rpc').length,
1,
);
assert.equal(harness.outbound.at(-1).result.retryAllowed, false);
});
test('adapter request builder enforces per-operation parameters', () => {
assert.deepEqual(
buildEditorRpcRequest('status', {
processId: 7,
projectPath: 'C:\\demo',
}),
{
method: 'editor.status',
params: { processId: 7, projectPath: 'C:\\demo' },
},
);
assert.throws(
() =>
buildEditorRpcRequest('execute', { projectPath: 'C:\\demo', code: '' }),
/不能为空/,
);
assert.deepEqual(
buildEditorRpcRequest('inject', {
processId: 1,
projectPath: 'C:\\demo',
payloadPath: 'C:\\payload\\cocos-editor-bridge.dll',
}).params.payloadPath,
'C:\\payload\\cocos-editor-bridge.dll',
);
assert.throws(
() =>
buildEditorRpcRequest('ping', {
processId: 1,
projectPath: 'C:\\demo',
payloadPath: 'C:\\payload\\cocos-editor-bridge.dll',
}),
/payloadPath/,
);
assert.throws(() => buildEditorRpcRequest('detect', {}), /缺少 projectPath/);
assert.throws(() => buildEditorRpcRequest('eval', {}), /不支持/);
assert.throws(() => validateExecuteCode('x'.repeat(128 * 1024 + 1)), /上限/);
});
test('manifest, native adapter and SDK agree on ids and protocol', () => {
const manifest = JSON.parse(
fs.readFileSync(path.join(pluginRoot, 'plugin.json'), 'utf8'),
);
assert.equal(
manifest.$schema,
'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json',
);
assert.equal(manifest.name, 'agc-cocos-editor');
const runtime = manifest.extensions['world.genarrative.agc'];
assert.equal(runtime.apiVersion, 'v1');
assert.equal(runtime.adapter, 'cocos-editor');
assert.equal(runtime.entry, './src/entry.mjs');
assert.ok(fs.existsSync(path.join(pluginRoot, runtime.entry)));
assert.deepEqual(
runtime.panels.map((panel) => panel.id),
[COCOS_EDITOR_PANEL.id],
);
for (const panel of runtime.panels) {
assert.ok(
fs.existsSync(path.join(pluginRoot, panel.entry)),
`panel entry missing: ${panel.entry}`,
);
}
assert.ok(runtime.permissions.includes('editor.rpc'));
assert.ok(runtime.permissions.includes('capability.register'));
const nativeAdapter = fs.readFileSync(
path.join(pluginRoot, 'native/cocos-editor-bridge/src/adapter.rs'),
'utf8',
);
const nativeOperations = [
...nativeAdapter.matchAll(/\("editor\.([a-z]+)", "([a-z]+)"\)/gu),
].map((match) => match[1]);
assert.deepEqual(nativeOperations, [...COCOS_EDITOR_OPERATIONS]);
assert.ok(nativeAdapter.includes(`"${runtime.adapter}"`));
const sdk = fs.readFileSync(
path.join(pluginRoot, '../../packages/agc-plugin-sdk/src/index.ts'),
'utf8',
);
assert.ok(
sdk.includes(
`AGC_PLUGIN_PROTOCOL_VERSION = '${COCOS_PLUGIN_PROTOCOL_VERSION}'`,
),
);
const host = fs.readFileSync(
path.join(
pluginRoot,
'../../apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs',
),
'utf8',
);
assert.ok(host.includes(`"${COCOS_PLUGIN_PROTOCOL_VERSION}"`));
assert.ok(host.includes(`"${runtime.apiVersion}"`));
});