// OTA end to end: a real HTTP forge, the real pgh-runtime, a real multi-megabyte bundle, real files.
//
// ⛔ WHY A SECOND OTA TEST. test/ota.test.mjs drives installTree against an in-memory `doFetch`, which
// proves the CONTRACT and nothing about the plumbing: bare-fetch's behaviour on a large body, the global
// fetch shim startOta installs, whether PghRuntime is wired to our fetcher and store correctly, whether
// anything is actually written to disk. Every one of those is where an integration breaks, and none of
// them are exercised by a fake fetch.
//
// It matters more than usual here because the code under test SHIPPED BROKEN AND SILENT for two weeks:
// pgh-runtime was deferred out of the bundle, so `startOta` returned null after logging "updates
// disabled" and nothing below it had ever executed. A test that stops at the module boundary is how that
// survives a second time.
//
// The forge served here implements exactly the two routes lib/ota.js calls, and nothing else.
import test from 'brittle'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import http from 'bare-http1'
import { treeFor, bundleStore, startOta, bundleFetcher } from '../lib/ota.js'
// startOta takes an INJECTED fetcher now — the same seam pgh-runtime uses. Production builds a swarm
// one (Hyperswarm + hyper-overlay); this suite injects HTTP so the runtime/store wiring is exercised
// without a DHT. That wiring is what the two-week 'OTA never ran' bug hid in, and it is transport-
// independent, so testing it over a local forge is faster and hermetic.
await import('bare-fetch/global') // startOta no longer installs a fetch shim; it has no HTTP path
import { chooseBundle } from '../lib/choose-bundle.js'
// Multi-megabyte, so the transfer is a real streamed body rather than a string that fits in one chunk.
// Deterministic content: the suite must not depend on randomness to be reproducible.
function bigBundle (mb = 5) {
const buf = Buffer.alloc(mb * 1024 * 1024)
for (let i = 0; i < buf.length; i++) buf[i] = (i * 31 + 7) & 0xff
return buf
}
let seq = 0
function tmp (name) {
const dir = path.join(os.tmpdir(), 'favela-ota-e2e-' + name + '-' + (++seq))
fs.rmSync(dir, { recursive: true, force: true })
fs.mkdirSync(dir, { recursive: true })
return dir
}
// A forge with the two routes and nothing else — the same shape pear-git-v3 serves.
async function forge ({ repo, version, bundle, tree }) {
const server = http.createServer((req, res) => {
if (req.method === 'POST' && req.url.endsWith('/rpc/release-index')) {
// The real shape: { result: { files: [...] } }, one entry per artifact, so the client can tell a
// complete release from a half-replicated one.
const files = [
{ path: `${repo}/versions/${version}/tree.json`, repo, version, mtimeMs: 2000 },
{ path: `${repo}/versions/${version}/worklet.bundle`, repo, version, mtimeMs: 2000 },
{ path: `${repo}/versions/older/favela-android.apk`, repo, version: 'older', mtimeMs: 9000 }
]
res.writeHead(200, { 'content-type': 'application/json' })
return res.end(JSON.stringify({ result: { files } }))
}
if (req.url.endsWith(`/releases/${repo}/${version}/tree.json`)) {
res.writeHead(200, { 'content-type': 'application/json' })
return res.end(JSON.stringify(tree))
}
if (req.url.endsWith(`/releases/${repo}/${version}/worklet.bundle`)) {
res.writeHead(200, { 'content-type': 'application/octet-stream' })
return res.end(bundle)
}
res.writeHead(404, { 'content-type': 'application/json' })
res.end('{"error":"not found"}')
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
return { server, port: server.address().port }
}
test('e2e: startOta downloads, verifies and STAGES a real bundle', async (t) => {
const storage = tmp('stage')
const repo = 'favela-android'
const version = 'master-deadbee'
const bundle = bigBundle(5)
const { entries, treeHash, contentHash } = treeFor(bundle)
const { server, port } = await forge({ repo, version, bundle, tree: { treeHash, size: bundle.length, entries } })
t.teardown(() => new Promise((resolve) => server.close(resolve)))
// interval 0 disables the repeating timer; startImmediately (default true) still does one check, which
// is the whole flow a phone runs at boot.
const said = []
const ota = await startOta({
storage,
fetcher: bundleFetcher({ base: `http://127.0.0.1:${port}`, repo, doFetch: globalThis.fetch }),
repo,
interval: 0,
say: (m) => said.push(m)
})
t.ok(ota, 'pgh-runtime is bundled and startOta returned a handle — the thing that was false for two weeks')
t.ok(said.some((m) => /staged/.test(m)), 'it reports staging, not silence: ' + JSON.stringify(said))
// The bytes are on disk, byte-identical, under their content hash.
const store = bundleStore({ dir: path.join(storage, 'bundles') })
t.ok(store.hasTree(treeHash), 'the store reports the tree as installed')
const staged = store.pathFor(treeHash)
t.is(path.basename(staged), contentHash + '.bundle', 'staged under its content hash')
t.alike(fs.readFileSync(staged), bundle, 'byte-identical to what the forge served')
// current.json is what the React Native side reads before starting the worklet. Without it the download
// is invisible to the only code that can act on it.
const current = JSON.parse(fs.readFileSync(path.join(storage, 'bundles', 'current.json'), 'utf8'))
t.is(current.treeHash, treeHash, 'current.json names the staged tree')
t.is(current.file, contentHash + '.bundle')
// …and the boot-time decision picks it up. This is the handoff between the two halves.
const choice = chooseBundle({ current, pending: null, failed: [] })
t.is(choice.use, 'staged', 'the next boot would run it')
t.is(choice.hash, treeHash)
t.ok(choice.markPending, 'and would record the attempt first')
})
test('e2e: a forge serving the WRONG bytes stages nothing', async (t) => {
// The load-balanced-forge case, made concrete: the manifest is honest, the bundle is not. Releases are
// unsigned, so this hash check is the only thing standing between the phone and someone else's code.
const storage = tmp('tamper')
const repo = 'favela-android'
const version = 'master-deadbee'
const real = bigBundle(2)
const { entries, treeHash } = treeFor(real)
const { server, port } = await forge({
repo, version, bundle: bigBundle(3), tree: { treeHash, size: real.length, entries }
})
t.teardown(() => new Promise((resolve) => server.close(resolve)))
const said = []
await startOta({ storage, fetcher: bundleFetcher({ base: `http://127.0.0.1:${port}`, repo, doFetch: globalThis.fetch }), repo, interval: 0, say: (m) => said.push(m) })
t.ok(said.some((m) => /content hash mismatch/.test(m)), 'the mismatch is REPORTED, not swallowed: ' + JSON.stringify(said))
t.absent(said.some((m) => /staged/.test(m)), 'and nothing claims to have staged')
const store = bundleStore({ dir: path.join(storage, 'bundles') })
t.absent(store.hasTree(treeHash), 'nothing installed')
t.absent(fs.existsSync(path.join(storage, 'bundles', 'current.json')), 'and the next boot is told nothing')
})
test('e2e: an unreachable forge is a no-op, not a crashed boot', async (t) => {
// What a phone on a train does. `startOta` must never take the node down with it.
const storage = tmp('offline')
const said = []
const ota = await startOta({
storage,
fetcher: bundleFetcher({ base: 'http://127.0.0.1:1', repo: 'favela-android', doFetch: globalThis.fetch }), // nothing listens here
repo: 'favela-android',
interval: 0,
say: (m) => said.push(m)
})
t.ok(ota, 'still returns a handle')
t.absent(said.some((m) => /staged/.test(m)), 'nothing staged')
t.absent(fs.existsSync(path.join(storage, 'bundles', 'current.json')), 'and nothing is advertised to the next boot')
})
// ⛔ stop() MUST RELEASE WHAT startOta OPENED, and must not release what it did not.
//
// The production fetcher is a Hyperswarm node plus a corestore. stop() used to halt only the poll loop,
// so a phone that switched updates off kept a DHT node and its sockets alive — the same shape as the
// hyper-svc UDS leak that reached ~205k sockets on stortron. Caught empirically: the real swarm probe
// staged its bundle in 23.5s and then never exited (timeout killed it at 600s).
//
// The owned-fetcher path needs a real DHT, so it is proven by that probe exiting cleanly. What IS
// unit-testable is the ownership rule, and it is the half that silently corrupts a test suite: closing a
// handle the caller still owns.
test('stop() does not close an INJECTED fetcher — the caller owns it', async (t) => {
const storage = tmp('ownership')
let closed = 0
const inert = {
async getLatestRelease () { return null },
async getTree () { throw new Error('not used') },
async getContent () { throw new Error('not used') },
async close () { closed++ }
}
const ota = await startOta({ storage, fetcher: inert, repo: 'favela-android', interval: 0, say: () => {} })
t.ok(ota, 'startOta returned a handle with an injected fetcher')
await ota.stop()
t.is(closed, 0, 'the injected fetcher was NOT closed — its owner closes it')
})