4a46f89c9b
客户端新增随包提供的插件宿主和 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>
417 lines
13 KiB
JavaScript
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}"`));
|
|
});
|