Files
SpineViewer/tests/release-mirror.test.js
T
2026-09-18 00:07:38 +08:00

196 lines
9.4 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.
'use strict';
// 出包前体检:release/UNIVERSAL_SpineViewer 必须是「干净 + 与源逐字节一致 + 引用无断链」的目录,
// 因为 Eagle 的“打包插件”是直接把这个目录打成 .eagleplugin,污染会被原样带进安装包。
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const mirror = path.join(root, 'release', 'UNIVERSAL_SpineViewer');
const SHARED_TREES = ['viewer', 'js', 'lib', '_locales', 'assets'];
// package.json 是“发行版专有”文件:不再要求逐字节一致(发行版删掉了指向 tests/ 的 scripts),
// 但 name/version/type 必须与源一致,下面单独校验。
const SHARED_FILES = ['manifest.json', 'package-lock.json', 'logo-spineviewer.png'];
// 只留在源目录、刻意不进入安装包的文件(新增这类文件时必须显式登记,避免漏打包)。
const SOURCE_ONLY = [
'viewer/icns.html',
'js/icns-util.js',
'js/image-size.js',
'js/spine-render.js',
'assets/README.md',
'assets/spineviewer-icon-master.png',
];
// 临时豁免清单:包内暂时无人引用的脚本。历史上曾登记过旧「导入缩略图处理器」的
// js/eagle-compound.js,已随死代码清理删除。新增的无用脚本会被下面的断言拦住。
const UNREFERENCED_SCRIPT_OK = [];
// 语言文件里未被任何代码引用的键(旧的导入缩略图/元素提示文案遗留)。
const UNUSED_MESSAGE_OK = [
'viewer.groupSkipCustom',
'viewer.thumbWrittenNoRefresh',
'viewer.compoundName',
'viewer.txtRegistered',
'viewer.noItemId',
];
const JUNK = /(\.log|\.tmp|\.bak|\.orig|\.swp|~)$|^\.DS_Store$|^Thumbs\.db$|^node_modules$|^\.git$|^\.idea$|^\.vscode$/i;
function walk(directory, prefix = '') {
const out = [];
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const relative = prefix ? prefix + '/' + entry.name : entry.name;
if (entry.isDirectory()) out.push(...walk(path.join(directory, entry.name), relative));
else out.push(relative);
}
return out;
}
assert.ok(fs.existsSync(mirror), '缺少 release/UNIVERSAL_SpineViewer 目录');
// 1) 洁净度:不含日志、临时文件、系统或编辑器垃圾
const junk = walk(mirror).filter((file) => JUNK.test(path.basename(file)) || file.split('/').some((part) => JUNK.test(part)));
assert.deepEqual(junk, [], '镜像内存在垃圾文件:' + junk.join(', '));
// 2) 共享文件必须与源逐字节一致(镜像过时会发出带旧代码的包)
const drift = [];
for (const tree of SHARED_TREES) {
for (const relative of walk(path.join(root, tree))) {
const name = tree + '/' + relative;
const source = path.join(root, tree, relative);
const copy = path.join(mirror, tree, relative);
if (!fs.existsSync(copy)) {
if (!SOURCE_ONLY.includes(name)) drift.push('MISSING ' + name);
} else if (!fs.readFileSync(source).equals(fs.readFileSync(copy))) {
drift.push('DIFFERENT ' + name);
}
}
}
for (const file of SHARED_FILES) {
const source = path.join(root, file);
const copy = path.join(mirror, file);
if (!fs.existsSync(copy)) drift.push('MISSING ' + file);
else if (!fs.readFileSync(source).equals(fs.readFileSync(copy))) drift.push('DIFFERENT ' + file);
}
assert.deepEqual(drift, [], '镜像与源不一致:' + drift.join(', '));
// 3) 镜像内不能有源目录里不存在的文件(排除根级专有文档)
const extra = [];
for (const tree of SHARED_TREES) {
for (const relative of walk(path.join(mirror, tree))) {
if (!fs.existsSync(path.join(root, tree, relative))) extra.push(tree + '/' + relative);
}
}
assert.deepEqual(extra, [], '镜像内存在源目录没有的文件:' + extra.join(', '));
// 4) 相对 require 目标必须存在(缺一个就会在运行时抛错)
const broken = [];
for (const relative of walk(mirror)) {
if (!/\.(js|html)$/.test(relative)) continue;
const text = fs.readFileSync(path.join(mirror, relative), 'utf8');
for (const match of text.matchAll(/require\(\s*['"]([^'"]+)['"]\s*\)/g)) {
if (!match[1].startsWith('.')) continue;
const target = path.posix.normalize(path.posix.join(path.posix.dirname(relative), match[1]));
if (!fs.existsSync(path.join(mirror, target))) broken.push(relative + ' -> ' + match[1]);
}
}
assert.deepEqual(broken, [], '镜像内 require 目标缺失:' + broken.join(', '));
// 5) 不能残留已移除模块的引用
const removed = ['icns-util', 'image-size', 'spine-render'];
const leftovers = [];
for (const relative of walk(mirror)) {
if (!/\.(js|html)$/.test(relative)) continue;
const text = fs.readFileSync(path.join(mirror, relative), 'utf8');
for (const name of removed) {
if (text.includes(name)) leftovers.push(relative + ' 提到 ' + name);
}
}
assert.deepEqual(leftovers, [], '镜像内残留已移除模块的引用:' + leftovers.join(', '));
// 6) manifest 指向的资源必须齐全,语言文件必须是合法 JSON
const manifest = JSON.parse(fs.readFileSync(path.join(mirror, 'manifest.json'), 'utf8'));
assert.ok(fs.existsSync(path.join(mirror, manifest.logo.replace(/^\//, ''))), 'manifest.logo 指向的文件不存在');
for (const entry of Object.values(manifest.preview || {})) {
const viewerPath = entry && entry.viewer && entry.viewer.path;
assert.ok(viewerPath && fs.existsSync(path.join(mirror, viewerPath)), 'manifest.preview 的 viewer 路径不存在');
}
assert.deepEqual(manifest.languages, ['zh_CN', 'en', 'zh_TW', 'ja_JP', 'ko_KR', 'ru_RU', 'es_ES', 'de_DE']);
for (const lang of manifest.languages) {
const file = path.join(mirror, '_locales', lang + '.json');
assert.ok(fs.existsSync(file), '缺少语言文件 ' + lang + '.json');
const table = JSON.parse(fs.readFileSync(file, 'utf8'));
assert.equal(typeof table.viewer, 'object', lang + '.json 缺少 viewer 段');
}
// 7) 九个运行时版本齐全(版本检测按 3.4–4.3 选择运行时)
const expectedRuntimes = ['3.4', '3.5', '3.6', '3.7', '3.8', '4.0', '4.1', '4.2', '4.3'];
assert.deepEqual(fs.readdirSync(path.join(mirror, 'lib')).sort(), expectedRuntimes.slice().sort());
for (const runtime of expectedRuntimes) {
assert.ok(fs.existsSync(path.join(mirror, 'lib', runtime, 'spine-canvas.js')), '缺少 lib/' + runtime + '/spine-canvas.js');
}
// 8) 镜像里的 LICENSE 必须齐全
for (const license of fs.readdirSync(path.join(root, 'LICENSES'))) {
assert.ok(fs.existsSync(path.join(mirror, 'LICENSES', license)), '缺少 LICENSES/' + license);
}
// 8) 发行版 package.json:不能指向包内不存在的文件(曾经的 check:release -> tests/ 死链)
const rootPackage = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
const mirrorPackage = JSON.parse(fs.readFileSync(path.join(mirror, 'package.json'), 'utf8'));
for (const field of ['name', 'version', 'type']) {
assert.equal(mirrorPackage[field], rootPackage[field], '发行版 package.json 的 ' + field + ' 与源不一致');
}
const deadScripts = [];
for (const [name, command] of Object.entries(mirrorPackage.scripts || {})) {
for (const token of String(command).split(/\s+/)) {
if (!/^[\w./-]+\.(js|mjs|cjs|json)$/.test(token)) continue;
if (!fs.existsSync(path.join(mirror, token))) deadScripts.push('scripts.' + name + ' -> ' + token);
}
}
assert.deepEqual(deadScripts, [], '发行版 package.json 的 scripts 指向包内不存在的文件:' + deadScripts.join(', '));
// 9) 死脚本:包内每个 js 模块都必须被入口或其他模块引用
const entrySource = fs.readFileSync(path.join(mirror, 'viewer', 'spine.html'), 'utf8');
const scriptNames = fs.readdirSync(path.join(mirror, 'js'));
const unreferenced = [];
for (const file of scriptNames) {
const others = [entrySource]
.concat(scriptNames.filter((name) => name !== file).map((name) => fs.readFileSync(path.join(mirror, 'js', name), 'utf8')))
.join('\n');
if (!others.includes(file)) unreferenced.push('js/' + file);
}
assert.deepEqual(
unreferenced.filter((file) => !UNREFERENCED_SCRIPT_OK.includes(file)),
[],
'包内存在无人引用的脚本:' + unreferenced.join(', '),
);
// 10) 死文案:语言文件里的键必须被入口/脚本/manifest 引用(manifest 的 name 用 {{manifest.app.name}})
const referenced = new Set();
const sources = [entrySource, fs.readFileSync(path.join(mirror, 'manifest.json'), 'utf8')]
.concat(scriptNames.map((name) => fs.readFileSync(path.join(mirror, 'js', name), 'utf8')));
for (const text of sources) {
for (const match of text.matchAll(/[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+/g)) referenced.add(match[0]);
}
const unusedMessages = [];
for (const lang of manifest.languages) {
const table = JSON.parse(fs.readFileSync(path.join(mirror, '_locales', lang + '.json'), 'utf8'));
const keys = [];
const collect = (node, prefix) => {
for (const key of Object.keys(node)) {
const value = node[key];
if (value && typeof value === 'object') collect(value, prefix + key + '.');
else keys.push(prefix + key);
}
};
collect(table, '');
for (const key of keys) {
if (!referenced.has(key) && !unusedMessages.includes(key)) unusedMessages.push(key);
}
}
assert.deepEqual(
unusedMessages.filter((key) => !UNUSED_MESSAGE_OK.includes(key)),
[],
'语言文件里存在无人引用的死文案:' + unusedMessages.join(', '),
);
console.log('PASS: 镜像与源一致、无垃圾文件、无断链引用、无死脚本/死文案,manifest/语言/运行时/许可齐全');