diff options
| author | radiohotline <radoihotline@disroot.rg> | 2026-08-25 16:33:56 +0300 |
|---|---|---|
| committer | radiohotline <radoihotline@disroot.rg> | 2026-08-25 16:33:56 +0300 |
| commit | a89e744ed0e8c759caf041a580112365bbfe4303 (patch) | |
| tree | dd61e320904176995727e86a857d00c90d4933ba /addons/gdUnit4/src/reporters | |
| parent | 6d53d8cac78250dc888aa49d5f9d4175c6652feb (diff) | |
Diffstat (limited to 'addons/gdUnit4/src/reporters')
29 files changed, 2262 insertions, 0 deletions
diff --git a/addons/gdUnit4/src/reporters/GdUnitReportSummary.gd b/addons/gdUnit4/src/reporters/GdUnitReportSummary.gd new file mode 100644 index 0000000..b55b964 --- /dev/null +++ b/addons/gdUnit4/src/reporters/GdUnitReportSummary.gd @@ -0,0 +1,202 @@ +class_name GdUnitReportSummary +extends RefCounted + +var _resource_path: String +var _name: String +var _test_count := 0 +var _failure_count := 0 +var _error_count := 0 +var _orphan_count := 0 +var _skipped_count := 0 +var _flaky_count := 0 +var _duration := 0 +var _reports: Array[GdUnitReportSummary] = [] +var _text_formatter: Callable + + +func _init(text_formatter: Callable) -> void: + _text_formatter = text_formatter + + +func name() -> String: + return _name + + +func path() -> String: + return _resource_path.get_base_dir().replace("res://", "") + + +func get_resource_path() -> String: + return _resource_path + + +func suite_count() -> int: + return _reports.size() + + +func suite_executed_count() -> int: + var executed := _reports.size() + for report in _reports: + if report.test_count() == report.skipped_count(): + executed -= 1 + return executed + + +func test_count() -> int: + var count := _test_count + for report in _reports: + count += report.test_count() + return count + + +func test_executed_count() -> int: + return test_count() - skipped_count() + + +func success_count() -> int: + return test_count() - error_count() - failure_count() - flaky_count() - skipped_count() + + +func error_count() -> int: + return _error_count + + +func failure_count() -> int: + return _failure_count + + +func skipped_count() -> int: + return _skipped_count + + +func flaky_count() -> int: + return _flaky_count + + +func orphan_count() -> int: + return _orphan_count + + +func duration() -> int: + return _duration + + +func get_reports() -> Array: + return _reports + + +func add_report(report: GdUnitReportSummary) -> void: + _reports.append(report) + + +func report_state() -> String: + return calculate_state(error_count(), failure_count(), orphan_count(), flaky_count(), skipped_count()) + + +func succes_rate() -> String: + return calculate_succes_rate(test_count(), error_count(), failure_count()) + + +@warning_ignore("shadowed_variable") +func add_testcase(resource_path: String, suite_name: String, test_name: String) -> void: + for report: GdUnitTestSuiteReport in _reports: + if report.get_resource_path() == resource_path: + var test_report := GdUnitTestCaseReport.new(resource_path, suite_name, test_name, _text_formatter) + report.add_or_create_test_report(test_report) + + +func add_reports( + p_resource_path: String, + p_test_name: String, + p_reports: Array[GdUnitReport]) -> void: + + for report:GdUnitTestSuiteReport in _reports: + if report.get_resource_path() == p_resource_path: + report.add_testcase_reports(p_test_name, p_reports) + + +func add_testsuite_report(p_resource_path: String, p_suite_name: String, p_test_count: int) -> void: + _reports.append(GdUnitTestSuiteReport.new(p_resource_path, p_suite_name, p_test_count, _text_formatter)) + + +func add_testsuite_reports( + p_resource_path: String, + p_reports: Array = []) -> void: + + for report:GdUnitTestSuiteReport in _reports: + if report.get_resource_path() == p_resource_path: + report.set_reports(p_reports) + + +func set_counters( + p_resource_path: String, + p_test_name: String, + p_error_count: int, + p_failure_count: int, + p_orphan_count: int, + p_is_skipped: bool, + p_is_flaky: bool, + p_duration: int) -> void: + + for report: GdUnitTestSuiteReport in _reports: + if report.get_resource_path() == p_resource_path: + report.set_testcase_counters(p_test_name, p_error_count, p_failure_count, p_orphan_count, + p_is_skipped, p_is_flaky, p_duration) + + +func update_testsuite_counters( + p_resource_path: String, + p_error_count: int, + p_failure_count: int, + p_orphan_count: int, + p_skipped_count: int, + p_flaky_count: int, + p_duration: int) -> void: + + for report:GdUnitTestSuiteReport in _reports: + if report.get_resource_path() == p_resource_path: + report._update_testsuite_counters(p_error_count, p_failure_count, p_orphan_count, p_skipped_count, p_flaky_count, p_duration) + _update_summary_counters(p_error_count, p_failure_count, p_orphan_count, p_skipped_count, p_flaky_count, 0) + + +func _update_summary_counters( + p_error_count: int, + p_failure_count: int, + p_orphan_count: int, + p_skipped_count: int, + p_flaky_count: int, + p_duration: int) -> void: + + _error_count += p_error_count + _failure_count += p_failure_count + _orphan_count += p_orphan_count + _skipped_count += p_skipped_count + _flaky_count += p_flaky_count + _duration += p_duration + + +func calculate_state(p_error_count :int, p_failure_count :int, p_orphan_count :int, p_flaky_count: int, p_skipped_count: int) -> String: + if p_error_count > 0: + return "ERROR" + if p_failure_count > 0: + return "FAILED" + if p_flaky_count > 0: + return "FLAKY" + if p_orphan_count > 0: + return "WARNING" + if p_skipped_count > 0: + return "SKIPPED" + return "PASSED" + + +func calculate_succes_rate(p_test_count :int, p_error_count :int, p_failure_count :int) -> String: + if p_failure_count == 0: + return "100%" + var count := p_test_count-p_failure_count-p_error_count + if count < 0: + return "0%" + return "%d" % (( 0 if count < 0 else count) * 100.0 / p_test_count) + "%" + + +func create_summary(_report_dir :String) -> String: + return "" diff --git a/addons/gdUnit4/src/reporters/GdUnitReportSummary.gd.uid b/addons/gdUnit4/src/reporters/GdUnitReportSummary.gd.uid new file mode 100644 index 0000000..6092039 --- /dev/null +++ b/addons/gdUnit4/src/reporters/GdUnitReportSummary.gd.uid @@ -0,0 +1 @@ +uid://koedaj41qehf diff --git a/addons/gdUnit4/src/reporters/GdUnitReportWriter.gd b/addons/gdUnit4/src/reporters/GdUnitReportWriter.gd new file mode 100644 index 0000000..bf4c96e --- /dev/null +++ b/addons/gdUnit4/src/reporters/GdUnitReportWriter.gd @@ -0,0 +1,12 @@ +class_name GdUnitReportWriter +extends RefCounted + + +func write(_report_path: String, _report: GdUnitReportSummary) -> String: + assert(false, "'write' is not implemented!") + return "" + + +func output_format() -> String: + assert(false, "'output_format' is not implemented!") + return "" diff --git a/addons/gdUnit4/src/reporters/GdUnitReportWriter.gd.uid b/addons/gdUnit4/src/reporters/GdUnitReportWriter.gd.uid new file mode 100644 index 0000000..c9be1dc --- /dev/null +++ b/addons/gdUnit4/src/reporters/GdUnitReportWriter.gd.uid @@ -0,0 +1 @@ +uid://dcm0384aabejh diff --git a/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd b/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd new file mode 100644 index 0000000..2801696 --- /dev/null +++ b/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd @@ -0,0 +1,47 @@ +class_name GdUnitTestCaseReport +extends GdUnitReportSummary + + +var _suite_name: String +var _failure_reports: Array[GdUnitReport] = [] + + +func _init(p_resource_path: String, p_suite_name: String, p_test_name: String, text_formatter: Callable) -> void: + _resource_path = p_resource_path + _suite_name = p_suite_name + _name = p_test_name + _text_formatter = text_formatter + + +func suite_name() -> String: + return _suite_name + + +func failure_report() -> String: + var report_message := "" + for report in get_test_reports(): + report_message += _text_formatter.call(str(report)) + "\n" + return report_message + + +func add_testcase_reports(reports: Array[GdUnitReport]) -> void: + _failure_reports.append_array(reports) + + +func set_testcase_counters( + p_error_count: int, + p_failure_count: int, + p_orphan_count: int, + p_is_skipped: bool, + p_is_flaky: bool, + p_duration: int) -> void: + _error_count = p_error_count + _failure_count = p_failure_count + _orphan_count = p_orphan_count + _skipped_count = p_is_skipped + _flaky_count = p_is_flaky as int + _duration = p_duration + + +func get_test_reports() -> Array[GdUnitReport]: + return _failure_reports diff --git a/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd.uid b/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd.uid new file mode 100644 index 0000000..eaf88d0 --- /dev/null +++ b/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd.uid @@ -0,0 +1 @@ +uid://dnts7oirmp8ad diff --git a/addons/gdUnit4/src/reporters/GdUnitTestReporter.gd b/addons/gdUnit4/src/reporters/GdUnitTestReporter.gd new file mode 100644 index 0000000..46b8193 --- /dev/null +++ b/addons/gdUnit4/src/reporters/GdUnitTestReporter.gd @@ -0,0 +1,112 @@ +class_name GdUnitTestReporter +extends RefCounted + + +var _statistics := {} +var _summary := {} + + +func init_summary() -> void: + _summary["suite_count"] = 0 + _summary["total_count"] = 0 + _summary["error_count"] = 0 + _summary["failed_count"] = 0 + _summary["skipped_count"] = 0 + _summary["flaky_count"] = 0 + _summary["orphan_nodes"] = 0 + _summary["elapsed_time"] = 0 + + +func init_statistics() -> void: + _statistics.clear() + + +func add_test_statistics(event: GdUnitEvent) -> void: + _statistics[event.guid()] = { + "error_count" : event.error_count(), + "failed_count" : event.failed_count(), + "skipped_count" : event.skipped_count(), + "flaky_count" : event.is_flaky() as int, + "orphan_nodes" : event.orphan_nodes() + } + + +func build_test_suite_statisitcs(event: GdUnitEvent) -> Dictionary: + var statistic := { + "total_count" : _statistics.size(), + "error_count" : event.error_count(), + "failed_count" : event.failed_count(), + "skipped_count" : event.skipped_count(), + "flaky_count" : 0, + "orphan_nodes" : event.orphan_nodes() + } + _summary["suite_count"] += 1 + _summary["total_count"] += _statistics.size() + _summary["error_count"] += event.error_count() + _summary["failed_count"] += event.failed_count() + _summary["skipped_count"] += event.skipped_count() + _summary["orphan_nodes"] += event.orphan_nodes() + _summary["elapsed_time"] += event.elapsed_time() + + for key: String in ["error_count", "failed_count", "skipped_count", "flaky_count", "orphan_nodes"]: + var value: int = _statistics.values().reduce(get_value.bind(key), 0 ) + statistic[key] += value + _summary[key] += value + + return statistic + + +func get_value(acc: int, value: Dictionary, key: String) -> int: + return acc + value[key] + + +func processed_suite_count() -> int: + return _summary["suite_count"] + + +func total_test_count() -> int: + return _summary["total_count"] + + +func total_flaky_count() -> int: + return _summary["flaky_count"] + + +func total_error_count() -> int: + return _summary["error_count"] + + +func total_failure_count() -> int: + return _summary["failed_count"] + + +func total_skipped_count() -> int: + return _summary["skipped_count"] + + +func total_orphan_count() -> int: + return _summary["orphan_nodes"] + + +func elapsed_time() -> int: + return _summary["elapsed_time"] + + +func error_count(statistics: Dictionary) -> int: + return statistics["error_count"] + + +func failed_count(statistics: Dictionary) -> int: + return statistics["failed_count"] + + +func orphan_nodes(statistics: Dictionary) -> int: + return statistics["orphan_nodes"] + + +func skipped_count(statistics: Dictionary) -> int: + return statistics["skipped_count"] + + +func flaky_count(statistics: Dictionary) -> int: + return statistics["flaky_count"] diff --git a/addons/gdUnit4/src/reporters/GdUnitTestReporter.gd.uid b/addons/gdUnit4/src/reporters/GdUnitTestReporter.gd.uid new file mode 100644 index 0000000..30ffd28 --- /dev/null +++ b/addons/gdUnit4/src/reporters/GdUnitTestReporter.gd.uid @@ -0,0 +1 @@ +uid://5u0wypoy6una diff --git a/addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd b/addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd new file mode 100644 index 0000000..9be0682 --- /dev/null +++ b/addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd @@ -0,0 +1,96 @@ +class_name GdUnitTestSuiteReport +extends GdUnitReportSummary + +var _time_stamp: int +var _failure_reports: Array[GdUnitReport] = [] + + +func _init(p_resource_path: String, p_name: String, p_test_count: int, text_formatter: Callable) -> void: + _resource_path = p_resource_path + _name = p_name + _test_count = p_test_count + _time_stamp = Time.get_unix_time_from_system() as int + _text_formatter = text_formatter + + +func failure_report() -> String: + var report_message := "" + for report in _failure_reports: + report_message += _text_formatter.call(str(report)) + return report_message + + +func set_duration(p_duration :int) -> void: + _duration = p_duration + + +func time_stamp() -> int: + return _time_stamp + + +func duration() -> int: + return _duration + + +func set_skipped(skipped :int) -> void: + _skipped_count += skipped + + +func set_orphans(orphans :int) -> void: + _orphan_count = orphans + + +func set_failed(count :int) -> void: + _failure_count += count + + +func set_reports(failure_reports :Array[GdUnitReport]) -> void: + _failure_reports = failure_reports + + +func add_or_create_test_report(test_report: GdUnitTestCaseReport) -> void: + _reports.append(test_report) + + +func _update_testsuite_counters( + p_error_count: int, + p_failure_count: int, + p_orphan_count: int, + p_skipped_count: int, + p_flaky_count: int, + p_duration: int) -> void: + _error_count += p_error_count + _failure_count += p_failure_count + _orphan_count += p_orphan_count + _skipped_count += p_skipped_count + _flaky_count += p_flaky_count + _duration += p_duration + + +func set_testcase_counters( + test_name: String, + p_error_count: int, + p_failure_count: int, + p_orphan_count: int, + p_is_skipped: bool, + p_is_flaky: bool, + p_duration: int) -> void: + if _reports.is_empty(): + return + var test_report: GdUnitTestCaseReport = _reports.filter(func (report: GdUnitTestCaseReport) -> bool: + return report.name() == test_name + ).back() + if test_report: + test_report.set_testcase_counters(p_error_count, p_failure_count, p_orphan_count, p_is_skipped, p_is_flaky, p_duration) + + +func add_testcase_reports(test_name: String, reports: Array[GdUnitReport]) -> void: + if reports.is_empty(): + return + # we lookup to latest matching report because of flaky tests could be retry the tests + # and resultis in multipe report entries with the same name + var test_report: GdUnitTestCaseReport = _reports.filter(func (report: GdUnitTestCaseReport) -> bool: + return report.name() == test_name + ).back() + if test_report: + test_report.add_testcase_reports(reports) diff --git a/addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd.uid b/addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd.uid new file mode 100644 index 0000000..2784de4 --- /dev/null +++ b/addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd.uid @@ -0,0 +1 @@ +uid://dyii7lcvudbf8 diff --git a/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd b/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd new file mode 100644 index 0000000..9978deb --- /dev/null +++ b/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd @@ -0,0 +1,235 @@ +@tool +class_name GdUnitConsoleTestReporter + + +var test_session: GdUnitTestSession: + get: + return test_session + set(value): + # disconnect first possible connected listener + if test_session != null: + test_session.test_event.disconnect(on_gdunit_event) + # add listening to current session + test_session = value + if test_session != null: + test_session.test_event.connect(on_gdunit_event) + + +var _writer: GdUnitMessageWriter +var _reporter: GdUnitTestReporter = GdUnitTestReporter.new() +var _status_indent := 86 +var _detailed: bool +var _text_color: Color = Color.ANTIQUE_WHITE +var _function_color: Color = Color.ANTIQUE_WHITE +var _engine_type_color: Color = Color.ANTIQUE_WHITE + + +func _init(writer: GdUnitMessageWriter, detailed := false) -> void: + _writer = writer + _writer.clear() + _detailed = detailed + if _detailed: + _status_indent = 20 + init_colors() + + +func init_colors() -> void: + if Engine.is_editor_hint(): + var settings := EditorInterface.get_editor_settings() + _text_color = settings.get_setting("text_editor/theme/highlighting/text_color") + _function_color = settings.get_setting("text_editor/theme/highlighting/function_color") + _engine_type_color = settings.get_setting("text_editor/theme/highlighting/engine_type_color") + + +func clear() -> void: + _writer.clear() + + +func on_gdunit_event(event: GdUnitEvent) -> void: + match event.type(): + GdUnitEvent.INIT: + _reporter.init_summary() + + GdUnitEvent.STOP: + _print_summary() + println_message(build_executed_test_suite_msg(processed_suite_count(), processed_suite_count()), Color.DARK_SALMON) + println_message(build_executed_test_case_msg(total_test_count(), total_skipped_count()), Color.DARK_SALMON) + println_message("Total execution time: %s" % LocalTime.elapsed(elapsed_time()), Color.DARK_SALMON) + # We need finally to set the wave effect to enable the animations + _writer.effect(GdUnitMessageWriter.Effect.WAVE).print_at("", 0) + + GdUnitEvent.TESTSUITE_BEFORE: + _reporter.init_statistics() + print_message("Run Test Suite: ", Color.DARK_TURQUOISE) + println_message(event.resource_path(), _engine_type_color) + + GdUnitEvent.TESTSUITE_AFTER: + if not event.reports().is_empty(): + _writer.indent(1).color(_engine_type_color).print_message(event._suite_name) + print_message(" > ") + print_message("finalize()", _function_color) + _print_failure_report(event.reports()) + _print_statistics(_reporter.build_test_suite_statisitcs(event)) + _print_status(event) + println_message("") + if _detailed: + println_message("") + + GdUnitEvent.TESTCASE_BEFORE: + var test := test_session.find_test_by_id(event.guid()) + _print_test_path(test, event.guid()) + if _detailed: + _writer.color(Color.FOREST_GREEN).print_at("STARTED", _status_indent) + println_message("") + + GdUnitEvent.TESTCASE_AFTER: + _reporter.add_test_statistics(event) + if _detailed: + var test := test_session.find_test_by_id(event.guid()) + _print_test_path(test, event.guid()) + _print_status(event) + _print_failure_report(event.reports()) + if _detailed: + println_message("") + + +func _print_test_path(test: GdUnitTestCase, uid: GdUnitGUID) -> void: + if test == null: + prints_warning("Can't print full test info, the test by uid: '%s' was not discovered." % uid) + _writer.indent(1).color(_engine_type_color).print_message("Test ID: %s" % uid) + return + + var suite_name := test.source_file if _detailed else test.suite_name + _writer.indent(1).color(_engine_type_color).print_message(suite_name) + print_message(" > ") + print_message(test.display_name, _function_color) + + +func _print_status(event: GdUnitEvent) -> void: + if event.is_flaky() and event.is_success(): + var retries: int = event.statistic(GdUnitEvent.RETRY_COUNT) + _writer.color(Color.GREEN_YELLOW) \ + .style(GdUnitMessageWriter.ITALIC) \ + .print_at("FLAKY (%d retries)" % retries, _status_indent) + elif event.is_success(): + _writer.color(Color.FOREST_GREEN).print_at("PASSED", _status_indent) + elif event.is_skipped(): + _writer.color(Color.GOLDENROD).style(GdUnitMessageWriter.ITALIC).print_at("SKIPPED", _status_indent) + elif event.is_failed() or event.is_error(): + var retries: int = event.statistic(GdUnitEvent.RETRY_COUNT) + var message := "FAILED (retry %d)" % retries if retries > 1 else "FAILED" + _writer.color(Color.FIREBRICK) \ + .style(GdUnitMessageWriter.BOLD) \ + .effect(GdUnitMessageWriter.Effect.WAVE) \ + .print_at(message, _status_indent) + elif event.is_warning(): + _writer.color(Color.GOLDENROD) \ + .style(GdUnitMessageWriter.UNDERLINE) \ + .print_at("WARNING", _status_indent) + + println_message(" %s" % LocalTime.elapsed(event.elapsed_time()), Color.CORNFLOWER_BLUE) + + +func _print_failure_report(reports: Array[GdUnitReport]) -> void: + for report in reports: + if ( + report.is_failure() + or report.is_error() + or report.is_warning() + or report.is_skipped() + or report.is_orphan() + ): + _writer.indent(1) \ + .color(Color.DARK_TURQUOISE) \ + .style(GdUnitMessageWriter.BOLD | GdUnitMessageWriter.UNDERLINE) \ + .println_message("Report:") + var text := str(report) + for line in text.split("\n", false): + _writer.indent(2).color(Color.DARK_TURQUOISE).println_message(line) + + if not reports.is_empty(): + println_message("") + + +func _print_statistics(statistics: Dictionary) -> void: + print_message("Statistics:", Color.DODGER_BLUE) + print_message(" %d test cases | %d errors | %d failures | %d flaky | %d skipped | %d orphans |" % \ + [statistics["total_count"], + statistics["error_count"], + statistics["failed_count"], + statistics["flaky_count"], + statistics["skipped_count"], + statistics["orphan_nodes"]]) + + +func _print_summary() -> void: + print_message("Overall Summary:", Color.DODGER_BLUE) + _writer \ + .println_message(" %d test cases | %d errors | %d failures | %d flaky | %d skipped | %d orphans |" % [ + total_test_count(), + total_error_count(), + total_failure_count(), + total_flaky_count(), + total_skipped_count(), + total_orphan_count() + ]) + + +func build_executed_test_suite_msg(executed_count: int, total_count: int) -> String: + if executed_count == total_count: + return "Executed test suites: (%d/%d)" % [executed_count, total_count] + return "Executed test suites: (%d/%d), %d skipped" % [executed_count, total_count, (total_count - executed_count)] + + +func build_executed_test_case_msg(total_count: int, p_skipped_count: int) -> String: + if p_skipped_count == 0: + return "Executed test cases : (%d/%d)" % [total_count, total_count] + return "Executed test cases : (%d/%d), %d skipped" % [total_count - p_skipped_count, total_count, p_skipped_count] + + +func print_message(message: String, color: Color = _text_color) -> void: + _writer.color(color).print_message(message) + + +func println_message(message: String, color: Color = _text_color) -> void: + _writer.color(color).println_message(message) + + +func prints_warning(message: String) -> void: + _writer.prints_warning(message) + + +func prints_error(message: String) -> void: + _writer.prints_error(message) + + +func total_test_count() -> int: + return _reporter.total_test_count() + + +func total_error_count() -> int: + return _reporter.total_error_count() + + +func total_failure_count() -> int: + return _reporter.total_failure_count() + + +func total_flaky_count() -> int: + return _reporter.total_flaky_count() + + +func total_skipped_count() -> int: + return _reporter.total_skipped_count() + + +func total_orphan_count() -> int: + return _reporter.total_orphan_count() + + +func processed_suite_count() -> int: + return _reporter.processed_suite_count() + + +func elapsed_time() -> int: + return _reporter.elapsed_time() diff --git a/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd.uid b/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd.uid new file mode 100644 index 0000000..7ca4b35 --- /dev/null +++ b/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd.uid @@ -0,0 +1 @@ +uid://d0bs18q20xbdb diff --git a/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd b/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd new file mode 100644 index 0000000..74b961b --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd @@ -0,0 +1,60 @@ +class_name GdUnitByPathReport +extends GdUnitReportSummary + + +func _init(p_path :String, report_summaries :Array[GdUnitReportSummary]) -> void: + _resource_path = p_path + _reports = report_summaries + + +# -> Dictionary[String, Array[GdUnitReportSummary]] +static func sort_reports_by_path(report_summaries :Array[GdUnitReportSummary]) -> Dictionary: + var by_path := Dictionary() + for report in report_summaries: + var suite_path :String = ProjectSettings.localize_path(report.path()) + var suite_report :Array[GdUnitReportSummary] = by_path.get(suite_path, [] as Array[GdUnitReportSummary]) + suite_report.append(report) + by_path[suite_path] = suite_report + return by_path + + +func path() -> String: + return _resource_path.replace("res://", "").trim_suffix("/") + + +func create_record(report_link :String) -> String: + return GdUnitHtmlPatterns.build(GdUnitHtmlPatterns.TABLE_RECORD_PATH, self, report_link) + + +func write(report_dir :String) -> String: + calculate_summary() + var template := GdUnitHtmlPatterns.load_template("res://addons/gdUnit4/src/reporters/html/template/folder_report.html") + var path_report := GdUnitHtmlPatterns.build(template, self, "") + path_report = apply_testsuite_reports(report_dir, path_report, _reports) + + var output_path := "%s/path/%s.html" % [report_dir, path().replace("/", ".")] + var dir := output_path.get_base_dir() + if not DirAccess.dir_exists_absolute(dir): + @warning_ignore("return_value_discarded") + DirAccess.make_dir_recursive_absolute(dir) + FileAccess.open(output_path, FileAccess.WRITE).store_string(path_report) + return output_path + + +func apply_testsuite_reports(report_dir :String, template :String, test_suite_reports :Array[GdUnitReportSummary]) -> String: + var table_records := PackedStringArray() + for report:GdUnitTestSuiteReport in test_suite_reports: + var report_link := GdUnitHtmlReportWriter.create_output_path(report_dir, report.path(), report.name()).replace(report_dir, "..") + @warning_ignore("return_value_discarded") + table_records.append(GdUnitHtmlPatterns.create_suite_record(report_link, report)) + return template.replace(GdUnitHtmlPatterns.TABLE_BY_TESTSUITES, "\n".join(table_records)) + + +func calculate_summary() -> void: + for report:GdUnitTestSuiteReport in get_reports(): + _error_count += report.error_count() + _failure_count += report.failure_count() + _orphan_count += report.orphan_count() + _skipped_count += report.skipped_count() + _flaky_count += report.flaky_count() + _duration += report.duration() diff --git a/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd.uid b/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd.uid new file mode 100644 index 0000000..607e423 --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd.uid @@ -0,0 +1 @@ +uid://ct0fnu7t8q2lw diff --git a/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd b/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd new file mode 100644 index 0000000..b8be904 --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd @@ -0,0 +1,199 @@ +class_name GdUnitHtmlPatterns +extends RefCounted + +const TABLE_RECORD_TESTSUITE = """ + <tr class="${report_state}"> + <td><a href=${report_link}>${testsuite_name}</a></td> + <td><span class="status status-${report_state}">${report_state_label}</span></td> + <td>${test_count}</td> + <td>${skipped_count}</td> + <td>${flaky_count}</td> + <td>${failure_count}</td> + <td>${orphan_count}</td> + <td>${duration}</td> + <td> + <div class="status-bar"> + <div class="status-bar-column status-skipped" style="width: ${skipped-percent};"></div> + <div class="status-bar-column status-passed" style="width: ${passed-percent};"></div> + <div class="status-bar-column status-flaky" style="width: ${flaky-percent};"></div> + <div class="status-bar-column status-error" style="width: ${error-percent};"></div> + <div class="status-bar-column status-failed" style="width: ${failed-percent};"></div> + <div class="status-bar-column status-warning" style="width: ${warning-percent};"></div> + </div> + </td> + </tr> +""" + +const TABLE_RECORD_PATH = """ + <tr class="${report_state}"> + <td><a class="${report_state}" href="${report_link}">${path}</a></td> + <td><span class="status status-${report_state}">${report_state_label}</span></td> + <td>${test_count}</td> + <td>${skipped_count}</td> + <td>${flaky_count}</td> + <td>${failure_count}</td> + <td>${orphan_count}</td> + <td>${duration}</td> + <td> + <div class="status-bar"> + <div class="status-bar-column status-skipped" style="width: ${skipped-percent};"></div> + <div class="status-bar-column status-passed" style="width: ${passed-percent};"></div> + <div class="status-bar-column status-flaky" style="width: ${flaky-percent};"></div> + <div class="status-bar-column status-error" style="width: ${error-percent};"></div> + <div class="status-bar-column status-failed" style="width: ${failed-percent};"></div> + <div class="status-bar-column status-warning" style="width: ${warning-percent};"></div> + </div> + </td> + </tr> +""" + + +const TABLE_REPORT_TESTSUITE = """ + <tr class="${report_state}"> + <td>TestSuite hooks</td> + <td>n/a</td> + <td>${orphan_count}</td> + <td>${duration}</td> + <td class="report-column"> + <pre> +${failure-report} + </pre> + </td> + </tr> +""" + + +const TABLE_RECORD_TESTCASE = """ + <tr class="testcase-group"> + <td>${testcase_name}</td> + <td><span class="status status-${report_state}">${report_state_label}</span></td> + <td>${skipped_count}</td> + <td>${orphan_count}</td> + <td>${duration}</td> + <td class="report-column"> + <pre> +${failure-report} + </pre> + </td> + </tr> +""" + +const CHARACTERS_TO_ENCODE := { + '<' : '<', + '>' : '>' +} + +const TABLE_BY_PATHS = "${report_table_paths}" +const TABLE_BY_TESTSUITES = "${report_table_testsuites}" +const TABLE_BY_TESTCASES = "${report_table_tests}" + +# the report state success, error, warning +const REPORT_STATE = "${report_state}" +const REPORT_STATE_LABEL = "${report_state_label}" +const PATH = "${path}" +const RESOURCE_PATH = "${resource_path}" +const TESTSUITE_COUNT = "${suite_count}" +const TESTCASE_COUNT = "${test_count}" +const FAILURE_COUNT = "${failure_count}" +const FLAKY_COUNT = "${flaky_count}" +const SKIPPED_COUNT = "${skipped_count}" +const ORPHAN_COUNT = "${orphan_count}" +const DURATION = "${duration}" +const FAILURE_REPORT = "${failure-report}" +const SUCCESS_PERCENT = "${success_percent}" + + +const QUICK_STATE_SKIPPED = "${skipped-percent}" +const QUICK_STATE_PASSED = "${passed-percent}" +const QUICK_STATE_FLAKY = "${flaky-percent}" +const QUICK_STATE_ERROR = "${error-percent}" +const QUICK_STATE_FAILED = "${failed-percent}" +const QUICK_STATE_WARNING = "${warning-percent}" + +const TESTSUITE_NAME = "${testsuite_name}" +const TESTCASE_NAME = "${testcase_name}" +const REPORT_LINK = "${report_link}" +const BREADCRUMP_PATH_LINK = "${breadcrumb_path_link}" +const BUILD_DATE = "${buid_date}" + + +static func current_date() -> String: + return Time.get_datetime_string_from_system(true, true) + + +static func build(template: String, report: GdUnitReportSummary, report_link: String) -> String: + return template\ + .replace(PATH, get_report_path(report))\ + .replace(BREADCRUMP_PATH_LINK, get_path_as_link(report))\ + .replace(RESOURCE_PATH, report.get_resource_path())\ + .replace(TESTSUITE_NAME, html_encoded(report.name()))\ + .replace(TESTSUITE_COUNT, str(report.suite_count()))\ + .replace(TESTCASE_COUNT, str(report.test_count()))\ + .replace(FAILURE_COUNT, str(report.error_count() + report.failure_count()))\ + .replace(FLAKY_COUNT, str(report.flaky_count()))\ + .replace(SKIPPED_COUNT, str(report.skipped_count()))\ + .replace(ORPHAN_COUNT, str(report.orphan_count()))\ + .replace(DURATION, LocalTime.elapsed(report.duration()))\ + .replace(SUCCESS_PERCENT, report.calculate_succes_rate(report.test_count(), report.error_count(), report.failure_count()))\ + .replace(REPORT_STATE, report.report_state().to_lower())\ + .replace(REPORT_STATE_LABEL, report.report_state())\ + .replace(QUICK_STATE_SKIPPED, calculate_percentage(report.test_count(), report.skipped_count()))\ + .replace(QUICK_STATE_PASSED, calculate_percentage(report.test_count(), report.success_count()))\ + .replace(QUICK_STATE_FLAKY, calculate_percentage(report.test_count(), report.flaky_count()))\ + .replace(QUICK_STATE_ERROR, calculate_percentage(report.test_count(), report.error_count()))\ + .replace(QUICK_STATE_FAILED, calculate_percentage(report.test_count(), report.failure_count()))\ + .replace(QUICK_STATE_WARNING, calculate_percentage(report.test_count(), 0))\ + .replace(REPORT_LINK, report_link)\ + .replace(BUILD_DATE, current_date()) + + +static func load_template(template_name :String) -> String: + return FileAccess.open(template_name, FileAccess.READ).get_as_text() + + +static func get_path_as_link(report: GdUnitReportSummary) -> String: + return "../path/%s.html" % report.path().replace("/", ".") + + +static func get_report_path(report: GdUnitReportSummary) -> String: + var path := report.path() + if path.is_empty(): + return "/" + return path + + +static func calculate_percentage(p_test_count: int, count: int) -> String: + if count <= 0: + return "0%" + return "%d" % (( 0 if count < 0 else count) * 100.0 / p_test_count) + "%" + + +static func html_encoded(value: String) -> String: + for key: String in CHARACTERS_TO_ENCODE.keys(): + @warning_ignore("unsafe_cast") + value = value.replace(key, CHARACTERS_TO_ENCODE[key] as String) + return value + + +static func create_suite_record(report_link: String, report: GdUnitTestSuiteReport) -> String: + return GdUnitHtmlPatterns.build(GdUnitHtmlPatterns.TABLE_RECORD_TESTSUITE, report, report_link) + + +static func create_test_failure_report(_report_dir :String, report: GdUnitTestCaseReport) -> String: + return GdUnitHtmlPatterns.TABLE_RECORD_TESTCASE\ + .replace(GdUnitHtmlPatterns.REPORT_STATE, report.report_state().to_lower())\ + .replace(GdUnitHtmlPatterns.REPORT_STATE_LABEL, report.report_state())\ + .replace(GdUnitHtmlPatterns.TESTCASE_NAME, report.name())\ + .replace(GdUnitHtmlPatterns.SKIPPED_COUNT, str(report.skipped_count()))\ + .replace(GdUnitHtmlPatterns.ORPHAN_COUNT, str(report.orphan_count()))\ + .replace(GdUnitHtmlPatterns.DURATION, LocalTime.elapsed(report._duration))\ + .replace(GdUnitHtmlPatterns.FAILURE_REPORT, report.failure_report()) + + +static func create_suite_failure_report(report: GdUnitTestSuiteReport) -> String: + return GdUnitHtmlPatterns.TABLE_REPORT_TESTSUITE\ + .replace(GdUnitHtmlPatterns.REPORT_STATE, report.report_state().to_lower())\ + .replace(GdUnitHtmlPatterns.REPORT_STATE_LABEL, report.report_state())\ + .replace(GdUnitHtmlPatterns.ORPHAN_COUNT, str(report.orphan_count()))\ + .replace(GdUnitHtmlPatterns.DURATION, LocalTime.elapsed(report._duration))\ + .replace(GdUnitHtmlPatterns.FAILURE_REPORT, report.failure_report()) diff --git a/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd.uid b/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd.uid new file mode 100644 index 0000000..aa548b9 --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd.uid @@ -0,0 +1 @@ +uid://g7tdvdatyesm diff --git a/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd b/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd new file mode 100644 index 0000000..a3d2bff --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd @@ -0,0 +1,72 @@ +class_name GdUnitHtmlReportWriter +extends GdUnitReportWriter + + +func output_format() -> String: + return "HTML" + + +func write(report_path: String, report: GdUnitReportSummary) -> String: + var template := GdUnitHtmlPatterns.load_template("res://addons/gdUnit4/src/reporters/html/template/index.html") + var to_write := GdUnitHtmlPatterns.build(template, report, "") + to_write = _apply_path_reports(report_path, to_write, report.get_reports()) + to_write = _apply_testsuite_reports(report_path, to_write, report.get_reports()) + # write report + DirAccess.make_dir_recursive_absolute(report_path) + var html_report_file := "%s/index.html" % report_path + FileAccess.open(html_report_file, FileAccess.WRITE).store_string(to_write) + @warning_ignore("return_value_discarded") + GdUnitFileAccess.copy_directory("res://addons/gdUnit4/src/reporters/html/template/css/", report_path + "/css") + return html_report_file + + +func _apply_path_reports(report_dir: String, template: String, report_summaries: Array) -> String: + #Dictionary[String, Array[GdUnitReportSummary]] + var path_report_mapping := GdUnitByPathReport.sort_reports_by_path(report_summaries) + var table_records := PackedStringArray() + var paths: Array[String] = [] + paths.append_array(path_report_mapping.keys()) + paths.sort() + for report_at_path in paths: + var reports: Array[GdUnitReportSummary] = path_report_mapping.get(report_at_path) + var report := GdUnitByPathReport.new(report_at_path, reports) + var report_link: String = report.write(report_dir).replace(report_dir, ".") + @warning_ignore("return_value_discarded") + table_records.append(report.create_record(report_link)) + return template.replace(GdUnitHtmlPatterns.TABLE_BY_PATHS, "\n".join(table_records)) + + +func _apply_testsuite_reports(report_dir: String, template: String, test_suite_reports: Array[GdUnitReportSummary]) -> String: + var table_records := PackedStringArray() + for report: GdUnitTestSuiteReport in test_suite_reports: + var report_link: String = _write(report_dir, report).replace(report_dir, ".") + @warning_ignore("return_value_discarded") + table_records.append(GdUnitHtmlPatterns.create_suite_record(report_link, report)) + return template.replace(GdUnitHtmlPatterns.TABLE_BY_TESTSUITES, "\n".join(table_records)) + + +func _write(report_dir :String, report: GdUnitTestSuiteReport) -> String: + var template := GdUnitHtmlPatterns.load_template("res://addons/gdUnit4/src/reporters/html/template/suite_report.html") + template = GdUnitHtmlPatterns.build(template, report, "") + + var report_output_path := create_output_path(report_dir, report.path(), report.name()) + var test_report_table := PackedStringArray() + if not report._failure_reports.is_empty(): + @warning_ignore("return_value_discarded") + test_report_table.append(GdUnitHtmlPatterns.create_suite_failure_report(report)) + for test_report: GdUnitTestCaseReport in report._reports: + @warning_ignore("return_value_discarded") + test_report_table.append(GdUnitHtmlPatterns.create_test_failure_report(report_output_path, test_report)) + + template = template.replace(GdUnitHtmlPatterns.TABLE_BY_TESTCASES, "\n".join(test_report_table)) + + var dir := report_output_path.get_base_dir() + if not DirAccess.dir_exists_absolute(dir): + @warning_ignore("return_value_discarded") + DirAccess.make_dir_recursive_absolute(dir) + FileAccess.open(report_output_path, FileAccess.WRITE).store_string(template) + return report_output_path + + +static func create_output_path(report_dir :String, path: String, name: String) -> String: + return "%s/test_suites/%s.%s.html" % [report_dir, path.replace("/", "."), name] diff --git a/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd.uid b/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd.uid new file mode 100644 index 0000000..8a9edd6 --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd.uid @@ -0,0 +1 @@ +uid://co0bj8mioxbsk diff --git a/addons/gdUnit4/src/reporters/html/template/.gdignore b/addons/gdUnit4/src/reporters/html/template/.gdignore new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/template/.gdignore diff --git a/addons/gdUnit4/src/reporters/html/template/css/breadcrumb.css b/addons/gdUnit4/src/reporters/html/template/css/breadcrumb.css new file mode 100644 index 0000000..17215ff --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/template/css/breadcrumb.css @@ -0,0 +1,66 @@ +.breadcrumb { + display: flex; + border-radius: 6px; + overflow: hidden; + height: 45px; + z-index: 1; + background-color: #9d73eb; + margin-top: 0px; + margin-bottom: 10px; + box-shadow: 0 0 3px black; +} + +.breadcrumb a { + position: relative; + display: flex; + -ms-flex-positive: 1; + flex-grow: 1; + text-decoration: none; + margin: auto; + height: 100%; + color: white; +} + +.breadcrumb a:first-child { + padding-left: 5.2px; +} + +.breadcrumb a:last-child { + padding-right: 5.2px; +} + +.breadcrumb a:after { + content: ""; + position: absolute; + display: inline-block; + width: 45px; + height: 45px; + top: 0; + right: -20px; + background-color: #9d73eb; + border-top-right-radius: 5px; + transform: scale(0.707) rotate(45deg); + box-shadow: 2px -2px rgba(0, 0, 0, 0.25); + z-index: 1; +} + +.breadcrumb a:last-child:after { + content: none; +} + +.breadcrumb a.active, +.breadcrumb a:hover { + background: #b899f2; + color: white; + text-decoration: underline; +} + +.breadcrumb a.active:after, +.breadcrumb a:hover:after { + background: #b899f2; +} + +.breadcrumb span { + margin: inherit; + z-index: 2; +} diff --git a/addons/gdUnit4/src/reporters/html/template/css/logo.png b/addons/gdUnit4/src/reporters/html/template/css/logo.png Binary files differnew file mode 100644 index 0000000..12de79f --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/template/css/logo.png diff --git a/addons/gdUnit4/src/reporters/html/template/css/styles.css b/addons/gdUnit4/src/reporters/html/template/css/styles.css new file mode 100644 index 0000000..e92d59b --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/template/css/styles.css @@ -0,0 +1,475 @@ +html, +body { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + font-family: sans-serif; + background-color: white; + height: 100%; +} + +main { + flex-grow: 1; + overflow: auto; + margin: 0 10em; +} + + +header { + color: white; + padding: 1px; + position: relative; + background-image: linear-gradient(to bottom right, #8058e3, #9d73eb); +} + +.logo { + position: fixed; + top: 20px; + left: 20px; + display: flex; + align-items: center; + z-index: 1000; + filter: grayscale(1); + mix-blend-mode: plus-lighter; +} + +.logo img { + width: 64px; + height: 64px; +} + +.logo span { + font-size: 1.2em; + color: lightslategray; +} + +.report-container { + margin: 0 15em; + text-align: center; + margin-top: 60px; + flex-grow: 0; +} + +h1 { + margin: 0 0 20px 0; + font-size: 2.5em; + font-weight: normal; +} + +.summary { + display: inline-flex; + justify-content: center; + flex-wrap: nowrap; + margin-bottom: 20px; + align-items: baseline; + max-width: 960px; +} + +.summary-item { + flex: 1; + min-width: 80px; +} + +.label { + font-size: 1em; + flex-wrap: nowrap; +} + +.value { + font-size: 0.9em; + display: block; + padding-top: 10px; + color: lightgray; +} + +.success-rate { + padding-left: 40px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.check-icon { + background-color: #34c538; + color: white; + width: 48px; + height: 48px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.4em; +} + +.rate-text { + text-align: center; + flex-wrap: nowrap; +} + +.percentage { + font-size: 1.2em; + font-weight: bold; +} + + +nav { + padding: 20px 0px; + font-family: monospace; +} + +nav ul { + list-style-type: none; + padding: 0; + margin: 0; + display: flex; + justify-content: flex-start; + border-bottom: 1px solid lightgray; +} + +nav li { + cursor: pointer; + padding: 5px 20px; + font-size: 1.1em; + color: lightslategray; +} + +nav li.active { + color: darkslategray; + border-bottom: 1px solid darkslategray; + font-weight: bold; +} + +div#content { + height: calc(100vh - 400px); +} + + +table { + width: 100%; + height: 100%; + border-collapse: collapse; + overflow: hidden; +} + +thead th { + position: sticky; + top: 0; + background-color: white; + z-index: 1; + border-bottom: 2px solid #ddd; +} + +tbody { + display: block; + /* Limit the height of the table body */ + max-height: calc(100vh - 400px); + /* Enable scrolling on the table body */ + overflow-y: auto; +} + +thead, +tbody tr { + display: table; + width: 100%; + table-layout: fixed; +} + +tbody td { + overflow: hidden; +} + +/* Ensure scrollbar visibility */ +tbody::-webkit-scrollbar { + height: 4px; + width: 14px; +} + +tbody::-webkit-scrollbar-thumb { + background-color: #aaa6a6; + border-radius: 4px; +} + +tbody::-webkit-scrollbar-track { + background-color: #f1f1f1; +} + +th, +td { + font-size: .9em; + padding: 5px 0px; + border-bottom: 1px solid #eee; + color: lightslategrey; + text-align: left; + text-wrap: nowrap; + /* Default max and min width for all columns */ + max-width: 150px; + min-width: 80px; + width: 80px; +} + +th { + font-size: 1em; + font-weight: normal; + padding-top: 20px; + color: gray; + text-wrap: nowrap; +} + +.tab-report { + display: grid; + grid-template-columns: 100%; + margin-bottom: 20px; +} + +.tab-report-grid { + display: grid; + grid-template-columns: 70% 30%; + margin-bottom: 20px; +} + + +/* Specific styling for the first column (Testcase) */ +th:first-child, +td:first-child { + padding-left: 5px; + text-align: left; + /* Max width for the first column */ + min-width: 249px; + width: 250px; + /* Enable scrollbar if content exceeds max-width */ + white-space: nowrap; + overflow: auto; +} + +/* Scrollbar styles for first column */ +td:first-child { + overflow-x: auto; + text-overflow: initial; +} + +/* Scrollbar appearance */ +td:first-child::-webkit-scrollbar { + height: 6px; +} + +td:first-child::-webkit-scrollbar-thumb { + background-color: #888; + border-radius: 10px; +} + +td:first-child::-webkit-scrollbar-track { + background-color: #f1f1f1; +} + +/* Max width for Result column */ +th:nth-child(2), +td:nth-child(2) { + max-width: 140px; + min-width: 140px; + width: 140px; +} + +/* Max width for Quick Results column */ +th:nth-child(9), +td:nth-child(9) { + max-width: 140px; + min-width: 140px; + width: 140px; + padding-right: 10px; +} + +/* Background color for alternating groups */ +.group-bg-1 { + background-color: #f1f1f1; +} + +.group-bg-2 { + background-color: #e0e0e0; +} + +.grid-item { + overflow: auto; + padding-left: 20px; + color: lightslategrey; + max-height: calc(100vh - 350px); +} + +div.tab td.report-column, +th.report-column { + display: none; +} + +/* Result status styles */ +.status { + padding: 2px 40px; + border-radius: 6px; + color: black; + width: 40px; + display: flex; + align-content: center; + align-items: center; +} + +.status-bar { + display: flex; + border-radius: 8px; + overflow: hidden; + height: 20px; + flex-wrap: nowrap; + justify-content: space-evenly; +} + +.status-bar-column { + margin: -2px; + color: black; + display: flex; + align-content: center; + align-items: center; + transition: width 0.3s ease; +} + +.status-skipped { + background-color: #888888; +} + +.status-passed { + background-color: #63bb38; +} + +.status-error { + background-color: #fd1100; +} + +.status-failed { + background-color: #ed594f; +} + +.status-flaky { + background-color: #1d9a1f; +} + +.status-warning { + background-color: #fdda3f; +} + +div.tab tr:hover { + background-color: #d9e7fa; + box-shadow: 0 0 5px black; +} + +div.tab tr.selected { + background-color: #d9e7fa; +} + +div.report-column { + margin-top: 10px; + width: 100%; + text-align: left; +} + +.logging-container { + width: 100%; + height: 100%; +} + +div.godot-report-frame { + margin: 10px; + font-family: monospace; + height: 100%; + background-color: #eee; +} + +div.include-footer { + position: fixed; + bottom: 0; + width: 100%; + display: flex; +} + +footer { + position: static; + left: 0; + bottom: 0; + width: 100%; + white-space: nowrap; + color: lightgray; + font-size: 12px; + background-image: linear-gradient(to bottom right, #8058e3, #9d73eb); + display: flex; + justify-content: space-between; + align-items: center; +} + +footer p { + padding-left: 10em; +} + +footer .status-legend { + display: flex; + gap: 15px; + width: 500px; +} + +footer a { + color: lightgray; +} + +footer a:hover { + color: whitesmoke; +} + +footer a:visited { + color: whitesmoke; +} + +.status-legend-item { + display: flex; + align-items: center; + gap: 5px; +} + +.status-box { + width: 15px; + height: 15px; + border-radius: 3px; + display: inline-block; +} + +/* Normal link */ +a { + color: lightslategrey; +} + +/* Link when hovered */ +a:hover { + color: #9d73eb; +} + +/* Visited link */ +a:visited { + color: #8058e3; +} + +/* Active link (while being clicked) */ +a:active { + color: #8058e3; + /* Custom color when link is clicked */ +} + + +@media (max-width: 1024px) { + .summary { + flex-direction: column; + } + + nav ul { + flex-wrap: wrap; + } + + nav li { + margin-right: 10px; + margin-bottom: 5px; + } +} diff --git a/addons/gdUnit4/src/reporters/html/template/folder_report.html b/addons/gdUnit4/src/reporters/html/template/folder_report.html new file mode 100644 index 0000000..2cdca67 --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/template/folder_report.html @@ -0,0 +1,122 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="UTF-8"> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> + <meta http-equiv="x-ua-compatible" content="IE=edge" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>GdUnit4 Testsuite</title> + <link rel="stylesheet" href="../css/styles.css"> + <link href="../css/breadcrumb.css" rel="stylesheet" type="text/css" /> +</head> + +<body> + <header> + <div class="logo"> + <img src="../css/logo.png" alt="GdUnit4 Logo"> + <span>GdUnit4</span> + </div> + <div class="report-container"> + <h1>Report by Paths</h1> + <div> + <span class="label">${resource_path}</span> + </div> + <div class="summary"> + <div class="summary-item"> + <span class="label">TestSuites</span> + <span class="value">${suite_count}</span> + </div> + <div class="summary-item"> + <span class="label">Tests</span> + <span class="value">${test_count}</span> + </div> + <div class="summary-item"> + <span class="label">Skipped</span> + <span class="value">${skipped_count}</span> + </div> + <div class="summary-item"> + <span class="label">Flaky</span> + <span class="value">${flaky_count}</span> + </div> + <div class="summary-item"> + <span class="label">Failures</span> + <span class="value">${failure_count}</span> + </div> + <div class="summary-item"> + <span class="label">Orphans</span> + <span class="value">${orphan_count}</span> + </div> + <div class="summary-item"> + <span class="label">Duration</span> + <span class="value">${duration}</span> + </div> + <div class="success-rate"> + <div class="check-icon status-${report_state}">✓</div> + <div class="rate-text"> + <span class="label">Success Rate</span> + <span class="value">${success_percent}</span> + </div> + </div> + </div> + </div> + <div class="breadcrumb"> + <a href="../index.html"><span>All</span></a> + <a href="${breadcrumb_path_link}"><span>${path}</span></a> + <a class="active" href="#"><span>${testsuite_name}</span></a> + </div> + </header> + + <main> + <div> + <div class="tab-report"> + <div class="grid-item tab"> + <table id="report-table"> + <thead> + <tr> + <th>TestSuites</th> + <th>Result</th> + <th>Tests</th> + <th>Skipped</th> + <th>Flaky</th> + <th>Failures</th> + <th>Orphans</th> + <th>Duration</th> + <th>Success rate</th> + </tr> + </thead> + <tbody> + ${report_table_testsuites} + </tbody> + </table> + </div> + </div> + </div> + </main> + + <footer> + <p>Generated by <a href="https://github.com/MikeSchulze/gdUnit4">GdUnit4</a> at ${buid_date}</p> + <div class="status-legend"> + <span class="status-legend-item"> + <span class="status-box status-skipped"></span> Skipped + </span> + <span class="status-legend-item"> + <span class="status-box status-passed"></span> Passed + </span> + <span class="status-legend-item"> + <span class="status-box status-flaky"></span> Flaky + </span> + <span class="status-legend-item"> + <span class="status-box status-warning"></span> Warning + </span> + <span class="status-legend-item"> + <span class="status-box status-failed"></span> Failed + </span> + <span class="status-legend-item"> + <span class="status-box status-error"></span> Error + </span> + </div> + </footer> +</body> + +</html> diff --git a/addons/gdUnit4/src/reporters/html/template/index.html b/addons/gdUnit4/src/reporters/html/template/index.html new file mode 100644 index 0000000..342c8f2 --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/template/index.html @@ -0,0 +1,164 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>GdUnit4 Report</title> + <link rel="stylesheet" href="css/styles.css"> +</head> + +<body> + <header> + <div class="logo"> + <img src="css/logo.png" alt="GdUnit4 Logo"> + <span>GdUnit4</span> + </div> + <div class="report-container"> + <h1>Summary Report</h1> + <div class="summary"> + <div class="summary-item"> + <span class="label">Test Suites</span> + <span class="value">${suite_count}</span> + </div> + <div class="summary-item"> + <span class="label">Tests</span> + <span class="value">${test_count}</span> + </div> + <div class="summary-item"> + <span class="label">Skipped</span> + <span class="value">${skipped_count}</span> + </div> + <div class="summary-item"> + <span class="label">Flaky</span> + <span class="value">${flaky_count}</span> + </div> + <div class="summary-item"> + <span class="label">Failures</span> + <span class="value">${failure_count}</span> + </div> + <div class="summary-item"> + <span class="label">Orphans</span> + <span class="value">${orphan_count}</span> + </div> + <div class="summary-item"> + <span class="label">Duration</span> + <span class="value">${duration}</span> + </div> + <div class="success-rate"> + <div class="check-icon status-${report_state}">✓</div> + <div class="rate-text"> + <span class="label">Success Rate</span> + <span class="value">${success_percent}</span> + </div> + </div> + </div> + </div> + </header> + <main> + <nav> + <ul> + <li class="active" data-page="test-suites">TEST SUITES</li> + <li data-page="paths">PATHS</li> + <li data-page="logging">LOGGING</li> + </ul> + </nav> + <div id="content"> + <!-- Content will be loaded here based on selected page --> + </div> + </main> + + <footer> + <p>Generated by <a href="https://github.com/MikeSchulze/gdUnit4">GdUnit4</a> at ${buid_date}</p> + <div class="status-legend"> + <span class="status-legend-item"> + <span class="status-box status-skipped"></span> Skipped + </span> + <span class="status-legend-item"> + <span class="status-box status-passed"></span> Passed + </span> + <span class="status-legend-item"> + <span class="status-box status-flaky"></span> Flaky + </span> + <span class="status-legend-item"> + <span class="status-box status-warning"></span> Warning + </span> + <span class="status-legend-item"> + <span class="status-box status-failed"></span> Failed + </span> + <span class="status-legend-item"> + <span class="status-box status-error"></span> Error + </span> + </div> + </footer> + + <script> + // Simple JavaScript to handle page switching + document.querySelectorAll('nav li').forEach(item => { + item.addEventListener('click', function () { + document.querySelectorAll('nav li').forEach(li => li.classList.remove('active')); + this.classList.add('active'); + loadPage(this.getAttribute('data-page')); + }); + }); + + function loadPage(page) { + if (page === 'test-suites') { + document.getElementById('content').innerHTML = ` + <div class="grid-item tab"> + <table> + <thead> + <tr> + <th>Test Suites</th> + <th>State</th> + <th>Tests</th> + <th>Skipped</th> + <th>Flaky</th> + <th>Failures</th> + <th>Orphans</th> + <th>Duration</th> + <th>Quick State</th> + </tr> + </thead> + <tbody> + ${report_table_testsuites} + </tbody> + </table> + </div>` + } else if (page === 'paths') { + document.getElementById('content').innerHTML = ` + <div class="grid-item tab"> + <table> + <thead> + <tr> + <th>Paths</th> + <th>State</th> + <th>Tests</th> + <th>Skipped</th> + <th>Flaky</th> + <th>Failures</th> + <th>Orphans</th> + <th>Duration</th> + <th>Quick State</th> + </tr> + </thead> + <tbody> + ${report_table_paths} + </tbody> + </table> + </div>` + } else if (page === 'logging') { + document.getElementById('content').innerHTML = ` + <h3>${godot_log_file}</h3> + <div class="logging-container"> + <iframe id="logging_content" src="${log_report}" frameborder="0" height="100%" width="100%"></iframe> + </div>` + } + } + + // Load default page + loadPage('test-suites'); + </script> +</body> + +</html> diff --git a/addons/gdUnit4/src/reporters/html/template/suite_report.html b/addons/gdUnit4/src/reporters/html/template/suite_report.html new file mode 100644 index 0000000..4746841 --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/template/suite_report.html @@ -0,0 +1,177 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="UTF-8"> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> + <meta http-equiv="x-ua-compatible" content="IE=edge" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>GdUnit4 Testsuite</title> + <link rel="stylesheet" href="../css/styles.css"> + <link href="../css/breadcrumb.css" rel="stylesheet" type="text/css" /> + + <script src="https://code.jquery.com/jquery-3.6.4.js"></script> + <script> + $(document).ready(function () { + var report_view = $('#report_area').find('.report-column') + + $('#report-table tr').click(function () { + var report = $(this).find('.report-column').html(); + report_view.html(report) + $('.selected').removeClass('selected'); + $(this).addClass('selected'); + }); + }); + </script> +</head> + +<body> + <header> + <div class="logo"> + <img src="../css/logo.png" alt="GdUnit4 Logo"> + <span>GdUnit4</span> + </div> + <div class="report-container"> + <h1>Testsuite Report</h1> + <div> + <span class="label">${resource_path}</span> + </div> + <div class="summary"> + <div class="summary-item"> + <span class="label">Tests</span> + <span class="value">${test_count}</span> + </div> + <div class="summary-item"> + <span class="label">Skipped</span> + <span class="value">${skipped_count}</span> + </div> + <div class="summary-item"> + <span class="label">Flaky</span> + <span class="value">${flaky_count}</span> + </div> + <div class="summary-item"> + <span class="label">Failures</span> + <span class="value">${failure_count}</span> + </div> + <div class="summary-item"> + <span class="label">Orphans</span> + <span class="value">${orphan_count}</span> + </div> + <div class="summary-item"> + <span class="label">Duration</span> + <span class="value">${duration}</span> + </div> + <div class="success-rate"> + <div class="check-icon status-${report_state}">✓</div> + <div class="rate-text"> + <span class="label">Success Rate</span> + <span class="value">${success_percent}</span> + </div> + </div> + </div> + </div> + <div class="breadcrumb"> + <a href="../index.html"><span>All</span></a> + <a href="${breadcrumb_path_link}"><span>${path}</span></a> + <a class="active" href="#"><span>${testsuite_name}</span></a> + </div> + </header> + + <main> + <div class="tab-report-grid"> + <div class="grid-item tab"> + <table id="report-table"> + <thead> + <tr> + <th>Testcase</th> + <th>Result</th> + <th>Skipped</th> + <th>Orphans</th> + <th>Duration</th> + <th class="report-column">Report</th> + </tr> + </thead> + <tbody> + ${report_table_tests} + </tbody> + </table> + </div> + <div id="report_area" class="grid-item tab"> + <h4>Failure Report</h4> + <div class="report-column"></div> + </div> + </div> + </main> + + <footer> + <p>Generated by <a href="https://github.com/MikeSchulze/gdUnit4">GdUnit4</a> at ${buid_date}</p> + <div class="status-legend"> + <span class="status-legend-item"> + <span class="status-box status-skipped"></span> Skipped + </span> + <span class="status-legend-item"> + <span class="status-box status-passed"></span> Passed + </span> + <span class="status-legend-item"> + <span class="status-box status-flaky"></span> Flaky + </span> + <span class="status-legend-item"> + <span class="status-box status-warning"></span> Warning + </span> + <span class="status-legend-item"> + <span class="status-box status-failed"></span> Failed + </span> + <span class="status-legend-item"> + <span class="status-box status-error"></span> Error + </span> + </div> + </footer> +</body> + +<script> + function groupTableRows() { + const table = document.getElementById('report-table'); + const rows = table.querySelectorAll('tbody tr'); + + let previousTestCase = ''; + let groupStartIndex = 0; + let groupColorToggle = true; // Toggle between two colors + + rows.forEach((row, index) => { + const testCaseName = row.cells[0].textContent.trim(); + + if (testCaseName !== previousTestCase) { + // Close the previous group + if (index > 0 && groupStartIndex !== index - 1) { + // Apply background color to the group + const groupClass = groupColorToggle ? 'group-bg-1' : 'group-bg-2'; + for (let i = groupStartIndex; i <= index - 1; i++) { + rows[i].classList.add(groupClass); + } + + // Toggle background color for the next group + groupColorToggle = !groupColorToggle; + } + + // Start a new group + groupStartIndex = index; + } + + previousTestCase = testCaseName; + }); + + // Handle the last group + if (groupStartIndex !== rows.length - 1) { + // Apply background color to the last group + const groupClass = groupColorToggle ? 'group-bg-1' : 'group-bg-2'; + for (let i = groupStartIndex; i < rows.length; i++) { + rows[i].classList.add(groupClass); + } + } + } + + // Call the function to group table rows after the DOM loads + document.addEventListener('DOMContentLoaded', groupTableRows); +</script> + +</html> diff --git a/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd b/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd new file mode 100644 index 0000000..0b9674f --- /dev/null +++ b/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd @@ -0,0 +1,143 @@ +# This class implements the JUnit XML file format +# based checked https://github.com/windyroad/JUnit-Schema/blob/master/JUnit.xsd +class_name JUnitXmlReportWriter +extends GdUnitReportWriter + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + +const ATTR_CLASSNAME := "classname" +const ATTR_ERRORS := "errors" +const ATTR_FAILURES := "failures" +const ATTR_HOST := "hostname" +const ATTR_ID := "id" +const ATTR_MESSAGE := "message" +const ATTR_NAME := "name" +const ATTR_PACKAGE := "package" +const ATTR_SKIPPED := "skipped" +const ATTR_FLAKY := "flaky" +const ATTR_TESTS := "tests" +const ATTR_TIME := "time" +const ATTR_TIMESTAMP := "timestamp" +const ATTR_TYPE := "type" + +const HEADER := '<?xml version="1.0" encoding="UTF-8" ?>\n' + + +func output_format() -> String: + return "XML" + + +func write(report_path: String, report: GdUnitReportSummary) -> String: + var result_file: String = "%s/results.xml" % report_path + DirAccess.make_dir_recursive_absolute(report_path) + var file := FileAccess.open(result_file, FileAccess.WRITE) + if file == null: + push_warning("Can't saving the result to '%s'\n Error: %s" % [result_file, error_string(FileAccess.get_open_error())]) + else: + file.store_string(build_junit_report(report_path, report)) + return result_file + + +func build_junit_report(report_path: String, report: GdUnitReportSummary) -> String: + var iso8601_datetime := Time.get_date_string_from_system() + var test_suites := XmlElement.new("testsuites")\ + .attribute(ATTR_ID, iso8601_datetime)\ + .attribute(ATTR_NAME, report_path.get_file())\ + .attribute(ATTR_TESTS, report.test_count())\ + .attribute(ATTR_FAILURES, report.failure_count())\ + .attribute(ATTR_SKIPPED, report.skipped_count())\ + .attribute(ATTR_FLAKY, report.flaky_count())\ + .attribute(ATTR_TIME, JUnitXmlReportWriter.to_time(report.duration()))\ + .add_childs(build_test_suites(report)) + var as_string := test_suites.to_xml() + test_suites.dispose() + return HEADER + as_string + + +func build_test_suites(summary: GdUnitReportSummary) -> Array: + var test_suites: Array[XmlElement] = [] + for index in summary.get_reports().size(): + var suite_report :GdUnitTestSuiteReport = summary.get_reports()[index] + var iso8601_datetime := Time.get_datetime_string_from_unix_time(suite_report.time_stamp()) + test_suites.append(XmlElement.new("testsuite")\ + .attribute(ATTR_ID, index)\ + .attribute(ATTR_NAME, suite_report.name())\ + .attribute(ATTR_PACKAGE, suite_report.path())\ + .attribute(ATTR_TIMESTAMP, iso8601_datetime)\ + .attribute(ATTR_HOST, "localhost")\ + .attribute(ATTR_TESTS, suite_report.test_count())\ + .attribute(ATTR_FAILURES, suite_report.failure_count())\ + .attribute(ATTR_ERRORS, suite_report.error_count())\ + .attribute(ATTR_SKIPPED, suite_report.skipped_count())\ + .attribute(ATTR_FLAKY, suite_report.flaky_count())\ + .attribute(ATTR_TIME, JUnitXmlReportWriter.to_time(suite_report.duration()))\ + .add_childs(build_test_cases(suite_report))) + return test_suites + + +func build_test_cases(suite_report: GdUnitTestSuiteReport) -> Array: + var test_cases: Array[XmlElement] = [] + for index in suite_report.get_reports().size(): + var report :GdUnitTestCaseReport = suite_report.get_reports()[index] + test_cases.append( XmlElement.new("testcase")\ + .attribute(ATTR_NAME, JUnitXmlReportWriter.encode_xml(report.name()))\ + .attribute(ATTR_CLASSNAME, report.suite_name())\ + .attribute(ATTR_TIME, JUnitXmlReportWriter.to_time(report.duration()))\ + .add_childs(build_reports(report))) + return test_cases + + +func build_reports(test_report: GdUnitTestCaseReport) -> Array: + var failure_reports: Array[XmlElement] = [] + + for report: GdUnitReport in test_report.get_test_reports(): + if report.is_failure(): + failure_reports.append(XmlElement.new("failure")\ + .attribute(ATTR_MESSAGE, "FAILED: %s:%d" % [test_report.get_resource_path(), report.line_number()])\ + .attribute(ATTR_TYPE, JUnitXmlReportWriter.to_type(report.type()))\ + .text(convert_rtf_to_text(report.message()))) + elif report.is_error(): + failure_reports.append(XmlElement.new("error")\ + .attribute(ATTR_MESSAGE, "ERROR: %s:%d" % [test_report.get_resource_path(), report.line_number()])\ + .attribute(ATTR_TYPE, JUnitXmlReportWriter.to_type(report.type()))\ + .text(convert_rtf_to_text(report.message()))) + elif report.is_skipped(): + failure_reports.append(XmlElement.new("skipped")\ + .attribute(ATTR_MESSAGE, "SKIPPED: %s:%d" % [test_report.get_resource_path(), report.line_number()])\ + .text(convert_rtf_to_text(report.message()))) + return failure_reports + + +func convert_rtf_to_text(bbcode: String) -> String: + return GdUnitTools.richtext_normalize(bbcode) + + +static func to_type(type: int) -> String: + match type: + GdUnitReport.SUCCESS: + return "SUCCESS" + GdUnitReport.WARN: + return "WARN" + GdUnitReport.FAILURE: + return "FAILURE" + GdUnitReport.ORPHAN: + return "ORPHAN" + GdUnitReport.TERMINATED: + return "TERMINATED" + GdUnitReport.INTERUPTED: + return "INTERUPTED" + GdUnitReport.ABORT: + return "ABORT" + return "UNKNOWN" + + +static func to_time(duration: int) -> String: + return "%4.03f" % (duration / 1000.0) + + +static func encode_xml(value: String) -> String: + return value.xml_escape(true) + + +#static func to_ISO8601_datetime() -> String: + #return "%04d-%02d-%02dT%02d:%02d:%02d" % [date["year"], date["month"], date["day"], date["hour"], date["minute"], date["second"]] diff --git a/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd.uid b/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd.uid new file mode 100644 index 0000000..16ffa18 --- /dev/null +++ b/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd.uid @@ -0,0 +1 @@ +uid://bf8g04wtq4lfp diff --git a/addons/gdUnit4/src/reporters/xml/XmlElement.gd b/addons/gdUnit4/src/reporters/xml/XmlElement.gd new file mode 100644 index 0000000..86c7421 --- /dev/null +++ b/addons/gdUnit4/src/reporters/xml/XmlElement.gd @@ -0,0 +1,69 @@ +class_name XmlElement +extends RefCounted + +var _name :String +# Dictionary[String, String] +var _attributes :Dictionary = {} +var _childs :Array[XmlElement] = [] +var _parent :XmlElement = null +var _text :String = "" + + +func _init(name :String) -> void: + _name = name + + +func dispose() -> void: + for child in _childs: + child.dispose() + _childs.clear() + _attributes.clear() + _parent = null + + +func attribute(name :String, value :Variant) -> XmlElement: + _attributes[name] = str(value) + return self + + +func text(p_text :String) -> XmlElement: + _text = p_text if p_text.ends_with("\n") else p_text + "\n" + return self + + +func add_child(child :XmlElement) -> XmlElement: + _childs.append(child) + child._parent = self + return self + + +func add_childs(childs :Array[XmlElement]) -> XmlElement: + for child in childs: + @warning_ignore("return_value_discarded") + add_child(child) + return self + + +func indentation() -> String: + return "" if _parent == null else _parent.indentation() + " " + + +func to_xml() -> String: + var attributes := "" + for key in _attributes.keys() as Array[String]: + attributes += ' {attr}="{value}"'.format({"attr": key, "value": _attributes.get(key)}) + + var childs := "" + for child in _childs: + childs += child.to_xml() + + return "{_indentation}<{name}{attributes}>\n{childs}{text}{_indentation}</{name}>\n"\ + .format({"name": _name, + "attributes": attributes, + "childs": childs, + "_indentation": indentation(), + "text": cdata(_text)}) + + +func cdata(p_text :String) -> String: + return "" if p_text.is_empty() else "<![CDATA[\n{text}]]>\n".format({"text" : p_text}) diff --git a/addons/gdUnit4/src/reporters/xml/XmlElement.gd.uid b/addons/gdUnit4/src/reporters/xml/XmlElement.gd.uid new file mode 100644 index 0000000..f02b6a2 --- /dev/null +++ b/addons/gdUnit4/src/reporters/xml/XmlElement.gd.uid @@ -0,0 +1 @@ +uid://bae36annbua3k |
