agora-appbuilder-core 4.1.22-beta.2 → 4.1.22-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/package.json +2 -2
  2. package/template/.eslintrc.js +20 -0
  3. package/template/.prettierrc.js +18 -0
  4. package/template/.watchmanconfig +1 -0
  5. package/template/agora-rn-uikit/src/Views/MaxVideoView.tsx +2 -0
  6. package/template/agora-rn-uikit/src/Views/MinVideoView.tsx +12 -2
  7. package/template/bridge/rtc/webNg/RtcEngine.ts +639 -251
  8. package/template/bridge/rtc/webNg/RtcSurfaceView.tsx +203 -16
  9. package/template/bridge/rtc/webNg/__tests__/screenshareLifecycle.test.ts +358 -0
  10. package/template/bridge/rtc/webNg/__tests__/screenshareMediaHealth.test.ts +49 -0
  11. package/template/bridge/rtc/webNg/screenshareMediaHealth.ts +49 -0
  12. package/template/bridge/rtm/web/index.ts +22 -31
  13. package/template/config.json +101 -0
  14. package/template/customization-api/atoms.ts +0 -2
  15. package/template/customization-api/customEvents.ts +1 -4
  16. package/template/customization-api/index.ts +0 -1
  17. package/template/customization-api/typeDefinition.ts +1 -6
  18. package/template/defaultConfig.js +2 -2
  19. package/template/src/components/Controls.tsx +12 -6
  20. package/template/src/components/CustomSidePanel.tsx +13 -13
  21. package/template/src/components/RTMConfigure.tsx +0 -14
  22. package/template/src/components/participants/UserActionMenuOptions.tsx +1 -28
  23. package/template/src/logger/transports/__tests__/agora-transport.test.ts +48 -0
  24. package/template/src/logger/transports/agora-transport.ts +18 -6
  25. package/template/src/pages/Create.tsx +19 -31
  26. package/template/src/pages/video-call/ActionSheet.native.tsx +0 -2
  27. package/template/src/pages/video-call/ActionSheet.tsx +0 -2
  28. package/template/src/pages/video-call/ActionSheetHandle.tsx +0 -1
  29. package/template/src/pages/video-call/SidePanelHeader.tsx +1 -3
  30. package/template/src/pages/video-call/VideoCallScreen.tsx +0 -3
  31. package/template/src/pages/video-call/VideoRenderer.tsx +0 -4
  32. package/template/src/rtm-events-api/Events.ts +3 -1
  33. package/template/src/rtm-events-api/__tests__/Events.test.ts +55 -0
  34. package/template/src/subComponents/SidePanelHeader.tsx +2 -16
  35. package/template/src/subComponents/screenshare/ScreenshareButton.tsx +38 -4
  36. package/template/src/subComponents/screenshare/ScreenshareConfigure.tsx +280 -34
  37. package/template/src/subComponents/screenshare/__tests__/screenshareJourney.test.ts +10 -0
  38. package/template/src/subComponents/screenshare/__tests__/screenshareOperation.test.ts +31 -0
  39. package/template/src/subComponents/screenshare/screenshareJourney.ts +6 -0
  40. package/template/src/subComponents/screenshare/screenshareOperation.ts +21 -0
  41. package/template/src/subComponents/screenshare/useScreenshare.tsx +8 -0
  42. package/template/theme.json +91 -0
  43. package/template/customization-api/ai-agent.ts +0 -4
@@ -1,25 +1,30 @@
1
1
  /*
2
2
  ********************************************
3
3
  Copyright © 2021 Agora Lab, Inc., all rights reserved.
4
- AppBuilder and all associated components, source code, APIs, services, and documentation
5
- (the “Materials”) are owned by Agora Lab, Inc. and its licensors. The Materials may not be
6
- accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc.
7
- Use without a license or in violation of any license terms and conditions (including use for
8
- any purpose competitive to Agora Lab, Inc.’s business) is strictly prohibited. For more
9
- information visit https://appbuilder.agora.io.
4
+ AppBuilder and all associated components, source code, APIs, services, and documentation
5
+ (the “Materials”) are owned by Agora Lab, Inc. and its licensors. The Materials may not be
6
+ accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc.
7
+ Use without a license or in violation of any license terms and conditions (including use for
8
+ any purpose competitive to Agora Lab, Inc.’s business) is strictly prohibited. For more
9
+ information visit https://appbuilder.agora.io.
10
10
  *********************************************
11
11
  */
12
12
 
13
13
  import {ILocalVideoTrack, IRemoteVideoTrack} from 'agora-rtc-sdk-ng';
14
- import React, {useEffect} from 'react';
14
+ import React, {useEffect, useRef} from 'react';
15
15
  import {StyleProp, StyleSheet, ViewProps, ViewStyle} from 'react-native';
16
16
  import {RenderModeType} from './Types';
17
17
  import {LogSource, logger} from '../../../src/logger/AppBuilderLogger';
18
+ import {getScreenshareMediaHealthOutcome} from './screenshareMediaHealth';
19
+
20
+ let screenshareRenderInstanceCounter = 0;
18
21
 
19
22
  export interface RtcSurfaceViewProps extends ViewProps {
20
23
  canvas: {
21
24
  renderMode?: RenderModeType;
22
25
  uid?: number;
26
+ isScreenshare?: boolean;
27
+ screenshareSurface?: 'min' | 'max' | 'unknown';
23
28
  };
24
29
  }
25
30
 
@@ -30,28 +35,210 @@ export interface StyleProps {
30
35
  interface SurfaceViewInterface extends RtcSurfaceViewProps, StyleProps {}
31
36
 
32
37
  const RtcSurfaceView = (props: SurfaceViewInterface) => {
33
- const {uid, renderMode} = props.canvas;
38
+ const {
39
+ uid,
40
+ renderMode,
41
+ isScreenshare = false,
42
+ screenshareSurface = renderMode === RenderModeType.RenderModeHidden
43
+ ? 'min'
44
+ : 'max',
45
+ } = props.canvas;
46
+ const renderInstanceIdRef = useRef<string | null>(null);
47
+ if (!renderInstanceIdRef.current) {
48
+ renderInstanceIdRef.current = `screenshare-render-${++screenshareRenderInstanceCounter}`;
49
+ }
50
+ const renderInstanceId = renderInstanceIdRef.current;
34
51
  const stream: ILocalVideoTrack | IRemoteVideoTrack =
35
52
  uid === 0
36
- ? window.engine.localStream.video
53
+ ? window.engine?.localStream?.video
37
54
  : uid === 1
38
- ? window.engine.screenStream.video
39
- : window.engine.remoteStreams.get(uid)?.video;
55
+ ? window.engine?.screenStream?.video
56
+ : window.engine?.remoteStreams?.get?.(uid)?.video;
40
57
  useEffect(
41
58
  function () {
59
+ if (isScreenshare && uid !== undefined && uid !== 1) {
60
+ window.engine?.registerRemoteScreenshareUid?.(uid);
61
+ }
42
62
  if (stream?.play) {
43
- if (renderMode === RenderModeType.RenderModeFit) {
44
- stream.play(String(uid), {fit: 'contain'});
45
- } else {
46
- stream.play(String(uid));
63
+ try {
64
+ if (renderMode === RenderModeType.RenderModeFit) {
65
+ stream.play(String(uid), {fit: 'contain'});
66
+ } else {
67
+ stream.play(String(uid));
68
+ }
69
+ if (isScreenshare) {
70
+ logger.log(
71
+ LogSource.AgoraSDK,
72
+ 'API',
73
+ '[SCREENSHARE_JOURNEY] screen share video surface mounted and track.play invoked',
74
+ {
75
+ action: 'monitor',
76
+ stage: 'video_play',
77
+ outcome: 'invoked',
78
+ screenShareUid: uid,
79
+ role: uid === 1 ? 'sender' : 'viewer',
80
+ screenshareSurface,
81
+ renderInstanceId,
82
+ },
83
+ );
84
+ }
85
+ } catch (error) {
86
+ if (isScreenshare) {
87
+ logger.error(
88
+ LogSource.AgoraSDK,
89
+ 'API',
90
+ '[SCREENSHARE_JOURNEY] screen share video track play failed',
91
+ {
92
+ action: 'monitor',
93
+ stage: 'video_play',
94
+ outcome: 'failure',
95
+ screenShareUid: uid,
96
+ role: uid === 1 ? 'sender' : 'viewer',
97
+ screenshareSurface,
98
+ renderInstanceId,
99
+ sdkErrorName: (error as Error)?.name,
100
+ sdkErrorMessage:
101
+ (error as Error)?.message || String(error || ''),
102
+ },
103
+ );
104
+ }
47
105
  }
48
106
  }
107
+ let healthTimer: ReturnType<typeof setInterval> | undefined;
108
+ let consecutiveUnhealthySamples = 0;
109
+ let lastLoggedOutcome: string | undefined;
110
+ if (isScreenshare && stream) {
111
+ const checkHealth = () => {
112
+ const diagnostics =
113
+ window.engine?.getScreenshareMediaDiagnostics?.(uid);
114
+ if (!diagnostics) {
115
+ return;
116
+ }
117
+ const stats: any = diagnostics?.stats || {};
118
+ const videoElement = document
119
+ .getElementById(String(uid))
120
+ ?.querySelector('video');
121
+ const isSender = diagnostics.role === 'sender';
122
+ const outcome = getScreenshareMediaHealthOutcome({
123
+ trackReadyState: diagnostics.mediaTrack?.readyState,
124
+ bitrate: isSender ? stats.sendBitrate : stats.receiveBitrate,
125
+ frameRate: isSender
126
+ ? stats.sendFrameRate || stats.captureFrameRate
127
+ : stats.renderFrameRate ||
128
+ stats.decodeFrameRate ||
129
+ stats.receiveFrameRate,
130
+ width: isSender
131
+ ? stats.sendResolutionWidth || stats.captureResolutionWidth
132
+ : stats.receiveResolutionWidth,
133
+ height: isSender
134
+ ? stats.sendResolutionHeight || stats.captureResolutionHeight
135
+ : stats.receiveResolutionHeight,
136
+ videoElementPresent: Boolean(videoElement),
137
+ renderedWidth: videoElement?.videoWidth,
138
+ renderedHeight: videoElement?.videoHeight,
139
+ });
140
+ consecutiveUnhealthySamples =
141
+ outcome === 'healthy' ? 0 : consecutiveUnhealthySamples + 1;
142
+ const reportableOutcome =
143
+ outcome === 'healthy'
144
+ ? lastLoggedOutcome && lastLoggedOutcome !== 'healthy'
145
+ ? 'recovered'
146
+ : 'healthy'
147
+ : consecutiveUnhealthySamples >= 2
148
+ ? outcome
149
+ : 'checking';
150
+ if (
151
+ reportableOutcome !== 'checking' &&
152
+ reportableOutcome !== lastLoggedOutcome
153
+ ) {
154
+ logger[
155
+ reportableOutcome === 'healthy' ||
156
+ reportableOutcome === 'recovered'
157
+ ? 'log'
158
+ : 'warn'
159
+ ](
160
+ LogSource.AgoraSDK,
161
+ 'API',
162
+ `[SCREENSHARE_JOURNEY] screen share media health ${reportableOutcome}`,
163
+ {
164
+ action: 'monitor',
165
+ stage: 'media_health',
166
+ outcome: reportableOutcome,
167
+ role: diagnostics.role,
168
+ screenshareSessionId: diagnostics.screenshareSessionId,
169
+ screenShareUid: diagnostics.screenShareUid,
170
+ screenshareSurface,
171
+ renderInstanceId,
172
+ stats,
173
+ mediaTrack: diagnostics.mediaTrack
174
+ ? {
175
+ readyState: diagnostics.mediaTrack.readyState,
176
+ enabled: diagnostics.mediaTrack.enabled,
177
+ muted: diagnostics.mediaTrack.muted,
178
+ }
179
+ : null,
180
+ render: {
181
+ videoElementPresent: Boolean(videoElement),
182
+ readyState: videoElement?.readyState,
183
+ paused: videoElement?.paused,
184
+ videoWidth: videoElement?.videoWidth,
185
+ videoHeight: videoElement?.videoHeight,
186
+ },
187
+ },
188
+ );
189
+ lastLoggedOutcome = reportableOutcome;
190
+ }
191
+ };
192
+ healthTimer = setInterval(checkHealth, 5000);
193
+ return () => {
194
+ clearInterval(healthTimer);
195
+ let mediaTrack: MediaStreamTrack | null = null;
196
+ try {
197
+ mediaTrack = stream?.getMediaStreamTrack?.() || null;
198
+ } catch (_) {}
199
+ logger.log(
200
+ LogSource.AgoraSDK,
201
+ 'Event',
202
+ '[SCREENSHARE_JOURNEY] screen share video render unmounted',
203
+ {
204
+ action: 'stop',
205
+ stage: 'video_render_cleanup',
206
+ outcome: 'unmounted',
207
+ role: uid === 1 ? 'sender' : 'viewer',
208
+ screenshareSessionId:
209
+ uid === 1
210
+ ? window.engine?.getScreenshareMediaDiagnostics?.(uid)
211
+ ?.screenshareSessionId || 'unknown-session'
212
+ : 'remote-session-by-screen-uid',
213
+ screenShareUid: uid,
214
+ screenshareSurface,
215
+ renderInstanceId,
216
+ playbackCleanup: 'track_playback_stopped',
217
+ mediaTrack: mediaTrack
218
+ ? {
219
+ readyState: mediaTrack.readyState,
220
+ enabled: mediaTrack.enabled,
221
+ muted: mediaTrack.muted,
222
+ }
223
+ : null,
224
+ },
225
+ );
226
+ stream?.stop?.();
227
+ };
228
+ }
49
229
  return () => {
50
230
  console.log(`unmounting stream ${uid}`, stream);
51
231
  stream && stream.stop();
52
232
  };
53
233
  },
54
- [uid, renderMode, stream],
234
+ [
235
+ uid,
236
+ renderMode,
237
+ stream,
238
+ isScreenshare,
239
+ screenshareSurface,
240
+ renderInstanceId,
241
+ ],
55
242
  );
56
243
 
57
244
  return stream ? (
@@ -0,0 +1,358 @@
1
+ const mockCreateScreenVideoTrack = jest.fn();
2
+
3
+ jest.mock('agora-rtc-sdk-ng', () => ({
4
+ __esModule: true,
5
+ default: {
6
+ createScreenVideoTrack: mockCreateScreenVideoTrack,
7
+ setArea: jest.fn(),
8
+ setLogLevel: jest.fn(),
9
+ enableLogUpload: jest.fn(),
10
+ disableLogUpload: jest.fn(),
11
+ },
12
+ }));
13
+
14
+ jest.mock('react-native-agora', () => ({}));
15
+
16
+ jest.mock('../../../../agora-rn-uikit', () => ({
17
+ ChannelProfileType: {
18
+ ChannelProfileCommunication: 0,
19
+ ChannelProfileLiveBroadcasting: 1,
20
+ },
21
+ ClientRoleType: {
22
+ ClientRoleBroadcaster: 1,
23
+ ClientRoleAudience: 2,
24
+ },
25
+ }));
26
+
27
+ jest.mock('../../../../src/logger/AppBuilderLogger', () => ({
28
+ LogSource: {AgoraSDK: 'AgoraSDK'},
29
+ logger: {
30
+ log: jest.fn(),
31
+ debug: jest.fn(),
32
+ error: jest.fn(),
33
+ },
34
+ }));
35
+
36
+ type Deferred<T> = {
37
+ promise: Promise<T>;
38
+ resolve: (value: T) => void;
39
+ reject: (reason: unknown) => void;
40
+ };
41
+
42
+ const deferred = <T>(): Deferred<T> => {
43
+ let resolve!: (value: T) => void;
44
+ let reject!: (reason: unknown) => void;
45
+ const promise = new Promise<T>((res, rej) => {
46
+ resolve = res;
47
+ reject = rej;
48
+ });
49
+ return {promise, resolve, reject};
50
+ };
51
+
52
+ const createTrack = () => {
53
+ const handlers: Record<string, () => Promise<void>> = {};
54
+ return {
55
+ handlers,
56
+ track: {
57
+ on: jest.fn((event: string, handler: () => Promise<void>) => {
58
+ handlers[event] = handler;
59
+ }),
60
+ stop: jest.fn(),
61
+ close: jest.fn(),
62
+ getMediaStreamTrack: jest.fn(() => ({
63
+ readyState: 'ended',
64
+ enabled: true,
65
+ muted: false,
66
+ label: 'screen',
67
+ getSettings: () => ({}),
68
+ })),
69
+ },
70
+ };
71
+ };
72
+
73
+ describe('web RTC screen-share lifecycle', () => {
74
+ let RtcEngine: any;
75
+ let engine: any;
76
+ let screenClient: any;
77
+
78
+ const callScreenshare = (action: 'start' | 'stop', sessionId = 'session-1') =>
79
+ engine.startScreenshare(
80
+ 'token',
81
+ 'channel',
82
+ null,
83
+ 101,
84
+ 'app-id',
85
+ engine,
86
+ null,
87
+ {encoderConfig: '1080p_2'},
88
+ 'auto',
89
+ {
90
+ action,
91
+ screenshareAttemptId: `${action}-request`,
92
+ screenshareSessionId: sessionId,
93
+ screenShareUid: 101,
94
+ stopOrigin: 'toolbar',
95
+ },
96
+ );
97
+
98
+ beforeAll(() => {
99
+ (global as any).window = {};
100
+ RtcEngine = require('../RtcEngine').default;
101
+ });
102
+
103
+ beforeEach(() => {
104
+ jest.clearAllMocks();
105
+ engine = new RtcEngine();
106
+ engine.initialize({appId: 'app-id'});
107
+ screenClient = {
108
+ uid: 101,
109
+ join: jest.fn().mockResolvedValue(101),
110
+ publish: jest.fn().mockResolvedValue(undefined),
111
+ leave: jest.fn().mockResolvedValue(undefined),
112
+ setEncryptionConfig: jest.fn().mockResolvedValue(undefined),
113
+ };
114
+ engine.screenClient = screenClient;
115
+ engine.client = {off: jest.fn()};
116
+ });
117
+
118
+ it('rejects a second Start before it can open another picker', async () => {
119
+ const picker = deferred<any>();
120
+ const {track} = createTrack();
121
+ mockCreateScreenVideoTrack.mockReturnValueOnce(picker.promise);
122
+
123
+ const firstStart = callScreenshare('start');
124
+ await expect(
125
+ callScreenshare('start', 'duplicate-session'),
126
+ ).rejects.toMatchObject({code: 'SCREENSHARE_OPERATION_IN_PROGRESS'});
127
+
128
+ expect(mockCreateScreenVideoTrack).toHaveBeenCalledTimes(1);
129
+ picker.resolve(track);
130
+ await firstStart;
131
+ expect(screenClient.join).toHaveBeenCalledTimes(1);
132
+ expect(screenClient.publish).toHaveBeenCalledTimes(1);
133
+ });
134
+
135
+ it('executes an explicit Stop as a no-op when RTC is inactive', async () => {
136
+ const stopped = jest.fn();
137
+ engine.eventsMap.set('onScreenshareStopped', stopped);
138
+
139
+ await callScreenshare('stop');
140
+
141
+ expect(mockCreateScreenVideoTrack).not.toHaveBeenCalled();
142
+ expect(screenClient.join).not.toHaveBeenCalled();
143
+ expect(screenClient.leave).not.toHaveBeenCalled();
144
+ expect(stopped).toHaveBeenCalledTimes(1);
145
+ });
146
+
147
+ it('cleans only locally created tracks when Start fails', async () => {
148
+ const {track} = createTrack();
149
+ mockCreateScreenVideoTrack.mockResolvedValueOnce(track);
150
+ screenClient.join.mockRejectedValueOnce(
151
+ Object.assign(new Error('already connected'), {
152
+ code: 'INVALID_OPERATION',
153
+ }),
154
+ );
155
+
156
+ await expect(callScreenshare('start')).rejects.toMatchObject({
157
+ code: 'INVALID_OPERATION',
158
+ });
159
+
160
+ expect(track.stop).toHaveBeenCalledTimes(1);
161
+ expect(track.close).toHaveBeenCalledTimes(1);
162
+ expect(screenClient.leave).not.toHaveBeenCalled();
163
+ expect(engine.screenStream).toEqual({});
164
+ });
165
+
166
+ it('leaves and closes local tracks when publish fails', async () => {
167
+ const {track} = createTrack();
168
+ mockCreateScreenVideoTrack.mockResolvedValueOnce(track);
169
+ screenClient.publish.mockRejectedValueOnce(new Error('publish failed'));
170
+
171
+ await expect(callScreenshare('start')).rejects.toThrow('publish failed');
172
+
173
+ expect(track.stop).toHaveBeenCalledTimes(1);
174
+ expect(track.close).toHaveBeenCalledTimes(1);
175
+ expect(screenClient.leave).toHaveBeenCalledTimes(1);
176
+ expect(engine.screenStream).toEqual({});
177
+ });
178
+
179
+ it('preserves the publish error and attempts every cleanup operation when a track cleanup throws', async () => {
180
+ const {track} = createTrack();
181
+ const publishError = new Error('publish failed');
182
+ track.stop.mockImplementationOnce(() => {
183
+ throw new Error('track stop failed');
184
+ });
185
+ mockCreateScreenVideoTrack.mockResolvedValueOnce(track);
186
+ screenClient.publish.mockRejectedValueOnce(publishError);
187
+
188
+ await expect(callScreenshare('start')).rejects.toBe(publishError);
189
+
190
+ expect(track.stop).toHaveBeenCalledTimes(1);
191
+ expect(track.close).toHaveBeenCalledTimes(1);
192
+ expect(screenClient.leave).toHaveBeenCalledTimes(1);
193
+ expect(engine.screenStream).toEqual({});
194
+ expect(engine.inScreenshare).toBe(false);
195
+ });
196
+
197
+ it('returns to inactive after picker cancellation so a later Start can run', async () => {
198
+ mockCreateScreenVideoTrack.mockRejectedValueOnce(
199
+ Object.assign(new Error('permission denied'), {
200
+ code: 'PERMISSION_DENIED',
201
+ }),
202
+ );
203
+
204
+ await expect(callScreenshare('start')).rejects.toMatchObject({
205
+ code: 'PERMISSION_DENIED',
206
+ });
207
+
208
+ const {track} = createTrack();
209
+ mockCreateScreenVideoTrack.mockResolvedValueOnce(track);
210
+ await callScreenshare('start', 'session-2');
211
+
212
+ expect(mockCreateScreenVideoTrack).toHaveBeenCalledTimes(2);
213
+ expect(screenClient.join).toHaveBeenCalledTimes(1);
214
+ expect(screenClient.publish).toHaveBeenCalledTimes(1);
215
+ expect(screenClient.leave).not.toHaveBeenCalled();
216
+ });
217
+
218
+ it('does not publish a pending Start after end-call release', async () => {
219
+ const picker = deferred<any>();
220
+ const {track} = createTrack();
221
+ mockCreateScreenVideoTrack.mockReturnValueOnce(picker.promise);
222
+
223
+ const pendingStart = callScreenshare('start');
224
+ await engine.release('end_call_cleanup');
225
+ picker.resolve(track);
226
+
227
+ await expect(pendingStart).rejects.toMatchObject({
228
+ code: 'SCREENSHARE_START_CANCELLED',
229
+ });
230
+ expect(track.stop).toHaveBeenCalledTimes(1);
231
+ expect(track.close).toHaveBeenCalledTimes(1);
232
+ expect(screenClient.join).not.toHaveBeenCalled();
233
+ expect(screenClient.publish).not.toHaveBeenCalled();
234
+ });
235
+
236
+ it('ignores a stale track-ended callback after app cleanup', async () => {
237
+ const {track, handlers} = createTrack();
238
+ const stopped = jest.fn();
239
+ engine.eventsMap.set('onScreenshareStopped', stopped);
240
+ mockCreateScreenVideoTrack.mockResolvedValueOnce(track);
241
+
242
+ await callScreenshare('start');
243
+ await callScreenshare('stop');
244
+ await handlers['track-ended']();
245
+
246
+ expect(screenClient.leave).toHaveBeenCalledTimes(1);
247
+ expect(track.stop).toHaveBeenCalledTimes(1);
248
+ expect(track.close).toHaveBeenCalledTimes(1);
249
+ expect(stopped).toHaveBeenCalledTimes(1);
250
+ });
251
+
252
+ it('joins an existing cleanup when Stop is requested twice', async () => {
253
+ const {track} = createTrack();
254
+ const leave = deferred<void>();
255
+ const stopped = jest.fn();
256
+ engine.eventsMap.set('onScreenshareStopped', stopped);
257
+ mockCreateScreenVideoTrack.mockResolvedValueOnce(track);
258
+ screenClient.leave.mockReturnValueOnce(leave.promise);
259
+
260
+ await callScreenshare('start');
261
+ const firstStop = callScreenshare('stop');
262
+ const secondStop = callScreenshare('stop');
263
+ leave.resolve(undefined);
264
+ await Promise.all([firstStop, secondStop]);
265
+
266
+ expect(screenClient.leave).toHaveBeenCalledTimes(1);
267
+ expect(track.stop).toHaveBeenCalledTimes(1);
268
+ expect(track.close).toHaveBeenCalledTimes(1);
269
+ expect(stopped).toHaveBeenCalledTimes(1);
270
+ });
271
+
272
+ it('attempts leave and the stopped callback when track cleanup fails', async () => {
273
+ const {track} = createTrack();
274
+ const stopped = jest.fn();
275
+ engine.eventsMap.set('onScreenshareStopped', stopped);
276
+ mockCreateScreenVideoTrack.mockResolvedValueOnce(track);
277
+ track.stop.mockImplementationOnce(() => {
278
+ throw new Error('track stop failed');
279
+ });
280
+
281
+ await callScreenshare('start');
282
+ await expect(callScreenshare('stop')).resolves.toBeUndefined();
283
+
284
+ expect(track.close).toHaveBeenCalledTimes(1);
285
+ expect(screenClient.leave).toHaveBeenCalledTimes(1);
286
+ expect(stopped).toHaveBeenCalledTimes(1);
287
+ expect(engine.inScreenshare).toBe(false);
288
+ });
289
+
290
+ it('handles leave rejection from native track-ended without rejecting the SDK callback', async () => {
291
+ const {track, handlers} = createTrack();
292
+ const stopped = jest.fn();
293
+ engine.eventsMap.set('onScreenshareStopped', stopped);
294
+ mockCreateScreenVideoTrack.mockResolvedValueOnce(track);
295
+ screenClient.leave.mockRejectedValueOnce(new Error('leave failed'));
296
+
297
+ await callScreenshare('start');
298
+ await expect(handlers['track-ended']()).resolves.toBeUndefined();
299
+
300
+ expect(screenClient.leave).toHaveBeenCalledTimes(1);
301
+ expect(stopped).toHaveBeenCalledTimes(1);
302
+ expect(engine.inScreenshare).toBe(false);
303
+ const {logger} = require('../../../../src/logger/AppBuilderLogger');
304
+ const journeyMessages = logger.log.mock.calls
305
+ .map((call: unknown[]) => call[2])
306
+ .filter((message: unknown) =>
307
+ String(message).includes('[SCREENSHARE_JOURNEY]'),
308
+ );
309
+ expect(journeyMessages[journeyMessages.length - 1]).toBe(
310
+ '----- [SCREENSHARE_JOURNEY] SCREEN SHARE SESSION END | sessionId=session-1 -----',
311
+ );
312
+ });
313
+
314
+ it('writes the session end boundary after release cleanup logs', async () => {
315
+ const {track} = createTrack();
316
+ const stopped = jest.fn();
317
+ engine.eventsMap.set('onScreenshareStopped', stopped);
318
+ mockCreateScreenVideoTrack.mockResolvedValueOnce(track);
319
+
320
+ await callScreenshare('start');
321
+ await engine.release('end_call_cleanup');
322
+
323
+ const {logger} = require('../../../../src/logger/AppBuilderLogger');
324
+ const journeyMessages = logger.log.mock.calls
325
+ .map((call: unknown[]) => call[2])
326
+ .filter((message: unknown) =>
327
+ String(message).includes('[SCREENSHARE_JOURNEY]'),
328
+ );
329
+ expect(journeyMessages[journeyMessages.length - 1]).toBe(
330
+ '----- [SCREENSHARE_JOURNEY] SCREEN SHARE SESSION END | sessionId=session-1 -----',
331
+ );
332
+ });
333
+
334
+ it('logs a receiver screen-share termination once for an RTC event', () => {
335
+ const remoteTrack = createTrack().track;
336
+ engine.remoteStreams.set(202, {video: remoteTrack});
337
+ engine.registerRemoteScreenshareUid(202);
338
+
339
+ engine.logRemoteScreenshareTermination(202, 'user-unpublished', 'video');
340
+ engine.logRemoteScreenshareTermination(202, 'user-left');
341
+
342
+ const {logger} = require('../../../../src/logger/AppBuilderLogger');
343
+ const terminationLogs = logger.log.mock.calls.filter((call: unknown[]) =>
344
+ String(call[2]).includes(
345
+ 'receiver detected screen share stopped through RTC',
346
+ ),
347
+ );
348
+ expect(terminationLogs).toHaveLength(1);
349
+ expect(terminationLogs[0][2]).toContain('user-unpublished');
350
+ expect(terminationLogs[0][3]).toMatchObject({
351
+ action: 'stop',
352
+ stage: 'receiver_rtc_termination',
353
+ role: 'viewer',
354
+ screenShareUid: 202,
355
+ sdkEvent: 'user-unpublished',
356
+ });
357
+ });
358
+ });
@@ -0,0 +1,49 @@
1
+ import {getScreenshareMediaHealthOutcome} from '../screenshareMediaHealth';
2
+
3
+ describe('screen-share media health classification', () => {
4
+ it('classifies flowing and rendered video as healthy', () => {
5
+ expect(
6
+ getScreenshareMediaHealthOutcome({
7
+ trackReadyState: 'live',
8
+ bitrate: 900000,
9
+ frameRate: 15,
10
+ width: 1920,
11
+ height: 1080,
12
+ videoElementPresent: true,
13
+ renderedWidth: 1920,
14
+ renderedHeight: 1080,
15
+ }),
16
+ ).toBe('healthy');
17
+ });
18
+
19
+ it('classifies zero bitrate or frames as suspected blank video', () => {
20
+ expect(
21
+ getScreenshareMediaHealthOutcome({
22
+ trackReadyState: 'live',
23
+ bitrate: 0,
24
+ frameRate: 0,
25
+ width: 1920,
26
+ height: 1080,
27
+ }),
28
+ ).toBe('suspected_blank');
29
+ });
30
+
31
+ it('classifies a missing render element as suspected blank video', () => {
32
+ expect(
33
+ getScreenshareMediaHealthOutcome({
34
+ trackReadyState: 'live',
35
+ bitrate: 900000,
36
+ frameRate: 15,
37
+ width: 1920,
38
+ height: 1080,
39
+ videoElementPresent: false,
40
+ }),
41
+ ).toBe('suspected_blank');
42
+ });
43
+
44
+ it('reports an ended source track separately', () => {
45
+ expect(getScreenshareMediaHealthOutcome({trackReadyState: 'ended'})).toBe(
46
+ 'track_ended',
47
+ );
48
+ });
49
+ });