gdharness 1.3.50 → 1.3.53

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.
@@ -6,6 +6,7 @@ extends EditorPlugin
6
6
 
7
7
  const GdhBridgeClient = preload("bridge_client.gd")
8
8
  const GdhToolExecutor = preload("tool_executor.gd")
9
+ const GdhReleaseExport = preload("release_export.gd")
9
10
  ## The tools answered while the editor scans or imports, because none of them saves a file: the
10
11
  ## ones a caller waits on a scan with, and the ones that only read or stop a played game.
11
12
  const ANSWERED_WHILE_SCANNING: Array[String] = [
@@ -20,9 +21,13 @@ const ANSWERED_WHILE_SCANNING: Array[String] = [
20
21
  var _client: GdhBridgeClient
21
22
  var _tool_executor: GdhToolExecutor
22
23
  var _status_label: Label
24
+ var _release_export: GdhReleaseExport
23
25
 
24
26
 
25
27
  func _enter_tree() -> void:
28
+ _release_export = GdhReleaseExport.new()
29
+ add_export_plugin(_release_export)
30
+
26
31
  _client = GdhBridgeClient.new()
27
32
  _client.name = "GdharnessBridgeClient"
28
33
  add_child(_client)
@@ -41,6 +46,10 @@ func _enter_tree() -> void:
41
46
 
42
47
 
43
48
  func _exit_tree() -> void:
49
+ if _release_export:
50
+ remove_export_plugin(_release_export)
51
+ _release_export = null
52
+
44
53
  if _client:
45
54
  if _client.connected.is_connected(_on_connected):
46
55
  _client.connected.disconnect(_on_connected)
@@ -0,0 +1,66 @@
1
+ @tool
2
+ extends EditorExportPlugin
3
+ ## Leaves gdharness out of a release export: the runtime autoload's entry, its loader and the
4
+ ## addons, none of which does anything in a release build, so no player's build carries them. A
5
+ ## debug export keeps them, since the runtime serves a debug build.
6
+
7
+ ## The loader the runtime autoload's entry names, as `gdharness setup` writes it.
8
+ const LOADER: String = "res://gdharness/loader.gd"
9
+ ## The folders a release export leaves out: the loader's and the addons that never run there.
10
+ const LEFT_OUT: Array[String] = [
11
+ "res://gdharness/",
12
+ "res://addons/gdharness_runtime/",
13
+ "res://addons/gdharness_editor/",
14
+ "res://addons/auto_reload/",
15
+ ]
16
+
17
+ ## Each autoload entry taken out for the export, by setting name, with its value and its order.
18
+ var _values: Dictionary[String, Variant] = {}
19
+ var _orders: Dictionary[String, int] = {}
20
+ var _release: bool = false
21
+
22
+
23
+ ## Before "GDScript", by which the export orders its plugins: a file stops at the first that skips
24
+ ## or replaces it, and that one turns every script into tokens, so a later skip leaves them in.
25
+ func _get_name() -> String:
26
+ return "GDHarness"
27
+
28
+
29
+ func _export_begin(
30
+ _features: PackedStringArray, is_debug: bool, _path: String, _flags: int
31
+ ) -> void:
32
+ _release = not is_debug
33
+ _values.clear()
34
+ _orders.clear()
35
+ if not _release:
36
+ return
37
+ # The export writes project.binary from the settings as they stand once this returns, and the
38
+ # editor saves project.godot only for an edit made in its settings dialog.
39
+ for property: Dictionary in ProjectSettings.get_property_list():
40
+ var setting: String = property["name"]
41
+ if not setting.begins_with("autoload/"):
42
+ continue
43
+ var value: Variant = ProjectSettings.get_setting(setting)
44
+ if value is String and str(value).trim_prefix("*") == LOADER:
45
+ _values[setting] = value
46
+ _orders[setting] = ProjectSettings.get_order(setting)
47
+ for setting: String in _values:
48
+ ProjectSettings.set_setting(setting, null)
49
+
50
+
51
+ func _export_file(path: String, _type: String, _features: PackedStringArray) -> void:
52
+ if not _release:
53
+ return
54
+ for folder: String in LEFT_OUT:
55
+ if path.begins_with(folder):
56
+ skip()
57
+ return
58
+
59
+
60
+ func _export_end() -> void:
61
+ for setting: String in _values:
62
+ ProjectSettings.set_setting(setting, _values[setting])
63
+ ProjectSettings.set_order(setting, _orders[setting])
64
+ _values.clear()
65
+ _orders.clear()
66
+ _release = false
@@ -0,0 +1 @@
1
+ uid://dlypkbduqc14w
@@ -31,8 +31,9 @@ func _ready() -> void:
31
31
  _runtime = runtime
32
32
 
33
33
 
34
- ## Whether gdharness's runtime is listening in this engine: false in a release build, a script run
35
- ## and an engine without the addon, though this node is in every one of them.
34
+ ## Whether gdharness's runtime is listening in this engine: false in a script run, an engine without
35
+ ## the addon and a release build that still carries this node, though it is in every one of them. A
36
+ ## release export made with the editor addon enabled leaves this node out.
36
37
  func serving() -> bool:
37
38
  if not is_instance_valid(_runtime) or not _runtime.has_method("serving"):
38
39
  return false
@@ -7,10 +7,10 @@ extends SceneTree
7
7
  ##
8
8
  ## Started from outside the project, `-s` with this file's absolute path, so it declares no class of
9
9
  ## its own and reaches gdUnit4 through the project's global classes. Besides the file of suites it
10
- ## finds each case by its id in a dictionary ([GdhIndexedSession]), can compile the suites together
11
- ## before gdUnit4 loads them ([constant TOGETHER]), can turn off the reports the caller does
12
- ## not read ([constant REPORTS_OFF]), and writes the errors and warnings each suite declares it
13
- ## prints ([constant EXPECTED_OUT]).
10
+ ## finds each case by its id in a dictionary ([GdhIndexedSession]), can turn off the reports the
11
+ ## caller does not read ([constant REPORTS_OFF]), writes the errors and warnings each suite declares
12
+ ## it prints ([constant EXPECTED_OUT]) and what the run loads ([constant REACHED_OUT]), and in an
13
+ ## engine of its own compiles every script together ([constant COMPILE_ONLY]).
14
14
 
15
15
  const LISTED: String = "--suites-in"
16
16
  const SKIPPING: String = "--skipping-in"
@@ -42,11 +42,6 @@ const REPORTS_OFF: Dictionary[String, String] = {
42
42
  "--html-report-off": "res://addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd",
43
43
  "--junit-report-off": "res://addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd",
44
44
  }
45
- ## Compiles the suites together beside every class of the game ([method compiled_together]) before
46
- ## gdUnit4 loads them. A gate asks for it, since it runs every suite and wants every script that
47
- ## does not compile named. project_test does not: its answer is about the scripts its run reached,
48
- ## and the compile names every broken script of the game, then once more as the one script it made.
49
- const TOGETHER: String = "--compile-together"
50
45
  ## The file to write what every suite declares it prints into, as JSON: each key of
51
46
  ## [constant EXPECTED] holding the declarations keyed by suite path and then case name.
52
47
  const EXPECTED_OUT: String = "--expected-out"
@@ -56,6 +51,20 @@ const EXPECTED: Dictionary[String, String] = {
56
51
  "errors": "GDHARNESS_EXPECTED_ERRORS",
57
52
  "warnings": "GDHARNESS_EXPECTED_WARNINGS",
58
53
  }
54
+ ## Compiles the engine's own suites together ([method compiled_together]) before gdUnit4 loads them,
55
+ ## so they share one reading of what they reach, and no class of the game they do not: what the
56
+ ## engine holds is then what its suites reach, which is what [constant REACHED_OUT] records.
57
+ const TOGETHER: String = "--compile-together"
58
+ ## The file to write what the run loads into, a line as each suite ends ([GdhReached]).
59
+ const REACHED_OUT: String = "--reached-out"
60
+ ## Compiles the suites together beside every class of the game ([method compiled_together]), prints
61
+ ## [constant COMPILED] and quits, running no suite: a run's check of every script, made in an engine
62
+ ## of its own so the engines running its suites load only what their suites do. `gdharness test`
63
+ ## asks for it, since it wants every script that does not compile named. project_test does not: its
64
+ ## answer is about the scripts its run reached, and the compile names every broken script of the
65
+ ## game, then once more as the one script it made.
66
+ const COMPILE_ONLY: String = "--compile-only"
67
+ const COMPILED: String = "gdharness compiled every script together"
59
68
  ## The tool name gdUnit4 reads its options after.
60
69
  const TOOL: String = "GdUnitCmdTool.gd"
61
70
  ## Ends the runner's own arguments: everything after it is gdUnit4's options, for a run with no
@@ -76,20 +85,57 @@ const WALK_HOLD: StringName = &"gdharness_scripts_held_past_the_shutdown_walk"
76
85
  var _runner: GdhRunner = null
77
86
 
78
87
 
88
+ ## Here rather than in [method _initialize]: Godot loads the script given with `-s` and makes its
89
+ ## instance before it loads the autoloads, so a load an autoload makes as it is made is recorded
90
+ ## too.
91
+ func _init() -> void:
92
+ var into: String = argument_after(OS.get_cmdline_user_args(), REACHED_OUT)
93
+ if not into.is_empty():
94
+ GdhReached.start(into)
95
+
96
+
79
97
  func _initialize() -> void:
80
- _runner = started(OS.get_cmdline_user_args())
98
+ var given: PackedStringArray = OS.get_cmdline_user_args()
99
+ if given.has(COMPILE_ONLY):
100
+ compile_only(given)
101
+ return
102
+ if GdhReached.current != null:
103
+ GdhReached.current.autoloaded()
104
+ _runner = started(given)
81
105
  _runner.own_path = get_script().resource_path
82
106
  root.add_child(_runner)
83
107
 
84
108
 
85
109
  ## As gdUnit4's own `GdUnitCmdTool.gd` ends, which names in a verbose run the nodes left orphaned.
110
+ ## The runner is not queued for deletion here, as gdUnit4's does: the tree has freed root and the
111
+ ## runner with it before this is called, and queueing a freed node writes into freed memory.
86
112
  func _finalize() -> void:
87
- queue_delete(_runner)
113
+ GdhReached.stop()
88
114
  if OS.is_stdout_verbose():
89
115
  print("Orphan nodes left as the run ends:")
90
116
  Window.print_orphan_nodes()
91
117
 
92
118
 
119
+ ## Compiles the suites [param given] lists together and quits, holding the scripts past the
120
+ ## engine's shutdown walk as a run of suites does ([GdhShutdownWalk]).
121
+ func compile_only(given: PackedStringArray) -> void:
122
+ var oldest: Array[GDScript] = []
123
+ if GdhShutdownWalk.guarded():
124
+ oldest = GdhShutdownWalk.marker(WALK_ENDED)
125
+ var together: GDScript = compiled_together(listed(given, LISTED), held_classes())
126
+ print(COMPILED)
127
+ if GdhShutdownWalk.guarded():
128
+ var also: Array[GDScript] = oldest.duplicate()
129
+ also.append_array(GdhShutdownWalk.marker(WALK_BEGAN))
130
+ also.append(together)
131
+ var own_path: String = get_script().resource_path
132
+ var own: GDScript = ResourceLoader.get_cached_ref(own_path) as GDScript
133
+ if own != null:
134
+ also.append(own)
135
+ GdhShutdownWalk.hold(also)
136
+ quit()
137
+
138
+
93
139
  ## The runner over the suites [param given] lists, to be added to the tree, which starts it. Shared
94
140
  ## by this script and the suites scene of the tests addon, which loads this file from where the
95
141
  ## server ships it and calls this, since the addon itself names no gdUnit4 class.
@@ -124,7 +170,7 @@ static func started(given: PackedStringArray) -> GdhRunner:
124
170
  if at >= 0:
125
171
  runner.given = given.slice(at + 1)
126
172
  if asked.has(TOGETHER):
127
- runner.together = compiled_together(suites)
173
+ runner.together = compiled_together(suites, [])
128
174
  var expected_out: String = argument_after(asked, EXPECTED_OUT)
129
175
  if not expected_out.is_empty():
130
176
  write_expected(expected_out, suites)
@@ -161,16 +207,16 @@ static func unread_off(
161
207
  ## [param suites] compiled inside one script, answered for the caller to hold so gdUnit4's own loads
162
208
  ## find them compiled. The engine keeps what it read of a class only while the compile that asked
163
209
  ## for it runs (`GDScriptCache`), so a suite compiled on its own reads again every class it reaches
164
- ## a member of, some seventy milliseconds a suite. As the preloads of one script naming every class
165
- ## of the game's own first, held from that script's compile, they share one reading of each: two
166
- ## hundred suites compiled in a third of the time they took one at a time. The names are public,
210
+ ## a member of, some seventy milliseconds a suite. As the preloads of one script, after the
211
+ ## [param classes] it names first, held from that script's compile, they share one reading of each:
212
+ ## two hundred suites compiled in a third of the time they took one at a time. The names are public,
167
213
  ## since a private one unread is a warning and every warning an error, and each a variable, since a
168
214
  ## class is no constant expression. A suite that does not compile fails here and again when gdUnit4
169
215
  ## loads it, which reports it as it would have. A directory listed is left to gdUnit4 to walk.
170
- static func compiled_together(suites: PackedStringArray) -> GDScript:
216
+ static func compiled_together(suites: PackedStringArray, classes: PackedStringArray) -> GDScript:
171
217
  var lines: PackedStringArray = ["extends RefCounted"]
172
218
  var at: int = 0
173
- for named: String in held_classes():
219
+ for named: String in classes:
174
220
  var _held: bool = lines.append("var held_%d: Variant = %s" % [at, named])
175
221
  at += 1
176
222
  at = 0
@@ -185,7 +231,7 @@ static func compiled_together(suites: PackedStringArray) -> GDScript:
185
231
  return together
186
232
 
187
233
 
188
- ## The classes [method compiled_together] names before the suites: the game's own GDScript classes,
234
+ ## The classes [constant COMPILE_ONLY] compiles before the suites: the game's own GDScript classes,
189
235
  ## none of an addon's or a suite's, then gdUnit4's suite. Only GDScript shares the engine's reading,
190
236
  ## and a sandboxed mod's SafeGDScript class would load the sandbox's compiler, some 290 MB and up to
191
237
  ## 2.5 s, for nothing.
@@ -454,6 +500,8 @@ class GdhRunner:
454
500
  var _watched: int = tree.process_frame.connect(drawn_window)
455
501
  GdUnitEvent.TESTSUITE_BEFORE:
456
502
  drawn_window()
503
+ if GdhReached.current != null:
504
+ GdhReached.current.began(event.resource_path())
457
505
  suite_running = event.resource_path()
458
506
  orphans_at_suite = orphan_ids()
459
507
  orphans_named.clear()
@@ -471,6 +519,8 @@ class GdhRunner:
471
519
  # gdUnit4 minimises the window once the suite's end is reported.
472
520
  if event.type() == GdUnitEvent.TESTSUITE_AFTER:
473
521
  minimise_pending = true
522
+ if GdhReached.current != null:
523
+ GdhReached.current.ended(event.resource_path())
474
524
  # After gdUnit4's own report of the case, so the line falls under it.
475
525
  if event.orphan_nodes() == 0:
476
526
  return
@@ -742,3 +792,128 @@ class GdhShutdownWalk:
742
792
  if not inner.begins_with("."):
743
793
  var _inner: bool = pending.append(directory.path_join(inner))
744
794
  ThemeDB.set_meta(WALK_HOLD, held)
795
+
796
+
797
+ ## What the run loads, written to the file named after [constant REACHED_OUT] a line of JSON at a
798
+ ## time, each with every path asked for since the line before: one once the autoloads are loaded,
799
+ ## one as each suite begins, which also names the project's files the cache holds then, and one as
800
+ ## it ends. The engine answers a load of a resource it holds from its cache without asking any
801
+ ## loader, and it holds one only while something refers to it, so what a suite can load unasked is
802
+ ## what the cache held as it began. Placed first among the loaders, it is asked about every load and
803
+ ## every [method ResourceLoader.exists] and declines each. A script compiled for a preload, a base
804
+ ## or a class name is read without a loader, so a suite's last line also has the project's files
805
+ ## the cache came to hold that no loader was asked for.
806
+ class GdhReached:
807
+ extends ResourceFormatLoader
808
+
809
+ static var current: GdhReached = null
810
+
811
+ var _file: FileAccess = null
812
+ ## Loads on other threads ask too, as a threaded load runs its own.
813
+ var _mutex: Mutex = Mutex.new()
814
+ ## What the current line holds, since a path freed and loaded again is asked for again.
815
+ var _seen: Dictionary[String, bool] = {}
816
+ var _fresh: PackedStringArray = []
817
+ var _files: PackedStringArray = []
818
+ var _resident: Dictionary[String, bool] = {}
819
+
820
+ static func start(into: String) -> void:
821
+ var file: FileAccess = FileAccess.open(into, FileAccess.WRITE)
822
+ if file == null:
823
+ push_error(
824
+ (
825
+ "gdharness test could not write what the run loads to %s: %s"
826
+ % [into, error_string(FileAccess.get_open_error())]
827
+ )
828
+ )
829
+ return
830
+ current = GdhReached.new()
831
+ current._file = file
832
+ current._files = project_files()
833
+ ResourceLoader.add_resource_format_loader(current, true)
834
+
835
+ static func stop() -> void:
836
+ if current == null:
837
+ return
838
+ ResourceLoader.remove_resource_format_loader(current)
839
+ current._file.close()
840
+ current = null
841
+
842
+ ## Every file under res:// the engine sees, as its file system does: no hidden folder, and no
843
+ ## folder holding a `.gdignore`.
844
+ static func project_files() -> PackedStringArray:
845
+ var found: PackedStringArray = []
846
+ var pending: PackedStringArray = ["res://"]
847
+ while not pending.is_empty():
848
+ var directory: String = pending[pending.size() - 1]
849
+ pending.remove_at(pending.size() - 1)
850
+ if FileAccess.file_exists(directory.path_join(".gdignore")):
851
+ continue
852
+ for file: String in DirAccess.get_files_at(directory):
853
+ if not file.ends_with(".import") and not file.ends_with(".uid"):
854
+ var _file_found: bool = found.append(directory.path_join(file))
855
+ for inner: String in DirAccess.get_directories_at(directory):
856
+ if not inner.begins_with("."):
857
+ var _inner: bool = pending.append(directory.path_join(inner))
858
+ return found
859
+
860
+ func _recognize_path(path: String, _type: StringName) -> bool:
861
+ _mutex.lock()
862
+ if not _seen.has(path):
863
+ _seen[path] = true
864
+ var _asked: bool = _fresh.append(path)
865
+ _mutex.unlock()
866
+ return false
867
+
868
+ func _get_recognized_extensions() -> PackedStringArray:
869
+ return []
870
+
871
+ func _handles_type(_type: StringName) -> bool:
872
+ return false
873
+
874
+ ## Writes the line for the autoloads, which have all been loaded by now.
875
+ func autoloaded() -> void:
876
+ written({ "autoloads": true })
877
+
878
+ ## Writes the line of the suite at [param suite], which is about to begin, with what the cache
879
+ ## holds. The cache is asked outside the lock, since a load on another thread holds the cache's
880
+ ## own lock while it asks this.
881
+ func began(suite: String) -> void:
882
+ var resident: PackedStringArray = []
883
+ _resident.clear()
884
+ for path: String in _files:
885
+ if ResourceLoader.has_cached(path):
886
+ var _held: bool = resident.append(path)
887
+ _resident[path] = true
888
+ written({ "began": suite, "resident": resident })
889
+
890
+ ## Writes the line of the suite at [param suite], which has just ended, with the project's files
891
+ ## the cache came to hold while it ran that no loader was asked for.
892
+ func ended(suite: String) -> void:
893
+ _mutex.lock()
894
+ var unseen: PackedStringArray = []
895
+ for path: String in _files:
896
+ if not _seen.has(path) and not _resident.has(path):
897
+ var _unseen: bool = unseen.append(path)
898
+ _mutex.unlock()
899
+ var cached: PackedStringArray = []
900
+ for path: String in unseen:
901
+ if ResourceLoader.has_cached(path):
902
+ var _cached: bool = cached.append(path)
903
+ _mutex.lock()
904
+ for path: String in cached:
905
+ if not _seen.has(path):
906
+ _seen[path] = true
907
+ var _held: bool = _fresh.append(path)
908
+ _mutex.unlock()
909
+ written({ "suite": suite })
910
+
911
+ ## Writes [param fields] as a line with every path asked for since the line before.
912
+ func written(fields: Dictionary) -> void:
913
+ _mutex.lock()
914
+ fields["reached"] = _fresh
915
+ _fresh = []
916
+ _seen.clear()
917
+ _mutex.unlock()
918
+ var _stored: bool = _file.store_line(JSON.stringify(fields))
919
+ _file.flush()