@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hautajoki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # Hexagon
2
+
3
+ Hexagon is a Luau and Roblox test/benchmark runner. One package contains:
4
+
5
+ - the `hexagon` Bun CLI;
6
+ - the dependency-free Luau runtime and native runner;
7
+ - a Roblox runner bundled into exact Tessera artifacts on demand;
8
+ - TypeScript types for TypeScript-Go-Roblox suites.
9
+
10
+ It is intentionally a testing tool, not a task runner. Tessera remains
11
+ responsible for project configuration, builds, saved place versions,
12
+ credentials, and Open Cloud execution.
13
+
14
+ ## Install
15
+
16
+ During local development in this repository:
17
+
18
+ ```json
19
+ {
20
+ "devDependencies": {
21
+ "@hautajoki/hexagon": "workspace:*"
22
+ }
23
+ }
24
+ ```
25
+
26
+ A packaged consumer installs the release as a normal development dependency
27
+ and exposes direct scripts:
28
+
29
+ ```bash
30
+ bun add --dev @hautajoki/hexagon
31
+ ```
32
+
33
+ ```json
34
+ {
35
+ "scripts": {
36
+ "test": "hexagon test",
37
+ "bench": "hexagon bench"
38
+ }
39
+ }
40
+ ```
41
+
42
+ No second Hexagon config file is required.
43
+
44
+ Native runs require the `luau` executable. Cloud runs additionally require the
45
+ `tessera` executable at version 0.2.0 or newer and an existing Tessera
46
+ project/place configuration.
47
+ Hexagon owns the transient `<root>/test/hexagon` path; add that path to the
48
+ project ignore file so an interrupted run cannot surface generated runtime
49
+ files as authored changes.
50
+
51
+ ## Conventions
52
+
53
+ - `*.local.spec.luau` and `*.local.bench.luau` are native-capable and also run
54
+ in cloud coverage.
55
+ - `*.spec.ts`, `*.bench.ts`, `*.spec.luau`, and `*.bench.luau` run in a Roblox
56
+ Open Cloud artifact.
57
+ - Suites return or default-export `(t) => { ... }`.
58
+ - Files are discovered in lexical order.
59
+ - Correctness tests in benchmark modules always run before timings.
60
+ - Empty selections and focused `.only` cases exit nonzero by default.
61
+
62
+ TypeScript suites import only the API type:
63
+
64
+ ```ts
65
+ import type { Suite } from "@hautajoki/hexagon";
66
+
67
+ const suite: Suite = (t) => {
68
+ t.test("works", () => {
69
+ const resource = acquireResource();
70
+ t.cleanup(() => resource.Destroy());
71
+ t.expect(resource).toExist();
72
+ });
73
+ };
74
+
75
+ export = suite;
76
+ ```
77
+
78
+ Every `t.cleanup` callback runs once in LIFO order, even when the body or
79
+ another cleanup fails. `afterEach` callbacks receive the same all-cleanups
80
+ guarantee. Benchmark `afterSample` also runs when setup or timing fails, and a
81
+ cleanup failure is retained alongside the primary failure.
82
+
83
+ ## Isolation
84
+
85
+ File isolation is the default:
86
+
87
+ - native files run in separate `luau` processes and therefore separate VMs;
88
+ - cloud files run in separate Open Cloud executions and therefore separate
89
+ DataModels.
90
+
91
+ Cases within one file intentionally share that file's VM/DataModel. Use
92
+ `t.cleanup` for case-owned state. `--isolation run` is a faster opt-in that
93
+ loads all selected files in one VM/DataModel.
94
+
95
+ For cloud file isolation, Hexagon asks Tessera to build and save once, reads
96
+ the exact version from `tessera run --json`, then invokes every suite against
97
+ that same version.
98
+
99
+ For native execution, Hexagon temporarily materializes its runtime under
100
+ `<root>/test/hexagon` as well. This keeps Luau's relative module resolution
101
+ inside the consumer source tree even when the package manager stores Hexagon in
102
+ an external cache.
103
+
104
+ ## Open Cloud artifact bundling
105
+
106
+ `hexagon test --cloud` temporarily materializes its version-matched Roblox
107
+ runtime under `<root>/test/hexagon`, invokes `tessera run --fresh`, and removes
108
+ the generated runtime afterward. The project keeps its normal
109
+ `tessera.config.ts`; no generated suite manifest or second deployment config is
110
+ committed.
111
+
112
+ Co-located suites are compiled by the project's existing source/build recipe,
113
+ so the saved test artifact contains both suites and the runner. This includes
114
+ native-capable `.local.*` suites. A custom source router can override the runner
115
+ DataModel path with `--runner`.
116
+
117
+ This mechanism does not decide whether a project's production build excludes
118
+ co-located suites. That remains an explicit build/release choice; use a
119
+ dedicated test artifact or a project build variant when production exclusion is
120
+ required.
121
+
122
+ ## Commands
123
+
124
+ ```bash
125
+ hexagon test
126
+ hexagon bench terrain
127
+ hexagon test --cloud
128
+ hexagon test --cloud --environment development --place main
129
+ hexagon test --cloud --version 1234
130
+ hexagon test --cloud --isolation run
131
+ hexagon test --json
132
+ ```
133
+
134
+ Fresh exact artifacts are the cloud default. `--published` is deliberately
135
+ named for its weaker mutable-head semantics and is permitted only with shared
136
+ run isolation. `--allow-focused` likewise requires `--isolation run`, ensuring
137
+ that focus is global rather than scoped independently to each file. It is
138
+ intended for a deliberate interactive run, not release validation.
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@hautajoki/hexagon",
3
+ "version": "0.1.0",
4
+ "description": "Luau and Roblox test/benchmark runner with exact-version Tessera execution",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "hautajoki",
8
+ "bin": {
9
+ "hexagon": "src/cli.ts"
10
+ },
11
+ "types": "./types.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./types.d.ts"
15
+ }
16
+ },
17
+ "files": [
18
+ "src",
19
+ "runtime",
20
+ "types.d.ts",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "test": "bun test && bun run src/cli.ts test --root test",
29
+ "typecheck": "tsc -p tsconfig.json"
30
+ },
31
+ "devDependencies": {
32
+ "@types/bun": "1.3.14",
33
+ "typescript": "5.5.3"
34
+ },
35
+ "engines": {
36
+ "bun": ">=1.2.0"
37
+ },
38
+ "keywords": [
39
+ "roblox",
40
+ "luau",
41
+ "testing",
42
+ "benchmark",
43
+ "tessera"
44
+ ]
45
+ }
@@ -0,0 +1,139 @@
1
+ --!strict
2
+ -- Roblox Open Cloud entry point materialized into the consumer's test artifact.
3
+
4
+ local hexagon = require("./hexagon")
5
+
6
+ type InstanceLike = {
7
+ Name: string,
8
+ GetChildren: (self: InstanceLike) -> { InstanceLike },
9
+ GetFullName: (self: InstanceLike) -> string,
10
+ IsA: (self: InstanceLike, class_name: string) -> boolean,
11
+ }
12
+
13
+ type DataModelLike = InstanceLike & {
14
+ GetService: (self: DataModelLike, name: string) -> any,
15
+ }
16
+
17
+ type HttpServiceLike = {
18
+ JSONEncode: (self: HttpServiceLike, value: unknown) -> string,
19
+ }
20
+
21
+ type TaskSchedulerLike = {
22
+ wait: (duration: number?) -> number,
23
+ }
24
+
25
+ local data_model = (game :: any) :: DataModelLike
26
+ local HttpService = data_model:GetService("HttpService") :: HttpServiceLike
27
+ local require_module: (InstanceLike) -> unknown = require :: any
28
+ local task_scheduler = (task :: any) :: TaskSchedulerLike
29
+
30
+ local CASES_PER_YIELD = 16
31
+ local MAX_SECONDS_WITHOUT_YIELD = 0.1
32
+
33
+ export type RunRequest = {
34
+ mode: ("test" | "bench" | "all")?,
35
+ filter: string?,
36
+ suite: string?,
37
+ }
38
+
39
+ local PRUNE = {
40
+ node_modules = true,
41
+ rbxts_include = true,
42
+ _Index = true,
43
+ }
44
+
45
+ local function matches(module_script: InstanceLike, mode: string): boolean
46
+ if mode == "test" then return module_script.Name:match("%.spec$") ~= nil end
47
+ if mode == "bench" then return module_script.Name:match("%.bench$") ~= nil end
48
+ return module_script.Name:match("%.spec$") ~= nil or module_script.Name:match("%.bench$") ~= nil
49
+ end
50
+
51
+ local function discover(mode: string): { InstanceLike }
52
+ local modules: { InstanceLike } = {}
53
+ local function walk(parent: InstanceLike)
54
+ for _, child in parent:GetChildren() do
55
+ if PRUNE[child.Name] then continue end
56
+ if child:IsA("ModuleScript") and matches(child, mode) then table.insert(modules, child) end
57
+ walk(child)
58
+ end
59
+ end
60
+ walk(data_model)
61
+ table.sort(modules, function(left: InstanceLike, right: InstanceLike)
62
+ return left:GetFullName() < right:GetFullName()
63
+ end)
64
+ return modules
65
+ end
66
+
67
+ local module = {}
68
+
69
+ function module.run(request: RunRequest?): string
70
+ local options: RunRequest = request or ({} :: RunRequest)
71
+ local mode: "test" | "bench" | "all" = options.mode or "all"
72
+ local modules = discover(mode)
73
+ if options.suite ~= nil then
74
+ local selected: { InstanceLike } = {}
75
+ for _, module_script in modules do
76
+ if module_script:GetFullName() == options.suite then table.insert(selected, module_script) end
77
+ end
78
+ if #selected == 0 then error(`suite not found in artifact: {options.suite}`, 0) end
79
+ modules = selected
80
+ end
81
+ local collection = hexagon.collect(function(t)
82
+ for _, module_script in modules do
83
+ t.describe(module_script:GetFullName(), function()
84
+ local loaded, exported = pcall(require_module, module_script)
85
+ if not loaded then
86
+ t.test("loads suite", function()
87
+ error(exported, 0)
88
+ end)
89
+ return
90
+ end
91
+
92
+ local suite: unknown = exported
93
+ if type(exported) == "table" then suite = (exported :: { default: unknown }).default end
94
+ if type(suite) ~= "function" then
95
+ t.test("loads suite", function()
96
+ error("suite module must return a function", 0)
97
+ end)
98
+ return
99
+ end
100
+
101
+ local registered, message = pcall(function(): unknown
102
+ (suite :: (hexagon.Suite) -> ())(t)
103
+ return nil
104
+ end)
105
+ if not registered then t.test("registers suite", function()
106
+ error(message, 0)
107
+ end) end
108
+ end)
109
+ end
110
+ end)
111
+ local cases_since_yield = 0
112
+ local last_yield = os.clock()
113
+ return HttpService:JSONEncode(hexagon.run(collection, {
114
+ mode = mode,
115
+ filter = options.filter,
116
+ onCaseComplete = function()
117
+ cases_since_yield += 1
118
+ if cases_since_yield < CASES_PER_YIELD and os.clock() - last_yield < MAX_SECONDS_WITHOUT_YIELD then return end
119
+ task_scheduler.wait()
120
+ cases_since_yield = 0
121
+ last_yield = os.clock()
122
+ end,
123
+ }))
124
+ end
125
+
126
+ function module.list(request: RunRequest?): string
127
+ local options: RunRequest = request or ({} :: RunRequest)
128
+ local mode: "test" | "bench" | "all" = options.mode or "all"
129
+ local suites: { string } = {}
130
+ for _, module_script in discover(mode) do
131
+ table.insert(suites, module_script:GetFullName())
132
+ end
133
+ return HttpService:JSONEncode {
134
+ schema_version = 1,
135
+ suites = suites,
136
+ }
137
+ end
138
+
139
+ return table.freeze(module)
@@ -0,0 +1,57 @@
1
+ --!strict
2
+ -- Native Luau JSON encoder for runner reports.
3
+
4
+ -- Kept dependency-free so the native Luau runner and Roblox use the same data.
5
+
6
+ local function escape(value: string): string
7
+ return '"'
8
+ .. value:gsub('[%z\1-\31\\"]', function(character)
9
+ local replacements: { [string]: string } = {
10
+ ['"'] = '\\"',
11
+ ["\\"] = "\\\\",
12
+ ["\b"] = "\\b",
13
+ ["\f"] = "\\f",
14
+ ["\n"] = "\\n",
15
+ ["\r"] = "\\r",
16
+ ["\t"] = "\\t",
17
+ }
18
+ local code = string.byte(character) :: number
19
+ return replacements[character] or string.format("\\u%04x", code)
20
+ end)
21
+ .. '"'
22
+ end
23
+
24
+ local function encode(value: unknown): string
25
+ local kind = type(value)
26
+ if value == nil then return "null" end
27
+ if kind == "boolean" or kind == "number" then return tostring(value) end
28
+ if kind == "string" then return escape(value :: string) end
29
+ if kind ~= "table" then error(`cannot encode {kind} as JSON`, 2) end
30
+
31
+ local object = value :: { [unknown]: unknown }
32
+ local length = #object
33
+ local array = true
34
+ local count = 0
35
+ for key in object do
36
+ count += 1
37
+ if type(key) ~= "number" or key < 1 or key > length or key ~= math.floor(key) then array = false end
38
+ end
39
+ array = array and count == length
40
+
41
+ local parts = {}
42
+ if array then
43
+ for index = 1, length do
44
+ parts[index] = encode(object[index])
45
+ end
46
+ return "[" .. table.concat(parts, ",") .. "]"
47
+ end
48
+
49
+ for key, item in object do
50
+ if type(key) ~= "string" then error("JSON object keys must be strings", 2) end
51
+ table.insert(parts, `{escape(key)}:{encode(item)}`)
52
+ end
53
+ table.sort(parts)
54
+ return "{" .. table.concat(parts, ",") .. "}"
55
+ end
56
+
57
+ return encode
@@ -0,0 +1,125 @@
1
+ --!strict
2
+ -- Shared native Luau and Roblox expectations.
3
+
4
+ local quote = require("./quote")
5
+
6
+ export type Expectation = {
7
+ never: Expectation,
8
+ toBe: (expected: unknown) -> Expectation,
9
+ toEqual: (expected: unknown) -> Expectation,
10
+ toBeNear: (expected: number, tolerance: number?) -> Expectation,
11
+ toBeTruthy: () -> Expectation,
12
+ toExist: () -> Expectation,
13
+ toHaveType: (expected: string) -> Expectation,
14
+ toHaveKey: (expected: unknown) -> Expectation,
15
+ toContain: (expected: unknown) -> Expectation,
16
+ toMatch: (pattern: string) -> Expectation,
17
+ toThrow: (pattern: string?) -> Expectation,
18
+ }
19
+
20
+ local function deep_equal(left: unknown, right: unknown, seen: { [any]: any }): boolean
21
+ if left == right then return true end
22
+ if type(left) ~= "table" or type(right) ~= "table" then return false end
23
+ local left_table = left :: { [unknown]: unknown }
24
+ local right_table = right :: { [unknown]: unknown }
25
+ if seen[left_table] ~= nil then return seen[left_table] == right_table end
26
+ seen[left_table] = right_table
27
+ for key, value in left_table do
28
+ if not deep_equal(value, right_table[key], seen) then return false end
29
+ end
30
+ for key in right_table do
31
+ if left_table[key] == nil then return false end
32
+ end
33
+ return true
34
+ end
35
+
36
+ local function source_location(): (string, number, string)
37
+ for level = 3, 12 do
38
+ local source, line = debug.info(level, "sl")
39
+ if type(source) ~= "string" then break end
40
+ local implementation = source:find("/test/hexagon/expect", 1, true) or source:find("/test/hexagon/quote", 1, true)
41
+ if implementation == nil then return source, if type(line) == "number" then line else 0, debug.traceback(nil, level) end
42
+ end
43
+ local source, line = debug.info(3, "sl")
44
+ return if type(source) == "string" then source else "unknown", if type(line) == "number" then line else 0, debug.traceback(nil, 3)
45
+ end
46
+
47
+ local function fail(actual: unknown, assertion: string, expected: unknown, detail: string?): never
48
+ local source, line, trace = source_location()
49
+ local message = `expected {quote(actual)} {assertion}`
50
+ if expected ~= nil then
51
+ message ..= ` {quote(expected)}`
52
+ end
53
+ if detail ~= nil then
54
+ message ..= `\n{detail}`
55
+ end
56
+ error({
57
+ type = "hexagon.error",
58
+ source = source,
59
+ line = line,
60
+ message = message,
61
+ trace = trace,
62
+ }, 0)
63
+ end
64
+
65
+ local function expect(actual: unknown): Expectation
66
+ local function make(negated: boolean): Expectation
67
+ local self = {} :: any
68
+ local function check(ok: boolean, assertion: string, expected: unknown, detail: string?): Expectation
69
+ if ok == negated then fail(actual, if negated then `not {assertion}` else assertion, expected, detail) end
70
+ return self
71
+ end
72
+
73
+ self.toBe = function(expected: unknown)
74
+ return check(actual == expected, "to be", expected, nil)
75
+ end
76
+ self.toEqual = function(expected: unknown)
77
+ return check(deep_equal(actual, expected, {}), "to equal", expected, nil)
78
+ end
79
+ self.toBeNear = function(expected: number, tolerance: number?)
80
+ local epsilon = tolerance or 1e-6
81
+ if typeof(actual) ~= "number" then fail(actual, "to be a number near", expected, nil) end
82
+ if epsilon < 0 then error("expect().toBeNear() tolerance must be non-negative", 2) end
83
+ return check(math.abs((actual :: number) - expected) <= epsilon, "to be near", expected, `tolerance: {epsilon}`)
84
+ end
85
+ self.toBeTruthy = function()
86
+ return check(actual ~= nil and actual ~= false, "to be truthy", nil, nil)
87
+ end
88
+ self.toExist = function()
89
+ return check(actual ~= nil, "to exist", nil, nil)
90
+ end
91
+ self.toHaveType = function(expected: string)
92
+ return check(typeof(actual) == expected, "to have type", expected, `actual type: {typeof(actual)}`)
93
+ end
94
+ self.toHaveKey = function(expected: unknown)
95
+ if type(actual) ~= "table" then fail(actual, "to be a table containing key", expected, nil) end
96
+ return check((actual :: { [unknown]: unknown })[expected] ~= nil, "to have key", expected, nil)
97
+ end
98
+ self.toContain = function(expected: unknown)
99
+ if type(actual) ~= "table" then fail(actual, "to be an array containing", expected, nil) end
100
+ return check(table.find(actual :: { unknown }, expected) ~= nil, "to contain", expected, nil)
101
+ end
102
+ self.toMatch = function(pattern: string)
103
+ if type(actual) ~= "string" then fail(actual, "to be a string matching", pattern, nil) end
104
+ return check((actual :: string):match(pattern) ~= nil, "to match", pattern, nil)
105
+ end
106
+ self.toThrow = function(pattern: string?)
107
+ if type(actual) ~= "function" then fail(actual, "to be a function that throws", pattern, nil) end
108
+ local ok, message = pcall(function(): unknown
109
+ (actual :: () -> ())()
110
+ return nil
111
+ end)
112
+ local matches = not ok and (pattern == nil or tostring(message):match(pattern) ~= nil)
113
+ return check(matches, "to throw", pattern, if ok then "function returned successfully" else `threw: {message}`)
114
+ end
115
+ return self
116
+ end
117
+
118
+ local positive = make(false)
119
+ local negative = make(true)
120
+ positive.never = negative
121
+ negative.never = positive
122
+ return positive
123
+ end
124
+
125
+ return expect