benchforge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +432 -0
  2. package/bin/benchforge +3 -0
  3. package/dist/bin/benchforge.mjs +9 -0
  4. package/dist/bin/benchforge.mjs.map +1 -0
  5. package/dist/browser/index.js +914 -0
  6. package/dist/index.mjs +3 -0
  7. package/dist/src-CGuaC3Wo.mjs +3676 -0
  8. package/dist/src-CGuaC3Wo.mjs.map +1 -0
  9. package/package.json +49 -0
  10. package/src/BenchMatrix.ts +380 -0
  11. package/src/Benchmark.ts +33 -0
  12. package/src/BenchmarkReport.ts +156 -0
  13. package/src/GitUtils.ts +79 -0
  14. package/src/HtmlDataPrep.ts +148 -0
  15. package/src/MeasuredResults.ts +127 -0
  16. package/src/NodeGC.ts +48 -0
  17. package/src/PermutationTest.ts +115 -0
  18. package/src/StandardSections.ts +268 -0
  19. package/src/StatisticalUtils.ts +176 -0
  20. package/src/TypeUtil.ts +8 -0
  21. package/src/bin/benchforge.ts +4 -0
  22. package/src/browser/BrowserGcStats.ts +44 -0
  23. package/src/browser/BrowserHeapSampler.ts +248 -0
  24. package/src/cli/CliArgs.ts +64 -0
  25. package/src/cli/FilterBenchmarks.ts +68 -0
  26. package/src/cli/RunBenchCLI.ts +856 -0
  27. package/src/export/JsonExport.ts +103 -0
  28. package/src/export/JsonFormat.ts +91 -0
  29. package/src/export/PerfettoExport.ts +203 -0
  30. package/src/heap-sample/HeapSampleReport.ts +196 -0
  31. package/src/heap-sample/HeapSampler.ts +78 -0
  32. package/src/html/HtmlReport.ts +131 -0
  33. package/src/html/HtmlTemplate.ts +284 -0
  34. package/src/html/Types.ts +88 -0
  35. package/src/html/browser/CIPlot.ts +287 -0
  36. package/src/html/browser/HistogramKde.ts +118 -0
  37. package/src/html/browser/LegendUtils.ts +163 -0
  38. package/src/html/browser/RenderPlots.ts +263 -0
  39. package/src/html/browser/SampleTimeSeries.ts +389 -0
  40. package/src/html/browser/Types.ts +96 -0
  41. package/src/html/browser/index.ts +1 -0
  42. package/src/html/index.ts +17 -0
  43. package/src/index.ts +92 -0
  44. package/src/matrix/CaseLoader.ts +36 -0
  45. package/src/matrix/MatrixFilter.ts +103 -0
  46. package/src/matrix/MatrixReport.ts +290 -0
  47. package/src/matrix/VariantLoader.ts +46 -0
  48. package/src/runners/AdaptiveWrapper.ts +391 -0
  49. package/src/runners/BasicRunner.ts +368 -0
  50. package/src/runners/BenchRunner.ts +60 -0
  51. package/src/runners/CreateRunner.ts +11 -0
  52. package/src/runners/GcStats.ts +107 -0
  53. package/src/runners/RunnerOrchestrator.ts +374 -0
  54. package/src/runners/RunnerUtils.ts +2 -0
  55. package/src/runners/TimingUtils.ts +13 -0
  56. package/src/runners/WorkerScript.ts +256 -0
  57. package/src/table-util/ConvergenceFormatters.ts +19 -0
  58. package/src/table-util/Formatters.ts +152 -0
  59. package/src/table-util/README.md +70 -0
  60. package/src/table-util/TableReport.ts +293 -0
  61. package/src/table-util/test/TableReport.test.ts +105 -0
  62. package/src/table-util/test/TableValueExtractor.test.ts +41 -0
  63. package/src/table-util/test/TableValueExtractor.ts +100 -0
  64. package/src/test/AdaptiveRunner.test.ts +185 -0
  65. package/src/test/AdaptiveStatistics.integration.ts +119 -0
  66. package/src/test/BenchmarkReport.test.ts +82 -0
  67. package/src/test/BrowserBench.e2e.test.ts +44 -0
  68. package/src/test/BrowserBench.test.ts +79 -0
  69. package/src/test/GcStats.test.ts +94 -0
  70. package/src/test/PermutationTest.test.ts +121 -0
  71. package/src/test/RunBenchCLI.test.ts +166 -0
  72. package/src/test/RunnerOrchestrator.test.ts +102 -0
  73. package/src/test/StatisticalUtils.test.ts +112 -0
  74. package/src/test/TestUtils.ts +93 -0
  75. package/src/test/fixtures/test-bench-script.ts +30 -0
  76. package/src/tests/AdaptiveConvergence.test.ts +177 -0
  77. package/src/tests/AdaptiveSampling.test.ts +240 -0
  78. package/src/tests/BenchMatrix.test.ts +366 -0
  79. package/src/tests/MatrixFilter.test.ts +117 -0
  80. package/src/tests/MatrixReport.test.ts +139 -0
  81. package/src/tests/RealDataValidation.test.ts +177 -0
  82. package/src/tests/fixtures/baseline/impl.ts +4 -0
  83. package/src/tests/fixtures/bevy30-samples.ts +158 -0
  84. package/src/tests/fixtures/cases/asyncCases.ts +7 -0
  85. package/src/tests/fixtures/cases/cases.ts +8 -0
  86. package/src/tests/fixtures/cases/variants/product.ts +2 -0
  87. package/src/tests/fixtures/cases/variants/sum.ts +2 -0
  88. package/src/tests/fixtures/discover/fast.ts +1 -0
  89. package/src/tests/fixtures/discover/slow.ts +4 -0
  90. package/src/tests/fixtures/invalid/bad.ts +1 -0
  91. package/src/tests/fixtures/loader/fast.ts +1 -0
  92. package/src/tests/fixtures/loader/slow.ts +4 -0
  93. package/src/tests/fixtures/loader/stateful.ts +2 -0
  94. package/src/tests/fixtures/stateful/stateful.ts +2 -0
  95. package/src/tests/fixtures/variants/extra.ts +1 -0
  96. package/src/tests/fixtures/variants/impl.ts +1 -0
  97. package/src/tests/fixtures/worker/fast.ts +1 -0
  98. package/src/tests/fixtures/worker/slow.ts +4 -0
@@ -0,0 +1,152 @@
1
+ import pico from "picocolors";
2
+ import type { CIDirection, DifferenceCI } from "../StatisticalUtils.ts";
3
+
4
+ const isTest = process.env.NODE_ENV === "test" || process.env.VITEST === "true";
5
+ const { red, green } = isTest
6
+ ? { red: (str: string) => str, green: (str: string) => str }
7
+ : pico;
8
+
9
+ /** Format floats with custom precision */
10
+ export function floatPrecision(precision: number) {
11
+ return (x: unknown): string | null => {
12
+ if (typeof x !== "number") return null;
13
+ return x.toFixed(precision).replace(/\.?0+$/, "");
14
+ };
15
+ }
16
+
17
+ /** Format percentages with custom precision */
18
+ export function percentPrecision(precision: number) {
19
+ return (x: unknown): string | null => {
20
+ if (typeof x !== "number") return null;
21
+ return percent(x, precision);
22
+ };
23
+ }
24
+
25
+ /** Format duration in milliseconds with appropriate units */
26
+ export function duration(ms: unknown): string | null {
27
+ if (typeof ms !== "number") return null;
28
+ if (ms < 0.001) return `${(ms * 1000000).toFixed(0)}ns`;
29
+ if (ms < 1) return `${(ms * 1000).toFixed(1)}μs`;
30
+ if (ms < 1000) return `${ms.toFixed(2)}ms`;
31
+ return `${(ms / 1000).toFixed(2)}s`;
32
+ }
33
+
34
+ /** Format time in milliseconds, showing very small values with units */
35
+ export function timeMs(ms: unknown): string | null {
36
+ if (typeof ms !== "number") return null;
37
+ if (ms < 0.001) return `${(ms * 1000000).toFixed(0)}ns`;
38
+ if (ms < 0.01) return `${(ms * 1000).toFixed(1)}μs`;
39
+ if (ms >= 10) return ms.toFixed(0);
40
+ return ms.toFixed(2);
41
+ }
42
+
43
+ /** Format as rate (value per unit) */
44
+ export function rate(unit: string) {
45
+ return (value: unknown) => {
46
+ if (typeof value !== "number") return null;
47
+ return `${integer(value)}/${unit}`;
48
+ };
49
+ }
50
+
51
+ /** Format integer with thousand separators */
52
+ export function integer(x: unknown): string | null {
53
+ if (typeof x !== "number") return null;
54
+ return new Intl.NumberFormat("en-US").format(Math.round(x));
55
+ }
56
+
57
+ /** Format fraction as percentage (0.473 → 47.3%) */
58
+ export function percent(fraction: unknown, precision = 1): string | null {
59
+ if (typeof fraction !== "number") return null;
60
+ return `${Math.abs(fraction * 100).toFixed(precision)}%`;
61
+ }
62
+
63
+ /** Format percentage difference between two values */
64
+ export function diffPercent(main: unknown, base: unknown): string {
65
+ if (typeof main !== "number" || typeof base !== "number") return " ";
66
+ const diff = main - base;
67
+ return coloredPercent(diff, base);
68
+ }
69
+
70
+ /** Format percentage difference for benchmarks (lower is better) */
71
+ export function diffPercentBenchmark(main: unknown, base: unknown): string {
72
+ if (typeof main !== "number" || typeof base !== "number") return " ";
73
+ const diff = main - base;
74
+ return coloredPercent(diff, base, false); // negative is good for benchmarks
75
+ }
76
+
77
+ /** Format fraction as colored +/- percentage */
78
+ function coloredPercent(
79
+ numerator: number,
80
+ denominator: number,
81
+ positiveIsGreen = true,
82
+ ): string {
83
+ const fraction = numerator / denominator;
84
+ if (Number.isNaN(fraction) || !Number.isFinite(fraction)) {
85
+ return " ";
86
+ }
87
+ const positive = fraction >= 0;
88
+ const sign = positive ? "+" : "-";
89
+ const percentStr = `${sign}${percent(fraction)}`;
90
+ const isGood = positive === positiveIsGreen;
91
+ return isGood ? green(percentStr) : red(percentStr);
92
+ }
93
+
94
+ /** Format memory size in KB with appropriate units */
95
+ export function memoryKB(kb: unknown): string | null {
96
+ if (typeof kb !== "number") return null;
97
+ if (kb < 1024) return `${kb.toFixed(0)}KB`;
98
+ return `${(kb / 1024).toFixed(1)}MB`;
99
+ }
100
+
101
+ /** Format bytes with appropriate units (B, KB, MB, GB) */
102
+ export function formatBytes(bytes: unknown): string | null {
103
+ if (typeof bytes !== "number") return null;
104
+ if (bytes < 1024) return `${bytes.toFixed(0)}B`;
105
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
106
+ if (bytes < 1024 * 1024 * 1024)
107
+ return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
108
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}GB`;
109
+ }
110
+
111
+ /** Format percentage difference with confidence interval */
112
+ export function formatDiffWithCI(value: unknown): string | null {
113
+ if (!isDifferenceCI(value)) return null;
114
+ const { percent, ci, direction } = value;
115
+ return colorByDirection(diffCIText(percent, ci), direction);
116
+ }
117
+
118
+ /** Format percentage difference with CI for throughput metrics (higher is better) */
119
+ export function formatDiffWithCIHigherIsBetter(value: unknown): string | null {
120
+ if (!isDifferenceCI(value)) return null;
121
+ const { percent, ci, direction } = value;
122
+ // Flip percent sign for "higher is better" metrics (direction stays same)
123
+ return colorByDirection(diffCIText(-percent, [-ci[1], -ci[0]]), direction);
124
+ }
125
+
126
+ /** @return formatted "pct [lo, hi]" text for a diff with CI */
127
+ function diffCIText(pct: number, ci: [number, number]): string {
128
+ return `${formatBound(pct)} [${formatBound(ci[0])}, ${formatBound(ci[1])}]`;
129
+ }
130
+
131
+ /** @return text colored green for faster, red for slower */
132
+ function colorByDirection(text: string, direction: CIDirection): string {
133
+ if (direction === "faster") return green(text);
134
+ if (direction === "slower") return red(text);
135
+ return text;
136
+ }
137
+
138
+ /** @return signed percentage string (e.g. "+1.2%", "-3.4%") */
139
+ function formatBound(v: number): string {
140
+ const sign = v >= 0 ? "+" : "";
141
+ return `${sign}${v.toFixed(1)}%`;
142
+ }
143
+
144
+ /** @return true if value is a DifferenceCI object */
145
+ function isDifferenceCI(x: unknown): x is DifferenceCI {
146
+ return typeof x === "object" && x !== null && "ci" in x && "direction" in x;
147
+ }
148
+
149
+ /** @return truncated string with ellipsis if over maxLen */
150
+ export function truncate(str: string, maxLen = 30): string {
151
+ return str.length > maxLen ? str.slice(0, maxLen - 3) + "..." : str;
152
+ }
@@ -0,0 +1,70 @@
1
+ # TableReport
2
+
3
+ Utilities for creating formatted text-based tables.
4
+ Under the hood, TableReport uses the npm [table](https://www.npmjs.com/package/table) library.
5
+
6
+ ### Features
7
+
8
+ * **Column Grouping:** Group related columns under a common header.
9
+ * **Difference Columns:** Automatically generate columns that show the percentage difference between a value and a baseline value.
10
+ * **Custom Formatting:** Declaratively provide custom formatters for columns.
11
+ * **Simplified Configuration:** A higher-level API for the `table` library.
12
+
13
+ ## `TableReport.ts`
14
+
15
+ The `buildTable` function in `TableReport.ts` is the main entry point for creating a table.
16
+ It takes a configuration object for columns and an array of data records for rows.
17
+
18
+ ### Example
19
+
20
+ Here's a simplified example of how to use `buildTable`:
21
+
22
+ ```typescript
23
+ import { buildTable, ColumnGroup } from './TableReport';
24
+ import { integer, floatPrecision } from './Formatters';
25
+
26
+ interface MyData {
27
+ name: string;
28
+ value: number;
29
+ score: number;
30
+ }
31
+
32
+ const data: MyData[] = [
33
+ { name: 'test A', value: 123, score: 45.6 },
34
+ { name: 'test B', value: 456, score: 78.9 },
35
+ ];
36
+
37
+ const baselineData: MyData[] = [
38
+ { name: 'test A', value: 100, score: 50.0 },
39
+ { name: 'test B', value: 500, score: 75.0 },
40
+ ];
41
+
42
+ const tableConfig: ColumnGroup<MyData>[] = [
43
+ {
44
+ columns: [{ key: 'name', title: 'Name' }],
45
+ },
46
+ {
47
+ groupTitle: 'Metrics',
48
+ columns: [
49
+ { key: 'value', title: 'Value', formatter: integer },
50
+ { key: 'value_diff', title: 'Δ%', diffKey: 'value' },
51
+ { key: 'score', title: 'Score', formatter: floatPrecision(1) },
52
+ { key: 'score_diff', title: 'Δ%', diffKey: 'score' },
53
+ ],
54
+ },
55
+ ];
56
+
57
+ const table = buildTable(tableConfig, data, baselineData);
58
+ console.log(table);
59
+ ```
60
+
61
+ For a more complex example, see `BenchmarkReport.ts`, specifically the `mostlyFullRow` and `tableConfig` variables.
62
+
63
+ ## `Formatters.ts`
64
+
65
+ This file contains various utility functions for formatting numbers and strings, such as:
66
+
67
+ * `float`, `integer`: Format numbers to a specific precision.
68
+ * `percent`: Format a number as a percentage.
69
+ * `diffPercent`, `diffPercentNegative`: Format the percentage difference between two numbers, with color-coding for positive/negative changes.
70
+ * `bytes`, `duration`, `rate`: Format numbers with appropriate units.
@@ -0,0 +1,293 @@
1
+ import pico from "picocolors";
2
+ import type { Alignment, SpanningCellConfig, TableUserConfig } from "table";
3
+ import { table } from "table";
4
+ import { diffPercent } from "./Formatters.ts";
5
+
6
+ const isTest = process.env.NODE_ENV === "test" || process.env.VITEST === "true";
7
+ const { bold } = isTest ? { bold: (str: string) => str } : pico;
8
+
9
+ /** Related table columns */
10
+ export interface ColumnGroup<T> {
11
+ groupTitle?: string;
12
+ columns: AnyColumn<T>[];
13
+ }
14
+
15
+ export type AnyColumn<T> = Column<T> | DiffColumn<T>;
16
+
17
+ /** Column with optional formatter */
18
+ export interface Column<T> extends ColumnFormat<T> {
19
+ formatter?: (value: unknown) => string | null;
20
+ diffKey?: undefined;
21
+ }
22
+
23
+ /** Comparison column against baseline */
24
+ interface DiffColumn<T> extends ColumnFormat<T> {
25
+ diffFormatter?: (value: unknown, baseline: unknown) => string | null;
26
+ formatter?: undefined;
27
+
28
+ /** Key for comparison value against baseline */
29
+ diffKey: keyof T;
30
+ }
31
+
32
+ interface ColumnFormat<T> {
33
+ key: keyof T;
34
+ title: string;
35
+
36
+ alignment?: Alignment;
37
+
38
+ width?: number;
39
+ }
40
+
41
+ /** Table headers and configuration */
42
+ export interface TableSetup {
43
+ headerRows: string[][];
44
+ config: TableUserConfig;
45
+ }
46
+
47
+ /** Data rows with optional baseline */
48
+ export interface ResultGroup<T extends Record<string, any>> {
49
+ results: T[];
50
+
51
+ baseline?: T;
52
+ }
53
+
54
+ /** Build formatted table with column groups and baselines */
55
+ export function buildTable<T extends Record<string, any>>(
56
+ columnGroups: ColumnGroup<T>[],
57
+ resultGroups: ResultGroup<T>[],
58
+ nameKey: keyof T = "name" as keyof T,
59
+ ): string {
60
+ const allRecords = flattenGroups(columnGroups, resultGroups, nameKey);
61
+ return createTable(columnGroups, allRecords);
62
+ }
63
+
64
+ /** Convert columns and records to formatted table */
65
+ function createTable<T extends Record<string, any>>(
66
+ groups: ColumnGroup<T>[],
67
+ records: T[],
68
+ ): string {
69
+ const dataRows = toRows(records, groups);
70
+ const { headerRows, config } = setup(groups, dataRows);
71
+ const allRows = [...headerRows, ...dataRows];
72
+ return table(allRows, config);
73
+ }
74
+
75
+ /** Create header rows with group titles */
76
+ function createGroupHeaders<T>(
77
+ groups: ColumnGroup<T>[],
78
+ numColumns: number,
79
+ ): string[][] {
80
+ if (!groups.some(g => g.groupTitle)) return [];
81
+
82
+ const sectionRow = groups.flatMap(g => {
83
+ const title = g.groupTitle ? [bold(g.groupTitle)] : [];
84
+ return padWithBlanks(title, g.columns.length);
85
+ });
86
+ const blankRow = padWithBlanks([], numColumns);
87
+ return [sectionRow, blankRow];
88
+ }
89
+
90
+ interface Lines {
91
+ drawHorizontalLine: (index: number, size: number) => boolean;
92
+ drawVerticalLine: (index: number, size: number) => boolean;
93
+ }
94
+
95
+ /** @return draw functions for horizontal/vertical table borders */
96
+ function createLines<T>(groups: ColumnGroup<T>[]): Lines {
97
+ const { sectionBorders, headerBottom } = calcBorders(groups);
98
+
99
+ function drawVerticalLine(index: number, size: number): boolean {
100
+ return index === 0 || index === size || sectionBorders.includes(index);
101
+ }
102
+ function drawHorizontalLine(index: number, size: number): boolean {
103
+ return index === 0 || index === size || index === headerBottom;
104
+ }
105
+ return { drawHorizontalLine, drawVerticalLine };
106
+ }
107
+
108
+ /** @return spanning cell configs for group title headers */
109
+ function createSectionSpans<T>(groups: ColumnGroup<T>[]): SpanningCellConfig[] {
110
+ let col = 0;
111
+ const alignment: Alignment = "center";
112
+ return groups.map(g => {
113
+ const colSpan = g.columns.length;
114
+ const span = { row: 0, col, colSpan, alignment };
115
+ col += colSpan;
116
+ return span;
117
+ });
118
+ }
119
+
120
+ /** @return bolded column title strings */
121
+ function getTitles<T>(groups: ColumnGroup<T>[]): string[] {
122
+ return groups.flatMap(g => g.columns.map(c => bold(c.title || " ")));
123
+ }
124
+
125
+ /** @return array padded with blank strings to the given length */
126
+ function padWithBlanks(arr: string[], length: number): string[] {
127
+ if (arr.length >= length) return arr;
128
+ return [...arr, ...Array(length - arr.length).fill(" ")];
129
+ }
130
+
131
+ /** Convert records to string arrays for table */
132
+ export function toRows<T extends Record<string, any>>(
133
+ records: T[],
134
+ groups: ColumnGroup<T>[],
135
+ ): string[][] {
136
+ const allColumns = groups.flatMap(group => group.columns);
137
+
138
+ const rawRows = records.map(record =>
139
+ allColumns.map(col => {
140
+ const value = record[col.key];
141
+ return col.formatter ? col.formatter(value) : value;
142
+ }),
143
+ );
144
+
145
+ return rawRows.map(row => row.map(cell => cell ?? " "));
146
+ }
147
+
148
+ /** Add comparison values for diff columns */
149
+ function addComparisons<T extends Record<string, any>>(
150
+ groups: ColumnGroup<T>[],
151
+ mainRecord: T,
152
+ baselineRecord: T,
153
+ ): T {
154
+ const diffColumns = groups.flatMap(g => g.columns).filter(col => col.diffKey);
155
+ const updatedMain = { ...mainRecord };
156
+
157
+ for (const col of diffColumns) {
158
+ const dcol = col as DiffColumn<T>;
159
+ const diffKey = dcol.diffKey;
160
+ const mainValue = mainRecord[diffKey];
161
+ const baselineValue = baselineRecord[diffKey];
162
+ const diffFormat = dcol.diffFormatter ?? diffPercent;
163
+ const diffStr = diffFormat(mainValue, baselineValue);
164
+ (updatedMain as any)[col.key] = diffStr;
165
+ }
166
+
167
+ return updatedMain;
168
+ }
169
+
170
+ /** Flatten groups with spacing */
171
+ function flattenGroups<T extends Record<string, any>>(
172
+ columnGroups: ColumnGroup<T>[],
173
+ resultGroups: ResultGroup<T>[],
174
+ nameKey: keyof T,
175
+ ): T[] {
176
+ return resultGroups.flatMap((group, i) => {
177
+ const groupRecords = addBaseline(columnGroups, group, nameKey);
178
+
179
+ const isLast = i === resultGroups.length - 1;
180
+ return isLast ? groupRecords : [...groupRecords, {} as T];
181
+ });
182
+ }
183
+
184
+ /** Process results with baseline comparisons */
185
+ function addBaseline<T extends Record<string, any>>(
186
+ columnGroups: ColumnGroup<T>[],
187
+ group: ResultGroup<T>,
188
+ nameKey: keyof T,
189
+ ): T[] {
190
+ const { results, baseline } = group;
191
+
192
+ if (!baseline) return results;
193
+
194
+ const diffResults = results.map(result =>
195
+ addComparisons(columnGroups, result, baseline),
196
+ );
197
+
198
+ const markedBaseline = {
199
+ ...baseline,
200
+ [nameKey]: `--> ${baseline[nameKey]}`,
201
+ };
202
+
203
+ return [...diffResults, markedBaseline];
204
+ }
205
+
206
+ /** Calculate vertical lines between sections and header bottom position */
207
+ function calcBorders<T>(groups: ColumnGroup<T>[]): {
208
+ sectionBorders: number[];
209
+ headerBottom: number;
210
+ } {
211
+ if (groups.length === 0) return { sectionBorders: [], headerBottom: 1 };
212
+
213
+ const sectionBorders: number[] = [];
214
+ let border = 0;
215
+ for (const g of groups) {
216
+ border += g.columns.length;
217
+ sectionBorders.push(border);
218
+ }
219
+ return { sectionBorders, headerBottom: 3 };
220
+ }
221
+
222
+ /** Create headers and table configuration */
223
+ function setup<T>(groups: ColumnGroup<T>[], dataRows: string[][]): TableSetup {
224
+ const titles = getTitles(groups);
225
+ const numColumns = titles.length;
226
+
227
+ const sectionRows = createGroupHeaders(groups, numColumns);
228
+ const headerRows = [...sectionRows, titles];
229
+ const spanningCells = createSectionSpans(groups);
230
+ const columnWidths = calcColumnWidths(groups, titles, dataRows);
231
+ const config: TableUserConfig = {
232
+ spanningCells,
233
+ columns: columnWidths,
234
+ ...createLines(groups),
235
+ };
236
+
237
+ return { headerRows, config };
238
+ }
239
+
240
+ /** Calculate column widths based on content, including group titles */
241
+ function calcColumnWidths<T>(
242
+ groups: ColumnGroup<T>[],
243
+ titles: string[],
244
+ dataRows: unknown[][],
245
+ ): Record<number, { width: number; wrapWord: boolean }> {
246
+ // First pass: calculate base widths from titles and data
247
+ const widths: number[] = [];
248
+ for (let i = 0; i < titles.length; i++) {
249
+ const titleW = cellWidth(titles[i]);
250
+ const maxDataW = dataRows.reduce(
251
+ (max, row) => Math.max(max, cellWidth(row[i])),
252
+ 0,
253
+ );
254
+ widths.push(Math.max(titleW, maxDataW));
255
+ }
256
+
257
+ // Second pass: ensure group titles fit (accounting for column separators)
258
+ let colIndex = 0;
259
+ for (const group of groups) {
260
+ const groupW = cellWidth(group.groupTitle);
261
+ if (groupW > 0) {
262
+ const numCols = group.columns.length;
263
+ const separatorWidth = (numCols - 1) * 3; // " | " between columns
264
+ const currentWidth = widths
265
+ .slice(colIndex, colIndex + numCols)
266
+ .reduce((a, b) => a + b, 0);
267
+ const needed = groupW - currentWidth - separatorWidth;
268
+ if (needed > 0) {
269
+ // Distribute extra width to last column in group
270
+ widths[colIndex + numCols - 1] += needed;
271
+ }
272
+ }
273
+ colIndex += group.columns.length;
274
+ }
275
+
276
+ // Convert to table config format
277
+ return Object.fromEntries(
278
+ widths.map((w, i) => [i, { width: w, wrapWord: false }]),
279
+ );
280
+ }
281
+
282
+ // Regex to strip ANSI escape codes (ESC [ ... m sequences)
283
+ const ansiEscapeRegex = new RegExp(
284
+ String.fromCharCode(27) + "\\[[0-9;]*m",
285
+ "g",
286
+ );
287
+
288
+ /** Get visible length of a cell value (strips ANSI escape codes) */
289
+ function cellWidth(value: unknown): number {
290
+ if (value == null) return 0;
291
+ const str = String(value);
292
+ return str.replace(ansiEscapeRegex, "").length;
293
+ }
@@ -0,0 +1,105 @@
1
+ import { expect, test } from "vitest";
2
+ import { integer } from "../Formatters.ts";
3
+ import {
4
+ buildTable,
5
+ type ColumnGroup,
6
+ type ResultGroup,
7
+ } from "../TableReport.ts";
8
+
9
+ interface TestRecord {
10
+ name: string;
11
+ max: number;
12
+ maxDelta?: string;
13
+ p50: number;
14
+ p50Delta?: string;
15
+ kb: number;
16
+ l1miss: string;
17
+ n: number;
18
+ }
19
+
20
+ const columnGroups: ColumnGroup<TestRecord>[] = [
21
+ { columns: [{ key: "name", title: "name", alignment: "left" }] },
22
+ {
23
+ groupTitle: "lines / sec",
24
+ columns: [
25
+ { key: "max", title: "max", alignment: "right", formatter: integer },
26
+ { key: "maxDelta", title: "Δ%", alignment: "right", diffKey: "max" },
27
+ { key: "p50", title: "p50", alignment: "right", formatter: integer },
28
+ { key: "p50Delta", title: "Δ%", alignment: "right", diffKey: "p50" },
29
+ ],
30
+ },
31
+ {
32
+ columns: [
33
+ { key: "kb", title: "kb", alignment: "right", formatter: integer },
34
+ { key: "l1miss", title: "L1 miss", alignment: "right" },
35
+ { key: "n", title: "N", alignment: "right" },
36
+ ],
37
+ },
38
+ ];
39
+
40
+ const mainData: TestRecord[] = [
41
+ {
42
+ name: "reduceBuffer",
43
+ max: 77045,
44
+ p50: 74351,
45
+ kb: 2044,
46
+ l1miss: "1.6%",
47
+ n: 545,
48
+ },
49
+ {
50
+ name: "unity_webgpu",
51
+ max: 33448,
52
+ p50: 31819,
53
+ kb: 67895,
54
+ l1miss: "2.1%",
55
+ n: 12,
56
+ },
57
+ ];
58
+
59
+ const baselineData: TestRecord[] = [
60
+ {
61
+ name: "reduceBuffer",
62
+ max: 77463,
63
+ p50: 75044,
64
+ kb: 2026,
65
+ l1miss: "1.6%",
66
+ n: 556,
67
+ },
68
+ {
69
+ name: "unity_webgpu",
70
+ max: 33925,
71
+ p50: 33107,
72
+ kb: 69808,
73
+ l1miss: "2.0%",
74
+ n: 12,
75
+ },
76
+ ];
77
+
78
+ const expectedTable = `
79
+ ╔══════════════════╤══════════════════════════════╤══════════════════════╗
80
+ ║ │ lines / sec │ ║
81
+ ║ │ │ ║
82
+ ║ name │ max Δ% p50 Δ% │ kb L1 miss N ║
83
+ ╟──────────────────┼──────────────────────────────┼──────────────────────╢
84
+ ║ reduceBuffer │ 77,045 -0.5% 74,351 -0.9% │ 2,044 1.6% 545 ║
85
+ ║ --> reduceBuffer │ 77,463 75,044 │ 2,026 1.6% 556 ║
86
+ ║ │ │ ║
87
+ ║ unity_webgpu │ 33,448 -1.4% 31,819 -3.9% │ 67,895 2.1% 12 ║
88
+ ║ --> unity_webgpu │ 33,925 33,107 │ 69,808 2.0% 12 ║
89
+ ╚══════════════════╧══════════════════════════════╧══════════════════════╝
90
+ `;
91
+
92
+ test("buildTable creates table with name, lines/sec, and kb/L1/N sections", () => {
93
+ const resultGroups: ResultGroup<TestRecord>[] = mainData.map((main, i) => ({
94
+ results: [main],
95
+ baseline: baselineData[i],
96
+ }));
97
+ const result = buildTable(columnGroups, resultGroups);
98
+ const trim = (s: string) =>
99
+ s
100
+ .split("\n")
101
+ .map(l => l.trim())
102
+ .filter(Boolean)
103
+ .join("\n");
104
+ expect(trim(result)).toBe(trim(expectedTable));
105
+ });
@@ -0,0 +1,41 @@
1
+ import { expect, test } from "vitest";
2
+
3
+ import { extractValue } from "./TableValueExtractor.ts";
4
+
5
+ test("extract mean values from table with two column groups", () => {
6
+ const table = `
7
+ ╔═════════════════╤══════════════════════════╤═══════════════════════╗
8
+ ║ │ time │ gc ║
9
+ ║ │ │ ║
10
+ ║ name │ mean p50 p99 │ mean max ║
11
+ ╟─────────────────┼──────────────────────────┼───────────────────────╢
12
+ ║ test1 │ 12.3 1.45 2.1 │ 8.7 89.1 ║
13
+ ║ test2 │ 23.4 14.5 20.0 │ 15.6 102.5 ║
14
+ ╚═════════════════╧══════════════════════════╧═══════════════════════╝
15
+ `;
16
+
17
+ expect(extractValue(table, "test1", "mean", "time")).toBe(12.3);
18
+ expect(extractValue(table, "test2", "mean", "time")).toBe(23.4);
19
+
20
+ expect(extractValue(table, "test1", "mean", "gc")).toBe(8.7);
21
+ expect(extractValue(table, "test2", "mean", "gc")).toBe(15.6);
22
+
23
+ expect(extractValue(table, "test1", "p99", "time")).toBe(2.1);
24
+ expect(extractValue(table, "test1", "max", "gc")).toBe(89.1);
25
+ });
26
+
27
+ test("extract values from table with column title containing space", () => {
28
+ const table = `
29
+ ╔══════════════════╤══════════════════════════════╤══════════════════════╗
30
+ ║ │ lines / sec │ ║
31
+ ║ │ │ ║
32
+ ║ name │ max Δ% p50 Δ% │ kb L1 miss N ║
33
+ ╟──────────────────┼──────────────────────────────┼──────────────────────╢
34
+ ║ reduceBuffer │ 77,045 -0.5% 74,351 -0.9% │ 2,044 1.6% 545 ║
35
+ ║ unity_webgpu │ 33,448 -1.4% 31,819 -3.9% │ 67,895 2.1% 12 ║
36
+ ╚══════════════════╧══════════════════════════════╧══════════════════════╝
37
+ `;
38
+
39
+ expect(extractValue(table, "reduceBuffer", "L1 miss")).toBe(1.6);
40
+ expect(extractValue(table, "unity_webgpu", "L1 miss")).toBe(2.1);
41
+ });