From 34c70808ad4508031f82a765a98bf43755e5cf28 Mon Sep 17 00:00:00 2001 From: pabloFuente Date: Wed, 16 Sep 2026 00:53:49 +0200 Subject: [PATCH] e2e: verify every mat-select quality change and diagnose stalled subscriber videos --- .../e2e/AbstractOpenViduTestappE2eTest.java | 101 +++++++++++++++--- .../e2e/OpenViduTestAppE2eServerSdkTest.java | 7 +- .../test/e2e/OpenViduTestAppE2eTest.java | 56 ++++------ .../video-track/video-track.component.html | 2 +- 4 files changed, 105 insertions(+), 61 deletions(-) diff --git a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/AbstractOpenViduTestappE2eTest.java b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/AbstractOpenViduTestappE2eTest.java index 608eee1c4..281554ec4 100644 --- a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/AbstractOpenViduTestappE2eTest.java +++ b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/AbstractOpenViduTestappE2eTest.java @@ -9,7 +9,6 @@ import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; @@ -260,6 +259,45 @@ public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e { return isStatPresent(statValue) ? String.valueOf(statValue) : "absent"; } + // Every subscriber stat that says something about the state of the inbound + // video, so that a wait that times out reports what it actually saw instead + // of only the one value it was comparing + protected String describeSubscriberVideoLayer(JsonObject layer) { + return "Last observed: frameWidth=" + describeStat(getLayerCounter(layer, "frameWidth")) + " frameHeight=" + + describeStat(getLayerCounter(layer, "frameHeight")) + " framesPerSecond=" + + describeStat(getLayerCounter(layer, "framesPerSecond")) + " framesReceived=" + + describeStat(getLayerCounter(layer, "framesReceived")) + " framesDecoded=" + + describeStat(getLayerCounter(layer, "framesDecoded")) + " keyFramesDecoded=" + + describeStat(getLayerCounter(layer, "keyFramesDecoded")) + " framesDropped=" + + describeStat(getLayerCounter(layer, "framesDropped")) + " freezeCount=" + + describeStat(getLayerCounter(layer, "freezeCount")) + " bytesReceived=" + + describeStat(getLayerCounter(layer, "bytesReceived")); + } + + // The three states a stalled subscriber video can be in, told apart by + // framesReceived (frames the depacketizer assembled, before the decoder) and + // framesDecoded. bytesReceived alone cannot tell them apart: it also counts + // retransmissions and the padding the SFU sends to probe for bandwidth, so it + // grows even while no frame at all reaches the decoder + protected String diagnoseSubscriberVideoLayer(JsonObject layer) { + long framesReceived = getLayerCounter(layer, "framesReceived"); + long framesDecoded = getLayerCounter(layer, "framesDecoded"); + if (!isStatPresent(framesReceived) || !isStatPresent(framesDecoded)) { + return "getStats() reported no frame counters for this track, so the subscriber never got as far as" + + " receiving media on it"; + } + if (framesReceived <= 0) { + return "The subscriber is not receiving assembled frames at all: the media is not reaching it"; + } + if (framesDecoded <= 0) { + return "The subscriber IS receiving assembled frames (" + framesReceived + + ") but decoded none of them: the media that reaches it is undecodable (a Producer bound to" + + " the wrong codec, or a missing or wrong dependency descriptor)"; + } + return "The subscriber received " + framesReceived + " assembled frame(s) and decoded " + framesDecoded + + " of them, so media did flow at some point"; + } + // If rid is null, retrieve the first layer protected JsonElement getPublisherVideoLayerAttribute(OpenViduTestappUser user, WebElement publisherVideo, String rid, @@ -296,25 +334,31 @@ public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e { } protected void waitUntilSubscriberFramesPerSecondNotZero(OpenViduTestappUser user, WebElement videoElement) { - // Kept across iterations only to tell "absent" from "present and 0" if the - // wait times out. Those two mean very different things here, and the - // original message ("waiting for framesPerSecond to exist") named neither - final AtomicLong lastFps = new AtomicLong(-1); + // Kept across iterations only to report what the last sample actually held + // if the wait times out. An absent framesPerSecond and a present 0 mean very + // different things here, and neither of them says whether the media reached + // the subscriber at all: only framesReceived (frames the depacketizer + // assembled) against framesDecoded tells "nothing is arriving" from + // "something is arriving that the decoder cannot use" + final JsonObject[] lastLayer = { new JsonObject() }; this.waitUntilAux(user, videoElement, () -> { // Chrome only starts reporting framesPerSecond once the decoder has // produced frames for a whole second, so right after playback starts // it is legitimately absent for a while: a "not yet", not a failure JsonObject layer = this.getSubscriberVideoLayer(user, videoElement); + lastLayer[0] = layer; long fps = this.getLayerCounter(layer, "framesPerSecond"); - lastFps.set(fps); return isStatPresent(fps) && fps > 0; }, () -> { - long fps = lastFps.get(); + long fps = this.getLayerCounter(lastLayer[0], "framesPerSecond"); return "Timeout waiting for video track to have a framesPerSecond greater than 0. Last value: " - + describeStat(fps) + (isStatPresent(fps) ? "" + + describeStat(fps) + + (isStatPresent(fps) ? "" : ". Chrome omits framesPerSecond altogether when the decoder produced no frame" + " during the last second, so this subscriber video was frozen for the" - + " whole wait, not merely slow to start"); + + " whole wait, not merely slow to start") + + ". " + describeSubscriberVideoLayer(lastLayer[0]) + ". " + + diagnoseSubscriberVideoLayer(lastLayer[0]); }); } @@ -336,13 +380,8 @@ public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e { lastLayer[0] = layer; long frameWidth = this.getLayerCounter(layer, "frameWidth"); return isStatPresent(frameWidth) && frameWidth == expectedFrameWidth; - }, () -> "Timeout waiting for video track to have a frameWidth of " + expectedFrameWidth - + ". Last observed: frameWidth=" + describeStat(getLayerCounter(lastLayer[0], "frameWidth")) - + " frameHeight=" + describeStat(getLayerCounter(lastLayer[0], "frameHeight")) + " framesPerSecond=" - + describeStat(getLayerCounter(lastLayer[0], "framesPerSecond")) + " framesDecoded=" - + describeStat(getLayerCounter(lastLayer[0], "framesDecoded")) + " keyFramesDecoded=" - + describeStat(getLayerCounter(lastLayer[0], "keyFramesDecoded")) + " bytesReceived=" - + describeStat(getLayerCounter(lastLayer[0], "bytesReceived"))); + }, () -> "Timeout waiting for video track to have a frameWidth of " + expectedFrameWidth + ". " + + describeSubscriberVideoLayer(lastLayer[0])); } protected void waitUntilSubscriberFrameHeightIs(OpenViduTestappUser user, WebElement videoElement, @@ -356,11 +395,18 @@ public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e { protected void waitUntilSubscriberFrameWidthChanges(OpenViduTestappUser user, WebElement videoElement, final int oldFrameWidth, final boolean shouldBeHigher) { + final JsonObject[] lastLayer = { new JsonObject() }; this.waitUntilAux(user, videoElement, () -> { JsonObject layer = this.getSubscriberVideoLayer(user, videoElement); + lastLayer[0] = layer; long frameWidth = this.getLayerCounter(layer, "frameWidth"); return isStatPresent(frameWidth) && frameWidth != oldFrameWidth; - }, "Timeout waiting for video track to reach a " + (shouldBeHigher ? "higher" : "lower") + " resolution"); + }, () -> "Timeout waiting for video track to reach a " + (shouldBeHigher ? "higher" : "lower") + + " resolution than " + oldFrameWidth + ". " + describeSubscriberVideoLayer(lastLayer[0]) + ". " + // frameWidth is the width of the last frame the decoder produced, so a + // video that froze keeps reporting the old width forever and looks + // exactly like a layer switch that never happened + + diagnoseSubscriberVideoLayer(lastLayer[0])); int newFrameWidth = this.getSubscriberVideoFrameWidth(user, videoElement); if (shouldBeHigher) { Assertions.assertTrue(newFrameWidth > oldFrameWidth, @@ -744,6 +790,27 @@ public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e { + maxAttempts + " attempts"); } + /** + * Selects the max video quality (LOW, MEDIUM or HIGH) of the remote video of a + * testapp instance, closing the track info dialog first if it is open (its + * backdrop covers the video controls). + * + * The selection is verified (selectMatOption). An unverified click that only + * opens and closes the mat-select panel leaves the quality untouched and + * raises nothing: setVideoQuality() is never called, no UpdateTrackSettings + * ever reaches the server, and the wait that follows times out reporting that + * the subscriber never changed layer, when in fact nothing was ever asked of + * it. + */ + protected void selectSubscriberVideoQuality(OpenViduTestappUser user, String instanceSelector, String quality) + throws InterruptedException { + if (!user.getDriver().findElements(By.cssSelector("app-info-dialog")).isEmpty()) { + this.waitAndClick(user, "#close-dialog-btn"); + Thread.sleep(300); + } + this.selectMatOption(user, instanceSelector + " #max-video-quality", quality); + } + /** * Waits until the element matching the CSS selector is present, displayed and * enabled, then clicks it, retrying every 250 ms for up to 10 seconds on the diff --git a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eServerSdkTest.java b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eServerSdkTest.java index 3c94da3ca..d7d3cc589 100644 --- a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eServerSdkTest.java +++ b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eServerSdkTest.java @@ -268,12 +268,7 @@ public class OpenViduTestAppE2eServerSdkTest extends AbstractOpenViduTestappE2eT * over the page, which would block the later clicks on the video controls. */ private void selectSubscriberVideoQuality(Subscriber subscriber, String quality) throws InterruptedException { - OpenViduTestappUser user = subscriber.user(); - if (!user.getDriver().findElements(By.cssSelector("app-info-dialog")).isEmpty()) { - user.getDriver().findElement(By.cssSelector("#close-dialog-btn")).click(); - Thread.sleep(300); - } - this.selectMatOption(user, subscriber.instanceSelector() + " #max-video-quality", quality); + this.selectSubscriberVideoQuality(subscriber.user(), subscriber.instanceSelector(), quality); } /** 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 248bf9b2c..1021a7f50 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 @@ -2640,12 +2640,10 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest { user.getEventManager().waitUntilEventReaches(2, "trackSubscribed", "RoomEvent", 1); // Manually change video quality of first subscriber to q - user.getDriver().findElement(By.cssSelector("#openvidu-instance-1 #max-video-quality")).click(); - this.waitAndClick(user, "mat-option.mode-LOW"); + this.selectSubscriberVideoQuality(user, "#openvidu-instance-1", "LOW"); // Manually change video quality of second subscriber to f - user.getDriver().findElement(By.cssSelector("#openvidu-instance-2 #max-video-quality")).click(); - this.waitAndClick(user, "mat-option.mode-HIGH"); + this.selectSubscriberVideoQuality(user, "#openvidu-instance-2", "HIGH"); subscriberVideo1 = user.getDriver().findElement(By.cssSelector("#openvidu-instance-1 video.remote")); WebElement subscriberVideo2 = user.getDriver().findElement(By.cssSelector("#openvidu-instance-2 video.remote")); @@ -2672,8 +2670,7 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest { secondSubscriberToggle.click(); // Manually change video quality of second subscriber to h - user.getDriver().findElement(By.cssSelector("#openvidu-instance-2 #max-video-quality")).click(); - this.waitAndClick(user, "mat-option.mode-MEDIUM"); + this.selectSubscriberVideoQuality(user, "#openvidu-instance-2", "MEDIUM"); this.waitUntilPublisherLayerActive(user, publisherVideo, "q", true); this.waitUntilPublisherLayerActive(user, publisherVideo, "h", true); @@ -2836,18 +2833,15 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest { this.waitUntilSubscriberFramesDecodedIncrease(user, subscriberVideo); // Manually change video quality of subscriber to h - user.getDriver().findElement(By.cssSelector("#openvidu-instance-1 #max-video-quality")).click(); - this.waitAndClick(user, "mat-option.mode-MEDIUM"); + this.selectSubscriberVideoQuality(user, "#openvidu-instance-1", "MEDIUM"); this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, h); // Manually change video quality of subscriber to q - user.getDriver().findElement(By.cssSelector("#openvidu-instance-1 #max-video-quality")).click(); - this.waitAndClick(user, "mat-option.mode-LOW"); + this.selectSubscriberVideoQuality(user, "#openvidu-instance-1", "LOW"); this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, q); // Manually change video quality of subscriber to f - user.getDriver().findElement(By.cssSelector("#openvidu-instance-1 #max-video-quality")).click(); - this.waitAndClick(user, "mat-option.mode-HIGH"); + this.selectSubscriberVideoQuality(user, "#openvidu-instance-1", "HIGH"); this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, f); gracefullyLeaveParticipants(user, 2); @@ -3426,8 +3420,7 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest { // Even after forcing the low quality layer in the subscriber, with dynacast // enabled, the entire SVC stream should remain active in publisher - user.getDriver().findElement(By.cssSelector("#openvidu-instance-1 #max-video-quality")).click(); - this.waitAndClick(user, "mat-option.mode-LOW"); + this.selectSubscriberVideoQuality(user, "#openvidu-instance-1", "LOW"); this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, 960); this.waitUntilSubscriberFramesDecodedIncrease(user, subscriberVideo); @@ -3646,8 +3639,7 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest { // layers // at that size, so it stops producing the top one final long switchStart = System.currentTimeMillis(); - user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 #restart-video-resolution")).click(); - this.waitAndClick(user, "mat-option.res-640x360"); + this.selectMatOption(user, "#openvidu-instance-0 #restart-video-resolution", "640x360"); this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, 640); final long switchMillis = System.currentTimeMillis() - switchStart; this.waitUntilSubscriberFramesDecodedIncrease(user, subscriberVideo); @@ -3666,8 +3658,7 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest { + " ms)"); // And it must be able to follow the publisher back up (this needs a keyframe) - user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 #restart-video-resolution")).click(); - this.waitAndClick(user, "mat-option.res-1920x1080"); + this.selectMatOption(user, "#openvidu-instance-0 #restart-video-resolution", "1920x1080"); this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, 1920); this.waitUntilSubscriberFramesDecodedIncrease(user, subscriberVideo); @@ -3910,7 +3901,7 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest { // sufficient for that size. Only "LOW" (small element) and "HIGH" (large // element) are meaningful in that case. private void switchSubscriberSpatialLayer(OpenViduTestappUser user, WebElement subscriberVideo, - boolean adaptiveStream, String quality) { + boolean adaptiveStream, String quality) throws InterruptedException { if (adaptiveStream) { if ("LOW".equals(quality)) { changeElementSize(user, subscriberVideo, 80, 100); @@ -3918,8 +3909,7 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest { changeElementSize(user, subscriberVideo, 1000, 700); } } else { - user.getDriver().findElement(By.cssSelector("#openvidu-instance-1 #max-video-quality")).click(); - this.waitAndClick(user, "mat-option.mode-" + quality); + this.selectSubscriberVideoQuality(user, "#openvidu-instance-1", quality); } } @@ -4819,38 +4809,30 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest { private void testThreeLayers(OpenViduTestappUser user, WebElement subscriberVideo) throws InterruptedException { // Check manual simulcast changes this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, 1920); - user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 #max-video-quality")).click(); - this.waitAndClick(user, "mat-option.mode-LOW"); + this.selectSubscriberVideoQuality(user, "#openvidu-instance-0", "LOW"); this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, 640); - user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 #max-video-quality")).click(); - this.waitAndClick(user, "mat-option.mode-MEDIUM"); + this.selectSubscriberVideoQuality(user, "#openvidu-instance-0", "MEDIUM"); this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, 1280); - user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 #max-video-quality")).click(); - this.waitAndClick(user, "mat-option.mode-HIGH"); + this.selectSubscriberVideoQuality(user, "#openvidu-instance-0", "HIGH"); this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, 1920); } private void testTwoLayers(OpenViduTestappUser user, WebElement subscriberVideo) throws InterruptedException { // Check manual simulcast changes this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, 960); - user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 #max-video-quality")).click(); - this.waitAndClick(user, "mat-option.mode-LOW"); + this.selectSubscriberVideoQuality(user, "#openvidu-instance-0", "LOW"); this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, 480); - user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 #max-video-quality")).click(); - this.waitAndClick(user, "mat-option.mode-MEDIUM"); + this.selectSubscriberVideoQuality(user, "#openvidu-instance-0", "MEDIUM"); this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, 960); - user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 #max-video-quality")).click(); - this.waitAndClick(user, "mat-option.mode-LOW"); + this.selectSubscriberVideoQuality(user, "#openvidu-instance-0", "LOW"); this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, 480); - user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 #max-video-quality")).click(); - this.waitAndClick(user, "mat-option.mode-HIGH"); + this.selectSubscriberVideoQuality(user, "#openvidu-instance-0", "HIGH"); this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, 960); } private void testNoSimulcast(OpenViduTestappUser user, WebElement subscriberVideo) throws InterruptedException { this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, 1920); - user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 #max-video-quality")).click(); - this.waitAndClick(user, "mat-option.mode-LOW"); + this.selectSubscriberVideoQuality(user, "#openvidu-instance-0", "LOW"); // Without simulcast video should remain in high quality Thread.sleep(4000); this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, 1920); diff --git a/openvidu-testapp/src/app/components/video-track/video-track.component.html b/openvidu-testapp/src/app/components/video-track/video-track.component.html index 40314f6ae..f8cd5e65b 100644 --- a/openvidu-testapp/src/app/components/video-track/video-track.component.html +++ b/openvidu-testapp/src/app/components/video-track/video-track.component.html @@ -23,7 +23,7 @@ @for (r of ['1920x1080', '1280x720', '640x360', '320x180']; track r) { - {{r}} + {{r}} }