Skip to content

Commit 9b9b76e

Browse files
committed
Added PlaywrightTestRunner for automated unit testing
1 parent e14dbd2 commit 9b9b76e

20 files changed

Lines changed: 3212 additions & 69 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
namespace PlaywrightTestRunner
2+
{
3+
[SetUpFixture]
4+
public class GlobalSetup
5+
{
6+
[OneTimeSetUp]
7+
public void SetUp()
8+
{
9+
// This tells Playwright to run in headed mode for the entire test run
10+
//Environment.SetEnvironmentVariable("HEADED", "1");
11+
Environment.SetEnvironmentVariable("PLAYWRIGHT_ARGS", "--use-fake-ui-for-media-stream --use-fake-device-for-media-stream");
12+
}
13+
}
14+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<LangVersion>latest</LangVersion>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
<IsPackable>false</IsPackable>
9+
</PropertyGroup>
10+
11+
<ItemGroup>
12+
<None Remove="assets\testcert.pfx" />
13+
</ItemGroup>
14+
15+
<ItemGroup>
16+
<Content Include="assets\testcert.pfx">
17+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
18+
</Content>
19+
</ItemGroup>
20+
21+
<ItemGroup>
22+
<PackageReference Include="coverlet.collector" Version="6.0.4" />
23+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
24+
<PackageReference Include="Microsoft.Playwright.NUnit" Version="1.57.0" />
25+
<PackageReference Include="NUnit" Version="4.4.0" />
26+
<PackageReference Include="NUnit.Analyzers" Version="4.11.2">
27+
<PrivateAssets>all</PrivateAssets>
28+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
29+
</PackageReference>
30+
<PackageReference Include="NUnit3TestAdapter" Version="6.1.0" />
31+
<FrameworkReference Include="Microsoft.AspNetCore.App" />
32+
</ItemGroup>
33+
34+
<ItemGroup>
35+
<Using Include="Microsoft.Playwright.NUnit" />
36+
<Using Include="NUnit.Framework" />
37+
<Using Include="System.Text.RegularExpressions" />
38+
<Using Include="System.Threading.Tasks" />
39+
</ItemGroup>
40+
41+
</Project>
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
using Microsoft.AspNetCore.Builder;
2+
using Microsoft.AspNetCore.Hosting;
3+
using Microsoft.Extensions.FileProviders;
4+
using System.Net;
5+
6+
namespace PlaywrightTestRunner
7+
{
8+
internal class StaticFileServer
9+
{
10+
WebApplication? app;
11+
Task? runningTask;
12+
string WWWRoot;
13+
string RequestPath;
14+
string Url;
15+
string devcertPath;
16+
public StaticFileServer(string wwwroot, string url, string requestPath = "")
17+
{
18+
if (string.IsNullOrEmpty(wwwroot))
19+
{
20+
throw new ArgumentNullException(nameof(wwwroot));
21+
}
22+
if (!Directory.Exists(wwwroot))
23+
{
24+
throw new DirectoryNotFoundException(wwwroot);
25+
}
26+
WWWRoot = Path.GetFullPath(wwwroot);
27+
RequestPath = requestPath;
28+
Url = url;
29+
devcertPath = Path.GetFullPath("assets/testcert.pfx");
30+
if (!File.Exists(devcertPath))
31+
throw new Exception("testcert.pfx not found. Cannot create static server");
32+
}
33+
public bool Running => runningTask?.IsCompleted == false;
34+
public void Start()
35+
{
36+
runningTask ??= StartAsync();
37+
}
38+
private async Task StartAsync()
39+
{
40+
try
41+
{
42+
var builder = WebApplication.CreateBuilder();
43+
var port = new Uri(Url).Port;
44+
45+
// Configure static file serving
46+
builder.WebHost.UseKestrel();
47+
builder.WebHost.ConfigureKestrel(serverOptions =>
48+
{
49+
serverOptions.Listen(IPAddress.Loopback, port, listenOptions =>
50+
{
51+
listenOptions.UseHttps(devcertPath, "unittests");
52+
});
53+
});
54+
// Use the current directory as the web root
55+
builder.Environment.WebRootPath = WWWRoot;
56+
builder.WebHost.UseUrls(Url);
57+
58+
app = builder.Build();
59+
60+
// (optional) add headers that enables: window.crossOriginIsolated == true
61+
app.Use(async (context, next) =>
62+
{
63+
context.Response.Headers["Cross-Origin-Embedder-Policy"] = "credentialless";
64+
context.Response.Headers["Cross-Origin-Opener-Policy"] = "same-origin";
65+
await next();
66+
});
67+
68+
// enable 404 fallback to default root
69+
app.UseStatusCodePagesWithReExecute(string.IsNullOrEmpty(RequestPath) ? "/" : RequestPath);
70+
71+
// enable index.html fallback
72+
app.UseDefaultFiles(new DefaultFilesOptions
73+
{
74+
FileProvider = new PhysicalFileProvider(WWWRoot),
75+
RequestPath = RequestPath
76+
});
77+
// enable unknown file types (required)
78+
app.UseFileServer(new FileServerOptions
79+
{
80+
FileProvider = new PhysicalFileProvider(WWWRoot),
81+
RequestPath = RequestPath,
82+
EnableDirectoryBrowsing = false, // Optional: allows browsing directory listings
83+
StaticFileOptions = {
84+
ServeUnknownFileTypes = true, // Crucial: serves all file types, even those without known MIME types
85+
DefaultContentType = "application/octet-stream" // Optional: default MIME type for unknown files
86+
}
87+
});
88+
// start hosting
89+
await app.RunAsync();
90+
}
91+
finally
92+
{
93+
app = null;
94+
runningTask = null;
95+
}
96+
}
97+
public async Task Stop()
98+
{
99+
if (app == null || runningTask == null) return;
100+
try
101+
{
102+
await app.StopAsync();
103+
}
104+
catch { }
105+
await app.DisposeAsync();
106+
if (runningTask != null)
107+
{
108+
try
109+
{
110+
await runningTask;
111+
}
112+
catch { }
113+
}
114+
}
115+
}
116+
}

PlaywrightTestRunner/TestRunner.cs

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
using Microsoft.Playwright;
2+
using System.Diagnostics;
3+
using System.Xml.Linq;
4+
5+
namespace PlaywrightTestRunner
6+
{
7+
[Parallelizable(ParallelScope.Self)]
8+
[TestFixture]
9+
public class TestRunner : PageTest
10+
{
11+
// test port
12+
string dotnetVersion = "";
13+
static ushort _port = 32301;
14+
StaticFileServer? staticFileServer;
15+
protected string BaseUrl = Environment.GetEnvironmentVariable("BASE_URL") ?? $"https://localhost:{_port}/";
16+
/// <summary>
17+
/// This environment value should be set by the batch file that calls this script
18+
/// </summary>
19+
protected string TestProjectDirName = Environment.GetEnvironmentVariable("TestProjectDirName") ?? "";
20+
/// <summary>
21+
/// Unit test page
22+
/// </summary>
23+
protected string UnitTestPage = Environment.GetEnvironmentVariable("UnitTestPage") ?? "";
24+
/// <inheritdoc/>
25+
public override BrowserNewContextOptions ContextOptions()
26+
{
27+
return new BrowserNewContextOptions
28+
{
29+
// required to use the included self signed certificate
30+
IgnoreHTTPSErrors = true,
31+
};
32+
}
33+
/// <summary>
34+
/// Starts serving the Blazor WebAssembly app using dotnet and waits for it to be ready for a max amount of time
35+
/// </summary>
36+
[OneTimeSetUp]
37+
public async Task StartApp()
38+
{
39+
// get the directory that contains the project being tested
40+
var projectDirectory = Path.GetFullPath($@"../../../../{TestProjectDirName}");
41+
if (!Directory.Exists(projectDirectory))
42+
{
43+
throw new DirectoryNotFoundException(projectDirectory);
44+
}
45+
46+
// find the first *.csproj in the project's directory
47+
var projectPath = Directory.GetFiles(projectDirectory, "*.csproj").FirstOrDefault();
48+
if (projectPath == null)
49+
{
50+
throw new FileNotFoundException($".csproj not found in: {projectDirectory}");
51+
}
52+
53+
// get the Blazor WASM project's dotnet version from its csproj file
54+
dotnetVersion = GetDotnetVersion(projectPath);
55+
56+
// get wwwroot path
57+
var publishPath = Path.GetFullPath(Path.Combine(projectDirectory, $"bin/Release/{dotnetVersion}/publish/wwwroot"));
58+
59+
// create https server for testing using StaticFileServer
60+
// uses the included self signed certificate for unit testing: assets/testcert.pfx
61+
staticFileServer = new StaticFileServer(publishPath, BaseUrl);
62+
63+
// start https server
64+
staticFileServer.Start();
65+
66+
// wait for the server to start
67+
// use HttpClient to test for the server readiness
68+
using var httpClient = new HttpClient() { BaseAddress = new Uri(BaseUrl) };
69+
var sw = Stopwatch.StartNew();
70+
while (sw.Elapsed < TimeSpan.FromSeconds(30))
71+
{
72+
try
73+
{
74+
using var response = await httpClient.GetAsync(BaseUrl).WaitAsync(TimeSpan.FromSeconds(2));
75+
if (response?.IsSuccessStatusCode == true)
76+
{
77+
break;
78+
}
79+
}
80+
catch { }
81+
await Task.Delay(1000);
82+
}
83+
}
84+
/// <summary>
85+
/// Shutdown Blazor WASM host process
86+
/// </summary>
87+
[OneTimeTearDown]
88+
public async Task StopApp()
89+
{
90+
// shutdown the Blazor WASM host
91+
if (staticFileServer != null)
92+
{
93+
await staticFileServer.Stop();
94+
}
95+
}
96+
/// <summary>
97+
/// Runs all tests in Home.razor > UnitTestsView component one at a time
98+
/// </summary>
99+
/// <returns></returns>
100+
/// <exception cref="Exception"></exception>
101+
[Test]
102+
public async Task RunAllTestsInTable_ShouldSucceed()
103+
{
104+
var testPage = new Uri(new Uri(BaseUrl), UnitTestPage).ToString();
105+
await Page.GotoAsync(testPage);
106+
107+
// get the table
108+
var table = Page.Locator("table.unit-test-view");
109+
110+
// wait for the table to finish rendering
111+
await Expect(table).ToHaveClassAsync(new Regex("unit-test-ready"), new() { Timeout = 10000 });
112+
113+
// get table body
114+
var tbody = table.Locator("tbody");
115+
116+
// get all rows in the target table body
117+
var rows = tbody.Locator("tr");
118+
119+
// iterate the rows
120+
int rowCount = await rows.CountAsync();
121+
for (int i = 0; i < rowCount; i++)
122+
{
123+
// get the specific row by index
124+
var currentRow = rows.Nth(i);
125+
126+
// find the button within THIS specific row
127+
var runButton = currentRow.GetByRole(AriaRole.Button, new() { Name = "Run" });
128+
129+
// click the button to start the process for this row
130+
await runButton.ClickAsync();
131+
132+
// assert that the row eventually gets the class 'test-state-done'
133+
await Expect(currentRow).ToHaveClassAsync(new Regex("test-state-done"), new() { Timeout = 15000 });
134+
135+
// get test type name
136+
var typeName = await currentRow.Locator(".test-type-name").TextContentAsync();
137+
138+
// get test method name
139+
var methodName = await currentRow.Locator(".test-method-name").TextContentAsync();
140+
141+
// current state text
142+
var stateMessage = await currentRow.Locator(".test-state").TextContentAsync();
143+
144+
// check for error class
145+
var wasError = await currentRow.EvaluateAsync<bool>("el => el.classList.contains('test-error')");
146+
if (wasError)
147+
{
148+
throw new Exception($"Failed - {typeName}.{methodName}\nTest-error: {stateMessage}");
149+
}
150+
}
151+
}
152+
153+
/// <summary>
154+
/// Gets the dotnet version from the csproj file
155+
/// </summary>
156+
/// <param name="projectPath">Path to the csproj file</param>
157+
/// <returns>The dotnet version</returns>
158+
private string GetDotnetVersion(string projectPath)
159+
{
160+
var xml = XDocument.Load(projectPath);
161+
var targetFramework = xml.Descendants("TargetFramework").FirstOrDefault();
162+
if (targetFramework == null)
163+
{
164+
throw new Exception("Could not find TargetFramework in csproj file");
165+
}
166+
return targetFramework.Value;
167+
}
168+
}
169+
}

PlaywrightTestRunner/_test.bat

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
@echo off
2+
3+
REM test project's directory name
4+
SET "TestProjectDirName=SpawnDev.BlazorJS.WebWorkers.Demo"
5+
6+
REM The page that serves the unit tests
7+
SET "UnitTestPage=tests"
8+
9+
REM save the original directory
10+
SET "OriginalDir=%CD%"
11+
12+
REM switch to the script's directory
13+
cd "%~dp0"
14+
15+
REM switch to the test project's dirctory
16+
cd ../%TestProjectDirName%
17+
18+
REM run the test project's _publish script to get a published version for testing
19+
call _publish.bat || goto :ERROR
20+
21+
REM switch to the Playwright test runner project directory (where this is)
22+
cd "%~dp0"
23+
24+
echo Preparing tests
25+
dotnet restore || goto :ERROR
26+
27+
echo Testing Chromium
28+
dotnet test ./PlaywrightTestRunner.csproj --no-restore -- Playwright.BrowserName=chromium || goto :ERROR
29+
30+
echo Testing Firefox
31+
dotnet test ./PlaywrightTestRunner.csproj --no-restore -- Playwright.BrowserName=firefox || goto :ERROR
32+
33+
echo Success
34+
REM return to original directory
35+
CD /D "%OriginalDir%"
36+
exit /b 0
37+
38+
:ERROR
39+
echo Failed
40+
REM return to original directory
41+
CD /D "%OriginalDir%"
42+
exit /b 1

0 commit comments

Comments
 (0)