Kaynağa Gözat

Merge pull request #150 from Timmoth/v1.0.0-prep

v1.0.0-prep
Tim Jones 1 ay önce
ebeveyn
işleme
d2cbeb295e

+ 31 - 44
Shared.Rcl/Components/ResourcesListComponent.razor

@@ -1,4 +1,5 @@
 @using RackPeek.Domain.Persistence
+@using RackPeek.Domain.Resources
 @typeparam TResource where TResource : RackPeek.Domain.Resources.Resource
 
 @inject IResourceCollection Repo
@@ -12,38 +13,32 @@
 <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6 space-y-6"
      data-testid="@($"{TestId}-page-root")">
 
-    <!-- Add Resource Section -->
     <div data-testid="@($"{TestId}-add-section")">
         <AddResourceComponent TResource="TResource"
-                              Placeholder=@($"{Title} name")
+                              Placeholder=@($"{ResourceKind} name")
                               OnCreated="OnCreated"/>
     </div>
 
-    @if (_resources is null)
+    @if (ResourcesToRender is null)
     {
-        <div class="text-zinc-500"
-             data-testid="@($"{TestId}-loading")">
-            loading @Title.ToLower()…
+        <div class="text-zinc-500" data-testid="@($"{TestId}-loading")">
+            loading @ResourceKind.ToLower()…
         </div>
     }
-    else if (!_resources.Any())
+    else if (!ResourcesToRender.Any())
     {
-        <div class="text-zinc-500"
-             data-testid="@($"{TestId}-empty")">
-            no @Title.ToLower() found
+        <div class="text-zinc-500" data-testid="@($"{TestId}-empty")">
+            no @ResourceKind.ToLower() found
         </div>
     }
     else
     {
-        <div class="space-y-4"
-             data-testid="@($"{TestId}-list")">
-
-            @foreach (var group in GroupResources(_resources))
+        <div class="space-y-4" data-testid="@($"{TestId}-list")">
+            @foreach (var group in GroupResources(ResourcesToRender))
             {
                 var groupKey = FormatGroupKey(group.Key);
 
                 <div data-testid="@($"{TestId}-group-{groupKey}")">
-
                     @if (ShouldGroup)
                     {
                         <div class="text-xs text-zinc-500 uppercase tracking-wider mb-2"
@@ -52,18 +47,14 @@
                         </div>
                     }
 
-                    <div class="space-y-4"
-                         data-testid="@($"{TestId}-group-list-{groupKey}")">
-
+                    <div class="space-y-4" data-testid="@($"{TestId}-group-list-{groupKey}")">
                         @foreach (var item in group)
                         {
                             @ItemTemplate(item)
                         }
-
                     </div>
                 </div>
             }
-
         </div>
     }
 </div>
@@ -72,41 +63,37 @@
     [Parameter] public string Title { get; set; } = default!;
     [Parameter] public string TestId { get; set; } = default!;
 
+    [Parameter] public IReadOnlyList<TResource>? Resources { get; set; }
+
     [Parameter] public RenderFragment AddSection { get; set; } = default!;
     [Parameter] public RenderFragment<TResource> ItemTemplate { get; set; } = default!;
 
     [Parameter] public bool ShouldGroup { get; set; }
+    [Parameter] public Func<TResource, string?> GroupBy { get; set; } = _ => null;
 
-    [Parameter] public Func<TResource, string?> GroupBy { get; set; } = s => null;
     [Parameter] public EventCallback<string> OnCreated { get; set; }
 
-    private IReadOnlyList<TResource>? _resources;
+    private IReadOnlyList<TResource>? _loadedResources;
+    
+    private IReadOnlyList<TResource>? ResourcesToRender => Resources ?? _loadedResources;
 
-    protected override async Task OnInitializedAsync()
+    public string ResourceKind { get; set; } = Resource.GetKind<TResource>();
+    
+    protected override async Task OnParametersSetAsync()
     {
-        _resources = await Repo.GetAllOfTypeAsync<TResource>();
-    }
+        if (Resources is not null)
+            return;
 
-    private IEnumerable<IGrouping<string?, TResource>> GroupResources(
-        IEnumerable<TResource> resources)
-    {
-        return resources
-            .GroupBy(GroupBy)
-            .OrderByDescending(g => g.Count());
+        if (_loadedResources is null)
+            _loadedResources = await Repo.GetAllOfTypeAsync<TResource>();
     }
 
-    private static string FormatGroupKey(string? key)
-    {
-        return string.IsNullOrWhiteSpace(key)
-            ? "unassigned"
-            : key.Replace(" ", "-");
-    }
+    private IEnumerable<IGrouping<string?, TResource>> GroupResources(IEnumerable<TResource> resources) =>
+        resources.GroupBy(GroupBy).OrderByDescending(g => g.Count());
 
-    private static string DisplayGroupKey(string? key)
-    {
-        return string.IsNullOrWhiteSpace(key)
-            ? "Unassigned"
-            : key;
-    }
+    private static string FormatGroupKey(string? key) =>
+        string.IsNullOrWhiteSpace(key) ? "unassigned" : key.Replace(" ", "-");
 
-}
+    private static string DisplayGroupKey(string? key) =>
+        string.IsNullOrWhiteSpace(key) ? "Unassigned" : key;
+}

+ 40 - 8
Shared.Rcl/Systems/SystemsListPage.razor

@@ -5,8 +5,9 @@
 @inject NavigationManager Nav
 
 <ResourcesListComponent TResource="SystemResource"
-                        Title="Systems"
+                        Title="@PageTitle"
                         TestId="systems"
+                        Resources="@Systems"
                         ShouldGroup="true"
                         GroupBy="@(s => s.RunsOn)"
                         OnCreated="NavigateToNewResource">
@@ -35,6 +36,40 @@
     [SupplyParameterFromQuery(Name = "os")]
     public string? Os { get; set; }
 
+    public IReadOnlyList<SystemResource> Systems { get; set; } = new List<SystemResource>();
+
+    // Computed title that reflects active filters
+    private string PageTitle
+    {
+        get
+        {
+            var type = Normalize(Type);
+            var os = Normalize(Os);
+
+            if (string.IsNullOrEmpty(type) && string.IsNullOrEmpty(os))
+                return "Systems";
+
+            var parts = new List<string>();
+            if (!string.IsNullOrEmpty(type)) parts.Add(type);
+            if (!string.IsNullOrEmpty(os)) parts.Add(os);
+
+            return $"Systems ({string.Join(" / ", parts)})";
+        }
+    }
+
+    protected override async Task OnInitializedAsync()
+    {
+        await Reload();
+        await base.OnInitializedAsync();
+    }
+
+    protected override async Task OnParametersSetAsync()
+    {
+        // If query params change while staying on the page, keep list + title in sync
+        await Reload();
+        await base.OnParametersSetAsync();
+    }
+
     private async Task Reload(string _ = "")
     {
         var type = Normalize(Type);
@@ -42,18 +77,16 @@
 
         if (string.IsNullOrEmpty(type) && string.IsNullOrEmpty(os))
         {
-            await Repo.GetAllOfTypeAsync<SystemResource>();
+            Systems = await Repo.GetAllOfTypeAsync<SystemResource>();
         }
         else
         {
-            await SystemRepo.GetFilteredAsync(type, os);
+            Systems = await SystemRepo.GetFilteredAsync(type, os);
         }
     }
 
     private static string? Normalize(string? s)
-    {
-        return string.IsNullOrWhiteSpace(s) ? null : s.Trim();
-    }
+        => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
 
     private async Task UpdateSystem(SystemEditModel edit)
     {
@@ -75,5 +108,4 @@
         Nav.NavigateTo($"resources/systems/{name}");
         return Task.CompletedTask;
     }
-
-}
+}

+ 61 - 0
Tests.E2e/DesktopTests.cs

@@ -0,0 +1,61 @@
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class DesktopTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    [Fact]
+    public async Task User_Can_Add_And_Delete_Desktop()
+    {
+        var (context, page) = await CreatePageAsync();
+        var resourceName = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            // Go home
+            await page.GotoAsync(fixture.BaseUrl);
+
+            _output.WriteLine($"URL after Goto: {page.Url}");
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwarePage = new HardwareTreePom(page);
+            await hardwarePage.AssertLoadedAsync();
+            await hardwarePage.GotoDesktopsListAsync();
+
+            var listPage = new DesktopsListPom(page);
+            await listPage.AssertLoadedAsync();
+            await listPage.AddDesktopAsync(resourceName);
+            await listPage.AssertDesktopExists(resourceName);
+            await listPage.DeleteDesktopAsync(resourceName);
+            await listPage.AssertDesktopDoesNotExist(resourceName);
+
+            await context.CloseAsync();
+        }
+        catch (Exception ex)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+}

+ 61 - 0
Tests.E2e/FirewallTests.cs

@@ -0,0 +1,61 @@
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class FirewallTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    [Fact]
+    public async Task User_Can_Add_And_Delete_Firewall()
+    {
+        var (context, page) = await CreatePageAsync();
+        var resourceName = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            // Go home
+            await page.GotoAsync(fixture.BaseUrl);
+
+            _output.WriteLine($"URL after Goto: {page.Url}");
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwarePage = new HardwareTreePom(page);
+            await hardwarePage.AssertLoadedAsync();
+            await hardwarePage.GotoFirewallsListAsync();
+
+            var listPage = new FirewallsListPom(page);
+            await listPage.AssertLoadedAsync();
+            await listPage.AddFirewallAsync(resourceName);
+            await listPage.AssertFirewallExists(resourceName);
+            await listPage.DeleteFirewallAsync(resourceName);
+            await listPage.AssertFirewallDoesNotExist(resourceName);
+
+            await context.CloseAsync();
+        }
+        catch (Exception ex)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+}

+ 61 - 0
Tests.E2e/LaptopTests.cs

@@ -0,0 +1,61 @@
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class LaptopTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    [Fact]
+    public async Task User_Can_Add_And_Delete_Laptop()
+    {
+        var (context, page) = await CreatePageAsync();
+        var resourceName = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            // Go home
+            await page.GotoAsync(fixture.BaseUrl);
+
+            _output.WriteLine($"URL after Goto: {page.Url}");
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwarePage = new HardwareTreePom(page);
+            await hardwarePage.AssertLoadedAsync();
+            await hardwarePage.GotoLaptopsListAsync();
+
+            var listPage = new LaptopListPom(page);
+            await listPage.AssertLoadedAsync();
+            await listPage.AddLaptopAsync(resourceName);
+            await listPage.AssertLaptopExists(resourceName);
+            await listPage.DeleteLaptopAsync(resourceName);
+            await listPage.AssertLaptopDoesNotExist(resourceName);
+
+            await context.CloseAsync();
+        }
+        catch (Exception ex)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+}

+ 124 - 0
Tests.E2e/PageObjectModels/DesktopListPom.cs

@@ -0,0 +1,124 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class DesktopsListPom(IPage page)
+{
+    public AddResourceComponent AddDesktop => new(page, "desktop");
+
+    public ILocator PageRoot => page.GetByTestId("desktops-page-root");
+    public ILocator PageTitle => page.GetByTestId("desktops-page-title");
+
+    public ILocator Loading => page.GetByTestId("desktops-loading");
+    public ILocator EmptyState => page.GetByTestId("desktops-empty");
+    public ILocator DesktopsList => page.GetByTestId("desktops-list");
+
+    public ILocator AddSection => page.GetByTestId("desktops-add-section");
+
+    // Must match AddResourceComponent test IDs
+    public ILocator AddInput => page.GetByTestId("add-desktop-input");
+    public ILocator AddButton => page.GetByTestId("add-desktop-button");
+
+    // -------------------------------------------------
+    // Dynamic Desktop Items
+    // -------------------------------------------------
+
+    public ILocator DesktopItem(string name)
+    {
+        return page.GetByTestId($"desktop-item-{Sanitize(name)}");
+    }
+
+    public ILocator OpenLink(string name)
+    {
+        return DesktopItem(name)
+            .GetByTestId("open-desktop-link");
+    }
+
+    public ILocator DeleteButton(string name)
+    {
+        return DesktopItem(name)
+            .GetByTestId("delete-desktop-button");
+    }
+
+    public ILocator RenameButton(string name)
+    {
+        return DesktopItem(name)
+            .GetByTestId("rename-desktop-button");
+    }
+
+    public ILocator CloneButton(string name)
+    {
+        return DesktopItem(name)
+            .GetByTestId("clone-desktop-button");
+    }
+
+    public ILocator ModelBadge(string name)
+    {
+        return DesktopItem(name)
+            .GetByTestId("desktop-model-badge");
+    }
+
+    // -------------------------------------------------
+    // Navigation
+    // -------------------------------------------------
+
+    public async Task GotoAsync(string baseUrl)
+    {
+        await page.GotoAsync($"{baseUrl}/desktops/list");
+        await AssertLoadedAsync();
+    }
+
+    public async Task AssertLoadedAsync()
+    {
+        await Assertions.Expect(PageRoot).ToBeVisibleAsync();
+        await Assertions.Expect(PageTitle).ToBeVisibleAsync();
+    }
+
+    public async Task WaitForListAsync()
+    {
+        await Assertions.Expect(DesktopsList).ToBeVisibleAsync();
+    }
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public async Task AddDesktopAsync(string name)
+    {
+        await AddDesktop.AddAsync(name);
+        await Assertions.Expect(DesktopItem(name))
+            .ToBeVisibleAsync();
+    }
+
+    public async Task DeleteDesktopAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+        await page.GetByTestId("Desktop-confirm-modal-confirm").ClickAsync();
+
+        await Assertions.Expect(DesktopItem(name))
+            .Not.ToBeVisibleAsync();
+    }
+
+    public async Task OpenDesktopAsync(string name)
+    {
+        await OpenLink(name).ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{name}");
+    }
+
+    public async Task AssertDesktopExists(string name)
+    {
+        await Assertions.Expect(DesktopItem(name))
+            .ToBeVisibleAsync();
+    }
+
+    public async Task AssertDesktopDoesNotExist(string name)
+    {
+        await Assertions.Expect(DesktopItem(name))
+            .Not.ToBeVisibleAsync();
+    }
+
+    private static string Sanitize(string value)
+    {
+        return value.Replace(" ", "-");
+    }
+}

+ 136 - 0
Tests.E2e/PageObjectModels/FirewallListPom.cs

@@ -0,0 +1,136 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class FirewallsListPom(IPage page)
+{
+    public AddResourceComponent AddFirewall => new(page, "firewall");
+
+    public ILocator PageRoot => page.GetByTestId("firewalls-page-root");
+    public ILocator PageTitle => page.GetByTestId("firewalls-page-title");
+
+    public ILocator Loading => page.GetByTestId("firewalls-loading");
+    public ILocator EmptyState => page.GetByTestId("firewalls-empty");
+    public ILocator FirewallsList => page.GetByTestId("firewalls-list");
+
+    public ILocator AddSection => page.GetByTestId("firewalls-add-section");
+
+    // Must match AddResourceComponent test IDs
+    public ILocator AddInput => page.GetByTestId("add-firewall-input");
+    public ILocator AddButton => page.GetByTestId("add-firewall-button");
+
+    // -------------------------------------------------
+    // Dynamic Firewall Items
+    // -------------------------------------------------
+
+    public ILocator FirewallItem(string name)
+    {
+        return page.GetByTestId($"firewall-item-{Sanitize(name)}");
+    }
+
+    public ILocator OpenLink(string name)
+    {
+        return FirewallItem(name)
+            .GetByTestId("open-firewall-link");
+    }
+
+    public ILocator EditButton(string name)
+    {
+        return FirewallItem(name)
+            .GetByTestId("edit-firewall-button");
+    }
+
+    public ILocator SaveButton(string name)
+    {
+        return FirewallItem(name)
+            .GetByTestId("save-firewall-button");
+    }
+
+    public ILocator CancelButton(string name)
+    {
+        return FirewallItem(name)
+            .GetByTestId("cancel-firewall-button");
+    }
+
+    public ILocator RenameButton(string name)
+    {
+        return FirewallItem(name)
+            .GetByTestId("rename-firewall-button");
+    }
+
+    public ILocator CloneButton(string name)
+    {
+        return FirewallItem(name)
+            .GetByTestId("clone-firewall-button");
+    }
+
+    public ILocator DeleteButton(string name)
+    {
+        return FirewallItem(name)
+            .GetByTestId("delete-firewall-button");
+    }
+
+    // -------------------------------------------------
+    // Navigation
+    // -------------------------------------------------
+
+    public async Task GotoAsync(string baseUrl)
+    {
+        await page.GotoAsync($"{baseUrl}/firewalls/list");
+        await AssertLoadedAsync();
+    }
+
+    public async Task AssertLoadedAsync()
+    {
+        await Assertions.Expect(PageRoot).ToBeVisibleAsync();
+        await Assertions.Expect(PageTitle).ToBeVisibleAsync();
+    }
+
+    public async Task WaitForListAsync()
+    {
+        await Assertions.Expect(FirewallsList).ToBeVisibleAsync();
+    }
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public async Task AddFirewallAsync(string name)
+    {
+        await AddFirewall.AddAsync(name);
+        await Assertions.Expect(FirewallItem(name))
+            .ToBeVisibleAsync();
+    }
+
+    public async Task DeleteFirewallAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+        await page.GetByTestId("Firewall-confirm-modal-confirm").ClickAsync();
+
+        await Assertions.Expect(FirewallItem(name))
+            .Not.ToBeVisibleAsync();
+    }
+
+    public async Task OpenFirewallAsync(string name)
+    {
+        await OpenLink(name).ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{name}");
+    }
+
+    public async Task AssertFirewallExists(string name)
+    {
+        await Assertions.Expect(FirewallItem(name))
+            .ToBeVisibleAsync();
+    }
+
+    public async Task AssertFirewallDoesNotExist(string name)
+    {
+        await Assertions.Expect(FirewallItem(name))
+            .Not.ToBeVisibleAsync();
+    }
+
+    private static string Sanitize(string value)
+    {
+        return value.Replace(" ", "-");
+    }
+}

+ 124 - 0
Tests.E2e/PageObjectModels/LaptopListPom.cs

@@ -0,0 +1,124 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class LaptopListPom(IPage page)
+{
+    public AddResourceComponent Addlaptop => new(page, "laptop");
+
+    public ILocator PageRoot => page.GetByTestId("laptops-page-root");
+    public ILocator PageTitle => page.GetByTestId("laptops-page-title");
+
+    public ILocator Loading => page.GetByTestId("laptops-loading");
+    public ILocator EmptyState => page.GetByTestId("laptops-empty");
+    public ILocator LaptopsList => page.GetByTestId("laptops-list");
+
+    public ILocator AddSection => page.GetByTestId("laptops-add-section");
+
+    // Must match AddResourceComponent test IDs
+    public ILocator AddInput => page.GetByTestId("add-laptop-input");
+    public ILocator AddButton => page.GetByTestId("add-laptop-button");
+
+    // -------------------------------------------------
+    // Dynamic laptop Items
+    // -------------------------------------------------
+
+    public ILocator LaptopItem(string name)
+    {
+        return page.GetByTestId($"laptop-item-{Sanitize(name)}");
+    }
+
+    public ILocator OpenLink(string name)
+    {
+        return LaptopItem(name)
+            .GetByTestId("open-laptop-link");
+    }
+
+    public ILocator DeleteButton(string name)
+    {
+        return LaptopItem(name)
+            .GetByTestId("delete-laptop-button");
+    }
+
+    public ILocator RenameButton(string name)
+    {
+        return LaptopItem(name)
+            .GetByTestId("rename-laptop-button");
+    }
+
+    public ILocator CloneButton(string name)
+    {
+        return LaptopItem(name)
+            .GetByTestId("clone-laptop-button");
+    }
+
+    public ILocator ModelBadge(string name)
+    {
+        return LaptopItem(name)
+            .GetByTestId("laptop-model-badge");
+    }
+
+    // -------------------------------------------------
+    // Navigation
+    // -------------------------------------------------
+
+    public async Task GotoAsync(string baseUrl)
+    {
+        await page.GotoAsync($"{baseUrl}/laptops/list");
+        await AssertLoadedAsync();
+    }
+
+    public async Task AssertLoadedAsync()
+    {
+        await Assertions.Expect(PageRoot).ToBeVisibleAsync();
+        await Assertions.Expect(PageTitle).ToBeVisibleAsync();
+    }
+
+    public async Task WaitForListAsync()
+    {
+        await Assertions.Expect(LaptopsList).ToBeVisibleAsync();
+    }
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public async Task AddLaptopAsync(string name)
+    {
+        await Addlaptop.AddAsync(name);
+        await Assertions.Expect(LaptopItem(name))
+            .ToBeVisibleAsync();
+    }
+
+    public async Task DeleteLaptopAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+        await page.GetByTestId("Laptop-confirm-modal-confirm").ClickAsync();
+
+        await Assertions.Expect(LaptopItem(name))
+            .Not.ToBeVisibleAsync();
+    }
+
+    public async Task OpenLaptopAsync(string name)
+    {
+        await OpenLink(name).ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{name}");
+    }
+
+    public async Task AssertLaptopExists(string name)
+    {
+        await Assertions.Expect(LaptopItem(name))
+            .ToBeVisibleAsync();
+    }
+
+    public async Task AssertLaptopDoesNotExist(string name)
+    {
+        await Assertions.Expect(LaptopItem(name))
+            .Not.ToBeVisibleAsync();
+    }
+
+    private static string Sanitize(string value)
+    {
+        return value.Replace(" ", "-");
+    }
+}

+ 136 - 0
Tests.E2e/PageObjectModels/RouterListPom.cs

@@ -0,0 +1,136 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class RouterListPom(IPage page)
+{
+    public AddResourceComponent AddRouter => new(page, "router");
+
+    public ILocator PageRoot => page.GetByTestId("routers-page-root");
+    public ILocator PageTitle => page.GetByTestId("routers-page-title");
+
+    public ILocator Loading => page.GetByTestId("routers-loading");
+    public ILocator EmptyState => page.GetByTestId("routers-empty");
+    public ILocator RoutersList => page.GetByTestId("routers-list");
+
+    public ILocator AddSection => page.GetByTestId("routers-add-section");
+
+    // Must match AddResourceComponent test IDs
+    public ILocator AddInput => page.GetByTestId("add-router-input");
+    public ILocator AddButton => page.GetByTestId("add-router-button");
+
+    // -------------------------------------------------
+    // Dynamic Router Items
+    // -------------------------------------------------
+
+    public ILocator RouterItem(string name)
+    {
+        return page.GetByTestId($"router-item-{Sanitize(name)}");
+    }
+
+    public ILocator OpenLink(string name)
+    {
+        return RouterItem(name)
+            .GetByTestId("open-router-link");
+    }
+
+    public ILocator EditButton(string name)
+    {
+        return RouterItem(name)
+            .GetByTestId("edit-router-button");
+    }
+
+    public ILocator SaveButton(string name)
+    {
+        return RouterItem(name)
+            .GetByTestId("save-router-button");
+    }
+
+    public ILocator CancelButton(string name)
+    {
+        return RouterItem(name)
+            .GetByTestId("cancel-router-button");
+    }
+
+    public ILocator RenameButton(string name)
+    {
+        return RouterItem(name)
+            .GetByTestId("rename-router-button");
+    }
+
+    public ILocator CloneButton(string name)
+    {
+        return RouterItem(name)
+            .GetByTestId("clone-router-button");
+    }
+
+    public ILocator DeleteButton(string name)
+    {
+        return RouterItem(name)
+            .GetByTestId("delete-router-button");
+    }
+
+    // -------------------------------------------------
+    // Navigation
+    // -------------------------------------------------
+
+    public async Task GotoAsync(string baseUrl)
+    {
+        await page.GotoAsync($"{baseUrl}/routers/list");
+        await AssertLoadedAsync();
+    }
+
+    public async Task AssertLoadedAsync()
+    {
+        await Assertions.Expect(PageRoot).ToBeVisibleAsync();
+        await Assertions.Expect(PageTitle).ToBeVisibleAsync();
+    }
+
+    public async Task WaitForListAsync()
+    {
+        await Assertions.Expect(RoutersList).ToBeVisibleAsync();
+    }
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public async Task AddRouterAsync(string name)
+    {
+        await AddRouter.AddAsync(name);
+        await Assertions.Expect(RouterItem(name))
+            .ToBeVisibleAsync();
+    }
+
+    public async Task DeleteRouterAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+        await page.GetByTestId("Router-confirm-modal-confirm").ClickAsync();
+
+        await Assertions.Expect(RouterItem(name))
+            .Not.ToBeVisibleAsync();
+    }
+
+    public async Task OpenRouterAsync(string name)
+    {
+        await OpenLink(name).ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{name}");
+    }
+
+    public async Task AssertRouterExists(string name)
+    {
+        await Assertions.Expect(RouterItem(name))
+            .ToBeVisibleAsync();
+    }
+
+    public async Task AssertRouterDoesNotExist(string name)
+    {
+        await Assertions.Expect(RouterItem(name))
+            .Not.ToBeVisibleAsync();
+    }
+
+    private static string Sanitize(string value)
+    {
+        return value.Replace(" ", "-");
+    }
+}

+ 136 - 0
Tests.E2e/PageObjectModels/SwitchListPom.cs

@@ -0,0 +1,136 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class SwitchListPom(IPage page)
+{
+    public AddResourceComponent AddSwitch => new(page, "switch");
+
+    public ILocator PageRoot => page.GetByTestId("switches-page-root");
+    public ILocator PageTitle => page.GetByTestId("switches-page-title");
+
+    public ILocator Loading => page.GetByTestId("switches-loading");
+    public ILocator EmptyState => page.GetByTestId("switches-empty");
+    public ILocator SwitchsList => page.GetByTestId("switches-list");
+
+    public ILocator AddSection => page.GetByTestId("switches-add-section");
+
+    // Must match AddResourceComponent test IDs
+    public ILocator AddInput => page.GetByTestId("add-switch-input");
+    public ILocator AddButton => page.GetByTestId("add-switch-button");
+
+    // -------------------------------------------------
+    // Dynamic Switch Items
+    // -------------------------------------------------
+
+    public ILocator SwitchItem(string name)
+    {
+        return page.GetByTestId($"switch-item-{Sanitize(name)}");
+    }
+
+    public ILocator OpenLink(string name)
+    {
+        return SwitchItem(name)
+            .GetByTestId("open-switch-link");
+    }
+
+    public ILocator EditButton(string name)
+    {
+        return SwitchItem(name)
+            .GetByTestId("edit-switch-button");
+    }
+
+    public ILocator SaveButton(string name)
+    {
+        return SwitchItem(name)
+            .GetByTestId("save-switch-button");
+    }
+
+    public ILocator CancelButton(string name)
+    {
+        return SwitchItem(name)
+            .GetByTestId("cancel-switch-button");
+    }
+
+    public ILocator RenameButton(string name)
+    {
+        return SwitchItem(name)
+            .GetByTestId("rename-switch-button");
+    }
+
+    public ILocator CloneButton(string name)
+    {
+        return SwitchItem(name)
+            .GetByTestId("clone-switch-button");
+    }
+
+    public ILocator DeleteButton(string name)
+    {
+        return SwitchItem(name)
+            .GetByTestId("delete-switch-button");
+    }
+
+    // -------------------------------------------------
+    // Navigation
+    // -------------------------------------------------
+
+    public async Task GotoAsync(string baseUrl)
+    {
+        await page.GotoAsync($"{baseUrl}/switches/list");
+        await AssertLoadedAsync();
+    }
+
+    public async Task AssertLoadedAsync()
+    {
+        await Assertions.Expect(PageRoot).ToBeVisibleAsync();
+        await Assertions.Expect(PageTitle).ToBeVisibleAsync();
+    }
+
+    public async Task WaitForListAsync()
+    {
+        await Assertions.Expect(SwitchsList).ToBeVisibleAsync();
+    }
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public async Task AddSwitchAsync(string name)
+    {
+        await AddSwitch.AddAsync(name);
+        await Assertions.Expect(SwitchItem(name))
+            .ToBeVisibleAsync();
+    }
+
+    public async Task DeleteSwitchAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+        await page.GetByTestId("Switch-confirm-modal-confirm").ClickAsync();
+
+        await Assertions.Expect(SwitchItem(name))
+            .Not.ToBeVisibleAsync();
+    }
+
+    public async Task OpenSwitchAsync(string name)
+    {
+        await OpenLink(name).ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{name}");
+    }
+
+    public async Task AssertSwitchExists(string name)
+    {
+        await Assertions.Expect(SwitchItem(name))
+            .ToBeVisibleAsync();
+    }
+
+    public async Task AssertSwitchDoesNotExist(string name)
+    {
+        await Assertions.Expect(SwitchItem(name))
+            .Not.ToBeVisibleAsync();
+    }
+
+    private static string Sanitize(string value)
+    {
+        return value.Replace(" ", "-");
+    }
+}

+ 136 - 0
Tests.E2e/PageObjectModels/UpsListPom.cs

@@ -0,0 +1,136 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class UpsListPom(IPage page)
+{
+    public AddResourceComponent AddUps => new(page, "ups");
+
+    public ILocator PageRoot => page.GetByTestId("ups-page-root");
+    public ILocator PageTitle => page.GetByTestId("ups-page-title");
+
+    public ILocator Loading => page.GetByTestId("ups-loading");
+    public ILocator EmptyState => page.GetByTestId("ups-empty");
+    public ILocator UpsList => page.GetByTestId("ups-list");
+
+    public ILocator AddSection => page.GetByTestId("ups-add-section");
+
+    // Must match AddResourceComponent test IDs
+    public ILocator AddInput => page.GetByTestId("add-ups-input");
+    public ILocator AddButton => page.GetByTestId("add-ups-button");
+
+    // -------------------------------------------------
+    // Dynamic Ups Items
+    // -------------------------------------------------
+
+    public ILocator UpsItem(string name)
+    {
+        return page.GetByTestId($"ups-item-{Sanitize(name)}");
+    }
+
+    public ILocator OpenLink(string name)
+    {
+        return UpsItem(name)
+            .GetByTestId("open-ups-link");
+    }
+
+    public ILocator EditButton(string name)
+    {
+        return UpsItem(name)
+            .GetByTestId("edit-ups-button");
+    }
+
+    public ILocator SaveButton(string name)
+    {
+        return UpsItem(name)
+            .GetByTestId("save-ups-button");
+    }
+
+    public ILocator CancelButton(string name)
+    {
+        return UpsItem(name)
+            .GetByTestId("cancel-ups-button");
+    }
+
+    public ILocator RenameButton(string name)
+    {
+        return UpsItem(name)
+            .GetByTestId("rename-ups-button");
+    }
+
+    public ILocator CloneButton(string name)
+    {
+        return UpsItem(name)
+            .GetByTestId("clone-ups-button");
+    }
+
+    public ILocator DeleteButton(string name)
+    {
+        return UpsItem(name)
+            .GetByTestId("delete-ups-button");
+    }
+
+    // -------------------------------------------------
+    // Navigation
+    // -------------------------------------------------
+
+    public async Task GotoAsync(string baseUrl)
+    {
+        await page.GotoAsync($"{baseUrl}/ups/list");
+        await AssertLoadedAsync();
+    }
+
+    public async Task AssertLoadedAsync()
+    {
+        await Assertions.Expect(PageRoot).ToBeVisibleAsync();
+        await Assertions.Expect(PageTitle).ToBeVisibleAsync();
+    }
+
+    public async Task WaitForListAsync()
+    {
+        await Assertions.Expect(UpsList).ToBeVisibleAsync();
+    }
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public async Task AddUpsAsync(string name)
+    {
+        await AddUps.AddAsync(name);
+        await Assertions.Expect(UpsItem(name))
+            .ToBeVisibleAsync();
+    }
+
+    public async Task DeleteUpsAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+        await page.GetByTestId("Ups-confirm-modal-confirm").ClickAsync();
+
+        await Assertions.Expect(UpsItem(name))
+            .Not.ToBeVisibleAsync();
+    }
+
+    public async Task OpenUpsAsync(string name)
+    {
+        await OpenLink(name).ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{name}");
+    }
+
+    public async Task AssertUpsExists(string name)
+    {
+        await Assertions.Expect(UpsItem(name))
+            .ToBeVisibleAsync();
+    }
+
+    public async Task AssertUpsDoesNotExist(string name)
+    {
+        await Assertions.Expect(UpsItem(name))
+            .Not.ToBeVisibleAsync();
+    }
+
+    private static string Sanitize(string value)
+    {
+        return value.Replace(" ", "-");
+    }
+}

+ 61 - 0
Tests.E2e/RouterTests.cs

@@ -0,0 +1,61 @@
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class RouterTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    [Fact]
+    public async Task User_Can_Add_And_Delete_Router()
+    {
+        var (context, page) = await CreatePageAsync();
+        var resourceName = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            // Go home
+            await page.GotoAsync(fixture.BaseUrl);
+
+            _output.WriteLine($"URL after Goto: {page.Url}");
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwarePage = new HardwareTreePom(page);
+            await hardwarePage.AssertLoadedAsync();
+            await hardwarePage.GotoRoutersListAsync();
+
+            var listPage = new RouterListPom(page);
+            await listPage.AssertLoadedAsync();
+            await listPage.AddRouterAsync(resourceName);
+            await listPage.AssertRouterExists(resourceName);
+            await listPage.DeleteRouterAsync(resourceName);
+            await listPage.AssertRouterDoesNotExist(resourceName);
+
+            await context.CloseAsync();
+        }
+        catch (Exception ex)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+}

+ 61 - 0
Tests.E2e/SwitchTests.cs

@@ -0,0 +1,61 @@
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class SwitchTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    [Fact]
+    public async Task User_Can_Add_And_Delete_Switch()
+    {
+        var (context, page) = await CreatePageAsync();
+        var resourceName = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            // Go home
+            await page.GotoAsync(fixture.BaseUrl);
+
+            _output.WriteLine($"URL after Goto: {page.Url}");
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwarePage = new HardwareTreePom(page);
+            await hardwarePage.AssertLoadedAsync();
+            await hardwarePage.GotoSwitchesListAsync();
+
+            var listPage = new SwitchListPom(page);
+            await listPage.AssertLoadedAsync();
+            await listPage.AddSwitchAsync(resourceName);
+            await listPage.AssertSwitchExists(resourceName);
+            await listPage.DeleteSwitchAsync(resourceName);
+            await listPage.AssertSwitchDoesNotExist(resourceName);
+
+            await context.CloseAsync();
+        }
+        catch (Exception ex)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+}

+ 61 - 0
Tests.E2e/UpsTests.cs

@@ -0,0 +1,61 @@
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class UpsTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    [Fact]
+    public async Task User_Can_Add_And_Delete_Ups()
+    {
+        var (context, page) = await CreatePageAsync();
+        var resourceName = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            // Go home
+            await page.GotoAsync(fixture.BaseUrl);
+
+            _output.WriteLine($"URL after Goto: {page.Url}");
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwarePage = new HardwareTreePom(page);
+            await hardwarePage.AssertLoadedAsync();
+            await hardwarePage.GotoUpsListAsync();
+
+            var listPage = new UpsListPom(page);
+            await listPage.AssertLoadedAsync();
+            await listPage.AddUpsAsync(resourceName);
+            await listPage.AssertUpsExists(resourceName);
+            await listPage.DeleteUpsAsync(resourceName);
+            await listPage.AssertUpsDoesNotExist(resourceName);
+
+            await context.CloseAsync();
+        }
+        catch (Exception ex)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+}