Răsfoiți Sursa

Add failing E2E repros for #333: case-mismatched connection endpoints

Three tests fail on staging, proving both layers of the bug:

- CLI: `rpk connections add core-sw-a ...` against resources named
  Core-SW-A persists `resource: core-sw-a` — ValidatePortReference in
  AddConnectionUseCase resolves the resource case-insensitively but
  discards its canonical Name.
- Web UI (Playwright): with a case-mismatched endpoint seeded via YAML
  import, the card port square renders title="Available" and the details
  Ports panel shows "free" with no link to the peer — PortLayout.razor
  and PortGroupVisualizer.razor compare endpoints with ==. Clicking the
  falsely-free port kills the Blazor circuit.

Fix direction: canonicalize the stored endpoint name on write, and make
the render-path comparisons OrdinalIgnoreCase for existing data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tim Jones 6 zile în urmă
părinte
comite
a04bb6304f

+ 134 - 0
Tests.E2e/ConnectionCasingTests.cs

@@ -0,0 +1,134 @@
+using System.Text.RegularExpressions;
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+/// <summary>
+///     Reproduces https://github.com/Timmoth/RackPeek/issues/333: when a stored
+///     connection endpoint differs in case from the resource name (created by
+///     `rpk connections add` or hand-edited/imported YAML), the Web UI treats
+///     the port as unconnected — the card square stays grey with an "Available"
+///     tooltip, and the details-page Ports panel shows "free" instead of a link
+///     to the peer. Clicking the supposedly free port then crashes the Blazor
+///     circuit. Both tests fail on staging until the endpoint comparisons (or
+///     the stored casing) are fixed.
+/// </summary>
+public class ConnectionCasingTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output) {
+    private readonly PlaywrightFixture _fixture = fixture;
+    private readonly ITestOutputHelper _output = output;
+
+    private static string CaseMismatchedConfig(string switchA, string switchB) =>
+        $"""
+         version: 3
+         resources:
+           - kind: Switch
+             name: {switchA}
+             ports:
+               - type: rj45
+                 speed: 1
+                 count: 2
+           - kind: Switch
+             name: {switchB}
+             ports:
+               - type: rj45
+                 speed: 1
+                 count: 2
+         connections:
+           - a:
+               resource: {switchA.ToLowerInvariant()}
+               portGroup: 0
+               portIndex: 0
+             b:
+               resource: {switchB.ToLowerInvariant()}
+               portGroup: 0
+               portIndex: 0
+             label: probe-link
+         """;
+
+    [Fact]
+    public async Task Card_Port_Square_Shows_The_Case_Mismatched_Connection() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var switchA = $"E2E-Csa-{Guid.NewGuid():N}"[..14];
+        var switchB = $"E2E-Csb-{Guid.NewGuid():N}"[..14];
+
+        try {
+            await SeedAsync(page, switchA, switchB);
+
+            await page.GotoAsync(
+                $"{_fixture.BaseUrl}/resources/hardware/{Uri.EscapeDataString(switchA)}");
+
+            var ports = new PortsPom(page);
+            ILocator square = ports.Port("switch-ports", 0, 0);
+            await Assertions.Expect(square).ToBeVisibleAsync();
+
+            // A connected port's tooltip names the peer; "Available" means the
+            // UI failed to match the stored connection to this port.
+            await Assertions.Expect(square).Not.ToHaveAttributeAsync("title", "Available");
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task Details_Ports_Panel_Links_To_The_Case_Mismatched_Peer() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var switchA = $"E2E-Cta-{Guid.NewGuid():N}"[..14];
+        var switchB = $"E2E-Ctb-{Guid.NewGuid():N}"[..14];
+
+        try {
+            await SeedAsync(page, switchA, switchB);
+
+            await page.GotoAsync(
+                $"{_fixture.BaseUrl}/resources/hardware/{Uri.EscapeDataString(switchA)}");
+
+            // A connected port renders as a NavLink to the peer's details page.
+            // Today the row renders "1 - free" instead, and clicking it kills
+            // the Blazor circuit.
+            ILocator peerLink = page.Locator(
+                $"a[href*='{Uri.EscapeDataString(switchB)}']");
+
+            await Assertions.Expect(peerLink.First).ToBeVisibleAsync();
+
+            await peerLink.First.ClickAsync();
+            await Assertions.Expect(page).ToHaveURLAsync(
+                new Regex(Regex.Escape(Uri.EscapeDataString(switchB))));
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    private async Task SeedAsync(IPage page, string switchA, string switchB) {
+        var import = new YamlImportPom(page);
+        await import.GotoAsync(_fixture.BaseUrl);
+        await import.PasteAsync(CaseMismatchedConfig(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 ====");
+    }
+}

+ 61 - 0
Tests/EndToEnd/ConnectionTests/ConnectionCasingTests.cs

@@ -0,0 +1,61 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd.ConnectionTests;
+
+// Reproduces https://github.com/Timmoth/RackPeek/issues/333 (CLI side):
+// `rpk connections add` stores the endpoint names as typed. ValidatePortReference
+// looks the resource up case-insensitively but never keeps its canonical Name,
+// so `connections add srv01 ...` against a resource named `Srv01` persists
+// `resource: srv01`. The Web UI then compares endpoints case-sensitively and
+// treats the port as unconnected.
+[Collection("Yaml CLI tests")]
+public class ConnectionCasingTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)
+    : IClassFixture<TempYamlCliFixture> {
+    private async Task<(string output, string yaml)> ExecuteAsync(params string[] args) {
+        outputHelper.WriteLine($"rpk {string.Join(" ", args)}");
+
+        var output = await YamlCliTestHost.RunAsync(
+            args,
+            fs.Root,
+            outputHelper,
+            "config.yaml");
+
+        outputHelper.WriteLine(output);
+
+        var yaml = await File.ReadAllTextAsync(Path.Combine(fs.Root, "config.yaml"));
+        return (output, yaml);
+    }
+
+    [Fact]
+    public async Task connection_endpoints_are_stored_with_the_canonical_resource_casing() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        await ExecuteAsync("switches", "add", "Core-SW-A");
+        await ExecuteAsync("switches", "add", "Core-SW-B");
+
+        await ExecuteAsync(
+            "switches", "port", "add", "Core-SW-A",
+            "--type", "rj45", "--speed", "1", "--count", "2");
+        await ExecuteAsync(
+            "switches", "port", "add", "Core-SW-B",
+            "--type", "rj45", "--speed", "1", "--count", "2");
+
+        // Endpoints typed in a different case from the stored names.
+        (var output, var yaml) = await ExecuteAsync(
+            "connections", "add",
+            "core-sw-a", "0", "0",
+            "core-sw-b", "0", "0",
+            "--label", "probe-link");
+
+        Assert.Contains("Connection created", output);
+
+        // The stored endpoint must carry the canonical resource name, or every
+        // case-sensitive consumer (PortLayout, PortGroupVisualizer, the
+        // connection modal) treats the port as free.
+        Assert.Contains("resource: Core-SW-A", yaml);
+        Assert.Contains("resource: Core-SW-B", yaml);
+        Assert.DoesNotContain("resource: core-sw-a", yaml);
+        Assert.DoesNotContain("resource: core-sw-b", yaml);
+    }
+}