diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/TopologyAreaTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/TopologyAreaTests.cs
new file mode 100644
index 00000000..9f0d5119
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/TopologyAreaTests.cs
@@ -0,0 +1,177 @@
+using Microsoft.Playwright;
+using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
+using Xunit;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
+
+///
+/// E2E coverage for the Topology page's area authoring surface — creating an area
+/// from the toolbar "+ Area" dialog (the site-picker variant), and the inline
+/// double-click rename on an area node's label.
+///
+///
+/// Every fact opens the page through , which
+/// FIRST unchecks the #live-updates switch. The page reloads the whole tree
+/// on a 15s timer; left on, that reload collapses freshly-expanded nodes and tears
+/// down any open dialog or in-progress rename input mid-interaction. With it off
+/// the tree stays stable for the duration of the action. The helper then clicks
+/// Expand all areas so nested area nodes are rendered and locatable.
+///
+///
+///
+/// Toolbar create: "+ Area" opens CreateAreaDialog in its site-picker mode,
+/// where the FIRST <select> is the Site picker (option value = site id)
+/// and the second is the parent area. Success closes the dialog, raises an
+/// "Area '…' created." toast, and renders the new node.
+///
+///
+///
+/// Inline rename: double-clicking an area's span.tv-label swaps it for a
+/// rename input. Enter commits, Escape reverts, and blur CANCELS — so a
+/// committing fact fills the input then presses Enter on that SAME input, never
+/// clicking elsewhere first (a click would blur and silently discard the edit).
+///
+///
+///
+/// Areas these tests create/rename are ephemeral and cleaned up in a
+/// finally: the rename facts create their own area over the CLI (known id →
+/// delete by id), while the toolbar-create fact never learns the new id from the
+/// UI, so it sweeps by name prefix
+/// ( →
+/// ). A CLI read-back after each action
+/// confirms the change actually persisted, not just that the toast appeared.
+///
+///
+[Collection("Playwright")]
+public class TopologyAreaTests : IClassFixture
+{
+ private readonly PlaywrightFixture _pw;
+ private readonly DeploymentFixture _cluster;
+
+ public TopologyAreaTests(PlaywrightFixture pw, DeploymentFixture cluster)
+ {
+ _pw = pw;
+ _cluster = cluster;
+ }
+
+ [SkippableFact]
+ public async Task CreateArea_ViaToolbar_AppearsInTreeAndPersists()
+ {
+ Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
+
+ var areaName = CliRunner.UniqueName("topoarea");
+ try
+ {
+ var page = await OpenStableTopologyAsync(_pw);
+
+ await page.Locator("button:has-text('+ Area')").ClickAsync();
+ var dialog = page.Locator(".modal.show:has(h6.modal-title:has-text('New Area'))");
+ await Assertions.Expect(dialog).ToBeVisibleAsync();
+
+ // First select = Site (option value = site.Id); then the name; then Create.
+ await dialog.Locator("select").First.SelectOptionAsync(
+ new SelectOptionValue { Value = _cluster.SiteAId.ToString() });
+ await dialog.Locator("input[placeholder='Area name']").FillAsync(areaName);
+ await dialog.Locator("button.btn.btn-primary.btn-sm:has-text('Create')").ClickAsync();
+
+ await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
+ await Assertions.Expect(page.Locator($"span.tv-label:has-text('{areaName}')"))
+ .ToBeVisibleAsync(new() { Timeout = 10_000 });
+
+ var ids = await CliRunner.ListAreaIdsByNamePrefixAsync(_cluster.SiteAId, areaName);
+ Assert.Single(ids);
+ }
+ finally
+ {
+ foreach (var id in await CliRunner.ListAreaIdsByNamePrefixAsync(_cluster.SiteAId, areaName))
+ await CliRunner.DeleteAreaAsync(id);
+ }
+ }
+
+ [SkippableFact]
+ public async Task RenameArea_EnterCommits_PersistsNewName()
+ {
+ Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
+
+ var original = CliRunner.UniqueName("rnarea");
+ var renamed = CliRunner.UniqueName("rndone");
+ var areaId = await CliRunner.CreateAreaAsync(_cluster.SiteAId, original);
+ try
+ {
+ var page = await OpenStableTopologyAsync(_pw);
+
+ var label = page.Locator($"span.tv-label:has-text('{original}')");
+ await Assertions.Expect(label).ToBeVisibleAsync(new() { Timeout = 10_000 });
+ await label.DblClickAsync();
+
+ var input = page.Locator($"input[aria-label='Rename {original}']");
+ await Assertions.Expect(input).ToBeVisibleAsync();
+ await input.FillAsync(renamed);
+ // The input is @bind="_renameBuffer" with the default onchange trigger, which a
+ // raw Enter keystroke does NOT fire (onchange only fires on blur in the browser) —
+ // and blur CANCELS the rename. So dispatch a `change` event to flush the bound
+ // value to the server WITHOUT losing focus, then press Enter to run CommitRename
+ // against the now-updated _renameBuffer.
+ await input.DispatchEventAsync("change");
+ await input.PressAsync("Enter"); // commit WITHOUT blurring (blur would cancel)
+
+ await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
+ await Assertions.Expect(page.Locator($"span.tv-label:has-text('{renamed}')"))
+ .ToBeVisibleAsync(new() { Timeout = 10_000 });
+
+ Assert.Single(await CliRunner.ListAreaIdsByNamePrefixAsync(_cluster.SiteAId, renamed));
+ }
+ finally
+ {
+ await CliRunner.DeleteAreaAsync(areaId);
+ }
+ }
+
+ [SkippableFact]
+ public async Task RenameArea_Escape_RevertsToOriginalLabel()
+ {
+ Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
+
+ var original = CliRunner.UniqueName("esarea");
+ var areaId = await CliRunner.CreateAreaAsync(_cluster.SiteAId, original);
+ try
+ {
+ var page = await OpenStableTopologyAsync(_pw);
+
+ var label = page.Locator($"span.tv-label:has-text('{original}')");
+ await Assertions.Expect(label).ToBeVisibleAsync(new() { Timeout = 10_000 });
+ await label.DblClickAsync();
+
+ var input = page.Locator($"input[aria-label='Rename {original}']");
+ await Assertions.Expect(input).ToBeVisibleAsync();
+ await input.FillAsync(CliRunner.UniqueName("discarded"));
+ await input.PressAsync("Escape");
+
+ await Assertions.Expect(input).ToHaveCountAsync(0, new() { Timeout = 5_000 });
+ await Assertions.Expect(page.Locator($"span.tv-label:has-text('{original}')")).ToBeVisibleAsync();
+ Assert.Single(await CliRunner.ListAreaIdsByNamePrefixAsync(_cluster.SiteAId, original));
+ }
+ finally
+ {
+ await CliRunner.DeleteAreaAsync(areaId);
+ }
+ }
+
+ ///
+ /// Navigates to the Topology page and returns a page whose tree is stable and
+ /// fully expanded: it unchecks the #live-updates switch (stopping the 15s
+ /// timer that would otherwise reload the tree and collapse expansions / tear down
+ /// open dialogs and rename inputs), then clicks Expand all areas so nested
+ /// area nodes are rendered and locatable.
+ ///
+ private static async Task OpenStableTopologyAsync(PlaywrightFixture pw)
+ {
+ var page = await pw.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/topology");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+ var live = page.Locator("#live-updates");
+ if (await live.IsCheckedAsync()) await live.UncheckAsync(); // stop the 15s tree reload
+ await page.Locator("button[aria-label='Expand all areas']").ClickAsync();
+ return page;
+ }
+}