import assert from 'node:assert/strict' import { execFileSync } from 'node:child_process' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' // 这些用例只检查"改动 → 要重建哪一端"的判定与部署脚本的关键分支, // 不真正跑 docker;分类逻辑本身放在 deploy/lib/classify-changes.sh 里,方便直接 exec。 const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const REPO_ROOT = path.resolve(BACKEND_ROOT, '../..') const CLASSIFIER = path.join(REPO_ROOT, 'deploy/lib/classify-changes.sh') const DEPLOY_SCRIPT = path.join(REPO_ROOT, 'deploy/ubuntu-deploy.sh') const hasDeployScripts = fs.existsSync(CLASSIFIER) && fs.existsSync(DEPLOY_SCRIPT) function classify(files: string[]): string { return execFileSync('bash', [CLASSIFIER], { input: files.join('\n'), encoding: 'utf8', }).trim() } function withTempGitRepo(fn: (repo: string, git: (...args: string[]) => string) => void): void { const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'deploy-classify-')) const git = (...args: string[]) => execFileSync('git', ['-C', repo, '-c', 'user.email=t@t', '-c', 'user.name=t', ...args], { encoding: 'utf8', }) try { git('init', '-q') git('commit', '-q', '--allow-empty', '-m', 'base') fn(repo, git) } finally { fs.rmSync(repo, { recursive: true, force: true }) } } test( '改动分类:只改后端源码只重建 backend,只改前端源码只重建 web', { skip: !hasDeployScripts }, () => { assert.equal(classify(['apps/backend/src/services/worker-platform/x.ts']), 'backend') assert.equal( classify(['apps/backend/src/db/migrations/092_x.sql', 'apps/backend/src/index.ts']), 'backend', ) assert.equal(classify(['apps/frontend/src/pages/admin/x.tsx']), 'web') }, ) test('改动分类:进得了镜像的部署文件要带上对应一端', { skip: !hasDeployScripts }, () => { assert.equal(classify(['deploy/docker/backend.Dockerfile']), 'backend') assert.equal(classify(['deploy/docker/frontend.Dockerfile']), 'web') // Caddyfile 会被 COPY 进 web 镜像 assert.equal(classify(['deploy/caddy/Caddyfile']), 'web') }) test('改动分类:进不了镜像的改动不触发重建', { skip: !hasDeployScripts }, () => { assert.equal(classify([]), '') assert.equal(classify(['README.md', 'docs/部署启动与数据库重建.md']), '') assert.equal(classify(['deploy/ubuntu-deploy.sh', 'deploy/lib/classify-changes.sh']), '') assert.equal(classify(['deploy/docker/backend-dev.Dockerfile']), '') assert.equal(classify(['scripts/install-git-hooks.sh', '.gitignore']), '') // 测试文件不进 dist、生产也不跑 npm test,只改测试不该重建镜像 assert.equal(classify(['apps/backend/src/services/x.test.ts']), '') assert.equal(classify(['apps/frontend/src/pages/x.test.tsx']), '') }) test('改动分类:拿不准的路径一律两端重建(保守兜底)', { skip: !hasDeployScripts }, () => { assert.equal(classify(['docker-compose.yml']), 'backend web') assert.equal(classify(['apps/backend/src/index.ts', 'apps/frontend/src/main.tsx']), 'backend web') assert.equal(classify(['some/unknown/path.txt']), 'backend web') }) test('部署脚本:按改动重建,不再无脑两端全量构建', { skip: !hasDeployScripts }, () => { const source = fs.readFileSync(DEPLOY_SCRIPT, 'utf8') assert.ok(source.includes('detect_changed_services'), '缺少改动检测') assert.ok(source.includes('deploy/lib/classify-changes.sh'), '缺少改动分类脚本调用') assert.ok(source.includes('两端镜像都不需要重建'), '缺少"无需重建"的短路') assert.ok(source.includes('ensure_service_running'), '缺少未变化一侧的保活逻辑') // 旧的写死双端构建必须消失,否则按需重建会被绕过 assert.ok(!source.includes('log "构建后端镜像"'), '旧的写死后端构建仍在') assert.ok(!source.includes('log "构建 Web 镜像"'), '旧的写死 Web 构建仍在') }) test('改动分类:中文文件名不会被 git 转义成"漏判 → 两端全建"', { skip: !hasDeployScripts }, () => { // 真实踩过的坑:docs/部署说明.md 被 git 输出成 "docs/\351\203\250...", // 匹配不到 docs/* 规则,直接掉进兜底分支把 backend + web 都重建了。 withTempGitRepo((repo, git) => { fs.mkdirSync(path.join(repo, 'docs'), { recursive: true }) fs.writeFileSync(path.join(repo, 'docs', '部署说明.md'), 'v1') git('add', '-A') git('commit', '-q', '-m', 'docs: 中文文件名') const changed = execFileSync('bash', [CLASSIFIER, '--git-diff', 'HEAD~1', 'HEAD', repo], { encoding: 'utf8', }) assert.ok( changed.includes('docs/部署说明.md'), `git 改动列表应保留中文原文,实际输出:${changed.trim()}`, ) assert.equal(classify(changed.split('\n').filter(Boolean)), '') }) }) test('部署脚本:基础镜像只补缺、部署状态在健康检查之后才落盘', { skip: !hasDeployScripts }, () => { const source = fs.readFileSync(DEPLOY_SCRIPT, 'utf8') assert.ok(source.includes('--policy missing'), 'compose pull 应使用 --policy missing') assert.ok(source.includes('write_deploy_state'), '缺少部署状态落盘') assert.ok(source.includes('print_timing_summary'), '缺少阶段耗时输出') assert.ok(source.includes('--git-diff'), '改动列表应通过 classify-changes.sh --git-diff 获取') const healthIndex = source.indexOf('wait_for_health\n') const stateIndex = source.indexOf('write_deploy_state\n') assert.ok(healthIndex > 0 && stateIndex > 0, '找不到健康检查/状态落盘调用') assert.ok( healthIndex < stateIndex, '部署状态必须在健康检查通过后才写入,否则失败的部署会被误判为已上线', ) }) test( '部署脚本:无 migrations 改动时自动跳过备份,但算不出改动范围时仍然备份', { skip: !hasDeployScripts, }, () => { const source = fs.readFileSync(DEPLOY_SCRIPT, 'utf8') assert.ok(source.includes('自动跳过迁移前备份'), '缺少无迁移改动的自动跳过') assert.ok(source.includes('DIFF_KNOWN'), '缺少"改动范围是否已知"的判定') assert.ok(source.includes('migrations_changed'), '缺少 migrations 改动判定') assert.ok(source.includes('--backup 可强制'), '缺少强制备份的提示') // --backend-only/--all 这类参数只能覆盖"重建哪一端",不能顺手把"改了什么"一起跳过: // 否则 DIFF_KNOWN 永远是 false,无 schema 变更也会去跑 pg_dump + 传 OSS(生产 ~12 分钟)。 const computeIndex = source.indexOf('compute_changed_files\n') const detectIndex = source.indexOf('detect_changed_services\n') assert.ok(computeIndex > 0 && detectIndex > 0, '找不到改动计算/重建判定的调用') assert.ok(computeIndex < detectIndex, '必须先算改动范围,再决定重建范围') }, )