Files
youle_cocos/cocoscreator_projects/scripts/test/build-game.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

148 lines
6.0 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 { buildGame } from '../build-game.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 SKINNED = 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 scaffold(gameVersion = '3.8.8') {
const root = mkdtempSync(join(tmpdir(), 'youle-build-'));
const hostProject = join(root, 'nexus');
const frameworkSrc = join(hostProject, 'assets', 'framework');
mkdirSync(frameworkSrc, { recursive: true });
writeFileSync(join(hostProject, 'package.json'), JSON.stringify({ name: 'YouleNexus', creator: { version: '3.8.8' } }));
put(frameworkSrc, 'ui/atlas-hall/btn.png', DEFAULT_BTN);
// junction 故意指向一个诱饵目录而非 frameworkSrc 自身,与 materialize.test.mjs 同理:
// 否则"经 junction 拷来的字节"与"从真源拷来的字节"完全相同,测不出 Critical 1 那类 bug。
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/__decoy_marker__.png', DEFAULT_BTN);
const gamesDir = join(root, 'games');
const gameDir = join(gamesDir, 'demo');
mkdirSync(join(gameDir, 'assets'), { recursive: true });
writeFileSync(join(gameDir, 'package.json'), JSON.stringify({ name: 'demo', creator: { version: gameVersion } }));
symlinkSync(frameworkDecoy, join(gameDir, 'assets', 'framework'), LINK_TYPE);
return { root, hostProject, frameworkSrc, frameworkDecoy, gamesDir, gameDir, workspace: join(root, 'build-workspace') };
}
function cleanup(root) { rmSync(root, { recursive: true, force: true }); }
function baseOpts(s, extra = {}) {
return {
gamesDir: s.gamesDir, frameworkSrc: s.frameworkSrc, hostProject: s.hostProject,
workspace: s.workspace, platform: 'web-mobile',
frameworkCommit: 'abc1234', now: '2026-08-27T00:00:00.000Z',
runBuild: () => {},
...extra,
};
}
test('buildGame 走通全流程:实体化 → 合成 → 调构建 → 写 build-info', () => {
const s = scaffold();
try {
put(s.gameDir, 'assets/game/override/atlas-hall/btn.png', SKINNED);
const calls = [];
const r = buildGame('demo', baseOpts(s, { runBuild: (dir, p) => calls.push([dir, p]) }));
assert.ok(readFileSync(join(r.workspaceDir, 'assets', 'framework', 'ui', 'atlas-hall', 'btn.png')).equals(SKINNED));
assert.ok(readFileSync(join(s.frameworkSrc, 'ui', 'atlas-hall', 'btn.png')).equals(DEFAULT_BTN), '框架真源必须只读:合成只能发生在临时工作区');
assert.ok(!existsSync(join(r.workspaceDir, 'assets', 'framework', 'ui', 'atlas-hall', '__decoy_marker__.png')),
'framework 必须来自 frameworkSrc 真源,不能残留 junction 诱饵目标独有的文件(Critical 1)');
assert.equal(calls.length, 1);
assert.equal(calls[0][0], r.workspaceDir);
assert.equal(calls[0][1], 'web-mobile');
const info = JSON.parse(readFileSync(join(r.workspaceDir, 'build-info.json'), 'utf8'));
assert.equal(info.game, 'demo');
assert.equal(info.frameworkCommit, 'abc1234');
assert.equal(info.platform, 'web-mobile');
assert.deepEqual(info.skin, { matched: 1, frameworkTotal: 1 });
} finally { cleanup(s.root); }
});
test('buildGame 在引擎版本不一致时中止,且不调用构建', () => {
const s = scaffold('3.8.6');
try {
let called = false;
assert.throws(
() => buildGame('demo', baseOpts(s, { runBuild: () => { called = true; } })),
/引擎版本不一致/,
);
assert.equal(called, false);
} finally { cleanup(s.root); }
});
test('buildGame 在皮肤校验失败时中止,且不调用构建', () => {
const s = scaffold();
try {
put(s.gameDir, 'assets/game/override/atlas-hall/typo.png', 'X');
let called = false;
assert.throws(
() => buildGame('demo', baseOpts(s, { runBuild: () => { called = true; } })),
/皮肤覆盖校验未通过/,
);
assert.equal(called, false);
} finally { cleanup(s.root); }
});
test('buildGame 重复构建:清掉旧工作区后重建(幂等)', () => {
const s = scaffold();
try {
const opts = baseOpts(s);
buildGame('demo', opts);
const r2 = buildGame('demo', opts); // 不应因目标已存在而抛错
assert.ok(existsSync(join(r2.workspaceDir, 'build-info.json')));
} finally { cleanup(s.root); }
});
test('buildGame 对不存在的子游戏抛错', () => {
const s = scaffold();
try {
assert.throws(() => buildGame('nosuch', baseOpts(s)), /找不到子游戏工程/);
} finally { cleanup(s.root); }
});