diff --git a/.gitignore b/.gitignore index f42b3902..7c249e8e 100644 --- a/.gitignore +++ b/.gitignore @@ -408,5 +408,6 @@ Docs/pages/build/ Docs/pages/docs/Abstractions Docs/pages/docs/aweXpect Docs/pages/docs/Mockolate +Docs/pages/docs/Awaiten Docs/pages/docs/Extensions/*/ Docs/pages/docs/Chronology diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index 9d1166a1..1f0ec30f 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -24,6 +24,7 @@ "ExecutableTarget": { "type": "string", "enum": [ + "AwaitenBenchmarks", "Benchmarks", "MockolateBenchmarks", "Pages" diff --git a/Docs/mirror-local-docs.ps1 b/Docs/mirror-local-docs.ps1 index 95e99833..6e0be321 100644 --- a/Docs/mirror-local-docs.ps1 +++ b/Docs/mirror-local-docs.ps1 @@ -50,6 +50,7 @@ $Sources = @( [pscustomobject]@{ Repo="Mockolate"; SourcePath="Docs/pages"; Target="Mockolate"; InlineReadme=$false; ExtraReadmes=@( [pscustomobject]@{ Repo="Mockolate.Migration"; TargetFile="11-migration.md" } ) } + [pscustomobject]@{ Repo="Awaiten"; SourcePath="Docs/pages"; Target="Awaiten"; InlineReadme=$false; ExtraReadmes=@() } ) # Local clones often contain build output that the GitHub-API path would not diff --git a/Docs/pages/docusaurus.config.ts b/Docs/pages/docusaurus.config.ts index cdf2fdec..236b19f5 100644 --- a/Docs/pages/docusaurus.config.ts +++ b/Docs/pages/docusaurus.config.ts @@ -85,6 +85,12 @@ const config: Config = { position: 'left', label: 'Mockolate', }, + { + type: 'docSidebar', + sidebarId: 'awaitenSidebar', + position: 'left', + label: 'Awaiten', + }, { type: 'docSidebar', sidebarId: 'chronologySidebar', @@ -132,6 +138,16 @@ const config: Config = { }, ], }, + { + items: [ + { + html: `Awaiten`, + }, + { + html: `Awaiten on NuGet`, + }, + ], + }, ], copyright: `Copyright © ${new Date().getFullYear()} Testably.`, }, diff --git a/Docs/pages/sidebars.ts b/Docs/pages/sidebars.ts index 62952179..0eac1ff2 100644 --- a/Docs/pages/sidebars.ts +++ b/Docs/pages/sidebars.ts @@ -4,6 +4,7 @@ const sidebars: SidebarsConfig = { abstractionsSidebar: [{type: 'autogenerated', dirName: 'Abstractions'}], awexpectSidebar: [{type: 'autogenerated', dirName: 'aweXpect'}], mockolateSidebar: [{type: 'autogenerated', dirName: 'Mockolate'}], + awaitenSidebar: [{type: 'autogenerated', dirName: 'Awaiten'}], extensionsSidebar: [{type: 'autogenerated', dirName: 'Extensions'}], chronologySidebar: [{type: 'autogenerated', dirName: 'Chronology'}], }; diff --git a/Docs/pages/src/clientModules/sectionTheme.ts b/Docs/pages/src/clientModules/sectionTheme.ts index ec586bfa..33e4581c 100644 --- a/Docs/pages/src/clientModules/sectionTheme.ts +++ b/Docs/pages/src/clientModules/sectionTheme.ts @@ -4,12 +4,12 @@ * library section, and by the swizzled `Logo` component to pick the matching * SVG icon. * - * Sections: 'abstractions' | 'awexpect' | 'mockolate' | 'extensions' + * Sections: 'abstractions' | 'awexpect' | 'mockolate' | 'awaiten' | 'extensions' * Anything else (including `/`) leaves the attribute unset, falling back to * the bracket beige defined in `:root`. */ -const SECTION_PATTERN = /^\/(abstractions|awexpect|mockolate|extensions)(?:\/|$)/i; +const SECTION_PATTERN = /^\/(abstractions|awexpect|mockolate|awaiten|extensions)(?:\/|$)/i; function getSection(pathname: string): string | null { const match = pathname.match(SECTION_PATTERN); diff --git a/Docs/pages/src/components/AwaitenBenchmarkResult/index.tsx b/Docs/pages/src/components/AwaitenBenchmarkResult/index.tsx new file mode 100644 index 00000000..f4b7e429 --- /dev/null +++ b/Docs/pages/src/components/AwaitenBenchmarkResult/index.tsx @@ -0,0 +1,334 @@ +/** + * Renders the latest Awaiten-vs-other-DI-containers snapshot for a single + * benchmark scenario ("Build", "Resolve", "Realistic"). Reads from + * `src/data/awaiten/benchmarks.json` — refreshed at deploy time by + * Testably.Site's docs pipeline (the `AwaitenBenchmarks` Nuke target) from + * the `Testably/Awaiten` benchmarks branch. The committed file doubles as a + * fallback so `npm start`/`npm run build` work in a clean checkout. + * + * The Build/Resolve scenarios are parameterised by container size; the + * component renders inline Size=8 / Size=64 / Size=256 buttons so readers can + * switch between them. + */ +import React, {type ReactElement, useCallback, useState} from 'react'; +import benchmarks from '@site/src/data/awaiten/benchmarks.json'; +import styles from './styles.module.css'; + +const BASELINE_LIBRARY = 'Awaiten'; +const LIBRARY_ORDER = [ + 'Awaiten', + 'MsDI', + 'Autofac', + 'Jab', + 'PureDI', + 'DryIoc', + 'SimpleInjector', +] as const; +const LIBRARY_DISPLAY_NAMES: Record = { + MsDI: 'MS.DI', + PureDI: 'Pure.DI', + SimpleInjector: 'Simple Injector', +}; + +type Sample = { + timeNs: number; + memoryBytes?: number; + history?: { + timeNs: number[]; + memoryBytes: number[]; + }; +}; + +type Scenario = { + parameters: string[]; + samples: Record>; +}; + +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 formatRatio(value: number, baseline: number): string { + if (baseline === 0) return value === 0 ? '1.00x' : '∞'; + return `${(value / baseline).toFixed(2)}x`; +} + +const SPARK_WIDTH = 90; +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 LibraryRow = { + library: string; + value: number | null; + history?: number[]; +}; + +type MetricBlockProps = { + label: string; + rows: LibraryRow[]; + baselineValue: number; + format: (n: number) => string; + inactive: ReadonlySet; + onToggle: (library: string) => void; +}; + +function MetricBlock({label, rows, baselineValue, format, inactive, onToggle}: MetricBlockProps): ReactElement { + // Bars rescale to the max of currently *active* rows so toggling a slow + // container off lets the faster ones spread out instead of all hugging the + // left edge. + const max = Math.max( + ...rows + .filter(r => r.value !== null && !inactive.has(r.library)) + .map(r => r.value as number), + 0, + ); + + return ( +
+
{label}
+
+ {rows.map(row => { + const isBaseline = row.library === BASELINE_LIBRARY; + const isMissing = row.value === null; + const isInactive = !isMissing && inactive.has(row.library); + const showFill = !isMissing && !isInactive; + const width = showFill && max !== 0 ? (row.value! / max) * 100 : 0; + const rowClass = [ + styles.barRow, + isBaseline && styles.barRowBaseline, + isMissing && styles.barRowMissing, + isInactive && styles.barRowInactive, + ].filter(Boolean).join(' '); + const display = LIBRARY_DISPLAY_NAMES[row.library] ?? row.library; + const content = ( + <> + {display} + + {showFill && ( + + )} + + + {isMissing ? 'not available' : format(row.value!)} + + + {isMissing ? '—' : isBaseline ? 'baseline' : formatRatio(row.value!, baselineValue)} + + {!isMissing && ( + + )} + + ); + if (isMissing) { + return ( +
+ {content} +
+ ); + } + return ( + + ); + })} +
+
+ ); +} + +function buildRows( + samples: Record, + picker: (s: Sample) => number | undefined, + history: (s: Sample) => number[] | undefined, +): LibraryRow[] { + // Render every library in LIBRARY_ORDER so absences are visible: a library + // with no sample for this scenario / metric appears as a "not available" + // placeholder rather than silently disappearing from the comparison. + // Present rows sort by value ascending (fastest first); missing rows fall + // through to the bottom in declared order so the chart's shape stays stable + // across scenarios. + const present: LibraryRow[] = []; + const missing: LibraryRow[] = []; + for (const library of LIBRARY_ORDER) { + const sample = samples[library]; + const value = sample ? picker(sample) : undefined; + if (value === undefined) { + missing.push({library, value: null}); + } else { + present.push({library, value, history: sample ? history(sample) : undefined}); + } + } + present.sort((a, b) => (a.value as number) - (b.value as number)); + return [...present, ...missing]; +} + +type Props = { + name: string; +}; + +export default function AwaitenBenchmarkResult({name}: Props): ReactElement { + const scenario = data.benchmarks[name]; + if (!scenario) { + return ( +
+ No benchmark data for {name}. +
+ ); + } + + const params = scenario.parameters; + const [activeParam, setActiveParam] = useState(params[0]); + const [inactive, setInactive] = useState>(() => new Set()); + const toggleLibrary = useCallback((library: string) => { + setInactive(prev => { + const next = new Set(prev); + if (next.has(library)) { + next.delete(library); + } else { + next.add(library); + } + return next; + }); + }, []); + const samples = scenario.samples[activeParam] ?? {}; + const baselineSample = samples[BASELINE_LIBRARY]; + + const timeRows = buildRows(samples, s => s.timeNs, s => s.history?.timeNs); + const memoryRows = buildRows(samples, + s => s.memoryBytes, + s => s.history?.memoryBytes); + + const showParamTabs = params.length > 1 || (params.length === 1 && params[0] !== ''); + + return ( +
+ {showParamTabs && ( +
+ {params.map(p => ( + + ))} +
+ )} + + {memoryRows.length > 0 && baselineSample?.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/AwaitenBenchmarkResult/styles.module.css b/Docs/pages/src/components/AwaitenBenchmarkResult/styles.module.css new file mode 100644 index 00000000..7a7bad77 --- /dev/null +++ b/Docs/pages/src/components/AwaitenBenchmarkResult/styles.module.css @@ -0,0 +1,240 @@ +.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)); +} + +.paramTabs { + display: flex; + gap: 0.25rem; + margin: 0 0 0.75rem; + padding: 0.2rem; + background: var(--ifm-color-emphasis-100); + border-radius: 6px; + width: fit-content; +} + +.paramTab { + padding: 0.25rem 0.75rem; + border: 0; + background: transparent; + border-radius: 4px; + color: var(--ifm-color-emphasis-700); + font-size: 0.85rem; + font-family: var(--ifm-font-family-monospace); + cursor: pointer; + transition: background 0.1s, color 0.1s; +} + +.paramTab:hover { + background: var(--ifm-color-emphasis-200); +} + +.paramTabActive, +.paramTabActive:hover { + background: var(--ifm-card-background-color, var(--ifm-background-surface-color)); + color: var(--ifm-color-emphasis-900); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); +} + +.metricBlock { + margin: 0.75rem 0; +} + +.metricLabel { + font-weight: 600; + color: var(--ifm-color-emphasis-700); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 0.4rem; +} + +.bars { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.barRow { + /* Columns: library label, bar, value, ratio, sparkline. The ratio sits between + the value and the sparkline so the visual flow is "what" → "how much" → + "vs Awaiten" → "trend." */ + display: grid; + grid-template-columns: 9rem 1fr 5.5rem 4rem 6rem; + align-items: center; + column-gap: 0.5rem; + font-variant-numeric: tabular-nums; + font-size: 0.9rem; + padding: 0.15rem 0.4rem; + border-radius: 4px; + /* Reset button defaults so the same grid layout works for clickable rows + (active/inactive toggles) and non-clickable divs (missing data). */ + width: 100%; + border: 0; + font-family: inherit; + color: inherit; + text-align: left; + background: transparent; +} + +button.barRow { + cursor: pointer; + transition: box-shadow 0.1s, background 0.1s; +} + +button.barRow:hover { + box-shadow: inset 0 0 0 1px var(--ifm-color-emphasis-300); +} + +button.barRow:focus-visible { + outline: 2px solid var(--ifm-color-primary); + outline-offset: -2px; +} + +.barRowBaseline { + background: var(--ifm-color-primary-contrast-background, rgba(99, 162, 172, 0.12)); + font-weight: 600; +} + +.barRowMissing { + color: var(--ifm-color-emphasis-500); + font-style: italic; +} + +.barRowInactive, +.barRowInactive.barRowBaseline { + background: transparent; + opacity: 0.5; +} + +.barRowInactive .libraryLabel { + text-decoration: line-through; +} + +.barRowMissing .libraryLabel, +.barRowMissing .barValue, +.barRowMissing .ratio { + color: var(--ifm-color-emphasis-500); +} + +.libraryLabel { + color: var(--ifm-color-emphasis-800); + font-size: 0.85rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.bar { + position: relative; + height: 0.6rem; + background: var(--ifm-color-emphasis-100); + border-radius: 3px; + overflow: hidden; +} + +.barFill { + display: block; + height: 100%; + background: var(--ifm-color-emphasis-500); + border-radius: 3px; +} + +.barFillBaseline { + display: block; + height: 100%; + background: var(--ifm-color-primary); + border-radius: 3px; +} + +.barValue { + text-align: right; + color: var(--ifm-color-emphasis-800); +} + +.ratio { + text-align: right; + font-size: 0.85rem; + color: var(--ifm-color-emphasis-600); +} + +.barRowBaseline .ratio { + color: var(--ifm-color-emphasis-700); + font-style: italic; + font-weight: 400; +} + +.sparkline { + display: block; + justify-self: end; + color: var(--ifm-color-emphasis-500); +} + +.sparklineBaseline { + color: var(--ifm-color-primary); +} + +.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 + and ratio columns keep their 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: 9rem 1fr 5.5rem 4rem; + } + .sparkline { + display: none; + } +} + +/* Phone: stack label / bar / value+ratio so values stay readable. */ +@media (max-width: 600px) { + .barRow { + grid-template-columns: 1fr 4.5rem 3.5rem; + column-gap: 0.4rem; + row-gap: 0.2rem; + } + .bar { + grid-column: 1 / -1; + order: 2; + } + .libraryLabel { + grid-column: 1 / -1; + order: 1; + } + .barValue { + grid-column: 1; + order: 3; + text-align: left; + } + .ratio { + grid-column: 2 / -1; + order: 4; + text-align: right; + } +} diff --git a/Docs/pages/src/css/custom.css b/Docs/pages/src/css/custom.css index 671f688b..4bc06141 100644 --- a/Docs/pages/src/css/custom.css +++ b/Docs/pages/src/css/custom.css @@ -97,6 +97,24 @@ html[data-theme='dark'][data-section='mockolate'] { --ifm-color-primary-lighter: #d0aa81; --ifm-color-primary-lightest: #d8b696; } +html[data-section='awaiten'] { + --ifm-color-primary: #3949ab; + --ifm-color-primary-dark: #34429c; + --ifm-color-primary-darker: #313f93; + --ifm-color-primary-darkest: #2a357d; + --ifm-color-primary-light: #4b59b3; + --ifm-color-primary-lighter: #5562b7; + --ifm-color-primary-lightest: #6e7ac2; +} +html[data-theme='dark'][data-section='awaiten'] { + --ifm-color-primary: #5c6bc0; + --ifm-color-primary-dark: #5461af; + --ifm-color-primary-darker: #4f5ca5; + --ifm-color-primary-darkest: #434e8c; + --ifm-color-primary-light: #6b78c6; + --ifm-color-primary-lighter: #7380c9; + --ifm-color-primary-lightest: #8893d1; +} /* Footer: each library is its own column with the project name on top (linking to GitHub) and the NuGet badge below (linking to NuGet). diff --git a/Docs/pages/src/data/awaiten/benchmarks.json b/Docs/pages/src/data/awaiten/benchmarks.json new file mode 100644 index 00000000..edf1322a --- /dev/null +++ b/Docs/pages/src/data/awaiten/benchmarks.json @@ -0,0 +1,1663 @@ +{ + "capturedAt": { + "sha": "90154748d1e19719e3bf1d35782aa730481f7cc3", + "shortSha": "90154748", + "date": "2026-07-05", + "message": "docs: trim comments to WHY-only and make XML docs concise (#73)" + }, + "environment": { + "runner": "ubuntu-latest", + "toolchain": "BenchmarkDotNet MediumRun (in-process)" + }, + "benchmarks": { + "Build": { + "parameters": [ + "Size=8", + "Size=64", + "Size=256" + ], + "samples": { + "Size=256": { + "Awaiten": { + "timeNs": 92.2, + "memoryBytes": 2128, + "history": { + "timeNs": [ + 90.4, + 85, + 82, + 127.7, + 150.5, + 79.8, + 83.8, + 89.2, + 82, + 91.7, + 88.1, + 99.5, + 90.9, + 92.2, + 103.9, + 92.2 + ], + "memoryBytes": [ + 2128, + 2128, + 2128, + 2128, + 2128, + 2128, + 2128, + 2128, + 2128, + 2128, + 2128, + 2128, + 2128, + 2128, + 2128, + 2128 + ] + } + }, + "MsDI": { + "timeNs": 14419.7, + "memoryBytes": 61016, + "history": { + "timeNs": [ + 14688.2, + 13744.8, + 14107.8, + 14204.7, + 15134.1, + 13475.5, + 14387.2, + 14231.3, + 15006.3, + 14713.2, + 14722.9, + 16173.8, + 14698.3, + 14592.6, + 15827.2, + 14419.7 + ], + "memoryBytes": [ + 61016, + 61016, + 61016, + 61016, + 61016, + 61016, + 61016, + 61016, + 61016, + 61016, + 61016, + 61016, + 61016, + 61016, + 61016, + 61016 + ] + } + }, + "Autofac": { + "timeNs": 731020.8, + "memoryBytes": 740931, + "history": { + "timeNs": [ + 716730.8, + 727315.5, + 823439.6, + 723267.1, + 586506.6, + 811687, + 819831.5, + 717437.6, + 805773.5, + 713575, + 738997.5, + 745427.5, + 715789.4, + 740070.7, + 737210.7, + 731020.8 + ], + "memoryBytes": [ + 737373, + 741612, + 741589, + 738967, + 739569, + 740944, + 740937, + 737611, + 737614, + 737617, + 740919, + 720923, + 718927, + 740933, + 737597, + 740931 + ] + } + }, + "Jab": { + "timeNs": 79, + "memoryBytes": 2080, + "history": { + "timeNs": [ + 76.6, + 75.9, + 67.7, + 78.8, + 127.3, + 65.2, + 69.2, + 75.5, + 83.5, + 71.4, + 78.6, + 77.1, + 77.9, + 81.1, + 93.3, + 79 + ], + "memoryBytes": [ + 2080, + 2080, + 2080, + 2080, + 2080, + 2080, + 2080, + 2080, + 2080, + 2080, + 2080, + 2080, + 2080, + 2080, + 2080, + 2080 + ] + } + }, + "PureDI": { + "timeNs": 88.1, + "memoryBytes": 2112, + "history": { + "timeNs": [ + 84.2, + 82, + 76.2, + 86.1, + 130.8, + 75, + 78.7, + 84, + 74.1, + 85, + 87.3, + 87.8, + 86.5, + 130.8, + 102.1, + 88.1 + ], + "memoryBytes": [ + 2112, + 2112, + 2112, + 2112, + 2112, + 2112, + 2112, + 2112, + 2112, + 2112, + 2112, + 2112, + 2112, + 2112, + 2112, + 2112 + ] + } + }, + "DryIoc": { + "timeNs": 44084.8, + "memoryBytes": 80674, + "history": { + "timeNs": [ + 43704.6, + 41818.7, + 46589.4, + 44993.6, + 44127.2, + 45028.5, + 46228.3, + 43880.6, + 45820.1, + 43341.6, + 44866.6, + 45859.6, + 44742.6, + 44130.9, + 45413.1, + 44084.8 + ], + "memoryBytes": [ + 81169, + 80680, + 80673, + 80637, + 81384, + 80676, + 80672, + 81164, + 81167, + 81176, + 80681, + 80769, + 81124, + 80669, + 81163, + 80674 + ] + } + }, + "SimpleInjector": { + "timeNs": 358087.4, + "memoryBytes": 573008, + "history": { + "timeNs": [ + 341498.2, + 341459.1, + 373282.1, + 348342.6, + 388490.1, + 337242.8, + 348010.2, + 384299.6, + 345919.3, + 337711.7, + 359742.1, + 370384.6, + 377314, + 350610.3, + 382330.3, + 358087.4 + ], + "memoryBytes": [ + 573011, + 573139, + 573104, + 573059, + 572305, + 573218, + 573104, + 573028, + 573091, + 573105, + 573074, + 573184, + 573056, + 573011, + 573105, + 573008 + ] + } + } + }, + "Size=64": { + "Awaiten": { + "timeNs": 42.7, + "memoryBytes": 608, + "history": { + "timeNs": [ + 45.6, + 42.7 + ], + "memoryBytes": [ + 608, + 608 + ] + } + }, + "MsDI": { + "timeNs": 4191.6, + "memoryBytes": 16336, + "history": { + "timeNs": [ + 4279.2, + 4191.6 + ], + "memoryBytes": [ + 16336, + 16336 + ] + } + }, + "Autofac": { + "timeNs": 167634.6, + "memoryBytes": 190574, + "history": { + "timeNs": [ + 135563, + 167634.6 + ], + "memoryBytes": [ + 189845, + 190574 + ] + } + }, + "Jab": { + "timeNs": 25.5, + "memoryBytes": 544, + "history": { + "timeNs": [ + 31.8, + 25.5 + ], + "memoryBytes": [ + 544, + 544 + ] + } + }, + "PureDI": { + "timeNs": 34.2, + "memoryBytes": 576, + "history": { + "timeNs": [ + 38.2, + 34.2 + ], + "memoryBytes": [ + 576, + 576 + ] + } + } + }, + "Size=8": { + "Awaiten": { + "timeNs": 16.8, + "memoryBytes": 144, + "history": { + "timeNs": [ + 15.9, + 16.6, + 17.3, + 17.2, + 19.4, + 16.9, + 17.9, + 16.6, + 17.8, + 16.7, + 17.3, + 19.6, + 16.7, + 16.7, + 19.4, + 16.8 + ], + "memoryBytes": [ + 144, + 144, + 144, + 144, + 144, + 144, + 144, + 144, + 144, + 144, + 144, + 144, + 144, + 144, + 144, + 144 + ] + } + }, + "MsDI": { + "timeNs": 1447.5, + "memoryBytes": 5688, + "history": { + "timeNs": [ + 1504.2, + 1407, + 1501, + 1448.8, + 1732.6, + 1411, + 1624.6, + 1488, + 1489.1, + 1480.9, + 1519.3, + 1583.2, + 1450.1, + 1471.2, + 1611.9, + 1447.5 + ], + "memoryBytes": [ + 5688, + 5688, + 5688, + 5688, + 5688, + 5688, + 5688, + 5688, + 5688, + 5688, + 5688, + 5688, + 5688, + 5688, + 5688, + 5688 + ] + } + }, + "Autofac": { + "timeNs": 30248.1, + "memoryBytes": 33098, + "history": { + "timeNs": [ + 30371.9, + 29960.8, + 33760.7, + 29996.6, + 25372.1, + 33179.4, + 34257.7, + 30041.8, + 33005.4, + 29749.3, + 30009.2, + 31437.3, + 30277.6, + 29764.1, + 30252.2, + 30248.1 + ], + "memoryBytes": [ + 33098, + 33098, + 33098, + 33098, + 33092, + 33098, + 33098, + 33098, + 33098, + 33098, + 33098, + 33098, + 33098, + 33098, + 33098, + 33098 + ] + } + }, + "Jab": { + "timeNs": 13.4, + "memoryBytes": 96, + "history": { + "timeNs": [ + 9.1, + 11.8, + 8.6, + 8.6, + 10.7, + 8.3, + 8.5, + 16.5, + 8.9, + 10.8, + 12.1, + 12.9, + 14.4, + 9.7, + 12.1, + 13.4 + ], + "memoryBytes": [ + 96, + 96, + 96, + 96, + 96, + 96, + 96, + 96, + 96, + 96, + 96, + 96, + 96, + 96, + 96, + 96 + ] + } + }, + "PureDI": { + "timeNs": 15.1, + "memoryBytes": 128, + "history": { + "timeNs": [ + 14.8, + 14.5, + 15.3, + 14.9, + 18.1, + 15.5, + 15.7, + 15, + 15.4, + 15.6, + 16.4, + 17.9, + 15.7, + 15.4, + 17.4, + 15.1 + ], + "memoryBytes": [ + 128, + 128, + 128, + 128, + 128, + 128, + 128, + 128, + 128, + 128, + 128, + 128, + 128, + 128, + 128, + 128 + ] + } + }, + "DryIoc": { + "timeNs": 702.1, + "memoryBytes": 1528, + "history": { + "timeNs": [ + 720.4, + 667.9, + 758.1, + 761.7, + 721.3, + 760.8, + 770.6, + 674.7, + 781, + 722.5, + 745.7, + 852.1, + 710.3, + 749.6, + 784.1, + 702.1 + ], + "memoryBytes": [ + 1528, + 1528, + 1528, + 1528, + 1528, + 1528, + 1528, + 1528, + 1528, + 1528, + 1528, + 1528, + 1528, + 1528, + 1528, + 1528 + ] + } + }, + "SimpleInjector": { + "timeNs": 11784.6, + "memoryBytes": 24760, + "history": { + "timeNs": [ + 11410.5, + 11270.2, + 12033, + 11748.1, + 13496.9, + 11192.1, + 11591, + 12519.3, + 12048.3, + 11552.9, + 12005.6, + 12486.2, + 12224.4, + 11792.8, + 12421, + 11784.6 + ], + "memoryBytes": [ + 24761, + 24760, + 24760, + 24760, + 24760, + 24760, + 24760, + 24760, + 24760, + 24761, + 24762, + 24761, + 24761, + 24761, + 24760, + 24760 + ] + } + } + } + } + }, + "Realistic": { + "parameters": [ + "" + ], + "samples": { + "": { + "Awaiten": { + "timeNs": 261.1, + "memoryBytes": 568, + "history": { + "timeNs": [ + 235.9, + 251.3, + 232.2, + 248.2, + 260.7, + 232, + 232.3, + 242.9, + 258.7, + 238, + 236.3, + 259.3, + 240, + 276.8, + 253.5, + 261.1 + ], + "memoryBytes": [ + 568, + 568, + 568, + 568, + 568, + 568, + 568, + 568, + 568, + 568, + 568, + 568, + 568, + 568, + 568, + 568 + ] + } + }, + "MsDI": { + "timeNs": 579.4, + "memoryBytes": 1104, + "history": { + "timeNs": [ + 573.9, + 601.4, + 576.4, + 619.7, + 582.4, + 571.1, + 574.3, + 715.9, + 578.6, + 610.1, + 561.2, + 588.3, + 612, + 687.4, + 579.6, + 579.4 + ], + "memoryBytes": [ + 1104, + 1104, + 1104, + 1104, + 1104, + 1104, + 1104, + 1104, + 1104, + 1104, + 1104, + 1104, + 1104, + 1104, + 1104, + 1104 + ] + } + }, + "Autofac": { + "timeNs": 8021.8, + "memoryBytes": 13696, + "history": { + "timeNs": [ + 8274.1, + 8352, + 8269, + 8656.7, + 8253.4, + 7881.2, + 8147.9, + 8464.4, + 8379.7, + 7965.1, + 7931.3, + 8412, + 8397.4, + 7335.8, + 7995.2, + 8021.8 + ], + "memoryBytes": [ + 13696, + 13696, + 13696, + 13696, + 13696, + 13696, + 13696, + 13696, + 13696, + 13696, + 13696, + 13696, + 13696, + 13696, + 13696, + 13696 + ] + } + }, + "Jab": { + "timeNs": 196, + "memoryBytes": 432, + "history": { + "timeNs": [ + 174.4, + 179.7, + 176.6, + 185, + 198.3, + 175.5, + 174.1, + 180.1, + 203.4, + 177.9, + 173.3, + 197.6, + 182.1, + 220.2, + 196.3, + 196 + ], + "memoryBytes": [ + 432, + 432, + 432, + 432, + 432, + 432, + 432, + 432, + 432, + 432, + 432, + 432, + 432, + 432, + 432, + 432 + ] + } + }, + "PureDI": { + "timeNs": 192.9, + "memoryBytes": 632, + "history": { + "timeNs": [ + 172.3, + 193.6, + 179.9, + 192, + 187.4, + 186.6, + 175.4, + 187.7, + 187.8, + 186.4, + 173.1, + 191.4, + 180.3, + 194.3, + 200, + 192.9 + ], + "memoryBytes": [ + 632, + 632, + 632, + 632, + 632, + 632, + 632, + 632, + 632, + 632, + 632, + 632, + 632, + 632, + 632, + 632 + ] + } + }, + "DryIoc": { + "timeNs": 432.2, + "memoryBytes": 944, + "history": { + "timeNs": [ + 393.8, + 410.6, + 387.5, + 438.5, + 445, + 394.6, + 383, + 398, + 449.7, + 414.2, + 388, + 429.3, + 394.9, + 434.2, + 444.5, + 432.2 + ], + "memoryBytes": [ + 944, + 944, + 944, + 944, + 944, + 944, + 944, + 944, + 944, + 944, + 944, + 944, + 944, + 944, + 944, + 944 + ] + } + }, + "SimpleInjector": { + "timeNs": 700.5, + "memoryBytes": 1096, + "history": { + "timeNs": [ + 700.5, + 760.1, + 691.5, + 740.2, + 695, + 742.2, + 715, + 722.7, + 671.2, + 761.3, + 711.7, + 704.1, + 699.2, + 757.1, + 708.7, + 700.5 + ], + "memoryBytes": [ + 1096, + 1096, + 1096, + 1096, + 1096, + 1096, + 1096, + 1096, + 1096, + 1096, + 1096, + 1096, + 1096, + 1096, + 1096, + 1096 + ] + } + } + } + } + }, + "Resolve": { + "parameters": [ + "Size=8", + "Size=64", + "Size=256" + ], + "samples": { + "Size=256": { + "Awaiten": { + "timeNs": 8.5, + "memoryBytes": 0, + "history": { + "timeNs": [ + 8.4, + 8.4, + 9.1, + 9.2, + 9, + 11.2, + 8.7, + 7.3, + 8.4, + 8.4, + 8.8, + 8.4, + 8.5, + 8.8, + 8.4, + 8.5 + ], + "memoryBytes": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "MsDI": { + "timeNs": 7.2, + "memoryBytes": 0, + "history": { + "timeNs": [ + 7.3, + 7.3, + 7.2, + 7.5, + 8.5, + 7.8, + 7.8, + 7.5, + 7.2, + 7.3, + 7.2, + 7.2, + 8.5, + 7.2, + 7.2, + 7.2 + ], + "memoryBytes": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "Autofac": { + "timeNs": 151.9, + "memoryBytes": 656, + "history": { + "timeNs": [ + 105.3, + 107.1, + 109.1, + 130.3, + 114, + 127.8, + 112.3, + 159.9, + 110.2, + 107.1, + 117.8, + 124.5, + 118.5, + 143.4, + 118, + 151.9 + ], + "memoryBytes": [ + 656, + 656, + 656, + 656, + 656, + 656, + 656, + 656, + 656, + 656, + 656, + 656, + 656, + 656, + 656, + 656 + ] + } + }, + "Jab": { + "timeNs": 42.7, + "memoryBytes": 0, + "history": { + "timeNs": [ + 42.7, + 42.7, + 42.7, + 78.7, + 46.6, + 43.9, + 43.4, + 80.8, + 42.7, + 42.7, + 42.7, + 43, + 46.8, + 42.7, + 42.7, + 42.7 + ], + "memoryBytes": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "PureDI": { + "timeNs": 8.3, + "memoryBytes": 0, + "history": { + "timeNs": [ + 7.3, + 8.3, + 8.3, + 7.3, + 7.8, + 8.3, + 7.3, + 8.2, + 7.6, + 7.6, + 8.3, + 7.6, + 9.1, + 8.3, + 7.7, + 8.3 + ], + "memoryBytes": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "DryIoc": { + "timeNs": 8.7, + "memoryBytes": 0, + "history": { + "timeNs": [ + 9.1, + 9.4, + 9.3, + 8.3, + 8.5, + 8.6, + 9.1, + 7.6, + 8.4, + 9.4, + 9, + 9.6, + 8.6, + 8.7, + 9, + 8.7 + ], + "memoryBytes": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "SimpleInjector": { + "timeNs": 14.6, + "memoryBytes": 0, + "history": { + "timeNs": [ + 14.9, + 14.4, + 14.7, + 10.9, + 15.1, + 15.3, + 14.7, + 9.8, + 14.8, + 14.6, + 14.9, + 14.1, + 14.5, + 14.9, + 14.4, + 14.6 + ], + "memoryBytes": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + } + }, + "Size=64": { + "Awaiten": { + "timeNs": 10.6, + "memoryBytes": 0, + "history": { + "timeNs": [ + 15.4, + 10.6 + ], + "memoryBytes": [ + 0, + 0 + ] + } + }, + "MsDI": { + "timeNs": 7.7, + "memoryBytes": 0, + "history": { + "timeNs": [ + 7, + 7.7 + ], + "memoryBytes": [ + 0, + 0 + ] + } + }, + "Autofac": { + "timeNs": 109.3, + "memoryBytes": 656, + "history": { + "timeNs": [ + 110.4, + 109.3 + ], + "memoryBytes": [ + 656, + 656 + ] + } + }, + "Jab": { + "timeNs": 12.8, + "memoryBytes": 0, + "history": { + "timeNs": [ + 12.8, + 12.8 + ], + "memoryBytes": [ + 0, + 0 + ] + } + }, + "PureDI": { + "timeNs": 7.7, + "memoryBytes": 0, + "history": { + "timeNs": [ + 7.4, + 7.7 + ], + "memoryBytes": [ + 0, + 0 + ] + } + } + }, + "Size=8": { + "Awaiten": { + "timeNs": 7.5, + "memoryBytes": 0, + "history": { + "timeNs": [ + 7.5, + 7.5, + 8.1, + 8, + 8.1, + 7.5, + 8.1, + 6.3, + 7.5, + 9.1, + 8.2, + 7.5, + 8.4, + 7.5, + 7.6, + 7.5 + ], + "memoryBytes": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "MsDI": { + "timeNs": 7.2, + "memoryBytes": 0, + "history": { + "timeNs": [ + 7.5, + 7.2, + 7.4, + 9.6, + 10.4, + 9, + 7.9, + 7.5, + 7.2, + 7.2, + 8.3, + 8.4, + 8.3, + 7.4, + 7.3, + 7.2 + ], + "memoryBytes": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "Autofac": { + "timeNs": 147.7, + "memoryBytes": 656, + "history": { + "timeNs": [ + 104.3, + 109.1, + 112.5, + 132, + 105.5, + 123.7, + 114.3, + 160.5, + 110.9, + 107.2, + 129.4, + 124.2, + 112.3, + 153.7, + 112.7, + 147.7 + ], + "memoryBytes": [ + 656, + 656, + 656, + 656, + 656, + 656, + 656, + 656, + 656, + 656, + 656, + 656, + 656, + 656, + 656, + 656 + ] + } + }, + "Jab": { + "timeNs": 2.7, + "memoryBytes": 0, + "history": { + "timeNs": [ + 3.2, + 2.8, + 3.2, + 2.9, + 2.7, + 2.7, + 2.6, + 3.4, + 2.8, + 2.6, + 2.7, + 2.8, + 3.5, + 2.8, + 2.8, + 2.7 + ], + "memoryBytes": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "PureDI": { + "timeNs": 5.4, + "memoryBytes": 0, + "history": { + "timeNs": [ + 5.5, + 5.5, + 3.5, + 4.9, + 4.4, + 6.2, + 5.5, + 5.1, + 5.4, + 5.5, + 5.4, + 5.4, + 4.8, + 5.4, + 5.4, + 5.4 + ], + "memoryBytes": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "DryIoc": { + "timeNs": 8.8, + "memoryBytes": 0, + "history": { + "timeNs": [ + 9.2, + 9.4, + 9.4, + 8.5, + 8.7, + 8.4, + 9, + 7.5, + 8.4, + 9.3, + 9.1, + 9.6, + 8.8, + 8.8, + 9.1, + 8.8 + ], + "memoryBytes": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "SimpleInjector": { + "timeNs": 10.9, + "memoryBytes": 0, + "history": { + "timeNs": [ + 10.9, + 10.8, + 11.2, + 10.8, + 11.4, + 11.3, + 11, + 9.8, + 11.2, + 10.9, + 11.3, + 10.7, + 10.9, + 11.2, + 11, + 10.9 + ], + "memoryBytes": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + } + } + } + } + } +} diff --git a/Docs/pages/src/pages/index.tsx b/Docs/pages/src/pages/index.tsx index 8c727374..f81bb1b1 100644 --- a/Docs/pages/src/pages/index.tsx +++ b/Docs/pages/src/pages/index.tsx @@ -45,6 +45,15 @@ const libraries: Library[] = [ iconLight: 'img/mockolate-light.svg', iconDark: 'img/mockolate-dark.svg', }, + { + label: 'Awaiten', + href: '/Awaiten/', + tagline: 'The async-first DI container.', + description: + 'A source-generator dependency injection container. No runtime reflection, compile-time-verified wiring, and first-class async initialization. Native-AOT clean.', + iconLight: 'img/awaiten.png', + iconDark: 'img/awaiten.png', + }, ]; function HomepageHeader() { @@ -65,7 +74,7 @@ function LibraryCard({library}: {library: Library}) { const lightSrc = useBaseUrl(library.iconLight); const darkSrc = useBaseUrl(library.iconDark); return ( -
+
diff --git a/Docs/pages/src/theme/Logo/index.tsx b/Docs/pages/src/theme/Logo/index.tsx index 62e098d7..cf24e2eb 100644 --- a/Docs/pages/src/theme/Logo/index.tsx +++ b/Docs/pages/src/theme/Logo/index.tsx @@ -25,6 +25,10 @@ const SECTION_LOGOS: Record = { light: 'img/mockolate-light.svg', dark: 'img/mockolate-dark.svg', }, + awaiten: { + light: 'img/awaiten.png', + dark: 'img/awaiten.png', + }, }; const DEFAULT_LOGO = { @@ -32,7 +36,7 @@ const DEFAULT_LOGO = { dark: 'img/testably-dark.svg', }; -const SECTION_PATTERN = /^\/(abstractions|awexpect|mockolate)(?:\/|$)/i; +const SECTION_PATTERN = /^\/(abstractions|awexpect|mockolate|awaiten)(?:\/|$)/i; function pickLogo(pathname: string) { const match = pathname.match(SECTION_PATTERN); diff --git a/Docs/pages/static/img/awaiten.png b/Docs/pages/static/img/awaiten.png new file mode 100644 index 00000000..0092993d Binary files /dev/null and b/Docs/pages/static/img/awaiten.png differ diff --git a/Pipeline/Build.AwaitenBenchmarks.cs b/Pipeline/Build.AwaitenBenchmarks.cs new file mode 100644 index 00000000..42329118 --- /dev/null +++ b/Pipeline/Build.AwaitenBenchmarks.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +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 Awaiten benchmark snapshot from the long-lived + /// benchmarks branch of Testably/Awaiten and reduces it to + /// the shape consumed by the AwaitenBenchmarkResult React component. + /// Mirrors the target but for the + /// 7-container Awaiten dataset (Awaiten, MsDI, Autofac, Jab, PureDI, + /// DryIoc, SimpleInjector) and groups the Size variants of the + /// Build/Resolve scenarios under one scenario key. + /// + Target AwaitenBenchmarks => _ => _ + .Executes(async () => + { + AbsolutePath outputDirectory = RootDirectory / "Docs" / "pages" / "src" / "data" / "awaiten"; + outputDirectory.CreateDirectory(); + AbsolutePath outputPath = outputDirectory / "benchmarks.json"; + + Log.Information( + "Fetching benchmark snapshot from {Repo} branch {Branch}", + AwaitenBenchmarksRepo, 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/{AwaitenBenchmarksRepo}/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}"); + + AwaitenSnapshot snapshot = ReduceToAwaitenSnapshot(rawData, BenchmarkHistoryLength); + + string pretty = JsonSerializer.Serialize(snapshot, OutputJsonOptions); + await File.WriteAllTextAsync(outputPath, pretty + Environment.NewLine); + + Log.Information( + "Wrote {ScenarioCount} scenarios to {Path} (captured commit {Sha} on {Date})", + snapshot.Benchmarks.Count, outputPath, snapshot.CapturedAt.ShortSha, snapshot.CapturedAt.Date); + }); + + const string AwaitenBenchmarksRepo = "Testably/Awaiten"; + + static readonly string[] AwaitenLibraries = + ["Awaiten", "MsDI", "Autofac", "Jab", "PureDI", "DryIoc", "SimpleInjector",]; + + static AwaitenSnapshot ReduceToAwaitenSnapshot(Dictionary rawData, int historyLength) + { + // All benchmark groups share the same commit list (Awaiten's benchmark CI appends one + // entry per push to main, in lockstep across every chart). 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"); + } + + // Group chart keys ("Build (Size=8)", "Resolve (Size=256)", "Realistic") by scenario name. + // The Size variants become parameters under a single "Build" / "Resolve" scenario; the + // unparameterised "Realistic" chart keeps an empty parameter key. + Dictionary scenarios = new(); + foreach ((string chartKey, RawBenchmark benchmark) in rawData.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)) + { + if (benchmark.Datasets is null) continue; + + (string scenario, string parameter) = SplitChartKey(chartKey); + Dictionary samples = new(); + foreach (string library in AwaitenLibraries) + { + AwaitenLibrarySample? sample = BuildAwaitenSample(benchmark, library, historyLength); + if (sample is not null) + { + samples[library] = sample; + } + } + + if (samples.Count == 0) continue; + + if (!scenarios.TryGetValue(scenario, out AwaitenScenario? entry)) + { + entry = new AwaitenScenario(); + scenarios[scenario] = entry; + } + + entry.Parameters.Add(parameter); + entry.Samples[parameter] = samples; + } + + // The ordinal chart-key sort orders sizes as 256, 64, 8; re-sort each scenario's + // parameters by their numeric size so the tabs read 8, 64, 256. + foreach (AwaitenScenario scenario in scenarios.Values) + { + scenario.Parameters.Sort((a, b) => ParameterSize(a).CompareTo(ParameterSize(b))); + } + + string sha = latestCommit.Sha ?? string.Empty; + string shortSha = sha.Length >= 8 ? sha[..8] : sha; + string date = ParseShortDate(latestCommit.Date); + + return new AwaitenSnapshot + { + CapturedAt = new SnapshotCommit + { + Sha = sha, + ShortSha = shortSha, + Date = date, + Message = latestCommit.Message, + }, + Environment = new SnapshotEnvironment + { + Runner = "ubuntu-latest", + Toolchain = "BenchmarkDotNet MediumRun (in-process)", + }, + Benchmarks = scenarios, + }; + } + + // Extracts the trailing integer of a "Size=N" parameter for natural sorting; an empty or + // number-less parameter sorts first. + static int ParameterSize(string parameter) + { + int equals = parameter.LastIndexOf('='); + string digits = equals >= 0 ? parameter[(equals + 1)..] : parameter; + return int.TryParse(digits, out int value) ? value : -1; + } + + static AwaitenLibrarySample? BuildAwaitenSample(RawBenchmark benchmark, string library, int historyLength) + { + double[]? time = FindData(benchmark.Datasets!, library, "y"); + double[]? memory = FindData(benchmark.Datasets!, library, "y1"); + if (time is not {Length: > 0,}) return null; + + return new AwaitenLibrarySample + { + 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(), + }, + }; + } + + // ─── Snapshot shape consumed by AwaitenBenchmarkResult.tsx ─── + sealed class AwaitenSnapshot + { + [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 AwaitenScenario + { + [JsonPropertyName("parameters")] public List Parameters { get; init; } = new(); + + [JsonPropertyName("samples")] + public Dictionary> Samples { get; init; } = new(); + } + + sealed class AwaitenLibrarySample + { + [JsonPropertyName("timeNs")] public double TimeNs { get; init; } + [JsonPropertyName("memoryBytes")] public long? MemoryBytes { get; init; } + [JsonPropertyName("history")] public SampleHistory? History { get; init; } + } +} diff --git a/Pipeline/Build.Pages.cs b/Pipeline/Build.Pages.cs index 9bac8c0a..4cad37f5 100644 --- a/Pipeline/Build.Pages.cs +++ b/Pipeline/Build.Pages.cs @@ -82,11 +82,13 @@ record ReadmeSubstitution( [ new("Testably", "Mockolate.Migration", "11-migration.md"), ]), + new("Testably", "Awaiten", "Docs/pages", "Awaiten"), ]; Target Pages => _ => _ .DependsOn(Benchmarks) .DependsOn(MockolateBenchmarks) + .DependsOn(AwaitenBenchmarks) .Executes(async () => { AbsolutePath docsRoot = RootDirectory / "Docs" / "pages" / "docs";