diff --git a/Directory.Build.props b/Directory.Build.props index 698713f3b..b13101a72 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,7 +6,7 @@ enable enable true - 4.5.3 + 4.6.0 Debug;Release;SourceGen Highlighting AnyCPU true diff --git a/build-tools/build-scripts/GBTest.cs b/build-tools/build-scripts/GBTest.cs index 32fd6cce7..0b8451d96 100644 --- a/build-tools/build-scripts/GBTest.cs +++ b/build-tools/build-scripts/GBTest.cs @@ -162,14 +162,11 @@ await RunDotnetCommandWithOutputAsync(testProjectDir, "run", buildArgs, environm double passedPercentage = (double)progress.Passed / ran * 100; Console.WriteLine($"PASSED TESTS: {progress.Passed} / {ran} TESTS RAN ({passedPercentage:F2}%)."); Console.WriteLine($"FAILED: {progress.Failed} / SKIPPED: {progress.Skipped}"); - - if (passedPercentage < percentage) - { - Console.WriteLine($"TEST RUN FAILED: Passed percentage {passedPercentage:F2}% is below the required { - percentage}%."); - failed = true; - } } + + // A cancelled run never completed, so it can never be reported as a pass. + Console.WriteLine("TEST RUN FAILED: Test run was cancelled before completion."); + failed = true; } else { @@ -178,35 +175,56 @@ await RunDotnetCommandWithOutputAsync(testProjectDir, "run", buildArgs, environm Regex finalCountRegex = new(@"^.*FINAL_SUMMARY: PASSED TESTS: (?\d+) / (?\d+)\s*$"); + bool summaryFound = false; int total = 0; int passed = 0; - foreach (string line in await File.ReadAllLinesAsync(testOutputLogPath)) + + if (File.Exists(testOutputLogPath)) { - if (line.Contains("FINAL_SUMMARY")) + foreach (string line in await File.ReadAllLinesAsync(testOutputLogPath)) { - string content = line.Substring(38); // 38 is the timestamp plus FINAL_SUMMARY: - - if (finalCountRegex.Match(line) is { Success: true } match) - { - total = int.Parse(match.Groups["total"].Value); - passed = int.Parse(match.Groups["passed"].Value); - } - else + if (line.Contains("FINAL_SUMMARY")) { - Console.WriteLine(content); + string content = line.Substring(38); // 38 is the timestamp plus FINAL_SUMMARY: + + if (finalCountRegex.Match(line) is { Success: true } match) + { + total = int.Parse(match.Groups["total"].Value); + passed = int.Parse(match.Groups["passed"].Value); + summaryFound = true; + } + else + { + Console.WriteLine(content); + } } } } - double passedPercentage = total > 0 ? (double)passed / total * 100 : 100; - Console.WriteLine($"PASSED TESTS: {passed} / {total} TESTS PASSED ({passedPercentage:F2}%)."); - - if (passedPercentage < percentage) + if (!summaryFound || total == 0) { - Console.WriteLine($"TEST RUN FAILED: Passed percentage {passedPercentage - :F2}% is below the required {percentage}%."); + // No completed-test summary was produced. This happens when the run aborts before any + // test executes - e.g. AssemblyInitialize throws because the test web app never became + // reachable, so AssemblyCleanup (which writes the FINAL_SUMMARY) never runs. Previously + // this was reported as 100% passing because the percentage defaulted to 100 when total + // was 0, masking a hard failure as a green run. + Console.WriteLine("TEST RUN FAILED: No completed test results were recorded. The test run " + + "aborted before any tests ran (for example, AssemblyInitialize failed or the test web " + + "app was not reachable). Review the log output above for the root cause."); failed = true; } + else + { + double passedPercentage = (double)passed / total * 100; + Console.WriteLine($"PASSED TESTS: {passed} / {total} TESTS PASSED ({passedPercentage:F2}%)."); + + if (passedPercentage < percentage) + { + Console.WriteLine($"TEST RUN FAILED: Passed percentage {passedPercentage + :F2}% is below the required {percentage}%."); + failed = true; + } + } } if (failed) diff --git a/build-tools/build-scripts/ScriptBuilder.cs b/build-tools/build-scripts/ScriptBuilder.cs index c6bf79787..5a8ea1c99 100644 --- a/build-tools/build-scripts/ScriptBuilder.cs +++ b/build-tools/build-scripts/ScriptBuilder.cs @@ -32,6 +32,7 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.RegularExpressions; bool excludeMode = false; @@ -424,10 +425,58 @@ static async Task BuildScript(string scriptName, string scriptsDir, string process.BeginOutputReadLine(); process.BeginErrorReadLine(); await process.WaitForExitAsync(cancellationToken); + + // The file-based-app SDK bakes the source .cs file's absolute path into the generated + // runtimeconfig.json. Rewrite it to a stable relative value so the committed output doesn't churn across machines. + if (process.ExitCode == 0 && scriptName.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + { + RewriteRuntimeConfigPaths(scriptName, outDir); + } + return process.ExitCode; } -static async Task CleanScript(string scriptName, string scriptsDir, string outDir, string runtime, +/// +/// Replaces the absolute EntryPointFilePath/EntryPointFileDirectoryPath that the file-based-app SDK +/// writes into a script's runtimeconfig.json with machine-independent relative values. +/// +static void RewriteRuntimeConfigPaths(string scriptName, string outDir) +{ + string runtimeConfigPath = Path.Combine(outDir, Path.GetFileNameWithoutExtension(scriptName) + ".runtimeconfig.json"); + if (!File.Exists(runtimeConfigPath)) + { + return; + } + + try + { + if (JsonNode.Parse(File.ReadAllText(runtimeConfigPath)) is not { } root + || root["runtimeOptions"] is not JsonObject runtimeOptions) + { + return; + } + + // Remove the dead doubly-nested block left by the old runtimeconfig.template.json + runtimeOptions.Remove("runtimeOptions"); + + if (runtimeOptions["configProperties"] is not JsonObject configProperties + || !configProperties.ContainsKey("EntryPointFilePath")) + { + return; + } + + configProperties["EntryPointFilePath"] = $".\\{scriptName}"; + configProperties["EntryPointFileDirectoryPath"] = "."; + + File.WriteAllText(runtimeConfigPath, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + } + catch (Exception ex) + { + Trace.WriteLine($"Failed to rewrite runtime config paths for {scriptName}: {ex.Message}"); + } +} + +static async Task CleanScript(string scriptName, string scriptsDir, string outDir, string runtime, CancellationToken cancellationToken) { Console.WriteLine($"Cleaning script: {scriptName} for runtime: {runtime}"); diff --git a/build-tools/build-scripts/runtimeconfig.template.json b/build-tools/build-scripts/runtimeconfig.template.json deleted file mode 100644 index 835174faf..000000000 --- a/build-tools/build-scripts/runtimeconfig.template.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "runtimeOptions": { - "configProperties": { - "EntryPointFilePath": ".\\GBTest.cs", - "EntryPointFileDirectoryPath": "." - } - } -} \ No newline at end of file diff --git a/build-tools/linux-x64/GBTest b/build-tools/linux-x64/GBTest index 8f68d49a4..b49deac07 100755 Binary files a/build-tools/linux-x64/GBTest and b/build-tools/linux-x64/GBTest differ diff --git a/build-tools/linux-x64/GBTest.dll b/build-tools/linux-x64/GBTest.dll index ea8b15112..749c86d89 100644 Binary files a/build-tools/linux-x64/GBTest.dll and b/build-tools/linux-x64/GBTest.dll differ diff --git a/build-tools/linux-x64/GBTest.runtimeconfig.json b/build-tools/linux-x64/GBTest.runtimeconfig.json index 6c47522f2..7ff48568f 100644 --- a/build-tools/linux-x64/GBTest.runtimeconfig.json +++ b/build-tools/linux-x64/GBTest.runtimeconfig.json @@ -12,8 +12,8 @@ } }, "configProperties": { - "EntryPointFilePath": "/Users/timpurdum/repos/GeoBlazor/GeoBlazor.Pro/GeoBlazor/build-tools/build-scripts/GBTest.cs", - "EntryPointFileDirectoryPath": "/Users/timpurdum/repos/GeoBlazor/GeoBlazor.Pro/GeoBlazor/build-tools/build-scripts", + "EntryPointFilePath": "D:\\dymaptic.GeoBlazor.CodeGen\\GeoBlazor.Pro\\GeoBlazor\\build-tools\\build-scripts\\GBTest.cs", + "EntryPointFileDirectoryPath": "D:\\dymaptic.GeoBlazor.CodeGen\\GeoBlazor.Pro\\GeoBlazor\\build-tools\\build-scripts", "Microsoft.Extensions.DependencyInjection.VerifyOpenGenericServiceTrimmability": true, "System.ComponentModel.DefaultValueAttribute.IsSupported": false, "System.ComponentModel.Design.IDesignerHost.IsSupported": false, diff --git a/build-tools/linux-x64/Utilities.dll b/build-tools/linux-x64/Utilities.dll index 302f41cf7..faf2d8902 100644 Binary files a/build-tools/linux-x64/Utilities.dll and b/build-tools/linux-x64/Utilities.dll differ diff --git a/build-tools/osx-arm64/GBTest b/build-tools/osx-arm64/GBTest index 67b0b835c..e3670f8fb 100755 Binary files a/build-tools/osx-arm64/GBTest and b/build-tools/osx-arm64/GBTest differ diff --git a/build-tools/osx-arm64/GBTest.dll b/build-tools/osx-arm64/GBTest.dll index df13fc9f4..4aee4ef77 100644 Binary files a/build-tools/osx-arm64/GBTest.dll and b/build-tools/osx-arm64/GBTest.dll differ diff --git a/build-tools/osx-arm64/GBTest.runtimeconfig.json b/build-tools/osx-arm64/GBTest.runtimeconfig.json index c0db3c7b0..1cf2450bf 100644 --- a/build-tools/osx-arm64/GBTest.runtimeconfig.json +++ b/build-tools/osx-arm64/GBTest.runtimeconfig.json @@ -12,8 +12,8 @@ } }, "configProperties": { - "EntryPointFilePath": "/Users/timpurdum/repos/GeoBlazor/GeoBlazor.Pro/GeoBlazor/build-tools/build-scripts/GBTest.cs", - "EntryPointFileDirectoryPath": "/Users/timpurdum/repos/GeoBlazor/GeoBlazor.Pro/GeoBlazor/build-tools/build-scripts", + "EntryPointFilePath": "D:\\dymaptic.GeoBlazor.CodeGen\\GeoBlazor.Pro\\GeoBlazor\\build-tools\\build-scripts\\GBTest.cs", + "EntryPointFileDirectoryPath": "D:\\dymaptic.GeoBlazor.CodeGen\\GeoBlazor.Pro\\GeoBlazor\\build-tools\\build-scripts", "Microsoft.Extensions.DependencyInjection.VerifyOpenGenericServiceTrimmability": true, "System.ComponentModel.DefaultValueAttribute.IsSupported": false, "System.ComponentModel.Design.IDesignerHost.IsSupported": false, diff --git a/build-tools/osx-arm64/Utilities.dll b/build-tools/osx-arm64/Utilities.dll index 302f41cf7..faf2d8902 100644 Binary files a/build-tools/osx-arm64/Utilities.dll and b/build-tools/osx-arm64/Utilities.dll differ diff --git a/build-tools/win-x64/GBTest.dll b/build-tools/win-x64/GBTest.dll index 2879d97fb..bca7aac54 100644 Binary files a/build-tools/win-x64/GBTest.dll and b/build-tools/win-x64/GBTest.dll differ diff --git a/build-tools/win-x64/GBTest.exe b/build-tools/win-x64/GBTest.exe index ec9ba2c5d..157e2f253 100755 Binary files a/build-tools/win-x64/GBTest.exe and b/build-tools/win-x64/GBTest.exe differ diff --git a/build-tools/win-x64/GBTest.runtimeconfig.json b/build-tools/win-x64/GBTest.runtimeconfig.json index ff061f894..0feb20dac 100644 --- a/build-tools/win-x64/GBTest.runtimeconfig.json +++ b/build-tools/win-x64/GBTest.runtimeconfig.json @@ -12,8 +12,8 @@ } }, "configProperties": { - "EntryPointFilePath": "/Users/timpurdum/repos/GeoBlazor/GeoBlazor.Pro/GeoBlazor/build-tools/build-scripts/GBTest.cs", - "EntryPointFileDirectoryPath": "/Users/timpurdum/repos/GeoBlazor/GeoBlazor.Pro/GeoBlazor/build-tools/build-scripts", + "EntryPointFilePath": "D:\\dymaptic.GeoBlazor.CodeGen\\GeoBlazor.Pro\\GeoBlazor\\build-tools\\build-scripts\\GBTest.cs", + "EntryPointFileDirectoryPath": "D:\\dymaptic.GeoBlazor.CodeGen\\GeoBlazor.Pro\\GeoBlazor\\build-tools\\build-scripts", "Microsoft.Extensions.DependencyInjection.VerifyOpenGenericServiceTrimmability": true, "System.ComponentModel.DefaultValueAttribute.IsSupported": false, "System.ComponentModel.Design.IDesignerHost.IsSupported": false, diff --git a/build-tools/win-x64/Utilities.dll b/build-tools/win-x64/Utilities.dll index 302f41cf7..faf2d8902 100644 Binary files a/build-tools/win-x64/Utilities.dll and b/build-tools/win-x64/Utilities.dll differ diff --git a/src/dymaptic.GeoBlazor.Core/Components/ActiveLayerInfo.gb.cs b/src/dymaptic.GeoBlazor.Core/Components/ActiveLayerInfo.gb.cs index b6e2ab9f5..ee4b6559d 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/ActiveLayerInfo.gb.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/ActiveLayerInfo.gb.cs @@ -286,50 +286,6 @@ public ActiveLayerInfo( return IsScaleDriven; } - /// - /// Asynchronously retrieve the current value of the Layer property. - /// - public async Task GetLayer() - { - if (CoreJsModule is null) - { - return Layer; - } - - try - { - JsComponentReference ??= await CoreJsModule.InvokeAsync( - "getJsComponent", CancellationTokenSource.Token, Id); - } - catch (JSException) - { - // this is expected if the component is not yet built - } - - if (JsComponentReference is null) - { - return Layer; - } - - Layer? result = await JsComponentReference.InvokeAsync( - "getLayer", CancellationTokenSource.Token); - - if (result is not null) - { - if (Layer is not null) - { - result.Id = Layer.Id; - } - result.UpdateGeoBlazorReferences(CoreJsModule!, ProJsModule, View, this, Layer); - -#pragma warning disable BL0005 - Layer = result; -#pragma warning restore BL0005 - ModifiedParameters[nameof(Layer)] = Layer; - } - - return Layer; - } /// /// Asynchronously retrieve the current value of the LayerView property. @@ -733,48 +689,6 @@ await CoreJsModule.InvokeVoidAsync("setProperty", CancellationTokenSource.Token, JsComponentReference, "hideLayersNotInCurrentView", value); } - /// - /// Asynchronously set the value of the Layer property after render. - /// - /// - /// The value to set. - /// - public async Task SetLayer(Layer? value) - { - if (value is not null) - { - value.UpdateGeoBlazorReferences(CoreJsModule!, ProJsModule, View, this, Layer); - } - -#pragma warning disable BL0005 - Layer = value; -#pragma warning restore BL0005 - ModifiedParameters[nameof(Layer)] = value; - - if (CoreJsModule is null) - { - return; - } - - try - { - JsComponentReference ??= await CoreJsModule.InvokeAsync( - "getJsComponent", CancellationTokenSource.Token, Id); - } - catch (JSException) - { - // this is expected if the component is not yet built - } - - if (JsComponentReference is null) - { - return; - } - - await CoreJsModule.InvokeVoidAsync("setProperty", CancellationTokenSource.Token, - JsComponentReference, "layer", value); - } - /// /// Asynchronously set the value of the LayerView property after render. /// diff --git a/src/dymaptic.GeoBlazor.Core/Components/FeatureSnappingLayerSource.gb.cs b/src/dymaptic.GeoBlazor.Core/Components/FeatureSnappingLayerSource.gb.cs index f5f8bc33c..e7ca0fff0 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/FeatureSnappingLayerSource.gb.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/FeatureSnappingLayerSource.gb.cs @@ -99,50 +99,6 @@ public FeatureSnappingLayerSource( return Enabled; } - /// - /// Asynchronously retrieve the current value of the Layer property. - /// - public async Task GetLayer() - { - if (CoreJsModule is null) - { - return Layer; - } - - try - { - JsComponentReference ??= await CoreJsModule.InvokeAsync( - "getJsComponent", CancellationTokenSource.Token, Id); - } - catch (JSException) - { - // this is expected if the component is not yet built - } - - if (JsComponentReference is null) - { - return Layer; - } - - Layer? result = await JsComponentReference.InvokeAsync( - "getLayer", CancellationTokenSource.Token); - - if (result is not null) - { - if (Layer is not null) - { - result.Id = Layer.Id; - } - result.UpdateGeoBlazorReferences(CoreJsModule!, ProJsModule, View, this, Layer); - -#pragma warning disable BL0005 - Layer = result; -#pragma warning restore BL0005 - ModifiedParameters[nameof(Layer)] = Layer; - } - - return Layer; - } #endregion diff --git a/src/dymaptic.GeoBlazor.Core/Components/Graphic.cs b/src/dymaptic.GeoBlazor.Core/Components/Graphic.cs index 8c5e21c29..90e482011 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/Graphic.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/Graphic.cs @@ -196,7 +196,7 @@ public Graphic( /// Retrieves the from the rendered graphic. /// [CodeGenerationIgnore] - public Task GetLayer() + public override Task GetLayer() { return Task.FromResult(Parent as Layer); } diff --git a/src/dymaptic.GeoBlazor.Core/Components/LayerSearchSource.gb.cs b/src/dymaptic.GeoBlazor.Core/Components/LayerSearchSource.gb.cs index 4770ff324..178abd4a4 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/LayerSearchSource.gb.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/LayerSearchSource.gb.cs @@ -335,50 +335,6 @@ public LayerSearchSource( return ExactMatch; } - /// - /// Asynchronously retrieve the current value of the Layer property. - /// - public async Task GetLayer() - { - if (CoreJsModule is null) - { - return Layer; - } - - try - { - JsComponentReference ??= await CoreJsModule.InvokeAsync( - "getJsComponent", CancellationTokenSource.Token, Id); - } - catch (JSException) - { - // this is expected if the component is not yet built - } - - if (JsComponentReference is null) - { - return Layer; - } - - Layer? result = await JsComponentReference.InvokeAsync( - "getLayer", CancellationTokenSource.Token); - - if (result is not null) - { - if (Layer is not null) - { - result.Id = Layer.Id; - } - result.UpdateGeoBlazorReferences(CoreJsModule!, ProJsModule, View, this, Layer); - -#pragma warning disable BL0005 - Layer = result; -#pragma warning restore BL0005 - ModifiedParameters[nameof(Layer)] = Layer; - } - - return Layer; - } /// /// Asynchronously retrieve the current value of the Name property. @@ -653,48 +609,6 @@ await CoreJsModule.InvokeVoidAsync("setProperty", CancellationTokenSource.Token, JsComponentReference, "exactMatch", value); } - /// - /// Asynchronously set the value of the Layer property after render. - /// - /// - /// The value to set. - /// - public async Task SetLayer(Layer? value) - { - if (value is not null) - { - value.UpdateGeoBlazorReferences(CoreJsModule!, ProJsModule, View, this, Layer); - } - -#pragma warning disable BL0005 - Layer = value; -#pragma warning restore BL0005 - ModifiedParameters[nameof(Layer)] = value; - - if (CoreJsModule is null) - { - return; - } - - try - { - JsComponentReference ??= await CoreJsModule.InvokeAsync( - "getJsComponent", CancellationTokenSource.Token, Id); - } - catch (JSException) - { - // this is expected if the component is not yet built - } - - if (JsComponentReference is null) - { - return; - } - - await CoreJsModule.InvokeVoidAsync("setProperty", CancellationTokenSource.Token, - JsComponentReference, "layer", value); - } - /// /// Asynchronously set the value of the Name property after render. /// diff --git a/src/dymaptic.GeoBlazor.Core/Components/LegendLayerInfos.gb.cs b/src/dymaptic.GeoBlazor.Core/Components/LegendLayerInfos.gb.cs index fcce3af86..d2368b2d5 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/LegendLayerInfos.gb.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/LegendLayerInfos.gb.cs @@ -37,50 +37,6 @@ public partial class LegendLayerInfos : MapComponent #region Property Getters - /// - /// Asynchronously retrieve the current value of the Layer property. - /// - public async Task GetLayer() - { - if (CoreJsModule is null) - { - return Layer; - } - - try - { - JsComponentReference ??= await CoreJsModule.InvokeAsync( - "getJsComponent", CancellationTokenSource.Token, Id); - } - catch (JSException) - { - // this is expected if the component is not yet built - } - - if (JsComponentReference is null) - { - return Layer; - } - - Layer? result = await JsComponentReference.InvokeAsync( - "getLayer", CancellationTokenSource.Token); - - if (result is not null) - { - if (Layer is not null) - { - result.Id = Layer.Id; - } - result.UpdateGeoBlazorReferences(CoreJsModule!, ProJsModule, View, this, Layer); - -#pragma warning disable BL0005 - Layer = result; -#pragma warning restore BL0005 - ModifiedParameters[nameof(Layer)] = Layer; - } - - return Layer; - } /// /// Asynchronously retrieve the current value of the SublayerIds property. @@ -164,48 +120,6 @@ public partial class LegendLayerInfos : MapComponent #region Property Setters - /// - /// Asynchronously set the value of the Layer property after render. - /// - /// - /// The value to set. - /// - public async Task SetLayer(Layer? value) - { - if (value is not null) - { - value.UpdateGeoBlazorReferences(CoreJsModule!, ProJsModule, View, this, Layer); - } - -#pragma warning disable BL0005 - Layer = value; -#pragma warning restore BL0005 - ModifiedParameters[nameof(Layer)] = value; - - if (CoreJsModule is null) - { - return; - } - - try - { - JsComponentReference ??= await CoreJsModule.InvokeAsync( - "getJsComponent", CancellationTokenSource.Token, Id); - } - catch (JSException) - { - // this is expected if the component is not yet built - } - - if (JsComponentReference is null) - { - return; - } - - await CoreJsModule.InvokeVoidAsync("setProperty", CancellationTokenSource.Token, - JsComponentReference, "layer", value); - } - /// /// Asynchronously set the value of the SublayerIds property after render. /// diff --git a/src/dymaptic.GeoBlazor.Core/Components/LegendViewModelLayerInfo.gb.cs b/src/dymaptic.GeoBlazor.Core/Components/LegendViewModelLayerInfo.gb.cs index fd4ec2b20..4aed9fabf 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/LegendViewModelLayerInfo.gb.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/LegendViewModelLayerInfo.gb.cs @@ -66,44 +66,6 @@ public LegendViewModelLayerInfo( #region Property Getters - /// - /// Asynchronously retrieve the current value of the Layer property. - /// - public async Task GetLayer() - { - if (CoreJsModule is null) - { - return Layer; - } - - try - { - JsComponentReference ??= await CoreJsModule.InvokeAsync( - "getJsComponent", CancellationTokenSource.Token, Id); - } - catch (JSException) - { - // this is expected if the component is not yet built - } - - if (JsComponentReference is null) - { - return Layer; - } - - // get the property value - Layer? result = await JsComponentReference!.InvokeAsync("getProperty", - CancellationTokenSource.Token, "layer"); - if (result is not null) - { -#pragma warning disable BL0005 - Layer = result; -#pragma warning restore BL0005 - ModifiedParameters[nameof(Layer)] = Layer; - } - - return Layer; - } /// /// Asynchronously retrieve the current value of the SublayerIds property. @@ -187,43 +149,6 @@ public LegendViewModelLayerInfo( #region Property Setters - /// - /// Asynchronously set the value of the Layer property after render. - /// - /// - /// The value to set. - /// - public async Task SetLayer(Layer? value) - { -#pragma warning disable BL0005 - Layer = value; -#pragma warning restore BL0005 - ModifiedParameters[nameof(Layer)] = value; - - if (CoreJsModule is null) - { - return; - } - - try - { - JsComponentReference ??= await CoreJsModule.InvokeAsync( - "getJsComponent", CancellationTokenSource.Token, Id); - } - catch (JSException) - { - // this is expected if the component is not yet built - } - - if (JsComponentReference is null) - { - return; - } - - await CoreJsModule.InvokeVoidAsync("setProperty", CancellationTokenSource.Token, - JsComponentReference, "layer", value); - } - /// /// Asynchronously set the value of the SublayerIds property after render. /// diff --git a/src/dymaptic.GeoBlazor.Core/Components/LegendViewModelLayerInfos.gb.cs b/src/dymaptic.GeoBlazor.Core/Components/LegendViewModelLayerInfos.gb.cs index 7e7e23bf0..7cbebc635 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/LegendViewModelLayerInfos.gb.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/LegendViewModelLayerInfos.gb.cs @@ -58,50 +58,6 @@ public LegendViewModelLayerInfos( #region Property Getters - /// - /// Asynchronously retrieve the current value of the Layer property. - /// - public async Task GetLayer() - { - if (CoreJsModule is null) - { - return Layer; - } - - try - { - JsComponentReference ??= await CoreJsModule.InvokeAsync( - "getJsComponent", CancellationTokenSource.Token, Id); - } - catch (JSException) - { - // this is expected if the component is not yet built - } - - if (JsComponentReference is null) - { - return Layer; - } - - Layer? result = await JsComponentReference.InvokeAsync( - "getLayer", CancellationTokenSource.Token); - - if (result is not null) - { - if (Layer is not null) - { - result.Id = Layer.Id; - } - result.UpdateGeoBlazorReferences(CoreJsModule!, ProJsModule, View, this, Layer); - -#pragma warning disable BL0005 - Layer = result; -#pragma warning restore BL0005 - ModifiedParameters[nameof(Layer)] = Layer; - } - - return Layer; - } /// /// Asynchronously retrieve the current value of the Title property. @@ -146,48 +102,6 @@ public LegendViewModelLayerInfos( #region Property Setters - /// - /// Asynchronously set the value of the Layer property after render. - /// - /// - /// The value to set. - /// - public async Task SetLayer(Layer? value) - { - if (value is not null) - { - value.UpdateGeoBlazorReferences(CoreJsModule!, ProJsModule, View, this, Layer); - } - -#pragma warning disable BL0005 - Layer = value; -#pragma warning restore BL0005 - ModifiedParameters[nameof(Layer)] = value; - - if (CoreJsModule is null) - { - return; - } - - try - { - JsComponentReference ??= await CoreJsModule.InvokeAsync( - "getJsComponent", CancellationTokenSource.Token, Id); - } - catch (JSException) - { - // this is expected if the component is not yet built - } - - if (JsComponentReference is null) - { - return; - } - - await CoreJsModule.InvokeVoidAsync("setProperty", CancellationTokenSource.Token, - JsComponentReference, "layer", value); - } - /// /// Asynchronously set the value of the Title property after render. /// diff --git a/src/dymaptic.GeoBlazor.Core/Components/ListItem.gb.cs b/src/dymaptic.GeoBlazor.Core/Components/ListItem.gb.cs index 12dee0527..00d597273 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/ListItem.gb.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/ListItem.gb.cs @@ -502,50 +502,6 @@ public ListItem() return Incompatible; } - /// - /// Asynchronously retrieve the current value of the Layer property. - /// - public async Task GetLayer() - { - if (CoreJsModule is null) - { - return Layer; - } - - try - { - JsComponentReference ??= await CoreJsModule.InvokeAsync( - "getJsComponent", CancellationTokenSource.Token, Id); - } - catch (JSException) - { - // this is expected if the component is not yet built - } - - if (JsComponentReference is null) - { - return Layer; - } - - Layer? result = await JsComponentReference.InvokeAsync( - "getLayer", CancellationTokenSource.Token); - - if (result is not null) - { - if (Layer is not null) - { - result.Id = Layer.Id; - } - result.UpdateGeoBlazorReferences(CoreJsModule!, ProJsModule, View, this, Layer); - -#pragma warning disable BL0005 - Layer = result; -#pragma warning restore BL0005 - ModifiedParameters[nameof(Layer)] = Layer; - } - - return Layer; - } /// /// Asynchronously retrieve the current value of the LayerView property. @@ -1058,48 +1014,6 @@ await CoreJsModule.InvokeVoidAsync("setProperty", CancellationTokenSource.Token, JsComponentReference, "hidden", value); } - /// - /// Asynchronously set the value of the Layer property after render. - /// - /// - /// The value to set. - /// - public async Task SetLayer(Layer? value) - { - if (value is not null) - { - value.UpdateGeoBlazorReferences(CoreJsModule!, ProJsModule, View, this, Layer); - } - -#pragma warning disable BL0005 - Layer = value; -#pragma warning restore BL0005 - ModifiedParameters[nameof(Layer)] = value; - - if (CoreJsModule is null) - { - return; - } - - try - { - JsComponentReference ??= await CoreJsModule.InvokeAsync( - "getJsComponent", CancellationTokenSource.Token, Id); - } - catch (JSException) - { - // this is expected if the component is not yet built - } - - if (JsComponentReference is null) - { - return; - } - - await JsComponentReference.InvokeVoidAsync("setLayer", - CancellationTokenSource.Token, value); - } - /// /// Asynchronously set the value of the ListModeDisabled property after render. /// diff --git a/src/dymaptic.GeoBlazor.Core/Components/MapComponent.razor.cs b/src/dymaptic.GeoBlazor.Core/Components/MapComponent.razor.cs index 81c9c5ee0..95f68aad5 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/MapComponent.razor.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/MapComponent.razor.cs @@ -494,6 +494,95 @@ await CoreJsModule.InvokeVoidAsync("setProperty", CancellationTokenSource.Token, return currentValue; } } + + /// + /// Asynchronously retrieve the current value of the Layer property. + /// + [CodeGenerationIgnore] + public virtual async Task GetLayer() + { + if (CoreJsModule is null) + { + return Layer; + } + + try + { + JsComponentReference ??= await CoreJsModule.InvokeAsync( + "getJsComponent", CancellationTokenSource.Token, Id); + } + catch (JSException) + { + // this is expected if the component is not yet built + } + + if (JsComponentReference is null) + { + return Layer; + } + + Layer? result = + await JsComponentReference.InvokeAsync("getLayer", CancellationTokenSource.Token); + + if (result is not null) + { + if (Layer is not null) + { + result.Id = Layer.Id; + } + + result.UpdateGeoBlazorReferences(CoreJsModule!, ProJsModule, View, this, Layer); + +#pragma warning disable BL0005 + Layer = result; +#pragma warning restore BL0005 + ModifiedParameters[nameof(Layer)] = Layer; + } + + return Layer; + } + + /// + /// Asynchronously set the value of the Layer property after render. + /// + /// + /// The value to set. + /// + public virtual async Task SetLayer(Layer? value) + { + if (value is not null) + { + value.UpdateGeoBlazorReferences(CoreJsModule!, ProJsModule, View, this, Layer); + } + +#pragma warning disable BL0005 + Layer = value; +#pragma warning restore BL0005 + ModifiedParameters[nameof(Layer)] = value; + + if (CoreJsModule is null) + { + return; + } + + try + { + JsComponentReference ??= await CoreJsModule.InvokeAsync( + "getJsComponent", CancellationTokenSource.Token, Id); + } + catch (JSException) + { + // this is expected if the component is not yet built + } + + if (JsComponentReference is null) + { + return; + } + + await JsComponentReference.InvokeVoidAsync("setLayer", + CancellationTokenSource.Token, value); + } /// /// Called from to "Register" the current component with its parent. @@ -1023,9 +1112,20 @@ protected override async Task OnAfterRenderAsync(bool firstRender) AbortManager ??= new AbortManager(CoreJsModule); } + // A Layer can be resolved after this component's JS object was built (e.g. a layer added to + // the map after render and bound by id). buildJs* could not bind it then, so push the + // resolved layer to the JS component once it exists. + if (!_layerSyncedToJs && Layer is {} graphicsLayer && JsComponentReference is not null) + { + _layerSyncedToJs = true; + await SetLayer(graphicsLayer); + } + IsRenderedBlazorComponent = true; } + private bool _layerSyncedToJs; + /// /// Tells the to completely re-render. /// diff --git a/src/dymaptic.GeoBlazor.Core/Scripts/arcGisJsInterop.ts b/src/dymaptic.GeoBlazor.Core/Scripts/arcGisJsInterop.ts index 942cb3d45..1320f3904 100644 --- a/src/dymaptic.GeoBlazor.Core/Scripts/arcGisJsInterop.ts +++ b/src/dymaptic.GeoBlazor.Core/Scripts/arcGisJsInterop.ts @@ -125,7 +125,7 @@ export let queryLayer: FeatureLayer; export let blazorServer: boolean = false; export let ProtoGraphicCollection; export let ProtoViewHitCollection; -export let geometryEngine: GeometryEngineWrapper = new GeometryEngineWrapper(false); +export let geometryEngine: GeometryEngineWrapper = new GeometryEngineWrapper(true); export let projectionEngine: ProjectionWrapper = new ProjectionWrapper(false); // region module variables diff --git a/src/dymaptic.GeoBlazor.Core/Scripts/baseComponent.ts b/src/dymaptic.GeoBlazor.Core/Scripts/baseComponent.ts index abf1eae12..ffd0dc5cc 100644 --- a/src/dymaptic.GeoBlazor.Core/Scripts/baseComponent.ts +++ b/src/dymaptic.GeoBlazor.Core/Scripts/baseComponent.ts @@ -30,6 +30,27 @@ export default class BaseComponent implements IPropertyWrapper { getProperty(prop: string) { return this.component[prop]; } + + async setLayer(value: any): Promise { + if ('layer' in this.component) { + let { buildJsLayer } = await import('./layer'); + this.component.layer = await buildJsLayer(value, this.layerId, this.viewId); + } + } + + async getLayer(): Promise { + if (hasValue(this.component.loadStatus) && this.component.loadStatus === 'not-loaded') { + await this.component.load(); + } + + if (!hasValue(this.component.layer)) { + return null; + } + + let { buildDotNetLayer } = await import('./layer'); + return await buildDotNetLayer(this.component.layer, this.layerId, this.viewId); + } + unwrap() { return this.component; } diff --git a/src/dymaptic.GeoBlazor.Core/Scripts/cSVLayerView.gb.ts b/src/dymaptic.GeoBlazor.Core/Scripts/cSVLayerView.gb.ts index 961c9874f..351acf06f 100644 --- a/src/dymaptic.GeoBlazor.Core/Scripts/cSVLayerView.gb.ts +++ b/src/dymaptic.GeoBlazor.Core/Scripts/cSVLayerView.gb.ts @@ -171,15 +171,6 @@ export default class CSVLayerViewGenerated extends BaseComponent { this.component.highlightOptions = await buildJsHighlightOptions(value); } - async getLayer(): Promise { - if (!hasValue(this.component.layer)) { - return null; - } - - let { buildDotNetLayer } = await import('./layer'); - return await buildDotNetLayer(this.component.layer, this.layerId, this.viewId); - } - } diff --git a/src/dymaptic.GeoBlazor.Core/Scripts/featureLayerView.gb.ts b/src/dymaptic.GeoBlazor.Core/Scripts/featureLayerView.gb.ts index 53f5e8055..825e5298f 100644 --- a/src/dymaptic.GeoBlazor.Core/Scripts/featureLayerView.gb.ts +++ b/src/dymaptic.GeoBlazor.Core/Scripts/featureLayerView.gb.ts @@ -109,15 +109,6 @@ export default class FeatureLayerViewGenerated extends BaseComponent { this.component.highlightOptions = await buildJsHighlightOptions(value); } - async getLayer(): Promise { - if (!hasValue(this.component.layer)) { - return null; - } - - let { buildDotNetFeatureLayer } = await import('./featureLayer'); - return await buildDotNetFeatureLayer(this.component.layer, this.layerId, this.viewId); - } - } diff --git a/src/dymaptic.GeoBlazor.Core/Scripts/geoJSONLayerView.gb.ts b/src/dymaptic.GeoBlazor.Core/Scripts/geoJSONLayerView.gb.ts index 6726aca77..7052564da 100644 --- a/src/dymaptic.GeoBlazor.Core/Scripts/geoJSONLayerView.gb.ts +++ b/src/dymaptic.GeoBlazor.Core/Scripts/geoJSONLayerView.gb.ts @@ -171,15 +171,6 @@ export default class GeoJSONLayerViewGenerated extends BaseComponent { this.component.highlightOptions = await buildJsHighlightOptions(value); } - async getLayer(): Promise { - if (!hasValue(this.component.layer)) { - return null; - } - - let { buildDotNetLayer } = await import('./layer'); - return await buildDotNetLayer(this.component.layer, this.layerId, this.viewId); - } - } diff --git a/src/dymaptic.GeoBlazor.Core/Scripts/geoRSSLayerView.gb.ts b/src/dymaptic.GeoBlazor.Core/Scripts/geoRSSLayerView.gb.ts index 5daceac95..767f1b009 100644 --- a/src/dymaptic.GeoBlazor.Core/Scripts/geoRSSLayerView.gb.ts +++ b/src/dymaptic.GeoBlazor.Core/Scripts/geoRSSLayerView.gb.ts @@ -40,17 +40,6 @@ export default class GeoRSSLayerViewGenerated extends BaseComponent { return await this.component.when(callback, errback); } - - // region properties - - async getLayer(): Promise { - if (!hasValue(this.component.layer)) { - return null; - } - - let { buildDotNetLayer } = await import('./layer'); - return await buildDotNetLayer(this.component.layer, this.layerId, this.viewId); - } } diff --git a/src/dymaptic.GeoBlazor.Core/Scripts/graphicsLayerView.gb.ts b/src/dymaptic.GeoBlazor.Core/Scripts/graphicsLayerView.gb.ts index e613b22d4..317687b0c 100644 --- a/src/dymaptic.GeoBlazor.Core/Scripts/graphicsLayerView.gb.ts +++ b/src/dymaptic.GeoBlazor.Core/Scripts/graphicsLayerView.gb.ts @@ -73,15 +73,6 @@ export default class GraphicsLayerViewGenerated extends BaseComponent { this.component.highlightOptions = await buildJsHighlightOptions(value); } - async getLayer(): Promise { - if (!hasValue(this.component.layer)) { - return null; - } - - let { buildDotNetLayer } = await import('./layer'); - return await buildDotNetLayer(this.component.layer, this.layerId, this.viewId); - } - } diff --git a/src/dymaptic.GeoBlazor.Core/Scripts/imageryLayerView.gb.ts b/src/dymaptic.GeoBlazor.Core/Scripts/imageryLayerView.gb.ts index 6903c2db8..dc77063a2 100644 --- a/src/dymaptic.GeoBlazor.Core/Scripts/imageryLayerView.gb.ts +++ b/src/dymaptic.GeoBlazor.Core/Scripts/imageryLayerView.gb.ts @@ -77,15 +77,6 @@ export default class ImageryLayerViewGenerated extends BaseComponent { this.component.highlightOptions = await buildJsHighlightOptions(value); } - async getLayer(): Promise { - if (!hasValue(this.component.layer)) { - return null; - } - - let { buildDotNetLayer } = await import('./layer'); - return await buildDotNetLayer(this.component.layer, this.layerId, this.viewId); - } - async getPixelData(): Promise { if (!hasValue(this.component.pixelData)) { return null; diff --git a/src/dymaptic.GeoBlazor.Core/Scripts/imageryTileLayerView.gb.ts b/src/dymaptic.GeoBlazor.Core/Scripts/imageryTileLayerView.gb.ts index 3d16accdf..3b11ee233 100644 --- a/src/dymaptic.GeoBlazor.Core/Scripts/imageryTileLayerView.gb.ts +++ b/src/dymaptic.GeoBlazor.Core/Scripts/imageryTileLayerView.gb.ts @@ -45,17 +45,6 @@ export default class ImageryTileLayerViewGenerated extends BaseComponent { errback); } - // region properties - - async getLayer(): Promise { - if (!hasValue(this.component.layer)) { - return null; - } - - let { buildDotNetLayer } = await import('./layer'); - return await buildDotNetLayer(this.component.layer, this.layerId, this.viewId); - } - } diff --git a/src/dymaptic.GeoBlazor.Core/Scripts/kMLLayerView.gb.ts b/src/dymaptic.GeoBlazor.Core/Scripts/kMLLayerView.gb.ts index 238aec05d..452fb155d 100644 --- a/src/dymaptic.GeoBlazor.Core/Scripts/kMLLayerView.gb.ts +++ b/src/dymaptic.GeoBlazor.Core/Scripts/kMLLayerView.gb.ts @@ -79,15 +79,6 @@ export default class KMLLayerViewGenerated extends BaseComponent { return this.component.allVisiblePolylines!.map(i => buildDotNetGraphic(i, this.layerId, this.viewId)); } - async getLayer(): Promise { - if (!hasValue(this.component.layer)) { - return null; - } - - let { buildDotNetLayer } = await import('./layer'); - return await buildDotNetLayer(this.component.layer, this.layerId, this.viewId); - } - } diff --git a/src/dymaptic.GeoBlazor.Core/Scripts/listItem.gb.ts b/src/dymaptic.GeoBlazor.Core/Scripts/listItem.gb.ts index 6f348cd0f..0ca79e82a 100644 --- a/src/dymaptic.GeoBlazor.Core/Scripts/listItem.gb.ts +++ b/src/dymaptic.GeoBlazor.Core/Scripts/listItem.gb.ts @@ -87,20 +87,6 @@ export default class ListItemGenerated extends BaseComponent { this.component.children = await Promise.all(value.map(async i => await buildJsListItem(i, this.layerId, this.viewId))) as any; } - async getLayer(): Promise { - if (!hasValue(this.component.layer)) { - return null; - } - - let { buildDotNetLayer } = await import('./layer'); - return await buildDotNetLayer(this.component.layer, this.layerId, this.viewId); - } - - async setLayer(value: any): Promise { - let { buildJsLayer } = await import('./layer'); - this.component.layer = await buildJsLayer(value, this.layerId, this.viewId); - } - async getLayerView(): Promise { if (!hasValue(this.component.layerView)) { return null; diff --git a/src/dymaptic.GeoBlazor.Core/Scripts/sublayer.gb.ts b/src/dymaptic.GeoBlazor.Core/Scripts/sublayer.gb.ts index ee528e74a..784c703f0 100644 --- a/src/dymaptic.GeoBlazor.Core/Scripts/sublayer.gb.ts +++ b/src/dymaptic.GeoBlazor.Core/Scripts/sublayer.gb.ts @@ -286,19 +286,6 @@ export default class SublayerGenerated extends BaseComponent { this.component.labelingInfo = await Promise.all(value.map(async i => await buildJsLabel(i, this.layerId, this.viewId))) as any; } - async getLayer(): Promise { - if (this.component.loadStatus === 'not-loaded') { - await this.component.load(); - } - - if (!hasValue(this.component.layer)) { - return null; - } - - let { buildDotNetLayer } = await import('./layer'); - return await buildDotNetLayer(this.component.layer, this.layerId, this.viewId); - } - async getObjectIdField(): Promise { if (this.component.loadStatus === 'not-loaded') { await this.component.load(); diff --git a/src/dymaptic.GeoBlazor.Core/Scripts/wFSLayerView.gb.ts b/src/dymaptic.GeoBlazor.Core/Scripts/wFSLayerView.gb.ts index facd281f4..92bce80e2 100644 --- a/src/dymaptic.GeoBlazor.Core/Scripts/wFSLayerView.gb.ts +++ b/src/dymaptic.GeoBlazor.Core/Scripts/wFSLayerView.gb.ts @@ -169,17 +169,7 @@ export default class WFSLayerViewGenerated extends BaseComponent { async setHighlightOptions(value: any): Promise { let { buildJsHighlightOptions } = await import('./highlightOptions'); this.component.highlightOptions = await buildJsHighlightOptions(value); - } - - async getLayer(): Promise { - if (!hasValue(this.component.layer)) { - return null; - } - - let { buildDotNetLayer } = await import('./layer'); - return await buildDotNetLayer(this.component.layer, this.layerId, this.viewId); - } - + } } diff --git a/src/dymaptic.GeoBlazor.Core/Serialization/GeometryConverter.cs b/src/dymaptic.GeoBlazor.Core/Serialization/GeometryConverter.cs index 6af3370b4..e7fc4929e 100644 --- a/src/dymaptic.GeoBlazor.Core/Serialization/GeometryConverter.cs +++ b/src/dymaptic.GeoBlazor.Core/Serialization/GeometryConverter.cs @@ -17,52 +17,69 @@ internal class GeometryConverter : JsonConverter return null; } + Geometry? geometry = null; + if (temp.TryGetValue("type", out object? typeValue)) { switch (typeValue?.ToString()) { case "extent": - return JsonSerializer.Deserialize(ref cloneReader, newOptions); + geometry = JsonSerializer.Deserialize(ref cloneReader, newOptions); + break; case "point": - return JsonSerializer.Deserialize(ref cloneReader, newOptions); + geometry = JsonSerializer.Deserialize(ref cloneReader, newOptions); + break; case "polygon": - return JsonSerializer.Deserialize(ref cloneReader, newOptions); + geometry = JsonSerializer.Deserialize(ref cloneReader, newOptions); + break; case "polyline": - return JsonSerializer.Deserialize(ref cloneReader, newOptions); + geometry = JsonSerializer.Deserialize(ref cloneReader, newOptions); + break; case "multipoint": // multipoint is in GeoBlazor Pro and must be loaded via Reflection Type? multipointType = Type.GetType("dymaptic.GeoBlazor.Pro.Components.Geometries.Multipoint, " + "dymaptic.GeoBlazor.Pro"); if (multipointType is not null) { - return (Geometry?)JsonSerializer.Deserialize(ref cloneReader, multipointType, newOptions); + geometry = (Geometry?)JsonSerializer.Deserialize(ref cloneReader, multipointType, newOptions); } - return null; + break; } } - if (temp.ContainsKey("rings")) - { - return JsonSerializer.Deserialize(ref cloneReader, newOptions); - } - - if (temp.ContainsKey("paths")) + if (geometry is null) { - return JsonSerializer.Deserialize(ref cloneReader, newOptions); - } - - if (temp.ContainsKey("latitude") || temp.ContainsKey("x")) - { - return JsonSerializer.Deserialize(ref cloneReader, newOptions); + if (temp.ContainsKey("rings")) + { + geometry = JsonSerializer.Deserialize(ref cloneReader, newOptions); + } + else if (temp.ContainsKey("paths")) + { + geometry = JsonSerializer.Deserialize(ref cloneReader, newOptions); + } + else if (temp.ContainsKey("latitude") || temp.ContainsKey("x")) + { + geometry = JsonSerializer.Deserialize(ref cloneReader, newOptions); + } + else if (temp.ContainsKey("xmax")) + { + geometry = JsonSerializer.Deserialize(ref cloneReader, newOptions); + } } - if (temp.ContainsKey("xmax")) + // Operator results (e.g. Union) send a computed `extent`, but deserializing the concrete + // geometry type does not carry the nested Extent through; populate it explicitly so callers + // (e.g. map.GoTo(extent)) get a usable Extent. + if (geometry is not null and not Extent + && geometry.Extent is null + && temp.TryGetValue("extent", out object? extentValue) + && extentValue is JsonElement { ValueKind: JsonValueKind.Object } extentElement) { - return JsonSerializer.Deserialize(ref cloneReader, newOptions); + geometry.Extent = extentElement.Deserialize(newOptions); } - return null; + return geometry; } public override void Write(Utf8JsonWriter writer, Geometry value, JsonSerializerOptions options) diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/GeoBlazorTestClass.cs b/test/dymaptic.GeoBlazor.Core.Test.Automation/GeoBlazorTestClass.cs index 4196521a1..b83d8e32d 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Automation/GeoBlazorTestClass.cs +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/GeoBlazorTestClass.cs @@ -207,9 +207,23 @@ await _retryPipeline.ExecuteAsync(async ctx => Trace.WriteLine(messages, ProcessName.WEB_TEST); Trace.WriteLine(errors, ProcessName.WEB_TEST_ERROR); - TestConfig.FailedTests[ProcessName.WEB_TEST][testName] = $"{messages}{Environment.NewLine}{errors}"; - - Assert.Fail($"{testName} Failed: {errors}"); + // Capture the actual exception (Playwright timeout, assertion, navigation error, etc.) - not + // just browser console text, which is empty when the page fails before logging anything. Without + // this the FINAL_SUMMARY failure details were blank and the real cause was invisible. + string failureDetail = $"{ex.GetType().Name}: {ex.Message}"; + if (!string.IsNullOrWhiteSpace(messages)) + { + failureDetail += $"{Environment.NewLine}Console: {messages}"; + } + if (!string.IsNullOrWhiteSpace(errors)) + { + failureDetail += $"{Environment.NewLine}Errors: {errors}"; + } + failureDetail += $"{Environment.NewLine}{ex.StackTrace}"; + + TestConfig.FailedTests[ProcessName.WEB_TEST][testName] = failureDetail; + + Assert.Fail($"{testName} Failed: {ex.Message}"); } finally { @@ -220,8 +234,20 @@ await _retryPipeline.ExecuteAsync(async ctx => private string BuildTestUrl(string testName) { + // Map the enum to its name via a switch of compile-time nameof constants rather than interpolating + // the enum directly. Interpolation calls Enum.ToString -> EnumInfo.Create, which on every test was + // throwing BadImageFormatException/CLDB_E_INDEX_NOTFOUND (corrupt enum name metadata in this process). + // A switch on enum constants compiles to integer comparisons and needs no reflection metadata. + string renderMode = TestConfig.RenderMode switch + { + BlazorMode.Server => nameof(BlazorMode.Server), + BlazorMode.WebAssembly => nameof(BlazorMode.WebAssembly), + BlazorMode.Hybrid => nameof(BlazorMode.Hybrid), + _ => ((int)TestConfig.RenderMode).ToString() + }; + return $"{TestConfig.TestAppUrl}?testFilter={testName}&renderMode={ - TestConfig.RenderMode}{(TestConfig.ProOnly ? "&proOnly" : "")}{(TestConfig.CoreOnly ? "&coreOnly" : "")}"; + renderMode}{(TestConfig.ProOnly ? "&proOnly" : "")}{(TestConfig.CoreOnly ? "&coreOnly" : "")}"; } private async Task Setup() diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/TestConfig.cs b/test/dymaptic.GeoBlazor.Core.Test.Automation/TestConfig.cs index 85fd401ad..482b85c5c 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Automation/TestConfig.cs +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/TestConfig.cs @@ -526,15 +526,37 @@ public static async Task AssemblyCleanup() Trace.WriteLine("------------", ProcessName.FINAL_SUMMARY); } - // trim off extra timestamp from web browser and split lines - string[] errorLines = failedTest.Value.Substring(26).Split(Environment.NewLine); - Trace.WriteLine($" {testCategory} - {failedTest.Key}", ProcessName.FINAL_SUMMARY); - foreach (string errorLine in errorLines) + try + { + // Trim off the optional "[timestamp] " prefix the browser logging prepends, but + // only when present. A hard Substring(26) previously threw on short messages + // (e.g. "Test Failed"), and because the enclosing block has no catch it aborted + // the entire FAILED TEST DETAILS section, leaving it blank. + string rawError = failedTest.Value ?? string.Empty; + string trimmedError = rawError; + + if (rawError.StartsWith('[')) + { + int closeIndex = rawError.IndexOf("] ", StringComparison.Ordinal); + + if (closeIndex >= 0) + { + trimmedError = rawError[(closeIndex + 2)..]; + } + } + + foreach (string errorLine in trimmedError.Split(Environment.NewLine)) + { + Trace.WriteLine($" {errorLine}", ProcessName.FINAL_SUMMARY); + } + } + catch (Exception ex) { - Trace.WriteLine($" {errorLine}", ProcessName.FINAL_SUMMARY); + Trace.WriteLine($" (could not format failure detail: {ex.Message})", + ProcessName.FINAL_SUMMARY); } } } @@ -857,6 +879,62 @@ private static async ValueTask LaunchWebTests(ResilienceContext context) } } + // Test projects are launched with --no-build (dotnet run / dotnet test), so they must be compiled + // first. On a clean runner nothing pre-builds them, and the web app and unit-test tasks run in + // parallel, so building concurrently races on the shared ESBuild lock (AcquireBuildLock exits with + // code 1). Serialize builds through a semaphore and build each project at most once per run. + private static readonly SemaphoreSlim _buildSemaphore = new(1, 1); + private static readonly HashSet _builtProjects = []; + + private static async Task EnsureTestProjectBuilt(string projectPath, CancellationToken token) + { + await _buildSemaphore.WaitAsync(token); + + try + { + if (!_builtProjects.Add(projectPath)) + { + // Already built this run (e.g. a Polly retry of the same task). + return; + } + + string projectName = Path.GetFileNameWithoutExtension(projectPath); + Trace.WriteLine($"Building {projectName} ({_runConfig})...", ProcessName.TEST_SETUP); + + CommandResult buildResult = await Cli.Wrap("dotnet") + .WithArguments([ + "build", projectPath, + "-c", _runConfig!, + "/p:GenerateXmlComments=false", + "/p:GeneratePackage=false", + "/p:GenerateDocs=false", + "/p:DebugSymbols=true", + "/p:DebugType=portable", + "/p:UsePackageReferences=false", + "/p:ShowScriptDialogs=false" + ]) + .WithStandardOutputPipe(PipeTarget.ToDelegate(line => Trace.WriteLine(line, ProcessName.TEST_SETUP))) + .WithStandardErrorPipe(PipeTarget.ToDelegate(line => Trace.WriteLine(line, ProcessName.TEST_SETUP))) + .WithWorkingDirectory(ProjectFolder) + .WithValidation(CommandResultValidation.None) + .ExecuteAsync(token, gracefulCts.Token); + + if (buildResult.ExitCode != 0) + { + // Allow a later retry to attempt the build again. + _builtProjects.Remove(projectPath); + + throw new InvalidOperationException( + $"Failed to build {projectName} (exit code {buildResult.ExitCode}). " + + "See the TEST_SETUP output above for the build error."); + } + } + finally + { + _buildSemaphore.Release(); + } + } + private static async ValueTask LaunchUnitTests(ResilienceContext context) { Stopwatch unitTestStopwatch = Stopwatch.StartNew(); @@ -970,6 +1048,11 @@ private static async ValueTask RunUnitTests(ResilienceContext context) _ => throw new ArgumentOutOfRangeException(nameof(processName), processName, null) }; + // dotnet test runs below with --no-build, so the unit-test project must be compiled first. On a + // clean runner nothing else builds it, which previously caused "An error occurred trying to start + // process .../bin/Release/.../*.Test.Unit ... No such file or directory". + await EnsureTestProjectBuilt(testPath, context.CancellationToken); + string cmdLineApp = "dotnet"; List args = @@ -1290,11 +1373,21 @@ private static async Task StartWebApp(CancellationToken token) EnsureGeoBlazorLicenseKeyInUserSecrets(TestAppPath, licenseKey); } + // The web app reads the ArcGIS API key from configuration key "ArcGISApiKey"; without it the basemap + // never loads and the render tests fail. CI provides it as the ARCGIS_API_KEY env var, but nothing + // injected it into the web app (only the license was), so the app fell back to the placeholder in + // appsettings.Development.json. Pass it through as the "ArcGISApiKey" env var, which ASP.NET maps onto + // that config key and which overrides appsettings regardless of environment. + string? apiKey = Configuration["ARCGIS_API_KEY"]; + string cmdLineApp = "dotnet"; string[] args = [ - "run", "--no-build", "--project", TestAppPath, + // -c must precede "--" so it sets the run/build configuration: with --no-build, dotnet run + // looks for the app under bin//, and without an explicit -c it defaults to Debug and + // fails to find the Release build produced by EnsureTestProjectBuilt ("No such file"). + "run", "--no-build", "-c", _runConfig!, "--project", TestAppPath, "--urls", $"{TestAppUrl};{TestAppHttpUrl}", "--", "-c", _runConfig!, "/p:GenerateXmlComments=false", @@ -1327,6 +1420,13 @@ private static async Task StartWebApp(CancellationToken token) ]; } + // The web app is launched below with --no-build, so it must be compiled first. Nothing else in + // the test pipeline builds the test web app - the Automation project does not reference it - so on + // a clean runner (e.g. a hosted CI agent with no prior build output) the --no-build launch would + // have no assembly to run. Build it once, up front. (See EnsureTestProjectBuilt for why builds are + // serialized.) + await EnsureTestProjectBuilt(TestAppPath, token); + Trace.WriteLine($"Starting test app: {cmdLineApp} {string.Join(" ", args)}", ProcessName.WEB_APP_SERVER); bool ioExceptionThrown = false; @@ -1349,9 +1449,16 @@ private static async Task StartWebApp(CancellationToken token) .WithStandardErrorPipe(PipeTarget.ToDelegate(line => Trace.WriteLine(line, ProcessName.WEB_APP_ERROR))) .WithWorkingDirectory(ProjectFolder) + .WithEnvironmentVariables(apiKey is null + ? new Dictionary() + : new Dictionary { ["ArcGISApiKey"] = apiKey }) .ExecuteAsync(token, gracefulCts.Token); processIds.Add(commandTask.ProcessId); + // Track the web-app process so WaitForHttpResponse can fail fast if it exits before becoming + // reachable, instead of polling for the full timeout. (Previously this was only set in the + // container path, so a crash in the non-container path went undetected until the 15m timeout.) + _webTestProcessId = commandTask.ProcessId; try { @@ -1651,19 +1758,26 @@ private static async Task WaitForHttpResponse() if (testProcess is not null && testProcess.HasExited) { + int? exitCode = null; + try { - int exitCode = testProcess.ExitCode; - - if (exitCode != 0) - { - throw new ProcessExitedException($"Test process exited with code {exitCode}"); - } + exitCode = testProcess.ExitCode; } catch { // ignore - the container building process can exit silently and all is fine } + + // Read ExitCode defensively above, but throw outside the catch so a genuine non-zero + // exit actually propagates. (Previously the throw was inside the try and the bare catch + // swallowed it, so a crashed web app was never surfaced and the poll ran to timeout.) + if (exitCode is not null and not 0) + { + throw new ProcessExitedException( + $"Test web app process exited with code {exitCode} before becoming reachable. " + + "See the WEB_APP_SERVER / WEB_APP_ERROR output above for the cause."); + } } await Task.Delay(1000, Cts.Token); diff --git a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/WebMapTests.razor b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/WebMapTests.razor index e073d32c7..1fea007d7 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/WebMapTests.razor +++ b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/WebMapTests.razor @@ -49,6 +49,7 @@ } } + [CICondition(ConditionMode.Exclude)] [TestMethod] public async Task TestDymapticEnterpriseWebMaps(Action renderHandler) {