Files
opendance-web/web-server/test/assimp-locate.spec.ts
T
opendance da03777995 fix(models): Assimp 缺失时给出可操作报错,部署脚本自动安装
现象:服务器未安装 Assimp,上传 PMX 压缩包只得到
  GLB 直出:导出失败(ENOENT):spawn assimp ENOENT
  glTF 直出:导出失败(ENOENT):spawn assimp ENOENT
用户看不出缺什么、怎么装,也不知道只传 glb/vrm 其实不需要它。

修复:
1. 新增 src/common/assimp.ts:按 ASSIMP_BIN -> PATH -> /usr/bin、/usr/local/bin
   等常见目录定位可执行文件(只认文件,不认同名目录),转换前先预检;
   找不到就一次性给出各发行版安装命令、查过的路径与替代办法,
   不再打两条 ENOENT。动作预处理(FBX/glTF -> BVH)复用同一文案。
2. 新增 deploy/assimp-lib.sh:install.sh 按发行版自动安装
   (apt-get / dnf / yum / zypper / apk / pacman),装在非常规目录时把绝对
   路径写入 deploy/.env 的 ASSIMP_BIN;装不上只告警不中止,可用
   SKIP_ASSIMP_INSTALL=1 跳过。check-access.sh 自检增加 Assimp 一行。
3. /health 增加 assimp.path;管理端「概览 -> 服务健康」显示 Assimp 状态,
   不可用时给出安装提示。
4. 新增 test/assimp-locate.spec.ts 13 项;强化「assimp 不存在」用例断言安装命令。
   测试规模 12 文件 / 193 项。
5. 版本 0.2.2,README(.md/.txt)/DSH.md 同步(前置要求、FAQ、环境变量说明)。
2026-09-16 02:55:32 +08:00

201 lines
7.9 KiB
TypeScript
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 { 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 libassimp6');
});
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 libassimp6');
});
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');
});
});