From 34877b36bf2b2be081675afbc4fb464af081e462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sun, 3 May 2026 09:45:47 +0200 Subject: [PATCH 1/3] feat: aggregate aweXpect benchmark snapshot for site Adds a build-time fetch of the latest aweXpect benchmark numbers from the long-lived benchmarks branch on aweXpect/aweXpect, and renders them on the new "Benchmarks" page contributed by the matching aweXpect change. - Pipeline/Build.Benchmarks.cs: new Nuke target that downloads Docs/pages/static/js/limited-data.js from the benchmarks branch, strips the Chart.js prefix, picks the latest commit's metadata, and reduces every benchmark group to {timeNs, memoryBytes, history[16]} per library. Output lands at Docs/pages/src/data/awexpect/benchmarks.json so it can be consumed as a build-time JSON import. - Pipeline/Build.Pages.cs: Pages now DependsOn(Benchmarks) so the existing pages.yml workflow refreshes the snapshot alongside the doc aggregation. - Docs/pages/src/components/BenchmarkResult: small React component used by the new Benchmarks .mdx in aweXpect. Renders two metric rows (time, memory) with comparison bars, a +/- delta, and a 100x16 SVG sparkline of the last 16 datapoints. The sparkline hides below 1200 px viewport so the value column keeps its space when sidebar+TOC are visible. - Docs/pages/src/data/awexpect/benchmarks.json: snapshot committed so the page renders on a fresh checkout without requiring the build target to run first. Co-Authored-By: Claude Opus 4.7 (1M context) --- .nuke/build.schema.json | 1 + .../src/components/BenchmarkResult/index.tsx | 209 ++++++ .../BenchmarkResult/styles.module.css | 146 +++++ Docs/pages/src/data/awexpect/benchmarks.json | 616 ++++++++++++++++++ Pipeline/Build.Benchmarks.cs | 284 ++++++++ Pipeline/Build.Pages.cs | 1 + 6 files changed, 1257 insertions(+) create mode 100644 Docs/pages/src/components/BenchmarkResult/index.tsx create mode 100644 Docs/pages/src/components/BenchmarkResult/styles.module.css create mode 100644 Docs/pages/src/data/awexpect/benchmarks.json create mode 100644 Pipeline/Build.Benchmarks.cs diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index daef0bcd..9729375e 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -24,6 +24,7 @@ "ExecutableTarget": { "type": "string", "enum": [ + "Benchmarks", "Pages" ] }, diff --git a/Docs/pages/src/components/BenchmarkResult/index.tsx b/Docs/pages/src/components/BenchmarkResult/index.tsx new file mode 100644 index 00000000..b532b8d4 --- /dev/null +++ b/Docs/pages/src/components/BenchmarkResult/index.tsx @@ -0,0 +1,209 @@ +/** + * Renders the latest aweXpect vs FluentAssertions snapshot for a single + * benchmark name (e.g. "Bool", "String", "Int_GreaterThan"). Reads from + * `src/data/awexpect/benchmarks.json` — eventually written by Testably.Server's + * docs build pipeline after fetching the latest values from the + * `aweXpect/aweXpect` benchmarks branch. + */ +import React, {type ReactElement} from 'react'; +import benchmarks from '@site/src/data/awexpect/benchmarks.json'; +import styles from './styles.module.css'; + +type Sample = { + timeNs: number; + memoryBytes: number; + history?: { + timeNs: number[]; + memoryBytes: number[]; + }; +}; + +type BenchmarkEntry = { + aweXpect: Sample; + FluentAssertions: Sample; +}; + +type Snapshot = { + capturedAt: { + sha: string; + shortSha: string; + date: string; + message?: string; + }; + environment?: { + runner?: string; + toolchain?: string; + }; + benchmarks: Record; +}; + +const data = benchmarks as Snapshot; + +function formatNs(ns: number): string { + if (ns < 1_000) return `${ns.toFixed(1)} ns`; + if (ns < 1_000_000) return `${(ns / 1_000).toFixed(2)} µs`; + return `${(ns / 1_000_000).toFixed(2)} ms`; +} + +function formatBytes(b: number): string { + if (b < 1_024) return `${b} B`; + if (b < 1_024 * 1_024) return `${(b / 1_024).toFixed(2)} KiB`; + return `${(b / (1_024 * 1_024)).toFixed(2)} MiB`; +} + +function formatDelta(ratio: number): {text: string; better: boolean} { + // ratio = aweXpect / FluentAssertions; lower is better for both metrics here. + if (ratio === 1) return {text: '±0%', better: true}; + const pct = Math.abs(ratio - 1) * 100; + const better = ratio < 1; + const arrow = better ? '−' : '+'; + return {text: `${arrow}${pct.toFixed(0)}%`, better}; +} + +const SPARK_WIDTH = 100; +const SPARK_HEIGHT = 16; +const SPARK_PADDING = 2; + +type SparklineProps = { + values: number[] | undefined; + className: string; +}; + +function Sparkline({values, className}: SparklineProps): ReactElement | null { + if (!values || values.length < 2) return null; + + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min; + const innerHeight = SPARK_HEIGHT - SPARK_PADDING * 2; + + const points = values.map((v, i) => { + const x = (i / (values.length - 1)) * SPARK_WIDTH; + const y = range === 0 + ? SPARK_HEIGHT / 2 + : SPARK_PADDING + innerHeight - ((v - min) / range) * innerHeight; + return [x, y] as const; + }); + + const d = points + .map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x.toFixed(2)} ${y.toFixed(2)}`) + .join(' '); + const [lastX, lastY] = points[points.length - 1]; + + return ( + + ); +} + +type MetricRowProps = { + label: string; + aweXpectValue: number; + faValue: number; + aweXpectHistory?: number[]; + faHistory?: number[]; + format: (n: number) => string; +}; + +function MetricRow({ + label, + aweXpectValue, + faValue, + aweXpectHistory, + faHistory, + format, +}: MetricRowProps): ReactElement { + const max = Math.max(aweXpectValue, faValue); + const aweXpectWidth = max === 0 ? 0 : (aweXpectValue / max) * 100; + const faWidth = max === 0 ? 0 : (faValue / max) * 100; + const delta = formatDelta(faValue === 0 ? 1 : aweXpectValue / faValue); + + return ( +
+
{label}
+
+
+ aweXpect + + + + {format(aweXpectValue)} + +
+
+ FluentAssertions + + + + {format(faValue)} + +
+
+
+ {delta.text} +
+
+ ); +} + +type Props = { + name: string; +}; + +export default function BenchmarkResult({name}: Props): ReactElement { + const entry = data.benchmarks[name]; + if (!entry) { + return ( +
+ No benchmark data for {name}. +
+ ); + } + + return ( +
+ + +
+ Captured on commit{' '} + + {data.capturedAt.shortSha} + {' '} + ({data.capturedAt.date}) + {data.environment?.runner ? ` on ${data.environment.runner}` : ''}. +
+
+ ); +} diff --git a/Docs/pages/src/components/BenchmarkResult/styles.module.css b/Docs/pages/src/components/BenchmarkResult/styles.module.css new file mode 100644 index 00000000..927ef648 --- /dev/null +++ b/Docs/pages/src/components/BenchmarkResult/styles.module.css @@ -0,0 +1,146 @@ +.result { + margin: 1rem 0 1.5rem; + border: 1px solid var(--ifm-color-emphasis-200); + border-radius: 6px; + padding: 1rem 1.25rem 0.75rem; + background: var(--ifm-card-background-color, var(--ifm-background-surface-color)); +} + +.metricRow { + display: grid; + /* Last column sized to fit the trend indicator (e.g. "−66%") with a sliver + of breathing room. Anything larger leaves dead space between the bars + and the indicator because the text is right-aligned. */ + grid-template-columns: 5rem 1fr 3.5rem; + align-items: center; + column-gap: 0.75rem; + margin: 0.4rem 0; +} + +.metricLabel { + font-weight: 600; + color: var(--ifm-color-emphasis-700); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.bars { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.barRow { + display: grid; + grid-template-columns: 8rem 1fr 5.5rem 7rem; + align-items: center; + column-gap: 0.5rem; + font-variant-numeric: tabular-nums; + font-size: 0.9rem; +} + +.libraryLabel { + color: var(--ifm-color-emphasis-700); + font-size: 0.85rem; +} + +.bar { + position: relative; + height: 0.6rem; + background: var(--ifm-color-emphasis-100); + border-radius: 3px; + overflow: hidden; +} + +.barFillPrimary { + display: block; + height: 100%; + background: var(--ifm-color-primary); + border-radius: 3px; +} + +.barFillFluentAssertions { + display: block; + height: 100%; + background: #ff671b; + border-radius: 3px; +} + +.barValue { + text-align: right; + color: var(--ifm-color-emphasis-800); +} + +.sparkline { + display: block; + justify-self: end; +} + +.sparklineAwexpect { + color: var(--ifm-color-primary); +} + +.sparklineFluentAssertions { + color: #ff671b; +} + +.delta { + text-align: right; + font-size: 0.85rem; + font-weight: 600; + color: var(--ifm-color-success); + font-variant-numeric: tabular-nums; +} + +.deltaWorse { + color: var(--ifm-color-danger); +} + +.caption { + margin-top: 0.75rem; + padding-top: 0.5rem; + border-top: 1px dashed var(--ifm-color-emphasis-200); + color: var(--ifm-color-emphasis-600); + font-size: 0.8rem; +} + +.caption code { + font-size: 0.78rem; +} + +.error { + margin: 1rem 0; + padding: 0.75rem 1rem; + border: 1px solid var(--ifm-color-danger); + border-radius: 6px; + color: var(--ifm-color-danger); + background: var(--ifm-color-danger-contrast-background); + font-size: 0.9rem; +} + +/* Anything narrower than wide-desktop: drop the sparkline so the value + column keeps its room. Threshold accounts for Docusaurus' left sidebar + (~250 px) and right TOC (~200 px) eating into the content area. */ +@media (max-width: 1200px) { + .barRow { + grid-template-columns: 8rem 1fr 5.5rem; + } + .sparkline { + display: none; + } +} + +/* Phone: also stack the metric label / bars / delta vertically. */ +@media (max-width: 600px) { + .metricRow { + grid-template-columns: 1fr; + row-gap: 0.4rem; + } + .delta { + text-align: left; + } + .barRow { + grid-template-columns: 7rem 1fr 5rem; + } +} diff --git a/Docs/pages/src/data/awexpect/benchmarks.json b/Docs/pages/src/data/awexpect/benchmarks.json new file mode 100644 index 00000000..3d68ffbd --- /dev/null +++ b/Docs/pages/src/data/awexpect/benchmarks.json @@ -0,0 +1,616 @@ +{ + "capturedAt": { + "sha": "72cc22d39dba4e5966016bc86b24ea778823a04b", + "shortSha": "72cc22d3", + "date": "2026-05-02", + "message": "chore: Bump coverlet.collector from 8.0.1 to 10.0.0 (#923)" + }, + "environment": { + "runner": "ubuntu-latest", + "toolchain": "BenchmarkDotNet MediumRun (in-process)" + }, + "benchmarks": { + "Bool": { + "aweXpect": { + "timeNs": 244, + "memoryBytes": 696, + "history": { + "timeNs": [ + 255.4, + 263.6, + 246.5, + 277.3, + 248.5, + 258, + 256.6, + 257.6, + 293, + 247.3, + 251.4, + 247, + 266.3, + 271.2, + 251.3, + 244 + ], + "memoryBytes": [ + 696, + 696, + 696, + 696, + 696, + 696, + 696, + 696, + 696, + 696, + 696, + 696, + 696, + 696, + 696, + 696 + ] + } + }, + "FluentAssertions": { + "timeNs": 231.5, + "memoryBytes": 952, + "history": { + "timeNs": [ + 238.9, + 265.9, + 238.1, + 258.3, + 233.3, + 234.4, + 237.8, + 246.3, + 243.4, + 238.3, + 242.9, + 233.8, + 258.7, + 253, + 249.4, + 231.5 + ], + "memoryBytes": [ + 952, + 952, + 952, + 952, + 952, + 952, + 952, + 952, + 952, + 952, + 952, + 952, + 952, + 952, + 952, + 952 + ] + } + } + }, + "Equivalency": { + "aweXpect": { + "timeNs": 297022.9, + "memoryBytes": 335444, + "history": { + "timeNs": [ + 323185.2, + 339061.5, + 306435.1, + 310221.7, + 305610.1, + 309060, + 329450.1, + 311301, + 312479.1, + 294185.4, + 306580.2, + 295828.4, + 306320.4, + 309191.6, + 309507.3, + 297022.9 + ], + "memoryBytes": [ + 335444, + 335444, + 335444, + 335444, + 335444, + 335444, + 335444, + 335444, + 335444, + 335444, + 335444, + 335444, + 335444, + 335444, + 335444, + 335444 + ] + } + }, + "FluentAssertions": { + "timeNs": 2678833.5, + "memoryBytes": 4804906, + "history": { + "timeNs": [ + 2661700.4, + 2628906.3, + 2610370.3, + 2741954.4, + 2707282.4, + 2687938.6, + 2822776.1, + 2803125.3, + 2682176.3, + 2676068.3, + 2608338.4, + 2690390.8, + 2599897.4, + 2742604.6, + 2531989.4, + 2678833.5 + ], + "memoryBytes": [ + 4804906, + 4804906, + 4804906, + 4804906, + 4804906, + 4804906, + 4804906, + 4804906, + 4804906, + 4804906, + 4804906, + 4804906, + 4804906, + 4804906, + 4804902, + 4804906 + ] + } + } + }, + "Int_GreaterThan": { + "aweXpect": { + "timeNs": 239.4, + "memoryBytes": 808, + "history": { + "timeNs": [ + 250.2, + 245, + 252.4, + 259.5, + 242.9, + 249.8, + 273.3, + 248.9, + 256, + 236.7, + 253.5, + 246.6, + 245.5, + 260.7, + 240.9, + 239.4 + ], + "memoryBytes": [ + 808, + 808, + 808, + 808, + 808, + 808, + 808, + 808, + 808, + 808, + 808, + 808, + 808, + 808, + 808, + 808 + ] + } + }, + "FluentAssertions": { + "timeNs": 229.4, + "memoryBytes": 1224, + "history": { + "timeNs": [ + 247.6, + 265.4, + 236.4, + 252.9, + 239.5, + 243.3, + 287, + 261.3, + 239.9, + 247.3, + 245, + 240.2, + 247.4, + 269.4, + 245.1, + 229.4 + ], + "memoryBytes": [ + 1224, + 1224, + 1224, + 1224, + 1224, + 1224, + 1224, + 1224, + 1224, + 1224, + 1224, + 1224, + 1224, + 1224, + 1224, + 1224 + ] + } + } + }, + "ItemsCount_AtLeast": { + "aweXpect": { + "timeNs": 470.2, + "memoryBytes": 1360, + "history": { + "timeNs": [ + 474.4, + 507, + 490.1, + 490.6, + 471.9, + 459.9, + 582.6, + 481, + 462.2, + 489.9, + 530.7, + 494, + 496.9, + 492.3, + 472.1, + 470.2 + ], + "memoryBytes": [ + 1360, + 1360, + 1360, + 1360, + 1360, + 1360, + 1360, + 1360, + 1360, + 1360, + 1360, + 1360, + 1360, + 1360, + 1360, + 1360 + ] + } + }, + "FluentAssertions": { + "timeNs": 458.4, + "memoryBytes": 2008, + "history": { + "timeNs": [ + 487.1, + 500.7, + 462.6, + 496.8, + 454.4, + 447.1, + 561, + 495.9, + 479, + 500.8, + 490.3, + 494, + 496.9, + 537.5, + 473.2, + 458.4 + ], + "memoryBytes": [ + 2008, + 2008, + 2008, + 2008, + 2008, + 2008, + 2008, + 2008, + 2008, + 2008, + 2008, + 2008, + 2008, + 2008, + 2008, + 2008 + ] + } + } + }, + "String": { + "aweXpect": { + "timeNs": 464.1, + "memoryBytes": 1128, + "history": { + "timeNs": [ + 466.8, + 452.8, + 434.6, + 454.3, + 456.7, + 461.3, + 506.7, + 487.3, + 484, + 456.9, + 456.3, + 453.1, + 502.7, + 496.8, + 467.2, + 464.1 + ], + "memoryBytes": [ + 1128, + 1128, + 1128, + 1128, + 1128, + 1128, + 1128, + 1128, + 1128, + 1128, + 1128, + 1128, + 1128, + 1128, + 1128, + 1128 + ] + } + }, + "FluentAssertions": { + "timeNs": 1181.9, + "memoryBytes": 3944, + "history": { + "timeNs": [ + 1249.1, + 1262.7, + 1219.8, + 1297.1, + 1199.8, + 1160.3, + 1346.7, + 1218.4, + 1385.2, + 1419, + 1178.8, + 1219.4, + 1211.9, + 1279.3, + 1130.2, + 1181.9 + ], + "memoryBytes": [ + 3944, + 3944, + 3944, + 3944, + 3944, + 3944, + 3944, + 3944, + 3944, + 3944, + 3944, + 3944, + 3944, + 3944, + 3944, + 3944 + ] + } + } + }, + "StringArray": { + "aweXpect": { + "timeNs": 1914.1, + "memoryBytes": 2624, + "history": { + "timeNs": [ + 2004.8, + 1922.8, + 1916.7, + 1877.6, + 1905.5, + 1957, + 1969.8, + 1995.9, + 2047, + 2002.1, + 1978, + 1837.5, + 1901.1, + 2042.3, + 1864.9, + 1914.1 + ], + "memoryBytes": [ + 2624, + 2624, + 2624, + 2624, + 2624, + 2624, + 2624, + 2624, + 2624, + 2624, + 2624, + 2624, + 2624, + 2624, + 2624, + 2624 + ] + } + }, + "FluentAssertions": { + "timeNs": 1216.4, + "memoryBytes": 4152, + "history": { + "timeNs": [ + 1313.7, + 1316.9, + 1312.4, + 1344.6, + 1255.3, + 1339, + 1478.6, + 1333.3, + 1463, + 1435.1, + 1282.6, + 1364.3, + 1377.6, + 1363.8, + 1292.2, + 1216.4 + ], + "memoryBytes": [ + 4152, + 4152, + 4152, + 4152, + 4152, + 4152, + 4152, + 4152, + 4152, + 4152, + 4152, + 4152, + 4152, + 4152, + 4152, + 4152 + ] + } + } + }, + "StringArrayInAnyOrder": { + "aweXpect": { + "timeNs": 2508, + "memoryBytes": 2816, + "history": { + "timeNs": [ + 2635.6, + 2624.5, + 2564.2, + 2524.2, + 2473.5, + 2561.3, + 2534.7, + 2542.3, + 2712.8, + 2569.2, + 2634.2, + 2496.7, + 2489.4, + 2558.8, + 2423.4, + 2508 + ], + "memoryBytes": [ + 2816, + 2816, + 2816, + 2816, + 2816, + 2816, + 2816, + 2816, + 2816, + 2816, + 2816, + 2816, + 2816, + 2816, + 2816, + 2816 + ] + } + }, + "FluentAssertions": { + "timeNs": 88445.5, + "memoryBytes": 57481, + "history": { + "timeNs": [ + 88515.5, + 90939.5, + 85872.4, + 88075.9, + 86934.5, + 89031.1, + 87612.4, + 87532.5, + 94248.3, + 91714.6, + 90774.7, + 89676.5, + 65326.7, + 93161.2, + 61502.3, + 88445.5 + ], + "memoryBytes": [ + 58598, + 58598, + 58598, + 59100, + 58598, + 58598, + 58598, + 58598, + 58598, + 58598, + 57480, + 57481, + 57481, + 56986, + 57481, + 57481 + ] + } + } + } + } +} diff --git a/Pipeline/Build.Benchmarks.cs b/Pipeline/Build.Benchmarks.cs new file mode 100644 index 00000000..27790b7c --- /dev/null +++ b/Pipeline/Build.Benchmarks.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Nuke.Common; +using Nuke.Common.IO; +using Serilog; + +// ReSharper disable AllUnderscoreLocalParameterName + +namespace Build; + +partial class Build +{ + /// + /// Fetches the latest aweXpect benchmark snapshot from the long-lived + /// benchmarks branch of aweXpect/aweXpect and reduces it + /// to the shape consumed by the BenchmarkResult React component. + /// Pulled from the same limited-data.js file the legacy benchmarks + /// page used; the source is kept up-to-date by aweXpect's own CI on every + /// push to main (see Pipeline/Build.Benchmarks.cs in that + /// repo). + /// + Target Benchmarks => _ => _ + .Executes(async () => + { + AbsolutePath outputDirectory = RootDirectory / "Docs" / "pages" / "src" / "data" / "awexpect"; + outputDirectory.CreateDirectory(); + AbsolutePath outputPath = outputDirectory / "benchmarks.json"; + + Log.Information( + "Fetching benchmark snapshot from {Repo} branch {Branch}", + BenchmarksRepo, BenchmarksBranch); + + using HttpClient client = new(); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Testably.Site"); + if (!string.IsNullOrEmpty(GithubToken)) + { + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", GithubToken); + } + + string raw = await client.GetStringAsync( + $"https://raw.githubusercontent.com/{BenchmarksRepo}/refs/heads/{BenchmarksBranch}/Docs/pages/static/js/{BenchmarksFile}"); + if (!raw.StartsWith(BenchmarksJsPrefix, StringComparison.Ordinal)) + { + throw new NotSupportedException( + $"{BenchmarksFile} does not start with '{BenchmarksJsPrefix}' — upstream format changed?"); + } + string json = raw.Substring(BenchmarksJsPrefix.Length).TrimEnd(';', '\r', '\n', ' ', '\t'); + + Dictionary rawData = + JsonSerializer.Deserialize>(json, RawJsonOptions) + ?? throw new NotSupportedException($"Could not deserialize {BenchmarksFile}"); + + Snapshot snapshot = ReduceToSnapshot(rawData, BenchmarkHistoryLength); + + string pretty = JsonSerializer.Serialize(snapshot, OutputJsonOptions); + await File.WriteAllTextAsync(outputPath, pretty + Environment.NewLine); + + Log.Information( + "Wrote {BenchmarkCount} benchmarks to {Path} (captured commit {Sha} on {Date})", + snapshot.Benchmarks.Count, outputPath, snapshot.CapturedAt.ShortSha, snapshot.CapturedAt.Date); + }); + + const string BenchmarksRepo = "aweXpect/aweXpect"; + const string BenchmarksBranch = "benchmarks"; + const string BenchmarksFile = "limited-data.js"; + const string BenchmarksJsPrefix = "window.BENCHMARK_DATA = "; + const int BenchmarkHistoryLength = 16; + + static readonly JsonSerializerOptions RawJsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + static readonly JsonSerializerOptions OutputJsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + static Snapshot ReduceToSnapshot(Dictionary rawData, int historyLength) + { + // All benchmark groups share the same commit list (the `Build.Benchmarks` target in aweXpect + // appends one new entry per push to main, in lockstep across every group). Pick the first + // non-empty list to source the "captured at" metadata. + RawCommit? latestCommit = rawData.Values + .Where(b => b.Commits is {Count: > 0,}) + .Select(b => b.Commits![^1]) + .FirstOrDefault(); + if (latestCommit is null) + { + throw new NotSupportedException("Benchmark data has no commits"); + } + + Dictionary benchmarks = new(); + foreach ((string name, RawBenchmark benchmark) in rawData.OrderBy(kvp => kvp.Key)) + { + if (benchmark.Datasets is null) continue; + BenchmarkEntry? entry = ReduceBenchmark(benchmark, historyLength); + if (entry is not null) + { + benchmarks[name] = entry; + } + } + + string sha = latestCommit.Sha ?? string.Empty; + string shortSha = sha.Length >= 8 ? sha[..8] : sha; + string date = ParseShortDate(latestCommit.Date); + + return new Snapshot + { + CapturedAt = new SnapshotCommit + { + Sha = sha, + ShortSha = shortSha, + Date = date, + Message = latestCommit.Message, + }, + Environment = new SnapshotEnvironment + { + Runner = "ubuntu-latest", + Toolchain = "BenchmarkDotNet MediumRun (in-process)", + }, + Benchmarks = benchmarks, + }; + } + + static BenchmarkEntry? ReduceBenchmark(RawBenchmark benchmark, int historyLength) + { + double[]? aweTime = FindData(benchmark.Datasets!, "aweXpect", "y"); + double[]? aweMem = FindData(benchmark.Datasets!, "aweXpect", "y1"); + double[]? faTime = FindData(benchmark.Datasets!, "FluentAssertions", "y"); + double[]? faMem = FindData(benchmark.Datasets!, "FluentAssertions", "y1"); + if (aweTime is null || faTime is null) return null; + + return new BenchmarkEntry + { + AweXpect = new Sample + { + TimeNs = Round1(aweTime[^1]), + MemoryBytes = (long)Math.Round(aweMem?[^1] ?? double.NaN), + History = new SampleHistory + { + TimeNs = TakeLast(aweTime, historyLength).Select(Round1).ToArray(), + MemoryBytes = aweMem is null + ? Array.Empty() + : TakeLast(aweMem, historyLength).Select(v => (long)Math.Round(v)).ToArray(), + }, + }, + FluentAssertions = new Sample + { + TimeNs = Round1(faTime[^1]), + MemoryBytes = (long)Math.Round(faMem?[^1] ?? double.NaN), + History = new SampleHistory + { + TimeNs = TakeLast(faTime, historyLength).Select(Round1).ToArray(), + MemoryBytes = faMem is null + ? Array.Empty() + : TakeLast(faMem, historyLength).Select(v => (long)Math.Round(v)).ToArray(), + }, + }, + }; + } + + static double[]? FindData(List datasets, string libraryPrefix, string axisId) + { + // Dataset labels follow " time" / " memory"; the y-axis encodes the metric: + // y = time (ns), y1 = allocated memory (bytes). Match by both to be robust to label drift. + foreach (RawDataset dataset in datasets) + { + if (dataset.Label?.StartsWith(libraryPrefix, StringComparison.OrdinalIgnoreCase) == true && + string.Equals(dataset.YAxisId, axisId, StringComparison.OrdinalIgnoreCase) && + dataset.Data is not null) + { + return dataset.Data; + } + } + return null; + } + + static IEnumerable TakeLast(IReadOnlyList source, int count) + { + int start = Math.Max(0, source.Count - count); + for (int i = start; i < source.Count; i++) yield return source[i]; + } + + static double Round1(double v) => Math.Round(v, 1); + + static string ParseShortDate(string? raw) + { + // Upstream stores dates in `git log` format (e.g. "Fri Nov 21 08:09:23 2025 +0100" + // or "Sat May 2 12:21:34 2026 +0200" with a single-digit day). We only need an + // ISO-style short date for display. Parse with invariant culture so the English + // month abbreviation parses on machines whose default culture is non-English. + if (string.IsNullOrWhiteSpace(raw)) return string.Empty; + string[] formats = + [ + "ddd MMM d HH:mm:ss yyyy zzz", + "ddd MMM dd HH:mm:ss yyyy zzz", + ]; + if (DateTimeOffset.TryParseExact(raw, formats, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal, out DateTimeOffset parsed) || + DateTimeOffset.TryParse(raw, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal, out parsed)) + { + return parsed.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + } + return raw; + } + + // ─── Upstream JSON shape (Chart.js-ready) ─── + sealed class RawBenchmark + { + [JsonPropertyName("commits")] public List? Commits { get; init; } + [JsonPropertyName("labels")] public List? Labels { get; init; } + [JsonPropertyName("datasets")] public List? Datasets { get; init; } + } + + sealed class RawCommit + { + [JsonPropertyName("sha")] public string? Sha { get; init; } + [JsonPropertyName("author")] public string? Author { get; init; } + [JsonPropertyName("date")] public string? Date { get; init; } + [JsonPropertyName("message")] public string? Message { get; init; } + } + + sealed class RawDataset + { + [JsonPropertyName("label")] public string? Label { get; init; } + [JsonPropertyName("unit")] public string? Unit { get; init; } + [JsonPropertyName("data")] public double[]? Data { get; init; } + [JsonPropertyName("yAxisID")] public string? YAxisId { get; init; } + } + + // ─── Snapshot shape consumed by BenchmarkResult.tsx ─── + sealed class Snapshot + { + [JsonPropertyName("capturedAt")] public SnapshotCommit CapturedAt { get; init; } = new(); + [JsonPropertyName("environment")] public SnapshotEnvironment? Environment { get; init; } + [JsonPropertyName("benchmarks")] public Dictionary Benchmarks { get; init; } = new(); + } + + sealed class SnapshotCommit + { + [JsonPropertyName("sha")] public string Sha { get; init; } = string.Empty; + [JsonPropertyName("shortSha")] public string ShortSha { get; init; } = string.Empty; + [JsonPropertyName("date")] public string Date { get; init; } = string.Empty; + [JsonPropertyName("message")] public string? Message { get; init; } + } + + sealed class SnapshotEnvironment + { + [JsonPropertyName("runner")] public string? Runner { get; init; } + [JsonPropertyName("toolchain")] public string? Toolchain { get; init; } + } + + sealed class BenchmarkEntry + { + [JsonPropertyName("aweXpect")] public Sample AweXpect { get; init; } = new(); + [JsonPropertyName("FluentAssertions")] public Sample FluentAssertions { get; init; } = new(); + } + + sealed class Sample + { + [JsonPropertyName("timeNs")] public double TimeNs { get; init; } + [JsonPropertyName("memoryBytes")] public long MemoryBytes { get; init; } + [JsonPropertyName("history")] public SampleHistory? History { get; init; } + } + + sealed class SampleHistory + { + [JsonPropertyName("timeNs")] public double[] TimeNs { get; init; } = Array.Empty(); + [JsonPropertyName("memoryBytes")] public long[] MemoryBytes { get; init; } = Array.Empty(); + } +} diff --git a/Pipeline/Build.Pages.cs b/Pipeline/Build.Pages.cs index a274fd5c..9cadcf5b 100644 --- a/Pipeline/Build.Pages.cs +++ b/Pipeline/Build.Pages.cs @@ -55,6 +55,7 @@ record DocsSource( ]; Target Pages => _ => _ + .DependsOn(Benchmarks) .Executes(async () => { AbsolutePath docsRoot = RootDirectory / "Docs" / "pages" / "docs"; From 48853bb9ea562242846b379072c89ec0c08cc831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sun, 3 May 2026 10:55:18 +0200 Subject: [PATCH 2/3] chore: gitignore aggregated benchmark snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Benchmarks Nuke target regenerates Docs/pages/src/data/awexpect/benchmarks.json on every Pages run, so it follows the same convention as the other aggregated content under Docs/pages/docs/* — owned by the build target, not the repo. Removed from the index but left on disk so existing local checkouts continue to work without re-running the Nuke target. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + Docs/pages/src/data/awexpect/benchmarks.json | 616 ------------------- 2 files changed, 1 insertion(+), 616 deletions(-) delete mode 100644 Docs/pages/src/data/awexpect/benchmarks.json diff --git a/.gitignore b/.gitignore index 49748b0d..d3100fd7 100644 --- a/.gitignore +++ b/.gitignore @@ -409,3 +409,4 @@ Docs/pages/docs/abstractions Docs/pages/docs/awexpect Docs/pages/docs/mockolate Docs/pages/docs/extensions +Docs/pages/src/data/awexpect/benchmarks.json diff --git a/Docs/pages/src/data/awexpect/benchmarks.json b/Docs/pages/src/data/awexpect/benchmarks.json deleted file mode 100644 index 3d68ffbd..00000000 --- a/Docs/pages/src/data/awexpect/benchmarks.json +++ /dev/null @@ -1,616 +0,0 @@ -{ - "capturedAt": { - "sha": "72cc22d39dba4e5966016bc86b24ea778823a04b", - "shortSha": "72cc22d3", - "date": "2026-05-02", - "message": "chore: Bump coverlet.collector from 8.0.1 to 10.0.0 (#923)" - }, - "environment": { - "runner": "ubuntu-latest", - "toolchain": "BenchmarkDotNet MediumRun (in-process)" - }, - "benchmarks": { - "Bool": { - "aweXpect": { - "timeNs": 244, - "memoryBytes": 696, - "history": { - "timeNs": [ - 255.4, - 263.6, - 246.5, - 277.3, - 248.5, - 258, - 256.6, - 257.6, - 293, - 247.3, - 251.4, - 247, - 266.3, - 271.2, - 251.3, - 244 - ], - "memoryBytes": [ - 696, - 696, - 696, - 696, - 696, - 696, - 696, - 696, - 696, - 696, - 696, - 696, - 696, - 696, - 696, - 696 - ] - } - }, - "FluentAssertions": { - "timeNs": 231.5, - "memoryBytes": 952, - "history": { - "timeNs": [ - 238.9, - 265.9, - 238.1, - 258.3, - 233.3, - 234.4, - 237.8, - 246.3, - 243.4, - 238.3, - 242.9, - 233.8, - 258.7, - 253, - 249.4, - 231.5 - ], - "memoryBytes": [ - 952, - 952, - 952, - 952, - 952, - 952, - 952, - 952, - 952, - 952, - 952, - 952, - 952, - 952, - 952, - 952 - ] - } - } - }, - "Equivalency": { - "aweXpect": { - "timeNs": 297022.9, - "memoryBytes": 335444, - "history": { - "timeNs": [ - 323185.2, - 339061.5, - 306435.1, - 310221.7, - 305610.1, - 309060, - 329450.1, - 311301, - 312479.1, - 294185.4, - 306580.2, - 295828.4, - 306320.4, - 309191.6, - 309507.3, - 297022.9 - ], - "memoryBytes": [ - 335444, - 335444, - 335444, - 335444, - 335444, - 335444, - 335444, - 335444, - 335444, - 335444, - 335444, - 335444, - 335444, - 335444, - 335444, - 335444 - ] - } - }, - "FluentAssertions": { - "timeNs": 2678833.5, - "memoryBytes": 4804906, - "history": { - "timeNs": [ - 2661700.4, - 2628906.3, - 2610370.3, - 2741954.4, - 2707282.4, - 2687938.6, - 2822776.1, - 2803125.3, - 2682176.3, - 2676068.3, - 2608338.4, - 2690390.8, - 2599897.4, - 2742604.6, - 2531989.4, - 2678833.5 - ], - "memoryBytes": [ - 4804906, - 4804906, - 4804906, - 4804906, - 4804906, - 4804906, - 4804906, - 4804906, - 4804906, - 4804906, - 4804906, - 4804906, - 4804906, - 4804906, - 4804902, - 4804906 - ] - } - } - }, - "Int_GreaterThan": { - "aweXpect": { - "timeNs": 239.4, - "memoryBytes": 808, - "history": { - "timeNs": [ - 250.2, - 245, - 252.4, - 259.5, - 242.9, - 249.8, - 273.3, - 248.9, - 256, - 236.7, - 253.5, - 246.6, - 245.5, - 260.7, - 240.9, - 239.4 - ], - "memoryBytes": [ - 808, - 808, - 808, - 808, - 808, - 808, - 808, - 808, - 808, - 808, - 808, - 808, - 808, - 808, - 808, - 808 - ] - } - }, - "FluentAssertions": { - "timeNs": 229.4, - "memoryBytes": 1224, - "history": { - "timeNs": [ - 247.6, - 265.4, - 236.4, - 252.9, - 239.5, - 243.3, - 287, - 261.3, - 239.9, - 247.3, - 245, - 240.2, - 247.4, - 269.4, - 245.1, - 229.4 - ], - "memoryBytes": [ - 1224, - 1224, - 1224, - 1224, - 1224, - 1224, - 1224, - 1224, - 1224, - 1224, - 1224, - 1224, - 1224, - 1224, - 1224, - 1224 - ] - } - } - }, - "ItemsCount_AtLeast": { - "aweXpect": { - "timeNs": 470.2, - "memoryBytes": 1360, - "history": { - "timeNs": [ - 474.4, - 507, - 490.1, - 490.6, - 471.9, - 459.9, - 582.6, - 481, - 462.2, - 489.9, - 530.7, - 494, - 496.9, - 492.3, - 472.1, - 470.2 - ], - "memoryBytes": [ - 1360, - 1360, - 1360, - 1360, - 1360, - 1360, - 1360, - 1360, - 1360, - 1360, - 1360, - 1360, - 1360, - 1360, - 1360, - 1360 - ] - } - }, - "FluentAssertions": { - "timeNs": 458.4, - "memoryBytes": 2008, - "history": { - "timeNs": [ - 487.1, - 500.7, - 462.6, - 496.8, - 454.4, - 447.1, - 561, - 495.9, - 479, - 500.8, - 490.3, - 494, - 496.9, - 537.5, - 473.2, - 458.4 - ], - "memoryBytes": [ - 2008, - 2008, - 2008, - 2008, - 2008, - 2008, - 2008, - 2008, - 2008, - 2008, - 2008, - 2008, - 2008, - 2008, - 2008, - 2008 - ] - } - } - }, - "String": { - "aweXpect": { - "timeNs": 464.1, - "memoryBytes": 1128, - "history": { - "timeNs": [ - 466.8, - 452.8, - 434.6, - 454.3, - 456.7, - 461.3, - 506.7, - 487.3, - 484, - 456.9, - 456.3, - 453.1, - 502.7, - 496.8, - 467.2, - 464.1 - ], - "memoryBytes": [ - 1128, - 1128, - 1128, - 1128, - 1128, - 1128, - 1128, - 1128, - 1128, - 1128, - 1128, - 1128, - 1128, - 1128, - 1128, - 1128 - ] - } - }, - "FluentAssertions": { - "timeNs": 1181.9, - "memoryBytes": 3944, - "history": { - "timeNs": [ - 1249.1, - 1262.7, - 1219.8, - 1297.1, - 1199.8, - 1160.3, - 1346.7, - 1218.4, - 1385.2, - 1419, - 1178.8, - 1219.4, - 1211.9, - 1279.3, - 1130.2, - 1181.9 - ], - "memoryBytes": [ - 3944, - 3944, - 3944, - 3944, - 3944, - 3944, - 3944, - 3944, - 3944, - 3944, - 3944, - 3944, - 3944, - 3944, - 3944, - 3944 - ] - } - } - }, - "StringArray": { - "aweXpect": { - "timeNs": 1914.1, - "memoryBytes": 2624, - "history": { - "timeNs": [ - 2004.8, - 1922.8, - 1916.7, - 1877.6, - 1905.5, - 1957, - 1969.8, - 1995.9, - 2047, - 2002.1, - 1978, - 1837.5, - 1901.1, - 2042.3, - 1864.9, - 1914.1 - ], - "memoryBytes": [ - 2624, - 2624, - 2624, - 2624, - 2624, - 2624, - 2624, - 2624, - 2624, - 2624, - 2624, - 2624, - 2624, - 2624, - 2624, - 2624 - ] - } - }, - "FluentAssertions": { - "timeNs": 1216.4, - "memoryBytes": 4152, - "history": { - "timeNs": [ - 1313.7, - 1316.9, - 1312.4, - 1344.6, - 1255.3, - 1339, - 1478.6, - 1333.3, - 1463, - 1435.1, - 1282.6, - 1364.3, - 1377.6, - 1363.8, - 1292.2, - 1216.4 - ], - "memoryBytes": [ - 4152, - 4152, - 4152, - 4152, - 4152, - 4152, - 4152, - 4152, - 4152, - 4152, - 4152, - 4152, - 4152, - 4152, - 4152, - 4152 - ] - } - } - }, - "StringArrayInAnyOrder": { - "aweXpect": { - "timeNs": 2508, - "memoryBytes": 2816, - "history": { - "timeNs": [ - 2635.6, - 2624.5, - 2564.2, - 2524.2, - 2473.5, - 2561.3, - 2534.7, - 2542.3, - 2712.8, - 2569.2, - 2634.2, - 2496.7, - 2489.4, - 2558.8, - 2423.4, - 2508 - ], - "memoryBytes": [ - 2816, - 2816, - 2816, - 2816, - 2816, - 2816, - 2816, - 2816, - 2816, - 2816, - 2816, - 2816, - 2816, - 2816, - 2816, - 2816 - ] - } - }, - "FluentAssertions": { - "timeNs": 88445.5, - "memoryBytes": 57481, - "history": { - "timeNs": [ - 88515.5, - 90939.5, - 85872.4, - 88075.9, - 86934.5, - 89031.1, - 87612.4, - 87532.5, - 94248.3, - 91714.6, - 90774.7, - 89676.5, - 65326.7, - 93161.2, - 61502.3, - 88445.5 - ], - "memoryBytes": [ - 58598, - 58598, - 58598, - 59100, - 58598, - 58598, - 58598, - 58598, - 58598, - 58598, - 57480, - 57481, - 57481, - 56986, - 57481, - 57481 - ] - } - } - } - } -} From ab97f12a94f743903463e3e4c67aa7a8c75137d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sun, 3 May 2026 11:16:13 +0200 Subject: [PATCH 3/3] Fix review issues --- .github/workflows/pages.yml | 1 + .gitignore | 1 - .../src/components/BenchmarkResult/index.tsx | 44 +- Docs/pages/src/data/awexpect/benchmarks.json | 616 ++++++++++++++++++ Pipeline/Build.Benchmarks.cs | 59 +- 5 files changed, 668 insertions(+), 53 deletions(-) create mode 100644 Docs/pages/src/data/awexpect/benchmarks.json diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 62a113b7..cd888972 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -6,6 +6,7 @@ on: paths: - 'Docs/pages/**' - 'Pipeline/Build.Pages.cs' + - 'Pipeline/Build.Benchmarks.cs' - '.github/workflows/pages.yml' repository_dispatch: types: [ extension-documentation-updated-event ] diff --git a/.gitignore b/.gitignore index d3100fd7..49748b0d 100644 --- a/.gitignore +++ b/.gitignore @@ -409,4 +409,3 @@ Docs/pages/docs/abstractions Docs/pages/docs/awexpect Docs/pages/docs/mockolate Docs/pages/docs/extensions -Docs/pages/src/data/awexpect/benchmarks.json diff --git a/Docs/pages/src/components/BenchmarkResult/index.tsx b/Docs/pages/src/components/BenchmarkResult/index.tsx index b532b8d4..17aeaec4 100644 --- a/Docs/pages/src/components/BenchmarkResult/index.tsx +++ b/Docs/pages/src/components/BenchmarkResult/index.tsx @@ -1,9 +1,10 @@ /** * Renders the latest aweXpect vs FluentAssertions snapshot for a single * benchmark name (e.g. "Bool", "String", "Int_GreaterThan"). Reads from - * `src/data/awexpect/benchmarks.json` — eventually written by Testably.Server's - * docs build pipeline after fetching the latest values from the - * `aweXpect/aweXpect` benchmarks branch. + * `src/data/awexpect/benchmarks.json` — refreshed at deploy time by + * Testably.Server's docs pipeline (the `Benchmarks` Nuke target) from the + * `Testably/aweXpect` benchmarks branch. The committed file doubles as a + * fallback so `npm start`/`npm run build` work in a clean checkout. */ import React, {type ReactElement} from 'react'; import benchmarks from '@site/src/data/awexpect/benchmarks.json'; @@ -11,7 +12,7 @@ import styles from './styles.module.css'; type Sample = { timeNs: number; - memoryBytes: number; + memoryBytes?: number; history?: { timeNs: number[]; memoryBytes: number[]; @@ -51,9 +52,17 @@ function formatBytes(b: number): string { return `${(b / (1_024 * 1_024)).toFixed(2)} MiB`; } -function formatDelta(ratio: number): {text: string; better: boolean} { - // ratio = aweXpect / FluentAssertions; lower is better for both metrics here. - if (ratio === 1) return {text: '±0%', better: true}; +function formatDelta(awe: number, fa: number): {text: string; better: boolean} { + // Lower is better for both metrics. Handle the zero-baseline cases explicitly: + // FluentAssertions reports 0 B for many allocation benchmarks, so a naive + // awe / fa would divide by zero and a "ratio === 1" fallback would silently + // hide regressions where aweXpect allocates and FA does not. + if (fa === 0) { + if (awe === 0) return {text: '±0%', better: true}; + return {text: '+∞', better: false}; + } + if (awe === fa) return {text: '±0%', better: true}; + const ratio = awe / fa; const pct = Math.abs(ratio - 1) * 100; const better = ratio < 1; const arrow = better ? '−' : '+'; @@ -130,7 +139,7 @@ function MetricRow({ const max = Math.max(aweXpectValue, faValue); const aweXpectWidth = max === 0 ? 0 : (aweXpectValue / max) * 100; const faWidth = max === 0 ? 0 : (faValue / max) * 100; - const delta = formatDelta(faValue === 0 ? 1 : aweXpectValue / faValue); + const delta = formatDelta(aweXpectValue, faValue); return (
@@ -184,14 +193,17 @@ export default function BenchmarkResult({name}: Props): ReactElement { faHistory={entry.FluentAssertions.history?.timeNs} format={formatNs} /> - + {entry.aweXpect.memoryBytes !== undefined && + entry.FluentAssertions.memoryBytes !== undefined && ( + + )}
Captured on commit{' '} /// Fetches the latest aweXpect benchmark snapshot from the long-lived - /// benchmarks branch of aweXpect/aweXpect and reduces it + /// benchmarks branch of Testably/aweXpect and reduces it /// to the shape consumed by the BenchmarkResult React component. /// Pulled from the same limited-data.js file the legacy benchmarks /// page used; the source is kept up-to-date by aweXpect's own CI on every @@ -70,7 +68,7 @@ partial class Build snapshot.Benchmarks.Count, outputPath, snapshot.CapturedAt.ShortSha, snapshot.CapturedAt.Date); }); - const string BenchmarksRepo = "aweXpect/aweXpect"; + const string BenchmarksRepo = "Testably/aweXpect"; const string BenchmarksBranch = "benchmarks"; const string BenchmarksFile = "limited-data.js"; const string BenchmarksJsPrefix = "window.BENCHMARK_DATA = "; @@ -140,36 +138,28 @@ static Snapshot ReduceToSnapshot(Dictionary rawData, int h double[]? aweMem = FindData(benchmark.Datasets!, "aweXpect", "y1"); double[]? faTime = FindData(benchmark.Datasets!, "FluentAssertions", "y"); double[]? faMem = FindData(benchmark.Datasets!, "FluentAssertions", "y1"); - if (aweTime is null || faTime is null) return null; + if (aweTime is not {Length: > 0,} || faTime is not {Length: > 0,}) return null; return new BenchmarkEntry { - AweXpect = new Sample - { - TimeNs = Round1(aweTime[^1]), - MemoryBytes = (long)Math.Round(aweMem?[^1] ?? double.NaN), - History = new SampleHistory - { - TimeNs = TakeLast(aweTime, historyLength).Select(Round1).ToArray(), - MemoryBytes = aweMem is null - ? Array.Empty() - : TakeLast(aweMem, historyLength).Select(v => (long)Math.Round(v)).ToArray(), - }, - }, - FluentAssertions = new Sample + AweXpect = BuildSample(aweTime, aweMem, historyLength), + FluentAssertions = BuildSample(faTime, faMem, historyLength), + }; + } + + static Sample BuildSample(double[] time, double[]? memory, int historyLength) + => new() + { + TimeNs = Round1(time[^1]), + MemoryBytes = memory is {Length: > 0,} ? (long)Math.Round(memory[^1]) : null, + History = new SampleHistory { - TimeNs = Round1(faTime[^1]), - MemoryBytes = (long)Math.Round(faMem?[^1] ?? double.NaN), - History = new SampleHistory - { - TimeNs = TakeLast(faTime, historyLength).Select(Round1).ToArray(), - MemoryBytes = faMem is null - ? Array.Empty() - : TakeLast(faMem, historyLength).Select(v => (long)Math.Round(v)).ToArray(), - }, + TimeNs = TakeLast(time, historyLength).Select(Round1).ToArray(), + MemoryBytes = memory is null + ? Array.Empty() + : TakeLast(memory, historyLength).Select(v => (long)Math.Round(v)).ToArray(), }, }; - } static double[]? FindData(List datasets, string libraryPrefix, string axisId) { @@ -220,25 +210,22 @@ static string ParseShortDate(string? raw) // ─── Upstream JSON shape (Chart.js-ready) ─── sealed class RawBenchmark { - [JsonPropertyName("commits")] public List? Commits { get; init; } - [JsonPropertyName("labels")] public List? Labels { get; init; } + [JsonPropertyName("commits")] public List? Commits { get; init; } [JsonPropertyName("datasets")] public List? Datasets { get; init; } } sealed class RawCommit { [JsonPropertyName("sha")] public string? Sha { get; init; } - [JsonPropertyName("author")] public string? Author { get; init; } [JsonPropertyName("date")] public string? Date { get; init; } [JsonPropertyName("message")] public string? Message { get; init; } } sealed class RawDataset { - [JsonPropertyName("label")] public string? Label { get; init; } - [JsonPropertyName("unit")] public string? Unit { get; init; } - [JsonPropertyName("data")] public double[]? Data { get; init; } - [JsonPropertyName("yAxisID")] public string? YAxisId { get; init; } + [JsonPropertyName("label")] public string? Label { get; init; } + [JsonPropertyName("data")] public double[]? Data { get; init; } + [JsonPropertyName("yAxisID")] public string? YAxisId { get; init; } } // ─── Snapshot shape consumed by BenchmarkResult.tsx ─── @@ -272,7 +259,7 @@ sealed class BenchmarkEntry sealed class Sample { [JsonPropertyName("timeNs")] public double TimeNs { get; init; } - [JsonPropertyName("memoryBytes")] public long MemoryBytes { get; init; } + [JsonPropertyName("memoryBytes")] public long? MemoryBytes { get; init; } [JsonPropertyName("history")] public SampleHistory? History { get; init; } }