import { spawnSync } from 'node:child_process'; import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; /** * deploy/init-env.sh —— Web 端首次启动的 .env 引导流程。 * * 这些用例全部在临时目录里驱动真实脚本: * * 非交互场景必须“要么 0、要么非 0”,绝不允许挂住(spawnSync 带 timeout, * 一旦超时 r.error 会被置为 ETIMEDOUT,用例据此断言)。 * * 不访问真实数据库:统一注入 OPENDANCE_SKIP_DB_CHECK=1。 */ function findScript(): string { const candidates = [ resolve(process.cwd(), '..', 'deploy', 'init-env.sh'), // 从 web-server/ 运行(vitest 默认) resolve(process.cwd(), 'deploy', 'init-env.sh'), // 从仓库根运行 resolve(process.cwd(), '..', '..', 'deploy', 'init-env.sh'), ]; for (const candidate of candidates) { if (existsSync(candidate)) return candidate; } throw new Error(`找不到 deploy/init-env.sh,候选路径:\n${candidates.join('\n')}`); } const SCRIPT = findScript(); /** 测试模板:含注释与“其它字段”,用于验证写回时不丢注释。 */ const EXAMPLE = `# ============================================================ # 测试用模板:这些注释必须被保留 # ============================================================ NODE_ENV=production PORT=3000 # 管理端端口注释 ADMIN_PORT=3001 HOST=0.0.0.0 # 数据库 DB_HOST= DB_PORT=3306 DB_USER=opendance_app DB_PASS=<部署方提供> DB_NAME=opendance DB_SYNCHRONIZE=false UPLOAD_MAX_BYTES=536870912 `; /** 一份“齐备”的 .env 内容。 */ const COMPLETE_ENV = [ 'DB_HOST=10.0.0.5', 'DB_PORT=3306', 'DB_USER=u1', 'DB_PASS=p1', 'DB_NAME=d1', 'PORT=3000', 'ADMIN_PORT=3001', 'HOST=0.0.0.0', ].join('\n') + '\n'; const tempDirs: string[] = []; function makeDir(withExample = true): string { const dir = mkdtempSync(join(tmpdir(), 'opendance-init-env-')); tempDirs.push(dir); if (withExample) writeFileSync(join(dir, '.env.example'), EXAMPLE, 'utf8'); return dir; } function envPath(dir: string): string { return join(dir, '.env'); } interface RunResult { status: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string; error?: Error; } function run( args: string[], opts: { input?: string; env?: Record; timeoutMs?: number } = {}, ): RunResult { const result = spawnSync('bash', [SCRIPT, ...args], { input: opts.input ?? '', encoding: 'utf8', timeout: opts.timeoutMs ?? 8000, env: { ...process.env, OPENDANCE_SKIP_DB_CHECK: '1', ...opts.env }, }); return { status: result.status, signal: result.signal, stdout: result.stdout ?? '', stderr: result.stderr ?? '', error: result.error, }; } function outputOf(result: RunResult): string { return `${result.stdout}${result.stderr}`; } afterEach(() => { while (tempDirs.length > 0) { const dir = tempDirs.pop(); if (dir) rmSync(dir, { recursive: true, force: true }); } }); describe('init-env.sh:非交互模式绝不阻塞', () => { it( '缺必填 → 非 0 退出并列出字段名,且不阻塞', () => { const dir = makeDir(); // 人为缺 DB_USER / DB_PASS writeFileSync(envPath(dir), 'DB_HOST=1.2.3.4\nDB_PORT=3306\nDB_NAME=opendance\n'); const result = run(['--non-interactive', '--dir', dir], { timeoutMs: 5000 }); expect(result.error).toBeUndefined(); // 超时被杀则 error=ETIMEDOUT expect(result.status).not.toBe(0); const out = outputOf(result); expect(out).toContain('DB_USER'); expect(out).toContain('DB_PASS'); // 不修改文件 expect(readFileSync(envPath(dir), 'utf8')).toBe( 'DB_HOST=1.2.3.4\nDB_PORT=3306\nDB_NAME=opendance\n', ); }, 15000, ); it( 'OPENDANCE_NONINTERACTIVE=1 与 --yes 同样跳过提问', () => { const dir = makeDir(); writeFileSync(envPath(dir), 'DB_HOST=h\nDB_NAME=n\n'); // 缺 DB_USER / DB_PASS const byEnv = run(['--dir', dir], { input: 'should-not-be-read\n', env: { OPENDANCE_NONINTERACTIVE: '1' }, timeoutMs: 5000, }); expect(byEnv.error).toBeUndefined(); expect(byEnv.status).not.toBe(0); expect(outputOf(byEnv)).not.toContain('请输入'); const byFlag = run(['--yes', '--dir', dir], { timeoutMs: 5000 }); expect(byFlag.error).toBeUndefined(); expect(byFlag.status).not.toBe(0); expect(outputOf(byFlag)).not.toContain('请输入'); }, 15000, ); it( '全部齐备 → 0 退出且完全不修改文件', () => { const dir = makeDir(); writeFileSync(envPath(dir), COMPLETE_ENV); const before = readFileSync(envPath(dir), 'utf8'); const modeBefore = statSync(envPath(dir)).mode; const result = run(['--non-interactive', '--dir', dir]); expect(result.status).toBe(0); expect(result.error).toBeUndefined(); expect(readFileSync(envPath(dir), 'utf8')).toBe(before); expect(statSync(envPath(dir)).mode).toBe(modeBefore); }, 15000, ); it( '.env 不存在 → 从 .env.example 建立基础,但仍因占位符未填而报错', () => { const dir = makeDir(); const result = run(['--non-interactive', '--dir', dir]); expect(result.status).not.toBe(0); const out = outputOf(result); expect(out).toContain('已从模板创建'); expect(out).toContain('DB_HOST'); // expect(out).toContain('DB_PASS'); // <部署方提供> // 文件确实被创建(作为用户后续填写的基础) expect(existsSync(envPath(dir))).toBe(true); }, 15000, ); }); describe('init-env.sh:交互式引导(printf ... | bash)', () => { it( '逐项回答后写回正确值,注释保留,ADMIN_PORT 默认 = PORT + 1', () => { const dir = makeDir(); // 顺序:DB_HOST DB_PORT DB_USER DB_PASS DB_NAME PORT ADMIN_PORT HOST const input = ['db.example.com', '3307', 'dance_user', 'p@ss*word%', 'dance_db', '4100', '', ''].join( '\n', ) + '\n'; const result = run(['--dir', dir], { input, timeoutMs: 10000 }); expect(result.error).toBeUndefined(); expect(result.status).toBe(0); const content = readFileSync(envPath(dir), 'utf8'); expect(content).toMatch(/^DB_HOST=db\.example\.com$/m); expect(content).toMatch(/^DB_PORT=3307$/m); expect(content).toMatch(/^DB_USER=dance_user$/m); expect(content).toMatch(/^DB_PASS=p@ss\*word%$/m); expect(content).toMatch(/^DB_NAME=dance_db$/m); expect(content).toMatch(/^PORT=4100$/m); expect(content).toMatch(/^ADMIN_PORT=4101$/m); // 默认 = PORT + 1 expect(content).toMatch(/^HOST=0\.0\.0\.0$/m); // 原有注释与其它字段必须保留 expect(content).toContain('# 测试用模板:这些注释必须被保留'); expect(content).toContain('# 管理端端口注释'); expect(content).toContain('NODE_ENV=production'); expect(content).toContain('DB_SYNCHRONIZE=false'); expect(content).toContain('UPLOAD_MAX_BYTES=536870912'); // 口令落盘为 600 expect(statSync(envPath(dir)).mode & 0o777).toBe(0o600); }, 15000, ); it( '交互输入中途 EOF → 立即非 0 退出,不挂住', () => { const dir = makeDir(); const result = run(['--dir', dir], { input: 'only-a-host\n', timeoutMs: 5000 }); expect(result.error).toBeUndefined(); // 未被 timeout 杀掉 expect(result.status).not.toBe(0); expect(outputOf(result)).toContain('stdin'); }, 15000, ); it( '重复运行(--force + 同样输入)结果幂等', () => { const dir = makeDir(); const input = ['h1', '3306', 'u1', 'pw1', 'd1', '3000', '3001', '0.0.0.0'].join('\n') + '\n'; expect(run(['--force', '--dir', dir], { input, timeoutMs: 10000 }).status).toBe(0); const first = readFileSync(envPath(dir), 'utf8'); expect(run(['--force', '--dir', dir], { input, timeoutMs: 10000 }).status).toBe(0); const second = readFileSync(envPath(dir), 'utf8'); expect(second).toBe(first); }, 20000, ); }); describe('init-env.sh:字段校验', () => { it.each([ ['DB_PORT', '70000'], ['PORT', 'abc'], ['ADMIN_PORT', '70000'], ])( '端口非法 %s=%s → 非 0 退出并报出字段名', (key, bad) => { const dir = makeDir(); const lines = COMPLETE_ENV.trimEnd() .split('\n') .filter((line) => !line.startsWith(`${key}=`)); lines.push(`${key}=${bad}`); writeFileSync(envPath(dir), lines.join('\n') + '\n'); const result = run(['--non-interactive', '--dir', dir], { timeoutMs: 5000 }); expect(result.error).toBeUndefined(); expect(result.status).not.toBe(0); expect(outputOf(result)).toContain(key); }, 15000, ); it( '交互下端口非法会重新询问,最终接受合法值', () => { const dir = makeDir(); // DB_PORT 连续给两个非法值,脚本应重新询问 const input = ['h', 'abc', '70000', '3306', 'u', 'p', 'd', '', '', ''].join('\n') + '\n'; const result = run(['--dir', dir], { input, timeoutMs: 10000 }); expect(result.error).toBeUndefined(); expect(result.status).toBe(0); const content = readFileSync(envPath(dir), 'utf8'); expect(content).toMatch(/^DB_PORT=3306$/m); expect(outputOf(result)).toContain('必须'); }, 15000, ); it.each([['6*yGoR9lHz510nl*'], ['a%b*c-d_e.f']])( '口令含特殊字符 %s:不加引号写盘且 set -a; . .env 能读回同一个值', (password) => { const dir = makeDir(); const input = ['h', '3306', 'u', password, 'd', '', '', ''].join('\n') + '\n'; const result = run(['--dir', dir], { input, timeoutMs: 10000 }); expect(result.error).toBeUndefined(); expect(result.status).toBe(0); const content = readFileSync(envPath(dir), 'utf8'); expect(content).toContain(`DB_PASS=${password}`); // 不得给值加引号(否则 shell 语义可能变化) expect(content).not.toMatch(/^DB_PASS=["']/m); // 真实地用 shell source 一次,确认读回同一个值 const sourced = spawnSync( 'bash', ['-c', 'set -a; . "$1"; printf "%s" "$DB_PASS"', '_', envPath(dir)], { encoding: 'utf8' }, ); expect(sourced.status).toBe(0); expect(sourced.stdout).toBe(password); }, 15000, ); }); describe('init-env.sh:--force / --check / 幂等', () => { it( '--force 在已有 .env 时重新生成,并提示 ADMIN_PORT === PORT 的单端口模式', () => { const dir = makeDir(); writeFileSync(envPath(dir), COMPLETE_ENV); // 旧配置 const input = ['newhost', '3406', 'newuser', 'newpass', 'newdb', '5100', '5100', '127.0.0.1'].join('\n') + '\n'; const result = run(['--force', '--dir', dir], { input, timeoutMs: 10000 }); expect(result.error).toBeUndefined(); expect(result.status).toBe(0); const content = readFileSync(envPath(dir), 'utf8'); expect(content).toMatch(/^DB_HOST=newhost$/m); expect(content).toMatch(/^DB_PORT=3406$/m); expect(content).toMatch(/^DB_USER=newuser$/m); expect(content).toMatch(/^DB_PASS=newpass$/m); expect(content).toMatch(/^DB_NAME=newdb$/m); expect(content).toMatch(/^PORT=5100$/m); expect(content).toMatch(/^ADMIN_PORT=5100$/m); expect(content).toMatch(/^HOST=127\.0\.0\.1$/m); expect(outputOf(result)).toContain('单端口'); }, 15000, ); it( '已有完整 .env 且未加 --force:跳过询问、不修改文件(幂等)', () => { const dir = makeDir(); writeFileSync(envPath(dir), COMPLETE_ENV); const before = readFileSync(envPath(dir), 'utf8'); // 即使管道里塞了数据也应当忽略(因为完整且未 --force) const result = run(['--dir', dir], { input: '\n\n\n\n\n\n\n\n', timeoutMs: 8000 }); expect(result.error).toBeUndefined(); expect(result.status).toBe(0); expect(outputOf(result)).toContain('跳过询问'); expect(readFileSync(envPath(dir), 'utf8')).toBe(before); }, 15000, ); it( '--check:合法则 0 且不改文件;缺字段则非 0', () => { const okDir = makeDir(); writeFileSync(envPath(okDir), COMPLETE_ENV); const before = readFileSync(envPath(okDir), 'utf8'); const okResult = run(['--check', '--dir', okDir], { timeoutMs: 5000 }); expect(okResult.error).toBeUndefined(); expect(okResult.status).toBe(0); expect(readFileSync(envPath(okDir), 'utf8')).toBe(before); const badDir = makeDir(); writeFileSync(envPath(badDir), 'DB_HOST=h\n'); const badResult = run(['--check', '--dir', badDir], { timeoutMs: 5000 }); expect(badResult.error).toBeUndefined(); expect(badResult.status).not.toBe(0); expect(outputOf(badResult)).toContain('DB_USER'); expect(outputOf(badResult)).toContain('DB_PASS'); }, 15000, ); });