@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.
@@ -0,0 +1,569 @@
1
+ --!strict
2
+ --!optimize 2
3
+ -- Hexagon runtime. Keep this module dependency-free across native Luau and Roblox.
4
+
5
+ local expect = require("./expect")
6
+
7
+ export type TestOptions = {
8
+ only: boolean?,
9
+ skip: (boolean | string)?,
10
+ }
11
+
12
+ export type BenchOptions = {
13
+ only: boolean?,
14
+ skip: (boolean | string)?,
15
+ iterations: number?,
16
+ samples: number?,
17
+ beforeSample: (() -> ())?,
18
+ afterSample: (() -> ())?,
19
+ }
20
+
21
+ export type RunOptions = {
22
+ mode: ("test" | "bench" | "all")?,
23
+ filter: string?,
24
+ onCaseComplete: (() -> ())?,
25
+ }
26
+
27
+ type ErrorReport = {
28
+ type: string,
29
+ source: string,
30
+ line: number,
31
+ message: string,
32
+ trace: string,
33
+ }
34
+
35
+ type Hook = () -> ()
36
+
37
+ type Case = {
38
+ kind: "test" | "bench",
39
+ labels: { string },
40
+ run: () -> (),
41
+ before_each: { Hook },
42
+ after_each: { Hook },
43
+ only: boolean,
44
+ skip: (boolean | string)?,
45
+ bench: BenchOptions?,
46
+ }
47
+
48
+ type Context = {
49
+ labels: { string },
50
+ before_each: { Hook },
51
+ after_each: { Hook },
52
+ }
53
+
54
+ export type FailureReport = {
55
+ kind: "test" | "bench",
56
+ labels: { string },
57
+ message: string,
58
+ source: string,
59
+ line: number,
60
+ trace: string,
61
+ }
62
+
63
+ export type TestReport = {
64
+ labels: { string },
65
+ duration_seconds: number,
66
+ }
67
+
68
+ export type SkippedReport = {
69
+ kind: "test" | "bench",
70
+ labels: { string },
71
+ reason: string,
72
+ }
73
+
74
+ export type BenchReport = {
75
+ labels: { string },
76
+ iterations: number,
77
+ samples: number,
78
+ calibration_iterations: number,
79
+ total_seconds: number,
80
+ mean_seconds: number,
81
+ median_seconds: number,
82
+ p95_seconds: number,
83
+ min_seconds: number,
84
+ max_seconds: number,
85
+ margin_percent: number,
86
+ }
87
+
88
+ export type RunReport = {
89
+ schema_version: number,
90
+ ok: boolean,
91
+ mode: string,
92
+ filter: string?,
93
+ focused: boolean,
94
+ duration_seconds: number,
95
+ discovered: number,
96
+ selected: number,
97
+ test_total: number,
98
+ test_pass: number,
99
+ test_fail: number,
100
+ test_skip: number,
101
+ bench_total: number,
102
+ bench_pass: number,
103
+ bench_fail: number,
104
+ bench_skip: number,
105
+ failures: { FailureReport },
106
+ skipped: { SkippedReport },
107
+ tests: { TestReport },
108
+ benchmarks: { BenchReport },
109
+ }
110
+
111
+ export type Suite = {
112
+ describe: (label: string, inner: () -> ()) -> (),
113
+ test: (label: string, run: () -> (), options: TestOptions?) -> (),
114
+ testEach: (values: { any }, label: string | ((any, number) -> string), run: (any, number) -> ()) -> (),
115
+ bench: (label: string, run: () -> (), options: BenchOptions?) -> (),
116
+ todo: (label: string, reason: string?) -> (),
117
+ beforeEach: (hook: Hook) -> (),
118
+ afterEach: (hook: Hook) -> (),
119
+ cleanup: (hook: Hook) -> (),
120
+ expect: (unknown) -> expect.Expectation,
121
+ skip: (reason: string?) -> never,
122
+ fail: (message: string?) -> never,
123
+ }
124
+
125
+ export type Collection = {
126
+ cases: { Case },
127
+ begin_case: () -> (),
128
+ drain_cleanups: () -> { Hook },
129
+ }
130
+
131
+ local DEFAULT_SAMPLES = 12
132
+ local TARGET_SAMPLE_SECONDS = 0.005
133
+ local MAX_AUTO_ITERATIONS = 1_048_576
134
+
135
+ local function clone<T>(values: { T }): { T }
136
+ return table.clone(values)
137
+ end
138
+
139
+ local function capture_error(message: unknown): ErrorReport
140
+ if typeof(message) == "table" then
141
+ local tagged = message :: { type: unknown }
142
+ if tagged.type == "hexagon.error" then return message :: ErrorReport end
143
+ if tagged.type == "hexagon.skip" then return message :: ErrorReport end
144
+ end
145
+ local source, line = debug.info(2, "sl")
146
+ return {
147
+ type = "hexagon.error",
148
+ source = if type(source) == "string" then source else "unknown",
149
+ line = if type(line) == "number" then line else 0,
150
+ message = tostring(message),
151
+ trace = debug.traceback(nil, 2),
152
+ }
153
+ end
154
+
155
+ local function positive_integer(value: number?, name: string): number?
156
+ if value == nil then return nil end
157
+ if value <= 0 or value ~= math.floor(value) then error(`bench option {name} must be a positive integer`, 3) end
158
+ return value
159
+ end
160
+
161
+ local hexagon = {}
162
+
163
+ function hexagon.collect(load: (Suite) -> ()): Collection
164
+ local cases: { Case } = {}
165
+ local current: Context? = nil
166
+ local cleanups: { Hook }? = nil
167
+ local api = {} :: any
168
+
169
+ local function context(): Context
170
+ return assert(current, "Hexagon suite methods must be called while collecting a suite")
171
+ end
172
+
173
+ local function register(kind: "test" | "bench", label: string, run: () -> (), options: TestOptions?, bench: BenchOptions?)
174
+ if label == "" then error("Hexagon case labels cannot be empty", 3) end
175
+ local active = context()
176
+ local labels = clone(active.labels)
177
+ table.insert(labels, label)
178
+ table.insert(cases, {
179
+ kind = kind,
180
+ labels = labels,
181
+ run = run,
182
+ before_each = clone(active.before_each),
183
+ after_each = clone(active.after_each),
184
+ only = options ~= nil and options.only == true,
185
+ skip = if options then options.skip else nil,
186
+ bench = bench,
187
+ })
188
+ end
189
+
190
+ api.describe = function(label: string, inner: () -> ())
191
+ local outer = context()
192
+ local labels = clone(outer.labels)
193
+ table.insert(labels, label)
194
+ current = {
195
+ labels = labels,
196
+ before_each = clone(outer.before_each),
197
+ after_each = clone(outer.after_each),
198
+ }
199
+ local ok, message = xpcall(function(): ErrorReport?
200
+ inner()
201
+ return nil
202
+ end, capture_error)
203
+ current = outer
204
+ if not ok then error(message, 0) end
205
+ end
206
+
207
+ api.test = function(label: string, run: () -> (), options: TestOptions?)
208
+ register("test", label, run, options, nil)
209
+ end
210
+
211
+ api.testEach = function(values: { any }, label: string | ((any, number) -> string), run: (any, number) -> ())
212
+ for index, value in values do
213
+ local case_label = if type(label) == "function" then label(value, index) else `{label} [{index}]`
214
+ api.test(case_label, function()
215
+ run(value, index)
216
+ end)
217
+ end
218
+ end
219
+
220
+ api.bench = function(label: string, run: () -> (), options: BenchOptions?)
221
+ local declared: BenchOptions = options or ({} :: BenchOptions)
222
+ positive_integer(declared.iterations, "iterations")
223
+ positive_integer(declared.samples, "samples")
224
+ register("bench", label, run, declared, declared)
225
+ end
226
+
227
+ api.todo = function(label: string, reason: string?)
228
+ api.test(label, function() end, { skip = reason or "todo" })
229
+ end
230
+
231
+ api.beforeEach = function(hook: Hook)
232
+ table.insert(context().before_each, hook)
233
+ end
234
+
235
+ api.afterEach = function(hook: Hook)
236
+ table.insert(context().after_each, hook)
237
+ end
238
+
239
+ api.cleanup = function(hook: Hook)
240
+ local active = cleanups
241
+ if active == nil then error("t.cleanup() must be called while a case is running", 2) end
242
+ table.insert(active, hook)
243
+ end
244
+
245
+ api.expect = expect
246
+ api.skip = function(reason: string?): never
247
+ local source, line = debug.info(2, "sl")
248
+ error({
249
+ type = "hexagon.skip",
250
+ source = if type(source) == "string" then source else "unknown",
251
+ line = if type(line) == "number" then line else 0,
252
+ message = reason or "skipped",
253
+ trace = "",
254
+ }, 0)
255
+ end
256
+ api.fail = function(message: string?): never
257
+ error(message or "explicit failure", 2)
258
+ end
259
+
260
+ current = { labels = {}, before_each = {}, after_each = {} }
261
+ local ok, message = xpcall(function(): ErrorReport?
262
+ load(api :: Suite)
263
+ return nil
264
+ end, capture_error)
265
+ current = nil
266
+ if not ok then error(message, 0) end
267
+ return {
268
+ cases = cases,
269
+ begin_case = function()
270
+ if cleanups ~= nil then error("a Hexagon case is already running", 2) end
271
+ cleanups = {}
272
+ end,
273
+ drain_cleanups = function(): { Hook }
274
+ local active = cleanups
275
+ if active == nil then error("no Hexagon case is running", 2) end
276
+ cleanups = nil
277
+ return active
278
+ end,
279
+ }
280
+ end
281
+
282
+ local function percentile(sorted: { number }, fraction: number): number
283
+ local index = math.max(1, math.ceil(#sorted * fraction))
284
+ return sorted[index]
285
+ end
286
+
287
+ local function invoke_hook(hook: Hook): ErrorReport?
288
+ local ok, message = xpcall(function(): ErrorReport?
289
+ hook()
290
+ return nil
291
+ end, capture_error)
292
+ return if ok then nil else assert(message)
293
+ end
294
+
295
+ local function invoke_cleanup_hooks(hooks: { Hook }): { ErrorReport }
296
+ local failures: { ErrorReport } = {}
297
+ for index = #hooks, 1, -1 do
298
+ local failure = invoke_hook(hooks[index])
299
+ if failure ~= nil then table.insert(failures, failure) end
300
+ end
301
+ return failures
302
+ end
303
+
304
+ local function append_cleanup_failures(primary: ErrorReport?, failures: { ErrorReport }): ErrorReport?
305
+ if #failures == 0 then return primary end
306
+ local result = primary or failures[1]
307
+ if primary == nil then table.remove(failures, 1) end
308
+ if result.type == "hexagon.skip" then
309
+ result = {
310
+ type = "hexagon.error",
311
+ source = result.source,
312
+ line = result.line,
313
+ message = `case skipped but cleanup failed: {result.message}`,
314
+ trace = result.trace,
315
+ }
316
+ end
317
+ for _, failure in failures do
318
+ result.message ..= `\ncleanup failed: {failure.message}`
319
+ if failure.trace ~= "" then
320
+ result.trace ..= `\n{failure.trace}`
321
+ end
322
+ end
323
+ return result
324
+ end
325
+
326
+ local function measure(benchmark: Case): BenchReport
327
+ local options = benchmark.bench :: BenchOptions
328
+ local samples = options.samples or DEFAULT_SAMPLES
329
+ local sample_iterations: { number } = {}
330
+ local calibration_iterations = 0
331
+ local measured_iterations = 0
332
+
333
+ local function before_sample()
334
+ if options.beforeSample ~= nil then options.beforeSample() end
335
+ end
336
+
337
+ local function after_sample()
338
+ if options.afterSample ~= nil then options.afterSample() end
339
+ end
340
+
341
+ local function run_sample(iterations: number): number
342
+ local failure = invoke_hook(before_sample)
343
+ local elapsed = 0
344
+ if failure == nil then
345
+ local started = os.clock()
346
+ local ok, result = xpcall(function(): ErrorReport?
347
+ for _ = 1, iterations do
348
+ benchmark.run()
349
+ end
350
+ return nil
351
+ end, capture_error)
352
+ elapsed = os.clock() - started
353
+ if not ok then failure = result end
354
+ end
355
+ local cleanup_failures: { ErrorReport } = {}
356
+ local cleanup_failure = invoke_hook(after_sample)
357
+ if cleanup_failure ~= nil then table.insert(cleanup_failures, cleanup_failure) end
358
+ failure = append_cleanup_failures(failure, cleanup_failures)
359
+ if failure ~= nil then error(failure, 0) end
360
+ return elapsed
361
+ end
362
+
363
+ if options.iterations ~= nil then
364
+ samples = math.min(samples, options.iterations)
365
+ local base = math.floor(options.iterations / samples)
366
+ local remainder = options.iterations % samples
367
+ for sample = 1, samples do
368
+ sample_iterations[sample] = base + if sample <= remainder then 1 else 0
369
+ end
370
+ measured_iterations = options.iterations
371
+ else
372
+ local iterations = 1
373
+ while true do
374
+ local elapsed = run_sample(iterations)
375
+ calibration_iterations += iterations
376
+ if elapsed >= TARGET_SAMPLE_SECONDS or iterations >= MAX_AUTO_ITERATIONS then break end
377
+ iterations = math.min(iterations * 2, MAX_AUTO_ITERATIONS)
378
+ end
379
+ for sample = 1, samples do
380
+ sample_iterations[sample] = iterations
381
+ end
382
+ measured_iterations = iterations * samples
383
+ end
384
+
385
+ local timings: { number } = table.create(samples, 0)
386
+ local total = 0
387
+ for sample = 1, samples do
388
+ local iterations = sample_iterations[sample]
389
+ local elapsed = run_sample(iterations)
390
+ total += elapsed
391
+ timings[sample] = elapsed / iterations
392
+ end
393
+
394
+ table.sort(timings)
395
+ local mean = total / measured_iterations
396
+ local variance = 0
397
+ for _, timing in timings do
398
+ variance += (timing - mean) ^ 2
399
+ end
400
+ if samples > 1 then
401
+ variance /= samples - 1
402
+ end
403
+ local margin = if mean > 0 then 1.96 * math.sqrt(variance / samples) / mean * 100 else 0
404
+
405
+ return {
406
+ labels = benchmark.labels,
407
+ iterations = measured_iterations,
408
+ samples = samples,
409
+ calibration_iterations = calibration_iterations,
410
+ total_seconds = total,
411
+ mean_seconds = mean,
412
+ median_seconds = percentile(timings, 0.5),
413
+ p95_seconds = percentile(timings, 0.95),
414
+ min_seconds = timings[1],
415
+ max_seconds = timings[#timings],
416
+ margin_percent = margin,
417
+ }
418
+ end
419
+
420
+ function hexagon.run(collection: Collection, options: RunOptions?): RunReport
421
+ local opts: RunOptions = options or ({} :: RunOptions)
422
+ local mode = opts.mode or "all"
423
+ local declared_filter = opts.filter
424
+ local filter: string? = if declared_filter ~= nil and declared_filter ~= "" then declared_filter:lower() else nil
425
+ local selected: { Case } = {}
426
+ for _, case in collection.cases do
427
+ -- Benchmark modules may contain correctness guards. The drivers already
428
+ -- select files by mode, so bench/all runs every case from those files.
429
+ local correct_mode = mode ~= "test" or case.kind == "test"
430
+ local correct_name = filter == nil or table.concat(case.labels, " / "):lower():find(filter, 1, true) ~= nil
431
+ if correct_mode and correct_name then table.insert(selected, case) end
432
+ end
433
+
434
+ local focused = false
435
+ for _, case in selected do
436
+ if case.only then
437
+ focused = true
438
+ break
439
+ end
440
+ end
441
+ if focused then
442
+ local only = {}
443
+ for _, case in selected do
444
+ if case.only then table.insert(only, case) end
445
+ end
446
+ selected = only
447
+ end
448
+ if mode ~= "test" then
449
+ local ordered: { Case } = {}
450
+ for _, case in selected do
451
+ if case.kind == "test" then table.insert(ordered, case) end
452
+ end
453
+ for _, case in selected do
454
+ if case.kind == "bench" then table.insert(ordered, case) end
455
+ end
456
+ selected = ordered
457
+ end
458
+
459
+ local report: RunReport = {
460
+ schema_version = 1,
461
+ ok = true,
462
+ mode = mode,
463
+ filter = opts.filter,
464
+ focused = focused,
465
+ duration_seconds = 0,
466
+ discovered = #collection.cases,
467
+ selected = #selected,
468
+ test_total = 0,
469
+ test_pass = 0,
470
+ test_fail = 0,
471
+ test_skip = 0,
472
+ bench_total = 0,
473
+ bench_pass = 0,
474
+ bench_fail = 0,
475
+ bench_skip = 0,
476
+ failures = {},
477
+ skipped = {},
478
+ tests = {},
479
+ benchmarks = {},
480
+ }
481
+ local run_started = os.clock()
482
+ local function complete_case()
483
+ if opts.onCaseComplete ~= nil then opts.onCaseComplete() end
484
+ end
485
+
486
+ for _, case in selected do
487
+ if case.kind == "test" then
488
+ report.test_total += 1
489
+ else
490
+ report.bench_total += 1
491
+ end
492
+ if case.skip then
493
+ if case.kind == "test" then
494
+ report.test_skip += 1
495
+ else
496
+ report.bench_skip += 1
497
+ end
498
+ table.insert(report.skipped, {
499
+ kind = case.kind,
500
+ labels = case.labels,
501
+ reason = if type(case.skip) == "string" then case.skip else "skipped",
502
+ })
503
+ complete_case()
504
+ continue
505
+ end
506
+
507
+ local started = os.clock()
508
+ collection.begin_case()
509
+ local failure: ErrorReport? = nil
510
+ for _, hook in case.before_each do
511
+ failure = invoke_hook(hook)
512
+ if failure ~= nil then break end
513
+ end
514
+ local outcome: BenchReport?
515
+ if failure == nil then
516
+ local ok, result = xpcall(function(): unknown
517
+ if case.kind == "bench" then return measure(case) end
518
+ case.run()
519
+ return nil
520
+ end, capture_error)
521
+ if ok then
522
+ outcome = result :: BenchReport?
523
+ else
524
+ failure = result :: ErrorReport
525
+ end
526
+ end
527
+ local cleanup_failures = invoke_cleanup_hooks(collection.drain_cleanups())
528
+ for _, hook_failure in invoke_cleanup_hooks(case.after_each) do
529
+ table.insert(cleanup_failures, hook_failure)
530
+ end
531
+ failure = append_cleanup_failures(failure, cleanup_failures)
532
+
533
+ if failure ~= nil and failure.type == "hexagon.skip" then
534
+ if case.kind == "test" then
535
+ report.test_skip += 1
536
+ else
537
+ report.bench_skip += 1
538
+ end
539
+ table.insert(report.skipped, { kind = case.kind, labels = case.labels, reason = failure.message })
540
+ elseif failure ~= nil then
541
+ report.ok = false
542
+ if case.kind == "test" then
543
+ report.test_fail += 1
544
+ else
545
+ report.bench_fail += 1
546
+ end
547
+ table.insert(report.failures, {
548
+ kind = case.kind,
549
+ labels = case.labels,
550
+ message = failure.message,
551
+ source = failure.source,
552
+ line = failure.line,
553
+ trace = failure.trace,
554
+ })
555
+ elseif case.kind == "test" then
556
+ report.test_pass += 1
557
+ table.insert(report.tests, { labels = case.labels, duration_seconds = os.clock() - started })
558
+ else
559
+ report.bench_pass += 1
560
+ table.insert(report.benchmarks, outcome :: BenchReport)
561
+ end
562
+ complete_case()
563
+ end
564
+
565
+ report.duration_seconds = os.clock() - run_started
566
+ return report
567
+ end
568
+
569
+ return table.freeze(hexagon)
@@ -0,0 +1,51 @@
1
+ --!strict
2
+ --!optimize 2
3
+ -- One native Luau VM entry point. The host controls file-level isolation.
4
+
5
+ local encode_json = require("./encode_json")
6
+ local hexagon = require("./hexagon")
7
+ assert(
8
+ type(hexagon) == "table" and type(hexagon.collect) == "function",
9
+ `Hexagon runtime did not load ({type(hexagon)} / {tostring(hexagon)})`
10
+ )
11
+
12
+ local arguments = { ... }
13
+ local mode = arguments[1] or "all"
14
+ local filter = if arguments[2] ~= "-" then arguments[2] else nil
15
+
16
+ local collection = hexagon.collect(function(t)
17
+ for index = 3, #arguments, 2 do
18
+ local module_path = arguments[index]
19
+ local display_path = arguments[index + 1] or module_path
20
+ t.describe(display_path, function()
21
+ local loaded, module = pcall(require, module_path)
22
+ if not loaded then
23
+ t.test("loads suite", function()
24
+ error(module, 0)
25
+ end)
26
+ return
27
+ end
28
+
29
+ local suite: unknown = module
30
+ if type(module) == "table" then suite = (module :: { default: unknown }).default end
31
+ if type(suite) ~= "function" then
32
+ t.test("loads suite", function()
33
+ error("suite module must return a function", 0)
34
+ end)
35
+ return
36
+ end
37
+
38
+ local registered, message = pcall(function(): unknown
39
+ (suite :: (hexagon.Suite) -> ())(t)
40
+ return nil
41
+ end)
42
+ if not registered then t.test("registers suite", function()
43
+ error(message, 0)
44
+ end) end
45
+ end)
46
+ end
47
+ end)
48
+
49
+ local report = hexagon.run(collection, { mode = mode :: any, filter = filter })
50
+ local encoded = encode_json(report)
51
+ print(`HEXAGON_JSON:{encoded}`)
@@ -0,0 +1,20 @@
1
+ --!strict
2
+ -- Deterministic failure-value formatting.
3
+
4
+ local function quote(value: unknown, indent: number?): string
5
+ local kind = typeof(value)
6
+ if kind == "string" then return string.format("%q", value :: string) end
7
+ if kind ~= "table" then return tostring(value) end
8
+
9
+ local amount = indent or 0
10
+ local inner = amount + 1
11
+ local lines = {}
12
+ for key, item in value :: { [unknown]: unknown } do
13
+ table.insert(lines, `{string.rep("\t", inner)}[{quote(key, inner)}] = {quote(item, inner)}`)
14
+ end
15
+ table.sort(lines)
16
+ if #lines == 0 then return "{}" end
17
+ return "{\n" .. table.concat(lines, ",\n") .. "\n" .. string.rep("\t", amount) .. "}"
18
+ end
19
+
20
+ return quote