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