diff --git a/.generated.NoMobile.slnx b/.generated.NoMobile.slnx index 4b4ac6e63e..3ed20edf11 100644 --- a/.generated.NoMobile.slnx +++ b/.generated.NoMobile.slnx @@ -179,6 +179,8 @@ - + + + diff --git a/.github/workflows/playwright-blazor-wasm.yml b/.github/workflows/playwright-blazor-wasm.yml new file mode 100644 index 0000000000..47b12619eb --- /dev/null +++ b/.github/workflows/playwright-blazor-wasm.yml @@ -0,0 +1,65 @@ +name: Blazor WASM Playwright Tests + +on: + push: + branches: + - main + - release/* + pull_request: + paths: + - 'src/*' + - 'src/Sentry/**' + - 'src/Sentry.AspNetCore/**' + - 'src/Sentry.AspNetCore.Blazor.WebAssembly/**' + - 'src/Sentry.Extensions.Logging/**' + - 'test/*' + - 'test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests/**' + - 'test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/**' + - 'test/Sentry.Testing/**' + - 'global.json' + - 'Directory.Build.props' + - 'Directory.Build.targets' + - 'nuget.config' + - '.github/workflows/playwright-blazor-wasm.yml' + workflow_dispatch: + +jobs: + playwright: + name: Blazor WASM E2E + runs-on: ubuntu-latest + env: + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + DOTNET_NOLOGO: 1 + steps: + - name: Cancel Previous Runs + if: github.ref_name != 'main' && !startsWith(github.ref_name, 'release/') + uses: styfle/cancel-workflow-action@3155a141048f8f89c06b4cdae32e7853e97536bc # 0.13.0 + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: recursive + + - name: Install .NET SDK + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + global-json-file: global.json + + - name: Install .NET Workloads + run: dotnet workload restore --temp-dir "${{ runner.temp }}" --skip-sign-check + + - name: Build + run: dotnet build test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests -c Release + + - name: Install Playwright Browsers + run: pwsh test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests/bin/Release/net10.0/playwright.ps1 install chromium --with-deps + + - name: Run Playwright Tests + run: dotnet test test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests -c Release --no-build --logger "trx;LogFileName=results.trx" + + - name: Upload Test Results + if: failure() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: playwright-blazor-wasm-results + path: test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests/TestResults/ diff --git a/Sentry.slnx b/Sentry.slnx index 4b4ac6e63e..3ed20edf11 100644 --- a/Sentry.slnx +++ b/Sentry.slnx @@ -179,6 +179,8 @@ - + + + diff --git a/SentryAspNetCore.slnf b/SentryAspNetCore.slnf index bbaecea920..ccd8904461 100644 --- a/SentryAspNetCore.slnf +++ b/SentryAspNetCore.slnf @@ -24,6 +24,8 @@ "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", + "test\\Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp\\Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp.csproj", + "test\\Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests\\Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.csproj", "test\\Sentry.AspNetCore.Blazor.WebAssembly.Tests\\Sentry.AspNetCore.Blazor.WebAssembly.Tests.csproj", "test\\Sentry.AspNetCore.Grpc.Tests\\Sentry.AspNetCore.Grpc.Tests.csproj", "test\\Sentry.AspNetCore.Tests\\Sentry.AspNetCore.Tests.csproj", diff --git a/SentryNoMobile.slnf b/SentryNoMobile.slnf index 5284aab01f..a5db92897e 100644 --- a/SentryNoMobile.slnf +++ b/SentryNoMobile.slnf @@ -52,6 +52,8 @@ "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", "test\\Sentry.AspNet.Tests\\Sentry.AspNet.Tests.csproj", + "test\\Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp\\Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp.csproj", + "test\\Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests\\Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.csproj", "test\\Sentry.AspNetCore.Blazor.WebAssembly.Tests\\Sentry.AspNetCore.Blazor.WebAssembly.Tests.csproj", "test\\Sentry.AspNetCore.Grpc.Tests\\Sentry.AspNetCore.Grpc.Tests.csproj", "test\\Sentry.AspNetCore.Tests\\Sentry.AspNetCore.Tests.csproj", diff --git a/SentryNoSamples.slnf b/SentryNoSamples.slnf index 5ca7fb6d84..81a7c4243b 100644 --- a/SentryNoSamples.slnf +++ b/SentryNoSamples.slnf @@ -27,6 +27,7 @@ "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", "test\\Sentry.Android.AssemblyReader.Tests\\Sentry.Android.AssemblyReader.Tests.csproj", "test\\Sentry.AspNet.Tests\\Sentry.AspNet.Tests.csproj", + "test\\Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests\\Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.csproj", "test\\Sentry.AspNetCore.Blazor.WebAssembly.Tests\\Sentry.AspNetCore.Blazor.WebAssembly.Tests.csproj", "test\\Sentry.AspNetCore.Grpc.Tests\\Sentry.AspNetCore.Grpc.Tests.csproj", "test\\Sentry.AspNetCore.Tests\\Sentry.AspNetCore.Tests.csproj", diff --git a/scripts/generate-solution-filters-config.yaml b/scripts/generate-solution-filters-config.yaml index 6526054ea9..e3eaf125bc 100644 --- a/scripts/generate-solution-filters-config.yaml +++ b/scripts/generate-solution-filters-config.yaml @@ -32,6 +32,8 @@ groupConfigs: trimTests: - "**/Sentry.TrimTest.csproj" - "**/Sentry.MauiTrimTest.csproj" + playwrightTests: + - "**/*PlaywrightTests*.csproj" mobileOnly: - "**/*Android*.csproj" - "**/*Ios*.csproj" @@ -50,6 +52,7 @@ filterConfigs: - "windowsOnly" - "artefacts" - "trimTests" + - "playwrightTests" patterns: - "**/*AndroidTestApp.csproj" - "**/*DeviceTests*.csproj" @@ -67,6 +70,7 @@ filterConfigs: - "artefacts" - "trimTests" - "mobileOnly" + - "playwrightTests" patterns: - "**/*Android*.csproj" - "**/*DeviceTests*.csproj" @@ -83,6 +87,7 @@ filterConfigs: groups: - "artefacts" - "trimTests" + - "playwrightTests" patterns: - "**/*AndroidTestApp.csproj" - "**/*DeviceTests*.csproj" @@ -100,6 +105,7 @@ filterConfigs: - "macOnly" - "artefacts" - "trimTests" + - "playwrightTests" patterns: - "**/*AndroidTestApp.csproj" # AssemblyReader tests are flaky on Windows: https://github.com/getsentry/sentry-dotnet/issues/4091 @@ -121,6 +127,7 @@ filterConfigs: - "macOnly" - "artefacts" - "trimTests" + - "playwrightTests" patterns: - "**/*AndroidTestApp.csproj" # AssemblyReader tests are flaky on Windows: https://github.com/getsentry/sentry-dotnet/issues/4091 diff --git a/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/App.razor b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/App.razor new file mode 100644 index 0000000000..93b6831e5e --- /dev/null +++ b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/App.razor @@ -0,0 +1,8 @@ + + + + + +

Not found

+
+
diff --git a/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/Directory.Build.props b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/Directory.Build.props new file mode 100644 index 0000000000..f81630fc42 --- /dev/null +++ b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/Directory.Build.props @@ -0,0 +1,3 @@ + + + diff --git a/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/Directory.Build.targets b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/Directory.Build.targets new file mode 100644 index 0000000000..70545e531e --- /dev/null +++ b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/Directory.Build.targets @@ -0,0 +1,3 @@ + + + diff --git a/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/Pages/Index.razor b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/Pages/Index.razor new file mode 100644 index 0000000000..a5c06a63be --- /dev/null +++ b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/Pages/Index.razor @@ -0,0 +1,4 @@ +@page "/" + +

Home

+Go to Second diff --git a/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/Pages/Second.razor b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/Pages/Second.razor new file mode 100644 index 0000000000..2ad425c0a3 --- /dev/null +++ b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/Pages/Second.razor @@ -0,0 +1,4 @@ +@page "/second" + +

Second Page

+Go to Trigger diff --git a/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/Pages/TriggerCapture.razor b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/Pages/TriggerCapture.razor new file mode 100644 index 0000000000..e504e9f017 --- /dev/null +++ b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/Pages/TriggerCapture.razor @@ -0,0 +1,11 @@ +@page "/trigger-capture" + +

Trigger Capture

+ + +@code { + private void Capture() + { + SentrySdk.CaptureMessage("playwright-test"); + } +} diff --git a/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/Program.cs b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/Program.cs new file mode 100644 index 0000000000..2f88219148 --- /dev/null +++ b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/Program.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.UseSentry(options => +{ + // Fake DSN — Playwright intercepts requests before they reach the network + options.Dsn = "https://key@o0.ingest.sentry.io/0"; + options.AutoSessionTracking = false; +}); + +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +await builder.Build().RunAsync(); diff --git a/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp.csproj b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp.csproj new file mode 100644 index 0000000000..aad41e58d8 --- /dev/null +++ b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + false + false + + + + + + + + + diff --git a/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/Shared/MainLayout.razor b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/Shared/MainLayout.razor new file mode 100644 index 0000000000..724fc91b60 --- /dev/null +++ b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/Shared/MainLayout.razor @@ -0,0 +1,5 @@ +@inherits LayoutComponentBase + +
+ @Body +
diff --git a/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/_Imports.razor b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/_Imports.razor new file mode 100644 index 0000000000..263f7a0fd9 --- /dev/null +++ b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/_Imports.razor @@ -0,0 +1,4 @@ +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp +@using Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp.Shared diff --git a/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/wwwroot/index.html b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/wwwroot/index.html new file mode 100644 index 0000000000..5eafdd6ae8 --- /dev/null +++ b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp/wwwroot/index.html @@ -0,0 +1,16 @@ + + + + + + + Playwright Test App + + + + +
Loading...
+ + + + diff --git a/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests/BlazorWasmTestApp.cs b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests/BlazorWasmTestApp.cs new file mode 100644 index 0000000000..8411e888e8 --- /dev/null +++ b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests/BlazorWasmTestApp.cs @@ -0,0 +1,92 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; + +namespace Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests; + +internal sealed class BlazorWasmTestApp : IAsyncDisposable +{ + private Process? _process; + private readonly ConcurrentQueue _output = new(); + + public string BaseUrl { get; private set; } = null!; + + public async Task StartAsync() + { + var port = GetFreePort(); + BaseUrl = $"http://localhost:{port}"; + + var projectPath = Path.GetFullPath( + Path.Combine(AppContext.BaseDirectory, + "..", "..", "..", "..", + "Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.TestApp")); + + _process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"run --project \"{projectPath}\" --configuration Release --urls {BaseUrl}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + } + }; + _process.OutputDataReceived += (_, e) => { if (e.Data != null) _output.Enqueue($"[stdout] {e.Data}"); }; + _process.ErrorDataReceived += (_, e) => { if (e.Data != null) _output.Enqueue($"[stderr] {e.Data}"); }; + _process.Start(); + _process.BeginOutputReadLine(); + _process.BeginErrorReadLine(); + + using var http = new HttpClient(); + var timeout = TimeSpan.FromSeconds(180); + var sw = Stopwatch.StartNew(); + while (sw.Elapsed < timeout) + { + if (_process.HasExited) + { + var logs = string.Join(Environment.NewLine, _output); + throw new InvalidOperationException( + $"Blazor WASM test app exited with code {_process.ExitCode} before becoming ready. Output:{Environment.NewLine}{logs}"); + } + + try + { + var response = await http.GetAsync(BaseUrl); + if (response.IsSuccessStatusCode) + { + return; + } + } + catch + { + // Server not ready yet + } + await Task.Delay(500); + } + + var timeoutLogs = string.Join(Environment.NewLine, _output); + throw new TimeoutException( + $"Blazor WASM test app did not start within {(int)timeout.TotalSeconds}s at {BaseUrl}. Output:{Environment.NewLine}{timeoutLogs}"); + } + + private static int GetFreePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + public async ValueTask DisposeAsync() + { + if (_process is { HasExited: false }) + { + _process.Kill(entireProcessTree: true); + await _process.WaitForExitAsync(); + } + _process?.Dispose(); + } +} diff --git a/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests/NavigationBreadcrumbTests.cs b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests/NavigationBreadcrumbTests.cs new file mode 100644 index 0000000000..be0092b550 --- /dev/null +++ b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests/NavigationBreadcrumbTests.cs @@ -0,0 +1,107 @@ +using System.Text.Json; +using Microsoft.Playwright; + +namespace Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests; + +public class NavigationBreadcrumbTests : IAsyncLifetime +{ + private readonly BlazorWasmTestApp _app = new(); + private IPlaywright? _playwright; + private IBrowser? _browser; + + public async Task InitializeAsync() + { + // Ensure Chromium is installed (no-op if already cached) + var exitCode = Microsoft.Playwright.Program.Main(["install", "chromium"]); + if (exitCode != 0) + { + throw new InvalidOperationException($"Playwright browser install failed with exit code {exitCode}"); + } + + await _app.StartAsync(); + + _playwright = await Playwright.CreateAsync(); + _browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions + { + Headless = true + }); + } + + public async Task DisposeAsync() + { + if (_browser != null) + { + await _browser.DisposeAsync(); + } + _playwright?.Dispose(); + await _app.DisposeAsync(); + } + + [Fact] + public async Task Navigation_CreatesBreadcrumbs_WithCorrectFromAndTo() + { + var page = await _browser!.NewPageAsync(); + + // Collect all intercepted envelopes + var envelopeReceived = new TaskCompletionSource(); + + await page.RouteAsync("**/api/0/envelope/**", async route => + { + var body = route.Request.PostData; + if (body != null && body.Contains("\"breadcrumbs\"")) + { + envelopeReceived.TrySetResult(body); + } + await route.FulfillAsync(new RouteFulfillOptions + { + Status = 200, + ContentType = "application/json", + Body = "{}" + }); + }); + + // 1. Navigate to app root + await page.GotoAsync(_app.BaseUrl); + await page.WaitForSelectorAsync("#page-title"); + + // 2. Navigate to /second (creates first navigation breadcrumb: / -> /second) + await page.ClickAsync("#nav-second"); + await page.WaitForSelectorAsync("h1:has-text('Second Page')"); + + // 3. Navigate to /trigger-capture (creates second breadcrumb: /second -> /trigger-capture) + await page.ClickAsync("#nav-trigger"); + await page.WaitForSelectorAsync("h1:has-text('Trigger Capture')"); + + // 4. Click button to trigger SentrySdk.CaptureMessage — sends event with breadcrumbs + await page.ClickAsync("#btn-capture"); + + // 5. Wait for the envelope containing breadcrumbs + var envelopeBody = await envelopeReceived.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + // 6. Parse and verify + var eventPayload = SentryEnvelopeParser.ExtractEventFromEnvelope(envelopeBody); + eventPayload.Should().NotBeNull("expected an event payload in the Sentry envelope"); + + var breadcrumbs = eventPayload.Value.GetProperty("breadcrumbs").EnumerateArray().ToList(); + + var navBreadcrumbs = breadcrumbs + .Where(b => + b.TryGetProperty("type", out var t) && t.GetString() == "navigation" && + b.TryGetProperty("category", out var c) && c.GetString() == "navigation") + .ToList(); + + navBreadcrumbs.Should().HaveCount(2, "expected two navigation breadcrumbs (/ -> /second -> /trigger-capture)"); + + // First navigation: / -> /second + var first = navBreadcrumbs[0]; + first.GetProperty("data").GetProperty("from").GetString().Should().Be("/"); + first.GetProperty("data").GetProperty("to").GetString().Should().Be("/second"); + + // Second navigation: /second -> /trigger-capture + var second = navBreadcrumbs[1]; + second.GetProperty("data").GetProperty("from").GetString().Should().Be("/second"); + second.GetProperty("data").GetProperty("to").GetString().Should().Be("/trigger-capture"); + + await page.CloseAsync(); + } +} diff --git a/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.csproj b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.csproj new file mode 100644 index 0000000000..87bb45f2a2 --- /dev/null +++ b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests/SentryEnvelopeParser.cs b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests/SentryEnvelopeParser.cs new file mode 100644 index 0000000000..6ddc8f3496 --- /dev/null +++ b/test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests/SentryEnvelopeParser.cs @@ -0,0 +1,33 @@ +using System.Text.Json; + +namespace Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests; + +internal static class SentryEnvelopeParser +{ + /// + /// Extracts the first event payload from a Sentry envelope body. + /// Envelope format: newline-delimited JSON lines. + /// Line 0 = envelope header, then pairs of (item header, item payload). + /// + public static JsonElement? ExtractEventFromEnvelope(string envelopeBody) + { + var lines = envelopeBody.Split('\n'); + + // lines[0] = envelope header + // lines[1..] = pairs of (item header, item payload) + for (var i = 1; i < lines.Length - 1; i += 2) + { + using var itemHeaderDoc = JsonDocument.Parse(lines[i]); + var itemHeader = itemHeaderDoc.RootElement; + + if (itemHeader.TryGetProperty("type", out var typeEl) && + typeEl.GetString() == "event") + { + using var eventDoc = JsonDocument.Parse(lines[i + 1]); + return eventDoc.RootElement.Clone(); + } + } + + return null; + } +}