diff --git a/lib/seed-demo-data-template.test.mjs b/lib/seed-demo-data-template.test.mjs index 6247191..1d27cf8 100644 --- a/lib/seed-demo-data-template.test.mjs +++ b/lib/seed-demo-data-template.test.mjs @@ -13,6 +13,8 @@ const TEMPLATE = fileURLToPath(new URL('../skills/plan/skeleton-gen/templates/sc // 搭一个临时目标项目:scripts/seed-demo-data.mjs + config-vars.yaml + 可选 sql/seed/*.sql,然后跑脚本。 // seedFiles 为 null 表示不创建 sql/seed 目录;为对象 { name: content } 表示创建目录并写入这些文件({} = 空目录)。 +// env:传给子进程的增量环境变量;laneMarker:非 null 时写 .tmp/erp-lane-db 兜底标记。 +// 基础环境强制剔除 ERP_TEST_DB_SCHEMA——外层 shell 若恰好设了该变量,"缺省行为"用例会被污染。 function run({ host = '127.0.0.1', port = '1', @@ -20,6 +22,8 @@ function run({ password = 'x', schemaLine = 'schema: erp_dev', seedFiles = null, + env = {}, + laneMarker = null, } = {}) { const dir = mkdtempSync(join(tmpdir(), 'erp-seed-')) mkdirSync(join(dir, 'scripts')) @@ -35,7 +39,13 @@ function run({ writeFileSync(join(seedDir, name), content) } } - return spawnSync('node', [join(dir, 'scripts', 'seed-demo-data.mjs')], { encoding: 'utf8' }) + 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', 'seed-demo-data.mjs')], { encoding: 'utf8', env: childEnv }) } test('seed-demo-data: unfilled 【人工填写】 config placeholders fail before mysql is invoked', () => { @@ -114,3 +124,27 @@ test('seed-demo-data: valid seed + port 1 passes local checks then fails at DB c assert.doesNotMatch(r.stderr, /仍是占位/, 'stderr: ' + r.stderr) assert.doesNotMatch(r.stderr, /非法种子文件名/, 'stderr: ' + r.stderr) }) + +// ── 测试 DB lane 化:取值顺序 env > .tmp/erp-lane-db marker > config ── + +const LANE_SEED = { '01__inventory.sql': '-- expect: inventory_item=3\nSELECT 1;\n' } + +test('seed-demo-data: ERP_TEST_DB_SCHEMA env overrides config schema', () => { + const r = run({ seedFiles: LANE_SEED, env: { ERP_TEST_DB_SCHEMA: 'erp_dev_lane2' } }) + assert.match(r.stdout, /目标库 erp_dev_lane2/, 'stdout: ' + r.stdout) +}) + +test('seed-demo-data: .tmp/erp-lane-db marker overrides config when env is absent, with audit log', () => { + const r = run({ seedFiles: LANE_SEED, laneMarker: 'erp_dev_lane3\n' }) + assert.match(r.stdout, /lane DB marker 生效: erp_dev_lane3/, 'stdout: ' + r.stdout) + assert.match(r.stdout, /目标库 erp_dev_lane3/, 'stdout: ' + r.stdout) +}) + +test('seed-demo-data: env wins over marker; blank marker falls through to config', () => { + const both = run({ seedFiles: LANE_SEED, env: { ERP_TEST_DB_SCHEMA: 'erp_dev_lane2' }, laneMarker: 'erp_dev_lane3\n' }) + assert.match(both.stdout, /目标库 erp_dev_lane2/, 'stdout: ' + both.stdout) + assert.doesNotMatch(both.stdout, /lane DB marker 生效/, 'stdout: ' + both.stdout) + const blank = run({ seedFiles: LANE_SEED, laneMarker: '\n' }) + assert.match(blank.stdout, /目标库 erp_dev/, 'stdout: ' + blank.stdout) + assert.doesNotMatch(blank.stdout, /lane DB marker 生效/, 'stdout: ' + blank.stdout) +}) diff --git a/lib/setup-test-db-template.test.mjs b/lib/setup-test-db-template.test.mjs index 1c6c3b1..6ceb410 100644 --- a/lib/setup-test-db-template.test.mjs +++ b/lib/setup-test-db-template.test.mjs @@ -15,7 +15,9 @@ function runWithSchema(schemaLine) { return runWithDb({ schemaLine }) } -function runWithDb({ host = '127.0.0.1', port = '1', user = 'root', password = 'x', schemaLine = 'schema: erp_dev' }) { +// env:传给子进程的增量环境变量;laneMarker:非 null 时写 .tmp/erp-lane-db 兜底标记。 +// 基础环境强制剔除 ERP_TEST_DB_SCHEMA——外层 shell 若恰好设了该变量,"缺省行为"用例会被污染。 +function runWithDb({ host = '127.0.0.1', port = '1', user = 'root', password = 'x', schemaLine = 'schema: erp_dev', env = {}, laneMarker = null }) { const dir = mkdtempSync(join(tmpdir(), 'erp-stdb-')) mkdirSync(join(dir, 'scripts')) copyFileSync(TEMPLATE, join(dir, 'scripts', 'setup-test-db.mjs')) @@ -23,7 +25,13 @@ function runWithDb({ host = '127.0.0.1', port = '1', user = 'root', password = ' join(dir, 'config-vars.yaml'), ['database:', ` host: ${host}`, ` port: ${port}`, ` user: ${user}`, ` password: ${password}`, ' ' + schemaLine, ''].join('\n'), ) - return spawnSync('node', [join(dir, 'scripts', 'setup-test-db.mjs')], { encoding: 'utf8' }) + 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', 'setup-test-db.mjs')], { encoding: 'utf8', env: childEnv }) } test('setup-test-db: empty schema fails before mysql is invoked', () => { @@ -61,3 +69,33 @@ test('setup-test-db: non-test names are allowed in sandbox/dev workflows', () => const r = runWithSchema('schema: erp_prod') assert.match(r.stdout, /DROP \+ CREATE `erp_prod`/, 'stdout: ' + r.stdout) }) + +// ── 测试 DB lane 化:取值顺序 env > .tmp/erp-lane-db marker > config ── + +test('setup-test-db: ERP_TEST_DB_SCHEMA env overrides config schema', () => { + const r = runWithDb({ env: { ERP_TEST_DB_SCHEMA: 'erp_dev_lane2' } }) + assert.match(r.stdout, /DROP \+ CREATE `erp_dev_lane2`/, 'stdout: ' + r.stdout) +}) + +test('setup-test-db: empty ERP_TEST_DB_SCHEMA env falls through to config schema', () => { + const r = runWithDb({ env: { ERP_TEST_DB_SCHEMA: '' } }) + assert.match(r.stdout, /DROP \+ CREATE `erp_dev`/, 'stdout: ' + r.stdout) +}) + +test('setup-test-db: .tmp/erp-lane-db marker overrides config when env is absent, with audit log', () => { + const r = runWithDb({ laneMarker: 'erp_dev_lane3\n' }) + assert.match(r.stdout, /lane DB marker 生效: erp_dev_lane3/, 'stdout: ' + r.stdout) + assert.match(r.stdout, /DROP \+ CREATE `erp_dev_lane3`/, 'stdout: ' + r.stdout) +}) + +test('setup-test-db: env wins over marker (marker log absent)', () => { + const r = runWithDb({ env: { ERP_TEST_DB_SCHEMA: 'erp_dev_lane2' }, laneMarker: 'erp_dev_lane3\n' }) + assert.match(r.stdout, /DROP \+ CREATE `erp_dev_lane2`/, 'stdout: ' + r.stdout) + assert.doesNotMatch(r.stdout, /lane DB marker 生效/, 'stdout: ' + r.stdout) +}) + +test('setup-test-db: blank marker falls through to config schema', () => { + const r = runWithDb({ laneMarker: '\n' }) + assert.match(r.stdout, /DROP \+ CREATE `erp_dev`/, 'stdout: ' + r.stdout) + assert.doesNotMatch(r.stdout, /lane DB marker 生效/, 'stdout: ' + r.stdout) +}) diff --git a/lib/test-template.test.mjs b/lib/test-template.test.mjs new file mode 100644 index 0000000..11b604e --- /dev/null +++ b/lib/test-template.test.mjs @@ -0,0 +1,69 @@ +// lib/test-template.test.mjs — 校验生成模板 scripts/test.mjs 的测试 DB lane 化贯通逻辑。 +// D5 的要害是 env 必须真正打穿到后端测试子进程(gradle fork / e2e globalSetup→bootRun 均靠继承 env, +// 由 application*.yml 的 ${ERP_TEST_DB_SCHEMA:...} 占位消费)——故用探针命令替换 {{...}} 占位 +// (模拟 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 显式可断言)。 +// 占位填充值不能带引号(模板里占位嵌在 JS 单引号字符串内),故用脚本文件而非内联 node -e。 +const PROBE_SOURCE = `console.log('PROBE_SCHEMA=' + (process.env.ERP_TEST_DB_SCHEMA ?? '(unset)'))\n` + +// env:传给闸门进程的增量环境变量;laneMarker:非 null 时写 .tmp/erp-lane-db 兜底标记。 +// 基础环境强制剔除 ERP_TEST_DB_SCHEMA——外层 shell 若恰好设了该变量,"缺省行为"用例会被污染。 +function runGate({ env = {}, laneMarker = null } = {}) { + const dir = mkdtempSync(join(tmpdir(), 'erp-gate-')) + mkdirSync(join(dir, 'scripts')) + mkdirSync(join(dir, 'backend')) + // backend_* 在 backend/ 子目录下执行、e2e 在仓库根执行,相对路径相应不同(frontend_* 因无 frontend/ 被跳过)。 + const filled = readFileSync(TEMPLATE, 'utf8') + .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) + // setup-test-db stub:真模板会连库失败中断闸门、且自身也读 marker,无法隔离 test.mjs 的回填路径。 + writeFileSync(join(dir, 'scripts', 'setup-test-db.mjs'), PROBE_SOURCE) + 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 }) +} + +test('test.mjs: ERP_TEST_DB_SCHEMA env reaches every child process', () => { + const r = runGate({ env: { ERP_TEST_DB_SCHEMA: 'erp_dev_lane2' } }) + assert.equal(r.status, 0, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr) + // setup-db / build / lint / unit / e2e 五步各打印一次。 + assert.equal((r.stdout.match(/PROBE_SCHEMA=erp_dev_lane2/g) || []).length, 5, '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((r.stdout.match(/PROBE_SCHEMA=erp_dev_lane3/g) || []).length, 5, '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((r.stdout.match(/PROBE_SCHEMA=erp_dev_lane2/g) || []).length, 5, 'stdout: ' + r.stdout) + assert.doesNotMatch(r.stdout, /lane DB marker 生效/, 'stdout: ' + r.stdout) +}) + +test('test.mjs: no env and no marker leaves children unset (serial behavior unchanged)', () => { + const r = runGate() + assert.equal(r.status, 0, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr) + assert.equal((r.stdout.match(/PROBE_SCHEMA=\(unset\)/g) || []).length, 5, 'stdout: ' + r.stdout) + assert.doesNotMatch(r.stdout, /lane DB marker 生效/, 'stdout: ' + r.stdout) +}) diff --git a/skills/plan/skeleton-gen/templates/docs-04-skeleton-template.md b/skills/plan/skeleton-gen/templates/docs-04-skeleton-template.md index 9e6c4d2..da9df6e 100644 --- a/skills/plan/skeleton-gen/templates/docs-04-skeleton-template.md +++ b/skills/plan/skeleton-gen/templates/docs-04-skeleton-template.md @@ -3,6 +3,10 @@ ### 1.1 规则 - 【人工填写:一条后端通用约定,按需复制本行新增更多;无则填「无」】 +**测试库 env 贯通(锁定约定,生成时原样保留)**: +- 后端 `application*.yml`(含测试 profile)JDBC URL 的 schema 段一律写成 Spring 占位 `${ERP_TEST_DB_SCHEMA:}`(如 `jdbc:mysql://localhost:3306/${ERP_TEST_DB_SCHEMA:erp_dev}?...`)。env 缺省回退原 schema、串行行为不变;并行 lane 经该 env 把整条测试/起栈链指向 lane 库——gradle test fork 的 JVM 与 bootRun 默认继承 env,无需拼 `SPRING_DATASOURCE_URL`。 +- Flyway 配置 `out-of-order: true`:并行版本段下版本号**只保唯一、不保连续、不保合并序**;独立模块的 migration 互不引用(正是同波次并行的准入条件),乱序 apply 安全。 + ### 1.2 分层结构 ### 1.3 命名约定 diff --git a/skills/plan/skeleton-gen/templates/scripts-seed-demo-data-template.mjs b/skills/plan/skeleton-gen/templates/scripts-seed-demo-data-template.mjs index a4ffba0..8d637c9 100644 --- a/skills/plan/skeleton-gen/templates/scripts-seed-demo-data-template.mjs +++ b/skills/plan/skeleton-gen/templates/scripts-seed-demo-data-template.mjs @@ -86,11 +86,26 @@ if (!existsSync(CONFIG_FILE)) { const db = parseYamlConfig(readFileSync(CONFIG_FILE, 'utf8')).database || {} +// lane 兜底 marker:并行波次执行器建好 lane worktree 后在仓库根写 .tmp/erp-lane-db(一行 lane schema 名)。 +// 与 scripts/setup-test-db.mjs 同一约定:取值顺序 env > marker > config——提示词链丢前缀时 +// 种子仍确定性灌进 lane 库,不污染共享库;主根无 marker 且 env 缺省时走 config,串行行为一字不变。 +function readLaneDbMarker() { + const markerFile = join(PROJECT_ROOT, '.tmp', 'erp-lane-db') + if (!existsSync(markerFile)) return '' + return readFileSync(markerFile, 'utf8').split('\n')[0].trim() +} + const DB_HOST = db.host ?? '' const DB_PORT = db.port ?? '3306' const DB_USER = db.user ?? '' const DB_PASSWORD = db.password ?? '' -const DB_SCHEMA = db.schema ?? '' +// 校验逻辑沿用,作用于生效值。 +const LANE_MARKER_SCHEMA = readLaneDbMarker() +const DB_SCHEMA = process.env.ERP_TEST_DB_SCHEMA || LANE_MARKER_SCHEMA || (db.schema ?? '') +// marker 是不可见的兜底路径(env 前缀在命令行上自明),生效时必须留痕便于排查指错库。 +if (!process.env.ERP_TEST_DB_SCHEMA && LANE_MARKER_SCHEMA) { + console.log(`${LOG} lane DB marker 生效: ${LANE_MARKER_SCHEMA}`) +} function rejectPlaceholder(key, value) { if (typeof value === 'string' && value.includes('【人工填写')) { diff --git a/skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs b/skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs index cb7f964..0ea86ca 100644 --- a/skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs +++ b/skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs @@ -61,11 +61,26 @@ if (!existsSync(CONFIG_FILE)) { const db = parseYamlConfig(readFileSync(CONFIG_FILE, 'utf8')).database || {} +// lane 兜底 marker:并行波次执行器建好 lane worktree 后在仓库根写 .tmp/erp-lane-db(一行 lane schema 名)。 +// lane 注入是 prompt-only(提示词链两跳,任何一跳丢 ERP_TEST_DB_SCHEMA 前缀即对共享库 DROP+CREATE), +// 本脚本是最具毁灭性的操作,必须有确定性兜底;主根无 marker 且 env 缺省时走 config,串行行为一字不变。 +function readLaneDbMarker() { + const markerFile = join(SCRIPT_DIR, '..', '.tmp', 'erp-lane-db') + if (!existsSync(markerFile)) return '' + return readFileSync(markerFile, 'utf8').split('\n')[0].trim() +} + const DB_HOST = db.host ?? '' const DB_PORT = db.port ?? '3306' const DB_USER = db.user ?? '' const DB_PASSWORD = db.password ?? '' -const DB_SCHEMA = db.schema ?? '' +// 取值顺序 env > marker > config(并行 lane 化;校验逻辑沿用,作用于生效值)。 +const LANE_MARKER_SCHEMA = readLaneDbMarker() +const DB_SCHEMA = process.env.ERP_TEST_DB_SCHEMA || LANE_MARKER_SCHEMA || (db.schema ?? '') +// marker 是不可见的兜底路径(env 前缀在命令行上自明),生效时必须留痕便于排查指错库。 +if (!process.env.ERP_TEST_DB_SCHEMA && LANE_MARKER_SCHEMA) { + console.log(`[setup-test-db] lane DB marker 生效: ${LANE_MARKER_SCHEMA}`) +} function rejectPlaceholder(key, value) { if (typeof value === 'string' && value.includes('【人工填写')) { diff --git a/skills/plan/skeleton-gen/templates/scripts-test-template.mjs b/skills/plan/skeleton-gen/templates/scripts-test-template.mjs index ccccaa6..d1e395d 100644 --- a/skills/plan/skeleton-gen/templates/scripts-test-template.mjs +++ b/skills/plan/skeleton-gen/templates/scripts-test-template.mjs @@ -9,12 +9,28 @@ // 命令字符串来自 docs/04 §零(构建/lint/单测/e2e)——由 skeleton-gen 在 Plan 期填充。 import { spawnSync } from 'node:child_process' -import { existsSync } from 'node:fs' +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)), '..') +// 测试 DB lane 化:本闸门自身不连库,schema 经 ERP_TEST_DB_SCHEMA env 贯通到全部子进程—— +// setup-test-db.mjs 直读该 env;gradle test fork 的 JVM 与 e2e globalSetup 起的 bootRun 默认继承 env, +// 由后端 application*.yml 的 ${ERP_TEST_DB_SCHEMA:<缺省 schema>} 占位消费(锁定约定见 docs/04 §一)。 +// env 缺省时读 lane 兜底 marker(.tmp/erp-lane-db,并行波次执行器建 lane worktree 后写入)回填 process.env, +// 保证提示词链丢前缀时整个闸门仍确定性指向 lane 库;主根无 marker,串行行为一字不变。 +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}`) + } + } +} + // 在指定子目录下跑一条 shell 命令;非零退出码即终止整个闸门并透传该码。 function run(label, command, cwd = PROJECT_ROOT) { console.log(`[test.mjs] ${label}: ${command}`)