diff --git a/docs/docs/examples/playwright.md b/docs/docs/examples/playwright.md index aef6a794532..08b91e1bdc0 100644 --- a/docs/docs/examples/playwright.md +++ b/docs/docs/examples/playwright.md @@ -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? 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/). diff --git a/src/TUnit.Playwright/BrowserTest.cs b/src/TUnit.Playwright/BrowserTest.cs index ec00a8c2cfe..c12233bc0f4 100644 --- a/src/TUnit.Playwright/BrowserTest.cs +++ b/src/TUnit.Playwright/BrowserTest.cs @@ -17,10 +17,10 @@ public BrowserTest(BrowserTypeLaunchOptions options) public IBrowser Browser { get; internal set; } = null!; /// - /// Seeds each created via - /// with W3C traceparent/baggage headers from the current test's - /// . Override to false to avoid - /// leaking trace ids to third-party domains the page contacts. + /// Adds W3C traceparent/baggage headers from the current test's + /// to requests that contexts created via + /// send to . Override to + /// false to disable propagation entirely. /// /// /// Has no effect on netstandard2.0 targets — the engine's Activity plumbing @@ -28,6 +28,24 @@ public BrowserTest(BrowserTypeLaunchOptions options) /// public virtual bool PropagateTraceContext => true; + /// + /// Origins (for example https://localhost:5001) whose requests receive trace + /// context headers. Defaults to null, which uses the origin of + /// ; 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. + /// + /// + /// Headers are added by a context route, which disables Playwright's HTTP cache for + /// that context; set to false to keep the cache. + /// Routes you register later run first; call route.FallbackAsync() rather than + /// 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. Playwright keeps the headers when a routed request is redirected, including + /// redirects to another origin. + /// + public virtual IReadOnlyList? TraceContextOrigins => null; + private readonly List> _contexts = []; private readonly Lock _contextsLock = new(); private readonly BrowserTypeLaunchOptions _options; @@ -35,12 +53,12 @@ public BrowserTest(BrowserTypeLaunchOptions options) public async Task 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); } @@ -49,9 +67,22 @@ public async Task 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; diff --git a/src/TUnit.Playwright/ContextFixture.cs b/src/TUnit.Playwright/ContextFixture.cs index 05cd4e4479a..93e3c39330b 100644 --- a/src/TUnit.Playwright/ContextFixture.cs +++ b/src/TUnit.Playwright/ContextFixture.cs @@ -31,18 +31,54 @@ protected virtual BrowserNewContextOptions GetContextOptions() => PlaywrightContextOptions.Defaults(); /// - /// When true, seeds the context with W3C trace propagation headers from - /// the current test's . + /// When true, adds W3C trace propagation headers from the current test's + /// to requests sent to + /// . /// protected virtual bool PropagateTraceContext => true; + /// + /// Origins (for example https://localhost:5001) whose requests receive trace + /// context headers. Defaults to null, which uses the origin of + /// ; 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. + /// + /// + /// Headers are added by a context route, which disables Playwright's HTTP cache for + /// that context; set to false to keep the cache. + /// Routes you register later run first; call route.FallbackAsync() rather than + /// 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. Playwright keeps the headers when a routed request is redirected, including + /// redirects to another origin. + /// + protected virtual IReadOnlyList? 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; + } + } + // 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) diff --git a/src/TUnit.Playwright/PlaywrightTelemetryHeaders.cs b/src/TUnit.Playwright/PlaywrightTelemetryHeaders.cs deleted file mode 100644 index 67d3b2c62c3..00000000000 --- a/src/TUnit.Playwright/PlaywrightTelemetryHeaders.cs +++ /dev/null @@ -1,37 +0,0 @@ -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(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 - } -} diff --git a/src/TUnit.Playwright/PlaywrightTraceContextRoute.cs b/src/TUnit.Playwright/PlaywrightTraceContextRoute.cs new file mode 100644 index 00000000000..1c49a581dfd --- /dev/null +++ b/src/TUnit.Playwright/PlaywrightTraceContextRoute.cs @@ -0,0 +1,110 @@ +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Playwright; + +namespace TUnit.Playwright; + +/// +/// Adds W3C trace context headers to requests sent to the origins under test only. +/// Context-wide ExtraHTTPHeaders would also reach third-party origins, where the +/// non-safelisted traceparent header triggers CORS preflights that usually fail. +/// +internal sealed class PlaywrightTraceContextRoute +{ + private readonly Regex _origins; + private readonly KeyValuePair[] _headers; + + private PlaywrightTraceContextRoute(Regex origins, KeyValuePair[] headers) + { + _origins = origins; + _headers = headers; + } + + /// + /// Captures the trace headers of the current . + /// Returns null when propagation is disabled, there is no activity, or no origin + /// is known. Without explicit , the origin of + /// is used. + /// + public static PlaywrightTraceContextRoute? Create(bool propagate, IReadOnlyList? 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(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); + + private Task HandleAsync(IRoute route) + { + var headers = new Dictionary(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 }); + } + + internal static Regex? BuildOriginPattern(IReadOnlyList? 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); + } +} diff --git a/src/TUnit.Playwright/Telemetry/PlaywrightActivityPropagator.cs b/src/TUnit.Playwright/Telemetry/PlaywrightActivityPropagator.cs index 9a70cf1c3c0..0401cb09b84 100644 --- a/src/TUnit.Playwright/Telemetry/PlaywrightActivityPropagator.cs +++ b/src/TUnit.Playwright/Telemetry/PlaywrightActivityPropagator.cs @@ -7,8 +7,7 @@ namespace TUnit.Playwright.Telemetry; /// /// Injects W3C traceparent/baggage headers from -/// into a Playwright -/// dictionary. Pre-existing keys in the target are preserved. +/// into a header dictionary for Playwright requests. Pre-existing keys in the target are preserved. /// internal static class PlaywrightActivityPropagator { diff --git a/tests/TUnit.PublicAPI/Tests.Playwright_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/tests/TUnit.PublicAPI/Tests.Playwright_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 53b2a50ab57..d778e1fbd92 100644 --- a/tests/TUnit.PublicAPI/Tests.Playwright_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/tests/TUnit.PublicAPI/Tests.Playwright_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -19,6 +19,7 @@ namespace public BrowserTest(.BrowserTypeLaunchOptions options) { } public .IBrowser Browser { get; } public virtual bool PropagateTraceContext { get; } + public virtual .? TraceContextOrigins { get; } [.Before(., "", 0)] public . BrowserSetup() { } [.After(., "", 0)] @@ -32,6 +33,7 @@ namespace public required .BrowserFixture BrowserFixture { get; init; } public .IBrowserContext Context { get; } protected virtual bool PropagateTraceContext { get; } + protected virtual .? TraceContextOrigins { get; } public virtual . DisposeAsync() { } protected virtual .BrowserNewContextOptions GetContextOptions() { } public virtual . InitializeAsync() { } diff --git a/tests/TUnit.PublicAPI/Tests.Playwright_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/tests/TUnit.PublicAPI/Tests.Playwright_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 33fe5092b73..08643d74c9a 100644 --- a/tests/TUnit.PublicAPI/Tests.Playwright_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/tests/TUnit.PublicAPI/Tests.Playwright_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -19,6 +19,7 @@ namespace public BrowserTest(.BrowserTypeLaunchOptions options) { } public .IBrowser Browser { get; } public virtual bool PropagateTraceContext { get; } + public virtual .? TraceContextOrigins { get; } [.Before(., "", 0)] public . BrowserSetup() { } [.After(., "", 0)] @@ -32,6 +33,7 @@ namespace public required .BrowserFixture BrowserFixture { get; init; } public .IBrowserContext Context { get; } protected virtual bool PropagateTraceContext { get; } + protected virtual .? TraceContextOrigins { get; } public virtual . DisposeAsync() { } protected virtual .BrowserNewContextOptions GetContextOptions() { } public virtual . InitializeAsync() { } diff --git a/tests/TUnit.PublicAPI/Tests.Playwright_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/tests/TUnit.PublicAPI/Tests.Playwright_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 4cd7311fda3..6ade7d85baf 100644 --- a/tests/TUnit.PublicAPI/Tests.Playwright_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/tests/TUnit.PublicAPI/Tests.Playwright_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -19,6 +19,7 @@ namespace public BrowserTest(.BrowserTypeLaunchOptions options) { } public .IBrowser Browser { get; } public virtual bool PropagateTraceContext { get; } + public virtual .? TraceContextOrigins { get; } [.Before(., "", 0)] public . BrowserSetup() { } [.After(., "", 0)] @@ -32,6 +33,7 @@ namespace public required .BrowserFixture BrowserFixture { get; init; } public .IBrowserContext Context { get; } protected virtual bool PropagateTraceContext { get; } + protected virtual .? TraceContextOrigins { get; } public virtual . DisposeAsync() { } protected virtual .BrowserNewContextOptions GetContextOptions() { } public virtual . InitializeAsync() { } diff --git a/tests/TUnit.PublicAPI/Tests.Playwright_Library_Has_No_API_Changes.Net4_7.verified.txt b/tests/TUnit.PublicAPI/Tests.Playwright_Library_Has_No_API_Changes.Net4_7.verified.txt index b0631f97ab0..c1fc6e41df0 100644 --- a/tests/TUnit.PublicAPI/Tests.Playwright_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/tests/TUnit.PublicAPI/Tests.Playwright_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -19,6 +19,7 @@ namespace public BrowserTest(.BrowserTypeLaunchOptions options) { } public .IBrowser Browser { get; } public virtual bool PropagateTraceContext { get; } + public virtual .? TraceContextOrigins { get; } [.Before(., "", 0)] public . BrowserSetup() { } [.After(., "", 0)] @@ -32,6 +33,7 @@ namespace public required .BrowserFixture BrowserFixture { get; init; } public .IBrowserContext Context { get; } protected virtual bool PropagateTraceContext { get; } + protected virtual .? TraceContextOrigins { get; } public virtual . DisposeAsync() { } protected virtual .BrowserNewContextOptions GetContextOptions() { } public virtual . InitializeAsync() { } diff --git a/tests/TUnit.UnitTests/PlaywrightTraceContextTests.cs b/tests/TUnit.UnitTests/PlaywrightTraceContextTests.cs new file mode 100644 index 00000000000..49709499a99 --- /dev/null +++ b/tests/TUnit.UnitTests/PlaywrightTraceContextTests.cs @@ -0,0 +1,232 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; +using Microsoft.Playwright; +using NSubstitute; +using TUnit.Playwright; + +namespace TUnit.UnitTests; + +public class PlaywrightTraceContextTests +{ + [Test] + [Arguments("https://app.test", true)] + [Arguments("https://app.test/", true)] + [Arguments("https://app.test/page?q=1", true)] + [Arguments("https://app.test?q=1", true)] + [Arguments("https://APP.test/page", true)] + [Arguments("https://app.test.evil/page", false)] + [Arguments("https://app.test:8443/page", false)] + [Arguments("http://app.test/page", false)] + [Arguments("https://fonts.gstatic.com/s/font.woff2", false)] + public async Task OriginPatternMatchesOnlyConfiguredOrigin(string url, bool expected) + { + var pattern = PlaywrightTraceContextRoute.BuildOriginPattern(["https://app.test/ignored/path"], null)!; + + await Assert.That(pattern.IsMatch(url)).IsEqualTo(expected); + } + + [Test] + public async Task OriginPatternNormalizesDefaultPortAndMatchesEveryOrigin() + { + var pattern = PlaywrightTraceContextRoute.BuildOriginPattern(["https://app.test:443", "http://localhost:5000"], null)!; + + await Assert.That(pattern.IsMatch("https://app.test/")).IsTrue(); + await Assert.That(pattern.IsMatch("http://localhost:5000/api")).IsTrue(); + await Assert.That(pattern.IsMatch("http://localhost:5001/api")).IsFalse(); + } + + [Test] + public async Task OriginPatternFallsBackToBaseUrl() + { + var pattern = PlaywrightTraceContextRoute.BuildOriginPattern(null, "https://localhost:7217/app/")!; + + await Assert.That(pattern.IsMatch("https://localhost:7217/other")).IsTrue(); + await Assert.That(pattern.IsMatch("https://fonts.gstatic.com/")).IsFalse(); + } + + [Test] + public async Task OriginPatternIsNullWithoutOrigins() + { + await Assert.That(PlaywrightTraceContextRoute.BuildOriginPattern(null, null)).IsNull(); + await Assert.That(PlaywrightTraceContextRoute.BuildOriginPattern([], "https://app.test")).IsNull(); + } + + [Test] + [Arguments("not a url")] + [Arguments("/relative")] + [Arguments("file:///C:/app")] + public async Task OriginPatternRejectsInvalidOrigins(string origin) + { + await Assert.That(() => PlaywrightTraceContextRoute.BuildOriginPattern([origin], null)) + .Throws(); + } + + [Test] + public async Task NewContextRoutesTraceHeadersToConfiguredOriginsOnly() + { + using var activity = new Activity("trace-context-test").SetIdFormat(ActivityIdFormat.W3C).Start(); + var (browser, context) = CreateBrowser(); + var test = new OriginsBrowserTest(["https://app.test"]) { Browser = browser }; + + await test.NewContext(new BrowserNewContextOptions()); + + await browser.Received(1).NewContextAsync(Arg.Is(o => o.ExtraHTTPHeaders == null)); + var (pattern, handler) = CapturedRoute(context); + await Assert.That(pattern.IsMatch("https://app.test/")).IsTrue(); + await Assert.That(pattern.IsMatch("https://fonts.gstatic.com/")).IsFalse(); + + var route = CreateRoute(new Dictionary { ["accept"] = "text/html" }); + await handler(route); + + var headers = FallbackHeaders(route); + await Assert.That(headers["accept"]).IsEqualTo("text/html"); + await Assert.That(headers["traceparent"]).Contains(activity.TraceId.ToHexString()); + } + + [Test] + public async Task RoutedRequestKeepsExistingTraceparent() + { + using var activity = new Activity("trace-context-test").SetIdFormat(ActivityIdFormat.W3C).Start(); + var (browser, context) = CreateBrowser(); + var test = new OriginsBrowserTest(["https://app.test"]) { Browser = browser }; + + await test.NewContext(new BrowserNewContextOptions()); + var route = CreateRoute(new Dictionary { ["traceparent"] = "from-page" }); + await CapturedRoute(context).Handler(route); + + await Assert.That(FallbackHeaders(route)["traceparent"]).IsEqualTo("from-page"); + } + + [Test] + public async Task NewContextUsesBaseUrlOriginByDefault() + { + using var activity = new Activity("trace-context-test").SetIdFormat(ActivityIdFormat.W3C).Start(); + var (browser, context) = CreateBrowser(); + var test = new BrowserTest { Browser = browser }; + + await test.NewContext(new BrowserNewContextOptions { BaseURL = "https://localhost:7217" }); + + var (pattern, _) = CapturedRoute(context); + await Assert.That(pattern.IsMatch("https://localhost:7217/login")).IsTrue(); + await Assert.That(pattern.IsMatch("https://fonts.gstatic.com/")).IsFalse(); + } + + [Test] + public async Task NewContextSendsNoTraceHeadersWithoutKnownOrigin() + { + using var activity = new Activity("trace-context-test").SetIdFormat(ActivityIdFormat.W3C).Start(); + var (browser, context) = CreateBrowser(); + var test = new BrowserTest { Browser = browser }; + + await test.NewContext(new BrowserNewContextOptions()); + + await browser.Received(1).NewContextAsync(Arg.Is(o => o.ExtraHTTPHeaders == null)); + await context.DidNotReceiveWithAnyArgs().RouteAsync(default(Regex)!, default(Func)!); + } + + [Test] + public async Task NewContextSendsNoTraceHeadersWhenDisabled() + { + using var activity = new Activity("trace-context-test").SetIdFormat(ActivityIdFormat.W3C).Start(); + var (browser, context) = CreateBrowser(); + var test = new OriginsBrowserTest(["https://app.test"], propagate: false) { Browser = browser }; + + await test.NewContext(new BrowserNewContextOptions()); + + await context.DidNotReceiveWithAnyArgs().RouteAsync(default(Regex)!, default(Func)!); + } + + [Test] + public async Task ContextFixtureRoutesTraceHeadersToConfiguredOrigins() + { + using var activity = new Activity("trace-context-test").SetIdFormat(ActivityIdFormat.W3C).Start(); + var (browser, context) = CreateBrowser(); + var fixture = new OriginsContextFixture + { + BrowserFixture = new BrowserFixture { Browser = browser, PlaywrightFixture = null! } + }; + + await fixture.InitializeAsync(); + + var (pattern, handler) = CapturedRoute(context); + await Assert.That(pattern.IsMatch("https://app.test/")).IsTrue(); + await Assert.That(pattern.IsMatch("https://fonts.gstatic.com/")).IsFalse(); + + var route = CreateRoute([]); + await handler(route); + await Assert.That(FallbackHeaders(route)["traceparent"]).Contains(activity.TraceId.ToHexString()); + } + + [Test] + public async Task ContextIsClosedWhenRouteRegistrationFails() + { + using var activity = new Activity("trace-context-test").SetIdFormat(ActivityIdFormat.W3C).Start(); + var (browser, context) = CreateBrowser(); + context.WhenForAnyArgs(c => c.RouteAsync(default(Regex)!, default(Func)!)) + .Do(_ => throw new PlaywrightException("route failed")); + var test = new OriginsBrowserTest(["https://app.test"]) { Browser = browser }; + + await Assert.That(() => test.NewContext(new BrowserNewContextOptions())).Throws(); + await context.Received(1).CloseAsync(); + } + + [Test] + public async Task ContextFixtureClosesContextWhenRouteRegistrationFails() + { + using var activity = new Activity("trace-context-test").SetIdFormat(ActivityIdFormat.W3C).Start(); + var (browser, context) = CreateBrowser(); + context.WhenForAnyArgs(c => c.RouteAsync(default(Regex)!, default(Func)!)) + .Do(_ => throw new PlaywrightException("route failed")); + var fixture = new OriginsContextFixture + { + BrowserFixture = new BrowserFixture { Browser = browser, PlaywrightFixture = null! } + }; + + await Assert.That(fixture.InitializeAsync).Throws(); + await context.Received(1).CloseAsync(); + await Assert.That(fixture.Context).IsNull(); + } + + private static (IBrowser Browser, IBrowserContext Context) CreateBrowser() + { + var browser = Substitute.For(); + var context = Substitute.For(); + context.Pages.Returns(Array.Empty()); + browser.NewContextAsync(Arg.Any()).Returns(context); + return (browser, context); + } + + private static (Regex Pattern, Func Handler) CapturedRoute(IBrowserContext context) + { + var call = context.ReceivedCalls().Single(c => c.GetMethodInfo().Name == nameof(IBrowserContext.RouteAsync)); + var arguments = call.GetArguments(); + return ((Regex)arguments[0]!, (Func)arguments[1]!); + } + + private static IRoute CreateRoute(Dictionary headers) + { + var request = Substitute.For(); + request.Headers.Returns(headers); + var route = Substitute.For(); + route.Request.Returns(request); + return route; + } + + private static Dictionary FallbackHeaders(IRoute route) => + ((RouteFallbackOptions)route.ReceivedCalls() + .Single(c => c.GetMethodInfo().Name == nameof(IRoute.FallbackAsync)) + .GetArguments()[0]!).Headers! + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.OrdinalIgnoreCase); + + private sealed class OriginsBrowserTest(IReadOnlyList origins, bool propagate = true) : BrowserTest + { + public override bool PropagateTraceContext => propagate; + + public override IReadOnlyList? TraceContextOrigins => origins; + } + + private sealed class OriginsContextFixture : ContextFixture + { + protected override IReadOnlyList? TraceContextOrigins => ["https://app.test"]; + } +}