openvidu-test-e2e: OpenViduTestAppE2eEgressProTest holds every test that needs openvidu/egress-pro

pull/914/head
pabloFuente 2026-10-08 18:59:55 +02:00
parent 9413108885
commit 3fbf223b45
5 changed files with 4159 additions and 351 deletions

View File

@ -329,6 +329,28 @@ public class NetworkConditioner {
blockOutboundPackets(targetContainer, Protocol.TCP, mediaPortRange);
blockOutboundPackets(targetContainer, Protocol.TCP, SFU_ICE_TCP_PORT);
blockOutboundPackets(targetContainer, Protocol.UDP, SFU_TURN_UDP_PORT);
// No test may go on without the blackout it asked for. Checked once all the
// rules are in: a check after each one would make the first ones last longer.
// Split on the rule prefix: the command output comes with its lines joined
String[] rules = nettools(targetContainer, "iptables", "-S OUTPUT").split("-A OUTPUT");
List<String> missing = new ArrayList<>();
for (String drop : List.of("udp " + mediaPortRange, "tcp " + mediaPortRange, "tcp " + SFU_ICE_TCP_PORT,
"udp " + SFU_TURN_UDP_PORT)) {
String[] protoPorts = drop.split(" ");
String proto = "-p " + protoPorts[0] + " ";
String dport = "--dport " + protoPorts[1].replace('-', ':') + " ";
boolean found = false;
for (String rule : rules) {
found |= rule.contains(proto) && rule.contains(dport) && rule.contains("DROP");
}
if (!found) {
missing.add(drop);
}
}
if (!missing.isEmpty()) {
throw new IllegalStateException(
"The blackout of container " + targetContainer + " lacks the DROP rules for " + missing);
}
}
/**
@ -397,6 +419,60 @@ public class NetworkConditioner {
runPumba(cmd);
}
/**
* Limit the bandwidth of the packets leaving {@code targetContainer} towards
* {@code remotePorts} to {@code rate} (a tc rate such as "250kbit"), during
* {@code durationSec}. OUTBOUND only, like every netem impairment: the packets
* beyond the rate wait in a queue, as at a congested link, so the sender's
* bandwidth estimation sees the delay grow before any loss.
*/
public static void applyRateLimitToOutboundPackets(String targetContainer, String remotePorts, String rate,
int durationSec) {
log.info("Limiting the packets leaving container {} towards port {} to {} during {} seconds",
targetContainer, remotePorts, rate, durationSec);
StringBuilder opts = new StringBuilder("--duration ").append(durationSec).append("s --interface eth0")
.append(" --tc-image ").append(NETTOOLS_IMAGE);
final String ports = expandPorts(remotePorts);
if (ports != null) {
opts.append(" --ingress-port ").append(ports);
}
impairedContainer = targetContainer;
runPumba(pumbaRun() + " netem " + opts + " rate --rate " + rate + " " + targetContainer);
}
/**
* Impair the packets leaving {@code targetContainer} towards {@code remotePorts}
* with several netem settings at once, as a poor uplink has them: {@code netem}
* is a netem specification such as "delay 50ms 30ms loss random 5% rate 300kbit".
* Pumba runs one netem command at a time, and two of them cannot share an
* interface, so this installs the qdisc itself, the way Pumba does: a prio root
* whose third band carries the netem, with one u32 filter per destination port;
* everything else goes through the second band, untouched. Nothing reverts it
* but {@link #clear()}, which deletes the target's root qdisc.
*/
public static void applyOutboundNetem(String targetContainer, String remotePorts, String netem) {
final String ports = expandPorts(remotePorts);
if (ports == null) {
throw new IllegalArgumentException("applyOutboundNetem needs the destination ports to impair");
}
StringBuilder script = new StringBuilder("tc qdisc add dev eth0 root handle 1: prio bands 3 priomap")
.append(" 1".repeat(16)).append(" && tc qdisc add dev eth0 parent 1:3 handle 30: netem ").append(netem);
for (String port : ports.split(",")) {
script.append(" && tc filter add dev eth0 parent 1: protocol ip prio 1 u32 match ip dport ").append(port)
.append(" 0xffff flowid 1:3");
}
log.info("Impairing the packets leaving container {} towards port {} with netem {}", targetContainer,
remotePorts, netem);
impairedContainer = targetContainer;
String out = nettools(targetContainer, "sh", "-c \"" + script + "\"", 120);
// a command that timed out returns no output either
String qdiscs = nettools(targetContainer, "tc", "qdisc show dev eth0");
if (!out.isBlank() || !qdiscs.contains("netem")) {
throw new IllegalStateException(
"Could not install netem " + netem + " on " + targetContainer + ": " + out + " / " + qdiscs);
}
}
/**
* Remove every impairment: stop the running Pumba containers (SIGTERM, so they
* revert the netem qdisc / iptables rules themselves) and then scrub the target's

View File

@ -1,10 +1,16 @@
package io.openvidu.test.e2e;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
@ -18,12 +24,16 @@ import org.openqa.selenium.TakesScreenshot;
import org.openqa.selenium.TimeoutException;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.minio.BucketExistsArgs;
import io.minio.DownloadObjectArgs;
import io.minio.MinioClient;
import io.openvidu.test.browsers.BrowserUser;
import static org.openqa.selenium.OutputType.BASE64;
@ -32,6 +42,76 @@ public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e {
protected Collection<OpenViduTestappUser> testappUsers = new HashSet<>();
protected String getLivekitWssUrlFromReadyCheckContainer() {
String logs = commandLine.executeCommand("docker logs ready-check 2>&1", 30);
if (logs != null && !logs.isBlank()) {
/*-----------------LiveKit Server API-----------------
- Access from this machine:
- http://localhost:7880
- ws://localhost:7880
- Access from other devices in your LAN:
- https://10-10-1-203.openvidu-local.dev:7443
- wss://10-10-1-203.openvidu-local.dev:7443 <-- We want this value
- Credentials:
- API Key: devkey
- API Secret: secret
----------------------------------------------------*/
java.util.regex.Matcher m = java.util.regex.Pattern
.compile(
"LiveKit Server API.*?Access from other devices in your LAN.*?(wss://[A-Za-z0-9.\\-]+:\\d+)",
java.util.regex.Pattern.DOTALL)
.matcher(logs);
if (m.find()) {
return m.group(1);
}
}
return null;
}
/**
* Get ready to open bridged ("chromeNetwork") browsers, the only ones that
* {@link NetworkConditioner} can impair, and return the secure LiveKit URL they
* must connect to.
*/
protected String prepareNetemBrowsers() {
String secureLivekitUrl = getLivekitWssUrlFromReadyCheckContainer();
Assertions.assertNotNull(secureLivekitUrl,
"Could not obtain the LiveKit wss:// URL from the 'ready-check' container log. Is openvidu-local-deployment running? ");
log.info("Using LiveKit URL: {}", secureLivekitUrl);
// Bridged browsers live in their own Docker network and reach the SFU through
// this public wildcard name: pin its address so that they never depend on the
// runner's DNS to open the signaling WebSocket
pinHostForNetemBrowser(secureLivekitUrl);
NetworkConditioner.pullImages();
return secureLivekitUrl;
}
/**
* Open the openvidu-testapp in a new bridged ("chromeNetwork") browser, with
* the connection settings filled in and its events being polled.
*/
protected OpenViduTestappUser setupNetemBrowserUser(String secureLivekitUrl) throws Exception {
OpenViduTestappUser user = new OpenViduTestappUser(setupBrowser("chromeNetwork"));
this.testappUsers.add(user);
// Connect to the openvidu-testapp through "host.docker.internal"
user.getDriver().get(APP_URL.replace("localhost", "host.docker.internal"));
WebElement urlInput = user.getDriver().findElement(By.id("livekit-url"));
urlInput.clear();
urlInput.sendKeys(secureLivekitUrl);
WebElement keyInput = user.getDriver().findElement(By.id("livekit-api-key"));
keyInput.clear();
keyInput.sendKeys(LIVEKIT_API_KEY);
WebElement secretInput = user.getDriver().findElement(By.id("livekit-api-secret"));
secretInput.clear();
secretInput.sendKeys(LIVEKIT_API_SECRET);
user.getEventManager().startPolling();
return user;
}
private void connectToOpenViduTestApp(OpenViduTestappUser user) {
try {
user.getDriver().get(APP_URL);
@ -914,4 +994,284 @@ public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e {
return current > previous.getAndSet(current);
}, "Timeout waiting for the subscriber track bytesReceived to grow between consecutive samples");
}
protected void forceCodec(OpenViduTestappUser user, int numberOfUser, String codec) throws InterruptedException {
forceCodec(user, numberOfUser, codec, true);
}
protected void forceCodec(OpenViduTestappUser user, int numberOfUser, String codec, boolean disableBackupCodec)
throws InterruptedException {
String codecLowerCase = codec.toLowerCase();
this.waitAndClick(user, "#room-options-btn-" + numberOfUser);
Thread.sleep(300);
if (disableBackupCodec) {
// Enabled by default
user.getDriver().findElement(By.id("trackPublish-backupCodec")).click();
}
user.getDriver().findElement(By.id("trackPublish-videoCodec")).click();
this.waitAndClick(user, "#mat-option-" + codecLowerCase);
this.waitAndClick(user, "#close-dialog-btn");
Thread.sleep(300);
}
protected JsonObject waitUntilEgressStatus(OpenViduTestappUser user, String egressId, String egressStatus,
int timeoutMillis) {
final int intervalWait = 250;
final int MAX_ITERATIONS = timeoutMillis / intervalWait;
int iteration = 0;
boolean egressActive = false;
JsonObject egressObject = null;
while (!egressActive && iteration < MAX_ITERATIONS) {
iteration++;
try {
user.getDriver().findElement(By.cssSelector("#list-egress-api-btn")).click();
String textareaContent = user.getDriver().findElement(By.cssSelector("#api-response-text-area"))
.getDomProperty("value");
JsonArray egressArray = JsonParser.parseString(textareaContent).getAsJsonArray();
// Find the egress object with the matching egressId
JsonObject targetEgress = null;
for (int i = 0; i < egressArray.size(); i++) {
egressObject = egressArray.get(i).getAsJsonObject();
if (egressId.equals(egressObject.get("egressId").getAsString())) {
targetEgress = egressObject;
break;
}
}
if (targetEgress != null && egressStatus.equals(targetEgress.get("status").getAsString())) {
egressActive = true;
}
} catch (Exception e) {
// Continue polling if there's an exception
}
if (!egressActive) {
try {
Thread.sleep(intervalWait);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
if (!egressActive) {
Assertions.fail("Timeout waiting for egress '" + egressId + "' to reach status '" + egressStatus + "'");
}
return egressObject;
}
protected boolean isMinioAvailable() {
MinioClient minioClient = MinioClient.builder().endpoint("localhost", 9000, false)
.credentials("minioadmin", "minioadmin").build();
try {
minioClient.bucketExists(BucketExistsArgs.builder().bucket("openvidu-appdata").build());
return true;
} catch (Exception e) {
return false;
}
}
/**
* Records the video track of a Chrome publisher with Track Egress and checks
* the file. {@code codec} is vp8, h264, vp9 or av1 (only openvidu/egress-pro
* writes AV1); {@code layers} is "single", "simulcast" or a scalability mode.
*/
protected void trackEgressVideoCodec(String codec, String layers) throws Exception {
OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("chrome");
log.info("Track egress {} {}", codec, layers);
// A single participant publishing a single 1280x720 video track (Chrome sends
// fewer than 3 spatial layers below that): simulcast or not for VP8 and H264,
// an explicit scalability mode for the SVC codecs (without one, livekit-client
// publishes them L3T3_KEY)
final boolean simulcast = "simulcast".equals(layers);
final String scalabilityMode = layers.startsWith("L") ? layers : null;
final boolean singleLayer = !simulcast && !"L3T3".equals(scalabilityMode);
this.addPublisher(user, false, simulcast, false, false, false, true, 1280, 720, scalabilityMode);
this.forceCodec(user, 0, codec);
user.getDriver().findElement(By.cssSelector(".connect-btn")).sendKeys(Keys.ENTER);
user.getEventManager().waitUntilEventReaches("localTrackPublished", "RoomEvent", 1);
// The publisher sends the requested codec and layers
WebElement publisherVideo = user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 video.local"));
this.waitUntilVideoLayersNotEmpty(user, publisherVideo);
Assertions.assertEquals("video/" + codec.toUpperCase(),
getPublisherVideoLayerAttribute(user, publisherVideo, null, "codec").getAsString());
if (scalabilityMode != null) {
Assertions.assertEquals(scalabilityMode,
getPublisherVideoLayerAttribute(user, publisherVideo, null, "scalabilityMode").getAsString());
} else {
int publishedLayers = countNumberOfPublishedLayers(user, publisherVideo);
Assertions.assertTrue(simulcast ? publishedLayers > 1 : publishedLayers == 1,
"Wrong number of simulcast layers: " + publishedLayers);
}
// And it sends its top layer before the egress starts: Chrome turns its top
// simulcast layer on as its uplink estimate allows, which toward mediasoup
// takes up to 20 s for H264
this.waitUntilPublisherSendsWidth(user, publisherVideo, 1280, 40);
this.waitAndClick(user, "#close-dialog-btn");
Thread.sleep(300);
// The API dialog fills in the room and the video track
user.getDriver().findElement(By.cssSelector("#room-api-btn-0")).click();
Thread.sleep(300);
user.getDriver().findElement(By.cssSelector("#start-track-egress-api-btn")).click();
WebElement egressIdField = user.getDriver().findElement(By.id("egress-id-field"));
new WebDriverWait(user.getDriver(), Duration.ofSeconds(10))
.until(ExpectedConditions.textToBePresentInElementValue(egressIdField, "EG_"));
String egressId = egressIdField.getDomProperty("value");
// Long enough for the egress to reach the top layer of a multi-layer track
this.waitUntilEgressStatus(user, egressId, "EGRESS_ACTIVE", 10000);
Thread.sleep(8000);
user.getDriver().findElement(By.cssSelector("#stop-egress-api-btn")).click();
JsonObject egress = this.waitUntilEgressStatus(user, egressId, "EGRESS_COMPLETE", 10000);
this.assertHealthyVideoRecording(this.downloadRecording(egress), codec, 5, 1280, 720, singleLayer);
}
protected void waitUntilPublisherSendsWidth(OpenViduTestappUser user, WebElement publisherVideo, int width,
int timeoutSeconds) throws InterruptedException {
final long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;
JsonArray layers = null;
while (System.currentTimeMillis() < deadline) {
layers = getLayersAsJsonArray(user, publisherVideo);
for (JsonElement layer : layers) {
JsonElement frameWidth = layer.getAsJsonObject().get("frameWidth");
if (frameWidth != null && !frameWidth.isJsonNull() && frameWidth.getAsInt() == width) {
return;
}
}
Thread.sleep(500);
}
Assertions.fail("The publisher did not send its " + width + " px layer in " + timeoutSeconds + " s: " + layers);
}
/**
* Downloads the media file of a completed egress, from MinIO or, when the
* deployment has none, from the egress container's backup folder.
*/
protected Path downloadRecording(JsonObject egress) throws Exception {
String filename = egress.get("file").getAsJsonObject().get("filename").getAsString();
Path file = Files.createTempDirectory("egress").resolve(Path.of(filename).getFileName());
if (isMinioAvailable()) {
MinioClient.builder().endpoint("localhost", 9000, false).credentials("minioadmin", "minioadmin").build()
.downloadObject(DownloadObjectArgs.builder().bucket("openvidu-appdata").object(filename)
.filename(file.toString()).build());
} else {
runMediaCommand("docker", "cp", "egress:/home/egress/backup_storage/" + filename, file.toString());
}
return file;
}
/**
* The file is a playable recording of a single video track: the codec's native
* container holding one track of the given codec, starting with a keyframe,
* decoding without errors, lasting at least minSeconds at a real frame rate,
* showing a moving picture, and reaching the published width x height: in every
* frame for a single-layer track, from the top layer on for a multi-layer one
* (the egress starts on a low layer and moves up as its bandwidth allows).
*/
protected void assertHealthyVideoRecording(Path file, String codec, double minSeconds, int width, int height,
boolean singleLayer) throws Exception {
String container = "h264".equals(codec) ? "mp4" : "webm";
Assertions.assertTrue(file.toString().endsWith("." + container), "Wrong file extension: " + file);
JsonObject probe = JsonParser.parseString(runMediaCommand("ffprobe", "-v", "error", "-show_format",
"-show_streams", "-count_packets", "-of", "json", file.toString())[0]).getAsJsonObject();
JsonObject format = probe.getAsJsonObject("format");
JsonArray streams = probe.getAsJsonArray("streams");
log.info("Track egress recording {}: {}, {}", file.getFileName(), format, streams);
Assertions.assertTrue(format.get("format_name").getAsString().contains(container),
"Wrong container: " + format);
Assertions.assertEquals(1, streams.size(), "A single track expected: " + streams);
JsonObject video = streams.get(0).getAsJsonObject();
Assertions.assertEquals("video", video.get("codec_type").getAsString());
Assertions.assertEquals(codec, video.get("codec_name").getAsString());
Assertions.assertTrue(video.get("width").getAsInt() >= 16 && video.get("height").getAsInt() >= 16,
"Wrong video size: " + video);
double duration = format.get("duration").getAsDouble();
int frames = video.get("nb_read_packets").getAsInt();
Assertions.assertTrue(duration >= minSeconds && duration < 30, "Wrong duration: " + duration + " s");
Assertions.assertTrue(frames / duration >= 10,
"Too few frames: " + frames + " in " + duration + " s");
// Starts with a keyframe, and its timestamps never go back. A simulcast layer
// switch gives the keyframe of the new layer the timestamp of the frame it
// replaces, so a timestamp may repeat on a keyframe of a multi-layer track only
List<String[]> packets = runMediaCommand("ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "packet=dts_time,flags", "-of", "csv=p=0", file.toString())[0].lines()
.map(l -> l.trim().split(",")).filter(p -> p.length == 2 && !"N/A".equals(p[0])).toList();
Assertions.assertTrue(packets.get(0)[1].startsWith("K"), "The recording must start with a keyframe");
for (int i = 1; i < packets.size(); i++) {
double dts = Double.parseDouble(packets.get(i)[0]);
double previous = Double.parseDouble(packets.get(i - 1)[0]);
Assertions.assertTrue(dts > previous || (dts == previous && !singleLayer && packets.get(i)[1].startsWith("K")),
"Wrong timestamp " + dts + " after " + previous + " (flags " + packets.get(i)[1] + ")");
}
// Decode every frame: no errors, the picture moves (Chrome's fake camera changes
// on every frame) and reaches the published size. VP9 goes through libvpx, the
// decoder of browsers and GStreamer: FFmpeg's own shows only the base layer of a
// VP9 SVC superframe. AV1 goes through dav1d: FFmpeg's own needs hardware
List<String> command = new ArrayList<>(List.of("ffmpeg", "-nostdin", "-loglevel", "level+info"));
if ("vp9".equals(codec)) {
command.addAll(List.of("-c:v", "libvpx-vp9"));
} else if ("av1".equals(codec)) {
command.addAll(List.of("-c:v", "libdav1d"));
}
command.addAll(List.of("-i", file.toString(), "-map", "0:v", "-vf", "showinfo", "-fps_mode", "passthrough",
"-enc_time_base", "demux", "-f", "null", "-"));
String decodeLog = runMediaCommand(command.toArray(new String[0]))[1];
// Errors of the demuxer and the decoder: the null muxer's own complaints about a
// repeated timestamp are covered above
List<String> errors = decodeLog.lines().filter(l -> l.contains("[error]") || l.contains("[fatal]"))
.filter(l -> !l.startsWith("[null @")).toList();
Assertions.assertTrue(errors.isEmpty(), "Decoding reported errors: " + errors);
java.util.regex.Matcher frame = java.util.regex.Pattern
.compile("Parsed_showinfo.* n: *\\d+ .* s:(\\d+x\\d+) .* checksum:([0-9A-F]+)").matcher(decodeLog);
Map<String, Long> framesBySize = new LinkedHashMap<>();
List<String> checksums = new ArrayList<>();
while (frame.find()) {
framesBySize.merge(frame.group(1), 1L, Long::sum);
checksums.add(frame.group(2));
}
log.info("Track egress recording {}: decoded frames by size {}", file.getFileName(), framesBySize);
// MP4's edit list can leave the last packet out of the presentation (flag D)
long presented = packets.stream().filter(p -> !p[1].contains("D")).count();
Assertions.assertEquals(presented, checksums.size(), "Every presented packet must decode to one frame");
long distinct = checksums.stream().distinct().count();
Assertions.assertTrue(distinct >= checksums.size() / 2,
"The video looks frozen: " + distinct + " distinct frames out of " + checksums.size());
String expected = width + "x" + height;
if (singleLayer) {
Assertions.assertEquals(Map.of(expected, (long) checksums.size()), framesBySize,
"Every frame of a single-layer track must be " + expected);
} else {
Assertions.assertTrue(framesBySize.containsKey(expected),
"The recording must reach the top layer " + expected + ": " + framesBySize);
}
}
/** Runs a command and returns its stdout and stderr; it must exit with 0. */
protected static String[] runMediaCommand(String... command) throws IOException, InterruptedException {
Path out = Files.createTempFile("media-command", ".out");
Path err = Files.createTempFile("media-command", ".err");
try {
Process process = new ProcessBuilder(command).redirectOutput(out.toFile()).redirectError(err.toFile())
.start();
Assertions.assertTrue(process.waitFor(120, TimeUnit.SECONDS), "Timeout: " + String.join(" ", command));
Assertions.assertEquals(0, process.exitValue(),
String.join(" ", command) + " failed: " + Files.readString(err));
return new String[] { Files.readString(out), Files.readString(err) };
} finally {
Files.deleteIfExists(out);
Files.deleteIfExists(err);
}
}
}

View File

@ -77,8 +77,6 @@ import com.google.gson.JsonParser;
import io.livekit.server.AccessToken;
import io.livekit.server.RoomList;
import io.minio.BucketExistsArgs;
import io.minio.DownloadObjectArgs;
import io.minio.ListObjectsArgs;
import io.minio.MinioClient;
import io.minio.Result;
@ -823,34 +821,6 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
}
}
private String getLivekitWssUrlFromReadyCheckContainer() {
String logs = commandLine.executeCommand("docker logs ready-check 2>&1", 30);
if (logs != null && !logs.isBlank()) {
/*-----------------LiveKit Server API-----------------
- Access from this machine:
- http://localhost:7880
- ws://localhost:7880
- Access from other devices in your LAN:
- https://10-10-1-203.openvidu-local.dev:7443
- wss://10-10-1-203.openvidu-local.dev:7443 <-- We want this value
- Credentials:
- API Key: devkey
- API Secret: secret
----------------------------------------------------*/
java.util.regex.Matcher m = java.util.regex.Pattern
.compile(
"LiveKit Server API.*?Access from other devices in your LAN.*?(wss://[A-Za-z0-9.\\-]+:\\d+)",
java.util.regex.Pattern.DOTALL)
.matcher(logs);
if (m.find()) {
return m.group(1);
}
}
return null;
}
/**
* Dump everything needed to understand why the bridged (netem) PunchbagUser
* can't reach the SFU via
@ -1077,48 +1047,6 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
return new ImmutablePair<>(punchbagUser, regularUser);
}
/**
* Get ready to open bridged ("chromeNetwork") browsers, the only ones that
* {@link NetworkConditioner} can impair, and return the secure LiveKit URL they
* must connect to.
*/
private String prepareNetemBrowsers() {
String secureLivekitUrl = getLivekitWssUrlFromReadyCheckContainer();
Assertions.assertNotNull(secureLivekitUrl,
"Could not obtain the LiveKit wss:// URL from the 'ready-check' container log. Is openvidu-local-deployment running? ");
log.info("Using LiveKit URL: {}", secureLivekitUrl);
// Bridged browsers live in their own Docker network and reach the SFU through
// this public wildcard name: pin its address so that they never depend on the
// runner's DNS to open the signaling WebSocket
pinHostForNetemBrowser(secureLivekitUrl);
NetworkConditioner.pullImages();
return secureLivekitUrl;
}
/**
* Open the openvidu-testapp in a new bridged ("chromeNetwork") browser, with
* the connection settings filled in and its events being polled.
*/
private OpenViduTestappUser setupNetemBrowserUser(String secureLivekitUrl) throws Exception {
OpenViduTestappUser user = new OpenViduTestappUser(setupBrowser("chromeNetwork"));
this.testappUsers.add(user);
// Connect to the openvidu-testapp through "host.docker.internal"
user.getDriver().get(APP_URL.replace("localhost", "host.docker.internal"));
WebElement urlInput = user.getDriver().findElement(By.id("livekit-url"));
urlInput.clear();
urlInput.sendKeys(secureLivekitUrl);
WebElement keyInput = user.getDriver().findElement(By.id("livekit-api-key"));
keyInput.clear();
keyInput.sendKeys(LIVEKIT_API_KEY);
WebElement secretInput = user.getDriver().findElement(By.id("livekit-api-secret"));
secretInput.clear();
secretInput.sendKeys(LIVEKIT_API_SECRET);
user.getEventManager().startPolling();
return user;
}
private void setParticipantAndRoomName(OpenViduTestappUser user, String participantName, String roomName) {
WebElement participantNameInput = user.getDriver().findElement(By.id("participant-name-input-0"));
participantNameInput.clear();
@ -2888,25 +2816,6 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
gracefullyLeaveParticipants(user, 2);
}
private void forceCodec(OpenViduTestappUser user, int numberOfUser, String codec) throws InterruptedException {
forceCodec(user, numberOfUser, codec, true);
}
private void forceCodec(OpenViduTestappUser user, int numberOfUser, String codec, boolean disableBackupCodec)
throws InterruptedException {
String codecLowerCase = codec.toLowerCase();
this.waitAndClick(user, "#room-options-btn-" + numberOfUser);
Thread.sleep(300);
if (disableBackupCodec) {
// Enabled by default
user.getDriver().findElement(By.id("trackPublish-backupCodec")).click();
}
user.getDriver().findElement(By.id("trackPublish-videoCodec")).click();
this.waitAndClick(user, "#mat-option-" + codecLowerCase);
this.waitAndClick(user, "#close-dialog-btn");
Thread.sleep(300);
}
@Test
@DisplayName("Firefox subscribe to VP8")
void firefoxSubscribeToVP8Test() throws Exception {
@ -4716,65 +4625,6 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
"RoomEvent", 2);
}
private JsonObject waitUntilEgressStatus(OpenViduTestappUser user, String egressId, String egressStatus,
int timeoutMillis) {
final int intervalWait = 250;
final int MAX_ITERATIONS = timeoutMillis / intervalWait;
int iteration = 0;
boolean egressActive = false;
JsonObject egressObject = null;
while (!egressActive && iteration < MAX_ITERATIONS) {
iteration++;
try {
user.getDriver().findElement(By.cssSelector("#list-egress-api-btn")).click();
String textareaContent = user.getDriver().findElement(By.cssSelector("#api-response-text-area"))
.getDomProperty("value");
JsonArray egressArray = JsonParser.parseString(textareaContent).getAsJsonArray();
// Find the egress object with the matching egressId
JsonObject targetEgress = null;
for (int i = 0; i < egressArray.size(); i++) {
egressObject = egressArray.get(i).getAsJsonObject();
if (egressId.equals(egressObject.get("egressId").getAsString())) {
targetEgress = egressObject;
break;
}
}
if (targetEgress != null && egressStatus.equals(targetEgress.get("status").getAsString())) {
egressActive = true;
}
} catch (Exception e) {
// Continue polling if there's an exception
}
if (!egressActive) {
try {
Thread.sleep(intervalWait);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
if (!egressActive) {
Assertions.fail("Timeout waiting for egress '" + egressId + "' to reach status '" + egressStatus + "'");
}
return egressObject;
}
private boolean isMinioAvailable() {
MinioClient minioClient = MinioClient.builder().endpoint("localhost", 9000, false)
.credentials("minioadmin", "minioadmin").build();
try {
minioClient.bucketExists(BucketExistsArgs.builder().bucket("openvidu-appdata").build());
return true;
} catch (Exception e) {
return false;
}
}
private void checkRecordingInBucket(JsonObject egress) {
MinioClient minioClient = MinioClient.builder().endpoint("localhost", 9000, false)
.credentials("minioadmin", "minioadmin").build();
@ -4864,209 +4714,11 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
}
}
// AV1 needs openvidu/egress-pro: its cases are in OpenViduTestAppE2eEgressProTest
@ParameterizedTest(name = "Track egress {0} {1}")
@CsvSource({ "vp8,single", "vp8,simulcast", "h264,single", "h264,simulcast", "vp9,L1T1", "vp9,L3T3",
"av1,L1T1", "av1,L3T3" })
@CsvSource({ "vp8,single", "vp8,simulcast", "h264,single", "h264,simulcast", "vp9,L1T1", "vp9,L3T3" })
void trackEgressVideoCodecTest(String codec, String layers) throws Exception {
OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("chrome");
log.info("Track egress {} {}", codec, layers);
// A single participant publishing a single 1280x720 video track (Chrome sends
// fewer than 3 spatial layers below that): simulcast or not for VP8 and H264,
// an explicit scalability mode for the SVC codecs (without one, livekit-client
// publishes them L3T3_KEY)
final boolean simulcast = "simulcast".equals(layers);
final String scalabilityMode = layers.startsWith("L") ? layers : null;
final boolean singleLayer = !simulcast && !"L3T3".equals(scalabilityMode);
this.addPublisher(user, false, simulcast, false, false, false, true, 1280, 720, scalabilityMode);
this.forceCodec(user, 0, codec);
user.getDriver().findElement(By.cssSelector(".connect-btn")).sendKeys(Keys.ENTER);
user.getEventManager().waitUntilEventReaches("localTrackPublished", "RoomEvent", 1);
// The publisher sends the requested codec and layers
WebElement publisherVideo = user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 video.local"));
this.waitUntilVideoLayersNotEmpty(user, publisherVideo);
Assertions.assertEquals("video/" + codec.toUpperCase(),
getPublisherVideoLayerAttribute(user, publisherVideo, null, "codec").getAsString());
if (scalabilityMode != null) {
Assertions.assertEquals(scalabilityMode,
getPublisherVideoLayerAttribute(user, publisherVideo, null, "scalabilityMode").getAsString());
} else {
int publishedLayers = countNumberOfPublishedLayers(user, publisherVideo);
Assertions.assertTrue(simulcast ? publishedLayers > 1 : publishedLayers == 1,
"Wrong number of simulcast layers: " + publishedLayers);
}
// And it sends its top layer before the egress starts: Chrome turns its top
// simulcast layer on as its uplink estimate allows, which toward mediasoup
// takes up to 20 s for H264
this.waitUntilPublisherSendsWidth(user, publisherVideo, 1280, 40);
this.waitAndClick(user, "#close-dialog-btn");
Thread.sleep(300);
// The API dialog fills in the room and the video track
user.getDriver().findElement(By.cssSelector("#room-api-btn-0")).click();
Thread.sleep(300);
user.getDriver().findElement(By.cssSelector("#start-track-egress-api-btn")).click();
WebElement egressIdField = user.getDriver().findElement(By.id("egress-id-field"));
new WebDriverWait(user.getDriver(), Duration.ofSeconds(10))
.until(ExpectedConditions.textToBePresentInElementValue(egressIdField, "EG_"));
String egressId = egressIdField.getDomProperty("value");
if ("av1".equals(codec)) {
// Like every egress subscribing through the server SDK, Track Egress cannot
// write AV1: it fails as soon as it subscribes to the track
JsonObject egress = this.waitUntilEgressStatus(user, egressId, "EGRESS_FAILED", 20000);
Assertions.assertTrue(egress.get("error").getAsString().toLowerCase().contains("av1"),
"The egress error must name the AV1 codec: " + egress);
return;
}
// Long enough for the egress to reach the top layer of a multi-layer track
this.waitUntilEgressStatus(user, egressId, "EGRESS_ACTIVE", 10000);
Thread.sleep(8000);
user.getDriver().findElement(By.cssSelector("#stop-egress-api-btn")).click();
JsonObject egress = this.waitUntilEgressStatus(user, egressId, "EGRESS_COMPLETE", 10000);
this.assertHealthyVideoRecording(this.downloadRecording(egress), codec, 5, 1280, 720, singleLayer);
}
private void waitUntilPublisherSendsWidth(OpenViduTestappUser user, WebElement publisherVideo, int width,
int timeoutSeconds) throws InterruptedException {
final long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;
JsonArray layers = null;
while (System.currentTimeMillis() < deadline) {
layers = getLayersAsJsonArray(user, publisherVideo);
for (JsonElement layer : layers) {
JsonElement frameWidth = layer.getAsJsonObject().get("frameWidth");
if (frameWidth != null && !frameWidth.isJsonNull() && frameWidth.getAsInt() == width) {
return;
}
}
Thread.sleep(500);
}
Assertions.fail("The publisher did not send its " + width + " px layer in " + timeoutSeconds + " s: " + layers);
}
/**
* Downloads the media file of a completed egress, from MinIO or, when the
* deployment has none, from the egress container's backup folder.
*/
private Path downloadRecording(JsonObject egress) throws Exception {
String filename = egress.get("file").getAsJsonObject().get("filename").getAsString();
Path file = Files.createTempDirectory("egress").resolve(Path.of(filename).getFileName());
if (isMinioAvailable()) {
MinioClient.builder().endpoint("localhost", 9000, false).credentials("minioadmin", "minioadmin").build()
.downloadObject(DownloadObjectArgs.builder().bucket("openvidu-appdata").object(filename)
.filename(file.toString()).build());
} else {
runMediaCommand("docker", "cp", "egress:/home/egress/backup_storage/" + filename, file.toString());
}
return file;
}
/**
* The file is a playable recording of a single video track: the codec's native
* container holding one track of the given codec, starting with a keyframe,
* decoding without errors, lasting at least minSeconds at a real frame rate,
* showing a moving picture, and reaching the published width x height: in every
* frame for a single-layer track, from the top layer on for a multi-layer one
* (the egress starts on a low layer and moves up as its bandwidth allows).
*/
private void assertHealthyVideoRecording(Path file, String codec, double minSeconds, int width, int height,
boolean singleLayer) throws Exception {
String container = "h264".equals(codec) ? "mp4" : "webm";
Assertions.assertTrue(file.toString().endsWith("." + container), "Wrong file extension: " + file);
JsonObject probe = JsonParser.parseString(runMediaCommand("ffprobe", "-v", "error", "-show_format",
"-show_streams", "-count_frames", "-of", "json", file.toString())[0]).getAsJsonObject();
JsonObject format = probe.getAsJsonObject("format");
JsonArray streams = probe.getAsJsonArray("streams");
log.info("Track egress recording {}: {}, {}", file.getFileName(), format, streams);
Assertions.assertTrue(format.get("format_name").getAsString().contains(container),
"Wrong container: " + format);
Assertions.assertEquals(1, streams.size(), "A single track expected: " + streams);
JsonObject video = streams.get(0).getAsJsonObject();
Assertions.assertEquals("video", video.get("codec_type").getAsString());
Assertions.assertEquals(codec, video.get("codec_name").getAsString());
Assertions.assertTrue(video.get("width").getAsInt() >= 16 && video.get("height").getAsInt() >= 16,
"Wrong video size: " + video);
double duration = format.get("duration").getAsDouble();
int frames = video.get("nb_read_frames").getAsInt();
Assertions.assertTrue(duration >= minSeconds && duration < 30, "Wrong duration: " + duration + " s");
Assertions.assertTrue(frames / duration >= 10,
"Too few frames: " + frames + " in " + duration + " s");
// Starts with a keyframe, and its timestamps never go back. A simulcast layer
// switch gives the keyframe of the new layer the timestamp of the frame it
// replaces, so a timestamp may repeat on a keyframe of a multi-layer track only
List<String[]> packets = runMediaCommand("ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "packet=dts_time,flags", "-of", "csv=p=0", file.toString())[0].lines()
.map(l -> l.trim().split(",")).filter(p -> p.length == 2 && !"N/A".equals(p[0])).toList();
Assertions.assertTrue(packets.get(0)[1].startsWith("K"), "The recording must start with a keyframe");
for (int i = 1; i < packets.size(); i++) {
double dts = Double.parseDouble(packets.get(i)[0]);
double previous = Double.parseDouble(packets.get(i - 1)[0]);
Assertions.assertTrue(dts > previous || (dts == previous && !singleLayer && packets.get(i)[1].startsWith("K")),
"Wrong timestamp " + dts + " after " + previous + " (flags " + packets.get(i)[1] + ")");
}
// Decode every frame: no errors, the picture moves (Chrome's fake camera changes
// on every frame) and reaches the published size. VP9 goes through libvpx, the
// decoder of browsers and GStreamer: FFmpeg's own shows only the base layer of a
// VP9 SVC superframe
List<String> command = new ArrayList<>(List.of("ffmpeg", "-nostdin", "-loglevel", "level+info"));
if ("vp9".equals(codec)) {
command.addAll(List.of("-c:v", "libvpx-vp9"));
}
command.addAll(List.of("-i", file.toString(), "-map", "0:v", "-vf", "showinfo", "-fps_mode", "passthrough",
"-enc_time_base", "demux", "-f", "null", "-"));
String decodeLog = runMediaCommand(command.toArray(new String[0]))[1];
// Errors of the demuxer and the decoder: the null muxer's own complaints about a
// repeated timestamp are covered above
List<String> errors = decodeLog.lines().filter(l -> l.contains("[error]") || l.contains("[fatal]"))
.filter(l -> !l.startsWith("[null @")).toList();
Assertions.assertTrue(errors.isEmpty(), "Decoding reported errors: " + errors);
java.util.regex.Matcher frame = java.util.regex.Pattern
.compile("Parsed_showinfo.* n: *\\d+ .* s:(\\d+x\\d+) .* checksum:([0-9A-F]+)").matcher(decodeLog);
Map<String, Long> framesBySize = new LinkedHashMap<>();
List<String> checksums = new ArrayList<>();
while (frame.find()) {
framesBySize.merge(frame.group(1), 1L, Long::sum);
checksums.add(frame.group(2));
}
log.info("Track egress recording {}: decoded frames by size {}", file.getFileName(), framesBySize);
Assertions.assertEquals(frames, checksums.size(), "Every frame must be decoded");
long distinct = checksums.stream().distinct().count();
Assertions.assertTrue(distinct >= checksums.size() / 2,
"The video looks frozen: " + distinct + " distinct frames out of " + checksums.size());
String expected = width + "x" + height;
if (singleLayer) {
Assertions.assertEquals(Map.of(expected, (long) checksums.size()), framesBySize,
"Every frame of a single-layer track must be " + expected);
} else {
Assertions.assertTrue(framesBySize.containsKey(expected),
"The recording must reach the top layer " + expected + ": " + framesBySize);
}
}
/** Runs a command and returns its stdout and stderr; it must exit with 0. */
private static String[] runMediaCommand(String... command) throws IOException, InterruptedException {
Path out = Files.createTempFile("media-command", ".out");
Path err = Files.createTempFile("media-command", ".err");
try {
Process process = new ProcessBuilder(command).redirectOutput(out.toFile()).redirectError(err.toFile())
.start();
Assertions.assertTrue(process.waitFor(120, TimeUnit.SECONDS), "Timeout: " + String.join(" ", command));
Assertions.assertEquals(0, process.exitValue(),
String.join(" ", command) + " failed: " + Files.readString(err));
return new String[] { Files.readString(out), Files.readString(err) };
} finally {
Files.deleteIfExists(out);
Files.deleteIfExists(err);
}
this.trackEgressVideoCodec(codec, layers);
}
@Test

View File

@ -0,0 +1,150 @@
// Synthetic camera and microphone for OpenViduTestAppE2eEgressProTest (Participant Passthrough Egress).
//
// Replaces navigator.mediaDevices.getUserMedia and getDisplayMedia so that every
// track the testapp captures (at connect, when unmuting, when adding a track,
// when sharing the screen) comes from:
// - a 640x480 canvas (the camera) or an 800x600 canvas (the screen) that is
// dark with a moving bar, and white during the first 100 ms of every second
// of the page clock. The bar crosses the canvas once every BAR_PERIOD_MS of
// that clock, whatever its width, so the left edge of the bar in any frame
// tells the page time it was drawn at, within 2 s and to a few ms (whole
// seconds at 0 and half the width);
// - a WebAudio 1 kHz tone that sounds during the first 100 ms of every second
// of the same clock, and is silent otherwise.
// A recording keeps audio and video in sync when each white flash starts with
// a beep, whenever each track was published.
//
// A camera track that livekit-client stops (it does on every mute) is kept live
// and handed out again by the next getUserMedia. Chrome times every new canvas
// capture track afresh, so a camera reopened as a new track sent RTP timestamps
// 30-90 ms off its frames, which a real camera does not do (with Chrome's fake
// capture device they stay within 1 ms across reopens): the recordings would
// show an A/V offset that no SFU or egress introduced.
(function () {
if (window.__ovSyncMedia) {
return;
}
var WIDTH = 640;
var HEIGHT = 480;
var SCREEN_WIDTH = 800;
var SCREEN_HEIGHT = 600;
var MARKER_MS = 100;
var BAR_PERIOD_MS = 2000; // a multiple of 1000: the bar also tells whole seconds
var origin = performance.now();
function clock() {
return performance.now() - origin;
}
function createCanvas(width, height) {
var canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
return canvas;
}
var camera = createCanvas(WIDTH, HEIGHT);
var screen = createCanvas(SCREEN_WIDTH, SCREEN_HEIGHT);
function drawOn(canvas, t) {
var ctx = canvas.getContext('2d');
var w = canvas.width;
var h = canvas.height;
if (t % 1000 < MARKER_MS) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, w, h);
return;
}
ctx.fillStyle = '#202020';
ctx.fillRect(0, 0, w, h);
// something always moves, so that encoders keep producing frames
ctx.fillStyle = '#3070c0';
ctx.fillRect(((t % BAR_PERIOD_MS) / BAR_PERIOD_MS) * w, h / 3, 80, h / 3);
ctx.fillStyle = '#c0c0c0';
ctx.font = '40px sans-serif';
ctx.fillText((t / 1000).toFixed(1), 20, 60);
}
function draw() {
var t = clock();
drawOn(camera, t);
drawOn(screen, t);
}
// timers keep running (throttled) in background tabs, where requestAnimationFrame stops
setInterval(draw, 1000 / 60);
draw();
var video = camera.captureStream(30).getVideoTracks()[0];
var screenVideo = screen.captureStream(30).getVideoTracks()[0];
var audioContext = new AudioContext({ sampleRate: 48000 });
var destination = audioContext.createMediaStreamDestination();
var oscillator = audioContext.createOscillator();
oscillator.frequency.value = 1000;
var gain = audioContext.createGain();
gain.gain.value = 0;
oscillator.connect(gain).connect(destination);
oscillator.start();
var audio = destination.stream.getAudioTracks()[0];
// Beeps are scheduled on the audio clock, a few hundred ms ahead, mapping each
// whole second of the page clock to audio time at the moment of scheduling.
var nextBeep = Math.ceil(clock() / 1000) * 1000;
setInterval(function () {
var now = clock();
var audioNow = audioContext.currentTime;
while (nextBeep < now + 300) {
if (nextBeep >= now) {
var at = audioNow + (nextBeep - now) / 1000;
gain.gain.setValueAtTime(0.5, at);
gain.gain.setValueAtTime(0, at + MARKER_MS / 1000);
}
nextBeep += 1000;
}
}, 50);
// Autoplay policies keep the context suspended until a user gesture: the
// WebDriver clicks on the testapp buttons count as one.
function resume() {
if (audioContext.state !== 'running') {
audioContext.resume();
}
}
document.addEventListener('click', resume, true);
document.addEventListener('keydown', resume, true);
// camera tracks stopped by the page, still live, for the next getUserMedia
var stoppedCameras = [];
function cameraTrack() {
var track = stoppedCameras.pop();
if (!track) {
track = video.clone();
track.stop = function () {
if (stoppedCameras.indexOf(track) < 0) {
stoppedCameras.push(track);
}
};
}
track.enabled = true;
return track;
}
var originalGetUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
navigator.mediaDevices.getUserMedia = function (constraints) {
resume();
var tracks = [];
if (constraints && constraints.audio) {
tracks.push(audio.clone());
}
if (constraints && constraints.video) {
tracks.push(cameraTrack());
}
if (tracks.length === 0) {
return originalGetUserMedia(constraints);
}
return Promise.resolve(new MediaStream(tracks));
};
// the screen flashes with the camera: it must be in sync with the microphone too
navigator.mediaDevices.getDisplayMedia = function () {
resume();
return Promise.resolve(new MediaStream([screenVideo.clone()]));
};
window.__ovSyncMedia = true;
})();