gdharness 1.3.12 → 1.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/build/cli.js +32841 -31861
  2. package/build/godot/addons/gdharness_editor/bridge_client.gd +49 -0
  3. package/build/godot/addons/gdharness_editor/file_walk.gd +7 -4
  4. package/build/godot/addons/gdharness_editor/serialisation.gd +2 -0
  5. package/build/godot/addons/gdharness_editor/value_shapes.gd +39 -0
  6. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +19 -0
  7. package/build/godot/addons/gdharness_runtime/serialisation.gd +2 -0
  8. package/build/godot/addons/gdharness_runtime/value_shapes.gd +39 -0
  9. package/build/godot/addons/gdharness_tests/bench.gd +28 -0
  10. package/build/godot/addons/gdharness_tests/bench.gd.uid +1 -0
  11. package/build/godot/addons/gdharness_tests/event.gd +92 -0
  12. package/build/godot/addons/gdharness_tests/event.gd.uid +1 -0
  13. package/build/godot/addons/gdharness_tests/share.gd +289 -0
  14. package/build/godot/addons/gdharness_tests/share.gd.uid +1 -0
  15. package/build/godot/addons/gdharness_tests/suites.gd +34 -0
  16. package/build/godot/addons/gdharness_tests/suites.gd.uid +1 -0
  17. package/build/godot/addons/gdharness_tests/suites.tscn +6 -0
  18. package/build/godot/addons/gdharness_tests/tweens.gd +24 -0
  19. package/build/godot/addons/gdharness_tests/tweens.gd.uid +1 -0
  20. package/build/godot/addons/gdharness_tests/wait.gd +53 -0
  21. package/build/godot/addons/gdharness_tests/wait.gd.uid +1 -0
  22. package/build/godot/loader/loader.gd +28 -0
  23. package/build/godot/operations/class_cache.gd +1 -1
  24. package/build/godot/operations/file_walk.gd +7 -4
  25. package/build/godot/operations/input_actions.gd +2 -2
  26. package/build/godot/operations/plugins.gd +2 -2
  27. package/build/godot/operations/project_config.gd +37 -10
  28. package/build/godot/operations/project_file.gd +89 -31
  29. package/build/godot/operations/serialisation.gd +2 -0
  30. package/build/godot/operations/value_shapes.gd +39 -0
  31. package/build/godot/tests/listed.gd +130 -19
  32. package/build/keeper.js +8401 -931
  33. package/package.json +1 -1
@@ -5,21 +5,25 @@ extends RefCounted
5
5
  ## The engine writes each section's keys in the order it registered them, and an engine running a
6
6
  ## script registers them in another order than the editor does, so a one-setting change moved lines
7
7
  ## nobody touched across several sections and a commit of it read as a rewrite. The save is still the
8
- ## engine's, for what only it gets right: the header, each value's text and dropping a key that is
9
- ## back at its default. Only the order is taken from the file as it was.
8
+ ## engine's, for what only it gets right: the header and each value's text. The order is taken from
9
+ ## the file as it was, and so is every line of a key the call did not change: the engine leaves out
10
+ ## each key at its default, so a project holding `unsafe_call_argument=0` on purpose lost the line to
11
+ ## a change of another key.
10
12
 
11
13
  const PATH: String = "res://project.godot"
12
14
 
13
15
 
14
16
  ## Saves the project's settings, keeping the sections and keys already in the file where they were.
15
- ## A key or a section new to the file goes where the engine put it relative to its neighbours.
16
- static func save() -> Error:
17
+ ## A key or a section new to the file goes where the engine put it relative to its neighbours. Only
18
+ ## the settings in [param changed] may leave the file, as the engine leaves out one set back to its
19
+ ## default or erased.
20
+ static func save(changed: PackedStringArray) -> Error:
17
21
  var before: String = FileAccess.get_file_as_string(PATH)
18
22
  var err: Error = ProjectSettings.save()
19
23
  if err != OK or before.is_empty():
20
24
  return err
21
25
  var written: String = FileAccess.get_file_as_string(PATH)
22
- var kept: String = in_order_of(before, written)
26
+ var kept: String = in_order_of(before, written, changed)
23
27
  if kept == written:
24
28
  return OK
25
29
  var file: FileAccess = FileAccess.open(PATH, FileAccess.WRITE)
@@ -31,14 +35,33 @@ static func save() -> Error:
31
35
  return OK
32
36
 
33
37
 
34
- ## [param written] with its sections and their keys put in the order [param before] has them. Every
35
- ## line of the result is a line of [param written], so nothing the engine wrote is lost or changed.
36
- static func in_order_of(before: String, written: String) -> String:
38
+ ## [param written] with its sections and their keys put in the order [param before] has them, and
39
+ ## each line [param before] has for a key [param written] lacks put back, unless that key's setting
40
+ ## is in [param changed]. Every other line of the result is a line of [param written], so nothing the
41
+ ## engine wrote is lost or changed.
42
+ static func in_order_of(before: String, written: String, changed: PackedStringArray) -> String:
37
43
  var old: Dictionary = _parsed(before)
38
44
  var fresh: Dictionary = _parsed(written)
39
45
  var old_sections: Array[Dictionary] = old["sections"]
40
46
  var fresh_sections: Array[Dictionary] = fresh["sections"]
41
47
 
48
+ var by_name: Dictionary[String, Dictionary] = {}
49
+ var fresh_names: Array[String] = []
50
+ var entry_of: Dictionary[String, Dictionary] = {}
51
+ var fresh_keys: Dictionary[String, Array] = {}
52
+ for section: Dictionary in fresh_sections:
53
+ var name: String = section["name"]
54
+ by_name[name] = section
55
+ fresh_names.append(name)
56
+ var texts: Dictionary[String, String] = {}
57
+ var keys: Array[String] = []
58
+ for entry: Dictionary in section["entries"]:
59
+ var key: String = entry["key"]
60
+ texts[key] = str(entry["text"])
61
+ keys.append(key)
62
+ entry_of[name] = texts
63
+ fresh_keys[name] = keys
64
+
42
65
  var old_names: Array[String] = []
43
66
  var old_keys: Dictionary[String, Array] = {}
44
67
  for section: Dictionary in old_sections:
@@ -48,39 +71,74 @@ static func in_order_of(before: String, written: String) -> String:
48
71
  for entry: Dictionary in section["entries"]:
49
72
  keys.append(str(entry["key"]))
50
73
  old_keys[name] = keys
74
+ var kept: Array[Dictionary] = []
75
+ for entry: Dictionary in section["entries"]:
76
+ var key: String = entry["key"]
77
+ if (
78
+ not (by_name.has(name) and entry_of[name].has(key))
79
+ and not changed.has(_setting_of(name, key))
80
+ ):
81
+ kept.append(entry)
82
+ if kept.is_empty():
83
+ continue
84
+ if not by_name.has(name):
85
+ by_name[name] = section
86
+ fresh_names.append(name)
87
+ entry_of[name] = {}
88
+ fresh_keys[name] = []
89
+ for entry: Dictionary in kept:
90
+ entry_of[name][str(entry["key"])] = str(entry["text"])
91
+ fresh_keys[name].append(str(entry["key"]))
51
92
 
52
- var by_name: Dictionary[String, Dictionary] = {}
53
- var fresh_names: Array[String] = []
93
+ # The blank lines closing a section belong to its place in the file rather than to the section,
94
+ # so the last one keeps the ending the engine gave the file wherever it moved from. A section put
95
+ # back sits between two others, which the engine parts with a blank line.
96
+ var names: Array[String] = _merged(old_names, fresh_names)
97
+ var tails: Array[String] = []
54
98
  for section: Dictionary in fresh_sections:
55
- var name: String = section["name"]
56
- by_name[name] = section
57
- fresh_names.append(name)
99
+ tails.append(str(section["tail"]))
100
+ while tails.size() < names.size():
101
+ # Not checked: a position inside the array cannot be refused.
102
+ var _inserted: int = tails.insert(maxi(tails.size() - 1, 0), "\n")
58
103
 
59
104
  var text: String = fresh["preamble"]
60
- var at: int = 0
61
- for name: String in _merged(old_names, fresh_names):
62
- var section: Dictionary = by_name[name]
63
- var entries: Array[Dictionary] = section["entries"]
64
- var entry_of: Dictionary[String, String] = {}
65
- var fresh_keys: Array[String] = []
66
- for entry: Dictionary in entries:
67
- var key: String = entry["key"]
68
- entry_of[key] = str(entry["text"])
69
- fresh_keys.append(key)
105
+ for at: int in names.size():
106
+ var name: String = names[at]
70
107
  var known: Array = old_keys.get(name, [])
71
108
  var held: Array[String] = []
72
109
  held.assign(known)
73
- text += str(section["head"])
74
- for key: String in _merged(held, fresh_keys):
75
- text += entry_of[key]
76
- # The blank lines closing a section belong to its place in the file rather than to the
77
- # section, so the last one keeps the ending the engine gave the file wherever it moved from.
78
- var placed: Dictionary = fresh_sections[at]
79
- text += str(placed["tail"])
80
- at += 1
110
+ var listed: Array = fresh_keys[name]
111
+ var wanted: Array[String] = []
112
+ wanted.assign(listed)
113
+ text += str(by_name[name]["head"])
114
+ for key: String in _merged(held, wanted):
115
+ text += str(entry_of[name][key])
116
+ text += tails[at]
81
117
  return text
82
118
 
83
119
 
120
+ ## The setting [param key] in section [param section] stands for, as the engine names it: the
121
+ ## section and the key joined, a quoted key read without its quotes.
122
+ static func _setting_of(section: String, key: String) -> String:
123
+ var bare: String = key
124
+ if bare.length() >= 2 and bare.begins_with('"') and bare.ends_with('"'):
125
+ bare = bare.substr(1, bare.length() - 2).c_unescape()
126
+ return bare if section.is_empty() else section + "/" + bare
127
+
128
+
129
+ ## Whether project.godot has a line for [param setting_path]. A setting at its default is in the
130
+ ## file only when the file says so, which the value alone cannot tell.
131
+ static func sets(setting_path: String) -> bool:
132
+ var parsed: Dictionary = _parsed(FileAccess.get_file_as_string(PATH))
133
+ var sections: Array[Dictionary] = parsed["sections"]
134
+ for section: Dictionary in sections:
135
+ var entries: Array[Dictionary] = section["entries"]
136
+ for entry: Dictionary in entries:
137
+ if _setting_of(str(section["name"]), str(entry["key"])) == setting_path:
138
+ return true
139
+ return false
140
+
141
+
84
142
  ## The names of [param wanted] in the order [param held] lists them, those [param held] lacks each
85
143
  ## placed after the name [param wanted] puts before it. Names [param wanted] lacks are left out.
86
144
  static func _merged(held: Array[String], wanted: Array[String]) -> Array[String]:
@@ -222,6 +222,8 @@ func deserialize_value(value: Variant) -> Variant:
222
222
  # to compile a script that gives a Variant to a typed parameter: the operations are compiled
223
223
  # under the target project's warning levels, so one such project lost every headless call.
224
224
  var tag: String = str(fields["_type"])
225
+ if GdhShapes.LIST_TAGS.has(tag):
226
+ return GdhShapes.packed_tagged(fields, fitted)
225
227
  var builder: String = BUILDERS.get(tag, "")
226
228
  if builder.is_empty() or not GdhShapes.buildable(fields, tag):
227
229
  return fields
@@ -20,6 +20,18 @@ const PACKED_ELEMENTS: Dictionary = {
20
20
  TYPE_PACKED_VECTOR4_ARRAY: TYPE_VECTOR4,
21
21
  TYPE_PACKED_COLOR_ARRAY: TYPE_COLOR,
22
22
  }
23
+ ## The tags of the packed arrays that cross as a plain list, and the type each builds.
24
+ const LIST_TAGS: Dictionary = {
25
+ "PackedInt32Array": TYPE_PACKED_INT32_ARRAY,
26
+ "PackedInt64Array": TYPE_PACKED_INT64_ARRAY,
27
+ "PackedFloat32Array": TYPE_PACKED_FLOAT32_ARRAY,
28
+ "PackedFloat64Array": TYPE_PACKED_FLOAT64_ARRAY,
29
+ "PackedStringArray": TYPE_PACKED_STRING_ARRAY,
30
+ "PackedVector2Array": TYPE_PACKED_VECTOR2_ARRAY,
31
+ "PackedVector3Array": TYPE_PACKED_VECTOR3_ARRAY,
32
+ "PackedVector4Array": TYPE_PACKED_VECTOR4_ARRAY,
33
+ "PackedColorArray": TYPE_PACKED_COLOR_ARRAY,
34
+ }
23
35
 
24
36
  # What each tag has to carry before it is built. A tag naming a type whose keys are not there is a
25
37
  # tag somebody wrote by mistake or wrote for a different shape, and building one out of defaults
@@ -44,6 +56,15 @@ const REQUIRED: Dictionary = {
44
56
  "Color": [["r", "g", "b"]],
45
57
  "NodePath": [["path"]],
46
58
  "PackedByteArray": [["base64"]],
59
+ "PackedInt32Array": [["value"]],
60
+ "PackedInt64Array": [["value"]],
61
+ "PackedFloat32Array": [["value"]],
62
+ "PackedFloat64Array": [["value"]],
63
+ "PackedStringArray": [["value"]],
64
+ "PackedVector2Array": [["value"]],
65
+ "PackedVector3Array": [["value"]],
66
+ "PackedVector4Array": [["value"]],
67
+ "PackedColorArray": [["value"]],
47
68
  "float": [["value"]],
48
69
  "int": [["value"]],
49
70
  "RID": [["id"]],
@@ -79,6 +100,10 @@ static func written_as(type: int) -> String:
79
100
  var alternatives: Array = REQUIRED.get(tag, [])
80
101
  if alternatives.is_empty() or tag in ["float", "int", "Dictionary"]:
81
102
  return ""
103
+ # A packed list is written as the plain list wherever its type is declared, so its tag is no
104
+ # remedy there.
105
+ if PACKED_ELEMENTS.has(type) and type != TYPE_PACKED_BYTE_ARRAY:
106
+ return ""
82
107
  var keys: Array = alternatives[0]
83
108
  var optional: Array = OPTIONAL.get(tag, [])
84
109
  var parts: Array[String] = []
@@ -172,6 +197,20 @@ static func fraction(value: Variant, type: int, elements: Array = []) -> bool:
172
197
  return false
173
198
 
174
199
 
200
+ ## A packed array tagged with its type, {"_type": "PackedStringArray", "value": [...]}, for where
201
+ ## nothing declares what a plain list is to become: a setting the engine does not declare, or a
202
+ ## Variant property, takes a list as an Array. Each item is fitted as a declared list's would be,
203
+ ## and a list with an item that does not fit stays the tag it arrived as, for the caller to refuse.
204
+ static func packed_tagged(fields: Dictionary, fit: Callable) -> Variant:
205
+ var tag: String = str(fields["_type"])
206
+ if not buildable(fields, tag) or not fields["value"] is Array:
207
+ return fields
208
+ var type: int = LIST_TAGS[tag]
209
+ var items: Array = fields["value"]
210
+ var built: Variant = packed_from(items, type, fit)
211
+ return built if typeof(built) == type else fields
212
+
213
+
175
214
  ## The packed array [param type] built from [param items], each fitted by [param fit] to what the
176
215
  ## array holds, or [param items] as they are when one cannot become that, for the caller to refuse.
177
216
  ## Element by element, so a point given as its fields is read as one: converted whole, a list of
@@ -48,14 +48,22 @@ const EXPECTED: Dictionary[String, String] = {
48
48
  }
49
49
  ## The tool name gdUnit4 reads its options after.
50
50
  const TOOL: String = "GdUnitCmdTool.gd"
51
+ ## Ends the runner's own arguments: everything after it is gdUnit4's options, for a run with no
52
+ ## script path on its command line to find them after.
53
+ const GDUNIT: String = "--gdh-gdunit"
51
54
  ## How long a windowless engine sleeps after each frame. The engine's own default is the
52
55
  ## low-processor pause, 6.8 ms measured, which a run of suites awaiting frames spends thousands of
53
56
  ## times; zero makes a frame's delta round to nothing, and a tween then never moves.
54
57
  const FRAME_SLEEP_USEC: int = 1000
58
+ ## What the engine's shutdown prints as it clears the newest script and the oldest, on an engine
59
+ ## whose walk [GdhShutdownWalk] guards. Kept in step with the server's engine-leaks.ts, which reads
60
+ ## a run printing the first and not the second as a walk that stopped part way.
61
+ const WALK_BEGAN: String = "gdharness: the engine's shutdown began clearing scripts"
62
+ const WALK_ENDED: String = "gdharness: the engine's shutdown cleared every script"
63
+ ## The meta of the theme database holding the scripts [method GdhShutdownWalk.hold] keeps.
64
+ const WALK_HOLD: StringName = &"gdharness_scripts_held_past_the_shutdown_walk"
55
65
 
56
66
  var _runner: GdhRunner = null
57
- ## The suites of the run, compiled by [method compiled_together] and held for as long as it runs.
58
- var _together: GDScript = null
59
67
 
60
68
 
61
69
  ## gdUnit4's runner reading its options after this script's path rather than after its own tool's,
@@ -64,12 +72,38 @@ class GdhRunner:
64
72
  extends GdUnitTestCIRunner
65
73
 
66
74
  var own_path: String = ""
75
+ ## gdUnit4's options when they were given after [constant GDUNIT] rather than read from the
76
+ ## engine's command line, as the suites scene gives them: a played scene has no script path to
77
+ ## find them after.
78
+ var given: PackedStringArray = []
79
+ ## The suites compiled by [method compiled_together], held for as long as the run goes on.
80
+ var together: GDScript = null
67
81
  ## The cases dealt by their rows, by `res://suite.gd:test_case`.
68
82
  var row_shares: Dictionary[String, GdhRowShare] = {}
69
83
  ## The cases gdUnit4 said it finished, which [GdhSessionCheck] holds the session's lookups against.
70
84
  var cases_ended: int = 0
85
+ ## The marker of the shutdown walk's oldest end, made as the run starts.
86
+ var walk_scripts: Array[GDScript] = []
87
+
88
+ ## As the run ends, the marker of the walk's newest end, and every script held past the walk, on
89
+ ## an engine whose walk can stop part way ([GdhShutdownWalk]).
90
+ func _notification(what: int) -> void:
91
+ if what != NOTIFICATION_EXIT_TREE or not GdhShutdownWalk.guarded():
92
+ return
93
+ var also: Array[GDScript] = walk_scripts.duplicate()
94
+ also.append_array(GdhShutdownWalk.marker(WALK_BEGAN))
95
+ if together != null:
96
+ also.append(together)
97
+ var own: GDScript = ResourceLoader.get_cached_ref(own_path) as GDScript
98
+ if own != null:
99
+ also.append(own)
100
+ GdhShutdownWalk.hold(also)
71
101
 
72
102
  func get_cmdline_args() -> PackedStringArray:
103
+ if not given.is_empty():
104
+ var options: PackedStringArray = [TOOL]
105
+ options.append_array(given)
106
+ return options
73
107
  return options_of(OS.get_cmdline_args(), own_path)
74
108
 
75
109
  ## gdUnit4 asks this of every case it finds, a parameterised case once a row, each row carrying
@@ -200,9 +234,92 @@ class GdhSessionCheck:
200
234
  )
201
235
 
202
236
 
237
+ ## The engine's shutdown walk over its scripts, on an engine below 4.8. It clears them from the
238
+ ## newest, and a script whose release frees the next one, as the last reference to its base can,
239
+ ## leaves the walk on freed memory, where it ends (godot#117991, fixed in 4.8 by godot#120976).
240
+ ## Every script it never reached keeps what it holds, which the engine then reports as leaked at
241
+ ## exit, so a run of suites that leaked nothing failed on it.
242
+ class GdhShutdownWalk:
243
+ extends RefCounted
244
+
245
+ ## Whether the engine of [param version] is one whose walk can stop part way.
246
+ static func guarded(version: Dictionary = Engine.get_version_info()) -> bool:
247
+ var major: int = version.get("major", 0)
248
+ var minor: int = version.get("minor", 0)
249
+ return major == 4 and minor < 8
250
+
251
+ ## A script the walk clears as it passes it, printing [param said] then, made now so it takes
252
+ ## this place in the walk. It holds an instance of itself, which keeps it alive past the engine
253
+ ## dropping its cache of scripts until the walk clears it, and the instance that prints, whose
254
+ ## script is older and so is not cleared yet.
255
+ static func marker(said: String) -> Array[GDScript]:
256
+ var printing: GDScript = GDScript.new()
257
+ printing.source_code = (
258
+ 'extends RefCounted\n\nvar said: String = ""\n\n\nfunc _notification(what: int) -> void:\n'
259
+ + "\tif what == NOTIFICATION_PREDELETE:\n\t\tprint(said)\n"
260
+ )
261
+ var marking: GDScript = GDScript.new()
262
+ marking.source_code = (
263
+ "extends RefCounted\n\nstatic var kept: RefCounted = null\n"
264
+ + "static var printing: RefCounted = null\n"
265
+ )
266
+ if printing.reload() != OK or marking.reload() != OK:
267
+ push_error("gdharness test could not make the marker of the engine's shutdown: " + said)
268
+ return []
269
+ var instance: Object = printing.new()
270
+ instance.set("said", said)
271
+ marking.set("kept", marking.new())
272
+ marking.set("printing", instance)
273
+ return [printing, marking]
274
+
275
+ ## Every script loaded from a file under res://, and [param also], held until the theme database
276
+ ## goes, which is after the walk and before the GDScript language does. Freed in the walk, a
277
+ ## script can take the next one with it and end the walk; held, none is freed there, so the walk
278
+ ## reaches every script and what the engine reports at exit is only what was leaked. A script a
279
+ ## run leaks is held by what leaked it as well, and is still reported.
280
+ static func hold(also: Array[GDScript]) -> void:
281
+ var held: Array[GDScript] = also.duplicate()
282
+ var pending: PackedStringArray = ["res://"]
283
+ while not pending.is_empty():
284
+ var directory: String = pending[pending.size() - 1]
285
+ pending.remove_at(pending.size() - 1)
286
+ for file: String in DirAccess.get_files_at(directory):
287
+ var path: String = directory.path_join(file)
288
+ if file.ends_with(".gd") and ResourceLoader.has_cached(path):
289
+ var script: GDScript = ResourceLoader.get_cached_ref(path) as GDScript
290
+ if script != null:
291
+ held.append(script)
292
+ for inner: String in DirAccess.get_directories_at(directory):
293
+ if not inner.begins_with("."):
294
+ var _inner: bool = pending.append(directory.path_join(inner))
295
+ ThemeDB.set_meta(WALK_HOLD, held)
296
+
297
+
203
298
  func _initialize() -> void:
299
+ _runner = started(OS.get_cmdline_user_args())
300
+ _runner.own_path = get_script().resource_path
301
+ root.add_child(_runner)
302
+
303
+
304
+ ## As gdUnit4's own `GdUnitCmdTool.gd` ends, which names in a verbose run the nodes left orphaned.
305
+ func _finalize() -> void:
306
+ queue_delete(_runner)
307
+ if OS.is_stdout_verbose():
308
+ print("Orphan nodes left as the run ends:")
309
+ Window.print_orphan_nodes()
310
+
311
+
312
+ ## The runner over the suites [param given] lists, to be added to the tree, which starts it. Shared
313
+ ## by this script and the suites scene of the tests addon, which loads this file from where the
314
+ ## server ships it and calls this, since the addon itself names no gdUnit4 class.
315
+ static func started(given: PackedStringArray) -> GdhRunner:
316
+ # First, so the marker is older than every script the run loads.
317
+ var oldest: Array[GDScript] = []
318
+ if GdhShutdownWalk.guarded():
319
+ oldest = GdhShutdownWalk.marker(WALK_ENDED)
204
320
  OS.low_processor_usage_mode_sleep_usec = FRAME_SLEEP_USEC
205
- var asked: PackedStringArray = OS.get_cmdline_user_args()
321
+ var at: int = given.find(GDUNIT)
322
+ var asked: PackedStringArray = given if at < 0 else given.slice(0, at)
206
323
  var unread: PackedStringArray = []
207
324
  for flag: String in REPORTS_OFF:
208
325
  if asked.has(flag):
@@ -221,28 +338,22 @@ func _initialize() -> void:
221
338
  )
222
339
  )
223
340
  var suites: PackedStringArray = listed(asked, LISTED)
341
+ var runner: GdhRunner = GdhRunner.new()
342
+ runner.walk_scripts = oldest
343
+ if at >= 0:
344
+ runner.given = given.slice(at + 1)
224
345
  if asked.has(TOGETHER):
225
- _together = compiled_together(suites)
346
+ runner.together = compiled_together(suites)
226
347
  var expected_out: String = argument_after(asked, EXPECTED_OUT)
227
348
  if not expected_out.is_empty():
228
349
  write_expected(expected_out, suites)
229
- _runner = GdhRunner.new()
230
- _runner.own_path = get_script().resource_path
231
- _runner.add_test_suites(suites)
232
- _runner.skip_test_suites(listed(asked, SKIPPING))
233
- _runner.row_shares = row_shares_of(listed(asked, ROWS))
350
+ runner.add_test_suites(suites)
351
+ runner.skip_test_suites(listed(asked, SKIPPING))
352
+ runner.row_shares = row_shares_of(listed(asked, ROWS))
234
353
  # Added to the list itself rather than through `register`, which saves every hook it holds into
235
354
  # `project.godot`.
236
- hooks.enigne_hooks.append(GdhSessionCheck.new(_runner))
237
- root.add_child(_runner)
238
-
239
-
240
- ## As gdUnit4's own `GdUnitCmdTool.gd` ends, which names in a verbose run the nodes left orphaned.
241
- func _finalize() -> void:
242
- queue_delete(_runner)
243
- if OS.is_stdout_verbose():
244
- print("Orphan nodes left as the run ends:")
245
- Window.print_orphan_nodes()
355
+ hooks.enigne_hooks.append(GdhSessionCheck.new(runner))
356
+ return runner
246
357
 
247
358
 
248
359
  ## Turns off the hooks among [param hooks] whose scripts [param unread] names, answering the scripts