/**
* 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)
})
}