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