diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b4ededd39033d..386ff52431b28 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -756,10 +756,10 @@ # ServiceOwners: @rhurey @dargilco # PRLabel: %Microsoft Playwright Testing -/sdk/playwrighttesting/ @shreyaanand @mjmadhu +/sdk/playwrighttesting/ @Sid200026 @puagarwa @ShreyaAnand # ServiceLabel: %Microsoft Playwright Testing -# ServiceOwners: @shreyaanand @mjmadhu +# ServiceOwners: @Sid200026 @puagarwa @ShreyaAnand # ServiceLabel: %Policy # ServiceOwners: @aperezcloud @kenieva diff --git a/eng/Packages.Data.props b/eng/Packages.Data.props index 753a3ebbd1ef2..99681333eaf17 100644 --- a/eng/Packages.Data.props +++ b/eng/Packages.Data.props @@ -178,6 +178,11 @@ + + + + + + + diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/README.md b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/README.md new file mode 100644 index 0000000000000..bbca4b74ad9d7 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/README.md @@ -0,0 +1,119 @@ +# Microsoft Azure Playwright Testing NUnit client library for .NET + +Microsoft Playwright Testing is a fully managed service that uses the cloud to enable you to run Playwright tests with much higher parallelization across different operating system-browser combinations simultaneously. This means faster test runs with broader scenario coverage, which helps speed up delivery of features without sacrificing quality. The service also enables you to publish test results and related artifacts to the service and view them in the service portal enabling faster and easier troubleshooting. With Microsoft Playwright Testing service, you can release features faster and more confidently. + +Ready to get started? Jump into our [quickstart guide]! + +## Useful links +- [Quickstart: Run end-to-end tests at scale](https://aka.ms/mpt/quickstart) +- [View Microsoft Playwright Testing service demo](https://youtu.be/GenC1jAeTZE) +- [Documentation](https://aka.ms/mpt/docs) +- [Pricing](https://aka.ms/mpt/pricing) +- [Share feedback](https://aka.ms/mpt/feedback) + +## Getting started + +### Install the package + +Install the client library for .NET with [NuGet](https://www.nuget.org/): + +```dotnetcli +dotnet add package Azure.Developer.MicrosoftPlaywrightTesting.NUnit --prerelease +``` + +### Prerequisites + +- An [Azure subscription](https://azure.microsoft.com/free/dotnet/) +- Your Azure account must be assigned the [Owner](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#owner), [Contributor](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#contributor), or one of the [classic administrator roles](https://learn.microsoft.com/azure/role-based-access-control/rbac-and-directory-admin-roles#classic-subscription-administrator-roles). + +### Authenticate the client + +To learn more about options for Microsoft Entra Id authentication, refer to [Azure.Identity credentials](https://github.com/Azure/azure-sdk-for-net/tree/main/sdk/identity/Azure.Identity#credentials). You can also refer to [our samples] on how to configurate different Azure Identity credentials. + +#### Create a Workspace + +1. Sign in to the [Playwright portal](https://aka.ms/mpt/portal) with your Azure account. + +2. Create the Workspace + + ![Create new workspace](https://github.com/microsoft/playwright-testing-service/assets/12104064/d571e86b-9d43-48ac-a2b7-63afb9bb86a8) + + |Field |Description | + |---------|---------| + |**Workspace Name** | A unique name to identify your workspace.
The name can't contain special characters or whitespace. | + |**Azure Subscription** | Select an Azure subscription where you want to create the workspace. | + |**Region** | This is where test run data will be stored for your workspace. | + + > [!NOTE] + > If you don't see this screen, select an existing workspace and go to the next section. + ``` + +### Set up Microsoft Playwright Testing + +Create a file `PlaywrightServiceSetup.cs` in the root directory with the below content + +```C# Snippet:Sample2_SetDefaultAuthenticationMechanism +using Azure.Developer.MicrosoftPlaywrightTesting.NUnit; + +namespace PlaywrightATests; // Remember to change this as per your project namespace + +[SetUpFixture] +public class PlaywrightServiceSetup : PlaywrightServiceNUnit {}; +``` + +> [!NOTE] +> Make sure your project uses `Microsoft.Playwright.NUnit` version 1.37 or above. + +### Obtain region endpoint + +1. In the [Playwright portal](https://aka.ms/mpt/portal), copy the command under **Add region endpoint in your set up**. + + ![Set workspace endpoint](https://github.com/microsoft/playwright-testing-service/assets/12104064/d81ca629-2b23-4d34-8b70-67b6f7061a83) + + The endpoint URL corresponds to the workspace region. You might see a different endpoint URL in the Playwright portal, depending on the region you selected when creating the workspace. + +### Set up environment + +Ensure that the `PLAYWRIGHT_SERVICE_URL` that you obtained in previous step is available in your environment. + +### Run the tests + +Run Playwright tests against browsers managed by the service using the configuration you created above. + +```dotnetcli +dotnet test --logger "ms-playwright-service" +``` + +## Key concepts + +Key concepts of the Microsoft Playwright Testing NUnit SDK for .NET can be found [here](https://aka.ms/mpt/what-is-mpt) + +## Examples + +Code samples for using this SDK can be found in the following locations +- [.NET Microsoft Playwright Testing NUnit Library Code Samples](https://aka.ms/mpt/sample) + +## Troubleshooting + +- File an issue via [GitHub Issues](https://github.com/Azure/azure-sdk-for-net/issues). +- Check [previous questions](https://stackoverflow.com/questions/tagged/azure+.net) or ask new ones on Stack Overflow using Azure and .NET tags. + +## Next steps + +- Run tests in a [CI/CD pipeline.](https://aka.ms/mpt/configure-pipeline) + +- Learn how to [manage access](https://aka.ms/mpt/manage-access) to the created workspace. + +- Experiment with different number of workers to [determine the optimal configuration of your test suite](https://aka.ms/mpt/parallelism). + +## Contributing +This project welcomes contributions and suggestions. Most contributions require +you to agree to a Contributor License Agreement (CLA) declaring that you have +the right to, and actually do, grant us the rights to use your contribution. For +details, visit [cla.microsoft.com][cla]. + +This project has adopted the [Microsoft Open Source Code of Conduct][coc]. +For more information see the [Code of Conduct FAQ][coc_faq] or contact +[opencode@microsoft.com][coc_contact] with any additional questions or comments. + +![Impressions](https://azure-sdk-impressions.azurewebsites.net/api/impressions/azure-sdk-for-net/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/README.png) \ No newline at end of file diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/api/Azure.Developer.MicrosoftPlaywrightTesting.NUnit.netstandard2.0.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/api/Azure.Developer.MicrosoftPlaywrightTesting.NUnit.netstandard2.0.cs new file mode 100644 index 0000000000000..7e6eac3ac4c33 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/api/Azure.Developer.MicrosoftPlaywrightTesting.NUnit.netstandard2.0.cs @@ -0,0 +1,13 @@ +namespace Azure.Developer.MicrosoftPlaywrightTesting.NUnit +{ + [NUnit.Framework.SetUpFixtureAttribute] + public partial class PlaywrightServiceNUnit : Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.PlaywrightService + { + public PlaywrightServiceNUnit(Azure.Core.TokenCredential? credential = null) : base (default(Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.PlaywrightServiceOptions), default(Azure.Core.TokenCredential)) { } + public static Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.PlaywrightServiceOptions playwrightServiceOptions { get { throw null; } } + [NUnit.Framework.OneTimeSetUpAttribute] + public System.Threading.Tasks.Task SetupAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + [NUnit.Framework.OneTimeTearDownAttribute] + public void Teardown() { } + } +} diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/samples/README.md b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/samples/README.md new file mode 100644 index 0000000000000..218796676686f --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/samples/README.md @@ -0,0 +1,15 @@ +--- +page_type: sample +languages: +- csharp +products: +- azure +- playwright-testing +name: Azure.Developer.MicrosoftPlaywrightTesting.NUnit samples for .NET +description: Samples for the Azure.Developer.MicrosoftPlaywrightTesting.NUnit client library +--- + +# Azure.Developer.MicrosoftPlaywrightTesting.NUnit samples for .NET + +- [Customising service parameters] +- [Set default authentication mechanism] \ No newline at end of file diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/samples/Sample1_CustomisingServiceParameters.md b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/samples/Sample1_CustomisingServiceParameters.md new file mode 100644 index 0000000000000..ef62a3929fe6a --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/samples/Sample1_CustomisingServiceParameters.md @@ -0,0 +1,115 @@ +## Learn about different available service parameters and how to use them + +Follow the steps listed in this [README] to integrate your existing Playwright test suite with the Microsoft Playwright Testing service. + +This guide explains the different options available to you in the Azure.Developer.MicrosoftPlaywrightTesting.NUnit package and how to use them. + +### Using .runsettings file + +1. Create a `.runsettings` file in the root directory: + +```xml + + + + + + + + + + + + + + + + + + + + +``` + + > [!NOTE] + > You can also modify the runid by setting the environment variable `PLAYWRIGHT_SERVICE_RUN_ID`. + +2. Run tests using the above `.runsettings` file: + +```dotnetcli +dotnet test --settings .runsettings +``` + +#### Known issue: Minimal support for Azure Identity library credentials + +This issue only impacts the reporting feature. Currently, the service provides minimal support for the following [Azure Credential types.](https://learn.microsoft.com/dotnet/api/overview/azure/identity-readme?view=azure-dotnet#credential-classes) + +Along with this, we also support passing a Managed Identity ClientId to be used along with `DefaultAzureCredential` and `ManagedIdentityCredential`. + +If you only want to use cloud-hosted browsers along with your tests, you can disable the reporting feature by removing the logger from the runsettings file and then modify the `PlaywrightServiceSetup.cs` file as per the following. + +```C# Snippet:Sample1_CustomisingServiceParameters +using Azure.Core; +using Azure.Developer.MicrosoftPlaywrightTesting.NUnit; +using Azure.Identity; + +namespace PlaywrightTests; + +[SetUpFixture] +public class PlaywrightServiceSetup : PlaywrightServiceNUnit +{ + public static readonly TokenCredential managedIdentityCredential = new ManagedIdentityCredential(); + + public PlaywrightServiceSetup() : base(managedIdentityCredential) {} +} +``` + +## Options + +1. **`Os`**: + - **Description**: This setting allows you to choose the operating system where the browsers running Playwright tests will be hosted. + - **Available Options**: + - `System.Runtime.InteropServices.OSPlatform.Windows` for Windows OS. + - `System.Runtime.InteropServices.OSPlatform.LINUX` for Linux OS. + - **Default Value**: `System.Runtime.InteropServices.OSPlatform.LINUX` + +2. **`RunId`**: + - **Description**: This setting allows you to set a unique ID for every test run to distinguish them in the service portal. + +3. **`ExposeNetwork`**: + - **Description**: This settings exposes network available on the connecting client to the browser being connected to. + +4. **`ServiceAuth`** + - **Description**: This setting allows you to specify the default authentication mechanism to be used for sending requests to the service. + - **Available Options**: + - `ServiceAuthType.EntraId` for Microsoft Entra ID authentication. + - `ServiceAuthType.AccessToken` for MPT Access Token authentication. + - **Default Value**: `ServiceAuthType.EntraId` + +5. **`UseCloudHostedBrowsers`** + - **Description**: This setting allows you to select whether to use cloud-hosted browsers to run your Playwright tests. Reporting features remain available even if you disable this setting. + - **Default Value**: `true` + +6. **`AzureTokenCredentialType`**: + - **Description**: This setting allows you to select the authentication method you want to use with Entra. + - **Available Options**: + - `AzureTokenCredentialType.EnvironmentCredential` + - `AzureTokenCredentialType.WorkloadIdentityCredential` + - `AzureTokenCredentialType.ManagedIdentityCredential` + - `AzureTokenCredentialType.SharedTokenCacheCredential` + - `AzureTokenCredentialType.VisualStudioCredential` + - `AzureTokenCredentialType.VisualStudioCodeCredential` + - `AzureTokenCredentialType.AzureCliCredential` + - `AzureTokenCredentialType.AzurePowerShellCredential` + - `AzureTokenCredentialType.AzureDeveloperCliCredential` + - `AzureTokenCredentialType.InteractiveBrowserCredential` + - `AzureTokenCredentialType.DefaultAzureCredential` + - **Default Value**: `AzureTokenCredentialType.DefaultAzureCredential` + +7. **`ManagedIdentityClientId`** + - **Description**: This setting allows you to specify the managed identity client id to be used for Microsoft Entra Id authentication. + +8. **`EnableGitHubSummary`**: + - **Description**: This setting allows you to configure the Microsoft Playwright Testing service reporter. You can choose whether to include the test run summary in the GitHub summary when running in GitHub Actions. + - **Default Value**: `true` + diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/samples/Sample2_SetDefaultAuthenticationMechanism.md b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/samples/Sample2_SetDefaultAuthenticationMechanism.md new file mode 100644 index 0000000000000..13c3ba68314c6 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/samples/Sample2_SetDefaultAuthenticationMechanism.md @@ -0,0 +1,57 @@ +# How to authenticate to Microsoft Playwright Testing service using service access token. + +Follow the steps listed in this [README] to integrate your existing Playwright test suite with the Microsoft Playwright Testing service. + +This guide will walk you through the steps to integrate your Playwright project where you are launching browsers from within the tests with the service. + +### Setup Microsoft Playwright Testing + +1. Create a file `PlaywrightServiceSetup.cs` in the root directory with the following + +```C# Snippet:Sample2_SetDefaultAuthenticationMechanism +using Azure.Developer.MicrosoftPlaywrightTesting.NUnit; + +namespace PlaywrightATests; // Remember to change this as per your project namespace + +[SetUpFixture] +public class PlaywrightServiceSetup : PlaywrightServiceNUnit {}; +``` + +2. Create a .runsettings file to modify default authentication mechanism. + +```xml + + + + + + + +``` + +> [!NOTE] +> Make sure your project uses Microsoft.Playwright.NUnit version 1.37 or above. + +### Obtain region endpoint + +1. In the [Playwright portal](https://aka.ms/mpt/portal), copy the command under **Add region endpoint in your set up**. + + ![Set workspace endpoint](https://github.com/microsoft/playwright-testing-service/assets/12104064/d81ca629-2b23-4d34-8b70-67b6f7061a83) + + The endpoint URL corresponds to the workspace region. You might see a different endpoint URL in the Playwright portal, depending on the region you selected when creating the workspace. + +### Set up environment + +Ensure that the `PLAYWRIGHT_SERVICE_URL` that you obtained in previous step is available in your environment. + +### Authenticate the client + +To learn more about options for Microsoft Entra Id authentication, refer to [Azure.Identity credentials](https://github.com/Azure/azure-sdk-for-net/tree/main/sdk/identity/Azure.Identity#credentials). You can also refer to [our samples] on how to configurate different Azure Identity credentials. + +### Run the tests + +Run Playwright tests against browsers managed by the service using the configuration you created above. + +```dotnetcli +dotnet test --settings .runsettings +``` \ No newline at end of file diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/src/Azure.Developer.MicrosoftPlaywrightTesting.NUnit.csproj b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/src/Azure.Developer.MicrosoftPlaywrightTesting.NUnit.csproj new file mode 100644 index 0000000000000..bd13873735388 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/src/Azure.Developer.MicrosoftPlaywrightTesting.NUnit.csproj @@ -0,0 +1,19 @@ + + + Azure, Cloud, Playwright, Playwright Service, Reporting, Playwright Testing + + Package to integrate your Playwright test suite with Microsoft Playwright Testing + service + + 1.0.0-beta.1 + 1.0.0-beta.1 + true + $(RequiredTargetFrameworks) + enable + + + + + + \ No newline at end of file diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/src/PlaywrightServiceNUnit.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/src/PlaywrightServiceNUnit.cs new file mode 100644 index 0000000000000..46141565b2e98 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/src/PlaywrightServiceNUnit.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Azure.Core; +using NUnit.Framework; +using System.Threading.Tasks; +using System.Runtime.InteropServices; +using System.Threading; +using Azure.Developer.MicrosoftPlaywrightTesting.TestLogger; + +namespace Azure.Developer.MicrosoftPlaywrightTesting.NUnit; + +/// +/// NUnit setup fixture to initialize Playwright Service. +/// +[SetUpFixture] +public class PlaywrightServiceNUnit : PlaywrightService +{ + /// + /// Initializes a new instance of the class. + /// + /// The azure token credential to use for authentication. + public PlaywrightServiceNUnit(TokenCredential? credential = null) + : base(playwrightServiceOptions, credential: credential) + { + } + + /// + /// Creates a new instance of based on the runsettings file. + /// + public static PlaywrightServiceOptions playwrightServiceOptions { get; } = new( + os: GetOsPlatform(TestContext.Parameters.Get(RunSettingKey.Os)), + runId: TestContext.Parameters.Get(RunSettingKey.RunId), + exposeNetwork: TestContext.Parameters.Get(RunSettingKey.ExposeNetwork), + serviceAuth: TestContext.Parameters.Get(RunSettingKey.ServiceAuthType), + useCloudHostedBrowsers: TestContext.Parameters.Get(RunSettingKey.UseCloudHostedBrowsers), + azureTokenCredentialType: TestContext.Parameters.Get(RunSettingKey.AzureTokenCredentialType), + managedIdentityClientId: TestContext.Parameters.Get(RunSettingKey.ManagedIdentityClientId) + ); + + /// + /// Setup the resources utilized by Playwright service. + /// + /// Cancellation token. + /// + [OneTimeSetUp] + public async Task SetupAsync(CancellationToken cancellationToken = default) + { + await InitializeAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Tear down resources utilized by Playwright service. + /// + [OneTimeTearDown] + public void Teardown() + { + Cleanup(); + } + + private static OSPlatform? GetOsPlatform(string? os) + { + if (string.IsNullOrEmpty(os)) + { + return null; + } + else if (os!.Equals("Windows", System.StringComparison.OrdinalIgnoreCase)) + { + return OSPlatform.Windows; + } + else if (os.Equals("Linux", System.StringComparison.OrdinalIgnoreCase)) + { + return OSPlatform.Linux; + } + return null; + } +} diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/tests/Azure.Developer.MicrosoftPlaywrightTesting.NUnit.Tests.csproj b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/tests/Azure.Developer.MicrosoftPlaywrightTesting.NUnit.Tests.csproj new file mode 100644 index 0000000000000..876d71881eb32 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/tests/Azure.Developer.MicrosoftPlaywrightTesting.NUnit.Tests.csproj @@ -0,0 +1,23 @@ + + + + enable + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/tests/samples/README.md b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/tests/samples/README.md new file mode 100644 index 0000000000000..ecd5c103e8fe3 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/tests/samples/README.md @@ -0,0 +1,3 @@ +Source files in this directory are written as tests from which samples are extracted. +They are not intended to be viewed directly and help ensure our samples compile and work correctly. +See our [list of samples](https://github.com/Azure/azure-sdk-for-net/tree/main/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/samples) for more explanation about how to use this client library. \ No newline at end of file diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/tests/samples/Sample1_CustomisingServiceParameters.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/tests/samples/Sample1_CustomisingServiceParameters.cs new file mode 100644 index 0000000000000..aaaf15ee8fe49 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/tests/samples/Sample1_CustomisingServiceParameters.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#region Snippet:Sample1_CustomisingServiceParameters +using Azure.Core; +using Azure.Developer.MicrosoftPlaywrightTesting.NUnit; +using Azure.Identity; + +namespace PlaywrightTests; + +[SetUpFixture] +#if SNIPPET +public class PlaywrightServiceSetup : PlaywrightServiceNUnit +#else +public class Sample2ServiceSetup : PlaywrightServiceNUnit +#endif +{ + public static readonly TokenCredential managedIdentityCredential = new ManagedIdentityCredential(); + +#if SNIPPET + public PlaywrightServiceSetup() : base(managedIdentityCredential) {} +#else + public Sample2ServiceSetup() : base(managedIdentityCredential) {} +#endif +} +#endregion \ No newline at end of file diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/tests/samples/Sample2_SetDefaultAuthenticationMechanism.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/tests/samples/Sample2_SetDefaultAuthenticationMechanism.cs new file mode 100644 index 0000000000000..6cf327b7d4687 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.NUnit/tests/samples/Sample2_SetDefaultAuthenticationMechanism.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#region Snippet:Sample2_SetDefaultAuthenticationMechanism +using Azure.Developer.MicrosoftPlaywrightTesting.NUnit; + +namespace PlaywrightATests; // Remember to change this as per your project namespace + +[SetUpFixture] +#if SNIPPET +public class PlaywrightServiceSetup : PlaywrightServiceNUnit {}; +#else +public class Sample2ServiceSetup : PlaywrightServiceNUnit { }; +#endif +#endregion \ No newline at end of file diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.sln b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.sln new file mode 100644 index 0000000000000..127dd846d079e --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.Developer.MicrosoftPlaywrightTesting.TestLogger", "src\Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.csproj", "{2C1F9880-AD7D-433E-A73E-43F503772CD8}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Tests", "tests\Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Tests.csproj", "{6C78D318-9B96-43B4-97A7-7B3C8088983C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2C1F9880-AD7D-433E-A73E-43F503772CD8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2C1F9880-AD7D-433E-A73E-43F503772CD8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2C1F9880-AD7D-433E-A73E-43F503772CD8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2C1F9880-AD7D-433E-A73E-43F503772CD8}.Release|Any CPU.Build.0 = Release|Any CPU + {6C78D318-9B96-43B4-97A7-7B3C8088983C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6C78D318-9B96-43B4-97A7-7B3C8088983C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6C78D318-9B96-43B4-97A7-7B3C8088983C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6C78D318-9B96-43B4-97A7-7B3C8088983C}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {C3BE422E-8FFF-4EF8-BA89-186F153965D8} + EndGlobalSection +EndGlobal diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/CHANGELOG.md b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/CHANGELOG.md new file mode 100644 index 0000000000000..1576c72198be6 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/CHANGELOG.md @@ -0,0 +1,8 @@ +# Release History + +## 1.0.0-beta.1 (2024-09-11) + +### Features Added + +- Added authentication using Microsoft Entra ID for the service. +- Added reporting capabilities for the service. You can now publish the reports and artifacts generated by Playwright OSS to the service. \ No newline at end of file diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/Directory.Build.props b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/Directory.Build.props new file mode 100644 index 0000000000000..63bd836ad44b7 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/Directory.Build.props @@ -0,0 +1,6 @@ + + + + diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/README.md b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/README.md new file mode 100644 index 0000000000000..34bbeced75f66 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/README.md @@ -0,0 +1,66 @@ +# Microsoft Azure Playwright Testing client library for .NET + +Microsoft Playwright Testing is a fully managed service that uses the cloud to enable you to run Playwright tests with much higher parallelization across different operating system-browser combinations simultaneously. This means faster test runs with broader scenario coverage, which helps speed up delivery of features without sacrificing quality. The service also enables you to publish test results and related artifacts to the service and view them in the service portal enabling faster and easier troubleshooting. With Microsoft Playwright Testing service, you can release features faster and more confidently. + +## Getting started + +### Install the package + +Install the client library for .NET with [NuGet](https://www.nuget.org/): + +```dotnetcli +dotnet add package Azure.Developer.MicrosoftPlaywrightTesting.TestLogger --prerelease +``` + +### Authenticate the client + +To learn more about options for Microsoft Entra Id authentication, refer to [Azure.Identity credentials](https://github.com/Azure/azure-sdk-for-net/tree/main/sdk/identity/Azure.Identity#credentials). You can also refer to [our samples] on how to configurate different Azure Identity credentials. + +### Prerequisites + +- An [Azure subscription](https://azure.microsoft.com/free/dotnet/) +- Your Azure account must be assigned the [Owner](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#owner), [Contributor](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#contributor), or one of the [classic administrator roles](https://learn.microsoft.com/azure/role-based-access-control/rbac-and-directory-admin-roles#classic-subscription-administrator-roles). + +## Useful links +- [Quickstart: Run end-to-end tests at scale](https://aka.ms/mpt/quickstart) +- [Quickstart: Set up continuous end-to-end testing across different browsers and operating systems](https://aka.ms/mpt/ci) +- [Explore features and benefits](https://aka.ms/mpt/about) +- [View Microsoft Playwright Testing service demo](https://youtu.be/GenC1jAeTZE) +- [Documentation](https://aka.ms/mpt/docs) +- [Pricing](https://aka.ms/mpt/pricing) +- [Share feedback](https://aka.ms/mpt/feedback) + +## Key concepts + +Key concepts of the Microsoft Playwright Testing NUnit SDK for .NET can be found [here](https://aka.ms/mpt/what-is-mpt) + +## Examples + +Code samples for using this SDK can be found in the following locations +- [.NET Microsoft Playwright Testing NUnit Library Code Samples](https://aka.ms/mpt/sample) + +## Troubleshooting + +- File an issue via [GitHub Issues](https://github.com/Azure/azure-sdk-for-net/issues). +- Check [previous questions](https://stackoverflow.com/questions/tagged/azure+.net) or ask new ones on Stack Overflow using Azure and .NET tags. + +## Next steps + +- Run tests in a [CI/CD pipeline.](https://aka.ms/mpt/configure-pipeline) + +- Learn how to [manage access](https://aka.ms/mpt/manage-access) to the created workspace. + +- Experiment with different number of workers to [determine the optimal configuration of your test suite](https://aka.ms/mpt/parallelism). + +## Contributing + +This project welcomes contributions and suggestions. Most contributions require +you to agree to a Contributor License Agreement (CLA) declaring that you have +the right to, and actually do, grant us the rights to use your contribution. For +details, visit [cla.microsoft.com][cla]. + +This project has adopted the [Microsoft Open Source Code of Conduct][coc]. +For more information see the [Code of Conduct FAQ][coc_faq] or contact +[opencode@microsoft.com][coc_contact] with any additional questions or comments. + +![Impressions](https://azure-sdk-impressions.azurewebsites.net/api/impressions/azure-sdk-for-net/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/README.png) \ No newline at end of file diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/api/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.netstandard2.0.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/api/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.netstandard2.0.cs new file mode 100644 index 0000000000000..0880153e7dc93 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/api/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.netstandard2.0.cs @@ -0,0 +1,64 @@ +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger +{ + public partial class ConnectOptions where T : class, new() + { + public ConnectOptions() { } + public T? Options { get { throw null; } set { } } + public string? WsEndpoint { get { throw null; } set { } } + } + public partial class PlaywrightService + { + public PlaywrightService(Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.PlaywrightServiceOptions playwrightServiceOptions, Azure.Core.TokenCredential? credential = null) { } + public PlaywrightService(System.Runtime.InteropServices.OSPlatform? os = default(System.Runtime.InteropServices.OSPlatform?), string? runId = null, string? exposeNetwork = null, string? serviceAuth = null, bool? useCloudHostedBrowsers = default(bool?), Azure.Core.TokenCredential? credential = null) { } + public System.Threading.Timer? RotationTimer { get { throw null; } set { } } + public string ServiceAuth { get { throw null; } set { } } + public static string? ServiceEndpoint { get { throw null; } } + public bool UseCloudHostedBrowsers { get { throw null; } set { } } + public void Cleanup() { } + public System.Threading.Tasks.Task> GetConnectOptionsAsync(System.Runtime.InteropServices.OSPlatform? os = default(System.Runtime.InteropServices.OSPlatform?), string? runId = null, string? exposeNetwork = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) where T : class, new() { throw null; } + public System.Threading.Tasks.Task InitializeAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + } + public partial class PlaywrightServiceOptions + { + public PlaywrightServiceOptions(System.Runtime.InteropServices.OSPlatform? os = default(System.Runtime.InteropServices.OSPlatform?), string? runId = null, string? exposeNetwork = null, string? serviceAuth = null, string? useCloudHostedBrowsers = null, string? azureTokenCredentialType = null, string? managedIdentityClientId = null) { } + } + public partial class RunSettingKey + { + public static readonly string AzureTokenCredentialType; + public static readonly string EnableGitHubSummary; + public static readonly string EnableResultPublish; + public static readonly string ExposeNetwork; + public static readonly string ManagedIdentityClientId; + public static readonly string Os; + public static readonly string RunId; + public static readonly string ServiceAuthType; + public static readonly string UseCloudHostedBrowsers; + public RunSettingKey() { } + } + public partial class ServiceAuthType + { + public static readonly string AccessToken; + public static readonly string EntraId; + public ServiceAuthType() { } + } + public partial class ServiceEnvironmentVariable + { + public static readonly string PlaywrightServiceAccessToken; + public static readonly string PlaywrightServiceExposeNetwork; + public static readonly string PlaywrightServiceOs; + public static readonly string PlaywrightServiceRunId; + public static readonly string PlaywrightServiceUri; + public ServiceEnvironmentVariable() { } + } +} +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Client +{ + public partial class TestReportingClientOptions : Azure.Core.ClientOptions + { + public TestReportingClientOptions(Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Client.TestReportingClientOptions.ServiceVersion version = Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Client.TestReportingClientOptions.ServiceVersion.V2024_05_20_Preview) { } + public enum ServiceVersion + { + V2024_05_20_Preview = 1, + } + } +} diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/AssemblyInfo.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/AssemblyInfo.cs new file mode 100644 index 0000000000000..a50e225747117 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100d15ddcb29688295338af4b7686603fe614abd555e09efba8fb88ee09e1f7b1ccaeed2e8f823fa9eef3fdd60217fc012ea67d2479751a0b8c087a4185541b851bd8b16f8d91b840e51b1cb0ba6fe647997e57429265e85ef62d565db50a69ae1647d54d7bd855e4db3d8a91510e5bcbd0edfbbecaa20a7bd9ae74593daa7b11b4")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.csproj b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.csproj new file mode 100644 index 0000000000000..547bab277e258 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.csproj @@ -0,0 +1,22 @@ + + + Azure, Cloud, Playwright, Playwright Service, Reporting, Playwright Testing + + Package to integrate your Playwright test suite with Microsoft Playwright Testing + service + + 1.0.0-beta.1 + 1.0.0-beta.1 + true + $(RequiredTargetFrameworks) + enable + true + + + + + + + + + \ No newline at end of file diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Client/Internal/Argument.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Client/Internal/Argument.cs new file mode 100644 index 0000000000000..825022ea323ce --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Client/Internal/Argument.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Client +{ + internal static class Argument + { + public static void AssertNotNull(T value, string name) + { + if (value is null) + { + throw new ArgumentNullException(name); + } + } + + public static void AssertNotNull(T? value, string name) + where T : struct + { + if (!value.HasValue) + { + throw new ArgumentNullException(name); + } + } + + public static void AssertNotNullOrEmpty(IEnumerable value, string name) + { + if (value is null) + { + throw new ArgumentNullException(name); + } + if (value is ICollection collectionOfT && collectionOfT.Count == 0) + { + throw new ArgumentException("Value cannot be an empty collection.", name); + } + if (value is ICollection collection && collection.Count == 0) + { + throw new ArgumentException("Value cannot be an empty collection.", name); + } + using IEnumerator e = value.GetEnumerator(); + if (!e.MoveNext()) + { + throw new ArgumentException("Value cannot be an empty collection.", name); + } + } + + public static void AssertNotNullOrEmpty(string value, string name) + { + if (value is null) + { + throw new ArgumentNullException(name); + } + if (value.Length == 0) + { + throw new ArgumentException("Value cannot be an empty string.", name); + } + } + + public static void AssertNotNullOrWhiteSpace(string value, string name) + { + if (value is null) + { + throw new ArgumentNullException(name); + } + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("Value cannot be empty or contain only white-space characters.", name); + } + } + + public static void AssertNotDefault(ref T value, string name) + where T : struct, IEquatable + { + if (value.Equals(default)) + { + throw new ArgumentException("Value cannot be empty.", name); + } + } + + public static void AssertInRange(T value, T minimum, T maximum, string name) + where T : notnull, IComparable + { + if (minimum.CompareTo(value) > 0) + { + throw new ArgumentOutOfRangeException(name, "Value is less than the minimum allowed."); + } + if (maximum.CompareTo(value) < 0) + { + throw new ArgumentOutOfRangeException(name, "Value is greater than the maximum allowed."); + } + } + + public static void AssertEnumDefined(Type enumType, object value, string name) + { + if (!Enum.IsDefined(enumType, value)) + { + throw new ArgumentException($"Value not defined for {enumType.FullName}.", name); + } + } + + public static T CheckNotNull(T value, string name) + where T : class + { + AssertNotNull(value, name); + return value; + } + + public static string CheckNotNullOrEmpty(string value, string name) + { + AssertNotNullOrEmpty(value, name); + return value; + } + + public static void AssertNull(T value, string name, string message = null) + { + if (value != null) + { + throw new ArgumentException(message ?? "Value must be null.", name); + } + } + } +} diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Client/ReportingTestResultsClient.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Client/ReportingTestResultsClient.cs new file mode 100644 index 0000000000000..ed2f8c76c3fe1 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Client/ReportingTestResultsClient.cs @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Threading.Tasks; +using Azure.Core; +using Azure.Core.Pipeline; + +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Client +{ + // Data plane generated client. + /// The TestResults service client. + internal partial class ReportingTestResultsClient + { + private readonly HttpPipeline _pipeline; + private readonly Uri _endpoint; + private readonly string _apiVersion; + + /// The ClientDiagnostics is used to provide tracing support for the client library. + internal ClientDiagnostics ClientDiagnostics { get; } + + /// The HTTP pipeline for sending and receiving REST requests and responses. + public virtual HttpPipeline Pipeline => _pipeline; + + /// Initializes a new instance of ReportingTestResultsClient for mocking. + protected ReportingTestResultsClient() + { + } + + /// Initializes a new instance of ReportingTestResultsClient. + /// server parameter. + /// is null. + public ReportingTestResultsClient(Uri endpoint) : this(endpoint, new TestReportingClientOptions()) + { + } + + /// Initializes a new instance of ReportingTestResultsClient. + /// server parameter. + /// The options for configuring the client. + /// is null. + public ReportingTestResultsClient(Uri endpoint, TestReportingClientOptions options) + { + Argument.AssertNotNull(endpoint, nameof(endpoint)); + options ??= new TestReportingClientOptions(); + + ClientDiagnostics = new ClientDiagnostics(options, true); + _pipeline = HttpPipelineBuilder.Build(options, Array.Empty(), Array.Empty(), new ResponseClassifier()); + _endpoint = endpoint; + _apiVersion = options.Version; + } + + /// + /// [Protocol Method] Uploads a batch of test results to the test run + /// + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// + /// The to use. + /// The content to send as the body of the request. + /// access token. + /// Correlation-id used for tracing and debugging. + /// The request context, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual async Task UploadBatchTestResultsAsync(string workspaceId, RequestContent content, string authorization = null, string xCorrelationId = null, RequestContext context = null) + { + Argument.AssertNotNullOrEmpty(workspaceId, nameof(workspaceId)); + + using var scope = ClientDiagnostics.CreateScope("ReportingTestResultsClient.UploadBatchTestResults"); + scope.Start(); + try + { + using HttpMessage message = CreateUploadBatchTestResultsRequest(workspaceId, content, authorization, xCorrelationId, context); + return await _pipeline.ProcessMessageAsync(message, context).ConfigureAwait(false); + } + catch (Exception e) + { + scope.Failed(e); + throw; + } + } + + /// + /// [Protocol Method] Uploads a batch of test results to the test run + /// + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// + /// The to use. + /// The content to send as the body of the request. + /// access token. + /// Correlation-id used for tracing and debugging. + /// The request context, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual Response UploadBatchTestResults(string workspaceId, RequestContent content, string authorization = null, string xCorrelationId = null, RequestContext context = null) + { + Argument.AssertNotNullOrEmpty(workspaceId, nameof(workspaceId)); + + using var scope = ClientDiagnostics.CreateScope("ReportingTestResultsClient.UploadBatchTestResults"); + scope.Start(); + try + { + using HttpMessage message = CreateUploadBatchTestResultsRequest(workspaceId, content, authorization, xCorrelationId, context); + return _pipeline.ProcessMessage(message, context); + } + catch (Exception e) + { + scope.Failed(e); + throw; + } + } + + internal HttpMessage CreateUploadBatchTestResultsRequest(string workspaceId, RequestContent content, string authorization, string xCorrelationId, RequestContext context) + { + var message = _pipeline.CreateMessage(context, ResponseClassifier200400500); + var request = message.Request; + request.Method = RequestMethod.Post; + var uri = new RawRequestUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/workspaces/", false); + uri.AppendPath(workspaceId, true); + uri.AppendPath("/test-results/upload-batch", false); + uri.AppendQuery("api-version", _apiVersion, true); + request.Uri = uri; + request.Headers.Add("Accept", "application/json"); + if (authorization != null) + { + request.Headers.Add("Authorization", authorization); + } + if (xCorrelationId != null) + { + request.Headers.Add("x-correlation-id", xCorrelationId); + } + request.Headers.Add("Content-Type", "application/json"); + request.Content = content; + return message; + } + + private static ResponseClassifier _responseClassifier200400500; + private static ResponseClassifier ResponseClassifier200400500 => _responseClassifier200400500 ??= new StatusCodeClassifier(stackalloc ushort[] { 200, 400, 500 }); + } +} diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Client/ReportingTestRunsClient.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Client/ReportingTestRunsClient.cs new file mode 100644 index 0000000000000..4bdb0cee6e0a9 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Client/ReportingTestRunsClient.cs @@ -0,0 +1,482 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Threading.Tasks; +using Azure.Core; +using Azure.Core.Pipeline; + +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Client +{ + // Data plane generated client. + /// The TestRuns service client. + internal partial class ReportingTestRunsClient + { + private readonly HttpPipeline _pipeline; + private readonly Uri _endpoint; + private readonly string _apiVersion; + + /// The ClientDiagnostics is used to provide tracing support for the client library. + internal ClientDiagnostics ClientDiagnostics { get; } + + /// The HTTP pipeline for sending and receiving REST requests and responses. + public virtual HttpPipeline Pipeline => _pipeline; + + /// Initializes a new instance of ReportingTestRunsClient for mocking. + protected ReportingTestRunsClient() + { + } + + /// Initializes a new instance of ReportingTestRunsClient. + /// server parameter. + /// is null. + public ReportingTestRunsClient(Uri endpoint) : this(endpoint, new TestReportingClientOptions()) + { + } + + /// Initializes a new instance of ReportingTestRunsClient. + /// server parameter. + /// The options for configuring the client. + /// is null. + public ReportingTestRunsClient(Uri endpoint, TestReportingClientOptions options) + { + Argument.AssertNotNull(endpoint, nameof(endpoint)); + options ??= new TestReportingClientOptions(); + + ClientDiagnostics = new ClientDiagnostics(options, true); + _pipeline = HttpPipelineBuilder.Build(options, Array.Empty(), Array.Empty(), new ResponseClassifier()); + _endpoint = endpoint; + _apiVersion = options.Version; + } + + /// + /// [Protocol Method] Patch Test Run Info + /// + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// + /// The to use. + /// The to use. + /// The content to send as the body of the request. + /// Body Parameter content-type. Allowed values: "application/*+json" | "application/json" | "application/json-patch+json" | "text/json". + /// access token. + /// Correlation-id used for tracing and debugging. + /// The request context, which can override default behaviors of the client pipeline on a per-call basis. + /// or is null. + /// or is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual async Task PatchTestRunInfoAsync(string workspaceId, string testRunId, RequestContent content, ContentType contentType, string authorization = null, string xCorrelationId = null, RequestContext context = null) + { + Argument.AssertNotNullOrEmpty(workspaceId, nameof(workspaceId)); + Argument.AssertNotNullOrEmpty(testRunId, nameof(testRunId)); + + using var scope = ClientDiagnostics.CreateScope("ReportingTestRunsClient.PatchTestRunInfoAsync"); + scope.Start(); + try + { + using HttpMessage message = CreatePatchTestRunInfoAsyncRequest(workspaceId, testRunId, content, contentType, authorization, xCorrelationId, context); + return await _pipeline.ProcessMessageAsync(message, context).ConfigureAwait(false); + } + catch (Exception e) + { + scope.Failed(e); + throw; + } + } + + /// + /// [Protocol Method] Patch Test Run Info + /// + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// + /// The to use. + /// The to use. + /// The content to send as the body of the request. + /// Body Parameter content-type. Allowed values: "application/*+json" | "application/json" | "application/json-patch+json" | "text/json". + /// access token. + /// Correlation-id used for tracing and debugging. + /// The request context, which can override default behaviors of the client pipeline on a per-call basis. + /// or is null. + /// or is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual Response PatchTestRunInfo(string workspaceId, string testRunId, RequestContent content, ContentType contentType, string authorization = null, string xCorrelationId = null, RequestContext context = null) + { + Argument.AssertNotNullOrEmpty(workspaceId, nameof(workspaceId)); + Argument.AssertNotNullOrEmpty(testRunId, nameof(testRunId)); + + using var scope = ClientDiagnostics.CreateScope("ReportingTestRunsClient.PatchTestRunInfoAsync"); + scope.Start(); + try + { + using HttpMessage message = CreatePatchTestRunInfoAsyncRequest(workspaceId, testRunId, content, contentType, authorization, xCorrelationId, context); + return _pipeline.ProcessMessage(message, context); + } + catch (Exception e) + { + scope.Failed(e); + throw; + } + } + + /// + /// [Protocol Method] Get Test Run Info + /// + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// + /// The to use. + /// The to use. + /// access token. + /// Correlation-id used for tracing and debugging. + /// The request context, which can override default behaviors of the client pipeline on a per-call basis. + /// or is null. + /// or is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual async Task GetTestRunAsync(string workspaceId, string testRunId, string authorization, string xCorrelationId, RequestContext context) + { + Argument.AssertNotNullOrEmpty(workspaceId, nameof(workspaceId)); + Argument.AssertNotNullOrEmpty(testRunId, nameof(testRunId)); + + using var scope = ClientDiagnostics.CreateScope("ReportingTestRunsClient.GetTestRunAsync"); + scope.Start(); + try + { + using HttpMessage message = CreateGetTestRunAsyncRequest(workspaceId, testRunId, authorization, xCorrelationId, context); + return await _pipeline.ProcessMessageAsync(message, context).ConfigureAwait(false); + } + catch (Exception e) + { + scope.Failed(e); + throw; + } + } + + /// + /// [Protocol Method] Get Test Run Info + /// + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// + /// The to use. + /// The to use. + /// access token. + /// Correlation-id used for tracing and debugging. + /// The request context, which can override default behaviors of the client pipeline on a per-call basis. + /// or is null. + /// or is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual Response GetTestRun(string workspaceId, string testRunId, string authorization, string xCorrelationId, RequestContext context) + { + Argument.AssertNotNullOrEmpty(workspaceId, nameof(workspaceId)); + Argument.AssertNotNullOrEmpty(testRunId, nameof(testRunId)); + + using var scope = ClientDiagnostics.CreateScope("ReportingTestRunsClient.GetTestRunAsync"); + scope.Start(); + try + { + using HttpMessage message = CreateGetTestRunAsyncRequest(workspaceId, testRunId, authorization, xCorrelationId, context); + return _pipeline.ProcessMessage(message, context); + } + catch (Exception e) + { + scope.Failed(e); + throw; + } + } + + /// + /// [Protocol Method] Patch Test Run Shard Info + /// + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// + /// The to use. + /// The to use. + /// The to use. + /// The content to send as the body of the request. + /// Body Parameter content-type. Allowed values: "application/*+json" | "application/json" | "application/json-patch+json" | "text/json". + /// access token. + /// Correlation-id used for tracing and debugging. + /// The request context, which can override default behaviors of the client pipeline on a per-call basis. + /// , or is null. + /// , or is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual async Task PatchTestRunShardInfoAsync(string workspaceId, string testRunId, string shardId, RequestContent content, ContentType contentType, string authorization = null, string xCorrelationId = null, RequestContext context = null) + { + Argument.AssertNotNullOrEmpty(workspaceId, nameof(workspaceId)); + Argument.AssertNotNullOrEmpty(testRunId, nameof(testRunId)); + Argument.AssertNotNullOrEmpty(shardId, nameof(shardId)); + + using var scope = ClientDiagnostics.CreateScope("ReportingTestRunsClient.PatchTestRunShardInfoAsync"); + scope.Start(); + try + { + using HttpMessage message = CreatePatchTestRunShardInfoAsyncRequest(workspaceId, testRunId, shardId, content, contentType, authorization, xCorrelationId, context); + return await _pipeline.ProcessMessageAsync(message, context).ConfigureAwait(false); + } + catch (Exception e) + { + scope.Failed(e); + throw; + } + } + + /// + /// [Protocol Method] Patch Test Run Shard Info + /// + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// + /// The to use. + /// The to use. + /// The to use. + /// The content to send as the body of the request. + /// Body Parameter content-type. Allowed values: "application/*+json" | "application/json" | "application/json-patch+json" | "text/json". + /// access token. + /// Correlation-id used for tracing and debugging. + /// The request context, which can override default behaviors of the client pipeline on a per-call basis. + /// , or is null. + /// , or is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual Response PatchTestRunShardInfo(string workspaceId, string testRunId, string shardId, RequestContent content, ContentType contentType, string authorization = null, string xCorrelationId = null, RequestContext context = null) + { + Argument.AssertNotNullOrEmpty(workspaceId, nameof(workspaceId)); + Argument.AssertNotNullOrEmpty(testRunId, nameof(testRunId)); + Argument.AssertNotNullOrEmpty(shardId, nameof(shardId)); + + using var scope = ClientDiagnostics.CreateScope("ReportingTestRunsClient.PatchTestRunShardInfoAsync"); + scope.Start(); + try + { + using HttpMessage message = CreatePatchTestRunShardInfoAsyncRequest(workspaceId, testRunId, shardId, content, contentType, authorization, xCorrelationId, context); + return _pipeline.ProcessMessage(message, context); + } + catch (Exception e) + { + scope.Failed(e); + throw; + } + } + + /// + /// [Protocol Method] Get Test Run Results Uri + /// + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// + /// The to use. + /// The to use. + /// access token. + /// Correlation-id used for tracing and debugging. + /// The request context, which can override default behaviors of the client pipeline on a per-call basis. + /// or is null. + /// or is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual async Task GetTestRunResultsUriAsync(string workspaceId, string testRunId, string authorization, string xCorrelationId, RequestContext context) + { + Argument.AssertNotNullOrEmpty(workspaceId, nameof(workspaceId)); + Argument.AssertNotNullOrEmpty(testRunId, nameof(testRunId)); + + using var scope = ClientDiagnostics.CreateScope("ReportingTestRunsClient.GetTestRunResultsUriAsync"); + scope.Start(); + try + { + using HttpMessage message = CreateGetTestRunResultsUriAsyncRequest(workspaceId, testRunId, authorization, xCorrelationId, context); + return await _pipeline.ProcessMessageAsync(message, context).ConfigureAwait(false); + } + catch (Exception e) + { + scope.Failed(e); + throw; + } + } + + /// + /// [Protocol Method] Get Test Run Results Uri + /// + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// + /// The to use. + /// The to use. + /// access token. + /// Correlation-id used for tracing and debugging. + /// The request context, which can override default behaviors of the client pipeline on a per-call basis. + /// or is null. + /// or is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual Response GetTestRunResultsUri(string workspaceId, string testRunId, string authorization, string xCorrelationId, RequestContext context) + { + Argument.AssertNotNullOrEmpty(workspaceId, nameof(workspaceId)); + Argument.AssertNotNullOrEmpty(testRunId, nameof(testRunId)); + + using var scope = ClientDiagnostics.CreateScope("ReportingTestRunsClient.GetTestRunResultsUriAsync"); + scope.Start(); + try + { + using HttpMessage message = CreateGetTestRunResultsUriAsyncRequest(workspaceId, testRunId, authorization, xCorrelationId, context); + return _pipeline.ProcessMessage(message, context); + } + catch (Exception e) + { + scope.Failed(e); + throw; + } + } + + internal HttpMessage CreatePatchTestRunInfoAsyncRequest(string workspaceId, string testRunId, RequestContent content, ContentType contentType, string authorization, string xCorrelationId, RequestContext context) + { + var message = _pipeline.CreateMessage(context, ResponseClassifier200400401500); + var request = message.Request; + request.Method = RequestMethod.Patch; + var uri = new RawRequestUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/workspaces/", false); + uri.AppendPath(workspaceId, true); + uri.AppendPath("/test-runs/", false); + uri.AppendPath(testRunId, true); + uri.AppendQuery("api-version", _apiVersion, true); + request.Uri = uri; + request.Headers.Add("Accept", "application/json"); + if (authorization != null) + { + request.Headers.Add("Authorization", authorization); + } + if (xCorrelationId != null) + { + request.Headers.Add("x-correlation-id", xCorrelationId); + } + request.Headers.Add("Content-Type", contentType.ToString()); + request.Content = content; + return message; + } + + internal HttpMessage CreateGetTestRunAsyncRequest(string workspaceId, string testRunId, string authorization, string xCorrelationId, RequestContext context) + { + var message = _pipeline.CreateMessage(context, ResponseClassifier200400401500); + var request = message.Request; + request.Method = RequestMethod.Get; + var uri = new RawRequestUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/workspaces/", false); + uri.AppendPath(workspaceId, true); + uri.AppendPath("/test-runs/", false); + uri.AppendPath(testRunId, true); + uri.AppendQuery("api-version", _apiVersion, true); + request.Uri = uri; + request.Headers.Add("Accept", "application/json"); + if (authorization != null) + { + request.Headers.Add("Authorization", authorization); + } + if (xCorrelationId != null) + { + request.Headers.Add("x-correlation-id", xCorrelationId); + } + return message; + } + + internal HttpMessage CreatePatchTestRunShardInfoAsyncRequest(string workspaceId, string testRunId, string shardId, RequestContent content, ContentType contentType, string authorization, string xCorrelationId, RequestContext context) + { + var message = _pipeline.CreateMessage(context, ResponseClassifier200400401500); + var request = message.Request; + request.Method = RequestMethod.Patch; + var uri = new RawRequestUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/workspaces/", false); + uri.AppendPath(workspaceId, true); + uri.AppendPath("/test-runs/", false); + uri.AppendPath(testRunId, true); + uri.AppendPath("/shards/", false); + uri.AppendPath(shardId, true); + uri.AppendQuery("api-version", _apiVersion, true); + request.Uri = uri; + request.Headers.Add("Accept", "application/json"); + if (authorization != null) + { + request.Headers.Add("Authorization", authorization); + } + if (xCorrelationId != null) + { + request.Headers.Add("x-correlation-id", xCorrelationId); + } + request.Headers.Add("Content-Type", contentType.ToString()); + request.Content = content; + return message; + } + + internal HttpMessage CreateGetTestRunResultsUriAsyncRequest(string workspaceId, string testRunId, string authorization, string xCorrelationId, RequestContext context) + { + var message = _pipeline.CreateMessage(context, ResponseClassifier200400401500); + var request = message.Request; + request.Method = RequestMethod.Get; + var uri = new RawRequestUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/workspaces/", false); + uri.AppendPath(workspaceId, true); + uri.AppendPath("/test-runs/", false); + uri.AppendPath(testRunId, true); + uri.AppendPath("/resulturi", false); + uri.AppendQuery("api-version", _apiVersion, true); + request.Uri = uri; + request.Headers.Add("Accept", "application/json"); + if (authorization != null) + { + request.Headers.Add("Authorization", authorization); + } + if (xCorrelationId != null) + { + request.Headers.Add("x-correlation-id", xCorrelationId); + } + return message; + } + + private static ResponseClassifier _responseClassifier200400401500; + private static ResponseClassifier ResponseClassifier200400401500 => _responseClassifier200400401500 ??= new StatusCodeClassifier(stackalloc ushort[] { 200, 400, 401, 500 }); + } +} diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Client/TestReportingClientOptions.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Client/TestReportingClientOptions.cs new file mode 100644 index 0000000000000..cf4492767fe75 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Client/TestReportingClientOptions.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using Azure.Core; + +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Client +{ + /// Client options for TestReporting library clients. + public partial class TestReportingClientOptions : ClientOptions + { + private const ServiceVersion LatestVersion = ServiceVersion.V2024_05_20_Preview; + + /// The version of the service to use. + public enum ServiceVersion + { + /// Service version "2024-05-20-preview". + V2024_05_20_Preview = 1, + } + + internal string Version { get; } + + /// Initializes new instance of TestReportingClientOptions. + public TestReportingClientOptions(ServiceVersion version = LatestVersion) + { + Version = version switch + { + ServiceVersion.V2024_05_20_Preview => "2024-05-20-preview", + _ => throw new NotSupportedException() + }; + } + } +} diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/ConnectOptions.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/ConnectOptions.cs new file mode 100644 index 0000000000000..7b0b6e1fc17aa --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/ConnectOptions.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; + +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger; + +/// +/// Represents the connect options for a generic type. +/// +/// The type parameter. +public class ConnectOptions where T : class, new() +{ + /// + /// A browser websocket endpoint to connect to. + /// + public string? WsEndpoint { get; set; } + /// + /// Connect options for the service. + /// + public T? Options { get; set; } +} + +internal class BrowserConnectOptions +{ + public string? ExposeNetwork { get; set; } + public IEnumerable>? Headers { get; set; } + public float? SlowMo { get; set; } + public float? Timeout { get; set; } +} + +internal static class BrowserConnectOptionsConverter +{ + public static T Convert(object source) where T : class, new() + { + var target = new T(); + System.Type sourceType = source.GetType(); + System.Type targetType = typeof(T); + + foreach (System.Reflection.PropertyInfo? sourceProperty in sourceType.GetProperties()) + { + System.Reflection.PropertyInfo targetProperty = targetType.GetProperty(sourceProperty.Name); + if (targetProperty != null && targetProperty.CanWrite) + { + targetProperty.SetValue(target, sourceProperty.GetValue(source)); + } + } + + return target; + } +} diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Constants.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Constants.cs new file mode 100644 index 0000000000000..4b1f0b43a2bb5 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Constants.cs @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger; + +/// +/// Contains environment variable names used by the Playwright service. +/// +public class ServiceEnvironmentVariable +{ + /// + /// The environment variable for the Playwright service access token. + /// + public static readonly string PlaywrightServiceAccessToken = "PLAYWRIGHT_SERVICE_ACCESS_TOKEN"; + + /// + /// The environment variable for the Playwright service URL. + /// + public static readonly string PlaywrightServiceUri = "PLAYWRIGHT_SERVICE_URL"; + + /// + /// The environment variable for exposing the Playwright service network. + /// + public static readonly string PlaywrightServiceExposeNetwork = "PLAYWRIGHT_SERVICE_EXPOSE_NETWORK"; + + /// + /// The environment variable for the Playwright service operating system. + /// + public static readonly string PlaywrightServiceOs = "PLAYWRIGHT_SERVICE_OS"; + + /// + /// The environment variable for the Playwright service run ID. + /// + public static readonly string PlaywrightServiceRunId = "PLAYWRIGHT_SERVICE_RUN_ID"; +}; + +/// +/// Contains constants for supported operating systems on Microsoft Playwright Testing. +/// +internal class ServiceOs +{ + /// + /// Linux operating system. + /// + public static readonly string Linux = "linux"; + + /// + /// Windows operating system. + /// + public static readonly string Windows = "windows"; +}; + +/// +/// Contains constants for authentication methods. +/// +public class ServiceAuthType +{ + /// + /// Entra ID authentication method. + /// + public static readonly string EntraId = "EntraId"; + + /// + /// Access token authentication method. + /// + public static readonly string AccessToken = "AccessToken"; +}; + +/// +/// Contains constants for Azure token credential types. +/// +internal class AzureTokenCredentialType +{ + /// + /// Environment Credential. + /// + public static readonly string EnvironmentCredential = "EnvironmentCredential"; + + /// + /// Workload Identity Credential. + /// + public static readonly string WorkloadIdentityCredential = "WorkloadIdentityCredential"; + + /// + /// Managed Identity Credential. + /// + public static readonly string ManagedIdentityCredential = "ManagedIdentityCredential"; + + /// + /// Shared Token Cache Credential. + /// + public static readonly string SharedTokenCacheCredential = "SharedTokenCacheCredential"; + + /// + /// Visual Studio Credential. + /// + public static readonly string VisualStudioCredential = "VisualStudioCredential"; + + /// + /// Visual Studio Code Credential. + /// + public static readonly string VisualStudioCodeCredential = "VisualStudioCodeCredential"; + + /// + /// Azure CLI Credential. + /// + public static readonly string AzureCliCredential = "AzureCliCredential"; + + /// + /// Azure PowerShell Credential. + /// + public static readonly string AzurePowerShellCredential = "AzurePowerShellCredential"; + + /// + /// Azure Developer CLI Credential. + /// + public static readonly string AzureDeveloperCliCredential = "AzureDeveloperCliCredential"; + + /// + /// Interactive Browser Credential. + /// + public static readonly string InteractiveBrowserCredential = "InteractiveBrowserCredential"; + + /// + /// Default Azure Credential. + /// + public static readonly string DefaultAzureCredential = "DefaultAzureCredential"; +} + +/// +/// Contains constants for run setting keys. +/// +public class RunSettingKey +{ + /// + /// The operating system setting key. + /// + public static readonly string Os = "Os"; + + /// + /// The run ID setting key. + /// + public static readonly string RunId = "RunId"; + + /// + /// The expose network setting key. + /// + public static readonly string ExposeNetwork = "ExposeNetwork"; + + /// + /// The default authentication setting key. + /// + public static readonly string ServiceAuthType = "ServiceAuthType"; + + /// + /// The use cloud-hosted browsers setting key. + /// + public static readonly string UseCloudHostedBrowsers = "UseCloudHostedBrowsers"; + + /// + /// The Azure token credential type setting key. + /// + public static readonly string AzureTokenCredentialType = "AzureTokenCredentialType"; + + /// + /// The managed identity client ID setting key. + /// + public static readonly string ManagedIdentityClientId = "ManagedIdentityClientId"; + + /// + /// Enable GitHub summary setting key. + /// + public static readonly string EnableGitHubSummary = "EnableGitHubSummary"; + + /// + /// Enable Result publish. + /// + public static readonly string EnableResultPublish = "EnableResultPublish"; +} + +internal class Constants +{ + // Default constants + internal static readonly string s_default_os = ServiceOs.Linux; + internal static readonly string s_default_expose_network = ""; + + // Entra id access token constants + internal static readonly int s_entra_access_token_lifetime_left_threshold_in_minutes_for_rotation = 15; + internal static readonly string[] s_entra_access_token_scopes = new string[] { "https://management.core.windows.net/.default" }; + internal static readonly int s_entra_access_token_rotation_interval_period_in_minutes = 4; + + // Service constants + internal static readonly string s_api_version = "2023-10-01-preview"; + + // Error messages + internal static readonly string s_no_service_endpoint_error_message = "Please set PLAYWRIGHT_SERVICE_URL in your environment variables."; + internal static readonly string s_service_endpoint_removed_since_scalable_execution_disabled_error_message = "GetConnectOptionsAsync() method cannot be used when disableScalableExecution is set to true in the setup file."; + internal static readonly string s_no_auth_error = "Could not authenticate with the service. Please refer to https://aka.ms/mpt/authentication for more information."; + internal static readonly string s_invalid_mpt_pat_error = "The Access Token provided in the environment variable is invalid."; + internal static readonly string s_expired_mpt_pat_error = "The Access Token you are using is expired. Create a new token."; + internal static readonly string s_invalid_os_error = "Invalid operating system, supported values are 'linux' and 'windows'."; + + internal static readonly string s_playwright_service_disable_scalable_execution_environment_variable = "PLAYWRIGHT_SERVICE_DISABLE_SCALABLE_EXECUTION"; + internal static readonly string s_playwright_service_reporting_url_environment_variable = "PLAYWRIGHT_SERVICE_REPORTING_URL"; + internal static readonly string s_playwright_service_workspace_id_environment_variable = "PLAYWRIGHT_SERVICE_WORKSPACE_ID"; +} diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/EntraLifecycle.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/EntraLifecycle.cs new file mode 100644 index 0000000000000..a1c669902065d --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/EntraLifecycle.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Azure.Identity; +using Microsoft.IdentityModel.JsonWebTokens; + +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger; + +internal class EntraLifecycle +{ + internal string? _entraIdAccessToken; + internal long? _entraIdAccessTokenExpiry; + private readonly TokenCredential _tokenCredential; + private readonly JsonWebTokenHandler _jsonWebTokenHandler; + + public EntraLifecycle(TokenCredential? tokenCredential = null, JsonWebTokenHandler? jsonWebTokenHandler = null) + { + _tokenCredential = tokenCredential ?? new DefaultAzureCredential(); + _jsonWebTokenHandler = jsonWebTokenHandler ?? new JsonWebTokenHandler(); + SetEntraIdAccessTokenFromEnvironment(); + } + + internal async Task FetchEntraIdAccessTokenAsync(CancellationToken cancellationToken = default) + { + try + { + var tokenRequestContext = new TokenRequestContext(Constants.s_entra_access_token_scopes); + AccessToken accessToken = await _tokenCredential.GetTokenAsync(tokenRequestContext, cancellationToken).ConfigureAwait(false); + _entraIdAccessToken = accessToken.Token; + _entraIdAccessTokenExpiry = accessToken.ExpiresOn.ToUnixTimeSeconds(); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, _entraIdAccessToken); + return true; + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + return false; + } + } + + internal bool DoesEntraIdAccessTokenRequireRotation() + { + if (string.IsNullOrEmpty(_entraIdAccessToken)) + { + return true; + } + var lifetimeLeft = _entraIdAccessTokenExpiry - DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + return lifetimeLeft < Constants.s_entra_access_token_lifetime_left_threshold_in_minutes_for_rotation * 60; + } + + private void SetEntraIdAccessTokenFromEnvironment() + { + try + { + var token = Environment.GetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken); + JsonWebToken jsonWebToken = _jsonWebTokenHandler.ReadJsonWebToken(token); + jsonWebToken.TryGetClaim( + "aid", + out System.Security.Claims.Claim? aidClaim + ); + jsonWebToken.TryGetClaim( + "accountId", + out System.Security.Claims.Claim? accountIdClaim + ); + if (aidClaim != null || accountIdClaim != null) + return; // MPT Token + var expiry = (long)(jsonWebToken.ValidTo - new DateTime(1970, 1, 1)).TotalSeconds; + _entraIdAccessToken = token; + _entraIdAccessTokenExpiry = expiry; + } + catch (Exception) + { + } + } +} diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Model/CIInfo.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Model/CIInfo.cs new file mode 100644 index 0000000000000..e44bdae81bafd --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Model/CIInfo.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Model; + +internal class CIInfo +{ + internal string? Provider { get; set; } + internal string? Repo { get; set; } + internal string? Branch { get; set; } + internal string? Author { get; set; } + internal string? CommitId { get; set; } + internal string? RevisionUrl { get; set; } + internal string? RunId { get; set; } + internal int? RunAttempt { get; set; } + internal string? JobId { get; set; } +} diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Model/MPTResult.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Model/MPTResult.cs new file mode 100644 index 0000000000000..50006b0d55dc8 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Model/MPTResult.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Model; + +internal class RawTestStep +{ + internal string? Title { get; set; } + internal string? Category { get; set; } + internal string? StartTime { get; set; } + internal int Duration { get; set; } + internal string? Error { get; set; } + internal List Steps { get; set; } = new List(); + internal Location? Location { get; set; } + internal string? Snippet { get; set; } + internal int Count { get; set; } +} + +internal class Location +{ + internal int LineNumber { get; set; } +} + +internal class MPTError +{ + public string? message { get; set; } +} +internal class RawTestResult +{ + [JsonPropertyName("steps")] + public List Steps { get; set; } = new List(); + [JsonPropertyName("errors")] + public string errors { get; set; } = "[]"; + [JsonPropertyName("stdErr")] + public string stdErr { get; set; } = "[]"; + [JsonPropertyName("stdOut")] + public string stdOut { get; set; } = "[]"; +} + +internal class TokenDetails +{ + internal string? aid { get; set; } + internal string? oid { get; set; } + internal string? id { get; set; } + internal string? userName { get; set; } +} diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Model/TestReporting.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Model/TestReporting.cs new file mode 100644 index 0000000000000..08cff08b222fa --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Model/TestReporting.cs @@ -0,0 +1,313 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Model; + +internal enum AccessLevel +{ + Read = 0, + Write = 1, + ReadWrite = 2, + ReadAddCreateWrite = 3, +} + +internal partial class CIConfig +{ + [JsonPropertyName("ciProviderName")] public string CiProviderName { get; set; } = ""; + + [JsonPropertyName("branch")] public string Branch { get; set; } = ""; + + [JsonPropertyName("author")] public string Author { get; set; } = ""; + + [JsonPropertyName("commitId")] public string CommitId { get; set; } = ""; + + [JsonPropertyName("revisionUrl")] public string RevisionUrl { get; set; } = ""; +} + +internal partial class ClientConfig +{ + [JsonPropertyName("retries")] + public int Retries { get; set; } + + [JsonPropertyName("repeatEach")] + public int RepeatEach { get; set; } + + [JsonPropertyName("workers")] + public int Workers { get; set; } + + [JsonPropertyName("pwVersion")] public string PwVersion { get; set; } = ""; + + [JsonPropertyName("testFramework")] + public TestFramework? TestFramework { get; set; } + + [JsonPropertyName("shards")] + public Shard? Shards { get; set; } + + [JsonPropertyName("timeout")] + public int Timeout { get; set; } + + [JsonPropertyName("testType")] public string TestType { get; set; } = ""; + + [JsonPropertyName("testSdkLanguage")] public string TestSdkLanguage { get; set; } = ""; + + [JsonPropertyName("reporterPackageVersion")] public string ReporterPackageVersion { get; set; } = ""; +} + +internal partial class PreviousRetrySummary +{ + [JsonPropertyName("testExecutionId")] public string TestExecutionId { get; set; } = ""; + + [JsonPropertyName("retry")] + public int Retry { get; set; } + + [JsonPropertyName("status")] public string Status { get; set; } = ""; + + [JsonPropertyName("duration")] public long Duration { get; set; } + + [JsonPropertyName("startTime")] public string StartTime { get; set; } = ""; + + [JsonPropertyName("attachmentsMetadata")] public string AttachmentsMetadata { get; set; } = ""; + + [JsonPropertyName("artifactsPath")] + public ICollection ArtifactsPath { get; set; } = new List(); +} + +internal partial class Shard +{ + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("current")] + public int Current { get; set; } +} + +internal partial class TestFramework +{ + [JsonPropertyName("name")] public string Name { get; set; } = ""; + + [JsonPropertyName("version")] public string Version { get; set; } = ""; + + [JsonPropertyName("runnerName")] public string RunnerName { get; set; } = ""; +} + +internal partial class TestResults +{ + [JsonPropertyName("testExecutionId")] public string TestExecutionId { get; set; } = ""; + + [JsonPropertyName("testId")] public string TestId { get; set; } = ""; + + [JsonPropertyName("testCombinationId")] public string TestCombinationId { get; set; } = ""; + + [JsonPropertyName("runId")] public string RunId { get; set; } = ""; + + [JsonPropertyName("accountId")] public string AccountId { get; set; } = ""; + + [JsonPropertyName("suiteId")] public string SuiteId { get; set; } = ""; + + [JsonPropertyName("testTitle")] public string TestTitle { get; set; } = ""; + + [JsonPropertyName("suiteTitle")] public string SuiteTitle { get; set; } = ""; + + [JsonPropertyName("fileName")] public string FileName { get; set; } = ""; + + [JsonPropertyName("lineNumber")] + public int LineNumber { get; set; } + + [JsonPropertyName("retry")] + public int Retry { get; set; } + + [JsonPropertyName("status")] public string Status { get; set; } = ""; + + [JsonPropertyName("webTestConfig")] + public WebTestConfig? WebTestConfig { get; set; } + + [JsonPropertyName("ciConfig")] + public CIConfig? CiConfig { get; set; } + + [JsonPropertyName("resultsSummary")] + public TestResultsSummary? ResultsSummary { get; set; } + + [JsonPropertyName("previousRetries")] + public ICollection PreviousRetries { get; set; } = new List(); + + [JsonPropertyName("tags")] + public ICollection Tags { get; set; } = new List(); + + [JsonPropertyName("annotations")] + public ICollection Annotations { get; set; } = new List(); + + [JsonPropertyName("artifactsPath")] + public ICollection ArtifactsPath { get; set; } = new List(); +} + +internal partial class TestResultsSummary +{ + [JsonPropertyName("status")] public string Status { get; set; } = ""; + + [JsonPropertyName("duration")] public long Duration { get; set; } + + [JsonPropertyName("startTime")] public string StartTime { get; set; } = ""; + + [JsonPropertyName("attachmentsMetadata")] public string AttachmentsMetadata { get; set; } = ""; +} + +internal partial class TestResultsUri +{ + [JsonPropertyName("uri")] public string Uri { get; set; } = ""; + + [JsonPropertyName("createdAt")] public string CreatedAt { get; set; } = ""; + + [JsonPropertyName("expiresAt")] public string ExpiresAt { get; set; } = ""; + + [JsonPropertyName("accessLevel")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public AccessLevel? AccessLevel { get; set; } +} + +internal partial class TestRunDtoV2 +{ + [JsonPropertyName("testRunId")] + public string TestRunId { get; set; } = ""; + + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = ""; + + [JsonPropertyName("startTime")] + public string StartTime { get; set; } = ""; + + [JsonPropertyName("creatorId")] + public string CreatorId { get; set; } = ""; + + [JsonPropertyName("creatorName")] + public string CreatorName { get; set; } = ""; + + [JsonPropertyName("summary")] + public TestRunSummary? Summary { get; set; } + + [JsonPropertyName("resultsSummary")] + public TestRunResultsSummary? ResultsSummary { get; set; } + + [JsonPropertyName("ciConfig")] + public CIConfig? CiConfig { get; set; } + + [JsonPropertyName("testRunConfig")] + public ClientConfig? TestRunConfig { get; set; } + + [JsonPropertyName("testResultsUri")] + public TestResultsUri? TestResultsUri { get; set; } + + [JsonPropertyName("cloudRunEnabled")] public string CloudRunEnabled { get; set; } = ""; + + [JsonPropertyName("cloudReportingEnabled")] public string CloudReportingEnabled { get; set; } = ""; +} + +internal partial class TestRunResultsSummary +{ + [JsonPropertyName("numTotalTests")] + public long NumTotalTests { get; set; } + + [JsonPropertyName("numPassedTests")] + public long NumPassedTests { get; set; } + + [JsonPropertyName("numFailedTests")] + public long NumFailedTests { get; set; } + + [JsonPropertyName("numSkippedTests")] + public long NumSkippedTests { get; set; } + + [JsonPropertyName("numFlakyTests")] + public long NumFlakyTests { get; set; } + + [JsonPropertyName("status")] + public string Status { get; set; } = ""; +} + +internal partial class TestRunShardDto +{ + [JsonPropertyName("uploadCompleted")] public string UploadCompleted { get; set; } = ""; + + [JsonPropertyName("summary")] + public TestRunShardSummary? Summary { get; set; } + + [JsonPropertyName("testRunConfig")] + public ClientConfig? TestRunConfig { get; set; } + + [JsonPropertyName("resultsSummary")] + public TestRunResultsSummary? ResultsSummary { get; set; } +} + +internal partial class TestRunShardSummary +{ + [JsonPropertyName("status")] public string Status { get; set; } = ""; + + [JsonPropertyName("startTime")] public string StartTime { get; set; } = ""; + + [JsonPropertyName("endTime")] public string EndTime { get; set; } = ""; + + [JsonPropertyName("errorMessages")] + public ICollection ErrorMessages { get; set; } = new List(); + + [JsonPropertyName("uploadMetadata")] + public UploadMetadata? UploadMetadata { get; set; } + + [JsonPropertyName("totalTime")] public long TotalTime { get; set; } +} + +internal partial class TestRunSummary +{ + [JsonPropertyName("status")] public string Status { get; set; } = ""; + + [JsonPropertyName("startTime")] public string StartTime { get; set; } = ""; + + [JsonPropertyName("endTime")] public string EndTime { get; set; } = ""; + + [JsonPropertyName("billableTime")] public long BillableTime { get; set; } + + [JsonPropertyName("totalTime")] public long TotalTime { get; set; } + + [JsonPropertyName("numBrowserSessions")] public long NumBrowserSessions { get; set; } + + [JsonPropertyName("jobs")] + public ICollection Jobs { get; set; } = new List(); + + [JsonPropertyName("projects")] + public ICollection Projects { get; set; } = new List(); + + [JsonPropertyName("tags")] + public ICollection Tags { get; set; } = new List(); + + [JsonPropertyName("errorMessages")] + public ICollection ErrorMessages { get; set; } = new List(); + + [JsonPropertyName("uploadMetadata")] + public UploadMetadata? UploadMetadata { get; set; } +} + +internal partial class UploadMetadata +{ + [JsonPropertyName("numTestResults")] public long NumTestResults { get; set; } + + [JsonPropertyName("numTotalAttachments")] public long NumTotalAttachments { get; set; } + + [JsonPropertyName("sizeTotalAttachments")] public long SizeTotalAttachments { get; set; } +} + +internal partial class UploadTestResultsRequest +{ + [JsonPropertyName("value")] + public ICollection Value { get; set; } = new List(); +} + +internal partial class WebTestConfig +{ + [JsonPropertyName("jobName")] public string JobName { get; set; } = ""; + + [JsonPropertyName("projectName")] public string ProjectName { get; set; } = ""; + + [JsonPropertyName("browserName")] public string BrowserName { get; set; } = ""; + + [JsonPropertyName("os")] public string Os { get; set; } = ""; +} diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/PlaywrightReporter.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/PlaywrightReporter.cs new file mode 100644 index 0000000000000..bccfa06325172 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/PlaywrightReporter.cs @@ -0,0 +1,760 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Azure.Storage.Blobs; +using Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Model; +using Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Utility; +using Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Client; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Client; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Logging; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Utilities; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.IO; +using System.Text.Json; +using PlaywrightConstants = Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Utility.Constants; +using Azure.Core; +using Azure.Core.Serialization; + +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger; + +[FriendlyName("ms-playwright-service")] +[ExtensionUri("logger://Microsoft/Playwright/ServiceLogger/v1")] +internal class PlaywrightReporter : ITestLoggerWithParameters +{ + private Dictionary? _parametersDictionary; + + private bool IsInitialized { get; set; } + + private HttpClient? _httpClient; + + private ReportingTestResultsClient? _reportingTestResultsClient; + private ReportingTestRunsClient? _reportingTestRunsClient; + + private static readonly JsonWebTokenHandler s_tokenHandler = new(); + + private readonly LogLevel _logLevel = LogLevel.Debug; + + internal static string EnableConsoleLog { get => Environment.GetEnvironmentVariable(PlaywrightConstants.PLAYWRIGHT_SERVICE_DEBUG) ?? "false"; set { } } + + internal string? PortalUrl { get; set; } + + internal static string? BaseUrl { get => Environment.GetEnvironmentVariable(PlaywrightConstants.PLAYWRIGHT_SERVICE_REPORTING_URL); private set { } } + + internal static string AccessToken { get => Environment.GetEnvironmentVariable(PlaywrightConstants.PLAYWRIGHT_SERVICE_ACCESS_TOKEN) ?? ""; set { } } + + internal string? WorkspaceId { get; set; } + + internal TokenDetails? TokenDetails { get; set; } + + internal CIInfo? CIInfo { get; set; } + + internal string? RunId { get; set; } + + internal DateTime TestRunStartTime { get; private set; } + + internal int TotalTestCount { get; private set; } + + internal int PassedTestCount { get; private set; } + + internal int FailedTestCount { get; private set; } + + internal int SkippedTestCount { get; private set; } + + internal TestRunDtoV2? TestRun { get; set; } + + internal TestRunShardDto? TestRunShard { get; set; } + + internal bool EnableGithubSummary { get; set; } = true; + internal bool EnableResultPublish { get; set; } = true; + + internal List TestResults = new(); + + internal ConcurrentDictionary RawTestResultsMap = new(); + + internal PlaywrightService? playwrightService; + private List informationalMessages = new(); + private List processedErrorMessageKeys = new(); + + public void Initialize(TestLoggerEvents events, Dictionary parameters) + { + ValidateArg.NotNull(events, nameof(events)); + _parametersDictionary = parameters; + Initialize(events, _parametersDictionary[DefaultLoggerParameterNames.TestRunDirectory]!); + } + + public void Initialize(TestLoggerEvents events, string testResultsDirPath) + { + ValidateArg.NotNull(events, nameof(events)); + ValidateArg.NotNullOrEmpty(testResultsDirPath, nameof(testResultsDirPath)); + + // Register for the events. + events.TestRunMessage += TestMessageHandler; + events.TestResult += TestResultHandler; + events.TestRunComplete += TestRunCompleteHandler; + events.TestRunStart += TestRunStartHandler; + } + + #region Event Handlers + + internal void TestRunStartHandler(object? sender, TestRunStartEventArgs e) + { + InitializePlaywrightReporter(e.TestRunCriteria.TestRunSettings!); + LogMessage("Test Run start Handler"); + if (!EnableResultPublish) + { + return; + } + if (!IsInitialized || _reportingTestResultsClient == null || _reportingTestRunsClient == null) + { + LogErrorMessage("Test Run setup issue exiting handler"); + return; + } + + var startTime = TestRunStartTime.ToString("yyyy-MM-ddTHH:mm:ssZ"); + LogMessage("Test Run start time: " + startTime); + var corelationId = Guid.NewGuid().ToString(); + var gitBasedRunName = ReporterUtils.GetRunName(CiInfoProvider.GetCIInfo()); + var runName = string.IsNullOrEmpty(gitBasedRunName) ? Guid.NewGuid().ToString() : gitBasedRunName; + var run = new TestRunDtoV2 + { + TestRunId = RunId!, + DisplayName = runName, + StartTime = startTime, + CreatorId = TokenDetails!.oid ?? "", + CreatorName = TokenDetails.userName ?? "", + //CloudRunEnabled = "false", + CloudReportingEnabled = "true", + Summary = new TestRunSummary + { + Status = "RUNNING", + StartTime = startTime, + //Projects = ["playwright-dotnet"], + //Tags = ["Nunit", "dotnet"], + //Jobs = ["playwright-dotnet"], + }, + CiConfig = new CIConfig // TODO fetch dynamically + { + Branch = CIInfo!.Branch ?? "", + Author = CIInfo.Author ?? "", + CommitId = CIInfo.CommitId ?? "", + RevisionUrl = CIInfo.RevisionUrl ?? "" + }, + TestRunConfig = new ClientConfig // TODO fetch some of these dynamically + { + Workers = 1, + PwVersion = "1.40", + Timeout = 60000, + TestType = "WebTest", + TestSdkLanguage = "Dotnet", + TestFramework = new TestFramework() { Name = "VSTest", RunnerName = "Nunit/MSTest", Version = "3.1" }, // TODO fetch runner name MSTest/Nunit + ReporterPackageVersion = "0.0.1-dotnet", + Shards = new Shard() { Current = 0, Total = 1 } + } + }; + var shard = new TestRunShardDto + { + UploadCompleted = "false", + Summary = new TestRunShardSummary + { + Status = "RUNNING", + StartTime = startTime, + }, + TestRunConfig = new ClientConfig // TODO fetch some of these dynamically + { + Workers = 1, + PwVersion = "1.40", + Timeout = 60000, + TestType = "Functional", + TestSdkLanguage = "dotnet", + TestFramework = new TestFramework() { Name = "VSTest", RunnerName = "Nunit", Version = "3.1" }, + ReporterPackageVersion = "0.0.1-dotnet", + Shards = new Shard() { Current = 0, Total = 1 }, + } + }; + var token = "Bearer " + AccessToken; + TestRunDtoV2? response = null; + try + { + Response apiResponse = _reportingTestRunsClient.PatchTestRunInfo(WorkspaceId, RunId, RequestContent.Create(run), "application/json", token, corelationId); + if (apiResponse.Content != null) + { + response = apiResponse.Content!.ToObject(new JsonObjectSerializer()); + } + } + catch (Exception ex) + { + Logger.Log(true, LogLevel.Error, ex.ToString()); + throw; + } + if (response != null) + { + TestRun = response; + + // Start shard + corelationId = Guid.NewGuid().ToString(); + TestRunShardDto? response1 = null; + try + { + Response apiResponse = _reportingTestRunsClient.PatchTestRunShardInfo(WorkspaceId, RunId, "1", RequestContent.Create(shard), "application/json", token, corelationId); + if (apiResponse.Content != null) + { + response1 = apiResponse.Content!.ToObject(new JsonObjectSerializer()); + } + } + catch (Exception ex) + { + Logger.Log(true, LogLevel.Error, ex.ToString()); + throw; + } + if (response1 != null) + { + TestRunShard = shard; // due to wrong response type TODO + } + else + { + Logger.Log(true, LogLevel.Error, "Run shard creation Failed"); + } + } + else + { + Logger.Log(true, LogLevel.Error, "Run creation Failed"); + } + LogMessage("Test Run start Handler completed"); + } + + internal void TestMessageHandler(object? sender, TestRunMessageEventArgs e) + { + LogMessage("Test Message Handler"); + ValidateArg.NotNull(sender, nameof(sender)); + ValidateArg.NotNull(e, nameof(e)); + LogMessage(e.Message); + } + + internal void TestResultHandler(object? sender, TestResultEventArgs e) + { + LogMessage("Test Result Handler"); + TestResults? testResult = GetTestCaseResultData(e.Result); + if (!EnableResultPublish) + { + return; + } + if (!IsInitialized || _reportingTestResultsClient == null || _reportingTestRunsClient == null) + { + LogErrorMessage("Test Run setup issue exiting handler"); + return; + } + // Set various counts (passed tests, failed tests, total tests) + if (testResult != null) + { + TotalTestCount++; + if (testResult.Status == "failed") + { + FailedTestCount++; + } + else if (testResult.Status == "passed") + { + PassedTestCount++; + } + else if (testResult.Status == "skipped") + { + SkippedTestCount++; + } + } + if (testResult != null) + { + TestResults.Add(testResult); + } + } + + internal void TestRunCompleteHandler(object? sender, TestRunCompleteEventArgs e) + { + LogMessage("Test Run End Handler"); + if (!EnableResultPublish) + { + UpdateTestRun(e); // will not publish results, but will print informational messages + return; + } + if (!IsInitialized || _reportingTestResultsClient == null || _reportingTestRunsClient == null || TestRun == null) + { + LogErrorMessage("Test Run setup issue exiting handler"); + EnableResultPublish = false; + UpdateTestRun(e); // will not publish results, but will print informational messages + return; + } + // Upload TestResults + var corelationId = Guid.NewGuid().ToString(); + var token = "Bearer " + AccessToken; + + var body = new UploadTestResultsRequest() { Value = TestResults }; + try + { + _reportingTestResultsClient.UploadBatchTestResults(WorkspaceId, RequestContent.Create(JsonSerializer.Serialize(body)), token, corelationId, null); + LogMessage("Test Result Uploaded"); + } + catch (Exception ex) + { + LogErrorMessage(ex.Message); + } + + corelationId = Guid.NewGuid().ToString(); + TestResultsUri? sasUri = null; + Response response = _reportingTestRunsClient.GetTestRunResultsUri(WorkspaceId, RunId, token, corelationId, null); + var serializer = new JsonObjectSerializer(); + if (response.Content != null) + { + sasUri = response.Content.ToObject(serializer); + } + if (sasUri != null && !string.IsNullOrEmpty(sasUri.Uri)) + { + LogMessage("Test Run Uri: " + sasUri.ToString()); + foreach (TestResults testResult in TestResults) + { + if (RawTestResultsMap.TryGetValue(testResult.TestExecutionId!, out RawTestResult? rawResult) && rawResult != null) + { + // Upload rawResult to blob storage using sasUri + var rawTestResultJson = JsonSerializer.Serialize(rawResult); + var filePath = $"{testResult.TestExecutionId}/rawTestResult.json"; + UploadBuffer(sasUri.Uri!, rawTestResultJson, filePath); + } + else + { + LogMessage("Couldnt find rawResult for Id: " + testResult.TestExecutionId); + } + } + } + else + { + LogMessage("MPT API error: failed to upload artifacts"); + } + LogMessage("Test Results uploaded"); + // Update TestRun with CLIENT_COMPLETE + if (UpdateTestRun(e) == false) + { + LogErrorMessage("Test Run setup issue, Failed to update TestRun"); + } + } + #endregion + + private bool UpdateTestRun(TestRunCompleteEventArgs e) + { + if (EnableResultPublish) + { + if (!IsInitialized || _reportingTestResultsClient == null || _reportingTestRunsClient == null || TestRun == null || TestRunShard == null) + { + // no-op + } + else + { + DateTime testRunStartedOn = DateTime.MinValue; + DateTime testRunEndedOn = DateTime.UtcNow; + long durationInMs = 0; + + var result = FailedTestCount > 0 ? "failed" : "passed"; + + if (e.ElapsedTimeInRunningTests != null) + { + testRunEndedOn = TestRunStartTime.Add(e.ElapsedTimeInRunningTests); + durationInMs = (long)e.ElapsedTimeInRunningTests.TotalMilliseconds; + } + + // Update Shard End + if (TestRunShard.Summary == null) + TestRunShard.Summary = new TestRunShardSummary(); + TestRunShard.Summary.Status = "CLIENT_COMPLETE"; + TestRunShard.Summary.StartTime = TestRunStartTime.ToString("yyyy-MM-ddTHH:mm:ssZ"); + TestRunShard.Summary.EndTime = testRunEndedOn.ToString("yyyy-MM-ddTHH:mm:ssZ"); + TestRunShard.Summary.TotalTime = durationInMs; + TestRunShard.Summary.UploadMetadata = new UploadMetadata() { NumTestResults = TotalTestCount, NumTotalAttachments = 0, SizeTotalAttachments = 0 }; + LogMessage("duration:" + durationInMs); + LogMessage("StartTime:" + TestRunShard.Summary.StartTime); + LogMessage("EndTime:" + TestRunShard.Summary.EndTime); + TestRunShard.ResultsSummary = new TestRunResultsSummary + { + NumTotalTests = TotalTestCount, + NumPassedTests = PassedTestCount, + NumFailedTests = FailedTestCount, + NumSkippedTests = SkippedTestCount, + NumFlakyTests = 0, // TODO: Implement flaky tests + Status = result + }; + TestRunShard.UploadCompleted = "true"; + var token = "Bearer " + AccessToken; + var corelationId = Guid.NewGuid().ToString(); + try + { + _reportingTestRunsClient.PatchTestRunShardInfo(WorkspaceId, RunId, "1", RequestContent.Create(TestRunShard), "application/json", token, corelationId); + } + catch (Exception ex) + { + LogErrorMessage("Test Run shard failed: " + ex.ToString()); + throw; + } + + LogMessage("TestRun Shard updated"); + playwrightService?.Cleanup(); + Console.WriteLine("Visit MPT Portal for Debugging: " + Uri.EscapeUriString(PortalUrl!)); + if (EnableGithubSummary) + GenerateMarkdownSummary(); + } + } + if (informationalMessages.Count > 0) + Console.WriteLine(); + int index = 1; + foreach (string message in informationalMessages) + { + Console.WriteLine($"{index}) {message}"); + } + return true; + } + + private TestResults GetTestCaseResultData(TestResult testResultSource) + { + if (testResultSource == null) + return new TestResults(); + + LogMessage(testResultSource.TestCase.DisplayName); + TestResults testCaseResultData = new() + { + ArtifactsPath = new List(), + + AccountId = WorkspaceId!, + RunId = RunId!, + TestExecutionId = GetExecutionId(testResultSource).ToString() + }; + testCaseResultData.TestCombinationId = testCaseResultData.TestExecutionId; // TODO check + testCaseResultData.TestId = testResultSource.TestCase.Id.ToString(); + testCaseResultData.TestTitle = testResultSource.TestCase.DisplayName; + var className = FetchTestClassName(testResultSource.TestCase.FullyQualifiedName); + testCaseResultData.SuiteTitle = className; + testCaseResultData.SuiteId = className; + testCaseResultData.FileName = FetchFileName(testResultSource.TestCase.Source); + testCaseResultData.LineNumber = testResultSource.TestCase.LineNumber; + testCaseResultData.Retry = 0; // TODO Retry and PreviousRetries + testCaseResultData.WebTestConfig = new WebTestConfig + { + JobName = CIInfo!.JobId ?? "", + //ProjectName = "playwright-dotnet", // TODO no project concept NA?? + //BrowserName = "chromium", // TODO check if possible to get from test + Os = GetCurrentOS(), + }; + //testCaseResultData.Annotations = ["windows"]; // TODO MSTest/Nunit annotation ?? + //testCaseResultData.Tags = ["windows"]; // TODO NA ?? + + TimeSpan duration = testResultSource.Duration; + testCaseResultData.ResultsSummary = new TestResultsSummary + { + Duration = (long)duration.TotalMilliseconds, // TODO fallback get from End-Start + StartTime = testResultSource.StartTime.UtcDateTime.ToString(), + Status = "inconclusive" + }; + TestOutcome outcome = testResultSource.Outcome; + switch (outcome) + { + case TestOutcome.Passed: + testCaseResultData.ResultsSummary.Status = "passed"; + testCaseResultData.Status = "passed"; + break; + case TestOutcome.Failed: + testCaseResultData.ResultsSummary.Status = "failed"; + testCaseResultData.Status = "failed"; + break; + case TestOutcome.Skipped: + testCaseResultData.ResultsSummary.Status = "skipped"; + testCaseResultData.Status = "skipped"; + break; + default: + testCaseResultData.ResultsSummary.Status = "inconclusive"; + testCaseResultData.Status = "inconclusive"; + break; + } + // errorMessage, Stacktrace + RawTestResult rawResult = GetRawResultObject(testResultSource); + RawTestResultsMap.TryAdd(testCaseResultData.TestExecutionId, rawResult); + + if (!string.IsNullOrEmpty(testResultSource.ErrorMessage)) + { + ProcessTestResultMessage(testResultSource.ErrorMessage); + // TODO send it in blob + } + if (!string.IsNullOrEmpty(testResultSource.ErrorStackTrace)) + { + ProcessTestResultMessage(testResultSource.ErrorStackTrace); + // TODO send it in blob + } + + // TODO ArtifactsPaths + return testCaseResultData; + } + + private void ProcessTestResultMessage(string? message) + { + if (string.IsNullOrEmpty(message)) + { + return; + } + foreach (TestResultError testResultErrorObj in TestResultErrorConstants.ErrorConstants) + { + if (processedErrorMessageKeys.Contains(testResultErrorObj.Key!)) + continue; + if (testResultErrorObj.Pattern.IsMatch(message)) + { + AddInformationalMessage(testResultErrorObj.Message!); + processedErrorMessageKeys.Add(testResultErrorObj.Key!); + } + } + } + + private TokenDetails ParseWorkspaceIdFromAccessToken(string accessToken) + { + TokenDetails tokenDetails = new(); + if (accessToken == null) + { + if (string.IsNullOrEmpty(accessToken)) + { + throw new ArgumentNullException("AccessToken is null or empty"); + } + } + try + { + JsonWebToken inputToken = (JsonWebToken)s_tokenHandler.ReadToken(accessToken); + var aid = inputToken.Claims.FirstOrDefault(c => c.Type == "aid")?.Value ?? string.Empty; + + if (!string.IsNullOrEmpty(aid)) // Custom Token + { + LogMessage("Custom Token parsing"); + tokenDetails.aid = aid; + tokenDetails.oid = inputToken.Claims.FirstOrDefault(c => c.Type == "oid")?.Value ?? string.Empty; + tokenDetails.id = inputToken.Claims.FirstOrDefault(c => c.Type == "id")?.Value ?? string.Empty; + tokenDetails.userName = inputToken.Claims.FirstOrDefault(c => c.Type == "name")?.Value ?? string.Empty; + } + else // Entra Token + { + LogMessage("Entra Token parsing"); + tokenDetails.aid = Environment.GetEnvironmentVariable(PlaywrightConstants.PLAYWRIGHT_SERVICE_WORKSPACE_ID) ?? string.Empty; + tokenDetails.oid = inputToken.Claims.FirstOrDefault(c => c.Type == "oid")?.Value ?? string.Empty; + tokenDetails.id = string.Empty; + tokenDetails.userName = inputToken.Claims.FirstOrDefault(c => c.Type == "name")?.Value ?? string.Empty; + // TODO add back suport for old claims https://devdiv.visualstudio.com/OnlineServices/_git/PlaywrightService?path=/src/Common/Authorization/JwtSecurityTokenValidator.cs&version=GBmain&line=200&lineEnd=200&lineStartColumn=30&lineEndColumn=52&lineStyle=plain&_a=contents + } + + return tokenDetails; + } + catch (Exception ex) + { + LogErrorMessage(ex.Message); + throw; + } + } + + private static Guid GetExecutionId(TestResult testResult) + { + TestProperty? executionIdProperty = testResult.Properties.FirstOrDefault( + property => property.Id.Equals(PlaywrightConstants.ExecutionIdPropertyIdentifier)); + + Guid executionId = Guid.Empty; + if (executionIdProperty != null) + executionId = testResult.GetPropertyValue(executionIdProperty, Guid.Empty); + + return executionId.Equals(Guid.Empty) ? Guid.NewGuid() : executionId; + } + + private static RawTestResult GetRawResultObject(TestResult testResultSource) + { + List errors = new();//[testResultSource.ErrorMessage]; + if (testResultSource.ErrorMessage != null) + errors.Add(new MPTError() { message = testResultSource.ErrorMessage }); + var rawTestResult = new RawTestResult + { + errors = JsonSerializer.Serialize(errors), + stdErr = testResultSource?.ErrorStackTrace ?? string.Empty + }; + return rawTestResult; + } + + private static string GetCloudFilePath(string uri, string fileRelativePath) + { + // Assuming Constants.SAS_URI_SEPARATOR is a static property or field in a class named Constants + // that holds the character used to split the URI and the SAS token. + string[] parts = uri.Split(new string[] { PlaywrightConstants.SASUriSeparator }, StringSplitOptions.None); + string containerUri = parts[0]; + string sasToken = parts.Length > 1 ? parts[1] : string.Empty; + + return $"{containerUri}/{fileRelativePath}?{sasToken}"; + } + + private void UploadBuffer(string uri, string buffer, string fileRelativePath) + { + string cloudFilePath = GetCloudFilePath(uri, fileRelativePath); + LogMessage(cloudFilePath); + LogMessage(buffer); + BlobClient blobClient = new(new Uri(cloudFilePath)); + byte[] bufferBytes = Encoding.UTF8.GetBytes(buffer); + blobClient.Upload(new BinaryData(bufferBytes), overwrite: true); + LogMessage($"Uploaded buffer to {fileRelativePath}"); + } + + private static string FetchTestClassName(string fullyQualifiedName) + { + string[] parts = fullyQualifiedName.Split('.'); + return string.Join(".", parts.Take(parts.Length - 1)); + } + + private static string FetchFileName(string fullFilePath) + { + char[] delimiters = { '\\', '/' }; + string[] parts = fullFilePath.Split(delimiters); + return parts.Last(); + } + + private static string GetCurrentOS() + { + PlatformID platform = Environment.OSVersion.Platform; + if (platform == PlatformID.Unix) + return "Linux"; + else if (platform == PlatformID.MacOSX) + return "MacOS"; + else + return "Windows"; + } + + private void LogMessage(string message) + { + bool enable = bool.TryParse(EnableConsoleLog, out enable) == true && enable; + Logger.Log(enable, _logLevel, message); + } + + private static void LogErrorMessage(string message) + { + Logger.Log(true, LogLevel.Error, message); + } + + private void InitializePlaywrightReporter(string xmlSettings) + { + if (IsInitialized) + { + return; + } + + Dictionary runParameters = XmlRunSettingsUtilities.GetTestRunParameters(xmlSettings); + runParameters.TryGetValue(RunSettingKey.RunId, out var runId); + // If run id is not provided and not set via env, try fetching it from CI info. + CIInfo = CiInfoProvider.GetCIInfo(); + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable(PlaywrightConstants.PLAYWRIGHT_SERVICE_RUN_ID))) + { + if (string.IsNullOrEmpty(runId?.ToString())) + Environment.SetEnvironmentVariable(PlaywrightConstants.PLAYWRIGHT_SERVICE_RUN_ID, ReporterUtils.GetRunId(CIInfo)); + else + Environment.SetEnvironmentVariable(PlaywrightConstants.PLAYWRIGHT_SERVICE_RUN_ID, runId!.ToString()); + } + else + { + PlaywrightService.GetDefaultRunId(); // will not set run id if already present in the environment variable + } + + runParameters.TryGetValue(RunSettingKey.ServiceAuthType, out var serviceAuth); + runParameters.TryGetValue(RunSettingKey.AzureTokenCredentialType, out var azureTokenCredential); + runParameters.TryGetValue(RunSettingKey.ManagedIdentityClientId, out var managedIdentityClientId); + runParameters.TryGetValue(RunSettingKey.EnableGitHubSummary, out var enableGithubSummary); + runParameters.TryGetValue(RunSettingKey.EnableResultPublish, out var enableResultPublish); + string? enableGithubSummaryString = enableGithubSummary?.ToString(); + string? enableResultPublishString = enableResultPublish?.ToString(); + + EnableGithubSummary = string.IsNullOrEmpty(enableGithubSummaryString) || bool.Parse(enableGithubSummaryString!); + EnableResultPublish = string.IsNullOrEmpty(enableResultPublishString) || bool.Parse(enableResultPublishString!); + + PlaywrightServiceOptions? playwrightServiceSettings = null; + try + { + playwrightServiceSettings = new(runId: runId?.ToString(), serviceAuth: serviceAuth?.ToString(), azureTokenCredentialType: azureTokenCredential?.ToString(), managedIdentityClientId: managedIdentityClientId?.ToString()); + } + catch (Exception ex) + { + Console.Error.WriteLine("Failed to initialize PlaywrightServiceSettings: " + ex.Message); + Environment.Exit(1); + } + + // setup entra rotation handlers + playwrightService = new PlaywrightService(null, playwrightServiceSettings!.RunId, null, playwrightServiceSettings.ServiceAuth, null, playwrightServiceSettings.AzureTokenCredential); +#pragma warning disable AZC0102 // Do not use GetAwaiter().GetResult(). Use the TaskExtensions.EnsureCompleted() extension method instead. + playwrightService.InitializeAsync().GetAwaiter().GetResult(); +#pragma warning restore AZC0102 // Do not use GetAwaiter().GetResult(). Use the TaskExtensions.EnsureCompleted() extension method instead. + + RunId = Environment.GetEnvironmentVariable(PlaywrightConstants.PLAYWRIGHT_SERVICE_RUN_ID); + + try + { + ValidateArg.NotNullOrEmpty(BaseUrl, "Playwright Service URL"); + ValidateArg.NotNullOrEmpty(AccessToken, "Playwright Service Access Token"); + } + catch (Exception ex) + { + Console.Error.WriteLine("Missing values : " + ex.Message); + Environment.Exit(1); + } + + TotalTestCount = 0; + PassedTestCount = 0; + FailedTestCount = 0; + SkippedTestCount = 0; + + TestRunStartTime = DateTime.UtcNow; + TokenDetails = ParseWorkspaceIdFromAccessToken(AccessToken); + WorkspaceId = TokenDetails.aid; + LogMessage("RunId: " + RunId); + LogMessage("BaseUrl: " + BaseUrl); + LogMessage("Workspace Id: " + WorkspaceId); + + PortalUrl = PlaywrightConstants.PortalBaseUrl + WorkspaceId + PlaywrightConstants.ReportingRoute + RunId; + + _httpClient = new HttpClient(); + var baseUri = new Uri(BaseUrl!); + _reportingTestRunsClient = new ReportingTestRunsClient(baseUri); + _reportingTestResultsClient = new ReportingTestResultsClient(baseUri); + + IsInitialized = true; + + LogMessage("Playwright Service Reporter Intialized"); + } + + internal void GenerateMarkdownSummary() + { + if (CiInfoProvider.GetCIProvider() == PlaywrightConstants.GITHUB_ACTIONS) + { + string markdownContent = @$" +#### Results: + +![pass](https://img.shields.io/badge/status-passed-brightgreen) **Passed:** {TestRunShard!.ResultsSummary!.NumPassedTests} + +![fail](https://img.shields.io/badge/status-failed-red) **Failed:** {TestRunShard.ResultsSummary.NumFailedTests} + +![flaky](https://img.shields.io/badge/status-flaky-yellow) **Flaky:** {"0"} + +![skipped](https://img.shields.io/badge/status-skipped-lightgrey) **Skipped:** {TestRunShard.ResultsSummary.NumSkippedTests} + +#### For more details, visit the [service dashboard]({Uri.EscapeUriString(PortalUrl!)}). +"; + + string filePath = Environment.GetEnvironmentVariable("GITHUB_STEP_SUMMARY"); + try + { + File.WriteAllText(filePath, markdownContent); + } + catch (Exception ex) + { + LogErrorMessage($"Error writing Markdown summary: {ex}"); + } + } + } + + private void AddInformationalMessage(string message) + { + informationalMessages.Add(message); + } +} diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/PlaywrightService.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/PlaywrightService.cs new file mode 100644 index 0000000000000..66e8c38668802 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/PlaywrightService.cs @@ -0,0 +1,278 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Azure.Core; +using Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Model; +using Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Utility; +using Microsoft.IdentityModel.JsonWebTokens; +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger; + +/// +/// Sets up and manages the Playwright service. +/// +public class PlaywrightService +{ + /// + /// Gets or sets the default authentication mechanism. + /// + public string ServiceAuth { get; set; } = ServiceAuthType.EntraId; + /// + /// Gets or sets a flag indicating whether to use cloud-hosted browsers. + /// + public bool UseCloudHostedBrowsers { get; set; } = true; + /// + /// Gets or sets the rotation timer for the Playwright service. + /// + public Timer? RotationTimer { get; set; } + /// + /// Gets the service endpoint for the Playwright service. + /// + public static string? ServiceEndpoint => Environment.GetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceUri); + + private readonly EntraLifecycle? _entraLifecycle; + private readonly JsonWebTokenHandler? _jsonWebTokenHandler; + + /// + /// Initializes a new instance of the class. + /// + /// + /// + public PlaywrightService(PlaywrightServiceOptions playwrightServiceOptions, TokenCredential? credential = null) : this( + os: playwrightServiceOptions.Os, + runId: playwrightServiceOptions.RunId, + exposeNetwork: playwrightServiceOptions.ExposeNetwork, + serviceAuth: playwrightServiceOptions.ServiceAuth, + useCloudHostedBrowsers: playwrightServiceOptions.UseCloudHostedBrowsers, + credential: credential ?? playwrightServiceOptions.AzureTokenCredential + ) + { + // No-op + } + + /// + /// Initializes a new instance of the class. + /// + /// The operating system. + /// The run ID. + /// The network exposure. + /// The service authentication mechanism. + /// Whether to use cloud-hosted browsers. + /// The token credential. + public PlaywrightService(OSPlatform? os = null, string? runId = null, string? exposeNetwork = null, string? serviceAuth = null, bool? useCloudHostedBrowsers = null, TokenCredential? credential = null) + { + if (string.IsNullOrEmpty(ServiceEndpoint)) + return; + _entraLifecycle = new EntraLifecycle(tokenCredential: credential); + _jsonWebTokenHandler = new JsonWebTokenHandler(); + InitializePlaywrightServiceEnvironmentVariables(getServiceCompatibleOs(os), runId, exposeNetwork, serviceAuth, useCloudHostedBrowsers); + } + + internal PlaywrightService(OSPlatform? os = null, string? runId = null, string? exposeNetwork = null, string? serviceAuth = null, bool? useCloudHostedBrowsers = null, EntraLifecycle? entraLifecycle = null, JsonWebTokenHandler? jsonWebTokenHandler = null, TokenCredential? credential = null) + { + if (string.IsNullOrEmpty(ServiceEndpoint)) + return; + _entraLifecycle = entraLifecycle ?? new EntraLifecycle(credential); + _jsonWebTokenHandler = jsonWebTokenHandler ?? new JsonWebTokenHandler(); + InitializePlaywrightServiceEnvironmentVariables(getServiceCompatibleOs(os), runId, exposeNetwork, serviceAuth, useCloudHostedBrowsers); + } + + /// + /// Gets the connect options for connecting to Playwright Service's cloud hosted browsers. + /// + /// The type of the connect options. + /// The operating system. + /// The run ID. + /// The network exposure. + /// Cancellation token. + /// The connect options. + public async Task> GetConnectOptionsAsync(OSPlatform? os = null, string? runId = null, string? exposeNetwork = null, CancellationToken cancellationToken = default) where T : class, new() + { + if (Environment.GetEnvironmentVariable(Constants.s_playwright_service_disable_scalable_execution_environment_variable) == "true") + throw new Exception(Constants.s_service_endpoint_removed_since_scalable_execution_disabled_error_message); + if (string.IsNullOrEmpty(ServiceEndpoint)) + throw new Exception(Constants.s_no_service_endpoint_error_message); + string _serviceOs = Uri.EscapeDataString(getServiceCompatibleOs(os) ?? Environment.GetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceOs) ?? Constants.s_default_os); + string _runId = Uri.EscapeDataString(runId ?? Environment.GetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceRunId) ?? GetDefaultRunId()); + string _exposeNetwork = exposeNetwork ?? Environment.GetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceExposeNetwork) ?? Constants.s_default_expose_network; + + string wsEndpoint = $"{ServiceEndpoint}?os={_serviceOs}&runId={_runId}&api-version={Constants.s_api_version}"; + + // fetch Entra id access token if required + // 1. Entra id access token has been fetched once via global functions + // 2. Not close to expiry + if (!string.IsNullOrEmpty(_entraLifecycle!._entraIdAccessToken) && _entraLifecycle!.DoesEntraIdAccessTokenRequireRotation()) + { + _ = await _entraLifecycle.FetchEntraIdAccessTokenAsync(cancellationToken).ConfigureAwait(false); + } + if (string.IsNullOrEmpty(GetAuthToken())) + { + throw new Exception(Constants.s_no_auth_error); + } + + var browserConnectOptions = new BrowserConnectOptions + { + Timeout = 3 * 60 * 1000, + ExposeNetwork = _exposeNetwork, + Headers = new Dictionary + { + ["Authorization"] = $"Bearer {GetAuthToken()}" + } + }; + return new ConnectOptions + { + WsEndpoint = wsEndpoint, + Options = BrowserConnectOptionsConverter.Convert(browserConnectOptions) + }; + } + + /// + /// Initialises the resources used to setup entra id authentication. + /// + public async Task InitializeAsync(CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(ServiceEndpoint)) + return; + if (!UseCloudHostedBrowsers) + { + // Since playwright-dotnet checks PLAYWRIGHT_SERVICE_ACCESS_TOKEN and PLAYWRIGHT_SERVICE_URL to be set, remove PLAYWRIGHT_SERVICE_URL so that tests are run locally. + // If customers use GetConnectOptionsAsync, after setting disableScalableExecution, an error will be thrown. + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceUri, null); + } + // If default auth mechanism is Access token and token is available in the environment variable, no need to setup rotation handler + if (ServiceAuth == ServiceAuthType.AccessToken && !string.IsNullOrEmpty(GetAuthToken())) + { + ValidateMptPAT(); + return; + } + var operationStatus = await _entraLifecycle!.FetchEntraIdAccessTokenAsync(cancellationToken).ConfigureAwait(false); + if (!operationStatus) + { + if (!string.IsNullOrEmpty(GetAuthToken())) + { + ValidateMptPAT(); // throws exception if token is invalid + } + return; // no need to setup rotation handler. If token is not available, it will fallback to local browser launch + } + + RotationTimer = new Timer(RotationHandlerAsync, null, TimeSpan.FromMinutes(Constants.s_entra_access_token_rotation_interval_period_in_minutes), TimeSpan.FromMinutes(Constants.s_entra_access_token_rotation_interval_period_in_minutes)); + } + + /// + /// Cleans up the resources used to setup entra id authentication. + /// + public void Cleanup() + { + RotationTimer?.Dispose(); + } + + internal async void RotationHandlerAsync(object? _) + { + if (_entraLifecycle!.DoesEntraIdAccessTokenRequireRotation()) + { + await _entraLifecycle.FetchEntraIdAccessTokenAsync().ConfigureAwait(false); + } + } + + private void InitializePlaywrightServiceEnvironmentVariables(string? os = null, string? runId = null, string? exposeNetwork = null, string? serviceAuth = null, bool? useCloudHostedBrowsers = null) + { + if (!string.IsNullOrEmpty(serviceAuth)) + { + ServiceAuth = serviceAuth!; + } + if (useCloudHostedBrowsers != null) + { + UseCloudHostedBrowsers = (bool)useCloudHostedBrowsers; + if (!UseCloudHostedBrowsers) + Environment.SetEnvironmentVariable(Constants.s_playwright_service_disable_scalable_execution_environment_variable, "true"); + } + if (!string.IsNullOrEmpty(os)) + { + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceOs, os); + } + else + { + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceOs, Constants.s_default_os); + } + if (!string.IsNullOrEmpty(runId)) + { + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceRunId, runId); + } + else + { + GetDefaultRunId(); + } + if (!string.IsNullOrEmpty(exposeNetwork)) + { + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceExposeNetwork, exposeNetwork); + } + else + { + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceExposeNetwork, Constants.s_default_expose_network); + } + SetReportingUrlAndWorkspaceId(); + } + + internal static string GetDefaultRunId() + { + var runIdFromEnvironmentVariable = Environment.GetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceRunId); + if (!string.IsNullOrEmpty(runIdFromEnvironmentVariable)) + return runIdFromEnvironmentVariable!; + CIInfo ciInfo = CiInfoProvider.GetCIInfo(); + var runId = ReporterUtils.GetRunId(ciInfo); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceRunId, runId); + return runId; + } + + internal static void SetReportingUrlAndWorkspaceId() + { + Match match = Regex.Match(ServiceEndpoint, @"wss://(?[\w-]+)\.api\.(?playwright(?:-test|-int)?\.io|playwright\.microsoft\.com)/accounts/(?[\w-]+)/"); + if (!match.Success) + return; + var region = match.Groups["region"].Value; + var domain = match.Groups["domain"].Value; + var workspaceId = match.Groups["workspaceId"].Value; + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable(Constants.s_playwright_service_reporting_url_environment_variable))) + Environment.SetEnvironmentVariable(Constants.s_playwright_service_reporting_url_environment_variable, $"https://{region}.reporting.api.{domain}"); + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable(Constants.s_playwright_service_workspace_id_environment_variable))) + Environment.SetEnvironmentVariable(Constants.s_playwright_service_workspace_id_environment_variable, $"{workspaceId}"); + } + + private static string? GetAuthToken() + { + return Environment.GetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken); + } + + private void ValidateMptPAT() + { + try + { + string authToken = GetAuthToken()!; + JsonWebToken jsonWebToken = _jsonWebTokenHandler!.ReadJsonWebToken(authToken) ?? throw new Exception(Constants.s_invalid_mpt_pat_error); + var expiry = (long)(jsonWebToken.ValidTo - new DateTime(1970, 1, 1)).TotalSeconds; + if (expiry <= DateTimeOffset.UtcNow.ToUnixTimeSeconds()) + throw new Exception(Constants.s_expired_mpt_pat_error); + } + catch (Exception) + { + throw new Exception(Constants.s_invalid_mpt_pat_error); + } + } + + private string? getServiceCompatibleOs(OSPlatform? oSPlatform) + { + if (oSPlatform == null) + return null; + if (oSPlatform.Equals(OSPlatform.Linux)) + return ServiceOs.Linux; + if (oSPlatform.Equals(OSPlatform.Windows)) + return ServiceOs.Windows; + throw new ArgumentException(Constants.s_invalid_os_error); + } +} diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/PlaywrightServiceOptions.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/PlaywrightServiceOptions.cs new file mode 100644 index 0000000000000..cda80ce6fc29f --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/PlaywrightServiceOptions.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Runtime.InteropServices; +using Azure.Core; +using Azure.Identity; + +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger; + +/// +/// Options for the Playwright service. +/// +public class PlaywrightServiceOptions +{ + internal OSPlatform? Os { get; set; } + internal string? RunId { get; set; } + internal string? ExposeNetwork { get; set; } + internal string ServiceAuth { get; set; } + internal bool UseCloudHostedBrowsers { get; set; } + internal TokenCredential AzureTokenCredential { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The operating system. + /// The run ID. + /// The network exposure. + /// The default authentication mechanism. + /// Whether to use cloud-hosted browsers. + /// The Azure token credential type. + /// The managed identity client ID. + public PlaywrightServiceOptions(OSPlatform? os = null, string? runId = null, string? exposeNetwork = null, string? serviceAuth = null, string? useCloudHostedBrowsers = null, string? azureTokenCredentialType = null, string? managedIdentityClientId = null) + { + Os = os; + RunId = runId; + ExposeNetwork = exposeNetwork; + ServiceAuth = serviceAuth ?? ServiceAuthType.EntraId; + UseCloudHostedBrowsers = string.IsNullOrEmpty(useCloudHostedBrowsers) || bool.Parse(useCloudHostedBrowsers!); + AzureTokenCredential = GetTokenCredential(azureTokenCredentialType, managedIdentityClientId); + Validate(); + } + + private void Validate() + { + if (Os != null && Os != OSPlatform.Linux && Os != OSPlatform.Windows) + { + throw new System.Exception($"Invalid value for {nameof(Os)}: {Os}. Supported values are {ServiceOs.Linux} and {ServiceOs.Windows}"); + } + if (!string.IsNullOrEmpty(ServiceAuth) && ServiceAuth != ServiceAuthType.EntraId && ServiceAuth != ServiceAuthType.AccessToken) + { + throw new System.Exception($"Invalid value for {nameof(ServiceAuth)}: {ServiceAuth}. Supported values are {ServiceAuthType.EntraId} and {ServiceAuthType.AccessToken}"); + } + } + + private static TokenCredential GetTokenCredential(string? azureTokenCredentialType, string? managedIdentityClientId) + { + if (string.IsNullOrEmpty(azureTokenCredentialType) && string.IsNullOrEmpty(managedIdentityClientId)) + return new DefaultAzureCredential(); + if (azureTokenCredentialType == AzureTokenCredentialType.ManagedIdentityCredential) + { + return new ManagedIdentityCredential(managedIdentityClientId); + } + else if (azureTokenCredentialType == AzureTokenCredentialType.WorkloadIdentityCredential) + { + return new WorkloadIdentityCredential(); + } + else if (azureTokenCredentialType == AzureTokenCredentialType.EnvironmentCredential) + { + return new EnvironmentCredential(); + } + else if (azureTokenCredentialType == AzureTokenCredentialType.AzureCliCredential) + { + return new AzureCliCredential(); + } + else if (azureTokenCredentialType == AzureTokenCredentialType.AzurePowerShellCredential) + { + return new AzurePowerShellCredential(); + } + else if (azureTokenCredentialType == AzureTokenCredentialType.AzureDeveloperCliCredential) + { + return new AzureDeveloperCliCredential(); + } + else if (azureTokenCredentialType == AzureTokenCredentialType.SharedTokenCacheCredential) + { + return new SharedTokenCacheCredential(); + } + else if (azureTokenCredentialType == AzureTokenCredentialType.VisualStudioCredential) + { + return new VisualStudioCredential(); + } + else if (azureTokenCredentialType == AzureTokenCredentialType.VisualStudioCodeCredential) + { + return new VisualStudioCodeCredential(); + } + else if (azureTokenCredentialType == AzureTokenCredentialType.InteractiveBrowserCredential) + { + return new InteractiveBrowserCredential(); + } + else + { + return new DefaultAzureCredential(new DefaultAzureCredentialOptions + { + ManagedIdentityClientId = managedIdentityClientId + }); + } + } +} diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Utility/CiInfoProvider.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Utility/CiInfoProvider.cs new file mode 100644 index 0000000000000..97e332b625056 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Utility/CiInfoProvider.cs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Model; +using System; +using PlaywrightConstants = Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Utility.Constants; + +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Utility; + +internal class CiInfoProvider +{ + private static bool IsGitHubActions() + { + return Environment.GetEnvironmentVariable("GITHUB_ACTIONS") == "true"; + } + + private static bool IsAzureDevOps() + { + return Environment.GetEnvironmentVariable("AZURE_HTTP_USER_AGENT") != null && + Environment.GetEnvironmentVariable("TF_BUILD") != null; + } + + internal static string GetCIProvider() + { + if (IsGitHubActions()) + return PlaywrightConstants.GITHUB_ACTIONS; + else if (IsAzureDevOps()) + return PlaywrightConstants.AZURE_DEVOPS; + else + return PlaywrightConstants.DEFAULT; + } + + internal static CIInfo GetCIInfo() + { + string ciProvider = GetCIProvider(); + if (ciProvider == PlaywrightConstants.GITHUB_ACTIONS) + { + // Logic to get GitHub Actions CIInfo + return new CIInfo + { + Provider = PlaywrightConstants.GITHUB_ACTIONS, + Repo = Environment.GetEnvironmentVariable("GITHUB_REPOSITORY_ID"), + Branch = GetGHBranchName(), + Author = Environment.GetEnvironmentVariable("GITHUB_ACTOR"), + CommitId = Environment.GetEnvironmentVariable("GITHUB_SHA"), + RevisionUrl = Environment.GetEnvironmentVariable("GITHUB_SERVER_URL") != null + ? $"{Environment.GetEnvironmentVariable("GITHUB_SERVER_URL")}/{Environment.GetEnvironmentVariable("GITHUB_REPOSITORY")}/commit/{Environment.GetEnvironmentVariable("GITHUB_SHA")}" + : null, + RunId = Environment.GetEnvironmentVariable("GITHUB_RUN_ID"), + RunAttempt = Environment.GetEnvironmentVariable("GITHUB_RUN_ATTEMPT") != null + ? int.Parse(Environment.GetEnvironmentVariable("GITHUB_RUN_ATTEMPT")!) + : null, + JobId = Environment.GetEnvironmentVariable("GITHUB_JOB") + }; + } + else if (ciProvider == PlaywrightConstants.AZURE_DEVOPS) + { + // Logic to get Azure DevOps CIInfo + return new CIInfo + { + Provider = PlaywrightConstants.AZURE_DEVOPS, + Repo = Environment.GetEnvironmentVariable("BUILD_REPOSITORY_ID"), + Branch = Environment.GetEnvironmentVariable("BUILD_SOURCEBRANCH"), + Author = Environment.GetEnvironmentVariable("BUILD_REQUESTEDFOR"), + CommitId = Environment.GetEnvironmentVariable("BUILD_SOURCEVERSION"), + RevisionUrl = Environment.GetEnvironmentVariable("SYSTEM_TEAMFOUNDATIONCOLLECTIONURI") != null + ? $"{Environment.GetEnvironmentVariable("SYSTEM_TEAMFOUNDATIONCOLLECTIONURI")}{Environment.GetEnvironmentVariable("SYSTEM_TEAMPROJECT")}/_git/{Environment.GetEnvironmentVariable("BUILD_REPOSITORY_NAME")}/commit/{Environment.GetEnvironmentVariable("BUILD_SOURCEVERSION")}" + : null, + RunId = GetADORunId(), + RunAttempt = Environment.GetEnvironmentVariable("RELEASE_ATTEMPTNUMBER") != null + ? int.Parse(Environment.GetEnvironmentVariable("RELEASE_ATTEMPTNUMBER")!) + : int.Parse(Environment.GetEnvironmentVariable("SYSTEM_JOBATTEMPT")!), + JobId = Environment.GetEnvironmentVariable("RELEASE_DEPLOYMENTID") ?? Environment.GetEnvironmentVariable("SYSTEM_JOBID") + }; + } + else + { + // Handle unsupported CI provider + return new CIInfo + { + Provider = PlaywrightConstants.DEFAULT, + Repo = Environment.GetEnvironmentVariable("REPO"), + Branch = Environment.GetEnvironmentVariable("BRANCH"), + Author = Environment.GetEnvironmentVariable("AUTHOR"), + CommitId = Environment.GetEnvironmentVariable("COMMIT_ID"), + RevisionUrl = Environment.GetEnvironmentVariable("REVISION_URL"), + RunId = Environment.GetEnvironmentVariable("RUN_ID"), + RunAttempt = Environment.GetEnvironmentVariable("RUN_ATTEMPT") != null + ? int.Parse(Environment.GetEnvironmentVariable("RUN_ATTEMPT")!) + : null, + JobId = Environment.GetEnvironmentVariable("JOB_ID") + }; + } + } + + private static string GetADORunId() + { + if (Environment.GetEnvironmentVariable("RELEASE_DEFINITIONID") != null && Environment.GetEnvironmentVariable("RELEASE_DEPLOYMENTID") != null) + return $"{Environment.GetEnvironmentVariable("RELEASE_DEFINITIONID")}-{Environment.GetEnvironmentVariable("RELEASE_DEPLOYMENTID")}"; + else + return $"{Environment.GetEnvironmentVariable("SYSTEM_DEFINITIONID")}-{Environment.GetEnvironmentVariable("SYSTEM_JOBID")}"; + } + + private static string GetGHBranchName() + { + if (Environment.GetEnvironmentVariable("GITHUB_EVENT_NAME") == "pull_request" || + Environment.GetEnvironmentVariable("GITHUB_EVENT_NAME") == "pull_request_target") + return Environment.GetEnvironmentVariable("GITHUB_HEAD_REF")!; + else + return Environment.GetEnvironmentVariable("GITHUB_REF_NAME")!; + } +} diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Utility/Constants.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Utility/Constants.cs new file mode 100644 index 0000000000000..e04af7928a216 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Utility/Constants.cs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Utility; + +internal static class Constants +{ + /// + /// Property Id storing the ExecutionId. + /// + internal const string ExecutionIdPropertyIdentifier = "ExecutionId"; + + /// + /// Property Id storing the ParentExecutionId. + /// + internal const string ParentExecutionIdPropertyIdentifier = "ParentExecId"; + + /// + /// Property If storing the TestType. + /// + internal const string TestTypePropertyIdentifier = "TestType"; + + internal const string SASUriSeparator = "?"; + + internal const string PortalBaseUrl = "https://playwright.microsoft.com/workspaces/"; + + internal const string ReportingRoute = "/runs/"; + + internal const string ReportingAPIVersion_2024_04_30_preview = "2024-04-30-preview"; + + internal const string ReportingAPIVersion_2024_05_20_preview = "2024-05-20-preview"; + + internal const string PLAYWRIGHT_SERVICE_REPORTING_URL = "PLAYWRIGHT_SERVICE_REPORTING_URL"; + + internal const string PLAYWRIGHT_SERVICE_WORKSPACE_ID = "PLAYWRIGHT_SERVICE_WORKSPACE_ID"; + + internal const string PLAYWRIGHT_SERVICE_ACCESS_TOKEN = "PLAYWRIGHT_SERVICE_ACCESS_TOKEN"; + + internal const string PLAYWRIGHT_SERVICE_DEBUG = "PLAYWRIGHT_SERVICE_DEBUG"; + + internal const string PLAYWRIGHT_SERVICE_RUN_ID = "PLAYWRIGHT_SERVICE_RUN_ID"; + + internal const string GITHUB_ACTIONS = "GitHub Actions"; + internal const string AZURE_DEVOPS = "Azure DevOps"; + internal const string DEFAULT = "Default"; +} + +internal enum TestErrorType +{ + Scalable +} + +internal class TestResultError +{ + internal string? Key { get; set; } = string.Empty; + internal string? Message { get; set; } = string.Empty; + internal Regex Pattern { get; set; } = new Regex(string.Empty); + internal TestErrorType Type { get; set; } +} + +internal static class TestResultErrorConstants +{ + public static List ErrorConstants = new() + { + new TestResultError + { + Key = "Unauthorized_Scalable", + Message = "The authentication token provided is invalid. Please check the token and try again.", + Pattern = new Regex(@"(?=.*Microsoft\.Playwright\.PlaywrightException)(?=.*401 Unauthorized)", RegexOptions.IgnoreCase), + Type = TestErrorType.Scalable + }, + new TestResultError + { + Key = "NoPermissionOnWorkspace_Scalable", + Message = @"You do not have the required permissions to run tests. This could be because: + + a. You do not have the required roles on the workspace. Only Owner and Contributor roles can run tests. Contact the service administrator. + b. The workspace you are trying to run the tests on is in a different Azure tenant than what you are signed into. Check the tenant id from Azure portal and login using the command 'az login --tenant '.", + Pattern = new Regex(@"(?=.*Microsoft\.Playwright\.PlaywrightException)(?=.*403 Forbidden)(?=[\s\S]*CheckAccess API call with non successful response)", RegexOptions.IgnoreCase), + Type = TestErrorType.Scalable + }, + new TestResultError + { + Key = "InvalidWorkspace_Scalable", + Message = "The specified workspace does not exist. Please verify your workspace settings.", + Pattern = new Regex(@"(?=.*Microsoft\.Playwright\.PlaywrightException)(?=.*403 Forbidden)(?=.*InvalidAccountOrSubscriptionState)", RegexOptions.IgnoreCase), + Type = TestErrorType.Scalable + }, + new TestResultError + { + Key = "AccessKeyBasedAuthNotSupported_Scalable", + Message = "Authentication through service access token is disabled for this workspace. Please use Entra ID to authenticate.", + Pattern = new Regex(@"(?=.*Microsoft\.Playwright\.PlaywrightException)(?=.*403 Forbidden)(?=.*AccessKeyBasedAuthNotSupported)", RegexOptions.IgnoreCase), + Type = TestErrorType.Scalable + }, + new TestResultError + { + Key = "ServiceUnavailable_Scalable", + Message = "The service is currently unavailable. Please check the service status and try again.", + Pattern = new Regex(@"(?=.*Microsoft\.Playwright\.PlaywrightException)(?=.*503 Service Unavailable)", RegexOptions.IgnoreCase), + Type = TestErrorType.Scalable + }, + new TestResultError + { + Key = "GatewayTimeout_Scalable", + Message = "The request to the service timed out. Please try again later.", + Pattern = new Regex(@"(?=.*Microsoft\.Playwright\.PlaywrightException)(?=.*504 Gateway Timeout)", RegexOptions.IgnoreCase), + Type = TestErrorType.Scalable + }, + new TestResultError + { + Key = "QuotaLimitError_Scalable", + Message = "It is possible that the maximum number of concurrent sessions allowed for your workspace has been exceeded.", + Pattern = new Regex(@"(Timeout .* exceeded)(?=[\s\S]*ws connecting)", RegexOptions.IgnoreCase), + Type = TestErrorType.Scalable + }, + new TestResultError + { + Key = "BrowserConnectionError_Scalable", + Message = "The service is currently unavailable. Please try again after some time.", + Pattern = new Regex(@"Target page, context or browser has been closed", RegexOptions.IgnoreCase), + Type = TestErrorType.Scalable + } + }; +} diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Utility/Logger.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Utility/Logger.cs new file mode 100644 index 0000000000000..e01a38ca03960 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Utility/Logger.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; + +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Utility; + +internal enum LogLevel +{ + Debug, + Info, + Warning, + Error +} + +internal static class Logger +{ + public static void Log(bool enableConsoleLog, LogLevel level, string message) + { + if (enableConsoleLog) + { + Console.WriteLine($"{DateTime.Now} [{level}]: {message}"); + } + } +} diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Utility/ReporterUtils.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Utility/ReporterUtils.cs new file mode 100644 index 0000000000000..c25c8d466eb9d --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/src/Utility/ReporterUtils.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Diagnostics; +using System.Security.Cryptography; +using System.Threading.Tasks; +using Azure.Core.Pipeline; +using Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Model; + +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Utility +{ + internal class ReporterUtils + { + internal static string GetRunId(CIInfo cIInfo) + { + if (cIInfo.Provider == Constants.DEFAULT) + { + return Guid.NewGuid().ToString(); + } + var concatString = $"{cIInfo.Provider}-{cIInfo.Repo}-{cIInfo.RunId}-{cIInfo.RunAttempt}"; + return CalculateSha1Hash(concatString); + } + + internal static string CalculateSha1Hash(string input) + { + using (var sha1 = SHA1.Create()) + { + var hash = sha1.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input)); + return BitConverter.ToString(hash).Replace("-", string.Empty).ToLower(); + } + } + + internal static string GetRunName(CIInfo ciInfo) + { + string GIT_VERSION_COMMAND = "git --version"; + string GIT_REV_PARSE = "git rev-parse --is-inside-work-tree"; + string GIT_COMMIT_MESSAGE_COMMAND = "git log -1 --pretty=%B"; + + if (ciInfo.Provider == Constants.GITHUB_ACTIONS && + Environment.GetEnvironmentVariable("GITHUB_EVENT_NAME") == "pull_request") + { + var prNumber = Environment.GetEnvironmentVariable("GITHUB_REF_NAME")?.Split('/')[0]; + var repo = Environment.GetEnvironmentVariable("GITHUB_REPOSITORY"); + var prLink = $"{repo}/pull/{prNumber}"; + return $"PR# {prNumber} on Repo: {repo} ({prLink})"; + } + + try + { + string gitVersion = RunCommandAsync(GIT_VERSION_COMMAND).EnsureCompleted(); + if (string.IsNullOrEmpty(gitVersion)) + { + throw new Exception("Git is not installed on the machine"); + } + + string isInsideWorkTree = RunCommandAsync(GIT_REV_PARSE).EnsureCompleted(); + if (isInsideWorkTree.Trim() != "true") + { + throw new Exception("Not inside a git repository"); + } + + string gitCommitMessage = RunCommandAsync(GIT_COMMIT_MESSAGE_COMMAND).EnsureCompleted(); + return gitCommitMessage; + } + catch (Exception) + { + return string.Empty; + } + } + + internal static async Task RunCommandAsync(string command, bool async = false) + { + var processInfo = new ProcessStartInfo("cmd", $"/c {command}") + { + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using (var process = new Process { StartInfo = processInfo }) + { + process.Start(); + string result; + if (async) + { + result = await process.StandardOutput.ReadToEndAsync().ConfigureAwait(false); + } + else + { + result = process.StandardOutput.ReadToEnd(); + } + process.WaitForExit(); + return result; + } + } + } +} diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/tests/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Tests.csproj b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/tests/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Tests.csproj new file mode 100644 index 0000000000000..60229ecd863c2 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/tests/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Tests.csproj @@ -0,0 +1,23 @@ + + + + enable + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/tests/EntraLifecycleTests.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/tests/EntraLifecycleTests.cs new file mode 100644 index 0000000000000..62930afb0fff2 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/tests/EntraLifecycleTests.cs @@ -0,0 +1,201 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Azure.Identity; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using Moq; + +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Tests; + +[TestFixture] +public class EntraLifecycleTests +{ + private static string GetToken(Dictionary claims, DateTime? expires = null) + { + var tokenHandler = new JsonWebTokenHandler(); + var token = tokenHandler.CreateToken(new SecurityTokenDescriptor + { + Claims = claims, + Expires = expires ?? DateTime.UtcNow.AddMinutes(10), + }); + return token!; + } + + [TearDown] + public void TearDown() + { + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, null); + } + + [Test] + public void Constructor_WhenAccessTokenEnvironmentIsNotSet_DoesNotInitializeEntraToken() + { + EntraLifecycle entraLifecycle = new(); + Assert.That(entraLifecycle._entraIdAccessToken, Is.Null); + } + + [Test] + public void Constructor_WhenAccessTokenEnvironmentIsSetButTokenIsNotValid_DoesNotInitializeEntraToken() + { + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, "valid_token"); + EntraLifecycle entraLifecycle = new(); + Assert.That(entraLifecycle._entraIdAccessToken, Is.Null); + } + + [Test] + public void Constructor_WhenAccessTokenEnvironmentIsSetAndTokenIsMPTCustomToken_DoesNotInitializeEntraToken() + { + var token = GetToken(new Dictionary + { + {"aid", "account-id-guid"}, + }); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, token); + EntraLifecycle entraLifecycle = new(); + Assert.That(entraLifecycle._entraIdAccessToken, Is.Null); + } + + [Test] + public void Constructor_WhenAccessTokenEnvironmentIsSetAndTokenIsMPTCustomTokenWithAccountIdClaim_DoesNotInitializeEntraToken() + { + var token = GetToken(new Dictionary + { + {"accountId", "account-id-guid"}, + }); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, token); + EntraLifecycle entraLifecycle = new(); + Assert.That(entraLifecycle._entraIdAccessToken, Is.Null); + } + + [Test] + public void Constructor_WhenJWTValidationThrowsException_DoesNotInitializeEntraToken() + { + var token = GetToken(new Dictionary()); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, token); + var jsonWebTokenHandlerMock = new Mock(); + jsonWebTokenHandlerMock + .Setup(x => x.ReadJsonWebToken(token)) + .Throws(new Exception()); + EntraLifecycle entraLifecycle = new(jsonWebTokenHandler: jsonWebTokenHandlerMock.Object); + Assert.That(entraLifecycle._entraIdAccessToken, Is.Null); + } + + [Test] + public void Constructor_WhenAccessTokenEnvironmentIsSetAndValid_InitializeEntraToken() + { + DateTime expiry = DateTime.UtcNow.AddMinutes(10); + var token = GetToken(new Dictionary(), expiry); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, token); + EntraLifecycle entraLifecycle = new(); + Assert.Multiple(() => + { + Assert.That(entraLifecycle._entraIdAccessToken, Is.EqualTo(token)); + Assert.That(entraLifecycle._entraIdAccessTokenExpiry, Is.EqualTo((long)(expiry - new DateTime(1970, 1, 1)).TotalSeconds)); + }); + } + + [Test] + public async Task FetchEntraIdAccessTokenAsync_WhenTokenIsFetched_SetsEnvironmentVariable() + { + var defaultAzureCredentialMock = new Mock(); + var token = "valid_token"; + defaultAzureCredentialMock + .Setup(x => x.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccessToken(token, DateTimeOffset.UtcNow.AddMinutes(10))); + EntraLifecycle entraLifecycle = new(defaultAzureCredentialMock.Object); + await entraLifecycle.FetchEntraIdAccessTokenAsync(); + Assert.That(Environment.GetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken), Is.EqualTo(token)); + + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, null); + } + + [Test] + public async Task FetchEntraIdAccessTokenAsync_WhenTokenIsFetched_SetsTokenAndExpiry() + { + var defaultAzureCredentialMock = new Mock(); + var token = "valid_token"; + DateTimeOffset expiry = DateTimeOffset.UtcNow.AddMinutes(10); + defaultAzureCredentialMock + .Setup(x => x.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccessToken(token, expiry)); + EntraLifecycle entraLifecycle = new(defaultAzureCredentialMock.Object); + await entraLifecycle.FetchEntraIdAccessTokenAsync(); + Assert.That(entraLifecycle._entraIdAccessToken, Is.EqualTo(token)); + Assert.That(entraLifecycle._entraIdAccessTokenExpiry, Is.EqualTo((int)expiry.ToUnixTimeSeconds())); + + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, null); + } + + [Test] + public async Task FetchEntraIdAccessTokenAsync_WhenTokenIsFetched_ReturnsTrue() + { + var defaultAzureCredentialMock = new Mock(); + var token = "valid_token"; + DateTimeOffset expiry = DateTimeOffset.UtcNow.AddMinutes(10); + defaultAzureCredentialMock + .Setup(x => x.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccessToken(token, expiry)); + EntraLifecycle entraLifecycle = new(defaultAzureCredentialMock.Object); + Assert.That(await entraLifecycle.FetchEntraIdAccessTokenAsync(), Is.True); + + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, null); + } + + [Test] + public async Task FetchEntraIdAccessTokenAsync_WhenThrowsError_ReturnsFalse() + { + var defaultAzureCredentialMock = new Mock(); + defaultAzureCredentialMock + .Setup(x => x.GetTokenAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("sample exception")); + EntraLifecycle entraLifecycle = new(defaultAzureCredentialMock.Object); + Assert.That(await entraLifecycle.FetchEntraIdAccessTokenAsync(), Is.False); + } + + [Test] + public void DoesEntraIdAccessTokenRequireRotation_WhenEntraIdAccessTokenIsEmpty_ReturnsTrue() + { + EntraLifecycle entraLifecycle = new() + { + _entraIdAccessToken = "" + }; + Assert.That(entraLifecycle.DoesEntraIdAccessTokenRequireRotation(), Is.True); + } + + [Test] + public void DoesEntraIdAccessTokenRequireRotation_WhenEntraIdAccessTokenIsNull_ReturnsTrue() + { + EntraLifecycle entraLifecycle = new() + { + _entraIdAccessToken = null + }; + Assert.That(entraLifecycle.DoesEntraIdAccessTokenRequireRotation(), Is.True); + } + + [Test] + public void DoesEntraIdAccessTokenRequireRotation_WhenTokenIsNotAboutToExpire_ReturnsFalse() + { + EntraLifecycle entraLifecycle = new() + { + _entraIdAccessToken = "valid_token", + _entraIdAccessTokenExpiry = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 1000 // more than threshold of 10 mins + }; + Assert.That(entraLifecycle.DoesEntraIdAccessTokenRequireRotation(), Is.False); + } + + [Test] + public void DoesEntraIdAccessTokenRequireRotation_WhenTokenIsAboutToExpire_ReturnsTrue() + { + EntraLifecycle entraLifecycle = new() + { + _entraIdAccessToken = "valid_token", + _entraIdAccessTokenExpiry = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 400 // less than threshold of 10 mins + }; + Assert.That(entraLifecycle.DoesEntraIdAccessTokenRequireRotation(), Is.True); + } +} diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/tests/PlaywrightServiceClientTests.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/tests/PlaywrightServiceClientTests.cs new file mode 100644 index 0000000000000..a1896981a53c7 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/tests/PlaywrightServiceClientTests.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Azure.Core.TestFramework; + +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Tests; + +public class PlaywrightServiceClientTests : RecordedTestBase +{ + private PlaywrightService? _playwrightService; + private static string Access_Token => Environment.GetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken)!; + + public PlaywrightServiceClientTests(bool isAsync) : base(isAsync, RecordedTestMode.Live) { } + + [SetUp] + public async Task Setup() + { + var workspaceId = TestUtils.GetWorkspaceIdFromDashboardEndpoint(TestEnvironment.DashboardEndpoint); + var region = TestEnvironment.Region; + var serviceApiEndpoint = TestUtils.GetPlaywrightServiceAPIEndpoint(workspaceId, region); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceUri, serviceApiEndpoint); + _playwrightService = new PlaywrightService(new PlaywrightServiceOptions(), credential: TestEnvironment.Credential); + await _playwrightService.InitializeAsync(); + } + + [TearDown] + public void Teardown() + { + _playwrightService?.Cleanup(); + } + + [Test] + [Category("Live")] + public async Task TestPlaywrightServiceConnection() + { + var workspaceId = TestUtils.GetWorkspaceIdFromDashboardEndpoint(TestEnvironment.DashboardEndpoint); + var region = TestEnvironment.Region; + var serviceApiEndpoint = TestUtils.GetPlaywrightServiceAPIEndpoint(workspaceId, region); + var client = new HttpClient(new HttpClientHandler + { + AllowAutoRedirect = false + }); + var request = new HttpRequestMessage(HttpMethod.Get, serviceApiEndpoint); + request.Headers.Add("Authorization", $"Bearer {Access_Token}"); + request.RequestUri = new Uri($"{request.RequestUri}?cap={{}}"); + HttpResponseMessage response = await client.SendAsync(request); + response.Headers.TryGetValues("Location", out System.Collections.Generic.IEnumerable? location); + Assert.AreEqual(302, (int)response.StatusCode); + Assert.IsTrue(location!.Any(url => url.Contains("browser.playwright.microsoft.com"))); + } +} diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/tests/PlaywrightServiceOptionsTest.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/tests/PlaywrightServiceOptionsTest.cs new file mode 100644 index 0000000000000..be5afa3adc514 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/tests/PlaywrightServiceOptionsTest.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Azure.Identity; +using System; +using System.Runtime.InteropServices; + +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Tests; + +[TestFixture] +[Parallelizable(ParallelScope.Self)] +public class PlaywrightServiceOptionsTest +{ + [Test] + public void Constructor_ShouldInitializeProperties() + { + var os = OSPlatform.Linux; + var runId = "test-run-id"; + var exposeNetwork = "true"; + var serviceAuth = ServiceAuthType.EntraId; + var useCloudHostedBrowsers = "true"; + var azureTokenCredentialType = AzureTokenCredentialType.ManagedIdentityCredential; + var managedIdentityClientId = "test-client-id"; + + var settings = new PlaywrightServiceOptions( + os, runId, exposeNetwork, serviceAuth, useCloudHostedBrowsers, azureTokenCredentialType, managedIdentityClientId); + + Assert.Multiple(() => + { + Assert.That(settings.Os, Is.EqualTo(os)); + Assert.That(settings.RunId, Is.EqualTo(runId)); + Assert.That(settings.ExposeNetwork, Is.EqualTo(exposeNetwork)); + Assert.That(settings.ServiceAuth, Is.EqualTo(serviceAuth)); + Assert.That(settings.UseCloudHostedBrowsers, Is.True); + Assert.That(settings.AzureTokenCredential, Is.InstanceOf()); + }); + } + + [Test] + public void Constructor_ShouldUseDefaultValues() + { + var settings = new PlaywrightServiceOptions(); + Assert.Multiple(() => + { + Assert.That(settings.Os, Is.Null); + Assert.That(settings.RunId, Is.Null); + Assert.That(settings.ExposeNetwork, Is.Null); + Assert.That(settings.ServiceAuth, Is.EqualTo(ServiceAuthType.EntraId)); + Assert.That(settings.UseCloudHostedBrowsers, Is.True); + Assert.That(settings.AzureTokenCredential, Is.InstanceOf()); + }); + } + + [Test] + public void Validate_ShouldThrowExceptionForInvalidOs() + { + Exception? ex = Assert.Throws(() => new PlaywrightServiceOptions(os: OSPlatform.Create("invalid"))); + Assert.That(ex!.Message, Does.Contain("Invalid value for Os")); + } + + [Test] + public void Validate_ShouldThrowExceptionForInvalidDefaultAuth() + { + var invalidAuth = "InvalidAuth"; + Exception? ex = Assert.Throws(() => new PlaywrightServiceOptions(serviceAuth: invalidAuth)); + Assert.That(ex!.Message, Does.Contain("Invalid value for ServiceAuth")); + } + + [TestCase("ManagedIdentityCredential", typeof(ManagedIdentityCredential))] + [TestCase("WorkloadIdentityCredential", typeof(WorkloadIdentityCredential))] + [TestCase("EnvironmentCredential", typeof(EnvironmentCredential))] + [TestCase("AzureCliCredential", typeof(AzureCliCredential))] + [TestCase("AzurePowerShellCredential", typeof(AzurePowerShellCredential))] + [TestCase("AzureDeveloperCliCredential", typeof(AzureDeveloperCliCredential))] + [TestCase("InteractiveBrowserCredential", typeof(InteractiveBrowserCredential))] + [TestCase("SharedTokenCacheCredential", typeof(SharedTokenCacheCredential))] + [TestCase("VisualStudioCredential", typeof(VisualStudioCredential))] + [TestCase("VisualStudioCodeCredential", typeof(VisualStudioCodeCredential))] + [TestCase("DefaultAzureCredential", typeof(DefaultAzureCredential))] + [TestCase("", typeof(DefaultAzureCredential))] + [TestCase(null, typeof(DefaultAzureCredential))] + public void GetTokenCredential_ShouldReturnCorrectCredential(string? credentialType, Type expectedType) + { + var settings = new PlaywrightServiceOptions(azureTokenCredentialType: credentialType); + Assert.That(settings.AzureTokenCredential, Is.InstanceOf(expectedType)); + } +} diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/tests/PlaywrightServiceTestEnvironment.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/tests/PlaywrightServiceTestEnvironment.cs new file mode 100644 index 0000000000000..dbd0ca3ff17bd --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/tests/PlaywrightServiceTestEnvironment.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Azure.Core.TestFramework; + +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Tests; + +public class PlaywrightServiceTestEnvironment : TestEnvironment +{ + public string Region => GetRecordedVariable("PLAYWRIGHTTESTING_LOCATION"); + public string DashboardEndpoint => GetRecordedVariable("DASHBOARD_ENDPOINT"); +}; diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/tests/PlaywrightServiceTests.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/tests/PlaywrightServiceTests.cs new file mode 100644 index 0000000000000..8e1ab574e9804 --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/tests/PlaywrightServiceTests.cs @@ -0,0 +1,662 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Azure.Identity; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using Moq; + +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Tests; + +[TestFixture] +[Parallelizable(ParallelScope.Self)] +public class PlaywrightServiceTests +{ + private static string GetToken(Dictionary claims, DateTime? expires = null) + { + var tokenHandler = new JsonWebTokenHandler(); + var token = tokenHandler.CreateToken(new SecurityTokenDescriptor + { + Claims = claims, + Expires = expires ?? DateTime.UtcNow.AddMinutes(10), + }); + return token!; + } + + [SetUp] + public void Setup() + { + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceUri, "https://playwright.microsoft.com"); + } + [TearDown] + public void TearDown() + { + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceUri, null); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, null); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceOs, null); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceRunId, null); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceExposeNetwork, null); + Environment.SetEnvironmentVariable(Constants.s_playwright_service_disable_scalable_execution_environment_variable, null); + Environment.SetEnvironmentVariable(Constants.s_playwright_service_reporting_url_environment_variable, null); + Environment.SetEnvironmentVariable(Constants.s_playwright_service_workspace_id_environment_variable, null); + } + + [Test] + public void Constructor_NoConstructorParams_SetsEntraAuthMechanismAsDefault() + { + PlaywrightService service = new(entraLifecycle: null); + Assert.That(service.ServiceAuth, Is.EqualTo(ServiceAuthType.EntraId)); + } + + [Test] + public void Constructor_NoServiceParams_SetsDefaultValues() + { + var playwrightService = new PlaywrightService(entraLifecycle: null); + Assert.Multiple(() => + { + Assert.That(Environment.GetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceOs), Is.EqualTo(Constants.s_default_os)); + Assert.That(Environment.GetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceExposeNetwork), Is.EqualTo(Constants.s_default_expose_network)); + Assert.That(Environment.GetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceRunId), Is.Not.Null); + Assert.That(playwrightService.ServiceAuth, Is.EqualTo(ServiceAuthType.EntraId)); + Assert.That(playwrightService.UseCloudHostedBrowsers, Is.True); + }); + } + + [Test] + public void Constructor_PassServiceOS_SetsServiceOS() + { + _ = new PlaywrightService(os: OSPlatform.Windows, entraLifecycle: null); + Assert.That(Environment.GetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceOs), Is.EqualTo(ServiceOs.Windows)); + } + + [Test] + public void Constructor_PassExposeNetwork_SetsExposeNetwork() + { + _ = new PlaywrightService(exposeNetwork: "new-expose", entraLifecycle: null); + Assert.That(Environment.GetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceExposeNetwork), Is.EqualTo("new-expose")); + } + + [Test] + public void Constructor_PassRunId_SetsRunId() + { + _ = new PlaywrightService(runId: "new-run-id", entraLifecycle: null); + Assert.That(Environment.GetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceRunId), Is.EqualTo("new-run-id")); + } + + [Test] + public void Constructor_PassDefaultAuthMechanism_SetsDefaultAuthMechanism() + { + var playwrightService = new PlaywrightService(entraLifecycle: null, serviceAuth: ServiceAuthType.AccessToken); + Assert.That(playwrightService.ServiceAuth, Is.EqualTo(ServiceAuthType.AccessToken)); + } + + [Test] + public void Constructor_PassUseCloudHostedBrowsersAsFalse_SetsDisableScalableExecutionAndEnvVariable() + { + var playwrightService = new PlaywrightService(entraLifecycle: null, useCloudHostedBrowsers: false); + Assert.Multiple(() => + { + Assert.That(playwrightService.UseCloudHostedBrowsers, Is.False); + Assert.That(Environment.GetEnvironmentVariable(Constants.s_playwright_service_disable_scalable_execution_environment_variable), Is.EqualTo("true")); + }); + } + + [Test] + public void Constructor_PassUseCloudHostedBrowsersAsTrue_SetsDisableScalableExecutionButNotEnvVariable() + { + var playwrightService = new PlaywrightService(entraLifecycle: null, useCloudHostedBrowsers: true); + Assert.Multiple(() => + { + Assert.That(playwrightService.UseCloudHostedBrowsers, Is.True); + Assert.That(Environment.GetEnvironmentVariable(Constants.s_playwright_service_disable_scalable_execution_environment_variable), Is.Null); + }); + } + + [Test] + public void Initialize_WhenServiceEnpointIsNotSet_NoOP() + { + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceUri, null); + var defaultAzureCredentialMock = new Mock(); + var jsonWebTokenHandlerMock = new Mock(); + var entraLifecycleMock = new Mock(defaultAzureCredentialMock.Object, jsonWebTokenHandlerMock.Object); + PlaywrightService service = new(entraLifecycle: entraLifecycleMock.Object); + service.InitializeAsync().Wait(); + defaultAzureCredentialMock.Verify(x => x.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public void Initialize_WhenDefaultAuthIsEntraIdAccessTokenAndAccessTokenEnvironmentVariableIsSet_FetchesEntraIdAccessToken() + { + var defaultAzureCredentialMock = new Mock(); + var jsonWebTokenHandlerMock = new Mock(); + var token = "valid_token"; + defaultAzureCredentialMock + .Setup(x => x.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccessToken(token, DateTimeOffset.UtcNow.AddMinutes(10))); + var entraLifecycleMock = new Mock(defaultAzureCredentialMock.Object, jsonWebTokenHandlerMock.Object); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, "access_token"); + PlaywrightService service = new(entraLifecycle: entraLifecycleMock.Object); + service.InitializeAsync().Wait(); + defaultAzureCredentialMock.Verify(x => x.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + + service.RotationTimer!.Dispose(); + + Assert.That(Environment.GetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceUri), Is.Not.Null); + } + + [Test] + public void Initialize_WhenDefaultAuthIsEntraIdAccessTokenAndAccessTokenEnvironmentVariableIsSetAndCredentialsArePassed_FetchesEntraIdAccessTokenUsedPassedCredentials() + { + var tokenCredential = new Mock(); + var jsonWebTokenHandlerMock = new Mock(); + var token = "valid_token"; + tokenCredential + .Setup(x => x.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccessToken(token, DateTimeOffset.UtcNow.AddMinutes(10))); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, "access_token"); + PlaywrightService service = new(new PlaywrightServiceOptions(), credential: tokenCredential.Object); + service.InitializeAsync().Wait(); + tokenCredential.Verify(x => x.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + + service.RotationTimer!.Dispose(); + + Assert.That(Environment.GetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceUri), Is.Not.Null); + } + + [Test] + public void Initialize_WhenDefaultAuthIsEntraIdAccessTokenAndAccessTokenEnvironmentVariableIsSetButScalableExecutionIsDisabled_DeletesServiceUrlEnvVariable() + { + var defaultAzureCredentialMock = new Mock(); + var jsonWebTokenHandlerMock = new Mock(); + var token = "valid_token"; + defaultAzureCredentialMock + .Setup(x => x.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccessToken(token, DateTimeOffset.UtcNow.AddMinutes(10))); + var entraLifecycleMock = new Mock(defaultAzureCredentialMock.Object, jsonWebTokenHandlerMock.Object); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, "access_token"); + PlaywrightService service = new(entraLifecycle: entraLifecycleMock.Object, useCloudHostedBrowsers: false); + + Assert.That(Environment.GetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceUri), Is.Not.Null); + + service.InitializeAsync().Wait(); + defaultAzureCredentialMock.Verify(x => x.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + + service.RotationTimer!.Dispose(); + + Assert.That(Environment.GetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceUri), Is.Null); + } + + [Test] + public void Initialize_WhenDefaultAuthIsEntraIdAccessTokenAndAccessTokenEnvironmentVariableIsNotSet_FetchesEntraIdAccessToken() + { + var defaultAzureCredentialMock = new Mock(); + var jsonWebTokenHandlerMock = new Mock(); + var token = "valid_token"; + defaultAzureCredentialMock + .Setup(x => x.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccessToken(token, DateTimeOffset.UtcNow.AddMinutes(10))); + var entraLifecycleMock = new Mock(defaultAzureCredentialMock.Object, jsonWebTokenHandlerMock.Object); + PlaywrightService service = new(entraLifecycle: entraLifecycleMock.Object); + service.InitializeAsync().Wait(); + defaultAzureCredentialMock.Verify(x => x.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + + service.RotationTimer!.Dispose(); + } + + [Test] + public void Initialize_WhenFetchesEntraIdAccessToken_SetsUpRotationHandler() + { + var defaultAzureCredentialMock = new Mock(); + var jsonWebTokenHandlerMock = new Mock(); + var token = "valid_token"; + defaultAzureCredentialMock + .Setup(x => x.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccessToken(token, DateTimeOffset.UtcNow.AddMinutes(10))); + var entraLifecycleMock = new Mock(defaultAzureCredentialMock.Object, jsonWebTokenHandlerMock.Object); + PlaywrightService service = new(entraLifecycle: entraLifecycleMock.Object); + service.InitializeAsync().Wait(); + defaultAzureCredentialMock.Verify(x => x.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + Assert.That(service.RotationTimer, Is.Not.Null); + + service.RotationTimer!.Dispose(); + } + + [Test] + public void Initialize_WhenFailsToFetchEntraIdAccessToken_DoesNotSetUpRotationHandler() + { + var defaultAzureCredentialMock = new Mock(); + var jsonWebTokenHandlerMock = new Mock(); + defaultAzureCredentialMock + .Setup(x => x.GetTokenAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception()); + var entraLifecycleMock = new Mock(defaultAzureCredentialMock.Object, jsonWebTokenHandlerMock.Object); + PlaywrightService service = new(entraLifecycle: entraLifecycleMock.Object); + service.InitializeAsync().Wait(); + defaultAzureCredentialMock.Verify(x => x.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + Assert.That(service.RotationTimer, Is.Null); + } + + [Test] + public void Initialize_WhenEntraIdAccessTokenFailsAndMptPatIsSet_DoesNotSetUpRotationHandler() + { + var token = GetToken(new Dictionary + { + {"aid", "account-id-guid"}, + }); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, token); + var defaultAzureCredentialMock = new Mock(); + defaultAzureCredentialMock + .Setup(x => x.GetTokenAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception()); + var entraLifecycleMock = new Mock(defaultAzureCredentialMock.Object, new JsonWebTokenHandler()); + PlaywrightService service = new(entraLifecycle: entraLifecycleMock.Object, jsonWebTokenHandler: new JsonWebTokenHandler()); + service.InitializeAsync().Wait(); + Assert.That(service.RotationTimer, Is.Null); + } + + [Test] + public void Initialize_WhenEntraIdAccessTokenFailsAndMptPatIsNotSet_DoesNotSetUpRotationHandler() + { + var token = GetToken(new Dictionary + { + {"aid", "account-id-guid"}, + }); + var defaultAzureCredentialMock = new Mock(); + defaultAzureCredentialMock + .Setup(x => x.GetTokenAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception()); + var entraLifecycleMock = new Mock(defaultAzureCredentialMock.Object, new JsonWebTokenHandler()); + PlaywrightService service = new(entraLifecycle: entraLifecycleMock.Object, jsonWebTokenHandler: new JsonWebTokenHandler()); + service.InitializeAsync().Wait(); + Assert.That(service.RotationTimer, Is.Null); + } + + [Test] + public void Initialize_WhenEntraIdAccessTokenFailsAndMptPatIsNotValid_ThrowsError() + { + var token = "sample token"; + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, token); + var defaultAzureCredentialMock = new Mock(); + defaultAzureCredentialMock + .Setup(x => x.GetTokenAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception()); + var entraLifecycleMock = new Mock(defaultAzureCredentialMock.Object, new JsonWebTokenHandler()); + PlaywrightService service = new(entraLifecycle: entraLifecycleMock.Object, jsonWebTokenHandler: new JsonWebTokenHandler()); + Assert.That(() => service.InitializeAsync().Wait(), Throws.Exception); + } + + [Test] + public void Initialize_WhenEntraIdAccessTokenFailsAndMptPatTokenParsingReturnsNull_ThrowsError() + { + var token = "sample token"; + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, token); + var jsonWebTokenHandlerMock = new Mock(); +#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. + jsonWebTokenHandlerMock + .Setup(x => x.ReadJsonWebToken(It.IsAny())) + .Returns(value: null); +#pragma warning restore CS8625 // Cannot convert null literal to non-nullable reference type. + var defaultAzureCredentialMock = new Mock(); + defaultAzureCredentialMock + .Setup(x => x.GetTokenAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception()); + var entraLifecycleMock = new Mock(defaultAzureCredentialMock.Object, new JsonWebTokenHandler()); + PlaywrightService service = new(entraLifecycle: entraLifecycleMock.Object, jsonWebTokenHandler: jsonWebTokenHandlerMock.Object); + Assert.That(() => service.InitializeAsync().Wait(), Throws.Exception); + } + + [Test] + public void Initialize_WhenEntraIdAccessTokenFailsAndMptPatIsExpired_ThrowsError() + { + var token = GetToken(new Dictionary + { + {"aid", "account-id-guid"}, + }, DateTime.UtcNow.AddMinutes(-1)); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, token); + var defaultAzureCredentialMock = new Mock(); + defaultAzureCredentialMock + .Setup(x => x.GetTokenAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception()); + var entraLifecycleMock = new Mock(defaultAzureCredentialMock.Object, new JsonWebTokenHandler()); + PlaywrightService service = new(entraLifecycle: entraLifecycleMock.Object, jsonWebTokenHandler: new JsonWebTokenHandler()); + + Assert.That(() => service.InitializeAsync().Wait(), Throws.Exception); + } + + [Test] + public void Initialize_WhenDefaultAuthIsMptPATAndPATIsSet_DoesNotSetUpRotationHandler() + { + var token = GetToken(new Dictionary + { + {"aid", "account-id-guid"}, + }); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, token); + var defaultAzureCredentialMock = new Mock(); + var entraLifecycleMock = new Mock(defaultAzureCredentialMock.Object, new JsonWebTokenHandler()); + PlaywrightService service = new(entraLifecycle: entraLifecycleMock.Object, jsonWebTokenHandler: new JsonWebTokenHandler(), serviceAuth: ServiceAuthType.AccessToken); + service.InitializeAsync().Wait(); + Assert.That(service.RotationTimer, Is.Null); + } + + [Test] + public void RotationHandler_WhenEntraIdAccessTokenRequiresRotation_FetchesEntraIdAccessToken() + { + var defaultAzureCredentialMock = new Mock(); + var jsonWebTokenHandlerMock = new Mock(); + var token = "valid_token"; + defaultAzureCredentialMock + .Setup(x => x.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccessToken(token, DateTimeOffset.UtcNow.AddMinutes(5))); + var entraLifecycleMock = new Mock(defaultAzureCredentialMock.Object, jsonWebTokenHandlerMock.Object); + PlaywrightService service = new(entraLifecycle: entraLifecycleMock.Object); + + service.RotationHandlerAsync(null); + defaultAzureCredentialMock.Verify(x => x.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public void RotationHandler_WhenEntraIdAccessTokenDoesNotRequireRotation_NoOp() + { + var defaultAzureCredentialMock = new Mock(); + var jsonWebTokenHandlerMock = new Mock(); + var entraLifecycleMock = new Mock(defaultAzureCredentialMock.Object, jsonWebTokenHandlerMock.Object); + entraLifecycleMock.Object._entraIdAccessToken = "valid_token"; + entraLifecycleMock.Object._entraIdAccessTokenExpiry = (int)DateTimeOffset.UtcNow.AddMinutes(22).ToUnixTimeSeconds(); + PlaywrightService service = new(entraLifecycle: entraLifecycleMock.Object); + + service.RotationHandlerAsync(null); + defaultAzureCredentialMock.Verify(x => x.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public void GetConnectOptionsAsync_WhenServiceEndpointIsNotSet_ThrowsException() + { + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceUri, null); + PlaywrightService service = new(entraLifecycle: null); + Exception? ex = Assert.ThrowsAsync(() => service.GetConnectOptionsAsync()); + Assert.That(ex!.Message, Is.EqualTo(Constants.s_no_service_endpoint_error_message)); + } + + [Test] + public void GetConnectOptionsAsync_WhenUseCloudHostedBrowsersEnvironmentIsFalse_ThrowsException() + { + PlaywrightService service = new(entraLifecycle: null, useCloudHostedBrowsers: false); + Exception? ex = Assert.ThrowsAsync(() => service.GetConnectOptionsAsync()); + Assert.Multiple(() => + { + Assert.That(service.UseCloudHostedBrowsers, Is.False); + Assert.That(ex!.Message, Is.EqualTo(Constants.s_service_endpoint_removed_since_scalable_execution_disabled_error_message)); + }); + } + + [Test] + public async Task GetConnectOptionsAsync_WhenServiceEndpointIsSet_ReturnsConnectOptions() + { + var defaultAzureCredentialMock = new Mock(); + var jsonWebTokenHandlerMock = new Mock(); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, "valid_token"); + var entraLifecycleMock = new Mock(defaultAzureCredentialMock.Object, jsonWebTokenHandlerMock.Object); + entraLifecycleMock.Object._entraIdAccessToken = "valid_token"; + entraLifecycleMock.Object._entraIdAccessTokenExpiry = (int)DateTimeOffset.UtcNow.AddMinutes(22).ToUnixTimeSeconds(); + var runId = "run-id"; + + PlaywrightService service = new(entraLifecycle: entraLifecycleMock.Object); + ConnectOptions connectOptions = await service.GetConnectOptionsAsync(runId: runId); + var authorizationHeader = connectOptions.Options!.Headers!.Where(x => x.Key == "Authorization").FirstOrDefault().Value!; + Assert.Multiple(() => + { + Assert.That(connectOptions.WsEndpoint, Is.EqualTo($"https://playwright.microsoft.com?os={Constants.s_default_os}&runId={runId}&api-version={Constants.s_api_version}")); + Assert.That(connectOptions.Options!.Timeout, Is.EqualTo(3 * 60 * 1000)); + Assert.That(connectOptions.Options!.ExposeNetwork, Is.EqualTo(Constants.s_default_expose_network)); + Assert.That(authorizationHeader, Is.EqualTo("Bearer valid_token")); + }); + } + + [Test] + public async Task GetConnectOptionsAsync_WhenTokenRequiresRotation_RotatesEntraToken() + { + var defaultAzureCredentialMock = new Mock(); + defaultAzureCredentialMock + .Setup(x => x.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccessToken("valid_token", DateTimeOffset.UtcNow.AddMinutes(5))); + var jsonWebTokenHandlerMock = new Mock(); + var entraLifecycleMock = new Mock(defaultAzureCredentialMock.Object, jsonWebTokenHandlerMock.Object); + entraLifecycleMock.Object._entraIdAccessToken = "valid_token"; + entraLifecycleMock.Object._entraIdAccessTokenExpiry = (int)DateTimeOffset.UtcNow.AddMinutes(-1).ToUnixTimeSeconds(); + + PlaywrightService service = new(entraLifecycle: entraLifecycleMock.Object); + _ = await service.GetConnectOptionsAsync(); + defaultAzureCredentialMock.Verify(x => x.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task GetConnectOptionsAsync_WhenTokenDoesNotRequireRotation_DoesNotRotateEntraToken() + { + var defaultAzureCredentialMock = new Mock(); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, "valid_token"); + defaultAzureCredentialMock + .Setup(x => x.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccessToken("valid_token", DateTimeOffset.UtcNow.AddMinutes(5))); + var jsonWebTokenHandlerMock = new Mock(); + var entraLifecycleMock = new Mock(defaultAzureCredentialMock.Object, jsonWebTokenHandlerMock.Object); + entraLifecycleMock.Object._entraIdAccessToken = "valid_token"; + entraLifecycleMock.Object._entraIdAccessTokenExpiry = (int)DateTimeOffset.UtcNow.AddMinutes(22).ToUnixTimeSeconds(); + + PlaywrightService service = new(entraLifecycle: entraLifecycleMock.Object); + _ = await service.GetConnectOptionsAsync(); + defaultAzureCredentialMock.Verify(x => x.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task GetConnectOptionsAsync_WhenDefaultParametersAreProvided_SetsServiceParameters() + { + var defaultAzureCredentialMock = new Mock(); + var jsonWebTokenHandlerMock = new Mock(); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, "valid_token"); + var entraLifecycleMock = new Mock(defaultAzureCredentialMock.Object, jsonWebTokenHandlerMock.Object); + entraLifecycleMock.Object + ._entraIdAccessToken = "valid_token"; + entraLifecycleMock.Object + ._entraIdAccessTokenExpiry = (int)DateTimeOffset.UtcNow.AddMinutes(22).ToUnixTimeSeconds(); + var runId = "run-id"; + + var service = new PlaywrightService(entraLifecycle: entraLifecycleMock.Object); + ConnectOptions connectOptions = await service.GetConnectOptionsAsync(runId: runId, os: OSPlatform.Windows, exposeNetwork: "localhost"); + + Assert.Multiple(() => + { + Assert.That(connectOptions.WsEndpoint, Is.EqualTo($"https://playwright.microsoft.com?os={ServiceOs.Windows}&runId={runId}&api-version={Constants.s_api_version}")); + Assert.That(connectOptions.Options!.ExposeNetwork, Is.EqualTo("localhost")); + }); + } + + [Test] + public async Task GetConnectOptionsAsync_WhenDefaultParametersAreNotProvided_SetsDefaultServiceParameters() + { + var defaultAzureCredentialMock = new Mock(); + var jsonWebTokenHandlerMock = new Mock(); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, "valid_token"); + var entraLifecycleMock = new Mock(defaultAzureCredentialMock.Object, jsonWebTokenHandlerMock.Object); + entraLifecycleMock.Object + ._entraIdAccessToken = "valid_token"; + entraLifecycleMock.Object + ._entraIdAccessTokenExpiry = (int)DateTimeOffset.UtcNow.AddMinutes(22).ToUnixTimeSeconds(); + var runId = "run-id"; + + var service = new PlaywrightService(entraLifecycle: entraLifecycleMock.Object); + ConnectOptions connectOptions = await service.GetConnectOptionsAsync(runId: runId); + + Assert.Multiple(() => + { + Assert.That(connectOptions.WsEndpoint, Is.EqualTo($"https://playwright.microsoft.com?os={Constants.s_default_os}&runId={runId}&api-version={Constants.s_api_version}")); + Assert.That(connectOptions.Options!.ExposeNetwork, Is.EqualTo(Constants.s_default_expose_network)); + }); + } + + [Test] + public async Task GetConnectOptionsAsync_WhenServiceParametersAreSetViaEnvironment_SetsServiceParameters() + { + var defaultAzureCredentialMock = new Mock(); + var jsonWebTokenHandlerMock = new Mock(); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, "valid_token"); + var entraLifecycleMock = new Mock(defaultAzureCredentialMock.Object, jsonWebTokenHandlerMock.Object); + entraLifecycleMock.Object + ._entraIdAccessToken = "valid_token"; + entraLifecycleMock.Object + ._entraIdAccessTokenExpiry = (int)DateTimeOffset.UtcNow.AddMinutes(22).ToUnixTimeSeconds(); + var runId = "run-id"; + var service = new PlaywrightService(entraLifecycle: entraLifecycleMock.Object); + + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceOs, ServiceOs.Windows); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceExposeNetwork, "localhost"); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceRunId, runId); + + ConnectOptions connectOptions = await service.GetConnectOptionsAsync(); + Assert.Multiple(() => + { + Assert.That(connectOptions.WsEndpoint, Is.EqualTo($"https://playwright.microsoft.com?os={ServiceOs.Windows}&runId={runId}&api-version={Constants.s_api_version}")); + Assert.That(connectOptions.Options!.ExposeNetwork, Is.EqualTo("localhost")); + }); + } + + [Test] + public void GetConnectOptionsAsync_WhenNoAuthTokenIsSet_ThrowsException() + { + var defaultAzureCredentialMock = new Mock(); + var jsonWebTokenHandlerMock = new Mock(); + var entraLifecycleMock = new Mock(defaultAzureCredentialMock.Object, jsonWebTokenHandlerMock.Object); + var service = new PlaywrightService(entraLifecycle: entraLifecycleMock.Object); + + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceAccessToken, null); + + Exception? ex = Assert.ThrowsAsync(() => service.GetConnectOptionsAsync()); + Assert.That(ex!.Message, Is.EqualTo(Constants.s_no_auth_error)); + } + + [Test] + public void GetDefaultRunId_RunIdSetViaEnvironmentVariable_ReturnsRunId() + { + var runId = "run-id"; + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceRunId, runId); + Assert.Multiple(() => + { + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceRunId, null); + Assert.That(PlaywrightService.GetDefaultRunId(), Is.Not.Null); + }); + } + + [Test] + public void GetDefaultRunId_RunIdNotSetViaEnvironmentVariable_ReturnsRandomRunId() + { + Assert.Multiple(() => + { + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceRunId, null); + Assert.That(PlaywrightService.GetDefaultRunId(), Is.Not.Null); + }); + } + + [Test] + public void SetReportingUrlAndWorkspaceId_WhenServiceEndpointIsSet_SetsReportingUrlAndWorkspaceId() + { + var testRubricCombinations = new List>() + { + new() + { + { "url", "wss://eastus.api.playwright.microsoft.com/accounts/eastus_bd830e63-6120-40cb-8cd7-f0739502d888/browsers" }, + { "workspaceId", "eastus_bd830e63-6120-40cb-8cd7-f0739502d888" }, + { "region", "eastus" }, + { "domain", "playwright.microsoft.com" } + }, + new() + { + { "url", "wss://eastus.api.playwright.microsoft.com/accounts/77a38aac-4577-43a9-ac72-5720e5459c5a/browsers" }, + { "workspaceId", "77a38aac-4577-43a9-ac72-5720e5459c5a" }, + { "region", "eastus" }, + { "domain", "playwright.microsoft.com" } + }, + new() + { + { "url", "wss://westus3.api.playwright.microsoft.com/accounts/ad3cf59a-43e1-4dbe-af22-49bfe72b4178/browsers" }, + { "workspaceId", "ad3cf59a-43e1-4dbe-af22-49bfe72b4178" }, + { "region", "westus3" }, + { "domain", "playwright.microsoft.com" } + }, + new() + { + { "url", "wss://westus3.api.playwright-int.io/accounts/3c9ae1d4-e856-4ce0-8b56-1f4488676dff/browsers" }, + { "workspaceId", "3c9ae1d4-e856-4ce0-8b56-1f4488676dff" }, + { "region", "westus3" }, + { "domain", "playwright-int.io" } + }, + new() + { + { "url", "wss://eastasia.api.playwright-test.io/accounts/29abee44-a5f4-477e-9ff1-6c6786d09c7c/browsers" }, + { "workspaceId", "29abee44-a5f4-477e-9ff1-6c6786d09c7c" }, + { "region", "eastasia" }, + { "domain", "playwright-test.io" } + } + }; + + foreach (Dictionary testRubric in testRubricCombinations) + { + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceUri, $"{testRubric["url"]}"); + var service = new PlaywrightService(entraLifecycle: null); + Assert.Multiple(() => + { + Assert.That(Environment.GetEnvironmentVariable(Constants.s_playwright_service_reporting_url_environment_variable), Is.EqualTo($"https://{testRubric["region"]}.reporting.api.{testRubric["domain"]}")); + Assert.That(Environment.GetEnvironmentVariable(Constants.s_playwright_service_workspace_id_environment_variable), Is.EqualTo(testRubric["workspaceId"])); + }); + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceUri, null); + Environment.SetEnvironmentVariable(Constants.s_playwright_service_reporting_url_environment_variable, null); + Environment.SetEnvironmentVariable(Constants.s_playwright_service_workspace_id_environment_variable, null); + } + } + + [Test] + public void SetReportingUrlAndWorkspaceId_WhenReportingServiceEndpointIsSet_OnlySetsWorkspaceId() + { + var testRubric = new Dictionary + { + { "url", "wss://eastus.api.playwright.microsoft.com/accounts/eastus_bd830e63-6120-40cb-8cd7-f0739502d888/browsers" }, + { "workspaceId", "eastus_bd830e63-6120-40cb-8cd7-f0739502d888" }, + { "region", "eastus" }, + { "domain", "playwright.microsoft.com" } + }; + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceUri, $"{testRubric["url"]}"); + Environment.SetEnvironmentVariable(Constants.s_playwright_service_reporting_url_environment_variable, "https://playwright.microsoft.com"); + var service = new PlaywrightService(entraLifecycle: null); + Assert.Multiple(() => + { + Assert.That(Environment.GetEnvironmentVariable(Constants.s_playwright_service_reporting_url_environment_variable), Is.EqualTo("https://playwright.microsoft.com")); + Assert.That(Environment.GetEnvironmentVariable(Constants.s_playwright_service_workspace_id_environment_variable), Is.EqualTo(testRubric["workspaceId"])); + }); + } + + [Test] + public void SetReportingUrlAndWorkspaceId_WhenReportingServiceEndpointAndWorkspaceIdIsSet_NoOp() + { + var testRubric = new Dictionary + { + { "url", "wss://eastus.api.playwright.microsoft.com/accounts/eastus_bd830e63-6120-40cb-8cd7-f0739502d888/browsers" }, + { "workspaceId", "eastus_bd830e63-6120-40cb-8cd7-f0739502d888" }, + { "region", "eastus" }, + { "domain", "playwright.microsoft.com" } + }; + Environment.SetEnvironmentVariable(ServiceEnvironmentVariable.PlaywrightServiceUri, $"{testRubric["url"]}"); + Environment.SetEnvironmentVariable(Constants.s_playwright_service_reporting_url_environment_variable, "https://playwright.microsoft.com"); + Environment.SetEnvironmentVariable(Constants.s_playwright_service_workspace_id_environment_variable, "sample-id"); + var service = new PlaywrightService(entraLifecycle: null); + Assert.Multiple(() => + { + Assert.That(Environment.GetEnvironmentVariable(Constants.s_playwright_service_reporting_url_environment_variable), Is.EqualTo("https://playwright.microsoft.com")); + Assert.That(Environment.GetEnvironmentVariable(Constants.s_playwright_service_workspace_id_environment_variable), Is.EqualTo("sample-id")); + }); + } +} diff --git a/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/tests/TestUtils.cs b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/tests/TestUtils.cs new file mode 100644 index 0000000000000..52308c93a16cc --- /dev/null +++ b/sdk/playwrighttesting/Azure.Developer.MicrosoftPlaywrightTesting.TestLogger/tests/TestUtils.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Linq; + +namespace Azure.Developer.MicrosoftPlaywrightTesting.TestLogger.Tests; + +public class TestUtils +{ + public static string GetPlaywrightServiceAPIEndpoint(string workspaceId, string region) + { + return $"https://{region}.api.playwright.microsoft.com/accounts/{workspaceId}/browsers"; + } + + public static string GetWorkspaceIdFromDashboardEndpoint(string dashboardEndpoint) + { + var parts = dashboardEndpoint.Split('/'); + return parts.Last(); + } +} diff --git a/sdk/playwrighttesting/ci.yml b/sdk/playwrighttesting/ci.yml new file mode 100644 index 0000000000000..c36cc48c299de --- /dev/null +++ b/sdk/playwrighttesting/ci.yml @@ -0,0 +1,41 @@ +# NOTE: Please refer to https://aka.ms/azsdk/engsys/ci-yaml before editing this file. + +trigger: + branches: + include: + - main + - hotfix/* + - release/* + paths: + include: + - sdk/playwrighttesting/ + exclude: + - sdk/playwrighttesting/Azure.ResourceManager.PlaywrightTesting/ + +pr: + branches: + include: + - main + - feature/* + - hotfix/* + - release/* + paths: + include: + - sdk/playwrighttesting/ + exclude: + - sdk/playwrighttesting/Azure.ResourceManager.PlaywrightTesting/ + +extends: + template: /eng/pipelines/templates/stages/archetype-sdk-client.yml + parameters: + ServiceDirectory: playwrighttesting + BuildSnippets: false + ArtifactName: packages + Artifacts: + - name: Azure.Developer.MicrosoftPlaywrightTesting.NUnit + safeName: AzureDeveloperMicrosoftPlaywrightTestingNUnit + - name: Azure.Developer.MicrosoftPlaywrightTesting.TestLogger + safeName: AzureDeveloperMicrosoftPlaywrightTesting.TestLogger + + + diff --git a/sdk/playwrighttesting/test-resources.json b/sdk/playwrighttesting/test-resources.json new file mode 100644 index 0000000000000..a1d088553b8da --- /dev/null +++ b/sdk/playwrighttesting/test-resources.json @@ -0,0 +1,47 @@ +{ + "$schema": "http://schema.management.azure.com/schemas/2019-08-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "baseName": { + "type": "string", + "defaultValue": "[resourceGroup().name]", + "metadata": { + "description": "The base resource name." + } + }, + "tenantId": { + "type": "string", + "defaultValue": "72f988bf-86f1-41af-91ab-2d7cd011db47", + "metadata": { + "description": "The tenant ID to which the application and resources belong." + } + }, + "testApplicationOid": { + "type": "string", + "metadata": { + "description": "The client OID to grant access to test resources." + } + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "The location of the resource. By default, this is the same as the resource group." + } + } + }, + "resources": [ + { + "apiVersion": "2024-02-01-preview", + "name": "[parameters('baseName')]", + "location": "[parameters('location')]", + "type": "Microsoft.AzurePlaywrightService/accounts" + } + ], + "outputs": { + "DASHBOARD_ENDPOINT": { + "type": "string", + "value": "[reference(resourceId('Microsoft.AzurePlaywrightService/accounts', parameters('baseName'))).dashboardUri]" + } + } +} \ No newline at end of file diff --git a/sdk/playwrighttesting/tests.yml b/sdk/playwrighttesting/tests.yml new file mode 100644 index 0000000000000..2a730f27c3ea7 --- /dev/null +++ b/sdk/playwrighttesting/tests.yml @@ -0,0 +1,8 @@ +trigger: none + +extends: + template: ../../eng/pipelines/templates/stages/archetype-sdk-tests.yml + parameters: + ServiceDirectory: playwrighttesting + SupportedClouds: 'Public' + Location: 'westus3' \ No newline at end of file