// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Linq;
using Microsoft.Playwright;
namespace Microsoft.AspNetCore.Components.Testing.Infrastructure;
/// <summary>
/// Extension methods for Playwright types used in E2E tests.
/// </summary>
public static class PlaywrightExtensions
{
// Toggle video recording via environment variable.
// Set PLAYWRIGHT_RECORD_VIDEO=1 to enable video capture for all tests.
private static readonly bool s_recordVideo =
string.Equals(
Environment.GetEnvironmentVariable("PLAYWRIGHT_RECORD_VIDEO"),
"1",
StringComparison.Ordinal);
/// <summary>
/// Whether Playwright video recording is enabled for this test run
/// (set via the <c>PLAYWRIGHT_RECORD_VIDEO=1</c> environment variable).
/// </summary>
internal static bool RecordVideoEnabled => s_recordVideo;
/// <summary>
/// Sets the <c>X-Test-Backend</c> header on browser context options
/// so the YARP proxy routes requests to the correct <see cref="ServerInstance"/>.
/// </summary>
/// <param name="options">The browser context options to configure.</param>
/// <param name="server">The server instance to route traffic to.</param>
/// <returns>The same <paramref name="options"/> instance for chaining.</returns>
public static BrowserNewContextOptions WithServerRouting(
this BrowserNewContextOptions options, ServerInstance server)
{
var headers = options.ExtraHTTPHeaders?.ToDictionary(h => h.Key, h => h.Value)
?? new Dictionary<string, string>();
headers["X-Test-Backend"] = server.Id;
options.ExtraHTTPHeaders = headers;
return options;
}
/// <summary>
/// Configures video recording on the browser context options when
/// the <c>PLAYWRIGHT_RECORD_VIDEO=1</c> environment variable is set.
/// Must be called before creating the context (<c>RecordVideoDir</c> cannot be set after creation).
/// </summary>
/// <param name="options">The browser context options to configure.</param>
/// <param name="artifactDir">The directory to store video files in.</param>
/// <returns>The same <paramref name="options"/> instance for chaining.</returns>
internal static BrowserNewContextOptions WithArtifacts(
this BrowserNewContextOptions options, string? artifactDir = null)
{
if (s_recordVideo && artifactDir is not null)
{
options.RecordVideoDir = artifactDir;
}
return options;
}
/// <summary>
/// Starts tracing on an existing browser context. Returns a <see cref="TracingSession"/>
/// that saves or discards the trace (and video) on disposal based on the
/// <paramref name="artifactManager"/>.
/// </summary>
/// <param name="context">The browser context to trace.</param>
/// <param name="artifactDir">The directory to store trace artifacts in.</param>
/// <param name="artifactManager">Determines whether artifacts are retained and publishes retained files.</param>
/// <returns>A <see cref="TracingSession"/> that manages trace lifecycle.</returns>
internal static async Task<TracingSession> TraceAsync(
IBrowserContext context,
string artifactDir,
ITestArtifactManager artifactManager)
{
return await TracingSession.StartAsync(context, artifactDir, s_recordVideo, artifactManager)
.ConfigureAwait(false);
}
/// <summary>
/// Creates a new browser context on <paramref name="browser"/> with server routing,
/// artifact capture (video if enabled), and active tracing. Returns a
/// <see cref="TracedContext"/> that wraps the context and tracing session.
/// </summary>
/// <param name="browser">The Playwright browser to create the context on.</param>
/// <param name="server">The server instance to route traffic to.</param>
/// <param name="artifactDir">The directory to store trace artifacts in.</param>
/// <param name="artifactManager">Determines whether artifacts are retained and publishes retained files.</param>
/// <param name="options">Optional browser context options. If <c>null</c>, defaults are used.</param>
/// <returns>A <see cref="TracedContext"/> wrapping the browser context and tracing session.</returns>
internal static async Task<TracedContext> NewTracedContextAsync(
IBrowser browser,
ServerInstance server,
string artifactDir,
ITestArtifactManager artifactManager,
BrowserNewContextOptions? options = null)
{
ArgumentNullException.ThrowIfNull(browser);
ArgumentNullException.ThrowIfNull(server);
ArgumentNullException.ThrowIfNull(artifactDir);
ArgumentNullException.ThrowIfNull(artifactManager);
options ??= new BrowserNewContextOptions();
options = options
.WithServerRouting(server)
.WithArtifacts(artifactDir);
var context = await browser.NewContextAsync(options).ConfigureAwait(false);
var session = await TracingSession.StartAsync(context, artifactDir, s_recordVideo, artifactManager)
.ConfigureAwait(false);
// ownsContext: true — the IBrowser overload created the context, so the wrapper owns disposal.
return new TracedContext(context, session, ownsContext: true);
}
/// <summary>
/// Variant of <see cref="NewTracedContextAsync(IBrowser, ServerInstance, string, ITestArtifactManager, BrowserNewContextOptions?)"/>
/// that starts tracing on an existing <paramref name="context"/> instead of creating a
/// new one. Use this when you need to configure the context yourself (e.g. with
/// custom cookies, viewport, etc.) before tracing begins.
/// </summary>
/// <param name="context">An existing Playwright browser context. The caller owns its disposal.</param>
/// <param name="artifactDir">The directory to store trace artifacts in.</param>
/// <param name="artifactManager">Determines whether artifacts are retained and publishes retained files.</param>
/// <returns>A <see cref="TracedContext"/> wrapping the browser context and tracing session.</returns>
internal static async Task<TracedContext> NewTracedContextAsync(
IBrowserContext context,
string artifactDir,
ITestArtifactManager artifactManager)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(artifactDir);
ArgumentNullException.ThrowIfNull(artifactManager);
var session = await TracingSession.StartAsync(context, artifactDir, s_recordVideo, artifactManager)
.ConfigureAwait(false);
// ownsContext: false — caller passed the context in and is responsible for disposing it.
return new TracedContext(context, session, ownsContext: false);
}
/// <summary>
/// Sets the <c>test-session-id</c> cookie on a browser context. The cookie targets
/// the proxy URL so YARP forwards it to the app, where the test infrastructure
/// reads it into <see cref="TestSessionContext.Id"/>.
/// </summary>
/// <param name="context">The browser context to add the cookie to.</param>
/// <param name="server">The server instance providing the proxy URL.</param>
/// <param name="sessionId">The session identifier value.</param>
public static async Task SetTestSession(
this IBrowserContext context, ServerInstance server, string sessionId)
{
await context.AddCookiesAsync(
[
new Cookie
{
Name = "test-session-id",
Value = sessionId,
Url = server.TestUrl
}
]).ConfigureAwait(false);
}
/// <summary>
/// Waits for the Blazor framework to load on the page by checking that the
/// global <c>Blazor</c> object exists.
/// </summary>
/// <param name="page">The page to wait on.</param>
public static Task WaitForBlazorAsync(this IPage page)
=> page.WaitForFunctionAsync("() => typeof Blazor !== 'undefined'");
/// <summary>
/// Waits for a Blazor component to become interactive by detecting event handler
/// registrations on the element matching the CSS selector. Blazor's EventDelegator
/// stores handler info as an expando property (<c>_blazorEvents_{id}</c>) on DOM
/// elements when <c>@onclick</c>, <c>@onchange</c>, etc. are registered.
/// </summary>
/// <param name="page">The page to wait on.</param>
/// <param name="selector">CSS selector identifying the element to check.</param>
public static Task WaitForInteractiveAsync(this IPage page, string selector)
=> page.WaitForFunctionAsync("""
(selector) => {
const el = document.querySelector(selector);
return el && Object.getOwnPropertyNames(el)
.some(k => k.startsWith('_blazorEvents_'));
}
""", selector);
/// <summary>
/// Registers a one-shot listener for the Blazor <c>enhancedload</c> event
/// and returns a task that completes when the event fires. Enhanced navigation
/// patches the DOM instead of doing a full page reload.
/// Call before the action that triggers navigation, then await the returned task.
/// </summary>
/// <param name="page">The page to listen on.</param>
/// <returns>A task that completes when enhanced navigation finishes.</returns>
public static async Task WaitForEnhancedNavigationAsync(this IPage page)
{
await using var handle = await page.EvaluateHandleAsync(
"() => new Promise(resolve => Blazor.addEventListener('enhancedload', () => resolve(true), { once: true }))").ConfigureAwait(false);
}
/// <summary>
/// Installs a one-shot <c>enhancedload</c> listener, executes the navigation action,
/// and waits for the enhanced navigation to complete.
/// </summary>
/// <param name="page">The page to listen on.</param>
/// <param name="navigationAction">The async action that triggers enhanced navigation (e.g., clicking a link).</param>
public static async Task WaitForEnhancedNavigationAsync(this IPage page, Func<Task> navigationAction)
{
var navTask = page.WaitForEnhancedNavigationAsync();
await navigationAction().ConfigureAwait(false);
await navTask.ConfigureAwait(false);
}
}