238 lines
7.7 KiB
TypeScript
238 lines
7.7 KiB
TypeScript
import React from 'react'
|
|
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
|
|
|
const USER_ID = '11111111-1111-4111-8111-111111111111'
|
|
|
|
let mockSession: { user: { id: string }, access_token: string } | null = {
|
|
user: { id: USER_ID },
|
|
access_token: 'token-1',
|
|
}
|
|
const mockAcquire = jest.fn()
|
|
const mockRequestPermission = jest.fn(async () => true)
|
|
const mockLegacyStart = jest.fn(async () => {
|
|
throw new Error('Cannot start recorder while it is recording')
|
|
})
|
|
const mockGlobalCancel = jest.fn(async () => undefined)
|
|
const mockStreamTalkResponse = jest.fn()
|
|
|
|
jest.mock('@react-navigation/native', () => {
|
|
const ReactValue = require('react') as typeof React
|
|
return {
|
|
useFocusEffect: (effect: () => void | (() => void)) => {
|
|
ReactValue.useEffect(effect, [effect])
|
|
},
|
|
}
|
|
})
|
|
jest.mock('react-native-safe-area-context', () => ({
|
|
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
|
|
}))
|
|
jest.mock('@d3ro/i18n', () => ({
|
|
useI18n: () => ({ t: (key: string) => key }),
|
|
}))
|
|
jest.mock('../src/lib/auth-context', () => ({
|
|
useAuth: () => ({ session: mockSession }),
|
|
}))
|
|
jest.mock('../src/lib/preferences-context', () => ({
|
|
useMobilePreferences: () => ({
|
|
palette: {
|
|
bg: { app: 'app', sidebar: 'sidebar', card: 'card', inset: 'inset' },
|
|
text: { primary: 'primary', muted: 'muted' },
|
|
border: { default: 'border' },
|
|
accent: { main: 'accent', dim: 'dim', pressed: 'pressed' },
|
|
tag: { red: 'red' },
|
|
},
|
|
preferences: { preferredLlmModel: 'default', locale: 'en' },
|
|
}),
|
|
}))
|
|
jest.mock('../src/theme/themed-components', () => {
|
|
const { Pressable, Text } = require('react-native') as typeof import('react-native')
|
|
return {
|
|
ThemeText: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => (
|
|
<Text {...props}>{children}</Text>
|
|
),
|
|
ThemeButton: ({ label, onPress, ...props }: { label: string, onPress?: () => void }) => (
|
|
<Pressable {...props} onPress={onPress}>
|
|
<Text>{label}</Text>
|
|
</Pressable>
|
|
),
|
|
}
|
|
})
|
|
jest.mock('../src/components/ContentReportSheet', () => ({
|
|
__esModule: true,
|
|
default: () => null,
|
|
}))
|
|
jest.mock('../src/features/chat/chat-service', () => {
|
|
class MockChatServiceError extends Error {
|
|
code: string
|
|
retryable: boolean
|
|
|
|
constructor(mockCode: string, mockRetryable: boolean) {
|
|
super(mockCode)
|
|
this.code = mockCode
|
|
this.retryable = mockRetryable
|
|
}
|
|
}
|
|
return { ChatServiceError: MockChatServiceError }
|
|
})
|
|
jest.mock('../src/features/talk/llm-stream-service', () => ({
|
|
streamTalkResponse: (...args: unknown[]) => mockStreamTalkResponse(...args),
|
|
}))
|
|
jest.mock('../src/features/talk/talk-transcription-service', () => ({
|
|
transcribeTalkRecording: jest.fn(),
|
|
}))
|
|
jest.mock('../src/features/talk/talk-tts', () => {
|
|
class MockTalkTtsError extends Error {
|
|
code = 'FAILED'
|
|
}
|
|
return {
|
|
shutdownTalkSpeech: jest.fn(async () => undefined),
|
|
speakTalkText: jest.fn(async () => ({ status: 'completed' })),
|
|
stopTalkSpeech: jest.fn(async () => true),
|
|
TalkTtsError: MockTalkTtsError,
|
|
}
|
|
})
|
|
jest.mock('../src/features/talk/talk-debug-audio', () => ({
|
|
isDeterministicTalkAudioAvailable: () => false,
|
|
synthesizeDeterministicTalkAudio: jest.fn(),
|
|
}))
|
|
jest.mock('../src/lib/audio-recorder', () => {
|
|
class MockRecorderBusyError extends Error {
|
|
code = 'recorder_busy'
|
|
holder: string
|
|
|
|
constructor(mockHolder: string) {
|
|
super(`The recorder is in use by ${mockHolder}`)
|
|
this.holder = mockHolder
|
|
}
|
|
}
|
|
return {
|
|
RecorderBusyError: MockRecorderBusyError,
|
|
audioRecorder: {
|
|
acquire: (...args: unknown[]) => mockAcquire(...args),
|
|
requestPermission: () => mockRequestPermission(),
|
|
// The pre-lease API: a global start/cancel any screen could call.
|
|
start: () => mockLegacyStart(),
|
|
cancel: () => mockGlobalCancel(),
|
|
},
|
|
}
|
|
})
|
|
|
|
import TalkScreen from '../src/screens/TalkScreen'
|
|
import { RecorderBusyError } from '../src/lib/audio-recorder'
|
|
|
|
async function flush(): Promise<void> {
|
|
for (let index = 0; index < 5; index += 1) {
|
|
await act(async () => {
|
|
await Promise.resolve()
|
|
})
|
|
}
|
|
}
|
|
|
|
function byTestId(root: ReactTestInstance, testID: string, prop: string): ReactTestInstance {
|
|
const match = root.findAll((node) => (
|
|
node.props.testID === testID && typeof node.props[prop] === 'function'
|
|
))[0]
|
|
if (match === undefined) throw new Error(`Missing ${testID} with ${prop}`)
|
|
return match
|
|
}
|
|
|
|
function textContent(root: ReactTestInstance): string {
|
|
return root.findAll((node) => typeof node.props.children === 'string')
|
|
.map((node) => node.props.children as string)
|
|
.join('\n')
|
|
}
|
|
|
|
let renderer: ReactTestRenderer | null = null
|
|
|
|
beforeEach(() => {
|
|
mockSession = { user: { id: USER_ID }, access_token: 'token-1' }
|
|
mockAcquire.mockReset()
|
|
mockGlobalCancel.mockClear()
|
|
mockLegacyStart.mockClear()
|
|
mockStreamTalkResponse.mockReset()
|
|
})
|
|
|
|
afterEach(() => {
|
|
act(() => renderer?.unmount())
|
|
renderer = null
|
|
})
|
|
|
|
describe('TalkScreen redteam r1-24', () => {
|
|
test('pressing the mic while a meeting recording holds the recorder never cancels it', async () => {
|
|
mockAcquire.mockRejectedValue(new RecorderBusyError('record'))
|
|
await act(async () => {
|
|
renderer = create(<TalkScreen />)
|
|
})
|
|
|
|
await act(async () => {
|
|
byTestId(renderer!.root, 'talk-ptt', 'onPressIn').props.onPressIn()
|
|
})
|
|
await flush()
|
|
|
|
expect(mockAcquire).toHaveBeenCalledWith('talk')
|
|
expect(mockGlobalCancel).not.toHaveBeenCalled()
|
|
expect(textContent(renderer!.root)).toContain('mobile.talk.error.recording')
|
|
})
|
|
|
|
test('an access-token refresh for the same user keeps the in-flight reply', async () => {
|
|
let resolveStream: (value: { text: string, generationId: string }) => void = () => undefined
|
|
let streamSignal: AbortSignal | null = null
|
|
mockStreamTalkResponse.mockImplementation((_messages: unknown, options: { signal: AbortSignal }) => {
|
|
streamSignal = options.signal
|
|
return new Promise((resolve) => { resolveStream = resolve })
|
|
})
|
|
await act(async () => {
|
|
renderer = create(<TalkScreen />)
|
|
})
|
|
|
|
await act(async () => {
|
|
byTestId(renderer!.root, 'talk-input', 'onChangeText').props.onChangeText('hello')
|
|
})
|
|
await act(async () => {
|
|
byTestId(renderer!.root, 'talk-send', 'onPress').props.onPress()
|
|
})
|
|
await flush()
|
|
expect(mockStreamTalkResponse).toHaveBeenCalledTimes(1)
|
|
|
|
// supabase-js TOKEN_REFRESHED: same user, rotated access token.
|
|
mockSession = { user: { id: USER_ID }, access_token: 'token-2' }
|
|
await act(async () => {
|
|
renderer!.update(<TalkScreen />)
|
|
})
|
|
expect(streamSignal!.aborted).toBe(false)
|
|
|
|
await act(async () => {
|
|
resolveStream({ text: 'the finished reply', generationId: 'gen-1' })
|
|
})
|
|
await flush()
|
|
|
|
const text = textContent(renderer!.root)
|
|
expect(text).toContain('the finished reply')
|
|
expect(text).not.toContain('mobile.talk.error.auth')
|
|
})
|
|
|
|
test('switching to a different user still cancels the in-flight reply', async () => {
|
|
let streamSignal: AbortSignal | null = null
|
|
mockStreamTalkResponse.mockImplementation((_messages: unknown, options: { signal: AbortSignal }) => {
|
|
streamSignal = options.signal
|
|
return new Promise(() => undefined)
|
|
})
|
|
await act(async () => {
|
|
renderer = create(<TalkScreen />)
|
|
})
|
|
await act(async () => {
|
|
byTestId(renderer!.root, 'talk-input', 'onChangeText').props.onChangeText('hello')
|
|
})
|
|
await act(async () => {
|
|
byTestId(renderer!.root, 'talk-send', 'onPress').props.onPress()
|
|
})
|
|
await flush()
|
|
|
|
mockSession = { user: { id: '33333333-3333-4333-8333-333333333333' }, access_token: 'token-3' }
|
|
await act(async () => {
|
|
renderer!.update(<TalkScreen />)
|
|
})
|
|
|
|
expect(streamSignal!.aborted).toBe(true)
|
|
})
|
|
})
|