OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / comprehensive-test.js

521 lines · javascript ref:
#!/usr/bin/env node

/**
 * HiveRelay Comprehensive Test + Speed Benchmark
 *
 * Uses @hyperswarm/testnet for fast, deterministic local DHT.
 *
 * Tests:
 *   1. Network bootstrap speed (3 relay nodes)
 *   2. Client discovery speed
 *   3. Single file publish + replicate
 *   4. Multi-file publish + replicate (50 files)
 *   5. Large file publish + replicate (1 MB)
 *   6. Concurrent clients (5 pub + 5 con)
 *   7. Offline resilience (publisher dies, consumer reads from relays)
 *   8. Hot update propagation
 *   9. API throughput (burst 100 requests)
 *   10. Drive list + getStatus
 *
 * Usage:
 *   node scripts/comprehensive-test.js
 */

import createTestnet from '@hyperswarm/testnet'
import Hyperswarm from 'hyperswarm'
import { RelayNode } from '../core/relay-node/index.js'
import { HiveRelayClient } from '../client/index.js'
import Corestore from 'corestore'
import b4a from 'b4a'
import { mkdirSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { randomBytes } from 'crypto'

const testId = randomBytes(4).toString('hex')
const baseDir = join(tmpdir(), 'hiverelay-bench-' + testId)
mkdirSync(baseDir, { recursive: true })

const BASE_PORT = 18200
const NODES = 3

let passed = 0
let failed = 0
const results = []
const timings = []
let testnet = null

function log (msg) { console.log('  ' + msg) }
function pass (name, ms) {
  passed++
  results.push({ name, ok: true })
  if (ms !== undefined) {
    timings.push({ name, ms })
    log('[PASS] ' + name + ' (' + ms + ' ms)')
  } else {
    log('[PASS] ' + name)
  }
}
function fail (name, err) { failed++; results.push({ name, ok: false, error: err }); log('[FAIL] ' + name + ': ' + err) }
async function sleep (ms) { return new Promise((resolve) => setTimeout(resolve, ms)) }
function time () { return performance.now() }
function elapsed (start) { return Math.round(performance.now() - start) }

function tmpPath (name) {
  const p = join(baseDir, name)
  mkdirSync(p, { recursive: true })
  return p
}

function makeClient (name) {
  const store = new Corestore(tmpPath(name))
  const swarm = new Hyperswarm({ bootstrap: testnet.bootstrap })
  swarm.on('connection', (conn) => store.replicate(conn))
  return new HiveRelayClient({ swarm, store })
}

async function waitRelays (client, ms) {
  const deadline = Date.now() + (ms || 10000)
  while (client.relays.size === 0 && Date.now() < deadline) {
    await client.swarm.flush()
    await sleep(100)
  }
}

async function run () {
  console.log()
  console.log('==============================================================')
  console.log('    HiveRelay — Comprehensive Test + Speed Benchmark')
  console.log('==============================================================')
  console.log()
  log('Test ID:     ' + testId)
  log('Relay nodes: ' + NODES)
  log('Using:       @hyperswarm/testnet (local DHT)')
  console.log()

  // Create testnet
  testnet = await createTestnet(3)

  // ─── Test 1: Network Bootstrap Speed ─────────────────────────────
  console.log('  --- Test 1: Network Bootstrap Speed ---')

  const nodes = []
  const t1 = time()

  for (let i = 0; i < NODES; i++) {
    const node = new RelayNode({
      storage: tmpPath('relay-' + i),
      bootstrapNodes: testnet.bootstrap,
      enableAPI: true,
      apiPort: BASE_PORT + i,
      enableRelay: true,
      enableSeeding: true,
      enableMetrics: true,
      shutdownTimeoutMs: 5000
    })
    await node.start()
    nodes.push({ node, port: BASE_PORT + i, index: i })
  }

  pass('3 relay nodes started', elapsed(t1))

  // Let mesh form
  await sleep(500)
  console.log()

  // ─── Test 2: Client Discovery Speed ──────────────────────────────
  console.log('  --- Test 2: Client Discovery Speed ---')

  const t2 = time()
  const disc = makeClient('disc')
  await disc.start()
  await waitRelays(disc, 10000)

  if (disc.relays.size > 0) {
    pass('Client discovered ' + disc.relays.size + ' relay(s)', elapsed(t2))
  } else {
    fail('Client discovery', 'no relays found')
  }
  await disc.destroy()
  await disc.swarm.destroy()
  console.log()

  // ─── Test 3: Single File Publish + Replicate ─────────────────────
  console.log('  --- Test 3: Single File Publish + Replicate ---')

  const pub3 = makeClient('pub3')
  await pub3.start()

  const t3pub = time()
  const drive3 = await pub3.publish([
    { path: '/hello.txt', content: 'Hello from single file test' }
  ], { seed: false })
  const key3 = b4a.toString(drive3.key, 'hex')
  pass('Publish single file', elapsed(t3pub))

  // Consumer reads while publisher online (fast path)
  const con3 = makeClient('con3')
  await con3.start()

  const t3read = time()
  await con3.open(key3, { timeout: 10000 })
  const data3 = await con3.get(key3, '/hello.txt')
  if (data3 && b4a.toString(data3) === 'Hello from single file test') {
    pass('Replicate single file', elapsed(t3read))
  } else {
    fail('Single file replicate', 'got: ' + (data3 ? b4a.toString(data3) : 'null'))
  }
  await pub3.destroy(); await pub3.swarm.destroy()
  await con3.destroy(); await con3.swarm.destroy()
  console.log()

  // ─── Test 4: Multi-File Publish + Replicate (50 files) ──────────
  console.log('  --- Test 4: Multi-File (50 files) ---')

  const pub4 = makeClient('pub4')
  await pub4.start()

  const files50 = []
  for (let i = 0; i < 50; i++) {
    files50.push({
      path: '/data/file-' + String(i).padStart(3, '0') + '.json',
      content: JSON.stringify({ index: i, payload: randomBytes(64).toString('hex') })
    })
  }

  const t4pub = time()
  const drive4 = await pub4.publish(files50, { seed: false })
  const key4 = b4a.toString(drive4.key, 'hex')
  pass('Publish 50 files', elapsed(t4pub))

  const con4 = makeClient('con4')
  await con4.start()

  const t4read = time()
  await con4.open(key4, { timeout: 10000 })

  let read4 = 0
  for (let i = 0; i < 50; i++) {
    const f = await con4.get(key4, '/data/file-' + String(i).padStart(3, '0') + '.json')
    if (f) {
      const parsed = JSON.parse(b4a.toString(f))
      if (parsed.index === i) read4++
    }
  }

  if (read4 === 50) {
    pass('Replicate 50 files', elapsed(t4read))
  } else {
    fail('50 files replicate', 'read ' + read4 + '/50')
  }
  await pub4.destroy(); await pub4.swarm.destroy()
  await con4.destroy(); await con4.swarm.destroy()
  console.log()

  // ─── Test 5: Large File (1 MB) ──────────────────────────────────
  console.log('  --- Test 5: Large File (1 MB) ---')

  const pub5 = makeClient('pub5')
  await pub5.start()

  const largeContent = randomBytes(1024 * 1024)
  const t5pub = time()
  const drive5 = await pub5.publish([
    { path: '/large.bin', content: largeContent }
  ], { seed: false })
  const key5 = b4a.toString(drive5.key, 'hex')
  pass('Publish 1 MB file', elapsed(t5pub))

  const con5 = makeClient('con5')
  await con5.start()

  const t5read = time()
  await con5.open(key5, { timeout: 15000 })
  const data5 = await con5.get(key5, '/large.bin')
  if (data5 && data5.length === largeContent.length && Buffer.compare(data5, largeContent) === 0) {
    pass('Replicate 1 MB file', elapsed(t5read))
  } else {
    fail('1 MB file', 'expected ' + largeContent.length + ' bytes, got ' + (data5 ? data5.length : 0))
  }
  await pub5.destroy(); await pub5.swarm.destroy()
  await con5.destroy(); await con5.swarm.destroy()
  console.log()

  // ─── Test 6: Concurrent Clients (5 pub + 5 con) ─────────────────
  console.log('  --- Test 6: Concurrent Clients (5+5) ---')

  const t6 = time()
  const pubs6 = []
  const keys6 = []

  // Start 5 publishers concurrently
  for (let i = 0; i < 5; i++) pubs6.push(makeClient('cpub-' + i))
  await Promise.all(pubs6.map(p => p.start()))
  pass('5 publishers started', elapsed(t6))

  // Publish concurrently
  const t6pub = time()
  await Promise.all(pubs6.map(async (pub, i) => {
    const drive = await pub.publish([
      { path: '/app.json', content: JSON.stringify({ app: i, data: randomBytes(64).toString('hex') }) }
    ], { seed: false })
    keys6.push(b4a.toString(drive.key, 'hex'))
  }))
  pass('5 apps published concurrently', elapsed(t6pub))

  // 5 consumers read concurrently
  const t6con = time()
  const conResults6 = await Promise.all(keys6.map(async (key, i) => {
    const con = makeClient('ccon-' + i)
    await con.start()
    await con.open(key, { timeout: 10000 })
    const data = await con.get(key, '/app.json')
    await con.destroy(); await con.swarm.destroy()
    if (data) {
      const parsed = JSON.parse(b4a.toString(data))
      return parsed.app !== undefined
    }
    return false
  }))
  const success6 = conResults6.filter(Boolean).length

  if (success6 === 5) {
    pass('5 consumers read concurrently', elapsed(t6con))
  } else {
    fail('Concurrent consumers', success6 + '/5 succeeded')
  }

  // Cleanup publishers
  await Promise.all(pubs6.map(async (p) => { await p.destroy(); await p.swarm.destroy() }))
  console.log()

  // ─── Test 7: Offline Resilience ──────────────────────────────────
  console.log('  --- Test 7: Offline Resilience ---')

  const pub7 = makeClient('pub7')
  await pub7.start()
  await waitRelays(pub7, 5000)

  const drive7 = await pub7.publish([
    { path: '/offline.txt', content: 'Data that must survive publisher death' }
  ], { seed: false })
  const key7 = b4a.toString(drive7.key, 'hex')

  // Seed on relay nodes via API
  await Promise.all(nodes.map(({ port }) =>
    fetch('http://127.0.0.1:' + port + '/seed', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ appKey: key7 })
    }).catch(() => null)
  ))

  // Wait for at least one relay to actually have the file content
  let relayHasData = false
  for (let attempt = 0; attempt < 40 && !relayHasData; attempt++) {
    for (const { node } of nodes) {
      const entry = node.seededApps.get(key7)
      if (entry && entry.drive) {
        try {
          const check = await entry.drive.get('/offline.txt')
          if (check) { relayHasData = true; break }
        } catch {}
      }
    }
    if (!relayHasData) await sleep(250)
  }

  if (relayHasData) {
    pass('Relay downloaded content')
  } else {
    fail('Relay download', 'no relay has the file after 10s')
  }

  // Kill publisher
  await pub7.destroy(); await pub7.swarm.destroy()
  await sleep(500)

  // Consumer reads from relays only
  const con7 = makeClient('con7')
  await con7.start()
  await waitRelays(con7, 5000)

  const t7 = time()
  await con7.open(key7, { timeout: 15000 })

  let data7 = null
  for (let i = 0; i < 40 && !data7; i++) {
    data7 = await con7.get(key7, '/offline.txt')
    if (!data7) await sleep(250)
  }

  if (data7 && b4a.toString(data7) === 'Data that must survive publisher death') {
    pass('Read from relay after publisher death', elapsed(t7))
  } else {
    fail('Offline resilience', 'got: ' + (data7 ? b4a.toString(data7) : 'null'))
  }
  await con7.destroy(); await con7.swarm.destroy()
  console.log()

  // ─── Test 8: Hot Update Propagation ──────────────────────────────
  console.log('  --- Test 8: Hot Update Propagation ---')

  const pub8 = makeClient('pub8')
  await pub8.start()

  const drive8 = await pub8.publish([
    { path: '/version.txt', content: 'v1' }
  ], { seed: false })
  const key8 = b4a.toString(drive8.key, 'hex')

  const con8 = makeClient('con8')
  await con8.start()
  await con8.open(key8, { timeout: 10000 })

  const v1 = await con8.get(key8, '/version.txt')
  if (v1 && b4a.toString(v1) === 'v1') {
    pass('Initial version read')
  } else {
    fail('Initial version', 'got: ' + (v1 ? b4a.toString(v1) : 'null'))
  }

  // Publisher pushes update
  const t8 = time()
  await pub8.put(key8, '/version.txt', 'v2')
  await pub8.put(key8, '/changelog.txt', 'Updated to v2')

  // Consumer polls for update
  const conDrive8 = con8.drives.get(key8)
  let v2 = null
  for (let i = 0; i < 30 && !v2; i++) {
    await conDrive8.update({ wait: false })
    v2 = await con8.get(key8, '/version.txt')
    if (v2 && b4a.toString(v2) === 'v2') break
    v2 = null
    await sleep(100)
  }

  const changelog = await con8.get(key8, '/changelog.txt')
  if (v2 && b4a.toString(v2) === 'v2' && changelog) {
    pass('Hot update propagated', elapsed(t8))
  } else {
    fail('Hot update', 'v2=' + (v2 ? b4a.toString(v2) : 'null'))
  }
  await pub8.destroy(); await pub8.swarm.destroy()
  await con8.destroy(); await con8.swarm.destroy()
  console.log()

  // ─── Test 9: API Throughput ──────────────────────────────────────
  console.log('  --- Test 9: API Throughput ---')

  const t9 = time()
  const apiOps = []
  for (let i = 0; i < 100; i++) {
    apiOps.push(
      fetch('http://127.0.0.1:' + nodes[0].port + '/health')
        .then(r => r.json())
        .then(d => d.ok)
        .catch(() => false)
    )
  }
  const apiResults = await Promise.all(apiOps)
  const apiOk = apiResults.filter(Boolean).length
  const apiMs = elapsed(t9)
  const rps = Math.round(apiOk / (apiMs / 1000))

  if (apiOk >= 55) {
    pass('API burst: ' + apiOk + '/100 OK (' + rps + ' req/s)', apiMs)
  } else {
    fail('API throughput', apiOk + '/100 OK')
  }
  console.log()

  // ─── Test 10: Drive List + getStatus ─────────────────────────────
  console.log('  --- Test 10: Drive List + Status ---')

  const pub10 = makeClient('pub10')
  await pub10.start()

  const files10 = []
  for (let i = 0; i < 10; i++) {
    files10.push({ path: '/docs/page-' + i + '.txt', content: 'Page ' + i })
  }
  const drive10 = await pub10.publish(files10, { seed: false })
  const key10 = b4a.toString(drive10.key, 'hex')

  const t10list = time()
  const listing = await pub10.list(key10, '/docs')
  if (listing && listing.length === 10) {
    pass('List 10 files', elapsed(t10list))
  } else {
    fail('List files', 'expected 10, got ' + (listing ? listing.length : 0))
  }

  const status = pub10.getStatus()
  if (status.started && status.drives === 1) {
    pass('getStatus correct')
  } else {
    fail('getStatus', JSON.stringify(status))
  }

  await pub10.destroy(); await pub10.swarm.destroy()

  // Relay status
  for (const { node, index } of nodes) {
    const stats = node.getStats()
    pass('Relay ' + index + ' — seeded: ' + stats.seededApps + ', conns: ' + stats.connections)
  }
  console.log()

  // ─── Cleanup ─────────────────────────────────────────────────────
  console.log('  --- Cleanup ---')

  for (const { node, index } of nodes) {
    await node.stop()
    log('Relay ' + index + ' stopped')
  }
  await testnet.destroy()

  try {
    rmSync(baseDir, { recursive: true, force: true })
    log('Cleaned up: ' + baseDir)
  } catch {}

  // ─── Report ──────────────────────────────────────────────────────
  console.log()
  console.log('==============================================================')
  console.log('                    Test Report')
  console.log('==============================================================')
  console.log()
  log('Total:  ' + (passed + failed))
  log('Passed: ' + passed)
  log('Failed: ' + failed)
  console.log()

  if (timings.length > 0) {
    console.log('  --- Speed Results ---')
    for (const t of timings) {
      log(String(t.ms).padStart(7) + ' ms  ' + t.name)
    }
    console.log()
  }

  if (failed > 0) {
    console.log('  --- Failures ---')
    for (const r of results) {
      if (!r.ok) log('  - ' + r.name + ': ' + r.error)
    }
    console.log()
  }

  log(failed === 0 ? 'COMPREHENSIVE TEST: ALL PASSED' : 'COMPREHENSIVE TEST: SOME FAILURES')
  console.log()

  process.exit(failed > 0 ? 1 : 0)
}

run().catch((err) => {
  console.error('Fatal error:', err)
  process.exit(1)
})