gdharness 0.10.2 → 0.11.1

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,104 @@ 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 recordPaths() {
27802
+ return runtimeDirectories().map((directory) => join8(directory, "runs", "run.json"));
27803
+ }
27804
+ function openTranscript(startedAt) {
27805
+ mkdirSync3(runsDirectory(), { recursive: true });
27806
+ const path = join8(runsDirectory(), `run-${startedAt}.log`);
27807
+ return { path, fd: openSync(path, "a") };
27808
+ }
27809
+ function writeRunRecord(record) {
27810
+ mkdirSync3(runsDirectory(), { recursive: true });
27811
+ writeFileSync4(recordPath(), JSON.stringify(record, null, 2), "utf8");
27812
+ }
27813
+ function readRunRecord() {
27814
+ for (const path of recordPaths()) {
27815
+ const record = recordAt(path);
27816
+ if (record !== null) {
27817
+ return record;
27818
+ }
27819
+ }
27820
+ return null;
27821
+ }
27822
+ function recordAt(path) {
27823
+ let parsed;
27824
+ try {
27825
+ parsed = JSON.parse(readFileSync8(path, "utf8"));
27826
+ } catch {
27827
+ return null;
27828
+ }
27829
+ if (!parsed || typeof parsed !== "object") {
27830
+ return null;
27831
+ }
27832
+ const fields = parsed;
27833
+ const pid = fields["pid"];
27834
+ const transcript = fields["transcript"];
27835
+ const startedAt = fields["startedAt"];
27836
+ if (typeof pid !== "number" || typeof transcript !== "string" || typeof startedAt !== "number") {
27837
+ return null;
27838
+ }
27839
+ const args = fields["arguments"];
27840
+ return {
27841
+ pid,
27842
+ transcript,
27843
+ startedAt,
27844
+ projectPath: typeof fields["projectPath"] === "string" ? fields["projectPath"] : "",
27845
+ arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : []
27846
+ };
27847
+ }
27848
+ function clearRunRecord(ours = () => true) {
27849
+ for (const path of recordPaths()) {
27850
+ const record = recordAt(path);
27851
+ if (record === null || !ours(record)) {
27852
+ continue;
27853
+ }
27854
+ try {
27855
+ rmSync3(path);
27856
+ } catch {}
27857
+ }
27858
+ }
27859
+ function ageMs(path, now) {
27860
+ try {
27861
+ return now - statSync2(path).mtimeMs;
27862
+ } catch {
27863
+ return 0;
27864
+ }
27865
+ }
27866
+ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
27867
+ const directory = runsDirectory();
27868
+ const keep = readRunRecord()?.transcript;
27869
+ let entries;
27870
+ try {
27871
+ entries = readdirSync5(directory);
27872
+ } catch {
27873
+ return;
27874
+ }
27875
+ for (const entry of entries) {
27876
+ if (!entry.startsWith("run-") || !entry.endsWith(".log")) {
27877
+ continue;
27878
+ }
27879
+ const path = join8(directory, entry);
27880
+ if (path === keep || ageMs(path, now) < keepMs) {
27881
+ continue;
27882
+ }
27883
+ try {
27884
+ rmSync3(path);
27885
+ } catch {}
27886
+ }
27887
+ }
27888
+
27713
27889
  // 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";
27890
+ import { cpSync, existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync9, rmSync as rmSync4, writeFileSync as writeFileSync5 } from "node:fs";
27891
+ import { dirname as dirname3, join as join9 } from "node:path";
27716
27892
  var ADDONS = ["gdharness_editor", "gdharness_runtime", "auto_reload"];
27717
27893
  var RUNTIME_AUTOLOAD = {
27718
27894
  name: "GdharnessRuntime",
@@ -27720,11 +27896,11 @@ var RUNTIME_AUTOLOAD = {
27720
27896
  };
27721
27897
  var VERSION_MARKER = ".gdharness-version";
27722
27898
  function installedAddonVersion(projectPath) {
27723
- const marker = join8(projectPath, "addons", ADDONS[0], VERSION_MARKER);
27899
+ const marker = join9(projectPath, "addons", ADDONS[0], VERSION_MARKER);
27724
27900
  if (!existsSync6(marker)) {
27725
27901
  return null;
27726
27902
  }
27727
- const said = readFileSync8(marker, "utf8").trim();
27903
+ const said = readFileSync9(marker, "utf8").trim();
27728
27904
  return said === "" ? null : said;
27729
27905
  }
27730
27906
 
@@ -28041,7 +28217,7 @@ var TOOL_SPECS = [
28041
28217
  },
28042
28218
  {
28043
28219
  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.",
28220
+ 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
28221
  parameters: {
28046
28222
  projectPath: PROJECT_PATH,
28047
28223
  path: {
@@ -28353,8 +28529,14 @@ var TOOL_SPECS = [
28353
28529
  type: "boolean",
28354
28530
  description: "Only entries printed since the previous editor_output. Default false."
28355
28531
  },
28356
- contains: { type: "string", description: "Only entries mentioning this text." },
28357
- limit: { type: "number", description: "The most entries to answer with, newest kept. Default 200." }
28532
+ contains: {
28533
+ type: "string",
28534
+ description: "Only entries mentioning this text, matched against everything the run has printed rather than against the entries this answer would otherwise carry: a line is found however much was printed after it."
28535
+ },
28536
+ limit: {
28537
+ type: "number",
28538
+ description: "The most entries to answer with, newest kept. Default 200, and omitted says how many matching entries that left out. A run long enough to pass it needs this raised, not just filtered: filtering narrows what counts as matching, and the newest of those is still all one answer carries."
28539
+ }
28358
28540
  },
28359
28541
  requires: []
28360
28542
  },
@@ -28453,8 +28635,8 @@ var TOOL_SPECS = [
28453
28635
  },
28454
28636
  includeHidden: {
28455
28637
  type: "boolean",
28456
- ops: ["text"],
28457
- description: "text: read hidden nodes as well, for checking that something is not showing. Default false."
28638
+ ops: ["text", "find"],
28639
+ 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
28640
  },
28459
28641
  metrics: {
28460
28642
  type: "array",
@@ -28471,7 +28653,7 @@ var TOOL_SPECS = [
28471
28653
  requires: []
28472
28654
  },
28473
28655
  find: {
28474
- summary: "the paths of every node matching className, script, namePattern, group or says, with property read off each",
28656
+ 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
28657
  requires: []
28476
28658
  },
28477
28659
  rect: {
@@ -28779,15 +28961,24 @@ var SLOWEST_FRAME_RATE = 20;
28779
28961
  function patienceForFrames(frames, atLeast) {
28780
28962
  return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
28781
28963
  }
28964
+ function stillRunning(run) {
28965
+ if (run?.exitCode !== null || run.endedUnwatched === true) {
28966
+ return false;
28967
+ }
28968
+ if (!run.throughEditor && run.process === null && run.pid !== null) {
28969
+ return alive(run.pid);
28970
+ }
28971
+ return true;
28972
+ }
28782
28973
  function alive(pid) {
28783
- if (pid === undefined) {
28974
+ if (pid === undefined || pid === null) {
28784
28975
  return false;
28785
28976
  }
28786
28977
  try {
28787
28978
  process.kill(pid, 0);
28788
28979
  return true;
28789
- } catch {
28790
- return false;
28980
+ } catch (error) {
28981
+ return error.code === "EPERM";
28791
28982
  }
28792
28983
  }
28793
28984
  function isOfType(value, kind) {
@@ -28929,13 +29120,13 @@ function aboveTheRunner(entry) {
28929
29120
  }
28930
29121
  function elsewhereIn(projectPath, asked) {
28931
29122
  const wanted = asked.replace(/^res:\/\//, "").replace(/\/+$/, "");
28932
- if (wanted === "" || existsSync7(join9(projectPath, wanted))) {
29123
+ if (wanted === "" || existsSync7(join10(projectPath, wanted))) {
28933
29124
  return "";
28934
29125
  }
28935
29126
  const root = (wanted.split("/")[0] ?? "").toLowerCase();
28936
29127
  let near = [];
28937
29128
  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()));
29129
+ 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
29130
  } catch {
28940
29131
  return "";
28941
29132
  }
@@ -28943,11 +29134,11 @@ function elsewhereIn(projectPath, asked) {
28943
29134
  return near.length === 0 ? "" : ` The project does have ${there}.`;
28944
29135
  }
28945
29136
  function hasMainScene(projectFile) {
28946
- const scene = parseProjectGodot(readFileSync9(projectFile, "utf8"))["application"]?.["run/main_scene"];
29137
+ const scene = parseProjectGodot(readFileSync10(projectFile, "utf8"))["application"]?.["run/main_scene"];
28947
29138
  return typeof scene === "string" && scene !== "";
28948
29139
  }
28949
29140
  function editorPlaysHeadless(projectFile) {
28950
- const runArgs = parseProjectGodot(readFileSync9(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
29141
+ const runArgs = parseProjectGodot(readFileSync10(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
28951
29142
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
28952
29143
  }
28953
29144
  function camelCased(params) {
@@ -28963,7 +29154,7 @@ function camelCased(params) {
28963
29154
  class GodotServer {
28964
29155
  mcp;
28965
29156
  locator = new GodotLocator;
28966
- operationsScript = join9(__dirname2, "godot", "operations", "godot_operations.gd");
29157
+ operationsScript = join10(__dirname2, "godot", "operations", "godot_operations.gd");
28967
29158
  godotBridge;
28968
29159
  tools = buildToolDefinitions();
28969
29160
  activeProcess = null;
@@ -29120,10 +29311,7 @@ class GodotServer {
29120
29311
  this.stopWatchingForASuccessor();
29121
29312
  withdrawBridge(this.announcedAt);
29122
29313
  this.announcedAt = null;
29123
- if (this.activeProcess) {
29124
- this.activeProcess.process?.kill();
29125
- this.activeProcess = null;
29126
- }
29314
+ this.activeProcess = null;
29127
29315
  if (this.lspClient) {
29128
29316
  try {
29129
29317
  await this.lspClient.disconnect();
@@ -29188,14 +29376,7 @@ class GodotServer {
29188
29376
  return;
29189
29377
  }
29190
29378
  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
- }
29379
+ this.activeProcess = null;
29199
29380
  }
29200
29381
  setupToolHandlers() {
29201
29382
  this.mcp.server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: this.tools }));
@@ -29555,7 +29736,7 @@ class GodotServer {
29555
29736
  if (path === undefined) {
29556
29737
  return { ok: false, response: this.createErrorResponse("projectPath is required.") };
29557
29738
  }
29558
- const file = join9(path, "project.godot");
29739
+ const file = join10(path, "project.godot");
29559
29740
  if (!existsSync7(file)) {
29560
29741
  return {
29561
29742
  ok: false,
@@ -29725,7 +29906,7 @@ class GodotServer {
29725
29906
  if (!engine.ok) {
29726
29907
  return engine.response;
29727
29908
  }
29728
- const application = parseProjectGodot(readFileSync9(project.value.file, "utf8"))["application"] ?? {};
29909
+ const application = parseProjectGodot(readFileSync10(project.value.file, "utf8"))["application"] ?? {};
29729
29910
  const name = application["config/name"];
29730
29911
  const mainScene = application["run/main_scene"];
29731
29912
  const info = {
@@ -29788,7 +29969,7 @@ class GodotServer {
29788
29969
  }
29789
29970
  const folder = dirname4(output.absolutePath);
29790
29971
  try {
29791
- mkdirSync4(folder, { recursive: true });
29972
+ mkdirSync5(folder, { recursive: true });
29792
29973
  } catch (error) {
29793
29974
  return this.createErrorResponse(`Cannot create ${dirname4(output.relativePath)} to export into: ${errorMessage(error)}`);
29794
29975
  }
@@ -29850,7 +30031,7 @@ class GodotServer {
29850
30031
  return contained.response;
29851
30032
  }
29852
30033
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
29853
- if (!existsSync7(join9(project.value.path, runner))) {
30034
+ if (!existsSync7(join10(project.value.path, runner))) {
29854
30035
  return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
29855
30036
  "Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
29856
30037
  ]);
@@ -29884,7 +30065,7 @@ class GodotServer {
29884
30065
  ];
29885
30066
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 600000;
29886
30067
  this.logDebug(`Running tests: ${engine.value} ${cmdArgs.join(" ")}`);
29887
- const userData = mkdtempSync2(join9(tmpdir4(), "gdharness-tests-"));
30068
+ const userData = mkdtempSync2(join10(tmpdir4(), "gdharness-tests-"));
29888
30069
  const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
29889
30070
  const hung = await new Promise((resolve) => {
29890
30071
  const timer = setTimeout(() => {
@@ -29900,26 +30081,26 @@ class GodotServer {
29900
30081
  resolve(false);
29901
30082
  });
29902
30083
  });
29903
- const reportsDir = join9(project.value.path, ".godot", "gdharness-reports");
30084
+ const reportsDir = join10(project.value.path, ".godot", "gdharness-reports");
29904
30085
  let report = null;
29905
30086
  let reportProblem = null;
29906
30087
  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))) : [];
30088
+ 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
30089
  const newest = written.at(-1);
29909
30090
  if (newest !== undefined) {
29910
- report = parseJUnit(readFileSync9(join9(reportsDir, newest, "results.xml"), "utf8"));
30091
+ report = parseJUnit(readFileSync10(join10(reportsDir, newest, "results.xml"), "utf8"));
29911
30092
  }
29912
30093
  } catch (error) {
29913
30094
  reportProblem = errorMessage(error);
29914
30095
  } finally {
29915
- rmSync4(reportsDir, { recursive: true, force: true });
29916
- rmSync4(userData, { recursive: true, force: true });
30096
+ rmSync5(reportsDir, { recursive: true, force: true });
30097
+ rmSync5(userData, { recursive: true, force: true });
29917
30098
  }
29918
30099
  const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries.map(aboveTheRunner);
29919
30100
  const verdicts = {
29920
30101
  0: "passed",
29921
30102
  100: "failures",
29922
- 101: "warnings",
30103
+ 101: "warnings: orphan nodes",
29923
30104
  103: "headless not supported by this gdUnit4",
29924
30105
  104: "Godot version not supported by this gdUnit4",
29925
30106
  105: "script errors"
@@ -29951,7 +30132,17 @@ class GodotServer {
29951
30132
  };
29952
30133
  }
29953
30134
  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);
30135
+ const orphans = orphansPrinted(said);
30136
+ const leftBehind = new Map(orphans.suites.map((suite) => [suite.path, suite.orphans]));
30137
+ const orphansIn = (path) => path === null ? 0 : leftBehind.get(path) ?? 0;
30138
+ const warnings = orphans.suites.map((suite) => ({
30139
+ kind: "orphans",
30140
+ path: suite.path,
30141
+ name: report.suites.find((entry) => entry.path === suite.path)?.name ?? suite.path,
30142
+ orphans: suite.orphans,
30143
+ 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().`
30144
+ }));
30145
+ const unclean = report.suites.filter((suite) => suite.failures > 0 || suite.errors > 0 || suite.skipped > 0 || orphansIn(suite.path) > 0);
29955
30146
  return this.jsonTextResponse({
29956
30147
  passed: !hung && exitCode === 0 && report.failures === 0 && report.errors === 0,
29957
30148
  verdict,
@@ -29962,6 +30153,9 @@ class GodotServer {
29962
30153
  skipped: report.skipped,
29963
30154
  time: report.time,
29964
30155
  failed,
30156
+ warnings: warnings.length > 0 ? warnings : undefined,
30157
+ orphans: orphans.total > 0 ? orphans.total : undefined,
30158
+ 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
30159
  suites: unclean.map((suite) => ({
29966
30160
  name: suite.name,
29967
30161
  path: suite.path,
@@ -29969,6 +30163,7 @@ class GodotServer {
29969
30163
  failures: suite.failures,
29970
30164
  errors: suite.errors,
29971
30165
  skipped: suite.skipped,
30166
+ orphans: orphansIn(suite.path) > 0 ? orphansIn(suite.path) : undefined,
29972
30167
  time: suite.time
29973
30168
  })),
29974
30169
  suitesPassed: report.suites.length - unclean.length,
@@ -30095,7 +30290,7 @@ class GodotServer {
30095
30290
  version: godotPath === null ? null : await this.godotVersion(godotPath)
30096
30291
  },
30097
30292
  game: {
30098
- processActive: this.activeProcess?.exitCode === null,
30293
+ processActive: stillRunning(this.currentRun()),
30099
30294
  playingInEditor: playing,
30100
30295
  runtimeConnected: games.some((game) => game.reachable),
30101
30296
  runtimes: games
@@ -30263,7 +30458,7 @@ class GodotServer {
30263
30458
  if (op === "check") {
30264
30459
  return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value);
30265
30460
  }
30266
- if (this.activeProcess?.exitCode === null) {
30461
+ if (stillRunning(this.currentRun())) {
30267
30462
  this.logDebug("Ending the running game before starting another");
30268
30463
  await this.endActiveGame();
30269
30464
  }
@@ -30283,7 +30478,7 @@ class GodotServer {
30283
30478
  userArgs: given.value
30284
30479
  });
30285
30480
  this.logDebug(`Running Godot project: ${engine.value} ${cmdArgs.join(" ")}`);
30286
- const started = this.spawnGame(engine.value, cmdArgs);
30481
+ const started = this.spawnKeptGame(engine.value, cmdArgs, project.value.path);
30287
30482
  this.activeProcess = started;
30288
30483
  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
30484
  return this.jsonTextResponse({
@@ -30297,7 +30492,7 @@ class GodotServer {
30297
30492
  });
30298
30493
  }
30299
30494
  async runtimeUp(projectPath, before) {
30300
- if (!existsSync7(join9(projectPath, RUNTIME_AUTOLOAD.path))) {
30495
+ if (!existsSync7(join10(projectPath, RUNTIME_AUTOLOAD.path))) {
30301
30496
  return {
30302
30497
  listening: false,
30303
30498
  note: "this project has no gdharness_runtime addon, so the runtime_* tools have nothing to talk to"
@@ -30332,7 +30527,11 @@ class GodotServer {
30332
30527
  }
30333
30528
  const played = {
30334
30529
  process: null,
30530
+ pid: null,
30335
30531
  log,
30532
+ transcript: null,
30533
+ readOffset: 0,
30534
+ projectPath,
30336
30535
  startedAt: Date.now(),
30337
30536
  exitCode: null,
30338
30537
  throughEditor: true,
@@ -30371,6 +30570,7 @@ class GodotServer {
30371
30570
  async endActiveGame() {
30372
30571
  const running = this.activeProcess;
30373
30572
  this.activeProcess = null;
30573
+ clearRunRecord((record) => this.couldBeOurs(record.projectPath));
30374
30574
  if (!running) {
30375
30575
  return;
30376
30576
  }
@@ -30378,14 +30578,26 @@ class GodotServer {
30378
30578
  await this.handleViaBridge("stop_playing", {});
30379
30579
  return;
30380
30580
  }
30381
- running.process?.kill();
30581
+ if (running.process !== null) {
30582
+ running.process.kill();
30583
+ return;
30584
+ }
30585
+ if (running.pid !== null) {
30586
+ try {
30587
+ process.kill(running.pid);
30588
+ } catch {}
30589
+ }
30382
30590
  }
30383
30591
  spawnGame(godotPath, cmdArgs, env) {
30384
30592
  const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
30385
30593
  const log = new GameLog;
30386
30594
  const started = {
30387
30595
  process: child,
30596
+ pid: child.pid ?? null,
30388
30597
  log,
30598
+ transcript: null,
30599
+ readOffset: 0,
30600
+ projectPath: null,
30389
30601
  startedAt: Date.now(),
30390
30602
  exitCode: null,
30391
30603
  throughEditor: false,
@@ -30410,6 +30622,117 @@ class GodotServer {
30410
30622
  });
30411
30623
  return started;
30412
30624
  }
30625
+ spawnKeptGame(godotPath, cmdArgs, projectPath, env) {
30626
+ const startedAt = Date.now();
30627
+ const transcript = openTranscript(startedAt);
30628
+ let child;
30629
+ try {
30630
+ child = spawn(godotPath, cmdArgs, {
30631
+ stdio: ["ignore", transcript.fd, transcript.fd],
30632
+ detached: true,
30633
+ ...env ? { env } : {}
30634
+ });
30635
+ } finally {
30636
+ closeSync(transcript.fd);
30637
+ }
30638
+ child.unref();
30639
+ const log = new GameLog;
30640
+ const started = {
30641
+ process: child,
30642
+ pid: child.pid ?? null,
30643
+ log,
30644
+ transcript: transcript.path,
30645
+ readOffset: 0,
30646
+ projectPath,
30647
+ startedAt,
30648
+ exitCode: null,
30649
+ throughEditor: false,
30650
+ brokeOn: null
30651
+ };
30652
+ child.on("exit", (code) => {
30653
+ this.logDebug(`Godot process exited with code ${code ?? "none"}`);
30654
+ this.drainTranscript(started);
30655
+ log.finish();
30656
+ started.exitCode = code ?? -1;
30657
+ });
30658
+ child.on("error", (err) => {
30659
+ console.error("Failed to start Godot process:", err);
30660
+ log.append("stderr", `${err.message}
30661
+ `);
30662
+ started.exitCode = -1;
30663
+ });
30664
+ if (started.pid !== null) {
30665
+ writeRunRecord({
30666
+ pid: started.pid,
30667
+ transcript: transcript.path,
30668
+ startedAt,
30669
+ projectPath,
30670
+ arguments: cmdArgs
30671
+ });
30672
+ }
30673
+ sweepTranscripts();
30674
+ return started;
30675
+ }
30676
+ drainTranscript(game) {
30677
+ if (game.transcript === null) {
30678
+ return;
30679
+ }
30680
+ let handle;
30681
+ try {
30682
+ handle = openSync2(game.transcript, "r");
30683
+ } catch {
30684
+ return;
30685
+ }
30686
+ try {
30687
+ const size = fstatSync(handle).size;
30688
+ if (size <= game.readOffset) {
30689
+ return;
30690
+ }
30691
+ const buffer = Buffer.alloc(size - game.readOffset);
30692
+ const read = readSync(handle, buffer, 0, buffer.length, game.readOffset);
30693
+ game.readOffset += read;
30694
+ game.log.append("transcript", buffer.subarray(0, read));
30695
+ } finally {
30696
+ closeSync(handle);
30697
+ }
30698
+ }
30699
+ adoptRecordedRun() {
30700
+ const record = readRunRecord();
30701
+ if (record === null || !this.couldBeOurs(record.projectPath)) {
30702
+ return null;
30703
+ }
30704
+ const adopted = {
30705
+ process: null,
30706
+ pid: record.pid,
30707
+ log: new GameLog,
30708
+ transcript: record.transcript,
30709
+ readOffset: 0,
30710
+ projectPath: record.projectPath === "" ? null : record.projectPath,
30711
+ startedAt: record.startedAt,
30712
+ exitCode: null,
30713
+ throughEditor: false,
30714
+ brokeOn: null
30715
+ };
30716
+ this.drainTranscript(adopted);
30717
+ if (!alive(record.pid)) {
30718
+ adopted.log.finish();
30719
+ adopted.exitCode = null;
30720
+ adopted.endedUnwatched = true;
30721
+ }
30722
+ this.activeProcess = adopted;
30723
+ return adopted;
30724
+ }
30725
+ couldBeOurs(project) {
30726
+ if (project === "") {
30727
+ return true;
30728
+ }
30729
+ const status = this.godotBridge.getStatus();
30730
+ const mine = this.ownProject ?? (status.connected ? status.projectPath ?? null : null);
30731
+ return mine === null || samePath(mine, project);
30732
+ }
30733
+ currentRun() {
30734
+ return this.activeProcess ?? this.adoptRecordedRun();
30735
+ }
30413
30736
  async checkBoot(godotPath, projectPath, scene, args, userArgs) {
30414
30737
  const frames = readPositiveNumber(args, "frames") ?? 3;
30415
30738
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 60000;
@@ -30443,37 +30766,47 @@ class GodotServer {
30443
30766
  });
30444
30767
  }
30445
30768
  handleGetDebugOutput(args) {
30446
- if (!this.activeProcess) {
30769
+ const run = this.currentRun();
30770
+ if (!run) {
30447
30771
  return this.createErrorResponse("No game is running. Start one with editor_run.");
30448
30772
  }
30449
- this.drainEditorOutput(this.activeProcess);
30773
+ this.drainEditorOutput(run);
30774
+ this.drainTranscript(run);
30775
+ if (run.endedUnwatched !== true && !stillRunning(run)) {
30776
+ run.log.finish();
30777
+ run.endedUnwatched = run.exitCode === null;
30778
+ }
30450
30779
  const severity = readString(args, "severity");
30451
- const selected = this.activeProcess.log.select({
30780
+ const selected = run.log.select({
30452
30781
  severity: severity === "error" || severity === "warning" ? severity : "info",
30453
30782
  sinceLastCall: readBoolean(args, "sinceLastCall") ?? false,
30454
30783
  contains: readNonEmptyString(args, "contains"),
30455
30784
  limit: readPositiveNumber(args, "limit") ?? 200
30456
30785
  });
30457
- const halt = this.activeProcess.throughEditor ? this.dapClient?.whereItStopped() ?? null : null;
30786
+ const halt = run.throughEditor ? this.dapClient?.whereItStopped() ?? null : null;
30458
30787
  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,
30788
+ running: stillRunning(run),
30789
+ exitCode: run.exitCode,
30790
+ through: run.throughEditor ? "editor" : "gdharness",
30791
+ pid: run.pid,
30792
+ errors: run.log.count("error"),
30793
+ warnings: run.log.count("warning"),
30794
+ clean: run.log.count("error") === 0,
30466
30795
  heldAt: halt,
30796
+ endedUnwatched: run.endedUnwatched === true ? true : undefined,
30797
+ project: !run.throughEditor && run.process === null ? run.projectPath ?? undefined : undefined,
30798
+ 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
30799
  omitted: selected.omitted,
30468
30800
  entries: selected.entries
30469
30801
  });
30470
30802
  }
30471
30803
  async handleStopProject() {
30472
- if (!this.activeProcess) {
30804
+ const stopped = this.currentRun();
30805
+ if (!stopped) {
30473
30806
  return this.createErrorResponse("No game is running. Start one with editor_run.");
30474
30807
  }
30475
- const stopped = this.activeProcess;
30476
30808
  this.drainEditorOutput(stopped);
30809
+ this.drainTranscript(stopped);
30477
30810
  this.logDebug("Stopping the running game");
30478
30811
  await this.endActiveGame();
30479
30812
  return this.jsonTextResponse({
@@ -30579,8 +30912,8 @@ class GodotServer {
30579
30912
  return this.createErrorResponse(choice.problem);
30580
30913
  }
30581
30914
  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;
30915
+ const screenshotDir = expectsScreenshot ? mkdtempSync2(join10(tmpdir4(), "gdharness-runtime-screenshot-")) : null;
30916
+ const screenshotPath = screenshotDir ? join10(screenshotDir, "capture.png") : null;
30584
30917
  try {
30585
30918
  const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs);
30586
30919
  if (!reply.ok) {
@@ -30601,12 +30934,12 @@ class GodotServer {
30601
30934
  return {
30602
30935
  content: [
30603
30936
  { type: "text", text: `Screenshot captured: ${dimensions}` },
30604
- { type: "image", data: readFileSync9(screenshotPath).toString("base64"), mimeType: "image/png" }
30937
+ { type: "image", data: readFileSync10(screenshotPath).toString("base64"), mimeType: "image/png" }
30605
30938
  ]
30606
30939
  };
30607
30940
  } finally {
30608
30941
  if (screenshotDir) {
30609
- rmSync4(screenshotDir, { recursive: true, force: true });
30942
+ rmSync5(screenshotDir, { recursive: true, force: true });
30610
30943
  }
30611
30944
  }
30612
30945
  }
@@ -30631,12 +30964,14 @@ class GodotServer {
30631
30964
  return this.createErrorResponse("runtime_inspect find needs at least one of className, script, namePattern, group, says.");
30632
30965
  }
30633
30966
  const property = readNonEmptyString(args, "property");
30967
+ const includeHidden = readBoolean(args, "includeHidden");
30634
30968
  return await this.handleRuntimeCommand("find_nodes", {
30635
30969
  ...filters,
30636
30970
  projectPath: args["projectPath"],
30637
30971
  root: readNonEmptyString(args, "nodePath") ?? "/root",
30638
30972
  limit: readPositiveNumber(args, "limit") ?? 100,
30639
- ...property === undefined ? {} : { property }
30973
+ ...property === undefined ? {} : { property },
30974
+ ...includeHidden === undefined ? {} : { include_hidden: includeHidden }
30640
30975
  });
30641
30976
  }
30642
30977
  async handleRuntimeWait(op, args) {