OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-overlay / test / e2e-auto-sync.js

291 lines · javascript ref:
#!/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)
})