scripts-test-template.mjs 6.29 KB
#!/usr/bin/env node
// scripts/test.mjs —— 合并到默认分支(main / master)前的测试闸门。
// 顺序:detect → setup-db(复制源库→副本)→ build → lint → unit+integration → e2e → 绿则晋升新迁移到源库
//        → finally 删副本(无论红绿;源库绝不被 drop)
// 由 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, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'

const PROJECT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const CONFIG_FILE = join(PROJECT_ROOT, 'config-vars.yaml')

// 从 config-vars.yaml 只取 database.schema(源库名)——best-effort,缺文件/缺值返回空串。
function readSourceSchema() {
  if (!existsSync(CONFIG_FILE)) return ''
  let inDatabase = false
  for (const raw of readFileSync(CONFIG_FILE, 'utf8').split('\n')) {
    const line = raw.replace(/\r$/, '')
    if (line.trim() === '' || line.trim()[0] === '#') continue
    const indent = line.length - line.replace(/^\s+/, '').length
    if (indent === 0) { inDatabase = /^database\s*:/.test(line.trim()); continue }
    if (!inDatabase) continue
    const m = line.match(/^\s+schema\s*:\s*(.+?)\s*$/)
    if (m) {
      let v = m[1].trim()
      if (v[0] === '"' || v[0] === "'") { const e = v.indexOf(v[0], 1); if (e !== -1) v = v.slice(1, e) }
      else { const h = v.indexOf(' #'); if (h !== -1) v = v.slice(0, h).trim() }
      return v
    }
  }
  return ''
}

// 测试 DB = 一次性**副本库**(源库永不被本闸门 drop)。副本名经 ERP_TEST_DB_SCHEMA env 贯通到全部子进程——
// setup/drop/promote 脚本直读该 env;gradle test fork 的 JVM 与 e2e globalSetup 起的 bootRun 默认继承 env,
// 由后端 application*.yml 的 ${ERP_TEST_DB_SCHEMA:<source>__test} 占位消费(锁定约定见 docs/04 §一)。
// env 缺省时读 lane 兜底 marker(.tmp/erp-lane-db,并行波次执行器建 lane worktree 后写入)回填 process.env。
if (!process.env.ERP_TEST_DB_SCHEMA) {
  const markerFile = join(PROJECT_ROOT, '.tmp', 'erp-lane-db')
  if (existsSync(markerFile)) {
    const laneSchema = readFileSync(markerFile, 'utf8').split('\n')[0].trim()
    if (laneSchema !== '') {
      process.env.ERP_TEST_DB_SCHEMA = laneSchema
      console.log(`[test.mjs] lane DB marker 生效: ERP_TEST_DB_SCHEMA=${laneSchema}`)
    }
  }
}
// 串行缺省:仍无 env → 显式派生 `<source>__test` 并导出到所有子进程。这一步是安全关键——
// 即便后端 application*.yml 占位缺省万一回退到源库,此处显式 env 也会把它覆写到副本,测试绝不触碰源库。
if (!process.env.ERP_TEST_DB_SCHEMA) {
  const src = readSourceSchema()
  if (src !== '') {
    process.env.ERP_TEST_DB_SCHEMA = `${src}__test`
    console.log(`[test.mjs] 串行缺省副本库: ERP_TEST_DB_SCHEMA=${src}__test`)
  }
}

// 闸门失败类错误:携带退出码,供顶层在跑完 finally(删副本)后透传。
class GateError extends Error {
  constructor(message, code) { super(message); this.code = code }
}

// 在指定子目录下跑一条 shell 命令;非零退出码即抛 GateError(由顶层 finally 兜底删副本后透传码)。
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) throw new GateError(`无法执行 (${label}): ${res.error.message}`, 1)
  if (res.status !== 0) throw new GateError(`FAIL (${label}) exit=${res.status}`, res.status === null ? 1 : res.status)
}

// 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')
const nodeScript = (name) => `node ${JSON.stringify(join('scripts', name))}`

let failure = null
try {
  console.log('[test.mjs] 1/5 setup test db(复制源库 → 一次性副本)')
  run('setup-test-db', nodeScript('setup-test-db.mjs'))

  console.log('[test.mjs] 2/5 build')
  if (hasBackend) run('backend build', '{{backend_build}}', backendDir)
  else console.log('[test.mjs] skip backend build')
  if (hasFrontend) run('frontend build', '{{frontend_build}}', frontendDir)
  else console.log('[test.mjs] skip frontend build')

  console.log('[test.mjs] 3/5 lint')
  if (hasBackend) run('backend lint', '{{backend_lint}}', backendDir)
  else console.log('[test.mjs] skip backend lint')
  if (hasFrontend) run('frontend lint', '{{frontend_lint}}', frontendDir)
  else console.log('[test.mjs] skip frontend lint')

  console.log('[test.mjs] 4/5 unit + integration')
  if (hasBackend) run('backend test', '{{backend_test}}', backendDir)
  else console.log('[test.mjs] skip backend test')
  if (hasFrontend) run('frontend test', '{{frontend_test}}', frontendDir)
  else console.log('[test.mjs] skip frontend test')

  console.log('[test.mjs] 5/5 E2E')
  run('e2e', '{{e2e_cmd}}')

  // 绿 → 把副本上已验证的本轮新迁移晋升到源库(幂等;跨 lane 串行由编排层 test-gate 起栈互斥保证)。
  console.log('[test.mjs] promote:晋升本轮新迁移到源库')
  run('promote-to-source', nodeScript('promote-to-source.mjs'))

  console.log('[test.mjs] GREEN')
} catch (e) {
  failure = e instanceof GateError ? e : new GateError(String(e?.message || e), 1)
  console.error(`[test.mjs] ${failure.message}`)
} finally {
  // 做完测试直接删副本(无论红绿;源库绝不被删)。
  try {
    run('drop-test-db', nodeScript('drop-test-db.mjs'))
  } catch (e) {
    console.error(`[test.mjs] 警告:删副本失败:${e?.message || e}`)
    if (!failure) failure = e instanceof GateError ? e : new GateError(String(e?.message || e), 1)
  }
}

if (failure) process.exit(failure.code ?? 1)