OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / test / unit / ci-runner.test.js

284 lines · javascript ref:
/**
 * 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)
})