Ver Fonte

Added AccessPoint + Desktop e2e tests

Tim Jones há 1 mês atrás
pai
commit
5974edf959

+ 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>

+ 2 - 2
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>
 

+ 2 - 1
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

+ 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>

+ 2 - 1
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

+ 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();
+        }
+    }
+}

+ 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(" ", "-");
+}