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 sealed 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 ──────────────────────────────────────────────────────────── // Wait for the passphrase input to disappear before asserting the diff // summary — this gives a clearer failure when decrypt/preview is slow and // prevents a race between the decrypt completion and the render of the diff. await Assertions.Expect(page.Locator("#import-passphrase")) .ToBeHiddenAsync(new() { Timeout = 20_000 }); // 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(new() { Timeout = 15_000 }); 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.Matches(@"^/audit/configuration\?bundleImportId=[0-9a-fA-F\-]{36}$", 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 */ } } } /// /// Negative path: feed a real encrypted bundle, then submit the WRONG /// passphrase at Step 2. Per /// TransportImport.razor.cs::SubmitPassphraseAsync, the importer throws /// , which /// increments _failedUnlockAttempts to 1 (below the configured /// MaxUnlockAttemptsPerSession of 3, so no lockout / no re-upload), /// sets _errorMessage = "Wrong passphrase. Please try again.", clears /// the passphrase field, and leaves _step on Passphrase. The wizard /// therefore renders the [data-testid='error-message'] alert, keeps /// #import-passphrase visible, and never reaches the Diff step — so /// [data-testid='diff-summary'] stays absent. /// /// /// We never reach the diff/apply, so the source template is deliberately NOT /// deleted before import; teardown drops it by name prefix. /// /// [SkippableFact] public async Task ImportWithWrongPassphrase_ShowsErrorAndStaysOnPassphraseStep() { Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); var tmplName = CliRunner.UniqueName("wrongpass"); var bundlePath = Path.Combine(Path.GetTempPath(), tmplName + ".scadabundle"); try { // ── ARRANGE: build + export a synthetic single-template encrypted bundle ── int tmplId = await CliRunner.CreateTemplateAsync(tmplName); await CliRunner.AddAttributeAsync(tmplId, "Value", "Double"); await CliRunner.BundleExportAsync(bundlePath, tmplId, "correct-passphrase-1", "src-env"); 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 (wrong) ──────────────────────────────────────────── await Assertions.Expect(page.Locator("#import-passphrase")) .ToBeVisibleAsync(new() { Timeout = 10_000 }); await page.Locator("#import-passphrase").FillAsync("WRONG-passphrase-xyz"); await page.Locator("button.btn.btn-primary:has-text('Unlock')").ClickAsync(); // The wrong passphrase surfaces the typed error, keeps the passphrase // input visible (still on Step 2), and never reveals the diff summary. await Assertions.Expect(page.Locator("[data-testid='error-message']")) .ToContainTextAsync("Wrong passphrase. Please try again.", new() { Timeout = 10_000 }); await Assertions.Expect(page.Locator("#import-passphrase")).ToBeVisibleAsync(); // Assert the diff step is genuinely absent (the @switch never rendered it), not merely // hidden — ToBeHiddenAsync is vacuously true for an element that doesn't exist. await Assertions.Expect(page.Locator("[data-testid='diff-summary']")).ToHaveCountAsync(0); // Secondary indicator: one failed attempt recorded (1 of MaxUnlockAttempts). await Assertions.Expect(page.Locator("[data-testid='unlock-attempts']")) .ToContainTextAsync("Failed unlock attempts: 1 of"); } finally { // The source template was never deleted (we never reached apply), so // teardown drops it by name prefix and removes the staged bundle. 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 */ } } } /// /// 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 }); // The code line-diff sits inside NESTED
(the diff-row expander wraps the // per-field "Field diff" expander), so a `:has([code-line-diff]) > summary` selector // matches BOTH summaries. Force every disclosure open via JS — deterministic and // independent of nested click-ordering — so the LineDiffView lays out and is visible. await page.EvaluateAsync("() => document.querySelectorAll('details').forEach(d => d.open = true)"); // 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 }); // Both add and remove hunks are present (≥1 each) — a genuine +/- per-line diff, // not a context-only render. (We don't assert specific line text: the exact diff // content depends on CLI-seeded source/destination bodies and is brittle to pin.) Assert.True(await page.Locator("[data-testid='line-diff-add']").CountAsync() >= 1); Assert.True(await page.Locator("[data-testid='line-diff-remove']").CountAsync() >= 1); } 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 */ } } } }