Explorar el Código

Added webui e2e tests

Tim Jones hace 1 mes
padre
commit
af57b9e2ad

BIN
.DS_Store


+ 1 - 1
.gitignore

@@ -134,7 +134,7 @@ ipch/
 *.sap
 
 # Visual Studio Trace Files
-*.e2e
+# *.e2e
 
 # TFS 2012 Local Workspace
 $tf/

+ 25 - 0
Tests.E2e/Infra/E2ETestBase.cs

@@ -0,0 +1,25 @@
+using Microsoft.Playwright;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public abstract class E2ETestBase( PlaywrightFixture fixture,
+    ITestOutputHelper output) : IClassFixture<PlaywrightFixture>
+{
+    public async Task<(IBrowserContext, IPage)> CreatePageAsync()
+    {
+        var context = await fixture.Browser.NewContextAsync();
+        var page = await context.NewPageAsync();
+        
+        page.Console += (_, msg) =>
+            output.WriteLine($"[BrowserConsole] {msg.Type}: {msg.Text}");
+
+        page.PageError += (_, msg) =>
+            output.WriteLine($"[PageError] {msg}");
+
+
+        output.WriteLine($"BaseUrl: {fixture.BaseUrl}");
+        
+        return (context, page);
+    }
+}

+ 77 - 0
Tests.E2e/Infra/PlaywrightFixture.cs

@@ -0,0 +1,77 @@
+using DotNet.Testcontainers.Builders;
+using DotNet.Testcontainers.Containers;
+using Microsoft.Playwright;
+
+namespace Tests.E2e;
+
+public class PlaywrightFixture : IAsyncLifetime
+{
+    public IBrowser Browser { get; private set; } = default!;
+    public string BaseUrl { get; private set; } = default!;
+
+    private IPlaywright _playwright = default!;
+    private IContainer _container = default!;
+    private string _configDirectory = default!;
+
+    // Change this if needed
+    private const string DockerImage = "rackpeek:ci";
+
+    public async Task InitializeAsync()
+    {
+        // Create isolated config directory per test run
+        _configDirectory = Path.Combine(
+            Path.GetTempPath(),
+            "rackpeek-e2e",
+            Guid.NewGuid().ToString());
+
+        Directory.CreateDirectory(_configDirectory);
+
+        File.WriteAllText(
+            Path.Combine(_configDirectory, "config.yaml"),
+            "# E2E test config");
+
+        _container = new ContainerBuilder()
+            .WithImage(DockerImage)
+            .WithPortBinding(8080, true) // random host port
+            .WithBindMount(_configDirectory, "/app/config")
+            .WithWaitStrategy(
+                Wait.ForUnixContainer()
+                    .UntilHttpRequestIsSucceeded(r => r
+                        .ForPort(8080)
+                        .ForPath("/")))
+            .Build();
+
+        await _container.StartAsync();
+
+        var mappedPort = _container.GetMappedPublicPort(8080);
+        BaseUrl = $"http://127.0.0.1:{mappedPort}";
+
+        Console.WriteLine($"RackPeek running at: {BaseUrl}");
+
+        _playwright = await Playwright.CreateAsync();
+
+        Browser = await _playwright.Chromium.LaunchAsync(new()
+        {
+            Headless = true
+            //Headless = false,
+            //SlowMo = 1000
+        });
+    }
+
+    public async Task DisposeAsync()
+    {
+        if (Browser != null)
+            await Browser.DisposeAsync();
+
+        _playwright?.Dispose();
+
+        if (_container != null)
+            await _container.DisposeAsync();
+
+        if (!string.IsNullOrWhiteSpace(_configDirectory) &&
+            Directory.Exists(_configDirectory))
+        {
+            Directory.Delete(_configDirectory, true);
+        }
+    }
+}

+ 53 - 0
Tests.E2e/PageObjectModels/AddResourceComponent.cs

@@ -0,0 +1,53 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class AddResourceComponent(IPage page, string resourceType)
+{
+    private readonly string _resourceType = resourceType.ToLower();
+
+    // -------------------------------------------------
+    // Root & Structure
+    // -------------------------------------------------
+
+    public ILocator Root
+        => page.GetByTestId($"add-{_resourceType}-root");
+
+    public ILocator Title
+        => page.GetByTestId($"add-{_resourceType}-title");
+
+    public ILocator Form
+        => page.GetByTestId($"add-{_resourceType}-form");
+
+    public ILocator Input
+        => page.GetByTestId($"add-{_resourceType}-input");
+
+    public ILocator Button
+        => page.GetByTestId($"add-{_resourceType}-button");
+
+    public ILocator Error
+        => page.GetByTestId($"add-{_resourceType}-error");
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public async Task AddAsync(string name)
+    {
+        await Assertions.Expect(Root).ToBeVisibleAsync();
+
+        await Input.FillAsync(name);
+        await Button.ClickAsync();
+    }
+
+    public async Task AssertErrorAsync(string message)
+    {
+        await Assertions.Expect(Error)
+            .ToHaveTextAsync(message);
+    }
+
+    public async Task AssertVisibleAsync()
+    {
+        await Assertions.Expect(Root).ToBeVisibleAsync();
+    }
+}

+ 182 - 0
Tests.E2e/PageObjectModels/HardwareTreePom.cs

@@ -0,0 +1,182 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.Pages;
+
+public class HardwareTreePom(IPage page)
+{
+    // -------------------------------------------------
+    // Root & State
+    // -------------------------------------------------
+
+    public ILocator PageRoot => page.GetByTestId("hardware-page-root");
+    public ILocator PageTitle => page.GetByTestId("hardware-page-title");
+    public ILocator SubNav => page.GetByTestId("hardware-subnav");
+
+    public ILocator Loading => page.GetByTestId("hardware-loading");
+    public ILocator EmptyState => page.GetByTestId("hardware-empty");
+    public ILocator Tree => page.GetByTestId("hardware-tree");
+
+    // -------------------------------------------------
+    // Navigation
+    // -------------------------------------------------
+
+    public ILocator NavServers => page.GetByTestId("nav-servers");
+    public ILocator NavSwitches => page.GetByTestId("nav-switches");
+    public ILocator NavFirewalls => page.GetByTestId("nav-firewalls");
+    public ILocator NavRouters => page.GetByTestId("nav-routers");
+    public ILocator NavAccessPoints => page.GetByTestId("nav-accesspoints");
+    public ILocator NavUps => page.GetByTestId("nav-ups");
+    public ILocator NavDesktops => page.GetByTestId("nav-desktops");
+    public ILocator NavLaptops => page.GetByTestId("nav-laptops");
+
+    public async Task GotoAsync(string baseUrl)
+    {
+        await page.GotoAsync($"{baseUrl}/hardware/tree");
+        await AssertLoadedAsync();
+    }
+
+    public async Task AssertLoadedAsync()
+    {
+        await Assertions.Expect(PageRoot).ToBeVisibleAsync();
+        await Assertions.Expect(PageTitle).ToBeVisibleAsync();
+    }
+
+    public async Task WaitForTreeAsync()
+    {
+        await Assertions.Expect(Tree).ToBeVisibleAsync();
+    }
+
+    // -------------------------------------------------
+    // Hardware Groups
+    // -------------------------------------------------
+
+    public ILocator HardwareGroup(string kind)
+        => page.GetByTestId($"hardware-group-{kind}");
+
+    public ILocator HardwareGroupTitle(string kind)
+        => page.GetByTestId($"hardware-group-title-{kind}");
+
+    public async Task AssertHardwareGroupExists(string kind)
+    {
+        await Assertions.Expect(HardwareGroup(kind)).ToBeVisibleAsync();
+    }
+
+    // -------------------------------------------------
+    // Hardware Items
+    // -------------------------------------------------
+
+    public ILocator HardwareItem(string hardwareName)
+        => page.GetByTestId($"hardware-item-{hardwareName}");
+
+    public ILocator HardwareLink(string hardwareName)
+        => page.GetByTestId($"hardware-link-{hardwareName}");
+
+    public ILocator HardwareName(string hardwareName)
+        => page.GetByTestId($"hardware-name-{hardwareName}");
+
+    public async Task OpenHardwareAsync(string hardwareName)
+    {
+        await HardwareLink(hardwareName).ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{hardwareName}");
+    }
+
+    // -------------------------------------------------
+    // Systems Under Hardware
+    // -------------------------------------------------
+
+    public ILocator SystemList(string hardwareName)
+        => page.GetByTestId($"system-list-{hardwareName}");
+
+    public ILocator SystemItem(string systemName)
+        => page.GetByTestId($"system-item-{systemName}");
+
+    public ILocator SystemLink(string systemName)
+        => page.GetByTestId($"system-link-{systemName}");
+
+    public async Task OpenSystemAsync(string systemName)
+    {
+        await SystemLink(systemName).ClickAsync();
+        await page.WaitForURLAsync($"**/resources/systems/{systemName}");
+    }
+
+    public async Task AssertSystemExists(string systemName)
+    {
+        await Assertions.Expect(SystemItem(systemName)).ToBeVisibleAsync();
+    }
+
+    // -------------------------------------------------
+    // Services Under System
+    // -------------------------------------------------
+
+    public ILocator ServiceList(string systemName)
+        => page.GetByTestId($"service-list-{systemName}");
+
+    public ILocator ServiceItem(string serviceName)
+        => page.GetByTestId($"service-item-{serviceName}");
+
+    public ILocator ServiceLink(string serviceName)
+        => page.GetByTestId($"service-link-{serviceName}");
+
+    public async Task OpenServiceAsync(string serviceName)
+    {
+        await ServiceLink(serviceName).ClickAsync();
+        await page.WaitForURLAsync($"**/resources/services/{serviceName}");
+    }
+
+    public async Task AssertServiceExists(string serviceName)
+    {
+        await Assertions.Expect(ServiceItem(serviceName)).ToBeVisibleAsync();
+    }
+
+    // -------------------------------------------------
+    // Secondary Navigation Actions
+    // -------------------------------------------------
+
+    public async Task GotoServersListAsync()
+    {
+        await NavServers.ClickAsync();
+        await page.WaitForURLAsync("**/servers/list");
+    }
+
+    public async Task GotoSwitchesListAsync()
+    {
+        await NavSwitches.ClickAsync();
+        await page.WaitForURLAsync("**/switches/list");
+    }
+
+    public async Task GotoFirewallsListAsync()
+    {
+        await NavFirewalls.ClickAsync();
+        await page.WaitForURLAsync("**/firewalls/list");
+    }
+
+    public async Task GotoRoutersListAsync()
+    {
+        await NavRouters.ClickAsync();
+        await page.WaitForURLAsync("**/routers/list");
+    }
+
+    public async Task GotoAccessPointsListAsync()
+    {
+        await NavAccessPoints.ClickAsync();
+        await page.WaitForURLAsync("**/accesspoints/list");
+    }
+
+    public async Task GotoUpsListAsync()
+    {
+        await NavUps.ClickAsync();
+        await page.WaitForURLAsync("**/ups/list");
+    }
+
+    public async Task GotoDesktopsListAsync()
+    {
+        await NavDesktops.ClickAsync();
+        await page.WaitForURLAsync("**/desktops/list");
+    }
+
+    public async Task GotoLaptopsListAsync()
+    {
+        await NavLaptops.ClickAsync();
+        await page.WaitForURLAsync("**/laptops/list");
+    }
+}

+ 70 - 0
Tests.E2e/PageObjectModels/MainLayoutPom.cs

@@ -0,0 +1,70 @@
+namespace Tests.E2e.PageObjectModels;
+using Microsoft.Playwright;
+
+public class MainLayoutPom(IPage page)
+{
+    public ILocator AppRoot => page.GetByTestId("app-root");
+    public ILocator Header => page.GetByTestId("app-header");
+    public ILocator PageContent => page.GetByTestId("page-content");
+    
+    public ILocator BrandLink => page.GetByTestId("brand-link");
+    public ILocator BrandText => page.GetByTestId("brand-text");
+    
+    public ILocator NavHome => page.GetByTestId("nav-home");
+    public ILocator NavCli => page.GetByTestId("nav-cli");
+    public ILocator NavYaml => page.GetByTestId("nav-yaml");
+    public ILocator NavHardware => page.GetByTestId("nav-hardware");
+    public ILocator NavSystems => page.GetByTestId("nav-systems");
+    public ILocator NavServices => page.GetByTestId("nav-services");
+    public ILocator NavDocs => page.GetByTestId("nav-docs");
+
+
+    public async Task GotoHomeAsync()
+    {
+        await NavHome.ClickAsync();
+        await Assertions.Expect(PageContent).ToBeVisibleAsync();
+    }
+
+    public async Task GotoHardwareAsync()
+    {
+        await NavHardware.ClickAsync();
+        await page.WaitForURLAsync("**/hardware/**");
+    }
+
+    public async Task GotoSystemsAsync()
+    {
+        await NavSystems.ClickAsync();
+        await page.WaitForURLAsync("**/systems/**");
+    }
+
+    public async Task GotoServicesAsync()
+    {
+        await NavServices.ClickAsync();
+        await page.WaitForURLAsync("**/services/**");
+    }
+
+    public async Task GotoCliAsync()
+    {
+        await NavCli.ClickAsync();
+        await page.WaitForURLAsync("**/cli");
+    }
+
+    public async Task GotoYamlAsync()
+    {
+        await NavYaml.ClickAsync();
+        await page.WaitForURLAsync("**/yaml");
+    }
+
+    public async Task GotoDocsAsync()
+    {
+        await NavDocs.ClickAsync();
+        await page.WaitForURLAsync("**/docs/**");
+    }
+    
+    public async Task AssertLoadedAsync()
+    {
+        await Assertions.Expect(AppRoot).ToBeVisibleAsync();
+        await Assertions.Expect(Header).ToBeVisibleAsync();
+        await Assertions.Expect(PageContent).ToBeVisibleAsync();
+    }
+}

+ 108 - 0
Tests.E2e/PageObjectModels/ServerListPom.cs

@@ -0,0 +1,108 @@
+using Microsoft.Playwright;
+using Tests.E2e.PageObjectModels;
+
+namespace Tests.E2e.Pages;
+
+public class ServersListPom(IPage page)
+{
+    public AddResourceComponent AddServer =>
+        new AddResourceComponent(page, "server");
+    
+    public ILocator PageRoot => page.GetByTestId("servers-page-root");
+    public ILocator PageTitle => page.GetByTestId("servers-page-title");
+
+    public ILocator Loading => page.GetByTestId("servers-loading");
+    public ILocator EmptyState => page.GetByTestId("servers-empty");
+    public ILocator ServersList => page.GetByTestId("servers-list");
+
+    public ILocator AddSection => page.GetByTestId("add-server-section");
+
+    // These must match your AddResourceComponent test IDs
+    public ILocator AddInput => page.GetByTestId("add-server-input");
+    public ILocator AddButton => page.GetByTestId("add-server-button");
+
+    // -------------------------------------------------
+    // Dynamic Server Items
+    // -------------------------------------------------
+
+    public ILocator ServerItem(string serverName)
+        => page.GetByTestId($"server-item-{Sanitize(serverName)}");
+
+    public ILocator DeleteButton(string serverName)
+        => ServerItem(serverName)
+            .GetByTestId("delete-server-button");
+
+    public ILocator EditButton(string serverName)
+        => ServerItem(serverName)
+            .GetByTestId("edit-server-button");
+
+    public ILocator RenameButton(string serverName)
+        => ServerItem(serverName)
+            .GetByTestId("rename-server-button");
+
+    public ILocator CloneButton(string serverName)
+        => ServerItem(serverName)
+            .GetByTestId("clone-server-button");
+
+    // -------------------------------------------------
+    // Navigation
+    // -------------------------------------------------
+
+    public async Task GotoAsync(string baseUrl)
+    {
+        await page.GotoAsync($"{baseUrl}/servers/list");
+        await AssertLoadedAsync();
+    }
+
+    public async Task AssertLoadedAsync()
+    {
+        await Assertions.Expect(PageRoot).ToBeVisibleAsync();
+        await Assertions.Expect(PageTitle).ToBeVisibleAsync();
+    }
+
+    public async Task WaitForListAsync()
+    {
+        await Assertions.Expect(ServersList).ToBeVisibleAsync();
+    }
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public async Task AddServerAsync(string serverName)
+    {
+        await AddServer.AddAsync(serverName);
+        await Assertions.Expect(ServerItem(serverName))
+            .ToBeVisibleAsync();
+    }
+
+    public async Task DeleteServerAsync(string serverName)
+    {
+        await DeleteButton(serverName).ClickAsync();
+        await page.GetByTestId("Server-confirm-modal-confirm").ClickAsync();
+
+        await Assertions.Expect(ServerItem(serverName))
+            .Not.ToBeVisibleAsync();
+    }
+
+    public async Task OpenServerAsync(string serverName)
+    {
+        await ServerItem(serverName).ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{serverName}");
+    }
+
+    public async Task AssertServerExists(string serverName)
+    {
+        await Assertions.Expect(ServerItem(serverName))
+            .ToBeVisibleAsync();
+    }
+
+    public async Task AssertServerDoesNotExist(string serverName)
+    {
+        await Assertions.Expect(ServerItem(serverName))
+            .Not.ToBeVisibleAsync();
+    }
+
+    private static string Sanitize(string value)
+        => value.Replace(" ", "-");
+}

+ 164 - 0
Tests.E2e/PageObjectModels/ServicesListPom.cs

@@ -0,0 +1,164 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class ServicesListPom
+{
+     private readonly IPage _page;
+
+    public ServicesListPom(IPage page)
+    {
+        _page = page;
+    }
+
+    // -------------------------------------------------
+    // Root & State
+    // -------------------------------------------------
+
+    public ILocator PageRoot => _page.GetByTestId("services-page-root");
+    public ILocator PageTitle => _page.GetByTestId("services-page-title");
+
+    public ILocator Loading => _page.GetByTestId("services-loading");
+    public ILocator EmptyState => _page.GetByTestId("services-empty");
+    public ILocator ServicesList => _page.GetByTestId("services-list");
+
+    // -------------------------------------------------
+    // Add Service Component (Reusable Component Object)
+    // -------------------------------------------------
+
+    public AddResourceComponent AddService =>
+        new AddResourceComponent(_page, "service");
+
+    // -------------------------------------------------
+    // Grouping (RunsOn)
+    // -------------------------------------------------
+
+    public ILocator Group(string groupKey)
+        => _page.GetByTestId($"services-group-{SanitizeGroup(groupKey)}");
+
+    public ILocator GroupTitle(string groupKey)
+        => _page.GetByTestId($"services-group-title-{SanitizeGroup(groupKey)}");
+
+    public ILocator GroupList(string groupKey)
+        => _page.GetByTestId($"services-group-list-{SanitizeGroup(groupKey)}");
+
+    // -------------------------------------------------
+    // Individual Services
+    // -------------------------------------------------
+
+    public ILocator ServiceListItem(string name)
+        => _page.GetByTestId($"services-list-item-{Sanitize(name)}");
+
+    public ILocator ServiceCard(string name)
+        => _page.GetByTestId($"service-item-{Sanitize(name)}");
+
+    public ILocator DeleteButton(string name)
+        => ServiceCard(name).GetByTestId("delete-service-button");
+
+    public ILocator EditButton(string name)
+        => ServiceCard(name).GetByTestId("edit-service-button");
+
+    public ILocator RenameButton(string name)
+        => ServiceCard(name).GetByTestId("rename-service-button");
+
+    public ILocator CloneButton(string name)
+        => ServiceCard(name).GetByTestId("clone-service-button");
+
+    // -------------------------------------------------
+    // Navigation
+    // -------------------------------------------------
+
+    public async Task GotoAsync(string baseUrl)
+    {
+        await _page.GotoAsync($"{baseUrl}/services/list");
+        await AssertLoadedAsync();
+    }
+
+    public async Task AssertLoadedAsync()
+    {
+        await Assertions.Expect(PageRoot).ToBeVisibleAsync();
+        await Assertions.Expect(PageTitle).ToBeVisibleAsync();
+    }
+
+    public async Task WaitForListAsync()
+    {
+        await Assertions.Expect(ServicesList).ToBeVisibleAsync();
+    }
+
+    public async Task AssertEmptyAsync()
+    {
+        await Assertions.Expect(EmptyState).ToBeVisibleAsync();
+    }
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public async Task AddServiceAsync(string name)
+    {
+        await AddService.AddAsync(name);
+
+        await Assertions.Expect(ServiceCard(name))
+            .ToBeVisibleAsync();
+    }
+
+    public async Task DeleteServiceAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+
+        await _page.GetByTestId("Service-confirm-modal-confirm")
+            .ClickAsync();
+
+        await Assertions.Expect(ServiceCard(name))
+            .Not.ToBeVisibleAsync();
+    }
+
+    public async Task OpenServiceAsync(string name)
+    {
+        await ServiceCard(name).ClickAsync();
+        await _page.WaitForURLAsync($"**/resources/services/{name}");
+    }
+
+    // -------------------------------------------------
+    // Assertions
+    // -------------------------------------------------
+
+    public async Task AssertServiceExists(string name)
+    {
+        await Assertions.Expect(ServiceCard(name))
+            .ToBeVisibleAsync();
+    }
+
+    public async Task AssertServiceDoesNotExist(string name)
+    {
+        await Assertions.Expect(ServiceCard(name))
+            .Not.ToBeVisibleAsync();
+    }
+
+    public async Task AssertGroupExists(string groupKey)
+    {
+        await Assertions.Expect(Group(groupKey))
+            .ToBeVisibleAsync();
+    }
+
+    public async Task AssertServiceInGroup(string groupKey, string serviceName)
+    {
+        await Assertions.Expect(
+                GroupList(groupKey)
+                    .GetByTestId($"services-list-item-{Sanitize(serviceName)}")
+            )
+            .ToBeVisibleAsync();
+    }
+
+    // -------------------------------------------------
+    // Utilities
+    // -------------------------------------------------
+
+    private static string Sanitize(string value)
+        => value.Replace(" ", "-");
+
+    private static string SanitizeGroup(string? value)
+        => string.IsNullOrWhiteSpace(value)
+            ? "unassigned"
+            : value.Replace(" ", "-");
+}

+ 169 - 0
Tests.E2e/PageObjectModels/SystemsListPom.cs

@@ -0,0 +1,169 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class SystemsListPom
+{
+      private readonly IPage _page;
+      public AddResourceComponent AddSystem =>
+          new AddResourceComponent(_page, "system");
+
+    public SystemsListPom(IPage page)
+    {
+        _page = page;
+    }
+
+    // -------------------------------------------------
+    // Root & State
+    // -------------------------------------------------
+
+    public ILocator PageRoot => _page.GetByTestId("systems-page-root");
+    public ILocator PageTitle => _page.GetByTestId("systems-page-title");
+
+    public ILocator Loading => _page.GetByTestId("systems-loading");
+    public ILocator EmptyState => _page.GetByTestId("systems-empty");
+    public ILocator SystemsList => _page.GetByTestId("systems-list");
+
+    // -------------------------------------------------
+    // Add System Section
+    // -------------------------------------------------
+
+    public ILocator AddSection => _page.GetByTestId("add-system-section");
+
+    // These must match AddResourceComponent test IDs
+    public ILocator AddInput => _page.GetByTestId("add-system-input");
+    public ILocator AddButton => _page.GetByTestId("add-system-button");
+
+    // -------------------------------------------------
+    // Grouping (RunsOn)
+    // -------------------------------------------------
+
+    public ILocator Group(string groupKey)
+        => _page.GetByTestId($"systems-group-{SanitizeGroup(groupKey)}");
+
+    public ILocator GroupTitle(string groupKey)
+        => _page.GetByTestId($"systems-group-title-{SanitizeGroup(groupKey)}");
+
+    public ILocator GroupList(string groupKey)
+        => _page.GetByTestId($"systems-group-list-{SanitizeGroup(groupKey)}");
+
+    // -------------------------------------------------
+    // Individual Systems
+    // -------------------------------------------------
+
+    public ILocator SystemListItem(string name)
+        => _page.GetByTestId($"systems-list-item-{Sanitize(name)}");
+
+    public ILocator SystemCard(string name)
+        => _page.GetByTestId($"system-item-{Sanitize(name)}");
+
+    public ILocator DeleteButton(string name)
+        => SystemCard(name).GetByTestId("delete-system-button");
+
+    public ILocator EditButton(string name)
+        => SystemCard(name).GetByTestId("edit-system-button");
+
+    public ILocator RenameButton(string name)
+        => SystemCard(name).GetByTestId("rename-system-button");
+
+    public ILocator CloneButton(string name)
+        => SystemCard(name).GetByTestId("clone-system-button");
+
+    // -------------------------------------------------
+    // Navigation
+    // -------------------------------------------------
+
+    public async Task GotoAsync(string baseUrl)
+    {
+        await _page.GotoAsync($"{baseUrl}/systems/list");
+        await AssertLoadedAsync();
+    }
+
+    public async Task AssertLoadedAsync()
+    {
+        await Assertions.Expect(PageRoot).ToBeVisibleAsync();
+        await Assertions.Expect(PageTitle).ToBeVisibleAsync();
+    }
+
+    public async Task WaitForListAsync()
+    {
+        await Assertions.Expect(SystemsList).ToBeVisibleAsync();
+    }
+
+    public async Task AssertEmptyAsync()
+    {
+        await Assertions.Expect(EmptyState).ToBeVisibleAsync();
+    }
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public async Task AddSystemAsync(string name)
+    {
+        await AddSystem.AddAsync(name);
+        
+        await Assertions.Expect(SystemCard(name))
+            .ToBeVisibleAsync();
+    }
+
+    public async Task DeleteSystemAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+
+        await _page.GetByTestId("System-confirm-modal-confirm")
+            .ClickAsync();
+
+        await Assertions.Expect(SystemCard(name))
+            .Not.ToBeVisibleAsync();
+    }
+
+    public async Task OpenSystemAsync(string name)
+    {
+        await SystemCard(name).ClickAsync();
+        await _page.WaitForURLAsync($"**/resources/systems/{name}");
+    }
+
+    // -------------------------------------------------
+    // Assertions
+    // -------------------------------------------------
+
+    public async Task AssertSystemExists(string name)
+    {
+        await Assertions.Expect(SystemCard(name))
+            .ToBeVisibleAsync();
+    }
+
+    public async Task AssertSystemDoesNotExist(string name)
+    {
+        await Assertions.Expect(SystemCard(name))
+            .Not.ToBeVisibleAsync();
+    }
+
+    public async Task AssertGroupExists(string groupKey)
+    {
+        await Assertions.Expect(Group(groupKey))
+            .ToBeVisibleAsync();
+    }
+
+    public async Task AssertSystemInGroup(string groupKey, string systemName)
+    {
+        await Assertions.Expect(
+                GroupList(groupKey)
+                    .GetByTestId($"systems-list-item-{Sanitize(systemName)}")
+            )
+            .ToBeVisibleAsync();
+    }
+
+    // -------------------------------------------------
+    // Utilities
+    // -------------------------------------------------
+
+    private static string Sanitize(string value)
+        => value.Replace(" ", "-");
+
+    private static string SanitizeGroup(string? value)
+        => string.IsNullOrWhiteSpace(value)
+            ? "unassigned"
+            : value.Replace(" ", "-");
+}

+ 61 - 0
Tests.E2e/ServerTests.cs

@@ -0,0 +1,61 @@
+using Tests.E2e.PageObjectModels;
+using Tests.E2e.Pages;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class ServerTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) :E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    [Fact]
+    public async Task User_Can_Add_And_Delete_Server()
+    {
+        var (context, page) = await CreatePageAsync();
+        var serverName = $"e2e-server-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            // Go home
+            await page.GotoAsync(fixture.BaseUrl);
+
+            _output.WriteLine($"URL after Goto: {page.Url}");
+            
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+            
+            var hardwarePage = new HardwareTreePom(page);
+            await hardwarePage.AssertLoadedAsync();
+            await hardwarePage.GotoServersListAsync();
+
+            var serverListPage = new ServersListPom(page);
+            await serverListPage.AssertLoadedAsync();
+            await serverListPage.AddServerAsync(serverName);
+            await serverListPage.AssertServerExists(serverName);
+            await serverListPage.DeleteServerAsync(serverName);
+            await serverListPage.AssertServerDoesNotExist(serverName);
+
+            await context.CloseAsync();
+        }
+        catch (Exception ex)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+            
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+}

+ 63 - 0
Tests.E2e/ServiceTests.cs

@@ -0,0 +1,63 @@
+using Microsoft.Playwright;
+using Shared.Rcl.Systems;
+using Tests.E2e.PageObjectModels;
+using Tests.E2e.Pages;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class ServiceTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) :E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    [Fact]
+    public async Task User_Can_Add_And_Delete_System()
+    {
+        var (context, page) = await CreatePageAsync();
+
+        try
+        {
+            // Go home
+            await page.GotoAsync(fixture.BaseUrl);
+
+            _output.WriteLine($"URL after Goto: {page.Url}");
+            
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoServicesAsync();
+            
+            var systems = new ServicesListPom(page);
+            
+            var serviceName = $"e2e-service-{Guid.NewGuid():N}"[..12];
+
+            await systems.AddServiceAsync(serviceName);
+
+            await systems.AssertServiceExists(serviceName);
+
+            await systems.DeleteServiceAsync(serviceName);
+
+            await systems.AssertServiceDoesNotExist(serviceName);
+
+            await context.CloseAsync();
+        }
+        catch (Exception ex)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+            
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+}

+ 63 - 0
Tests.E2e/SystemTests.cs

@@ -0,0 +1,63 @@
+using Microsoft.Playwright;
+using Shared.Rcl.Systems;
+using Tests.E2e.PageObjectModels;
+using Tests.E2e.Pages;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class SystemTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) :E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    [Fact]
+    public async Task User_Can_Add_And_Delete_System()
+    {
+        var (context, page) = await CreatePageAsync();
+        
+        try
+        {
+            // Go home
+            await page.GotoAsync(fixture.BaseUrl);
+
+            _output.WriteLine($"URL after Goto: {page.Url}");
+            
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoSystemsAsync();
+            
+            var systems = new SystemsListPom(page);
+            
+            var systemName = $"e2e-system-{Guid.NewGuid():N}"[..12];
+
+            await systems.AddSystemAsync(systemName);
+
+            await systems.AssertSystemExists(systemName);
+
+            await systems.DeleteSystemAsync(systemName);
+
+            await systems.AssertSystemDoesNotExist(systemName);
+
+            await context.CloseAsync();
+        }
+        catch (Exception ex)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+            
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+}

+ 30 - 0
Tests.E2e/Tests.E2e.csproj

@@ -0,0 +1,30 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+    <PropertyGroup>
+        <TargetFramework>net10.0</TargetFramework>
+        <ImplicitUsings>enable</ImplicitUsings>
+        <Nullable>enable</Nullable>
+        <IsPackable>false</IsPackable>
+    </PropertyGroup>
+
+    <ItemGroup>
+        <PackageReference Include="coverlet.collector" Version="6.0.4" />
+        <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.3" />
+        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
+        <PackageReference Include="Microsoft.Playwright" Version="1.58.0" />
+        <PackageReference Include="Microsoft.Playwright.Xunit" Version="1.58.0" />
+        <PackageReference Include="Testcontainers" Version="4.10.0" />
+        <PackageReference Include="xunit" Version="2.9.3" />
+        <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
+    </ItemGroup>
+
+    <ItemGroup>
+        <Using Include="Xunit" />
+    </ItemGroup>
+
+    <ItemGroup>
+      <ProjectReference Include="..\RackPeek.Web.Viewer\RackPeek.Web.Viewer.csproj" />
+      <ProjectReference Include="..\RackPeek.Web\RackPeek.Web.csproj" />
+    </ItemGroup>
+
+</Project>