OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android / test / ipc.test.mjs

177 lines · javascript ref:
// The IPC contract, driven against the REAL worklet.
//
// ⛔ NOT A MOCK. lib/node.js is loaded and run for real — it boots an appview on a real Corestore, and
// this drives it through lib/ipc-client.js exactly as React Native will. A mock would prove the client
// talks to the mock; this proves the two halves of the protocol agree, which is the only useful claim.
//
// This is also what lets the whole RN data layer be built and verified with NO device, NO emulator and
// NO Metro. The rendering surface is the only part that genuinely needs a phone.
//
// Run: bare test/ipc.test.mjs
import process from 'bare-process'
import fsm from 'bare-fs'
import os from 'bare-os'
import pathm from 'bare-path'
import { WorkletClient } from '../lib/ipc-client.js'
import { encodeUtf8, createUtf8Decoder } from '../lib/utf8.js'

const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log('  ok -', m) }

// ── in-process transport ─────────────────────────────────────────────────────────────────────────────
// The FRAMING is real — the same line-delimited JSON crosses — only the pipe is short-circuited.
//
// ⛔ THIS FAKE MUST MIRROR bare-kit, NOT lib/node.js. The previous version was written to match the
// code it was testing: it exposed `read(cb)` and stringified writes with `String(buf)`. Both are wrong,
// and because the fake agreed with the code, the suite passed while the app was dead on a real phone —
// the worklet consumed zero inbound bytes, and the RN side turned every reply into "123,34,116,...".
//
// The real contract (react-native-bare-kit/index.js): IPC is a streamx Duplex. `_read` pushes a plain
// `Uint8Array` (so consumers use `.on('data')`, never `read(cb)`), and `_write` reads `.buffer` /
// `.byteOffset` / `.byteLength`, so ONLY a TypedArray may be written. Bytes on the wire, both ways.
let workletOnData = null
const clientInbox = []

globalThis.BareKit = {
  IPC: {
    write (data) {
      assert(ArrayBuffer.isView(data), 'worklet wrote a TypedArray — bare-kit can write nothing else')
      clientInbox.push(new Uint8Array(data.buffer, data.byteOffset, data.byteLength))
      flush()
    },
    on (event, cb) { if (event === 'data') workletOnData = cb }
  }
}

let client = null
// The RN side does exactly this decode (app/worklet.js) — stateful, because chunk boundaries are not
// character boundaries.
const decode = createUtf8Decoder()
const flush = () => { while (clientInbox.length && client) client.receive(decode(clientInbox.shift())) }

// Android hands storage in as the last argv entry; the worklet reads it from there.
const dir = fsm.mkdtempSync(pathm.join(os.tmpdir(), 'ipc-'))
globalThis.Bare.argv.push(dir)

client = new WorkletClient((line) => {
  // Deliver to the worklet's reader on a later tick — a synchronous re-entry would not resemble a pipe.
  // Encoded, because the app encodes: the worklet is handed bytes, never a string.
  setTimeout(() => { if (workletOnData) workletOnData(encodeUtf8(line)) }, 0)
}, { timeout: 60_000 })

// A subscriber that cannot unsubscribe is a leak with a UI attached: the pairing screen subscribes to
// `log` for the ceremony's progress on EVERY attempt, so a failed pair followed by a retry would stack
// listeners on the same client and fan out every later line to all of them.
console.log('\n# a listener can be removed again')
{
  let n = 0
  const fn = () => { n++ }
  client.on('log', fn)
  client.off('log', fn)
  client._emit('log', { msg: 'after off' })
  assert(n === 0, 'off() removes the listener')
  client.on('log', fn)
  client._emit('log', { msg: 'after on' })
  assert(n === 1, 'and on() still works afterwards — off removed one listener, not the list')
  client.off('log', fn)
}

const logs = []
client.on('log', (m) => logs.push(m.msg || JSON.stringify(m)))

console.log('\n# the worklet boots and announces itself')
// ⛔ NEVER THE DEFAULT PORT. lib/node.js boots a REAL favela-web, and its default (4041) is the port the
// deployed favela-web listens on — so on any machine actually running favela (i.e. every machine anyone
// develops this on) the suite died with EADDRINUSE partway through, after minutes of real Corestore work.
// It reads as a broken test and is a port collision with production.
//
// Assigned, not random: a random port makes a rerun a different test, and a collision a coin-flip that
// reproduces once in a hundred runs. `||=` so CI or a developer can still override.
process.env.FAVELA_PORT = process.env.FAVELA_PORT || '4147'
await import('../lib/node.js')
await client.whenReady({ timeout: 120_000 })
assert(client.ready === true, 'client saw the ready signal')
assert(logs.some((l) => /booting/.test(l)), 'boot was logged: ' + JSON.stringify(logs.slice(0, 2)))

console.log('\n# status')
{
  const s = await client.status()
  assert(s.ready === true, 'status.ready')
  assert(s.storage === dir, 'status reports the storage handed in via argv — the env-var replacement')
  // No DID is paired in this fixture, so the node must report itself read-only rather than pretending.
  assert(s.signing === false, 'signing:false with no DID paired — honest about being read-only')
}

console.log('\n# collections — the #33 ledger, reachable from the phone')
{
  const c = await client.collections()
  assert(Array.isArray(c.ledger), 'collections returns the ingest ledger')
}

// ⛔ THE READ SURFACE IS NO LONGER AN IPC VERB. timeline/thread/replies/search/follows/followers/
// people/tag/author/profile moved to favela-web's JSON routes (favela-web 0.3.51+), reached through
// lib/favela-api.js — one engine, one index, one definition. Their absence here is the POINT: a verb
// that still answered would be a second implementation reading a second appview, which is the bug this
// port exists to remove. lib/favela-api.js has its own test; screens call it, never this client.
console.log('\n# the read verbs are GONE — the routes own that surface now')
{
  for (const dead of ['timeline', 'thread', 'replies', 'search', 'follows', 'followers', 'people', 'tag', 'author', 'profile']) {
    assert(typeof client[dead] !== 'function', dead + ' is no longer an IPC verb')
  }
}

console.log('\n# the WRITE surface refuses cleanly on a node with no identity')
{
  // The fixture has no DID paired, so every write must say so — not throw a TypeError from deep inside
  // the writer, and above all not half-write. This is the state a fresh install is actually in.
  for (const [op, params] of [['compose', { text: 'hi' }], ['deletePost', { rkey: 'x' }], ['follow', { did: 'did:plc:someone' }]]) {
    let threw = null
    try { await client.request(op, params) } catch (e) { threw = e }
    assert(threw !== null && /no identity yet|pair it/.test(threw.message), op + ' refuses with the pairing message: ' + (threw && threw.message))
  }
}

console.log('\n# an unknown op is an error, not a hang')
{
  let threw = null
  try { await client.request('nonsense') } catch (e) { threw = e }
  assert(threw !== null, 'rejects')
  assert(/unknown op/.test(threw.message), 'and names the problem: ' + threw.message)
}

console.log('\n# id correlation — concurrent requests do not cross')
{
  // Three DIFFERENT ops in flight at once — the point is that each reply finds its own caller, so they
  // must be distinguishable by shape, not just by count.
  const [a, b, c] = await Promise.all([client.status(), client.hostedDids(), client.trackedDids()])
  assert(a.ready === true && Array.isArray(b.dids) && Array.isArray(c.dids),
    'three in flight resolve to their OWN replies')
}

console.log('\n# partial lines are buffered — chunk boundaries are not message boundaries')
{
  // Feed a reply split mid-JSON. A client that treats each chunk as a frame drops or corrupts this.
  const probe = new WorkletClient(() => {})
  const p = probe.request('x')
  const id = 1
  const reply = JSON.stringify({ id, result: { ok: true } }) + '\n'
  probe.receive(reply.slice(0, 7))
  probe.receive(reply.slice(7, 15))
  probe.receive(reply.slice(15))
  const r = await p
  assert(r.ok === true, 'a reply split across three chunks resolves correctly')
}

console.log('\n# a dead worklet fails pending requests instead of stranding the UI')
{
  const probe = new WorkletClient(() => {})
  const p = probe.request('status')
  probe.close('transport gone')
  let threw = null
  try { await p } catch (e) { threw = e }
  assert(threw !== null && /transport gone/.test(threw.message), 'pending request rejected on close: ' + (threw && threw.message))
}

try { fsm.rmSync(dir, { recursive: true, force: true }) } catch {}
console.log('\nIPC OK: client and worklet agree on the wire, verified against the real node')
process.exit(0)