fix(mobile-recorder): scope recorder control to its owner and keep Android recordings on stop failure
This commit is contained in:
parent
72c4ddaefb
commit
ace946d5c7
10 changed files with 1673 additions and 697 deletions
292
apps/mobile-rn/__tests__/audio-recorder-redteam-r1-24.test.ts
Normal file
292
apps/mobile-rn/__tests__/audio-recorder-redteam-r1-24.test.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
import { NativeModules } from 'react-native'
|
||||
import {
|
||||
AudioRecorder,
|
||||
RecorderBusyError,
|
||||
type RecorderBackend,
|
||||
type RecordingRuntimeSnapshot,
|
||||
} from '../src/lib/audio-recorder'
|
||||
import { AndroidForegroundRecorderBackend } from '../src/lib/recorder/android-foreground-backend'
|
||||
|
||||
const MEETING_PATH = '/data/user/0/com.d3ro.voice/files/recordings/d3ro-recording-11111111-1111-4111-8111-111111111111.wav'
|
||||
|
||||
type NativeState = 'idle' | 'starting' | 'recording' | 'paused' | 'stopped' | 'recoverable'
|
||||
|
||||
function nativeSnapshot(state: NativeState, size = 64_044, path: string | null = MEETING_PATH) {
|
||||
const hasFile = state !== 'idle' && path !== null
|
||||
return {
|
||||
state,
|
||||
path: hasFile ? path : null,
|
||||
uri: hasFile ? `file://${path}` : null,
|
||||
fileName: hasFile ? path!.slice(path!.lastIndexOf('/') + 1) : null,
|
||||
mimeType: 'audio/wav',
|
||||
size: hasFile ? size : 0,
|
||||
durationMs: hasFile ? 4_000 : 0,
|
||||
meteringDb: null,
|
||||
meetingId: null,
|
||||
interruptionReason: null,
|
||||
startedAtMs: hasFile ? 1_000 : 0,
|
||||
recoverable: state === 'recoverable',
|
||||
}
|
||||
}
|
||||
|
||||
interface FakeNativeModule {
|
||||
current: ReturnType<typeof nativeSnapshot>
|
||||
getStatus: jest.Mock
|
||||
start: jest.Mock
|
||||
pause: jest.Mock
|
||||
resume: jest.Mock
|
||||
stop: jest.Mock
|
||||
cancel: jest.Mock
|
||||
dispose: jest.Mock
|
||||
}
|
||||
|
||||
function installNativeModule(initial: NativeState = 'idle'): FakeNativeModule {
|
||||
const native: FakeNativeModule = {
|
||||
current: nativeSnapshot(initial),
|
||||
getStatus: jest.fn(async () => native.current),
|
||||
start: jest.fn(async () => {
|
||||
if (native.current.state !== 'idle') {
|
||||
throw Object.assign(new Error('Finish or discard the existing recording before starting another'), {
|
||||
code: 'recording-already-exists',
|
||||
})
|
||||
}
|
||||
native.current = nativeSnapshot('recording')
|
||||
return native.current
|
||||
}),
|
||||
pause: jest.fn(async () => {
|
||||
native.current = nativeSnapshot('paused')
|
||||
return native.current
|
||||
}),
|
||||
resume: jest.fn(async () => {
|
||||
native.current = nativeSnapshot('recording')
|
||||
return native.current
|
||||
}),
|
||||
stop: jest.fn(async () => {
|
||||
native.current = nativeSnapshot('stopped')
|
||||
return native.current
|
||||
}),
|
||||
cancel: jest.fn(async () => {
|
||||
native.current = nativeSnapshot('idle')
|
||||
return native.current
|
||||
}),
|
||||
dispose: jest.fn(async (path: string) => {
|
||||
if (native.current.path === path) native.current = nativeSnapshot('idle')
|
||||
}),
|
||||
}
|
||||
;(NativeModules as Record<string, unknown>).D3RORecording = native
|
||||
return native
|
||||
}
|
||||
|
||||
function androidRecorder(): AudioRecorder {
|
||||
return new AudioRecorder(new AndroidForegroundRecorderBackend())
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllTimers()
|
||||
jest.useRealTimers()
|
||||
delete (NativeModules as Record<string, unknown>).D3RORecording
|
||||
})
|
||||
|
||||
describe('recorder ownership lease (redteam r1-24 bug 1)', () => {
|
||||
test('Talk cannot acquire while a Record meeting capture runs, and the capture survives', async () => {
|
||||
const native = installNativeModule()
|
||||
const recorder = androidRecorder()
|
||||
const record = await recorder.acquire('record')
|
||||
await record.start(() => undefined, { meetingId: '22222222-2222-4222-8222-222222222222' })
|
||||
|
||||
await expect(recorder.acquire('talk')).rejects.toBeInstanceOf(RecorderBusyError)
|
||||
|
||||
expect(native.cancel).not.toHaveBeenCalled()
|
||||
expect(native.dispose).not.toHaveBeenCalled()
|
||||
expect(native.current.state).toBe('recording')
|
||||
// Record still owns and can finish its capture.
|
||||
await expect(record.stop()).resolves.toMatchObject({ path: MEETING_PATH })
|
||||
})
|
||||
|
||||
test('a Talk session never cancels a capture it does not hold', async () => {
|
||||
const native = installNativeModule()
|
||||
const recorder = androidRecorder()
|
||||
const talk = await recorder.acquire('talk')
|
||||
const record = await recorder.acquire('record')
|
||||
await record.start(() => undefined)
|
||||
|
||||
await expect(talk.start(() => undefined)).rejects.toBeInstanceOf(RecorderBusyError)
|
||||
await talk.cancel()
|
||||
await talk.stop().catch(() => undefined)
|
||||
|
||||
expect(native.cancel).not.toHaveBeenCalled()
|
||||
expect(native.stop).not.toHaveBeenCalled()
|
||||
expect(native.current.state).toBe('recording')
|
||||
})
|
||||
|
||||
test('a native capture that outlived the JS process is treated as Record-owned', async () => {
|
||||
const native = installNativeModule('recording')
|
||||
const recorder = androidRecorder()
|
||||
|
||||
await expect(recorder.acquire('talk')).rejects.toMatchObject({ holder: 'record' })
|
||||
expect(native.cancel).not.toHaveBeenCalled()
|
||||
|
||||
const record = await recorder.acquire('record')
|
||||
const snapshot = await record.restore()
|
||||
expect(snapshot.state).toBe('recording')
|
||||
})
|
||||
|
||||
test('a recoverable meeting file also blocks Talk instead of being discarded', async () => {
|
||||
const native = installNativeModule('recoverable')
|
||||
const recorder = androidRecorder()
|
||||
|
||||
await expect(recorder.acquire('talk')).rejects.toBeInstanceOf(RecorderBusyError)
|
||||
expect(native.cancel).not.toHaveBeenCalled()
|
||||
expect(native.current.state).toBe('recoverable')
|
||||
})
|
||||
|
||||
test('Talk can use the recorder again once the meeting capture is disposed', async () => {
|
||||
installNativeModule()
|
||||
const recorder = androidRecorder()
|
||||
const record = await recorder.acquire('record')
|
||||
await record.start(() => undefined)
|
||||
const recording = await record.stop()
|
||||
await record.cleanup(recording)
|
||||
|
||||
const talk = await recorder.acquire('talk')
|
||||
await expect(talk.start(() => undefined)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test('the privileged account-purge cancel still discards any capture', async () => {
|
||||
const native = installNativeModule()
|
||||
const recorder = androidRecorder()
|
||||
const record = await recorder.acquire('record')
|
||||
await record.start(() => undefined)
|
||||
|
||||
await recorder.cancel()
|
||||
|
||||
expect(native.cancel).toHaveBeenCalledTimes(1)
|
||||
await expect(recorder.acquire('talk')).resolves.toMatchObject({ owner: 'talk' })
|
||||
})
|
||||
|
||||
test('the lease is enforced for any backend, not only Android', async () => {
|
||||
let active = false
|
||||
const backend: RecorderBackend = {
|
||||
requestPermission: jest.fn(async () => true),
|
||||
start: jest.fn(async () => { active = true }),
|
||||
pause: jest.fn(async () => undefined),
|
||||
resume: jest.fn(async () => undefined),
|
||||
stop: jest.fn(async () => ({
|
||||
uri: 'file:///cache/a.m4a',
|
||||
path: '/cache/a.m4a',
|
||||
fileName: 'a.m4a',
|
||||
mimeType: 'audio/mp4',
|
||||
size: 10,
|
||||
durationMs: 1_000,
|
||||
})),
|
||||
cleanup: jest.fn(async () => { active = false }),
|
||||
cancel: jest.fn(async () => { active = false }),
|
||||
restore: jest.fn(async () => ({
|
||||
state: 'idle' as const,
|
||||
recording: null,
|
||||
meetingId: null,
|
||||
interruptionReason: null,
|
||||
startedAtMs: 0,
|
||||
})),
|
||||
isBusy: jest.fn(async () => active),
|
||||
hasInProcessCapture: jest.fn(() => active),
|
||||
}
|
||||
const recorder = new AudioRecorder(backend)
|
||||
const earlierTalk = await recorder.acquire('talk')
|
||||
const record = await recorder.acquire('record')
|
||||
await record.start(() => undefined)
|
||||
|
||||
await expect(recorder.acquire('talk')).rejects.toBeInstanceOf(RecorderBusyError)
|
||||
await earlierTalk.cancel()
|
||||
expect(backend.cancel).not.toHaveBeenCalled()
|
||||
await record.cancel()
|
||||
expect(backend.cancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Android stop failure keeps the recording recoverable (redteam r1-24 bug 3)', () => {
|
||||
test('a native stop timeout surfaces the native error and reports the recoverable file', async () => {
|
||||
const native = installNativeModule()
|
||||
const recorder = androidRecorder()
|
||||
const record = await recorder.acquire('record')
|
||||
const states: RecordingRuntimeSnapshot[] = []
|
||||
await record.start(() => undefined, { onStateChange: (snapshot) => states.push(snapshot) })
|
||||
|
||||
const timeout = Object.assign(new Error('Recording could not be stopped in time'), {
|
||||
code: 'recording-stop-timeout',
|
||||
})
|
||||
native.stop.mockImplementationOnce(async () => {
|
||||
// The service keeps finalizing after the command times out.
|
||||
native.current = nativeSnapshot('recoverable')
|
||||
throw timeout
|
||||
})
|
||||
|
||||
await expect(record.stop()).rejects.toBe(timeout)
|
||||
|
||||
expect(states.at(-1)).toMatchObject({
|
||||
state: 'recoverable',
|
||||
recording: { path: MEETING_PATH },
|
||||
})
|
||||
expect(native.dispose).not.toHaveBeenCalled()
|
||||
expect(native.cancel).not.toHaveBeenCalled()
|
||||
|
||||
// The recovered file can be discarded and the recorder is usable again.
|
||||
await record.cleanup({ path: MEETING_PATH })
|
||||
expect(native.dispose).toHaveBeenCalledWith(MEETING_PATH)
|
||||
await expect(record.start(() => undefined)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test('a header-only WAV reports the stopped file instead of masking the error', async () => {
|
||||
const native = installNativeModule()
|
||||
const recorder = androidRecorder()
|
||||
const record = await recorder.acquire('record')
|
||||
const states: RecordingRuntimeSnapshot[] = []
|
||||
await record.start(() => undefined, { onStateChange: (snapshot) => states.push(snapshot) })
|
||||
native.stop.mockImplementationOnce(async () => {
|
||||
native.current = nativeSnapshot('stopped', 44)
|
||||
return native.current
|
||||
})
|
||||
|
||||
await expect(record.stop()).rejects.toThrow('Android recorder produced no recoverable audio')
|
||||
expect(states.at(-1)).toMatchObject({ state: 'stopped' })
|
||||
|
||||
// A later poll must not be able to wedge the recorder in "stopped".
|
||||
await jest.runOnlyPendingTimersAsync()
|
||||
await record.cleanup({ path: MEETING_PATH })
|
||||
await expect(record.start(() => undefined)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test('an unreadable status after a failed stop keeps the capture instead of dropping it', async () => {
|
||||
const native = installNativeModule()
|
||||
const recorder = androidRecorder()
|
||||
const record = await recorder.acquire('record')
|
||||
await record.start(() => undefined)
|
||||
const failure = Object.assign(new Error('Only an active recording can be stopped'), {
|
||||
code: 'recording-invalid-state',
|
||||
})
|
||||
native.stop.mockRejectedValueOnce(failure)
|
||||
native.getStatus.mockRejectedValueOnce(new Error('bridge unavailable'))
|
||||
|
||||
await expect(record.stop()).rejects.toBe(failure)
|
||||
|
||||
// The capture is still Record's and can still be stopped normally.
|
||||
await expect(recorder.acquire('talk')).rejects.toBeInstanceOf(RecorderBusyError)
|
||||
await expect(record.stop()).resolves.toMatchObject({ path: MEETING_PATH })
|
||||
})
|
||||
|
||||
test('disposing an older file does not reset a capture that is running now', async () => {
|
||||
const native = installNativeModule()
|
||||
const recorder = androidRecorder()
|
||||
const record = await recorder.acquire('record')
|
||||
await record.start(() => undefined)
|
||||
|
||||
await record.cleanup({ path: '/data/user/0/com.d3ro.voice/files/recordings/older.wav' })
|
||||
|
||||
await expect(record.stop()).resolves.toMatchObject({ path: MEETING_PATH })
|
||||
expect(native.stop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
238
apps/mobile-rn/__tests__/talk-screen-redteam-r1-24.test.tsx
Normal file
238
apps/mobile-rn/__tests__/talk-screen-redteam-r1-24.test.tsx
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue