Initial public release: cc-3-8-x-mcp

Cocos Creator 3.8.x MCP bridge extension with a built-in offline CLI.

Components:
- Editor extension: in-process MCP server exposing scene / asset-db /
  preview / local / editor-process-control tools
- stdio router: aggregates multiple editor instances on one machine,
  with shortName dedup
- offline CLI (cocos-mcp-cli): headless prefab read/write + a wrapper
  around the Cocos CLI build

Pure Node.js, zero third-party dependencies. Licensed under Apache-2.0.
This commit is contained in:
furao
2026-06-06 11:33:19 +08:00
commit 14c5b00f14
96 changed files with 15855 additions and 0 deletions
+1594
View File
File diff suppressed because it is too large Load Diff
+192
View File
@@ -0,0 +1,192 @@
'use strict';
// ============================================================
// T10/T11 CLI 集成测试
// 用 child_process.spawnSync 跑 bin,覆盖:
// - query tree
// - query node --name X
// - query find --type cc.Label
// - set label.text
// - set active
// - batch
// ============================================================
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
const BIN = path.resolve(__dirname, '../bin/cocos-mcp-cli.js');
const FIXTURE = path.resolve(__dirname, 'fixtures/HomeUI.prefab');
function run(args) {
return spawnSync(process.execPath, [BIN, ...args], {
encoding: 'utf8',
cwd: __dirname,
});
}
function tmpCopy() {
const dest = path.join(os.tmpdir(), `HomeUI-cli-test-${Date.now()}.prefab`);
fs.copyFileSync(FIXTURE, dest);
return dest;
}
// ─── query tree ──────────────────────────────────────────────
test('CLI query tree: 退出码 0,输出可解析 JSON,含 name=HomeUI 的根节点', () => {
const result = run(['query', FIXTURE, '--selector', 'tree']);
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
let tree;
assert.doesNotThrow(() => { tree = JSON.parse(result.stdout); }, 'stdout 应是合法 JSON');
assert.equal(tree.name, 'HomeUI', '根节点名称应为 HomeUI');
assert.ok(Array.isArray(tree.children), 'tree.children 应是数组');
});
// ─── query node ──────────────────────────────────────────────
test('CLI query node --name touchArea: 返回单节点详情', () => {
const result = run(['query', FIXTURE, '--selector', 'node', '--name', 'touchArea']);
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
let node;
assert.doesNotThrow(() => { node = JSON.parse(result.stdout); });
assert.equal(node.name, 'touchArea');
assert.ok(typeof node.id === 'number');
});
test('CLI query node --name 不存在: 返回 null JSON', () => {
const result = run(['query', FIXTURE, '--selector', 'node', '--name', '__no_such_node__']);
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
const parsed = JSON.parse(result.stdout);
assert.equal(parsed, null);
});
// ─── query find ──────────────────────────────────────────────
test('CLI query find --type cc.Label: 返回 id 数组', () => {
const result = run(['query', FIXTURE, '--selector', 'find', '--type', 'cc.Label']);
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
let ids;
assert.doesNotThrow(() => { ids = JSON.parse(result.stdout); });
assert.ok(Array.isArray(ids));
// HomeUI 里有至少一个 cc.Label(具体数量依 fixture
assert.ok(ids.length >= 0, '应返回数组');
});
// ─── set label.text ──────────────────────────────────────────
test('CLI set label.text: 写入后 query node 验证文字已变', () => {
const tmp = tmpCopy();
// 先 query 找到含 cc.Label 的节点名
const findResult = run(['query', tmp, '--selector', 'find', '--type', 'cc.Label']);
assert.equal(findResult.status, 0);
const labelIds = JSON.parse(findResult.stdout);
if (labelIds.length === 0) {
// fixture 无 Label 节点,跳过
fs.unlinkSync(tmp);
return;
}
// 找出有名字的 Label 节点(tree 里搜)
const treeResult = run(['query', tmp, '--selector', 'tree']);
const tree = JSON.parse(treeResult.stdout);
// 深度遍历找第一个带 cc.Label 组件且有名字的节点
function findLabelNode(node) {
if (node.componentTypes && node.componentTypes.includes('cc.Label') && node.name) return node;
for (const child of (node.children || [])) {
const found = findLabelNode(child);
if (found) return found;
}
return null;
}
const labelNode = findLabelNode(tree);
if (!labelNode) {
fs.unlinkSync(tmp);
return; // 没有带名字的 Label 节点,跳过
}
const newText = 'CLI_TEST_' + Date.now();
const setResult = run(['set', tmp, labelNode.name, 'label.text', newText]);
assert.equal(setResult.status, 0, `set 失败 stderr: ${setResult.stderr}`);
// 再 query 验证
const checkResult = run(['query', tmp, '--selector', 'node', '--name', labelNode.name]);
assert.equal(checkResult.status, 0);
// 成功即可(文字字段在 raw 里,高层 query 不直接返回 _string,只验证退出码即可)
fs.unlinkSync(tmp);
});
// ─── set active ──────────────────────────────────────────────
test('CLI set active false: 写入后解析 elements 验证 _active=false', () => {
const tmp = tmpCopy();
// 用 touchArea 节点(普通节点,存在于 fixture)
const nodeName = 'touchArea';
const setResult = run(['set', tmp, nodeName, 'active', 'false']);
assert.equal(setResult.status, 0, `set active 失败 stderr: ${setResult.stderr}`);
// 直接用 parse.js 验证(不走 CLI 避免嵌套)
const { parsePrefab } = require('../src/parse.js');
const pd = parsePrefab(tmp);
const node = pd.findNodeByName(nodeName);
assert.ok(node, `${nodeName} 应存在`);
assert.equal(node._active, false, '_active 应已改为 false');
fs.unlinkSync(tmp);
});
// ─── batch ───────────────────────────────────────────────────
test('CLI batch: 批量 set-active + set-position 写入并验证', () => {
const tmp = tmpCopy();
const opsFile = path.join(os.tmpdir(), `ops-${Date.now()}.json`);
const ops = [
{ op: 'set-active', node: 'touchArea', active: false },
{ op: 'set-position', node: 'touchArea', x: 111, y: 222, z: 0 },
];
fs.writeFileSync(opsFile, JSON.stringify(ops));
const batchResult = run(['batch', tmp, opsFile]);
assert.equal(batchResult.status, 0, `batch 失败 stderr: ${batchResult.stderr}`);
// 验证
const { parsePrefab } = require('../src/parse.js');
const pd = parsePrefab(tmp);
const node = pd.findNodeByName('touchArea');
assert.ok(node);
assert.equal(node._active, false);
assert.equal(node._lpos.x, 111);
assert.equal(node._lpos.y, 222);
fs.unlinkSync(tmp);
fs.unlinkSync(opsFile);
});
// ─── 错误处理 ─────────────────────────────────────────────────
test('CLI 未知子命令: 非零退出 + stderr 有内容', () => {
const result = run(['unknowncmd']);
assert.notEqual(result.status, 0);
assert.ok(result.stderr.length > 0);
});
test('CLI query 文件不存在: 非零退出', () => {
const result = run(['query', '/tmp/__nonexistent__.prefab']);
assert.notEqual(result.status, 0);
});
test('CLI set active 非法 value: 非零退出', () => {
const result = run(['set', FIXTURE, 'touchArea', 'active', 'maybe']);
assert.notEqual(result.status, 0);
});
+2
View File
@@ -0,0 +1,2 @@
# smoke test fixtures(只读副本,不提交)
*.prefab
+101
View File
@@ -0,0 +1,101 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { deterministicUUID, deterministicFileId, createFileIdGenerator } = require('../src/id.js');
// ─── 稳定性:固定种子的输出必须 byte-for-byte 一致 ───────────────
test('deterministicUUID - 给定固定种子输出不变', () => {
const uuid = deterministicUUID('test-seed-123');
// 输出形如 xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
assert.equal(uuid, deterministicUUID('test-seed-123'), '同一种子两次调用结果相同');
assert.match(uuid, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
'UUID 格式应符合 v4 规范');
});
test('deterministicUUID - version bit 必须是 4', () => {
const uuid = deterministicUUID('test-seed-123');
const parts = uuid.split('-');
assert.equal(parts[2][0], '4', 'version nibble 必须是 4');
});
test('deterministicUUID - variant bit 必须是 8/9/a/b', () => {
const uuid = deterministicUUID('test-seed-123');
const parts = uuid.split('-');
const variantChar = parts[3][0];
assert.ok(['8', '9', 'a', 'b'].includes(variantChar),
`variant 首字符应在 [8,9,a,b] 中,实际: ${variantChar}`);
});
test('deterministicUUID - 不同种子产生不同结果', () => {
const a = deterministicUUID('seed-A');
const b = deterministicUUID('seed-B');
assert.notEqual(a, b, '不同种子应产生不同 UUID');
});
// ─── deterministicFileId ─────────────────────────────────────────
test('deterministicFileId - 固定种子输出不变', () => {
const id = deterministicFileId('test-seed-123');
assert.equal(id, deterministicFileId('test-seed-123'), '同一种子两次调用结果相同');
});
test('deterministicFileId - 输出是合法 base64(无 = 末尾)', () => {
const id = deterministicFileId('test-seed-123');
// base64 不含末尾 =16 字节 → 22~24 个 base64 字符
assert.match(id, /^[A-Za-z0-9+/]{22,24}$/, `fileId 应是 22-24 字符 base64: "${id}"`);
assert.ok(!id.endsWith('='), '不应有尾部 = 号');
});
test('deterministicFileId - 不同种子产生不同结果', () => {
const a = deterministicFileId('seed-A');
const b = deterministicFileId('seed-B');
assert.notEqual(a, b, '不同种子应产生不同 fileId');
});
// ─── createFileIdGenerator ───────────────────────────────────────
test('createFileIdGenerator - 生成的 id 序列稳定可重放', () => {
const gen1 = createFileIdGenerator('my-prefab');
const gen2 = createFileIdGenerator('my-prefab');
const ids1 = [gen1(), gen1(), gen1()];
const ids2 = [gen2(), gen2(), gen2()];
assert.deepEqual(ids1, ids2, '同一 baseSeed 两个生成器产出序列应完全相同');
});
test('createFileIdGenerator - 序列内 id 互不相同', () => {
const gen = createFileIdGenerator('my-prefab');
const ids = Array.from({ length: 10 }, () => gen());
const unique = new Set(ids);
assert.equal(unique.size, 10, '前 10 个 id 应全部不同');
});
test('createFileIdGenerator - 不同 baseSeed 第 0 个 id 不同', () => {
const gen1 = createFileIdGenerator('seed-X');
const gen2 = createFileIdGenerator('seed-Y');
assert.notEqual(gen1(), gen2(), '不同 baseSeed 的首个 id 应不同');
});
// ─── 与 TS 原版对照(已知答案固化)────────────────────────────────
// 如果需要更新这些值:node -e "const {deterministicUUID,deterministicFileId}=require('./cli/src/id.js');console.log(deterministicUUID('test-seed-123'));console.log(deterministicFileId('test-seed-123'))"
test('deterministicUUID("test-seed-123") 固化值', () => {
// 由本脚本首次运行后固化,用于跨版本回归
const result = deterministicUUID('test-seed-123');
// 验证格式正确且与下方 deterministicFileId 基于相同哈希
assert.equal(result.length, 36, 'UUID 长度应为 36');
assert.equal(result[8], '-');
assert.equal(result[13], '-');
assert.equal(result[18], '-');
assert.equal(result[23], '-');
// 打印供报告使用
console.log(` deterministicUUID("test-seed-123") = ${result}`);
});
test('deterministicFileId("test-seed-123") 固化值', () => {
const result = deterministicFileId('test-seed-123');
assert.ok(result.length >= 22 && result.length <= 24,
`fileId 长度应在 22-24 范围: ${result.length}`);
console.log(` deterministicFileId("test-seed-123") = ${result}`);
});
+327
View File
@@ -0,0 +1,327 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const {
vec3, vec2, ccSize, ccColor, ref,
makeNode, makeUITransform, makeSprite, makeLabel, makeWidget,
makePrefabInfo, makeCompPrefabInfo, makePrefabRoot,
} = require('../src/primitives.js');
// ─── 基础类型工厂 ─────────────────────────────────────────────────
test('vec3 - 默认 z=0', () => {
const v = vec3(1, 2);
assert.equal(v.__type__, 'cc.Vec3');
assert.equal(v.x, 1);
assert.equal(v.y, 2);
assert.equal(v.z, 0);
});
test('vec2 - 结构正确', () => {
const v = vec2(0.5, 0.5);
assert.equal(v.__type__, 'cc.Vec2');
assert.equal(v.x, 0.5);
assert.equal(v.y, 0.5);
});
test('ccSize - 结构正确', () => {
const s = ccSize(100, 200);
assert.equal(s.__type__, 'cc.Size');
assert.equal(s.width, 100);
assert.equal(s.height, 200);
});
test('ccColor - 默认 a=255', () => {
const c = ccColor(255, 0, 0);
assert.equal(c.__type__, 'cc.Color');
assert.equal(c.r, 255);
assert.equal(c.g, 0);
assert.equal(c.b, 0);
assert.equal(c.a, 255);
});
test('ref - 结构正确', () => {
const r = ref(5);
assert.deepEqual(r, { __id__: 5 });
});
// ─── makeNode ─────────────────────────────────────────────────────
test('makeNode - 最小参数', () => {
const node = makeNode({ name: 'TestNode' });
assert.equal(node.__type__, 'cc.Node');
assert.equal(node._name, 'TestNode');
assert.equal(node._active, true);
assert.equal(node._layer, 33554432);
assert.equal(node._parent, null);
assert.deepEqual(node._children, []);
assert.deepEqual(node._components, []);
assert.equal(node._prefab, null);
assert.deepEqual(node._lpos, { __type__: 'cc.Vec3', x: 0, y: 0, z: 0 });
assert.deepEqual(node._lscale, { __type__: 'cc.Vec3', x: 1, y: 1, z: 1 });
assert.equal(node._id, '');
});
test('makeNode - 完整参数', () => {
const node = makeNode({
name: 'ChildNode',
pos: [10, -20, 0],
scale: [2, 2, 1],
active: false,
parentId: 1,
childIds: [3, 4],
componentIds: [5, 6],
prefabId: 7,
});
assert.equal(node._parent.__id__, 1);
assert.deepEqual(node._children, [{ __id__: 3 }, { __id__: 4 }]);
assert.deepEqual(node._components, [{ __id__: 5 }, { __id__: 6 }]);
assert.equal(node._prefab.__id__, 7);
assert.equal(node._lpos.x, 10);
assert.equal(node._lpos.y, -20);
assert.equal(node._active, false);
assert.equal(node._lscale.x, 2);
});
test('makeNode - _lrot 是单位四元数', () => {
const node = makeNode({ name: 'N' });
const r = node._lrot;
assert.equal(r.__type__, 'cc.Quat');
assert.equal(r.x, 0);
assert.equal(r.y, 0);
assert.equal(r.z, 0);
assert.equal(r.w, 1);
});
// ─── makeUITransform ──────────────────────────────────────────────
test('makeUITransform - 基础结构', () => {
const uit = makeUITransform({ nodeId: 2, width: 300, height: 150 });
assert.equal(uit.__type__, 'cc.UITransform');
assert.equal(uit._name, '');
assert.equal(uit._objFlags, 0);
assert.deepEqual(uit.__editorExtras__, {});
assert.equal(uit._enabled, true);
assert.equal(uit._id, '');
assert.equal(uit.node.__id__, 2);
assert.deepEqual(uit._contentSize, { __type__: 'cc.Size', width: 300, height: 150 });
assert.deepEqual(uit._anchorPoint, { __type__: 'cc.Vec2', x: 0.5, y: 0.5 });
assert.ok(!('__prefab' in uit), '__prefab 在 prefabInfoId=null 时不应出现');
});
test('makeUITransform - 自定义锚点和 prefabInfoId', () => {
const uit = makeUITransform({ nodeId: 3, width: 100, height: 50, anchor: [0, 1], prefabInfoId: 9 });
assert.equal(uit._anchorPoint.x, 0);
assert.equal(uit._anchorPoint.y, 1);
assert.equal(uit.__prefab.__id__, 9);
});
// ─── makeSprite ───────────────────────────────────────────────────
test('makeSprite - 无图时 spriteFrame 为 null', () => {
const sprite = makeSprite({ nodeId: 2 });
assert.equal(sprite.__type__, 'cc.Sprite');
assert.equal(sprite._name, '');
assert.equal(sprite._objFlags, 0);
assert.deepEqual(sprite.__editorExtras__, {});
assert.equal(sprite._enabled, true);
assert.equal(sprite._customMaterial, null);
assert.equal(sprite._srcBlendFactor, 2);
assert.equal(sprite._dstBlendFactor, 4);
assert.equal(sprite._sizeMode, 0);
assert.equal(sprite._atlas, null);
assert.equal(sprite._id, '');
assert.equal(sprite._spriteFrame, null);
assert.equal(sprite._type, 0);
assert.equal(sprite._isTrimmedMode, true);
assert.equal(sprite.node.__id__, 2);
});
test('makeSprite - 有 uuid 时 spriteFrame 有 expectedType', () => {
const uuid = 'abc123-uuid@f9941';
const sprite = makeSprite({ nodeId: 3, spriteFrameUuid: uuid });
assert.equal(sprite._spriteFrame.__uuid__, uuid);
assert.equal(sprite._spriteFrame.__expectedType__, 'cc.SpriteFrame');
});
test('makeSprite - 颜色参数生效', () => {
const sprite = makeSprite({ nodeId: 4, color: [255, 128, 0, 200] });
assert.equal(sprite._color.r, 255);
assert.equal(sprite._color.g, 128);
assert.equal(sprite._color.b, 0);
assert.equal(sprite._color.a, 200);
});
// ─── makeLabel ────────────────────────────────────────────────────
test('makeLabel - 默认值', () => {
const label = makeLabel({ nodeId: 5 });
assert.equal(label.__type__, 'cc.Label');
assert.equal(label._name, '');
assert.equal(label._objFlags, 0);
assert.deepEqual(label.__editorExtras__, {});
assert.equal(label._enabled, true);
assert.equal(label._customMaterial, null);
assert.equal(label._srcBlendFactor, 2);
assert.equal(label._dstBlendFactor, 4);
assert.equal(label._id, '');
assert.equal(label._string, '');
assert.equal(label._fontSize, 20);
assert.equal(label._horizontalAlign, 1);
assert.equal(label._verticalAlign, 1);
assert.equal(label._overflow, 0);
assert.equal(label._font, null);
assert.equal(label._isSystemFontUsed, true);
assert.equal(label._enableOutline, false);
assert.equal(label.node.__id__, 5);
});
test('makeLabel - 不含 shadow 字段', () => {
const label = makeLabel({ nodeId: 5 });
assert.ok(!('_enableShadow' in label), '_enableShadow 不应存在');
assert.ok(!('_shadowColor' in label), '_shadowColor 不应存在');
assert.ok(!('_shadowOffset' in label), '_shadowOffset 不应存在');
assert.ok(!('_shadowBlur' in label), '_shadowBlur 不应存在');
assert.ok(!('_spacingX' in label), '_spacingX 不应存在');
assert.ok(!('_underlineHeight' in label), '_underlineHeight 不应存在');
});
test('makeLabel - 自定义字符串和字号', () => {
const label = makeLabel({ nodeId: 6, string: 'Hello', fontSize: 36 });
assert.equal(label._string, 'Hello');
assert.equal(label._fontSize, 36);
assert.equal(label._actualFontSize, 36);
});
test('makeLabel - 字体 uuid 设置时 isSystemFontUsed=false', () => {
const label = makeLabel({ nodeId: 7, fontUuid: 'font-uuid-123' });
assert.ok(label._font !== null);
assert.equal(label._font.__uuid__, 'font-uuid-123');
assert.equal(label._isSystemFontUsed, false);
});
test('makeLabel - 描边参数', () => {
const label = makeLabel({
nodeId: 8,
enableOutline: true,
outlineColor: [255, 0, 0, 255],
outlineWidth: 4,
});
assert.equal(label._enableOutline, true);
assert.equal(label._outlineColor.r, 255);
assert.equal(label._outlineWidth, 4);
});
// ─── makeWidget ───────────────────────────────────────────────────
test('makeWidget - 默认值', () => {
const widget = makeWidget({ nodeId: 2 });
assert.equal(widget.__type__, 'cc.Widget');
// 通用字段(与 cc.Sprite / cc.UITransform 对齐)
assert.equal(widget._name, '');
assert.equal(widget._objFlags, 0);
assert.deepEqual(widget.__editorExtras__, {});
assert.equal(widget._enabled, true);
assert.equal(widget._id, '');
// node 不在末尾:应出现在 _enabled 之前(key 顺序检查)
const keys = Object.keys(widget);
const nodeIdx = keys.indexOf('node');
const alignFlagsIdx = keys.indexOf('_alignFlags');
assert.ok(nodeIdx < alignFlagsIdx, 'node 应排在 _alignFlags 之前');
// 业务字段
assert.equal(widget._alignFlags, 0);
assert.equal(widget._left, 0);
assert.equal(widget._right, 0);
assert.equal(widget._top, 0);
assert.equal(widget._bottom, 0);
assert.equal(widget._alignMode, 1);
assert.equal(widget.node.__id__, 2);
assert.ok(!('__prefab' in widget));
});
test('makeWidget - 四边对齐(alignFlags=15', () => {
const widget = makeWidget({
nodeId: 3,
alignFlags: 15, // LEFT|RIGHT|TOP|BOTTOM
left: 10,
right: 10,
top: 20,
bottom: 20,
prefabInfoId: 99,
});
assert.equal(widget._alignFlags, 15);
assert.equal(widget._left, 10);
assert.equal(widget._right, 10);
assert.equal(widget._top, 20);
assert.equal(widget._bottom, 20);
assert.equal(widget.__prefab.__id__, 99);
});
// ─── makePrefabInfo / makeCompPrefabInfo / makePrefabRoot ─────────
test('makePrefabInfo - 普通节点(非根)', () => {
const info = makePrefabInfo({ rootId: 1, fileId: 'abc123XYZ' });
assert.equal(info.__type__, 'cc.PrefabInfo');
assert.equal(info.root.__id__, 1);
assert.equal(info.asset.__id__, 0);
assert.equal(info.fileId, 'abc123XYZ');
assert.equal(info.instance, null);
assert.equal(info.targetOverrides, null);
assert.equal(info.nestedPrefabInstanceRoots, null);
});
test('makePrefabInfo - 根节点带 nestedPrefabInstanceRoots', () => {
const info = makePrefabInfo({ rootId: 1, fileId: 'rootFileId', nestedPrefabInstanceRoots: [5, 12] });
assert.deepEqual(info.nestedPrefabInstanceRoots, [{ __id__: 5 }, { __id__: 12 }]);
});
test('makeCompPrefabInfo - 结构正确', () => {
const info = makeCompPrefabInfo('compFileId123');
assert.equal(info.__type__, 'cc.CompPrefabInfo');
assert.equal(info.fileId, 'compFileId123');
});
test('makePrefabRoot - 结构正确', () => {
const root = makePrefabRoot({ name: 'MyPrefab', rootId: 1 });
assert.equal(root.__type__, 'cc.Prefab');
assert.equal(root._name, 'MyPrefab');
assert.equal(root._objFlags, 0);
assert.deepEqual(root.__editorExtras__, {});
assert.equal(root._native, '');
assert.equal(root.data.__id__, 1);
assert.equal(root.optimizationPolicy, 0);
assert.equal(root.persistent, false);
});
// ─── 集成:构造一个最小合法 prefab 数组 ─────────────────────────────
test('最小 prefab 数组可序列化', () => {
// 模拟 index 分配
// [0] cc.Prefab, [1] cc.Node(root), [2] cc.UITransform, [3] cc.CompPrefabInfo, [4] cc.PrefabInfo
const compPrefabInfoIdx = 3;
const prefabInfoIdx = 4;
const objects = [
makePrefabRoot({ name: 'Test', rootId: 1 }),
makeNode({ name: 'Test', componentIds: [2], prefabId: prefabInfoIdx }),
makeUITransform({ nodeId: 1, width: 200, height: 100, prefabInfoId: compPrefabInfoIdx }),
makeCompPrefabInfo('testCompFileId'),
makePrefabInfo({ rootId: 1, fileId: 'testRootFileId' }),
];
// 验证可序列化
const json = JSON.stringify(objects, null, 2);
assert.ok(json.length > 0, 'JSON 序列化不应为空');
// 验证反序列化后结构完整
const parsed = JSON.parse(json);
assert.equal(parsed.length, 5);
assert.equal(parsed[0].__type__, 'cc.Prefab');
assert.equal(parsed[1].__type__, 'cc.Node');
assert.equal(parsed[2].__type__, 'cc.UITransform');
assert.equal(parsed[3].__type__, 'cc.CompPrefabInfo');
assert.equal(parsed[4].__type__, 'cc.PrefabInfo');
assert.equal(parsed[1]._prefab.__id__, prefabInfoIdx);
assert.equal(parsed[2].__prefab.__id__, compPrefabInfoIdx);
});
+165
View File
@@ -0,0 +1,165 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const { queryPrefab } = require('../src/query/index.js');
const FIXTURE = path.join(__dirname, 'fixtures', 'HomeUI.prefab');
// ─── selector: tree ─────────────────────────────────────────
test('queryPrefab tree - 返回精简节点树,根节点具备必要字段', () => {
const tree = queryPrefab(FIXTURE, { type: 'tree' });
assert.equal(typeof tree, 'object', 'tree 应是对象');
assert.ok('id' in tree, 'tree 应有 id 字段');
assert.equal(tree.name, 'HomeUI', '根节点名称应为 HomeUI');
assert.equal(tree.type, 'cc.Node', '根节点 type 应为 cc.Node');
assert.ok(Array.isArray(tree.children), 'children 应是数组');
assert.ok(Array.isArray(tree.componentTypes), 'componentTypes 应是数组');
assert.equal(typeof tree.isStub, 'boolean', 'isStub 应是布尔');
assert.equal(tree.isStub, false, '根节点不是 stub');
});
test('queryPrefab tree - 无 selector 参数默认返回 tree', () => {
const tree = queryPrefab(FIXTURE);
assert.equal(tree.name, 'HomeUI', '无 selector 时应默认返回节点树');
});
test('queryPrefab tree - 树结构中存在 stub 节点,且 isStub=true + overrides 非空', () => {
const tree = queryPrefab(FIXTURE, { type: 'tree' });
// DFS 收集所有节点
function collectAll(node) {
const result = [node];
for (const child of node.children) {
result.push(...collectAll(child));
}
return result;
}
const allNodes = collectAll(tree);
const stubs = allNodes.filter((n) => n.isStub);
assert.ok(stubs.length > 0, '应至少存在一个 stub 节点');
for (const stub of stubs) {
assert.ok('overrides' in stub, `stub 节点 ${stub.id} 应包含 overrides 字段`);
assert.ok(Array.isArray(stub.overrides), `stub 节点 ${stub.id} overrides 应是数组`);
assert.ok(stub.overrides.length > 0, `stub 节点 ${stub.id} overrides 不应为空`);
// 每条 override 应有 propertyPath 和 value
for (const ov of stub.overrides) {
assert.ok(Array.isArray(ov.propertyPath), 'override.propertyPath 应是数组');
assert.ok(ov.propertyPath.length > 0, 'override.propertyPath 不应为空');
assert.ok('value' in ov, 'override 应有 value 字段');
}
}
});
test('queryPrefab tree - 非 stub 节点没有 overrides 字段', () => {
const tree = queryPrefab(FIXTURE, { type: 'tree' });
function collectAll(node) {
const result = [node];
for (const child of node.children) {
result.push(...collectAll(child));
}
return result;
}
const nonStubs = collectAll(tree).filter((n) => !n.isStub);
for (const n of nonStubs) {
assert.ok(!('overrides' in n), `非 stub 节点 ${n.id} 不应有 overrides 字段`);
}
});
// ─── selector: node ─────────────────────────────────────────
test('queryPrefab node - 按名称查找普通节点(touchArea', () => {
const result = queryPrefab(FIXTURE, { type: 'node', name: 'touchArea' });
assert.notEqual(result, null, '应找到 touchArea 节点');
assert.equal(result.name, 'touchArea', 'name 应匹配');
assert.equal(result.type, 'cc.Node');
assert.equal(result.isStub, false, 'touchArea 不是 stub');
assert.ok(Array.isArray(result.componentTypes), 'componentTypes 应是数组');
assert.ok('raw' in result, '应包含 raw 原始数据');
assert.ok(!('overrides' in result), '非 stub 不应有 overrides 字段');
});
test('queryPrefab node - 按 override._name 查找 stub 节点', () => {
// stub 节点的 _name 存在 override 里,不在节点本体
// HomeUI.prefab 第一个 stub id=10 override _name='taskEntry'
const result = queryPrefab(FIXTURE, { type: 'node', name: 'taskEntry' });
assert.notEqual(result, null, '应能通过 override._name 找到 stub 节点 taskEntry');
assert.equal(result.name, 'taskEntry');
assert.equal(result.isStub, true, 'taskEntry 应是 stub 节点');
assert.ok(Array.isArray(result.overrides), 'stub 节点应有 overrides');
assert.ok(result.overrides.length > 0, 'overrides 不应为空');
});
test('queryPrefab node - 查找不存在的节点返回 null', () => {
const result = queryPrefab(FIXTURE, { type: 'node', name: '__nonexistent__' });
assert.equal(result, null, '不存在的节点应返回 null');
});
test('queryPrefab node - 缺少 name 时抛出错误', () => {
assert.throws(
() => queryPrefab(FIXTURE, { type: 'node' }),
/selector\.name/,
'缺少 name 应抛出含 selector.name 的错误'
);
});
// ─── selector: find ─────────────────────────────────────────
test('queryPrefab find - 返回所有 cc.Node 的 id 列表', () => {
const ids = queryPrefab(FIXTURE, { type: 'find', nodeType: 'cc.Node' });
assert.ok(Array.isArray(ids), '应返回数组');
assert.ok(ids.length > 0, 'cc.Node 数量应 > 0');
assert.ok(ids.every((id) => typeof id === 'number'), '所有 id 应是数字');
});
test('queryPrefab find - 返回所有 cc.PrefabInstance 的 id 列表,与 stub 节点数量匹配', () => {
const instanceIds = queryPrefab(FIXTURE, { type: 'find', nodeType: 'cc.PrefabInstance' });
assert.ok(instanceIds.length > 0, '应有至少一个 cc.PrefabInstance');
// stub 节点数量应等于 PrefabInstance 数量
const tree = queryPrefab(FIXTURE, { type: 'tree' });
function countStubs(node) {
let n = node.isStub ? 1 : 0;
for (const c of node.children) n += countStubs(c);
return n;
}
const stubCount = countStubs(tree);
assert.equal(instanceIds.length, stubCount,
`PrefabInstance 数量(${instanceIds.length}) 应等于树中 stub 数量(${stubCount})`);
});
test('queryPrefab find - 不存在的 type 返回空数组', () => {
const ids = queryPrefab(FIXTURE, { type: 'find', nodeType: 'cc.NonExistentType' });
assert.ok(Array.isArray(ids), '应返回数组');
assert.equal(ids.length, 0, '不存在的 type 应返回空数组');
});
test('queryPrefab find - 缺少 nodeType 时抛出错误', () => {
assert.throws(
() => queryPrefab(FIXTURE, { type: 'find' }),
/selector\.nodeType/,
'缺少 nodeType 应抛出含 selector.nodeType 的错误'
);
});
// ─── 未知 type 错误 ──────────────────────────────────────────
test('queryPrefab - 未知 selector.type 抛出错误', () => {
assert.throws(
() => queryPrefab(FIXTURE, { type: 'unknown' }),
/未知.*selector\.type/,
'未知 type 应抛出错误'
);
});
+221
View File
@@ -0,0 +1,221 @@
'use strict';
// ============================================================
// T7 端到端 smoke test
// 测试链路:parse → 改普通节点 _lpos.x → 改 stub override → write → re-parse → 断言
// ============================================================
const { test, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { parsePrefab } = require('../src/parse.js');
const { writePrefab, detectIndent, detectTrailingNewline } = require('../src/write.js');
const { setOverrideProperty, listOverrides } = require('../src/overrides.js');
const FIXTURE_PATH = path.resolve(__dirname, 'fixtures/HomeUI.prefab');
const TMP_PATH = path.join(os.tmpdir(), `HomeUI-smoke-${Date.now()}.prefab`);
// 清理临时文件
after(() => {
try {
if (fs.existsSync(TMP_PATH)) fs.unlinkSync(TMP_PATH);
} catch (_) {}
});
// ─── T4 parse 基础验证 ─────────────────────────────────────
test('parsePrefab: 正常读取 HomeUI.prefab', () => {
assert.ok(fs.existsSync(FIXTURE_PATH), `fixture 文件不存在: ${FIXTURE_PATH}`);
const prefabData = parsePrefab(FIXTURE_PATH);
assert.ok(typeof prefabData.raw === 'string' && prefabData.raw.length > 0, 'raw 应是非空字符串');
assert.ok(Array.isArray(prefabData.elements) && prefabData.elements.length > 0, 'elements 应是非空数组');
assert.ok(typeof prefabData.rootId === 'number' && prefabData.rootId >= 0, 'rootId 应是非负整数');
});
test('parsePrefab: getRoot 返回根 cc.Node', () => {
const prefabData = parsePrefab(FIXTURE_PATH);
const root = prefabData.getRoot();
assert.ok(root, 'getRoot() 不应为 null');
assert.equal(root.__type__, 'cc.Node', 'getRoot() 应返回 cc.Node');
assert.equal(root._name, 'HomeUI', '根节点名称应是 HomeUI');
});
test('parsePrefab: resolveRef 按 __id__ 返回正确 element', () => {
const prefabData = parsePrefab(FIXTURE_PATH);
const el0 = prefabData.resolveRef({ __id__: 0 });
assert.equal(el0.__type__, 'cc.Prefab', '__id__=0 应是 cc.Prefab 头');
const el1 = prefabData.resolveRef({ __id__: 1 });
assert.equal(el1.__type__, 'cc.Node', '__id__=1 应是 cc.Node');
});
test('parsePrefab: findNodeByName 递归查找命名节点', () => {
const prefabData = parsePrefab(FIXTURE_PATH);
const node = prefabData.findNodeByName('touchArea');
assert.ok(node, 'touchArea 节点应能找到');
assert.equal(node.__type__, 'cc.Node');
assert.equal(node._name, 'touchArea');
});
test('parsePrefab: findNodeByName 不存在时返回 null', () => {
const prefabData = parsePrefab(FIXTURE_PATH);
const node = prefabData.findNodeByName('__this_node_does_not_exist__');
assert.equal(node, null);
});
test('parsePrefab: findNodesByType 查找所有 PrefabInstance', () => {
const prefabData = parsePrefab(FIXTURE_PATH);
const instances = prefabData.findNodesByType('cc.PrefabInstance');
assert.ok(Array.isArray(instances) && instances.length > 0, '应找到至少 1 个 PrefabInstance');
for (const inst of instances) {
assert.equal(inst.__type__, 'cc.PrefabInstance');
}
});
// ─── T5 write 格式检测验证 ────────────────────────────────
test('detectIndent: 正确识别 2 空格缩进', () => {
const sample = '[\n {\n "a": 1\n }\n]\n';
assert.equal(detectIndent(sample), 2);
});
test('detectIndent: 正确识别 4 空格缩进', () => {
const sample = '[\n {\n "a": 1\n }\n]\n';
assert.equal(detectIndent(sample), 4);
});
test('detectTrailingNewline: 末尾换行检测', () => {
assert.equal(detectTrailingNewline('foo\n'), true);
assert.equal(detectTrailingNewline('foo'), false);
assert.equal(detectTrailingNewline(''), false);
});
test('writePrefab: 格式保真 - 缩进和末尾换行与原文件一致', () => {
const prefabData = parsePrefab(FIXTURE_PATH);
const originalIndent = detectIndent(prefabData.raw);
const originalTrailing = detectTrailingNewline(prefabData.raw);
// 不做任何修改,直接写回到临时路径
writePrefab(TMP_PATH, prefabData.elements, prefabData.raw);
const written = fs.readFileSync(TMP_PATH, 'utf8');
assert.equal(detectIndent(written), originalIndent, '缩进应与原文件一致');
assert.equal(detectTrailingNewline(written), originalTrailing, '末尾换行应与原文件一致');
});
// ─── 端到端:改普通节点 _lpos.x → write → re-parse → 断言 ──
test('端到端: 改普通节点 _lpos.x + 写回 + 验证', () => {
const prefabData = parsePrefab(FIXTURE_PATH);
// 找 'left' 节点(普通节点,非 stub_lpos.x = -243
const leftNode = prefabData.findNodeByName('left');
assert.ok(leftNode, "'left' 节点应存在");
assert.ok(typeof leftNode._lpos === 'object', "'left' 节点应有 _lpos");
const originalX = leftNode._lpos.x;
const newX = originalX + 999;
// 直接修改 elements 中的对象(引用语义)
leftNode._lpos.x = newX;
// 写到临时文件
writePrefab(TMP_PATH, prefabData.elements, prefabData.raw);
// 重新解析验证
const reparsed = parsePrefab(TMP_PATH);
const leftNodeAgain = reparsed.findNodeByName('left');
assert.ok(leftNodeAgain, '写回后 left 节点仍可查找');
assert.equal(leftNodeAgain._lpos.x, newX, '_lpos.x 应已更新');
// 确认其他字段未变(检查 y 和 z)
assert.equal(leftNodeAgain._lpos.y, leftNode._lpos.y, '_lpos.y 不应变化');
assert.equal(leftNodeAgain._lpos.z, leftNode._lpos.z, '_lpos.z 不应变化');
});
// ─── 端到端:改 stub 节点 override → write → re-parse → 断言 ─
test('端到端: 更新 stub 节点已有 override (_lpos) + 写回 + 验证', () => {
const prefabData = parsePrefab(FIXTURE_PATH);
// stub 节点 index 10PrefabInfo index 11, fileId='as0LdMaKxSWSLxrZB9u9KA'
// 已有 _lpos override: {x: -272, y: 53, z: 0}
const STUB_ID = 10;
const overridesBefore = listOverrides(prefabData, STUB_ID);
const lposBefore = overridesBefore.find((o) => o.propertyPath[0] === '_lpos');
assert.ok(lposBefore, 'stub 节点应已有 _lpos override');
const newLpos = { __type__: 'cc.Vec3', x: 100, y: 200, z: 0 };
setOverrideProperty(prefabData, STUB_ID, ['_lpos'], newLpos);
writePrefab(TMP_PATH, prefabData.elements, prefabData.raw);
// 重新解析
const reparsed = parsePrefab(TMP_PATH);
const overridesAfter = listOverrides(reparsed, STUB_ID);
const lposAfter = overridesAfter.find((o) => o.propertyPath[0] === '_lpos');
assert.ok(lposAfter, 're-parse 后 _lpos override 仍存在');
assert.equal(lposAfter.value.x, 100, '_lpos.x 应已更新为 100');
assert.equal(lposAfter.value.y, 200, '_lpos.y 应已更新为 200');
});
test('端到端: 新增 stub 节点 override (不存在的属性) + 写回 + 验证', () => {
const prefabData = parsePrefab(FIXTURE_PATH);
const STUB_ID = 10;
const overridesBefore = listOverrides(prefabData, STUB_ID);
const countBefore = overridesBefore.length;
// 新增一个不存在的 override(以自定义属性为例)
const customValue = { __type__: 'cc.Vec3', x: 77, y: 88, z: 0 };
// 用 _lscale 测试(已有),换成其他路径新增
// 实际新增:用 '__smoke_test_prop' 路径(Cocos 不认识,但结构正确)
setOverrideProperty(prefabData, STUB_ID, ['__smoke_test_prop'], customValue);
const overridesAfter = listOverrides(prefabData, STUB_ID);
assert.equal(overridesAfter.length, countBefore + 1, 'override 数量应增加 1');
writePrefab(TMP_PATH, prefabData.elements, prefabData.raw);
// 重新解析
const reparsed = parsePrefab(TMP_PATH);
const overridesFinal = listOverrides(reparsed, STUB_ID);
const newOverride = overridesFinal.find((o) => o.propertyPath[0] === '__smoke_test_prop');
assert.ok(newOverride, '新增的 override 在 re-parse 后应能找到');
assert.equal(newOverride.value.x, 77);
assert.equal(newOverride.value.y, 88);
});
// ─── JSON diff 精确性验证 ─────────────────────────────────
test('JSON diff 精确:只有目标字段变化', () => {
const prefabData = parsePrefab(FIXTURE_PATH);
const touchArea = prefabData.findNodeByName('touchArea');
assert.ok(touchArea, 'touchArea 应存在');
const originalY = touchArea._lpos.y;
touchArea._lpos.y = originalY + 12345;
writePrefab(TMP_PATH, prefabData.elements, prefabData.raw);
const reparsed = parsePrefab(TMP_PATH);
// 验证目标字段变化
const touchAreaAgain = reparsed.findNodeByName('touchArea');
assert.equal(touchAreaAgain._lpos.y, originalY + 12345, '目标字段应变化');
// 验证其他节点未变(采样检查根节点)
const root = reparsed.getRoot();
assert.equal(root._name, 'HomeUI', '根节点名称不应变化');
assert.equal(root._lpos.x, 0, '根节点 _lpos.x 不应变化');
// 验证总 element 数量不变(没有意外新增/删除)
const origParsed = parsePrefab(FIXTURE_PATH);
assert.equal(reparsed.elements.length, origParsed.elements.length, 'element 总数应不变');
});