diff --git a/addons/vest/cli/vest-cli-runner.gd b/addons/vest/cli/vest-cli-runner.gd index 438b5a7..d4652fd 100644 --- a/addons/vest/cli/vest-cli-runner.gd +++ b/addons/vest/cli/vest-cli-runner.gd @@ -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: diff --git a/addons/vest/cli/vest-cli.gd b/addons/vest/cli/vest-cli.gd index 6b4ac5b..5ae523e 100644 --- a/addons/vest/cli/vest-cli.gd +++ b/addons/vest/cli/vest-cli.gd @@ -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 @@ -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") @@ -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. @@ -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) diff --git a/addons/vest/plugin.gd b/addons/vest/plugin.gd index 862b31f..479c6f2 100644 --- a/addons/vest/plugin.gd +++ b/addons/vest/plugin.gd @@ -2,6 +2,7 @@ extends EditorPlugin var bottom_control: Control +var status_indicator: Control static var SETTINGS := [ { @@ -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) @@ -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): diff --git a/addons/vest/runner/vest-local-runner.gd b/addons/vest/runner/vest-local-runner.gd index c827a44..cf4cb9e 100644 --- a/addons/vest/runner/vest-local-runner.gd +++ b/addons/vest/runner/vest-local-runner.gd @@ -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: @@ -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 diff --git a/addons/vest/ui/vest-status-indicator.gd b/addons/vest/ui/vest-status-indicator.gd new file mode 100644 index 0000000..158dff0 --- /dev/null +++ b/addons/vest/ui/vest-status-indicator.gd @@ -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 diff --git a/addons/vest/ui/vest-status-indicator.gd.uid b/addons/vest/ui/vest-status-indicator.gd.uid new file mode 100644 index 0000000..ffe2351 --- /dev/null +++ b/addons/vest/ui/vest-status-indicator.gd.uid @@ -0,0 +1 @@ +uid://et5uijepnhvo