Skip to content

Commit 9edf6bd

Browse files
committed
feat: add lifecycle harness (seedable E2E over scaffold + ep CLI)
- scripts/lifecycle-harness: randomized scenarios, mutations, ep commands - ensureBunOnPath() so bun is resolvable when launched by absolute path - repoPath existence guard to avoid misleading ENOENT when scaffold fails - Unit tests for report, args, prng, list-parse, disk - docs/Architecture: document scaffold and lifecycle harness - package.json: lifecycle-harness script - .gitignore: lifecycle-harness reports/ Made-with: Cursor
1 parent 488fc35 commit 9edf6bd

20 files changed

Lines changed: 1908 additions & 1 deletion

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ Thumbs.db
3030
# Coverage
3131
coverage/
3232

33+
# Lifecycle harness run reports (generated)
34+
scripts/lifecycle-harness/reports/
35+
3336
# Misc
3437
*.tgz
3538
.idea/

docs/Architecture.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,11 @@ bun run ep:preflight
430430
bun run ep:smoke-test
431431
```
432432

433+
### Scripts (scaffold and lifecycle harness)
434+
435+
- **Scaffold** (`bun run scaffold`) creates new projects under `$HOME/Projects/TestRepos` (see [scripts/scaffold-test-project.ts](../scripts/scaffold-test-project.ts) and [docs/development/SCAFFOLD_USER_GUIDE.md](development/SCAFFOLD_USER_GUIDE.md)).
436+
- **Lifecycle harness** (`bun run lifecycle-harness --seed <n>`) runs seedable E2E over real repos and the `ep` CLI. It depends on the scaffold’s output path: it uses the same root (`defaultScaffoldRootDir()` in `scripts/lifecycle-harness/src/paths.ts`). The harness discovers the monorepo root by walking up from `scripts/lifecycle-harness/src/` and looking for a root `package.json` containing `effect-patterns-hub` or a `"scaffold"` script.
437+
433438
### Testing
434439

435440
**Unit and integration:**

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@
9898
"ep:preflight": "bun run --filter @effect-patterns/ep-cli preflight",
9999
"ep:smoke-test": "bun run --filter @effect-patterns/ep-cli smoke-test",
100100
"seed:skills": "bun run scripts/seed-skills.ts",
101-
"scaffold": "bun run scripts/scaffold-test-project.ts"
101+
"scaffold": "bun run scripts/scaffold-test-project.ts",
102+
"lifecycle-harness": "bun run scripts/lifecycle-harness/src/index.ts"
102103
},
103104
"dependencies": {
104105
"@effect/cli": "0.73.2",
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Unit tests for lifecycle harness args: parseArgs with given argv.
3+
*/
4+
5+
import { describe, expect, it } from 'vitest'
6+
import { parseArgs } from '../../lifecycle-harness/src/args.js'
7+
8+
describe('parseArgs', () => {
9+
it('returns help mode for --help', () => {
10+
const out = parseArgs(['node', 'harness', '--help'])
11+
expect(out.mode).toBe('help')
12+
})
13+
14+
it('returns help mode for -h', () => {
15+
const out = parseArgs(['node', 'harness', '-h'])
16+
expect(out.mode).toBe('help')
17+
})
18+
19+
it('returns version mode for --version', () => {
20+
const out = parseArgs(['node', 'harness', '--version'])
21+
expect(out.mode).toBe('version')
22+
})
23+
24+
it('returns run mode with defaults when only node and script', () => {
25+
const out = parseArgs(['node', 'scripts/lifecycle-harness/src/index.ts'])
26+
expect(out.mode).toBe('run')
27+
if (out.mode === 'run') {
28+
expect(typeof out.seed).toBe('number')
29+
expect(out.scenarios).toBe(10)
30+
expect(out.budgetMinutes).toBe(14)
31+
expect(out.scenarioTimeoutSeconds).toBe(90)
32+
expect(out.diskBudgetMb).toBe(1024)
33+
expect(out.commits).toBe('minimal')
34+
expect(out.verbose).toBe(false)
35+
expect(out.dryRun).toBe(false)
36+
expect(out.epBin).toBe('ep')
37+
}
38+
})
39+
40+
it('parses --seed', () => {
41+
const out = parseArgs(['node', 'harness', '--seed', '42'])
42+
expect(out.mode).toBe('run')
43+
if (out.mode === 'run') expect(out.seed).toBe(42)
44+
})
45+
46+
it('parses --scenarios', () => {
47+
const out = parseArgs(['node', 'harness', '--seed', '1', '--scenarios', '3'])
48+
expect(out.mode).toBe('run')
49+
if (out.mode === 'run') expect(out.scenarios).toBe(3)
50+
})
51+
52+
it('parses --only-scenario', () => {
53+
const out = parseArgs(['node', 'harness', '--seed', '1', '--only-scenario', '2'])
54+
expect(out.mode).toBe('run')
55+
if (out.mode === 'run') expect(out.onlyScenario).toBe(2)
56+
})
57+
58+
it('ignores negative --only-scenario', () => {
59+
const out = parseArgs(['node', 'harness', '--seed', '1', '--only-scenario', '-1'])
60+
expect(out.mode).toBe('run')
61+
if (out.mode === 'run') expect(out.onlyScenario).toBeUndefined()
62+
})
63+
64+
it('parses --commits none', () => {
65+
const out = parseArgs(['node', 'harness', '--seed', '1', '--commits', 'none'])
66+
expect(out.mode).toBe('run')
67+
if (out.mode === 'run') expect(out.commits).toBe('none')
68+
})
69+
70+
it('parses --verbose and --dry-run', () => {
71+
const out = parseArgs(['node', 'harness', '--seed', '1', '--verbose', '--dry-run'])
72+
expect(out.mode).toBe('run')
73+
if (out.mode === 'run') {
74+
expect(out.verbose).toBe(true)
75+
expect(out.dryRun).toBe(true)
76+
}
77+
})
78+
79+
it('parses --ep-bin', () => {
80+
const out = parseArgs(['node', 'harness', '--seed', '1', '--ep-bin', '/usr/local/bin/ep'])
81+
expect(out.mode).toBe('run')
82+
if (out.mode === 'run') expect(out.epBin).toBe('/usr/local/bin/ep')
83+
})
84+
})
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* Unit tests for lifecycle harness disk: dirSizeBytes, totalSizeBytes.
3+
*/
4+
5+
import fs from 'node:fs'
6+
import path from 'node:path'
7+
import { describe, expect, it } from 'vitest'
8+
import {
9+
bytesToMb,
10+
dirSizeBytes,
11+
totalSizeBytes,
12+
totalSizeBytesIncludingNodeModules,
13+
} from '../../lifecycle-harness/src/disk.js'
14+
15+
describe('dirSizeBytes', () => {
16+
it('returns 0 for non-existent or empty dir', () => {
17+
const tmp = path.join(import.meta.dirname, `disk-test-${Date.now()}`)
18+
expect(dirSizeBytes(tmp)).toBe(0)
19+
})
20+
21+
it('sums file sizes and excludes node_modules', () => {
22+
const tmp = path.join(import.meta.dirname, `disk-test-${Date.now()}`)
23+
fs.mkdirSync(tmp, { recursive: true })
24+
fs.mkdirSync(path.join(tmp, 'node_modules'), { recursive: true })
25+
fs.writeFileSync(path.join(tmp, 'a.txt'), 'hello', 'utf-8')
26+
fs.writeFileSync(path.join(tmp, 'node_modules', 'big.js'), 'x'.repeat(1000), 'utf-8')
27+
try {
28+
const size = dirSizeBytes(tmp)
29+
expect(size).toBe(5)
30+
} finally {
31+
fs.rmSync(tmp, { recursive: true, force: true })
32+
}
33+
})
34+
})
35+
36+
describe('totalSizeBytes', () => {
37+
it('sums multiple dirs', () => {
38+
const tmp = path.join(import.meta.dirname, `disk-test-${Date.now()}`)
39+
fs.mkdirSync(tmp, { recursive: true })
40+
const d1 = path.join(tmp, 'd1')
41+
const d2 = path.join(tmp, 'd2')
42+
fs.mkdirSync(d1, { recursive: true })
43+
fs.mkdirSync(d2, { recursive: true })
44+
fs.writeFileSync(path.join(d1, 'f'), 'ab', 'utf-8')
45+
fs.writeFileSync(path.join(d2, 'g'), 'c', 'utf-8')
46+
try {
47+
expect(totalSizeBytes([d1, d2])).toBe(3)
48+
} finally {
49+
fs.rmSync(tmp, { recursive: true, force: true })
50+
}
51+
})
52+
})
53+
54+
describe('totalSizeBytesIncludingNodeModules', () => {
55+
it('includes node_modules in sum', () => {
56+
const tmp = path.join(import.meta.dirname, `disk-test-${Date.now()}`)
57+
fs.mkdirSync(tmp, { recursive: true })
58+
fs.writeFileSync(path.join(tmp, 'a.txt'), 'x', 'utf-8')
59+
fs.mkdirSync(path.join(tmp, 'node_modules'), { recursive: true })
60+
fs.writeFileSync(path.join(tmp, 'node_modules', 'pkg.js'), 'yy', 'utf-8')
61+
try {
62+
expect(totalSizeBytesIncludingNodeModules([tmp])).toBe(3)
63+
} finally {
64+
fs.rmSync(tmp, { recursive: true, force: true })
65+
}
66+
})
67+
})
68+
69+
describe('bytesToMb', () => {
70+
it('converts bytes to MB', () => {
71+
expect(bytesToMb(1024 * 1024)).toBe(1)
72+
expect(bytesToMb(1024 * 1024 * 2.5)).toBe(2.5)
73+
})
74+
})
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Unit tests for parseFirstPatternIdFromList (ep list output parsing).
3+
*/
4+
5+
import { describe, expect, it } from 'vitest'
6+
import { parseFirstPatternIdFromList } from '../../lifecycle-harness/src/list-parse.js'
7+
8+
describe('parseFirstPatternIdFromList', () => {
9+
it('returns id from "Next: ep show <id>" line', () => {
10+
const stdout = 'Some header\nNext: ep show retry-with-backoff\nMore text'
11+
expect(parseFirstPatternIdFromList(stdout)).toBe('retry-with-backoff')
12+
})
13+
14+
it('returns id from slug line (indented 2+ spaces)', () => {
15+
const stdout = ' retry-with-backoff\n other-pattern'
16+
expect(parseFirstPatternIdFromList(stdout)).toBe('retry-with-backoff')
17+
})
18+
19+
it('prefers Next: ep show over slug line', () => {
20+
const stdout = ' first-slug\nNext: ep show preferred-id'
21+
expect(parseFirstPatternIdFromList(stdout)).toBe('preferred-id')
22+
})
23+
24+
it('returns null for empty string', () => {
25+
expect(parseFirstPatternIdFromList('')).toBe(null)
26+
})
27+
28+
it('returns null when no match', () => {
29+
expect(parseFirstPatternIdFromList('No pattern here\nJust text')).toBe(null)
30+
})
31+
})
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* Unit tests for lifecycle harness PRNG: determinism and range.
3+
*/
4+
5+
import { describe, expect, it } from 'vitest'
6+
import {
7+
mulberry32,
8+
pick,
9+
randomInt,
10+
randomIntInclusive,
11+
randomSubset,
12+
shortRand,
13+
} from '../../lifecycle-harness/src/prng.js'
14+
15+
describe('mulberry32', () => {
16+
it('is deterministic: same seed yields same sequence', () => {
17+
const rng1 = mulberry32(12345)
18+
const rng2 = mulberry32(12345)
19+
for (let i = 0; i < 20; i++) {
20+
expect(rng1()).toBe(rng2())
21+
}
22+
})
23+
24+
it('returns values in [0, 1)', () => {
25+
const rng = mulberry32(999)
26+
for (let i = 0; i < 100; i++) {
27+
const v = rng()
28+
expect(v).toBeGreaterThanOrEqual(0)
29+
expect(v).toBeLessThan(1)
30+
}
31+
})
32+
33+
it('different seeds yield different sequences', () => {
34+
const a = mulberry32(1)
35+
const b = mulberry32(2)
36+
const valsA = [a(), a(), a()]
37+
const valsB = [b(), b(), b()]
38+
expect(valsA).not.toEqual(valsB)
39+
})
40+
})
41+
42+
describe('randomInt', () => {
43+
it('returns integer in [0, max)', () => {
44+
const rng = mulberry32(42)
45+
for (let i = 0; i < 50; i++) {
46+
const v = randomInt(rng, 10)
47+
expect(Number.isInteger(v)).toBe(true)
48+
expect(v).toBeGreaterThanOrEqual(0)
49+
expect(v).toBeLessThan(10)
50+
}
51+
})
52+
})
53+
54+
describe('pick', () => {
55+
it('returns one element from array', () => {
56+
const rng = mulberry32(7)
57+
const arr = ['a', 'b', 'c']
58+
const v = pick(rng, arr)
59+
expect(arr).toContain(v)
60+
})
61+
})
62+
63+
describe('randomIntInclusive', () => {
64+
it('returns integer in [min, max] inclusive', () => {
65+
const rng = mulberry32(11)
66+
const seen = new Set<number>()
67+
for (let i = 0; i < 200; i++) {
68+
const v = randomIntInclusive(rng, 5, 10)
69+
expect(Number.isInteger(v)).toBe(true)
70+
expect(v).toBeGreaterThanOrEqual(5)
71+
expect(v).toBeLessThanOrEqual(10)
72+
seen.add(v)
73+
}
74+
expect(seen.size).toBe(6)
75+
})
76+
})
77+
78+
describe('shortRand', () => {
79+
it('returns string of requested length', () => {
80+
const rng = mulberry32(13)
81+
const s = shortRand(rng, 6)
82+
expect(s.length).toBe(6)
83+
expect(s).toMatch(/^[a-z0-9]+$/)
84+
})
85+
})
86+
87+
describe('randomSubset', () => {
88+
it('returns subset of array', () => {
89+
const rng = mulberry32(17)
90+
const arr = [1, 2, 3, 4, 5]
91+
const sub = randomSubset(rng, arr, 3)
92+
expect(sub.length).toBeLessThanOrEqual(3)
93+
for (const x of sub) expect(arr).toContain(x)
94+
})
95+
96+
it('returns empty array when input is empty', () => {
97+
const rng = mulberry32(19)
98+
expect(randomSubset(rng, [], 5)).toEqual([])
99+
})
100+
})
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Unit tests for lifecycle harness report: classifyOutcome, truncateStderr.
3+
*/
4+
5+
import { describe, expect, it } from 'vitest'
6+
import { classifyOutcome, truncateStderr } from '../../lifecycle-harness/src/report.js'
7+
8+
describe('truncateStderr', () => {
9+
it('returns input when within limit', () => {
10+
const s = 'a'.repeat(100)
11+
expect(truncateStderr(s)).toBe(s)
12+
})
13+
14+
it('truncates and appends when over limit', () => {
15+
const s = 'a'.repeat(2500)
16+
const out = truncateStderr(s)
17+
expect(out.length).toBeLessThan(s.length)
18+
expect(out.endsWith('...[truncated]')).toBe(true)
19+
expect(out.slice(0, 2000)).toBe(s.slice(0, 2000))
20+
})
21+
})
22+
23+
describe('classifyOutcome', () => {
24+
it('success when exit 0 and not expectFailure', () => {
25+
expect(classifyOutcome(0, false, '', {})).toBe('success')
26+
})
27+
28+
it('hard-fail when timed out', () => {
29+
expect(classifyOutcome(0, true, '', {})).toBe('hard-fail')
30+
expect(classifyOutcome(1, true, '', {})).toBe('hard-fail')
31+
})
32+
33+
it('soft-fail when non-zero and stderr has known external pattern (401)', () => {
34+
expect(classifyOutcome(1, false, 'Error: 401 Unauthorized', {})).toBe('soft-fail')
35+
})
36+
37+
it('soft-fail when non-zero and stderr has 404', () => {
38+
expect(classifyOutcome(1, false, '404 Not Found', {})).toBe('soft-fail')
39+
})
40+
41+
it('soft-fail when non-zero and stderr has ENOTFOUND', () => {
42+
expect(classifyOutcome(1, false, 'getaddrinfo ENOTFOUND api.example.com', {})).toBe('soft-fail')
43+
})
44+
45+
it('soft-fail when non-zero and stderr has not found pattern', () => {
46+
expect(classifyOutcome(1, false, 'no such pattern', {})).toBe('soft-fail')
47+
})
48+
49+
it('hard-fail when non-zero and stderr has no known pattern', () => {
50+
expect(classifyOutcome(1, false, 'SyntaxError: unexpected token', {})).toBe('hard-fail')
51+
})
52+
53+
it('expectedToFail: non-zero -> success', () => {
54+
expect(classifyOutcome(1, false, '', { expectFailure: true })).toBe('success')
55+
expect(classifyOutcome(1, false, 'any stderr', { expectFailure: true })).toBe('success')
56+
})
57+
58+
it('expectedToFail: exit 0 -> soft-fail', () => {
59+
expect(classifyOutcome(0, false, '', { expectFailure: true })).toBe('soft-fail')
60+
})
61+
})

0 commit comments

Comments
 (0)