also looking at this
test(ios): verify app termination before local notification delivery
#308
5 files
+96
-21
| @@ -2,12 +2,69 @@ import { describe, expect, it } from 'bun:test' | ||
| 2 | 2 | import { readFileSync } from 'node:fs' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | 4 | import { 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' |
| 5 | import { LOCAL_NOTIFICATION_DELAY_MS, NOTIFICATION_TEST_TIMEOUT_MS } from './protocol' | |
| 5 | 6 | |
| 6 | 7 | const ESC = String.fromCharCode(27) |
| 7 | 8 | |
| 8 | 9 | /** A transcript shaped like the one a real iOS run produces. */ |
| 9 | 10 | const RUN = 'craft-e2e-ios-shim-1234' |
| 10 | 11 | |
| 12 | describe('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 | ||
| 11 | 68 | function iosTranscript(overrides: { cases?: string[], omit?: string[], fail?: Record<string, string>, done?: boolean, run?: string } = {}): string { |
| 12 | 69 | const planned = overrides.cases ?? REQUIRED_CASES.ios |
| 13 | 70 | const omit = overrides.omit ?? [] |