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.
- package/package.json +2 -2
- package/template/.eslintrc.js +20 -0
- package/template/.prettierrc.js +18 -0
- package/template/.watchmanconfig +1 -0
- package/template/agora-rn-uikit/src/Views/MaxVideoView.tsx +2 -0
- package/template/agora-rn-uikit/src/Views/MinVideoView.tsx +12 -2
- package/template/bridge/rtc/webNg/RtcEngine.ts +639 -251
- package/template/bridge/rtc/webNg/RtcSurfaceView.tsx +203 -16
- package/template/bridge/rtc/webNg/__tests__/screenshareLifecycle.test.ts +358 -0
- package/template/bridge/rtc/webNg/__tests__/screenshareMediaHealth.test.ts +49 -0
- package/template/bridge/rtc/webNg/screenshareMediaHealth.ts +49 -0
- package/template/bridge/rtm/web/index.ts +22 -31
- package/template/config.json +101 -0
- package/template/customization-api/atoms.ts +0 -2
- package/template/customization-api/customEvents.ts +1 -4
- package/template/customization-api/index.ts +0 -1
- package/template/customization-api/typeDefinition.ts +1 -6
- package/template/defaultConfig.js +2 -2
- package/template/src/components/Controls.tsx +12 -6
- package/template/src/components/CustomSidePanel.tsx +13 -13
- package/template/src/components/RTMConfigure.tsx +0 -14
- package/template/src/components/participants/UserActionMenuOptions.tsx +1 -28
- package/template/src/logger/transports/__tests__/agora-transport.test.ts +48 -0
- package/template/src/logger/transports/agora-transport.ts +18 -6
- package/template/src/pages/Create.tsx +19 -31
- package/template/src/pages/video-call/ActionSheet.native.tsx +0 -2
- package/template/src/pages/video-call/ActionSheet.tsx +0 -2
- package/template/src/pages/video-call/ActionSheetHandle.tsx +0 -1
- package/template/src/pages/video-call/SidePanelHeader.tsx +1 -3
- package/template/src/pages/video-call/VideoCallScreen.tsx +0 -3
- package/template/src/pages/video-call/VideoRenderer.tsx +0 -4
- package/template/src/rtm-events-api/Events.ts +3 -1
- package/template/src/rtm-events-api/__tests__/Events.test.ts +55 -0
- package/template/src/subComponents/SidePanelHeader.tsx +2 -16
- package/template/src/subComponents/screenshare/ScreenshareButton.tsx +38 -4
- package/template/src/subComponents/screenshare/ScreenshareConfigure.tsx +280 -34
- package/template/src/subComponents/screenshare/__tests__/screenshareJourney.test.ts +10 -0
- package/template/src/subComponents/screenshare/__tests__/screenshareOperation.test.ts +31 -0
- package/template/src/subComponents/screenshare/screenshareJourney.ts +6 -0
- package/template/src/subComponents/screenshare/screenshareOperation.ts +21 -0
- package/template/src/subComponents/screenshare/useScreenshare.tsx +8 -0
- package/template/theme.json +91 -0
- 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 {
|
|
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
|
|
53
|
+
? window.engine?.localStream?.video
|
|
37
54
|
: uid === 1
|
|
38
|
-
? window.engine
|
|
39
|
-
: window.engine
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
[
|
|
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
|
+
});
|