| … | … | @@ -0,0 +1,145 @@ |
| 1 | // OTA end to end: a real HTTP forge, the real pgh-runtime, a real multi-megabyte bundle, real files. |
| 2 | // |
| 3 | // ⛔ WHY A SECOND OTA TEST. test/ota.test.mjs drives installTree against an in-memory `doFetch`, which |
| 4 | // proves the CONTRACT and nothing about the plumbing: bare-fetch's behaviour on a large body, the global |
| 5 | // fetch shim startOta installs, whether PghRuntime is wired to our fetcher and store correctly, whether |
| 6 | // anything is actually written to disk. Every one of those is where an integration breaks, and none of |
| 7 | // them are exercised by a fake fetch. |
| 8 | // |
| 9 | // It matters more than usual here because the code under test SHIPPED BROKEN AND SILENT for two weeks: |
| 10 | // pgh-runtime was deferred out of the bundle, so `startOta` returned null after logging "updates |
| 11 | // disabled" and nothing below it had ever executed. A test that stops at the module boundary is how that |
| 12 | // survives a second time. |
| 13 | // |
| 14 | // The forge served here implements exactly the two routes lib/ota.js calls, and nothing else. |
| 15 | import test from 'brittle' |
| 16 | import fs from 'bare-fs' |
| 17 | import path from 'bare-path' |
| 18 | import os from 'bare-os' |
| 19 | import http from 'bare-http1' |
| 20 | import { treeFor, bundleStore, startOta } from '../lib/ota.js' |
| 21 | import { chooseBundle } from '../lib/choose-bundle.js' |
| 22 | |
| 23 | // Multi-megabyte, so the transfer is a real streamed body rather than a string that fits in one chunk. |
| 24 | // Deterministic content: the suite must not depend on randomness to be reproducible. |
| 25 | function bigBundle (mb = 5) { |
| 26 | const buf = Buffer.alloc(mb * 1024 * 1024) |
| 27 | for (let i = 0; i < buf.length; i++) buf[i] = (i * 31 + 7) & 0xff |
| 28 | return buf |
| 29 | } |
| 30 | |
| 31 | let seq = 0 |
| 32 | function tmp (name) { |
| 33 | const dir = path.join(os.tmpdir(), 'favela-ota-e2e-' + name + '-' + (++seq)) |
| 34 | fs.rmSync(dir, { recursive: true, force: true }) |
| 35 | fs.mkdirSync(dir, { recursive: true }) |
| 36 | return dir |
| 37 | } |
| 38 | |
| 39 | // A forge with the two routes and nothing else — the same shape pear-git-v3 serves. |
| 40 | async function forge ({ repo, version, bundle, tree }) { |
| 41 | const server = http.createServer((req, res) => { |
| 42 | if (req.method === 'POST' && req.url.endsWith('/rpc/list-repo-releases')) { |
| 43 | const body = JSON.stringify({ versions: [{ version, builtAt: 2000 }, { version: 'older', builtAt: 1000 }] }) |
| 44 | res.writeHead(200, { 'content-type': 'application/json' }) |
| 45 | return res.end(body) |
| 46 | } |
| 47 | if (req.url.endsWith(`/releases/${repo}/${version}/tree.json`)) { |
| 48 | res.writeHead(200, { 'content-type': 'application/json' }) |
| 49 | return res.end(JSON.stringify(tree)) |
| 50 | } |
| 51 | if (req.url.endsWith(`/releases/${repo}/${version}/worklet.bundle`)) { |
| 52 | res.writeHead(200, { 'content-type': 'application/octet-stream' }) |
| 53 | return res.end(bundle) |
| 54 | } |
| 55 | res.writeHead(404, { 'content-type': 'application/json' }) |
| 56 | res.end('{"error":"not found"}') |
| 57 | }) |
| 58 | await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) |
| 59 | return { server, port: server.address().port } |
| 60 | } |
| 61 | |
| 62 | test('e2e: startOta downloads, verifies and STAGES a real bundle', async (t) => { |
| 63 | const storage = tmp('stage') |
| 64 | const repo = 'favela-android' |
| 65 | const version = 'master-deadbee' |
| 66 | const bundle = bigBundle(5) |
| 67 | const { entries, treeHash, contentHash } = treeFor(bundle) |
| 68 | |
| 69 | const { server, port } = await forge({ repo, version, bundle, tree: { treeHash, size: bundle.length, entries } }) |
| 70 | t.teardown(() => new Promise((resolve) => server.close(resolve))) |
| 71 | |
| 72 | // interval 0 disables the repeating timer; startImmediately (default true) still does one check, which |
| 73 | // is the whole flow a phone runs at boot. |
| 74 | const said = [] |
| 75 | const ota = await startOta({ |
| 76 | storage, |
| 77 | base: `http://127.0.0.1:${port}`, |
| 78 | repo, |
| 79 | interval: 0, |
| 80 | say: (m) => said.push(m) |
| 81 | }) |
| 82 | t.ok(ota, 'pgh-runtime is bundled and startOta returned a handle — the thing that was false for two weeks') |
| 83 | |
| 84 | t.ok(said.some((m) => /staged/.test(m)), 'it reports staging, not silence: ' + JSON.stringify(said)) |
| 85 | |
| 86 | // The bytes are on disk, byte-identical, under their content hash. |
| 87 | const store = bundleStore({ dir: path.join(storage, 'bundles') }) |
| 88 | t.ok(store.hasTree(treeHash), 'the store reports the tree as installed') |
| 89 | const staged = store.pathFor(treeHash) |
| 90 | t.is(path.basename(staged), contentHash + '.bundle', 'staged under its content hash') |
| 91 | t.alike(fs.readFileSync(staged), bundle, 'byte-identical to what the forge served') |
| 92 | |
| 93 | // current.json is what the React Native side reads before starting the worklet. Without it the download |
| 94 | // is invisible to the only code that can act on it. |
| 95 | const current = JSON.parse(fs.readFileSync(path.join(storage, 'bundles', 'current.json'), 'utf8')) |
| 96 | t.is(current.treeHash, treeHash, 'current.json names the staged tree') |
| 97 | t.is(current.file, contentHash + '.bundle') |
| 98 | |
| 99 | // …and the boot-time decision picks it up. This is the handoff between the two halves. |
| 100 | const choice = chooseBundle({ current, pending: null, failed: [] }) |
| 101 | t.is(choice.use, 'staged', 'the next boot would run it') |
| 102 | t.is(choice.hash, treeHash) |
| 103 | t.ok(choice.markPending, 'and would record the attempt first') |
| 104 | }) |
| 105 | |
| 106 | test('e2e: a forge serving the WRONG bytes stages nothing', async (t) => { |
| 107 | // The load-balanced-forge case, made concrete: the manifest is honest, the bundle is not. Releases are |
| 108 | // unsigned, so this hash check is the only thing standing between the phone and someone else's code. |
| 109 | const storage = tmp('tamper') |
| 110 | const repo = 'favela-android' |
| 111 | const version = 'master-deadbee' |
| 112 | const real = bigBundle(2) |
| 113 | const { entries, treeHash } = treeFor(real) |
| 114 | |
| 115 | const { server, port } = await forge({ |
| 116 | repo, version, bundle: bigBundle(3), tree: { treeHash, size: real.length, entries } |
| 117 | }) |
| 118 | t.teardown(() => new Promise((resolve) => server.close(resolve))) |
| 119 | |
| 120 | const said = [] |
| 121 | await startOta({ storage, base: `http://127.0.0.1:${port}`, repo, interval: 0, say: (m) => said.push(m) }) |
| 122 | |
| 123 | t.ok(said.some((m) => /content hash mismatch/.test(m)), 'the mismatch is REPORTED, not swallowed: ' + JSON.stringify(said)) |
| 124 | t.absent(said.some((m) => /staged/.test(m)), 'and nothing claims to have staged') |
| 125 | |
| 126 | const store = bundleStore({ dir: path.join(storage, 'bundles') }) |
| 127 | t.absent(store.hasTree(treeHash), 'nothing installed') |
| 128 | t.absent(fs.existsSync(path.join(storage, 'bundles', 'current.json')), 'and the next boot is told nothing') |
| 129 | }) |
| 130 | |
| 131 | test('e2e: an unreachable forge is a no-op, not a crashed boot', async (t) => { |
| 132 | // What a phone on a train does. `startOta` must never take the node down with it. |
| 133 | const storage = tmp('offline') |
| 134 | const said = [] |
| 135 | const ota = await startOta({ |
| 136 | storage, |
| 137 | base: 'http://127.0.0.1:1', // nothing listens here |
| 138 | repo: 'favela-android', |
| 139 | interval: 0, |
| 140 | say: (m) => said.push(m) |
| 141 | }) |
| 142 | t.ok(ota, 'still returns a handle') |
| 143 | t.absent(said.some((m) => /staged/.test(m)), 'nothing staged') |
| 144 | t.absent(fs.existsSync(path.join(storage, 'bundles', 'current.json')), 'and nothing is advertised to the next boot') |
| 145 | }) |