Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-doctor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"lutest": minor
---

Add a doctor command for local Lutest runtime diagnostics.
58 changes: 58 additions & 0 deletions cli/command.luau
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const t = require '@lutest_release'

export type ParsedCommand = {
kind: 'help'
| 'doctor'
| 'install-package'
| 'setup'
| 'test'
Expand Down Expand Up @@ -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 <path>`.',
}
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')?
Expand Down Expand Up @@ -220,6 +253,7 @@ const function parse_command(args: { string }): ParsedCommand

const suggested_command = suggest.closest_match(command, {
'help',
'doctor',
'install-package',
'setup',
'test',
Expand Down Expand Up @@ -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 <path>`.',
1,
true
) ~= nil
)
end)

t.test('should reject setup arguments', function()
const command = parse_command { 'setup', 'roblox' }

Expand Down
228 changes: 228 additions & 0 deletions cli/doctor.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
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 = {} :: { 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

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,
}
2 changes: 2 additions & 0 deletions cli/help.luau
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const function format_help(): string
ui.section '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',
Expand Down Expand Up @@ -39,6 +40,7 @@ t.test('should format concise help with usage and examples', function()
true
) ~= nil
)
assert(output:find('doctor [--config <path>]', 1, true) ~= nil)
assert(
output:find(
'todo [paths...] [--runtime lute|roblox] [--config <path>] [--dry-run]',
Expand Down
13 changes: 13 additions & 0 deletions cli/init.luau
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading