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