// The shell↔node handoff and the settings page's message bridge.
// Run: bare test/shell.test.mjs
//
// Both shapes exist because the SHELL and the BUNDLE version independently: the shell ships in an APK,
// the bundle arrives by OTA, and either can be older than the other. Every test below is a case where
// getting that wrong breaks a handset that cannot be reached with adb.
import { parseShell, encodeShell, shellState, parseShellMessage, SHELL_FILE, STATE_FILE } from '../lib/shell-handoff.js'
let pass = 0
const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m); pass++ }
const HASH = 'eaa75a4bd017a3e0ae030aaf0bb49eab655ebd6fa9fd82232e810e498e2e8647'
// ── the shell's handoff ──────────────────────────────────────────────────────────────────────────
// An OLD shell writes no file at all. The node must boot, and must not claim to know things it does not.
const none = parseShell(null)
ok(none.ota === false && none.bundle === null && none.dns.length === 0, 'no handoff => no capability, no bundle, no resolver')
ok(parseShell('{ this is not json').ota === false, 'a corrupt handoff reads as no handoff, never throws')
ok(parseShell('null').ota === false, 'JSON null reads as no handoff')
const full = parseShell(encodeShell({ ota: true, bundle: HASH, dns: ['192.168.1.1', '8.8.8.8'] }))
ok(full.ota === true && full.bundle === HASH && full.dns.length === 2, 'a full handoff round-trips')
// ⛔ The bundle field decides what pgh-runtime compares a release against. A non-hash slipping through
// would be compared against a release treeHash and never match — silently restoring the bug where every
// boot re-stages the bundle it is already running.
ok(parseShell(JSON.stringify({ bundle: 'apk' })).bundle === null, 'a non-hash bundle is dropped')
ok(parseShell(JSON.stringify({ bundle: 123 })).bundle === null, 'a numeric bundle is dropped')
ok(parseShell(encodeShell({ bundle: HASH.toUpperCase() })).bundle === HASH.toUpperCase(), 'hex case is preserved, not normalised away')
// ⛔ The dns list is handed to connect(). A hostname there would need a resolver to resolve — the very
// thing being obtained — and anything else is an injection surface into a socket call.
ok(parseShell(JSON.stringify({ dns: ['dns.example.com'] })).dns.length === 0, 'a hostname is not an address')
ok(parseShell(JSON.stringify({ dns: ['8.8.8.8', '', null, 42, '1.1.1.1'] })).dns.length === 2, 'junk entries are dropped, good ones kept')
ok(parseShell(JSON.stringify({ dns: '8.8.8.8' })).dns.length === 0, 'a bare string is not a list')
ok(parseShell(JSON.stringify({ dns: ['8.8.8.8:53'] })).dns.length === 0, 'a port is not part of an address — connect() takes the host alone')
ok(parseShell(JSON.stringify({ dns: ['999.1.1.1'] })).dns.length === 0, 'an octet over 255 is not an address')
ok(parseShell(JSON.stringify({ dns: ['::ffff:8.8.8.8'] })).dns.length === 1, 'an IPv4-mapped IPv6 address is one')
ok(parseShell(JSON.stringify({ dns: ['2606:4700:4700::1111'] })).dns.length === 1, 'IPv6 is an address')
ok(encodeShell({ ota: 'yes' }).includes('"ota":false'), 'ota is a boolean claim, not a truthy value')
// ⛔ `pair` IS ITS OWN CAPABILITY. It shipped a release after `ota`, and the page arrives by OTA while
// the shell arrives by APK — so a bundle can meet a shell that services update buttons and knows
// nothing about pairing. One flag for both would render a dead button on exactly that pairing.
ok(parseShell(JSON.stringify({ ota: true })).pair === false, 'an ota-capable shell is not assumed to be pair-capable')
ok(parseShell(encodeShell({ ota: true, pair: true })).pair === true, 'a shell that claims pair round-trips')
ok(shellState({ shell: parseShell(JSON.stringify({ ota: true })) }).pair === false, 'and the page is told so')
ok(shellState({ shell: parseShell(encodeShell({ pair: true })) }).pair === true, 'pair survives into the page state independently of ota')
// ── what the settings page renders ───────────────────────────────────────────────────────────────
// ⛔ `running` comes from the SHELL. The OTA store only knows what is staged; deriving "running" from it
// is exactly how the OTA log came to announce it had staged the bundle it was already executing.
const st = shellState({ shell: full, staged: HASH, checkedAt: '2026-08-24T21:41:31.000Z' })
ok(st.running === HASH && st.staged === HASH, 'running and staged are reported independently')
ok(st.ota === true, 'the page renders its buttons only on the shell capability')
ok(shellState({ shell: none, staged: HASH }).ota === false, 'an old shell gets no buttons even when an update is staged')
ok(shellState({}).running === null && shellState({}).staged === null, 'no shell, no state — never a guess')
ok(shellState({ shell: full, staged: 'nonsense' }).staged === null, 'a malformed staged marker is not shown as an update')
ok(shellState({ shell: full, checkedAt: 12345 }).checkedAt === null, 'checkedAt is an ISO string or nothing (favela-web parses it)')
// ── the bridge ───────────────────────────────────────────────────────────────────────────────────
// This parses a string from a WEB PAGE that reaches a function which restarts the node. Two verbs,
// allow-listed: no op name is ever passed through to a handler map.
ok(parseShellMessage(JSON.stringify({ t: 'app-update', op: 'check' })).op === 'check', 'check is accepted')
ok(parseShellMessage(JSON.stringify({ t: 'app-update', op: 'restart' })).op === 'restart', 'restart is accepted')
ok(parseShellMessage(JSON.stringify({ t: 'app-update', op: 'pair' })).op === 'pair', 'pair is accepted')
ok(parseShellMessage(JSON.stringify({ t: 'app-update', op: 'compose' })) === null, 'an unknown op is refused, not forwarded')
ok(parseShellMessage(JSON.stringify({ t: 'other', op: 'restart' })) === null, 'another message type is not an update command')
ok(parseShellMessage('restart') === null, 'a bare string is not a command')
ok(parseShellMessage('{"t":"app-update"') === null, 'malformed JSON is refused, never throws')
ok(parseShellMessage(null) === null, 'null is refused')
ok(parseShellMessage(JSON.stringify({ t: 'app-update', op: ['check'] })) === null, 'a non-string op is refused')
// ── the two filenames are a contract with favela-web and the RN side ─────────────────────────────
ok(SHELL_FILE === 'shell.json' && STATE_FILE === 'app-update.json', 'the handoff filenames are stable')
console.log('\n' + pass + ' assertions passed')