OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / test / unit / git-process-queue.test.js

197 lines · javascript ref:
import test from 'brittle'
import { GitProcessQueue } from '../../lib/p2p/sync/git-process-queue.js'

test('constructor: sets defaults', (t) => {
  const q = new GitProcessQueue()
  t.is(q.maxConcurrent, 5)
  t.is(q.timeout, 120000)
  q.destroy()
})

test('constructor: accepts custom options', (t) => {
  const q = new GitProcessQueue({ maxConcurrent: 3, timeout: 5000 })
  t.is(q.maxConcurrent, 3)
  t.is(q.timeout, 5000)
  q.destroy()
})

test('spawn: resolves on exit code 0', async (t) => {
  const q = new GitProcessQueue({ maxConcurrent: 2, timeout: 5000 })
  const result = await q.spawn('echo', ['hello'])
  t.is(result.code, 0)
  t.ok(result.jobId)
  q.destroy()
})

test('spawn: rejects on non-zero exit code', async (t) => {
  const q = new GitProcessQueue({ maxConcurrent: 2, timeout: 5000 })
  try {
    await q.spawn('sh', ['-c', 'exit 1'])
    t.fail('should have rejected')
  } catch (err) {
    t.ok(err.message.includes('exit code 1') || err.message.includes('exited with code 1'))
  }
  q.destroy()
})

test('spawn: respects maxConcurrent', async (t) => {
  const q = new GitProcessQueue({ maxConcurrent: 2, timeout: 10000 })
  let maxRunning = 0
  let running = 0

  const hooks = {
    onStart () {
      running++
      if (running > maxRunning) maxRunning = running
    },
    onExit () {
      running--
    }
  }

  // Spawn 5 jobs that each take ~200ms
  const promises = []
  for (let i = 0; i < 5; i++) {
    promises.push(q.spawn('sh', ['-c', 'sleep 0.2'], hooks))
  }

  await Promise.all(promises)

  t.is(maxRunning, 2, 'never exceeded maxConcurrent of 2')
  t.is(running, 0, 'all jobs finished')
  q.destroy()
})

test('spawn: processes remaining jobs after prior complete', async (t) => {
  const q = new GitProcessQueue({ maxConcurrent: 1, timeout: 5000 })
  const order = []

  const promises = []
  for (let i = 0; i < 3; i++) {
    const idx = i
    promises.push(
      q.spawn('echo', [String(idx)], {
        onStart () { order.push('start-' + idx) },
        onExit () { order.push('exit-' + idx) }
      })
    )
  }

  await Promise.all(promises)

  // With maxConcurrent=1, jobs run sequentially: start-0, exit-0, start-1, exit-1, start-2, exit-2
  t.is(order.length, 6)
  t.is(order[0], 'start-0')
  t.is(order[1], 'exit-0')
  t.is(order[2], 'start-1')
  t.is(order[3], 'exit-1')
  t.is(order[4], 'start-2')
  t.is(order[5], 'exit-2')
  q.destroy()
})

test('spawn: timeout kills process and rejects promise', async (t) => {
  const q = new GitProcessQueue({ maxConcurrent: 2, timeout: 500 })

  try {
    await q.spawn('sleep', ['10'])
    t.fail('should have rejected')
  } catch (err) {
    t.ok(err.message.includes('timeout') || err.message.includes('Timeout'))
  }
  q.destroy()
})

test('cancel: kills active process and rejects promise', async (t) => {
  const q = new GitProcessQueue({ maxConcurrent: 2, timeout: 10000 })

  let jobId = null
  const p = q.spawn('sleep', ['10'], {
    onStart (child) {
      jobId = q.activeJobs()[0]
    }
  })

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

  t.ok(jobId, 'got jobId')
  const cancelled = q.cancel(jobId)
  t.is(cancelled, true, 'cancel returned true')

  try {
    await p
    t.fail('should have rejected')
  } catch (err) {
    t.ok(err.message.includes('cancel') || err.message.includes('Cancel'))
  }
  q.destroy()
})

test('cancel: returns false for unknown jobId', (t) => {
  const q = new GitProcessQueue({ maxConcurrent: 2, timeout: 5000 })
  t.is(q.cancel('nonexistent'), false)
  q.destroy()
})

test('activeJobs: returns correct IDs', async (t) => {
  const q = new GitProcessQueue({ maxConcurrent: 5, timeout: 10000 })

  t.alike(q.activeJobs(), [], 'empty initially')

  // Start some long-running jobs
  const p1 = q.spawn('sleep', ['10'])
  const p2 = q.spawn('sleep', ['10'])

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

  const active = q.activeJobs()
  t.is(active.length, 2, 'two active jobs')

  // Clean up
  q.destroy()
  await Promise.allSettled([p1, p2])
})

test('destroy: kills all active and rejects all queued', async (t) => {
  const q = new GitProcessQueue({ maxConcurrent: 1, timeout: 10000 })

  // One active (maxConcurrent=1) and two queued
  const p1 = q.spawn('sleep', ['10'])
  const p2 = q.spawn('sleep', ['10'])
  const p3 = q.spawn('sleep', ['10'])

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

  q.destroy()

  const results = await Promise.allSettled([p1, p2, p3])

  for (const r of results) {
    t.is(r.status, 'rejected', 'all promises rejected')
  }
})

test('spawn: hooks are optional', async (t) => {
  const q = new GitProcessQueue({ maxConcurrent: 2, timeout: 5000 })
  // No hooks — should not throw
  const result = await q.spawn('echo', ['no-hooks'])
  t.is(result.code, 0)
  q.destroy()
})

test('spawn: onStart receives child process', async (t) => {
  const q = new GitProcessQueue({ maxConcurrent: 2, timeout: 5000 })
  let receivedChild = null
  await q.spawn('echo', ['hello'], {
    onStart (child) {
      receivedChild = child
    }
  })
  t.ok(receivedChild, 'onStart received child process')
  t.ok(typeof receivedChild.kill === 'function', 'child has kill method')
  q.destroy()
})