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