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.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.Comparator;
import java.util.HashSet; import java.util.HashSet;
import java.util.Iterator; import java.util.Iterator;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@ -15,9 +16,13 @@ import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future; import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
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.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.By;
import org.openqa.selenium.Keys; import org.openqa.selenium.Keys;
import org.openqa.selenium.TakesScreenshot; import org.openqa.selenium.TakesScreenshot;
@ -39,10 +44,20 @@ import io.openvidu.test.browsers.utils.Ffmpeg;
import static org.openqa.selenium.OutputType.BASE64; import static org.openqa.selenium.OutputType.BASE64;
@ExtendWith(AbstractOpenViduTestappE2eTest.DeleteDownloadsOfPassedTests.class)
public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e { 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<>(); protected Collection<OpenViduTestappUser> testappUsers = new HashSet<>();
private final List<Path> downloadDirs = new ArrayList<>();
protected String getLivekitWssUrlFromReadyCheckContainer() { protected String getLivekitWssUrlFromReadyCheckContainer() {
String logs = commandLine.executeCommand("docker logs ready-check 2>&1", 30); String logs = commandLine.executeCommand("docker logs ready-check 2>&1", 30);
if (logs != null && !logs.isBlank()) { if (logs != null && !logs.isBlank()) {
@ -1157,7 +1172,7 @@ public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e {
*/ */
protected Path downloadRecording(JsonObject egress) throws Exception { protected Path downloadRecording(JsonObject egress) throws Exception {
String filename = egress.get("file").getAsJsonObject().get("filename").getAsString(); 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()) { if (isMinioAvailable()) {
MinioClient.builder().endpoint("localhost", 9000, false).credentials("minioadmin", "minioadmin").build() MinioClient.builder().endpoint("localhost", 9000, false).credentials("minioadmin", "minioadmin").build()
.downloadObject(DownloadObjectArgs.builder().bucket("openvidu-appdata").object(filename) .downloadObject(DownloadObjectArgs.builder().bucket("openvidu-appdata").object(filename)
@ -1275,4 +1290,37 @@ public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e {
Files.deleteIfExists(err); 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 * timeline (OpenVidu egress fork: the PASSTHROUGH preset extended from one
* track to an audio track plus a video track). It is requested with * track to an audio track plus a video track). It is requested with
* StartParticipantEgress, or with StartEgress and a MediaSource, and preset * 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 * Publishers are openvidu-testapp instances whose camera, screen and microphone
* are replaced (src/test/resources/participant-passthrough-sync-media.js) by * are replaced (src/test/resources/participant-passthrough-sync-media.js) by
@ -333,40 +333,27 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT
} }
@Test @Test
@DisplayName("AV1 from the start is not supported") @DisplayName("AV1 audio and video from the start")
void av1FromTheStartNotSupportedTest() throws Exception { void av1AudioAndVideoTest() throws Exception {
log.info("Participant Passthrough: AV1 from the start is not supported"); log.info("Participant Passthrough: AV1 audio and video from the start");
String room = uniqueRoom("av1-start"); // SVC (L3T3_KEY, livekit-client's default for AV1): the egress may start on a
joinPublisher("chrome", room, "alice", "av1", true, true, true); // low layer and moves up, as with simulcast
audioAndVideoFromTheStart("chrome", "av1", true, "mkv");
// 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);
} }
@Test @Test
@DisplayName("AV1 published later is not supported") @DisplayName("AV1 without SVC")
void av1PublishedLaterNotSupportedTest() throws Exception { void av1WithoutSvcTest() throws Exception {
log.info("Participant Passthrough: AV1 published later is not supported"); log.info("Participant Passthrough: AV1 without SVC");
String room = uniqueRoom("av1-later"); Recording recording = audioAndVideoFromTheStart("chrome", "av1", false, "mkv");
Publisher alice = joinPublisher("chrome", room, "alice", "av1", true, true, false); recording.video(0).assertConstantSize(CAMERA_WIDTH, CAMERA_HEIGHT);
}
String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv")); @Test
waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); @DisplayName("WebM file with AV1")
Thread.sleep(4000); void webmAv1Test() throws Exception {
alice.addVideo(); log.info("Participant Passthrough: WebM file with AV1");
audioAndVideoFromTheStart("chrome", "av1", true, "webm");
// 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);
} }
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
@ -437,6 +424,13 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT
audioFirstThenVideo("h264"); 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 @Test
@DisplayName("Video first, audio added later") @DisplayName("Video first, audio added later")
void videoFirstThenAudioTest() throws Exception { void videoFirstThenAudioTest() throws Exception {
@ -793,6 +787,13 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT
unpublishRepublishVideo("h264"); 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 @Test
@DisplayName("Unpublish and publish again audio") @DisplayName("Unpublish and publish again audio")
void unpublishRepublishAudioTest() throws Exception { void unpublishRepublishAudioTest() throws Exception {
@ -876,31 +877,43 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT
@DisplayName("Publish video again with another codec") @DisplayName("Publish video again with another codec")
void republishVideoWithAnotherCodecTest() throws Exception { void republishVideoWithAnotherCodecTest() throws Exception {
log.info("Participant Passthrough: publish video again with another codec"); log.info("Participant Passthrough: publish video again with another codec");
String room = uniqueRoom("republish-codec"); republishVideoWithAnotherCodec("vp8", "h264");
Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, true, true); }
@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")); String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv"));
waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20); waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20);
Thread.sleep(5000); Thread.sleep(5000);
alice.unpublishVideo(); alice.unpublishVideo();
alice.setVideoCodec("h264"); alice.setVideoCodec(second);
alice.addVideo(); alice.addVideo();
Thread.sleep(7000); 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 recording = stopAndGetRecording(egressId);
recording.assertFinalized(); recording.assertFinalized();
recording.assertTracks("audio:opus", "video:vp8", "video:h264"); recording.assertTracks("audio:opus", "video:" + first, "video:" + second);
recording.assertHealthy(); recording.assertHealthy();
recording.assertStartTogether(); recording.assertStartTogether();
recording.assertAvSync(8); recording.assertAvSync(8);
FileTrack vp8 = recording.videoWithCodec("vp8"); FileTrack before = recording.videoWithCodec(first);
FileTrack h264 = recording.videoWithCodec("h264"); FileTrack after = recording.videoWithCodec(second);
Assertions.assertTrue(vp8.lastPacketTime() <= h264.firstPacketTime(), Assertions.assertTrue(before.lastPacketTime() <= after.firstPacketTime(),
"The H264 track must follow the VP8 track: " + vp8 + " / " + h264); "The " + second + " track must follow the " + first + " track: " + before + " / " + after);
vp8.assertMarkers(3); before.assertMarkers(3);
h264.assertMarkers(4); after.assertMarkers(4);
h264.assertReachedSize(CAMERA_WIDTH, CAMERA_HEIGHT); after.assertReachedSize(CAMERA_WIDTH, CAMERA_HEIGHT);
Assertions.assertEquals(1, recording.ofType("video").stream().filter(s -> s.isDefault).count(), Assertions.assertEquals(1, recording.ofType("video").stream().filter(s -> s.isDefault).count(),
"Exactly one video track must be the default one: " + recording.streams); "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"); 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 @Test
@DisplayName("Packet loss on the publisher's uplink VP8") @DisplayName("Packet loss on the publisher's uplink VP8")
void publisherPacketLossVp8Test() throws Exception { void publisherPacketLossVp8Test() throws Exception {
@ -1210,6 +1230,13 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT
networkTrouble(Trouble.PUBLISHER_CONGESTION, "h264"); 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 @Test
@DisplayName("Jitter on the publisher's uplink VP8") @DisplayName("Jitter on the publisher's uplink VP8")
void publisherJitterVp8Test() throws Exception { void publisherJitterVp8Test() throws Exception {
@ -2520,7 +2547,7 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT
*/ */
private Recording getRecording(JsonObject egressInfo) throws Exception { private Recording getRecording(JsonObject egressInfo) throws Exception {
String key = fileKey(egressInfo); 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) minio.downloadObject(DownloadObjectArgs.builder().bucket(MINIO_BUCKET).object(key)
.filename(local.toString()).build()); .filename(local.toString()).build());
return Recording.analyze(local); return Recording.analyze(local);
@ -2539,7 +2566,7 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT
*/ */
private Recording snapshotRecordingInProgress(String egressId) throws Exception { private Recording snapshotRecordingInProgress(String egressId) throws Exception {
String remote = fileBeingRecorded(egressId); 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()); 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); Assertions.assertEquals(0, copy.exitCode, "Could not copy the file being recorded: " + copy.stderr);
return Recording.analyze(local); return Recording.analyze(local);