From b073d041d12266422a6931758db907a54569b686 Mon Sep 17 00:00:00 2001 From: Tim Purdum Date: Tue, 9 Dec 2025 16:54:59 -0600 Subject: [PATCH 01/18] Add protobuf serialization infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add protobuf-net package for binary serialization support - Add ProtobufSerializationBase.cs with base records for serialization - Add IProtobufSerializable interface for type contracts - Add ProtobufSerializableAttribute and SerializedMethodAttribute - Add JsSyncManager stub for JS interop with protobuf support - Add ProtobufSourceGenerator skeleton for future serialization code generation - Update ESBuildGenerator to use new shared project structure - Add SourceGenerator.Shared project with ProcessHelper and extension methods - Update Directory.Build.props with CoreProjectPath for source generators 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Directory.Build.props | 31 +- esBuildWaitForCompletion.ps1 | 33 ++ .../ProcessHelper.cs | 115 ++++ .../ProtobufDefinitionsGenerator.cs | 376 +++++++++++++ .../SerializationGenerator.cs | 521 ++++++++++++++++++ .../StringExtensions.cs | 14 + ...oBlazor.Core.SourceGenerator.Shared.csproj | 17 + .../ESBuildGenerator.cs | 272 +++++++++ .../ESBuildLauncher.cs | 326 ----------- .../ProtobufSourceGenerator.cs | 83 +++ ...ptic.GeoBlazor.Core.SourceGenerator.csproj | 32 +- .../ProtobufSerializableAttribute.cs | 6 + .../Attributes/SerializedMethodAttribute.cs | 6 + .../Components/MapComponent.razor.cs | 4 +- .../Interfaces/IProtobufSerializable.cs | 18 + .../Serialization/GeoBlazorMetaData.cs | 22 + .../Serialization/JsSyncManager.cs | 85 +++ .../ProtobufSerializationBase.cs | 72 +++ .../Serialization/SerializationExtensions.cs | 6 + 19 files changed, 1692 insertions(+), 347 deletions(-) create mode 100644 esBuildWaitForCompletion.ps1 create mode 100644 src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProcessHelper.cs create mode 100644 src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProtobufDefinitionsGenerator.cs create mode 100644 src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/SerializationGenerator.cs create mode 100644 src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/StringExtensions.cs create mode 100644 src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/dymaptic.GeoBlazor.Core.SourceGenerator.Shared.csproj create mode 100644 src/dymaptic.GeoBlazor.Core.SourceGenerator/ESBuildGenerator.cs delete mode 100644 src/dymaptic.GeoBlazor.Core.SourceGenerator/ESBuildLauncher.cs create mode 100644 src/dymaptic.GeoBlazor.Core.SourceGenerator/ProtobufSourceGenerator.cs create mode 100644 src/dymaptic.GeoBlazor.Core/Attributes/ProtobufSerializableAttribute.cs create mode 100644 src/dymaptic.GeoBlazor.Core/Attributes/SerializedMethodAttribute.cs create mode 100644 src/dymaptic.GeoBlazor.Core/Interfaces/IProtobufSerializable.cs create mode 100644 src/dymaptic.GeoBlazor.Core/Serialization/GeoBlazorMetaData.cs create mode 100644 src/dymaptic.GeoBlazor.Core/Serialization/JsSyncManager.cs create mode 100644 src/dymaptic.GeoBlazor.Core/Serialization/ProtobufSerializationBase.cs create mode 100644 src/dymaptic.GeoBlazor.Core/Serialization/SerializationExtensions.cs diff --git a/Directory.Build.props b/Directory.Build.props index 9236771c7..8c8de5ef3 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,10 +1,23 @@ - - enable - enable - 4.3.0.8 - true - false - $(DefineConstants);ENABLE_COMPRESSION - - \ No newline at end of file + + enable + enable + 4.3.0.8 + true + Debug;Release;SourceGen Highlighting + AnyCPU + $(MSBuildThisFileDirectory)src\dymaptic.GeoBlazor.Core + + $(StaticWebAssetEndpointExclusionPattern);js/** + + + + + + + + + + + + diff --git a/esBuildWaitForCompletion.ps1 b/esBuildWaitForCompletion.ps1 new file mode 100644 index 000000000..370398507 --- /dev/null +++ b/esBuildWaitForCompletion.ps1 @@ -0,0 +1,33 @@ +# PowerShell +param([string][Alias("c")]$Configuration = "Debug", + [switch]$Pro) + +if ($Pro) { + $RootDir = Join-Path -Path $PSScriptRoot "..\src\dymaptic.GeoBlazor.Pro" +} else { + $RootDir = Join-Path -Path $PSScriptRoot "src\dymaptic.GeoBlazor.Core" +} + +$LockFilePath = Join-Path -Path $RootDir "esBuild.$Configuration.lock" + +Write-Host "Waiting for lock file:" $LockFilePath + +if (Test-Path -Path $LockFilePath) { + Write-Host "Lock file found. Waiting for release." +} else { + Start-Sleep -Seconds 1 + if (Test-Path -Path $LockFilePath) { + Write-Host "Lock file found. Waiting for release." + } else { + Write-Host "No lock file found. Exiting." + return 0 + } +} + +while (Test-Path -Path $LockFilePath) { + Start-Sleep -Seconds 1 + Write-Host -NoNewline "." +} + +Write-Host "Lock file removed. Exiting." +exit 0 diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProcessHelper.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProcessHelper.cs new file mode 100644 index 000000000..b6a9560c6 --- /dev/null +++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProcessHelper.cs @@ -0,0 +1,115 @@ +using CliWrap; +using CliWrap.EventStream; +using Microsoft.CodeAnalysis; +using System.Runtime.InteropServices; +using System.Text; + + +namespace dymaptic.GeoBlazor.Core.SourceGenerator.Shared; + +public static class ProcessHelper +{ + public static async Task RunPowerShellScript(string processName, string workingDirectory, + string powershellScriptName, string arguments, StringBuilder logBuilder, CancellationToken token, + Dictionary? environmentVariables = null) + { + string shellArguments = $"-NoProfile -ExecutionPolicy ByPass -File \"{ + Path.Combine(workingDirectory, powershellScriptName)}\" {arguments}"; + + await Execute(processName, workingDirectory, "pwsh", shellArguments, logBuilder, token, + environmentVariables); + } + + public static async Task RunPowerShellCommand(string processName, string workingDirectory, + string arguments, StringBuilder logBuilder, CancellationToken token, + Dictionary? environmentVariables = null) + { + string shellArguments = $"-NoProfile -ExecutionPolicy ByPass -Command {{ {arguments} }}"; + + await Execute(processName, workingDirectory, "pwsh", shellArguments, logBuilder, token, + environmentVariables); + } + + public static async Task Execute(string processName, string workingDirectory, string? fileName, + string shellArguments, StringBuilder logBuilder, CancellationToken token, + Dictionary? environmentVariables = null) + { + fileName ??= shellCommand; + + StringBuilder outputBuilder = new(); + int? processId = null; + int? exitCode = null; + + token.Register(() => + { + logBuilder.AppendLine($"{processName}: Command execution cancelled."); + logBuilder.AppendLine(outputBuilder.ToString()); + outputBuilder.Clear(); + }); + + Command cmd = Cli.Wrap(fileName) + .WithArguments(shellArguments) + .WithWorkingDirectory(workingDirectory) + .WithValidation(CommandResultValidation.None) + .WithEnvironmentVariables(environmentVariables ?? new Dictionary()); + + await foreach (CommandEvent cmdEvent in cmd.ListenAsync(cancellationToken: token)) + { + switch (cmdEvent) + { + case StartedCommandEvent started: + processId = started.ProcessId; + outputBuilder.AppendLine($"{processName} Process started: {started.ProcessId}"); + outputBuilder.AppendLine($"{processName} - PID {processId}: Executing command: {fileName} {shellArguments}"); + break; + case StandardOutputCommandEvent stdOut: + string line = stdOut.Text.Trim(); + outputBuilder.AppendLine($"{processName} - PID {processId}: [stdout] {line}"); + break; + case StandardErrorCommandEvent stdErr: + outputBuilder.AppendLine($"{processName} - PID {processId}: [stderr] {stdErr.Text}"); + break; + case ExitedCommandEvent exited: + exitCode = exited.ExitCode; + outputBuilder.AppendLine($"{processName} - PID {processId}: Process exited with code: {exited.ExitCode}"); + logBuilder.AppendLine(outputBuilder.ToString()); + outputBuilder.Clear(); + break; + } + } + + if (outputBuilder.Length > 0) + { + logBuilder.AppendLine(outputBuilder.ToString()); + } + + + if (exitCode != 0) + { + logBuilder.AppendLine(outputBuilder.ToString()); + // Throw an exception if the process returned an error + throw new Exception($"{processName}: Error executing command '{shellArguments}' for process {processId}. Exit code: {exitCode}"); + } + + // Return the standard output if the process completed normally + logBuilder.AppendLine($"{processName}: Command '{shellArguments}' completed successfully on process {processId}."); + } + + public static void Log(string title, string message, DiagnosticSeverity severity, + SourceProductionContext context) + { + context.ReportDiagnostic(Diagnostic.Create(new DiagnosticDescriptor( + "GBSourceGen", + title, + message, + "Logging", + severity, + isEnabledByDefault: true), Location.None)); + } + + private const string LinuxShell = "/bin/bash"; + private const string WindowsShell = "cmd"; + private static readonly string shellCommand = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? WindowsShell + : LinuxShell; +} \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProtobufDefinitionsGenerator.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProtobufDefinitionsGenerator.cs new file mode 100644 index 000000000..57f4ab0f3 --- /dev/null +++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProtobufDefinitionsGenerator.cs @@ -0,0 +1,376 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Immutable; +using System.Text; + + +namespace dymaptic.GeoBlazor.Core.SourceGenerator.Shared; + +/// +/// Generates Protobuf definitions by invoking the ProtoGen project. +/// +public static class ProtobufDefinitionsGenerator +{ + public static Dictionary? ProtoDefinitions; + + public static Dictionary UpdateProtobufDefinitions(SourceProductionContext context, + ImmutableArray types, string corePath) + { + ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator), + "Updating Protobuf definitions...", + DiagnosticSeverity.Info, + context); + + // fetch protobuf definitions + string protoTypeContent = Generate(context, types); + + string typescriptContent = $""" + export let protoTypeDefinitions: string = ` + {protoTypeContent} + `; + """; + string encoded = typescriptContent + .Replace("\"", "\\\"") + .Replace("\r\n", "\\r\\n") + .Replace("\n", "\\n"); + StringBuilder logBuilder = new(); + + string scriptPath = Path.Combine(corePath, "copyProtobuf.ps1"); + + // write protobuf definitions to geoblazorProto.ts + ProcessHelper.RunPowerShellScript("Copy Protobuf Definitions", + corePath, scriptPath, + $"-Content \"{encoded}\"", + logBuilder, context.CancellationToken).GetAwaiter().GetResult(); + + ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator), + logBuilder.ToString(), + DiagnosticSeverity.Info, + context); + + ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator), + $"Protobuf definitions updated successfully.", + DiagnosticSeverity.Info, + context); + + return ProtoDefinitions!; + } + + private static string Generate(SourceProductionContext context, + ImmutableArray types) + { + try + { + ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator), + "Generating Protobuf schema", + DiagnosticSeverity.Info, + context); + + // Extract protobuf definitions from syntax nodes + ProtoDefinitions ??= ExtractProtobufDefinitions(types, context); + + ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator), + $"Extracted {ProtoDefinitions.Count} Protobuf message definitions.", + DiagnosticSeverity.Info, + context); + + // Generate new proto file content + string newProtoContent = GenerateProtoFileContent(ProtoDefinitions); + + ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator), + "Protobuf schema generation complete", + DiagnosticSeverity.Info, + context); + + return newProtoContent; + } + catch (Exception ex) + { + ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator), + $"Error generating Protobuf definitions: {ex.Message}", + DiagnosticSeverity.Error, + context); + return string.Empty; + } + } + + public static Dictionary ExtractProtobufDefinitions( + ImmutableArray types, SourceProductionContext context) + { + var definitions = new Dictionary(); + const string protoContractAttribute = "ProtoContract"; + + foreach (BaseTypeDeclarationSyntax type in types) + { + if (type.AttributeLists.SelectMany(a => a.Attributes) + .All(a => a.Name.ToString() != protoContractAttribute)) + { + if (type.Identifier.Text.EndsWith("SerializationRecord") + && type.Identifier.Text != "MapComponentSerializationRecord" + && type.Identifier.Text != "MapComponentCollectionSerializationRecord") + { + ProcessHelper.Log( + nameof(ProtobufDefinitionsGenerator), + $"Processing syntax node: {type.Identifier.Text}, which is a SerializationRecord without ProtoContract attribute. Attributes: {string.Join(", ", type.AttributeLists.SelectMany(al => al.Attributes.SelectMany(a => a.ToString())))}", + DiagnosticSeverity.Warning, + context); + } + continue; + } + + try + { + var messageDef = ExtractMessageDefinition(type); + + if (messageDef != null) + { + definitions[messageDef.Name] = messageDef; + } + } + catch (Exception ex) + { + ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator), + $"Error processing syntax node {type.Identifier.Text}: {ex.Message}", + DiagnosticSeverity.Warning, + context); + } + } + + return definitions; + } + + private static ProtoMessageDefinition? ExtractMessageDefinition(BaseTypeDeclarationSyntax syntaxNode) + { + // Get ProtoContract attribute to find the message name + var protoContractAttr = syntaxNode.AttributeLists + .SelectMany(al => al.Attributes) + .FirstOrDefault(a => a.Name.ToString().Contains("ProtoContract")); + + if (protoContractAttr == null) + { + return null; + } + + // Extract the Name parameter from ProtoContract attribute + string messageName = syntaxNode.Identifier.Text; + var nameArg = protoContractAttr.ArgumentList?.Arguments + .FirstOrDefault(arg => arg.NameEquals?.Name.Identifier.Text == "Name"); + + if (nameArg is { Expression: LiteralExpressionSyntax literal }) + { + messageName = literal.Token.ValueText; + } + + var fields = new List(); + var protoIncludeFields = new List(); + + // Extract ProtoInclude attributes for oneof fields + var protoIncludeAttrs = syntaxNode.AttributeLists + .SelectMany(al => al.Attributes) + .Where(a => a.Name.ToString().Contains("ProtoInclude")); + + BaseTypeSyntax? baseType = syntaxNode.BaseList?.Types.FirstOrDefault(); + bool geoBlazorTypeIsInterface = false; + + if (!messageName.EndsWith("Collection") && baseType is not null) + { + string baseTypeName = baseType.Type.ToString(); + int innerTypeIndex = baseTypeName.IndexOf("<", StringComparison.OrdinalIgnoreCase) + 1; + string geoBlazorTypeName = baseTypeName.Substring(innerTypeIndex, baseTypeName.Length - innerTypeIndex - 1); + + if (geoBlazorTypeName[0] == 'I' && char.IsUpper(geoBlazorTypeName[1])) + { + geoBlazorTypeIsInterface = true; + } + } + + foreach (var includeAttr in protoIncludeAttrs) + { + if (includeAttr.ArgumentList?.Arguments.Count >= 2) + { + var tagArg = includeAttr.ArgumentList.Arguments[0].Expression; + var typeArg = includeAttr.ArgumentList.Arguments[1].Expression; + + if (tagArg is LiteralExpressionSyntax tagLiteral && + int.TryParse(tagLiteral.Token.ValueText, out int tag)) + { + string typeName = ExtractTypeFromExpression(typeArg); + protoIncludeFields.Add(new ProtoIncludeDefinition + { + Tag = tag, + TypeName = typeName + }); + } + } + } + + // Extract fields with ProtoMember attributes + foreach (var member in syntaxNode.ChildNodes()) + { + if (member is PropertyDeclarationSyntax property) + { + var protoMemberAttr = property.AttributeLists + .SelectMany(al => al.Attributes) + .FirstOrDefault(a => a.Name.ToString().Contains("ProtoMember")); + + if (protoMemberAttr is { ArgumentList.Arguments.Count: > 0 }) + { + var fieldNumber = protoMemberAttr.ArgumentList.Arguments[0].Expression; + if (fieldNumber is LiteralExpressionSyntax fieldNumLiteral && + int.TryParse(fieldNumLiteral.Token.ValueText, out int num)) + { + var fieldType = ConvertCSharpTypeToProtoType(property.Type.ToString()); + fields.Add(new ProtoFieldDefinition + { + Type = fieldType, + Name = property.Identifier.Text.ToLowerFirstChar(), + Number = num + }); + } + } + } + } + + return new ProtoMessageDefinition + { + Name = messageName, + Fields = fields.OrderBy(f => f.Number).ToList(), + ProtoIncludes = protoIncludeFields.OrderBy(p => p.Tag).ToList(), + GeoBlazorTypeIsInterface = geoBlazorTypeIsInterface + }; + } + + private static string ExtractTypeFromExpression(ExpressionSyntax expression) + { + // Handle typeof(TypeName) expression + if (expression is TypeOfExpressionSyntax typeOfExpr) + { + return typeOfExpr.Type.ToString(); + } + + return expression.ToString(); + } + + private static string ConvertCSharpTypeToProtoType(string csharpType) + { + // Check if it's an array type (need repeated keyword) + bool isRepeated = (csharpType.Contains("[]") && csharpType != "byte[]" && csharpType != "byte[]?") + || csharpType.Contains("IEnumerable") + || csharpType.Contains("List<"); + + // Remove nullable markers and array indicators + string cleanType = csharpType.Replace("?", "").Trim(); + + if (isRepeated) + { + cleanType = cleanType.Replace("[]", "") + .Replace("IEnumerable<", "") + .Replace("IList<", "") + .Replace("List<", "") + .Replace(">", "") + .Trim(); + } + + // Map C# types to proto types + string protoType = cleanType switch + { + "string" => "string", + "int" => "int32", + "long" => "int64", + "double" => "double", + "float" => "float", + "bool" => "bool", + "byte[]" => "bytes", + _ when cleanType.Contains("SerializationRecord") => cleanType.Replace("SerializationRecord", ""), + _ => cleanType + }; + + return isRepeated ? $"repeated {protoType}" : protoType; + } + + private static string GenerateProtoFileContent(Dictionary definitions) + { + var sb = new StringBuilder(); + + // Header + sb.AppendLine("syntax = \"proto3\";"); + sb.AppendLine("package dymaptic.GeoBlazor.Core.Serialization;"); + sb.AppendLine("import \"google/protobuf/empty.proto\";"); + sb.AppendLine(); + + // First, generate regular message definitions (those without ProtoIncludes or with both fields and includes) + foreach (var def in definitions.Values.OrderBy(d => d.Name)) + { + if (def.Name == "MapComponent" || def.Name == "MapComponentCollection") + { + continue; // Handle these special cases separately + } + + sb.AppendLine($"message {def.Name} {{"); + + // Generate regular fields + foreach (var field in def.Fields) + { + sb.AppendLine($" {field.Type} {field.Name} = {field.Number};"); + } + + sb.AppendLine("}"); + } + + // Generate MapComponent with oneof for all the serialization records + if (definitions.TryGetValue("MapComponent", out var mapComponentDef) && mapComponentDef.ProtoIncludes.Any()) + { + sb.AppendLine("message MapComponent {"); + sb.AppendLine(" oneof subtype {"); + + foreach (var include in mapComponentDef.ProtoIncludes) + { + string typeName = include.TypeName.Replace("SerializationRecord", ""); + sb.AppendLine($" {typeName} {typeName} = {include.Tag};"); + } + + sb.AppendLine(" }"); + sb.AppendLine("}"); + } + + // Generate MapComponentCollection with oneof for all the collection types + if (definitions.TryGetValue("MapComponentCollection", out var collectionDef) && collectionDef.ProtoIncludes.Any()) + { + sb.AppendLine("message MapComponentCollection {"); + sb.AppendLine(" oneof subtype {"); + + foreach (var include in collectionDef.ProtoIncludes) + { + string typeName = include.TypeName.Replace("SerializationRecord", ""); + sb.AppendLine($" {typeName} {typeName} = {include.Tag};"); + } + + sb.AppendLine(" }"); + sb.AppendLine("}"); + } + + return sb.ToString(); + } +} + +public class ProtoMessageDefinition +{ + public string Name { get; set; } = string.Empty; + public List Fields { get; set; } = new(); + public List ProtoIncludes { get; set; } = new(); + + public bool GeoBlazorTypeIsInterface { get; set; } +} + +public class ProtoFieldDefinition +{ + public string Type { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public int Number { get; set; } +} + +public class ProtoIncludeDefinition +{ + public int Tag { get; set; } + public string TypeName { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/SerializationGenerator.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/SerializationGenerator.cs new file mode 100644 index 000000000..ebee6f687 --- /dev/null +++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/SerializationGenerator.cs @@ -0,0 +1,521 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Immutable; +using System.Text; + + +namespace dymaptic.GeoBlazor.Core.SourceGenerator.Shared; + +/// +/// Generates serialization data for methods and classes marked with the appropriate attributes. +/// +public static class SerializationGenerator +{ + public static void GenerateSerializationDataClass(SourceProductionContext context, + ImmutableArray types, + Dictionary protoMessageDefinitions, + bool isPro, bool isTest) + { + try + { + ProcessHelper.Log(nameof(SerializationGenerator), + "Generating serialized data class...", + DiagnosticSeverity.Info, + context); + + ImmutableArray serializedMethodsCollection = + [ + ..types + .SelectMany(t => t.ToSerializableMethodRecords()) + ]; + + Dictionary protoSerializableTypes = []; + + foreach (BaseTypeDeclarationSyntax type in types) + { + string typeName = type.Identifier.Text; + if (type.AttributeLists.SelectMany(a => a.Attributes) + .Any(a => a.Name.ToString() is ProtoSerializableAttribute)) + { + if (protoMessageDefinitions.TryGetValue(typeName, out ProtoMessageDefinition? messageDefinition)) + { + protoSerializableTypes[typeName] = messageDefinition; + } + } + + // get the parent type + string? parentTypeName = type.BaseList? + .Types + .FirstOrDefault(bt => protoMessageDefinitions.ContainsKey(bt.ToString()))? + .ToString(); + + if (parentTypeName is not null) + { + // check parent type + BaseTypeDeclarationSyntax? parentType = types.FirstOrDefault(bt => bt.Identifier.Text == parentTypeName); + + if (parentType?.AttributeLists.SelectMany(a => a.Attributes) + .Any(a => a.Name.ToString() is ProtoSerializableAttribute) == true) + { + protoSerializableTypes[typeName] = protoMessageDefinitions[parentTypeName]; + } + } + } + + string className = isPro ? "ProSerializationData" : "CoreSerializationData"; + + StringBuilder classBuilder = new($$""" + // + + #nullable enable + + {{(isPro ? "using dymaptic.GeoBlazor.Core.Serialization;" : "")}} + using System.Collections; + using FieldInfo = dymaptic.GeoBlazor.Core.Components.FieldInfo; + + namespace dymaptic.GeoBlazor.{{(isPro ? "Pro" : "Core")}}.Serialization; + + /// + /// This class is generated by a source generator and contains pre-analyzed serialization information. + /// + internal static class {{className}} + { + + """); + + if (!isPro) + { + classBuilder.AppendLine(GenerateExtensionMethods(protoSerializableTypes)); + } + + classBuilder.AppendLine( + GenerateSerializableMethodRecords(serializedMethodsCollection, isPro)); + + classBuilder.AppendLine("}"); + + ProcessHelper.Log(nameof(SerializationGenerator), + $"Generated serialized data class: {className}.g.cs", + DiagnosticSeverity.Info, + context); + + if (isTest) + { + ProcessHelper.Log(nameof(SerializationGenerator), + $"Skipping generating file for test.", + DiagnosticSeverity.Info, + context); + } + else + { + context.AddSource($"{className}.g.cs", classBuilder.ToString()); + } + } + catch (Exception ex) + { + ProcessHelper.Log(nameof(SerializationGenerator), + $"Error generating serialized data class: {ex}", + DiagnosticSeverity.Error, + context); + } + } + + private static string GenerateExtensionMethods(Dictionary protoDefinitions) + { + StringBuilder readJsProtoStreamRefMethod = new( + """ + /// + /// Convenience method to deserialize an to a specific type via protobuf. + /// + public static async Task ReadJsStreamReferenceAsProtobuf(this IJSStreamReference jsStreamReference, + Type returnType, long maxAllowedSize = 1_000_000_000) + { + await using Stream stream = await jsStreamReference.OpenReadStreamAsync(maxAllowedSize); + using MemoryStream memoryStream = new(); + await stream.CopyToAsync(memoryStream); + memoryStream.Seek(0, SeekOrigin.Begin); + + string typeName = returnType.Name.Replace("SerializationRecord", ""); + + switch (typeName) + { + + """); + + StringBuilder readJsProtoCollectionStreamRefMethod = new( + """ + /// + /// Convenience method to deserialize an to a specific coolection type via protobuf. + /// + public static async Task ReadJsStreamReferenceAsProtobufCollection(this IJSStreamReference jsStreamReference, + Type returnType, long maxAllowedSize = 1_000_000_000) + { + await using Stream stream = await jsStreamReference.OpenReadStreamAsync(maxAllowedSize); + using MemoryStream memoryStream = new(); + await stream.CopyToAsync(memoryStream); + memoryStream.Seek(0, SeekOrigin.Begin); + + string typeName = returnType.Name.Replace("SerializationRecord", ""); + + switch (typeName) + { + + """); + + StringBuilder protoTypeDictionary = new( + """ + /// + /// A collection of types that can be serialized to Protobuf + /// + public static Dictionary ProtoContractTypes => _protoContractTypes; + + private static readonly Dictionary _protoContractTypes = new() + { + + """); + + StringBuilder protoCollectionTypeDictionary = new( + """ + /// + /// A collection of types that can be serialized to Protobuf as collections of a specific Type + /// + public static Dictionary ProtoCollectionTypes => _protoCollectionTypes; + + private static readonly Dictionary _protoCollectionTypes = new() + { + + """); + + StringBuilder protoMemberGenerationMethod = new( + """ + /// + /// Convenience method to generate a Protobuf serialized parameter. + /// + public static object ToProtobufParameter(this object value, Type serializableType, bool isServer) + { + MemoryStream memoryStream = new(); + switch (serializableType.Name) + { + + """); + + StringBuilder protoCollectionGenerationMethod = new( + """ + /// + /// Convenience method to generate a Protobuf serialized collection parameter. + /// + public static object ToProtobufCollectionParameter(this IList items, Type serializableType, bool isServer) + { + MemoryStream memoryStream = new(); + string typeName = $"{serializableType.Name}Collection"; + switch (serializableType.Name) + { + + """); + + foreach (KeyValuePair kvp in protoDefinitions.OrderBy(kvp => kvp.Key)) + { + string protoSerializableType = kvp.Key; + ProtoMessageDefinition definition = kvp.Value; + + if (definition.Name == "MapComponent") + { + continue; + } + + string serializationRecordType = definition.Name + "SerializationRecord"; + string serializationCollectionRecordType = definition.Name + "CollectionSerializationRecord"; + + string variableName = protoSerializableType.ToLowerFirstChar(); + + protoTypeDictionary.AppendLine( + $$""" + { typeof({{protoSerializableType}}), typeof({{serializationRecordType}}) }, + """); + + protoCollectionTypeDictionary.AppendLine( + $$""" + { typeof({{protoSerializableType}}), typeof({{serializationCollectionRecordType}}) }, + """); + + readJsProtoStreamRefMethod.AppendLine( + $$""" + case "{{protoSerializableType}}": + {{serializationRecordType}} {{variableName}} = + Serializer.Deserialize<{{serializationRecordType}}>(memoryStream); + if ({{variableName}}.IsNull) + { + return default!; + } + return (T?)(object?){{variableName}}?.FromSerializationRecord(); + """); + + readJsProtoCollectionStreamRefMethod.AppendLine( + $$""" + case "{{protoSerializableType}}": + {{serializationCollectionRecordType}} {{variableName}} = + Serializer.Deserialize<{{serializationCollectionRecordType}}>(memoryStream); + if ({{variableName}}.IsNull) + { + return default!; + } + return (T?)(object?){{variableName}}?.Items?.Select(i => i.FromSerializationRecord()).Cast<{{protoSerializableType}}>().ToArray(); + """); + + if (definition.Name == "Attribute") + { + // Attribute is not a GeoBlazor type so skip next injection + continue; + } + + protoMemberGenerationMethod.AppendLine( + $$""" + case "{{protoSerializableType}}": + {{definition.Name}}SerializationRecord {{variableName}} = + (({{protoSerializableType}})value).ToProtobuf(); + Serializer.Serialize(memoryStream, {{variableName}}); + + break; + """); + + protoCollectionGenerationMethod.AppendLine( + $$""" + case "{{protoSerializableType}}": + {{definition.Name}}CollectionSerializationRecord {{variableName}} = + new(items.Cast<{{protoSerializableType}}>().Select(i => i.ToProtobuf()).ToArray()); + Serializer.Serialize(memoryStream, {{variableName}}); + + break; + """); + } + + protoTypeDictionary.AppendLine(" };"); + protoCollectionTypeDictionary.AppendLine(" };"); + + readJsProtoStreamRefMethod.AppendLine( + """ + } + + return default!; + } + """); + + readJsProtoCollectionStreamRefMethod.AppendLine( + """ + } + + return default!; + } + """); + + protoMemberGenerationMethod.AppendLine( + """ + } + + memoryStream.Seek(0, SeekOrigin.Begin); + + if (isServer) + { + return new DotNetStreamReference(memoryStream); + } + + byte[] data = memoryStream.ToArray(); + memoryStream.Dispose(); + + return data; + } + """); + + protoCollectionGenerationMethod.AppendLine( + """ + } + + memoryStream.Seek(0, SeekOrigin.Begin); + + if (isServer) + { + return new DotNetStreamReference(memoryStream); + } + + byte[] data = memoryStream.ToArray(); + memoryStream.Dispose(); + + return data; + } + """); + + return $""" + {readJsProtoStreamRefMethod} + + {readJsProtoCollectionStreamRefMethod} + + {protoMemberGenerationMethod} + + {protoCollectionGenerationMethod} + + {protoTypeDictionary} + + {protoCollectionTypeDictionary} + """; + } + + private static string GenerateSerializableMethodRecords( + ImmutableArray serializedMethodsCollection, bool isPro) + { + StringBuilder outputBuilder; + + if (isPro) + { + outputBuilder = new( + """ + /// + /// A collection of serializable methods and their parameters/return types. + /// + public static Dictionary SerializableMethods => _serializableMethods + .Concat(CoreSerializationData.SerializableMethods) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + private static readonly Dictionary _serializableMethods = new() + { + + """); + } + else + { + outputBuilder = new( + """ + /// + /// A collection of serializable methods and their parameters/return types. + /// + public static Dictionary SerializableMethods => _serializableMethods; + + private static readonly Dictionary _serializableMethods = new() + { + + """); + } + + foreach (var classGroup in serializedMethodsCollection.GroupBy(m => m.ClassName)) + { + outputBuilder.AppendLine($$""" + ["{{classGroup.Key}}"] = + [ + """); + + foreach (var methodRecord in classGroup) + { + if (methodRecord.Parameters.Values.Contains("T") + || methodRecord.Parameters.Values.Contains("T?") + || methodRecord.ReturnType == "T" + || methodRecord.ReturnType == "T?" + || methodRecord.ReturnType == "T[]" + || methodRecord.ReturnType == "T[]?" + || methodRecord.ReturnType?.Contains("") == true) + { + continue; + } + outputBuilder.AppendLine($$""" + new SerializableMethodRecord("{{methodRecord.MethodName.ToLowerFirstChar()}}", + [ + """); + + foreach (var param in methodRecord.Parameters) + { + bool isNullable = param.Value.EndsWith("?"); + string value = isNullable ? param.Value.TrimEnd('?') : param.Value; + string isNullableText = isNullable ? "true" : "false"; + string? collectionType = null; + + if (value.EndsWith("[]")) + { + collectionType = value.Replace("[]", ""); + } + else if (value.Contains("<") && value.Contains(">")) + { + int genericStart = value.IndexOf("<", StringComparison.OrdinalIgnoreCase); + collectionType = value.Substring(genericStart + 1, value.Length - genericStart - 2); + } + + string collectionText = collectionType is null + ? "null" + : $"typeof({collectionType})"; + outputBuilder.AppendLine($" new SerializableParameterRecord(typeof({value}), {isNullableText}, {collectionText}),"); + } + + if (methodRecord.ReturnType != null) + { + string returnValue = methodRecord.ReturnType.TrimEnd('?'); + + bool isCollectionReturn = returnValue.EndsWith("[]") || + (returnValue.Contains("<") && returnValue.Contains(">")); + + string singleType = isCollectionReturn + ? returnValue.Contains("<") && returnValue.Contains(">") + ? $"typeof({returnValue.Substring( + returnValue.IndexOf("<", StringComparison.OrdinalIgnoreCase) + 1, + returnValue.Length - returnValue.IndexOf("<", StringComparison.OrdinalIgnoreCase) - 2) + })" + : $"typeof({returnValue.Replace("[]", "")})" + : "null"; + string isNullable = methodRecord.ReturnType.EndsWith("?") ? "true" : "false"; + outputBuilder.AppendLine($" ], new SerializableParameterRecord(typeof({returnValue}), {isNullable}, {singleType})),"); + } + else + { + outputBuilder.AppendLine(" ])),"); + } + } + + outputBuilder.AppendLine(" ],"); + } + + outputBuilder.AppendLine(" };"); + + return outputBuilder.ToString(); + } + + private static List ToSerializableMethodRecords(this BaseTypeDeclarationSyntax typeSyntax) + { + List methods = typeSyntax + .ChildNodes() + .OfType() + .Where(m => m.AttributeLists + .SelectMany(a => a.Attributes) + .Any(attr => attr.Name.ToString() == SerializedMethodAttributeName)) + .ToList(); + + List methodRecords = []; + + foreach (MethodDeclarationSyntax method in methods) + { + string returnType = method.ReturnType.ToString(); + + if (returnType.StartsWith("Task") || returnType.StartsWith("ValueTask")) + { + int bracketIndex = returnType.IndexOf('<'); + returnType = returnType.Substring(bracketIndex + 1, returnType.Length - bracketIndex - 2); + } + SerializableMethodRecord record = new(typeSyntax.Identifier.Text, + method.Identifier.Text, + method.ParameterList.Parameters.ToDictionary( + p => p.Identifier.Text, + p => p.Type!.ToString()), + returnType); + + methodRecords.Add(record); + } + + return methodRecords; + } + + private const string SerializedMethodAttributeName = "SerializedMethod"; + private const string ProtoSerializableAttribute = "ProtobufSerializable"; +} + +public record SerializableMethodRecord(string ClassName, string MethodName, Dictionary Parameters, + string ReturnType) +{ + public string ClassName { get; } = ClassName; + public string MethodName { get; } = MethodName; + public Dictionary Parameters { get; } = Parameters; + public string? ReturnType { get; } = ReturnType; +} \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/StringExtensions.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/StringExtensions.cs new file mode 100644 index 000000000..435535321 --- /dev/null +++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/StringExtensions.cs @@ -0,0 +1,14 @@ +namespace dymaptic.GeoBlazor.Core.SourceGenerator.Shared; + +public static class StringExtensions +{ + public static string ToLowerFirstChar(this string input) + { + if (string.IsNullOrEmpty(input) || char.IsLower(input[0])) + { + return input; + } + + return char.ToLower(input[0]) + input.Substring(1); + } +} \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/dymaptic.GeoBlazor.Core.SourceGenerator.Shared.csproj b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/dymaptic.GeoBlazor.Core.SourceGenerator.Shared.csproj new file mode 100644 index 000000000..3737db384 --- /dev/null +++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/dymaptic.GeoBlazor.Core.SourceGenerator.Shared.csproj @@ -0,0 +1,17 @@ + + + + Library + netstandard2.0 + enable + enable + latest + Debug;Release;SourceGen Highlighting + AnyCPU + + + + + + + diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator/ESBuildGenerator.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator/ESBuildGenerator.cs new file mode 100644 index 000000000..2217fcad3 --- /dev/null +++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator/ESBuildGenerator.cs @@ -0,0 +1,272 @@ +using dymaptic.GeoBlazor.Core.SourceGenerator.Shared; +using Microsoft.CodeAnalysis; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Text; + + +namespace dymaptic.GeoBlazor.Core.SourceGenerator; + +/// +/// Triggers the ESBuild build process for the GeoBlazor project, so that your JavaScript code is up to date. +/// +[Generator] +public class ESBuildGenerator : IIncrementalGenerator +{ + public static bool InProcess { get; private set; } + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Tracks all TypeScript source files in the Scripts directories of Core and Pro. + // This will trigger the build any time a TypeScript file is added, removed, or changed. + IncrementalValueProvider> tsFilesProvider = context.AdditionalTextsProvider + .Where(static text => text.Path.Contains("Scripts") + && text.Path.EndsWith(".ts")) + .Collect(); + + // Reads the MSBuild properties to get the project directory and configuration. + IncrementalValueProvider<(string?, string?, string?)> optionsProvider = + context.AnalyzerConfigOptionsProvider.Select((configProvider, _) => + { + configProvider.GlobalOptions.TryGetValue("build_property.CoreProjectPath", + out var projectDirectory); + + configProvider.GlobalOptions.TryGetValue("build_property.Configuration", + out var configuration); + + configProvider.GlobalOptions.TryGetValue("build_property.PipelineBuild", + out var pipelineBuild); + + return (projectDirectory, configuration, pipelineBuild); + }); + + var combined = + tsFilesProvider + .Combine(optionsProvider) + .Combine(context.CompilationProvider); + + context.RegisterSourceOutput(combined, FilesChanged); + } + + private void FilesChanged(SourceProductionContext context, + ((ImmutableArray Files, + (string? ProjectDirectory, string? Configuration, string? PipelineBuild) Options) Data, + Compilation Compilation) pipeline) + { + if (!SetProjectDirectoryAndConfiguration(pipeline.Data.Options, context)) + { + return; + } + + ProcessHelper.Log(nameof(ESBuildGenerator), + "ESBuild Source Generation triggered.", + DiagnosticSeverity.Info, + context); + + if (pipeline.Data.Options.PipelineBuild == "true") + { + // If the pipeline build is enabled, we skip the ESBuild process. + // This is to avoid race conditions where the files are not ready on time, and we do the build separately. + ProcessHelper.Log(nameof(ESBuildGenerator), + "Skipping ESBuild process as PipelineBuild is set to true.", + DiagnosticSeverity.Info, + context); + + return; + } + + if (pipeline.Data.Files.Length > 0) + { + LaunchESBuild(context); + } + } + + private bool SetProjectDirectoryAndConfiguration( + (string? ProjectDirectory, string? Configuration, string? _) options, + SourceProductionContext context) + { + var projectDirectory = options.ProjectDirectory; + + if (projectDirectory is not null) + { + _corePath = Path.GetFullPath(projectDirectory); + + ProcessHelper.Log(nameof(ESBuildGenerator), + $"Project directory set to {_corePath}", + DiagnosticSeverity.Info, + context); + + if (_corePath.Contains("GeoBlazor.Pro")) + { + // we are inside the Pro submodule, we should also set the Pro path to build the Pro JavaScript files + var path = _corePath; + + while (!path.EndsWith("GeoBlazor.Pro")) + { + // move up the directory tree until we find the GeoBlazor.Pro directory + path = Path.GetDirectoryName(path)!; + } + + // set the pro path to the src/dymaptic.GeoBlazor.Pro directory + _proPath = Path.GetFullPath(Path.Combine(path, "src", "dymaptic.GeoBlazor.Pro")); + } + } + else + { + ProcessHelper.Log(nameof(ESBuildGenerator), + "Invalid project directory.", + DiagnosticSeverity.Error, + context); + + return false; + } + + if (options.Configuration is { } configuration) + { + _configuration = configuration; + + return true; + } + + ProcessHelper.Log(nameof(ESBuildGenerator), + "Could not parse configuration setting, invalid configuration.", + DiagnosticSeverity.Error, + context); + + return false; + } + + private void LaunchESBuild(SourceProductionContext context) + { + Stopwatch? sw = null; + + while (InProcess && (sw is null || sw.ElapsedMilliseconds < 5_000)) + { + if (sw is null) + { + sw = new Stopwatch(); + sw.Start(); + } + + Thread.Sleep(100); + } + + if (InProcess) + { + ProcessHelper.Log(nameof(ESBuildGenerator), + "Another instance of the ESBuild process has been running continuously for 5 seconds.", + DiagnosticSeverity.Error, + context); + + return; + } + + InProcess = true; + ClearESBuildLocks(context); + context.CancellationToken.ThrowIfCancellationRequested(); + + ProcessHelper.Log(nameof(ESBuildGenerator), + "Starting Core ESBuild process...", + DiagnosticSeverity.Info, + context); + + var logBuilder = new StringBuilder(DateTime.Now.ToLongTimeString()); + logBuilder.AppendLine("Starting Core ESBuild process..."); + + try + { + List tasks = []; + var buildSuccess = false; + var proBuildSuccess = false; + + // gets the esBuild.ps1 script from the Core path + tasks.Add(Task.Run(async () => + { + await ProcessHelper.RunPowerShellScript("Core", + _corePath!, "esBuild.ps1", + $"-c {_configuration}", logBuilder, context.CancellationToken); + buildSuccess = true; + })); + + if (_proPath is not null) + { + logBuilder.AppendLine("Starting Pro ESBuild process..."); + + tasks.Add(Task.Run(async () => + { + await ProcessHelper.RunPowerShellScript("Pro", + _proPath, "esProBuild.ps1", + $"-c {_configuration}", logBuilder, context.CancellationToken); + proBuildSuccess = true; + })); + } + + Task.WhenAll(tasks).GetAwaiter().GetResult(); + + if (!buildSuccess) + { + ProcessHelper.Log(nameof(ESBuildGenerator), + $"Core ESBuild process failed\r\n{logBuilder}", + DiagnosticSeverity.Error, + context); + + return; + } + + logBuilder.AppendLine("Core ESBuild process completed successfully."); + logBuilder.AppendLine(); + + if (_proPath is not null) + { + if (!proBuildSuccess) + { + ProcessHelper.Log(nameof(ESBuildGenerator), + $"Pro ESBuild process failed\r\n{logBuilder}", + DiagnosticSeverity.Error, + context); + + return; + } + + logBuilder.AppendLine("Pro ESBuild process completed successfully."); + logBuilder.AppendLine(); + } + + ProcessHelper.Log(nameof(ESBuildGenerator), + logBuilder.ToString(), + DiagnosticSeverity.Info, + context); + } + catch (Exception ex) + { + ProcessHelper.Log(nameof(ESBuildGenerator), + $"An error occurred while running ESBuild: {ex.Message}\r\n{ex.StackTrace}", + DiagnosticSeverity.Error, + context); + + ClearESBuildLocks(context); + } + finally + { + InProcess = false; + } + } + + private void ClearESBuildLocks(SourceProductionContext context) + { + StringBuilder logBuilder = new(); + string rootCorePath = Path.Combine(_corePath!, "..", ".."); + _ = Task.Run(async () => await ProcessHelper.RunPowerShellScript("Clear Locks", + rootCorePath, "esBuildClearLocks.ps1", "", + logBuilder, context.CancellationToken)); + + ProcessHelper.Log(nameof(ESBuildGenerator), + "Cleared ESBuild Process Locks", + DiagnosticSeverity.Info, + context); + } + + private static string? _corePath; + private static string? _proPath; + private static string? _configuration; +} \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator/ESBuildLauncher.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator/ESBuildLauncher.cs deleted file mode 100644 index 4cbe8fcde..000000000 --- a/src/dymaptic.GeoBlazor.Core.SourceGenerator/ESBuildLauncher.cs +++ /dev/null @@ -1,326 +0,0 @@ -using Microsoft.CodeAnalysis; -using System.Collections.Immutable; -using System.Diagnostics; -using System.Text; -using System.Text.RegularExpressions; - - -namespace dymaptic.GeoBlazor.Core.SourceGenerator; - -/// -/// Triggers the ESBuild build process for the GeoBlazor project, so that your JavaScript code is up to date. -/// -[Generator] -public class ESBuildLauncher : IIncrementalGenerator -{ - // Notifications are only used for the unit tests, source generators are not intended to have logging/output typically. - public event EventHandler? Notification; - - public void Initialize(IncrementalGeneratorInitializationContext context) - { - // Tracks all TypeScript source files in the Scripts directories of Core and Pro. - // This will trigger the build any time a TypeScript file is added, removed, or changed. - IncrementalValueProvider> jsFilesProvider = context.AdditionalTextsProvider - .Where(static text => text.Path.Contains("Scripts") && text.Path.EndsWith(".ts")) - .Collect(); - - // Reads the MSBuild properties to get the project directory and configuration. - IncrementalValueProvider<(string?, string?, string?, string?)> optionsProvider = - context.AnalyzerConfigOptionsProvider.Select((configProvider, _) => - { - configProvider.GlobalOptions.TryGetValue("build_property.MSBuildProjectDirectory", - out string? projectDirectory); - - configProvider.GlobalOptions.TryGetValue("build_property.Configuration", - out string? configuration); - - configProvider.GlobalOptions.TryGetValue("build_property.PipelineBuild", - out string? pipelineBuild); - - configProvider.GlobalOptions.TryGetValue("build_property.LogESBuildOutput", - out string? logESBuildOutput); - - return (projectDirectory, configuration, pipelineBuild, logESBuildOutput); - }); - - context.RegisterSourceOutput(optionsProvider.Combine(jsFilesProvider), FilesChanged); - } - - private void FilesChanged(SourceProductionContext context, - ((string? projectDirectory, string? configuration, string? pipelineBuild, string? logESBuildOutput) OptionsConfig, - ImmutableArray _) pipeline) - { - _logESBuildOutput = pipeline.OptionsConfig.logESBuildOutput == "true"; - - if (pipeline.OptionsConfig.pipelineBuild == "true") - { - // If the pipeline build is enabled, we skip the ESBuild process. - // This is to avoid race conditions where the files are not ready on time, and we do the build separately. - Notification?.Invoke(this, "Skipping ESBuild process as PipelineBuild is set to true."); - Log("Skipping ESBuild process as PipelineBuild is set to true."); - return; - } - - SetProjectDirectoryAndConfiguration(pipeline.OptionsConfig); - Log("ESBuildLauncher triggered."); - LaunchESBuild(context); - } - - private void SetProjectDirectoryAndConfiguration((string? projectDirectory, string? configuration, - string? _, string? __) options) - { - if (options.projectDirectory is { } projectDirectory) - { - _corePath = Path.GetFullPath(projectDirectory); - - if (_corePath.Contains("GeoBlazor.Pro")) - { - // we are inside the Pro submodule, we should also set the Pro path to build the Pro JavaScript files - string path = _corePath; - - while (!path.EndsWith("GeoBlazor.Pro")) - { - // move up the directory tree until we find the GeoBlazor.Pro directory - path = Path.GetDirectoryName(path)!; - } - - // set the pro path to the src/dymaptic.GeoBlazor.Pro directory - _proPath = Path.GetFullPath(Path.Combine(path, "src", "dymaptic.GeoBlazor.Pro")); - } - } - else - { - throw new Exception("Invalid project directory"); - } - - if (options.configuration is { } configuration) - { - _configuration = configuration; - } - else - { - Log("Could not parse configuration setting", true); - throw new Exception("Invalid configuration"); - } - } - - private void LaunchESBuild(SourceProductionContext context) - { - context.CancellationToken.ThrowIfCancellationRequested(); - - Notification?.Invoke(this, "Starting Core ESBuild process..."); - - StringBuilder logBuilder = new StringBuilder(DateTime.Now.ToLongTimeString()); - logBuilder.AppendLine("Starting Core ESBuild process..."); - - try - { - List tasks = []; - bool buildSuccess = false; - bool proBuildSuccess = false; - - // gets the esBuild.ps1 script from the Core path - tasks.Add(Task.Run(async () => - buildSuccess = await RunPowerShellScript("Core", "esBuild.ps1", _corePath!, - $"-c {_configuration}", logBuilder, context.CancellationToken))); - - if (_proPath is not null) - { - Notification?.Invoke(this, "Starting Pro ESBuild process..."); - logBuilder.AppendLine("Starting Pro ESBuild process..."); - - tasks.Add(Task.Run(async () => - proBuildSuccess = await RunPowerShellScript("Pro", "esProBuild.ps1", _proPath, - $"-c {_configuration}", logBuilder, context.CancellationToken))); - } - - string gitBranch = string.Empty; - - Process gitBranchProc = Process.Start(new ProcessStartInfo - { - WorkingDirectory = _corePath!, - FileName = "git", - Arguments = "rev-parse --abbrev-ref HEAD", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - })!; - - tasks.Add(Task.Run(async () => - gitBranch = (await gitBranchProc.StandardOutput.ReadLineAsync())?.Trim() ?? string.Empty)); - - Task.WhenAll(tasks).GetAwaiter().GetResult(); - - if (!buildSuccess) - { - Notification?.Invoke(this, "Core ESBuild process failed"); - - throw new Exception($"Core ESBuild process failed.\r\n{logBuilder}"); - } - - Notification?.Invoke(this, "Core ESBuild process completed successfully."); - Notification?.Invoke(this, ""); - logBuilder.AppendLine("Core ESBuild process completed successfully."); - logBuilder.AppendLine(); - - if (_proPath is not null) - { - if (!proBuildSuccess) - { - Notification?.Invoke(this, "Pro ESBuild process failed"); - - throw new Exception($"Pro ESBuild process failed.\r\n{logBuilder}"); - } - - Notification?.Invoke(this, "Pro ESBuild process completed successfully."); - Notification?.Invoke(this, ""); - logBuilder.AppendLine("Pro ESBuild process completed successfully."); - logBuilder.AppendLine(); - } - - if (string.IsNullOrEmpty(gitBranch)) - { - Notification?.Invoke(this, "Could not determine the current Git branch. " + - "This may affect the generated ESBuildRecord class."); - - logBuilder.AppendLine("Could not determine the current Git branch. " + - "This may affect the generated ESBuildRecord class."); - gitBranch = "unknown"; - } - - Notification?.Invoke(this, - $"ESBuild completed successfully for branch '{gitBranch}' with configuration '{_configuration}'."); - - logBuilder.AppendLine($"ESBuild completed successfully for branch '{gitBranch}' with configuration '{ - _configuration}'."); - - string source = $$""" - // - - namespace dymaptic.GeoBlazor.Core; - - /// - /// This class is generated by a source generator and contains metadata about the build. - /// - internal class ESBuildRecord - { - private const long Timestamp = {{DateTime.UtcNow.Ticks}}; - private const string GitBranch = "{{gitBranch}}"; - private const string Configuration = "{{_configuration}}"; - private const bool IncludeProBuild = {{(_proPath is not null).ToString().ToLower()}}; - } - """; - - context.AddSource("ESBuildRecord.g.cs", source); - logBuilder.AppendLine(); - logBuilder.AppendLine(source); - Log(logBuilder.ToString()); - - Notification?.Invoke(this, ""); - Notification?.Invoke(this, source); - } - catch (Exception ex) - { - Notification?.Invoke(this, $"An error occurred while running ESBuild: {ex.Message}"); - Notification?.Invoke(this, ex.StackTrace); - - Log($"{ex.Message}\r\n{ex.StackTrace}", true); - - throw new Exception( - $"An error occurred while running ESBuild: {ex.Message}\n\n{logBuilder}\n\n{ex.StackTrace}", ex); - } - } - - private void Log(string content, bool isError = false) - { - if (!_logESBuildOutput && !isError) - { - return; - } - StringBuilder loggerOutput = new StringBuilder(); - // Replace multiple consecutive newlines (with optional whitespace) with a single newline - content = Regex.Replace(content, @"\r?\n(?:\s*\r?\n)+", "\n"); - - if (!RunPowerShellScript("Logger", "esBuildLogger.ps1", _corePath!, - $"-c \"{content}\" -e {isError.ToString().ToLower()}", loggerOutput, - CancellationToken.None) - .GetAwaiter() - .GetResult()) - { - throw new Exception($"Failed to run the ESBuild logger script. {loggerOutput}"); - } - } - - private async Task RunPowerShellScript(string processName, string powershellScriptName, string workingFolder, - string arguments, StringBuilder logBuilder, CancellationToken cancellationToken) - { - ProcessStartInfo processStartInfo = new() - { - WorkingDirectory = workingFolder, - FileName = "pwsh", - Arguments = - $"-NoProfile -ExecutionPolicy ByPass -File \"{Path.Combine(workingFolder, powershellScriptName)}\" {arguments}", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var process = Process.Start(processStartInfo); - - if (process == null) - { - Notification?.Invoke(this, $"Failed to start ESBuild process for {processName}."); - logBuilder.AppendLine($"Failed to start ESBuild process for {processName}."); - return false; - } - - // Read both streams concurrently to avoid deadlocks - Task outputTask = ReadStreamAsync(process.StandardOutput, $"{processName} ESBuild Output", logBuilder, cancellationToken); - Task errorTask = ReadStreamAsync(process.StandardError, $"{processName} ESBuild Error", logBuilder, cancellationToken); - - try - { - // Use Task.Run to make the blocking WaitForExit async-friendly - await Task.Run(() => process.WaitForExit(), cancellationToken); - await Task.WhenAll(outputTask, errorTask); - - return process.ExitCode == 0; - } - catch (OperationCanceledException) - { - process.Kill(); - return false; - } - } - - private async Task ReadStreamAsync(StreamReader reader, string prefix, StringBuilder logBuilder, CancellationToken cancellationToken) - { - try - { - while (!cancellationToken.IsCancellationRequested) - { - string? line = await reader.ReadLineAsync(); - if (line == null) break; - - if (!string.IsNullOrWhiteSpace(line)) - { - Notification?.Invoke(this, $"{prefix}: {line}"); - logBuilder.AppendLine($"{prefix}: {line}"); - } - } - } - catch when (cancellationToken.IsCancellationRequested) - { - // Expected when cancellation occurs - Notification?.Invoke(this, $"{prefix}: Process was cancelled."); - logBuilder.AppendLine($"{prefix}: Process was cancelled."); - } - } - - private static string? _corePath; - private static string? _proPath; - private static string? _configuration; - private static bool _logESBuildOutput; -} \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator/ProtobufSourceGenerator.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator/ProtobufSourceGenerator.cs new file mode 100644 index 000000000..474cfe653 --- /dev/null +++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator/ProtobufSourceGenerator.cs @@ -0,0 +1,83 @@ +using dymaptic.GeoBlazor.Core.SourceGenerator.Shared; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Immutable; + + +namespace dymaptic.GeoBlazor.Core.SourceGenerator; + +/// +/// Source generator for protobuf serialization records. +/// This generator scans for types marked with ProtoContract or ProtobufSerializable attributes +/// and generates serialization infrastructure. +/// +[Generator] +public class ProtobufSourceGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Finds all class, struct, and record declarations marked with protobuf attributes. + IncrementalValueProvider> typeProvider = + context.SyntaxProvider.CreateSyntaxProvider(static (syntaxNode, _) => + syntaxNode is ClassDeclarationSyntax or StructDeclarationSyntax or RecordDeclarationSyntax + && (((BaseTypeDeclarationSyntax)syntaxNode).AttributeLists.SelectMany(a => a.Attributes) + .Any(a => a.Name.ToString() is ProtoContractAttribute or ProtoSerializableAttribute) + || syntaxNode.ChildNodes() + .OfType() + .Any(m => m.AttributeLists + .SelectMany(a => a.Attributes) + .Any(attr => attr.Name.ToString() == SerializedMethodAttributeName))), + static (context, _) => (BaseTypeDeclarationSyntax)context.Node) + .Collect(); + + // Reads the MSBuild properties to get the project directory. + IncrementalValueProvider optionsProvider = + context.AnalyzerConfigOptionsProvider.Select((configProvider, _) => + { + configProvider.GlobalOptions.TryGetValue("build_property.CoreProjectPath", + out var projectDirectory); + + return projectDirectory; + }); + + var combined = + typeProvider.Combine(optionsProvider) + .Combine(context.CompilationProvider); + + context.RegisterSourceOutput(combined, FilesChanged); + } + + private void FilesChanged(SourceProductionContext context, + ((ImmutableArray Types, string? ProjectDirectory) Data, + Compilation Compilation) pipeline) + { + // Skip if not running from the Core project + if (pipeline.Compilation.AssemblyName != "dymaptic.GeoBlazor.Core") + { + return; + } + + // Skip source generation if the project path is not available + if (string.IsNullOrEmpty(pipeline.Data.ProjectDirectory)) + { + ProcessHelper.Log(nameof(ProtobufSourceGenerator), + "CoreProjectPath not set. Skipping protobuf source generation.", + DiagnosticSeverity.Warning, + context); + return; + } + + // Log that protobuf types were found (infrastructure ready for future implementation) + if (pipeline.Data.Types.Length > 0) + { + ProcessHelper.Log(nameof(ProtobufSourceGenerator), + $"Found {pipeline.Data.Types.Length} protobuf-serializable types.", + DiagnosticSeverity.Info, + context); + } + } + + private const string ProtoContractAttribute = "ProtoContract"; + private const string ProtoSerializableAttribute = "ProtobufSerializable"; + private const string SerializedMethodAttributeName = "SerializedMethod"; +} \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator/dymaptic.GeoBlazor.Core.SourceGenerator.csproj b/src/dymaptic.GeoBlazor.Core.SourceGenerator/dymaptic.GeoBlazor.Core.SourceGenerator.csproj index 8c719dbc0..f84022e51 100644 --- a/src/dymaptic.GeoBlazor.Core.SourceGenerator/dymaptic.GeoBlazor.Core.SourceGenerator.csproj +++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator/dymaptic.GeoBlazor.Core.SourceGenerator.csproj @@ -3,23 +3,37 @@ netstandard2.0 false - enable latest - true true - dymaptic.GeoBlazor.Core.SourceGenerator dymaptic.GeoBlazor.Core.SourceGenerator - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + + + + + + $(GetTargetPathDependsOn);GetDependencyTargetPaths + + + + + + + + + + diff --git a/src/dymaptic.GeoBlazor.Core/Attributes/ProtobufSerializableAttribute.cs b/src/dymaptic.GeoBlazor.Core/Attributes/ProtobufSerializableAttribute.cs new file mode 100644 index 000000000..d22ded48b --- /dev/null +++ b/src/dymaptic.GeoBlazor.Core/Attributes/ProtobufSerializableAttribute.cs @@ -0,0 +1,6 @@ +namespace dymaptic.GeoBlazor.Core.Attributes; + +/// +/// Marks a type as serializable by Protobuf. To be used in conjunction with the interface. +/// +public class ProtobufSerializableAttribute: Attribute; \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core/Attributes/SerializedMethodAttribute.cs b/src/dymaptic.GeoBlazor.Core/Attributes/SerializedMethodAttribute.cs new file mode 100644 index 000000000..e0f732f84 --- /dev/null +++ b/src/dymaptic.GeoBlazor.Core/Attributes/SerializedMethodAttribute.cs @@ -0,0 +1,6 @@ +namespace dymaptic.GeoBlazor.Core.Attributes; + +/// +/// Identifies a method that will be serialized by the GeoBlazor serialization system. +/// +public class SerializedMethodAttribute: Attribute; \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core/Components/MapComponent.razor.cs b/src/dymaptic.GeoBlazor.Core/Components/MapComponent.razor.cs index e0f37e791..6b63fa749 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/MapComponent.razor.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/MapComponent.razor.cs @@ -1832,6 +1832,4 @@ public override void Write(Utf8JsonWriter writer, MapComponent value, JsonSerial writer.WriteRawValue(JsonSerializer.Serialize(value, typeof(object), GeoBlazorSerialization.JsonSerializerOptions)); } -} - -internal record MapComponentSerializationRecord; \ No newline at end of file +} \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core/Interfaces/IProtobufSerializable.cs b/src/dymaptic.GeoBlazor.Core/Interfaces/IProtobufSerializable.cs new file mode 100644 index 000000000..fc299ce40 --- /dev/null +++ b/src/dymaptic.GeoBlazor.Core/Interfaces/IProtobufSerializable.cs @@ -0,0 +1,18 @@ +using dymaptic.GeoBlazor.Core.Serialization; + +namespace dymaptic.GeoBlazor.Core.Interfaces; + +/// +/// Interface to indicate that a class can be serialized to and from Protobuf format. +/// +/// The serialization record type. +public interface IProtobufSerializable where T : MapComponentSerializationRecord, new() +{ + /// + /// Converts the class to its Protobuf serialization record. + /// + /// + /// Returns the serializable instance + /// + T ToProtobuf(); +} diff --git a/src/dymaptic.GeoBlazor.Core/Serialization/GeoBlazorMetaData.cs b/src/dymaptic.GeoBlazor.Core/Serialization/GeoBlazorMetaData.cs new file mode 100644 index 000000000..58836528a --- /dev/null +++ b/src/dymaptic.GeoBlazor.Core/Serialization/GeoBlazorMetaData.cs @@ -0,0 +1,22 @@ +namespace dymaptic.GeoBlazor.Core.Serialization; + +public static class GeoBlazorMetaData +{ + static GeoBlazorMetaData() + { + GeoblazorTypes = typeof(GeoBlazorMetaData).Assembly.GetTypes(); + + try + { + Assembly proAssembly = Assembly.Load("dymaptic.GeoBlazor.Pro"); + Type[] proTypes = proAssembly.GetTypes(); + GeoblazorTypes = GeoblazorTypes.Concat(proTypes).ToArray(); + } + catch + { + // GeoBlazor.Pro not available + } + } + + public static Type[] GeoblazorTypes { get; } +} \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core/Serialization/JsSyncManager.cs b/src/dymaptic.GeoBlazor.Core/Serialization/JsSyncManager.cs new file mode 100644 index 000000000..8762da1ec --- /dev/null +++ b/src/dymaptic.GeoBlazor.Core/Serialization/JsSyncManager.cs @@ -0,0 +1,85 @@ +using Microsoft.JSInterop; +using ProtoBuf.Meta; +using System.Runtime.CompilerServices; + +namespace dymaptic.GeoBlazor.Core.Serialization; + +/// +/// Manages JavaScript interop with support for Protobuf serialization and streaming. +/// +/// +/// This is the infrastructure class for the Protobuf serialization system. +/// The full implementation with serialization records will be added in a subsequent PR. +/// +public static class JsSyncManager +{ + /// + /// Dictionary of serializable methods keyed by class name. + /// + public static Dictionary SerializableMethods { get; set; } = []; + + /// + /// Dictionary mapping source types to their Protobuf contract types. + /// + public static Dictionary ProtoContractTypes { get; set; } = []; + + /// + /// Dictionary mapping collection item types to their Protobuf collection types. + /// + public static Dictionary ProtoCollectionTypes { get; set; } = []; + + private static Dictionary>? _serializableMethods; + + /// + /// Initializes the JsSyncManager with Protobuf type registrations. + /// + public static void Initialize() + { + foreach (Type protoType in ProtoContractTypes.Values) + { + RuntimeTypeModel.Default.Add(protoType, true); + } + + RuntimeTypeModel.Default.CompileInPlace(); + + _serializableMethods = SerializableMethods + .ToDictionary(g => g.Key, g => g.Value.ToList()); + } + + /// + /// Wrapper method to invoke a void JS function with serialization support. + /// + /// The IJSObjectReference to invoke the method on. + /// Boolean flag to identify if GeoBlazor is running in Blazor Server mode. + /// The name of the JS function to call. + /// The name of the calling class. + /// A CancellationToken to cancel an asynchronous operation. + /// The collection of parameters to pass to the JS call. + public static async Task InvokeVoidJsMethod(this IJSObjectReference js, bool isServer, + [CallerMemberName] string method = "", string className = "", + CancellationToken cancellationToken = default, params object?[] parameters) + { + // Placeholder implementation - full serialization support will be added in subsequent PR + await js.InvokeVoidAsync(method, cancellationToken, parameters); + } + + /// + /// Wrapper method to invoke a JS function that returns a value with serialization support. + /// + /// The expected return type. + /// The IJSObjectReference to invoke the method on. + /// Boolean flag to identify if GeoBlazor is running in Blazor Server mode. + /// The name of the JS function to call. + /// The name of the calling class. + /// A CancellationToken to cancel an asynchronous operation. + /// The collection of parameters to pass to the JS call. + /// The result of the JS call. + public static async Task InvokeJsMethod(this IJSObjectReference js, bool isServer, + [CallerMemberName] string method = "", string className = "", + CancellationToken cancellationToken = default, + params object?[] parameters) + { + // Placeholder implementation - full serialization support will be added in subsequent PR + return await js.InvokeAsync(method, cancellationToken, parameters); + } +} diff --git a/src/dymaptic.GeoBlazor.Core/Serialization/ProtobufSerializationBase.cs b/src/dymaptic.GeoBlazor.Core/Serialization/ProtobufSerializationBase.cs new file mode 100644 index 000000000..94364d4aa --- /dev/null +++ b/src/dymaptic.GeoBlazor.Core/Serialization/ProtobufSerializationBase.cs @@ -0,0 +1,72 @@ +using ProtoBuf; + +namespace dymaptic.GeoBlazor.Core.Serialization; + +/// +/// Base class for all Protobuf serialization records for MapComponents. +/// +[ProtoContract(Name = "MapComponent")] +public record MapComponentSerializationRecord +{ + /// + /// Indicates whether this record represents a null value. + /// + [ProtoMember(1000)] + public virtual bool IsNull { get; init; } +} + +/// +/// Generic base class for Protobuf serialization records that can convert back to their source type. +/// +/// The type that this record serializes. +public abstract record MapComponentSerializationRecord : MapComponentSerializationRecord +{ + /// + /// Converts this serialization record back to its source type. + /// + public abstract T? FromSerializationRecord(); +} + +/// +/// Base class for Protobuf serialization records representing collections. +/// +[ProtoContract(Name = "MapComponentCollection")] +public record MapComponentBaseCollectionSerializationRecord +{ + /// + /// Indicates whether this record represents a null collection. + /// + [ProtoMember(1000)] + public virtual bool IsNull { get; init; } +} + +/// +/// Generic base class for Protobuf serialization records representing collections. +/// +/// The type of items in the collection. +public abstract record MapComponentCollectionSerializationRecord : MapComponentBaseCollectionSerializationRecord + where TItem : MapComponentSerializationRecord +{ + /// + /// The items in the collection. + /// + public abstract TItem[]? Items { get; set; } +} + +/// +/// Record representing serializable method metadata for JS interop. +/// +public record SerializableMethodRecord( + string MethodName, + SerializableParameterRecord[]? Parameters, + SerializableParameterRecord? ReturnValue); + +/// +/// Record representing a serializable parameter for JS interop. +/// +public record SerializableParameterRecord( + string Name, + Type Type, + bool IsNullable, + bool IsCollection, + Type? CollectionItemType); diff --git a/src/dymaptic.GeoBlazor.Core/Serialization/SerializationExtensions.cs b/src/dymaptic.GeoBlazor.Core/Serialization/SerializationExtensions.cs new file mode 100644 index 000000000..feaca6aec --- /dev/null +++ b/src/dymaptic.GeoBlazor.Core/Serialization/SerializationExtensions.cs @@ -0,0 +1,6 @@ +namespace dymaptic.GeoBlazor.Core.Serialization; + +internal static partial class SerializationExtensions +{ + +} \ No newline at end of file From 950a7ec8974dc5434e3384e71a222f23ab85cc8f Mon Sep 17 00:00:00 2001 From: "submodule-validation-for-geoblazor[bot]" <235551211+submodule-validation-for-geoblazor[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 23:00:49 +0000 Subject: [PATCH 02/18] Pipeline Build Commit of Version Bump --- Directory.Build.props | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 8c8de5ef3..e7d1750e5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,23 +1,23 @@ - - enable - enable - 4.3.0.8 - true - Debug;Release;SourceGen Highlighting - AnyCPU - $(MSBuildThisFileDirectory)src\dymaptic.GeoBlazor.Core - + + enable + enable + 4.3.0.9 + true + Debug;Release;SourceGen Highlighting + AnyCPU + $(MSBuildThisFileDirectory)src\dymaptic.GeoBlazor.Core + $(StaticWebAssetEndpointExclusionPattern);js/** - - - - - - - - - - - + + + + + + + + + + + \ No newline at end of file From c6db4ade6ace3afc44bc68d5d5ec2dafa294450f Mon Sep 17 00:00:00 2001 From: Tim Purdum Date: Tue, 9 Dec 2025 19:11:48 -0600 Subject: [PATCH 03/18] Address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TODO comments to placeholder implementations (SerializationExtensions, JsSyncManager, ProtobufSourceGenerator) documenting future work - Document ProtoMember(1000) magic number - reserved for base class properties to avoid conflicts with derived class tags (1-999) - Improve GeoBlazorMetaData exception handling - catch FileNotFoundException specifically instead of bare catch - Add XML documentation to GeoBlazorMetaData class and properties - Fix duplicate logging bug in ProcessHelper - output was being logged in ExitedCommandEvent then cleared, making error diagnostics empty 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../ProcessHelper.cs | 6 +----- .../ProtobufSourceGenerator.cs | 7 +++++++ .../Serialization/GeoBlazorMetaData.cs | 14 +++++++++++--- .../Serialization/JsSyncManager.cs | 14 ++++++++++++-- .../Serialization/ProtobufSerializationBase.cs | 5 +++++ .../Serialization/SerializationExtensions.cs | 10 +++++++++- 6 files changed, 45 insertions(+), 11 deletions(-) diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProcessHelper.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProcessHelper.cs index b6a9560c6..fd1855624 100644 --- a/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProcessHelper.cs +++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProcessHelper.cs @@ -72,22 +72,18 @@ public static async Task Execute(string processName, string workingDirectory, st case ExitedCommandEvent exited: exitCode = exited.ExitCode; outputBuilder.AppendLine($"{processName} - PID {processId}: Process exited with code: {exited.ExitCode}"); - logBuilder.AppendLine(outputBuilder.ToString()); - outputBuilder.Clear(); break; } } + // Append any accumulated output to the log if (outputBuilder.Length > 0) { logBuilder.AppendLine(outputBuilder.ToString()); } - if (exitCode != 0) { - logBuilder.AppendLine(outputBuilder.ToString()); - // Throw an exception if the process returned an error throw new Exception($"{processName}: Error executing command '{shellArguments}' for process {processId}. Exit code: {exitCode}"); } diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator/ProtobufSourceGenerator.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator/ProtobufSourceGenerator.cs index 474cfe653..40abeecf1 100644 --- a/src/dymaptic.GeoBlazor.Core.SourceGenerator/ProtobufSourceGenerator.cs +++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator/ProtobufSourceGenerator.cs @@ -74,6 +74,13 @@ private void FilesChanged(SourceProductionContext context, $"Found {pipeline.Data.Types.Length} protobuf-serializable types.", DiagnosticSeverity.Info, context); + + // TODO: Generate protobuf serialization records and registration code. + // This will include: + // 1. Generating *SerializationRecord classes for each protobuf-attributed type + // 2. Generating ToProtobuf()/FromProtobuf() extension methods + // 3. Generating JsSyncManager registration code for ProtoContractTypes dictionary + // 4. Copying .proto definitions to TypeScript for JS-side deserialization } } diff --git a/src/dymaptic.GeoBlazor.Core/Serialization/GeoBlazorMetaData.cs b/src/dymaptic.GeoBlazor.Core/Serialization/GeoBlazorMetaData.cs index 58836528a..81be3e64a 100644 --- a/src/dymaptic.GeoBlazor.Core/Serialization/GeoBlazorMetaData.cs +++ b/src/dymaptic.GeoBlazor.Core/Serialization/GeoBlazorMetaData.cs @@ -1,5 +1,8 @@ namespace dymaptic.GeoBlazor.Core.Serialization; +/// +/// Provides metadata about GeoBlazor types for serialization and reflection purposes. +/// public static class GeoBlazorMetaData { static GeoBlazorMetaData() @@ -8,15 +11,20 @@ static GeoBlazorMetaData() try { + // Attempt to load GeoBlazor.Pro types if the assembly is available. + // This enables protobuf serialization to work with both Core and Pro types. Assembly proAssembly = Assembly.Load("dymaptic.GeoBlazor.Pro"); Type[] proTypes = proAssembly.GetTypes(); GeoblazorTypes = GeoblazorTypes.Concat(proTypes).ToArray(); } - catch + catch (FileNotFoundException) { - // GeoBlazor.Pro not available + // GeoBlazor.Pro assembly not available - this is expected for Core-only installations } } - + + /// + /// All types from GeoBlazor.Core and GeoBlazor.Pro (if available) assemblies. + /// public static Type[] GeoblazorTypes { get; } } \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core/Serialization/JsSyncManager.cs b/src/dymaptic.GeoBlazor.Core/Serialization/JsSyncManager.cs index 8762da1ec..b7856f288 100644 --- a/src/dymaptic.GeoBlazor.Core/Serialization/JsSyncManager.cs +++ b/src/dymaptic.GeoBlazor.Core/Serialization/JsSyncManager.cs @@ -33,8 +33,14 @@ public static class JsSyncManager /// /// Initializes the JsSyncManager with Protobuf type registrations. /// + /// + /// This method should be called once at application startup to register all + /// protobuf types with the RuntimeTypeModel and compile the serialization model. + /// public static void Initialize() { + // TODO: ProtoContractTypes and SerializableMethods dictionaries will be populated + // by generated code from ProtobufSourceGenerator in a subsequent PR. foreach (Type protoType in ProtoContractTypes.Values) { RuntimeTypeModel.Default.Add(protoType, true); @@ -59,7 +65,9 @@ public static async Task InvokeVoidJsMethod(this IJSObjectReference js, bool isS [CallerMemberName] string method = "", string className = "", CancellationToken cancellationToken = default, params object?[] parameters) { - // Placeholder implementation - full serialization support will be added in subsequent PR + // TODO: Implement protobuf serialization for Blazor Server mode. + // When isServer is true and the method/parameters support protobuf serialization, + // this should serialize parameters to binary format for more efficient transfer. await js.InvokeVoidAsync(method, cancellationToken, parameters); } @@ -79,7 +87,9 @@ public static async Task InvokeJsMethod(this IJSObjectReference js, bool i CancellationToken cancellationToken = default, params object?[] parameters) { - // Placeholder implementation - full serialization support will be added in subsequent PR + // TODO: Implement protobuf deserialization for Blazor Server mode. + // When isServer is true and the return type supports protobuf serialization, + // this should deserialize the binary response for more efficient transfer. return await js.InvokeAsync(method, cancellationToken, parameters); } } diff --git a/src/dymaptic.GeoBlazor.Core/Serialization/ProtobufSerializationBase.cs b/src/dymaptic.GeoBlazor.Core/Serialization/ProtobufSerializationBase.cs index 94364d4aa..bbc6528be 100644 --- a/src/dymaptic.GeoBlazor.Core/Serialization/ProtobufSerializationBase.cs +++ b/src/dymaptic.GeoBlazor.Core/Serialization/ProtobufSerializationBase.cs @@ -8,6 +8,9 @@ namespace dymaptic.GeoBlazor.Core.Serialization; [ProtoContract(Name = "MapComponent")] public record MapComponentSerializationRecord { + // ProtoMember tag 1000 is used for base class properties to avoid conflicts with derived class tags. + // Derived classes use tags 1-999 for their specific properties. + /// /// Indicates whether this record represents a null value. /// @@ -33,6 +36,8 @@ public abstract record MapComponentSerializationRecord : MapComponentSerializ [ProtoContract(Name = "MapComponentCollection")] public record MapComponentBaseCollectionSerializationRecord { + // ProtoMember tag 1000 is used for base class properties to avoid conflicts with derived class tags. + /// /// Indicates whether this record represents a null collection. /// diff --git a/src/dymaptic.GeoBlazor.Core/Serialization/SerializationExtensions.cs b/src/dymaptic.GeoBlazor.Core/Serialization/SerializationExtensions.cs index feaca6aec..036410cdb 100644 --- a/src/dymaptic.GeoBlazor.Core/Serialization/SerializationExtensions.cs +++ b/src/dymaptic.GeoBlazor.Core/Serialization/SerializationExtensions.cs @@ -1,6 +1,14 @@ namespace dymaptic.GeoBlazor.Core.Serialization; +/// +/// Extension methods for protobuf serialization support. +/// +/// +/// This partial class will contain generated extension methods for serializing +/// MapComponent types to their protobuf representations. +/// internal static partial class SerializationExtensions { - + // TODO: Extension methods will be generated by ProtobufSourceGenerator in a subsequent PR. + // These methods will provide ToProtobuf() and FromProtobuf() conversions for MapComponent types. } \ No newline at end of file From d1f6e84be5218d089f9ffa6996b982f85d9e37e1 Mon Sep 17 00:00:00 2001 From: "submodule-validation-for-geoblazor[bot]" <235551211+submodule-validation-for-geoblazor[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 01:16:47 +0000 Subject: [PATCH 04/18] Pipeline Build Commit of Version Bump --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index e7d1750e5..05a5be595 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,7 +2,7 @@ enable enable - 4.3.0.9 + 4.3.0.10 true Debug;Release;SourceGen Highlighting AnyCPU From b2d8f1908a4fb5ecd5ff4d01323a9e41e44f83a3 Mon Sep 17 00:00:00 2001 From: Tim Purdum Date: Tue, 9 Dec 2025 22:33:39 -0600 Subject: [PATCH 05/18] Address PR feedback --- .../ProcessHelper.cs | 7 +++++-- .../ProtobufDefinitionsGenerator.cs | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProcessHelper.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProcessHelper.cs index fd1855624..091165cec 100644 --- a/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProcessHelper.cs +++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProcessHelper.cs @@ -13,6 +13,7 @@ public static async Task RunPowerShellScript(string processName, string workingD string powershellScriptName, string arguments, StringBuilder logBuilder, CancellationToken token, Dictionary? environmentVariables = null) { + // Since we are always providing the scripts, this is safe to call `ByPass` string shellArguments = $"-NoProfile -ExecutionPolicy ByPass -File \"{ Path.Combine(workingDirectory, powershellScriptName)}\" {arguments}"; @@ -84,7 +85,7 @@ public static async Task Execute(string processName, string workingDirectory, st if (exitCode != 0) { - throw new Exception($"{processName}: Error executing command '{shellArguments}' for process {processId}. Exit code: {exitCode}"); + throw new ProcessException($"{processName}: Error executing command '{shellArguments}' for process {processId}. Exit code: {exitCode}"); } // Return the standard output if the process completed normally @@ -108,4 +109,6 @@ public static void Log(string title, string message, DiagnosticSeverity severity private static readonly string shellCommand = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? WindowsShell : LinuxShell; -} \ No newline at end of file +} + +public class ProcessException(string message): Exception(message); \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProtobufDefinitionsGenerator.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProtobufDefinitionsGenerator.cs index 57f4ab0f3..90e46c922 100644 --- a/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProtobufDefinitionsGenerator.cs +++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProtobufDefinitionsGenerator.cs @@ -38,6 +38,7 @@ public static Dictionary UpdateProtobufDefinitio string scriptPath = Path.Combine(corePath, "copyProtobuf.ps1"); // write protobuf definitions to geoblazorProto.ts + // must use GetAwaiter().GetResult(), since Source Generator is not Async ProcessHelper.RunPowerShellScript("Copy Protobuf Definitions", corePath, scriptPath, $"-Content \"{encoded}\"", @@ -53,7 +54,7 @@ public static Dictionary UpdateProtobufDefinitio DiagnosticSeverity.Info, context); - return ProtoDefinitions!; + return ProtoDefinitions ?? []; } private static string Generate(SourceProductionContext context, From a81a8e1ee5c541f7b8d02b7f5a33b347bfab0e08 Mon Sep 17 00:00:00 2001 From: Tim Purdum Date: Tue, 9 Dec 2025 22:37:47 -0600 Subject: [PATCH 06/18] Update Claude instructions --- .github/workflows/claude-auto-review.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/claude-auto-review.yml b/.github/workflows/claude-auto-review.yml index 26dd868e4..c029e7440 100644 --- a/.github/workflows/claude-auto-review.yml +++ b/.github/workflows/claude-auto-review.yml @@ -43,4 +43,10 @@ jobs: Use inline comments to highlight specific areas of concern. Keep the review concise, and focus on actionable feedback, not positive praise. Limit the review to about 5000 words or less. + + There is a `GlobalUsings.cs` file in this repository, so be aware that individual files do not necessarily require using statements. + + If you see an unusual pattern, first check to see if there is a specific reason why that pattern had to be used instead of the more + typical pattern. If so, you can recommend a comment on the pattern rather than changing the pattern if that is appropriate. + allowed_tools: "mcp__github__create_pending_pull_request_review,mcp__github__add_pull_request_review_comment_to_pending_review,mcp__github__submit_pending_pull_request_review,mcp__github__get_pull_request_diff" \ No newline at end of file From b6323368d29bcb9d27dd4c10c343e6a150cbcbde Mon Sep 17 00:00:00 2001 From: Tim Purdum Date: Fri, 26 Dec 2025 09:31:49 -0600 Subject: [PATCH 07/18] update from V5 branch --- .../ESBuildGenerator.cs | 77 ++++++++++--------- 1 file changed, 42 insertions(+), 35 deletions(-) diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator/ESBuildGenerator.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator/ESBuildGenerator.cs index 2217fcad3..5c331cacd 100644 --- a/src/dymaptic.GeoBlazor.Core.SourceGenerator/ESBuildGenerator.cs +++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator/ESBuildGenerator.cs @@ -2,6 +2,7 @@ using Microsoft.CodeAnalysis; using System.Collections.Immutable; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Text; @@ -11,6 +12,7 @@ namespace dymaptic.GeoBlazor.Core.SourceGenerator; /// Triggers the ESBuild build process for the GeoBlazor project, so that your JavaScript code is up to date. /// [Generator] +[SuppressMessage("MicrosoftCodeAnalysisCorrectness", "RS1035:Do not use APIs banned for analyzers")] public class ESBuildGenerator : IIncrementalGenerator { public static bool InProcess { get; private set; } @@ -29,18 +31,18 @@ public void Initialize(IncrementalGeneratorInitializationContext context) context.AnalyzerConfigOptionsProvider.Select((configProvider, _) => { configProvider.GlobalOptions.TryGetValue("build_property.CoreProjectPath", - out var projectDirectory); + out string? projectDirectory); configProvider.GlobalOptions.TryGetValue("build_property.Configuration", - out var configuration); + out string? configuration); configProvider.GlobalOptions.TryGetValue("build_property.PipelineBuild", - out var pipelineBuild); + out string? pipelineBuild); return (projectDirectory, configuration, pipelineBuild); }); - var combined = + IncrementalValueProvider<((ImmutableArray Left, (string?, string?, string?) Right) Left, Compilation Right)> combined = tsFilesProvider .Combine(optionsProvider) .Combine(context.CompilationProvider); @@ -85,7 +87,7 @@ private bool SetProjectDirectoryAndConfiguration( (string? ProjectDirectory, string? Configuration, string? _) options, SourceProductionContext context) { - var projectDirectory = options.ProjectDirectory; + string? projectDirectory = options.ProjectDirectory; if (projectDirectory is not null) { @@ -99,7 +101,7 @@ private bool SetProjectDirectoryAndConfiguration( if (_corePath.Contains("GeoBlazor.Pro")) { // we are inside the Pro submodule, we should also set the Pro path to build the Pro JavaScript files - var path = _corePath; + string path = _corePath; while (!path.EndsWith("GeoBlazor.Pro")) { @@ -138,46 +140,21 @@ private bool SetProjectDirectoryAndConfiguration( private void LaunchESBuild(SourceProductionContext context) { - Stopwatch? sw = null; - - while (InProcess && (sw is null || sw.ElapsedMilliseconds < 5_000)) - { - if (sw is null) - { - sw = new Stopwatch(); - sw.Start(); - } - - Thread.Sleep(100); - } - - if (InProcess) - { - ProcessHelper.Log(nameof(ESBuildGenerator), - "Another instance of the ESBuild process has been running continuously for 5 seconds.", - DiagnosticSeverity.Error, - context); - - return; - } - - InProcess = true; - ClearESBuildLocks(context); context.CancellationToken.ThrowIfCancellationRequested(); - + ShowMessageBox("Starting GeoBlazor Core ESBuild process..."); ProcessHelper.Log(nameof(ESBuildGenerator), "Starting Core ESBuild process...", DiagnosticSeverity.Info, context); - var logBuilder = new StringBuilder(DateTime.Now.ToLongTimeString()); + StringBuilder logBuilder = new StringBuilder(DateTime.Now.ToLongTimeString()); logBuilder.AppendLine("Starting Core ESBuild process..."); try { List tasks = []; - var buildSuccess = false; - var proBuildSuccess = false; + bool buildSuccess = false; + bool proBuildSuccess = false; // gets the esBuild.ps1 script from the Core path tasks.Add(Task.Run(async () => @@ -190,6 +167,7 @@ await ProcessHelper.RunPowerShellScript("Core", if (_proPath is not null) { + ShowMessageBox("Starting GeoBlazor Pro ESBuild process..."); logBuilder.AppendLine("Starting Pro ESBuild process..."); tasks.Add(Task.Run(async () => @@ -249,6 +227,7 @@ await ProcessHelper.RunPowerShellScript("Pro", finally { InProcess = false; + CloseMessageBox(); } } @@ -265,8 +244,36 @@ private void ClearESBuildLocks(SourceProductionContext context) DiagnosticSeverity.Info, context); } + + private void ShowMessageBox(string message) + { + string path = Path.Combine(_corePath!, "..", ".."); + + ProcessStartInfo processStartInfo = new() + { + WorkingDirectory = path, + FileName = "pwsh", + Arguments = + $"-NoProfile -ExecutionPolicy ByPass -File showDialog.ps1 -Message \"{message}\" -Title \"GeoBlazor ESBuild\" -Buttons None", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + _popupProcesses.Add(Process.Start(processStartInfo)); + } + + private void CloseMessageBox() + { + foreach (Process process in _popupProcesses) + { + process.Kill(); + } + } private static string? _corePath; private static string? _proPath; private static string? _configuration; + private readonly List _popupProcesses = []; } \ No newline at end of file From de5a4bb69d59fb55260490f954c76bc0ae4d3515 Mon Sep 17 00:00:00 2001 From: Tim Purdum Date: Tue, 13 Jan 2026 14:15:13 -0600 Subject: [PATCH 08/18] merge in from v5.0 --- .../ESBuildGenerator.cs | 53 ++++--------------- 1 file changed, 10 insertions(+), 43 deletions(-) diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator/ESBuildGenerator.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator/ESBuildGenerator.cs index 5c331cacd..cca2966ed 100644 --- a/src/dymaptic.GeoBlazor.Core.SourceGenerator/ESBuildGenerator.cs +++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator/ESBuildGenerator.cs @@ -1,7 +1,6 @@ using dymaptic.GeoBlazor.Core.SourceGenerator.Shared; using Microsoft.CodeAnalysis; using System.Collections.Immutable; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Text; @@ -16,13 +15,13 @@ namespace dymaptic.GeoBlazor.Core.SourceGenerator; public class ESBuildGenerator : IIncrementalGenerator { public static bool InProcess { get; private set; } - + public void Initialize(IncrementalGeneratorInitializationContext context) { // Tracks all TypeScript source files in the Scripts directories of Core and Pro. // This will trigger the build any time a TypeScript file is added, removed, or changed. IncrementalValueProvider> tsFilesProvider = context.AdditionalTextsProvider - .Where(static text => text.Path.Contains("Scripts") + .Where(static text => text.Path.Contains("Scripts") && text.Path.EndsWith(".ts")) .Collect(); @@ -42,7 +41,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) return (projectDirectory, configuration, pipelineBuild); }); - IncrementalValueProvider<((ImmutableArray Left, (string?, string?, string?) Right) Left, Compilation Right)> combined = + IncrementalValueProvider<((ImmutableArray Left, (string?, string?, string?) Right) Left, + Compilation Right)> combined = tsFilesProvider .Combine(optionsProvider) .Combine(context.CompilationProvider); @@ -79,7 +79,7 @@ private void FilesChanged(SourceProductionContext context, if (pipeline.Data.Files.Length > 0) { - LaunchESBuild(context); + LaunchESBuild(context); } } @@ -141,7 +141,7 @@ private bool SetProjectDirectoryAndConfiguration( private void LaunchESBuild(SourceProductionContext context) { context.CancellationToken.ThrowIfCancellationRequested(); - ShowMessageBox("Starting GeoBlazor Core ESBuild process..."); + ProcessHelper.Log(nameof(ESBuildGenerator), "Starting Core ESBuild process...", DiagnosticSeverity.Info, @@ -167,7 +167,6 @@ await ProcessHelper.RunPowerShellScript("Core", if (_proPath is not null) { - ShowMessageBox("Starting GeoBlazor Pro ESBuild process..."); logBuilder.AppendLine("Starting Pro ESBuild process..."); tasks.Add(Task.Run(async () => @@ -221,59 +220,27 @@ await ProcessHelper.RunPowerShellScript("Pro", $"An error occurred while running ESBuild: {ex.Message}\r\n{ex.StackTrace}", DiagnosticSeverity.Error, context); - + ClearESBuildLocks(context); } - finally - { - InProcess = false; - CloseMessageBox(); - } } private void ClearESBuildLocks(SourceProductionContext context) { StringBuilder logBuilder = new(); string rootCorePath = Path.Combine(_corePath!, "..", ".."); + _ = Task.Run(async () => await ProcessHelper.RunPowerShellScript("Clear Locks", - rootCorePath, "esBuildClearLocks.ps1", "", + rootCorePath, "esBuildClearLocks.ps1", "", logBuilder, context.CancellationToken)); - + ProcessHelper.Log(nameof(ESBuildGenerator), "Cleared ESBuild Process Locks", DiagnosticSeverity.Info, context); } - - private void ShowMessageBox(string message) - { - string path = Path.Combine(_corePath!, "..", ".."); - - ProcessStartInfo processStartInfo = new() - { - WorkingDirectory = path, - FileName = "pwsh", - Arguments = - $"-NoProfile -ExecutionPolicy ByPass -File showDialog.ps1 -Message \"{message}\" -Title \"GeoBlazor ESBuild\" -Buttons None", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - _popupProcesses.Add(Process.Start(processStartInfo)); - } - - private void CloseMessageBox() - { - foreach (Process process in _popupProcesses) - { - process.Kill(); - } - } private static string? _corePath; private static string? _proPath; private static string? _configuration; - private readonly List _popupProcesses = []; } \ No newline at end of file From 05de39a384223251346819b92955da52f611ecf7 Mon Sep 17 00:00:00 2001 From: "submodule-validation-for-geoblazor[bot]" <235551211+submodule-validation-for-geoblazor[bot]@users.noreply.github.com> Date: Fri, 16 Jan 2026 01:58:17 +0000 Subject: [PATCH 09/18] Pipeline Build Commit of Version and Docs --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index d178f2209..ca8523ba7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,7 @@ enable enable - 5.0.0.8 + 5.0.0.9 true Debug;Release;SourceGen Highlighting AnyCPU From ea76854191de652aba68558c9b362bd356951385 Mon Sep 17 00:00:00 2001 From: Tim Purdum Date: Thu, 15 Jan 2026 20:14:07 -0600 Subject: [PATCH 10/18] Optimize test automation performance with browser pool pre-warming and reduced timeouts --- .../BrowserPool.cs | 248 +++++++++++++----- .../GeoBlazorTestClass.cs | 44 ++-- .../TestConfig.cs | 21 +- 3 files changed, 218 insertions(+), 95 deletions(-) diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/BrowserPool.cs b/test/dymaptic.GeoBlazor.Core.Test.Automation/BrowserPool.cs index 9136183d0..8da268b47 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Automation/BrowserPool.cs +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/BrowserPool.cs @@ -11,24 +11,6 @@ namespace dymaptic.GeoBlazor.Core.Test.Automation; /// public sealed class BrowserPool : IAsyncDisposable { - private static BrowserPool? _instance; - private static readonly Lock _instanceLock = new(); - - private readonly ConcurrentQueue _availableBrowsers = new(); - private readonly ConcurrentDictionary _checkedOutBrowsers = new(); - private readonly SemaphoreSlim _poolSemaphore; - private readonly SemaphoreSlim _creationLock = new(1, 1); - private readonly BrowserTypeLaunchOptions _launchOptions; - private readonly IBrowserType _browserType; - private readonly int _maxPoolSize; - private int _currentPoolSize; - private bool _disposed; - - /// - /// Maximum time to wait for a browser from the pool (5 minutes) - /// - private static readonly TimeSpan CheckoutTimeout = TimeSpan.FromMinutes(5); - private BrowserPool(IBrowserType browserType, BrowserTypeLaunchOptions launchOptions, int maxPoolSize) { _browserType = browserType; @@ -37,10 +19,41 @@ private BrowserPool(IBrowserType browserType, BrowserTypeLaunchOptions launchOpt _poolSemaphore = new SemaphoreSlim(maxPoolSize, maxPoolSize); } + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + + // Dispose all available browsers + while (_availableBrowsers.TryDequeue(out var browser)) + { + await browser.DisposeAsync().ConfigureAwait(false); + } + + // Dispose all checked out browsers + foreach (var browser in _checkedOutBrowsers.Values) + { + await browser.DisposeAsync().ConfigureAwait(false); + } + + _checkedOutBrowsers.Clear(); + + _poolSemaphore.Dispose(); + _creationLock.Dispose(); + + _instance = null; + Trace.WriteLine("Browser pool disposed", "BROWSER_POOL"); + } + /// /// Gets or creates the singleton browser pool instance. /// - public static BrowserPool GetInstance(IBrowserType browserType, BrowserTypeLaunchOptions launchOptions, int maxPoolSize = 2) + public static BrowserPool GetInstance(IBrowserType browserType, BrowserTypeLaunchOptions launchOptions, + int maxPoolSize = 2) { if (_instance is null) { @@ -113,10 +126,14 @@ public async Task CheckoutAsync(CancellationToken cancellationTok newPooledBrowser.MarkCheckedOut(); _checkedOutBrowsers[newPooledBrowser.Id] = newPooledBrowser; Interlocked.Increment(ref _currentPoolSize); + Trace.WriteLine( $"Created new browser {newPooledBrowser.Id}, pool size: {_currentPoolSize}/{_maxPoolSize}", "BROWSER_POOL"); + // Trigger pre-warming of additional browsers on first checkout + TriggerPreWarm(); + return newPooledBrowser; } finally @@ -196,38 +213,122 @@ public async Task ReportFailedAsync(PooledBrowser pooledBrowser) Trace.WriteLine($"Removed failed browser {pooledBrowser.Id} from pool", "BROWSER_POOL"); } - public async ValueTask DisposeAsync() + /// + /// Gets pool statistics for diagnostics + /// + public (int Available, int CheckedOut, int TotalCreated) GetStats() => + (_availableBrowsers.Count, _checkedOutBrowsers.Count, _currentPoolSize); + + /// + /// Triggers background pre-warming of additional browser instances. + /// Called once after the first browser is created. + /// + private void TriggerPreWarm() { - if (_disposed) return; + if (_preWarmStarted || _disposed) + { + return; + } - _disposed = true; + _preWarmStarted = true; - // Dispose all available browsers - while (_availableBrowsers.TryDequeue(out var browser)) + // Calculate how many additional browsers to pre-warm + // Leave at least 1 slot for the current checkout + var browsersToCreate = _maxPoolSize - 1; + + if (browsersToCreate <= 0) { - await browser.DisposeAsync().ConfigureAwait(false); + return; } - // Dispose all checked out browsers - foreach (var browser in _checkedOutBrowsers.Values) + Trace.WriteLine($"Pre-warming {browsersToCreate} additional browser(s) in background", "BROWSER_POOL"); + + // Fire and forget - pre-warm in background + _ = PreWarmAsync(browsersToCreate); + } + + /// + /// Pre-warms the pool by creating additional browser instances in the background. + /// + private async Task PreWarmAsync(int count) + { + var tasks = new List(); + + for (var i = 0; i < count; i++) { - await browser.DisposeAsync().ConfigureAwait(false); + tasks.Add(CreatePreWarmBrowserAsync()); } - _checkedOutBrowsers.Clear(); + await Task.WhenAll(tasks).ConfigureAwait(false); - _poolSemaphore.Dispose(); - _creationLock.Dispose(); + var stats = GetStats(); - _instance = null; - Trace.WriteLine("Browser pool disposed", "BROWSER_POOL"); + Trace.WriteLine($"Pre-warm complete. Pool stats: {stats.Available} available, {stats.CheckedOut} checked out, { + stats.TotalCreated} total", "BROWSER_POOL"); } /// - /// Gets pool statistics for diagnostics + /// Creates a single browser for pre-warming and adds it to the available queue. + /// Pre-warmed browsers do NOT hold semaphore slots - slots are acquired on checkout. /// - public (int Available, int CheckedOut, int TotalCreated) GetStats() => - (_availableBrowsers.Count, _checkedOutBrowsers.Count, _currentPoolSize); + private async Task CreatePreWarmBrowserAsync() + { + if (_disposed) + { + return; + } + + try + { + await _creationLock.WaitAsync().ConfigureAwait(false); + + try + { + // Check if we've already hit the max pool size + if (_currentPoolSize >= _maxPoolSize) + { + return; + } + + var browser = await _browserType.LaunchAsync(_launchOptions).ConfigureAwait(false); + var pooledBrowser = new PooledBrowser(browser, this); + Interlocked.Increment(ref _currentPoolSize); + + // Add directly to available queue (not checked out, no semaphore slot held) + _availableBrowsers.Enqueue(pooledBrowser); + + Trace.WriteLine($"Pre-warmed browser {pooledBrowser.Id}, pool size: {_currentPoolSize}/{_maxPoolSize}", + "BROWSER_POOL"); + } + finally + { + _creationLock.Release(); + } + } + catch (Exception ex) + { + Trace.WriteLine($"Failed to pre-warm browser: {ex.Message}", "BROWSER_POOL"); + } + } + + private static readonly Lock _instanceLock = new(); + + /// + /// Maximum time to wait for a browser from the pool (5 minutes) + /// + private static readonly TimeSpan CheckoutTimeout = TimeSpan.FromMinutes(5); + private static BrowserPool? _instance; + + private readonly ConcurrentQueue _availableBrowsers = new(); + private readonly ConcurrentDictionary _checkedOutBrowsers = new(); + private readonly SemaphoreSlim _poolSemaphore; + private readonly SemaphoreSlim _creationLock = new(1, 1); + private readonly BrowserTypeLaunchOptions _launchOptions; + private readonly IBrowserType _browserType; + private readonly int _maxPoolSize; + private int _currentPoolSize; + private bool _disposed; + private bool _preWarmStarted; } /// @@ -235,16 +336,6 @@ public async ValueTask DisposeAsync() /// public sealed class PooledBrowser : IAsyncDisposable { - private readonly BrowserPool _pool; - private bool _disposed; - - public Guid Id { get; } = Guid.NewGuid(); - public IBrowser Browser { get; } - public DateTime CreatedAt { get; } = DateTime.UtcNow; - public DateTime? CheckedOutAt { get; private set; } - public DateTime? ReturnedAt { get; private set; } - public int UseCount { get; private set; } - internal PooledBrowser(IBrowser browser, BrowserPool pool) { Browser = browser; @@ -254,21 +345,32 @@ internal PooledBrowser(IBrowser browser, BrowserPool pool) browser.Disconnected += OnBrowserDisconnected; } - private async void OnBrowserDisconnected(object? sender, IBrowser browser) - { - Trace.WriteLine($"Browser {Id} disconnected unexpectedly", "BROWSER_POOL"); - await _pool.ReportFailedAsync(this).ConfigureAwait(false); - } + public Guid Id { get; } = Guid.NewGuid(); + public IBrowser Browser { get; } + public DateTime CreatedAt { get; } = DateTime.UtcNow; + public DateTime? CheckedOutAt { get; private set; } + public DateTime? ReturnedAt { get; private set; } + public int UseCount { get; private set; } - internal void MarkCheckedOut() + public async ValueTask DisposeAsync() { - CheckedOutAt = DateTime.UtcNow; - UseCount++; - } + if (_disposed) + { + return; + } - internal void MarkReturned() - { - ReturnedAt = DateTime.UtcNow; + _disposed = true; + + Browser.Disconnected -= OnBrowserDisconnected; + + try + { + await Browser.CloseAsync().ConfigureAwait(false); + } + catch + { + // Ignore errors during browser close + } } /// @@ -309,21 +411,23 @@ public async Task CloseAllContextsAsync() } } - public async ValueTask DisposeAsync() + private async void OnBrowserDisconnected(object? sender, IBrowser browser) { - if (_disposed) return; - - _disposed = true; + Trace.WriteLine($"Browser {Id} disconnected unexpectedly", "BROWSER_POOL"); + await _pool.ReportFailedAsync(this).ConfigureAwait(false); + } - Browser.Disconnected -= OnBrowserDisconnected; + internal void MarkCheckedOut() + { + CheckedOutAt = DateTime.UtcNow; + UseCount++; + } - try - { - await Browser.CloseAsync().ConfigureAwait(false); - } - catch - { - // Ignore errors during browser close - } + internal void MarkReturned() + { + ReturnedAt = DateTime.UtcNow; } -} + + private readonly BrowserPool _pool; + private bool _disposed; +} \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/GeoBlazorTestClass.cs b/test/dymaptic.GeoBlazor.Core.Test.Automation/GeoBlazorTestClass.cs index 05d0df3f4..a402026b3 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Automation/GeoBlazorTestClass.cs +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/GeoBlazorTestClass.cs @@ -9,6 +9,17 @@ public abstract class GeoBlazorTestClass : PlaywrightTest { private IBrowserContext Context { get; set; } = null!; + // Optimized navigation: WaitUntil.Commit is faster - element waits handle actual readiness + private PageGotoOptions PageGotoOptions => new() + { + WaitUntil = WaitUntilState.Commit, Timeout = TestConfig.IsCI ? 45_000 : 30_000 // Reduced from 60_000 + }; + + // Reduced timeouts: 90s/60s instead of 120s - still generous but faster failure detection + private LocatorClickOptions ClickOptions => new() { Timeout = TestConfig.IsCI ? 90_000 : 60_000 }; + + private LocatorAssertionsToBeVisibleOptions VisibleOptions => new() { Timeout = TestConfig.IsCI ? 90_000 : 60_000 }; + [TestInitialize] public Task TestSetup() { @@ -68,13 +79,20 @@ protected async Task RunTestImplementation(string testName, int retries = 0) Trace.WriteLine($"Navigating to {testUrl}", "TEST"); - await page.GotoAsync(testUrl, - _pageGotoOptions); + await page.GotoAsync(testUrl, PageGotoOptions); Trace.WriteLine($"Page loaded for {testName}", "TEST"); + + // Skip section toggle click if already expanded (optimization) ILocator sectionToggle = page.GetByTestId("section-toggle"); - await sectionToggle.ClickAsync(_clickOptions); + var isExpanded = await sectionToggle.GetAttributeAsync("aria-expanded"); + + if (isExpanded != "true") + { + await sectionToggle.ClickAsync(ClickOptions); + } + ILocator testBtn = page.GetByText("Run Test"); - await testBtn.ClickAsync(_clickOptions); + await testBtn.ClickAsync(ClickOptions); ILocator passedSpan = page.GetByTestId("passed"); ILocator inconclusiveSpan = page.GetByTestId("inconclusive"); @@ -85,7 +103,7 @@ await page.GotoAsync(testUrl, } else { - await Expect(passedSpan).ToBeVisibleAsync(_visibleOptions); + await Expect(passedSpan).ToBeVisibleAsync(VisibleOptions); await Expect(passedSpan).ToHaveTextAsync("Passed: 1"); Trace.WriteLine($"{testName} Passed", "TEST"); } @@ -112,11 +130,16 @@ await page.GotoAsync(testUrl, Trace.WriteLine($"{ex.Message}{Environment.NewLine}{ex.StackTrace}", "ERROR"); } - if (retries > 2) + if (retries > 1) // Reduced from 2 to 1 (max 2 retries instead of 3) { Assert.Fail($"{testName} Exceeded the maximum number of retries."); } + // Exponential backoff: 1s, 2s between retries + var backoffMs = 1000 * (retries + 1); + Trace.WriteLine($"Retrying {testName} in {backoffMs}ms (attempt {retries + 2}/3)", "TEST"); + await Task.Delay(backoffMs); + await RunTestImplementation(testName, retries + 1); } finally @@ -244,15 +267,6 @@ private void HandlePageError(object? pageObject, string message) ] }; - private readonly PageGotoOptions _pageGotoOptions = new() - { - WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 60_000 - }; - - private readonly LocatorClickOptions _clickOptions = new() { Timeout = 120_000 }; - - private readonly LocatorAssertionsToBeVisibleOptions _visibleOptions = new() { Timeout = 120_000 }; - private readonly Dictionary> _consoleMessages = []; private readonly Dictionary> _errorMessages = []; private PooledBrowser? _pooledBrowser; diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/TestConfig.cs b/test/dymaptic.GeoBlazor.Core.Test.Automation/TestConfig.cs index 3233ed911..9af448a63 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Automation/TestConfig.cs +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/TestConfig.cs @@ -24,9 +24,15 @@ public class TestConfig /// /// Maximum number of concurrent browser instances in the pool. /// Configurable via BROWSER_POOL_SIZE environment variable. - /// Default: 2 for CI environments, 4 for local development. + /// Default: 4 for CI environments, 8 for local development. /// - public static int BrowserPoolSize { get; private set; } = 2; + public static int BrowserPoolSize { get; private set; } = 4; + + /// + /// Indicates whether the tests are running in a CI environment. + /// Used for timeout and pool size configuration. + /// + public static bool IsCI { get; private set; } private static string ComposeFilePath => Path.Combine(_projectFolder, _proAvailable && !CoreOnly ? "docker-compose-pro.yml" : "docker-compose-core.yml"); @@ -180,11 +186,11 @@ private static void SetupConfiguration() _useContainer = _configuration.GetValue("USE_CONTAINER", false); - // Configure browser pool size - smaller for CI, larger for local development - _isCI = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")); - var defaultPoolSize = _isCI ? 2 : 4; + // Configure browser pool size - larger pools improve parallelism + IsCI = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")); + var defaultPoolSize = IsCI ? 4 : 8; // Doubled from 2/4 to 4/8 for better parallelization BrowserPoolSize = _configuration.GetValue("BROWSER_POOL_SIZE", defaultPoolSize); - Trace.WriteLine($"Browser pool size set to: {BrowserPoolSize} (CI: {_isCI})", "TEST_SETUP"); + Trace.WriteLine($"Browser pool size set to: {BrowserPoolSize} (CI: {IsCI})", "TEST_SETUP"); _cover = _configuration.GetValue("COVER", false) @@ -615,7 +621,7 @@ await Cli.Wrap("reportgenerator") Trace.WriteLine($"Coverage report generated: {indexPath}", "CODE_COVERAGE"); // Open report in browser for local development (not CI) - if (!_isCI) + if (!IsCI) { try { @@ -694,5 +700,4 @@ private static void OpenInBrowser(string path) private static string _coverageFormat = string.Empty; private static string _coverageFileVersion = string.Empty; private static string? _reportGenLicenseKey; - private static bool _isCI; } \ No newline at end of file From 40ee22b722499e0e23b0745cfd16e15e3d3dabc4 Mon Sep 17 00:00:00 2001 From: "submodule-validation-for-geoblazor[bot]" <235551211+submodule-validation-for-geoblazor[bot]@users.noreply.github.com> Date: Fri, 16 Jan 2026 02:18:13 +0000 Subject: [PATCH 11/18] Pipeline Build Commit of Version and Docs --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index ca8523ba7..27f67b492 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,7 @@ enable enable - 5.0.0.9 + 5.0.0.10 true Debug;Release;SourceGen Highlighting AnyCPU From 495b281d91af5792a66342bad3a925aed2cdd2a1 Mon Sep 17 00:00:00 2001 From: Tim Purdum Date: Fri, 16 Jan 2026 22:44:51 -0600 Subject: [PATCH 12/18] update coverage --- .github/workflows/dev-pr-build.yml | 2 +- .github/workflows/tests.yml | 4 +- Directory.Build.props | 48 ++-- Dockerfile | 4 +- ReadMe.md | 7 +- badge_fullmethodcoverage.svg | 144 ++++++++++++ esBuildWaitForCompletion.ps1 | 18 ++ .../ComponentConstructorValidator.cs | 24 +- src/dymaptic.GeoBlazor.Core/ReadMe.md | 6 + .../badge_fullmethodcoverage.svg | 151 +++++++++++++ .../badge_linecoverage.svg | 144 ++++++++++++ .../badge_methodcoverage.svg | 148 +++++++++++++ .../dymaptic.GeoBlazor.Core.csproj | 3 - test/Directory.Build.props | 11 + .../CoreSourceGeneratorTests.cs | 206 ++++++++++++++++++ .../ESBuildLauncherTests.cs | 180 --------------- .../Utils/RoslynUtility.cs | 54 +++++ ...eoBlazor.Core.SourceGenerator.Tests.csproj | 8 +- .../GeoBlazorTestClass.cs | 18 +- .../TestConfig.cs | 120 +++++----- .../docker-compose-core.yml | 4 +- .../docker-compose-pro.yml | 4 +- .../docker-entrypoint.sh | 4 + 23 files changed, 1002 insertions(+), 310 deletions(-) create mode 100644 badge_fullmethodcoverage.svg create mode 100644 test/Directory.Build.props create mode 100644 test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/CoreSourceGeneratorTests.cs delete mode 100644 test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/ESBuildLauncherTests.cs create mode 100644 test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/Utils/RoslynUtility.cs diff --git a/.github/workflows/dev-pr-build.yml b/.github/workflows/dev-pr-build.yml index e155769bd..bb0db7f4a 100644 --- a/.github/workflows/dev-pr-build.yml +++ b/.github/workflows/dev-pr-build.yml @@ -57,7 +57,7 @@ jobs: ARCGIS_API_KEY: ${{ secrets.ARCGIS_TESTING_API_KEY }} GEOBLAZOR_CORE_LICENSE_KEY: ${{ secrets.GEOBLAZOR_CORE_LICENSE_KEY }} run: | - dotnet test --project ./test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj -c Release --filter CORE_ --max-parallel-test-modules 2 /p:GeneratePackage=false /p:GenerateDocs=false + dotnet test --project ./test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj -c Release --filter CORE_ -l "console;verbosity=detailed" build: runs-on: ubuntu-latest diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 70b0c17af..a0eb79c67 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,7 +38,9 @@ jobs: shell: pwsh env: USE_CONTAINER: true + COVERAGE_ENABLED: true + COVERAGE_FORMAT: xml ARCGIS_API_KEY: ${{ secrets.ARCGIS_TESTING_API_KEY }} GEOBLAZOR_CORE_LICENSE_KEY: ${{ secrets.GEOBLAZOR_CORE_LICENSE_KEY }} run: | - dotnet test --project ./test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj -c Release --filter CORE_ --max-parallel-test-modules 2 /p:GeneratePackage=false /p:GenerateDocs=false \ No newline at end of file + dotnet test --project ./test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj -c Release --filter CORE_ -l "console;verbosity=detailed" \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index 27f67b492..82c5dc489 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,26 +1,26 @@ - - - - enable - enable - 5.0.0.10 - true - Debug;Release;SourceGen Highlighting - AnyCPU - $(MSBuildThisFileDirectory)src\dymaptic.GeoBlazor.Core - - - $(StaticWebAssetEndpointExclusionPattern);js/** - - - - - - - - - - - + + + + enable + enable + 5.0.0.10 + true + Debug;Release;SourceGen Highlighting + AnyCPU + $(MSBuildThisFileDirectory)src\dymaptic.GeoBlazor.Core + + + $(StaticWebAssetEndpointExclusionPattern);js/** + + + + + + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index c4aa578cc..8825e75fc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -106,8 +106,8 @@ ENV ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx ENV ASPNETCORE_Kestrel__Certificates__Default__Password=password # Coverage configuration (can be overridden via environment) -ENV COVERAGE_ENABLED=false -ENV COVERAGE_OUTPUT=/coverage/coverage.xml +ENV COVERAGE_ENABLED +ENV COVERAGE_FORMAT # Copy entrypoint script COPY ./test/dymaptic.GeoBlazor.Core.Test.Automation/docker-entrypoint.sh /docker-entrypoint.sh diff --git a/ReadMe.md b/ReadMe.md index 99b707036..09c5a59de 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -17,9 +17,10 @@ GeoBlazor brings the power of the ArcGIS Maps SDK for JavaScript into your Blazo [![Build](https://img.shields.io/github/actions/workflow/status/dymaptic/GeoBlazor/main-release-build.yml?logo=github)](https://github.com/dymaptic/GeoBlazor/actions/workflows/main-release-build.yml) [![Issues](https://img.shields.io/github/issues/dymaptic/GeoBlazor?logo=github)](https://github.com/dymaptic/GeoBlazor/issues) [![Pull Requests](https://img.shields.io/github/issues-pr/dymaptic/GeoBlazor?logo=github&color=)](https://github.com/dymaptic/GeoBlazor/pulls) -[![Line Code Coverage](badge_linecoverage.svg)] -[![Method Coverage](badge_methodcoverage.svg)] -[![Full Method Coverage](badge_fullmethodcoverage.svg)] + +![Line Code Coverage](badge_linecoverage.svg "Line Code Coverage") +![Method Coverage](badge_methodcoverage.svg "Method Coverage") +![Full Method Coverage](badge_fullmethodcoverage.svg "Full Method Coverage") **CORE** diff --git a/badge_fullmethodcoverage.svg b/badge_fullmethodcoverage.svg new file mode 100644 index 000000000..6b260a6f0 --- /dev/null +++ b/badge_fullmethodcoverage.svg @@ -0,0 +1,144 @@ + + + Code coverage + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Generated by: ReportGenerator 5.5.1.0 + + + + Coverage + Coverage + + + + 27.8%27.8% + + + + + + + Full method coverage + + \ No newline at end of file diff --git a/esBuildWaitForCompletion.ps1 b/esBuildWaitForCompletion.ps1 index 8a0ac25ba..c7254199f 100644 --- a/esBuildWaitForCompletion.ps1 +++ b/esBuildWaitForCompletion.ps1 @@ -15,9 +15,27 @@ if ((Test-Path -Path $CoreLockFilePath) -or (Test-Path -Path $ProLockFilePath)) return 0 } +$timeout = 30 +$elapsed = 0 + while ((Test-Path -Path $CoreLockFilePath) -or (Test-Path -Path $ProLockFilePath)) { Start-Sleep -Seconds 1 Write-Host -NoNewline "." + $elapsed++ + + if ($elapsed -ge $timeout) { + Write-Host "" + Write-Host "Timeout reached ($timeout seconds). Deleting lock files." + if (Test-Path -Path $CoreLockFilePath) { + Remove-Item -Path $CoreLockFilePath -Force + Write-Host "Deleted: $CoreLockFilePath" + } + if (Test-Path -Path $ProLockFilePath) { + Remove-Item -Path $ProLockFilePath -Force + Write-Host "Deleted: $ProLockFilePath" + } + break + } } Write-Host "Lock file removed. Exiting." diff --git a/src/dymaptic.GeoBlazor.Core.Analyzers/ComponentConstructorValidator.cs b/src/dymaptic.GeoBlazor.Core.Analyzers/ComponentConstructorValidator.cs index aa0c03fa7..a87245286 100644 --- a/src/dymaptic.GeoBlazor.Core.Analyzers/ComponentConstructorValidator.cs +++ b/src/dymaptic.GeoBlazor.Core.Analyzers/ComponentConstructorValidator.cs @@ -8,6 +8,8 @@ namespace dymaptic.GeoBlazor.Core.Analyzers; [DiagnosticAnalyzer(LanguageNames.CSharp)] internal class ComponentConstructorValidator : DiagnosticAnalyzer { + public override ImmutableArray SupportedDiagnostics => [rule]; + public override void Initialize(AnalysisContext context) { context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); @@ -15,13 +17,6 @@ public override void Initialize(AnalysisContext context) context.RegisterSymbolAction(ValidateSymbol, SymbolKind.NamedType); } - private static readonly DiagnosticDescriptor rule = new("GeoBlazor_AUC", "Missing ActivatorUtilitiesConstructorAttribute", - "Class '{0}' has multiple constructors but does not have a parameterless constructor with the [ActivatorUtilitiesConstructor] attribute. Add [ActivatorUtilitiesConstructor] to the parameterless constructor to enable proper dependency injection.", - "Usage", DiagnosticSeverity.Error, isEnabledByDefault: true, - description: "When a Blazor component has multiple constructors, the parameterless constructor must be marked with [ActivatorUtilitiesConstructor] to ensure proper instantiation. Example: [ActivatorUtilitiesConstructor] public YourComponent() { }."); - - public override ImmutableArray SupportedDiagnostics => [rule]; - private void ValidateSymbol(SymbolAnalysisContext context) { INamedTypeSymbol classSymbol = (INamedTypeSymbol)context.Symbol; @@ -34,6 +29,7 @@ private void ValidateSymbol(SymbolAnalysisContext context) // more than one constructor, at least one with parameters, if (constructors.Any(c => c.Parameters.Length > 0) + // and no parameterless constructor with the attribute && constructors.All(c => c.Parameters.Length > 0 || !HasActivatorUtilitiesConstructorAttribute(c))) { @@ -55,7 +51,7 @@ bool IsRazorComponent(INamedTypeSymbol classSymbol) { return true; } - + baseType = baseType.BaseType; } @@ -64,7 +60,15 @@ bool IsRazorComponent(INamedTypeSymbol classSymbol) bool HasActivatorUtilitiesConstructorAttribute(IMethodSymbol constructor) { - return constructor.GetAttributes().Any(attr => - attr.AttributeClass?.Name == "ActivatorUtilitiesConstructorAttribute"); + return constructor.GetAttributes() + .Any(attr => + attr.AttributeClass?.Name == "ActivatorUtilitiesConstructorAttribute"); } + + private static readonly DiagnosticDescriptor rule = new("GeoBlazor_AUC", + "Missing ActivatorUtilitiesConstructorAttribute", + "Class '{0}' has multiple constructors but does not have a parameterless constructor with the [ActivatorUtilitiesConstructor] attribute. Add [ActivatorUtilitiesConstructor] to the parameterless constructor to enable proper dependency injection.", + "Usage", DiagnosticSeverity.Error, isEnabledByDefault: true, + description: + "When a Blazor component has multiple constructors, the parameterless constructor must be marked with [ActivatorUtilitiesConstructor] to ensure proper instantiation. Example: [ActivatorUtilitiesConstructor] public YourComponent() { }."); } \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core/ReadMe.md b/src/dymaptic.GeoBlazor.Core/ReadMe.md index 81485862c..57f162e61 100644 --- a/src/dymaptic.GeoBlazor.Core/ReadMe.md +++ b/src/dymaptic.GeoBlazor.Core/ReadMe.md @@ -10,6 +10,10 @@ GeoBlazor brings the power of the ArcGIS Maps SDK for JavaScript into your Blazo [![Issues](https://img.shields.io/github/issues/dymaptic/GeoBlazor?logo=github)](https://github.com/dymaptic/GeoBlazor/issues) [![Pull Requests](https://img.shields.io/github/issues-pr/dymaptic/GeoBlazor?logo=github&color=)](https://github.com/dymaptic/GeoBlazor/pulls) +![Line Code Coverage](badge_linecoverage.svg "Line Code Coverage") +![Method Coverage](badge_methodcoverage.svg "Method Coverage") +![Full Method Coverage](badge_fullmethodcoverage.svg "Full Method Coverage") + **CORE** [![NuGet](https://img.shields.io/nuget/v/dymaptic.GeoBlazor.Core.svg?logo=nuget&logoColor=white)](https://www.nuget.org/packages/dymaptic.GeoBlazor.Core/) @@ -20,6 +24,8 @@ GeoBlazor brings the power of the ArcGIS Maps SDK for JavaScript into your Blazo [![NuGet](https://img.shields.io/nuget/v/dymaptic.GeoBlazor.Pro.svg?logo=nuget&logoColor=white)](https://www.nuget.org/packages/dymaptic.GeoBlazor.Pro/) [![Downloads](https://img.shields.io/nuget/dt/dymaptic.GeoBlazor.Pro?logo=nuget&label=downloads)](https://www.nuget.org/stats/packages/dymaptic.GeoBlazor.Pro?groupby=Version) +**COMMUNITY** + [![Discord](https://img.shields.io/discord/1027907220949717033?color=%235865F2&label=chat&logo=discord&logoColor=white)](https://discord.gg/hcmbPzn4VW) ## ✨ Key Features diff --git a/src/dymaptic.GeoBlazor.Core/badge_fullmethodcoverage.svg b/src/dymaptic.GeoBlazor.Core/badge_fullmethodcoverage.svg index e69de29bb..5ade9747c 100644 --- a/src/dymaptic.GeoBlazor.Core/badge_fullmethodcoverage.svg +++ b/src/dymaptic.GeoBlazor.Core/badge_fullmethodcoverage.svg @@ -0,0 +1,151 @@ + + + Code coverage + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Generated by: ReportGenerator 5.5.1.0 + + + + Coverage + Coverage + + + 27.8% + 27.8% + + + + + + + Full method coverage + + + \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core/badge_linecoverage.svg b/src/dymaptic.GeoBlazor.Core/badge_linecoverage.svg index e69de29bb..76f9ab5d1 100644 --- a/src/dymaptic.GeoBlazor.Core/badge_linecoverage.svg +++ b/src/dymaptic.GeoBlazor.Core/badge_linecoverage.svg @@ -0,0 +1,144 @@ + + + Code coverage + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Generated by: ReportGenerator 5.5.1.0 + + + + Coverage + Coverage + 8.3% + 8.3% + + + + + + + Line coverage + + + + + \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core/badge_methodcoverage.svg b/src/dymaptic.GeoBlazor.Core/badge_methodcoverage.svg index e69de29bb..3f7b01cc7 100644 --- a/src/dymaptic.GeoBlazor.Core/badge_methodcoverage.svg +++ b/src/dymaptic.GeoBlazor.Core/badge_methodcoverage.svg @@ -0,0 +1,148 @@ + + + Code coverage + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Generated by: ReportGenerator 5.5.1.0 + + + + Coverage + Coverage + + + 30.9% + 30.9% + + + + + + + + Method coverage + + + + \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core/dymaptic.GeoBlazor.Core.csproj b/src/dymaptic.GeoBlazor.Core/dymaptic.GeoBlazor.Core.csproj index 3b56a3245..ba582181a 100644 --- a/src/dymaptic.GeoBlazor.Core/dymaptic.GeoBlazor.Core.csproj +++ b/src/dymaptic.GeoBlazor.Core/dymaptic.GeoBlazor.Core.csproj @@ -77,9 +77,6 @@ - - - diff --git a/test/Directory.Build.props b/test/Directory.Build.props new file mode 100644 index 000000000..438ec4d30 --- /dev/null +++ b/test/Directory.Build.props @@ -0,0 +1,11 @@ + + + + + + false + false + false + false + + \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/CoreSourceGeneratorTests.cs b/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/CoreSourceGeneratorTests.cs new file mode 100644 index 000000000..f0455631f --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/CoreSourceGeneratorTests.cs @@ -0,0 +1,206 @@ +using dymaptic.GeoBlazor.Core.SourceGenerator.Tests.Utils; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using ProtoBuf; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + + +namespace dymaptic.GeoBlazor.Core.SourceGenerator.Tests; + +[TestClass] +public class CoreSourceGeneratorTests +{ + [TestMethod] + public void TestCanTriggerESBuildInDebugMode() + { + var corePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, + "..", "..", "..", "..", "..", "src", "dymaptic.GeoBlazor.Core"); + + var generator = new ProtobufSourceGenerator(); + + // get actual Scripts files + var scriptsPath = Path.Combine(corePath, "Scripts"); + + var additionalTexts = Directory + .GetFiles(scriptsPath, "*.ts") + .Select(f => new TestAdditionalFile(f, File.ReadAllText(f))); + + var cSharpParseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest); + + TestAnalyzerConfigOptionsProvider analyzerConfigOptions = new(new Dictionary + { + { "build_property.CoreProjectPath", corePath }, + { "build_property.Configuration", "Debug" }, + { "build_property.PipelineBuild", "false" } + }); + + // Source generators should be tested using 'GeneratorDriver'. + GeneratorDriver driver = CSharpGeneratorDriver + .Create([generator.AsSourceGenerator()], additionalTexts, cSharpParseOptions, analyzerConfigOptions); + + // Create a compilation that includes the dymaptic.GeoBlazor.Core sources so generated trees are merged with real trees. + var compilation = CreateCompilationWithCoreSources(corePath, cSharpParseOptions); + + // Run generators. Don't forget to use the new compilation rather than the previous one. + driver.RunGeneratorsAndUpdateCompilation(compilation, out var newCompilation, + out var diagnostics); + + Assert.IsTrue(diagnostics.Any(d => d.Id == "GBSourceGen"), + "Expected a GBSourceGen diagnostic from the generator."); + + // find the generated tree that contains the ESBuildRecord marker + var generatedTree = newCompilation.SyntaxTrees.FirstOrDefault(t => t.ToString().Contains("ESBuildRecord")); + Assert.IsNotNull(generatedTree, "Expected a generated syntax tree containing 'ESBuildRecord'."); + var generatedText = generatedTree!.ToString(); + + Assert.Contains("private const string Configuration = \"Debug\";", generatedText, + "Expected Configuration = \"Debug\" in generated tree."); + } + + [TestMethod] + public void TestCanTriggerESBuildInReleaseMode() + { + var corePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, + "..", "..", "..", "..", "..", "src", "dymaptic.GeoBlazor.Core"); + + var generator = new ProtobufSourceGenerator(); + + // get actual Scripts files + var scriptsPath = Path.Combine(corePath, "Scripts"); + + var additionalTexts = Directory + .GetFiles(scriptsPath, "*.ts") + .Select(f => new TestAdditionalFile(f, File.ReadAllText(f))); + + var cSharpParseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest); + + TestAnalyzerConfigOptionsProvider analyzerConfigOptions = new(new Dictionary + { + { "build_property.MSBuildProjectDirectory", corePath }, + { "build_property.Configuration", "Release" }, + { "build_property.PipelineBuild", "false" } + }); + + // Source generators should be tested using 'GeneratorDriver'. + GeneratorDriver driver = CSharpGeneratorDriver + .Create([generator.AsSourceGenerator()], additionalTexts, cSharpParseOptions, analyzerConfigOptions); + + var compilation = CreateCompilationWithCoreSources(corePath, cSharpParseOptions); + + // Run generators. Don't forget to use the new compilation rather than the previous one. + driver.RunGeneratorsAndUpdateCompilation(compilation, out var newCompilation, + out var diagnostics); + + Assert.IsTrue(diagnostics.Any(d => d.Id == "GBSourceGen"), + "Expected a GBSourceGen diagnostic from the generator."); + + var generatedTree = newCompilation.SyntaxTrees.FirstOrDefault(t => t.ToString().Contains("ESBuildRecord")); + Assert.IsNotNull(generatedTree, "Expected a generated syntax tree containing 'ESBuildRecord'."); + var generatedText = generatedTree!.ToString(); + + Assert.Contains("private const string Configuration = \"Release\";", generatedText, + "Expected Configuration = \"Release\" in generated tree."); + } + + [TestMethod] + public void TestCanSkipBuildInPipelineMode() + { + var corePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, + "..", "..", "..", "..", "..", "src", "dymaptic.GeoBlazor.Core"); + + var generator = new ProtobufSourceGenerator(); + + // get actual Scripts files + var scriptsPath = Path.Combine(corePath, "Scripts"); + + var additionalTexts = Directory + .GetFiles(scriptsPath, "*.ts") + .Select(f => new TestAdditionalFile(f, File.ReadAllText(f))); + + var cSharpParseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest); + + TestAnalyzerConfigOptionsProvider analyzerConfigOptions = new(new Dictionary + { + { "build_property.MSBuildProjectDirectory", corePath }, + { "build_property.Configuration", "Release" }, + { "build_property.PipelineBuild", "true" } + }); + + // Source generators should be tested using 'GeneratorDriver'. + GeneratorDriver driver = CSharpGeneratorDriver + .Create([generator.AsSourceGenerator()], additionalTexts, cSharpParseOptions, analyzerConfigOptions); + + // Create compilation with core sources so there are real syntax trees present + var compilation = CreateCompilationWithCoreSources(corePath, cSharpParseOptions); + + // Run generators. Don't forget to use the new compilation rather than the previous one. + driver.RunGeneratorsAndUpdateCompilation(compilation, out var newCompilation, + out var diagnostics); + + Assert.IsTrue(diagnostics.Any(d => d.Id == "GBSourceGen"), + "Expected a GBSourceGen diagnostic from the generator."); + + // When running in pipeline mode the generator should not produce the ESBuildRecord generated tree + Assert.IsFalse(newCompilation.SyntaxTrees.Any(t => t.ToString().Contains("ESBuildRecord")), + "Did not expect an ESBuildRecord generated tree when PipelineBuild is true."); + } + + // Helper: create a compilation that includes the dymaptic.GeoBlazor.Core source files + private static CSharpCompilation CreateCompilationWithCoreSources(string corePath, CSharpParseOptions parseOptions) + { + // gather all .cs files from the core project + var csFiles = Directory.GetFiles(corePath, "*.cs", SearchOption.AllDirectories); + var trees = csFiles.Select(f => CSharpSyntaxTree.ParseText(File.ReadAllText(f), parseOptions, f)).ToList(); + + // minimal set of references for compilation + var referencePaths = new[] + { + typeof(object).Assembly.Location, typeof(Enumerable).Assembly.Location, + typeof(Attribute).Assembly.Location, typeof(Console).Assembly.Location, + typeof(ProtoContractAttribute).Assembly.Location + } + .Where(p => !string.IsNullOrEmpty(p)) + .Distinct(); + + var references = referencePaths.Select(p => MetadataReference.CreateFromFile(p)).ToList(); + + return CSharpCompilation.Create(nameof(CoreSourceGeneratorTests), trees, references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } +} + +public class TestAnalyzerConfigOptionsProvider(Dictionary options) + : AnalyzerConfigOptionsProvider +{ + public override AnalyzerConfigOptions GlobalOptions { get; } = new TestAnalyzerConfigOptions(options); + + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) + { + return GlobalOptions; + } + + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) + { + return GlobalOptions; + } +} + +public class TestAnalyzerConfigOptions(Dictionary options) + : AnalyzerConfigOptions +{ + public override bool TryGetValue(string key, [NotNullWhen(true)] [UnscopedRef] out string? value) + { + if (options.TryGetValue(key, out var optionValue)) + { + value = optionValue; + + return true; + } + + value = null; + + return false; + } +} \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/ESBuildLauncherTests.cs b/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/ESBuildLauncherTests.cs deleted file mode 100644 index 3fb97123f..000000000 --- a/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/ESBuildLauncherTests.cs +++ /dev/null @@ -1,180 +0,0 @@ -using dymaptic.GeoBlazor.Core.SourceGenerator.Tests.Utils; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Diagnostics; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using System.Collections.Immutable; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; - - -namespace dymaptic.GeoBlazor.Core.SourceGenerator.Tests; - -[TestClass] -public class ESBuildLauncherTests -{ - - [TestMethod] - public void TestCanTriggerESBuildInDebugMode() - { - string corePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, - "..", "..", "..", "..", "..", "src", "dymaptic.GeoBlazor.Core"); - - var generator = new ESBuildLauncher(); - bool resultsReceived = false; - generator.Notification += (_, message) => - { - Console.WriteLine(message); - resultsReceived = true; - }; - - // get actual Scripts files - string scriptsPath = Path.Combine(corePath, "Scripts"); - IEnumerable additionalTexts = Directory - .GetFiles(scriptsPath, "*.ts") - .Select(f => new TestAdditionalFile(f, File.ReadAllText(f))); - - CSharpParseOptions cSharpParseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp7_3); - - TestAnalyzerConfigOptionsProvider analyzerConfigOptions = new(new Dictionary - { - { "build_property.MSBuildProjectDirectory", corePath }, - { "build_property.Configuration", "Debug" }, - { "build_property.PipelineBuild", "false" } - }); - - // Source generators should be tested using 'GeneratorDriver'. - GeneratorDriver driver = CSharpGeneratorDriver - .Create([generator.AsSourceGenerator()], additionalTexts, cSharpParseOptions, analyzerConfigOptions); - - // To run generators, we can use an empty compilation. - var compilation = CSharpCompilation.Create(nameof(ESBuildLauncherTests)); - // Run generators. Don't forget to use the new compilation rather than the previous one. - driver.RunGeneratorsAndUpdateCompilation(compilation, out Compilation newCompilation, - out ImmutableArray _); - - Assert.IsTrue(resultsReceived); - Assert.IsNotEmpty(newCompilation.SyntaxTrees); - Assert.Contains("ESBuildRecord", newCompilation.SyntaxTrees.First().ToString()); - Assert.Contains("private const string Configuration = \"Debug\";", newCompilation.SyntaxTrees.First().ToString()); - } - - [TestMethod] - public void TestCanTriggerESBuildInReleaseMode() - { - string corePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, - "..", "..", "..", "..", "..", "src", "dymaptic.GeoBlazor.Core"); - - var generator = new ESBuildLauncher(); - bool resultsReceived = false; - generator.Notification += (_, message) => - { - Console.WriteLine(message); - resultsReceived = true; - }; - - // get actual Scripts files - string scriptsPath = Path.Combine(corePath, "Scripts"); - IEnumerable additionalTexts = Directory - .GetFiles(scriptsPath, "*.ts") - .Select(f => new TestAdditionalFile(f, File.ReadAllText(f))); - - CSharpParseOptions cSharpParseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp7_3); - - TestAnalyzerConfigOptionsProvider analyzerConfigOptions = new(new Dictionary - { - { "build_property.MSBuildProjectDirectory", corePath }, - { "build_property.Configuration", "Release" }, - { "build_property.PipelineBuild", "false" } - }); - - // Source generators should be tested using 'GeneratorDriver'. - GeneratorDriver driver = CSharpGeneratorDriver - .Create([generator.AsSourceGenerator()], additionalTexts, cSharpParseOptions, analyzerConfigOptions); - - // To run generators, we can use an empty compilation. - var compilation = CSharpCompilation.Create(nameof(ESBuildLauncherTests)); - // Run generators. Don't forget to use the new compilation rather than the previous one. - driver.RunGeneratorsAndUpdateCompilation(compilation, out Compilation newCompilation, - out ImmutableArray _); - - Assert.IsTrue(resultsReceived); - Assert.IsNotEmpty(newCompilation.SyntaxTrees); - Assert.Contains("ESBuildRecord", newCompilation.SyntaxTrees.First().ToString()); - Assert.Contains("private const string Configuration = \"Release\";", newCompilation.SyntaxTrees.First().ToString()); - } - - [TestMethod] - public void TestCanSkipBuildInPipelineMode() - { - string corePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, - "..", "..", "..", "..", "..", "src", "dymaptic.GeoBlazor.Core"); - - var generator = new ESBuildLauncher(); - bool resultsReceived = false; - generator.Notification += (_, message) => - { - Console.WriteLine(message); - resultsReceived = true; - }; - - // get actual Scripts files - string scriptsPath = Path.Combine(corePath, "Scripts"); - IEnumerable additionalTexts = Directory - .GetFiles(scriptsPath, "*.ts") - .Select(f => new TestAdditionalFile(f, File.ReadAllText(f))); - - CSharpParseOptions cSharpParseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp7_3); - - TestAnalyzerConfigOptionsProvider analyzerConfigOptions = new(new Dictionary - { - { "build_property.MSBuildProjectDirectory", corePath }, - { "build_property.Configuration", "Release" }, - { "build_property.PipelineBuild", "true" } - }); - - // Source generators should be tested using 'GeneratorDriver'. - GeneratorDriver driver = CSharpGeneratorDriver - .Create([generator.AsSourceGenerator()], additionalTexts, cSharpParseOptions, analyzerConfigOptions); - - // To run generators, we can use an empty compilation. - var compilation = CSharpCompilation.Create(nameof(ESBuildLauncherTests)); - // Run generators. Don't forget to use the new compilation rather than the previous one. - driver.RunGeneratorsAndUpdateCompilation(compilation, out Compilation newCompilation, - out ImmutableArray _); - Assert.IsEmpty(newCompilation.SyntaxTrees); - Assert.IsTrue(resultsReceived); - } -} - -public class TestAnalyzerConfigOptionsProvider(Dictionary options) - : AnalyzerConfigOptionsProvider -{ - public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) - { - return GlobalOptions; - } - - public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) - { - return GlobalOptions; - } - - public override AnalyzerConfigOptions GlobalOptions { get; } = new TestAnalyzerConfigOptions(options); -} - -public class TestAnalyzerConfigOptions(Dictionary options) - : AnalyzerConfigOptions -{ - public override bool TryGetValue(string key, [NotNullWhen(true)] [UnscopedRef] out string? value) - { - if (options.TryGetValue(key, out string? optionValue)) - { - value = optionValue; - return true; - } - - value = null; - return false; - } -} \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/Utils/RoslynUtility.cs b/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/Utils/RoslynUtility.cs new file mode 100644 index 000000000..3ed81e2aa --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/Utils/RoslynUtility.cs @@ -0,0 +1,54 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using System.Reflection; + + +[assembly: DoNotParallelize] + + +namespace dymaptic.GeoBlazor.Core.SourceGenerator.Tests.Utils; + +public static class RoslynUtility +{ + public static string CoreDllPath => Path.Combine(CorePath, "bin", "Debug", "net8.0", + "dymaptic.GeoBlazor.Core.dll"); + + public static string CorePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, + "..", "..", "..", "..", "..", "src", "dymaptic.GeoBlazor.Core"); + + public static CSharpCompilation CreateCompilation(string sourceCode) + { + CSharpParseOptions options = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp7_3); + SyntaxTree tree = CSharpSyntaxTree.ParseText(sourceCode, options); + SyntaxTree protobufStubTree = CSharpSyntaxTree.ParseText(protobufStub, options); + + PortableExecutableReference[] references = new[] + { + typeof(object).Assembly, // System.Runtime + typeof(Console).Assembly, // System.Console + typeof(ComponentBase).Assembly, // Microsoft.AspNetCore.Components + typeof(ActivatorUtilitiesConstructorAttribute).Assembly, // Microsoft.Extensions.DependencyInjection + Assembly.Load("System.Runtime"), Assembly.Load("netstandard"), + Assembly.LoadFile(CoreDllPath) // dymaptic.GeoBlazor.Core + } + .Select(a => MetadataReference.CreateFromFile(a.Location)) + .ToArray(); + + return CSharpCompilation.Create("dymaptic.GeoBlazor.Core", + [tree, protobufStubTree], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } + + private static readonly string protobufStub = """ + + + namespace ProtoBuf + { + [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Struct)] + public sealed class ProtoContractAttribute : System.Attribute + { + } + } + """; +} \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/dymaptic.GeoBlazor.Core.SourceGenerator.Tests.csproj b/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/dymaptic.GeoBlazor.Core.SourceGenerator.Tests.csproj index bb4638238..d1b8ce60a 100644 --- a/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/dymaptic.GeoBlazor.Core.SourceGenerator.Tests.csproj +++ b/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/dymaptic.GeoBlazor.Core.SourceGenerator.Tests.csproj @@ -7,9 +7,11 @@ false dymaptic.GeoBlazor.Core.SourceGenerator.Tests + true + @@ -19,10 +21,14 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + - + + diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/GeoBlazorTestClass.cs b/test/dymaptic.GeoBlazor.Core.Test.Automation/GeoBlazorTestClass.cs index a402026b3..f742d214b 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Automation/GeoBlazorTestClass.cs +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/GeoBlazorTestClass.cs @@ -9,16 +9,9 @@ public abstract class GeoBlazorTestClass : PlaywrightTest { private IBrowserContext Context { get; set; } = null!; - // Optimized navigation: WaitUntil.Commit is faster - element waits handle actual readiness - private PageGotoOptions PageGotoOptions => new() - { - WaitUntil = WaitUntilState.Commit, Timeout = TestConfig.IsCI ? 45_000 : 30_000 // Reduced from 60_000 - }; - - // Reduced timeouts: 90s/60s instead of 120s - still generous but faster failure detection - private LocatorClickOptions ClickOptions => new() { Timeout = TestConfig.IsCI ? 90_000 : 60_000 }; + private PageGotoOptions PageGotoOptions => new() { WaitUntil = WaitUntilState.Commit, Timeout = 60_000 }; - private LocatorAssertionsToBeVisibleOptions VisibleOptions => new() { Timeout = TestConfig.IsCI ? 90_000 : 60_000 }; + private LocatorAssertionsToBeVisibleOptions VisibleOptions => new() { Timeout = 90_000 }; [TestInitialize] public Task TestSetup() @@ -88,11 +81,11 @@ protected async Task RunTestImplementation(string testName, int retries = 0) if (isExpanded != "true") { - await sectionToggle.ClickAsync(ClickOptions); + await sectionToggle.ClickAsync(); } ILocator testBtn = page.GetByText("Run Test"); - await testBtn.ClickAsync(ClickOptions); + await testBtn.ClickAsync(); ILocator passedSpan = page.GetByTestId("passed"); ILocator inconclusiveSpan = page.GetByTestId("inconclusive"); @@ -130,7 +123,7 @@ protected async Task RunTestImplementation(string testName, int retries = 0) Trace.WriteLine($"{ex.Message}{Environment.NewLine}{ex.StackTrace}", "ERROR"); } - if (retries > 1) // Reduced from 2 to 1 (max 2 retries instead of 3) + if (retries > 2) { Assert.Fail($"{testName} Exceeded the maximum number of retries."); } @@ -170,6 +163,7 @@ private async Task Setup(int retries) // Create context on the pooled browser Context = await NewContextAsync(ContextOptions()).ConfigureAwait(false); + Context.SetDefaultTimeout(60_000); } catch (Exception e) { diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/TestConfig.cs b/test/dymaptic.GeoBlazor.Core.Test.Automation/TestConfig.cs index 9af448a63..d670a8650 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Automation/TestConfig.cs +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/TestConfig.cs @@ -34,8 +34,9 @@ public class TestConfig /// public static bool IsCI { get; private set; } - private static string ComposeFilePath => Path.Combine(_projectFolder, - _proAvailable && !CoreOnly ? "docker-compose-pro.yml" : "docker-compose-core.yml"); + private static string ComposeFilePath => _proAvailable && !CoreOnly ? ProComposeFilePath : CoreComposeFilePath; + private static string CoreComposeFilePath => Path.Combine(_projectFolder, "docker-compose-core.yml"); + private static string ProComposeFilePath => Path.Combine(_projectFolder, "docker-compose-pro.yml"); private static string TestAppPath => _proAvailable ? Path.GetFullPath(Path.Combine(_projectFolder, "..", "..", "..", "test", "dymaptic.GeoBlazor.Pro.Test.WebApp", @@ -63,12 +64,13 @@ public static async Task AssemblyInitialize(TestContext testContext) Trace.Listeners.Add(new StringBuilderTraceListener(logBuilder)); Trace.AutoFlush = true; + SetupConfiguration(); + // kill old running test apps and containers - await StopContainer(); + await StopContainer(CoreComposeFilePath); + await StopContainer(ProComposeFilePath); await StopTestApp(); - SetupConfiguration(); - if (_cover) { await InstallCodeCoverageTools(); @@ -105,7 +107,12 @@ public static async Task AssemblyCleanup() if (_useContainer) { - await StopContainer(); + await StopContainer(ComposeFilePath); + + if (_cover) + { + CopyCoverageFromContainer(); + } } else { @@ -343,8 +350,12 @@ private static async Task StartTestApp() "run", "--project", $"\"{TestAppPath}\"", "--urls", $"{TestAppUrl};{TestAppHttpUrl}", "--", "-c", "Release", - "/p:GenerateXmlComments=false", "/p:GeneratePackage=false", - "/p:DebugSymbols=true", "/p:DebugType=portable" + "/p:GenerateXmlComments=false", + "/p:GeneratePackage=false", + "/p:GenerateDocs=false", + "/p:DebugSymbols=true", + "/p:DebugType=portable", + "/p:UsePackageReference=false" ]; if (_cover) @@ -360,6 +371,8 @@ private static async Task StartTestApp() "collect", "-o", CoverageFilePath, "-f", _coverageFormat, + "--include-files", "**/dymaptic.GeoBlazor.Core.dll", + "--include-files", "**/dymaptic.GeoBlazor.Pro.dll", dotnetCommand ]; } @@ -378,7 +391,7 @@ private static async Task StartTestApp() await WaitForHttpResponse(); } - private static async Task StopContainer() + private static async Task StopContainer(string composeFilePath) { // If coverage is enabled, gracefully shutdown dotnet-coverage before stopping the container if (_cover) @@ -388,36 +401,20 @@ private static async Task StopContainer() try { - Trace.WriteLine($"Stopping container with: docker compose -f {ComposeFilePath} down", "TEST_CLEANUP"); + Trace.WriteLine($"Stopping container with: docker compose -f {composeFilePath} down", "TEST_CLEANUP"); await Cli.Wrap("docker") - .WithArguments($"compose -f \"{ComposeFilePath}\" down") + .WithArguments($"compose -f \"{composeFilePath}\" down") .WithValidation(CommandResultValidation.None) + .WithStandardOutputPipe(PipeTarget.ToDelegate(line => Trace.WriteLine(line, "TEST_CONTAINER_CLEANUP"))) + .WithStandardErrorPipe(PipeTarget.ToDelegate(line => Trace.WriteLine(line, "TEST_CONTAINER_ERROR"))) .ExecuteAsync(cts.Token); - Trace.WriteLine("Container stopped successfully", "TEST_CLEANUP"); } catch { // ignore, these just clutter the output } - // If coverage was enabled, copy the coverage file from the volume mount directory - if (_cover) - { - var containerCoverageFile = Path.Combine(_projectFolder, "coverage", "coverage.xml"); - var targetCoverageFile = Path.Combine(_projectFolder, $"coverage.{_coverageFormat}"); - - if (File.Exists(containerCoverageFile)) - { - File.Copy(containerCoverageFile, targetCoverageFile, true); - Trace.WriteLine($"Coverage file copied from container: {targetCoverageFile}", "TEST_CLEANUP"); - } - else - { - Trace.WriteLine($"Container coverage file not found: {containerCoverageFile}", "TEST_CLEANUP"); - } - } - await KillProcessesByTestPorts(); } @@ -442,9 +439,9 @@ private static async Task ShutdownCoverageCollection() await Cli.Wrap("docker") .WithArguments($"exec {containerName} /tools/dotnet-coverage shutdown geoblazor-coverage") .WithStandardOutputPipe(PipeTarget.ToDelegate(line => - Trace.WriteLine(line, "CODE_COVERAGE"))) + Trace.WriteLine(line, "CODE_COVERAGE_SHUTDOWN"))) .WithStandardErrorPipe(PipeTarget.ToDelegate(line => - Trace.WriteLine(line, "CODE_COVERAGE_ERROR"))) + Trace.WriteLine(line, "CODE_COVERAGE_SHUTDOWN_ERROR"))) .ExecuteAsync(); // Give time for coverage file to be written @@ -457,6 +454,23 @@ await Cli.Wrap("docker") } } + private static void CopyCoverageFromContainer() + { + // If coverage was enabled, copy the coverage file from the volume mount directory + var containerCoverageFile = Path.Combine(_projectFolder, "coverage", "coverage.xml"); + var targetCoverageFile = Path.Combine(_projectFolder, $"coverage.{_coverageFormat}"); + + if (File.Exists(containerCoverageFile)) + { + File.Copy(containerCoverageFile, targetCoverageFile, true); + Trace.WriteLine($"Coverage file copied from container: {targetCoverageFile}", "TEST_CLEANUP"); + } + else + { + Trace.WriteLine($"Container coverage file not found: {containerCoverageFile}", "TEST_CLEANUP"); + } + } + private static async Task WaitForHttpResponse() { // Configure HttpClient to ignore SSL certificate errors (for self-signed certs in Docker) @@ -575,7 +589,7 @@ private static async Task GenerateCoverageReport() try { - Trace.WriteLine("Generating coverage report...", "CODE_COVERAGE"); + Trace.WriteLine("Generating coverage report...", "CODE_COVERAGE_REPORT"); List assemblyFilters = CoreOnly ? ["+dymaptic.GeoBlazor.Core.dll"] @@ -614,35 +628,20 @@ await Cli.Wrap("reportgenerator") .WithValidation(CommandResultValidation.None) .ExecuteAsync(); - var indexPath = Path.Combine(reportDir, "index.html"); + string textSummaryPath = Path.Combine(reportDir, "Summary.txt"); + string webReportPath = Path.Combine(reportDir, "index.html"); - if (File.Exists(indexPath)) + if (File.Exists(textSummaryPath)) { - Trace.WriteLine($"Coverage report generated: {indexPath}", "CODE_COVERAGE"); - - // Open report in browser for local development (not CI) - if (!IsCI) - { - try - { - OpenInBrowser(indexPath); - Trace.WriteLine("Coverage report opened in browser", "CODE_COVERAGE"); - } - catch (Exception ex) - { - Trace.WriteLine($"Failed to open browser: {ex.Message}", "CODE_COVERAGE"); - } - } - } - else - { - Trace.WriteLine("Coverage report index.html was not generated", "CODE_COVERAGE_ERROR"); + Trace.WriteLine(await File.ReadAllTextAsync(textSummaryPath), + "CODE_COVERAGE_SUCCESS"); + Trace.WriteLine($"Full report at [Coverage Report]({webReportPath})", "CODE_COVERAGE_SUCCESS"); } // copy the badge image to the repo root var lineBadgePath = Path.Combine(reportDir, "badge_linecoverage.svg"); var methodBadgePath = Path.Combine(reportDir, "badge_methodcoverage.svg"); - var fullMethodBadgePath = Path.Combine(_projectFolder, "badge_fullmethodcoverage.svg"); + var fullMethodBadgePath = Path.Combine(reportDir, "badge_fullmethodcoverage.svg"); if (!ProOnly) { @@ -670,19 +669,6 @@ await Cli.Wrap("reportgenerator") } } - private static void OpenInBrowser(string path) - { - var cmdLineApp = OperatingSystem.IsWindows() - ? "start" - : OperatingSystem.IsMacOS() - ? "open" - : "xdg-open"; - - Cli.Wrap(cmdLineApp) - .WithArguments(path) - .ExecuteAsync(); - } - private static readonly CancellationTokenSource cts = new(); private static readonly CancellationTokenSource gracefulCts = new(); private static readonly StringBuilder logBuilder = new(); diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-compose-core.yml b/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-compose-core.yml index 6c44fa83e..a6b5caa81 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-compose-core.yml +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-compose-core.yml @@ -10,8 +10,6 @@ services: GEOBLAZOR_LICENSE_KEY: ${GEOBLAZOR_CORE_LICENSE_KEY} HTTP_PORT: ${HTTP_PORT} HTTPS_PORT: ${HTTPS_PORT} - COVERAGE_FORMAT: ${COVERAGE_FORMAT} - COVERAGE_FILE_VERSION: ${COVERAGE_FILE_VERSION} WFS_SERVERS: |- "WFSServers": [ { @@ -27,7 +25,7 @@ services: environment: - ASPNETCORE_ENVIRONMENT=Production - COVERAGE_ENABLED=${COVERAGE_ENABLED:-false} - - COVERAGE_OUTPUT=/coverage/coverage.${COVERAGE_FILE_VERSION}.${COVERAGE_FORMAT:-xml} + - COVERAGE_FORMAT=${COVERAGE_FORMAT:-xml} ports: - "${HTTP_PORT:-8080}:${HTTP_PORT:-8080}" - "${HTTPS_PORT:-9443}:${HTTPS_PORT:-9443}" diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-compose-pro.yml b/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-compose-pro.yml index 44e77827a..94a000e5e 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-compose-pro.yml +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-compose-pro.yml @@ -10,8 +10,6 @@ services: GEOBLAZOR_LICENSE_KEY: ${GEOBLAZOR_PRO_LICENSE_KEY} HTTP_PORT: ${HTTP_PORT} HTTPS_PORT: ${HTTPS_PORT} - COVERAGE_FORMAT: ${COVERAGE_FORMAT} - COVERAGE_FILE_VERSION: ${COVERAGE_FILE_VERSION} WFS_SERVERS: |- "WFSServers": [ { @@ -27,7 +25,7 @@ services: environment: - ASPNETCORE_ENVIRONMENT=Production - COVERAGE_ENABLED=${COVERAGE_ENABLED:-false} - - COVERAGE_OUTPUT=/coverage/coverage.${COVERAGE_FILE_VERSION}.${COVERAGE_FORMAT:-xml} + - COVERAGE_FORMAT=${COVERAGE_FORMAT:-xml} ports: - "${HTTP_PORT:-8080}:${HTTP_PORT:-8080}" - "${HTTPS_PORT:-9443}:${HTTPS_PORT:-9443}" diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-entrypoint.sh b/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-entrypoint.sh index d9e94a894..7569cef12 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-entrypoint.sh +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-entrypoint.sh @@ -2,6 +2,8 @@ set -e SESSION_ID="geoblazor-coverage" +COVERAGE_FILE_VERSION="$(date +%Y-%m-%d-%H-%M-%S)" +COVERAGE_OUTPUT="/coverage/coverage.$COVERAGE_FILE_VERSION.$COVERAGE_FORMAT" # Trap SIGTERM to gracefully shutdown coverage collection _term() { @@ -34,6 +36,8 @@ if [ "$COVERAGE_ENABLED" = "true" ]; then --session-id "$SESSION_ID" \ -o "$COVERAGE_OUTPUT" \ -f xml \ + --include-files "**/dymaptic.GeoBlazor.Core.dll" \ + --include-files "**/dymaptic.GeoBlazor.Pro.dll" \ -l "$COVERAGE_OUTPUT.log" \ -ll Verbose \ -- "$@" From 9ef3f44f9d185ef1f0c73ccd8e9029c9c0c8ffaf Mon Sep 17 00:00:00 2001 From: Tim Purdum Date: Fri, 16 Jan 2026 22:57:50 -0600 Subject: [PATCH 13/18] update coverage --- .github/workflows/dev-pr-build.yml | 4 +- .github/workflows/tests.yml | 6 +- badge_fullmethodcoverage.svg | 2 +- badge_linecoverage.svg | 138 ++++++++++++++++++ badge_methodcoverage.svg | 138 ++++++++++++++++++ .../badge_fullmethodcoverage.svg | 4 +- .../badge_methodcoverage.svg | 4 +- 7 files changed, 287 insertions(+), 9 deletions(-) create mode 100644 badge_linecoverage.svg create mode 100644 badge_methodcoverage.svg diff --git a/.github/workflows/dev-pr-build.yml b/.github/workflows/dev-pr-build.yml index bb0db7f4a..8fa52d5d9 100644 --- a/.github/workflows/dev-pr-build.yml +++ b/.github/workflows/dev-pr-build.yml @@ -56,8 +56,10 @@ jobs: USE_CONTAINER: true ARCGIS_API_KEY: ${{ secrets.ARCGIS_TESTING_API_KEY }} GEOBLAZOR_CORE_LICENSE_KEY: ${{ secrets.GEOBLAZOR_CORE_LICENSE_KEY }} + HTTP_PORT: 8082 + HTTPS_PORT: 9445 run: | - dotnet test --project ./test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj -c Release --filter CORE_ -l "console;verbosity=detailed" + dotnet test --project ./test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj -c Release --filter CORE_ --output Detailed build: runs-on: ubuntu-latest diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a0eb79c67..b50edf699 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,9 +38,9 @@ jobs: shell: pwsh env: USE_CONTAINER: true - COVERAGE_ENABLED: true - COVERAGE_FORMAT: xml ARCGIS_API_KEY: ${{ secrets.ARCGIS_TESTING_API_KEY }} GEOBLAZOR_CORE_LICENSE_KEY: ${{ secrets.GEOBLAZOR_CORE_LICENSE_KEY }} + HTTP_PORT: 8082 + HTTPS_PORT: 9445 run: | - dotnet test --project ./test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj -c Release --filter CORE_ -l "console;verbosity=detailed" \ No newline at end of file + dotnet test --project ./test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj -c Release --filter CORE_ --output Detailed \ No newline at end of file diff --git a/badge_fullmethodcoverage.svg b/badge_fullmethodcoverage.svg index 6b260a6f0..1888c66f1 100644 --- a/badge_fullmethodcoverage.svg +++ b/badge_fullmethodcoverage.svg @@ -132,7 +132,7 @@ - 27.8%27.8% + 28.6%28.6% diff --git a/badge_linecoverage.svg b/badge_linecoverage.svg new file mode 100644 index 000000000..f2be831f2 --- /dev/null +++ b/badge_linecoverage.svg @@ -0,0 +1,138 @@ + + + Code coverage + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Generated by: ReportGenerator 5.5.1.0 + + + + Coverage + Coverage + 8.3%8.3% + + + + + + + Line coverage + + + + + \ No newline at end of file diff --git a/badge_methodcoverage.svg b/badge_methodcoverage.svg new file mode 100644 index 000000000..820772278 --- /dev/null +++ b/badge_methodcoverage.svg @@ -0,0 +1,138 @@ + + + Code coverage + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Generated by: ReportGenerator 5.5.1.0 + + + + Coverage + Coverage + + + 31.6%31.6% + + + + + + + Method coverage + + + \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core/badge_fullmethodcoverage.svg b/src/dymaptic.GeoBlazor.Core/badge_fullmethodcoverage.svg index 5ade9747c..d93f43e4d 100644 --- a/src/dymaptic.GeoBlazor.Core/badge_fullmethodcoverage.svg +++ b/src/dymaptic.GeoBlazor.Core/badge_fullmethodcoverage.svg @@ -137,8 +137,8 @@ Coverage - 27.8% - 27.8% + 28.6% + 28.6% diff --git a/src/dymaptic.GeoBlazor.Core/badge_methodcoverage.svg b/src/dymaptic.GeoBlazor.Core/badge_methodcoverage.svg index 3f7b01cc7..73fde5e8e 100644 --- a/src/dymaptic.GeoBlazor.Core/badge_methodcoverage.svg +++ b/src/dymaptic.GeoBlazor.Core/badge_methodcoverage.svg @@ -132,8 +132,8 @@ Coverage - 30.9% - 30.9% + 31.6% + 31.6% From 85b751381f98d119273092f82f18bb2b6ea854be Mon Sep 17 00:00:00 2001 From: "submodule-validation-for-geoblazor[bot]" <235551211+submodule-validation-for-geoblazor[bot]@users.noreply.github.com> Date: Sat, 17 Jan 2026 05:02:41 +0000 Subject: [PATCH 14/18] Pipeline Build Commit of Version and Docs --- Directory.Build.props | 44 +++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 82c5dc489..b55db67fb 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,26 +1,26 @@ - - - - enable - enable - 5.0.0.10 - true - Debug;Release;SourceGen Highlighting - AnyCPU - $(MSBuildThisFileDirectory)src\dymaptic.GeoBlazor.Core - - + + + + enable + enable + 5.0.0.11 + true + Debug;Release;SourceGen Highlighting + AnyCPU + $(MSBuildThisFileDirectory)src\dymaptic.GeoBlazor.Core + + $(StaticWebAssetEndpointExclusionPattern);js/** - - - - - - - - - - + + + + + + + + + + \ No newline at end of file From 1cef169c52490315e8a2c6eb513f0b0aa5f2bb21 Mon Sep 17 00:00:00 2001 From: Tim Purdum Date: Fri, 16 Jan 2026 23:16:30 -0600 Subject: [PATCH 15/18] pipeline fixes --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8825e75fc..55b675c51 100644 --- a/Dockerfile +++ b/Dockerfile @@ -106,8 +106,8 @@ ENV ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx ENV ASPNETCORE_Kestrel__Certificates__Default__Password=password # Coverage configuration (can be overridden via environment) -ENV COVERAGE_ENABLED -ENV COVERAGE_FORMAT +ENV COVERAGE_ENABLED=false +ENV COVERAGE_FORMAT=xml # Copy entrypoint script COPY ./test/dymaptic.GeoBlazor.Core.Test.Automation/docker-entrypoint.sh /docker-entrypoint.sh From f43b14f72576e6b4a91a5411fa116844dd2a039d Mon Sep 17 00:00:00 2001 From: "submodule-validation-for-geoblazor[bot]" <235551211+submodule-validation-for-geoblazor[bot]@users.noreply.github.com> Date: Sat, 17 Jan 2026 05:21:24 +0000 Subject: [PATCH 16/18] Pipeline Build Commit of Version and Docs --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index b55db67fb..c59138b2e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,7 @@ enable enable - 5.0.0.11 + 5.0.0.12 true Debug;Release;SourceGen Highlighting AnyCPU From 2ef05f9d7756997fa249d676d3d29b0fa970a320 Mon Sep 17 00:00:00 2001 From: Tim Purdum Date: Sat, 17 Jan 2026 13:59:03 -0600 Subject: [PATCH 17/18] PR feedback, xml comments, test coverage expansion, include unit tests in test automation and pipelines --- .github/workflows/dev-pr-build.yml | 2 +- .github/workflows/main-release-build.yml | 4 +- .github/workflows/tests.yml | 2 +- .gitignore | 2 + src/.editorconfig | 6 +- .../ProcessHelper.cs | 104 ++- .../ProtobufDefinitionsGenerator.cs | 194 ++-- .../SerializationGenerator.cs | 620 +++++++------ .../StringExtensions.cs | 11 + .../ArcGISVersionInfoSourceGenerator.cs | 9 +- .../ESBuildGenerator.cs | 4 + .../ProtobufSourceGenerator.cs | 4 +- .../Serialization/GeoBlazorMetaData.cs | 42 +- .../CoreSourceGeneratorTests.cs | 80 +- .../ProtobufDefinitionsGeneratorTests.cs | 724 +++++++++++++++ .../Utils/RoslynUtility.cs | 2 + ...eoBlazor.Core.SourceGenerator.Tests.csproj | 12 +- .../TestConfig.cs | 188 +++- ...ptic.GeoBlazor.Core.Test.Automation.csproj | 1 - .../PlaywrightTests.cs | 865 ------------------ .../SerializationUnitTests.cs | 135 +-- .../dymaptic.GeoBlazor.Core.Test.Unit.csproj | 4 +- 22 files changed, 1603 insertions(+), 1412 deletions(-) create mode 100644 test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/ProtobufDefinitionsGeneratorTests.cs delete mode 100644 test/dymaptic.GeoBlazor.Core.Test.Unit/PlaywrightTests.cs diff --git a/.github/workflows/dev-pr-build.yml b/.github/workflows/dev-pr-build.yml index 8fa52d5d9..727a27b42 100644 --- a/.github/workflows/dev-pr-build.yml +++ b/.github/workflows/dev-pr-build.yml @@ -59,7 +59,7 @@ jobs: HTTP_PORT: 8082 HTTPS_PORT: 9445 run: | - dotnet test --project ./test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj -c Release --filter CORE_ --output Detailed + dotnet test --project ./test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj -c Release --filter CORE_ build: runs-on: ubuntu-latest diff --git a/.github/workflows/main-release-build.yml b/.github/workflows/main-release-build.yml index 1a6ac4b0c..ab4a040ff 100644 --- a/.github/workflows/main-release-build.yml +++ b/.github/workflows/main-release-build.yml @@ -41,8 +41,10 @@ jobs: USE_CONTAINER: true ARCGIS_API_KEY: ${{ secrets.ARCGIS_TESTING_API_KEY }} GEOBLAZOR_CORE_LICENSE_KEY: ${{ secrets.GEOBLAZOR_CORE_LICENSE_KEY }} + HTTP_PORT: 8082 + HTTPS_PORT: 9445 run: | - dotnet test --project ./test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj -c Release --filter CORE_ --max-parallel-test-modules 2 /p:GeneratePackage=false /p:GenerateDocs=false + dotnet test --project ./test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj -c Release --filter CORE_ # This runs the main GeoBlazor build script - name: Build GeoBlazor diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b50edf699..d6ab46c3c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -43,4 +43,4 @@ jobs: HTTP_PORT: 8082 HTTPS_PORT: 9445 run: | - dotnet test --project ./test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj -c Release --filter CORE_ --output Detailed \ No newline at end of file + dotnet test --project ./test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj -c Release --filter CORE_ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 88ce23af4..1fd10f24f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,8 @@ CustomerTests.razor test/dymaptic.GeoBlazor.Core.Test.Automation/test.txt test/dymaptic.GeoBlazor.Core.Test.Automation/test-run.log test/dymaptic.GeoBlazor.Core.Test.Automation/coverage* +test/dymaptic.GeoBlazor.Core.Test.Automation/unit-coverage* +test/dymaptic.GeoBlazor.Core.Test.Automation/sgen-coverage* test/dymaptic.GeoBlazor.Core.Test.Automation/Summary.txt # User-specific files (MonoDevelop/Xamarin Studio) diff --git a/src/.editorconfig b/src/.editorconfig index b499c6efd..9415adabd 100644 --- a/src/.editorconfig +++ b/src/.editorconfig @@ -130,7 +130,7 @@ csharp_style_prefer_readonly_struct_member = true csharp_prefer_braces = true csharp_prefer_simple_using_statement = true csharp_prefer_system_threading_lock = true -csharp_style_namespace_declarations = block_scoped +csharp_style_namespace_declarations = file_scoped csharp_style_prefer_method_group_conversion = true csharp_style_prefer_primary_constructors = true csharp_style_prefer_simple_property_accessors = true @@ -240,10 +240,6 @@ dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case -dotnet_naming_rule.private_constants_should_be_pascal_case.severity = suggestion -dotnet_naming_rule.private_constants_should_be_pascal_case.symbols = private_constants -dotnet_naming_rule.private_constants_should_be_pascal_case.style = pascal_case - dotnet_naming_rule.private_static_readonly_should_be_camel_case.severity = suggestion dotnet_naming_rule.private_static_readonly_should_be_camel_case.symbols = private_static_readonly dotnet_naming_rule.private_static_readonly_should_be_camel_case.style = camel_case diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProcessHelper.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProcessHelper.cs index 091165cec..e96a1822f 100644 --- a/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProcessHelper.cs +++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProcessHelper.cs @@ -7,40 +7,73 @@ namespace dymaptic.GeoBlazor.Core.SourceGenerator.Shared; +/// +/// Provides helper methods for executing external processes, PowerShell scripts, and logging diagnostics. +/// public static class ProcessHelper { - public static async Task RunPowerShellScript(string processName, string workingDirectory, - string powershellScriptName, string arguments, StringBuilder logBuilder, CancellationToken token, + /// + /// Executes a PowerShell script file with the specified arguments. + /// + /// A descriptive name for the process, used in logging. + /// The working directory for the script execution. + /// The name of the PowerShell script file to execute. + /// Command-line arguments to pass to the script. + /// A StringBuilder to accumulate log output. + /// A cancellation token to cancel the operation. + /// Optional environment variables to set for the process. + public static async Task RunPowerShellScript(string processName, string workingDirectory, + string powershellScriptName, string arguments, StringBuilder logBuilder, CancellationToken token, Dictionary? environmentVariables = null) { // Since we are always providing the scripts, this is safe to call `ByPass` string shellArguments = $"-NoProfile -ExecutionPolicy ByPass -File \"{ Path.Combine(workingDirectory, powershellScriptName)}\" {arguments}"; - - await Execute(processName, workingDirectory, "pwsh", shellArguments, logBuilder, token, + + await Execute(processName, workingDirectory, "pwsh", shellArguments, logBuilder, token, environmentVariables); } - - public static async Task RunPowerShellCommand(string processName, string workingDirectory, - string arguments, StringBuilder logBuilder, CancellationToken token, + + /// + /// Executes a PowerShell command directly without a script file. + /// + /// A descriptive name for the process, used in logging. + /// The working directory for the command execution. + /// The PowerShell command to execute. + /// A StringBuilder to accumulate log output. + /// A cancellation token to cancel the operation. + /// Optional environment variables to set for the process. + public static async Task RunPowerShellCommand(string processName, string workingDirectory, + string arguments, StringBuilder logBuilder, CancellationToken token, Dictionary? environmentVariables = null) { string shellArguments = $"-NoProfile -ExecutionPolicy ByPass -Command {{ {arguments} }}"; - - await Execute(processName, workingDirectory, "pwsh", shellArguments, logBuilder, token, + + await Execute(processName, workingDirectory, "pwsh", shellArguments, logBuilder, token, environmentVariables); } + /// + /// Executes an external process with the specified arguments and captures its output. + /// + /// A descriptive name for the process, used in logging. + /// The working directory for the process execution. + /// The executable file name. If null, uses the platform-specific shell command. + /// Command-line arguments to pass to the process. + /// A StringBuilder to accumulate log output. + /// A cancellation token to cancel the operation. + /// Optional environment variables to set for the process. + /// Thrown when the process exits with a non-zero exit code. public static async Task Execute(string processName, string workingDirectory, string? fileName, - string shellArguments, StringBuilder logBuilder, CancellationToken token, + string shellArguments, StringBuilder logBuilder, CancellationToken token, Dictionary? environmentVariables = null) { fileName ??= shellCommand; - + StringBuilder outputBuilder = new(); int? processId = null; int? exitCode = null; - + token.Register(() => { logBuilder.AppendLine($"{processName}: Command execution cancelled."); @@ -53,7 +86,7 @@ public static async Task Execute(string processName, string workingDirectory, st .WithWorkingDirectory(workingDirectory) .WithValidation(CommandResultValidation.None) .WithEnvironmentVariables(environmentVariables ?? new Dictionary()); - + await foreach (CommandEvent cmdEvent in cmd.ListenAsync(cancellationToken: token)) { switch (cmdEvent) @@ -61,18 +94,26 @@ public static async Task Execute(string processName, string workingDirectory, st case StartedCommandEvent started: processId = started.ProcessId; outputBuilder.AppendLine($"{processName} Process started: {started.ProcessId}"); - outputBuilder.AppendLine($"{processName} - PID {processId}: Executing command: {fileName} {shellArguments}"); + + outputBuilder.AppendLine($"{processName} - PID {processId}: Executing command: {fileName} { + shellArguments}"); + break; case StandardOutputCommandEvent stdOut: string line = stdOut.Text.Trim(); outputBuilder.AppendLine($"{processName} - PID {processId}: [stdout] {line}"); + break; case StandardErrorCommandEvent stdErr: outputBuilder.AppendLine($"{processName} - PID {processId}: [stderr] {stdErr.Text}"); + break; case ExitedCommandEvent exited: exitCode = exited.ExitCode; - outputBuilder.AppendLine($"{processName} - PID {processId}: Process exited with code: {exited.ExitCode}"); + + outputBuilder.AppendLine($"{processName} - PID {processId}: Process exited with code: { + exited.ExitCode}"); + break; } } @@ -85,30 +126,43 @@ public static async Task Execute(string processName, string workingDirectory, st if (exitCode != 0) { - throw new ProcessException($"{processName}: Error executing command '{shellArguments}' for process {processId}. Exit code: {exitCode}"); + throw new ProcessException($"{processName}: Error executing command '{shellArguments}' for process { + processId}. Exit code: {exitCode}"); } // Return the standard output if the process completed normally - logBuilder.AppendLine($"{processName}: Command '{shellArguments}' completed successfully on process {processId}."); + logBuilder.AppendLine($"{processName}: Command '{shellArguments}' completed successfully on process {processId + }."); } - + + /// + /// Logs a diagnostic message to the source generator context. + /// + /// The title of the diagnostic message. + /// The diagnostic message content. + /// The severity level of the diagnostic. + /// The source production context to report the diagnostic to. public static void Log(string title, string message, DiagnosticSeverity severity, SourceProductionContext context) { - context.ReportDiagnostic(Diagnostic.Create(new DiagnosticDescriptor( - "GBSourceGen", + context.ReportDiagnostic(Diagnostic.Create(new DiagnosticDescriptor("GBSourceGen", title, message, "Logging", severity, isEnabledByDefault: true), Location.None)); } - - private const string LinuxShell = "/bin/bash"; - private const string WindowsShell = "cmd"; + private static readonly string shellCommand = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? WindowsShell + ? WindowsShell : LinuxShell; + + private const string LinuxShell = "/bin/bash"; + private const string WindowsShell = "cmd"; } -public class ProcessException(string message): Exception(message); \ No newline at end of file +/// +/// Exception thrown when an external process execution fails. +/// +/// The error message describing the failure. +public class ProcessException(string message) : Exception(message); \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProtobufDefinitionsGenerator.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProtobufDefinitionsGenerator.cs index 90e46c922..2f5ca8b3d 100644 --- a/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProtobufDefinitionsGenerator.cs +++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProtobufDefinitionsGenerator.cs @@ -2,6 +2,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using System.Collections.Immutable; using System.Text; +using System.Text.RegularExpressions; namespace dymaptic.GeoBlazor.Core.SourceGenerator.Shared; @@ -11,52 +12,56 @@ namespace dymaptic.GeoBlazor.Core.SourceGenerator.Shared; /// public static class ProtobufDefinitionsGenerator { - public static Dictionary? ProtoDefinitions; - - public static Dictionary UpdateProtobufDefinitions(SourceProductionContext context, + public static Dictionary UpdateProtobufDefinitions(SourceProductionContext context, ImmutableArray types, string corePath) { - ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator), - "Updating Protobuf definitions...", + ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator), + "Updating Protobuf definitions...", DiagnosticSeverity.Info, context); - + // fetch protobuf definitions - string protoTypeContent = Generate(context, types); - - string typescriptContent = $""" - export let protoTypeDefinitions: string = ` - {protoTypeContent} - `; - """; - string encoded = typescriptContent - .Replace("\"", "\\\"") - .Replace("\r\n", "\\r\\n") - .Replace("\n", "\\n"); + var protoTypeContent = Generate(context, types); + + var typescriptContent = $""" + export let protoTypeDefinitions: string = ` + {protoTypeContent} + `; + """; + + var encoded = escapeRegex.Replace(typescriptContent, match => match.Value switch + { + "\"" => "\\\"", + "\r" => "\\r", + "\n" => "\\n", + _ => match.Value + }); StringBuilder logBuilder = new(); - - string scriptPath = Path.Combine(corePath, "copyProtobuf.ps1"); - + + var scriptPath = Path.Combine(corePath, "copyProtobuf.ps1"); + // write protobuf definitions to geoblazorProto.ts // must use GetAwaiter().GetResult(), since Source Generator is not Async ProcessHelper.RunPowerShellScript("Copy Protobuf Definitions", - corePath, scriptPath, - $"-Content \"{encoded}\"", - logBuilder, context.CancellationToken).GetAwaiter().GetResult(); - + corePath, scriptPath, + $"-Content \"{encoded}\"", + logBuilder, context.CancellationToken) + .GetAwaiter() + .GetResult(); + ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator), - logBuilder.ToString(), + logBuilder.ToString(), DiagnosticSeverity.Info, context); - - ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator), - $"Protobuf definitions updated successfully.", + + ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator), + "Protobuf definitions updated successfully.", DiagnosticSeverity.Info, context); - - return ProtoDefinitions ?? []; + + return _protoDefinitions ?? []; } - + private static string Generate(SourceProductionContext context, ImmutableArray types) { @@ -68,15 +73,15 @@ private static string Generate(SourceProductionContext context, context); // Extract protobuf definitions from syntax nodes - ProtoDefinitions ??= ExtractProtobufDefinitions(types, context); - + _protoDefinitions ??= ExtractProtobufDefinitions(types, context); + ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator), - $"Extracted {ProtoDefinitions.Count} Protobuf message definitions.", + $"Extracted {_protoDefinitions.Count} Protobuf message definitions.", DiagnosticSeverity.Info, context); // Generate new proto file content - string newProtoContent = GenerateProtoFileContent(ProtoDefinitions); + var newProtoContent = GenerateProtoFileContent(_protoDefinitions); ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator), "Protobuf schema generation complete", @@ -91,31 +96,36 @@ private static string Generate(SourceProductionContext context, $"Error generating Protobuf definitions: {ex.Message}", DiagnosticSeverity.Error, context); + return string.Empty; } } - public static Dictionary ExtractProtobufDefinitions( + private static Dictionary ExtractProtobufDefinitions( ImmutableArray types, SourceProductionContext context) { var definitions = new Dictionary(); const string protoContractAttribute = "ProtoContract"; - foreach (BaseTypeDeclarationSyntax type in types) + foreach (var type in types) { if (type.AttributeLists.SelectMany(a => a.Attributes) .All(a => a.Name.ToString() != protoContractAttribute)) { if (type.Identifier.Text.EndsWith("SerializationRecord") - && type.Identifier.Text != "MapComponentSerializationRecord" - && type.Identifier.Text != "MapComponentCollectionSerializationRecord") + && (type.Identifier.Text != "MapComponentSerializationRecord") + && (type.Identifier.Text != "MapComponentCollectionSerializationRecord")) { - ProcessHelper.Log( - nameof(ProtobufDefinitionsGenerator), - $"Processing syntax node: {type.Identifier.Text}, which is a SerializationRecord without ProtoContract attribute. Attributes: {string.Join(", ", type.AttributeLists.SelectMany(al => al.Attributes.SelectMany(a => a.ToString())))}", + ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator), + $"Processing syntax node: {type.Identifier.Text + }, which is a SerializationRecord without ProtoContract attribute. Attributes: { + string.Join(", ", + type.AttributeLists.SelectMany(al => al.Attributes.SelectMany(a => a.ToString()))) + }", DiagnosticSeverity.Warning, context); } + continue; } @@ -153,7 +163,8 @@ public static Dictionary ExtractProtobufDefiniti } // Extract the Name parameter from ProtoContract attribute - string messageName = syntaxNode.Identifier.Text; + var messageName = syntaxNode.Identifier.Text; + var nameArg = protoContractAttr.ArgumentList?.Arguments .FirstOrDefault(arg => arg.NameEquals?.Name.Identifier.Text == "Name"); @@ -169,17 +180,19 @@ public static Dictionary ExtractProtobufDefiniti var protoIncludeAttrs = syntaxNode.AttributeLists .SelectMany(al => al.Attributes) .Where(a => a.Name.ToString().Contains("ProtoInclude")); - - BaseTypeSyntax? baseType = syntaxNode.BaseList?.Types.FirstOrDefault(); - bool geoBlazorTypeIsInterface = false; + + var baseType = syntaxNode.BaseList?.Types.FirstOrDefault(); + var geoBlazorTypeIsInterface = false; if (!messageName.EndsWith("Collection") && baseType is not null) { - string baseTypeName = baseType.Type.ToString(); - int innerTypeIndex = baseTypeName.IndexOf("<", StringComparison.OrdinalIgnoreCase) + 1; - string geoBlazorTypeName = baseTypeName.Substring(innerTypeIndex, baseTypeName.Length - innerTypeIndex - 1); + var baseTypeName = baseType.Type.ToString(); + var innerTypeIndex = baseTypeName.IndexOf("<", StringComparison.OrdinalIgnoreCase) + 1; + + var geoBlazorTypeName = + baseTypeName.Substring(innerTypeIndex, baseTypeName.Length - innerTypeIndex - 1); - if (geoBlazorTypeName[0] == 'I' && char.IsUpper(geoBlazorTypeName[1])) + if ((geoBlazorTypeName[0] == 'I') && char.IsUpper(geoBlazorTypeName[1])) { geoBlazorTypeIsInterface = true; } @@ -193,14 +206,10 @@ public static Dictionary ExtractProtobufDefiniti var typeArg = includeAttr.ArgumentList.Arguments[1].Expression; if (tagArg is LiteralExpressionSyntax tagLiteral && - int.TryParse(tagLiteral.Token.ValueText, out int tag)) + int.TryParse(tagLiteral.Token.ValueText, out var tag)) { - string typeName = ExtractTypeFromExpression(typeArg); - protoIncludeFields.Add(new ProtoIncludeDefinition - { - Tag = tag, - TypeName = typeName - }); + var typeName = ExtractTypeFromExpression(typeArg); + protoIncludeFields.Add(new ProtoIncludeDefinition { Tag = tag, TypeName = typeName }); } } } @@ -217,15 +226,15 @@ public static Dictionary ExtractProtobufDefiniti if (protoMemberAttr is { ArgumentList.Arguments.Count: > 0 }) { var fieldNumber = protoMemberAttr.ArgumentList.Arguments[0].Expression; + if (fieldNumber is LiteralExpressionSyntax fieldNumLiteral && - int.TryParse(fieldNumLiteral.Token.ValueText, out int num)) + int.TryParse(fieldNumLiteral.Token.ValueText, out var num)) { var fieldType = ConvertCSharpTypeToProtoType(property.Type.ToString()); + fields.Add(new ProtoFieldDefinition { - Type = fieldType, - Name = property.Identifier.Text.ToLowerFirstChar(), - Number = num + Type = fieldType, Name = property.Identifier.Text.ToLowerFirstChar(), Number = num }); } } @@ -255,12 +264,12 @@ private static string ExtractTypeFromExpression(ExpressionSyntax expression) private static string ConvertCSharpTypeToProtoType(string csharpType) { // Check if it's an array type (need repeated keyword) - bool isRepeated = (csharpType.Contains("[]") && csharpType != "byte[]" && csharpType != "byte[]?") + var isRepeated = (csharpType.Contains("[]") && (csharpType != "byte[]") && (csharpType != "byte[]?")) || csharpType.Contains("IEnumerable") || csharpType.Contains("List<"); - + // Remove nullable markers and array indicators - string cleanType = csharpType.Replace("?", "").Trim(); + var cleanType = csharpType.Replace("?", "").Trim(); if (isRepeated) { @@ -273,7 +282,7 @@ private static string ConvertCSharpTypeToProtoType(string csharpType) } // Map C# types to proto types - string protoType = cleanType switch + var protoType = cleanType switch { "string" => "string", "int" => "int32", @@ -302,7 +311,7 @@ private static string GenerateProtoFileContent(Dictionary d.Name)) { - if (def.Name == "MapComponent" || def.Name == "MapComponentCollection") + if ((def.Name == "MapComponent") || (def.Name == "MapComponentCollection")) { continue; // Handle these special cases separately } @@ -326,7 +335,7 @@ private static string GenerateProtoFileContent(Dictionary? _protoDefinitions; } +/// +/// Represents a Protobuf message definition extracted from a C# type with ProtoContract attribute. +/// public class ProtoMessageDefinition { + /// + /// The name of the Protobuf message, derived from the ProtoContract Name parameter or the type identifier. + /// public string Name { get; set; } = string.Empty; + + /// + /// The list of fields in this message, extracted from properties with ProtoMember attributes. + /// public List Fields { get; set; } = new(); + + /// + /// The list of ProtoInclude definitions for polymorphic serialization (oneof fields). + /// public List ProtoIncludes { get; set; } = new(); - + + /// + /// Indicates whether the corresponding GeoBlazor type is an interface. + /// public bool GeoBlazorTypeIsInterface { get; set; } } +/// +/// Represents a field within a Protobuf message definition. +/// public class ProtoFieldDefinition { + /// + /// The Protobuf type of the field (e.g., "string", "int32", "repeated Message"). + /// public string Type { get; set; } = string.Empty; + + /// + /// The name of the field in camelCase format. + /// public string Name { get; set; } = string.Empty; + + /// + /// The field number from the ProtoMember attribute, used for wire format encoding. + /// public int Number { get; set; } } +/// +/// Represents a ProtoInclude attribute definition for polymorphic type hierarchies. +/// public class ProtoIncludeDefinition { + /// + /// The tag number used to identify this subtype in the Protobuf oneof field. + /// public int Tag { get; set; } + + /// + /// The name of the included subtype. + /// public string TypeName { get; set; } = string.Empty; } \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/SerializationGenerator.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/SerializationGenerator.cs index ebee6f687..4a291c4ea 100644 --- a/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/SerializationGenerator.cs +++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/SerializationGenerator.cs @@ -11,9 +11,17 @@ namespace dymaptic.GeoBlazor.Core.SourceGenerator.Shared; /// public static class SerializationGenerator { + /// + /// Generates a serialization data class containing pre-analyzed serialization information for Protobuf serialization. + /// + /// The source production context for adding generated source and reporting diagnostics. + /// The collection of type declarations to analyze for serialization attributes. + /// A dictionary of Protobuf message definitions keyed by type name. + /// Whether generating for Pro (true) or Core (false) library. + /// If true, skips file generation (for testing purposes). public static void GenerateSerializationDataClass(SourceProductionContext context, - ImmutableArray types, - Dictionary protoMessageDefinitions, + ImmutableArray types, + Dictionary protoMessageDefinitions, bool isPro, bool isTest) { try @@ -22,8 +30,8 @@ public static void GenerateSerializationDataClass(SourceProductionContext contex "Generating serialized data class...", DiagnosticSeverity.Info, context); - - ImmutableArray serializedMethodsCollection = + + ImmutableArray serializedMethodsCollection = [ ..types .SelectMany(t => t.ToSerializableMethodRecords()) @@ -34,6 +42,7 @@ public static void GenerateSerializationDataClass(SourceProductionContext contex foreach (BaseTypeDeclarationSyntax type in types) { string typeName = type.Identifier.Text; + if (type.AttributeLists.SelectMany(a => a.Attributes) .Any(a => a.Name.ToString() is ProtoSerializableAttribute)) { @@ -42,22 +51,24 @@ public static void GenerateSerializationDataClass(SourceProductionContext contex protoSerializableTypes[typeName] = messageDefinition; } } - + // get the parent type string? parentTypeName = type.BaseList? .Types - .FirstOrDefault(bt => protoMessageDefinitions.ContainsKey(bt.ToString()))? + .FirstOrDefault(bt => protoMessageDefinitions.ContainsKey(bt.ToString())) + ? .ToString(); if (parentTypeName is not null) { // check parent type - BaseTypeDeclarationSyntax? parentType = types.FirstOrDefault(bt => bt.Identifier.Text == parentTypeName); + var parentType = + types.FirstOrDefault(bt => bt.Identifier.Text == parentTypeName); if (parentType?.AttributeLists.SelectMany(a => a.Attributes) .Any(a => a.Name.ToString() is ProtoSerializableAttribute) == true) { - protoSerializableTypes[typeName] = protoMessageDefinitions[parentTypeName]; + protoSerializableTypes[typeName] = protoMessageDefinitions[parentTypeName]; } } } @@ -68,7 +79,7 @@ public static void GenerateSerializationDataClass(SourceProductionContext contex // #nullable enable - + {{(isPro ? "using dymaptic.GeoBlazor.Core.Serialization;" : "")}} using System.Collections; using FieldInfo = dymaptic.GeoBlazor.Core.Components.FieldInfo; @@ -88,11 +99,10 @@ internal static class {{className}} classBuilder.AppendLine(GenerateExtensionMethods(protoSerializableTypes)); } - classBuilder.AppendLine( - GenerateSerializableMethodRecords(serializedMethodsCollection, isPro)); + classBuilder.AppendLine(GenerateSerializableMethodRecords(serializedMethodsCollection, isPro)); classBuilder.AppendLine("}"); - + ProcessHelper.Log(nameof(SerializationGenerator), $"Generated serialized data class: {className}.g.cs", DiagnosticSeverity.Info, @@ -118,246 +128,248 @@ internal static class {{className}} context); } } - + private static string GenerateExtensionMethods(Dictionary protoDefinitions) { - StringBuilder readJsProtoStreamRefMethod = new( - """ - /// - /// Convenience method to deserialize an to a specific type via protobuf. - /// - public static async Task ReadJsStreamReferenceAsProtobuf(this IJSStreamReference jsStreamReference, - Type returnType, long maxAllowedSize = 1_000_000_000) - { - await using Stream stream = await jsStreamReference.OpenReadStreamAsync(maxAllowedSize); - using MemoryStream memoryStream = new(); - await stream.CopyToAsync(memoryStream); - memoryStream.Seek(0, SeekOrigin.Begin); - - string typeName = returnType.Name.Replace("SerializationRecord", ""); - - switch (typeName) - { - - """); - - StringBuilder readJsProtoCollectionStreamRefMethod = new( - """ - /// - /// Convenience method to deserialize an to a specific coolection type via protobuf. - /// - public static async Task ReadJsStreamReferenceAsProtobufCollection(this IJSStreamReference jsStreamReference, - Type returnType, long maxAllowedSize = 1_000_000_000) - { - await using Stream stream = await jsStreamReference.OpenReadStreamAsync(maxAllowedSize); - using MemoryStream memoryStream = new(); - await stream.CopyToAsync(memoryStream); - memoryStream.Seek(0, SeekOrigin.Begin); - - string typeName = returnType.Name.Replace("SerializationRecord", ""); - - switch (typeName) - { + StringBuilder readJsProtoStreamRefMethod = new(""" + /// + /// Convenience method to deserialize an to a specific type via protobuf. + /// + public static async Task ReadJsStreamReferenceAsProtobuf(this IJSStreamReference jsStreamReference, + Type returnType, long maxAllowedSize = 1_000_000_000) + { + await using Stream stream = await jsStreamReference.OpenReadStreamAsync(maxAllowedSize); + using MemoryStream memoryStream = new(); + await stream.CopyToAsync(memoryStream); + memoryStream.Seek(0, SeekOrigin.Begin); + + string typeName = returnType.Name.Replace("SerializationRecord", ""); + + switch (typeName) + { + + """); + + StringBuilder readJsProtoCollectionStreamRefMethod = new(""" + /// + /// Convenience method to deserialize an to a specific coolection type via protobuf. + /// + public static async Task ReadJsStreamReferenceAsProtobufCollection(this IJSStreamReference jsStreamReference, + Type returnType, long maxAllowedSize = 1_000_000_000) + { + await using Stream stream = await jsStreamReference.OpenReadStreamAsync(maxAllowedSize); + using MemoryStream memoryStream = new(); + await stream.CopyToAsync(memoryStream); + memoryStream.Seek(0, SeekOrigin.Begin); + + string typeName = returnType.Name.Replace("SerializationRecord", ""); + + switch (typeName) + { + + """); + + StringBuilder protoTypeDictionary = new(""" + /// + /// A collection of types that can be serialized to Protobuf + /// + public static Dictionary ProtoContractTypes => _protoContractTypes; + + private static readonly Dictionary _protoContractTypes = new() + { + + """); + + StringBuilder protoCollectionTypeDictionary = new(""" + /// + /// A collection of types that can be serialized to Protobuf as collections of a specific Type + /// + public static Dictionary ProtoCollectionTypes => _protoCollectionTypes; + + private static readonly Dictionary _protoCollectionTypes = new() + { + + """); + + StringBuilder protoMemberGenerationMethod = new(""" + /// + /// Convenience method to generate a Protobuf serialized parameter. + /// + public static object ToProtobufParameter(this object value, Type serializableType, bool isServer) + { + MemoryStream memoryStream = new(); + switch (serializableType.Name) + { + + """); + + StringBuilder protoCollectionGenerationMethod = new(""" + /// + /// Convenience method to generate a Protobuf serialized collection parameter. + /// + public static object ToProtobufCollectionParameter(this IList items, Type serializableType, bool isServer) + { + MemoryStream memoryStream = new(); + string typeName = $"{serializableType.Name}Collection"; + switch (serializableType.Name) + { + + """); + + foreach (KeyValuePair kvp in protoDefinitions.OrderBy(kvp => kvp.Key)) + { + var protoSerializableType = kvp.Key; + var definition = kvp.Value; - """); - - StringBuilder protoTypeDictionary = new( - """ - /// - /// A collection of types that can be serialized to Protobuf - /// - public static Dictionary ProtoContractTypes => _protoContractTypes; - - private static readonly Dictionary _protoContractTypes = new() - { - - """); - - StringBuilder protoCollectionTypeDictionary = new( - """ - /// - /// A collection of types that can be serialized to Protobuf as collections of a specific Type - /// - public static Dictionary ProtoCollectionTypes => _protoCollectionTypes; - - private static readonly Dictionary _protoCollectionTypes = new() - { - - """); - - StringBuilder protoMemberGenerationMethod = new( - """ - /// - /// Convenience method to generate a Protobuf serialized parameter. - /// - public static object ToProtobufParameter(this object value, Type serializableType, bool isServer) - { - MemoryStream memoryStream = new(); - switch (serializableType.Name) - { - - """); - - StringBuilder protoCollectionGenerationMethod = new( - """ - /// - /// Convenience method to generate a Protobuf serialized collection parameter. - /// - public static object ToProtobufCollectionParameter(this IList items, Type serializableType, bool isServer) - { - MemoryStream memoryStream = new(); - string typeName = $"{serializableType.Name}Collection"; - switch (serializableType.Name) - { - - """); - - foreach (KeyValuePair kvp in protoDefinitions.OrderBy(kvp => kvp.Key)) + if (definition.Name == "MapComponent") { - string protoSerializableType = kvp.Key; - ProtoMessageDefinition definition = kvp.Value; - - if (definition.Name == "MapComponent") - { - continue; - } + continue; + } - string serializationRecordType = definition.Name + "SerializationRecord"; - string serializationCollectionRecordType = definition.Name + "CollectionSerializationRecord"; - - string variableName = protoSerializableType.ToLowerFirstChar(); - - protoTypeDictionary.AppendLine( - $$""" - { typeof({{protoSerializableType}}), typeof({{serializationRecordType}}) }, - """); - - protoCollectionTypeDictionary.AppendLine( - $$""" - { typeof({{protoSerializableType}}), typeof({{serializationCollectionRecordType}}) }, - """); - - readJsProtoStreamRefMethod.AppendLine( - $$""" - case "{{protoSerializableType}}": - {{serializationRecordType}} {{variableName}} = - Serializer.Deserialize<{{serializationRecordType}}>(memoryStream); - if ({{variableName}}.IsNull) - { - return default!; - } - return (T?)(object?){{variableName}}?.FromSerializationRecord(); - """); - - readJsProtoCollectionStreamRefMethod.AppendLine( - $$""" - case "{{protoSerializableType}}": - {{serializationCollectionRecordType}} {{variableName}} = - Serializer.Deserialize<{{serializationCollectionRecordType}}>(memoryStream); - if ({{variableName}}.IsNull) - { - return default!; - } - return (T?)(object?){{variableName}}?.Items?.Select(i => i.FromSerializationRecord()).Cast<{{protoSerializableType}}>().ToArray(); - """); - - if (definition.Name == "Attribute") - { - // Attribute is not a GeoBlazor type so skip next injection - continue; - } - - protoMemberGenerationMethod.AppendLine( - $$""" - case "{{protoSerializableType}}": - {{definition.Name}}SerializationRecord {{variableName}} = - (({{protoSerializableType}})value).ToProtobuf(); - Serializer.Serialize(memoryStream, {{variableName}}); - - break; - """); - - protoCollectionGenerationMethod.AppendLine( - $$""" - case "{{protoSerializableType}}": - {{definition.Name}}CollectionSerializationRecord {{variableName}} = - new(items.Cast<{{protoSerializableType}}>().Select(i => i.ToProtobuf()).ToArray()); - Serializer.Serialize(memoryStream, {{variableName}}); - - break; - """); + var serializationRecordType = definition.Name + "SerializationRecord"; + var serializationCollectionRecordType = definition.Name + "CollectionSerializationRecord"; + + var variableName = protoSerializableType.ToLowerFirstChar(); + + protoTypeDictionary.AppendLine($$""" + { typeof({{protoSerializableType}}), typeof({{ + serializationRecordType}}) }, + """); + + protoCollectionTypeDictionary.AppendLine($$""" + { typeof({{protoSerializableType}}), typeof({{ + serializationCollectionRecordType}}) }, + """); + + readJsProtoStreamRefMethod.AppendLine($$""" + case "{{protoSerializableType}}": + {{serializationRecordType}} {{variableName}} = + Serializer.Deserialize<{{serializationRecordType + }}>(memoryStream); + if ({{variableName}}.IsNull) + { + return default!; + } + return (T?)(object?){{variableName + }}?.FromSerializationRecord(); + """); + + readJsProtoCollectionStreamRefMethod.AppendLine($$""" + case "{{protoSerializableType}}": + {{serializationCollectionRecordType}} {{ + variableName}} = + Serializer.Deserialize<{{ + serializationCollectionRecordType + }}>(memoryStream); + if ({{variableName}}.IsNull) + { + return default!; + } + return (T?)(object?){{variableName + }} + ?.Items?.Select(i => i.FromSerializationRecord()).Cast<{{ + protoSerializableType}}>().ToArray(); + """); + + if (definition.Name == "Attribute") + { + // Attribute is not a GeoBlazor type so skip next injection + continue; } - protoTypeDictionary.AppendLine(" };"); - protoCollectionTypeDictionary.AppendLine(" };"); - - readJsProtoStreamRefMethod.AppendLine( - """ - } - - return default!; - } - """); - - readJsProtoCollectionStreamRefMethod.AppendLine( - """ - } - - return default!; - } - """); - - protoMemberGenerationMethod.AppendLine( - """ - } - - memoryStream.Seek(0, SeekOrigin.Begin); - - if (isServer) - { - return new DotNetStreamReference(memoryStream); - } - - byte[] data = memoryStream.ToArray(); - memoryStream.Dispose(); - - return data; - } - """); - - protoCollectionGenerationMethod.AppendLine( - """ - } - - memoryStream.Seek(0, SeekOrigin.Begin); - - if (isServer) - { - return new DotNetStreamReference(memoryStream); - } - - byte[] data = memoryStream.ToArray(); - memoryStream.Dispose(); - - return data; - } - """); - - return $""" - {readJsProtoStreamRefMethod} - - {readJsProtoCollectionStreamRefMethod} - - {protoMemberGenerationMethod} - - {protoCollectionGenerationMethod} - - {protoTypeDictionary} - - {protoCollectionTypeDictionary} - """; + protoMemberGenerationMethod.AppendLine($$""" + case "{{protoSerializableType}}": + {{definition.Name}}SerializationRecord {{ + variableName}} = + (({{protoSerializableType + }})value).ToProtobuf(); + Serializer.Serialize(memoryStream, {{variableName + }}); + + break; + """); + + protoCollectionGenerationMethod.AppendLine($$""" + case "{{protoSerializableType}}": + {{definition.Name + }}CollectionSerializationRecord {{variableName + }} = + new(items.Cast<{{protoSerializableType + }} + >().Select(i => i.ToProtobuf()).ToArray()); + Serializer.Serialize(memoryStream, {{ + variableName}}); + + break; + """); + } + + protoTypeDictionary.AppendLine(" };"); + protoCollectionTypeDictionary.AppendLine(" };"); + + readJsProtoStreamRefMethod.AppendLine(""" + } + + return default!; + } + """); + + readJsProtoCollectionStreamRefMethod.AppendLine(""" + } + + return default!; + } + """); + + protoMemberGenerationMethod.AppendLine(""" + } + + memoryStream.Seek(0, SeekOrigin.Begin); + + if (isServer) + { + return new DotNetStreamReference(memoryStream); + } + + byte[] data = memoryStream.ToArray(); + memoryStream.Dispose(); + + return data; + } + """); + + protoCollectionGenerationMethod.AppendLine(""" + } + + memoryStream.Seek(0, SeekOrigin.Begin); + + if (isServer) + { + return new DotNetStreamReference(memoryStream); + } + + byte[] data = memoryStream.ToArray(); + memoryStream.Dispose(); + + return data; + } + """); + + return $""" + {readJsProtoStreamRefMethod} + + {readJsProtoCollectionStreamRefMethod} + + {protoMemberGenerationMethod} + + {protoCollectionGenerationMethod} + + {protoTypeDictionary} + + {protoCollectionTypeDictionary} + """; } - + private static string GenerateSerializableMethodRecords( ImmutableArray serializedMethodsCollection, bool isPro) { @@ -365,33 +377,31 @@ private static string GenerateSerializableMethodRecords( if (isPro) { - outputBuilder = new( - """ - /// - /// A collection of serializable methods and their parameters/return types. - /// - public static Dictionary SerializableMethods => _serializableMethods - .Concat(CoreSerializationData.SerializableMethods) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - - private static readonly Dictionary _serializableMethods = new() - { - - """); + outputBuilder = new(""" + /// + /// A collection of serializable methods and their parameters/return types. + /// + public static Dictionary SerializableMethods => _serializableMethods + .Concat(CoreSerializationData.SerializableMethods) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + private static readonly Dictionary _serializableMethods = new() + { + + """); } else { - outputBuilder = new( - """ - /// - /// A collection of serializable methods and their parameters/return types. - /// - public static Dictionary SerializableMethods => _serializableMethods; - - private static readonly Dictionary _serializableMethods = new() - { + outputBuilder = new(""" + /// + /// A collection of serializable methods and their parameters/return types. + /// + public static Dictionary SerializableMethods => _serializableMethods; - """); + private static readonly Dictionary _serializableMethods = new() + { + + """); } foreach (var classGroup in serializedMethodsCollection.GroupBy(m => m.ClassName)) @@ -404,17 +414,19 @@ private static string GenerateSerializableMethodRecords( foreach (var methodRecord in classGroup) { if (methodRecord.Parameters.Values.Contains("T") - || methodRecord.Parameters.Values.Contains("T?") - || methodRecord.ReturnType == "T" - || methodRecord.ReturnType == "T?" - || methodRecord.ReturnType == "T[]" - || methodRecord.ReturnType == "T[]?" - || methodRecord.ReturnType?.Contains("") == true) + || methodRecord.Parameters.Values.Contains("T?") + || (methodRecord.ReturnType == "T") + || (methodRecord.ReturnType == "T?") + || (methodRecord.ReturnType == "T[]") + || (methodRecord.ReturnType == "T[]?") + || (methodRecord.ReturnType?.Contains("") == true)) { continue; } + outputBuilder.AppendLine($$""" - new SerializableMethodRecord("{{methodRecord.MethodName.ToLowerFirstChar()}}", + new SerializableMethodRecord("{{ + methodRecord.MethodName.ToLowerFirstChar()}}", [ """); @@ -434,11 +446,13 @@ private static string GenerateSerializableMethodRecords( int genericStart = value.IndexOf("<", StringComparison.OrdinalIgnoreCase); collectionType = value.Substring(genericStart + 1, value.Length - genericStart - 2); } - + string collectionText = collectionType is null ? "null" : $"typeof({collectionType})"; - outputBuilder.AppendLine($" new SerializableParameterRecord(typeof({value}), {isNullableText}, {collectionText}),"); + + outputBuilder.AppendLine($" new SerializableParameterRecord(typeof({value + }), {isNullableText}, {collectionText}),"); } if (methodRecord.ReturnType != null) @@ -446,8 +460,8 @@ private static string GenerateSerializableMethodRecords( string returnValue = methodRecord.ReturnType.TrimEnd('?'); bool isCollectionReturn = returnValue.EndsWith("[]") || - (returnValue.Contains("<") && returnValue.Contains(">")); - + (returnValue.Contains("<") && returnValue.Contains(">")); + string singleType = isCollectionReturn ? returnValue.Contains("<") && returnValue.Contains(">") ? $"typeof({returnValue.Substring( @@ -457,8 +471,10 @@ private static string GenerateSerializableMethodRecords( : $"typeof({returnValue.Replace("[]", "")})" : "null"; string isNullable = methodRecord.ReturnType.EndsWith("?") ? "true" : "false"; - outputBuilder.AppendLine($" ], new SerializableParameterRecord(typeof({returnValue}), {isNullable}, {singleType})),"); - } + + outputBuilder.AppendLine($" ], new SerializableParameterRecord(typeof({ + returnValue}), {isNullable}, {singleType})),"); + } else { outputBuilder.AppendLine(" ])),"); @@ -482,28 +498,28 @@ private static List ToSerializableMethodRecords(this B .SelectMany(a => a.Attributes) .Any(attr => attr.Name.ToString() == SerializedMethodAttributeName)) .ToList(); - + List methodRecords = []; - + foreach (MethodDeclarationSyntax method in methods) { string returnType = method.ReturnType.ToString(); - + if (returnType.StartsWith("Task") || returnType.StartsWith("ValueTask")) { int bracketIndex = returnType.IndexOf('<'); returnType = returnType.Substring(bracketIndex + 1, returnType.Length - bracketIndex - 2); } - SerializableMethodRecord record = new(typeSyntax.Identifier.Text, + + SerializableMethodRecord record = new(typeSyntax.Identifier.Text, method.Identifier.Text, - method.ParameterList.Parameters.ToDictionary( - p => p.Identifier.Text, + method.ParameterList.Parameters.ToDictionary(p => p.Identifier.Text, p => p.Type!.ToString()), returnType); - + methodRecords.Add(record); } - + return methodRecords; } @@ -511,11 +527,37 @@ private static List ToSerializableMethodRecords(this B private const string ProtoSerializableAttribute = "ProtobufSerializable"; } -public record SerializableMethodRecord(string ClassName, string MethodName, Dictionary Parameters, +/// +/// Represents metadata about a method marked for serialization, including its class, name, parameters, and return +/// type. +/// +/// The name of the class containing the method. +/// The name of the method. +/// A dictionary mapping parameter names to their type names. +/// The return type of the method, or null if void. +public record SerializableMethodRecord( + string ClassName, + string MethodName, + Dictionary Parameters, string ReturnType) { + /// + /// The name of the class containing the method. + /// public string ClassName { get; } = ClassName; + + /// + /// The name of the method. + /// public string MethodName { get; } = MethodName; + + /// + /// A dictionary mapping parameter names to their type names. + /// public Dictionary Parameters { get; } = Parameters; + + /// + /// The return type of the method, or null if void. + /// public string? ReturnType { get; } = ReturnType; } \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/StringExtensions.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/StringExtensions.cs index 435535321..67b002473 100644 --- a/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/StringExtensions.cs +++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/StringExtensions.cs @@ -1,7 +1,18 @@ namespace dymaptic.GeoBlazor.Core.SourceGenerator.Shared; +/// +/// Provides extension methods for string manipulation in the source generator. +/// public static class StringExtensions { + /// + /// Converts the first character of a string to lowercase. + /// + /// The input string to convert. + /// + /// The input string with its first character converted to lowercase, + /// or the original string if it is null, empty, or already starts with a lowercase character. + /// public static string ToLowerFirstChar(this string input) { if (string.IsNullOrEmpty(input) || char.IsLower(input[0])) diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator/ArcGISVersionInfoSourceGenerator.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator/ArcGISVersionInfoSourceGenerator.cs index ee6ce2419..f15f5b525 100644 --- a/src/dymaptic.GeoBlazor.Core.SourceGenerator/ArcGISVersionInfoSourceGenerator.cs +++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator/ArcGISVersionInfoSourceGenerator.cs @@ -1,15 +1,16 @@ -using System.Collections.Immutable; using Microsoft.CodeAnalysis; +using System.Collections.Immutable; namespace dymaptic.GeoBlazor.Core.SourceGenerator; /// -/// Copies the JavaScript NPM Package Versions from the package.json file into the C# source code. +/// Copies the JavaScript NPM Package Versions from the package.json file into the C# source code. /// [Generator] public class ArcGISVersionInfoSourceGenerator : IIncrementalGenerator { + /// public void Initialize(IncrementalGeneratorInitializationContext context) { IncrementalValueProvider> provider = context.AdditionalTextsProvider @@ -22,7 +23,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) private void GenerateCode(SourceProductionContext context, ImmutableArray files) { AdditionalText? file = files.FirstOrDefault(); - + // Get the text of the file. string[]? lines = file?.GetText(context.CancellationToken)?.ToString().Split('\n'); @@ -30,7 +31,7 @@ private void GenerateCode(SourceProductionContext context, ImmutableArray + /// Gets a value indicating whether an ESBuild process is currently running. + /// public static bool InProcess { get; private set; } + /// public void Initialize(IncrementalGeneratorInitializationContext context) { // Tracks all TypeScript source files in the Scripts directories of Core and Pro. diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator/ProtobufSourceGenerator.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator/ProtobufSourceGenerator.cs index 40abeecf1..3f5c9033d 100644 --- a/src/dymaptic.GeoBlazor.Core.SourceGenerator/ProtobufSourceGenerator.cs +++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator/ProtobufSourceGenerator.cs @@ -14,6 +14,7 @@ namespace dymaptic.GeoBlazor.Core.SourceGenerator; [Generator] public class ProtobufSourceGenerator : IIncrementalGenerator { + /// public void Initialize(IncrementalGeneratorInitializationContext context) { // Finds all class, struct, and record declarations marked with protobuf attributes. @@ -21,7 +22,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) context.SyntaxProvider.CreateSyntaxProvider(static (syntaxNode, _) => syntaxNode is ClassDeclarationSyntax or StructDeclarationSyntax or RecordDeclarationSyntax && (((BaseTypeDeclarationSyntax)syntaxNode).AttributeLists.SelectMany(a => a.Attributes) - .Any(a => a.Name.ToString() is ProtoContractAttribute or ProtoSerializableAttribute) + .Any(a => a.Name.ToString() is ProtoContractAttribute or ProtoSerializableAttribute) || syntaxNode.ChildNodes() .OfType() .Any(m => m.AttributeLists @@ -64,6 +65,7 @@ private void FilesChanged(SourceProductionContext context, "CoreProjectPath not set. Skipping protobuf source generation.", DiagnosticSeverity.Warning, context); + return; } diff --git a/src/dymaptic.GeoBlazor.Core/Serialization/GeoBlazorMetaData.cs b/src/dymaptic.GeoBlazor.Core/Serialization/GeoBlazorMetaData.cs index 81be3e64a..0852aabd4 100644 --- a/src/dymaptic.GeoBlazor.Core/Serialization/GeoBlazorMetaData.cs +++ b/src/dymaptic.GeoBlazor.Core/Serialization/GeoBlazorMetaData.cs @@ -5,26 +5,32 @@ namespace dymaptic.GeoBlazor.Core.Serialization; /// public static class GeoBlazorMetaData { - static GeoBlazorMetaData() + /// + /// All types from GeoBlazor.Core and GeoBlazor.Pro (if available) assemblies. + /// + public static Type[] GeoblazorTypes { - GeoblazorTypes = typeof(GeoBlazorMetaData).Assembly.GetTypes(); - - try - { - // Attempt to load GeoBlazor.Pro types if the assembly is available. - // This enables protobuf serialization to work with both Core and Pro types. - Assembly proAssembly = Assembly.Load("dymaptic.GeoBlazor.Pro"); - Type[] proTypes = proAssembly.GetTypes(); - GeoblazorTypes = GeoblazorTypes.Concat(proTypes).ToArray(); - } - catch (FileNotFoundException) + get { - // GeoBlazor.Pro assembly not available - this is expected for Core-only installations + if (field == null) + { + field = typeof(GeoBlazorMetaData).Assembly.GetTypes(); + + try + { + // Attempt to load GeoBlazor.Pro types if the assembly is available. + // This enables protobuf serialization to work with both Core and Pro types. + var proAssembly = Assembly.Load("dymaptic.GeoBlazor.Pro"); + var proTypes = proAssembly.GetTypes(); + field = field.Concat(proTypes).ToArray(); + } + catch (FileNotFoundException) + { + // GeoBlazor.Pro assembly not available - this is expected for Core-only installations + } + } + + return field; } } - - /// - /// All types from GeoBlazor.Core and GeoBlazor.Pro (if available) assemblies. - /// - public static Type[] GeoblazorTypes { get; } } \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/CoreSourceGeneratorTests.cs b/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/CoreSourceGeneratorTests.cs index f0455631f..31c35b516 100644 --- a/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/CoreSourceGeneratorTests.cs +++ b/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/CoreSourceGeneratorTests.cs @@ -1,8 +1,11 @@ +using CliWrap; +using CliWrap.EventStream; using dymaptic.GeoBlazor.Core.SourceGenerator.Tests.Utils; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Diagnostics; using ProtoBuf; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Reflection; @@ -15,15 +18,15 @@ public class CoreSourceGeneratorTests [TestMethod] public void TestCanTriggerESBuildInDebugMode() { - var corePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, + string corePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "..", "..", "..", "..", "..", "src", "dymaptic.GeoBlazor.Core"); - var generator = new ProtobufSourceGenerator(); + var generator = new ESBuildGenerator(); // get actual Scripts files var scriptsPath = Path.Combine(corePath, "Scripts"); - var additionalTexts = Directory + IEnumerable additionalTexts = Directory .GetFiles(scriptsPath, "*.ts") .Select(f => new TestAdditionalFile(f, File.ReadAllText(f))); @@ -31,9 +34,7 @@ public void TestCanTriggerESBuildInDebugMode() TestAnalyzerConfigOptionsProvider analyzerConfigOptions = new(new Dictionary { - { "build_property.CoreProjectPath", corePath }, - { "build_property.Configuration", "Debug" }, - { "build_property.PipelineBuild", "false" } + { "build_property.CoreProjectPath", corePath }, { "build_property.Configuration", "Debug" } }); // Source generators should be tested using 'GeneratorDriver'. @@ -44,33 +45,31 @@ public void TestCanTriggerESBuildInDebugMode() var compilation = CreateCompilationWithCoreSources(corePath, cSharpParseOptions); // Run generators. Don't forget to use the new compilation rather than the previous one. - driver.RunGeneratorsAndUpdateCompilation(compilation, out var newCompilation, + driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out var diagnostics); + Trace.WriteLine(string.Join(Environment.NewLine, diagnostics.Select(d => d.GetMessage()))); + Assert.IsTrue(diagnostics.Any(d => d.Id == "GBSourceGen"), "Expected a GBSourceGen diagnostic from the generator."); - // find the generated tree that contains the ESBuildRecord marker - var generatedTree = newCompilation.SyntaxTrees.FirstOrDefault(t => t.ToString().Contains("ESBuildRecord")); - Assert.IsNotNull(generatedTree, "Expected a generated syntax tree containing 'ESBuildRecord'."); - var generatedText = generatedTree!.ToString(); - - Assert.Contains("private const string Configuration = \"Debug\";", generatedText, - "Expected Configuration = \"Debug\" in generated tree."); + Assert.IsTrue(diagnostics.Any(d => d.GetMessage() + .Contains("Core ESBuild process completed successfully.")), + "Expected a Core ESBuild process completed successfully."); } [TestMethod] public void TestCanTriggerESBuildInReleaseMode() { - var corePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, + string corePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "..", "..", "..", "..", "..", "src", "dymaptic.GeoBlazor.Core"); - var generator = new ProtobufSourceGenerator(); + var generator = new ESBuildGenerator(); // get actual Scripts files var scriptsPath = Path.Combine(corePath, "Scripts"); - var additionalTexts = Directory + IEnumerable additionalTexts = Directory .GetFiles(scriptsPath, "*.ts") .Select(f => new TestAdditionalFile(f, File.ReadAllText(f))); @@ -78,7 +77,7 @@ public void TestCanTriggerESBuildInReleaseMode() TestAnalyzerConfigOptionsProvider analyzerConfigOptions = new(new Dictionary { - { "build_property.MSBuildProjectDirectory", corePath }, + { "build_property.CoreProjectPath", corePath }, { "build_property.Configuration", "Release" }, { "build_property.PipelineBuild", "false" } }); @@ -90,32 +89,31 @@ public void TestCanTriggerESBuildInReleaseMode() var compilation = CreateCompilationWithCoreSources(corePath, cSharpParseOptions); // Run generators. Don't forget to use the new compilation rather than the previous one. - driver.RunGeneratorsAndUpdateCompilation(compilation, out var newCompilation, + driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out var diagnostics); + Trace.WriteLine(string.Join(Environment.NewLine, diagnostics.Select(d => d.GetMessage()))); + Assert.IsTrue(diagnostics.Any(d => d.Id == "GBSourceGen"), "Expected a GBSourceGen diagnostic from the generator."); - var generatedTree = newCompilation.SyntaxTrees.FirstOrDefault(t => t.ToString().Contains("ESBuildRecord")); - Assert.IsNotNull(generatedTree, "Expected a generated syntax tree containing 'ESBuildRecord'."); - var generatedText = generatedTree!.ToString(); - - Assert.Contains("private const string Configuration = \"Release\";", generatedText, - "Expected Configuration = \"Release\" in generated tree."); + Assert.IsTrue(diagnostics.Any(d => d.GetMessage() + .Contains("Core ESBuild process completed successfully.")), + "Expected a Core ESBuild process completed successfully."); } [TestMethod] public void TestCanSkipBuildInPipelineMode() { - var corePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, + string corePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "..", "..", "..", "..", "..", "src", "dymaptic.GeoBlazor.Core"); - var generator = new ProtobufSourceGenerator(); + var generator = new ESBuildGenerator(); // get actual Scripts files var scriptsPath = Path.Combine(corePath, "Scripts"); - var additionalTexts = Directory + IEnumerable additionalTexts = Directory .GetFiles(scriptsPath, "*.ts") .Select(f => new TestAdditionalFile(f, File.ReadAllText(f))); @@ -123,7 +121,7 @@ public void TestCanSkipBuildInPipelineMode() TestAnalyzerConfigOptionsProvider analyzerConfigOptions = new(new Dictionary { - { "build_property.MSBuildProjectDirectory", corePath }, + { "build_property.CoreProjectPath", corePath }, { "build_property.Configuration", "Release" }, { "build_property.PipelineBuild", "true" } }); @@ -136,15 +134,18 @@ public void TestCanSkipBuildInPipelineMode() var compilation = CreateCompilationWithCoreSources(corePath, cSharpParseOptions); // Run generators. Don't forget to use the new compilation rather than the previous one. - driver.RunGeneratorsAndUpdateCompilation(compilation, out var newCompilation, + driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out var diagnostics); + Trace.WriteLine(string.Join(Environment.NewLine, diagnostics.Select(d => d.GetMessage()))); + Assert.IsTrue(diagnostics.Any(d => d.Id == "GBSourceGen"), "Expected a GBSourceGen diagnostic from the generator."); - // When running in pipeline mode the generator should not produce the ESBuildRecord generated tree - Assert.IsFalse(newCompilation.SyntaxTrees.Any(t => t.ToString().Contains("ESBuildRecord")), - "Did not expect an ESBuildRecord generated tree when PipelineBuild is true."); + // When running in pipeline mode the generator should not run the esbuild processes + Assert.IsFalse(diagnostics.Any(d => d.GetMessage() + .Contains("Core ESBuild process completed successfully.")), + "ESBuild ran when it should not have in PipelineBuild"); } // Helper: create a compilation that includes the dymaptic.GeoBlazor.Core source files @@ -152,19 +153,24 @@ private static CSharpCompilation CreateCompilationWithCoreSources(string corePat { // gather all .cs files from the core project var csFiles = Directory.GetFiles(corePath, "*.cs", SearchOption.AllDirectories); - var trees = csFiles.Select(f => CSharpSyntaxTree.ParseText(File.ReadAllText(f), parseOptions, f)).ToList(); + + var trees = csFiles.Select(f => CSharpSyntaxTree.ParseText(File.ReadAllText(f), parseOptions, f)) + .ToList(); // minimal set of references for compilation - var referencePaths = new[] + IEnumerable referencePaths = new[] { typeof(object).Assembly.Location, typeof(Enumerable).Assembly.Location, typeof(Attribute).Assembly.Location, typeof(Console).Assembly.Location, - typeof(ProtoContractAttribute).Assembly.Location + typeof(ProtoContractAttribute).Assembly.Location, typeof(Cli).Assembly.Location, + typeof(EventStreamCommandExtensions).Assembly.Location } .Where(p => !string.IsNullOrEmpty(p)) .Distinct(); - var references = referencePaths.Select(p => MetadataReference.CreateFromFile(p)).ToList(); + List references = referencePaths + .Select(p => MetadataReference.CreateFromFile(p)) + .ToList(); return CSharpCompilation.Create(nameof(CoreSourceGeneratorTests), trees, references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); diff --git a/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/ProtobufDefinitionsGeneratorTests.cs b/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/ProtobufDefinitionsGeneratorTests.cs new file mode 100644 index 000000000..cdc0e27b5 --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/ProtobufDefinitionsGeneratorTests.cs @@ -0,0 +1,724 @@ +using dymaptic.GeoBlazor.Core.SourceGenerator.Shared; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Reflection; + + +namespace dymaptic.GeoBlazor.Core.SourceGenerator.Tests; + +/// +/// Tests for the ProtobufDefinitionsGenerator class which extracts and generates +/// Protobuf schema definitions from C# types marked with ProtoContract attributes. +/// +/// +/// These tests use reflection to test the private extraction methods directly, +/// bypassing the SourceProductionContext dependency which cannot be properly +/// initialized outside the Roslyn infrastructure. +/// +[TestClass] +public class ProtobufDefinitionsGeneratorTests +{ + /// + /// Tests that a simple type with ProtoContract and ProtoMember attributes + /// is correctly extracted into a ProtoMessageDefinition. + /// + [TestMethod] + public void ExtractMessageDefinition_ExtractsSimpleProtoMessage() + { + // Arrange + var source = """ + using ProtoBuf; + + namespace Test; + + [ProtoContract(Name = "SimpleMessage")] + public record SimpleSerializationRecord + { + [ProtoMember(1)] + public string? Name { get; init; } + + [ProtoMember(2)] + public int Value { get; init; } + } + """; + + var type = ParseSingleTypeFromSource(source, "SimpleSerializationRecord"); + + // Act + var messageDef = ExtractMessageDefinition(type); + + // Assert + Assert.IsNotNull(messageDef, "Expected a message definition to be extracted."); + Assert.AreEqual("SimpleMessage", messageDef.Name); + Assert.HasCount(2, messageDef.Fields); + + var nameField = messageDef.Fields.First(f => f.Name == "name"); + Assert.AreEqual("string", nameField.Type); + Assert.AreEqual(1, nameField.Number); + + var valueField = messageDef.Fields.First(f => f.Name == "value"); + Assert.AreEqual("int32", valueField.Type); + Assert.AreEqual(2, valueField.Number); + } + + /// + /// Tests that types without ProtoContract attribute return null. + /// + [TestMethod] + public void ExtractMessageDefinition_ReturnsNullForTypesWithoutProtoContract() + { + // Arrange + var source = """ + namespace Test; + + public record NonProtoRecord + { + public string? Name { get; init; } + } + """; + + var type = ParseSingleTypeFromSource(source, "NonProtoRecord"); + + // Act + var messageDef = ExtractMessageDefinition(type); + + // Assert + Assert.IsNull(messageDef, + "Expected null for types without ProtoContract attribute."); + } + + /// + /// Tests that various C# types are correctly converted to Protobuf types. + /// + [TestMethod] + public void ConvertCSharpTypeToProtoType_ConvertsBasicTypesCorrectly() + { + // Assert basic type conversions + Assert.AreEqual("string", ConvertCSharpTypeToProtoType("string")); + Assert.AreEqual("string", ConvertCSharpTypeToProtoType("string?")); + Assert.AreEqual("int32", ConvertCSharpTypeToProtoType("int")); + Assert.AreEqual("int32", ConvertCSharpTypeToProtoType("int?")); + Assert.AreEqual("int64", ConvertCSharpTypeToProtoType("long")); + Assert.AreEqual("int64", ConvertCSharpTypeToProtoType("long?")); + Assert.AreEqual("double", ConvertCSharpTypeToProtoType("double")); + Assert.AreEqual("double", ConvertCSharpTypeToProtoType("double?")); + Assert.AreEqual("float", ConvertCSharpTypeToProtoType("float")); + Assert.AreEqual("float", ConvertCSharpTypeToProtoType("float?")); + Assert.AreEqual("bool", ConvertCSharpTypeToProtoType("bool")); + Assert.AreEqual("bool", ConvertCSharpTypeToProtoType("bool?")); + Assert.AreEqual("bytes", ConvertCSharpTypeToProtoType("byte[]")); + Assert.AreEqual("bytes", ConvertCSharpTypeToProtoType("byte[]?")); + } + + /// + /// Tests that collection types are converted to repeated fields. + /// + [TestMethod] + public void ConvertCSharpTypeToProtoType_ConvertsCollectionsToRepeated() + { + // Assert collection type conversions + Assert.AreEqual("repeated string", ConvertCSharpTypeToProtoType("string[]")); + Assert.AreEqual("repeated int32", ConvertCSharpTypeToProtoType("int[]")); + Assert.AreEqual("repeated string", ConvertCSharpTypeToProtoType("List")); + Assert.AreEqual("repeated int32", ConvertCSharpTypeToProtoType("List")); + Assert.AreEqual("repeated string", ConvertCSharpTypeToProtoType("IEnumerable")); + Assert.AreEqual("repeated int32", ConvertCSharpTypeToProtoType("IList")); + } + + /// + /// Tests that SerializationRecord suffix is removed from type references. + /// + [TestMethod] + public void ConvertCSharpTypeToProtoType_RemovesSerializationRecordSuffix() + { + // Assert SerializationRecord suffix handling + Assert.AreEqual("Child", ConvertCSharpTypeToProtoType("ChildSerializationRecord")); + Assert.AreEqual("repeated Child", ConvertCSharpTypeToProtoType("ChildSerializationRecord[]")); + Assert.AreEqual("repeated Child", ConvertCSharpTypeToProtoType("List")); + } + + /// + /// Tests that ProtoInclude attributes are correctly extracted for polymorphic types. + /// + [TestMethod] + public void ExtractMessageDefinition_ExtractsProtoIncludes() + { + // Arrange + var source = """ + using ProtoBuf; + + namespace Test; + + [ProtoContract(Name = "BaseType")] + [ProtoInclude(100, typeof(DerivedOneSerializationRecord))] + [ProtoInclude(101, typeof(DerivedTwoSerializationRecord))] + public record BaseTypeSerializationRecord + { + [ProtoMember(1)] + public string? CommonField { get; init; } + } + + [ProtoContract(Name = "DerivedOne")] + public record DerivedOneSerializationRecord : BaseTypeSerializationRecord + { + [ProtoMember(2)] + public int SpecificField { get; init; } + } + + [ProtoContract(Name = "DerivedTwo")] + public record DerivedTwoSerializationRecord : BaseTypeSerializationRecord + { + [ProtoMember(3)] + public double OtherField { get; init; } + } + """; + + var type = ParseSingleTypeFromSource(source, "BaseTypeSerializationRecord"); + + // Act + var messageDef = ExtractMessageDefinition(type); + + // Assert + Assert.IsNotNull(messageDef); + Assert.AreEqual("BaseType", messageDef.Name); + + Assert.HasCount(2, messageDef.ProtoIncludes, + "Expected 2 ProtoInclude entries."); + + var include100 = messageDef.ProtoIncludes.First(p => p.Tag == 100); + Assert.AreEqual("DerivedOneSerializationRecord", include100.TypeName); + + var include101 = messageDef.ProtoIncludes.First(p => p.Tag == 101); + Assert.AreEqual("DerivedTwoSerializationRecord", include101.TypeName); + } + + /// + /// Tests that the message name is extracted from the ProtoContract Name parameter. + /// + [TestMethod] + public void ExtractMessageDefinition_UsesProtoContractNameParameter() + { + // Arrange + var source = """ + using ProtoBuf; + + namespace Test; + + [ProtoContract(Name = "CustomMessageName")] + public record SomeSerializationRecord + { + [ProtoMember(1)] + public string? Field { get; init; } + } + """; + + var type = ParseSingleTypeFromSource(source, "SomeSerializationRecord"); + + // Act + var messageDef = ExtractMessageDefinition(type); + + // Assert + Assert.IsNotNull(messageDef); + + Assert.AreEqual("CustomMessageName", messageDef.Name, + "Expected message to use name from ProtoContract attribute."); + } + + /// + /// Tests that fields are ordered by their ProtoMember field number. + /// + [TestMethod] + public void ExtractMessageDefinition_OrdersFieldsByNumber() + { + // Arrange + var source = """ + using ProtoBuf; + + namespace Test; + + [ProtoContract(Name = "OrderedFields")] + public record OrderedFieldsSerializationRecord + { + [ProtoMember(3)] + public string? Third { get; init; } + + [ProtoMember(1)] + public string? First { get; init; } + + [ProtoMember(2)] + public string? Second { get; init; } + } + """; + + var type = ParseSingleTypeFromSource(source, "OrderedFieldsSerializationRecord"); + + // Act + var messageDef = ExtractMessageDefinition(type); + + // Assert + Assert.IsNotNull(messageDef); + Assert.HasCount(3, messageDef.Fields); + Assert.AreEqual(1, messageDef.Fields[0].Number); + Assert.AreEqual("first", messageDef.Fields[0].Name); + Assert.AreEqual(2, messageDef.Fields[1].Number); + Assert.AreEqual("second", messageDef.Fields[1].Name); + Assert.AreEqual(3, messageDef.Fields[2].Number); + Assert.AreEqual("third", messageDef.Fields[2].Name); + } + + /// + /// Tests that properties without ProtoMember attribute are skipped. + /// + [TestMethod] + public void ExtractMessageDefinition_SkipsPropertiesWithoutProtoMember() + { + // Arrange + var source = """ + using ProtoBuf; + + namespace Test; + + [ProtoContract(Name = "PartialMessage")] + public record PartialSerializationRecord + { + [ProtoMember(1)] + public string? IncludedField { get; init; } + + public string? ExcludedField { get; init; } + } + """; + + var type = ParseSingleTypeFromSource(source, "PartialSerializationRecord"); + + // Act + var messageDef = ExtractMessageDefinition(type); + + // Assert + Assert.IsNotNull(messageDef); + + Assert.HasCount(1, messageDef.Fields, + "Expected only the field with ProtoMember attribute to be included."); + Assert.AreEqual("includedField", messageDef.Fields[0].Name); + } + + /// + /// Tests that nullable types are handled correctly (nullable marker removed). + /// + [TestMethod] + public void ExtractMessageDefinition_HandlesNullableTypes() + { + // Arrange + var source = """ + using ProtoBuf; + + namespace Test; + + [ProtoContract(Name = "NullableTypes")] + public record NullableTypesSerializationRecord + { + [ProtoMember(1)] + public string? NullableString { get; init; } + + [ProtoMember(2)] + public int? NullableInt { get; init; } + + [ProtoMember(3)] + public double? NullableDouble { get; init; } + } + """; + + var type = ParseSingleTypeFromSource(source, "NullableTypesSerializationRecord"); + + // Act + var messageDef = ExtractMessageDefinition(type); + + // Assert + Assert.IsNotNull(messageDef); + + // Nullable markers should be stripped from the protobuf type + AssertFieldType(messageDef, "nullableString", "string"); + AssertFieldType(messageDef, "nullableInt", "int32"); + AssertFieldType(messageDef, "nullableDouble", "double"); + } + + /// + /// Tests that SerializationRecord suffix is handled correctly for nested types. + /// + [TestMethod] + public void ExtractMessageDefinition_HandlesSerializationRecordReferences() + { + // Arrange + var source = """ + using ProtoBuf; + + namespace Test; + + [ProtoContract(Name = "Parent")] + public record ParentSerializationRecord + { + [ProtoMember(1)] + public ChildSerializationRecord? Child { get; init; } + } + + [ProtoContract(Name = "Child")] + public record ChildSerializationRecord + { + [ProtoMember(1)] + public string? Name { get; init; } + } + """; + + var type = ParseSingleTypeFromSource(source, "ParentSerializationRecord"); + + // Act + var messageDef = ExtractMessageDefinition(type); + + // Assert + Assert.IsNotNull(messageDef); + + // The type should have SerializationRecord suffix removed + var childField = messageDef.Fields.First(f => f.Name == "child"); + + Assert.AreEqual("Child", childField.Type, + "Expected 'SerializationRecord' suffix to be removed from the type reference."); + } + + /// + /// Tests that IEnumerable collections are converted to repeated fields. + /// + [TestMethod] + public void ExtractMessageDefinition_HandlesIEnumerableAsRepeated() + { + // Arrange + var source = """ + using ProtoBuf; + using System.Collections.Generic; + + namespace Test; + + [ProtoContract(Name = "EnumerableTest")] + public record EnumerableSerializationRecord + { + [ProtoMember(1)] + public IEnumerable? StringItems { get; init; } + + [ProtoMember(2)] + public IList? IntItems { get; init; } + } + """; + + var type = ParseSingleTypeFromSource(source, "EnumerableSerializationRecord"); + + // Act + var messageDef = ExtractMessageDefinition(type); + + // Assert + Assert.IsNotNull(messageDef); + AssertFieldType(messageDef, "stringItems", "repeated string"); + AssertFieldType(messageDef, "intItems", "repeated int32"); + } + + /// + /// Tests that the generator handles interface base types correctly. + /// + [TestMethod] + public void ExtractMessageDefinition_DetectsInterfaceBaseType() + { + // Arrange + var source = """ + using ProtoBuf; + + namespace Test; + + public interface ITestInterface { } + + [ProtoContract(Name = "InterfaceImpl")] + public record InterfaceImplSerializationRecord : MapComponentSerializationRecord + { + [ProtoMember(1)] + public string? Field { get; init; } + } + + public abstract record MapComponentSerializationRecord { } + """; + + var type = ParseSingleTypeFromSource(source, "InterfaceImplSerializationRecord"); + + // Act + var messageDef = ExtractMessageDefinition(type); + + // Assert + Assert.IsNotNull(messageDef); + + Assert.IsTrue(messageDef.GeoBlazorTypeIsInterface, + "Expected GeoBlazorTypeIsInterface to be true for types implementing interface-based generics."); + } + + /// + /// Tests that the generator falls back to type identifier when no Name parameter is specified. + /// + [TestMethod] + public void ExtractMessageDefinition_FallsBackToTypeIdentifier() + { + // Arrange + var source = """ + using ProtoBuf; + + namespace Test; + + [ProtoContract] + public record FallbackSerializationRecord + { + [ProtoMember(1)] + public string? Field { get; init; } + } + """; + + var type = ParseSingleTypeFromSource(source, "FallbackSerializationRecord"); + + // Act + var messageDef = ExtractMessageDefinition(type); + + // Assert + Assert.IsNotNull(messageDef); + + Assert.AreEqual("FallbackSerializationRecord", messageDef.Name, + "Expected type identifier to be used as message name when Name parameter is not specified."); + } + + /// + /// Tests that empty types with no fields are still processed. + /// + [TestMethod] + public void ExtractMessageDefinition_HandlesEmptyTypes() + { + // Arrange + var source = """ + using ProtoBuf; + + namespace Test; + + [ProtoContract(Name = "EmptyMessage")] + public record EmptySerializationRecord + { + } + """; + + var type = ParseSingleTypeFromSource(source, "EmptySerializationRecord"); + + // Act + var messageDef = ExtractMessageDefinition(type); + + // Assert + Assert.IsNotNull(messageDef); + Assert.IsEmpty(messageDef.Fields, "Expected no fields for empty message."); + } + + /// + /// Tests that ProtoIncludes are ordered by tag number. + /// + [TestMethod] + public void ExtractMessageDefinition_OrdersProtoIncludesByTag() + { + // Arrange + var source = """ + using ProtoBuf; + + namespace Test; + + [ProtoContract(Name = "OrderedIncludes")] + [ProtoInclude(300, typeof(ThirdSerializationRecord))] + [ProtoInclude(100, typeof(FirstSerializationRecord))] + [ProtoInclude(200, typeof(SecondSerializationRecord))] + public record OrderedIncludesSerializationRecord + { + } + + [ProtoContract(Name = "First")] + public record FirstSerializationRecord { } + + [ProtoContract(Name = "Second")] + public record SecondSerializationRecord { } + + [ProtoContract(Name = "Third")] + public record ThirdSerializationRecord { } + """; + + var type = ParseSingleTypeFromSource(source, "OrderedIncludesSerializationRecord"); + + // Act + var messageDef = ExtractMessageDefinition(type); + + // Assert + Assert.IsNotNull(messageDef); + Assert.HasCount(3, messageDef.ProtoIncludes); + Assert.AreEqual(100, messageDef.ProtoIncludes[0].Tag); + Assert.AreEqual(200, messageDef.ProtoIncludes[1].Tag); + Assert.AreEqual(300, messageDef.ProtoIncludes[2].Tag); + } + + /// + /// Tests that GenerateProtoFileContent generates valid proto3 syntax. + /// + [TestMethod] + public void GenerateProtoFileContent_GeneratesValidProto3Header() + { + // Arrange + var definitions = new Dictionary + { + ["TestMessage"] = new() + { + Name = "TestMessage", + Fields = [new ProtoFieldDefinition { Name = "field", Type = "string", Number = 1 }] + } + }; + + // Act + var content = GenerateProtoFileContent(definitions); + + // Assert + Assert.Contains("syntax = \"proto3\";", content, + "Expected proto3 syntax declaration."); + + Assert.Contains("package dymaptic.GeoBlazor.Core.Serialization;", content, + "Expected package declaration."); + + Assert.Contains("import \"google/protobuf/empty.proto\";", content, + "Expected empty.proto import."); + } + + /// + /// Tests that GenerateProtoFileContent generates message definitions correctly. + /// + [TestMethod] + public void GenerateProtoFileContent_GeneratesMessageDefinitions() + { + // Arrange + var definitions = new Dictionary + { + ["SimpleMessage"] = new() + { + Name = "SimpleMessage", + Fields = + [ + new ProtoFieldDefinition { Name = "name", Type = "string", Number = 1 }, + new ProtoFieldDefinition { Name = "value", Type = "int32", Number = 2 } + ] + } + }; + + // Act + var content = GenerateProtoFileContent(definitions); + + // Assert + Assert.Contains("message SimpleMessage {", content, + "Expected message declaration."); + + Assert.Contains("string name = 1;", content, + "Expected name field declaration."); + + Assert.Contains("int32 value = 2;", content, + "Expected value field declaration."); + } + + // Reflection-based access to private methods + private static readonly MethodInfo extractMessageDefinitionMethod = + typeof(ProtobufDefinitionsGenerator).GetMethod("ExtractMessageDefinition", + BindingFlags.NonPublic | BindingFlags.Static)!; + + private static readonly MethodInfo convertCSharpTypeToProtoTypeMethod = + typeof(ProtobufDefinitionsGenerator).GetMethod("ConvertCSharpTypeToProtoType", + BindingFlags.NonPublic | BindingFlags.Static)!; + + private static readonly MethodInfo generateProtoFileContentMethod = + typeof(ProtobufDefinitionsGenerator).GetMethod("GenerateProtoFileContent", + BindingFlags.NonPublic | BindingFlags.Static)!; + + +#region Helper Methods + + /// + /// Parses C# source code and extracts a specific type declaration by name. + /// + private static BaseTypeDeclarationSyntax ParseSingleTypeFromSource(string source, string typeName) + { + var sourceWithProtobuf = PROTOBUF_ATTRIBUTE_STUB + source; + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest); + var tree = CSharpSyntaxTree.ParseText(sourceWithProtobuf, parseOptions); + var root = tree.GetCompilationUnitRoot(); + + var type = root.DescendantNodes() + .OfType() + .FirstOrDefault(t => t.Identifier.Text == typeName); + + return type ?? throw new InvalidOperationException($"Type '{typeName}' not found in source."); + } + + /// + /// Invokes the private ExtractMessageDefinition method via reflection. + /// + private static ProtoMessageDefinition? ExtractMessageDefinition(BaseTypeDeclarationSyntax type) + { + return (ProtoMessageDefinition?)extractMessageDefinitionMethod.Invoke(null, [type]); + } + + /// + /// Invokes the private ConvertCSharpTypeToProtoType method via reflection. + /// + private static string ConvertCSharpTypeToProtoType(string csharpType) + { + return (string)convertCSharpTypeToProtoTypeMethod.Invoke(null, [csharpType])!; + } + + /// + /// Invokes the private GenerateProtoFileContent method via reflection. + /// + private static string GenerateProtoFileContent(Dictionary definitions) + { + return (string)generateProtoFileContentMethod.Invoke(null, [definitions])!; + } + + /// + /// Asserts that a field with the given name has the expected Protobuf type. + /// + private static void AssertFieldType(ProtoMessageDefinition messageDef, string fieldName, string expectedType) + { + var field = messageDef.Fields.FirstOrDefault(f => f.Name == fieldName); + Assert.IsNotNull(field, $"Expected field '{fieldName}' to exist in message '{messageDef.Name}'."); + + Assert.AreEqual(expectedType, field.Type, + $"Expected field '{fieldName}' to have type '{expectedType}' but was '{field.Type}'."); + } + + /// + /// Stub definitions for ProtoBuf attributes to enable parsing without full ProtoBuf reference. + /// + private const string PROTOBUF_ATTRIBUTE_STUB = """ + namespace ProtoBuf + { + [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Struct)] + public sealed class ProtoContractAttribute : System.Attribute + { + public string? Name { get; set; } + } + + [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Field)] + public sealed class ProtoMemberAttribute : System.Attribute + { + public ProtoMemberAttribute(int tag) { Tag = tag; } + public int Tag { get; } + } + + [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Struct, AllowMultiple = true)] + public sealed class ProtoIncludeAttribute : System.Attribute + { + public ProtoIncludeAttribute(int tag, System.Type knownType) { Tag = tag; KnownType = knownType; } + public int Tag { get; } + public System.Type KnownType { get; } + } + } + + """; + +#endregion +} \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/Utils/RoslynUtility.cs b/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/Utils/RoslynUtility.cs index 3ed81e2aa..943f8b7b4 100644 --- a/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/Utils/RoslynUtility.cs +++ b/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/Utils/RoslynUtility.cs @@ -1,5 +1,7 @@ +using Microsoft.AspNetCore.Components; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.Extensions.DependencyInjection; using System.Reflection; diff --git a/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/dymaptic.GeoBlazor.Core.SourceGenerator.Tests.csproj b/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/dymaptic.GeoBlazor.Core.SourceGenerator.Tests.csproj index d1b8ce60a..54a30f3c5 100644 --- a/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/dymaptic.GeoBlazor.Core.SourceGenerator.Tests.csproj +++ b/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/dymaptic.GeoBlazor.Core.SourceGenerator.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -8,15 +8,21 @@ dymaptic.GeoBlazor.Core.SourceGenerator.Tests true + + + true + Exe + + + + - - runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/TestConfig.cs b/test/dymaptic.GeoBlazor.Core.Test.Automation/TestConfig.cs index d670a8650..94d9aa22b 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Automation/TestConfig.cs +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/TestConfig.cs @@ -50,6 +50,12 @@ public class TestConfig private static string CoverageFolderPath => Path.Combine(_projectFolder, "coverage"); private static string CoverageFilePath => Path.Combine(CoverageFolderPath, $"coverage.{_coverageFileVersion}.{_coverageFormat}"); + private static string UnitCoverageFolderPath => Path.Combine(_projectFolder, "unit-coverage"); + private static string UnitCoverageFilePath => + Path.Combine(UnitCoverageFolderPath, $"coverage.{_coverageFileVersion}.{_coverageFormat}"); + private static string SgenCoverageFolderPath => Path.Combine(_projectFolder, "sgen-coverage"); + private static string SgenCoverageFilePath => + Path.Combine(SgenCoverageFolderPath, $"coverage.{_coverageFileVersion}.{_coverageFormat}"); private static string CoreRepoRoot => Path.GetFullPath(Path.Combine(_projectFolder, "..", "..")); private static string ProRepoRoot => Path.GetFullPath(Path.Combine(_projectFolder, "..", "..", "..")); private static string CoreProjectPath => @@ -67,25 +73,31 @@ public static async Task AssemblyInitialize(TestContext testContext) SetupConfiguration(); // kill old running test apps and containers - await StopContainer(CoreComposeFilePath); - await StopContainer(ProComposeFilePath); - await StopTestApp(); + Task[] cleanupTasks = + [ + StopContainer(CoreComposeFilePath), + StopContainer(ProComposeFilePath), + StopTestApp() + ]; - if (_cover) - { - await InstallCodeCoverageTools(); - } + await Task.WhenAll(cleanupTasks); - await EnsurePlaywrightBrowsersAreInstalled(); + Task[] setupTasks = + [ + InstallCodeCoverageTools(), + EnsurePlaywrightBrowsersAreInstalled() + ]; - if (_useContainer) - { - await StartContainer(); - } - else - { - await StartTestApp(); - } + await Task.WhenAll(setupTasks); + + Task[] runTasks = + [ + RunUnitTests(), + RunSourceGeneratorTests(), + LaunchWebTests() + ]; + + await Task.WhenAll(runTasks); } [AssemblyCleanup] @@ -231,8 +243,123 @@ private static void SetupConfiguration() _targetFramework ??= _configuration.GetValue("TARGET_FRAMEWORK", "net10.0"); } + private static async Task RunUnitTests() + { + var unitTestPath = Path.Combine(_projectFolder, "..", + "dymaptic.GeoBlazor.Core.Test.Unit", + "dymaptic.GeoBlazor.Core.Test.Unit.csproj"); + var cmdLineApp = "dotnet"; + + string[] args = + [ + "test", + "--project", + unitTestPath, + "-c", + "Release", + "--output", + "Detailed" + ]; + + if (_cover) + { + Directory.CreateDirectory(UnitCoverageFolderPath); + cmdLineApp = "dotnet-coverage"; + var dotnetCommand = $"dotnet {string.Join(" ", args)}"; + + args = + [ + "collect", + "-o", UnitCoverageFilePath, + "-f", _coverageFormat, + "--include-files", "**/dymaptic.GeoBlazor.Core.dll", + "--include-files", "**/dymaptic.GeoBlazor.Pro.dll", + dotnetCommand + ]; + } + + var result = await Cli.Wrap(cmdLineApp) + .WithArguments(args) + .WithStandardOutputPipe(PipeTarget.ToDelegate(output => Trace.WriteLine(output, "UNIT_TEST"))) + .WithStandardErrorPipe(PipeTarget.ToDelegate(output => Trace.WriteLine(output, "UNIT_TEST_ERROR"))) + .ExecuteAsync(); + + if (result.ExitCode != 0) + { + throw new ProcessExitedException($"Unit test process exited with code {result.ExitCode}"); + } + } + + private static async Task RunSourceGeneratorTests() + { + var sgenFilePath = Path.Combine(_projectFolder, "..", + "dymaptic.GeoBlazor.Core.SourceGenerator.Tests", + "dymaptic.GeoBlazor.Core.SourceGenerator.Tests.csproj"); + var cmdLineApp = "dotnet"; + + string[] args = + [ + "test", + "--project", + sgenFilePath, + "-c", + "Release", + "--output", + "Detailed" + ]; + + if (_cover) + { + Directory.CreateDirectory(SgenCoverageFolderPath); + cmdLineApp = "dotnet-coverage"; + var dotnetCommand = $"dotnet {string.Join(" ", args)}"; + + args = + [ + "collect", + "-o", SgenCoverageFilePath, + "-f", _coverageFormat, + "--include-files", "**/dymaptic.GeoBlazor.Core.dll", + "--include-files", "**/dymaptic.GeoBlazor.Pro.dll", + "--include-files", "**/dymaptic.GeoBlazor.Core.SourceGenerator.dll", + "--include-files", "**/dymaptic.GeoBlazor.Pro.SourceGenerator.dll", + "--include-files", "**/dymaptic.GeoBlazor.Core.SourceGenerator.Shared.dll", + "--include-files", "**/dymaptic.GeoBlazor.Core.Analyzers.dll", + dotnetCommand + ]; + } + + var result = await Cli.Wrap(cmdLineApp) + .WithArguments(args) + .WithStandardOutputPipe(PipeTarget.ToDelegate(output => Trace.WriteLine(output, "SGEN_TEST"))) + .WithStandardErrorPipe(PipeTarget.ToDelegate(output => Trace.WriteLine(output, "SGEN_TEST_ERROR"))) + .ExecuteAsync(); + + if (result.ExitCode != 0) + { + throw new ProcessExitedException($"Source Generator test process exited with code {result.ExitCode}"); + } + } + + private static async Task LaunchWebTests() + { + if (_useContainer) + { + await StartContainer(); + } + else + { + await StartTestApp(); + } + } + private static async Task InstallCodeCoverageTools() { + if (!_cover) + { + return; + } + await Cli.Wrap("dotnet") .WithArguments([ "tool", @@ -331,12 +458,6 @@ private static async Task StartContainer() .ExecuteAsync(cts.Token, gracefulCts.Token); _testProcessId = commandTask.ProcessId; - var result = await commandTask; - - if (result.ExitCode != 0) - { - throw new ProcessExitedException($"Container failed to start: {result.ExitCode}"); - } await WaitForHttpResponse(); } @@ -347,7 +468,7 @@ private static async Task StartTestApp() string[] args = [ - "run", "--project", $"\"{TestAppPath}\"", + "run", "--project", TestAppPath, "--urls", $"{TestAppUrl};{TestAppHttpUrl}", "--", "-c", "Release", "/p:GenerateXmlComments=false", @@ -407,7 +528,6 @@ await Cli.Wrap("docker") .WithArguments($"compose -f \"{composeFilePath}\" down") .WithValidation(CommandResultValidation.None) .WithStandardOutputPipe(PipeTarget.ToDelegate(line => Trace.WriteLine(line, "TEST_CONTAINER_CLEANUP"))) - .WithStandardErrorPipe(PipeTarget.ToDelegate(line => Trace.WriteLine(line, "TEST_CONTAINER_ERROR"))) .ExecuteAsync(cts.Token); } catch @@ -591,11 +711,21 @@ private static async Task GenerateCoverageReport() { Trace.WriteLine("Generating coverage report...", "CODE_COVERAGE_REPORT"); - List assemblyFilters = CoreOnly - ? ["+dymaptic.GeoBlazor.Core.dll"] + List assemblyFilters = + [ + "+dymaptic.GeoBlazor.Core.dll", + "+dymaptic.GeoBlazor.Core.SourceGenerator.dll", + "+dymaptic.GeoBlazor.Core.SourceGenerator.Shared.dll", + "+dymaptic.GeoBlazor.Core.Analyzers.dll", + "+dymaptic.GeoBlazor.Pro.dll", + "+dymaptic.GeoBlazor.Pro.SourceGenerator.dll" + ]; + + assemblyFilters = CoreOnly + ? assemblyFilters.Where(a => a.Contains("Core")).ToList() : ProOnly - ? ["+dymaptic.GeoBlazor.Pro.dll"] - : ["+dymaptic.GeoBlazor.Core.dll", "+dymaptic.GeoBlazor.Pro.dll"]; + ? assemblyFilters.Where(a => a.Contains("Pro")).ToList() + : assemblyFilters; List sourceDirs = CoreOnly ? [CoreProjectPath] @@ -605,7 +735,7 @@ private static async Task GenerateCoverageReport() List args = [ - $"-reports:{CoverageFilePath}", + $"-reports:{CoverageFilePath};{UnitCoverageFilePath};{SgenCoverageFilePath}", $"-targetdir:{reportDir}", "-reporttypes:Html;HtmlSummary;TextSummary;Badges", diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj b/test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj index 7f1c07ff7..a3d3ab3fb 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj @@ -21,7 +21,6 @@ - diff --git a/test/dymaptic.GeoBlazor.Core.Test.Unit/PlaywrightTests.cs b/test/dymaptic.GeoBlazor.Core.Test.Unit/PlaywrightTests.cs deleted file mode 100644 index 5a02576a1..000000000 --- a/test/dymaptic.GeoBlazor.Core.Test.Unit/PlaywrightTests.cs +++ /dev/null @@ -1,865 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Playwright; -using System.Diagnostics; - - -namespace dymaptic.GeoBlazor.Core.Test.Unit; - -[TestClass] -public class PlaywrightTests -{ - /// - /// An automated, yet still tedious process of going through each screen and clicking on things. - /// This test will likely break on changes to the repository, so don't rely on for stability. - /// - /// - /// For faster and more reliable testing, use the blazor test runner in `dymaptic.GeoBlazor.Core.Test.Blazor.Server` - /// or `dymaptic.GeoBlazor.Core.Test.Blazor.Wasm`. - /// - [TestMethod] - public async Task RunThroughScreens() - { - StartServer(); - string? apiKey = new ConfigurationBuilder().AddUserSecrets().Build()["ArcGISApiKey"]; - - if (!Directory.Exists(_screenShotsFolder)) - { - Directory.CreateDirectory(_screenShotsFolder); - } - else - { - // move current screenshots to previous folder - FileInfo[] screenshots = new DirectoryInfo(_screenShotsFolder).GetFiles(); - - foreach (FileInfo ssFile in screenshots) - { - File.Move(ssFile.FullName, Path.Combine(_screenShotsFolder, "Previous", ssFile.Name), true); - } - } - - IPlaywright playwright = await Playwright.CreateAsync()!; - IBrowser browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = false }); - IPage page = await browser.NewPageAsync(); - - var renderMessage = new PageWaitForConsoleMessageOptions - { - Predicate = m => m.Text.Contains("View Render Complete"), Timeout = 60000 - }; - await Task.Delay(1000); - Task waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - - // Go to https://localhost:7255/ - await page.GotoAsync("https://localhost:7255/navigation"); - await Task.Delay(1000); - - if (await page.Locator("#api-key-field").IsVisibleAsync()) - { - await page.Locator("#api-key-field").FillAsync(apiKey!); - await page.Locator("#api-key-field").PressAsync("Enter"); - } - - await waitForRenderTask; - - // Click text=Latitude: >> input[type="number"] - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Navigation1.png"), FullPage = true - }); - - // Fill text=Latitude: >> input[type="number"] - await page.Locator("text=Latitude: >> input[type=\"number\"]").FillAsync("34.023"); - - // Press Tab - await page.Locator("text=Latitude: >> input[type=\"number\"]").PressAsync("Tab"); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Navigation2.png"), FullPage = true - }); - - // Click text=Longitude: >> input[type="number"] - await page.Locator("text=Longitude: >> input[type=\"number\"]").ClickAsync(); - - // Fill text=Longitude: >> input[type="number"] - await page.Locator("text=Longitude: >> input[type=\"number\"]").FillAsync("-118.905"); - - // Press Tab - await page.Locator("text=Longitude: >> input[type=\"number\"]").PressAsync("Tab"); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Navigation3.png"), FullPage = true - }); - - // Fill text=Zoom: >> input[type="number"] - await page.Locator("text=Zoom: >> input[type=\"number\"]").FillAsync("12"); - - // Press Tab - await page.Locator("text=Zoom: >> input[type=\"number\"]").PressAsync("Tab"); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Navigation4.png"), FullPage = true - }); - - // Fill text=Rotation (deg): >> input[type="number"] - await page.Locator("text=Rotation (deg): >> input[type=\"number\"]").FillAsync("30"); - - // Press Tab - await page.Locator("text=Rotation (deg): >> input[type=\"number\"]").PressAsync("Tab"); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Navigation4.png"), FullPage = true - }); - - Task pageLoadTask = page.WaitForURLAsync("https://localhost:7255/source-code/navigation"); - - // Click text=Source Code - await page.Locator("text=Source Code").ClickAsync(); - await pageLoadTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Navigation_Source.png"), FullPage = true - }); - - // Click text=Drawing - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - pageLoadTask = page.WaitForURLAsync("https://localhost:7255/drawing"); - await page.Locator("text=Drawing").ClickAsync(); - await pageLoadTask; - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Drawing1.png"), FullPage = true - }); - - // Click text=Draw a Point >> span - await page.Locator("text=Draw a Point >> span").ClickAsync(); - - // Click text=Longitude: Latitude: Draw >> button - await page.Locator("text=Longitude: Latitude: Draw >> button").ClickAsync(); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Drawing2.png"), FullPage = true - }); - - // Click text=Longitude: >> input[type="number"] - await page.Locator("text=Longitude: >> input[type=\"number\"]").ClickAsync(); - - // Fill text=Longitude: >> input[type="number"] - await page.Locator("text=Longitude: >> input[type=\"number\"]").FillAsync("-118.7"); - - // Press Tab - await page.Locator("text=Longitude: >> input[type=\"number\"]").PressAsync("Tab"); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Drawing3.png"), FullPage = true - }); - - // Press ArrowDown - await page.Locator("text=Latitude: >> input[type=\"number\"]").PressAsync("ArrowDown"); - - // Press ArrowDown - await page.Locator("text=Latitude: >> input[type=\"number\"]").PressAsync("ArrowDown"); - - // Press ArrowUp - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Drawing4.png"), FullPage = true - }); - - // Click text=Longitude: Latitude: Remove >> button - await page.Locator("text=Longitude: Latitude: Remove >> button").ClickAsync(); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Drawing5.png"), FullPage = true - }); - - // Click text=Draw a Line - await page.Locator("text=Draw a Line").ClickAsync(); - - // Click button:has-text("Draw") >> nth=1 - await page.Locator("button:has-text(\"Draw\")").Nth(1).ClickAsync(); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Drawing6.png"), FullPage = true - }); - - // Click tr:nth-child(2) > td:nth-child(2) > input >> nth=0 - await page.Locator("tr:nth-child(2) > td:nth-child(2) > input").First.ClickAsync(); - - // Press ArrowDown - await page.Locator("tr:nth-child(2) > td:nth-child(2) > input").First.PressAsync("ArrowDown"); - - // Press ArrowDown - await page.Locator("tr:nth-child(2) > td:nth-child(2) > input").First.PressAsync("ArrowDown"); - - // Press ArrowDown - await page.Locator("tr:nth-child(2) > td:nth-child(2) > input").First.PressAsync("ArrowDown"); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Drawing7.png"), FullPage = true - }); - - // Click text=Add Pt >> nth=0 - await page.Locator("text=Add Pt").First.ClickAsync(); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Drawing8.png"), FullPage = true - }); - - // Click tr:nth-child(4) > td:nth-child(2) > input >> nth=0 - await page.Locator("tr:nth-child(4) > td:nth-child(2) > input").First.ClickAsync(); - - // Press ArrowUp - await page.Locator("tr:nth-child(4) > td:nth-child(2) > input").First.PressAsync("ArrowUp"); - - // Press ArrowUp - await page.Locator("tr:nth-child(4) > td:nth-child(2) > input").First.PressAsync("ArrowUp"); - - // Press ArrowUp - await page.Locator("tr:nth-child(4) > td:nth-child(2) > input").First.PressAsync("ArrowUp"); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Drawing9.png"), FullPage = true - }); - - // Click text=Remove >> nth=0 - await page.Locator("text=Remove").First.ClickAsync(); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Drawing10.png"), FullPage = true - }); - - // Click text=Draw a Polygon - await page.Locator("text=Draw a Polygon").ClickAsync(); - - // Click button:has-text("Draw") >> nth=2 - await page.Locator("button:has-text(\"Draw\")").Nth(1).ClickAsync(); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Drawing11.png"), FullPage = true - }); - - // Click text=Add Pt >> nth=1 - await page.Locator("text=Add Pt").Nth(1).ClickAsync(); - - // Click tr:nth-child(6) > td:nth-child(2) > input - await page.Locator("tr:nth-child(6) > td:nth-child(2) > input").ClickAsync(); - - // Press ArrowUp - await page.Locator("tr:nth-child(6) > td:nth-child(2) > input").PressAsync("ArrowUp"); - - // Press ArrowUp - await page.Locator("tr:nth-child(6) > td:nth-child(2) > input").PressAsync("ArrowUp"); - - // Press ArrowUp - await page.Locator("tr:nth-child(6) > td:nth-child(2) > input").PressAsync("ArrowUp"); - - // Press ArrowUp - await page.Locator("tr:nth-child(6) > td:nth-child(2) > input").PressAsync("ArrowUp"); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Drawing12.png"), FullPage = true - }); - - // Click text=Remove Pt >> nth=1 - await page.Locator("text=Remove Pt").Nth(1).ClickAsync(); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Drawing13.png"), FullPage = true - }); - - // Click text=Remove >> nth=1 - await page.Locator("text=Remove").Nth(1).ClickAsync(); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Drawing14.png"), FullPage = true - }); - - // Click text=Source Code - pageLoadTask = page.WaitForURLAsync("https://localhost:7255/source-code/drawing"); - await page.Locator("text=Source Code").ClickAsync(); - await pageLoadTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Drawing_Source.png"), FullPage = true - }); - - // Click text=Scene >> nth=0 - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - pageLoadTask = page.WaitForURLAsync("https://localhost:7255/scene"); - await page.Locator("text=Scene").First.ClickAsync(); - await pageLoadTask; - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Scene.png"), FullPage = true - }); - - // Click text=Widgets - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - pageLoadTask = page.WaitForURLAsync("https://localhost:7255/widgets"); - await page.Locator("text=Widgets").ClickAsync(); - await pageLoadTask; - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Widgets1.png"), FullPage = true - }); - - // Check text=Locator: >> input[type="checkbox"] - await page.Locator("text=Locator: >> input[type=\"checkbox\"]").CheckAsync(); - await Task.Delay(1000); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Widgets2.png"), FullPage = true - }); - - // Check text=Search: >> input[type="checkbox"] - await page.Locator("text=Search: >> input[type=\"checkbox\"]").CheckAsync(); - await Task.Delay(1000); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Widgets3.png"), FullPage = true - }); - - // Check text=Basemap Toggle: >> input[type="checkbox"] - await page.Locator("text=Basemap Toggle: >> input[type=\"checkbox\"]").CheckAsync(); - await Task.Delay(1000); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Widgets4.png"), FullPage = true - }); - - // Check text=Basemap Gallery: >> input[type="checkbox"] - await page.Locator("text=Basemap Gallery: >> input[type=\"checkbox\"]").CheckAsync(); - await Task.Delay(1000); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Widgets5.png"), FullPage = true - }); - - // Check text=Legend: >> input[type="checkbox"] - await page.Locator("text=Legend: >> input[type=\"checkbox\"]").CheckAsync(); - await Task.Delay(1000); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Widgets6.png"), FullPage = true - }); - - // Check text=Scale Bar: >> input[type="checkbox"] - await page.Locator("text=Scale Bar: >> input[type=\"checkbox\"]").CheckAsync(); - await Task.Delay(1000); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Widgets7.png"), FullPage = true - }); - - // Uncheck text=Legend: >> input[type="checkbox"] - await page.Locator("text=Legend: >> input[type=\"checkbox\"]").UncheckAsync(); - await Task.Delay(1000); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Widgets8.png"), FullPage = true - }); - - // Check text=Legend: >> input[type="checkbox"] - await page.Locator("text=Legend: >> input[type=\"checkbox\"]").CheckAsync(); - await Task.Delay(1000); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Widgets9.png"), FullPage = true - }); - - // Click [aria-label="Find my location"] - await page.Locator("[aria-label=\"Find my location\"]").ClickAsync(); - await Task.Delay(1000); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Widgets10.png"), FullPage = true - }); - - // Click [placeholder="Find address or place"] - await page.Locator("[placeholder=\"Find address or place\"]").ClickAsync(); - await Task.Delay(1000); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Widgets11.png"), FullPage = true - }); - - // Fill [placeholder="Find address or place"] - await page.Locator("[placeholder=\"Find address or place\"]") - .FillAsync("1600 Pennsylvania Ave, Washington, DC"); - - // Press Enter - await page.Locator("[placeholder=\"Find address or place\"]").PressAsync("Enter"); - await Task.Delay(1000); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Widgets12.png"), FullPage = true - }); - - // Click [aria-label="Close"] - await page.Locator("[aria-label=\"Close\"]").ClickAsync(); - await Task.Delay(1000); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Widgets13.png"), FullPage = true - }); - - // Click li[role="menuitem"]:has-text("Imagery Hybrid") - await page.Locator("li[role=\"menuitem\"]:has-text(\"Imagery Hybrid\")").ClickAsync(); - await Task.Delay(1000); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Widgets14.png"), FullPage = true - }); - - // Click .esri-basemap-gallery__item-thumbnail >> nth=0 - await page.Locator(".esri-basemap-gallery__item-thumbnail").First.ClickAsync(); - await Task.Delay(1000); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Widgets15.png"), FullPage = true - }); - - // Click text=Source Code - pageLoadTask = page.WaitForURLAsync("https://localhost:7255/source-code/widgets"); - await page.Locator("text=Source Code").ClickAsync(); - await pageLoadTask; - await Task.Delay(1000); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Widgets_Source.png"), FullPage = true - }); - - // Click text=Basemaps - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - pageLoadTask = page.WaitForURLAsync("https://localhost:7255/basemaps"); - await page.Locator("text=Basemaps").ClickAsync(); - await pageLoadTask; - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "BaseMaps1.png"), FullPage = true - }); - - // Check text=From Portal Id >> input[name="basemap-type"] - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - await page.Locator("text=From Portal Id >> input[name=\"basemap-type\"]").CheckAsync(); - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "BaseMaps2.png"), FullPage = true - }); - - // Check text=From Tile Layers >> input[name="basemap-type"] - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - await page.Locator("text=From Tile Layers >> input[name=\"basemap-type\"]").CheckAsync(); - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "BaseMaps3.png"), FullPage = true - }); - - // Click text=Source Code - pageLoadTask = page.WaitForURLAsync("https://localhost:7255/source-code/basemaps"); - await page.Locator("text=Source Code").ClickAsync(); - await pageLoadTask; - await Task.Delay(1000); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "BaseMaps_Source.png"), FullPage = true - }); - - // Click text=Feature Layers - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - pageLoadTask = page.WaitForURLAsync("https://localhost:7255/feature-layers"); - await page.Locator("text=Feature Layers").ClickAsync(); - await pageLoadTask; - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "FeatureLayers1.png"), FullPage = true - }); - - // Check text=Show Trailheads Points Layer: >> input[name="points"] - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - await page.Locator("text=Show Trailheads Points Layer: >> input[name=\"points\"]").CheckAsync(); - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "FeatureLayers2.png"), FullPage = true - }); - - // Check text=Show Trailheads Lines Layer: >> input[name="points"] - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - await page.Locator("text=Show Trailheads Lines Layer: >> input[name=\"points\"]").CheckAsync(); - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "FeatureLayers3.png"), FullPage = true - }); - - // Check text=Show Trailheads Lines With Elevation Style Renderer: >> input[name="points"] - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - - await page.Locator("text=Show Trailheads Lines With Elevation Style Renderer: >> input[name=\"points\"]") - .CheckAsync(); - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "FeatureLayers4.png"), FullPage = true - }); - - // Check text=Show Trailheads Lines With Bike Trails Styled: >> input[name="points"] - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - - await page.Locator("text=Show Trailheads Lines With Bike Trails Styled: >> input[name=\"points\"]") - .CheckAsync(); - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "FeatureLayers5.png"), FullPage = true - }); - - // Check text=Show Trailheads Polygons Layer: >> input[name="points"] - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - await page.Locator("text=Show Trailheads Polygons Layer: >> input[name=\"points\"]").CheckAsync(); - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "FeatureLayers6.png"), FullPage = true - }); - - // Click text=Vector Layer - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - pageLoadTask = page.WaitForURLAsync("https://localhost:7255/vector-layer"); - await page.Locator("text=Vector Layer").ClickAsync(); - await pageLoadTask; - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "VectorLayer.png"), FullPage = true - }); - - // Click text=Source Code - pageLoadTask = page.WaitForURLAsync("https://localhost:7255/source-code/vector-layer"); - await page.Locator("text=Source Code").ClickAsync(); - await pageLoadTask; - await Task.Delay(1000); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "VectorLayer_Source.png"), FullPage = true - }); - - // Click text=Web Map - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - pageLoadTask = page.WaitForURLAsync("https://localhost:7255/web-map"); - await page.Locator("text=Web Map").ClickAsync(); - await pageLoadTask; - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "WebMap1.png"), FullPage = true - }); - - // Click text=Web Scene - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - pageLoadTask = page.WaitForURLAsync("https://localhost:7255/web-scene"); - await page.Locator("text=Web Scene").ClickAsync(); - await pageLoadTask; - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "WebScene1.png"), FullPage = true - }); - - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - pageLoadTask = page.WaitForURLAsync("https://localhost:7255/sql-query"); - await page.Locator("text=SQL Query").ClickAsync(); - await pageLoadTask; - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "SqlQuery1.png"), FullPage = true - }); - - // Select UseType = 'Residential' - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - await page.Locator("select").SelectOptionAsync(new[] { "UseType = 'Residential'" }); - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "SqlQuery2.png"), FullPage = true - }); - - // Select UseType = 'Irrigated Farm' - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - await page.Locator("select").SelectOptionAsync(new[] { "UseType = 'Irrigated Farm'" }); - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "SqlQuery3.png"), FullPage = true - }); - - // Select TaxRateArea = 08637 - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - await page.Locator("select").SelectOptionAsync(new[] { "TaxRateArea = 08637" }); - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "SqlQuery4.png"), FullPage = true - }); - - // Select Roll_LandValue < 1000000 - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - await page.Locator("select").SelectOptionAsync(new[] { "Roll_LandValue < 1000000" }); - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "SqlQuery5.png"), FullPage = true - }); - - // Click text=Source Code - pageLoadTask = page.WaitForURLAsync("https://localhost:7255/source-code/sql-query"); - await page.Locator("text=Source Code").ClickAsync(); - await pageLoadTask; - await Task.Delay(1000); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "SqlQuery_Source.png"), FullPage = true - }); - - // Click text=SQL Filter Query - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - pageLoadTask = page.WaitForURLAsync("https://localhost:7255/sql-filter-query"); - await page.Locator("text=SQL Filter Query").ClickAsync(); - await pageLoadTask; - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "SqlFilterQuery1.png"), FullPage = true - }); - - // Select Roll_LandValue < 200000 - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - await page.Locator("select").SelectOptionAsync(new[] { "Roll_LandValue < 200000" }); - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "SqlFilterQuery2.png"), FullPage = true - }); - - // Select Bedrooms5 > 0 - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - await page.Locator("select").SelectOptionAsync(new[] { "Bedrooms5 > 0" }); - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "SqlFilterQuery3.png"), FullPage = true - }); - - // Select Roll_RealEstateExemp > 0 - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - await page.Locator("select").SelectOptionAsync(new[] { "Roll_RealEstateExemp > 0" }); - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "SqlFilterQuery4.png"), FullPage = true - }); - - // Click text=Place Selector - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - pageLoadTask = page.WaitForURLAsync("https://localhost:7255/place-selector"); - await page.Locator("text=Place Selector").ClickAsync(); - await pageLoadTask; - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "PlaceSelector1.png"), FullPage = true - }); - - // Select Coffee shop - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - await page.Locator("select").SelectOptionAsync(new[] { "Coffee shop" }); - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "PlaceSelector2.png"), FullPage = true - }); - - // Click text=Routing - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - pageLoadTask = page.WaitForURLAsync("https://localhost:7255/routing"); - await page.Locator("text=Routing").ClickAsync(); - await pageLoadTask; - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Routing1.png"), FullPage = true - }); - - // Click canvas - await page.Mouse.ClickAsync(441, 320); - await Task.Delay(1000); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Routing2.png"), FullPage = true - }); - - // Click canvas - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - await page.Mouse.ClickAsync(954, 348); - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "Routing3.png"), FullPage = true - }); - - // Click text=Service Areas - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - pageLoadTask = page.WaitForURLAsync("https://localhost:7255/service-areas"); - await page.Locator("text=Service Areas").ClickAsync(); - await pageLoadTask; - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "ServiceAreas1.png"), FullPage = true - }); - - // Click canvas - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - await page.Mouse.ClickAsync(505, 339); - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "ServiceAreas2.png"), FullPage = true - }); - - // Click canvas - waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage); - await page.Mouse.ClickAsync(950, 442); - await waitForRenderTask; - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "ServiceAreas3.png"), FullPage = true - }); - - // Click text=Source Code - pageLoadTask = page.WaitForURLAsync("https://localhost:7255/source-code/service-areas"); - await page.Locator("text=Source Code").ClickAsync(); - await pageLoadTask; - await Task.Delay(1000); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "ServiceAreas_Source.png"), FullPage = true - }); - - // Click text=About - pageLoadTask = page.WaitForURLAsync("https://localhost:7255/about"); - await page.Locator("text=Home").ClickAsync(); - await pageLoadTask; - await Task.Delay(1000); - - await page.ScreenshotAsync(new PageScreenshotOptions - { - Path = Path.Combine(_screenShotsFolder, "About.png"), FullPage = true - }); - - StopServer(); - } - - private static void StartServer() - { - var processStartInfo = new ProcessStartInfo("dotnet", - "run --project ../../../../../samples/dymaptic.GeoBlazor.Core.Sample.Server/dymaptic.GeoBlazor.Core.Sample.Server.csproj --no-build") - { - UseShellExecute = true - }; - - _serverProcess = Process.Start(processStartInfo); - Assert.IsNotNull(_serverProcess); - } - - private static void StopServer() - { - if (_serverProcess is not null && !_serverProcess.HasExited) - { - _serverProcess.CloseMainWindow(); - _serverProcess.Kill(); - } - - _serverProcess = null; - } - - private static Process? _serverProcess; - private readonly string _screenShotsFolder = "../../../ScreenShots"; -} \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.Unit/SerializationUnitTests.cs b/test/dymaptic.GeoBlazor.Core.Test.Unit/SerializationUnitTests.cs index 41b33f2b8..347d6c985 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Unit/SerializationUnitTests.cs +++ b/test/dymaptic.GeoBlazor.Core.Test.Unit/SerializationUnitTests.cs @@ -20,15 +20,15 @@ public class SerializationUnitTests [TestMethod] public void SerializeGraphicToJson() { - var graphic = new Graphic(new Point(_random.NextDouble() * 10 + 11.0, - _random.NextDouble() * 10 + 50.0), + var graphic = new Graphic(new Point((_random.NextDouble() * 10) + 11.0, + (_random.NextDouble() * 10) + 50.0), new SimpleMarkerSymbol(new Outline(new MapColor("green")), new MapColor("red"), 10), new PopupTemplate("Test", "Test Content
{testString}
{testNumber}", ["*"]), new AttributesDictionary( new Dictionary { { "testString", "test" }, { "testNumber", 123 } })); var sw = Stopwatch.StartNew(); - string json = JsonSerializer.Serialize(graphic.ToSerializationRecord()); - byte[] data = Encoding.UTF8.GetBytes(json); + var json = JsonSerializer.Serialize(graphic.ToSerializationRecord()); + var data = Encoding.UTF8.GetBytes(json); sw.Stop(); Console.WriteLine($"SerializeGraphicToJson: {sw.ElapsedMilliseconds}ms"); Console.WriteLine($"Size: {data.Length} bytes"); @@ -37,38 +37,51 @@ public void SerializeGraphicToJson() [TestMethod] public void RoundTripSerializeGraphicToJson() { - var graphic = new Graphic(new Point(_random.NextDouble() * 10 + 11.0, - _random.Next() * 10 + 50.0), + var graphic = new Graphic(new Point((_random.NextDouble() * 10) + 11.0, + (_random.Next() * 10) + 50.0), new SimpleMarkerSymbol(new Outline(new MapColor("green")), new MapColor("red"), 10), new PopupTemplate("Test", - "Test Content
{testString}
{testNumber}", - outFields: [ "*" ]), + "Test Content
{testString}
{testNumber}", + ["*"]), new AttributesDictionary( new Dictionary { { "testString", "test" }, { "testNumber", 123 } })); + JsonSerializerOptions options = new() { ReferenceHandler = ReferenceHandler.Preserve, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true }; - string json = JsonSerializer.Serialize(graphic, options); - Graphic deserialized = JsonSerializer.Deserialize(json, options)!; - Assert.AreEqual(((Point)graphic.Geometry!).Latitude, ((Point)deserialized.Geometry!).Latitude); - Assert.AreEqual(((Point)graphic.Geometry!).Longitude, ((Point)deserialized.Geometry!).Longitude); - Assert.AreEqual(((SimpleMarkerSymbol)graphic.Symbol!).Color, ((SimpleMarkerSymbol)deserialized.Symbol!).Color); - Assert.AreEqual(((SimpleMarkerSymbol)graphic.Symbol!).Outline!.Color, ((SimpleMarkerSymbol)deserialized.Symbol!).Outline!.Color); - Assert.AreEqual(((SimpleMarkerSymbol)graphic.Symbol!).Size, ((SimpleMarkerSymbol)deserialized.Symbol!).Size); + var json = JsonSerializer.Serialize(graphic, options); + var deserialized = JsonSerializer.Deserialize(json, options)!; + var graphicPoint = graphic.Geometry as Point; + var deserializedPoint = deserialized.Geometry as Point; + Assert.IsNotNull(graphicPoint); + Assert.IsNotNull(deserializedPoint); + Assert.AreEqual(graphicPoint.Latitude, deserializedPoint.Latitude); + Assert.AreEqual(graphicPoint.Longitude, deserializedPoint.Longitude); + var graphicSymbol = deserialized.Symbol as SimpleMarkerSymbol; + var deserializedSymbol = deserialized.Symbol as SimpleMarkerSymbol; + Assert.IsNotNull(graphicSymbol); + Assert.IsNotNull(deserializedSymbol); + + Assert.AreEqual(graphicSymbol.Color, deserializedSymbol.Color, + $"Color {graphicSymbol.Color} does not match {deserializedSymbol.Color}"); + + Assert.AreEqual(graphicSymbol.Outline!.Color, deserializedSymbol.Outline!.Color, + $"Color {graphicSymbol.Outline} does not match {deserializedSymbol.Outline}"); + Assert.AreEqual(graphicSymbol.Size, deserializedSymbol.Size); Assert.AreEqual(graphic.PopupTemplate!.Title, deserialized.PopupTemplate!.Title); Assert.AreEqual(graphic.Attributes.Count, deserialized.Attributes.Count); Assert.AreEqual(graphic.Attributes["testString"], deserialized.Attributes["testString"]); Assert.AreEqual(graphic.Attributes["testNumber"], deserialized.Attributes["testNumber"]); - } + } [TestMethod] public void SerializeToProtobuf() { - var graphic = new Graphic(new Point(_random.NextDouble() * 10 + 11.0, - _random.NextDouble() * 10 + 50.0), + var graphic = new Graphic(new Point((_random.NextDouble() * 10) + 11.0, + (_random.NextDouble() * 10) + 50.0), new SimpleMarkerSymbol(new Outline(new MapColor("green")), new MapColor("red"), 10), new PopupTemplate("Test", "Test Content
{testString}
{testNumber}", ["*"]), new AttributesDictionary( @@ -77,17 +90,17 @@ public void SerializeToProtobuf() ProtoGraphicCollection collection = new([graphic.ToSerializationRecord()]); using MemoryStream ms = new(); Serializer.Serialize(ms, collection); - byte[] data = ms.ToArray(); + var data = ms.ToArray(); sw.Stop(); Console.WriteLine($"SerializeGraphicToJson: {sw.ElapsedMilliseconds}ms"); Console.WriteLine($"Size: {data.Length} bytes"); } - + [TestMethod] public void RoundTripSerializeToProtobuf() { - var graphic = new Graphic(new Point(_random.NextDouble() * 10 + 11.0, - _random.NextDouble() * 10 + 50.0), + var graphic = new Graphic(new Point((_random.NextDouble() * 10) + 11.0, + (_random.NextDouble() * 10) + 50.0), new SimpleMarkerSymbol(new Outline(new MapColor("green")), new MapColor("red"), 10), new PopupTemplate("Test", "Test Content
{testString}
{testNumber}", ["*"]), new AttributesDictionary( @@ -95,14 +108,17 @@ public void RoundTripSerializeToProtobuf() ProtoGraphicCollection collection = new([graphic.ToSerializationRecord()]); using MemoryStream ms = new(); Serializer.Serialize(ms, collection); - byte[] data = ms.ToArray(); - ProtoGraphicCollection deserializedCollection = + var data = ms.ToArray(); + + var deserializedCollection = Serializer.Deserialize((ReadOnlyMemory)data); - Graphic deserialized = deserializedCollection.Graphics[0].FromSerializationRecord(); + var deserialized = deserializedCollection.Graphics[0].FromSerializationRecord(); Assert.AreEqual(((Point)graphic.Geometry!).Latitude, ((Point)deserialized.Geometry!).Latitude); Assert.AreEqual(((Point)graphic.Geometry!).Longitude, ((Point)deserialized.Geometry!).Longitude); Assert.AreEqual(((SimpleMarkerSymbol)graphic.Symbol!).Color, ((SimpleMarkerSymbol)deserialized.Symbol!).Color); - Assert.AreEqual(((SimpleMarkerSymbol)graphic.Symbol!).Outline!.Color, ((SimpleMarkerSymbol)deserialized.Symbol!).Outline!.Color); + + Assert.AreEqual(((SimpleMarkerSymbol)graphic.Symbol!).Outline!.Color, + ((SimpleMarkerSymbol)deserialized.Symbol!).Outline!.Color); Assert.AreEqual(((SimpleMarkerSymbol)graphic.Symbol!).Size, ((SimpleMarkerSymbol)deserialized.Symbol!).Size); Assert.AreEqual(graphic.PopupTemplate!.Title, deserialized.PopupTemplate!.Title); Assert.AreEqual(graphic.Attributes.Count, deserialized.Attributes.Count); @@ -113,60 +129,61 @@ public void RoundTripSerializeToProtobuf() [TestMethod] public void DeserializePortal() { - string portalJson = File.ReadAllText("Portal.json"); - Portal portal = JsonSerializer.Deserialize(portalJson)!; + var portalJson = File.ReadAllText("Portal.json"); + var portal = JsonSerializer.Deserialize(portalJson)!; Assert.IsNotNull(portal); } [TestMethod] public void DeserializePortalWithInvalidRotatorPanels() { - string invalidJson = - """ - { - "rotatorPanels": { - "id": "this is one object not an array" - } - } - """; - Portal portal = JsonSerializer.Deserialize(invalidJson)!; + var invalidJson = + """ + { + "rotatorPanels": { + "id": "this is one object not an array" + } + } + """; + var portal = JsonSerializer.Deserialize(invalidJson)!; Assert.IsNotNull(portal); Assert.IsNull(portal.RotatorPanels); } - + [TestMethod] public void DeserializePortalWithEmptyRotatorPanels() { - string invalidJson = - """ - { - "rotatorPanels": [] - } - """; - Portal portal = JsonSerializer.Deserialize(invalidJson)!; - Assert.IsNotNull(portal); - Assert.IsNull(portal.RotatorPanels); + var invalidJson = + """ + { + "rotatorPanels": [] + } + """; + var portal = JsonSerializer.Deserialize(invalidJson)!; + Assert.IsNotNull(portal); + Assert.IsNull(portal.RotatorPanels); } - + [TestMethod] public void DeserializePortalWithEmptyStringAsRotatorPanels() { - string invalidJson = - """ - { - "rotatorPanels": "" - } - """; - Portal portal = JsonSerializer.Deserialize(invalidJson)!; - Assert.IsNotNull(portal); - Assert.IsNull(portal.RotatorPanels); + var invalidJson = + """ + { + "rotatorPanels": "" + } + """; + var portal = JsonSerializer.Deserialize(invalidJson)!; + Assert.IsNotNull(portal); + Assert.IsNull(portal.RotatorPanels); } [TestMethod] public void DeserializeWFSCapabilities() { - string json = File.ReadAllText("WFSCapabilities.json"); - WFSCapabilities capabilities = JsonSerializer.Deserialize(json, + var json = File.ReadAllText("WFSCapabilities.json"); + + var capabilities = JsonSerializer.Deserialize(json, GeoBlazorSerialization.JsonSerializerOptions)!; Assert.IsNotNull(capabilities.FeatureTypes); Assert.IsNotEmpty(capabilities.FeatureTypes); diff --git a/test/dymaptic.GeoBlazor.Core.Test.Unit/dymaptic.GeoBlazor.Core.Test.Unit.csproj b/test/dymaptic.GeoBlazor.Core.Test.Unit/dymaptic.GeoBlazor.Core.Test.Unit.csproj index 9ed68c8e7..0fa305358 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Unit/dymaptic.GeoBlazor.Core.Test.Unit.csproj +++ b/test/dymaptic.GeoBlazor.Core.Test.Unit/dymaptic.GeoBlazor.Core.Test.Unit.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -17,8 +17,6 @@ - - all runtime; build; native; contentfiles; analyzers; buildtransitive From 223dcf856b4f9380d516b82a7eea07150ac1aa8a Mon Sep 17 00:00:00 2001 From: "submodule-validation-for-geoblazor[bot]" <235551211+submodule-validation-for-geoblazor[bot]@users.noreply.github.com> Date: Sat, 17 Jan 2026 20:03:20 +0000 Subject: [PATCH 18/18] Pipeline Build Commit of Version and Docs --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index c59138b2e..0c202811e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,7 @@ enable enable - 5.0.0.12 + 5.0.0.13 true Debug;Release;SourceGen Highlighting AnyCPU