/**
* Unit tests for lib/ci/ci-runner.js
*
* Tests YAML config loading, validation, and step execution.
*/
import test from 'brittle'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import { loadCIConfig, runCISteps } from '../../lib/ci/ci-runner.js'
function tmpDir () {
const dir = path.join(os.tmpdir(), 'ci-runner-test-' + Date.now() + '-' + Math.random().toString(36).slice(2))
fs.mkdirSync(dir, { recursive: true })
return dir
}
function writeCIConfig (dir, content) {
const ciDir = path.join(dir, '.pear-git')
fs.mkdirSync(ciDir, { recursive: true })
fs.writeFileSync(path.join(ciDir, 'ci.yaml'), content)
}
function cleanup (dir) {
try { fs.rmSync(dir, { recursive: true }) } catch {}
}
// ========================================
// loadCIConfig — No File
// ========================================
test('loadCIConfig - returns null when no ci.yaml', (t) => {
const dir = tmpDir()
const result = loadCIConfig(dir)
t.is(result, null, 'returns null when file missing')
cleanup(dir)
})
test('loadCIConfig - returns null when .pear-git dir missing', (t) => {
const dir = tmpDir()
const result = loadCIConfig(dir)
t.is(result, null, 'returns null')
cleanup(dir)
})
// ========================================
// loadCIConfig — Valid YAML
// ========================================
test('loadCIConfig - parses valid single-step config', (t) => {
const dir = tmpDir()
writeCIConfig(dir, `
steps:
- name: install
run: npm install --production
`)
const config = loadCIConfig(dir)
t.ok(config, 'config is not null')
t.is(config.steps.length, 1, 'one step')
t.is(config.steps[0].name, 'install')
t.is(config.steps[0].run, 'npm install --production')
cleanup(dir)
})
test('loadCIConfig - parses multi-step config with timeout', (t) => {
const dir = tmpDir()
writeCIConfig(dir, `
steps:
- name: install
run: npm install
- name: build
run: make -j4
timeout: 300
`)
const config = loadCIConfig(dir)
t.is(config.steps.length, 2, 'two steps')
t.is(config.steps[0].name, 'install')
t.is(config.steps[1].name, 'build')
t.is(config.steps[1].timeout, 300, 'timeout parsed as integer')
cleanup(dir)
})
// ========================================
// loadCIConfig — Validation Errors
// ========================================
test('loadCIConfig - rejects missing steps array', (t) => {
const dir = tmpDir()
writeCIConfig(dir, 'foo: bar\n')
try {
loadCIConfig(dir)
t.fail('should have thrown')
} catch (err) {
t.ok(err.message.includes('steps'), 'error mentions steps')
}
cleanup(dir)
})
test('loadCIConfig - rejects empty steps array', (t) => {
const dir = tmpDir()
writeCIConfig(dir, 'steps: []\n')
try {
loadCIConfig(dir)
t.fail('should have thrown')
} catch (err) {
t.ok(err.message.includes('empty'), 'error mentions empty')
}
cleanup(dir)
})
test('loadCIConfig - rejects step without name', (t) => {
const dir = tmpDir()
writeCIConfig(dir, `
steps:
- run: echo hi
`)
try {
loadCIConfig(dir)
t.fail('should have thrown')
} catch (err) {
t.ok(err.message.includes('name'), 'error mentions name')
}
cleanup(dir)
})
test('loadCIConfig - rejects step without run', (t) => {
const dir = tmpDir()
writeCIConfig(dir, `
steps:
- name: test
`)
try {
loadCIConfig(dir)
t.fail('should have thrown')
} catch (err) {
t.ok(err.message.includes('run'), 'error mentions run')
}
cleanup(dir)
})
test('loadCIConfig - rejects invalid timeout', (t) => {
const dir = tmpDir()
writeCIConfig(dir, `
steps:
- name: test
run: echo hi
timeout: abc
`)
try {
loadCIConfig(dir)
t.fail('should have thrown')
} catch (err) {
t.ok(err.message.includes('timeout'), 'error mentions timeout')
}
cleanup(dir)
})
// ========================================
// runCISteps — Single Step
// ========================================
test('runCISteps - runs single step successfully', async (t) => {
const dir = tmpDir()
const config = { steps: [{ name: 'hello', run: 'echo hello' }] }
const results = await runCISteps(dir, config)
t.is(results.length, 1, 'one result')
t.is(results[0].name, 'hello')
t.is(results[0].status, 'ok')
t.ok(results[0].duration >= 0, 'duration is non-negative')
t.ok(results[0].output.includes('hello'), 'captured stdout')
cleanup(dir)
})
// ========================================
// runCISteps — Multiple Steps
// ========================================
test('runCISteps - runs multiple steps sequentially', async (t) => {
const dir = tmpDir()
const marker = path.join(dir, 'marker.txt')
const config = {
steps: [
{ name: 'create', run: `echo created > "${marker}"` },
{ name: 'verify', run: `cat "${marker}"` }
]
}
const results = await runCISteps(dir, config)
t.is(results.length, 2, 'two results')
t.is(results[0].status, 'ok')
t.is(results[1].status, 'ok')
t.ok(results[1].output.includes('created'), 'second step sees first step output')
cleanup(dir)
})
// ========================================
// runCISteps — Abort on Failure
// ========================================
test('runCISteps - aborts on first failure', async (t) => {
const dir = tmpDir()
const config = {
steps: [
{ name: 'pass', run: 'echo ok' },
{ name: 'fail', run: 'exit 1' },
{ name: 'skip', run: 'echo should-not-run' }
]
}
try {
await runCISteps(dir, config)
t.fail('should have thrown')
} catch (err) {
t.ok(err.message.includes('fail'), 'error includes step name')
}
cleanup(dir)
})
// ========================================
// runCISteps — Environment Variables
// ========================================
test('runCISteps - sets CI environment variables', async (t) => {
const dir = tmpDir()
const config = {
steps: [{ name: 'env', run: 'echo $CI:$PEAR_GIT:$REPO_NAME:$CHANNEL' }]
}
const results = await runCISteps(dir, config, { repoName: 'myrepo', channel: 'mychannel' })
t.ok(results[0].output.includes('true:true:myrepo:mychannel'), 'env vars set correctly')
cleanup(dir)
})
// ========================================
// runCISteps — Timeout
// ========================================
test('runCISteps - times out long-running step', async (t) => {
const dir = tmpDir()
const config = {
steps: [{ name: 'slow', run: 'sleep 30', timeout: 1 }]
}
try {
await runCISteps(dir, config)
t.fail('should have thrown')
} catch (err) {
t.ok(err.message.includes('slow'), 'error includes step name')
t.ok(err.message.toLowerCase().includes('timed out'), 'error mentions timeout')
}
cleanup(dir)
})
// ========================================
// runCISteps — Shell Features
// ========================================
test('runCISteps - supports pipes and shell features', async (t) => {
const dir = tmpDir()
const config = {
steps: [{ name: 'pipe', run: 'echo "hello world" | tr a-z A-Z' }]
}
const results = await runCISteps(dir, config)
t.ok(results[0].output.includes('HELLO WORLD'), 'pipe works')
cleanup(dir)
})
test('runCISteps - uses checkoutDir as cwd', async (t) => {
const dir = tmpDir()
const config = {
steps: [{ name: 'pwd', run: 'pwd' }]
}
const results = await runCISteps(dir, config)
// Resolve both to handle symlinks
const expected = fs.realpathSync(dir)
const actual = results[0].output.trim()
t.is(actual, expected, 'cwd is checkout dir')
cleanup(dir)
})