From 9cc5b7355ea9cbc041491458911260876ec7897e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 5 Jun 2026 10:38:42 -0400 Subject: [PATCH] test(e2e): cover Transport Import apply round-trip --- .../Transport/TransportImportTests.cs | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Transport/TransportImportTests.cs diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Transport/TransportImportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Transport/TransportImportTests.cs new file mode 100644 index 00000000..4fb4763c --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Transport/TransportImportTests.cs @@ -0,0 +1,153 @@ +using Microsoft.Playwright; +using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Transport; + +/// +/// End-to-end round-trip for the Transport Import wizard (Component #24, Task T22). +/// +/// +/// The test exercises the full five-step Apply path at +/// /design/transport/import against the running dev cluster, using a +/// synthetic single-template bundle the test itself exports via the CLI: +/// +/// +/// Create a throwaway template (one Double attribute) and export it to an +/// encrypted .scadabundle on the host temp dir. +/// Delete the source template so the importer sees the bundle's template +/// as — it renders as a static Add +/// row with no blocker, so the diff step has nothing to resolve. +/// Drive the wizard: upload → passphrase → diff (Next) → confirm +/// (type the source-environment name) → Apply → assert the success +/// summary and the audit drill-in link. +/// +/// +/// +/// Remote file upload note: the Playwright browser runs in a Docker container +/// (ws://localhost:3000) with no host-filesystem volume mount, so the +/// staged bundle on the host is NOT visible to the remote browser as a path. +/// Playwright's +/// handles this transparently: the Playwright client (running on the host, in this +/// test process) reads the file and streams its bytes over the WebSocket to the +/// remote browser, which materialises it for the <input type=file>. +/// This test is the de-risking evidence that the path-based overload works over a +/// WS-connected remote browser. +/// +/// +[Collection("Playwright")] +public class TransportImportTests +{ + private readonly PlaywrightFixture _fixture; + + public TransportImportTests(PlaywrightFixture fixture) + { + _fixture = fixture; + } + + [SkippableFact] + public async Task ImportSyntheticBundle_AppliesAndShowsAuditDrillIn() + { + Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); + + // The source-environment name doubles as the Step-4 confirm phrase, so it + // must round-trip verbatim through the bundle manifest and back into the UI. + var env = CliRunner.UniqueName("env"); + var tmplName = CliRunner.UniqueName("imp"); + var pass = "pw-" + env; + + // Stage the exported bundle on the host temp dir. SetInputFilesAsync streams + // the bytes to the remote browser, so the path need not be visible inside + // the Playwright container. + var bundlePath = Path.Combine(Path.GetTempPath(), tmplName + ".scadabundle"); + + try + { + // ── ARRANGE: build + export a synthetic single-template bundle ───────────── + int tmplId = await CliRunner.CreateTemplateAsync(tmplName); + await CliRunner.AddAttributeAsync(tmplId, "Value", "Double"); + await CliRunner.BundleExportAsync(bundlePath, tmplId, pass, env); + + // Delete the source so the import diff classifies the bundle's template + // as New (Add) rather than Identical/Modified — no blocker, no conflict + // resolution required, Apply proceeds cleanly. + await CliRunner.DeleteTemplateAsync(tmplId); + + var page = await _fixture.NewAuthenticatedPageAsync(); + + // ── STEP 1: Upload ──────────────────────────────────────────────────────── + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/transport/import"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // De-risk the remote upload: stream the host file to the container browser. + await page.Locator("#bundle-input").SetInputFilesAsync(bundlePath); + + // The bundle is encrypted (we exported with a passphrase), so the page + // peeks the manifest with no passphrase, fails to decrypt, and shows the + // encrypted-bundle notice instead of the (unencrypted-only) manifest + // summary. Either way, the Step-1 Next button only renders once the file + // was read successfully — its appearance proves SetInputFiles worked. + 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 ──────────────────────────────────────────────────────────── + // A brand-new template renders as a static Add item with no blocker, so + // the diff summary appears and the Next button is enabled. + await Assertions.Expect(page.Locator("[data-testid='diff-summary']")) + .ToBeVisibleAsync(new() { Timeout = 15_000 }); + + var diffNext = page.Locator("button.btn.btn-primary:has-text('Next')"); + await Assertions.Expect(diffNext).ToBeEnabledAsync(); + await diffNext.ClickAsync(); + + // ── STEP 4: Confirm ────────────────────────────────────────────────────────── + // The Apply button is disabled until the typed text matches the bundle's + // source-environment name exactly. + await Assertions.Expect(page.Locator("#confirm-env")) + .ToBeVisibleAsync(new() { Timeout = 10_000 }); + await page.Locator("#confirm-env").FillAsync(env); + + var applyBtn = page.Locator("button.btn.btn-danger:has-text('Apply Import')"); + await Assertions.Expect(applyBtn).ToBeEnabledAsync(); + await applyBtn.ClickAsync(); + + // ── STEP 5: Result ─────────────────────────────────────────────────────────── + var resultSummary = page.Locator("div.alert.alert-success[data-testid='result-summary']"); + await Assertions.Expect(resultSummary).ToBeVisibleAsync(new() { Timeout = 20_000 }); + await Assertions.Expect(resultSummary).ToContainTextAsync("Import complete."); + + // The audit drill-in link must carry the correlated BundleImportId. + var auditLink = page.Locator("a:has-text('Audit trail →')"); + await Assertions.Expect(auditLink).ToBeVisibleAsync(); + var href = await auditLink.GetAttributeAsync("href"); + Assert.NotNull(href); + Assert.StartsWith("/audit/configuration?bundleImportId=", href); + } + finally + { + // Best-effort teardown: drop any template the test left behind (the source + // template, plus the one created by a successful import — both carry the + // tmplName prefix) and delete the staged bundle file. + try + { + foreach (var id in await CliRunner.ListTemplateIdsByNamePrefixAsync(tmplName)) + { + await CliRunner.DeleteTemplateAsync(id); + } + } + catch + { + // Best-effort — never mask the test's own failure. + } + + try { File.Delete(bundlePath); } catch { /* best-effort */ } + } + } +}