4
0
Эх сурвалжийг харах

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

V1.0.0 prep
Tim Jones 1 сар өмнө
parent
commit
2c14c591ee
40 өөрчлөгдсөн 4116 нэмэгдсэн , 87 устгасан
  1. 1 1
      Shared.Rcl/AccessPoints/AccessPointCardComponent.razor
  2. 1 1
      Shared.Rcl/Desktops/DesktopCardComponent.razor
  3. 1 1
      Shared.Rcl/Firewalls/FirewallCardComponent.razor
  4. 8 5
      Shared.Rcl/Hardware/HardwareDetailsPage.razor
  5. 1 1
      Shared.Rcl/Laptops/LaptopCardComponent.razor
  6. 1 1
      Shared.Rcl/Routers/RouterCardComponent.razor
  7. 27 12
      Shared.Rcl/Servers/ServerCardComponent.razor
  8. 46 37
      Shared.Rcl/Services/ServiceCardComponent.razor
  9. 4 2
      Shared.Rcl/Services/ServiceDetailsPage.razor
  10. 1 1
      Shared.Rcl/Switches/SwitchCardComponent.razor
  11. 11 5
      Shared.Rcl/Systems/SystemCardComponent.razor
  12. 2 1
      Shared.Rcl/Systems/SystemsDetailsPage.razor
  13. 1 1
      Shared.Rcl/Ups/UpsCardComponent.razor
  14. 249 0
      Tests.E2e/AccessPointCardTests.cs
  15. 284 0
      Tests.E2e/DesktopCardTests.cs
  16. 231 0
      Tests.E2e/FirewallCardTests.cs
  17. 284 0
      Tests.E2e/LaptopCardTests.cs
  18. 167 0
      Tests.E2e/PageObjectModels/AccessPointCardPom.cs
  19. 6 2
      Tests.E2e/PageObjectModels/AccessPointListPom.cs
  20. 189 0
      Tests.E2e/PageObjectModels/DesktopCardPom.cs
  21. 217 0
      Tests.E2e/PageObjectModels/FirewallCardPom.cs
  22. 2 5
      Tests.E2e/PageObjectModels/FirewallListPom.cs
  23. 189 0
      Tests.E2e/PageObjectModels/LaptopCardPom.cs
  24. 217 0
      Tests.E2e/PageObjectModels/RouterCardPom.cs
  25. 1 4
      Tests.E2e/PageObjectModels/RouterListPom.cs
  26. 165 0
      Tests.E2e/PageObjectModels/ServerCardPom.cs
  27. 1 1
      Tests.E2e/PageObjectModels/ServerListPom.cs
  28. 133 0
      Tests.E2e/PageObjectModels/ServiceCardPom.cs
  29. 1 1
      Tests.E2e/PageObjectModels/ServicesListPom.cs
  30. 217 0
      Tests.E2e/PageObjectModels/SwitchCardPom.cs
  31. 1 4
      Tests.E2e/PageObjectModels/SwitchListPom.cs
  32. 180 0
      Tests.E2e/PageObjectModels/SystemCardPom.cs
  33. 1 1
      Tests.E2e/PageObjectModels/SystemsListPom.cs
  34. 146 0
      Tests.E2e/PageObjectModels/UpsCardPom.cs
  35. 231 0
      Tests.E2e/RouterCardTests.cs
  36. 104 0
      Tests.E2e/ServerCardTests.cs
  37. 181 0
      Tests.E2e/ServiceCardTests.cs
  38. 231 0
      Tests.E2e/SwitchCardTests.cs
  39. 207 0
      Tests.E2e/SystemCardTests.cs
  40. 176 0
      Tests.E2e/UpsCardTests.cs

+ 1 - 1
Shared.Rcl/AccessPoints/AccessPointCardComponent.razor

@@ -13,7 +13,7 @@
         <div class="text-zinc-100 hover:text-emerald-300">
             <NavLink href="@($"resources/hardware/{AccessPoint.Name}")"
                      class="block"
-                     data-testid="open-accesspoint-link">
+                     data-testid="@($"open-accesspoint-{AccessPoint.Name.Replace(" ", "-")}-link")">
                 @AccessPoint.Name
             </NavLink>
         </div>

+ 1 - 1
Shared.Rcl/Desktops/DesktopCardComponent.razor

@@ -31,7 +31,7 @@
         <div class="text-zinc-100 hover:text-emerald-300">
             <NavLink href="@($"resources/hardware/{Desktop.Name}")"
                      class="block"
-                     data-testid="open-desktop-link">
+                     data-testid=@($"desktop-item-{Desktop.Name.Replace(" ", "-")}-link")>
                 @Desktop.Name
             </NavLink>
         </div>

+ 1 - 1
Shared.Rcl/Firewalls/FirewallCardComponent.razor

@@ -19,7 +19,7 @@
         <div class="text-zinc-100 hover:text-emerald-300">
             <NavLink href="@($"resources/hardware/{Firewall.Name}")"
                      class="block"
-                     data-testid="open-firewall-link">
+                     data-testid=@($"firewall-item-{Firewall.Name.Replace(" ", "-")}-link")>
                 @Firewall.Name
             </NavLink>
         </div>

+ 8 - 5
Shared.Rcl/Hardware/HardwareDetailsPage.razor

@@ -108,21 +108,24 @@
 
     private Hardware? _hardware;
     private bool _loading = true;
-    HardwareDependencyTree? _tree;
+    private HardwareDependencyTree? _tree;
 
-    protected override async Task OnInitializedAsync()
+    protected override async Task OnParametersSetAsync()
     {
-        _hardware = await Repo.GetByNameAsync(HardwareName) as Hardware;
+        _loading = true;
+        _hardware = null;
         _tree = null;
+
+        _hardware = await Repo.GetByNameAsync(HardwareName) as Hardware;
+
         if (!string.IsNullOrEmpty(_hardware?.Name))
         {
-            _tree = await GetHardwareSystemTreeUseCase.ExecuteAsync(_hardware?.Name!);
+            _tree = await GetHardwareSystemTreeUseCase.ExecuteAsync(_hardware.Name);
         }
 
         _loading = false;
     }
 
-
     private async Task DeleteCallback(string obj)
     {
         Nav.NavigateTo("/hardware/tree");

+ 1 - 1
Shared.Rcl/Laptops/LaptopCardComponent.razor

@@ -28,7 +28,7 @@
         <div class="text-zinc-100 hover:text-emerald-300">
             <NavLink href="@($"resources/hardware/{Laptop.Name}")"
                      class="block"
-                     data-testid="open-laptop-link">
+                     data-testid=@($"laptop-item-{Laptop.Name.Replace(" ", "-")}-link")>
                 @Laptop.Name
             </NavLink>
         </div>

+ 1 - 1
Shared.Rcl/Routers/RouterCardComponent.razor

@@ -21,7 +21,7 @@
         <div class="text-zinc-100 hover:text-emerald-300">
             <NavLink href="@($"resources/hardware/{Router.Name}")"
                      class="block"
-                     data-testid="open-router-link">
+                     data-testid=@($"router-item-{Router.Name.Replace(" ", "-")}-link")>
                 @Router.Name
             </NavLink>
         </div>

+ 27 - 12
Shared.Rcl/Servers/ServerCardComponent.razor

@@ -32,8 +32,8 @@
      data-testid=@($"server-item-{Server.Name.Replace(" ", "-")}")>
     <div class="flex justify-between items-center mb-3">
         <div class="text-zinc-100 hover:text-emerald-300">
-            <NavLink href="@($"resources/hardware/{Server.Name}")" class="block">
-
+            <NavLink href="@($"resources/hardware/{Server.Name}")" class="block"
+                     data-testid=@($"server-item-{Server.Name.Replace(" ", "-")}-link")>
                 @Server.Name
             </NavLink>
 
@@ -241,7 +241,8 @@
             <MarkdownViewer
                 Value="@Server.Notes"
                 ShowEditButton="true"
-                OnEdit="BeginNotesEdit"/>
+                OnEdit="BeginNotesEdit"
+                TestIdPrefix="server-markdown"/>
         }
         else
         {
@@ -249,7 +250,9 @@
                 @bind-Value="_notesDraft"
                 ShowActionButtons="true"
                 OnSave="SaveNotes"
-                OnCancel="CancelNotesEdit"/>
+                OnCancel="CancelNotesEdit"
+                TestIdPrefix="server-markdown"/>
+
         }
     </div>
 
@@ -260,34 +263,42 @@
     IsOpenChanged="v => _cpuModalOpen = v"
     Value="@_editingCpu"
     OnSubmit="HandleCpuSubmit"
-    OnDelete="HandleCpuDelete"/>
+    OnDelete="HandleCpuDelete"
+    TestIdPrefix="server-cpu"/>
+
 
 <RamModal
     IsOpen="@_isRamModalOpen"
     IsOpenChanged="v => _isRamModalOpen = v"
     Value="@Server.Ram"
-    OnSubmit="HandleRamSubmit"/>
+    OnSubmit="HandleRamSubmit"
+    TestIdPrefix="server-ram"/>
+
 
 <DriveModal
     IsOpen="@_driveModalOpen"
     IsOpenChanged="v => _driveModalOpen = v"
     Value="@_editingDrive"
     OnSubmit="HandleDriveSubmit"
-    OnDelete="HandleDriveDelete"/>
+    OnDelete="HandleDriveDelete"
+    TestIdPrefix="server-drive"/>
+
 
 <NicModal
     IsOpen="@_nicModalOpen"
     IsOpenChanged="v => _nicModalOpen = v"
     Value="@_editingNic"
     OnSubmit="HandleNicSubmit"
-    OnDelete="HandleNicDelete"/>
+    OnDelete="HandleNicDelete"
+    TestIdPrefix="server-nic"/>
 
 <GpuModal
     IsOpen="@_gpuModalOpen"
     IsOpenChanged="v => _gpuModalOpen = v"
     Value="@_editingGpu"
     OnSubmit="HandleGpuSubmit"
-    OnDelete="HandleGpuDelete"/>
+    OnDelete="HandleGpuDelete"
+    TestIdPrefix="server-gpu"/>
 
 <ConfirmModal
     IsOpen="_confirmDeleteOpen"
@@ -296,7 +307,7 @@
     ConfirmText="Delete"
     ConfirmClass="bg-red-600 hover:bg-red-500"
     OnConfirm="DeleteServer"
-    TestIdPrefix="Server">
+    TestIdPrefix="server-delete">
     Are you sure you want to delete <strong>@Server.Name</strong>?
     <br/>
     This will detach all dependent systems.
@@ -309,7 +320,9 @@
     Description="Enter a new name for this server"
     Label="New server name"
     Value="@Server.Name"
-    OnSubmit="HandleRenameSubmit"/>
+    OnSubmit="HandleRenameSubmit"
+    TestIdPrefix="server-rename" />
+
 <StringValueModal
     IsOpen="_cloneOpen"
     IsOpenChanged="v => _cloneOpen = v"
@@ -317,7 +330,9 @@
     Description="Enter a name for the cloned resource"
     Label="New resource name"
     Value="@($"{Server.Name}-copy")"
-    OnSubmit="HandleCloneSubmit"/>
+    OnSubmit="HandleCloneSubmit"
+    TestIdPrefix="server-clone" />
+
 
 @code {
     [Parameter] [EditorRequired] public Server Server { get; set; } = default!;

+ 46 - 37
Shared.Rcl/Services/ServiceCardComponent.razor

@@ -8,7 +8,8 @@
 <div class="border border-zinc-800 rounded p-4 bg-zinc-900"
      data-testid=@($"service-item-{Service.Name.Replace(" ", "-")}")>
     <div class="flex justify-between items-center mb-3">
-        <NavLink href="@($"resources/services/{Service.Name}")" class="block">
+        <NavLink href="@($"resources/services/{Service.Name}")" class="block"
+                 data-testid=@($"service-item-{Service.Name.Replace(" ", "-")}-link")>
 
             <div class="text-zinc-100 hover:text-emerald-300">
                 @Service.Name
@@ -69,82 +70,84 @@
     <div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
 
         <!-- IP -->
-        <div>
+        <div data-testid="service-ip-section">
             <div class="text-zinc-400 mb-1">IP</div>
+
             @if (_isEditing)
             {
                 <input
+                    data-testid="service-ip-input"
                     class="w-full px-3 py-2 rounded-md
-                           bg-zinc-800 text-zinc-100
-                           border border-zinc-600
-                           placeholder-zinc-500
-                           focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
-                           hover:border-zinc-400
-                           transition-colors duration-150
-                           cursor-text"
+                   bg-zinc-800 text-zinc-100
+                   border border-zinc-600"
                     @bind="_edit.Ip"/>
             }
             else if (!string.IsNullOrWhiteSpace(Service.Network?.Ip))
             {
-                <div class="text-zinc-300">@Service.Network!.Ip</div>
+                <div class="text-zinc-300"
+                     data-testid="service-ip-value">
+                    @Service.Network!.Ip
+                </div>
             }
         </div>
 
+
         <!-- Port -->
-        <div>
+        <div data-testid="service-port-section">
             <div class="text-zinc-400 mb-1">Port</div>
+
             @if (_isEditing)
             {
                 <input type="number"
+                       data-testid="service-port-input"
                        @bind="_edit.Port"/>
             }
             else if (Service.Network?.Port.HasValue == true)
             {
-                <div class="text-zinc-300">@Service.Network.Port</div>
+                <div class="text-zinc-300"
+                     data-testid="service-port-value">
+                    @Service.Network.Port
+                </div>
             }
         </div>
 
+
         <!-- Protocol -->
-        <div>
+        <div data-testid="service-protocol-section">
             <div class="text-zinc-400 mb-1">Protocol</div>
+
             @if (_isEditing)
             {
                 <input
-                    class="w-full px-3 py-2 rounded-md
-                           bg-zinc-800 text-zinc-100
-                           border border-zinc-600
-                           placeholder-zinc-500
-                           focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
-                           hover:border-zinc-400
-                           transition-colors duration-150
-                           cursor-text"
+                    data-testid="service-protocol-input"
+                    class="w-full px-3 py-2 rounded-md bg-zinc-800 text-zinc-100 border border-zinc-600"
                     @bind="_edit.Protocol"/>
             }
             else if (!string.IsNullOrWhiteSpace(Service.Network?.Protocol))
             {
-                <div class="text-zinc-300">@Service.Network!.Protocol</div>
+                <div class="text-zinc-300"
+                     data-testid="service-protocol-value">
+                    @Service.Network!.Protocol
+                </div>
             }
         </div>
 
+
         <!-- URL -->
-        <div>
+        <div data-testid="service-url-section">
             <div class="text-zinc-400 mb-1">URL</div>
+
             @if (_isEditing)
             {
                 <input
-                    class="w-full px-3 py-2 rounded-md
-                           bg-zinc-800 text-zinc-100
-                           border border-zinc-600
-                           placeholder-zinc-500
-                           focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
-                           hover:border-zinc-400
-                           transition-colors duration-150
-                           cursor-text"
+                    data-testid="service-url-input"
+                    class="w-full px-3 py-2 rounded-md bg-zinc-800 text-zinc-100 border border-zinc-600"
                     @bind="_edit.Url"/>
             }
             else if (!string.IsNullOrWhiteSpace(Service.Network?.Url))
             {
                 <a href="@Service.Network!.Url"
+                   data-testid="service-url-value"
                    target="_blank"
                    rel="noopener noreferrer"
                    class="text-emerald-400 hover:underline break-all">
@@ -153,14 +156,16 @@
             }
         </div>
 
+
         <!-- Runs On -->
-        <div>
+        <div data-testid="service-runson-section">
             <div class="text-zinc-400 mb-1">Runs On</div>
+
             @if (_isEditing)
             {
                 <button
+                    data-testid="service-runson-button"
                     class="hover:text-emerald-400"
-                    title="Edit Runs On"
                     @onclick="() => _selectParentOpen = true">
                     @if (!string.IsNullOrWhiteSpace(Service.RunsOn))
                     {
@@ -170,18 +175,19 @@
                     {
                         @("Edit parent")
                     }
-
                 </button>
             }
             else if (!string.IsNullOrWhiteSpace(Service.RunsOn))
             {
                 <NavLink href="@($"resources/systems/{Service.RunsOn}")"
+                         data-testid="service-runson-link"
                          class="text-emerald-400">
                     @Service.RunsOn
                 </NavLink>
             }
         </div>
 
+
         <ResourceTagEditor Resource="Service"/>
 
         <div class="md:col-span-2">
@@ -220,7 +226,7 @@
     Title="Delete service"
     ConfirmText="Delete"
     ConfirmClass="bg-red-600 hover:bg-red-500"
-    OnConfirm="DeleteServer" TestIdPrefix="Service">
+    OnConfirm="DeleteServer" TestIdPrefix="service-delete">
     Are you sure you want to delete <strong>@Service.Name</strong>?
 </ConfirmModal>
 
@@ -231,7 +237,8 @@
     Description="Enter a name for the cloned service"
     Label="New service name"
     Value="@($"{Service.Name}-copy")"
-    OnSubmit="HandleCloneSubmit"/>
+    OnSubmit="HandleCloneSubmit"
+    TestIdPrefix="service-clone"/>
 
 <StringValueModal
     IsOpen="_renameOpen"
@@ -240,7 +247,9 @@
     Description="Enter a new name for this service"
     Label="New service name"
     Value="@Service.Name"
-    OnSubmit="HandleRenameSubmit"/>
+    OnSubmit="HandleRenameSubmit"
+    TestIdPrefix="service-rename"/>
+
 
 
 @code

+ 4 - 2
Shared.Rcl/Services/ServiceDetailsPage.razor

@@ -33,12 +33,14 @@
 
     private Service? _service;
     private bool _loading = true;
-
-    protected override async Task OnInitializedAsync()
+    
+    protected override async Task OnParametersSetAsync()
     {
+        _loading = true;
         _service = await Repo.GetByNameAsync<Service>(ServiceName);
         _loading = false;
     }
+    
 
     private void OnDeleted(string obj)
     {

+ 1 - 1
Shared.Rcl/Switches/SwitchCardComponent.razor

@@ -19,7 +19,7 @@
         <div class="text-zinc-100 hover:text-emerald-300">
             <NavLink href="@($"resources/hardware/{Switch.Name}")"
                      class="block"
-                     data-testid="open-switch-link">
+                     data-testid=@($"switch-item-{Switch.Name.Replace(" ", "-")}-link")>
                 @Switch.Name
             </NavLink>
         </div>

+ 11 - 5
Shared.Rcl/Systems/SystemCardComponent.razor

@@ -15,7 +15,8 @@
 <div class="border border-zinc-800 rounded p-4 bg-zinc-900"
      data-testid=@($"system-item-{System.Name.Replace(" ", "-")}")>
     <div class="flex justify-between items-center mb-3">
-        <NavLink href="@($"resources/systems/{System.Name}")" class="block">
+        <NavLink href="@($"resources/systems/{System.Name}")" class="block"
+                 data-testid=@($"system-item-{System.Name.Replace(" ", "-")}-link")>
 
             <div class="text-zinc-100 hover:text-emerald-300">
                 @System.Name
@@ -263,7 +264,9 @@
     IsOpenChanged="v => _driveModalOpen = v"
     Value="@_editingDrive"
     OnSubmit="HandleDriveSubmit"
-    OnDelete="HandleDriveDelete"/>
+    OnDelete="HandleDriveDelete"
+    TestIdPrefix="system"/>
+
 
 <ConfirmModal
     IsOpen="_confirmDeleteOpen"
@@ -272,7 +275,7 @@
     ConfirmText="Delete"
     ConfirmClass="bg-red-600 hover:bg-red-500"
     OnConfirm="DeleteServer"
-    TestIdPrefix="System">>
+    TestIdPrefix="system-delete">
     Are you sure you want to delete <strong>@System.Name</strong>?
     <br/>
     This will detach all dependent systems.
@@ -285,7 +288,9 @@
     Description="Enter a name for the cloned system"
     Label="New system name"
     Value="@($"{System.Name}-copy")"
-    OnSubmit="HandleCloneSubmit"/>
+    OnSubmit="HandleCloneSubmit"
+    TestIdPrefix="system-clone"/>
+
 
 @code {
     [Parameter] [EditorRequired] public SystemResource System { get; set; } = default!;
@@ -389,7 +394,8 @@
     Description="Enter a new name for this system"
     Label="New system name"
     Value="@System.Name"
-    OnSubmit="HandleRenameSubmit"/>
+    OnSubmit="HandleRenameSubmit"
+    TestIdPrefix="system-rename"/>
 
 @code {
     private bool _confirmDeleteOpen;

+ 2 - 1
Shared.Rcl/Systems/SystemsDetailsPage.razor

@@ -58,8 +58,9 @@
     private bool _loading = true;
     SystemDependencyTree? _tree;
 
-    protected override async Task OnInitializedAsync()
+    protected override async Task OnParametersSetAsync()
     {
+        _loading = true;
         _system = await Repo.GetByNameAsync<SystemResource>(SystemName);
         _tree = null;
         if (!string.IsNullOrEmpty(_system?.Name))

+ 1 - 1
Shared.Rcl/Ups/UpsCardComponent.razor

@@ -14,7 +14,7 @@
         <div class="text-zinc-100 hover:text-emerald-300">
             <NavLink href="@($"resources/hardware/{Ups.Name}")"
                      class="block"
-                     data-testid="open-ups-link">
+                     data-testid=@($"ups-item-{Ups.Name.Replace(" ", "-")}-link")>
                 @Ups.Name
             </NavLink>
         </div>

+ 249 - 0
Tests.E2e/AccessPointCardTests.cs

@@ -0,0 +1,249 @@
+using System.Globalization;
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class AccessPointCardTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    [Fact]
+    public async Task User_Can_Edit_Model_And_Speed_And_Save()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoAccessPointsListAsync();
+
+            var list = new AccessPointsListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddAccessPointAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new AccessPointCardPom(page);
+            await card.AssertCardVisibleAsync(name);
+
+            var newModel = "AP-Model-9000";
+            var newSpeed = 2.5;
+
+            await card.BeginEditAsync(name);
+            await card.SetModelAsync(name, newModel);
+            await card.SetSpeedAsync(name, newSpeed);
+            await card.SaveAsync(name);
+
+            await page.ReloadAsync();
+            
+            await Assertions.Expect(card.ModelValue(name)).ToHaveTextAsync(newModel);
+            await Assertions.Expect(card.SpeedValue(name))
+                .ToHaveTextAsync($"{newSpeed.ToString(CultureInfo.InvariantCulture)} Gbps");
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Cancel_Edit_And_Changes_Are_Not_Applied()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoAccessPointsListAsync();
+
+            var list = new AccessPointsListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddAccessPointAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new AccessPointCardPom(page);
+            await card.AssertCardVisibleAsync(name);
+
+            // Capture current values (may be empty depending on seed data)
+            var beforeModel = await card.ModelSection(name).TextContentAsync();
+            var beforeSpeed = await card.SpeedSection(name).TextContentAsync();
+
+            await card.BeginEditAsync(name);
+            await card.SetModelAsync(name, "SHOULD-NOT-SAVE");
+            await card.SetSpeedAsync(name, 9.9);
+            await card.CancelEditAsync(name);
+
+            var afterModel = await card.ModelSection(name).TextContentAsync();
+            var afterSpeed = await card.SpeedSection(name).TextContentAsync();
+
+            Assert.Equal(beforeModel, afterModel);
+            Assert.Equal(beforeSpeed, afterSpeed);
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Rename_AccessPoint_From_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+        var newName = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoAccessPointsListAsync();
+
+            var list = new AccessPointsListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddAccessPointAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new AccessPointCardPom(page);
+            await card.AssertCardVisibleAsync(name);
+
+            await card.RenameAsync(name, newName);
+
+            // After rename, the card test id uses the new name
+            await card.AssertCardVisibleAsync(newName);
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Clone_AccessPoint_From_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+        var cloneName = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoAccessPointsListAsync();
+
+            var list = new AccessPointsListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddAccessPointAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new AccessPointCardPom(page);
+            await card.AssertCardVisibleAsync(name);
+
+            await card.CloneAsync(name, cloneName);
+
+            // Clone navigates to the clone details page
+            await card.AssertCardVisibleAsync(cloneName);
+
+            // Cleanup: delete clone then original (both from details pages)
+            await card.DeleteAsync(cloneName);
+            await page.WaitForURLAsync("**/hardware/tree");
+
+            await hardwareTree.GotoAccessPointsListAsync();
+            await list.AssertLoadedAsync();
+            await list.OpenAccessPointAsync(name);
+
+            await card.AssertCardVisibleAsync(name);
+            await card.DeleteAsync(name);
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Delete_AccessPoint_From_Card_And_Is_Redirected()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoAccessPointsListAsync();
+
+            var list = new AccessPointsListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddAccessPointAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new AccessPointCardPom(page);
+            await card.AssertCardVisibleAsync(name);
+
+            await card.DeleteAsync(name);
+            await page.WaitForURLAsync("**/hardware/tree");
+
+            // Verify it’s gone from the list
+            await hardwareTree.GotoAccessPointsListAsync();
+            await list.AssertLoadedAsync();
+            await list.AssertAccessPointDoesNotExist(name);
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+}

+ 284 - 0
Tests.E2e/DesktopCardTests.cs

@@ -0,0 +1,284 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class DesktopCardTests(
+    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 desktopName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            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(desktopName);
+
+            // creation should navigate to details page
+            await page.WaitForURLAsync($"**/resources/hardware/{desktopName}");
+
+            // delete from details page (card)
+            var card = new DesktopCardPom(page);
+            await Assertions.Expect(card.DesktopItem(desktopName)).ToBeVisibleAsync();
+            await card.DeleteDesktopAsync(desktopName);
+
+            // after deletion you redirect (your page does Nav.NavigateTo("/hardware/tree"))
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(await page.ContentAsync());
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Rename_Desktop_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var originalName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+        var renamedName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            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(originalName);
+            await page.WaitForURLAsync($"**/resources/hardware/{originalName}");
+
+            var card = new DesktopCardPom(page);
+            await Assertions.Expect(card.DesktopItem(originalName)).ToBeVisibleAsync();
+
+            await card.RenameDesktopAsync(originalName, renamedName);
+            await Assertions.Expect(card.DesktopItem(renamedName)).ToBeVisibleAsync();
+
+            // cleanup
+            await card.DeleteDesktopAsync(renamedName);
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(await page.ContentAsync());
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Clone_Desktop_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var originalName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+        var cloneName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            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(originalName);
+            await page.WaitForURLAsync($"**/resources/hardware/{originalName}");
+
+            var card = new DesktopCardPom(page);
+            await Assertions.Expect(card.DesktopItem(originalName)).ToBeVisibleAsync();
+
+            await card.CloneDesktopAsync(originalName, cloneName);
+            await Assertions.Expect(card.DesktopItem(cloneName)).ToBeVisibleAsync();
+
+            // cleanup: delete clone then original
+            await card.DeleteDesktopAsync(cloneName);
+            await page.WaitForURLAsync("**/hardware/tree");
+
+            // go back to original and delete it too
+            await page.GotoAsync($"{fixture.BaseUrl}/resources/hardware/{originalName}");
+            await Assertions.Expect(card.DesktopItem(originalName)).ToBeVisibleAsync();
+            await card.DeleteDesktopAsync(originalName);
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(await page.ContentAsync());
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Edit_Desktop_Notes_And_Save()
+    {
+        var (context, page) = await CreatePageAsync();
+        var desktopName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+        var notes = $"notes-{Guid.NewGuid():N}";
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            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(desktopName);
+            await page.WaitForURLAsync($"**/resources/hardware/{desktopName}");
+
+            var card = new DesktopCardPom(page);
+            await Assertions.Expect(card.DesktopItem(desktopName)).ToBeVisibleAsync();
+
+            // start editing notes via MarkdownViewer edit button
+            await card.NotesViewerEditButton(desktopName).ClickAsync();
+
+            // ensure editor visible then fill + save
+            await Assertions.Expect(card.NotesEditorRoot(desktopName)).ToBeVisibleAsync();
+            await card.NotesEditorTextarea(desktopName).FillAsync(notes);
+            await card.NotesEditorSave(desktopName).ClickAsync();
+
+            // viewer back, and content should contain the notes
+            await Assertions.Expect(card.NotesViewerRoot(desktopName)).ToBeVisibleAsync();
+            await Assertions.Expect(card.NotesViewerRoot(desktopName)).ToContainTextAsync(notes);
+
+            // cleanup
+            await card.DeleteDesktopAsync(desktopName);
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(await page.ContentAsync());
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Edit_Desktop_Notes_And_Cancel()
+    {
+        var (context, page) = await CreatePageAsync();
+        var desktopName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+        var notes = $"notes-{Guid.NewGuid():N}";
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            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(desktopName);
+            await page.WaitForURLAsync($"**/resources/hardware/{desktopName}");
+
+            var card = new DesktopCardPom(page);
+            await Assertions.Expect(card.DesktopItem(desktopName)).ToBeVisibleAsync();
+
+            await card.NotesViewerEditButton(desktopName).ClickAsync();
+            await Assertions.Expect(card.NotesEditorRoot(desktopName)).ToBeVisibleAsync();
+
+            await card.NotesEditorTextarea(desktopName).FillAsync(notes);
+            await card.NotesEditorCancel(desktopName).ClickAsync();
+
+            // viewer should be back, and should NOT show the cancelled notes
+            await Assertions.Expect(card.NotesViewerRoot(desktopName)).ToBeVisibleAsync();
+            await Assertions.Expect(card.NotesViewerRoot(desktopName)).Not.ToContainTextAsync(notes);
+
+            // cleanup
+            await card.DeleteDesktopAsync(desktopName);
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(await page.ContentAsync());
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+}

+ 231 - 0
Tests.E2e/FirewallCardTests.cs

@@ -0,0 +1,231 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class FirewallCardTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    [Fact]
+    public async Task User_Can_Edit_Firewall_Model_And_Features()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-fw-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoFirewallsListAsync();
+
+            var list = new FirewallsListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddFirewallAsync(name);
+            await list.AssertFirewallExists(name);
+
+            // Go to details page so we can exercise the card component
+            await list.OpenFirewallAsync(name);
+
+            var card = new FirewallCardPom(page);
+
+            await Assertions.Expect(card.FirewallItem(name)).ToBeVisibleAsync();
+
+            // Enter edit mode
+            await card.EnterEditModeAsync(name);
+
+            // Update Model + toggle features
+            var newModel = "e2e-model-123";
+            await card.ModelInput(name).FillAsync(newModel);
+
+            // Ensure both toggles are ON (idempotent)
+            if (!await card.ManagedCheckbox(name).IsCheckedAsync())
+                await card.ManagedCheckbox(name).CheckAsync();
+            if (!await card.PoeCheckbox(name).IsCheckedAsync())
+                await card.PoeCheckbox(name).CheckAsync();
+
+            await card.SaveEditsAsync(name);
+
+            // Validate view mode reflects changes
+            await Assertions.Expect(card.ModelValue(name)).ToHaveTextAsync(newModel);
+            await Assertions.Expect(card.ManagedBadge(name)).ToBeVisibleAsync();
+            await Assertions.Expect(card.PoeBadge(name)).ToBeVisibleAsync();
+        }
+        catch (Exception)
+        {
+            _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();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Rename_Firewall_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-fw-{Guid.NewGuid():N}"[..16];
+        var renamed = $"{name}-ren";
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoFirewallsListAsync();
+
+            var list = new FirewallsListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddFirewallAsync(name);
+            await list.OpenFirewallAsync(name);
+
+            var card = new FirewallCardPom(page);
+
+            await card.RenameFirewallAsync(name, renamed);
+
+            // after rename we should be on new URL and see the renamed card root
+            await Assertions.Expect(card.FirewallItem(renamed)).ToBeVisibleAsync();
+        }
+        catch (Exception)
+        {
+            _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();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Clone_Firewall_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-fw-{Guid.NewGuid():N}"[..16];
+        var clone = $"{name}-cpy";
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoFirewallsListAsync();
+
+            var list = new FirewallsListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddFirewallAsync(name);
+            await list.OpenFirewallAsync(name);
+
+            var card = new FirewallCardPom(page);
+
+            await card.CloneFirewallAsync(name, clone);
+
+            // should be on the clone's details URL and see the clone card
+            await Assertions.Expect(card.FirewallItem(clone)).ToBeVisibleAsync();
+        }
+        catch (Exception)
+        {
+            _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();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Delete_Firewall_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-fw-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoFirewallsListAsync();
+
+            var list = new FirewallsListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddFirewallAsync(name);
+            await list.OpenFirewallAsync(name);
+
+            var card = new FirewallCardPom(page);
+
+            await card.DeleteFirewallAsync(name);
+
+            // After delete, your page navigates away (Nav.NavigateTo("/hardware/tree"))
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _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();
+        }
+    }
+}

+ 284 - 0
Tests.E2e/LaptopCardTests.cs

@@ -0,0 +1,284 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class LaptopCardTests(
+    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 laptopName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            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(laptopName);
+
+            // creation should navigate to details page
+            await page.WaitForURLAsync($"**/resources/hardware/{laptopName}");
+
+            // delete from details page (card)
+            var card = new LaptopCardPom(page);
+            await Assertions.Expect(card.LaptopItem(laptopName)).ToBeVisibleAsync();
+            await card.DeleteLaptopAsync(laptopName);
+
+            // after deletion you redirect (your page does Nav.NavigateTo("/hardware/tree"))
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(await page.ContentAsync());
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Rename_Laptop_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var originalName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+        var renamedName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            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(originalName);
+            await page.WaitForURLAsync($"**/resources/hardware/{originalName}");
+
+            var card = new LaptopCardPom(page);
+            await Assertions.Expect(card.LaptopItem(originalName)).ToBeVisibleAsync();
+
+            await card.RenameLaptopAsync(originalName, renamedName);
+            await Assertions.Expect(card.LaptopItem(renamedName)).ToBeVisibleAsync();
+
+            // cleanup
+            await card.DeleteLaptopAsync(renamedName);
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(await page.ContentAsync());
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Clone_Laptop_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var originalName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+        var cloneName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            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(originalName);
+            await page.WaitForURLAsync($"**/resources/hardware/{originalName}");
+
+            var card = new LaptopCardPom(page);
+            await Assertions.Expect(card.LaptopItem(originalName)).ToBeVisibleAsync();
+
+            await card.CloneLaptopAsync(originalName, cloneName);
+            await Assertions.Expect(card.LaptopItem(cloneName)).ToBeVisibleAsync();
+
+            // cleanup: delete clone then original
+            await card.DeleteLaptopAsync(cloneName);
+            await page.WaitForURLAsync("**/hardware/tree");
+
+            // go back to original and delete it too
+            await page.GotoAsync($"{fixture.BaseUrl}/resources/hardware/{originalName}");
+            await Assertions.Expect(card.LaptopItem(originalName)).ToBeVisibleAsync();
+            await card.DeleteLaptopAsync(originalName);
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(await page.ContentAsync());
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Edit_Laptop_Notes_And_Save()
+    {
+        var (context, page) = await CreatePageAsync();
+        var laptopName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+        var notes = $"notes-{Guid.NewGuid():N}";
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            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(laptopName);
+            await page.WaitForURLAsync($"**/resources/hardware/{laptopName}");
+
+            var card = new LaptopCardPom(page);
+            await Assertions.Expect(card.LaptopItem(laptopName)).ToBeVisibleAsync();
+
+            // start editing notes via MarkdownViewer edit button
+            await card.NotesViewerEditButton(laptopName).ClickAsync();
+
+            // ensure editor visible then fill + save
+            await Assertions.Expect(card.NotesEditorRoot(laptopName)).ToBeVisibleAsync();
+            await card.NotesEditorTextarea(laptopName).FillAsync(notes);
+            await card.NotesEditorSave(laptopName).ClickAsync();
+
+            // viewer back, and content should contain the notes
+            await Assertions.Expect(card.NotesViewerRoot(laptopName)).ToBeVisibleAsync();
+            await Assertions.Expect(card.NotesViewerRoot(laptopName)).ToContainTextAsync(notes);
+
+            // cleanup
+            await card.DeleteLaptopAsync(laptopName);
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(await page.ContentAsync());
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Edit_Laptop_Notes_And_Cancel()
+    {
+        var (context, page) = await CreatePageAsync();
+        var laptopName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+        var notes = $"notes-{Guid.NewGuid():N}";
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            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(laptopName);
+            await page.WaitForURLAsync($"**/resources/hardware/{laptopName}");
+
+            var card = new LaptopCardPom(page);
+            await Assertions.Expect(card.LaptopItem(laptopName)).ToBeVisibleAsync();
+
+            await card.NotesViewerEditButton(laptopName).ClickAsync();
+            await Assertions.Expect(card.NotesEditorRoot(laptopName)).ToBeVisibleAsync();
+
+            await card.NotesEditorTextarea(laptopName).FillAsync(notes);
+            await card.NotesEditorCancel(laptopName).ClickAsync();
+
+            // viewer should be back, and should NOT show the cancelled notes
+            await Assertions.Expect(card.NotesViewerRoot(laptopName)).ToBeVisibleAsync();
+            await Assertions.Expect(card.NotesViewerRoot(laptopName)).Not.ToContainTextAsync(notes);
+
+            // cleanup
+            await card.DeleteLaptopAsync(laptopName);
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(await page.ContentAsync());
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+}

+ 167 - 0
Tests.E2e/PageObjectModels/AccessPointCardPom.cs

@@ -0,0 +1,167 @@
+namespace Tests.E2e.PageObjectModels;
+
+using Microsoft.Playwright;
+
+public class AccessPointCardPom(IPage page)
+{
+    // Root
+    public ILocator Card(string accessPointName)
+        => page.GetByTestId($"accesspoint-item-{Sanitize(accessPointName)}");
+
+    // Link / navigation
+    public ILocator OpenLink(string accessPointName)
+        => Card(accessPointName).GetByTestId("open-accesspoint-link");
+
+    // Top-right actions (view mode)
+    public ILocator EditButton(string accessPointName)
+        => Card(accessPointName).GetByTestId("edit-accesspoint-button");
+
+    public ILocator RenameButton(string accessPointName)
+        => Card(accessPointName).GetByTestId("rename-accesspoint-button");
+
+    public ILocator CloneButton(string accessPointName)
+        => Card(accessPointName).GetByTestId("clone-accesspoint-button");
+
+    public ILocator DeleteButton(string accessPointName)
+        => Card(accessPointName).GetByTestId("delete-accesspoint-button");
+
+    // Top-right actions (edit mode)
+    public ILocator SaveButton(string accessPointName)
+        => Card(accessPointName).GetByTestId("save-accesspoint-button");
+
+    public ILocator CancelButton(string accessPointName)
+        => Card(accessPointName).GetByTestId("cancel-accesspoint-button");
+
+    // Fields
+    public ILocator ModelSection(string accessPointName)
+        => Card(accessPointName).GetByTestId("accesspoint-model-section");
+
+    public ILocator ModelInput(string accessPointName)
+        => Card(accessPointName).GetByTestId("accesspoint-model-input");
+
+    public ILocator ModelValue(string accessPointName)
+        => Card(accessPointName).GetByTestId("accesspoint-model-value");
+
+    public ILocator SpeedSection(string accessPointName)
+        => Card(accessPointName).GetByTestId("accesspoint-speed-section");
+
+    public ILocator SpeedInput(string accessPointName)
+        => Card(accessPointName).GetByTestId("accesspoint-speed-input");
+
+    public ILocator SpeedValue(string accessPointName)
+        => Card(accessPointName).GetByTestId("accesspoint-speed-value");
+
+    // Notes (prefixed components)
+    public ILocator NotesViewerRoot(string accessPointName)
+        => Card(accessPointName).GetByTestId("accesspoint-notes-viewer-markdown-viewer");
+
+    public ILocator NotesViewerContent(string accessPointName)
+        => Card(accessPointName).GetByTestId("accesspoint-notes-viewer-markdown-viewer-content");
+
+    public ILocator NotesViewerEmpty(string accessPointName)
+        => Card(accessPointName).GetByTestId("accesspoint-notes-viewer-markdown-viewer-empty");
+
+    public ILocator NotesEditorRoot(string accessPointName)
+        => Card(accessPointName).GetByTestId("accesspoint-notes-editor-markdown-editor");
+
+    public ILocator NotesEditorTextarea(string accessPointName)
+        => Card(accessPointName).GetByTestId("accesspoint-notes-editor-markdown-editor-textarea");
+
+    // Modals
+    public ILocator DeleteConfirmModal => page.GetByTestId("AccessPoint-confirm-modal");
+    public ILocator DeleteConfirmButton => page.GetByTestId("AccessPoint-confirm-modal-confirm");
+    public ILocator DeleteCancelButton => page.GetByTestId("AccessPoint-confirm-modal-cancel");
+
+    public ILocator RenameModal => page.GetByTestId("accesspoint-rename-string-value-modal");
+    public ILocator RenameModalInput => page.GetByTestId("accesspoint-rename-string-value-modal-input");
+    public ILocator RenameModalSubmit => page.GetByTestId("accesspoint-rename-string-value-modal-submit");
+    public ILocator RenameModalCancel => page.GetByTestId("accesspoint-rename-string-value-modal-cancel");
+
+    public ILocator CloneModal => page.GetByTestId("accesspoint-clone-string-value-modal");
+    public ILocator CloneModalInput => page.GetByTestId("accesspoint-clone-string-value-modal-input");
+    public ILocator CloneModalSubmit => page.GetByTestId("accesspoint-clone-string-value-modal-submit");
+    public ILocator CloneModalCancel => page.GetByTestId("accesspoint-clone-string-value-modal-cancel");
+
+    // -------------------------------------------------
+    // Navigation (hardware details page)
+    // -------------------------------------------------
+
+    public async Task GotoHardwareAsync(string baseUrl, string hardwareName)
+    {
+        await page.GotoAsync($"{baseUrl}/resources/hardware/{hardwareName}");
+        await AssertCardVisibleAsync(hardwareName);
+    }
+
+    public async Task AssertCardVisibleAsync(string accessPointName)
+    {
+        await Assertions.Expect(Card(accessPointName)).ToBeVisibleAsync();
+    }
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public async Task OpenAsync(string accessPointName)
+    {
+        await OpenLink(accessPointName).ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{accessPointName}");
+    }
+
+    public async Task BeginEditAsync(string accessPointName)
+    {
+        await EditButton(accessPointName).ClickAsync();
+        await Assertions.Expect(ModelInput(accessPointName)).ToBeVisibleAsync();
+    }
+
+    public async Task SetModelAsync(string accessPointName, string model)
+    {
+        await ModelInput(accessPointName).FillAsync(model);
+    }
+
+    public async Task SetSpeedAsync(string accessPointName, double speed)
+    {
+        await SpeedInput(accessPointName).FillAsync(speed.ToString(System.Globalization.CultureInfo.InvariantCulture));
+    }
+
+    public async Task SaveAsync(string accessPointName)
+    {
+        await SaveButton(accessPointName).ClickAsync();
+        await Assertions.Expect(ModelSection(accessPointName)).ToBeVisibleAsync();
+    }
+
+    public async Task CancelEditAsync(string accessPointName)
+    {
+        await CancelButton(accessPointName).ClickAsync();
+        await Assertions.Expect(EditButton(accessPointName)).ToBeVisibleAsync();
+    }
+
+    public async Task DeleteAsync(string accessPointName)
+    {
+        await DeleteButton(accessPointName).ClickAsync();
+        await DeleteConfirmButton.ClickAsync();
+
+        await Assertions.Expect(Card(accessPointName))
+            .Not.ToBeVisibleAsync();
+    }
+
+    public async Task RenameAsync(string accessPointName, string newName)
+    {
+        await RenameButton(accessPointName).ClickAsync();
+        await RenameModalInput.FillAsync(newName);
+        await RenameModalSubmit.ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{newName}");
+    }
+
+    public async Task CloneAsync(string accessPointName, string cloneName)
+    {
+        await CloneButton(accessPointName).ClickAsync();
+        await CloneModalInput.FillAsync(cloneName);
+        await CloneModalSubmit.ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{cloneName}");
+    }
+
+    private static string Sanitize(string value)
+        => value.Replace(" ", "-");
+}

+ 6 - 2
Tests.E2e/PageObjectModels/AccessPointListPom.cs

@@ -15,7 +15,6 @@ public class AccessPointsListPom(IPage page)
 
     public ILocator AddSection => page.GetByTestId("accesspoints-add-section");
 
-    // These must match your AddResourceComponent test IDs
     public ILocator AddInput => page.GetByTestId("add-accesspoint-input");
     public ILocator AddButton => page.GetByTestId("add-accesspoint-button");
 
@@ -27,6 +26,11 @@ public class AccessPointsListPom(IPage page)
     {
         return page.GetByTestId($"accesspoint-item-{Sanitize(name)}");
     }
+    
+    public ILocator AccessPointItemLink(string name)
+    {
+        return page.GetByTestId($"open-accesspoint-{Sanitize(name)}-link");
+    }
 
     public ILocator DeleteButton(string name)
     {
@@ -95,7 +99,7 @@ public class AccessPointsListPom(IPage page)
 
     public async Task OpenAccessPointAsync(string name)
     {
-        await AccessPointItem(name).ClickAsync();
+        await AccessPointItemLink(name).ClickAsync();
         await page.WaitForURLAsync($"**/resources/hardware/{name}");
     }
 

+ 189 - 0
Tests.E2e/PageObjectModels/DesktopCardPom.cs

@@ -0,0 +1,189 @@
+namespace Tests.E2e.PageObjectModels;
+
+using Microsoft.Playwright;
+
+public class DesktopCardPom(IPage page)
+{
+    // -------------------------------------------------
+    // Root + Navigation
+    // -------------------------------------------------
+
+    public ILocator DesktopItem(string name)
+        => page.GetByTestId($"desktop-item-{Sanitize(name)}");
+
+    public ILocator OpenDesktopLink(string name)
+        => page.GetByTestId($"desktop-item-{Sanitize(name)}-link");
+
+    public async Task OpenDesktopAsync(string name)
+    {
+        await OpenDesktopLink(name).ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{name}");
+    }
+
+    // -------------------------------------------------
+    // Header actions
+    // -------------------------------------------------
+
+    public ILocator RenameButton(string name)
+        => DesktopItem(name).GetByTestId("rename-desktop-button");
+
+    public ILocator CloneButton(string name)
+        => DesktopItem(name).GetByTestId("clone-desktop-button");
+
+    public ILocator DeleteButton(string name)
+        => DesktopItem(name).GetByTestId("delete-desktop-button");
+
+    public ILocator ModelBadge(string name)
+        => DesktopItem(name).GetByTestId("desktop-model-badge");
+
+    // -------------------------------------------------
+    // CPU section
+    // -------------------------------------------------
+
+    public ILocator CpuSection(string name)
+        => DesktopItem(name).GetByTestId("desktop-cpu-section");
+
+    public ILocator AddCpuButton(string name)
+        => DesktopItem(name).GetByTestId("add-cpu-button");
+
+    public ILocator EditCpuButton(string name, string cpuToString)
+        => DesktopItem(name).GetByTestId($"edit-cpu-{Sanitize(cpuToString)}");
+
+    // -------------------------------------------------
+    // RAM section
+    // -------------------------------------------------
+
+    public ILocator RamSection(string name)
+        => DesktopItem(name).GetByTestId("desktop-ram-section");
+
+    public ILocator EditRamButton(string name)
+        => DesktopItem(name).GetByTestId("edit-ram-button");
+
+    public ILocator RamValueButton(string name)
+        => DesktopItem(name).GetByTestId("ram-value-button");
+
+    // -------------------------------------------------
+    // Drive section
+    // -------------------------------------------------
+
+    public ILocator DriveSection(string name)
+        => DesktopItem(name).GetByTestId("desktop-drive-section");
+
+    public ILocator AddDriveButton(string name)
+        => DesktopItem(name).GetByTestId("add-drive-button");
+
+    public ILocator EditDriveButton(string name, string type, int size)
+        => DesktopItem(name).GetByTestId($"edit-drive-{type}-{size}");
+
+    // -------------------------------------------------
+    // NIC section
+    // -------------------------------------------------
+
+    public ILocator NicSection(string name)
+        => DesktopItem(name).GetByTestId("desktop-nic-section");
+
+    public ILocator AddNicButton(string name)
+        => DesktopItem(name).GetByTestId("add-nic-button");
+
+    public ILocator EditNicButton(string name, string type, double speed)
+        => DesktopItem(name).GetByTestId($"edit-nic-{type}-{speed}");
+
+    // -------------------------------------------------
+    // GPU section
+    // -------------------------------------------------
+
+    public ILocator GpuSection(string name)
+        => DesktopItem(name).GetByTestId("desktop-gpu-section");
+
+    public ILocator AddGpuButton(string name)
+        => DesktopItem(name).GetByTestId("add-gpu-button");
+
+    public ILocator EditGpuButton(string name, string model, int vram)
+        => DesktopItem(name).GetByTestId($"edit-gpu-{model}-{vram}");
+
+    // -------------------------------------------------
+    // Notes (MarkdownViewer/MarkdownEditor use prefixes)
+    // -------------------------------------------------
+
+    public ILocator NotesSection(string name)
+        => DesktopItem(name).GetByTestId("desktop-notes-section");
+
+    // MarkdownViewer (TestIdPrefix="desktop-notes-viewer")
+    public ILocator NotesViewerRoot(string name)
+        => DesktopItem(name).GetByTestId("desktop-notes-viewer-markdown-viewer");
+
+    public ILocator NotesViewerEditButton(string name)
+        => DesktopItem(name).GetByTestId("desktop-notes-viewer-markdown-viewer-edit-button");
+
+    // MarkdownEditor (TestIdPrefix="desktop-notes-editor")
+    public ILocator NotesEditorRoot(string name)
+        => DesktopItem(name).GetByTestId("desktop-notes-editor-markdown-editor");
+
+    public ILocator NotesEditorTextarea(string name)
+        => DesktopItem(name).GetByTestId("desktop-notes-editor-markdown-editor-textarea");
+
+    public ILocator NotesEditorSave(string name)
+        => DesktopItem(name).GetByTestId("desktop-notes-editor-markdown-editor-save");
+
+    public ILocator NotesEditorCancel(string name)
+        => DesktopItem(name).GetByTestId("desktop-notes-editor-markdown-editor-cancel");
+
+    // -------------------------------------------------
+    // Modals
+    // -------------------------------------------------
+
+    // ConfirmModal TestIdPrefix="Desktop" => "Desktop-confirm-modal-*"
+    public ILocator DeleteConfirmModal => page.GetByTestId("Desktop-confirm-modal");
+    public ILocator DeleteConfirmButton => page.GetByTestId("Desktop-confirm-modal-confirm");
+    public ILocator DeleteCancelButton => page.GetByTestId("Desktop-confirm-modal-cancel");
+
+    // StringValueModal prefixes you set:
+    // desktop-rename => "desktop-rename-string-value-modal-*"
+    public ILocator RenameModal => page.GetByTestId("desktop-rename-string-value-modal");
+    public ILocator RenameModalInput => page.GetByTestId("desktop-rename-string-value-modal-input");
+    public ILocator RenameModalAccept => page.GetByTestId("desktop-rename-string-value-modal-submit");
+    public ILocator RenameModalCancel => page.GetByTestId("desktop-rename-string-value-modal-cancel");
+
+    // desktop-clone => "desktop-clone-string-value-modal-*"
+    public ILocator CloneModal => page.GetByTestId("desktop-clone-string-value-modal");
+    public ILocator CloneModalInput => page.GetByTestId("desktop-clone-string-value-modal-input");
+    public ILocator CloneModalAccept => page.GetByTestId("desktop-clone-string-value-modal-submit");
+    public ILocator CloneModalCancel => page.GetByTestId("desktop-clone-string-value-modal-cancel");
+
+    // -------------------------------------------------
+    // Actions helpers
+    // -------------------------------------------------
+
+    public async Task DeleteDesktopAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+        await Assertions.Expect(DeleteConfirmModal).ToBeVisibleAsync();
+        await DeleteConfirmButton.ClickAsync();
+        await Assertions.Expect(DesktopItem(name)).Not.ToBeVisibleAsync();
+    }
+
+    public async Task RenameDesktopAsync(string currentName, string newName)
+    {
+        await RenameButton(currentName).ClickAsync();
+        await Assertions.Expect(RenameModal).ToBeVisibleAsync();
+
+        await RenameModalInput.FillAsync(newName);
+        await RenameModalAccept.ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{newName}");
+    }
+
+    public async Task CloneDesktopAsync(string currentName, string cloneName)
+    {
+        await CloneButton(currentName).ClickAsync();
+        await Assertions.Expect(CloneModal).ToBeVisibleAsync();
+
+        await CloneModalInput.FillAsync(cloneName);
+        await CloneModalAccept.ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{cloneName}");
+    }
+
+    private static string Sanitize(string value)
+        => value.Replace(" ", "-");
+}

+ 217 - 0
Tests.E2e/PageObjectModels/FirewallCardPom.cs

@@ -0,0 +1,217 @@
+namespace Tests.E2e.PageObjectModels;
+
+using Microsoft.Playwright;
+
+public class FirewallCardPom(IPage page)
+{
+    // -------------------------------------------------
+    // Dynamic Firewall Item (root)
+    // -------------------------------------------------
+
+    public ILocator FirewallItem(string name)
+        => page.GetByTestId($"firewall-item-{Sanitize(name)}");
+
+    public ILocator OpenLink(string name)
+        => FirewallItem(name).GetByTestId($"firewall-item-{Sanitize(name)}-link");
+
+    // -------------------------------------------------
+    // Header Actions
+    // -------------------------------------------------
+
+    public ILocator EditButton(string name)
+        => FirewallItem(name).GetByTestId("edit-firewall-button");
+
+    public ILocator SaveButton(string name)
+        => FirewallItem(name).GetByTestId("save-firewall-button");
+
+    public ILocator CancelButton(string name)
+        => FirewallItem(name).GetByTestId("cancel-firewall-button");
+
+    public ILocator RenameButton(string name)
+        => FirewallItem(name).GetByTestId("rename-firewall-button");
+
+    public ILocator CloneButton(string name)
+        => FirewallItem(name).GetByTestId("clone-firewall-button");
+
+    public ILocator DeleteButton(string name)
+        => FirewallItem(name).GetByTestId("delete-firewall-button");
+
+    // -------------------------------------------------
+    // Model
+    // -------------------------------------------------
+
+    public ILocator ModelSection(string name)
+        => FirewallItem(name).GetByTestId("firewall-model-section");
+
+    public ILocator ModelInput(string name)
+        => FirewallItem(name).GetByTestId("firewall-model-input");
+
+    public ILocator ModelValue(string name)
+        => FirewallItem(name).GetByTestId("firewall-model-value");
+
+    // -------------------------------------------------
+    // Features
+    // -------------------------------------------------
+
+    public ILocator FeaturesSection(string name)
+        => FirewallItem(name).GetByTestId("firewall-features-section");
+
+    public ILocator ManagedCheckbox(string name)
+        => FirewallItem(name).GetByTestId("firewall-managed-checkbox");
+
+    public ILocator PoeCheckbox(string name)
+        => FirewallItem(name).GetByTestId("firewall-poe-checkbox");
+
+    public ILocator ManagedBadge(string name)
+        => FirewallItem(name).GetByTestId("firewall-managed-badge");
+
+    public ILocator PoeBadge(string name)
+        => FirewallItem(name).GetByTestId("firewall-poe-badge");
+
+    // -------------------------------------------------
+    // Ports
+    // -------------------------------------------------
+
+    public ILocator PortsSection(string name)
+        => FirewallItem(name).GetByTestId("firewall-ports-section");
+
+    public ILocator AddPortButton(string name)
+        => FirewallItem(name).GetByTestId("add-port-button");
+
+    public ILocator EditPortButton(string firewallName, string portType, double portSpeed)
+        => FirewallItem(firewallName).GetByTestId($"edit-port-{portType}-{portSpeed}");
+
+    // -------------------------------------------------
+    // Notes (Markdown)
+    // -------------------------------------------------
+
+    public ILocator NotesSection(string name)
+        => FirewallItem(name).GetByTestId("firewall-notes-section");
+
+    // MarkdownViewer / MarkdownEditor now use TestIdPrefix internally
+    public ILocator NotesViewerRoot(string name)
+        => FirewallItem(name).GetByTestId("firewall-notes-viewer");
+
+    public ILocator NotesViewerContent(string name)
+        => FirewallItem(name).GetByTestId("firewall-notes-viewer-content");
+
+    public ILocator NotesEditorRoot(string name)
+        => FirewallItem(name).GetByTestId("firewall-notes-editor");
+
+    public ILocator NotesEditorTextarea(string name)
+        => FirewallItem(name).GetByTestId("firewall-notes-editor-textarea");
+
+    public ILocator NotesEditorSave(string name)
+        => FirewallItem(name).GetByTestId("firewall-notes-editor-save");
+
+    public ILocator NotesEditorCancel(string name)
+        => FirewallItem(name).GetByTestId("firewall-notes-editor-cancel");
+
+    // -------------------------------------------------
+    // Modals
+    // -------------------------------------------------
+
+    public ILocator DeleteConfirmConfirmButton()
+        => page.GetByTestId("Firewall-confirm-modal-confirm");
+
+    public ILocator DeleteConfirmCancelButton()
+        => page.GetByTestId("Firewall-confirm-modal-cancel");
+
+    // StringValueModal (rename)
+    public ILocator RenameModal()
+        => page.GetByTestId("firewall-rename-string-value-modal");
+
+    public ILocator RenameInput()
+        => page.GetByTestId("firewall-rename-string-value-modal-input");
+
+    public ILocator RenameAccept()
+        => page.GetByTestId("firewall-rename-string-value-modal-submit");
+
+    public ILocator RenameCancel()
+        => page.GetByTestId("firewall-rename-string-value-modal-cancel");
+
+    // StringValueModal (clone)
+    public ILocator CloneModal()
+        => page.GetByTestId("firewall-clone-string-value-modal");
+
+    public ILocator CloneInput()
+        => page.GetByTestId("firewall-clone-string-value-modal-input");
+
+    public ILocator CloneAccept()
+        => page.GetByTestId("firewall-clone-string-value-modal-submit");
+
+    public ILocator CloneCancel()
+        => page.GetByTestId("firewall-clone-string-value-modal-cancel");
+
+    // -------------------------------------------------
+    // Navigation helpers
+    // -------------------------------------------------
+
+    public async Task OpenFirewallAsync(string name)
+    {
+        await OpenLink(name).ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{name}");
+    }
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public async Task DeleteFirewallAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+        await DeleteConfirmConfirmButton().ClickAsync();
+
+        await Assertions.Expect(FirewallItem(name)).Not.ToBeVisibleAsync();
+    }
+
+    public async Task RenameFirewallAsync(string currentName, string newName)
+    {
+        await RenameButton(currentName).ClickAsync();
+
+        await Assertions.Expect(RenameModal()).ToBeVisibleAsync();
+        await RenameInput().FillAsync(newName);
+        await RenameAccept().ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{newName}");
+        await Assertions.Expect(FirewallItem(newName)).ToBeVisibleAsync();
+    }
+
+    public async Task CloneFirewallAsync(string currentName, string cloneName)
+    {
+        await CloneButton(currentName).ClickAsync();
+
+        await Assertions.Expect(CloneModal()).ToBeVisibleAsync();
+        await CloneInput().FillAsync(cloneName);
+        await CloneAccept().ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{cloneName}");
+        await Assertions.Expect(FirewallItem(cloneName)).ToBeVisibleAsync();
+    }
+
+    public async Task EnterEditModeAsync(string name)
+    {
+        await EditButton(name).ClickAsync();
+        await Assertions.Expect(ModelInput(name)).ToBeVisibleAsync();
+        await Assertions.Expect(ManagedCheckbox(name)).ToBeVisibleAsync();
+        await Assertions.Expect(PoeCheckbox(name)).ToBeVisibleAsync();
+    }
+
+    public async Task SaveEditsAsync(string name)
+    {
+        await SaveButton(name).ClickAsync();
+
+        // back to view mode
+        await Assertions.Expect(EditButton(name)).ToBeVisibleAsync();
+    }
+
+    public async Task CancelEditsAsync(string name)
+    {
+        await CancelButton(name).ClickAsync();
+
+        // back to view mode
+        await Assertions.Expect(EditButton(name)).ToBeVisibleAsync();
+    }
+
+    private static string Sanitize(string value) => value.Replace(" ", "-");
+}

+ 2 - 5
Tests.E2e/PageObjectModels/FirewallListPom.cs

@@ -27,12 +27,9 @@ public class FirewallsListPom(IPage page)
     {
         return page.GetByTestId($"firewall-item-{Sanitize(name)}");
     }
-
+    
     public ILocator OpenLink(string name)
-    {
-        return FirewallItem(name)
-            .GetByTestId("open-firewall-link");
-    }
+        => FirewallItem(name).GetByTestId($"firewall-item-{Sanitize(name)}-link");
 
     public ILocator EditButton(string name)
     {

+ 189 - 0
Tests.E2e/PageObjectModels/LaptopCardPom.cs

@@ -0,0 +1,189 @@
+namespace Tests.E2e.PageObjectModels;
+
+using Microsoft.Playwright;
+
+public class LaptopCardPom(IPage page)
+{
+    // -------------------------------------------------
+    // Root + Navigation
+    // -------------------------------------------------
+
+    public ILocator LaptopItem(string name)
+        => page.GetByTestId($"laptop-item-{Sanitize(name)}");
+
+    public ILocator OpenLaptopLink(string name)
+        => page.GetByTestId($"laptop-item-{Sanitize(name)}-link");
+
+    public async Task OpenLaptopAsync(string name)
+    {
+        await OpenLaptopLink(name).ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{name}");
+    }
+
+    // -------------------------------------------------
+    // Header actions
+    // -------------------------------------------------
+
+    public ILocator RenameButton(string name)
+        => LaptopItem(name).GetByTestId("rename-laptop-button");
+
+    public ILocator CloneButton(string name)
+        => LaptopItem(name).GetByTestId("clone-laptop-button");
+
+    public ILocator DeleteButton(string name)
+        => LaptopItem(name).GetByTestId("delete-laptop-button");
+
+    public ILocator ModelBadge(string name)
+        => LaptopItem(name).GetByTestId("laptop-model-badge");
+
+    // -------------------------------------------------
+    // CPU section
+    // -------------------------------------------------
+
+    public ILocator CpuSection(string name)
+        => LaptopItem(name).GetByTestId("laptop-cpu-section");
+
+    public ILocator AddCpuButton(string name)
+        => LaptopItem(name).GetByTestId("add-cpu-button");
+
+    public ILocator EditCpuButton(string name, string cpuToString)
+        => LaptopItem(name).GetByTestId($"edit-cpu-{Sanitize(cpuToString)}");
+
+    // -------------------------------------------------
+    // RAM section
+    // -------------------------------------------------
+
+    public ILocator RamSection(string name)
+        => LaptopItem(name).GetByTestId("laptop-ram-section");
+
+    public ILocator EditRamButton(string name)
+        => LaptopItem(name).GetByTestId("edit-ram-button");
+
+    public ILocator RamValueButton(string name)
+        => LaptopItem(name).GetByTestId("ram-value-button");
+
+    // -------------------------------------------------
+    // Drive section
+    // -------------------------------------------------
+
+    public ILocator DriveSection(string name)
+        => LaptopItem(name).GetByTestId("laptop-drive-section");
+
+    public ILocator AddDriveButton(string name)
+        => LaptopItem(name).GetByTestId("add-drive-button");
+
+    public ILocator EditDriveButton(string name, string type, int size)
+        => LaptopItem(name).GetByTestId($"edit-drive-{type}-{size}");
+
+    // -------------------------------------------------
+    // NIC section
+    // -------------------------------------------------
+
+    public ILocator NicSection(string name)
+        => LaptopItem(name).GetByTestId("laptop-nic-section");
+
+    public ILocator AddNicButton(string name)
+        => LaptopItem(name).GetByTestId("add-nic-button");
+
+    public ILocator EditNicButton(string name, string type, double speed)
+        => LaptopItem(name).GetByTestId($"edit-nic-{type}-{speed}");
+
+    // -------------------------------------------------
+    // GPU section
+    // -------------------------------------------------
+
+    public ILocator GpuSection(string name)
+        => LaptopItem(name).GetByTestId("laptop-gpu-section");
+
+    public ILocator AddGpuButton(string name)
+        => LaptopItem(name).GetByTestId("add-gpu-button");
+
+    public ILocator EditGpuButton(string name, string model, int vram)
+        => LaptopItem(name).GetByTestId($"edit-gpu-{model}-{vram}");
+
+    // -------------------------------------------------
+    // Notes (MarkdownViewer/MarkdownEditor use prefixes)
+    // -------------------------------------------------
+
+    public ILocator NotesSection(string name)
+        => LaptopItem(name).GetByTestId("laptop-notes-section");
+
+    // MarkdownViewer (TestIdPrefix="laptop-notes-viewer")
+    public ILocator NotesViewerRoot(string name)
+        => LaptopItem(name).GetByTestId("laptop-notes-viewer-markdown-viewer");
+
+    public ILocator NotesViewerEditButton(string name)
+        => LaptopItem(name).GetByTestId("laptop-notes-viewer-markdown-viewer-edit-button");
+
+    // MarkdownEditor (TestIdPrefix="laptop-notes-editor")
+    public ILocator NotesEditorRoot(string name)
+        => LaptopItem(name).GetByTestId("laptop-notes-editor-markdown-editor");
+
+    public ILocator NotesEditorTextarea(string name)
+        => LaptopItem(name).GetByTestId("laptop-notes-editor-markdown-editor-textarea");
+
+    public ILocator NotesEditorSave(string name)
+        => LaptopItem(name).GetByTestId("laptop-notes-editor-markdown-editor-save");
+
+    public ILocator NotesEditorCancel(string name)
+        => LaptopItem(name).GetByTestId("laptop-notes-editor-markdown-editor-cancel");
+
+    // -------------------------------------------------
+    // Modals
+    // -------------------------------------------------
+
+    // ConfirmModal TestIdPrefix="Laptop" => "Laptop-confirm-modal-*"
+    public ILocator DeleteConfirmModal => page.GetByTestId("Laptop-confirm-modal");
+    public ILocator DeleteConfirmButton => page.GetByTestId("Laptop-confirm-modal-confirm");
+    public ILocator DeleteCancelButton => page.GetByTestId("Laptop-confirm-modal-cancel");
+
+    // StringValueModal prefixes you set:
+    // laptop-rename => "laptop-rename-string-value-modal-*"
+    public ILocator RenameModal => page.GetByTestId("laptop-rename-string-value-modal");
+    public ILocator RenameModalInput => page.GetByTestId("laptop-rename-string-value-modal-input");
+    public ILocator RenameModalAccept => page.GetByTestId("laptop-rename-string-value-modal-submit");
+    public ILocator RenameModalCancel => page.GetByTestId("laptop-rename-string-value-modal-cancel");
+
+    // laptop-clone => "laptop-clone-string-value-modal-*"
+    public ILocator CloneModal => page.GetByTestId("laptop-clone-string-value-modal");
+    public ILocator CloneModalInput => page.GetByTestId("laptop-clone-string-value-modal-input");
+    public ILocator CloneModalAccept => page.GetByTestId("laptop-clone-string-value-modal-submit");
+    public ILocator CloneModalCancel => page.GetByTestId("laptop-clone-string-value-modal-cancel");
+
+    // -------------------------------------------------
+    // Actions helpers
+    // -------------------------------------------------
+
+    public async Task DeleteLaptopAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+        await Assertions.Expect(DeleteConfirmModal).ToBeVisibleAsync();
+        await DeleteConfirmButton.ClickAsync();
+        await Assertions.Expect(LaptopItem(name)).Not.ToBeVisibleAsync();
+    }
+
+    public async Task RenameLaptopAsync(string currentName, string newName)
+    {
+        await RenameButton(currentName).ClickAsync();
+        await Assertions.Expect(RenameModal).ToBeVisibleAsync();
+
+        await RenameModalInput.FillAsync(newName);
+        await RenameModalAccept.ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{newName}");
+    }
+
+    public async Task CloneLaptopAsync(string currentName, string cloneName)
+    {
+        await CloneButton(currentName).ClickAsync();
+        await Assertions.Expect(CloneModal).ToBeVisibleAsync();
+
+        await CloneModalInput.FillAsync(cloneName);
+        await CloneModalAccept.ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{cloneName}");
+    }
+
+    private static string Sanitize(string value)
+        => value.Replace(" ", "-");
+}

+ 217 - 0
Tests.E2e/PageObjectModels/RouterCardPom.cs

@@ -0,0 +1,217 @@
+namespace Tests.E2e.PageObjectModels;
+
+using Microsoft.Playwright;
+
+public class RouterCardPom(IPage page)
+{
+    // -------------------------------------------------
+    // Dynamic Router Item (root)
+    // -------------------------------------------------
+
+    public ILocator RouterItem(string name)
+        => page.GetByTestId($"router-item-{Sanitize(name)}");
+
+    public ILocator OpenLink(string name)
+        => page.GetByTestId($"router-item-{Sanitize(name)}-link");
+    
+    // -------------------------------------------------
+    // Header Actions
+    // -------------------------------------------------
+
+    public ILocator EditButton(string name)
+        => RouterItem(name).GetByTestId("edit-router-button");
+
+    public ILocator SaveButton(string name)
+        => RouterItem(name).GetByTestId("save-router-button");
+
+    public ILocator CancelButton(string name)
+        => RouterItem(name).GetByTestId("cancel-router-button");
+
+    public ILocator RenameButton(string name)
+        => RouterItem(name).GetByTestId("rename-router-button");
+
+    public ILocator CloneButton(string name)
+        => RouterItem(name).GetByTestId("clone-router-button");
+
+    public ILocator DeleteButton(string name)
+        => RouterItem(name).GetByTestId("delete-router-button");
+
+    // -------------------------------------------------
+    // Model
+    // -------------------------------------------------
+
+    public ILocator ModelSection(string name)
+        => RouterItem(name).GetByTestId("router-model-section");
+
+    public ILocator ModelInput(string name)
+        => RouterItem(name).GetByTestId("router-model-input");
+
+    public ILocator ModelValue(string name)
+        => RouterItem(name).GetByTestId("router-model-value");
+
+    // -------------------------------------------------
+    // Features
+    // -------------------------------------------------
+
+    public ILocator FeaturesSection(string name)
+        => RouterItem(name).GetByTestId("router-features-section");
+
+    public ILocator ManagedCheckbox(string name)
+        => RouterItem(name).GetByTestId("router-managed-checkbox");
+
+    public ILocator PoeCheckbox(string name)
+        => RouterItem(name).GetByTestId("router-poe-checkbox");
+
+    public ILocator ManagedBadge(string name)
+        => RouterItem(name).GetByTestId("router-managed-badge");
+
+    public ILocator PoeBadge(string name)
+        => RouterItem(name).GetByTestId("router-poe-badge");
+
+    // -------------------------------------------------
+    // Ports
+    // -------------------------------------------------
+
+    public ILocator PortsSection(string name)
+        => RouterItem(name).GetByTestId("router-ports-section");
+
+    public ILocator AddPortButton(string name)
+        => RouterItem(name).GetByTestId("add-port-button");
+
+    public ILocator EditPortButton(string routerName, string portType, double portSpeed)
+        => RouterItem(routerName).GetByTestId($"edit-port-{portType}-{portSpeed}");
+
+    // -------------------------------------------------
+    // Notes (Markdown)
+    // -------------------------------------------------
+
+    public ILocator NotesSection(string name)
+        => RouterItem(name).GetByTestId("router-notes-section");
+
+    // MarkdownViewer / MarkdownEditor now use TestIdPrefix internally
+    public ILocator NotesViewerRoot(string name)
+        => RouterItem(name).GetByTestId("router-notes-viewer");
+
+    public ILocator NotesViewerContent(string name)
+        => RouterItem(name).GetByTestId("router-notes-viewer-content");
+
+    public ILocator NotesEditorRoot(string name)
+        => RouterItem(name).GetByTestId("router-notes-editor");
+
+    public ILocator NotesEditorTextarea(string name)
+        => RouterItem(name).GetByTestId("router-notes-editor-textarea");
+
+    public ILocator NotesEditorSave(string name)
+        => RouterItem(name).GetByTestId("router-notes-editor-save");
+
+    public ILocator NotesEditorCancel(string name)
+        => RouterItem(name).GetByTestId("router-notes-editor-cancel");
+
+    // -------------------------------------------------
+    // Modals
+    // -------------------------------------------------
+
+    public ILocator DeleteConfirmConfirmButton()
+        => page.GetByTestId("Router-confirm-modal-confirm");
+
+    public ILocator DeleteConfirmCancelButton()
+        => page.GetByTestId("Router-confirm-modal-cancel");
+
+    // StringValueModal (rename)
+    public ILocator RenameModal()
+        => page.GetByTestId("router-rename-string-value-modal");
+
+    public ILocator RenameInput()
+        => page.GetByTestId("router-rename-string-value-modal-input");
+
+    public ILocator RenameAccept()
+        => page.GetByTestId("router-rename-string-value-modal-submit");
+
+    public ILocator RenameCancel()
+        => page.GetByTestId("router-rename-string-value-modal-cancel");
+
+    // StringValueModal (clone)
+    public ILocator CloneModal()
+        => page.GetByTestId("router-clone-string-value-modal");
+
+    public ILocator CloneInput()
+        => page.GetByTestId("router-clone-string-value-modal-input");
+
+    public ILocator CloneAccept()
+        => page.GetByTestId("router-clone-string-value-modal-submit");
+
+    public ILocator CloneCancel()
+        => page.GetByTestId("router-clone-string-value-modal-cancel");
+
+    // -------------------------------------------------
+    // Navigation helpers
+    // -------------------------------------------------
+
+    public async Task OpenRouterAsync(string name)
+    {
+        await OpenLink(name).ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{name}");
+    }
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public async Task DeleteRouterAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+        await DeleteConfirmConfirmButton().ClickAsync();
+
+        await Assertions.Expect(RouterItem(name)).Not.ToBeVisibleAsync();
+    }
+
+    public async Task RenameRouterAsync(string currentName, string newName)
+    {
+        await RenameButton(currentName).ClickAsync();
+
+        await Assertions.Expect(RenameModal()).ToBeVisibleAsync();
+        await RenameInput().FillAsync(newName);
+        await RenameAccept().ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{newName}");
+        await Assertions.Expect(RouterItem(newName)).ToBeVisibleAsync();
+    }
+
+    public async Task CloneRouterAsync(string currentName, string cloneName)
+    {
+        await CloneButton(currentName).ClickAsync();
+
+        await Assertions.Expect(CloneModal()).ToBeVisibleAsync();
+        await CloneInput().FillAsync(cloneName);
+        await CloneAccept().ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{cloneName}");
+        await Assertions.Expect(RouterItem(cloneName)).ToBeVisibleAsync();
+    }
+
+    public async Task EnterEditModeAsync(string name)
+    {
+        await EditButton(name).ClickAsync();
+        await Assertions.Expect(ModelInput(name)).ToBeVisibleAsync();
+        await Assertions.Expect(ManagedCheckbox(name)).ToBeVisibleAsync();
+        await Assertions.Expect(PoeCheckbox(name)).ToBeVisibleAsync();
+    }
+
+    public async Task SaveEditsAsync(string name)
+    {
+        await SaveButton(name).ClickAsync();
+
+        // back to view mode
+        await Assertions.Expect(EditButton(name)).ToBeVisibleAsync();
+    }
+
+    public async Task CancelEditsAsync(string name)
+    {
+        await CancelButton(name).ClickAsync();
+
+        // back to view mode
+        await Assertions.Expect(EditButton(name)).ToBeVisibleAsync();
+    }
+
+    private static string Sanitize(string value) => value.Replace(" ", "-");
+}

+ 1 - 4
Tests.E2e/PageObjectModels/RouterListPom.cs

@@ -29,10 +29,7 @@ public class RouterListPom(IPage page)
     }
 
     public ILocator OpenLink(string name)
-    {
-        return RouterItem(name)
-            .GetByTestId("open-router-link");
-    }
+        => page.GetByTestId($"router-item-{Sanitize(name)}-link");
 
     public ILocator EditButton(string name)
     {

+ 165 - 0
Tests.E2e/PageObjectModels/ServerCardPom.cs

@@ -0,0 +1,165 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class ServerCardPom(IPage page)
+{
+    // -------------------------------------------------
+    // Root / Identity
+    // -------------------------------------------------
+
+    public ILocator ServerItem(string name)
+        => page.GetByTestId($"server-item-{Sanitize(name)}");
+
+    public ILocator ServerLink(string name)
+        => page.GetByTestId($"server-item-{Sanitize(name)}-link");
+
+    public async Task AssertVisibleAsync(string name)
+        => await Assertions.Expect(ServerItem(name)).ToBeVisibleAsync();
+
+    // -------------------------------------------------
+    // Top actions
+    // -------------------------------------------------
+
+    public ILocator RenameButton(string name)
+        => ServerItem(name).GetByTestId("rename-server-button");
+
+    public ILocator CloneButton(string name)
+        => ServerItem(name).GetByTestId("clone-server-button");
+
+    public ILocator DeleteButton(string name)
+        => ServerItem(name).GetByTestId("delete-server-button");
+
+    // -------------------------------------------------
+    // CPU section + modal (TestIdPrefix="server-cpu")
+    // -------------------------------------------------
+
+    public ILocator CpuSection(string name)
+        => ServerItem(name).GetByTestId("server-cpu-section");
+
+    public ILocator AddCpuButton(string name)
+        => ServerItem(name).GetByTestId("add-cpu-button");
+
+    public ILocator EditCpuButton(string name, string cpuDisplayKey)
+        => ServerItem(name).GetByTestId($"edit-cpu-{Sanitize(cpuDisplayKey)}");
+
+    // CpuModal base id becomes: "server-cpu-cpu-modal"
+    public ILocator CpuModalRoot => page.GetByTestId("server-cpu-cpu-modal");
+    public ILocator CpuModalModelInput => page.GetByTestId("server-cpu-cpu-modal-model-input");
+    public ILocator CpuModalCoresInput => page.GetByTestId("server-cpu-cpu-modal-cores-input");
+    public ILocator CpuModalThreadsInput => page.GetByTestId("server-cpu-cpu-modal-threads-input");
+    public ILocator CpuModalSubmit => page.GetByTestId("server-cpu-cpu-modal-submit");
+    public ILocator CpuModalCancel => page.GetByTestId("server-cpu-cpu-modal-cancel");
+    public ILocator CpuModalDelete => page.GetByTestId("server-cpu-cpu-modal-delete");
+    
+
+    public ILocator RamModalRoot => page.GetByTestId("server-ram-ram-modal");
+    public ILocator RamModalSizeInput => page.GetByTestId("server-ram-ram-modal-size-input");
+    public ILocator RamModalMtsInput => page.GetByTestId("server-ram-ram-modal-mts-input");
+    public ILocator RamModalSubmit => page.GetByTestId("server-ram-ram-modal-submit");
+    public ILocator RamModalCancel => page.GetByTestId("server-ram-ram-modal-cancel");
+
+    // -------------------------------------------------
+    // Drive modal (TestIdPrefix="server-drive")
+    // -------------------------------------------------
+
+    public ILocator DriveModalRoot => page.GetByTestId("server-drive-drive-modal");
+    public ILocator DriveModalTypeInput => page.GetByTestId("server-drive-drive-modal-type-input");
+    public ILocator DriveModalSizeInput => page.GetByTestId("server-drive-drive-modal-size-input");
+    public ILocator DriveModalSubmit => page.GetByTestId("server-drive-drive-modal-submit");
+    public ILocator DriveModalCancel => page.GetByTestId("server-drive-drive-modal-cancel");
+    public ILocator DriveModalDelete => page.GetByTestId("server-drive-drive-modal-delete");
+
+    // -------------------------------------------------
+    // NIC modal (TestIdPrefix="server-nic")
+    // -------------------------------------------------
+
+    public ILocator NicModalRoot => page.GetByTestId("server-nic-nic-modal");
+    public ILocator NicModalTypeInput => page.GetByTestId("server-nic-nic-modal-type-input");
+    public ILocator NicModalSpeedInput => page.GetByTestId("server-nic-nic-modal-speed-input");
+    public ILocator NicModalPortsInput => page.GetByTestId("server-nic-nic-modal-ports-input");
+    public ILocator NicModalSubmit => page.GetByTestId("server-nic-nic-modal-submit");
+    public ILocator NicModalCancel => page.GetByTestId("server-nic-nic-modal-cancel");
+    public ILocator NicModalDelete => page.GetByTestId("server-nic-nic-modal-delete");
+
+    // -------------------------------------------------
+    // GPU modal (TestIdPrefix="server-gpu")
+    // -------------------------------------------------
+
+    public ILocator GpuModalRoot => page.GetByTestId("server-gpu-gpu-modal");
+    public ILocator GpuModalModelInput => page.GetByTestId("server-gpu-gpu-modal-model-input");
+    public ILocator GpuModalVramInput => page.GetByTestId("server-gpu-gpu-modal-vram-input");
+    public ILocator GpuModalSubmit => page.GetByTestId("server-gpu-gpu-modal-submit");
+    public ILocator GpuModalCancel => page.GetByTestId("server-gpu-gpu-modal-cancel");
+    public ILocator GpuModalDelete => page.GetByTestId("server-gpu-gpu-modal-delete");
+
+    // -------------------------------------------------
+    // Notes (TestIdPrefix="server-markdown")
+    // MarkdownViewer base id: "server-markdown-markdown-viewer"
+    // MarkdownEditor base id: "server-markdown-markdown-editor"
+    // -------------------------------------------------
+
+    public ILocator NotesViewerRoot => page.GetByTestId("server-markdown-markdown-viewer");
+    public ILocator NotesViewerEditButton => page.GetByTestId("server-markdown-markdown-viewer-edit");
+
+    public ILocator NotesEditorRoot => page.GetByTestId("server-markdown-markdown-editor");
+    public ILocator NotesEditorTextarea => page.GetByTestId("server-markdown-markdown-editor-textarea");
+    public ILocator NotesEditorSave => page.GetByTestId("server-markdown-markdown-editor-save");
+    public ILocator NotesEditorCancel => page.GetByTestId("server-markdown-markdown-editor-cancel");
+
+    // -------------------------------------------------
+    // Delete confirm modal (TestIdPrefix="server-delete")
+    // ConfirmModal base id becomes: "server-delete-confirm-modal"
+    // -------------------------------------------------
+
+    public ILocator DeleteConfirmModal => page.GetByTestId("server-delete-confirm-modal");
+    public ILocator DeleteConfirm => page.GetByTestId("server-delete-confirm-modal-confirm");
+    public ILocator DeleteCancel => page.GetByTestId("server-delete-confirm-modal-cancel");
+    
+
+    public ILocator RenameModal => page.GetByTestId("server-rename-string-value-modal");
+    public ILocator RenameInput => page.GetByTestId("server-rename-string-value-modal-input");
+    public ILocator RenameAccept => page.GetByTestId("server-rename-string-value-modal-submit");
+    public ILocator RenameCancel => page.GetByTestId("server-rename-string-value-modal-cancel");
+
+    // -------------------------------------------------
+    // Clone modal (TestIdPrefix="server-clone")
+    // -------------------------------------------------
+
+    public ILocator CloneModal => page.GetByTestId("server-clone-string-value-modal");
+    public ILocator CloneInput => page.GetByTestId("server-clone-string-value-modal-input");
+    public ILocator CloneAccept => page.GetByTestId("server-clone-string-value-modal-submit");
+    public ILocator CloneCancel => page.GetByTestId("server-clone-string-value-modal-cancel");
+
+    // -------------------------------------------------
+    // Helpers / Common actions
+    // -------------------------------------------------
+
+    public async Task RenameAsync(string currentName, string newName)
+    {
+        await RenameButton(currentName).ClickAsync();
+        await Assertions.Expect(RenameModal).ToBeVisibleAsync();
+        await RenameInput.FillAsync(newName);
+        await RenameAccept.ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{newName}");
+    }
+
+    public async Task CloneAsync(string currentName, string cloneName)
+    {
+        await CloneButton(currentName).ClickAsync();
+        await Assertions.Expect(CloneModal).ToBeVisibleAsync();
+        await CloneInput.FillAsync(cloneName);
+        await CloneAccept.ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{cloneName}");
+    }
+
+    public async Task DeleteAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+        await Assertions.Expect(DeleteConfirmModal).ToBeVisibleAsync();
+        await DeleteConfirm.ClickAsync();
+    }
+
+    private static string Sanitize(string value)
+        => value.Replace(" ", "-");
+}

+ 1 - 1
Tests.E2e/PageObjectModels/ServerListPom.cs

@@ -87,7 +87,7 @@ public class ServersListPom(IPage page)
     public async Task DeleteServerAsync(string serverName)
     {
         await DeleteButton(serverName).ClickAsync();
-        await page.GetByTestId("Server-confirm-modal-confirm").ClickAsync();
+        await page.GetByTestId("server-delete-confirm-modal-confirm").ClickAsync();
 
         await Assertions.Expect(ServerItem(serverName))
             .Not.ToBeVisibleAsync();

+ 133 - 0
Tests.E2e/PageObjectModels/ServiceCardPom.cs

@@ -0,0 +1,133 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class ServiceCardPom(IPage page)
+{
+    // -------------------------------------------------
+    // Root
+    // -------------------------------------------------
+
+    public ILocator Card(string name)
+        => page.GetByTestId($"service-item-{Sanitize(name)}");
+
+    public ILocator Link(string name)
+        => page.GetByTestId($"service-item-{Sanitize(name)}-link");
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public ILocator EditButton(string name)
+        => Card(name).GetByTestId("edit-service-button");
+
+    public ILocator SaveButton(string name)
+        => Card(name).GetByTestId("save-service-button");
+
+    public ILocator CancelButton(string name)
+        => Card(name).GetByTestId("cancel-service-button");
+
+    public ILocator RenameButton(string name)
+        => Card(name).GetByTestId("rename-service-button");
+
+    public ILocator CloneButton(string name)
+        => Card(name).GetByTestId("clone-service-button");
+
+    public ILocator DeleteButton(string name)
+        => Card(name).GetByTestId("delete-service-button");
+
+    // -------------------------------------------------
+    // Inputs (Edit Mode)
+    // -------------------------------------------------
+
+    public ILocator IpInput(string name)
+        => Card(name).GetByTestId("service-ip-input");
+
+    public ILocator PortInput(string name)
+        => Card(name).GetByTestId("service-port-input");
+
+    public ILocator ProtocolInput(string name)
+        => Card(name).GetByTestId("service-protocol-input");
+
+    public ILocator UrlInput(string name)
+        => Card(name).GetByTestId("service-url-input");
+
+    public ILocator RunsOnButton(string name)
+        => Card(name).GetByTestId("service-runson-button");
+
+    // -------------------------------------------------
+    // View Mode Values
+    // -------------------------------------------------
+
+    public ILocator IpValue(string name)
+        => Card(name).GetByTestId("service-ip-value");
+
+    public ILocator PortValue(string name)
+        => Card(name).GetByTestId("service-port-value");
+
+    public ILocator ProtocolValue(string name)
+        => Card(name).GetByTestId("service-protocol-value");
+
+    public ILocator UrlValue(string name)
+        => Card(name).GetByTestId("service-url-value");
+
+    // -------------------------------------------------
+    // Notes
+    // -------------------------------------------------
+
+    public ILocator NotesViewer
+        => page.GetByTestId("service-notes-viewer-container");
+
+    public ILocator NotesEditor
+        => page.GetByTestId("service-notes-editor-container");
+
+    // -------------------------------------------------
+    // Confirm Modal
+    // -------------------------------------------------
+
+    public ILocator ConfirmDeleteButton
+        => page.GetByTestId("service-delete-confirm-modal-confirm");
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task AssertVisibleAsync(string name)
+    {
+        await Assertions.Expect(Card(name)).ToBeVisibleAsync();
+    }
+
+    public async Task BeginEditAsync(string name)
+        => await EditButton(name).ClickAsync();
+
+    public async Task SaveAsync(string name)
+        => await SaveButton(name).ClickAsync();
+
+    public async Task CancelAsync(string name)
+        => await CancelButton(name).ClickAsync();
+
+    public async Task RenameAsync(string currentName, string newName)
+    {
+        await RenameButton(currentName).ClickAsync();
+        await page.GetByTestId("service-rename-string-value-modal-input").FillAsync(newName);
+        await page.GetByTestId("service-rename-string-value-modal-submit").ClickAsync();
+        await page.WaitForURLAsync($"**/resources/services/{newName}");
+    }
+
+    public async Task CloneAsync(string currentName, string cloneName)
+    {
+        await CloneButton(currentName).ClickAsync();
+        await page.GetByTestId("service-clone-string-value-modal-input").FillAsync(cloneName);
+        await page.GetByTestId("service-clone-string-value-modal-submit").ClickAsync();
+        await page.WaitForURLAsync($"**/resources/services/{cloneName}");
+    }
+
+    public async Task DeleteAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+        await ConfirmDeleteButton.ClickAsync();
+    }
+
+    private static string Sanitize(string value)
+        => value.Replace(" ", "-");
+}

+ 1 - 1
Tests.E2e/PageObjectModels/ServicesListPom.cs

@@ -123,7 +123,7 @@ public class ServicesListPom
     {
         await DeleteButton(name).ClickAsync();
 
-        await _page.GetByTestId("Service-confirm-modal-confirm")
+        await _page.GetByTestId("service-delete-confirm-modal-confirm")
             .ClickAsync();
 
         await Assertions.Expect(ServiceCard(name))

+ 217 - 0
Tests.E2e/PageObjectModels/SwitchCardPom.cs

@@ -0,0 +1,217 @@
+namespace Tests.E2e.PageObjectModels;
+
+using Microsoft.Playwright;
+
+public class SwitchCardPom(IPage page)
+{
+    // -------------------------------------------------
+    // Dynamic Switch Item (root)
+    // -------------------------------------------------
+
+    public ILocator SwitchItem(string name)
+        => page.GetByTestId($"switch-item-{Sanitize(name)}");
+
+    public ILocator OpenLink(string name)
+        => page.GetByTestId($"switch-item-{Sanitize(name)}-link");
+    
+    // -------------------------------------------------
+    // Header Actions
+    // -------------------------------------------------
+
+    public ILocator EditButton(string name)
+        => SwitchItem(name).GetByTestId("edit-switch-button");
+
+    public ILocator SaveButton(string name)
+        => SwitchItem(name).GetByTestId("save-switch-button");
+
+    public ILocator CancelButton(string name)
+        => SwitchItem(name).GetByTestId("cancel-switch-button");
+
+    public ILocator RenameButton(string name)
+        => SwitchItem(name).GetByTestId("rename-switch-button");
+
+    public ILocator CloneButton(string name)
+        => SwitchItem(name).GetByTestId("clone-switch-button");
+
+    public ILocator DeleteButton(string name)
+        => SwitchItem(name).GetByTestId("delete-switch-button");
+
+    // -------------------------------------------------
+    // Model
+    // -------------------------------------------------
+
+    public ILocator ModelSection(string name)
+        => SwitchItem(name).GetByTestId("switch-model-section");
+
+    public ILocator ModelInput(string name)
+        => SwitchItem(name).GetByTestId("switch-model-input");
+
+    public ILocator ModelValue(string name)
+        => SwitchItem(name).GetByTestId("switch-model-value");
+
+    // -------------------------------------------------
+    // Features
+    // -------------------------------------------------
+
+    public ILocator FeaturesSection(string name)
+        => SwitchItem(name).GetByTestId("switch-features-section");
+
+    public ILocator ManagedCheckbox(string name)
+        => SwitchItem(name).GetByTestId("switch-managed-checkbox");
+
+    public ILocator PoeCheckbox(string name)
+        => SwitchItem(name).GetByTestId("switch-poe-checkbox");
+
+    public ILocator ManagedBadge(string name)
+        => SwitchItem(name).GetByTestId("switch-managed-badge");
+
+    public ILocator PoeBadge(string name)
+        => SwitchItem(name).GetByTestId("switch-poe-badge");
+
+    // -------------------------------------------------
+    // Ports
+    // -------------------------------------------------
+
+    public ILocator PortsSection(string name)
+        => SwitchItem(name).GetByTestId("switch-ports-section");
+
+    public ILocator AddPortButton(string name)
+        => SwitchItem(name).GetByTestId("add-port-button");
+
+    public ILocator EditPortButton(string switchName, string portType, double portSpeed)
+        => SwitchItem(switchName).GetByTestId($"edit-port-{portType}-{portSpeed}");
+
+    // -------------------------------------------------
+    // Notes (Markdown)
+    // -------------------------------------------------
+
+    public ILocator NotesSection(string name)
+        => SwitchItem(name).GetByTestId("switch-notes-section");
+
+    // MarkdownViewer / MarkdownEditor now use TestIdPrefix internally
+    public ILocator NotesViewerRoot(string name)
+        => SwitchItem(name).GetByTestId("switch-notes-viewer");
+
+    public ILocator NotesViewerContent(string name)
+        => SwitchItem(name).GetByTestId("switch-notes-viewer-content");
+
+    public ILocator NotesEditorRoot(string name)
+        => SwitchItem(name).GetByTestId("switch-notes-editor");
+
+    public ILocator NotesEditorTextarea(string name)
+        => SwitchItem(name).GetByTestId("switch-notes-editor-textarea");
+
+    public ILocator NotesEditorSave(string name)
+        => SwitchItem(name).GetByTestId("switch-notes-editor-save");
+
+    public ILocator NotesEditorCancel(string name)
+        => SwitchItem(name).GetByTestId("switch-notes-editor-cancel");
+
+    // -------------------------------------------------
+    // Modals
+    // -------------------------------------------------
+
+    public ILocator DeleteConfirmConfirmButton()
+        => page.GetByTestId("Switch-confirm-modal-confirm");
+
+    public ILocator DeleteConfirmCancelButton()
+        => page.GetByTestId("Switch-confirm-modal-cancel");
+
+    // StringValueModal (rename)
+    public ILocator RenameModal()
+        => page.GetByTestId("switch-rename-string-value-modal");
+
+    public ILocator RenameInput()
+        => page.GetByTestId("switch-rename-string-value-modal-input");
+
+    public ILocator RenameAccept()
+        => page.GetByTestId("switch-rename-string-value-modal-submit");
+
+    public ILocator RenameCancel()
+        => page.GetByTestId("switch-rename-string-value-modal-cancel");
+
+    // StringValueModal (clone)
+    public ILocator CloneModal()
+        => page.GetByTestId("switch-clone-string-value-modal");
+
+    public ILocator CloneInput()
+        => page.GetByTestId("switch-clone-string-value-modal-input");
+
+    public ILocator CloneAccept()
+        => page.GetByTestId("switch-clone-string-value-modal-submit");
+
+    public ILocator CloneCancel()
+        => page.GetByTestId("switch-clone-string-value-modal-cancel");
+
+    // -------------------------------------------------
+    // Navigation helpers
+    // -------------------------------------------------
+
+    public async Task OpenSwitchAsync(string name)
+    {
+        await OpenLink(name).ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{name}");
+    }
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public async Task DeleteSwitchAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+        await DeleteConfirmConfirmButton().ClickAsync();
+
+        await Assertions.Expect(SwitchItem(name)).Not.ToBeVisibleAsync();
+    }
+
+    public async Task RenameSwitchAsync(string currentName, string newName)
+    {
+        await RenameButton(currentName).ClickAsync();
+
+        await Assertions.Expect(RenameModal()).ToBeVisibleAsync();
+        await RenameInput().FillAsync(newName);
+        await RenameAccept().ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{newName}");
+        await Assertions.Expect(SwitchItem(newName)).ToBeVisibleAsync();
+    }
+
+    public async Task CloneSwitchAsync(string currentName, string cloneName)
+    {
+        await CloneButton(currentName).ClickAsync();
+
+        await Assertions.Expect(CloneModal()).ToBeVisibleAsync();
+        await CloneInput().FillAsync(cloneName);
+        await CloneAccept().ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{cloneName}");
+        await Assertions.Expect(SwitchItem(cloneName)).ToBeVisibleAsync();
+    }
+
+    public async Task EnterEditModeAsync(string name)
+    {
+        await EditButton(name).ClickAsync();
+        await Assertions.Expect(ModelInput(name)).ToBeVisibleAsync();
+        await Assertions.Expect(ManagedCheckbox(name)).ToBeVisibleAsync();
+        await Assertions.Expect(PoeCheckbox(name)).ToBeVisibleAsync();
+    }
+
+    public async Task SaveEditsAsync(string name)
+    {
+        await SaveButton(name).ClickAsync();
+
+        // back to view mode
+        await Assertions.Expect(EditButton(name)).ToBeVisibleAsync();
+    }
+
+    public async Task CancelEditsAsync(string name)
+    {
+        await CancelButton(name).ClickAsync();
+
+        // back to view mode
+        await Assertions.Expect(EditButton(name)).ToBeVisibleAsync();
+    }
+
+    private static string Sanitize(string value) => value.Replace(" ", "-");
+}

+ 1 - 4
Tests.E2e/PageObjectModels/SwitchListPom.cs

@@ -29,10 +29,7 @@ public class SwitchListPom(IPage page)
     }
 
     public ILocator OpenLink(string name)
-    {
-        return SwitchItem(name)
-            .GetByTestId("open-switch-link");
-    }
+        => page.GetByTestId($"switch-item-{Sanitize(name)}-link");
 
     public ILocator EditButton(string name)
     {

+ 180 - 0
Tests.E2e/PageObjectModels/SystemCardPom.cs

@@ -0,0 +1,180 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class SystemCardPom(IPage page)
+{
+    // -------------------------------------------------
+    // Helpers
+    // -------------------------------------------------
+
+    private static string Sanitize(string value)
+        => value.Replace(" ", "-");
+
+    public ILocator Card(string name)
+        => page.GetByTestId($"system-item-{Sanitize(name)}");
+
+    public ILocator Link(string name)
+        => page.GetByTestId($"system-item-{Sanitize(name)}-link");
+
+    // -------------------------------------------------
+    // Action Buttons
+    // -------------------------------------------------
+
+    public ILocator EditButton(string name)
+        => Card(name).GetByTestId("edit-system-button");
+
+    public ILocator SaveButton(string name)
+        => Card(name).GetByTestId("save-system-button");
+
+    public ILocator CancelButton(string name)
+        => Card(name).GetByTestId("cancel-system-button");
+
+    public ILocator RenameButton(string name)
+        => Card(name).GetByTestId("rename-system-button");
+
+    public ILocator CloneButton(string name)
+        => Card(name).GetByTestId("clone-system-button");
+
+    public ILocator DeleteButton(string name)
+        => Card(name).GetByTestId("delete-system-button");
+
+    // -------------------------------------------------
+    // Edit Inputs
+    // -------------------------------------------------
+
+    public ILocator TypeSelect(string name)
+        => Card(name).GetByTestId("system-type-select");
+
+    public ILocator OsInput(string name)
+        => Card(name).GetByTestId("system-os-input");
+
+    public ILocator CoresInput(string name)
+        => Card(name).GetByTestId("system-cores-input");
+
+    public ILocator RamInput(string name)
+        => Card(name).GetByTestId("system-ram-input");
+
+    public ILocator RunsOnButton(string name)
+        => Card(name).GetByTestId("system-runs-on-button");
+
+    // -------------------------------------------------
+    // Drives
+    // -------------------------------------------------
+
+    public ILocator AddDriveButton(string name)
+        => Card(name).GetByTestId("add-drive-button");
+
+    public ILocator DriveItem(string name, string type, int size)
+        => Card(name).GetByTestId($"drive-item-{type}-{size}");
+
+    // ---- Drive Modal (TestIdPrefix = "system") ----
+
+// ---- Drive Modal (TestIdPrefix = "system") ----
+
+    public ILocator DriveTypeSelect
+        => page.GetByTestId("system-drive-modal-type-input");
+    public ILocator DriveSizeInput
+        => page.GetByTestId("system-drive-modal-size-input");
+
+    public ILocator DriveSubmitButton
+        => page.GetByTestId("system-drive-modal-submit");
+
+    public ILocator DriveDeleteButton
+        => page.GetByTestId("system-delete-button");
+
+    // High-level drive action
+    public async Task AddDriveAsync(string name, string type, int size)
+    {
+        await AddDriveButton(name).ClickAsync();
+
+        await DriveTypeSelect.SelectOptionAsync(new SelectOptionValue
+        {
+            Value = type
+        });
+
+        await DriveSizeInput.FillAsync(size.ToString());
+
+        await DriveSubmitButton.ClickAsync();
+
+        await Assertions.Expect(
+            DriveItem(name, type, size)
+        ).ToBeVisibleAsync();
+    }
+
+    // -------------------------------------------------
+    // Notes
+    // -------------------------------------------------
+
+    public ILocator NotesViewer
+        => page.GetByTestId("system-notes-viewer-container");
+
+    public ILocator NotesEditor
+        => page.GetByTestId("system-notes-editor-container");
+
+    // -------------------------------------------------
+    // Confirm Delete Modal
+    // -------------------------------------------------
+
+    public ILocator ConfirmDeleteButton
+        => page.GetByTestId("system-delete-confirm-modal-confirm");
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task AssertVisibleAsync(string name)
+    {
+        await Assertions.Expect(Card(name)).ToBeVisibleAsync();
+    }
+
+    public async Task BeginEditAsync(string name)
+    {
+        await EditButton(name).ClickAsync();
+        await Assertions.Expect(SaveButton(name)).ToBeVisibleAsync();
+    }
+
+    public async Task SaveAsync(string name)
+    {
+        await SaveButton(name).ClickAsync();
+        await Assertions.Expect(EditButton(name)).ToBeVisibleAsync();
+    }
+
+    public async Task CancelAsync(string name)
+    {
+        await CancelButton(name).ClickAsync();
+        await Assertions.Expect(EditButton(name)).ToBeVisibleAsync();
+    }
+
+    public async Task RenameAsync(string currentName, string newName)
+    {
+        await RenameButton(currentName).ClickAsync();
+
+        await page.GetByTestId("system-rename-string-value-modal-input")
+            .FillAsync(newName);
+
+        await page.GetByTestId("system-rename-string-value-modal-submit")
+            .ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/systems/{newName}");
+    }
+
+    public async Task CloneAsync(string currentName, string cloneName)
+    {
+        await CloneButton(currentName).ClickAsync();
+
+        await page.GetByTestId("system-clone-string-value-modal-input")
+            .FillAsync(cloneName);
+
+        await page.GetByTestId("system-clone-string-value-modal-submit")
+            .ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/systems/{cloneName}");
+    }
+
+    public async Task DeleteAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+        await ConfirmDeleteButton.ClickAsync();
+    }
+}

+ 1 - 1
Tests.E2e/PageObjectModels/SystemsListPom.cs

@@ -129,7 +129,7 @@ public class SystemsListPom
     {
         await DeleteButton(name).ClickAsync();
 
-        await _page.GetByTestId("System-confirm-modal-confirm")
+        await _page.GetByTestId("system-delete-confirm-modal-confirm")
             .ClickAsync();
 
         await Assertions.Expect(SystemCard(name))

+ 146 - 0
Tests.E2e/PageObjectModels/UpsCardPom.cs

@@ -0,0 +1,146 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class UpsCardPom(IPage page)
+{
+    // -------------------------------------------------
+    // Root
+    // -------------------------------------------------
+
+    private static string Sanitize(string value)
+        => value.Replace(" ", "-");
+
+    public ILocator Card(string name)
+        => page.GetByTestId($"ups-item-{Sanitize(name)}");
+
+    public ILocator Link(string name)
+        => page.GetByTestId($"ups-item-{Sanitize(name)}-link");
+
+    // -------------------------------------------------
+    // Action Buttons
+    // -------------------------------------------------
+
+    public ILocator EditButton(string name)
+        => Card(name).GetByTestId("edit-ups-button");
+
+    public ILocator SaveButton(string name)
+        => Card(name).GetByTestId("save-ups-button");
+
+    public ILocator CancelButton(string name)
+        => Card(name).GetByTestId("cancel-ups-button");
+
+    public ILocator RenameButton(string name)
+        => Card(name).GetByTestId("rename-ups-button");
+
+    public ILocator CloneButton(string name)
+        => Card(name).GetByTestId("clone-ups-button");
+
+    public ILocator DeleteButton(string name)
+        => Card(name).GetByTestId("delete-ups-button");
+
+    // -------------------------------------------------
+    // Edit Inputs
+    // -------------------------------------------------
+
+    public ILocator ModelInput(string name)
+        => Card(name).GetByTestId("ups-model-input");
+
+    public ILocator CapacityInput(string name)
+        => Card(name).GetByTestId("ups-capacity-input");
+
+    // -------------------------------------------------
+    // View Values
+    // -------------------------------------------------
+
+    public ILocator ModelValue(string name)
+        => Card(name).GetByTestId("ups-model-value");
+
+    public ILocator CapacityValue(string name)
+        => Card(name).GetByTestId("ups-capacity-value");
+
+    // -------------------------------------------------
+    // Notes
+    // -------------------------------------------------
+
+    public ILocator NotesViewer
+        => page.GetByTestId("ups-notes-viewer-container");
+
+    public ILocator NotesEditor
+        => page.GetByTestId("ups-notes-editor-container");
+
+    // -------------------------------------------------
+    // Confirm Modal (TestIdPrefix="Ups")
+    // -------------------------------------------------
+
+    public ILocator ConfirmDeleteButton
+        => page.GetByTestId("Ups-confirm-modal-confirm");
+
+    // -------------------------------------------------
+    // Rename Modal (TestIdPrefix="ups-rename")
+    // -------------------------------------------------
+
+    public ILocator RenameInput
+        => page.GetByTestId("ups-rename-string-value-modal-input");
+
+    public ILocator RenameSubmit
+        => page.GetByTestId("ups-rename-string-value-modal-submit");
+
+    // -------------------------------------------------
+    // Clone Modal (TestIdPrefix="ups-clone")
+    // -------------------------------------------------
+
+    public ILocator CloneInput
+        => page.GetByTestId("ups-clone-string-value-modal-input");
+
+    public ILocator CloneSubmit
+        => page.GetByTestId("ups-clone-string-value-modal-submit");
+
+    // -------------------------------------------------
+    // Assertions
+    // -------------------------------------------------
+
+    public async Task AssertVisibleAsync(string name)
+    {
+        await Assertions.Expect(Card(name)).ToBeVisibleAsync();
+    }
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task BeginEditAsync(string name)
+        => await EditButton(name).ClickAsync();
+
+    public async Task SaveAsync(string name)
+        => await SaveButton(name).ClickAsync();
+
+    public async Task CancelAsync(string name)
+        => await CancelButton(name).ClickAsync();
+
+    public async Task RenameAsync(string currentName, string newName)
+    {
+        await RenameButton(currentName).ClickAsync();
+
+        await RenameInput.FillAsync(newName);
+        await RenameSubmit.ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{newName}");
+    }
+
+    public async Task CloneAsync(string currentName, string cloneName)
+    {
+        await CloneButton(currentName).ClickAsync();
+
+        await CloneInput.FillAsync(cloneName);
+        await CloneSubmit.ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{cloneName}");
+    }
+
+    public async Task DeleteAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+        await ConfirmDeleteButton.ClickAsync();
+    }
+}

+ 231 - 0
Tests.E2e/RouterCardTests.cs

@@ -0,0 +1,231 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class RouterCardTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    [Fact]
+    public async Task User_Can_Edit_Router_Model_And_Features()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-fw-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoRoutersListAsync();
+
+            var list = new RouterListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddRouterAsync(name);
+            await list.AssertRouterExists(name);
+
+            // Go to details page so we can exercise the card component
+            await list.OpenRouterAsync(name);
+
+            var card = new RouterCardPom(page);
+
+            await Assertions.Expect(card.RouterItem(name)).ToBeVisibleAsync();
+
+            // Enter edit mode
+            await card.EnterEditModeAsync(name);
+
+            // Update Model + toggle features
+            var newModel = "e2e-model-123";
+            await card.ModelInput(name).FillAsync(newModel);
+
+            // Ensure both toggles are ON (idempotent)
+            if (!await card.ManagedCheckbox(name).IsCheckedAsync())
+                await card.ManagedCheckbox(name).CheckAsync();
+            if (!await card.PoeCheckbox(name).IsCheckedAsync())
+                await card.PoeCheckbox(name).CheckAsync();
+
+            await card.SaveEditsAsync(name);
+
+            // Validate view mode reflects changes
+            await Assertions.Expect(card.ModelValue(name)).ToHaveTextAsync(newModel);
+            await Assertions.Expect(card.ManagedBadge(name)).ToBeVisibleAsync();
+            await Assertions.Expect(card.PoeBadge(name)).ToBeVisibleAsync();
+        }
+        catch (Exception)
+        {
+            _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();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Rename_Router_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-fw-{Guid.NewGuid():N}"[..16];
+        var renamed = $"{name}-ren";
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoRoutersListAsync();
+
+            var list = new RouterListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddRouterAsync(name);
+            await list.OpenRouterAsync(name);
+
+            var card = new RouterCardPom(page);
+
+            await card.RenameRouterAsync(name, renamed);
+
+            // after rename we should be on new URL and see the renamed card root
+            await Assertions.Expect(card.RouterItem(renamed)).ToBeVisibleAsync();
+        }
+        catch (Exception)
+        {
+            _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();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Clone_Router_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-fw-{Guid.NewGuid():N}"[..16];
+        var clone = $"{name}-cpy";
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoRoutersListAsync();
+
+            var list = new RouterListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddRouterAsync(name);
+            await list.OpenRouterAsync(name);
+
+            var card = new RouterCardPom(page);
+
+            await card.CloneRouterAsync(name, clone);
+
+            // should be on the clone's details URL and see the clone card
+            await Assertions.Expect(card.RouterItem(clone)).ToBeVisibleAsync();
+        }
+        catch (Exception)
+        {
+            _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();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Delete_Router_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-fw-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoRoutersListAsync();
+
+            var list = new RouterListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddRouterAsync(name);
+            await list.OpenRouterAsync(name);
+
+            var card = new RouterCardPom(page);
+
+            await card.DeleteRouterAsync(name);
+
+            // After delete, your page navigates away (Nav.NavigateTo("/hardware/tree"))
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _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();
+        }
+    }
+}

+ 104 - 0
Tests.E2e/ServerCardTests.cs

@@ -0,0 +1,104 @@
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class ServerCardTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    [Fact]
+    public async Task User_Can_Rename_Clone_And_Delete_Server_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+
+        var originalName = $"e2e-srv-{Guid.NewGuid():N}"[..16];
+        var renamedName  = $"e2e-srv-rn-{Guid.NewGuid():N}"[..16];
+        var cloneName    = $"e2e-srv-cl-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            // ------------------------------------
+            // Navigate to Servers list
+            // ------------------------------------
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoServersListAsync();
+
+            var listPage = new ServersListPom(page);
+            await listPage.AssertLoadedAsync();
+
+            // ------------------------------------
+            // Create server
+            // ------------------------------------
+            await listPage.AddServerAsync(originalName);
+
+            // If list does not auto-navigate, open it
+            if (!page.Url.Contains($"/resources/hardware/{originalName}", StringComparison.OrdinalIgnoreCase))
+            {
+                await listPage.OpenServerAsync(originalName);
+            }
+
+            var card = new ServerCardPom(page);
+            await card.AssertVisibleAsync(originalName);
+
+            // ====================================
+            // RENAME
+            // ====================================
+            await card.RenameAsync(originalName, renamedName);
+
+            await card.AssertVisibleAsync(renamedName);
+
+            // ====================================
+            // CLONE
+            // ====================================
+            await card.CloneAsync(renamedName, cloneName);
+
+            await card.AssertVisibleAsync(cloneName);
+
+            // ====================================
+            // DELETE CLONE
+            // ====================================
+            await card.DeleteAsync(cloneName);
+
+            // Details page delete navigates to tree
+            await page.WaitForURLAsync("**/hardware/tree");
+
+            // ====================================
+            // DELETE RENAMED ORIGINAL
+            // ====================================
+            await page.GotoAsync($"{fixture.BaseUrl}/resources/hardware/{renamedName}");
+
+            await card.AssertVisibleAsync(renamedName);
+
+            await card.DeleteAsync(renamedName);
+
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _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();
+        }
+    }
+}

+ 181 - 0
Tests.E2e/ServiceCardTests.cs

@@ -0,0 +1,181 @@
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+using Microsoft.Playwright;
+
+namespace Tests.E2e;
+
+public class ServiceCardTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    // =============================================================
+    // Rename / Clone / Delete Flow
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Rename_Clone_And_Delete_Service_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+
+        var originalName = $"e2e-svc-{Guid.NewGuid():N}"[..16];
+        var renamedName  = $"e2e-svc-rn-{Guid.NewGuid():N}"[..16];
+        var cloneName    = $"e2e-svc-cl-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync($"{fixture.BaseUrl}/services/list");
+
+            var list = new ServicesListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddServiceAsync(originalName);
+
+            if (!page.Url.Contains($"/resources/services/{originalName}",
+                    StringComparison.OrdinalIgnoreCase))
+            {
+                await list.OpenServiceAsync(originalName);
+            }
+
+            var card = new ServiceCardPom(page);
+            await card.AssertVisibleAsync(originalName);
+
+            // -------------------------
+            // Rename
+            // -------------------------
+            await card.RenameAsync(originalName, renamedName);
+            await card.AssertVisibleAsync(renamedName);
+
+            // -------------------------
+            // Clone
+            // -------------------------
+            await card.CloneAsync(renamedName, cloneName);
+            await card.AssertVisibleAsync(cloneName);
+
+            // -------------------------
+            // Delete clone
+            // -------------------------
+            await card.DeleteAsync(cloneName);
+            await page.WaitForURLAsync("**/services/list");
+
+            // Delete renamed original
+            await page.GotoAsync($"{fixture.BaseUrl}/resources/services/{renamedName}");
+            await card.AssertVisibleAsync(renamedName);
+
+            await card.DeleteAsync(renamedName);
+            await page.WaitForURLAsync("**/services/list");
+        }
+        catch (Exception)
+        {
+            _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();
+        }
+    }
+
+    // =============================================================
+    // Edit Flow Test
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Edit_And_Save_Service()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-svc-edit-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync($"{fixture.BaseUrl}/services/list");
+
+            var list = new ServicesListPom(page);
+            await list.AddServiceAsync(name);
+
+            if (!page.Url.Contains($"/resources/services/{name}",
+                    StringComparison.OrdinalIgnoreCase))
+            {
+                await list.OpenServiceAsync(name);
+            }
+
+            var card = new ServiceCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            await card.BeginEditAsync(name);
+
+            // Fill via proper test ids
+            await card.IpInput(name).FillAsync("127.0.0.1");
+            await card.PortInput(name).FillAsync("8080");
+            await card.ProtocolInput(name).FillAsync("http");
+            await card.UrlInput(name).FillAsync("http://localhost:8080");
+
+            await card.SaveAsync(name);
+
+            // Verify edit mode exited
+            await Assertions.Expect(card.EditButton(name)).ToBeVisibleAsync();
+
+            // Verify persisted values
+            await Assertions.Expect(card.IpValue(name)).ToHaveTextAsync("127.0.0.1");
+            await Assertions.Expect(card.PortValue(name)).ToHaveTextAsync("8080");
+            await Assertions.Expect(card.ProtocolValue(name)).ToHaveTextAsync("http");
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Cancel Edit Test
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Cancel_Edit_Without_Saving()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-svc-cancel-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync($"{fixture.BaseUrl}/services/list");
+
+            var list = new ServicesListPom(page);
+            await list.AddServiceAsync(name);
+
+            if (!page.Url.Contains($"/resources/services/{name}",
+                    StringComparison.OrdinalIgnoreCase))
+            {
+                await list.OpenServiceAsync(name);
+            }
+
+            var card = new ServiceCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            await card.BeginEditAsync(name);
+
+            await card.IpInput(name).FillAsync("should-not-save");
+
+            await card.CancelAsync(name);
+
+            // Confirm edit mode exited
+            await Assertions.Expect(card.EditButton(name)).ToBeVisibleAsync();
+
+            // Confirm value did NOT persist
+            await Assertions.Expect(card.IpValue(name)).Not.ToBeVisibleAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+}

+ 231 - 0
Tests.E2e/SwitchCardTests.cs

@@ -0,0 +1,231 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class SwitchCardTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    [Fact]
+    public async Task User_Can_Edit_Switch_Model_And_Features()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-fw-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoSwitchesListAsync();
+
+            var list = new SwitchListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddSwitchAsync(name);
+            await list.AssertSwitchExists(name);
+
+            // Go to details page so we can exercise the card component
+            await list.OpenSwitchAsync(name);
+
+            var card = new SwitchCardPom(page);
+
+            await Assertions.Expect(card.SwitchItem(name)).ToBeVisibleAsync();
+
+            // Enter edit mode
+            await card.EnterEditModeAsync(name);
+
+            // Update Model + toggle features
+            var newModel = "e2e-model-123";
+            await card.ModelInput(name).FillAsync(newModel);
+
+            // Ensure both toggles are ON (idempotent)
+            if (!await card.ManagedCheckbox(name).IsCheckedAsync())
+                await card.ManagedCheckbox(name).CheckAsync();
+            if (!await card.PoeCheckbox(name).IsCheckedAsync())
+                await card.PoeCheckbox(name).CheckAsync();
+
+            await card.SaveEditsAsync(name);
+
+            // Validate view mode reflects changes
+            await Assertions.Expect(card.ModelValue(name)).ToHaveTextAsync(newModel);
+            await Assertions.Expect(card.ManagedBadge(name)).ToBeVisibleAsync();
+            await Assertions.Expect(card.PoeBadge(name)).ToBeVisibleAsync();
+        }
+        catch (Exception)
+        {
+            _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();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Rename_Switch_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-fw-{Guid.NewGuid():N}"[..16];
+        var renamed = $"{name}-ren";
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoSwitchesListAsync();
+
+            var list = new SwitchListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddSwitchAsync(name);
+            await list.OpenSwitchAsync(name);
+
+            var card = new SwitchCardPom(page);
+
+            await card.RenameSwitchAsync(name, renamed);
+
+            // after rename we should be on new URL and see the renamed card root
+            await Assertions.Expect(card.SwitchItem(renamed)).ToBeVisibleAsync();
+        }
+        catch (Exception)
+        {
+            _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();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Clone_Switch_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-fw-{Guid.NewGuid():N}"[..16];
+        var clone = $"{name}-cpy";
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoSwitchesListAsync();
+
+            var list = new SwitchListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddSwitchAsync(name);
+            await list.OpenSwitchAsync(name);
+
+            var card = new SwitchCardPom(page);
+
+            await card.CloneSwitchAsync(name, clone);
+
+            // should be on the clone's details URL and see the clone card
+            await Assertions.Expect(card.SwitchItem(clone)).ToBeVisibleAsync();
+        }
+        catch (Exception)
+        {
+            _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();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Delete_Switch_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-fw-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoSwitchesListAsync();
+
+            var list = new SwitchListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddSwitchAsync(name);
+            await list.OpenSwitchAsync(name);
+
+            var card = new SwitchCardPom(page);
+
+            await card.DeleteSwitchAsync(name);
+
+            // After delete, your page navigates away (Nav.NavigateTo("/hardware/tree"))
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _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();
+        }
+    }
+}

+ 207 - 0
Tests.E2e/SystemCardTests.cs

@@ -0,0 +1,207 @@
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+using Microsoft.Playwright;
+
+namespace Tests.E2e;
+
+public class SystemCardTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    // ============================================================
+    // Rename / Clone / Delete Flow
+    // ============================================================
+
+    [Fact]
+    public async Task User_Can_Rename_Clone_And_Delete_System()
+    {
+        var (context, page) = await CreatePageAsync();
+
+        var originalName = $"e2e-sys-{Guid.NewGuid():N}"[..16];
+        var renamedName  = $"e2e-sys-rn-{Guid.NewGuid():N}"[..16];
+        var cloneName    = $"e2e-sys-cl-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync($"{fixture.BaseUrl}/systems/list");
+
+            var list = new SystemsListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddSystemAsync(originalName);
+
+            if (!page.Url.Contains($"/resources/systems/{originalName}",
+                    StringComparison.OrdinalIgnoreCase))
+            {
+                await list.OpenSystemAsync(originalName);
+            }
+
+            var card = new SystemCardPom(page);
+            await card.AssertVisibleAsync(originalName);
+
+            // -------------------------
+            // Rename
+            // -------------------------
+            await card.RenameAsync(originalName, renamedName);
+            await card.AssertVisibleAsync(renamedName);
+
+            // -------------------------
+            // Clone
+            // -------------------------
+            await card.CloneAsync(renamedName, cloneName);
+            await card.AssertVisibleAsync(cloneName);
+
+            // -------------------------
+            // Delete clone
+            // -------------------------
+            await card.DeleteAsync(cloneName);
+            await page.WaitForURLAsync("**/systems/list");
+
+            // Delete renamed original
+            await page.GotoAsync($"{fixture.BaseUrl}/resources/systems/{renamedName}");
+            await card.AssertVisibleAsync(renamedName);
+
+            await card.DeleteAsync(renamedName);
+            await page.WaitForURLAsync("**/systems/list");
+        }
+        catch (Exception)
+        {
+            _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();
+        }
+    }
+
+    // ============================================================
+    // Edit Flow
+    // ============================================================
+
+    [Fact]
+    public async Task User_Can_Edit_And_Save_System()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-sys-edit-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync($"{fixture.BaseUrl}/systems/list");
+
+            var list = new SystemsListPom(page);
+            await list.AddSystemAsync(name);
+
+            if (!page.Url.Contains($"/resources/systems/{name}",
+                    StringComparison.OrdinalIgnoreCase))
+            {
+                await list.OpenSystemAsync(name);
+            }
+
+            var card = new SystemCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            await card.BeginEditAsync(name);
+
+            // Edit fields
+            await card.TypeSelect(name).SelectOptionAsync(new SelectOptionValue { Index = 0 });
+            await card.OsInput(name).FillAsync("Ubuntu 22.04");
+            await card.CoresInput(name).FillAsync("8");
+            await card.RamInput(name).FillAsync("16");
+
+            await card.SaveAsync(name);
+
+            // Verify read mode restored
+            await Assertions.Expect(card.EditButton(name)).ToBeVisibleAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    // ============================================================
+    // Cancel Edit
+    // ============================================================
+
+    [Fact]
+    public async Task User_Can_Cancel_System_Edit()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-sys-cancel-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync($"{fixture.BaseUrl}/systems/list");
+
+            var list = new SystemsListPom(page);
+            await list.AddSystemAsync(name);
+
+            if (!page.Url.Contains($"/resources/systems/{name}",
+                    StringComparison.OrdinalIgnoreCase))
+            {
+                await list.OpenSystemAsync(name);
+            }
+
+            var card = new SystemCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            await card.BeginEditAsync(name);
+
+            await card.OsInput(name).FillAsync("Should Not Save");
+
+            await card.CancelAsync(name);
+
+            await Assertions.Expect(card.EditButton(name)).ToBeVisibleAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    // ============================================================
+    // Drive Flow
+    // ============================================================
+
+    [Fact]
+    public async Task User_Can_Add_And_Edit_System_Drive()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-sys-drive-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync($"{fixture.BaseUrl}/systems/list");
+
+            var list = new SystemsListPom(page);
+            await list.AddSystemAsync(name);
+
+            if (!page.Url.Contains($"/resources/systems/{name}",
+                    StringComparison.OrdinalIgnoreCase))
+            {
+                await list.OpenSystemAsync(name);
+            }
+
+            var card = new SystemCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            await card.AddDriveAsync(name, "ssd", 512);
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+}

+ 176 - 0
Tests.E2e/UpsCardTests.cs

@@ -0,0 +1,176 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class UpsCardTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    // =============================================================
+    // Rename + Clone + Delete Flow
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Rename_Clone_And_Delete_Ups_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+
+        var originalName = $"e2e-ups-{Guid.NewGuid():N}"[..16];
+        var renamedName  = $"e2e-ups-rn-{Guid.NewGuid():N}"[..16];
+        var cloneName    = $"e2e-ups-cl-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync($"{fixture.BaseUrl}/ups/list");
+
+            var list = new UpsListPom(page);
+            await list.AddUpsAsync(originalName);
+
+            if (!page.Url.Contains($"/resources/hardware/{originalName}",
+                    StringComparison.OrdinalIgnoreCase))
+            {
+                await list.OpenUpsAsync(originalName);
+            }
+
+            var card = new UpsCardPom(page);
+            await card.AssertVisibleAsync(originalName);
+
+            // -------------------------
+            // Rename
+            // -------------------------
+            await card.RenameAsync(originalName, renamedName);
+            await card.AssertVisibleAsync(renamedName);
+
+            // -------------------------
+            // Clone
+            // -------------------------
+            await card.CloneAsync(renamedName, cloneName);
+            await card.AssertVisibleAsync(cloneName);
+
+            // -------------------------
+            // Delete clone
+            // -------------------------
+            await card.DeleteAsync(cloneName);
+            await page.WaitForURLAsync("**/hardware/tree");
+
+            // Navigate back and delete renamed
+            await page.GotoAsync($"{fixture.BaseUrl}/resources/hardware/{renamedName}");
+            await card.AssertVisibleAsync(renamedName);
+
+            await card.DeleteAsync(renamedName);
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _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();
+        }
+    }
+
+    // =============================================================
+    // Edit + Save Flow
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Edit_And_Save_Ups()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ups-edit-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync($"{fixture.BaseUrl}/ups/list");
+
+            var list = new UpsListPom(page);
+            await list.AddUpsAsync(name);
+
+            if (!page.Url.Contains($"/resources/hardware/{name}",
+                    StringComparison.OrdinalIgnoreCase))
+            {
+                await list.OpenUpsAsync(name);
+            }
+
+            var card = new UpsCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            await card.BeginEditAsync(name);
+
+            await card.ModelInput(name).FillAsync("APC Smart-UPS");
+            await card.CapacityInput(name).FillAsync("1500");
+
+            await card.SaveAsync(name);
+
+            await Assertions.Expect(
+                card.ModelValue(name)
+            ).ToContainTextAsync("APC Smart-UPS");
+
+            await Assertions.Expect(
+                card.CapacityValue(name)
+            ).ToContainTextAsync("1500");
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Cancel Edit Flow
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Cancel_Ups_Edit_Without_Saving()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ups-cancel-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync($"{fixture.BaseUrl}/ups/list");
+
+            var list = new UpsListPom(page);
+            await list.AddUpsAsync(name);
+
+            if (!page.Url.Contains($"/resources/hardware/{name}",
+                    StringComparison.OrdinalIgnoreCase))
+            {
+                await list.OpenUpsAsync(name);
+            }
+
+            var card = new UpsCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            await card.BeginEditAsync(name);
+
+            await card.ModelInput(name).FillAsync("ShouldNotPersist");
+            await card.CapacityInput(name).FillAsync("9999");
+
+            await card.CancelAsync(name);
+
+            // Verify edit mode exited
+            await Assertions.Expect(
+                card.EditButton(name)
+            ).ToBeVisibleAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+}