Generate acceptance entry points incrementally

Every acceptance run re-parsed and regenerated all features even when only
one changed. Record feature_hash and aps_commit in the per-feature metadata
and skip parse/generate when both match, and drop generated tests for
features that no longer exist.

By specifier.
This commit is contained in:
Chris Troutner
2026-09-15 18:13:11 -07:00
parent 66a791bd72
commit 5bbe276c58
6 changed files with 195 additions and 9 deletions
+51 -1
View File
@@ -15,6 +15,7 @@
'use strict'
const { execFileSync } = require('node:child_process')
const crypto = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
@@ -46,6 +47,49 @@ function ensureAps () {
sh('git', ['clone', '--depth', '1', APS_URL, apsDir])
}
function apsCommit () {
try {
return sh('git', ['-C', apsDir, 'rev-parse', 'HEAD']).trim()
} catch (err) {
return ''
}
}
function featureHash (featurePath) {
return crypto.createHash('sha256').update(fs.readFileSync(featurePath)).digest('hex')
}
// True when the generated entry point and metadata already match the current
// feature text and APS checkout, so parse/generate can be skipped.
function isUpToDate (featurePath, base, commit) {
const testFile = path.join(genDir, `${base}.acceptance.test.js`)
const metaFile = path.join(genDir, 'metadata', `${base}.json`)
if (!fs.existsSync(testFile) || !fs.existsSync(metaFile)) return false
try {
const meta = JSON.parse(fs.readFileSync(metaFile, 'utf8'))
return meta.feature_hash === featureHash(featurePath) && meta.aps_commit === commit
} catch (err) {
return false
}
}
// Remove generated tests for features that no longer exist under specs/.
function removeStaleGeneratedTests (features) {
const bases = new Set(features.map((f) => f.replace(/\.feature$/i, '')))
if (!fs.existsSync(genDir)) return
for (const file of fs.readdirSync(genDir)) {
if (!file.endsWith('.acceptance.test.js')) continue
const base = file.replace(/\.acceptance\.test\.js$/, '')
if (!bases.has(base)) {
try {
fs.rmSync(path.join(genDir, file), { force: true })
} catch (err) {
// ignore cleanup errors
}
}
}
}
function main () {
ensureAps()
@@ -61,17 +105,23 @@ function main () {
fs.mkdirSync(irDir, { recursive: true })
fs.mkdirSync(genDir, { recursive: true })
removeStaleGeneratedTests(features)
const commit = apsCommit()
for (const featureFile of features) {
const base = featureFile.replace(/\.feature$/i, '')
const featurePath = path.join(specsDir, featureFile)
if (isUpToDate(featurePath, base, commit)) continue
const irPath = path.join(irDir, `${base}.json`)
// 1) Parse the feature to JSON IR using the Babashka APS gherkin-parser.
sh('bb', ['gherkin-parser', featurePath, irPath], { cwd: apsDir })
// 2) Generate executable acceptance entry points from the IR.
sh('node', [path.join(root, 'acceptance', 'lib', 'generate.js'), irPath, genDir])
sh('node', [path.join(root, 'acceptance', 'lib', 'generate.js'), irPath, genDir, featurePath, commit])
}
// 3) Run every generated acceptance test.
+14 -2
View File
@@ -37,12 +37,22 @@ function relativeRequire (fromDir, targetFile) {
return rel
}
function hashFile (file) {
try {
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
} catch (err) {
return null
}
}
function main () {
const irArg = process.argv[2]
const outArg = process.argv[3]
const featureArg = process.argv[4]
const apsCommit = process.argv[5]
if (!irArg || !outArg) {
console.error('usage: acceptance-entrypoint-generator <json-ir> <generated-test-output-dir>')
console.error('usage: acceptance-entrypoint-generator <json-ir> <generated-test-output-dir> [feature-file] [aps-commit]')
process.exit(2)
}
@@ -99,7 +109,9 @@ main().catch((err) => { console.error(err); process.exit(1) })
ir_path: path.resolve(irArg),
implementation_hash: `sha256:${hash}`,
hash_scope: 'generated_files',
generated_files: [testFile]
generated_files: [testFile],
feature_hash: featureArg ? hashFile(featureArg) : null,
aps_commit: apsCommit || null
}
fs.writeFileSync(
path.join(metaDir, metadataName(featureKey)),
+51 -1
View File
@@ -7,6 +7,7 @@
*/
import { execFileSync } from 'node:child_process'
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -50,6 +51,49 @@ function cleanStaleWorlds () {
}
}
function apsCommit () {
try {
return sh('git', ['-C', apsDir, 'rev-parse', 'HEAD']).trim()
} catch (err) {
return ''
}
}
function featureHash (featurePath) {
return crypto.createHash('sha256').update(fs.readFileSync(featurePath)).digest('hex')
}
// True when the generated entry point and metadata already match the current
// feature text and APS checkout, so parse/generate can be skipped.
function isUpToDate (featurePath, base, commit) {
const testFile = path.join(genDir, `${base}.acceptance.test.js`)
const metaFile = path.join(genDir, 'metadata', `${base}.json`)
if (!fs.existsSync(testFile) || !fs.existsSync(metaFile)) return false
try {
const meta = JSON.parse(fs.readFileSync(metaFile, 'utf8'))
return meta.feature_hash === featureHash(featurePath) && meta.aps_commit === commit
} catch (err) {
return false
}
}
// Remove generated tests for features that no longer exist under specs/.
function removeStaleGeneratedTests (features) {
const bases = new Set(features.map((f) => f.replace(/\.feature$/i, '')))
if (!fs.existsSync(genDir)) return
for (const file of fs.readdirSync(genDir)) {
if (!file.endsWith('.acceptance.test.js')) continue
const base = file.replace(/\.acceptance\.test\.js$/, '')
if (!bases.has(base)) {
try {
fs.rmSync(path.join(genDir, file), { force: true })
} catch (err) {
// ignore cleanup errors
}
}
}
}
function main () {
cleanStaleWorlds()
ensureAps()
@@ -66,14 +110,20 @@ function main () {
fs.mkdirSync(irDir, { recursive: true })
fs.mkdirSync(genDir, { recursive: true })
removeStaleGeneratedTests(features)
const commit = apsCommit()
for (const featureFile of features) {
const base = featureFile.replace(/\.feature$/i, '')
const featurePath = path.join(specsDir, featureFile)
if (isUpToDate(featurePath, base, commit)) continue
const irPath = path.join(irDir, `${base}.json`)
sh('bb', ['gherkin-parser', featurePath, irPath], { cwd: apsDir })
sh('node', [path.join(__dirname, 'lib', 'generate.js'), irPath, genDir])
sh('node', [path.join(__dirname, 'lib', 'generate.js'), irPath, genDir, featurePath, commit])
}
const tests = fs
+14 -2
View File
@@ -27,12 +27,22 @@ function relativeImport (fromDir, targetFile) {
return rel
}
function hashFile (file) {
try {
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
} catch (err) {
return null
}
}
function main () {
const irArg = process.argv[2]
const outArg = process.argv[3]
const featureArg = process.argv[4]
const apsCommit = process.argv[5]
if (!irArg || !outArg) {
console.error('usage: acceptance-entrypoint-generator <json-ir> <generated-test-output-dir>')
console.error('usage: acceptance-entrypoint-generator <json-ir> <generated-test-output-dir> [feature-file] [aps-commit]')
process.exit(2)
}
@@ -87,7 +97,9 @@ main().catch((err) => { console.error(err); process.exit(1) })
ir_path: path.resolve(irArg),
implementation_hash: `sha256:${hash}`,
hash_scope: 'generated_files',
generated_files: [testFile]
generated_files: [testFile],
feature_hash: featureArg ? hashFile(featureArg) : null,
aps_commit: apsCommit || null
}
fs.writeFileSync(
path.join(metaDir, metadataName(featureKey)),
+51 -1
View File
@@ -3,6 +3,7 @@
*/
import { execFileSync } from 'node:child_process'
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -35,6 +36,49 @@ function ensureAps () {
sh('git', ['clone', '--depth', '1', APS_URL, apsDir])
}
function apsCommit () {
try {
return sh('git', ['-C', apsDir, 'rev-parse', 'HEAD']).trim()
} catch (err) {
return ''
}
}
function featureHash (featurePath) {
return crypto.createHash('sha256').update(fs.readFileSync(featurePath)).digest('hex')
}
// True when the generated entry point and metadata already match the current
// feature text and APS checkout, so parse/generate can be skipped.
function isUpToDate (featurePath, base, commit) {
const testFile = path.join(genDir, `${base}.acceptance.test.js`)
const metaFile = path.join(genDir, 'metadata', `${base}.json`)
if (!fs.existsSync(testFile) || !fs.existsSync(metaFile)) return false
try {
const meta = JSON.parse(fs.readFileSync(metaFile, 'utf8'))
return meta.feature_hash === featureHash(featurePath) && meta.aps_commit === commit
} catch (err) {
return false
}
}
// Remove generated tests for features that no longer exist under specs/.
function removeStaleGeneratedTests (features) {
const bases = new Set(features.map((f) => f.replace(/\.feature$/i, '')))
if (!fs.existsSync(genDir)) return
for (const file of fs.readdirSync(genDir)) {
if (!file.endsWith('.acceptance.test.js')) continue
const base = file.replace(/\.acceptance\.test\.js$/, '')
if (!bases.has(base)) {
try {
fs.rmSync(path.join(genDir, file), { force: true })
} catch (err) {
// ignore cleanup errors
}
}
}
}
function main () {
ensureAps()
@@ -50,14 +94,20 @@ function main () {
fs.mkdirSync(irDir, { recursive: true })
fs.mkdirSync(genDir, { recursive: true })
removeStaleGeneratedTests(features)
const commit = apsCommit()
for (const featureFile of features) {
const base = featureFile.replace(/\.feature$/i, '')
const featurePath = path.join(specsDir, featureFile)
if (isUpToDate(featurePath, base, commit)) continue
const irPath = path.join(irDir, `${base}.json`)
sh('bb', ['gherkin-parser', featurePath, irPath], { cwd: apsDir })
sh('node', [path.join(__dirname, 'lib', 'generate.js'), irPath, genDir])
sh('node', [path.join(__dirname, 'lib', 'generate.js'), irPath, genDir, featurePath, commit])
}
const tests = fs
+14 -2
View File
@@ -23,12 +23,22 @@ function relativeImport (fromDir, targetFile) {
return rel
}
function hashFile (file) {
try {
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
} catch (err) {
return null
}
}
function main () {
const irArg = process.argv[2]
const outArg = process.argv[3]
const featureArg = process.argv[4]
const apsCommit = process.argv[5]
if (!irArg || !outArg) {
console.error('usage: acceptance-entrypoint-generator <json-ir> <generated-test-output-dir>')
console.error('usage: acceptance-entrypoint-generator <json-ir> <generated-test-output-dir> [feature-file] [aps-commit]')
process.exit(2)
}
@@ -83,7 +93,9 @@ main().catch((err) => { console.error(err); process.exit(1) })
ir_path: path.resolve(irArg),
implementation_hash: `sha256:${hash}`,
hash_scope: 'generated_files',
generated_files: [testFile]
generated_files: [testFile],
feature_hash: featureArg ? hashFile(featureArg) : null,
aps_commit: apsCommit || null
}
fs.writeFileSync(
path.join(metaDir, metadataName(featureKey)),