test-template.test.mjs
7.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
// lib/test-template.test.mjs — 校验生成模板 scripts/test.mjs 的副本库贯通 + 晋升/删副本时序。
// 要害:① 副本库名(ERP_TEST_DB_SCHEMA)必须真正打穿到每个测试子进程(gradle fork / e2e globalSetup→bootRun
// 均靠继承 env,由 application*.yml 的 ${ERP_TEST_DB_SCHEMA:<source>__test} 占位消费);② 绿后晋升、无论红绿删副本。
// 用探针/桩命令替换 {{...}} 占位(模拟 skeleton-gen 的 Plan 期填充),直接断言子进程眼中的 ERP_TEST_DB_SCHEMA。
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
const TEMPLATE = fileURLToPath(new URL('../skills/plan/skeleton-gen/templates/scripts-test-template.mjs', import.meta.url))
// 探针:以子进程身份打印自己继承到的 ERP_TEST_DB_SCHEMA(unset 显式可断言)。用于 setup-db + build/lint/test + e2e。
const PROBE_SOURCE = `console.log('PROBE_SCHEMA=' + (process.env.ERP_TEST_DB_SCHEMA ?? '(unset)'))\n`
// 晋升 / 删副本桩:打印各自可辨识的标记(用于断言"绿才晋升、无论红绿都删")。
const PROMOTE_STUB = `console.log('PROMOTE_RAN=' + (process.env.ERP_TEST_DB_SCHEMA ?? '(unset)'))\n`
const DROP_STUB = `console.log('DROP_RAN=' + (process.env.ERP_TEST_DB_SCHEMA ?? '(unset)'))\n`
// env:传给闸门进程的增量环境变量;laneMarker:非 null 时写 .tmp/erp-lane-db 兜底标记;
// sourceSchema:非 null 时写 config-vars.yaml(database.schema),触发串行缺省副本派生;
// failBackendTest:把后端单测桩换成必失败命令,验证红路径(跳晋升、仍删副本)。
// 基础环境强制剔除 ERP_TEST_DB_SCHEMA——外层 shell 若恰好设了该变量,"缺省行为"用例会被污染。
function runGate({ env = {}, laneMarker = null, sourceSchema = null, failBackendTest = false } = {}) {
const dir = mkdtempSync(join(tmpdir(), 'erp-gate-'))
mkdirSync(join(dir, 'scripts'))
mkdirSync(join(dir, 'backend'))
// backend_* 在 backend/ 子目录下执行、e2e / promote / drop 在仓库根执行(相对路径相应不同;frontend_* 因无 frontend/ 被跳过)。
let filled = readFileSync(TEMPLATE, 'utf8')
if (failBackendTest) filled = filled.replace('{{backend_test}}', 'node ../scripts/fail.mjs')
filled = filled
.replace(/\{\{(backend|frontend)_[a-z_]+\}\}/g, 'node ../scripts/probe.mjs')
.replace(/\{\{e2e_cmd\}\}/g, 'node scripts/probe.mjs')
writeFileSync(join(dir, 'scripts', 'test.mjs'), filled)
writeFileSync(join(dir, 'scripts', 'probe.mjs'), PROBE_SOURCE)
writeFileSync(join(dir, 'scripts', 'fail.mjs'), 'process.exit(1)\n')
// 真模板会连库失败中断闸门,无法隔离 test.mjs 的编排逻辑——故 setup/promote/drop 全用桩。
writeFileSync(join(dir, 'scripts', 'setup-test-db.mjs'), PROBE_SOURCE)
writeFileSync(join(dir, 'scripts', 'promote-to-source.mjs'), PROMOTE_STUB)
writeFileSync(join(dir, 'scripts', 'drop-test-db.mjs'), DROP_STUB)
if (sourceSchema !== null) {
writeFileSync(
join(dir, 'config-vars.yaml'),
['database:', ' host: 127.0.0.1', ' port: 3306', ' user: root', ' password: x', ` schema: ${sourceSchema}`, ''].join('\n'),
)
}
if (laneMarker !== null) {
mkdirSync(join(dir, '.tmp'))
writeFileSync(join(dir, '.tmp', 'erp-lane-db'), laneMarker)
}
const childEnv = { ...process.env, ...env }
if (!('ERP_TEST_DB_SCHEMA' in env)) delete childEnv.ERP_TEST_DB_SCHEMA
return spawnSync('node', [join(dir, 'scripts', 'test.mjs')], { encoding: 'utf8', env: childEnv })
}
// 核心 5 段(setup-db + backend build/lint/test + e2e)各打印一次 PROBE_SCHEMA;晋升/删副本另有自己的标记。
function countProbe(stdout, value) {
return (stdout.match(new RegExp('PROBE_SCHEMA=' + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length
}
test('test.mjs: ERP_TEST_DB_SCHEMA env reaches every child, then promotes + drops on green', () => {
const r = runGate({ env: { ERP_TEST_DB_SCHEMA: 'erp_dev_lane2' } })
assert.equal(r.status, 0, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr)
assert.equal(countProbe(r.stdout, 'erp_dev_lane2'), 5, '5 段核心应各见一次副本名 — stdout: ' + r.stdout)
assert.match(r.stdout, /PROMOTE_RAN=erp_dev_lane2/, '绿后应晋升 — stdout: ' + r.stdout)
assert.match(r.stdout, /DROP_RAN=erp_dev_lane2/, '应删副本 — stdout: ' + r.stdout)
assert.match(r.stdout, /GREEN/, 'stdout: ' + r.stdout)
})
test('test.mjs: .tmp/erp-lane-db marker backfills env for children when env is absent', () => {
const r = runGate({ laneMarker: 'erp_dev_lane3\n' })
assert.equal(r.status, 0, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr)
assert.match(r.stdout, /lane DB marker 生效: ERP_TEST_DB_SCHEMA=erp_dev_lane3/, 'stdout: ' + r.stdout)
assert.equal(countProbe(r.stdout, 'erp_dev_lane3'), 5, 'stdout: ' + r.stdout)
assert.match(r.stdout, /PROMOTE_RAN=erp_dev_lane3/, 'stdout: ' + r.stdout)
assert.match(r.stdout, /DROP_RAN=erp_dev_lane3/, 'stdout: ' + r.stdout)
})
test('test.mjs: env wins over marker (marker log absent)', () => {
const r = runGate({ env: { ERP_TEST_DB_SCHEMA: 'erp_dev_lane2' }, laneMarker: 'erp_dev_lane3\n' })
assert.equal(r.status, 0, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr)
assert.equal(countProbe(r.stdout, 'erp_dev_lane2'), 5, 'stdout: ' + r.stdout)
assert.doesNotMatch(r.stdout, /lane DB marker 生效/, 'stdout: ' + r.stdout)
})
// 串行缺省:无 env/marker 但有 config-vars → 从 database.schema 派生副本 `<schema>__test` 并显式导出到所有子进程。
test('test.mjs: serial default derives <source>__test from config and exports it to every child', () => {
const r = runGate({ sourceSchema: 'erp_dev' })
assert.equal(r.status, 0, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr)
assert.match(r.stdout, /串行缺省副本库: ERP_TEST_DB_SCHEMA=erp_dev__test/, 'stdout: ' + r.stdout)
assert.equal(countProbe(r.stdout, 'erp_dev__test'), 5, '子进程应连副本而非源库 — stdout: ' + r.stdout)
assert.match(r.stdout, /PROMOTE_RAN=erp_dev__test/, 'stdout: ' + r.stdout)
assert.match(r.stdout, /DROP_RAN=erp_dev__test/, 'stdout: ' + r.stdout)
})
test('test.mjs: no env, no marker, no config leaves children unset (graceful degrade)', () => {
const r = runGate()
assert.equal(r.status, 0, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr)
assert.equal(countProbe(r.stdout, '(unset)'), 5, 'stdout: ' + r.stdout)
assert.doesNotMatch(r.stdout, /lane DB marker 生效/, 'stdout: ' + r.stdout)
assert.doesNotMatch(r.stdout, /串行缺省副本库/, 'stdout: ' + r.stdout)
})
// 红路径:某段失败 → 不晋升(源库不被污染),但 finally 仍删副本。
test('test.mjs: a failing stage skips promotion but still drops the copy', () => {
const r = runGate({ sourceSchema: 'erp_dev', failBackendTest: true })
assert.notEqual(r.status, 0, '失败段应让闸门非零退出 — stdout: ' + r.stdout + ' stderr: ' + r.stderr)
assert.doesNotMatch(r.stdout, /PROMOTE_RAN=/, '红则绝不晋升到源库 — stdout: ' + r.stdout)
assert.match(r.stdout, /DROP_RAN=erp_dev__test/, '无论红绿都删副本 — stdout: ' + r.stdout)
assert.doesNotMatch(r.stdout, /GREEN/, 'stdout: ' + r.stdout)
})