OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android / test / lifecycle.test.mjs

179 lines · javascript ref:
// 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')
})