Skip to content
Merged
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
31 changes: 31 additions & 0 deletions docs/docs/examples/playwright.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,4 +193,35 @@ public class LoginPageTests : PageTest
- `width` - viewport width used for the recording, in pixels. Defaults to `1280`.
- `height` - viewport height used for the recording, in pixels. Defaults to `1400`.

## Trace Context Propagation

TUnit can add W3C `traceparent` and `baggage` headers from the current test to the requests your page sends to the application under test. This lets the application's telemetry be correlated with the test that triggered it.

Headers are sent only to the origins under test. By default this is the origin of `BaseURL` in the context options. If no base URL is set, no headers are sent. List the origins explicitly with `TraceContextOrigins`:

```csharp
public class DashboardTests : PageTest
{
public override IReadOnlyList<string>? TraceContextOrigins => ["https://localhost:7217"];

[Test]
public async Task Dashboard_Loads()
{
await Page.GotoAsync("https://localhost:7217/dashboard");
await Assert.That(await Page.TitleAsync()).IsEqualTo("Dashboard");
}
}
```

`ContextFixture` has the same property as a `protected virtual` member. Override `PropagateTraceContext` to return `false` to disable propagation.

Requests to other origins, such as web fonts or third-party APIs, never receive the headers. `traceparent` is not a CORS-safelisted header, so sending it to a third-party origin causes a CORS preflight that most servers reject, and it also leaks the test's trace id.

The headers are added by a context route, which has these limits:

- Playwright disables its HTTP cache for contexts with routes. Set `PropagateTraceContext` to `false` to keep the cache.
- Routes you add later run before TUnit's route. Call `route.FallbackAsync()` instead of `route.ContinueAsync()` in them to keep the headers.
- Context routes do not see WebSocket traffic or requests handled by a service worker, so those requests get no headers. To route requests that a service worker would handle, set `ServiceWorkers = ServiceWorkerPolicy.Block` in the context options.
- When a request to an origin under test is redirected, Playwright keeps the headers on the redirected request, even if it goes to another origin.

For full Playwright API details, see the [Playwright for .NET documentation](https://playwright.dev/dotnet/).
45 changes: 38 additions & 7 deletions src/TUnit.Playwright/BrowserTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,30 +17,48 @@ public BrowserTest(BrowserTypeLaunchOptions options)
public IBrowser Browser { get; internal set; } = null!;

/// <summary>
/// Seeds each <see cref="IBrowserContext"/> created via <see cref="NewContext"/>
/// with W3C <c>traceparent</c>/<c>baggage</c> headers from the current test's
/// <see cref="System.Diagnostics.Activity"/>. Override to <c>false</c> to avoid
/// leaking trace ids to third-party domains the page contacts.
/// Adds W3C <c>traceparent</c>/<c>baggage</c> headers from the current test's
/// <see cref="System.Diagnostics.Activity"/> to requests that contexts created via
/// <see cref="NewContext"/> send to <see cref="TraceContextOrigins"/>. Override to
/// <c>false</c> to disable propagation entirely.
/// </summary>
/// <remarks>
/// Has no effect on <c>netstandard2.0</c> targets — the engine's Activity plumbing
/// is .NET-only.
/// </remarks>
public virtual bool PropagateTraceContext => true;

/// <summary>
/// Origins (for example <c>https://localhost:5001</c>) whose requests receive trace
/// context headers. Defaults to <c>null</c>, which uses the origin of
/// <see cref="BrowserNewContextOptions.BaseURL"/>; when no base URL is set, no headers
/// are sent. Requests to other origins never receive the headers, so third-party
/// CORS requests are not preflighted and trace ids do not leak.
/// </summary>
/// <remarks>
/// Headers are added by a context route, which disables Playwright's HTTP cache for
/// that context; set <see cref="PropagateTraceContext"/> to <c>false</c> to keep the cache.
/// Routes you register later run first; call <c>route.FallbackAsync()</c> rather than
/// <c>route.ContinueAsync()</c> in them to keep the headers. Context routes do not see
/// WebSocket traffic or requests handled by a service worker, so those requests get no
/// headers. Playwright keeps the headers when a routed request is redirected, including
/// redirects to another origin.
/// </remarks>
public virtual IReadOnlyList<string>? TraceContextOrigins => null;

private readonly List<Task<(IBrowserContext Context, PlaywrightVideoRecorder? Recording)>> _contexts = [];
private readonly Lock _contextsLock = new();
private readonly BrowserTypeLaunchOptions _options;

public async Task<IBrowserContext> NewContext(BrowserNewContextOptions options)
{
var owner = TestContext.Current;
options = PlaywrightTelemetryHeaders.Merge(options, PropagateTraceContext);
var traceContext = PlaywrightTraceContextRoute.Create(PropagateTraceContext, TraceContextOrigins, options);
Task<(IBrowserContext Context, PlaywrightVideoRecorder? Recording)> creation;
lock (_contextsLock)
{
var browser = Browser ?? throw new InvalidOperationException("Cannot create a browser context before setup or after teardown has started.");
creation = CreateContextAsync(browser, options, owner);
creation = CreateContextAsync(browser, options, traceContext, owner);
// Track pending creation too, so teardown waits for every context it owns.
_contexts.Add(creation);
}
Expand All @@ -49,9 +67,22 @@ public async Task<IBrowserContext> NewContext(BrowserNewContextOptions options)
}

private static async Task<(IBrowserContext Context, PlaywrightVideoRecorder? Recording)> CreateContextAsync(
IBrowser browser, BrowserNewContextOptions options, TestContext? owner)
IBrowser browser, BrowserNewContextOptions options, PlaywrightTraceContextRoute? traceContext, TestContext? owner)
{
var context = await browser.NewContextAsync(options).ConfigureAwait(false);
if (traceContext is not null)
{
try
{
await traceContext.RegisterAsync(context).ConfigureAwait(false);
}
catch
{
await context.CloseAsync().ConfigureAwait(false);
throw;
}
}

var recording = !string.IsNullOrEmpty(options.RecordVideoDir) && owner is not null
? new PlaywrightVideoRecorder(context, owner)
: null;
Expand Down
42 changes: 39 additions & 3 deletions src/TUnit.Playwright/ContextFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,54 @@ protected virtual BrowserNewContextOptions GetContextOptions() =>
PlaywrightContextOptions.Defaults();

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

/// <summary>
/// Origins (for example <c>https://localhost:5001</c>) whose requests receive trace
/// context headers. Defaults to <c>null</c>, which uses the origin of
/// <see cref="BrowserNewContextOptions.BaseURL"/>; when no base URL is set, no headers
/// are sent. Requests to other origins never receive the headers, so third-party
/// CORS requests are not preflighted and trace ids do not leak.
/// </summary>
/// <remarks>
/// Headers are added by a context route, which disables Playwright's HTTP cache for
/// that context; set <see cref="PropagateTraceContext"/> to <c>false</c> to keep the cache.
/// Routes you register later run first; call <c>route.FallbackAsync()</c> rather than
/// <c>route.ContinueAsync()</c> in them to keep the headers. Context routes do not see
/// WebSocket traffic or requests handled by a service worker, so those requests get no
/// headers. Playwright keeps the headers when a routed request is redirected, including
/// redirects to another origin.
/// </remarks>
protected virtual IReadOnlyList<string>? TraceContextOrigins => null;

public virtual async Task InitializeAsync()
{
var owner = TestContext.Current;
_recording = null;
var options = PlaywrightContextOptions.ApplyRecording(GetContextOptions(), owner);
options = PlaywrightTelemetryHeaders.Merge(options, PropagateTraceContext);
var traceContext = PlaywrightTraceContextRoute.Create(PropagateTraceContext, TraceContextOrigins, options);
_context = await BrowserFixture.Browser.NewContextAsync(options).ConfigureAwait(false);
if (traceContext is not null)
{
try
{
await traceContext.RegisterAsync(_context).ConfigureAwait(false);
}
catch
{
if (Interlocked.Exchange(ref _context, null) is { } context)
{
await context.CloseAsync().ConfigureAwait(false);
}

throw;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Option-only recording can span shared fixture lifetimes and has no single test owner.
_recording = PlaywrightContextOptions.Recording(owner) is not null && owner is not null
? new PlaywrightVideoRecorder(_context, owner)
Expand Down
37 changes: 0 additions & 37 deletions src/TUnit.Playwright/PlaywrightTelemetryHeaders.cs

This file was deleted.

110 changes: 110 additions & 0 deletions src/TUnit.Playwright/PlaywrightTraceContextRoute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Playwright;

namespace TUnit.Playwright;

/// <summary>
/// Adds W3C trace context headers to requests sent to the origins under test only.
/// Context-wide <c>ExtraHTTPHeaders</c> would also reach third-party origins, where the
/// non-safelisted <c>traceparent</c> header triggers CORS preflights that usually fail.
/// </summary>
internal sealed class PlaywrightTraceContextRoute
{
private readonly Regex _origins;
private readonly KeyValuePair<string, string>[] _headers;

private PlaywrightTraceContextRoute(Regex origins, KeyValuePair<string, string>[] headers)
{
_origins = origins;
_headers = headers;
}

/// <summary>
/// Captures the trace headers of the current <see cref="System.Diagnostics.Activity"/>.
/// Returns <c>null</c> when propagation is disabled, there is no activity, or no origin
/// is known. Without explicit <paramref name="origins"/>, the origin of
/// <see cref="BrowserNewContextOptions.BaseURL"/> is used.
/// </summary>
public static PlaywrightTraceContextRoute? Create(bool propagate, IReadOnlyList<string>? origins, BrowserNewContextOptions options)
{
#if NET
if (!propagate || System.Diagnostics.Activity.Current is null)
{
return null;
}

var pattern = BuildOriginPattern(origins, options.BaseURL);
if (pattern is null)
{
return null;
}

var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
Telemetry.PlaywrightActivityPropagator.InjectInto(headers);
return headers.Count == 0
? null
: new PlaywrightTraceContextRoute(pattern, headers.ToArray());
#else
return null;
#endif
}

public Task RegisterAsync(IBrowserContext context) => context.RouteAsync(_origins, HandleAsync);
Comment thread
thomhurst marked this conversation as resolved.

private Task HandleAsync(IRoute route)
{
var headers = new Dictionary<string, string>(route.Request.Headers, StringComparer.OrdinalIgnoreCase);
foreach (var header in _headers)
{
// Headers set by the page or by ExtraHTTPHeaders win.
if (!headers.ContainsKey(header.Key))
{
headers[header.Key] = header.Value;
}
}

// Fallback rather than Continue, so any handler registered before this one still runs.
return route.FallbackAsync(new RouteFallbackOptions { Headers = headers });
Comment thread
thomhurst marked this conversation as resolved.
}

internal static Regex? BuildOriginPattern(IReadOnlyList<string>? origins, string? baseUrl)
{
if (origins is null)
{
return string.IsNullOrEmpty(baseUrl) ? null : BuildOriginPattern([baseUrl!], null);
}

if (origins.Count == 0)
{
return null;
}

var pattern = new StringBuilder("^(?:");
for (var i = 0; i < origins.Count; i++)
{
if (i > 0)
{
pattern.Append('|');
}

pattern.Append(Regex.Escape(NormalizeOrigin(origins[i])));
}

// Match the origin exactly: "https://app.test" must not match "https://app.test.evil".
pattern.Append(")(?:[/?#]|$)");
return new Regex(pattern.ToString(), RegexOptions.IgnoreCase);
}

private static string NormalizeOrigin(string origin)
{
if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
throw new ArgumentException($"Trace context origin '{origin}' must be an absolute http or https URL.", nameof(origin));
}

// Scheme, host and non-default port, as the browser writes request URLs.
return uri.GetLeftPart(UriPartial.Authority);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ namespace TUnit.Playwright.Telemetry;

/// <summary>
/// Injects W3C <c>traceparent</c>/<c>baggage</c> headers from <see cref="Activity.Current"/>
/// into a Playwright <see cref="Microsoft.Playwright.BrowserNewContextOptions.ExtraHTTPHeaders"/>
/// dictionary. Pre-existing keys in the target are preserved.
/// into a header dictionary for Playwright requests. Pre-existing keys in the target are preserved.
/// </summary>
internal static class PlaywrightActivityPropagator
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ namespace
public BrowserTest(.BrowserTypeLaunchOptions options) { }
public .IBrowser Browser { get; }
public virtual bool PropagateTraceContext { get; }
public virtual .<string>? TraceContextOrigins { get; }
[.Before(., "", 0)]
public . BrowserSetup() { }
[.After(., "", 0)]
Expand All @@ -32,6 +33,7 @@ namespace
public required .BrowserFixture BrowserFixture { get; init; }
public .IBrowserContext Context { get; }
protected virtual bool PropagateTraceContext { get; }
protected virtual .<string>? TraceContextOrigins { get; }
public virtual . DisposeAsync() { }
protected virtual .BrowserNewContextOptions GetContextOptions() { }
public virtual . InitializeAsync() { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ namespace
public BrowserTest(.BrowserTypeLaunchOptions options) { }
public .IBrowser Browser { get; }
public virtual bool PropagateTraceContext { get; }
public virtual .<string>? TraceContextOrigins { get; }
[.Before(., "", 0)]
public . BrowserSetup() { }
[.After(., "", 0)]
Expand All @@ -32,6 +33,7 @@ namespace
public required .BrowserFixture BrowserFixture { get; init; }
public .IBrowserContext Context { get; }
protected virtual bool PropagateTraceContext { get; }
protected virtual .<string>? TraceContextOrigins { get; }
public virtual . DisposeAsync() { }
protected virtual .BrowserNewContextOptions GetContextOptions() { }
public virtual . InitializeAsync() { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ namespace
public BrowserTest(.BrowserTypeLaunchOptions options) { }
public .IBrowser Browser { get; }
public virtual bool PropagateTraceContext { get; }
public virtual .<string>? TraceContextOrigins { get; }
[.Before(., "", 0)]
public . BrowserSetup() { }
[.After(., "", 0)]
Expand All @@ -32,6 +33,7 @@ namespace
public required .BrowserFixture BrowserFixture { get; init; }
public .IBrowserContext Context { get; }
protected virtual bool PropagateTraceContext { get; }
protected virtual .<string>? TraceContextOrigins { get; }
public virtual . DisposeAsync() { }
protected virtual .BrowserNewContextOptions GetContextOptions() { }
public virtual . InitializeAsync() { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ namespace
public BrowserTest(.BrowserTypeLaunchOptions options) { }
public .IBrowser Browser { get; }
public virtual bool PropagateTraceContext { get; }
public virtual .<string>? TraceContextOrigins { get; }
[.Before(., "", 0)]
public . BrowserSetup() { }
[.After(., "", 0)]
Expand All @@ -32,6 +33,7 @@ namespace
public required .BrowserFixture BrowserFixture { get; init; }
public .IBrowserContext Context { get; }
protected virtual bool PropagateTraceContext { get; }
protected virtual .<string>? TraceContextOrigins { get; }
public virtual . DisposeAsync() { }
protected virtual .BrowserNewContextOptions GetContextOptions() { }
public virtual . InitializeAsync() { }
Expand Down
Loading
Loading