ReviewOS

also looking at this

craft-native/craft

test(ios): verify app termination before local notification delivery

#308
Merged glennmichael123 wants to merge codex/ios-notification-deadline into main
5 files +96 -21
scripts/mobile-e2e/protocol.test.tsmodified+57-1
Changes to scripts/mobile-e2e/protocol.test.ts
@@ -2,12 +2,69 @@ import { describe, expect, it } from 'bun:test'
22import { readFileSync } from 'node:fs'
33import { join } from 'node:path'
44import { ANDROID_DECLINE_PHRASES, androidDeclines, awaitedNeeds, deepLinkProblems, deepLinkReports, deepLinkResults, DISMISS_SHARE_MENU, elfSectionNames, evaluateRun, hasTerminated, localNotificationProblems, NOTIFICATION_ROUTE, notificationBody, notificationPayload, notificationReceiptProblems, notificationReceiptReport, notificationTapProblems, notificationTapReport, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, runtimePermissionGranted, shareMenuInFront, strippedLibraryProblems, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol'
5import { LOCAL_NOTIFICATION_DELAY_MS, NOTIFICATION_TEST_TIMEOUT_MS } from './protocol'
56
67const ESC = String.fromCharCode(27)
78
89/** A transcript shaped like the one a real iOS run produces. */
910const RUN = 'craft-e2e-ios-shim-1234'
1011
12describe('local notification scheduling window', () => {
13 const page = readFileSync(join(import.meta.dir, 'driver.html'), 'utf8')
14 const start = page.indexOf(" if (launch && launch.url.indexOf('://e2e/local')")
15 const end = page.indexOf(' var onTap', start)
16 if (start < 0 || end < 0) throw new Error('Cannot find the actual driver scheduling block')
17 const schedule = new Function('window', 'document', 'launch', 'NONCE', 'Date', 'describeError', page.slice(start, end))
18
19 it('keeps the host budget large enough for the bounded delivery window', () => {
20 expect(LOCAL_NOTIFICATION_DELAY_MS).toBe(120_000)
21 expect(NOTIFICATION_TEST_TIMEOUT_MS - LOCAL_NOTIFICATION_DELAY_MS).toBeGreaterThanOrEqual(300_000)
22 const host = readFileSync(join(import.meta.dir, 'ios.ts'), 'utf8')
23 expect(host).toContain('&delayMs=${LOCAL_NOTIFICATION_DELAY_MS}')
24 expect(host).toContain('Date.now() + NOTIFICATION_TEST_TIMEOUT_MS')
25 })
26
27 it('reports the earliest delivery deadline, not a fresh window after a delayed callback', async () => {
28 const element = { textContent: '' }
29 let options: any
30 let now = 1000
31 let resolve!: () => void
32 const pending = new Promise<void>((done) => { resolve = done })
33 schedule({ craft: { notifications: { schedule(value: unknown) { options = value; return pending } } } },
34 { getElementById: () => element }, { url: 'crafte2eprobe://e2e/local?delayMs=120000' }, RUN, { now: () => now }, String)
35 expect(options.delay).toBe(120_000)
36 expect(options.data).toEqual({ craftE2E: RUN, stage: 'local', nested: { kept: true } })
37 expect(element.textContent).toBe('')
38 now += 16_000
39 resolve()
40 await pending
41 expect(element.textContent).toBe('CRAFT-E2E-LOCAL-SCHEDULED ok 121000')
42 // Even after the observed 16-second UI lookup and 32-second termination,
43 // the deadline is still ahead. The old 8-second window failed this case.
44 expect(121_000 - (now + 16_000 + 32_000)).toBeGreaterThan(0)
45 })
46
47 it('refuses missing, malformed or unbounded scheduling delays', () => {
48 for (const query of ['', '?delayMs=NaN', '?delayMs=0', '?delayMs=8000', '?delayMs=180001']) {
49 const element = { textContent: '' }
50 let called = false
51 schedule({ craft: { notifications: { schedule() { called = true; return Promise.resolve() } } } },
52 { getElementById: () => element }, { url: `crafte2eprobe://e2e/local${query}` }, RUN, Date, String)
53 expect(called).toBe(false)
54 expect(element.textContent).toContain('CRAFT-E2E-LOCAL-SCHEDULED rejected')
55 }
56 })
57
58 it('reports native scheduling rejection without claiming a valid deadline', async () => {
59 const element = { textContent: '' }
60 const rejected = Promise.reject(new Error('permission denied'))
61 schedule({ craft: { notifications: { schedule: () => rejected } } },
62 { getElementById: () => element }, { url: 'crafte2eprobe://e2e/local?delayMs=120000' }, RUN, Date, (error: Error) => error)
63 await rejected.catch(() => {})
64 expect(element.textContent).toBe('CRAFT-E2E-LOCAL-SCHEDULED rejected permission denied')
65 })
66})
67
1168function iosTranscript(overrides: { cases?: string[], omit?: string[], fail?: Record<string, string>, done?: boolean, run?: string } = {}): string {
1269 const planned = overrides.cases ?? REQUIRED_CASES.ios
1370 const omit = overrides.omit ?? []
formatting only · 4 hidden lines
Changes to scripts/mobile-e2e/protocol.test.ts
@@ -675,4 +732,3 @@ describe('android declines', () => { formatting only
675732 expect(dispatch).toContain(`" ${phrase} ({s})"`)
676733 })
677734})
678