From 586465da67999519461e26b83ddd6e2dfebb9e94 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:40:10 +1100 Subject: [PATCH 1/3] Every project builds and tests on both .NET 9 and .NET 10 - Directory.Build.props targets net9.0;net10.0. The Blazor WASM site and the h5 netstandard2.1 project opt out. - Mvc.Testing is referenced per framework; Reporting integration tests fixed for the analyzer findings the new net9.0 build surfaced. - Native AOT DataProviderMigrate publishes from net10.0 (LTS). - make test gates coverage on the lowest per-framework report. --- .github/workflows/ci.yml | 4 +- .../release-dotnet-aot-multiplatform.yml | 2 +- DataProvider/DataProvider/DataProvider.csproj | 1 - ...blesite.DataProvider.Example.FSharp.fsproj | 1 - ...mblesite.DataProvider.SQLite.FSharp.fsproj | 1 - Directory.Build.props | 3 +- .../Nimblesite.Lql.Browser.csproj | 1 - ....Lql.TypeProvider.FSharp.Tests.Data.csproj | 1 - ...esite.Lql.TypeProvider.FSharp.Tests.fsproj | 1 - .../Nimblesite.Lql.TypeProvider.FSharp.fsproj | 1 - .../Nimblesite.Lql.Website.csproj | 2 + Makefile | 29 +++++---- ...blesite.Reporting.Integration.Tests.csproj | 13 +++- .../ReportingApiTests.cs | 40 ++++++------ .../ReportingE2EFixture.cs | 63 ++++++++++--------- .../ReportingPlatformApiTests.cs | 20 +++--- ... ReportingPlatformCollectionDefinition.cs} | 2 +- .../Nimblesite.Reporting.React.csproj | 2 + .../Nimblesite.Sync.Http.Tests.csproj | 12 +++- .../Nimblesite.Sync.Integration.Tests.csproj | 15 ++++- Website/src/docs/getting-started.md | 2 +- Website/src/docs/installation.md | 4 +- docs/specs/ci-spec.md | 21 +++++++ docs/specs/codegen-cli-tool.md | 4 +- 24 files changed, 155 insertions(+), 90 deletions(-) rename Reporting/Nimblesite.Reporting.Integration.Tests/{ReportingPlatformCollection.cs => ReportingPlatformCollectionDefinition.cs} (86%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79edd6e9..585f5ee1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ concurrency: cancel-in-progress: true env: - # Projects target a mix of net9.0, net10.0 and netstandard2.1, so both SDKs + # Projects target both net9.0 and net10.0 ([BUILD-MULTI-TARGET]), so both SDKs # are installed everywhere .NET builds (see the setup-dotnet steps below). DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true DOTNET_CLI_TELEMETRY_OPTOUT: true @@ -113,7 +113,7 @@ jobs: - name: Native AOT smoke tests (DataProviderMigrate) env: - DATAPROVIDERMIGRATE_AOT_BIN: ${{ github.workspace }}/Migration/DataProviderMigrate/bin/Release/net9.0/linux-x64/publish/DataProviderMigrate + DATAPROVIDERMIGRATE_AOT_BIN: ${{ github.workspace }}/Migration/DataProviderMigrate/bin/Release/net10.0/linux-x64/publish/DataProviderMigrate run: >- dotnet test Migration/Nimblesite.DataProvider.Migration.Tests --filter "FullyQualifiedName~NativeAotMigrateSmokeTests" diff --git a/.github/workflows/release-dotnet-aot-multiplatform.yml b/.github/workflows/release-dotnet-aot-multiplatform.yml index 03653d3e..0adfccc9 100644 --- a/.github/workflows/release-dotnet-aot-multiplatform.yml +++ b/.github/workflows/release-dotnet-aot-multiplatform.yml @@ -76,7 +76,7 @@ jobs: run: | set -euo pipefail dotnet publish "${{ inputs.project }}" \ - -c Release -r "${{ matrix.rid }}" \ + -c Release -f net10.0 -r "${{ matrix.rid }}" \ -p:PublishAot=true -p:InvariantGlobalization=true \ --self-contained \ -o "${GITHUB_WORKSPACE}/aot-out" diff --git a/DataProvider/DataProvider/DataProvider.csproj b/DataProvider/DataProvider/DataProvider.csproj index 8888130b..9d47412d 100644 --- a/DataProvider/DataProvider/DataProvider.csproj +++ b/DataProvider/DataProvider/DataProvider.csproj @@ -1,7 +1,6 @@ Exe - net10.0 enable enable false diff --git a/DataProvider/Nimblesite.DataProvider.Example.FSharp/Nimblesite.DataProvider.Example.FSharp.fsproj b/DataProvider/Nimblesite.DataProvider.Example.FSharp/Nimblesite.DataProvider.Example.FSharp.fsproj index f488c3e2..54a89eb9 100644 --- a/DataProvider/Nimblesite.DataProvider.Example.FSharp/Nimblesite.DataProvider.Example.FSharp.fsproj +++ b/DataProvider/Nimblesite.DataProvider.Example.FSharp/Nimblesite.DataProvider.Example.FSharp.fsproj @@ -3,7 +3,6 @@ Exe false - net9.0 true preview false diff --git a/DataProvider/Nimblesite.DataProvider.SQLite.FSharp/Nimblesite.DataProvider.SQLite.FSharp.fsproj b/DataProvider/Nimblesite.DataProvider.SQLite.FSharp/Nimblesite.DataProvider.SQLite.FSharp.fsproj index 0212d73d..d3c60f45 100644 --- a/DataProvider/Nimblesite.DataProvider.SQLite.FSharp/Nimblesite.DataProvider.SQLite.FSharp.fsproj +++ b/DataProvider/Nimblesite.DataProvider.SQLite.FSharp/Nimblesite.DataProvider.SQLite.FSharp.fsproj @@ -3,7 +3,6 @@ Exe false - net9.0 true preview false diff --git a/Directory.Build.props b/Directory.Build.props index 34c7ff91..827c6a9d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -16,7 +16,8 @@ false README.md true - net9.0 + + net9.0;net10.0 latest enable enable diff --git a/Lql/Nimblesite.Lql.Browser/Nimblesite.Lql.Browser.csproj b/Lql/Nimblesite.Lql.Browser/Nimblesite.Lql.Browser.csproj index 54321947..c88d99e3 100644 --- a/Lql/Nimblesite.Lql.Browser/Nimblesite.Lql.Browser.csproj +++ b/Lql/Nimblesite.Lql.Browser/Nimblesite.Lql.Browser.csproj @@ -2,7 +2,6 @@ WinExe false - net9.0 enable true app.manifest diff --git a/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data.csproj b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data.csproj index 0772c5d8..3ee27a53 100644 --- a/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data.csproj +++ b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data.csproj @@ -1,6 +1,5 @@ - net9.0 enable enable true diff --git a/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/Nimblesite.Lql.TypeProvider.FSharp.Tests.fsproj b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/Nimblesite.Lql.TypeProvider.FSharp.Tests.fsproj index 7aea85ef..a1e45949 100644 --- a/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/Nimblesite.Lql.TypeProvider.FSharp.Tests.fsproj +++ b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/Nimblesite.Lql.TypeProvider.FSharp.Tests.fsproj @@ -1,7 +1,6 @@ - net9.0 preview false true diff --git a/Lql/Nimblesite.Lql.TypeProvider.FSharp/Nimblesite.Lql.TypeProvider.FSharp.fsproj b/Lql/Nimblesite.Lql.TypeProvider.FSharp/Nimblesite.Lql.TypeProvider.FSharp.fsproj index e7753329..9700af1f 100644 --- a/Lql/Nimblesite.Lql.TypeProvider.FSharp/Nimblesite.Lql.TypeProvider.FSharp.fsproj +++ b/Lql/Nimblesite.Lql.TypeProvider.FSharp/Nimblesite.Lql.TypeProvider.FSharp.fsproj @@ -1,7 +1,6 @@ - net9.0 true preview false diff --git a/Lql/Nimblesite.Lql.Website/Nimblesite.Lql.Website.csproj b/Lql/Nimblesite.Lql.Website/Nimblesite.Lql.Website.csproj index 78b604aa..7e8279ab 100644 --- a/Lql/Nimblesite.Lql.Website/Nimblesite.Lql.Website.csproj +++ b/Lql/Nimblesite.Lql.Website/Nimblesite.Lql.Website.csproj @@ -1,5 +1,7 @@ + + net9.0 enable enable diff --git a/Makefile b/Makefile index 5c810c4e..761fde8d 100644 --- a/Makefile +++ b/Makefile @@ -208,16 +208,21 @@ _test_dotnet: echo "FAIL [$$SRC_KEY]: Tests failed ($$test_proj)"; \ exit 1; \ fi; \ - COBERTURA=$$(find "$$test_proj/TestResults" -name "coverage.cobertura.xml" -type f 2>/dev/null | head -1); \ - if [ -z "$$COBERTURA" ]; then \ + COBERTURA_FILES=$$(find "$$test_proj/TestResults" -name "coverage.cobertura.xml" -type f 2>/dev/null); \ + if [ -z "$$COBERTURA_FILES" ]; then \ echo "FAIL [$$SRC_KEY]: No coverage file produced ($$test_proj)"; \ exit 1; \ fi; \ - LINE_RATE=$$(sed -n 's/.*line-rate="\([0-9.]*\)".*/\1/p' "$$COBERTURA" | head -1); \ - if [ -z "$$LINE_RATE" ]; then \ - echo "FAIL [$$SRC_KEY]: Could not parse line-rate from $$COBERTURA"; \ - exit 1; \ - fi; \ + LINE_RATE=""; \ + for COBERTURA in $$COBERTURA_FILES; do \ + RATE=$$(sed -n 's/.*line-rate="\([0-9.]*\)".*/\1/p' "$$COBERTURA" | head -1); \ + if [ -z "$$RATE" ]; then \ + echo "FAIL [$$SRC_KEY]: Could not parse line-rate from $$COBERTURA"; \ + exit 1; \ + fi; \ + echo " Coverage report $$COBERTURA: line-rate $$RATE"; \ + if [ -z "$$LINE_RATE" ] || [ "$$(echo "$$RATE < $$LINE_RATE" | bc -l)" = "1" ]; then LINE_RATE=$$RATE; fi; \ + done; \ COVERAGE=$$(echo "$$LINE_RATE * 100" | bc -l); \ COVERAGE_FMT=$$(printf "%.2f" $$COVERAGE); \ echo ""; \ @@ -277,24 +282,26 @@ _setup_dotnet: # as a black-box smoke test against a throwaway SQLite database. Implements # [MIG-AOT-CI]. RID auto-detects per platform; override with `make aot RID=...`. AOT_PROJ := Migration/DataProviderMigrate/DataProviderMigrate.csproj +# The native binary is self-contained, so it ships one runtime: .NET 10 (LTS). [BUILD-MULTI-TARGET] +AOT_TFM := net10.0 ifeq ($(OS),Windows_NT) RID ?= win-x64 -AOT_EXE := Migration/DataProviderMigrate/bin/Release/net9.0/$(RID)/publish/DataProviderMigrate.exe +AOT_EXE := Migration/DataProviderMigrate/bin/Release/$(AOT_TFM)/$(RID)/publish/DataProviderMigrate.exe else ifeq ($(shell uname -s),Darwin) ifeq ($(shell uname -m),arm64) RID ?= osx-arm64 else RID ?= osx-x64 endif -AOT_EXE := Migration/DataProviderMigrate/bin/Release/net9.0/$(RID)/publish/DataProviderMigrate +AOT_EXE := Migration/DataProviderMigrate/bin/Release/$(AOT_TFM)/$(RID)/publish/DataProviderMigrate else RID ?= linux-x64 -AOT_EXE := Migration/DataProviderMigrate/bin/Release/net9.0/$(RID)/publish/DataProviderMigrate +AOT_EXE := Migration/DataProviderMigrate/bin/Release/$(AOT_TFM)/$(RID)/publish/DataProviderMigrate endif _aot_dotnet: @echo "==> Publishing Native AOT ($(RID))..." - dotnet publish $(AOT_PROJ) -c Release -r $(RID) -p:PublishAot=true --self-contained $(DOTNET_BUILD_FLAGS) + dotnet publish $(AOT_PROJ) -c Release -f $(AOT_TFM) -r $(RID) -p:PublishAot=true --self-contained $(DOTNET_BUILD_FLAGS) @echo "==> Native smoke test: migrate example schema to SQLite..." $(AOT_EXE) migrate --schema Migration/DataProviderMigrate/example-schema.yaml \ --output $(AOT_SMOKE_DB) --provider sqlite diff --git a/Reporting/Nimblesite.Reporting.Integration.Tests/Nimblesite.Reporting.Integration.Tests.csproj b/Reporting/Nimblesite.Reporting.Integration.Tests/Nimblesite.Reporting.Integration.Tests.csproj index 6c5124e3..b49e1155 100644 --- a/Reporting/Nimblesite.Reporting.Integration.Tests/Nimblesite.Reporting.Integration.Tests.csproj +++ b/Reporting/Nimblesite.Reporting.Integration.Tests/Nimblesite.Reporting.Integration.Tests.csproj @@ -1,6 +1,5 @@ - net10.0 Library true false @@ -17,7 +16,17 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + + + diff --git a/Reporting/Nimblesite.Reporting.Integration.Tests/ReportingApiTests.cs b/Reporting/Nimblesite.Reporting.Integration.Tests/ReportingApiTests.cs index 0193140d..dc101807 100644 --- a/Reporting/Nimblesite.Reporting.Integration.Tests/ReportingApiTests.cs +++ b/Reporting/Nimblesite.Reporting.Integration.Tests/ReportingApiTests.cs @@ -27,7 +27,7 @@ public async Task ListReports_ReturnsReportList_FromRealApi() using var client = ReportingE2EFixture.CreateApiClient(); // Act - var response = await client.GetAsync($"{_fixture.ApiUrl}/api/reports"); + var response = await client.GetAsync(new Uri($"{_fixture.ApiUrl}/api/reports")); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -58,7 +58,9 @@ public async Task GetReport_ReturnsReportMetadata_WithoutConnectionStrings() using var client = ReportingE2EFixture.CreateApiClient(); // Act - var response = await client.GetAsync($"{_fixture.ApiUrl}/api/reports/e2e-products"); + var response = await client.GetAsync( + new Uri($"{_fixture.ApiUrl}/api/reports/e2e-products") + ); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -97,7 +99,7 @@ public async Task GetReport_NonexistentId_Returns404() using var client = ReportingE2EFixture.CreateApiClient(); // Act - var response = await client.GetAsync($"{_fixture.ApiUrl}/api/reports/nonexistent"); + var response = await client.GetAsync(new Uri($"{_fixture.ApiUrl}/api/reports/nonexistent")); // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -108,7 +110,7 @@ public async Task ExecuteReport_ReturnsRealDataFromSQLite() { // Arrange using var client = ReportingE2EFixture.CreateApiClient(); - var request = new StringContent( + using var request = new StringContent( """{"parameters": {}, "format": "json"}""", Encoding.UTF8, "application/json" @@ -116,7 +118,7 @@ public async Task ExecuteReport_ReturnsRealDataFromSQLite() // Act var response = await client.PostAsync( - $"{_fixture.ApiUrl}/api/reports/e2e-products/execute", + new Uri($"{_fixture.ApiUrl}/api/reports/e2e-products/execute"), request ); @@ -163,7 +165,7 @@ public async Task ExecuteReport_LqlHighValueFilter_ReturnsFilteredProducts() { // Arrange using var client = ReportingE2EFixture.CreateApiClient(); - var request = new StringContent( + using var request = new StringContent( """{"parameters": {}, "format": "json"}""", Encoding.UTF8, "application/json" @@ -171,7 +173,7 @@ public async Task ExecuteReport_LqlHighValueFilter_ReturnsFilteredProducts() // Act var response = await client.PostAsync( - $"{_fixture.ApiUrl}/api/reports/e2e-products/execute", + new Uri($"{_fixture.ApiUrl}/api/reports/e2e-products/execute"), request ); @@ -214,7 +216,7 @@ public async Task ExecuteReport_LqlCaseExpression_ReturnsPriceTiers() { // Arrange using var client = ReportingE2EFixture.CreateApiClient(); - var request = new StringContent( + using var request = new StringContent( """{"parameters": {}, "format": "json"}""", Encoding.UTF8, "application/json" @@ -222,7 +224,7 @@ public async Task ExecuteReport_LqlCaseExpression_ReturnsPriceTiers() // Act var response = await client.PostAsync( - $"{_fixture.ApiUrl}/api/reports/e2e-products/execute", + new Uri($"{_fixture.ApiUrl}/api/reports/e2e-products/execute"), request ); @@ -261,7 +263,7 @@ public async Task ExecuteReport_LqlGroupByHaving_ReturnsFilteredAggregates() { // Arrange using var client = ReportingE2EFixture.CreateApiClient(); - var request = new StringContent( + using var request = new StringContent( """{"parameters": {}, "format": "json"}""", Encoding.UTF8, "application/json" @@ -269,7 +271,7 @@ public async Task ExecuteReport_LqlGroupByHaving_ReturnsFilteredAggregates() // Act var response = await client.PostAsync( - $"{_fixture.ApiUrl}/api/reports/e2e-products/execute", + new Uri($"{_fixture.ApiUrl}/api/reports/e2e-products/execute"), request ); @@ -295,7 +297,7 @@ public async Task ExecuteReport_CategorySummary_ReturnsAggregatedData() { // Arrange using var client = ReportingE2EFixture.CreateApiClient(); - var request = new StringContent( + using var request = new StringContent( """{"parameters": {}, "format": "json"}""", Encoding.UTF8, "application/json" @@ -303,7 +305,7 @@ public async Task ExecuteReport_CategorySummary_ReturnsAggregatedData() // Act var response = await client.PostAsync( - $"{_fixture.ApiUrl}/api/reports/e2e-products/execute", + new Uri($"{_fixture.ApiUrl}/api/reports/e2e-products/execute"), request ); @@ -343,7 +345,7 @@ public async Task ExecuteReport_Totals_ReturnsCorrectAggregates() { // Arrange using var client = ReportingE2EFixture.CreateApiClient(); - var request = new StringContent( + using var request = new StringContent( """{"parameters": {}, "format": "json"}""", Encoding.UTF8, "application/json" @@ -351,7 +353,7 @@ public async Task ExecuteReport_Totals_ReturnsCorrectAggregates() // Act var response = await client.PostAsync( - $"{_fixture.ApiUrl}/api/reports/e2e-products/execute", + new Uri($"{_fixture.ApiUrl}/api/reports/e2e-products/execute"), request ); @@ -384,7 +386,7 @@ public async Task ExecuteReport_NonexistentId_Returns404() { // Arrange using var client = ReportingE2EFixture.CreateApiClient(); - var request = new StringContent( + using var request = new StringContent( """{"parameters": {}, "format": "json"}""", Encoding.UTF8, "application/json" @@ -392,7 +394,7 @@ public async Task ExecuteReport_NonexistentId_Returns404() // Act var response = await client.PostAsync( - $"{_fixture.ApiUrl}/api/reports/does-not-exist/execute", + new Uri($"{_fixture.ApiUrl}/api/reports/does-not-exist/execute"), request ); @@ -405,7 +407,7 @@ public async Task ExecuteReport_AllDataSourcesPresent_InResponse() { // Arrange using var client = ReportingE2EFixture.CreateApiClient(); - var request = new StringContent( + using var request = new StringContent( """{"parameters": {}, "format": "json"}""", Encoding.UTF8, "application/json" @@ -413,7 +415,7 @@ public async Task ExecuteReport_AllDataSourcesPresent_InResponse() // Act var response = await client.PostAsync( - $"{_fixture.ApiUrl}/api/reports/e2e-products/execute", + new Uri($"{_fixture.ApiUrl}/api/reports/e2e-products/execute"), request ); diff --git a/Reporting/Nimblesite.Reporting.Integration.Tests/ReportingE2EFixture.cs b/Reporting/Nimblesite.Reporting.Integration.Tests/ReportingE2EFixture.cs index e498e71c..f56dcafc 100644 --- a/Reporting/Nimblesite.Reporting.Integration.Tests/ReportingE2EFixture.cs +++ b/Reporting/Nimblesite.Reporting.Integration.Tests/ReportingE2EFixture.cs @@ -5,7 +5,6 @@ using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.Data.Sqlite; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; using Microsoft.Playwright; using Nimblesite.DataProvider.Migration.Core; @@ -66,28 +65,28 @@ public async Task InitializeAsync() // 2. Start Reporting.Api as a real host _apiHost = CreateApiHost(); - await _apiHost.StartAsync(); + await _apiHost.StartAsync().ConfigureAwait(false); var apiServer = _apiHost.Services.GetRequiredService(); var apiAddresses = apiServer.Features.Get(); ApiUrl = apiAddresses!.Addresses.First(); Console.WriteLine($"[E2E] Reporting.Api started on {ApiUrl}"); // 3. Verify API is reachable - await WaitForApiAsync(ApiUrl, "/api/reports"); + await WaitForApiAsync(ApiUrl, "/api/reports").ConfigureAwait(false); // 4. Start frontend static file host for React renderer _frontendHost = CreateFrontendHost(); - await _frontendHost.StartAsync(); + await _frontendHost.StartAsync().ConfigureAwait(false); var frontendServer = _frontendHost.Services.GetRequiredService(); var frontendAddresses = frontendServer.Features.Get(); FrontendUrl = frontendAddresses!.Addresses.First(); Console.WriteLine($"[E2E] React renderer hosted on {FrontendUrl}"); // 5. Launch Playwright + Chromium - Playwright = await Microsoft.Playwright.Playwright.CreateAsync(); - Browser = await Playwright.Chromium.LaunchAsync( - new BrowserTypeLaunchOptions { Headless = true } - ); + Playwright = await Microsoft.Playwright.Playwright.CreateAsync().ConfigureAwait(false); + Browser = await Playwright + .Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true }) + .ConfigureAwait(false); Console.WriteLine("[E2E] Playwright browser launched"); } @@ -96,7 +95,7 @@ public async Task DisposeAsync() try { if (Browser is not null) - await Browser.CloseAsync(); + await Browser.CloseAsync().ConfigureAwait(false); } catch { /* best-effort cleanup */ @@ -106,7 +105,7 @@ public async Task DisposeAsync() try { if (_frontendHost is not null) - await _frontendHost.StopAsync(TimeSpan.FromSeconds(5)); + await _frontendHost.StopAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); } catch { /* best-effort cleanup */ @@ -116,7 +115,7 @@ public async Task DisposeAsync() try { if (_apiHost is not null) - await _apiHost.StopAsync(TimeSpan.FromSeconds(5)); + await _apiHost.StopAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); } catch { /* best-effort cleanup */ @@ -142,15 +141,17 @@ public async Task DisposeAsync() public async Task CreatePageAsync() { // The report viewer calls the bearer-protected API ([REPORT-AUTH-BEARER]). - var page = await Browser!.NewPageAsync( - new BrowserNewPageOptions - { - ExtraHTTPHeaders = new Dictionary + var page = await Browser! + .NewPageAsync( + new BrowserNewPageOptions { - ["Authorization"] = $"Bearer {GatekeeperTestTokens.Create()}", - }, - } - ); + ExtraHTTPHeaders = new Dictionary + { + ["Authorization"] = $"Bearer {GatekeeperTestTokens.Create()}", + }, + } + ) + .ConfigureAwait(false); page.Console += (_, msg) => Console.WriteLine($"[BROWSER {msg.Type}] {msg.Text}"); page.PageError += (_, err) => Console.WriteLine($"[PAGE ERROR] {err}"); return page; @@ -161,17 +162,18 @@ public async Task CreatePageAsync() /// public async Task CreateReportPageAsync(string reportId = "") { - var page = await CreatePageAsync(); + var page = await CreatePageAsync().ConfigureAwait(false); // Inject the API base URL config before any script runs await page.AddInitScriptAsync( - $@"window.reportConfig = {{ + $@"window.reportConfig = {{ apiBaseUrl: '{ApiUrl}', reportId: '{reportId}' }};" - ); + ) + .ConfigureAwait(false); - await page.GotoAsync(FrontendUrl); + await page.GotoAsync(FrontendUrl).ConfigureAwait(false); return page; } @@ -259,19 +261,20 @@ private static IHost CreateFrontendHost() Environment.SetEnvironmentVariable("ASPNETCORE_URLS", null); var wwwrootPath = Path.Combine(AppContext.BaseDirectory, "wwwroot"); - var fileProvider = new PhysicalFileProvider(wwwrootPath); + // The host owns (and disposes) the web root's file provider. return Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseUrls("http://127.0.0.1:0"); + webBuilder.UseWebRoot(wwwrootPath); webBuilder.Configure(app => { app.UseCors(policy => policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod() ); - app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = fileProvider }); - app.UseStaticFiles(new StaticFileOptions { FileProvider = fileProvider }); + app.UseDefaultFiles(); + app.UseStaticFiles(); }); webBuilder.ConfigureServices(services => { @@ -289,14 +292,16 @@ private static async Task WaitForApiAsync(string baseUrl, string endpoint) { try { - var response = await client.GetAsync($"{baseUrl}{endpoint}"); + using var response = await client + .GetAsync(new Uri($"{baseUrl}{endpoint}")) + .ConfigureAwait(false); if (response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.NotFound) return; } catch { /* API not ready yet */ } - await Task.Delay(500); + await Task.Delay(500).ConfigureAwait(false); } throw new TimeoutException($"Reporting API at {baseUrl} did not start within 30 seconds"); } @@ -307,4 +312,4 @@ private static async Task WaitForApiAsync(string baseUrl, string endpoint) /// All tests share ONE fixture instance. Tests run sequentially. /// [CollectionDefinition("Reporting E2E")] -public sealed class ReportingE2ECollection : ICollectionFixture; +public sealed class ReportingE2ECollectionDefinition : ICollectionFixture; diff --git a/Reporting/Nimblesite.Reporting.Integration.Tests/ReportingPlatformApiTests.cs b/Reporting/Nimblesite.Reporting.Integration.Tests/ReportingPlatformApiTests.cs index 59edb8e6..3f366c99 100644 --- a/Reporting/Nimblesite.Reporting.Integration.Tests/ReportingPlatformApiTests.cs +++ b/Reporting/Nimblesite.Reporting.Integration.Tests/ReportingPlatformApiTests.cs @@ -13,7 +13,7 @@ namespace Nimblesite.Reporting.Integration.Tests; -[Collection(ReportingPlatformCollection.Name)] +[Collection(ReportingPlatformCollectionDefinition.Name)] [Trait("Category", "E2E")] public sealed record ReportingPlatformApiTests { @@ -116,9 +116,11 @@ private static async Task AssertAuthenticationAsync( private static async Task AssertUnauthorizedEndpointsAsync(HttpClient client, string apiUrl) { - using var list = await client.GetAsync($"{apiUrl}/api/reports").ConfigureAwait(false); + using var list = await client + .GetAsync(new Uri($"{apiUrl}/api/reports")) + .ConfigureAwait(false); using var metadata = await client - .GetAsync($"{apiUrl}/api/reports/platform-contract") + .GetAsync(new Uri($"{apiUrl}/api/reports/platform-contract")) .ConfigureAwait(false); using var request = new StringContent( """{"parameters": {}, "format": "json"}""", @@ -126,7 +128,7 @@ private static async Task AssertUnauthorizedEndpointsAsync(HttpClient client, st "application/json" ); using var execution = await client - .PostAsync($"{apiUrl}/api/reports/platform-contract/execute", request) + .PostAsync(new Uri($"{apiUrl}/api/reports/platform-contract/execute"), request) .ConfigureAwait(false); Assert.Equal(HttpStatusCode.Unauthorized, list.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, metadata.StatusCode); @@ -227,7 +229,7 @@ private static IHost CreateHost(string connectionString) private static async Task AssertReportJourneyAsync(HttpClient client, string apiUrl) { using var listResponse = await client - .GetAsync($"{apiUrl}/api/reports") + .GetAsync(new Uri($"{apiUrl}/api/reports")) .ConfigureAwait(false); Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); Assert.Equal("application/json", listResponse.Content.Headers.ContentType?.MediaType); @@ -243,7 +245,7 @@ await listResponse.Content.ReadAsStringAsync().ConfigureAwait(false) Assert.Empty(listed.GetProperty("parameters").EnumerateArray()); using var metadataResponse = await client - .GetAsync($"{apiUrl}/api/reports/platform-contract") + .GetAsync(new Uri($"{apiUrl}/api/reports/platform-contract")) .ConfigureAwait(false); Assert.Equal(HttpStatusCode.OK, metadataResponse.StatusCode); var metadataJson = await metadataResponse.Content.ReadAsStringAsync().ConfigureAwait(false); @@ -266,7 +268,7 @@ await listResponse.Content.ReadAsStringAsync().ConfigureAwait(false) var secondRows = await ExecuteAndReadRowsAsync(client, apiUrl).ConfigureAwait(false); Assert.Equal(firstRows, secondRows); using var missing = await client - .GetAsync($"{apiUrl}/api/reports/does-not-exist") + .GetAsync(new Uri($"{apiUrl}/api/reports/does-not-exist")) .ConfigureAwait(false); Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode); using var missingRequest = new StringContent( @@ -275,7 +277,7 @@ await listResponse.Content.ReadAsStringAsync().ConfigureAwait(false) "application/json" ); using var missingExecution = await client - .PostAsync($"{apiUrl}/api/reports/does-not-exist/execute", missingRequest) + .PostAsync(new Uri($"{apiUrl}/api/reports/does-not-exist/execute"), missingRequest) .ConfigureAwait(false); Assert.Equal(HttpStatusCode.NotFound, missingExecution.StatusCode); } @@ -288,7 +290,7 @@ private static async Task ExecuteAndReadRowsAsync(HttpClient client, str "application/json" ); using var response = await client - .PostAsync($"{apiUrl}/api/reports/platform-contract/execute", request) + .PostAsync(new Uri($"{apiUrl}/api/reports/platform-contract/execute"), request) .ConfigureAwait(false); Assert.Equal(HttpStatusCode.OK, response.StatusCode); using var result = JsonDocument.Parse( diff --git a/Reporting/Nimblesite.Reporting.Integration.Tests/ReportingPlatformCollection.cs b/Reporting/Nimblesite.Reporting.Integration.Tests/ReportingPlatformCollectionDefinition.cs similarity index 86% rename from Reporting/Nimblesite.Reporting.Integration.Tests/ReportingPlatformCollection.cs rename to Reporting/Nimblesite.Reporting.Integration.Tests/ReportingPlatformCollectionDefinition.cs index ead09c14..836dee3d 100644 --- a/Reporting/Nimblesite.Reporting.Integration.Tests/ReportingPlatformCollection.cs +++ b/Reporting/Nimblesite.Reporting.Integration.Tests/ReportingPlatformCollectionDefinition.cs @@ -4,7 +4,7 @@ namespace Nimblesite.Reporting.Integration.Tests; // Implements [MIG-TEST-CROSS-PLATFORM]: container startup is lazy per provider. [CollectionDefinition(Name)] -public sealed record ReportingPlatformCollection +public sealed record ReportingPlatformCollectionDefinition : ICollectionFixture, ICollectionFixture { diff --git a/Reporting/Nimblesite.Reporting.React/Nimblesite.Reporting.React.csproj b/Reporting/Nimblesite.Reporting.React/Nimblesite.Reporting.React.csproj index feb5f934..908f7d79 100644 --- a/Reporting/Nimblesite.Reporting.React/Nimblesite.Reporting.React.csproj +++ b/Reporting/Nimblesite.Reporting.React/Nimblesite.Reporting.React.csproj @@ -1,6 +1,8 @@ Library + + netstandard2.1 9.0 enable diff --git a/Sync/Nimblesite.Sync.Http.Tests/Nimblesite.Sync.Http.Tests.csproj b/Sync/Nimblesite.Sync.Http.Tests/Nimblesite.Sync.Http.Tests.csproj index 4f44690e..10acdc8d 100644 --- a/Sync/Nimblesite.Sync.Http.Tests/Nimblesite.Sync.Http.Tests.csproj +++ b/Sync/Nimblesite.Sync.Http.Tests/Nimblesite.Sync.Http.Tests.csproj @@ -18,7 +18,17 @@ all runtime; build; native; contentfiles; analyzers - + + + diff --git a/Sync/Nimblesite.Sync.Integration.Tests/Nimblesite.Sync.Integration.Tests.csproj b/Sync/Nimblesite.Sync.Integration.Tests/Nimblesite.Sync.Integration.Tests.csproj index 33923a7a..b699f1cc 100644 --- a/Sync/Nimblesite.Sync.Integration.Tests/Nimblesite.Sync.Integration.Tests.csproj +++ b/Sync/Nimblesite.Sync.Integration.Tests/Nimblesite.Sync.Integration.Tests.csproj @@ -16,8 +16,19 @@ - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Website/src/docs/getting-started.md b/Website/src/docs/getting-started.md index c190a928..d7160672 100644 --- a/Website/src/docs/getting-started.md +++ b/Website/src/docs/getting-started.md @@ -8,7 +8,7 @@ This walkthrough takes you from an empty folder to a running, type-safe database ## Prerequisites -- **.NET 10 SDK** or later +- **.NET 9 or .NET 10 SDK** - A terminal No database server required — we use SQLite. diff --git a/Website/src/docs/installation.md b/Website/src/docs/installation.md index 3c6e7c7a..16a60e15 100644 --- a/Website/src/docs/installation.md +++ b/Website/src/docs/installation.md @@ -81,11 +81,11 @@ dotnet add package Nimblesite.DataProvider.Migration.SQLite ## 3. Requirements -- **.NET 10 SDK** or later +- **.NET 9 or .NET 10 SDK**. Every package and tool targets both `net9.0` and `net10.0`. - C# latest language version - `enable` -Add the following to your `.csproj`: +Add the following to your `.csproj` (`net9.0` works too): ```xml diff --git a/docs/specs/ci-spec.md b/docs/specs/ci-spec.md index 3c83ac05..42b8f3ab 100644 --- a/docs/specs/ci-spec.md +++ b/docs/specs/ci-spec.md @@ -45,6 +45,27 @@ fails when credentials are missing or invalid, and never falls back after an OIDC login failure. The supported Azure credential option is passed directly to the pinned VSCE CLI. Retrying publication skips already published packages. +## Target Frameworks [BUILD-MULTI-TARGET] + +`Directory.Build.props` sets `net9.0;net10.0` +for every project: the libraries, the `DataProvider`, `Lql` and +`DataProviderMigrate` dotnet tools, the examples and the tests. NuGet packages +carry both `lib/net9.0` and `lib/net10.0`, and every test project runs on both +runtimes. Two projects opt out with an empty `TargetFrameworks`: the Blazor +WebAssembly LQL website, deployed once on net9.0, and the h5 +`Nimblesite.Reporting.React` project, which h5 transpiles only from +netstandard2.1. Packages that ship one framework per major version, such as +`Microsoft.AspNetCore.Mvc.Testing`, are referenced per target framework. + +The self-contained Native AOT `DataProviderMigrate` binary ships one runtime, +.NET 10 (LTS), so `make aot` and the release publish pass `-f net10.0`. +`make test` reads every per-framework coverage report and gates on the lowest +line rate. + +netstandard2.1 is not a target. Migration and Sync depend on SqlParserCS, +Npgsql and Microsoft.Data.SqlClient, which ship no netstandard build. The other +libraries use .NET 6+ APIs that would need polyfills and a separate test harness. + ## Rust Coverage Instrumentation [CI-RUST-COVERAGE] `make test` and CI use Tarpaulin's LLVM engine explicitly on every platform. diff --git a/docs/specs/codegen-cli-tool.md b/docs/specs/codegen-cli-tool.md index 761fe944..df6c2c5a 100644 --- a/docs/specs/codegen-cli-tool.md +++ b/docs/specs/codegen-cli-tool.md @@ -81,9 +81,9 @@ To bump a grammar: | ID | Spec | |---|---| | TOOL-NAME | `DataProvider` | -| TOOL-PROJECT | `DataProvider/DataProvider/`. `Exe`, `net9.0`, `DataProvider`. **No** `PackAsTool=true`. | +| TOOL-PROJECT | `DataProvider/DataProvider/`. `Exe`, the shared `net9.0;net10.0` target frameworks ([BUILD-MULTI-TARGET]), `DataProvider`. **No** `PackAsTool=true`. | | TOOL-ARGS | Required: `--out`, `--platform`. `--mode {schema-doc\|live-db}` defaults to `schema-doc`. `--connection` required iff `--mode live-db`. `--config` optional in both modes (overrides only — see [CON-NOCONFIG]). `--watch` runs the tool as a long-lived LSP per `## SCHEMA-DOC`. `--templates` points at a custom template directory per `## TEMPLATES`. Optional: `--namespace` (default `.DataProvider.Generated`), `--accessibility` (default `public`), `--verbosity`. | -| TOOL-DEPS | References `DataProvider.Core`, every `DataProvider.{Platform}`, every `Nimblesite.Lql.{Platform}`, `Nimblesite.Sql.Model`, `Outcome`, plus every database driver. **All TFMs are net9.0**. No netstandard2.0. | +| TOOL-DEPS | References `DataProvider.Core`, every `DataProvider.{Platform}`, every `Nimblesite.Lql.{Platform}`, `Nimblesite.Sql.Model`, `Outcome`, plus every database driver. **All TFMs are net9.0 and net10.0** ([BUILD-MULTI-TARGET]). No netstandard2.0. | | TOOL-PROCESS | Own process per invocation. Process isolation guarantees: any native driver dep loads cleanly; tool crashes do not corrupt MSBuild build-server state; tool memory fully reclaimed at exit; tool dll never locks files in IDE edit-rebuild cycles. | | TOOL-EXIT | `0` on success, `1` on any error. Errors → **stderr** in MSBuild error format `path/to/file(line,col): error DPSGxxx: message` so `` parses them into structured IDE errors. Diagnostics → stdout. | | TOOL-PIPELINE | Per invocation: (1) resolve `--mode`. (2a) **schema-doc**: locate `*.schema.yaml` in the project (recursive; one schema doc per project per [DOC-ONE-PER-PROJECT]) → `DPSG010`; load via `SchemaSerializer.FromYaml` → `DPSG011`; skip [TOOL-DBOPEN] entirely. (2b) **live-db**: validate `--connection` → `DPSG001`; open driver per [TOOL-DBOPEN]; introspect schema. (3) parse `--config` JSON if present → `DPSG006` (optional per [CON-NOCONFIG]); apply convention resolver per [CONV-CRUD]. (4) for each `*.lql`, transpile `LqlStatementConverter.ToStatement(content).To{Platform}Sql()`. (5) render templates per `## TEMPLATES`. (6) write generated files to `--out`. (7) if `--watch`, run the LSP loop per `## SCHEMA-DOC` instead of exiting. | From 566ea08970ba990818c6b4637813226a38482537 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:09:52 +1100 Subject: [PATCH 2/3] Ratchet LqlExtension coverage threshold to 55% --- coverage-thresholds.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coverage-thresholds.json b/coverage-thresholds.json index e8488196..ab0ef1a0 100644 --- a/coverage-thresholds.json +++ b/coverage-thresholds.json @@ -53,7 +53,7 @@ "threshold": 98 }, "Lql/LqlExtension": { - "threshold": 54 + "threshold": 55 } } } From 24cdba24531f3f41313ad20f351aa75cc12a572d Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:35:41 +1100 Subject: [PATCH 3/3] Codegen runs the tool built for the same target framework The codegen targets build each tool through the MSBuild task, which inherits the caller's TargetFramework. Hard-coded net9.0/net10.0 tool paths therefore pointed at outputs a clean build had not produced yet, so `dotnet build -c Debug` from clean failed with missing Generated types. Tool paths now use $(TargetFramework). Also restores the LqlExtension threshold to 54%: CI measures 54.48%. --- .../Nimblesite.DataProvider.Example.csproj | 10 ++++++---- ...imblesite.Lql.TypeProvider.FSharp.Tests.Data.csproj | 6 ++++-- .../Nimblesite.Lql.TypeProvider.FSharp.Tests.fsproj | 4 +++- coverage-thresholds.json | 2 +- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/DataProvider/Nimblesite.DataProvider.Example/Nimblesite.DataProvider.Example.csproj b/DataProvider/Nimblesite.DataProvider.Example/Nimblesite.DataProvider.Example.csproj index ff092f7c..3031bc8e 100644 --- a/DataProvider/Nimblesite.DataProvider.Example/Nimblesite.DataProvider.Example.csproj +++ b/DataProvider/Nimblesite.DataProvider.Example/Nimblesite.DataProvider.Example.csproj @@ -45,14 +45,16 @@ + - $(MSBuildThisFileDirectory)../../Migration/DataProviderMigrate/bin/$(Configuration)/net9.0/DataProviderMigrate.dll - $(MSBuildThisFileDirectory)../../Lql/Lql/bin/$(Configuration)/net9.0/Lql.dll - $(MSBuildThisFileDirectory)../DataProvider/bin/$(Configuration)/net10.0/DataProvider.dll + $(MSBuildThisFileDirectory)../../Migration/DataProviderMigrate/bin/$(Configuration)/$(TargetFramework)/DataProviderMigrate.dll + $(MSBuildThisFileDirectory)../../Lql/Lql/bin/$(Configuration)/$(TargetFramework)/Lql.dll + $(MSBuildThisFileDirectory)../DataProvider/bin/$(Configuration)/$(TargetFramework)/DataProvider.dll - + diff --git a/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data.csproj b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data.csproj index 3ee27a53..2d26f3bd 100644 --- a/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data.csproj +++ b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data.csproj @@ -25,9 +25,11 @@ + - $(MSBuildThisFileDirectory)../../Migration/DataProviderMigrate/bin/$(Configuration)/net9.0/DataProviderMigrate.dll - $(MSBuildThisFileDirectory)../../DataProvider/DataProvider/bin/$(Configuration)/net10.0/DataProvider.dll + $(MSBuildThisFileDirectory)../../Migration/DataProviderMigrate/bin/$(Configuration)/$(TargetFramework)/DataProviderMigrate.dll + $(MSBuildThisFileDirectory)../../DataProvider/DataProvider/bin/$(Configuration)/$(TargetFramework)/DataProvider.dll diff --git a/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/Nimblesite.Lql.TypeProvider.FSharp.Tests.fsproj b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/Nimblesite.Lql.TypeProvider.FSharp.Tests.fsproj index a1e45949..871da4cb 100644 --- a/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/Nimblesite.Lql.TypeProvider.FSharp.Tests.fsproj +++ b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/Nimblesite.Lql.TypeProvider.FSharp.Tests.fsproj @@ -39,8 +39,10 @@ + - $(MSBuildThisFileDirectory)../../Migration/DataProviderMigrate/bin/$(Configuration)/net9.0/DataProviderMigrate.dll + $(MSBuildThisFileDirectory)../../Migration/DataProviderMigrate/bin/$(Configuration)/$(TargetFramework)/DataProviderMigrate.dll diff --git a/coverage-thresholds.json b/coverage-thresholds.json index ab0ef1a0..e8488196 100644 --- a/coverage-thresholds.json +++ b/coverage-thresholds.json @@ -53,7 +53,7 @@ "threshold": 98 }, "Lql/LqlExtension": { - "threshold": 55 + "threshold": 54 } } }