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>
255 lines
8.3 KiB
JavaScript
255 lines
8.3 KiB
JavaScript
// 对显式指定的自有测试工程执行真实操作;先用 native creator_smoke 建立桥接。
|
|
import assert from 'node:assert/strict';
|
|
import { randomUUID } from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import net from 'node:net';
|
|
import path from 'node:path';
|
|
|
|
import {
|
|
buildCocosOperationCode,
|
|
COCOS_EDITOR_OPERATIONS,
|
|
} from '../src/cocos-editor-operations.mjs';
|
|
|
|
const project = fs.realpathSync(process.argv[2] || '');
|
|
const pid = Number(process.argv[3]);
|
|
assert.ok(Number.isInteger(pid) && pid > 0, '需要显式指定 Creator PID');
|
|
assert.equal(
|
|
JSON.parse(fs.readFileSync(path.join(project, 'package.json'), 'utf8')).name,
|
|
'agc-cocos-capability-smoke',
|
|
'只允许自有测试工程,不操作用户项目',
|
|
);
|
|
const report = [];
|
|
const reportPath = path.join(project, 'temp/agc-operation-smoke.json');
|
|
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
|
|
|
async function raw(code) {
|
|
const request = {
|
|
schemaVersion: 'game-creator-cocos-editor-bridge.v1',
|
|
requestId: randomUUID(),
|
|
processId: pid,
|
|
projectPath: project,
|
|
command: { op: 'execute', code },
|
|
};
|
|
return await new Promise((resolve, reject) => {
|
|
const socket = net.connect('\\\\.\\pipe\\genarrative-cocos-editor-' + pid);
|
|
let output = '';
|
|
socket.setTimeout(60000);
|
|
socket.on('connect', () => socket.write(JSON.stringify(request) + '\n'));
|
|
socket.on('data', (data) => {
|
|
output += data.toString();
|
|
if (!output.includes('\n')) return;
|
|
socket.destroy();
|
|
const response = JSON.parse(output.split('\n')[0]);
|
|
assert.equal(response.requestId, request.requestId);
|
|
resolve(response);
|
|
});
|
|
socket.on('error', reject);
|
|
socket.on('timeout', () => {
|
|
socket.destroy();
|
|
reject(new Error('结果不确定:禁止自动重放'));
|
|
});
|
|
socket.on('end', () => {
|
|
if (!output.includes('\n')) reject(new Error('回执不完整'));
|
|
});
|
|
});
|
|
}
|
|
async function op(name, args = {}) {
|
|
const start = Date.now();
|
|
const response = await raw(buildCocosOperationCode(name, args));
|
|
const copy = structuredClone(response);
|
|
if (copy.result?.result?.__image) copy.result.result.__image.data = '[PNG]';
|
|
report.push({ name, elapsedMs: Date.now() - start, response: copy });
|
|
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
|
assert.equal(response.ok, true, response.error);
|
|
assert.equal(
|
|
response.result.status,
|
|
'completed',
|
|
JSON.stringify(response.result),
|
|
);
|
|
return response.result.result ?? response.result;
|
|
}
|
|
async function evalScene(source) {
|
|
const result = await raw(
|
|
"return await require('electron').webContents.getAllWebContents().find(w=>w.getURL().startsWith('packages://scene/')).executeJavaScript(" +
|
|
JSON.stringify(source) +
|
|
');',
|
|
);
|
|
assert.equal(result.ok, true, result.error);
|
|
return result.result;
|
|
}
|
|
|
|
await op('cocos_ping');
|
|
await op('cocos_get_capabilities');
|
|
await op('cocos_get_project_info');
|
|
const meta = await op('cocos_get_current_scene_meta');
|
|
assert.ok(meta.named && !meta.dirty, '测试要求已保存且无未保存修改的 2D 场景');
|
|
const mainUuid = meta.sceneAssetUuid;
|
|
const tree = await op('cocos_get_hierarchy');
|
|
const canvas = tree.tree.children.find((node) => node.name === 'Canvas');
|
|
assert.ok(canvas, '测试工程需要 Canvas');
|
|
const parent = canvas.uuid;
|
|
const baseline = await evalScene(
|
|
'cce.SceneFacadeManager.getCurrentFacade()._sceneProxy.serialize(true)',
|
|
);
|
|
const node = await op('cocos_create_node', {
|
|
parentNid: parent,
|
|
name: 'AGC_TestNode',
|
|
});
|
|
await op('cocos_set_node_name', { nid: node.nid, name: 'AGC_Renamed' });
|
|
await op('cocos_set_node_transform', {
|
|
nid: node.nid,
|
|
position: { x: 12, y: 34, z: 0 },
|
|
rotationEuler: { x: 0, y: 0, z: 15 },
|
|
scale: { x: 2, y: 2, z: 1 },
|
|
});
|
|
await op('cocos_set_node_active', { nid: node.nid, active: false });
|
|
const component = await op('cocos_add_component', {
|
|
nid: node.nid,
|
|
componentType: 'Label',
|
|
});
|
|
await op('cocos_set_component_property', {
|
|
nid: node.nid,
|
|
componentIndex: component.componentIndex,
|
|
property: 'string',
|
|
value: '测试',
|
|
});
|
|
await op('cocos_remove_component', {
|
|
nid: node.nid,
|
|
componentIndex: component.componentIndex,
|
|
expectedComponentType: 'Label',
|
|
});
|
|
await op('cocos_mcp_undo_last');
|
|
const clone = await op('cocos_duplicate_node', { nid: node.nid });
|
|
await op('cocos_reparent_node', { childNid: clone.nid, parentNid: node.nid });
|
|
await op('cocos_set_node_sibling_index', { nid: node.nid, index: 0 });
|
|
await op('cocos_search_nodes', {
|
|
nameSubstring: 'AGC_Renamed',
|
|
componentFilter: 'Label',
|
|
});
|
|
await op('cocos_inspect_node', { nid: node.nid });
|
|
await op('cocos_set_node_name', {
|
|
nid: node.nid,
|
|
name: 'AGC_BeforeEditorUndo',
|
|
});
|
|
await op('cocos_editor_undo');
|
|
assert.equal(
|
|
(await op('cocos_inspect_node', { nid: node.nid })).name,
|
|
'AGC_Renamed',
|
|
);
|
|
await op('cocos_delete_node', { nid: node.nid, confirm: true, cascade: true });
|
|
await op('cocos_save_scene');
|
|
|
|
for (const [kind, args] of [
|
|
['shape', { shape: 'circle', color: '#FF0000' }],
|
|
['label', { text: 'Cocos smoke', fontSize: 24 }],
|
|
['sprite', { assetPath: 'assets/agc-ui-shapes/circle.png', nineSlice: true }],
|
|
['button', { label: 'Start', color: '#0088FF' }],
|
|
]) {
|
|
await op('cocos_create_ui_' + kind, {
|
|
parentNid: parent,
|
|
name: 'AGC_UI_' + kind,
|
|
...args,
|
|
save: true,
|
|
});
|
|
await op('cocos_mcp_undo_last');
|
|
}
|
|
await op('cocos_apply_ui_spec', {
|
|
parentNid: parent,
|
|
save: true,
|
|
nodes: [
|
|
{
|
|
kind: 'container',
|
|
layout: { type: 'horizontal', spacingX: 12 },
|
|
children: [
|
|
{ kind: 'shape', shape: 'circle', color: '#FF0000' },
|
|
{ kind: 'shape', shape: 'rectangle', color: '#0000FF' },
|
|
{ kind: 'button', buttonLabel: 'Start' },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
await op('cocos_mcp_undo_last');
|
|
assert.equal(
|
|
await evalScene(
|
|
'cce.SceneFacadeManager.getCurrentFacade()._sceneProxy.serialize(true)',
|
|
),
|
|
baseline,
|
|
);
|
|
|
|
await op('cocos_get_log_tail', { maxLines: 10 });
|
|
await op('cocos_get_build_diagnostics', { maxFiles: 2, maxLinesPerFile: 20 });
|
|
await op('cocos_diagnose', { maxLogLines: 10 });
|
|
await op('cocos_list_assets', { extensions: ['png'], maxResults: 10 });
|
|
const seed = await op('cocos_create_node', {
|
|
parentNid: parent,
|
|
name: 'AGC_PrefabSeed',
|
|
});
|
|
const data = await evalScene(
|
|
'(()=>{const cc=require("cc"),p=new cc.Prefab();p.data=cc.instantiate(cce.Node.query(' +
|
|
JSON.stringify(seed.uuid) +
|
|
'));try{return cce.Utils.serialize(p);}finally{p.data.destroy();p.destroy();}})()',
|
|
);
|
|
const prefabPath = 'assets/AGC-Smoke-' + Date.now() + '.prefab';
|
|
const asset = await raw(
|
|
"return await Editor.Message.request('asset-db','create-asset'," +
|
|
JSON.stringify('db://' + prefabPath) +
|
|
',' +
|
|
JSON.stringify(data) +
|
|
');',
|
|
);
|
|
assert.equal(asset.ok, true, asset.error);
|
|
await op('cocos_get_prefab_info', { prefabPath });
|
|
const instance = await op('cocos_instantiate_prefab', {
|
|
parentNid: parent,
|
|
prefabPath,
|
|
});
|
|
await op('cocos_delete_node', {
|
|
nid: instance.nid,
|
|
confirm: true,
|
|
cascade: true,
|
|
});
|
|
await op('cocos_delete_node', { nid: seed.nid, confirm: true, cascade: true });
|
|
await op('cocos_save_scene');
|
|
assert.equal(
|
|
(
|
|
await raw(
|
|
"return await Editor.Message.request('scene','load-empty-scene');",
|
|
)
|
|
).ok,
|
|
true,
|
|
);
|
|
await op('cocos_save_scene', {
|
|
path: 'assets/AGC-Fresh-' + Date.now() + '.scene',
|
|
});
|
|
assert.equal(
|
|
(
|
|
await raw(
|
|
"return await Editor.Message.request('scene','open-scene'," +
|
|
JSON.stringify(mainUuid) +
|
|
');',
|
|
)
|
|
).ok,
|
|
true,
|
|
);
|
|
|
|
const urls = await raw(
|
|
"return require('electron').webContents.getAllWebContents().filter(w=>w.getType()==='webview').map(w=>w.getURL()).filter(url=>url.startsWith('http://localhost:'));",
|
|
);
|
|
const url = new URL(urls.result[0]).origin + '/';
|
|
await op('cocos_preview_debug_start', { url });
|
|
try {
|
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
await op('cocos_preview_debug_read', { maxEvents: 30 });
|
|
const capture = await op('cocos_preview_debug_capture');
|
|
assert.equal(capture.__image.mimeType, 'image/png');
|
|
fs.writeFileSync(
|
|
path.join(project, 'temp/agc-preview-smoke.png'),
|
|
Buffer.from(capture.__image.data, 'base64'),
|
|
);
|
|
} finally {
|
|
await op('cocos_preview_debug_stop');
|
|
}
|
|
const names = new Set(report.map((item) => item.name));
|
|
assert.deepEqual([...names].sort(), [...COCOS_EDITOR_OPERATIONS].sort());
|
|
console.log('36/36 Cocos operations passed; report: ' + reportPath);
|