| File: Playwright\UITest.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 Microsoft.AspNetCore.Components.Testing.Infrastructure; using Microsoft.Playwright; using System.Linq; namespace Microsoft.AspNetCore.Components.Testing.Playwright; /// <summary> /// Root base class for E2E UI test classes. It owns the framework-agnostic per-test /// lifecycle (<see cref="InitializeCoreAsync"/> / <see cref="CleanupCoreAsync"/>) and the /// set of servers whose captured output should be attached to a failing test. /// </summary> /// <remarks> /// <para> /// This type deliberately has no dependency on any test framework. A test class opts in /// to the E2E infrastructure by deriving from a capability base (<see cref="PlaywrightTest"/>, /// <see cref="BrowserTest"/>, <see cref="ContextTest"/>, or <see cref="PageTest"/>) and /// applying <see cref="UITestAttribute"/>. The bundled source generator then emits the /// test-framework binding (for MSTest: <c>[TestClass]</c>, the <c>TestContext</c> property, /// and the <c>[TestInitialize]</c>/<c>[TestCleanup]</c> hooks) onto the consumer's /// <c>partial</c> class, calling the lifecycle methods below in the correct base-first order. /// </para> /// <para> /// Derived classes override <see cref="InitializeCoreAsync"/> / <see cref="CleanupCoreAsync"/> /// and <b>must call the base implementation first</b> so the capability ladder (browser → /// context → page) is established before per-test setup runs. /// </para> /// </remarks> public abstract class UITest : IAsyncDisposable { private readonly object _disposablesLock = new(); private readonly List<IAsyncDisposable> _disposables = new(); private readonly List<ServerInstance> _diagnosticServers = new(); internal ITestArtifactManager ArtifactManager => this as ITestArtifactManager ?? throw new InvalidOperationException( $"{GetType().FullName} must be annotated with UITestAttribute."); internal T RegisterForDisposal<T>(T disposable) where T : IAsyncDisposable { ArgumentNullException.ThrowIfNull(disposable); lock (_disposablesLock) { _disposables.Add(disposable); } return disposable; } /// <summary> /// Starts an application server and automatically captures its output when startup or the test fails. /// </summary> /// <typeparam name="TApp">Any public type from the application assembly.</typeparam> /// <param name="factory">The assembly-scoped server factory.</param> /// <param name="configure">Optional server-start configuration.</param> /// <returns>The running server instance.</returns> protected async Task<ServerInstance> StartServerAsync<TApp>( ServerFactory factory, Action<ServerStartOptions>? configure = null) { ArgumentNullException.ThrowIfNull(factory); var appName = typeof(TApp).Assembly.GetName().Name ?? throw new InvalidOperationException( $"Could not determine assembly name for type '{typeof(TApp).FullName}'."); var server = await factory.StartServerAsync(appName, configure, ArtifactManager).ConfigureAwait(false); lock (_disposablesLock) { _diagnosticServers.Add(server); } return server; } /// <summary> /// Starts tracing an existing browser context until the current test is disposed. /// </summary> /// <param name="context">The browser context to trace.</param> protected async Task TraceAsync(IBrowserContext context) { ArgumentNullException.ThrowIfNull(context); var directory = ArtifactManager.CreateArtifactDirectory("browser"); var session = await PlaywrightExtensions.TraceAsync(context, directory, ArtifactManager).ConfigureAwait(false); RegisterForDisposal(session); } /// <summary> /// Per-test initialization. Override to perform setup, calling /// <c>await base.InitializeCoreAsync()</c> first. /// </summary> protected internal virtual Task InitializeCoreAsync() => Task.CompletedTask; /// <summary> /// Per-test cleanup. Override to perform teardown; the base chain runs last. /// </summary> protected internal virtual Task CleanupCoreAsync() => Task.CompletedTask; /// <inheritdoc/> async ValueTask IAsyncDisposable.DisposeAsync() { IAsyncDisposable[] disposables; ServerInstance[] diagnosticServers; lock (_disposablesLock) { disposables = _disposables.ToArray(); _disposables.Clear(); diagnosticServers = _diagnosticServers.ToArray(); _diagnosticServers.Clear(); } List<Exception>? exceptions = null; try { if (ArtifactManager.ShouldSaveArtifacts() && diagnosticServers.Length > 0) { try { var directory = ArtifactManager.CreateArtifactDirectory("server-output"); var paths = diagnosticServers .SelectMany(server => server.WriteCapturedOutputTo(directory)) .ToArray(); ArtifactManager.AddArtifacts(paths); } catch (Exception exception) { exceptions ??= new(); exceptions.Add(exception); } } for (var i = disposables.Length - 1; i >= 0; i--) { try { await disposables[i].DisposeAsync().ConfigureAwait(false); } catch (Exception exception) { exceptions ??= new(); exceptions.Add(exception); } } } finally { GC.SuppressFinalize(this); } if (exceptions is not null) { throw new AggregateException(exceptions); } } }