diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml
index 09ce7e5..1cc6936 100644
--- a/.github/workflows/pull_request.yml
+++ b/.github/workflows/pull_request.yml
@@ -29,6 +29,9 @@ jobs:
run: dotnet restore
- name: Build project
run: dotnet build --no-restore
+ - name: Install playwright dependencies
+ shell: pwsh
+ run: ./test/Blog.Tests/bin/Debug/net8.0/playwright.ps1 install
- name: Test project
run: dotnet test -e DOTNET_ENVIRONMENT=CI --no-build --verbosity normal
- name: Upload test results
diff --git a/.gitignore b/.gitignore
index e2aa736..65b9f93 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@
# appsettings
appsettings*.json
+!appsettings.example.json
# dotenv files
.env*
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 7301fa2..f2aafb8 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -3,5 +3,6 @@
"Antiforgery",
"blazor",
"Hsts"
- ]
+ ],
+ "dotnet.unitTests.runSettingsPath": "./test/Blog.Tests/.runsettings"
}
\ No newline at end of file
diff --git a/src/Blog/Blog.csproj b/src/Blog/Blog.csproj
index 420ef61..50fdd4f 100644
--- a/src/Blog/Blog.csproj
+++ b/src/Blog/Blog.csproj
@@ -11,4 +11,9 @@
+
+
+
+
+
diff --git a/src/Blog/Components/App.razor b/src/Blog/Components/App.razor
index 1f3f9ab..a4ce524 100644
--- a/src/Blog/Components/App.razor
+++ b/src/Blog/Components/App.razor
@@ -7,6 +7,7 @@
+
diff --git a/src/Blog/Components/Layout/Header.razor b/src/Blog/Components/Layout/Header.razor
new file mode 100644
index 0000000..485121b
--- /dev/null
+++ b/src/Blog/Components/Layout/Header.razor
@@ -0,0 +1,13 @@
+@namespace Blog.Components
+
+
+
+
+
by
+

+
+
diff --git a/src/Blog/Components/Layout/Header.razor.css b/src/Blog/Components/Layout/Header.razor.css
new file mode 100644
index 0000000..bf913a7
--- /dev/null
+++ b/src/Blog/Components/Layout/Header.razor.css
@@ -0,0 +1,24 @@
+.container {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+ gap: 1rem;
+
+ & .title-container h1 {
+ font-size: 1.5rem;
+ font-weight: 700;
+ }
+
+ & .author-container {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+
+ & img {
+ width: 2rem;
+ height: 2rem;
+ border-radius: 50%;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Blog/Components/Layout/MainLayout.razor b/src/Blog/Components/Layout/MainLayout.razor
index 0d3d3af..6af4224 100644
--- a/src/Blog/Components/Layout/MainLayout.razor
+++ b/src/Blog/Components/Layout/MainLayout.razor
@@ -1,6 +1,10 @@
@inherits LayoutComponentBase
+@rendermode InteractiveServer
-@Body
+
+
+ @Body
+
An unhandled error has occurred.
diff --git a/src/Blog/Components/Layout/MainLayout.razor.css b/src/Blog/Components/Layout/MainLayout.razor.css
index 21a8d24..0431dce 100644
--- a/src/Blog/Components/Layout/MainLayout.razor.css
+++ b/src/Blog/Components/Layout/MainLayout.razor.css
@@ -1,3 +1,13 @@
+.container {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ max-width: 42rem;
+ padding: 1.25rem;
+ width: 100%;
+}
+
#blazor-error-ui {
background: lightyellow;
bottom: 0;
diff --git a/src/Blog/Components/Pages/Home.razor b/src/Blog/Components/Pages/Home.razor
index 9001e0b..1f8286f 100644
--- a/src/Blog/Components/Pages/Home.razor
+++ b/src/Blog/Components/Pages/Home.razor
@@ -1,6 +1,10 @@
@page "/"
-
Home
+
journal - A blog by Stevan Freeborn
+
+
+
+
Hello, world!
diff --git a/src/Blog/Program.cs b/src/Blog/Program.cs
index a2d5151..bc42976 100644
--- a/src/Blog/Program.cs
+++ b/src/Blog/Program.cs
@@ -23,4 +23,6 @@ app
.MapRazorComponents
()
.AddInteractiveServerRenderMode();
-app.Run();
\ No newline at end of file
+app.Run();
+
+public partial class Program {}
\ No newline at end of file
diff --git a/src/Blog/README.md b/src/Blog/README.md
new file mode 100644
index 0000000..63851e3
--- /dev/null
+++ b/src/Blog/README.md
@@ -0,0 +1,48 @@
+# Blog
+
+## Setup for development
+
+### Clone the repository
+
+```sh
+git clone https://github.com/StevanFreeborn/blog.stevanfreeborn.com.git
+```
+
+### Generate development SSL certificates
+
+```sh
+dotnet dev-certs https -ep ${HOME}/.aspnet/https/Blog.pfx -p
+dotnet dev-certs https --trust
+```
+
+```powershell
+dotnet dev-certs https -ep $env:USERPROFILE\.aspnet\https\Blog.pfx -p --trust
+```
+
+### Developing with Docker
+
+#### Create `.env.development` file
+
+```sh
+cp src/Blog/example.env.development src/Blog/.env.development
+```
+
+#### Build and run the Docker container
+
+```sh
+docker-compose -f docker-compose.dev.yml up --build
+```
+
+### Developing without Docker
+
+#### Create `appsettings.Development.json` file
+
+```sh
+cp src/Blog/appsettings.example.json src/Blog/appsettings.Development.json
+```
+
+#### Run the application
+
+```sh
+dotnet run --project src/Blog/Blog.csproj
+```
diff --git a/src/Blog/appsettings.example.json b/src/Blog/appsettings.example.json
new file mode 100644
index 0000000..acae0aa
--- /dev/null
+++ b/src/Blog/appsettings.example.json
@@ -0,0 +1,3 @@
+{
+ "Urls": "https://localhost:4500;http://localhost:4501"
+}
\ No newline at end of file
diff --git a/src/Blog/example.env.development b/src/Blog/example.env.development
new file mode 100644
index 0000000..fb80ac5
--- /dev/null
+++ b/src/Blog/example.env.development
@@ -0,0 +1,15 @@
+URLS=https://0.0.0.0:4500;http://0.0.0.0:4501
+
+# Path needs to match the path where the cert file
+# is mounted in the container
+ASPNETCORE_Kestrel__Certificates__Default__Path=
+
+# Password needs to match the password for the cert file
+# you generated
+ASPNETCORE_Kestrel__Certificates__Default__Password=
+
+# This is the hostname that the dotnet watch process will use to connect to the browser
+# The first url in the list is the port that the browser will attempt to connect to
+# The second url in the list is the port that the dotnet watch process will be running
+# on in the container. This is necessary for hot reload to work.
+DOTNET_WATCH_AUTO_RELOAD_WS_HOSTNAME=localhost:1234;http://0.0.0.0:5555;http://127.0.0.1
\ No newline at end of file
diff --git a/src/Blog/wwwroot/app.css b/src/Blog/wwwroot/app.css
index 795e36b..16a6d49 100644
--- a/src/Blog/wwwroot/app.css
+++ b/src/Blog/wwwroot/app.css
@@ -1,3 +1,63 @@
+@import url('https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,300;0,400;0,700;0,900;1,300;1,400;1,700;1,900&display=swap');
+
+/* Resets */
+
+* {
+ margin: 0;
+ padding: 0;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+button {
+ background: none;
+ border: none;
+ outline: none;
+ cursor: pointer;
+}
+
+a {
+ text-decoration: none;
+ color: inherit;
+}
+
+ul {
+ list-style-type: none;
+}
+
+img {
+ max-width: 100%;
+ display: block;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+/* Site */
+
+html,
+body {
+ height: 100%;
+}
+
+body {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ font-family: 'Merriweather', serif;
+ font-size: 16px;
+ line-height: 1.5;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
h1:focus {
outline: none;
}
diff --git a/src/Blog/wwwroot/favicon.ico b/src/Blog/wwwroot/favicon.ico
new file mode 100644
index 0000000..ec4c0e3
Binary files /dev/null and b/src/Blog/wwwroot/favicon.ico differ
diff --git a/src/Blog/wwwroot/favicon.png b/src/Blog/wwwroot/favicon.png
deleted file mode 100644
index 8422b59..0000000
Binary files a/src/Blog/wwwroot/favicon.png and /dev/null differ
diff --git a/test/Blog.Tests/.runsettings b/test/Blog.Tests/.runsettings
new file mode 100644
index 0000000..d2d9961
--- /dev/null
+++ b/test/Blog.Tests/.runsettings
@@ -0,0 +1,10 @@
+
+
+
+ chromium
+
+ false
+ msedge
+
+
+
\ No newline at end of file
diff --git a/test/Blog.Tests/Blog.Tests.csproj b/test/Blog.Tests/Blog.Tests.csproj
index 636746e..61db240 100644
--- a/test/Blog.Tests/Blog.Tests.csproj
+++ b/test/Blog.Tests/Blog.Tests.csproj
@@ -1,4 +1,4 @@
-
+
net8.0
@@ -11,6 +11,12 @@
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
@@ -19,6 +25,22 @@
+
+ true
+ ./TestResults/coverage/
+ cobertura
+ [Blog]*
+ **/Blog/Program.cs
+
+
+
+
+
+
+
+
+
+
diff --git a/test/Blog.Tests/EndToEnd/BlogHostFactory.cs b/test/Blog.Tests/EndToEnd/BlogHostFactory.cs
new file mode 100644
index 0000000..c138951
--- /dev/null
+++ b/test/Blog.Tests/EndToEnd/BlogHostFactory.cs
@@ -0,0 +1,62 @@
+using Microsoft.AspNetCore.Hosting.Server.Features;
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.Extensions.Logging;
+
+namespace Blog.Tests.EndToEnd;
+
+public class BlogHostFactory : WebApplicationFactory where TProgram : class
+{
+ private IHost? _host;
+
+ private void EnsureServer()
+ {
+ if (_host is null)
+ {
+ using var _ = CreateDefaultClient();
+ }
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ _host?.Dispose();
+ }
+
+ public string ServerAddress
+ {
+ get
+ {
+ EnsureServer();
+ return ClientOptions.BaseAddress.ToString();
+ }
+ }
+
+ protected override IHost CreateHost(IHostBuilder builder)
+ {
+ var testHost = builder
+ .ConfigureWebHost(
+ webHostBuilder => webHostBuilder.ConfigureLogging(
+ config => config.ClearProviders()
+ )
+ )
+ .Build();
+
+ builder.ConfigureWebHost(
+ webHostBuilder => webHostBuilder.UseKestrel(
+ o => o.Listen(IPAddress.Loopback, 0)
+ )
+ );
+
+ _host = builder.Build();
+ _host.Start();
+
+ var server = _host.Services.GetRequiredService();
+ var addresses = server.Features.GetRequiredFeature();
+
+ ClientOptions.BaseAddress = addresses.Addresses
+ .Select(x => new Uri(x))
+ .Last();
+
+ testHost.Start();
+ return testHost;
+ }
+}
\ No newline at end of file
diff --git a/test/Blog.Tests/EndToEnd/BlogTest.cs b/test/Blog.Tests/EndToEnd/BlogTest.cs
new file mode 100644
index 0000000..27ec44a
--- /dev/null
+++ b/test/Blog.Tests/EndToEnd/BlogTest.cs
@@ -0,0 +1,17 @@
+using Microsoft.AspNetCore.Hosting.Server.Features;
+using Microsoft.AspNetCore.Http.Features;
+
+namespace Blog.Tests.EndToEnd;
+
+[TestFixture]
+public class BlogTest : PageTest
+{
+ private readonly BlogHostFactory _factory = new();
+
+ public override BrowserNewContextOptions ContextOptions()
+ {
+ var options = base.ContextOptions();
+ options.BaseURL = _factory.ServerAddress;
+ return options;
+ }
+}
\ No newline at end of file
diff --git a/test/Blog.Tests/EndToEnd/HomeTests.cs b/test/Blog.Tests/EndToEnd/HomeTests.cs
new file mode 100644
index 0000000..e0ba4e5
--- /dev/null
+++ b/test/Blog.Tests/EndToEnd/HomeTests.cs
@@ -0,0 +1,32 @@
+namespace Blog.Tests.EndToEnd;
+
+[TestFixture]
+public class HomeTests : BlogTest
+{
+ [Test]
+ public async Task Home_WhenNavigatedTo_ItShouldDisplayCorrectPageTitle()
+ {
+ await Page.GotoAsync("/");
+ var title = await Page.TitleAsync();
+ title.Should().Be("journal - A blog by Stevan Freeborn");
+ }
+
+ [Test]
+ public async Task Home_WhenNavigatedTo_ItShouldCorrectSiteTitle()
+ {
+ await Page.GotoAsync("/");
+ var message = Page.GetByRole(AriaRole.Heading, new () { Name = "journal" });
+ await Expect(message).ToBeVisibleAsync();
+ }
+
+ [Test]
+ public async Task Home_WhenNavigatedTo_ItShouldDisplayWrittenByAttribution()
+ {
+ await Page.GotoAsync("/");
+ var message = Page.GetByText("by");
+ var image = Page.GetByAltText("Stevan Freeborn");
+
+ await Expect(message).ToBeVisibleAsync();
+ await Expect(image).ToBeVisibleAsync();
+ }
+}
\ No newline at end of file
diff --git a/test/Blog.Tests/GlobalUsings.cs b/test/Blog.Tests/GlobalUsings.cs
index cefced4..7892f58 100644
--- a/test/Blog.Tests/GlobalUsings.cs
+++ b/test/Blog.Tests/GlobalUsings.cs
@@ -1 +1,10 @@
-global using NUnit.Framework;
\ No newline at end of file
+global using NUnit.Framework;
+global using Microsoft.AspNetCore.Mvc.Testing;
+global using Microsoft.Extensions.Hosting;
+global using Microsoft.AspNetCore.Hosting;
+global using Microsoft.Playwright.NUnit;
+global using Microsoft.Playwright;
+global using FluentAssertions;
+global using Microsoft.AspNetCore.Hosting.Server;
+global using Microsoft.Extensions.DependencyInjection;
+global using System.Net;
\ No newline at end of file
diff --git a/test/Blog.Tests/Unit/Header.spec.razor b/test/Blog.Tests/Unit/Header.spec.razor
new file mode 100644
index 0000000..7963c28
--- /dev/null
+++ b/test/Blog.Tests/Unit/Header.spec.razor
@@ -0,0 +1,32 @@
+@inherits Bunit.TestContext
+
+@code
+{
+ [Test]
+ public void Header_WhenRendered_ItShouldContainSiteTitle()
+ {
+ var cut = Render(@);
+
+ var heading = cut.Find("h1");
+
+ heading.MarkupMatches(@journal
);
+ }
+
+ [Test]
+ public void Header_WhenRendered_ItShouldContainAuthorInfo()
+ {
+ var cut = Render(@);
+
+ var author = cut.Find(".author-container > span");
+
+ author.MarkupMatches(@by);
+ }
+
+ [Test]
+ public void Header_WhenRendered_ItShouldContainAuthorImage()
+ {
+ var cut = Render(@);
+ var image = cut.Find("img[alt='Stevan Freeborn']");
+ image.Should().NotBeNull();
+ }
+}
diff --git a/test/Blog.Tests/UnitTest1.cs b/test/Blog.Tests/UnitTest1.cs
deleted file mode 100644
index cd61a57..0000000
--- a/test/Blog.Tests/UnitTest1.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-namespace Blog.Tests;
-
-public class Tests
-{
- [SetUp]
- public void Setup()
- {
- }
-
- [Test]
- public void Test1()
- {
- Assert.Pass();
- }
-}
\ No newline at end of file
diff --git a/test/Blog.Tests/_Imports.razor b/test/Blog.Tests/_Imports.razor
new file mode 100644
index 0000000..99e4f3d
--- /dev/null
+++ b/test/Blog.Tests/_Imports.razor
@@ -0,0 +1,3 @@
+@using Bunit
+@using Blog.Components
+@using FluentAssertions
\ No newline at end of file