test(mobile): pin both stale-failure guards and the in-flight retry precedence

Review found two coverage gaps, each proven by mutation:

- The `requestIsCurrent()` guard on the not-ok/`{error}` page path could be
  deleted with all 913 tests still green — only the rejection twin was pinned.
  The swapped-out-from-under test now runs over both failure shapes.
- `not.toContain('Tap to retry')` was asserted with no `loadEarlierError` set,
  so it could not fail. It now renders both props, pinning that an in-flight
  retry outranks the failure it is retrying.
This commit is contained in:
Brennan Benson
2026-08-04 15:19:38 -07:00
parent a4aef5bd18
commit 9fd63e3933
2 changed files with 26 additions and 6 deletions
@@ -269,11 +269,19 @@ describe('MobileNativeChatView', () => {
expect(header.props.accessibilityLabel).toBe('Couldn’t load earlier messages. Tap to retry')
})
it('keeps the header tappable-but-idle while a page is in flight', async () => {
await render({ hasMore: true, loadingEarlier: true, onLoadEarlier: vi.fn() })
// An in-flight retry outranks the failure it is retrying, so the header shows
// the spinner rather than a stale error the user has already acted on.
it('disables the header and hides the failure while a retry is in flight', async () => {
await render({
hasMore: true,
loadingEarlier: true,
loadEarlierError: 'Couldn’t load earlier messages',
onLoadEarlier: vi.fn()
})
const header = loadEarlierHeader()
expect(header.props.disabled).toBe(true)
expect(headerText(header)).not.toContain('Couldn’t load earlier messages')
expect(headerText(header)).not.toContain('Tap to retry')
})
})
@@ -8,6 +8,9 @@ import {
type MobileNativeChatSession
} from './use-mobile-native-chat-session'
/** Hands a pending `sendRequest` promise's settlers to the test body. */
type PageSettle = { resolve: (response: unknown) => void; reject: (reason: unknown) => void }
function message(id: string): NativeChatMessage {
return {
id,
@@ -255,10 +258,19 @@ describe('useMobileNativeChatSession', () => {
expect(state?.loadingEarlier).toBe(false)
})
it('drops a failure from a page the session swapped out from under', async () => {
let rejectEarlier: (reason: unknown) => void = () => {}
// Both stale-failure guards have to hold: a swapped-out page can fail by
// rejecting or by resolving not-ok, and each has its own `requestIsCurrent()`.
it.each([
['a rejected request', (settle: PageSettle) => settle.reject(new Error('disconnected'))],
['a not-ok response', (settle: PageSettle) => settle.resolve({ ok: false, error: 'offline' })]
])('drops %s from a page the session swapped out from under', async (_label, settleEarlier) => {
const settle: PageSettle = { resolve: () => {}, reject: () => {} }
const sendRequest = vi.fn(
() => new Promise((_resolve, reject) => (rejectEarlier = reject))
() =>
new Promise((resolve, reject) => {
settle.resolve = resolve
settle.reject = reject
})
) as unknown as RpcClient['sendRequest']
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
emit = onData
@@ -277,7 +289,7 @@ describe('useMobileNativeChatSession', () => {
emit({ type: 'replacement', messages: [message('fresh')], hasMore: true, beforeOffset: 0 })
)
await act(async () => {
rejectEarlier(new Error('disconnected'))
settleEarlier(settle)
await Promise.resolve()
})