Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { setCurrentScreen } from './lib/methods/helpers/log';
import { themes } from './lib/constants/colors';
import { emitter } from './lib/methods/helpers';
import MediaCallHeader from './containers/MediaCallHeader/MediaCallHeader';
import { CallNavRouter } from './lib/services/voip/CallNavRouter';

const createStackNavigator = createNativeStackNavigator;

Expand All @@ -36,6 +37,11 @@ const Stack = createStackNavigator<StackParamList>();
const App = memo(({ root, isMasterDetail }: { root: string; isMasterDetail: boolean }) => {
const { theme } = useContext(ThemeContext);

useEffect(() => {
// Mount CallNavRouter once — it subscribes to CallLifecycle after NavigationContainer is ready.
CallNavRouter.mount();
}, []);

useEffect(() => {
if (root) {
const state = Navigation.navigationRef.current?.getRootState();
Expand Down
35 changes: 32 additions & 3 deletions app/containers/MediaCallHeader/MediaCallHeader.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import React from 'react';
import { fireEvent, render } from '@testing-library/react-native';
import { act, fireEvent, render } from '@testing-library/react-native';
import { Provider } from 'react-redux';

import MediaCallHeader from './MediaCallHeader';
import { navigateToCallRoom } from '../../lib/services/voip/navigateToCallRoom';
import { useCallStore } from '../../lib/services/voip/useCallStore';
import { callLifecycle } from '../../lib/services/voip/CallLifecycle';
import { InMemoryVoipNative } from '../../lib/services/voip/VoipNative';
import { mockedStore } from '../../reducers/mockedStore';
import * as stories from './MediaCallHeader.stories';
import { generateSnapshots } from '../../../.rnstorybook/generateSnapshots';
Expand Down Expand Up @@ -94,8 +96,30 @@ describe('MediaCallHeader', () => {
expect(queryByTestId('media-call-header-end')).toBeNull();
});

it('should render empty placeholder when native accepted but call not bound yet (before answerCall completes)', () => {
useCallStore.getState().setNativeAcceptedCallId('e3246c4d-d23a-412f-8a8b-37ec9f29ef1a');
it('should render empty placeholder when native accepted but call not bound yet (awaitingMediaCall pre-bind state)', async () => {
// Drive the FSM into awaitingMediaCall via the real event path (not stubs).
// MediaCallHeader reads store.call; during the pre-bind window call is still null,
// so the header must render the empty placeholder — not a partial/broken UI.
(callLifecycle as any)._resetForTesting();
useCallStore.setState({ call: null });

// Wire native adapter → FSM.
const native = new InMemoryVoipNative();
callLifecycle.attach(native);
await native.attach({ onEvent: callLifecycle.handleNativeEvent.bind(callLifecycle) });

await act(async () => {
native.__emit({
type: 'acceptSucceeded',
payload: { callId: 'header-pre-bind-1', host: 'ws', type: 'incoming_call' } as any,
fromColdStart: false
});
});
Comment on lines +111 to +117
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use a synchronous act() callback here.

native.__emit(...) is synchronous, so the async wrapper only triggers the require-await lint error without changing the test behavior. This is likely a CI blocker.

Proposed fix
-		await act(async () => {
+		act(() => {
 			native.__emit({
 				type: 'acceptSucceeded',
 				payload: { callId: 'header-pre-bind-1', host: 'ws', type: 'incoming_call' } as any,
 				fromColdStart: false
 			});
 		});
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await act(async () => {
native.__emit({
type: 'acceptSucceeded',
payload: { callId: 'header-pre-bind-1', host: 'ws', type: 'incoming_call' } as any,
fromColdStart: false
});
});
act(() => {
native.__emit({
type: 'acceptSucceeded',
payload: { callId: 'header-pre-bind-1', host: 'ws', type: 'incoming_call' } as any,
fromColdStart: false
});
});
🧰 Tools
🪛 ESLint

[error] 111-111: Async arrow function has no 'await' expression.

(require-await)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/containers/MediaCallHeader/MediaCallHeader.test.tsx` around lines 111 -
117, The test uses an unnecessary async wrapper around act for a synchronous
call: replace await act(async () => { native.__emit(...) }) with a synchronous
act call (remove async from the callback and the awaiting pattern) so that
native.__emit is invoked directly inside act; update the test in
MediaCallHeader.test.tsx where native.__emit and act are used to avoid the
require-await lint error and preserve the same behavior.


// FSM is now in awaitingMediaCall; store.call is still null.
expect(callLifecycle.preBindStatus()).toMatchObject({ kind: 'awaitingMediaCall', uuid: 'header-pre-bind-1' });
expect(useCallStore.getState().call).toBeNull();

const { getByTestId, queryByTestId } = render(
<Wrapper>
<MediaCallHeader />
Expand All @@ -104,6 +128,11 @@ describe('MediaCallHeader', () => {

expect(getByTestId('media-call-header-empty')).toBeTruthy();
expect(queryByTestId('media-call-header')).toBeNull();

// Cleanup: collapse FSM to idle so it doesn't bleed into subsequent tests.
act(() => {
(callLifecycle as any)._resetForTesting();
});
});

it('should render full header when call exists', () => {
Expand Down
117 changes: 86 additions & 31 deletions app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ import React from 'react';
import { act, fireEvent, render } from '@testing-library/react-native';
import { Provider } from 'react-redux';
import RNCallKeep from 'react-native-callkeep';
import InCallManager from 'react-native-incall-manager';
import type { IClientMediaCall } from '@rocket.chat/media-signaling';

import { NewMediaCall } from './NewMediaCall';
import CallView from '../../views/CallView';
import Navigation from '../../lib/navigation/appNavigation';
import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocompleteStore';
import { useCallStore } from '../../lib/services/voip/useCallStore';
import { callLifecycle } from '../../lib/services/voip/CallLifecycle';
import { mediaSessionInstance } from '../../lib/services/voip/MediaSessionInstance';
import { voipNative, type InMemoryVoipNative } from '../../lib/services/voip/VoipNative';
import { mockedStore } from '../../reducers/mockedStore';
Expand Down Expand Up @@ -387,6 +387,8 @@ describe('VoIP call lifecycle (integration)', () => {
unexpectedConsoleErrors = [];
usePeerAutocompleteStore.getState().reset();
useCallStore.getState().reset();
// Reset CallLifecycle FSM to idle so pre-bind state from prior tests does not bleed.
(callLifecycle as any)._resetForTesting();
mediaSessionInstance.reset();
(voipNative as InMemoryVoipNative).reset();

Expand Down Expand Up @@ -449,12 +451,16 @@ describe('VoIP call lifecycle (integration)', () => {
const { call } = useCallStore.getState();
expect(call?.callId).toBe('call-user-1');

// Firing 'ended' triggers voipNative cleanup and navigation back via real handlers.
// Firing 'ended' triggers CallLifecycle teardown via the handleEnded listener.
// Navigation.back() is now handled by CallNavRouter (not wired in this integration test).
// We verify the teardown sequence runs: store cleared, native end issued.
act(() => {
(call!.emitter as unknown as ReturnType<typeof mockCallEmitter>).emit('ended');
});
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'end', callUuid: 'call-user-1' });
expect(Navigation.back).toHaveBeenCalled();
// Navigation.back() is now owned by CallNavRouter after callEnded emits.
// In this test environment, CallNavRouter is not mounted, so we assert the store cleared instead.
expect(useCallStore.getState().call).toBeNull();
});

it('SIP peer: press Call → startCall(sip, number) → navigates to CallView', async () => {
Expand Down Expand Up @@ -545,39 +551,78 @@ describe('VoIP call lifecycle (integration)', () => {
// ── MediaSessionInstance contract: answerCall ────────────────────────────

describe('MediaSessionInstance contract: answerCall', () => {
it('A1: DDP accepted signal with native pre-accept → answerCall navigates to CallView', async () => {
it('A1: native acceptSucceeded → FSM awaitingMediaCall → newCall → onMediaCallNew → answerCall navigates to CallView', async () => {
// This test exercises the real FSM wiring end-to-end (no preBindStatus stubs):
// 1. voipNative.__emit(acceptSucceeded) → callLifecycle.handleNativeEvent → FSM awaitingMediaCall
// 2. session.emit('newCall') → MediaSessionInstance newCall handler → callLifecycle.onMediaCallNew
// 3. onMediaCallNew: FSM idle, answerIncoming called
// 4. answerIncoming (overridden in this test) delegates to mediaSessionInstance.answerCall
// 5. answerCall: accept(), markActive, store.setCall, navigate
const session = createdSessions[createdSessions.length - 1];
const mainCall = makeCall({ callId: 'incoming-1', role: 'callee' });
session.getCallData.mockReturnValue(mainCall);

act(() => {
useCallStore.getState().setNativeAcceptedCallId('incoming-1');
// Wire the native event adapter to the FSM. In production this is done by createVoipEventDispatcher;
// here we attach callLifecycle.handleNativeEvent directly for simplicity.
await (voipNative as InMemoryVoipNative).attach({
onEvent: callLifecycle.handleNativeEvent.bind(callLifecycle)
});

// Override answerIncoming stub so the FSM's bind step does the real answerCall work.
// Slice 06 will provide the full implementation; this override ensures A1 can assert
// the actual observable effects (markActive, navigate, store populated).
const answerIncomingSpy = jest.spyOn(callLifecycle, 'answerIncoming').mockImplementation(async (callId: string) => {
await mediaSessionInstance.answerCall(callId);
});

// Native accepted the call — FSM transitions to awaitingMediaCall.
(voipNative as InMemoryVoipNative).__emit({
type: 'acceptSucceeded',
payload: { callId: 'incoming-1', host: 'workspace-1', type: 'incoming_call' } as any,
fromColdStart: false
});
expect(callLifecycle.preBindStatus()).toMatchObject({ kind: 'awaitingMediaCall', uuid: 'incoming-1' });

// MediaSignalingSession fires newCall → onMediaCallNew → answerIncoming → answerCall.
await act(async () => {
emitDDPMediaSignal({
type: 'notification',
notification: 'accepted',
signedContractId: 'test-device-id',
callId: 'incoming-1'
});
// Flush the answerCall() microtask queue.
session.emit('newCall', { call: mainCall });
await flushMicrotasks();
});

expect(RNCallKeep.setCurrentCallActive as jest.Mock).toHaveBeenCalledWith('incoming-1');
answerIncomingSpy.mockRestore();

// Observable effects: callId bound, navigation triggered, native markActive issued.
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'markActive', callUuid: 'incoming-1' });
expect(Navigation.navigate).toHaveBeenCalledWith('CallView');
expect(useCallStore.getState().call?.callId).toBe('incoming-1');
// FSM is back to idle after successful bind.
expect(callLifecycle.preBindStatus()).toEqual({ kind: 'idle' });
});

it('A2: accepted signal but call not found → RNCallKeep.endCall, no navigate', async () => {
it('A2: native acceptSucceeded → FSM awaitingMediaCall → DDP signal for missing call → voipNative.end, no navigate', async () => {
// This test exercises the real FSM wiring for the "call not found" path:
// 1. voipNative.__emit(acceptSucceeded) → FSM awaitingMediaCall
// 2. DDP notification/accepted fires → tryAnswerIfNativeAcceptedNotification
// → answerCall('missing-1') → getCallData returns undefined → voipNative.end
// No newCall event arrives (call was never established server-side).
const session = createdSessions[createdSessions.length - 1];
session.getCallData.mockReturnValue(undefined);

act(() => {
useCallStore.getState().setNativeAcceptedCallId('missing-1');
// Wire the native event adapter to the FSM.
await (voipNative as InMemoryVoipNative).attach({
onEvent: callLifecycle.handleNativeEvent.bind(callLifecycle)
});

// Native accepted — FSM transitions to awaitingMediaCall.
(voipNative as InMemoryVoipNative).__emit({
type: 'acceptSucceeded',
payload: { callId: 'missing-1', host: 'workspace-1', type: 'incoming_call' } as any,
fromColdStart: false
});
expect(callLifecycle.preBindStatus()).toMatchObject({ kind: 'awaitingMediaCall', uuid: 'missing-1' });

// DDP signal fires — tryAnswerIfNativeAcceptedNotification sees awaitingMediaCall,
// calls answerCall('missing-1'), which fails to find the call and ends via callLifecycle.end('error').
await act(async () => {
emitDDPMediaSignal({
type: 'notification',
Expand All @@ -589,16 +634,17 @@ describe('VoIP call lifecycle (integration)', () => {
});

expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'end', callUuid: 'missing-1' });
expect(useCallStore.getState().nativeAcceptedCallId).toBeNull();
// Pre-bind FSM returns to idle via callLifecycle.end('error').
expect(callLifecycle.preBindStatus()).toEqual({ kind: 'idle' });
expect(Navigation.navigate).not.toHaveBeenCalled();
expect(useCallStore.getState().call).toBeNull();
// Tighten: confirm the known-noise allowlist entry was actually triggered.
expect(consoleWarnSpy).toHaveBeenCalledWith('[VoIP] Call not found after accept:', 'missing-1');
});

it('A3: idempotency — existing call matches callId, answerCall early-returns', async () => {
// Test-pollution guard: confirms the outer reset() actually cleared state.
expect(useCallStore.getState().nativeAcceptedCallId).toBeNull();
// Test-pollution guard: confirms the FSM is idle (pre-bind state owned by lifecycle).
expect(callLifecycle.preBindStatus()).toEqual({ kind: 'idle' });

const session = createdSessions[createdSessions.length - 1];
const existingCall = makeCall({ callId: 'incoming-1', role: 'callee' });
Expand Down Expand Up @@ -641,30 +687,39 @@ describe('VoIP call lifecycle (integration)', () => {
});

expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'end', callUuid: 'call-user-1' });
expect(InCallManager.stop as jest.Mock).toHaveBeenCalled();
// stopAudio is now issued by CallLifecycle.end (step 6) via voipNative.call.stopAudio(),
// which in the test environment records to InMemoryVoipNative.recorded rather than calling InCallManager.stop.
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'stopAudio' });
expect(useCallStore.getState().call).toBeNull();
expect(useCallStore.getState().callId).toBeNull();
});

it('B2: MediaSessionInstance.endCall during active state → voipNative cleanup, store reset', () => {
const session = createdSessions[createdSessions.length - 1];
// endCall now delegates to callLifecycle.end('local'). CallLifecycle reads the
// active call from useCallStore, so the call must be set there first.
const activeCall = makeCall({ callId: 'active-1', state: 'active' });
session.getCallData.mockReturnValue(activeCall);
act(() => {
useCallStore.getState().setCall(activeCall);
});

act(() => {
mediaSessionInstance.endCall('active-1');
});

// CallLifecycle.end() steps 2-4 run via InMemoryVoipNative (records commands instead of calling RNCallKeep).
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'end', callUuid: 'active-1' });
expect(RNCallKeep.setCurrentCallActive as jest.Mock).toHaveBeenCalledWith('');
expect(RNCallKeep.setAvailable as jest.Mock).toHaveBeenCalledWith(true);
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'markActive', callUuid: '' });
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'markAvailable', callUuid: 'active-1' });
expect(useCallStore.getState().call).toBeNull();
});

it('B3: MediaSessionInstance.endCall during ringing → reject (not hangup) + voipNative cleanup', () => {
const session = createdSessions[createdSessions.length - 1];
const ringingCall = makeCall({ callId: 'ringing-1' });
session.getCallData.mockReturnValue(ringingCall);
// CallLifecycle reads the active call from useCallStore to decide reject vs hangup.
// The ringing call must be in the store for reject() to be called.
const ringingCall = makeCall({ callId: 'ringing-1', state: 'ringing' });
act(() => {
useCallStore.getState().setCall(ringingCall);
});

act(() => {
mediaSessionInstance.endCall('ringing-1');
Expand Down Expand Up @@ -749,13 +804,13 @@ describe('VoIP call lifecycle (integration)', () => {
await act(async () => {
await useCallStore.getState().toggleSpeaker();
});
expect(InCallManager.setForceSpeakerphoneOn as jest.Mock).toHaveBeenCalledWith(true);
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'setSpeaker', on: true });
expect(useCallStore.getState().isSpeakerOn).toBe(true);

await act(async () => {
await useCallStore.getState().toggleSpeaker();
});
expect(InCallManager.setForceSpeakerphoneOn as jest.Mock).toHaveBeenCalledWith(false);
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'setSpeaker', on: false });
expect(useCallStore.getState().isSpeakerOn).toBe(false);
});
});
Expand Down Expand Up @@ -846,7 +901,7 @@ describe('VoIP call lifecycle (integration)', () => {

expect(useCallStore.getState().callState).toBe('active');
expect(useCallStore.getState().callStartTime).not.toBeNull();
expect(RNCallKeep.setCurrentCallActive as jest.Mock).toHaveBeenCalledWith('state-1');
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'markActive', callUuid: 'state-1' });
});
});

Expand Down
Loading