diff --git a/Sources/HtmlTinkerX.Tests/HtmlBrowserInitialReadinessTests.cs b/Sources/HtmlTinkerX.Tests/HtmlBrowserInitialReadinessTests.cs
new file mode 100644
index 00000000..403bce4e
--- /dev/null
+++ b/Sources/HtmlTinkerX.Tests/HtmlBrowserInitialReadinessTests.cs
@@ -0,0 +1,74 @@
+using Microsoft.Playwright;
+using Moq;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace HtmlTinkerX.Tests;
+
+[Collection("Playwright collection")]
+public sealed class HtmlBrowserInitialReadinessTests {
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public async Task DeferredInitialNavigationPreservesLoginAndNormalNavigationPolicy(bool deferInitialNavigation, bool login) {
+ var page = new Mock();
+ var context = new Mock();
+ var browser = new Mock();
+ var browserType = new Mock();
+ var playwright = new Mock();
+ var navigations = new List<(string Url, WaitUntilState? State, float? Timeout)>();
+ page.Setup(value => value.GotoAsync(It.IsAny(), It.IsAny()))
+ .Callback((url, options) => navigations.Add((url, options.WaitUntil, options.Timeout)))
+ .ReturnsAsync((IResponse?)null);
+ context.Setup(value => value.NewPageAsync()).ReturnsAsync(page.Object);
+ browser.Setup(value => value.NewContextAsync(It.IsAny())).ReturnsAsync(context.Object);
+ browserType.Setup(value => value.LaunchAsync(It.IsAny())).ReturnsAsync(browser.Object);
+ playwright.SetupGet(value => value.Chromium).Returns(browserType.Object);
+ var originalFactory = HtmlBrowser.PlaywrightFactory;
+ HtmlBrowser.PlaywrightFactory = () => Task.FromResult(playwright.Object);
+ try {
+ var options = new HtmlBrowserLaunchOptions {
+ BrowserChannel = "msedge",
+ Timeout = 1379,
+ FormLogin = login ? new HtmlFormLogin { LoginUrl = "https://example.test/login", SubmitSelector = "#submit" } : null
+ };
+ await using var session = deferInitialNavigation
+ ? await HtmlBrowser.OpenSessionForNavigationAsync(options, default)
+ : await HtmlBrowser.OpenSessionAsync("about:blank", options);
+ Assert.Equal((login ? 1 : 0) + (deferInitialNavigation ? 0 : 1), navigations.Count);
+ if (!deferInitialNavigation) {
+ Assert.Equal(("about:blank", (WaitUntilState?)WaitUntilState.NetworkIdle, (float?)1379), navigations[navigations.Count - 1]);
+ }
+ if (login) {
+ Assert.Equal(("https://example.test/login", (WaitUntilState?)WaitUntilState.NetworkIdle, (float?)1379), navigations[0]);
+ page.Verify(value => value.WaitForLoadStateAsync(LoadState.NetworkIdle,
+ It.Is(options => options.Timeout == 1379)), Times.Once);
+ }
+ } finally {
+ HtmlBrowser.PlaywrightFactory = originalFactory;
+ }
+ }
+
+ [Theory]
+ [InlineData(HtmlBrowserEngine.Chromium)]
+ [InlineData(HtmlBrowserEngine.Firefox)]
+ [InlineData(HtmlBrowserEngine.WebKit)]
+ public async Task DeferredInitialNavigationRunsInitScriptsOnTheActualDestination(HtmlBrowserEngine browser) {
+ var options = new HtmlBrowserLaunchOptions {
+ Browser = browser,
+ InitScripts = { "window.initializationCount = (window.initializationCount || 0) + 1" }
+ };
+ await using var session = await HtmlBrowser.OpenSessionForNavigationAsync(options, default);
+ await session.Page.RouteAsync("https://initialization.test/**", route => route.FulfillAsync(new RouteFulfillOptions {
+ ContentType = "text/html", Body = "Destination
"
+ }));
+
+ await HtmlBrowser.NavigateAsync(session, "https://initialization.test/", HtmlBrowserLoadState.DomContentLoaded);
+
+ Assert.Equal("https://initialization.test/", session.Page.Url);
+ Assert.Equal(1, await session.Page.EvaluateAsync("() => window.initializationCount"));
+ }
+}
diff --git a/Sources/HtmlTinkerX/HtmlCrawler.Browser.cs b/Sources/HtmlTinkerX/HtmlCrawler.Browser.cs
index 8984862a..7ebbdace 100644
--- a/Sources/HtmlTinkerX/HtmlCrawler.Browser.cs
+++ b/Sources/HtmlTinkerX/HtmlCrawler.Browser.cs
@@ -18,7 +18,7 @@ private static async Task CreateRenderSessionAsync(HtmlCrawl
&& !string.IsNullOrEmpty(options.Username) && options.Password != null) {
headers["Authorization"] = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{options.Username}:{options.Password}"));
}
- HtmlBrowserSession session = await HtmlBrowser.OpenSessionAsync("about:blank", new HtmlBrowserLaunchOptions {
+ HtmlBrowserSession session = await HtmlBrowser.OpenSessionForNavigationAsync(new HtmlBrowserLaunchOptions {
Browser = options.Browser, Clean = options.CleanBrowserInstall, Headless = options.Headless,
Username = imported || (options.FormLogin == null && options.Browser == HtmlBrowserEngine.Chromium) ? null : options.Username,
Password = imported || (options.FormLogin == null && options.Browser == HtmlBrowserEngine.Chromium) ? null : options.Password,
diff --git a/Sources/HtmlTinkerX/Playwright/HtmlBrowser.Navigation.cs b/Sources/HtmlTinkerX/Playwright/HtmlBrowser.Navigation.cs
index 8f13471e..2da30f87 100644
--- a/Sources/HtmlTinkerX/Playwright/HtmlBrowser.Navigation.cs
+++ b/Sources/HtmlTinkerX/Playwright/HtmlBrowser.Navigation.cs
@@ -18,7 +18,8 @@ private static async Task NavigateAsync(
string? password,
HtmlBrowserLoadState loadState,
int timeout,
- CancellationToken cancellationToken) {
+ CancellationToken cancellationToken,
+ bool skipInitialNavigation = false) {
if (formLogin != null) {
cancellationToken.ThrowIfCancellationRequested();
await page.GotoAsync(formLogin.LoginUrl, new PageGotoOptions {
@@ -36,6 +37,7 @@ private static async Task NavigateAsync(
}
cancellationToken.ThrowIfCancellationRequested();
+ if (skipInitialNavigation) return;
await page.GotoAsync(url, new PageGotoOptions {
Timeout = timeout,
WaitUntil = ToWaitUntilState(loadState)
diff --git a/Sources/HtmlTinkerX/Playwright/HtmlBrowser.cs b/Sources/HtmlTinkerX/Playwright/HtmlBrowser.cs
index e94992d7..9d3fb01c 100644
--- a/Sources/HtmlTinkerX/Playwright/HtmlBrowser.cs
+++ b/Sources/HtmlTinkerX/Playwright/HtmlBrowser.cs
@@ -492,7 +492,8 @@ private static void ApplySharedContextOptions(BrowserTypeLaunchPersistentContext
private static async Task CreatePageAsync(
string url,
HtmlBrowserLaunchOptions options,
- CancellationToken cancellationToken = default) {
+ CancellationToken cancellationToken = default,
+ bool skipInitialNavigation = false) {
if (!string.IsNullOrWhiteSpace(options.UserDataDirectory) && !string.IsNullOrWhiteSpace(options.StorageStatePath)) {
throw new ArgumentException("Use either UserDataDirectory for a persistent profile or StorageStatePath for imported context state, not both.");
}
@@ -597,7 +598,7 @@ await HtmlBrowserPdfCapture.ExecuteWithCancellationAsync(
AbortOwnershipAsync,
cancellationToken).ConfigureAwait(false);
await HtmlBrowserPdfCapture.ExecuteWithCancellationAsync(
- () => NavigateAsync(page!, url, options.FormLogin, options.Username, options.Password, options.LoadState, options.Timeout, cancellationToken),
+ () => NavigateAsync(page!, url, options.FormLogin, options.Username, options.Password, options.LoadState, options.Timeout, cancellationToken, skipInitialNavigation),
AbortOwnershipAsync,
cancellationToken).ConfigureAwait(false);
if (options.ManualLogin) {
@@ -666,6 +667,10 @@ private static bool HasCdpContextOnlyOptions(HtmlBrowserLaunchOptions options) =
|| !string.IsNullOrWhiteSpace(options.Timezone)
|| options.Permissions.Count > 0;
+ /// Creates a session for installing routes before its first destination. Configured form login still runs.
+ internal static Task OpenSessionForNavigationAsync(HtmlBrowserLaunchOptions options, CancellationToken cancellationToken) =>
+ CreatePageAsync("about:blank", options, cancellationToken, skipInitialNavigation: true);
+
///
/// Creates a new using reusable launch options and navigates to the specified URL.
///