diff --git a/Directory.Packages.props b/Directory.Packages.props index e820b4f..b0bb0f4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,7 +9,8 @@ + - + \ No newline at end of file diff --git a/NetInteractor.sln b/NetInteractor.sln index 87eb8e7..093d77f 100644 --- a/NetInteractor.sln +++ b/NetInteractor.sln @@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetInteractor", "src\NetInt EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetInteractor.Test", "test\NetInteractor.Test\NetInteractor.Test.csproj", "{A9186BD7-EFFC-4B4D-B828-C4F4AA924C50}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetInteractor.PuppeteerSharp", "src\NetInteractor.PuppeteerSharp\NetInteractor.PuppeteerSharp.csproj", "{96C0F6DB-7F80-4E41-B9E8-75C5F750B4BF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -18,9 +20,6 @@ Global Release|x64 = Release|x64 Release|x86 = Release|x86 EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {75781078-1761-4EDD-A872-5F71D1FF9721}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {75781078-1761-4EDD-A872-5F71D1FF9721}.Debug|Any CPU.Build.0 = Debug|Any CPU @@ -46,9 +45,25 @@ Global {A9186BD7-EFFC-4B4D-B828-C4F4AA924C50}.Release|x64.Build.0 = Release|Any CPU {A9186BD7-EFFC-4B4D-B828-C4F4AA924C50}.Release|x86.ActiveCfg = Release|Any CPU {A9186BD7-EFFC-4B4D-B828-C4F4AA924C50}.Release|x86.Build.0 = Release|Any CPU + {96C0F6DB-7F80-4E41-B9E8-75C5F750B4BF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {96C0F6DB-7F80-4E41-B9E8-75C5F750B4BF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {96C0F6DB-7F80-4E41-B9E8-75C5F750B4BF}.Debug|x64.ActiveCfg = Debug|Any CPU + {96C0F6DB-7F80-4E41-B9E8-75C5F750B4BF}.Debug|x64.Build.0 = Debug|Any CPU + {96C0F6DB-7F80-4E41-B9E8-75C5F750B4BF}.Debug|x86.ActiveCfg = Debug|Any CPU + {96C0F6DB-7F80-4E41-B9E8-75C5F750B4BF}.Debug|x86.Build.0 = Debug|Any CPU + {96C0F6DB-7F80-4E41-B9E8-75C5F750B4BF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {96C0F6DB-7F80-4E41-B9E8-75C5F750B4BF}.Release|Any CPU.Build.0 = Release|Any CPU + {96C0F6DB-7F80-4E41-B9E8-75C5F750B4BF}.Release|x64.ActiveCfg = Release|Any CPU + {96C0F6DB-7F80-4E41-B9E8-75C5F750B4BF}.Release|x64.Build.0 = Release|Any CPU + {96C0F6DB-7F80-4E41-B9E8-75C5F750B4BF}.Release|x86.ActiveCfg = Release|Any CPU + {96C0F6DB-7F80-4E41-B9E8-75C5F750B4BF}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {75781078-1761-4EDD-A872-5F71D1FF9721} = {F5108642-5386-4F95-ACF8-8DA0AD621820} {A9186BD7-EFFC-4B4D-B828-C4F4AA924C50} = {F5108642-5386-4F95-ACF8-8DA0AD621820} + {96C0F6DB-7F80-4E41-B9E8-75C5F750B4BF} = {F5108642-5386-4F95-ACF8-8DA0AD621820} EndGlobalSection EndGlobal diff --git a/src/NetInteractor.PuppeteerSharp/NetInteractor.PuppeteerSharp.csproj b/src/NetInteractor.PuppeteerSharp/NetInteractor.PuppeteerSharp.csproj new file mode 100644 index 0000000..3906d1c --- /dev/null +++ b/src/NetInteractor.PuppeteerSharp/NetInteractor.PuppeteerSharp.csproj @@ -0,0 +1,22 @@ + + + netstandard2.0;net8.0;net9.0 + Kerry Jiang + PuppeteerSharp implementation of IWebAccessor for NetInteractor - enables browser automation with headless Chrome/Chromium for advanced web interactions. + Apache-2.0 + Web;Automation;Html;Scraping;Testing;PuppeteerSharp;Browser;WebAutomation;Headless;Chrome;Chromium + https://github.com/kerryjiang/NetInteractor.git + + + v$(PackageVersion).md + + + + + + + + + + + diff --git a/src/NetInteractor.PuppeteerSharp/PuppeteerSharpWebAccessor.cs b/src/NetInteractor.PuppeteerSharp/PuppeteerSharpWebAccessor.cs new file mode 100644 index 0000000..408991b --- /dev/null +++ b/src/NetInteractor.PuppeteerSharp/PuppeteerSharpWebAccessor.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Specialized; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using PuppeteerSharp; + +namespace NetInteractor.WebAccessors +{ + public class PuppeteerSharpWebAccessor : IWebAccessor, IDisposable + { + private IBrowser _browser; + private readonly LaunchOptions _launchOptions; + private readonly SemaphoreSlim _browserLock = new SemaphoreSlim(1, 1); + private bool _disposed; + + public PuppeteerSharpWebAccessor(LaunchOptions launchOptions = null) + { + _launchOptions = launchOptions ?? new LaunchOptions + { + Headless = true, + Args = new[] { "--no-sandbox", "--disable-setuid-sandbox" } + }; + } + + private async Task GetBrowserAsync() + { + if (_browser == null) + { + await _browserLock.WaitAsync(); + try + { + // Double-check after acquiring the lock + if (_browser == null) + { + // Download browser if needed + var browserFetcher = new BrowserFetcher(); + + // Download the browser (it will skip if already downloaded) + await browserFetcher.DownloadAsync(); + + _browser = await Puppeteer.LaunchAsync(_launchOptions); + } + } + finally + { + _browserLock.Release(); + } + } + return _browser; + } + + public async Task GetAsync(string url) + { + var browser = await GetBrowserAsync(); + var page = await browser.NewPageAsync(); + + try + { + var response = await page.GoToAsync(url, new NavigationOptions + { + WaitUntil = new[] { WaitUntilNavigation.Networkidle0 } + }); + + return await GetResultFromResponse(page, response); + } + finally + { + await page.CloseAsync(); + } + } + + public async Task PostAsync(string url, NameValueCollection formValues) + { + var browser = await GetBrowserAsync(); + var page = await browser.NewPageAsync(); + + try + { + // Navigate to the page first + await page.GoToAsync(url, new NavigationOptions + { + WaitUntil = new[] { WaitUntilNavigation.Networkidle0 } + }); + + // Set up request interception to modify the request to POST + await page.SetRequestInterceptionAsync(true); + + var formData = string.Join("&", formValues.Keys.OfType() + .Select(k => k + "=" + Uri.EscapeDataString(formValues[k]))); + + var responseTaskSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + EventHandler requestHandler = null; + EventHandler responseHandler = null; + + try + { + requestHandler = async (sender, e) => + { + if (e.Request.Url == url) + { + await e.Request.ContinueAsync(new Payload + { + Method = HttpMethod.Post, + PostData = formData, + Headers = e.Request.Headers.Concat(new[] + { + new System.Collections.Generic.KeyValuePair( + "Content-Type", "application/x-www-form-urlencoded") + }).ToDictionary(kvp => kvp.Key, kvp => kvp.Value) + }); + } + else + { + await e.Request.ContinueAsync(); + } + }; + + responseHandler = (sender, e) => + { + if (e.Response.Url == url) + { + responseTaskSource.TrySetResult(e.Response); + } + }; + + page.Request += requestHandler; + page.Response += responseHandler; + + // Register cancellation + cts.Token.Register(() => responseTaskSource.TrySetCanceled()); + + // Navigate again to trigger the POST + await page.GoToAsync(url, new NavigationOptions + { + WaitUntil = new[] { WaitUntilNavigation.Networkidle0 } + }); + + var response = await responseTaskSource.Task; + return await GetResultFromResponse(page, response); + } + finally + { + // Clean up event handlers + if (requestHandler != null) + page.Request -= requestHandler; + if (responseHandler != null) + page.Response -= responseHandler; + cts.Dispose(); + } + } + finally + { + await page.CloseAsync(); + } + } + + private async Task GetResultFromResponse(IPage page, IResponse response) + { + var html = await page.GetContentAsync(); + + // Create a mock HttpResponseHeaders since we cannot instantiate it directly + // We'll store headers in a custom wrapper that mimics HttpResponseHeaders + var mockHeaders = CreateMockHeaders(response.Headers); + + return new ResponseInfo + { + StatusCode = (int)response.Status, + StatusDescription = response.StatusText, + Html = html, + Url = response.Url, + Headers = mockHeaders + }; + } + + private HttpResponseHeaders CreateMockHeaders(System.Collections.Generic.IDictionary headers) + { + // Since HttpResponseHeaders is sealed and cannot be instantiated directly, + // we create it via HttpResponseMessage which is the standard way + var mockResponse = new System.Net.Http.HttpResponseMessage(); + + if (headers != null) + { + foreach (var header in headers) + { + try + { + mockResponse.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + catch (ArgumentException) + { + // Some headers might be content headers, skip them + } + catch (FormatException) + { + // Invalid header format, skip it + } + } + } + + return mockResponse.Headers; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _browser?.Dispose(); + _browserLock?.Dispose(); + } + _disposed = true; + } + } + } +} diff --git a/test/NetInteractor.Test/IntegrationTests.cs b/test/NetInteractor.Test/IntegrationTests.cs index f58518e..6d5cc62 100644 --- a/test/NetInteractor.Test/IntegrationTests.cs +++ b/test/NetInteractor.Test/IntegrationTests.cs @@ -1,4 +1,6 @@ using System; +using System.Collections; +using System.Collections.Generic; using System.Collections.Specialized; using System.IO; using System.Reflection; @@ -10,9 +12,43 @@ namespace NetInteractor.Test { - public class IntegrationTests : IClassFixture + /// + /// Provides web accessor test data for integration tests. + /// + public class WebAccessorTestData : IEnumerable + { + private readonly TestWebApplicationFactory _testServerFactory; + private readonly TestWebApplicationFactory _kestrelFactory; + + public WebAccessorTestData() + { + // Create TestServer factory for HttpClient tests (in-memory, faster) + _testServerFactory = new TestWebApplicationFactory(ServerMode.TestServer); + + // Create Kestrel factory for PuppeteerSharp tests (real HTTP endpoint) + _kestrelFactory = new TestWebApplicationFactory(ServerMode.Kestrel); + } + + public IEnumerator GetEnumerator() + { + // HttpClient accessor with TestServer (in-memory) + var httpClient = _testServerFactory.CreateClient(); + yield return new object[] { new HttpClientWebAccessor(httpClient), _testServerFactory.ServerUrl }; + + // PuppeteerSharp accessor with Kestrel (real HTTP) - disabled by default in CI/CD + // Requires Chrome download which needs internet access not available in GitHub Actions + // To enable locally: set environment variable ENABLE_PUPPETEER_TESTS=true + if (Environment.GetEnvironmentVariable("ENABLE_PUPPETEER_TESTS") == "true") + { + yield return new object[] { new PuppeteerSharpWebAccessor(), _kestrelFactory.ServerUrl }; + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + public class IntegrationTests { - private readonly TestWebApplicationFactory _factory; private static readonly string ScriptsDirectory; static IntegrationTests() @@ -28,160 +64,185 @@ private static InteractConfig LoadConfig(string configName) return ConfigFactory.DeserializeXml(xml); } - public IntegrationTests(TestWebApplicationFactory factory) - { - _factory = factory; - } - - [Fact] - public async Task TestGetRequest_ExtractTitle() + [Theory] + [ClassData(typeof(WebAccessorTestData))] + public async Task TestGetRequest_ExtractTitle(IWebAccessor webAccessor, string baseUrl) { // Arrange - var client = _factory.CreateClient(); - var webAccessor = new HttpClientWebAccessor(client); var executor = new InterationExecutor(webAccessor); var config = LoadConfig("GetRequest_ExtractTitle.config"); + var inputs = new NameValueCollection { ["BaseUrl"] = baseUrl }; // Act - var result = await executor.ExecuteAsync(config); + var result = await executor.ExecuteAsync(config, inputs); // Assert Assert.True(result.Ok, result.Message); Assert.Equal("Welcome to Test Shop", result.Outputs["title"]); + + // Cleanup + if (webAccessor is IDisposable disposable) + disposable.Dispose(); } - [Fact] - public async Task TestGetRequest_ExtractMultipleValues() + [Theory] + [ClassData(typeof(WebAccessorTestData))] + public async Task TestGetRequest_ExtractMultipleValues(IWebAccessor webAccessor, string baseUrl) { // Arrange - var client = _factory.CreateClient(); - var webAccessor = new HttpClientWebAccessor(client); var executor = new InterationExecutor(webAccessor); var config = LoadConfig("GetRequest_ExtractMultipleValues.config"); + var inputs = new NameValueCollection { ["BaseUrl"] = baseUrl }; // Act - var result = await executor.ExecuteAsync(config); + var result = await executor.ExecuteAsync(config, inputs); // Assert Assert.True(result.Ok, result.Message); Assert.Contains("Value One", result.Outputs["values"]); Assert.Contains("Value Two", result.Outputs["values"]); Assert.Contains("Value Three", result.Outputs["values"]); + + // Cleanup + if (webAccessor is IDisposable disposable) + disposable.Dispose(); } - [Fact] - public async Task TestGetRequest_ExtractAttribute() + [Theory] + [ClassData(typeof(WebAccessorTestData))] + public async Task TestGetRequest_ExtractAttribute(IWebAccessor webAccessor, string baseUrl) { // Arrange - var client = _factory.CreateClient(); - var webAccessor = new HttpClientWebAccessor(client); var executor = new InterationExecutor(webAccessor); var config = LoadConfig("GetRequest_ExtractAttribute.config"); + var inputs = new NameValueCollection { ["BaseUrl"] = baseUrl }; // Act - var result = await executor.ExecuteAsync(config); + var result = await executor.ExecuteAsync(config, inputs); // Assert Assert.True(result.Ok, result.Message); Assert.Equal("/images/test.png", result.Outputs["imageSrc"]); + + // Cleanup + if (webAccessor is IDisposable disposable) + disposable.Dispose(); } - [Fact] - public async Task TestGetRequest_ExtractWithRegex() + [Theory] + [ClassData(typeof(WebAccessorTestData))] + public async Task TestGetRequest_ExtractWithRegex(IWebAccessor webAccessor, string baseUrl) { // Arrange - var client = _factory.CreateClient(); - var webAccessor = new HttpClientWebAccessor(client); var executor = new InterationExecutor(webAccessor); var config = LoadConfig("GetRequest_ExtractWithRegex.config"); + var inputs = new NameValueCollection { ["BaseUrl"] = baseUrl }; // Act - var result = await executor.ExecuteAsync(config); + var result = await executor.ExecuteAsync(config, inputs); // Assert Assert.True(result.Ok, result.Message); Assert.Equal("98765", result.Outputs["orderId"]); + + // Cleanup + if (webAccessor is IDisposable disposable) + disposable.Dispose(); } - [Fact] - public async Task TestGetRequest_ExpectedValueValidation_Success() + [Theory] + [ClassData(typeof(WebAccessorTestData))] + public async Task TestGetRequest_ExpectedValueValidation_Success(IWebAccessor webAccessor, string baseUrl) { // Arrange - var client = _factory.CreateClient(); - var webAccessor = new HttpClientWebAccessor(client); var executor = new InterationExecutor(webAccessor); var config = LoadConfig("GetRequest_ExpectedValueValidation_Success.config"); + var inputs = new NameValueCollection { ["BaseUrl"] = baseUrl }; // Act - var result = await executor.ExecuteAsync(config); + var result = await executor.ExecuteAsync(config, inputs); // Assert Assert.True(result.Ok, result.Message); + + // Cleanup + if (webAccessor is IDisposable disposable) + disposable.Dispose(); } - [Fact] - public async Task TestGetRequest_ExpectedValueValidation_Failure() + [Theory] + [ClassData(typeof(WebAccessorTestData))] + public async Task TestGetRequest_ExpectedValueValidation_Failure(IWebAccessor webAccessor, string baseUrl) { // Arrange - var client = _factory.CreateClient(); - var webAccessor = new HttpClientWebAccessor(client); var executor = new InterationExecutor(webAccessor); var config = LoadConfig("GetRequest_ExpectedValueValidation_Failure.config"); + var inputs = new NameValueCollection { ["BaseUrl"] = baseUrl }; // Act - var result = await executor.ExecuteAsync(config); + var result = await executor.ExecuteAsync(config, inputs); // Assert Assert.False(result.Ok); Assert.Contains("Expected", result.Message); + + // Cleanup + if (webAccessor is IDisposable disposable) + disposable.Dispose(); } - [Fact] - public async Task TestPostRequest_FormSubmission() + [Theory] + [ClassData(typeof(WebAccessorTestData))] + public async Task TestPostRequest_FormSubmission(IWebAccessor webAccessor, string baseUrl) { // Arrange - var client = _factory.CreateClient(); - var webAccessor = new HttpClientWebAccessor(client); var executor = new InterationExecutor(webAccessor); var config = LoadConfig("PostRequest_FormSubmission.config"); + var inputs = new NameValueCollection { ["BaseUrl"] = baseUrl }; // Act - var result = await executor.ExecuteAsync(config); + var result = await executor.ExecuteAsync(config, inputs); // Assert Assert.True(result.Ok, result.Message); + + // Cleanup + if (webAccessor is IDisposable disposable) + disposable.Dispose(); } - [Fact] - public async Task TestPostRequest_FormSubmissionWithOutputExtraction() + [Theory] + [ClassData(typeof(WebAccessorTestData))] + public async Task TestPostRequest_FormSubmissionWithOutputExtraction(IWebAccessor webAccessor, string baseUrl) { // Arrange - var client = _factory.CreateClient(); - var webAccessor = new HttpClientWebAccessor(client); var executor = new InterationExecutor(webAccessor); var config = LoadConfig("PostRequest_FormSubmissionWithOutputExtraction.config"); + var inputs = new NameValueCollection { ["BaseUrl"] = baseUrl }; // Act - var result = await executor.ExecuteAsync(config); + var result = await executor.ExecuteAsync(config, inputs); // Assert Assert.True(result.Ok, result.Message); Assert.Equal("Jane Smith", result.Outputs["customerName"]); + + // Cleanup + if (webAccessor is IDisposable disposable) + disposable.Dispose(); } - [Fact] - public async Task TestLoginFlow_WithInputParameters() + [Theory] + [ClassData(typeof(WebAccessorTestData))] + public async Task TestLoginFlow_WithInputParameters(IWebAccessor webAccessor, string baseUrl) { // Arrange - var client = _factory.CreateClient(); - var webAccessor = new HttpClientWebAccessor(client); var executor = new InterationExecutor(webAccessor); - - // This test focuses on input parameter substitution var config = LoadConfig("LoginFlow_WithInputParameters.config"); var inputs = new NameValueCollection { + ["BaseUrl"] = baseUrl, ["BillingName"] = "Test User", ["Email"] = "test@example.com" }; @@ -193,60 +254,66 @@ public async Task TestLoginFlow_WithInputParameters() Assert.True(result.Ok, result.Message); Assert.Equal("Test User", result.Outputs["customerName"]); Assert.Equal("test@example.com", result.Outputs["customerEmail"]); + + // Cleanup + if (webAccessor is IDisposable disposable) + disposable.Dispose(); } - [Fact] - public async Task TestMultiStepWorkflow_ShoppingCart() + [Theory] + [ClassData(typeof(WebAccessorTestData))] + public async Task TestMultiStepWorkflow_ShoppingCart(IWebAccessor webAccessor, string baseUrl) { // Arrange - var client = _factory.CreateClient(); - var webAccessor = new HttpClientWebAccessor(client); var executor = new InterationExecutor(webAccessor); - - // This test focuses on multi-step workflow - note that outputs from later steps - // may overwrite earlier outputs if they use the same context var config = LoadConfig("MultiStepWorkflow_ShoppingCart.config"); + var inputs = new NameValueCollection { ["BaseUrl"] = baseUrl }; // Act - var result = await executor.ExecuteAsync(config); + var result = await executor.ExecuteAsync(config, inputs); // Assert Assert.True(result.Ok, result.Message); - // Check that cart outputs are available (final step outputs) Assert.Equal("$25.00", result.Outputs["cartTotal"]); - Assert.Equal("Medium", result.Outputs["itemSize"]); // Default cart item + Assert.Equal("Medium", result.Outputs["itemSize"]); + + // Cleanup + if (webAccessor is IDisposable disposable) + disposable.Dispose(); } - [Fact] - public async Task TestCallTarget_ReusableWorkflow() + [Theory] + [ClassData(typeof(WebAccessorTestData))] + public async Task TestCallTarget_ReusableWorkflow(IWebAccessor webAccessor, string baseUrl) { // Arrange - var client = _factory.CreateClient(); - var webAccessor = new HttpClientWebAccessor(client); var executor = new InterationExecutor(webAccessor); - - // This test focuses on the call target functionality var config = LoadConfig("CallTarget_ReusableWorkflow.config"); + var inputs = new NameValueCollection { ["BaseUrl"] = baseUrl }; // Act - var result = await executor.ExecuteAsync(config); + var result = await executor.ExecuteAsync(config, inputs); // Assert Assert.True(result.Ok, result.Message); Assert.Equal("Data Extraction Test", result.Outputs["title"]); + + // Cleanup + if (webAccessor is IDisposable disposable) + disposable.Dispose(); } - [Fact] - public async Task TestConditionalExecution_IfStatement() + [Theory] + [ClassData(typeof(WebAccessorTestData))] + public async Task TestConditionalExecution_IfStatement(IWebAccessor webAccessor, string baseUrl) { // Arrange - var client = _factory.CreateClient(); - var webAccessor = new HttpClientWebAccessor(client); var executor = new InterationExecutor(webAccessor); var config = LoadConfig("ConditionalExecution_IfStatement.config"); var inputs = new NameValueCollection { + ["BaseUrl"] = baseUrl, ["ShouldLogin"] = "false" }; @@ -256,61 +323,75 @@ public async Task TestConditionalExecution_IfStatement() // Assert Assert.True(result.Ok, result.Message); Assert.Equal("Data Extraction Test", result.Outputs["title"]); + + // Cleanup + if (webAccessor is IDisposable disposable) + disposable.Dispose(); } - [Fact] - public async Task TestExecuteSpecificTarget() + [Theory] + [ClassData(typeof(WebAccessorTestData))] + public async Task TestExecuteSpecificTarget(IWebAccessor webAccessor, string baseUrl) { // Arrange - var client = _factory.CreateClient(); - var webAccessor = new HttpClientWebAccessor(client); var executor = new InterationExecutor(webAccessor); var config = LoadConfig("ExecuteSpecificTarget.config"); + var inputs = new NameValueCollection { ["BaseUrl"] = baseUrl }; // Act - Execute specific target instead of default - var result = await executor.ExecuteAsync(config, null, "Products"); + var result = await executor.ExecuteAsync(config, inputs, "Products"); // Assert Assert.True(result.Ok, result.Message); Assert.Equal("Test Product", result.Outputs["productName"]); Assert.Null(result.Outputs["title"]); // Should not have executed Main target + + // Cleanup + if (webAccessor is IDisposable disposable) + disposable.Dispose(); } - [Fact] - public async Task TestRedirect_301_FollowsRedirect() + [Theory] + [ClassData(typeof(WebAccessorTestData))] + public async Task TestRedirect_301_FollowsRedirect(IWebAccessor webAccessor, string baseUrl) { // Arrange - var client = _factory.CreateClient(); - // Use TestHttpClientWebAccessor which manually handles redirects - // because HttpClient from TestServer doesn't support AllowAutoRedirect - var webAccessor = new HttpClientWebAccessor(client); var executor = new InterationExecutor(webAccessor); var config = LoadConfig("RedirectTest.config"); + var inputs = new NameValueCollection { ["BaseUrl"] = baseUrl }; // Act - Should follow 301 redirect from /redirect-test to /products - var result = await executor.ExecuteAsync(config); + 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(); } - [Fact] - public async Task TestRedirect_AfterPost_FollowsRedirect() + [Theory] + [ClassData(typeof(WebAccessorTestData))] + public async Task TestRedirect_AfterPost_FollowsRedirect(IWebAccessor webAccessor, string baseUrl) { // Arrange - var client = _factory.CreateClient(); - var webAccessor = new HttpClientWebAccessor(client); var executor = new InterationExecutor(webAccessor); var config = LoadConfig("RedirectAfterPostTest.config"); + var inputs = new NameValueCollection { ["BaseUrl"] = baseUrl }; // Act - Should follow redirect after POST from /post-redirect-test to /post-redirect-result - var result = await executor.ExecuteAsync(config); + var result = await executor.ExecuteAsync(config, inputs); // Assert Assert.True(result.Ok, result.Message); Assert.Equal("Post Redirect Success", result.Outputs["title"]); Assert.Equal("Redirect User", result.Outputs["customerName"]); + + // Cleanup + if (webAccessor is IDisposable disposable) + disposable.Dispose(); } } } diff --git a/test/NetInteractor.Test/NetInteractor.Test.csproj b/test/NetInteractor.Test/NetInteractor.Test.csproj index c1abf57..2107d95 100644 --- a/test/NetInteractor.Test/NetInteractor.Test.csproj +++ b/test/NetInteractor.Test/NetInteractor.Test.csproj @@ -14,6 +14,7 @@ + diff --git a/test/NetInteractor.Test/PuppeteerSharpWebAccessorTests.cs b/test/NetInteractor.Test/PuppeteerSharpWebAccessorTests.cs new file mode 100644 index 0000000..320245d --- /dev/null +++ b/test/NetInteractor.Test/PuppeteerSharpWebAccessorTests.cs @@ -0,0 +1,158 @@ +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(" - + @@ -8,7 +8,7 @@ - + diff --git a/test/NetInteractor.Test/Scripts/ConditionalExecution_IfStatement.config b/test/NetInteractor.Test/Scripts/ConditionalExecution_IfStatement.config index 7a0ac9c..153e77e 100644 --- a/test/NetInteractor.Test/Scripts/ConditionalExecution_IfStatement.config +++ b/test/NetInteractor.Test/Scripts/ConditionalExecution_IfStatement.config @@ -1,16 +1,16 @@ - + - + - + diff --git a/test/NetInteractor.Test/Scripts/ExecuteSpecificTarget.config b/test/NetInteractor.Test/Scripts/ExecuteSpecificTarget.config index b01ffde..6353a4c 100644 --- a/test/NetInteractor.Test/Scripts/ExecuteSpecificTarget.config +++ b/test/NetInteractor.Test/Scripts/ExecuteSpecificTarget.config @@ -1,12 +1,12 @@ - + - + diff --git a/test/NetInteractor.Test/Scripts/GetRequest_ExpectedValueValidation_Failure.config b/test/NetInteractor.Test/Scripts/GetRequest_ExpectedValueValidation_Failure.config index d6495f1..49bf7b5 100644 --- a/test/NetInteractor.Test/Scripts/GetRequest_ExpectedValueValidation_Failure.config +++ b/test/NetInteractor.Test/Scripts/GetRequest_ExpectedValueValidation_Failure.config @@ -1,6 +1,6 @@ - + diff --git a/test/NetInteractor.Test/Scripts/GetRequest_ExpectedValueValidation_Success.config b/test/NetInteractor.Test/Scripts/GetRequest_ExpectedValueValidation_Success.config index b78da45..d9e2053 100644 --- a/test/NetInteractor.Test/Scripts/GetRequest_ExpectedValueValidation_Success.config +++ b/test/NetInteractor.Test/Scripts/GetRequest_ExpectedValueValidation_Success.config @@ -1,6 +1,6 @@ - + diff --git a/test/NetInteractor.Test/Scripts/GetRequest_ExtractAttribute.config b/test/NetInteractor.Test/Scripts/GetRequest_ExtractAttribute.config index 04c24e2..430c116 100644 --- a/test/NetInteractor.Test/Scripts/GetRequest_ExtractAttribute.config +++ b/test/NetInteractor.Test/Scripts/GetRequest_ExtractAttribute.config @@ -1,6 +1,6 @@ - + diff --git a/test/NetInteractor.Test/Scripts/GetRequest_ExtractMultipleValues.config b/test/NetInteractor.Test/Scripts/GetRequest_ExtractMultipleValues.config index 8924d3d..ea7381e 100644 --- a/test/NetInteractor.Test/Scripts/GetRequest_ExtractMultipleValues.config +++ b/test/NetInteractor.Test/Scripts/GetRequest_ExtractMultipleValues.config @@ -1,6 +1,6 @@ - + diff --git a/test/NetInteractor.Test/Scripts/GetRequest_ExtractTitle.config b/test/NetInteractor.Test/Scripts/GetRequest_ExtractTitle.config index 35b20c0..a69a283 100644 --- a/test/NetInteractor.Test/Scripts/GetRequest_ExtractTitle.config +++ b/test/NetInteractor.Test/Scripts/GetRequest_ExtractTitle.config @@ -1,6 +1,6 @@ - + diff --git a/test/NetInteractor.Test/Scripts/GetRequest_ExtractWithRegex.config b/test/NetInteractor.Test/Scripts/GetRequest_ExtractWithRegex.config index ad0813d..20a7c98 100644 --- a/test/NetInteractor.Test/Scripts/GetRequest_ExtractWithRegex.config +++ b/test/NetInteractor.Test/Scripts/GetRequest_ExtractWithRegex.config @@ -1,6 +1,6 @@ - + diff --git a/test/NetInteractor.Test/Scripts/LoginFlow_WithInputParameters.config b/test/NetInteractor.Test/Scripts/LoginFlow_WithInputParameters.config index 24d5a40..80f915c 100644 --- a/test/NetInteractor.Test/Scripts/LoginFlow_WithInputParameters.config +++ b/test/NetInteractor.Test/Scripts/LoginFlow_WithInputParameters.config @@ -1,6 +1,6 @@ - + diff --git a/test/NetInteractor.Test/Scripts/MultiStepWorkflow_ShoppingCart.config b/test/NetInteractor.Test/Scripts/MultiStepWorkflow_ShoppingCart.config index 853d69a..e14cef4 100644 --- a/test/NetInteractor.Test/Scripts/MultiStepWorkflow_ShoppingCart.config +++ b/test/NetInteractor.Test/Scripts/MultiStepWorkflow_ShoppingCart.config @@ -1,13 +1,13 @@ - + - + diff --git a/test/NetInteractor.Test/Scripts/PostRequest_FormSubmission.config b/test/NetInteractor.Test/Scripts/PostRequest_FormSubmission.config index e0aede8..4882387 100644 --- a/test/NetInteractor.Test/Scripts/PostRequest_FormSubmission.config +++ b/test/NetInteractor.Test/Scripts/PostRequest_FormSubmission.config @@ -1,6 +1,6 @@ - + diff --git a/test/NetInteractor.Test/Scripts/PostRequest_FormSubmissionWithOutputExtraction.config b/test/NetInteractor.Test/Scripts/PostRequest_FormSubmissionWithOutputExtraction.config index a862390..198886e 100644 --- a/test/NetInteractor.Test/Scripts/PostRequest_FormSubmissionWithOutputExtraction.config +++ b/test/NetInteractor.Test/Scripts/PostRequest_FormSubmissionWithOutputExtraction.config @@ -1,6 +1,6 @@ - + diff --git a/test/NetInteractor.Test/Scripts/RedirectAfterPostTest.config b/test/NetInteractor.Test/Scripts/RedirectAfterPostTest.config index 52fbbc2..e2fa636 100644 --- a/test/NetInteractor.Test/Scripts/RedirectAfterPostTest.config +++ b/test/NetInteractor.Test/Scripts/RedirectAfterPostTest.config @@ -1,6 +1,6 @@ - + diff --git a/test/NetInteractor.Test/Scripts/RedirectTest.config b/test/NetInteractor.Test/Scripts/RedirectTest.config index adb69fe..19b27b0 100644 --- a/test/NetInteractor.Test/Scripts/RedirectTest.config +++ b/test/NetInteractor.Test/Scripts/RedirectTest.config @@ -1,6 +1,6 @@ - + diff --git a/test/NetInteractor.Test/TestWebApp/TestWebApplicationFactory.cs b/test/NetInteractor.Test/TestWebApp/TestWebApplicationFactory.cs index 6d2b34e..3cdb704 100644 --- a/test/NetInteractor.Test/TestWebApp/TestWebApplicationFactory.cs +++ b/test/NetInteractor.Test/TestWebApp/TestWebApplicationFactory.cs @@ -6,17 +6,27 @@ using Microsoft.Extensions.Hosting; using System; using System.IO; +using System.Linq; using System.Net.Http; using System.Reflection; using System.Threading.Tasks; namespace NetInteractor.Test.TestWebApp { + public enum ServerMode + { + TestServer, // In-memory TestServer for HttpClient + Kestrel // Real HTTP server for PuppeteerSharp + } + public class TestWebApplicationFactory : IDisposable { private readonly WebApplication _app; - private readonly TestServer _server; + private readonly HttpClient _httpClient; + private readonly TestServer _testServer; private static readonly string PagesDirectory; + private readonly string _serverUrl; + private readonly ServerMode _mode; static TestWebApplicationFactory() { @@ -29,10 +39,26 @@ private static string LoadPage(string pageName) return File.ReadAllText(Path.Combine(PagesDirectory, pageName)); } - public TestWebApplicationFactory() + public TestWebApplicationFactory(ServerMode mode = ServerMode.TestServer) { + _mode = mode; var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); + + if (mode == ServerMode.Kestrel) + { + // Configure to use Kestrel for real HTTP (required for PuppeteerSharp) + builder.WebHost.UseKestrel(options => + { + // Use a dynamic port for Kestrel to avoid conflicts + // Listen on IPv4 loopback with dynamic port + options.Listen(System.Net.IPAddress.Loopback, 0); + }); + } + else + { + // Use TestServer for in-memory testing (faster for HttpClient) + builder.WebHost.UseTestServer(); + } // Add services builder.Services.AddRouting(); @@ -174,16 +200,48 @@ public TestWebApplicationFactory() }); _app.Start(); - _server = _app.GetTestServer(); + + if (mode == ServerMode.Kestrel) + { + // Get the actual URL assigned by Kestrel + var addresses = _app.Urls; + if (addresses.Count > 0) + { + _serverUrl = addresses.First(); + } + else + { + _serverUrl = "http://localhost:5000"; // Fallback + } + + // Create an HttpClient without BaseAddress so it works with absolute URLs + _httpClient = new HttpClient(); + } + else + { + // For TestServer, get the TestServer instance and create client + _testServer = _app.GetTestServer(); + _httpClient = _testServer.CreateClient(); + _serverUrl = "http://localhost"; // TestServer doesn't have real URL, use placeholder + } } public HttpClient CreateClient() { - return _server.CreateClient(); + return _httpClient; } + /// + /// Gets the base URL of the server. + /// For TestServer: returns "http://localhost" (placeholder, works with relative URLs) + /// For Kestrel: returns actual server URL like "http://127.0.0.1:12345" + /// + public string ServerUrl => _serverUrl; + public void Dispose() { + _httpClient?.Dispose(); + _testServer?.Dispose(); _app?.DisposeAsync().AsTask().Wait(); } }