Skip to content
Draft
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
1 change: 1 addition & 0 deletions addons/vest/cli/vest-cli-runner.gd
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func run(params: VestCLI.Params) -> int:

func _run_tests(params: VestCLI.Params) -> VestResult.Suite:
var runner := VestLocalRunner.new()
runner.log_file = params.log_file
runner.on_partial_result.connect(func(result: VestResult.Suite):
if _peer != null:
if result != null:
Expand Down
36 changes: 35 additions & 1 deletion addons/vest/cli/vest-cli.gd
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ class Params:
## Port to connect to for sending results
var port: int = -1

## Log file path for capturing push_error output
var log_file: String = ""

## Validate parameters.
## [br][br]
## Returns an array of error messages, or an empty array of the parameters
Expand All @@ -52,6 +55,7 @@ class Params:
if report_file: result.append_array(["--vest-report-file", report_file])
if host: result.append_array(["--vest-host", host])
if port != -1: result.append_array(["--vest-port", str(port)])
if log_file: result.append_array(["--vest-log-file", log_file])

match only_mode:
Vest.__.ONLY_DISABLED: result.append("--no-only")
Expand All @@ -76,17 +80,33 @@ class Params:
elif arg == "--vest-report-format": result.report_format = val
elif arg == "--vest-port": result.port = val.to_int()
elif arg == "--vest-host": result.host = val
elif arg == "--vest-log-file": result.log_file = val
elif arg == "--no-only": result.only_mode = Vest.__.ONLY_DISABLED
elif arg == "--only": result.only_mode = Vest.__.ONLY_ENABLED
elif arg == "--auto-only": result.only_mode = Vest.__.ONLY_AUTO

return result

static func uuid4() -> String:
var b := PackedByteArray()
b.resize(16)
for i in 16:
b[i] = randi() & 0xff
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x" % [
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]]

## Run vest CLI with parameters.
## [br][br]
## Returns the spawned process' ID.
static func run(params: Params) -> int:
var args = ["--headless", "-s", (VestCLI as Script).resource_path]
var log_dir := ProjectSettings.globalize_path("user://unit-tests")
DirAccess.make_dir_recursive_absolute(log_dir)
var log_path := log_dir.path_join("%s.log" % uuid4())
params.log_file = log_path
var args = ["--headless", "--log-file", log_path, "-s", (VestCLI as Script).resource_path]
return OS.create_instance(args + params.to_args())

## Run vest in debug mode.
Expand All @@ -103,6 +123,20 @@ func _init():
await process_frame

var params := Params.parse(OS.get_cmdline_args())

# Auto-detect log file if not explicitly provided
if params.log_file.is_empty():
# Check if --log-file was passed as a Godot engine arg
var engine_args := OS.get_cmdline_args()
for i in range(engine_args.size()):
if engine_args[i] == "--log-file" and i + 1 < engine_args.size():
params.log_file = engine_args[i + 1]
break
# Fall back to Godot's default log path
if params.log_file.is_empty():
params.log_file = ProjectSettings.globalize_path(
ProjectSettings.get_setting("debug/file_logging/log_path", "user://logs/godot.log"))

var runner := VestCLIRunner.new()

var exit_code := await runner.run(params)
Expand Down
16 changes: 15 additions & 1 deletion addons/vest/plugin.gd
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
extends EditorPlugin

var bottom_control: Control
var status_indicator: Control

static var SETTINGS := [
{
Expand Down Expand Up @@ -53,7 +54,17 @@ func _enter_tree():
add_control_to_bottom_panel(bottom_control, "Vest")

add_settings(SETTINGS)


# Status indicator (bottom-right overlay, auto-runs tests on save)
status_indicator = preload("res://addons/vest/ui/vest-status-indicator.gd").new()
var base := EditorInterface.get_base_control()
base.add_child(status_indicator)
status_indicator.set_anchors_and_offsets_preset(
Control.PRESET_BOTTOM_RIGHT, Control.PRESET_MODE_KEEP_SIZE, 12)
status_indicator.grow_horizontal = Control.GROW_DIRECTION_BEGIN
status_indicator.grow_vertical = Control.GROW_DIRECTION_BEGIN
resource_saved.connect(status_indicator.handle_resource_saved)

# Create commands
for command in Vest.__.create_commands():
add_child(command)
Expand All @@ -63,6 +74,9 @@ func _exit_tree():
remove_control_from_bottom_panel(bottom_control)
bottom_control.queue_free()

resource_saved.disconnect(status_indicator.handle_resource_saved)
status_indicator.queue_free()

remove_settings(SETTINGS)

func add_settings(settings: Array):
Expand Down
60 changes: 60 additions & 0 deletions addons/vest/runner/vest-local-runner.gd
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ extends "res://addons/vest/runner/vest-base-runner.gd"
class_name VestLocalRunner

var _result_buffer: VestResult.Suite
var log_file: String = ""
var _log_offset: int = 0

## Run a test script
func run_script(script: Script, only_mode: int = Vest.__.ONLY_DEFAULT) -> VestResult.Suite:
Expand Down Expand Up @@ -90,14 +92,72 @@ func _run_case(case: VestDefs.Case, test_instance: VestTest, run_only: bool, is_
if run_only and not case.is_only and not is_parent_only:
return null

_mark_log_position()
await test_instance._begin(case)
await case.callback.call()
await test_instance._finish(case)
_check_log_for_errors(test_instance)

on_partial_result.emit(_result_buffer)

return test_instance._get_result()

func _mark_log_position() -> void:
if log_file.is_empty():
return
var fa := FileAccess.open(log_file, FileAccess.READ)
if fa:
fa.seek_end()
_log_offset = fa.get_position()

func _check_log_for_errors(test_instance: VestTest) -> void:
if log_file.is_empty():
return
var fa := FileAccess.open(log_file, FileAccess.READ)
if not fa:
return
if fa.get_length() <= _log_offset:
return
fa.seek(_log_offset)
var new_content := fa.get_buffer(fa.get_length() - _log_offset).get_string_from_utf8()
var errors := _parse_gdscript_errors(new_content)
if not errors.is_empty():
var msg := "\n".join(errors)
test_instance.fail("push_error during test:\n" + msg)
for err in errors:
print_rich("[color=red]VEST ERROR:[/color] %s — in [b]%s[/b]" % [err, test_instance._result.case.description])

## Parse log content and return only errors with a GDScript backtrace or SCRIPT ERROR.
## Filters out pure engine/renderer errors (null material, RID, etc.) that are
## false positives in headless mode.
static func _parse_gdscript_errors(content: String) -> PackedStringArray:
var errors: PackedStringArray = []
var lines := content.split("\n")
var i := 0
while i < lines.size():
var line := lines[i].strip_edges()
if line.begins_with("SCRIPT ERROR:"):
errors.append(line)
i += 1
elif line.begins_with("ERROR:"):
# Look ahead: does this error block have a GDScript backtrace?
var has_backtrace := false
var j := i + 1
while j < lines.size():
var next := lines[j].strip_edges()
if next.is_empty() or next.begins_with("ERROR:") or next.begins_with("SCRIPT ERROR:") or next.begins_with("WARNING:"):
break
if next.contains("GDScript backtrace"):
has_backtrace = true
break
j += 1
if has_backtrace:
errors.append(line)
i = j
else:
i += 1
return errors

func _run_suite(result: VestResult.Suite, suite: VestDefs.Suite, test_instance: VestTest, run_only: bool, is_parent_only: bool = false) -> VestResult.Suite:
if run_only and not suite.has_only() and not is_parent_only:
return null
Expand Down
116 changes: 116 additions & 0 deletions addons/vest/ui/vest-status-indicator.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
@tool
extends PanelContainer

## Persistent status indicator that auto-runs tests after script reloads.
## Shows pass/fail count in the bottom-right corner of the editor.

const DEBOUNCE_SEC := 20.0

var _timer: Timer
var _label: Label
var _icon: TextureRect
var _running := false

func _ready() -> void:
z_index = 100

# Dark semi-transparent background
var style := StyleBoxFlat.new()
style.bg_color = Color(0.15, 0.15, 0.15, 0.9)
style.corner_radius_top_left = 4
style.corner_radius_top_right = 4
style.corner_radius_bottom_left = 4
style.corner_radius_bottom_right = 4
style.content_margin_left = 8
style.content_margin_right = 8
style.content_margin_top = 4
style.content_margin_bottom = 4
add_theme_stylebox_override("panel", style)

_timer = Timer.new()
_timer.one_shot = true
_timer.wait_time = DEBOUNCE_SEC
_timer.timeout.connect(_on_timer_timeout)
add_child(_timer)

var hbox := HBoxContainer.new()
hbox.add_theme_constant_override("separation", 6)

_icon = TextureRect.new()
_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
_icon.custom_minimum_size = Vector2(16, 16)
hbox.add_child(_icon)

_label = Label.new()
_label.text = "tests: --"
hbox.add_child(_label)

var run_btn := Button.new()
run_btn.text = "Run Now"
run_btn.flat = true
run_btn.pressed.connect(_on_run_now_pressed)
hbox.add_child(run_btn)

add_child(hbox)


func handle_resource_saved(resource: Resource) -> void:
if not resource is Script:
return
_timer.start(DEBOUNCE_SEC)


func _on_run_now_pressed() -> void:
_timer.stop()
if not _running:
_run_tests()


func _on_timer_timeout() -> void:
if _running:
return
_run_tests()


func _run_tests() -> void:
_running = true
_label.text = "running..."
_label.remove_theme_color_override("font_color")
_icon.texture = null

var runner := VestDaemonRunner.new()
var glob_pattern: String = Vest.__.LocalSettings.test_glob
if glob_pattern.is_empty() or glob_pattern == "res://*.test.gd":
glob_pattern = "res://addons/map_generator/test/*.test.gd"

var results: VestResult.Suite = await runner.run_glob(glob_pattern)
_running = false

if results == null:
_label.text = "ERR"
_label.add_theme_color_override("font_color", Color(1.0, 0.5, 0.0))
return

var passed := _count_recursive(results, VestResult.TEST_PASS)
var failed := _count_recursive(results, VestResult.TEST_FAIL)
var total := results.size()

_label.text = "%d/%d" % [passed, total]

if failed > 0:
_icon.texture = Vest.Icons.result_fail
_label.add_theme_color_override("font_color", Color(1.0, 0.3, 0.3))
else:
_icon.texture = Vest.Icons.result_pass
_label.add_theme_color_override("font_color", Color(0.3, 1.0, 0.3))


## Recursively count all cases with a given status across the entire suite tree.
static func _count_recursive(suite: VestResult.Suite, status: int) -> int:
var count := 0
for c: VestResult.Case in suite.cases:
if c.status == status:
count += 1
for s: VestResult.Suite in suite.subsuites:
count += _count_recursive(s, status)
return count
1 change: 1 addition & 0 deletions addons/vest/ui/vest-status-indicator.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://et5uijepnhvo