mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-23 09:48:48 +08:00
484 lines
19 KiB
C#
484 lines
19 KiB
C#
using System.Diagnostics;
|
|
using System.IO.Compression;
|
|
using System.Xml.Linq;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace AtomUI.Localization.IntegrationTests;
|
|
|
|
public sealed class LanguagePackEndToEndTests
|
|
{
|
|
private static readonly TimeSpan s_stageTimeout = TimeSpan.FromSeconds(60);
|
|
|
|
[Fact(Timeout = 360_000)]
|
|
public async Task Module_And_Static_Language_Pack_Are_Consumed_Through_NuGet()
|
|
{
|
|
var repositoryRoot = FindRepositoryRoot();
|
|
var fixtureRoot = Path.Combine(repositoryRoot, "tests", "fixtures", "LanguagePackEndToEnd");
|
|
var temporaryRoot = Path.Combine(
|
|
Path.GetTempPath(),
|
|
"AtomUI.Localization.IntegrationTests",
|
|
Guid.NewGuid().ToString("N"));
|
|
var feed = Path.Combine(temporaryRoot, "feed");
|
|
var packages = Path.Combine(temporaryRoot, "packages");
|
|
var packageVersion = "1.0.0-local." + Guid.NewGuid().ToString("N");
|
|
Directory.CreateDirectory(feed);
|
|
Directory.CreateDirectory(packages);
|
|
|
|
try
|
|
{
|
|
await RunProcess(
|
|
"Pack module",
|
|
repositoryRoot,
|
|
temporaryRoot,
|
|
"dotnet",
|
|
"msbuild",
|
|
Path.Combine(fixtureRoot, "Module", "Module.csproj"),
|
|
"-t:Pack",
|
|
"-m:1",
|
|
"-nr:false",
|
|
"-p:Configuration=Debug",
|
|
$"-p:Version={packageVersion}",
|
|
$"-p:PackageOutputPath={feed}",
|
|
"-p:NoPackageAnalysis=true");
|
|
await RunProcess(
|
|
"Pack language pack",
|
|
repositoryRoot,
|
|
temporaryRoot,
|
|
"dotnet",
|
|
"msbuild",
|
|
Path.Combine(fixtureRoot, "LanguagePack", "LanguagePack.csproj"),
|
|
"-t:Pack",
|
|
"-m:1",
|
|
"-nr:false",
|
|
"-p:Configuration=Debug",
|
|
$"-p:PackageVersion={packageVersion}",
|
|
$"-p:PackageOutputPath={feed}");
|
|
|
|
AssertPackageLayout(feed, packageVersion);
|
|
|
|
var consumerProject = Path.Combine(fixtureRoot, "Consumer", "Consumer.csproj");
|
|
var globalPackages = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
|
".nuget",
|
|
"packages");
|
|
await RunProcess(
|
|
"Restore consumer",
|
|
repositoryRoot,
|
|
temporaryRoot,
|
|
"dotnet",
|
|
"restore",
|
|
consumerProject,
|
|
"--disable-build-servers",
|
|
"-m:1",
|
|
"-nr:false",
|
|
$"-p:FixturePackageVersion={packageVersion}",
|
|
$"-p:RestoreAdditionalProjectSources={feed}",
|
|
$"-p:RestorePackagesPath={packages}",
|
|
$"-p:RestoreAdditionalProjectFallbackFolders={globalPackages}");
|
|
await RunProcess(
|
|
"Build consumer",
|
|
repositoryRoot,
|
|
temporaryRoot,
|
|
"dotnet",
|
|
"build",
|
|
consumerProject,
|
|
"--no-restore",
|
|
"--disable-build-servers",
|
|
"-m:1",
|
|
"-nr:false",
|
|
"-p:Configuration=Debug",
|
|
$"-p:FixturePackageVersion={packageVersion}",
|
|
$"-p:RestorePackagesPath={packages}",
|
|
$"-p:RestoreAdditionalProjectFallbackFolders={globalPackages}");
|
|
|
|
var consumerExecutable = Path.Combine(
|
|
repositoryRoot,
|
|
"output",
|
|
"bin",
|
|
"Debug",
|
|
"net10.0",
|
|
OperatingSystem.IsWindows() ? "Consumer.exe" : "Consumer");
|
|
var runResult = await RunProcess(
|
|
"Run consumer",
|
|
repositoryRoot,
|
|
temporaryRoot,
|
|
consumerExecutable);
|
|
runResult.Output.ShouldContain("ja-JP:Welcome=ようこそ;ItemCount=3 件の項目があります。");
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(temporaryRoot))
|
|
{
|
|
Directory.Delete(temporaryRoot, recursive: true);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact(Timeout = 360_000)]
|
|
public async Task Language_Template_Is_Exported_From_A_Module_Package_Into_The_Current_Project()
|
|
{
|
|
var repositoryRoot = FindRepositoryRoot();
|
|
var fixtureRoot = Path.Combine(repositoryRoot, "tests", "fixtures", "LanguagePackEndToEnd");
|
|
var templateProjectDirectory = Path.Combine(fixtureRoot, "TemplateExport");
|
|
var exportedTemplate = Path.Combine(
|
|
templateProjectDirectory,
|
|
"Localization",
|
|
"Welcome",
|
|
"ja-JP.xlf");
|
|
var temporaryRoot = Path.Combine(
|
|
Path.GetTempPath(),
|
|
"AtomUI.Localization.IntegrationTests",
|
|
Guid.NewGuid().ToString("N"));
|
|
var feed = Path.Combine(temporaryRoot, "feed");
|
|
var packages = Path.Combine(temporaryRoot, "packages");
|
|
var packageVersion = "1.0.0-local." + Guid.NewGuid().ToString("N");
|
|
Directory.CreateDirectory(feed);
|
|
Directory.CreateDirectory(packages);
|
|
|
|
try
|
|
{
|
|
File.Exists(exportedTemplate).ShouldBeFalse(
|
|
$"The template export fixture must start empty: '{exportedTemplate}'.");
|
|
await RunProcess(
|
|
"Pack module for template export",
|
|
repositoryRoot,
|
|
temporaryRoot,
|
|
"dotnet",
|
|
"msbuild",
|
|
Path.Combine(fixtureRoot, "Module", "Module.csproj"),
|
|
"-t:Pack",
|
|
"-m:1",
|
|
"-nr:false",
|
|
"-p:Configuration=Debug",
|
|
$"-p:Version={packageVersion}",
|
|
$"-p:PackageOutputPath={feed}",
|
|
"-p:NoPackageAnalysis=true");
|
|
|
|
var templateProject = Path.Combine(templateProjectDirectory, "TemplateExport.csproj");
|
|
var globalPackages = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
|
".nuget",
|
|
"packages");
|
|
await RunProcess(
|
|
"Restore template export project",
|
|
repositoryRoot,
|
|
temporaryRoot,
|
|
"dotnet",
|
|
"restore",
|
|
templateProject,
|
|
"--disable-build-servers",
|
|
$"-p:FixturePackageVersion={packageVersion}",
|
|
$"-p:RestoreAdditionalProjectSources={feed}",
|
|
$"-p:RestorePackagesPath={packages}",
|
|
$"-p:RestoreAdditionalProjectFallbackFolders={globalPackages}");
|
|
await RunProcess(
|
|
"Export language template",
|
|
repositoryRoot,
|
|
temporaryRoot,
|
|
"dotnet",
|
|
"msbuild",
|
|
templateProject,
|
|
"-t:AtomUIExportLanguageTemplates",
|
|
"-m:1",
|
|
"-nr:false",
|
|
"-p:Configuration=Debug",
|
|
"-p:AtomUITargetLanguage=ja-JP",
|
|
$"-p:FixturePackageVersion={packageVersion}",
|
|
$"-p:RestorePackagesPath={packages}",
|
|
$"-p:RestoreAdditionalProjectFallbackFolders={globalPackages}");
|
|
|
|
File.Exists(exportedTemplate).ShouldBeTrue();
|
|
var exported = XDocument.Load(exportedTemplate);
|
|
XNamespace xliff = "urn:oasis:names:tc:xliff:document:2.0";
|
|
var root = exported.Root.ShouldNotBeNull();
|
|
((string?)root.Attribute("version")).ShouldBe("2.1");
|
|
((string?)root.Attribute("srcLang")).ShouldBe("en-US");
|
|
((string?)root.Attribute("trgLang")).ShouldBe("ja-JP");
|
|
|
|
var file = root.Element(xliff + "file").ShouldNotBeNull();
|
|
((string?)file.Attribute("id"))
|
|
.ShouldBe("Acme.LocalizationComponent.Localization.WelcomeLangResourceKind");
|
|
var units = file.Elements(xliff + "unit")
|
|
.ToDictionary(
|
|
static unit => (string)unit.Attribute("id")!,
|
|
StringComparer.Ordinal);
|
|
AssertExportedUnit(units["1"], xliff, "Greeting", "Welcome");
|
|
AssertExportedUnit(units["2"], xliff, "ItemCount", "You have {0} items.");
|
|
|
|
var packageCacheTemplate = Path.Combine(
|
|
packages,
|
|
"acme.localizationcomponent",
|
|
packageVersion,
|
|
"contentFiles",
|
|
"any",
|
|
"any",
|
|
"Localization",
|
|
"Welcome",
|
|
"ja-JP.xlf");
|
|
File.Exists(packageCacheTemplate).ShouldBeFalse();
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(exportedTemplate))
|
|
{
|
|
File.Delete(exportedTemplate);
|
|
}
|
|
if (Directory.Exists(temporaryRoot))
|
|
{
|
|
Directory.Delete(temporaryRoot, recursive: true);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void AssertPackageLayout(string feed, string packageVersion)
|
|
{
|
|
var modulePackage = Path.Combine(
|
|
feed,
|
|
$"Acme.LocalizationComponent.{packageVersion}.nupkg");
|
|
var languagePackage = Path.Combine(
|
|
feed,
|
|
$"Acme.LocalizationComponent.I18n.JaJP.{packageVersion}.nupkg");
|
|
|
|
var moduleEntries = PackageEntries(modulePackage);
|
|
moduleEntries.ShouldContain("buildTransitive/Acme.LocalizationComponent.props");
|
|
moduleEntries.ShouldContain("contentFiles/any/any/Localization/Welcome/en-US.xlf");
|
|
moduleEntries.ShouldContain("lib/net10.0/Module.dll");
|
|
moduleEntries.ShouldContain("lib/net10.0/Module.pdb");
|
|
moduleEntries.Count(IsBuildAsset).ShouldBe(1);
|
|
moduleEntries.Count(IsRuntimeAsset).ShouldBe(2);
|
|
moduleEntries.ShouldNotContain(static path => IsUnexpectedBuildOrRuntimeAsset(path));
|
|
|
|
var moduleFingerprint = AssertLanguageProps(
|
|
modulePackage,
|
|
"buildTransitive/Acme.LocalizationComponent.props",
|
|
"ModuleBuiltIn",
|
|
"Localization/Welcome/en-US.xlf");
|
|
|
|
var languageEntries = PackageEntries(languagePackage);
|
|
languageEntries.ShouldContain("buildTransitive/Acme.LocalizationComponent.I18n.JaJP.props");
|
|
languageEntries.ShouldContain("contentFiles/any/any/AtomUI.LanguagePack.xml");
|
|
languageEntries.ShouldContain("contentFiles/any/any/Welcome/ja-JP.xlf");
|
|
languageEntries.Count(IsBuildAsset).ShouldBe(1);
|
|
languageEntries.ShouldNotContain(static path => IsRuntimeAsset(path));
|
|
languageEntries.ShouldNotContain(static path => IsUnexpectedBuildOrRuntimeAsset(path));
|
|
|
|
var languageFingerprint = AssertLanguageProps(
|
|
languagePackage,
|
|
"buildTransitive/Acme.LocalizationComponent.I18n.JaJP.props",
|
|
"StaticLanguagePack",
|
|
"Welcome/ja-JP.xlf");
|
|
languageFingerprint.ShouldBe(moduleFingerprint);
|
|
|
|
var manifest = XDocument.Parse(PackageEntryText(
|
|
languagePackage,
|
|
"contentFiles/any/any/AtomUI.LanguagePack.xml"));
|
|
var manifestCatalog = manifest.Descendants("catalog").ShouldHaveSingleItem();
|
|
((string?)manifestCatalog.Attribute("sourceFingerprint")).ShouldBe(languageFingerprint);
|
|
}
|
|
|
|
private static void AssertExportedUnit(
|
|
XElement unit,
|
|
XNamespace xliff,
|
|
string expectedName,
|
|
string expectedSource)
|
|
{
|
|
((string?)unit.Attribute("name")).ShouldBe(expectedName);
|
|
var segment = unit.Element(xliff + "segment").ShouldNotBeNull();
|
|
segment.Element(xliff + "source").ShouldNotBeNull().Value.ShouldBe(expectedSource);
|
|
var target = segment.Element(xliff + "target").ShouldNotBeNull();
|
|
((string?)target.Attribute("state")).ShouldBe("initial");
|
|
target.Value.ShouldBeEmpty();
|
|
}
|
|
|
|
private static string AssertLanguageProps(
|
|
string packagePath,
|
|
string entryPath,
|
|
string sourceKind,
|
|
string packageLanguagePath)
|
|
{
|
|
var props = XDocument.Parse(PackageEntryText(packagePath, entryPath));
|
|
var item = props.Descendants("AtomUILanguage").ShouldHaveSingleItem();
|
|
((string?)item.Attribute("AtomUILanguageSourceKind")).ShouldBe(sourceKind);
|
|
((string?)item.Attribute("AtomUILanguagePackagePath")).ShouldBe(packageLanguagePath);
|
|
var fingerprint = ((string?)item.Attribute("AtomUILanguageSourceFingerprint"))
|
|
.ShouldNotBeNull();
|
|
fingerprint.ShouldMatch("^[0-9a-f]{64}$");
|
|
return fingerprint;
|
|
}
|
|
|
|
private static string[] PackageEntries(string packagePath)
|
|
{
|
|
File.Exists(packagePath).ShouldBeTrue($"Expected package '{packagePath}' was not created.");
|
|
using var archive = ZipFile.OpenRead(packagePath);
|
|
return archive.Entries.Select(static entry => entry.FullName).ToArray();
|
|
}
|
|
|
|
private static string PackageEntryText(string packagePath, string entryPath)
|
|
{
|
|
using var archive = ZipFile.OpenRead(packagePath);
|
|
var entry = archive.GetEntry(entryPath).ShouldNotBeNull(
|
|
$"Expected package '{packagePath}' to contain '{entryPath}'.");
|
|
using var reader = new StreamReader(entry.Open());
|
|
return reader.ReadToEnd();
|
|
}
|
|
|
|
private static bool IsBuildAsset(string path)
|
|
{
|
|
return path.StartsWith("build/", StringComparison.OrdinalIgnoreCase) ||
|
|
path.StartsWith("buildMultiTargeting/", StringComparison.OrdinalIgnoreCase) ||
|
|
path.StartsWith("buildTransitive/", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static bool IsRuntimeAsset(string path)
|
|
{
|
|
return path.StartsWith("lib/", StringComparison.OrdinalIgnoreCase) ||
|
|
path.StartsWith("runtimes/", StringComparison.OrdinalIgnoreCase) ||
|
|
path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ||
|
|
path.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static bool IsUnexpectedBuildOrRuntimeAsset(string path)
|
|
{
|
|
return path.StartsWith("analyzers/", StringComparison.OrdinalIgnoreCase) ||
|
|
path.StartsWith("tools/", StringComparison.OrdinalIgnoreCase) ||
|
|
path.EndsWith(".targets", StringComparison.OrdinalIgnoreCase) ||
|
|
path.Contains("AtomUI.Build.Tasks", StringComparison.OrdinalIgnoreCase) ||
|
|
path.Contains("AtomUI.Generator", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static async Task<ProcessResult> RunProcess(
|
|
string stage,
|
|
string workingDirectory,
|
|
string temporaryRoot,
|
|
string executable,
|
|
params string[] arguments)
|
|
{
|
|
var startInfo = new ProcessStartInfo(executable)
|
|
{
|
|
WorkingDirectory = workingDirectory,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
UseShellExecute = false
|
|
};
|
|
startInfo.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1";
|
|
startInfo.Environment["DOTNET_NOLOGO"] = "1";
|
|
startInfo.Environment["DOTNET_CLI_HOME"] = Path.Combine(temporaryRoot, "cli");
|
|
startInfo.Environment["NUGET_PACKAGES"] = Path.Combine(temporaryRoot, "packages");
|
|
startInfo.Environment["DOTNET_CLI_USE_MSBUILD_SERVER"] = "0";
|
|
startInfo.Environment["MSBUILDUSESERVER"] = "0";
|
|
startInfo.Environment["UseSharedCompilation"] = "false";
|
|
foreach (var argument in arguments)
|
|
{
|
|
startInfo.ArgumentList.Add(argument);
|
|
}
|
|
|
|
var command = FormatCommand(executable, arguments);
|
|
using var process = Process.Start(startInfo) ?? throw new InvalidOperationException(
|
|
$"The '{stage}' process could not be started.");
|
|
var stopwatch = Stopwatch.StartNew();
|
|
var standardOutput = process.StandardOutput.ReadToEndAsync();
|
|
var standardError = process.StandardError.ReadToEndAsync();
|
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(
|
|
TestContext.Current.CancellationToken);
|
|
timeout.CancelAfter(s_stageTimeout);
|
|
try
|
|
{
|
|
await process.WaitForExitAsync(timeout.Token);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
if (!process.HasExited)
|
|
{
|
|
process.Kill(entireProcessTree: true);
|
|
}
|
|
await process.WaitForExitAsync();
|
|
var timedOutStandardOutput = await standardOutput;
|
|
var timedOutStandardError = await standardError;
|
|
var outcome = TestContext.Current.CancellationToken.IsCancellationRequested
|
|
? "was cancelled"
|
|
: "timed out";
|
|
throw new TimeoutException(
|
|
FormatFailure(
|
|
stage,
|
|
command,
|
|
stopwatch.Elapsed,
|
|
exitCode: null,
|
|
timedOutStandardOutput,
|
|
timedOutStandardError,
|
|
outcome));
|
|
}
|
|
|
|
var result = new ProcessResult(
|
|
stage,
|
|
command,
|
|
process.Id,
|
|
stopwatch.Elapsed,
|
|
process.ExitCode,
|
|
await standardOutput,
|
|
await standardError);
|
|
result.ExitCode.ShouldBe(0, FormatFailure(result, "failed"));
|
|
return result;
|
|
}
|
|
|
|
private static string FormatCommand(string executable, IReadOnlyList<string> arguments)
|
|
{
|
|
return executable + " " + string.Join(' ', arguments);
|
|
}
|
|
|
|
private static string FormatFailure(ProcessResult result, string outcome)
|
|
{
|
|
return FormatFailure(
|
|
result.Stage,
|
|
result.Command,
|
|
result.Elapsed,
|
|
result.ExitCode,
|
|
result.StandardOutput,
|
|
result.StandardError,
|
|
outcome);
|
|
}
|
|
|
|
private static string FormatFailure(
|
|
string stage,
|
|
string command,
|
|
TimeSpan elapsed,
|
|
int? exitCode,
|
|
string standardOutput,
|
|
string standardError,
|
|
string outcome)
|
|
{
|
|
return $"{stage} {outcome} after {elapsed.TotalSeconds:F1}s.{Environment.NewLine}" +
|
|
$"Command: {command}{Environment.NewLine}" +
|
|
$"Exit code: {exitCode?.ToString() ?? "not available"}{Environment.NewLine}" +
|
|
$"stdout:{Environment.NewLine}{standardOutput}{Environment.NewLine}" +
|
|
$"stderr:{Environment.NewLine}{standardError}";
|
|
}
|
|
|
|
private static string FindRepositoryRoot()
|
|
{
|
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
|
while (directory is not null)
|
|
{
|
|
if (File.Exists(Path.Combine(directory.FullName, "AtomUI.slnx")))
|
|
{
|
|
return directory.FullName;
|
|
}
|
|
directory = directory.Parent;
|
|
}
|
|
|
|
throw new DirectoryNotFoundException("The AtomUI repository root could not be located.");
|
|
}
|
|
|
|
private sealed record ProcessResult(
|
|
string Stage,
|
|
string Command,
|
|
int ProcessId,
|
|
TimeSpan Elapsed,
|
|
int ExitCode,
|
|
string StandardOutput,
|
|
string StandardError)
|
|
{
|
|
public string Output => StandardOutput + StandardError;
|
|
}
|
|
}
|