diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/.runsettings b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/.runsettings
new file mode 100644
index 000000000..e493393fa
--- /dev/null
+++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/.runsettings
@@ -0,0 +1,23 @@
+
+
+
+ chromium
+
+ 0
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/PageObjects/Call/Page/CallPage.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/PageObjects/Call/Page/CallPage.cs
new file mode 100644
index 000000000..e429ae7e1
--- /dev/null
+++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/PageObjects/Call/Page/CallPage.cs
@@ -0,0 +1,17 @@
+namespace RecordingBot.UiTests.PageObjects.Call.Page
+{
+ public class CallPage
+ {
+ public const string CallOptionsBtn = "[data-tid='dropdown-calling-toggle-more-options-btn']";
+ public const string VideoCallBtn = "[data-tid='chat-call-video-button']";
+ public const string AudioCallBtn = "[data-tid='chat-call-audio-button']";
+ public const string HangUpBtn = "#hangup-button";
+
+ public const string CallToastCallingActions = "[data-testid='calling-actions']";
+ public const string CallToastAcceptVideo = CallToastCallingActions + " button:nth-child(1)";
+ public const string CallToastAcceptAudio = CallToastCallingActions + " button:nth-child(2)";
+ public const string CallToastHangUp = CallToastCallingActions + "button:nth-child(3)";
+
+ public const string CallComplianceToast = "[data-tid='ufd_ComplianceRecordingStartedByCurrentUser']";
+ }
+}
diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/PageObjects/Login/Page/LoginPage.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/PageObjects/Login/Page/LoginPage.cs
new file mode 100644
index 000000000..d73c558f8
--- /dev/null
+++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/PageObjects/Login/Page/LoginPage.cs
@@ -0,0 +1,11 @@
+namespace RecordingBot.UiTests.PageObjects.Login.Page
+{
+ public class LoginPage
+ {
+ public const string UsernameInput = "//*[@id=\"i0116\"]";
+ public const string PasswordInput = "//*[@id=\"i0118\"]";
+ public const string TokenInput = "//*[@id=\"idTxtBx_SAOTCC_OTC\"]";
+ public const string TokenSubmitBtn = "//*[@id=\"idSubmit_SAOTCC_Continue\"]";
+ public const string SubmitBtn = "//*[@id=\"idSIButton9\"]";
+ }
+}
diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/PageObjects/Login/Steps/LoginSteps.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/PageObjects/Login/Steps/LoginSteps.cs
new file mode 100644
index 000000000..fa3ecd0b7
--- /dev/null
+++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/PageObjects/Login/Steps/LoginSteps.cs
@@ -0,0 +1,46 @@
+using Microsoft.Playwright;
+using RecordingBot.UiTests.Shared.Models;
+using RecordingBot.UiTests.PageObjects.Login.Page;
+using OtpNet;
+
+namespace RecordingBot.UiTests.PageObjects.Login.Steps
+{
+ public class LoginSteps
+ {
+ public static async Task LoginPerson(IPage page, Person person)
+ {
+ await page.GotoAsync("https://teams.microsoft.com/");
+
+ if (!string.IsNullOrWhiteSpace(person.Username))
+ {
+ var usernameInput = page.Locator(LoginPage.UsernameInput);
+ await usernameInput.ClickAsync();
+ await usernameInput.FillAsync(person.Username);
+ await page.Locator(LoginPage.SubmitBtn).ClickAsync();
+ }
+
+ if (!string.IsNullOrWhiteSpace(person.Password))
+ {
+ var passwordInput = page.Locator(LoginPage.PasswordInput);
+ await passwordInput.ClickAsync();
+ await passwordInput.FillAsync(person.Password);
+ await page.Locator(LoginPage.SubmitBtn).ClickAsync();
+ }
+
+ if (!string.IsNullOrWhiteSpace(person.Seed))
+ {
+ var tokenInput = page.Locator(LoginPage.TokenInput);
+
+ var totp = new Totp(Base32Encoding.ToBytes(person.Seed), totpSize: 6 );
+ string otpCode = totp.ComputeTotp();
+
+ if (!string.IsNullOrWhiteSpace(otpCode))
+ {
+ await tokenInput.ClickAsync();
+ await tokenInput.FillAsync(otpCode);
+ await page.Locator(LoginPage.TokenSubmitBtn).ClickAsync();
+ }
+ }
+ }
+ }
+}
diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/PageObjects/Teams/Page/CalendarPage.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/PageObjects/Teams/Page/CalendarPage.cs
new file mode 100644
index 000000000..78dc02c69
--- /dev/null
+++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/PageObjects/Teams/Page/CalendarPage.cs
@@ -0,0 +1,10 @@
+namespace RecordingBot.UiTests.PageObjects.Teams.Page
+{
+ public class CalendarPage
+ {
+ public const string StartMeetingBtn = "//*[@id=\"app\"]/div/div/div/div[5]/div/div/div[3]/button[2]";
+ public const string MeetNowFlyoutBtn = "[data-tid='meet_now_calendar_flyout_start_meeting_button']";
+ public const string JoinBtn = "[data-tid='prejoin-join-button']";
+ public const string InviteDismissBtn = "[data-tid='share_meeting_invite_dialog_dismiss_button']";
+ }
+}
diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/PageObjects/Teams/Page/SearchPage.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/PageObjects/Teams/Page/SearchPage.cs
new file mode 100644
index 000000000..db23997eb
--- /dev/null
+++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/PageObjects/Teams/Page/SearchPage.cs
@@ -0,0 +1,8 @@
+namespace RecordingBot.UiTests.PageObjects.Teams.Page
+{
+ public class SearchPage
+ {
+ public const string TabBarPeople = "[data-tid='people-tab']";
+ public const string ContentAreaPerson = "[data-tid='app-layout-area--main'] ul > li.ms-FocusZone:first-child";
+ }
+}
diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/PageObjects/Teams/Page/TeamsPage.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/PageObjects/Teams/Page/TeamsPage.cs
new file mode 100644
index 000000000..4502fdda1
--- /dev/null
+++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/PageObjects/Teams/Page/TeamsPage.cs
@@ -0,0 +1,8 @@
+namespace RecordingBot.UiTests.PageObjects.Teams.Page
+{
+ public class TeamsPage
+ {
+ public const string Search = "[data-tid='AUTOSUGGEST_INPUT']";
+ public const string Calendar = "//*[@id=\"ef56c0de-36fc-4ef8-b417-3d82ba9d073c\"]";
+ }
+}
diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/PageObjects/Teams/Steps/CalendarSteps.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/PageObjects/Teams/Steps/CalendarSteps.cs
new file mode 100644
index 000000000..0ab6302f2
--- /dev/null
+++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/PageObjects/Teams/Steps/CalendarSteps.cs
@@ -0,0 +1,17 @@
+using Microsoft.Playwright;
+using RecordingBot.UiTests.PageObjects.Teams.Page;
+
+namespace RecordingBot.UiTests.PageObjects.Teams.Steps
+{
+ public class CalendarSteps
+ {
+ public static async Task CreateAudioCall(IPage page)
+ {
+ await page.Locator(TeamsPage.Calendar).ClickAsync();
+ await page.Locator(CalendarPage.StartMeetingBtn).ClickAsync();
+ await page.Locator(CalendarPage.MeetNowFlyoutBtn).ClickAsync();
+ await page.Locator(CalendarPage.JoinBtn).ClickAsync();
+ await page.Locator(CalendarPage.InviteDismissBtn).ClickAsync();
+ }
+ }
+}
diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/PageObjects/Teams/Steps/TeamsSteps.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/PageObjects/Teams/Steps/TeamsSteps.cs
new file mode 100644
index 000000000..42e7f60b7
--- /dev/null
+++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/PageObjects/Teams/Steps/TeamsSteps.cs
@@ -0,0 +1,24 @@
+using Microsoft.Playwright;
+using RecordingBot.UiTests.Shared.Models;
+using RecordingBot.UiTests.PageObjects.Teams.Page;
+
+namespace RecordingBot.UiTests.PageObjects.Teams.Steps
+{
+ public class TeamsSteps
+ {
+ public static async Task SearchPersonAndOpenChat(IPage page, Person person)
+ {
+ var searchInput = page.Locator(TeamsPage.Search);
+
+ if (!string.IsNullOrWhiteSpace(person.Username))
+ {
+ await searchInput.ClickAsync();
+ await searchInput.FillAsync(person.Username);
+ await searchInput.PressAsync("Enter");
+ }
+
+ await page.ClickAsync(SearchPage.TabBarPeople);
+ await page.ClickAsync(SearchPage.ContentAreaPerson);
+ }
+ }
+}
diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/README.md b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/README.md
new file mode 100644
index 000000000..9f13a9e7b
--- /dev/null
+++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/README.md
@@ -0,0 +1,53 @@
+# Introduction
+This project aims to provide automated end to end tests for a compliance recording bot between two or more users participating in a call.
+It's purpose is to ensure that a compliance recording bot joins a call and the call participants receives a visual notification that a records has started.
+
+## End-To-End Framework
+As a end-to-end testing framework playwright.net is used. (Read more [Playwright](https://playwright.dev/dotnet/))
+
+## Framework
+As a framework to write and execute tests, nUnit is used. (Read more [NUnit](https://nunit.org/))
+
+## Otp
+Otp.Net is used to generate one time passwords and optain a code for login. (Read more [Otp.Net](https://www.nuget.org/packages/Otp.NET))
+
+
+# Getting Started
+To start using this project, you need to provide at least two users that are registered for using teams.
+Therefore you need to provide the following information in the .runsettings file:
+```json
+{
+ "RunConfiguration": {
+ "EnvironmentVariables": {
+ "UserA_Username": "",
+ "UserA_UserPassword": "",
+ "UserA_UserSeed": "",
+ "UserB_Username": "",
+ "UserB_UserPassword": "",
+ "UserB_UserSeed": "",
+ "UserC_Username": "",
+ "UserC_UserPassword": "",
+ "UserC_UserSeed": ""
+ }
+ }
+}
+```
+
+Furthermore you should adjust the launch options to your needs in the .runsettings file.
+Locally its a good idea execute the tests not in headless mode to see the test running, but if you consider to run the tests in a pipeline you should keep it headless:
+```json
+{
+ "RunConfiguration": {
+ "EnvironmentVariables": {
+ "LaunchOptions": {
+ "headless": false,
+ "slowMo": 0
+ }
+ }
+ }
+}
+```
+
+# Contribute
+TODO:
+Furthermore the login and some other locators uses xPath to find the elements. This should be changed to use the id or data-tid once it is provided.
\ No newline at end of file
diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/RecordingBot.UiTests.csproj b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/RecordingBot.UiTests.csproj
new file mode 100644
index 000000000..493310244
--- /dev/null
+++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/RecordingBot.UiTests.csproj
@@ -0,0 +1,33 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/Shared/Models/Person.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/Shared/Models/Person.cs
new file mode 100644
index 000000000..5612b0caf
--- /dev/null
+++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/Shared/Models/Person.cs
@@ -0,0 +1,22 @@
+namespace RecordingBot.UiTests.Shared.Models
+{
+ public abstract class Person
+ {
+ ///
+ /// Username that is used to login
+ ///
+ /// Example: Max.Mustermann@musterpage.com
+ public abstract string Username { get; set; }
+ ///
+ /// Password that is used to login
+ ///
+ /// Example: YourPassword
+ public abstract string Password { get; set; }
+
+ ///
+ /// Seed that is used to calculate token for login when 2fa is enabled
+ ///
+ /// Example: YourSeed
+ public abstract string Seed { get; set; }
+ }
+}
diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/Shared/Users/UserA.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/Shared/Users/UserA.cs
new file mode 100644
index 000000000..82240ef5e
--- /dev/null
+++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/Shared/Users/UserA.cs
@@ -0,0 +1,12 @@
+using RecordingBot.UiTests.Shared.Models;
+using System.Reflection.Metadata.Ecma335;
+
+namespace RecordingBot.UiTests.Shared.Users
+{
+ public class UserA : Person
+ {
+ public override string Username { get; set; } = Environment.GetEnvironmentVariable("UserA_Username") ?? throw new Exception("Please provide environment variable UserA_Username");
+ public override string Password { get; set; } = Environment.GetEnvironmentVariable("UserA_UserPassword") ?? throw new Exception("Please provide environment variable UserA_UserPassword");
+ public override string Seed { get; set; } = Environment.GetEnvironmentVariable("UserA_UserSeed") ?? throw new Exception("Please provide environment variable UserA_UserSeed");
+ }
+}
diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/Shared/Users/UserB.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/Shared/Users/UserB.cs
new file mode 100644
index 000000000..d6ce5881e
--- /dev/null
+++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/Shared/Users/UserB.cs
@@ -0,0 +1,11 @@
+using RecordingBot.UiTests.Shared.Models;
+
+namespace RecordingBot.UiTests.Shared.Users
+{
+ public class UserB : Person
+ {
+ public override string Username { get; set; } = Environment.GetEnvironmentVariable("UserB_Username") ?? throw new Exception("Please provide environment variable UserB_Username");
+ public override string Password { get; set; } = Environment.GetEnvironmentVariable("UserB_UserPassword") ?? throw new Exception("Please provide environment variable UserB_UserPassword");
+ public override string Seed { get; set; } = Environment.GetEnvironmentVariable("UserB_UserSeed") ?? throw new Exception("Please provide environment variable UserB_UserSeed");
+ }
+}
diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/Shared/Users/UserC.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/Shared/Users/UserC.cs
new file mode 100644
index 000000000..df100a3aa
--- /dev/null
+++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/Shared/Users/UserC.cs
@@ -0,0 +1,11 @@
+using RecordingBot.UiTests.Shared.Models;
+
+namespace RecordingBot.UiTests.Shared.Users
+{
+ public class UserC : Person
+ {
+ public override string Username { get; set; } = Environment.GetEnvironmentVariable("UserC_Username") ?? throw new Exception("Please provide environment variable UserC_Username");
+ public override string Password { get; set; } = Environment.GetEnvironmentVariable("UserC_UserPassword") ?? throw new Exception("Please provide environment variable UserC_UserPassword");
+ public override string Seed { get; set; } = Environment.GetEnvironmentVariable("UserC_UserSeed") ?? throw new Exception("Please provide environment variable UserC_UserSeed");
+ }
+}
diff --git a/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/Tests/Call/CallComplianceBotOnlineTests.cs b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/Tests/Call/CallComplianceBotOnlineTests.cs
new file mode 100644
index 000000000..374b237df
--- /dev/null
+++ b/Samples/PublicSamples/RecordingBot/src/RecordingBot.UiTests/Tests/Call/CallComplianceBotOnlineTests.cs
@@ -0,0 +1,45 @@
+using Microsoft.Playwright;
+using RecordingBot.UiTests.PageObjects.Call.Page;
+using RecordingBot.UiTests.PageObjects.Login.Steps;
+using RecordingBot.UiTests.PageObjects.Teams.Steps;
+using RecordingBot.UiTests.Shared.Users;
+
+namespace RecordingBot.UiTests.Tests.Call;
+
+[TestFixture]
+[Category("CallComplianceBotOnline")]
+[Description("Automated E2E-Tests for a call with joined compliance bot")]
+public class CallComplianceBotOnlineTests : PageTest
+{
+ public override BrowserNewContextOptions ContextOptions()
+ {
+ return new BrowserNewContextOptions
+ {
+ Permissions = ["microphone", "camera"]
+ };
+ }
+
+ [Test]
+ [Description("PersonA creates a meeting. Compliance bot starts recording call")]
+ public async Task AudioCall_Should_DisplayRecordingComplianceToast_When_PersonACreatesMeeting()
+ {
+ var user = new UserA();
+ var page = Page;
+
+ await LoginSteps.LoginPerson(page, user);
+ await CalendarSteps.CreateAudioCall(page);
+
+ await VerifyRecordingToast(page);
+ await HangUpCall(page);
+ }
+
+ private async Task VerifyRecordingToast(IPage page)
+ {
+ await Expect(page.Locator(CallPage.CallComplianceToast)).ToBeVisibleAsync();
+ }
+
+ private static async Task HangUpCall(IPage page)
+ {
+ await page.Locator(CallPage.HangUpBtn).ClickAsync();
+ }
+}
diff --git a/Samples/PublicSamples/RecordingBot/src/TeamsRecordingBot.sln b/Samples/PublicSamples/RecordingBot/src/TeamsRecordingBot.sln
index 12e5bb139..4e583a0ab 100644
--- a/Samples/PublicSamples/RecordingBot/src/TeamsRecordingBot.sln
+++ b/Samples/PublicSamples/RecordingBot/src/TeamsRecordingBot.sln
@@ -1,7 +1,7 @@
Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 16
-VisualStudioVersion = 16.0.30011.22
+# Visual Studio Version 17
+VisualStudioVersion = 17.12.35707.178 d17.12
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RecordingBot.Services", "RecordingBot.Services\RecordingBot.Services.csproj", "{55C6D645-F418-4262-BEB2-9BAE523DEC60}"
EndProject
@@ -13,6 +13,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RecordingBot.Console", "RecordingBot.Console\RecordingBot.Console.csproj", "{AEEB866D-E17B-406F-9385-32273D2F8691}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RecordingBot.UiTests", "RecordingBot.UiTests\RecordingBot.UiTests.csproj", "{4B4859C9-86B5-4AF8-B305-DF4D9EF45358}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -53,6 +55,14 @@ Global
{AEEB866D-E17B-406F-9385-32273D2F8691}.Release|Any CPU.Build.0 = Release|x64
{AEEB866D-E17B-406F-9385-32273D2F8691}.Release|x64.ActiveCfg = Release|x64
{AEEB866D-E17B-406F-9385-32273D2F8691}.Release|x64.Build.0 = Release|x64
+ {4B4859C9-86B5-4AF8-B305-DF4D9EF45358}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4B4859C9-86B5-4AF8-B305-DF4D9EF45358}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4B4859C9-86B5-4AF8-B305-DF4D9EF45358}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {4B4859C9-86B5-4AF8-B305-DF4D9EF45358}.Debug|x64.Build.0 = Debug|Any CPU
+ {4B4859C9-86B5-4AF8-B305-DF4D9EF45358}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4B4859C9-86B5-4AF8-B305-DF4D9EF45358}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4B4859C9-86B5-4AF8-B305-DF4D9EF45358}.Release|x64.ActiveCfg = Release|Any CPU
+ {4B4859C9-86B5-4AF8-B305-DF4D9EF45358}.Release|x64.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE