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