gdharness 0.10.2 → 0.11.0

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.
package/build/index.js CHANGED
@@ -10126,9 +10126,21 @@ function defectReport(where, error, godotVersion) {
10126
10126
 
10127
10127
  // src/server.ts
10128
10128
  import { execFile as execFile3, spawn } from "node:child_process";
10129
- import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync as mkdtempSync2, readdirSync as readdirSync5, readFileSync as readFileSync9, realpathSync as realpathSync2, rmSync as rmSync4 } from "node:fs";
10129
+ import {
10130
+ closeSync,
10131
+ existsSync as existsSync7,
10132
+ fstatSync,
10133
+ mkdirSync as mkdirSync5,
10134
+ mkdtempSync as mkdtempSync2,
10135
+ openSync as openSync2,
10136
+ readdirSync as readdirSync6,
10137
+ readFileSync as readFileSync10,
10138
+ readSync,
10139
+ realpathSync as realpathSync2,
10140
+ rmSync as rmSync5
10141
+ } from "node:fs";
10130
10142
  import { tmpdir as tmpdir4 } from "node:os";
10131
- import { basename, dirname as dirname4, join as join9, normalize as normalize2 } from "node:path";
10143
+ import { basename, dirname as dirname4, join as join10, normalize as normalize2 } from "node:path";
10132
10144
  import { setTimeout as delay3 } from "node:timers/promises";
10133
10145
  import { fileURLToPath as fileURLToPath2 } from "node:url";
10134
10146
  import { promisify as promisify3 } from "node:util";
@@ -25425,11 +25437,16 @@ function announced(line) {
25425
25437
  }
25426
25438
  return { severity: match[2] === "WARNING" ? "warning" : "error", text: match[3] ?? "" };
25427
25439
  }
25440
+ var STREAMS = ["stdout", "stderr", "transcript"];
25428
25441
 
25429
25442
  class GameLog {
25430
25443
  entries = [];
25431
- decoders = { stdout: new StringDecoder("utf8"), stderr: new StringDecoder("utf8") };
25432
- partial = { stdout: "", stderr: "" };
25444
+ decoders = {
25445
+ stdout: new StringDecoder("utf8"),
25446
+ stderr: new StringDecoder("utf8"),
25447
+ transcript: new StringDecoder("utf8")
25448
+ };
25449
+ partial = { stdout: "", stderr: "", transcript: "" };
25433
25450
  lastHeadline = null;
25434
25451
  cursor = 0;
25435
25452
  append(source, chunk) {
@@ -25442,7 +25459,7 @@ class GameLog {
25442
25459
  }
25443
25460
  }
25444
25461
  finish() {
25445
- for (const source of ["stdout", "stderr"]) {
25462
+ for (const source of STREAMS) {
25446
25463
  const tail = this.partial[source] + this.decoders[source].end();
25447
25464
  this.partial[source] = "";
25448
25465
  if (tail !== "") {
@@ -26604,6 +26621,39 @@ function whyNoReport(printed, asked) {
26604
26621
  }
26605
26622
  return printed.some((line) => NOTHING_RAN.test(line)) ? `no test cases found at ${asked}` : null;
26606
26623
  }
26624
+ var RUNNING_SUITE = /Run Test Suite:\s*(\S+)/;
26625
+ var ORPHANS = /(\d+)\s+orphans/;
26626
+ var OVERALL = "Overall Summary";
26627
+ function orphansPrinted(printed) {
26628
+ const suites = [];
26629
+ let total = null;
26630
+ let running = null;
26631
+ for (const line of printed) {
26632
+ const started = RUNNING_SUITE.exec(line);
26633
+ if (started?.[1] !== undefined) {
26634
+ running = started[1];
26635
+ continue;
26636
+ }
26637
+ const counted = ORPHANS.exec(line);
26638
+ if (counted?.[1] === undefined) {
26639
+ continue;
26640
+ }
26641
+ const orphans = Number(counted[1]);
26642
+ if (line.includes(OVERALL)) {
26643
+ total = orphans;
26644
+ running = null;
26645
+ continue;
26646
+ }
26647
+ if (running !== null && orphans > 0) {
26648
+ suites.push({ path: running, orphans });
26649
+ }
26650
+ running = null;
26651
+ }
26652
+ return {
26653
+ total: total ?? suites.reduce((sum, suite) => sum + suite.orphans, 0),
26654
+ suites
26655
+ };
26656
+ }
26607
26657
 
26608
26658
  // src/lsp_client.ts
26609
26659
  import { realpathSync } from "node:fs";
@@ -26908,6 +26958,14 @@ class GodotLSPClient {
26908
26958
  }
26909
26959
  return uri;
26910
26960
  }
26961
+ closeDocument(uri) {
26962
+ if (!this.documentVersions.delete(uri)) {
26963
+ return;
26964
+ }
26965
+ try {
26966
+ this.sendNotification("textDocument/didClose", { textDocument: { uri } });
26967
+ } catch {}
26968
+ }
26911
26969
  async initialize(rootPath) {
26912
26970
  await this.ensureConnected();
26913
26971
  const resolvedRootPath = resolve2(rootPath);
@@ -26961,8 +27019,9 @@ class GodotLSPClient {
26961
27019
  timer
26962
27020
  });
26963
27021
  });
27022
+ let opened = null;
26964
27023
  try {
26965
- this.syncDocument(filePath, content);
27024
+ opened = this.syncDocument(filePath, content);
26966
27025
  return await diagnosticsPromise;
26967
27026
  } catch (error) {
26968
27027
  const waiter = this.diagnosticsWaiters.get(key);
@@ -26971,16 +27030,25 @@ class GodotLSPClient {
26971
27030
  this.diagnosticsWaiters.delete(key);
26972
27031
  }
26973
27032
  throw error;
27033
+ } finally {
27034
+ if (opened !== null) {
27035
+ this.closeDocument(opened);
27036
+ }
26974
27037
  }
26975
27038
  }
26976
27039
  async getCompletions(filePath, content, line, character) {
26977
27040
  await this.ensureConnected();
26978
27041
  await this.ensureInitializedForFile(filePath);
26979
27042
  const uri = this.syncDocument(filePath, content);
26980
- const result = await this.sendRequest("textDocument/completion", {
26981
- textDocument: { uri },
26982
- position: { line, character }
26983
- });
27043
+ let result;
27044
+ try {
27045
+ result = await this.sendRequest("textDocument/completion", {
27046
+ textDocument: { uri },
27047
+ position: { line, character }
27048
+ });
27049
+ } finally {
27050
+ this.closeDocument(uri);
27051
+ }
26984
27052
  if (Array.isArray(result)) {
26985
27053
  return result;
26986
27054
  }
@@ -26996,18 +27064,27 @@ class GodotLSPClient {
26996
27064
  await this.ensureConnected();
26997
27065
  await this.ensureInitializedForFile(filePath);
26998
27066
  const uri = this.syncDocument(filePath, content);
26999
- return this.sendRequest("textDocument/hover", {
27000
- textDocument: { uri },
27001
- position: { line, character }
27002
- });
27067
+ try {
27068
+ return await this.sendRequest("textDocument/hover", {
27069
+ textDocument: { uri },
27070
+ position: { line, character }
27071
+ });
27072
+ } finally {
27073
+ this.closeDocument(uri);
27074
+ }
27003
27075
  }
27004
27076
  async getDocumentSymbols(filePath, content) {
27005
27077
  await this.ensureConnected();
27006
27078
  await this.ensureInitializedForFile(filePath);
27007
27079
  const uri = this.syncDocument(filePath, content);
27008
- const result = await this.sendRequest("textDocument/documentSymbol", {
27009
- textDocument: { uri }
27010
- });
27080
+ let result;
27081
+ try {
27082
+ result = await this.sendRequest("textDocument/documentSymbol", {
27083
+ textDocument: { uri }
27084
+ });
27085
+ } finally {
27086
+ this.closeDocument(uri);
27087
+ }
27011
27088
  return Array.isArray(result) ? result : [];
27012
27089
  }
27013
27090
  isConnected() {
@@ -27415,6 +27492,10 @@ function setupResourceHandlers(mcp, getProjectPath) {
27415
27492
  });
27416
27493
  }
27417
27494
 
27495
+ // src/run-record.ts
27496
+ import { mkdirSync as mkdirSync3, openSync, readdirSync as readdirSync5, readFileSync as readFileSync8, rmSync as rmSync3, statSync as statSync2, writeFileSync as writeFileSync4 } from "node:fs";
27497
+ import { join as join8 } from "node:path";
27498
+
27418
27499
  // src/runtime-client.ts
27419
27500
  import { existsSync as existsSync5, readdirSync as readdirSync4, readFileSync as readFileSync7, unlinkSync } from "node:fs";
27420
27501
  import { createConnection as createConnection3 } from "node:net";
@@ -27710,9 +27791,86 @@ function runtimeRequest(endpoint, command, params, timeoutMs) {
27710
27791
  });
27711
27792
  }
27712
27793
 
27794
+ // src/run-record.ts
27795
+ function runsDirectory() {
27796
+ return join8(runtimeDirectory(), "runs");
27797
+ }
27798
+ function recordPath() {
27799
+ return join8(runsDirectory(), "run.json");
27800
+ }
27801
+ function openTranscript(startedAt) {
27802
+ mkdirSync3(runsDirectory(), { recursive: true });
27803
+ const path = join8(runsDirectory(), `run-${startedAt}.log`);
27804
+ return { path, fd: openSync(path, "a") };
27805
+ }
27806
+ function writeRunRecord(record) {
27807
+ mkdirSync3(runsDirectory(), { recursive: true });
27808
+ writeFileSync4(recordPath(), JSON.stringify(record, null, 2), "utf8");
27809
+ }
27810
+ function readRunRecord() {
27811
+ let parsed;
27812
+ try {
27813
+ parsed = JSON.parse(readFileSync8(recordPath(), "utf8"));
27814
+ } catch {
27815
+ return null;
27816
+ }
27817
+ if (!parsed || typeof parsed !== "object") {
27818
+ return null;
27819
+ }
27820
+ const fields = parsed;
27821
+ const pid = fields["pid"];
27822
+ const transcript = fields["transcript"];
27823
+ const startedAt = fields["startedAt"];
27824
+ if (typeof pid !== "number" || typeof transcript !== "string" || typeof startedAt !== "number") {
27825
+ return null;
27826
+ }
27827
+ const args = fields["arguments"];
27828
+ return {
27829
+ pid,
27830
+ transcript,
27831
+ startedAt,
27832
+ projectPath: typeof fields["projectPath"] === "string" ? fields["projectPath"] : "",
27833
+ arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : []
27834
+ };
27835
+ }
27836
+ function clearRunRecord() {
27837
+ try {
27838
+ rmSync3(recordPath());
27839
+ } catch {}
27840
+ }
27841
+ function ageMs(path, now) {
27842
+ try {
27843
+ return now - statSync2(path).mtimeMs;
27844
+ } catch {
27845
+ return 0;
27846
+ }
27847
+ }
27848
+ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
27849
+ const directory = runsDirectory();
27850
+ const keep = readRunRecord()?.transcript;
27851
+ let entries;
27852
+ try {
27853
+ entries = readdirSync5(directory);
27854
+ } catch {
27855
+ return;
27856
+ }
27857
+ for (const entry of entries) {
27858
+ if (!entry.startsWith("run-") || !entry.endsWith(".log")) {
27859
+ continue;
27860
+ }
27861
+ const path = join8(directory, entry);
27862
+ if (path === keep || ageMs(path, now) < keepMs) {
27863
+ continue;
27864
+ }
27865
+ try {
27866
+ rmSync3(path);
27867
+ } catch {}
27868
+ }
27869
+ }
27870
+
27713
27871
  // src/setup.ts
27714
- import { cpSync, existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync8, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "node:fs";
27715
- import { dirname as dirname3, join as join8 } from "node:path";
27872
+ import { cpSync, existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync9, rmSync as rmSync4, writeFileSync as writeFileSync5 } from "node:fs";
27873
+ import { dirname as dirname3, join as join9 } from "node:path";
27716
27874
  var ADDONS = ["gdharness_editor", "gdharness_runtime", "auto_reload"];
27717
27875
  var RUNTIME_AUTOLOAD = {
27718
27876
  name: "GdharnessRuntime",
@@ -27720,11 +27878,11 @@ var RUNTIME_AUTOLOAD = {
27720
27878
  };
27721
27879
  var VERSION_MARKER = ".gdharness-version";
27722
27880
  function installedAddonVersion(projectPath) {
27723
- const marker = join8(projectPath, "addons", ADDONS[0], VERSION_MARKER);
27881
+ const marker = join9(projectPath, "addons", ADDONS[0], VERSION_MARKER);
27724
27882
  if (!existsSync6(marker)) {
27725
27883
  return null;
27726
27884
  }
27727
- const said = readFileSync8(marker, "utf8").trim();
27885
+ const said = readFileSync9(marker, "utf8").trim();
27728
27886
  return said === "" ? null : said;
27729
27887
  }
27730
27888
 
@@ -28041,7 +28199,7 @@ var TOOL_SPECS = [
28041
28199
  },
28042
28200
  {
28043
28201
  name: "project_test",
28044
- description: "Runs the project's gdUnit4 tests headless and answers with every case that did not pass: where it is, and what the assertion said. Suites where everything passed are counted rather than listed, and an engine message keeps the frames above gdUnit4 rather than the twenty inside it, so a clean tier answers in a few lines. The class list is rebuilt first, so a suite written a moment ago is found. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. Needs gdUnit4 under addons/gdUnit4.",
28202
+ description: "Runs the project's gdUnit4 tests headless and answers with every case that did not pass: where it is, and what the assertion said. Suites where everything passed are counted rather than listed, and an engine message keeps the frames above gdUnit4 rather than the twenty inside it, so a clean tier answers in a few lines. The class list is rebuilt first, so a suite written a moment ago is found. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. A run where nothing failed but nodes were left in the tree comes back under warnings, with the count per suite: gdUnit4 decides its verdict on those and keeps them out of its report, so they are read off what it printed. Needs gdUnit4 under addons/gdUnit4.",
28045
28203
  parameters: {
28046
28204
  projectPath: PROJECT_PATH,
28047
28205
  path: {
@@ -28453,8 +28611,8 @@ var TOOL_SPECS = [
28453
28611
  },
28454
28612
  includeHidden: {
28455
28613
  type: "boolean",
28456
- ops: ["text"],
28457
- description: "text: read hidden nodes as well, for checking that something is not showing. Default false."
28614
+ ops: ["text", "find"],
28615
+ description: "text: read hidden nodes as well, for checking that something is not showing. Default false. find: default true, since a find means the node whether or not it is drawn; false answers with what the player can actually see, which is how a panel that keeps a label for every line and hides the ones that do not is read. A node counts as hidden when anything above it is, and how many matches were left out comes back under hidden."
28458
28616
  },
28459
28617
  metrics: {
28460
28618
  type: "array",
@@ -28471,7 +28629,7 @@ var TOOL_SPECS = [
28471
28629
  requires: []
28472
28630
  },
28473
28631
  find: {
28474
- summary: "the paths of every node matching className, script, namePattern, group or says, with property read off each",
28632
+ summary: "the paths of every node matching className, script, namePattern, group or says, with property read off each, hidden ones included unless includeHidden says otherwise",
28475
28633
  requires: []
28476
28634
  },
28477
28635
  rect: {
@@ -28779,15 +28937,24 @@ var SLOWEST_FRAME_RATE = 20;
28779
28937
  function patienceForFrames(frames, atLeast) {
28780
28938
  return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
28781
28939
  }
28940
+ function stillRunning(run) {
28941
+ if (run?.exitCode !== null || run.endedUnwatched === true) {
28942
+ return false;
28943
+ }
28944
+ if (!run.throughEditor && run.process === null && run.pid !== null) {
28945
+ return alive(run.pid);
28946
+ }
28947
+ return true;
28948
+ }
28782
28949
  function alive(pid) {
28783
- if (pid === undefined) {
28950
+ if (pid === undefined || pid === null) {
28784
28951
  return false;
28785
28952
  }
28786
28953
  try {
28787
28954
  process.kill(pid, 0);
28788
28955
  return true;
28789
- } catch {
28790
- return false;
28956
+ } catch (error) {
28957
+ return error.code === "EPERM";
28791
28958
  }
28792
28959
  }
28793
28960
  function isOfType(value, kind) {
@@ -28929,13 +29096,13 @@ function aboveTheRunner(entry) {
28929
29096
  }
28930
29097
  function elsewhereIn(projectPath, asked) {
28931
29098
  const wanted = asked.replace(/^res:\/\//, "").replace(/\/+$/, "");
28932
- if (wanted === "" || existsSync7(join9(projectPath, wanted))) {
29099
+ if (wanted === "" || existsSync7(join10(projectPath, wanted))) {
28933
29100
  return "";
28934
29101
  }
28935
29102
  const root = (wanted.split("/")[0] ?? "").toLowerCase();
28936
29103
  let near = [];
28937
29104
  try {
28938
- near = readdirSync5(projectPath, { withFileTypes: true }).filter((entry) => entry.isDirectory() && entry.name.toLowerCase() !== root).map((entry) => entry.name).filter((name) => name.toLowerCase().startsWith("test") || root.startsWith(name.toLowerCase()));
29105
+ near = readdirSync6(projectPath, { withFileTypes: true }).filter((entry) => entry.isDirectory() && entry.name.toLowerCase() !== root).map((entry) => entry.name).filter((name) => name.toLowerCase().startsWith("test") || root.startsWith(name.toLowerCase()));
28939
29106
  } catch {
28940
29107
  return "";
28941
29108
  }
@@ -28943,11 +29110,11 @@ function elsewhereIn(projectPath, asked) {
28943
29110
  return near.length === 0 ? "" : ` The project does have ${there}.`;
28944
29111
  }
28945
29112
  function hasMainScene(projectFile) {
28946
- const scene = parseProjectGodot(readFileSync9(projectFile, "utf8"))["application"]?.["run/main_scene"];
29113
+ const scene = parseProjectGodot(readFileSync10(projectFile, "utf8"))["application"]?.["run/main_scene"];
28947
29114
  return typeof scene === "string" && scene !== "";
28948
29115
  }
28949
29116
  function editorPlaysHeadless(projectFile) {
28950
- const runArgs = parseProjectGodot(readFileSync9(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
29117
+ const runArgs = parseProjectGodot(readFileSync10(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
28951
29118
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
28952
29119
  }
28953
29120
  function camelCased(params) {
@@ -28963,7 +29130,7 @@ function camelCased(params) {
28963
29130
  class GodotServer {
28964
29131
  mcp;
28965
29132
  locator = new GodotLocator;
28966
- operationsScript = join9(__dirname2, "godot", "operations", "godot_operations.gd");
29133
+ operationsScript = join10(__dirname2, "godot", "operations", "godot_operations.gd");
28967
29134
  godotBridge;
28968
29135
  tools = buildToolDefinitions();
28969
29136
  activeProcess = null;
@@ -29120,10 +29287,7 @@ class GodotServer {
29120
29287
  this.stopWatchingForASuccessor();
29121
29288
  withdrawBridge(this.announcedAt);
29122
29289
  this.announcedAt = null;
29123
- if (this.activeProcess) {
29124
- this.activeProcess.process?.kill();
29125
- this.activeProcess = null;
29126
- }
29290
+ this.activeProcess = null;
29127
29291
  if (this.lspClient) {
29128
29292
  try {
29129
29293
  await this.lspClient.disconnect();
@@ -29188,14 +29352,7 @@ class GodotServer {
29188
29352
  return;
29189
29353
  }
29190
29354
  this.shutdownInitiated = true;
29191
- if (this.activeProcess) {
29192
- try {
29193
- this.activeProcess.process?.kill();
29194
- } catch (error) {
29195
- console.error("[SERVER] Failed to kill the Godot process on exit:", errorMessage(error));
29196
- }
29197
- this.activeProcess = null;
29198
- }
29355
+ this.activeProcess = null;
29199
29356
  }
29200
29357
  setupToolHandlers() {
29201
29358
  this.mcp.server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: this.tools }));
@@ -29555,7 +29712,7 @@ class GodotServer {
29555
29712
  if (path === undefined) {
29556
29713
  return { ok: false, response: this.createErrorResponse("projectPath is required.") };
29557
29714
  }
29558
- const file = join9(path, "project.godot");
29715
+ const file = join10(path, "project.godot");
29559
29716
  if (!existsSync7(file)) {
29560
29717
  return {
29561
29718
  ok: false,
@@ -29725,7 +29882,7 @@ class GodotServer {
29725
29882
  if (!engine.ok) {
29726
29883
  return engine.response;
29727
29884
  }
29728
- const application = parseProjectGodot(readFileSync9(project.value.file, "utf8"))["application"] ?? {};
29885
+ const application = parseProjectGodot(readFileSync10(project.value.file, "utf8"))["application"] ?? {};
29729
29886
  const name = application["config/name"];
29730
29887
  const mainScene = application["run/main_scene"];
29731
29888
  const info = {
@@ -29788,7 +29945,7 @@ class GodotServer {
29788
29945
  }
29789
29946
  const folder = dirname4(output.absolutePath);
29790
29947
  try {
29791
- mkdirSync4(folder, { recursive: true });
29948
+ mkdirSync5(folder, { recursive: true });
29792
29949
  } catch (error) {
29793
29950
  return this.createErrorResponse(`Cannot create ${dirname4(output.relativePath)} to export into: ${errorMessage(error)}`);
29794
29951
  }
@@ -29850,7 +30007,7 @@ class GodotServer {
29850
30007
  return contained.response;
29851
30008
  }
29852
30009
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
29853
- if (!existsSync7(join9(project.value.path, runner))) {
30010
+ if (!existsSync7(join10(project.value.path, runner))) {
29854
30011
  return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
29855
30012
  "Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
29856
30013
  ]);
@@ -29884,7 +30041,7 @@ class GodotServer {
29884
30041
  ];
29885
30042
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 600000;
29886
30043
  this.logDebug(`Running tests: ${engine.value} ${cmdArgs.join(" ")}`);
29887
- const userData = mkdtempSync2(join9(tmpdir4(), "gdharness-tests-"));
30044
+ const userData = mkdtempSync2(join10(tmpdir4(), "gdharness-tests-"));
29888
30045
  const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
29889
30046
  const hung = await new Promise((resolve) => {
29890
30047
  const timer = setTimeout(() => {
@@ -29900,26 +30057,26 @@ class GodotServer {
29900
30057
  resolve(false);
29901
30058
  });
29902
30059
  });
29903
- const reportsDir = join9(project.value.path, ".godot", "gdharness-reports");
30060
+ const reportsDir = join10(project.value.path, ".godot", "gdharness-reports");
29904
30061
  let report = null;
29905
30062
  let reportProblem = null;
29906
30063
  try {
29907
- const written = existsSync7(reportsDir) ? readdirSync5(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
30064
+ const written = existsSync7(reportsDir) ? readdirSync6(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
29908
30065
  const newest = written.at(-1);
29909
30066
  if (newest !== undefined) {
29910
- report = parseJUnit(readFileSync9(join9(reportsDir, newest, "results.xml"), "utf8"));
30067
+ report = parseJUnit(readFileSync10(join10(reportsDir, newest, "results.xml"), "utf8"));
29911
30068
  }
29912
30069
  } catch (error) {
29913
30070
  reportProblem = errorMessage(error);
29914
30071
  } finally {
29915
- rmSync4(reportsDir, { recursive: true, force: true });
29916
- rmSync4(userData, { recursive: true, force: true });
30072
+ rmSync5(reportsDir, { recursive: true, force: true });
30073
+ rmSync5(userData, { recursive: true, force: true });
29917
30074
  }
29918
30075
  const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries.map(aboveTheRunner);
29919
30076
  const verdicts = {
29920
30077
  0: "passed",
29921
30078
  100: "failures",
29922
- 101: "warnings",
30079
+ 101: "warnings: orphan nodes",
29923
30080
  103: "headless not supported by this gdUnit4",
29924
30081
  104: "Godot version not supported by this gdUnit4",
29925
30082
  105: "script errors"
@@ -29951,7 +30108,17 @@ class GodotServer {
29951
30108
  };
29952
30109
  }
29953
30110
  const failed = report.suites.flatMap((suite) => suite.cases.filter((entry) => entry.status === "failed" || entry.status === "error").map((entry) => ({ ...entry, path: suite.path })));
29954
- const unclean = report.suites.filter((suite) => suite.failures > 0 || suite.errors > 0 || suite.skipped > 0);
30111
+ const orphans = orphansPrinted(said);
30112
+ const leftBehind = new Map(orphans.suites.map((suite) => [suite.path, suite.orphans]));
30113
+ const orphansIn = (path) => path === null ? 0 : leftBehind.get(path) ?? 0;
30114
+ const warnings = orphans.suites.map((suite) => ({
30115
+ kind: "orphans",
30116
+ path: suite.path,
30117
+ name: report.suites.find((entry) => entry.path === suite.path)?.name ?? suite.path,
30118
+ orphans: suite.orphans,
30119
+ message: `${suite.orphans} node${suite.orphans === 1 ? "" : "s"} were still in the tree when this suite finished. A node built in a test and neither freed nor added to the tree is the usual cause; free it, or add it with auto_free().`
30120
+ }));
30121
+ const unclean = report.suites.filter((suite) => suite.failures > 0 || suite.errors > 0 || suite.skipped > 0 || orphansIn(suite.path) > 0);
29955
30122
  return this.jsonTextResponse({
29956
30123
  passed: !hung && exitCode === 0 && report.failures === 0 && report.errors === 0,
29957
30124
  verdict,
@@ -29962,6 +30129,9 @@ class GodotServer {
29962
30129
  skipped: report.skipped,
29963
30130
  time: report.time,
29964
30131
  failed,
30132
+ warnings: warnings.length > 0 ? warnings : undefined,
30133
+ orphans: orphans.total > 0 ? orphans.total : undefined,
30134
+ note: verdict.startsWith("warnings") && warnings.length === 0 ? "gdUnit4 exits 101 for orphan nodes when nothing failed, and this run printed no count of them: orphan reporting may be off in the project settings." : undefined,
29965
30135
  suites: unclean.map((suite) => ({
29966
30136
  name: suite.name,
29967
30137
  path: suite.path,
@@ -29969,6 +30139,7 @@ class GodotServer {
29969
30139
  failures: suite.failures,
29970
30140
  errors: suite.errors,
29971
30141
  skipped: suite.skipped,
30142
+ orphans: orphansIn(suite.path) > 0 ? orphansIn(suite.path) : undefined,
29972
30143
  time: suite.time
29973
30144
  })),
29974
30145
  suitesPassed: report.suites.length - unclean.length,
@@ -30095,7 +30266,7 @@ class GodotServer {
30095
30266
  version: godotPath === null ? null : await this.godotVersion(godotPath)
30096
30267
  },
30097
30268
  game: {
30098
- processActive: this.activeProcess?.exitCode === null,
30269
+ processActive: stillRunning(this.currentRun()),
30099
30270
  playingInEditor: playing,
30100
30271
  runtimeConnected: games.some((game) => game.reachable),
30101
30272
  runtimes: games
@@ -30263,7 +30434,7 @@ class GodotServer {
30263
30434
  if (op === "check") {
30264
30435
  return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value);
30265
30436
  }
30266
- if (this.activeProcess?.exitCode === null) {
30437
+ if (stillRunning(this.currentRun())) {
30267
30438
  this.logDebug("Ending the running game before starting another");
30268
30439
  await this.endActiveGame();
30269
30440
  }
@@ -30283,7 +30454,7 @@ class GodotServer {
30283
30454
  userArgs: given.value
30284
30455
  });
30285
30456
  this.logDebug(`Running Godot project: ${engine.value} ${cmdArgs.join(" ")}`);
30286
- const started = this.spawnGame(engine.value, cmdArgs);
30457
+ const started = this.spawnKeptGame(engine.value, cmdArgs, project.value.path);
30287
30458
  this.activeProcess = started;
30288
30459
  const spawnedInstead = this.godotBridge.isConnected() && editorWouldPlay && given.value.length > 0 ? " The editor cannot be handed arguments for a game it plays, so this one was started " + "here: the debug_* tools answer only for a game the editor is playing." : "";
30289
30460
  return this.jsonTextResponse({
@@ -30297,7 +30468,7 @@ class GodotServer {
30297
30468
  });
30298
30469
  }
30299
30470
  async runtimeUp(projectPath, before) {
30300
- if (!existsSync7(join9(projectPath, RUNTIME_AUTOLOAD.path))) {
30471
+ if (!existsSync7(join10(projectPath, RUNTIME_AUTOLOAD.path))) {
30301
30472
  return {
30302
30473
  listening: false,
30303
30474
  note: "this project has no gdharness_runtime addon, so the runtime_* tools have nothing to talk to"
@@ -30332,7 +30503,11 @@ class GodotServer {
30332
30503
  }
30333
30504
  const played = {
30334
30505
  process: null,
30506
+ pid: null,
30335
30507
  log,
30508
+ transcript: null,
30509
+ readOffset: 0,
30510
+ projectPath,
30336
30511
  startedAt: Date.now(),
30337
30512
  exitCode: null,
30338
30513
  throughEditor: true,
@@ -30371,6 +30546,7 @@ class GodotServer {
30371
30546
  async endActiveGame() {
30372
30547
  const running = this.activeProcess;
30373
30548
  this.activeProcess = null;
30549
+ clearRunRecord();
30374
30550
  if (!running) {
30375
30551
  return;
30376
30552
  }
@@ -30378,14 +30554,26 @@ class GodotServer {
30378
30554
  await this.handleViaBridge("stop_playing", {});
30379
30555
  return;
30380
30556
  }
30381
- running.process?.kill();
30557
+ if (running.process !== null) {
30558
+ running.process.kill();
30559
+ return;
30560
+ }
30561
+ if (running.pid !== null) {
30562
+ try {
30563
+ process.kill(running.pid);
30564
+ } catch {}
30565
+ }
30382
30566
  }
30383
30567
  spawnGame(godotPath, cmdArgs, env) {
30384
30568
  const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
30385
30569
  const log = new GameLog;
30386
30570
  const started = {
30387
30571
  process: child,
30572
+ pid: child.pid ?? null,
30388
30573
  log,
30574
+ transcript: null,
30575
+ readOffset: 0,
30576
+ projectPath: null,
30389
30577
  startedAt: Date.now(),
30390
30578
  exitCode: null,
30391
30579
  throughEditor: false,
@@ -30410,6 +30598,117 @@ class GodotServer {
30410
30598
  });
30411
30599
  return started;
30412
30600
  }
30601
+ spawnKeptGame(godotPath, cmdArgs, projectPath, env) {
30602
+ const startedAt = Date.now();
30603
+ const transcript = openTranscript(startedAt);
30604
+ let child;
30605
+ try {
30606
+ child = spawn(godotPath, cmdArgs, {
30607
+ stdio: ["ignore", transcript.fd, transcript.fd],
30608
+ detached: true,
30609
+ ...env ? { env } : {}
30610
+ });
30611
+ } finally {
30612
+ closeSync(transcript.fd);
30613
+ }
30614
+ child.unref();
30615
+ const log = new GameLog;
30616
+ const started = {
30617
+ process: child,
30618
+ pid: child.pid ?? null,
30619
+ log,
30620
+ transcript: transcript.path,
30621
+ readOffset: 0,
30622
+ projectPath,
30623
+ startedAt,
30624
+ exitCode: null,
30625
+ throughEditor: false,
30626
+ brokeOn: null
30627
+ };
30628
+ child.on("exit", (code) => {
30629
+ this.logDebug(`Godot process exited with code ${code ?? "none"}`);
30630
+ this.drainTranscript(started);
30631
+ log.finish();
30632
+ started.exitCode = code ?? -1;
30633
+ });
30634
+ child.on("error", (err) => {
30635
+ console.error("Failed to start Godot process:", err);
30636
+ log.append("stderr", `${err.message}
30637
+ `);
30638
+ started.exitCode = -1;
30639
+ });
30640
+ if (started.pid !== null) {
30641
+ writeRunRecord({
30642
+ pid: started.pid,
30643
+ transcript: transcript.path,
30644
+ startedAt,
30645
+ projectPath,
30646
+ arguments: cmdArgs
30647
+ });
30648
+ }
30649
+ sweepTranscripts();
30650
+ return started;
30651
+ }
30652
+ drainTranscript(game) {
30653
+ if (game.transcript === null) {
30654
+ return;
30655
+ }
30656
+ let handle;
30657
+ try {
30658
+ handle = openSync2(game.transcript, "r");
30659
+ } catch {
30660
+ return;
30661
+ }
30662
+ try {
30663
+ const size = fstatSync(handle).size;
30664
+ if (size <= game.readOffset) {
30665
+ return;
30666
+ }
30667
+ const buffer = Buffer.alloc(size - game.readOffset);
30668
+ const read = readSync(handle, buffer, 0, buffer.length, game.readOffset);
30669
+ game.readOffset += read;
30670
+ game.log.append("transcript", buffer.subarray(0, read));
30671
+ } finally {
30672
+ closeSync(handle);
30673
+ }
30674
+ }
30675
+ adoptRecordedRun() {
30676
+ const record = readRunRecord();
30677
+ if (record === null || !this.couldBeOurs(record.projectPath)) {
30678
+ return null;
30679
+ }
30680
+ const adopted = {
30681
+ process: null,
30682
+ pid: record.pid,
30683
+ log: new GameLog,
30684
+ transcript: record.transcript,
30685
+ readOffset: 0,
30686
+ projectPath: record.projectPath === "" ? null : record.projectPath,
30687
+ startedAt: record.startedAt,
30688
+ exitCode: null,
30689
+ throughEditor: false,
30690
+ brokeOn: null
30691
+ };
30692
+ this.drainTranscript(adopted);
30693
+ if (!alive(record.pid)) {
30694
+ adopted.log.finish();
30695
+ adopted.exitCode = null;
30696
+ adopted.endedUnwatched = true;
30697
+ }
30698
+ this.activeProcess = adopted;
30699
+ return adopted;
30700
+ }
30701
+ couldBeOurs(project) {
30702
+ if (project === "") {
30703
+ return true;
30704
+ }
30705
+ const status = this.godotBridge.getStatus();
30706
+ const mine = this.ownProject ?? (status.connected ? status.projectPath ?? null : null);
30707
+ return mine === null || samePath(mine, project);
30708
+ }
30709
+ currentRun() {
30710
+ return this.activeProcess ?? this.adoptRecordedRun();
30711
+ }
30413
30712
  async checkBoot(godotPath, projectPath, scene, args, userArgs) {
30414
30713
  const frames = readPositiveNumber(args, "frames") ?? 3;
30415
30714
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 60000;
@@ -30443,37 +30742,47 @@ class GodotServer {
30443
30742
  });
30444
30743
  }
30445
30744
  handleGetDebugOutput(args) {
30446
- if (!this.activeProcess) {
30745
+ const run = this.currentRun();
30746
+ if (!run) {
30447
30747
  return this.createErrorResponse("No game is running. Start one with editor_run.");
30448
30748
  }
30449
- this.drainEditorOutput(this.activeProcess);
30749
+ this.drainEditorOutput(run);
30750
+ this.drainTranscript(run);
30751
+ if (run.endedUnwatched !== true && !stillRunning(run)) {
30752
+ run.log.finish();
30753
+ run.endedUnwatched = run.exitCode === null;
30754
+ }
30450
30755
  const severity = readString(args, "severity");
30451
- const selected = this.activeProcess.log.select({
30756
+ const selected = run.log.select({
30452
30757
  severity: severity === "error" || severity === "warning" ? severity : "info",
30453
30758
  sinceLastCall: readBoolean(args, "sinceLastCall") ?? false,
30454
30759
  contains: readNonEmptyString(args, "contains"),
30455
30760
  limit: readPositiveNumber(args, "limit") ?? 200
30456
30761
  });
30457
- const halt = this.activeProcess.throughEditor ? this.dapClient?.whereItStopped() ?? null : null;
30762
+ const halt = run.throughEditor ? this.dapClient?.whereItStopped() ?? null : null;
30458
30763
  return this.jsonTextResponse({
30459
- running: this.activeProcess.exitCode === null,
30460
- exitCode: this.activeProcess.exitCode,
30461
- through: this.activeProcess.throughEditor ? "editor" : "gdharness",
30462
- pid: this.activeProcess.process?.pid ?? null,
30463
- errors: this.activeProcess.log.count("error"),
30464
- warnings: this.activeProcess.log.count("warning"),
30465
- clean: this.activeProcess.log.count("error") === 0,
30764
+ running: stillRunning(run),
30765
+ exitCode: run.exitCode,
30766
+ through: run.throughEditor ? "editor" : "gdharness",
30767
+ pid: run.pid,
30768
+ errors: run.log.count("error"),
30769
+ warnings: run.log.count("warning"),
30770
+ clean: run.log.count("error") === 0,
30466
30771
  heldAt: halt,
30772
+ endedUnwatched: run.endedUnwatched === true ? true : undefined,
30773
+ project: !run.throughEditor && run.process === null ? run.projectPath ?? undefined : undefined,
30774
+ note: run.endedUnwatched === true ? "This run outlived the server that started it and is over now, so its exit code was never collected. Everything it printed is below, read back from its transcript." : undefined,
30467
30775
  omitted: selected.omitted,
30468
30776
  entries: selected.entries
30469
30777
  });
30470
30778
  }
30471
30779
  async handleStopProject() {
30472
- if (!this.activeProcess) {
30780
+ const stopped = this.currentRun();
30781
+ if (!stopped) {
30473
30782
  return this.createErrorResponse("No game is running. Start one with editor_run.");
30474
30783
  }
30475
- const stopped = this.activeProcess;
30476
30784
  this.drainEditorOutput(stopped);
30785
+ this.drainTranscript(stopped);
30477
30786
  this.logDebug("Stopping the running game");
30478
30787
  await this.endActiveGame();
30479
30788
  return this.jsonTextResponse({
@@ -30579,8 +30888,8 @@ class GodotServer {
30579
30888
  return this.createErrorResponse(choice.problem);
30580
30889
  }
30581
30890
  const expectsScreenshot = command === "capture_screenshot" || command === "capture_viewport";
30582
- const screenshotDir = expectsScreenshot ? mkdtempSync2(join9(tmpdir4(), "gdharness-runtime-screenshot-")) : null;
30583
- const screenshotPath = screenshotDir ? join9(screenshotDir, "capture.png") : null;
30891
+ const screenshotDir = expectsScreenshot ? mkdtempSync2(join10(tmpdir4(), "gdharness-runtime-screenshot-")) : null;
30892
+ const screenshotPath = screenshotDir ? join10(screenshotDir, "capture.png") : null;
30584
30893
  try {
30585
30894
  const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs);
30586
30895
  if (!reply.ok) {
@@ -30601,12 +30910,12 @@ class GodotServer {
30601
30910
  return {
30602
30911
  content: [
30603
30912
  { type: "text", text: `Screenshot captured: ${dimensions}` },
30604
- { type: "image", data: readFileSync9(screenshotPath).toString("base64"), mimeType: "image/png" }
30913
+ { type: "image", data: readFileSync10(screenshotPath).toString("base64"), mimeType: "image/png" }
30605
30914
  ]
30606
30915
  };
30607
30916
  } finally {
30608
30917
  if (screenshotDir) {
30609
- rmSync4(screenshotDir, { recursive: true, force: true });
30918
+ rmSync5(screenshotDir, { recursive: true, force: true });
30610
30919
  }
30611
30920
  }
30612
30921
  }
@@ -30631,12 +30940,14 @@ class GodotServer {
30631
30940
  return this.createErrorResponse("runtime_inspect find needs at least one of className, script, namePattern, group, says.");
30632
30941
  }
30633
30942
  const property = readNonEmptyString(args, "property");
30943
+ const includeHidden = readBoolean(args, "includeHidden");
30634
30944
  return await this.handleRuntimeCommand("find_nodes", {
30635
30945
  ...filters,
30636
30946
  projectPath: args["projectPath"],
30637
30947
  root: readNonEmptyString(args, "nodePath") ?? "/root",
30638
30948
  limit: readPositiveNumber(args, "limit") ?? 100,
30639
- ...property === undefined ? {} : { property }
30949
+ ...property === undefined ? {} : { property },
30950
+ ...includeHidden === undefined ? {} : { include_hidden: includeHidden }
30640
30951
  });
30641
30952
  }
30642
30953
  async handleRuntimeWait(op, args) {