OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / test / unit / git-sync-pipe.test.js

270 lines · javascript ref:
import test from 'brittle'
import path from 'bare-path'
import os from 'bare-os'
import { spawn } from 'bare-subprocess'
import { createGitPipe } from '../../lib/p2p/sync/git-sync-pipe.js'

/**
 * Create a temporary bare git repo for testing.
 * Returns the repo path. Caller should clean up.
 */
async function createBareRepo () {
  const tmp = path.join(os.tmpdir(), 'test-pipe-' + Date.now() + '-' + Math.random().toString(36).slice(2) + '.git')
  const init = spawn('git', ['init', '--bare', tmp])
  await new Promise((resolve) => init.on('exit', resolve))
  return tmp
}

/**
 * Push an initial commit to the bare repo so git-upload-pack has something to serve.
 */
async function pushInitialCommit (bareRepoPath) {
  const workDir = path.join(os.tmpdir(), 'test-work-' + Date.now() + '-' + Math.random().toString(36).slice(2))

  const cmds = [
    ['git', ['clone', bareRepoPath, workDir]],
    ['git', ['-C', workDir, 'config', 'user.email', 'test@test.com']],
    ['git', ['-C', workDir, 'config', 'user.name', 'Test']],
    ['sh', ['-c', `echo "hello" > ${workDir}/README.md`]],
    ['git', ['-C', workDir, 'add', '.']],
    ['git', ['-C', workDir, 'commit', '-m', 'initial']],
    ['git', ['-C', workDir, 'push', 'origin', 'master']]
  ]

  for (const [cmd, args] of cmds) {
    const proc = spawn(cmd, args)
    const code = await new Promise((resolve) => proc.on('exit', resolve))
    if (code !== 0) throw new Error(`${cmd} ${args.join(' ')} exited with ${code}`)
  }

  // Clean up work dir
  spawn('rm', ['-rf', workDir])
}

/**
 * Create a mock data channel that captures sent messages.
 * Mimics the { message } entry returned by GitSyncBase.openDataChannel().
 */
function createMockChannel () {
  const chunks = []
  return {
    chunks,
    message: {
      send (data) { chunks.push(Buffer.from(data)) },
      onmessage () {} // createGitPipe overrides this
    }
  }
}

/**
 * Wait until at least one chunk has been received by the mock channel.
 */
function waitForOutput (mock) {
  return new Promise((resolve) => {
    const check = setInterval(() => {
      if (mock.chunks.length > 0) {
        clearInterval(check)
        resolve()
      }
    }, 50)
  })
}

// ---------------------------------------------------------------------------
// Test: createGitPipe sends git output through channel
// ---------------------------------------------------------------------------

test('createGitPipe sends git-upload-pack output through channel', async (t) => {
  t.timeout(15000)
  const repoPath = await createBareRepo()
  await pushInitialCommit(repoPath)

  const mock = createMockChannel()
  const pipe = createGitPipe('git-upload-pack', repoPath, mock)

  // git-upload-pack advertises refs then blocks waiting for wants on stdin.
  // Wait for ref advertisement to arrive, then close stdin.
  await waitForOutput(mock)

  pipe.closeStdin()
  const { exitCode } = await pipe.exitPromise

  // git-upload-pack exits 0 or 128 when stdin closes without protocol completion
  t.ok(exitCode === 0 || exitCode === 128, 'git-upload-pack exited after ref advertisement')
  t.ok(mock.chunks.length > 0, 'git output was sent through channel')

  // The output should contain ref advertisement (pkt-line format)
  const combined = Buffer.concat(mock.chunks).toString('utf-8')
  t.ok(combined.includes('refs/heads/'), 'output contains ref advertisement')

  // Clean up
  spawn('rm', ['-rf', repoPath])
})

// ---------------------------------------------------------------------------
// Test: writeToGit sends data to git stdin
// ---------------------------------------------------------------------------

test('writeToGit sends data to git stdin', async (t) => {
  t.timeout(15000)
  const repoPath = await createBareRepo()
  await pushInitialCommit(repoPath)

  const mock = createMockChannel()
  const pipe = createGitPipe('git-upload-pack', repoPath, mock)

  // Wait for ref advertisement output
  await waitForOutput(mock)
  t.ok(mock.chunks.length > 0, 'output was produced before stdin data')

  // Send flush packet (0000) — tells git-upload-pack the client has no wants.
  // Then close stdin so git can fully exit.
  pipe.writeToGit(Buffer.from('0000'))
  pipe.closeStdin()

  const { exitCode } = await pipe.exitPromise

  t.ok(exitCode === 0 || exitCode === 128, 'git-upload-pack exited after receiving stdin data')

  // Clean up
  spawn('rm', ['-rf', repoPath])
})

// ---------------------------------------------------------------------------
// Test: exitPromise resolves with exit code and stderr
// ---------------------------------------------------------------------------

test('exitPromise resolves with exitCode and stderr for valid repo', async (t) => {
  t.timeout(15000)
  const repoPath = await createBareRepo()
  await pushInitialCommit(repoPath)

  const mock = createMockChannel()
  const pipe = createGitPipe('git-upload-pack', repoPath, mock)

  await waitForOutput(mock)
  pipe.closeStdin()

  const result = await pipe.exitPromise

  t.is(typeof result.exitCode, 'number', 'exitCode is a number')
  t.ok(result.exitCode === 0 || result.exitCode === 128, 'exit code for valid repo')
  t.ok(typeof result.stderr === 'string', 'stderr is a string')
  t.is(result.timedOut, false, 'did not time out')
  t.is(result.killed, false, 'was not killed')

  // Clean up
  spawn('rm', ['-rf', repoPath])
})

test('exitPromise captures non-zero exit code for invalid repo', async (t) => {
  t.timeout(15000)

  const mock = createMockChannel()
  // git-upload-pack on a non-existent repo should fail immediately
  const pipe = createGitPipe('git-upload-pack', '/tmp/nonexistent-repo-' + Date.now() + '.git', mock)

  const result = await pipe.exitPromise

  t.ok(result.exitCode !== 0, 'non-zero exit code for invalid repo')
  t.ok(result.stderr.length > 0, 'stderr contains error message')
})

// ---------------------------------------------------------------------------
// Test: timeout kills process
// ---------------------------------------------------------------------------

test('timeout kills long-running process', async (t) => {
  t.timeout(10000)

  const mock = createMockChannel()
  // Use 'sleep' as a stand-in for a hung git process
  const pipe = createGitPipe('sleep', '60', mock, { timeout: 500 })

  const result = await pipe.exitPromise

  // The key assertion: timedOut flag must be set
  t.is(result.timedOut, true, 'timedOut flag is set')
  // Process was terminated (exit code varies by runtime — may be 0, null, or signal code)
  t.is(result.killed, false, 'killed flag is not set (timeout, not manual kill)')
})

// ---------------------------------------------------------------------------
// Test: kill() terminates process
// ---------------------------------------------------------------------------

test('kill terminates the git process', async (t) => {
  t.timeout(10000)

  const mock = createMockChannel()
  const pipe = createGitPipe('sleep', '60', mock, { timeout: 30000 })

  // Wait for process to start
  await new Promise((resolve) => setTimeout(resolve, 100))

  pipe.kill()

  const result = await pipe.exitPromise

  // The key assertion: killed flag must be set
  t.is(result.killed, true, 'killed flag is set')
  t.is(result.timedOut, false, 'timedOut flag is not set (manual kill, not timeout)')
})

// ---------------------------------------------------------------------------
// Test: channel onmessage is wired to git stdin
// ---------------------------------------------------------------------------

test('channel onmessage is wired to git stdin', async (t) => {
  t.timeout(15000)
  const repoPath = await createBareRepo()
  await pushInitialCommit(repoPath)

  const mock = createMockChannel()
  const pipe = createGitPipe('git-upload-pack', repoPath, mock)

  // Wait for ref advertisement
  await waitForOutput(mock)

  // Simulate receiving data through the channel (like Protomux would deliver).
  // createGitPipe overrides mock.message.onmessage to forward to git stdin.
  mock.message.onmessage(Buffer.from('0000'))
  pipe.closeStdin()

  const { exitCode } = await pipe.exitPromise

  t.ok(exitCode === 0 || exitCode === 128, 'git process received channel data and exited')

  // Clean up
  spawn('rm', ['-rf', repoPath])
})

// ---------------------------------------------------------------------------
// Test: closeStdin ends git process stdin
// ---------------------------------------------------------------------------

test('closeStdin ends git process stdin', async (t) => {
  t.timeout(15000)
  const repoPath = await createBareRepo()
  await pushInitialCommit(repoPath)

  const mock = createMockChannel()
  const pipe = createGitPipe('git-upload-pack', repoPath, mock)

  // Wait for ref advertisement
  await waitForOutput(mock)

  // Close stdin without sending any wants — git-upload-pack should exit
  pipe.closeStdin()

  const result = await pipe.exitPromise

  // git-upload-pack exits 0 or 128 depending on git version when stdin closes mid-protocol
  t.ok(result.exitCode === 0 || result.exitCode === 128, 'git-upload-pack exits after stdin close')
  t.is(result.timedOut, false, 'did not time out')
  t.is(result.killed, false, 'was not killed')

  // Clean up
  spawn('rm', ['-rf', repoPath])
})