From fd8a441ceaf5e83bf6e83daff8bc2a6c0568ea43 Mon Sep 17 00:00:00 2001 From: cayasde Date: Mon, 13 Jul 2026 17:31:20 -0400 Subject: [PATCH 1/2] feat: add local environment diagnostics --- .changeset/calm-doctor.md | 5 + cli/command.luau | 58 ++++++++ cli/doctor.luau | 232 +++++++++++++++++++++++++++++++ cli/help.luau | 2 + cli/init.luau | 13 ++ dx-viewer.html | 18 +++ runtimes/roblox/bundle.luau | 29 ++-- runtimes/roblox/credentials.luau | 10 +- 8 files changed, 358 insertions(+), 9 deletions(-) create mode 100644 .changeset/calm-doctor.md create mode 100644 cli/doctor.luau diff --git a/.changeset/calm-doctor.md b/.changeset/calm-doctor.md new file mode 100644 index 0000000..4648daa --- /dev/null +++ b/.changeset/calm-doctor.md @@ -0,0 +1,5 @@ +--- +"lutest": minor +--- + +Add a doctor command for local Lutest runtime diagnostics. diff --git a/cli/command.luau b/cli/command.luau index ed72b4b..bd0994c 100644 --- a/cli/command.luau +++ b/cli/command.luau @@ -4,6 +4,7 @@ const t = require '@lutest_release' export type ParsedCommand = { kind: 'help' + | 'doctor' | 'install-package' | 'setup' | 'test' @@ -181,6 +182,38 @@ const function parse_command(args: { string }): ParsedCommand } end + if command == 'doctor' then + local paths = nil :: { string }? + local runtime = nil :: ('lute' | 'roblox')? + local config_path = nil :: string? + local dry_run = false + local parse_error = nil :: string? + const ok = xpcall(function() + paths, runtime, config_path, dry_run = + collect_command_arguments(args, 2) + end, function(err) + parse_error = tostring(err) + return parse_error + end) + if not ok then + return { + kind = 'error', + message = parse_error, + } + end + if #(paths :: { string }) > 0 or runtime ~= nil or dry_run then + return { + kind = 'error', + message = errors.usage_error '`doctor` only accepts `--config `.', + } + end + + return { + kind = 'doctor', + config_path = config_path, + } + end + if command == 'test' or command == 'todo' then local paths = nil :: { string }? local runtime = nil :: ('lute' | 'roblox')? @@ -220,6 +253,7 @@ const function parse_command(args: { string }): ParsedCommand const suggested_command = suggest.closest_match(command, { 'help', + 'doctor', 'install-package', 'setup', 'test', @@ -274,6 +308,30 @@ t.test('should parse setup without arguments', function() assert(command.kind == 'setup') end) +t.test('should parse doctor with an explicit configuration path', function() + const command = parse_command { + 'doctor', + '--config', + 'configs/roblox.toml', + } + + assert(command.kind == 'doctor') + assert(command.config_path == 'configs/roblox.toml') +end) + +t.test('should reject doctor test options', function() + const command = parse_command { 'doctor', '--runtime', 'roblox' } + + assert(command.kind == 'error') + assert( + (command.message :: string):find( + '`doctor` only accepts `--config `.', + 1, + true + ) ~= nil + ) +end) + t.test('should reject setup arguments', function() const command = parse_command { 'setup', 'roblox' } diff --git a/cli/doctor.luau b/cli/doctor.luau new file mode 100644 index 0000000..da2dbd7 --- /dev/null +++ b/cli/doctor.luau @@ -0,0 +1,232 @@ +const fs = require '@std/fs' +const config = require '@runtimes/lute/config' +const bundle = require '@runtimes/roblox/bundle' +const credentials = require '@runtimes/roblox/credentials' +const path_utils = require '@lutest/utils/path' +const ui = require '@lutest/utils/ui' +const t = require '@lutest_release' + +type ProjectConfig = config.ProjectConfig +type LutestConfig = config.LutestConfig +type RobloxConfig = config.RobloxConfig + +export type DoctorReport = { + ready: boolean, + output: string, +} + +type DoctorOptions = { + config_path: string?, + config_exists: boolean, + has_api_key: boolean, + bundle_available: boolean, +} + +const function has_discovery_config(runtime: LutestConfig?): boolean + return runtime ~= nil and runtime.require ~= '' and #runtime.roots > 0 +end + +const function has_roblox_config(project: ProjectConfig): boolean + const options = project.roblox_options + return has_discovery_config(project.roblox) + and options ~= nil + and (options :: RobloxConfig).universe_id > 0 + and (options :: RobloxConfig).place_id > 0 +end + +const function append_configuration(lines: { string }, options: DoctorOptions) + if options.config_exists then + table.insert(lines, ui.passed 'Configuration loaded') + table.insert(lines, ` {options.config_path or 'lutest.toml'}`) + return + end + + table.insert(lines, ui.passed 'Default configuration loaded') + table.insert(lines, ' No lutest.toml found; using Lute defaults.') +end + +const function append_lute_runtime( + lines: { string }, + project: ProjectConfig +): boolean + if project.lute == nil then return true end + if not has_discovery_config(project.lute) then + table.insert(lines, '') + table.insert(lines, ui.failed 'Lute runtime not ready') + table.insert(lines, ' require or roots: missing') + table.insert(lines, '') + table.insert( + lines, + ui.warned 'Set require and roots in [discovery.lute].' + ) + return false + end + + const runtime = project.lute :: LutestConfig + table.insert(lines, '') + table.insert(lines, ui.passed 'Lute runtime ready') + table.insert(lines, ` roots: {table.concat(runtime.roots, ', ')}`) + return true +end + +const function append_roblox_runtime( + lines: { string }, + project: ProjectConfig, + options: DoctorOptions +): boolean + if project.roblox == nil and project.roblox_options == nil then + return true + end + + table.insert(lines, '') + if not has_roblox_config(project) then + table.insert(lines, ui.failed 'Roblox runtime not ready') + table.insert(lines, ' configuration: incomplete') + table.insert(lines, '') + table.insert( + lines, + ui.warned( + 'Set require and roots in [discovery.roblox], and ' + .. 'universe_id and place_id in [roblox].' + ) + ) + return false + end + + const runtime = project.roblox :: LutestConfig + const roblox = project.roblox_options :: RobloxConfig + if not options.has_api_key then + table.insert(lines, ui.failed 'Roblox runtime not ready') + table.insert(lines, ' API key: missing') + table.insert(lines, '') + table.insert( + lines, + ui.warned 'Add ROBLOX_OPEN_CLOUD_API_KEY to .env or your environment.' + ) + return false + end + if not options.bundle_available then + table.insert(lines, ui.failed 'Roblox runtime not ready') + table.insert( + lines, + ' Lutest could not prepare the Roblox bundle.' + ) + table.insert(lines, '') + table.insert( + lines, + ui.warned 'Reinstall Lutest or report this output as an issue.' + ) + return false + end + + table.insert(lines, ui.passed 'Roblox runtime ready') + table.insert(lines, ` roots: {table.concat(runtime.roots, ', ')}`) + table.insert(lines, ' API key: available') + table.insert(lines, ` place: {roblox.place_id}`) + return true +end + +const function inspect( + project: ProjectConfig, + options: DoctorOptions +): DoctorReport + local lines = { ui.section 'Lutest doctor', '' } :: { string } + append_configuration(lines, options) + + const lute_ready = append_lute_runtime(lines, project) + const roblox_ready = append_roblox_runtime(lines, project, options) + const ready = lute_ready and roblox_ready + if ready then + table.insert(lines, '') + table.insert(lines, ui.passed 'Local environment ready') + end + + return { + ready = ready, + output = table.concat(lines, '\n'), + } +end + +const function run(config_path: string?): DoctorReport + const project = config.load_project_config(config_path) + const config_exists = config_path ~= nil + or fs.exists(path_utils.resolve_from_cwd 'lutest.toml') + const bundle_available = if has_roblox_config(project) + then bundle.is_available() + else true + + return inspect(project, { + config_path = config_path, + config_exists = config_exists, + has_api_key = credentials.find() ~= nil, + bundle_available = bundle_available, + }) +end + +t.test('should report configured runtimes as ready', function() + const report = inspect({ + lute = { + roots = { 'src' }, + ignore = {}, + require = '@lutest_release', + gitignore = true, + }, + roblox = { + roots = { 'game' }, + ignore = {}, + require = 'ReplicatedStorage.Packages.lutest', + gitignore = true, + }, + roblox_options = { + universe_id = 1, + place_id = 2, + poll_interval_seconds = 2, + timeout_seconds = 300, + }, + }, { + config_exists = true, + has_api_key = true, + bundle_available = true, + }) + + assert(report.ready == true) + assert(report.output:find('Roblox runtime ready', 1, true) ~= nil) + assert(report.output:find('API key: available', 1, true) ~= nil) +end) + +t.test( + 'should report a missing Roblox API key without exposing values', + function() + const report = inspect({ + lute = nil, + roblox = { + roots = { 'game' }, + ignore = {}, + require = 'ReplicatedStorage.Packages.lutest', + gitignore = true, + }, + roblox_options = { + universe_id = 1, + place_id = 2, + poll_interval_seconds = 2, + timeout_seconds = 300, + }, + }, { + config_exists = true, + has_api_key = false, + bundle_available = true, + }) + + assert(report.ready == false) + assert(report.output:find('API key: missing', 1, true) ~= nil) + assert( + report.output:find('ROBLOX_OPEN_CLOUD_API_KEY', 1, true) + ~= nil + ) + end +) + +return { + inspect = inspect, + run = run, +} diff --git a/cli/help.luau b/cli/help.luau index 55e5ef8..3129035 100644 --- a/cli/help.luau +++ b/cli/help.luau @@ -9,6 +9,7 @@ const function format_help(): string ui.section 'Commands', ' test [paths...] [--runtime lute|roblox] [--config ] [--dry-run]', ' todo [paths...] [--runtime lute|roblox] [--config ] [--dry-run]', + ' doctor [--config ]', ' setup', ' install-package [--output ]', ' version', @@ -39,6 +40,7 @@ t.test('should format concise help with usage and examples', function() true ) ~= nil ) + assert(output:find('doctor [--config ]', 1, true) ~= nil) assert( output:find( 'todo [paths...] [--runtime lute|roblox] [--config ] [--dry-run]', diff --git a/cli/init.luau b/cli/init.luau index 5b5e8b3..df6569e 100644 --- a/cli/init.luau +++ b/cli/init.luau @@ -1,6 +1,7 @@ const process = require '@std/process' const errors = require '@core/errors' const command = require '@self/command' +const doctor = require '@self/doctor' const help = require '@self/help' const install_package = require '@self/install_package' const setup = require '@self/setup' @@ -115,6 +116,7 @@ const function main() local todo_suites = nil :: { TodoSuite }? local dry_run_files = nil :: { string }? local dry_run_runtime = nil :: ('lute' | 'roblox')? + local doctor_report = nil :: doctor.DoctorReport? local runtime_error = nil :: string? const ok = xpcall(function() if parsed_command.kind == 'install-package' then @@ -127,6 +129,11 @@ const function main() return end + if parsed_command.kind == 'doctor' then + doctor_report = doctor.run(parsed_command.config_path) + return + end + const runtime = parsed_command.runtime or config.default_runtime( config.load_project_config( @@ -206,6 +213,12 @@ const function main() process.exit(1) end + if doctor_report ~= nil then + const report = doctor_report :: doctor.DoctorReport + print(report.output) + process.exit(if report.ready then 0 else 1) + end + if dry_run_files ~= nil then reporter.print_dry_run( dry_run_runtime :: 'lute' | 'roblox', diff --git a/dx-viewer.html b/dx-viewer.html index e1bbc24..5eb6d31 100644 --- a/dx-viewer.html +++ b/dx-viewer.html @@ -253,6 +253,7 @@

Command surface

> Commands test [paths...] [--runtime lute|roblox] [--config <path>] [--dry-run] todo [paths...] [--runtime lute|roblox] [--config <path>] [--dry-run] + doctor [--config <path>] setup install-package [--output <parent-directory>] version @@ -275,6 +276,23 @@

Command surface

game/arena/src/server/leaderstats.luau + 2 test files discovered. Nothing was executed. + +
+

Local diagnostics

Check prerequisites without contacting Roblox

+
> Lutest doctor
+
++ Configuration loaded
+  lutest.toml
+
++ Lute runtime ready
+  roots: core, lib, runtimes, cli, utils
+
++ Roblox runtime ready
+  roots: game
+  API key: available
+  place: 87484847405188
+
++ Local environment ready

Todo list

Intentional work has its own state

diff --git a/runtimes/roblox/bundle.luau b/runtimes/roblox/bundle.luau index a51e6e1..f3f7bf6 100644 --- a/runtimes/roblox/bundle.luau +++ b/runtimes/roblox/bundle.luau @@ -35,14 +35,15 @@ const function runtime_error(summary: string, details: string?): never error( errors.runtime_error(summary, { where = 'Roblox bundle preparation', - how_to_fix = 'Install Lune on PATH, or set LUTEST_LUNE_BIN to its executable path.', + how_to_fix = 'Install Lune on PATH, or set LUTEST_LUNE_BIN ' + .. 'to its executable path.', details = details, }), 0 ) end -const function ensure_lune_available(command: string) +const function inspect_lune(command: string): (boolean, string?) local result = nil :: process.ProcessResult? local failure = nil :: string? const ok = xpcall(function() @@ -51,18 +52,29 @@ const function ensure_lune_available(command: string) failure = tostring(err) return failure end) - if - not ok - or result == nil - or (result :: process.ProcessResult).exitcode ~= 0 - then + if not ok or result == nil then return false, failure end + if (result :: process.ProcessResult).exitcode ~= 0 then + return false, (result :: process.ProcessResult).stderr + end + + return true, nil +end + +const function ensure_lune_available(command: string) + const available, details = inspect_lune(command) + if not available then runtime_error( `Could not run Lune executable '{command}'.`, - failure + details ) end end +const function is_available(): boolean + const available = inspect_lune(lune_command()) + return available +end + const function bundle_directory(): string return path_utils.normalize_path( path.join(system.tmpdir(), BUNDLE_DIRECTORY) @@ -230,4 +242,5 @@ t.test( return { build = build, + is_available = is_available, } diff --git a/runtimes/roblox/credentials.luau b/runtimes/roblox/credentials.luau index 657e449..b3a6836 100644 --- a/runtimes/roblox/credentials.luau +++ b/runtimes/roblox/credentials.luau @@ -23,7 +23,7 @@ const function parse_dotenv(contents: string): { [string]: string } return values end -const function load(cwd: string?): string +const function find(cwd: string?): string? const environment_value = process.env[ENVIRONMENT_KEY] if type(environment_value) == 'string' and environment_value ~= '' then return environment_value @@ -37,6 +37,13 @@ const function load(cwd: string?): string if value ~= nil and value ~= '' then return value end end + return nil +end + +const function load(cwd: string?): string + const value = find(cwd) + if value ~= nil then return value end + error( errors.configuration_error('Roblox API key missing', { how_to_fix = 'Set ROBLOX_OPEN_CLOUD_API_KEY in .env or your environment.', @@ -52,5 +59,6 @@ t.test('should parse a dotenv API key without exposing it', function() end) return { + find = find, load = load, } From 09d0cca32f65cfd29be65d1a05a1335ea6d96835 Mon Sep 17 00:00:00 2001 From: cayasde Date: Mon, 13 Jul 2026 17:41:55 -0400 Subject: [PATCH 2/2] refactor(cli): simplify doctor output --- cli/doctor.luau | 6 +----- dx-viewer.html | 8 ++------ 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/cli/doctor.luau b/cli/doctor.luau index da2dbd7..8aa55a7 100644 --- a/cli/doctor.luau +++ b/cli/doctor.luau @@ -130,16 +130,12 @@ const function inspect( project: ProjectConfig, options: DoctorOptions ): DoctorReport - local lines = { ui.section 'Lutest doctor', '' } :: { string } + local lines = {} :: { string } append_configuration(lines, options) const lute_ready = append_lute_runtime(lines, project) const roblox_ready = append_roblox_runtime(lines, project, options) const ready = lute_ready and roblox_ready - if ready then - table.insert(lines, '') - table.insert(lines, ui.passed 'Local environment ready') - end return { ready = ready, diff --git a/dx-viewer.html b/dx-viewer.html index 5eb6d31..fe08c28 100644 --- a/dx-viewer.html +++ b/dx-viewer.html @@ -279,9 +279,7 @@

Command surface

Local diagnostics

Check prerequisites without contacting Roblox

-
> Lutest doctor
-
-+ Configuration loaded
+              
+ Configuration loaded
   lutest.toml
 
 + Lute runtime ready
@@ -290,9 +288,7 @@ 

Command surface

+ Roblox runtime ready roots: game API key: available - place: 87484847405188 - -+ Local environment ready
+ place: 87484847405188

Todo list

Intentional work has its own state