import { spawnSync } from 'node:child_process'; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; import { ASSIMP_FALLBACK_DIRS, assimpInstallHint, assimpMissingMessage, detectAssimpBin, resolveAssimpBinary, } from '../src/common/assimp'; import { convertModelFile } from '../src/models/model-convert'; /** * Assimp 缺失时的可诊断性。 * * 背景(真实故障):服务器没装 Assimp,上传 PMX 压缩包只得到 * GLB 直出:导出失败(ENOENT):spawn assimp ENOENT * glTF 直出:导出失败(ENOENT):spawn assimp ENOENT * 用户看不出「缺什么、怎么装」。现在: * * 转换前先定位二进制,找不到就一次性给出安装命令与查过的路径; * * 定位逻辑覆盖 PATH 之外的常见目录(服务由 systemd 托管时 PATH 可能很窄)。 */ let workRoot = ''; let fakeBin = ''; function findDeployLib(): string { const candidates = [ resolve(process.cwd(), '..', 'deploy', 'assimp-lib.sh'), resolve(process.cwd(), 'deploy', 'assimp-lib.sh'), resolve(process.cwd(), '..', '..', 'deploy', 'assimp-lib.sh'), ]; for (const candidate of candidates) { if (existsSync(candidate)) return candidate; } throw new Error(`找不到 deploy/assimp-lib.sh,候选路径:\n${candidates.join('\n')}`); } const ASSIMP_LIB = findDeployLib(); /** 写一个最小的假 assimp:只实现 `version` */ function writeFakeAssimp(path: string, versionLine = 'Assimp version 6.0.4'): string { mkdirSync(join(path, '..'), { recursive: true }); writeFileSync(path, `#!/bin/bash\n[ "\${1:-}" = "version" ] && echo "${versionLine}"\nexit 0\n`); chmodSync(path, 0o755); return path; } beforeAll(() => { workRoot = mkdtempSync(join(tmpdir(), 'opendance-assimp-test-')); fakeBin = writeFakeAssimp(join(workRoot, 'bin', 'assimp')); }); afterAll(() => { rmSync(workRoot, { recursive: true, force: true }); }); const savedPath = process.env.PATH; afterEach(() => { process.env.PATH = savedPath; }); describe('Assimp 定位(resolveAssimpBinary)', () => { it('能在 PATH 中找到可执行文件', () => { process.env.PATH = `${join(workRoot, 'bin')}:/usr/bin:/bin`; const result = resolveAssimpBinary('assimp'); expect(result.found).toBe(true); expect(result.path).toBe(fakeBin); }); it('显式绝对路径优先,不存在时 found=false 并给出查找记录', () => { const missing = join(workRoot, 'no-such-assimp'); const result = resolveAssimpBinary(missing); expect(result.found).toBe(false); expect(result.searched).toContain(missing); const present = resolveAssimpBinary(fakeBin); expect(present.found).toBe(true); expect(present.path).toBe(fakeBin); }); it('PATH 里没有时仍会查常见系统目录(systemd 服务 PATH 可能很窄)', () => { process.env.PATH = '/nonexistent-dir-only'; const result = resolveAssimpBinary('assimp'); // 结果取决于本机是否装了真实 assimp:两种都必须把常见目录查过 for (const dir of ASSIMP_FALLBACK_DIRS) { expect(result.searched).toContain(join(dir, 'assimp')); } if (!result.found) expect(result.path).toBe('assimp'); }); }); describe('Assimp 缺失时的错误信息', () => { it('包含二进制名、各发行版安装命令、替代办法与查过的路径', () => { const message = assimpMissingMessage('assimp', ['/usr/bin/assimp', '/usr/local/bin/assimp']); expect(message).toContain('assimp'); expect(message).toContain('apt-get install -y assimp-utils'); expect(message).toContain('dnf install -y assimp'); expect(message).toContain('/usr/bin/assimp'); expect(message).toContain('ASSIMP_BIN'); expect(message).toContain('.glb / .vrm'); }); it('安装提示各发行版一条命令', () => { const hint = assimpInstallHint(); for (const distro of ['Debian / Ubuntu', 'Alpine', 'Arch']) { expect(hint).toContain(distro); } }); }); describe('Assimp 可用性探测(detectAssimpBin)', () => { it('能执行时返回版本与路径', async () => { const probe = await detectAssimpBin(fakeBin); expect(probe.available).toBe(true); expect(probe.path).toBe(fakeBin); expect(probe.version).toContain('6.0.4'); }); it('找不到时 available=false 且 error 说明未安装', async () => { const probe = await detectAssimpBin(join(workRoot, 'no-such-assimp')); expect(probe.available).toBe(false); expect(probe.error).toContain('未安装'); expect(probe.searched.length).toBeGreaterThan(0); }); }); describe('转换管线:Assimp 缺失时快速失败并给出可操作信息', () => { it('convertModelFile 抛出的错误包含安装命令,且不再出现两次 spawn ENOENT', async () => { let message = ''; try { await convertModelFile(Buffer.from('PMX dummy'), 'Hatsune Miku.pmx', { assimpBin: join(workRoot, 'no-such-assimp'), timeoutMs: 5_000, }); throw new Error('应当抛出异常'); } catch (error) { message = error instanceof Error ? error.message : String(error); } expect(message).toContain('模型转换失败'); expect(message).toContain('apt-get install -y assimp-utils'); expect(message).toContain('ASSIMP_BIN'); expect(message).not.toContain('ENOENT'); }); }); describe('deploy/assimp-lib.sh:部署脚本共用的安装逻辑', () => { function runLib(script: string, path?: string) { // 覆盖 PATH 的用例里必须用绝对路径调 bash,否则连 bash 都找不到 return spawnSync('/bin/bash', ['-c', `. "$1"; ${script}`, '_', ASSIMP_LIB], { encoding: 'utf8', timeout: 15000, env: { ...process.env, ...(path ? { PATH: path } : {}) }, }); } it('opendance_assimp_resolve 能在 PATH 中找到二进制', () => { const result = runLib('opendance_assimp_resolve', `${join(workRoot, 'bin')}:/usr/bin:/bin`); expect(result.stdout.trim()).toBe(fakeBin); expect(result.status).toBe(0); }); it('opendance_assimp_resolve 在找不到时返回非 0 且无输出', () => { const result = runLib('opendance_assimp_resolve || echo MISSING', '/nonexistent-dir-only'); // 本机若真的装了 assimp(/usr/bin),则跳过这条断言的严格性 if (!existsSync('/usr/bin/assimp') && !existsSync('/usr/local/bin/assimp')) { expect(result.stdout.trim()).toBe('MISSING'); } expect(result.status).toBe(0); }); it('opendance_assimp_install_cmd 按包管理器给出对应命令', () => { const aptDir = join(workRoot, 'apt'); mkdirSync(aptDir, { recursive: true }); writeFileSync(join(aptDir, 'apt-get'), '#!/bin/bash\nexit 0\n'); chmodSync(join(aptDir, 'apt-get'), 0o755); const result = runLib('opendance_assimp_install_cmd', `${aptDir}:/usr/bin:/bin`); expect(result.stdout).toContain('apt-get install -y assimp-utils'); }); it('opendance_assimp_install 调用包管理器安装 assimp-utils(离线失败也不抛)', () => { const aptDir = join(workRoot, 'apt'); const log = join(aptDir, 'calls.log'); rmSync(log, { force: true }); writeFileSync(join(aptDir, 'apt-get'), `#!/bin/bash\necho "apt-get $*" >> "${log}"\nexit 0\n`); chmodSync(join(aptDir, 'apt-get'), 0o755); const result = runLib('opendance_assimp_install', `${aptDir}:/usr/bin:/bin`); expect(result.status).toBe(0); const calls = existsSync(log) ? readFileSync(log, 'utf8') : ''; expect(calls).toContain('apt-get install -y -qq assimp-utils'); }); it('非常规目录需要写 ASSIMP_BIN,标准目录不需要', () => { expect(runLib(`opendance_assimp_needs_env_path ${join(workRoot, 'bin', 'assimp')} && echo YES`).stdout).toContain('YES'); expect(runLib('opendance_assimp_needs_env_path /usr/bin/assimp || echo NO').stdout).toContain('NO'); }); });