gdharness 1.3.4 → 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.
@@ -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
@@ -3,9 +3,33 @@
3
3
  // src/keeper.ts
4
4
  import { closeSync as closeSync2, existsSync as existsSync3, mkdtempSync as mkdtempSync4, openSync as openSync3, readFileSync as readFileSync3, rmSync as rmSync6, writeFileSync as writeFileSync3 } from "node:fs";
5
5
  import { tmpdir as tmpdir5 } from "node:os";
6
- import { join as join6 } from "node:path";
6
+ import { join as join7 } from "node:path";
7
7
  import { setTimeout as delay2 } from "node:timers/promises";
8
8
 
9
+ // src/alive.ts
10
+ import process2 from "node:process";
11
+ function exitOf(child) {
12
+ if (child.exitCode !== null || child.signalCode !== null) {
13
+ return Promise.resolve({ code: child.exitCode, signal: child.signalCode });
14
+ }
15
+ return new Promise((resolve) => {
16
+ child.once("exit", (code, signal) => {
17
+ resolve({ code, signal });
18
+ });
19
+ });
20
+ }
21
+ function alive(pid) {
22
+ if (pid === undefined || pid === null) {
23
+ return false;
24
+ }
25
+ try {
26
+ process2.kill(pid, 0);
27
+ return true;
28
+ } catch (error) {
29
+ return error.code === "EPERM";
30
+ }
31
+ }
32
+
9
33
  // src/desktop.ts
10
34
  import { createHash } from "node:crypto";
11
35
  import {
@@ -23,20 +47,6 @@ import { tmpdir } from "node:os";
23
47
  import { join } from "node:path";
24
48
  import { setTimeout as delay } from "node:timers/promises";
25
49
 
26
- // src/alive.ts
27
- import process2 from "node:process";
28
- function alive(pid) {
29
- if (pid === undefined || pid === null) {
30
- return false;
31
- }
32
- try {
33
- process2.kill(pid, 0);
34
- return true;
35
- } catch (error) {
36
- return error.code === "EPERM";
37
- }
38
- }
39
-
40
50
  // src/spawn.ts
41
51
  import {
42
52
  execFile,
@@ -96,6 +106,9 @@ public static class GdharnessDesktop {
96
106
  [DllImport("kernel32.dll")] static extern bool GetExitCodeProcess(IntPtr handle, out uint code);
97
107
  [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
98
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;
99
112
 
100
113
  delegate bool EachWindow(IntPtr window, IntPtr unused);
101
114
  [DllImport("user32.dll")] static extern bool EnumWindows(EachWindow each, IntPtr unused);
@@ -169,6 +182,11 @@ public static class GdharnessDesktop {
169
182
  startup.output = GetStdHandle(-11);
170
183
  startup.error = startup.output;
171
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);
172
190
  if (noActivate) {
173
191
  LockSetForegroundWindow(LockForeground);
174
192
  }
@@ -350,6 +368,33 @@ function throughHelper(how, command, args, files, assembly = helperAssembly()) {
350
368
  };
351
369
  }
352
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
+
353
398
  // src/game-debugger.ts
354
399
  import { createServer } from "node:net";
355
400
 
@@ -1031,7 +1076,7 @@ class GameDebugger {
1031
1076
  }
1032
1077
  if (!held.hasStack) {
1033
1078
  this.forget("debug_enter");
1034
- this.send("next", [], held.thread);
1079
+ this.send("step", [], held.thread);
1035
1080
  const again = await this.next(GameDebugger.named("debug_enter"), STEP_MS);
1036
1081
  if (again === null) {
1037
1082
  return {
@@ -1132,7 +1177,7 @@ import { mkdtempSync as mkdtempSync2, rmSync as rmSync2 } from "node:fs";
1132
1177
  import { connect, createServer as createServer2 } from "node:net";
1133
1178
  import { tmpdir as tmpdir2 } from "node:os";
1134
1179
  import { join as join2 } from "node:path";
1135
- import process3 from "node:process";
1180
+ import process4 from "node:process";
1136
1181
  var DEBUG_PREFIX = "debug ";
1137
1182
  function endedOrWhyNot(end) {
1138
1183
  try {
@@ -1159,7 +1204,7 @@ function errorText(error) {
1159
1204
  async function listenForTheServer(end, debug) {
1160
1205
  let directory = null;
1161
1206
  try {
1162
- directory = process3.platform === "win32" ? null : mkdtempSync2(join2(tmpdir2(), "gdharness-keeper-"));
1207
+ directory = process4.platform === "win32" ? null : mkdtempSync2(join2(tmpdir2(), "gdharness-keeper-"));
1163
1208
  const address = directory === null ? `\\\\.\\pipe\\gdharness-keeper-${randomUUID()}` : join2(directory, "keeper.sock");
1164
1209
  const server = createServer2((socket) => {
1165
1210
  socket.setEncoding("utf8");
@@ -1253,8 +1298,28 @@ function readLaunched(printed) {
1253
1298
 
1254
1299
  // src/run-record.ts
1255
1300
  import { createHash as createHash2 } from "node:crypto";
1256
- import { mkdirSync, openSync as openSync2, readdirSync, readFileSync as readFileSync2, realpathSync, rmSync as rmSync4, statSync as statSync2 } from "node:fs";
1257
- import { basename, dirname as dirname2, join as join4, resolve as resolve2 } from "node:path";
1301
+ import { mkdirSync, openSync as openSync2, readdirSync, readFileSync as readFileSync2, rmSync as rmSync4, statSync as statSync3 } from "node:fs";
1302
+ import { basename as basename2, join as join5, resolve as resolve3 } from "node:path";
1303
+
1304
+ // src/paths.ts
1305
+ import { realpathSync, statSync as statSync2 } from "node:fs";
1306
+ import { basename, dirname, isAbsolute, join as join3, relative, resolve, sep } from "node:path";
1307
+ function realAsFarAsItExists(path) {
1308
+ const missing = [];
1309
+ let at = path;
1310
+ for (;; ) {
1311
+ try {
1312
+ return join3(realpathSync.native(at), ...missing);
1313
+ } catch {
1314
+ const parent = dirname(at);
1315
+ if (parent === at) {
1316
+ return path;
1317
+ }
1318
+ missing.unshift(basename(at));
1319
+ at = parent;
1320
+ }
1321
+ }
1322
+ }
1258
1323
 
1259
1324
  // src/windows-ask.ts
1260
1325
  var SCRIPT = [
@@ -1278,7 +1343,7 @@ var SCRIPT = [
1278
1343
 
1279
1344
  // src/runtime-client.ts
1280
1345
  import { tmpdir as tmpdir3 } from "node:os";
1281
- import { dirname, join as join3, resolve } from "node:path";
1346
+ import { dirname as dirname2, join as join4, resolve as resolve2 } from "node:path";
1282
1347
 
1283
1348
  // src/launch.ts
1284
1349
  function envValue(name, variables = process.env) {
@@ -1323,7 +1388,7 @@ function runtimeDirectory(variables = process.env) {
1323
1388
  return explicit;
1324
1389
  }
1325
1390
  const perUser = envValue("XDG_RUNTIME_DIR", variables);
1326
- return join3(perUser ?? tmpdir3(), "gdharness");
1391
+ return join4(perUser ?? tmpdir3(), "gdharness");
1327
1392
  }
1328
1393
  var ERROR_REPORT_KEEP_MS = 60 * 60 * 1000;
1329
1394
  var verdicts = new Map;
@@ -1363,31 +1428,15 @@ function writeWhole(path, text) {
1363
1428
 
1364
1429
  // src/run-record.ts
1365
1430
  function runsDirectory() {
1366
- return join4(runtimeDirectory(), "runs");
1367
- }
1368
- function realAsFarAsItExists(path) {
1369
- const missing = [];
1370
- let at = path;
1371
- for (;; ) {
1372
- try {
1373
- return join4(realpathSync.native(at), ...missing);
1374
- } catch {
1375
- const parent = dirname2(at);
1376
- if (parent === at) {
1377
- return path;
1378
- }
1379
- missing.unshift(basename(at));
1380
- at = parent;
1381
- }
1382
- }
1431
+ return join5(runtimeDirectory(), "runs");
1383
1432
  }
1384
1433
  function projectKey(projectPath) {
1385
- const real = realAsFarAsItExists(resolve2(projectPath)).replace(/[\\/]+$/, "");
1434
+ const real = realAsFarAsItExists(resolve3(projectPath)).replace(/[\\/]+$/, "");
1386
1435
  const folded = process.platform === "win32" || process.platform === "darwin" ? real.toLowerCase() : real;
1387
1436
  return createHash2("sha256").update(folded.replaceAll("\\", "/")).digest("hex").slice(0, 16);
1388
1437
  }
1389
1438
  function runRecordPath(projectPath, directory = runtimeDirectory()) {
1390
- return join4(directory, "runs", `note-${projectKey(projectPath)}.json`);
1439
+ return join5(directory, "runs", `note-${projectKey(projectPath)}.json`);
1391
1440
  }
1392
1441
  function writeRunRecord(record) {
1393
1442
  mkdirSync(runsDirectory(), { recursive: true });
@@ -1454,14 +1503,14 @@ function recordAt(path) {
1454
1503
  // src/scratch.ts
1455
1504
  import { existsSync as existsSync2, mkdtempSync as mkdtempSync3, readdirSync as readdirSync2, rmSync as rmSync5 } from "node:fs";
1456
1505
  import { tmpdir as tmpdir4 } from "node:os";
1457
- import { join as join5 } from "node:path";
1458
- import process4 from "node:process";
1506
+ import { join as join6 } from "node:path";
1507
+ import process5 from "node:process";
1459
1508
  var SWEPT = { recursive: true, force: true };
1460
1509
  var AGAIN_AFTER_MS = [250, 500, 1000, 2000, 4000, 8000, 16000];
1461
1510
  var waiting = new Set;
1462
1511
  var atExit = new Set;
1463
1512
  function scratchDirectory(kind) {
1464
- return mkdtempSync3(join5(tmpdir4(), `gdharness-${kind}-${process4.pid}-`));
1513
+ return mkdtempSync3(join6(tmpdir4(), `gdharness-${kind}-${process5.pid}-`));
1465
1514
  }
1466
1515
  function discardWith(remove, paths) {
1467
1516
  const left = [];
@@ -1519,7 +1568,7 @@ function lastTryOnExit() {
1519
1568
  return;
1520
1569
  }
1521
1570
  lastTryWatched = true;
1522
- process4.once("exit", () => {
1571
+ process5.once("exit", () => {
1523
1572
  for (const path of [...waiting, ...atExit]) {
1524
1573
  try {
1525
1574
  remove(path);
@@ -1921,16 +1970,16 @@ async function startDirectly(spec, output, hidden, detached = true, errors = out
1921
1970
  }
1922
1971
  async function startThroughHelper(spec, output, stopFile) {
1923
1972
  const scratch = scratchDirectory("desktop");
1924
- const pidFile = join6(scratch, "pid");
1925
- const errorFile = join6(scratch, "error");
1926
- const where = spec.desktop === undefined ? "without activation" : `on the ${spec.desktop} desktop`;
1973
+ const pidFile = join7(scratch, "pid");
1974
+ const errorFile = join7(scratch, "error");
1975
+ const where = spec.desktop !== undefined ? `on the ${spec.desktop} desktop` : spec.noActivate === true ? "without activation" : "through the desktop helper";
1927
1976
  try {
1928
1977
  const compiled = await ensureHelperCompiled();
1929
1978
  if (compiled !== null) {
1930
1979
  return { error: `it could not be started ${where}: ${compiled}` };
1931
1980
  }
1932
1981
  const wrapper = throughHelper({ ...spec, directory: process.cwd() }, spec.command, spec.args, {
1933
- spec: join6(scratch, "spec.json"),
1982
+ spec: join7(scratch, "spec.json"),
1934
1983
  pid: pidFile,
1935
1984
  error: errorFile,
1936
1985
  stop: stopFile
@@ -1974,7 +2023,7 @@ function profiledBy(path, projectPath) {
1974
2023
  };
1975
2024
  let projectGodot = null;
1976
2025
  try {
1977
- projectGodot = readFileSync3(join6(projectPath, "project.godot"), "utf8");
2026
+ projectGodot = readFileSync3(join7(projectPath, "project.godot"), "utf8");
1978
2027
  } catch {}
1979
2028
  const observer = new ProfileObserver((totals) => {
1980
2029
  const first = latest === null;
@@ -2006,10 +2055,10 @@ async function startDetached(spec, output, hidden, stopFile = "") {
2006
2055
  return throughTheHelper(spec) ? await startThroughHelper(spec, output, stopFile) : await startDirectly(spec, output, hidden);
2007
2056
  }
2008
2057
  function throughTheHelper(spec) {
2009
- return process.platform === "win32" && (spec.desktop !== undefined || spec.noActivate === true);
2058
+ return needsItsHelperKept(spec) || process.platform === "win32" && !crashesReported();
2010
2059
  }
2011
2060
  function needsItsHelperKept(spec) {
2012
- return throughTheHelper(spec);
2061
+ return process.platform === "win32" && (spec.desktop !== undefined || spec.noActivate === true);
2013
2062
  }
2014
2063
  async function launch() {
2015
2064
  const spec = await receivedSpec();
@@ -2077,8 +2126,8 @@ async function keep() {
2077
2126
  const debuggerPeer = run === undefined || run.debugged === false ? null : await GameDebugger.open(run.breakpoints ?? [], profiled === null ? [] : [profiled.observer]);
2078
2127
  const toStart = debuggerPeer === null ? spec : { ...spec, args: withEngineArguments(spec.args, debuggerPeer.engineArguments) };
2079
2128
  const transcript = run === undefined ? "ignore" : openSync3(run.transcript, "a");
2080
- const own = run !== undefined && throughTheHelper(spec) ? mkdtempSync4(join6(tmpdir5(), "gdharness-stop-")) : null;
2081
- const stopFile = own === null ? "" : join6(own, "stop");
2129
+ const own = run !== undefined && throughTheHelper(spec) ? mkdtempSync4(join7(tmpdir5(), "gdharness-stop-")) : null;
2130
+ const stopFile = own === null ? "" : join7(own, "stop");
2082
2131
  let started;
2083
2132
  try {
2084
2133
  started = await startDetached(toStart, transcript, run === undefined, stopFile);
@@ -2108,26 +2157,8 @@ async function keep() {
2108
2157
  if (record !== null) {
2109
2158
  writeNoteIfItCan(record);
2110
2159
  }
2160
+ const exited = exitOf(child);
2111
2161
  let listening = null;
2112
- child.once("exit", (code, signal) => {
2113
- const finish = () => {
2114
- profiled?.finish();
2115
- if (run !== undefined) {
2116
- recordRunEnded(run.projectPath, pid, { exitCode: code, exitSignal: code === null ? signal : null });
2117
- }
2118
- debuggerPeer?.close();
2119
- listening?.close();
2120
- if (own !== null) {
2121
- rmSync6(own, { recursive: true, force: true });
2122
- }
2123
- process.exit(0);
2124
- };
2125
- if (debuggerPeer === null) {
2126
- finish();
2127
- } else {
2128
- debuggerPeer.drained(DRAIN_MS).then(finish);
2129
- }
2130
- });
2131
2162
  if (record !== null) {
2132
2163
  listening = await listenForTheServer(() => {
2133
2164
  if (stopFile !== "") {
@@ -2140,10 +2171,29 @@ async function keep() {
2140
2171
  writeNoteIfItCan({ ...record, keeper: listening.address });
2141
2172
  }
2142
2173
  }
2143
- process.stdout.write(`pid ${pid}
2144
- `);
2145
- process.stdout.end();
2174
+ await new Promise((resolve) => {
2175
+ process.stdout.once("error", () => {
2176
+ resolve();
2177
+ });
2178
+ process.stdout.end(`pid ${pid}
2179
+ `, resolve);
2180
+ });
2181
+ const { code, signal } = await exited;
2182
+ if (debuggerPeer !== null) {
2183
+ await debuggerPeer.drained(DRAIN_MS);
2184
+ }
2185
+ profiled?.finish();
2186
+ if (run !== undefined) {
2187
+ recordRunEnded(run.projectPath, pid, { exitCode: code, exitSignal: code === null ? signal : null });
2188
+ }
2189
+ debuggerPeer?.close();
2190
+ listening?.close();
2191
+ if (own !== null) {
2192
+ rmSync6(own, { recursive: true, force: true });
2193
+ }
2194
+ process.exit(0);
2146
2195
  }
2196
+ await letWindowsReportCrashes();
2147
2197
  var mode = process.argv[2];
2148
2198
  if (mode === "launch") {
2149
2199
  await launch();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.3.4",
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",