diff --git a/src/NetInteractor.PuppeteerSharp/PuppeteerSharpWebAccessor.cs b/src/NetInteractor.PuppeteerSharp/PuppeteerSharpWebAccessor.cs index e3708bc..84014a7 100644 --- a/src/NetInteractor.PuppeteerSharp/PuppeteerSharpWebAccessor.cs +++ b/src/NetInteractor.PuppeteerSharp/PuppeteerSharpWebAccessor.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using PuppeteerSharp; +using NetInteractor.Config; namespace NetInteractor.WebAccessors { @@ -77,27 +78,61 @@ private async Task GetBrowserAsync() return _browser; } - public async Task GetAsync(string url) + public async Task GetAsync(string url, InteractActionConfig config = null) { var browser = await GetBrowserAsync(); var page = await browser.NewPageAsync(); try { + // Navigate to the URL and wait for network to be idle + // This will handle the initial page load and any immediate redirects var response = await page.GoToAsync(url, new NavigationOptions { WaitUntil = new[] { WaitUntilNavigation.Networkidle0 } }); + // Check if load delay is configured in options + var loadDelayStr = config?.Options?.FirstOrDefault(attr => attr.Name == "loadDelay")?.Value; + if (!string.IsNullOrEmpty(loadDelayStr) && int.TryParse(loadDelayStr, out var loadDelay)) + { + // After the page loads, check if JavaScript might trigger a delayed redirect + // This handles cases like: setTimeout(() => window.location.href = '/other', 500) + // We race between a delay and a navigation wait + var delayTask = Task.Delay(loadDelay); + var navigationTask = page.WaitForNavigationAsync(new NavigationOptions + { + WaitUntil = new[] { WaitUntilNavigation.Networkidle0 } + }); + + var completedTask = await Task.WhenAny(delayTask, navigationTask); + + if (completedTask == navigationTask) + { + // Navigation occurred - await it to get the response and handle any exceptions + try + { + response = await navigationTask; + } + catch (PuppeteerException) + { + // Navigation failed or was cancelled - use original response + } + } + // else: delay completed first, meaning no navigation occurred within timeout - use original response + // Note: The navigationTask will be cancelled when the page closes in the finally block + } + return await GetResultFromResponse(page, response); } finally { + // Close the page. Any pending navigation will be cancelled. await page.CloseAsync(); } } - public async Task PostAsync(string url, NameValueCollection formValues) + public async Task PostAsync(string url, NameValueCollection formValues, InteractActionConfig config = null) { var browser = await GetBrowserAsync(); var page = await browser.NewPageAsync(); diff --git a/src/NetInteractor/Config/InteractActionConfig.cs b/src/NetInteractor/Config/InteractActionConfig.cs index 702b8e2..aefd662 100644 --- a/src/NetInteractor/Config/InteractActionConfig.cs +++ b/src/NetInteractor/Config/InteractActionConfig.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Specialized; using System.Threading.Tasks; +using System.Xml; using System.Xml.Serialization; namespace NetInteractor.Config @@ -19,6 +20,9 @@ public abstract class InteractActionConfig : IInteractActionConfig [XmlAttribute("expectedHttpStatusCodes")] public string ExpectedHttpStatusCodes { get; set; } + [XmlAnyAttribute] + public XmlAttribute[] Options { get; set; } + public abstract IInteractAction GetAction(); } } \ No newline at end of file diff --git a/src/NetInteractor/IWebAccessor.cs b/src/NetInteractor/IWebAccessor.cs index b0a06aa..dc37e3d 100644 --- a/src/NetInteractor/IWebAccessor.cs +++ b/src/NetInteractor/IWebAccessor.cs @@ -2,13 +2,14 @@ using System.Collections.Specialized; using System.Net; using System.Threading.Tasks; +using NetInteractor.Config; namespace NetInteractor { public interface IWebAccessor { - Task GetAsync(string url); + Task GetAsync(string url, InteractActionConfig config = null); - Task PostAsync(string url, NameValueCollection formValues); + Task PostAsync(string url, NameValueCollection formValues, InteractActionConfig config = null); } } diff --git a/src/NetInteractor/Interacts/Get.cs b/src/NetInteractor/Interacts/Get.cs index e245feb..b27382f 100644 --- a/src/NetInteractor/Interacts/Get.cs +++ b/src/NetInteractor/Interacts/Get.cs @@ -18,7 +18,7 @@ protected override async Task MakeRequest(InterationContext contex { var url = PrepareValue(context, Config.Url); var webAccessor = context.WebAccessor; - return await webAccessor.GetAsync(url); + return await webAccessor.GetAsync(url, Config); } } } \ No newline at end of file diff --git a/src/NetInteractor/Interacts/Post.cs b/src/NetInteractor/Interacts/Post.cs index b665d93..bb2c8da 100644 --- a/src/NetInteractor/Interacts/Post.cs +++ b/src/NetInteractor/Interacts/Post.cs @@ -98,7 +98,7 @@ protected override async Task MakeRequest(InterationContext contex } } - return await webAccessor.PostAsync(url, formValues); + return await webAccessor.PostAsync(url, formValues, Config); } private NameValueCollection MergeFormValues(InterationContext context, FormInfo form, FormValue[] formValues) diff --git a/src/NetInteractor/Interacts/WebInteractionBase.cs b/src/NetInteractor/Interacts/WebInteractionBase.cs index 8308cff..1839b43 100644 --- a/src/NetInteractor/Interacts/WebInteractionBase.cs +++ b/src/NetInteractor/Interacts/WebInteractionBase.cs @@ -80,7 +80,7 @@ protected WebInteractionBase(TConfig config) private Task MakeRedirectRequest(InterationContext context, string redirectUrl) { var webAccessor = context.WebAccessor; - return webAccessor.GetAsync(redirectUrl); + return webAccessor.GetAsync(redirectUrl, Config); } private async Task MakeRequestInternal(InterationContext context, string redirectUrl = null) diff --git a/src/NetInteractor/WebAccessors/HttpClientWebAccessor.cs b/src/NetInteractor/WebAccessors/HttpClientWebAccessor.cs index 0a6639a..980538a 100644 --- a/src/NetInteractor/WebAccessors/HttpClientWebAccessor.cs +++ b/src/NetInteractor/WebAccessors/HttpClientWebAccessor.cs @@ -5,6 +5,7 @@ using System.Net.Http; using System.Threading.Tasks; using System.Text; +using NetInteractor.Config; namespace NetInteractor.WebAccessors { @@ -35,7 +36,7 @@ public HttpClientWebAccessor() { } - public virtual async Task GetAsync(string url) + public virtual async Task GetAsync(string url, InteractActionConfig config = null) { var request = new HttpRequestMessage(HttpMethod.Get, url); @@ -44,7 +45,7 @@ public virtual async Task GetAsync(string url) return await GetResultFromResponse(response); } - public virtual async Task PostAsync(string url, NameValueCollection formValues) + public virtual async Task PostAsync(string url, NameValueCollection formValues, InteractActionConfig config = null) { var request = new HttpRequestMessage(HttpMethod.Post, url); diff --git a/test/NetInteractor.Test/IntegrationTests.cs b/test/NetInteractor.Test/IntegrationTests.cs index 6d5cc62..f558c1a 100644 --- a/test/NetInteractor.Test/IntegrationTests.cs +++ b/test/NetInteractor.Test/IntegrationTests.cs @@ -393,5 +393,36 @@ public async Task TestRedirect_AfterPost_FollowsRedirect(IWebAccessor webAccesso if (webAccessor is IDisposable disposable) disposable.Dispose(); } + + [Theory] + [ClassData(typeof(WebAccessorTestData))] + public async Task TestJavaScriptRedirect_FollowsRedirect(IWebAccessor webAccessor, string baseUrl) + { + // This test verifies that JavaScript redirects are handled properly when + // loadDelay option is configured in the config + // JavaScript redirects only work with PuppeteerSharp (browser automation) + // HttpClient cannot execute JavaScript, so we skip this test for HttpClient + if (webAccessor is HttpClientWebAccessor) + { + // Skip test for HttpClient - it cannot handle JavaScript redirects + return; + } + + // Arrange + var executor = new InterationExecutor(webAccessor); + var config = LoadConfig("JavaScriptRedirectTest.config"); + var inputs = new NameValueCollection { ["BaseUrl"] = baseUrl }; + + // Act - Should follow JavaScript redirect from /js-redirect-test to /products + var result = await executor.ExecuteAsync(config, inputs); + + // Assert + Assert.True(result.Ok, result.Message); + Assert.Equal("Products", result.Outputs["title"]); // Should get products page after redirect + + // Cleanup + if (webAccessor is IDisposable disposable) + disposable.Dispose(); + } } } diff --git a/test/NetInteractor.Test/PuppeteerSharpWebAccessorTests.cs b/test/NetInteractor.Test/PuppeteerSharpWebAccessorTests.cs deleted file mode 100644 index 320245d..0000000 --- a/test/NetInteractor.Test/PuppeteerSharpWebAccessorTests.cs +++ /dev/null @@ -1,158 +0,0 @@ -using System; -using System.Collections.Specialized; -using System.Threading.Tasks; -using NetInteractor.WebAccessors; -using Xunit; - -namespace NetInteractor.Test -{ - /// - /// Tests specifically for PuppeteerSharpWebAccessor. - /// These tests use real HTTP endpoints to verify browser automation functionality. - /// - public class PuppeteerSharpWebAccessorTests : IDisposable - { - private PuppeteerSharpWebAccessor _accessor; - - public PuppeteerSharpWebAccessorTests() - { - _accessor = new PuppeteerSharpWebAccessor(); - } - - public void Dispose() - { - _accessor?.Dispose(); - } - - [Fact(Skip = "Requires browser download and real network access. Enable for manual testing.")] - public async Task GetAsync_RetrievesPageContent() - { - // Arrange - var url = "https://example.com"; - - // Act - var result = await _accessor.GetAsync(url); - - // Assert - Assert.NotNull(result); - Assert.Equal(200, result.StatusCode); - Assert.NotNull(result.Html); - Assert.Contains("Example Domain", result.Html); - Assert.Contains(url, result.Url); - } - - [Fact(Skip = "Requires browser download and real network access. Enable for manual testing.")] - public async Task GetAsync_HandlesHttpsUrls() - { - // Arrange - var url = "https://www.google.com"; - - // Act - var result = await _accessor.GetAsync(url); - - // Assert - Assert.NotNull(result); - Assert.Equal(200, result.StatusCode); - Assert.NotNull(result.Html); - Assert.NotEmpty(result.Html); - } - - [Fact(Skip = "Requires browser download and real network access. Enable for manual testing.")] - public async Task GetAsync_PopulatesResponseInfo() - { - // Arrange - var url = "https://example.com"; - - // Act - var result = await _accessor.GetAsync(url); - - // Assert - Assert.NotNull(result); - Assert.InRange(result.StatusCode, 200, 299); // Success status codes - Assert.NotNull(result.Html); - Assert.NotEmpty(result.Html); - Assert.NotNull(result.Url); - Assert.NotNull(result.Headers); - } - - [Fact(Skip = "Requires browser download and real network access. Enable for manual testing.")] - public async Task GetAsync_RendersJavaScript() - { - // This test verifies that PuppeteerSharp can execute JavaScript - // which is a key advantage over HttpClient-based solutions - - // Arrange - // Use a site known to have JavaScript-rendered content - var url = "https://example.com"; - - // Act - var result = await _accessor.GetAsync(url); - - // Assert - Assert.NotNull(result); - Assert.Equal(200, result.StatusCode); - Assert.NotNull(result.Html); - // Verify we got rendered HTML (not just source) - Assert.Contains(" + + + + + + diff --git a/test/NetInteractor.Test/TestWebApp/Pages/js-redirect.html b/test/NetInteractor.Test/TestWebApp/Pages/js-redirect.html new file mode 100644 index 0000000..58047e4 --- /dev/null +++ b/test/NetInteractor.Test/TestWebApp/Pages/js-redirect.html @@ -0,0 +1,16 @@ + + + + JavaScript Redirect Test + + + +

Redirecting...

+

You should be redirected to the products page shortly.

+ + diff --git a/test/NetInteractor.Test/TestWebApp/TestWebApplicationFactory.cs b/test/NetInteractor.Test/TestWebApp/TestWebApplicationFactory.cs index 3cdb704..2876133 100644 --- a/test/NetInteractor.Test/TestWebApp/TestWebApplicationFactory.cs +++ b/test/NetInteractor.Test/TestWebApp/TestWebApplicationFactory.cs @@ -199,6 +199,12 @@ public TestWebApplicationFactory(ServerMode mode = ServerMode.TestServer) await context.Response.WriteAsync(html); }); + // JavaScript redirect test endpoint + _app.MapGet("/js-redirect-test", async context => + { + await context.Response.WriteAsync(LoadPage("js-redirect.html")); + }); + _app.Start(); if (mode == ServerMode.Kestrel)