19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,81 @@
import {
click,
contains,
start,
startServer,
openDiscuss,
mockGetMedia,
onlineTest,
defineMailModels,
} from "@mail/../tests/mail_test_helpers";
import { onRpc } from "@web/../tests/web_test_helpers";
import { PeerToPeer, UPDATE_EVENT } from "@mail/discuss/call/common/peer_to_peer";
defineMailModels();
function connectionReady(p2p) {
return new Promise((resolve) => {
p2p.addEventListener("update", ({ detail }) => {
if (
detail.name === UPDATE_EVENT.CONNECTION_CHANGE &&
detail.payload.state === "connected"
) {
resolve();
}
});
});
}
async function mockPeerToPeerCallEnvironment({ channelId, remoteSessionId }) {
const env = await start();
const rtc = env.services["discuss.rtc"];
const localUserP2P = env.services["discuss.p2p"];
const remoteUserP2P = new PeerToPeer({
notificationRoute: "/mail/rtc/session/notify_call_members",
});
remoteUserP2P.connect(remoteSessionId, channelId);
onRpc("/mail/rtc/session/notify_call_members", async (req) => {
const {
params: { peer_notifications },
} = await req.json();
for (const [sender, , message] of peer_notifications) {
/**
* This is a simplification, if more than 2 users we should check notification.target to know which user
* should get the notification.
*/
if (sender === rtc.selfSession.id) {
await remoteUserP2P.handleNotification(sender, message);
} else {
await localUserP2P.handleNotification(sender, message);
}
}
});
const localUserConnected = connectionReady(localUserP2P);
const remoteUserConnected = connectionReady(remoteUserP2P);
return { localUserConnected, remoteUserConnected };
}
onlineTest("Can join a call in p2p", async (assert) => {
mockGetMedia();
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const remoteSessionId = pyEnv["discuss.channel.rtc.session"].create({
channel_member_id: pyEnv["discuss.channel.member"].create({
channel_id: channelId,
partner_id: pyEnv["res.partner"].create({ name: "Remote" }),
}),
channel_id: channelId,
});
const { localUserConnected, remoteUserConnected } = await mockPeerToPeerCallEnvironment({
channelId,
remoteSessionId,
});
await openDiscuss(channelId);
await click("[title='Join Call']");
await contains(".o-discuss-Call");
await contains(".o-discuss-CallParticipantCard[title='Remote']");
await Promise.all([localUserConnected, remoteUserConnected]);
await contains("span[data-connection-state='connected']");
});

View file

@ -0,0 +1,103 @@
import {
click,
contains,
defineMailModels,
mockGetMedia,
mockPermissionsPrompt,
openDiscuss,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
describe.current.tags("desktop");
defineMailModels();
test("Starting a video call asks for permissions", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
mockGetMedia();
mockPermissionsPrompt();
const env = await start();
const rtc = env.services["discuss.rtc"];
await openDiscuss(channelId);
await click("[title='Start Video Call']");
await contains(".modal[role='dialog']", { count: 1 });
rtc.cameraPermission = "granted";
await click(".modal-footer button", { text: "Use Camera" });
await contains(".o-discuss-CallActionList button[title='Stop camera']");
});
test("Turning on the microphone asks for permissions", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
mockGetMedia();
mockPermissionsPrompt();
const env = await start();
const rtc = env.services["discuss.rtc"];
await openDiscuss(channelId);
await click("[title='Start Call']");
await contains(".o-discuss-CallActionList button[title='Turn camera on']");
await click(".o-discuss-CallActionList button[title='Unmute']");
await contains(".modal[role='dialog']", { count: 1 });
rtc.microphonePermission = "granted";
await click(".modal-footer button", { text: "Use Microphone" });
await contains(".o-discuss-CallActionList button[title='Mute']");
await contains(".o-discuss-CallActionList button[title='Turn camera on']");
});
test("Turning on the camera asks for permissions", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
mockGetMedia();
mockPermissionsPrompt();
const env = await start();
const rtc = env.services["discuss.rtc"];
await openDiscuss(channelId);
await click("[title='Start Call']");
await click(".o-discuss-CallActionList button[title='Turn camera on']");
await contains(".modal[role='dialog']", { count: 1 });
rtc.cameraPermission = "granted";
await click(".modal-footer button", { text: "Use Camera" });
await contains(".o-discuss-CallActionList button[title='Stop camera']");
});
test("Turn on both microphone and camera from permission dialog", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
mockGetMedia();
mockPermissionsPrompt();
const env = await start();
const rtc = env.services["discuss.rtc"];
await openDiscuss(channelId);
await click("[title='Start Call']");
await contains(".o-discuss-CallActionList button[title='Turn camera on']");
await click(".o-discuss-CallActionList button[title='Turn camera on']");
await contains(".modal[role='dialog']", { count: 1 });
rtc.microphonePermission = "granted";
rtc.cameraPermission = "granted";
await click(".modal-footer button", { text: "Use microphone and camera" });
await contains(".o-discuss-CallActionList button[title='Stop camera']");
await contains(".o-discuss-CallActionList button[title='Mute']");
});
test("Combined mic+camera button only shown when both permissions not granted", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
mockGetMedia();
mockPermissionsPrompt();
const env = await start();
const rtc = env.services["discuss.rtc"];
await openDiscuss(channelId);
await click("[title='Start Call']");
await click(".o-discuss-CallActionList button[title='Turn camera on']");
await contains(".modal-footer button", { count: 2 });
await contains(".modal-footer button", { text: "Use microphone and camera" });
await contains(".modal-footer button", { text: "Use Camera" });
rtc.cameraPermission = "granted";
await click(".modal-footer button", { text: "Use Camera" });
await click(".o-discuss-CallActionList button[title='Unmute']");
await contains(".modal-footer button");
await contains(".modal-footer button", { text: "Use Microphone" });
});

View file

@ -0,0 +1,22 @@
import {
click,
contains,
defineMailModels,
openDiscuss,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
describe.current.tags("desktop");
defineMailModels();
test("Call has Picture-in-picture feature", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
await start();
await openDiscuss(channelId);
await click("[title='Start Call']");
await contains(".o-discuss-Call");
await contains(".o-discuss-Call-layoutActions button[title='Picture in Picture']");
});

View file

@ -0,0 +1,131 @@
import {
click,
contains,
defineMailModels,
editInput,
openDiscuss,
patchUiSize,
SIZES,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, test, expect } from "@odoo/hoot";
import { advanceTime } from "@odoo/hoot-mock";
import { asyncStep, patchWithCleanup, waitForSteps } from "@web/../tests/web_test_helpers";
import { browser } from "@web/core/browser/browser";
describe.current.tags("desktop");
defineMailModels();
test("Renders the call settings", async () => {
patchWithCleanup(browser.navigator.mediaDevices, {
enumerateDevices: () =>
Promise.resolve([
{
deviceId: "mockAudioDeviceId",
kind: "audioinput",
label: "mockAudioDeviceLabel",
},
{
deviceId: "mockVideoDeviceId",
kind: "videoinput",
label: "mockVideoDeviceLabel",
},
]),
});
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "test" });
patchUiSize({ size: SIZES.SM });
const env = await start();
const rtc = env.services["discuss.rtc"];
await openDiscuss(channelId);
// dropdown requires an extra delay before click (because handler is registered in useEffect)
await contains("[title='Open Actions Menu']");
await click("[title='Open Actions Menu']");
await click(".o-dropdown-item", { text: "Call Settings" });
await contains(".o-discuss-CallSettings");
await contains("label[aria-label='Camera']");
await contains("label[aria-label='Microphone']");
await contains("label[aria-label='Audio Output']");
await contains("option", { textContent: "Permission Needed", count: 3 });
rtc.microphonePermission = "granted";
await contains("option[value=mockAudioDeviceId]");
rtc.cameraPermission = "granted";
await contains("option[value=mockVideoDeviceId]");
await contains("button", { text: "Voice Detection" });
await contains("button", { text: "Push to Talk" });
await contains("span", { text: "Voice detection sensitivity" });
await contains("button", { text: "Test" });
await contains("label", { text: "Show video participants only" });
await contains("label", { text: "Blur video background" });
});
test("activate push to talk", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "test" });
patchUiSize({ size: SIZES.SM });
await start();
await openDiscuss(channelId);
// dropdown requires an extra delay before click (because handler is registered in useEffect)
await contains("[title='Open Actions Menu']");
await click("[title='Open Actions Menu']");
await click(".o-dropdown-item", { text: "Call Settings" });
await click("button", { text: "Push to Talk" });
await contains("i[aria-label='Register new key']");
await contains("label", { text: "Delay after releasing push-to-talk" });
await contains("label", { text: "Voice detection sensitivity", count: 0 });
});
test("activate blur", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "test" });
patchUiSize({ size: SIZES.SM });
await start();
await openDiscuss(channelId);
// dropdown requires an extra delay before click (because handler is registered in useEffect)
await contains("[title='Open Actions Menu']");
await click("[title='Open Actions Menu']");
await click(".o-dropdown-item", { text: "Call Settings" });
await click("input[title='Blur video background']");
await contains("label", { text: "Blur video background" });
await contains("label", { text: "Edge blur intensity" });
});
test("local storage for call settings", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "test" });
localStorage.setItem("mail_user_setting_background_blur_amount", "3");
localStorage.setItem("mail_user_setting_edge_blur_amount", "5");
localStorage.setItem("mail_user_setting_show_only_video", "true");
localStorage.setItem("mail_user_setting_use_blur", "true");
patchWithCleanup(localStorage, {
setItem(key, value) {
if (key.startsWith("mail_user_setting")) {
asyncStep(`${key}: ${value}`);
}
return super.setItem(key, value);
},
});
patchUiSize({ size: SIZES.SM });
await start();
await openDiscuss(channelId);
// testing load from local storage
// dropdown requires an extra delay before click (because handler is registered in useEffect)
await contains("[title='Open Actions Menu']");
await click("[title='Open Actions Menu']");
await click(".o-dropdown-item", { text: "Call Settings" });
await contains("input[title='Show video participants only']:checked");
await contains("input[title='Blur video background']:checked");
await contains("label[title='Background blur intensity']", { text: "15%" });
await contains("label[title='Edge blur intensity']", { text: "25%" });
// testing save to local storage
await click("input[title='Show video participants only']");
await waitForSteps(["mail_user_setting_show_only_video: false"]);
await click("input[title='Blur video background']");
expect(localStorage.getItem("mail_user_setting_use_blur")).toBe(null);
await editInput(document.body, ".o-Discuss-CallSettings-thresholdInput", 0.3);
await advanceTime(2000); // threshold setting debounce timer
await waitForSteps(["mail_user_setting_voice_threshold: 0.3"]);
});

View file

@ -0,0 +1,211 @@
import { describe, expect } from "@odoo/hoot";
import { advanceTime } from "@odoo/hoot-mock";
import { browser } from "@web/core/browser/browser";
import { asyncStep, onRpc, mountWebClient, waitForSteps } from "@web/../tests/web_test_helpers";
import { defineMailModels, mockGetMedia, onlineTest } from "@mail/../tests/mail_test_helpers";
import { PeerToPeer, STREAM_TYPE, UPDATE_EVENT } from "@mail/discuss/call/common/peer_to_peer";
describe.current.tags("desktop");
defineMailModels();
class Network {
_peerToPeerInstances = new Map();
_notificationRoute;
constructor(route) {
this._notificationRoute = route || "/any/mock/notification";
onRpc(this._notificationRoute, async (req) => {
const {
params: { peer_notifications },
} = await req.json();
for (const notification of peer_notifications) {
const [sender_session_id, target_session_ids, content] = notification;
for (const id of target_session_ids) {
const p2p = this._peerToPeerInstances.get(id);
p2p.handleNotification(sender_session_id, content);
}
}
});
}
/**
* @param id
* @return {{id, p2p: PeerToPeer}}
*/
register(id) {
const p2p = new PeerToPeer({ notificationRoute: this._notificationRoute });
this._peerToPeerInstances.set(id, p2p);
return { id, p2p };
}
close() {
for (const p2p of this._peerToPeerInstances.values()) {
p2p.disconnect();
}
}
}
onlineTest("basic peer to peer connection", async () => {
await mountWebClient();
const channelId = 1;
const network = new Network();
const user1 = network.register(1);
const user2 = network.register(2);
user2.p2p.addEventListener("update", ({ detail: { name, payload } }) => {
if (name === UPDATE_EVENT.CONNECTION_CHANGE && payload.state === "connected") {
asyncStep(payload.state);
}
});
user2.p2p.connect(user2.id, channelId);
user1.p2p.connect(user1.id, channelId);
await user1.p2p.addPeer(user2.id);
await waitForSteps(["connected"]);
network.close();
});
onlineTest("mesh peer to peer connections", async () => {
await mountWebClient();
const channelId = 2;
const network = new Network();
const userCount = 10;
const users = Array.from({ length: userCount }, (_, i) => network.register(i));
const promises = [];
for (const user of users) {
user.p2p.connect(user.id, channelId);
for (let i = 0; i < user.id; i++) {
promises.push(user.p2p.addPeer(i));
}
}
await Promise.all(promises);
let connectionsCount = 0;
for (const user of users) {
connectionsCount += user.p2p.peers.size;
}
expect(connectionsCount).toBe(userCount * (userCount - 1));
connectionsCount = 0;
network.close();
for (const user of users) {
connectionsCount += user.p2p.peers.size;
}
expect(connectionsCount).toBe(0);
});
onlineTest("connection recovery", async () => {
await mountWebClient();
const channelId = 1;
const network = new Network();
const user1 = network.register(1);
const user2 = network.register(2);
user2.remoteStates = new Map();
user2.p2p.addEventListener("update", ({ detail: { name, payload } }) => {
if (name === UPDATE_EVENT.CONNECTION_CHANGE && payload.state === "connected") {
asyncStep(payload.state);
}
});
user1.p2p.connect(user1.id, channelId);
user1.p2p.addPeer(user2.id);
// only connecting user2 after user1 has called addPeer so that user2 ignores notifications
// from user1, which simulates a connection drop that should be recovered.
user2.p2p.connect(user2.id, channelId);
const openPromise = new Promise((resolve) => {
user1.p2p.peers.get(2).dataChannel.onopen = resolve;
});
advanceTime(5_000); // recovery timeout
await openPromise;
await waitForSteps(["connected"]);
network.close();
});
onlineTest("can broadcast a stream and control download", async () => {
mockGetMedia();
await mountWebClient();
const channelId = 3;
const network = new Network();
const user1 = network.register(1);
const user2 = network.register(2);
user2.remoteMedia = new Map();
const trackPromise = new Promise((resolve) => {
user2.p2p.addEventListener("update", ({ detail: { name, payload } }) => {
if (name === UPDATE_EVENT.TRACK) {
user2.remoteMedia.set(payload.sessionId, {
[payload.type]: {
track: payload.track,
active: payload.active,
},
});
resolve();
}
});
});
user2.p2p.connect(user2.id, channelId);
user1.p2p.connect(user1.id, channelId);
await user1.p2p.addPeer(user2.id);
const videoStream = await browser.navigator.mediaDevices.getUserMedia({
video: true,
});
const videoTrack = videoStream.getVideoTracks()[0];
await user1.p2p.updateUpload(STREAM_TYPE.CAMERA, videoTrack);
await trackPromise;
const user2RemoteMedia = user2.remoteMedia.get(user1.id);
const user2CameraTransceiver = user2.p2p.peers.get(user1.id).getTransceiver(STREAM_TYPE.CAMERA);
expect(user2CameraTransceiver.direction).toBe("recvonly");
expect(user2RemoteMedia[STREAM_TYPE.CAMERA].track.kind).toBe("video");
expect(user2RemoteMedia[STREAM_TYPE.CAMERA].active).toBe(true);
user2.p2p.updateDownload(user1.id, { camera: false });
expect(user2CameraTransceiver.direction).toBe("inactive");
network.close();
});
onlineTest("can broadcast arbitrary messages (dataChannel)", async () => {
await mountWebClient();
const channelId = 4;
const network = new Network();
const user1 = network.register(1);
const user2 = network.register(2);
user2.p2p.connect(user2.id, channelId);
user1.p2p.connect(user1.id, channelId);
await user1.p2p.addPeer(user2.id);
user1.inbox = [];
const pongPromise = new Promise((resolve) => {
user1.p2p.addEventListener("update", ({ detail: { name, payload } }) => {
if (name === UPDATE_EVENT.BROADCAST) {
user1.inbox.push(payload);
resolve();
}
});
});
user2.inbox = [];
user2.p2p.addEventListener("update", ({ detail: { name, payload } }) => {
if (name === UPDATE_EVENT.BROADCAST && payload.message === "ping") {
user2.inbox.push(payload);
user2.p2p.broadcast("pong");
}
});
user1.p2p.broadcast("ping");
await pongPromise;
expect(user2.inbox[0].senderId).toBe(user1.id);
expect(user2.inbox[0].message).toBe("ping");
expect(user1.inbox[0].senderId).toBe(user2.id);
expect(user1.inbox[0].message).toBe("pong");
network.close();
});
onlineTest("can reject arbitrary offers", async () => {
await mountWebClient();
const channelId = 1;
const network = new Network();
const user1 = network.register(1);
const user2 = network.register(2);
user2.p2p.connect(user2.id, channelId);
user1.p2p.connect(user1.id, channelId);
user2.p2p._emitLog = (id, message) => {
if (message === "offer rejected") {
asyncStep("offer rejected");
}
};
user2.p2p.acceptOffer = (id, sequence) => id !== user1.id || sequence > 20;
user1.p2p.addPeer(user2.id, { sequence: 19 });
await waitForSteps(["offer rejected"]);
network.close();
});

View file

@ -0,0 +1,47 @@
import {
click,
contains,
defineMailModels,
mockGetMedia,
openDiscuss,
patchUiSize,
SIZES,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { pttExtensionServiceInternal } from "@mail/discuss/call/common/ptt_extension_service";
import { describe, test } from "@odoo/hoot";
import { patchWithCleanup } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineMailModels();
test("display banner when ptt extension is not enabled", async () => {
mockGetMedia();
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
patchWithCleanup(pttExtensionServiceInternal, {
onAnswerIsEnabled(pttService) {
pttService.isEnabled = false;
},
});
patchUiSize({ size: SIZES.SM });
await start();
await openDiscuss(channelId);
// dropdown requires an extra delay before click (because handler is registered in useEffect)
await contains("[title='Open Actions Menu']");
await click("[title='Open Actions Menu']");
await click(".o-dropdown-item", { text: "Call Settings" });
await click("button", { text: "Push to Talk" });
await click("[title*='Close Chat Window']");
await click("button[title='New Meeting']");
await click("button[title='Close panel']"); // invitation panel automatically open
await contains(".o-discuss-PttAdBanner");
// dropdown requires an extra delay before click (because handler is registered in useEffect)
await contains("[title='Open Actions Menu']");
await click("[title='Open Actions Menu']");
await click(".o-dropdown-item", { text: "Call Settings" });
await click("button", { text: "Voice Detection" });
await click("[title*='Close Chat Window']");
await contains(".o-discuss-PttAdBanner", { count: 0 });
});

View file

@ -0,0 +1,123 @@
import {
click,
contains,
defineMailModels,
insertText,
mockGetMedia,
openDiscuss,
patchUiSize,
SIZES,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { pttExtensionServiceInternal } from "@mail/discuss/call/common/ptt_extension_service";
import { PTT_RELEASE_DURATION } from "@mail/discuss/call/common/rtc_service";
import { advanceTime, freezeTime, keyDown, mockTouch, mockUserAgent, test } from "@odoo/hoot";
import { patchWithCleanup, serverState } from "@web/../tests/web_test_helpers";
import { browser } from "@web/core/browser/browser";
defineMailModels();
test.tags("desktop");
test("no auto-call on joining chat", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Mario" });
pyEnv["res.users"].create({ partner_id: partnerId });
await start();
await openDiscuss();
await click("input[placeholder='Search conversations']");
await contains(".o_command_name", { count: 5 });
await insertText("input[placeholder='Search a conversation']", "mario");
await contains(".o_command_name", { count: 3 });
await click(".o_command_name", { text: "Mario" });
await contains(".o-mail-DiscussSidebar-item", { text: "Mario" });
await contains(".o-mail-Message", { count: 0 });
await contains(".o-discuss-Call", { count: 0 });
});
test.tags("desktop");
test("no auto-call on joining group chat", async () => {
const pyEnv = await startServer();
const [partnerId_1, partnerId_2] = pyEnv["res.partner"].create([
{ name: "Mario" },
{ name: "Luigi" },
]);
pyEnv["res.users"].create([{ partner_id: partnerId_1 }, { partner_id: partnerId_2 }]);
await start();
await openDiscuss();
await click("input[placeholder='Search conversations']");
await click("a", { text: "Create Chat" });
await click("li", { text: "Mario" });
await click("li", { text: "Luigi" });
await click("button", { text: "Create Group Chat" });
await contains(".o-mail-DiscussSidebar-item:contains('Mario, and Luigi')");
await contains(".o-mail-Message", { count: 0 });
await contains(".o-discuss-Call", { count: 0 });
});
test.tags("mobile");
test("show Push-to-Talk button on mobile", async () => {
mockGetMedia();
mockTouch(true);
mockUserAgent("android");
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
patchWithCleanup(pttExtensionServiceInternal, {
onAnswerIsEnabled(pttService) {
pttService.isEnabled = false;
},
});
patchUiSize({ size: SIZES.SM });
await start();
await openDiscuss(channelId);
await click(".o-mail-ChatWindow-moreActions", { text: "General" });
await click(".o-dropdown-item:text('Start Call')");
// dropdown requires an extra delay before click (because handler is registered in useEffect)
await contains("[title='Open Actions Menu']");
await click("[title='Open Actions Menu']");
await click(".o-dropdown-item", { text: "Call Settings" });
await click("button", { text: "Push to Talk" });
// dropdown requires an extra delay before click (because handler is registered in useEffect)
await contains("[title='Open Actions Menu']");
await click("[title='Open Actions Menu']");
await click(".o-dropdown-item", { text: "Call Settings" });
await contains("button", { text: "Push to talk" });
});
test.tags("desktop");
test("Can push-to-talk", async () => {
mockGetMedia();
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["res.users.settings"].create({
use_push_to_talk: true,
user_id: serverState.userId,
push_to_talk_key: "...f",
});
patchWithCleanup(pttExtensionServiceInternal, {
onAnswerIsEnabled(pttService) {
pttService.isEnabled = false;
},
});
freezeTime();
await start();
await openDiscuss(channelId);
await advanceTime(1000);
await click("[title='Start Call']");
await advanceTime(1000);
await contains(".o-discuss-Call");
await click(".o-discuss-Call");
await advanceTime(1000);
await keyDown("f");
await advanceTime(PTT_RELEASE_DURATION);
await contains(".o-discuss-CallParticipantCard .o-isTalking");
// switching tab while PTT key still pressed then released on other tab should eventually release PTT
browser.dispatchEvent(new Event("blur"));
await advanceTime(PTT_RELEASE_DURATION + 1000);
await contains(".o-discuss-CallParticipantCard:not(:has(.o-isTalking))");
await click(".o-discuss-Call");
await advanceTime(1000);
await keyDown("f");
await advanceTime(PTT_RELEASE_DURATION);
await contains(".o-discuss-CallParticipantCard .o-isTalking");
});