Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]
Expand Down
1 change: 1 addition & 0 deletions .nuke/build.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"ExecutableTarget": {
"type": "string",
"enum": [
"Benchmarks",
"Pages"
]
},
Expand Down
221 changes: 221 additions & 0 deletions Docs/pages/src/components/BenchmarkResult/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Comment thread
vbreuss marked this conversation as resolved.
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<string, BenchmarkEntry>;
};

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 (
<svg
className={className}
width={SPARK_WIDTH}
height={SPARK_HEIGHT}
viewBox={`0 0 ${SPARK_WIDTH} ${SPARK_HEIGHT}`}
aria-hidden="true">
<path
d={d}
fill="none"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
strokeLinecap="round"
/>
<circle cx={lastX} cy={lastY} r="1.6" fill="currentColor" />
</svg>
);
}

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 (
<div className={styles.metricRow}>
<div className={styles.metricLabel}>{label}</div>
<div className={styles.bars}>
<div className={styles.barRow}>
<span className={styles.libraryLabel}>aweXpect</span>
<span className={styles.bar}>
<span className={styles.barFillPrimary} style={{width: `${aweXpectWidth}%`}} />
</span>
<span className={styles.barValue}>{format(aweXpectValue)}</span>
<Sparkline values={aweXpectHistory} className={`${styles.sparkline} ${styles.sparklineAwexpect}`} />
</div>
<div className={styles.barRow}>
<span className={styles.libraryLabel}>FluentAssertions</span>
<span className={styles.bar}>
<span className={styles.barFillFluentAssertions} style={{width: `${faWidth}%`}} />
</span>
<span className={styles.barValue}>{format(faValue)}</span>
<Sparkline values={faHistory} className={`${styles.sparkline} ${styles.sparklineFluentAssertions}`} />
</div>
</div>
<div className={delta.better ? styles.delta : `${styles.delta} ${styles.deltaWorse}`}>
{delta.text}
</div>
</div>
);
}

type Props = {
name: string;
};

export default function BenchmarkResult({name}: Props): ReactElement {
const entry = data.benchmarks[name];
if (!entry) {
return (
<div className={styles.error}>
No benchmark data for <code>{name}</code>.
</div>
);
}

return (
<div className={styles.result}>
<MetricRow
label="Time"
aweXpectValue={entry.aweXpect.timeNs}
faValue={entry.FluentAssertions.timeNs}
aweXpectHistory={entry.aweXpect.history?.timeNs}
faHistory={entry.FluentAssertions.history?.timeNs}
format={formatNs}
/>
{entry.aweXpect.memoryBytes !== undefined &&
entry.FluentAssertions.memoryBytes !== undefined && (
<MetricRow
label="Memory"
aweXpectValue={entry.aweXpect.memoryBytes}
faValue={entry.FluentAssertions.memoryBytes}
aweXpectHistory={entry.aweXpect.history?.memoryBytes}
faHistory={entry.FluentAssertions.history?.memoryBytes}
format={formatBytes}
/>
)}
<div className={styles.caption}>
Captured on commit{' '}
<a
href={`https://github.com/Testably/aweXpect/commit/${data.capturedAt.sha}`}
Comment thread
vbreuss marked this conversation as resolved.
target="_blank"
rel="noreferrer"
title={data.capturedAt.message}>
<code>{data.capturedAt.shortSha}</code>
</a>{' '}
({data.capturedAt.date})
{data.environment?.runner ? ` on ${data.environment.runner}` : ''}.
</div>
</div>
);
}
146 changes: 146 additions & 0 deletions Docs/pages/src/components/BenchmarkResult/styles.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading