openvidu-test-e2e: separate regular client tests for the openvidu-testapp from the server SDK tests

pull/907/head
pabloFuente 2026-09-03 14:57:52 +02:00
parent 1f59766996
commit bfb3f3291e
3 changed files with 975 additions and 909 deletions

View File

@ -3,14 +3,25 @@ package io.openvidu.test.e2e;
import java.util.Collection; import java.util.Collection;
import java.util.HashSet; import java.util.HashSet;
import java.util.Iterator; import java.util.Iterator;
import java.util.concurrent.Callable;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.openqa.selenium.By; import org.openqa.selenium.By;
import org.openqa.selenium.Keys; import org.openqa.selenium.Keys;
import org.openqa.selenium.TakesScreenshot;
import org.openqa.selenium.WebElement; import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.ui.ExpectedConditions;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.openvidu.test.browsers.BrowserUser; import io.openvidu.test.browsers.BrowserUser;
import static org.openqa.selenium.OutputType.BASE64;
public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e { public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e {
protected Collection<OpenViduTestappUser> testappUsers = new HashSet<>(); protected Collection<OpenViduTestappUser> testappUsers = new HashSet<>();
@ -66,4 +77,611 @@ public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e {
super.dispose(); super.dispose();
} }
protected static final long WAIT_UNTIL_MAX_MILLIS = 20000;
// Minimum average frame rate that a subscriber video must sustain over a
// window of at least MIN_FRAMES_DECODED_WINDOW_MILLIS to be considered
// properly decoded and played
protected static final long MIN_FRAMES_DECODED_FPS = 4;
protected static final long MIN_FRAMES_DECODED_WINDOW_MILLIS = 2000;
protected 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);
}
protected 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());
}
}
protected int countNumberOfPublishedLayers(OpenViduTestappUser user, WebElement publisherVideo) {
JsonArray json = this.getLayersAsJsonArray(user, publisherVideo);
return json.size();
}
protected int getSubscriberVideoFrameWidth(OpenViduTestappUser user, WebElement subscriberVideo) {
return getSubscriberVideoLayerStat(user, subscriberVideo, "frameWidth", JsonElement::getAsInt);
}
protected int getSubscriberVideoFrameHeight(OpenViduTestappUser user, WebElement subscriberVideo) {
return getSubscriberVideoLayerStat(user, subscriberVideo, "frameHeight", JsonElement::getAsInt);
}
protected long getSubscriberVideoBytesReceived(OpenViduTestappUser user, WebElement subscriberVideo) {
return getSubscriberVideoLayerStat(user, subscriberVideo, "bytesReceived", JsonElement::getAsLong);
}
protected int getSubscriberVideoFramesPerSecond(OpenViduTestappUser user, WebElement subscriberVideo) {
return getSubscriberVideoLayerStat(user, subscriberVideo, "framesPerSecond", JsonElement::getAsInt);
}
protected long getSubscriberVideoFramesDecoded(OpenViduTestappUser user, WebElement subscriberVideo) {
return getSubscriberVideoLayerStat(user, subscriberVideo, "framesDecoded", JsonElement::getAsLong);
}
protected long getSubscriberVideoFramesReceived(OpenViduTestappUser user, WebElement subscriberVideo) {
return getSubscriberVideoLayerStat(user, subscriberVideo, "framesReceived", JsonElement::getAsLong);
}
protected String getSubscriberVideoCodec(OpenViduTestappUser user, WebElement subscriberVideo) {
return getSubscriberVideoLayerStat(user, subscriberVideo, "codec", JsonElement::getAsString);
}
protected <T> T getSubscriberVideoLayerStat(OpenViduTestappUser user, WebElement subscriberVideo, String field,
java.util.function.Function<JsonElement, T> extractor) {
final long deadline = System.currentTimeMillis() + WAIT_UNTIL_MAX_MILLIS;
JsonElement element = null;
do {
try {
element = getLayersAsJsonArray(user, subscriberVideo).get(0).getAsJsonObject().get(field);
} catch (Exception e) {
element = null;
}
if (element == null || element.isJsonNull()) {
try {
Thread.sleep(250);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
} while ((element == null || element.isJsonNull()) && System.currentTimeMillis() < deadline);
if (element == null || element.isJsonNull()) {
Assertions.fail("Timeout waiting for " + field + " to exist");
}
return extractor.apply(element);
}
// Several stats of the same subscriber layer, taken from a single info dialog
// update. Sampling them one by one through getSubscriberVideoLayerStat would
// pay
// a full dialog read, and a stats refresh, for each one of them
protected JsonObject getSubscriberVideoLayer(OpenViduTestappUser user, WebElement subscriberVideo) {
JsonArray layers = this.getLayersAsJsonArray(user, subscriberVideo);
return layers.isEmpty() ? new JsonObject() : layers.get(0).getAsJsonObject();
}
// Cumulative counter of the given layer, or -1 if it is not there
protected long getLayerCounter(JsonObject layer, String field) {
JsonElement element = layer.get(field);
return element == null || element.isJsonNull() ? -1 : element.getAsLong();
}
// If rid is null, retrieve the first layer
protected JsonElement getPublisherVideoLayerAttribute(OpenViduTestappUser user, WebElement publisherVideo,
String rid,
String attribute) {
JsonArray json = this.getLayersAsJsonArray(user, publisherVideo);
JsonElement result;
if (rid != null) {
result = json.asList().stream().parallel()
.filter(jsonElement -> rid.equals(jsonElement.getAsJsonObject().get("rid").getAsString())).findAny()
.get();
} else {
result = json.get(0);
}
return result.getAsJsonObject().get(attribute);
}
protected String getLayersAsString(OpenViduTestappUser user, WebElement video) {
this.openInfoDialog(user, video);
user.getDriver().findElement(By.cssSelector("#update-value-btn")).click();
WebElement textarea = user.getDriver().findElement(By.id("info-text-area"));
return textarea.getAttribute("value");
}
protected JsonArray getLayersAsJsonArray(OpenViduTestappUser user, WebElement video) {
String value = getLayersAsString(user, video);
return JsonParser.parseString(value).getAsJsonArray();
}
protected void waitUntilVideoLayersNotEmpty(OpenViduTestappUser user, WebElement videoElement) {
this.waitUntilAux(user, videoElement, () -> {
String value = getLayersAsString(user, videoElement);
return !value.isBlank() && !JsonParser.parseString(value).getAsJsonArray().isEmpty();
}, "Timeout waiting video layers to not be empty");
}
protected void waitUntilSubscriberFramesPerSecondNotZero(OpenViduTestappUser user, WebElement videoElement) {
this.waitUntilAux(user, videoElement, () -> {
return this.getSubscriberVideoFramesPerSecond(user, videoElement) > 0;
}, "Timeout waiting for video track to have a framesPerSecond greater than 0");
}
protected void waitUntilSubscriberFramesPerSecondIs(OpenViduTestappUser user, WebElement videoElement, int fps) {
this.waitUntilAux(user, videoElement, () -> {
return this.getSubscriberVideoFramesPerSecond(user, videoElement) == fps;
}, "Timeout waiting for video track to have a framesPerSecond equal to " + fps);
}
protected void waitUntilSubscriberFrameWidthIs(OpenViduTestappUser user, WebElement videoElement,
final int expectedFrameWidth) {
this.waitUntilAux(user, videoElement, () -> {
return this.getSubscriberVideoFrameWidth(user, videoElement) == expectedFrameWidth;
}, "Timeout waiting for video track to have a frameWidth of " + expectedFrameWidth);
}
protected void waitUntilSubscriberFrameHeightIs(OpenViduTestappUser user, WebElement videoElement,
final int expectedFrameHeight) {
this.waitUntilAux(user, videoElement, () -> {
return this.getSubscriberVideoFrameHeight(user, videoElement) == expectedFrameHeight;
}, "Timeout waiting for video track to have a frameHeight of " + expectedFrameHeight);
}
protected void waitUntilSubscriberFrameWidthChanges(OpenViduTestappUser user, WebElement videoElement,
final int oldFrameWidth, final boolean shouldBeHigher) {
this.waitUntilAux(user, videoElement, () -> {
return this.getSubscriberVideoFrameWidth(user, videoElement) != oldFrameWidth;
}, "Timeout waiting for video track to reach a " + (shouldBeHigher ? "higher" : "lower") + " resolution");
int newFrameWidth = this.getSubscriberVideoFrameWidth(user, videoElement);
if (shouldBeHigher) {
Assertions.assertTrue(newFrameWidth > oldFrameWidth,
"Video track should have now a higher resolution, but it is not. Old width: " + oldFrameWidth
+ ". New width: " + newFrameWidth);
} else {
Assertions.assertTrue(newFrameWidth < oldFrameWidth,
"Video track should have now a lower resolution, but it is not. Old width: " + oldFrameWidth
+ ". New width: " + newFrameWidth);
}
}
protected void waitUntilSubscriberBytesReceivedIncrease(OpenViduTestappUser user, WebElement videoElement,
final long previousBytesReceived) {
this.waitUntilAux(user, videoElement, () -> {
return this.getSubscriberVideoBytesReceived(user, videoElement) > previousBytesReceived;
}, "Timeout waiting for subscriber track to increase its bytesReceived from " + previousBytesReceived);
}
// A subscriber video is only properly received AND played if its decoder keeps
// producing new frames at a sustained rate. Receiving bytes is not enough: a
// subscriber may receive media that it is not able to decode at all. And a
// single new decoded frame is not enough either: a video that only decodes one
// or two frames over a timespan of several seconds is a frozen video, not a
// playing one, and must fail the test. So framesDecoded is required to grow at
// MIN_FRAMES_DECODED_FPS or more, averaged over a window of at least
// MIN_FRAMES_DECODED_WINDOW_MILLIS
protected void waitUntilSubscriberFramesDecodedIncrease(OpenViduTestappUser user, WebElement videoElement) {
final long initialFramesDecoded = this.getSubscriberVideoFramesDecoded(user, videoElement);
final long initialFramesReceived = this.getSubscriberVideoFramesReceived(user, videoElement);
final long windowStart = System.currentTimeMillis();
// Last sample taken by the loop, only to report it if the wait times out
final java.util.concurrent.atomic.AtomicLong lastFramesDecoded = new java.util.concurrent.atomic.AtomicLong();
final java.util.concurrent.atomic.AtomicLong lastFramesReceived = new java.util.concurrent.atomic.AtomicLong();
final java.util.concurrent.atomic.AtomicLong lastWindowMillis = new java.util.concurrent.atomic.AtomicLong();
this.waitUntilAux(user, videoElement, () -> {
// Both counters must come from the very same dialog update: sampling
// them one by one would double the cost of every iteration
JsonObject layer = this.getSubscriberVideoLayer(user, videoElement);
long framesDecoded = this.getLayerCounter(layer, "framesDecoded");
long framesReceived = this.getLayerCounter(layer, "framesReceived");
if (framesDecoded < 0 || framesReceived < 0) {
return false;
}
long windowMillis = System.currentTimeMillis() - windowStart;
lastFramesDecoded.set(framesDecoded - initialFramesDecoded);
lastFramesReceived.set(framesReceived - initialFramesReceived);
lastWindowMillis.set(windowMillis);
// The window keeps growing while waiting, so a video that decodes a
// frame every now and then falls further behind the required rate
// instead of eventually satisfying it
return windowMillis >= MIN_FRAMES_DECODED_WINDOW_MILLIS
&& lastFramesDecoded.get() * 1000 >= MIN_FRAMES_DECODED_FPS * windowMillis;
}, () -> {
long framesDecoded = lastFramesDecoded.get();
long framesReceived = lastFramesReceived.get();
long windowMillis = lastWindowMillis.get();
// framesReceived counts the frames the depacketizer assembled, before
// handing them to the decoder. Comparing it against framesDecoded tells
// apart three failures that otherwise all look like "no video"
String diagnosis;
if (framesReceived <= 0) {
diagnosis = "The subscriber is not receiving assembled frames at all:"
+ " the media is not reaching it";
} else if (framesDecoded <= 0) {
diagnosis = "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)";
} else {
diagnosis = "The subscriber received " + framesReceived + " assembled frame(s) and decoded "
+ framesDecoded + " of them, but too slowly for a video that is actually playing";
}
return "Timeout waiting for subscriber track to decode video at a sustained frame rate: only "
+ framesDecoded + " frame(s) decoded in " + windowMillis + " ms ("
+ String.format("%.2f", framesDecoded * 1000d / Math.max(1, windowMillis))
+ " fps), while at least " + MIN_FRAMES_DECODED_FPS
+ " fps are required. Such a subscriber video is a frozen video. " + diagnosis;
});
}
protected void waitUntilPublisherBytesSentIncrease(OpenViduTestappUser user, WebElement videoElement, String rid,
final long previousBytesSent) {
this.waitUntilAux(user, videoElement, () -> {
return this.getPublisherVideoLayerAttribute(user, videoElement, rid, "bytesSent")
.getAsLong() > previousBytesSent;
}, "Timeout waiting for publisher track to increase its bytesSent from " + previousBytesSent);
}
protected void waitUntilPublisherFramesEncodedIncrease(OpenViduTestappUser user, WebElement videoElement,
String rid,
final long previousFramesEncoded) {
this.waitUntilAux(user, videoElement, () -> {
return this.getPublisherVideoLayerAttribute(user, videoElement, rid, "framesEncoded")
.getAsLong() > previousFramesEncoded;
}, "Timeout waiting for publisher track to increase its framesEncoded from " + previousFramesEncoded);
}
protected void waitUntilPublisherLayerActive(OpenViduTestappUser user, final WebElement publisherVideo,
final String rid, final boolean active) {
this.waitUntilAux(user, publisherVideo, () -> {
boolean currentlyActive = this.getPublisherVideoLayerAttribute(user, publisherVideo, rid, "active")
.getAsBoolean();
if (active) {
JsonElement frameWidth = this.getPublisherVideoLayerAttribute(user, publisherVideo, rid, "frameWidth");
return currentlyActive && frameWidth != null;
} else {
return !currentlyActive;
}
}, "Timeout waiting for video track layer to be " + (active ? "active" : "inactive"));
}
protected void waitUntilAux(OpenViduTestappUser user, WebElement videoElement,
Callable<Boolean> breakFromLoopFunction, String errMsg) {
this.waitUntilAux(user, videoElement, breakFromLoopFunction, () -> errMsg);
}
// Same as above, but building the error message only if the wait times out, so
// that it can report the values actually observed by the last iteration
protected void waitUntilAux(OpenViduTestappUser user, WebElement videoElement,
Callable<Boolean> breakFromLoopFunction, java.util.function.Supplier<String> errMsg) {
try {
final long intervalWait = 250;
final long deadline = System.currentTimeMillis() + WAIT_UNTIL_MAX_MILLIS;
boolean breakFromLoop = false;
while (!breakFromLoop && System.currentTimeMillis() < deadline) {
try {
breakFromLoop = breakFromLoopFunction.call();
} catch (Exception e1) {
e1.printStackTrace();
}
if (breakFromLoop) {
break;
} else {
try {
Thread.sleep(intervalWait);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
if (!breakFromLoop) {
Assertions.fail(errMsg.get());
}
} finally {
// Best-effort close of the info dialog
try {
if (!user.getDriver().findElements(By.cssSelector("#close-dialog-btn")).isEmpty()) {
this.waitForBackdropAndClick(user, "#close-dialog-btn");
Thread.sleep(500);
}
} catch (Exception e) {
log.warn("Best-effort info-dialog close failed (ignored): {}", e.getMessage());
}
}
}
protected void openInfoDialog(OpenViduTestappUser user, WebElement video) {
String videoId = video.getDomProperty("id");
// Open the track info dialog if required
boolean dialogWasOpened;
if (!user.getDriver().findElements(By.cssSelector("app-info-dialog")).isEmpty()) {
// Dialog already opened
if (!user.getDriver().findElement(By.cssSelector("#subtitle")).getText().equals(videoId)) {
// Wrong dialog
this.waitForBackdropAndClick(user, "#close-dialog-btn");
this.waitForBackdropAndClick(user, "#" + videoId + " ~ .bottom-div .video-track-info");
dialogWasOpened = true;
} else {
dialogWasOpened = false;
}
} else {
// Dialog is not opened
this.waitForBackdropAndClick(user, "#" + videoId + " ~ .bottom-div .video-track-info");
dialogWasOpened = true;
}
if (dialogWasOpened) {
try {
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
protected void addPublisherSubscriber(OpenViduTestappUser user, boolean hasAudio, boolean hasVideo)
throws InterruptedException {
this.addPublisher(user, true, true, true, true, hasAudio, hasVideo, null, null, null);
}
protected void addOnlyPublisherVideo(OpenViduTestappUser user, boolean simulcast, boolean dynacast, boolean hd)
throws InterruptedException {
if (hd) {
this.addPublisher(user, false, simulcast, dynacast, false, false, true, 1920, 1080, null);
} else {
this.addPublisher(user, false, simulcast, dynacast, false, false, true, null, null, null);
}
}
protected void addOnlyPublisherVideo(OpenViduTestappUser user, boolean simulcast, boolean dynacast, boolean hd,
String scalabilityMode)
throws InterruptedException {
if (hd) {
this.addPublisher(user, false, simulcast, dynacast, false, false, true, 1920, 1080, scalabilityMode);
} else {
this.addPublisher(user, false, simulcast, dynacast, false, false, true, null, null, null);
}
}
protected void addOnlyPublisherAudio(OpenViduTestappUser user) throws InterruptedException {
this.addPublisher(user, false, false, false, false, true, false, null, null, null);
}
protected void addPublisher(OpenViduTestappUser user, boolean isSubscriber, boolean simulcast, boolean dynacast,
boolean adaptiveStream, boolean hasAudio, boolean hasVideo, Integer width, Integer height,
String scalabilityMode) throws InterruptedException {
if (!user.getDriver().findElements(By.id("close-dialog-btn")).isEmpty()) {
user.getDriver().findElement(By.id("close-dialog-btn")).click();
Thread.sleep(300);
}
final int previousInstances = user.getDriver().findElements(By.cssSelector("app-openvidu-instance")).size();
user.getDriver().findElement(By.id("add-user-btn")).click();
// The new instance is rendered asynchronously: counting the instances right
// after the click can still see only the previous ones, and then every
// "#openvidu-instance-<index>" selector built from that count is off by one
// (with a single instance it even becomes "#openvidu-instance--1")
user.getWaiter().until(ExpectedConditions.numberOfElementsToBe(By.cssSelector("app-openvidu-instance"),
previousInstances + 1));
int numberOfUser = previousInstances;
if (!isSubscriber) {
user.getDriver().findElement(By.cssSelector("#openvidu-instance-" + numberOfUser + " .subscriber-checkbox"))
.click();
}
this.waitForBackdropAndClick(user, "#room-options-btn-" + numberOfUser);
Thread.sleep(300);
if (!hasAudio) {
user.getDriver().findElement(By.id("audio-capture-false")).click();
} else {
user.getDriver().findElement(By.id("audio-capture-true")).click();
}
if (!hasVideo) {
user.getDriver().findElement(By.id("video-capture-false")).click();
} else {
user.getDriver().findElement(By.id("video-capture-true")).click();
if (width != null || height != null || scalabilityMode != null) {
this.setPublisherCustomVideoProperties(user, width, height, scalabilityMode);
}
}
if (!simulcast) {
user.getDriver().findElement(By.id("trackPublish-simulcast")).click();
}
if (!dynacast) {
user.getDriver().findElement(By.id("room-dynacast")).click();
}
if (!adaptiveStream) {
user.getDriver().findElement(By.id("room-adaptiveStream")).click();
}
user.getDriver().findElement(By.id("close-dialog-btn")).click();
Thread.sleep(300);
}
protected void addSubscriber(OpenViduTestappUser user, boolean adaptiveStream) throws InterruptedException {
if (!user.getDriver().findElements(By.id("close-dialog-btn")).isEmpty()) {
user.getDriver().findElement(By.id("close-dialog-btn")).click();
Thread.sleep(300);
}
final int previousInstances = user.getDriver().findElements(By.cssSelector("app-openvidu-instance")).size();
user.getDriver().findElement(By.id("add-user-btn")).click();
// The new instance is rendered asynchronously: counting the instances right
// after the click can still see only the previous ones, and then every
// "#openvidu-instance-<index>" selector built from that count is off by one
// (with a single instance it even becomes "#openvidu-instance--1")
user.getWaiter().until(ExpectedConditions.numberOfElementsToBe(By.cssSelector("app-openvidu-instance"),
previousInstances + 1));
int numberOfUser = previousInstances;
user.getDriver().findElement(By.cssSelector("#openvidu-instance-" + numberOfUser + " .publisher-checkbox"))
.click();
if (!adaptiveStream) {
this.waitForBackdropAndClick(user, "#room-options-btn-" + numberOfUser);
this.waitForBackdropAndClick(user, "#room-adaptiveStream");
user.getDriver().findElement(By.id("close-dialog-btn")).click();
Thread.sleep(300);
}
}
protected void createIngress(OpenViduTestappUser user, String preset, String codec, boolean simulcast,
String urlType,
String urlUri) throws InterruptedException {
if (!user.getDriver().findElements(By.id("close-dialog-btn")).isEmpty()) {
this.waitForBackdropAndClick(user, "#close-dialog-btn");
Thread.sleep(300);
}
user.getDriver().findElement(By.xpath("//button[contains(@title,'Room API')]")).click();
if (preset != null) {
this.waitForBackdropAndClick(user, "#ingress-preset-select");
this.waitForBackdropAndClick(user, "#mat-option-" + preset.toUpperCase());
} else {
if (!simulcast) {
this.waitForBackdropAndClick(user, "#ingress-simulcast");
Thread.sleep(300);
}
this.waitForBackdropAndClick(user, "#ingress-video-codec-select");
this.waitForBackdropAndClick(user, "#mat-option-" + codec.toUpperCase());
}
if (urlType != null) {
this.waitForBackdropAndClick(user, "#ingress-url-type-select");
this.waitForBackdropAndClick(user, "#mat-option-" + urlType.toUpperCase());
}
if (urlUri != null) {
user.getDriver().findElement(By.cssSelector("#ingress-url-uri-field")).sendKeys(urlUri);
Thread.sleep(300);
}
this.waitForBackdropAndClick(user, "#create-ingress-api-btn");
this.waitForBackdropAndClick(user, "#close-dialog-btn");
Thread.sleep(300);
}
protected void setPublisherSimulcastLayersAndResolution(OpenViduTestappUser user, int numberOfUser,
String simulcastLayerName, Integer width, Integer height) throws InterruptedException {
this.waitForBackdropAndClick(user, "#room-options-btn-" + numberOfUser);
Thread.sleep(300);
this.setPublisherCustomVideoProperties(user, width, height, null);
user.getDriver().findElement(By.id("trackPublish-videoSimulcastLayers")).click();
this.waitForBackdropAndClick(user, "#mat-option-" + simulcastLayerName);
new org.openqa.selenium.interactions.Actions(user.getDriver())
.sendKeys(org.openqa.selenium.Keys.ESCAPE).perform();
Thread.sleep(300);
this.waitForBackdropAndClick(user, "#close-dialog-btn");
Thread.sleep(300);
}
protected void setPublisherCustomVideoProperties(OpenViduTestappUser user, Integer width, Integer height,
String scalabilityMode) {
user.getDriver().findElement(By.id("video-capture-custom")).click();
if (width != null) {
WebElement trackWidth = user.getDriver().findElement(By.id("resolution-video-capture-options-width"));
trackWidth.clear();
trackWidth.sendKeys(width.toString());
}
if (height != null) {
WebElement trackHeight = user.getDriver().findElement(By.id("resolution-video-capture-options-height"));
trackHeight.clear();
trackHeight.sendKeys(height.toString());
}
if (scalabilityMode != null) {
user.getDriver().findElement(By.id("trackPublish-scalabilityMode")).click();
this.waitForBackdropAndClick(user, ".mode-" + scalabilityMode);
}
}
/**
* Waits for any Material Design backdrop overlays to disappear and then clicks
* the element. This prevents ElementClickInterceptedException caused by overlay
* backdrops.
*/
protected void waitForBackdropAndClick(OpenViduTestappUser user, String cssSelector) {
final long startTime = System.currentTimeMillis();
final long timeoutMillis = 10000; // 10 seconds total timeout
final long retryIntervalMillis = 500; // 500ms between retries
WebElement element = null;
while (System.currentTimeMillis() - startTime < timeoutMillis) {
try {
// Try to find and click the element immediately
element = user.getDriver().findElement(By.cssSelector(cssSelector));
if (element.isDisplayed() && element.isEnabled()) {
element.click();
return; // Success! Exit the method
}
} catch (org.openqa.selenium.ElementClickInterceptedException e) {
// Element is being intercepted by overlay, continue retrying
} catch (org.openqa.selenium.NoSuchElementException e) {
// Element not found, wait a bit and retry
} catch (org.openqa.selenium.StaleElementReferenceException e) {
// Element reference is stale, retry with fresh element
} catch (Exception e) {
// Any other exception, continue retrying
}
// Wait before next retry
try {
Thread.sleep(retryIntervalMillis);
} catch (InterruptedException e) {
// Print screenshot
String screenshot = "data:image/png;base64,"
+ ((TakesScreenshot) user.getDriver()).getScreenshotAs(BASE64);
System.out.println("INTERRUPTED EXCEPTION WHILE WAITING FOR ELEMENT TO BE CLICKABLE: " + cssSelector);
System.out.println(screenshot);
Thread.currentThread().interrupt();
throw new RuntimeException("Thread interrupted while waiting for backdrop to clear", e);
}
}
String screenshot = "data:image/png;base64," + ((TakesScreenshot) user.getDriver()).getScreenshotAs(BASE64);
System.out.println("TIMEOUT WAITING FOR ELEMENT TO BE CLICKABLE (): " + cssSelector);
System.out.println(screenshot);
// If we get here, we've timed out
throw new RuntimeException("Timeout waiting for element '" + cssSelector
+ "' to be clickable without backdrop interference after " + timeoutMillis + "ms");
}
protected boolean assertAllElementsHaveTracks(OpenViduTestappUser user, String selector, boolean hasAudio,
boolean hasVideo) {
org.openqa.selenium.JavascriptExecutor js = (org.openqa.selenium.JavascriptExecutor) user.getDriver();
String script = "var elements = document.querySelectorAll(arguments[0]);" +
"for (var i = 0; i < elements.length; i++) {" +
" var el = elements[i];" +
" if (!el.srcObject) return false;" +
" if (arguments[1] && el.srcObject.getAudioTracks().length === 0) return false;" +
" if (!arguments[1] && el.srcObject.getAudioTracks().length > 0) return false;" +
" if (arguments[2] && el.srcObject.getVideoTracks().length === 0) return false;" +
" if (!arguments[2] && el.srcObject.getVideoTracks().length > 0) return false;" +
"}" +
"return true;";
return (Boolean) js.executeScript(script, selector, hasAudio, hasVideo);
}
protected void changeElementSize(OpenViduTestappUser user, org.openqa.selenium.WebElement element, int width,
int height) {
org.openqa.selenium.JavascriptExecutor js = (org.openqa.selenium.JavascriptExecutor) user.getDriver();
js.executeScript(
"arguments[0].style.width = '" + width + "px'; arguments[0].style.height = '" + height + "px';",
element);
}
/**
* 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.
*/
protected 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");
}
} }

View File

@ -0,0 +1,357 @@
package io.openvidu.test.e2e;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Stream;
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.DisplayName;
import org.junit.jupiter.api.Tag;
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.openqa.selenium.By;
import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.Keys;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import com.google.gson.JsonArray;
import com.google.gson.JsonParser;
import livekit.LivekitModels.ParticipantInfo;
import livekit.LivekitModels.TrackInfo;
import livekit.LivekitModels.TrackType;
import livekit.LivekitModels.VideoLayer;
/**
* E2E tests of the LiveKit server RTC SDK publishers (go, node, python, rust,
* dotnet) against browser subscribers.
*
* Split out of {@link OpenViduTestAppE2eTest} because the full matrix is slow:
* this way it can be run on its own with
* {@code mvn -Dtest=OpenViduTestAppE2eServerSdkTest test}, and the system
* properties {@code sdk.codecs} / {@code sdk.layers} narrow it further.
*
* @author Pablo Fuente (pablofuenteperez@gmail.com)
*/
@Tag("e2e")
@DisplayName("E2E tests for OpenVidu TestApp: server SDK publishers")
@ExtendWith(SpringExtension.class)
public class OpenViduTestAppE2eServerSdkTest extends AbstractOpenViduTestappE2eTest {
@BeforeAll()
protected static void setupAll() throws Exception {
loadEnvironmentVariables();
setUpLiveKitClient();
CompletableFuture.runAsync(OpenViduTestAppE2eServerSdkTest::pullRemoteBrowserImages);
}
@BeforeEach()
protected void setupEach() {
this.closeAllRooms(LK);
}
@AfterEach()
protected void finishEach() {
this.closeAllRooms(LK);
}
// 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<Arguments> serverSdkPublisherMatrix() {
List<String> layersFilter = List.of(System.getProperty("sdk.layers", "single,multi").split(","));
List<String> 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<OpenViduTestappUser> 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", "");
}
/**
* 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<TrackInfo> 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();
}
}

View File

@ -27,8 +27,6 @@ import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
import java.util.Map; import java.util.Map;
import java.util.Map.Entry; import java.util.Map.Entry;
import java.util.concurrent.Callable; import java.util.concurrent.Callable;
@ -45,7 +43,6 @@ import org.apache.commons.lang3.tuple.ImmutablePair;
import org.apache.commons.lang3.tuple.Pair; import org.apache.commons.lang3.tuple.Pair;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Disabled;
@ -53,14 +50,9 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; 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.By;
import org.openqa.selenium.JavascriptExecutor; import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.Keys; import org.openqa.selenium.Keys;
import org.openqa.selenium.TakesScreenshot;
import org.openqa.selenium.WebElement; import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.ui.ExpectedConditions; import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait; import org.openqa.selenium.support.ui.WebDriverWait;
@ -86,12 +78,6 @@ import io.minio.messages.Item;
import livekit.LivekitIngress.IngressInfo; import livekit.LivekitIngress.IngressInfo;
import livekit.LivekitIngress.IngressState; import livekit.LivekitIngress.IngressState;
import livekit.LivekitModels.ConnectionQuality; 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. * E2E tests for openvidu-testapp.
@ -104,14 +90,6 @@ import static org.openqa.selenium.OutputType.BASE64;
@ExtendWith(SpringExtension.class) @ExtendWith(SpringExtension.class)
public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest { public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
private static final long WAIT_UNTIL_MAX_MILLIS = 20000;
// Minimum average frame rate that a subscriber video must sustain over a
// window of at least MIN_FRAMES_DECODED_WINDOW_MILLIS to be considered
// properly decoded and played
private static final long MIN_FRAMES_DECODED_FPS = 4;
private static final long MIN_FRAMES_DECODED_WINDOW_MILLIS = 2000;
@BeforeAll() @BeforeAll()
protected static void setupAll() throws Exception { protected static void setupAll() throws Exception {
checkFfmpegInstallation(); checkFfmpegInstallation();
@ -127,24 +105,6 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
CompletableFuture.runAsync(OpenViduTestAppE2eTest::pullRemoteBrowserImages); 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() @BeforeEach()
protected void setupEach() { protected void setupEach() {
this.closeAllRooms(LK); this.closeAllRooms(LK);
@ -4031,314 +3991,6 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
testNoSimulcast(user, subscriberVideo); 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<Arguments> serverSdkPublisherMatrix() {
List<String> layersFilter = List.of(System.getProperty("sdk.layers", "single,multi").split(","));
List<String> 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<OpenViduTestappUser> 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<TrackInfo> 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 @Test
@DisplayName("RTSP ingress H264 + OPUS") @DisplayName("RTSP ingress H264 + OPUS")
void rtspIngressH264_OPUSTest() throws Exception { void rtspIngressH264_OPUSTest() throws Exception {
@ -4838,565 +4490,4 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, 1920); this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, 1920);
} }
private int countNumberOfPublishedLayers(OpenViduTestappUser user, WebElement publisherVideo) {
JsonArray json = this.getLayersAsJsonArray(user, publisherVideo);
return json.size();
}
private int getSubscriberVideoFrameWidth(OpenViduTestappUser user, WebElement subscriberVideo) {
return getSubscriberVideoLayerStat(user, subscriberVideo, "frameWidth", JsonElement::getAsInt);
}
private int getSubscriberVideoFrameHeight(OpenViduTestappUser user, WebElement subscriberVideo) {
return getSubscriberVideoLayerStat(user, subscriberVideo, "frameHeight", JsonElement::getAsInt);
}
private long getSubscriberVideoBytesReceived(OpenViduTestappUser user, WebElement subscriberVideo) {
return getSubscriberVideoLayerStat(user, subscriberVideo, "bytesReceived", JsonElement::getAsLong);
}
private int getSubscriberVideoFramesPerSecond(OpenViduTestappUser user, WebElement subscriberVideo) {
return getSubscriberVideoLayerStat(user, subscriberVideo, "framesPerSecond", JsonElement::getAsInt);
}
private long getSubscriberVideoFramesDecoded(OpenViduTestappUser user, WebElement subscriberVideo) {
return getSubscriberVideoLayerStat(user, subscriberVideo, "framesDecoded", JsonElement::getAsLong);
}
private long getSubscriberVideoFramesReceived(OpenViduTestappUser user, WebElement subscriberVideo) {
return getSubscriberVideoLayerStat(user, subscriberVideo, "framesReceived", JsonElement::getAsLong);
}
private String getSubscriberVideoCodec(OpenViduTestappUser user, WebElement subscriberVideo) {
return getSubscriberVideoLayerStat(user, subscriberVideo, "codec", JsonElement::getAsString);
}
private <T> T getSubscriberVideoLayerStat(OpenViduTestappUser user, WebElement subscriberVideo, String field,
java.util.function.Function<JsonElement, T> extractor) {
final long deadline = System.currentTimeMillis() + WAIT_UNTIL_MAX_MILLIS;
JsonElement element = null;
do {
try {
element = getLayersAsJsonArray(user, subscriberVideo).get(0).getAsJsonObject().get(field);
} catch (Exception e) {
element = null;
}
if (element == null || element.isJsonNull()) {
try {
Thread.sleep(250);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
} while ((element == null || element.isJsonNull()) && System.currentTimeMillis() < deadline);
if (element == null || element.isJsonNull()) {
Assertions.fail("Timeout waiting for " + field + " to exist");
}
return extractor.apply(element);
}
// Several stats of the same subscriber layer, taken from a single info dialog
// update. Sampling them one by one through getSubscriberVideoLayerStat would pay
// a full dialog read, and a stats refresh, for each one of them
private JsonObject getSubscriberVideoLayer(OpenViduTestappUser user, WebElement subscriberVideo) {
JsonArray layers = this.getLayersAsJsonArray(user, subscriberVideo);
return layers.isEmpty() ? new JsonObject() : layers.get(0).getAsJsonObject();
}
// Cumulative counter of the given layer, or -1 if it is not there
private long getLayerCounter(JsonObject layer, String field) {
JsonElement element = layer.get(field);
return element == null || element.isJsonNull() ? -1 : element.getAsLong();
}
// If rid is null, retrieve the first layer
private JsonElement getPublisherVideoLayerAttribute(OpenViduTestappUser user, WebElement publisherVideo, String rid,
String attribute) {
JsonArray json = this.getLayersAsJsonArray(user, publisherVideo);
JsonElement result;
if (rid != null) {
result = json.asList().stream().parallel()
.filter(jsonElement -> rid.equals(jsonElement.getAsJsonObject().get("rid").getAsString())).findAny()
.get();
} else {
result = json.get(0);
}
return result.getAsJsonObject().get(attribute);
}
private String getLayersAsString(OpenViduTestappUser user, WebElement video) {
this.openInfoDialog(user, video);
user.getDriver().findElement(By.cssSelector("#update-value-btn")).click();
WebElement textarea = user.getDriver().findElement(By.id("info-text-area"));
return textarea.getAttribute("value");
}
private JsonArray getLayersAsJsonArray(OpenViduTestappUser user, WebElement video) {
String value = getLayersAsString(user, video);
return JsonParser.parseString(value).getAsJsonArray();
}
private void waitUntilVideoLayersNotEmpty(OpenViduTestappUser user, WebElement videoElement) {
this.waitUntilAux(user, videoElement, () -> {
String value = getLayersAsString(user, videoElement);
return !value.isBlank() && !JsonParser.parseString(value).getAsJsonArray().isEmpty();
}, "Timeout waiting video layers to not be empty");
}
private void waitUntilSubscriberFramesPerSecondNotZero(OpenViduTestappUser user, WebElement videoElement) {
this.waitUntilAux(user, videoElement, () -> {
return this.getSubscriberVideoFramesPerSecond(user, videoElement) > 0;
}, "Timeout waiting for video track to have a framesPerSecond greater than 0");
}
private void waitUntilSubscriberFramesPerSecondIs(OpenViduTestappUser user, WebElement videoElement, int fps) {
this.waitUntilAux(user, videoElement, () -> {
return this.getSubscriberVideoFramesPerSecond(user, videoElement) == fps;
}, "Timeout waiting for video track to have a framesPerSecond equal to " + fps);
}
private void waitUntilSubscriberFrameWidthIs(OpenViduTestappUser user, WebElement videoElement,
final int expectedFrameWidth) {
this.waitUntilAux(user, videoElement, () -> {
return this.getSubscriberVideoFrameWidth(user, videoElement) == expectedFrameWidth;
}, "Timeout waiting for video track to have a frameWidth of " + expectedFrameWidth);
}
private void waitUntilSubscriberFrameHeightIs(OpenViduTestappUser user, WebElement videoElement,
final int expectedFrameHeight) {
this.waitUntilAux(user, videoElement, () -> {
return this.getSubscriberVideoFrameHeight(user, videoElement) == expectedFrameHeight;
}, "Timeout waiting for video track to have a frameHeight of " + expectedFrameHeight);
}
private void waitUntilSubscriberFrameWidthChanges(OpenViduTestappUser user, WebElement videoElement,
final int oldFrameWidth, final boolean shouldBeHigher) {
this.waitUntilAux(user, videoElement, () -> {
return this.getSubscriberVideoFrameWidth(user, videoElement) != oldFrameWidth;
}, "Timeout waiting for video track to reach a " + (shouldBeHigher ? "higher" : "lower") + " resolution");
int newFrameWidth = this.getSubscriberVideoFrameWidth(user, videoElement);
if (shouldBeHigher) {
Assertions.assertTrue(newFrameWidth > oldFrameWidth,
"Video track should have now a higher resolution, but it is not. Old width: " + oldFrameWidth
+ ". New width: " + newFrameWidth);
} else {
Assertions.assertTrue(newFrameWidth < oldFrameWidth,
"Video track should have now a lower resolution, but it is not. Old width: " + oldFrameWidth
+ ". New width: " + newFrameWidth);
}
}
private void waitUntilSubscriberBytesReceivedIncrease(OpenViduTestappUser user, WebElement videoElement,
final long previousBytesReceived) {
this.waitUntilAux(user, videoElement, () -> {
return this.getSubscriberVideoBytesReceived(user, videoElement) > previousBytesReceived;
}, "Timeout waiting for subscriber track to increase its bytesReceived from " + previousBytesReceived);
}
// A subscriber video is only properly received AND played if its decoder keeps
// producing new frames at a sustained rate. Receiving bytes is not enough: a
// subscriber may receive media that it is not able to decode at all. And a
// single new decoded frame is not enough either: a video that only decodes one
// or two frames over a timespan of several seconds is a frozen video, not a
// playing one, and must fail the test. So framesDecoded is required to grow at
// MIN_FRAMES_DECODED_FPS or more, averaged over a window of at least
// MIN_FRAMES_DECODED_WINDOW_MILLIS
private void waitUntilSubscriberFramesDecodedIncrease(OpenViduTestappUser user, WebElement videoElement) {
final long initialFramesDecoded = this.getSubscriberVideoFramesDecoded(user, videoElement);
final long initialFramesReceived = this.getSubscriberVideoFramesReceived(user, videoElement);
final long windowStart = System.currentTimeMillis();
// Last sample taken by the loop, only to report it if the wait times out
final java.util.concurrent.atomic.AtomicLong lastFramesDecoded = new java.util.concurrent.atomic.AtomicLong();
final java.util.concurrent.atomic.AtomicLong lastFramesReceived = new java.util.concurrent.atomic.AtomicLong();
final java.util.concurrent.atomic.AtomicLong lastWindowMillis = new java.util.concurrent.atomic.AtomicLong();
this.waitUntilAux(user, videoElement, () -> {
// Both counters must come from the very same dialog update: sampling
// them one by one would double the cost of every iteration
JsonObject layer = this.getSubscriberVideoLayer(user, videoElement);
long framesDecoded = this.getLayerCounter(layer, "framesDecoded");
long framesReceived = this.getLayerCounter(layer, "framesReceived");
if (framesDecoded < 0 || framesReceived < 0) {
return false;
}
long windowMillis = System.currentTimeMillis() - windowStart;
lastFramesDecoded.set(framesDecoded - initialFramesDecoded);
lastFramesReceived.set(framesReceived - initialFramesReceived);
lastWindowMillis.set(windowMillis);
// The window keeps growing while waiting, so a video that decodes a
// frame every now and then falls further behind the required rate
// instead of eventually satisfying it
return windowMillis >= MIN_FRAMES_DECODED_WINDOW_MILLIS
&& lastFramesDecoded.get() * 1000 >= MIN_FRAMES_DECODED_FPS * windowMillis;
}, () -> {
long framesDecoded = lastFramesDecoded.get();
long framesReceived = lastFramesReceived.get();
long windowMillis = lastWindowMillis.get();
// framesReceived counts the frames the depacketizer assembled, before
// handing them to the decoder. Comparing it against framesDecoded tells
// apart three failures that otherwise all look like "no video"
String diagnosis;
if (framesReceived <= 0) {
diagnosis = "The subscriber is not receiving assembled frames at all:"
+ " the media is not reaching it";
} else if (framesDecoded <= 0) {
diagnosis = "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)";
} else {
diagnosis = "The subscriber received " + framesReceived + " assembled frame(s) and decoded "
+ framesDecoded + " of them, but too slowly for a video that is actually playing";
}
return "Timeout waiting for subscriber track to decode video at a sustained frame rate: only "
+ framesDecoded + " frame(s) decoded in " + windowMillis + " ms ("
+ String.format("%.2f", framesDecoded * 1000d / Math.max(1, windowMillis))
+ " fps), while at least " + MIN_FRAMES_DECODED_FPS
+ " fps are required. Such a subscriber video is a frozen video. " + diagnosis;
});
}
private void waitUntilPublisherBytesSentIncrease(OpenViduTestappUser user, WebElement videoElement, String rid,
final long previousBytesSent) {
this.waitUntilAux(user, videoElement, () -> {
return this.getPublisherVideoLayerAttribute(user, videoElement, rid, "bytesSent")
.getAsLong() > previousBytesSent;
}, "Timeout waiting for publisher track to increase its bytesSent from " + previousBytesSent);
}
private void waitUntilPublisherFramesEncodedIncrease(OpenViduTestappUser user, WebElement videoElement, String rid,
final long previousFramesEncoded) {
this.waitUntilAux(user, videoElement, () -> {
return this.getPublisherVideoLayerAttribute(user, videoElement, rid, "framesEncoded")
.getAsLong() > previousFramesEncoded;
}, "Timeout waiting for publisher track to increase its framesEncoded from " + previousFramesEncoded);
}
private void waitUntilPublisherLayerActive(OpenViduTestappUser user, final WebElement publisherVideo,
final String rid, final boolean active) {
this.waitUntilAux(user, publisherVideo, () -> {
boolean currentlyActive = this.getPublisherVideoLayerAttribute(user, publisherVideo, rid, "active")
.getAsBoolean();
if (active) {
JsonElement frameWidth = this.getPublisherVideoLayerAttribute(user, publisherVideo, rid, "frameWidth");
return currentlyActive && frameWidth != null;
} else {
return !currentlyActive;
}
}, "Timeout waiting for video track layer to be " + (active ? "active" : "inactive"));
}
private void waitUntilAux(OpenViduTestappUser user, WebElement videoElement,
Callable<Boolean> breakFromLoopFunction, String errMsg) {
this.waitUntilAux(user, videoElement, breakFromLoopFunction, () -> errMsg);
}
// Same as above, but building the error message only if the wait times out, so
// that it can report the values actually observed by the last iteration
private void waitUntilAux(OpenViduTestappUser user, WebElement videoElement,
Callable<Boolean> breakFromLoopFunction, java.util.function.Supplier<String> errMsg) {
try {
final long intervalWait = 250;
final long deadline = System.currentTimeMillis() + WAIT_UNTIL_MAX_MILLIS;
boolean breakFromLoop = false;
while (!breakFromLoop && System.currentTimeMillis() < deadline) {
try {
breakFromLoop = breakFromLoopFunction.call();
} catch (Exception e1) {
e1.printStackTrace();
}
if (breakFromLoop) {
break;
} else {
try {
Thread.sleep(intervalWait);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
if (!breakFromLoop) {
Assertions.fail(errMsg.get());
}
} finally {
// Best-effort close of the info dialog
try {
if (!user.getDriver().findElements(By.cssSelector("#close-dialog-btn")).isEmpty()) {
this.waitForBackdropAndClick(user, "#close-dialog-btn");
Thread.sleep(500);
}
} catch (Exception e) {
log.warn("Best-effort info-dialog close failed (ignored): {}", e.getMessage());
}
}
}
private void openInfoDialog(OpenViduTestappUser user, WebElement video) {
String videoId = video.getDomProperty("id");
// Open the track info dialog if required
boolean dialogWasOpened;
if (!user.getDriver().findElements(By.cssSelector("app-info-dialog")).isEmpty()) {
// Dialog already opened
if (!user.getDriver().findElement(By.cssSelector("#subtitle")).getText().equals(videoId)) {
// Wrong dialog
this.waitForBackdropAndClick(user, "#close-dialog-btn");
this.waitForBackdropAndClick(user, "#" + videoId + " ~ .bottom-div .video-track-info");
dialogWasOpened = true;
} else {
dialogWasOpened = false;
}
} else {
// Dialog is not opened
this.waitForBackdropAndClick(user, "#" + videoId + " ~ .bottom-div .video-track-info");
dialogWasOpened = true;
}
if (dialogWasOpened) {
try {
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
private void addPublisherSubscriber(OpenViduTestappUser user, boolean hasAudio, boolean hasVideo)
throws InterruptedException {
this.addPublisher(user, true, true, true, true, hasAudio, hasVideo, null, null, null);
}
private void addOnlyPublisherVideo(OpenViduTestappUser user, boolean simulcast, boolean dynacast, boolean hd)
throws InterruptedException {
if (hd) {
this.addPublisher(user, false, simulcast, dynacast, false, false, true, 1920, 1080, null);
} else {
this.addPublisher(user, false, simulcast, dynacast, false, false, true, null, null, null);
}
}
private void addOnlyPublisherVideo(OpenViduTestappUser user, boolean simulcast, boolean dynacast, boolean hd,
String scalabilityMode)
throws InterruptedException {
if (hd) {
this.addPublisher(user, false, simulcast, dynacast, false, false, true, 1920, 1080, scalabilityMode);
} else {
this.addPublisher(user, false, simulcast, dynacast, false, false, true, null, null, null);
}
}
private void addOnlyPublisherAudio(OpenViduTestappUser user) throws InterruptedException {
this.addPublisher(user, false, false, false, false, true, false, null, null, null);
}
private void addPublisher(OpenViduTestappUser user, boolean isSubscriber, boolean simulcast, boolean dynacast,
boolean adaptiveStream, boolean hasAudio, boolean hasVideo, Integer width, Integer height,
String scalabilityMode) throws InterruptedException {
if (!user.getDriver().findElements(By.id("close-dialog-btn")).isEmpty()) {
user.getDriver().findElement(By.id("close-dialog-btn")).click();
Thread.sleep(300);
}
final int previousInstances = user.getDriver().findElements(By.cssSelector("app-openvidu-instance")).size();
user.getDriver().findElement(By.id("add-user-btn")).click();
// The new instance is rendered asynchronously: counting the instances right
// after the click can still see only the previous ones, and then every
// "#openvidu-instance-<index>" selector built from that count is off by one
// (with a single instance it even becomes "#openvidu-instance--1")
user.getWaiter().until(ExpectedConditions.numberOfElementsToBe(By.cssSelector("app-openvidu-instance"),
previousInstances + 1));
int numberOfUser = previousInstances;
if (!isSubscriber) {
user.getDriver().findElement(By.cssSelector("#openvidu-instance-" + numberOfUser + " .subscriber-checkbox"))
.click();
}
this.waitForBackdropAndClick(user, "#room-options-btn-" + numberOfUser);
Thread.sleep(300);
if (!hasAudio) {
user.getDriver().findElement(By.id("audio-capture-false")).click();
} else {
user.getDriver().findElement(By.id("audio-capture-true")).click();
}
if (!hasVideo) {
user.getDriver().findElement(By.id("video-capture-false")).click();
} else {
user.getDriver().findElement(By.id("video-capture-true")).click();
if (width != null || height != null || scalabilityMode != null) {
this.setPublisherCustomVideoProperties(user, width, height, scalabilityMode);
}
}
if (!simulcast) {
user.getDriver().findElement(By.id("trackPublish-simulcast")).click();
}
if (!dynacast) {
user.getDriver().findElement(By.id("room-dynacast")).click();
}
if (!adaptiveStream) {
user.getDriver().findElement(By.id("room-adaptiveStream")).click();
}
user.getDriver().findElement(By.id("close-dialog-btn")).click();
Thread.sleep(300);
}
private void addSubscriber(OpenViduTestappUser user, boolean adaptiveStream) throws InterruptedException {
if (!user.getDriver().findElements(By.id("close-dialog-btn")).isEmpty()) {
user.getDriver().findElement(By.id("close-dialog-btn")).click();
Thread.sleep(300);
}
final int previousInstances = user.getDriver().findElements(By.cssSelector("app-openvidu-instance")).size();
user.getDriver().findElement(By.id("add-user-btn")).click();
// The new instance is rendered asynchronously: counting the instances right
// after the click can still see only the previous ones, and then every
// "#openvidu-instance-<index>" selector built from that count is off by one
// (with a single instance it even becomes "#openvidu-instance--1")
user.getWaiter().until(ExpectedConditions.numberOfElementsToBe(By.cssSelector("app-openvidu-instance"),
previousInstances + 1));
int numberOfUser = previousInstances;
user.getDriver().findElement(By.cssSelector("#openvidu-instance-" + numberOfUser + " .publisher-checkbox"))
.click();
if (!adaptiveStream) {
this.waitForBackdropAndClick(user, "#room-options-btn-" + numberOfUser);
this.waitForBackdropAndClick(user, "#room-adaptiveStream");
user.getDriver().findElement(By.id("close-dialog-btn")).click();
Thread.sleep(300);
}
}
private void createIngress(OpenViduTestappUser user, String preset, String codec, boolean simulcast, String urlType,
String urlUri) throws InterruptedException {
if (!user.getDriver().findElements(By.id("close-dialog-btn")).isEmpty()) {
this.waitForBackdropAndClick(user, "#close-dialog-btn");
Thread.sleep(300);
}
user.getDriver().findElement(By.xpath("//button[contains(@title,'Room API')]")).click();
if (preset != null) {
this.waitForBackdropAndClick(user, "#ingress-preset-select");
this.waitForBackdropAndClick(user, "#mat-option-" + preset.toUpperCase());
} else {
if (!simulcast) {
this.waitForBackdropAndClick(user, "#ingress-simulcast");
Thread.sleep(300);
}
this.waitForBackdropAndClick(user, "#ingress-video-codec-select");
this.waitForBackdropAndClick(user, "#mat-option-" + codec.toUpperCase());
}
if (urlType != null) {
this.waitForBackdropAndClick(user, "#ingress-url-type-select");
this.waitForBackdropAndClick(user, "#mat-option-" + urlType.toUpperCase());
}
if (urlUri != null) {
user.getDriver().findElement(By.cssSelector("#ingress-url-uri-field")).sendKeys(urlUri);
Thread.sleep(300);
}
this.waitForBackdropAndClick(user, "#create-ingress-api-btn");
this.waitForBackdropAndClick(user, "#close-dialog-btn");
Thread.sleep(300);
}
private void setPublisherSimulcastLayersAndResolution(OpenViduTestappUser user, int numberOfUser,
String simulcastLayerName, Integer width, Integer height) throws InterruptedException {
this.waitForBackdropAndClick(user, "#room-options-btn-" + numberOfUser);
Thread.sleep(300);
this.setPublisherCustomVideoProperties(user, width, height, null);
user.getDriver().findElement(By.id("trackPublish-videoSimulcastLayers")).click();
this.waitForBackdropAndClick(user, "#mat-option-" + simulcastLayerName);
new org.openqa.selenium.interactions.Actions(user.getDriver())
.sendKeys(org.openqa.selenium.Keys.ESCAPE).perform();
Thread.sleep(300);
this.waitForBackdropAndClick(user, "#close-dialog-btn");
Thread.sleep(300);
}
private void setPublisherCustomVideoProperties(OpenViduTestappUser user, Integer width, Integer height,
String scalabilityMode) {
user.getDriver().findElement(By.id("video-capture-custom")).click();
if (width != null) {
WebElement trackWidth = user.getDriver().findElement(By.id("resolution-video-capture-options-width"));
trackWidth.clear();
trackWidth.sendKeys(width.toString());
}
if (height != null) {
WebElement trackHeight = user.getDriver().findElement(By.id("resolution-video-capture-options-height"));
trackHeight.clear();
trackHeight.sendKeys(height.toString());
}
if (scalabilityMode != null) {
user.getDriver().findElement(By.id("trackPublish-scalabilityMode")).click();
this.waitForBackdropAndClick(user, ".mode-" + scalabilityMode);
}
}
/**
* Waits for any Material Design backdrop overlays to disappear and then clicks
* the element. This prevents ElementClickInterceptedException caused by overlay
* backdrops.
*/
private void waitForBackdropAndClick(OpenViduTestappUser user, String cssSelector) {
final long startTime = System.currentTimeMillis();
final long timeoutMillis = 10000; // 10 seconds total timeout
final long retryIntervalMillis = 500; // 500ms between retries
WebElement element = null;
while (System.currentTimeMillis() - startTime < timeoutMillis) {
try {
// Try to find and click the element immediately
element = user.getDriver().findElement(By.cssSelector(cssSelector));
if (element.isDisplayed() && element.isEnabled()) {
element.click();
return; // Success! Exit the method
}
} catch (org.openqa.selenium.ElementClickInterceptedException e) {
// Element is being intercepted by overlay, continue retrying
} catch (org.openqa.selenium.NoSuchElementException e) {
// Element not found, wait a bit and retry
} catch (org.openqa.selenium.StaleElementReferenceException e) {
// Element reference is stale, retry with fresh element
} catch (Exception e) {
// Any other exception, continue retrying
}
// Wait before next retry
try {
Thread.sleep(retryIntervalMillis);
} catch (InterruptedException e) {
// Print screenshot
String screenshot = "data:image/png;base64,"
+ ((TakesScreenshot) user.getDriver()).getScreenshotAs(BASE64);
System.out.println("INTERRUPTED EXCEPTION WHILE WAITING FOR ELEMENT TO BE CLICKABLE: " + cssSelector);
System.out.println(screenshot);
Thread.currentThread().interrupt();
throw new RuntimeException("Thread interrupted while waiting for backdrop to clear", e);
}
}
String screenshot = "data:image/png;base64," + ((TakesScreenshot) user.getDriver()).getScreenshotAs(BASE64);
System.out.println("TIMEOUT WAITING FOR ELEMENT TO BE CLICKABLE (): " + cssSelector);
System.out.println(screenshot);
// If we get here, we've timed out
throw new RuntimeException("Timeout waiting for element '" + cssSelector
+ "' to be clickable without backdrop interference after " + timeoutMillis + "ms");
}
public boolean assertAllElementsHaveTracks(OpenViduTestappUser user, String selector, boolean hasAudio,
boolean hasVideo) {
org.openqa.selenium.JavascriptExecutor js = (org.openqa.selenium.JavascriptExecutor) user.getDriver();
String script = "var elements = document.querySelectorAll(arguments[0]);" +
"for (var i = 0; i < elements.length; i++) {" +
" var el = elements[i];" +
" if (!el.srcObject) return false;" +
" if (arguments[1] && el.srcObject.getAudioTracks().length === 0) return false;" +
" if (!arguments[1] && el.srcObject.getAudioTracks().length > 0) return false;" +
" if (arguments[2] && el.srcObject.getVideoTracks().length === 0) return false;" +
" if (!arguments[2] && el.srcObject.getVideoTracks().length > 0) return false;" +
"}" +
"return true;";
return (Boolean) js.executeScript(script, selector, hasAudio, hasVideo);
}
public void changeElementSize(OpenViduTestappUser user, org.openqa.selenium.WebElement element, int width,
int height) {
org.openqa.selenium.JavascriptExecutor js = (org.openqa.selenium.JavascriptExecutor) user.getDriver();
js.executeScript(
"arguments[0].style.width = '" + width + "px'; arguments[0].style.height = '" + height + "px';",
element);
}
} }