Skip to content

Commit ff914ba

Browse files
Copilotkerryjiang
andauthored
Add Playwright web accessor coverage to integration suite (#4)
* Initial plan * feat: add Playwright web accessor project Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> * test: add Playwright accessor to integration tests Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com>
1 parent 451a560 commit ff914ba

6 files changed

Lines changed: 228 additions & 1 deletion

File tree

Directory.Packages.props

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
</PropertyGroup>
55
<ItemGroup>
66
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
7+
<PackageVersion Include="Microsoft.Playwright" Version="1.48.0" />
78
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
89
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
910
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.0" />
@@ -13,4 +14,4 @@
1314
<PackageVersion Include="xunit" Version="2.9.2" />
1415
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
1516
</ItemGroup>
16-
</Project>
17+
</Project>

NetInteractor.sln

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetInteractor.Test", "test\
1111
EndProject
1212
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetInteractor.PuppeteerSharp", "src\NetInteractor.PuppeteerSharp\NetInteractor.PuppeteerSharp.csproj", "{96C0F6DB-7F80-4E41-B9E8-75C5F750B4BF}"
1313
EndProject
14+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetInteractor.Playwright", "src\NetInteractor.Playwright\NetInteractor.Playwright.csproj", "{1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}"
15+
EndProject
1416
Global
1517
GlobalSection(SolutionConfigurationPlatforms) = preSolution
1618
Debug|Any CPU = Debug|Any CPU
@@ -57,6 +59,18 @@ Global
5759
{96C0F6DB-7F80-4E41-B9E8-75C5F750B4BF}.Release|x64.Build.0 = Release|Any CPU
5860
{96C0F6DB-7F80-4E41-B9E8-75C5F750B4BF}.Release|x86.ActiveCfg = Release|Any CPU
5961
{96C0F6DB-7F80-4E41-B9E8-75C5F750B4BF}.Release|x86.Build.0 = Release|Any CPU
62+
{1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
63+
{1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Debug|Any CPU.Build.0 = Debug|Any CPU
64+
{1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Debug|x64.ActiveCfg = Debug|Any CPU
65+
{1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Debug|x64.Build.0 = Debug|Any CPU
66+
{1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Debug|x86.ActiveCfg = Debug|Any CPU
67+
{1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Debug|x86.Build.0 = Debug|Any CPU
68+
{1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Release|Any CPU.ActiveCfg = Release|Any CPU
69+
{1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Release|Any CPU.Build.0 = Release|Any CPU
70+
{1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Release|x64.ActiveCfg = Release|Any CPU
71+
{1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Release|x64.Build.0 = Release|Any CPU
72+
{1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Release|x86.ActiveCfg = Release|Any CPU
73+
{1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Release|x86.Build.0 = Release|Any CPU
6074
EndGlobalSection
6175
GlobalSection(SolutionProperties) = preSolution
6276
HideSolutionNode = FALSE
@@ -65,5 +79,6 @@ Global
6579
{75781078-1761-4EDD-A872-5F71D1FF9721} = {F5108642-5386-4F95-ACF8-8DA0AD621820}
6680
{A9186BD7-EFFC-4B4D-B828-C4F4AA924C50} = {F5108642-5386-4F95-ACF8-8DA0AD621820}
6781
{96C0F6DB-7F80-4E41-B9E8-75C5F750B4BF} = {F5108642-5386-4F95-ACF8-8DA0AD621820}
82+
{1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62} = {F5108642-5386-4F95-ACF8-8DA0AD621820}
6883
EndGlobalSection
6984
EndGlobal
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<TargetFrameworks>netstandard2.0;net8.0;net9.0</TargetFrameworks>
4+
<Authors>Kerry Jiang</Authors>
5+
<Description>Playwright implementation of IWebAccessor for NetInteractor - enables browser automation with Chromium/Firefox/WebKit.</Description>
6+
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
7+
<PackageTags>Web;Automation;Html;Scraping;Testing;Playwright;Browser;WebAutomation;Headless;Chromium;Firefox;WebKit</PackageTags>
8+
<RepositoryUrl>https://github.com/kerryjiang/NetInteractor.git</RepositoryUrl>
9+
</PropertyGroup>
10+
<PropertyGroup Condition="'$(IncludeReleaseNotes)' == 'true'">
11+
<PackageReadmeFile>v$(PackageVersion).md</PackageReadmeFile>
12+
</PropertyGroup>
13+
<ItemGroup Condition="'$(IncludeReleaseNotes)' == 'true'">
14+
<None Include="../../releaseNotes/v$(PackageVersion).md" Pack="true" PackagePath="/" />
15+
</ItemGroup>
16+
<ItemGroup>
17+
<ProjectReference Include="../NetInteractor/NetInteractor.csproj" />
18+
</ItemGroup>
19+
<ItemGroup>
20+
<PackageReference Include="Microsoft.Playwright" />
21+
</ItemGroup>
22+
</Project>
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
using System;
2+
using System.Collections.Specialized;
3+
using System.Collections.Generic;
4+
using System.Linq;
5+
using System.Net.Http;
6+
using System.Net.Http.Headers;
7+
using System.Threading;
8+
using System.Threading.Tasks;
9+
using System.Text;
10+
using Microsoft.Playwright;
11+
using NetInteractor.Config;
12+
13+
namespace NetInteractor.WebAccessors
14+
{
15+
public class PlaywrightWebAccessor : IWebAccessor, IAsyncDisposable
16+
{
17+
private IPlaywright _playwright;
18+
private IBrowser _browser;
19+
private readonly SemaphoreSlim _browserLock = new SemaphoreSlim(1, 1);
20+
private readonly BrowserTypeLaunchOptions _launchOptions;
21+
private bool _disposed;
22+
23+
public PlaywrightWebAccessor(BrowserTypeLaunchOptions launchOptions = null)
24+
{
25+
_launchOptions = launchOptions ?? new BrowserTypeLaunchOptions
26+
{
27+
Headless = true,
28+
Args = new[] { "--no-sandbox", "--disable-setuid-sandbox" }
29+
};
30+
}
31+
32+
private async Task<IBrowser> GetBrowserAsync()
33+
{
34+
if (_browser == null)
35+
{
36+
await _browserLock.WaitAsync();
37+
try
38+
{
39+
if (_browser == null)
40+
{
41+
_playwright = await Microsoft.Playwright.Playwright.CreateAsync();
42+
_browser = await _playwright.Chromium.LaunchAsync(_launchOptions);
43+
}
44+
}
45+
finally
46+
{
47+
_browserLock.Release();
48+
}
49+
}
50+
return _browser;
51+
}
52+
53+
public async Task<ResponseInfo> GetAsync(string url, InteractActionConfig config = null)
54+
{
55+
var browser = await GetBrowserAsync();
56+
var page = await browser.NewPageAsync();
57+
58+
try
59+
{
60+
var response = await page.GotoAsync(url, new PageGotoOptions
61+
{
62+
WaitUntil = WaitUntilState.NetworkIdle
63+
});
64+
65+
var loadDelayStr = config?.Options?.FirstOrDefault(attr => attr.Name == "loadDelay")?.Value;
66+
if (!string.IsNullOrEmpty(loadDelayStr) && int.TryParse(loadDelayStr, out var loadDelay))
67+
{
68+
var delayTask = page.WaitForTimeoutAsync(loadDelay);
69+
var navigationTask = page.WaitForNavigationAsync(new PageWaitForNavigationOptions
70+
{
71+
WaitUntil = WaitUntilState.NetworkIdle
72+
});
73+
74+
var completedTask = await Task.WhenAny(delayTask, navigationTask);
75+
if (completedTask == navigationTask)
76+
{
77+
try
78+
{
79+
response = await navigationTask;
80+
}
81+
catch (PlaywrightException)
82+
{
83+
}
84+
}
85+
}
86+
87+
return await GetResultFromResponse(page, response);
88+
}
89+
finally
90+
{
91+
await page.CloseAsync();
92+
}
93+
}
94+
95+
public async Task<ResponseInfo> PostAsync(string url, NameValueCollection formValues, InteractActionConfig config = null)
96+
{
97+
var browser = await GetBrowserAsync();
98+
var page = await browser.NewPageAsync();
99+
100+
try
101+
{
102+
var formData = string.Join("&", formValues.Keys.OfType<string>()
103+
.Select(k => k + "=" + Uri.EscapeDataString(formValues[k] ?? string.Empty)));
104+
105+
await page.RouteAsync("**/*", async route =>
106+
{
107+
var request = route.Request;
108+
if (request.Url == url && request.Method == "GET")
109+
{
110+
await route.ContinueAsync(new RouteContinueOptions
111+
{
112+
Method = "POST",
113+
PostData = Encoding.UTF8.GetBytes(formData),
114+
Headers = request.Headers.Concat(new[]
115+
{
116+
new KeyValuePair<string, string>("Content-Type", "application/x-www-form-urlencoded")
117+
}).ToDictionary(x => x.Key, x => x.Value)
118+
});
119+
}
120+
else
121+
{
122+
await route.ContinueAsync();
123+
}
124+
});
125+
126+
var response = await page.GotoAsync(url, new PageGotoOptions
127+
{
128+
WaitUntil = WaitUntilState.NetworkIdle
129+
});
130+
131+
return await GetResultFromResponse(page, response);
132+
}
133+
finally
134+
{
135+
await page.CloseAsync();
136+
}
137+
}
138+
139+
private async Task<ResponseInfo> GetResultFromResponse(IPage page, IResponse response)
140+
{
141+
var html = await page.ContentAsync();
142+
143+
var mockResponse = new HttpResponseMessage();
144+
IEnumerable<KeyValuePair<string, string>> headerPairs = response?.Headers ?? Enumerable.Empty<KeyValuePair<string, string>>();
145+
146+
foreach (var header in headerPairs)
147+
{
148+
try
149+
{
150+
mockResponse.Headers.TryAddWithoutValidation(header.Key, header.Value);
151+
}
152+
catch (Exception)
153+
{
154+
}
155+
}
156+
157+
return new ResponseInfo
158+
{
159+
StatusCode = (int)(response?.Status ?? 0),
160+
StatusDescription = response?.StatusText,
161+
Html = html,
162+
Url = response?.Url,
163+
Headers = mockResponse.Headers
164+
};
165+
}
166+
167+
public async ValueTask DisposeAsync()
168+
{
169+
if (!_disposed)
170+
{
171+
_disposed = true;
172+
if (_browser != null)
173+
{
174+
await _browser.DisposeAsync();
175+
}
176+
_playwright?.Dispose();
177+
_browserLock.Dispose();
178+
}
179+
}
180+
}
181+
}

test/NetInteractor.Test/IntegrationTests.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ public IEnumerator<object[]> GetEnumerator()
4242
{
4343
yield return new object[] { new PuppeteerSharpWebAccessor(), _kestrelFactory.ServerUrl };
4444
}
45+
46+
// Playwright accessor with Kestrel (real HTTP) - disabled by default in CI/CD
47+
// Requires browser download; enable locally via ENABLE_PLAYWRIGHT_TESTS=true
48+
if (Environment.GetEnvironmentVariable("ENABLE_PLAYWRIGHT_TESTS") == "true")
49+
{
50+
yield return new object[] { new PlaywrightWebAccessor(), _kestrelFactory.ServerUrl };
51+
}
4552
}
4653

4754
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

test/NetInteractor.Test/NetInteractor.Test.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
<ItemGroup>
1616
<ProjectReference Include="../../src/NetInteractor/NetInteractor.csproj" />
1717
<ProjectReference Include="../../src/NetInteractor.PuppeteerSharp/NetInteractor.PuppeteerSharp.csproj" />
18+
<ProjectReference Include="../../src/NetInteractor.Playwright/NetInteractor.Playwright.csproj" />
1819
</ItemGroup>
1920
<ItemGroup>
2021
<None Include="Html\*.html" CopyToOutputDirectory="Always" />

0 commit comments

Comments
 (0)