diff --git a/openvidu-test-e2e/src/main/java/io/openvidu/test/e2e/OpenViduTestE2e.java b/openvidu-test-e2e/src/main/java/io/openvidu/test/e2e/OpenViduTestE2e.java index 4d8a07b4a..47bbb7eaf 100644 --- a/openvidu-test-e2e/src/main/java/io/openvidu/test/e2e/OpenViduTestE2e.java +++ b/openvidu-test-e2e/src/main/java/io/openvidu/test/e2e/OpenViduTestE2e.java @@ -10,6 +10,7 @@ import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.Duration; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.Collection; @@ -31,13 +32,18 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.testcontainers.containers.BindMode; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.Network; import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.containers.wait.strategy.WaitStrategy; import org.testcontainers.utility.DockerImageName; +import io.livekit.server.AccessToken; +import io.livekit.server.CanPublish; import io.livekit.server.IngressServiceClient; +import io.livekit.server.RoomJoin; +import io.livekit.server.RoomName; import io.livekit.server.RoomServiceClient; import io.openvidu.test.browsers.BrowserUser; import io.openvidu.test.browsers.ChromeUser; @@ -362,6 +368,151 @@ public class OpenViduTestE2e { return "srt://" + srtServerIp + ":" + RTSP_SRT_PORT; } + /** + * Starts a LiveKit server RTC SDK participant (a minimal program at + * src/test/resources/<sdk>-publisher) in a plain runtime container + * that joins the given room and publishes a single video track with the + * given codec (vp8, h264, vp9 or av1) as one plain RTP encoding: without + * simulcast for VP8/H264 and without SVC (scalabilityMode L1T1) for VP9/AV1. + * + * The container mounts the program read-only at /app, copies it to /work + * and runs it there, with a per-SDK dependency cache mounted at /cache so + * repeated runs skip downloads. Blocks until the participant has published + * its track (its TRACK_PUBLISHED log line). The container is stopped + * automatically on test dispose. + */ + public void startServerSdkPublisher(String sdk, String roomName, String codec) throws Exception { + startServerSdkPublisher(sdk, roomName, codec, false); + } + + /** + * Launches the minimal publisher program of the given LiveKit server RTC SDK + * (src/test/resources/-publisher) in a plain runtime container, joining + * roomName and publishing one video track of the given codec. multiLayer=false + * publishes one plain RTP encoding (no simulcast, no SVC); multiLayer=true + * publishes two layers from the same 640x480 source: simulcast (480x360 + + * 640x480, the SDKs' absolute presets) for VP8/H264 and SVC L2T2 (320x240 + + * 640x480) for VP9/AV1 — except the Go SDK, which forwards pre-encoded + * samples and publishes two simulcast files (320x240 + 640x480). Returns once + * the program logs TRACK_PUBLISHED. + */ + public void startServerSdkPublisher(String sdk, String roomName, String codec, boolean multiLayer) + throws Exception { + final String image; + final String runCommand; + int startupTimeoutMinutes = 5; + Map env = new HashMap<>(); + switch (sdk) { + case "go": + image = "golang:1.26"; + runCommand = "go run ."; + env.put("GOMODCACHE", "/cache/gomod"); + env.put("GOCACHE", "/cache/gobuild"); + break; + case "node": + image = "node:22"; + runCommand = "npm install --no-audit --no-fund --loglevel=error && node main.mjs"; + env.put("npm_config_cache", "/cache/npm"); + break; + case "python": + image = "python:3.12"; + runCommand = "pip install -q -r requirements.txt && python main.py"; + env.put("PIP_CACHE_DIR", "/cache/pip"); + break; + case "rust": + image = "rust:1"; + // webrtc-sys links a prebuilt libwebrtc that requires clang++ >= 21 + // (apt.llvm.org); the first run then compiles the whole livekit + // crate graph + runCommand = "apt-get update -qq >/dev/null && apt-get install -y -qq lsb-release gnupg >/dev/null" + + " && wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh && bash /tmp/llvm.sh 21 >/dev/null 2>&1" + + " && cargo run --release --locked"; + env.put("CARGO_HOME", "/cache/cargo"); + env.put("CARGO_TARGET_DIR", "/cache/cargo-target"); + env.put("CC", "clang-21"); + env.put("CXX", "clang++-21"); + startupTimeoutMinutes = 30; + break; + case "dotnet": + image = "mcr.microsoft.com/dotnet/sdk:8.0"; + runCommand = "dotnet run -c Release"; + env.put("NUGET_PACKAGES", "/cache/nuget"); + env.put("DOTNET_CLI_TELEMETRY_OPTOUT", "1"); + startupTimeoutMinutes = 10; + break; + default: + throw new IllegalArgumentException("Unknown server SDK publisher: " + sdk); + } + // Publishers receive a ready-made token: it keeps the programs shorter + // (no per-SDK token dependency) and works with any api secret length + AccessToken accessToken = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET); + accessToken.setIdentity(sdk + "-publisher"); + accessToken.addGrants(new RoomJoin(true), new RoomName(roomName), new CanPublish(true)); + env.put("LIVEKIT_URL", LIVEKIT_URL); + env.put("LIVEKIT_TOKEN", accessToken.toJwt()); + env.put("VIDEO_CODEC", codec); + env.put("VIDEO_LAYERS", multiLayer ? "multi" : "single"); + + String programDir = Paths.get("src/test/resources/" + sdk + "-publisher").toAbsolutePath().toString(); + Path cacheDir = Paths.get(System.getProperty("java.io.tmpdir"), "openvidu-e2e-sdk-cache", sdk); + Files.createDirectories(cacheDir); + + GenericContainer publisherContainer = new GenericContainer<>(DockerImageName.parse(image)) + .withCreateContainerCmdModifier( + cmd -> cmd.withName(sdk + "-publisher-" + (int) (Math.random() * 100000))) + .withNetworkMode("host").withFileSystemBind(programDir, "/app", BindMode.READ_ONLY) + .withFileSystemBind(cacheDir.toString(), "/cache").withEnv(env) + .withCommand("sh", "-c", "mkdir -p /work && cp -r /app/. /work && cd /work && " + runCommand) + .withLogConsumer( + frame -> log.info("[{}-publisher] {}", sdk, frame.getUtf8String().stripTrailing())) + .waitingFor(Wait.forLogMessage("^.*TRACK_PUBLISHED.*$", 1) + .withStartupTimeout(Duration.ofMinutes(startupTimeoutMinutes))); + + if ("go".equals(sdk)) { + // The Go SDK sends pre-encoded media: loop a real (decodable) 5s + // file generated with the host's ffmpeg (Annex-B for H264, IVF for + // VP8/VP9/AV1). The FFI-based SDKs (node, python, rust, dotnet) + // encode raw frames themselves. + final String encoder; + switch (codec) { + case "h264": + encoder = "-c:v libx264 -profile:v baseline -level 3.1 -x264-params keyint=30:scenecut=0 -f h264"; + break; + case "vp8": + encoder = "-c:v libvpx -g 30 -f ivf"; + break; + case "vp9": + encoder = "-c:v libvpx-vp9 -g 30 -f ivf"; + break; + case "av1": + encoder = "-c:v libaom-av1 -usage realtime -cpu-used 8 -g 30 -f ivf"; + break; + default: + throw new IllegalArgumentException("Unknown video codec: " + codec); + } + Path mediaDir = Files.createTempDirectory("go-publisher-media"); + // One file per layer: the single encoding, or the three simulcast + // layers (must match the sizes declared by go-publisher/main.go) + Map layerSizes = multiLayer + ? Map.of("VIDEO_FILE_LOW", "320x240", "VIDEO_FILE_HIGH", "640x480") + : Map.of("VIDEO_FILE", "640x480"); + for (Map.Entry layer : layerSizes.entrySet()) { + Path videoFile = mediaDir + .resolve("test-video-" + layer.getValue() + "." + ("h264".equals(codec) ? "h264" : "ivf")); + commandLine.executeCommand("ffmpeg -y -f lavfi -i testsrc=size=" + layer.getValue() + + ":rate=30 -t 5 -pix_fmt yuv420p " + encoder + " " + videoFile, 120); + if (!Files.exists(videoFile) || Files.size(videoFile) == 0) { + Assertions.fail("ffmpeg could not generate the " + codec + " test file " + videoFile); + } + publisherContainer.withEnv(layer.getKey(), "/media/" + videoFile.getFileName()); + } + publisherContainer.withFileSystemBind(mediaDir.toString(), "/media", BindMode.READ_ONLY); + } + + publisherContainer.start(); + containers.add(publisherContainer); + } + private void waitUntilLog(GenericContainer container, String regex, int secondsTimeout) { int t = secondsTimeout * 2; // We wait half a second between retries Pattern pattern = Pattern.compile(regex); diff --git a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eTest.java b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eTest.java index 879161ed9..d8cd141b4 100644 --- a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eTest.java +++ b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eTest.java @@ -27,6 +27,8 @@ import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; import java.util.Map; import java.util.Map.Entry; import java.util.concurrent.Callable; @@ -43,6 +45,7 @@ import org.apache.commons.lang3.tuple.ImmutablePair; import org.apache.commons.lang3.tuple.Pair; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; @@ -50,6 +53,10 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; import org.openqa.selenium.By; import org.openqa.selenium.JavascriptExecutor; import org.openqa.selenium.Keys; @@ -79,6 +86,10 @@ import io.minio.messages.Item; import livekit.LivekitIngress.IngressInfo; import livekit.LivekitIngress.IngressState; import livekit.LivekitModels.ConnectionQuality; +import livekit.LivekitModels.ParticipantInfo; +import livekit.LivekitModels.TrackInfo; +import livekit.LivekitModels.TrackType; +import livekit.LivekitModels.VideoLayer; import static org.openqa.selenium.OutputType.BASE64; @@ -3257,8 +3268,7 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest { String subscriberCodec = this.getSubscriberVideoCodec(user, subscriberVideo); Assertions.assertEquals("video/" + codec.toUpperCase(), subscriberCodec); - // Subscriber should settle in 960 - this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, 960); + this.waitUntilSubscriberFramesPerSecondNotZero(user, subscriberVideo); this.waitUntilSubscriberFramesDecodedIncrease(user, subscriberVideo); changeElementSize(user, subscriberVideo, 1000, 700); @@ -3379,15 +3389,9 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest { } @Test - @DisplayName("SVC VP9 (L3T3_KEY)") - void svcVP9L3T3_KEYTest() throws Exception { - svcTest("VP9", "L3T3_KEY", false); - } - - @Test - @DisplayName("SVC AV1 (L3T3_KEY)") - void svcAV1L3T3_KEYTest() throws Exception { - svcTest("AV1", "L3T3_KEY", false); + @DisplayName("SVC VP9 (L1T1)") + void svcVP9L1T1Test() throws Exception { + svcTest("VP9", "L1T1", false); } @Test @@ -3396,6 +3400,30 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest { svcTest("VP9", "L2T2", false); } + @Test + @DisplayName("SVC VP9 (L2T2_KEY)") + void svcVP9L2T2_KEYTest() throws Exception { + svcTest("VP9", "L2T2_KEY", false); + } + + @Test + @DisplayName("SVC VP9 (L3T3)") + void svcVP9L3T3Test() throws Exception { + svcTest("VP9", "L3T3", false); + } + + @Test + @DisplayName("SVC VP9 (L3T3_KEY)") + void svcVP9L3T3_KEYTest() throws Exception { + svcTest("VP9", "L3T3_KEY", false); + } + + @Test + @DisplayName("SVC AV1 (L1T1)") + void svcAV1L1T1Test() throws Exception { + svcTest("AV1", "L1T1", false); + } + @Test @DisplayName("SVC AV1 (L2T2)") void svcAV1L2T2Test() throws Exception { @@ -3403,11 +3431,83 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest { } @Test - @DisplayName("SVC AV1 (L2T2) with adaptiveStream") - void svcAV1L2T2WithAdaptiveStreamTest() throws Exception { + @DisplayName("SVC AV1 (L2T2_KEY)") + void svcAV1L2T2_KEYTest() throws Exception { + svcTest("AV1", "L2T2_KEY", false); + } + + @Test + @DisplayName("SVC AV1 (L3T3)") + void svcAV1L3T3Test() throws Exception { + svcTest("AV1", "L3T3", false); + } + + @Test + @DisplayName("SVC AV1 (L3T3_KEY)") + void svcAV1L3T3_KEYTest() throws Exception { + svcTest("AV1", "L3T3_KEY", false); + } + + @Test + @DisplayName("SVC VP9 (L1T1) adaptiveStream") + void svcVP9L1T1AdaptiveStreamTest() throws Exception { + svcTest("VP9", "L1T1", true); + } + + @Test + @DisplayName("SVC VP9 (L2T2) adaptiveStream") + void svcVP9L2T2AdaptiveStreamTest() throws Exception { + svcTest("VP9", "L2T2", true); + } + + @Test + @DisplayName("SVC VP9 (L2T2_KEY) adaptiveStream") + void svcVP9L2T2_KEYAdaptiveStreamTest() throws Exception { + svcTest("VP9", "L2T2_KEY", true); + } + + @Test + @DisplayName("SVC VP9 (L3T3) adaptiveStream") + void svcVP9L3T3AdaptiveStreamTest() throws Exception { + svcTest("VP9", "L3T3", true); + } + + @Test + @DisplayName("SVC VP9 (L3T3_KEY) adaptiveStream") + void svcVP9L3T3_KEYAdaptiveStreamTest() throws Exception { + svcTest("VP9", "L3T3_KEY", true); + } + + @Test + @DisplayName("SVC AV1 (L1T1) adaptiveStream") + void svcAV1L1T1AdaptiveStreamTest() throws Exception { + svcTest("AV1", "L1T1", true); + } + + @Test + @DisplayName("SVC AV1 (L2T2) adaptiveStream") + void svcAV1L2T2AdaptiveStreamTest() throws Exception { svcTest("AV1", "L2T2", true); } + @Test + @DisplayName("SVC AV1 (L2T2_KEY) adaptiveStream") + void svcAV1L2T2_KEYAdaptiveStreamTest() throws Exception { + svcTest("AV1", "L2T2_KEY", true); + } + + @Test + @DisplayName("SVC AV1 (L3T3) adaptiveStream") + void svcAV1L3T3AdaptiveStreamTest() throws Exception { + svcTest("AV1", "L3T3", true); + } + + @Test + @DisplayName("SVC AV1 (L3T3_KEY) adaptiveStream") + void svcAV1L3T3_KEYAdaptiveStreamTest() throws Exception { + svcTest("AV1", "L3T3_KEY", true); + } + private void svcTest(String codec, String scalabilityMode, boolean adaptiveStream) throws Exception { final String codecUpperCase = codec.toUpperCase(); final long testStart = System.currentTimeMillis(); @@ -3449,7 +3549,20 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest { user.getDriver().findElement(By.cssSelector("#close-dialog-btn")).click(); Thread.sleep(300); - if (adaptiveStream) { + this.waitUntilSubscriberBytesReceivedIncreasing(user, subscriberVideo); + this.waitUntilSubscriberFramesPerSecondNotZero(user, subscriberVideo); + + if (spatialLayers == 1) { + // A single spatial layer. Just ensure subscriber is properly receiving video + Assertions.assertTrue(highWidth > 0, "Expected a positive frameWidth, but got " + highWidth); + this.waitUntilSubscriberFramesDecodedIncrease(user, subscriberVideo); + } else if (adaptiveStream) { + // adaptiveStream picks the layer from the rendered size of the subscriber's + // video element, so drive the layer changes by resizing that element. Only + // two element sizes are used, so only the lowest and the highest spatial + // layers are observable: with 3 spatial layers the middle one is never + // visited. The default element size settles below the top layer, so the + // first switch to HIGH must increase the received width. this.switchSubscriberSpatialLayer(user, subscriberVideo, adaptiveStream, "HIGH"); this.waitUntilSubscriberFrameWidthChanges(user, subscriberVideo, highWidth, true); this.waitUntilSubscriberFramesDecodedIncrease(user, subscriberVideo); @@ -3497,7 +3610,7 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest { this.switchSubscriberSpatialLayer(user, subscriberVideo, adaptiveStream, "HIGH"); this.waitUntilSubscriberFrameWidthChanges(user, subscriberVideo, lowWidth, true); this.waitUntilSubscriberFramesDecodedIncrease(user, subscriberVideo); - } else { + } else if (spatialLayers == 2) { // 2 spatial layers (e.g. L2T2): layers are LOW and MEDIUM only. // HIGH and MEDIUM both map to the highest spatial layer (same width). // Only LOW gives a distinct lower resolution. @@ -3900,6 +4013,312 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest { testNoSimulcast(user, subscriberVideo); } + // Server RTC SDK publishers matrix: each SDK publishes each codec first as + // one plain RTP encoding (no simulcast for VP8/H264, no SVC for VP9/AV1) + // and then as two layers (high and low quality: simulcast for VP8/H264, SVC + // L2T2 for VP9/AV1), and a Chrome subscriber and a Firefox subscriber must + // both receive it — for two layers, switching to the LOW and then to the + // HIGH layer. The Go SDK single-layer H264 lane reproduces the mediasoup + // Producer codec-binding bug: Go SDK publishers declare no codec in their + // AddTrackRequest and prefer an H264 variant the server does not support, + // so the server's answer ends up VP8 first, the Producer is bound to VP8, + // the worker discards every incoming packet and the subscribers receive no + // media at all. See MEDIASOUP_CODEC_BINDING_BUG.md + + /** + * {vp8, h264, vp9, av1} x {single, multi} layers, single-layer cases first. + * System properties sdk.codecs / sdk.layers (comma-separated) restrict the + * matrix, e.g. -Dsdk.layers=multi -Dsdk.codecs=vp9,av1 + */ + static Stream serverSdkPublisherMatrix() { + List layersFilter = List.of(System.getProperty("sdk.layers", "single,multi").split(",")); + List codecsFilter = List.of(System.getProperty("sdk.codecs", "vp8,h264,vp9,av1").split(",")); + return Stream.of("single", "multi").filter(layersFilter::contains) + .flatMap(layers -> Stream.of("vp8", "h264", "vp9", "av1").filter(codecsFilter::contains) + .map(codec -> Arguments.of(codec, layers))); + } + + @ParameterizedTest(name = "Go SDK {0} {1}-layer publisher to Chrome and Firefox subscribers") + @MethodSource("serverSdkPublisherMatrix") + @DisplayName("Go SDK publisher to Chrome and Firefox subscribers") + void goSdkPublisherToBrowserSubscribersTest(String codec, String layers) throws Exception { + serverSdkPublisherToBrowserSubscribersAux("go", codec, layers); + } + + @ParameterizedTest(name = "Node SDK {0} {1}-layer publisher to Chrome and Firefox subscribers") + @MethodSource("serverSdkPublisherMatrix") + @DisplayName("Node SDK publisher to Chrome and Firefox subscribers") + void nodeSdkPublisherToBrowserSubscribersTest(String codec, String layers) throws Exception { + serverSdkPublisherToBrowserSubscribersAux("node", codec, layers); + } + + @ParameterizedTest(name = "Python SDK {0} {1}-layer publisher to Chrome and Firefox subscribers") + @MethodSource("serverSdkPublisherMatrix") + @DisplayName("Python SDK publisher to Chrome and Firefox subscribers") + void pythonSdkPublisherToBrowserSubscribersTest(String codec, String layers) throws Exception { + serverSdkPublisherToBrowserSubscribersAux("python", codec, layers); + } + + @ParameterizedTest(name = "Rust SDK {0} {1}-layer publisher to Chrome and Firefox subscribers") + @MethodSource("serverSdkPublisherMatrix") + @DisplayName("Rust SDK publisher to Chrome and Firefox subscribers") + void rustSdkPublisherToBrowserSubscribersTest(String codec, String layers) throws Exception { + serverSdkPublisherToBrowserSubscribersAux("rust", codec, layers); + } + + @ParameterizedTest(name = ".NET SDK {0} {1}-layer publisher to Chrome and Firefox subscribers") + @MethodSource("serverSdkPublisherMatrix") + @DisplayName(".NET SDK publisher to Chrome and Firefox subscribers") + void dotnetSdkPublisherToBrowserSubscribersTest(String codec, String layers) throws Exception { + // Requires Livekit.Rtc.Dotnet >= 0.1.4 (TrackPublishOptions.VideoCodec) + serverSdkPublisherToBrowserSubscribersAux("dotnet", codec, layers); + } + + /** + * A Chrome browser and a Firefox browser join the room as subscriber-only + * participants and a LiveKit server RTC SDK participant + * (startServerSdkPublisher) joins the same room ("TestRoom" is the testapp + * default) publishing a single video track with the given codec: as one plain + * RTP encoding (layers "single") or as two layers (layers "multi": high and + * low quality — simulcast for VP8/H264, SVC L2T2 for VP9/AV1). Both + * subscribers must receive the track's media with that codec, going through + * exactly the same steps; with two layers they also switch to the LOW and + * then to the HIGH layer. + */ + private void serverSdkPublisherToBrowserSubscribersAux(String sdk, String codec, String layers) + throws Exception { + final String expectedCodec = "video/" + codec.toUpperCase(); + final String publisherIdentity = sdk + "-publisher"; + final boolean multiLayer = "multi".equals(layers); + + // The Go SDK forwards pre-encoded samples (no encoder), so its only + // multi-layer shape is RID simulcast — and LiveKit does not support RID + // simulcast for the SVC-class codecs (multi-layer VP9/AV1 must be SVC): + // that combination is skipped as an unsupported publish shape + Assumptions.assumeFalse(multiLayer && "go".equals(sdk) && ("vp9".equals(codec) || "av1".equals(codec)), + "The Go SDK cannot publish SVC, and VP9/AV1 RID simulcast is not a supported LiveKit publish shape"); + + List subscribers = List.of(setupBrowserAndConnectToOpenViduTestapp("chrome"), + setupBrowserAndConnectToOpenViduTestapp("firefox")); + + log.info("{} SDK {} {}-layer publisher to Chrome and Firefox subscribers", sdk, codec, layers); + + // Both browsers join the room as subscriber-only participants with + // adaptiveStream disabled (the received layer only changes through the + // testapp's max-video-quality selector), each with its own identity (a + // second join with the testapp's default identity would kick the first + // browser out of the room) + for (OpenViduTestappUser user : subscribers) { + this.addSubscriber(user, false); + WebElement participantNameInput = user.getDriver().findElement(By.id("participant-name-input-0")); + participantNameInput.clear(); + participantNameInput.sendKeys(browserName(user) + "-subscriber"); + user.getDriver().findElements(By.className("connect-btn")).forEach(el -> el.sendKeys(Keys.ENTER)); + user.getEventManager().waitUntilEventReaches("connected", "RoomEvent", 1); + } + + this.startServerSdkPublisher(sdk, "TestRoom", codec, multiLayer); + + // The server's authoritative view of the publication (RoomService API): + // the LiveKit TrackInfo of a video publication lists one layer per + // simulcast layer or SVC spatial layer, so one plain encoding (no + // simulcast, no SVC / L1T1) has exactly one layer and a two-layer + // publish (simulcast or SVC L2T2) has two + TrackInfo trackInfo = this.getPublishedVideoTrackInfo("TestRoom", publisherIdentity); + final int expectedLayers = multiLayer ? 2 : 1; + Assertions.assertEquals(expectedLayers, trackInfo.getLayersCount(), "Expected " + expectedLayers + + " video layer(s) in the track published by the " + sdk + " SDK, but the server reports " + + trackInfo.getLayersList()); + Assertions.assertEquals(expectedLayers, trackInfo.getCodecs(0).getLayersCount(), "Expected " + expectedLayers + + " video layer(s) for codec " + expectedCodec + " published by the " + sdk + " SDK"); + // VP8/H264 multi-layer publishes are simulcast and VP9/AV1 ones are SVC + // L2T2, except for the Go SDK: it forwards pre-encoded samples (no + // encoder, so no SVC) and publishes simulcast for every codec + final boolean expectSimulcast = multiLayer && ("vp8".equals(codec) || "h264".equals(codec) || "go".equals(sdk)); + if (!multiLayer || expectSimulcast) { + Assertions.assertEquals(expectSimulcast, trackInfo.getSimulcast(), + "Simulcast flag of the track published by the " + sdk + " SDK"); + // (the server's explicit SVC verdict: an SVC publication would be + // MULTIPLE_SPATIAL_LAYERS_PER_STREAM) + Assertions.assertEquals(VideoLayer.Mode.ONE_SPATIAL_LAYER_PER_STREAM, + trackInfo.getCodecs(0).getVideoLayerMode(), + "The track published by the " + sdk + " SDK should not be SVC"); + } + + // Both browsers must receive the track, with its codec and its layers + for (OpenViduTestappUser user : subscribers) { + assertSubscriberReceivesVideo(user, sdk, publisherIdentity, expectedCodec, trackInfo); + } + + for (OpenViduTestappUser user : subscribers) { + gracefullyLeaveParticipants(user, 1); + } + } + + /** "Chrome", "Firefox"... from the BrowserUser class of the testapp user. */ + private String browserName(OpenViduTestappUser user) { + return user.getBrowserUser().getClass().getSimpleName().replace("User", ""); + } + + /** + * Waits until the subscriber's video bytesReceived grows between two + * consecutive samples. Unlike waitUntilSubscriberBytesReceivedIncrease (which + * compares against the very first sample) this tolerates the periodic + * inbound-rtp counter restarts Firefox shows with the mediasoup engine: a + * low-bitrate stream could otherwise never climb back above a first sample + * taken late in a counter window. + */ + private void waitUntilSubscriberBytesReceivedIncreasing(OpenViduTestappUser user, WebElement videoElement) { + final java.util.concurrent.atomic.AtomicLong previous = new java.util.concurrent.atomic.AtomicLong( + this.getSubscriberVideoBytesReceived(user, videoElement)); + this.waitUntilAux(user, videoElement, () -> { + long current = this.getSubscriberVideoBytesReceived(user, videoElement); + return current > previous.getAndSet(current); + }, "Timeout waiting for the subscriber track bytesReceived to grow between consecutive samples"); + } + + /** + * Selects the max video quality (LOW, MEDIUM or HIGH) of the remote track of + * the first testapp instance, closing the track info dialog if it is open. + */ + private void selectSubscriberVideoQuality(OpenViduTestappUser user, String quality) throws InterruptedException { + if (!user.getDriver().findElements(By.cssSelector("app-info-dialog")).isEmpty()) { + user.getDriver().findElement(By.cssSelector("#close-dialog-btn")).click(); + Thread.sleep(300); + } + user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 #max-video-quality")).click(); + this.waitForBackdropAndClick(user, "mat-option.mode-" + quality); + } + + /** + * The subscriber-only participant of the given browser must receive the video + * track published by the SDK participant: media actually flowing, with the + * expected codec, with the layers the server reports in trackInfo. With more + * than one layer the subscriber switches to the LOW and then to the HIGH + * layer, each recognised by its frame width. + */ + private void assertSubscriberReceivesVideo(OpenViduTestappUser user, String sdk, String publisherIdentity, + String expectedCodec, TrackInfo trackInfo) throws Exception { + final String browser = browserName(user); + + user.getEventManager().waitUntilEventReaches("trackSubscribed", "ParticipantEvent", 1); + + user.getWaiter().until(ExpectedConditions.numberOfElementsToBe(By.tagName("video"), 1)); + Assertions.assertTrue(assertAllElementsHaveTracks(user, "video", false, true), + browser + ": HTMLVideoElements were expected to have only one video track"); + + WebElement subscriberVideo = user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 video.remote")); + + // Media must actually reach the subscriber (with the codec-binding bug + // present the track subscribes but receives 0 bytes forever) + waitUntilVideoLayersNotEmpty(user, subscriberVideo); + this.waitUntilSubscriberBytesReceivedIncreasing(user, subscriberVideo); + this.waitUntilSubscriberFramesPerSecondNotZero(user, subscriberVideo); + + // And with the codec the publisher actually sends (a Producer bound to + // the wrong codec makes every subscriber negotiate that wrong codec) + Assertions.assertEquals(expectedCodec, this.getSubscriberVideoCodec(user, subscriberVideo), + browser + " subscriber should negotiate the codec the " + sdk + " SDK publisher sends"); + + // And with the layers the server reports, in the track info received by + // the subscriber + JsonArray subscriberLayers = this.getRemoteVideoTrackInfoLayers(user, publisherIdentity); + Assertions.assertEquals(trackInfo.getLayersCount(), subscriberLayers.size(), + "Expected " + trackInfo.getLayersCount() + " video layer(s) in the track published by the " + sdk + + " SDK, but the " + browser + " subscriber sees " + subscriberLayers); + + if (trackInfo.getLayersCount() > 1) { + // Multiple layers: with adaptiveStream disabled the received layer + // only changes through the max-video-quality selector. Receiving the + // LOW layer and then the HIGH layer — each recognised by the frame + // width the publisher declared for it in the TrackInfo, and each + // actually decoding (framesPerSecond > 0: an undecodable layer still + // reports its frame size) — proves that the publisher sends every + // layer and that the SFU forwards the requested one. The declared + // widths are reliable because the multi-layer publishers capture at + // 15 fps: at 30 fps libwebrtc's CPU adaptation can scale every layer + // down under load + this.selectQualityAndAwaitLayer(user, subscriberVideo, "LOW", lowestLayerWidth(trackInfo)); + this.selectQualityAndAwaitLayer(user, subscriberVideo, "HIGH", highestLayerWidth(trackInfo)); + } + } + + /** + * Selects the max video quality of the subscriber's remote track and waits + * until the received video has the frame width of that layer and decodes + * (framesPerSecond > 0). Retries the selection once: under CPU load the SFU + * can take longer than one wait window to ramp back up to a higher layer. + */ + private void selectQualityAndAwaitLayer(OpenViduTestappUser user, WebElement subscriberVideo, String quality, + int expectedFrameWidth) throws Exception { + for (int attempt = 1; attempt <= 2; attempt++) { + this.selectSubscriberVideoQuality(user, quality); + try { + this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, expectedFrameWidth); + break; + } catch (AssertionError e) { + if (attempt == 2) { + throw e; + } + } + } + this.waitUntilSubscriberFramesPerSecondNotZero(user, subscriberVideo); + } + + /** + * Width of the lowest-quality published layer, from the server's TrackInfo. + * The layers are picked by width, not by their VideoQuality label: the SDKs + * label a two-layer publish inconsistently (simulcast: LOW + MEDIUM; SVC + * L2T2: MEDIUM + HIGH; the Go program: LOW + HIGH), while the subscriber's + * LOW/HIGH selection always clamps to the lowest/highest available layer. + */ + private int lowestLayerWidth(TrackInfo trackInfo) { + return trackInfo.getLayersList().stream().mapToInt(VideoLayer::getWidth).min() + .orElseThrow(() -> new AssertionError("No layers in " + trackInfo)); + } + + /** Width of the highest-quality published layer, from the server's TrackInfo. */ + private int highestLayerWidth(TrackInfo trackInfo) { + return trackInfo.getLayersList().stream().mapToInt(VideoLayer::getWidth).max() + .orElseThrow(() -> new AssertionError("No layers in " + trackInfo)); + } + + /** + * The first video track published by the given participant, as reported by + * the LiveKit server (RoomService GetParticipant). Waits up to 10 seconds for + * it: the SDK programs log TRACK_PUBLISHED as soon as their publish call + * returns, a few milliseconds before the server registers the track. + */ + private TrackInfo getPublishedVideoTrackInfo(String roomName, String participantIdentity) throws Exception { + for (int attempt = 0; attempt < 40; attempt++) { + ParticipantInfo participant = LK.getParticipant(roomName, participantIdentity).execute().body(); + if (participant != null) { + Optional videoTrack = participant.getTracksList().stream() + .filter(track -> track.getType() == TrackType.VIDEO).findFirst(); + if (videoTrack.isPresent()) { + return videoTrack.get(); + } + } + Thread.sleep(250); + } + throw new AssertionError(participantIdentity + " has no published video track in room " + roomName); + } + + /** + * Video layers (VideoLayer[] of the LiveKit TrackInfo) of the first video + * track published by the remote participant with the given identity, as seen + * by the local participant of the first testapp instance. + */ + private JsonArray getRemoteVideoTrackInfoLayers(OpenViduTestappUser user, String participantIdentity) { + String layers = (String) ((JavascriptExecutor) user.getDriver()).executeScript( + "var room = window['room_0'];" + + "var participant = room.remoteParticipants.get(arguments[0]);" + + "var publication = participant.videoTrackPublications.values().next().value;" + + "return JSON.stringify(publication.trackInfo.layers);", + participantIdentity); + return JsonParser.parseString(layers).getAsJsonArray(); + } + @Test @DisplayName("RTSP ingress H264 + OPUS") void rtspIngressH264_OPUSTest() throws Exception { diff --git a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/changed.java b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/changed.java new file mode 100644 index 000000000..8fb06846c --- /dev/null +++ b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/changed.java @@ -0,0 +1,5249 @@ +/* + * (C) Copyright 2017-2022 OpenVidu (https://openvidu.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package io.openvidu.test.e2e; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; +import java.util.Map; +import java.util.Map.Entry; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.function.BiFunction; + +import org.apache.commons.lang3.tuple.ImmutablePair; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.openqa.selenium.By; +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.Keys; +import org.openqa.selenium.TakesScreenshot; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.ui.ExpectedConditions; +import org.openqa.selenium.support.ui.WebDriverWait; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import com.google.common.collect.ImmutableList; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import io.minio.BucketExistsArgs; +import io.minio.ListObjectsArgs; +import io.minio.MinioClient; +import io.minio.Result; +import io.minio.errors.ErrorResponseException; +import io.minio.errors.InsufficientDataException; +import io.minio.errors.InternalException; +import io.minio.errors.InvalidResponseException; +import io.minio.errors.ServerException; +import io.minio.errors.XmlParserException; +import io.minio.messages.Item; +import livekit.LivekitIngress.IngressInfo; +import livekit.LivekitIngress.IngressState; +import livekit.LivekitModels.ConnectionQuality; +import livekit.LivekitModels.ParticipantInfo; +import livekit.LivekitModels.TrackInfo; +import livekit.LivekitModels.TrackType; +import livekit.LivekitModels.VideoLayer; + +import static org.openqa.selenium.OutputType.BASE64; + +/** + * E2E tests for openvidu-testapp. + * + * @author Pablo Fuente (pablofuenteperez@gmail.com) + * @since 1.1.1 + */ +@Tag("e2e") +@DisplayName("E2E tests for OpenVidu TestApp") +@ExtendWith(SpringExtension.class) +public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest { + + @BeforeAll() + protected static void setupAll() throws Exception { + checkFfmpegInstallation(); + loadEnvironmentVariables(); + setUpLiveKitClient(); + CompletableFuture.runAsync(() -> { + try { + NetworkConditioner.pullImages(); + } catch (Exception e) { + System.err.println("Download of images failed: " + e.getMessage()); + } + }); + CompletableFuture.runAsync(OpenViduTestAppE2eTest::pullRemoteBrowserImages); + } + + private static void pullRemoteBrowserImages() { + pullRemoteBrowserImage("REMOTE_URL_CHROME", "selenium/standalone-chrome:" + CHROME_VERSION); + pullRemoteBrowserImage("REMOTE_URL_FIREFOX", "selenium/standalone-firefox:" + FIREFOX_VERSION); + pullRemoteBrowserImage("REMOTE_URL_EDGE", "selenium/standalone-edge:" + EDGE_VERSION); + } + + private static void pullRemoteBrowserImage(String remoteUrlProperty, String image) { + if (System.getProperty(remoteUrlProperty) == null) { + return; // This browser runs as a native driver here. No Docker image to pull + } + try { + log.info("Pre-pulling Selenium image {}", image); + commandLine.executeCommand("docker pull " + image, 300); + } catch (Exception e) { + System.err.println("Pre-pull of " + image + " failed: " + e.getMessage()); + } + } + + @BeforeEach() + protected void setupEach() { + this.closeAllRooms(LK); + this.deleteAllIngresses(LK_INGRESS); + } + + @AfterEach() + protected void finishEach() { + this.closeAllRooms(LK); + this.deleteAllIngresses(LK_INGRESS); + } + + @Test + @DisplayName("One2One Chrome") + void oneToOneChrome() throws Exception { + OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("chrome"); + log.info("One2One Chrome"); + oneToOneAux(user); + } + + @Test + @DisplayName("One2One Firefox") + void oneToOneFirefox() throws Exception { + OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("firefox"); + log.info("One2One Firefox"); + oneToOneAux(user); + } + + @Test + @DisplayName("One2One Edge") + void oneToOneEdge() throws Exception { + OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("edge"); + log.info("One2One Edge"); + oneToOneAux(user); + } + + private void oneToOneAux(OpenViduTestappUser user) throws Exception { + user.getDriver().findElement(By.id("auto-join-checkbox")).click(); + user.getDriver().findElement(By.id("one2one-btn")).click(); + user.getEventManager().waitUntilEventReaches("signalConnected", "RoomEvent", 2); + user.getEventManager().waitUntilEventReaches("connected", "RoomEvent", 2); + user.getEventManager().waitUntilEventReaches("participantActive", "RoomEvent", 1); + user.getEventManager().waitUntilEventReaches("active", "ParticipantEvent", 3); + user.getEventManager().waitUntilEventReaches("connectionStateChanged", "RoomEvent", 4); + user.getEventManager().waitUntilEventReaches("localTrackPublished", "RoomEvent", 4); + user.getEventManager().waitUntilEventReaches("localTrackPublished", "ParticipantEvent", 4); + user.getEventManager().waitUntilEventReaches("localTrackSubscribed", "RoomEvent", 4); + user.getEventManager().waitUntilEventReaches("localTrackSubscribed", "ParticipantEvent", 4); + user.getEventManager().waitUntilEventReaches("trackSubscribed", "RoomEvent", 4); + user.getEventManager().waitUntilEventReaches("trackSubscribed", "ParticipantEvent", 4); + user.getEventManager().waitUntilEventReaches("trackSubscriptionStatusChanged", "RoomEvent", 8); + user.getEventManager().waitUntilEventReaches("trackSubscriptionStatusChanged", "ParticipantEvent", 8); + user.getEventManager().waitUntilEventReaches("visibilityChanged", "TrackEvent", 2); + + user.getWaiter().until(ExpectedConditions.numberOfElementsToBe(By.tagName("video"), 4)); + user.getWaiter().until(ExpectedConditions.numberOfElementsToBe(By.tagName("audio"), 4)); + final int numberOfVideos = user.getDriver().findElements(By.tagName("video")).size(); + final int numberOfAudios = user.getDriver().findElements(By.tagName("audio")).size(); + Assertions.assertEquals(4, numberOfVideos, "Wrong number of videos"); + Assertions.assertEquals(4, numberOfAudios, "Wrong number of audios"); + + Assertions.assertTrue(assertAllElementsHaveTracks(user, "video", false, true), + "HTMLVideoElements were expected to have only one video track"); + Assertions.assertTrue(assertAllElementsHaveTracks(user, "audio.remote", true, false), + "HTMLAudioElements were expected to have only one audio track"); + gracefullyLeaveParticipants(user, 2); + } + + @Test + @DisplayName("Signal Reliable DataChannel") + void signalReliableTest() throws Exception { + OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("chrome"); + log.info("Signal Reliable DataChannel"); + signalReliableLossyAux(user, true); + } + + @Test + @DisplayName("Signal Lossy DataChannel") + void signalLossyTest() throws Exception { + OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("chrome"); + log.info("Signal Lossy DataChannel"); + signalReliableLossyAux(user, false); + } + + private void signalReliableLossyAux(OpenViduTestappUser user, boolean reliable) throws Exception { + final int expectedKind = reliable ? 0 : 1; // DataPacket_Kind: RELIABLE=0, LOSSY=1 + final String expectedKindStr = reliable ? "RELIABLE" : "LOSSY"; + final String btnClass = reliable ? ".message-reliable-btn" : ".message-lossy-btn"; + + for (int i = 0; i < 2; i++) { + WebElement addUserBtn = user.getDriver().findElement(By.id("add-user-btn")); + addUserBtn.click(); + user.getDriver().findElement(By.cssSelector("#openvidu-instance-" + i + " .subscriber-checkbox")).click(); + user.getDriver().findElement(By.cssSelector("#openvidu-instance-" + i + " .publisher-checkbox")).click(); + } + user.getDriver().findElements(By.className("connect-btn")).forEach(el -> el.sendKeys(Keys.ENTER)); + user.getEventManager().waitUntilEventReaches("signalConnected", "RoomEvent", 2); + user.getEventManager().waitUntilEventReaches("connected", "RoomEvent", 2); + user.getEventManager().waitUntilEventReaches("connectionStateChanged", "RoomEvent", 2); + user.getEventManager().waitUntilEventReaches("participantActive", "RoomEvent", 1); + + Collection> assertions = new ArrayList<>(); + List kindAssertions = Collections.synchronizedList(new ArrayList<>()); + + // Broadcast from TestParticipant0 + final CountDownLatch broadcastLatch0 = new CountDownLatch(2); + + user.getEventManager().on(1, "dataReceived", "RoomEvent", json -> { + assertions.add(new AbstractMap.SimpleEntry<>( + "Message from TestParticipant0 to all room (kind: " + expectedKindStr + ")", + json.getAsJsonObject().get("eventDescription").getAsString())); + kindAssertions.add(json.getAsJsonObject().get("eventContent").getAsJsonObject().get("kind").getAsInt()); + broadcastLatch0.countDown(); + }); + user.getEventManager().on(1, "dataReceived", "ParticipantEvent", json -> { + assertions.add(new AbstractMap.SimpleEntry<>( + "Message from TestParticipant0 to all room (kind: " + expectedKindStr + ")", + json.getAsJsonObject().get("eventDescription").getAsString())); + kindAssertions.add(json.getAsJsonObject().get("eventContent").getAsJsonObject().get("kind").getAsInt()); + broadcastLatch0.countDown(); + }); + + user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 " + btnClass)).click(); + user.getEventManager().waitUntilEventReaches(1, "dataReceived", "RoomEvent", 1); + user.getEventManager().waitUntilEventReaches(1, "dataReceived", "ParticipantEvent", 1); + // Do not trigger own signals + Assertions.assertEquals(0, user.getEventManager().getNumEvents(0, "dataReceived-RoomEvent").get()); + Assertions.assertEquals(0, user.getEventManager().getNumEvents(0, "dataReceived-ParticipantEvent").get()); + + if (!broadcastLatch0.await(3, TimeUnit.SECONDS)) { + Assertions.fail("Timeout waiting for broadcast signal event from TestParticipant0"); + } + assertions.forEach(assertion -> Assertions.assertEquals(assertion.getKey(), assertion.getValue())); + kindAssertions.forEach( + kind -> Assertions.assertEquals(expectedKind, kind, "Expected DataPacket_Kind " + expectedKind)); + user.getEventManager().off(1, "dataReceived", "RoomEvent"); + user.getEventManager().off(1, "dataReceived", "ParticipantEvent"); + assertions.clear(); + kindAssertions.clear(); + user.getEventManager().clearAllCurrentEvents(); + + // Broadcast from TestParticipant1 + final CountDownLatch broadcastLatch1 = new CountDownLatch(2); + + user.getEventManager().on(0, "dataReceived", "RoomEvent", json -> { + assertions.add(new AbstractMap.SimpleEntry<>( + "Message from TestParticipant1 to all room (kind: " + expectedKindStr + ")", + json.getAsJsonObject().get("eventDescription").getAsString())); + kindAssertions.add(json.getAsJsonObject().get("eventContent").getAsJsonObject().get("kind").getAsInt()); + broadcastLatch1.countDown(); + }); + user.getEventManager().on(0, "dataReceived", "ParticipantEvent", json -> { + assertions.add(new AbstractMap.SimpleEntry<>( + "Message from TestParticipant1 to all room (kind: " + expectedKindStr + ")", + json.getAsJsonObject().get("eventDescription").getAsString())); + kindAssertions.add(json.getAsJsonObject().get("eventContent").getAsJsonObject().get("kind").getAsInt()); + broadcastLatch1.countDown(); + }); + user.getDriver().findElement(By.cssSelector("#openvidu-instance-1 " + btnClass)).click(); + user.getEventManager().waitUntilEventReaches(0, "dataReceived", "RoomEvent", 1); + user.getEventManager().waitUntilEventReaches(0, "dataReceived", "ParticipantEvent", 1); + // Do not trigger own signals + Assertions.assertEquals(1, user.getEventManager().getNumEvents(0, "dataReceived-RoomEvent").get()); + Assertions.assertEquals(1, user.getEventManager().getNumEvents(0, "dataReceived-ParticipantEvent").get()); + + if (!broadcastLatch1.await(3, TimeUnit.SECONDS)) { + Assertions.fail("Timeout waiting for broadcast signal event from TestParticipant1"); + } + assertions.forEach(assertion -> Assertions.assertEquals(assertion.getKey(), assertion.getValue())); + kindAssertions.forEach( + kind -> Assertions.assertEquals(expectedKind, kind, "Expected DataPacket_Kind " + expectedKind)); + user.getEventManager().off(0, "dataReceived", "RoomEvent"); + user.getEventManager().off(0, "dataReceived", "ParticipantEvent"); + assertions.clear(); + kindAssertions.clear(); + user.getEventManager().clearAllCurrentEvents(); + + // Signal specific participant + + // Signal from TestParticipant0 to TestParticipant1 + final CountDownLatch directLatch0 = new CountDownLatch(2); + + user.getEventManager().on(1, "dataReceived", "RoomEvent", json -> { + assertions.add(new AbstractMap.SimpleEntry<>( + "Message from TestParticipant0 to TestParticipant1 (kind: " + expectedKindStr + ")", + json.getAsJsonObject().get("eventDescription").getAsString())); + kindAssertions.add(json.getAsJsonObject().get("eventContent").getAsJsonObject().get("kind").getAsInt()); + directLatch0.countDown(); + }); + user.getEventManager().on(1, "dataReceived", "ParticipantEvent", json -> { + assertions.add(new AbstractMap.SimpleEntry<>( + "Message from TestParticipant0 to TestParticipant1 (kind: " + expectedKindStr + ")", + json.getAsJsonObject().get("eventDescription").getAsString())); + kindAssertions.add(json.getAsJsonObject().get("eventContent").getAsJsonObject().get("kind").getAsInt()); + directLatch0.countDown(); + }); + user.getDriver() + .findElement( + By.cssSelector("#openvidu-instance-0 app-participant.remote-participant " + btnClass)) + .click(); + user.getEventManager().waitUntilEventReaches(1, "dataReceived", "RoomEvent", 1); + user.getEventManager().waitUntilEventReaches(1, "dataReceived", "ParticipantEvent", 1); + // Do not trigger own signals + Assertions.assertEquals(0, user.getEventManager().getNumEvents(0, "dataReceived-RoomEvent").get()); + Assertions.assertEquals(0, user.getEventManager().getNumEvents(0, "dataReceived-ParticipantEvent").get()); + + if (!directLatch0.await(3, TimeUnit.SECONDS)) { + Assertions.fail("Timeout waiting for direct signal event from TestParticipant0"); + } + assertions.forEach(assertion -> Assertions.assertEquals(assertion.getKey(), assertion.getValue())); + kindAssertions.forEach( + kind -> Assertions.assertEquals(expectedKind, kind, "Expected DataPacket_Kind " + expectedKind)); + user.getEventManager().off(1, "dataReceived", "RoomEvent"); + user.getEventManager().off(1, "dataReceived", "ParticipantEvent"); + assertions.clear(); + kindAssertions.clear(); + user.getEventManager().clearAllCurrentEvents(); + + // Signal from TestParticipant1 to TestParticipant0 + final CountDownLatch directLatch1 = new CountDownLatch(2); + + user.getEventManager().on(0, "dataReceived", "RoomEvent", json -> { + assertions.add(new AbstractMap.SimpleEntry<>( + "Message from TestParticipant1 to TestParticipant0 (kind: " + expectedKindStr + ")", + json.getAsJsonObject().get("eventDescription").getAsString())); + kindAssertions.add(json.getAsJsonObject().get("eventContent").getAsJsonObject().get("kind").getAsInt()); + directLatch1.countDown(); + }); + user.getEventManager().on(0, "dataReceived", "ParticipantEvent", json -> { + assertions.add(new AbstractMap.SimpleEntry<>( + "Message from TestParticipant1 to TestParticipant0 (kind: " + expectedKindStr + ")", + json.getAsJsonObject().get("eventDescription").getAsString())); + kindAssertions.add(json.getAsJsonObject().get("eventContent").getAsJsonObject().get("kind").getAsInt()); + directLatch1.countDown(); + }); + user.getDriver() + .findElement( + By.cssSelector("#openvidu-instance-1 app-participant.remote-participant " + btnClass)) + .click(); + user.getEventManager().waitUntilEventReaches(0, "dataReceived", "RoomEvent", 1); + user.getEventManager().waitUntilEventReaches(0, "dataReceived", "ParticipantEvent", 1); + // Do not trigger own signals + Assertions.assertEquals(0, user.getEventManager().getNumEvents(1, "dataReceived-RoomEvent").get()); + Assertions.assertEquals(0, user.getEventManager().getNumEvents(1, "dataReceived-ParticipantEvent").get()); + + if (!directLatch1.await(3, TimeUnit.SECONDS)) { + Assertions.fail("Timeout waiting for direct signal event from TestParticipant1"); + } + assertions.forEach(assertion -> Assertions.assertEquals(assertion.getKey(), assertion.getValue())); + kindAssertions.forEach( + kind -> Assertions.assertEquals(expectedKind, kind, "Expected DataPacket_Kind " + expectedKind)); + + gracefullyLeaveParticipants(user, 2); + } + + @Test + @DisplayName("ConnectionQualityChanged") + void connectionQualityChangedTest() throws Exception { + + OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("chrome"); + + log.info("ConnectionQualityChanged"); + + user.getDriver().findElement(By.id("auto-join-checkbox")).click(); + user.getDriver().findElement(By.id("one2one-btn")).click(); + + user.getEventManager().waitUntilEventReaches("signalConnected", "RoomEvent", 2); + user.getEventManager().waitUntilEventReaches("connected", "RoomEvent", 2); + user.getEventManager().waitUntilEventReaches("localTrackPublished", "RoomEvent", 4); + user.getEventManager().waitUntilEventReaches("trackSubscribed", "RoomEvent", 4); + user.getEventManager().waitUntilEventReaches("connectionQualityChanged", "RoomEvent", 4); + user.getEventManager().waitUntilEventReaches("connectionQualityChanged", "ParticipantEvent", 4); + + // Expect the connection quality events to include as text content: "excellent" + user.getDriver().findElements(By.cssSelector(".connectionQualityChanged-TestParticipant0 .event-content")) + .forEach(el -> Assertions.assertTrue(el.getText().contains("excellent"), + "Expected connection quality to be excellent")); + user.getDriver().findElements(By.cssSelector(".connectionQualityChanged-TestParticipant1 .event-content")) + .forEach(el -> Assertions.assertTrue(el.getText().contains("excellent"), + "Expected connection quality to be excellent")); + + gracefullyLeaveParticipants(user, 2); + } + + private List getConnectionQualityEventContents(OpenViduTestappUser user, int numberOfUser, + String participantName) { + String xpath = "//*[@id='openvidu-instance-" + numberOfUser + + "']//mat-expansion-panel[.//mat-expansion-panel-header[contains(@class,'connectionQualityChanged-" + + participantName + "')]]//div[contains(@class,'event-content')]"; + return user.getDriver().findElements(By.xpath(xpath)); + } + + // Latest connection-quality level currently reported for a participant, or null + // if none yet. + private ConnectionQuality latestConnectionQuality(OpenViduTestappUser user, int numberOfUser, + String participantName) { + List contents = getConnectionQualityEventContents(user, numberOfUser, participantName); + for (int i = contents.size() - 1; i >= 0; i--) { + String text = contents.get(i).getAttribute("textContent"); + if (text == null) { + continue; + } + text = text.toLowerCase().trim(); + if (text.contains("excellent")) { + return ConnectionQuality.EXCELLENT; + } + if (text.contains("good")) { + return ConnectionQuality.GOOD; + } + if (text.contains("poor")) { + return ConnectionQuality.POOR; + } + if (text.contains("lost")) { + return ConnectionQuality.LOST; + } + } + return null; + } + + // First loss% (ascending) at which the recorded quality equals `level`, or -1 + // if never reached. + private static int firstLossReaching(Map observed, ConnectionQuality level) { + for (Entry e : observed.entrySet()) { + if (e.getValue() == level) { + return e.getKey(); + } + } + return -1; + } + + // Render the ramp results (publisher vs subscriber-self quality per loss step) + // + the band transitions as a single log table. + private static String buildRampResultTable(Map publisher, + Map subscriber) { + StringBuilder sb = new StringBuilder("\nConnectionQuality ramp-up results\n"); + sb.append(" loss% | PunchbagUser (publisher) | RegularUser (subscriber, self)\n"); + sb.append(" ------+--------------------------+-------------------------------\n"); + ConnectionQuality prev = null; + for (Entry e : publisher.entrySet()) { + int pct = e.getKey(); + ConnectionQuality pub = e.getValue(); + ConnectionQuality sub = subscriber.get(pct); + String transition = (prev != null && pub != prev) ? " <- " + prev + " -> " + pub : ""; + sb.append(String.format(" %4d%% | %-24s | %-30s%s%n", pct, String.valueOf(pub), String.valueOf(sub), + transition)); + prev = pub; + } + sb.append(" transitions: EXCELLENT->GOOD @").append(firstLossReaching(publisher, ConnectionQuality.GOOD)) + .append("%, GOOD->POOR @").append(firstLossReaching(publisher, ConnectionQuality.POOR)) + .append("%, POOR->LOST @").append(firstLossReaching(publisher, ConnectionQuality.LOST)).append("%"); + return sb.toString(); + } + + private void assertConnectionQualityNeverDegraded(OpenViduTestappUser user, int numberOfUser, + String participantName) { + List contents = getConnectionQualityEventContents(user, numberOfUser, participantName); + Assertions.assertFalse(contents.isEmpty(), "Expected at least one connectionQualityChanged event for " + + participantName + " in openvidu-instance-" + numberOfUser); + String latest = null; + for (WebElement el : contents) { + String quality = el.getAttribute("textContent"); + if (quality == null) { + continue; + } + quality = quality.toLowerCase().trim(); + if (quality.isEmpty()) { + continue; + } + Assertions.assertFalse(quality.contains("poor") || quality.contains("lost"), + "Connection quality for " + participantName + " (openvidu-instance-" + numberOfUser + + ") must never be POOR or LOST under server-side-only conditions, but observed: '" + + quality + + "'"); + latest = quality; + } + Assertions.assertNotNull(latest, + "No connection quality value found for " + participantName + " in openvidu-instance-" + numberOfUser); + Assertions.assertTrue(latest.contains("excellent"), + "Connection quality for " + participantName + " (openvidu-instance-" + numberOfUser + + ") should settle on EXCELLENT, but the latest value was: '" + latest + "'"); + } + + @Test + @DisplayName("ConnectionQuality EXCELLENT with many published and subscribed tracks") + void connectionQualityManyTracksAlwaysExcellentTest() throws Exception { + + OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("chrome"); + + log.info("ConnectionQuality EXCELLENT with many published and subscribed tracks"); + + final int N = 3; + user.getDriver().findElement(By.id("auto-join-checkbox")).click(); + WebElement nInput = user.getDriver().findElement(By.id("one2many-input")); + nInput.clear(); + nInput.sendKeys(String.valueOf(N)); + user.getDriver().findElement(By.id("one2one-btn")).click(); + + // N participants, each publishing audio+video and subscribing to all others + user.getEventManager().waitUntilEventReaches("connected", "RoomEvent", N); + user.getEventManager().waitUntilEventReaches("localTrackPublished", "RoomEvent", 2 * N); + user.getEventManager().waitUntilEventReaches("connectionQualityChanged", "RoomEvent", N); + + // Let quality settle and give any (wrong) degradation time to surface + Thread.sleep(CONNECTION_QUALITY_OBSERVATION_MS); + + for (int i = 0; i < N; i++) { + assertConnectionQualityNeverDegraded(user, i, "TestParticipant" + i); + } + + gracefullyLeaveParticipants(user, N); + } + + @Test + @DisplayName("ConnectionQuality EXCELLENT when publisher pauses a track") + void connectionQualityPublisherPauseAlwaysExcellentTest() throws Exception { + + OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("chrome"); + + log.info("ConnectionQuality EXCELLENT when publisher pauses a track"); + + user.getDriver().findElement(By.id("auto-join-checkbox")).click(); + user.getDriver().findElement(By.id("one2one-btn")).click(); + + user.getEventManager().waitUntilEventReaches("connected", "RoomEvent", 2); + user.getEventManager().waitUntilEventReaches("localTrackPublished", "RoomEvent", 4); + user.getEventManager().waitUntilEventReaches("trackSubscribed", "RoomEvent", 4); + user.getEventManager().waitUntilEventReaches("connectionQualityChanged", "RoomEvent", 2); + + // Publisher (instance 0) pauses (mutes) its video track -> the scorer heals to + // EXCELLENT (mute -> cMaxScore), so quality must not degrade. + user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 .mute-unmute-video")).sendKeys(Keys.ENTER); + user.getEventManager().waitUntilEventReaches("trackMuted", "RoomEvent", 2); + + Thread.sleep(CONNECTION_QUALITY_OBSERVATION_MS); + + assertConnectionQualityNeverDegraded(user, 0, "TestParticipant0"); + assertConnectionQualityNeverDegraded(user, 1, "TestParticipant1"); + + gracefullyLeaveParticipants(user, 2); + } + + @Test + @DisplayName("ConnectionQuality EXCELLENT when subscriber pauses a track") + void connectionQualitySubscriberPauseAlwaysExcellentTest() throws Exception { + + OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("chrome"); + + log.info("ConnectionQuality EXCELLENT when subscriber pauses a track"); + + user.getDriver().findElement(By.id("auto-join-checkbox")).click(); + user.getDriver().findElement(By.id("one2one-btn")).click(); + + user.getEventManager().waitUntilEventReaches("connected", "RoomEvent", 2); + user.getEventManager().waitUntilEventReaches("trackSubscribed", "RoomEvent", 4); + user.getEventManager().waitUntilEventReaches("connectionQualityChanged", "RoomEvent", 2); + + // Subscriber (instance 1) disables playback of the subscribed video track -> + // the + // DownTrack forwarder is muted and the downstream scorer heals to EXCELLENT. + user.getDriver().findElement(By.cssSelector("#openvidu-instance-1 .toggle-video-enabled")).sendKeys(Keys.ENTER); + + Thread.sleep(CONNECTION_QUALITY_OBSERVATION_MS); + + assertConnectionQualityNeverDegraded(user, 0, "TestParticipant0"); + assertConnectionQualityNeverDegraded(user, 1, "TestParticipant1"); + + gracefullyLeaveParticipants(user, 2); + } + + @Test + @DisplayName("ConnectionQuality EXCELLENT when Dynacast pauses an unsubscribed track") + void connectionQualityDynacastPauseAlwaysExcellentTest() throws Exception { + + OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("chrome"); + + log.info("ConnectionQuality EXCELLENT when Dynacast pauses an unsubscribed track"); + + // Instance 0: video-only publisher with Dynacast + simulcast enabled. + this.addPublisher(user, false, true, true, false, false, true, null, null, null); + // Instance 1: subscriber only. + this.addSubscriber(user, true); + + user.getDriver().findElements(By.className("connect-btn")).forEach(el -> el.sendKeys(Keys.ENTER)); + + user.getEventManager().waitUntilEventReaches("connected", "RoomEvent", 2); + user.getEventManager().waitUntilEventReaches("trackSubscribed", "RoomEvent", 1); + user.getEventManager().waitUntilEventReaches("connectionQualityChanged", "RoomEvent", 1); + + // Subscriber unsubscribes from the only track -> the publisher's track becomes + // fully unwatched -> Dynacast pauses it -> the publisher stops sending and the + // mediasoup Producer score would decay to 0. Connection quality MUST stay + // EXCELLENT, NOT drop to POOR/LOST. + user.getDriver().findElement(By.cssSelector("#openvidu-instance-1 .toggle-video-subscribed")) + .sendKeys(Keys.ENTER); + + // Generous wait: Dynacast pause delay + producer inactivity (~1.5s) + EMA + + // room ticks + Thread.sleep(CONNECTION_QUALITY_OBSERVATION_MS + 10000); + + assertConnectionQualityNeverDegraded(user, 0, "TestParticipant0"); + + gracefullyLeaveParticipants(user, 2); + } + + @Test + @DisplayName("ConnectionQuality EXCELLENT when Adaptive Stream pauses a track") + void connectionQualityAdaptiveStreamPauseAlwaysExcellentTest() throws Exception { + + OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("chrome"); + + log.info("ConnectionQuality EXCELLENT when Adaptive Stream pauses a track"); + + // one2one publishes audio+video with Adaptive Stream enabled (testapp default). + user.getDriver().findElement(By.id("auto-join-checkbox")).click(); + user.getDriver().findElement(By.id("one2one-btn")).click(); + + user.getEventManager().waitUntilEventReaches("connected", "RoomEvent", 2); + user.getEventManager().waitUntilEventReaches("trackSubscribed", "RoomEvent", 4); + user.getEventManager().waitUntilEventReaches("connectionQualityChanged", "RoomEvent", 2); + + // Hide the remote video elements so Adaptive Stream pauses those subscriptions + // (Adaptive Stream pauses tracks whose