summaryrefslogtreecommitdiff
path: root/addons/gdUnit4/src/core/runners
diff options
context:
space:
mode:
Diffstat (limited to 'addons/gdUnit4/src/core/runners')
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd470
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd90
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestRunner.tscn34
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestSession.gd169
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestSession.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd180
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd.uid1
9 files changed, 947 insertions, 0 deletions
diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd b/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd
new file mode 100644
index 0000000..34dcfa3
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd
@@ -0,0 +1,470 @@
+#warning-ignore-all:return_value_discarded
+class_name GdUnitTestCIRunner
+extends "res://addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd"
+## Command line test runner implementation.[br]
+## [br]
+## This runner is designed for CI/CD pipelines and command line test execution.[br]
+## Features:[br]
+## - Command line options for test configuration[br]
+## - HTML and JUnit report generation[br]
+## - Console output with colored formatting[br]
+## - Progress and error reporting[br]
+## - Test history management[br]
+## [br]
+## Example usage:[br]
+## [codeblock]
+## # Run all tests in a directory
+## runtest -a <directory>
+##
+## # Run specific test suite with ignored tests
+## runtest -a <directory> -i <testsuite:test_name>
+## [/codeblock]
+
+const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+
+var _console := GdUnitCSIMessageWriter.new()
+var _console_reporter: GdUnitConsoleTestReporter
+var _headless_mode_ignore := false
+var _runner_config_file := ""
+var _debug_cmd_args := PackedStringArray()
+var _included_tests := PackedStringArray()
+var _excluded_tests := PackedStringArray()
+
+## Command line options configuration
+var _cmd_options := CmdOptions.new([
+ CmdOption.new(
+ "-a, --add",
+ "-a <directory|path of testsuite>",
+ "Adds the given test suite or directory to the execution pipeline.",
+ TYPE_STRING
+ ),
+ CmdOption.new(
+ "-i, --ignore",
+ "-i <testsuite_name|testsuite_name:test-name>",
+ "Adds the given test suite or test case to the ignore list.",
+ TYPE_STRING
+ ),
+ CmdOption.new(
+ "-c, --continue",
+ "",
+ """By default GdUnit will abort checked first test failure to be fail fast,
+ instead of stop after first failure you can use this option to run the complete test set.""".dedent()
+ ),
+ CmdOption.new(
+ "-conf, --config",
+ "-conf [testconfiguration.cfg]",
+ "Run all tests by given test configuration. Default is 'GdUnitRunner.cfg'",
+ TYPE_STRING,
+ true
+ ),
+ CmdOption.new(
+ "-help", "",
+ "Shows this help message."
+ ),
+ CmdOption.new("--help-advanced", "",
+ "Shows advanced options."
+ )
+ ],
+ [
+ # advanced options
+ CmdOption.new(
+ "-rd, --report-directory",
+ "-rd <directory>",
+ "Specifies the output directory in which the reports are to be written. The default is res://reports/.",
+ TYPE_STRING,
+ true
+ ),
+ CmdOption.new(
+ "-rc, --report-count",
+ "-rc <count>",
+ "Specifies how many reports are saved before they are deleted. The default is %s." % str(GdUnitConstants.DEFAULT_REPORT_HISTORY_COUNT),
+ TYPE_INT,
+ true
+ ),
+ #CmdOption.new("--list-suites", "--list-suites [directory]", "Lists all test suites located in the given directory.", TYPE_STRING),
+ #CmdOption.new("--describe-suite", "--describe-suite <suite name>", "Shows the description of selected test suite.", TYPE_STRING),
+ CmdOption.new(
+ "--info", "",
+ "Shows the GdUnit version info"
+ ),
+ CmdOption.new(
+ "--selftest", "",
+ "Runs the GdUnit self test"
+ ),
+ CmdOption.new(
+ "--ignoreHeadlessMode",
+ "--ignoreHeadlessMode",
+ "By default, running GdUnit4 in headless mode is not allowed. You can switch off the headless mode check by set this property."
+ ),
+ ])
+
+
+func _init() -> void:
+ super()
+
+
+func _ready() -> void:
+ super()
+ # stop checked first test failure to fail fast
+ _executor.fail_fast(true)
+ _console_reporter = GdUnitConsoleTestReporter.new(_console, true)
+ GdUnitSignals.instance().gdunit_message.connect(_on_send_message)
+
+
+func _notification(what: int) -> void:
+ super(what)
+ if what == NOTIFICATION_PREDELETE:
+ prints("Finallize .. done")
+
+
+func init_runner() -> void:
+ init_gd_unit()
+
+
+## Returns the exit code based on test results.[br]
+## Maps test report status to process exit codes.
+func get_exit_code() -> int:
+ return report_exit_code()
+
+
+## Cleanup and quit the runner.[br]
+## [br]
+## [param code] The exit code to return.
+func quit(code: int) -> void:
+ _state = EXIT
+ GdUnitTools.dispose_all()
+ await GdUnitMemoryObserver.gc_on_guarded_instances()
+ await super(code)
+
+
+## Prints info message to console.[br]
+## [br]
+## [param message] The message to print.[br]
+## [param color] Optional color for the message.
+func console_info(message: String, color: Color = Color.WHITE) -> void:
+ _console.color(color).println_message(message)
+
+
+## Prints error message to console.[br]
+## [br]
+## [param message] The error message to print.
+func console_error(message: String) -> void:
+ _console.prints_error(message)
+
+
+## Prints warning message to console.[br]
+## [br]
+## [param message] The warning message to print.
+func console_warning(message: String) -> void:
+ _console.prints_warning(message)
+
+
+## Sets the directory for test reports.[br]
+## [br]
+## [param path] The path where reports should be written.
+func set_report_dir(path: String) -> void:
+ report_base_path = ProjectSettings.globalize_path(GdUnitFileAccess.make_qualified_path(path))
+ console_info(
+ "Set write reports to %s" % report_base_path,
+ Color.DEEP_SKY_BLUE
+ )
+
+
+## Sets how many report files to keep.[br]
+## [br]
+## [param count] The number of reports to keep.
+func set_report_count(count: String) -> void:
+ var report_count := count.to_int()
+ if report_count < 1:
+ console_error(
+ "Invalid report history count '%s' set back to default %d"
+ % [count, GdUnitConstants.DEFAULT_REPORT_HISTORY_COUNT]
+ )
+ max_report_history = GdUnitConstants.DEFAULT_REPORT_HISTORY_COUNT
+ else:
+ console_info(
+ "Set report history count to %s" % count,
+ Color.DEEP_SKY_BLUE
+ )
+ max_report_history = report_count
+
+
+## Disables fail-fast mode to run all tests.[br]
+## By default tests stop on first failure.
+func disable_fail_fast() -> void:
+ console_info(
+ "Disabled fail fast!",
+ Color.DEEP_SKY_BLUE
+ )
+ @warning_ignore("unsafe_method_access")
+ _executor.fail_fast(false)
+
+
+func run_self_test() -> void:
+ console_info(
+ "Run GdUnit4 self tests.",
+ Color.DEEP_SKY_BLUE
+ )
+ disable_fail_fast()
+
+
+
+## Shows GdUnit and Godot version information.
+func show_version() -> void:
+ console_info(
+ "Godot %s" % Engine.get_version_info().get("string") as String,
+ Color.DARK_SALMON
+ )
+ var config := ConfigFile.new()
+ config.load("addons/gdUnit4/plugin.cfg")
+ console_info(
+ "GdUnit4 %s" % config.get_value("plugin", "version") as String,
+ Color.DARK_SALMON
+ )
+ quit(RETURN_SUCCESS)
+
+
+## Ignores headless mode restrictions.[br]
+## Allows tests to run in headless mode despite limitations.
+func check_headless_mode() -> void:
+ _headless_mode_ignore = true
+
+
+## Shows available command line options.[br]
+## [br]
+## [param show_advanced] Whether to show advanced options.
+func show_options(show_advanced: bool = false) -> void:
+ console_info(
+ """
+ Usage:
+ runtest -a <directory|path of testsuite>
+ runtest -a <directory> -i <path of testsuite|testsuite_name|testsuite_name:test_name>
+ """.dedent(),
+ Color.DARK_SALMON
+ )
+ console_info(
+ "-- Options ---------------------------------------------------------------------------------------",
+ Color.DARK_SALMON
+ )
+ for option in _cmd_options.default_options():
+ descripe_option(option)
+ if show_advanced:
+ console_info(
+ "-- Advanced options --------------------------------------------------------------------------",
+ Color.DARK_SALMON
+ )
+ for option in _cmd_options.advanced_options():
+ descripe_option(option)
+
+
+## Describes a single command line option.[br]
+## [br]
+## [param cmd_option] The option to describe.
+func descripe_option(cmd_option: CmdOption) -> void:
+ console_info(
+ " %-40s" % str(cmd_option.commands()),
+ Color.CORNFLOWER_BLUE
+ )
+ console_info(
+ cmd_option.description(),
+ Color.LIGHT_GREEN
+ )
+ if not cmd_option.help().is_empty():
+ console_info(
+ "%-4s %s" % ["", cmd_option.help()],
+ Color.DARK_TURQUOISE
+ )
+ console_info("")
+
+
+## Loads test configuration from file.[br]
+## [br]
+## [param path] Path to the configuration file.
+func load_test_config(path := GdUnitRunnerConfig.CONFIG_FILE) -> void:
+ console_info(
+ "Loading test configuration %s\n" % path,
+ Color.CORNFLOWER_BLUE
+ )
+ _runner_config_file = path
+ _runner_config.load_config(path)
+
+
+## Shows basic help and exits.
+func show_help() -> void:
+ show_options()
+ quit(RETURN_SUCCESS)
+
+
+## Shows advanced help and exits.
+func show_advanced_help() -> void:
+ show_options(true)
+ quit(RETURN_SUCCESS)
+
+
+## Gets command line arguments.[br]
+## Returns debug args if set, otherwise actual command line args.
+func get_cmdline_args() -> PackedStringArray:
+ if _debug_cmd_args.is_empty():
+ return OS.get_cmdline_args()
+ return _debug_cmd_args
+
+
+## Initializes the test runner and processes command line arguments.
+func init_gd_unit() -> void:
+ console_info(
+ """
+ --------------------------------------------------------------------------------------------------
+ GdUnit4 Comandline Tool
+ --------------------------------------------------------------------------------------------------""".dedent(),
+ Color.DARK_SALMON
+ )
+
+ var cmd_parser := CmdArgumentParser.new(_cmd_options, "GdUnitCmdTool.gd")
+ var result := cmd_parser.parse(get_cmdline_args())
+ if result.is_error():
+ console_error(result.error_message())
+ show_options()
+ console_error("Abnormal exit with %d" % RETURN_ERROR)
+ quit(RETURN_ERROR)
+ return
+ if result.is_empty():
+ show_help()
+ return
+ # build runner config by given commands
+ var commands :Array[CmdCommand] = []
+ @warning_ignore("unsafe_cast")
+ commands.append_array(result.value() as Array)
+ result = (
+ CmdCommandHandler.new(_cmd_options)
+ .register_cb("-help", show_help)
+ .register_cb("--help-advanced", show_advanced_help)
+ .register_cb("-a", add_test_suite)
+ .register_cbv("-a", add_test_suites)
+ .register_cb("-i", skip_test_suite)
+ .register_cbv("-i", skip_test_suites)
+ .register_cb("-rd", set_report_dir)
+ .register_cb("-rc", set_report_count)
+ .register_cb("--selftest", run_self_test)
+ .register_cb("-c", disable_fail_fast)
+ .register_cb("-conf", load_test_config)
+ .register_cb("--info", show_version)
+ .register_cb("--ignoreHeadlessMode", check_headless_mode)
+ .execute(commands)
+ )
+ if result.is_error():
+ console_error(result.error_message())
+ quit(RETURN_ERROR)
+ return
+
+ if DisplayServer.get_name() == "headless":
+ if _headless_mode_ignore:
+ console_warning("""
+ Headless mode is ignored by option '--ignoreHeadlessMode'"
+
+ Please note that tests that use UI interaction do not work correctly in headless mode.
+ Godot 'InputEvents' are not transported by the Godot engine in headless mode and therefore
+ have no effect in the test!
+ """.dedent()
+ )
+ else:
+ console_error("""
+ Headless mode is not supported!
+
+ Please note that tests that use UI interaction do not work correctly in headless mode.
+ Godot 'InputEvents' are not transported by the Godot engine in headless mode and therefore
+ have no effect in the test!
+
+ You can run with '--ignoreHeadlessMode' to swtich off this check.
+ """.dedent()
+ )
+ console_error(
+ "Abnormal exit with %d" % RETURN_ERROR_HEADLESS_NOT_SUPPORTED
+ )
+ quit(RETURN_ERROR_HEADLESS_NOT_SUPPORTED)
+ return
+
+ _test_cases = discover_tests()
+ if _test_cases.is_empty():
+ console_info("No test cases found, abort test run!", Color.YELLOW)
+ console_info("Exit code: %d" % RETURN_SUCCESS, Color.DARK_SALMON)
+ quit(RETURN_SUCCESS)
+ return
+ _state = RUN
+
+
+func discover_tests() -> Array[GdUnitTestCase]:
+ var gdunit_test_discover_added := GdUnitSignals.instance().gdunit_test_discover_added
+
+ _test_cases = _runner_config.test_cases()
+ var scanner := GdUnitTestSuiteScanner.new()
+ for path in _included_tests:
+ var scripts := scanner.scan(path)
+ for script in scripts:
+ GdUnitTestDiscoverer.discover_tests(script, func(test: GdUnitTestCase) -> void:
+ if not is_skipped(test):
+ #_console.println_message("discoverd %s" % test.display_name)
+ _test_cases.append(test)
+ gdunit_test_discover_added.emit(test)
+ )
+
+ return _test_cases
+
+
+func add_test_suite(path: String) -> void:
+ _included_tests.append(path)
+
+
+func add_test_suites(paths: PackedStringArray) -> void:
+ _included_tests.append_array(paths)
+
+
+func skip_test_suite(path: String) -> void:
+ _excluded_tests.append(path)
+
+
+func skip_test_suites(paths: PackedStringArray) -> void:
+ _excluded_tests.append_array(paths)
+
+
+func is_skipped(test: GdUnitTestCase) -> bool:
+ for skipped_info in _excluded_tests:
+
+ # is suite skipped by full path or suite name
+ if skipped_info == test.suite_name or test.source_file.contains(skipped_info):
+ return true
+ var skip_file := skipped_info.replace("res://", "")
+
+ # check for skipped single test
+ if not skip_file.contains(":"):
+ continue
+ var parts: PackedStringArray = skip_file.rsplit(":")
+ var skipped_suite := parts[0]
+ var skipped_test := parts[1]
+ # is suite skipped by full path or suite name
+ if (skipped_suite == test.suite_name or test.source_file.contains(skipped_suite)) and skipped_test == test.test_name:
+ return true
+
+ return false
+
+
+func _on_send_message(message: String) -> void:
+ _console.color(Color.CORNFLOWER_BLUE).println_message(message)
+
+
+func _on_gdunit_event(event: GdUnitEvent) -> void:
+ match event.type():
+ GdUnitEvent.SESSION_START:
+ _console_reporter.test_session = _test_session
+ GdUnitEvent.SESSION_CLOSE:
+ _console_reporter.test_session = null
+
+
+func report_exit_code() -> int:
+ if _console_reporter.total_error_count() + _console_reporter.total_failure_count() > 0:
+ console_info("Exit code: %d" % RETURN_ERROR, Color.FIREBRICK)
+ return RETURN_ERROR
+ if _console_reporter.total_orphan_count() > 0:
+ console_info("Exit code: %d" % RETURN_WARNING, Color.GOLDENROD)
+ return RETURN_WARNING
+ console_info("Exit code: %d" % RETURN_SUCCESS, Color.DARK_SALMON)
+ return RETURN_SUCCESS
diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd.uid b/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd.uid
new file mode 100644
index 0000000..58e1061
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd.uid
@@ -0,0 +1 @@
+uid://dllu6e8jp1nuk
diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd
new file mode 100644
index 0000000..0fd8fc7
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd
@@ -0,0 +1,90 @@
+@tool
+extends "res://addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd"
+## Runner implementation used by the editor UI.[br]
+## [br]
+## This runner connects to a GdUnit server via TCP to report test results.[br]
+## Test results are reported in real-time and displayed in the editor UI.[br]
+## [br]
+## The runner uses an RPC message protocol to communicate status and events:[br]
+## - Messages to report progress[br]
+## - Events to report test results[br]
+
+## The TCP client used to connect to the GdUnit server
+@onready var _client: GdUnitTcpClient = $GdUnitTcpClient
+@onready var _version_label: Control = %Version
+
+
+func _init() -> void:
+ super()
+ # We set the default max report history to 1
+ max_report_history = 1
+
+
+func _ready() -> void:
+ super()
+ GdUnit4Version.init_version_label(_version_label)
+
+ var config_result := _runner_config.load_config()
+ if config_result.is_error():
+ push_error(config_result.error_message())
+ _state = EXIT
+ return
+ @warning_ignore("return_value_discarded")
+ _client.connection_failed.connect(_on_connection_failed)
+ GdUnitSignals.instance().gdunit_message.connect(_on_send_message)
+ _executor.fail_fast(_runner_config.is_fail_fast())
+ var result := _client.start("127.0.0.1", _runner_config.server_port())
+ if result.is_error():
+ push_error(result.error_message())
+ return
+
+
+## Cleanup and quit the runner.[br]
+## [br]
+## [param code] The exit code to return.
+func quit(code: int) -> void:
+ if code != RETURN_SUCCESS:
+ _state = EXIT
+ await GdUnitMemoryObserver.gc_on_guarded_instances()
+ await super.quit(code)
+
+
+## Called when the TCP connection to the GdUnit server fails.[br]
+## Stops the test execution.[br]
+## [br]
+## [param message] The error message describing the failure.
+func _on_connection_failed(message: String) -> void:
+ prints("_on_connection_failed", message)
+ _state = STOP
+
+
+## Initializes the test runner.[br]
+## Waits for TCP client connection and then scans for test suites.[br]
+## Reports the number of found test suites via TCP message.
+func init_runner() -> void:
+ # wait until client is connected to the GdUnitServer
+ if _client.is_client_connected():
+ await gdUnitInit()
+ _state = RUN
+
+
+## Initializes the GdUnit framework.[br]
+## Sends initial message about number of test suites.
+func gdUnitInit() -> void:
+ #enable_manuall_polling()
+ _test_cases = _runner_config.test_cases()
+ await get_tree().process_frame
+
+
+## Sends a message via TCP to the GdUnit server.[br]
+## [br]
+## [param message] The message to send.
+func _on_send_message(message: String) -> void:
+ _client.send(RPCMessage.of(message))
+
+
+## Handles GdUnit events by sending them via TCP to the server.[br]
+## [br]
+## [param event] The event to send.
+func _on_gdunit_event(event: GdUnitEvent) -> void:
+ _client.send(RPCGdUnitEvent.of(event))
diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd.uid b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd.uid
new file mode 100644
index 0000000..fd35f6b
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd.uid
@@ -0,0 +1 @@
+uid://cc77n13jrf5x6
diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestRunner.tscn b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.tscn
new file mode 100644
index 0000000..eaa6f1a
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.tscn
@@ -0,0 +1,34 @@
+[gd_scene load_steps=3 format=3 uid="uid://belidlfknh74r"]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd" id="1"]
+[ext_resource type="Script" path="res://addons/gdUnit4/src/network/GdUnitTcpClient.gd" id="2"]
+
+[node name="Control" type="Node"]
+script = ExtResource("1")
+
+[node name="GdUnitTcpClient" type="Node" parent="."]
+script = ExtResource("2")
+
+[node name="HBoxContainer" type="HBoxContainer" parent="."]
+custom_minimum_size = Vector2(0, 24)
+layout_direction = 2
+anchors_preset = 12
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 0
+size_flags_horizontal = 3
+size_flags_vertical = 10
+alignment = 2
+
+[node name="Version" type="RichTextLabel" parent="HBoxContainer"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(128, 0)
+layout_mode = 2
+size_flags_horizontal = 10
+bbcode_enabled = true
+scroll_active = false
+shortcut_keys_enabled = false
+horizontal_alignment = 1
+justification_flags = 0
diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd b/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd
new file mode 100644
index 0000000..c789847
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd
@@ -0,0 +1,169 @@
+##
+## @since GdUnit4 5.1.0
+##
+## Represents a test execution session in GdUnit4.[br]
+## [br]
+## [i]A test session encapsulates a complete test execution cycle, managing the collection
+## of test cases to be executed and providing communication channels for test events
+## and messages. This class serves as the central coordination point for test execution
+## and allows hooks and other components to interact with the running test session.[/i][br]
+## [br]
+## [b][u]Key Features[/u][/b][br]
+## - [i][b]Test Case Management[/b][/i]: Maintains a collection of test cases to be executed[br]
+## - [i][b]Event Broadcasting[/b][/i]: Forwards GdUnit events to session-specific listeners[br]
+## - [i][b]Message Communication[/b][/i]: Provides a channel for sending messages during test execution[br]
+## - [i][b]Hook Integration[/b][/i]: Passed to test session hooks for startup and shutdown operations[br]
+## [br]
+## [b][u]Usage in Test Hooks[/u][/b]
+## [codeblock]
+## func startup(session: GdUnitTestSession) -> GdUnitResult:
+## # Access test cases
+## print("Running %d test cases" % session.test_cases.size())
+##
+## # Send status messages
+## session.send_message("Custom hook initialized")
+##
+## # Listen for test events
+## session.test_event.connect(_on_test_event)
+##
+## return GdUnitResult.success()
+##
+## func _on_test_event(event: GdUnitEvent) -> void:
+## print("Test event received: %s" % event.type)
+## [/codeblock]
+## [br]
+## [b][u]Event Flow[/u][/b][br]
+## 1. Session is created with a collection of test cases[br]
+## 2. Session connects to the global GdUnit event system[br]
+## 3. During test execution, events are automatically forwarded to session listeners[br]
+## 4. Hooks and other components can subscribe to session events[br]
+## 5. Messages can be sent through the session for logging and communication[br]
+class_name GdUnitTestSession
+extends RefCounted
+
+
+## Emitted when a test execution event occurs.[br]
+## [br]
+## [i]This signal forwards events from the global GdUnit event system to session-specific
+## listeners. It allows hooks and other session components to react to test events
+## without directly connecting to the global event system.[/i][br]
+## [br]
+## [u]Common event types include:[/u][br]
+## - Test suite start/end events[br]
+## - Test case start/end events[br]
+## - Test assertion events[br]
+## - Test failure/error events[br]
+##
+## [param event] The test event containing details about test execution, timing, and results
+@warning_ignore("unused_signal")
+signal test_event(event: GdUnitEvent)
+
+
+## [b][color=red]@readonly: Should not be modified directly during test execution![/color][/b][br]
+## Collection of test cases to be executed in this session.[br]
+## [br]
+## This array contains all the test cases that will be run during the session.
+## Test hooks can access this collection to:
+## - Get the total number of tests to be executed
+## - Access individual test case metadata
+## - Perform setup/teardown based on test case requirements
+## - Generate reports or statistics about the test suite
+##
+## The collection is typically populated before session startup and remains
+## constant during test execution.
+var _test_cases : Array[GdUnitTestCase] = []
+
+
+## [b][color=red]@readonly: The report path should not be modified after session creation![/color][/b][br]
+## The file system path where test reports for this session will be generated.[br]
+## [br]
+## [i]This property provides centralized access to the report output location,
+## allowing test hooks, reporters, and other components to reference the same
+## report path without coupling to specific reporter implementations.[/i][br]
+## [br]
+## [b][u]Common use cases include:[/u][/b][br]
+## - Test hooks generating additional report files in the same directory[br]
+## - Custom reporters creating supplementary output files[br]
+## - Post-processing scripts that need to locate generated reports[br]
+## - Cleanup operations that need to manage report artifacts[br]
+## [br]
+## [b][u]Example Usage:[/u][/b]
+## [codeblock]
+## # In a test hook
+## func startup(session: GdUnitTestSession) -> GdUnitResult:
+## var report_dir = session.report_path.get_base_dir()
+## var custom_report = report_dir.path_join("custom_metrics.json")
+## # Generate additional reports in the same location
+## return GdUnitResult.success()
+##
+## func shutdown(session: GdUnitTestSession) -> GdUnitResult:
+## session.send_message("Reports available at: " + session.report_path)
+## return GdUnitResult.success()
+## [/codeblock]
+## [br]
+## The path is set during session initialization and remains constant throughout
+## the test execution lifecycle.
+var report_path: String:
+ get:
+ return report_path
+
+
+## Initializes the test session and sets up event forwarding.[br]
+## [br]
+## [i]This constructor automatically connects to the global GdUnit event system
+## and forwards all events to the session's test_event signal. This allows
+## session-specific components to listen for test events without managing
+## global signal connections.[/i]
+func _init(test_cases: Array[GdUnitTestCase], session_report_path: String) -> void:
+ # We build a copy to prevent a user is modifing the tests
+ _test_cases = test_cases.duplicate(true)
+ report_path = session_report_path
+ GdUnitSignals.instance().gdunit_event.connect(func(event: GdUnitEvent) -> void:
+ test_event.emit(event)
+ )
+
+
+## Finds a test case by its unique identifier.[br]
+## [br]
+## [i]Searches through all test cases to find a test with the matching GUID.[/i][br]
+## [br]
+## [param id] The GUID of the test to find[br]
+## Returns the matching test case or null if not found.
+func find_test_by_id(id: GdUnitGUID) -> GdUnitTestCase:
+ for test in _test_cases:
+ if test.guid.equals(id):
+ return test
+
+ return null
+
+
+## Sends a message through the GdUnit messaging system.[br]
+## [br]
+## [i]This method provides a convenient way for test hooks and other session
+## components to send messages that will be handled by the GdUnit framework.[/i]
+## [br][br]
+## [b][u]Messages are typically used for:[/u][/b][br]
+## - Status updates during test execution[br]
+## - Progress reporting from test hooks[br]
+## - Debug information and logging[br]
+## - User notifications and alerts[br]
+## [br]
+## The message will be processed by the global GdUnit message system and
+## may be displayed in the test runner UI, logged to files, or handled
+## by other registered message handlers.
+## [br]
+## [b][u]Example Usage:[/u][/b]
+## [codeblock]
+## # In a test hook
+## func startup(session: GdUnitTestSession) -> GdUnitResult:
+## session.send_message("Database connection established")
+## return GdUnitResult.success()
+##
+## func shutdown(session: GdUnitTestSession) -> GdUnitResult:
+## session.send_message("Generated test report: report.html")
+## return GdUnitResult.success()
+## ```
+## [/codeblock]
+## [param message] The message text to send through the GdUnit messaging system
+func send_message(message: String) -> void:
+ GdUnitSignals.instance().gdunit_message.emit(message)
diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd.uid b/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd.uid
new file mode 100644
index 0000000..aa729ff
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd.uid
@@ -0,0 +1 @@
+uid://dj37sbm0qh3sj
diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd b/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd
new file mode 100644
index 0000000..6551e08
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd
@@ -0,0 +1,180 @@
+extends Node
+## The base test runner implementation.[br]
+## [br]
+## This class provides the core functionality to execute test suites with following features:[br]
+## - Loading and initialization of test suites[br]
+## - Executing test suites and managing test states[br]
+## - Event dispatching and test reporting[br]
+## - Support for headless mode[br]
+## - Plugin version verification[br]
+## [br]
+## Supported by specialized runners:[br]
+## - [b]GdUnitTestRunner[/b]: Used in the editor, connects via tcp to report test results[br]
+## - [b]GdUnitCLRunner[/b]: A command line interface runner, writes test reports to file[br]
+## The test runner runs checked default in fail-fast mode, it stops checked first test failure.
+
+## Overall test run status codes used by the runners
+const RETURN_SUCCESS = 0
+const RETURN_ERROR = 100
+const RETURN_ERROR_HEADLESS_NOT_SUPPORTED = 103
+const RETURN_ERROR_GODOT_VERSION_NOT_SUPPORTED = 104
+const RETURN_WARNING = 101
+
+## Specifies the Node name under which the runner is registered
+const GDUNIT_RUNNER = "GdUnitRunner"
+
+## The current runner configuration
+@warning_ignore("unused_private_class_variable")
+var _runner_config := GdUnitRunnerConfig.new()
+
+## The test suite executor instance
+var _executor: GdUnitTestSuiteExecutor
+var _hooks : GdUnitTestSessionHookService
+
+## Current runner state
+var _state := READY
+
+## Current tests to be processed
+var _test_cases: Array[GdUnitTestCase] = []
+
+
+## Configured report base path (can be set on CI test runner)
+var report_base_path: String = GdUnitFileAccess.current_dir() + "reports":
+ get:
+ return report_base_path
+
+
+## Current session report path
+var report_path: String:
+ get:
+ return "%s/%s%d" % [report_base_path, GdUnitConstants.REPORT_DIR_PREFIX, current_report_history_index]
+
+
+## Current report history index, if max_report_history > 1 we scan for the next index over the existing reports
+var current_report_history_index: int:
+ get:
+ if max_report_history > 1:
+ return GdUnitFileAccess.find_last_path_index(report_base_path, GdUnitConstants.REPORT_DIR_PREFIX) + 1
+ else:
+ return 1
+
+
+## Controls how many report historys will be hold
+var max_report_history: int = GdUnitConstants.DEFAULT_REPORT_HISTORY_COUNT:
+ get:
+ return max_report_history
+ set(value):
+ max_report_history = value
+
+
+# holds the current test session context
+var _test_session: GdUnitTestSession
+
+## Runner state machine
+enum {
+ READY,
+ INIT,
+ RUN,
+ STOP,
+ EXIT
+}
+
+func _init() -> void:
+ if OS.get_cmdline_args().size() == 1:
+ DisplayServer.window_set_title("GdUnit4 Runner (Debug Mode)")
+ else:
+ DisplayServer.window_set_title("GdUnit4 Runner (Release Mode)")
+ if not Engine.is_embedded_in_editor():
+ # minimize scene window checked debug mode
+ DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED)
+ # store current runner instance to engine meta data to can be access in as a singleton
+ Engine.set_meta(GDUNIT_RUNNER, self)
+
+
+# Called when the node enters the scene tree for the first time.
+func _ready() -> void:
+ if Engine.get_version_info().hex < 0x40300:
+ printerr("The GdUnit4 plugin requires Godot version 4.3 or higher to run.")
+ quit(RETURN_ERROR_GODOT_VERSION_NOT_SUPPORTED)
+ return
+ _executor = GdUnitTestSuiteExecutor.new()
+
+ GdUnitSignals.instance().gdunit_event.connect(_on_gdunit_event)
+ _state = INIT
+
+
+func _notification(what: int) -> void:
+ if what == NOTIFICATION_PREDELETE:
+ Engine.remove_meta(GDUNIT_RUNNER)
+
+
+## Main test runner loop. Is called every frame to manage the test execution.
+func _process(_delta: float) -> void:
+ match _state:
+ INIT:
+ await init_runner()
+ RUN:
+ _hooks = GdUnitTestSessionHookService.instance()
+ _test_session = GdUnitTestSession.new(_test_cases, report_path)
+ GdUnitSignals.instance().gdunit_event.emit(GdUnitSessionStart.new())
+ # process next test suite
+ set_process(false)
+ var result := await _hooks.execute_startup(_test_session)
+ if result.is_error():
+ push_error(result.error_message())
+ await _executor.run_and_wait(_test_cases)
+ result = await _hooks.execute_shutdown(_test_session)
+ if result.is_error():
+ push_error(result.error_message())
+ _state = STOP
+ set_process(true)
+ GdUnitSignals.instance().gdunit_event.emit(GdUnitSessionClose.new())
+ cleanup_report_history()
+ STOP:
+ _state = EXIT
+ # give the engine small amount time to finish the rpc
+ await get_tree().create_timer(0.1).timeout
+ await quit(get_exit_code())
+
+
+## Used by the inheriting runners to initialize test execution
+func init_runner() -> void:
+ await get_tree().process_frame
+
+
+func cleanup_report_history() -> int:
+ return GdUnitFileAccess.delete_path_index_lower_equals_than(
+ report_path.get_base_dir(),
+ GdUnitConstants.REPORT_DIR_PREFIX,
+ current_report_history_index-1-max_report_history)
+
+
+## Returns the exit code when the test run is finished.[br]
+## Abstract method to be implemented by the inheriting runners.
+func get_exit_code() -> int:
+ return RETURN_SUCCESS
+
+
+## Quits the test runner with given exit code.
+func quit(code: int) -> void:
+ await get_tree().process_frame
+ await get_tree().physics_frame
+ get_tree().quit(code)
+
+
+func prints_warning(message: String) -> void:
+ prints(message)
+
+
+## Default event handler to process test events.[br]
+## Should be overridden by concrete runner implementation.
+@warning_ignore("unused_parameter")
+func _on_gdunit_event(event: GdUnitEvent) -> void:
+ pass
+
+
+## Event bridge from C# GdUnit4.ITestEventListener.cs[br]
+## Used to handle test events from C# tests.
+# gdlint: disable=function-name
+func PublishEvent(data: Dictionary) -> void:
+ _on_gdunit_event(GdUnitEvent.new().deserialize(data))
diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd.uid b/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd.uid
new file mode 100644
index 0000000..cfd20db
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd.uid
@@ -0,0 +1 @@
+uid://wnlvwy04lmn6