diff --git a/Cargo.lock b/Cargo.lock index 36f020c..924ce6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -551,7 +551,7 @@ dependencies = [ [[package]] name = "lest" -version = "0.5.0" +version = "0.6.0-canary.2" dependencies = [ "clap", "dotenvy", diff --git a/Cargo.toml b/Cargo.toml index df14ea0..7def845 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lest" -version = "0.5.0" +version = "0.6.0-canary.2" edition = "2021" description = "lest: a testing toolchain for Luau" license = "MIT" diff --git a/README.md b/README.md index e500f87..b030824 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ neither knows nor cares where a test ran. | `lute` | A spawned `lute run` process | Scripts using `@lute/*`, code transforms, tooling | — | ✅ | | `cloud` | A real Roblox place via Open Cloud | Instances, services, the DataModel — in CI | — | — | | `studio` | A launched Roblox Studio | The same engine code, locally, zero clicks | — | — | +| `gargantuan` | A spawned headless [Gargantuan](https://github.com/teamfireworks/gargantuan) engine | Gargantuan engine code (experimental) | — | — | ```toml [suites.unit] diff --git a/docs/backends.md b/docs/backends.md index 2645c25..89b2193 100644 --- a/docs/backends.md +++ b/docs/backends.md @@ -11,6 +11,8 @@ CI output — neither knows nor cares where a test ran. | [`lune`](#lune--lute) | A spawned `lune run` process | — | ✅ | | [`lute`](#lune--lute) | A spawned `lute run` process | — | ✅ | | [`cloud`](#cloud) | A real Roblox place via Open Cloud | — | — | +| [`studio`](#studio) | A launched Roblox Studio | — | — | +| [`gargantuan`](#gargantuan-experimental) | A spawned headless Gargantuan engine | — | — | **No backend fakes an environment.** Nothing mocks Instances, and nothing reimplements a runtime's standard library. If a test needs an environment, Lest @@ -250,6 +252,42 @@ matters the moment a spec asks `IsServer()` or touches DataStores refuses to run under `$CI`, and watch mode does not include it. Details and troubleshooting: **[Studio](studio.md)**. +## gargantuan (experimental) + +[Gargantuan](https://github.com/teamfireworks/gargantuan) is an independent, +Roblox-shaped game engine scripted with Luau — a `game` DataModel, +Instances, Signals, a `task` library. The gargantuan backend runs specs +inside the real engine, headless: Lest bundles the suite (the same bundler +as cloud and studio), spawns `gargantuan --script --headless`, and +decodes sentinel-framed events from its stdout with the lune/lute decoder (in bursts rather than live — the engine never flushes `print`). +No emulation, per the usual rule — specs get the engine's actual Instances +because they genuinely run in it. + +```toml +[suites.engine-gg] +include = ["tests/gargantuan/**/*.spec.luau"] +backend = "gargantuan" +default = false + +[gargantuan] +binary = "vendor/gargantuan/build/gargantuan" +``` + +**Experimental, stated plainly.** The engine is pre-release: it has no +tagged releases (build it from source and point `[gargantuan] binary` at +the result — with no `binary` configured, Lest looks for `gargantuan` on +`PATH`), and an API surface that is still filling in, so specs will find +`not yet implemented` edges. Those are engine facts, not test failures. +Excluded from watch mode and from `$CI` auto-enable; run it by naming the +suite explicitly. + +How a run ends depends on the engine build: on engines with +`ProcessService`, Lest's generated entrypoint exits the engine cleanly +(`ExitAsync(0)`) once the suite completes; on builds that predate the +service (or whose `ExitAsync` fails), Lest kills the engine a few +seconds after the suite's completion marker arrives — deliberate, not an +error. + ## Overriding a backend `--backend` forces every selected suite onto one backend for a single run: diff --git a/docs/cli.md b/docs/cli.md index 793fac2..a71af6c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -69,7 +69,7 @@ than accepted and ignored. #### `--backend ` Force every selected suite onto one backend for this run: `native`, `lune`, -`lute`, `cloud`, or `studio`. A debugging override — see +`lute`, `cloud`, `studio`, or `gargantuan`. A debugging override — see [Backends](backends.md#overriding-a-backend). ### Watch mode @@ -86,8 +86,8 @@ $ lest run unit --watch Watch mode is graph-driven, not glob-driven. Saving a file invalidates its content hash; the inverted require graph then yields exactly the specs whose transitive requires touched it, and only those re-run. Watching is debounced, -hidden files are ignored (except `.luaurc`, which affects resolution), and -`cloud` and `studio` suites are always excluded — the fast loop never waits on a network round-trip or a Studio boot. +hidden files are ignored (except `.luaurc` and `.config.luau`, which affect resolution), and +`cloud`, `studio`, and `gargantuan` suites are always excluded — the fast loop never waits on a network round-trip or an engine boot. At startup and after every pass, a dim banner marks the loop as alive: @@ -111,11 +111,11 @@ $ lest run unit -t 'parser' --watch #### `--reporter ` -| Reporter | Output | -| --- | --- | +| Reporter | Output | +| -------- | ---------------------------------------------------------------- | | `pretty` | **Default.** Nested suites, inline diffs, slowest tests, summary | -| `json` | The event log, one JSON object per line | -| `junit` | JUnit XML, for CI annotations | +| `json` | The event log, one JSON object per line | +| `junit` | JUnit XML, for CI annotations | All reporters consume the same merged stream regardless of which backend produced it, tagging each suite's section with the environment it ran in. @@ -183,7 +183,9 @@ Re-running is safe: an existing `lest.toml` prompts untouched; with `--yes` init refuses and exits 2), and an alias already bound to `lest` in `.luaurc` is left alone. `.luaurc` is only rewritten when it parses as plain JSON with no comments; otherwise init prints the snippet for -you to paste. Key order is preserved. +you to paste. Key order is preserved. A project using `.config.luau` never +gains a `.luaurc` (the Luau config RFC makes the pair an error) — init prints +the alias line to paste into it instead. See [Getting started](getting-started.md#scaffold-a-project). @@ -200,11 +202,11 @@ being baked into whatever they expanded to that day. ## Exit codes -| Code | Meaning | -| --- | --- | -| `0` | Everything passed | -| `1` | Test failures — including timeouts, spec load errors, and a `--min` shortfall | -| `2` | Tool error — bad config, a backend that couldn't start, an undecodable event stream, a filter or coverage gate over nothing | +| Code | Meaning | +| ---- | --------------------------------------------------------------------------------------------------------------------------- | +| `0` | Everything passed | +| `1` | Test failures — including timeouts, spec load errors, and a `--min` shortfall | +| `2` | Tool error — bad config, a backend that couldn't start, an undecodable event stream, a filter or coverage gate over nothing | These are never conflated, which is the whole point. A test that times out or a spec that fails to load is a *test* failure (1). A backend that can't start, a @@ -214,11 +216,11 @@ be a lie. ## Environment variables -| Variable | Effect | -| --- | --- | -| `ROBLOX_API_KEY` | Open Cloud API key for `cloud` suites | -| `LEST_API_KEY` | Alternative name for the same key | -| `CI` | When set (and not empty, `0`, or `false`), suites with `default = false` run automatically | -| `NO_COLOR` | When set, disables ANSI color everywhere, same as `--no-color` | +| Variable | Effect | +| ---------------- | ------------------------------------------------------------------------------------------ | +| `ROBLOX_API_KEY` | Open Cloud API key for `cloud` suites | +| `LEST_API_KEY` | Alternative name for the same key | +| `CI` | When set (and not empty, `0`, or `false`), suites with `default = false` run automatically | +| `NO_COLOR` | When set, disables ANSI color everywhere, same as `--no-color` | A `.env` file at the project root is loaded automatically. diff --git a/docs/configuration.md b/docs/configuration.md index b2530be..c262071 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -88,7 +88,7 @@ never re-run on save — not running it at all is the consistent reading. Where this suite's specs run, overriding the top-level default. See [Backends](backends.md). -- **Type:** `"native"` · `"lune"` · `"lute"` · `"cloud"` · `"studio"` +- **Type:** `"native"` · `"lune"` · `"lute"` · `"cloud"` · `"studio"` · `"gargantuan"` - **Default:** `[settings] backend` ### `default` @@ -167,7 +167,7 @@ file at the project root. See [Backends → cloud](backends.md#cloud). The default backend for suites that don't declare one. -- **Type:** `"native"` · `"lune"` · `"lute"` · `"cloud"` · `"studio"` +- **Type:** `"native"` · `"lune"` · `"lute"` · `"cloud"` · `"studio"` · `"gargantuan"` - **Default:** `"native"` Precedence for where a suite runs: the `--backend` CLI flag, then the diff --git a/docs/getting-started.md b/docs/getting-started.md index 3849882..66fb6c1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -123,7 +123,11 @@ return nil Three things to notice: - **`require('@lest')`** works because `lest init` wrote that alias into - `.luaurc`. If you declined it, require the framework by path instead — + `.luaurc`. In a project configured with `.config.luau`, init prints the + alias line to paste instead (aliases resolve from either format) — and + until you paste it, the generated example requires core by relative path. + If you declined the alias, + require the framework by path instead — `require('../.lest/core')`, adjusted for where the spec sits. - **The destructuring line** (`local describe, it, expect = ...`) is deliberate. Lest has no ambient globals: your specs require real `.luau` diff --git a/luau/runtime/encode.luau b/luau/runtime/encode.luau new file mode 100644 index 0000000..15ca0e8 --- /dev/null +++ b/luau/runtime/encode.luau @@ -0,0 +1,126 @@ +--!strict +--- Pure-Luau JSON encoding for sentinel-framed protocol events, embedded into +--- bundles that run where no engine JSON service exists (gargantuan has no +--- HttpService). This is the twin of the spawned-runtime harness template's +--- inline encoder — same rules, same degradations, kept in step by hand: the +--- harness must stay a single self-contained file, so it cannot require this +--- module. + +--- Replaces every byte that is not part of a valid UTF-8 sequence with U+FFFD +--- (the replacement character). Luau strings are byte arrays, but this stream +--- ends in a real JSON decoder: a raw invalid byte makes the sentinel line +--- undecodable, and one bad message becomes a tool error for the whole run. +--- The invariant, shared with the cloud sanitizer: degrade one string, never +--- the run. Valid multi-byte UTF-8 passes through untouched. +local REPLACEMENT = utf8.char(0xFFFD) + +local function toValidUtf8 (s: string): string + -- Fast path: `utf8.len` validates the whole string in one pass. + if utf8.len(s) ~= nil then + return s + end + local parts = {} + local index = 1 + while index <= #s do + local remaining, invalidAt = utf8.len(s, index) + if remaining ~= nil then + -- Everything from `index` to the end is valid. + table.insert(parts, string.sub(s, index)) + break + end + -- Bytes before the reported position are valid; the byte there is not, + -- so it degrades alone and scanning resumes right after it. + local bad = invalidAt :: number + if bad > index then + table.insert(parts, string.sub(s, index, bad - 1)) + end + table.insert(parts, REPLACEMENT) + index = bad + 1 + end + return table.concat(parts) +end + +local function escapeJson (s: string): string + s = toValidUtf8(s) + return ( + s:gsub('[%c"\\]', function (c: string): string + if c == '"' then + return '\\"' + elseif c == '\\' then + return '\\\\' + elseif c == '\n' then + return '\\n' + elseif c == '\r' then + return '\\r' + elseif c == '\t' then + return '\\t' + end + return string.format('\\u%04x', string.byte(c)) + end) + ) +end + +local function encodeJson (value: any): string + local t = type(value) + if t == 'nil' then + return 'null' + elseif t == 'boolean' then + return tostring(value) + elseif t == 'number' then + -- JSON has no NaN or Infinity. On the cloud path a Luau table cannot + -- hold nil, so `null` is unrepresentable there; `0` is the substitute + -- every wire path can produce, so both paths emit `0` and backends + -- agree on the shape of the same pathological event. + if value ~= value or value == math.huge or value == -math.huge then + return '0' + end + return tostring(value) + elseif t == 'string' then + return '"' .. escapeJson(value) .. '"' + elseif t == 'table' then + -- A table is an array only when its keys are exactly 1..#value: the + -- ipairs walk below would silently drop any other key, so a mixed table + -- (`{1, 2, foo = 'bar'}`) encodes as an object instead. An empty table + -- encodes as an empty array — protocol objects always carry at least a + -- `kind`/`type` field, so empty can only mean an empty array (e.g. a + -- top-level test's path). + local arrayLength = #value + local keyCount = 0 + local contiguous = true + for key in pairs(value) do + keyCount += 1 + if type(key) ~= 'number' or key ~= math.floor(key) or key < 1 or key > arrayLength then + contiguous = false + end + end + if keyCount == 0 or (contiguous and keyCount == arrayLength) then + local parts = {} + for _, item in ipairs(value) do + table.insert(parts, encodeJson(item)) + end + return '[' .. table.concat(parts, ',') .. ']' + end + -- Object: every key stringified (JSON objects hold only string keys). + -- Two distinct keys can stringify to the same text (1 and '1'), so a + -- collision takes a ` #n` suffix rather than emitting duplicate JSON + -- keys — the same rule as the cloud sanitizer. + local parts = {} + local taken: { [string]: boolean } = {} + for key, item in pairs(value) do + local keyString = tostring(key) + if taken[keyString] then + local suffix = 2 + while taken[`{keyString} #{suffix}`] do + suffix += 1 + end + keyString = `{keyString} #{suffix}` + end + taken[keyString] = true + table.insert(parts, '"' .. escapeJson(keyString) .. '":' .. encodeJson(item)) + end + return '{' .. table.concat(parts, ',') .. '}' + end + return '"<' .. t .. '>"' +end + +return { value = encodeJson } diff --git a/src/backend/cloud/bundle.rs b/src/backend/cloud/bundle.rs index 74f8236..a929db9 100644 --- a/src/backend/cloud/bundle.rs +++ b/src/backend/cloud/bundle.rs @@ -120,8 +120,11 @@ fn brief_reason(error: &ResolveError) -> String { } ResolveError::UnknownAlias { .. } => "unknown alias".to_string(), ResolveError::InvalidSelf { .. } => "@self is only valid from an init module".to_string(), - ResolveError::Luaurc { path, .. } => { - format!("unreadable .luaurc at {}", path.display()) + ResolveError::AliasConfig { path, .. } => { + // "Unusable", not "unreadable": the variant also covers the + // RFC-forbidden state of `.luaurc` and `.config.luau` sharing a + // directory, where both files read fine. + format!("unusable alias config at {}", path.display()) } ResolveError::Project { path, .. } => { format!("unreadable rojo project at {}", path.display()) @@ -143,6 +146,13 @@ pub enum Head { /// A done marker after the last spec is the completion authority — a /// GUI quit is a weaker signal than a process exit. Studio, + /// Spawned Gargantuan engine run (`--script … --headless`): events print + /// as sentinel-framed stdout lines, decoded live like lune/lute. The + /// done marker is the completion authority; after it the head exits the + /// engine cleanly via `ProcessService:ExitAsync` where the engine has + /// it, and falls back to padding stdout until the CLI's kill on engines + /// that predate the service (see [`emit_gargantuan_head`]). + Gargantuan, } /// Everything the bundler needs to emit one self-contained entrypoint. @@ -328,9 +338,14 @@ pub fn bundle_with_cache( )?; } // The CLI-embedded in-engine runtime, inlined from compiled-in source - // under fixed `lr_*` ids. + // under fixed `lr_*` ids — but only the modules this head actually + // drives. Cloud submits one bundle per spec file, so an unused module + // is not just dead bytes on disk but dead bytes uploaded N times. + let embedded_ids = embedded_ids_for(input.head); for module in EMBEDDED { - emit_embedded(&mut out, module, &mut source_map); + if embedded_ids.contains(&module.id) { + emit_embedded(&mut out, module, &mut source_map); + } } // ── Entrypoint ────────────────────────────────────────────────────────── @@ -338,6 +353,7 @@ pub fn bundle_with_cache( match input.head { Head::Cloud => emit_cloud_head(&mut out, input, &core_id, &id_of)?, Head::Studio => emit_studio_head(&mut out, input, &core_id, &id_of)?, + Head::Gargantuan => emit_gargantuan_head(&mut out, input, &core_id, &id_of)?, } Ok(Bundle { @@ -545,6 +561,152 @@ print({done_sentinel}) Ok(()) } +/// The gargantuan entrypoint tail: the studio head's per-spec drive and +/// sentinel framing, with two engine-shaped differences. Gargantuan has no +/// HttpService, so the embedded pure-Luau encoder does the JSON. And ending +/// the run is two-mode: after the done marker the head calls +/// `ProcessService:ExitAsync(0)` where the engine has it — the clean exit +/// flushes stdout, carrying the marker out, and the CLI sees the process +/// end on its own. On engines predating the service the `pcall` fails and +/// the head falls back to padding stdout in a `task.wait` loop until the +/// CLI's kill lands: Luau's `print` never flushes, a killed process +/// discards its stdio buffer, and pipe buffer sizes vary by platform — an +/// unpadded (or fixed-size-padded) tail could strand the done marker in +/// that buffer forever, turning every completed run into a budget expiry. +/// The CLI stops echoing output once the marker is seen, so the padding +/// never reaches the terminal. +fn emit_gargantuan_head( + out: &mut String, + input: &BundleInput, + core_id: &str, + id_of: &BTreeMap, +) -> Result<(), ToolError> { + use crate::backend::runtime::{DONE_SENTINEL, SENTINEL, SPEC_SENTINEL}; + + let module_id = |path: &Path| -> Result { + id_of.get(&normalize(path)).cloned().ok_or_else(|| { + ToolError(format!( + "cannot bundle {} for the gargantuan suite: it is not in the computed require \ + closure", + path.display() + )) + }) + }; + + out.push_str("-- Entrypoint: stream each spec's events as sentinel-framed print lines.\n"); + out.push_str(&format!("local Lest = __lest_require('{core_id}')\n")); + out.push_str(&format!( + "local Scheduler = __lest_require('{SCHEDULER_ID}')\n" + )); + out.push_str(&format!( + "local Sanitize = __lest_require('{SANITIZE_ID}')\n" + )); + out.push_str(&format!("local Encode = __lest_require('{ENCODE_ID}')\n")); + // Gargantuan's `task.cancel` errors ("not yet implemented"), so a spec + // the scheduler abandons at its deadline is not actually stopped — the + // engine resumes it on later frames, and its late events would stream + // into the *next* spec's attribution (misfiled snapshots included). + // Each spec therefore emits through a generation-stamped closure: + // bumping the generation orphans every closure handed to earlier specs, + // so a stale thread's emissions drop instead of misattributing. + out.push_str(&format!( + "local __lest_generation = 0\n\ + local function __lest_make_emit ()\n\ + \tlocal generation = __lest_generation\n\ + \treturn function (event)\n\ + \t\tif generation ~= __lest_generation then\n\ + \t\t\treturn\n\ + \t\tend\n\ + \t\tprint({sent} .. Encode.value(Sanitize.value(event)))\n\ + \tend\n\ + end\n", + sent = split_marker(SENTINEL) + )); + + out.push_str("local __lest_specs = {\n"); + for spec in input.specs { + let spec_id = module_id(&spec.path)?; + out.push_str(&format!( + "\t{{ name = '{}', load = function () return __lest_require('{spec_id}') end }},\n", + luau_escape(&spec.name), + )); + } + out.push_str("}\n"); + + let name_filter = match input.name_filter { + Some(filter) => format!("'{}'", luau_escape(filter)), + None => "nil".to_string(), + }; + + // The same load/timeout/error synthesis as the studio head; the padding + // loop after the done marker is what the module doc above explains. + out.push_str(&format!( + r#"for __lest_index, spec in __lest_specs do + print({spec_sentinel} .. tostring(__lest_index)) + __lest_generation += 1 + local __lest_emit = __lest_make_emit() + Lest.reset() + local ok, err = pcall(spec.load) + if not ok then + __lest_emit({{ + kind = 'test_fail', path = {{ spec.name }}, name = '(load)', + durationMs = 0, + failure = {{ type = 'error', message = tostring(err), trace = '' }}, + }}) + else + local result = Scheduler.runSuite(function () + Lest.run(__lest_emit, {{ nameFilter = {name_filter} }}) + end, {{ task = task, deadlineMs = {deadline} }}) + if result.timedOut then + __lest_emit({{ + kind = 'test_fail', path = {{ spec.name }}, name = '(timeout)', + durationMs = result.durationMs, + failure = {{ type = 'error', message = 'spec exceeded its deadline', trace = '' }}, + }}) + elseif result.error ~= nil then + -- Same guard as the cloud head: a captured mid-run error must + -- surface, or the remaining tests vanish behind a green run. + __lest_emit({{ + kind = 'test_fail', path = {{ spec.name }}, name = '(error)', + durationMs = result.durationMs, + failure = {{ type = 'error', message = tostring(result.error), trace = '' }}, + }}) + end + end +end +-- Orphan the last spec's emitter too: a straggler resuming during the +-- padding below must stay silent, not print between padding lines. +__lest_generation += 1 +-- The completion authority goes out first: nothing below may run before it. +print({done_sentinel}) +-- pcall twice over: engines predating ProcessService throw on GetService, +-- and the current engine's ExitAsync itself raises (an upstream +-- argument-index bug reads the exit code from the self slot). Either +-- failure falls through to the padding loop below; a working ExitAsync +-- ends the process before the next frame — the clean exit flushes stdout, +-- carrying the done marker out — and control never leaves the pcall. +local __lest_exit_ok, __lest_process = pcall(function () + return game:GetService('ProcessService') +end) +if __lest_exit_ok and __lest_process ~= nil then + pcall(function () + __lest_process:ExitAsync(0) + end) +end +local __lest_pad = string.rep('=', 1024) +while true do + print(__lest_pad) + task.wait() +end +"#, + spec_sentinel = split_marker(SPEC_SENTINEL), + done_sentinel = split_marker(DONE_SENTINEL), + deadline = input.deadline_ms, + )); + + Ok(()) +} + /// One module of the CLI-embedded in-engine runtime. struct EmbeddedModule { /// Base name, matched against `require('./name')` / `require('@self/name')`. @@ -556,6 +718,7 @@ struct EmbeddedModule { const COLLECTOR_ID: &str = "lr_collector"; const SCHEDULER_ID: &str = "lr_scheduler"; const SANITIZE_ID: &str = "lr_sanitize"; +const ENCODE_ID: &str = "lr_encode"; /// The in-engine runtime the cloud entrypoint drives: a collector that buffers /// protocol events for the task to return, the task-scheduler integration that @@ -583,8 +746,29 @@ const EMBEDDED: &[EmbeddedModule] = &[ id: "lr_sanitize", source: include_str!("../../../luau/runtime/cloud/sanitize.luau"), }, + // Not under cloud/: the encoder serves any head whose engine offers no + // JSON service (gargantuan today), and lives next to the harness template + // whose inline encoder it mirrors. + EmbeddedModule { + name: "encode", + id: ENCODE_ID, + source: include_str!("../../../luau/runtime/encode.luau"), + }, ]; +/// The embedded modules a head's generated tail actually requires, +/// transitively (the collector requires sanitize as a sibling). Kept next to +/// the head emitters that hold the matching `__lest_require` lines — a head +/// that gains a require must gain an id here, and the module-reference +/// completeness test catches the mismatch. +fn embedded_ids_for(head: Head) -> &'static [&'static str] { + match head { + Head::Cloud => &[COLLECTOR_ID, SCHEDULER_ID, SANITIZE_ID], + Head::Studio => &[SCHEDULER_ID, SANITIZE_ID], + Head::Gargantuan => &[SCHEDULER_ID, SANITIZE_ID, ENCODE_ID], + } +} + /// Resolves an embedded module's require arg (`./sanitize`, `@self/sanitize`) /// to a sibling embedded module id by its final path segment. fn embedded_id_for(arg: &str) -> Option<&'static str> { @@ -935,6 +1119,66 @@ mod tests { } } + #[test] + fn gargantuan_head_encodes_without_httpservice_and_pads_after_done() { + let root = repo_root(); + let spec = root.join("tests/core/expect.spec.luau"); + let specs = vec![SpecEntry { + name: "tests/core/expect.spec".to_string(), + path: spec.clone(), + }]; + let input = BundleInput { + core_entry: &core_entry(&root), + specs: &specs, + name_filter: None, + head: Head::Gargantuan, + deadline_ms: 4321, + place: None, + }; + let bundle = bundle(&input).expect("bundle should succeed"); + let script = bundle.script; + + // The marker discipline studio established holds here for the same + // reason: erroring source can end up echoed into the framed channel, + // and a whole marker in source would decode as a broken event. + assert!(!script.contains(crate::backend::runtime::SENTINEL)); + assert!(!script.contains(crate::backend::runtime::SPEC_SENTINEL)); + assert!(!script.contains(crate::backend::runtime::DONE_SENTINEL)); + assert!(script.contains("'@@LE' .. 'ST@@'")); + // No engine JSON service: the embedded encoder does the work. The + // negative is matched on the studio head's local (embedded module + // doc comments legitimately mention JSONEncode in every bundle). + assert!(script.contains("Encode.value(Sanitize.value(event))")); + assert!(!script.contains("__lest_http")); + // The two-mode ending, with the order pinned: the done marker + // precedes the exit attempt (nothing may run before the completion + // authority), which precedes the padding fallback. The guard and + // the pcall around ExitAsync are asserted literally — a raising + // ExitAsync (the current upstream argument-index bug) must fall + // through to the padding, not kill the head. + let done_at = script + .find("'@@LEST_STU' .. 'DIO_DONE@@'") + .expect("split done marker in the head"); + let exit_at = script + .find("__lest_process:ExitAsync(0)") + .expect("exit call in the head"); + let pad_at = script.find("local __lest_pad").expect("padding fallback"); + assert!(done_at < exit_at && exit_at < pad_at); + assert!(script.contains("game:GetService('ProcessService')")); + assert!(script.contains("if __lest_exit_ok and __lest_process ~= nil then")); + assert!(script.contains("pcall(function ()\n\t\t__lest_process:ExitAsync(0)")); + assert!(script.contains("task.wait()")); + // Shared machinery: scheduler deadline and the embedded modules, + // the encoder among them. + assert!(script.contains("deadlineMs = 4321")); + for id in ["lr_scheduler", "lr_sanitize", "lr_encode"] { + assert!( + script.contains(&format!("__lest_modules['{id}']")), + "embedded module {id} must inline" + ); + } + } + #[test] fn bundles_real_core_and_embedded_roblox_with_a_spec() { let root = repo_root(); diff --git a/src/backend/cloud/mod.rs b/src/backend/cloud/mod.rs index 76fbeb3..a91604e 100644 --- a/src/backend/cloud/mod.rs +++ b/src/backend/cloud/mod.rs @@ -463,6 +463,7 @@ mod tests { coverage: false, rojo_project: None, studio_executable: None, + gargantuan_binary: None, } } @@ -696,6 +697,7 @@ mod tests { coverage: false, rojo_project: None, studio_executable: None, + gargantuan_binary: None, }; let mut sink = |_: Option<&Path>, _: &Event| {}; @@ -757,6 +759,7 @@ mod tests { coverage: false, rojo_project: Some(root.join("default.project.json")), studio_executable: None, + gargantuan_binary: None, }; let transport = RoutedCloud::new(); let mut sink = |_: Option<&Path>, _: &Event| {}; diff --git a/src/backend/gargantuan.rs b/src/backend/gargantuan.rs new file mode 100644 index 0000000..e65b14c --- /dev/null +++ b/src/backend/gargantuan.rs @@ -0,0 +1,627 @@ +//! The gargantuan backend: the Gargantuan engine as a spawned runtime. +//! +//! [Gargantuan](https://github.com/teamfireworks/gargantuan) is an +//! independent, Roblox-shaped game engine scripted with Luau. Its CLI runs a +//! script headlessly (`--script --headless`), which makes the backend +//! a hybrid of two existing shapes: the suite is bundled exactly like +//! cloud/studio (the engine's own `require` is still settling, so every +//! module inlines and nothing delegates), and the process is driven exactly +//! like lune/lute — sentinel-framed events decoded live off a stdout pipe. +//! +//! One thing is unlike either: the run's ending is the CLI's to arrange. +//! The done marker is the completion authority, and what follows it is +//! two-mode. On engines with `ProcessService`, the head calls +//! `ExitAsync(0)` right after the marker: the engine exits cleanly, the +//! exit flushes stdio, and the CLI just reaps the child. On engines that +//! predate the service, nothing can stop the loop headless — the head pads +//! stdout in its place (a killed process discards its stdio buffer and +//! Luau's `print` never flushes, so an unpadded marker could sit in the +//! pipe buffer forever) and the CLI kills the process after a short grace +//! wait, deliberately. +//! +//! Experimental, stated plainly: the engine is pre-release, unversioned, and +//! restructuring quickly. The spawn contract this backend leans on +//! (`--script`, `--headless`, exit 1 on a failed load) was verified against +//! its 2026-08 tree; a `[gargantuan] binary` that predates or postdates that +//! contract fails loudly through the same guards every backend carries. + +use std::collections::HashSet; +use std::io::{BufRead, BufReader}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use crate::backend::cloud::bundle::{self, BundleInput, Head, SpecEntry}; +use crate::backend::runtime::{classify, is_done_framed, passthrough, Decoded}; +use crate::backend::{display_rel, EventSink, SuitePlan}; +use crate::error::ToolError; +use crate::report::{check_protocol_version, Event, Failure}; + +/// Fixed allowance for the engine to boot (headless: no GPU, no window, but +/// still SDL init and library setup) on top of the per-spec budgets. +const BOOT_ALLOWANCE: Duration = Duration::from_secs(30); + +/// How many trailing stderr lines are retained for diagnosing a process that +/// dies before completing. The engine logs errors and criticals to stderr +/// (a failed `--script` load among them), so the tail usually names the +/// cause. A bound, not a log — lines still stream to the terminal live. +const STDERR_TAIL_LINES: usize = 20; + +/// How long after the done marker the CLI waits for the engine to exit on +/// its own before killing it. An engine whose `ProcessService:ExitAsync` +/// works exits within milliseconds of the marker; one without the service — +/// or with the current upstream argument-index bug that makes `ExitAsync` +/// raise — runs the head's padding fallback instead and spends the full +/// grace before the kill. The cost of not needing a version probe. +const EXIT_GRACE: Duration = Duration::from_secs(3); + +pub fn run(plan: &SuitePlan, on_event: &mut EventSink) -> Result<(), ToolError> { + let exe = gargantuan_executable(plan)?; + + let entries: Vec = plan + .specs + .iter() + .map(|spec| SpecEntry { + name: display_rel(spec, &plan.root), + path: spec.clone(), + }) + .collect(); + + // Per-spec deadline inside the engine: the studio rule (single-spec + // budget plus fixed slack), for the studio reasons — the scheduler + // cannot preempt a stuck spec, only abandon it at the deadline. + let budget = plan.timeout.saturating_add(Duration::from_secs(10)); + let deadline_ms = u64::try_from(budget.as_millis().max(1)).unwrap_or(u64::MAX); + + // `place: None` always — there is no Roblox place to delegate requires + // into, so `[place] rojo` is deliberately not consulted and every module + // bundles. (The engine is growing its own require; revisit when it + // settles.) + let input = BundleInput { + core_entry: &plan.core_entry, + specs: &entries, + name_filter: plan.name_filter.as_deref(), + head: Head::Gargantuan, + deadline_ms, + place: None, + }; + let mut sources = bundle::SourceCache::default(); + let built = bundle::bundle_with_cache(&input, &mut sources)?; + let mut warned: HashSet = HashSet::new(); + for miss in &built.unresolved { + if warned.insert(miss.clone()) { + crate::report::warn_to_stderr(&crate::backend::cloud::unresolved_warning( + miss, &plan.root, + )); + } + } + + let work_dir = plan.root.join(".lest"); + std::fs::create_dir_all(&work_dir) + .map_err(|e| ToolError(format!("cannot create {}: {e}", work_dir.display())))?; + let script_path = work_dir.join("gargantuan-run.luau"); + std::fs::write(&script_path, &built.script) + .map_err(|e| ToolError(format!("cannot write {}: {e}", script_path.display())))?; + + let spec_count = u32::try_from(plan.specs.len().max(1)).unwrap_or(u32::MAX); + let overall = BOOT_ALLOWANCE.saturating_add(budget.saturating_mul(spec_count)); + + let mut child = Command::new(&exe) + .arg("--script") + .arg(&script_path) + .arg("--headless") + .current_dir(&plan.root) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + ToolError(format!( + "cannot find `{}` — the engine has no releases yet, so build it from \ + source (https://github.com/teamfireworks/gargantuan) and point \ + `[gargantuan] binary` in lest.toml at the built executable", + exe.display() + )) + } else { + ToolError(format!("cannot start {}: {e}", exe.display())) + } + })?; + + let stdout = child.stdout.take().expect("stdout was piped"); + let (tx, rx) = mpsc::channel::>(); + let reader = std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + if tx.send(line).is_err() { + break; + } + } + }); + + // stderr streams to the terminal as it arrives and keeps a bounded tail: + // the engine logs errors there (a bundle that fails to load is one + // Critical line), and quoting that line beats a bare exit status. + let stderr = child.stderr.take().expect("stderr was piped"); + let stderr_tail: Arc>> = Arc::new(Mutex::new(Vec::new())); + let stderr_reader = { + let tail = Arc::clone(&stderr_tail); + std::thread::spawn(move || { + for line in BufReader::new(stderr).lines() { + let Ok(line) = line else { break }; + eprintln!("{line}"); + let mut tail = tail.lock().unwrap(); + if tail.len() >= STDERR_TAIL_LINES { + tail.remove(0); + } + tail.push(line); + } + }) + }; + + let deadline = Instant::now() + .checked_add(overall) + .unwrap_or_else(|| Instant::now() + Duration::from_secs(86_400)); + + type Readers = (std::thread::JoinHandle<()>, std::thread::JoinHandle<()>); + let finish = + |mut child: Child, rx: mpsc::Receiver>, readers: Readers| { + let _ = child.kill(); + let _ = child.wait(); + drop(rx); + let _ = readers.0.join(); + let _ = readers.1.join(); + }; + + let mut state = StreamState::new(plan); + let ending = loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break Ending::Deadline; + } + match rx.recv_timeout(remaining) { + Ok(Ok(line)) => match state.feed(&line, on_event) { + Ok(Feed::Continue) => {} + Ok(Feed::Done) => break Ending::Done, + Err(err) => { + finish(child, rx, (reader, stderr_reader)); + return Err(err); + } + }, + Ok(Err(err)) => { + finish(child, rx, (reader, stderr_reader)); + return Err(ToolError(format!( + "cannot read {} output: {err}", + exe.display() + ))); + } + // recv_timeout returned early; the loop re-checks the deadline. + Err(RecvTimeoutError::Timeout) => continue, + Err(RecvTimeoutError::Disconnected) => break Ending::Eof, + } + }; + + match ending { + // The good path: the suite completed. Grace-wait for the engine to + // exit itself (the head calls ProcessService:ExitAsync(0) after the + // marker on engines that have it) before killing — the wait is what + // distinguishes a modern engine from one running the padding + // fallback, without a version probe. `finish` runs either way: its + // kill is a no-op on an exited child, and the joins are still owed. + // The exit status is deliberately ignored here, unlike Eof's: every + // verdict already streamed before the marker, so a teardown crash + // after it has nothing left to change. + Ending::Done => { + let grace = Instant::now() + EXIT_GRACE; + loop { + // Keep draining (and discarding) the channel: an engine on + // the padding fallback prints ~1 KiB per unthrottled + // headless frame, and an undrained channel would buffer + // tens of megabytes across the grace. + while rx.try_recv().is_ok() {} + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() >= grace => break, + Ok(None) => std::thread::sleep(Duration::from_millis(25)), + Err(_) => break, + } + } + finish(child, rx, (reader, stderr_reader)); + } + // The budget expired. Which kind of failure that is depends on + // whether the protocol ever started: the engine cannot exit on its + // own, so a bundle that fails to load (compile error, a raise at + // top level) is logged to stderr and then the engine steps frames + // *silently forever* — from out here that is indistinguishable from + // a hang except by the absence of protocol traffic. No protocol at + // all means the run never happened: a tool error (exit 2), with the + // stderr tail that names the cause. Protocol followed by silence + // means something under test hung: a test failure (exit 1), + // matching every other backend's budget expiry. + Ending::Deadline => { + finish(child, rx, (reader, stderr_reader)); + if !state.saw_protocol { + let tail = stderr_tail.lock().unwrap().join("\n "); + let tail = if tail.is_empty() { + String::new() + } else { + format!("\n {tail}") + }; + return Err(ToolError(format!( + "gargantuan never spoke the protocol within suite \"{}\"'s budget ({}s) — \ + the bundle likely failed to load (the engine cannot exit on its own, so a \ + load failure looks like silence); bundle kept at {}{tail}", + plan.name, + overall.as_secs(), + script_path.display() + ))); + } + let spec = state.current_spec.map(|i| plan.specs[i].as_path()); + let path = spec + .map(|p| display_rel(p, &plan.root)) + .unwrap_or_else(|| plan.name.clone()); + let event = Event::TestFail { + path: vec![path], + name: "(timeout)".to_string(), + duration_ms: overall.as_millis() as f64, + failure: Failure::Error { + message: format!( + "gargantuan exceeded suite \"{}\"'s budget ({}s) and was killed — a \ + hung test, or an engine that stopped stepping scripts", + plan.name, + overall.as_secs() + ), + trace: None, + }, + origin: None, + }; + on_event(spec, &event); + return Ok(()); + } + // The process exited before the done marker — the one ending the + // engine can produce on its own, and it always means failure: a + // launch that died at boot, a bundle the engine could not load + // (exit 1 with a Critical stderr line), or a crash mid-suite. + Ending::Eof => { + let _ = reader.join(); + let status = wait_bounded(&mut child, deadline); + let _ = stderr_reader.join(); + let status_text = status + .map(|s| s.to_string()) + .unwrap_or_else(|| "killed".into()); + let tail = stderr_tail.lock().unwrap().join("\n "); + let tail = if tail.is_empty() { + String::new() + } else { + format!("\n {tail}") + }; + if state.outcomes == 0 { + if state.saw_protocol { + return Err(ToolError(format!( + "gargantuan ({status_text}) died mid-suite before any test finished \ + in \"{}\" — bundle kept at {}{tail}", + plan.name, + script_path.display() + ))); + } + return Err(ToolError(format!( + "gargantuan exited ({status_text}) without running suite \"{}\" — the \ + launch or the bundle load failed; bundle kept at {}{tail}", + plan.name, + script_path.display() + ))); + } + // Partial results then death: report it against the spec that + // was running, keep what streamed. + let spec = state.current_spec.map(|i| plan.specs[i].as_path()); + let path = spec + .map(|p| display_rel(p, &plan.root)) + .unwrap_or_else(|| plan.name.clone()); + let event = Event::TestFail { + path: vec![path], + name: "(aborted)".to_string(), + duration_ms: 0.0, + failure: Failure::Error { + message: format!( + "gargantuan exited ({status_text}) before suite \"{}\" finished — \ + bundle kept at {}{tail}", + plan.name, + script_path.display() + ), + trace: None, + }, + origin: None, + }; + on_event(spec, &event); + return Ok(()); + } + } + + // The same false-green guard every backend carries, disarmed under a + // name filter (which legitimately selects zero tests). + if state.outcomes == 0 && !plan.specs.is_empty() && plan.name_filter.is_none() { + return Err(ToolError(format!( + "gargantuan ran {} spec file(s) for suite \"{}\" but produced no test outcomes — \ + bundle kept at {}", + plan.specs.len(), + plan.name, + script_path.display() + ))); + } + + // Success: the generated bundle is noise now. + let _ = std::fs::remove_file(&script_path); + Ok(()) +} + +/// How the stream loop ended. +enum Ending { + /// The done marker arrived: the suite completed. + Done, + /// The overall budget expired first. + Deadline, + /// stdout closed before the done marker: the process died. + Eof, +} + +/// Waits for the child, bounded by the run's own deadline — an engine that +/// closed stdout but wedged instead of exiting must not hang the CLI. +fn wait_bounded(child: &mut Child, deadline: Instant) -> Option { + loop { + match child.try_wait() { + Ok(Some(status)) => return Some(status), + Ok(None) if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + Ok(None) => std::thread::sleep(Duration::from_millis(25)), + Err(_) => return None, + } + } +} + +/// What one fed line meant for the run. +#[derive(Debug, PartialEq, Eq)] +enum Feed { + Continue, + /// The done marker: stop reading and kill the engine. + Done, +} + +/// Decodes live stdout lines into protocol events, tracking the state the +/// run's ending needs. Split from `run` so the rules — boundary mapping, +/// done-marker completion, chatter suppression, outcome counting — are +/// testable without an engine binary on the machine. +struct StreamState<'p> { + plan: &'p SuitePlan, + outcomes: usize, + saw_protocol: bool, + current_spec: Option, +} + +impl<'p> StreamState<'p> { + fn new(plan: &'p SuitePlan) -> Self { + StreamState { + plan, + outcomes: 0, + saw_protocol: false, + current_spec: None, + } + } + + fn feed(&mut self, line: &str, on_event: &mut EventSink) -> Result { + if is_done_framed(line) { + return Ok(Feed::Done); + } + match classify(line) { + Decoded::SpecBoundary { leading, index } => { + passthrough(leading); + self.saw_protocol = true; + let raw = index.trim(); + let resolved = raw + .parse::() + .ok() + .and_then(|one_based| one_based.checked_sub(1)) + .filter(|&i| i < self.plan.specs.len()); + match resolved { + Some(index) => { + self.current_spec = Some(index); + Ok(Feed::Continue) + } + None => Err(ToolError(format!( + "gargantuan sent the spec-boundary marker \"{raw}\", which is not a \ + 1-based index into suite \"{}\"'s {} spec file(s) — the bundle and \ + the CLI disagree about the spec list", + self.plan.name, + self.plan.specs.len() + ))), + } + } + Decoded::Event { leading, json } => { + passthrough(leading); + self.saw_protocol = true; + let event = serde_json::from_str::(json).map_err(|err| { + ToolError(format!( + "undecodable protocol line from gargantuan while running suite \ + \"{}\": {err}", + self.plan.name + )) + })?; + if let Event::RunStart { + protocol_version, .. + } = event + { + check_protocol_version(protocol_version).map_err(|mismatch| { + ToolError(format!( + "framework/CLI protocol mismatch from gargantuan: {mismatch}" + )) + })?; + } + if matches!( + event, + Event::TestPass { .. } | Event::TestFail { .. } | Event::TestSkip { .. } + ) { + self.outcomes += 1; + } + let spec = self.current_spec.map(|i| self.plan.specs[i].as_path()); + on_event(spec, &event); + Ok(Feed::Continue) + } + Decoded::Output => { + // The engine's own boot chatter logs to stdout before the + // suite runs (`Gargantuan[Info] …`); the first boundary + // marker is where test output becomes possible. Echo only + // from there, like the studio decoder does. + if self.saw_protocol { + println!("{line}"); + } + Ok(Feed::Continue) + } + } + } +} + +/// Resolves the engine binary: the `[gargantuan] binary` path when set (and +/// checked to exist, so a typo fails as config rather than as a spawn), or +/// the bare name for a PATH lookup. +fn gargantuan_executable(plan: &SuitePlan) -> Result { + match &plan.gargantuan_binary { + Some(path) => { + if path.is_file() { + Ok(path.clone()) + } else { + Err(ToolError(format!( + "the configured [gargantuan] binary does not exist: {}", + path.display() + ))) + } + } + None => Ok(PathBuf::from("gargantuan")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::{Path, PathBuf}; + + fn plan() -> SuitePlan { + SuitePlan { + name: "engine-gg".into(), + specs: vec![PathBuf::from("a.spec.luau"), PathBuf::from("b.spec.luau")], + root: PathBuf::from("."), + core_entry: PathBuf::from("core/init.luau"), + timeout: Duration::from_secs(5), + workers: 0, + name_filter: None, + coverage: false, + rojo_project: None, + studio_executable: None, + gargantuan_binary: None, + } + } + + /// One recorded sink call: the spec attribution and an event kind tag. + type Seen = (Option, &'static str); + + /// Feeds lines and records (spec attribution, event kind tag) pairs plus + /// the final feed result. + fn feed_all(state: &mut StreamState, lines: &[&str]) -> Result<(Vec, Feed), ToolError> { + let mut seen = Vec::new(); + let mut last = Feed::Continue; + for line in lines { + let mut sink = |spec: Option<&Path>, event: &Event| { + let tag = match event { + Event::RunStart { .. } => "run_start", + Event::TestPass { .. } => "test_pass", + Event::TestFail { .. } => "test_fail", + _ => "other", + }; + seen.push((spec.map(Path::to_path_buf), tag)); + }; + last = state.feed(line, &mut sink)?; + } + Ok((seen, last)) + } + + #[test] + fn boundaries_attribute_events_and_done_stops_the_stream() { + let plan = plan(); + let mut state = StreamState::new(&plan); + let (seen, last) = feed_all( + &mut state, + &[ + "Gargantuan[Info] Constructed engine", + "@@LEST_SPEC@@1", + r#"@@LEST@@{"kind":"test_pass","path":[],"name":"a","durationMs":1}"#, + "@@LEST_SPEC@@2", + r#"@@LEST@@{"kind":"test_fail","path":[],"name":"b","durationMs":1,"failure":{"type":"error","message":"x"}}"#, + "@@LEST_STUDIO_DONE@@", + ], + ) + .expect("feed"); + assert_eq!( + seen, + vec![ + (Some(PathBuf::from("a.spec.luau")), "test_pass"), + (Some(PathBuf::from("b.spec.luau")), "test_fail"), + ] + ); + assert_eq!(last, Feed::Done); + assert_eq!(state.outcomes, 2); + assert!(state.saw_protocol); + } + + #[test] + fn a_done_marker_inside_a_payload_is_not_completion() { + let plan = plan(); + let mut state = StreamState::new(&plan); + let (seen, last) = feed_all( + &mut state, + &[r#"@@LEST@@{"kind":"test_fail","path":[],"name":"has @@LEST_STUDIO_DONE@@ inside","durationMs":1,"failure":{"type":"error","message":"x"}}"#], + ) + .expect("feed"); + assert_eq!(seen.len(), 1); + assert_eq!(last, Feed::Continue); + } + + #[test] + fn a_bad_boundary_is_a_tool_error() { + let plan = plan(); + let mut state = StreamState::new(&plan); + let err = feed_all(&mut state, &["@@LEST_SPEC@@9"]).expect_err("must fail"); + assert!(err.to_string().contains("spec-boundary marker")); + } + + #[test] + fn undecodable_json_and_protocol_mismatch_abort() { + let plan = plan(); + let mut state = StreamState::new(&plan); + let err = feed_all(&mut state, &["@@LEST@@{not json"]).expect_err("must fail"); + assert!(err.to_string().contains("undecodable protocol line")); + + let mut state = StreamState::new(&plan); + let err = feed_all( + &mut state, + &[r#"@@LEST@@{"kind":"run_start","specCount":1,"protocolVersion":99}"#], + ) + .expect_err("must fail"); + assert!(err.to_string().contains("protocol mismatch")); + } + + #[test] + fn a_configured_binary_that_does_not_exist_is_a_config_error() { + let mut plan = plan(); + plan.gargantuan_binary = Some(PathBuf::from("definitely/not/here/gargantuan.exe")); + let err = gargantuan_executable(&plan).expect_err("must fail"); + assert!(err.to_string().contains("does not exist")); + + let unset = self::plan(); + assert_eq!( + gargantuan_executable(&unset).expect("PATH name"), + PathBuf::from("gargantuan") + ); + } +} diff --git a/src/backend/mod.rs b/src/backend/mod.rs index ab900e9..c08e80d 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -4,6 +4,7 @@ use std::time::Duration; use crate::report::{Event, Failure}; pub mod cloud; +pub mod gargantuan; pub mod native; pub mod runtime; pub mod studio; @@ -41,6 +42,11 @@ pub struct SuitePlan { /// `[studio] executable` — the Roblox Studio binary, for non-standard /// installs. Only the studio backend consults it. pub studio_executable: Option, + /// `[gargantuan] binary` — the Gargantuan engine executable. The engine + /// has no releases yet, so most projects must point this at a local + /// build; `None` falls back to `gargantuan` on PATH. Only the gargantuan + /// backend consults it. + pub gargantuan_binary: Option, } /// Root-relative display form of a spec path, with forward slashes. diff --git a/src/backend/native.rs b/src/backend/native.rs index edf0dde..5f8e57a 100644 --- a/src/backend/native.rs +++ b/src/backend/native.rs @@ -622,6 +622,7 @@ mod tests { coverage: true, rojo_project: None, studio_executable: None, + gargantuan_binary: None, }; // On case-insensitive hosts, hand the loader a deliberately mangled // spelling — the filesystem still finds the file, and attribution must @@ -683,6 +684,7 @@ mod tests { coverage: false, rojo_project: None, studio_executable: None, + gargantuan_binary: None, }; let (events, _coverage) = run_spec_file(&plan, &spec).unwrap(); // The cycle surfaces as the spec's load failure — and it must be diff --git a/src/backend/runtime.rs b/src/backend/runtime.rs index 67dc748..4694330 100644 --- a/src/backend/runtime.rs +++ b/src/backend/runtime.rs @@ -122,6 +122,25 @@ pub(crate) fn passthrough(leading: &str) { } } +/// The done marker only *frames* a line when no event marker precedes it: a +/// legitimate event payload (a snapshot's text, a failure message) may +/// contain the marker characters, and dropping that line would lose a real +/// verdict. The first-marker-wins rule from `classify`, applied to the done +/// marker. Shared by the studio and gargantuan backends, whose runs end on +/// this marker rather than on a process exit. +pub(crate) fn is_done_framed(line: &str) -> bool { + match line.find(DONE_SENTINEL) { + None => false, + Some(done_at) => { + let framing = [line.find(SENTINEL), line.find(SPEC_SENTINEL)] + .into_iter() + .flatten() + .min(); + framing.is_none_or(|other_at| done_at < other_at) + } + } +} + /// Reports an exhausted process budget as a *test* failure (exit 1), not a /// tool error (exit 2) — the same call native makes when its interrupt fires. /// Shared by the mid-stream deadline check and the post-EOF wait, so both diff --git a/src/backend/studio.rs b/src/backend/studio.rs index 7cac633..3c97e0f 100644 --- a/src/backend/studio.rs +++ b/src/backend/studio.rs @@ -30,7 +30,7 @@ use std::time::{Duration, Instant}; use crate::backend::cloud::bundle::{self, BundleInput, Head, SpecEntry}; use crate::backend::runtime::{ - classify, passthrough, Decoded, DONE_SENTINEL, SENTINEL, SPEC_SENTINEL, + classify, is_done_framed, passthrough, Decoded, DONE_SENTINEL, SENTINEL, SPEC_SENTINEL, }; use crate::backend::{display_rel, EventSink, SuitePlan}; use crate::config::PlaceTarget; @@ -475,23 +475,6 @@ fn unwrap_csv_field(line: &str) -> String { out } -/// The done marker only *frames* a line when no event marker precedes it: a -/// legitimate event payload (a snapshot's text, a failure message) may -/// contain the marker characters, and dropping that line would lose a real -/// verdict. The first-marker-wins rule from `classify`, applied here. -fn is_done_framed(line: &str) -> bool { - match line.find(DONE_SENTINEL) { - None => false, - Some(done_at) => { - let framing = [line.find(SENTINEL), line.find(SPEC_SENTINEL)] - .into_iter() - .flatten() - .min(); - framing.is_none_or(|other_at| done_at < other_at) - } - } -} - /// Decodes output-file sentinel lines into protocol events, tracking the /// state the run outcome needs afterward. Split from `run` so the decode /// rules — boundary mapping, the done-framing skip, protocol validation, @@ -615,6 +598,7 @@ mod tests { coverage: false, rojo_project: None, studio_executable: None, + gargantuan_binary: None, } } diff --git a/src/config.rs b/src/config.rs index d2407f1..3a2c2d7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,6 +19,9 @@ pub enum BackendKind { Cloud, /// Roblox Studio, launched per run via its official CLI. Studio, + /// The Gargantuan engine, spawned headless per run. Experimental — the + /// engine itself is pre-release. + Gargantuan, } impl fmt::Display for BackendKind { @@ -29,6 +32,7 @@ impl fmt::Display for BackendKind { BackendKind::Lute => "lute", BackendKind::Cloud => "cloud", BackendKind::Studio => "studio", + BackendKind::Gargantuan => "gargantuan", }; f.write_str(name) } @@ -57,6 +61,8 @@ struct RawConfig { place: RawPlace, #[serde(default)] studio: RawStudio, + #[serde(default)] + gargantuan: RawGargantuan, } /// The `[place]` table: the Roblox place engine suites run in, agnostic of @@ -81,6 +87,14 @@ struct RawStudio { executable: Option, } +/// The `[gargantuan]` table: settings for spawning the Gargantuan engine. +/// Only the binary path today — the engine has no releases, so most projects +/// point this at a local build. +#[derive(Debug, Default, Deserialize)] +struct RawGargantuan { + binary: Option, +} + /// Open Cloud target for cloud-backend suites. `universe_id`/`place_id` are /// non-secret Roblox identifiers and belong in config; the API key never does /// (it is read from the environment). Numbers are accepted as TOML integers or @@ -195,6 +209,9 @@ pub struct Config { /// `[studio] executable` — a path to the Roblox Studio binary, for /// non-standard installs. `None` means the platform default location. pub studio_executable: Option, + /// `[gargantuan] binary` — a path to the Gargantuan engine executable. + /// `None` means `gargantuan` on PATH. + pub gargantuan_binary: Option, /// The `lest.toml` this config was read from, or `None` in zero-config /// mode. Carried so callers can point at the real file (watch mode watches /// it by identity; the empty-discovery message only mentions a config file @@ -389,7 +406,14 @@ fn unknown_keys(text: &str) -> Vec { // list (with its collect calls below) by hand, or removed spellings // will be silently ignored with no warning at all. const TOP: &[&str] = &[ - "backend", "suites", "settings", "coverage", "cloud", "place", "studio", + "backend", + "suites", + "settings", + "coverage", + "cloud", + "place", + "studio", + "gargantuan", ]; const SUITE: &[&str] = &["include", "backend", "default", "cloud", "place"]; const SETTINGS: &[&str] = &["backend", "timeout_ms", "workers", "rojo", "core"]; @@ -397,6 +421,7 @@ fn unknown_keys(text: &str) -> Vec { const CLOUD: &[&str] = &["universe_id", "place_id", "place_file"]; const PLACE: &[&str] = &["universe_id", "place_id", "file", "rojo"]; const STUDIO: &[&str] = &["executable"]; + const GARGANTUAN: &[&str] = &["binary"]; fn collect(prefix: &str, table: &toml::Table, known: &[&str], out: &mut Vec) { for key in table.keys() { @@ -433,6 +458,9 @@ fn unknown_keys(text: &str) -> Vec { if let Some(studio) = table(root, "studio") { collect("studio.", studio, STUDIO, &mut out); } + if let Some(gargantuan) = table(root, "gargantuan") { + collect("gargantuan.", gargantuan, GARGANTUAN, &mut out); + } if let Some(suites) = table(root, "suites") { for (name, suite) in suites { let Some(suite) = suite.as_table() else { @@ -563,6 +591,7 @@ fn resolve_raw(raw: RawConfig) -> Result { core: raw.settings.core, coverage, studio_executable: raw.studio.executable, + gargantuan_binary: raw.gargantuan.binary, // Filled in by `load`, which is the only place that knows the path. file: None, }) @@ -988,6 +1017,46 @@ mod tests { assert_eq!(config.studio_executable, None); } + #[test] + fn gargantuan_backend_and_binary_are_parsed() { + let config = parse( + r#" + [suites.engine-gg] + include = ["tests/gg/**/*.spec.luau"] + backend = "gargantuan" + default = false + + [gargantuan] + binary = "vendor/gargantuan/build/gargantuan" + "#, + ); + assert_eq!(config.suites[0].backend, BackendKind::Gargantuan); + assert!(!config.suites[0].default_enabled); + assert_eq!( + config.gargantuan_binary.as_deref(), + Some("vendor/gargantuan/build/gargantuan") + ); + let config = parse( + r#" + [suites.unit] + include = ["src/**/*.spec.luau"] + "#, + ); + assert_eq!(config.gargantuan_binary, None); + } + + #[test] + fn gargantuan_table_keys_are_checked() { + let found = unknown_keys( + r#" + [gargantuan] + binary = "x" + executable = "y" + "#, + ); + assert_eq!(found, vec!["gargantuan.executable".to_string()]); + } + #[test] fn studio_table_keys_are_checked() { let found = unknown_keys( diff --git a/src/init.rs b/src/init.rs index 6a81271..e9a3851 100644 --- a/src/init.rs +++ b/src/init.rs @@ -577,6 +577,25 @@ fn ensure_luaurc_alias(cwd: &Path) -> Result { ); }; + // A project on `.config.luau` must not gain a sibling `.luaurc`: the Luau + // config RFC makes the pair an error, so writing one would break every + // conforming tool (lest's own resolver included). The file is real Luau — + // not something to rewrite programmatically — so print the line to paste. + let config_luau = cwd.join(".config.luau"); + if config_luau.is_file() { + println!( + "Left alias config alone — this project uses .config.luau, which lest will not \ + rewrite (and adding a .luaurc beside it is an error per the Luau config RFC)." + ); + println!( + "Add this under `luau.aliases` in {} yourself to require('@lest'):\n \ + lest = '{}'", + config_luau.display(), + embed::CORE_DIR + ); + return Ok(false); + } + let existing = match std::fs::read_to_string(&path) { Ok(text) => text, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { @@ -734,6 +753,17 @@ mod tests { std::fs::read_to_string(temp.path().join(".luaurc")).unwrap() } + #[test] + fn a_config_luau_project_never_gains_a_luaurc() { + // The Luau config RFC makes `.luaurc` + `.config.luau` in one + // directory an error, so init must print-and-decline rather than + // create the forbidden pair. + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join(".config.luau"), "return { luau = {} }\n").unwrap(); + assert!(!ensure_luaurc_alias(temp.path()).unwrap()); + assert!(!temp.path().join(".luaurc").exists()); + } + #[test] fn luaurc_alias_is_created_when_absent() { let temp = tempfile::tempdir().unwrap(); diff --git a/src/main.rs b/src/main.rs index 1860eb0..df197f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -813,6 +813,7 @@ pub fn run_suites_with( coverage: params.coverage && suite.backend == BackendKind::Native, rojo_project: suite.place.rojo.as_ref().map(|project| root.join(project)), studio_executable: config.studio_executable.as_ref().map(|exe| root.join(exe)), + gargantuan_binary: config.gargantuan_binary.as_ref().map(|exe| root.join(exe)), }; let mut suite_totals = Totals::default(); @@ -936,6 +937,7 @@ pub fn run_suites_with( } BackendKind::Cloud => backend::cloud::run(&plan, &suite.place, &mut sink), BackendKind::Studio => backend::studio::run(&plan, &suite.place, &mut sink), + BackendKind::Gargantuan => backend::gargantuan::run(&plan, &mut sink), } }; @@ -1039,7 +1041,15 @@ pub fn select_suites( config .suites .iter() - .filter(|suite| suite.default_enabled || (ci && suite.backend != BackendKind::Studio)) + // Studio needs a GUI session; gargantuan needs a locally built + // engine binary. Neither exists on a CI runner, so `$CI` + // auto-enable skips both — explicit naming still runs them. + .filter(|suite| { + suite.default_enabled + || (ci + && suite.backend != BackendKind::Studio + && suite.backend != BackendKind::Gargantuan) + }) .cloned() .collect() } else { diff --git a/src/resolve/mod.rs b/src/resolve/mod.rs index 9b4eb28..170f7cc 100644 --- a/src/resolve/mod.rs +++ b/src/resolve/mod.rs @@ -6,7 +6,10 @@ //! builtins `@lune/*` and `@lute/*`, which are never resolved to disk — the //! native backend refuses them with a pointer to the right backend, and the //! spawned backends pass them straight through to the real runtime. -//! `.luaurc` aliases and `@self` requires resolve here too. Rojo project +//! Aliases resolve here too, from either config format the Luau ecosystem +//! uses: `.luaurc` (JSONC-lenient) or `.config.luau` (a Luau file returning +//! the config table, per the accepted RFC, evaluated in an isolated embedded +//! VM). `@self` requires as well. Rojo project //! mapping (phase 4, [`VirtualDataModel`]) turns a `default.project.json` into //! a bidirectional filesystem ↔ DataModel map for the cloud backend; the pesde //! lockfile walk arrives in a later phase. @@ -73,7 +76,7 @@ pub enum ResolveError { spec: String, }, /// An `@` alias that is neither a runtime builtin, `@self`, nor defined - /// in any `.luaurc` up the directory tree. + /// in any `.luaurc` or `.config.luau` up the directory tree. UnknownAlias { spec: String, }, @@ -83,8 +86,10 @@ pub enum ResolveError { InvalidSelf { spec: String, }, - /// A `.luaurc` on the lookup path could not be read or parsed. - Luaurc { + /// An alias config (`.luaurc` or `.config.luau`) on the lookup path could + /// not be read, parsed, or evaluated — or a directory holds both, which + /// the Luau config RFC forbids. + AliasConfig { path: PathBuf, message: String, }, @@ -108,13 +113,13 @@ impl fmt::Display for ResolveError { ), ResolveError::UnknownAlias { spec } => write!( f, - "cannot resolve require(\"{spec}\"): unknown alias — not a runtime builtin (@lune/*, @lute/*), not @self, and no `.luaurc` up the tree defines it" + "cannot resolve require(\"{spec}\"): unknown alias — not a runtime builtin (@lune/*, @lute/*), not @self, and no `.luaurc` or `.config.luau` up the tree defines it" ), ResolveError::InvalidSelf { spec } => write!( f, "cannot resolve require(\"{spec}\"): `@self` is only valid as `@self/` from within an init module" ), - ResolveError::Luaurc { path, message } => write!( + ResolveError::AliasConfig { path, message } => write!( f, "cannot read aliases from {}: {message}", path.display() @@ -160,11 +165,12 @@ pub fn resolve(requiring_file: &Path, spec: &str) -> Result, ResolveError>>; +/// The parsed alias table of one directory's config (`.luaurc` or +/// `.config.luau`): alias names lowercased for the RFC's case-insensitive +/// matching. `None` means the directory has neither file; a parse or +/// evaluation failure is memoized too, so a broken file is read once and +/// reported on every resolution that reaches it. +type DirAliases = Option, ResolveError>>; /// Memoized resolution state, scoped to one "run" of resolution work: a native /// VM's lifetime, one dependency-graph build, one cloud bundle. @@ -178,8 +184,8 @@ type LuaurcAliases = Option, ResolveError>>; /// fresh resolvers — when a `.luaurc` changes). #[derive(Debug, Default)] pub struct Resolver { - /// Normalized directory → its `.luaurc` alias table (see [`LuaurcAliases`]). - luaurc: RefCell>, + /// Normalized directory → its alias table (see [`DirAliases`]). + aliases: RefCell>, /// Queried path → its canonical cache key, memoizing the /// `fs::canonicalize` syscall behind [`cache_key_path`]. canonical: RefCell>, @@ -224,9 +230,9 @@ impl Resolver { resolve_target(spec, &target) } - /// `.luaurc` aliases: walks up from the requiring file's directory; the - /// nearest `.luaurc` defining the alias wins, and its value is a path - /// relative to that `.luaurc`'s own directory (per the require-by-string + /// Config aliases: walks up from the requiring file's directory; the + /// nearest config defining the alias wins, and its value is a path + /// relative to that config's own directory (per the require-by-string /// RFC). Missing keys fall through to ancestor configs by continuing the /// walk. Alias names are matched case-insensitively (the RFC treats them /// so). @@ -254,15 +260,29 @@ impl Resolver { }) } - /// Looks `alias` up in `dir`'s `.luaurc`, reading and parsing the file at - /// most once per resolver lifetime. `Ok(None)` means "keep walking up": - /// either no `.luaurc` here, or one that does not define the alias. + /// Looks `alias` up in `dir`'s config (`.luaurc` or `.config.luau`), + /// reading and parsing (or evaluating) the file at most once per resolver + /// lifetime. `Ok(None)` means "keep walking up": either no config here, + /// or one that does not define the alias. A directory holding both files + /// is an error per the Luau config RFC — silently preferring one would + /// have lest resolve differently from every RFC-conforming tool. fn alias_in(&self, dir: &Path, alias: &str) -> Result, ResolveError> { let key = normalize(dir); - let mut cache = self.luaurc.borrow_mut(); + let mut cache = self.aliases.borrow_mut(); let entry = cache.entry(key).or_insert_with(|| { let luaurc = dir.join(".luaurc"); - luaurc.is_file().then(|| parse_luaurc_aliases(&luaurc)) + let config_luau = dir.join(".config.luau"); + match (luaurc.is_file(), config_luau.is_file()) { + (false, false) => None, + (true, true) => Some(Err(ResolveError::AliasConfig { + path: config_luau, + message: "this directory holds both `.luaurc` and `.config.luau`, which \ + the Luau config RFC forbids — keep one" + .to_string(), + })), + (true, false) => Some(parse_luaurc_aliases(&luaurc)), + (false, true) => Some(cached_config_luau_aliases(&config_luau)), + } }); match entry { None => Ok(None), @@ -321,12 +341,12 @@ fn resolve_self(requiring_file: &Path, spec: &str) -> Result Result, ResolveError> { - let text = std::fs::read_to_string(path).map_err(|e| ResolveError::Luaurc { + let text = std::fs::read_to_string(path).map_err(|e| ResolveError::AliasConfig { path: path.to_path_buf(), message: e.to_string(), })?; let value: serde_json::Value = - serde_json::from_str(&sanitize_luaurc(&text)).map_err(|e| ResolveError::Luaurc { + serde_json::from_str(&sanitize_luaurc(&text)).map_err(|e| ResolveError::AliasConfig { path: path.to_path_buf(), message: e.to_string(), })?; @@ -343,6 +363,153 @@ fn parse_luaurc_aliases(path: &Path) -> Result, ResolveE Ok(aliases) } +/// The RFC's default budget for evaluating a `.config.luau`. Generous for a +/// config file; the ceiling exists because the format is real Luau and a +/// stray `while true do end` must not hang every resolution forever. +const CONFIG_LUAU_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); + +/// [`eval_config_luau_aliases`] behind a process-wide memo keyed by +/// normalized path and invalidated by mtime. Resolvers are deliberately +/// short-lived (native builds one per spec VM), which is fine for reading +/// `.luaurc` but would boot a config VM per spec per directory here — and a +/// config that exhausts its time budget would then cost that budget times +/// the spec count. The mtime check keeps watch mode honest: an edited config +/// re-evaluates on the next pass, which builds fresh Resolvers but shares +/// this cache. +fn cached_config_luau_aliases(path: &Path) -> Result, ResolveError> { + use std::sync::{LazyLock, Mutex}; + use std::time::SystemTime; + + type Cache = HashMap< + PathBuf, + ( + Option, + Result, ResolveError>, + ), + >; + static CACHE: LazyLock> = LazyLock::new(Default::default); + + let key = normalize(path); + let mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok(); + let mut cache = CACHE.lock().unwrap(); + if let Some((cached_mtime, result)) = cache.get(&key) { + if *cached_mtime == mtime { + return result.clone(); + } + } + let result = eval_config_luau_aliases(path, CONFIG_LUAU_TIMEOUT); + cache.insert(key, (mtime, result.clone())); + result +} + +/// Evaluates one `.config.luau` and extracts its alias table +/// (`config.luau.aliases`, per the Luau config RFC), lowercasing alias names +/// for the same case-insensitive matching `.luaurc` gets. +/// +/// The RFC's evaluation model, followed here: the file is real Luau, run "in +/// an isolated Luau VM with only the standard libraries enabled" — so +/// computed configs work, but there is no `require`, no filesystem, and no +/// escape hatch. The VM is fresh per file, sandboxed, memory-capped, and +/// interrupt-bounded by `timeout`; it is discarded as soon as the table is +/// read. Evaluating project-authored code at resolution time is not a new +/// trust boundary — lest exists to run the project's own test code. +fn eval_config_luau_aliases( + path: &Path, + timeout: std::time::Duration, +) -> Result, ResolveError> { + use std::time::Instant; + + let fail = |message: String| ResolveError::AliasConfig { + path: path.to_path_buf(), + message, + }; + + let text = std::fs::read_to_string(path).map_err(|e| fail(e.to_string()))?; + // Windows editors love UTF-8 BOMs; the Luau parser does not — same guard + // module sources get in the native backend. + let source = text.strip_prefix('\u{feff}').unwrap_or(&text); + let lua = mlua::Lua::new(); + lua.sandbox(true).map_err(|e| fail(e.to_string()))?; + lua.set_memory_limit(64 * 1024 * 1024) + .map_err(|e| fail(e.to_string()))?; + let deadline = Instant::now() + timeout; + lua.set_interrupt(move |_| { + if Instant::now() >= deadline { + return Err(mlua::Error::RuntimeError( + "config evaluation exceeded its time budget".to_string(), + )); + } + Ok(mlua::VmState::Continue) + }); + + // `@` marks the chunk name as a filename, so Luau renders positions as + // `path:line:` and truncates long names at the head, keeping the file + // name visible. The traceback is trimmed off the message: this error + // reaches one-line diagnostics, and the trace is all inside the config + // file the message already names. + let value: mlua::Value = lua + .load(source) + .set_name(format!("@{}", path.display())) + .eval() + .map_err(|e| { + let rendered = e.to_string(); + let message = rendered + .split("\nstack traceback:") + .next() + .unwrap_or(&rendered); + fail(message.trim_end().to_string()) + })?; + let mlua::Value::Table(config) = value else { + return Err(fail( + "the file must return a table (`return { luau = { aliases = { … } } }`)".to_string(), + )); + }; + + // Absent keys are valid configs with no aliases; wrongly-typed keys are + // authoring mistakes worth naming, not silently empty alias sets. + let mut aliases = HashMap::new(); + let luau: mlua::Value = config.get("luau").map_err(|e| fail(e.to_string()))?; + let table = match luau { + mlua::Value::Nil => return Ok(aliases), + mlua::Value::Table(table) => table, + other => { + return Err(fail(format!( + "the `luau` key must be a table, not {}", + other.type_name() + ))); + } + }; + let raw: mlua::Value = table.get("aliases").map_err(|e| fail(e.to_string()))?; + let map = match raw { + mlua::Value::Nil => return Ok(aliases), + mlua::Value::Table(map) => map, + other => { + return Err(fail(format!( + "`luau.aliases` must be a table of string → string, not {}", + other.type_name() + ))); + } + }; + let mut pairs: Vec<(String, String)> = Vec::new(); + map.for_each(|name: mlua::Value, target: mlua::Value| { + if let (mlua::Value::String(name), mlua::Value::String(target)) = (name, target) { + pairs.push((name.to_string_lossy(), target.to_string_lossy())); + } + Ok(()) + }) + .map_err(|e| fail(e.to_string()))?; + // Luau table iteration is hash-ordered, so two names folding to the same + // lowercase key would otherwise pick an arbitrary (if stable) winner. + // Sorting first makes the rule deterministic and statable: the + // lexicographically first original spelling wins. (`.luaurc` keeps + // document order; a Luau table has no document order to keep.) + pairs.sort(); + for (name, target) in pairs { + aliases.entry(name.to_lowercase()).or_insert(target); + } + Ok(aliases) +} + /// Probes a resolved target path for an on-disk module. An explicit `.luau`/ /// `.lua` extension is accepted directly; otherwise the `init`/extension /// candidates are tried in precedence order. Both the relative and alias diff --git a/src/resolve/tests.rs b/src/resolve/tests.rs index f586ac0..b0cc0d5 100644 --- a/src/resolve/tests.rs +++ b/src/resolve/tests.rs @@ -2,8 +2,8 @@ use std::fs; use std::path::{Path, PathBuf}; use super::{ - builtin_runtime, cache_key, cache_key_path, content_hash, hash_bytes, normalize, resolve, - DependencyGraph, ResolveError, Resolved, Runtime, + builtin_runtime, cache_key, cache_key_path, content_hash, eval_config_luau_aliases, hash_bytes, + normalize, resolve, DependencyGraph, ResolveError, Resolved, Runtime, }; /// Writes `contents` to `rel` under `dir`, creating parent directories. @@ -402,8 +402,156 @@ fn malformed_luaurc_reports_error() { let dir = tree(&["main.luau"]); write(dir.path(), ".luaurc", "{ this is not json ]"); match resolve(&dir.path().join("main.luau"), "@util") { - Err(ResolveError::Luaurc { .. }) => {} - other => panic!("expected a Luaurc parse error, got {other:?}"), + Err(ResolveError::AliasConfig { .. }) => {} + other => panic!("expected an AliasConfig parse error, got {other:?}"), + } +} + +// ── .config.luau: the RFC's Luau-syntax config, evaluated in an embedded VM ── + +#[test] +fn config_luau_alias_resolves() { + let dir = tree(&["libs/util.luau", "src/mod.luau"]); + write( + dir.path(), + ".config.luau", + "return { luau = { aliases = { util = './libs/util' } } }\n", + ); + assert_resolves(dir.path(), "src/mod.luau", "@util", "libs/util.luau"); +} + +#[test] +fn config_luau_aliases_match_case_insensitively() { + let dir = tree(&["libs/util.luau", "src/mod.luau"]); + write( + dir.path(), + ".config.luau", + "return { luau = { aliases = { Util = './libs/util' } } }\n", + ); + assert_resolves(dir.path(), "src/mod.luau", "@UTIL", "libs/util.luau"); +} + +#[test] +fn config_luau_may_compute_its_aliases() { + // The RFC allows real Luau — variables, loops, functions — so a computed + // config must evaluate, not merely parse. + let dir = tree(&["libs/util.luau", "src/mod.luau"]); + write( + dir.path(), + ".config.luau", + "local libs = './li' .. 'bs'\nlocal t = {}\nt.util = libs .. '/util'\n\ + return { luau = { aliases = t } }\n", + ); + assert_resolves(dir.path(), "src/mod.luau", "@util", "libs/util.luau"); +} + +#[test] +fn config_luau_nearest_wins_over_ancestor_luaurc() { + // Mixed formats up one tree: the nearer config wins per alias, exactly + // as two .luaurc files would. + let dir = tree(&["libs/near.luau", "libs/far.luau", "src/mod.luau"]); + write( + dir.path(), + "src/.config.luau", + "return { luau = { aliases = { util = '../libs/near' } } }\n", + ); + write( + dir.path(), + ".luaurc", + r#"{ "aliases": { "util": "libs/far" } }"#, + ); + assert_resolves(dir.path(), "src/mod.luau", "@util", "libs/near.luau"); +} + +#[test] +fn both_configs_in_one_directory_error() { + let dir = tree(&["libs/util.luau", "main.luau"]); + write( + dir.path(), + ".luaurc", + r#"{ "aliases": { "util": "libs/util" } }"#, + ); + write( + dir.path(), + ".config.luau", + "return { luau = { aliases = { util = './libs/util' } } }\n", + ); + match resolve(&dir.path().join("main.luau"), "@util") { + Err(ResolveError::AliasConfig { message, .. }) => { + assert!(message.contains("both"), "message was: {message}"); + } + other => panic!("expected the both-configs error, got {other:?}"), + } +} + +#[test] +fn config_luau_that_does_not_return_a_table_errors() { + let dir = tree(&["main.luau"]); + write(dir.path(), ".config.luau", "return 42\n"); + match resolve(&dir.path().join("main.luau"), "@util") { + Err(ResolveError::AliasConfig { message, .. }) => { + assert!(message.contains("return a table"), "message was: {message}"); + } + other => panic!("expected an AliasConfig error, got {other:?}"), + } +} + +#[test] +fn config_luau_with_a_syntax_error_reports_it() { + let dir = tree(&["main.luau"]); + write(dir.path(), ".config.luau", "return { luau = "); + match resolve(&dir.path().join("main.luau"), "@util") { + Err(ResolveError::AliasConfig { .. }) => {} + other => panic!("expected an AliasConfig error, got {other:?}"), + } +} + +#[test] +fn config_luau_without_aliases_falls_through_to_ancestors() { + let dir = tree(&["libs/util.luau", "src/mod.luau"]); + // A valid config with no aliases must not shadow the ancestor's table. + write( + dir.path(), + "src/.config.luau", + "return { luau = { languagemode = 'strict' } }\n", + ); + write( + dir.path(), + ".luaurc", + r#"{ "aliases": { "util": "libs/util" } }"#, + ); + assert_resolves(dir.path(), "src/mod.luau", "@util", "libs/util.luau"); +} + +#[test] +fn config_luau_case_collisions_resolve_deterministically() { + // Two spellings folding to one case-insensitive name: Luau tables have + // no document order, so the rule is "lexicographically first original + // spelling wins" — here `Util` (ASCII 'U' < 'u') over `util`. + let dir = tree(&["a.luau", "b.luau", "src/mod.luau"]); + write( + dir.path(), + ".config.luau", + "return { luau = { aliases = { util = './b', Util = './a' } } }\n", + ); + assert_resolves(dir.path(), "src/mod.luau", "@util", "a.luau"); +} + +#[test] +fn config_luau_that_never_finishes_hits_the_time_budget() { + // Direct call with a tiny budget: the resolve() path wires the RFC's 2s + // default, which would cost this suite two real seconds to demonstrate. + let dir = tree(&["main.luau"]); + write(dir.path(), ".config.luau", "while true do end\n"); + let result = eval_config_luau_aliases( + &dir.path().join(".config.luau"), + std::time::Duration::from_millis(50), + ); + match result { + Err(ResolveError::AliasConfig { message, .. }) => { + assert!(message.contains("budget"), "message was: {message}"); + } + other => panic!("expected the time-budget error, got {other:?}"), } } diff --git a/src/watch.rs b/src/watch.rs index 058d42c..a8dcc92 100644 --- a/src/watch.rs +++ b/src/watch.rs @@ -73,7 +73,7 @@ pub fn run( let mut selected = watchable(select_suites(&config, suite_names, backend_override)?); if selected.is_empty() { return Err(ToolError( - "nothing to watch — every selected suite uses a backend watch cannot re-run (cloud or studio)".to_string(), + "nothing to watch — every selected suite uses a backend watch cannot re-run (cloud, studio, or gargantuan)".to_string(), )); } @@ -208,15 +208,34 @@ pub fn run( "{}", render_warning( "nothing to watch — every selected suite now uses a backend watch cannot \ - re-run (cloud or studio); edit lest.toml to select a watchable suite", + re-run (cloud, studio, or gargantuan); edit lest.toml to select a \ + watchable suite", err_color, ) ); continue; } + // A changed alias config (`.luaurc` / `.config.luau`) redefines what + // requires resolve to, and config files are not nodes in the require + // graph — it cannot say which specs an alias edit reaches. Re-run + // everything rather than silently nothing. + let alias_config_changed = changed_set.iter().any(|path| { + matches!( + path.file_name().and_then(|n| n.to_str()), + Some(".luaurc" | ".config.luau") + ) + }); + let (pre_discovered, affected) = if config_changed { (None, None) // full re-run under the fresh config + } else if alias_config_changed { + print_note( + "an alias config changed — re-running everything, since any require may now \ + resolve differently", + err_color, + ); + (None, None) } else { match affected_specs(root, &selected, &changed_set) { Ok((_, affected)) if affected.is_empty() => { @@ -334,8 +353,14 @@ fn watchable(suites: Vec) -> Vec { suites .into_iter() // Cloud is excluded by physics (network round-trips per save); - // studio too — a full Studio boot per save is no loop at all. - .filter(|suite| suite.backend != BackendKind::Cloud && suite.backend != BackendKind::Studio) + // studio too — a full Studio boot per save is no loop at all — and + // gargantuan, whose engine boot and kill-based lifecycle are not a + // save-to-green loop either. + .filter(|suite| { + suite.backend != BackendKind::Cloud + && suite.backend != BackendKind::Studio + && suite.backend != BackendKind::Gargantuan + }) .collect() } @@ -399,9 +424,10 @@ fn is_interesting(path: &Path, root: &Path, names: &WatchNames) -> bool { if IGNORED_DIRS.contains(&name.as_ref()) { return false; } - // Hidden entries are ignored except .luaurc, which affects require - // resolution and therefore which tests a change reaches. - if name.starts_with('.') && name != ".luaurc" { + // Hidden entries are ignored except the alias configs (.luaurc and + // .config.luau), which affect require resolution and therefore which + // tests a change reaches. + if name.starts_with('.') && name != ".luaurc" && name != ".config.luau" { return false; } }