diff options
Diffstat (limited to 'addons/gdUnit4/src/core/execution')
30 files changed, 1034 insertions, 0 deletions
diff --git a/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd b/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd new file mode 100644 index 0000000..844561b --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd @@ -0,0 +1,274 @@ +## The execution context +## It contains all the necessary information about the executed stage, such as memory observers, reports, orphan monitor +class_name GdUnitExecutionContext + +enum GC_ORPHANS_CHECK { + NONE, + SUITE_HOOK_AFTER, + TEST_HOOK_AFTER, + TEST_CASE +} + + +var _parent_context: GdUnitExecutionContext +var _sub_context: Array[GdUnitExecutionContext] = [] +var _orphan_monitor: GdUnitOrphanNodesMonitor +var _memory_observer: GdUnitMemoryObserver +var _report_collector: GdUnitTestReportCollector +var _timer: LocalTime +var _test_case_name: StringName +var _test_case_parameter_set: Array +var _name: String +var _test_execution_iteration: int = 0 +var _flaky_test_check := GdUnitSettings.is_test_flaky_check_enabled() +var _flaky_test_retries := GdUnitSettings.get_flaky_max_retries() + + +var error_monitor: GodotGdErrorMonitor = null: + get: + if _parent_context != null: + return _parent_context.error_monitor + if error_monitor == null: + error_monitor = GodotGdErrorMonitor.new() + return error_monitor + + +var test_suite: GdUnitTestSuite = null: + get: + if _parent_context != null: + return _parent_context.test_suite + return test_suite + + +var test_case: _TestCase = null: + get: + if test_case == null and _parent_context != null: + return _parent_context.test_case + return test_case + + +func _init(name: StringName, parent_context: GdUnitExecutionContext = null) -> void: + _name = name + _parent_context = parent_context + _timer = LocalTime.now() + _orphan_monitor = GdUnitOrphanNodesMonitor.new(name) + + if parent_context != null: + parent_context._orphan_monitor.add_child_monitor(_orphan_monitor) + orphan_monitor_start() + + _memory_observer = GdUnitMemoryObserver.new() + _report_collector = GdUnitTestReportCollector.new() + if parent_context != null: + parent_context._sub_context.append(self) + + +func dispose() -> void: + if test_suite != null: + test_suite.free() + _timer = null + _orphan_monitor = null + _report_collector = null + _memory_observer = null + _parent_context = null + test_suite = null + test_case = null + dispose_sub_contexts() + + +func dispose_sub_contexts() -> void: + for context in _sub_context: + context.dispose() + _sub_context.clear() + + +func terminate() -> void: + if test_case: + test_case.do_terminate() + + +static func of(pe: GdUnitExecutionContext) -> GdUnitExecutionContext: + var context := GdUnitExecutionContext.new(pe._test_case_name, pe) + context._test_case_name = pe._test_case_name + context._test_execution_iteration = pe._test_execution_iteration + return context + + +static func of_test_case(pe: GdUnitExecutionContext, p_test_case: _TestCase) -> GdUnitExecutionContext: + assert(p_test_case, "test_case is null") + var context := GdUnitExecutionContext.new(p_test_case.test_name(), pe) + context.test_case = p_test_case + return context + + +static func of_parameterized_test(pe: GdUnitExecutionContext, test_case_name: String, test_case_parameter_set: Array) -> GdUnitExecutionContext: + var context := GdUnitExecutionContext.new(test_case_name, pe) + context._test_case_name = test_case_name + context._test_case_parameter_set = test_case_parameter_set + return context + + +func get_test_suite_path() -> String: + return test_suite.get_script().resource_path + + +func get_test_suite_name() -> StringName: + return test_suite.get_name() + + +func get_test_case_name() -> StringName: + if _test_case_name.is_empty(): + return test_case._test_case.display_name + return _test_case_name + + +func error_monitor_start() -> void: + error_monitor.start() + + +func error_monitor_stop() -> void: + error_monitor.stop() + for error_report in error_monitor.to_reports(): + if error_report.is_error(): + _report_collector.push_back(error_report) + + +func orphan_monitor_start() -> void: + _orphan_monitor.start() + + +func orphan_monitor_collect() -> void: + _orphan_monitor.collect() + + +func orphan_monitor_stop() -> void: + _orphan_monitor.stop() + + +func add_report(report: GdUnitReport) -> GdUnitReport: + _report_collector.push_back(report) + return report + + +func reports() -> Array[GdUnitReport]: + return _report_collector.reports() + + +func collect_reports(recursive: bool) -> Array[GdUnitReport]: + if not recursive: + return reports() + + # we combine the reports of test_before(), test_after() and test() to be reported by `fire_test_ended` + # we strictly need to copy the reports before adding sub context reports to avoid manipulation of the current context + var current_reports := reports().duplicate() + for sub_context in _sub_context: + current_reports.append_array(sub_context.collect_reports(true)) + + return current_reports + + +func calculate_statistics(reports_: Array[GdUnitReport]) -> Dictionary: + var failed_count := GdUnitTestReportCollector.count_failures(reports_) + var error_count := GdUnitTestReportCollector.count_errors(reports_) + var warn_count := GdUnitTestReportCollector.count_warnings(reports_) + var skip_count := GdUnitTestReportCollector.count_skipped(reports_) + var orphan_count := GdUnitTestReportCollector.count_orphans(reports_) + var is_failed := !is_success() + var elapsed_time := _timer.elapsed_since_ms() + var retries := 1 if _parent_context == null else _sub_context.size() + # Mark as flaky if it is successful, but errors were counted + var is_flaky := retries > 1 and not is_failed + # In the case of a flakiness test, we do not report an error counter, as an unreliable test is considered successful + # after a certain number of repetitions. + if is_flaky: + failed_count = 0 + + return { + GdUnitEvent.RETRY_COUNT: retries, + GdUnitEvent.ELAPSED_TIME: elapsed_time, + GdUnitEvent.FAILED: is_failed, + GdUnitEvent.ERRORS: error_count > 0, + GdUnitEvent.WARNINGS: warn_count > 0, + GdUnitEvent.FLAKY: is_flaky, + GdUnitEvent.SKIPPED: skip_count > 0, + GdUnitEvent.FAILED_COUNT: failed_count, + GdUnitEvent.ERROR_COUNT: error_count, + GdUnitEvent.SKIPPED_COUNT: skip_count, + GdUnitEvent.ORPHAN_NODES: orphan_count, + } + + +func is_success() -> bool: + if _sub_context.is_empty(): + return not _report_collector.has_failures() + # we on test suite level? + if _parent_context == null: + return not _report_collector.has_failures() + + return _sub_context[-1].is_success() and not _report_collector.has_failures() + + +func is_skipped() -> bool: + return ( + _sub_context.any(func(c :GdUnitExecutionContext) -> bool: + return c.is_skipped()) + or test_case.is_skipped() if test_case != null else false + ) + + +func is_interupted() -> bool: + return false if test_case == null else test_case.is_interupted() + + +func sum(accum: int, number: int) -> int: + return accum + number + + +func retry_execution() -> bool: + var retry := _test_execution_iteration < 1 if not _flaky_test_check else _test_execution_iteration < _flaky_test_retries + if retry: + _test_execution_iteration += 1 + return retry + + +func register_auto_free(obj: Variant) -> Variant: + return _memory_observer.register_auto_free(obj) + + +## Runs the gdunit garbage collector to free registered object and handle orphan node reporting +func gc(gc_orphan_check: GC_ORPHANS_CHECK = GC_ORPHANS_CHECK.NONE) -> void: + # unreference last used assert form the test to prevent memory leaks + GdUnitThreadManager.get_current_context().clear_assert() + await _memory_observer.gc() + orphan_monitor_stop() + + match(gc_orphan_check): + GC_ORPHANS_CHECK.SUITE_HOOK_AFTER: + _orphan_monitor.collect() + var orphan_infos := _orphan_monitor.detected_orphans() + if orphan_infos.is_empty(): + return + reports().push_front(GdUnitReport.new() \ + .create(GdUnitReport.ORPHAN, 1, GdAssertMessages.orphan_detected_on_suite_setup(orphan_infos)) + .with_current_value(orphan_infos.size())) + + GC_ORPHANS_CHECK.TEST_HOOK_AFTER: + _orphan_monitor.collect() + var orphans := _orphan_monitor.detected_orphans() + if not orphans.is_empty(): + reports().push_front(GdUnitReport.new()\ + .create(GdUnitReport.ORPHAN, 1, GdAssertMessages.orphan_detected_on_test_setup(orphans)) + .with_current_value(orphans.size())) + + GC_ORPHANS_CHECK.TEST_CASE: + var orphans := _orphan_monitor.detected_orphans() + if orphans.is_empty(): + var orphans_count := _orphan_monitor.orphans_count() + if orphans_count > 0: + reports().push_front(GdUnitReport.new() \ + .create(GdUnitReport.ORPHAN, test_case.line_number(), GdAssertMessages.orphan_warning(orphans_count)) + .with_current_value(orphans_count)) + else: + reports().push_front(GdUnitReport.new()\ + .create(GdUnitReport.ORPHAN, test_case.line_number(), GdAssertMessages.orphan_detected_on_test(orphans)) + .with_current_value(orphans.size())) diff --git a/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd.uid b/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd.uid new file mode 100644 index 0000000..98ede87 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd.uid @@ -0,0 +1 @@ +uid://doxksl3xmwd4t diff --git a/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd b/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd new file mode 100644 index 0000000..dd03a31 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd @@ -0,0 +1,135 @@ +## The memory watcher for objects that have been registered and are released when 'gc' is called. +class_name GdUnitMemoryObserver +extends RefCounted + +const TAG_OBSERVE_INSTANCE := "GdUnit4_observe_instance_" +const TAG_AUTO_FREE = "GdUnit4_marked_auto_free" +const GdUnitTools = preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + + +var _store :Array[Variant] = [] +# enable for debugging purposes +var _is_stdout_verbose := false +const _show_debug := false + + +## Registration of an instance to be released when an execution phase is completed +func register_auto_free(obj :Variant) -> Variant: + if not is_instance_valid(obj): + return obj + # do not register on GDScriptNativeClass + @warning_ignore("unsafe_cast") + if typeof(obj) == TYPE_OBJECT and (obj as Object).is_class("GDScriptNativeClass") : + return obj + #if obj is GDScript or obj is ScriptExtension: + # return obj + if obj is MainLoop: + push_error("GdUnit4: Avoid to add mainloop to auto_free queue %s" % obj) + return + if _is_stdout_verbose: + print_verbose("GdUnit4:gc():register auto_free(%s)" % obj) + # only register pure objects + if obj is GdUnitSceneRunner: + _store.push_back(obj) + else: + _store.append(obj) + _tag_object(obj) + return obj + + +# to disable instance guard when run into issues. +static func _is_instance_guard_enabled() -> bool: + return false + + +static func debug_observe(name :String, obj :Object, indent :int = 0) -> void: + if not _show_debug: + return + var script :GDScript= obj if obj is GDScript else obj.get_script() + if script: + var base_script :GDScript = script.get_base_script() + @warning_ignore("unsafe_method_access") + prints("".lpad(indent, " "), name, obj, obj.get_class(), "reference_count:", obj.get_reference_count() if obj is RefCounted else 0, "script:", script, script.resource_path) + if base_script: + debug_observe("+", base_script, indent+1) + else: + @warning_ignore("unsafe_method_access") + prints(name, obj, obj.get_class(), obj.get_name()) + + +static func guard_instance(obj :Object) -> void: + if not _is_instance_guard_enabled(): + return + var tag := TAG_OBSERVE_INSTANCE + str(abs(obj.get_instance_id())) + if Engine.has_meta(tag): + return + debug_observe("Gard on instance", obj) + Engine.set_meta(tag, obj) + + +static func unguard_instance(obj :Object, verbose := true) -> void: + if not _is_instance_guard_enabled(): + return + var tag := TAG_OBSERVE_INSTANCE + str(abs(obj.get_instance_id())) + if verbose: + debug_observe("unguard instance", obj) + if Engine.has_meta(tag): + Engine.remove_meta(tag) + + +static func gc_guarded_instance(name :String, instance :Object) -> void: + if not _is_instance_guard_enabled(): + return + await (Engine.get_main_loop() as SceneTree).process_frame + unguard_instance(instance, false) + if is_instance_valid(instance) and instance is RefCounted: + # finally do this very hacky stuff + # we need to manually unreferece to avoid leaked scripts + # but still leaked GDScriptFunctionState exists + #var script :GDScript = instance.get_script() + #if script: + # var base_script :GDScript = script.get_base_script() + # if base_script: + # base_script.unreference() + debug_observe(name, instance) + (instance as RefCounted).unreference() + await (Engine.get_main_loop() as SceneTree).process_frame + + +static func gc_on_guarded_instances() -> void: + if not _is_instance_guard_enabled(): + return + for tag in Engine.get_meta_list(): + if tag.begins_with(TAG_OBSERVE_INSTANCE): + var instance :Object = Engine.get_meta(tag) + await gc_guarded_instance("Leaked instance detected:", instance) + await GdUnitTools.free_instance(instance, false) + + +# store the object into global store aswell to be verified by 'is_marked_auto_free' +func _tag_object(obj :Variant) -> void: + var tagged_object: Array = Engine.get_meta(TAG_AUTO_FREE, []) + tagged_object.append(obj) + Engine.set_meta(TAG_AUTO_FREE, tagged_object) + + +## Runs over all registered objects and releases them +func gc() -> void: + if _store.is_empty(): + return + # give engine time to free objects to process objects marked by queue_free() + await (Engine.get_main_loop() as SceneTree).process_frame + if _is_stdout_verbose: + print_verbose("GdUnit4:gc():running", " freeing %d objects .." % _store.size()) + var tagged_objects: Array = Engine.get_meta(TAG_AUTO_FREE, []) + while not _store.is_empty(): + var value :Variant = _store.pop_front() + tagged_objects.erase(value) + await GdUnitTools.free_instance(value, _is_stdout_verbose) + assert(_store.is_empty(), "The memory observer has still entries in the store!") + + +## Checks whether the specified object is registered for automatic release +static func is_marked_auto_free(obj: Variant) -> bool: + var tagged_objects: Array = Engine.get_meta(TAG_AUTO_FREE, []) + return tagged_objects.has(obj) diff --git a/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd.uid b/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd.uid new file mode 100644 index 0000000..35465f8 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd.uid @@ -0,0 +1 @@ +uid://3kyvv6qv76xy diff --git a/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd b/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd new file mode 100644 index 0000000..91247bd --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd @@ -0,0 +1,77 @@ +# Collects all reports seperated as warnings, failures and errors +class_name GdUnitTestReportCollector +extends RefCounted + + +var _reports :Array[GdUnitReport] = [] + + +static func __filter_is_error(report :GdUnitReport) -> bool: + return report.is_error() + + +static func __filter_is_failure(report :GdUnitReport) -> bool: + return report.is_failure() + + +static func __filter_is_warning(report :GdUnitReport) -> bool: + return report.is_warning() + + +static func __filter_is_skipped(report :GdUnitReport) -> bool: + return report.is_skipped() + + +static func __filter_is_orphan(report: GdUnitReport) -> bool: + return report.is_orphan() + + +static func count_failures(reports_: Array[GdUnitReport]) -> int: + return reports_.filter(__filter_is_failure).size() + + +static func count_errors(reports_: Array[GdUnitReport]) -> int: + return reports_.filter(__filter_is_error).size() + + +static func count_warnings(reports_: Array[GdUnitReport]) -> int: + return reports_.filter(__filter_is_warning).size() + + +static func count_skipped(reports_: Array[GdUnitReport]) -> int: + return reports_.filter(__filter_is_skipped).size() + + +static func count_orphans(reports_: Array[GdUnitReport]) -> int: + var orphan_reports := reports_.filter(__filter_is_orphan) + if orphan_reports.is_empty(): + return 0 + ## Collect orphan count from the reports + var orphans := 0 + for report: GdUnitReport in orphan_reports: + orphans += report._current_value + return orphans + + +func has_failures() -> bool: + return _reports.any(__filter_is_failure) + + +func has_errors() -> bool: + return _reports.any(__filter_is_error) + + +func has_warnings() -> bool: + return _reports.any(__filter_is_warning) + + +func has_skipped() -> bool: + return _reports.any(__filter_is_skipped) + + +func reports() -> Array[GdUnitReport]: + return _reports + + +func push_back(report :GdUnitReport) -> void: + _reports.push_back(report) diff --git a/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd.uid b/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd.uid new file mode 100644 index 0000000..3c1601f --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd.uid @@ -0,0 +1 @@ +uid://5dmb52xvknlt diff --git a/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd b/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd new file mode 100644 index 0000000..dfb4441 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd @@ -0,0 +1,56 @@ +## The executor to run a test-suite +class_name GdUnitTestSuiteExecutor + + +# preload all asserts here +@warning_ignore("unused_private_class_variable") +var _assertions := GdUnitAssertions.new() +var _executeStage := GdUnitTestSuiteExecutionStage.new() +var _debug_mode : bool +var _terminated := false + +func _init(debug_mode: bool = false) -> void: + _executeStage.set_debug_mode(debug_mode) + _debug_mode = debug_mode + GdUnitSignals.instance().gdunit_test_session_terminate.connect(_on_testsession_terminated) + + +func _on_testsession_terminated() -> void: + _terminated = true + GdUnitThreadManager.interrupt() + + +func run_and_wait(tests: Array[GdUnitTestCase]) -> void: + if !_debug_mode: + GdUnitSignals.instance().gdunit_event.emit(GdUnitInit.new()) + + var orphan_detection_enabled := GdUnitSettings.is_verbose_orphans() + if not orphan_detection_enabled: + prints("!!! Reporting orphan nodes is disabled. Please check GdUnit settings.") + + # first we group all tests by resource path + var grouped_by_suites := GdArrayTools.group_by(tests, func(test: GdUnitTestCase) -> String: + return test.suite_resource_path + ) + var scanner := GdUnitTestSuiteScanner.new() + for suite_path: String in grouped_by_suites.keys(): + if _terminated: + break + @warning_ignore("unsafe_call_argument") + var suite_tests: Array[GdUnitTestCase] = Array(grouped_by_suites[suite_path], TYPE_OBJECT, "RefCounted", GdUnitTestCase) + var script := GdUnitTestSuiteScanner.load_with_disabled_warnings(suite_path) + if script.get_class() == "GDScript": + var context := GdUnitExecutionContext.new(suite_path) + var test_suite := scanner.load_suite(script as GDScript, suite_tests) + context.test_suite = test_suite + (Engine.get_main_loop() as SceneTree).root.add_child(test_suite) + await _executeStage.execute(context) + context.dispose() + else: + await GdUnit4CSharpApiLoader.execute(suite_tests) + if !_debug_mode: + GdUnitSignals.instance().gdunit_event.emit(GdUnitStop.new()) + + +func fail_fast(enabled :bool) -> void: + _executeStage.fail_fast(enabled) diff --git a/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd.uid b/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd.uid new file mode 100644 index 0000000..6717eba --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd.uid @@ -0,0 +1 @@ +uid://cfluc4qh1cia6 diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd new file mode 100644 index 0000000..f106f21 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd @@ -0,0 +1,22 @@ +## The test case shutdown hook implementation.[br] +## It executes the 'test_after()' block from the test-suite. +class_name GdUnitTestCaseAfterStage +extends IGdUnitExecutionStage + + +var _call_stage: bool + + +func _init(call_stage := true) -> void: + _call_stage = call_stage + + +func _execute(context: GdUnitExecutionContext) -> void: + var test_suite := context.test_suite + + if _call_stage: + @warning_ignore("redundant_await") + await test_suite.after_test() + + await context.gc(GdUnitExecutionContext.GC_ORPHANS_CHECK.TEST_HOOK_AFTER) + context.error_monitor_stop() diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd.uid new file mode 100644 index 0000000..f241c9f --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd.uid @@ -0,0 +1 @@ +uid://cry2ikys4ww2x diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd new file mode 100644 index 0000000..d7790a9 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd @@ -0,0 +1,20 @@ +## The test case startup hook implementation.[br] +## It executes the 'test_before()' block from the test-suite. +class_name GdUnitTestCaseBeforeStage +extends IGdUnitExecutionStage + +var _call_stage :bool + + +func _init(call_stage := true) -> void: + _call_stage = call_stage + + +func _execute(context: GdUnitExecutionContext) -> void: + var test_suite := context.test_suite + + if _call_stage: + @warning_ignore("redundant_await") + await test_suite.before_test() + + context.error_monitor_start() diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd.uid new file mode 100644 index 0000000..2073bc3 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd.uid @@ -0,0 +1 @@ +uid://lqofqnr401s3 diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd new file mode 100644 index 0000000..473f1e2 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd @@ -0,0 +1,37 @@ +## The test case execution stage.[br] +class_name GdUnitTestCaseExecutionStage +extends IGdUnitExecutionStage + + +var _stage_single_test: IGdUnitExecutionStage = GdUnitTestCaseSingleExecutionStage.new() +var _stage_fuzzer_test: IGdUnitExecutionStage = GdUnitTestCaseFuzzedExecutionStage.new() + + +## Executes the test case 'test_<name>()'.[br] +## It executes synchronized following stages[br] +## -> test_before() [br] +## -> test_case() [br] +## -> test_after() [br] +@warning_ignore("redundant_await") +func _execute(context :GdUnitExecutionContext) -> void: + var test_case := context.test_case + + context.error_monitor_start() + + if test_case.is_fuzzed(): + await _stage_fuzzer_test.execute(context) + else: + await _stage_single_test.execute(context) + + await context.gc() + context.error_monitor_stop() + + # finally free the test instance + if is_instance_valid(context.test_case): + context.test_case.dispose() + + +func set_debug_mode(debug_mode :bool = false) -> void: + super.set_debug_mode(debug_mode) + _stage_single_test.set_debug_mode(debug_mode) + _stage_fuzzer_test.set_debug_mode(debug_mode) diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd.uid new file mode 100644 index 0000000..7105242 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd.uid @@ -0,0 +1 @@ +uid://bnx6cbvnp5pr1 diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd new file mode 100644 index 0000000..03bbd0f --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd @@ -0,0 +1,29 @@ +## The test suite shutdown hook implementation.[br] +## It executes the 'after()' block from the test-suite. +class_name GdUnitTestSuiteAfterStage +extends IGdUnitExecutionStage + + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + + +func _execute(context :GdUnitExecutionContext) -> void: + var test_suite := context.test_suite + + @warning_ignore("redundant_await") + await test_suite.after() + await context.gc(GdUnitExecutionContext.GC_ORPHANS_CHECK.SUITE_HOOK_AFTER) + + var reports := context.collect_reports(false) + var statistics := context.calculate_statistics(reports) + fire_event(GdUnitEvent.new()\ + .suite_after(context.get_test_suite_path(),\ + test_suite.get_name(), + statistics, + reports)) + GdUnitFileAccess.clear_tmp() + # Guard that checks if all doubled (spy/mock) objects are released + await GdUnitClassDoubler.check_leaked_instances() + # we hide the scene/main window after runner is finished + if not Engine.is_embedded_in_editor(): + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED) diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd.uid new file mode 100644 index 0000000..1b19c2a --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd.uid @@ -0,0 +1 @@ +uid://mwtvw184h4nk diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd new file mode 100644 index 0000000..a6ed8c5 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd @@ -0,0 +1,14 @@ +## The test suite startup hook implementation.[br] +## It executes the 'before()' block from the test-suite. +class_name GdUnitTestSuiteBeforeStage +extends IGdUnitExecutionStage + + +func _execute(context: GdUnitExecutionContext) -> void: + var test_suite := context.test_suite + + fire_event(GdUnitEvent.new()\ + .suite_before(context.get_test_suite_path(), test_suite.get_name(), test_suite.get_child_count())) + + @warning_ignore("redundant_await") + await test_suite.before() diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd.uid new file mode 100644 index 0000000..2fdfdc3 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd.uid @@ -0,0 +1 @@ +uid://fx62snxk32g4 diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd new file mode 100644 index 0000000..07e3779 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd @@ -0,0 +1,147 @@ +## The test suite main execution stage.[br] +class_name GdUnitTestSuiteExecutionStage +extends IGdUnitExecutionStage + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + +var _stage_before :IGdUnitExecutionStage = GdUnitTestSuiteBeforeStage.new() +var _stage_after :IGdUnitExecutionStage = GdUnitTestSuiteAfterStage.new() +var _stage_test :IGdUnitExecutionStage = GdUnitTestCaseExecutionStage.new() +var _fail_fast := false + + +## Executes all tests of an test suite.[br] +## It executes synchronized following stages[br] +## -> before() [br] +## -> run all test cases [br] +## -> after() [br] +func _execute(context :GdUnitExecutionContext) -> void: + if context.test_suite.__is_skipped: + await fire_test_suite_skipped(context) + else: + @warning_ignore("return_value_discarded") + GdUnitMemoryObserver.guard_instance(context.test_suite.__awaiter) + await _stage_before.execute(context) + for test_case_index in context.test_suite.get_child_count(): + # iterate only over test cases + var test_case := context.test_suite.get_child(test_case_index) as _TestCase + if not is_instance_valid(test_case): + continue + context.test_suite.set_active_test_case(test_case.test_name()) + var test_case_context := GdUnitExecutionContext.of_test_case(context, test_case) + await _stage_test.execute(test_case_context) + # stop on first error or if fail fast is enabled + if test_case.is_terminated() or (_fail_fast and not test_case_context.is_success()): + break + if test_case.is_interupted(): + # it needs to go this hard way to kill the outstanding awaits of a test case when the test timed out + # we delete the current test suite where is execute the current test case to kill the function state + # and replace it by a clone without function state + context.test_suite = await clone_test_suite(context.test_suite) + await _stage_after.execute(context) + GdUnitMemoryObserver.unguard_instance(context.test_suite.__awaiter) + + await (Engine.get_main_loop() as SceneTree).process_frame + + +# clones a test suite and moves the test cases to new instance +func clone_test_suite(test_suite :GdUnitTestSuite) -> GdUnitTestSuite: + await (Engine.get_main_loop() as SceneTree).process_frame + dispose_timers(test_suite) + await GdUnitMemoryObserver.gc_guarded_instance("Manually free on awaiter", test_suite.__awaiter) + var parent := test_suite.get_parent() + var _test_suite := GdUnitTestSuite.new() + parent.remove_child(test_suite) + copy_properties(test_suite, _test_suite) + for child in test_suite.get_children(): + test_suite.remove_child(child) + _test_suite.add_child(child) + parent.add_child(_test_suite) + @warning_ignore("return_value_discarded") + GdUnitMemoryObserver.guard_instance(_test_suite.__awaiter) + # finally free current test suite instance + test_suite.free() + await (Engine.get_main_loop() as SceneTree).process_frame + return _test_suite + + +func dispose_timers(test_suite :GdUnitTestSuite) -> void: + GdUnitTools.release_timers() + for child in test_suite.get_children(): + if child is Timer: + (child as Timer).stop() + test_suite.remove_child(child) + child.free() + + +func copy_properties(source :Object, target :Object) -> void: + if not source is _TestCase and not source is GdUnitTestSuite: + return + for property in source.get_property_list(): + var property_name :String = property["name"] + if property_name == "__awaiter": + continue + target.set(property_name, source.get(property_name)) + + +func fire_test_suite_skipped(context :GdUnitExecutionContext) -> void: + var test_suite := context.test_suite + var skip_count := test_suite.get_child_count() + fire_event(GdUnitEvent.new()\ + .suite_before(context.get_test_suite_path(), test_suite.get_name(), skip_count)) + + + for test_case_index in context.test_suite.get_child_count(): + # iterate only over test cases + var test_case := context.test_suite.get_child(test_case_index) as _TestCase + if not is_instance_valid(test_case): + continue + var test_case_context := GdUnitExecutionContext.of_test_case(context, test_case) + fire_event(GdUnitEvent.new().test_before(test_case.id())) + # use skip count 0 because we counted it over the complete test suite + fire_test_skipped(test_case_context, 0) + + + var statistics := { + GdUnitEvent.ORPHAN_NODES: 0, + GdUnitEvent.ELAPSED_TIME: 0, + GdUnitEvent.WARNINGS: false, + GdUnitEvent.ERRORS: false, + GdUnitEvent.ERROR_COUNT: 0, + GdUnitEvent.FAILED: false, + GdUnitEvent.FAILED_COUNT: 0, + GdUnitEvent.SKIPPED_COUNT: skip_count, + GdUnitEvent.SKIPPED: true + } + var report := GdUnitReport.new().create(GdUnitReport.SKIPPED, -1, GdAssertMessages.test_suite_skipped(test_suite.__skip_reason, skip_count)) + fire_event(GdUnitEvent.new().suite_after(context.get_test_suite_path(), test_suite.get_name(), statistics, [report])) + await (Engine.get_main_loop() as SceneTree).process_frame + + +func fire_test_skipped(context: GdUnitExecutionContext, skip_count := 1) -> void: + var test_case := context.test_case + var statistics := { + GdUnitEvent.ORPHAN_NODES: 0, + GdUnitEvent.ELAPSED_TIME: 0, + GdUnitEvent.WARNINGS: false, + GdUnitEvent.ERRORS: false, + GdUnitEvent.ERROR_COUNT: 0, + GdUnitEvent.FAILED: false, + GdUnitEvent.FAILED_COUNT: 0, + GdUnitEvent.SKIPPED: true, + GdUnitEvent.SKIPPED_COUNT: skip_count, + } + var report := GdUnitReport.new() \ + .create(GdUnitReport.SKIPPED, test_case.line_number(), GdAssertMessages.test_skipped("Skipped from the entire test suite")) + fire_event(GdUnitEvent.new().test_after(test_case.id(), test_case.test_name(), statistics, [report])) + + +func set_debug_mode(debug_mode :bool = false) -> void: + super.set_debug_mode(debug_mode) + _stage_before.set_debug_mode(debug_mode) + _stage_after.set_debug_mode(debug_mode) + _stage_test.set_debug_mode(debug_mode) + + +func fail_fast(enabled :bool) -> void: + _fail_fast = enabled diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd.uid new file mode 100644 index 0000000..f250acb --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd.uid @@ -0,0 +1 @@ +uid://c00afkws3ewfx diff --git a/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd b/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd new file mode 100644 index 0000000..a04f7e7 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd @@ -0,0 +1,37 @@ +## The interface of execution stage.[br] +## An execution stage is defined as an encapsulated task that can execute 1-n substages covered by its own execution context.[br] +## Execution stage are always called synchronously. +@abstract class_name IGdUnitExecutionStage +extends RefCounted + +var _debug_mode := false + + +## Executes synchronized the implemented stage in its own execution context.[br] +## example:[br] +## [codeblock] +## # waits for 100ms +## await MyExecutionStage.new().execute(<GdUnitExecutionContext>) +## [/codeblock][br] +func execute(context: GdUnitExecutionContext) -> void: + GdUnitThreadManager.get_current_context().set_execution_context(context) + @warning_ignore("redundant_await") + await _execute(context) + + +## Sends the event to registered listeners +func fire_event(event: GdUnitEvent) -> void: + if _debug_mode: + GdUnitSignals.instance().gdunit_event_debug.emit(event) + else: + GdUnitSignals.instance().gdunit_event.emit(event) + + +## Internal testing stuff.[br] +## Sets the executor into debug mode to emit `GdUnitEvent` via signal `gdunit_event_debug` +func set_debug_mode(debug_mode: bool) -> void: + _debug_mode = debug_mode + + +## The execution phase to be carried out. +@abstract func _execute(context: GdUnitExecutionContext) -> void diff --git a/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd.uid new file mode 100644 index 0000000..5ae6f69 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd.uid @@ -0,0 +1 @@ +uid://bfvoxsa3oad2k diff --git a/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd new file mode 100644 index 0000000..c1e0924 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd @@ -0,0 +1,52 @@ +## The test case execution stage.[br] +class_name GdUnitTestCaseFuzzedExecutionStage +extends IGdUnitExecutionStage + +var _stage_before :IGdUnitExecutionStage = GdUnitTestCaseBeforeStage.new(false) +var _stage_after :IGdUnitExecutionStage = GdUnitTestCaseAfterStage.new(false) +var _stage_test :IGdUnitExecutionStage = GdUnitTestCaseFuzzedTestStage.new() + + +func _execute(context :GdUnitExecutionContext) -> void: + fire_event(GdUnitEvent.new().test_before(context.test_case.id())) + + while context.retry_execution(): + var test_context := GdUnitExecutionContext.of(context) + await _stage_before.execute(test_context) + if not context.test_case.is_skipped(): + await _stage_test.execute(GdUnitExecutionContext.of(test_context)) + await _stage_after.execute(test_context) + if test_context.is_success() or test_context.is_skipped() or test_context.is_interupted(): + break + + context.gc() + if context.is_skipped(): + fire_test_skipped(context) + else: + var reports: = context.collect_reports(true) + var statistics := context.calculate_statistics(reports) + fire_event(GdUnitEvent.new().test_after(context.test_case.id(), context.test_case.test_name(), statistics, reports)) + +func set_debug_mode(debug_mode :bool = false) -> void: + super.set_debug_mode(debug_mode) + _stage_before.set_debug_mode(debug_mode) + _stage_after.set_debug_mode(debug_mode) + _stage_test.set_debug_mode(debug_mode) + + +func fire_test_skipped(context: GdUnitExecutionContext) -> void: + var test_case := context.test_case + var statistics := { + GdUnitEvent.ORPHAN_NODES: 0, + GdUnitEvent.ELAPSED_TIME: 0, + GdUnitEvent.WARNINGS: false, + GdUnitEvent.ERRORS: false, + GdUnitEvent.ERROR_COUNT: 0, + GdUnitEvent.FAILED: false, + GdUnitEvent.FAILED_COUNT: 0, + GdUnitEvent.SKIPPED: true, + GdUnitEvent.SKIPPED_COUNT: 1, + } + var report := GdUnitReport.new() \ + .create(GdUnitReport.SKIPPED, test_case.line_number(), GdAssertMessages.test_skipped(test_case.skip_info())) + fire_event(GdUnitEvent.new().test_after(test_case.id(), test_case.test_name(), statistics, [report])) diff --git a/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd.uid new file mode 100644 index 0000000..7444e55 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd.uid @@ -0,0 +1 @@ +uid://buqq7wmo7x6de diff --git a/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd new file mode 100644 index 0000000..62dda37 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd @@ -0,0 +1,55 @@ +## The fuzzed test case execution stage.[br] +class_name GdUnitTestCaseFuzzedTestStage +extends IGdUnitExecutionStage + +var _expression_runner := GdUnitExpressionRunner.new() + + +## Executes a test case with given fuzzers 'test_<name>(<fuzzer>)' iterative.[br] +## It executes synchronized following stages[br] +## -> test_case() [br] +func _execute(context :GdUnitExecutionContext) -> void: + var test_suite := context.test_suite + var test_case := context.test_case + var fuzzers := create_fuzzers(test_suite, test_case) + + # guard on fuzzers + for fuzzer in fuzzers: + @warning_ignore("return_value_discarded") + GdUnitMemoryObserver.guard_instance(fuzzer) + + for iteration in test_case.iterations(): + @warning_ignore("redundant_await") + await test_suite.before_test() + await test_case.execute(fuzzers, iteration) + @warning_ignore("redundant_await") + await test_suite.after_test() + if test_case.is_interupted(): + break + # interrupt at first failure + var reports := context.reports() + if not reports.is_empty(): + var report :GdUnitReport = reports.pop_front() + reports.append(GdUnitReport.new() \ + .create(GdUnitReport.FAILURE, report.line_number(), GdAssertMessages.fuzzer_interuped(iteration, report.message()))) + break + await context.gc(GdUnitExecutionContext.GC_ORPHANS_CHECK.TEST_CASE) + + # unguard on fuzzers + if not test_case.is_interupted(): + for fuzzer in fuzzers: + GdUnitMemoryObserver.unguard_instance(fuzzer) + + +func create_fuzzers(test_suite :GdUnitTestSuite, test_case :_TestCase) -> Array[Fuzzer]: + if not test_case.is_fuzzed(): + return Array() + test_case.generate_seed() + var fuzzers :Array[Fuzzer] = [] + for fuzzer_arg in test_case.fuzzer_arguments(): + @warning_ignore("unsafe_cast") + var fuzzer := _expression_runner.to_fuzzer(test_suite.get_script() as GDScript, fuzzer_arg.plain_value() as String) + fuzzer._iteration_index = 0 + fuzzer._iteration_limit = test_case.iterations() + fuzzers.append(fuzzer) + return fuzzers diff --git a/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd.uid new file mode 100644 index 0000000..5df0ddb --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd.uid @@ -0,0 +1 @@ +uid://b3tpfwvmfjkoq diff --git a/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd new file mode 100644 index 0000000..03f8fa5 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd @@ -0,0 +1,53 @@ +## The test case execution stage.[br] +class_name GdUnitTestCaseSingleExecutionStage +extends IGdUnitExecutionStage + + +var _stage_before :IGdUnitExecutionStage = GdUnitTestCaseBeforeStage.new() +var _stage_after :IGdUnitExecutionStage = GdUnitTestCaseAfterStage.new() +var _stage_test :IGdUnitExecutionStage = GdUnitTestCaseSingleTestStage.new() + + +func _execute(context :GdUnitExecutionContext) -> void: + fire_event(GdUnitEvent.new().test_before(context.test_case.id())) + while context.retry_execution(): + var test_context := GdUnitExecutionContext.of(context) + await _stage_before.execute(test_context) + if not test_context.is_skipped(): + await _stage_test.execute(GdUnitExecutionContext.of(test_context)) + await _stage_after.execute(test_context) + if test_context.is_success() or test_context.is_skipped() or test_context.is_interupted(): + break + + context.gc() + if context.is_skipped(): + fire_test_skipped(context) + else: + var reports: = context.collect_reports(true) + var statistics := context.calculate_statistics(reports) + fire_event(GdUnitEvent.new().test_after(context.test_case.id(), context.test_case.test_name(), statistics, reports)) + + +func set_debug_mode(debug_mode :bool = false) -> void: + super.set_debug_mode(debug_mode) + _stage_before.set_debug_mode(debug_mode) + _stage_after.set_debug_mode(debug_mode) + _stage_test.set_debug_mode(debug_mode) + + +func fire_test_skipped(context: GdUnitExecutionContext) -> void: + var test_case := context.test_case + var statistics := { + GdUnitEvent.ORPHAN_NODES: 0, + GdUnitEvent.ELAPSED_TIME: 0, + GdUnitEvent.WARNINGS: false, + GdUnitEvent.ERRORS: false, + GdUnitEvent.ERROR_COUNT: 0, + GdUnitEvent.FAILED: false, + GdUnitEvent.FAILED_COUNT: 0, + GdUnitEvent.SKIPPED: true, + GdUnitEvent.SKIPPED_COUNT: 1, + } + var report := GdUnitReport.new() \ + .create(GdUnitReport.SKIPPED, test_case.line_number(), GdAssertMessages.test_skipped(test_case.skip_info())) + fire_event(GdUnitEvent.new().test_after(test_case.id(), test_case.test_name(), statistics, [report])) diff --git a/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd.uid new file mode 100644 index 0000000..9a0459d --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd.uid @@ -0,0 +1 @@ +uid://ctjx74gak008y diff --git a/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd new file mode 100644 index 0000000..b8ace50 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd @@ -0,0 +1,11 @@ +## The single test case execution stage.[br] +class_name GdUnitTestCaseSingleTestStage +extends IGdUnitExecutionStage + + +## Executes a single test case 'test_<name>()'.[br] +## It executes synchronized following stages[br] +## -> test_case() [br] +func _execute(context: GdUnitExecutionContext) -> void: + await context.test_case.execute() + await context.gc(GdUnitExecutionContext.GC_ORPHANS_CHECK.TEST_CASE) diff --git a/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd.uid new file mode 100644 index 0000000..48f2c97 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd.uid @@ -0,0 +1 @@ +uid://66t5ejuybo1s |
