diff --git a/release/SpineViewer.eagleplugin b/release/SpineViewer.eagleplugin index 3995e28..d0b8882 100644 Binary files a/release/SpineViewer.eagleplugin and b/release/SpineViewer.eagleplugin differ diff --git a/tests/release-mirror.test.js b/tests/release-mirror.test.js new file mode 100644 index 0000000..5b28940 --- /dev/null +++ b/tests/release-mirror.test.js @@ -0,0 +1,122 @@ +'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']; +const SHARED_FILES = ['manifest.json', 'package.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', +]; +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); +} + +console.log('PASS: 镜像与源逐字节一致、无垃圾文件、无断链引用,manifest/语言/运行时/许可齐全');