@hautajoki/hexagon 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.
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/package.json +45 -0
- package/runtime/cloud_runner.luau +139 -0
- package/runtime/encode_json.luau +57 -0
- package/runtime/expect.luau +125 -0
- package/runtime/hexagon.luau +569 -0
- package/runtime/local_runner.luau +51 -0
- package/runtime/quote.luau +20 -0
- package/src/bundle.ts +128 -0
- package/src/cli.ts +441 -0
- package/src/discovery.ts +32 -0
- package/src/protocol.ts +189 -0
- package/src/reporter.ts +105 -0
- package/types.d.ts +51 -0
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
export const REPORT_SCHEMA_VERSION = 1 as const;
|
|
2
|
+
|
|
3
|
+
export interface FailureReport {
|
|
4
|
+
readonly kind: 'test' | 'bench';
|
|
5
|
+
readonly labels: string[];
|
|
6
|
+
readonly message: string;
|
|
7
|
+
readonly source: string;
|
|
8
|
+
readonly line: number;
|
|
9
|
+
readonly trace: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TestReport {
|
|
13
|
+
readonly labels: string[];
|
|
14
|
+
readonly duration_seconds: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SkippedReport {
|
|
18
|
+
readonly kind: 'test' | 'bench';
|
|
19
|
+
readonly labels: string[];
|
|
20
|
+
readonly reason: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface BenchReport {
|
|
24
|
+
readonly labels: string[];
|
|
25
|
+
readonly iterations: number;
|
|
26
|
+
readonly samples: number;
|
|
27
|
+
readonly calibration_iterations: number;
|
|
28
|
+
readonly total_seconds: number;
|
|
29
|
+
readonly mean_seconds: number;
|
|
30
|
+
readonly median_seconds: number;
|
|
31
|
+
readonly p95_seconds: number;
|
|
32
|
+
readonly min_seconds: number;
|
|
33
|
+
readonly max_seconds: number;
|
|
34
|
+
readonly margin_percent: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface RunReport {
|
|
38
|
+
readonly schema_version: 1;
|
|
39
|
+
readonly ok: boolean;
|
|
40
|
+
readonly mode: string;
|
|
41
|
+
readonly filter?: string;
|
|
42
|
+
readonly focused: boolean;
|
|
43
|
+
readonly duration_seconds: number;
|
|
44
|
+
readonly discovered: number;
|
|
45
|
+
readonly selected: number;
|
|
46
|
+
readonly test_total: number;
|
|
47
|
+
readonly test_pass: number;
|
|
48
|
+
readonly test_fail: number;
|
|
49
|
+
readonly test_skip: number;
|
|
50
|
+
readonly bench_total: number;
|
|
51
|
+
readonly bench_pass: number;
|
|
52
|
+
readonly bench_fail: number;
|
|
53
|
+
readonly bench_skip: number;
|
|
54
|
+
readonly failures: FailureReport[];
|
|
55
|
+
readonly skipped: SkippedReport[];
|
|
56
|
+
readonly tests: TestReport[];
|
|
57
|
+
readonly benchmarks: BenchReport[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface TesseraRunResult {
|
|
61
|
+
readonly environment: string;
|
|
62
|
+
readonly place: string;
|
|
63
|
+
readonly placeId: number;
|
|
64
|
+
readonly version?: number;
|
|
65
|
+
readonly taskPath: string;
|
|
66
|
+
readonly state: string;
|
|
67
|
+
readonly results?: unknown[];
|
|
68
|
+
readonly logs?: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function object(value: unknown, label: string): Record<string, unknown> {
|
|
72
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
73
|
+
throw new TypeError(`${label} must be an object`);
|
|
74
|
+
}
|
|
75
|
+
return value as Record<string, unknown>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function number(value: unknown, label: string): number {
|
|
79
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
80
|
+
throw new TypeError(`${label} must be a finite number`);
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function array(value: unknown, label: string): unknown[] {
|
|
86
|
+
if (!Array.isArray(value)) throw new TypeError(`${label} must be an array`);
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function parseRunReport(payload: string): RunReport {
|
|
91
|
+
const value = object(JSON.parse(payload), 'Hexagon report');
|
|
92
|
+
if (value.schema_version !== REPORT_SCHEMA_VERSION) {
|
|
93
|
+
throw new TypeError(`unsupported Hexagon report schema ${String(value.schema_version)}`);
|
|
94
|
+
}
|
|
95
|
+
if (typeof value.ok !== 'boolean' || typeof value.focused !== 'boolean') {
|
|
96
|
+
throw new TypeError('Hexagon report status fields are invalid');
|
|
97
|
+
}
|
|
98
|
+
for (const key of [
|
|
99
|
+
'duration_seconds',
|
|
100
|
+
'discovered',
|
|
101
|
+
'selected',
|
|
102
|
+
'test_total',
|
|
103
|
+
'test_pass',
|
|
104
|
+
'test_fail',
|
|
105
|
+
'test_skip',
|
|
106
|
+
'bench_total',
|
|
107
|
+
'bench_pass',
|
|
108
|
+
'bench_fail',
|
|
109
|
+
'bench_skip',
|
|
110
|
+
] as const) {
|
|
111
|
+
number(value[key], `Hexagon report ${key}`);
|
|
112
|
+
}
|
|
113
|
+
for (const key of ['failures', 'skipped', 'tests', 'benchmarks'] as const) {
|
|
114
|
+
array(value[key], `Hexagon report ${key}`);
|
|
115
|
+
}
|
|
116
|
+
return value as unknown as RunReport;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function parseTesseraRun(payload: string): TesseraRunResult {
|
|
120
|
+
const value = object(JSON.parse(payload), 'Tessera run result');
|
|
121
|
+
if (
|
|
122
|
+
typeof value.environment !== 'string' ||
|
|
123
|
+
typeof value.place !== 'string' ||
|
|
124
|
+
typeof value.taskPath !== 'string' ||
|
|
125
|
+
typeof value.state !== 'string'
|
|
126
|
+
) {
|
|
127
|
+
throw new TypeError('Tessera run result metadata is invalid');
|
|
128
|
+
}
|
|
129
|
+
number(value.placeId, 'Tessera run result placeId');
|
|
130
|
+
if (value.version !== undefined) number(value.version, 'Tessera run result version');
|
|
131
|
+
if (value.results !== undefined) array(value.results, 'Tessera run result results');
|
|
132
|
+
if (value.logs !== undefined) array(value.logs, 'Tessera run result logs');
|
|
133
|
+
return value as unknown as TesseraRunResult;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function returnedString(result: TesseraRunResult, label: string): string {
|
|
137
|
+
const values = result.results ?? [];
|
|
138
|
+
if (values.length !== 1 || typeof values[0] !== 'string') {
|
|
139
|
+
throw new TypeError(`${label} must return exactly one JSON string`);
|
|
140
|
+
}
|
|
141
|
+
return values[0];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function aggregateReports(mode: 'test' | 'bench', filter: string | undefined, reports: readonly RunReport[]): RunReport {
|
|
145
|
+
const aggregate: {
|
|
146
|
+
-readonly [Key in keyof RunReport]: RunReport[Key];
|
|
147
|
+
} = {
|
|
148
|
+
schema_version: REPORT_SCHEMA_VERSION,
|
|
149
|
+
ok: true,
|
|
150
|
+
mode,
|
|
151
|
+
...(filter === undefined ? {} : { filter }),
|
|
152
|
+
focused: false,
|
|
153
|
+
duration_seconds: 0,
|
|
154
|
+
discovered: 0,
|
|
155
|
+
selected: 0,
|
|
156
|
+
test_total: 0,
|
|
157
|
+
test_pass: 0,
|
|
158
|
+
test_fail: 0,
|
|
159
|
+
test_skip: 0,
|
|
160
|
+
bench_total: 0,
|
|
161
|
+
bench_pass: 0,
|
|
162
|
+
bench_fail: 0,
|
|
163
|
+
bench_skip: 0,
|
|
164
|
+
failures: [],
|
|
165
|
+
skipped: [],
|
|
166
|
+
tests: [],
|
|
167
|
+
benchmarks: [],
|
|
168
|
+
};
|
|
169
|
+
for (const report of reports) {
|
|
170
|
+
aggregate.ok &&= report.ok;
|
|
171
|
+
aggregate.focused ||= report.focused;
|
|
172
|
+
aggregate.duration_seconds += report.duration_seconds;
|
|
173
|
+
aggregate.discovered += report.discovered;
|
|
174
|
+
aggregate.selected += report.selected;
|
|
175
|
+
aggregate.test_total += report.test_total;
|
|
176
|
+
aggregate.test_pass += report.test_pass;
|
|
177
|
+
aggregate.test_fail += report.test_fail;
|
|
178
|
+
aggregate.test_skip += report.test_skip;
|
|
179
|
+
aggregate.bench_total += report.bench_total;
|
|
180
|
+
aggregate.bench_pass += report.bench_pass;
|
|
181
|
+
aggregate.bench_fail += report.bench_fail;
|
|
182
|
+
aggregate.bench_skip += report.bench_skip;
|
|
183
|
+
aggregate.failures.push(...report.failures);
|
|
184
|
+
aggregate.skipped.push(...report.skipped);
|
|
185
|
+
aggregate.tests.push(...report.tests);
|
|
186
|
+
aggregate.benchmarks.push(...report.benchmarks);
|
|
187
|
+
}
|
|
188
|
+
return aggregate;
|
|
189
|
+
}
|
package/src/reporter.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { BenchReport, FailureReport, RunReport, SkippedReport, TestReport } from './protocol.ts';
|
|
2
|
+
|
|
3
|
+
export interface ReporterOptions {
|
|
4
|
+
readonly color: boolean;
|
|
5
|
+
readonly write?: (text: string) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function painter(enabled: boolean) {
|
|
9
|
+
return (code: string, text: string): string => (enabled ? `\u001b[${code}m${text}\u001b[0m` : text);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function labelPath(labels: readonly string[], dim: (text: string) => string) {
|
|
13
|
+
return labels.join(dim(' › '));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function duration(seconds: number): string {
|
|
17
|
+
if (seconds >= 1) return `${seconds.toFixed(2).padStart(7)} s `;
|
|
18
|
+
if (seconds >= 1e-3) return `${(seconds * 1e3).toFixed(2).padStart(7)} ms`;
|
|
19
|
+
if (seconds >= 1e-6) return `${(seconds * 1e6).toFixed(2).padStart(7)} µs`;
|
|
20
|
+
return `${(seconds * 1e9).toFixed(2).padStart(7)} ns`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function reportFailures(lines: string[], failures: readonly FailureReport[], paint: ReturnType<typeof painter>) {
|
|
24
|
+
if (failures.length === 0) return;
|
|
25
|
+
lines.push('', paint('1;31', `Failures (${failures.length})`));
|
|
26
|
+
for (const failure of failures) {
|
|
27
|
+
lines.push(
|
|
28
|
+
` ${paint('31', '✗')} ${labelPath(failure.labels, (value) => paint('2', value))} ${paint('2', `[${failure.kind}]`)}`,
|
|
29
|
+
` ${failure.message}`,
|
|
30
|
+
paint('2', ` ${failure.source}:${failure.line}`),
|
|
31
|
+
);
|
|
32
|
+
if (failure.trace !== '') lines.push(paint('2', failure.trace));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function reportSkipped(lines: string[], skipped: readonly SkippedReport[], paint: ReturnType<typeof painter>) {
|
|
37
|
+
if (skipped.length === 0) return;
|
|
38
|
+
lines.push('', paint('1;33', `Skipped (${skipped.length})`));
|
|
39
|
+
for (const item of skipped) {
|
|
40
|
+
lines.push(` ${paint('33', '○')} ${labelPath(item.labels, (value) => paint('2', value))} ${paint('2', `— ${item.reason}`)}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function reportSlowest(lines: string[], tests: readonly TestReport[], paint: ReturnType<typeof painter>) {
|
|
45
|
+
const slowest = [...tests].sort((left, right) => right.duration_seconds - left.duration_seconds).slice(0, 5);
|
|
46
|
+
if (slowest.length === 0 || slowest[0]!.duration_seconds < 0.01) return;
|
|
47
|
+
lines.push('', paint('1', `Slowest tests (${slowest.length})`));
|
|
48
|
+
for (const test of slowest) {
|
|
49
|
+
lines.push(` ${paint('33', duration(test.duration_seconds))} ${labelPath(test.labels, (value) => paint('2', value))}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function reportBenchmarks(lines: string[], benchmarks: readonly BenchReport[], paint: ReturnType<typeof painter>) {
|
|
54
|
+
if (benchmarks.length === 0) return;
|
|
55
|
+
lines.push('', paint('1;36', `Benchmarks (${benchmarks.length})`));
|
|
56
|
+
for (const benchmark of [...benchmarks].sort((left, right) => left.labels.join('\0').localeCompare(right.labels.join('\0')))) {
|
|
57
|
+
const rate = benchmark.mean_seconds > 0 ? `${(1 / benchmark.mean_seconds).toFixed(2)} ops/s` : '—';
|
|
58
|
+
lines.push(
|
|
59
|
+
` ${labelPath(benchmark.labels, (value) => paint('2', value))}`,
|
|
60
|
+
` ${paint('36', duration(benchmark.mean_seconds))} mean · ${duration(benchmark.p95_seconds)} p95 · ${rate} · ±${benchmark.margin_percent.toFixed(1)}% · ${benchmark.iterations}×${benchmark.samples}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function tally(label: string, pass: number, fail: number, skip: number, total: number, paint: ReturnType<typeof painter>): string {
|
|
66
|
+
const parts: string[] = [];
|
|
67
|
+
if (fail > 0) parts.push(paint('1;31', `${fail} failed`));
|
|
68
|
+
if (pass > 0) parts.push(paint('32', `${pass} passed`));
|
|
69
|
+
if (skip > 0) parts.push(paint('33', `${skip} skipped`));
|
|
70
|
+
parts.push(paint('2', `${total} total`));
|
|
71
|
+
return `${label}: ${parts.join(paint('2', ' · '))}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function formatReport(report: RunReport, options: ReporterOptions): string {
|
|
75
|
+
const paint = painter(options.color);
|
|
76
|
+
const lines: string[] = [];
|
|
77
|
+
reportFailures(lines, report.failures, paint);
|
|
78
|
+
reportSkipped(lines, report.skipped, paint);
|
|
79
|
+
reportSlowest(lines, report.tests, paint);
|
|
80
|
+
reportBenchmarks(lines, report.benchmarks, paint);
|
|
81
|
+
lines.push('');
|
|
82
|
+
if (report.selected === 0) {
|
|
83
|
+
lines.push(paint('33', `no cases matched${report.filter ? ` “${report.filter}”` : ''} (${report.discovered} discovered)`));
|
|
84
|
+
return `${lines.join('\n')}\n`;
|
|
85
|
+
}
|
|
86
|
+
const totals: string[] = [];
|
|
87
|
+
if (report.test_total > 0) {
|
|
88
|
+
totals.push(tally('tests', report.test_pass, report.test_fail, report.test_skip, report.test_total, paint));
|
|
89
|
+
}
|
|
90
|
+
if (report.bench_total > 0) {
|
|
91
|
+
totals.push(tally('benches', report.bench_pass, report.bench_fail, report.bench_skip, report.bench_total, paint));
|
|
92
|
+
}
|
|
93
|
+
lines.push(
|
|
94
|
+
`${report.ok ? paint('32', '✓') : paint('31', '✗')} ${totals.join(paint('2', ' | '))}`,
|
|
95
|
+
paint(
|
|
96
|
+
report.focused ? '33' : '2',
|
|
97
|
+
` ${report.selected}/${report.discovered} selected${report.focused ? ' · focused run' : ''} · ${duration(report.duration_seconds)}`,
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
return `${lines.join('\n')}\n`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function printReport(report: RunReport, options: ReporterOptions): void {
|
|
104
|
+
(options.write ?? process.stdout.write.bind(process.stdout))(formatReport(report, options));
|
|
105
|
+
}
|
package/types.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Options shared by tests and benchmarks. */
|
|
2
|
+
export interface TestOptions {
|
|
3
|
+
only?: boolean;
|
|
4
|
+
skip?: boolean | string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface BenchOptions extends TestOptions {
|
|
8
|
+
/** Total measured invocations, distributed across samples. Omit for calibration. */
|
|
9
|
+
iterations?: number;
|
|
10
|
+
/** Independent timing samples. Defaults to 12. */
|
|
11
|
+
samples?: number;
|
|
12
|
+
/** Reset or prepare state immediately before each calibration or timing sample. */
|
|
13
|
+
beforeSample?: () => void;
|
|
14
|
+
/** Clean up after every attempted sample, including failed setup or timing. */
|
|
15
|
+
afterSample?: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Expectation {
|
|
19
|
+
readonly never: Expectation;
|
|
20
|
+
readonly toBe: (expected: unknown) => Expectation;
|
|
21
|
+
readonly toEqual: (expected: unknown) => Expectation;
|
|
22
|
+
readonly toBeNear: (expected: number, tolerance?: number) => Expectation;
|
|
23
|
+
readonly toBeTruthy: () => Expectation;
|
|
24
|
+
readonly toExist: () => Expectation;
|
|
25
|
+
readonly toHaveType: (expected: string) => Expectation;
|
|
26
|
+
readonly toHaveKey: (expected: unknown) => Expectation;
|
|
27
|
+
readonly toContain: (expected: unknown) => Expectation;
|
|
28
|
+
readonly toMatch: (pattern: string) => Expectation;
|
|
29
|
+
readonly toThrow: (pattern?: string) => Expectation;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SuiteContext {
|
|
33
|
+
readonly describe: (label: string, body: () => void) => void;
|
|
34
|
+
readonly test: (label: string, run: () => void, options?: TestOptions) => void;
|
|
35
|
+
readonly testEach: <T>(
|
|
36
|
+
values: readonly T[],
|
|
37
|
+
label: string | ((value: T, index: number) => string),
|
|
38
|
+
run: (value: T, index: number) => void,
|
|
39
|
+
) => void;
|
|
40
|
+
readonly bench: (label: string, run: () => void, options?: BenchOptions) => void;
|
|
41
|
+
readonly todo: (label: string, reason?: string) => void;
|
|
42
|
+
readonly beforeEach: (hook: () => void) => void;
|
|
43
|
+
readonly afterEach: (hook: () => void) => void;
|
|
44
|
+
/** Register case-scoped cleanup. Every cleanup runs once in LIFO order, even after failures. */
|
|
45
|
+
readonly cleanup: (hook: () => void) => void;
|
|
46
|
+
readonly expect: (value: unknown) => Expectation;
|
|
47
|
+
readonly skip: (reason?: string) => never;
|
|
48
|
+
readonly fail: (message?: string) => never;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type Suite = (hexagon: SuiteContext) => void;
|