OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-deploy / test / incremental-rescan.test.js

174 lines · javascript ref:
/**
 * Incremental rescan proof.
 *
 * Asserts:
 *   1. After `rescan({ newHashes: [hash] })`, the named subdir's
 *      files appear in the manifest + fileIndex.
 *   2. Files NOT in the named subdir are not re-hashed (we test this
 *      by mutating an existing file's bytes WITHOUT updating mtime —
 *      a full rescan would detect new content; incremental ignores).
 *   3. Subsequent incremental scans accumulate without duplicates.
 *   4. The legacy full-rescan still works (no opts).
 */

import test from 'brittle'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import crypto from 'hypercore-crypto'

import { DeployServer } from '../index.js'

function mktemp (prefix) {
  const dir = path.join(os.tmpdir(), 'hd-incr-' + prefix + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6))
  fs.mkdirSync(dir, { recursive: true })
  return dir
}

function writeTree (root, treeName, files) {
  const treeDir = path.join(root, treeName)
  fs.mkdirSync(treeDir, { recursive: true })
  for (const [name, content] of Object.entries(files)) {
    fs.writeFileSync(path.join(treeDir, name), content)
  }
  return treeDir
}

test('rescan({ newHashes }) — indexes only the named subdir', async (t) => {
  const dir = mktemp('only-new')

  // Pre-seed an existing tree the server will see at first scan.
  writeTree(dir, 'tree-A', { 'a.txt': 'alpha', 'b.txt': 'beta' })

  const server = new DeployServer(dir, { topic: crypto.randomBytes(32) })
  await server.ready()

  // After ready: full scan ran. Manifest should have tree-A's 2 files.
  t.is(server.manifest.files.length, 2, 'initial full scan saw 2 files')

  // Stage a NEW tree on disk.
  writeTree(dir, 'tree-B', { 'c.txt': 'gamma' })

  // Incremental rescan naming tree-B only.
  const result = await server.rescan({ newHashes: ['tree-B'] })
  t.ok(result.incremental, 'reported as incremental')
  t.is(result.addedFiles, 1, 'added exactly 1 file (c.txt)')
  t.is(server.manifest.files.length, 3, 'manifest now has 3 files')

  // Verify the new file is in fileIndex.
  const meta = await server._fileIndex.getFile('tree-B/c.txt')
  t.ok(meta, 'tree-B/c.txt indexed')
  t.is(meta.size, 'gamma'.length)

  await server.close()
  fs.rmSync(dir, { recursive: true, force: true })
})

test('rescan({ newHashes }) — does NOT re-walk other subdirs', async (t) => {
  const dir = mktemp('no-rewalk')
  writeTree(dir, 'tree-A', { 'a.txt': 'alpha' })

  const server = new DeployServer(dir, { topic: crypto.randomBytes(32) })
  await server.ready()

  // Capture tree-A's recorded hash, then change its content on disk
  // WITHOUT calling full rescan. A full _scan() would notice and update
  // the contentHash; an incremental scan naming only tree-B must NOT.
  const before = await server._fileIndex.getFile('tree-A/a.txt')

  fs.writeFileSync(path.join(dir, 'tree-A', 'a.txt'), 'MUTATED')

  writeTree(dir, 'tree-B', { 'b.txt': 'beta' })
  await server.rescan({ newHashes: ['tree-B'] })

  // tree-A's recorded contentHash MUST be unchanged — we didn't ask
  // for it to be rescanned.
  const after = await server._fileIndex.getFile('tree-A/a.txt')
  t.is(after.contentHash, before.contentHash, 'tree-A entry untouched')

  await server.close()
  fs.rmSync(dir, { recursive: true, force: true })
})

test('rescan({ newHashes }) — idempotent on duplicate calls', async (t) => {
  const dir = mktemp('idempotent')
  writeTree(dir, 'tree-A', { 'a.txt': 'alpha' })

  const server = new DeployServer(dir, { topic: crypto.randomBytes(32) })
  await server.ready()

  writeTree(dir, 'tree-B', { 'b.txt': 'beta' })

  const first = await server.rescan({ newHashes: ['tree-B'] })
  t.is(first.addedFiles, 1, 'first call adds the file')

  const second = await server.rescan({ newHashes: ['tree-B'] })
  t.is(second.addedFiles, 0, 'second call adds nothing (already indexed)')
  t.is(server.manifest.files.length, 2, 'manifest still has 2 files total')

  await server.close()
  fs.rmSync(dir, { recursive: true, force: true })
})

test('rescan() with no args still does the legacy full scan', async (t) => {
  const dir = mktemp('legacy')
  writeTree(dir, 'tree-A', { 'a.txt': 'alpha' })

  const server = new DeployServer(dir, { topic: crypto.randomBytes(32) })
  await server.ready()

  t.is(server.manifest.files.length, 1)

  // Add a new tree on disk + call rescan() with NO opts → legacy full scan.
  writeTree(dir, 'tree-B', { 'b.txt': 'beta' })
  const result = await server.rescan()
  t.absent(result.incremental, 'no incremental flag for legacy path')
  t.is(server.manifest.files.length, 2, 'full scan picked up the new file')

  await server.close()
  fs.rmSync(dir, { recursive: true, force: true })
})

test('legacy _scan() — mtime cache skips re-hashing unchanged files', async (t) => {
  const dir = mktemp('mtime-cache')
  // Seed with a moderate number of files so we can clearly see hits.
  for (let i = 0; i < 25; i++) {
    writeTree(dir, 'tree-' + i, { 'a.txt': 'content-' + i, 'b.txt': 'other-' + i })
  }

  const server = new DeployServer(dir, { topic: crypto.randomBytes(32) })

  // Capture cache-stats events.
  let firstStats = null
  let secondStats = null
  let n = 0
  server.on('scan-cache-stats', (s) => {
    if (n === 0) firstStats = s
    else if (n === 1) secondStats = s
    n++
  })

  await server.ready() // initial scan — every file is a miss
  t.is(firstStats.hits, 0, 'first scan: zero cache hits (cold)')
  t.is(firstStats.misses, 50, 'first scan: 50 misses')

  // Full rescan — every file is unchanged, every lookup should hit.
  await server.rescan()
  t.is(secondStats.hits, 50, 'second scan: 50 hits (warm)')
  t.is(secondStats.misses, 0, 'second scan: zero misses')
  t.is(secondStats.hashedBytes, 0, 'no bytes re-hashed on warm rescan')

  // Mutate ONE file and confirm the warm scan only re-hashes it.
  fs.writeFileSync(path.join(dir, 'tree-0', 'a.txt'), 'mutated')
  await new Promise(r => setTimeout(r, 10)) // ensure mtime tick
  await server.rescan()
  // The third stats event lands in the listener's accumulator.
  // We can read it from the next emission.
  // For simplicity, assert via fileIndex state.
  const meta = await server._fileIndex.getFile('tree-0/a.txt')
  t.is(meta.size, 'mutated'.length, 'mutated file picked up new size')

  await server.close()
  fs.rmSync(dir, { recursive: true, force: true })
})