This commit is contained in:
Stevan Freeborn
2024-04-10 12:41:51 -05:00
50 changed files with 17064 additions and 61 deletions
+50
View File
@@ -0,0 +1,50 @@
name: Pull Request
on:
pull_request:
branches:
- main
paths-ignore:
- '.github/**'
- '.vscode/**'
- '.gitignore'
- '.editorconfig'
- 'LICENSE.md'
- 'docker-compose.dev.yml'
- '**/*/README.md'
- '**/*/Dockerfile'
jobs:
run_tests:
name: Run tests
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Install report generator
run: dotnet tool install --global dotnet-reportgenerator-globaltool
- name: Restore dependencies
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-deps
- name: Install playwright
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 -s ./test/Blog.Tests/ci.runsettings
- name: Upload test results
uses: actions/upload-artifact@v4
with:
name: test-results
path: ./test/Blog.Tests/TestResults
- name: Upload playwright traces
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-traces
path: ./test/Blog.Tests/bin/Debug/net8.0/playwright-traces
+1
View File
@@ -5,6 +5,7 @@
# appsettings
appsettings*.json
!appsettings.example.json
# dotenv files
.env*
+46
View File
@@ -0,0 +1,46 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Blog - Docker",
"type": "coreclr",
"request": "attach",
"processId": "${command:pickRemoteProcess}",
"pipeTransport": {
"pipeProgram": "docker",
"pipeArgs": [ "exec", "-i", "blog.stevanfreeborn.com-development" ],
"debuggerPath": "/root/vsdbg/vsdbg",
"pipeCwd": "${workspaceRoot}",
"quoteArgs": false
},
"sourceFileMap": {
"/app": "${workspaceRoot}/src/Blog/"
}
},
{
"name": "Blog",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build_blog",
"program": "${workspaceFolder}/src/Blog/bin/Debug/net8.0/Blog.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Blog",
"stopAtEntry": false,
"serverReadyAction": {
"action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
},
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
}
]
}
+3 -1
View File
@@ -2,7 +2,9 @@
"cSpell.words": [
"Antiforgery",
"blazor",
"Hsts"
"Hsts",
"Markdig",
"msedge"
],
"dotnet.unitTests.runSettingsPath": "./test/Blog.Tests/.runsettings"
}
+41
View File
@@ -0,0 +1,41 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build_blog",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/Blog.sln",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "publish_blog",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/Blog.sln",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch_blog",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"--project",
"${workspaceFolder}/Blog.sln"
],
"problemMatcher": "$msCompile"
}
]
}
+21
View File
@@ -0,0 +1,21 @@
# The MIT License (MIT)
Copyright (c) 2024 Stevan Freeborn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+7
View File
@@ -9,6 +9,13 @@
<ItemGroup>
<PackageReference Include="Markdig" Version="0.36.2" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.0.2" />
<PackageReference Include="WebStoating.Markdig.Prism" Version="1.0.0" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
</Project>
+1
View File
@@ -7,6 +7,7 @@
<base href="/" />
<link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="Blog.styles.css" />
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
<HeadOutlet @rendermode="InteractiveServer" />
</head>
+16
View File
@@ -0,0 +1,16 @@
@namespace Blog.Components.Layout
<header class="container">
<div class="title-container">
<a href="/">
<h1>journal</h1>
</a>
</div>
<div class="author-container">
<span>by</span>
<a href="https://stevanfreeborn.com" target="_blank">
<img src="https://github.com/StevanFreeborn.png" alt="Stevan Freeborn" />
<span class="sr-only">Stevan Freeborn</span>
</a>
</div>
</header>
@@ -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%;
}
}
}
+6 -1
View File
@@ -1,7 +1,12 @@
@inherits LayoutComponentBase
@rendermode InteractiveServer
@Body
<div class="container">
<Header />
<main>
@Body
</main>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
@@ -1,5 +1,22 @@
.container {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
max-width: 42rem;
padding: 1.25rem;
width: 100%;
gap: 4rem;
}
main {
display: flex;
flex-direction: column;
width: 100%;
}
#blazor-error-ui {
background: lightyellow;
background: var(--color-background-soft);
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
+7 -16
View File
@@ -3,26 +3,17 @@
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
<div class="container">
<h2>Error</h2>
<p>Sorry it's me, not you. It looks like an unhandled error occurred while processing your request.</p>
@if (ShowRequestId)
{
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
}
</div>
@code{
[CascadingParameter]
@@ -0,0 +1,5 @@
.container {
display: flex;
flex-direction: column;
gap: 1rem;
}
+6 -3
View File
@@ -1,7 +1,10 @@
@page "/"
<PageTitle>Home</PageTitle>
<PageTitle>journal - A blog by Stevan Freeborn</PageTitle>
<h1>Hello, world!</h1>
<HeadContent>
<meta name="description" content="A blog by Stevan Freeborn" />
</HeadContent>
Welcome to your new app.
<h2 class="sr-only">Posts</h2>
<Feed />
@@ -0,0 +1,8 @@
@page "/Error/404"
<div class="container">
<h2>Not Found</h2>
<p>Hmm...it doesn't look like the page you were looking for exists, but we did find this adorable corgi.</p>
<img src="not-found-corgi.jpg" alt="A cute corgi" />
</div>
@@ -0,0 +1,9 @@
.container {
display: flex;
flex-direction: column;
gap: 1rem;
& img {
border-radius: 0.5rem;
}
}
+34
View File
@@ -0,0 +1,34 @@
@namespace Blog.Components
@inject IPostService postService
@inject IJSRuntime JS
@if (posts.Count == 0)
{
<div class="container">
<p>Looks like writers block. Check back later.</p>
</div>
}
else
{
<section role="feed" class="container">
@foreach (var post in posts)
{
<a href="/@post.Slug">
<article>
<h3 title="@post.Title" >@post.Title</h3>
<p class="date">@post.PublishedAt.ToString("MMMM d, yyyy")</p>
<p class="lead">@post.Lead</p>
</article>
</a>
}
</section>
}
@code {
private List<Post> posts = [];
protected override async Task OnInitializedAsync()
{
posts = await postService.GetPostsAsync();
}
}
+46
View File
@@ -0,0 +1,46 @@
.container {
display: flex;
flex-direction: column;
width: 100%;
gap: 2rem;
& a {
padding: 1rem;
border-radius: 1rem;
background-color: var(--color-background-soft);
transition-property: transform;
transition-duration: 100ms;
transition-timing-function: ease-in-out
}
& a:active {
transform: scale(0.95)
}
& article {
display: flex;
flex-direction: column;
gap: 0.25rem;
& h3 {
background: linear-gradient(to right top, #db3e3e, #df3741, #e32e44, #e72347, #eb124b);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-size: 2rem;
font-weight: 900;
}
& .date {
font-size: 0.85rem;
}
& h3,
& .date,
& .lead {
text-wrap: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
<FocusOnNavigate RouteData="routeData" Selector="h2" />
</Found>
</Router>
+11
View File
@@ -0,0 +1,11 @@
global using System.IO.Abstractions;
global using System.Text.Json;
global using Blog.Components;
global using Blog.Posts;
global using Markdig;
global using Markdig.Prism;
global using Markdig.Syntax;
global using Microsoft.Extensions.Options;
+108
View File
@@ -0,0 +1,108 @@
namespace Blog.Posts;
class FilePostService(
IOptions<FilePostServiceOptions> options,
IFileSystem fileSystem,
ILogger<FilePostService> logger
) : IPostService
{
private static readonly JsonSerializerOptions JsonSerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
};
private static readonly MarkdownPipeline MarkdownPipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.UsePrism()
.Build();
private const string MetaFence = "meta";
private const string IndexFile = "index.md";
private readonly FilePostServiceOptions _options = options.Value;
private readonly IFileSystem _fileSystem = fileSystem;
private readonly ILogger<FilePostService> _logger = logger;
private static (string? metadata, MarkdownDocument document) ParsePost(string postText)
{
var markDoc = Markdown.Parse(postText, MarkdownPipeline);
var postMetadata = markDoc
.Where(
x =>
x is FencedCodeBlock fencedCodeBlock &&
fencedCodeBlock.Arguments is not null &&
fencedCodeBlock.Arguments.Contains(MetaFence)
)
.Select(x => x as FencedCodeBlock)
.FirstOrDefault();
if (postMetadata is not null)
{
markDoc.Remove(postMetadata);
}
var metaContent = postMetadata?.Lines.ToString();
return (metaContent, markDoc);
}
public async Task<List<Post>> GetPostsAsync()
{
var posts = new List<Post>();
if (_fileSystem.Directory.Exists(_options.PostsDirectory) is false)
{
return posts;
}
var subDirectories = _fileSystem.Directory.GetDirectories(_options.PostsDirectory);
if (subDirectories.Length is 0)
{
return posts;
}
foreach (var subDirectory in subDirectories)
{
try
{
var indexFilePath = _fileSystem.Path.Combine(subDirectory, IndexFile);
if (_fileSystem.File.Exists(indexFilePath) is false)
{
continue;
}
var postText = await _fileSystem.File.ReadAllTextAsync(indexFilePath);
var (metaContent, document) = ParsePost(postText);
if (metaContent is null || document.Count is 0)
{
continue;
}
var post = JsonSerializer.Deserialize<Post>(metaContent, JsonSerializerOptions);
if (post is null || post.IsPublished is false)
{
continue;
}
var finalPost = post with
{
Slug = _fileSystem.Path.GetFileName(subDirectory),
};
posts.Add(finalPost);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while processing post in {SubDirectory}", subDirectory);
}
}
return [.. posts.OrderByDescending(x => x.PublishedAt)];
}
}
+17
View File
@@ -0,0 +1,17 @@
namespace Blog.Posts;
class FilePostServiceOptions
{
public string PostsDirectory { get; set; } = string.Empty;
}
class FilePostServiceOptionsSetup(IConfiguration configuration) : IConfigureOptions<FilePostServiceOptions>
{
private const string SectionName = nameof(FilePostServiceOptions);
private readonly IConfiguration _configuration = configuration;
public void Configure(FilePostServiceOptions options)
{
_configuration.GetSection(SectionName).Bind(options);
}
}
+6
View File
@@ -0,0 +1,6 @@
namespace Blog.Posts;
interface IPostService
{
Task<List<Post>> GetPostsAsync();
}
+15
View File
@@ -0,0 +1,15 @@
namespace Blog.Posts;
record Post
{
public string Title { get; init; } = string.Empty;
public string Lead { get; init; } = string.Empty;
public bool IsPublished { get; init; } = false;
public DateTime PublishedAt { get; init; }
public string Slug { get; init; } = string.Empty;
}
record PostWithContent : Post
{
public string Content { get; init; } = string.Empty;
}
+7 -3
View File
@@ -1,7 +1,9 @@
using Blog.Components;
var builder = WebApplication.CreateBuilder(args);
builder.Services.ConfigureOptions<FilePostServiceOptionsSetup>();
builder.Services.AddSingleton<IFileSystem, FileSystem>();
builder.Services.AddScoped<IPostService, FilePostService>();
builder.Services
.AddRazorComponents()
.AddInteractiveServerComponents();
@@ -23,6 +25,8 @@ app
.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.UseStatusCodePagesWithRedirects("/Error/{0}");
app.Run();
public partial class Program {}
public partial class Program { }
+48
View File
@@ -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 <password>
dotnet dev-certs https --trust
```
```powershell
dotnet dev-certs https -ep $env:USERPROFILE\.aspnet\https\Blog.pfx -p <password> --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
```
+7
View File
@@ -0,0 +1,7 @@
{
"Urls": "https://localhost:4500;http://localhost:4501",
"FilePostServiceOptions": {
"PostsDirectory": "wwwroot/posts"
},
"DetailedErrors": "true"
}
+17
View File
@@ -0,0 +1,17 @@
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
DETAILED_ERRORS=true
DOTNET_WATCH_RESTART_ON_RUDE_EDIT=true
+96 -1
View File
@@ -1,4 +1,99 @@
h1:focus {
@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 */
:root {
--primary-white: #ffffff;
--secondary-white: #f8f8f8;
--primary-black: #121212;
--secondary-black: #1e1e1e;
}
:root {
--color-background: var(--primary-white);
--color-background-soft: var(--secondary-white);
--color-text: var(--primary-black);
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--primary-black);
--color-background-soft: var(--secondary-black);
--color-text: var(--primary-white);
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
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;
background-color: var(--color-background);
color: var(--color-text);
}
h2:focus {
outline: none;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+114
View File
@@ -0,0 +1,114 @@
/* PrismJS 1.29.0
https://prismjs.com/download.html#themes=prism-tomorrow&languages=markup+css+clike+javascript+abap+abnf+actionscript+ada+agda+al+antlr4+apacheconf+apex+apl+applescript+aql+arduino+arff+armasm+arturo+asciidoc+aspnet+asm6502+asmatmel+autohotkey+autoit+avisynth+avro-idl+awk+bash+basic+batch+bbcode+bbj+bicep+birb+bison+bnf+bqn+brainfuck+brightscript+bro+bsl+c+csharp+cpp+cfscript+chaiscript+cil+cilkc+cilkcpp+clojure+cmake+cobol+coffeescript+concurnas+csp+cooklang+coq+crystal+css-extras+csv+cue+cypher+d+dart+dataweave+dax+dhall+diff+django+dns-zone-file+docker+dot+ebnf+editorconfig+eiffel+ejs+elixir+elm+etlua+erb+erlang+excel-formula+fsharp+factor+false+firestore-security-rules+flow+fortran+ftl+gml+gap+gcode+gdscript+gedcom+gettext+gherkin+git+glsl+gn+linker-script+go+go-module+gradle+graphql+groovy+haml+handlebars+haskell+haxe+hcl+hlsl+hoon+http+hpkp+hsts+ichigojam+icon+icu-message-format+idris+ignore+inform7+ini+io+j+java+javadoc+javadoclike+javastacktrace+jexl+jolie+jq+jsdoc+js-extras+json+json5+jsonp+jsstacktrace+js-templates+julia+keepalived+keyman+kotlin+kumir+kusto+latex+latte+less+lilypond+liquid+lisp+livescript+llvm+log+lolcode+lua+magma+makefile+markdown+markup-templating+mata+matlab+maxscript+mel+mermaid+metafont+mizar+mongodb+monkey+moonscript+n1ql+n4js+nand2tetris-hdl+naniscript+nasm+neon+nevod+nginx+nim+nix+nsis+objectivec+ocaml+odin+opencl+openqasm+oz+parigp+parser+pascal+pascaligo+psl+pcaxis+peoplecode+perl+php+phpdoc+php-extras+plant-uml+plsql+powerquery+powershell+processing+prolog+promql+properties+protobuf+pug+puppet+pure+purebasic+purescript+python+qsharp+q+qml+qore+r+racket+cshtml+jsx+tsx+reason+regex+rego+renpy+rescript+rest+rip+roboconf+robotframework+ruby+rust+sas+sass+scss+scala+scheme+shell-session+smali+smalltalk+smarty+sml+solidity+solution-file+soy+sparql+splunk-spl+sqf+sql+squirrel+stan+stata+iecst+stylus+supercollider+swift+systemd+t4-templating+t4-cs+t4-vb+tap+tcl+tt2+textile+toml+tremor+turtle+twig+typescript+typoscript+unrealscript+uorazor+uri+v+vala+vbnet+velocity+verilog+vhdl+vim+visual-basic+warpscript+wasm+web-idl+wgsl+wiki+wolfram+wren+xeora+xml-doc+xojo+xquery+yaml+yang+zig */
code[class*=language-],
pre[class*=language-] {
color: #ccc;
background: 0 0;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
font-size: 1em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none
}
pre[class*=language-] {
padding: 1em;
margin: .5em 0;
overflow: auto
}
:not(pre)>code[class*=language-],
pre[class*=language-] {
background: #2d2d2d
}
:not(pre)>code[class*=language-] {
padding: .1em;
border-radius: .3em;
white-space: normal
}
.token.block-comment,
.token.cdata,
.token.comment,
.token.doctype,
.token.prolog {
color: #999
}
.token.punctuation {
color: #ccc
}
.token.attr-name,
.token.deleted,
.token.namespace,
.token.tag {
color: #e2777a
}
.token.function-name {
color: #6196cc
}
.token.boolean,
.token.function,
.token.number {
color: #f08d49
}
.token.class-name,
.token.constant,
.token.property,
.token.symbol {
color: #f8c555
}
.token.atrule,
.token.builtin,
.token.important,
.token.keyword,
.token.selector {
color: #cc99cd
}
.token.attr-value,
.token.char,
.token.regex,
.token.string,
.token.variable {
color: #7ec699
}
.token.entity,
.token.operator,
.token.url {
color: #67cdcc
}
.token.bold,
.token.important {
font-weight: 700
}
.token.italic {
font-style: italic
}
.token.entity {
cursor: help
}
.token.inserted {
color: green
}
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Allow: /
+1
View File
@@ -2,6 +2,7 @@
<RunSettings>
<Playwright>
<BrowserName>chromium</BrowserName>
<ExpectTimeout>30000</ExpectTimeout>
<LaunchOptions>
<Headless>false</Headless>
<Channel>msedge</Channel>
+30 -1
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
@@ -11,17 +11,46 @@
<ItemGroup>
<PackageReference Include="bunit" Version="1.27.17" />
<PackageReference Include="coverlet.msbuild" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
<PackageReference Include="Microsoft.Playwright.NUnit" Version="1.42.0" />
<PackageReference Include="moq" Version="4.20.70" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="NUnit.Analyzers" Version="3.6.1" />
<PackageReference Include="coverlet.collector" Version="6.0.0" />
</ItemGroup>
<PropertyGroup>
<CollectCoverage>true</CollectCoverage>
<CoverletOutput>./TestResults/coverage/</CoverletOutput>
<CoverletOutputFormat>cobertura</CoverletOutputFormat>
<Include>[Blog]*</Include>
<ExcludeByFile>**/Blog/Program.cs</ExcludeByFile>
</PropertyGroup>
<Target Name="GenerateHtmlCoverageReport" AfterTargets="GenerateCoverageResultAfterTest">
<Exec Command="reportgenerator -reports:./TestResults/coverage/*.xml -targetdir:./TestResults/coverage/report/ -reporttypes:Html_Dark" />
</Target>
<Target Name="OpenTestReport" AfterTargets="GenerateHtmlCoverageReport" Condition="'$(DOTNET_ENVIRONMENT)' != 'CI'">
<Exec Command="start ./TestResults/coverage/report/index.html" />
</Target>
<ItemGroup>
<ProjectReference Include="..\..\src\Blog\Blog.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include=".\EndToEnd\TestPosts\**">
<Link>TestPosts\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
+61 -5
View File
@@ -1,15 +1,71 @@
using Blog.Posts;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Blog.Tests.EndToEnd;
public class BlogHostFactory<TProgram> : WebApplicationFactory<TProgram> 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 = base.CreateHost(builder);
builder.ConfigureWebHost(webHostBuilder => webHostBuilder.UseKestrel());
var testHost = builder
.ConfigureWebHost(webHostBuilder =>
{
webHostBuilder.ConfigureLogging(config => config.ClearProviders());
webHostBuilder.ConfigureTestServices(services =>
{
var postsDirectory = Path.Combine(Directory.GetCurrentDirectory(), "TestPosts");
var postServiceOptions = new FilePostServiceOptions() { PostsDirectory = postsDirectory };
services.AddSingleton(Options.Create(postServiceOptions));
});
})
.Build();
var kestrelHost = builder.Build();
kestrelHost.Start();
builder.ConfigureWebHost(
webHostBuilder => webHostBuilder.UseKestrel(
o => o.Listen(IPAddress.Loopback, 0)
)
);
return new CompositeHost(testHost, kestrelHost);
_host = builder.Build();
_host.Start();
var server = _host.Services.GetRequiredService<IServer>();
var addresses = server.Features.GetRequiredFeature<IServerAddressesFeature>();
ClientOptions.BaseAddress = addresses.Addresses
.Select(x => new Uri(x))
.Last();
testHost.Start();
return testHost;
}
}
+42
View File
@@ -0,0 +1,42 @@
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http.Features;
namespace Blog.Tests.EndToEnd;
[TestFixture]
public class BlogTest : PageTest
{
private readonly BlogHostFactory<Program> _factory = new();
public override BrowserNewContextOptions ContextOptions()
{
var options = base.ContextOptions();
options.BaseURL = _factory.ServerAddress;
return options;
}
[SetUp]
public async Task Setup()
{
await Context.Tracing.StartAsync(new()
{
Title = TestContext.CurrentContext.Test.ClassName + "." + TestContext.CurrentContext.Test.Name,
Screenshots = true,
Snapshots = true,
Sources = true
});
}
[TearDown]
public async Task TearDown()
{
await Context.Tracing.StopAsync(new()
{
Path = Path.Combine(
TestContext.CurrentContext.WorkDirectory,
"playwright-traces",
$"{TestContext.CurrentContext.Test.ClassName}.{TestContext.CurrentContext.Test.Name}.zip"
)
});
}
}
+48
View File
@@ -0,0 +1,48 @@
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();
}
[Test]
public async Task Home_WhenNavigatedTo_ItShouldDisplayPostFeed()
{
await Page.GotoAsync("/");
var feed = Page.GetByRole(AriaRole.Feed);
await Expect(feed).ToBeVisibleAsync();
}
[Test]
public async Task Home_WhenNavigatedTo_ItShouldDisplayPosts()
{
await Page.GotoAsync("/");
var posts = Page.GetByRole(AriaRole.Article);
await Expect(posts).ToHaveCountAsync(2);
}
}
@@ -0,0 +1,76 @@
# Test Blog
```json meta
{
"title": "Another Test Blog",
"lead": "This is a test blog post",
"isPublished": true,
"publishedAt": "2020-01-01"
}
```
Welcome to my Markdown blog post! In this post, I'll cover various Markdown elements to help you test styling.
## Text Formatting
Here are some examples of text formatting:
- *Italic Text*
- **Bold Text**
- ***Bold Italic Text***
- ~~Strikethrough Text~~
## Lists
### Unordered List
- Item 1
- Item 2
- Subitem A
- Subitem B
- Item 3
### Ordered List
1. First item
2. Second item
3. Third item
## Links and Images
### Link
[OpenAI](https://openai.com) - An amazing AI research organization.
### Image
![Markdown Logo](https://upload.wikimedia.org/wikipedia/commons/thumb/4/48/Markdown-mark.svg/1920px-Markdown-mark.svg.png)
## Blockquotes
> Markdown is a lightweight markup language with plain-text formatting syntax.
## Code Blocks
```python
def greet(name):
print(f"Hello, {name}!")
greet("World")
```
## Tables
| Name | Age |
|-------|-----|
| Alice | 30 |
| Bob | 25 |
| Carol | 35 |
## Horizontal Rule
---
## Conclusion
That's it for this Markdown blog post! Feel free to experiment with styling these elements further.
@@ -0,0 +1,76 @@
# Test Blog
```json meta
{
"title": "Test Blog",
"lead": "This is a test blog post.",
"isPublished": true,
"publishedAt": "2021-01-01"
}
```
Welcome to my Markdown blog post! In this post, I'll cover various Markdown elements to help you test styling.
## Text Formatting
Here are some examples of text formatting:
- *Italic Text*
- **Bold Text**
- ***Bold Italic Text***
- ~~Strikethrough Text~~
## Lists
### Unordered List
- Item 1
- Item 2
- Subitem A
- Subitem B
- Item 3
### Ordered List
1. First item
2. Second item
3. Third item
## Links and Images
### Link
[OpenAI](https://openai.com) - An amazing AI research organization.
### Image
![Markdown Logo](https://upload.wikimedia.org/wikipedia/commons/thumb/4/48/Markdown-mark.svg/1920px-Markdown-mark.svg.png)
## Blockquotes
> Markdown is a lightweight markup language with plain-text formatting syntax.
## Code Blocks
```python
def greet(name):
print(f"Hello, {name}!")
greet("World")
```
## Tables
| Name | Age |
|-------|-----|
| Alice | 30 |
| Bob | 25 |
| Carol | 35 |
## Horizontal Rule
---
## Conclusion
That's it for this Markdown blog post! Feel free to experiment with styling these elements further.
+18 -4
View File
@@ -1,6 +1,20 @@
global using NUnit.Framework;
global using Microsoft.AspNetCore.Mvc.Testing;
global using Microsoft.Extensions.Hosting;
global using System.IO.Abstractions;
global using System.Net;
global using Blog.Posts;
global using FluentAssertions;
global using Microsoft.AspNetCore.Hosting;
global using Microsoft.Playwright.NUnit;
global using Microsoft.AspNetCore.Hosting.Server;
global using Microsoft.AspNetCore.Mvc.Testing;
global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Hosting;
global using Microsoft.Extensions.Options;
global using Microsoft.Playwright;
global using Microsoft.Playwright.NUnit;
global using Microsoft.Extensions.Logging;
global using Moq;
global using NUnit.Framework;
+44
View File
@@ -0,0 +1,44 @@
@inherits Bunit.TestContext
@code {
private Mock<IPostService> _postServiceMock = new();
[Test]
public void Feed_WhenRendedAndNoPosts_ItShouldRenderNoPostsMessage()
{
_postServiceMock
.Setup(x => x.GetPostsAsync())
.ReturnsAsync(new List<Post>());
var ctx = new TestContext();
ctx.Services.AddSingleton(_postServiceMock.Object);
var cut = ctx.Render(@<Feed />);
cut.Find("p").MarkupMatches("<p>Looks like writers block. Check back later.</p>");
}
[Test]
public void Feed_WhenRendedAndPosts_ItShouldRenderPosts()
{
List<Post> posts = [
new Post() { Title = "Post 1", },
new Post() { Title = "Post 2", },
];
_postServiceMock
.Setup(x => x.GetPostsAsync())
.ReturnsAsync(posts);
var ctx = new TestContext();
ctx.Services.AddScoped(_ => _postServiceMock.Object);
var cut = ctx.Render(@<Feed />);
var articles = cut.FindAll("article");
articles.Count.Should().Be(2);
}
}
@@ -0,0 +1,423 @@
namespace Blog.Tests.Unit;
[TestFixture]
class FilePostServiceTests
{
private readonly Mock<IFileSystem> _mockFileSystem = new();
private readonly Mock<IOptions<FilePostServiceOptions>> _mockOptions = new();
private readonly Mock<ILogger<FilePostService>> _mockLogger = new();
private readonly FilePostService _sut;
public FilePostServiceTests()
{
_mockOptions
.Setup(x => x.Value)
.Returns(new FilePostServiceOptions { PostsDirectory = "posts" });
_sut = new FilePostService(
_mockOptions.Object,
_mockFileSystem.Object,
_mockLogger.Object
);
}
[Test]
public async Task GetPostsAsync_WhenCalledAndPostsDirectoryDoesNotExist_ItShouldReturnEmptyList()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(false);
var result = await _sut.GetPostsAsync();
result.Should().BeEmpty();
}
[Test]
public async Task GetPostsAsync_WhenCalledAndPostsDirectoryContainsNoSubDirectories_ItShouldReturnEmptyList()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.Directory.GetDirectories(It.IsAny<string>()))
.Returns([]);
var result = await _sut.GetPostsAsync();
result.Should().BeEmpty();
}
[Test]
public async Task GetPostsAsync_WhenCalledAndPostsDirectoryContainsSubDirectoryWithNoIndexFile_ItShouldReturnEmptyList()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.Directory.GetDirectories(It.IsAny<string>()))
.Returns(["subdirectory"]);
_mockFileSystem
.Setup(x => x.Path.Combine(It.IsAny<string>(), It.IsAny<string>()))
.Returns("subdirectory/INDEX.md");
_mockFileSystem
.Setup(x => x.File.Exists(It.IsAny<string>()))
.Returns(false);
var result = await _sut.GetPostsAsync();
result.Should().BeEmpty();
}
[Test]
public async Task GetPostsAsync_WhenCalledAndPostIndexFileIsEmpty_ItShouldReturnEmptyList()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.Directory.GetDirectories(It.IsAny<string>()))
.Returns(["subdirectory"]);
_mockFileSystem
.Setup(x => x.Path.Combine(It.IsAny<string>(), It.IsAny<string>()))
.Returns("subdirectory/INDEX.md");
_mockFileSystem
.Setup(x => x.File.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.File.ReadAllTextAsync(It.IsAny<string>(), default))
.ReturnsAsync(string.Empty);
var result = await _sut.GetPostsAsync();
result.Should().BeEmpty();
}
[Test]
public async Task GetPostsAsync_WhenCalledAndPostIndexFileContainsNoMetadata_ItShouldReturnEmptyList()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.Directory.GetDirectories(It.IsAny<string>()))
.Returns(["subdirectory"]);
_mockFileSystem
.Setup(x => x.Path.Combine(It.IsAny<string>(), It.IsAny<string>()))
.Returns("subdirectory/INDEX.md");
_mockFileSystem
.Setup(x => x.File.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.File.ReadAllTextAsync(It.IsAny<string>(), default))
.ReturnsAsync(
"""
# Post Title
Post content
"""
);
var result = await _sut.GetPostsAsync();
result.Should().BeEmpty();
}
[Test]
public async Task GetPostsAsync_WhenCalledAndPostIndexFileOnlyContainsMetadata_ItShouldReturnEmptyList()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.Directory.GetDirectories(It.IsAny<string>()))
.Returns(["subdirectory"]);
_mockFileSystem
.Setup(x => x.Path.Combine(It.IsAny<string>(), It.IsAny<string>()))
.Returns("subdirectory/INDEX.md");
_mockFileSystem
.Setup(x => x.File.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.File.ReadAllTextAsync(It.IsAny<string>(), default))
.ReturnsAsync(
"""
```json meta
{
"title": "Post Title",
"date": "2021-01-01"
}
```
"""
);
var result = await _sut.GetPostsAsync();
result.Should().BeEmpty();
}
[Test]
public async Task GetPostsAsync_WhenCalledAndPostIndexFileHasInvalidMetadata_ItShouldReturnEmptyList()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.Directory.GetDirectories(It.IsAny<string>()))
.Returns(["subdirectory"]);
_mockFileSystem
.Setup(x => x.Path.Combine(It.IsAny<string>(), It.IsAny<string>()))
.Returns("subdirectory/INDEX.md");
_mockFileSystem
.Setup(x => x.File.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.File.ReadAllTextAsync(It.IsAny<string>(), default))
.ReturnsAsync(
"""
# Post Title
```json meta
{
"title": "Post Title",
"isPublished": "true",
"date": "2021-01-01"
}
```
Post content
"""
);
var result = await _sut.GetPostsAsync();
result.Should().BeEmpty();
}
[Test]
public async Task GetPostsAsync_WhenCalledAndPostIndexFileIsNotPublished_ItShouldReturnEmptyList()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.Directory.GetDirectories(It.IsAny<string>()))
.Returns(["subdirectory"]);
_mockFileSystem
.Setup(x => x.Path.Combine(It.IsAny<string>(), It.IsAny<string>()))
.Returns("subdirectory/INDEX.md");
_mockFileSystem
.Setup(x => x.File.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.File.ReadAllTextAsync(It.IsAny<string>(), default))
.ReturnsAsync(
"""
# Post Title
```json meta
{
"title": "Post Title",
"lead": "Post lead",
"isPublished": false,
"publishedAt": "2021-01-01"
}
```
Post content
"""
);
var result = await _sut.GetPostsAsync();
result.Should().BeEmpty();
}
[Test]
public async Task GetPostsAsync_WhenCalledAndPostIndexFileContainsMetadataAndContent_ItShouldReturnPosts()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.Directory.GetDirectories(It.IsAny<string>()))
.Returns(["subdirectory"]);
_mockFileSystem
.Setup(x => x.Path.Combine(It.IsAny<string>(), It.IsAny<string>()))
.Returns("subdirectory/INDEX.md");
_mockFileSystem
.Setup(x => x.File.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.File.ReadAllTextAsync(It.IsAny<string>(), default))
.ReturnsAsync(
"""
# Post Title
```json meta
{
"title": "Post Title",
"lead": "Post lead",
"isPublished": true,
"publishedAt": "2021-01-01",
"slug": "subdirectory"
}
```
Post content
"""
);
_mockFileSystem
.Setup(x => x.Path.GetFileName(It.IsAny<string>()))
.Returns("subdirectory");
var result = await _sut.GetPostsAsync();
result.Should().NotBeEmpty();
result.Should().HaveCount(1);
result.Should().BeEquivalentTo([
new Post
{
Title = "Post Title",
Lead = "Post lead",
IsPublished = true,
PublishedAt = new DateTime(2021, 1, 1),
Slug = "subdirectory",
}
]);
}
[Test]
public async Task GetPostsAsync_WhenCalled_ItShouldValidPostsInDescendingOrder()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.Directory.GetDirectories(It.IsAny<string>()))
.Returns([
"valid-blog-post",
"another-valid-blog-post",
"invalid-blog-post"
]);
_mockFileSystem
.SetupSequence(x => x.Path.Combine(It.IsAny<string>(), It.IsAny<string>()))
.Returns("valid-blog-post/INDEX.md")
.Returns("another-valid-blog-post/INDEX.md")
.Returns("invalid-blog-post/INDEX.md");
_mockFileSystem
.Setup(x => x.File.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.SetupSequence(x => x.File.ReadAllTextAsync(It.IsAny<string>(), default))
.ReturnsAsync(
"""
# Valid Blog Post
```json meta
{
"title": "Valid Blog Post",
"lead": "Valid post lead",
"isPublished": true,
"publishedAt": "2021-01-01",
"slug": "valid-blog-post"
}
```
Post content
"""
)
.ReturnsAsync(
"""
# Another Valid Blog Post
```json meta
{
"title": "Another Valid Blog Post",
"lead": "Another valid post lead",
"isPublished": true,
"publishedAt": "2022-01-02",
"slug": "another-valid-blog-post"
}
```
Post content
"""
)
.ReturnsAsync(
"""
```json meta
{
"title": "Invalid Blog Post",
"lead": "Invalid post lead",
"isPublished": false,
"publishedAt": "2023-01-03",
"slug": "invalid-blog-post"
}
```
"""
);
_mockFileSystem
.SetupSequence(x => x.Path.GetFileName(It.IsAny<string>()))
.Returns("valid-blog-post")
.Returns("another-valid-blog-post")
.Returns("invalid-blog-post");
var result = await _sut.GetPostsAsync();
result.Should().NotBeEmpty();
result.Should().HaveCount(2);
result.Should().BeEquivalentTo([
new Post
{
Title = "Another Valid Blog Post",
Lead = "Another valid post lead",
IsPublished = true,
PublishedAt = new DateTime(2022, 1, 2),
Slug = "another-valid-blog-post",
},
new Post
{
Title = "Valid Blog Post",
Lead = "Valid post lead",
IsPublished = true,
PublishedAt = new DateTime(2021, 1, 1),
Slug = "valid-blog-post",
}
]);
}
}
+32
View File
@@ -0,0 +1,32 @@
@inherits Bunit.TestContext
@code
{
[Test]
public void Header_WhenRendered_ItShouldContainSiteTitle()
{
var cut = Render(@<Header />);
var heading = cut.Find("h1");
heading.MarkupMatches(@<h1>journal</h1>);
}
[Test]
public void Header_WhenRendered_ItShouldContainAuthorInfo()
{
var cut = Render(@<Header />);
var author = cut.Find(".author-container > span");
author.MarkupMatches(@<span>by</span>);
}
[Test]
public void Header_WhenRendered_ItShouldContainAuthorImage()
{
var cut = Render(@<Header />);
var image = cut.Find("img[alt='Stevan Freeborn']");
image.Should().NotBeNull();
}
}
-15
View File
@@ -1,15 +0,0 @@
namespace Blog.Tests;
public class Tests
{
[SetUp]
public void Setup()
{
}
[Test]
public void Test1()
{
Assert.Pass();
}
}
+4
View File
@@ -0,0 +1,4 @@
@using Bunit
@using Blog.Components
@using Blog.Components.Layout
@using FluentAssertions
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<Playwright>
<BrowserName>chromium</BrowserName>
<ExpectTimeout>10000</ExpectTimeout>
<LaunchOptions>
<Headless>true</Headless>
<Channel>msedge</Channel>
</LaunchOptions>
</Playwright>
</RunSettings>