Просмотр исходного кода

Add E2E coverage for docs, yaml import, exports and subnets

Seven routes had no E2E coverage at all, two of them the subject of
fixes shipping in 2.1.0. Adds 14 tests across four classes, taking the
suite from 73 to 87.

/docs (#304) fails silently when the host cannot read the docs assets:
the page still renders, only with a "document not found" placeholder, so
the assertions check for resolved content rather than a visible page. A
test that only checked visibility would pass against the bug.

/yaml/import (#308) asserts the connection reaches the import preview,
then applies it and reads the saved config back from /yaml rather than
trusting the preview it just asserted on.

/ssh/export, /hosts/export, /subnets and /visualise render from whatever
is in the config, so each test creates the system it asserts on — the
container starts with an empty config volume.

Tests are grouped into four classes rather than one per page because
PlaywrightFixture is an IClassFixture, so every class costs another
container boot.

Adds two data-testid hooks following the existing -value convention:
system-ip-value on the system card's read view and yaml-file-content on
the config view.

Note for future tests: Blazor's @bind commits on change, but Playwright's
FillAsync only raises input. Existing tests get away with it because they
click a Generate button afterwards, which blurs the field as a side
effect. DocsPom.SearchAsync blurs explicitly and documents why.

No E2E test for #328 — the card always passes the stored name, so a case
mismatch is not reachable through the UI. The CLI tests added with that
fix cover it at the right level.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tim Jones 17 часов назад
Родитель
Сommit
97e7efd861

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

@@ -179,7 +179,8 @@
             }
             else if (!string.IsNullOrWhiteSpace(EffectiveIp))
             {
-                <div class="text-zinc-300">
+                <div class="text-zinc-300"
+                     data-testid="system-ip-value">
                     @EffectiveIp
                     @if (string.IsNullOrWhiteSpace(System.Ip))
                     {

+ 2 - 1
Shared.Rcl/YamlFileComponent.razor

@@ -98,7 +98,8 @@
     }
     else
     {
-        <pre class="text-zinc-300 text-xs whitespace-pre-wrap">@_currentText</pre>
+        <pre class="text-zinc-300 text-xs whitespace-pre-wrap"
+             data-testid="yaml-file-content">@_currentText</pre>
     }
 </div>
 

+ 157 - 0
Tests.E2e/DocsTests.cs

@@ -0,0 +1,157 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+/// <summary>
+///     Coverage for the /docs knowledge base. The Blazor Server host reads the
+///     docs assets off disk via StaticWebAssetDocsContentProvider; fetching them
+///     over HTTP breaks behind a reverse proxy (issue #304), and the failure is
+///     silent — the page still renders, just with a "document not found"
+///     placeholder. These tests assert on resolved content for that reason.
+/// </summary>
+public class DocsTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output) {
+    private readonly PlaywrightFixture _fixture = fixture;
+    private readonly ITestOutputHelper _output = output;
+
+    // =============================================================
+    // Overview renders (issue #304 regression)
+    // =============================================================
+
+    [Fact]
+    public async Task Docs_Overview_Renders_Content_Not_A_Not_Found_Placeholder() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        try {
+            await page.GotoAsync($"{_fixture.BaseUrl}/docs");
+
+            var docs = new DocsPom(page);
+            await docs.AssertLoadedAsync();
+
+            // The index and the default overview document must both resolve.
+            await docs.AssertIndexResolvedAsync();
+            await docs.AssertContentResolvedAsync();
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Deep link renders
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Deep_Link_Straight_To_A_Docs_Page() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        try {
+            // Navigating directly (rather than clicking through) is the path a
+            // bookmark or an external link takes.
+            await page.GotoAsync($"{_fixture.BaseUrl}/docs/ssh-config-export");
+
+            var docs = new DocsPom(page);
+            await docs.AssertLoadedAsync();
+            await docs.AssertContentResolvedAsync();
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Navigation between docs
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Navigate_Between_Docs_From_The_Index() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        try {
+            await page.GotoAsync($"{_fixture.BaseUrl}/docs");
+
+            var docs = new DocsPom(page);
+            await docs.AssertLoadedAsync();
+            await docs.AssertIndexResolvedAsync();
+
+            await docs.OpenDocAsync("install-guide.md");
+            await docs.AssertContentResolvedAsync();
+
+            // A second hop exercises OnParametersSetAsync re-fetching for an
+            // already-initialised component, not just the first render.
+            await docs.OpenDocAsync("versioning.md");
+            await docs.AssertContentResolvedAsync();
+
+            // Back to the overview via the header link.
+            await docs.HomeLink.ClickAsync();
+            await page.WaitForURLAsync("**/docs");
+            await docs.AssertContentResolvedAsync();
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Sidebar search
+    // =============================================================
+
+    [Fact]
+    public async Task Docs_Search_Filters_The_Index() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        try {
+            await page.GotoAsync($"{_fixture.BaseUrl}/docs");
+
+            var docs = new DocsPom(page);
+            await docs.AssertLoadedAsync();
+            await docs.AssertIndexResolvedAsync();
+
+            var totalCount = await docs.IndexLinks.CountAsync();
+            Assert.True(totalCount > 1, $"Expected a multi-entry docs index, found {totalCount}.");
+
+            await docs.SearchAsync("ansible");
+
+            // Only the ansible guide should survive the filter.
+            await Assertions.Expect(docs.IndexLink("ansible-generator-guide.md")).ToBeVisibleAsync();
+            await Assertions.Expect(docs.IndexLink("versioning.md")).ToHaveCountAsync(0);
+
+            // Clearing restores the full index.
+            await docs.SearchAsync("");
+            await Assertions.Expect(docs.IndexLinks).ToHaveCountAsync(totalCount);
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    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 ====");
+    }
+}

+ 160 - 0
Tests.E2e/ExportTests.cs

@@ -0,0 +1,160 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+/// <summary>
+///     Coverage for the /ssh/export and /hosts/export generators. Both render
+///     from whatever is in the config, so each test creates the system it
+///     asserts on rather than relying on seed data — the container starts with
+///     an empty config volume.
+///     Kept in one class so both pages share a single container.
+/// </summary>
+public class ExportTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output) {
+    private readonly PlaywrightFixture _fixture = fixture;
+    private readonly ITestOutputHelper _output = output;
+
+    // =============================================================
+    // Hosts export — localhost defaults toggle
+    // =============================================================
+
+    [Fact]
+    public async Task Hosts_Export_Honours_The_Localhost_Defaults_Toggle() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        try {
+            await page.GotoAsync($"{_fixture.BaseUrl}/hosts/export");
+
+            var hosts = new HostsExportPom(page);
+            await hosts.AssertVisibleAsync();
+
+            // Defaults on: the loopback block is emitted.
+            await hosts.SetIncludeLocalhostAsync(true);
+            await hosts.GenerateAsync();
+            await hosts.AssertOutputContainsAsync("127.0.0.1");
+            await hosts.AssertNoWarningsAsync();
+
+            // Defaults off: it is not.
+            await hosts.SetIncludeLocalhostAsync(false);
+            await hosts.GenerateAsync();
+            await hosts.AssertOutputDoesNotContainAsync("127.0.0.1");
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Hosts export — a real system reaches the output
+    // =============================================================
+
+    [Fact]
+    public async Task Hosts_Export_Emits_An_Entry_For_A_System_With_An_Ip() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+        var name = $"e2e-hx-{Guid.NewGuid():N}"[..14];
+        const string ip = "10.42.7.21";
+
+        try {
+            await CreateSystemWithIpAsync(page, name, ip);
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/hosts/export");
+
+            var hosts = new HostsExportPom(page);
+            await hosts.AssertVisibleAsync();
+
+            await hosts.SetDomainSuffixAsync("home.local");
+            await hosts.GenerateAsync();
+
+            await hosts.AssertOutputContainsAsync(ip);
+            await hosts.AssertOutputContainsAsync(name);
+            await hosts.AssertOutputContainsAsync($"{name}.home.local");
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // SSH export — a real system reaches the output
+    // =============================================================
+
+    [Fact]
+    public async Task Ssh_Export_Emits_A_Host_Block_For_A_System_With_An_Ip() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+        var name = $"e2e-sx-{Guid.NewGuid():N}"[..14];
+        const string ip = "10.42.7.22";
+
+        try {
+            await CreateSystemWithIpAsync(page, name, ip);
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/ssh/export");
+
+            var ssh = new SshExportPom(page);
+            await ssh.AssertVisibleAsync();
+
+            await ssh.SetDefaultUserAsync("ansible");
+            await ssh.SetDefaultPortAsync("2222");
+            await ssh.SetDefaultIdentityAsync("~/.ssh/id_ed25519");
+            await ssh.GenerateAsync();
+
+            await ssh.AssertOutputContainsAsync($"Host {name}");
+            await ssh.AssertOutputContainsAsync($"HostName {ip}");
+            await ssh.AssertOutputContainsAsync("User ansible");
+            await ssh.AssertOutputContainsAsync("Port 2222");
+            await ssh.AssertOutputContainsAsync("IdentityFile ~/.ssh/id_ed25519");
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Helpers
+    // =============================================================
+
+    private async Task CreateSystemWithIpAsync(IPage page, string name, string ip) {
+        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.BeginEditAsync(name);
+        await card.IpInput(name).FillAsync(ip);
+        await card.SaveAsync(name);
+
+        await Assertions.Expect(card.IpValue(name)).ToContainTextAsync(ip);
+    }
+
+    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 ====");
+    }
+}

+ 86 - 0
Tests.E2e/PageObjectModels/DocsPom.cs

@@ -0,0 +1,86 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class DocsPom(IPage page) {
+    // -------------------------------------------------
+    // Root
+    // -------------------------------------------------
+
+    public ILocator Viewer
+        => page.GetByTestId("docs-viewer");
+
+    public ILocator HomeLink
+        => page.GetByTestId("docs-home-link");
+
+    public ILocator SearchInput
+        => page.GetByTestId("docs-search-input");
+
+    // -------------------------------------------------
+    // Index
+    // -------------------------------------------------
+
+    /// <summary>
+    ///     Every sidebar entry. Matched on the test-id prefix so the assertions
+    ///     survive docs being added to or removed from docs-index.json.
+    /// </summary>
+    public ILocator IndexLinks
+        => page.Locator("[data-testid^='docs-index-link-']");
+
+    /// <summary>
+    ///     A single sidebar entry, addressed by its source file name
+    ///     (e.g. "overview.md" -> docs-index-link-overview-md).
+    /// </summary>
+    public ILocator IndexLink(string docFileName)
+        => page.GetByTestId($"docs-index-link-{Sanitize(docFileName)}");
+
+    private static string Sanitize(string value)
+        => value.Replace(" ", "-")
+            .Replace("/", "-")
+            .Replace("\\", "-")
+            .Replace(".", "-");
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task AssertLoadedAsync() {
+        await Assertions.Expect(Viewer).ToBeVisibleAsync();
+        await Assertions.Expect(SearchInput).ToBeVisibleAsync();
+    }
+
+    public async Task OpenDocAsync(string docFileName) {
+        await IndexLink(docFileName).ClickAsync();
+        await page.WaitForURLAsync($"**/docs/{docFileName.Replace(".md", "")}");
+    }
+
+    /// <summary>
+    ///     The search box is a plain Blazor @bind, which updates on the change
+    ///     event rather than on input. Playwright's FillAsync only raises input,
+    ///     so the value must be committed with an explicit blur — elsewhere in
+    ///     the app a Generate button happens to do that as a side effect.
+    /// </summary>
+    public async Task SearchAsync(string filter) {
+        await SearchInput.FillAsync(filter);
+        await SearchInput.BlurAsync();
+    }
+
+    public async Task AssertContentContainsAsync(string text)
+        => await Assertions.Expect(Viewer).ToContainTextAsync(text);
+
+    /// <summary>
+    ///     Guards issue #304: when the Blazor Server host cannot read the docs
+    ///     assets it renders the "document not found" placeholder rather than
+    ///     failing outright, so a passing page load is not on its own proof the
+    ///     content resolved.
+    /// </summary>
+    public async Task AssertContentResolvedAsync() {
+        await Assertions.Expect(Viewer).Not.ToContainTextAsync("document not found");
+        await Assertions.Expect(Viewer).Not.ToContainTextAsync("loading documentation…");
+    }
+
+    public async Task AssertIndexResolvedAsync() {
+        await Assertions.Expect(Viewer).Not.ToContainTextAsync("docs index not found");
+        await Assertions.Expect(IndexLinks.First).ToBeVisibleAsync();
+    }
+}

+ 73 - 0
Tests.E2e/PageObjectModels/HostsExportPom.cs

@@ -0,0 +1,73 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class HostsExportPom(IPage page) {
+    // -------------------------------------------------
+    // Root
+    // -------------------------------------------------
+
+    public ILocator Page
+        => page.GetByTestId("hosts-export-page");
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public ILocator GenerateButton
+        => page.GetByTestId("generate-hosts-button");
+
+    // -------------------------------------------------
+    // Inputs
+    // -------------------------------------------------
+
+    public ILocator IncludeTagsInput
+        => page.GetByTestId("hosts-include-tags-input");
+
+    public ILocator DomainSuffixInput
+        => page.GetByTestId("hosts-domain-suffix-input");
+
+    public ILocator IncludeLocalhostCheckbox
+        => page.GetByTestId("hosts-include-localhost-checkbox");
+
+    // -------------------------------------------------
+    // Output
+    // -------------------------------------------------
+
+    public ILocator Output
+        => page.GetByTestId("hosts-output");
+
+    public ILocator WarningsContainer
+        => page.GetByTestId("hosts-warnings");
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task AssertVisibleAsync()
+        => await Assertions.Expect(Page).ToBeVisibleAsync();
+
+    public async Task SetIncludeTagsAsync(string value)
+        => await IncludeTagsInput.FillAsync(value);
+
+    public async Task SetDomainSuffixAsync(string value)
+        => await DomainSuffixInput.FillAsync(value);
+
+    public async Task SetIncludeLocalhostAsync(bool value)
+        => await IncludeLocalhostCheckbox.SetCheckedAsync(value);
+
+    public async Task GenerateAsync()
+        => await GenerateButton.ClickAsync();
+
+    public async Task<string> GetOutputTextAsync()
+        => await Output.InputValueAsync();
+
+    public async Task AssertOutputContainsAsync(string text)
+        => await Assertions.Expect(Output).ToContainTextAsync(text);
+
+    public async Task AssertOutputDoesNotContainAsync(string text)
+        => await Assertions.Expect(Output).Not.ToContainTextAsync(text);
+
+    public async Task AssertNoWarningsAsync()
+        => await Assertions.Expect(WarningsContainer).ToHaveCountAsync(0);
+}

+ 76 - 0
Tests.E2e/PageObjectModels/SshExportPom.cs

@@ -0,0 +1,76 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class SshExportPom(IPage page) {
+    // -------------------------------------------------
+    // Root
+    // -------------------------------------------------
+
+    public ILocator Page
+        => page.GetByTestId("ssh-export-page");
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public ILocator GenerateButton
+        => page.GetByTestId("generate-ssh-button");
+
+    // -------------------------------------------------
+    // Inputs
+    // -------------------------------------------------
+
+    public ILocator IncludeTagsInput
+        => page.GetByTestId("ssh-include-tags-input");
+
+    public ILocator DefaultUserInput
+        => page.GetByTestId("ssh-default-user-input");
+
+    public ILocator DefaultPortInput
+        => page.GetByTestId("ssh-default-port-input");
+
+    public ILocator DefaultIdentityInput
+        => page.GetByTestId("ssh-default-identity-input");
+
+    // -------------------------------------------------
+    // Output
+    // -------------------------------------------------
+
+    public ILocator Output
+        => page.GetByTestId("ssh-output");
+
+    public ILocator WarningsContainer
+        => page.GetByTestId("ssh-warnings");
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task AssertVisibleAsync()
+        => await Assertions.Expect(Page).ToBeVisibleAsync();
+
+    public async Task SetIncludeTagsAsync(string value)
+        => await IncludeTagsInput.FillAsync(value);
+
+    public async Task SetDefaultUserAsync(string value)
+        => await DefaultUserInput.FillAsync(value);
+
+    public async Task SetDefaultPortAsync(string value)
+        => await DefaultPortInput.FillAsync(value);
+
+    public async Task SetDefaultIdentityAsync(string value)
+        => await DefaultIdentityInput.FillAsync(value);
+
+    public async Task GenerateAsync()
+        => await GenerateButton.ClickAsync();
+
+    public async Task<string> GetOutputTextAsync()
+        => await Output.InputValueAsync();
+
+    public async Task AssertOutputContainsAsync(string text)
+        => await Assertions.Expect(Output).ToContainTextAsync(text);
+
+    public async Task AssertNoWarningsAsync()
+        => await Assertions.Expect(WarningsContainer).ToHaveCountAsync(0);
+}

+ 62 - 0
Tests.E2e/PageObjectModels/SubnetBrowserPom.cs

@@ -0,0 +1,62 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class SubnetBrowserPom(IPage page) {
+    // -------------------------------------------------
+    // Root
+    // -------------------------------------------------
+
+    public ILocator Root
+        => page.GetByTestId("subnet-browser-root");
+
+    public ILocator Title
+        => page.GetByTestId("subnet-browser-title");
+
+    public ILocator Filter
+        => page.GetByTestId("subnet-browser-filter");
+
+    // -------------------------------------------------
+    // States
+    // -------------------------------------------------
+
+    public ILocator Loading
+        => page.GetByTestId("subnet-browser-loading");
+
+    public ILocator Empty
+        => page.GetByTestId("subnet-browser-empty");
+
+    public ILocator List
+        => page.GetByTestId("subnet-browser-list");
+
+    /// <summary>
+    ///     A subnet group, addressed by its /24 key. The page groups on the
+    ///     first three octets, so 10.42.7.21 lands in the "10.42.7.x" group.
+    /// </summary>
+    public ILocator Group(string subnetKey)
+        => page.GetByTestId($"subnet-group-{subnetKey.Replace('.', '-')}");
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task AssertLoadedAsync() {
+        await Assertions.Expect(Root).ToBeVisibleAsync();
+        await Assertions.Expect(Title).ToBeVisibleAsync();
+    }
+
+    /// <summary>
+    ///     The filter binds on oninput, so no explicit blur is needed here.
+    /// </summary>
+    public async Task FilterAsync(string value)
+        => await Filter.FillAsync(value);
+
+    public async Task AssertGroupVisibleAsync(string subnetKey)
+        => await Assertions.Expect(Group(subnetKey)).ToBeVisibleAsync();
+
+    public async Task AssertGroupContainsAsync(string subnetKey, string text)
+        => await Assertions.Expect(Group(subnetKey)).ToContainTextAsync(text);
+
+    public async Task AssertEmptyAsync()
+        => await Assertions.Expect(Empty).ToBeVisibleAsync();
+}

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

@@ -93,6 +93,16 @@ public class SystemCardPom(IPage page) {
     public ILocator RamInput(string name)
         => Card(name).GetByTestId("system-ram-input");
 
+    public ILocator IpInput(string name)
+        => Card(name).GetByTestId("system-ip-input");
+
+    /// <summary>
+    ///     Read-mode IP. Renders the effective IP, which may be inherited from
+    ///     the host rather than set on the system itself.
+    /// </summary>
+    public ILocator IpValue(string name)
+        => Card(name).GetByTestId("system-ip-value");
+
     public ILocator RunsOnButton(string name)
         => Card(name).GetByTestId("system-runs-on-button");
 

+ 54 - 0
Tests.E2e/PageObjectModels/VisualisePom.cs

@@ -0,0 +1,54 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class VisualisePom(IPage page) {
+    // -------------------------------------------------
+    // Root
+    // -------------------------------------------------
+
+    public ILocator Root
+        => page.GetByTestId("visualise-page-root");
+
+    // -------------------------------------------------
+    // Tabs
+    // -------------------------------------------------
+
+    public ILocator TopologyTab
+        => page.GetByTestId("visualise-tab-topology");
+
+    public ILocator LogicalTab
+        => page.GetByTestId("visualise-tab-logical");
+
+    // -------------------------------------------------
+    // Exports
+    // -------------------------------------------------
+
+    public ILocator ExportPngButton
+        => page.GetByTestId("visualise-export-png");
+
+    public ILocator ExportSourceButton
+        => page.GetByTestId("visualise-export-source");
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task AssertLoadedAsync()
+        => await Assertions.Expect(Root).ToBeVisibleAsync();
+
+    public async Task SelectTopologyAsync() {
+        await TopologyTab.ClickAsync();
+        await page.WaitForURLAsync("**/visualise/topology");
+    }
+
+    public async Task SelectLogicalAsync() {
+        await LogicalTab.ClickAsync();
+        await page.WaitForURLAsync("**/visualise/logical");
+    }
+
+    public async Task AssertExportsEnabledAsync() {
+        await Assertions.Expect(ExportPngButton).ToBeEnabledAsync();
+        await Assertions.Expect(ExportSourceButton).ToBeEnabledAsync();
+    }
+}

+ 72 - 0
Tests.E2e/PageObjectModels/YamlImportPom.cs

@@ -0,0 +1,72 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class YamlImportPom(IPage page) {
+    // -------------------------------------------------
+    // Input
+    // -------------------------------------------------
+
+    /// <summary>
+    ///     The YAML textarea carries no test id, so it is addressed by its
+    ///     placeholder. It binds on oninput and recomputes the diff after each
+    ///     change, so filling it is enough to render the preview.
+    /// </summary>
+    public ILocator Input
+        => page.GetByPlaceholder("Paste YAML here...");
+
+    public ILocator ApplyButton
+        => page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Apply" });
+
+    // -------------------------------------------------
+    // Errors
+    // -------------------------------------------------
+
+    public ILocator Error
+        => page.GetByTestId("yaml-import-error");
+
+    public ILocator ErrorSnippet
+        => page.GetByTestId("yaml-import-error-snippet");
+
+    // -------------------------------------------------
+    // Connections preview (issue #308)
+    // -------------------------------------------------
+
+    public ILocator ConnectionsSummary
+        => page.GetByTestId("import-connections-summary");
+
+    public ILocator ConnectionsAdded
+        => page.GetByTestId("import-connection-added");
+
+    public ILocator ConnectionsRemoved
+        => page.GetByTestId("import-connection-removed");
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task GotoAsync(string baseUrl) {
+        await page.GotoAsync($"{baseUrl}/yaml/import");
+        await Assertions.Expect(Input).ToBeVisibleAsync();
+    }
+
+    public async Task PasteAsync(string yaml)
+        => await Input.FillAsync(yaml);
+
+    public async Task ApplyAsync() {
+        await Assertions.Expect(ApplyButton).ToBeEnabledAsync();
+        await ApplyButton.ClickAsync();
+    }
+
+    public async Task AssertNoErrorAsync()
+        => await Assertions.Expect(Error).ToHaveCountAsync(0);
+
+    public async Task AssertErrorShownAsync()
+        => await Assertions.Expect(Error).ToBeVisibleAsync();
+
+    public async Task AssertConnectionsSummaryVisibleAsync()
+        => await Assertions.Expect(ConnectionsSummary).ToBeVisibleAsync();
+
+    public async Task AssertConnectionAddedContainsAsync(string text)
+        => await Assertions.Expect(ConnectionsAdded.First).ToContainTextAsync(text);
+}

+ 165 - 0
Tests.E2e/SubnetAndVisualiseTests.cs

@@ -0,0 +1,165 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+/// <summary>
+///     Coverage for the two read-only overview pages, /subnets and /visualise.
+///     Both derive entirely from the resources in the config, so the tests
+///     create what they assert on. Kept in one class so they share a container.
+/// </summary>
+public class SubnetAndVisualiseTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output) {
+    private readonly PlaywrightFixture _fixture = fixture;
+    private readonly ITestOutputHelper _output = output;
+
+    // =============================================================
+    // Subnet browser
+    // =============================================================
+
+    [Fact]
+    public async Task Subnet_Browser_Groups_A_System_Under_Its_Slash_24() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+        var name = $"e2e-sn-{Guid.NewGuid():N}"[..14];
+        const string ip = "10.99.4.17";
+        const string subnet = "10.99.4.x";
+
+        try {
+            await CreateSystemWithIpAsync(page, name, ip);
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/subnets");
+
+            var subnets = new SubnetBrowserPom(page);
+            await subnets.AssertLoadedAsync();
+
+            await subnets.AssertGroupVisibleAsync(subnet);
+            await subnets.AssertGroupContainsAsync(subnet, ip);
+            await subnets.AssertGroupContainsAsync(subnet, name);
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task Subnet_Browser_Filter_Narrows_And_Can_Match_Nothing() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+        var name = $"e2e-sf-{Guid.NewGuid():N}"[..14];
+        const string ip = "10.88.3.9";
+        const string subnet = "10.88.3.x";
+
+        try {
+            await CreateSystemWithIpAsync(page, name, ip);
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/subnets");
+
+            var subnets = new SubnetBrowserPom(page);
+            await subnets.AssertLoadedAsync();
+            await subnets.AssertGroupVisibleAsync(subnet);
+
+            // A matching prefix keeps the group.
+            await subnets.FilterAsync("10.88.3");
+            await subnets.AssertGroupVisibleAsync(subnet);
+
+            // A subnet nothing lives in falls through to the empty state.
+            await subnets.FilterAsync("203.0.113");
+            await subnets.AssertEmptyAsync();
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Visualise
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Switch_Between_Visualise_Views() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        try {
+            await page.GotoAsync($"{_fixture.BaseUrl}/visualise");
+
+            var visualise = new VisualisePom(page);
+            await visualise.AssertLoadedAsync();
+
+            await visualise.SelectLogicalAsync();
+            await visualise.AssertLoadedAsync();
+
+            await visualise.SelectTopologyAsync();
+            await visualise.AssertLoadedAsync();
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task Visualise_Deep_Links_Straight_To_A_View() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        try {
+            await page.GotoAsync($"{_fixture.BaseUrl}/visualise/logical");
+
+            var visualise = new VisualisePom(page);
+            await visualise.AssertLoadedAsync();
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Helpers
+    // =============================================================
+
+    private async Task CreateSystemWithIpAsync(IPage page, string name, string ip) {
+        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.BeginEditAsync(name);
+        await card.IpInput(name).FillAsync(ip);
+        await card.SaveAsync(name);
+
+        await Assertions.Expect(card.IpValue(name)).ToContainTextAsync(ip);
+    }
+
+    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 ====");
+    }
+}

+ 164 - 0
Tests.E2e/YamlImportTests.cs

@@ -0,0 +1,164 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+/// <summary>
+///     Coverage for /yaml/import and the /yaml config view.
+///     Issue #308: connections in an imported document were parsed but silently
+///     dropped — they never showed in the preview and never reached the config.
+///     The preview assertions here are the regression guard; the /yaml
+///     round-trip proves the connection actually persisted.
+/// </summary>
+public class YamlImportTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output) {
+    private readonly PlaywrightFixture _fixture = fixture;
+    private readonly ITestOutputHelper _output = output;
+
+    private static string TwoSwitchesWithConnection(string switchA, string switchB) =>
+        $"""
+         version: 3
+         resources:
+           - kind: Switch
+             name: {switchA}
+             ports:
+               - type: rj45
+                 speed: 1
+                 count: 4
+           - kind: Switch
+             name: {switchB}
+             ports:
+               - type: rj45
+                 speed: 1
+                 count: 4
+         connections:
+           - a:
+               resource: {switchA}
+               portGroup: 0
+               portIndex: 0
+             b:
+               resource: {switchB}
+               portGroup: 0
+               portIndex: 0
+         """;
+
+    // =============================================================
+    // Connections reach the preview (issue #308 regression)
+    // =============================================================
+
+    [Fact]
+    public async Task Import_Preview_Lists_Connections_From_The_Document() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var switchA = $"e2e-ia-{Guid.NewGuid():N}"[..14];
+        var switchB = $"e2e-ib-{Guid.NewGuid():N}"[..14];
+
+        try {
+            var import = new YamlImportPom(page);
+            await import.GotoAsync(_fixture.BaseUrl);
+
+            await import.PasteAsync(TwoSwitchesWithConnection(switchA, switchB));
+
+            await import.AssertNoErrorAsync();
+
+            // Before the #308 fix this section never rendered: the connection
+            // was dropped between parsing and the diff.
+            await import.AssertConnectionsSummaryVisibleAsync();
+            await Assertions.Expect(import.ConnectionsAdded).ToHaveCountAsync(1);
+            await import.AssertConnectionAddedContainsAsync(switchA);
+            await import.AssertConnectionAddedContainsAsync(switchB);
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Applying the import persists the connection
+    // =============================================================
+
+    [Fact]
+    public async Task Applying_An_Import_Persists_Resources_And_Their_Connection() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var switchA = $"e2e-pa-{Guid.NewGuid():N}"[..14];
+        var switchB = $"e2e-pb-{Guid.NewGuid():N}"[..14];
+
+        try {
+            var import = new YamlImportPom(page);
+            await import.GotoAsync(_fixture.BaseUrl);
+
+            await import.PasteAsync(TwoSwitchesWithConnection(switchA, switchB));
+            await import.AssertNoErrorAsync();
+            await import.ApplyAsync();
+
+            // The saved config is the source of truth — read it back rather
+            // than trusting the preview we just asserted on.
+            await page.GotoAsync($"{_fixture.BaseUrl}/yaml");
+
+            ILocator yaml = page.GetByTestId("yaml-file-content");
+            await Assertions.Expect(yaml).ToBeVisibleAsync();
+
+            await Assertions.Expect(yaml).ToContainTextAsync(switchA);
+            await Assertions.Expect(yaml).ToContainTextAsync(switchB);
+            await Assertions.Expect(yaml).ToContainTextAsync("connections:");
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Malformed input is reported, not swallowed
+    // =============================================================
+
+    [Fact]
+    public async Task Import_Surfaces_An_Error_For_Malformed_Yaml() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        try {
+            var import = new YamlImportPom(page);
+            await import.GotoAsync(_fixture.BaseUrl);
+
+            // Unclosed bracket — a parser error rather than a schema violation.
+            await import.PasteAsync("""
+                                    version: 3
+                                    resources: [
+                                      - kind: Switch
+                                    """);
+
+            await import.AssertErrorShownAsync();
+
+            // A broken document must not be applyable.
+            await Assertions.Expect(import.ApplyButton).ToBeDisabledAsync();
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    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 ====");
+    }
+}