Files
Genarrative/plugins/agc-cocos-editor/scripts/smoke-creator-operations.mjs
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

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