From 8c92524a53dd0ad59bb9b015d811063ffe5df4aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dar=C3=ADo=20Kondratiuk?= Date: Tue, 6 Oct 2026 14:55:23 -0300 Subject: [PATCH] Port tracing disposable, HAR startedDateTime, and pagehide binding tests Tracing.StartAsync / StartChunkAsync expanded overloads now return a disposable that discards the trace or chunk, and stopChunk now ends the chunk like upstream. HAR entries take startedDateTime from the browser wall time instead of the float ResourceTiming start time, and WebKit reads the protocol's `walltime` field. Co-Authored-By: Claude Opus 5.5 --- ...BrowserContextExposeFunctionParityTests.cs | 36 +++++++++++++ .../LibraryHarParityTests.cs | 42 +++++++++++++++ .../LibraryTracingParityTests.cs | 51 +++++++++++++++++++ .../Chromium/CRNetworkManager.cs | 1 + src/PlaywrightNative/Chromium/CRRequest.cs | 6 +++ .../Chromium/ChromiumRequest.cs | 5 +- src/PlaywrightNative/Helpers/HarRecorder.cs | 5 ++ src/PlaywrightNative/Helpers/IHasWallTime.cs | 30 +++++++++++ .../Helpers/OfficialTraceSession.cs | 7 ++- .../MicrosoftSurfaceCompatExtensions.cs | 37 ++++++++++++-- .../WebKit/WKNetworkManager.cs | 3 +- src/PlaywrightNative/WebKit/WKRequest.cs | 5 +- 12 files changed, 220 insertions(+), 8 deletions(-) create mode 100644 src/PlaywrightNative/Helpers/IHasWallTime.cs diff --git a/src/PlaywrightNative.Tests/LibraryBrowserContextExposeFunctionParityTests.cs b/src/PlaywrightNative.Tests/LibraryBrowserContextExposeFunctionParityTests.cs index e774785e..2b0e1297 100644 --- a/src/PlaywrightNative.Tests/LibraryBrowserContextExposeFunctionParityTests.cs +++ b/src/PlaywrightNative.Tests/LibraryBrowserContextExposeFunctionParityTests.cs @@ -19,6 +19,7 @@ using System.Globalization; using System.Text.Json; using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; using Microsoft.Playwright; using NUnit.Framework; using PlaywrightNative.NUnit; @@ -244,6 +245,41 @@ public async Task ShouldWorkWithCsp() await context.CloseAsync().ConfigureAwait(false); } + [PlaywrightTest("browsercontext-expose-function.spec.ts", "should call binding from pagehide handler when navigating away")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public async Task ShouldCallBindingFromPagehideHandlerWhenNavigatingAway() + { + if (TestConstants.IsChromium) + { + Assert.Ignore("upstream it.fixme(chromium): Chromium drops CDP events from the old document after the frame host swap"); + } + + EnsureServer(); + IBrowserContext context = await _browser.NewContextAsync().ConfigureAwait(false); + IPage page = await context.NewPageAsync().ConfigureAwait(false); + List calls = new List(); + await context.ExposeBindingAsync("reportFromPage", (BindingSource source, string message) => { calls.Add(message); }).ConfigureAwait(false); + static string Html(string body) => "" + body + ""; + Server.SetRoute("/a.html", async http => + { + http.Response.StatusCode = 200; + http.Response.ContentType = "text/html"; + await http.Response.WriteAsync(Html("next")).ConfigureAwait(false); + }); + Server.SetRoute("/b.html", async http => + { + http.Response.StatusCode = 200; + http.Response.ContentType = "text/html"; + await http.Response.WriteAsync(Html("

b

")).ConfigureAwait(false); + }); + await page.GoToAsync(Prefix + "/a.html").ConfigureAwait(false); + await page.ClickAsync("#next").ConfigureAwait(false); + await page.WaitForURLAsync("**/b.html").ConfigureAwait(false); + await PollEqualAsync(() => Task.FromResult(calls.Contains("pagehide of /a.html")), true).ConfigureAwait(false); + await context.CloseAsync().ConfigureAwait(false); + } + private async Task CloseLeftoverContextsAsync() { if (_browser == null) diff --git a/src/PlaywrightNative.Tests/LibraryHarParityTests.cs b/src/PlaywrightNative.Tests/LibraryHarParityTests.cs index e62fb790..2716e7e5 100644 --- a/src/PlaywrightNative.Tests/LibraryHarParityTests.cs +++ b/src/PlaywrightNative.Tests/LibraryHarParityTests.cs @@ -281,6 +281,48 @@ public async Task ShouldIncludeRequest() Assert.That(entry.GetProperty("request").GetProperty("bodySize").GetInt32(), Is.EqualTo(0)); } + [PlaywrightTest("har.spec.ts", "should populate entry startedDateTime from the browser")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public async Task ShouldPopulateEntryStartedDateTimeFromTheBrowser() + { + EnsureServer(); + await using HarSession session = await PageWithHarAsync().ConfigureAwait(false); + await session.Page.GoToAsync(EmptyPage).ConfigureAwait(false); + + // The browser issues a request after a short delay, then we deliberately + // block the test thread. If `startedDateTime` is populated from our clock + // at observation time it can land inside the busy-loop window (i.e. close + // to `unblockedAt`); if it comes from the browser via the debugging + // protocol it will be tied to when the browser actually sent the request. + // Protocol events keep flowing on other threads in .NET, so the response + // wait is registered before the busy loop instead of after it. + // `window.builtins` is upstream test-only injected state. No clock is + // installed here, so the page `setTimeout` is the same function. + Task responseTask = session.Page.WaitForResponseAsync("**/delayed-fetch"); + await session.Page.EvaluateAsync("() => { setTimeout(() => { void fetch('/delayed-fetch'); }, 50); }").ConfigureAwait(false); + + long blockUntil = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + 300; + while (DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() < blockUntil) + { + // Busy loop. + } + + long unblockedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + await responseTask.ConfigureAwait(false); + JsonElement log = await session.GetLogAsync().ConfigureAwait(false); + + JsonElement entry = log.GetProperty("entries").EnumerateArray() + .First(e => e.GetProperty("request").GetProperty("url").GetString().EndsWith("/delayed-fetch", StringComparison.Ordinal)); + long startedAt = DateTimeOffset.Parse(entry.GetProperty("startedDateTime").GetString(), CultureInfo.InvariantCulture).ToUnixTimeMilliseconds(); + + // The recorded time should be tied to when the browser actually sent the + // request (during the busy loop), not to when we observed the protocol + // event (after the busy loop). + Assert.That(startedAt, Is.LessThan(unblockedAt - 100)); + } + [PlaywrightTest("har.spec.ts", "should include response")] [Test] [Timeout(TestConstants.DefaultTestTimeout)] diff --git a/src/PlaywrightNative.Tests/LibraryTracingParityTests.cs b/src/PlaywrightNative.Tests/LibraryTracingParityTests.cs index 677018e7..2fa6bd32 100644 --- a/src/PlaywrightNative.Tests/LibraryTracingParityTests.cs +++ b/src/PlaywrightNative.Tests/LibraryTracingParityTests.cs @@ -194,6 +194,57 @@ public async Task ShouldNotThrowWhenStoppingWithoutStartButNotExporting() await context.Tracing.StopAsync().ConfigureAwait(false); } + [PlaywrightTest("tracing.spec.ts", "start should return a disposable that discards the trace")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public async Task StartShouldReturnADisposableThatDiscardsTheTrace() + { + string path = Path.Combine(Path.GetTempPath(), "pwsharp-trace-" + Path.GetRandomFileName() + ".zip"); + try + { + await using IBrowser browser = await BrowserLauncher.LaunchAsync().ConfigureAwait(false); + await using IBrowserContext context = await browser.NewContextAsync().ConfigureAwait(false); + IPage page = await context.NewPageAsync().ConfigureAwait(false); + IAsyncDisposable disposable = await context.Tracing.StartAsync(name: default).ConfigureAwait(false); + await page.GoToAsync(EmptyPage).ConfigureAwait(false); + await disposable.DisposeAsync().ConfigureAwait(false); + PlaywrightException error = Assert.CatchAsync( + () => context.Tracing.StopAsync(new TracingStopOptions { Path = path })); + Assert.That(error, Is.Not.Null); + Assert.That(error.Message, Does.Contain("Must start tracing before stopping")); + } + finally + { + TryDelete(path); + } + } + + [PlaywrightTest("tracing.spec.ts", "startChunk should return a disposable that discards the chunk")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public async Task StartChunkShouldReturnADisposableThatDiscardsTheChunk() + { + string path = Path.Combine(Path.GetTempPath(), "pwsharp-trace-" + Path.GetRandomFileName() + ".zip"); + try + { + await using IBrowser browser = await BrowserLauncher.LaunchAsync().ConfigureAwait(false); + await using IBrowserContext context = await browser.NewContextAsync().ConfigureAwait(false); + IPage page = await context.NewPageAsync().ConfigureAwait(false); + await context.Tracing.StartAsync().ConfigureAwait(false); + IAsyncDisposable disposable = await context.Tracing.StartChunkAsync(name: default).ConfigureAwait(false); + await page.GoToAsync(EmptyPage).ConfigureAwait(false); + await disposable.DisposeAsync().ConfigureAwait(false); + PlaywrightException error = Assert.CatchAsync( + () => context.Tracing.StopChunkAsync(new TracingStopChunkOptions { Path = path })); + Assert.That(error, Is.Not.Null); + Assert.That(error.Message, Does.Contain("Must start tracing before stopping")); + } + finally + { + TryDelete(path); + } + } + [PlaywrightTest("tracing.spec.ts", "should use the correct title for event driven callbacks")] [Test] [Timeout(TestConstants.DefaultTestTimeout)] diff --git a/src/PlaywrightNative/Chromium/CRNetworkManager.cs b/src/PlaywrightNative/Chromium/CRNetworkManager.cs index 5c4fe264..769dd7b5 100644 --- a/src/PlaywrightNative/Chromium/CRNetworkManager.cs +++ b/src/PlaywrightNative/Chromium/CRNetworkManager.cs @@ -1363,6 +1363,7 @@ private void OnRequestWillBeSent(JsonElement? parameters, CRSession session, boo request.DocumentUrl = isNavigationRequest ? url : frame?.Url; request.TimestampSeconds = ResourceTimingParser.ReadDouble(p, "timestamp"); double wallTime = ResourceTimingParser.ReadDouble(p, "wallTime"); + request.WallTimeMs = wallTime * 1000; if (wallTime <= 0) { wallTime = request.TimestampSeconds; diff --git a/src/PlaywrightNative/Chromium/CRRequest.cs b/src/PlaywrightNative/Chromium/CRRequest.cs index 3d3ef12c..6d659be9 100644 --- a/src/PlaywrightNative/Chromium/CRRequest.cs +++ b/src/PlaywrightNative/Chromium/CRRequest.cs @@ -330,6 +330,12 @@ internal CRResponse Response /// internal double TimestampSeconds { get; set; } + /// + /// Gets or sets the browser wallTime from Network.requestWillBeSent + /// (milliseconds since the Unix epoch). + /// + internal double WallTimeMs { get; set; } + /// /// Official Network.requestServedFromCache / memory-cache. /// diff --git a/src/PlaywrightNative/Chromium/ChromiumRequest.cs b/src/PlaywrightNative/Chromium/ChromiumRequest.cs index 449d82cc..cd918f99 100644 --- a/src/PlaywrightNative/Chromium/ChromiumRequest.cs +++ b/src/PlaywrightNative/Chromium/ChromiumRequest.cs @@ -26,7 +26,7 @@ namespace PlaywrightNative.Chromium { /// Public wrapping . - internal sealed partial class ChromiumRequest : IRequest, IHasOwningPage + internal sealed partial class ChromiumRequest : IRequest, IHasOwningPage, IHasWallTime { private readonly CRRequest _crRequest; private readonly Func _resolveResponse; @@ -79,6 +79,9 @@ public IFrame Frame /// public IPage OwningPage => ResolveFrame()?.Page; + /// + public double WallTimeMs => _crRequest.WallTimeMs; + /// public Dictionary Headers { diff --git a/src/PlaywrightNative/Helpers/HarRecorder.cs b/src/PlaywrightNative/Helpers/HarRecorder.cs index 3524917d..fb7663fe 100644 --- a/src/PlaywrightNative/Helpers/HarRecorder.cs +++ b/src/PlaywrightNative/Helpers/HarRecorder.cs @@ -930,6 +930,11 @@ private static double Elapsed(float start, float end) private static DateTimeOffset StartedAt(IRequest request) { + if (request is IHasWallTime hasWallTime && hasWallTime.WallTimeMs > 0) + { + return DateTimeOffset.FromUnixTimeMilliseconds((long)Math.Round(hasWallTime.WallTimeMs)); + } + RequestTimingResult timing = request?.Timing; if (timing != null && timing.StartTime > 0) { diff --git a/src/PlaywrightNative/Helpers/IHasWallTime.cs b/src/PlaywrightNative/Helpers/IHasWallTime.cs new file mode 100644 index 00000000..f0a04a9d --- /dev/null +++ b/src/PlaywrightNative/Helpers/IHasWallTime.cs @@ -0,0 +1,30 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace PlaywrightNative.Helpers +{ + /// + /// Browser wall time for a request, as reported by the engine + /// (upstream Request.wallTimeMs()). + /// + internal interface IHasWallTime + { + /// + /// Milliseconds since the Unix epoch when the browser sent the request, + /// or 0 when the engine did not report it. + /// + double WallTimeMs { get; } + } +} diff --git a/src/PlaywrightNative/Helpers/OfficialTraceSession.cs b/src/PlaywrightNative/Helpers/OfficialTraceSession.cs index 99a55820..74ea9299 100644 --- a/src/PlaywrightNative/Helpers/OfficialTraceSession.cs +++ b/src/PlaywrightNative/Helpers/OfficialTraceSession.cs @@ -52,6 +52,7 @@ internal sealed class OfficialTraceSession private readonly bool _apiOnly; private TracingStartOptions _options; private bool _recording; + private bool _chunkRecording; private bool _networkAttached; private int _callId; private int _shaIndex; @@ -170,6 +171,7 @@ internal void Start(TracingStartOptions options, bool chunk) _options = next; _recording = true; + _chunkRecording = true; _traceLines.Clear(); _resources.Clear(); _chunkCallIds.Clear(); @@ -842,7 +844,9 @@ async Task StopInnerAsync() string tracesDir; lock (_gate) { - if (!_recording) + // Upstream ends the chunk on every stopChunk; a second stopChunk + // (or an exporting stop) without a new startChunk has nothing to save. + if (!_recording || (!_chunkRecording && (keepRecording || !string.IsNullOrEmpty(path)))) { if (!string.IsNullOrEmpty(path)) { @@ -912,6 +916,7 @@ async Task StopInnerAsync() } else { + _chunkRecording = false; WriteContextOptions(); } } diff --git a/src/PlaywrightNative/MicrosoftSurfaceCompatExtensions.cs b/src/PlaywrightNative/MicrosoftSurfaceCompatExtensions.cs index 8fc6d801..2fc9eb00 100644 --- a/src/PlaywrightNative/MicrosoftSurfaceCompatExtensions.cs +++ b/src/PlaywrightNative/MicrosoftSurfaceCompatExtensions.cs @@ -249,6 +249,29 @@ public static Task StartAsync(this ITracing tracing, PlaywrightNative.TracingSta }), }; + /// + /// Expanded-parameter start overload. Returns a disposable that stops + /// tracing without exporting, which discards the trace. + /// + public static async Task StartAsync( + this ITracing tracing, + string name = default, + string title = default, + bool? screenshots = default, + bool? snapshots = default, + bool? sources = default) + { + await tracing.StartAsync(new Microsoft.Playwright.TracingStartOptions + { + Name = name, + Title = title, + Screenshots = screenshots, + Snapshots = snapshots, + Sources = sources, + }).ConfigureAwait(false); + return AddInitScriptHelper.CreateDisposable(() => tracing.StopAsync()); + } + /// Expanded-parameter stop overload. public static Task StopAsync(this ITracing tracing, string path) => tracing.StopAsync(new TracingStopOptions { Path = path }); @@ -262,14 +285,20 @@ public static Task StopChunkAsync(this ITracing tracing, string path = default) _ => tracing.StopChunkAsync(new TracingStopChunkOptions { Path = path }), }; - /// Expanded-parameter start-chunk overload. - public static Task StartChunkAsync(this ITracing tracing, string name = default, string title = default) - => tracing switch + /// + /// Expanded-parameter start-chunk overload. Returns a disposable that stops + /// the chunk without exporting, which discards the chunk. + /// + public static async Task StartChunkAsync(this ITracing tracing, string name = default, string title = default) + { + await (tracing switch { CRTracing chromium => chromium.StartChunkAsync(name, title), EmptyTracing empty => empty.StartChunkAsync(name, title), _ => tracing.StartChunkAsync(new TracingStartChunkOptions { Name = name, Title = title }), - }; + }).ConfigureAwait(false); + return AddInitScriptHelper.CreateDisposable(() => tracing.StopChunkAsync()); + } /// HAR recording helper on tracing. public static Task StartHarAsync( diff --git a/src/PlaywrightNative/WebKit/WKNetworkManager.cs b/src/PlaywrightNative/WebKit/WKNetworkManager.cs index 749a03f6..3becb697 100644 --- a/src/PlaywrightNative/WebKit/WKNetworkManager.cs +++ b/src/PlaywrightNative/WebKit/WKNetworkManager.cs @@ -852,7 +852,8 @@ private WKRequest CreateRequestFromWillBeSent(JsonElement p, bool allowRoute) request.DocumentUrl = isNavigationRequest ? url : frame?.Url; request.TimestampSeconds = ResourceTimingParser.ReadDouble(p, "timestamp"); - double wallTime = ResourceTimingParser.ReadDouble(p, "wallTime"); + double wallTime = ResourceTimingParser.ReadDouble(p, "walltime"); + request.WallTimeMs = wallTime * 1000; if (wallTime <= 0) { wallTime = request.TimestampSeconds; diff --git a/src/PlaywrightNative/WebKit/WKRequest.cs b/src/PlaywrightNative/WebKit/WKRequest.cs index 97e297d5..5fdcc9de 100644 --- a/src/PlaywrightNative/WebKit/WKRequest.cs +++ b/src/PlaywrightNative/WebKit/WKRequest.cs @@ -34,7 +34,7 @@ namespace PlaywrightNative.WebKit /// corresponding response and redirect chain. Mirrors CRRequest. /// Implements directly. /// - internal partial class WKRequest : IRequest, IHasOwningPage + internal partial class WKRequest : IRequest, IHasOwningPage, IHasWallTime { private readonly TaskCompletionSource _finished = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -152,6 +152,9 @@ public IFrame Frame /// public IPage OwningPage => _frame?.Page; + /// + public double WallTimeMs { get; set; } + /// public byte[] PostDataBuffer => _postDataBuffer;