test(playwright): M8 import Map step + per-line diff coverage (#230)

This commit is contained in:
Joseph Doherty
2026-06-19 04:23:12 -04:00
parent 7bd081ba50
commit 529921f2de
2 changed files with 362 additions and 0 deletions
@@ -156,6 +156,55 @@ public static partial class CliRunner
return RequireId(doc, "site area create"); return RequireId(doc, "site area create");
} }
/// <summary>
/// Creates a site via <c>site create</c> and returns its new <c>id</c>.
/// </summary>
/// <param name="name">Site display name (typically from <see cref="UniqueName"/>).</param>
/// <param name="identifier">
/// Stable <c>SiteIdentifier</c> the bundle subsystem matches on. Must be unique
/// across the cluster; pass a <see cref="UniqueName"/>-derived value so the import
/// preview finds no auto-match in the destination once the source site is deleted.
/// </param>
/// <returns>The id of the newly created site.</returns>
/// <exception cref="InvalidOperationException">
/// The CLI failed, or the response did not carry an integer <c>id</c>.
/// </exception>
public static async Task<int> CreateSiteAsync(string name, string identifier)
{
using var doc = await RunJsonAsync(
"site", "create", "--name", name, "--identifier", identifier);
return RequireId(doc, "site create");
}
/// <summary>
/// Returns the ids of all sites whose <c>siteIdentifier</c> starts with
/// <paramref name="identifierPrefix"/> (ordinal), via <c>site list</c>. 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).
/// </summary>
/// <param name="identifierPrefix">The <c>siteIdentifier</c> prefix to match.</param>
public static async Task<IReadOnlyList<int>> ListSiteIdsByIdentifierPrefixAsync(string identifierPrefix)
{
using var doc = await RunJsonAsync("site", "list");
var ids = new List<int>();
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;
}
/// <summary> /// <summary>
/// Resolves a site's numeric id from its <c>siteIdentifier</c> (e.g. /// Resolves a site's numeric id from its <c>siteIdentifier</c> (e.g.
/// <c>"site-a"</c>) via <c>site list</c>. /// <c>"site-a"</c>) via <c>site list</c>.
@@ -519,6 +568,27 @@ public static partial class CliRunner
return RequireId(doc, "shared-script create"); return RequireId(doc, "shared-script create");
} }
/// <summary>
/// Updates a shared script's <c>Code</c> via <c>shared-script update</c>. The
/// CLI requires both <c>--name</c> and <c>--code</c>, 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 <c>Modified</c> and emits a per-line
/// <c>lineDiff</c>. Surfaces CLI errors (the test asserts the update succeeded).
/// </summary>
/// <param name="id">Shared script id.</param>
/// <param name="name">Shared script name (unchanged; the CLI requires it).</param>
/// <param name="code">The new script body.</param>
/// <exception cref="InvalidOperationException">The CLI failed.</exception>
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);
}
/// <summary> /// <summary>
/// Creates an LDAP→role mapping via <c>security role-mapping create</c> and returns its new <c>id</c>. /// Creates an LDAP→role mapping via <c>security role-mapping create</c> and returns its new <c>id</c>.
/// </summary> /// </summary>
@@ -659,6 +729,62 @@ public static partial class CliRunner
"--source-environment", sourceEnvironment); "--source-environment", sourceEnvironment);
} }
/// <summary>
/// Exports a Transport bundle scoped to a single site via <c>bundle export
/// --sites &lt;siteIdentifier&gt;</c>. A bundle that carries a site produces a
/// <c>RequiredSiteMapping</c> 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.
/// </summary>
/// <param name="outputPath">Destination <c>.scadabundle</c> path.</param>
/// <param name="siteIdentifier">The <c>SiteIdentifier</c> of the single site to export.</param>
/// <param name="passphrase">Encryption passphrase for the bundle.</param>
/// <param name="sourceEnvironment"><c>SourceEnvironment</c> value stamped into the bundle manifest.</param>
/// <exception cref="InvalidOperationException">The CLI failed.</exception>
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);
}
/// <summary>
/// Exports a Transport bundle scoped to a single shared script via
/// <c>bundle export --shared-scripts &lt;name&gt;</c>. A shared script carries a
/// <c>Code</c> field, so when the destination's copy is later diverged the import
/// preview classifies it as <c>Modified</c> and emits a structured per-line
/// <c>lineDiff</c> — the payload the <c>LineDiffView</c> render asserts on.
/// </summary>
/// <param name="outputPath">Destination <c>.scadabundle</c> path.</param>
/// <param name="sharedScriptName">Name of the single shared script to export.</param>
/// <param name="passphrase">Encryption passphrase for the bundle.</param>
/// <param name="sourceEnvironment"><c>SourceEnvironment</c> value stamped into the bundle manifest.</param>
/// <exception cref="InvalidOperationException">
/// The shared-script name contains a comma (it would split the comma-separated
/// <c>--shared-scripts</c> selector), or the CLI failed.
/// </exception>
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);
}
/// <summary> /// <summary>
/// Resolves a template's name from its id via <c>template list</c>. /// Resolves a template's name from its id via <c>template list</c>.
/// </summary> /// </summary>
@@ -240,4 +240,240 @@ public sealed class TransportImportTests
try { File.Delete(bundlePath); } catch { /* best-effort */ } try { File.Delete(bundlePath); } catch { /* best-effort */ }
} }
} }
/// <summary>
/// M8 Map step (#230): drive an import whose bundle carries a <c>Site</c>, so the
/// preview surfaces a <see cref="RequiredSiteMapping"/> and the wizard's Step-3
/// <strong>Map</strong> sub-section (<c>[data-testid='map-section']</c>) renders.
///
/// <para>
/// ARRANGE: create a throwaway site with a unique <c>SiteIdentifier</c>, 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
/// <see cref="ConflictKind.New"/> (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 <c>site-a</c>) is accepted and the wizard advances to
/// Confirm.
/// </para>
///
/// <para>
/// The test deliberately STOPS at the Confirm step and never clicks Apply: mapping
/// the source site onto <c>site-a</c> 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.
/// </para>
/// </summary>
[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 */ }
}
}
/// <summary>
/// M8 per-line diff (#230): drive an import where a bundled shared script's
/// <c>Code</c> diverges from the destination's, so the preview classifies it
/// <see cref="ConflictKind.Modified"/> and the row's "Field diff" carries a
/// structured <c>lineDiff</c> — rendered by the <c>LineDiffView</c> component as a
/// GitHub-style +/- list.
///
/// <para>
/// 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
/// <c>ArtifactDiff.CompareSharedScript</c> Code comparison emits a
/// <c>changes[].lineDiff</c> payload (Myers hunks with at least one <c>add</c> and
/// one <c>remove</c> for the changed line), which the diff row surfaces inside a
/// collapsed <c>&lt;details&gt;</c>.
/// </para>
///
/// <para>
/// The test STOPS at the diff step (no Apply): the assertion target is the render
/// of the +/- hunk rows, not the mutation. Expanding the <c>&lt;details&gt;</c> is
/// required because the line-diff block is not laid out until the disclosure opens.
/// </para>
/// </summary>
[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 <details>; 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 <details> ("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 */ }
}
}
} }