openvidu-test-e2e: AV1 in Participant Passthrough

egress-pro's Participant Passthrough records AV1 now. The two tests that
expected it to fail (av1FromTheStartNotSupportedTest,
av1PublishedLaterNotSupportedTest) become eight that expect a valid
recording: audio and video from the start (SVC L3T3_KEY), without SVC
(constant size), WebM, video published later, unpublished and published
again, published again as AV1 after VP8 (republishVideoWithAnotherCodec,
the VP8 -> H264 test's body as a helper), packet loss from the SFU and a
congested uplink (the SFU's spatial layer switches). The analysis needs no
change: the pinned ffmpeg decodes AV1 with libdav1d, the highest spatial
layer present in each unit.

Downloads go to target/e2e-downloads/<prefix><random>/ instead of /tmp:
a JUnit TestWatcher deletes a passing test's downloads and logs where a
failed one kept them, so the directory holds what a failure needs and the
egress-pro CI workflow can upload it.

Verified against the local deployment with the AV1 egress-pro image: the
8 tests and av1TrackEgressTest pass on mediasoup; the whole class, 71
tests, passes on pion.

Assisted-by: claude-code - Anthropic - Claude Fable 5.1
pull/918/head
pabloFuente 2026-10-22 13:25:01 +02:00
parent 5fd4502a1d
commit 32ed2b1096
2 changed files with 121 additions and 46 deletions

View File

@ -6,6 +6,7 @@ import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
@ -15,9 +16,13 @@ import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.TestWatcher;
import org.openqa.selenium.By;
import org.openqa.selenium.Keys;
import org.openqa.selenium.TakesScreenshot;
@ -39,10 +44,20 @@ import io.openvidu.test.browsers.utils.Ffmpeg;
import static org.openqa.selenium.OutputType.BASE64;
@ExtendWith(AbstractOpenViduTestappE2eTest.DeleteDownloadsOfPassedTests.class)
public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e {
/**
* Where the tests download the recordings they analyse. A test that passes
* deletes its downloads; one that fails keeps them, and the egress-pro CI
* workflow uploads this directory when the tests fail.
*/
protected static final Path DOWNLOADS_DIR = Path.of("target", "e2e-downloads").toAbsolutePath();
protected Collection<OpenViduTestappUser> testappUsers = new HashSet<>();
private final List<Path> downloadDirs = new ArrayList<>();
protected String getLivekitWssUrlFromReadyCheckContainer() {
String logs = commandLine.executeCommand("docker logs ready-check 2>&1", 30);
if (logs != null && !logs.isBlank()) {
@ -1157,7 +1172,7 @@ public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e {
*/
protected Path downloadRecording(JsonObject egress) throws Exception {
String filename = egress.get("file").getAsJsonObject().get("filename").getAsString();
Path file = Files.createTempDirectory("egress").resolve(Path.of(filename).getFileName());
Path file = newDownloadDir("egress").resolve(Path.of(filename).getFileName());
if (isMinioAvailable()) {
MinioClient.builder().endpoint("localhost", 9000, false).credentials("minioadmin", "minioadmin").build()
.downloadObject(DownloadObjectArgs.builder().bucket("openvidu-appdata").object(filename)
@ -1275,4 +1290,37 @@ public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e {
Files.deleteIfExists(err);
}
}
/** A new directory under {@link #DOWNLOADS_DIR} for what the running test downloads. */
protected Path newDownloadDir(String prefix) throws IOException {
Path dir = Files.createTempDirectory(Files.createDirectories(DOWNLOADS_DIR), prefix);
downloadDirs.add(dir);
return dir;
}
/** Deletes the downloads of a test that passed, and logs where a failed one kept them. */
public static class DeleteDownloadsOfPassedTests implements TestWatcher {
@Override
public void testSuccessful(ExtensionContext context) {
context.getTestInstance().filter(AbstractOpenViduTestappE2eTest.class::isInstance)
.map(AbstractOpenViduTestappE2eTest.class::cast).ifPresent(test -> {
for (Path dir : test.downloadDirs) {
try (Stream<Path> files = Files.walk(dir)) {
files.sorted(Comparator.reverseOrder()).forEach(f -> f.toFile().delete());
} catch (IOException e) {
log.warn("Could not delete the downloads in {}: {}", dir, e.getMessage());
}
}
});
}
@Override
public void testFailed(ExtensionContext context, Throwable cause) {
context.getTestInstance().filter(AbstractOpenViduTestappE2eTest.class::isInstance)
.map(AbstractOpenViduTestappE2eTest.class::cast).filter(test -> !test.downloadDirs.isEmpty())
.ifPresent(test -> log.info("Downloads of failed test {} kept in {}", context.getDisplayName(),
test.downloadDirs));
}
}
}

View File

@ -69,7 +69,7 @@ import okhttp3.Response;
* timeline (OpenVidu egress fork: the PASSTHROUGH preset extended from one
* track to an audio track plus a video track). It is requested with
* StartParticipantEgress, or with StartEgress and a MediaSource, and preset
* PASSTHROUGH.
* PASSTHROUGH. Video VP8, VP9 and AV1 (SVC included) and H.264, audio Opus.
*
* Publishers are openvidu-testapp instances whose camera, screen and microphone
* are replaced (src/test/resources/participant-passthrough-sync-media.js) by
@ -333,40 +333,27 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT
}
@Test
@DisplayName("AV1 from the start is not supported")
void av1FromTheStartNotSupportedTest() throws Exception {
log.info("Participant Passthrough: AV1 from the start is not supported");
String room = uniqueRoom("av1-start");
joinPublisher("chrome", room, "alice", "av1", true, true, true);
// like every other egress, it fails on a track it cannot handle when started
String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv"));
JsonObject info = waitUntilEgressEnds(egressId, 60);
Assertions.assertEquals("EGRESS_FAILED", info.get("status").getAsString(), "Unexpected egress end: " + info);
String error = info.has("error") ? info.get("error").getAsString() : "";
Assertions.assertTrue(error.toLowerCase(Locale.ROOT).contains("av1"),
"The egress error must name the AV1 codec: " + info);
@DisplayName("AV1 audio and video from the start")
void av1AudioAndVideoTest() throws Exception {
log.info("Participant Passthrough: AV1 audio and video from the start");
// SVC (L3T3_KEY, livekit-client's default for AV1): the egress may start on a
// low layer and moves up, as with simulcast
audioAndVideoFromTheStart("chrome", "av1", true, "mkv");
}
@Test
@DisplayName("AV1 published later is not supported")
void av1PublishedLaterNotSupportedTest() throws Exception {
log.info("Participant Passthrough: AV1 published later is not supported");
String room = uniqueRoom("av1-later");
Publisher alice = joinPublisher("chrome", room, "alice", "av1", true, true, false);
@DisplayName("AV1 without SVC")
void av1WithoutSvcTest() throws Exception {
log.info("Participant Passthrough: AV1 without SVC");
Recording recording = audioAndVideoFromTheStart("chrome", "av1", false, "mkv");
recording.video(0).assertConstantSize(CAMERA_WIDTH, CAMERA_HEIGHT);
}
String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv"));
waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20);
Thread.sleep(4000);
alice.addVideo();
// like every other egress, it fails on a track it cannot handle, whenever it
// is published
JsonObject info = waitUntilEgressEnds(egressId, 30);
Assertions.assertEquals("EGRESS_FAILED", info.get("status").getAsString(), "Unexpected egress end: " + info);
String error = info.has("error") ? info.get("error").getAsString() : "";
Assertions.assertTrue(error.toLowerCase(Locale.ROOT).contains("av1"),
"The egress error must name the AV1 codec: " + info);
@Test
@DisplayName("WebM file with AV1")
void webmAv1Test() throws Exception {
log.info("Participant Passthrough: WebM file with AV1");
audioAndVideoFromTheStart("chrome", "av1", true, "webm");
}
// ------------------------------------------------------------------------
@ -437,6 +424,13 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT
audioFirstThenVideo("h264");
}
@Test
@DisplayName("Audio first, AV1 video added later")
void audioFirstThenAv1VideoTest() throws Exception {
log.info("Participant Passthrough: audio first, AV1 video added later");
audioFirstThenVideo("av1");
}
@Test
@DisplayName("Video first, audio added later")
void videoFirstThenAudioTest() throws Exception {
@ -793,6 +787,13 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT
unpublishRepublishVideo("h264");
}
@Test
@DisplayName("Unpublish and publish again AV1 video")
void unpublishRepublishAv1VideoTest() throws Exception {
log.info("Participant Passthrough: unpublish and publish again AV1 video");
unpublishRepublishVideo("av1");
}
@Test
@DisplayName("Unpublish and publish again audio")
void unpublishRepublishAudioTest() throws Exception {
@ -876,31 +877,43 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT
@DisplayName("Publish video again with another codec")
void republishVideoWithAnotherCodecTest() throws Exception {
log.info("Participant Passthrough: publish video again with another codec");
String room = uniqueRoom("republish-codec");
Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, true, true);
republishVideoWithAnotherCodec("vp8", "h264");
}
@Test
@DisplayName("Publish video again as AV1")
void republishVideoAsAv1Test() throws Exception {
log.info("Participant Passthrough: publish video again as AV1");
republishVideoWithAnotherCodec("vp8", "av1");
}
private void republishVideoWithAnotherCodec(String first, String second) throws Exception {
String room = uniqueRoom("republish-codec-" + second);
Publisher alice = joinPublisher("chrome", room, "alice", first, true, true, true);
String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv"));
waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20);
Thread.sleep(5000);
alice.unpublishVideo();
alice.setVideoCodec("h264");
alice.setVideoCodec(second);
alice.addVideo();
Thread.sleep(7000);
// a VP8 track cannot continue as H264: the file gets a second video track
// a track of one codec cannot continue as another: the file gets a second
// video track
Recording recording = stopAndGetRecording(egressId);
recording.assertFinalized();
recording.assertTracks("audio:opus", "video:vp8", "video:h264");
recording.assertTracks("audio:opus", "video:" + first, "video:" + second);
recording.assertHealthy();
recording.assertStartTogether();
recording.assertAvSync(8);
FileTrack vp8 = recording.videoWithCodec("vp8");
FileTrack h264 = recording.videoWithCodec("h264");
Assertions.assertTrue(vp8.lastPacketTime() <= h264.firstPacketTime(),
"The H264 track must follow the VP8 track: " + vp8 + " / " + h264);
vp8.assertMarkers(3);
h264.assertMarkers(4);
h264.assertReachedSize(CAMERA_WIDTH, CAMERA_HEIGHT);
FileTrack before = recording.videoWithCodec(first);
FileTrack after = recording.videoWithCodec(second);
Assertions.assertTrue(before.lastPacketTime() <= after.firstPacketTime(),
"The " + second + " track must follow the " + first + " track: " + before + " / " + after);
before.assertMarkers(3);
after.assertMarkers(4);
after.assertReachedSize(CAMERA_WIDTH, CAMERA_HEIGHT);
Assertions.assertEquals(1, recording.ofType("video").stream().filter(s -> s.isDefault).count(),
"Exactly one video track must be the default one: " + recording.streams);
}
@ -1175,6 +1188,13 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT
networkTrouble(Trouble.EGRESS_LOSS, "h264");
}
@Test
@DisplayName("Packet loss from the SFU to the egress AV1")
void egressPacketLossAv1Test() throws Exception {
log.info("Participant Passthrough: packet loss from the SFU to the egress AV1");
networkTrouble(Trouble.EGRESS_LOSS, "av1");
}
@Test
@DisplayName("Packet loss on the publisher's uplink VP8")
void publisherPacketLossVp8Test() throws Exception {
@ -1210,6 +1230,13 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT
networkTrouble(Trouble.PUBLISHER_CONGESTION, "h264");
}
@Test
@DisplayName("Publisher's uplink congested AV1")
void publisherCongestionAv1Test() throws Exception {
log.info("Participant Passthrough: publisher's uplink congested AV1");
networkTrouble(Trouble.PUBLISHER_CONGESTION, "av1");
}
@Test
@DisplayName("Jitter on the publisher's uplink VP8")
void publisherJitterVp8Test() throws Exception {
@ -2520,7 +2547,7 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT
*/
private Recording getRecording(JsonObject egressInfo) throws Exception {
String key = fileKey(egressInfo);
Path local = Files.createTempDirectory("participant-passthrough").resolve(Paths.get(key).getFileName());
Path local = newDownloadDir("participant-passthrough").resolve(Paths.get(key).getFileName());
minio.downloadObject(DownloadObjectArgs.builder().bucket(MINIO_BUCKET).object(key)
.filename(local.toString()).build());
return Recording.analyze(local);
@ -2539,7 +2566,7 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT
*/
private Recording snapshotRecordingInProgress(String egressId) throws Exception {
String remote = fileBeingRecorded(egressId);
Path local = Files.createTempDirectory("participant-passthrough-live").resolve(Paths.get(remote).getFileName());
Path local = newDownloadDir("participant-passthrough-live").resolve(Paths.get(remote).getFileName());
Exec copy = Exec.run(60, "docker", "cp", EGRESS_CONTAINER + ":" + remote, local.toString());
Assertions.assertEquals(0, copy.exitCode, "Could not copy the file being recorded: " + copy.stderr);
return Recording.analyze(local);