From 859fd25812c077fec35db1faaf28153b1576cb02 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:24:14 -0500 Subject: [PATCH 1/8] Reduce StaticHost publish compression cost and expose phase timings Preserve Brotli negotiation and immutable asset caching, cover isolated publishes, and add opt-in full-site CI diagnostics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3761b826-bc44-40e0-99dd-a3162446f1a6 --- .github/workflows/apphost-build.yml | 81 +++++ .github/workflows/ci.yml | 2 + CONTRIBUTING.md | 42 +++ src/statichost/StaticHost/Publishing.targets | 33 +++ src/statichost/StaticHost/StaticHost.csproj | 1 + .../PublishingTargetsTests.cs | 279 ++++++++++++++++++ .../StaticHost.Tests/StaticHost.Tests.csproj | 9 + tests/scripts/Measure-StaticHostPublish.ps1 | 91 ++++++ 8 files changed, 538 insertions(+) create mode 100644 src/statichost/StaticHost/Publishing.targets create mode 100644 tests/StaticHost.Tests/PublishingTargetsTests.cs create mode 100644 tests/scripts/Measure-StaticHostPublish.ps1 diff --git a/.github/workflows/apphost-build.yml b/.github/workflows/apphost-build.yml index d8fe952aa0..3be184a4c1 100644 --- a/.github/workflows/apphost-build.yml +++ b/.github/workflows/apphost-build.yml @@ -2,6 +2,12 @@ name: AppHost Build on: workflow_call: + inputs: + profile_publish: + description: Publish the complete site and capture static-asset and container diagnostics + required: false + default: false + type: boolean permissions: contents: read @@ -110,3 +116,78 @@ jobs: path: src/apphost/Aspire.Dev.AppHost/bin/Release/*/ if-no-files-found: warn retention-days: 7 + + publish: + name: StaticHost publish diagnostics + if: ${{ inputs.profile_publish }} + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + DOTNET_NOLOGO: 1 + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + ASTRO_TELEMETRY_DISABLED: 1 + NODE_OPTIONS: --max-old-space-size=8192 + BUILD_TIMING: 1 + BUILD_TIMING_LABEL: ${{ github.sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json + + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24.x" + cache: pnpm + cache-dependency-path: src/frontend/pnpm-lock.yaml + + - name: Install frontend dependencies + working-directory: src/frontend + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Publish complete StaticHost container archive + env: + BUILD_TIMING_OUT: ${{ runner.temp }}/statichost-publish/frontend-timing.jsonl + shell: bash + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/statichost-publish/images" + /usr/bin/time -v -o "$RUNNER_TEMP/statichost-publish/resources.txt" \ + dotnet publish src/statichost/StaticHost/StaticHost.csproj \ + --configuration Release --runtime linux-x64 \ + /t:PublishContainer \ + -p:ContainerRepository=aspiredev \ + -p:ContainerImageTag=ci \ + -p:ContainerImageFormat=OCI \ + -p:ContainerArchiveOutputPath="$RUNNER_TEMP/statichost-publish/images" \ + "-bl:$RUNNER_TEMP/statichost-publish/publish.binlog;ProjectImports=None" \ + 2>&1 | tee "$RUNNER_TEMP/statichost-publish/publish.log" + + - name: Verify published files and summarize timings + shell: pwsh + run: | + & ./tests/scripts/Measure-StaticHostPublish.ps1 ` + -PublishDirectory ./src/statichost/StaticHost/bin/Release/net10.0/linux-x64/publish ` + -FrontendDirectory ./src/frontend/dist ` + -DiagnosticsDirectory "$env:RUNNER_TEMP/statichost-publish" ` + -SummaryPath $env:GITHUB_STEP_SUMMARY + + - name: Upload publish diagnostics + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: statichost-publish-performance + path: | + ${{ runner.temp }}/statichost-publish/*.json + ${{ runner.temp }}/statichost-publish/*.jsonl + ${{ runner.temp }}/statichost-publish/*.binlog + ${{ runner.temp }}/statichost-publish/*.log + ${{ runner.temp }}/statichost-publish/*.txt + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 615e43ca96..111c81edd4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,8 @@ jobs: needs: changes if: ${{ needs.changes.outputs.apphost == 'true' }} uses: ./.github/workflows/apphost-build.yml + with: + profile_publish: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'build-profile') }} release-sync-tests: runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dcc1ba8175..854f94c086 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,6 +58,48 @@ setting only to the vnext build, not the live build. If vnext builds a different source branch, set the intended label explicitly instead. Changing the badge on a deployed static site requires rebuilding its frontend assets. +## StaticHost publishing + +StaticHost publishes Brotli-compressed static assets using `Optimal` compression. +This reduces compression time for the generated documentation and API catalog. +HTML, Markdown, and other original assets remain available to clients that do +not accept Brotli. Hashed `_astro/` assets retain their immutable cache headers. + +`src/statichost/StaticHost/Publishing.targets` sets the compression default and +maps `BrotliCompressionLevel` to the compatibility property that .NET 10's +publish target consumes. Explicit public or compatibility-property overrides +remain supported. + +Publish logs include `[publish-timing]` messages with the compression level, +asset count, compression duration, and subsequent static-asset metadata and +manifest duration. Use these messages to distinguish MSBuild work from the +frontend build and container archive creation. + +Run the publishing regression tests without building the frontend: + +```shell title="Run isolated static-asset publishing tests" +dotnet test tests/StaticHost.Tests/StaticHost.Tests.csproj --configuration Release --filter FullyQualifiedName~PublishingTargetsTests +``` + +These tests publish small isolated projects using the production targets. They +check the default and overridden compression levels, compressed file contents, +HTTP content negotiation, and cache headers. AppHost CI runs them with the +StaticHost tests and includes their publish binary logs in +`logs-AppHost-ubuntu-latest`. + +Add the `build-profile` label to a pull request to run full StaticHost publishing +diagnostics alongside the frontend profile. This opt-in CI job builds the +frontend, publishes the Linux container archive, verifies the frontend output +and representative Brotli round trips, and uploads +`statichost-publish-performance`. The artifact contains the publish log, MSBuild +binary log, resource measurements, frontend timings, and output sizes. It does +not upload or deploy the container image. + +Measure full-site publishing in CI. Compare equivalent source commits, SDKs, +runner images, and clean intermediate directories before attributing a timing +change to compression. A full StaticHost publish also builds the frontend. +Do not run a local full-site build without explicit maintainer approval. + ## 🆘 Getting help - **Issues**: Report bugs or request features via [GitHub Issues](https://github.com/microsoft/aspire.dev/issues) diff --git a/src/statichost/StaticHost/Publishing.targets b/src/statichost/StaticHost/Publishing.targets new file mode 100644 index 0000000000..83eef84801 --- /dev/null +++ b/src/statichost/StaticHost/Publishing.targets @@ -0,0 +1,33 @@ + + + Optimal + + <_BlazorBrotliCompressionLevel Condition="'$(_BlazorBrotliCompressionLevel)' == ''">$(BrotliCompressionLevel) + + + + + <_StaticHostCompressionStartedTicks>$([System.DateTime]::UtcNow.Ticks) + + + + + + + <_StaticHostCompressionFinishedTicks>$([System.DateTime]::UtcNow.Ticks) + <_StaticHostCompressionSeconds>$([MSBuild]::Divide($([MSBuild]::Subtract('$(_StaticHostCompressionFinishedTicks)', '$(_StaticHostCompressionStartedTicks)')), 10000000)) + + + + + + + <_StaticHostManifestSeconds>$([MSBuild]::Divide($([MSBuild]::Subtract($([System.DateTime]::UtcNow.Ticks), '$(_StaticHostCompressionFinishedTicks)')), 10000000)) + + + + diff --git a/src/statichost/StaticHost/StaticHost.csproj b/src/statichost/StaticHost/StaticHost.csproj index f6e5fdfb8e..3bc0a4bd8f 100644 --- a/src/statichost/StaticHost/StaticHost.csproj +++ b/src/statichost/StaticHost/StaticHost.csproj @@ -48,5 +48,6 @@ + diff --git a/tests/StaticHost.Tests/PublishingTargetsTests.cs b/tests/StaticHost.Tests/PublishingTargetsTests.cs new file mode 100644 index 0000000000..c3e5a39fa5 --- /dev/null +++ b/tests/StaticHost.Tests/PublishingTargetsTests.cs @@ -0,0 +1,279 @@ +using System.Diagnostics; +using System.Globalization; +using System.IO.Compression; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Hosting; + +namespace StaticHost.Tests; + +public sealed class PublishingTargetsTests(PublishingFixture fixture) : IClassFixture +{ + [Fact] + public void PublishUsesOptimalBrotliByDefault() + { + Assert.Contains("brotliLevel=Optimal", fixture.DefaultOutput); + AssertCompressedAssets(fixture.DefaultDirectory, CompressionLevel.Optimal); + } + + [Fact] + public void PublishHonorsExplicitCompressionLevel() + { + Assert.Contains("brotliLevel=SmallestSize", fixture.SmallestSizeOutput); + AssertCompressedAssets(fixture.SmallestSizeDirectory, CompressionLevel.SmallestSize); + } + + [Fact] + public void PublishHonorsLegacyCompressionLevel() + { + Assert.Contains("brotliLevel=SmallestSize", fixture.LegacyOutput); + AssertCompressedAssets(fixture.LegacyDirectory, CompressionLevel.SmallestSize); + } + + [Fact] + public void PublishLogsCompressionAndMetadataDurations() + { + Assert.Contains("[publish-timing] compression start assets=4 brotliLevel=Optimal", fixture.DefaultOutput); + Assert.Contains("[publish-timing] compression complete durationSeconds=", fixture.DefaultOutput); + Assert.Contains("[publish-timing] static asset metadata and manifest complete durationSeconds=", fixture.DefaultOutput); + var durations = Regex.Matches(fixture.DefaultOutput, @"durationSeconds=([0-9.eE+-]+)"); + Assert.Equal(2, durations.Count); + foreach (Match duration in durations) + { + Assert.True(double.Parse(duration.Groups[1].Value, CultureInfo.InvariantCulture) >= 0); + } + } + + [Fact] + public async Task PublishMeasurementVerifiesAssetsAndRejectsIncompleteOutput() + { + var complete = await fixture.MeasureAsync(includeMissingFile: false); + Assert.True(complete.ExitCode == 0, complete.Log); + var incomplete = await fixture.MeasureAsync(includeMissingFile: true); + Assert.NotEqual(0, incomplete.ExitCode); + Assert.Contains("Frontend output is missing from the published host", incomplete.Log); + } + + [Theory] + [InlineData("/index.html")] + [InlineData("/reference/api/sample.md")] + [InlineData("/_astro/app.abc123.css")] + public async Task PublishedEndpointsPreserveContentAndBrotliNegotiation(string path) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + EnvironmentName = Environments.Production, + ContentRootPath = fixture.DefaultDirectory, + ApplicationName = typeof(PublishingTargetsTests).Assembly.GetName().Name + }); + builder.WebHost.UseTestServer(); + await using var app = builder.Build(); + app.MapStaticAssets(Path.Combine(fixture.DefaultDirectory, "PublishFixture.staticwebassets.endpoints.json")); + await app.StartAsync(); + using var client = app.GetTestClient(); + + using var plain = await client.GetAsync(path); + Assert.Equal(HttpStatusCode.OK, plain.StatusCode); + Assert.Empty(plain.Content.Headers.ContentEncoding); + var expected = await plain.Content.ReadAsByteArrayAsync(); + + using var request = new HttpRequestMessage(HttpMethod.Get, path); + request.Headers.AcceptEncoding.ParseAdd("br"); + using var compressed = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, compressed.StatusCode); + Assert.Contains("br", compressed.Content.Headers.ContentEncoding); + await using var encoded = await compressed.Content.ReadAsStreamAsync(); + await using var decoder = new BrotliStream(encoded, CompressionMode.Decompress); + using var restored = new MemoryStream(); + await decoder.CopyToAsync(restored); + Assert.Equal(expected, restored.ToArray()); + + using var gzipRequest = new HttpRequestMessage(HttpMethod.Get, path); + gzipRequest.Headers.AcceptEncoding.ParseAdd("gzip"); + using var fallback = await client.SendAsync(gzipRequest); + Assert.Equal(HttpStatusCode.OK, fallback.StatusCode); + Assert.Empty(fallback.Content.Headers.ContentEncoding); + Assert.Equal(expected, await fallback.Content.ReadAsByteArrayAsync()); + + if (path.StartsWith("/_astro/", StringComparison.Ordinal)) + { + Assert.True(plain.Headers.CacheControl?.Public); + Assert.Equal(TimeSpan.FromDays(365), plain.Headers.CacheControl?.MaxAge); + Assert.Contains(plain.Headers.CacheControl!.Extensions, value => value.Name == "immutable"); + Assert.Equal(plain.Headers.CacheControl?.ToString(), compressed.Headers.CacheControl?.ToString()); + } + else + { + Assert.False(plain.Headers.CacheControl?.Extensions.Any(value => value.Name == "immutable") ?? false); + } + } + + private static void AssertCompressedAssets(string directory, CompressionLevel level) + { + foreach (var path in PublishingFixture.AssetPaths) + { + var published = Path.Combine(directory, "wwwroot", path); + var original = File.ReadAllBytes(published); + using var expected = new MemoryStream(); + using (var compressor = new BrotliStream(expected, level, leaveOpen: true)) + { + compressor.Write(original); + } + Assert.Equal(expected.ToArray(), File.ReadAllBytes(published + ".br")); + Assert.False(File.Exists(published + ".gz")); + } + } +} + +public sealed class PublishingFixture : IAsyncLifetime +{ + internal static readonly string[] AssetPaths = + [ + "index.html", + Path.Combine("reference", "api", "sample.md"), + Path.Combine("reference", "api", "sample", "index.html"), + Path.Combine("_astro", "app.abc123.css") + ]; + + private readonly string _root = Path.Combine(Path.GetTempPath(), $"aspire-publish-test-{Guid.NewGuid():N}"); + + public string DefaultDirectory => Path.Combine(_root, "default", "publish"); + public string SmallestSizeDirectory => Path.Combine(_root, "smallest", "publish"); + public string LegacyDirectory => Path.Combine(_root, "legacy", "publish"); + public string DefaultOutput { get; private set; } = string.Empty; + public string SmallestSizeOutput { get; private set; } = string.Empty; + public string LegacyOutput { get; private set; } = string.Empty; + + public async Task InitializeAsync() + { + DefaultOutput = await PublishAsync("default", null); + SmallestSizeOutput = await PublishAsync("smallest", "-p:BrotliCompressionLevel=SmallestSize"); + LegacyOutput = await PublishAsync("legacy", "-p:_BlazorBrotliCompressionLevel=SmallestSize"); + } + + public Task DisposeAsync() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + return Task.CompletedTask; + } + + public async Task<(int ExitCode, string Log)> MeasureAsync(bool includeMissingFile) + { + var directory = Path.Combine(_root, includeMissingFile ? "measure-incomplete" : "measure-complete"); + var frontend = Path.Combine(directory, "frontend"); + var diagnostics = Path.Combine(directory, "diagnostics"); + Directory.CreateDirectory(Path.Combine(diagnostics, "images")); + foreach (var path in AssetPaths) + { + var file = Path.Combine(frontend, path); + Directory.CreateDirectory(Path.GetDirectoryName(file)!); + File.Copy(Path.Combine(DefaultDirectory, "wwwroot", path), file); + } + if (includeMissingFile) + { + await File.WriteAllTextAsync(Path.Combine(frontend, "missing.html"), "missing"); + } + await File.WriteAllTextAsync(Path.Combine(diagnostics, "images", "fixture.tar.gz"), "fixture archive"); + await File.WriteAllTextAsync(Path.Combine(diagnostics, "publish.log"), DefaultOutput); + var start = new ProcessStartInfo("pwsh") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + foreach (var argument in new[] + { + "-NoProfile", "-NonInteractive", "-File", + Path.Combine(AppContext.BaseDirectory, "PublishFixture", "Measure-StaticHostPublish.ps1"), + "-PublishDirectory", DefaultDirectory, + "-FrontendDirectory", frontend, + "-DiagnosticsDirectory", diagnostics, + "-SummaryPath", Path.Combine(diagnostics, "summary.md") + }) + { + start.ArgumentList.Add(argument); + } + return await RunAsync(start, "publish measurements"); + } + + private async Task PublishAsync(string name, string? compressionProperty) + { + var directory = Path.Combine(_root, name); + Directory.CreateDirectory(directory); + foreach (var path in AssetPaths) + { + var file = Path.Combine(directory, "wwwroot", path); + Directory.CreateDirectory(Path.GetDirectoryName(file)!); + var contents = path.EndsWith(".css", StringComparison.Ordinal) + ? string.Concat(Enumerable.Range(0, 100).Select(index => $".item-{index} {{ color: #345678; padding: {index}px; }}\n")) + : string.Concat(Enumerable.Range(0, 100).Select(index => $"

Asset {index}: Aspire documentation and API reference.

\n")); + await File.WriteAllTextAsync(file, contents); + } + await File.WriteAllTextAsync(Path.Combine(directory, "Program.cs"), "var app = WebApplication.CreateBuilder(args).Build(); app.MapStaticAssets(); app.Run();"); + var targets = Path.Combine(AppContext.BaseDirectory, "PublishFixture"); + var project = new XDocument(new XElement("Project", + new XAttribute("Sdk", "Microsoft.NET.Sdk.Web"), + new XElement("PropertyGroup", + new XElement("TargetFramework", "net10.0"), + new XElement("ImplicitUsings", "enable"), + new XElement("EnableDefaultCompressionFormats", "false"), + new XElement("PublishCompressionFormats", "brotli")), + new XElement("Import", new XAttribute("Project", Path.Combine(targets, "Publishing.targets"))), + new XElement("Import", new XAttribute("Project", Path.Combine(targets, "Caching.targets"))))); + await File.WriteAllTextAsync(Path.Combine(directory, "PublishFixture.csproj"), project.ToString()); + + var start = new ProcessStartInfo("dotnet") + { + WorkingDirectory = directory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + foreach (var argument in new[] { "publish", "--configuration", "Release", "--nologo", "--verbosity", "minimal", "--output", Path.Combine(directory, "publish") }) + { + start.ArgumentList.Add(argument); + } + if (compressionProperty is not null) + { + start.ArgumentList.Add(compressionProperty); + } + var results = Environment.GetEnvironmentVariable("TEST_RESULTS_DIR"); + if (!string.IsNullOrEmpty(results)) + { + Directory.CreateDirectory(results); + start.ArgumentList.Add($"-bl:{Path.Combine(results, $"StaticHost.Publish.{name}.binlog")};ProjectImports=None"); + } + start.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"; + var result = await RunAsync(start, $"publish fixture '{name}'"); + Assert.True(result.ExitCode == 0, $"Publish fixture '{name}' failed with exit code {result.ExitCode}.\n{result.Log}"); + return result.Log; + } + + private static async Task<(int ExitCode, string Log)> RunAsync(ProcessStartInfo start, string name) + { + using var process = new Process { StartInfo = start }; + Assert.True(process.Start(), $"Failed to start {name}."); + var output = process.StandardOutput.ReadToEndAsync(); + var error = process.StandardError.ReadToEndAsync(); + using var timeout = new System.Threading.CancellationTokenSource(TimeSpan.FromMinutes(3)); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + throw new TimeoutException($"{name} timed out.\n{await output}\n{await error}"); + } + return (process.ExitCode, $"{await output}\n{await error}"); + } +} diff --git a/tests/StaticHost.Tests/StaticHost.Tests.csproj b/tests/StaticHost.Tests/StaticHost.Tests.csproj index 648d9c0b73..40e83b3882 100644 --- a/tests/StaticHost.Tests/StaticHost.Tests.csproj +++ b/tests/StaticHost.Tests/StaticHost.Tests.csproj @@ -34,6 +34,15 @@ static layout it wants to assert on. --> + + + diff --git a/tests/scripts/Measure-StaticHostPublish.ps1 b/tests/scripts/Measure-StaticHostPublish.ps1 new file mode 100644 index 0000000000..656412e813 --- /dev/null +++ b/tests/scripts/Measure-StaticHostPublish.ps1 @@ -0,0 +1,91 @@ +param( + [Parameter(Mandatory)][string]$PublishDirectory, + [Parameter(Mandatory)][string]$FrontendDirectory, + [Parameter(Mandatory)][string]$DiagnosticsDirectory, + [Parameter(Mandatory)][string]$SummaryPath +) + +$ErrorActionPreference = 'Stop' +$webRoot = Join-Path $PublishDirectory 'wwwroot' +$frontendRoot = (Resolve-Path -LiteralPath $FrontendDirectory).Path +$frontendFiles = @(Get-ChildItem -LiteralPath $FrontendDirectory -Recurse -File) +if ($frontendFiles.Count -eq 0) { + throw 'The frontend output is empty.' +} +foreach ($file in $frontendFiles) { + $relative = [IO.Path]::GetRelativePath($frontendRoot, $file.FullName) + if (-not (Test-Path -LiteralPath (Join-Path $webRoot $relative) -PathType Leaf)) { + throw "Frontend output is missing from the published host: $relative" + } +} + +$assets = @(Get-ChildItem -LiteralPath $webRoot -Recurse -File) +$compressed = @($assets | Where-Object Extension -eq '.br') +if ($compressed.Count -eq 0) { + throw 'No Brotli assets were published.' +} +$samples = @( + Get-Item -LiteralPath (Join-Path $webRoot 'index.html') + $assets | Where-Object { $_.Extension -eq '.md' } | Select-Object -First 1 + $assets | Where-Object { $_.Extension -eq '.html' -and $_.FullName -match '[\\/]reference[\\/]api[\\/]' } | Select-Object -First 1 + Get-ChildItem -LiteralPath (Join-Path $webRoot '_astro') -File -Filter '*.css' | Select-Object -First 1 +) +if ($samples.Count -ne 4) { + throw 'Expected published home, Markdown, API HTML, and hashed CSS samples.' +} +foreach ($sample in $samples) { + $original = [IO.File]::ReadAllBytes($sample.FullName) + $inputStream = [IO.File]::OpenRead("$($sample.FullName).br") + $decoder = [IO.Compression.BrotliStream]::new($inputStream, [IO.Compression.CompressionMode]::Decompress) + $restored = [IO.MemoryStream]::new() + try { + $decoder.CopyTo($restored) + if ([Convert]::ToBase64String($original) -cne [Convert]::ToBase64String($restored.ToArray())) { + throw "Brotli content differs from the original: $($sample.FullName)" + } + } finally { + $restored.Dispose() + $decoder.Dispose() + $inputStream.Dispose() + } +} + +$archives = @(Get-ChildItem -LiteralPath (Join-Path $DiagnosticsDirectory 'images') -File) +if ($archives.Count -ne 1 -or $archives[0].Length -eq 0) { + throw 'Expected one nonempty container archive.' +} +$timings = @(Get-Content -LiteralPath (Join-Path $DiagnosticsDirectory 'publish.log') | + Where-Object { $_.Contains('[publish-timing]') }) +foreach ($phase in @('compression start', 'compression complete', 'static asset metadata and manifest complete')) { + if (-not ($timings | Where-Object { $_.Contains($phase) })) { + throw "Publish timing is missing: $phase" + } +} +if (-not ($timings | Where-Object { $_.Contains('brotliLevel=Optimal') })) { + throw 'Full-site publishing did not use Optimal Brotli.' +} +$metrics = [ordered]@{ + frontendFiles = $frontendFiles.Count + publishedFiles = $assets.Count + brotliFiles = $compressed.Count + brotliBytes = ($compressed | Measure-Object Length -Sum).Sum + containerArchiveBytes = $archives[0].Length + roundTripSamples = $samples.Count + timings = $timings +} +$metrics | ConvertTo-Json -Depth 3 | + Set-Content -LiteralPath (Join-Path $DiagnosticsDirectory 'output.json') +@( + '## StaticHost publish measurements' + '' + "- Verified $($metrics.frontendFiles) frontend files in the published host." + "- $($metrics.brotliFiles) Brotli files; $($metrics.brotliBytes) compressed bytes." + "- Container archive: $($metrics.containerArchiveBytes) bytes." + '- Verified exact Brotli round trips for home, Markdown, API HTML, and hashed CSS.' + '' + '```text' + $timings + '```' + '' + 'Full timings, resource usage, and MSBuild binary log: `statichost-publish-performance` artifact.' +) | Add-Content -LiteralPath $SummaryPath From 59dc212d95cb04e04aed8f0b0f850093d483c01b Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:25:28 -0500 Subject: [PATCH 2/8] Reject corrupted Brotli output in publish diagnostics Exercise the full-site verifier against incorrect compressed content without mutating shared publish fixtures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3761b826-bc44-40e0-99dd-a3162446f1a6 --- .../PublishingTargetsTests.cs | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/tests/StaticHost.Tests/PublishingTargetsTests.cs b/tests/StaticHost.Tests/PublishingTargetsTests.cs index c3e5a39fa5..f390f2619a 100644 --- a/tests/StaticHost.Tests/PublishingTargetsTests.cs +++ b/tests/StaticHost.Tests/PublishingTargetsTests.cs @@ -57,6 +57,14 @@ public async Task PublishMeasurementVerifiesAssetsAndRejectsIncompleteOutput() Assert.Contains("Frontend output is missing from the published host", incomplete.Log); } + [Fact] + public async Task PublishMeasurementRejectsIncorrectBrotliContent() + { + var result = await fixture.MeasureAsync(includeMissingFile: false, corruptBrotli: true); + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("Brotli content differs from the original", result.Log); + } + [Theory] [InlineData("/index.html")] [InlineData("/reference/api/sample.md")] @@ -163,10 +171,11 @@ public Task DisposeAsync() return Task.CompletedTask; } - public async Task<(int ExitCode, string Log)> MeasureAsync(bool includeMissingFile) + public async Task<(int ExitCode, string Log)> MeasureAsync(bool includeMissingFile, bool corruptBrotli = false) { - var directory = Path.Combine(_root, includeMissingFile ? "measure-incomplete" : "measure-complete"); + var directory = Path.Combine(_root, $"measure-{Guid.NewGuid():N}"); var frontend = Path.Combine(directory, "frontend"); + var publish = Path.Combine(directory, "publish"); var diagnostics = Path.Combine(directory, "diagnostics"); Directory.CreateDirectory(Path.Combine(diagnostics, "images")); foreach (var path in AssetPaths) @@ -174,11 +183,21 @@ public Task DisposeAsync() var file = Path.Combine(frontend, path); Directory.CreateDirectory(Path.GetDirectoryName(file)!); File.Copy(Path.Combine(DefaultDirectory, "wwwroot", path), file); + var published = Path.Combine(publish, "wwwroot", path); + Directory.CreateDirectory(Path.GetDirectoryName(published)!); + File.Copy(file, published); + File.Copy(Path.Combine(DefaultDirectory, "wwwroot", path + ".br"), published + ".br"); } if (includeMissingFile) { await File.WriteAllTextAsync(Path.Combine(frontend, "missing.html"), "missing"); } + if (corruptBrotli) + { + await using var output = File.Create(Path.Combine(publish, "wwwroot", "index.html.br")); + await using var compressor = new BrotliStream(output, CompressionLevel.Optimal); + await compressor.WriteAsync(Encoding.UTF8.GetBytes("incorrect content")); + } await File.WriteAllTextAsync(Path.Combine(diagnostics, "images", "fixture.tar.gz"), "fixture archive"); await File.WriteAllTextAsync(Path.Combine(diagnostics, "publish.log"), DefaultOutput); var start = new ProcessStartInfo("pwsh") @@ -192,7 +211,7 @@ public Task DisposeAsync() { "-NoProfile", "-NonInteractive", "-File", Path.Combine(AppContext.BaseDirectory, "PublishFixture", "Measure-StaticHostPublish.ps1"), - "-PublishDirectory", DefaultDirectory, + "-PublishDirectory", publish, "-FrontendDirectory", frontend, "-DiagnosticsDirectory", diagnostics, "-SummaryPath", Path.Combine(diagnostics, "summary.md") From 0ae48c5481e820a9cf449b6a5228514a24b45a83 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:45:32 -0500 Subject: [PATCH 3/8] Align dashboard persistence assertions with the writable volume example Update the stale mount and data-directory expectations to match the corrected non-root dashboard example in both shell variants. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3761b826-bc44-40e0-99dd-a3162446f1a6 --- .../tests/unit/dashboard-image-references.vitest.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frontend/tests/unit/dashboard-image-references.vitest.test.ts b/src/frontend/tests/unit/dashboard-image-references.vitest.test.ts index a4960b8891..1de12e5ba9 100644 --- a/src/frontend/tests/unit/dashboard-image-references.vitest.test.ts +++ b/src/frontend/tests/unit/dashboard-image-references.vitest.test.ts @@ -81,10 +81,10 @@ describe('standalone dashboard container images', () => { expect(commands).toHaveLength(2); for (const command of commands) { expect(command).toContain( - '--mount type=volume,source=aspire-dashboard-data,target=/dashboard-data' + '--mount type=volume,source=aspire-dashboard-data,target=/home/app' ); expect(command).toContain('-e ASPIRE_DASHBOARD_APPLICATION_NAME=my-app'); - expect(command).toContain('-e ASPIRE_DASHBOARD_DATA_DIRECTORY=/dashboard-data'); + expect(command).toContain('-e ASPIRE_DASHBOARD_DATA_DIRECTORY=/home/app/dashboard-data'); expect(command).toContain('-e ASPIRE_DASHBOARD_PERSISTENCE_MODE=run'); } }); From e0fdfee8664cf8f917a7d82057aa8a1662c39ea0 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:05:41 -0500 Subject: [PATCH 4/8] Profile compression size and browser tradeoffs on identical site output Add opt-in controlled SmallestSize versus Optimal publishing, image, CPU, memory, native decode, and cold/warm Chromium measurements without rendering the frontend twice. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3761b826-bc44-40e0-99dd-a3162446f1a6 --- .github/workflows/apphost-build.yml | 30 ++- .github/workflows/ci.yml | 1 + CONTRIBUTING.md | 24 ++ .../profile-static-host-compression.mjs | 207 ++++++++++++++++++ .../unit/compression-profile.vitest.test.ts | 19 ++ .../CompressionProfileTests.cs | 40 ++++ .../StaticHost.Tests/StaticHost.Tests.csproj | 2 + .../CompressionProfile/CompressionInputs.cs | 41 ++++ .../CompressionProfile.csproj | 15 ++ .../performance/CompressionProfile/Program.cs | 204 +++++++++++++++++ tests/scripts/Measure-StaticHostPublish.ps1 | 7 +- .../profile-static-host-compression.sh | 92 ++++++++ 12 files changed, 677 insertions(+), 5 deletions(-) create mode 100644 src/frontend/scripts/profile-static-host-compression.mjs create mode 100644 src/frontend/tests/unit/compression-profile.vitest.test.ts create mode 100644 tests/StaticHost.Tests/CompressionProfileTests.cs create mode 100644 tests/performance/CompressionProfile/CompressionInputs.cs create mode 100644 tests/performance/CompressionProfile/CompressionProfile.csproj create mode 100644 tests/performance/CompressionProfile/Program.cs create mode 100644 tests/scripts/profile-static-host-compression.sh diff --git a/.github/workflows/apphost-build.yml b/.github/workflows/apphost-build.yml index 3be184a4c1..8098f92a4c 100644 --- a/.github/workflows/apphost-build.yml +++ b/.github/workflows/apphost-build.yml @@ -8,6 +8,11 @@ on: required: false default: false type: boolean + profile_compression: + description: Compare compression, image sizes, and browser performance on identical generated content + required: false + default: false + type: boolean permissions: contents: read @@ -119,9 +124,9 @@ jobs: publish: name: StaticHost publish diagnostics - if: ${{ inputs.profile_publish }} + if: ${{ inputs.profile_publish || inputs.profile_compression }} runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 90 env: DOTNET_NOLOGO: 1 DOTNET_CLI_TELEMETRY_OPTOUT: 1 @@ -178,6 +183,22 @@ jobs: -DiagnosticsDirectory "$env:RUNNER_TEMP/statichost-publish" ` -SummaryPath $env:GITHUB_STEP_SUMMARY + - name: Build controlled compression profiler + if: ${{ inputs.profile_compression }} + run: > + dotnet build tests/performance/CompressionProfile/CompressionProfile.csproj + --configuration Release --output "$RUNNER_TEMP/statichost-publish/profiler" + + - name: Install Chromium for compression comparison + if: ${{ inputs.profile_compression }} + working-directory: src/frontend + run: pnpm exec playwright install --with-deps chromium + + - name: Compare complete compression variants and browser performance + if: ${{ inputs.profile_compression }} + shell: bash + run: bash tests/scripts/profile-static-host-compression.sh + - name: Upload publish diagnostics if: ${{ always() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -189,5 +210,10 @@ jobs: ${{ runner.temp }}/statichost-publish/*.binlog ${{ runner.temp }}/statichost-publish/*.log ${{ runner.temp }}/statichost-publish/*.txt + ${{ runner.temp }}/statichost-publish/comparison/**/*.json + ${{ runner.temp }}/statichost-publish/comparison/**/*.binlog + ${{ runner.temp }}/statichost-publish/comparison/**/*.log + ${{ runner.temp }}/statichost-publish/comparison/**/*.txt + ${{ runner.temp }}/statichost-publish/comparison/*.sha256 if-no-files-found: error retention-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 111c81edd4..52ce20bb57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,6 +104,7 @@ jobs: uses: ./.github/workflows/apphost-build.yml with: profile_publish: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'build-profile') }} + profile_compression: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'compression-profile') }} release-sync-tests: runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 854f94c086..efc61917c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -95,6 +95,30 @@ and representative Brotli round trips, and uploads binary log, resource measurements, frontend timings, and output sizes. It does not upload or deploy the container image. +Use the `compression-profile` label for a controlled `SmallestSize` versus +`Optimal` comparison. The job renders the frontend once, snapshots every output +file with SHA-256, and republishes each compression variant without rebuilding +the frontend or StaticHost assembly. It clears only the generated publish and +compression directories between variants. Both variants must retain identical +frontend content, assembly/configuration hashes, and container base layers. + +The comparison records Brotli and published-output totals, sizes by directory, +actual OCI archive sizes, compression timings, CPU time, and peak RSS. Chromium +measures response/transfer sizes, TTFB, FCP/LCP, main-thread activity, and JS heap +for home, a guide, and median-sized C# and TypeScript API pages. Each page gets +three cold/warm browser-cache pairs in desktop and mobile-like scenarios. +The mobile-like profile uses 5 Mbps downstream, 40 ms simulated latency, and +4x CPU throttling. An isolated native decoder measurement and 1/5/20 Mbps +body-byte transfer models are also included. + +These are lab measurements against Kestrel with the published SDK static-asset +manifest. Front Door, TLS, Redis, live APIs, external requests, and physical +device behavior are not measured. Server filesystem caches are not forcibly +evicted. Medians and ranges are descriptive, not statistical significance +claims. Review `comparison/comparison.json` in +`statichost-publish-performance` before deciding whether the byte-size tradeoff +is acceptable. + Measure full-site publishing in CI. Compare equivalent source commits, SDKs, runner images, and clean intermediate directories before attributing a timing change to compression. A full StaticHost publish also builds the frontend. diff --git a/src/frontend/scripts/profile-static-host-compression.mjs b/src/frontend/scripts/profile-static-host-compression.mjs new file mode 100644 index 0000000000..662b1ad8a4 --- /dev/null +++ b/src/frontend/scripts/profile-static-host-compression.mjs @@ -0,0 +1,207 @@ +import { createHash } from 'node:crypto'; +import { appendFile, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { chromium } from '@playwright/test'; + +/** @param {number[]} values @returns {number} */ +export function median(values) { + if (values.length === 0 || values.some((value) => !Number.isFinite(value))) { + throw new Error('Median requires nonempty finite measurements.'); + } + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; +} + +/** @param {number} extraBytes @param {number} megabitsPerSecond @returns {number} */ +export function transferPenaltyMs(extraBytes, megabitsPerSecond) { + if (!Number.isFinite(extraBytes) || !Number.isFinite(megabitsPerSecond) || megabitsPerSecond <= 0) { + throw new Error('Invalid transfer model inputs.'); + } + return extraBytes * 8 / (megabitsPerSecond * 1000); +} + +async function run(baseUrl, manifestPath, variant, output) { + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + const browser = await chromium.launch({ headless: true }); + const environment = { + node: process.version, chromium: browser.version(), platform: process.platform, + architecture: process.arch, runnerImage: process.env.ImageVersion, sourceCommit: process.env.GITHUB_SHA, + }; + const samples = []; + const serverBefore = await (await fetch(`${baseUrl}/__profile/stats`)).json(); + try { + for (const scenario of ['desktop', 'mobile-like']) { + for (const target of manifest.pages) { + for (let trial = 0; trial < 3; trial++) { + const context = await browser.newContext({ + viewport: scenario === 'desktop' ? { width: 1440, height: 900 } : { width: 390, height: 844 }, + locale: 'en-US', + }); + try { + await context.route('**/*', (route) => { + return new URL(route.request().url()).origin === new URL(baseUrl).origin + ? route.continue() : route.abort(); + }); + for (const cache of ['cold', 'warm']) { + const page = await context.newPage(); + try { + const session = await context.newCDPSession(page); + await session.send('Network.enable'); + if (cache === 'cold') await session.send('Network.clearBrowserCache'); + await session.send('Performance.enable'); + if (scenario === 'mobile-like') { + await session.send('Network.emulateNetworkConditions', { + offline: false, latency: 40, downloadThroughput: 625000, uploadThroughput: 312500, + }); + await session.send('Emulation.setCPUThrottlingRate', { rate: 4 }); + } + await page.addInitScript(() => { + globalThis.__compressionLcp = null; + new PerformanceObserver((list) => { + globalThis.__compressionLcp = list.getEntries().at(-1)?.startTime ?? null; + }).observe({ type: 'largest-contentful-paint', buffered: true }); + }); + const response = await page.goto(`${baseUrl}/${target.path}`, { waitUntil: 'load', timeout: 60000 }); + if (!response || response.status() !== 200 || response.headers()['content-encoding'] !== 'br') { + throw new Error(`Expected a successful Brotli response for ${target.path}.`); + } + const hash = createHash('sha256').update(await response.body()).digest('hex').toUpperCase(); + if (hash !== target.sha256) throw new Error(`Browser received different content: ${target.path}.`); + await page.waitForTimeout(1000); + const timing = await page.evaluate(() => { + const navigation = performance.getEntriesByType('navigation')[0]; + const resources = performance.getEntriesByType('resource').filter((entry) => + new URL(entry.name).origin === globalThis.location.origin); + return { + ttfbMs: navigation.responseStart - navigation.requestStart, + downloadMs: navigation.responseEnd - navigation.responseStart, + domContentLoadedMs: navigation.domContentLoadedEventEnd, + loadMs: navigation.loadEventEnd, + fcpMs: performance.getEntriesByName('first-contentful-paint')[0]?.startTime ?? null, + lcpMs: globalThis.__compressionLcp, + documentEncodedBytes: navigation.encodedBodySize, + documentDecodedBytes: navigation.decodedBodySize, + transferBytes: navigation.transferSize + resources.reduce((sum, entry) => sum + entry.transferSize, 0), + }; + }); + const performanceMetrics = Object.fromEntries((await session.send('Performance.getMetrics')) + .metrics.map(({ name, value }) => [name, value])); + samples.push({ + variant, scenario, page: target.name, path: target.path, trial, cache, ...timing, + mainThreadTaskMs: performanceMetrics.TaskDuration * 1000, + scriptMs: performanceMetrics.ScriptDuration * 1000, + layoutMs: performanceMetrics.LayoutDuration * 1000, + jsHeapBytes: performanceMetrics.JSHeapUsedSize, + }); + await writeFile(output, JSON.stringify({ variant, environment, samples, serverBefore }, null, 2)); + } finally { + await page.close(); + } + } + } finally { + await context.close(); + } + } + } + } + const serverAfter = await (await fetch(`${baseUrl}/__profile/stats`)).json(); + await writeFile(output, JSON.stringify({ variant, environment, samples, serverBefore, serverAfter }, null, 2)); + } finally { + await browser.close(); + } +} + +async function compare(directory, summaryPath) { + const variants = await Promise.all(['SmallestSize', 'Optimal'].map(async (name) => ({ + name, + output: JSON.parse(await readFile(`${directory}/${name}/files.json`, 'utf8')), + image: JSON.parse(await readFile(`${directory}/${name}/image.json`, 'utf8')), + browser: JSON.parse(await readFile(`${directory}/${name}/browser.json`, 'utf8')), + timings: JSON.parse(await readFile(`${directory}/${name}/output.json`, 'utf8')), + tasks: JSON.parse(await readFile(`${directory}/${name}/tasks.json`, 'utf8')), + resources: await readFile(`${directory}/${name}/resources.txt`, 'utf8'), + }))); + const [baseline, candidate] = variants; + if (JSON.stringify(baseline.image.baseLayers) !== JSON.stringify(candidate.image.baseLayers)) { + throw new Error('Container base layers changed between variants.'); + } + if (baseline.output.pages.some((page, index) => + page.sha256 !== candidate.output.pages[index]?.sha256)) { + throw new Error('Representative pages changed between variants.'); + } + const browser = baseline.browser.samples.filter(({ trial }) => trial === 0).map((key) => { + const select = (variant) => variant.browser.samples.filter((sample) => + sample.page === key.page && sample.scenario === key.scenario && sample.cache === key.cache); + return { + page: key.page, scenario: key.scenario, cache: key.cache, + variants: variants.map((variant) => { + const samples = select(variant); + return { + name: variant.name, samples: samples.length, + metrics: Object.fromEntries([ + 'ttfbMs', 'downloadMs', 'domContentLoadedMs', 'loadMs', 'fcpMs', 'lcpMs', + 'documentEncodedBytes', 'documentDecodedBytes', 'transferBytes', 'mainThreadTaskMs', + 'scriptMs', 'layoutMs', 'jsHeapBytes', + ].map((metric) => { + const values = samples.map((sample) => sample[metric]).filter((value) => value !== null); + return [metric, values.length ? { + median: median(values), min: Math.min(...values), max: Math.max(...values), + } : null]; + })), + }; + }), + }; + }); + const deltas = { + brotliBytes: candidate.output.brotliBytes - baseline.output.brotliBytes, + publishedBytes: candidate.output.totalBytes - baseline.output.totalBytes, + archiveBytes: candidate.image.archiveBytes - baseline.image.archiveBytes, + }; + const model = baseline.output.pages.map((page, index) => ({ + page: page.name, + extraBytes: candidate.output.pages[index].brotliBytes - page.brotliBytes, + extraTransferMs: Object.fromEntries([1, 5, 20].map((rate) => + [rate, transferPenaltyMs(candidate.output.pages[index].brotliBytes - page.brotliBytes, rate)])), + })); + const report = { + methodology: { + content: 'One SHA-256-verified generated frontend, same binaries and runner, clean compression outputs per variant.', + order: ['SmallestSize', 'Optimal'], + origin: 'Isolated Kestrel using the actual published MapStaticAssets manifest; no Front Door, TLS, Redis or live APIs.', + browser: 'Chromium; 3 cold/warm pairs per page/scenario; outside-origin requests blocked; 1-second LCP observation after load.', + mobile: '390x844 viewport, 5 Mbps downstream, 40 ms simulated latency, 4x CPU throttling; not a physical device.', + cache: 'Browser cache is cold or warm; server filesystem cache is not forcibly evicted.', + statistics: 'Descriptive medians and ranges; three pairs do not establish statistical significance.', + transferModel: 'Body-byte delta at 1/5/20 Mbps; excludes RTT, headers and multiplexing.', + }, + variants, deltas, browser, model, + }; + await writeFile(`${directory}/comparison.json`, JSON.stringify(report, null, 2)); + const lines = [ + '## Controlled compression comparison', '', + '| Metric | SmallestSize | Optimal | Delta |', + '| --- | ---: | ---: | ---: |', + `| Brotli task seconds | ${baseline.tasks.BrotliCompress.toFixed(3)} | ${candidate.tasks.BrotliCompress.toFixed(3)} | ${(candidate.tasks.BrotliCompress - baseline.tasks.BrotliCompress).toFixed(3)} |`, + `| Brotli bytes | ${baseline.output.brotliBytes} | ${candidate.output.brotliBytes} | ${deltas.brotliBytes} |`, + `| Published wwwroot bytes | ${baseline.output.totalBytes} | ${candidate.output.totalBytes} | ${deltas.publishedBytes} |`, + `| OCI archive bytes | ${baseline.image.archiveBytes} | ${candidate.image.archiveBytes} | ${deltas.archiveBytes} |`, + '', 'Compression timings and GNU time CPU/RSS measurements:', '', + ...variants.flatMap((variant) => [`### ${variant.name}`, '```text', ...variant.timings.timings, variant.resources, '```']), + '', 'Browser response, transfer, FCP/LCP, main-thread, heap, server CPU/RSS, and bandwidth-model data: `comparison.json`.', + 'These are controlled lab measurements, not deployed Front Door or field Core Web Vitals.', '', + ]; + await appendFile(summaryPath, lines.join('\n')); +} + +async function main() { + const [command, ...args] = process.argv.slice(2); + if (command === 'run' && args.length === 4) await run(...args); + else if (command === 'compare' && args.length === 2) await compare(...args); + else throw new Error('Usage: profile-static-host-compression.mjs run | compare '); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + await main(); +} diff --git a/src/frontend/tests/unit/compression-profile.vitest.test.ts b/src/frontend/tests/unit/compression-profile.vitest.test.ts new file mode 100644 index 0000000000..e6876c20ba --- /dev/null +++ b/src/frontend/tests/unit/compression-profile.vitest.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from 'vitest'; +import { median, transferPenaltyMs } from '../../scripts/profile-static-host-compression.mjs'; + +test('profile summaries use medians without mutating source observations', () => { + const values = [10, 3, 6]; + expect(median(values)).toBe(6); + expect(values).toEqual([10, 3, 6]); + expect(median([2, 8, 4, 6])).toBe(5); + expect(() => median([])).toThrow(); + expect(() => median([Number.NaN])).toThrow(); +}); + +test('byte-to-bandwidth model reports signed transfer deltas in milliseconds', () => { + expect(transferPenaltyMs(125000, 1)).toBe(1000); + expect(transferPenaltyMs(125000, 5)).toBe(200); + expect(transferPenaltyMs(-125000, 5)).toBe(-200); + expect(() => transferPenaltyMs(1, 0)).toThrow(); + expect(() => transferPenaltyMs(Number.NaN, 1)).toThrow(); +}); diff --git a/tests/StaticHost.Tests/CompressionProfileTests.cs b/tests/StaticHost.Tests/CompressionProfileTests.cs new file mode 100644 index 0000000000..172b673f62 --- /dev/null +++ b/tests/StaticHost.Tests/CompressionProfileTests.cs @@ -0,0 +1,40 @@ +namespace StaticHost.Tests; + +public sealed class CompressionProfileTests +{ + [Fact] + public void SnapshotDetectsChangedContentWithUnchangedFileSize() + { + using var root = new TempWebRoot(); + root.WriteFile("index.html", "before"); + var snapshot = CompressionInputs.Create(root.Path); + CompressionInputs.Verify(snapshot); + root.WriteFile("index.html", "change"); + Assert.Throws(() => CompressionInputs.Verify(snapshot)); + } + + [Fact] + public void SnapshotDetectsAddedAndRemovedFiles() + { + using var root = new TempWebRoot(); + root.WriteFile("index.html", "home"); + var snapshot = CompressionInputs.Create(root.Path); + root.WriteFile("other.html", "other"); + Assert.Throws(() => CompressionInputs.Verify(snapshot)); + File.Delete(Path.Combine(root.Path, "other.html")); + CompressionInputs.Verify(snapshot); + File.Delete(Path.Combine(root.Path, "index.html")); + Assert.Throws(() => CompressionInputs.Verify(snapshot)); + } + + [Theory] + [InlineData("reference/api/csharp/type/index.html", "reference/api/csharp")] + [InlineData("reference/api/typescript/type.md.br", "reference/api/typescript")] + [InlineData("reference/api/index.html.br", "reference/api")] + [InlineData("index.html", "(root)")] + [InlineData("_astro/app.css.br", "_astro")] + public void SizeGroupsKeepApiLanguagesAndStaticAssetsSeparate(string path, string expected) + { + Assert.Equal(expected, CompressionInputs.Group(path)); + } +} diff --git a/tests/StaticHost.Tests/StaticHost.Tests.csproj b/tests/StaticHost.Tests/StaticHost.Tests.csproj index 40e83b3882..4d1c3bd5ba 100644 --- a/tests/StaticHost.Tests/StaticHost.Tests.csproj +++ b/tests/StaticHost.Tests/StaticHost.Tests.csproj @@ -34,6 +34,8 @@ static layout it wants to assert on. --> + diff --git a/tests/performance/CompressionProfile/CompressionInputs.cs b/tests/performance/CompressionProfile/CompressionInputs.cs new file mode 100644 index 0000000000..c483db8b28 --- /dev/null +++ b/tests/performance/CompressionProfile/CompressionInputs.cs @@ -0,0 +1,41 @@ +using System.Security.Cryptography; + +public sealed record InputFile(string Path, long Bytes, string Sha256); +public sealed record InputSnapshot(string Root, InputFile[] Files); + +public static class CompressionInputs +{ + public static InputSnapshot Create(string directory) + { + var root = Path.GetFullPath(directory); + var files = Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories) + .OrderBy(path => path, StringComparer.Ordinal) + .Select(path => new InputFile(Path.GetRelativePath(root, path).Replace('\\', '/'), + new FileInfo(path).Length, Hash(path))).ToArray(); + if (files.Length == 0) throw new InvalidDataException("Generated frontend output is empty."); + return new InputSnapshot(root, files); + } + + public static void Verify(InputSnapshot expected) + { + var actual = Create(expected.Root); + if (!actual.Files.SequenceEqual(expected.Files)) + { + throw new InvalidDataException("Generated frontend content changed between compression variants."); + } + } + + public static string Hash(string path) + { + using var stream = File.OpenRead(path); + return Convert.ToHexString(SHA256.HashData(stream)); + } + + public static string Group(string path) + { + var parts = path.Split('/'); + return path.StartsWith("reference/api/", StringComparison.Ordinal) + ? parts.Length > 3 ? string.Join('/', parts.Take(3)) : "reference/api" + : parts.Length > 1 ? parts[0] : "(root)"; + } +} diff --git a/tests/performance/CompressionProfile/CompressionProfile.csproj b/tests/performance/CompressionProfile/CompressionProfile.csproj new file mode 100644 index 0000000000..bc211d338b --- /dev/null +++ b/tests/performance/CompressionProfile/CompressionProfile.csproj @@ -0,0 +1,15 @@ + + + net10.0 + enable + enable + + + + $(MSBuildBinPath)\Microsoft.Build.dll + + + $(MSBuildBinPath)\Microsoft.Build.Framework.dll + + + diff --git a/tests/performance/CompressionProfile/Program.cs b/tests/performance/CompressionProfile/Program.cs new file mode 100644 index 0000000000..155adcc7bb --- /dev/null +++ b/tests/performance/CompressionProfile/Program.cs @@ -0,0 +1,204 @@ +using System.Diagnostics; +using System.Formats.Tar; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Build.Framework; +using Microsoft.Build.Logging; + +if (args.Length == 0 || args.Length != (args[0] == "verify" ? 2 : 3)) +{ + throw new ArgumentException("Usage: CompressionProfile snapshot|describe|image|tasks|serve , or verify "); +} + +switch (args[0]) +{ + case "snapshot": + WriteJson(args[2], CompressionInputs.Create(args[1])); + break; + case "verify": + CompressionInputs.Verify(ReadJson(args[1])); + Console.WriteLine("[prebuilt-frontend] Verified unchanged generated content."); + break; + case "describe": + var webRoot = Path.Combine(args[1], "wwwroot"); + var files = Directory.EnumerateFiles(webRoot, "*", SearchOption.AllDirectories) + .Select(file => new { Path = Path.GetRelativePath(webRoot, file).Replace('\\', '/'), Bytes = new FileInfo(file).Length }) + .OrderBy(file => file.Path, StringComparer.Ordinal).ToArray(); + var groups = files.GroupBy(file => CompressionInputs.Group(file.Path)); + var pageGroups = new[] + { + ("home", "index.html"), + ("guide", "get-started/first-app/index.html"), + ("csharp", MedianPage("reference/api/csharp/")), + ("typescript", MedianPage("reference/api/typescript/")) + }; + WriteJson(args[2], new + { + environment = new + { + runtime = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription, + os = System.Runtime.InteropServices.RuntimeInformation.OSDescription, + architecture = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture.ToString(), + cores = Environment.ProcessorCount, + sourceCommit = Environment.GetEnvironmentVariable("GITHUB_SHA") + }, + files = files.Length, + totalBytes = files.Sum(file => file.Bytes), + brotliFiles = files.Count(file => file.Path.EndsWith(".br", StringComparison.Ordinal)), + brotliBytes = files.Where(file => file.Path.EndsWith(".br", StringComparison.Ordinal)).Sum(file => file.Bytes), + groups = groups.Select(group => new + { + name = group.Key, + bytes = group.Sum(file => file.Bytes), + brotliBytes = group.Where(file => file.Path.EndsWith(".br", StringComparison.Ordinal)).Sum(file => file.Bytes) + }), + pages = pageGroups.Select(page => new + { + name = page.Item1, + path = page.Item2, + bytes = new FileInfo(Path.Combine(webRoot, page.Item2)).Length, + brotliBytes = new FileInfo(Path.Combine(webRoot, page.Item2 + ".br")).Length, + sha256 = CompressionInputs.Hash(Path.Combine(webRoot, page.Item2)), + nativeDecode = DecodeProfile(Path.Combine(webRoot, page.Item2)) + }) + }); + string MedianPage(string prefix) + { + var pages = files.Where(file => file.Path.StartsWith(prefix, StringComparison.Ordinal) && + file.Path.EndsWith(".html", StringComparison.Ordinal)).OrderBy(file => file.Bytes) + .ThenBy(file => file.Path, StringComparer.Ordinal).ToArray(); + if (pages.Length == 0) throw new InvalidDataException($"No HTML pages for {prefix}."); + return pages[pages.Length / 2].Path; + } + break; + case "image": + WriteJson(args[2], InspectImage(args[1])); + break; + case "tasks": + var tasks = new Dictionary<(int Node, int Project, int Target, int Task), TaskStartedEventArgs>(); + var durations = new Dictionary(StringComparer.Ordinal); + var source = new BinaryLogReplayEventSource(); + source.TaskStarted += (_, e) => + { + if (e.TaskName is not ("BrotliCompress" or "Copy" or "CreateNewImage" or "UpdateExternallyDefinedStaticWebAssets")) return; + var c = e.BuildEventContext ?? throw new InvalidDataException("A task lacks build context."); + tasks.Add((c.NodeId, c.ProjectContextId, c.TargetId, c.TaskId), e); + }; + source.TaskFinished += (_, e) => + { + var c = e.BuildEventContext ?? throw new InvalidDataException("A task lacks build context."); + if (!tasks.Remove((c.NodeId, c.ProjectContextId, c.TargetId, c.TaskId), out var start)) return; + var name = e.TaskName ?? throw new InvalidDataException("A task lacks its name."); + durations[name] = durations.GetValueOrDefault(name) + (e.Timestamp - start.Timestamp).TotalSeconds; + }; + source.Replay(args[1]); + if (tasks.Count != 0 || !durations.ContainsKey("BrotliCompress")) + { + throw new InvalidDataException("Compression task timing is missing or incomplete."); + } + WriteJson(args[2], durations); + break; + case "serve": + var started = Stopwatch.StartNew(); + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + ContentRootPath = Path.GetFullPath(args[1]), + WebRootPath = "wwwroot", + EnvironmentName = Environments.Production + }); + builder.Logging.SetMinimumLevel(LogLevel.Warning); + builder.WebHost.UseUrls(args[2]); + await using (var app = builder.Build()) + { + app.UseDefaultFiles(); + app.UseRouting(); + app.MapGet("/__profile/stats", () => + { + using var process = Process.GetCurrentProcess(); + return new + { + cpuMilliseconds = process.TotalProcessorTime.TotalMilliseconds, + workingSetBytes = process.WorkingSet64, + peakWorkingSetBytes = process.PeakWorkingSet64, + startupMilliseconds = started.Elapsed.TotalMilliseconds + }; + }); + app.MapStaticAssets(Path.Combine(Path.GetFullPath(args[1]), "StaticHost.staticwebassets.endpoints.json")); + await app.StartAsync(); + started.Stop(); + Console.WriteLine($"Serving published SDK endpoints at {app.Services.GetRequiredService().Features.Get()?.Addresses.First()}"); + await app.WaitForShutdownAsync(); + } + break; + default: + throw new ArgumentException($"Unknown command: {args[0]}"); +} + +static T ReadJson(string path) => JsonSerializer.Deserialize(File.ReadAllText(path)) + ?? throw new InvalidDataException($"Invalid JSON in {path}."); + +static void WriteJson(string path, object value) => + File.WriteAllText(path, JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true })); + +static object DecodeProfile(string path) +{ + var compressed = File.ReadAllBytes(path + ".br"); + using (var input = new MemoryStream(compressed)) + using (var decoder = new BrotliStream(input, CompressionMode.Decompress)) + { + if (Convert.ToHexString(SHA256.HashData(decoder)) != CompressionInputs.Hash(path)) + { + throw new InvalidDataException($"Incorrect Brotli content: {path}."); + } + } + var milliseconds = new List(); + for (var repeat = 0; repeat < 31; repeat++) + { + using var input = new MemoryStream(compressed); + using var decoder = new BrotliStream(input, CompressionMode.Decompress); + var timer = Stopwatch.StartNew(); + decoder.CopyTo(Stream.Null); + timer.Stop(); + if (repeat > 0) milliseconds.Add(timer.Elapsed.TotalMilliseconds); + } + milliseconds.Sort(); + return new + { + samples = milliseconds.Count, + medianMilliseconds = (milliseconds[14] + milliseconds[15]) / 2, + minMilliseconds = milliseconds[0], + maxMilliseconds = milliseconds[^1], + methodology = ".NET BrotliStream decode from memory; one warmup, no disk IO; not Chromium decoder CPU." + }; +} + +static object InspectImage(string path) +{ + var documents = new Dictionary(StringComparer.Ordinal); + using var file = File.OpenRead(path); + var gzip = file.ReadByte() == 0x1f && file.ReadByte() == 0x8b; + file.Position = 0; + using Stream input = gzip ? new GZipStream(file, CompressionMode.Decompress) : file; + using var reader = new TarReader(input); + while (reader.GetNextEntry() is { } entry) + { + if (entry.DataStream is null || entry.Length > 1024 * 1024) continue; + using var data = new MemoryStream(); + entry.DataStream.CopyTo(data); + var bytes = data.ToArray(); + if (bytes.Length == 0 || bytes[0] != '{') continue; + using var document = JsonDocument.Parse(bytes); + documents.Add(entry.Name.TrimStart('.', '/'), document.RootElement.Clone()); + } + var index = documents["index.json"]; + var digest = index.GetProperty("manifests")[0].GetProperty("digest").GetString() + ?? throw new InvalidDataException("Missing OCI image digest."); + var manifest = documents["blobs/" + digest.Replace(':', '/')]; + var layers = manifest.GetProperty("layers").EnumerateArray() + .Select(layer => layer.GetProperty("digest").GetString()).ToArray(); + if (layers.Length < 2) throw new InvalidDataException("Expected base and application layers."); + return new { archiveBytes = new FileInfo(path).Length, baseLayers = layers[..^1], applicationLayer = layers[^1] }; +} diff --git a/tests/scripts/Measure-StaticHostPublish.ps1 b/tests/scripts/Measure-StaticHostPublish.ps1 index 656412e813..9caacd582d 100644 --- a/tests/scripts/Measure-StaticHostPublish.ps1 +++ b/tests/scripts/Measure-StaticHostPublish.ps1 @@ -2,7 +2,8 @@ param( [Parameter(Mandatory)][string]$PublishDirectory, [Parameter(Mandatory)][string]$FrontendDirectory, [Parameter(Mandatory)][string]$DiagnosticsDirectory, - [Parameter(Mandatory)][string]$SummaryPath + [Parameter(Mandatory)][string]$SummaryPath, + [ValidateSet('Optimal', 'SmallestSize')][string]$BrotliLevel = 'Optimal' ) $ErrorActionPreference = 'Stop' @@ -61,8 +62,8 @@ foreach ($phase in @('compression start', 'compression complete', 'static asset throw "Publish timing is missing: $phase" } } -if (-not ($timings | Where-Object { $_.Contains('brotliLevel=Optimal') })) { - throw 'Full-site publishing did not use Optimal Brotli.' +if (-not ($timings | Where-Object { $_.Contains("brotliLevel=$BrotliLevel") })) { + throw "Full-site publishing did not use $BrotliLevel Brotli." } $metrics = [ordered]@{ frontendFiles = $frontendFiles.Count diff --git a/tests/scripts/profile-static-host-compression.sh b/tests/scripts/profile-static-host-compression.sh new file mode 100644 index 0000000000..b4190a2e5d --- /dev/null +++ b/tests/scripts/profile-static-host-compression.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$RUNNER_TEMP/statichost-publish" +comparison="$root/comparison" +tool="$root/profiler/CompressionProfile.dll" +project="$GITHUB_WORKSPACE/src/statichost/StaticHost/StaticHost.csproj" +publish="$GITHUB_WORKSPACE/src/statichost/StaticHost/bin/Release/net10.0/linux-x64/publish" +compressed="$GITHUB_WORKSPACE/src/statichost/StaticHost/obj/Release/net10.0/linux-x64/compressed/publish" +frontend="$GITHUB_WORKSPACE/src/frontend/dist" +server_pid="" + +stop_server() { + if [[ -n "$server_pid" ]] && kill -0 "$server_pid" 2>/dev/null; then + kill "$server_pid" + wait "$server_pid" || true + fi + server_pid="" +} +trap stop_server EXIT + +mkdir -p "$comparison" +dotnet --info > "$comparison/dotnet-info.txt" +dotnet "$tool" snapshot "$frontend" "$comparison/frontend-input.json" +sha256sum "$publish/StaticHost.dll" "$publish/StaticHost.deps.json" \ + "$publish/StaticHost.runtimeconfig.json" > "$comparison/binaries.sha256" + +# The initial publish rendered the frontend once. Keep its measurements but +# remove its archive before producing the controlled pair on this same runner. +rm -- "$root/images/aspiredev.tar.gz" + +for level in SmallestSize Optimal; do + variant="$comparison/$level" + mkdir -p "$variant/images" + if [[ "$(realpath "$publish")" != "$publish" || "$(realpath "$compressed")" != "$compressed" ]]; then + echo "Refusing to clean redirected publish or compression directories." >&2 + exit 1 + fi + rm -rf -- "$publish" "$compressed" + /usr/bin/time -v -o "$variant/resources.txt" \ + dotnet publish "$project" --configuration Release --runtime linux-x64 --no-build \ + /t:PublishContainer \ + -p:BrotliCompressionLevel="$level" \ + "-p:BuildCommand=dotnet \"$tool\" verify \"$comparison/frontend-input.json\"" \ + -p:ContainerRepository=aspiredev \ + -p:ContainerImageTag=ci \ + -p:ContainerImageFormat=OCI \ + -p:ContainerArchiveOutputPath="$variant/images" \ + "-bl:$variant/publish.binlog;ProjectImports=None" \ + 2>&1 | tee "$variant/publish.log" + dotnet "$tool" verify "$comparison/frontend-input.json" + sha256sum --check "$comparison/binaries.sha256" + pwsh -NoProfile -NonInteractive -File "$GITHUB_WORKSPACE/tests/scripts/Measure-StaticHostPublish.ps1" \ + -PublishDirectory "$publish" -FrontendDirectory "$frontend" \ + -DiagnosticsDirectory "$variant" -SummaryPath "$GITHUB_STEP_SUMMARY" -BrotliLevel "$level" + dotnet "$tool" describe "$publish" "$variant/files.json" + dotnet "$tool" tasks "$variant/publish.binlog" "$variant/tasks.json" + dotnet "$tool" image "$variant/images/aspiredev.tar.gz" "$variant/image.json" + + dotnet "$tool" serve "$publish" http://127.0.0.1:4322 > "$variant/server.log" 2>&1 & + server_pid=$! + ready=false + for attempt in $(seq 1 120); do + if ! kill -0 "$server_pid" 2>/dev/null; then + cat "$variant/server.log" >&2 + echo "Published endpoint server exited before becoming ready." >&2 + exit 1 + fi + if curl --fail --silent http://127.0.0.1:4322/__profile/stats > /dev/null; then + ready=true + break + fi + sleep 1 + done + if [[ "$ready" != true ]]; then + cat "$variant/server.log" >&2 + echo "Published endpoint server did not become ready." >&2 + exit 1 + fi + ( + cd "$GITHUB_WORKSPACE/src/frontend" + node scripts/profile-static-host-compression.mjs run \ + http://127.0.0.1:4322 "$variant/files.json" "$level" "$variant/browser.json" + ) + stop_server + rm -- "$variant/images/aspiredev.tar.gz" +done + +( + cd "$GITHUB_WORKSPACE/src/frontend" + node scripts/profile-static-host-compression.mjs compare "$comparison" "$GITHUB_STEP_SUMMARY" +) From 31861a38bf6b3084d081c0a7cf8fcb00e2c703ff Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:46:35 -0500 Subject: [PATCH 5/8] Allow the approved three-hour controlled compression profile Keep ordinary publish diagnostics capped at 45 minutes; use the maintainer-approved three-hour limit only for the full compression comparison. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3761b826-bc44-40e0-99dd-a3162446f1a6 --- .github/workflows/apphost-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/apphost-build.yml b/.github/workflows/apphost-build.yml index 8098f92a4c..18fcf2d10e 100644 --- a/.github/workflows/apphost-build.yml +++ b/.github/workflows/apphost-build.yml @@ -126,7 +126,7 @@ jobs: name: StaticHost publish diagnostics if: ${{ inputs.profile_publish || inputs.profile_compression }} runs-on: ubuntu-latest - timeout-minutes: 90 + timeout-minutes: ${{ inputs.profile_compression && 180 || 45 }} env: DOTNET_NOLOGO: 1 DOTNET_CLI_TELEMETRY_OPTOUT: 1 From 2935fc9ad7a3b1e1dc70e3953d9e9ded9333f22d Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:32:21 -0500 Subject: [PATCH 6/8] Validate real browser caches without rerunning full compression Replace cache-disabling request interception with Chromium URL blocking. Reuse a completed paired corpus for SHA-verified representative browser-only profiling, requiring identical Brotli bytes and genuine cache hits. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3761b826-bc44-40e0-99dd-a3162446f1a6 --- .github/workflows/apphost-build.yml | 85 +++++++++++++++++++ .github/workflows/ci.yml | 2 + CONTRIBUTING.md | 12 +++ .../profile-static-host-compression.mjs | 45 ++++++++-- .../unit/compression-profile.vitest.test.ts | 10 +++ .../performance/CompressionProfile/Program.cs | 46 +++++++++- tests/scripts/profile-static-host-browser.sh | 65 ++++++++++++++ 7 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 tests/scripts/profile-static-host-browser.sh diff --git a/.github/workflows/apphost-build.yml b/.github/workflows/apphost-build.yml index 18fcf2d10e..cd43983b23 100644 --- a/.github/workflows/apphost-build.yml +++ b/.github/workflows/apphost-build.yml @@ -13,9 +13,15 @@ on: required: false default: false type: boolean + profile_browser: + description: Rerun browser/cache profiling from the latest successful paired corpus + required: false + default: false + type: boolean permissions: contents: read + actions: read jobs: build: @@ -122,6 +128,85 @@ jobs: if-no-files-found: warn retention-days: 7 + browser-profile: + name: StaticHost browser-cache profile + if: ${{ inputs.profile_browser }} + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + DOTNET_NOLOGO: 1 + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + ASTRO_TELEMETRY_DISABLED: 1 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24.x" + cache: pnpm + cache-dependency-path: src/frontend/pnpm-lock.yaml + - name: Select the latest successful paired source on this branch + id: source + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + SOURCE_BRANCH: ${{ github.head_ref || github.ref_name }} + shell: bash + run: | + set -euo pipefail + candidates=$(gh run list --workflow ci.yml --branch "$SOURCE_BRANCH" --status success \ + --limit 20 --json databaseId --jq '.[].databaseId') + for id in $candidates; do + matched=$(gh api "repos/$GH_REPO/actions/runs/$id/jobs" --jq \ + 'any(.jobs[]; .name == "apphost-build / StaticHost publish diagnostics" and any(.steps[]; .name == "Compare complete compression variants and browser performance" and .conclusion == "success"))') + if [[ "$matched" == true ]]; then + echo "run_id=$id" >> "$GITHUB_OUTPUT" + echo "Reusing paired corpus from run $id." + exit 0 + fi + done + echo "No completed paired compression profile was found on this branch." >&2 + exit 1 + - name: Download paired measurements + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: statichost-publish-performance + path: ${{ runner.temp }}/statichost-browser/source + run-id: ${{ steps.source.outputs.run_id }} + github-token: ${{ github.token }} + - name: Download the same generated frontend + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: frontend-dist + path: ${{ runner.temp }}/statichost-browser/frontend + run-id: ${{ steps.source.outputs.run_id }} + github-token: ${{ github.token }} + - name: Install browser profiler dependencies + working-directory: src/frontend + run: pnpm install --frozen-lockfile --prefer-offline && pnpm exec playwright install --with-deps chromium + - name: Build representative browser profiler + run: > + dotnet build tests/performance/CompressionProfile/CompressionProfile.csproj + --configuration Release --output "$RUNNER_TEMP/statichost-browser/profiler" + - name: Measure genuine cold and warm HTTP caches + shell: bash + run: bash tests/scripts/profile-static-host-browser.sh + - name: Upload browser-cache measurements + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: statichost-browser-performance + path: | + ${{ runner.temp }}/statichost-browser/results/**/*.json + ${{ runner.temp }}/statichost-browser/results/**/*.log + if-no-files-found: error + retention-days: 7 + publish: name: StaticHost publish diagnostics if: ${{ inputs.profile_publish || inputs.profile_compression }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52ce20bb57..e8d911b1a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ concurrency: permissions: contents: read + actions: read security-events: write jobs: @@ -105,6 +106,7 @@ jobs: with: profile_publish: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'build-profile') }} profile_compression: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'compression-profile') }} + profile_browser: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'browser-profile') }} release-sync-tests: runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index efc61917c3..145494a8c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,6 +119,18 @@ claims. Review `comparison/comparison.json` in `statichost-publish-performance` before deciding whether the byte-size tradeoff is acceptable. +Use `browser-profile` to repeat only browser/cache measurements from the latest +successful paired profile on the same pull-request branch. It downloads that +run's generated frontend, copies the representative pages and static assets +into an SDK endpoint fixture, and requires matching page hashes and Brotli +bytes. It does not render the frontend or recompress the complete API catalog. +The fixture has fewer routes, so its startup time and RSS are not full-site +origin measurements. Results are in `statichost-browser-performance`. + +External URLs are blocked through Chromium's native network controls. +Playwright request interception disables HTTP caching and must not be used in +cache profiles. Warm observations explicitly require cached resource hits. + Measure full-site publishing in CI. Compare equivalent source commits, SDKs, runner images, and clean intermediate directories before attributing a timing change to compression. A full StaticHost publish also builds the frontend. diff --git a/src/frontend/scripts/profile-static-host-compression.mjs b/src/frontend/scripts/profile-static-host-compression.mjs index 662b1ad8a4..1d16a233d4 100644 --- a/src/frontend/scripts/profile-static-host-compression.mjs +++ b/src/frontend/scripts/profile-static-host-compression.mjs @@ -40,15 +40,21 @@ async function run(baseUrl, manifestPath, variant, output) { locale: 'en-US', }); try { - await context.route('**/*', (route) => { - return new URL(route.request().url()).origin === new URL(baseUrl).origin - ? route.continue() : route.abort(); - }); for (const cache of ['cold', 'warm']) { const page = await context.newPage(); try { const session = await context.newCDPSession(page); await session.send('Network.enable'); + // Playwright routing disables HTTP caching, so block external + // URLs in Chromium without installing a request interceptor. + await session.send('Network.setBlockedURLs', { + urlPatterns: [ + { urlPattern: `${new URL(baseUrl).origin}/*`, block: false }, + { urlPattern: 'http://*:*/*', block: true }, + { urlPattern: 'https://*:*/*', block: true }, + ], + }); + await session.send('Network.setCacheDisabled', { cacheDisabled: false }); if (cache === 'cold') await session.send('Network.clearBrowserCache'); await session.send('Performance.enable'); if (scenario === 'mobile-like') { @@ -84,8 +90,12 @@ async function run(baseUrl, manifestPath, variant, output) { documentEncodedBytes: navigation.encodedBodySize, documentDecodedBytes: navigation.decodedBodySize, transferBytes: navigation.transferSize + resources.reduce((sum, entry) => sum + entry.transferSize, 0), + cachedResourceCount: resources.filter((entry) => entry.transferSize === 0 && entry.encodedBodySize > 0).length, }; }); + if (cache === 'warm' && timing.cachedResourceCount === 0) { + throw new Error(`Warm navigation did not reuse HTTP-cached resources: ${target.path}.`); + } const performanceMetrics = Object.fromEntries((await session.send('Performance.getMetrics')) .metrics.map(({ name, value }) => [name, value])); samples.push({ @@ -144,6 +154,7 @@ async function compare(directory, summaryPath) { 'ttfbMs', 'downloadMs', 'domContentLoadedMs', 'loadMs', 'fcpMs', 'lcpMs', 'documentEncodedBytes', 'documentDecodedBytes', 'transferBytes', 'mainThreadTaskMs', 'scriptMs', 'layoutMs', 'jsHeapBytes', + 'cachedResourceCount', ].map((metric) => { const values = samples.map((sample) => sample[metric]).filter((value) => value !== null); return [metric, values.length ? { @@ -195,11 +206,35 @@ async function compare(directory, summaryPath) { await appendFile(summaryPath, lines.join('\n')); } +async function browserReport(directory, originalComparison, summaryPath) { + const source = JSON.parse(await readFile(originalComparison, 'utf8')); + const variants = await Promise.all(['SmallestSize', 'Optimal'].map(async (name) => ({ + name, + output: JSON.parse(await readFile(`${directory}/${name}/files.json`, 'utf8')), + browser: JSON.parse(await readFile(`${directory}/${name}/browser.json`, 'utf8')), + }))); + for (const variant of variants) { + const original = source.variants.find((candidate) => candidate.name === variant.name); + if (!original || variant.output.pages.some((page, index) => + page.sha256 !== original.output.pages[index]?.sha256 || + page.brotliBytes !== original.output.pages[index]?.brotliBytes)) { + throw new Error('Browser follow-up changed representative content or compression bytes.'); + } + } + await writeFile(`${directory}/browser-comparison.json`, JSON.stringify({ + sourceCommit: source.variants[0].output.environment.sourceCommit, + methodology: 'Representative pages and static assets from the completed full-corpus profile. Page hashes and Brotli bytes must match. Chromium-native URL blocking preserves HTTP caching; warm samples require cache hits. Kestrel fixture has fewer routes, so its startup/RSS is not the full-site origin footprint.', + variants, + }, null, 2)); + await appendFile(summaryPath, '\n## Corrected browser-cache comparison\n\nPage hashes and Brotli bytes match the completed full-site profile. Genuine warm HTTP-cache hits were verified. See `browser-comparison.json`; earlier interceptor-based browser observations are superseded.\n'); +} + async function main() { const [command, ...args] = process.argv.slice(2); if (command === 'run' && args.length === 4) await run(...args); else if (command === 'compare' && args.length === 2) await compare(...args); - else throw new Error('Usage: profile-static-host-compression.mjs run | compare '); + else if (command === 'browser-report' && args.length === 3) await browserReport(...args); + else throw new Error('Usage: profile-static-host-compression.mjs run | compare | browser-report '); } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { diff --git a/src/frontend/tests/unit/compression-profile.vitest.test.ts b/src/frontend/tests/unit/compression-profile.vitest.test.ts index e6876c20ba..4fda0b5359 100644 --- a/src/frontend/tests/unit/compression-profile.vitest.test.ts +++ b/src/frontend/tests/unit/compression-profile.vitest.test.ts @@ -1,4 +1,5 @@ import { expect, test } from 'vitest'; +import { readFile } from 'node:fs/promises'; import { median, transferPenaltyMs } from '../../scripts/profile-static-host-compression.mjs'; test('profile summaries use medians without mutating source observations', () => { @@ -17,3 +18,12 @@ test('byte-to-bandwidth model reports signed transfer deltas in milliseconds', ( expect(() => transferPenaltyMs(1, 0)).toThrow(); expect(() => transferPenaltyMs(Number.NaN, 1)).toThrow(); }); + +test('browser cache profiling blocks external URLs without disabling HTTP cache', async () => { + const source = await readFile(new URL('../../scripts/profile-static-host-compression.mjs', import.meta.url), 'utf8'); + expect(source).not.toMatch(/(?:context|page)\.route\(/); + expect(source).toContain('Network.setBlockedURLs'); + expect(source).toContain('Network.setCacheDisabled'); + expect(source).toContain('cacheDisabled: false'); + expect(source).toContain("cache === 'warm' && timing.cachedResourceCount === 0"); +}); diff --git a/tests/performance/CompressionProfile/Program.cs b/tests/performance/CompressionProfile/Program.cs index 155adcc7bb..cf9b3c5054 100644 --- a/tests/performance/CompressionProfile/Program.cs +++ b/tests/performance/CompressionProfile/Program.cs @@ -3,12 +3,13 @@ using System.IO.Compression; using System.Security.Cryptography; using System.Text.Json; +using System.Xml.Linq; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.Build.Framework; using Microsoft.Build.Logging; -if (args.Length == 0 || args.Length != (args[0] == "verify" ? 2 : 3)) +if (args.Length == 0 || args.Length != (args[0] == "verify" ? 2 : args[0] == "subset" ? 5 : 3)) { throw new ArgumentException("Usage: CompressionProfile snapshot|describe|image|tasks|serve , or verify "); } @@ -18,6 +19,49 @@ case "snapshot": WriteJson(args[2], CompressionInputs.Create(args[1])); break; + case "subset": + using (var comparison = JsonDocument.Parse(File.ReadAllText(args[2]))) + { + var expected = comparison.RootElement.GetProperty("variants")[0].GetProperty("output").GetProperty("pages"); + var selected = expected.EnumerateArray().Select(page => page.GetProperty("path").GetString() + ?? throw new InvalidDataException("Missing representative page path.")).ToHashSet(StringComparer.Ordinal); + var root = Path.GetFullPath(args[1]); + var destination = Path.GetFullPath(args[3]); + var subsetWebRoot = Path.Combine(destination, "wwwroot"); + var extensions = new HashSet(StringComparer.OrdinalIgnoreCase) + { + ".js", ".mjs", ".css", ".svg", ".png", ".jpg", ".jpeg", ".webp", ".gif", + ".ico", ".woff", ".woff2", ".otf", ".ttf", ".wasm", ".webmanifest" + }; + foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(root, file).Replace('\\', '/'); + if (!selected.Contains(relative) && !extensions.Contains(Path.GetExtension(file))) continue; + var output = Path.Combine(subsetWebRoot, relative); + Directory.CreateDirectory(Path.GetDirectoryName(output)!); + File.Copy(file, output, overwrite: false); + } + foreach (var page in expected.EnumerateArray()) + { + var path = Path.Combine(subsetWebRoot, page.GetProperty("path").GetString()!); + if (CompressionInputs.Hash(path) != page.GetProperty("sha256").GetString()) + { + throw new InvalidDataException($"Downloaded representative page differs from the complete-corpus profile: {path}."); + } + } + Directory.CreateDirectory(destination); + File.WriteAllText(Path.Combine(destination, "Program.cs"), + "var app = WebApplication.CreateBuilder(args).Build(); app.MapStaticAssets(); app.Run();"); + var project = new XDocument(new XElement("Project", new XAttribute("Sdk", "Microsoft.NET.Sdk.Web"), + new XElement("PropertyGroup", + new XElement("TargetFramework", "net10.0"), new XElement("ImplicitUsings", "enable"), + new XElement("EnableDefaultCompressionFormats", "false"), + new XElement("PublishCompressionFormats", "brotli")), + new XElement("Import", new XAttribute("Project", Path.Combine(Path.GetFullPath(args[4]), "Publishing.targets"))), + new XElement("Import", new XAttribute("Project", Path.Combine(Path.GetFullPath(args[4]), "Caching.targets"))))); + File.WriteAllText(Path.Combine(destination, "StaticHost.csproj"), project.ToString()); + } + break; case "verify": CompressionInputs.Verify(ReadJson(args[1])); Console.WriteLine("[prebuilt-frontend] Verified unchanged generated content."); diff --git a/tests/scripts/profile-static-host-browser.sh b/tests/scripts/profile-static-host-browser.sh new file mode 100644 index 0000000000..46cc27cadd --- /dev/null +++ b/tests/scripts/profile-static-host-browser.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$RUNNER_TEMP/statichost-browser" +tool="$root/profiler/CompressionProfile.dll" +source="$root/source/comparison/comparison.json" +site="$root/site" +server_pid="" +stop_server() { + if [[ -n "$server_pid" ]] && kill -0 "$server_pid" 2>/dev/null; then + kill "$server_pid" + wait "$server_pid" || true + fi + server_pid="" +} +trap stop_server EXIT + +dotnet "$tool" subset "$root/frontend" "$source" "$site" \ + "$GITHUB_WORKSPACE/src/statichost/StaticHost" + +for level in SmallestSize Optimal; do + variant="$root/results/$level" + mkdir -p "$variant" + if [[ -d "$site/obj/Release/net10.0/compressed/publish" ]]; then + if [[ "$(realpath "$site/obj/Release/net10.0/compressed/publish")" != "$site/obj/Release/net10.0/compressed/publish" ]]; then + echo "Refusing to clean a redirected fixture compression directory." >&2 + exit 1 + fi + rm -rf -- "$site/obj/Release/net10.0/compressed/publish" + fi + dotnet publish "$site/StaticHost.csproj" --configuration Release --output "$variant/publish" \ + -p:BrotliCompressionLevel="$level" 2>&1 | tee "$variant/publish.log" + dotnet "$tool" describe "$variant/publish" "$variant/files.json" + dotnet "$tool" serve "$variant/publish" http://127.0.0.1:4322 > "$variant/server.log" 2>&1 & + server_pid=$! + ready=false + for attempt in $(seq 1 120); do + if ! kill -0 "$server_pid" 2>/dev/null; then + cat "$variant/server.log" >&2 + echo "Representative endpoint server exited before readiness." >&2 + exit 1 + fi + if curl --fail --silent http://127.0.0.1:4322/__profile/stats > /dev/null; then + ready=true + break + fi + sleep 1 + done + if [[ "$ready" != true ]]; then + echo "Representative endpoint server did not become ready." >&2 + exit 1 + fi + ( + cd "$GITHUB_WORKSPACE/src/frontend" + node scripts/profile-static-host-compression.mjs run \ + http://127.0.0.1:4322 "$variant/files.json" "$level" "$variant/browser.json" + ) + stop_server +done + +( + cd "$GITHUB_WORKSPACE/src/frontend" + node scripts/profile-static-host-compression.mjs browser-report \ + "$root/results" "$source" "$GITHUB_STEP_SUMMARY" +) From 8b7c52318cb1da38af1f329e591c4df8ea3d4cc7 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:56:01 -0500 Subject: [PATCH 7/8] Keep only the production Brotli compression configuration Remove investigative profiling tools, added tests, workflow changes, documentation, unrelated dashboard assertions, and redundant timing messages. Keep the Optimal default in StaticHost.csproj and only the .NET 10 publish-property compatibility mapping in Publishing.targets. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3761b826-bc44-40e0-99dd-a3162446f1a6 --- .github/workflows/apphost-build.yml | 192 ----------- .github/workflows/ci.yml | 5 - CONTRIBUTING.md | 78 ----- .../profile-static-host-compression.mjs | 242 -------------- .../unit/compression-profile.vitest.test.ts | 29 -- .../dashboard-image-references.vitest.test.ts | 4 +- src/statichost/StaticHost/Publishing.targets | 27 -- src/statichost/StaticHost/StaticHost.csproj | 1 + .../CompressionProfileTests.cs | 40 --- .../PublishingTargetsTests.cs | 298 ------------------ .../StaticHost.Tests/StaticHost.Tests.csproj | 11 - .../CompressionProfile/CompressionInputs.cs | 41 --- .../CompressionProfile.csproj | 15 - .../performance/CompressionProfile/Program.cs | 248 --------------- tests/scripts/Measure-StaticHostPublish.ps1 | 92 ------ tests/scripts/profile-static-host-browser.sh | 65 ---- .../profile-static-host-compression.sh | 92 ------ 17 files changed, 3 insertions(+), 1477 deletions(-) delete mode 100644 src/frontend/scripts/profile-static-host-compression.mjs delete mode 100644 src/frontend/tests/unit/compression-profile.vitest.test.ts delete mode 100644 tests/StaticHost.Tests/CompressionProfileTests.cs delete mode 100644 tests/StaticHost.Tests/PublishingTargetsTests.cs delete mode 100644 tests/performance/CompressionProfile/CompressionInputs.cs delete mode 100644 tests/performance/CompressionProfile/CompressionProfile.csproj delete mode 100644 tests/performance/CompressionProfile/Program.cs delete mode 100644 tests/scripts/Measure-StaticHostPublish.ps1 delete mode 100644 tests/scripts/profile-static-host-browser.sh delete mode 100644 tests/scripts/profile-static-host-compression.sh diff --git a/.github/workflows/apphost-build.yml b/.github/workflows/apphost-build.yml index cd43983b23..d8fe952aa0 100644 --- a/.github/workflows/apphost-build.yml +++ b/.github/workflows/apphost-build.yml @@ -2,26 +2,9 @@ name: AppHost Build on: workflow_call: - inputs: - profile_publish: - description: Publish the complete site and capture static-asset and container diagnostics - required: false - default: false - type: boolean - profile_compression: - description: Compare compression, image sizes, and browser performance on identical generated content - required: false - default: false - type: boolean - profile_browser: - description: Rerun browser/cache profiling from the latest successful paired corpus - required: false - default: false - type: boolean permissions: contents: read - actions: read jobs: build: @@ -127,178 +110,3 @@ jobs: path: src/apphost/Aspire.Dev.AppHost/bin/Release/*/ if-no-files-found: warn retention-days: 7 - - browser-profile: - name: StaticHost browser-cache profile - if: ${{ inputs.profile_browser }} - runs-on: ubuntu-latest - timeout-minutes: 45 - env: - DOTNET_NOLOGO: 1 - DOTNET_CLI_TELEMETRY_OPTOUT: 1 - ASTRO_TELEMETRY_DISABLED: 1 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - global-json-file: global.json - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: "24.x" - cache: pnpm - cache-dependency-path: src/frontend/pnpm-lock.yaml - - name: Select the latest successful paired source on this branch - id: source - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - SOURCE_BRANCH: ${{ github.head_ref || github.ref_name }} - shell: bash - run: | - set -euo pipefail - candidates=$(gh run list --workflow ci.yml --branch "$SOURCE_BRANCH" --status success \ - --limit 20 --json databaseId --jq '.[].databaseId') - for id in $candidates; do - matched=$(gh api "repos/$GH_REPO/actions/runs/$id/jobs" --jq \ - 'any(.jobs[]; .name == "apphost-build / StaticHost publish diagnostics" and any(.steps[]; .name == "Compare complete compression variants and browser performance" and .conclusion == "success"))') - if [[ "$matched" == true ]]; then - echo "run_id=$id" >> "$GITHUB_OUTPUT" - echo "Reusing paired corpus from run $id." - exit 0 - fi - done - echo "No completed paired compression profile was found on this branch." >&2 - exit 1 - - name: Download paired measurements - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: statichost-publish-performance - path: ${{ runner.temp }}/statichost-browser/source - run-id: ${{ steps.source.outputs.run_id }} - github-token: ${{ github.token }} - - name: Download the same generated frontend - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: frontend-dist - path: ${{ runner.temp }}/statichost-browser/frontend - run-id: ${{ steps.source.outputs.run_id }} - github-token: ${{ github.token }} - - name: Install browser profiler dependencies - working-directory: src/frontend - run: pnpm install --frozen-lockfile --prefer-offline && pnpm exec playwright install --with-deps chromium - - name: Build representative browser profiler - run: > - dotnet build tests/performance/CompressionProfile/CompressionProfile.csproj - --configuration Release --output "$RUNNER_TEMP/statichost-browser/profiler" - - name: Measure genuine cold and warm HTTP caches - shell: bash - run: bash tests/scripts/profile-static-host-browser.sh - - name: Upload browser-cache measurements - if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: statichost-browser-performance - path: | - ${{ runner.temp }}/statichost-browser/results/**/*.json - ${{ runner.temp }}/statichost-browser/results/**/*.log - if-no-files-found: error - retention-days: 7 - - publish: - name: StaticHost publish diagnostics - if: ${{ inputs.profile_publish || inputs.profile_compression }} - runs-on: ubuntu-latest - timeout-minutes: ${{ inputs.profile_compression && 180 || 45 }} - env: - DOTNET_NOLOGO: 1 - DOTNET_CLI_TELEMETRY_OPTOUT: 1 - ASTRO_TELEMETRY_DISABLED: 1 - NODE_OPTIONS: --max-old-space-size=8192 - BUILD_TIMING: 1 - BUILD_TIMING_LABEL: ${{ github.sha }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - - - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - global-json-file: global.json - - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: "24.x" - cache: pnpm - cache-dependency-path: src/frontend/pnpm-lock.yaml - - - name: Install frontend dependencies - working-directory: src/frontend - run: pnpm install --frozen-lockfile --prefer-offline - - - name: Publish complete StaticHost container archive - env: - BUILD_TIMING_OUT: ${{ runner.temp }}/statichost-publish/frontend-timing.jsonl - shell: bash - run: | - set -euo pipefail - mkdir -p "$RUNNER_TEMP/statichost-publish/images" - /usr/bin/time -v -o "$RUNNER_TEMP/statichost-publish/resources.txt" \ - dotnet publish src/statichost/StaticHost/StaticHost.csproj \ - --configuration Release --runtime linux-x64 \ - /t:PublishContainer \ - -p:ContainerRepository=aspiredev \ - -p:ContainerImageTag=ci \ - -p:ContainerImageFormat=OCI \ - -p:ContainerArchiveOutputPath="$RUNNER_TEMP/statichost-publish/images" \ - "-bl:$RUNNER_TEMP/statichost-publish/publish.binlog;ProjectImports=None" \ - 2>&1 | tee "$RUNNER_TEMP/statichost-publish/publish.log" - - - name: Verify published files and summarize timings - shell: pwsh - run: | - & ./tests/scripts/Measure-StaticHostPublish.ps1 ` - -PublishDirectory ./src/statichost/StaticHost/bin/Release/net10.0/linux-x64/publish ` - -FrontendDirectory ./src/frontend/dist ` - -DiagnosticsDirectory "$env:RUNNER_TEMP/statichost-publish" ` - -SummaryPath $env:GITHUB_STEP_SUMMARY - - - name: Build controlled compression profiler - if: ${{ inputs.profile_compression }} - run: > - dotnet build tests/performance/CompressionProfile/CompressionProfile.csproj - --configuration Release --output "$RUNNER_TEMP/statichost-publish/profiler" - - - name: Install Chromium for compression comparison - if: ${{ inputs.profile_compression }} - working-directory: src/frontend - run: pnpm exec playwright install --with-deps chromium - - - name: Compare complete compression variants and browser performance - if: ${{ inputs.profile_compression }} - shell: bash - run: bash tests/scripts/profile-static-host-compression.sh - - - name: Upload publish diagnostics - if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: statichost-publish-performance - path: | - ${{ runner.temp }}/statichost-publish/*.json - ${{ runner.temp }}/statichost-publish/*.jsonl - ${{ runner.temp }}/statichost-publish/*.binlog - ${{ runner.temp }}/statichost-publish/*.log - ${{ runner.temp }}/statichost-publish/*.txt - ${{ runner.temp }}/statichost-publish/comparison/**/*.json - ${{ runner.temp }}/statichost-publish/comparison/**/*.binlog - ${{ runner.temp }}/statichost-publish/comparison/**/*.log - ${{ runner.temp }}/statichost-publish/comparison/**/*.txt - ${{ runner.temp }}/statichost-publish/comparison/*.sha256 - if-no-files-found: error - retention-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8d911b1a8..615e43ca96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,6 @@ concurrency: permissions: contents: read - actions: read security-events: write jobs: @@ -103,10 +102,6 @@ jobs: needs: changes if: ${{ needs.changes.outputs.apphost == 'true' }} uses: ./.github/workflows/apphost-build.yml - with: - profile_publish: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'build-profile') }} - profile_compression: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'compression-profile') }} - profile_browser: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'browser-profile') }} release-sync-tests: runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 145494a8c9..dcc1ba8175 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,84 +58,6 @@ setting only to the vnext build, not the live build. If vnext builds a different source branch, set the intended label explicitly instead. Changing the badge on a deployed static site requires rebuilding its frontend assets. -## StaticHost publishing - -StaticHost publishes Brotli-compressed static assets using `Optimal` compression. -This reduces compression time for the generated documentation and API catalog. -HTML, Markdown, and other original assets remain available to clients that do -not accept Brotli. Hashed `_astro/` assets retain their immutable cache headers. - -`src/statichost/StaticHost/Publishing.targets` sets the compression default and -maps `BrotliCompressionLevel` to the compatibility property that .NET 10's -publish target consumes. Explicit public or compatibility-property overrides -remain supported. - -Publish logs include `[publish-timing]` messages with the compression level, -asset count, compression duration, and subsequent static-asset metadata and -manifest duration. Use these messages to distinguish MSBuild work from the -frontend build and container archive creation. - -Run the publishing regression tests without building the frontend: - -```shell title="Run isolated static-asset publishing tests" -dotnet test tests/StaticHost.Tests/StaticHost.Tests.csproj --configuration Release --filter FullyQualifiedName~PublishingTargetsTests -``` - -These tests publish small isolated projects using the production targets. They -check the default and overridden compression levels, compressed file contents, -HTTP content negotiation, and cache headers. AppHost CI runs them with the -StaticHost tests and includes their publish binary logs in -`logs-AppHost-ubuntu-latest`. - -Add the `build-profile` label to a pull request to run full StaticHost publishing -diagnostics alongside the frontend profile. This opt-in CI job builds the -frontend, publishes the Linux container archive, verifies the frontend output -and representative Brotli round trips, and uploads -`statichost-publish-performance`. The artifact contains the publish log, MSBuild -binary log, resource measurements, frontend timings, and output sizes. It does -not upload or deploy the container image. - -Use the `compression-profile` label for a controlled `SmallestSize` versus -`Optimal` comparison. The job renders the frontend once, snapshots every output -file with SHA-256, and republishes each compression variant without rebuilding -the frontend or StaticHost assembly. It clears only the generated publish and -compression directories between variants. Both variants must retain identical -frontend content, assembly/configuration hashes, and container base layers. - -The comparison records Brotli and published-output totals, sizes by directory, -actual OCI archive sizes, compression timings, CPU time, and peak RSS. Chromium -measures response/transfer sizes, TTFB, FCP/LCP, main-thread activity, and JS heap -for home, a guide, and median-sized C# and TypeScript API pages. Each page gets -three cold/warm browser-cache pairs in desktop and mobile-like scenarios. -The mobile-like profile uses 5 Mbps downstream, 40 ms simulated latency, and -4x CPU throttling. An isolated native decoder measurement and 1/5/20 Mbps -body-byte transfer models are also included. - -These are lab measurements against Kestrel with the published SDK static-asset -manifest. Front Door, TLS, Redis, live APIs, external requests, and physical -device behavior are not measured. Server filesystem caches are not forcibly -evicted. Medians and ranges are descriptive, not statistical significance -claims. Review `comparison/comparison.json` in -`statichost-publish-performance` before deciding whether the byte-size tradeoff -is acceptable. - -Use `browser-profile` to repeat only browser/cache measurements from the latest -successful paired profile on the same pull-request branch. It downloads that -run's generated frontend, copies the representative pages and static assets -into an SDK endpoint fixture, and requires matching page hashes and Brotli -bytes. It does not render the frontend or recompress the complete API catalog. -The fixture has fewer routes, so its startup time and RSS are not full-site -origin measurements. Results are in `statichost-browser-performance`. - -External URLs are blocked through Chromium's native network controls. -Playwright request interception disables HTTP caching and must not be used in -cache profiles. Warm observations explicitly require cached resource hits. - -Measure full-site publishing in CI. Compare equivalent source commits, SDKs, -runner images, and clean intermediate directories before attributing a timing -change to compression. A full StaticHost publish also builds the frontend. -Do not run a local full-site build without explicit maintainer approval. - ## 🆘 Getting help - **Issues**: Report bugs or request features via [GitHub Issues](https://github.com/microsoft/aspire.dev/issues) diff --git a/src/frontend/scripts/profile-static-host-compression.mjs b/src/frontend/scripts/profile-static-host-compression.mjs deleted file mode 100644 index 1d16a233d4..0000000000 --- a/src/frontend/scripts/profile-static-host-compression.mjs +++ /dev/null @@ -1,242 +0,0 @@ -import { createHash } from 'node:crypto'; -import { appendFile, readFile, writeFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { chromium } from '@playwright/test'; - -/** @param {number[]} values @returns {number} */ -export function median(values) { - if (values.length === 0 || values.some((value) => !Number.isFinite(value))) { - throw new Error('Median requires nonempty finite measurements.'); - } - const sorted = [...values].sort((a, b) => a - b); - const middle = Math.floor(sorted.length / 2); - return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; -} - -/** @param {number} extraBytes @param {number} megabitsPerSecond @returns {number} */ -export function transferPenaltyMs(extraBytes, megabitsPerSecond) { - if (!Number.isFinite(extraBytes) || !Number.isFinite(megabitsPerSecond) || megabitsPerSecond <= 0) { - throw new Error('Invalid transfer model inputs.'); - } - return extraBytes * 8 / (megabitsPerSecond * 1000); -} - -async function run(baseUrl, manifestPath, variant, output) { - const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); - const browser = await chromium.launch({ headless: true }); - const environment = { - node: process.version, chromium: browser.version(), platform: process.platform, - architecture: process.arch, runnerImage: process.env.ImageVersion, sourceCommit: process.env.GITHUB_SHA, - }; - const samples = []; - const serverBefore = await (await fetch(`${baseUrl}/__profile/stats`)).json(); - try { - for (const scenario of ['desktop', 'mobile-like']) { - for (const target of manifest.pages) { - for (let trial = 0; trial < 3; trial++) { - const context = await browser.newContext({ - viewport: scenario === 'desktop' ? { width: 1440, height: 900 } : { width: 390, height: 844 }, - locale: 'en-US', - }); - try { - for (const cache of ['cold', 'warm']) { - const page = await context.newPage(); - try { - const session = await context.newCDPSession(page); - await session.send('Network.enable'); - // Playwright routing disables HTTP caching, so block external - // URLs in Chromium without installing a request interceptor. - await session.send('Network.setBlockedURLs', { - urlPatterns: [ - { urlPattern: `${new URL(baseUrl).origin}/*`, block: false }, - { urlPattern: 'http://*:*/*', block: true }, - { urlPattern: 'https://*:*/*', block: true }, - ], - }); - await session.send('Network.setCacheDisabled', { cacheDisabled: false }); - if (cache === 'cold') await session.send('Network.clearBrowserCache'); - await session.send('Performance.enable'); - if (scenario === 'mobile-like') { - await session.send('Network.emulateNetworkConditions', { - offline: false, latency: 40, downloadThroughput: 625000, uploadThroughput: 312500, - }); - await session.send('Emulation.setCPUThrottlingRate', { rate: 4 }); - } - await page.addInitScript(() => { - globalThis.__compressionLcp = null; - new PerformanceObserver((list) => { - globalThis.__compressionLcp = list.getEntries().at(-1)?.startTime ?? null; - }).observe({ type: 'largest-contentful-paint', buffered: true }); - }); - const response = await page.goto(`${baseUrl}/${target.path}`, { waitUntil: 'load', timeout: 60000 }); - if (!response || response.status() !== 200 || response.headers()['content-encoding'] !== 'br') { - throw new Error(`Expected a successful Brotli response for ${target.path}.`); - } - const hash = createHash('sha256').update(await response.body()).digest('hex').toUpperCase(); - if (hash !== target.sha256) throw new Error(`Browser received different content: ${target.path}.`); - await page.waitForTimeout(1000); - const timing = await page.evaluate(() => { - const navigation = performance.getEntriesByType('navigation')[0]; - const resources = performance.getEntriesByType('resource').filter((entry) => - new URL(entry.name).origin === globalThis.location.origin); - return { - ttfbMs: navigation.responseStart - navigation.requestStart, - downloadMs: navigation.responseEnd - navigation.responseStart, - domContentLoadedMs: navigation.domContentLoadedEventEnd, - loadMs: navigation.loadEventEnd, - fcpMs: performance.getEntriesByName('first-contentful-paint')[0]?.startTime ?? null, - lcpMs: globalThis.__compressionLcp, - documentEncodedBytes: navigation.encodedBodySize, - documentDecodedBytes: navigation.decodedBodySize, - transferBytes: navigation.transferSize + resources.reduce((sum, entry) => sum + entry.transferSize, 0), - cachedResourceCount: resources.filter((entry) => entry.transferSize === 0 && entry.encodedBodySize > 0).length, - }; - }); - if (cache === 'warm' && timing.cachedResourceCount === 0) { - throw new Error(`Warm navigation did not reuse HTTP-cached resources: ${target.path}.`); - } - const performanceMetrics = Object.fromEntries((await session.send('Performance.getMetrics')) - .metrics.map(({ name, value }) => [name, value])); - samples.push({ - variant, scenario, page: target.name, path: target.path, trial, cache, ...timing, - mainThreadTaskMs: performanceMetrics.TaskDuration * 1000, - scriptMs: performanceMetrics.ScriptDuration * 1000, - layoutMs: performanceMetrics.LayoutDuration * 1000, - jsHeapBytes: performanceMetrics.JSHeapUsedSize, - }); - await writeFile(output, JSON.stringify({ variant, environment, samples, serverBefore }, null, 2)); - } finally { - await page.close(); - } - } - } finally { - await context.close(); - } - } - } - } - const serverAfter = await (await fetch(`${baseUrl}/__profile/stats`)).json(); - await writeFile(output, JSON.stringify({ variant, environment, samples, serverBefore, serverAfter }, null, 2)); - } finally { - await browser.close(); - } -} - -async function compare(directory, summaryPath) { - const variants = await Promise.all(['SmallestSize', 'Optimal'].map(async (name) => ({ - name, - output: JSON.parse(await readFile(`${directory}/${name}/files.json`, 'utf8')), - image: JSON.parse(await readFile(`${directory}/${name}/image.json`, 'utf8')), - browser: JSON.parse(await readFile(`${directory}/${name}/browser.json`, 'utf8')), - timings: JSON.parse(await readFile(`${directory}/${name}/output.json`, 'utf8')), - tasks: JSON.parse(await readFile(`${directory}/${name}/tasks.json`, 'utf8')), - resources: await readFile(`${directory}/${name}/resources.txt`, 'utf8'), - }))); - const [baseline, candidate] = variants; - if (JSON.stringify(baseline.image.baseLayers) !== JSON.stringify(candidate.image.baseLayers)) { - throw new Error('Container base layers changed between variants.'); - } - if (baseline.output.pages.some((page, index) => - page.sha256 !== candidate.output.pages[index]?.sha256)) { - throw new Error('Representative pages changed between variants.'); - } - const browser = baseline.browser.samples.filter(({ trial }) => trial === 0).map((key) => { - const select = (variant) => variant.browser.samples.filter((sample) => - sample.page === key.page && sample.scenario === key.scenario && sample.cache === key.cache); - return { - page: key.page, scenario: key.scenario, cache: key.cache, - variants: variants.map((variant) => { - const samples = select(variant); - return { - name: variant.name, samples: samples.length, - metrics: Object.fromEntries([ - 'ttfbMs', 'downloadMs', 'domContentLoadedMs', 'loadMs', 'fcpMs', 'lcpMs', - 'documentEncodedBytes', 'documentDecodedBytes', 'transferBytes', 'mainThreadTaskMs', - 'scriptMs', 'layoutMs', 'jsHeapBytes', - 'cachedResourceCount', - ].map((metric) => { - const values = samples.map((sample) => sample[metric]).filter((value) => value !== null); - return [metric, values.length ? { - median: median(values), min: Math.min(...values), max: Math.max(...values), - } : null]; - })), - }; - }), - }; - }); - const deltas = { - brotliBytes: candidate.output.brotliBytes - baseline.output.brotliBytes, - publishedBytes: candidate.output.totalBytes - baseline.output.totalBytes, - archiveBytes: candidate.image.archiveBytes - baseline.image.archiveBytes, - }; - const model = baseline.output.pages.map((page, index) => ({ - page: page.name, - extraBytes: candidate.output.pages[index].brotliBytes - page.brotliBytes, - extraTransferMs: Object.fromEntries([1, 5, 20].map((rate) => - [rate, transferPenaltyMs(candidate.output.pages[index].brotliBytes - page.brotliBytes, rate)])), - })); - const report = { - methodology: { - content: 'One SHA-256-verified generated frontend, same binaries and runner, clean compression outputs per variant.', - order: ['SmallestSize', 'Optimal'], - origin: 'Isolated Kestrel using the actual published MapStaticAssets manifest; no Front Door, TLS, Redis or live APIs.', - browser: 'Chromium; 3 cold/warm pairs per page/scenario; outside-origin requests blocked; 1-second LCP observation after load.', - mobile: '390x844 viewport, 5 Mbps downstream, 40 ms simulated latency, 4x CPU throttling; not a physical device.', - cache: 'Browser cache is cold or warm; server filesystem cache is not forcibly evicted.', - statistics: 'Descriptive medians and ranges; three pairs do not establish statistical significance.', - transferModel: 'Body-byte delta at 1/5/20 Mbps; excludes RTT, headers and multiplexing.', - }, - variants, deltas, browser, model, - }; - await writeFile(`${directory}/comparison.json`, JSON.stringify(report, null, 2)); - const lines = [ - '## Controlled compression comparison', '', - '| Metric | SmallestSize | Optimal | Delta |', - '| --- | ---: | ---: | ---: |', - `| Brotli task seconds | ${baseline.tasks.BrotliCompress.toFixed(3)} | ${candidate.tasks.BrotliCompress.toFixed(3)} | ${(candidate.tasks.BrotliCompress - baseline.tasks.BrotliCompress).toFixed(3)} |`, - `| Brotli bytes | ${baseline.output.brotliBytes} | ${candidate.output.brotliBytes} | ${deltas.brotliBytes} |`, - `| Published wwwroot bytes | ${baseline.output.totalBytes} | ${candidate.output.totalBytes} | ${deltas.publishedBytes} |`, - `| OCI archive bytes | ${baseline.image.archiveBytes} | ${candidate.image.archiveBytes} | ${deltas.archiveBytes} |`, - '', 'Compression timings and GNU time CPU/RSS measurements:', '', - ...variants.flatMap((variant) => [`### ${variant.name}`, '```text', ...variant.timings.timings, variant.resources, '```']), - '', 'Browser response, transfer, FCP/LCP, main-thread, heap, server CPU/RSS, and bandwidth-model data: `comparison.json`.', - 'These are controlled lab measurements, not deployed Front Door or field Core Web Vitals.', '', - ]; - await appendFile(summaryPath, lines.join('\n')); -} - -async function browserReport(directory, originalComparison, summaryPath) { - const source = JSON.parse(await readFile(originalComparison, 'utf8')); - const variants = await Promise.all(['SmallestSize', 'Optimal'].map(async (name) => ({ - name, - output: JSON.parse(await readFile(`${directory}/${name}/files.json`, 'utf8')), - browser: JSON.parse(await readFile(`${directory}/${name}/browser.json`, 'utf8')), - }))); - for (const variant of variants) { - const original = source.variants.find((candidate) => candidate.name === variant.name); - if (!original || variant.output.pages.some((page, index) => - page.sha256 !== original.output.pages[index]?.sha256 || - page.brotliBytes !== original.output.pages[index]?.brotliBytes)) { - throw new Error('Browser follow-up changed representative content or compression bytes.'); - } - } - await writeFile(`${directory}/browser-comparison.json`, JSON.stringify({ - sourceCommit: source.variants[0].output.environment.sourceCommit, - methodology: 'Representative pages and static assets from the completed full-corpus profile. Page hashes and Brotli bytes must match. Chromium-native URL blocking preserves HTTP caching; warm samples require cache hits. Kestrel fixture has fewer routes, so its startup/RSS is not the full-site origin footprint.', - variants, - }, null, 2)); - await appendFile(summaryPath, '\n## Corrected browser-cache comparison\n\nPage hashes and Brotli bytes match the completed full-site profile. Genuine warm HTTP-cache hits were verified. See `browser-comparison.json`; earlier interceptor-based browser observations are superseded.\n'); -} - -async function main() { - const [command, ...args] = process.argv.slice(2); - if (command === 'run' && args.length === 4) await run(...args); - else if (command === 'compare' && args.length === 2) await compare(...args); - else if (command === 'browser-report' && args.length === 3) await browserReport(...args); - else throw new Error('Usage: profile-static-host-compression.mjs run | compare | browser-report '); -} - -if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { - await main(); -} diff --git a/src/frontend/tests/unit/compression-profile.vitest.test.ts b/src/frontend/tests/unit/compression-profile.vitest.test.ts deleted file mode 100644 index 4fda0b5359..0000000000 --- a/src/frontend/tests/unit/compression-profile.vitest.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { expect, test } from 'vitest'; -import { readFile } from 'node:fs/promises'; -import { median, transferPenaltyMs } from '../../scripts/profile-static-host-compression.mjs'; - -test('profile summaries use medians without mutating source observations', () => { - const values = [10, 3, 6]; - expect(median(values)).toBe(6); - expect(values).toEqual([10, 3, 6]); - expect(median([2, 8, 4, 6])).toBe(5); - expect(() => median([])).toThrow(); - expect(() => median([Number.NaN])).toThrow(); -}); - -test('byte-to-bandwidth model reports signed transfer deltas in milliseconds', () => { - expect(transferPenaltyMs(125000, 1)).toBe(1000); - expect(transferPenaltyMs(125000, 5)).toBe(200); - expect(transferPenaltyMs(-125000, 5)).toBe(-200); - expect(() => transferPenaltyMs(1, 0)).toThrow(); - expect(() => transferPenaltyMs(Number.NaN, 1)).toThrow(); -}); - -test('browser cache profiling blocks external URLs without disabling HTTP cache', async () => { - const source = await readFile(new URL('../../scripts/profile-static-host-compression.mjs', import.meta.url), 'utf8'); - expect(source).not.toMatch(/(?:context|page)\.route\(/); - expect(source).toContain('Network.setBlockedURLs'); - expect(source).toContain('Network.setCacheDisabled'); - expect(source).toContain('cacheDisabled: false'); - expect(source).toContain("cache === 'warm' && timing.cachedResourceCount === 0"); -}); diff --git a/src/frontend/tests/unit/dashboard-image-references.vitest.test.ts b/src/frontend/tests/unit/dashboard-image-references.vitest.test.ts index 1de12e5ba9..a4960b8891 100644 --- a/src/frontend/tests/unit/dashboard-image-references.vitest.test.ts +++ b/src/frontend/tests/unit/dashboard-image-references.vitest.test.ts @@ -81,10 +81,10 @@ describe('standalone dashboard container images', () => { expect(commands).toHaveLength(2); for (const command of commands) { expect(command).toContain( - '--mount type=volume,source=aspire-dashboard-data,target=/home/app' + '--mount type=volume,source=aspire-dashboard-data,target=/dashboard-data' ); expect(command).toContain('-e ASPIRE_DASHBOARD_APPLICATION_NAME=my-app'); - expect(command).toContain('-e ASPIRE_DASHBOARD_DATA_DIRECTORY=/home/app/dashboard-data'); + expect(command).toContain('-e ASPIRE_DASHBOARD_DATA_DIRECTORY=/dashboard-data'); expect(command).toContain('-e ASPIRE_DASHBOARD_PERSISTENCE_MODE=run'); } }); diff --git a/src/statichost/StaticHost/Publishing.targets b/src/statichost/StaticHost/Publishing.targets index 83eef84801..156fef634e 100644 --- a/src/statichost/StaticHost/Publishing.targets +++ b/src/statichost/StaticHost/Publishing.targets @@ -1,33 +1,6 @@ - Optimal <_BlazorBrotliCompressionLevel Condition="'$(_BlazorBrotliCompressionLevel)' == ''">$(BrotliCompressionLevel) - - - - <_StaticHostCompressionStartedTicks>$([System.DateTime]::UtcNow.Ticks) - - - - - - - <_StaticHostCompressionFinishedTicks>$([System.DateTime]::UtcNow.Ticks) - <_StaticHostCompressionSeconds>$([MSBuild]::Divide($([MSBuild]::Subtract('$(_StaticHostCompressionFinishedTicks)', '$(_StaticHostCompressionStartedTicks)')), 10000000)) - - - - - - - <_StaticHostManifestSeconds>$([MSBuild]::Divide($([MSBuild]::Subtract($([System.DateTime]::UtcNow.Ticks), '$(_StaticHostCompressionFinishedTicks)')), 10000000)) - - - diff --git a/src/statichost/StaticHost/StaticHost.csproj b/src/statichost/StaticHost/StaticHost.csproj index 3bc0a4bd8f..32300bfda8 100644 --- a/src/statichost/StaticHost/StaticHost.csproj +++ b/src/statichost/StaticHost/StaticHost.csproj @@ -11,6 +11,7 @@ false brotli + Optimal diff --git a/tests/StaticHost.Tests/CompressionProfileTests.cs b/tests/StaticHost.Tests/CompressionProfileTests.cs deleted file mode 100644 index 172b673f62..0000000000 --- a/tests/StaticHost.Tests/CompressionProfileTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace StaticHost.Tests; - -public sealed class CompressionProfileTests -{ - [Fact] - public void SnapshotDetectsChangedContentWithUnchangedFileSize() - { - using var root = new TempWebRoot(); - root.WriteFile("index.html", "before"); - var snapshot = CompressionInputs.Create(root.Path); - CompressionInputs.Verify(snapshot); - root.WriteFile("index.html", "change"); - Assert.Throws(() => CompressionInputs.Verify(snapshot)); - } - - [Fact] - public void SnapshotDetectsAddedAndRemovedFiles() - { - using var root = new TempWebRoot(); - root.WriteFile("index.html", "home"); - var snapshot = CompressionInputs.Create(root.Path); - root.WriteFile("other.html", "other"); - Assert.Throws(() => CompressionInputs.Verify(snapshot)); - File.Delete(Path.Combine(root.Path, "other.html")); - CompressionInputs.Verify(snapshot); - File.Delete(Path.Combine(root.Path, "index.html")); - Assert.Throws(() => CompressionInputs.Verify(snapshot)); - } - - [Theory] - [InlineData("reference/api/csharp/type/index.html", "reference/api/csharp")] - [InlineData("reference/api/typescript/type.md.br", "reference/api/typescript")] - [InlineData("reference/api/index.html.br", "reference/api")] - [InlineData("index.html", "(root)")] - [InlineData("_astro/app.css.br", "_astro")] - public void SizeGroupsKeepApiLanguagesAndStaticAssetsSeparate(string path, string expected) - { - Assert.Equal(expected, CompressionInputs.Group(path)); - } -} diff --git a/tests/StaticHost.Tests/PublishingTargetsTests.cs b/tests/StaticHost.Tests/PublishingTargetsTests.cs deleted file mode 100644 index f390f2619a..0000000000 --- a/tests/StaticHost.Tests/PublishingTargetsTests.cs +++ /dev/null @@ -1,298 +0,0 @@ -using System.Diagnostics; -using System.Globalization; -using System.IO.Compression; -using System.Text.RegularExpressions; -using System.Xml.Linq; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Hosting; - -namespace StaticHost.Tests; - -public sealed class PublishingTargetsTests(PublishingFixture fixture) : IClassFixture -{ - [Fact] - public void PublishUsesOptimalBrotliByDefault() - { - Assert.Contains("brotliLevel=Optimal", fixture.DefaultOutput); - AssertCompressedAssets(fixture.DefaultDirectory, CompressionLevel.Optimal); - } - - [Fact] - public void PublishHonorsExplicitCompressionLevel() - { - Assert.Contains("brotliLevel=SmallestSize", fixture.SmallestSizeOutput); - AssertCompressedAssets(fixture.SmallestSizeDirectory, CompressionLevel.SmallestSize); - } - - [Fact] - public void PublishHonorsLegacyCompressionLevel() - { - Assert.Contains("brotliLevel=SmallestSize", fixture.LegacyOutput); - AssertCompressedAssets(fixture.LegacyDirectory, CompressionLevel.SmallestSize); - } - - [Fact] - public void PublishLogsCompressionAndMetadataDurations() - { - Assert.Contains("[publish-timing] compression start assets=4 brotliLevel=Optimal", fixture.DefaultOutput); - Assert.Contains("[publish-timing] compression complete durationSeconds=", fixture.DefaultOutput); - Assert.Contains("[publish-timing] static asset metadata and manifest complete durationSeconds=", fixture.DefaultOutput); - var durations = Regex.Matches(fixture.DefaultOutput, @"durationSeconds=([0-9.eE+-]+)"); - Assert.Equal(2, durations.Count); - foreach (Match duration in durations) - { - Assert.True(double.Parse(duration.Groups[1].Value, CultureInfo.InvariantCulture) >= 0); - } - } - - [Fact] - public async Task PublishMeasurementVerifiesAssetsAndRejectsIncompleteOutput() - { - var complete = await fixture.MeasureAsync(includeMissingFile: false); - Assert.True(complete.ExitCode == 0, complete.Log); - var incomplete = await fixture.MeasureAsync(includeMissingFile: true); - Assert.NotEqual(0, incomplete.ExitCode); - Assert.Contains("Frontend output is missing from the published host", incomplete.Log); - } - - [Fact] - public async Task PublishMeasurementRejectsIncorrectBrotliContent() - { - var result = await fixture.MeasureAsync(includeMissingFile: false, corruptBrotli: true); - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("Brotli content differs from the original", result.Log); - } - - [Theory] - [InlineData("/index.html")] - [InlineData("/reference/api/sample.md")] - [InlineData("/_astro/app.abc123.css")] - public async Task PublishedEndpointsPreserveContentAndBrotliNegotiation(string path) - { - var builder = WebApplication.CreateBuilder(new WebApplicationOptions - { - EnvironmentName = Environments.Production, - ContentRootPath = fixture.DefaultDirectory, - ApplicationName = typeof(PublishingTargetsTests).Assembly.GetName().Name - }); - builder.WebHost.UseTestServer(); - await using var app = builder.Build(); - app.MapStaticAssets(Path.Combine(fixture.DefaultDirectory, "PublishFixture.staticwebassets.endpoints.json")); - await app.StartAsync(); - using var client = app.GetTestClient(); - - using var plain = await client.GetAsync(path); - Assert.Equal(HttpStatusCode.OK, plain.StatusCode); - Assert.Empty(plain.Content.Headers.ContentEncoding); - var expected = await plain.Content.ReadAsByteArrayAsync(); - - using var request = new HttpRequestMessage(HttpMethod.Get, path); - request.Headers.AcceptEncoding.ParseAdd("br"); - using var compressed = await client.SendAsync(request); - Assert.Equal(HttpStatusCode.OK, compressed.StatusCode); - Assert.Contains("br", compressed.Content.Headers.ContentEncoding); - await using var encoded = await compressed.Content.ReadAsStreamAsync(); - await using var decoder = new BrotliStream(encoded, CompressionMode.Decompress); - using var restored = new MemoryStream(); - await decoder.CopyToAsync(restored); - Assert.Equal(expected, restored.ToArray()); - - using var gzipRequest = new HttpRequestMessage(HttpMethod.Get, path); - gzipRequest.Headers.AcceptEncoding.ParseAdd("gzip"); - using var fallback = await client.SendAsync(gzipRequest); - Assert.Equal(HttpStatusCode.OK, fallback.StatusCode); - Assert.Empty(fallback.Content.Headers.ContentEncoding); - Assert.Equal(expected, await fallback.Content.ReadAsByteArrayAsync()); - - if (path.StartsWith("/_astro/", StringComparison.Ordinal)) - { - Assert.True(plain.Headers.CacheControl?.Public); - Assert.Equal(TimeSpan.FromDays(365), plain.Headers.CacheControl?.MaxAge); - Assert.Contains(plain.Headers.CacheControl!.Extensions, value => value.Name == "immutable"); - Assert.Equal(plain.Headers.CacheControl?.ToString(), compressed.Headers.CacheControl?.ToString()); - } - else - { - Assert.False(plain.Headers.CacheControl?.Extensions.Any(value => value.Name == "immutable") ?? false); - } - } - - private static void AssertCompressedAssets(string directory, CompressionLevel level) - { - foreach (var path in PublishingFixture.AssetPaths) - { - var published = Path.Combine(directory, "wwwroot", path); - var original = File.ReadAllBytes(published); - using var expected = new MemoryStream(); - using (var compressor = new BrotliStream(expected, level, leaveOpen: true)) - { - compressor.Write(original); - } - Assert.Equal(expected.ToArray(), File.ReadAllBytes(published + ".br")); - Assert.False(File.Exists(published + ".gz")); - } - } -} - -public sealed class PublishingFixture : IAsyncLifetime -{ - internal static readonly string[] AssetPaths = - [ - "index.html", - Path.Combine("reference", "api", "sample.md"), - Path.Combine("reference", "api", "sample", "index.html"), - Path.Combine("_astro", "app.abc123.css") - ]; - - private readonly string _root = Path.Combine(Path.GetTempPath(), $"aspire-publish-test-{Guid.NewGuid():N}"); - - public string DefaultDirectory => Path.Combine(_root, "default", "publish"); - public string SmallestSizeDirectory => Path.Combine(_root, "smallest", "publish"); - public string LegacyDirectory => Path.Combine(_root, "legacy", "publish"); - public string DefaultOutput { get; private set; } = string.Empty; - public string SmallestSizeOutput { get; private set; } = string.Empty; - public string LegacyOutput { get; private set; } = string.Empty; - - public async Task InitializeAsync() - { - DefaultOutput = await PublishAsync("default", null); - SmallestSizeOutput = await PublishAsync("smallest", "-p:BrotliCompressionLevel=SmallestSize"); - LegacyOutput = await PublishAsync("legacy", "-p:_BlazorBrotliCompressionLevel=SmallestSize"); - } - - public Task DisposeAsync() - { - if (Directory.Exists(_root)) - { - Directory.Delete(_root, recursive: true); - } - return Task.CompletedTask; - } - - public async Task<(int ExitCode, string Log)> MeasureAsync(bool includeMissingFile, bool corruptBrotli = false) - { - var directory = Path.Combine(_root, $"measure-{Guid.NewGuid():N}"); - var frontend = Path.Combine(directory, "frontend"); - var publish = Path.Combine(directory, "publish"); - var diagnostics = Path.Combine(directory, "diagnostics"); - Directory.CreateDirectory(Path.Combine(diagnostics, "images")); - foreach (var path in AssetPaths) - { - var file = Path.Combine(frontend, path); - Directory.CreateDirectory(Path.GetDirectoryName(file)!); - File.Copy(Path.Combine(DefaultDirectory, "wwwroot", path), file); - var published = Path.Combine(publish, "wwwroot", path); - Directory.CreateDirectory(Path.GetDirectoryName(published)!); - File.Copy(file, published); - File.Copy(Path.Combine(DefaultDirectory, "wwwroot", path + ".br"), published + ".br"); - } - if (includeMissingFile) - { - await File.WriteAllTextAsync(Path.Combine(frontend, "missing.html"), "missing"); - } - if (corruptBrotli) - { - await using var output = File.Create(Path.Combine(publish, "wwwroot", "index.html.br")); - await using var compressor = new BrotliStream(output, CompressionLevel.Optimal); - await compressor.WriteAsync(Encoding.UTF8.GetBytes("incorrect content")); - } - await File.WriteAllTextAsync(Path.Combine(diagnostics, "images", "fixture.tar.gz"), "fixture archive"); - await File.WriteAllTextAsync(Path.Combine(diagnostics, "publish.log"), DefaultOutput); - var start = new ProcessStartInfo("pwsh") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - foreach (var argument in new[] - { - "-NoProfile", "-NonInteractive", "-File", - Path.Combine(AppContext.BaseDirectory, "PublishFixture", "Measure-StaticHostPublish.ps1"), - "-PublishDirectory", publish, - "-FrontendDirectory", frontend, - "-DiagnosticsDirectory", diagnostics, - "-SummaryPath", Path.Combine(diagnostics, "summary.md") - }) - { - start.ArgumentList.Add(argument); - } - return await RunAsync(start, "publish measurements"); - } - - private async Task PublishAsync(string name, string? compressionProperty) - { - var directory = Path.Combine(_root, name); - Directory.CreateDirectory(directory); - foreach (var path in AssetPaths) - { - var file = Path.Combine(directory, "wwwroot", path); - Directory.CreateDirectory(Path.GetDirectoryName(file)!); - var contents = path.EndsWith(".css", StringComparison.Ordinal) - ? string.Concat(Enumerable.Range(0, 100).Select(index => $".item-{index} {{ color: #345678; padding: {index}px; }}\n")) - : string.Concat(Enumerable.Range(0, 100).Select(index => $"

Asset {index}: Aspire documentation and API reference.

\n")); - await File.WriteAllTextAsync(file, contents); - } - await File.WriteAllTextAsync(Path.Combine(directory, "Program.cs"), "var app = WebApplication.CreateBuilder(args).Build(); app.MapStaticAssets(); app.Run();"); - var targets = Path.Combine(AppContext.BaseDirectory, "PublishFixture"); - var project = new XDocument(new XElement("Project", - new XAttribute("Sdk", "Microsoft.NET.Sdk.Web"), - new XElement("PropertyGroup", - new XElement("TargetFramework", "net10.0"), - new XElement("ImplicitUsings", "enable"), - new XElement("EnableDefaultCompressionFormats", "false"), - new XElement("PublishCompressionFormats", "brotli")), - new XElement("Import", new XAttribute("Project", Path.Combine(targets, "Publishing.targets"))), - new XElement("Import", new XAttribute("Project", Path.Combine(targets, "Caching.targets"))))); - await File.WriteAllTextAsync(Path.Combine(directory, "PublishFixture.csproj"), project.ToString()); - - var start = new ProcessStartInfo("dotnet") - { - WorkingDirectory = directory, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - foreach (var argument in new[] { "publish", "--configuration", "Release", "--nologo", "--verbosity", "minimal", "--output", Path.Combine(directory, "publish") }) - { - start.ArgumentList.Add(argument); - } - if (compressionProperty is not null) - { - start.ArgumentList.Add(compressionProperty); - } - var results = Environment.GetEnvironmentVariable("TEST_RESULTS_DIR"); - if (!string.IsNullOrEmpty(results)) - { - Directory.CreateDirectory(results); - start.ArgumentList.Add($"-bl:{Path.Combine(results, $"StaticHost.Publish.{name}.binlog")};ProjectImports=None"); - } - start.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"; - var result = await RunAsync(start, $"publish fixture '{name}'"); - Assert.True(result.ExitCode == 0, $"Publish fixture '{name}' failed with exit code {result.ExitCode}.\n{result.Log}"); - return result.Log; - } - - private static async Task<(int ExitCode, string Log)> RunAsync(ProcessStartInfo start, string name) - { - using var process = new Process { StartInfo = start }; - Assert.True(process.Start(), $"Failed to start {name}."); - var output = process.StandardOutput.ReadToEndAsync(); - var error = process.StandardError.ReadToEndAsync(); - using var timeout = new System.Threading.CancellationTokenSource(TimeSpan.FromMinutes(3)); - try - { - await process.WaitForExitAsync(timeout.Token); - } - catch (OperationCanceledException) when (timeout.IsCancellationRequested) - { - process.Kill(entireProcessTree: true); - await process.WaitForExitAsync(); - throw new TimeoutException($"{name} timed out.\n{await output}\n{await error}"); - } - return (process.ExitCode, $"{await output}\n{await error}"); - } -} diff --git a/tests/StaticHost.Tests/StaticHost.Tests.csproj b/tests/StaticHost.Tests/StaticHost.Tests.csproj index 4d1c3bd5ba..648d9c0b73 100644 --- a/tests/StaticHost.Tests/StaticHost.Tests.csproj +++ b/tests/StaticHost.Tests/StaticHost.Tests.csproj @@ -34,17 +34,6 @@ static layout it wants to assert on. --> - - - -
diff --git a/tests/performance/CompressionProfile/CompressionInputs.cs b/tests/performance/CompressionProfile/CompressionInputs.cs deleted file mode 100644 index c483db8b28..0000000000 --- a/tests/performance/CompressionProfile/CompressionInputs.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Security.Cryptography; - -public sealed record InputFile(string Path, long Bytes, string Sha256); -public sealed record InputSnapshot(string Root, InputFile[] Files); - -public static class CompressionInputs -{ - public static InputSnapshot Create(string directory) - { - var root = Path.GetFullPath(directory); - var files = Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories) - .OrderBy(path => path, StringComparer.Ordinal) - .Select(path => new InputFile(Path.GetRelativePath(root, path).Replace('\\', '/'), - new FileInfo(path).Length, Hash(path))).ToArray(); - if (files.Length == 0) throw new InvalidDataException("Generated frontend output is empty."); - return new InputSnapshot(root, files); - } - - public static void Verify(InputSnapshot expected) - { - var actual = Create(expected.Root); - if (!actual.Files.SequenceEqual(expected.Files)) - { - throw new InvalidDataException("Generated frontend content changed between compression variants."); - } - } - - public static string Hash(string path) - { - using var stream = File.OpenRead(path); - return Convert.ToHexString(SHA256.HashData(stream)); - } - - public static string Group(string path) - { - var parts = path.Split('/'); - return path.StartsWith("reference/api/", StringComparison.Ordinal) - ? parts.Length > 3 ? string.Join('/', parts.Take(3)) : "reference/api" - : parts.Length > 1 ? parts[0] : "(root)"; - } -} diff --git a/tests/performance/CompressionProfile/CompressionProfile.csproj b/tests/performance/CompressionProfile/CompressionProfile.csproj deleted file mode 100644 index bc211d338b..0000000000 --- a/tests/performance/CompressionProfile/CompressionProfile.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - net10.0 - enable - enable - - - - $(MSBuildBinPath)\Microsoft.Build.dll - - - $(MSBuildBinPath)\Microsoft.Build.Framework.dll - - - diff --git a/tests/performance/CompressionProfile/Program.cs b/tests/performance/CompressionProfile/Program.cs deleted file mode 100644 index cf9b3c5054..0000000000 --- a/tests/performance/CompressionProfile/Program.cs +++ /dev/null @@ -1,248 +0,0 @@ -using System.Diagnostics; -using System.Formats.Tar; -using System.IO.Compression; -using System.Security.Cryptography; -using System.Text.Json; -using System.Xml.Linq; -using Microsoft.AspNetCore.Hosting.Server; -using Microsoft.AspNetCore.Hosting.Server.Features; -using Microsoft.Build.Framework; -using Microsoft.Build.Logging; - -if (args.Length == 0 || args.Length != (args[0] == "verify" ? 2 : args[0] == "subset" ? 5 : 3)) -{ - throw new ArgumentException("Usage: CompressionProfile snapshot|describe|image|tasks|serve , or verify "); -} - -switch (args[0]) -{ - case "snapshot": - WriteJson(args[2], CompressionInputs.Create(args[1])); - break; - case "subset": - using (var comparison = JsonDocument.Parse(File.ReadAllText(args[2]))) - { - var expected = comparison.RootElement.GetProperty("variants")[0].GetProperty("output").GetProperty("pages"); - var selected = expected.EnumerateArray().Select(page => page.GetProperty("path").GetString() - ?? throw new InvalidDataException("Missing representative page path.")).ToHashSet(StringComparer.Ordinal); - var root = Path.GetFullPath(args[1]); - var destination = Path.GetFullPath(args[3]); - var subsetWebRoot = Path.Combine(destination, "wwwroot"); - var extensions = new HashSet(StringComparer.OrdinalIgnoreCase) - { - ".js", ".mjs", ".css", ".svg", ".png", ".jpg", ".jpeg", ".webp", ".gif", - ".ico", ".woff", ".woff2", ".otf", ".ttf", ".wasm", ".webmanifest" - }; - foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) - { - var relative = Path.GetRelativePath(root, file).Replace('\\', '/'); - if (!selected.Contains(relative) && !extensions.Contains(Path.GetExtension(file))) continue; - var output = Path.Combine(subsetWebRoot, relative); - Directory.CreateDirectory(Path.GetDirectoryName(output)!); - File.Copy(file, output, overwrite: false); - } - foreach (var page in expected.EnumerateArray()) - { - var path = Path.Combine(subsetWebRoot, page.GetProperty("path").GetString()!); - if (CompressionInputs.Hash(path) != page.GetProperty("sha256").GetString()) - { - throw new InvalidDataException($"Downloaded representative page differs from the complete-corpus profile: {path}."); - } - } - Directory.CreateDirectory(destination); - File.WriteAllText(Path.Combine(destination, "Program.cs"), - "var app = WebApplication.CreateBuilder(args).Build(); app.MapStaticAssets(); app.Run();"); - var project = new XDocument(new XElement("Project", new XAttribute("Sdk", "Microsoft.NET.Sdk.Web"), - new XElement("PropertyGroup", - new XElement("TargetFramework", "net10.0"), new XElement("ImplicitUsings", "enable"), - new XElement("EnableDefaultCompressionFormats", "false"), - new XElement("PublishCompressionFormats", "brotli")), - new XElement("Import", new XAttribute("Project", Path.Combine(Path.GetFullPath(args[4]), "Publishing.targets"))), - new XElement("Import", new XAttribute("Project", Path.Combine(Path.GetFullPath(args[4]), "Caching.targets"))))); - File.WriteAllText(Path.Combine(destination, "StaticHost.csproj"), project.ToString()); - } - break; - case "verify": - CompressionInputs.Verify(ReadJson(args[1])); - Console.WriteLine("[prebuilt-frontend] Verified unchanged generated content."); - break; - case "describe": - var webRoot = Path.Combine(args[1], "wwwroot"); - var files = Directory.EnumerateFiles(webRoot, "*", SearchOption.AllDirectories) - .Select(file => new { Path = Path.GetRelativePath(webRoot, file).Replace('\\', '/'), Bytes = new FileInfo(file).Length }) - .OrderBy(file => file.Path, StringComparer.Ordinal).ToArray(); - var groups = files.GroupBy(file => CompressionInputs.Group(file.Path)); - var pageGroups = new[] - { - ("home", "index.html"), - ("guide", "get-started/first-app/index.html"), - ("csharp", MedianPage("reference/api/csharp/")), - ("typescript", MedianPage("reference/api/typescript/")) - }; - WriteJson(args[2], new - { - environment = new - { - runtime = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription, - os = System.Runtime.InteropServices.RuntimeInformation.OSDescription, - architecture = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture.ToString(), - cores = Environment.ProcessorCount, - sourceCommit = Environment.GetEnvironmentVariable("GITHUB_SHA") - }, - files = files.Length, - totalBytes = files.Sum(file => file.Bytes), - brotliFiles = files.Count(file => file.Path.EndsWith(".br", StringComparison.Ordinal)), - brotliBytes = files.Where(file => file.Path.EndsWith(".br", StringComparison.Ordinal)).Sum(file => file.Bytes), - groups = groups.Select(group => new - { - name = group.Key, - bytes = group.Sum(file => file.Bytes), - brotliBytes = group.Where(file => file.Path.EndsWith(".br", StringComparison.Ordinal)).Sum(file => file.Bytes) - }), - pages = pageGroups.Select(page => new - { - name = page.Item1, - path = page.Item2, - bytes = new FileInfo(Path.Combine(webRoot, page.Item2)).Length, - brotliBytes = new FileInfo(Path.Combine(webRoot, page.Item2 + ".br")).Length, - sha256 = CompressionInputs.Hash(Path.Combine(webRoot, page.Item2)), - nativeDecode = DecodeProfile(Path.Combine(webRoot, page.Item2)) - }) - }); - string MedianPage(string prefix) - { - var pages = files.Where(file => file.Path.StartsWith(prefix, StringComparison.Ordinal) && - file.Path.EndsWith(".html", StringComparison.Ordinal)).OrderBy(file => file.Bytes) - .ThenBy(file => file.Path, StringComparer.Ordinal).ToArray(); - if (pages.Length == 0) throw new InvalidDataException($"No HTML pages for {prefix}."); - return pages[pages.Length / 2].Path; - } - break; - case "image": - WriteJson(args[2], InspectImage(args[1])); - break; - case "tasks": - var tasks = new Dictionary<(int Node, int Project, int Target, int Task), TaskStartedEventArgs>(); - var durations = new Dictionary(StringComparer.Ordinal); - var source = new BinaryLogReplayEventSource(); - source.TaskStarted += (_, e) => - { - if (e.TaskName is not ("BrotliCompress" or "Copy" or "CreateNewImage" or "UpdateExternallyDefinedStaticWebAssets")) return; - var c = e.BuildEventContext ?? throw new InvalidDataException("A task lacks build context."); - tasks.Add((c.NodeId, c.ProjectContextId, c.TargetId, c.TaskId), e); - }; - source.TaskFinished += (_, e) => - { - var c = e.BuildEventContext ?? throw new InvalidDataException("A task lacks build context."); - if (!tasks.Remove((c.NodeId, c.ProjectContextId, c.TargetId, c.TaskId), out var start)) return; - var name = e.TaskName ?? throw new InvalidDataException("A task lacks its name."); - durations[name] = durations.GetValueOrDefault(name) + (e.Timestamp - start.Timestamp).TotalSeconds; - }; - source.Replay(args[1]); - if (tasks.Count != 0 || !durations.ContainsKey("BrotliCompress")) - { - throw new InvalidDataException("Compression task timing is missing or incomplete."); - } - WriteJson(args[2], durations); - break; - case "serve": - var started = Stopwatch.StartNew(); - var builder = WebApplication.CreateBuilder(new WebApplicationOptions - { - ContentRootPath = Path.GetFullPath(args[1]), - WebRootPath = "wwwroot", - EnvironmentName = Environments.Production - }); - builder.Logging.SetMinimumLevel(LogLevel.Warning); - builder.WebHost.UseUrls(args[2]); - await using (var app = builder.Build()) - { - app.UseDefaultFiles(); - app.UseRouting(); - app.MapGet("/__profile/stats", () => - { - using var process = Process.GetCurrentProcess(); - return new - { - cpuMilliseconds = process.TotalProcessorTime.TotalMilliseconds, - workingSetBytes = process.WorkingSet64, - peakWorkingSetBytes = process.PeakWorkingSet64, - startupMilliseconds = started.Elapsed.TotalMilliseconds - }; - }); - app.MapStaticAssets(Path.Combine(Path.GetFullPath(args[1]), "StaticHost.staticwebassets.endpoints.json")); - await app.StartAsync(); - started.Stop(); - Console.WriteLine($"Serving published SDK endpoints at {app.Services.GetRequiredService().Features.Get()?.Addresses.First()}"); - await app.WaitForShutdownAsync(); - } - break; - default: - throw new ArgumentException($"Unknown command: {args[0]}"); -} - -static T ReadJson(string path) => JsonSerializer.Deserialize(File.ReadAllText(path)) - ?? throw new InvalidDataException($"Invalid JSON in {path}."); - -static void WriteJson(string path, object value) => - File.WriteAllText(path, JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true })); - -static object DecodeProfile(string path) -{ - var compressed = File.ReadAllBytes(path + ".br"); - using (var input = new MemoryStream(compressed)) - using (var decoder = new BrotliStream(input, CompressionMode.Decompress)) - { - if (Convert.ToHexString(SHA256.HashData(decoder)) != CompressionInputs.Hash(path)) - { - throw new InvalidDataException($"Incorrect Brotli content: {path}."); - } - } - var milliseconds = new List(); - for (var repeat = 0; repeat < 31; repeat++) - { - using var input = new MemoryStream(compressed); - using var decoder = new BrotliStream(input, CompressionMode.Decompress); - var timer = Stopwatch.StartNew(); - decoder.CopyTo(Stream.Null); - timer.Stop(); - if (repeat > 0) milliseconds.Add(timer.Elapsed.TotalMilliseconds); - } - milliseconds.Sort(); - return new - { - samples = milliseconds.Count, - medianMilliseconds = (milliseconds[14] + milliseconds[15]) / 2, - minMilliseconds = milliseconds[0], - maxMilliseconds = milliseconds[^1], - methodology = ".NET BrotliStream decode from memory; one warmup, no disk IO; not Chromium decoder CPU." - }; -} - -static object InspectImage(string path) -{ - var documents = new Dictionary(StringComparer.Ordinal); - using var file = File.OpenRead(path); - var gzip = file.ReadByte() == 0x1f && file.ReadByte() == 0x8b; - file.Position = 0; - using Stream input = gzip ? new GZipStream(file, CompressionMode.Decompress) : file; - using var reader = new TarReader(input); - while (reader.GetNextEntry() is { } entry) - { - if (entry.DataStream is null || entry.Length > 1024 * 1024) continue; - using var data = new MemoryStream(); - entry.DataStream.CopyTo(data); - var bytes = data.ToArray(); - if (bytes.Length == 0 || bytes[0] != '{') continue; - using var document = JsonDocument.Parse(bytes); - documents.Add(entry.Name.TrimStart('.', '/'), document.RootElement.Clone()); - } - var index = documents["index.json"]; - var digest = index.GetProperty("manifests")[0].GetProperty("digest").GetString() - ?? throw new InvalidDataException("Missing OCI image digest."); - var manifest = documents["blobs/" + digest.Replace(':', '/')]; - var layers = manifest.GetProperty("layers").EnumerateArray() - .Select(layer => layer.GetProperty("digest").GetString()).ToArray(); - if (layers.Length < 2) throw new InvalidDataException("Expected base and application layers."); - return new { archiveBytes = new FileInfo(path).Length, baseLayers = layers[..^1], applicationLayer = layers[^1] }; -} diff --git a/tests/scripts/Measure-StaticHostPublish.ps1 b/tests/scripts/Measure-StaticHostPublish.ps1 deleted file mode 100644 index 9caacd582d..0000000000 --- a/tests/scripts/Measure-StaticHostPublish.ps1 +++ /dev/null @@ -1,92 +0,0 @@ -param( - [Parameter(Mandatory)][string]$PublishDirectory, - [Parameter(Mandatory)][string]$FrontendDirectory, - [Parameter(Mandatory)][string]$DiagnosticsDirectory, - [Parameter(Mandatory)][string]$SummaryPath, - [ValidateSet('Optimal', 'SmallestSize')][string]$BrotliLevel = 'Optimal' -) - -$ErrorActionPreference = 'Stop' -$webRoot = Join-Path $PublishDirectory 'wwwroot' -$frontendRoot = (Resolve-Path -LiteralPath $FrontendDirectory).Path -$frontendFiles = @(Get-ChildItem -LiteralPath $FrontendDirectory -Recurse -File) -if ($frontendFiles.Count -eq 0) { - throw 'The frontend output is empty.' -} -foreach ($file in $frontendFiles) { - $relative = [IO.Path]::GetRelativePath($frontendRoot, $file.FullName) - if (-not (Test-Path -LiteralPath (Join-Path $webRoot $relative) -PathType Leaf)) { - throw "Frontend output is missing from the published host: $relative" - } -} - -$assets = @(Get-ChildItem -LiteralPath $webRoot -Recurse -File) -$compressed = @($assets | Where-Object Extension -eq '.br') -if ($compressed.Count -eq 0) { - throw 'No Brotli assets were published.' -} -$samples = @( - Get-Item -LiteralPath (Join-Path $webRoot 'index.html') - $assets | Where-Object { $_.Extension -eq '.md' } | Select-Object -First 1 - $assets | Where-Object { $_.Extension -eq '.html' -and $_.FullName -match '[\\/]reference[\\/]api[\\/]' } | Select-Object -First 1 - Get-ChildItem -LiteralPath (Join-Path $webRoot '_astro') -File -Filter '*.css' | Select-Object -First 1 -) -if ($samples.Count -ne 4) { - throw 'Expected published home, Markdown, API HTML, and hashed CSS samples.' -} -foreach ($sample in $samples) { - $original = [IO.File]::ReadAllBytes($sample.FullName) - $inputStream = [IO.File]::OpenRead("$($sample.FullName).br") - $decoder = [IO.Compression.BrotliStream]::new($inputStream, [IO.Compression.CompressionMode]::Decompress) - $restored = [IO.MemoryStream]::new() - try { - $decoder.CopyTo($restored) - if ([Convert]::ToBase64String($original) -cne [Convert]::ToBase64String($restored.ToArray())) { - throw "Brotli content differs from the original: $($sample.FullName)" - } - } finally { - $restored.Dispose() - $decoder.Dispose() - $inputStream.Dispose() - } -} - -$archives = @(Get-ChildItem -LiteralPath (Join-Path $DiagnosticsDirectory 'images') -File) -if ($archives.Count -ne 1 -or $archives[0].Length -eq 0) { - throw 'Expected one nonempty container archive.' -} -$timings = @(Get-Content -LiteralPath (Join-Path $DiagnosticsDirectory 'publish.log') | - Where-Object { $_.Contains('[publish-timing]') }) -foreach ($phase in @('compression start', 'compression complete', 'static asset metadata and manifest complete')) { - if (-not ($timings | Where-Object { $_.Contains($phase) })) { - throw "Publish timing is missing: $phase" - } -} -if (-not ($timings | Where-Object { $_.Contains("brotliLevel=$BrotliLevel") })) { - throw "Full-site publishing did not use $BrotliLevel Brotli." -} -$metrics = [ordered]@{ - frontendFiles = $frontendFiles.Count - publishedFiles = $assets.Count - brotliFiles = $compressed.Count - brotliBytes = ($compressed | Measure-Object Length -Sum).Sum - containerArchiveBytes = $archives[0].Length - roundTripSamples = $samples.Count - timings = $timings -} -$metrics | ConvertTo-Json -Depth 3 | - Set-Content -LiteralPath (Join-Path $DiagnosticsDirectory 'output.json') -@( - '## StaticHost publish measurements' - '' - "- Verified $($metrics.frontendFiles) frontend files in the published host." - "- $($metrics.brotliFiles) Brotli files; $($metrics.brotliBytes) compressed bytes." - "- Container archive: $($metrics.containerArchiveBytes) bytes." - '- Verified exact Brotli round trips for home, Markdown, API HTML, and hashed CSS.' - '' - '```text' - $timings - '```' - '' - 'Full timings, resource usage, and MSBuild binary log: `statichost-publish-performance` artifact.' -) | Add-Content -LiteralPath $SummaryPath diff --git a/tests/scripts/profile-static-host-browser.sh b/tests/scripts/profile-static-host-browser.sh deleted file mode 100644 index 46cc27cadd..0000000000 --- a/tests/scripts/profile-static-host-browser.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -root="$RUNNER_TEMP/statichost-browser" -tool="$root/profiler/CompressionProfile.dll" -source="$root/source/comparison/comparison.json" -site="$root/site" -server_pid="" -stop_server() { - if [[ -n "$server_pid" ]] && kill -0 "$server_pid" 2>/dev/null; then - kill "$server_pid" - wait "$server_pid" || true - fi - server_pid="" -} -trap stop_server EXIT - -dotnet "$tool" subset "$root/frontend" "$source" "$site" \ - "$GITHUB_WORKSPACE/src/statichost/StaticHost" - -for level in SmallestSize Optimal; do - variant="$root/results/$level" - mkdir -p "$variant" - if [[ -d "$site/obj/Release/net10.0/compressed/publish" ]]; then - if [[ "$(realpath "$site/obj/Release/net10.0/compressed/publish")" != "$site/obj/Release/net10.0/compressed/publish" ]]; then - echo "Refusing to clean a redirected fixture compression directory." >&2 - exit 1 - fi - rm -rf -- "$site/obj/Release/net10.0/compressed/publish" - fi - dotnet publish "$site/StaticHost.csproj" --configuration Release --output "$variant/publish" \ - -p:BrotliCompressionLevel="$level" 2>&1 | tee "$variant/publish.log" - dotnet "$tool" describe "$variant/publish" "$variant/files.json" - dotnet "$tool" serve "$variant/publish" http://127.0.0.1:4322 > "$variant/server.log" 2>&1 & - server_pid=$! - ready=false - for attempt in $(seq 1 120); do - if ! kill -0 "$server_pid" 2>/dev/null; then - cat "$variant/server.log" >&2 - echo "Representative endpoint server exited before readiness." >&2 - exit 1 - fi - if curl --fail --silent http://127.0.0.1:4322/__profile/stats > /dev/null; then - ready=true - break - fi - sleep 1 - done - if [[ "$ready" != true ]]; then - echo "Representative endpoint server did not become ready." >&2 - exit 1 - fi - ( - cd "$GITHUB_WORKSPACE/src/frontend" - node scripts/profile-static-host-compression.mjs run \ - http://127.0.0.1:4322 "$variant/files.json" "$level" "$variant/browser.json" - ) - stop_server -done - -( - cd "$GITHUB_WORKSPACE/src/frontend" - node scripts/profile-static-host-compression.mjs browser-report \ - "$root/results" "$source" "$GITHUB_STEP_SUMMARY" -) diff --git a/tests/scripts/profile-static-host-compression.sh b/tests/scripts/profile-static-host-compression.sh deleted file mode 100644 index b4190a2e5d..0000000000 --- a/tests/scripts/profile-static-host-compression.sh +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -root="$RUNNER_TEMP/statichost-publish" -comparison="$root/comparison" -tool="$root/profiler/CompressionProfile.dll" -project="$GITHUB_WORKSPACE/src/statichost/StaticHost/StaticHost.csproj" -publish="$GITHUB_WORKSPACE/src/statichost/StaticHost/bin/Release/net10.0/linux-x64/publish" -compressed="$GITHUB_WORKSPACE/src/statichost/StaticHost/obj/Release/net10.0/linux-x64/compressed/publish" -frontend="$GITHUB_WORKSPACE/src/frontend/dist" -server_pid="" - -stop_server() { - if [[ -n "$server_pid" ]] && kill -0 "$server_pid" 2>/dev/null; then - kill "$server_pid" - wait "$server_pid" || true - fi - server_pid="" -} -trap stop_server EXIT - -mkdir -p "$comparison" -dotnet --info > "$comparison/dotnet-info.txt" -dotnet "$tool" snapshot "$frontend" "$comparison/frontend-input.json" -sha256sum "$publish/StaticHost.dll" "$publish/StaticHost.deps.json" \ - "$publish/StaticHost.runtimeconfig.json" > "$comparison/binaries.sha256" - -# The initial publish rendered the frontend once. Keep its measurements but -# remove its archive before producing the controlled pair on this same runner. -rm -- "$root/images/aspiredev.tar.gz" - -for level in SmallestSize Optimal; do - variant="$comparison/$level" - mkdir -p "$variant/images" - if [[ "$(realpath "$publish")" != "$publish" || "$(realpath "$compressed")" != "$compressed" ]]; then - echo "Refusing to clean redirected publish or compression directories." >&2 - exit 1 - fi - rm -rf -- "$publish" "$compressed" - /usr/bin/time -v -o "$variant/resources.txt" \ - dotnet publish "$project" --configuration Release --runtime linux-x64 --no-build \ - /t:PublishContainer \ - -p:BrotliCompressionLevel="$level" \ - "-p:BuildCommand=dotnet \"$tool\" verify \"$comparison/frontend-input.json\"" \ - -p:ContainerRepository=aspiredev \ - -p:ContainerImageTag=ci \ - -p:ContainerImageFormat=OCI \ - -p:ContainerArchiveOutputPath="$variant/images" \ - "-bl:$variant/publish.binlog;ProjectImports=None" \ - 2>&1 | tee "$variant/publish.log" - dotnet "$tool" verify "$comparison/frontend-input.json" - sha256sum --check "$comparison/binaries.sha256" - pwsh -NoProfile -NonInteractive -File "$GITHUB_WORKSPACE/tests/scripts/Measure-StaticHostPublish.ps1" \ - -PublishDirectory "$publish" -FrontendDirectory "$frontend" \ - -DiagnosticsDirectory "$variant" -SummaryPath "$GITHUB_STEP_SUMMARY" -BrotliLevel "$level" - dotnet "$tool" describe "$publish" "$variant/files.json" - dotnet "$tool" tasks "$variant/publish.binlog" "$variant/tasks.json" - dotnet "$tool" image "$variant/images/aspiredev.tar.gz" "$variant/image.json" - - dotnet "$tool" serve "$publish" http://127.0.0.1:4322 > "$variant/server.log" 2>&1 & - server_pid=$! - ready=false - for attempt in $(seq 1 120); do - if ! kill -0 "$server_pid" 2>/dev/null; then - cat "$variant/server.log" >&2 - echo "Published endpoint server exited before becoming ready." >&2 - exit 1 - fi - if curl --fail --silent http://127.0.0.1:4322/__profile/stats > /dev/null; then - ready=true - break - fi - sleep 1 - done - if [[ "$ready" != true ]]; then - cat "$variant/server.log" >&2 - echo "Published endpoint server did not become ready." >&2 - exit 1 - fi - ( - cd "$GITHUB_WORKSPACE/src/frontend" - node scripts/profile-static-host-compression.mjs run \ - http://127.0.0.1:4322 "$variant/files.json" "$level" "$variant/browser.json" - ) - stop_server - rm -- "$variant/images/aspiredev.tar.gz" -done - -( - cd "$GITHUB_WORKSPACE/src/frontend" - node scripts/profile-static-host-compression.mjs compare "$comparison" "$GITHUB_STEP_SUMMARY" -) From d5e8f820aa9da522da4a53803541d4bbeecff14f Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Fri, 16 Oct 2026 16:53:31 -0500 Subject: [PATCH 8/8] Simplify StaticHost Brotli configuration Set _BlazorBrotliCompressionLevel directly in the csproj and drop Publishing.targets. Publish ignores BrotliCompressionLevel: https://github.com/dotnet/sdk/issues/56679 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c8e20bb7-5776-4e97-ab7f-ea75bf4ae889 --- src/statichost/StaticHost/Publishing.targets | 6 ------ src/statichost/StaticHost/StaticHost.csproj | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) delete mode 100644 src/statichost/StaticHost/Publishing.targets diff --git a/src/statichost/StaticHost/Publishing.targets b/src/statichost/StaticHost/Publishing.targets deleted file mode 100644 index 156fef634e..0000000000 --- a/src/statichost/StaticHost/Publishing.targets +++ /dev/null @@ -1,6 +0,0 @@ - - - - <_BlazorBrotliCompressionLevel Condition="'$(_BlazorBrotliCompressionLevel)' == ''">$(BrotliCompressionLevel) - - diff --git a/src/statichost/StaticHost/StaticHost.csproj b/src/statichost/StaticHost/StaticHost.csproj index 32300bfda8..88a7f29d34 100644 --- a/src/statichost/StaticHost/StaticHost.csproj +++ b/src/statichost/StaticHost/StaticHost.csproj @@ -11,7 +11,8 @@ false brotli - Optimal + + <_BlazorBrotliCompressionLevel>Optimal @@ -49,6 +50,5 @@ -