From 092d3d64203962aacfc06169dba3cd55a8e8bba1 Mon Sep 17 00:00:00 2001 From: redth Date: Wed, 22 Jul 2026 10:55:03 -0400 Subject: [PATCH 01/25] Add reusable XAML hot reload test harness Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1b9bf3f3-3e24-49bf-b3eb-c075858ee563 --- .../HotReload/XamlHotReloadTestHarness.cs | 423 ++++++++++ .../SourceGeneratorDriver.cs | 7 +- .../XamlIncrementalHotReloadE2ETests.cs | 741 ++++-------------- 3 files changed, 586 insertions(+), 585 deletions(-) create mode 100644 src/Controls/tests/SourceGen.UnitTests/HotReload/XamlHotReloadTestHarness.cs diff --git a/src/Controls/tests/SourceGen.UnitTests/HotReload/XamlHotReloadTestHarness.cs b/src/Controls/tests/SourceGen.UnitTests/HotReload/XamlHotReloadTestHarness.cs new file mode 100644 index 000000000000..420a306e41f3 --- /dev/null +++ b/src/Controls/tests/SourceGen.UnitTests/HotReload/XamlHotReloadTestHarness.cs @@ -0,0 +1,423 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Reflection.Metadata; +using System.Runtime.Loader; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; +using Microsoft.Maui.Controls.SourceGen; +using Microsoft.Maui.Controls.Xaml.UnitTests.SourceGen; +using Xunit; + +namespace Microsoft.Maui.Controls.SourceGen.UnitTests.HotReload; + +internal sealed class XamlHotReloadTestHarness : IDisposable +{ + static readonly Regex GeneratedCodeAttributeRegex = new( + @"\[global::System\.CodeDom\.Compiler\.GeneratedCodeAttribute\([^\]]+\)\]\s*\n", + RegexOptions.CultureInvariant); + + static int s_scenarioSequence; + + readonly ImmutableArray _additionalSources; + readonly string _pageSource; + bool _disposed; + bool _generated; + + public XamlHotReloadTestHarness( + string scenarioName, + string pageClass, + string pageSource, + params string[] additionalSources) + { + ArgumentException.ThrowIfNullOrWhiteSpace(scenarioName); + ArgumentException.ThrowIfNullOrWhiteSpace(pageClass); + ArgumentException.ThrowIfNullOrWhiteSpace(pageSource); + ArgumentNullException.ThrowIfNull(additionalSources); + + var sequence = Interlocked.Increment(ref s_scenarioSequence); + ScenarioIdentity = $"{SanitizeIdentifier(scenarioName)}_{sequence}"; + AssemblyName = $"XamlHotReload_{ScenarioIdentity}"; + PageClass = pageClass; + XamlPath = Path.Combine(Path.GetTempPath(), "MauiSourceGenHotReload", ScenarioIdentity, "MainPage.xaml"); + _pageSource = pageSource; + _additionalSources = [.. additionalSources]; + + XamlHotReloadState.Reset(); + } + + public string AssemblyName { get; } + + public string PageClass { get; } + + public string ScenarioIdentity { get; } + + public string XamlPath { get; } + + public XamlHotReloadGeneration Generate(params string[] xamlVersions) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(xamlVersions); + + if (_generated) + throw new InvalidOperationException("A hot reload harness can generate only one scenario."); + if (xamlVersions.Length == 0) + throw new ArgumentException("At least one XAML version is required.", nameof(xamlVersions)); + + _generated = true; + + var compilation = CreateCompilation(includeGeneratedSources: false); + var driverOptions = new GeneratorDriverOptions( + disabledOutputs: IncrementalGeneratorOutputKind.None, + trackIncrementalGeneratorSteps: true); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new XamlGenerator().AsSourceGenerator()], + driverOptions: driverOptions); + + var versions = ImmutableArray.CreateBuilder(xamlVersions.Length); + SourceGeneratorDriver.AdditionalFile? previousFile = null; + + for (var index = 0; index < xamlVersions.Length; index++) + { + var file = CreateAdditionalFile(xamlVersions[index]); + driver = previousFile is null + ? driver.AddAdditionalTexts([file.Text]) + : driver.ReplaceAdditionalText(previousFile.Text, file.Text); + driver = driver + .WithUpdatedAnalyzerConfigOptions(SourceGeneratorDriver.CreateAnalyzerConfigOptionsProvider(file)) + .RunGenerators(compilation); + + var result = driver.GetRunResult(); + var initializeComponentSource = FindGeneratedSource(result, static hintName => + hintName.EndsWith(".xsg.cs", StringComparison.OrdinalIgnoreCase) + && !hintName.Contains("uc.xsg", StringComparison.OrdinalIgnoreCase)); + Assert.NotNull(initializeComponentSource); + + versions.Add(new XamlHotReloadGeneratedVersion( + ScenarioIdentity, + index, + xamlVersions[index], + initializeComponentSource!, + FindGeneratedSource(result, static hintName => + hintName.Contains("uc.xsg", StringComparison.OrdinalIgnoreCase)), + result)); + previousFile = file; + } + + return new XamlHotReloadGeneration(ScenarioIdentity, versions.MoveToImmutable()); + } + + public XamlHotReloadCompiledVersion Compile(XamlHotReloadGeneratedVersion version) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(version); + + if (!string.Equals(version.ScenarioIdentity, ScenarioIdentity, StringComparison.Ordinal)) + throw new ArgumentException("The generated version belongs to a different harness.", nameof(version)); + + var compilation = CreateCompilation( + includeGeneratedSources: true, + version.InitializeComponentSource, + version.UpdateComponentSource); + var errors = compilation.GetDiagnostics() + .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .ToArray(); + Assert.True(errors.Length == 0, $"Compilation failed:{Environment.NewLine}{FormatDiagnostics(errors)}"); + + using var peStream = new MemoryStream(); + using var pdbStream = new MemoryStream(); + var emitResult = compilation.Emit( + peStream, + pdbStream, + options: new EmitOptions(debugInformationFormat: DebugInformationFormat.PortablePdb)); + Assert.True(emitResult.Success, $"Emit failed:{Environment.NewLine}{FormatDiagnostics(emitResult.Diagnostics)}"); + + return new XamlHotReloadCompiledVersion( + version, + compilation, + ImmutableArray.CreateRange(peStream.ToArray()), + ImmutableArray.CreateRange(pdbStream.ToArray())); + } + + public void RunLive(XamlHotReloadGeneration generation, Action exercise) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(exercise); + + if (!string.Equals(generation.ScenarioIdentity, ScenarioIdentity, StringComparison.Ordinal)) + throw new ArgumentException("The generated scenario belongs to a different harness.", nameof(generation)); + + using var session = new XamlHotReloadLiveSession(this, generation); + exercise(session); + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + XamlHotReloadState.Reset(); + } + + CSharpCompilation CreateCompilation( + bool includeGeneratedSources, + string? initializeComponentSource = null, + string? updateComponentSource = null) + { + var compilation = (CSharpCompilation)SourceGeneratorDriver.CreateMauiCompilation(AssemblyName); + var trees = ImmutableArray.CreateBuilder(); + trees.Add(ParseSource(_pageSource, "Page.cs")); + + for (var index = 0; index < _additionalSources.Length; index++) + trees.Add(ParseSource(_additionalSources[index], $"Additional{index}.cs")); + + if (includeGeneratedSources) + { + ArgumentNullException.ThrowIfNull(initializeComponentSource); + trees.Add(ParseSource(initializeComponentSource, "InitializeComponent.xsg.cs")); + if (updateComponentSource is not null) + trees.Add(ParseSource(StripGeneratedCodeAttribute(updateComponentSource), "UpdateComponent.uc.xsg.cs")); + } + + return compilation.AddSyntaxTrees(trees); + } + + SourceGeneratorDriver.AdditionalFile CreateAdditionalFile(string xaml) => + new( + SourceGeneratorDriver.ToAdditionalText(XamlPath, xaml), + Kind: "Xaml", + RelativePath: "MainPage.xaml", + TargetPath: $"{AssemblyName}/MainPage.xaml", + ManifestResourceName: null, + TargetFramework: "net11.0", + NoWarn: null, + EnableIncrementalHotReload: true); + + static SyntaxTree ParseSource(string source, string path) => + CSharpSyntaxTree.ParseText(source, path: path, encoding: Encoding.UTF8); + + static string? FindGeneratedSource( + GeneratorDriverRunResult result, + Func hintNamePredicate) + { + foreach (var generatorResult in result.Results) + { + foreach (var source in generatorResult.GeneratedSources) + { + if (hintNamePredicate(source.HintName)) + return source.SourceText.ToString(); + } + } + + return null; + } + + static string StripGeneratedCodeAttribute(string source) => + GeneratedCodeAttributeRegex.Replace(source, ""); + + static string FormatDiagnostics(IEnumerable diagnostics) => + string.Join(Environment.NewLine, diagnostics.Select(static diagnostic => + $"{diagnostic.Id}: {diagnostic.GetMessage()} at {diagnostic.Location}")); + + static string SanitizeIdentifier(string value) + { + var characters = value + .Select(static character => char.IsLetterOrDigit(character) ? character : '_') + .ToArray(); + return new string(characters); + } +} + +internal sealed record XamlHotReloadGeneration( + string ScenarioIdentity, + ImmutableArray Versions) +{ + public XamlHotReloadGeneratedVersion this[int index] => Versions[index]; +} + +internal sealed record XamlHotReloadGeneratedVersion( + string ScenarioIdentity, + int Index, + string Xaml, + string InitializeComponentSource, + string? UpdateComponentSource, + GeneratorDriverRunResult GeneratorResult); + +internal sealed record XamlHotReloadCompiledVersion( + XamlHotReloadGeneratedVersion GeneratedVersion, + CSharpCompilation Compilation, + ImmutableArray PeImage, + ImmutableArray PdbImage); + +internal sealed class XamlHotReloadLiveSession : IDisposable +{ + readonly XamlHotReloadTestHarness _harness; + readonly XamlHotReloadGeneration _generation; + Assembly? _assembly; + EmitBaseline? _baseline; + XamlHotReloadCompiledVersion? _compiledVersion; + object? _instance; + AssemblyLoadContext? _loadContext; + ModuleMetadata? _moduleMetadata; + Type? _pageType; + bool _disposed; + int _versionIndex; + + public XamlHotReloadLiveSession( + XamlHotReloadTestHarness harness, + XamlHotReloadGeneration generation) + { + _harness = harness; + _generation = generation; + _compiledVersion = harness.Compile(generation[0]); + _moduleMetadata = ModuleMetadata.CreateFromImage(_compiledVersion.PeImage); + _baseline = EmitBaseline.CreateInitialBaseline( + _compiledVersion.Compilation, + _moduleMetadata, + debugInformationProvider: static _ => default, + localSignatureProvider: static _ => default, + hasPortableDebugInformation: true); + + _loadContext = new AssemblyLoadContext( + $"XamlHotReload_{harness.ScenarioIdentity}", + isCollectible: true); + Assert.True(_loadContext.IsCollectible); + + using var peStream = new MemoryStream(_compiledVersion.PeImage.ToArray(), writable: false); + using var pdbStream = new MemoryStream(_compiledVersion.PdbImage.ToArray(), writable: false); + _assembly = _loadContext.LoadFromStream(peStream, pdbStream); + _pageType = _assembly.GetType(harness.PageClass); + Assert.NotNull(_pageType); + _instance = Activator.CreateInstance(_pageType!); + Assert.NotNull(_instance); + } + + public object Instance => + _instance ?? throw new ObjectDisposedException(nameof(XamlHotReloadLiveSession)); + + public T GetInstance() where T : class => + Assert.IsAssignableFrom(Instance); + + public T ApplyUpdate(int versionIndex) where T : class + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (versionIndex != _versionIndex + 1) + throw new ArgumentOutOfRangeException(nameof(versionIndex), "Updates must be applied sequentially."); + + var instance = Instance; + var nextVersion = _harness.Compile(_generation[versionIndex]); + var edits = CreateSemanticEdits( + _compiledVersion!.Compilation, + nextVersion.Compilation, + _harness.PageClass); + var insertsUpdateComponent = _compiledVersion.Compilation + .GetTypeByMetadataName(_harness.PageClass)! + .GetMembers("UpdateComponent") + .Length == 0; + + using var metadataDelta = new MemoryStream(); + using var ilDelta = new MemoryStream(); + using var pdbDelta = new MemoryStream(); + var difference = nextVersion.Compilation.EmitDifference( + _baseline!, + edits, + isAddedSymbol: symbol => insertsUpdateComponent && symbol.Name == "UpdateComponent", + metadataDelta, + ilDelta, + pdbDelta, + CancellationToken.None); + Assert.True( + difference.Success, + $"EmitDifference failed:{Environment.NewLine}{string.Join(Environment.NewLine, difference.Diagnostics)}"); + + MetadataUpdater.ApplyUpdate( + _assembly!, + metadataDelta.ToArray(), + ilDelta.ToArray(), + pdbDelta.ToArray()); + + var updateMethod = _pageType!.GetMethod( + "UpdateComponent", + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + Assert.NotNull(updateMethod); + updateMethod!.Invoke(instance, null); + Assert.Same(instance, Instance); + + _baseline = difference.Baseline; + _compiledVersion = nextVersion; + _versionIndex = versionIndex; + return Assert.IsAssignableFrom(Instance); + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + if (_instance is not null) + global::Microsoft.Maui.Controls.Xaml.XamlComponentRegistry.Unregister(_instance); + _instance = null; + _pageType = null; + _assembly = null; + _compiledVersion = null; + _baseline = null; + _moduleMetadata?.Dispose(); + _moduleMetadata = null; + + var loadContext = _loadContext; + _loadContext = null; + loadContext?.Unload(); + } + + static ImmutableArray CreateSemanticEdits( + CSharpCompilation previousCompilation, + CSharpCompilation nextCompilation, + string pageClass) + { + var previousPageType = previousCompilation.GetTypeByMetadataName(pageClass); + var nextPageType = nextCompilation.GetTypeByMetadataName(pageClass); + Assert.NotNull(previousPageType); + Assert.NotNull(nextPageType); + + var previousInitializeComponent = GetPartialImplementation(previousPageType!, "InitializeComponent"); + var nextInitializeComponent = GetPartialImplementation(nextPageType!, "InitializeComponent"); + var edits = ImmutableArray.CreateBuilder(); + edits.Add(new SemanticEdit( + SemanticEditKind.Update, + previousInitializeComponent, + nextInitializeComponent)); + + var previousUpdateComponent = previousPageType! + .GetMembers("UpdateComponent") + .OfType() + .SingleOrDefault(); + var nextUpdateComponent = nextPageType! + .GetMembers("UpdateComponent") + .OfType() + .SingleOrDefault(); + Assert.NotNull(nextUpdateComponent); + edits.Add(previousUpdateComponent is null + ? new SemanticEdit(SemanticEditKind.Insert, null, nextUpdateComponent) + : new SemanticEdit(SemanticEditKind.Update, previousUpdateComponent, nextUpdateComponent)); + + return edits.ToImmutable(); + } + + static IMethodSymbol GetPartialImplementation(INamedTypeSymbol type, string methodName) + { + var method = type.GetMembers(methodName).OfType().First(); + return method.PartialImplementationPart ?? method; + } +} diff --git a/src/Controls/tests/SourceGen.UnitTests/SourceGeneratorDriver.cs b/src/Controls/tests/SourceGen.UnitTests/SourceGeneratorDriver.cs index 7c74dfc1bb6a..d90d357b1b63 100644 --- a/src/Controls/tests/SourceGen.UnitTests/SourceGeneratorDriver.cs +++ b/src/Controls/tests/SourceGen.UnitTests/SourceGeneratorDriver.cs @@ -18,7 +18,7 @@ public static class SourceGeneratorDriver { private static MetadataReference[]? MauiReferences; - public record AdditionalFile(AdditionalText Text, string Kind, string RelativePath, string? TargetPath, string? ManifestResourceName, string? TargetFramework, string? NoWarn, string LineInfo="enable", bool EnablePreviewFeatures = true, bool EnableIncrementalHotReload = false); + public record AdditionalFile(AdditionalText Text, string Kind, string RelativePath, string? TargetPath, string? ManifestResourceName, string? TargetFramework, string? NoWarn, string LineInfo = "enable", bool EnablePreviewFeatures = true, bool EnableIncrementalHotReload = false); public static GeneratorDriverRunResult RunGenerator(Compilation compilation, AdditionalFile additionalFile, bool assertNoCompilationErrors = true) where T : IIncrementalGenerator, new() => RunGenerator(compilation, additionalFiles: [additionalFile], assertNoCompilationErrors); @@ -98,6 +98,9 @@ public static Compilation CreateMauiCompilation(string name = $"{nameof(SourceGe public static AdditionalText ToAdditionalText(string path, string text) => CustomAdditionalText.From(path, text); + internal static AnalyzerConfigOptionsProvider CreateAnalyzerConfigOptionsProvider(params AdditionalFile[] additionalFiles) => + new CustomAnalyzerConfigOptionsProvider(additionalFiles); + private static MetadataReference[] GetMauiReferences() { string dotNetAssemblyPath = Path.GetDirectoryName(typeof(object).Assembly.Location)!; @@ -184,7 +187,7 @@ public override bool TryGetValue(string key, [NotNullWhen(true)] out string? val #endif "build_property.EnableMauiXamlDiagnostics" => "true", "build_property.EnableMauiIncrementalHotReload" => _additionalFile.EnableIncrementalHotReload ? "true" : "false", - "build_property.MauiXamlLineInfo" => _additionalFile.LineInfo != "default" ? _additionalFile.LineInfo : null, + "build_property.MauiXamlLineInfo" => _additionalFile.LineInfo != "default" ? _additionalFile.LineInfo : null, "build_property.MauiXamlNoWarn" => _additionalFile.NoWarn, "build_property.EnablePreviewFeatures" => _additionalFile.EnablePreviewFeatures ? "true" : null, diff --git a/src/Controls/tests/SourceGen.UnitTests/XamlIncrementalHotReloadE2ETests.cs b/src/Controls/tests/SourceGen.UnitTests/XamlIncrementalHotReloadE2ETests.cs index 624e5526df81..f6ae0aa23175 100644 --- a/src/Controls/tests/SourceGen.UnitTests/XamlIncrementalHotReloadE2ETests.cs +++ b/src/Controls/tests/SourceGen.UnitTests/XamlIncrementalHotReloadE2ETests.cs @@ -1,220 +1,32 @@ -// End-to-end tests for XAML Incremental Hot Reload. -// -// These tests exercise the full pipeline, from XAML → SourceGen → Compile → Load → ApplyUpdate: -// 1. Run the XamlGenerator on XAML V1 → get generated InitializeComponent C# source. -// 2. Compile that C# into an in-memory assembly, load it, create an instance. -// 3. Run the XamlGenerator on XAML V2 → get generated UpdateComponent C# source. -// 4. Compile V2, EmitDifference from V1→V2, MetadataUpdater.ApplyUpdate. -// 5. Call UpdateComponent() on the live instance via reflection. -// 6. Assert that properties changed on the live MAUI object tree. -// -// Follows the pattern from: https://gist.github.com/StephaneDelcroix/2ed08a8ff3632ce7341c3c2c16d338b2 - #nullable enable using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; using System.Linq; -using System.Reflection; using System.Reflection.Metadata; -using System.Runtime.Loader; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Emit; -using Microsoft.Maui.Controls.SourceGen; -using Microsoft.Maui.Controls.Xaml; -using Microsoft.Maui.Controls.Xaml.UnitTests.SourceGen; +using System.Runtime.CompilerServices; +using Microsoft.Maui.Controls.SourceGen.UnitTests.HotReload; using Xunit; namespace Microsoft.Maui.Controls.SourceGen.UnitTests; /// -/// True end-to-end tests: XAML → SourceGen → Compile → Load → Hot Reload → Verify. +/// True end-to-end tests: XAML -> SourceGen -> Compile -> Load -> Hot Reload -> Verify. /// Uses to apply deltas to a live assembly. /// [Collection("XamlHotReloadTests")] -public class XamlIncrementalHotReloadE2ETests : IDisposable +public class XamlIncrementalHotReloadE2ETests { - public void Dispose() => XamlHotReloadState.Reset(); - - const string PageRelativePath = "MainPage.xaml"; const string PageClass = "TestE2EApp.MainPage"; - const string AssemblyName = "TestE2EApp"; - - static SourceGeneratorDriver.AdditionalFile MakeFile(string xaml) => - new( - SourceGeneratorDriver.ToAdditionalText(PageRelativePath, xaml), - Kind: "Xaml", - RelativePath: PageRelativePath, - TargetPath: "TestE2EApp/MainPage.xaml", - ManifestResourceName: null, - TargetFramework: "net11.0", - NoWarn: null, - EnableIncrementalHotReload: true); - - /// - /// Creates a Roslyn compilation with MAUI references, suitable for compiling - /// source-generated code that uses MAUI controls, XamlComponentRegistry, etc. - /// - static CSharpCompilation CreateMauiCompilation(params SyntaxTree[] trees) - { - var refs = GetCompilationReferences(); - return CSharpCompilation.Create( - AssemblyName, - trees, - refs, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - } - - /// - /// Gets metadata references for MAUI runtime assemblies + BCL. - /// - static MetadataReference[] GetCompilationReferences() - { - string dotNetAssemblyPath = Path.GetDirectoryName(typeof(object).Assembly.Location)!; - return - [ - MetadataReference.CreateFromFile(typeof(System.Runtime.CompilerServices.InternalsVisibleToAttribute).Assembly.Location), - MetadataReference.CreateFromFile(Path.Combine(dotNetAssemblyPath, "mscorlib.dll")), - MetadataReference.CreateFromFile(Path.Combine(dotNetAssemblyPath, "System.dll")), - MetadataReference.CreateFromFile(Path.Combine(dotNetAssemblyPath, "System.Core.dll")), - MetadataReference.CreateFromFile(Path.Combine(dotNetAssemblyPath, "System.Private.CoreLib.dll")), - MetadataReference.CreateFromFile(Path.Combine(dotNetAssemblyPath, "System.Runtime.dll")), - MetadataReference.CreateFromFile(Path.Combine(dotNetAssemblyPath, "System.ObjectModel.dll")), - MetadataReference.CreateFromFile(typeof(Uri).Assembly.Location), - MetadataReference.CreateFromFile(typeof(Microsoft.Maui.Graphics.Color).Assembly.Location), - MetadataReference.CreateFromFile(typeof(Button).Assembly.Location), - MetadataReference.CreateFromFile(typeof(BindingExtension).Assembly.Location), - MetadataReference.CreateFromFile(typeof(Thickness).Assembly.Location), - MetadataReference.CreateFromFile(Assembly.Load("System.Private.Xml").Location), - MetadataReference.CreateFromFile(Assembly.Load("System.Xml.ReaderWriter").Location), - MetadataReference.CreateFromFile(typeof(IServiceProvider).Assembly.Location), - MetadataReference.CreateFromFile(typeof(System.ComponentModel.TypeConverter).Assembly.Location), - MetadataReference.CreateFromFile(typeof(System.ComponentModel.TypeDescriptor).Assembly.Location), - ]; - } - - /// - /// Runs the XamlGenerator source generator to produce IC/UC code from XAML. - /// Returns the generated source file contents keyed by hint name suffix. - /// - (string? icSource, string? ucSource) RunSourceGen(string xaml) - { - var compilation = SourceGeneratorDriver.CreateMauiCompilation(AssemblyName); - var file = MakeFile(xaml); - - var result = SourceGeneratorDriver.RunGenerator( - compilation, file, assertNoCompilationErrors: false); - - string? icSource = null, ucSource = null; - foreach (var gen in result.Results) - { - foreach (var src in gen.GeneratedSources) - { - if (src.HintName.EndsWith(".xsg.cs", StringComparison.OrdinalIgnoreCase) - && !src.HintName.Contains("uc.xsg", StringComparison.OrdinalIgnoreCase)) - icSource = src.SourceText.ToString(); - if (src.HintName.Contains("uc.xsg", StringComparison.OrdinalIgnoreCase)) - ucSource = src.SourceText.ToString(); - } - } - return (icSource, ucSource); - } - - /// - /// Runs the source generator for V1, then for V2 (with V1 cached in state), - /// returning both IC sources and the UC source from V2. - /// - (string icV1, string icV2, string? ucV2) RunSourceGenTwoPhase(string xamlV1, string xamlV2) - { - // Phase 1: V1 seeds state - var (icV1, _) = RunSourceGen(xamlV1); - Assert.NotNull(icV1); - - // Phase 2: V2 produces UC (state now has V1 cached) - var compilation = SourceGeneratorDriver.CreateMauiCompilation(AssemblyName); - var fileV1 = MakeFile(xamlV1); - var fileV2 = MakeFile(xamlV2); - - var (run1, run2) = SourceGeneratorDriver.RunGeneratorWithChanges( - compilation, - applyChanges: (driver, comp) => - { - var updatedDriver = driver - .ReplaceAdditionalText(fileV1.Text, fileV2.Text) - .WithUpdatedAnalyzerConfigOptions(new OptionsProvider([fileV2])); - return (updatedDriver, comp); - }, - fileV1); - - // Get IC from run2 (V2's InitializeComponent) - string? icV2 = null, ucV2 = null; - foreach (var gen in run2.Results) - { - foreach (var src in gen.GeneratedSources) - { - if (src.HintName.EndsWith(".xsg.cs", StringComparison.OrdinalIgnoreCase) - && !src.HintName.Contains("uc.xsg", StringComparison.OrdinalIgnoreCase)) - icV2 = src.SourceText.ToString(); - if (src.HintName.Contains("uc.xsg", StringComparison.OrdinalIgnoreCase)) - ucV2 = src.SourceText.ToString(); - } - } - - Assert.NotNull(icV2); - return (icV1!, icV2!, ucV2); - } - - /// - /// Compiles C# sources into a PE+PDB byte pair, asserting compilation success. - /// - /// - /// Strips the [GeneratedCode] attribute from UC source to avoid CS0579 duplicate - /// when compiling IC + UC together (both emit it on the same partial class). - /// - static string StripGeneratedCodeAttribute(string source) => - System.Text.RegularExpressions.Regex.Replace(source, - @"\[global::System\.CodeDom\.Compiler\.GeneratedCodeAttribute\([^\]]+\)\]\s*\n", - ""); - - static (byte[] pe, byte[] pdb, CSharpCompilation compilation) CompileSources(params string[] sources) - { - var trees = sources.Select((s, i) => - CSharpSyntaxTree.ParseText(s, path: $"Source{i}.cs", encoding: System.Text.Encoding.UTF8)).ToArray(); - - var compilation = CreateMauiCompilation(trees); - - using var peStream = new MemoryStream(); - using var pdbStream = new MemoryStream(); - var emitResult = compilation.Emit(peStream, pdbStream, - options: new EmitOptions(debugInformationFormat: DebugInformationFormat.PortablePdb)); - - if (!emitResult.Success) - { - var errors = string.Join("\n", emitResult.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error)); - Assert.Fail($"Compilation failed:\n{errors}"); - } - - return (peStream.ToArray(), pdbStream.ToArray(), compilation); - } - /// - /// Minimal partial class stub with InitializeComponentRuntime() to satisfy the generated code. - /// - const string PageStub = $$""" + const string PageStub = """ namespace TestE2EApp; public partial class MainPage : global::Microsoft.Maui.Controls.ContentPage { - // Declaring partial method (the source generator provides the implementing declaration) private partial void InitializeComponent(); - // Runtime fallback used by UC fallback path public void InitializeComponentRuntime() { } - // Constructor calls IC public MainPage() { InitializeComponent(); @@ -222,12 +34,12 @@ public MainPage() } """; + static XamlHotReloadTestHarness CreateHarness([CallerMemberName] string scenarioName = "") => + new(scenarioName, PageClass, PageStub); + [MetadataUpdateFact] public void PropertyChange_AppliedViaHotReload() { - XamlHotReloadState.Reset(); - - // --- XAML V1: Label says "Hello" --- const string xamlV1 = """ """; - - // --- XAML V2: Label says "World" --- const string xamlV2 = """ """; - // Step 1: Source-gen both versions - var (icV1, icV2, ucV2) = RunSourceGenTwoPhase(xamlV1, xamlV2); - Assert.NotNull(ucV2); // UC must be generated for a property change + using var harness = CreateHarness(); + var generation = harness.Generate(xamlV1, xamlV2); + Assert.NotNull(generation[1].UpdateComponentSource); - // Step 2: Compile V1 (IC only, no UC) - var (peV1, pdbV1, compilationV1) = CompileSources(PageStub, icV1); - - // Step 3: Load V1, create instance - var alc = new AssemblyLoadContext("E2EHotReloadTest", isCollectible: true); - try + harness.RunLive(generation, live => { - var assembly = alc.LoadFromStream(new MemoryStream(peV1), new MemoryStream(pdbV1)); - var pageType = assembly.GetType(PageClass)!; - var instance = Activator.CreateInstance(pageType)!; - - // Verify V1: find the Label in the Content tree - var page = (ContentPage)instance; + var page = live.GetInstance(); var layout = page.Content as Layout; Assert.NotNull(layout); var label = layout!.Children.OfType