openvidu-testapp & openvidu-test-e2e: add Data Track e2e test

pull/894/head
pabloFuente 2026-04-21 21:49:36 +02:00
parent 8b3bfc3b69
commit 66001d8f11
9 changed files with 486 additions and 3 deletions

View File

@ -381,6 +381,127 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
gracefullyLeaveParticipants(user, 2);
}
@Test
@DisplayName("Data Tracks publish, subscribe, send and receive")
void dataTracksTest() throws Exception {
OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("chrome");
log.info("Data Tracks publish, subscribe, send and receive");
// Two participants, no audio/video publishing
for (int i = 0; i < 2; i++) {
WebElement addUserBtn = user.getDriver().findElement(By.id("add-user-btn"));
addUserBtn.click();
user.getDriver().findElement(By.cssSelector("#openvidu-instance-" + i + " .subscriber-checkbox")).click();
user.getDriver().findElement(By.cssSelector("#openvidu-instance-" + i + " .publisher-checkbox")).click();
}
user.getDriver().findElements(By.className("connect-btn")).forEach(el -> el.sendKeys(Keys.ENTER));
user.getEventManager().waitUntilEventReaches("signalConnected", "RoomEvent", 2);
user.getEventManager().waitUntilEventReaches("connected", "RoomEvent", 2);
user.getEventManager().waitUntilEventReaches("participantConnected", "RoomEvent", 1);
user.getEventManager().waitUntilEventReaches("participantActive", "RoomEvent", 1);
// Participant 0 publishes a data track
user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 .add-data-track-btn")).click();
// Wait for localDataTrackPublished on participant 0 and dataTrackPublished on participant 1
user.getEventManager().waitUntilEventReaches(0, "localDataTrackPublished", "RoomEvent", 1);
user.getEventManager().waitUntilEventReaches(1, "dataTrackPublished", "RoomEvent", 1);
// Verify event descriptions
user.getDriver().findElements(By.cssSelector(".localDataTrackPublished-TestParticipant0 .event-content"))
.forEach(el -> Assertions.assertTrue(el.getText().contains("data_track_1"),
"Expected localDataTrackPublished to contain track name 'data_track_1'"));
user.getDriver().findElements(By.cssSelector(".dataTrackPublished-TestParticipant1 .event-content"))
.forEach(el -> Assertions.assertTrue(
el.getText().contains("TestParticipant0") && el.getText().contains("data_track_1"),
"Expected dataTrackPublished to contain publisher identity and track name"));
// Participant 0 does not receive its own dataTrackPublished
Assertions.assertEquals(0, user.getEventManager().getNumEvents(0, "dataTrackPublished-RoomEvent").get(),
"Publisher should not receive dataTrackPublished for its own data track");
// Participant 0 sends data frames
org.openqa.selenium.JavascriptExecutor js = (org.openqa.selenium.JavascriptExecutor) user.getDriver();
js.executeScript("document.querySelector('#openvidu-instance-0 .send-data-frame-btn').click()");
js.executeScript("document.querySelector('#openvidu-instance-0 .send-data-frame-btn').click()");
js.executeScript("document.querySelector('#openvidu-instance-0 .send-data-frame-btn').click()");
// Wait for frames to arrive at participant 1
Thread.sleep(1000);
Long frameCount = (Long) js.executeScript(
"var el = document.querySelector('#openvidu-instance-1 .data-track-frame-count');" +
"return el ? parseInt(el.textContent) : 0;");
Assertions.assertEquals(frameCount, 3, "Expected 3 data track frames received, got " + frameCount);
String lastPayload = (String) js.executeScript(
"var el = document.querySelector('#openvidu-instance-1 .data-track-last-payload');" +
"return el ? el.textContent : '';");
Assertions.assertTrue(lastPayload.contains("DataTrackFrame from TestParticipant0"),
"Expected last payload to contain 'DataTrackFrame from TestParticipant0', got: " + lastPayload);
// Participant 0 unpublishes the data track
js.executeScript("document.querySelector('#openvidu-instance-0 .unpublish-data-track-btn').click()");
// Wait for unpublish events
user.getEventManager().waitUntilEventReaches(0, "localDataTrackUnpublished", "RoomEvent", 1);
user.getEventManager().waitUntilEventReaches(1, "dataTrackUnpublished", "RoomEvent", 1);
// Participant 0 does not receive its own dataTrackUnpublished
Assertions.assertEquals(0, user.getEventManager().getNumEvents(0, "dataTrackUnpublished-RoomEvent").get(),
"Publisher should not receive dataTrackUnpublished for its own data track");
user.getEventManager().clearAllCurrentEvents();
// Now participant 1 publishes a data track with custom name (bidirectional test)
String customTrackName = "my_custom_data_track";
user.getDriver().findElement(By.cssSelector("#openvidu-instance-1 .options-data-track-btn")).click();
Thread.sleep(500);
WebElement trackNameInput = user.getDriver().findElement(By.id("dataTrack-name"));
trackNameInput.clear();
trackNameInput.sendKeys(customTrackName);
user.getDriver().findElement(By.id("close-dialog-btn")).click();
Thread.sleep(500);
user.getDriver().findElement(By.cssSelector("#openvidu-instance-1 .add-data-track-btn")).click();
user.getEventManager().waitUntilEventReaches(1, "localDataTrackPublished", "RoomEvent", 1);
user.getEventManager().waitUntilEventReaches(0, "dataTrackPublished", "RoomEvent", 1);
// Verify participant 0 sees the custom track name on the remote data track
Thread.sleep(500);
String remoteTrackName = (String) js.executeScript(
"var el = document.querySelector('#openvidu-instance-0 .data-track-name');" +
"return el ? el.textContent.trim() : '';");
Assertions.assertEquals(customTrackName, remoteTrackName,
"Remote data track name should be '" + customTrackName + "', got: " + remoteTrackName);
// Participant 1 sends a data frame
js.executeScript("document.querySelector('#openvidu-instance-1 .send-data-frame-btn').click()");
// Wait for frame to arrive at participant 0
Thread.sleep(1000);
Long frameCount0 = (Long) js.executeScript(
"var el = document.querySelector('#openvidu-instance-0 .data-track-frame-count');" +
"return el ? parseInt(el.textContent) : 0;");
Assertions.assertTrue(frameCount0 >= 1,
"Expected at least 1 data track frame received by participant 0, got " + frameCount0);
String lastPayload0 = (String) js.executeScript(
"var el = document.querySelector('#openvidu-instance-0 .data-track-last-payload');" +
"return el ? el.textContent : '';");
Assertions.assertTrue(lastPayload0.contains("DataTrackFrame from TestParticipant1"),
"Expected last payload to contain 'DataTrackFrame from TestParticipant1', got: " + lastPayload0);
// Participant 1 unpublishes
js.executeScript("document.querySelector('#openvidu-instance-1 .unpublish-data-track-btn').click()");
user.getEventManager().waitUntilEventReaches(1, "localDataTrackUnpublished", "RoomEvent", 1);
user.getEventManager().waitUntilEventReaches(0, "dataTrackUnpublished", "RoomEvent", 1);
gracefullyLeaveParticipants(user, 2);
}
@Test
@DisplayName("One2One only audio")
void oneToOneOnlyAudioSession() throws Exception {

View File

@ -0,0 +1,52 @@
.parent-div {
display: grid;
margin-bottom: 4px;
background-color: #fcfcfc;
padding: 2px;
border-radius: 2px;
}
.data-track-name {
font-size: 10px;
}
.bottom-div {
display: flex;
justify-content: space-around;
margin-bottom: -2px;
}
.data-btn {
border: none;
background: rgba(255, 255, 255, 0.75);
cursor: pointer;
padding: 0;
height: 16px;
float: left;
}
.data-btn:hover {
color: #4d4d4d;
}
.data-btn mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
line-height: 16px;
}
.data-track-info {
font-size: 10px;
padding: 2px 0;
}
.data-track-frame-count {
font-weight: bold;
margin-right: 4px;
}
.data-track-last-payload {
color: #555;
word-break: break-all;
}

View File

@ -0,0 +1,25 @@
<div class="parent-div">
<span class="data-track-name">{{getTrackName()}}</span>
<div class="bottom-div">
@if (localParticipant && localDataTrack) {
<button (click)="sendDataFrame()" class="data-btn send-data-frame-btn" matTooltip="Send data frame"
matTooltipClass="custom-tooltip">
<mat-icon aria-label="Send data frame" class="mat-icon material-icons" role="img"
aria-hidden="true">send</mat-icon>
</button>
}
@if (localParticipant && localDataTrack) {
<button (click)="unpublishDataTrack()" class="data-btn unpublish-data-track-btn" matTooltip="Unpublish data track"
matTooltipClass="custom-tooltip">
<mat-icon aria-label="Unpublish data track" class="mat-icon material-icons" role="img"
aria-hidden="true">stop</mat-icon>
</button>
}
</div>
@if (frameCount > 0) {
<div class="data-track-info">
<span class="data-track-frame-count">{{frameCount}}</span>
<span class="data-track-last-payload">{{lastPayload}}</span>
</div>
}
</div>

View File

@ -0,0 +1,98 @@
import { Component, EventEmitter, Input, Output, ChangeDetectorRef } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import {
LocalDataTrack,
LocalParticipant,
RemoteDataTrack,
} from 'livekit-client';
import {
TestAppEvent,
TestFeedService,
} from 'src/app/services/test-feed.service';
@Component({
selector: 'app-data-track',
templateUrl: './data-track.component.html',
styleUrl: './data-track.component.css',
imports: [MatIconModule, MatTooltipModule],
})
export class DataTrackComponent {
@Input()
localParticipant: LocalParticipant | undefined;
@Input()
index: number;
@Input()
localDataTrack?: LocalDataTrack;
@Input()
remoteDataTrack?: RemoteDataTrack;
@Output()
newTrackEvent = new EventEmitter<TestAppEvent>();
@Output()
trackUnpublished = new EventEmitter<LocalDataTrack>();
// Received frame info
frameCount: number = 0;
lastPayload: string = '';
private decoder = new TextDecoder();
constructor(
private testFeedService: TestFeedService,
private cdr: ChangeDetectorRef
) {}
ngOnInit() {
if (this.remoteDataTrack) {
this.subscribeToRemoteDataTrack(this.remoteDataTrack);
}
}
sendDataFrame() {
if (this.localDataTrack) {
const payload = new TextEncoder().encode(
`DataTrackFrame from ${this.localParticipant?.identity}`
);
this.localDataTrack.tryPush({ payload });
}
}
async unpublishDataTrack() {
if (this.localDataTrack) {
const track = this.localDataTrack;
await track.unpublish();
this.trackUnpublished.emit(track);
}
}
private async subscribeToRemoteDataTrack(track: RemoteDataTrack) {
const stream = track.subscribe();
const reader = stream.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
this.frameCount++;
this.lastPayload = this.decoder.decode(value.payload);
this.cdr.detectChanges();
}
} catch (_) {
// Stream closed
}
}
getTrackName(): string {
if (this.localDataTrack?.info) {
return this.localDataTrack.info.name;
}
if (this.remoteDataTrack) {
return this.remoteDataTrack.info.name;
}
return '';
}
}

View File

@ -233,6 +233,16 @@
</mat-form-field>
</div>
}
@if (dataTrackName !== undefined) {
<mat-divider></mat-divider>
<div>
<label>DataTrackOptions</label><br>
<mat-form-field>
<mat-label>Track Name</mat-label>
<input matInput id="dataTrack-name" placeholder="data_track_1" [(ngModel)]="dataTrackName"/>
</mat-form-field>
</div>
}
</mat-dialog-content>
<mat-dialog-actions>

View File

@ -38,6 +38,8 @@ export class OptionsDialogComponent {
allowDisablingVideo = true;
allowDisablingScreen = true;
dataTrackName?: string;
videoOption: true | false | 'custom';
audioOption: true | false | 'custom';
screenOption: true | false | 'custom';
@ -62,6 +64,7 @@ export class OptionsDialogComponent {
allowDisablingAudio?: boolean;
allowDisablingVideo?: boolean;
allowDisablingScreen?: boolean;
dataTrackName?: string;
}>(MAT_DIALOG_DATA);
constructor(
@ -114,6 +117,9 @@ export class OptionsDialogComponent {
if (this.data.allowDisablingScreen === false) {
this.allowDisablingScreen = false;
}
if (this.data.dataTrackName !== undefined) {
this.dataTrackName = this.data.dataTrackName;
}
Room.getLocalDevices('videoinput').then((devices) => {
this.inputVideoDevices = devices;
});
@ -152,6 +158,7 @@ export class OptionsDialogComponent {
createLocalTracksOptions: this.createLocalTracksOptions,
screenShareCaptureOptions: this.screenShareCaptureOptions,
trackPublishOptions: this.trackPublishOptions,
dataTrackName: this.dataTrackName,
});
}

View File

@ -21,6 +21,7 @@ import {
DataPublishOptions,
DisconnectReason,
LocalAudioTrack,
LocalDataTrack,
LocalParticipant,
LocalTrack,
LocalTrackPublication,
@ -28,6 +29,7 @@ import {
MediaDeviceFailure,
Participant,
RemoteAudioTrack,
RemoteDataTrack,
RemoteParticipant,
RemoteTrack,
RemoteTrackPublication,
@ -143,7 +145,7 @@ export class OpenviduInstanceComponent {
this.participantName += this.index;
if (this.roomConf.startSession) {
const token = await this.roomApiService.createToken(
{ roomJoin: true },
{ roomJoin: true, canPublishData: true },
this.participantName,
this.roomName
);
@ -163,7 +165,7 @@ export class OpenviduInstanceComponent {
async createTokenAndConnectRoom() {
this.connectRoom(
await this.roomApiService.createToken(
{ roomJoin: true },
{ roomJoin: true, canPublishData: true },
this.participantName,
this.roomName
)
@ -1078,6 +1080,86 @@ export class OpenviduInstanceComponent {
);
}
}
if (
firstTime ||
this.roomEvents.get(RoomEvent.DataTrackPublished) !==
oldValues.get(RoomEvent.DataTrackPublished)
) {
this.room?.removeAllListeners(RoomEvent.DataTrackPublished);
if (this.roomEvents.get(RoomEvent.DataTrackPublished)) {
this.room!.on(
RoomEvent.DataTrackPublished,
(track: RemoteDataTrack) => {
this.updateEventList(
RoomEvent.DataTrackPublished,
{ name: track.info.name, sid: track.info.sid, publisherIdentity: track.publisherIdentity },
`${track.publisherIdentity} (${track.info.name})`
);
}
);
}
}
if (
firstTime ||
this.roomEvents.get(RoomEvent.DataTrackUnpublished) !==
oldValues.get(RoomEvent.DataTrackUnpublished)
) {
this.room?.removeAllListeners(RoomEvent.DataTrackUnpublished);
if (this.roomEvents.get(RoomEvent.DataTrackUnpublished)) {
this.room!.on(
RoomEvent.DataTrackUnpublished,
(sid: string) => {
this.updateEventList(
RoomEvent.DataTrackUnpublished,
{ sid },
`sid: ${sid}`
);
}
);
}
}
if (
firstTime ||
this.roomEvents.get(RoomEvent.LocalDataTrackPublished) !==
oldValues.get(RoomEvent.LocalDataTrackPublished)
) {
this.room?.removeAllListeners(RoomEvent.LocalDataTrackPublished);
if (this.roomEvents.get(RoomEvent.LocalDataTrackPublished)) {
this.room!.on(
RoomEvent.LocalDataTrackPublished,
(track: LocalDataTrack) => {
this.updateEventList(
RoomEvent.LocalDataTrackPublished,
{ name: track.info?.name, sid: track.info?.sid },
`${track.info?.name}`
);
}
);
}
}
if (
firstTime ||
this.roomEvents.get(RoomEvent.LocalDataTrackUnpublished) !==
oldValues.get(RoomEvent.LocalDataTrackUnpublished)
) {
this.room?.removeAllListeners(RoomEvent.LocalDataTrackUnpublished);
if (this.roomEvents.get(RoomEvent.LocalDataTrackUnpublished)) {
this.room!.on(
RoomEvent.LocalDataTrackUnpublished,
(sid: string) => {
this.updateEventList(
RoomEvent.LocalDataTrackUnpublished,
{ sid },
`sid: ${sid}`
);
}
);
}
}
}
updateEventList(

View File

@ -3,6 +3,18 @@
<p class="participant-identity" [ngClass]="{'local-participant-identity' : participant.isLocal}">
{{participant.identity}}</p>
<div class="participant-buttons">
@if (participant.isLocal) {
<button class="add-data-track-btn" (click)="addDataTrack()" title="New data track"
matTooltip="New data track" matTooltipClass="custom-tooltip">
<mat-icon aria-label="New data track">stream</mat-icon>
</button>
}
@if (participant.isLocal) {
<button class="options-data-track-btn" (click)="openDataTrackOptionsDialog()"
title="Data track options" matTooltip="Data track options" matTooltipClass="custom-tooltip">
<mat-icon aria-label="Data track options">more_vert</mat-icon>
</button>
}
@if (participant.isLocal) {
<button class="add-audio-btn" (click)="addAudioTrack()" title="New audio track"
matTooltip="New audio track" matTooltipClass="custom-tooltip">
@ -71,6 +83,24 @@
}
</mat-accordion>
</div>
<div class="data-tracks-container">
@for (track of localDataTracks; track track.info?.sid) {
<app-data-track
[index]="index"
[localDataTrack]="track"
[localParticipant]="localParticipant"
(trackUnpublished)="onLocalDataTrackUnpublished($event)"
(newTrackEvent)="onTrackEvent($event)">
</app-data-track>
}
@for (track of remoteDataTracks; track track.info.sid) {
<app-data-track
[index]="index"
[remoteDataTrack]="track"
(newTrackEvent)="onTrackEvent($event)">
</app-data-track>
}
</div>
<div class="audio-tracks-container">
@for (trackPublication of participant.audioTrackPublications| keyvalue; track trackPublication) {
<app-audio-track

View File

@ -10,15 +10,18 @@ import {
CreateLocalTracksOptions,
DataPacket_Kind,
LocalAudioTrack,
LocalDataTrack,
LocalParticipant,
LocalTrack,
LocalTrackPublication,
LocalVideoTrack,
Participant,
ParticipantEvent,
RemoteDataTrack,
RemoteTrack,
RemoteTrackPublication,
Room,
RoomEvent,
ScreenShareCaptureOptions,
SubscriptionError,
Track,
@ -38,13 +41,14 @@ import {
import { OptionsDialogComponent } from '../dialogs/options-dialog/options-dialog.component';
import { VideoTrackComponent } from '../video-track/video-track.component';
import { AudioTrackComponent } from '../audio-track/audio-track.component';
import { DataTrackComponent } from '../data-track/data-track.component';
import { ParticipantEventCallbacks } from 'node_modules/livekit-client/dist/src/room/participant/Participant';
@Component({
selector: 'app-participant',
templateUrl: './participant.component.html',
styleUrl: './participant.component.css',
imports: [NgClass, KeyValuePipe, MatIconModule, MatTooltipModule, MatExpansionModule, VideoTrackComponent, AudioTrackComponent],
imports: [NgClass, KeyValuePipe, MatIconModule, MatTooltipModule, MatExpansionModule, VideoTrackComponent, AudioTrackComponent, DataTrackComponent],
})
export class ParticipantComponent {
@Input()
@ -66,6 +70,11 @@ export class ParticipantComponent {
events: TestAppEvent[] = [];
localDataTracks: LocalDataTrack[] = [];
remoteDataTracks: RemoteDataTrack[] = [];
dataTrackCounter: number = 1;
dataTrackName: string = 'data_track_1';
createLocalTracksOptions: CreateLocalTracksOptions;
screenShareCaptureOptions: ScreenShareCaptureOptions = {};
trackPublishOptions?: TrackPublishOptions;
@ -91,6 +100,9 @@ export class ParticipantComponent {
this.trackPublishOptions = JSON.parse(
JSON.stringify(this.room.options.publishDefaults)
);
if (!this.participant.isLocal) {
this.setupDataTrackListeners();
}
}
onTrackEvent(event: TestAppEvent) {
@ -98,6 +110,31 @@ export class ParticipantComponent {
this.cdr.detectChanges();
}
async addDataTrack() {
const localParticipant = this.participant as LocalParticipant;
const track = await localParticipant.publishDataTrack({ name: this.dataTrackName });
this.localDataTracks.push(track);
this.dataTrackCounter++;
this.dataTrackName = 'data_track_' + this.dataTrackCounter;
this.cdr.detectChanges();
}
openDataTrackOptionsDialog() {
const dialogRef = this.dialog.open(OptionsDialogComponent, {
data: { dataTrackName: this.dataTrackName },
});
dialogRef.afterClosed().subscribe((result) => {
if (!!result) {
this.dataTrackName = result.dataTrackName;
}
});
}
onLocalDataTrackUnpublished(track: LocalDataTrack) {
this.localDataTracks = this.localDataTracks.filter((t) => t !== track);
this.cdr.detectChanges();
}
async addVideoTrack() {
const options =
this.createLocalTracksOptions.video === true
@ -479,4 +516,25 @@ export class ParticipantComponent {
sendDataLossy() {
this.sendLossyDataToOneParticipant.emit(this.participant.identity);
}
private setupDataTrackListeners() {
this.room.on(
RoomEvent.DataTrackPublished,
(track: RemoteDataTrack) => {
if (track.publisherIdentity === this.participant.identity) {
this.remoteDataTracks.push(track);
this.cdr.detectChanges();
}
}
);
this.room.on(
RoomEvent.DataTrackUnpublished,
(sid: string) => {
this.remoteDataTracks = this.remoteDataTracks.filter(
(t) => t.info.sid !== sid
);
this.cdr.detectChanges();
}
);
}
}