@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
|
@@ -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
|