OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / repo-follow-e2e.test.js

208 lines · javascript ref:
// repo-follow-e2e.test.js — full follower-mode end-to-end via @hyperswarm/testnet.
//
// Two real startPearGit daemons share a testnet DHT. A is the source.
// B is a NON-mirror, NON-pre-mounted follower — it has its own
// identity, joined no shared swarm topic via mounts/origin bundles.
//
// The flow:
//   1. A creates per-repo tracker for 'demo' + an issue.
//   2. B calls A's repo-follow-payload RPC over UDS → real payload.
//   3. B constructs a RepoFollower against B's own corestore + swarm.
//   4. RepoFollower joins A's per-daemon swarm topic, opens A's
//      tracker via FollowRegistry (read-only).
//   5. B sees A's issue. B's tracker is NOT writable.
//   6. A creates a second issue post-start. B sees it live.
//   7. CONTROL: B's local repo with same name 'demo' coexists in B's
//      own IssueRegistry without colliding with the followed copy.

import test from 'brittle'
import process from '#node:process'
import { mkdtempSync, mkdirSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import b4a from 'b4a'
import createTestnet from '@hyperswarm/testnet'

import { startPearGit } from '../index.js'
import { execFileSync } from '../lib/_compat-exec.js'
import { FollowRegistry } from '../lib/follow-registry.js'
import { RepoFollower } from '../lib/services/repo-follower.js'
import { decodeB64 } from '../lib/services/follow-payload.js'

// Two daemons in one process need DISTINCT identities. loadOrCreateIdentity
// prefers the user-level ~/.hyperident vault over per-storage identity, so on
// any machine that has a vault (every dev box) both nodes resolve to the SAME
// key and "B has its own identity" fails — the test's whole premise is that B
// is an independent peer. PGV3_USE_RAW_IDENTITY forces a per-storage identity
// at <storage>/identity.json, so a fresh tmp storage dir is a fresh device.
// Same line already opens gip-clone-e2e, daemon-pairing, per-room-swarm-join,
// pairing-persistence, org-issues-linked-core and cutover-prod-copy.
process.env.PGV3_USE_RAW_IDENTITY = '1'

let n = 0
function tmpStorage () {
  return mkdtempSync(join(tmpdir(), 'pgv3-follow-e2e-' + (n++) + '-'))
}
function tmpSockPath () {
  return join(mkdtempSync(join(tmpdir(), 'pgv3-follow-e2e-sock-' + (n++) + '-')), 'svc.sock')
}

async function waitFor (predicate, timeoutMs = 15000) {
  const t0 = Date.now()
  while (Date.now() - t0 < timeoutMs) {
    try { if (await predicate()) return true } catch {}
    await new Promise((r) => setTimeout(r, 100))
  }
  return false
}

test('non-mirror follower reads source repo issues, never enrols as writer', async (t) => {
  const testnet = await createTestnet(3, t.teardown)

  // ── SOURCE DAEMON A ──
  // No `mounts`, no `origin`, no shared `swarm.topic` — A boots as a
  // pristine primary with its own identity. Its swarm topic is the
  // default hash(A.identity + 'pear-git-v3').
  // Sync stays ON for A. repo-follow-payload calls ensureSync() and resolves
  // the repo through sync.info(name) (lib/handlers/repos.js), so a source
  // daemon with sync disabled can't answer it at all — that mismatch is why
  // this test could never get past the payload call.
  const nodeA = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: { swarm: { bootstrap: testnet.bootstrap } },
    deploy: false
  })
  t.teardown(async () => { try { await nodeA.close() } catch {} })

  // sync.info() reads the repos map, which is populated by scanning the
  // repos dir — so 'demo' has to exist as a real bare repo, not just as a
  // tracker. A source daemon serving a repo is the realistic shape here.
  mkdirSync(nodeA.svc._reposDir, { recursive: true })
  execFileSync('git', ['init', '--bare', '-q', join(nodeA.svc._reposDir, 'demo.git')])
  await nodeA.svc.dispatch('scan-repos', {}, { transport: 'uds' })

  // A creates per-repo tracker for 'demo' + an issue
  const trackerA = await nodeA.issueRegistry.getForRepo('demo')
  const issueId = await trackerA.create({
    title: 'first source issue',
    author: 'alice'
  })
  t.ok(issueId, 'A created issue')

  // ── FOLLOWER DAEMON B ──
  // ALSO no mounts/origin — B is fully independent. Its swarm topic is
  // hash(B.identity + 'pgv3'), different from A's.
  const nodeB = await startPearGit({
    storage: tmpStorage(),
    uds: tmpSockPath(),
    http: { port: 0 },
    swarm: { swarm: { bootstrap: testnet.bootstrap } },
    deploy: false,
    sync: false
  })
  t.teardown(async () => { try { await nodeB.close() } catch {} })

  // CONFIRM B is independent: its identity != A's
  t.not(nodeB.identity.publicKeyHex, nodeA.identity.publicKeyHex,
    'B has its own identity')

  // ── CALL A's repo-follow-payload RPC (in-process via UDS-equivalent)
  //    The handler registered in repoHandlers takes (ctx, params).
  //    Calling directly bypasses transport — equivalent to UDS call.
  // hyper-svc's Service exposes dispatch(method, params, ctx) — see
  // hyper-svc/lib/service.js. There is no svc.call(); .call() is the CLIENT's
  // API. The previous version called svc.call(), caught the resulting
  // TypeError, and then only ran its fallback when `svc._handlers` was ABSENT
  // — but _handlers is always present, so nothing ran, payloadResult stayed
  // undefined, and the next line threw an uncaught TypeError that aborted the
  // whole file (exit 134), taking every test after it with it.
  //
  // ctx.transport 'uds' is the in-process equivalent of a local operator call.
  const payloadResult = await nodeA.svc.dispatch(
    'repo-follow-payload', { name: 'demo' }, { transport: 'uds' }
  )

  t.ok(payloadResult, 'A returned a payload')
  t.ok(payloadResult.payload, 'payload b64 present')
  t.ok(payloadResult.url.startsWith('punch+follow://'), 'URL present')

  // Verify the payload contents
  const decoded = decodeB64(payloadResult.payload)
  t.is(decoded.kind, 'repo')
  t.is(decoded.name, 'demo')
  t.is(decoded.sourceServerKey, nodeA.identity.publicKeyHex.toLowerCase())
  t.is(decoded.issueBootstrapKey, trackerA.keyHex.toLowerCase())

  // ── B SPAWNS A FOLLOWER ──
  // Using B's own corestore + swarm + sync. FollowRegistry is fresh.
  const followReg = new FollowRegistry(nodeB.corestore)
  t.teardown(async () => { try { await followReg.close() } catch {} })

  const mockSync = {
    remotes: new Map(),
    addRemote (name, key) { this.remotes.set(name, key) },
    async pull () { /* no actual git in this e2e */ }
  }

  const follower = new RepoFollower({
    corestore: nodeB.corestore,
    // RepoFollower calls swarm.join(topic), so it needs the real Hyperswarm.
    // svc.swarm is hyper-svc's SwarmTransport wrapper; the Hyperswarm hangs
    // off it as .swarm. nodeB.swarm was simply undefined.
    swarm: nodeB.svc.swarm.swarm,
    sync: mockSync,
    followRegistry: followReg,
    sourceServerKey: decoded.sourceServerKey,
    repoName: decoded.name,
    issueBootstrapKey: decoded.issueBootstrapKey,
    repoEventLogKey: decoded.repoEventLogKey
  })
  t.teardown(async () => { try { await follower.close() } catch {} })

  await follower.start()

  // ── B SEES A'S FIRST ISSUE (replication via testnet swarm) ──
  const followerTracker = followReg.getOpenRepo(decoded.sourceServerKey, 'demo')
  t.ok(followerTracker, 'B opened follower tracker')

  const seenFirst = await waitFor(async () => {
    await followerTracker.base.update()
    const c = await followerTracker.counts()
    return (c.total || 0) >= 1
  })
  t.ok(seenFirst, 'B observed A\'s first issue via swarm replication')

  const firstIssue = await followerTracker.get(issueId)
  t.is(firstIssue.title, 'first source issue', 'B sees the right title')

  // ── B IS READ-ONLY ──
  t.is(followerTracker.writable, false, 'B follower is not writable')

  // ── LIVE: A creates SECOND issue, B sees it ──
  const issue2 = await trackerA.create({ title: 'live second', author: 'alice' })
  const seenSecond = await waitFor(async () => {
    await followerTracker.base.update()
    const got = await followerTracker.get(issue2)
    return !!got
  })
  t.ok(seenSecond, 'B observed A\'s SECOND issue live')

  // ── CONTROL: B's OWN local repo named 'demo' coexists ──
  // B's IssueRegistry instantiates a tracker for 'demo' under its own
  // namespace `issues:repo:demo`. The follower's tracker lives at
  // `follow:<A>:repo:demo`. No collision proves the namespace fix is
  // load-bearing in the daemon-spawn path too.
  const bLocalDemo = await nodeB.issueRegistry.getForRepo('demo')
  await bLocalDemo.create({ title: 'B local demo issue', author: 'bob' })
  t.not(bLocalDemo.keyHex, followerTracker.keyHex,
    'B local "demo" and followed "demo" are different autobases')

  const bLocalCount = await bLocalDemo.counts()
  t.is(bLocalCount.total, 1, 'B local demo has 1 issue (B\'s own)')

  const followedCount = await followerTracker.counts()
  t.is(followedCount.total, 2, 'followed demo has 2 issues (A\'s two)')
})