Files
youle_cocos/cocoscreator_projects/scripts/test/materialize.test.mjs
T
joywayerandClaude Opus 5 5708926711 fix(skin): 修复最终评审发现的 6 处缺陷(junction 排除失效/meta 静默丢失等)
跨任务视角发现的问题:Critical1 materialize.mjs 的 junction 排除比较在
Windows 上因长路径前缀恒假、从未生效,且测试夹具的 junction 指向真源自身,
掩盖了此问题;Critical2 composeSkin 例外通道在 uuid 缺失/框架侧无 meta 时
静默失败,违反第二准则;Important3 可覆盖范围误把 theme/ 下的框架 TS 代码
也纳入;Important5 cli-entry 测试漏注册 check-skin/build-game;Important6
composeSkin 与 check-skin 各自拼路径,未共用 paths.mjs 权威推导;Important7
--platform 缺值时静默落到默认平台。逐条修复并补测试,Critical1 用诱饵框架
目录验证过能真正杀掉该 bug(变异推演见 fix report)。

spec §5 step5 的 dist/<name>/ 拷贝按要求标注为本期未实现,不实现。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 23:15:28 +08:00

231 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, mkdtempSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { platform } from 'node:process';
import { deflateSync } from 'node:zlib';
import { materialize, composeSkin, SkinValidationError } from '../lib/materialize.mjs';
const LINK_TYPE = platform === 'win32' ? 'junction' : 'dir';
function crc32(buf) {
let c = ~0;
for (const x of buf) { c ^= x; for (let i = 0; i < 8; i++) c = (c >>> 1) ^ (0xEDB88320 & -(c & 1)); }
return ~c >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const c = Buffer.alloc(4); c.writeUInt32BE(crc32(td));
return Buffer.concat([len, td, c]);
}
function makePng(w, h, rgb = [0, 0, 0]) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
const raw = Buffer.alloc(h * (1 + w * 3));
for (let y = 0; y < h; y++) {
const off = y * (1 + w * 3);
raw[off] = 0;
for (let x = 0; x < w; x++) {
raw[off + 1 + x * 3] = rgb[0]; raw[off + 2 + x * 3] = rgb[1]; raw[off + 3 + x * 3] = rgb[2];
}
}
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0)),
]);
}
const DEFAULT_BTN = makePng(64, 64, [1, 2, 3]);
const DEFAULT_BG = makePng(64, 64, [4, 5, 6]);
const SKINNED_BTN = makePng(64, 64, [9, 9, 9]);
function put(dir, rel, content) {
const p = join(dir, ...rel.split('/'));
mkdirSync(join(p, '..'), { recursive: true });
writeFileSync(p, content);
}
function read(dir, rel) { return readFileSync(join(dir, ...rel.split('/')), 'utf8'); }
function readBinary(dir, rel) { return readFileSync(join(dir, ...rel.split('/'))); }
/**
* 造一个「框架真源 + 挂 junction 的子游戏工程」的临时环境。
*
* junction **故意不指向 frameworkSrc 本身**,而是指向一个内容相似、但多一个
* 标记文件的「诱饵」目录(frameworkDecoy)。这样"实体化后的 framework 到底是
* 从 junction 解引用来的,还是从真源 frameworkSrc 单独拷的"才是可判定的——
* 若两者是同一目录,字节完全相同,Critical 1(junction 排除分支在 Windows 上
* 从未生效)这个 bug 永远不会被测出来。
*/
function scaffold() {
const root = mkdtempSync(join(tmpdir(), 'youle-mat-'));
const frameworkSrc = join(root, 'nexus', 'assets', 'framework');
mkdirSync(frameworkSrc, { recursive: true });
put(frameworkSrc, 'ui/atlas-hall/btn.png', DEFAULT_BTN);
put(frameworkSrc, 'ui/atlas-hall/btn.png.meta', '{"uuid":"fw-btn-uuid"}');
put(frameworkSrc, 'ui/atlas-hall/bg.png', DEFAULT_BG);
put(frameworkSrc, 'core/net.ts', 'export const x=1;');
// 诱饵:junction 实际指向这里。内容与 frameworkSrc 一致,但多一个标记文件。
const frameworkDecoy = join(root, 'decoy', 'assets', 'framework');
mkdirSync(frameworkDecoy, { recursive: true });
put(frameworkDecoy, 'ui/atlas-hall/btn.png', DEFAULT_BTN);
put(frameworkDecoy, 'ui/atlas-hall/btn.png.meta', '{"uuid":"fw-btn-uuid"}');
put(frameworkDecoy, 'ui/atlas-hall/bg.png', DEFAULT_BG);
put(frameworkDecoy, 'core/net.ts', 'export const x=1;');
put(frameworkDecoy, 'ui/atlas-hall/__decoy_marker__.png', DEFAULT_BG); // 只存在于诱饵里
const gameDir = join(root, 'games', 'demo');
mkdirSync(join(gameDir, 'assets'), { recursive: true });
writeFileSync(join(gameDir, 'package.json'), JSON.stringify({ name: 'demo', creator: { version: '3.8.8' } }));
symlinkSync(frameworkDecoy, join(gameDir, 'assets', 'framework'), LINK_TYPE);
put(gameDir, 'assets/game/theme.ts', 'export const theme={};');
mkdirSync(join(gameDir, 'library'), { recursive: true }); // 缓存目录,应被跳过
writeFileSync(join(gameDir, 'library', 'junk'), 'x');
return { root, frameworkSrc, frameworkDecoy, gameDir, dest: join(root, 'workspace', 'demo') };
}
function cleanup(root) { rmSync(root, { recursive: true, force: true }); }
test('materialize 把 junction 变成真实目录拷贝,并跳过缓存目录', () => {
const s = scaffold();
try {
materialize(s.gameDir, s.frameworkSrc, s.dest);
assert.ok(readBinary(s.dest, 'assets/framework/ui/atlas-hall/btn.png').equals(DEFAULT_BTN), '框架 btn.png 被正确拷贝');
assert.equal(read(s.dest, 'assets/game/theme.ts'), 'export const theme={};', '游戏资源被拷贝');
assert.ok(!existsSync(join(s.dest, 'library')), 'library 应被跳过');
} finally { cleanup(s.root); }
});
test('materialize 只跳过工程根的缓存目录,不吞 assets 下的同名业务目录', () => {
const s = scaffold();
try {
put(s.gameDir, 'assets/game/native/bridge.ts', 'BRIDGE');
materialize(s.gameDir, s.frameworkSrc, s.dest);
assert.equal(read(s.dest, 'assets/game/native/bridge.ts'), 'BRIDGE', 'assets 下的 native 是业务目录,必须保留');
assert.ok(!existsSync(join(s.dest, 'library')), '工程根的 library 仍应被跳过');
} finally { cleanup(s.root); }
});
test('materialize 后写临时工程不影响框架真源', () => {
const s = scaffold();
try {
materialize(s.gameDir, s.frameworkSrc, s.dest);
writeFileSync(join(s.dest, 'assets', 'framework', 'ui', 'atlas-hall', 'btn.png'), Buffer.from('MUTATED'));
assert.ok(readBinary(s.frameworkSrc, 'ui/atlas-hall/btn.png').equals(DEFAULT_BTN), '真源必须只读');
} finally { cleanup(s.root); }
});
test('materialize 的 framework 来自真源 frameworkSrc,而非 junction 解引用(诱饵标记文件必须不存在)', () => {
const s = scaffold();
try {
materialize(s.gameDir, s.frameworkSrc, s.dest);
assert.ok(
!existsSync(join(s.dest, 'assets', 'framework', 'ui', 'atlas-hall', '__decoy_marker__.png')),
'framework 必须单独从 frameworkSrc 真源拷贝;诱饵(junction 目标)独有的标记文件不应出现在工作区里',
);
} finally { cleanup(s.root); }
});
test('materialize 拒绝覆盖已存在的目标目录', () => {
const s = scaffold();
try {
materialize(s.gameDir, s.frameworkSrc, s.dest);
assert.throws(() => materialize(s.gameDir, s.frameworkSrc, s.dest), /已存在/);
} finally { cleanup(s.root); }
});
test('composeSkin 用 override 覆盖框架资源内容,保留框架 .meta', () => {
const s = scaffold();
try {
put(s.gameDir, 'assets/game/override/atlas-hall/btn.png', SKINNED_BTN);
materialize(s.gameDir, s.frameworkSrc, s.dest);
const r = composeSkin(s.dest);
assert.deepEqual(r.matched, ['atlas-hall/btn.png'], 'matched 返回覆盖的文件');
assert.equal(r.frameworkTotal, 2, '框架资源总数');
assert.ok(readBinary(s.dest, 'assets/framework/ui/atlas-hall/btn.png').equals(SKINNED_BTN), '框架文件被 override 替换');
assert.equal(read(s.dest, 'assets/framework/ui/atlas-hall/btn.png.meta'), '{"uuid":"fw-btn-uuid"}', 'meta 文件不变');
assert.ok(readBinary(s.dest, 'assets/framework/ui/atlas-hall/bg.png').equals(DEFAULT_BG), '未覆盖的保持默认');
} finally { cleanup(s.root); }
});
test('composeSkin 例外通道:override 自带 meta 时,uuid 被改回框架的值', () => {
const s = scaffold();
try {
put(s.gameDir, 'assets/game/override/atlas-hall/btn.png', SKINNED_BTN);
put(s.gameDir, 'assets/game/override/atlas-hall/btn.png.meta', '{"uuid":"game-own-uuid","ver":"1.0.0"}');
materialize(s.gameDir, s.frameworkSrc, s.dest);
composeSkin(s.dest);
const meta = JSON.parse(read(s.dest, 'assets/framework/ui/atlas-hall/btn.png.meta'));
assert.equal(meta.uuid, 'fw-btn-uuid', 'uuid 必须沿用框架的');
assert.equal(meta.ver, '1.0.0', 'override meta 的其余字段保留');
} finally { cleanup(s.root); }
});
test('composeSkin 例外通道:框架侧 .meta 没有 uuid 时显式抛错(不得写出没有 uuid 的 .meta)', () => {
const s = scaffold();
try {
// 框架 bg.png 本身没有 .meta;给它补一份没有 uuid 字段的 .meta,
// 模拟 fwUuid === undefined 的场景(JSON.stringify 会静默丢掉 undefined 的键)
put(s.gameDir, 'assets/game/override/atlas-hall/bg.png', SKINNED_BTN);
put(s.gameDir, 'assets/game/override/atlas-hall/bg.png.meta', '{"ver":"1.0.0"}');
materialize(s.gameDir, s.frameworkSrc, s.dest);
// 框架侧补一份没有 uuid 的 .meta(模拟框架 meta 本身缺 uuid 的坏数据)
writeFileSync(join(s.dest, 'assets', 'framework', 'ui', 'atlas-hall', 'bg.png.meta'), '{"ver":"0.9.0"}');
let err;
assert.throws(() => {
try { composeSkin(s.dest); } catch (e) { err = e; throw e; }
}, SkinValidationError);
assert.equal(err.errors[0].code, 'meta-missing-uuid');
assert.match(err.errors[0].path, /bg\.png/, '错误须指向具体文件相对路径');
assert.match(err.errors[0].message, /demo/, '错误须指向子游戏名');
assert.match(err.errors[0].message, /uuid/, '错误须说明具体原因');
} finally { cleanup(s.root); }
});
test('composeSkin 例外通道:框架侧没有 .meta 时显式抛错(不得静默丢弃 override 的 meta)', () => {
const s = scaffold();
try {
// bg.png 在框架侧没有 .meta,但 override 给它带了一份 —— 之前的实现会静默跳过整段合并
put(s.gameDir, 'assets/game/override/atlas-hall/bg.png', SKINNED_BTN);
put(s.gameDir, 'assets/game/override/atlas-hall/bg.png.meta', '{"uuid":"game-own-uuid"}');
materialize(s.gameDir, s.frameworkSrc, s.dest);
let err;
assert.throws(() => {
try { composeSkin(s.dest); } catch (e) { err = e; throw e; }
}, SkinValidationError);
assert.equal(err.errors[0].code, 'meta-orphan');
assert.match(err.errors[0].path, /bg\.png/, '错误须指向具体文件相对路径');
assert.match(err.errors[0].message, /demo/, '错误须指向子游戏名');
assert.match(err.errors[0].message, /\.meta/, '错误须说明具体原因');
} finally { cleanup(s.root); }
});
test('composeSkin 校验不过时抛 SkinValidationError 且不写任何文件', () => {
const s = scaffold();
try {
// 同时放入一个合法的 override 和一个孤儿 override
put(s.gameDir, 'assets/game/override/atlas-hall/btn.png', SKINNED_BTN); // 合法:框架里有此文件
put(s.gameDir, 'assets/game/override/atlas-hall/typo.png', DEFAULT_BG); // 孤儿:框架里无此文件
materialize(s.gameDir, s.frameworkSrc, s.dest);
let validationErr;
assert.throws(() => {
try {
composeSkin(s.dest);
} catch (err) {
validationErr = err;
throw err;
}
}, SkinValidationError);
// 验证错误中确实有 orphan 错误指向孤儿文件
assert.ok(validationErr.errors.some(e => e.code === 'orphan' && e.path === 'atlas-hall/typo.png'), 'orphan 错误必须被检测');
// 验证即使合法的 btn.png 也没有被写入(校验失败时不得部分写入)
assert.ok(readBinary(s.dest, 'assets/framework/ui/atlas-hall/btn.png').equals(DEFAULT_BTN), '校验失败时不得部分写入:btn.png 应仍为框架默认值');
} finally { cleanup(s.root); }
});