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>
223 lines
7.0 KiB
JavaScript
223 lines
7.0 KiB
JavaScript
'use strict';
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { EventEmitter } = require('node:events');
|
|
const validate = require('./validate.cjs');
|
|
const catalog = require('./catalog.json');
|
|
const schema = (name) => catalog.find((t) => t.name === name).inputSchema;
|
|
|
|
test('catalog accepts nested UI and rejects invalid or ambiguous parameters', async () => {
|
|
const { buildCocosOperationCode } = await import(
|
|
'../cocos-editor-operations.mjs'
|
|
);
|
|
let nodes = [{ kind: 'label', text: '文字' }];
|
|
for (let level = 1; level < 12; level++)
|
|
nodes = [{ kind: 'container', children: nodes }];
|
|
const code = buildCocosOperationCode('cocos_apply_ui_spec', {
|
|
parentNid: 1,
|
|
nodes,
|
|
save: false,
|
|
});
|
|
assert.ok(Buffer.byteLength(code) < 128 * 1024);
|
|
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
new AsyncFunction('Editor', 'require', code);
|
|
assert.throws(
|
|
() =>
|
|
buildCocosOperationCode('cocos_delete_node', { nid: 1, confirm: false }),
|
|
/必须/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
buildCocosOperationCode('cocos_set_node_name', {
|
|
nid: 1,
|
|
name: 'ok',
|
|
command: 'exec',
|
|
}),
|
|
/未知/,
|
|
);
|
|
assert.throws(() => buildCocosOperationCode('cocos_unknown', {}), /不支持/);
|
|
assert.throws(
|
|
() =>
|
|
validate(schema('cocos_set_node_transform'), {
|
|
nid: 1,
|
|
scale: { x: Infinity, y: 1, z: 1 },
|
|
}),
|
|
/有限/,
|
|
);
|
|
});
|
|
|
|
test('UI limits and duplicate IDs fail before touching the scene or assets', async () => {
|
|
const create = require('./main.cjs');
|
|
let touched = 0;
|
|
const load = (name) => (name === 'validate.cjs' ? validate : () => ({}));
|
|
const requireMock = (name) =>
|
|
name === 'electron'
|
|
? {
|
|
webContents: {
|
|
getAllWebContents() {
|
|
touched++;
|
|
return [];
|
|
},
|
|
},
|
|
}
|
|
: require(name);
|
|
const runtime = create(
|
|
{ Project: { path: os.tmpdir() } },
|
|
requireMock,
|
|
load,
|
|
{},
|
|
'test',
|
|
[],
|
|
);
|
|
const invoke = (nodes) =>
|
|
runtime.execute(
|
|
'cocos_apply_ui_spec',
|
|
{ parentNid: 1, nodes, save: false },
|
|
schema('cocos_apply_ui_spec'),
|
|
);
|
|
await assert.rejects(
|
|
invoke([
|
|
{ kind: 'container', id: 'same' },
|
|
{ kind: 'label', id: 'same' },
|
|
]),
|
|
/重复/,
|
|
);
|
|
await assert.rejects(
|
|
invoke(Array.from({ length: 33 }, () => ({ kind: 'button' }))),
|
|
/64/,
|
|
);
|
|
let nodes = [{ kind: 'container' }];
|
|
for (let i = 1; i < 13; i++) nodes = [{ kind: 'container', children: nodes }];
|
|
await assert.rejects(invoke(nodes), /12/);
|
|
assert.equal(touched, 0);
|
|
});
|
|
|
|
test('log reads are bounded and do not follow project escapes or junctions', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-cocos-logs-'));
|
|
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-cocos-outside-'));
|
|
try {
|
|
fs.mkdirSync(path.join(root, 'temp/logs'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(root, 'temp/logs/project.log'),
|
|
'first\nerror: test\nlast',
|
|
);
|
|
const paths = require('./paths.cjs')(root, require);
|
|
assert.deepEqual((await paths.tail('temp/logs/project.log', 2)).lines, [
|
|
'error: test',
|
|
'last',
|
|
]);
|
|
assert.equal((await paths.tail('temp/logs/missing.log')).missing, true);
|
|
await assert.rejects(paths.tail('../secret.log'), /只读取/);
|
|
assert.throws(() => paths.resolve('assets/../../outside'), /路径/);
|
|
fs.symlinkSync(
|
|
outside,
|
|
path.join(root, 'assets'),
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
);
|
|
assert.throws(() => paths.resolve('assets/leak.png'), /链接|junction/);
|
|
} finally {
|
|
// 测试自己创建的两个明确临时根;先移除链接,递归清理不跟随外部目录。
|
|
if (fs.existsSync(path.join(root, 'assets')))
|
|
fs.unlinkSync(path.join(root, 'assets'));
|
|
for (const target of [root, outside]) {
|
|
assert.equal(path.dirname(target), os.tmpdir());
|
|
fs.rmSync(target, { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
function previewHarness({ stall = false } = {}) {
|
|
const windows = [];
|
|
class Window {
|
|
constructor() {
|
|
this.destroyed = false;
|
|
this.loaded = false;
|
|
this.calls = [];
|
|
const debuggerApi = new EventEmitter();
|
|
debuggerApi.attach = () => this.calls.push('attach');
|
|
debuggerApi.detach = () => this.calls.push('detach');
|
|
debuggerApi.sendCommand = async (command) => {
|
|
assert.equal(this.loaded, true, 'renderer must exist before CDP');
|
|
this.calls.push(command);
|
|
};
|
|
this.webContents = new EventEmitter();
|
|
this.webContents.debugger = debuggerApi;
|
|
this.webContents.setWindowOpenHandler = (fn) => (this.windowOpen = fn);
|
|
this.webContents.capturePage = async () => ({
|
|
isEmpty: () => false,
|
|
resize: () => ({
|
|
getSize: () => ({ width: 100, height: 60 }),
|
|
toPNG: () => Buffer.from('png'),
|
|
}),
|
|
getSize: () => ({ width: 100, height: 60 }),
|
|
});
|
|
windows.push(this);
|
|
}
|
|
async loadURL(url) {
|
|
this.calls.push(url);
|
|
if (stall) return new Promise(() => {});
|
|
this.loaded = true;
|
|
}
|
|
isDestroyed() {
|
|
return this.destroyed;
|
|
}
|
|
destroy() {
|
|
this.destroyed = true;
|
|
}
|
|
}
|
|
const electron = {
|
|
BrowserWindow: Window,
|
|
webContents: {
|
|
getAllWebContents: () => [
|
|
{
|
|
getType: () => 'webview',
|
|
getURL: () => 'http://localhost:7456/preview',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
return {
|
|
windows,
|
|
runtime: require('./preview.cjs')((name) =>
|
|
name === 'electron' ? electron : require(name),
|
|
),
|
|
};
|
|
}
|
|
test('managed preview captures errors and only closes its own window', async () => {
|
|
const { runtime, windows } = previewHarness();
|
|
await runtime.start({ url: 'http://localhost:7456/', timeoutMs: 100 });
|
|
const window = windows[0];
|
|
assert.equal(window.calls[0], 'about:blank');
|
|
assert.equal(window.windowOpen().action, 'deny');
|
|
window.webContents.debugger.emit('message', {}, 'Runtime.exceptionThrown', {
|
|
exceptionDetails: { text: 'script error' },
|
|
});
|
|
window.webContents.debugger.emit('message', {}, 'Network.loadingFailed', {
|
|
errorText: 'network failed',
|
|
});
|
|
assert.equal(runtime.read({ errorsOnly: true }).events.length, 2);
|
|
assert.equal((await runtime.capture()).__image.mimeType, 'image/png');
|
|
await runtime.stop();
|
|
assert.equal(window.destroyed, true);
|
|
assert.throws(() => runtime.read({}), /没有/);
|
|
await assert.rejects(
|
|
runtime.start({ url: 'http://localhost:9999/' }),
|
|
/当前 Creator/,
|
|
);
|
|
await assert.rejects(
|
|
runtime.start({ url: 'https://example.com' }),
|
|
/loopback/,
|
|
);
|
|
});
|
|
test('preview initialization timeout tears down even before renderer is ready', async () => {
|
|
const { runtime, windows } = previewHarness({ stall: true });
|
|
await assert.rejects(
|
|
runtime.start({ url: 'http://localhost:7456/', timeoutMs: 10 }),
|
|
/超时/,
|
|
);
|
|
assert.equal(windows[0].destroyed, true);
|
|
});
|