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
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..00d25f8e
--- /dev/null
+++ b/tests/LinkDotNet.Blog.IntegrationTests/PlaywrightSmokeTests.cs
@@ -0,0 +1,119 @@
+using System;
+using System.Threading.Tasks;
+using LinkDotNet.Blog.TestUtilities;
+using Microsoft.Playwright;
+
+namespace LinkDotNet.Blog.IntegrationTests;
+
+[Collection("Sequential")]
+public sealed class PlaywrightSmokeTests : IClassFixture, IAsyncDisposable
+{
+ private readonly PlaywrightWebApplicationFactory factory;
+ private readonly Lazy> playwrightTask;
+ private readonly Lazy> browserTask;
+
+ public PlaywrightSmokeTests(PlaywrightWebApplicationFactory factory)
+ {
+ this.factory = factory;
+ _ = factory.CreateClient();
+ 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 });
+
+ 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 browser = await browserTask.Value;
+ var page = await browser.NewPageAsync();
+
+ await page.GotoAsync(factory.ServerAddress, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
+
+ 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 });
+
+ var heading = await page.QuerySelectorAsync("h1");
+ heading.ShouldNotBeNull();
+ var headingText = await heading.TextContentAsync();
+ headingText.ShouldNotBeNullOrWhiteSpace();
+ }
+
+ [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 });
+
+ var page2Link = await page.QuerySelectorAsync("a[href='/2']");
+ page2Link.ShouldNotBeNull();
+ await page2Link.ClickAsync();
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ var currentUrl = page.Url;
+ currentUrl.ShouldContain("/2");
+
+ var blogPostElements = await page.QuerySelectorAllAsync("article");
+ blogPostElements.Count.ShouldBeGreaterThan(0);
+ }
+
+ [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 });
+
+ var blogPostLink = await page.QuerySelectorAsync("article a[href*='/blogPost/']");
+ blogPostLink.ShouldNotBeNull();
+
+ var expectedHref = await blogPostLink.GetAttributeAsync("href");
+ expectedHref.ShouldNotBeNull();
+
+ await blogPostLink.ClickAsync();
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ var currentUrl = page.Url;
+ currentUrl.ShouldContain("/blogPost/");
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ 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
new file mode 100644
index 00000000..f3701b10
--- /dev/null
+++ b/tests/LinkDotNet.Blog.IntegrationTests/PlaywrightWebApplicationFactory.cs
@@ -0,0 +1,63 @@
+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;
+
+ 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("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
+{
+}