OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / test / unit / archive.test.js

309 lines · javascript ref:
/**
 * Unit tests for lib/archive.js
 *
 * Tests archive generation from bare git repos with release branches.
 */

import test from 'brittle'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import subprocess from 'bare-subprocess'
import { getArchivePath, generateArchive } from '../../lib/archive.js'

function tmpDir (label) {
  const dir = path.join(os.tmpdir(), 'archive-test-' + label + '-' + Date.now() + '-' + Math.random().toString(36).slice(2))
  fs.mkdirSync(dir, { recursive: true })
  return dir
}

function cleanup (dir) {
  try { fs.rmSync(dir, { recursive: true }) } catch {}
}

/**
 * Create a temporary bare git repo with a release branch containing a test file.
 * Returns the path to the bare repo.
 */
function createBareRepoWithRelease () {
  const workDir = tmpDir('work')
  const bareDir = tmpDir('bare')

  // Init bare repo
  execSync('git', ['init', '--bare', bareDir])

  // Init working repo, add a file, push to bare as master, then create release branch
  const repoDir = path.join(workDir, 'repo')
  fs.mkdirSync(repoDir, { recursive: true })
  execSync('git', ['init', repoDir])
  execSync('git', ['-C', repoDir, 'config', 'user.email', 'test@test.com'])
  execSync('git', ['-C', repoDir, 'config', 'user.name', 'Test'])

  // Create a test file and commit
  fs.writeFileSync(path.join(repoDir, 'README.md'), '# Test Repo\n')
  fs.writeFileSync(path.join(repoDir, 'index.js'), 'console.log("hello")\n')
  execSync('git', ['-C', repoDir, 'add', '-A'])
  execSync('git', ['-C', repoDir, 'commit', '-m', 'initial commit'])

  // Create release branch
  execSync('git', ['-C', repoDir, 'checkout', '-b', 'release'])
  fs.writeFileSync(path.join(repoDir, 'release-marker.txt'), 'released\n')
  execSync('git', ['-C', repoDir, 'add', '-A'])
  execSync('git', ['-C', repoDir, 'commit', '-m', 'deploy: v1'])

  // Push both branches to bare repo
  execSync('git', ['-C', repoDir, 'remote', 'add', 'origin', bareDir])
  execSync('git', ['-C', repoDir, 'push', 'origin', 'master'])
  execSync('git', ['-C', repoDir, 'push', 'origin', 'release'])

  return { bareDir, workDir }
}

/**
 * Create a temporary bare git repo WITHOUT a release branch.
 */
function createBareRepoWithoutRelease () {
  const workDir = tmpDir('work-norel')
  const bareDir = tmpDir('bare-norel')

  execSync('git', ['init', '--bare', bareDir])

  const repoDir = path.join(workDir, 'repo')
  fs.mkdirSync(repoDir, { recursive: true })
  execSync('git', ['init', repoDir])
  execSync('git', ['-C', repoDir, 'config', 'user.email', 'test@test.com'])
  execSync('git', ['-C', repoDir, 'config', 'user.name', 'Test'])

  fs.writeFileSync(path.join(repoDir, 'README.md'), '# No Release\n')
  execSync('git', ['-C', repoDir, 'add', '-A'])
  execSync('git', ['-C', repoDir, 'commit', '-m', 'initial commit'])

  execSync('git', ['-C', repoDir, 'remote', 'add', 'origin', bareDir])
  execSync('git', ['-C', repoDir, 'push', 'origin', 'master'])

  return { bareDir, workDir }
}

/**
 * Synchronous subprocess exec (for test setup only — not production code).
 */
function execSync (cmd, args) {
  const proc = subprocess.spawnSync(cmd, args, {
    stdio: ['ignore', 'pipe', 'pipe']
  })
  if (proc.status !== 0) {
    const stderr = proc.stderr ? proc.stderr.toString() : ''
    throw new Error(`${cmd} ${args.join(' ')} failed (${proc.status}): ${stderr}`)
  }
  return proc.stdout ? proc.stdout.toString().trim() : ''
}

// ========================================
// getArchivePath
// ========================================

test('getArchivePath - returns correct path for tar.gz', (t) => {
  const result = getArchivePath('/tmp/archives', 'my-app', 'tar.gz')
  t.is(result, '/tmp/archives/my-app/release.tar.gz')
})

test('getArchivePath - returns correct path for zip', (t) => {
  const result = getArchivePath('/tmp/archives', 'my-app', 'zip')
  t.is(result, '/tmp/archives/my-app/release.zip')
})

test('getArchivePath - handles nested archive dir', (t) => {
  const result = getArchivePath('/data/pear-git/archives', 'cool-repo', 'tar.gz')
  t.is(result, '/data/pear-git/archives/cool-repo/release.tar.gz')
})

// ========================================
// generateArchive — tar.gz
// ========================================

test('generateArchive - generates tar.gz from release branch', async (t) => {
  const { bareDir, workDir } = createBareRepoWithRelease()
  const archiveDir = tmpDir('archives')

  try {
    const result = await generateArchive(bareDir, 'test-app', archiveDir, 'tar.gz')

    t.is(result.format, 'tar.gz', 'format is tar.gz')
    t.ok(result.path.endsWith('/test-app/release.tar.gz'), 'path ends correctly')
    t.ok(result.size > 0, 'size is positive')
    t.ok(typeof result.commitHash === 'string' && result.commitHash.length >= 7, 'has commit hash')

    // Verify file actually exists on disk
    const stat = fs.statSync(result.path)
    t.is(stat.size, result.size, 'reported size matches file size')
  } finally {
    cleanup(bareDir)
    cleanup(workDir)
    cleanup(archiveDir)
  }
})

// ========================================
// generateArchive — zip
// ========================================

test('generateArchive - generates zip from release branch', async (t) => {
  const { bareDir, workDir } = createBareRepoWithRelease()
  const archiveDir = tmpDir('archives')

  try {
    const result = await generateArchive(bareDir, 'test-app', archiveDir, 'zip')

    t.is(result.format, 'zip', 'format is zip')
    t.ok(result.path.endsWith('/test-app/release.zip'), 'path ends correctly')
    t.ok(result.size > 0, 'size is positive')
    t.ok(typeof result.commitHash === 'string' && result.commitHash.length >= 7, 'has commit hash')

    // Verify file exists
    const stat = fs.statSync(result.path)
    t.is(stat.size, result.size, 'reported size matches file size')
  } finally {
    cleanup(bareDir)
    cleanup(workDir)
    cleanup(archiveDir)
  }
})

// ========================================
// generateArchive — missing release branch
// ========================================

test('generateArchive - throws when release branch missing', async (t) => {
  const { bareDir, workDir } = createBareRepoWithoutRelease()
  const archiveDir = tmpDir('archives')

  try {
    await generateArchive(bareDir, 'no-release', archiveDir, 'tar.gz')
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err.message.includes('release'), 'error mentions release branch')
  } finally {
    cleanup(bareDir)
    cleanup(workDir)
    cleanup(archiveDir)
  }
})

// ========================================
// generateArchive — invalid format
// ========================================

test('generateArchive - throws on invalid format', async (t) => {
  try {
    await generateArchive('/tmp/fake', 'repo', '/tmp/out', 'rar')
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err.message.includes('format'), 'error mentions format')
  }
})

// ========================================
// generateArchive — creates output directory
// ========================================

test('generateArchive - creates output directory if missing', async (t) => {
  const { bareDir, workDir } = createBareRepoWithRelease()
  const archiveDir = path.join(tmpDir('archives'), 'nested', 'deep')

  try {
    const result = await generateArchive(bareDir, 'test-app', archiveDir, 'tar.gz')

    // Verify directory was created and archive exists
    t.ok(fs.existsSync(result.path), 'archive file exists')
    t.ok(result.size > 0, 'archive has content')
  } finally {
    cleanup(bareDir)
    cleanup(workDir)
    // Clean from the top-level nested dir
    cleanup(path.join(archiveDir, '..', '..'))
  }
})

// ========================================
// generateArchive — archive contains prefix directory
// ========================================

test('generateArchive - tar.gz archive contains prefixed directory', async (t) => {
  const { bareDir, workDir } = createBareRepoWithRelease()
  const archiveDir = tmpDir('archives')

  try {
    const result = await generateArchive(bareDir, 'my-app', archiveDir, 'tar.gz')

    // List tar contents to verify prefix
    const listOutput = await execAsync('tar', ['tzf', result.path])
    const entries = listOutput.trim().split('\n')

    // Every entry should start with "my-app/"
    const allPrefixed = entries.every(e => e.startsWith('my-app/'))
    t.ok(allPrefixed, 'all entries have repo name prefix')
    t.ok(entries.some(e => e.includes('README.md')), 'contains README.md')
    t.ok(entries.some(e => e.includes('index.js')), 'contains index.js')
  } finally {
    cleanup(bareDir)
    cleanup(workDir)
    cleanup(archiveDir)
  }
})

// ========================================
// generateArchive — both formats in sequence (simulates deploy)
// ========================================

test('generateArchive - both formats in sequence (simulates deploy)', async (t) => {
  const { bareDir, workDir } = createBareRepoWithRelease()
  const archiveDir = tmpDir('archives-deploy')

  try {
    // Simulate what deploy-hook does: generate tar.gz then zip in sequence
    const tarResult = await generateArchive(bareDir, 'deploy-app', archiveDir, 'tar.gz')
    const zipResult = await generateArchive(bareDir, 'deploy-app', archiveDir, 'zip')

    // Both should exist
    t.ok(fs.existsSync(tarResult.path), 'tar.gz archive exists')
    t.ok(fs.existsSync(zipResult.path), 'zip archive exists')

    // Both should have content
    t.ok(tarResult.size > 0, 'tar.gz has content')
    t.ok(zipResult.size > 0, 'zip has content')

    // Both should reference the same commit hash
    t.is(tarResult.commitHash, zipResult.commitHash, 'both archives reference same commit')

    // Formats are correct
    t.is(tarResult.format, 'tar.gz', 'tar result format is tar.gz')
    t.is(zipResult.format, 'zip', 'zip result format is zip')

    // Paths are in the same repo subdirectory
    t.ok(tarResult.path.includes('/deploy-app/'), 'tar path contains repo name')
    t.ok(zipResult.path.includes('/deploy-app/'), 'zip path contains repo name')
  } finally {
    cleanup(bareDir)
    cleanup(workDir)
    cleanup(archiveDir)
  }
})

/**
 * Async subprocess exec (for test verification).
 */
function execAsync (cmd, args) {
  return new Promise((resolve, reject) => {
    const proc = subprocess.spawn(cmd, args, {
      stdio: ['ignore', 'pipe', 'pipe']
    })
    const chunks = []
    proc.stdout.on('data', (c) => chunks.push(c))
    proc.on('exit', (code) => {
      if (code === 0) resolve(Buffer.concat(chunks).toString('utf-8'))
      else reject(new Error(`${cmd} exited ${code}`))
    })
    proc.on('error', reject)
  })
}