Basics Done

This commit is contained in:
2026-04-13 15:08:49 +05:00
parent 9bef5813ae
commit 06ec22704b
75 changed files with 5036 additions and 2733 deletions
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<Playwright>
<BrowserName>chromium</BrowserName>
<LaunchOptions>
<Headless>true</Headless>
</LaunchOptions>
</Playwright>
</RunSettings>
@@ -0,0 +1,91 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
namespace Enciphered.Blazor.UIComponents.Tests;
/// <summary>
/// Launches the demo Blazor app as a separate process on a random free port.
/// Shared across all tests in the assembly via [SetUpFixture].
/// </summary>
public sealed class DemoServerFixture : IDisposable
{
private Process? _process;
public string BaseUrl { get; private set; } = string.Empty;
public async Task StartAsync()
{
var port = GetFreePort();
BaseUrl = $"http://localhost:{port}";
// Resolve the demo project directory (navigate up from test bin output)
var testDir = AppContext.BaseDirectory; // …Tests/bin/Debug/net9.0
var solutionRoot = Path.GetFullPath(Path.Combine(testDir, "..", "..", "..", ".."));
var demoProjectDir = Path.Combine(solutionRoot, "Enciphered.Blazor.UIComponents.Demo");
if (!Directory.Exists(demoProjectDir))
throw new DirectoryNotFoundException($"Demo project not found at: {demoProjectDir}");
_process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "dotnet",
Arguments = $"run --no-build --urls {BaseUrl}",
WorkingDirectory = demoProjectDir,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
Environment =
{
["ASPNETCORE_ENVIRONMENT"] = "Development",
["DOTNET_NOLOGO"] = "1"
}
}
};
_process.Start();
// Wait for the server to be ready by polling the URL
using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
var deadline = DateTime.UtcNow.AddSeconds(30);
while (DateTime.UtcNow < deadline)
{
try
{
var response = await httpClient.GetAsync(BaseUrl);
if (response.IsSuccessStatusCode)
return;
}
catch
{
// Server not ready yet
}
await Task.Delay(500);
}
throw new TimeoutException($"Demo server did not start within 30 seconds at {BaseUrl}");
}
public void Dispose()
{
if (_process is not null && !_process.HasExited)
{
_process.Kill(entireProcessTree: true);
_process.WaitForExit(5000);
_process.Dispose();
}
}
private static int GetFreePort()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Microsoft.Playwright.NUnit" Version="1.59.0" />
<PackageReference Include="NUnit" Version="4.2.2" />
<PackageReference Include="NUnit.Analyzers" Version="4.4.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
</ItemGroup>
<ItemGroup>
<Using Include="NUnit.Framework" />
</ItemGroup>
</Project>
@@ -0,0 +1,605 @@
using Microsoft.Playwright;
namespace Enciphered.Blazor.UIComponents.Tests;
[TestFixture]
public class FormsTests : PlaywrightTestBase
{
// ── Helpers ──────────────────────────────────────────────────────────────
private async Task GoToFormsAsync()
{
await Page.GotoAsync($"{BaseUrl}/forms", new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
// Wait for Blazor interactive mode to be ready
await Page.WaitForSelectorAsync("[data-testid='btn-submit']", new PageWaitForSelectorOptions { Timeout = 10_000 });
}
private ILocator Input(string testId) => Page.Locator($"[data-testid='{testId}']");
private ILocator Trigger(string testId) => Page.Locator($"[data-testid='{testId}']");
private ILocator Btn(string testId) => Page.Locator($"[data-testid='{testId}']");
/// <summary>
/// Select a date via the calendar popover.
/// Opens the trigger, uses month/year pickers to navigate, then clicks the day.
/// </summary>
private async Task SelectDateAsync(string triggerId, DateOnly target)
{
// Open the popover
await Trigger(triggerId).ClickAsync();
await Page.WaitForTimeoutAsync(200);
await NavigateCalendarToDate(target);
// Click the target day (only enabled buttons in the calendar day grid)
// The day grid is the last grid-cols-7 div; find the button with matching day text
var dayGrid = Page.Locator(".grid.grid-cols-7").Last;
var dayButton = dayGrid.Locator($"button:not([disabled])").Filter(new LocatorFilterOptions { HasTextString = target.Day.ToString() }).First;
await dayButton.ClickAsync();
await Page.WaitForTimeoutAsync(200);
}
/// <summary>
/// Navigate the open calendar to a specific month/year using the month and year pickers.
/// </summary>
private async Task NavigateCalendarToDate(DateOnly target)
{
// Click year header to open year picker, then select the year
var yearButton = Page.Locator("[data-calendar-year]");
await yearButton.ClickAsync();
await Page.WaitForTimeoutAsync(100);
// The year picker is a scrollable grid; find and click the target year
var yearGrid = Page.Locator(".grid.grid-cols-4");
var targetYearBtn = yearGrid.Locator($"button:has-text('{target.Year}')");
// If the year isn't visible, use prev/next to shift the year range (±20 per click)
var attempts = 0;
while (await targetYearBtn.CountAsync() == 0 && attempts < 10)
{
// Read the first year button text to determine which direction to go
var firstYearText = await yearGrid.Locator("button").First.InnerTextAsync();
var firstYear = int.Parse(firstYearText.Trim());
if (target.Year < firstYear)
await Page.Locator("button[aria-label='Previous month']").ClickAsync();
else
await Page.Locator("button[aria-label='Next month']").ClickAsync();
await Page.WaitForTimeoutAsync(50);
yearGrid = Page.Locator(".grid.grid-cols-4");
targetYearBtn = yearGrid.Locator($"button:has-text('{target.Year}')");
attempts++;
}
await targetYearBtn.First.ClickAsync();
await Page.WaitForTimeoutAsync(100);
// Now click month header to open month picker, then select the month
var monthButton = Page.Locator("[data-calendar-month]");
await monthButton.ClickAsync();
await Page.WaitForTimeoutAsync(100);
var monthGrid = Page.Locator(".grid.grid-cols-3");
var targetMonthText = new DateOnly(2000, target.Month, 1).ToString("MMM");
await monthGrid.Locator($"button:has-text('{targetMonthText}')").First.ClickAsync();
await Page.WaitForTimeoutAsync(100);
}
/// <summary>
/// Select a time via the time picker popover.
/// Opens the trigger, clicks the hour, minute, and AM/PM.
/// </summary>
private async Task SelectTimeAsync(string triggerId, int hour, int minute)
{
// Open the popover
await Trigger(triggerId).ClickAsync();
await Page.WaitForTimeoutAsync(200);
await PickTimeInOpenPopover(hour, minute);
// Close popover by clicking the backdrop overlay
await Page.Locator(".fixed.inset-0.z-40").ClickAsync(new LocatorClickOptions { Force = true });
await Page.WaitForTimeoutAsync(100);
}
/// <summary>
/// Pick hour, minute, and AM/PM in an already-open time picker.
/// Scopes all locators to the visible popover content to avoid backdrop interception.
/// </summary>
private async Task PickTimeInOpenPopover(int hour, int minute)
{
// The popover content sits in a z-50 absolutely positioned container
var popoverContent = Page.Locator(".absolute.z-50");
// Convert to 12-hour format
var isPm = hour >= 12;
var hour12 = hour % 12;
if (hour12 == 0) hour12 = 12;
// Click the hour in the first scrollable column (within the popover)
var hourText = hour12.ToString("D2");
var hourColumn = popoverContent.Locator(".scrollbar-thin").First;
await hourColumn.Locator($"button:has-text('{hourText}')").First.ClickAsync();
await Page.WaitForTimeoutAsync(50);
// Click the minute in the second scrollable column (within the popover)
var minuteText = minute.ToString("D2");
var minuteColumn = popoverContent.Locator(".scrollbar-thin").Nth(1);
await minuteColumn.Locator($"button:has-text('{minuteText}')").First.ClickAsync();
await Page.WaitForTimeoutAsync(50);
// Click AM/PM (within the popover)
var periodText = isPm ? "PM" : "AM";
await popoverContent.Locator($"button:has-text('{periodText}')").First.ClickAsync();
await Page.WaitForTimeoutAsync(50);
}
// ════════════════════════════════════════════════════════════════════════
// Rendering
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task FormsPage_Loads_Successfully()
{
var response = await Page.GotoAsync($"{BaseUrl}/forms");
Assert.That(response!.Status, Is.EqualTo(200));
}
[Test]
public async Task FormsPage_Has_Title()
{
await GoToFormsAsync();
var heading = Page.Locator("[data-sidebar-inset] h1.text-3xl");
await Expect(heading).ToHaveTextAsync("Forms Demo");
}
[Test]
public async Task All_Inputs_Are_Rendered()
{
await GoToFormsAsync();
await Expect(Input("input-name")).ToBeVisibleAsync();
await Expect(Input("input-email")).ToBeVisibleAsync();
await Expect(Input("input-password")).ToBeVisibleAsync();
await Expect(Input("input-age")).ToBeVisibleAsync();
// Date/Time/DateTime use popover triggers instead of visible native inputs
await Expect(Trigger("trigger-birthdate")).ToBeVisibleAsync();
await Expect(Trigger("trigger-preferredtime")).ToBeVisibleAsync();
await Expect(Trigger("trigger-appointment-date")).ToBeVisibleAsync();
await Expect(Trigger("trigger-appointment-time")).ToBeVisibleAsync();
}
[Test]
public async Task All_Buttons_Are_Rendered()
{
await GoToFormsAsync();
await Expect(Btn("btn-submit")).ToBeVisibleAsync();
await Expect(Btn("btn-reset")).ToBeVisibleAsync();
await Expect(Btn("btn-disabled")).ToBeVisibleAsync();
}
// ════════════════════════════════════════════════════════════════════════
// Input types
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task TextInput_Has_Correct_Type()
{
await GoToFormsAsync();
await Expect(Input("input-name")).ToHaveAttributeAsync("type", "text");
}
[Test]
public async Task EmailInput_Has_Correct_Type()
{
await GoToFormsAsync();
await Expect(Input("input-email")).ToHaveAttributeAsync("type", "email");
}
[Test]
public async Task PasswordInput_Has_Correct_Type()
{
await GoToFormsAsync();
await Expect(Input("input-password")).ToHaveAttributeAsync("type", "password");
}
[Test]
public async Task NumberInput_Has_Correct_Type()
{
await GoToFormsAsync();
await Expect(Input("input-age")).ToHaveAttributeAsync("type", "number");
}
[Test]
public async Task DateInput_Has_Correct_Type()
{
await GoToFormsAsync();
await Expect(Input("input-birthdate")).ToHaveAttributeAsync("type", "date");
}
[Test]
public async Task TimeInput_Has_Correct_Type()
{
await GoToFormsAsync();
await Expect(Input("input-time")).ToHaveAttributeAsync("type", "time");
}
[Test]
public async Task DateTimeInput_Has_Correct_Type()
{
await GoToFormsAsync();
await Expect(Input("input-appointment")).ToHaveAttributeAsync("type", "datetime-local");
}
// ════════════════════════════════════════════════════════════════════════
// Placeholders
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task TextInput_Shows_Placeholder()
{
await GoToFormsAsync();
await Expect(Input("input-name")).ToHaveAttributeAsync("placeholder", "Jane Doe");
}
[Test]
public async Task EmailInput_Shows_Placeholder()
{
await GoToFormsAsync();
await Expect(Input("input-email")).ToHaveAttributeAsync("placeholder", "jane@example.com");
}
// ════════════════════════════════════════════════════════════════════════
// Labels
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task FormFields_Have_Labels()
{
await GoToFormsAsync();
var labels = Page.Locator("label");
var count = await labels.CountAsync();
Assert.That(count, Is.EqualTo(7), "Expected 7 labels (one per form field)");
}
[Test]
public async Task Label_For_Attribute_Matches_Input_Id()
{
await GoToFormsAsync();
var label = Page.Locator("label[for='name']");
await Expect(label).ToHaveTextAsync("Full Name");
var input = Input("input-name");
await Expect(input).ToHaveAttributeAsync("id", "name");
}
// ════════════════════════════════════════════════════════════════════════
// Two-way binding
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task TextInput_Binds_Value()
{
await GoToFormsAsync();
var input = Input("input-name");
await input.FillAsync("Alice");
await Expect(input).ToHaveValueAsync("Alice");
}
[Test]
public async Task NumberInput_Binds_Value()
{
await GoToFormsAsync();
var input = Input("input-age");
await input.FillAsync("30");
await Expect(input).ToHaveValueAsync("30");
}
[Test]
public async Task DateInput_Binds_Value()
{
await GoToFormsAsync();
// Use the calendar popover to select June 15, 2000
await SelectDateAsync("trigger-birthdate", new DateOnly(2000, 6, 15));
// The hidden input should reflect the selected date
await Expect(Input("input-birthdate")).ToHaveValueAsync("2000-06-15");
}
[Test]
public async Task TimeInput_Binds_Value()
{
await GoToFormsAsync();
// Use the time picker popover to select 14:30 (2:30 PM)
await SelectTimeAsync("trigger-preferredtime", 14, 30);
// The hidden input should reflect the selected time
await Expect(Input("input-time")).ToHaveValueAsync("14:30");
}
[Test]
public async Task DateTimeInput_Binds_Value()
{
await GoToFormsAsync();
// Pick the date part via the date trigger
await SelectDateAsync("trigger-appointment-date", new DateOnly(2025, 12, 25));
// Pick the time part via the time trigger
await SelectTimeAsync("trigger-appointment-time", 10, 0);
// The hidden input should have the combined datetime value
var value = await Input("input-appointment").InputValueAsync();
Assert.That(value, Does.StartWith("2025-12-25T10:00"));
}
// ════════════════════════════════════════════════════════════════════════
// Validation — empty submit shows errors
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task Empty_Submit_Shows_Validation_Errors()
{
await GoToFormsAsync();
await Btn("btn-submit").ClickAsync();
// Wait for at least one error message to appear
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
var errors = Page.Locator("p.text-destructive");
var count = await errors.CountAsync();
Assert.That(count, Is.GreaterThanOrEqualTo(7), "Expected at least 7 validation errors (one per required field)");
}
[Test]
public async Task Validation_Error_Shows_Name_Required()
{
await GoToFormsAsync();
await Btn("btn-submit").ClickAsync();
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
var nameError = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "Name is required" });
await Expect(nameError).ToBeVisibleAsync();
}
[Test]
public async Task Validation_Error_Shows_Email_Required()
{
await GoToFormsAsync();
await Btn("btn-submit").ClickAsync();
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
var emailError = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "Email is required" });
await Expect(emailError).ToBeVisibleAsync();
}
// ════════════════════════════════════════════════════════════════════════
// Validation — specific error messages
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task Short_Name_Shows_Length_Error()
{
await GoToFormsAsync();
var input = Input("input-name");
await input.FillAsync("A");
await Btn("btn-submit").ClickAsync();
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
var error = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "2100 characters" });
await Expect(error).ToBeVisibleAsync();
}
[Test]
public async Task Invalid_Email_Shows_Error()
{
await GoToFormsAsync();
var input = Input("input-email");
await input.FillAsync("not-an-email");
await Btn("btn-submit").ClickAsync();
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
var error = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "Invalid email" });
await Expect(error).ToBeVisibleAsync();
}
[Test]
public async Task Short_Password_Shows_Error()
{
await GoToFormsAsync();
var input = Input("input-password");
await input.FillAsync("123");
await Btn("btn-submit").ClickAsync();
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
var error = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "864 characters" });
await Expect(error).ToBeVisibleAsync();
}
// ════════════════════════════════════════════════════════════════════════
// Valid submission
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task Valid_Form_Shows_Success_Message()
{
await GoToFormsAsync();
await Input("input-name").FillAsync("Jane Doe");
await Input("input-email").FillAsync("jane@example.com");
await Input("input-password").FillAsync("securepassword123");
await Input("input-age").FillAsync("30");
// Use popover pickers for date/time fields
await SelectDateAsync("trigger-birthdate", new DateOnly(1995, 3, 15));
await SelectTimeAsync("trigger-preferredtime", 9, 30);
// DateTime: pick date and time via separate triggers
await SelectDateAsync("trigger-appointment-date", new DateOnly(2025, 12, 25));
await SelectTimeAsync("trigger-appointment-time", 10, 0);
await Btn("btn-submit").ClickAsync();
var success = Page.Locator("[data-testid='success-message']");
await Expect(success).ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 5_000 });
await Expect(success).ToContainTextAsync("Form submitted successfully");
await Expect(success).ToContainTextAsync("Jane Doe");
}
[Test]
public async Task No_Success_Message_Before_Submit()
{
await GoToFormsAsync();
var success = Page.Locator("[data-testid='success-message']");
await Expect(success).ToBeHiddenAsync();
}
// ════════════════════════════════════════════════════════════════════════
// Button variants
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task Submit_Button_Has_Default_Variant_Classes()
{
await GoToFormsAsync();
var btn = Btn("btn-submit");
var cls = await btn.GetAttributeAsync("class");
Assert.That(cls, Does.Contain("bg-primary"), "Submit should use default variant");
}
[Test]
public async Task Reset_Button_Has_Outline_Variant_Classes()
{
await GoToFormsAsync();
var btn = Btn("btn-reset");
var cls = await btn.GetAttributeAsync("class");
Assert.That(cls, Does.Contain("border"), "Reset should use outline variant");
Assert.That(cls, Does.Contain("bg-background"), "Reset should use outline variant");
}
[Test]
public async Task Disabled_Button_Is_Actually_Disabled()
{
await GoToFormsAsync();
var btn = Btn("btn-disabled");
await Expect(btn).ToBeDisabledAsync();
}
[Test]
public async Task Disabled_Button_Has_Destructive_Variant()
{
await GoToFormsAsync();
var btn = Btn("btn-disabled");
var cls = await btn.GetAttributeAsync("class");
Assert.That(cls, Does.Contain("bg-destructive"), "Disabled button should have destructive variant");
}
// ════════════════════════════════════════════════════════════════════════
// Reset
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task Reset_Button_Clears_Form()
{
await GoToFormsAsync();
// Fill some fields
await Input("input-name").FillAsync("Alice");
await Input("input-email").FillAsync("alice@test.com");
// Reset
await Btn("btn-reset").ClickAsync();
// Fields should be empty
await Expect(Input("input-name")).ToHaveValueAsync("");
await Expect(Input("input-email")).ToHaveValueAsync("");
}
[Test]
public async Task Reset_Button_Clears_Success_Message()
{
await GoToFormsAsync();
// Submit valid form
await Input("input-name").FillAsync("Jane Doe");
await Input("input-email").FillAsync("jane@example.com");
await Input("input-password").FillAsync("securepassword123");
await Input("input-age").FillAsync("30");
await SelectDateAsync("trigger-birthdate", new DateOnly(1995, 3, 15));
await SelectTimeAsync("trigger-preferredtime", 9, 30);
// DateTime picker — date and time via separate triggers
await SelectDateAsync("trigger-appointment-date", new DateOnly(2025, 12, 25));
await SelectTimeAsync("trigger-appointment-time", 10, 0);
await Btn("btn-submit").ClickAsync();
var success = Page.Locator("[data-testid='success-message']");
await Expect(success).ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 5_000 });
// Reset
await Btn("btn-reset").ClickAsync();
await Expect(success).ToBeHiddenAsync();
}
// ════════════════════════════════════════════════════════════════════════
// Input styling (base CSS classes present)
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task Inputs_Have_Base_Styling_Classes()
{
await GoToFormsAsync();
var input = Input("input-name");
var cls = await input.GetAttributeAsync("class");
Assert.That(cls, Does.Contain("rounded-md"), "Input should have rounded-md class");
Assert.That(cls, Does.Contain("border"), "Input should have border class");
}
// ════════════════════════════════════════════════════════════════════════
// Navigation to forms page
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task Sidebar_Has_Forms_Link()
{
await Page.GotoAsync(BaseUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions { Timeout = 10_000 });
var formsLink = Page.Locator("a[href='/forms']");
await Expect(formsLink).ToBeVisibleAsync();
}
[Test]
public async Task Navigate_To_Forms_Via_Sidebar()
{
await Page.GotoAsync(BaseUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions { Timeout = 10_000 });
await Page.Locator("a[href='/forms']").ClickAsync();
await Page.WaitForURLAsync($"{BaseUrl}/forms");
var heading = Page.Locator("[data-sidebar-inset] h1.text-3xl");
await Expect(heading).ToHaveTextAsync("Forms Demo");
}
}
@@ -0,0 +1,24 @@
namespace Enciphered.Blazor.UIComponents.Tests;
/// <summary>
/// Assembly-level setup: boots the demo server once before any test runs.
/// </summary>
[SetUpFixture]
public class GlobalSetup
{
public static DemoServerFixture Server { get; private set; } = null!;
[OneTimeSetUp]
public async Task OneTimeSetUp()
{
Server = new DemoServerFixture();
await Server.StartAsync();
TestContext.Out.WriteLine($"Demo server started at {Server.BaseUrl}");
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
Server?.Dispose();
}
}
@@ -0,0 +1,47 @@
using Microsoft.Playwright;
namespace Enciphered.Blazor.UIComponents.Tests;
/// <summary>
/// Base class for Playwright tests. Creates a fresh browser context per test.
/// </summary>
public abstract class PlaywrightTestBase
{
protected IBrowser Browser { get; private set; } = null!;
protected IBrowserContext Context { get; private set; } = null!;
protected IPage Page { get; private set; } = null!;
protected string BaseUrl => GlobalSetup.Server.BaseUrl;
protected static ILocatorAssertions Expect(ILocator locator) => Assertions.Expect(locator);
protected static IPageAssertions Expect(IPage page) => Assertions.Expect(page);
private static IPlaywright? _playwright;
private static IBrowser? _sharedBrowser;
[OneTimeSetUp]
public async Task PlaywrightOneTimeSetUp()
{
_playwright ??= await Playwright.CreateAsync();
_sharedBrowser ??= await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true
});
Browser = _sharedBrowser;
}
[SetUp]
public async Task PlaywrightSetUp()
{
Context = await Browser.NewContextAsync(new BrowserNewContextOptions
{
IgnoreHTTPSErrors = true
});
Page = await Context.NewPageAsync();
}
[TearDown]
public async Task PlaywrightTearDown()
{
await Context.CloseAsync();
}
}
@@ -0,0 +1,590 @@
using Microsoft.Playwright;
namespace Enciphered.Blazor.UIComponents.Tests;
[TestFixture]
public class SidebarTests : PlaywrightTestBase
{
/// <summary>
/// Helper: navigate to home and wait for sidebar JS to initialize.
/// </summary>
private async Task GoHomeAsync()
{
await Page.GotoAsync(BaseUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
// Wait for the sidebar JS to apply state (data-state attribute appears on wrapper)
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions
{
Timeout = 10_000
});
}
// ────────────────────────────────────────────
// Initial render
// ────────────────────────────────────────────
[Test]
public async Task Page_Loads_Successfully()
{
var response = await Page.GotoAsync(BaseUrl);
Assert.That(response, Is.Not.Null);
Assert.That(response!.Status, Is.EqualTo(200));
}
[Test]
public async Task Sidebar_Wrapper_Has_DataState_After_Init()
{
await GoHomeAsync();
var wrapper = Page.Locator("[data-sidebar-wrapper]");
var state = await wrapper.GetAttributeAsync("data-state");
Assert.That(state, Is.Not.Null.And.Not.Empty, "Wrapper should have data-state after JS init");
}
[Test]
public async Task Sidebar_Starts_Expanded_On_Desktop()
{
// Ensure desktop viewport
await Page.SetViewportSizeAsync(1280, 800);
await GoHomeAsync();
var wrapper = Page.Locator("[data-sidebar-wrapper]");
var state = await wrapper.GetAttributeAsync("data-state");
Assert.That(state, Is.EqualTo("expanded"), "Sidebar should start expanded on desktop");
}
[Test]
public async Task Sidebar_Element_Exists()
{
await GoHomeAsync();
var sidebar = Page.Locator("[data-sidebar]");
await Expect(sidebar).ToBeVisibleAsync();
}
[Test]
public async Task Sidebar_Trigger_Exists_On_Header()
{
await GoHomeAsync();
// The sidebar header itself is the trigger on desktop
var trigger = Page.Locator("[data-sidebar-header][data-sidebar-trigger]");
await Expect(trigger).ToBeVisibleAsync();
}
[Test]
public async Task Sidebar_Has_Navigation_Links()
{
await GoHomeAsync();
var sidebar = Page.Locator("[data-sidebar]");
var links = sidebar.Locator("a[href]");
var count = await links.CountAsync();
Assert.That(count, Is.GreaterThanOrEqualTo(3), "Should have at least Home, Counter, Weather links");
}
[Test]
public async Task Sidebar_Has_Footer()
{
await GoHomeAsync();
var footer = Page.Locator("[data-sidebar-footer]");
await Expect(footer).ToBeVisibleAsync();
}
// ────────────────────────────────────────────
// Desktop toggle (collapse / expand)
// ────────────────────────────────────────────
[Test]
public async Task Desktop_Click_Trigger_Collapses_Sidebar()
{
await Page.SetViewportSizeAsync(1280, 800);
await GoHomeAsync();
// Verify starts expanded
var wrapper = Page.Locator("[data-sidebar-wrapper]");
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
// Click the sidebar header (which is the trigger on desktop)
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
// Wait for state change
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']", new PageWaitForSelectorOptions
{
Timeout = 5_000
});
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"));
}
[Test]
public async Task Desktop_Click_Trigger_Twice_Re_Expands_Sidebar()
{
await Page.SetViewportSizeAsync(1280, 800);
await GoHomeAsync();
var trigger = Page.Locator("[data-sidebar-header][data-sidebar-trigger]");
// Collapse
await trigger.ClickAsync();
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
// Expand
await trigger.ClickAsync();
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='expanded']");
var state = await Page.Locator("[data-sidebar-wrapper]").GetAttributeAsync("data-state");
Assert.That(state, Is.EqualTo("expanded"));
}
[Test]
public async Task Desktop_Collapsed_Sidebar_Has_Reduced_Width()
{
await Page.SetViewportSizeAsync(1280, 800);
await GoHomeAsync();
var sidebar = Page.Locator("[data-sidebar]");
// Get expanded width
var expandedWidth = await sidebar.EvaluateAsync<double>("el => el.getBoundingClientRect().width");
Assert.That(expandedWidth, Is.GreaterThan(100), "Expanded sidebar should be wider than 100px");
// Collapse
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
// Wait for CSS transition
await Page.WaitForTimeoutAsync(300);
// Get collapsed width
var collapsedWidth = await sidebar.EvaluateAsync<double>("el => el.getBoundingClientRect().width");
Assert.That(collapsedWidth, Is.LessThan(expandedWidth), "Collapsed sidebar should be narrower");
Assert.That(collapsedWidth, Is.LessThanOrEqualTo(60), "Collapsed sidebar should be icon-only width (~3rem = 48px)");
}
[Test]
public async Task Desktop_Collapsed_Hides_Menu_Labels()
{
await Page.SetViewportSizeAsync(1280, 800);
await GoHomeAsync();
// Target only the menu item label spans (inside sidebar-content, not sidebar-header)
var labelSpans = Page.Locator("[data-sidebar-content] a span.truncate");
var countBefore = await labelSpans.CountAsync();
Assert.That(countBefore, Is.GreaterThan(0), "Should have label spans");
// Collapse
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
await Page.WaitForTimeoutAsync(300);
// Check that label text is hidden (CSS-driven via group-data-[state=collapsed])
for (int i = 0; i < countBefore; i++)
{
var display = await labelSpans.Nth(i).EvaluateAsync<string>("el => getComputedStyle(el).display");
Assert.That(display, Is.EqualTo("none"), $"Label span {i} should have display:none when sidebar is collapsed (got '{display}')");
}
}
[Test]
public async Task Desktop_Collapsed_Hides_Group_Label()
{
await Page.SetViewportSizeAsync(1280, 800);
await GoHomeAsync();
var groupLabel = Page.Locator("[data-sidebar-group-label]");
await Expect(groupLabel).ToBeVisibleAsync();
// Collapse
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
await Page.WaitForTimeoutAsync(300);
await Expect(groupLabel).Not.ToBeVisibleAsync();
}
// ────────────────────────────────────────────
// Spacer width tracks sidebar state
// ────────────────────────────────────────────
[Test]
public async Task Desktop_Spacer_Width_Changes_On_Collapse()
{
await Page.SetViewportSizeAsync(1280, 800);
await GoHomeAsync();
var spacer = Page.Locator("[data-sidebar-spacer]");
var expandedWidth = await spacer.EvaluateAsync<double>("el => el.getBoundingClientRect().width");
Assert.That(expandedWidth, Is.GreaterThan(100), "Spacer should be wide when expanded");
// Collapse
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
await Page.WaitForTimeoutAsync(300);
var collapsedWidth = await spacer.EvaluateAsync<double>("el => el.getBoundingClientRect().width");
Assert.That(collapsedWidth, Is.LessThan(expandedWidth), "Spacer should shrink when collapsed");
}
// ────────────────────────────────────────────
// Mobile behavior
// ────────────────────────────────────────────
[Test]
public async Task Mobile_Sidebar_Starts_Closed()
{
await Page.SetViewportSizeAsync(375, 812);
await GoHomeAsync();
var wrapper = Page.Locator("[data-sidebar-wrapper]");
var state = await wrapper.GetAttributeAsync("data-state");
Assert.That(state, Is.EqualTo("collapsed"), "Sidebar should start collapsed on mobile");
var mobile = await wrapper.GetAttributeAsync("data-mobile");
Assert.That(mobile, Is.EqualTo("true"), "data-mobile should be 'true' on small viewport");
}
[Test]
public async Task Mobile_Click_Trigger_Opens_Sidebar_And_Shows_Overlay()
{
await Page.SetViewportSizeAsync(375, 812);
await GoHomeAsync();
// Click the mobile trigger button in the inset (visible only on mobile)
await Page.Locator("[data-sidebar-inset] [data-sidebar-trigger]").ClickAsync();
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='expanded']");
// Overlay should be visible
var overlay = Page.Locator("[data-sidebar-overlay]");
var display = await overlay.EvaluateAsync<string>("el => getComputedStyle(el).display");
Assert.That(display, Is.Not.EqualTo("none"), "Overlay should be visible when mobile sidebar is open");
}
[Test]
public async Task Mobile_Click_Overlay_Closes_Sidebar()
{
await Page.SetViewportSizeAsync(375, 812);
await GoHomeAsync();
// Open via the inset trigger
await Page.Locator("[data-sidebar-inset] [data-sidebar-trigger]").ClickAsync();
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='expanded']");
// Click overlay
await Page.Locator("[data-sidebar-overlay]").ClickAsync(new LocatorClickOptions { Force = true });
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
var state = await Page.Locator("[data-sidebar-wrapper]").GetAttributeAsync("data-state");
Assert.That(state, Is.EqualTo("collapsed"), "Sidebar should close when overlay is clicked");
}
[Test]
public async Task Mobile_Overlay_Hidden_When_Sidebar_Closed()
{
await Page.SetViewportSizeAsync(375, 812);
await GoHomeAsync();
var overlay = Page.Locator("[data-sidebar-overlay]");
var display = await overlay.EvaluateAsync<string>("el => el.style.display || getComputedStyle(el).display");
Assert.That(display, Is.EqualTo("none"), "Overlay should be hidden when mobile sidebar is closed");
}
// ────────────────────────────────────────────
// Navigation links work
// ────────────────────────────────────────────
[Test]
public async Task Clicking_Counter_Link_Navigates_To_Counter()
{
await Page.SetViewportSizeAsync(1280, 800);
await GoHomeAsync();
var counterLink = Page.Locator("[data-sidebar] a[href='/counter']");
await counterLink.ClickAsync();
await Page.WaitForURLAsync($"{BaseUrl}/counter");
Assert.That(Page.Url, Does.Contain("/counter"));
}
[Test]
public async Task Clicking_Weather_Link_Navigates_To_Weather()
{
await Page.SetViewportSizeAsync(1280, 800);
await GoHomeAsync();
var weatherLink = Page.Locator("[data-sidebar] a[href='/weather']");
await weatherLink.ClickAsync();
await Page.WaitForURLAsync($"{BaseUrl}/weather");
Assert.That(Page.Url, Does.Contain("/weather"));
}
[Test]
public async Task Clicking_Header_Toggles_Sidebar()
{
await Page.SetViewportSizeAsync(1280, 800);
await GoHomeAsync();
// Starts expanded
var wrapper = Page.Locator("[data-sidebar-wrapper]");
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
// Click the sidebar header (logo area) to collapse
await Page.Locator("[data-sidebar-header]").ClickAsync();
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"));
// Click again to expand
await Page.Locator("[data-sidebar-header]").ClickAsync();
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='expanded']");
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
}
// ────────────────────────────────────────────
// Navigation should NOT change sidebar state
// ────────────────────────────────────────────
[Test]
public async Task Desktop_Collapsed_Sidebar_Stays_Collapsed_After_Navigation()
{
await Page.SetViewportSizeAsync(1280, 800);
await GoHomeAsync();
// Verify starts expanded
var wrapper = Page.Locator("[data-sidebar-wrapper]");
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
// Collapse the sidebar
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"));
// Click a navigation link (Counter)
var counterLink = Page.Locator("[data-sidebar] a[href='/counter']");
await counterLink.ClickAsync();
// Wait for navigation to complete
await Page.WaitForURLAsync($"{BaseUrl}/counter");
// Wait for the sidebar JS to re-apply state after enhanced navigation
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions
{
Timeout = 5_000
});
// Give any transitions/scripts time to settle
await Page.WaitForTimeoutAsync(500);
// Sidebar should STILL be collapsed
var stateAfterNav = await wrapper.GetAttributeAsync("data-state");
Assert.That(stateAfterNav, Is.EqualTo("collapsed"),
"Sidebar should remain collapsed after clicking a navigation link");
}
[Test]
public async Task Desktop_Collapsed_Sidebar_Stays_Collapsed_After_Multiple_Navigations()
{
await Page.SetViewportSizeAsync(1280, 800);
await GoHomeAsync();
// Collapse the sidebar
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
var wrapper = Page.Locator("[data-sidebar-wrapper]");
// Navigate to Counter
await Page.Locator("[data-sidebar] a[href='/counter']").ClickAsync();
await Page.WaitForURLAsync($"{BaseUrl}/counter");
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]");
await Page.WaitForTimeoutAsync(500);
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"),
"Sidebar should remain collapsed after navigating to Counter");
// Navigate to Weather
await Page.Locator("[data-sidebar] a[href='/weather']").ClickAsync();
await Page.WaitForURLAsync($"{BaseUrl}/weather");
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]");
await Page.WaitForTimeoutAsync(500);
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"),
"Sidebar should remain collapsed after navigating to Weather");
// Navigate back Home
await Page.Locator("[data-sidebar] a[href='/']").ClickAsync();
await Page.WaitForURLAsync(url => url == BaseUrl || url == BaseUrl + "/");
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]");
await Page.WaitForTimeoutAsync(500);
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"),
"Sidebar should remain collapsed after navigating back to Home");
}
[Test]
public async Task Desktop_Sidebar_Toggle_Works_After_Same_Page_Navigation()
{
await Page.SetViewportSizeAsync(1280, 800);
await GoHomeAsync();
var wrapper = Page.Locator("[data-sidebar-wrapper]");
// Starts expanded
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
// Click the Home link while already on home (same-page navigation)
await Page.Locator("[data-sidebar] a[href='/']").ClickAsync();
// Wait for any enhanced navigation / DOM mutation to settle
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions
{
Timeout = 5_000
});
await Page.WaitForTimeoutAsync(500);
// Now click the sidebar trigger to collapse
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
await Page.WaitForTimeoutAsync(300);
var stateAfterFirstToggle = await wrapper.GetAttributeAsync("data-state");
Assert.That(stateAfterFirstToggle, Is.EqualTo("collapsed"),
"Sidebar should be collapsed after one trigger click following same-page nav");
// Click the sidebar trigger again to expand
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
await Page.WaitForTimeoutAsync(300);
var stateAfterSecondToggle = await wrapper.GetAttributeAsync("data-state");
Assert.That(stateAfterSecondToggle, Is.EqualTo("expanded"),
"Sidebar should be expanded after second trigger click following same-page nav");
}
[Test]
public async Task Desktop_Collapsed_Sidebar_Stays_Collapsed_After_Same_Page_Navigation()
{
await Page.SetViewportSizeAsync(1280, 800);
await GoHomeAsync();
var wrapper = Page.Locator("[data-sidebar-wrapper]");
// Collapse the sidebar first
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"));
// Click the Home link while already on home (same-page navigation)
await Page.Locator("[data-sidebar] a[href='/']").ClickAsync();
// Wait for any enhanced navigation / DOM mutation to settle
await Page.WaitForTimeoutAsync(500);
// Sidebar should STILL be collapsed
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"),
"Sidebar should remain collapsed after same-page navigation");
// Toggle should still work correctly: collapse -> expand
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
await Page.WaitForTimeoutAsync(300);
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"),
"Trigger should expand sidebar after same-page nav while collapsed");
// And back to collapsed
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
await Page.WaitForTimeoutAsync(300);
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"),
"Trigger should collapse sidebar again after same-page nav");
}
// ────────────────────────────────────────────
// Cookie persistence
// ────────────────────────────────────────────
[Test]
public async Task Desktop_State_Persists_Via_Cookie()
{
await Page.SetViewportSizeAsync(1280, 800);
await GoHomeAsync();
// Collapse
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
// Check cookie
var cookies = await Context.CookiesAsync();
var sidebarCookie = cookies.FirstOrDefault(c => c.Name == "sidebar:state");
Assert.That(sidebarCookie, Is.Not.Null, "sidebar:state cookie should exist");
Assert.That(sidebarCookie!.Value, Is.EqualTo("closed"), "Cookie should be 'closed' after collapse");
// Reload page — sidebar should remain collapsed
await Page.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.NetworkIdle });
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]");
var state = await Page.Locator("[data-sidebar-wrapper]").GetAttributeAsync("data-state");
Assert.That(state, Is.EqualTo("collapsed"), "Sidebar state should persist after reload");
}
// ────────────────────────────────────────────
// Viewport resize transitions
// ────────────────────────────────────────────
[Test]
public async Task Resize_From_Desktop_To_Mobile_Collapses_Sidebar()
{
await Page.SetViewportSizeAsync(1280, 800);
await GoHomeAsync();
var wrapper = Page.Locator("[data-sidebar-wrapper]");
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
// Shrink to mobile
await Page.SetViewportSizeAsync(375, 812);
// Wait for resize handler to fire
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-mobile='true']", new PageWaitForSelectorOptions
{
Timeout = 5_000
});
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"));
Assert.That(await wrapper.GetAttributeAsync("data-mobile"), Is.EqualTo("true"));
}
[Test]
public async Task Resize_From_Mobile_To_Desktop_Expands_Sidebar()
{
await Page.SetViewportSizeAsync(375, 812);
await GoHomeAsync();
var wrapper = Page.Locator("[data-sidebar-wrapper]");
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"));
// Grow to desktop
await Page.SetViewportSizeAsync(1280, 800);
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-mobile='false']", new PageWaitForSelectorOptions
{
Timeout = 5_000
});
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
Assert.That(await wrapper.GetAttributeAsync("data-mobile"), Is.EqualTo("false"));
}
// ────────────────────────────────────────────
// Sidebar inset (main content area)
// ────────────────────────────────────────────
[Test]
public async Task SidebarInset_Exists_And_Contains_Page_Content()
{
await GoHomeAsync();
var inset = Page.Locator("[data-sidebar-inset]");
await Expect(inset).ToBeVisibleAsync();
// On mobile, the inset should contain the trigger button
await Page.SetViewportSizeAsync(375, 812);
await GoHomeAsync();
var mobileTrigger = inset.Locator("[data-sidebar-trigger]");
await Expect(mobileTrigger).ToBeVisibleAsync();
}
}
@@ -0,0 +1,324 @@
using Microsoft.Playwright;
namespace Enciphered.Blazor.UIComponents.Tests;
[TestFixture]
public class ThemeToggleTests : PlaywrightTestBase
{
/// <summary>
/// Navigate to home, wait for sidebar JS + darkmode JS to finish initializing.
/// </summary>
private async Task GoHomeAsync()
{
await Page.GotoAsync(BaseUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
// Wait for sidebar JS to apply state
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions
{
Timeout = 10_000
});
// Wait for the ThemeToggle button to appear
await Page.WaitForSelectorAsync("[data-theme-toggle]", new PageWaitForSelectorOptions
{
Timeout = 10_000
});
}
/// <summary>
/// Click the toggle and wait for the dark class to be added to &lt;html&gt;.
/// </summary>
private async Task ToggleToDarkAsync()
{
await Page.Locator("[data-theme-toggle]").ClickAsync();
await Page.WaitForFunctionAsync(
"() => document.documentElement.classList.contains('dark')",
null,
new PageWaitForFunctionOptions { Timeout = 5_000 });
}
/// <summary>
/// Click the toggle and wait for the dark class to be removed from &lt;html&gt;.
/// </summary>
private async Task ToggleToLightAsync()
{
await Page.Locator("[data-theme-toggle]").ClickAsync();
await Page.WaitForFunctionAsync(
"() => !document.documentElement.classList.contains('dark')",
null,
new PageWaitForFunctionOptions { Timeout = 5_000 });
}
// ────────────────────────────────────────────
// Initial render
// ────────────────────────────────────────────
[Test]
public async Task ThemeToggle_Button_Is_Visible()
{
await GoHomeAsync();
var toggle = Page.Locator("[data-theme-toggle]");
await Expect(toggle).ToBeVisibleAsync();
}
[Test]
public async Task ThemeToggle_Starts_In_Light_Mode_By_Default()
{
await GoHomeAsync();
var hasDark = await Page.EvaluateAsync<bool>(
"() => document.documentElement.classList.contains('dark')");
Assert.That(hasDark, Is.False, "Page should start in light mode when no preference is stored");
}
[Test]
public async Task ThemeToggle_Shows_Moon_Icon_In_Light_Mode()
{
await GoHomeAsync();
var moon = Page.Locator("[data-theme-toggle] [data-theme-icon-moon]");
var moonDisplay = await moon.EvaluateAsync<string>("el => getComputedStyle(el).display");
Assert.That(moonDisplay, Is.Not.EqualTo("none"), "Moon icon should be visible in light mode");
var sun = Page.Locator("[data-theme-toggle] [data-theme-icon-sun]");
var sunDisplay = await sun.EvaluateAsync<string>("el => el.style.display");
Assert.That(sunDisplay, Is.EqualTo("none"), "Sun icon should be hidden in light mode");
}
// ────────────────────────────────────────────
// Toggle to dark mode
// ────────────────────────────────────────────
[Test]
public async Task Click_Toggle_Adds_Dark_Class_To_Html()
{
await GoHomeAsync();
await ToggleToDarkAsync();
var hasDark = await Page.EvaluateAsync<bool>(
"() => document.documentElement.classList.contains('dark')");
Assert.That(hasDark, Is.True, "Clicking toggle should add 'dark' class to <html>");
}
[Test]
public async Task Click_Toggle_Shows_Sun_Icon_In_Dark_Mode()
{
await GoHomeAsync();
await ToggleToDarkAsync();
var sun = Page.Locator("[data-theme-toggle] [data-theme-icon-sun]");
var sunDisplay = await sun.EvaluateAsync<string>("el => el.style.display");
Assert.That(sunDisplay, Is.Not.EqualTo("none"), "Sun icon should be visible in dark mode");
var moon = Page.Locator("[data-theme-toggle] [data-theme-icon-moon]");
var moonDisplay = await moon.EvaluateAsync<string>("el => el.style.display");
Assert.That(moonDisplay, Is.EqualTo("none"), "Moon icon should be hidden in dark mode");
}
[Test]
public async Task Click_Toggle_Stores_Dark_In_LocalStorage()
{
await GoHomeAsync();
await ToggleToDarkAsync();
var stored = await Page.EvaluateAsync<string>("() => localStorage.getItem('theme')");
Assert.That(stored, Is.EqualTo("dark"), "localStorage 'theme' should be 'dark' after toggle");
}
// ────────────────────────────────────────────
// Toggle back to light mode
// ────────────────────────────────────────────
[Test]
public async Task Double_Click_Toggle_Returns_To_Light_Mode()
{
await GoHomeAsync();
// Toggle to dark
await ToggleToDarkAsync();
// Toggle back to light
await ToggleToLightAsync();
var hasDark = await Page.EvaluateAsync<bool>(
"() => document.documentElement.classList.contains('dark')");
Assert.That(hasDark, Is.False, "Double-clicking toggle should return to light mode");
var stored = await Page.EvaluateAsync<string>("() => localStorage.getItem('theme')");
Assert.That(stored, Is.EqualTo("light"), "localStorage should be 'light' after toggling back");
}
[Test]
public async Task Double_Click_Toggle_Shows_Moon_Icon_Again()
{
await GoHomeAsync();
// Dark
await ToggleToDarkAsync();
// Light again
await ToggleToLightAsync();
var moon = Page.Locator("[data-theme-toggle] [data-theme-icon-moon]");
var moonDisplay = await moon.EvaluateAsync<string>("el => getComputedStyle(el).display");
Assert.That(moonDisplay, Is.Not.EqualTo("none"), "Moon icon should be visible again after toggling back");
var sun = Page.Locator("[data-theme-toggle] [data-theme-icon-sun]");
var sunDisplay = await sun.EvaluateAsync<string>("el => el.style.display");
Assert.That(sunDisplay, Is.EqualTo("none"), "Sun icon should be hidden again after toggling back");
}
// ────────────────────────────────────────────
// Persistence across page reloads
// ────────────────────────────────────────────
[Test]
public async Task Dark_Mode_Persists_After_Reload()
{
await GoHomeAsync();
await ToggleToDarkAsync();
// Reload the page
await Page.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.NetworkIdle });
// The inline <script> in <head> should apply 'dark' before paint
var hasDark = await Page.EvaluateAsync<bool>(
"() => document.documentElement.classList.contains('dark')");
Assert.That(hasDark, Is.True, "Dark mode should persist after page reload");
}
[Test]
public async Task Dark_Mode_Persists_After_Navigation()
{
await GoHomeAsync();
await ToggleToDarkAsync();
// Navigate to counter page via sidebar link
var counterLink = Page.Locator("[data-sidebar] a[href='/counter']");
await counterLink.ClickAsync();
await Page.WaitForURLAsync($"{BaseUrl}/counter");
// Wait for the page to fully settle after enhanced navigation
await Page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// Dark class should still be present on <html>
var hasDark = await Page.EvaluateAsync<bool>(
"() => document.documentElement.classList.contains('dark')");
Assert.That(hasDark, Is.True, "Dark mode should persist across navigation");
// localStorage should also still have 'dark'
var stored = await Page.EvaluateAsync<string>("() => localStorage.getItem('theme')");
Assert.That(stored, Is.EqualTo("dark"), "localStorage should still be 'dark' after navigation");
}
[Test]
public async Task Light_Mode_Persists_After_Reload()
{
await GoHomeAsync();
// Toggle to dark then back to light
await ToggleToDarkAsync();
await ToggleToLightAsync();
// Reload
await Page.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.NetworkIdle });
var hasDark = await Page.EvaluateAsync<bool>(
"() => document.documentElement.classList.contains('dark')");
Assert.That(hasDark, Is.False, "Light mode should persist after page reload");
}
// ────────────────────────────────────────────
// No FOUC (flash of unstyled content)
// ────────────────────────────────────────────
[Test]
public async Task Dark_Mode_Applied_Before_First_Paint_No_FOUC()
{
await GoHomeAsync();
await ToggleToDarkAsync();
// Reload and check at DOMContentLoaded — the inline script should have already set .dark
await Page.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.DOMContentLoaded });
var hasDarkImmediately = await Page.EvaluateAsync<bool>(
"() => document.documentElement.classList.contains('dark')");
Assert.That(hasDarkImmediately, Is.True,
"Dark class should be on <html> immediately on DOMContentLoaded (no FOUC)");
}
// ────────────────────────────────────────────
// Visual theming verification
// ────────────────────────────────────────────
[Test]
public async Task Dark_Mode_Changes_Background_Color()
{
await Page.SetViewportSizeAsync(1280, 800);
await GoHomeAsync();
// Read the --background CSS custom property which drives bg-background
var lightBgVar = await Page.EvaluateAsync<string>(
"() => getComputedStyle(document.documentElement).getPropertyValue('--background').trim()");
// Toggle to dark
await ToggleToDarkAsync();
await Page.WaitForTimeoutAsync(200);
var darkBgVar = await Page.EvaluateAsync<string>(
"() => getComputedStyle(document.documentElement).getPropertyValue('--background').trim()");
Assert.That(darkBgVar, Is.Not.EqualTo(lightBgVar),
$"--background CSS variable should change in dark mode. Light={lightBgVar}, Dark={darkBgVar}");
}
[Test]
public async Task Dark_Mode_Changes_Sidebar_Background()
{
await Page.SetViewportSizeAsync(1280, 800);
await GoHomeAsync();
var sidebar = Page.Locator("[data-sidebar]");
var lightSidebarBg = await sidebar.EvaluateAsync<string>(
"el => getComputedStyle(el).backgroundColor");
// Toggle to dark
await ToggleToDarkAsync();
await Page.WaitForTimeoutAsync(200);
var darkSidebarBg = await sidebar.EvaluateAsync<string>(
"el => getComputedStyle(el).backgroundColor");
Assert.That(darkSidebarBg, Is.Not.EqualTo(lightSidebarBg),
"Sidebar background should change in dark mode");
}
// ────────────────────────────────────────────
// Button styling (shadcn ghost button)
// ────────────────────────────────────────────
[Test]
public async Task ThemeToggle_Has_Correct_Dimensions()
{
await GoHomeAsync();
var toggle = Page.Locator("[data-theme-toggle]");
var box = await toggle.BoundingBoxAsync();
Assert.That(box, Is.Not.Null, "Toggle button should have a bounding box");
// h-9 w-9 = 36px × 36px
Assert.That(box!.Width, Is.InRange(34, 38), "Toggle button width should be ~36px (h-9)");
Assert.That(box.Height, Is.InRange(34, 38), "Toggle button height should be ~36px (w-9)");
}
[Test]
public async Task ThemeToggle_Contains_Exactly_Two_Svg_Icons()
{
await GoHomeAsync();
var toggle = Page.Locator("[data-theme-toggle]");
var svgCount = await toggle.Locator("svg").CountAsync();
Assert.That(svgCount, Is.EqualTo(2),
"Toggle should contain exactly two SVG icons (moon and sun)");
}
}