Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
38 changes: 38 additions & 0 deletions TUnit.Playwright/BrowserFixture.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Microsoft.Playwright;
using TUnit.Core;
using TUnit.Core.Interfaces;

namespace TUnit.Playwright;

/// <summary>
/// The injected <see cref="PlaywrightFixture"/> is hardcoded to <see cref="SharedType.PerTestSession"/>.
/// Authoring a new fixture class is the way to change that scope — attribute arguments on
/// inherited <c>init</c> properties cannot be overridden.
/// </summary>
public class BrowserFixture : IAsyncInitializer, IAsyncDisposable
{
[ClassDataSource<PlaywrightFixture>(Shared = SharedType.PerTestSession)]
public required PlaywrightFixture PlaywrightFixture { get; init; }

public IBrowser Browser { get; private set; } = null!;

public virtual string BrowserName => Microsoft.Playwright.BrowserType.Chromium;

protected virtual BrowserTypeLaunchOptions GetLaunchOptions() => new();

public virtual async Task InitializeAsync()
{
var browserType = PlaywrightFixture.Playwright[BrowserName]
?? throw new InvalidOperationException($"Unknown BrowserName '{BrowserName}'.");

Browser = await PlaywrightServiceConnector.LaunchAsync(browserType, GetLaunchOptions()).ConfigureAwait(false);
}

public virtual async ValueTask DisposeAsync()
{
if (Browser is not null)
{
await Browser.CloseAsync().ConfigureAwait(false);
}
}
}
34 changes: 2 additions & 32 deletions TUnit.Playwright/BrowserService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.Globalization;
using Microsoft.Playwright;

namespace TUnit.Playwright;
Expand All @@ -17,37 +16,8 @@ public static Task<BrowserService> Register(
IBrowserType browserType,
BrowserTypeLaunchOptions options)
{
return test.RegisterService("Browser", async () => new BrowserService(await CreateBrowser(browserType, options).ConfigureAwait(false)));
}

private static async Task<IBrowser> CreateBrowser(
IBrowserType browserType,
BrowserTypeLaunchOptions options)
{
var accessToken = Environment.GetEnvironmentVariable("PLAYWRIGHT_SERVICE_ACCESS_TOKEN");
var serviceUrl = Environment.GetEnvironmentVariable("PLAYWRIGHT_SERVICE_URL");

if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(serviceUrl))
{
return await browserType.LaunchAsync(options).ConfigureAwait(false);
}

var exposeNetwork = Environment.GetEnvironmentVariable("PLAYWRIGHT_SERVICE_EXPOSE_NETWORK") ?? "<loopback>";
var os = Uri.EscapeDataString(Environment.GetEnvironmentVariable("PLAYWRIGHT_SERVICE_OS") ?? "linux");
var runId = Uri.EscapeDataString(Environment.GetEnvironmentVariable("PLAYWRIGHT_SERVICE_RUN_ID") ?? DateTime.Now.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture));
var apiVersion = "2023-10-01-preview";
var wsEndpoint = $"{serviceUrl}?os={os}&runId={runId}&api-version={apiVersion}";
var connectOptions = new BrowserTypeConnectOptions
{
Timeout = 3 * 60 * 1000,
ExposeNetwork = exposeNetwork,
Headers = new Dictionary<string, string>
{
["Authorization"] = $"Bearer {accessToken}"
}
};

return await browserType.ConnectAsync(wsEndpoint, connectOptions).ConfigureAwait(false);
return test.RegisterService("Browser", async () =>
new BrowserService(await PlaywrightServiceConnector.LaunchAsync(browserType, options).ConfigureAwait(false)));
}

public Task ResetAsync() => Task.CompletedTask;
Expand Down
32 changes: 1 addition & 31 deletions TUnit.Playwright/BrowserTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public BrowserTest(BrowserTypeLaunchOptions options)

public async Task<IBrowserContext> NewContext(BrowserNewContextOptions options)
{
options = MergeTelemetryHeaders(options);
options = PlaywrightTelemetryHeaders.Merge(options, PropagateTraceContext);
var context = await Browser.NewContextAsync(options).ConfigureAwait(false);

lock (_contextsLock)
Expand Down Expand Up @@ -91,34 +91,4 @@ public async Task BrowserTearDown(TestContext testContext)
}
}

private BrowserNewContextOptions MergeTelemetryHeaders(BrowserNewContextOptions options)
{
#if NET
if (!PropagateTraceContext || System.Diagnostics.Activity.Current is null)
{
return options;
}

// Seed user headers first so they win when the propagator tries to add the same key.
var merged = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (options.ExtraHTTPHeaders is not null)
{
foreach (var kvp in options.ExtraHTTPHeaders)
{
merged[kvp.Key] = kvp.Value;
}
}

var before = merged.Count;
Telemetry.PlaywrightActivityPropagator.InjectInto(merged);
if (merged.Count == before)
{
return options;
}

return new BrowserNewContextOptions(options) { ExtraHTTPHeaders = merged };
#else
return options;
#endif
}
}
41 changes: 41 additions & 0 deletions TUnit.Playwright/ContextFixture.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using Microsoft.Playwright;
using TUnit.Core;
using TUnit.Core.Interfaces;

namespace TUnit.Playwright;

/// <summary>
/// The injected <see cref="BrowserFixture"/> is hardcoded to <see cref="SharedType.PerTestSession"/>.
/// Authoring a new fixture class is the way to change that scope — attribute arguments on
/// inherited <c>init</c> properties cannot be overridden.
/// </summary>
public class ContextFixture : IAsyncInitializer, IAsyncDisposable
{
[ClassDataSource<BrowserFixture>(Shared = SharedType.PerTestSession)]
public required BrowserFixture BrowserFixture { get; init; }

public IBrowserContext Context { get; private set; } = null!;

protected virtual BrowserNewContextOptions GetContextOptions() =>
new() { Locale = "en-US", ColorScheme = ColorScheme.Light };

/// <summary>
/// When <c>true</c>, seeds the context with W3C trace propagation headers from
/// the current test's <see cref="System.Diagnostics.Activity"/>.
/// </summary>
protected virtual bool PropagateTraceContext => true;

public virtual async Task InitializeAsync()
{
var options = PlaywrightTelemetryHeaders.Merge(GetContextOptions(), PropagateTraceContext);
Context = await BrowserFixture.Browser.NewContextAsync(options).ConfigureAwait(false);
}

public virtual async ValueTask DisposeAsync()
{
if (Context is not null)
{
await Context.CloseAsync().ConfigureAwait(false);
}
}
}
32 changes: 32 additions & 0 deletions TUnit.Playwright/PageFixture.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Microsoft.Playwright;
using TUnit.Core;
using TUnit.Core.Interfaces;

namespace TUnit.Playwright;

/// <summary>
/// The injected <see cref="ContextFixture"/> defaults to <see cref="SharedType.None"/> (a fresh
/// context per <see cref="PageFixture"/>). Two <c>[ClassDataSource&lt;PageFixture&gt;]</c>
/// properties on the same test class therefore yield two isolated browser contexts while
/// sharing the underlying <see cref="BrowserFixture"/> at <see cref="SharedType.PerTestSession"/>.
/// </summary>
public class PageFixture : IAsyncInitializer, IAsyncDisposable
{
[ClassDataSource<ContextFixture>]
public required ContextFixture ContextFixture { get; init; }

public IPage Page { get; private set; } = null!;

public virtual async Task InitializeAsync()
{
Page = await ContextFixture.Context.NewPageAsync().ConfigureAwait(false);
}

public virtual async ValueTask DisposeAsync()
{
if (Page is not null)
{
await Page.CloseAsync().ConfigureAwait(false);
}
}
}
27 changes: 27 additions & 0 deletions TUnit.Playwright/PlaywrightFixture.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using Microsoft.Playwright;
using TUnit.Core.Interfaces;

namespace TUnit.Playwright;

public class PlaywrightFixture : IAsyncInitializer, IAsyncDisposable
{
public IPlaywright Playwright { get; private set; } = null!;

protected virtual string TestIdAttribute => "data-testid";

public virtual async Task InitializeAsync()
{
Playwright = await Microsoft.Playwright.Playwright.CreateAsync().ConfigureAwait(false);
Playwright.Selectors.SetTestIdAttribute(TestIdAttribute);
}

public virtual ValueTask DisposeAsync()
{
Playwright?.Dispose();
return default;
}

public ILocatorAssertions Expect(ILocator locator) => Microsoft.Playwright.Assertions.Expect(locator);
public IPageAssertions Expect(IPage page) => Microsoft.Playwright.Assertions.Expect(page);
public IAPIResponseAssertions Expect(IAPIResponse response) => Microsoft.Playwright.Assertions.Expect(response);
}
36 changes: 36 additions & 0 deletions TUnit.Playwright/PlaywrightServiceConnector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System.Globalization;
using Microsoft.Playwright;

namespace TUnit.Playwright;

internal static class PlaywrightServiceConnector
{
private const string ApiVersion = "2023-10-01-preview";
private const int ConnectTimeoutMs = 3 * 60 * 1000;

public static async Task<IBrowser> LaunchAsync(IBrowserType browserType, BrowserTypeLaunchOptions options)
{
var accessToken = Environment.GetEnvironmentVariable("PLAYWRIGHT_SERVICE_ACCESS_TOKEN");
var serviceUrl = Environment.GetEnvironmentVariable("PLAYWRIGHT_SERVICE_URL");

if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(serviceUrl))
{
return await browserType.LaunchAsync(options).ConfigureAwait(false);
}

var exposeNetwork = Environment.GetEnvironmentVariable("PLAYWRIGHT_SERVICE_EXPOSE_NETWORK") ?? "<loopback>";
var os = Uri.EscapeDataString(Environment.GetEnvironmentVariable("PLAYWRIGHT_SERVICE_OS") ?? "linux");
var runId = Uri.EscapeDataString(Environment.GetEnvironmentVariable("PLAYWRIGHT_SERVICE_RUN_ID")
?? DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture));
var wsEndpoint = $"{serviceUrl}?os={os}&runId={runId}&api-version={ApiVersion}";

var connectOptions = new BrowserTypeConnectOptions
{
Timeout = ConnectTimeoutMs,
ExposeNetwork = exposeNetwork,
Headers = new Dictionary<string, string> { ["Authorization"] = $"Bearer {accessToken}" }
};

return await browserType.ConnectAsync(wsEndpoint, connectOptions).ConfigureAwait(false);
}
}
37 changes: 37 additions & 0 deletions TUnit.Playwright/PlaywrightTelemetryHeaders.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using Microsoft.Playwright;

namespace TUnit.Playwright;

internal static class PlaywrightTelemetryHeaders
{
public static BrowserNewContextOptions Merge(BrowserNewContextOptions options, bool propagate)
{
#if NET
if (!propagate || System.Diagnostics.Activity.Current is null)
{
return options;
}

// Seed user headers first so they win when the propagator tries to add the same key.
var merged = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (options.ExtraHTTPHeaders is not null)
{
foreach (var kvp in options.ExtraHTTPHeaders)
{
merged[kvp.Key] = kvp.Value;
}
}

var before = merged.Count;
Telemetry.PlaywrightActivityPropagator.InjectInto(merged);
if (merged.Count == before)
{
return options;
}

return new BrowserNewContextOptions(options) { ExtraHTTPHeaders = merged };
#else
return options;
#endif
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using TUnit.Assertions;
using TUnit.Assertions.Extensions;
using TUnit.Core;
using TUnit.Playwright;

namespace TUnit.Playwright;

public class TwoContextFixtureTests
{
[ClassDataSource<PageFixture>]
public required PageFixture Alice { get; init; }

[ClassDataSource<PageFixture>]
public required PageFixture Bob { get; init; }

[Test]
public async Task Two_Pages_Have_Isolated_Storage_But_Share_Browser()
{
await Alice.Page.GotoAsync("about:blank");
await Bob.Page.GotoAsync("about:blank");

await Alice.Page.EvaluateAsync("() => localStorage.setItem('user', 'alice')");

var aliceUser = await Alice.Page.EvaluateAsync<string?>("() => localStorage.getItem('user')");
var bobUser = await Bob.Page.EvaluateAsync<string?>("() => localStorage.getItem('user')");

await Assert.That(aliceUser).IsEqualTo("alice");
await Assert.That(bobUser).IsNull();

await Assert.That(Alice.Page).IsNotSameReferenceAs(Bob.Page);
await Assert.That(Alice.ContextFixture.Context).IsNotSameReferenceAs(Bob.ContextFixture.Context);
await Assert.That(Alice.ContextFixture.BrowserFixture.Browser)
.IsSameReferenceAs(Bob.ContextFixture.BrowserFixture.Browser);
}
}
8 changes: 8 additions & 0 deletions TUnit.Templates/content/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,12 @@
<Using Include="TUnit.Assertions" />
<Using Include="TUnit.Assertions.Extensions" />
</ItemGroup>

<!-- During solution builds, swap TUnit.Playwright NuGet package for local project reference
so newly-added API in the same PR is visible to the template smoke test. End-user
template instantiations match a different MSBuild path and keep the PackageReference. -->
<ItemGroup Condition="$(MSBuildProjectDirectory.Contains('TUnit.Templates')) And $(MSBuildProjectDirectory.Contains('TUnit.Playwright'))">
<PackageReference Remove="TUnit.Playwright" />
<ProjectReference Include="$(MSBuildThisFileDirectory)..\..\TUnit.Playwright\TUnit.Playwright.csproj" />
</ItemGroup>
</Project>
35 changes: 35 additions & 0 deletions TUnit.Templates/content/TUnit.Playwright/TwoContextFixtureTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using TUnit.Assertions;
using TUnit.Assertions.Extensions;
using TUnit.Core;
using TUnit.Playwright;

namespace TestProject;

public class TwoContextFixtureTests
{
[ClassDataSource<PageFixture>]
public required PageFixture Alice { get; init; }

[ClassDataSource<PageFixture>]
public required PageFixture Bob { get; init; }

[Test]
public async Task Two_Pages_Have_Isolated_Storage_But_Share_Browser()
{
await Alice.Page.GotoAsync("about:blank");
await Bob.Page.GotoAsync("about:blank");

await Alice.Page.EvaluateAsync("() => localStorage.setItem('user', 'alice')");

var aliceUser = await Alice.Page.EvaluateAsync<string?>("() => localStorage.getItem('user')");
var bobUser = await Bob.Page.EvaluateAsync<string?>("() => localStorage.getItem('user')");

await Assert.That(aliceUser).IsEqualTo("alice");
await Assert.That(bobUser).IsNull();

await Assert.That(Alice.Page).IsNotSameReferenceAs(Bob.Page);
await Assert.That(Alice.ContextFixture.Context).IsNotSameReferenceAs(Bob.ContextFixture.Context);
await Assert.That(Alice.ContextFixture.BrowserFixture.Browser)
.IsSameReferenceAs(Bob.ContextFixture.BrowserFixture.Browser);
}
}
Loading