// Who decides whether the node keeps running when the screen goes off.
//
// ⛔ THE CONTROL BELOW IS THE BUG. react-native-bare-kit ends its module body with
//
// AppState.addEventListener('change', BareKitWorklet.update.bind(BareKitWorklet))
//
// and `update(state)` is `case 'background': return this.suspend()` — so EVERY worklet is suspended the
// moment the app is backgrounded, which on Android is every screen lock. That is a deliberate library
// policy for an app whose worklet serves a UI, and exactly wrong for one whose worklet IS a peer. It also
// means a foreground service on its own fixes nothing: it would keep a SUSPENDED node alive.
//
// So the first test replicates the library's update() verbatim to pin what we are overriding, and the
// rest assert the policy that replaces it: suspend follows whether we are ALLOWED to keep running (a
// live foreground service), not whether a screen is on.
import test from 'brittle'
import { createLifecycle } from '../lib/lifecycle.js'
import { chooseBundle } from '../lib/choose-bundle.js'
// A stand-in for react-native-bare-kit's Worklet, carrying its real suspend/resume semantics — including
// `update`, which is the method the library's AppState listener calls and the one we take over.
function fakeWorklet () {
const w = {
suspended: false,
terminated: false,
calls: [],
suspend () {
if (w.terminated) throw new Error('Worklet has been terminated')
w.calls.push('suspend'); w.suspended = true
},
resume () {
if (w.terminated) throw new Error('Worklet has been terminated')
w.calls.push('resume'); w.suspended = false
},
// react-native-bare-kit/index.js, verbatim.
update (state) {
switch (state) {
case 'active': return w.resume()
case 'background': return w.suspend()
}
}
}
return w
}
function fakeService (startResult) {
return {
started: 0,
stopped: 0,
async start () { this.started++; return startResult },
async stop () { this.stopped++ }
}
}
test('CONTROL: the library suspends the node on background — this is what we are replacing', (t) => {
const w = fakeWorklet()
w.update('background')
t.is(w.suspended, true, 'a screen lock suspends the node')
t.alike(w.calls, ['suspend'])
})
test('with the foreground service running, backgrounding does NOT suspend the node', async (t) => {
const w = fakeWorklet()
const svc = fakeService(true)
const lc = createLifecycle({ worklet: w, service: svc })
t.is(await lc.start(), true, 'the service started')
t.is(svc.started, 1)
// Both paths: the library's own listener (which calls worklet.update) and ours.
w.update('background')
await lc.onAppState('background')
t.is(w.suspended, false, 'the node keeps running with the screen off')
t.alike(w.calls, [], 'neither suspend nor resume was called')
})
test('without the service, the library behaviour is kept — a cached process must not run a peer', async (t) => {
const w = fakeWorklet()
const svc = fakeService(false)
const lc = createLifecycle({ worklet: w, service: svc })
t.is(await lc.start(), false, 'the service refused to start')
await lc.onAppState('background')
t.is(w.suspended, true, 'backgrounded without a service, the node suspends as before')
await lc.onAppState('active')
t.is(w.suspended, false, 'and resumes when the app comes back')
})
test('backgrounded BEFORE the service answers, then the service starts: the node is resumed', async (t) => {
const w = fakeWorklet()
const svc = fakeService(true)
const lc = createLifecycle({ worklet: w, service: svc })
// The user locks the screen during the ~40s boot: the library's listener already suspended us.
w.update('background')
t.is(w.suspended, true)
await lc.start()
t.is(w.suspended, false, 'taking over means undoing a suspend we would not have made')
})
test('stopping the node stops the service, and the fallback policy comes back with it', async (t) => {
const w = fakeWorklet()
const svc = fakeService(true)
const lc = createLifecycle({ worklet: w, service: svc })
await lc.start()
await lc.stop()
t.is(svc.stopped, 1, 'the notification goes away')
await lc.onAppState('background')
t.is(w.suspended, true, 'no service, no exemption')
})
test('a terminated worklet is never suspended or resumed', async (t) => {
const w = fakeWorklet()
const lc = createLifecycle({ worklet: w, service: fakeService(false) })
await lc.start()
w.terminated = true
await lc.onAppState('background')
await lc.onAppState('active')
t.alike(w.calls, [], 'no calls survive termination — they would throw')
})
test('a service that throws is a service that did not start, not a crashed boot', async (t) => {
const w = fakeWorklet()
const said = []
const svc = { async start () { throw new Error('FGS start not allowed') }, async stop () {} }
const lc = createLifecycle({ worklet: w, service: svc, log: (m) => said.push(m) })
t.is(await lc.start(), false)
t.ok(said.some((m) => /FGS start not allowed/.test(m)), 'and it SAYS why, or nobody will ever know')
await lc.onAppState('background')
t.is(w.suspended, true, 'falling back to the library policy')
})
// ⛔ A STAGED BUNDLE WITH NO PROVENANCE MUST NOT OUTRANK AN EXPLICIT APK INSTALL.
//
// The HTTPS fetcher's getLatestRelease returned { name, version, treeHash } and NO builtAt, so every
// bundle it staged got a marker with no build time. chooseBundle required BOTH times to be present
// before letting the APK win, so those bundles became permanently unbeatable: a phone would install a
// newer APK, boot, and keep running JS staged weeks earlier.
//
// Observed on the handset 2026-08-25 and it cost most of an evening: four APKs in a row installed
// cleanly and none of their fixes took effect, because a bundle staged from 0.1.6 won every boot. The
// symptom is the worst kind — the install succeeds, the version looks right, and the code does not run.
//
// An APK install is a PHYSICAL, DELIBERATE act by someone holding the device. A staged bundle that
// cannot say when it was built cannot be shown to be newer than that. So the tie now goes to the APK.
test('a stamped APK beats a staged bundle that cannot say when it was built', (t) => {
const r = chooseBundle({
current: { treeHash: 'aaa', file: 'aaa.bundle', builtAt: null }, // staged by the HTTPS client
assetBuiltAt: 1787708000000
})
t.is(r.use, 'asset', 'the freshly installed APK wins')
t.ok(/older|no build time|updated/i.test(r.reason), 'and says why: ' + r.reason)
})
test('but a staged bundle that IS newer still wins — OTA must keep working', (t) => {
const r = chooseBundle({
current: { treeHash: 'bbb', file: 'bbb.bundle', builtAt: 1787709000000 },
assetBuiltAt: 1787708000000
})
t.is(r.use, 'staged', 'a genuinely newer staged bundle is still preferred')
})
test('with NO APK stamp, nothing is orderable and the historic behaviour stands', (t) => {
const r = chooseBundle({
current: { treeHash: 'ccc', file: 'ccc.bundle', builtAt: null },
assetBuiltAt: null
})
t.is(r.use, 'staged', 'an older shell that cannot stamp its asset does not get a surprise downgrade')
})