From 44b2cf491f9ddc78dab3e42b222e6a49ad68aac2 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 21 Jan 2026 07:52:28 +0000
Subject: [PATCH 1/9] Initial plan
From be7ddb946df0bb0eac21f87407f21c23c64bbcb1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 21 Jan 2026 07:58:18 +0000
Subject: [PATCH 2/9] Add NetInteractor.PuppeteerSharp project with
IWebAccessor implementation
Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com>
---
Directory.Packages.props | 3 +-
NetInteractor.sln | 21 +-
.../NetInteractor.PuppeteerSharp.csproj | 22 +++
.../PuppeteerSharpWebAccessor.cs | 184 ++++++++++++++++++
4 files changed, 226 insertions(+), 4 deletions(-)
create mode 100644 src/NetInteractor.PuppeteerSharp/NetInteractor.PuppeteerSharp.csproj
create mode 100644 src/NetInteractor.PuppeteerSharp/PuppeteerSharpWebAccessor.cs
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..5033a61
--- /dev/null
+++ b/src/NetInteractor.PuppeteerSharp/PuppeteerSharpWebAccessor.cs
@@ -0,0 +1,184 @@
+using System;
+using System.Collections.Specialized;
+using System.Linq;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Threading.Tasks;
+using PuppeteerSharp;
+
+namespace NetInteractor.WebAccessors
+{
+ public class PuppeteerSharpWebAccessor : IWebAccessor, IDisposable
+ {
+ private IBrowser _browser;
+ private readonly LaunchOptions _launchOptions;
+ 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)
+ {
+ // Download browser if needed
+ var browserFetcher = new BrowserFetcher();
+ await browserFetcher.DownloadAsync();
+
+ _browser = await Puppeteer.LaunchAsync(_launchOptions);
+ }
+ 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])));
+
+ TaskCompletionSource responseTaskSource = new TaskCompletionSource();
+
+ page.Request += 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();
+ }
+ };
+
+ page.Response += (sender, e) =>
+ {
+ if (e.Response.Url == url)
+ {
+ responseTaskSource.TrySetResult(e.Response);
+ }
+ };
+
+ // 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
+ {
+ 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
+ {
+ // Some headers might be content headers, skip them
+ }
+ }
+ }
+
+ return mockResponse.Headers;
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!_disposed)
+ {
+ if (disposing)
+ {
+ _browser?.Dispose();
+ }
+ _disposed = true;
+ }
+ }
+ }
+}
From e88e5e763aeb672535a47b44be85c891e9251c60 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 21 Jan 2026 08:00:58 +0000
Subject: [PATCH 3/9] Fix code review issues: add thread safety, timeout
handling, event cleanup, and exception handling
Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com>
---
.../PuppeteerSharpWebAccessor.cs | 113 ++++++++++++------
1 file changed, 78 insertions(+), 35 deletions(-)
diff --git a/src/NetInteractor.PuppeteerSharp/PuppeteerSharpWebAccessor.cs b/src/NetInteractor.PuppeteerSharp/PuppeteerSharpWebAccessor.cs
index 5033a61..408991b 100644
--- a/src/NetInteractor.PuppeteerSharp/PuppeteerSharpWebAccessor.cs
+++ b/src/NetInteractor.PuppeteerSharp/PuppeteerSharpWebAccessor.cs
@@ -3,6 +3,7 @@
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
+using System.Threading;
using System.Threading.Tasks;
using PuppeteerSharp;
@@ -12,6 +13,7 @@ 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)
@@ -27,11 +29,25 @@ private async Task GetBrowserAsync()
{
if (_browser == null)
{
- // Download browser if needed
- var browserFetcher = new BrowserFetcher();
- await browserFetcher.DownloadAsync();
-
- _browser = await Puppeteer.LaunchAsync(_launchOptions);
+ 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;
}
@@ -75,45 +91,67 @@ public async Task PostAsync(string url, NameValueCollection formVa
var formData = string.Join("&", formValues.Keys.OfType()
.Select(k => k + "=" + Uri.EscapeDataString(formValues[k])));
- TaskCompletionSource responseTaskSource = new TaskCompletionSource();
+ var responseTaskSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
- page.Request += async (sender, e) =>
+ EventHandler requestHandler = null;
+ EventHandler responseHandler = null;
+
+ try
{
- if (e.Request.Url == url)
+ requestHandler = async (sender, e) =>
{
- await e.Request.ContinueAsync(new Payload
+ if (e.Request.Url == url)
{
- Method = HttpMethod.Post,
- PostData = formData,
- Headers = e.Request.Headers.Concat(new[]
+ await e.Request.ContinueAsync(new Payload
{
- new System.Collections.Generic.KeyValuePair(
- "Content-Type", "application/x-www-form-urlencoded")
- }).ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
- });
- }
- else
+ 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) =>
{
- await e.Request.ContinueAsync();
- }
- };
+ if (e.Response.Url == url)
+ {
+ responseTaskSource.TrySetResult(e.Response);
+ }
+ };
- page.Response += (sender, e) =>
- {
- if (e.Response.Url == url)
+ 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
{
- responseTaskSource.TrySetResult(e.Response);
- }
- };
+ WaitUntil = new[] { WaitUntilNavigation.Networkidle0 }
+ });
- // Navigate again to trigger the POST
- await page.GoToAsync(url, new NavigationOptions
+ var response = await responseTaskSource.Task;
+ return await GetResultFromResponse(page, response);
+ }
+ finally
{
- WaitUntil = new[] { WaitUntilNavigation.Networkidle0 }
- });
-
- var response = await responseTaskSource.Task;
- return await GetResultFromResponse(page, response);
+ // Clean up event handlers
+ if (requestHandler != null)
+ page.Request -= requestHandler;
+ if (responseHandler != null)
+ page.Response -= responseHandler;
+ cts.Dispose();
+ }
}
finally
{
@@ -153,10 +191,14 @@ private HttpResponseHeaders CreateMockHeaders(System.Collections.Generic.IDictio
{
mockResponse.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
- catch
+ catch (ArgumentException)
{
// Some headers might be content headers, skip them
}
+ catch (FormatException)
+ {
+ // Invalid header format, skip it
+ }
}
}
@@ -176,6 +218,7 @@ protected virtual void Dispose(bool disposing)
if (disposing)
{
_browser?.Dispose();
+ _browserLock?.Dispose();
}
_disposed = true;
}
From e2243975a747e26e7f3b184d0b30421288c424eb Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 21 Jan 2026 18:08:37 +0000
Subject: [PATCH 4/9] Add unit tests for PuppeteerSharpWebAccessor using
parameterized test approach
Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com>
---
.../NetInteractor.Test.csproj | 1 +
.../PuppeteerSharpWebAccessorTests.cs | 158 ++++++++++++
.../WebAccessorIntegrationTests.cs | 229 ++++++++++++++++++
3 files changed, 388 insertions(+)
create mode 100644 test/NetInteractor.Test/PuppeteerSharpWebAccessorTests.cs
create mode 100644 test/NetInteractor.Test/WebAccessorIntegrationTests.cs
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("
+ /// Integration tests that run with multiple IWebAccessor implementations.
+ /// This class reuses test logic to verify that different web accessors work correctly.
+ ///
+ public class WebAccessorIntegrationTests : IClassFixture
+ {
+ private readonly TestWebApplicationFactory _factory;
+ private static readonly string ScriptsDirectory;
+
+ static WebAccessorIntegrationTests()
+ {
+ var assemblyLocation = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
+ ScriptsDirectory = Path.Combine(assemblyLocation!, "Scripts");
+ }
+
+ private static InteractConfig LoadConfig(string configName)
+ {
+ var configPath = Path.Combine(ScriptsDirectory, configName);
+ var xml = File.ReadAllText(configPath);
+ return ConfigFactory.DeserializeXml(xml);
+ }
+
+ public WebAccessorIntegrationTests(TestWebApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ ///
+ /// Creates web accessor instances for testing.
+ /// Returns different IWebAccessor implementations to test with.
+ ///
+ public static IEnumerable