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