From 32ed2b1096c3e33bd9a490ba7fe2a101cccaffed Mon Sep 17 00:00:00 2001 From: pabloFuente Date: Thu, 22 Oct 2026 13:25:01 +0200 Subject: [PATCH] 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// 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 --- .../e2e/AbstractOpenViduTestappE2eTest.java | 50 +++++++- .../e2e/OpenViduTestAppE2eEgressProTest.java | 117 +++++++++++------- 2 files changed, 121 insertions(+), 46 deletions(-) diff --git a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/AbstractOpenViduTestappE2eTest.java b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/AbstractOpenViduTestappE2eTest.java index 58f4abf10..90d0bc973 100644 --- a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/AbstractOpenViduTestappE2eTest.java +++ b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/AbstractOpenViduTestappE2eTest.java @@ -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 testappUsers = new HashSet<>(); + private final List 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 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)); + } + } } diff --git a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eEgressProTest.java b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eEgressProTest.java index ecf2ac001..71957cfb3 100644 --- a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eEgressProTest.java +++ b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eEgressProTest.java @@ -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);