[cc-3-8-x-mcp] 修复: 补全离线 CLI 解析路径与自包含夹具

改了什么:解析结果记录资源来源路径,并生成独立 Prefab、嵌套资源和脚本夹具。

为什么:测试依赖外部项目资源和临时目录时会导致嵌套解析与类 ID 结果不稳定。

影响范围:离线 CLI 解析、组件引用错误信息及 offline tools 测试。
This commit is contained in:
furao
2026-08-14 12:09:20 +08:00
parent 4fa698a757
commit a3b016ea8e
9 changed files with 251 additions and 21 deletions
+1 -1
View File
@@ -92,7 +92,7 @@ function execSetComponentRef(prefabData, op) {
const comp = findComponent(elements, node, componentType); const comp = findComponent(elements, node, componentType);
if (!comp) { if (!comp) {
throw new Error(`editPrefab [set-component-ref]: 节点 "${node._name}" 未挂 "${componentType}" 组件`); throw new Error(`editPrefab [set-component-ref]: 节点 "${node._name}" 未挂 "${rawComponentType}" 组件`);
} }
const { node: refNode, nodeId: refNodeId } = resolveNode(prefabData, refNodeSelector, 'set-component-ref'); const { node: refNode, nodeId: refNodeId } = resolveNode(prefabData, refNodeSelector, 'set-component-ref');
+3
View File
@@ -153,6 +153,9 @@ function parsePrefab(filePath) {
raw, raw,
elements, elements,
rootId, rootId,
// 解析结果可直接交给 overrides/nested helpers 使用;调用方不应再被迫
// 手工补一遍来源路径。editPrefab 仍可用 options.projectRoot 覆盖它。
resolverStartPath: filePath,
findNodeByName, findNodeByName,
findNodesByType, findNodesByType,
getRoot, getRoot,
+8 -7
View File
@@ -18,16 +18,16 @@ const { editPrefab } = require('../src/editor/index.js');
const { parsePrefab } = require('../src/parse.js'); const { parsePrefab } = require('../src/parse.js');
const { listOverrides } = require('../src/overrides.js'); const { listOverrides } = require('../src/overrides.js');
const { addRootTargetOverride, resolveLocalIdChain } = require('../src/editor/nested.js'); const { addRootTargetOverride, resolveLocalIdChain } = require('../src/editor/nested.js');
const fixture = require('./fixture.js');
const FIXTURE_PATH = path.resolve(__dirname, 'fixtures/HomeUI.prefab'); const FIXTURE_PATH = fixture.ensureHomeUiFixture();
// 项目根(含 assets/ + package.json),传给 editPrefab options.projectRoot // 项目根(含 assets/ + package.json),传给 editPrefab options.projectRoot
// 让 UuidResolver 在 /tmp/ 临时文件场景下也能定位 assets/ 目录 // 让 UuidResolver 在 /tmp/ 临时文件场景下也能定位 assets/ 目录
const PROJECT_ROOT = path.resolve(__dirname, '../../../../'); const PROJECT_ROOT = fixture.FIXTURE_PROJECT_ROOT;
// 每个测试独立 tmp 文件 // 每个测试独立 tmp 文件
function makeTmp(tag) { function makeTmp(tag) {
return path.join(os.tmpdir(), `HomeUI-api-${tag}-${Date.now()}.prefab`); return path.join(fixture.FIXTURE_TEMP_DIR, `HomeUI-api-${tag}-${process.pid}-${Date.now()}.prefab`);
} }
// 把 fixture 复制到 tmp,返回 tmp 路径(让 editPrefab 可以写回) // 把 fixture 复制到 tmp,返回 tmp 路径(让 editPrefab 可以写回)
@@ -1236,7 +1236,8 @@ test('add-component: 普通节点挂自定义 ccclass 追加组件 + CompPrefabI
assert.ok(typeof newCompRef.__id__ === 'number'); assert.ok(typeof newCompRef.__id__ === 'number');
const comp = after.elements[newCompRef.__id__]; const comp = after.elements[newCompRef.__id__];
assert.equal(comp.__type__, 'TaskBtn'); const { compressUuid } = require('../src/id.js');
assert.equal(comp.__type__, compressUuid(fixture.TASK_BTN_UUID));
assert.equal(comp.node.__id__, after.elements.findIndex((e) => e === node)); assert.equal(comp.node.__id__, after.elements.findIndex((e) => e === node));
assert.equal(comp._enabled, true); assert.equal(comp._enabled, true);
assert.ok(comp.__prefab && typeof comp.__prefab.__id__ === 'number'); assert.ok(comp.__prefab && typeof comp.__prefab.__id__ === 'number');
@@ -1358,7 +1359,8 @@ test('remove-component: 普通节点移除组件引用 + 保持其他 __id__ 稳
// 组件元素本身作为 orphan 保留:elements 长度不变、原槽位仍是 TaskBtn // 组件元素本身作为 orphan 保留:elements 长度不变、原槽位仍是 TaskBtn
assert.equal(after.elements.length, beforeElementsLen); assert.equal(after.elements.length, beforeElementsLen);
assert.equal(after.elements[beforeCompId].__type__, 'TaskBtn'); const { compressUuid } = require('../src/id.js');
assert.equal(after.elements[beforeCompId].__type__, compressUuid(fixture.TASK_BTN_UUID));
}); });
test('remove-component: 节点上找不到对应组件抛错,不落盘', () => { test('remove-component: 节点上找不到对应组件抛错,不落盘', () => {
@@ -1674,4 +1676,3 @@ test('FIX-2 addRootTargetOverride: 数组 propertyPath 各索引独立不被幂
assert.ok(paths.includes(JSON.stringify(['_items', 1])), '应含 ["_items", 1]'); assert.ok(paths.includes(JSON.stringify(['_items', 1])), '应含 ["_items", 1]');
assert.ok(paths.includes(JSON.stringify(['_items', 2])), '应含 ["_items", 2]'); assert.ok(paths.includes(JSON.stringify(['_items', 2])), '应含 ["_items", 2]');
}); });
+3 -2
View File
@@ -19,7 +19,8 @@ const path = require('path');
const os = require('os'); const os = require('os');
const BIN = path.resolve(__dirname, '../bin/cocos-mcp-cli.js'); const BIN = path.resolve(__dirname, '../bin/cocos-mcp-cli.js');
const FIXTURE = path.resolve(__dirname, 'fixtures/HomeUI.prefab'); const fixture = require('./fixture.js');
const FIXTURE = fixture.ensureHomeUiFixture();
function run(args) { function run(args) {
return spawnSync(process.execPath, [BIN, ...args], { return spawnSync(process.execPath, [BIN, ...args], {
@@ -29,7 +30,7 @@ function run(args) {
} }
function tmpCopy() { function tmpCopy() {
const dest = path.join(os.tmpdir(), `HomeUI-cli-test-${Date.now()}.prefab`); const dest = path.join(fixture.FIXTURE_TEMP_DIR, `HomeUI-cli-test-${process.pid}-${Date.now()}.prefab`);
fs.copyFileSync(FIXTURE, dest); fs.copyFileSync(FIXTURE, dest);
return dest; return dest;
} }
+225
View File
@@ -0,0 +1,225 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const {
makePrefabRoot,
makeNode,
makePrefabInfo,
makeCompPrefabInfo,
makeUITransform,
makeSprite,
makeLabel,
} = require('../src/primitives.js');
const FIXTURE_DIR = path.join(__dirname, 'fixtures');
const FIXTURE_PROJECT_ROOT = path.join(FIXTURE_DIR, 'project');
const FIXTURE_ASSETS_DIR = path.join(FIXTURE_PROJECT_ROOT, 'assets');
const FIXTURE_TEMP_DIR = path.join(FIXTURE_ASSETS_DIR, '.tmp');
const HOME_UI_FIXTURE = path.join(FIXTURE_ASSETS_DIR, 'HomeUI.prefab');
const NESTED_PREFAB_FIXTURE = path.join(FIXTURE_ASSETS_DIR, 'TaskEntry.prefab');
const TASK_BTN_UUID = '5f4d2de3-a7dc-4d47-a41f-f27a12549b20';
const NESTED_PREFAB_UUID = 'addbe9a2-fd7e-5f3b-9164-ee44de8b1045';
const NESTED_ROOT_FILE_ID = 'as0LdMaKxSWSLxrZB9u9KA';
/**
* 生成一个只覆盖 CLI/override 测试契约的最小 Cocos 3 Prefab。
* stub 固定放在 id=10,保留旧测试对 mountedChildren/propertyOverrides 的覆盖。
*/
function buildHomeUiFixture() {
const elements = new Array(28);
elements[0] = makePrefabRoot({ name: 'HomeUI', rootId: 1 });
elements[1] = makeNode({
name: 'HomeUI',
childIds: [2, 4, 6, 8, 10, 24],
prefabId: 19,
});
elements[2] = makeNode({
name: 'touchArea',
pos: [0, 0, 0],
parentId: 1,
prefabId: 3,
});
elements[3] = makePrefabInfo({ rootId: 1, fileId: 'fixtureTouchAreaFileId' });
elements[4] = makeNode({
name: 'left',
pos: [-243, 12, 0],
parentId: 1,
prefabId: 5,
});
elements[5] = makePrefabInfo({ rootId: 1, fileId: 'fixtureLeftNodeFileId' });
elements[6] = makeNode({
name: 'n5',
parentId: 1,
componentIds: [7],
prefabId: 20,
});
elements[7] = makeSprite({
nodeId: 6,
spriteFrameUuid: '00000000-0000-0000-0000-000000000005@f9941',
prefabInfoId: 22,
});
elements[8] = makeNode({
name: 'n7',
parentId: 1,
componentIds: [9],
prefabId: 21,
});
elements[9] = makeLabel({
nodeId: 8,
string: '开始',
prefabInfoId: 23,
});
// nested prefab stub:结构与 Creator 3.8 序列化格式一致。
elements[10] = {
__type__: 'cc.Node',
_objFlags: 0,
_parent: { __id__: 1 },
_prefab: { __id__: 11 },
__editorExtras__: {},
};
elements[11] = {
__type__: 'cc.PrefabInfo',
root: { __id__: 10 },
asset: {
__uuid__: NESTED_PREFAB_UUID,
__expectedType__: 'cc.Prefab',
},
fileId: NESTED_ROOT_FILE_ID,
instance: { __id__: 12 },
targetOverrides: null,
};
elements[12] = {
__type__: 'cc.PrefabInstance',
fileId: 'fixtureNestedInstanceId',
prefabRootNode: { __id__: 1 },
mountedChildren: [],
mountedComponents: [],
propertyOverrides: [13, 15, 16, 17, 18].map((id) => ({ __id__: id })),
removedComponents: [],
};
elements[13] = {
__type__: 'CCPropertyOverrideInfo',
targetInfo: { __id__: 14 },
propertyPath: ['_name'],
value: 'taskEntry',
};
elements[14] = {
__type__: 'cc.TargetInfo',
localID: [NESTED_ROOT_FILE_ID],
};
elements[15] = {
__type__: 'CCPropertyOverrideInfo',
targetInfo: { __id__: 14 },
propertyPath: ['_lpos'],
value: { __type__: 'cc.Vec3', x: -272, y: 53, z: 0 },
};
elements[16] = {
__type__: 'CCPropertyOverrideInfo',
targetInfo: { __id__: 14 },
propertyPath: ['_lrot'],
value: { __type__: 'cc.Quat', x: 0, y: 0, z: 0, w: 1 },
};
elements[17] = {
__type__: 'CCPropertyOverrideInfo',
targetInfo: { __id__: 14 },
propertyPath: ['_euler'],
value: { __type__: 'cc.Vec3', x: 0, y: 0, z: 0 },
};
elements[18] = {
__type__: 'CCPropertyOverrideInfo',
targetInfo: { __id__: 14 },
propertyPath: ['_lscale'],
value: { __type__: 'cc.Vec3', x: 1, y: 1, z: 1 },
};
elements[19] = makePrefabInfo({
rootId: 1,
fileId: 'fixtureHomeRootFileId',
nestedPrefabInstanceRoots: [10],
});
elements[20] = makePrefabInfo({ rootId: 1, fileId: 'fixtureSpriteNodeFileId' });
elements[21] = makePrefabInfo({ rootId: 1, fileId: 'fixtureLabelNodeFileId' });
elements[22] = makeCompPrefabInfo('fixtureSpriteCompId');
elements[23] = makeCompPrefabInfo('fixtureLabelCompId');
elements[24] = makeNode({
name: 'btnMerge',
parentId: 1,
componentIds: [26],
prefabId: 25,
});
elements[25] = makePrefabInfo({ rootId: 1, fileId: 'fixtureBtnMergeNodeFileId' });
elements[26] = makeUITransform({
nodeId: 24,
width: 120,
height: 60,
prefabInfoId: 27,
});
elements[27] = makeCompPrefabInfo('fixtureBtnMergeUiTransformId');
return elements;
}
function buildNestedPrefabFixture() {
return [
makePrefabRoot({ name: 'TaskEntry', rootId: 1 }),
makeNode({ name: 'TaskEntry', prefabId: 2 }),
makePrefabInfo({ rootId: 1, fileId: NESTED_ROOT_FILE_ID }),
];
}
function writeGeneratedFile(filePath, content) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
if (fs.existsSync(filePath) && fs.readFileSync(filePath, 'utf8') === content) return;
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, content, 'utf8');
try {
fs.renameSync(tempPath, filePath);
} catch (error) {
if (fs.existsSync(filePath)) fs.unlinkSync(tempPath);
else throw error;
}
}
function ensureHomeUiFixture() {
fs.mkdirSync(FIXTURE_TEMP_DIR, { recursive: true });
writeGeneratedFile(
path.join(FIXTURE_PROJECT_ROOT, 'package.json'),
JSON.stringify({ name: 'cc-mcp-test-project', private: true }, null, 2) + '\n'
);
writeGeneratedFile(
NESTED_PREFAB_FIXTURE,
JSON.stringify(buildNestedPrefabFixture(), null, 2) + '\n'
);
writeGeneratedFile(
`${NESTED_PREFAB_FIXTURE}.meta`,
JSON.stringify({ ver: '1.1.50', importer: 'prefab', imported: true, uuid: NESTED_PREFAB_UUID }, null, 2) + '\n'
);
const taskBtnPath = path.join(FIXTURE_ASSETS_DIR, 'scripts', 'TaskBtn.ts');
writeGeneratedFile(
taskBtnPath,
"import { _decorator, Component } from 'cc';\nconst { ccclass } = _decorator;\n@ccclass('TaskBtn')\nexport class TaskBtn extends Component {}\n"
);
writeGeneratedFile(
`${taskBtnPath}.meta`,
JSON.stringify({ ver: '4.0.24', importer: 'typescript', imported: true, uuid: TASK_BTN_UUID }, null, 2) + '\n'
);
writeGeneratedFile(
HOME_UI_FIXTURE,
JSON.stringify(buildHomeUiFixture(), null, 2) + '\n'
);
return HOME_UI_FIXTURE;
}
module.exports = {
HOME_UI_FIXTURE,
FIXTURE_PROJECT_ROOT,
FIXTURE_TEMP_DIR,
TASK_BTN_UUID,
buildHomeUiFixture,
buildNestedPrefabFixture,
ensureHomeUiFixture,
};
+1
View File
@@ -1,2 +1,3 @@
# smoke test fixtures(只读副本,不提交) # smoke test fixtures(只读副本,不提交)
*.prefab *.prefab
project/
+1 -1
View File
@@ -5,7 +5,7 @@ const assert = require('node:assert/strict');
const path = require('path'); const path = require('path');
const { queryPrefab } = require('../src/query/index.js'); const { queryPrefab } = require('../src/query/index.js');
const FIXTURE = path.join(__dirname, 'fixtures', 'HomeUI.prefab'); const FIXTURE = require('./fixture.js').ensureHomeUiFixture();
// ─── selector: tree ───────────────────────────────────────── // ─── selector: tree ─────────────────────────────────────────
+5 -4
View File
@@ -9,14 +9,14 @@ const { test, after } = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const os = require('os'); const fixture = require('./fixture.js');
const { parsePrefab } = require('../src/parse.js'); const { parsePrefab } = require('../src/parse.js');
const { writePrefab, detectIndent, detectTrailingNewline } = require('../src/write.js'); const { writePrefab, detectIndent, detectTrailingNewline } = require('../src/write.js');
const { setOverrideProperty, listOverrides } = require('../src/overrides.js'); const { setOverrideProperty, listOverrides } = require('../src/overrides.js');
const FIXTURE_PATH = path.resolve(__dirname, 'fixtures/HomeUI.prefab'); const FIXTURE_PATH = fixture.ensureHomeUiFixture();
const TMP_PATH = path.join(os.tmpdir(), `HomeUI-smoke-${Date.now()}.prefab`); const TMP_PATH = path.join(fixture.FIXTURE_TEMP_DIR, `HomeUI-smoke-${process.pid}-${Date.now()}.prefab`);
// 清理临时文件 // 清理临时文件
after(() => { after(() => {
@@ -140,6 +140,7 @@ test('端到端: 改普通节点 _lpos.x + 写回 + 验证', () => {
test('端到端: 更新 stub 节点已有 override (_lpos) + 写回 + 验证', () => { test('端到端: 更新 stub 节点已有 override (_lpos) + 写回 + 验证', () => {
const prefabData = parsePrefab(FIXTURE_PATH); const prefabData = parsePrefab(FIXTURE_PATH);
prefabData.resolverStartPath = FIXTURE_PATH;
// stub 节点 index 10PrefabInfo index 11, fileId='as0LdMaKxSWSLxrZB9u9KA' // stub 节点 index 10PrefabInfo index 11, fileId='as0LdMaKxSWSLxrZB9u9KA'
// 已有 _lpos override: {x: -272, y: 53, z: 0} // 已有 _lpos override: {x: -272, y: 53, z: 0}
@@ -166,6 +167,7 @@ test('端到端: 更新 stub 节点已有 override (_lpos) + 写回 + 验证', (
test('端到端: 新增 stub 节点 override (不存在的属性) + 写回 + 验证', () => { test('端到端: 新增 stub 节点 override (不存在的属性) + 写回 + 验证', () => {
const prefabData = parsePrefab(FIXTURE_PATH); const prefabData = parsePrefab(FIXTURE_PATH);
prefabData.resolverStartPath = FIXTURE_PATH;
const STUB_ID = 10; const STUB_ID = 10;
const overridesBefore = listOverrides(prefabData, STUB_ID); const overridesBefore = listOverrides(prefabData, STUB_ID);
@@ -218,4 +220,3 @@ test('JSON diff 精确:只有目标字段变化', () => {
const origParsed = parsePrefab(FIXTURE_PATH); const origParsed = parsePrefab(FIXTURE_PATH);
assert.equal(reparsed.elements.length, origParsed.elements.length, 'element 总数应不变'); assert.equal(reparsed.elements.length, origParsed.elements.length, 'element 总数应不变');
}); });
+4 -6
View File
@@ -26,14 +26,12 @@ const {
} = require('../src/offline-tools.js'); } = require('../src/offline-tools.js');
// fixture: HomeUI.prefab(只读,在 cli/test/fixtures/ // fixture: HomeUI.prefab(只读,在 cli/test/fixtures/
const FIXTURE_PATH = path.resolve( const fixture = require('../../cli/test/fixture.js');
__dirname, const FIXTURE_PATH = fixture.ensureHomeUiFixture();
'../../cli/test/fixtures/HomeUI.prefab'
);
// 复制 fixture 到 tmp 用于写操作 // 复制 fixture 到 tmp 用于写操作
function makeTmp(tag) { function makeTmp(tag) {
var dst = path.join(os.tmpdir(), 'HomeUI-router-' + tag + '-' + Date.now() + '.prefab'); var dst = path.join(fixture.FIXTURE_TEMP_DIR, 'HomeUI-router-' + tag + '-' + process.pid + '-' + Date.now() + '.prefab');
fs.copyFileSync(FIXTURE_PATH, dst); fs.copyFileSync(FIXTURE_PATH, dst);
return dst; return dst;
} }
@@ -52,7 +50,7 @@ test('isOfflineTool 对已知 name 返回 true,未知 name 返回 false', () =
assert.equal(isOfflineTool('prefab_query'), true); assert.equal(isOfflineTool('prefab_query'), true);
assert.equal(isOfflineTool('prefab_edit'), true); assert.equal(isOfflineTool('prefab_edit'), true);
assert.equal(isOfflineTool('prefab_batch'), true); assert.equal(isOfflineTool('prefab_batch'), true);
assert.equal(isOfflineTool('router_list_editors'), false); assert.equal(isOfflineTool('gateway_list_editors'), false);
assert.equal(isOfflineTool('scene_set_property'), false); assert.equal(isOfflineTool('scene_set_property'), false);
assert.equal(isOfflineTool(''), false); assert.equal(isOfflineTool(''), false);
}); });