196 lines
9.4 KiB
JavaScript
196 lines
9.4 KiB
JavaScript
'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/语言/运行时/许可齐全');
|