mirror of https://github.com/OpenVidu/openvidu.git
openvidu-test-e2e: added extensive connection quality e2e tests
parent
c4b0ce1cd1
commit
94b9a852da
|
|
@ -0,0 +1,330 @@
|
||||||
|
package io.openvidu.test.e2e;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import io.openvidu.test.browsers.utils.CommandLineExecutor;
|
||||||
|
|
||||||
|
public class NetworkConditioner {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(NetworkConditioner.class);
|
||||||
|
private static final CommandLineExecutor commandLine = new CommandLineExecutor();
|
||||||
|
|
||||||
|
private static final String PUMBA_IMAGE = "gaiaadm/pumba";
|
||||||
|
private static final String NETTOOLS_IMAGE = "ghcr.io/alexei-led/pumba-alpine-nettools:latest";
|
||||||
|
private static final String DOCKER_SOCK = "/var/run/docker.sock";
|
||||||
|
|
||||||
|
// Upper bound on expanded ports: Pumba creates one tc filter / iptables rule
|
||||||
|
// per port, so a huge range would be slow and fragile. The SFU RTC range (e.g.
|
||||||
|
// 7900-7999) is 100 ports. This value covers it.
|
||||||
|
private static final int MAX_EXPANDED_PORTS = 1024;
|
||||||
|
|
||||||
|
// Name of the currently-running Pumba container to clear it.
|
||||||
|
private static String currentPumbaContainerName;
|
||||||
|
|
||||||
|
// Container that currently has an OUTBOUND blackout rule (a single iptables
|
||||||
|
// OUTPUT DROP added
|
||||||
|
// directly, not via Pumba). Tracked so clear() can flush it. See
|
||||||
|
// blackoutOutbound().
|
||||||
|
private static String blackoutContainer;
|
||||||
|
|
||||||
|
public enum Direction {
|
||||||
|
OUTBOUND, INBOUND
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L4 protocol to scope the drop to. Only honored for {@link Direction#INBOUND}
|
||||||
|
*/
|
||||||
|
public enum Protocol {
|
||||||
|
UDP, TCP
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void pullImages() {
|
||||||
|
log.info("Pulling Pumba images {} and {}", PUMBA_IMAGE, NETTOOLS_IMAGE);
|
||||||
|
commandLine.executeCommand("docker pull " + PUMBA_IMAGE, 180);
|
||||||
|
commandLine.executeCommand("docker pull " + NETTOOLS_IMAGE, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void applyLossToOutboundPackets(String targetContainer, String remotePorts, int lossPercent,
|
||||||
|
int durationSec) {
|
||||||
|
log.info("Dropping " + lossPercent + "% of packets leaving container " + targetContainer
|
||||||
|
+ " with destination port " + remotePorts + " during " + durationSec + " seconds");
|
||||||
|
applyLoss(targetContainer, NetworkConditioner.Direction.OUTBOUND, null, remotePorts, lossPercent, durationSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void applyLossToInboundPackets(String targetContainer, Protocol protocol, String remotePorts,
|
||||||
|
int lossPercent, int durationSec) {
|
||||||
|
log.info("Dropping " + lossPercent + "% of packets entering container " + targetContainer
|
||||||
|
+ " with origin port " + remotePorts + " during " + durationSec + " seconds");
|
||||||
|
applyLoss(targetContainer, NetworkConditioner.Direction.INBOUND, protocol, remotePorts, lossPercent,
|
||||||
|
durationSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the OUTBOUND packet-loss percentage in place (no Pumba restart).
|
||||||
|
* {@code tc qdisc change} rewrites only the netem loss parameter of the qdisc
|
||||||
|
* Pumba installed for the port-scoped egress filter, preserving the queue and
|
||||||
|
* the port filters. Requires a prior {@link #applyLossToOutboundPackets} with a
|
||||||
|
* {@code durationSec} long enough to span the whole ramp.
|
||||||
|
*/
|
||||||
|
public static void updateOutboundLossPercent(String targetContainer, int lossPercent) {
|
||||||
|
log.info("Updating OUTBOUND packet loss on container {} to {}% in place (tc qdisc change, no Pumba restart)",
|
||||||
|
targetContainer, lossPercent);
|
||||||
|
// --entrypoint tc is REQUIRED: the nettools image's default entrypoint is `tail
|
||||||
|
// -f /dev/null`, so without overriding it the tc args would be handed to tail.
|
||||||
|
// The netem qdisc MUST be named by its handle (30:) — `tc qdisc change` looks
|
||||||
|
// it up by handle, so `parent 1:3` alone fails with "Failed to find specified
|
||||||
|
// qdisc". Pumba adds it as `parent 1:3 handle 30:` (root prio 1: -> netem on
|
||||||
|
// band 1:3), which we mirror here.
|
||||||
|
String cmd = "docker run --rm --network container:" + targetContainer
|
||||||
|
+ " --cap-add NET_ADMIN --entrypoint tc " + NETTOOLS_IMAGE
|
||||||
|
+ " qdisc change dev eth0 parent 1:3 handle 30: netem loss " + lossPercent + "% 2>&1";
|
||||||
|
String out = commandLine.executeCommand(cmd, 30);
|
||||||
|
log.info("tc qdisc change result: {}", out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the INBOUND packet-loss percentage in place
|
||||||
|
*/
|
||||||
|
public static void updateInboundLossPercent(String targetContainer, Protocol protocol, String remotePorts,
|
||||||
|
int lossPercent) {
|
||||||
|
final String ports = expandPorts(remotePorts);
|
||||||
|
if (ports == null) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"updateInboundLossPercent needs the same remotePorts passed to applyLossToInboundPackets");
|
||||||
|
}
|
||||||
|
final String proto = protocol.name().toLowerCase(Locale.US);
|
||||||
|
final String probability = String.format(Locale.US, "%.2f", Math.max(0.0, Math.min(1.0, lossPercent / 100.0)));
|
||||||
|
log.info("Updating INBOUND packet loss on container {} to {}% in place (iptables -R, no Pumba restart)",
|
||||||
|
targetContainer, lossPercent);
|
||||||
|
|
||||||
|
// 1) Read the live INPUT rules (spec form) to find each port's DROP rule index
|
||||||
|
String listCmd = "docker run --rm --network container:" + targetContainer
|
||||||
|
+ " --cap-add NET_ADMIN --entrypoint iptables " + NETTOOLS_IMAGE + " -S INPUT 2>&1";
|
||||||
|
String[] rules = commandLine.executeCommand(listCmd, 30).split("-A INPUT");
|
||||||
|
|
||||||
|
// 2) Build an atomic `iptables -R` per target port (rules[0] is the "-P INPUT
|
||||||
|
// ..." policy).
|
||||||
|
StringBuilder script = new StringBuilder();
|
||||||
|
for (String port : ports.split(",")) {
|
||||||
|
int ruleNumber = -1;
|
||||||
|
for (int i = 1; i < rules.length; i++) {
|
||||||
|
if (rules[i].contains("--sport " + port + " ") && rules[i].contains("statistic")
|
||||||
|
&& rules[i].contains("DROP")) {
|
||||||
|
ruleNumber = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ruleNumber < 0) {
|
||||||
|
log.warn("No INPUT DROP rule found for source port {} on container {}; skipping", port,
|
||||||
|
targetContainer);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (script.length() > 0) {
|
||||||
|
script.append("; ");
|
||||||
|
}
|
||||||
|
script.append("iptables -R INPUT ").append(ruleNumber).append(" -i eth0 -p ").append(proto)
|
||||||
|
.append(" --sport ").append(port).append(" -m statistic --mode random --probability ")
|
||||||
|
.append(probability).append(" -j DROP");
|
||||||
|
}
|
||||||
|
if (script.length() == 0) {
|
||||||
|
log.warn("updateInboundLossPercent: no matching INPUT rules found on container {}; nothing updated",
|
||||||
|
targetContainer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Apply all replacements in one sidecar
|
||||||
|
String replaceCmd = "docker run --rm --network container:" + targetContainer
|
||||||
|
+ " --cap-add NET_ADMIN --entrypoint sh " + NETTOOLS_IMAGE + " -c \"" + script + "\" 2>&1";
|
||||||
|
String out = commandLine.executeCommand(replaceCmd, 30);
|
||||||
|
log.info("iptables -R result: {}", out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop a percentage of packets matching {@code direction} + {@code protocol} +
|
||||||
|
* {@code remotePorts}.
|
||||||
|
*
|
||||||
|
* @param targetContainer docker name/ID of the impaired container. Should never
|
||||||
|
* be --network=host
|
||||||
|
* @param direction {@link Direction#OUTBOUND} (egress, via netem) or
|
||||||
|
* {@link Direction#INBOUND} (ingress, via iptables)
|
||||||
|
* @param protocol {@link Protocol#UDP}/{@link Protocol#TCP}; honored for
|
||||||
|
* INBOUND, logged-and-ignored for OUTBOUND (netem cannot
|
||||||
|
* filter protocol)
|
||||||
|
* @param remotePorts the SFU/remote port(s): a single port ("7780"), a
|
||||||
|
* comma list ("7880,7881") or a range
|
||||||
|
* ("7900-7999"/"7900:7999"); expanded to a comma list
|
||||||
|
* (Pumba accepts no range syntax). null/blank = no port
|
||||||
|
* filter (whole interface).
|
||||||
|
* @param lossPercent packet loss percentage (0-100); for INBOUND this maps
|
||||||
|
* to a 0.0-1.0 per-packet probability
|
||||||
|
* @param durationSec how long Pumba keeps the impairment before
|
||||||
|
* auto-reverting
|
||||||
|
*/
|
||||||
|
private static void applyLoss(String targetContainer, Direction direction, Protocol protocol, String remotePorts,
|
||||||
|
int lossPercent, int durationSec) {
|
||||||
|
final String ports = expandPorts(remotePorts);
|
||||||
|
final String cmd;
|
||||||
|
if (direction == Direction.OUTBOUND) {
|
||||||
|
if (protocol != null) {
|
||||||
|
log.warn("Pumba netem (egress) cannot filter by L4 protocol; ignoring protocol={} for OUTBOUND loss "
|
||||||
|
+ "(the SFU RTC media ports are UDP-only, so this is equivalent to a UDP filter).", protocol);
|
||||||
|
}
|
||||||
|
StringBuilder opts = new StringBuilder("--duration ").append(durationSec).append("s --interface eth0")
|
||||||
|
.append(" --tc-image ").append(NETTOOLS_IMAGE);
|
||||||
|
if (ports != null) {
|
||||||
|
// remotePorts = SFU ports = DESTINATION of the client's egress packets =>
|
||||||
|
// --ingress-port (dport)
|
||||||
|
opts.append(" --ingress-port ").append(ports);
|
||||||
|
}
|
||||||
|
cmd = pumbaRun() + " netem " + opts + " loss --percent " + lossPercent + " " + targetContainer;
|
||||||
|
} else {
|
||||||
|
double probability = Math.max(0.0, Math.min(1.0, lossPercent / 100.0));
|
||||||
|
StringBuilder opts = new StringBuilder("--duration ").append(durationSec).append("s --interface eth0")
|
||||||
|
.append(" --iptables-image ").append(NETTOOLS_IMAGE);
|
||||||
|
if (protocol != null) {
|
||||||
|
opts.append(" --protocol ").append(protocol.name().toLowerCase(Locale.US));
|
||||||
|
}
|
||||||
|
if (ports != null) {
|
||||||
|
// remotePorts = SFU ports = SOURCE of the client's ingress packets =>
|
||||||
|
// --src-port (sport).
|
||||||
|
// Note: iptables only allows a port filter together with a tcp/udp protocol.
|
||||||
|
if (protocol == null) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"INBOUND loss with a port filter requires a protocol (Pumba iptables --src-port needs -p tcp/udp)");
|
||||||
|
}
|
||||||
|
opts.append(" --src-port ").append(ports);
|
||||||
|
}
|
||||||
|
cmd = pumbaRun() + " iptables " + opts + " loss --mode random --probability "
|
||||||
|
+ String.format(Locale.US, "%.2f", probability) + " " + targetContainer;
|
||||||
|
}
|
||||||
|
runPumba(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total OUTBOUND blackout: 100% packet loss across an ENTIRE SFU media port
|
||||||
|
* range, superseding any in-place single-port impairment. One native range rule
|
||||||
|
* (iptables matches {@code low:high} directly) reliably blocks all media,
|
||||||
|
* unlike a flaky 100-filter netem
|
||||||
|
*
|
||||||
|
* Stops the running Pumba first (avoids conflicting root qdiscs), then installs
|
||||||
|
* a SINGLE iptables OUTPUT DROP rule over the whole {@code mediaPortRange}
|
||||||
|
* (e.g. "7900-7999").
|
||||||
|
*/
|
||||||
|
public static void blackoutOutbound(String targetContainer, String mediaPortRange, int durationSec) {
|
||||||
|
clear();
|
||||||
|
final String iptablesRange = mediaPortRange.replace('-', ':'); // iptables ranges are low:high
|
||||||
|
log.info("Total OUTBOUND blackout (100% loss) on container {} across SFU media port range {} "
|
||||||
|
+ "(single iptables OUTPUT DROP)", targetContainer, mediaPortRange);
|
||||||
|
String cmd = "docker run --rm --network container:" + targetContainer
|
||||||
|
+ " --cap-add NET_ADMIN --entrypoint iptables " + NETTOOLS_IMAGE
|
||||||
|
+ " -A OUTPUT -o eth0 -p udp --dport " + iptablesRange + " -j DROP 2>&1";
|
||||||
|
String out = commandLine.executeCommand(cmd, 30);
|
||||||
|
log.info("blackout iptables -A OUTPUT result: {}", out);
|
||||||
|
blackoutContainer = targetContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add delay + jitter to the target container's. OUTBOUND only: Pumba can only
|
||||||
|
* delay with netem/tc, which is egress-only.
|
||||||
|
*/
|
||||||
|
public static void applyDelay(String targetContainer, int delayMs, int jitterMs, int durationSec,
|
||||||
|
String remotePorts) {
|
||||||
|
final String ports = expandPorts(remotePorts);
|
||||||
|
StringBuilder opts = new StringBuilder("--duration ").append(durationSec).append("s --interface eth0")
|
||||||
|
.append(" --tc-image ").append(NETTOOLS_IMAGE);
|
||||||
|
if (ports != null) {
|
||||||
|
opts.append(" --ingress-port ").append(ports);
|
||||||
|
}
|
||||||
|
String cmd = pumbaRun() + " netem " + opts + " delay --time " + delayMs + " --jitter " + jitterMs + " "
|
||||||
|
+ targetContainer;
|
||||||
|
runPumba(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the current Pumba container (SIGTERM) so it reverts the netem qdisc /
|
||||||
|
* iptables rule immediately.
|
||||||
|
*/
|
||||||
|
public static void clear() {
|
||||||
|
if (currentPumbaContainerName != null) {
|
||||||
|
log.info("Clearing network impairment (stopping Pumba container {})", currentPumbaContainerName);
|
||||||
|
commandLine.executeCommand("docker stop -t 5 " + currentPumbaContainerName, 30);
|
||||||
|
currentPumbaContainerName = null;
|
||||||
|
}
|
||||||
|
if (blackoutContainer != null) {
|
||||||
|
log.info("Clearing OUTBOUND blackout (flushing iptables OUTPUT) on container {}", blackoutContainer);
|
||||||
|
// Flush the OUTPUT chain: the blackout DROP is the only rule we ever add there
|
||||||
|
String cmd = "docker run --rm --network container:" + blackoutContainer
|
||||||
|
+ " --cap-add NET_ADMIN --entrypoint iptables " + NETTOOLS_IMAGE + " -F OUTPUT 2>&1";
|
||||||
|
commandLine.executeCommand(cmd, 30);
|
||||||
|
blackoutContainer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a port spec into the comma-separated single-port list Pumba expects.
|
||||||
|
* Accepts single ports, comma lists and {@code from-to} / {@code from:to}
|
||||||
|
* ranges. Returns {@code null} for a blank spec (no port filter).
|
||||||
|
*/
|
||||||
|
static String expandPorts(String spec) {
|
||||||
|
if (spec == null || spec.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
for (String rawToken : spec.split(",")) {
|
||||||
|
String token = rawToken.trim();
|
||||||
|
if (token.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String sep = token.contains("-") ? "-" : (token.contains(":") ? ":" : null);
|
||||||
|
if (sep != null) {
|
||||||
|
String[] parts = token.split(Pattern.quote(sep));
|
||||||
|
if (parts.length != 2) {
|
||||||
|
throw new IllegalArgumentException("Invalid port range: " + token);
|
||||||
|
}
|
||||||
|
int from = parsePort(parts[0].trim());
|
||||||
|
int to = parsePort(parts[1].trim());
|
||||||
|
if (from > to) {
|
||||||
|
int tmp = from;
|
||||||
|
from = to;
|
||||||
|
to = tmp;
|
||||||
|
}
|
||||||
|
for (int p = from; p <= to; p++) {
|
||||||
|
out.add(String.valueOf(p));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.add(String.valueOf(parsePort(token)));
|
||||||
|
}
|
||||||
|
if (out.size() > MAX_EXPANDED_PORTS) {
|
||||||
|
throw new IllegalArgumentException("Port spec '" + spec + "' expands to more than " + MAX_EXPANDED_PORTS
|
||||||
|
+ " ports; Pumba creates one rule/filter per port. Narrow the range.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.isEmpty() ? null : String.join(",", out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int parsePort(String s) {
|
||||||
|
int port = Integer.parseInt(s);
|
||||||
|
if (port < 0 || port > 65535) {
|
||||||
|
throw new IllegalArgumentException("Port out of range (0-65535): " + port);
|
||||||
|
}
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String pumbaRun() {
|
||||||
|
currentPumbaContainerName = "pumba-netem-" + System.currentTimeMillis();
|
||||||
|
return "docker run -d --name " + currentPumbaContainerName + " --rm -v " + DOCKER_SOCK + ":" + DOCKER_SOCK + " "
|
||||||
|
+ PUMBA_IMAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void runPumba(String cmd) {
|
||||||
|
log.info("Applying network impairment via Pumba: {}", cmd);
|
||||||
|
String out = commandLine.executeCommand(cmd, 60);
|
||||||
|
log.info("Pumba started: {}", out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import java.util.Iterator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
import javax.net.ssl.SSLContext;
|
import javax.net.ssl.SSLContext;
|
||||||
|
|
@ -31,6 +32,7 @@ import org.junit.jupiter.api.Assertions;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.testcontainers.containers.GenericContainer;
|
import org.testcontainers.containers.GenericContainer;
|
||||||
|
import org.testcontainers.containers.Network;
|
||||||
import org.testcontainers.containers.wait.strategy.Wait;
|
import org.testcontainers.containers.wait.strategy.Wait;
|
||||||
import org.testcontainers.containers.wait.strategy.WaitStrategy;
|
import org.testcontainers.containers.wait.strategy.WaitStrategy;
|
||||||
import org.testcontainers.utility.DockerImageName;
|
import org.testcontainers.utility.DockerImageName;
|
||||||
|
|
@ -71,7 +73,8 @@ public class OpenViduTestE2e {
|
||||||
put("VP9", Triple.of("libvpx-vp9", "", "VP9"));
|
put("VP9", Triple.of("libvpx-vp9", "", "VP9"));
|
||||||
put("MPEG-4", Triple.of("mpeg4", "", "MPEG-4"));
|
put("MPEG-4", Triple.of("mpeg4", "", "MPEG-4"));
|
||||||
put("M-JPEG", Triple.of("mjpeg", "-force_duplicated_matrix:v 1 -huffman:v 0", "M-JPEG"));
|
put("M-JPEG", Triple.of("mjpeg", "-force_duplicated_matrix:v 1 -huffman:v 0", "M-JPEG"));
|
||||||
// put("AV1", Triple.of("libaom-av1", "", "AV1")); // NOT SUPPORTED BY THE RTSP SERVER
|
// put("AV1", Triple.of("libaom-av1", "", "AV1")); // NOT SUPPORTED BY THE RTSP
|
||||||
|
// SERVER
|
||||||
// (maybe gstreamer?)
|
// (maybe gstreamer?)
|
||||||
// put("H265", Triple.of("libx265", "", "H265")); // NOT SUPPORTED BY INGRESS
|
// put("H265", Triple.of("libx265", "", "H265")); // NOT SUPPORTED BY INGRESS
|
||||||
}
|
}
|
||||||
|
|
@ -119,6 +122,19 @@ public class OpenViduTestE2e {
|
||||||
protected Collection<BrowserUser> browserUsers = ConcurrentHashMap.newKeySet();
|
protected Collection<BrowserUser> browserUsers = ConcurrentHashMap.newKeySet();
|
||||||
protected static Collection<GenericContainer<?>> containers = ConcurrentHashMap.newKeySet();
|
protected static Collection<GenericContainer<?>> containers = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
protected final int CONNECTION_QUALITY_OBSERVATION_MS = 22000;
|
||||||
|
protected static final int CONNECTION_QUALITY_STABLE_MS = 18000;
|
||||||
|
|
||||||
|
// Connection Quality Group 2: tracking for the isolated "chromeNetwork"
|
||||||
|
// browsers. Several can run at once (e.g. an impaired peer + a clean peer in
|
||||||
|
// the same test), so each gets a UNIQUE container name — keyed by its
|
||||||
|
// BrowserUser so a test can resolve the right one for Pumba via
|
||||||
|
// getNetemContainerName(user) — and its OWN docker network. All networks are
|
||||||
|
// closed in dispose().
|
||||||
|
protected Map<BrowserUser, String> netemContainerNames = new ConcurrentHashMap<>();
|
||||||
|
protected Collection<Network> netemNetworks = ConcurrentHashMap.newKeySet();
|
||||||
|
private final AtomicInteger netemContainerCounter = new AtomicInteger();
|
||||||
|
|
||||||
protected static RoomServiceClient LK;
|
protected static RoomServiceClient LK;
|
||||||
protected static IngressServiceClient LK_INGRESS;
|
protected static IngressServiceClient LK_INGRESS;
|
||||||
|
|
||||||
|
|
@ -151,6 +167,66 @@ public class OpenViduTestE2e {
|
||||||
return chrome;
|
return chrome;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connection Quality Group 2: a Chrome container on its OWN isolated network
|
||||||
|
// namespace so Pumba
|
||||||
|
// can apply netem to ONLY this client's traffic (NOT host networking, which
|
||||||
|
// would impair the
|
||||||
|
// whole host, SFU included). It joins a DEDICATED, uniquely-named docker
|
||||||
|
// network created just
|
||||||
|
// for this test (not the shared default bridge), so the impairment never
|
||||||
|
// touches any other
|
||||||
|
// docker network. Each network is tracked in `netemNetworks` and closed in
|
||||||
|
// dispose().
|
||||||
|
//
|
||||||
|
// The client does NOT need to share the SFU's docker network. Signalling
|
||||||
|
// reaches the secure
|
||||||
|
// wss://<host-LAN-IP>:7443 URL (host-published by caddy-proxy), and WebRTC
|
||||||
|
// media reaches the
|
||||||
|
// SFU's advertised host LAN IP candidate (e.g. 10.10.1.203:7900-7999/udp),
|
||||||
|
// which the SFU
|
||||||
|
// publishes to the host. Any docker bridge can reach a host-published port via
|
||||||
|
// the host IP +
|
||||||
|
// DNAT — docker's inter-bridge isolation only blocks direct container↔container
|
||||||
|
// routing, not
|
||||||
|
// the published-port path — and the SFU being ICE-lite means the client just
|
||||||
|
// sends to that one
|
||||||
|
// candidate and gets replies back via UDP conntrack. So full network isolation
|
||||||
|
// works.
|
||||||
|
//
|
||||||
|
// The WebDriver port 4444 is published to a mapped host port for the
|
||||||
|
// RemoteWebDriver, and
|
||||||
|
// host.docker.internal (host-gateway) reaches the host-published testapp.
|
||||||
|
private GenericContainer<?> chromeContainerNetem(String image, long shmSize, int maxBrowserSessions,
|
||||||
|
boolean headless, String containerName) {
|
||||||
|
Map<String, String> map = new HashMap<>();
|
||||||
|
map.put("SE_OPTS", "--port 4444");
|
||||||
|
map.put("SE_EVENT_BUS_PUBLISH_PORT", "4542");
|
||||||
|
map.put("SE_EVENT_BUS_SUBSCRIBE_PORT", "4543");
|
||||||
|
if (headless) {
|
||||||
|
map.put("START_XVFB", "false");
|
||||||
|
}
|
||||||
|
if (maxBrowserSessions > 1) {
|
||||||
|
map.put("SE_NODE_OVERRIDE_MAX_SESSIONS", "true");
|
||||||
|
map.put("SE_NODE_MAX_SESSIONS", String.valueOf(maxBrowserSessions));
|
||||||
|
}
|
||||||
|
Network netemNetwork = Network.builder()
|
||||||
|
.createNetworkCmdModifier(cmd -> cmd.withName(containerName + "-net"))
|
||||||
|
.build();
|
||||||
|
this.netemNetworks.add(netemNetwork);
|
||||||
|
GenericContainer<?> chrome = new GenericContainer<>(DockerImageName.parse(image)).withSharedMemorySize(shmSize)
|
||||||
|
.withFileSystemBind("/opt/openvidu", "/opt/openvidu").withEnv(map)
|
||||||
|
.withNetwork(netemNetwork)
|
||||||
|
.withExposedPorts(4444)
|
||||||
|
.withExtraHost("host.docker.internal", "host-gateway")
|
||||||
|
.withCreateContainerCmdModifier(cmd -> cmd.withName(containerName))
|
||||||
|
.waitingFor(waitBrowser);
|
||||||
|
return chrome;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected String getNetemContainerName(BrowserUser browserUser) {
|
||||||
|
return this.netemContainerNames.get(browserUser);
|
||||||
|
}
|
||||||
|
|
||||||
private GenericContainer<?> firefoxContainer(String image, long shmSize, int maxBrowserSessions, boolean headless) {
|
private GenericContainer<?> firefoxContainer(String image, long shmSize, int maxBrowserSessions, boolean headless) {
|
||||||
Map<String, String> map = new HashMap<>();
|
Map<String, String> map = new HashMap<>();
|
||||||
map.put("SE_OPTS", "--port 4445");
|
map.put("SE_OPTS", "--port 4445");
|
||||||
|
|
@ -232,12 +308,14 @@ public class OpenViduTestE2e {
|
||||||
// e.g. "[path live] stream is available and online, 2 tracks (H264, Opus)"
|
// e.g. "[path live] stream is available and online, 2 tracks (H264, Opus)"
|
||||||
if (videoCodec != null) {
|
if (videoCodec != null) {
|
||||||
String expectedVideoCodecLogValue = FFMPEG_VIDEO_CODEC_NAMES.get(videoCodec).getRight();
|
String expectedVideoCodecLogValue = FFMPEG_VIDEO_CODEC_NAMES.get(videoCodec).getRight();
|
||||||
String regex = ".*\\[path " + RTSP_PATH + "\\] stream is available.*\\(.*(?i)(" + expectedVideoCodecLogValue + ").*\\).*";
|
String regex = ".*\\[path " + RTSP_PATH + "\\] stream is available.*\\(.*(?i)(" + expectedVideoCodecLogValue
|
||||||
|
+ ").*\\).*";
|
||||||
waitUntilLog(rtspServerContainer, regex, 15);
|
waitUntilLog(rtspServerContainer, regex, 15);
|
||||||
}
|
}
|
||||||
if (audioCodec != null) {
|
if (audioCodec != null) {
|
||||||
String expectedValue = FFMPEG_AUDIO_CODEC_NAMES.get(audioCodec).getRight();
|
String expectedValue = FFMPEG_AUDIO_CODEC_NAMES.get(audioCodec).getRight();
|
||||||
String regex = ".*\\[path " + RTSP_PATH + "\\] stream is available.*\\(.*(?i)(" + expectedValue + ").*\\).*";
|
String regex = ".*\\[path " + RTSP_PATH + "\\] stream is available.*\\(.*(?i)(" + expectedValue
|
||||||
|
+ ").*\\).*";
|
||||||
waitUntilLog(rtspServerContainer, regex, 15);
|
waitUntilLog(rtspServerContainer, regex, 15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -435,77 +513,105 @@ public class OpenViduTestE2e {
|
||||||
boolean headless = false;
|
boolean headless = false;
|
||||||
|
|
||||||
switch (browser) {
|
switch (browser) {
|
||||||
case "chrome":
|
case "chrome":
|
||||||
container = chromeContainer("selenium/standalone-chrome:" + CHROME_VERSION, 2147483648L, 1, headless);
|
container = chromeContainer("selenium/standalone-chrome:" + CHROME_VERSION, 2147483648L, 1, headless);
|
||||||
setupBrowserAux(BrowserNames.CHROME, container, false);
|
setupBrowserAux(BrowserNames.CHROME, container, false);
|
||||||
browserUser = new ChromeUser("TestUser", 50, headless);
|
browserUser = new ChromeUser("TestUser", 50, headless);
|
||||||
break;
|
break;
|
||||||
case "chromeTwoInstances":
|
case "chromeTwoInstances":
|
||||||
container = chromeContainer("selenium/standalone-chrome:" + CHROME_VERSION, 2147483648L, 2, headless);
|
container = chromeContainer("selenium/standalone-chrome:" + CHROME_VERSION, 2147483648L, 2, headless);
|
||||||
setupBrowserAux(BrowserNames.CHROME, container, false);
|
setupBrowserAux(BrowserNames.CHROME, container, false);
|
||||||
browserUser = new ChromeUser("TestUser", 50, headless);
|
browserUser = new ChromeUser("TestUser", 50, headless);
|
||||||
break;
|
break;
|
||||||
case "chromeAlternateScreenShare":
|
case "chromeNetwork":
|
||||||
container = chromeContainer("selenium/standalone-chrome:" + CHROME_VERSION, 2147483648L, 1, false);
|
// Bridged Chrome (in its own isolated Docker network) targetable by Pumba. A
|
||||||
setupBrowserAux(BrowserNames.CHROME, container, false);
|
// unique name
|
||||||
browserUser = new ChromeUser("TestUser", 50, "OpenVidu TestApp");
|
// (millis + counter) lets several run simultaneously; it is mapped to its
|
||||||
break;
|
// BrowserUser below
|
||||||
case "chromeAlternateFakeVideo":
|
// so a test can resolve it for Pumba via getNetemContainerName(user).
|
||||||
container = chromeContainer("selenium/standalone-chrome:" + CHROME_VERSION, 2147483648L, 1, false);
|
String netemContainerName = "openvidu-test-chrome-netem-" + System.currentTimeMillis() + "-"
|
||||||
setupBrowserAux(BrowserNames.CHROME, container, false);
|
+ this.netemContainerCounter.incrementAndGet();
|
||||||
path = Paths.get("/opt/openvidu/barcode.y4m");
|
container = chromeContainerNetem("selenium/standalone-chrome:" + CHROME_VERSION, 2147483648L, 1,
|
||||||
checkMediafilePath(path);
|
headless,
|
||||||
browserUser = new ChromeUser("TestUser", 50, path);
|
netemContainerName);
|
||||||
break;
|
container.start();
|
||||||
case "chromeFakeAudio":
|
containers.add(container);
|
||||||
container = chromeContainer("selenium/standalone-chrome:" + CHROME_VERSION, 2147483648L, 1, false);
|
// Point the RemoteWebDriver at this container's mapped WebDriver port setting
|
||||||
setupBrowserAux(BrowserNames.CHROME, container, false);
|
// REMOTE_URL_CHROME. Restore the property afterwards
|
||||||
path = new File("/opt/openvidu/test.wav").toPath();
|
String previousRemoteUrlChrome = System.getProperty("REMOTE_URL_CHROME");
|
||||||
try {
|
System.setProperty("REMOTE_URL_CHROME", "http://localhost:" + container.getMappedPort(4444));
|
||||||
checkMediafilePath(path);
|
|
||||||
} catch (Exception e) {
|
|
||||||
try {
|
try {
|
||||||
FileUtils.copyURLToFile(
|
browserUser = new ChromeUser("TestUser", 50, headless);
|
||||||
new URL("https://openvidu-loadtest-mediafiles.s3.amazonaws.com/interview.wav"),
|
} finally {
|
||||||
new File("/opt/openvidu/test.wav"), 60000, 60000);
|
if (previousRemoteUrlChrome == null) {
|
||||||
} catch (FileNotFoundException e2) {
|
System.clearProperty("REMOTE_URL_CHROME");
|
||||||
e2.printStackTrace();
|
} else {
|
||||||
System.err.println("exception on: downLoadFile() function: " + e.getMessage());
|
System.setProperty("REMOTE_URL_CHROME", previousRemoteUrlChrome);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
this.netemContainerNames.put(browserUser, netemContainerName);
|
||||||
browserUser = new ChromeUser("TestUser", 50, null, path, headless);
|
break;
|
||||||
break;
|
case "chromeAlternateScreenShare":
|
||||||
case "chromeDtxAudio":
|
container = chromeContainer("selenium/standalone-chrome:" + CHROME_VERSION, 2147483648L, 1, false);
|
||||||
container = chromeContainer("selenium/standalone-chrome:" + CHROME_VERSION, 2147483648L, 1, false);
|
setupBrowserAux(BrowserNames.CHROME, container, false);
|
||||||
setupBrowserAux(BrowserNames.CHROME, container, false);
|
browserUser = new ChromeUser("TestUser", 50, "OpenVidu TestApp");
|
||||||
path = new File("/opt/openvidu/dtx_test_audio.wav").toPath();
|
break;
|
||||||
checkMediafilePath(path);
|
case "chromeAlternateFakeVideo":
|
||||||
browserUser = new ChromeUser("TestUser", 50, null, path, headless);
|
container = chromeContainer("selenium/standalone-chrome:" + CHROME_VERSION, 2147483648L, 1, false);
|
||||||
break;
|
setupBrowserAux(BrowserNames.CHROME, container, false);
|
||||||
case "chromeVirtualBackgroundFakeVideo":
|
path = Paths.get("/opt/openvidu/barcode.y4m");
|
||||||
container = chromeContainer("selenium/standalone-chrome:" + CHROME_VERSION, 2147483648L, 1, false);
|
checkMediafilePath(path);
|
||||||
setupBrowserAux(BrowserNames.CHROME, container, false);
|
browserUser = new ChromeUser("TestUser", 50, path);
|
||||||
path = Paths.get("/opt/openvidu/girl.mjpeg");
|
break;
|
||||||
checkMediafilePath(path);
|
case "chromeFakeAudio":
|
||||||
browserUser = new ChromeUser("TestUser", 50, path, false);
|
container = chromeContainer("selenium/standalone-chrome:" + CHROME_VERSION, 2147483648L, 1, false);
|
||||||
break;
|
setupBrowserAux(BrowserNames.CHROME, container, false);
|
||||||
case "firefox":
|
path = new File("/opt/openvidu/test.wav").toPath();
|
||||||
container = firefoxContainer("selenium/standalone-firefox:" + FIREFOX_VERSION, 2147483648L, 1, false);
|
try {
|
||||||
setupBrowserAux(BrowserNames.FIREFOX, container, false);
|
checkMediafilePath(path);
|
||||||
browserUser = new FirefoxUser("TestUser", 50, false, headless);
|
} catch (Exception e) {
|
||||||
break;
|
try {
|
||||||
case "firefoxDisabledOpenH264":
|
FileUtils.copyURLToFile(
|
||||||
container = firefoxContainer("selenium/standalone-firefox:" + FIREFOX_VERSION, 2147483648L, 1, true);
|
new URL("https://openvidu-loadtest-mediafiles.s3.amazonaws.com/interview.wav"),
|
||||||
setupBrowserAux(BrowserNames.FIREFOX, container, false);
|
new File("/opt/openvidu/test.wav"), 60000, 60000);
|
||||||
browserUser = new FirefoxUser("TestUser", 50, true, headless);
|
} catch (FileNotFoundException e2) {
|
||||||
break;
|
e2.printStackTrace();
|
||||||
case "edge":
|
System.err.println("exception on: downLoadFile() function: " + e.getMessage());
|
||||||
container = edgeContainer("selenium/standalone-edge:" + EDGE_VERSION, 2147483648L, 1, false);
|
}
|
||||||
setupBrowserAux(BrowserNames.EDGE, container, false);
|
}
|
||||||
browserUser = new EdgeUser("TestUser", 50, headless);
|
browserUser = new ChromeUser("TestUser", 50, null, path, headless);
|
||||||
break;
|
break;
|
||||||
default:
|
case "chromeDtxAudio":
|
||||||
log.error("Browser {} not recognized", browser);
|
container = chromeContainer("selenium/standalone-chrome:" + CHROME_VERSION, 2147483648L, 1, false);
|
||||||
|
setupBrowserAux(BrowserNames.CHROME, container, false);
|
||||||
|
path = new File("/opt/openvidu/dtx_test_audio.wav").toPath();
|
||||||
|
checkMediafilePath(path);
|
||||||
|
browserUser = new ChromeUser("TestUser", 50, null, path, headless);
|
||||||
|
break;
|
||||||
|
case "chromeVirtualBackgroundFakeVideo":
|
||||||
|
container = chromeContainer("selenium/standalone-chrome:" + CHROME_VERSION, 2147483648L, 1, false);
|
||||||
|
setupBrowserAux(BrowserNames.CHROME, container, false);
|
||||||
|
path = Paths.get("/opt/openvidu/girl.mjpeg");
|
||||||
|
checkMediafilePath(path);
|
||||||
|
browserUser = new ChromeUser("TestUser", 50, path, false);
|
||||||
|
break;
|
||||||
|
case "firefox":
|
||||||
|
container = firefoxContainer("selenium/standalone-firefox:" + FIREFOX_VERSION, 2147483648L, 1, false);
|
||||||
|
setupBrowserAux(BrowserNames.FIREFOX, container, false);
|
||||||
|
browserUser = new FirefoxUser("TestUser", 50, false, headless);
|
||||||
|
break;
|
||||||
|
case "firefoxDisabledOpenH264":
|
||||||
|
container = firefoxContainer("selenium/standalone-firefox:" + FIREFOX_VERSION, 2147483648L, 1, true);
|
||||||
|
setupBrowserAux(BrowserNames.FIREFOX, container, false);
|
||||||
|
browserUser = new FirefoxUser("TestUser", 50, true, headless);
|
||||||
|
break;
|
||||||
|
case "edge":
|
||||||
|
container = edgeContainer("selenium/standalone-edge:" + EDGE_VERSION, 2147483648L, 1, false);
|
||||||
|
setupBrowserAux(BrowserNames.EDGE, container, false);
|
||||||
|
browserUser = new EdgeUser("TestUser", 50, headless);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
log.error("Browser {} not recognized", browser);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.browserUsers.add(browserUser);
|
this.browserUsers.add(browserUser);
|
||||||
|
|
@ -536,20 +642,20 @@ public class OpenViduTestE2e {
|
||||||
private static boolean isRemote(BrowserNames browser) {
|
private static boolean isRemote(BrowserNames browser) {
|
||||||
String remoteUrl = null;
|
String remoteUrl = null;
|
||||||
switch (browser) {
|
switch (browser) {
|
||||||
case CHROME:
|
case CHROME:
|
||||||
remoteUrl = System.getProperty("REMOTE_URL_CHROME");
|
remoteUrl = System.getProperty("REMOTE_URL_CHROME");
|
||||||
break;
|
break;
|
||||||
case FIREFOX:
|
case FIREFOX:
|
||||||
remoteUrl = System.getProperty("REMOTE_URL_FIREFOX");
|
remoteUrl = System.getProperty("REMOTE_URL_FIREFOX");
|
||||||
break;
|
break;
|
||||||
case OPERA:
|
case OPERA:
|
||||||
remoteUrl = System.getProperty("REMOTE_URL_OPERA");
|
remoteUrl = System.getProperty("REMOTE_URL_OPERA");
|
||||||
break;
|
break;
|
||||||
case EDGE:
|
case EDGE:
|
||||||
remoteUrl = System.getProperty("REMOTE_URL_EDGE");
|
remoteUrl = System.getProperty("REMOTE_URL_EDGE");
|
||||||
break;
|
break;
|
||||||
case ANDROID:
|
case ANDROID:
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return remoteUrl != null;
|
return remoteUrl != null;
|
||||||
}
|
}
|
||||||
|
|
@ -576,6 +682,21 @@ public class OpenViduTestE2e {
|
||||||
c.close();
|
c.close();
|
||||||
it2.remove();
|
it2.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove the dedicated netem docker networks (created in chromeContainerNetem),
|
||||||
|
// now that their
|
||||||
|
// containers have been stopped.
|
||||||
|
Iterator<Network> itNet = this.netemNetworks.iterator();
|
||||||
|
while (itNet.hasNext()) {
|
||||||
|
Network net = itNet.next();
|
||||||
|
try {
|
||||||
|
net.close();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Error closing netem docker network: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
itNet.remove();
|
||||||
|
}
|
||||||
|
this.netemContainerNames.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void closeAllRooms(RoomServiceClient client) {
|
protected void closeAllRooms(RoomServiceClient client) {
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,10 @@ public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e {
|
||||||
return testappUser;
|
return testappUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected String getNetemContainerName(OpenViduTestappUser user) {
|
||||||
|
return this.getNetemContainerName(user.getBrowserUser());
|
||||||
|
}
|
||||||
|
|
||||||
protected void gracefullyLeaveParticipants(OpenViduTestappUser user, int numberOfParticipants) throws Exception {
|
protected void gracefullyLeaveParticipants(OpenViduTestappUser user, int numberOfParticipants) throws Exception {
|
||||||
int accumulatedDisconnected = 0;
|
int accumulatedDisconnected = 0;
|
||||||
for (int j = 1; j <= numberOfParticipants; j++) {
|
for (int j = 1; j <= numberOfParticipants; j++) {
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,12 @@ import java.util.AbstractMap;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Map.Entry;
|
import java.util.Map.Entry;
|
||||||
import java.util.concurrent.Callable;
|
import java.util.concurrent.Callable;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
|
|
@ -36,6 +39,8 @@ import java.util.concurrent.Future;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.function.BiFunction;
|
import java.util.function.BiFunction;
|
||||||
|
|
||||||
|
import org.apache.commons.lang3.tuple.ImmutablePair;
|
||||||
|
import org.apache.commons.lang3.tuple.Pair;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.Assertions;
|
import org.junit.jupiter.api.Assertions;
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
|
@ -46,6 +51,7 @@ 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.openqa.selenium.By;
|
import org.openqa.selenium.By;
|
||||||
|
import org.openqa.selenium.JavascriptExecutor;
|
||||||
import org.openqa.selenium.Keys;
|
import org.openqa.selenium.Keys;
|
||||||
import org.openqa.selenium.TakesScreenshot;
|
import org.openqa.selenium.TakesScreenshot;
|
||||||
import org.openqa.selenium.WebElement;
|
import org.openqa.selenium.WebElement;
|
||||||
|
|
@ -72,6 +78,7 @@ import io.minio.errors.XmlParserException;
|
||||||
import io.minio.messages.Item;
|
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 static org.openqa.selenium.OutputType.BASE64;
|
import static org.openqa.selenium.OutputType.BASE64;
|
||||||
|
|
||||||
|
|
@ -91,6 +98,13 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
|
||||||
checkFfmpegInstallation();
|
checkFfmpegInstallation();
|
||||||
loadEnvironmentVariables();
|
loadEnvironmentVariables();
|
||||||
setUpLiveKitClient();
|
setUpLiveKitClient();
|
||||||
|
CompletableFuture.runAsync(() -> {
|
||||||
|
try {
|
||||||
|
NetworkConditioner.pullImages();
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("Download of images failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@BeforeEach()
|
@BeforeEach()
|
||||||
|
|
@ -381,6 +395,843 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
|
||||||
gracefullyLeaveParticipants(user, 2);
|
gracefullyLeaveParticipants(user, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<WebElement> getConnectionQualityEventContents(OpenViduTestappUser user, int numberOfUser,
|
||||||
|
String participantName) {
|
||||||
|
String xpath = "//*[@id='openvidu-instance-" + numberOfUser
|
||||||
|
+ "']//mat-expansion-panel[.//mat-expansion-panel-header[contains(@class,'connectionQualityChanged-"
|
||||||
|
+ participantName + "')]]//div[contains(@class,'event-content')]";
|
||||||
|
return user.getDriver().findElements(By.xpath(xpath));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest connection-quality level currently reported for a participant, or null
|
||||||
|
// if none yet.
|
||||||
|
private ConnectionQuality latestConnectionQuality(OpenViduTestappUser user, int numberOfUser,
|
||||||
|
String participantName) {
|
||||||
|
List<WebElement> contents = getConnectionQualityEventContents(user, numberOfUser, participantName);
|
||||||
|
for (int i = contents.size() - 1; i >= 0; i--) {
|
||||||
|
String text = contents.get(i).getAttribute("textContent");
|
||||||
|
if (text == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
text = text.toLowerCase().trim();
|
||||||
|
if (text.contains("excellent")) {
|
||||||
|
return ConnectionQuality.EXCELLENT;
|
||||||
|
}
|
||||||
|
if (text.contains("good")) {
|
||||||
|
return ConnectionQuality.GOOD;
|
||||||
|
}
|
||||||
|
if (text.contains("poor")) {
|
||||||
|
return ConnectionQuality.POOR;
|
||||||
|
}
|
||||||
|
if (text.contains("lost")) {
|
||||||
|
return ConnectionQuality.LOST;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First loss% (ascending) at which the recorded quality equals `level`, or -1
|
||||||
|
// if never reached.
|
||||||
|
private static int firstLossReaching(Map<Integer, ConnectionQuality> observed, ConnectionQuality level) {
|
||||||
|
for (Entry<Integer, ConnectionQuality> e : observed.entrySet()) {
|
||||||
|
if (e.getValue() == level) {
|
||||||
|
return e.getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the ramp results (publisher vs subscriber-self quality per loss step)
|
||||||
|
// + the band transitions as a single log table.
|
||||||
|
private static String buildRampResultTable(Map<Integer, ConnectionQuality> publisher,
|
||||||
|
Map<Integer, ConnectionQuality> subscriber) {
|
||||||
|
StringBuilder sb = new StringBuilder("\nConnectionQuality ramp-up results\n");
|
||||||
|
sb.append(" loss% | PunchbagUser (publisher) | RegularUser (subscriber, self)\n");
|
||||||
|
sb.append(" ------+--------------------------+-------------------------------\n");
|
||||||
|
ConnectionQuality prev = null;
|
||||||
|
for (Entry<Integer, ConnectionQuality> e : publisher.entrySet()) {
|
||||||
|
int pct = e.getKey();
|
||||||
|
ConnectionQuality pub = e.getValue();
|
||||||
|
ConnectionQuality sub = subscriber.get(pct);
|
||||||
|
String transition = (prev != null && pub != prev) ? " <- " + prev + " -> " + pub : "";
|
||||||
|
sb.append(String.format(" %4d%% | %-24s | %-30s%s%n", pct, String.valueOf(pub), String.valueOf(sub),
|
||||||
|
transition));
|
||||||
|
prev = pub;
|
||||||
|
}
|
||||||
|
sb.append(" transitions: EXCELLENT->GOOD @").append(firstLossReaching(publisher, ConnectionQuality.GOOD))
|
||||||
|
.append("%, GOOD->POOR @").append(firstLossReaching(publisher, ConnectionQuality.POOR))
|
||||||
|
.append("%, POOR->LOST @").append(firstLossReaching(publisher, ConnectionQuality.LOST)).append("%");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertConnectionQualityNeverDegraded(OpenViduTestappUser user, int numberOfUser,
|
||||||
|
String participantName) {
|
||||||
|
List<WebElement> contents = getConnectionQualityEventContents(user, numberOfUser, participantName);
|
||||||
|
Assertions.assertFalse(contents.isEmpty(), "Expected at least one connectionQualityChanged event for "
|
||||||
|
+ participantName + " in openvidu-instance-" + numberOfUser);
|
||||||
|
String latest = null;
|
||||||
|
for (WebElement el : contents) {
|
||||||
|
String quality = el.getAttribute("textContent");
|
||||||
|
if (quality == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
quality = quality.toLowerCase().trim();
|
||||||
|
if (quality.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Assertions.assertFalse(quality.contains("poor") || quality.contains("lost"),
|
||||||
|
"Connection quality for " + participantName + " (openvidu-instance-" + numberOfUser
|
||||||
|
+ ") must never be POOR or LOST under server-side-only conditions, but observed: '"
|
||||||
|
+ quality
|
||||||
|
+ "'");
|
||||||
|
latest = quality;
|
||||||
|
}
|
||||||
|
Assertions.assertNotNull(latest,
|
||||||
|
"No connection quality value found for " + participantName + " in openvidu-instance-" + numberOfUser);
|
||||||
|
Assertions.assertTrue(latest.contains("excellent"),
|
||||||
|
"Connection quality for " + participantName + " (openvidu-instance-" + numberOfUser
|
||||||
|
+ ") should settle on EXCELLENT, but the latest value was: '" + latest + "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("ConnectionQuality EXCELLENT with many published and subscribed tracks")
|
||||||
|
void connectionQualityManyTracksAlwaysExcellentTest() throws Exception {
|
||||||
|
|
||||||
|
OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("chrome");
|
||||||
|
|
||||||
|
log.info("ConnectionQuality EXCELLENT with many published and subscribed tracks");
|
||||||
|
|
||||||
|
final int N = 3;
|
||||||
|
user.getDriver().findElement(By.id("auto-join-checkbox")).click();
|
||||||
|
WebElement nInput = user.getDriver().findElement(By.id("one2many-input"));
|
||||||
|
nInput.clear();
|
||||||
|
nInput.sendKeys(String.valueOf(N));
|
||||||
|
user.getDriver().findElement(By.id("one2one-btn")).click();
|
||||||
|
|
||||||
|
// N participants, each publishing audio+video and subscribing to all others
|
||||||
|
user.getEventManager().waitUntilEventReaches("connected", "RoomEvent", N);
|
||||||
|
user.getEventManager().waitUntilEventReaches("localTrackPublished", "RoomEvent", 2 * N);
|
||||||
|
user.getEventManager().waitUntilEventReaches("connectionQualityChanged", "RoomEvent", N);
|
||||||
|
|
||||||
|
// Let quality settle and give any (wrong) degradation time to surface
|
||||||
|
Thread.sleep(CONNECTION_QUALITY_OBSERVATION_MS);
|
||||||
|
|
||||||
|
for (int i = 0; i < N; i++) {
|
||||||
|
assertConnectionQualityNeverDegraded(user, i, "TestParticipant" + i);
|
||||||
|
}
|
||||||
|
|
||||||
|
gracefullyLeaveParticipants(user, N);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("ConnectionQuality EXCELLENT when publisher pauses a track")
|
||||||
|
void connectionQualityPublisherPauseAlwaysExcellentTest() throws Exception {
|
||||||
|
|
||||||
|
OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("chrome");
|
||||||
|
|
||||||
|
log.info("ConnectionQuality EXCELLENT when publisher pauses a track");
|
||||||
|
|
||||||
|
user.getDriver().findElement(By.id("auto-join-checkbox")).click();
|
||||||
|
user.getDriver().findElement(By.id("one2one-btn")).click();
|
||||||
|
|
||||||
|
user.getEventManager().waitUntilEventReaches("connected", "RoomEvent", 2);
|
||||||
|
user.getEventManager().waitUntilEventReaches("localTrackPublished", "RoomEvent", 4);
|
||||||
|
user.getEventManager().waitUntilEventReaches("trackSubscribed", "RoomEvent", 4);
|
||||||
|
user.getEventManager().waitUntilEventReaches("connectionQualityChanged", "RoomEvent", 2);
|
||||||
|
|
||||||
|
// Publisher (instance 0) pauses (mutes) its video track -> the scorer heals to
|
||||||
|
// EXCELLENT (mute -> cMaxScore), so quality must not degrade.
|
||||||
|
user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 .mute-unmute-video")).sendKeys(Keys.ENTER);
|
||||||
|
user.getEventManager().waitUntilEventReaches("trackMuted", "RoomEvent", 2);
|
||||||
|
|
||||||
|
Thread.sleep(CONNECTION_QUALITY_OBSERVATION_MS);
|
||||||
|
|
||||||
|
assertConnectionQualityNeverDegraded(user, 0, "TestParticipant0");
|
||||||
|
assertConnectionQualityNeverDegraded(user, 1, "TestParticipant1");
|
||||||
|
|
||||||
|
gracefullyLeaveParticipants(user, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("ConnectionQuality EXCELLENT when subscriber pauses a track")
|
||||||
|
void connectionQualitySubscriberPauseAlwaysExcellentTest() throws Exception {
|
||||||
|
|
||||||
|
OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("chrome");
|
||||||
|
|
||||||
|
log.info("ConnectionQuality EXCELLENT when subscriber pauses a track");
|
||||||
|
|
||||||
|
user.getDriver().findElement(By.id("auto-join-checkbox")).click();
|
||||||
|
user.getDriver().findElement(By.id("one2one-btn")).click();
|
||||||
|
|
||||||
|
user.getEventManager().waitUntilEventReaches("connected", "RoomEvent", 2);
|
||||||
|
user.getEventManager().waitUntilEventReaches("trackSubscribed", "RoomEvent", 4);
|
||||||
|
user.getEventManager().waitUntilEventReaches("connectionQualityChanged", "RoomEvent", 2);
|
||||||
|
|
||||||
|
// Subscriber (instance 1) disables playback of the subscribed video track ->
|
||||||
|
// the
|
||||||
|
// DownTrack forwarder is muted and the downstream scorer heals to EXCELLENT.
|
||||||
|
user.getDriver().findElement(By.cssSelector("#openvidu-instance-1 .toggle-video-enabled")).sendKeys(Keys.ENTER);
|
||||||
|
|
||||||
|
Thread.sleep(CONNECTION_QUALITY_OBSERVATION_MS);
|
||||||
|
|
||||||
|
assertConnectionQualityNeverDegraded(user, 0, "TestParticipant0");
|
||||||
|
assertConnectionQualityNeverDegraded(user, 1, "TestParticipant1");
|
||||||
|
|
||||||
|
gracefullyLeaveParticipants(user, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("ConnectionQuality EXCELLENT when Dynacast pauses an unsubscribed track")
|
||||||
|
void connectionQualityDynacastPauseAlwaysExcellentTest() throws Exception {
|
||||||
|
|
||||||
|
OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("chrome");
|
||||||
|
|
||||||
|
log.info("ConnectionQuality EXCELLENT when Dynacast pauses an unsubscribed track");
|
||||||
|
|
||||||
|
// Instance 0: video-only publisher with Dynacast + simulcast enabled.
|
||||||
|
this.addPublisher(user, false, true, true, false, false, true, null, null, null);
|
||||||
|
// Instance 1: subscriber only.
|
||||||
|
this.addSubscriber(user, true);
|
||||||
|
|
||||||
|
user.getDriver().findElements(By.className("connect-btn")).forEach(el -> el.sendKeys(Keys.ENTER));
|
||||||
|
|
||||||
|
user.getEventManager().waitUntilEventReaches("connected", "RoomEvent", 2);
|
||||||
|
user.getEventManager().waitUntilEventReaches("trackSubscribed", "RoomEvent", 1);
|
||||||
|
user.getEventManager().waitUntilEventReaches("connectionQualityChanged", "RoomEvent", 1);
|
||||||
|
|
||||||
|
// Subscriber unsubscribes from the only track -> the publisher's track becomes
|
||||||
|
// fully unwatched -> Dynacast pauses it -> the publisher stops sending and the
|
||||||
|
// mediasoup Producer score would decay to 0. Connection quality MUST stay
|
||||||
|
// EXCELLENT, NOT drop to POOR/LOST.
|
||||||
|
user.getDriver().findElement(By.cssSelector("#openvidu-instance-1 .toggle-video-subscribed"))
|
||||||
|
.sendKeys(Keys.ENTER);
|
||||||
|
|
||||||
|
// Generous wait: Dynacast pause delay + producer inactivity (~1.5s) + EMA +
|
||||||
|
// room ticks
|
||||||
|
Thread.sleep(CONNECTION_QUALITY_OBSERVATION_MS + 10000);
|
||||||
|
|
||||||
|
assertConnectionQualityNeverDegraded(user, 0, "TestParticipant0");
|
||||||
|
|
||||||
|
gracefullyLeaveParticipants(user, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("ConnectionQuality EXCELLENT when Adaptive Stream pauses a track")
|
||||||
|
void connectionQualityAdaptiveStreamPauseAlwaysExcellentTest() throws Exception {
|
||||||
|
|
||||||
|
OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("chrome");
|
||||||
|
|
||||||
|
log.info("ConnectionQuality EXCELLENT when Adaptive Stream pauses a track");
|
||||||
|
|
||||||
|
// one2one publishes audio+video with Adaptive Stream enabled (testapp default).
|
||||||
|
user.getDriver().findElement(By.id("auto-join-checkbox")).click();
|
||||||
|
user.getDriver().findElement(By.id("one2one-btn")).click();
|
||||||
|
|
||||||
|
user.getEventManager().waitUntilEventReaches("connected", "RoomEvent", 2);
|
||||||
|
user.getEventManager().waitUntilEventReaches("trackSubscribed", "RoomEvent", 4);
|
||||||
|
user.getEventManager().waitUntilEventReaches("connectionQualityChanged", "RoomEvent", 2);
|
||||||
|
|
||||||
|
// Hide the remote video elements so Adaptive Stream pauses those subscriptions
|
||||||
|
// (Adaptive Stream pauses tracks whose <video> element is not visible). Whether
|
||||||
|
// or not the auto-pause triggers in headless, the quality must remain
|
||||||
|
// EXCELLENT.
|
||||||
|
((JavascriptExecutor) user.getDriver()).executeScript(
|
||||||
|
"document.querySelectorAll('video.remote').forEach(v => v.style.display = 'none');");
|
||||||
|
|
||||||
|
Thread.sleep(CONNECTION_QUALITY_OBSERVATION_MS);
|
||||||
|
|
||||||
|
assertConnectionQualityNeverDegraded(user, 0, "TestParticipant0");
|
||||||
|
assertConnectionQualityNeverDegraded(user, 1, "TestParticipant1");
|
||||||
|
|
||||||
|
gracefullyLeaveParticipants(user, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void waitUntilConnectionQuality(OpenViduTestappUser user, int numberOfUser, String participantName,
|
||||||
|
java.util.function.Predicate<String> matcher, int timeoutSeconds, String errMsg)
|
||||||
|
throws InterruptedException {
|
||||||
|
long start = System.currentTimeMillis();
|
||||||
|
String latest = "<none>";
|
||||||
|
while (System.currentTimeMillis() - start < timeoutSeconds * 1000L) {
|
||||||
|
List<WebElement> contents = getConnectionQualityEventContents(user, numberOfUser, participantName);
|
||||||
|
if (!contents.isEmpty()) {
|
||||||
|
String text = contents.get(contents.size() - 1).getAttribute("textContent");
|
||||||
|
if (text != null) {
|
||||||
|
latest = text.toLowerCase().trim();
|
||||||
|
if (matcher.test(latest)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Thread.sleep(1000);
|
||||||
|
}
|
||||||
|
Assertions.fail(errMsg + " (latest observed for " + participantName + ": '" + latest + "')");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait until a participant's connection quality SETTLES — no new
|
||||||
|
* connectionQualityChanged level-change event for
|
||||||
|
* {@link #CONNECTION_QUALITY_STABLE_MS} — and then assert the settled (latest)
|
||||||
|
* level is {@code expectedLevel} (LiveKit's {@link ConnectionQuality}).
|
||||||
|
*
|
||||||
|
* Unlike {@link #waitUntilConnectionQuality}, which returns the instant a level
|
||||||
|
* is merely OBSERVED, this checks the LAST event, so a transient pass-through
|
||||||
|
* (e.g. EXCELLENT -> GOOD -> POOR) is NOT mistaken for "settled at GOOD", and a
|
||||||
|
* quality that never degrades (stays EXCELLENT) is not mistaken either. Fails
|
||||||
|
* if it settles at a different level or never settles within
|
||||||
|
* {@code timeoutSeconds}.
|
||||||
|
*/
|
||||||
|
private void assertConnectionQualitySettlesAt(OpenViduTestappUser user, int numberOfUser, String participantName,
|
||||||
|
ConnectionQuality expectedLevel, int timeoutSeconds, String errMsg)
|
||||||
|
throws InterruptedException {
|
||||||
|
long start = System.currentTimeMillis();
|
||||||
|
String settledLevel = null;
|
||||||
|
long stableSince = start;
|
||||||
|
while (System.currentTimeMillis() - start < timeoutSeconds * 1000L) {
|
||||||
|
List<WebElement> contents = getConnectionQualityEventContents(user, numberOfUser, participantName);
|
||||||
|
String current = null;
|
||||||
|
if (!contents.isEmpty()) {
|
||||||
|
String text = contents.get(contents.size() - 1).getAttribute("textContent");
|
||||||
|
if (text != null && !text.isBlank()) {
|
||||||
|
current = text.toLowerCase().trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current != null) {
|
||||||
|
if (!current.equals(settledLevel)) {
|
||||||
|
// A new (different) latest level resets the stabilization timer.
|
||||||
|
settledLevel = current;
|
||||||
|
stableSince = System.currentTimeMillis();
|
||||||
|
} else if (System.currentTimeMillis() - stableSince >= CONNECTION_QUALITY_STABLE_MS) {
|
||||||
|
Assertions.assertTrue(settledLevel.contains(expectedLevel.name().toLowerCase()),
|
||||||
|
errMsg + " (connection quality for " + participantName + " settled at '" + settledLevel
|
||||||
|
+ "', expected " + expectedLevel + ")");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Thread.sleep(500);
|
||||||
|
}
|
||||||
|
Assertions.fail(errMsg + " (connection quality for " + participantName + " did not settle within "
|
||||||
|
+ timeoutSeconds + "s; latest observed: '" + settledLevel + "')");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A task that may throw a checked exception, for {@link #runInParallel}. */
|
||||||
|
@FunctionalInterface
|
||||||
|
private interface ThrowingRunnable {
|
||||||
|
void run() throws Exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the given tasks concurrently (one thread each), wait for all to finish,
|
||||||
|
* then propagate the first failure — AssertionError or any exception — to the
|
||||||
|
* caller's thread, so assertions inside the tasks actually fail the test (an
|
||||||
|
* AssertionError thrown in a worker thread would otherwise be lost). Each task
|
||||||
|
* MUST drive a distinct WebDriver, since a Selenium driver is not thread-safe.
|
||||||
|
*/
|
||||||
|
private void runInParallel(ThrowingRunnable... tasks) throws Exception {
|
||||||
|
ExecutorService executor = Executors.newFixedThreadPool(tasks.length);
|
||||||
|
try {
|
||||||
|
List<Callable<Void>> callables = new ArrayList<>();
|
||||||
|
for (ThrowingRunnable task : tasks) {
|
||||||
|
callables.add(() -> {
|
||||||
|
task.run();
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (Future<Void> future : executor.invokeAll(callables)) {
|
||||||
|
try {
|
||||||
|
future.get();
|
||||||
|
} catch (ExecutionException e) {
|
||||||
|
Throwable cause = e.getCause() != null ? e.getCause() : e;
|
||||||
|
if (cause instanceof Error) {
|
||||||
|
throw (Error) cause;
|
||||||
|
}
|
||||||
|
if (cause instanceof Exception) {
|
||||||
|
throw (Exception) cause;
|
||||||
|
}
|
||||||
|
throw new RuntimeException(cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
executor.shutdownNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getConnectedSfuPortForPublisherPC(OpenViduTestappUser user, int numberOfUser)
|
||||||
|
throws InterruptedException {
|
||||||
|
return extractConnectedSfuPort(readPcTransportsInfoJson(user, numberOfUser), "publisher");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getConnectedSfuPortForSubscriberPC(OpenViduTestappUser user, int numberOfUser)
|
||||||
|
throws InterruptedException {
|
||||||
|
return extractConnectedSfuPort(readPcTransportsInfoJson(user, numberOfUser), "subscriber");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String extractConnectedSfuPort(String json, String transport) {
|
||||||
|
if (json == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Locate the transport's object, e.g. "publisher": { ... }. Matching the object
|
||||||
|
// form '{' (not '[') ignores the RTCIceCandidateStats arrays. No such element
|
||||||
|
// => return null.
|
||||||
|
java.util.regex.Matcher object = java.util.regex.Pattern.compile("\"" + transport + "\"\\s*:\\s*\\{([^}]*)\\}")
|
||||||
|
.matcher(json);
|
||||||
|
if (!object.find()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Within that object, read the connectedAddress port ("ip:port" or the empty-IP
|
||||||
|
// ":port" form).
|
||||||
|
java.util.regex.Matcher port = java.util.regex.Pattern.compile("\"connectedAddress\"\\s*:\\s*\"[^\"]*:(\\d+)\"")
|
||||||
|
.matcher(object.group(1));
|
||||||
|
return port.find() ? port.group(1) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String readPcTransportsInfoJson(OpenViduTestappUser user, int numberOfUser) throws InterruptedException {
|
||||||
|
user.getDriver().findElement(By.cssSelector("#openvidu-instance-" + numberOfUser + " .peer-info-btn")).click();
|
||||||
|
final java.util.regex.Pattern addrPattern = java.util.regex.Pattern
|
||||||
|
.compile("\"connectedAddress\"\\s*:\\s*\"[^\"]*:(\\d+)\"");
|
||||||
|
String json = null;
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
try {
|
||||||
|
json = user.getDriver().findElement(By.id("info-text-area")).getDomProperty("value");
|
||||||
|
if (json != null && addrPattern.matcher(json).find()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (org.openqa.selenium.NoSuchElementException ignored) {
|
||||||
|
}
|
||||||
|
Thread.sleep(500);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
user.getDriver().findElement(By.id("close-dialog-btn")).click();
|
||||||
|
} catch (org.openqa.selenium.NoSuchElementException ignored) {
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Pair<OpenViduTestappUser, OpenViduTestappUser> connectionQualityTest(boolean isPublisher,
|
||||||
|
boolean isSubscriber, Integer outboundPacketLoss, Integer inboundPacketLoss) throws Exception {
|
||||||
|
|
||||||
|
// Connect to LiveKit server from a secure URL
|
||||||
|
String secureLivekitUrlFromOpenViduLocalDeployment = getLivekitWssUrlFromReadyCheckContainer();
|
||||||
|
|
||||||
|
Assertions.assertNotNull(secureLivekitUrlFromOpenViduLocalDeployment,
|
||||||
|
"Could not obtain the LiveKit wss:// URL from the 'ready-check' container log. Is openvidu-local-deployment running? ");
|
||||||
|
log.info("Using LiveKit URL: {}", secureLivekitUrlFromOpenViduLocalDeployment);
|
||||||
|
|
||||||
|
NetworkConditioner.pullImages();
|
||||||
|
|
||||||
|
// Connect to the openvidu-testapp through "host.docker.internal"
|
||||||
|
final String secureAppUrl = APP_URL.replace("localhost", "host.docker.internal");
|
||||||
|
|
||||||
|
OpenViduTestappUser punchbagUser = new OpenViduTestappUser(setupBrowser("chromeNetwork"));
|
||||||
|
this.testappUsers.add(punchbagUser);
|
||||||
|
punchbagUser.getDriver().get(secureAppUrl);
|
||||||
|
WebElement urlInput = punchbagUser.getDriver().findElement(By.id("livekit-url"));
|
||||||
|
urlInput.clear();
|
||||||
|
urlInput.sendKeys(secureLivekitUrlFromOpenViduLocalDeployment);
|
||||||
|
WebElement keyInput = punchbagUser.getDriver().findElement(By.id("livekit-api-key"));
|
||||||
|
keyInput.clear();
|
||||||
|
keyInput.sendKeys(LIVEKIT_API_KEY);
|
||||||
|
WebElement secretInput = punchbagUser.getDriver().findElement(By.id("livekit-api-secret"));
|
||||||
|
secretInput.clear();
|
||||||
|
secretInput.sendKeys(LIVEKIT_API_SECRET);
|
||||||
|
punchbagUser.getEventManager().startPolling();
|
||||||
|
|
||||||
|
if (!isPublisher && !isSubscriber) {
|
||||||
|
throw new IllegalArgumentException("At least one of isPublisher or isSubscriber must be true");
|
||||||
|
}
|
||||||
|
if (isPublisher) {
|
||||||
|
this.addPublisher(punchbagUser, isSubscriber, false, false, false, true, true, null, null, null);
|
||||||
|
} else {
|
||||||
|
// Publish audio only (and keep subscribing) so the OTHER user subscribes to
|
||||||
|
// PunchbagUser and therefore also receives its connectionQualityChanged events
|
||||||
|
// - a user only gets quality events for itself and the participants it is
|
||||||
|
// subscribed to. PunchbagUser still subscribes to RegularUser's audio+video,
|
||||||
|
// which is the downlink actually impaired in this scenario; the extra audio
|
||||||
|
// uplink stays clean and does not affect its (min-based) quality.
|
||||||
|
this.addPublisher(punchbagUser, true, false, false, false, true, false, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
WebElement participantNameInput = punchbagUser.getDriver().findElement(By.id("participant-name-input-0"));
|
||||||
|
participantNameInput.clear();
|
||||||
|
participantNameInput.sendKeys("PunchbagUser");
|
||||||
|
|
||||||
|
punchbagUser.getDriver().findElement(By.cssSelector(".connect-btn")).sendKeys(Keys.ENTER);
|
||||||
|
punchbagUser.getEventManager().waitUntilEventReaches("connected", "RoomEvent", 1);
|
||||||
|
// PunchbagUser always publishes now: audio+video as a publisher, audio only as
|
||||||
|
// a subscriber.
|
||||||
|
punchbagUser.getEventManager().waitUntilEventReaches("localTrackPublished", "RoomEvent", isPublisher ? 2 : 1);
|
||||||
|
punchbagUser.getEventManager().waitUntilEventReaches("connectionQualityChanged", "RoomEvent", 1);
|
||||||
|
punchbagUser.getEventManager().waitUntilEventReaches("connectionQualityChanged", "ParticipantEvent", 1);
|
||||||
|
|
||||||
|
waitUntilConnectionQuality(punchbagUser, 0, "PunchbagUser", q -> q.contains("excellent"), 20,
|
||||||
|
"Expected baseline connection quality to be EXCELLENT before impairment");
|
||||||
|
|
||||||
|
OpenViduTestappUser regularUser = new OpenViduTestappUser(setupBrowser("chromeNetwork"));
|
||||||
|
this.testappUsers.add(regularUser);
|
||||||
|
regularUser.getDriver().get(secureAppUrl);
|
||||||
|
urlInput = regularUser.getDriver().findElement(By.id("livekit-url"));
|
||||||
|
urlInput.clear();
|
||||||
|
urlInput.sendKeys(secureLivekitUrlFromOpenViduLocalDeployment);
|
||||||
|
keyInput = regularUser.getDriver().findElement(By.id("livekit-api-key"));
|
||||||
|
keyInput.clear();
|
||||||
|
keyInput.sendKeys(LIVEKIT_API_KEY);
|
||||||
|
secretInput = regularUser.getDriver().findElement(By.id("livekit-api-secret"));
|
||||||
|
secretInput.clear();
|
||||||
|
secretInput.sendKeys(LIVEKIT_API_SECRET);
|
||||||
|
regularUser.getEventManager().startPolling();
|
||||||
|
|
||||||
|
if (isSubscriber) {
|
||||||
|
this.addPublisher(regularUser, true, false, false, false, true, true, null, null, null);
|
||||||
|
} else {
|
||||||
|
this.addSubscriber(regularUser, false);
|
||||||
|
}
|
||||||
|
participantNameInput = regularUser.getDriver().findElement(By.id("participant-name-input-0"));
|
||||||
|
participantNameInput.clear();
|
||||||
|
participantNameInput.sendKeys("RegularUser");
|
||||||
|
|
||||||
|
regularUser.getDriver().findElement(By.cssSelector(".connect-btn")).sendKeys(Keys.ENTER);
|
||||||
|
if (isPublisher) {
|
||||||
|
regularUser.getEventManager().waitUntilEventReaches("trackSubscribed", "RoomEvent", 2);
|
||||||
|
punchbagUser.getEventManager().waitUntilEventReaches("localTrackSubscribed", "RoomEvent", 2);
|
||||||
|
}
|
||||||
|
if (isSubscriber) {
|
||||||
|
punchbagUser.getEventManager().waitUntilEventReaches("trackSubscribed", "RoomEvent", 2);
|
||||||
|
regularUser.getEventManager().waitUntilEventReaches("localTrackSubscribed", "RoomEvent", 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
regularUser.getEventManager().waitUntilEventReaches("connectionQualityChanged", "RoomEvent", 2);
|
||||||
|
regularUser.getEventManager().waitUntilEventReaches("connectionQualityChanged", "ParticipantEvent", 2);
|
||||||
|
punchbagUser.getEventManager().waitUntilEventReaches("connectionQualityChanged", "RoomEvent", 1);
|
||||||
|
punchbagUser.getEventManager().waitUntilEventReaches("connectionQualityChanged", "ParticipantEvent", 1);
|
||||||
|
|
||||||
|
waitUntilConnectionQuality(regularUser, 0, "RegularUser", q -> q.contains("excellent"), 20,
|
||||||
|
"Expected baseline connection quality to be EXCELLENT");
|
||||||
|
waitUntilConnectionQuality(regularUser, 0, "PunchbagUser", q -> q.contains("excellent"), 20,
|
||||||
|
"Expected baseline connection quality to be EXCELLENT");
|
||||||
|
|
||||||
|
// Read the media port this client's publisher PC is connected to in
|
||||||
|
// openvidu-server
|
||||||
|
String publisherPortInSfu = getConnectedSfuPortForPublisherPC(punchbagUser, 0);
|
||||||
|
Assertions.assertFalse(publisherPortInSfu == null || publisherPortInSfu.isBlank(),
|
||||||
|
"Could not read the connected media port for the publisher PC");
|
||||||
|
// Read the media port this client's subscriber PC is connected to in
|
||||||
|
// openvidu-server
|
||||||
|
String subscriberPortInSfue = getConnectedSfuPortForSubscriberPC(punchbagUser, 0);
|
||||||
|
if (subscriberPortInSfue == null) {
|
||||||
|
log.info("Pion single peer connection mode is in use. Only publisher PC available, using port "
|
||||||
|
+ publisherPortInSfu);
|
||||||
|
}
|
||||||
|
|
||||||
|
punchbagUser.getEventManager().clearAllCurrentEvents();
|
||||||
|
regularUser.getEventManager().clearAllCurrentEvents();
|
||||||
|
|
||||||
|
if (outboundPacketLoss != null) {
|
||||||
|
// Drop packets the client sends through the publisher PC
|
||||||
|
NetworkConditioner.applyLossToOutboundPackets(getNetemContainerName(punchbagUser), publisherPortInSfu,
|
||||||
|
outboundPacketLoss, 10000);
|
||||||
|
}
|
||||||
|
if (inboundPacketLoss != null) {
|
||||||
|
// Drop packets the client receives through the subscriber PC (or publisher PC
|
||||||
|
// if single-PC mode)
|
||||||
|
NetworkConditioner.applyLossToInboundPackets(getNetemContainerName(punchbagUser),
|
||||||
|
NetworkConditioner.Protocol.UDP,
|
||||||
|
subscriberPortInSfue != null ? subscriberPortInSfue : publisherPortInSfu, inboundPacketLoss, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ImmutablePair<>(punchbagUser, regularUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("ConnectionQuality POOR publisher test")
|
||||||
|
void connectionQualityPoorPublisherTest() throws Exception {
|
||||||
|
|
||||||
|
log.info("ConnectionQuality POOR publisher test");
|
||||||
|
|
||||||
|
Pair<OpenViduTestappUser, OpenViduTestappUser> users = connectionQualityTest(true, false, 75, null);
|
||||||
|
OpenViduTestappUser punchbagUser = users.getLeft();
|
||||||
|
OpenViduTestappUser regularUser = users.getRight();
|
||||||
|
|
||||||
|
punchbagUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "RoomEvent", 1);
|
||||||
|
regularUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "RoomEvent", 1);
|
||||||
|
|
||||||
|
// Quality must degrade BELOW EXCELLENT (to GOOD or POOR)
|
||||||
|
runInParallel(
|
||||||
|
() -> waitUntilConnectionQuality(punchbagUser, 0, "PunchbagUser",
|
||||||
|
q -> q.contains("good") || q.contains("poor"), 45,
|
||||||
|
"Expected connection quality to degrade to GOOD or POOR"),
|
||||||
|
() -> waitUntilConnectionQuality(regularUser, 0, "PunchbagUser",
|
||||||
|
q -> q.contains("good") || q.contains("poor"), 45,
|
||||||
|
"Expected connection quality to degrade to GOOD or POOR"));
|
||||||
|
|
||||||
|
// Remove the impairment; quality must recover OUT of POOR (to GOOD or better)
|
||||||
|
NetworkConditioner.clear();
|
||||||
|
|
||||||
|
punchbagUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "RoomEvent", 2);
|
||||||
|
regularUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "RoomEvent", 2);
|
||||||
|
|
||||||
|
waitUntilConnectionQuality(punchbagUser, 0, "PunchbagUser", q -> q.contains("good") || q.contains("excellent"),
|
||||||
|
45, "Expected connection quality to RECOVER (to GOOD or better) after clearing impairment");
|
||||||
|
waitUntilConnectionQuality(regularUser, 0, "PunchbagUser", q -> q.contains("good") || q.contains("excellent"),
|
||||||
|
45, "Expected connection quality to RECOVER (to GOOD or better) after clearing impairment");
|
||||||
|
|
||||||
|
gracefullyLeaveParticipants(punchbagUser, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("ConnectionQuality GOOD publisher test")
|
||||||
|
void connectionQualityGoodPublisherTest() throws Exception {
|
||||||
|
|
||||||
|
log.info("ConnectionQuality GOOD publisher test");
|
||||||
|
|
||||||
|
Pair<OpenViduTestappUser, OpenViduTestappUser> users = connectionQualityTest(true, false, 15, null);
|
||||||
|
OpenViduTestappUser punchbagUser = users.getLeft();
|
||||||
|
OpenViduTestappUser regularUser = users.getRight();
|
||||||
|
|
||||||
|
punchbagUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "RoomEvent", 1);
|
||||||
|
regularUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "RoomEvent", 1);
|
||||||
|
|
||||||
|
// Quality must SETTLE at GOOD
|
||||||
|
runInParallel(
|
||||||
|
() -> assertConnectionQualitySettlesAt(punchbagUser, 0, "PunchbagUser",
|
||||||
|
ConnectionQuality.GOOD, 45, "Expected connection quality to settle at GOOD"),
|
||||||
|
() -> assertConnectionQualitySettlesAt(regularUser, 0, "PunchbagUser",
|
||||||
|
ConnectionQuality.GOOD, 45, "Expected connection quality to settle at GOOD"));
|
||||||
|
|
||||||
|
// Remove the impairment; quality must recover toward EXCELLENT
|
||||||
|
NetworkConditioner.clear();
|
||||||
|
|
||||||
|
punchbagUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "RoomEvent", 2);
|
||||||
|
regularUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "RoomEvent", 2);
|
||||||
|
|
||||||
|
waitUntilConnectionQuality(punchbagUser, 0, "PunchbagUser", q -> q.contains("excellent"), 45,
|
||||||
|
"Expected connection quality to RECOVER to EXCELLENT after clearing impairment");
|
||||||
|
waitUntilConnectionQuality(regularUser, 0, "PunchbagUser", q -> q.contains("excellent"), 45,
|
||||||
|
"Expected connection quality to RECOVER to EXCELLENT after clearing impairment");
|
||||||
|
|
||||||
|
gracefullyLeaveParticipants(punchbagUser, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("ConnectionQuality LOST publisher test")
|
||||||
|
void connectionQualityLostPublisherTest() throws Exception {
|
||||||
|
|
||||||
|
log.info("ConnectionQuality LOST publisher test");
|
||||||
|
|
||||||
|
Pair<OpenViduTestappUser, OpenViduTestappUser> users = connectionQualityTest(true, false, 99, null);
|
||||||
|
OpenViduTestappUser punchbagUser = users.getLeft();
|
||||||
|
OpenViduTestappUser regularUser = users.getRight();
|
||||||
|
|
||||||
|
punchbagUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "RoomEvent", 1);
|
||||||
|
regularUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "RoomEvent", 1);
|
||||||
|
|
||||||
|
waitUntilConnectionQuality(punchbagUser, 0, "PunchbagUser", q -> q.contains("lost"), 45,
|
||||||
|
"Expected connection quality to reach LOST");
|
||||||
|
waitUntilConnectionQuality(regularUser, 0, "PunchbagUser", q -> q.contains("lost"), 45,
|
||||||
|
"Expected connection quality to reach LOST");
|
||||||
|
|
||||||
|
// Remove the impairment; quality must recover toward EXCELLENT
|
||||||
|
NetworkConditioner.clear();
|
||||||
|
|
||||||
|
punchbagUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "RoomEvent", 2);
|
||||||
|
regularUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "RoomEvent", 2);
|
||||||
|
|
||||||
|
waitUntilConnectionQuality(punchbagUser, 0, "PunchbagUser", q -> q.contains("excellent"), 45,
|
||||||
|
"Expected connection quality to RECOVER to EXCELLENT after clearing impairment");
|
||||||
|
waitUntilConnectionQuality(regularUser, 0, "PunchbagUser", q -> q.contains("excellent"), 45,
|
||||||
|
"Expected connection quality to RECOVER to EXCELLENT after clearing impairment");
|
||||||
|
|
||||||
|
gracefullyLeaveParticipants(punchbagUser, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("ConnectionQuality POOR subscriber test")
|
||||||
|
void connectionQualityPoorSubscriberTest() throws Exception {
|
||||||
|
log.info("ConnectionQuality POOR subscriber test");
|
||||||
|
|
||||||
|
Pair<OpenViduTestappUser, OpenViduTestappUser> users = connectionQualityTest(false, true, null, 50);
|
||||||
|
OpenViduTestappUser punchbagUser = users.getLeft();
|
||||||
|
OpenViduTestappUser regularUser = users.getRight();
|
||||||
|
|
||||||
|
punchbagUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "RoomEvent", 1);
|
||||||
|
regularUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "RoomEvent", 1);
|
||||||
|
punchbagUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "ParticipantEvent", 1);
|
||||||
|
regularUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "ParticipantEvent", 1);
|
||||||
|
|
||||||
|
// Quality must SETTLE at POOR
|
||||||
|
runInParallel(
|
||||||
|
() -> assertConnectionQualitySettlesAt(punchbagUser, 0, "PunchbagUser",
|
||||||
|
ConnectionQuality.POOR, 45, "Expected connection quality to settle at POOR"),
|
||||||
|
() -> assertConnectionQualitySettlesAt(regularUser, 0, "PunchbagUser",
|
||||||
|
ConnectionQuality.POOR, 45, "Expected connection quality to settle at POOR"));
|
||||||
|
|
||||||
|
// Remove the impairment; quality must recover OUT of POOR (to GOOD or better)
|
||||||
|
NetworkConditioner.clear();
|
||||||
|
|
||||||
|
punchbagUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "RoomEvent", 3);
|
||||||
|
regularUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "RoomEvent", 3);
|
||||||
|
punchbagUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "ParticipantEvent", 3);
|
||||||
|
regularUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "ParticipantEvent", 3);
|
||||||
|
|
||||||
|
waitUntilConnectionQuality(punchbagUser, 0, "PunchbagUser", q -> q.contains("good") || q.contains("excellent"),
|
||||||
|
45, "Expected connection quality to RECOVER (to GOOD or better) after clearing impairment");
|
||||||
|
waitUntilConnectionQuality(regularUser, 0, "PunchbagUser", q -> q.contains("good") || q.contains("excellent"),
|
||||||
|
45, "Expected connection quality to RECOVER (to GOOD or better) after clearing impairment");
|
||||||
|
|
||||||
|
gracefullyLeaveParticipants(punchbagUser, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("ConnectionQuality GOOD subscriber test")
|
||||||
|
void connectionQualityGoodSubscriberTest() throws Exception {
|
||||||
|
log.info("ConnectionQuality GOOD subscriber test");
|
||||||
|
|
||||||
|
// Moderate inbound loss (22%) that degrades the subscriber below EXCELLENT
|
||||||
|
Pair<OpenViduTestappUser, OpenViduTestappUser> users = connectionQualityTest(false, true, null, 22);
|
||||||
|
OpenViduTestappUser punchbagUser = users.getLeft();
|
||||||
|
OpenViduTestappUser regularUser = users.getRight();
|
||||||
|
|
||||||
|
punchbagUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "RoomEvent", 1);
|
||||||
|
regularUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "RoomEvent", 1);
|
||||||
|
punchbagUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "ParticipantEvent", 1);
|
||||||
|
regularUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "ParticipantEvent", 1);
|
||||||
|
|
||||||
|
// Quality must degrade to GOOD or POOR (engine-divergent level -- see above)
|
||||||
|
runInParallel(
|
||||||
|
() -> waitUntilConnectionQuality(punchbagUser, 0, "PunchbagUser",
|
||||||
|
q -> q.contains("good") || q.contains("poor"), 45,
|
||||||
|
"Expected connection quality to degrade to GOOD or POOR"),
|
||||||
|
() -> waitUntilConnectionQuality(regularUser, 0, "PunchbagUser",
|
||||||
|
q -> q.contains("good") || q.contains("poor"), 45,
|
||||||
|
"Expected connection quality to degrade to GOOD or POOR"));
|
||||||
|
|
||||||
|
// Remove the impairment; quality must recover toward EXCELLENT
|
||||||
|
NetworkConditioner.clear();
|
||||||
|
|
||||||
|
punchbagUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "RoomEvent", 2);
|
||||||
|
regularUser.getEventManager().waitUntilEventReaches(0, "connectionQualityChanged", "RoomEvent", 2);
|
||||||
|
|
||||||
|
waitUntilConnectionQuality(punchbagUser, 0, "PunchbagUser", q -> q.contains("excellent"), 45,
|
||||||
|
"Expected connection quality to RECOVER to EXCELLENT after clearing impairment");
|
||||||
|
waitUntilConnectionQuality(regularUser, 0, "PunchbagUser", q -> q.contains("excellent"), 45,
|
||||||
|
"Expected connection quality to RECOVER to EXCELLENT after clearing impairment");
|
||||||
|
|
||||||
|
gracefullyLeaveParticipants(punchbagUser, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("ConnectionQuality ramp up test")
|
||||||
|
void connectionQualityRampUpTest() throws Exception {
|
||||||
|
log.info("ConnectionQuality ramp up test");
|
||||||
|
|
||||||
|
Pair<OpenViduTestappUser, OpenViduTestappUser> users = connectionQualityTest(true, false, 0, null);
|
||||||
|
OpenViduTestappUser punchbagUser = users.getLeft();
|
||||||
|
OpenViduTestappUser regularUser = users.getRight();
|
||||||
|
|
||||||
|
// Quality must SETTLE at EXCELLENT
|
||||||
|
runInParallel(
|
||||||
|
() -> assertConnectionQualitySettlesAt(punchbagUser, 0, "PunchbagUser",
|
||||||
|
ConnectionQuality.EXCELLENT, 45, "Expected connection quality to settle at EXCELLENT"),
|
||||||
|
() -> assertConnectionQualitySettlesAt(regularUser, 0, "PunchbagUser",
|
||||||
|
ConnectionQuality.EXCELLENT, 45, "Expected connection quality to settle at EXCELLENT"));
|
||||||
|
|
||||||
|
String container = getNetemContainerName(punchbagUser);
|
||||||
|
final int HOLD_SECONDS = 8;
|
||||||
|
|
||||||
|
// Ramp the PUBLISHER's uplink loss and record, at each step, the settled
|
||||||
|
// quality of both the impaired publisher (PunchbagUser) and the untouched
|
||||||
|
// subscriber's OWN quality (RegularUser).
|
||||||
|
Map<Integer, ConnectionQuality> publisherQuality = new LinkedHashMap<>();
|
||||||
|
Map<Integer, ConnectionQuality> subscriberQuality = new LinkedHashMap<>();
|
||||||
|
try {
|
||||||
|
for (int pct = 5; pct <= 95; pct += 5) {
|
||||||
|
log.info("Packet loss to " + pct + "%");
|
||||||
|
NetworkConditioner.updateOutboundLossPercent(container, pct);
|
||||||
|
Thread.sleep(HOLD_SECONDS * 1000L);
|
||||||
|
publisherQuality.put(pct, latestConnectionQuality(punchbagUser, 0, "PunchbagUser"));
|
||||||
|
subscriberQuality.put(pct, latestConnectionQuality(regularUser, 0, "RegularUser"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final step: a TOTAL blackout (100% loss) across the WHOLE SFU media port
|
||||||
|
// range (7900-7999)
|
||||||
|
final int BLACKOUT_PCT = 100;
|
||||||
|
NetworkConditioner.blackoutOutbound(container, "7900-7999", 120);
|
||||||
|
ConnectionQuality pubBlackout = latestConnectionQuality(punchbagUser, 0, "PunchbagUser");
|
||||||
|
long lostDeadline = System.currentTimeMillis() + 45000L;
|
||||||
|
while (pubBlackout != ConnectionQuality.LOST && System.currentTimeMillis() < lostDeadline) {
|
||||||
|
Thread.sleep(1000L);
|
||||||
|
pubBlackout = latestConnectionQuality(punchbagUser, 0, "PunchbagUser");
|
||||||
|
}
|
||||||
|
publisherQuality.put(BLACKOUT_PCT, pubBlackout);
|
||||||
|
subscriberQuality.put(BLACKOUT_PCT, latestConnectionQuality(regularUser, 0, "RegularUser"));
|
||||||
|
|
||||||
|
log.info(buildRampResultTable(publisherQuality, subscriberQuality));
|
||||||
|
|
||||||
|
int firstGood = firstLossReaching(publisherQuality, ConnectionQuality.GOOD);
|
||||||
|
int firstPoor = firstLossReaching(publisherQuality, ConnectionQuality.POOR);
|
||||||
|
int firstLost = firstLossReaching(publisherQuality, ConnectionQuality.LOST);
|
||||||
|
Assertions.assertTrue(firstGood >= 10 && firstGood <= 20,
|
||||||
|
"EXCELLENT->GOOD transition expected between 10% and 20% loss, but first GOOD was at " + firstGood
|
||||||
|
+ "%");
|
||||||
|
Assertions.assertTrue(firstPoor >= 20 && firstPoor <= 50,
|
||||||
|
"GOOD->POOR transition expected between 20% and 50% loss, but first POOR was at " + firstPoor
|
||||||
|
+ "%");
|
||||||
|
Assertions.assertTrue(firstPoor > firstGood,
|
||||||
|
"POOR must appear after GOOD (firstGood=" + firstGood + "%, firstPoor=" + firstPoor + "%)");
|
||||||
|
Assertions.assertTrue(firstLost >= 50,
|
||||||
|
"POOR->LOST transition expected ONLY at severe loss (>=50%), but first LOST was at " + firstLost
|
||||||
|
+ "%");
|
||||||
|
Assertions.assertTrue(firstLost > firstPoor,
|
||||||
|
"LOST must appear after POOR (firstPoor=" + firstPoor + "%, firstLost=" + firstLost + "%)");
|
||||||
|
|
||||||
|
// Subscriber's own network is always EXCELLENT
|
||||||
|
for (Entry<Integer, ConnectionQuality> e : subscriberQuality.entrySet()) {
|
||||||
|
ConnectionQuality sq = e.getValue();
|
||||||
|
Assertions.assertFalse(
|
||||||
|
sq == ConnectionQuality.GOOD || sq == ConnectionQuality.POOR || sq == ConnectionQuality.LOST,
|
||||||
|
"RegularUser (subscriber) network is NOT impaired, so its own connection quality must stay "
|
||||||
|
+ "EXCELLENT, but was " + sq + " at " + e.getKey() + "% publisher loss");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
NetworkConditioner.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
waitUntilConnectionQuality(punchbagUser, 0, "PunchbagUser",
|
||||||
|
q -> q.contains("good") || q.contains("excellent"), 80,
|
||||||
|
"Expected connection quality to RECOVER (to GOOD or better) after clearing impairment");
|
||||||
|
waitUntilConnectionQuality(regularUser, 0, "PunchbagUser",
|
||||||
|
q -> q.contains("good") || q.contains("excellent"), 80,
|
||||||
|
"Expected connection quality to RECOVER (to GOOD or better) after clearing impairment");
|
||||||
|
|
||||||
|
gracefullyLeaveParticipants(punchbagUser, 1);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("Data Tracks publish, subscribe, send and receive")
|
@DisplayName("Data Tracks publish, subscribe, send and receive")
|
||||||
void dataTracksTest() throws Exception {
|
void dataTracksTest() throws Exception {
|
||||||
|
|
@ -1338,7 +2189,8 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
|
||||||
final java.util.concurrent.atomic.AtomicLong subscriber1920AtMs = new java.util.concurrent.atomic.AtomicLong(
|
final java.util.concurrent.atomic.AtomicLong subscriber1920AtMs = new java.util.concurrent.atomic.AtomicLong(
|
||||||
-1);
|
-1);
|
||||||
|
|
||||||
final String publisherBrowser = "chromeTwoInstances".equals(subscriberBrowser) ? "chromeTwoInstances" : "chrome";
|
final String publisherBrowser = "chromeTwoInstances".equals(subscriberBrowser) ? "chromeTwoInstances"
|
||||||
|
: "chrome";
|
||||||
|
|
||||||
Future<?> task1 = executor.submit(() -> {
|
Future<?> task1 = executor.submit(() -> {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
<mat-checkbox id="room-stopLocalTrackOnUnpublish" [(ngModel)]="roomOptions.stopLocalTrackOnUnpublish">stopLocalTrackOnUnpublish</mat-checkbox>
|
<mat-checkbox id="room-stopLocalTrackOnUnpublish" [(ngModel)]="roomOptions.stopLocalTrackOnUnpublish">stopLocalTrackOnUnpublish</mat-checkbox>
|
||||||
<mat-checkbox id="room-webAudioMix" [(ngModel)]="roomOptions.webAudioMix">webAudioMix</mat-checkbox>
|
<mat-checkbox id="room-webAudioMix" [(ngModel)]="roomOptions.webAudioMix">webAudioMix</mat-checkbox>
|
||||||
<mat-checkbox id="room-forceRelay" [(ngModel)]="forceRelay">Force relay candidates</mat-checkbox>
|
<mat-checkbox id="room-forceRelay" [(ngModel)]="forceRelay">Force relay candidates</mat-checkbox>
|
||||||
|
<mat-checkbox id="room-singlePeerConnection" [(ngModel)]="roomOptions.singlePeerConnection">singlePeerConnection</mat-checkbox>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (createLocalTracksOptions) {
|
@if (createLocalTracksOptions) {
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ export class OpenviduInstanceComponent {
|
||||||
frameRate: 30,
|
frameRate: 30,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
singlePeerConnection: false
|
||||||
};
|
};
|
||||||
roomConnectOptions: RoomConnectOptions = {
|
roomConnectOptions: RoomConnectOptions = {
|
||||||
autoSubscribe: false,
|
autoSubscribe: false,
|
||||||
|
|
@ -1570,8 +1571,7 @@ export class OpenviduInstanceComponent {
|
||||||
const updateFunction = async (): Promise<string> => {
|
const updateFunction = async (): Promise<string> => {
|
||||||
const pub: PCTransport = this.getPublisherPC()!;
|
const pub: PCTransport = this.getPublisherPC()!;
|
||||||
const sub: PCTransport = this.getSubscriberPC()!;
|
const sub: PCTransport = this.getSubscriberPC()!;
|
||||||
return JSON.stringify(
|
const info = {
|
||||||
{
|
|
||||||
PCTransports: {
|
PCTransports: {
|
||||||
publisher: {
|
publisher: {
|
||||||
connectedAddress: await pub.getConnectedAddress(),
|
connectedAddress: await pub.getConnectedAddress(),
|
||||||
|
|
@ -1579,18 +1579,22 @@ export class OpenviduInstanceComponent {
|
||||||
iceConnectionState: pub.getICEConnectionState(),
|
iceConnectionState: pub.getICEConnectionState(),
|
||||||
signallingState: pub.getSignallingState(),
|
signallingState: pub.getSignallingState(),
|
||||||
},
|
},
|
||||||
subscriber: {
|
|
||||||
connectedAddress: await sub.getConnectedAddress(),
|
|
||||||
connectionState: sub.getConnectionState(),
|
|
||||||
iceConnectionState: sub.getICEConnectionState(),
|
|
||||||
signallingState: sub.getSignallingState(),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
RTCIceCandidateStats: {
|
RTCIceCandidateStats: {
|
||||||
publisher: await this.getPublisherRTCIceCandidateStats(),
|
publisher: await this.getPublisherRTCIceCandidateStats(),
|
||||||
subscriber: await this.getSubscriberRTCIceCandidateStats(),
|
}
|
||||||
},
|
};
|
||||||
},
|
if (!!sub) {
|
||||||
|
(info.PCTransports as any).subscriber = {
|
||||||
|
connectedAddress: await sub.getConnectedAddress(),
|
||||||
|
connectionState: sub.getConnectionState(),
|
||||||
|
iceConnectionState: sub.getICEConnectionState(),
|
||||||
|
signallingState: sub.getSignallingState(),
|
||||||
|
};
|
||||||
|
(info.RTCIceCandidateStats as any).subscriber = await this.getSubscriberRTCIceCandidateStats();
|
||||||
|
}
|
||||||
|
return JSON.stringify(
|
||||||
|
info,
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue