Explorar o código

Add E2E coverage for tag, label and connections pages

Covers the last three routes with no E2E tests, taking the suite from
87 to 96. All three had no data-testid attributes at all, so each gains
hooks following the existing conventions.

The tag and label editors on the resource cards were already covered;
where their links lead was not. The tag page is asserted to aggregate
across kinds (a hardware resource and a system on one page), and the
label page to render each resource's value with the type it detected for
it — TEXT vs NUMBER — plus its filter and sort direction.

/connections was the only route from which a connection can be made
without going through a resource card. Its fixtures are seeded through
the YAML import rather than by clicking port groups into existence.

Both pages read the whole config and tests share a container, so every
test uses a unique tag or label key rather than a fixed one.

Extracts ConnectionModalPom, keyed on the modal's base test id. The same
PortConnectionModal is mounted under two different prefixes — a card
passes "{kind}-ports-port-group" while /connections passes plain
"connections" — and PortsPom had that "-port-group" segment baked into
every locator, so it could only reach the card-hosted instance. PortsPom
now delegates to the shared POM and keeps its existing API, so no
existing test changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tim Jones hai 21 horas
pai
achega
bc3cda4b30

+ 21 - 7
Shared.Rcl/Components/LabelPage.razor

@@ -7,11 +7,13 @@
 
 <PageTitle>Label: @LabelName</PageTitle>
 
-<div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6 space-y-6">
+<div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6 space-y-6"
+     data-testid="label-page-root">
 
     <!-- Header -->
     <div class="space-y-2">
-        <h1 class="text-lg text-zinc-100">
+        <h1 class="text-lg text-zinc-100"
+            data-testid="label-page-title">
             Label: <span class="text-emerald-400">@LabelName</span>
         </h1>
     </div>
@@ -24,10 +26,12 @@
             <!-- Filter -->
             <input placeholder="filter resources..."
                    class="bg-zinc-900 border border-zinc-800 px-3 py-1 rounded focus:outline-none focus:border-emerald-600"
+                   data-testid="label-page-filter"
                    @bind="_filter"/>
 
             <!-- Sort By -->
             <select class="bg-zinc-900 border border-zinc-800 px-2 py-1 rounded"
+                    data-testid="label-page-sort"
                     @bind="_sortBy">
                 <option value="Value">Value</option>
                 <option value="Name">Name</option>
@@ -36,6 +40,7 @@
 
             <!-- Direction -->
             <button class="border border-zinc-800 px-3 py-1 rounded hover:border-emerald-600"
+                    data-testid="label-page-sort-direction"
                     @onclick="ToggleSortDirection">
                 @(_ascending ? "Asc ↑" : "Desc ↓")
             </button>
@@ -50,21 +55,29 @@
     }
     else if (_resources.Count == 0)
     {
-        <div class="text-zinc-500">no resources found for this label</div>
+        <div class="text-zinc-500"
+             data-testid="label-page-empty">
+            no resources found for this label
+        </div>
     }
     else if (!GetProcessed().Any())
     {
-        <div class="text-zinc-500">no results match your filter</div>
+        <div class="text-zinc-500"
+             data-testid="label-page-no-results">
+            no results match your filter
+        </div>
     }
     else
     {
-        <div class="space-y-3">
+        <div class="space-y-3"
+             data-testid="label-page-list">
             @foreach (var item in GetProcessed())
             {
                 var resource = item.Resource;
                 var value = item.Value;
 
-                <div class="border border-zinc-800 rounded p-3 bg-zinc-900 hover:border-emerald-700 transition">
+                <div class="border border-zinc-800 rounded p-3 bg-zinc-900 hover:border-emerald-700 transition"
+                     data-testid=@($"label-page-item-{resource.Name.Replace(" ", "-")}")>
                     <NavLink href="@GetResourceUrl(resource)"
                              class="block hover:text-emerald-300">
 
@@ -79,7 +92,8 @@
                         </div>
 
                         <!-- Label value -->
-                        <div class="mt-2 text-xs text-emerald-400 flex items-center gap-2">
+                        <div class="mt-2 text-xs text-emerald-400 flex items-center gap-2"
+                             data-testid="label-page-item-value">
                             <span>
                                 Value: @FormatValue(value)
                             </span>

+ 12 - 5
Shared.Rcl/Components/TagPage.razor

@@ -7,11 +7,13 @@
 
 <PageTitle>Tag: @TagName</PageTitle>
 
-<div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6 space-y-6">
+<div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6 space-y-6"
+     data-testid="tag-page-root">
 
     <!-- Header -->
     <div class="space-y-2">
-        <h1 class="text-lg text-zinc-100">
+        <h1 class="text-lg text-zinc-100"
+            data-testid="tag-page-title">
             Tag: <span class="text-emerald-400">@TagName</span>
         </h1>
     </div>
@@ -22,14 +24,19 @@
     }
     else if (_resources.Count == 0)
     {
-        <div class="text-zinc-500">no resources found for this tag</div>
+        <div class="text-zinc-500"
+             data-testid="tag-page-empty">
+            no resources found for this tag
+        </div>
     }
     else
     {
-        <div class="space-y-3">
+        <div class="space-y-3"
+             data-testid="tag-page-list">
             @foreach (var resource in _resources.OrderBy(r => r.Name))
             {
-                <div class="border border-zinc-800 rounded p-3 bg-zinc-900 hover:border-emerald-700 transition">
+                <div class="border border-zinc-800 rounded p-3 bg-zinc-900 hover:border-emerald-700 transition"
+                     data-testid=@($"tag-page-item-{resource.Name.Replace(" ", "-")}")>
                     <NavLink href="@GetResourceUrl(resource)"
                              class="block hover:text-emerald-300">
 

+ 3 - 1
Shared.Rcl/Connections/ConnectionsPage.razor

@@ -1,12 +1,14 @@
 @page "/connections"
 
-<div class="p-6 space-y-4">
+<div class="p-6 space-y-4"
+     data-testid="connections-page-root">
 
     <div class="text-zinc-200 text-lg">
         Connections
     </div>
 
     <button class="px-3 py-1 rounded bg-emerald-600 text-black hover:bg-emerald-500"
+            data-testid="connections-add-button"
             @onclick="OpenModal">
         Add Connection
     </button>

+ 182 - 0
Tests.E2e/ConnectionsPageTests.cs

@@ -0,0 +1,182 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+/// <summary>
+///     Coverage for the global /connections page — previously the only page in
+///     the app with no test ids at all, and the only route from which a
+///     connection can be made without going through a resource card.
+///     Fixtures are seeded through the YAML import rather than by clicking port
+///     groups into existence, which keeps the setup to one step.
+/// </summary>
+public class ConnectionsPageTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output) {
+    private readonly PlaywrightFixture _fixture = fixture;
+    private readonly ITestOutputHelper _output = output;
+
+    private const string _portType = "rj45";
+    private const string _portSpeed = "1";
+    private const int _portCount = 4;
+
+    private static string TwoSwitchesNoConnection(string switchA, string switchB) =>
+        $"""
+         version: 3
+         resources:
+           - kind: Switch
+             name: {switchA}
+             ports:
+               - type: {_portType}
+                 speed: {_portSpeed}
+                 count: {_portCount}
+           - kind: Switch
+             name: {switchB}
+             ports:
+               - type: {_portType}
+                 speed: {_portSpeed}
+                 count: {_portCount}
+         """;
+
+    // =============================================================
+    // Creating a connection from the global page
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Connect_Two_Resources_From_The_Connections_Page() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var switchA = $"e2e-cxa-{Guid.NewGuid():N}"[..14];
+        var switchB = $"e2e-cxb-{Guid.NewGuid():N}"[..14];
+
+        try {
+            await SeedSwitchesAsync(page, switchA, switchB);
+
+            var connections = new ConnectionsPagePom(page);
+            await connections.GotoAsync(_fixture.BaseUrl);
+            await connections.OpenModalAsync();
+
+            await connections.Modal.CreateConnectionAsync(
+                switchA,
+                ConnectionModalPom.GroupLabel(_portType, _portSpeed, _portCount),
+                ConnectionModalPom.PortLabel(1),
+                switchB,
+                ConnectionModalPom.GroupLabel(_portType, _portSpeed, _portCount),
+                ConnectionModalPom.PortLabel(1));
+
+            // Submitting closes the modal.
+            await connections.Modal.AssertClosedAsync();
+
+            // The saved config is the source of truth.
+            await page.GotoAsync($"{_fixture.BaseUrl}/yaml");
+
+            ILocator yaml = page.GetByTestId("yaml-file-content");
+            await Assertions.Expect(yaml).ToBeVisibleAsync();
+            await Assertions.Expect(yaml).ToContainTextAsync("connections:");
+            await Assertions.Expect(yaml).ToContainTextAsync(switchA);
+            await Assertions.Expect(yaml).ToContainTextAsync(switchB);
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Connection labels survive the round trip
+    // =============================================================
+
+    [Fact]
+    public async Task A_Connection_Label_Is_Persisted_With_The_Connection() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var switchA = $"e2e-cla-{Guid.NewGuid():N}"[..14];
+        var switchB = $"e2e-clb-{Guid.NewGuid():N}"[..14];
+        var label = $"uplink-{Guid.NewGuid():N}"[..14];
+
+        try {
+            await SeedSwitchesAsync(page, switchA, switchB);
+
+            var connections = new ConnectionsPagePom(page);
+            await connections.GotoAsync(_fixture.BaseUrl);
+            await connections.OpenModalAsync();
+
+            await connections.Modal.CreateConnectionAsync(
+                switchA,
+                ConnectionModalPom.GroupLabel(_portType, _portSpeed, _portCount),
+                ConnectionModalPom.PortLabel(2),
+                switchB,
+                ConnectionModalPom.GroupLabel(_portType, _portSpeed, _portCount),
+                ConnectionModalPom.PortLabel(2),
+                label);
+
+            await connections.Modal.AssertClosedAsync();
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/yaml");
+
+            ILocator yaml = page.GetByTestId("yaml-file-content");
+            await Assertions.Expect(yaml).ToBeVisibleAsync();
+            await Assertions.Expect(yaml).ToContainTextAsync(label);
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Opening and dismissing the modal
+    // =============================================================
+
+    [Fact]
+    public async Task Connections_Page_Opens_The_Connection_Modal() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        try {
+            var connections = new ConnectionsPagePom(page);
+            await connections.GotoAsync(_fixture.BaseUrl);
+
+            // The modal is not mounted until asked for.
+            await connections.Modal.AssertClosedAsync();
+
+            await connections.OpenModalAsync();
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Helpers
+    // =============================================================
+
+    private async Task SeedSwitchesAsync(IPage page, string switchA, string switchB) {
+        var import = new YamlImportPom(page);
+        await import.GotoAsync(_fixture.BaseUrl);
+        await import.PasteAsync(TwoSwitchesNoConnection(switchA, switchB));
+        await import.AssertNoErrorAsync();
+        await import.ApplyAsync();
+    }
+
+    private async Task DumpAsync(IPage page) {
+        _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 ====");
+    }
+}

+ 81 - 0
Tests.E2e/PageObjectModels/ConnectionModalPom.cs

@@ -0,0 +1,81 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+/// <summary>
+///     Drives PortConnectionModal, wherever it is mounted.
+///     The modal builds its test ids from its TestIdPrefix parameter, which
+///     differs by host: a resource card passes "{kind}-ports-port-group", while
+///     the global /connections page passes plain "connections". This POM is
+///     keyed on that base so both can share one implementation.
+/// </summary>
+public class ConnectionModalPom(IPage page, string baseTestId) {
+    private string Id(string suffix) => $"{baseTestId}-connection-modal-{suffix}";
+
+    public ILocator Container => page.GetByTestId(Id("container"));
+
+    public ILocator ResourceASelect => page.GetByTestId(Id("resource-a"));
+    public ILocator GroupASelect => page.GetByTestId(Id("group-a"));
+    public ILocator PortASelect => page.GetByTestId(Id("port-a"));
+
+    public ILocator ResourceBSelect => page.GetByTestId(Id("resource-b"));
+    public ILocator GroupBSelect => page.GetByTestId(Id("group-b"));
+    public ILocator PortBSelect => page.GetByTestId(Id("port-b"));
+
+    public ILocator LabelInput => page.GetByTestId(Id("label"));
+    public ILocator SubmitButton => page.GetByTestId(Id("submit"));
+    public ILocator CancelButton => page.GetByTestId(Id("cancel"));
+
+    // -------------------------------------------------
+    // Assertions
+    // -------------------------------------------------
+
+    public async Task AssertOpenAsync()
+        => await Assertions.Expect(Container).ToBeVisibleAsync();
+
+    /// <summary>
+    ///     The modal is wrapped in an @if, so when closed it is absent from the
+    ///     DOM rather than merely hidden.
+    /// </summary>
+    public async Task AssertClosedAsync()
+        => await Assertions.Expect(Container).ToHaveCountAsync(0);
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public async Task CreateConnectionAsync(
+        string resourceA,
+        string groupA,
+        string portA,
+        string resourceB,
+        string groupB,
+        string portB,
+        string? label = null) {
+        await ResourceASelect.SelectOptionAsync(new SelectOptionValue { Label = resourceA });
+        await GroupASelect.SelectOptionAsync(new SelectOptionValue { Label = groupA });
+        await PortASelect.SelectOptionAsync(new SelectOptionValue { Label = portA });
+
+        await ResourceBSelect.SelectOptionAsync(new SelectOptionValue { Label = resourceB });
+        await GroupBSelect.SelectOptionAsync(new SelectOptionValue { Label = groupB });
+        await PortBSelect.SelectOptionAsync(new SelectOptionValue { Label = portB });
+
+        if (label is not null)
+            await LabelInput.FillAsync(label);
+
+        await SubmitButton.ClickAsync();
+    }
+
+    public async Task CancelAsync()
+        => await CancelButton.ClickAsync();
+
+    // -------------------------------------------------
+    // Option label formats, as rendered by PortConnectionModal
+    // -------------------------------------------------
+
+    public static string GroupLabel(string type, string speed, int count)
+        => $"{type} — {speed} Gbps ({count})";
+
+    public static string PortLabel(int oneBasedIndex)
+        => $"Port {oneBasedIndex}";
+}

+ 37 - 0
Tests.E2e/PageObjectModels/ConnectionsPagePom.cs

@@ -0,0 +1,37 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+/// <summary>
+///     The global /connections page. It mounts PortConnectionModal with
+///     TestIdPrefix="connections" — note that is the modal's base directly,
+///     without the "-port-group" segment a resource card contributes.
+/// </summary>
+public class ConnectionsPagePom(IPage page) {
+    private const string _modalBaseTestId = "connections";
+
+    public ConnectionModalPom Modal => new(page, _modalBaseTestId);
+
+    public ILocator Root
+        => page.GetByTestId("connections-page-root");
+
+    public ILocator AddButton
+        => page.GetByTestId("connections-add-button");
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task GotoAsync(string baseUrl) {
+        await page.GotoAsync($"{baseUrl}/connections");
+        await AssertLoadedAsync();
+    }
+
+    public async Task AssertLoadedAsync()
+        => await Assertions.Expect(Root).ToBeVisibleAsync();
+
+    public async Task OpenModalAsync() {
+        await AddButton.ClickAsync();
+        await Modal.AssertOpenAsync();
+    }
+}

+ 103 - 0
Tests.E2e/PageObjectModels/LabelPagePom.cs

@@ -0,0 +1,103 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+/// <summary>
+///     The /labels/{LabelName} detail page — everything carrying one label key,
+///     with that resource's value for it. Distinct from <see cref="LabelsPom" />,
+///     which drives the label editor on a resource card.
+/// </summary>
+public class LabelPagePom(IPage page) {
+    public ILocator Root
+        => page.GetByTestId("label-page-root");
+
+    public ILocator Title
+        => page.GetByTestId("label-page-title");
+
+    public ILocator List
+        => page.GetByTestId("label-page-list");
+
+    public ILocator Empty
+        => page.GetByTestId("label-page-empty");
+
+    public ILocator NoResults
+        => page.GetByTestId("label-page-no-results");
+
+    public ILocator Filter
+        => page.GetByTestId("label-page-filter");
+
+    public ILocator SortSelect
+        => page.GetByTestId("label-page-sort");
+
+    public ILocator SortDirectionButton
+        => page.GetByTestId("label-page-sort-direction");
+
+    public ILocator Item(string resourceName)
+        => page.GetByTestId($"label-page-item-{resourceName.Replace(" ", "-")}");
+
+    public ILocator ItemValue(string resourceName)
+        => Item(resourceName).GetByTestId("label-page-item-value");
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task GotoAsync(string baseUrl, string labelKey) {
+        await page.GotoAsync($"{baseUrl}/labels/{Uri.EscapeDataString(labelKey)}");
+        await AssertLoadedAsync();
+    }
+
+    public async Task AssertLoadedAsync()
+        => await Assertions.Expect(Root).ToBeVisibleAsync();
+
+    public async Task AssertTitleContainsAsync(string labelKey)
+        => await Assertions.Expect(Title).ToContainTextAsync(labelKey);
+
+    public async Task AssertListsAsync(string resourceName)
+        => await Assertions.Expect(Item(resourceName)).ToBeVisibleAsync();
+
+    public async Task AssertDoesNotListAsync(string resourceName)
+        => await Assertions.Expect(Item(resourceName)).ToHaveCountAsync(0);
+
+    /// <summary>
+    ///     Asserts the resource's value for this label, plus the type the page
+    ///     detected for it (TEXT / NUMBER / BOOL / DATE).
+    /// </summary>
+    public async Task AssertValueAsync(string resourceName, string value, string detectedType) {
+        await Assertions.Expect(ItemValue(resourceName)).ToContainTextAsync(value);
+        await Assertions.Expect(ItemValue(resourceName)).ToContainTextAsync(detectedType);
+    }
+
+    /// <summary>
+    ///     The filter is a plain @bind, committing on change rather than input,
+    ///     so the value needs an explicit blur — see DocsPom.SearchAsync.
+    /// </summary>
+    public async Task FilterAsync(string value) {
+        await Filter.FillAsync(value);
+        await Filter.BlurAsync();
+    }
+
+    public async Task SortByAsync(string option)
+        => await SortSelect.SelectOptionAsync(option);
+
+    public async Task ToggleSortDirectionAsync()
+        => await SortDirectionButton.ClickAsync();
+
+    public async Task AssertNoResultsAsync()
+        => await Assertions.Expect(NoResults).ToBeVisibleAsync();
+
+    /// <summary>
+    ///     Names of the listed resources, in render order.
+    /// </summary>
+    public async Task<IReadOnlyList<string>> ListedOrderAsync() {
+        var ids = await List.Locator("[data-testid^='label-page-item-']")
+            .EvaluateAllAsync<string[]>(
+                "els => els.map(e => e.getAttribute('data-testid'))");
+
+        return ids
+            .Where(id => id.StartsWith("label-page-item-", StringComparison.Ordinal))
+            .Select(id => id["label-page-item-".Length..])
+            .Where(name => name != "value")
+            .ToList();
+    }
+}

+ 19 - 33
Tests.E2e/PageObjectModels/PortsPom.cs

@@ -50,32 +50,39 @@ public class PortsPom(IPage page) {
     // Connection Modal
     // -------------------------------------------------
 
+    /// <summary>
+    ///     A resource card mounts PortConnectionModal under its ports prefix,
+    ///     so the modal's own test ids are built from "{prefix}-port-group".
+    /// </summary>
+    public ConnectionModalPom ConnectionModalFor(string testIdPrefix)
+        => new(page, $"{testIdPrefix}-port-group");
+
     public ILocator ConnectionModal(string testIdPrefix)
-        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-container");
+        => ConnectionModalFor(testIdPrefix).Container;
 
     public ILocator ResourceASelect(string testIdPrefix)
-        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-resource-a");
+        => ConnectionModalFor(testIdPrefix).ResourceASelect;
 
     public ILocator GroupASelect(string testIdPrefix)
-        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-group-a");
+        => ConnectionModalFor(testIdPrefix).GroupASelect;
 
     public ILocator PortASelect(string testIdPrefix)
-        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-port-a");
+        => ConnectionModalFor(testIdPrefix).PortASelect;
 
     public ILocator ResourceBSelect(string testIdPrefix)
-        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-resource-b");
+        => ConnectionModalFor(testIdPrefix).ResourceBSelect;
 
     public ILocator GroupBSelect(string testIdPrefix)
-        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-group-b");
+        => ConnectionModalFor(testIdPrefix).GroupBSelect;
 
     public ILocator PortBSelect(string testIdPrefix)
-        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-port-b");
+        => ConnectionModalFor(testIdPrefix).PortBSelect;
 
     public ILocator SubmitConnection(string testIdPrefix)
-        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-submit");
+        => ConnectionModalFor(testIdPrefix).SubmitButton;
 
     public ILocator LabelInput(string testIdPrefix)
-        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-label");
+        => ConnectionModalFor(testIdPrefix).LabelInput;
 
     // -------------------------------------------------
     // Assertions
@@ -109,30 +116,9 @@ public class PortsPom(IPage page) {
         string resourceB,
         string groupB,
         string portB,
-        string? label = null) {
-        await ResourceASelect(prefix).SelectOptionAsync(
-            new SelectOptionValue { Label = resourceA });
-
-        await GroupASelect(prefix).SelectOptionAsync(
-            new SelectOptionValue { Label = groupA });
-
-        await PortASelect(prefix).SelectOptionAsync(
-            new SelectOptionValue { Label = portA });
-
-        await ResourceBSelect(prefix).SelectOptionAsync(
-            new SelectOptionValue { Label = resourceB });
-
-        await GroupBSelect(prefix).SelectOptionAsync(
-            new SelectOptionValue { Label = groupB });
-
-        await PortBSelect(prefix).SelectOptionAsync(
-            new SelectOptionValue { Label = portB });
-
-        if (label is not null)
-            await LabelInput(prefix).FillAsync(label);
-
-        await SubmitConnection(prefix).ClickAsync();
-    }
+        string? label = null)
+        => await ConnectionModalFor(prefix).CreateConnectionAsync(
+            resourceA, groupA, portA, resourceB, groupB, portB, label);
 
     // -------------------------------------------------
     // Port Modal Fields

+ 57 - 0
Tests.E2e/PageObjectModels/TagPagePom.cs

@@ -0,0 +1,57 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+/// <summary>
+///     The /tags/{TagName} detail page — everything carrying one tag.
+///     Distinct from <see cref="TagsPom" />, which drives the tag editor on a
+///     resource card.
+/// </summary>
+public class TagPagePom(IPage page) {
+    public ILocator Root
+        => page.GetByTestId("tag-page-root");
+
+    public ILocator Title
+        => page.GetByTestId("tag-page-title");
+
+    public ILocator List
+        => page.GetByTestId("tag-page-list");
+
+    public ILocator Empty
+        => page.GetByTestId("tag-page-empty");
+
+    public ILocator Item(string resourceName)
+        => page.GetByTestId($"tag-page-item-{resourceName.Replace(" ", "-")}");
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task GotoAsync(string baseUrl, string tag) {
+        await page.GotoAsync($"{baseUrl}/tags/{Uri.EscapeDataString(tag)}");
+        await AssertLoadedAsync();
+    }
+
+    public async Task AssertLoadedAsync()
+        => await Assertions.Expect(Root).ToBeVisibleAsync();
+
+    public async Task AssertTitleContainsAsync(string tag)
+        => await Assertions.Expect(Title).ToContainTextAsync(tag);
+
+    public async Task AssertListsAsync(string resourceName)
+        => await Assertions.Expect(Item(resourceName)).ToBeVisibleAsync();
+
+    public async Task AssertDoesNotListAsync(string resourceName)
+        => await Assertions.Expect(Item(resourceName)).ToHaveCountAsync(0);
+
+    public async Task AssertEmptyAsync()
+        => await Assertions.Expect(Empty).ToBeVisibleAsync();
+
+    /// <summary>
+    ///     Follows the link on a listed resource through to its card.
+    /// </summary>
+    public async Task OpenResourceAsync(string resourceName) {
+        await Item(resourceName).GetByRole(AriaRole.Link).First.ClickAsync();
+        await page.WaitForURLAsync($"**/resources/**/{resourceName}");
+    }
+}

+ 282 - 0
Tests.E2e/TagAndLabelPageTests.cs

@@ -0,0 +1,282 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+/// <summary>
+///     Coverage for /tags/{TagName} and /labels/{LabelName} — the pages that
+///     aggregate resources across kinds. The tag and label editors on the cards
+///     were already covered; where those links lead was not.
+///     Both pages read the whole config and tests share a container, so every
+///     test uses a unique tag or label key to stay deterministic.
+/// </summary>
+public class TagAndLabelPageTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output) {
+    private readonly PlaywrightFixture _fixture = fixture;
+    private readonly ITestOutputHelper _output = output;
+
+    private static string Unique(string prefix) => $"{prefix}{Guid.NewGuid():N}"[..14];
+
+    // =============================================================
+    // Tag page — aggregation across kinds
+    // =============================================================
+
+    [Fact]
+    public async Task Tag_Page_Lists_Resources_Of_Every_Kind_Carrying_The_Tag() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var tag = Unique("e2etag");
+        var serverName = Unique("e2e-tsv-");
+        var systemName = Unique("e2e-tsy-");
+
+        try {
+            await CreateServerWithTagAsync(page, serverName, tag);
+            await CreateSystemWithTagAsync(page, systemName, tag);
+
+            var tagPage = new TagPagePom(page);
+            await tagPage.GotoAsync(_fixture.BaseUrl, tag);
+
+            await tagPage.AssertTitleContainsAsync(tag);
+
+            // A hardware resource and a system, aggregated onto one page.
+            await tagPage.AssertListsAsync(serverName);
+            await tagPage.AssertListsAsync(systemName);
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Tag page — click-through from a card, and back out again
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Click_A_Card_Tag_Through_To_The_Tag_Page_And_Back() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var tag = Unique("e2enav");
+        var serverName = Unique("e2e-tnv-");
+
+        try {
+            await CreateServerWithTagAsync(page, serverName, tag);
+
+            // Follow the tag chip on the card.
+            var card = new ServerCardPom(page);
+            await card.Tags.NavigateToTagAsync("server", tag);
+
+            var tagPage = new TagPagePom(page);
+            await tagPage.AssertLoadedAsync();
+            await tagPage.AssertListsAsync(serverName);
+
+            // And back out to the resource it points at.
+            await tagPage.OpenResourceAsync(serverName);
+            await card.AssertVisibleAsync(serverName);
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task Tag_Page_Reports_An_Unused_Tag_As_Empty() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        try {
+            var tagPage = new TagPagePom(page);
+            await tagPage.GotoAsync(_fixture.BaseUrl, Unique("e2enone"));
+
+            await tagPage.AssertEmptyAsync();
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Label page — values and detected types
+    // =============================================================
+
+    [Fact]
+    public async Task Label_Page_Shows_Each_Resource_Value_And_Its_Detected_Type() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var labelKey = Unique("e2elbl");
+        var textServer = Unique("e2e-ltx-");
+        var numberServer = Unique("e2e-lnm-");
+
+        try {
+            await CreateServerWithLabelAsync(page, textServer, labelKey, "production");
+            await CreateServerWithLabelAsync(page, numberServer, labelKey, "42");
+
+            var labelPage = new LabelPagePom(page);
+            await labelPage.GotoAsync(_fixture.BaseUrl, labelKey);
+
+            await labelPage.AssertTitleContainsAsync(labelKey);
+
+            // The page classifies each value, not just displays it.
+            await labelPage.AssertValueAsync(textServer, "production", "TEXT");
+            await labelPage.AssertValueAsync(numberServer, "42", "NUMBER");
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Label page — filtering
+    // =============================================================
+
+    [Fact]
+    public async Task Label_Page_Filter_Narrows_The_List_And_Can_Match_Nothing() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var labelKey = Unique("e2efil");
+        var keptServer = Unique("e2e-lkp-");
+        var filteredServer = Unique("e2e-lfl-");
+
+        try {
+            await CreateServerWithLabelAsync(page, keptServer, labelKey, "alpha");
+            await CreateServerWithLabelAsync(page, filteredServer, labelKey, "beta");
+
+            var labelPage = new LabelPagePom(page);
+            await labelPage.GotoAsync(_fixture.BaseUrl, labelKey);
+
+            await labelPage.AssertListsAsync(keptServer);
+            await labelPage.AssertListsAsync(filteredServer);
+
+            // The filter matches on the label value as well as the name.
+            await labelPage.FilterAsync("alpha");
+            await labelPage.AssertListsAsync(keptServer);
+            await labelPage.AssertDoesNotListAsync(filteredServer);
+
+            // Nothing matching falls through to its own state, not an empty list.
+            await labelPage.FilterAsync(Unique("nomatch"));
+            await labelPage.AssertNoResultsAsync();
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Label page — sort direction
+    // =============================================================
+
+    [Fact]
+    public async Task Label_Page_Sort_Direction_Reverses_The_List() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var labelKey = Unique("e2esrt");
+        var lowServer = Unique("e2e-slo-");
+        var highServer = Unique("e2e-shi-");
+
+        try {
+            // Numeric values so the default "Value" sort is unambiguous.
+            await CreateServerWithLabelAsync(page, lowServer, labelKey, "1");
+            await CreateServerWithLabelAsync(page, highServer, labelKey, "2");
+
+            var labelPage = new LabelPagePom(page);
+            await labelPage.GotoAsync(_fixture.BaseUrl, labelKey);
+
+            IReadOnlyList<string> ascending = await labelPage.ListedOrderAsync();
+            Assert.Equal(new[] { lowServer, highServer }, ascending);
+
+            await labelPage.ToggleSortDirectionAsync();
+
+            IReadOnlyList<string> descending = await labelPage.ListedOrderAsync();
+            Assert.Equal(new[] { highServer, lowServer }, descending);
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Helpers
+    // =============================================================
+
+    private async Task CreateServerAsync(IPage page, string name) {
+        await page.GotoAsync($"{_fixture.BaseUrl}/servers/list");
+
+        var list = new ServersListPom(page);
+        await list.AssertLoadedAsync();
+        await list.AddServerAsync(name);
+
+        if (!page.Url.Contains($"/resources/hardware/{name}", StringComparison.OrdinalIgnoreCase))
+            await list.OpenServerAsync(name);
+
+        var card = new ServerCardPom(page);
+        await card.AssertVisibleAsync(name);
+    }
+
+    private async Task CreateServerWithTagAsync(IPage page, string name, string tag) {
+        await CreateServerAsync(page, name);
+
+        var card = new ServerCardPom(page);
+        await card.Tags.AddTagsAsync("server", tag);
+        await card.Tags.AssertTagVisibleAsync("server", tag);
+    }
+
+    private async Task CreateServerWithLabelAsync(IPage page, string name, string key, string value) {
+        await CreateServerAsync(page, name);
+
+        var card = new ServerCardPom(page);
+        await card.Labels.AddLabelAsync("server", key, value);
+        await card.Labels.AssertLabelVisibleAsync("server", key);
+    }
+
+    private async Task CreateSystemWithTagAsync(IPage page, string name, string tag) {
+        await page.GotoAsync($"{_fixture.BaseUrl}/systems/list");
+
+        var list = new SystemsListPom(page);
+        await list.AssertLoadedAsync();
+        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.Tags.AddTagsAsync("system", tag);
+        await card.Tags.AssertTagVisibleAsync("system", tag);
+    }
+
+    private async Task DumpAsync(IPage page) {
+        _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 ====");
+    }
+}