OverviewFilesCommitsIssuesDocsReleasesPeers

favela-host / test / watch-projection.test.js

131 lines · javascript ref:
// The bsky projection, running for as long as the desktop app runs.
//
// ⛔ WHY. favela-host only projected on demand, as a dry run by default, so a desktop that ran it instead
// of the four separate apps would silently stop posting to bsky — favela-bridge's `--watch` daemon was
// the only thing sending (hyper-social #77 §5, change 3). This replaces that daemon inside the app.
// ⛔ EVENT-DRIVEN, NOT POLLED: one pass at boot, then one per change to the node's own feed — the
// standing rule is "rip out polling timers". Coalesced: never two passes at once, and a change that
// lands during a pass gets exactly one more.

import test from 'brittle'
import { EventEmitter } from 'bare-events'
import { watchProjection } from '../lib/project.js'

const tick = () => new Promise((resolve) => setTimeout(resolve, 20))

// A fake feed whose single writer core emits 'append' — the event a real post produces.
function fakeNode () {
  const core = new EventEmitter()
  const base = { writers: [{ core }], emit: (ev) => { if (ev === 'update') core.emit('append') } }
  return { node: { peer: { feed: { base } } }, base }
}

test('no identity, no projection', async (t) => {
  const { node, base } = fakeNode()
  let runs = 0
  const w = await watchProjection(node, Promise.resolve(null), { project: async () => { runs++ } })
  base.emit('update'); await tick()
  t.is(runs, 0)
  t.absent(w.watching)
})

test('one pass at boot, then one per change to the own feed, always sending', async (t) => {
  const { node, base } = fakeNode()
  const calls = []
  const w = await watchProjection(node, Promise.resolve({ nodes: [{}] }), { project: async (n, hpp, opts) => { calls.push(opts.send) } })
  t.teardown(() => w.stop())
  await tick()
  t.is(calls.length, 1, 'the boot pass')
  base.emit('update'); await tick()
  t.is(calls.length, 2, 'a change triggers a pass')
  t.ok(calls.every((s) => s === true), 'it sends, like the deployed --watch did')
})

test('coalesced: changes during a pass produce exactly ONE more pass, never two at once', async (t) => {
  const { node, base } = fakeNode()
  let running = 0
  let maxConcurrent = 0
  let runs = 0
  let release
  const project = async () => {
    running++; runs++; maxConcurrent = Math.max(maxConcurrent, running)
    if (runs === 1) await new Promise((resolve) => { release = resolve })
    running--
  }
  const w = await watchProjection(node, Promise.resolve({ nodes: [{}] }), { project })
  t.teardown(() => w.stop())
  await tick()
  base.emit('update'); base.emit('update'); base.emit('update')
  await tick()
  release(); await tick(); await tick()
  t.is(maxConcurrent, 1, 'never two passes at once')
  t.is(runs, 2, 'the three changes during the first pass became one more pass')
})

test('a failing pass does not stop the watch', async (t) => {
  const { node, base } = fakeNode()
  let runs = 0
  const said = []
  const w = await watchProjection(node, Promise.resolve({ nodes: [{}] }), { project: async () => { runs++; if (runs === 1) throw new Error('sink respawning') }, log: (m) => said.push(m) })
  t.teardown(() => w.stop())
  await tick()
  base.emit('update'); await tick()
  t.is(runs, 2, 'the next change still projects')
  t.ok(said.some((m) => /sink respawning/.test(m)), 'the failure was said')
})

test('stop() detaches from the feed', async (t) => {
  const { node, base } = fakeNode()
  let runs = 0
  const w = await watchProjection(node, Promise.resolve({ nodes: [{}] }), { project: async () => { runs++ } })
  await tick()
  w.stop()
  base.emit('update'); await tick()
  t.is(runs, 1, 'only the boot pass')
})

// ⛔ AGAINST A REAL FEED. The tests above drive a fake emitter, and they passed while the watch was dead:
// autobee 2.12.1 emits 'update' ONLY on an interrupt (index.js:676), never when the view moves, so on
// tabertron the projection ran once at boot and never again. A post lands as an 'append' on a WRITER
// core — the trigger ap-bridge's FeedHolder already uses for held favela feeds.
test('a real post on a real favela feed triggers a projection pass', async (t) => {
  const { FavelaNode } = await import('favela-peer/lib/node.js')
  const { dispatch } = await import('favela-peer/lib/rpc.js')
  const { identity } = await import('favela-peer/test/helpers.js')
  const fs = (await import('bare-fs')).default
  const os = (await import('bare-os')).default
  const { join } = await import('bare-path')
  const storage = join(os.tmpdir(), 'fh-watch-real-' + Date.now() + '-' + Math.random().toString(16).slice(2))
  fs.mkdirSync(storage, { recursive: true })
  const n = new FavelaNode(storage, { transport: { async join () {}, async leave () {}, async destroy () {} } })
  await n.ready()
  t.teardown(() => n.close())
  const id = await identity()
  await n.link(await id.device())

  let runs = 0
  const w = await watchProjection(n, Promise.resolve({ nodes: [{}] }), { project: async () => { runs++ } })
  t.teardown(() => w.stop())
  await tick()
  t.is(runs, 1, 'the boot pass')

  await dispatch(n, 'post', { body: 'hello' })
  for (let i = 0; i < 50 && runs < 2; i++) await tick()
  t.ok(runs >= 2, 'the post triggered a pass (runs=' + runs + ')')
})

// ⛔ A REFUSAL IS SAID, ONCE. projectBsky returns { refused } without logging when it cannot choose an
// identity — on tabertron (two DIDs, bsky on, none designated) every pass refused and the log said
// nothing at all. Said once per distinct reason, so a refusing node does not spam every pass.
test('a refused pass is logged once per reason', async (t) => {
  const { node, base } = fakeNode()
  const said = []
  const w = await watchProjection(node, Promise.resolve({ nodes: [{}, {}] }), { project: async () => ({ refused: 'several identities, none designated' }), log: (m) => said.push(m) })
  t.teardown(() => w.stop())
  await tick()
  base.emit('update'); await tick()
  base.emit('update'); await tick()
  const lines = said.filter((m) => /several identities/.test(m))
  t.is(lines.length, 1, 'said once, not per pass')
})