diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs
index b8462816..a58fc0ab 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs
@@ -156,6 +156,55 @@ public static partial class CliRunner
return RequireId(doc, "site area create");
}
+ ///
+ /// Creates a site via site create and returns its new id.
+ ///
+ /// Site display name (typically from ).
+ ///
+ /// Stable SiteIdentifier the bundle subsystem matches on. Must be unique
+ /// across the cluster; pass a -derived value so the import
+ /// preview finds no auto-match in the destination once the source site is deleted.
+ ///
+ /// The id of the newly created site.
+ ///
+ /// The CLI failed, or the response did not carry an integer id.
+ ///
+ public static async Task CreateSiteAsync(string name, string identifier)
+ {
+ using var doc = await RunJsonAsync(
+ "site", "create", "--name", name, "--identifier", identifier);
+ return RequireId(doc, "site create");
+ }
+
+ ///
+ /// Returns the ids of all sites whose siteIdentifier starts with
+ /// (ordinal), via site list. Used to
+ /// drop any throwaway site a Transport test created (the create helper hands back
+ /// the id, but teardown re-resolves defensively in case the test failed mid-flow).
+ ///
+ /// The siteIdentifier prefix to match.
+ public static async Task> ListSiteIdsByIdentifierPrefixAsync(string identifierPrefix)
+ {
+ using var doc = await RunJsonAsync("site", "list");
+ var ids = new List();
+ if (doc.RootElement.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var site in doc.RootElement.EnumerateArray())
+ {
+ if (site.TryGetProperty("siteIdentifier", out var idtf)
+ && idtf.ValueKind == JsonValueKind.String
+ && (idtf.GetString()?.StartsWith(identifierPrefix, StringComparison.Ordinal) ?? false)
+ && site.TryGetProperty("id", out var id)
+ && id.TryGetInt32(out var siteId))
+ {
+ ids.Add(siteId);
+ }
+ }
+ }
+
+ return ids;
+ }
+
///
/// Resolves a site's numeric id from its siteIdentifier (e.g.
/// "site-a") via site list.
@@ -519,6 +568,27 @@ public static partial class CliRunner
return RequireId(doc, "shared-script create");
}
+ ///
+ /// Updates a shared script's Code via shared-script update. The
+ /// CLI requires both --name and --code, so the (unchanged) name is
+ /// passed through alongside the new body. Used by the Transport import line-diff
+ /// test to diverge the destination's code from the bundle's after export, so the
+ /// preview classifies the shared script as Modified and emits a per-line
+ /// lineDiff. Surfaces CLI errors (the test asserts the update succeeded).
+ ///
+ /// Shared script id.
+ /// Shared script name (unchanged; the CLI requires it).
+ /// The new script body.
+ /// The CLI failed.
+ public static async Task UpdateSharedScriptCodeAsync(int id, string name, string code)
+ {
+ await RunAsync(
+ "shared-script", "update",
+ "--id", id.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ "--name", name,
+ "--code", code);
+ }
+
///
/// Creates an LDAP→role mapping via security role-mapping create and returns its new id.
///
@@ -659,6 +729,62 @@ public static partial class CliRunner
"--source-environment", sourceEnvironment);
}
+ ///
+ /// Exports a Transport bundle scoped to a single site via bundle export
+ /// --sites <siteIdentifier>. A bundle that carries a site produces a
+ /// RequiredSiteMapping in the import preview, which is what makes the
+ /// import wizard's Map step render. With the source site deleted before import,
+ /// the mapping has no auto-match, so the Map row defaults to "Create new" and the
+ /// operator must pick a target — exactly the path the Map-step test exercises.
+ ///
+ /// Destination .scadabundle path.
+ /// The SiteIdentifier of the single site to export.
+ /// Encryption passphrase for the bundle.
+ /// SourceEnvironment value stamped into the bundle manifest.
+ /// The CLI failed.
+ public static async Task BundleExportSiteAsync(
+ string outputPath, string siteIdentifier, string passphrase, string sourceEnvironment)
+ {
+ await RunAsync(
+ "bundle", "export",
+ "--output", outputPath,
+ "--passphrase", passphrase,
+ "--sites", siteIdentifier,
+ "--source-environment", sourceEnvironment);
+ }
+
+ ///
+ /// Exports a Transport bundle scoped to a single shared script via
+ /// bundle export --shared-scripts <name>. A shared script carries a
+ /// Code field, so when the destination's copy is later diverged the import
+ /// preview classifies it as Modified and emits a structured per-line
+ /// lineDiff — the payload the LineDiffView render asserts on.
+ ///
+ /// Destination .scadabundle path.
+ /// Name of the single shared script to export.
+ /// Encryption passphrase for the bundle.
+ /// SourceEnvironment value stamped into the bundle manifest.
+ ///
+ /// The shared-script name contains a comma (it would split the comma-separated
+ /// --shared-scripts selector), or the CLI failed.
+ ///
+ public static async Task BundleExportSharedScriptAsync(
+ string outputPath, string sharedScriptName, string passphrase, string sourceEnvironment)
+ {
+ if (sharedScriptName.Contains(','))
+ {
+ throw new InvalidOperationException(
+ $"Shared-script name '{sharedScriptName}' contains a comma and cannot be used with '--shared-scripts'.");
+ }
+
+ await RunAsync(
+ "bundle", "export",
+ "--output", outputPath,
+ "--passphrase", passphrase,
+ "--shared-scripts", sharedScriptName,
+ "--source-environment", sourceEnvironment);
+ }
+
///
/// Resolves a template's name from its id via template list.
///
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Transport/TransportImportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Transport/TransportImportTests.cs
index 57950e85..272de536 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Transport/TransportImportTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Transport/TransportImportTests.cs
@@ -240,4 +240,240 @@ public sealed class TransportImportTests
try { File.Delete(bundlePath); } catch { /* best-effort */ }
}
}
+
+ ///
+ /// M8 Map step (#230): drive an import whose bundle carries a Site, so the
+ /// preview surfaces a and the wizard's Step-3
+ /// Map sub-section ([data-testid='map-section']) renders.
+ ///
+ ///
+ /// ARRANGE: create a throwaway site with a unique SiteIdentifier, export a
+ /// site-scoped bundle, then DELETE the source site. With no same-identifier site
+ /// in the destination the importer finds no auto-match, so the Map row defaults to
+ /// "Create new" — the operator must engage the dropdown. The site itself diffs as
+ /// (a static Add, no blocker), so Next is enabled
+ /// throughout; the assertion value is that the Map UI renders and that picking an
+ /// existing target (the live site-a) is accepted and the wizard advances to
+ /// Confirm.
+ ///
+ ///
+ ///
+ /// The test deliberately STOPS at the Confirm step and never clicks Apply: mapping
+ /// the source site onto site-a would Overwrite the live site on apply, and
+ /// the Map-step coverage doesn't require a mutation to be proven. Reaching Confirm
+ /// with the mapping chosen is the evidence that resolving the reference unblocks the
+ /// wizard.
+ ///
+ ///
+ [SkippableFact]
+ public async Task ImportSiteBundle_RendersMapStep_AndMappingEnablesNext()
+ {
+ Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
+
+ var env = CliRunner.UniqueName("env");
+ // The SiteIdentifier doubles as the export selector and the source identifier
+ // the Map row shows; keep it inside the zztest- namespace so a unique-prefix
+ // teardown can find it even if the test fails before the in-line delete.
+ var siteIdentifier = CliRunner.UniqueName("siteid");
+ var siteName = CliRunner.UniqueName("site");
+ var pass = "pw-" + env;
+ var bundlePath = Path.Combine(Path.GetTempPath(), siteIdentifier + ".scadabundle");
+
+ // The live cluster's canonical site — the existing target the operator maps to.
+ const string ExistingTargetIdentifier = "site-a";
+
+ try
+ {
+ // ── ARRANGE: create + export a site-scoped bundle, then drop the source ──
+ await CliRunner.CreateSiteAsync(siteName, siteIdentifier);
+ await CliRunner.BundleExportSiteAsync(bundlePath, siteIdentifier, pass, env);
+
+ // Delete the source site so the destination has no same-identifier match:
+ // the Map row then has no auto-match and defaults to "Create new".
+ foreach (var id in await CliRunner.ListSiteIdsByIdentifierPrefixAsync(siteIdentifier))
+ {
+ await CliRunner.DeleteSiteAsync(id);
+ }
+
+ var page = await _fixture.NewAuthenticatedPageAsync();
+
+ // ── STEP 1: Upload ────────────────────────────────────────────────────────
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/transport/import");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ await page.Locator("#bundle-input").SetInputFilesAsync(bundlePath);
+ await Assertions.Expect(page.Locator("[data-testid='encrypted-bundle-notice']"))
+ .ToBeVisibleAsync(new() { Timeout = 15_000 });
+ await page.Locator("button.btn.btn-primary:has-text('Next')").ClickAsync();
+
+ // ── STEP 2: Passphrase ──────────────────────────────────────────────────────
+ await Assertions.Expect(page.Locator("#import-passphrase"))
+ .ToBeVisibleAsync(new() { Timeout = 10_000 });
+ await page.Locator("#import-passphrase").FillAsync(pass);
+ await page.Locator("button.btn.btn-primary:has-text('Unlock')").ClickAsync();
+
+ // ── STEP 3: Diff + Map ───────────────────────────────────────────────────────
+ await Assertions.Expect(page.Locator("#import-passphrase"))
+ .ToBeHiddenAsync(new() { Timeout = 20_000 });
+
+ // The Map sub-section renders because the bundle references a source site.
+ await Assertions.Expect(page.Locator("[data-testid='map-section']"))
+ .ToBeVisibleAsync(new() { Timeout = 15_000 });
+ await Assertions.Expect(page.Locator("[data-testid='map-sites-table']")).ToBeVisibleAsync();
+
+ // Exactly one source-site row, for the identifier the bundle carried.
+ var siteRow = page.Locator("tr[data-testid='map-site-row']");
+ await Assertions.Expect(siteRow).ToHaveCountAsync(1);
+ await Assertions.Expect(siteRow).ToContainTextAsync(siteIdentifier);
+
+ // The per-site dropdown carries a "Create new" option plus every existing
+ // destination site. Map the source onto the live site-a target.
+ var siteSelect = page.Locator($"[data-testid='map-site-select-{siteIdentifier}']");
+ await Assertions.Expect(siteSelect).ToBeVisibleAsync(new() { Timeout = 10_000 });
+ await siteSelect.SelectOptionAsync(ExistingTargetIdentifier);
+ await Assertions.Expect(siteSelect).ToHaveValueAsync(ExistingTargetIdentifier);
+
+ // The site item is a New (Add) row — no blocker — so Next stays enabled and
+ // advances to Confirm. (We do NOT apply: see the method remarks.)
+ var diffNext = page.Locator("button.btn.btn-primary:has-text('Next')");
+ await Assertions.Expect(diffNext).ToBeEnabledAsync(new() { Timeout = 15_000 });
+ await diffNext.ClickAsync();
+
+ // ── STEP 4: Confirm (reached, not submitted) ──────────────────────────────────
+ await Assertions.Expect(page.Locator("#confirm-env"))
+ .ToBeVisibleAsync(new() { Timeout = 10_000 });
+ }
+ finally
+ {
+ // Best-effort teardown: drop the source site (already deleted in the happy
+ // path, but recover if the test failed before the in-line delete) and the
+ // staged bundle. We never applied, so no destination entity was created.
+ try
+ {
+ foreach (var id in await CliRunner.ListSiteIdsByIdentifierPrefixAsync(siteIdentifier))
+ {
+ await CliRunner.DeleteSiteAsync(id);
+ }
+ }
+ catch
+ {
+ // Best-effort — never mask the test's own failure.
+ }
+
+ try { File.Delete(bundlePath); } catch { /* best-effort */ }
+ }
+ }
+
+ ///
+ /// M8 per-line diff (#230): drive an import where a bundled shared script's
+ /// Code diverges from the destination's, so the preview classifies it
+ /// and the row's "Field diff" carries a
+ /// structured lineDiff — rendered by the LineDiffView component as a
+ /// GitHub-style +/- list.
+ ///
+ ///
+ /// ARRANGE: create a shared script with a known multi-line body, export it, then
+ /// UPDATE the destination copy's code to a divergent body. On import the
+ /// ArtifactDiff.CompareSharedScript Code comparison emits a
+ /// changes[].lineDiff payload (Myers hunks with at least one add and
+ /// one remove for the changed line), which the diff row surfaces inside a
+ /// collapsed <details>.
+ ///
+ ///
+ ///
+ /// The test STOPS at the diff step (no Apply): the assertion target is the render
+ /// of the +/- hunk rows, not the mutation. Expanding the <details> is
+ /// required because the line-diff block is not laid out until the disclosure opens.
+ ///
+ ///
+ [SkippableFact]
+ public async Task ImportModifiedSharedScript_RendersPerLineDiff()
+ {
+ Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
+
+ var env = CliRunner.UniqueName("env");
+ var scriptName = CliRunner.UniqueName("shsc");
+ var pass = "pw-" + env;
+ var bundlePath = Path.Combine(Path.GetTempPath(), scriptName + ".scadabundle");
+
+ // A small multi-line body whose middle line changes between export and import.
+ // The differing line yields a remove (old) + add (new) hunk; the surrounding
+ // lines stay as context, so the render shows all three op classes.
+ const string CodeV1 = "var a = 1;\nvar b = 2;\nreturn a + b;";
+ const string CodeV2 = "var a = 1;\nvar b = 99;\nreturn a + b;";
+
+ int scriptId = 0;
+ try
+ {
+ // ── ARRANGE: create + export the shared script, then diverge the destination ──
+ scriptId = await CliRunner.CreateSharedScriptAsync(scriptName, CodeV1);
+ await CliRunner.BundleExportSharedScriptAsync(bundlePath, scriptName, pass, env);
+
+ // Update the destination's copy so the incoming (bundle) body differs — the
+ // preview now classifies the shared script as Modified with a code lineDiff.
+ await CliRunner.UpdateSharedScriptCodeAsync(scriptId, scriptName, CodeV2);
+
+ var page = await _fixture.NewAuthenticatedPageAsync();
+
+ // ── STEP 1: Upload ────────────────────────────────────────────────────────
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/transport/import");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ await page.Locator("#bundle-input").SetInputFilesAsync(bundlePath);
+ await Assertions.Expect(page.Locator("[data-testid='encrypted-bundle-notice']"))
+ .ToBeVisibleAsync(new() { Timeout = 15_000 });
+ await page.Locator("button.btn.btn-primary:has-text('Next')").ClickAsync();
+
+ // ── STEP 2: Passphrase ──────────────────────────────────────────────────────
+ await Assertions.Expect(page.Locator("#import-passphrase"))
+ .ToBeVisibleAsync(new() { Timeout = 10_000 });
+ await page.Locator("#import-passphrase").FillAsync(pass);
+ await page.Locator("button.btn.btn-primary:has-text('Unlock')").ClickAsync();
+
+ // ── STEP 3: Diff — locate the Modified shared-script row's line diff ──────────
+ await Assertions.Expect(page.Locator("#import-passphrase"))
+ .ToBeHiddenAsync(new() { Timeout = 20_000 });
+ await Assertions.Expect(page.Locator("[data-testid='diff-summary']"))
+ .ToBeVisibleAsync(new() { Timeout = 15_000 });
+
+ // The Modified row attaches a code line-diff cell. The structured payload is
+ // wrapped in [data-testid='code-line-diff'] inside a collapsed ; the
+ // line-diff block is laid out only once the disclosure is opened, so open it.
+ var codeLineDiff = page.Locator("[data-testid='code-line-diff']");
+ await Assertions.Expect(codeLineDiff).ToHaveCountAsync(1, new() { Timeout = 15_000 });
+
+ // Open the enclosing ("Field diff" summary) so the diff renders.
+ await page.Locator("details:has([data-testid='code-line-diff']) > summary").ClickAsync();
+
+ // The LineDiffView root + at least one add and one remove hunk render.
+ await Assertions.Expect(page.Locator("[data-testid='line-diff']"))
+ .ToBeVisibleAsync(new() { Timeout = 15_000 });
+ await Assertions.Expect(page.Locator("[data-testid='line-diff-add']").First)
+ .ToBeVisibleAsync(new() { Timeout = 15_000 });
+ await Assertions.Expect(page.Locator("[data-testid='line-diff-remove']").First)
+ .ToBeVisibleAsync(new() { Timeout = 15_000 });
+
+ // The new-line text (CodeV2's changed middle line) appears in an add hunk.
+ await Assertions.Expect(page.Locator("[data-testid='line-diff-add']").First)
+ .ToContainTextAsync("99");
+ }
+ finally
+ {
+ // Best-effort teardown: drop the shared script + the staged bundle. We never
+ // applied, so the destination only carries the (now-diverged) source script.
+ try
+ {
+ if (scriptId != 0)
+ {
+ await CliRunner.DeleteSharedScriptAsync(scriptId);
+ }
+ }
+ catch
+ {
+ // Best-effort — never mask the test's own failure.
+ }
+
+ try { File.Delete(bundlePath); } catch { /* best-effort */ }
+ }
+ }
}