File: Infrastructure\PlaywrightExtensions.cs
Web Access
Project: src\aspnetcore\src\Components\Testing\src\Microsoft.AspNetCore.Components.Testing.csproj (Microsoft.AspNetCore.Components.Testing)
// 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);
    }
 
}