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 */ } + } + } }