From f016d57f23fb776dcf3f46d4cdbeaca7e7eb4f18 Mon Sep 17 00:00:00 2001 From: pabloFuente Date: Thu, 22 Oct 2026 22:43:22 +0200 Subject: [PATCH] openvidu-test-e2e: Participant Passthrough with several tracks of a kind 23 tests for a participant publishing more than one audio or video track: screen share and its audio next to the camera and the microphone, before them, alone, muted, stopped and resumed, two cameras, two microphones, the first of each unpublished, six tracks, mixed codecs, screen_share mode, MediaSource with two audios, WebM, publisher packet loss with two video tracks, and the manifest's track list against ffprobe. The synthetic screen gets an audio track when the capture asks for one, the Publisher helper drives the screen options dialog and addresses cameras and microphones by track sid, since the testapp lists tracks by sid, and parks the mouse after the add-track buttons. Also publisherPacketLossH264Test, publisherPacketLossAv1Test, publisherJitterVp9Test and publisherJitterAv1Test. The AV1 loss test found the SFU switching temporal layers up on a frame whose T1 reference it had dropped (openvidu-pro-mediasoup 4326f0c19); it carries a TODO and fails on mediasoup until the server image ships a worker released from that commit. Assisted-by: claude-code - Anthropic - Claude Fable 5.1 --- .../e2e/OpenViduTestAppE2eEgressProTest.java | 881 +++++++++++++++++- .../participant-passthrough-sync-media.js | 10 +- 2 files changed, 887 insertions(+), 4 deletions(-) diff --git a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eEgressProTest.java b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eEgressProTest.java index eedb668ed..0134f3de7 100644 --- a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eEgressProTest.java +++ b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eEgressProTest.java @@ -11,6 +11,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.DoubleSummaryStatistics; import java.util.List; +import java.util.Map; import java.util.Locale; import java.util.Random; import java.util.concurrent.CompletableFuture; @@ -36,6 +37,7 @@ import org.openqa.selenium.JavascriptExecutor; import org.openqa.selenium.Keys; import org.openqa.selenium.WebElement; import org.openqa.selenium.interactions.Actions; +import org.openqa.selenium.support.ui.ExpectedConditions; import org.springframework.test.context.junit.jupiter.SpringExtension; import com.google.gson.JsonArray; @@ -230,6 +232,7 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT // The testapp components of the local tracks, by source private static final String CAMERA = "app-video-track:has(video.local[id*='--camera--'])"; private static final String MICROPHONE = "app-audio-track:has(audio.local[id*='--microphone--'])"; + private static final String SCREEN = "app-video-track:has(video.local[id*='--screen_share--'])"; private static String syncMediaScript; private static MinioClient minio; @@ -1387,6 +1390,713 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT // Screen share // ------------------------------------------------------------------------ + // ----- Several tracks of a kind: simultaneous tracks of the same source ----- + + @Test + @DisplayName("Two cameras at once") + void twoCamerasTest() throws Exception { + log.info("Participant Passthrough: two cameras at once"); + String room = uniqueRoom("two-cameras"); + Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, true, true); + String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv")); + waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); + Thread.sleep(4000); + alice.addVideo(); + Thread.sleep(8000); + // the second camera is a track of its own, non-default: players keep showing the first + Recording recording = stopAndGetRecording(egressId); + recording.assertFinalized(); + recording.assertTracks("audio:opus", "video:vp8", "video:vp8"); + recording.assertHealthy(); + recording.assertStartTogether(); + recording.assertAvSync(5); + Assertions.assertEquals(2, recording.allNamed("camera").size(), "Two camera tracks: " + recording.streams); + recording.assertDefaults("camera", "microphone"); + FileTrack first = recording.named("camera", 0); + FileTrack second = recording.named("camera", 1); + Assertions.assertTrue(first.isDefault && !second.isDefault, "The first camera is the default: " + recording.streams); + first.assertReachedSize(CAMERA_WIDTH, CAMERA_HEIGHT); + second.assertReachedSize(CAMERA_WIDTH, CAMERA_HEIGHT); + first.assertMarkers(9); + second.assertMarkers(5); + double secondStart = second.firstPacketTime() - first.firstPacketTime(); + Assertions.assertTrue(secondStart > 2 && secondStart < 7, + "The second camera must start about 4 s after the first, not " + secondStart + " s: " + second); + Assertions.assertTrue(first.lastPacketTime() > second.firstPacketTime() + 5, + "The first camera must go on next to the second: " + first + " / " + second); + } + + @Test + @DisplayName("Two microphones at once") + void twoMicrophonesTest() throws Exception { + log.info("Participant Passthrough: two microphones at once"); + String room = uniqueRoom("two-mics"); + Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, true, true); + String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv")); + waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); + Thread.sleep(4000); + alice.addAudio(); + Thread.sleep(8000); + // the second microphone is a track of its own, non-default; both carry the beeps + Recording recording = stopAndGetRecording(egressId); + recording.assertFinalized(); + recording.assertTracks("audio:opus", "audio:opus", "video:vp8"); + recording.assertHealthy(); + recording.assertStartTogether(); + recording.assertAvSync(5); + recording.assertDefaults("camera", "microphone"); + FileTrack first = recording.named("microphone", 0); + FileTrack second = recording.named("microphone", 1); + Assertions.assertTrue(first.isDefault && !second.isDefault, + "The first microphone is the default: " + recording.streams); + first.assertMarkers(9); + second.assertMarkers(5); + double secondStart = second.firstPacketTime() - first.firstPacketTime(); + Assertions.assertTrue(secondStart > 2 && secondStart < 7, + "The second microphone must start about 4 s after the first, not " + secondStart + " s: " + second); + } + + @Test + @DisplayName("First camera unpublished, the second stays non-default, a third continues the first") + void firstCameraUnpublishedSecondStaysTest() throws Exception { + log.info("Participant Passthrough: first camera unpublished while a second one goes on"); + String room = uniqueRoom("first-camera-off"); + Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, true, true); + String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv")); + waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); + Thread.sleep(3000); + alice.addVideo(); + Thread.sleep(4000); + alice.unpublishVideo(0); + Thread.sleep(4000); + alice.addVideo(); + Thread.sleep(5000); + // the default track ends with the first camera and continues with the third; + // the second camera stays in its own, non-default track throughout + Recording recording = stopAndGetRecording(egressId); + recording.assertFinalized(); + recording.assertTracks("audio:opus", "video:vp8", "video:vp8"); + recording.assertHealthy(); + recording.assertAvSync(5); + recording.assertDefaults("camera", "microphone"); + FileTrack first = recording.named("camera", 0); + FileTrack second = recording.named("camera", 1); + Assertions.assertTrue(first.isDefault && !second.isDefault, "The first camera is the default: " + recording.streams); + List gaps = first.gaps(1); + Assertions.assertEquals(1, gaps.size(), + "The default track must have one gap, between the first camera and the third: " + first); + double gap = gaps.get(0)[1] - gaps.get(0)[0]; + Assertions.assertTrue(gap > 2 && gap < 7, "The gap must last about 4 s, not " + gap + " s: " + first); + Assertions.assertTrue(second.gaps(1).isEmpty(), "The second camera must go on throughout: " + second); + Assertions.assertTrue(first.lastPacketTime() > second.firstPacketTime() + 6, + "The third camera must continue the default track to the end: " + first + " / " + second); + } + + @Test + @DisplayName("First microphone unpublished, the second stays non-default") + void firstMicrophoneUnpublishedTest() throws Exception { + log.info("Participant Passthrough: first microphone unpublished while a second one goes on"); + String room = uniqueRoom("first-mic-off"); + Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, true, true); + String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv")); + waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); + Thread.sleep(3000); + alice.addAudio(); + Thread.sleep(4000); + alice.unpublishAudio(0); + Thread.sleep(6000); + // the default audio track is silence from then on; the second microphone keeps + // its beeps in its own, non-default track + Recording recording = stopAndGetRecording(egressId); + recording.assertFinalized(); + recording.assertTracks("audio:opus", "audio:opus", "video:vp8"); + recording.assertHealthy(); + recording.assertAvSync(5); + recording.assertDefaults("camera", "microphone"); + FileTrack first = recording.named("microphone", 0); + FileTrack second = recording.named("microphone", 1); + Assertions.assertTrue(first.isDefault && !second.isDefault, + "The first microphone is the default: " + recording.streams); + double firstLastBeep = first.markers.get(first.markers.size() - 1); + double secondLastBeep = second.markers.get(second.markers.size() - 1); + Assertions.assertTrue(secondLastBeep > firstLastBeep + 4, + "The second microphone must beep on after the first was unpublished: " + first + " / " + second); + Assertions.assertTrue(first.lastPacketTime() > second.lastPacketTime() - 1, + "The default audio track must be filled to the end: " + first + " / " + second); + } + + @Test + @DisplayName("Camera, microphone, screen and its audio published before the recording starts") + void everythingAtOnceTest() throws Exception { + log.info("Participant Passthrough: every track published before the recording starts"); + String room = uniqueRoom("everything"); + Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, true, true); + alice.addScreen(true); + Thread.sleep(2000); + String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv")); + waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); + Thread.sleep(10000); + // the four tracks are subscribed at start and start together + Recording recording = stopAndGetRecording(egressId); + recording.assertFinalized(); + recording.assertTracks("audio:opus", "audio:opus", "video:vp8", "video:vp8"); + recording.assertHealthy(); + recording.assertAvSync(5); + recording.assertDefaults("camera", "microphone"); + double firstStart = recording.streams.stream().mapToDouble(FileTrack::firstPacketTime).min().getAsDouble(); + double lastStart = recording.streams.stream().mapToDouble(FileTrack::firstPacketTime).max().getAsDouble(); + Assertions.assertTrue(lastStart - firstStart < 3.5, + "The four tracks must start together, not " + (lastStart - firstStart) + " s apart: " + recording.streams); + for (FileTrack track : recording.streams) { + track.assertMarkers(6); + } + } + + @Test + @DisplayName("Six tracks: two cameras, two microphones, screen and its audio") + void sixTracksTest() throws Exception { + log.info("Participant Passthrough: six tracks"); + String room = uniqueRoom("six-tracks"); + Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, true, true); + alice.addVideo(); + alice.addAudio(); + alice.addScreen(true); + Thread.sleep(2000); + String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv")); + waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); + Thread.sleep(12000); + Recording recording = stopAndGetRecording(egressId); + recording.assertFinalized(); + recording.assertTracks("audio:opus", "audio:opus", "audio:opus", "video:vp8", "video:vp8", "video:vp8"); + recording.assertHealthy(); + recording.assertAvSync(5); + recording.assertDefaults("camera", "microphone"); + Assertions.assertEquals(2, recording.allNamed("camera").size(), "Two camera tracks: " + recording.streams); + Assertions.assertEquals(2, recording.allNamed("microphone").size(), "Two microphone tracks: " + recording.streams); + Assertions.assertTrue(recording.named("camera", 0).isDefault && recording.named("microphone", 0).isDefault, + "The first camera and the first microphone are the defaults: " + recording.streams); + recording.named("screen_share").assertReachedSize(SCREEN_WIDTH, SCREEN_HEIGHT); + for (FileTrack track : recording.streams) { + track.assertMarkers(6); + } + } + + // ----- Order of arrival versus the default flag ----- + + @Test + @DisplayName("Screen shared before the camera is published") + void screenBeforeCameraTest() throws Exception { + log.info("Participant Passthrough: screen shared before the camera"); + String room = uniqueRoom("screen-first"); + Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, true, false); + alice.addScreen(); + String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv")); + waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); + Thread.sleep(5000); + alice.addVideo(); + Thread.sleep(8000); + // the camera takes the default flag from the screen when it arrives + Recording recording = stopAndGetRecording(egressId); + recording.assertFinalized(); + recording.assertTracks("audio:opus", "video:vp8", "video:vp8"); + recording.assertHealthy(); + recording.assertAvSync(5); + recording.assertDefaults("camera", "microphone"); + FileTrack screen = recording.named("screen_share"); + FileTrack camera = recording.named("camera"); + Assertions.assertTrue(screen.index < camera.index, "The screen is the earlier track: " + recording.streams); + Assertions.assertTrue(camera.firstPacketTime() > screen.firstPacketTime() + 3, + "The camera starts after the screen: " + camera + " / " + screen); + camera.assertReachedSize(CAMERA_WIDTH, CAMERA_HEIGHT); + screen.assertReachedSize(SCREEN_WIDTH, SCREEN_HEIGHT); + } + + @Test + @DisplayName("Screen audio before the microphone is published") + void tabAudioBeforeMicrophoneTest() throws Exception { + log.info("Participant Passthrough: screen audio before the microphone"); + String room = uniqueRoom("tab-audio-first"); + Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, false, false); + alice.addScreen(true); + String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv")); + waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); + Thread.sleep(5000); + alice.addAudio(); + Thread.sleep(8000); + // the microphone takes the default flag from the screen's audio when it arrives + Recording recording = stopAndGetRecording(egressId); + recording.assertFinalized(); + recording.assertTracks("audio:opus", "audio:opus", "video:vp8"); + recording.assertHealthy(); + recording.assertAvSync(5); + recording.assertDefaults("screen_share", "microphone"); + FileTrack tabAudio = recording.named("screen_share_audio"); + FileTrack microphone = recording.named("microphone"); + Assertions.assertTrue(tabAudio.index < microphone.index, "The screen audio is the earlier track: " + recording.streams); + Assertions.assertTrue(microphone.firstPacketTime() > tabAudio.firstPacketTime() + 3, + "The microphone starts after the screen audio: " + microphone + " / " + tabAudio); + tabAudio.assertMarkers(9); + microphone.assertMarkers(5); + } + + @Test + @DisplayName("Participant with a screen share only") + void screenOnlyParticipantTest() throws Exception { + log.info("Participant Passthrough: screen share and its audio only"); + String room = uniqueRoom("screen-only"); + Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, false, false); + alice.addScreen(true); + String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv")); + waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); + Thread.sleep(10000); + // the only tracks of their kind: both default + Recording recording = stopAndGetRecording(egressId); + recording.assertFinalized(); + recording.assertTracks("audio:opus", "video:vp8"); + recording.assertHealthy(); + recording.assertStartTogether(); + recording.assertAvSync(5); + recording.assertDefaults("screen_share", "screen_share_audio"); + recording.named("screen_share").assertReachedSize(SCREEN_WIDTH, SCREEN_HEIGHT); + } + + @Test + @DisplayName("Playable while recording, the camera arriving after the screen") + void playableWhileRecordingMultiTrackTest() throws Exception { + log.info("Participant Passthrough: playable while recording with the camera after the screen"); + String room = uniqueRoom("playable-multi"); + Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, true, false); + alice.addScreen(); + String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv")); + waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); + Thread.sleep(5000); + // the screen is the only video so far, and the default one + playInPlace(egressId); + Recording screenOnly = snapshotRecordingInProgress(egressId); + screenOnly.assertInProgress(); + screenOnly.assertTracks("audio:opus", "video:vp8"); + screenOnly.assertDefaults("screen_share", "microphone"); + // the camera is published: the growing file already shows it as the default + alice.addVideo(); + Thread.sleep(6000); + playInPlace(egressId); + Recording withCamera = snapshotRecordingInProgress(egressId); + withCamera.assertInProgress(); + withCamera.assertTracks("audio:opus", "video:vp8", "video:vp8"); + withCamera.assertHealthy(); + withCamera.assertDefaults("camera", "microphone"); + Recording recording = stopAndGetRecording(egressId); + recording.assertFinalized(); + recording.assertTracks("audio:opus", "video:vp8", "video:vp8"); + recording.assertHealthy(); + recording.assertAvSync(5); + recording.assertDefaults("camera", "microphone"); + } + + // ----- Lifecycle of the non-default tracks ----- + + @Test + @DisplayName("Screen share muted and unmuted") + void screenMutedUnmutedTest() throws Exception { + log.info("Participant Passthrough: screen share muted and unmuted"); + String room = uniqueRoom("screen-mute"); + Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, true, true); + alice.addScreen(); + String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv")); + waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); + Thread.sleep(4000); + alice.muteScreen(); + Thread.sleep(5000); + alice.unmuteScreen(); + Thread.sleep(5000); + // a gap in the screen track only + Recording recording = stopAndGetRecording(egressId); + recording.assertFinalized(); + recording.assertTracks("audio:opus", "video:vp8", "video:vp8"); + recording.assertHealthy(); + recording.assertAvSync(5); + recording.assertDefaults("camera", "microphone"); + FileTrack screen = recording.named("screen_share"); + List gaps = screen.gaps(1); + Assertions.assertEquals(1, gaps.size(), "The screen track must have one gap, while it was muted: " + screen); + double gap = gaps.get(0)[1] - gaps.get(0)[0]; + Assertions.assertTrue(gap > 3 && gap < 8, "The gap must last about 5 s, not " + gap + " s: " + screen); + Assertions.assertTrue(recording.named("camera").gaps(1).isEmpty(), + "The camera must go on throughout: " + recording.named("camera")); + } + + @Test + @DisplayName("Camera and microphone unpublished while the screen share goes on") + void defaultTracksEndWhileScreenGoesOnTest() throws Exception { + log.info("Participant Passthrough: camera and microphone unpublished while the screen share goes on"); + String room = uniqueRoom("defaults-end"); + Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, true, true); + String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv")); + waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); + Thread.sleep(3000); + alice.addScreen(true); + Thread.sleep(5000); + alice.unpublishVideo(); + alice.unpublishAudio(); + Thread.sleep(8000); + // the default tracks end (the microphone as silence) and the screen and its + // audio go on to the end: what players show is the known limitation + Recording recording = stopAndGetRecording(egressId); + recording.assertFinalized(); + recording.assertTracks("audio:opus", "audio:opus", "video:vp8", "video:vp8"); + recording.assertHealthy(); + recording.assertAvSync(5); + recording.assertDefaults("camera", "microphone"); + FileTrack camera = recording.named("camera"); + FileTrack microphone = recording.named("microphone"); + FileTrack screen = recording.named("screen_share"); + FileTrack tabAudio = recording.named("screen_share_audio"); + Assertions.assertTrue(camera.lastPacketTime() < screen.lastPacketTime() - 6, + "The camera must end well before the screen: " + camera + " / " + screen); + double micLastBeep = microphone.markers.get(microphone.markers.size() - 1); + double tabLastBeep = tabAudio.markers.get(tabAudio.markers.size() - 1); + Assertions.assertTrue(tabLastBeep > micLastBeep + 6, + "The screen audio must beep on after the microphone was unpublished: " + microphone + " / " + tabAudio); + Assertions.assertTrue(screen.lastPacketTime() > recording.duration - 1.5, + "The screen must run to the end of the file: " + screen + ", duration " + recording.duration); + Assertions.assertTrue(microphone.lastPacketTime() > recording.duration - 1.5, + "The default audio track must be filled to the end: " + microphone + ", duration " + recording.duration); + } + + @Test + @DisplayName("Screen share with another codec than the camera") + void screenWithAnotherCodecTest() throws Exception { + log.info("Participant Passthrough: screen share with another codec than the camera"); + String room = uniqueRoom("screen-codec"); + Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, true, true); + String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv")); + waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); + Thread.sleep(3000); + alice.setVideoCodec("h264"); + alice.addScreen(); + Thread.sleep(8000); + Recording recording = stopAndGetRecording(egressId); + recording.assertFinalized(); + recording.assertTracks("audio:opus", "video:vp8", "video:h264"); + recording.assertHealthy(); + recording.assertAvSync(5); + recording.assertDefaults("camera", "microphone"); + Assertions.assertEquals("vp8", recording.named("camera").codec, "The camera is VP8: " + recording.streams); + Assertions.assertEquals("h264", recording.named("screen_share").codec, "The screen is H.264: " + recording.streams); + recording.named("screen_share").assertReachedSize(SCREEN_WIDTH, SCREEN_HEIGHT); + } + + @Test + @DisplayName("Camera republished with another codec during a screen share") + void cameraCodecChangeDuringShareTest() throws Exception { + log.info("Participant Passthrough: camera republished with another codec during a screen share"); + String room = uniqueRoom("camera-codec-share"); + Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, true, true); + String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv")); + waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); + Thread.sleep(3000); + alice.addScreen(); + Thread.sleep(4000); + alice.unpublishVideo(); + alice.setVideoCodec("h264"); + alice.addVideo(); + Thread.sleep(6000); + // three video tracks, exactly one default: the first camera + Recording recording = stopAndGetRecording(egressId); + recording.assertFinalized(); + recording.assertTracks("audio:opus", "video:vp8", "video:vp8", "video:h264"); + recording.assertHealthy(); + recording.assertAvSync(5); + recording.assertDefaults("camera", "microphone"); + Assertions.assertEquals(2, recording.allNamed("camera").size(), "Two camera tracks: " + recording.streams); + FileTrack first = recording.named("camera", 0); + FileTrack second = recording.named("camera", 1); + Assertions.assertTrue(first.isDefault && "vp8".equals(first.codec), "The VP8 camera is the default: " + recording.streams); + Assertions.assertTrue(!second.isDefault && "h264".equals(second.codec), + "The H.264 camera is a non-default track: " + recording.streams); + Assertions.assertTrue(first.lastPacketTime() <= second.firstPacketTime(), + "The H.264 camera must follow the VP8 one: " + first + " / " + second); + Assertions.assertFalse(recording.named("screen_share").isDefault, "The screen is non-default: " + recording.streams); + } + + // ----- Request modes ----- + + @Test + @DisplayName("screen_share mode with a screen share and its audio") + void screenShareModeWithTabAudioTest() throws Exception { + log.info("Participant Passthrough: screen_share mode with the screen's audio"); + String room = uniqueRoom("screen-mode-audio"); + Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, true, true); + alice.addScreen(true); + // the screen share and its audio, not the camera or the microphone + String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv"), true); + waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); + Thread.sleep(10000); + Recording recording = stopAndGetRecording(egressId); + recording.assertFinalized(); + recording.assertTracks("audio:opus", "video:vp8"); + recording.assertHealthy(); + recording.assertStartTogether(); + recording.assertAvSync(5); + recording.assertDefaults("screen_share", "screen_share_audio"); + recording.named("screen_share").assertReachedSize(SCREEN_WIDTH, SCREEN_HEIGHT); + } + + @Test + @DisplayName("MediaSource audio route by identity with the microphone and the screen's audio") + void mediaSourceMicrophoneAndTabAudioTest() throws Exception { + log.info("Participant Passthrough: MediaSource with the microphone and the screen's audio"); + String room = uniqueRoom("media-two-audios"); + Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, true, true); + alice.addScreen(true); + JsonObject media = new JsonObject(); + media.add("participant_video", participantVideo("alice", true)); + media.add("audio", audioRoutes(audioRoute("participant_identity", "alice"))); + String egressId = startMediaRecording(room, media, filepath(room, "alice", "mkv")); + waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); + Thread.sleep(10000); + // the route matches both audio tracks: the microphone is the default one, + // whichever reached the file first + Recording recording = stopAndGetRecording(egressId); + recording.assertFinalized(); + recording.assertTracks("audio:opus", "audio:opus", "video:vp8"); + recording.assertHealthy(); + recording.assertAvSync(5); + recording.assertDefaults("screen_share", "microphone"); + recording.named("screen_share_audio").assertMarkers(6); + recording.named("microphone").assertMarkers(6); + } + + // ----- Robustness ----- + + @Test + @DisplayName("WebM with four tracks") + void webmMultiTrackTest() throws Exception { + log.info("Participant Passthrough: WebM with camera, microphone, screen and its audio"); + String room = uniqueRoom("webm-multi"); + Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, true, true); + alice.addScreen(true); + String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "webm")); + waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); + Thread.sleep(10000); + Recording recording = stopAndGetRecording(egressId); + recording.assertFinalized(); + recording.assertDocType("webm"); + recording.assertTracks("audio:opus", "audio:opus", "video:vp8", "video:vp8"); + recording.assertHealthy(); + recording.assertAvSync(5); + recording.assertDefaults("camera", "microphone"); + } + + @Test + @DisplayName("Packet loss on the publisher's uplink with the camera and a screen share") + void publisherPacketLossMultiTrackTest() throws Exception { + log.info("Participant Passthrough: packet loss on the publisher's uplink with two video tracks"); + String room = uniqueRoom("loss-multi"); + OpenViduTestappUser bridged = setupNetemBrowserUser(prepareNetemBrowsers()); + ((JavascriptExecutor) bridged.getDriver()).executeScript(syncMediaScript); + Publisher alice = addPublisherInstance(bridged, room, "alice", "vp8", true, true, true); + alice.addScreen(); + String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv")); + waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); + long active = System.currentTimeMillis(); + Thread.sleep(15000); + double troubleFrom = secondsSince(active); + try { + NetworkConditioner.applyLossToOutboundPackets(getNetemContainerName(bridged), SFU_MEDIA_PORTS, + PUBLISHER_PACKET_LOSS_PERCENT, 40); + Thread.sleep(10000); + } finally { + NetworkConditioner.clear(); + } + double troubleTo = secondsSince(active); + log.info("PUBLISHER_LOSS with two video tracks from {} s to {} s after the egress became active", + String.format(Locale.ROOT, "%.1f", troubleFrom), String.format(Locale.ROOT, "%.1f", troubleTo)); + Thread.sleep(20000); + // both video tracks drop frames until a keyframe on their own: neither decodes + // into a corrupted picture, both come back at their size + Recording recording = stopAndGetRecording(egressId); + recording.assertFinalized(); + recording.assertTracks("audio:opus", "video:vp8", "video:vp8"); + recording.assertDecodesWithoutErrors(); + recording.assertPicturesIntact(); + recording.assertAudioContinuous(); + recording.assertNoTinyVideoFrames(); + recording.assertDefaults("camera", "microphone"); + recording.named("camera").assertReachesSizeAfter(CAMERA_WIDTH, CAMERA_HEIGHT, troubleTo); + recording.named("screen_share").assertReachesSizeAfter(SCREEN_WIDTH, SCREEN_HEIGHT, troubleTo); + } + + @Test + @DisplayName("The manifest lists the tracks of the file") + void manifestTracksTest() throws Exception { + log.info("Participant Passthrough: the manifest lists the tracks"); + String room = uniqueRoom("manifest"); + Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, true, true); + alice.addScreen(true); + String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv")); + waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); + Thread.sleep(8000); + Recording recording = stopAndGetRecording(egressId); + recording.assertFinalized(); + recording.assertTracks("audio:opus", "audio:opus", "video:vp8", "video:vp8"); + recording.assertDefaults("camera", "microphone"); + JsonObject manifest = getManifest(egressId); + Assertions.assertEquals("alice", manifest.get("publisher_identity").getAsString(), "Manifest: " + manifest); + JsonArray tracks = manifest.getAsJsonArray("tracks"); + Assertions.assertNotNull(tracks, "The manifest must list the tracks: " + manifest); + Assertions.assertEquals(recording.streams.size(), tracks.size(), "One manifest entry per file track: " + manifest); + Map codecs = Map.of("V_VP8", "vp8", "V_VP9", "vp9", "V_MPEG4/ISO/AVC", "h264", "V_AV1", "av1", + "A_OPUS", "opus"); + for (int i = 0; i < tracks.size(); i++) { + JsonObject entry = tracks.get(i).getAsJsonObject(); + FileTrack track = recording.streams.get(i); + Assertions.assertEquals(i + 1, entry.get("number").getAsInt(), "Track number: " + entry); + Assertions.assertEquals(track.type, entry.get("kind").getAsString(), "Track kind: " + entry + " / " + track); + Assertions.assertEquals(track.codec, codecs.get(entry.get("codec").getAsString()), + "Track codec: " + entry + " / " + track); + Assertions.assertEquals(track.name, entry.get("source").getAsString(), "Track source: " + entry + " / " + track); + Assertions.assertEquals(track.isDefault, entry.get("default").getAsBoolean(), + "Default flag: " + entry + " / " + track); + JsonArray ids = entry.getAsJsonArray("track_ids"); + Assertions.assertTrue(ids != null && ids.size() == 1 && ids.get(0).getAsString().startsWith("TR_"), + "One LiveKit track id per file track: " + entry); + } + } + + @Test + @DisplayName("Screen share next to the camera") + void screenShareNextToCameraTest() throws Exception { + log.info("Participant Passthrough: screen share next to the camera"); + String room = uniqueRoom("screen-camera"); + Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, true, true); + String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv")); + waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); + Thread.sleep(5000); + alice.addScreen(); + Thread.sleep(8000); + // the screen is a second video track on the same timeline; the camera and + // the microphone stay the default tracks, which players show + Recording recording = stopAndGetRecording(egressId); + recording.assertFinalized(); + recording.assertTracks("audio:opus", "video:vp8", "video:vp8"); + recording.assertHealthy(); + recording.assertStartTogether(); + recording.assertAvSync(5); + FileTrack camera = recording.named("camera"); + FileTrack screen = recording.named("screen_share"); + Assertions.assertTrue(camera.isDefault && !screen.isDefault && recording.named("microphone").isDefault, + "The camera and the microphone must be the default tracks: " + recording.streams); + camera.assertReachedSize(CAMERA_WIDTH, CAMERA_HEIGHT); + screen.assertReachedSize(SCREEN_WIDTH, SCREEN_HEIGHT); + camera.assertMarkers(10); + screen.assertMarkers(5); + double screenStart = screen.firstPacketTime() - camera.firstPacketTime(); + Assertions.assertTrue(screenStart > 3 && screenStart < 8, + "The screen must start about 5 s after the camera, not " + screenStart + " s: " + screen); + Assertions.assertTrue(camera.lastPacketTime() > screen.firstPacketTime() + 5, + "The camera must go on while the screen is shared: " + camera + " / " + screen); + } + + @Test + @DisplayName("Screen share with its audio next to the camera and the microphone") + void screenShareWithAudioNextToCameraTest() throws Exception { + log.info("Participant Passthrough: screen share with its audio next to the camera and the microphone"); + String room = uniqueRoom("screen-audio"); + Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, true, true); + String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv")); + waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); + Thread.sleep(5000); + alice.addScreen(true); + Thread.sleep(8000); + // the screen and its audio are a second video and a second audio track + Recording recording = stopAndGetRecording(egressId); + recording.assertFinalized(); + recording.assertTracks("audio:opus", "audio:opus", "video:vp8", "video:vp8"); + recording.assertHealthy(); + recording.assertStartTogether(); + // every beep of both audio tracks sounds when a video shows a flash + recording.assertAvSync(5); + FileTrack microphone = recording.named("microphone"); + FileTrack tabAudio = recording.named("screen_share_audio"); + FileTrack screen = recording.named("screen_share"); + Assertions.assertTrue( + recording.named("camera").isDefault && microphone.isDefault && !screen.isDefault && !tabAudio.isDefault, + "The camera and the microphone must be the default tracks: " + recording.streams); + screen.assertMarkers(5); + tabAudio.assertMarkers(5); + Assertions.assertTrue(Math.abs(tabAudio.firstPacketTime() - screen.firstPacketTime()) < 2, + "The screen and its audio must start together: " + screen + " / " + tabAudio); + Assertions.assertTrue(microphone.lastPacketTime() > tabAudio.firstPacketTime() + 5, + "The microphone must go on while the screen is shared: " + microphone + " / " + tabAudio); + } + + @Test + @DisplayName("Screen share stopped and shared again") + void screenShareStopsAndResumesTest() throws Exception { + log.info("Participant Passthrough: screen share stopped and shared again"); + String room = uniqueRoom("screen-again"); + Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, true, true); + String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv")); + waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); + Thread.sleep(3000); + alice.addScreen(); + Thread.sleep(5000); + alice.unpublishScreen(); + Thread.sleep(3000); + alice.addScreen(); + Thread.sleep(5000); + // the second share continues the screen's track: still one screen track, + // with a gap while it was not shared + Recording recording = stopAndGetRecording(egressId); + recording.assertFinalized(); + recording.assertTracks("audio:opus", "video:vp8", "video:vp8"); + recording.assertHealthy(); + recording.assertAvSync(5); + FileTrack camera = recording.named("camera"); + FileTrack screen = recording.named("screen_share"); + Assertions.assertTrue(camera.isDefault && !screen.isDefault, + "The camera must be the default video track: " + recording.streams); + Assertions.assertEquals(1, screen.gaps(1).size(), + "The screen track must have one gap, while the screen was not shared: " + screen); + screen.assertMarkers(8); + Assertions.assertTrue(camera.gaps(1).isEmpty(), "The camera must go on throughout: " + camera); + } + + @Test + @DisplayName("Screen share while the camera is off") + void screenShareWhileCameraOffTest() throws Exception { + log.info("Participant Passthrough: screen share while the camera is off"); + String room = uniqueRoom("screen-camera-off"); + Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, true, true); + String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv")); + waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); + Thread.sleep(4000); + alice.unpublishVideo(); + Thread.sleep(2000); + alice.addScreen(); + Thread.sleep(5000); + alice.addVideo(); + Thread.sleep(5000); + // the screen does not take the camera's track while the camera is off: that + // track keeps a gap there, and the camera published again continues it + Recording recording = stopAndGetRecording(egressId); + recording.assertFinalized(); + recording.assertTracks("audio:opus", "video:vp8", "video:vp8"); + recording.assertHealthy(); + recording.assertAvSync(5); + FileTrack camera = recording.named("camera"); + FileTrack screen = recording.named("screen_share"); + Assertions.assertTrue(camera.isDefault && !screen.isDefault, + "The camera must be the default video track: " + recording.streams); + List gaps = camera.gaps(1); + Assertions.assertEquals(1, gaps.size(), "The camera track must have one gap, while the camera was off: " + camera); + Assertions.assertTrue(screen.firstPacketTime() > gaps.get(0)[0] && screen.firstPacketTime() < gaps.get(0)[1], + "The screen must start inside the camera's gap: " + screen + " / " + camera); + camera.assertReachedSize(CAMERA_WIDTH, CAMERA_HEIGHT); + screen.assertReachedSize(SCREEN_WIDTH, SCREEN_HEIGHT); + } + @Test @DisplayName("Screen share") void screenShareTest() throws Exception { @@ -2167,6 +2877,10 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT int unpublished; int mutes; int unmutes; + // the