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/.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..17aeaec4 --- /dev/null +++ b/Docs/pages/src/components/BenchmarkResult/index.tsx @@ -0,0 +1,221 @@ +/** + * 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` — 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'; +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(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 ? '−' : '+'; + 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(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 ( +
+ + {entry.aweXpect.memoryBytes !== undefined && + entry.FluentAssertions.memoryBytes !== undefined && ( + + )} +
+ 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..f57f331c --- /dev/null +++ b/Pipeline/Build.Benchmarks.cs @@ -0,0 +1,271 @@ +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.Json; +using System.Text.Json.Serialization; +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 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 + /// 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 = "Testably/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 not {Length: > 0,} || faTime is not {Length: > 0,}) return null; + + return new BenchmarkEntry + { + 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 = 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) + { + // 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("datasets")] public List? Datasets { get; init; } + } + + sealed class RawCommit + { + [JsonPropertyName("sha")] public string? Sha { 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("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";