test.mjs 5.94 KB
#!/usr/bin/env node
// scripts/test.mjs —— 合并到默认分支(main / master)前的测试闸门。
// 顺序:detect → setup-db → build → lint → unit+integration → e2e
// (不在尾部 reset:下次跑的 setup-db 会 DROP+CREATE,重复清库无意义)
// 由 coding.mjs 的 test-gate stage(通过子会话)调用。
//
// 跨平台:所有命令经 child_process.spawnSync(cmd, { shell:true }) 执行,
// 在 Windows 走 cmd.exe,在 *nix 走 /bin/sh,无需 WSL / Git-Bash。
// 命令字符串来自 docs/04 §零(构建/lint/单测/e2e)——由 skeleton-gen 在 Plan 期填充。

import { spawnSync } from 'node:child_process'
import { existsSync, readdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'

const PROJECT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')

// 在指定子目录下跑一条 shell 命令;非零退出码即终止整个闸门并透传该码。
function run(label, command, cwd = PROJECT_ROOT) {
  console.log(`[test.mjs] ${label}: ${command}`)
  const res = spawnSync(command, { cwd, shell: true, stdio: 'inherit' })
  if (res.error) {
    console.error(`[test.mjs] FATAL: 无法执行 (${label}): ${res.error.message}`)
    process.exit(1)
  }
  if (res.status !== 0) {
    console.error(`[test.mjs] FAIL (${label}) exit=${res.status}`)
    process.exit(res.status === null ? 1 : res.status)
  }
}

// ── JDK 固定(后端)─────────────────────────────────────────────────
// 本项目锁定 Java 17(docs/04 §零)。但开发机默认 JDK 可能更新(如 JDK 25),
// 而 Maven Surefire fork 出的测试 JVM 会沿用默认 JDK:Mockito 自带的 Byte Buddy
// 不支持过新的 class file 版本(JDK 25 = 69,Byte Buddy 仅到 Java 22 = 66),
// 导致 `Mockito cannot mock class ...` 在 setUp 阶段整片报 error。
// 这里在跑后端 Maven 前,把本进程(及其 spawnSync 子进程)的 JAVA_HOME 固定到
// 一个 Java 17 运行时;不改写全局/用户 profile,仅影响本次测试闸进程树。
function javaMajor(javaHome) {
  if (!javaHome) return null
  const bin = join(javaHome, 'bin', process.platform === 'win32' ? 'java.exe' : 'java')
  if (!existsSync(bin)) return null
  const res = spawnSync(bin, ['-version'], { encoding: 'utf8' })
  const out = `${res.stdout || ''}${res.stderr || ''}`
  const m = out.match(/version "(\d+)/)        // "17.0.19" → 17;"25.0.2" → 25
  return m ? Number(m[1]) : null
}

function resolveJava17Home() {
  // 1) 显式覆盖:JAVA17_HOME
  if (javaMajor(process.env.JAVA17_HOME) === 17) return process.env.JAVA17_HOME
  // 2) 现有 JAVA_HOME 恰好已是 17
  if (javaMajor(process.env.JAVA_HOME) === 17) return process.env.JAVA_HOME
  // 3) macOS:/usr/libexec/java_home -v 17
  if (process.platform === 'darwin') {
    const r = spawnSync('/usr/libexec/java_home', ['-v', '17'], { encoding: 'utf8' })
    if (r.status === 0) { const h = (r.stdout || '').trim(); if (javaMajor(h) === 17) return h }
  }
  // 4) 常见 Linux 安装位置(best-effort)
  for (const base of ['/usr/lib/jvm', '/opt/java', '/opt']) {
    if (!existsSync(base)) continue
    try {
      for (const name of readdirSync(base)) {
        if (!/17/.test(name)) continue
        const h = join(base, name)
        if (javaMajor(h) === 17) return h
      }
    } catch { /* 忽略不可读目录 */ }
  }
  return null
}

// 仅在默认 JDK 不是 17 时才介入;找不到 17 则告警并沿用默认(不擅自失败)。
function ensureJava17() {
  if (javaMajor(process.env.JAVA_HOME) === 17) {
    console.log(`[test.mjs] JAVA_HOME 已是 Java 17${process.env.JAVA_HOME}`)
    return
  }
  const home = resolveJava17Home()
  if (!home) {
    console.warn('[test.mjs] WARN: 未找到 Java 17(JAVA17_HOME / JAVA_HOME / java_home -v 17 / 常见路径均未命中);'
      + '将以默认 JDK 跑后端测试。若默认 JDK 过新,Mockito/Byte Buddy 可能在 mock 时整片报 error。')
    return
  }
  process.env.JAVA_HOME = home
  const sep = process.platform === 'win32' ? ';' : ':'
  process.env.PATH = `${join(home, 'bin')}${sep}${process.env.PATH || ''}`
  console.log(`[test.mjs] 固定 JAVA_HOME=Java 17  ${home}`)
}

// Stack detection (runtime, mode-agnostic)
const hasBackend = existsSync(join(PROJECT_ROOT, 'backend'))
const hasFrontend = existsSync(join(PROJECT_ROOT, 'frontend'))
if (!hasBackend && !hasFrontend) {
  console.error('[test.mjs] FATAL: neither backend/ nor frontend/ exists')
  process.exit(1)
}

const backendDir = join(PROJECT_ROOT, 'backend')
const frontendDir = join(PROJECT_ROOT, 'frontend')

// 后端存在时,先把测试用 JDK 固定到 Java 17(见 ensureJava17 注释)。
if (hasBackend) ensureJava17()

console.log('[test.mjs] 1/5 setup test db')
run('setup-test-db', `node ${JSON.stringify(join('scripts', 'setup-test-db.mjs'))}`)

console.log('[test.mjs] 2/5 build')
if (hasBackend) run('backend build', 'mvn -q -B -DskipTests package', backendDir)
else console.log('[test.mjs] skip backend build')
if (hasFrontend) run('frontend build', 'npm run build', frontendDir)
else console.log('[test.mjs] skip frontend build')

console.log('[test.mjs] 3/5 lint')
if (hasBackend) run('backend lint', 'mvn -q -B checkstyle:check', backendDir)
else console.log('[test.mjs] skip backend lint')
if (hasFrontend) run('frontend lint', 'npm run lint', frontendDir)
else console.log('[test.mjs] skip frontend lint')

console.log('[test.mjs] 4/5 unit + integration')
if (hasBackend) run('backend test', 'mvn -q -B test', backendDir)
else console.log('[test.mjs] skip backend test')
if (hasFrontend) run('frontend test', 'npm run test:unit', frontendDir)
else console.log('[test.mjs] skip frontend test')

console.log('[test.mjs] 5/5 E2E')
run('e2e', 'echo "[test.mjs] e2e 略(后端无 e2e;前端 e2e: npm run test:e2e,见 docs/04 §零,前端阶段单独执行)"')

console.log('[test.mjs] GREEN')