From 99d17d19bbdbf1dadd6cc613cc864f8df6ecc8d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dar=C3=ADo=20Kondratiuk?= Date: Mon, 5 Oct 2026 20:43:52 -0300 Subject: [PATCH 1/4] Port istanbul coverage collection into traces Tracing with the hidden coverage option now installs a page script that reports window.__coverage__ as deltas. Counters are pulled after each action and before page close, stashed in localStorage on pagehide, and harvested from origins left without a page when the chunk stops. The merged coverage lands in the trace as trace.coverage. Co-Authored-By: Claude Opus 5.5 --- .../LibraryCoverageParityTests.cs | 443 ++++++++++++++++++ src/PlaywrightNative/Chromium/CRTracing.cs | 14 +- .../Contracts/Models/TracingStartOptions.cs | 7 + .../Helpers/CoverageRecorder.cs | 289 ++++++++++++ src/PlaywrightNative/Helpers/EmptyTracing.cs | 17 +- .../Helpers/IstanbulBranchMapping.cs | 34 ++ .../Helpers/IstanbulCoverage.cs | 137 ++++++ .../Helpers/IstanbulCoverageChunk.cs | 33 ++ .../Helpers/IstanbulFileCoverage.cs | 57 +++ .../Helpers/OfficialTraceSession.cs | 337 +++++++++---- .../Helpers/StorageStateHelper.cs | 113 +++-- .../Helpers/coverageScriptSource.js | 170 +++++++ src/PlaywrightNative/Page.cs | 1 + src/PlaywrightNative/PlaywrightNative.csproj | 1 + src/PlaywrightNative/WebKit/WKPage.cs | 1 + 15 files changed, 1486 insertions(+), 168 deletions(-) create mode 100644 src/PlaywrightNative.Tests/LibraryCoverageParityTests.cs create mode 100644 src/PlaywrightNative/Helpers/CoverageRecorder.cs create mode 100644 src/PlaywrightNative/Helpers/IstanbulBranchMapping.cs create mode 100644 src/PlaywrightNative/Helpers/IstanbulCoverage.cs create mode 100644 src/PlaywrightNative/Helpers/IstanbulCoverageChunk.cs create mode 100644 src/PlaywrightNative/Helpers/IstanbulFileCoverage.cs create mode 100644 src/PlaywrightNative/Helpers/coverageScriptSource.js diff --git a/src/PlaywrightNative.Tests/LibraryCoverageParityTests.cs b/src/PlaywrightNative.Tests/LibraryCoverageParityTests.cs new file mode 100644 index 00000000..b34f8c52 --- /dev/null +++ b/src/PlaywrightNative.Tests/LibraryCoverageParityTests.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 LibraryCoverageParityTests : 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/CoverageRecorder.cs b/src/PlaywrightNative/Helpers/CoverageRecorder.cs new file mode 100644 index 00000000..045eafaa --- /dev/null +++ b/src/PlaywrightNative/Helpers/CoverageRecorder.cs @@ -0,0 +1,289 @@ +/* + * 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 Task CollectFromAllPagesAsync() + => Task.WhenAll(_context.Pages.Select(CollectFromPageAsync)); + + 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 CollectFromAllPagesAsync().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..1a06d809 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( @@ -431,6 +478,7 @@ internal async Task ContinueActionAsync(string callId, Func> body, await CapturePhaseAsync(callId, "after", method).ConfigureAwait(false); await CaptureAfterActionAsync(callId, method).ConfigureAwait(false); + await CaptureCoverageAsync().ConfigureAwait(false); } return value; @@ -816,119 +864,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) - { - resources[item.Key] = item.Value; - } - - name = _name; - tracesDir = ResolveTracesDir(); - if (_stacks.Count > 0) + CoverageRecorder recorder; + lock (_gate) { - 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)); + recorder = _coverage; + _coverage = null; } - _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 + if (recorder != null) { - WriteContextOptions(); + await recorder.UninstallAsync().ConfigureAwait(false); } } + } - 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 +1087,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() + { + CoverageRecorder recorder = CoverageRecorderOrNull(); + if (recorder != null) + { + await recorder.CollectFromAllPagesAsync().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/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/Page.cs b/src/PlaywrightNative/Page.cs index 5b018218..8e329303 100644 --- a/src/PlaywrightNative/Page.cs +++ b/src/PlaywrightNative/Page.cs @@ -520,6 +520,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 { 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..7b5f1e5e 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) { From 52182027bd18a4cbc94e1625215cdcfa700213af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dar=C3=ADo=20Kondratiuk?= Date: Tue, 6 Oct 2026 01:56:57 -0300 Subject: [PATCH 2/4] Pull coverage only from the page the action ran on Pulling from every page after each action raced navigations in other pages: the pull reset the counters of a popup that was navigating away, and the reply was lost with the destroyed context, so the pagehide stash had nothing left. Upstream pulls only from the action's page, so actions now carry their page into the trace session. Co-Authored-By: Claude Opus 5.5 --- src/PlaywrightNative/Helpers/ActionTrace.cs | 16 +++++----- .../Helpers/CoverageRecorder.cs | 5 +--- .../Helpers/OfficialTraceSession.cs | 15 +++++----- .../Helpers/PageDialogTracker.cs | 3 +- .../Helpers/PageWaitForEventHelper.cs | 3 +- src/PlaywrightNative/Locator.cs | 3 +- src/PlaywrightNative/Page.cs | 30 ++++++++++++------- src/PlaywrightNative/WebKit/WKPage.cs | 30 ++++++++++++------- 8 files changed, 61 insertions(+), 44 deletions(-) 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 index 045eafaa..c5a6e0a6 100644 --- a/src/PlaywrightNative/Helpers/CoverageRecorder.cs +++ b/src/PlaywrightNative/Helpers/CoverageRecorder.cs @@ -124,9 +124,6 @@ internal async Task TakeAsync(bool discard) } } - internal Task CollectFromAllPagesAsync() - => Task.WhenAll(_context.Pages.Select(CollectFromPageAsync)); - internal async Task CollectFromPageAsync(IPage page) { if (page == null || page.IsClosed) @@ -160,7 +157,7 @@ private static string LoadSource() private async Task FlushAsync() { - await CollectFromAllPagesAsync().ConfigureAwait(false); + await Task.WhenAll(_context.Pages.Select(CollectFromPageAsync)).ConfigureAwait(false); await HarvestOriginsWithoutPageAsync().ConfigureAwait(false); } diff --git a/src/PlaywrightNative/Helpers/OfficialTraceSession.cs b/src/PlaywrightNative/Helpers/OfficialTraceSession.cs index 1a06d809..104100aa 100644 --- a/src/PlaywrightNative/Helpers/OfficialTraceSession.cs +++ b/src/PlaywrightNative/Helpers/OfficialTraceSession.cs @@ -401,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, @@ -410,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) { @@ -478,7 +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().ConfigureAwait(false); + await CaptureCoverageAsync(page).ConfigureAwait(false); } return value; @@ -1213,12 +1214,12 @@ private CoverageRecorder CoverageRecorderOrNull() } } - private async Task CaptureCoverageAsync() + private async Task CaptureCoverageAsync(IPage page) { CoverageRecorder recorder = CoverageRecorderOrNull(); - if (recorder != null) + if (recorder != null && page != null) { - await recorder.CollectFromAllPagesAsync().ConfigureAwait(false); + await recorder.CollectFromPageAsync(page).ConfigureAwait(false); } } 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/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 8e329303..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) @@ -564,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( @@ -685,7 +687,8 @@ public Task EmulateVisionDeficiencyAsync(VisionDeficiency type = default) : EvaluateWithArg.Wrap(expression, arg); return EvaluateSerializedAsync(toEval); }, - consoleBaseline); + consoleBaseline, + page: this); } /// @@ -707,7 +710,8 @@ public Task EvaluateAsync(string expression, object arg = default) : EvaluateWithArg.Wrap(expression, arg); return EvaluateSerializedAsync(toEval); }, - consoleBaseline); + consoleBaseline, + page: this); } /// @@ -731,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); } /// @@ -938,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, @@ -958,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); @@ -1030,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) @@ -1352,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; } @@ -1472,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); } /// @@ -1677,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/WebKit/WKPage.cs b/src/PlaywrightNative/WebKit/WKPage.cs index 7b5f1e5e..2fc98b63 100644 --- a/src/PlaywrightNative/WebKit/WKPage.cs +++ b/src/PlaywrightNative/WebKit/WKPage.cs @@ -743,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) @@ -752,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) @@ -838,7 +840,8 @@ public Task EvaluateAsync(string expression, object arg = null) : EvaluateWithArg.Wrap(expression, arg); return EvaluateSerializedAsync(toEval); }, - consoleBaseline); + consoleBaseline, + page: this); } /// @@ -875,7 +878,8 @@ public Task EvaluateAsync(string expression, object arg = null) : EvaluateWithArg.Wrap(expression, arg); return EvaluateSerializedAsync(toEval); }, - consoleBaseline); + consoleBaseline, + page: this); } /// @@ -929,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); } /// @@ -1147,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); @@ -1218,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) { @@ -1327,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) @@ -1586,7 +1592,8 @@ async Task CaptureRectAsync() "Page", "screenshot", () => ScreenshotTimeout.RunAsync(timeout, CaptureAsync), - result: new Dictionary { ["binary"] = "" }); + result: new Dictionary { ["binary"] = "" }, + page: this); } /// @@ -1719,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. @@ -1921,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) From 843682c435ec843e7c37648edad3c12cafc6beed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dar=C3=ADo=20Kondratiuk?= Date: Tue, 6 Oct 2026 17:45:13 -0300 Subject: [PATCH 3/4] Move coverage tests to Library/Coverage/CoverageTests.cs Co-Authored-By: Claude Opus 5.5 --- .../Coverage/CoverageTests.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/PlaywrightNative.Tests/{LibraryCoverageParityTests.cs => Library/Coverage/CoverageTests.cs} (100%) diff --git a/src/PlaywrightNative.Tests/LibraryCoverageParityTests.cs b/src/PlaywrightNative.Tests/Library/Coverage/CoverageTests.cs similarity index 100% rename from src/PlaywrightNative.Tests/LibraryCoverageParityTests.cs rename to src/PlaywrightNative.Tests/Library/Coverage/CoverageTests.cs From 9eb0c42ce4f4603de1297d38fbba226c9c4a4c45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dar=C3=ADo=20Kondratiuk?= Date: Tue, 6 Oct 2026 17:45:44 -0300 Subject: [PATCH 4/4] Rename the coverage test class to CoverageTests Co-Authored-By: Claude Opus 5.5 --- src/PlaywrightNative.Tests/Library/Coverage/CoverageTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PlaywrightNative.Tests/Library/Coverage/CoverageTests.cs b/src/PlaywrightNative.Tests/Library/Coverage/CoverageTests.cs index b34f8c52..976bae5a 100644 --- a/src/PlaywrightNative.Tests/Library/Coverage/CoverageTests.cs +++ b/src/PlaywrightNative.Tests/Library/Coverage/CoverageTests.cs @@ -36,7 +36,7 @@ namespace PlaywrightNative.Tests /// [TestFixture] [NonParallelizable] - public class LibraryCoverageParityTests : BrowserTestEx + public class CoverageTests : BrowserTestEx { private string _outputDir;