#!/usr/bin/env bare
/**
* E2E Test: Automatic sync between two peers
*
* Verifies the full auto-sync pipeline:
* Alice writes file → logs to feed → protocol offers to Bob →
* Bob auto-requests → Alice chunks on demand → Bob receives →
* Bob finalizes → sync state updated on both sides
*
* Usage: bare test/e2e-auto-sync.js
*/
import Corestore from 'corestore'
import Protomux from 'protomux'
import { Duplex } from 'streamx'
import crypto from 'hypercore-crypto'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import process from 'bare-process'
import { FileIndex } from '../lib/file-index.js'
import { SyncEngine } from '../lib/sync-engine.js'
import { TransferManager } from '../lib/transfer.js'
import { OverlayProtocolV2 } from '../lib/protocol-v2.js'
import { hashChunk } from '../lib/chunker.js'
const tmpBase = path.join(os.tmpdir(), 'overlay-auto-' + Date.now())
function log (label, ...args) { console.log(`[${label}]`, ...args) }
function mkTmp (name) {
const dir = path.join(tmpBase, name)
fs.mkdirSync(dir, { recursive: true })
return dir
}
function makeDuplex () {
let aWrite, bWrite
const a = new Duplex({
write (data, cb) { bWrite(data); cb() },
read () {}
})
const b = new Duplex({
write (data, cb) { aWrite(data); cb() },
read () {}
})
aWrite = (data) => a.push(data)
bWrite = (data) => b.push(data)
return [a, b]
}
async function createPeer (name) {
const storageDir = mkTmp(name + '-store')
const filesDir = mkTmp(name + '-files')
const store = new Corestore(storageDir)
const ns = store.namespace(name)
const index = new FileIndex(ns)
await index.ready()
// Set up sync config with the files dir
await index.putSyncConfig({
folders: [{ path: filesDir, strategy: 'newest-wins', quota: null, peers: [] }]
})
const syncEngine = new SyncEngine(index, store)
await syncEngine.ready()
const transfer = new TransferManager(index)
const filePaths = new Map()
return { name, store, index, syncEngine, transfer, filePaths, filesDir }
}
let passed = 0
let failed = 0
function pass (msg) { passed++; log('test', 'PASS:', msg) }
function fail (msg) { failed++; console.error('[test] FAIL:', msg) }
async function main () {
console.log('=== hyper-overlay e2e: auto-sync ===\n')
const alice = await createPeer('alice')
const bob = await createPeer('bob')
// Track sync events
const aliceSynced = []
const bobSynced = []
const bobConflicts = []
// Create protocols with auto-sync enabled
const aliceProtocol = new OverlayProtocolV2(alice.syncEngine, alice.transfer, {
filePaths: alice.filePaths,
syncBaseDirs: [alice.filesDir],
autoSync: true,
onSynced (info) { aliceSynced.push(info) },
onConflict (msg) { log('alice', 'conflict:', msg.path) }
})
const bobProtocol = new OverlayProtocolV2(bob.syncEngine, bob.transfer, {
filePaths: bob.filePaths,
syncBaseDirs: [bob.filesDir],
autoSync: true,
onSynced (info) { bobSynced.push(info) },
onConflict (msg) { bobConflicts.push(msg) }
})
// ── Test 1: Alice creates file, offers to Bob ──────────────
console.log('--- Test 1: Auto-sync file from Alice to Bob ---')
// Alice creates a file and registers it
const content = Buffer.from('Auto-synced content from Alice! ' + Date.now())
const aliceFile = path.join(alice.filesDir, 'auto.txt')
fs.writeFileSync(aliceFile, content)
const contentHash = hashChunk(content)
const stat = fs.statSync(aliceFile)
// Alice indexes the file
await alice.index.putFile('/auto.txt', {
contentHash,
size: content.length,
mtime: stat.mtimeMs
})
// Alice logs the change to her feed
await alice.syncEngine.logPut('/auto.txt', {
contentHash,
size: content.length,
mtime: stat.mtimeMs
})
// Alice prepares the file (creates chunk map)
await alice.transfer.prepareFile(aliceFile, '/auto.txt')
// Set the file path mapping
alice.filePaths.set('/auto.txt', aliceFile)
log('alice', 'file created and indexed:', contentHash.slice(0, 16) + '...')
// Connect the peers with Protomux + corestore replication
// Use createProtocolStream which handles the noise handshake internally
const streamA = alice.store.replicate(true)
const streamB = bob.store.replicate(false)
streamA.pipe(streamB).pipe(streamA)
// Get the Protomux from the replication streams
const muxA = streamA.noiseStream ? Protomux.from(streamA.noiseStream) : Protomux.from(streamA)
const muxB = streamB.noiseStream ? Protomux.from(streamB.noiseStream) : Protomux.from(streamB)
aliceProtocol.attach(muxA)
bobProtocol.attach(muxB)
// Wait for feed replication + auto-sync
log('test', 'waiting for auto-sync...')
await new Promise(resolve => setTimeout(resolve, 1000))
// Check: did Bob receive the file?
const bobFile = path.join(bob.filesDir, 'auto.txt')
if (fs.existsSync(bobFile)) {
const bobContent = fs.readFileSync(bobFile)
if (Buffer.compare(bobContent, content) === 0) {
pass('auto-sync delivered file to Bob (' + content.length + ' bytes)')
} else {
fail('file arrived but content differs')
}
} else {
fail('file not received by Bob')
}
// Check sync state was updated
const bobState = await bob.index.getSyncState(alice.syncEngine.feedKey, '/auto.txt')
if (bobState && bobState.lastHash === contentHash) {
pass('sync state updated on Bob')
} else {
fail('sync state not updated on Bob: ' + JSON.stringify(bobState))
}
// ── Test 2: Bob creates a file, Alice auto-syncs ──────────
console.log('\n--- Test 2: Auto-sync file from Bob to Alice ---')
const bobContent = Buffer.from('Response from Bob! ' + Date.now())
const bobNewFile = path.join(bob.filesDir, 'reply.txt')
fs.writeFileSync(bobNewFile, bobContent)
const bobHash = hashChunk(bobContent)
const bobStat = fs.statSync(bobNewFile)
await bob.index.putFile('/reply.txt', {
contentHash: bobHash,
size: bobContent.length,
mtime: bobStat.mtimeMs
})
await bob.syncEngine.logPut('/reply.txt', {
contentHash: bobHash,
size: bobContent.length,
mtime: bobStat.mtimeMs
})
await bob.transfer.prepareFile(bobNewFile, '/reply.txt')
bob.filePaths.set('/reply.txt', bobNewFile)
// Bob broadcasts the offer
bobProtocol.broadcastOffer('/reply.txt', {
contentHash: bobHash,
size: bobContent.length,
mtime: bobStat.mtimeMs
}, 0)
await new Promise(resolve => setTimeout(resolve, 500))
const aliceReply = path.join(alice.filesDir, 'reply.txt')
if (fs.existsSync(aliceReply)) {
const aliceContent = fs.readFileSync(aliceReply)
if (Buffer.compare(aliceContent, bobContent) === 0) {
pass('auto-sync delivered file to Alice (' + bobContent.length + ' bytes)')
} else {
fail('file arrived at Alice but content differs')
}
} else {
fail('file not received by Alice')
}
// ── Test 3: Multi-chunk auto-sync ─────────────────────────
console.log('\n--- Test 3: Multi-chunk auto-sync ---')
const largeContent = crypto.randomBytes(64 * 1024) // 64KB
const largePath = path.join(alice.filesDir, 'large-auto.bin')
fs.writeFileSync(largePath, largeContent)
const largeHash = hashChunk(largeContent)
const largeStat = fs.statSync(largePath)
await alice.index.putFile('/large-auto.bin', {
contentHash: largeHash,
size: largeContent.length,
mtime: largeStat.mtimeMs
})
await alice.syncEngine.logPut('/large-auto.bin', {
contentHash: largeHash,
size: largeContent.length,
mtime: largeStat.mtimeMs
})
await alice.transfer.prepareFile(largePath, '/large-auto.bin')
alice.filePaths.set('/large-auto.bin', largePath)
aliceProtocol.broadcastOffer('/large-auto.bin', {
contentHash: largeHash,
size: largeContent.length,
mtime: largeStat.mtimeMs
}, 0)
await new Promise(resolve => setTimeout(resolve, 500))
const bobLarge = path.join(bob.filesDir, 'large-auto.bin')
if (fs.existsSync(bobLarge)) {
const received = fs.readFileSync(bobLarge)
if (Buffer.compare(received, largeContent) === 0) {
pass('multi-chunk auto-sync works (' + largeContent.length + ' bytes)')
} else {
fail('large file content mismatch')
}
} else {
fail('large file not received by Bob')
}
// ── Summary ────────────────────────────────────────────────
console.log('\n=== Results: ' + passed + ' passed, ' + failed + ' failed ===')
aliceProtocol.destroy()
bobProtocol.destroy()
await alice.syncEngine.close()
await alice.index.close()
await alice.store.close()
await bob.syncEngine.close()
await bob.index.close()
await bob.store.close()
process.exit(failed > 0 ? 1 : 0)
}
main().catch(err => {
console.error('E2E FAILED:', err)
process.exit(1)
})