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;