test(playwright): M8 import Map step + per-line diff coverage (#230)
This commit is contained in:
@@ -156,6 +156,55 @@ public static partial class CliRunner
|
||||
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>
|
||||
/// Resolves a site's numeric id from its <c>siteIdentifier</c> (e.g.
|
||||
/// <c>"site-a"</c>) via <c>site list</c>.
|
||||
@@ -519,6 +568,27 @@ public static partial class CliRunner
|
||||
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>
|
||||
/// Creates an LDAP→role mapping via <c>security role-mapping create</c> and returns its new <c>id</c>.
|
||||
/// </summary>
|
||||
@@ -659,6 +729,62 @@ public static partial class CliRunner
|
||||
"--source-environment", sourceEnvironment);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports a Transport bundle scoped to a single site via <c>bundle export
|
||||
/// --sites <siteIdentifier></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 <name></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>
|
||||
/// Resolves a template's name from its id via <c>template list</c>.
|
||||
/// </summary>
|
||||
|
||||
+236
@@ -240,4 +240,240 @@ public sealed class TransportImportTests
|
||||
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><details></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><details></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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user