gdharness 1.3.5 → 1.3.6

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.
@@ -120,6 +120,7 @@ func _init_tools() -> void:
120
120
  "play_scene": [_play_tools, "play_scene"],
121
121
  "restart_editor": [_play_tools, "restart_editor"],
122
122
  "quit_editor": [_play_tools, "quit_editor"],
123
+ "editor_servers": [_play_tools, "editor_servers"],
123
124
  "stop_playing": [_play_tools, "stop_playing"],
124
125
  "playing_status": [_play_tools, "playing_status"],
125
126
  # The script profiler on a game the editor plays
@@ -231,6 +231,54 @@ func quit_editor(_args: Dictionary) -> Dictionary:
231
231
  return {"ok": true, "quitting": true, "saved": true}
232
232
 
233
233
 
234
+ ## What the editor said as it started each of its servers, and the debug adapter started again when
235
+ ## [param args] names it under `rebind`.
236
+ ##
237
+ ## Godot binds each server once and says whether it could only in the editor's log panel, as a line
238
+ ## like `--- Failed to start Debug adapter server on port 6006: Already in use ---`, so those lines
239
+ ## are the one account of why a port it names has nothing on it. They are read before anything is
240
+ ## started again, so the answer carries the first attempt's reason as well as the next one's.
241
+ ##
242
+ ## The debug adapter binds as its node enters the tree, which is early in the editor's start and
243
+ ## seconds before the language server, and does not try again; taking its node out of the tree and
244
+ ## putting it back runs that start once more. The language server starts on the editor's first
245
+ ## frame after it is ready and only once, so re-entering the tree would stop it for good, and it is
246
+ ## never touched here.
247
+ func editor_servers(args: Dictionary) -> Dictionary:
248
+ var said: PackedStringArray = _servers_said()
249
+ var rebound: bool = false
250
+ if GdhRead.as_bool(args.get("rebind", false)):
251
+ var adapter: Node = _debug_adapter()
252
+ if adapter != null:
253
+ var parent: Node = adapter.get_parent()
254
+ var at: int = adapter.get_index()
255
+ parent.remove_child(adapter)
256
+ parent.add_child(adapter)
257
+ parent.move_child(adapter, at)
258
+ rebound = true
259
+ return {"ok": true, "said": said, "rebound": rebound, "saidSince": _servers_said().slice(said.size())}
260
+
261
+
262
+ ## The lines the editor's log panel holds about its language server and debug adapter, in order.
263
+ static func _servers_said() -> PackedStringArray:
264
+ var lines: PackedStringArray = []
265
+ var root: Node = EditorInterface.get_base_control().get_tree().root
266
+ for panel: Node in root.find_children("*", "EditorLog", true, false):
267
+ for shown: Node in panel.find_children("*", "RichTextLabel", true, false):
268
+ for line: String in (shown as RichTextLabel).get_parsed_text().split("\n", false):
269
+ var stripped: String = line.strip_edges()
270
+ if stripped.begins_with("---") and stripped.contains(" server"):
271
+ var _kept: bool = lines.append(stripped)
272
+ return lines
273
+
274
+
275
+ ## The editor's debug adapter node, found by its engine class, or null in an editor without one.
276
+ static func _debug_adapter() -> Node:
277
+ var root: Node = EditorInterface.get_base_control().get_tree().root
278
+ var found: Array[Node] = root.find_children("*", "DebugAdapterServer", true, false)
279
+ return found[0] if not found.is_empty() else null
280
+
281
+
234
282
  ## Asked of the tree the editor's own window is in, because this object is not in one.
235
283
  static func _leave() -> void:
236
284
  var base: Control = EditorInterface.get_base_control()
@@ -0,0 +1,241 @@
1
+ extends SceneTree
2
+
3
+ ## gdUnit4's command-line runner over the suites a file lists, in the file's order. One `-a` a suite
4
+ ## outgrows the command line once a game has a few hundred suites (Windows stops at 32767
5
+ ## characters), so the suites come from the file named after `--` with [constant LISTED], and the
6
+ ## cases to leave out from the one named with [constant SKIPPING]. Every other argument is gdUnit4's
7
+ ## own, as `GdUnitCmdTool.gd` takes them.
8
+ ##
9
+ ## Started from outside the project, `-s` with this file's absolute path, so it declares no class of
10
+ ## its own and reaches gdUnit4 through the project's global classes. Besides the file of suites it
11
+ ## finds each case by its id in a dictionary ([GdhIndexedSession]), can compile the suites together
12
+ ## before gdUnit4 loads them ([constant TOGETHER]), can turn off the reports the caller does
13
+ ## not read ([constant REPORTS_OFF]), and writes the errors each suite declares it expects
14
+ ## ([constant EXPECTED_OUT]).
15
+
16
+ const LISTED: String = "--suites-in"
17
+ const SKIPPING: String = "--skipping-in"
18
+ ## The flags turning off a report, with the gdUnit4 hook that writes it. A gate reads what a run
19
+ ## prints, and each report finds every case it hears of in the session to write them all out as the
20
+ ## run ends; project_test reads the JUnit one and turns off only the HTML one.
21
+ const REPORTS_OFF: Dictionary[String, String] = {
22
+ "--html-report-off": "res://addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd",
23
+ "--junit-report-off": "res://addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd",
24
+ }
25
+ ## Compiles the suites together beside every class of the game ([method compiled_together]) before
26
+ ## gdUnit4 loads them. A gate asks for it, since it runs every suite and wants every script that
27
+ ## does not compile named. project_test does not: its answer is about the scripts its run reached,
28
+ ## and the compile names every broken script of the game, then once more as the one script it made.
29
+ const TOGETHER: String = "--compile-together"
30
+ ## The file to write the errors every suite declares it expects into, as JSON keyed by suite path and
31
+ ## then case name.
32
+ const EXPECTED_OUT: String = "--expected-errors-out"
33
+ ## The constant a suite declares its expected errors in: case name to the exact texts it prints.
34
+ const EXPECTED: String = "GDHARNESS_EXPECTED_ERRORS"
35
+ ## The tool name gdUnit4 reads its options after.
36
+ const TOOL: String = "GdUnitCmdTool.gd"
37
+ ## How long a windowless engine sleeps after each frame. The engine's own default is the
38
+ ## low-processor pause, 6.8 ms measured, which a run of suites awaiting frames spends thousands of
39
+ ## times; zero makes a frame's delta round to nothing, and a tween then never moves.
40
+ const FRAME_SLEEP_USEC: int = 1000
41
+
42
+ var _runner: GdhRunner = null
43
+ ## The suites of the run, compiled by [method compiled_together] and held for as long as it runs.
44
+ var _together: GDScript = null
45
+
46
+
47
+ ## gdUnit4's runner reading its options after this script's path rather than after its own tool's,
48
+ ## which it looks for by name and otherwise finds none of them, printing its help.
49
+ class GdhRunner:
50
+ extends GdUnitTestCIRunner
51
+
52
+ var own_path: String = ""
53
+
54
+ func get_cmdline_args() -> PackedStringArray:
55
+ return options_of(OS.get_cmdline_args(), own_path)
56
+
57
+ ## The session gdUnit4 has just made, swapped for a [GdhIndexedSession] of the same cases as it
58
+ ## is announced: everything that reads the session, the hooks, the cases and the console printing
59
+ ## every case, is handed it after this.
60
+ func _on_gdunit_event(event: GdUnitEvent) -> void:
61
+ if event.type() == GdUnitEvent.SESSION_START:
62
+ _test_session = GdhIndexedSession.of(_test_session)
63
+ super(event)
64
+
65
+ ## [param args] with everything up to the script at [param own] given as gdUnit4's tool name. The
66
+ ## path is compared with its separators and, on Windows, its case folded, since the engine
67
+ ## reports the script as `X:/a/listed.gd` when it was started as `X:\a\listed.gd`.
68
+ static func options_of(args: PackedStringArray, own: String) -> PackedStringArray:
69
+ var wanted: String = spelt(own)
70
+ var at: int = -1
71
+ for index: int in args.size():
72
+ if spelt(args[index]) == wanted:
73
+ at = index
74
+ break
75
+ if at < 0:
76
+ return args
77
+ var options: PackedStringArray = [TOOL]
78
+ options.append_array(args.slice(at + 1))
79
+ return options
80
+
81
+ static func spelt(path: String) -> String:
82
+ var forward: String = path.replace("\\", "/")
83
+ return forward.to_lower() if OS.get_name() == "Windows" else forward
84
+
85
+
86
+ ## gdUnit4's session finding a case by its id in a dictionary, where its own walks every case of the
87
+ ## session, `GdUnitTestSession.find_test_by_id`: the console asks it twice a case and each report
88
+ ## twice more, so a run's time in it grew with the square of its cases (17.9 s of processor time
89
+ ## in an engine of 2300 cases).
90
+ class GdhIndexedSession:
91
+ extends GdUnitTestSession
92
+
93
+ var _by_id: Dictionary[String, GdUnitTestCase] = {}
94
+
95
+ func _init(cases: Array[GdUnitTestCase], reports_in: String) -> void:
96
+ super(cases, reports_in)
97
+ for test: GdUnitTestCase in _test_cases:
98
+ _by_id[str(test.guid)] = test
99
+
100
+ func find_test_by_id(id: GdUnitGUID) -> GdUnitTestCase:
101
+ var found: GdUnitTestCase = _by_id.get(str(id), null)
102
+ return found
103
+
104
+ ## [param made] as an indexed session of its cases, reporting where it does; one already indexed
105
+ ## as it is.
106
+ static func of(made: GdUnitTestSession) -> GdUnitTestSession:
107
+ if made is GdhIndexedSession:
108
+ return made
109
+ return GdhIndexedSession.new(made._test_cases, made.report_path)
110
+
111
+
112
+ func _initialize() -> void:
113
+ OS.low_processor_usage_mode_sleep_usec = FRAME_SLEEP_USEC
114
+ var asked: PackedStringArray = OS.get_cmdline_user_args()
115
+ var unread: PackedStringArray = []
116
+ for flag: String in REPORTS_OFF:
117
+ if asked.has(flag):
118
+ var _unread: bool = unread.append(REPORTS_OFF[flag])
119
+ if not unread.is_empty():
120
+ var _off: PackedStringArray = unread_off(GdUnitTestSessionHookService.instance(), unread)
121
+ var suites: PackedStringArray = listed(asked, LISTED)
122
+ if asked.has(TOGETHER):
123
+ _together = compiled_together(suites)
124
+ var expected_out: String = argument_after(asked, EXPECTED_OUT)
125
+ if not expected_out.is_empty():
126
+ write_expected(expected_out, suites)
127
+ _runner = GdhRunner.new()
128
+ _runner.own_path = get_script().resource_path
129
+ _runner.add_test_suites(suites)
130
+ _runner.skip_test_suites(listed(asked, SKIPPING))
131
+ root.add_child(_runner)
132
+
133
+
134
+ ## As gdUnit4's own `GdUnitCmdTool.gd` ends, which names in a verbose run the nodes left orphaned.
135
+ func _finalize() -> void:
136
+ queue_delete(_runner)
137
+ if OS.is_stdout_verbose():
138
+ print("Orphan nodes left as the run ends:")
139
+ Window.print_orphan_nodes()
140
+
141
+
142
+ ## Turns off the hooks among [param hooks] whose scripts [param unread] names, answering the scripts
143
+ ## of those it turned off. Through the mark gdUnit4's own `is_enabled` reads, rather than its
144
+ ## `enable_hook`, which writes the setting into `project.godot`: a run changes nothing in the tree.
145
+ static func unread_off(hooks: GdUnitTestSessionHookService, unread: PackedStringArray) -> PackedStringArray:
146
+ var off: PackedStringArray = []
147
+ for hook: GdUnitTestSessionHook in hooks.enigne_hooks:
148
+ var path: String = hook.get_script().resource_path
149
+ if unread.has(path):
150
+ hook.set_meta("enabled", false)
151
+ var _added: bool = off.append(path)
152
+ return off
153
+
154
+
155
+ ## [param suites] compiled inside one script, answered for the caller to hold so gdUnit4's own loads
156
+ ## find them compiled. The engine keeps what it read of a class only while the compile that asked for
157
+ ## it runs (`GDScriptCache`), so a suite compiled on its own reads again every class it reaches a
158
+ ## member of, some seventy milliseconds a suite. As the preloads of one script naming every class of
159
+ ## the game's own first, held from that script's compile, they share one reading of each: two hundred
160
+ ## suites compiled in a third of the time they took one at a time. The names are public, since a
161
+ ## private one unread is a warning and every warning an error, and each a variable, since a class is
162
+ ## no constant expression. A suite that does not compile fails here and again when gdUnit4 loads it,
163
+ ## which reports it as it would have. A directory listed is left to gdUnit4 to walk.
164
+ static func compiled_together(suites: PackedStringArray) -> GDScript:
165
+ var lines: PackedStringArray = ["extends RefCounted"]
166
+ var at: int = 0
167
+ for named: String in held_classes():
168
+ var _held: bool = lines.append("var held_%d: Variant = %s" % [at, named])
169
+ at += 1
170
+ at = 0
171
+ for path: String in suites:
172
+ if not path.ends_with(".gd"):
173
+ continue
174
+ var _suite: bool = lines.append('var suite_%d: Variant = preload("%s")' % [at, path])
175
+ at += 1
176
+ var together: GDScript = GDScript.new()
177
+ together.source_code = "\n".join(lines) + "\n"
178
+ var _compiled: Error = together.reload()
179
+ return together
180
+
181
+
182
+ ## The classes [method compiled_together] names before the suites: the game's own GDScript classes,
183
+ ## none of an addon's or a suite's, then gdUnit4's suite. Only GDScript shares the engine's reading,
184
+ ## and a sandboxed mod's SafeGDScript class would load the sandbox's compiler, some 290 MB and up to
185
+ ## 2.5 s, for nothing.
186
+ static func held_classes(
187
+ classes: Array[Dictionary] = ProjectSettings.get_global_class_list()
188
+ ) -> PackedStringArray:
189
+ var held: PackedStringArray = []
190
+ for known: Dictionary in classes:
191
+ var path: String = known["path"]
192
+ var own: bool = not path.begins_with("res://addons/") and not path.ends_with("_test.gd")
193
+ if own and known["language"] == &"GDScript":
194
+ var _own: bool = held.append(str(known["class"]))
195
+ var _suite: bool = held.append("GdUnitTestSuite")
196
+ return held
197
+
198
+
199
+ ## What the file after [param flag] in [param args] names, one a line in order, or none when no file
200
+ ## is named: gdUnit4 then says it found nothing, which a gate refuses.
201
+ static func listed(args: PackedStringArray, flag: String) -> PackedStringArray:
202
+ var path: String = argument_after(args, flag)
203
+ if path.is_empty():
204
+ return PackedStringArray()
205
+ var named: PackedStringArray = []
206
+ for line: String in FileAccess.get_file_as_string(path).split("\n", false):
207
+ if not line.strip_edges().is_empty():
208
+ var _named: bool = named.append(line.strip_edges())
209
+ return named
210
+
211
+
212
+ ## The argument after [param flag] in [param args], or "" when there is none.
213
+ static func argument_after(args: PackedStringArray, flag: String) -> String:
214
+ var at: int = args.find(flag)
215
+ if at < 0 or at + 1 >= args.size():
216
+ return ""
217
+ return args[at + 1]
218
+
219
+
220
+ ## The errors each of [param suites] declares it expects, by suite path and then case name, read off
221
+ ## the compiled suite's constants so a declaration is whatever the engine made of it. A suite
222
+ ## declaring none is left out.
223
+ static func expected_by_suite(suites: PackedStringArray) -> Dictionary:
224
+ var expected: Dictionary = {}
225
+ for path: String in suites:
226
+ if not path.ends_with(".gd") or not ResourceLoader.exists(path):
227
+ continue
228
+ var suite: Script = load(path)
229
+ var declared: Variant = suite.get_script_constant_map().get(EXPECTED)
230
+ if declared is Dictionary:
231
+ expected[path] = declared
232
+ return expected
233
+
234
+
235
+ static func write_expected(path: String, suites: PackedStringArray) -> void:
236
+ var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
237
+ if file == null:
238
+ push_error("Could not write the expected errors to %s: %s" % [path, FileAccess.get_open_error()])
239
+ return
240
+ var _stored: bool = file.store_string(JSON.stringify(expected_by_suite(suites)))
241
+ file.close()
package/build/keeper.js CHANGED
@@ -106,6 +106,9 @@ public static class GdharnessDesktop {
106
106
  [DllImport("kernel32.dll")] static extern bool GetExitCodeProcess(IntPtr handle, out uint code);
107
107
  [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
108
108
  [DllImport("kernel32.dll")] static extern bool TerminateProcess(IntPtr handle, uint code);
109
+ [DllImport("kernel32.dll")] static extern uint SetErrorMode(uint mode);
110
+ const uint FailCriticalErrors = 0x1;
111
+ const uint NoOpenFileErrorBox = 0x8000;
109
112
 
110
113
  delegate bool EachWindow(IntPtr window, IntPtr unused);
111
114
  [DllImport("user32.dll")] static extern bool EnumWindows(EachWindow each, IntPtr unused);
@@ -179,6 +182,11 @@ public static class GdharnessDesktop {
179
182
  startup.output = GetStdHandle(-11);
180
183
  startup.error = startup.output;
181
184
  Started started;
185
+ // Inherited by what it starts: without SEM_NOGPFAULTERRORBOX, Windows Error Reporting runs when
186
+ // the target crashes and writes its dump, and with the other two no hard-error box can open.
187
+ // Set outright rather than cleared from this process's own: Windows PowerShell changes the mode
188
+ // it inherited as it starts, and was seen reading 0x1 here, without the open-file bit.
189
+ SetErrorMode(FailCriticalErrors | NoOpenFileErrorBox);
182
190
  if (noActivate) {
183
191
  LockSetForegroundWindow(LockForeground);
184
192
  }
@@ -360,6 +368,33 @@ function throughHelper(how, command, args, files, assembly = helperAssembly()) {
360
368
  };
361
369
  }
362
370
 
371
+ // src/error-mode.ts
372
+ import process3 from "node:process";
373
+ var NO_FAULT_ERROR_BOX = 2;
374
+ var cleared = process3.platform !== "win32";
375
+ async function letWindowsReportCrashes() {
376
+ if (cleared || !("bun" in process3.versions)) {
377
+ return;
378
+ }
379
+ try {
380
+ const { dlopen, FFIType } = await import("bun:ffi");
381
+ const kernel32 = dlopen("kernel32.dll", {
382
+ GetErrorMode: { args: [], returns: FFIType.u32 },
383
+ SetErrorMode: { args: [FFIType.u32], returns: FFIType.u32 }
384
+ });
385
+ try {
386
+ const mode = kernel32.symbols.GetErrorMode();
387
+ kernel32.symbols.SetErrorMode(mode & ~NO_FAULT_ERROR_BOX);
388
+ cleared = true;
389
+ } finally {
390
+ kernel32.close();
391
+ }
392
+ } catch {}
393
+ }
394
+ function crashesReported() {
395
+ return cleared;
396
+ }
397
+
363
398
  // src/game-debugger.ts
364
399
  import { createServer } from "node:net";
365
400
 
@@ -1142,7 +1177,7 @@ import { mkdtempSync as mkdtempSync2, rmSync as rmSync2 } from "node:fs";
1142
1177
  import { connect, createServer as createServer2 } from "node:net";
1143
1178
  import { tmpdir as tmpdir2 } from "node:os";
1144
1179
  import { join as join2 } from "node:path";
1145
- import process3 from "node:process";
1180
+ import process4 from "node:process";
1146
1181
  var DEBUG_PREFIX = "debug ";
1147
1182
  function endedOrWhyNot(end) {
1148
1183
  try {
@@ -1169,7 +1204,7 @@ function errorText(error) {
1169
1204
  async function listenForTheServer(end, debug) {
1170
1205
  let directory = null;
1171
1206
  try {
1172
- directory = process3.platform === "win32" ? null : mkdtempSync2(join2(tmpdir2(), "gdharness-keeper-"));
1207
+ directory = process4.platform === "win32" ? null : mkdtempSync2(join2(tmpdir2(), "gdharness-keeper-"));
1173
1208
  const address = directory === null ? `\\\\.\\pipe\\gdharness-keeper-${randomUUID()}` : join2(directory, "keeper.sock");
1174
1209
  const server = createServer2((socket) => {
1175
1210
  socket.setEncoding("utf8");
@@ -1469,13 +1504,13 @@ function recordAt(path) {
1469
1504
  import { existsSync as existsSync2, mkdtempSync as mkdtempSync3, readdirSync as readdirSync2, rmSync as rmSync5 } from "node:fs";
1470
1505
  import { tmpdir as tmpdir4 } from "node:os";
1471
1506
  import { join as join6 } from "node:path";
1472
- import process4 from "node:process";
1507
+ import process5 from "node:process";
1473
1508
  var SWEPT = { recursive: true, force: true };
1474
1509
  var AGAIN_AFTER_MS = [250, 500, 1000, 2000, 4000, 8000, 16000];
1475
1510
  var waiting = new Set;
1476
1511
  var atExit = new Set;
1477
1512
  function scratchDirectory(kind) {
1478
- return mkdtempSync3(join6(tmpdir4(), `gdharness-${kind}-${process4.pid}-`));
1513
+ return mkdtempSync3(join6(tmpdir4(), `gdharness-${kind}-${process5.pid}-`));
1479
1514
  }
1480
1515
  function discardWith(remove, paths) {
1481
1516
  const left = [];
@@ -1533,7 +1568,7 @@ function lastTryOnExit() {
1533
1568
  return;
1534
1569
  }
1535
1570
  lastTryWatched = true;
1536
- process4.once("exit", () => {
1571
+ process5.once("exit", () => {
1537
1572
  for (const path of [...waiting, ...atExit]) {
1538
1573
  try {
1539
1574
  remove(path);
@@ -1937,7 +1972,7 @@ async function startThroughHelper(spec, output, stopFile) {
1937
1972
  const scratch = scratchDirectory("desktop");
1938
1973
  const pidFile = join7(scratch, "pid");
1939
1974
  const errorFile = join7(scratch, "error");
1940
- const where = spec.desktop === undefined ? "without activation" : `on the ${spec.desktop} desktop`;
1975
+ const where = spec.desktop !== undefined ? `on the ${spec.desktop} desktop` : spec.noActivate === true ? "without activation" : "through the desktop helper";
1941
1976
  try {
1942
1977
  const compiled = await ensureHelperCompiled();
1943
1978
  if (compiled !== null) {
@@ -2020,10 +2055,10 @@ async function startDetached(spec, output, hidden, stopFile = "") {
2020
2055
  return throughTheHelper(spec) ? await startThroughHelper(spec, output, stopFile) : await startDirectly(spec, output, hidden);
2021
2056
  }
2022
2057
  function throughTheHelper(spec) {
2023
- return process.platform === "win32" && (spec.desktop !== undefined || spec.noActivate === true);
2058
+ return needsItsHelperKept(spec) || process.platform === "win32" && !crashesReported();
2024
2059
  }
2025
2060
  function needsItsHelperKept(spec) {
2026
- return throughTheHelper(spec);
2061
+ return process.platform === "win32" && (spec.desktop !== undefined || spec.noActivate === true);
2027
2062
  }
2028
2063
  async function launch() {
2029
2064
  const spec = await receivedSpec();
@@ -2158,6 +2193,7 @@ async function keep() {
2158
2193
  }
2159
2194
  process.exit(0);
2160
2195
  }
2196
+ await letWindowsReportCrashes();
2161
2197
  var mode = process.argv[2];
2162
2198
  if (mode === "launch") {
2163
2199
  await launch();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.3.5",
3
+ "version": "1.3.6",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",