Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,12 @@ public static IResourceBuilder<AzureAppServiceEnvironmentResource> AddAzureAppSe
Value = identity.ClientId
});

if (resource.AddAzurePlaywrightWorkspace)
{
// Add Azure Playwright Testing Workspace resource
AzureAppServiceEnvironmentUtility.AddAzurePlaywrightWorkspaceResource(infra, resource, prefix);
}

if (resource.EnableDashboard)
{
// Add aspire dashboard website
Expand Down Expand Up @@ -291,4 +297,16 @@ public static IResourceBuilder<AzureAppServiceEnvironmentResource> WithAutomatic
builder.Resource.EnableAutomaticScaling = true;
return builder;
}

/// <summary>
/// Configures whether Azure Playwright Workspace should be created for the Azure App Service environment.
/// </summary>
/// <param name="builder">The <see cref="IResourceBuilder{AzureAppServiceEnvironmentResource}"/> to configure.</param>
/// <param name="enable">Whether to create Azure Playwright Workspace. Default is true.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining additional configuration.</returns>
public static IResourceBuilder<AzureAppServiceEnvironmentResource> WithAzurePlaywrightWorkspace(this IResourceBuilder<AzureAppServiceEnvironmentResource> builder, bool enable = true)
{
builder.Resource.AddAzurePlaywrightWorkspace = enable;
return builder;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,22 @@ await context.ReportingStep.CompleteAsync(
/// </summary>
internal bool EnableApplicationInsights { get; set; }

/// <summary>
/// Gets or sets a value indicating whether Azure Playwright Workspace should be created for the app service environment.
/// Default is false.
/// </summary>
internal bool AddAzurePlaywrightWorkspace { get; set; }

/// <summary>
/// Gets the location for the Playwright workspace resource. If <c>null</c>, the resource group location is used.
/// </summary>
internal string? PlaywrightWorkspaceLocation { get; set; }

/// <summary>
/// Gets the location parameter for the Playwright workspace resource.
/// </summary>
internal ParameterResource? PlaywrightWorkspaceLocationParameter { get; set; }

/// <summary>
/// Gets the location for the Application Insights resource. If <c>null</c>, the resource group location is used.
/// </summary>
Expand Down Expand Up @@ -214,6 +230,21 @@ await context.ReportingStep.CompleteAsync(
public BicepOutputReference AzureAppInsightsConnectionStringReference =>
new("AZURE_APPLICATION_INSIGHTS_CONNECTION_STRING", this);

/// <summary>
/// Gets the Playwright Workspace Name.
/// </summary>
public BicepOutputReference PlaywrightWorkspaceName => new("AZURE_PLAYWRIGHT_WORKSPACE_NAME", this);

/// <summary>
/// Gets the Playwright Workspace Id.
/// </summary>
public BicepOutputReference PlaywrightWorkspaceId => new("AZURE_PLAYWRIGHT_WORKSPACE_ID", this);

/// <summary>
/// Gets the Playwright Workspace DataPlane Uri.
/// </summary>
public BicepOutputReference PlaywrightWorkspaceDataPlaneUri => new("AZURE_PLAYWRIGHT_WORKSPACE_DATA_PLANE_URI", this);

internal static BicepValue<string> GetWebSiteSuffixBicep() =>
BicepFunction.GetUniqueString(BicepFunction.GetResourceGroup().Id);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Azure.Provisioning.AppService;
using Azure.Provisioning.Authorization;
using Azure.Provisioning.Expressions;
using Azure.Provisioning.Primitives;
using Azure.Provisioning.Resources;
using Azure.Provisioning.Roles;

Expand Down Expand Up @@ -114,4 +115,94 @@ public static WebSite AddDashboard(AzureResourceInfrastructure infra,

return dashboard;
}

public static void AddAzurePlaywrightWorkspaceResource(AzureResourceInfrastructure infra, AzureAppServiceEnvironmentResource resource, string prefix)
{
var playwrightWorkspaceResourceName = Infrastructure.NormalizeBicepIdentifier($"{prefix}_playwright");

var playwrightWorkspace = new PlaywrightWorkspaceResource(playwrightWorkspaceResourceName);

// Set location if specified
if (resource.PlaywrightWorkspaceLocation is not null)
{
playwrightWorkspace.Location = new AzureLocation(resource.PlaywrightWorkspaceLocation);
}
else if (resource.PlaywrightWorkspaceLocationParameter is not null)
{
var locationParameter = resource.PlaywrightWorkspaceLocationParameter.AsProvisioningParameter(infra);
playwrightWorkspace.Location = locationParameter;
}

infra.Add(playwrightWorkspace);

infra.Add(new ProvisioningOutput("AZURE_PLAYWRIGHT_WORKSPACE_NAME", typeof(string))
{
Value = new MemberExpression(new IdentifierExpression(playwrightWorkspace.BicepIdentifier), "name")
});

infra.Add(new ProvisioningOutput("AZURE_PLAYWRIGHT_WORKSPACE_ID", typeof(string))
{
Value = new MemberExpression(new IdentifierExpression(playwrightWorkspace.BicepIdentifier), "id")
});
infra.Add(new ProvisioningOutput("AZURE_PLAYWRIGHT_WORKSPACE_DATA_PLANE_URI", typeof(string))
{
Value = new MemberExpression(new IdentifierExpression(playwrightWorkspace.BicepIdentifier), "properties.dataplaneUri")
});
}
}

/// <summary>
/// Represents an Azure Playwright Testing Workspace resource.
/// </summary>
internal sealed class PlaywrightWorkspaceResource : ProvisionableResource
{
/// <summary>
/// Initializes a new instance of the <see cref="PlaywrightWorkspaceResource"/> class.
/// </summary>
/// <param name="bicepIdentifier">The Bicep identifier for this resource.</param>
internal PlaywrightWorkspaceResource(string bicepIdentifier)
: base(bicepIdentifier, new("Microsoft.LoadTestService/playwrightworkspaces"), "2025-07-01-preview")
{
}

public BicepValue<AzureLocation> Location
{
get { Initialize(); return _location!; }
set { Initialize(); _location!.Assign(value); }
}
private BicepValue<AzureLocation>? _location;

public BicepValue<string> Name
{
get { Initialize(); return _name!; }
set { Initialize(); _name!.Assign(value); }
}
private BicepValue<string>? _name;

public BicepDictionary<string> Properties
{
get { Initialize(); return _properties!; }
set { Initialize(); AssignOrReplace(ref _properties, value); }
}
private BicepDictionary<string>? _properties;

protected override void DefineProvisionableProperties()
{
base.DefineProvisionableProperties();

_location = DefineProperty<AzureLocation>(nameof(Location), ["location"], isOutput: false, isRequired: false);

// Set the name using Bicep expression for unique naming
var nameExpression = BicepFunction.Take(
BicepFunction.Interpolate($"pw-{BicepFunction.GetUniqueString(BicepFunction.GetResourceGroup().Id)}"),
24);

_name = DefineProperty<string>(nameof(Name), ["name"], isOutput: false, isRequired: true);
_name.Assign(nameExpression);

// Define properties
_properties = DefineDictionaryProperty<string>(nameof(Properties), ["properties"], isOutput: false);
_properties["regionalAffinity"] = "Enabled";
_properties["localAuth"] = "Disabled";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Hosting.ApplicationModel;

namespace Aspire.Hosting;

/// <summary>
/// Provides extension methods for configuring Azure App Service project resources.
/// </summary>
public static class AzureAppServiceProjectExtensions
{
/// <summary>
/// Enables Azure Playwright Testing for the project when deployed to Azure App Service.
/// </summary>
/// <typeparam name="T">The resource type.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <remarks>
/// This method adds an annotation to the project that signals the Azure App Service deployment
/// to inject the Playwright workspace environment variables
/// when the project is deployed to an environment that has Azure Playwright Workspace enabled via
/// <see cref="AzureAppServiceEnvironmentExtensions.WithAzurePlaywrightWorkspace"/>.
/// </remarks>
public static IResourceBuilder<T> EnablePlaywrightTesting<T>(this IResourceBuilder<T> builder)
where T : IResourceWithEnvironment
{
ArgumentNullException.ThrowIfNull(builder);

builder.Resource.Annotations.Add(new Azure.EnablePlaywrightTestingAnnotation());

return builder;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,18 @@ private RoleAssignment AddDashboardPermissionAndSettings(WebSite webSite, Provis
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "OTEL_COLLECTOR_URL", Value = dashboardUri });
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "OTEL_CLIENT_ID", Value = acrClientIdParameter });

// If playwright workspace is enabled for the environment and this app has enabled Playwright testing, add its details to appsettings
if (environmentContext.Environment.AddAzurePlaywrightWorkspace &&
resource.TryGetLastAnnotation<EnablePlaywrightTestingAnnotation>(out _))
{
var playwrightWorkspaceName = environmentContext.Environment.PlaywrightWorkspaceName.AsProvisioningParameter(Infra);
var playwrightWorkspaceId = environmentContext.Environment.PlaywrightWorkspaceId.AsProvisioningParameter(Infra);
var playwrightWorkspaceUri = environmentContext.Environment.PlaywrightWorkspaceDataPlaneUri.AsProvisioningParameter(Infra);
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "PLAYWRIGHT_WORKSPACE_NAME", Value = playwrightWorkspaceName });
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "PLAYWRIGHT_WORKSPACE_ID", Value = playwrightWorkspaceId });
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "PLAYWRIGHT_WORKSPACE_DATA_PLANE_URI", Value = playwrightWorkspaceUri});
}

// Add Website Contributor role assignment to dashboard's managed identity for this webapp
var websiteRaId = BicepFunction.GetSubscriptionResourceId(
"Microsoft.Authorization/roleDefinitions",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Hosting.ApplicationModel;

namespace Aspire.Hosting.Azure;

/// <summary>
/// Annotation to indicate that a project should have Playwright testing enabled in Azure App Service.
/// </summary>
internal sealed class EnablePlaywrightTestingAnnotation : IResourceAnnotation
{
}
42 changes: 42 additions & 0 deletions tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,48 @@ await Verify(manifest.ToString(), "json")
.AppendContentAsFile(bicep, "bicep");
}

[Fact]
public async Task AddAppServiceEnvironmentWithPlaywrightTestingAddsEnvironmentResource()
{
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);

builder.AddAzureAppServiceEnvironment("env").WithAzurePlaywrightWorkspace();

using var app = builder.Build();

await ExecuteBeforeStartHooksAsync(app, default);

var model = app.Services.GetRequiredService<DistributedApplicationModel>();

var environment = Assert.Single(model.Resources.OfType<AzureAppServiceEnvironmentResource>());

var (manifest, bicep) = await GetManifestWithBicep(environment);

await Verify(manifest.ToString(), "json")
.AppendContentAsFile(bicep, "bicep");
}

[Fact]
public async Task AddAppServiceEnvironmentWithoutPlaywrightTestingAddsEnvironmentResource()
{
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);

builder.AddAzureAppServiceEnvironment("env").WithAzurePlaywrightWorkspace(false);

using var app = builder.Build();

await ExecuteBeforeStartHooksAsync(app, default);

var model = app.Services.GetRequiredService<DistributedApplicationModel>();

var environment = Assert.Single(model.Resources.OfType<AzureAppServiceEnvironmentResource>());

var (manifest, bicep) = await GetManifestWithBicep(environment);

await Verify(manifest.ToString(), "json")
.AppendContentAsFile(bicep, "bicep");
}

private static Task<(JsonNode ManifestNode, string BicepText)> GetManifestWithBicep(IResource resource) =>
AzureManifestUtils.GetManifestWithBicep(resource, skipPreparer: true);

Expand Down
Loading