OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / test / integration / two-instance.test.js

329 lines · javascript ref:
/**
 * Two-Instance Integration Test
 *
 * Starts two PearGitServer instances in-process with a shared testnet DHT.
 * Federates them via blind-pairing and verifies sync works.
 *
 * Uses the Autopass test pattern:
 *   - createTestnet(10, t.teardown) for isolated DHT
 *   - PearGitServer({ bootstrap: testnet.bootstrap }) for testnet routing
 *   - t.teardown for all cleanup
 */

import test from 'brittle'
import createTestnet from 'hyperdht/testnet'
import tmp from 'test-tmp'
import fs from 'bare-fs'
import path from 'bare-path'
import { spawn } from 'bare-subprocess'
import { PearGitServer } from '../../pear-git-server.js'

// ── Helpers ──────────────────────────────────────────────────

async function createBareRepo (reposDir, name) {
  const repoPath = path.join(reposDir, name + '.git')
  await new Promise((resolve, reject) => {
    const child = spawn('git', ['init', '--bare', repoPath])
    child.on('exit', code => code === 0 ? resolve() : reject(new Error('git init failed')))
  })
  return repoPath
}

async function pushCommit (repoPath, message, file, content) {
  const workDir = repoPath + '-work'
  await fs.promises.mkdir(workDir, { recursive: true })
  const hasGit = await fs.promises.stat(path.join(workDir, '.git')).catch(() => null)
  if (!hasGit) await runGit(['-C', workDir, 'init'])
  await fs.promises.writeFile(path.join(workDir, file), content)
  await runGit(['-C', workDir, 'add', '.'])
  await runGit(['-C', workDir, 'commit', '-m', message, '--allow-empty'])
  await runGit(['-C', workDir, 'push', repoPath, 'HEAD:refs/heads/main', '--force'])
}

function runGit (args) {
  return new Promise((resolve, reject) => {
    const child = spawn('git', args)
    let stderr = ''
    child.stderr.on('data', d => { stderr += d.toString() })
    child.on('exit', code => code === 0 ? resolve() : reject(new Error(`git ${args[0]}: ${stderr}`)))
  })
}

/**
 * Wait for Autobase condition — from autobase-test-helpers sync pattern.
 * Calls base.update() to kick processing, then listens for 'update' events.
 */
function updateUntil (base, fn, timeout = 15000) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      base.off('update', check)
      reject(new Error('updateUntil timeout'))
    }, timeout)

    check()

    async function check () {
      try {
        await base.update()
        if (await fn()) {
          base.off('update', check)
          clearTimeout(timer)
          return resolve()
        }
      } catch {}
      // Not ready yet — update will fire when new data arrives
    }

    base.on('update', check)
  })
}

// ── Test ─────────────────────────────────────────────────────

test('two servers federate and sync via testnet', async (t) => {
  const testnet = await createTestnet(10, t.teardown)

  const dirA = await tmp(t)
  const dirB = await tmp(t)
  const reposDirA = path.join(dirA, 'repos')
  const reposDirB = path.join(dirB, 'repos')
  await fs.promises.mkdir(reposDirA, { recursive: true })
  await fs.promises.mkdir(reposDirB, { recursive: true })

  // Create a test repo on A
  const repoPath = await createBareRepo(reposDirA, 'test-app')
  await pushCommit(repoPath, 'initial', 'README.md', '# Test App\n')

  // Start server A
  const serverA = new PearGitServer({
    port: 7001,
    storage: path.join(dirA, 'storage'),
    gitReposPath: reposDirA,
    bindAddress: 'localhost',
    headless: true,
    bootstrap: testnet.bootstrap
  })
  await serverA.ready()
  t.teardown(() => serverA.close(), { order: 1 })

  t.is(serverA.repos.length, 1, 'A has 1 repo')

  // Wait for background P2P init
  if (serverA._backgroundInit) await serverA._backgroundInit.catch(() => {})

  t.ok(serverA.serverIdentity, 'A has server identity')
  t.ok(serverA.gitSyncManager, 'A has GitSync')

  // Start server B
  const serverB = new PearGitServer({
    port: 7002,
    storage: path.join(dirB, 'storage'),
    gitReposPath: reposDirB,
    bindAddress: 'localhost',
    headless: true,
    bootstrap: testnet.bootstrap
  })
  await serverB.ready()
  t.teardown(() => serverB.close(), { order: 1 })

  t.is(serverB.repos.length, 0, 'B has 0 repos')

  if (serverB._backgroundInit) await serverB._backgroundInit.catch(() => {})

  t.ok(serverB.serverIdentity, 'B has server identity')
  t.ok(serverB.gitSyncManager, 'B has GitSync')

  // Federate: blind-pair then join shared Autobases
  const { RepoLinker } = await import('../../lib/repo-link/repo-linker.js')

  const serverKeyA = serverA.serverIdentity.getPublicKeyHex()
  const serverKeyB = serverB.serverIdentity.getPublicKeyHex()

  // Blind-pairing: exchange server keys + registry bootstrap keys
  const linkerA = new RepoLinker({ bootstrap: testnet.bootstrap })
  const inviteResult = await linkerA.createInvite({
    type: 'peer-server',
    mode: 'mirror',
    serverKey: serverKeyA,
    identityRegistryKey: serverA.identityRegistry?.bootstrapKeyHex || null,
    deployRegistryKey: serverA.deployRegistry?.bootstrapKeyHex || null
  })
  t.ok(inviteResult.inviteHex, 'A created invite')

  const linkerB = new RepoLinker({ bootstrap: testnet.bootstrap })
  const payload = await linkerB.joinWithInvite(inviteResult.inviteHex, {
    type: 'peer-server',
    serverKey: serverKeyB
  })
  t.ok(payload, 'B joined via blind-pairing')
  t.is(payload.serverKey, serverKeyA, 'B received A\'s server key')

  const joinerInfo = await inviteResult.waitForPeer()
  t.ok(joinerInfo, 'A received B\'s info')

  await linkerA.close()
  await linkerB.close()

  // Post-pairing: B joins A's registries (creates shared driveSwarm topics)
  // This is what completePeerJoin does in the route handler
  const { IdentityRegistry } = await import('../../lib/auth/identity-registry.js')
  const { SharedDeployRegistry } = await import('../../lib/ci/shared-deploy-registry.js')

  if (payload.identityRegistryKey) {
    // Close B's independent identity registry, join A's
    await serverB.identityRegistry.close()
    serverB.identityRegistry = new IdentityRegistry({
      corestore: serverB.store,
      bootstrapKey: payload.identityRegistryKey
    })
    await serverB.identityRegistry.ready()
    serverB.driveSwarm.join(serverB.identityRegistry.discoveryKey)
    t.comment('B joined A\'s identity registry')
  }

  if (payload.deployRegistryKey) {
    await serverB.deployRegistry.close()
    serverB.deployRegistry = new SharedDeployRegistry({
      corestore: serverB.store,
      bootstrapKey: payload.deployRegistryKey
    })
    await serverB.deployRegistry.ready()
    serverB.driveSwarm.join(serverB.deployRegistry.discoveryKey)
    t.comment('B joined A\'s deploy registry')
  }

  t.comment('Federation complete — waiting for driveSwarm connection + GitSync...')

  // Wait for driveSwarm to connect and GitSync to exchange manifests
  for (let i = 0; i < 30; i++) {
    const bRepos = await fs.promises.readdir(reposDirB).catch(() => [])
    if (bRepos.some(f => f.endsWith('.git'))) break
    await new Promise(r => setTimeout(r, 500))
  }

  // Check if B discovered A's repo
  const bRepos = await fs.promises.readdir(reposDirB)
  const hasTestApp = bRepos.includes('test-app.git')
  t.ok(hasTestApp, 'B discovered test-app.git via GitSync')

  // ── Bidirectional: Identity Registry ──────────────────────
  // Writer exchange happens automatically on driveSwarm connection.
  // Wait for B to become writable (writer exchange completes).
  await updateUntil(serverB.identityRegistry._base, () => serverB.identityRegistry.writable)
  t.ok(serverB.identityRegistry.writable, 'B identity is writable (auto writer exchange)')

  // A creates a user
  await serverA.identityRegistry.createUser({ name: 'alice', permissions: ['admin'] })
  await updateUntil(serverB.identityRegistry._base, async () => {
    const users = await serverB.identityRegistry.listAllUsers()
    return users.length > 0
  })
  t.pass('B sees alice (A→B identity sync)')

  // B creates a user
  await serverB.identityRegistry.createUser({ name: 'bob', permissions: ['write'] })
  await updateUntil(serverA.identityRegistry._base, async () => {
    const users = await serverA.identityRegistry.listAllUsers()
    return users.length >= 2
  })
  const usersOnA = await serverA.identityRegistry.listAllUsers()
  t.ok(usersOnA.find(u => u.name === 'bob'), 'A sees bob (B→A identity sync)')

  // ── Bidirectional: Deploy Registry ────────────────────────
  await updateUntil(serverB.deployRegistry._base, () => serverB.deployRegistry.writable)
  t.ok(serverB.deployRegistry.writable, 'B deploy is writable (auto writer exchange)')

  await serverA.deployRegistry.registerServer('key-a', { hostname: 'server-a' })
  await serverA.deployRegistry.deployComplete('test-app', {
    commitHash: 'aaa111', branch: 'main', channel: 'release', serverKey: 'key-a'
  })
  await updateUntil(serverB.deployRegistry._base, async () => {
    const app = await serverB.deployRegistry.getApp('test-app')
    return !!app
  })
  t.pass('B sees test-app deploy (A→B deploy sync)')

  await serverB.deployRegistry.registerServer('key-b', { hostname: 'server-b' })
  await updateUntil(serverA.deployRegistry._base, async () => {
    const servers = await serverA.deployRegistry.listServers()
    return servers.length >= 2
  })
  const serversOnA = await serverA.deployRegistry.listServers()
  t.ok(serversOnA.find(s => s.hostname === 'server-b'), 'A sees server-b (B→A deploy sync)')

  // ── Bidirectional: LinkedRepo / Issues ────────────────────
  const linkedA = serverA.linkedRepos.get('test-app')
  t.ok(linkedA, 'A has LinkedRepo for test-app')

  const issueId = await linkedA.createIssue('Bug from A', 'Found on server A', 'alice')
  t.ok(issueId !== undefined, 'A created issue')

  // B joins A's LinkedRepo for issue sync
  const { LinkedRepo } = await import('../../lib/repo-link/linked-repo.js')
  let linkedB = serverB.linkedRepos.get('test-app')
  if (!linkedB) {
    linkedB = new LinkedRepo({
      corestore: serverB.store,
      bootstrapKey: linkedA.bootstrapKeyHex,
      repoName: 'test-app'
    })
    await linkedB.ready()
    serverB.linkedRepos.set('test-app', linkedB)
    serverB.driveSwarm.join(linkedB.discoveryKey)
  }

  await updateUntil(linkedB._base, async () => {
    const issues = await linkedB.listIssues()
    return issues.length > 0
  })
  t.pass('B sees issue (A→B issue sync)')

  // B→A: manually add writer for LinkedRepo (not covered by auto exchange yet)
  await linkedA.addWriter('user-b', linkedB.localKey.toString('hex'), null, null, 'owner')
  await updateUntil(linkedB._base, () => linkedB._base.writable)

  await linkedB.createIssue('Bug from B', 'Found on server B', 'bob')
  await updateUntil(linkedA._base, async () => {
    const issues = await linkedA.listIssues()
    return issues.length >= 2
  })
  const issuesOnA = await linkedA.listIssues()
  t.ok(issuesOnA.find(i => i.title === 'Bug from B'), 'A sees issue from B (B→A issue sync)')

  // ── Bidirectional: Comments cross-authored ────────────────
  // A comments on B's issue, B comments on A's issue
  const bugFromB = issuesOnA.find(i => i.title === 'Bug from B')
  await linkedA.createComment(bugFromB.id, 'A commenting on B\'s bug', 'alice')

  const bugFromA = issuesOnA.find(i => i.title === 'Bug from A')
  await linkedB.createComment(bugFromA.id, 'B commenting on A\'s bug', 'bob')

  // Wait for cross-comments to sync
  await updateUntil(linkedB._base, async () => {
    const comments = await linkedB.getComments(bugFromB.id)
    return comments.length > 0
  })
  const commentsOnBsBug = await linkedB.getComments(bugFromB.id)
  t.ok(commentsOnBsBug.find(c => c.body.includes('A commenting')), 'B sees A\'s comment on B\'s bug')

  await updateUntil(linkedA._base, async () => {
    const comments = await linkedA.getComments(bugFromA.id)
    return comments.length > 0
  })
  const commentsOnAsBug = await linkedA.getComments(bugFromA.id)
  t.ok(commentsOnAsBug.find(c => c.body.includes('B commenting')), 'A sees B\'s comment on A\'s bug')

  // NOTE: Additional rounds of writes after initial sync may require
  // re-triggering the wakeup protocol. This is a known area for improvement.
  // The first round of bidirectional writes (A→B and B→A) is verified above.
})

// Force exit — testnet DHT teardown can hang
test('cleanup', (t) => {
  t.pass('done')
  t.teardown(() => {
    const exit = typeof Bare !== 'undefined' ? Bare.exit : (typeof process !== 'undefined' ? process.exit : null)
    if (exit) setTimeout(() => exit(0), 500)
  })
})