diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentFixture.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentFixture.cs
new file mode 100644
index 00000000..7c25a16d
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentFixture.cs
@@ -0,0 +1,206 @@
+using System.Text.Json;
+using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
+
+///
+/// fixture that provisions the shared, ephemeral
+/// scaffolding (a template + an area on site-a) the deploy-action E2E test
+/// class consumes via IClassFixture<DeploymentFixture>, and mints
+/// per-test instances on demand via .
+///
+///
+/// Why site-a and not a throwaway site: the deploy / enable / disable
+/// actions relay to the owning site over the Akka ClusterClient. An unknown
+/// site identifier has no registered ClusterClient, so the relay only
+/// resolves on a slow 10-second timeout (and never surfaces a fast failure toast).
+/// To exercise the real action path, the ephemeral instances must therefore live
+/// on a real, running site — site-a — rather than a fixture-created
+/// throwaway site.
+///
+///
+///
+/// Provisioning is gated on :
+/// when the dev cluster is down, sets
+/// to and returns early without
+/// touching the cluster, so the consuming tests can skip (via Skip.IfNot)
+/// and teardown becomes a no-op.
+///
+///
+///
+/// Cleanup is best-effort and swallows every error:
+/// lists the instances on site-a, deletes any whose name/unique name carries
+/// the zztest-inst- fixture prefix, then deletes the fixture's area and
+/// template. The zztest- prefix marks entities as test-owned so teardown
+/// never touches cluster-owned data.
+///
+///
+public sealed class DeploymentFixture : IAsyncLifetime
+{
+ private const string SiteAIdentifier = "site-a";
+
+ ///
+ /// Fixture-name prefix for the per-test instances minted by
+ /// ; teardown deletes site-a instances
+ /// carrying this prefix.
+ ///
+ private const string InstanceNamePrefix = "zztest-inst-";
+
+ /// Numeric id of the real, running site-a the fixture provisions onto.
+ public int SiteAId { get; private set; }
+
+ /// Id of the ephemeral template instances are created from.
+ public int TemplateId { get; private set; }
+
+ /// Id of the ephemeral area on site-a instances are placed under.
+ public int AreaId { get; private set; }
+
+ ///
+ /// Whether the dev cluster was reachable at setup. When ,
+ /// nothing was provisioned: consuming tests should skip and teardown is a no-op.
+ ///
+ public bool Available { get; private set; }
+
+ ///
+ /// Probes cluster availability and, when reachable, provisions the shared
+ /// scaffolding: resolves site-a's id, creates an ephemeral template with a
+ /// single Double Value attribute (so the template validates and is
+ /// deployable), and creates an ephemeral area on site-a. When the cluster
+ /// is unavailable, sets to and
+ /// returns without touching the cluster.
+ ///
+ public async Task InitializeAsync()
+ {
+ Available = await ClusterAvailability.IsAvailableAsync();
+ if (!Available)
+ {
+ return;
+ }
+
+ SiteAId = await CliRunner.ResolveSiteIdAsync(SiteAIdentifier);
+ TemplateId = await CliRunner.CreateTemplateAsync(CliRunner.UniqueName("deploytmpl"));
+ await CliRunner.AddAttributeAsync(TemplateId, "Value", "Double");
+ AreaId = await CliRunner.CreateAreaAsync(SiteAId, CliRunner.UniqueName("area"));
+ }
+
+ ///
+ /// Creates a fresh ephemeral instance from the fixture's template, on
+ /// site-a, under the fixture's area, and returns both its numeric
+ /// id and its server-assigned uniqueName.
+ ///
+ ///
+ /// The uniqueName is read straight off the instance create response
+ /// because it can differ from the --name passed in (the server may
+ /// area-/path-qualify it). The Topology tree renders this uniqueName in
+ /// span.tv-label, so deploy tests must locate the instance row by the
+ /// returned uniqueName, not by the name they requested.
+ ///
+ ///
+ /// The new instance's id and server-assigned uniqueName.
+ ///
+ /// The CLI failed, or the response did not carry an integer id and a
+ /// string uniqueName.
+ ///
+ public async Task<(int Id, string UniqueName)> CreateInstanceAsync()
+ {
+ var inv = System.Globalization.CultureInfo.InvariantCulture;
+ var name = CliRunner.UniqueName("inst");
+
+ using var doc = await CliRunner.RunJsonAsync(
+ "instance", "create",
+ "--name", name,
+ "--template-id", TemplateId.ToString(inv),
+ "--site-id", SiteAId.ToString(inv),
+ "--area-id", AreaId.ToString(inv));
+
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object
+ || !root.TryGetProperty("id", out var idElement)
+ || !idElement.TryGetInt32(out var id)
+ || !root.TryGetProperty("uniqueName", out var uniqueNameElement)
+ || uniqueNameElement.ValueKind != JsonValueKind.String
+ || uniqueNameElement.GetString() is not { } uniqueName)
+ {
+ throw new InvalidOperationException(
+ "'instance create' response did not contain an integer 'id' and a string "
+ + $"'uniqueName': {root.GetRawText()}");
+ }
+
+ return (id, uniqueName);
+ }
+
+ ///
+ /// Best-effort teardown: when the fixture provisioned anything, lists the
+ /// instances on site-a and deletes any whose name/unique name starts with
+ /// , then deletes the fixture's area and
+ /// template. Swallows every error so a teardown hiccup never fails the suite.
+ ///
+ public async Task DisposeAsync()
+ {
+ if (!Available)
+ {
+ return;
+ }
+
+ try
+ {
+ foreach (var instanceId in await ListFixtureInstanceIdsAsync())
+ {
+ await CliRunner.DeleteInstanceAsync(instanceId);
+ }
+ }
+ catch
+ {
+ // Best-effort teardown — never fail the suite on a cleanup hiccup.
+ }
+
+ await CliRunner.DeleteAreaAsync(AreaId);
+ await CliRunner.DeleteTemplateAsync(TemplateId);
+ }
+
+ ///
+ /// Lists site-a via instance list and returns the ids of every
+ /// instance whose name or uniqueName starts with
+ /// — the fixture-owned instances to delete.
+ ///
+ private async Task> ListFixtureInstanceIdsAsync()
+ {
+ var inv = System.Globalization.CultureInfo.InvariantCulture;
+ using var doc = await CliRunner.RunJsonAsync(
+ "instance", "list",
+ "--site-id", SiteAId.ToString(inv));
+
+ var ids = new List();
+ if (doc.RootElement.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var instance in doc.RootElement.EnumerateArray())
+ {
+ if (instance.ValueKind == JsonValueKind.Object
+ && HasFixturePrefix(instance)
+ && instance.TryGetProperty("id", out var id)
+ && id.TryGetInt32(out var instanceId))
+ {
+ ids.Add(instanceId);
+ }
+ }
+ }
+
+ return ids;
+ }
+
+ ///
+ /// Returns whether an instance JSON object's name or uniqueName
+ /// carries the fixture's .
+ ///
+ private static bool HasFixturePrefix(JsonElement instance) =>
+ StartsWithPrefix(instance, "name") || StartsWithPrefix(instance, "uniqueName");
+
+ ///
+ /// Returns whether the named string property of
+ /// starts with (ordinal comparison).
+ ///
+ private static bool StartsWithPrefix(JsonElement instance, string property) =>
+ instance.TryGetProperty(property, out var value)
+ && value.ValueKind == JsonValueKind.String
+ && (value.GetString()?.StartsWith(InstanceNamePrefix, StringComparison.Ordinal) ?? false);
+}