Files
zijunchiang 06b476c4e2 test(accept): 产物级端到端验收自动化 + 实体注册核对落到 dist(v0.7.4 实测全通过)
背景:`npm run verify` 跑的是源码与刚构建的 dist,而交付给部署方的是
`dist-release/*.tar.gz`;两者之间隔着「解包 → npm install --omit=dev → 迁移 →
起进程」四步。本项目三次生产事故(v0.7.0/v0.7.1 起不来、v0.7.3 场景上传 400、
v0.7.4 `No metadata for "Scene"`)全都只在产物真正跑起来时才暴露。

一、`scripts/check-bootstrap.mjs` 新增「产物级实体注册核对」
   直接读包内 `dist/entities/`:逐个 import 每个 *.entity.js(@Entity() 装饰器
   在模块加载时写 metadata),要求
     * 每个实体文件里被 @Entity() 装饰的类都在 ENTITIES 里;
     * ENTITIES 里不允许有未装饰的成员;不允许重复;
     * Scene 必须注册(点名)。
   为什么必须单独有这一条:漏注册实体**不影响启动**(TypeORM 只按 ENTITIES 注册,
   DI 图照样解析、/health 正常、migrationsPending 0),只在第一次查该实体时抛
   `No metadata`,导致 /api/scenes、/api/catalog/export、/api/catalog/version、
   /api/reviews?status=pending 全部 500 —— 客户端一个目录都拉不到。
   原来的启动冒烟与 entities-registry.spec.ts(查 src/)都抓不到它。

   反向验证(在真实 v0.7.4 发布包里复现生产缺陷):把包内
   dist/entities/index.js 的 ENTITIES 去掉 Scene 后,核对立即失败并给出
   「scene.entity.js 里的 Scene 没有注册进 ENTITIES」与「ENTITIES 里没有 Scene」;
   还原后通过。

二、`scripts/accept-release.sh`(新增):一个 tar.gz 的产物级端到端验收
   解包 → 装生产依赖 → 跑迁移 → 起服务 → 校验 /health 关键字段
   (版本 / 数据库 / migrationsPending / scenes.available)→ 产物级实体注册核对
   (用仓库当前的 checker 覆盖包内旧版本,验收标准以最新为准)→ 跑发布包自带的
   test/smoke.mjs → 停服务。只需要一个现成的 MySQL/MariaDB,不装数据库
   (与项目「不部署 MySQL」的边界一致)。

   实测(opendance-web-v0.7.4.tar.gz + 真实 MySQL 9.7.2,全新库从零迁移):
     ✓ /health version=0.7.4 与 package.json 一致
     ✓ 数据库已连接(opendance_accept,pending=0)
     ✓ scenes.available=true(生产 v0.7.3 是 false + No metadata)
     ✓ 产物级实体注册核对通过:6 个实体文件全部注册进 ENTITIES(含 Scene)
     == 结果:80 通过 / 0 失败 ==
   冒烟 [7] 段即 ④ 验收:POST /api/scenes 201、一律 pending、GLB 直传不重编码、
   glbUrl 生成、重力/风/摩擦/坡度/出生点/相机模式按配置原样入库、越界夹紧与非法
   枚举回退、审核通过后 catalog/export 的 scenes[] 带完整物理参数与 GLB/缩略图字段、
   目录指纹纳入场景变更、详情、状态过滤、DELETE 204。

   `npm run verify` 全绿:契约 6 项 / 35 路由 / 版本一致 / 295 项测试 / build /
   产物级注册核对 / 启动冒烟。
2026-09-17 04:44:30 +08:00

291 lines
11 KiB
JavaScript
Raw Permalink 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.
#!/usr/bin/env node
/**
* 启动冒烟检查:**编译产物能不能真正 boot 起来**(不需要数据库)。
*
* 为什么必须有这一层:`src/catalog/catalog.module.ts` 曾经漏了 `ScenesModule`,
* 而 `CatalogService` 注入了 `ScenesService` —— Nest 的 DI 是**按模块**解析的,
* 于是 `node dist/main.js` 在 bootstrap 阶段直接抛 `UnknownDependenciesException`,
* **服务根本起不来**。但当时所有检查都是绿的:
* - `nest build` 通过(类型没问题);
* - 258 项单元测试通过(它们直接 new 服务 / 测纯函数,从不编译 AppModule);
* - 打包脚本只校验文件清单与压缩包完整性。
* 结果就是 v0.7.0 / v0.7.1 的产物「装得上、起不来」。
*
* 本脚本以**正式部署的方式**(systemd 的 `ExecStart=node dist/main.js`,
* 不带任何 npm 环境变量)拉起真实产物,然后:
* 1. 断言日志里**没有**依赖解析类错误(UnknownDependenciesException 等)→ 模块图可解析;
* 2. 断言日志里的「应用版本 vX.Y.Z」等于 `package.json` 的 version
* → 原生部署下版本号解析正确(原先会退化成硬编码的 0.1.0);
* 3. 断言启动确实**推进到了数据库连接阶段** → 证明 Nest 已把整个 DI 图解析完,
* 而不是中途失败。
*
* 不需要数据库:TypeORM 连不上时日志里会出现明确的连接错误,那就够了 ——
* 本脚本的职责是「启动能否走到连库这一步」,真连库的端到端验收由
* `test/smoke.mjs`(部署好服务端后运行)负责。
*
* 用法:node scripts/check-bootstrap.mjs
*/
import { spawn } from 'node:child_process';
import { mkdtempSync, readFileSync, readdirSync } from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const serverDir = join(here, '..');
const entry = join(serverDir, 'dist', 'main.js');
const EXPECTED_VERSION = JSON.parse(
readFileSync(join(serverDir, 'package.json'), 'utf8'),
).version;
/** 依赖解析失败的标志(任一出现即失败) */
const DI_ERROR_PATTERNS = [
/UnknownDependenciesException/,
/Nest can't resolve dependencies/i,
/cannot resolve dependencies/i,
/Nest can't resolve/i,
];
/** 走到数据库连接阶段的标志 */
const DB_STAGE_PATTERNS = [
/Unable to connect to the database/i,
/ECONNREFUSED/,
/getaddrinfo/i,
/Access denied for user/i,
/Unknown database/i,
/服务已?启动/,
/已启动,监听/,
];
const TIMEOUT_MS = Number(process.env.BOOTSTRAP_TIMEOUT_MS ?? 40_000);
/* ---------------------------------------------- 产物级实体注册核对 */
/**
* 就地核对**编译产物**里的实体注册,而不是源码。
*
* 为什么必须有这一层:生产事故「`/health` 报 `No metadata for "Scene" was found.`」
* 的根因是 `src/entities/index.ts` 的 `ENTITIES` 漏了 `Scene`。这类缺陷有三个特点:
* 1. **启动完全正常** —— TypeORM 只按 `ENTITIES` 注册 metadata,漏注册不影响 DI 图;
* 2. **`tsc` 与单元测试全绿** —— 类型没问题,`entities-registry.spec.ts` 只查源码;
* 3. 只有**第一次查该实体**时才抛(`/api/scenes`、`/api/catalog/export`、
* `/api/reviews?status=pending` 全部 500,客户端一个目录都拉不到)。
* 也就是说:源码层的校验挡不住「产物里的 ENTITIES 与 dist 里的实体文件不一致」
* (例如 `nest build` 没重新编译、打包时带了旧的 dist)。
*
* 因此按「校验必须落在产物上」的规则,这里直接读 `dist/entities/`:
* * 每个 `*.entity.js` 里被 `@Entity()` 装饰的类都必须在 `ENTITIES` 里;
* * `ENTITIES` 里不允许出现没有 `@Entity()` 装饰的成员(防复制粘贴写错);
* * `ENTITIES` 不允许重复;
* * `Scene` 必须注册(点名,一眼看出是不是又漏了场景)。
*/
function checkEntityRegistry() {
const requireFromServer = createRequire(join(serverDir, 'package.json'));
const errors = [];
let storage;
try {
requireFromServer('reflect-metadata');
storage = requireFromServer('typeorm').getMetadataArgsStorage();
} catch (error) {
return {
errors: [
`无法加载 typeorm(${error instanceof Error ? error.message : String(error)})——` +
'请先在 web-server 目录执行 npm install',
],
};
}
const entitiesDir = join(serverDir, 'dist', 'entities');
let files;
try {
files = readdirSync(entitiesDir).filter((name) => name.endsWith('.entity.js'));
} catch (error) {
return { errors: [`无法读取 ${entitiesDir}:${error.message}(是否忘了 npm run build?)`] };
}
if (files.length === 0) {
return { errors: [`${entitiesDir} 下没有任何 *.entity.js`] };
}
// 逐个 import:@Entity() 装饰器在模块加载时写入 metadata storage
const fileOfClass = new Map();
for (const file of files) {
const module_ = requireFromServer(join(entitiesDir, file));
let decorated = null;
for (const value of Object.values(module_)) {
if (typeof value === 'function' && storage.tables.some((t) => t.target === value)) {
decorated = value;
break;
}
}
if (!decorated) {
errors.push(`${file} 里没有找到被 @Entity() 装饰的类`);
continue;
}
fileOfClass.set(decorated, file);
}
let entities;
try {
entities = requireFromServer(join(entitiesDir, 'index.js')).ENTITIES;
} catch (error) {
return { errors: [`无法加载 dist/entities/index.js:${error.message}`] };
}
if (!Array.isArray(entities)) {
return { errors: ['dist/entities/index.js 没有导出数组形式的 ENTITIES'] };
}
const registered = new Set(entities);
for (const [cls, file] of fileOfClass) {
if (!registered.has(cls)) {
errors.push(
`${file} 里的 ${cls.name} 没有注册进 ENTITIES —— ` +
'查询该实体会抛 `No metadata for "..." was found.`(接口直接 500)',
);
}
}
for (const cls of entities) {
if (!fileOfClass.has(cls)) {
errors.push(`ENTITIES 里的 ${cls?.name ?? String(cls)} 不是 dist/entities/ 下任何 @Entity() 类`);
}
}
if (registered.size !== entities.length) {
errors.push(`ENTITIES 有重复项(${entities.length} 项,去重后 ${registered.size} 项)`);
}
if (!entities.some((cls) => cls?.name === 'Scene')) {
errors.push('ENTITIES 里没有 Scene —— 场景管理相关接口会全部 500(生产事故根因)');
}
return { errors, count: entities.length, fileCount: files.length };
}
function freePort(base) {
// 用一个不常见的端口即可;连库失败是预期的,端口只要不撞上真实服务
return base + Math.floor(Math.random() * 200);
}
async function main() {
// ---------- 第一步:产物级实体注册核对(不需要进程与数据库)----------
const registry = checkEntityRegistry();
if (registry.errors.length > 0) {
console.error('✗ 产物级实体注册核对失败(dist/entities 与 ENTITIES 不一致):');
for (const message of registry.errors) console.error(` - ${message}`);
console.error(' 说明:TypeORM 只按 ENTITIES 注册 metadata,漏注册不影响启动,');
console.error(' 但第一次查该实体会抛 No metadata,相关接口全部 500。');
process.exit(1);
}
console.log(
`✓ 产物级实体注册核对通过:dist/entities/ 下 ${registry.fileCount} 个实体文件` +
`全部注册进 ENTITIES(共 ${registry.count} 项,含 Scene)`,
);
const scratch = mkdtempSync(join(tmpdir(), 'opendance-boot-'));
// 模拟 systemd:删掉 npm_* 环境变量(ExecStart=node dist/main.js 时它们不存在)
const env = { ...process.env };
for (const key of Object.keys(env)) {
if (key.startsWith('npm_')) delete env[key];
}
delete env.APP_VERSION; // 也不允许用显式覆盖,必须走 package.json
Object.assign(env, {
NODE_ENV: 'development', // 免得 validateEnv 在生产模式直接 exit(1)
HOST: '127.0.0.1',
PORT: String(freePort(34000)),
ADMIN_PORT: String(freePort(35000)),
DB_HOST: '127.0.0.1',
DB_PORT: '3399', // 故意指向一个没有 MySQL 的端口
DB_USER: 'opendance_probe',
DB_PASSWORD: 'opendance_probe',
DB_NAME: 'opendance_probe',
DB_LOGGING: 'false',
STORAGE_DIR: join(scratch, 'storage'),
ADMIN_DIR: join(scratch, 'admin'),
});
const child = spawn(process.execPath, [entry], {
cwd: serverDir,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
let finished = false;
const decide = () => {
if (finished) return null;
const diError = DI_ERROR_PATTERNS.find((re) => re.test(output));
if (diError) {
finished = true;
return { ok: false, reason: 'di', pattern: String(diError) };
}
const versionLine = output.match(/应用版本 v(\S+)/);
const dbStage = DB_STAGE_PATTERNS.some((re) => re.test(output));
if (versionLine && dbStage) {
finished = true;
return { ok: true, version: versionLine[1] };
}
return null;
};
const result = await new Promise((resolvePromise) => {
const timer = setTimeout(() => {
resolvePromise(decide() ?? { ok: false, reason: 'timeout' });
}, TIMEOUT_MS);
const onData = (chunk) => {
output += chunk.toString();
const decided = decide();
if (decided) {
clearTimeout(timer);
child.kill('SIGKILL');
resolvePromise(decided);
}
};
child.stdout.on('data', onData);
child.stderr.on('data', onData);
child.on('exit', () => {
clearTimeout(timer);
resolvePromise(decide() ?? { ok: false, reason: 'exit' });
});
child.on('error', (error) => {
clearTimeout(timer);
resolvePromise({ ok: false, reason: 'spawn', error: error.message });
});
});
if (result.ok) {
if (result.version !== EXPECTED_VERSION) {
console.error(
`✗ 启动日志里的应用版本 v${result.version} 与 web-server/package.json 的 ${EXPECTED_VERSION} 不一致`,
);
console.error(' (原生部署下版本号必须从 package.json 解析,不能依赖 npm 环境变量)');
process.exit(1);
}
console.log(
`✓ 启动冒烟通过:模块依赖图可解析(无 UnknownDependenciesException)、` +
`应用版本 v${result.version} 与 package.json 一致、已推进到数据库连接阶段`,
);
return;
}
console.error('✗ 启动冒烟失败:');
if (result.reason === 'di') {
console.error(` 依赖解析错误(匹配 ${result.pattern})—— 有模块忘记 import 提供者了。`);
console.error(' 提示:检查各 @Module 的 imports 是否覆盖了构造函数里注入的服务。');
} else if (result.reason === 'timeout') {
console.error(' 进程既没有报依赖错误,也没有推进到数据库连接阶段(超时)。');
} else if (result.reason === 'exit') {
console.error(' 进程提前退出,且日志里既没有依赖错误也没有数据库阶段标志。');
} else if (result.reason === 'spawn') {
console.error(` 无法启动 ${entry}:${result.error}`);
}
console.error('\n---- 进程输出(末尾 40 行)----');
console.error(output.split('\n').slice(-40).join('\n'));
process.exit(1);
}
await main();