From 418ea38a2455a35afb693172eed6e34ff8dbb631 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 04:07:27 +0000 Subject: [PATCH 1/3] Initial plan From 85c11937998576f6253876397458c93293620689 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 04:10:27 +0000 Subject: [PATCH 2/3] feat: add Playwright web accessor project Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> --- Directory.Packages.props | 3 +- NetInteractor.sln | 15 ++ .../NetInteractor.Playwright.csproj | 22 +++ .../PlaywrightWebAccessor.cs | 181 ++++++++++++++++++ 4 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 src/NetInteractor.Playwright/NetInteractor.Playwright.csproj create mode 100644 src/NetInteractor.Playwright/PlaywrightWebAccessor.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index b0bb0f4..7238e77 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,6 +4,7 @@ + @@ -13,4 +14,4 @@ - \ No newline at end of file + diff --git a/NetInteractor.sln b/NetInteractor.sln index 093d77f..8a05bf0 100644 --- a/NetInteractor.sln +++ b/NetInteractor.sln @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetInteractor.Test", "test\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetInteractor.PuppeteerSharp", "src\NetInteractor.PuppeteerSharp\NetInteractor.PuppeteerSharp.csproj", "{96C0F6DB-7F80-4E41-B9E8-75C5F750B4BF}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetInteractor.Playwright", "src\NetInteractor.Playwright\NetInteractor.Playwright.csproj", "{1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -57,6 +59,18 @@ Global {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 + {1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Debug|x64.ActiveCfg = Debug|Any CPU + {1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Debug|x64.Build.0 = Debug|Any CPU + {1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Debug|x86.ActiveCfg = Debug|Any CPU + {1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Debug|x86.Build.0 = Debug|Any CPU + {1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Release|Any CPU.Build.0 = Release|Any CPU + {1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Release|x64.ActiveCfg = Release|Any CPU + {1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Release|x64.Build.0 = Release|Any CPU + {1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Release|x86.ActiveCfg = Release|Any CPU + {1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -65,5 +79,6 @@ Global {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} + {1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62} = {F5108642-5386-4F95-ACF8-8DA0AD621820} EndGlobalSection EndGlobal diff --git a/src/NetInteractor.Playwright/NetInteractor.Playwright.csproj b/src/NetInteractor.Playwright/NetInteractor.Playwright.csproj new file mode 100644 index 0000000..aa7c56d --- /dev/null +++ b/src/NetInteractor.Playwright/NetInteractor.Playwright.csproj @@ -0,0 +1,22 @@ + + + netstandard2.0;net8.0;net9.0 + Kerry Jiang + Playwright implementation of IWebAccessor for NetInteractor - enables browser automation with Chromium/Firefox/WebKit. + Apache-2.0 + Web;Automation;Html;Scraping;Testing;Playwright;Browser;WebAutomation;Headless;Chromium;Firefox;WebKit + https://github.com/kerryjiang/NetInteractor.git + + + v$(PackageVersion).md + + + + + + + + + + + diff --git a/src/NetInteractor.Playwright/PlaywrightWebAccessor.cs b/src/NetInteractor.Playwright/PlaywrightWebAccessor.cs new file mode 100644 index 0000000..bf211a4 --- /dev/null +++ b/src/NetInteractor.Playwright/PlaywrightWebAccessor.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Specialized; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using System.Text; +using Microsoft.Playwright; +using NetInteractor.Config; + +namespace NetInteractor.WebAccessors +{ + public class PlaywrightWebAccessor : IWebAccessor, IAsyncDisposable + { + private IPlaywright _playwright; + private IBrowser _browser; + private readonly SemaphoreSlim _browserLock = new SemaphoreSlim(1, 1); + private readonly BrowserTypeLaunchOptions _launchOptions; + private bool _disposed; + + public PlaywrightWebAccessor(BrowserTypeLaunchOptions launchOptions = null) + { + _launchOptions = launchOptions ?? new BrowserTypeLaunchOptions + { + Headless = true, + Args = new[] { "--no-sandbox", "--disable-setuid-sandbox" } + }; + } + + private async Task GetBrowserAsync() + { + if (_browser == null) + { + await _browserLock.WaitAsync(); + try + { + if (_browser == null) + { + _playwright = await Microsoft.Playwright.Playwright.CreateAsync(); + _browser = await _playwright.Chromium.LaunchAsync(_launchOptions); + } + } + finally + { + _browserLock.Release(); + } + } + return _browser; + } + + public async Task GetAsync(string url, InteractActionConfig config = null) + { + var browser = await GetBrowserAsync(); + var page = await browser.NewPageAsync(); + + try + { + var response = await page.GotoAsync(url, new PageGotoOptions + { + WaitUntil = WaitUntilState.NetworkIdle + }); + + var loadDelayStr = config?.Options?.FirstOrDefault(attr => attr.Name == "loadDelay")?.Value; + if (!string.IsNullOrEmpty(loadDelayStr) && int.TryParse(loadDelayStr, out var loadDelay)) + { + var delayTask = page.WaitForTimeoutAsync(loadDelay); + var navigationTask = page.WaitForNavigationAsync(new PageWaitForNavigationOptions + { + WaitUntil = WaitUntilState.NetworkIdle + }); + + var completedTask = await Task.WhenAny(delayTask, navigationTask); + if (completedTask == navigationTask) + { + try + { + response = await navigationTask; + } + catch (PlaywrightException) + { + } + } + } + + return await GetResultFromResponse(page, response); + } + finally + { + await page.CloseAsync(); + } + } + + public async Task PostAsync(string url, NameValueCollection formValues, InteractActionConfig config = null) + { + var browser = await GetBrowserAsync(); + var page = await browser.NewPageAsync(); + + try + { + var formData = string.Join("&", formValues.Keys.OfType() + .Select(k => k + "=" + Uri.EscapeDataString(formValues[k] ?? string.Empty))); + + await page.RouteAsync("**/*", async route => + { + var request = route.Request; + if (request.Url == url && request.Method == "GET") + { + await route.ContinueAsync(new RouteContinueOptions + { + Method = "POST", + PostData = Encoding.UTF8.GetBytes(formData), + Headers = request.Headers.Concat(new[] + { + new KeyValuePair("Content-Type", "application/x-www-form-urlencoded") + }).ToDictionary(x => x.Key, x => x.Value) + }); + } + else + { + await route.ContinueAsync(); + } + }); + + var response = await page.GotoAsync(url, new PageGotoOptions + { + WaitUntil = WaitUntilState.NetworkIdle + }); + + return await GetResultFromResponse(page, response); + } + finally + { + await page.CloseAsync(); + } + } + + private async Task GetResultFromResponse(IPage page, IResponse response) + { + var html = await page.ContentAsync(); + + var mockResponse = new HttpResponseMessage(); + IEnumerable> headerPairs = response?.Headers ?? Enumerable.Empty>(); + + foreach (var header in headerPairs) + { + try + { + mockResponse.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + catch (Exception) + { + } + } + + return new ResponseInfo + { + StatusCode = (int)(response?.Status ?? 0), + StatusDescription = response?.StatusText, + Html = html, + Url = response?.Url, + Headers = mockResponse.Headers + }; + } + + public async ValueTask DisposeAsync() + { + if (!_disposed) + { + _disposed = true; + if (_browser != null) + { + await _browser.DisposeAsync(); + } + _playwright?.Dispose(); + _browserLock.Dispose(); + } + } + } +} From 1411f2a45ec89a0b4c952d9ed0eddfc21bea47b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 04:17:23 +0000 Subject: [PATCH 3/3] test: add Playwright accessor to integration tests Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> --- test/NetInteractor.Test/IntegrationTests.cs | 7 +++++++ test/NetInteractor.Test/NetInteractor.Test.csproj | 1 + 2 files changed, 8 insertions(+) diff --git a/test/NetInteractor.Test/IntegrationTests.cs b/test/NetInteractor.Test/IntegrationTests.cs index f558c1a..3397020 100644 --- a/test/NetInteractor.Test/IntegrationTests.cs +++ b/test/NetInteractor.Test/IntegrationTests.cs @@ -42,6 +42,13 @@ public IEnumerator GetEnumerator() { yield return new object[] { new PuppeteerSharpWebAccessor(), _kestrelFactory.ServerUrl }; } + + // Playwright accessor with Kestrel (real HTTP) - disabled by default in CI/CD + // Requires browser download; enable locally via ENABLE_PLAYWRIGHT_TESTS=true + if (Environment.GetEnvironmentVariable("ENABLE_PLAYWRIGHT_TESTS") == "true") + { + yield return new object[] { new PlaywrightWebAccessor(), _kestrelFactory.ServerUrl }; + } } IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); diff --git a/test/NetInteractor.Test/NetInteractor.Test.csproj b/test/NetInteractor.Test/NetInteractor.Test.csproj index 2107d95..a81b972 100644 --- a/test/NetInteractor.Test/NetInteractor.Test.csproj +++ b/test/NetInteractor.Test/NetInteractor.Test.csproj @@ -15,6 +15,7 @@ +