OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / test / rpc-adapter.test.js

72 lines · javascript ref:
// The socket is an ADAPTER, so it must take the interface rather than assume it.
//
// `favela-surface/index.js` already states the split this rests on: "favela-peer's `lib/rpc.js`
// exports `dispatch(node, cmd, a)` alongside `createServer(node, sock)`: `dispatch` is the interface
// and the socket is one ADAPTER over it." But `createServer` called `dispatch` directly, so the
// sentence was only half true — a composition that adds verbs could serve them over HTTP, as the
// Android worklet does, and had no way to serve the same set over the socket.
//
// ⛔ THIS TEACHES favela-peer NOTHING. It does not learn what the extra verbs are, or that any
// exist; it learns only that the thing it dispatches to is supplied rather than assumed. The daemon
// still knows nothing about projections, which is what test/boundary.test.js is there to hold.
//
// ⚠ Why it matters: on Android the surface and the daemon are one process and there is no socket at
// all (SELinux refuses an unprivileged app a `sock_file`), so the worklet composes with an in-process
// client. A desktop host wants the SAME composition plus a socket, for `favela-bridge` and the CLI.
// Without this, the desktop could only have the composition by re-implementing the server.

import test from 'brittle'
import fs from '#node:fs'
import os from '#node:os'
import net from '#node:net'
import { join } from '#node:path'
import { createServer } from '../lib/rpc.js'

const sockPath = () => join(fs.mkdtempSync(join(os.tmpdir(), 'favela-rpc-')), 'favela.sock')

function ask (path, cmd, args = {}) {
  return new Promise((resolve, reject) => {
    const socket = net.connect(path)
    let buf = ''
    socket.on('error', reject)
    socket.on('connect', () => socket.write(JSON.stringify({ id: 1, cmd, args }) + '\n'))
    socket.on('data', (chunk) => {
      buf += chunk.toString()
      const i = buf.indexOf('\n')
      if (i === -1) return
      socket.destroy()
      resolve(JSON.parse(buf.slice(0, i)))
    })
  })
}

test('a supplied call handles the request instead of dispatch', async (t) => {
  const sock = sockPath()
  const seen = []
  const server = createServer(null, sock, {
    call: async (cmd, args) => { seen.push(cmd); return { echoed: cmd, got: args.n } }
  })
  t.teardown(() => { try { server.close() } catch {} })

  const res = await ask(sock, 'a-verb-dispatch-does-not-have', { n: 7 })

  t.ok(res.ok, 'the request succeeded, where dispatch would have thrown "unknown command"')
  t.is(res.value.echoed, 'a-verb-dispatch-does-not-have', 'the supplied call saw the verb')
  t.is(res.value.got, 7, 'and its arguments')
  t.alike(seen, ['a-verb-dispatch-does-not-have'], 'exactly once')
})

test('with no call supplied it still dispatches, so nothing that exists changes', async (t) => {
  const sock = sockPath()
  // ⚠ A stub with a `peer`, because `dispatch` reads `node.peer` before the switch — so a null node
  // throws a TypeError on the way IN and would not prove which arm answered. An unknown verb is not
  // in MUTATIONS, so nothing else about the node is touched before the default arm refuses it.
  const server = createServer({ peer: {} }, sock, {})
  t.teardown(() => { try { server.close() } catch {} })

  const res = await ask(sock, 'definitely-not-a-verb')

  t.absent(res.ok, 'it failed, as before')
  t.ok(/unknown command/.test(res.error), 'and failed through dispatch: ' + res.error)
})