From 0ffafdbbe1a11786fdf6c98e16b0b78f9c6c63b0 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 27 Dec 2025 13:02:50 +0000
Subject: [PATCH 1/5] Initial plan
From 379067028efce62d8322ee5b11fe0be786577a4b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 27 Dec 2025 13:12:06 +0000
Subject: [PATCH 2/5] Add Playwright smoke tests with UseKestrel and dummy data
Co-authored-by: linkdotnet <26365461+linkdotnet@users.noreply.github.com>
---
.../LinkDotNet.Blog.IntegrationTests.csproj | 1 +
.../PlaywrightSmokeTests.cs | 114 ++++++++++++++++++
.../PlaywrightWebApplicationFactory.cs | 66 ++++++++++
.../SequentialCollection.cs | 6 +
4 files changed, 187 insertions(+)
create mode 100644 tests/LinkDotNet.Blog.IntegrationTests/PlaywrightSmokeTests.cs
create mode 100644 tests/LinkDotNet.Blog.IntegrationTests/PlaywrightWebApplicationFactory.cs
create mode 100644 tests/LinkDotNet.Blog.IntegrationTests/SequentialCollection.cs
diff --git a/tests/LinkDotNet.Blog.IntegrationTests/LinkDotNet.Blog.IntegrationTests.csproj b/tests/LinkDotNet.Blog.IntegrationTests/LinkDotNet.Blog.IntegrationTests.csproj
index a6dd4a0b..be0f8519 100644
--- a/tests/LinkDotNet.Blog.IntegrationTests/LinkDotNet.Blog.IntegrationTests.csproj
+++ b/tests/LinkDotNet.Blog.IntegrationTests/LinkDotNet.Blog.IntegrationTests.csproj
@@ -8,6 +8,7 @@
+
diff --git a/tests/LinkDotNet.Blog.IntegrationTests/PlaywrightSmokeTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/PlaywrightSmokeTests.cs
new file mode 100644
index 00000000..49303688
--- /dev/null
+++ b/tests/LinkDotNet.Blog.IntegrationTests/PlaywrightSmokeTests.cs
@@ -0,0 +1,114 @@
+using System;
+using System.Threading.Tasks;
+using LinkDotNet.Blog.TestUtilities;
+using Microsoft.Playwright;
+using TestContext = Xunit.TestContext;
+
+namespace LinkDotNet.Blog.IntegrationTests;
+
+[Collection("Sequential")]
+public sealed class PlaywrightSmokeTests : IClassFixture, IAsyncDisposable
+{
+ private readonly PlaywrightWebApplicationFactory factory;
+ private readonly IPlaywright playwright;
+ private readonly IBrowser browser;
+
+ public PlaywrightSmokeTests(PlaywrightWebApplicationFactory factory)
+ {
+ this.factory = factory;
+ _ = factory.CreateClient(); // Initialize the factory
+ playwright = Playwright.CreateAsync().GetAwaiter().GetResult();
+ browser = playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true }).GetAwaiter().GetResult();
+ }
+
+ [Fact]
+ public async Task ShouldNavigateToHomePageAndShowBlogPosts()
+ {
+ var page = await browser.NewPageAsync();
+
+ await page.GotoAsync(factory.ServerAddress, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
+
+ var blogPostElements = await page.QuerySelectorAllAsync("article");
+ blogPostElements.Count.ShouldBeGreaterThan(0);
+
+ var title = await page.TitleAsync();
+ title.ShouldBe("Integration Tests Blog");
+ }
+
+ [Fact]
+ public async Task ShouldNavigateToBlogPostAndShowContent()
+ {
+ var page = await browser.NewPageAsync();
+
+ await page.GotoAsync(factory.ServerAddress, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
+
+ // Find and click the first blog post link
+ var blogPostLink = await page.QuerySelectorAsync("article a[href*='/blogPost/']");
+ blogPostLink.ShouldNotBeNull();
+
+ var href = await blogPostLink.GetAttributeAsync("href");
+ href.ShouldNotBeNull();
+
+ await page.GotoAsync($"{factory.ServerAddress.TrimEnd('/')}{href}", new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
+
+ // Verify we're on a blog post page
+ var heading = await page.QuerySelectorAsync("h1");
+ heading.ShouldNotBeNull();
+ var headingText = await heading.TextContentAsync();
+ headingText.ShouldNotBeNullOrWhiteSpace();
+ }
+
+ [Fact]
+ public async Task ShouldNavigateToSecondPageAndShowBlogPosts()
+ {
+ var page = await browser.NewPageAsync();
+
+ await page.GotoAsync(factory.ServerAddress, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
+
+ // Look for pagination button/link for page 2
+ var page2Link = await page.QuerySelectorAsync("a[href='/2']");
+ if (page2Link is not null)
+ {
+ await page2Link.ClickAsync();
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ // Verify we're on page 2
+ var currentUrl = page.Url;
+ currentUrl.ShouldContain("/2");
+
+ // Verify blog posts are shown on page 2
+ var blogPostElements = await page.QuerySelectorAllAsync("article");
+ blogPostElements.Count.ShouldBeGreaterThan(0);
+ }
+ }
+
+ [Fact]
+ public async Task ShouldClickOnBlogPostLinkAndNavigate()
+ {
+ var page = await browser.NewPageAsync();
+
+ await page.GotoAsync(factory.ServerAddress, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
+
+ // Find the first blog post link
+ var blogPostLink = await page.QuerySelectorAsync("article a[href*='/blogPost/']");
+ blogPostLink.ShouldNotBeNull();
+
+ // Get the expected URL
+ var expectedHref = await blogPostLink.GetAttributeAsync("href");
+ expectedHref.ShouldNotBeNull();
+
+ // Click the link
+ await blogPostLink.ClickAsync();
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ // Verify navigation occurred
+ var currentUrl = page.Url;
+ currentUrl.ShouldContain("/blogPost/");
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await browser.DisposeAsync();
+ playwright.Dispose();
+ }
+}
diff --git a/tests/LinkDotNet.Blog.IntegrationTests/PlaywrightWebApplicationFactory.cs b/tests/LinkDotNet.Blog.IntegrationTests/PlaywrightWebApplicationFactory.cs
new file mode 100644
index 00000000..058690d6
--- /dev/null
+++ b/tests/LinkDotNet.Blog.IntegrationTests/PlaywrightWebApplicationFactory.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Linq;
+using LinkDotNet.Blog.Infrastructure.Persistence;
+using LinkDotNet.Blog.Web;
+using LinkDotNet.Blog.Web.Features.DummyData;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Hosting.Server;
+using Microsoft.AspNetCore.Hosting.Server.Features;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace LinkDotNet.Blog.IntegrationTests;
+
+public sealed class PlaywrightWebApplicationFactory : WebApplicationFactory
+{
+ private IHost? host;
+ private static readonly string ConnectionString = $"DataSource=file:memdb{Guid.NewGuid():N}?mode=memory&cache=shared";
+
+ public string ServerAddress => ClientOptions.BaseAddress.ToString();
+
+ public override IServiceProvider Services => host?.Services
+ ?? throw new InvalidOperationException("Create the Client first before retrieving instances from the container");
+
+ protected override IHost CreateHost(IHostBuilder builder)
+ {
+ var testHost = builder.Build();
+
+ builder = builder.ConfigureWebHost(b =>
+ {
+ b.UseSetting("PersistenceProvider", PersistenceProvider.Sqlite.Key);
+ b.UseSetting("ConnectionString", ConnectionString);
+ b.UseSetting("Logging:LogLevel:Default", "Error");
+ b.UseSetting("BlogName", "Integration Tests Blog");
+ b.ConfigureServices(services =>
+ {
+ services.UseDummyData(new DummyDataOptions { NumberOfBlogPosts = 25 });
+ });
+ b.UseKestrel();
+ });
+
+ host?.Dispose();
+ host = builder.Build();
+ host.Start();
+
+ var server = host!.Services.GetRequiredService();
+ var addresses = server.Features.Get();
+
+ ClientOptions.BaseAddress = addresses!.Addresses
+ .Select(x => new Uri(x))
+ .Last();
+
+ testHost.Start();
+ return testHost;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ host?.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+}
diff --git a/tests/LinkDotNet.Blog.IntegrationTests/SequentialCollection.cs b/tests/LinkDotNet.Blog.IntegrationTests/SequentialCollection.cs
new file mode 100644
index 00000000..05bec004
--- /dev/null
+++ b/tests/LinkDotNet.Blog.IntegrationTests/SequentialCollection.cs
@@ -0,0 +1,6 @@
+namespace LinkDotNet.Blog.IntegrationTests;
+
+[CollectionDefinition("Sequential", DisableParallelization = true)]
+public class SequentialCollection
+{
+}
From f305aaca774f2f224e3ecfade728fe05d26d1f16 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 27 Dec 2025 13:15:55 +0000
Subject: [PATCH 3/5] Address code review feedback: remove unused imports, fix
lazy initialization, simplify configuration
Co-authored-by: linkdotnet <26365461+linkdotnet@users.noreply.github.com>
---
.../PlaywrightSmokeTests.cs | 30 ++++++++++++++-----
.../PlaywrightWebApplicationFactory.cs | 3 --
2 files changed, 23 insertions(+), 10 deletions(-)
diff --git a/tests/LinkDotNet.Blog.IntegrationTests/PlaywrightSmokeTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/PlaywrightSmokeTests.cs
index 49303688..86264076 100644
--- a/tests/LinkDotNet.Blog.IntegrationTests/PlaywrightSmokeTests.cs
+++ b/tests/LinkDotNet.Blog.IntegrationTests/PlaywrightSmokeTests.cs
@@ -2,7 +2,6 @@
using System.Threading.Tasks;
using LinkDotNet.Blog.TestUtilities;
using Microsoft.Playwright;
-using TestContext = Xunit.TestContext;
namespace LinkDotNet.Blog.IntegrationTests;
@@ -10,20 +9,25 @@ namespace LinkDotNet.Blog.IntegrationTests;
public sealed class PlaywrightSmokeTests : IClassFixture, IAsyncDisposable
{
private readonly PlaywrightWebApplicationFactory factory;
- private readonly IPlaywright playwright;
- private readonly IBrowser browser;
+ private readonly Lazy> playwrightTask;
+ private readonly Lazy> browserTask;
public PlaywrightSmokeTests(PlaywrightWebApplicationFactory factory)
{
this.factory = factory;
_ = factory.CreateClient(); // Initialize the factory
- playwright = Playwright.CreateAsync().GetAwaiter().GetResult();
- browser = playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true }).GetAwaiter().GetResult();
+ playwrightTask = new Lazy>(() => Playwright.CreateAsync());
+ browserTask = new Lazy>(async () =>
+ {
+ var playwright = await playwrightTask.Value;
+ return await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true });
+ });
}
[Fact]
public async Task ShouldNavigateToHomePageAndShowBlogPosts()
{
+ var browser = await browserTask.Value;
var page = await browser.NewPageAsync();
await page.GotoAsync(factory.ServerAddress, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
@@ -38,6 +42,7 @@ public async Task ShouldNavigateToHomePageAndShowBlogPosts()
[Fact]
public async Task ShouldNavigateToBlogPostAndShowContent()
{
+ var browser = await browserTask.Value;
var page = await browser.NewPageAsync();
await page.GotoAsync(factory.ServerAddress, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
@@ -61,6 +66,7 @@ public async Task ShouldNavigateToBlogPostAndShowContent()
[Fact]
public async Task ShouldNavigateToSecondPageAndShowBlogPosts()
{
+ var browser = await browserTask.Value;
var page = await browser.NewPageAsync();
await page.GotoAsync(factory.ServerAddress, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
@@ -85,6 +91,7 @@ public async Task ShouldNavigateToSecondPageAndShowBlogPosts()
[Fact]
public async Task ShouldClickOnBlogPostLinkAndNavigate()
{
+ var browser = await browserTask.Value;
var page = await browser.NewPageAsync();
await page.GotoAsync(factory.ServerAddress, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
@@ -108,7 +115,16 @@ public async Task ShouldClickOnBlogPostLinkAndNavigate()
public async ValueTask DisposeAsync()
{
- await browser.DisposeAsync();
- playwright.Dispose();
+ if (browserTask.IsValueCreated)
+ {
+ var browser = await browserTask.Value;
+ await browser.DisposeAsync();
+ }
+
+ if (playwrightTask.IsValueCreated)
+ {
+ var playwright = await playwrightTask.Value;
+ playwright.Dispose();
+ }
}
}
diff --git a/tests/LinkDotNet.Blog.IntegrationTests/PlaywrightWebApplicationFactory.cs b/tests/LinkDotNet.Blog.IntegrationTests/PlaywrightWebApplicationFactory.cs
index 058690d6..f3701b10 100644
--- a/tests/LinkDotNet.Blog.IntegrationTests/PlaywrightWebApplicationFactory.cs
+++ b/tests/LinkDotNet.Blog.IntegrationTests/PlaywrightWebApplicationFactory.cs
@@ -15,7 +15,6 @@ namespace LinkDotNet.Blog.IntegrationTests;
public sealed class PlaywrightWebApplicationFactory : WebApplicationFactory
{
private IHost? host;
- private static readonly string ConnectionString = $"DataSource=file:memdb{Guid.NewGuid():N}?mode=memory&cache=shared";
public string ServerAddress => ClientOptions.BaseAddress.ToString();
@@ -28,8 +27,6 @@ protected override IHost CreateHost(IHostBuilder builder)
builder = builder.ConfigureWebHost(b =>
{
- b.UseSetting("PersistenceProvider", PersistenceProvider.Sqlite.Key);
- b.UseSetting("ConnectionString", ConnectionString);
b.UseSetting("Logging:LogLevel:Default", "Error");
b.UseSetting("BlogName", "Integration Tests Blog");
b.ConfigureServices(services =>
From 75c6fd05041da88d400ad1cf8d0f59b383b370c5 Mon Sep 17 00:00:00 2001
From: Steven Giesel
Date: Sat, 27 Dec 2025 14:44:22 +0100
Subject: [PATCH 4/5] refactor: Remove conditionals and comments
---
.../PlaywrightSmokeTests.cs | 29 ++++++-------------
1 file changed, 9 insertions(+), 20 deletions(-)
diff --git a/tests/LinkDotNet.Blog.IntegrationTests/PlaywrightSmokeTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/PlaywrightSmokeTests.cs
index 86264076..00d25f8e 100644
--- a/tests/LinkDotNet.Blog.IntegrationTests/PlaywrightSmokeTests.cs
+++ b/tests/LinkDotNet.Blog.IntegrationTests/PlaywrightSmokeTests.cs
@@ -15,8 +15,8 @@ public sealed class PlaywrightSmokeTests : IClassFixture>(() => Playwright.CreateAsync());
+ _ = factory.CreateClient();
+ playwrightTask = new Lazy>(Playwright.CreateAsync);
browserTask = new Lazy>(async () =>
{
var playwright = await playwrightTask.Value;
@@ -47,7 +47,6 @@ public async Task ShouldNavigateToBlogPostAndShowContent()
await page.GotoAsync(factory.ServerAddress, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
- // Find and click the first blog post link
var blogPostLink = await page.QuerySelectorAsync("article a[href*='/blogPost/']");
blogPostLink.ShouldNotBeNull();
@@ -56,7 +55,6 @@ public async Task ShouldNavigateToBlogPostAndShowContent()
await page.GotoAsync($"{factory.ServerAddress.TrimEnd('/')}{href}", new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
- // Verify we're on a blog post page
var heading = await page.QuerySelectorAsync("h1");
heading.ShouldNotBeNull();
var headingText = await heading.TextContentAsync();
@@ -71,21 +69,16 @@ public async Task ShouldNavigateToSecondPageAndShowBlogPosts()
await page.GotoAsync(factory.ServerAddress, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
- // Look for pagination button/link for page 2
var page2Link = await page.QuerySelectorAsync("a[href='/2']");
- if (page2Link is not null)
- {
- await page2Link.ClickAsync();
- await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+ page2Link.ShouldNotBeNull();
+ await page2Link.ClickAsync();
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
- // Verify we're on page 2
- var currentUrl = page.Url;
- currentUrl.ShouldContain("/2");
+ var currentUrl = page.Url;
+ currentUrl.ShouldContain("/2");
- // Verify blog posts are shown on page 2
- var blogPostElements = await page.QuerySelectorAllAsync("article");
- blogPostElements.Count.ShouldBeGreaterThan(0);
- }
+ var blogPostElements = await page.QuerySelectorAllAsync("article");
+ blogPostElements.Count.ShouldBeGreaterThan(0);
}
[Fact]
@@ -96,19 +89,15 @@ public async Task ShouldClickOnBlogPostLinkAndNavigate()
await page.GotoAsync(factory.ServerAddress, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
- // Find the first blog post link
var blogPostLink = await page.QuerySelectorAsync("article a[href*='/blogPost/']");
blogPostLink.ShouldNotBeNull();
- // Get the expected URL
var expectedHref = await blogPostLink.GetAttributeAsync("href");
expectedHref.ShouldNotBeNull();
- // Click the link
await blogPostLink.ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
- // Verify navigation occurred
var currentUrl = page.Url;
currentUrl.ShouldContain("/blogPost/");
}
From 9b27830af38f0c28192503ca57801e14f95b0634 Mon Sep 17 00:00:00 2001
From: Steven Giesel
Date: Sat, 27 Dec 2025 14:46:14 +0100
Subject: [PATCH 5/5] feat: Install playwright on ci
---
.github/workflows/dotnet.yml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index 26b83ce3..f911baa3 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -32,5 +32,7 @@ jobs:
run: dotnet restore
- name: Build
run: dotnet build --no-restore -c Release
+ - name: Ensure browsers are installed
+ run: npx playwright install chromium --with-deps
- name: Test
run: dotnet test -c Release --no-build -p:VSTestUseMSBuildOutput=false