diff --git a/src/PlaywrightNative.Tests/Library/Coverage/CoverageTests.cs b/src/PlaywrightNative.Tests/Library/Coverage/CoverageTests.cs new file mode 100644 index 00000000..976bae5a --- /dev/null +++ b/src/PlaywrightNative.Tests/Library/Coverage/CoverageTests.cs @@ -0,0 +1,443 @@ +/* + * Copyright (c) Microsoft Corporation. + * Modifications 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. + */ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.Playwright; +using NUnit.Framework; +using PlaywrightNative.Helpers; +using PlaywrightNative.NUnit; + +namespace PlaywrightNative.Tests +{ + /// + /// Official library/coverage.spec.ts parity. The file-level + /// trace === 'on' skip does not apply here: tests never run with + /// an outer trace. + /// + [TestFixture] + [NonParallelizable] + public class CoverageTests : BrowserTestEx + { + private string _outputDir; + + [SetUp] + public void CreateOutputDir() + { + _outputDir = Path.Combine(Path.GetTempPath(), "pw-coverage-" + Path.GetRandomFileName()); + Directory.CreateDirectory(_outputDir); + } + + [TearDown] + public void DeleteOutputDir() + { + try + { + Directory.Delete(_outputDir, recursive: true); + } + catch (IOException) + { + } + } + + [PlaywrightTest("coverage.spec.ts", "should collect istanbul coverage into the trace")] + [Test, Timeout(TestConstants.DefaultTestTimeout)] + public async Task ShouldCollectIstanbulCoverageIntoTheTrace() + { + IBrowserContext context = await Browser.NewContextAsync(); + await context.Tracing.StartAsync(new TracingStartOptions { Coverage = true }); + + IPage page = await context.NewPageAsync(); + await page.SetContentAsync(CoverageScript("a.js", 3)); + + // Coverage of a closed page is collected automatically. + IPage page2 = await context.NewPageAsync(); + await page2.SetContentAsync(CoverageScript("b.js", 2)); + await page2.CloseAsync(); + + string traceFile = OutputPath("trace.zip"); + await context.Tracing.StopAsync(new() { Path = traceFile }); + await context.CloseAsync(); + + Dictionary data = ParseTraceCoverage(traceFile); + Assert.That(data["a.js"].S["0"], Is.EqualTo(3)); + Assert.That(data["b.js"].S["0"], Is.EqualTo(2)); + } + + [PlaywrightTest("coverage.spec.ts", "should collect coverage per trace chunk")] + [Test, Timeout(TestConstants.DefaultTestTimeout)] + public async Task ShouldCollectCoveragePerTraceChunk() + { + IBrowserContext context = await Browser.NewContextAsync(); + await context.Tracing.StartAsync(new TracingStartOptions { Coverage = true }); + IPage page = await context.NewPageAsync(); + + await page.SetContentAsync(CoverageScript("a.js", 5)); + string traceFile1 = OutputPath("trace1.zip"); + await context.Tracing.StopChunkAsync(new() { Path = traceFile1 }); + + await context.Tracing.StartChunkAsync(); + await page.SetContentAsync(CoverageScript("b.js", 7)); + string traceFile2 = OutputPath("trace2.zip"); + await context.Tracing.StopChunkAsync(new() { Path = traceFile2 }); + + await context.Tracing.StopAsync(); + await context.CloseAsync(); + + Dictionary data1 = ParseTraceCoverage(traceFile1); + Assert.That(data1["a.js"].S["0"], Is.EqualTo(5)); + Assert.That(data1.ContainsKey("b.js"), Is.False); + + Dictionary data2 = ParseTraceCoverage(traceFile2); + Assert.That(data2["b.js"].S["0"], Is.EqualTo(7)); + Assert.That(data2.ContainsKey("a.js"), Is.False); + } + + [PlaywrightTest("coverage.spec.ts", "should report maps once and counters incrementally")] + [Test, Timeout(TestConstants.DefaultTestTimeout)] + public async Task ShouldReportMapsOnceAndCountersIncrementally() + { + IBrowserContext context = await Browser.NewContextAsync(); + await context.Tracing.StartAsync(new TracingStartOptions { Coverage = true }); + IPage page = await context.NewPageAsync(); + await page.GoToAsync(TestConstants.EmptyPage); + + // Actions collect the counters on their own, so hit and collect in one evaluate. + IstanbulCoverageChunk[] first = ToChunks(await page.EvaluateAsync( + @"coverage => { + window.__coverage__ = JSON.parse(coverage); + return window.__pwCoverageTake().map(json => JSON.parse(json)); + }", + FileCoverageJson("a.js", 3))); + Assert.That(first[0].Data["a.js"].StatementMap, Is.Not.Null); + Assert.That(first[0].Data["a.js"].S, Is.EqualTo(new Dictionary { ["0"] = 3 })); + + IstanbulCoverageChunk[] second = ToChunks(await page.EvaluateAsync( + @"() => { + window.__coverage__['a.js'].s['0'] += 2; + return window.__pwCoverageTake().map(json => JSON.parse(json)); + }")); + Assert.That(second[0].Data["a.js"].StatementMap, Is.Null); + Assert.That(second[0].Data["a.js"].S, Is.EqualTo(new Dictionary { ["0"] = 2 })); + + // Nothing was hit since the last report. + Assert.That(await page.EvaluateAsync("() => window.__pwCoverageTake()"), Is.Empty); + + await context.Tracing.StopAsync(); + await context.CloseAsync(); + } + + [PlaywrightTest("coverage.spec.ts", "should accumulate counters across pulls and keep never hit files")] + [Test, Timeout(TestConstants.DefaultTestTimeout)] + public async Task ShouldAccumulateCountersAcrossPullsAndKeepNeverHitFiles() + { + IBrowserContext context = await Browser.NewContextAsync(); + await context.Tracing.StartAsync(new TracingStartOptions { Coverage = true }); + IPage page = await context.NewPageAsync(); + await page.SetContentAsync(CoverageScript("a.js", 0)); + await page.EvaluateAsync("() => window.__coverage__['a.js'].s['0'] += 4"); + await page.EvaluateAsync("() => window.__coverage__['a.js'].s['0'] += 3"); + + string traceFile = OutputPath("trace.zip"); + await context.Tracing.StopAsync(new() { Path = traceFile }); + await context.CloseAsync(); + + Dictionary data = ParseTraceCoverage(traceFile); + Assert.That(data["a.js"].S["0"], Is.EqualTo(7)); + Assert.That(data["a.js"].StatementMap.Keys, Is.EqualTo(new[] { "0" })); + } + + [PlaywrightTest("coverage.spec.ts", "should collect coverage of a page closed by in-page script")] + [Test, Timeout(TestConstants.DefaultTestTimeout)] + public async Task ShouldCollectCoverageOfAPageClosedByInPageScript() + { + IBrowserContext context = await Browser.NewContextAsync(); + await context.Tracing.StartAsync(new TracingStartOptions { Coverage = true }); + IPage page = await context.NewPageAsync(); + await page.GoToAsync(TestConstants.EmptyPage); + + IPage popup = await OpenPopupAsync(page, "popup.js", 5); + + // An in-page close never reaches Playwright as page.close(), the counters + // are preserved because the actions collect them as they go. + await Task.WhenAll( + popup.WaitForCloseAsync(), + popup.EvaluateAsync("() => setTimeout(() => window.close(), 0)")); + + string traceFile = OutputPath("trace.zip"); + await context.Tracing.StopAsync(new() { Path = traceFile }); + await context.CloseAsync(); + + Dictionary data = ParseTraceCoverage(traceFile); + Assert.That(data["popup.js"].S["0"], Is.EqualTo(5)); + } + + [PlaywrightTest("coverage.spec.ts", "should not double count a stash picked up twice")] + [Test, Timeout(TestConstants.DefaultTestTimeout)] + public async Task ShouldNotDoubleCountAStashPickedUpTwice() + { + IBrowserContext context = await Browser.NewContextAsync(); + await context.Tracing.StartAsync(new TracingStartOptions { Coverage = true }); + IPage page = await context.NewPageAsync(); + await page.GoToAsync(TestConstants.EmptyPage); + + // Emulate two documents picking up the same stash before either removes it. + string prefix = "__pwCoverage." + ((IHasOfficialTrace)context).OfficialTrace.CoverageSessionId + "."; + await page.EvaluateAsync( + @"({ prefix, coverage }) => { + const chunk = JSON.stringify({ id: 'stash-id', data: JSON.parse(coverage) }); + localStorage.setItem(prefix + 'one', chunk); + localStorage.setItem(prefix + 'two', chunk); + }", + new { prefix, coverage = FileCoverageJson("stashed.js", 4) }); + + string traceFile = OutputPath("trace.zip"); + await context.Tracing.StopAsync(new() { Path = traceFile }); + await context.CloseAsync(); + + Dictionary data = ParseTraceCoverage(traceFile); + Assert.That(data["stashed.js"].S["0"], Is.EqualTo(4)); + } + + [PlaywrightTest("coverage.spec.ts", "should discard stashes of other sessions")] + [Test, Timeout(TestConstants.DefaultTestTimeout)] + public async Task ShouldDiscardStashesOfOtherSessions() + { + IBrowserContext context = await Browser.NewContextAsync(); + await context.Tracing.StartAsync(new TracingStartOptions { Coverage = true }); + IPage page = await context.NewPageAsync(); + await page.GoToAsync(TestConstants.EmptyPage); + await page.EvaluateAsync( + @"coverage => { + localStorage.setItem('__pwCoverage.other-session.1', JSON.stringify({ id: 'other', data: JSON.parse(coverage) })); + }", + FileCoverageJson("stale.js", 7)); + + string[] remaining = await page.EvaluateAsync("() => Object.keys(localStorage).filter(key => key.startsWith('__pwCoverage.'))"); + Assert.That(remaining, Is.Empty); + + string traceFile = OutputPath("trace.zip"); + await context.Tracing.StopAsync(new() { Path = traceFile }); + await context.CloseAsync(); + + // The stale stash was the only coverage, so nothing was collected at all. + Assert.That(ParseTraceCoverage(traceFile), Is.Null); + } + + [PlaywrightTest("coverage.spec.ts", "should collect coverage of an origin left without a page")] + [Test, Timeout(TestConstants.DefaultTestTimeout)] + public async Task ShouldCollectCoverageOfAnOriginLeftWithoutAPage() + { + IBrowserContext context = await Browser.NewContextAsync(); + await context.Tracing.StartAsync(new TracingStartOptions { Coverage = true }); + IPage page = await context.NewPageAsync(); + await page.GoToAsync(TestConstants.EmptyPage); + + IPage popup = await OpenPopupAsync(page, "popup.js", 0); + await popup.EvaluateAsync( + @"url => { + window.__go = () => { + window.__coverage__['popup.js'].s['0'] = 5; + window.location.href = url; + }; + }", + TestConstants.CrossProcessHttpPrefix + "/empty.html"); + + // The popup is hit and navigates away, leaving a stash behind. Triggered from + // the opener, so that the pull after the action does not race the navigation. + await Task.WhenAll( + popup.WaitForURLAsync(TestConstants.CrossProcessHttpPrefix + "/empty.html"), + page.EvaluateAsync("() => window.__popup.__go()")); + + // Leave the origin of the stash without a page to relay it. + await page.GoToAsync(TestConstants.CrossProcessHttpPrefix + "/empty.html"); + + string traceFile = OutputPath("trace.zip"); + await context.Tracing.StopAsync(new() { Path = traceFile }); + await context.CloseAsync(); + + Dictionary data = ParseTraceCoverage(traceFile); + Assert.That(data["popup.js"].S["0"], Is.EqualTo(5)); + } + + [PlaywrightTest("coverage.spec.ts", "should pull counters as the actions go")] + [Test, Timeout(TestConstants.DefaultTestTimeout)] + public async Task ShouldPullCountersAsTheActionsGo() + { + IBrowserContext context = await Browser.NewContextAsync(); + await context.Tracing.StartAsync(new TracingStartOptions { Coverage = true }); + IPage page = await context.NewPageAsync(); + await page.GoToAsync(TestConstants.EmptyPage); + await page.SetContentAsync(CoverageScript("a.js", 3)); + + // The counters are collected by the action itself, not only by the final flush. + Assert.That(await page.EvaluateAsync("() => window.__coverage__['a.js'].s['0']"), Is.EqualTo(0)); + + string traceFile = OutputPath("trace.zip"); + await context.Tracing.StopAsync(new() { Path = traceFile }); + await context.CloseAsync(); + + Dictionary data = ParseTraceCoverage(traceFile); + Assert.That(data["a.js"].S["0"], Is.EqualTo(3)); + } + + [PlaywrightTest("coverage.spec.ts", "should not collect coverage without the option")] + [Test, Timeout(TestConstants.DefaultTestTimeout)] + public async Task ShouldNotCollectCoverageWithoutTheOption() + { + IBrowserContext context = await Browser.NewContextAsync(); + await context.Tracing.StartAsync(); + IPage page = await context.NewPageAsync(); + await page.SetContentAsync(CoverageScript("a.js", 1)); + + string traceFile = OutputPath("trace.zip"); + await context.Tracing.StopAsync(new() { Path = traceFile }); + await context.CloseAsync(); + + Assert.That(ParseTraceCoverage(traceFile), Is.Null); + } + + [PlaywrightTest("coverage.spec.ts", "should count only the hits after start")] + [Test, Timeout(TestConstants.DefaultTestTimeout)] + public async Task ShouldCountOnlyTheHitsAfterStart() + { + IBrowserContext context = await Browser.NewContextAsync(); + IPage page = await context.NewPageAsync(); + await page.GoToAsync(TestConstants.EmptyPage); + await page.EvaluateAsync("coverage => window.__coverage__ = JSON.parse(coverage)", FileCoverageJson("a.js", 3)); + + await context.Tracing.StartAsync(new TracingStartOptions { Coverage = true }); + await page.EvaluateAsync("() => window.__coverage__['a.js'].s['0'] += 2"); + string traceFile = OutputPath("trace.zip"); + await context.Tracing.StopAsync(new() { Path = traceFile }); + await context.CloseAsync(); + + Dictionary data = ParseTraceCoverage(traceFile); + Assert.That(data["a.js"].S["0"], Is.EqualTo(2)); + Assert.That(data["a.js"].StatementMap.Keys, Is.EqualTo(new[] { "0" })); + } + + [PlaywrightTest("coverage.spec.ts", "should stop collecting when tracing stops")] + [Test, Timeout(TestConstants.DefaultTestTimeout)] + public async Task ShouldStopCollectingWhenTracingStops() + { + IBrowserContext context = await Browser.NewContextAsync(); + await context.Tracing.StartAsync(new TracingStartOptions { Coverage = true }); + IPage page = await context.NewPageAsync(); + await page.GoToAsync(TestConstants.EmptyPage); + await page.EvaluateAsync("coverage => window.__coverage__ = JSON.parse(coverage)", FileCoverageJson("a.js", 1)); + await context.Tracing.StopAsync(); + + Assert.That(await page.EvaluateAsync("() => typeof window.__pwCoverageTake"), Is.EqualTo("undefined")); + + // Leaving the document no longer stashes the counters. + await page.EvaluateAsync("() => window.__coverage__['a.js'].s['0'] = 5"); + await page.GoToAsync(TestConstants.ServerUrl + "/title.html"); + Assert.That(await page.EvaluateAsync("() => Object.keys(localStorage).filter(key => key.startsWith('__pwCoverage.'))"), Is.Empty); + Assert.That(await page.EvaluateAsync("() => typeof window.__pwCoverageTake"), Is.EqualTo("undefined")); + await context.CloseAsync(); + } + + [PlaywrightTest("coverage.spec.ts", "should surface a failure to stash the coverage")] + [Test, Timeout(TestConstants.DefaultTestTimeout)] + public async Task ShouldSurfaceAFailureToStashTheCoverage() + { + IBrowserContext context = await Browser.NewContextAsync(); + await context.Tracing.StartAsync(new TracingStartOptions { Coverage = true }); + IPage page = await context.NewPageAsync(); + await page.GoToAsync(TestConstants.EmptyPage); + await page.EvaluateAsync( + @"coverage => { + window.__coverage__ = JSON.parse(coverage); + // The stash does not fit, the error record still does. + const setItem = Storage.prototype.setItem; + Storage.prototype.setItem = function(key, value) { + if (value.length > 100) + throw new DOMException('The quota has been exceeded.', 'QuotaExceededError'); + setItem.call(this, key, value); + }; + window.dispatchEvent(new Event('pagehide')); + }", + FileCoverageJson("a.js", 3)); + + PlaywrightException error = Assert.ThrowsAsync(() => context.Tracing.StopAsync(new() { Path = OutputPath("trace.zip") })); + Assert.That(error.Message, Does.Contain("Failed to stash the coverage: QuotaExceededError: The quota has been exceeded.")); + await context.CloseAsync(); + } + + private static string FileCoverageJson(string file, int s0) + => JsonSerializer.Serialize(new Dictionary + { + [file] = new + { + path = file, + statementMap = new Dictionary + { + ["0"] = new { start = new { line = 1, column = 0 }, end = new { line = 1, column = 20 } }, + }, + fnMap = new { }, + branchMap = new { }, + s = new Dictionary { ["0"] = s0 }, + f = new { }, + b = new { }, + }, + }); + + private static string CoverageScript(string file, int s0) + => ""; + + private static IstanbulCoverageChunk[] ToChunks(JsonElement value) + => JsonSerializer.Deserialize(value.GetRawText()); + + private static Dictionary ParseTraceCoverage(string file) + { + using ZipArchive zip = ZipFile.OpenRead(file); + ZipArchiveEntry[] entries = zip.Entries.Where(entry => Regex.IsMatch(entry.FullName, @"(^|-)trace\.coverage$")).ToArray(); + if (entries.Length == 0) + { + return null; + } + + Dictionary coverage = new(StringComparer.Ordinal); + foreach (ZipArchiveEntry entry in entries) + { + using StreamReader reader = new(entry.Open()); + IstanbulCoverage.Merge(coverage, IstanbulCoverage.Parse(reader.ReadToEnd())); + } + + return coverage; + } + + private static async Task OpenPopupAsync(IPage page, string file, int s0) + { + Task popupTask = page.WaitForPopupAsync(); + await Task.WhenAll( + popupTask, + page.EvaluateAsync("url => window.__popup = window.open(url)", TestConstants.EmptyPage)); + IPage popup = await popupTask; + await popup.EvaluateAsync("coverage => window.__coverage__ = JSON.parse(coverage)", FileCoverageJson(file, s0)); + return popup; + } + + private string OutputPath(string name) => Path.Combine(_outputDir, name); + } +} diff --git a/src/PlaywrightNative/Chromium/CRTracing.cs b/src/PlaywrightNative/Chromium/CRTracing.cs index 911a23d0..9d5f0451 100644 --- a/src/PlaywrightNative/Chromium/CRTracing.cs +++ b/src/PlaywrightNative/Chromium/CRTracing.cs @@ -50,10 +50,10 @@ internal CRTracing(CRSession session, IBrowserContext context) } /// - public Task StartAsync(PlaywrightNative.TracingStartOptions options) + public async Task StartAsync(PlaywrightNative.TracingStartOptions options) { OfficialTraceSession session = OfficialSession(); - session.Start(options, chunk: false); + await session.StartAsync(options, chunk: false).ConfigureAwait(false); // Keep an in-memory chrome-events buffer for Direct StopAsync(.json) paths // (groups / non-empty traceEvents). Official zip remains for .zip stops. @@ -65,8 +65,6 @@ public Task StartAsync(PlaywrightNative.TracingStartOptions options) _events.Add(ChromeTraceEvents.GroupBegin("__tracing__")); _events.Add(ChromeTraceEvents.GroupEnd("__tracing__")); } - - return Task.CompletedTask; } Task ITracing.StartAsync(Microsoft.Playwright.TracingStartOptions options) @@ -98,12 +96,12 @@ public async Task StartAsync(string categories = null) } /// - public Task StartChunkAsync(string name = default, string title = default) + public async Task StartChunkAsync(string name = default, string title = default) { OfficialTraceSession official = OfficialSessionOrNull(); if (official != null && official.IsRecording) { - official.Start(new TracingStartOptions { Name = name, Title = title }, chunk: true); + await official.StartAsync(new TracingStartOptions { Name = name, Title = title }, chunk: true).ConfigureAwait(false); lock (_gate) { _events.Clear(); @@ -113,7 +111,7 @@ public Task StartChunkAsync(string name = default, string title = default) _events.Add(ChromeTraceEvents.GroupEnd("__tracing__")); } - return Task.CompletedTask; + return; } _ = name; @@ -127,8 +125,6 @@ public Task StartChunkAsync(string name = default, string title = default) _events.Clear(); } - - return Task.CompletedTask; } /// diff --git a/src/PlaywrightNative/Contracts/Models/TracingStartOptions.cs b/src/PlaywrightNative/Contracts/Models/TracingStartOptions.cs index 1b8b44d0..29f39215 100644 --- a/src/PlaywrightNative/Contracts/Models/TracingStartOptions.cs +++ b/src/PlaywrightNative/Contracts/Models/TracingStartOptions.cs @@ -42,6 +42,7 @@ public TracingStartOptions(TracingStartOptions clone) ScreenSnapshots = clone.ScreenSnapshots; AriaSnapshots = clone.AriaSnapshots; + Coverage = clone.Coverage; } /// @@ -55,5 +56,11 @@ public TracingStartOptions(TracingStartOptions clone) /// with before/action/after phases. /// public bool? AriaSnapshots { get; set; } + + /// + /// Official hidden _coverage option. Collects istanbul + /// window.__coverage__ counters into the trace. + /// + internal bool? Coverage { get; set; } } } diff --git a/src/PlaywrightNative/Helpers/ActionTrace.cs b/src/PlaywrightNative/Helpers/ActionTrace.cs index c1ae4df8..48639b7a 100644 --- a/src/PlaywrightNative/Helpers/ActionTrace.cs +++ b/src/PlaywrightNative/Helpers/ActionTrace.cs @@ -35,7 +35,7 @@ internal static IDisposable SuppressRecording() return new SuppressScope(); } - internal static Task RunAsync(IBrowserContext context, string title, string className, string method, Func body, object parameters = null, object result = null) + internal static Task RunAsync(IBrowserContext context, string title, string className, string method, Func body, object parameters = null, object result = null, IPage page = null) { OfficialTraceSession session = OfficialTraceSession.Active(context); if (session == null || Suppress.Value > 0 || Depth.Value > 0) @@ -52,10 +52,10 @@ internal static Task RunAsync(IBrowserContext context, string title, string clas return body(); } - return RunInsideAsync(() => session.ContinueActionAsync(callId, body, result)); + return RunInsideAsync(() => session.ContinueActionAsync(callId, body, result, page)); } - internal static Task RunAsync(IBrowserContext context, string title, string className, string method, Func> body, object parameters = null, object result = null) + internal static Task RunAsync(IBrowserContext context, string title, string className, string method, Func> body, object parameters = null, object result = null, IPage page = null) { OfficialTraceSession session = OfficialTraceSession.Active(context); if (session == null || Suppress.Value > 0 || Depth.Value > 0) @@ -69,10 +69,10 @@ internal static Task RunAsync(IBrowserContext context, string title, strin return body(); } - return RunInsideAsync(() => session.ContinueActionAsync(callId, body, result)); + return RunInsideAsync(() => session.ContinueActionAsync(callId, body, result, page)); } - internal static Task EvaluateUserAsync(IBrowserContext context, Func> body, int consoleMessageCount = 0) + internal static Task EvaluateUserAsync(IBrowserContext context, Func> body, int consoleMessageCount = 0, IPage page = null) { if (body == null) { @@ -92,10 +92,10 @@ internal static Task EvaluateUserAsync(IBrowserContext context, Func session.ContinueActionAsync(callId, body, null)); + return RunInsideAsync(() => session.ContinueActionAsync(callId, body, null, page)); } - internal static Task EvaluateHandleUserAsync(IBrowserContext context, Func> body, int consoleMessageCount = 0) + internal static Task EvaluateHandleUserAsync(IBrowserContext context, Func> body, int consoleMessageCount = 0, IPage page = null) { if (body == null) { @@ -115,7 +115,7 @@ internal static Task EvaluateHandleUserAsync(IBrowserContext context, Func } session.NoteEvaluateConsoleBaseline(callId, consoleMessageCount); - return RunInsideAsync(() => session.ContinueActionAsync(callId, body, null)); + return RunInsideAsync(() => session.ContinueActionAsync(callId, body, null, page)); } internal static string NavigateTitle(string url) diff --git a/src/PlaywrightNative/Helpers/CoverageRecorder.cs b/src/PlaywrightNative/Helpers/CoverageRecorder.cs new file mode 100644 index 00000000..c5a6e0a6 --- /dev/null +++ b/src/PlaywrightNative/Helpers/CoverageRecorder.cs @@ -0,0 +1,286 @@ +/* + * Copyright (c) Microsoft Corporation. + * Modifications 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. + */ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Playwright; + +namespace PlaywrightNative.Helpers +{ + /// + /// Collects istanbul window.__coverage__ counters into the trace. + /// Port of packages/playwright-core/src/server/coverageRecorder.ts. + /// + internal sealed class CoverageRecorder + { + private const string TakeName = "__pwCoverageTake"; + + private const string TakeExpression = "window[\"" + TakeName + "\"] ? window[\"" + TakeName + "\"]() : []"; + + private const string DisposeExpression = "window[\"" + TakeName + "\"]?.dispose()"; + + private static readonly Lazy Source = new(LoadSource); + + private readonly object _gate = new(); + private readonly IBrowserContext _context; + private readonly string _sessionId; + private readonly Dictionary _coverage = new(StringComparer.Ordinal); + private readonly HashSet _stashedChunkIds = new(StringComparer.Ordinal); + private PlaywrightException _stashError; + private IAsyncDisposable _initScript; + + internal CoverageRecorder(IBrowserContext context, string sessionId) + { + _context = context; + _sessionId = JsonSerializer.Serialize(sessionId); + } + + internal async Task InstallAsync() + { + if (_initScript != null) + { + return; + } + + // The WebKit setContent replays the init scripts on the written document, + // a second instance would reset the counters the page already hit. + string source = ModuleExpression("if (!window[" + JsonSerializer.Serialize(TakeName) + "]) new (module.exports.CoverageScript())(window, " + JsonSerializer.Serialize(TakeName) + ", " + _sessionId + ");"); + using (ActionTrace.SuppressRecording()) + { + _initScript = await _context.AddInitScriptAsync(source).ConfigureAwait(false); + await EvaluateInAllFramesAsync(source).ConfigureAwait(false); + } + } + + internal async Task UninstallAsync() + { + using (ActionTrace.SuppressRecording()) + { + try + { + await Task.WhenAll( + _initScript?.DisposeAsync().AsTask() ?? Task.CompletedTask, + EvaluateInAllFramesAsync(DisposeExpression)).ConfigureAwait(false); + } + catch (PlaywrightException) + { + } + } + } + + /// + /// Returns the coverage collected since the last take, serialized, or + /// when there is none. + /// + /// Drops the collected coverage without pulling. + /// The coverage JSON. + internal async Task TakeAsync(bool discard) + { + if (discard) + { + lock (_gate) + { + _coverage.Clear(); + } + + return null; + } + + await FlushAsync().ConfigureAwait(false); + lock (_gate) + { + if (_stashError != null) + { + throw _stashError; + } + + _stashedChunkIds.Clear(); + if (_coverage.Count == 0) + { + return null; + } + + string json = IstanbulCoverage.Serialize(_coverage); + _coverage.Clear(); + return json; + } + } + + internal async Task CollectFromPageAsync(IPage page) + { + if (page == null || page.IsClosed) + { + return; + } + + using (ActionTrace.SuppressRecording()) + { + await Task.WhenAll(page.Frames.Select(async frame => + { + foreach (string json in await TakeFromFrameAsync(frame, TakeExpression).ConfigureAwait(false)) + { + Append(json); + } + })).ConfigureAwait(false); + } + } + + private static string ModuleExpression(string call) + => "(() => {\nconst module = {};\n" + Source.Value + "\n" + call + "\n})()"; + + private static string LoadSource() + { + Assembly assembly = typeof(CoverageRecorder).Assembly; + using Stream stream = assembly.GetManifestResourceStream("PlaywrightNative.Helpers.coverageScriptSource.js") + ?? throw new PlaywrightException("Bundled Playwright coverage script source is missing."); + using StreamReader reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + private async Task FlushAsync() + { + await Task.WhenAll(_context.Pages.Select(CollectFromPageAsync)).ConfigureAwait(false); + await HarvestOriginsWithoutPageAsync().ConfigureAwait(false); + } + + private async Task HarvestOriginsWithoutPageAsync() + { + if (_context is not IHasStorageStateInternals internals || _context.IsClosed) + { + return; + } + + HashSet liveOrigins = new(StringComparer.Ordinal); + foreach (IPage page in _context.Pages) + { + foreach (IFrame frame in page.Frames) + { + if (StorageStateHelper.TryGetHttpOrigin(frame.Url, out string origin)) + { + liveOrigins.Add(origin); + } + } + } + + // The origins that still have a page were drained by the sweep above. + HashSet origins = new(internals.VisitedOrigins.Where(origin => !liveOrigins.Contains(origin)), StringComparer.Ordinal); + if (origins.Count == 0) + { + return; + } + + string source = ModuleExpression("return (module.exports.takeCoverageStashes())(window, " + _sessionId + ");"); + using (ActionTrace.SuppressRecording()) + { + await StorageStateHelper.VisitOriginsAsync(_context, origins, async (frame, _) => + { + foreach (string json in await TakeFromFrameAsync(frame, source).ConfigureAwait(false)) + { + Append(json); + } + }).ConfigureAwait(false); + } + } + + private async Task TakeFromFrameAsync(IFrame frame, string expression) + { + try + { + return await frame.EvaluateAsync(expression).ConfigureAwait(false) ?? Array.Empty(); + } + catch (PlaywrightException ex) + { + return TakeFailed(ex); + } + catch (TimeoutException ex) + { + return TakeFailed(ex); + } + catch (InvalidOperationException ex) + { + return TakeFailed(ex); + } + } + + private Task EvaluateInAllFramesAsync(string expression) + => Task.WhenAll(_context.Pages.SelectMany(page => page.Frames).Select(async frame => + { + try + { + await frame.EvaluateAsync(expression).ConfigureAwait(false); + } + catch (PlaywrightException) + { + } + catch (TimeoutException) + { + } + catch (InvalidOperationException) + { + } + })); + + // Evaluation fails for pages on their way out, only the stash error matters. + private string[] TakeFailed(Exception error) + { + string message = error.Message ?? string.Empty; + int start = message.IndexOf(IstanbulCoverage.StashError, StringComparison.Ordinal); + if (start >= 0) + { + message = message.Substring(start).Split('\n')[0]; + lock (_gate) + { + _stashError ??= new PlaywrightException(message); + } + } + + return Array.Empty(); + } + + private void Append(string json) + { + IstanbulCoverageChunk chunk; + try + { + chunk = IstanbulCoverage.ParseChunk(json); + } + catch (JsonException) + { + return; + } + + if (chunk?.Data == null) + { + return; + } + + lock (_gate) + { + if (chunk.Id != null && !_stashedChunkIds.Add(chunk.Id)) + { + return; + } + + IstanbulCoverage.Merge(_coverage, chunk.Data); + } + } + } +} diff --git a/src/PlaywrightNative/Helpers/EmptyTracing.cs b/src/PlaywrightNative/Helpers/EmptyTracing.cs index 1aa94e5c..48d61f05 100644 --- a/src/PlaywrightNative/Helpers/EmptyTracing.cs +++ b/src/PlaywrightNative/Helpers/EmptyTracing.cs @@ -44,12 +44,12 @@ internal EmptyTracing(IBrowserContext context = null) } /// - public Task StartAsync(PlaywrightNative.TracingStartOptions options) + public async Task StartAsync(PlaywrightNative.TracingStartOptions options) { if (_context is IHasOfficialTrace host) { host.OfficialTrace ??= new OfficialTraceSession(_context); - host.OfficialTrace.Start(options, chunk: false); + await host.OfficialTrace.StartAsync(options, chunk: false).ConfigureAwait(false); // Also keep the chrome-events recorder so Direct StopAsync(.json) // can write traceEvents (groups) instead of an official zip. @@ -60,7 +60,7 @@ public Task StartAsync(PlaywrightNative.TracingStartOptions options) _openGroups.Clear(); } - return Task.CompletedTask; + return; } // Standalone APIRequest tracing has no browser context. Still start @@ -76,7 +76,7 @@ public Task StartAsync(PlaywrightNative.TracingStartOptions options) _openGroups.Clear(); } - return Task.CompletedTask; + return; } OwnOfficial().Start(options, chunk: false); @@ -86,8 +86,6 @@ public Task StartAsync(PlaywrightNative.TracingStartOptions options) _events.Clear(); _openGroups.Clear(); } - - return Task.CompletedTask; } Task ITracing.StartAsync(Microsoft.Playwright.TracingStartOptions options) @@ -108,12 +106,12 @@ public Task StartAsync(string categories = null) } /// - public Task StartChunkAsync(string name = default, string title = default) + public async Task StartChunkAsync(string name = default, string title = default) { OfficialTraceSession official = OfficialSessionOrNull(); if (official != null && official.IsRecording) { - official.Start(new TracingStartOptions { Name = name, Title = title }, chunk: true); + await official.StartAsync(new TracingStartOptions { Name = name, Title = title }, chunk: true).ConfigureAwait(false); lock (_gate) { _events.Clear(); @@ -121,12 +119,11 @@ public Task StartChunkAsync(string name = default, string title = default) _recording = true; } - return Task.CompletedTask; + return; } _ = name; _ = title; - return Task.CompletedTask; } /// diff --git a/src/PlaywrightNative/Helpers/IstanbulBranchMapping.cs b/src/PlaywrightNative/Helpers/IstanbulBranchMapping.cs new file mode 100644 index 00000000..6bb18f2a --- /dev/null +++ b/src/PlaywrightNative/Helpers/IstanbulBranchMapping.cs @@ -0,0 +1,34 @@ +/* + * Copyright (c) Microsoft Corporation. + * Modifications 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. + */ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PlaywrightNative.Helpers +{ + /// A branch mapping. Only the number of locations matters when merging. + internal sealed class IstanbulBranchMapping + { + /// Gets or sets the branch locations. + [JsonPropertyName("locations")] + public List Locations { get; set; } = new(); + + /// Gets or sets the other mapping fields, kept as reported. + [JsonExtensionData] + public Dictionary Extra { get; set; } + } +} diff --git a/src/PlaywrightNative/Helpers/IstanbulCoverage.cs b/src/PlaywrightNative/Helpers/IstanbulCoverage.cs new file mode 100644 index 00000000..17a43694 --- /dev/null +++ b/src/PlaywrightNative/Helpers/IstanbulCoverage.cs @@ -0,0 +1,137 @@ +/* + * Copyright (c) Microsoft Corporation. + * Modifications 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. + */ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PlaywrightNative.Helpers +{ + /// + /// The istanbul coverage format, mirrors the shapes from istanbul-lib-coverage. + /// Port of packages/isomorphic/istanbulCoverage.ts. + /// + internal static class IstanbulCoverage + { + /// Prefix of the localStorage keys that hold coverage stashes. + internal const string StashPrefix = "__pwCoverage."; + + /// Message prefix of the error raised when a stash could not be written. + internal const string StashError = "Failed to stash the coverage"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + /// Parses a coverage chunk reported by the page. + /// The chunk JSON. + /// The chunk. + internal static IstanbulCoverageChunk ParseChunk(string json) + => JsonSerializer.Deserialize(json, JsonOptions); + + /// Parses serialized coverage, e.g. a trace.coverage entry. + /// The coverage JSON. + /// The coverage keyed by file. + internal static Dictionary Parse(string json) + => JsonSerializer.Deserialize>(json, JsonOptions); + + /// Serializes sorted by file. + /// The coverage keyed by file. + /// The coverage JSON. + internal static string Serialize(Dictionary coverage) + => JsonSerializer.Serialize(Sorted(coverage), JsonOptions); + + /// Returns sorted by file. + /// The coverage keyed by file. + /// The sorted coverage. + internal static SortedDictionary Sorted(Dictionary coverage) + => new(coverage, StringComparer.Ordinal); + + /// Adds the counters of into . + /// The accumulated coverage. + /// A coverage delta, the maps are only present in the first report of a file. + internal static void Merge(Dictionary into, IReadOnlyDictionary data) + { + foreach (KeyValuePair item in data) + { + IstanbulFileCoverage fileCov = item.Value; + if (!into.TryGetValue(item.Key, out IstanbulFileCoverage existing)) + { + existing = new IstanbulFileCoverage { Path = fileCov.Path, StatementMap = new(), FnMap = new(), BranchMap = new() }; + into[item.Key] = existing; + } + + // The maps can arrive late if the report that carried them was lost. + if (fileCov.StatementMap != null && existing.StatementMap.Count == 0) + { + existing.StatementMap = fileCov.StatementMap; + existing.FnMap = fileCov.FnMap ?? new(); + existing.BranchMap = fileCov.BranchMap ?? new(); + + // Reports only carry the counters that were hit, zero fill the rest. + foreach (string key in existing.StatementMap.Keys) + { + existing.S.TryAdd(key, 0); + } + + foreach (string key in existing.FnMap.Keys) + { + existing.F.TryAdd(key, 0); + } + + foreach (KeyValuePair branch in existing.BranchMap) + { + existing.B.TryAdd(branch.Key, branch.Value.Locations.Select(_ => 0L).ToList()); + } + } + + foreach (KeyValuePair counter in fileCov.S ?? new()) + { + existing.S[counter.Key] = existing.S.GetValueOrDefault(counter.Key) + counter.Value; + } + + foreach (KeyValuePair counter in fileCov.F ?? new()) + { + existing.F[counter.Key] = existing.F.GetValueOrDefault(counter.Key) + counter.Value; + } + + foreach (KeyValuePair> counter in fileCov.B ?? new()) + { + if (!existing.B.TryGetValue(counter.Key, out List branches)) + { + branches = new List(); + existing.B[counter.Key] = branches; + } + + for (int i = 0; i < counter.Value.Count; i++) + { + if (i < branches.Count) + { + branches[i] += counter.Value[i]; + } + else + { + branches.Add(counter.Value[i]); + } + } + } + } + } + } +} diff --git a/src/PlaywrightNative/Helpers/IstanbulCoverageChunk.cs b/src/PlaywrightNative/Helpers/IstanbulCoverageChunk.cs new file mode 100644 index 00000000..fc889efa --- /dev/null +++ b/src/PlaywrightNative/Helpers/IstanbulCoverageChunk.cs @@ -0,0 +1,33 @@ +/* + * Copyright (c) Microsoft Corporation. + * Modifications 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. + */ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace PlaywrightNative.Helpers +{ + /// A coverage report of one document, or a stash it left behind. + internal sealed class IstanbulCoverageChunk + { + /// Gets or sets the coverage delta. + [JsonPropertyName("data")] + public Dictionary Data { get; set; } + + /// Gets or sets the stash id, set only for stashes. + [JsonPropertyName("id")] + public string Id { get; set; } + } +} diff --git a/src/PlaywrightNative/Helpers/IstanbulFileCoverage.cs b/src/PlaywrightNative/Helpers/IstanbulFileCoverage.cs new file mode 100644 index 00000000..bea60a77 --- /dev/null +++ b/src/PlaywrightNative/Helpers/IstanbulFileCoverage.cs @@ -0,0 +1,57 @@ +/* + * Copyright (c) Microsoft Corporation. + * Modifications 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. + */ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PlaywrightNative.Helpers +{ + /// + /// Coverage of one file. In a delta the maps are + /// unless it is the first report of the file. + /// + internal sealed class IstanbulFileCoverage + { + /// Gets or sets the file path. + [JsonPropertyName("path")] + public string Path { get; set; } + + /// Gets or sets the statement locations. + [JsonPropertyName("statementMap")] + public Dictionary StatementMap { get; set; } + + /// Gets or sets the function mappings. + [JsonPropertyName("fnMap")] + public Dictionary FnMap { get; set; } + + /// Gets or sets the branch mappings. + [JsonPropertyName("branchMap")] + public Dictionary BranchMap { get; set; } + + /// Gets or sets the statement counters. + [JsonPropertyName("s")] + public Dictionary S { get; set; } = new(); + + /// Gets or sets the function counters. + [JsonPropertyName("f")] + public Dictionary F { get; set; } = new(); + + /// Gets or sets the branch counters. + [JsonPropertyName("b")] + public Dictionary> B { get; set; } = new(); + } +} diff --git a/src/PlaywrightNative/Helpers/OfficialTraceSession.cs b/src/PlaywrightNative/Helpers/OfficialTraceSession.cs index 99a55820..104100aa 100644 --- a/src/PlaywrightNative/Helpers/OfficialTraceSession.cs +++ b/src/PlaywrightNative/Helpers/OfficialTraceSession.cs @@ -50,6 +50,8 @@ internal sealed class OfficialTraceSession private readonly List _pendingCaptures = new(); private readonly IBrowserContext _context; private readonly bool _apiOnly; + private readonly string _coverageSessionId = "browser-context@" + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + private CoverageRecorder _coverage; private TracingStartOptions _options; private bool _recording; private bool _networkAttached; @@ -120,6 +122,24 @@ internal bool AriaSnapshotsEnabled } } + /// + /// Gets the id that scopes the coverage stashes, official context.guid. + /// + internal string CoverageSessionId => _coverageSessionId; + + /// + /// Official instrumentation.onPageWillClose: pulls the coverage of + /// before it goes away. + /// + /// The owning context. + /// The page that is closing. + /// A task that completes once the coverage is pulled. + internal static Task OnPageWillCloseAsync(IBrowserContext context, IPage page) + { + CoverageRecorder recorder = (context as IHasOfficialTrace)?.OfficialTrace?.CoverageRecorderOrNull(); + return recorder == null ? Task.CompletedTask : recorder.CollectFromPageAsync(page); + } + /// /// Returns the official session on when it /// is recording. @@ -161,6 +181,7 @@ internal void Start(TracingStartOptions options, bool chunk) next.Snapshots ??= _options.Snapshots; next.ScreenSnapshots ??= _options.ScreenSnapshots; next.AriaSnapshots ??= _options.AriaSnapshots; + next.Coverage ??= _options.Coverage; next.Sources ??= _options.Sources; if (string.IsNullOrEmpty(next.Name)) { @@ -214,6 +235,10 @@ internal void Start(TracingStartOptions options, bool chunk) _name = string.IsNullOrEmpty(_options.Name) ? "trace" : _options.Name; WriteContextOptions(); + if (_options.Coverage == true && !_apiOnly && _context != null) + { + _coverage ??= new CoverageRecorder(_context, _coverageSessionId); + } } if (!chunk && !_apiOnly) @@ -222,6 +247,28 @@ internal void Start(TracingStartOptions options, bool chunk) } } + /// + /// Starts tracing or a new chunk, then installs the coverage script when + /// the _coverage option is set. + /// + /// The tracing options. + /// Whether this starts a new chunk. + /// A task that completes once tracing started. + internal async Task StartAsync(TracingStartOptions options, bool chunk) + { + Start(options, chunk); + CoverageRecorder recorder; + lock (_gate) + { + recorder = _options?.Coverage == true ? _coverage : null; + } + + if (recorder != null) + { + await recorder.InstallAsync().ConfigureAwait(false); + } + } + internal async Task RecordActionAsync(string title, string className, string method, Func body, object parameters = null, object result = null) { await RecordActionAsync( @@ -354,7 +401,7 @@ internal bool TryGetOpenEvaluateConsoleBaseline(out int baseline) /// /// Completes an action started with . /// - internal async Task ContinueActionAsync(string callId, Func body, object result = null) + internal async Task ContinueActionAsync(string callId, Func body, object result = null, IPage page = null) { await ContinueActionAsync( callId, @@ -363,13 +410,14 @@ await ContinueActionAsync( await body().ConfigureAwait(false); return null; }, - result).ConfigureAwait(false); + result, + page).ConfigureAwait(false); } /// /// Completes an action started with . /// - internal async Task ContinueActionAsync(string callId, Func> body, object result = null) + internal async Task ContinueActionAsync(string callId, Func> body, object result = null, IPage page = null) { if (body == null) { @@ -431,6 +479,7 @@ internal async Task ContinueActionAsync(string callId, Func> body, await CapturePhaseAsync(callId, "after", method).ConfigureAwait(false); await CaptureAfterActionAsync(callId, method).ConfigureAwait(false); + await CaptureCoverageAsync(page).ConfigureAwait(false); } return value; @@ -816,119 +865,43 @@ internal async Task CaptureAfterActionAsync(string callId = null, string method internal async Task StopAsync(string path, bool keepRecording) { - // Hard wall-clock so a stuck BodyAsync / zip write cannot hang - // StopChunk for the full NUnit budget (ShouldNotEmitAfterWithoutBefore - // under Windows headful suite load). - Task stop = StopInnerAsync(); - Task finished = await Task.WhenAny(stop, Task.Delay(5_000)).ConfigureAwait(false); - if (finished == stop) + // Collected before the chunk stops, while the pages can still be evaluated in. + PlaywrightException coverageError = null; + try { - await stop.ConfigureAwait(false); + await TakeCoverageAsync(discard: string.IsNullOrEmpty(path)).ConfigureAwait(false); } - - async Task StopInnerAsync() + catch (PlaywrightException ex) { - // Always bound-flush pending network body captures. Clearing them - // on StopChunk dropped CSS (ShouldRespectTracesDirAndName) while - // CaptureResourceAsync already caps BodyAsync at 500ms so this - // cannot wedge StopChunk the way an unbounded flush did - // (ShouldNotEmitAfterWithoutBefore). - await FlushPendingAsync().ConfigureAwait(false); + coverageError = ex; + } - List trace; - List network; - Dictionary resources; - string name; - string tracesDir; - lock (_gate) + // The chunk is torn down either way, the coverage error is reported after. + try + { + await StopChunkAsync(path, keepRecording).ConfigureAwait(false); + } + finally + { + if (!keepRecording) { - if (!_recording) - { - if (!string.IsNullOrEmpty(path)) - { - throw new PlaywrightException("Must start tracing before stopping"); - } - - return; - } - - CloseOpenGroupsLocked(); - foreach (string line in _consoleLines) - { - _traceLines.Add(line); - } - - _consoleLines.Clear(); - if (_wsLines.Count > 0) - { - _resources["resources/ws.jsonl"] = Encoding.UTF8.GetBytes(JoinLines(_wsLines)); - } - - if (_options?.Sources == true && _chunkCallIds.Count > 0) - { - _resources["src/0000000000000000000000000000000000000000.ts"] = Encoding.UTF8.GetBytes("// source"); - } - - EnsureReferencedStylesheet(); - - trace = new List(_traceLines); - network = new List(_networkLines); - resources = new Dictionary(_resources, StringComparer.Ordinal); - foreach (KeyValuePair item in _networkResources) + CoverageRecorder recorder; + lock (_gate) { - resources[item.Key] = item.Value; + recorder = _coverage; + _coverage = null; } - name = _name; - tracesDir = ResolveTracesDir(); - if (_stacks.Count > 0) + if (recorder != null) { - var stackMap = new Dictionary(StringComparer.Ordinal); - foreach (KeyValuePair stack in _stacks) - { - stackMap[stack.Key] = new[] - { - new Dictionary { ["file"] = stack.Value }, - }; - } - - resources["trace.stacks"] = Encoding.UTF8.GetBytes(Serialize(stackMap)); + await recorder.UninstallAsync().ConfigureAwait(false); } - - _traceLines.Clear(); - _resources.Clear(); - _chunkCallIds.Clear(); - _callMethods.Clear(); - _openEvaluateConsoleBaselines.Clear(); - _wsLines.Clear(); - _stacks.Clear(); - if (!keepRecording) - { - _recording = false; - _options = null; - _networkLines.Clear(); - _networkResources.Clear(); - _sessionWsOpenLines.Clear(); - } - else - { - WriteContextOptions(); - } - } - - if (!keepRecording) - { - DetachNetwork(); - } - - WriteTracesDirFiles(tracesDir, name, trace, network, resources); - - if (string.IsNullOrEmpty(path)) - { - return; } + } - await WriteZipAsync(path, trace, network, resources).ConfigureAwait(false); + if (coverageError != null) + { + throw coverageError; } } @@ -1115,6 +1088,169 @@ private static async Task WriteEntryAsync(ZipArchive zip, string name, string te await stream.WriteAsync(bytes).ConfigureAwait(false); } + private async Task StopChunkAsync(string path, bool keepRecording) + { + // Hard wall-clock so a stuck BodyAsync / zip write cannot hang + // StopChunk for the full NUnit budget (ShouldNotEmitAfterWithoutBefore + // under Windows headful suite load). + Task stop = StopInnerAsync(); + Task finished = await Task.WhenAny(stop, Task.Delay(5_000)).ConfigureAwait(false); + if (finished == stop) + { + await stop.ConfigureAwait(false); + } + + async Task StopInnerAsync() + { + // Always bound-flush pending network body captures. Clearing them + // on StopChunk dropped CSS (ShouldRespectTracesDirAndName) while + // CaptureResourceAsync already caps BodyAsync at 500ms so this + // cannot wedge StopChunk the way an unbounded flush did + // (ShouldNotEmitAfterWithoutBefore). + await FlushPendingAsync().ConfigureAwait(false); + + List trace; + List network; + Dictionary resources; + string name; + string tracesDir; + lock (_gate) + { + if (!_recording) + { + if (!string.IsNullOrEmpty(path)) + { + throw new PlaywrightException("Must start tracing before stopping"); + } + + return; + } + + CloseOpenGroupsLocked(); + foreach (string line in _consoleLines) + { + _traceLines.Add(line); + } + + _consoleLines.Clear(); + if (_wsLines.Count > 0) + { + _resources["resources/ws.jsonl"] = Encoding.UTF8.GetBytes(JoinLines(_wsLines)); + } + + if (_options?.Sources == true && _chunkCallIds.Count > 0) + { + _resources["src/0000000000000000000000000000000000000000.ts"] = Encoding.UTF8.GetBytes("// source"); + } + + EnsureReferencedStylesheet(); + + trace = new List(_traceLines); + network = new List(_networkLines); + resources = new Dictionary(_resources, StringComparer.Ordinal); + foreach (KeyValuePair item in _networkResources) + { + resources[item.Key] = item.Value; + } + + name = _name; + tracesDir = ResolveTracesDir(); + if (_stacks.Count > 0) + { + var stackMap = new Dictionary(StringComparer.Ordinal); + foreach (KeyValuePair stack in _stacks) + { + stackMap[stack.Key] = new[] + { + new Dictionary { ["file"] = stack.Value }, + }; + } + + resources["trace.stacks"] = Encoding.UTF8.GetBytes(Serialize(stackMap)); + } + + _traceLines.Clear(); + _resources.Clear(); + _chunkCallIds.Clear(); + _callMethods.Clear(); + _openEvaluateConsoleBaselines.Clear(); + _wsLines.Clear(); + _stacks.Clear(); + if (!keepRecording) + { + _recording = false; + _options = null; + _networkLines.Clear(); + _networkResources.Clear(); + _sessionWsOpenLines.Clear(); + } + else + { + WriteContextOptions(); + } + } + + if (!keepRecording) + { + DetachNetwork(); + } + + WriteTracesDirFiles(tracesDir, name, trace, network, resources); + + if (string.IsNullOrEmpty(path)) + { + return; + } + + await WriteZipAsync(path, trace, network, resources).ConfigureAwait(false); + } + } + + private CoverageRecorder CoverageRecorderOrNull() + { + lock (_gate) + { + return _coverage; + } + } + + private async Task CaptureCoverageAsync(IPage page) + { + CoverageRecorder recorder = CoverageRecorderOrNull(); + if (recorder != null && page != null) + { + await recorder.CollectFromPageAsync(page).ConfigureAwait(false); + } + } + + private async Task TakeCoverageAsync(bool discard) + { + CoverageRecorder recorder; + lock (_gate) + { + recorder = _recording ? _coverage : null; + } + + if (recorder == null) + { + return; + } + + string json = await recorder.TakeAsync(discard).ConfigureAwait(false); + if (json == null) + { + return; + } + + lock (_gate) + { + if (_recording) + { + _resources["trace.coverage"] = Encoding.UTF8.GetBytes(json); + } + } + } + private void WriteStack(string callId) { _stacks[callId] = "tracing.spec.ts"; diff --git a/src/PlaywrightNative/Helpers/PageDialogTracker.cs b/src/PlaywrightNative/Helpers/PageDialogTracker.cs index efb93fec..0b678407 100644 --- a/src/PlaywrightNative/Helpers/PageDialogTracker.cs +++ b/src/PlaywrightNative/Helpers/PageDialogTracker.cs @@ -251,7 +251,8 @@ public Task AcceptAsync(string promptText = default) "Accept dialog", "Dialog", "accept", - () => AcceptSilentAsync(promptText)); + () => AcceptSilentAsync(promptText), + page: Page); } public async Task DismissAsync() diff --git a/src/PlaywrightNative/Helpers/PageWaitForEventHelper.cs b/src/PlaywrightNative/Helpers/PageWaitForEventHelper.cs index 63cdb53e..a4157910 100644 --- a/src/PlaywrightNative/Helpers/PageWaitForEventHelper.cs +++ b/src/PlaywrightNative/Helpers/PageWaitForEventHelper.cs @@ -441,7 +441,8 @@ private static async Task WaitConsoleEventAsync( "Wait for event \"console\"", "Page", "waitForEvent", - () => consoleWait).ConfigureAwait(false); + () => consoleWait, + page: page).ConfigureAwait(false); } private static int SyncConsoleMessageCount(IPage page) diff --git a/src/PlaywrightNative/Helpers/StorageStateHelper.cs b/src/PlaywrightNative/Helpers/StorageStateHelper.cs index e01436f4..f458bb14 100644 --- a/src/PlaywrightNative/Helpers/StorageStateHelper.cs +++ b/src/PlaywrightNative/Helpers/StorageStateHelper.cs @@ -36,6 +36,9 @@ internal static class StorageStateHelper const items = []; for (let i = 0; i < localStorage.length; i++) { const name = localStorage.key(i); + if (name.startsWith('__pwCoverage.')) { + continue; + } const value = localStorage.getItem(name) || ''; const codes = []; for (let j = 0; j < value.length; j++) { @@ -263,6 +266,63 @@ await ApplyCredentialsAsync( } } + /// + /// Official browserContext.visitOrigins: opens a blank internal + /// page and calls on its main frame at each + /// of . + /// + /// The context to visit the origins in. + /// The origins to visit. + /// Called with the main frame and the origin. + /// A task that completes once every origin was visited. + internal static async Task VisitOriginsAsync(IBrowserContext context, IEnumerable origins, Func callback) + { + IHasStorageStateInternals flag = context as IHasStorageStateInternals; + if (flag != null) + { + flag.CreatingStorageStatePage = true; + } + + IPage probe = null; + try + { + probe = await context.NewPageAsync().ConfigureAwait(false); + + // Await fulfill so GoTo cannot race a fire-and-forget handler and + // hang until the default 30s navigation timeout (seen on Windows headful). + await probe.RouteAsync("**/*", async route => + { + await route.FulfillAsync(new() { Body = "" }).ConfigureAwait(false); + }).ConfigureAwait(false); + + foreach (string origin in origins) + { + await probe.GoToAsync(origin).ConfigureAwait(false); + await callback(probe.MainFrame, origin).ConfigureAwait(false); + } + } + finally + { + if (probe != null) + { + try + { + await probe.CloseAsync().ConfigureAwait(false); + } +#pragma warning disable RCS1075 + catch (Exception) +#pragma warning restore RCS1075 + { + } + } + + if (flag != null) + { + flag.CreatingStorageStatePage = false; + } + } + } + private static List CopyCredentials(IReadOnlyList source) { List copy = new(); @@ -477,61 +537,18 @@ private static async Task> CollectOriginsAsync(IBrowser return result; } - IHasStorageStateInternals flag = context as IHasStorageStateInternals; - if (flag != null) - { - flag.CreatingStorageStatePage = true; - } - - IPage probe = null; - try + await VisitOriginsAsync(context, originsToSave, async (frame, origin) => { - probe = await context.NewPageAsync().ConfigureAwait(false); - - // Await fulfill so GoTo cannot race a fire-and-forget handler and - // hang until the default 30s navigation timeout (seen on Windows headful). - await probe.RouteAsync("**/*", async route => + StorageStateOrigin collected = await CollectFromFrameAsync(frame, origin, includeIndexedDB).ConfigureAwait(false); + if (collected != null) { - await route.FulfillAsync(new() { Body = "" }).ConfigureAwait(false); - }).ConfigureAwait(false); - - foreach (string origin in originsToSave) - { - await probe.GoToAsync(origin).ConfigureAwait(false); - StorageStateOrigin collected = await CollectFromPageAsync(probe, origin, includeIndexedDB).ConfigureAwait(false); - if (collected != null) - { - result.Add(collected); - } - } - } - finally - { - if (probe != null) - { - try - { - await probe.CloseAsync().ConfigureAwait(false); - } -#pragma warning disable RCS1075 - catch (Exception) -#pragma warning restore RCS1075 - { - } + result.Add(collected); } - - if (flag != null) - { - flag.CreatingStorageStatePage = false; - } - } + }).ConfigureAwait(false); return result; } - private static Task CollectFromPageAsync(IPage page, string origin, bool includeIndexedDB) - => CollectFromFrameAsync(page?.MainFrame ?? page as IFrame, origin, includeIndexedDB); - private static async Task CollectFromFrameAsync(IFrame frame, string origin, bool includeIndexedDB) { if (frame == null) diff --git a/src/PlaywrightNative/Helpers/coverageScriptSource.js b/src/PlaywrightNative/Helpers/coverageScriptSource.js new file mode 100644 index 00000000..810fb703 --- /dev/null +++ b/src/PlaywrightNative/Helpers/coverageScriptSource.js @@ -0,0 +1,170 @@ +/** + * Copyright (c) Microsoft Corporation. + * Modifications 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. + */ + +// Port of packages/injected/src/coverageScript.ts and the counter helpers of +// packages/isomorphic/istanbulCoverage.ts. Expects a `module` object in scope. +(() => { + const kCoverageStashPrefix = '__pwCoverage.'; + const kCoverageStashError = 'Failed to stash the coverage'; + + function takeCounters(counters) { + let result; + for (const key of Object.keys(counters)) { + const count = counters[key]; + if (!count) + continue; + if (!result) + result = {}; + result[key] = count; + counters[key] = 0; + } + return result; + } + + // Branch counters are positional, so a hit branch is reported with the whole array. + function takeBranchCounters(counters) { + let result; + for (const key of Object.keys(counters)) { + const counts = counters[key]; + if (!counts.some(Boolean)) + continue; + if (!result) + result = {}; + result[key] = counts.slice(); + counts.fill(0); + } + return result; + } + + function stashErrorKey(sessionId) { + return kCoverageStashPrefix + 'error.' + sessionId; + } + + // Stashes of other sessions are stale, e.g. left by a previous run in a persistent profile. + function takeCoverageStashes(global, sessionId) { + const result = []; + let storage; + try { + storage = global.localStorage; + } catch { + return result; + } + const error = storage.getItem(stashErrorKey(sessionId)); + if (error) { + storage.removeItem(stashErrorKey(sessionId)); + throw new Error(kCoverageStashError + ': ' + error); + } + const sessionPrefix = kCoverageStashPrefix + sessionId + '.'; + const keys = []; + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + if (key && key.startsWith(kCoverageStashPrefix)) + keys.push(key); + } + for (const key of keys) { + const json = key.startsWith(sessionPrefix) ? storage.getItem(key) : undefined; + storage.removeItem(key); + if (json) + result.push(json); + } + return result; + } + + // Reports `__coverage__` as a delta: reading resets the counters, maps are sent once per file. + class CoverageScript { + constructor(global, takeName, sessionId) { + this._global = global; + this._takeName = takeName; + this._sessionId = sessionId; + this._reportedFiles = new Set(); + this._onPageHide = () => this._stashCurrent(); + global[takeName] = Object.assign(() => this.take(), { dispose: () => this.dispose() }); + this._resetCounters(); + // Counters die with the document and unload-time calls are not delivered. + global.addEventListener('pagehide', this._onPageHide); + } + + _resetCounters() { + const coverage = this._global.__coverage__; + for (const fileCoverage of Object.values(coverage || {})) { + takeCounters(fileCoverage.s); + takeCounters(fileCoverage.f); + takeBranchCounters(fileCoverage.b); + } + } + + dispose() { + this._global.removeEventListener('pagehide', this._onPageHide); + delete this._global[this._takeName]; + } + + take() { + const chunks = takeCoverageStashes(this._global, this._sessionId); + const delta = this._takeCurrent(); + if (delta) + chunks.push(JSON.stringify({ data: delta })); + return chunks; + } + + _takeCurrent() { + const coverage = this._global.__coverage__; + if (!coverage) + return undefined; + const delta = {}; + for (const [file, fileCoverage] of Object.entries(coverage)) { + const s = takeCounters(fileCoverage.s); + const f = takeCounters(fileCoverage.f); + const b = takeBranchCounters(fileCoverage.b); + // Every file is reported once, so that the never hit ones are accounted for. + const isFirstReport = !this._reportedFiles.has(file); + if (!isFirstReport && !s && !f && !b) + continue; + const entry = { path: fileCoverage.path, s: s || {}, f: f || {}, b: b || {} }; + if (isFirstReport) { + entry.statementMap = fileCoverage.statementMap; + entry.fnMap = fileCoverage.fnMap; + entry.branchMap = fileCoverage.branchMap; + this._reportedFiles.add(file); + } + delta[file] = entry; + } + return Object.keys(delta).length ? delta : undefined; + } + + _stashCurrent() { + const delta = this._takeCurrent(); + if (!delta) + return; + // Several documents can pick up the same stash, the id discards the copies. + const id = Math.random().toString(36).slice(2); + try { + this._global.localStorage.setItem(kCoverageStashPrefix + this._sessionId + '.' + id, JSON.stringify({ id, data: delta })); + } catch (error) { + // The counters are lost, the next take surfaces the error. + try { + this._global.localStorage.setItem(stashErrorKey(this._sessionId), String(error)); + } catch { + } + } + } + } + + module.exports = { + CoverageScript: () => CoverageScript, + takeCoverageStashes: () => takeCoverageStashes, + }; +})(); diff --git a/src/PlaywrightNative/Locator.cs b/src/PlaywrightNative/Locator.cs index 0c319fff..c5aab7a9 100644 --- a/src/PlaywrightNative/Locator.cs +++ b/src/PlaywrightNative/Locator.cs @@ -902,7 +902,8 @@ await ActionTrace.RunAsync( { IElementHandle handle = await WaitForHandleAsync(timeout, "locator.setInputFiles").ConfigureAwait(false); await handle.SetInputFilesAsync(files, noWaitAfter, timeout).ConfigureAwait(false); - }).ConfigureAwait(false); + }, + page: _frame.Page).ConfigureAwait(false); } /// diff --git a/src/PlaywrightNative/Page.cs b/src/PlaywrightNative/Page.cs index 5b018218..80f42f1d 100644 --- a/src/PlaywrightNative/Page.cs +++ b/src/PlaywrightNative/Page.cs @@ -497,7 +497,8 @@ public Task ClickAsync(string selector, MouseButton button = default, int? click (_context as IHasPlaywrightLogger)?.Logger, "page.click", () => ClickAction.RunOnSelectorAsync(sel => QueryActionAsync(sel, strict), selector, h => h.ClickAsync(button, clickCount, delay, position, modifiers, force, noWaitAfter, timeout, trial, scroll, steps), timeout, "page.click", scroll)), - new Dictionary { ["selector"] = selector }); + new Dictionary { ["selector"] = selector }, + page: this); /// public async Task CloseAsync(bool? runBeforeUnload = default, string reason = default) @@ -520,6 +521,7 @@ await ActionTrace.RunAsync(_context, "Close page", "Page", "close", async () => _closeReason = reason; _crPage.Session.CloseReason = reason; + await OfficialTraceSession.OnPageWillCloseAsync(_context, this).ConfigureAwait(false); bool runUnload = runBeforeUnload ?? false; try { @@ -563,7 +565,8 @@ public Task DblClickAsync(string selector, MouseButton button = default, float? "Double click " + ActionTrace.LocatorLabel(selector), "Page", "dblclick", - () => ClickAction.RunOnSelectorAsync(sel => QueryActionAsync(sel, strict), selector, h => h.DblClickAsync(button, delay, position, modifiers, force, noWaitAfter, timeout, trial, scroll), timeout, "page.dblclick", scroll)); + () => ClickAction.RunOnSelectorAsync(sel => QueryActionAsync(sel, strict), selector, h => h.DblClickAsync(button, delay, position, modifiers, force, noWaitAfter, timeout, trial, scroll), timeout, "page.dblclick", scroll), + page: this); /// public Task DragAndDropAsync( @@ -684,7 +687,8 @@ public Task EmulateVisionDeficiencyAsync(VisionDeficiency type = default) : EvaluateWithArg.Wrap(expression, arg); return EvaluateSerializedAsync(toEval); }, - consoleBaseline); + consoleBaseline, + page: this); } /// @@ -706,7 +710,8 @@ public Task EvaluateAsync(string expression, object arg = default) : EvaluateWithArg.Wrap(expression, arg); return EvaluateSerializedAsync(toEval); }, - consoleBaseline); + consoleBaseline, + page: this); } /// @@ -730,7 +735,8 @@ public Task EvaluateHandleAsync(string expression, object arg = defau CRJSHandle handle = await _crPage.EvaluateHandleInternalAsync(toEval).ConfigureAwait(false); return WrapJSHandle(handle); }, - consoleBaseline); + consoleBaseline, + page: this); } /// @@ -937,7 +943,7 @@ public async Task GoToAsync(string url, WaitUntilState waitUntil = de { url = NavigationTimeout.CompleteUserUrl(NavigationUrl.Resolve(Context, url)); IResponse result = null; - await ActionTrace.RunAsync(_context, ActionTrace.NavigateTitle(url), "Page", "goto", async () => + await ActionTrace.RunAsync(_context, ActionTrace.NavigateTitle(url), "Page", "goto", page: this, body: async () => { int timeoutMs = NavigationTimeout.ResolveMs( timeout, @@ -957,7 +963,7 @@ await ActionTrace.RunAsync(_context, ActionTrace.NavigateTitle(url), "Page", "go public async Task ReloadAsync(WaitUntilState waitUntil = default, float? timeout = default) { IResponse result = null; - await ActionTrace.RunAsync(_context, null, "Page", "reload", async () => + await ActionTrace.RunAsync(_context, null, "Page", "reload", page: this, body: async () => { int timeoutMs = timeout.HasValue ? (int)timeout.Value : (int)_defaultNavigationTimeout; CRResponse captured = await _crPage.ReloadAsync(waitUntil, timeoutMs).ConfigureAwait(false); @@ -1029,7 +1035,8 @@ public Task HoverAsync(string selector, Position position = default, IEnumerable ActionTrace.HoverTitle(selector), "Page", "hover", - () => ElementQuery.WaitRunAsync(sel => QueryActionAsync(sel, strict), selector, h => h.HoverAsync(position, modifiers, force, timeout, trial, scroll), timeout, "page.hover", scroll)); + () => ElementQuery.WaitRunAsync(sel => QueryActionAsync(sel, strict), selector, h => h.HoverAsync(position, modifiers, force, timeout, trial, scroll), timeout, "page.hover", scroll), + page: this); /// public Task InnerHTMLAsync(string selector, float? timeout = default, bool? strict = default) @@ -1351,7 +1358,8 @@ await ActionTrace.RunAsync( PathIo.WriteBytes(path, bytes); } }, - result: new Dictionary { ["binary"] = "" }).ConfigureAwait(false); + result: new Dictionary { ["binary"] = "" }, + page: this).ConfigureAwait(false); return bytes; } @@ -1471,7 +1479,8 @@ public Task SetContentAsync(string html, float? timeout = default, WaitUntilStat () => PlaywrightApiLog.RunAsync( (_context as IHasPlaywrightLogger)?.Logger, "page.setContent", - () => _crPage.SetContentAsync(html, waitUntil, timeoutMs))); + () => _crPage.SetContentAsync(html, waitUntil, timeoutMs)), + page: this); } /// @@ -1676,7 +1685,7 @@ public Task RemoveAllListenersAsync(string type = null, RemoveAllListenersBehavi /// public Task WaitForTimeoutAsync(float timeout) - => ActionTrace.RunAsync(_context, "Wait for timeout", "Page", "waitForTimeout", () => Task.Delay((int)timeout)); + => ActionTrace.RunAsync(_context, "Wait for timeout", "Page", "waitForTimeout", () => Task.Delay((int)timeout), page: this); /// public Task WaitForURLAsync(string urlString, Regex urlRegex, Func urlFunc, float? timeout = default, WaitUntilState waitUntil = default) diff --git a/src/PlaywrightNative/PlaywrightNative.csproj b/src/PlaywrightNative/PlaywrightNative.csproj index 215a5c1f..51382ef4 100644 --- a/src/PlaywrightNative/PlaywrightNative.csproj +++ b/src/PlaywrightNative/PlaywrightNative.csproj @@ -40,5 +40,6 @@ + diff --git a/src/PlaywrightNative/WebKit/WKPage.cs b/src/PlaywrightNative/WebKit/WKPage.cs index b9853b40..2fc98b63 100644 --- a/src/PlaywrightNative/WebKit/WKPage.cs +++ b/src/PlaywrightNative/WebKit/WKPage.cs @@ -613,6 +613,7 @@ await ActionTrace.RunAsync(Context, "Close page", "Page", "close", async () => } ApplyCloseReason(reason); + await OfficialTraceSession.OnPageWillCloseAsync(Context, this).ConfigureAwait(false); bool runUnload = runBeforeUnload ?? false; if (!runUnload) { @@ -742,7 +743,8 @@ public Task ClickAsync(string selector, MouseButton button = default, int? click _context?.Logger, "page.click", () => ClickAction.RunOnSelectorAsync(sel => QueryActionAsync(sel, strict), selector, h => h.ClickAsync(button, clickCount, delay, position, modifiers, force, noWaitAfter, timeout, trial, scroll, steps), timeout, "page.click", scroll)), - new Dictionary { ["selector"] = selector }); + new Dictionary { ["selector"] = selector }, + page: this); /// public Task DblClickAsync(string selector, MouseButton button = default, float? delay = null, Position position = null, IEnumerable modifiers = null, bool? force = null, bool? noWaitAfter = null, float? timeout = null, bool? trial = null, ActionScroll scroll = default, bool? strict = default) @@ -751,7 +753,8 @@ public Task DblClickAsync(string selector, MouseButton button = default, float? "Double click " + ActionTrace.LocatorLabel(selector), "Page", "dblclick", - () => ClickAction.RunOnSelectorAsync(sel => QueryActionAsync(sel, strict), selector, h => h.DblClickAsync(button, delay, position, modifiers, force, noWaitAfter, timeout, trial, scroll), timeout, "page.dblclick", scroll)); + () => ClickAction.RunOnSelectorAsync(sel => QueryActionAsync(sel, strict), selector, h => h.DblClickAsync(button, delay, position, modifiers, force, noWaitAfter, timeout, trial, scroll), timeout, "page.dblclick", scroll), + page: this); /// public Task EmulateMediaAsync(ColorScheme? colorScheme) @@ -837,7 +840,8 @@ public Task EvaluateAsync(string expression, object arg = null) : EvaluateWithArg.Wrap(expression, arg); return EvaluateSerializedAsync(toEval); }, - consoleBaseline); + consoleBaseline, + page: this); } /// @@ -874,7 +878,8 @@ public Task EvaluateAsync(string expression, object arg = null) : EvaluateWithArg.Wrap(expression, arg); return EvaluateSerializedAsync(toEval); }, - consoleBaseline); + consoleBaseline, + page: this); } /// @@ -928,7 +933,8 @@ public Task EvaluateHandleAsync(string expression, object arg = null) JsonElement? handleValue = await context.EvaluateHandleAsync(toEval).ConfigureAwait(false); return WrapRemoteObject(context, handleValue); }, - consoleBaseline); + consoleBaseline, + page: this); } /// @@ -1146,7 +1152,7 @@ public async Task GoToAsync(string url, WaitUntilState waitUntil = de url = NavigationTimeout.CompleteUserUrl(NavigationUrl.Resolve(Context, url)); IResponse result = null; - await ActionTrace.RunAsync(Context, ActionTrace.NavigateTitle(url), "Page", "goto", async () => + await ActionTrace.RunAsync(Context, ActionTrace.NavigateTitle(url), "Page", "goto", page: this, body: async () => { referer = NavigationTimeout.ReferrerFromExtraHeaders(referer, _extraHttpHeaders); NavigationTimeout.ThrowIfRefererConflict(url, referer, _extraHttpHeaders); @@ -1217,7 +1223,7 @@ void OnResponse(object sender, IResponse response) public async Task ReloadAsync(WaitUntilState waitUntil = default, float? timeout = default) { IResponse result = null; - await ActionTrace.RunAsync(Context, null, "Page", "reload", async () => + await ActionTrace.RunAsync(Context, null, "Page", "reload", page: this, body: async () => { if (_crashed) { @@ -1326,7 +1332,8 @@ public Task HoverAsync(string selector, Position position = null, IEnumerable ElementQuery.WaitRunAsync(sel => QueryActionAsync(sel, strict), selector, h => h.HoverAsync(position, modifiers, force, timeout, trial, scroll), timeout, "page.hover", scroll)); + () => ElementQuery.WaitRunAsync(sel => QueryActionAsync(sel, strict), selector, h => h.HoverAsync(position, modifiers, force, timeout, trial, scroll), timeout, "page.hover", scroll), + page: this); /// public Task InnerHTMLAsync(string selector, float? timeout = null, bool? strict = default) @@ -1585,7 +1592,8 @@ async Task CaptureRectAsync() "Page", "screenshot", () => ScreenshotTimeout.RunAsync(timeout, CaptureAsync), - result: new Dictionary { ["binary"] = "" }); + result: new Dictionary { ["binary"] = "" }, + page: this); } /// @@ -1718,7 +1726,8 @@ public Task SetContentAsync(string html, float? timeout = default, WaitUntilStat () => PlaywrightApiLog.RunAsync( _context?.Logger, "page.setContent", - () => SetContentInternalAsync(html, timeout, waitUntil))); + () => SetContentInternalAsync(html, timeout, waitUntil)), + page: this); /// Sets . /// The timeout. @@ -1920,7 +1929,7 @@ public Task RemoveAllListenersAsync(string type = null, RemoveAllListenersBehavi /// public Task WaitForTimeoutAsync(float timeout) - => ActionTrace.RunAsync(Context, "Wait for timeout", "Page", "waitForTimeout", () => Task.Delay((int)timeout)); + => ActionTrace.RunAsync(Context, "Wait for timeout", "Page", "waitForTimeout", () => Task.Delay((int)timeout), page: this); /// public Task WaitForURLAsync(string urlString, Regex urlRegex, Func urlFunc, float? timeout = default, WaitUntilState waitUntil = default)