From 9deb48775302d29a4df8f14209eb9a280fed365b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dar=C3=ADo=20Kondratiuk?= Date: Tue, 6 Oct 2026 02:18:50 -0300 Subject: [PATCH] Port page-goto, page-set-content and wait-for-selector-2 parity tests Add page.content({ includeShadow }) as ContentAsync(PageContentOptions), serializing open shadow roots as declarative shadow DOM like upstream injectedScript.documentContent. Port the COOP/COEP history-state goto tests. Listener-leak, __testHookBeforeAdoptNode and toImpl utility-world setContent tests get Node-only ignored twins. Co-Authored-By: Claude Opus 5.5 --- .../PageGotoParityTests.cs | 72 +++++++++++++++++++ .../PageSetContentTests.cs | 39 +++++++++- .../PageWaitForSelector2ParityTests.cs | 20 ++++++ src/PlaywrightNative/Chromium/CRPage.cs | 5 +- src/PlaywrightNative/Firefox/FFPage.cs | 17 ++--- src/PlaywrightNative/Firefox/FirefoxPage.cs | 5 ++ src/PlaywrightNative/Helpers/PageContent.cs | 39 ++++++++++ .../MicrosoftSurfaceCompatExtensions.cs | 25 +++++++ src/PlaywrightNative/Page.cs | 6 ++ src/PlaywrightNative/PageContentOptions.cs | 33 +++++++++ src/PlaywrightNative/WebKit/WKPage.cs | 8 ++- 11 files changed, 252 insertions(+), 17 deletions(-) create mode 100644 src/PlaywrightNative/PageContentOptions.cs diff --git a/src/PlaywrightNative.Tests/PageGotoParityTests.cs b/src/PlaywrightNative.Tests/PageGotoParityTests.cs index bf6c2d23..9090acfe 100644 --- a/src/PlaywrightNative.Tests/PageGotoParityTests.cs +++ b/src/PlaywrightNative.Tests/PageGotoParityTests.cs @@ -19,6 +19,7 @@ using System.Globalization; using System.IO; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; @@ -40,6 +41,10 @@ namespace PlaywrightNative.Tests [NonParallelizable] public class PageGotoParityTests : PageTestEx { + private const string NodeListenerLeakReason = + "Node-only: asserts no MaxListenersExceededWarning on process.on('warning'). " + + ".NET events have no listener cap or leak warning."; + private static SimpleServer _ownedServer; private static SimpleServer _ownedHttps; private static string Prefix = TestConstants.ServerUrl; @@ -461,6 +466,18 @@ await http.Response.WriteAsync( Assert.That(response.Request.Failure, Is.Null.Or.Empty); } + [PlaywrightTest("page-goto.spec.ts", "should work with Cross-Origin-Opener-Policy and history state")] + [Test] + [Timeout(30_000)] + public Task ShouldWorkWithCrossOriginOpenerPolicyAndHistoryState() + => CrossOriginOpenerPolicyHistoryStateAsync(crossOriginEmbedderPolicy: null); + + [PlaywrightTest("page-goto.spec.ts", "should work with Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy and history state")] + [Test] + [Timeout(30_000)] + public Task ShouldWorkWithCrossOriginOpenerPolicyAndCrossOriginEmbedderPolicyAndHistoryState() + => CrossOriginOpenerPolicyHistoryStateAsync(crossOriginEmbedderPolicy: "require-corp"); + [PlaywrightTest("page-goto.spec.ts", "should work with Cross-Origin-Opener-Policy and interception")] [Test] [Timeout(30_000)] @@ -1252,6 +1269,30 @@ public async Task ShouldReturnLastResponseInRedirectChain() Assert.That(response.Url, Is.EqualTo(EmptyPage)); } + [PlaywrightTest("page-goto.spec.ts", "should not leak listeners during navigation")] + [Test] + [Timeout(30_000)] + public void ShouldNotLeakListenersDuringNavigation() + { + Assert.Ignore(NodeListenerLeakReason); + } + + [PlaywrightTest("page-goto.spec.ts", "should not leak listeners during bad navigation")] + [Test] + [Timeout(30_000)] + public void ShouldNotLeakListenersDuringBadNavigation() + { + Assert.Ignore(NodeListenerLeakReason); + } + + [PlaywrightTest("page-goto.spec.ts", "should not leak listeners during 20 waitForNavigation")] + [Test] + [Timeout(30_000)] + public void ShouldNotLeakListenersDuring20WaitForNavigation() + { + Assert.Ignore(NodeListenerLeakReason); + } + [PlaywrightTest("page-goto.spec.ts", "should navigate to dataURL and not fire dataURL requests")] [Test] [Timeout(30_000)] @@ -1693,5 +1734,36 @@ public async Task ShouldReturnUrlWithBasicAuthInfo() await page.GoToAsync(url).ConfigureAwait(false); Assert.That(page.Url, Is.EqualTo(url)); } + + private async Task CrossOriginOpenerPolicyHistoryStateAsync(string crossOriginEmbedderPolicy) + { + // https://github.com/microsoft/playwright/issues/42731 + EnsureServer(); + Server.SetRoute("/empty.html", async http => + { + http.Response.Headers["Cross-Origin-Opener-Policy"] = "same-origin"; + if (crossOriginEmbedderPolicy != null) + { + http.Response.Headers["Cross-Origin-Embedder-Policy"] = crossOriginEmbedderPolicy; + } + + await http.Response.WriteAsync(@" + +

hello

+ + ").ConfigureAwait(false); + }); + + await using IBrowser browser = await BrowserLauncher.LaunchAsync().ConfigureAwait(false); + await using IBrowserContext context = await browser.NewContextAsync().ConfigureAwait(false); + IPage page = await context.NewPageAsync().ConfigureAwait(false); + + IResponse response = await page.GoToAsync(EmptyPage).ConfigureAwait(false); + Assert.That(response.Status, Is.EqualTo(200)); + Assert.That(page.Url, Is.EqualTo(EmptyPage)); + JsonElement state = await page.EvaluateAsync("() => history.state").ConfigureAwait(false); + Assert.That(state.GetRawText(), Is.EqualTo("{\"key\":1}")); + await Assertions.Expect(page.Locator("h1")).ToHaveTextAsync("hello").ConfigureAwait(false); + } } } diff --git a/src/PlaywrightNative.Tests/PageSetContentTests.cs b/src/PlaywrightNative.Tests/PageSetContentTests.cs index eb544d83..86381a36 100644 --- a/src/PlaywrightNative.Tests/PageSetContentTests.cs +++ b/src/PlaywrightNative.Tests/PageSetContentTests.cs @@ -26,7 +26,7 @@ namespace PlaywrightNative.Tests { /// /// Official page-set-content.spec.ts. - /// Skipped (not implemented): + /// Skipped (Node-only internals): /// should handle timeout properly (needs toImpl utility-world console.debug tamper); /// should handle timeout properly 2 (needs toImpl document.close infinite loop). /// @@ -132,6 +132,25 @@ public async Task ShouldWorkWithHtml4Doctype() Assert.That(result, Is.EqualTo(doctype + ExpectedHtml)); } + [PlaywrightTest("page-set-content.spec.ts", "should include shadow roots")] + [Test] + [Timeout(30_000)] + public async Task ShouldIncludeShadowRoots() + { + await using IBrowser browser = await BrowserLauncher.LaunchAsync().ConfigureAwait(false); + await using IBrowserContext context = await browser.NewContextAsync().ConfigureAwait(false); + IPage page = await context.NewPageAsync().ConfigureAwait(false); + + const string html = "
light
"; + + // Closed shadow roots are not accessible from script and are never serialized. + await page.SetContentAsync(html.Replace("
", "
", StringComparison.Ordinal)).ConfigureAwait(false); + Assert.That( + await page.ContentAsync().ConfigureAwait(false), + Is.EqualTo("
light
")); + Assert.That(await page.ContentAsync(new() { IncludeShadow = true }).ConfigureAwait(false), Is.EqualTo(html)); + } + [PlaywrightTest("page-set-content.spec.ts", "should respect timeout")] [Test] [Timeout(30_000)] @@ -322,5 +341,23 @@ public async Task ShouldReturnEmptyContentThereIsNoIframeSrc() string content = await child.ContentAsync().ConfigureAwait(false); Assert.That(content, Is.EqualTo(EmptyHtml)); } + + [PlaywrightTest("page-set-content.spec.ts", "should handle timeout properly")] + [Test] + [Timeout(30_000)] + public void ShouldHandleTimeoutProperly() + { + Assert.Ignore("Node-only: toImpl(page).mainFrame().evaluateExpression tampers console.debug in the server utility world. " + + "SetContent here does not use a persistent utility world or the console.debug tag."); + } + + [PlaywrightTest("page-set-content.spec.ts", "should handle timeout properly 2")] + [Test] + [Timeout(30_000)] + public void ShouldHandleTimeoutProperly2() + { + Assert.Ignore("Node-only: toImpl(page).mainFrame().evaluateExpression replaces document.close in the server utility world. " + + "SetContent here does not use a persistent utility world."); + } } } diff --git a/src/PlaywrightNative.Tests/PageWaitForSelector2ParityTests.cs b/src/PlaywrightNative.Tests/PageWaitForSelector2ParityTests.cs index 8a45ad92..e6ed540c 100644 --- a/src/PlaywrightNative.Tests/PageWaitForSelector2ParityTests.cs +++ b/src/PlaywrightNative.Tests/PageWaitForSelector2ParityTests.cs @@ -41,6 +41,10 @@ namespace PlaywrightNative.Tests [TestFixture] public class PageWaitForSelector2ParityTests : PageTestEx { + private const string NodeAdoptionHookReason = + "Node-only: drives the server-side __testHookBeforeAdoptNode between the utility-world match " + + "and its adoption into the main world. waitForSelector here polls the main world and has no adoption step."; + private const string AddElement = "tag => document.body.appendChild(document.createElement(tag))"; private static SimpleServer _ownedServer; @@ -622,6 +626,22 @@ await page.SetContentAsync(@" await page.WaitForSelectorAsync("div", WaitForSelectorState.Hidden).ConfigureAwait(false); } + [PlaywrightTest("page-wait-for-selector-2.spec.ts", "should work when navigating before node adoption")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public void ShouldWorkWhenNavigatingBeforeNodeAdoption() + { + Assert.Ignore(NodeAdoptionHookReason); + } + + [PlaywrightTest("page-wait-for-selector-2.spec.ts", "should fail when navigating while on handle")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public void ShouldFailWhenNavigatingWhileOnHandle() + { + Assert.Ignore(NodeAdoptionHookReason); + } + [PlaywrightTest("page-wait-for-selector-2.spec.ts", "should fail if element handle was detached while waiting")] [Test] [Timeout(TestConstants.DefaultTestTimeout)] diff --git a/src/PlaywrightNative/Chromium/CRPage.cs b/src/PlaywrightNative/Chromium/CRPage.cs index a3064605..c7f174e2 100644 --- a/src/PlaywrightNative/Chromium/CRPage.cs +++ b/src/PlaywrightNative/Chromium/CRPage.cs @@ -2001,9 +2001,10 @@ internal async Task GetUtilityWorldAsync(Frame frame) /// /// Returns the full HTML of the current document, including doctype and root element. /// + /// Whether to serialize open shadow roots as declarative shadow DOM. /// The serialized document HTML. - internal Task ContentAsync() - => PageContent.ReadAsync(() => EvaluateAsync(PageContent.EvaluateExpression)); + internal Task ContentAsync(bool includeShadow = false) + => PageContent.ReadAsync(() => EvaluateAsync(PageContent.Expression(includeShadow))); /// /// Adds a <script> tag to the page by URL or inline content. For URL diff --git a/src/PlaywrightNative/Firefox/FFPage.cs b/src/PlaywrightNative/Firefox/FFPage.cs index ab38e92a..9cbdc389 100644 --- a/src/PlaywrightNative/Firefox/FFPage.cs +++ b/src/PlaywrightNative/Firefox/FFPage.cs @@ -20,6 +20,7 @@ using System.Text.Json; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using PlaywrightNative.Helpers; namespace PlaywrightNative.Firefox { @@ -372,19 +373,9 @@ internal IReadOnlyCollection SnapshotLifecycle() /// /// Returns the full HTML content of the page. /// - internal async Task ContentAsync() - { - return await EvaluateFunctionAsync(@"() => { - let retVal = ''; - if (document.doctype) { - retVal = new XMLSerializer().serializeToString(document.doctype); - } - if (document.documentElement) { - retVal += document.documentElement.outerHTML; - } - return retVal; - }").ConfigureAwait(false); - } + /// Whether to serialize open shadow roots as declarative shadow DOM. + internal Task ContentAsync(bool includeShadow = false) + => EvaluateFunctionAsync("() => " + PageContent.Expression(includeShadow)); /// /// Sets the page HTML content. diff --git a/src/PlaywrightNative/Firefox/FirefoxPage.cs b/src/PlaywrightNative/Firefox/FirefoxPage.cs index 73110382..67033bf7 100644 --- a/src/PlaywrightNative/Firefox/FirefoxPage.cs +++ b/src/PlaywrightNative/Firefox/FirefoxPage.cs @@ -984,6 +984,11 @@ public Task DragAndDropAsync( throw NotImplementedHelper.ForMethod(nameof(DragAndDropAsync)); } + /// Returns the page HTML, optionally including open shadow roots. + /// Whether to serialize open shadow roots as declarative shadow DOM. + /// The serialized document HTML. + internal Task ContentAsync(bool includeShadow) => _page.ContentAsync(includeShadow); + private async Task PauseInternalAsync() { int timeoutMs = TimeoutSettings.TimeoutMs(DefaultTimeout); diff --git a/src/PlaywrightNative/Helpers/PageContent.cs b/src/PlaywrightNative/Helpers/PageContent.cs index d2a99057..9e961193 100644 --- a/src/PlaywrightNative/Helpers/PageContent.cs +++ b/src/PlaywrightNative/Helpers/PageContent.cs @@ -42,6 +42,37 @@ internal static class PageContent return retVal; })()"; + /// + /// JavaScript for official page.content({ includeShadow: true }) + /// (injectedScript.documentContent): open shadow roots are serialized as + /// declarative shadow DOM via getHTML({ shadowRoots }). + /// + internal const string EvaluateWithShadowExpression = + @"(() => { + let content = ''; + if (document.doctype) { + content = new XMLSerializer().serializeToString(document.doctype); + } + const root = document.documentElement; + if (!root) { + return content; + } + const shadowRoots = []; + const collectShadowRoots = node => { + for (const element of node.querySelectorAll('*')) { + if (element.shadowRoot) { + shadowRoots.push(element.shadowRoot); + collectShadowRoots(element.shadowRoot); + } + } + }; + collectShadowRoots(document); + // getHTML() serializes children only, wrap them with the root element tags. + const emptyRoot = root.cloneNode(false).outerHTML; + const endTagIndex = emptyRoot.lastIndexOf(' /// Official message when content() is evaluated while the document is /// being replaced by a navigation. @@ -49,6 +80,14 @@ internal static class PageContent internal const string NavigationError = "Unable to retrieve content because the page is navigating and changing the content."; + /// + /// Picks the content expression for . + /// + /// Whether to serialize open shadow roots. + /// The JavaScript expression. + internal static string Expression(bool includeShadow) + => includeShadow ? EvaluateWithShadowExpression : EvaluateExpression; + /// /// Runs and rewrites retriable evaluation /// failures (destroyed context, mid-navigation) to diff --git a/src/PlaywrightNative/MicrosoftSurfaceCompatExtensions.cs b/src/PlaywrightNative/MicrosoftSurfaceCompatExtensions.cs index 8fc6d801..06c9f9b8 100644 --- a/src/PlaywrightNative/MicrosoftSurfaceCompatExtensions.cs +++ b/src/PlaywrightNative/MicrosoftSurfaceCompatExtensions.cs @@ -117,6 +117,31 @@ public static Task GoBackAsync(this IPage page, WaitUntilState waitUn } } + /// + /// Gets the full HTML contents of the page, including the doctype. Official + /// page.content({ includeShadow }), which Microsoft.Playwright does not expose yet. + /// + /// The page. + /// Call options. + /// The serialized document HTML. + public static Task ContentAsync(this IPage page, PageContentOptions options) + { + bool includeShadow = options?.IncludeShadow ?? false; + switch (page) + { + case Page chromium: + return chromium.ContentAsync(includeShadow); + case FirefoxPage firefox: + return firefox.ContentAsync(includeShadow); + case WKPage webkit: + return webkit.ContentAsync(includeShadow); + default: + return includeShadow + ? throw new NotSupportedException("includeShadow is not supported for " + page?.GetType().Name + ".") + : page.ContentAsync(); + } + } + /// Legacy drag-and-drop with source/target positions. public static Task DragAndDropAsync( this IPage page, diff --git a/src/PlaywrightNative/Page.cs b/src/PlaywrightNative/Page.cs index 5b018218..749b8f11 100644 --- a/src/PlaywrightNative/Page.cs +++ b/src/PlaywrightNative/Page.cs @@ -1761,6 +1761,12 @@ public Task WaitForFileChooserAsync(float? timeout = default, Canc public Task WaitForEventAsync(PlaywrightEvent pageEvent, Func predicate = null, float? timeout = null) => PageWaitForEventHelper.WaitAsync(this, pageEvent, predicate, timeout); + /// Returns the page HTML, optionally including open shadow roots. + /// Whether to serialize open shadow roots as declarative shadow DOM. + /// The serialized document HTML. + internal Task ContentAsync(bool includeShadow) + => _crPage.ContentAsync(includeShadow); + /// /// Returns the cached instance for . /// diff --git a/src/PlaywrightNative/PageContentOptions.cs b/src/PlaywrightNative/PageContentOptions.cs new file mode 100644 index 00000000..0a954f54 --- /dev/null +++ b/src/PlaywrightNative/PageContentOptions.cs @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2020 Dario Kondratiuk + * 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. + */ + +namespace PlaywrightNative +{ + /// + /// Options for . + /// + public class PageContentOptions + { + /// + /// Gets or sets a value indicating whether the contents of open shadow roots are + /// included as declarative shadow DOM, i.e. <template shadowrootmode="open"> + /// elements nested inside their host elements. Closed shadow roots are never + /// included. Defaults to false. + /// + public bool? IncludeShadow { get; set; } + } +} diff --git a/src/PlaywrightNative/WebKit/WKPage.cs b/src/PlaywrightNative/WebKit/WKPage.cs index b9853b40..dc1f8fe5 100644 --- a/src/PlaywrightNative/WebKit/WKPage.cs +++ b/src/PlaywrightNative/WebKit/WKPage.cs @@ -550,7 +550,7 @@ public Task TitleAsync() /// public Task ContentAsync() - => PageContent.ReadAsync(() => EvaluateExpressionAsync(PageContent.EvaluateExpression)); + => ContentAsync(includeShadow: false); /// /// Emulates CSS media features on the page via the inner target session. Maps @@ -2069,6 +2069,12 @@ public Task DragAndDropAsync( bool? strict = default) => DragAndDropHelper.RunAsync(this, source, target, sourcePosition, targetPosition, force, timeout, trial, steps, scroll, strict); + /// Returns the page HTML, optionally including open shadow roots. + /// Whether to serialize open shadow roots as declarative shadow DOM. + /// The serialized document HTML. + internal Task ContentAsync(bool includeShadow) + => PageContent.ReadAsync(() => EvaluateExpressionAsync(PageContent.Expression(includeShadow))); + /// /// Fires for a page opened from this page. ///