gdharness 0.10.1 → 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 !== "") {
@@ -26154,6 +26171,9 @@ function runArguments(options) {
26154
26171
  if (options.scene !== null) {
26155
26172
  args.push(`res://${options.scene}`);
26156
26173
  }
26174
+ if (options.userArgs !== undefined && options.userArgs.length > 0) {
26175
+ args.push("--", ...options.userArgs);
26176
+ }
26157
26177
  return args;
26158
26178
  }
26159
26179
  function editorArguments(projectPath, ports) {
@@ -26601,6 +26621,39 @@ function whyNoReport(printed, asked) {
26601
26621
  }
26602
26622
  return printed.some((line) => NOTHING_RAN.test(line)) ? `no test cases found at ${asked}` : null;
26603
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
+ }
26604
26657
 
26605
26658
  // src/lsp_client.ts
26606
26659
  import { realpathSync } from "node:fs";
@@ -26905,6 +26958,14 @@ class GodotLSPClient {
26905
26958
  }
26906
26959
  return uri;
26907
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
+ }
26908
26969
  async initialize(rootPath) {
26909
26970
  await this.ensureConnected();
26910
26971
  const resolvedRootPath = resolve2(rootPath);
@@ -26958,8 +27019,9 @@ class GodotLSPClient {
26958
27019
  timer
26959
27020
  });
26960
27021
  });
27022
+ let opened = null;
26961
27023
  try {
26962
- this.syncDocument(filePath, content);
27024
+ opened = this.syncDocument(filePath, content);
26963
27025
  return await diagnosticsPromise;
26964
27026
  } catch (error) {
26965
27027
  const waiter = this.diagnosticsWaiters.get(key);
@@ -26968,16 +27030,25 @@ class GodotLSPClient {
26968
27030
  this.diagnosticsWaiters.delete(key);
26969
27031
  }
26970
27032
  throw error;
27033
+ } finally {
27034
+ if (opened !== null) {
27035
+ this.closeDocument(opened);
27036
+ }
26971
27037
  }
26972
27038
  }
26973
27039
  async getCompletions(filePath, content, line, character) {
26974
27040
  await this.ensureConnected();
26975
27041
  await this.ensureInitializedForFile(filePath);
26976
27042
  const uri = this.syncDocument(filePath, content);
26977
- const result = await this.sendRequest("textDocument/completion", {
26978
- textDocument: { uri },
26979
- position: { line, character }
26980
- });
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
+ }
26981
27052
  if (Array.isArray(result)) {
26982
27053
  return result;
26983
27054
  }
@@ -26993,18 +27064,27 @@ class GodotLSPClient {
26993
27064
  await this.ensureConnected();
26994
27065
  await this.ensureInitializedForFile(filePath);
26995
27066
  const uri = this.syncDocument(filePath, content);
26996
- return this.sendRequest("textDocument/hover", {
26997
- textDocument: { uri },
26998
- position: { line, character }
26999
- });
27067
+ try {
27068
+ return await this.sendRequest("textDocument/hover", {
27069
+ textDocument: { uri },
27070
+ position: { line, character }
27071
+ });
27072
+ } finally {
27073
+ this.closeDocument(uri);
27074
+ }
27000
27075
  }
27001
27076
  async getDocumentSymbols(filePath, content) {
27002
27077
  await this.ensureConnected();
27003
27078
  await this.ensureInitializedForFile(filePath);
27004
27079
  const uri = this.syncDocument(filePath, content);
27005
- const result = await this.sendRequest("textDocument/documentSymbol", {
27006
- textDocument: { uri }
27007
- });
27080
+ let result;
27081
+ try {
27082
+ result = await this.sendRequest("textDocument/documentSymbol", {
27083
+ textDocument: { uri }
27084
+ });
27085
+ } finally {
27086
+ this.closeDocument(uri);
27087
+ }
27008
27088
  return Array.isArray(result) ? result : [];
27009
27089
  }
27010
27090
  isConnected() {
@@ -27412,6 +27492,10 @@ function setupResourceHandlers(mcp, getProjectPath) {
27412
27492
  });
27413
27493
  }
27414
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
+
27415
27499
  // src/runtime-client.ts
27416
27500
  import { existsSync as existsSync5, readdirSync as readdirSync4, readFileSync as readFileSync7, unlinkSync } from "node:fs";
27417
27501
  import { createConnection as createConnection3 } from "node:net";
@@ -27707,9 +27791,86 @@ function runtimeRequest(endpoint, command, params, timeoutMs) {
27707
27791
  });
27708
27792
  }
27709
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
+
27710
27871
  // src/setup.ts
27711
- import { cpSync, existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync8, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "node:fs";
27712
- 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";
27713
27874
  var ADDONS = ["gdharness_editor", "gdharness_runtime", "auto_reload"];
27714
27875
  var RUNTIME_AUTOLOAD = {
27715
27876
  name: "GdharnessRuntime",
@@ -27717,11 +27878,11 @@ var RUNTIME_AUTOLOAD = {
27717
27878
  };
27718
27879
  var VERSION_MARKER = ".gdharness-version";
27719
27880
  function installedAddonVersion(projectPath) {
27720
- const marker = join8(projectPath, "addons", ADDONS[0], VERSION_MARKER);
27881
+ const marker = join9(projectPath, "addons", ADDONS[0], VERSION_MARKER);
27721
27882
  if (!existsSync6(marker)) {
27722
27883
  return null;
27723
27884
  }
27724
- const said = readFileSync8(marker, "utf8").trim();
27885
+ const said = readFileSync9(marker, "utf8").trim();
27725
27886
  return said === "" ? null : said;
27726
27887
  }
27727
27888
 
@@ -28038,7 +28199,7 @@ var TOOL_SPECS = [
28038
28199
  },
28039
28200
  {
28040
28201
  name: "project_test",
28041
- 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.",
28042
28203
  parameters: {
28043
28204
  projectPath: PROJECT_PATH,
28044
28205
  path: {
@@ -28313,10 +28474,15 @@ var TOOL_SPECS = [
28313
28474
  },
28314
28475
  {
28315
28476
  name: "editor_run",
28316
- description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way. A start waits for the game to become something the runtime_* tools can talk to and says which it is under runtime: listening with the port it took, or why not, so the first call after a start does not have to be made twice.",
28477
+ description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. args hands the game its own flags, the ones it reads with OS.get_cmdline_user_args(), and a run carrying any is started by this server rather than by the editor. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way. A start waits for the game to become something the runtime_* tools can talk to and says which it is under runtime: listening with the port it took, or why not, so the first call after a start does not have to be made twice.",
28317
28478
  parameters: {
28318
28479
  projectPath: PROJECT_PATH,
28319
28480
  scene: { type: "string", description: "A scene to run instead of the main scene." },
28481
+ args: {
28482
+ type: "array",
28483
+ items: { type: "string" },
28484
+ description: `The game's own arguments, what OS.get_cmdline_user_args() answers, such as ["--level=2"]. The separator is added here. A run with any is started by this server rather than by the editor, which fixes the game's command line when it opens the project, so the debug_* tools do not answer for it.`
28485
+ },
28320
28486
  headless: { type: "boolean", description: "start: force a window or no window." },
28321
28487
  frames: { type: "number", description: "check: frames to run before quitting. Default 3." },
28322
28488
  timeoutMs: {
@@ -28445,8 +28611,8 @@ var TOOL_SPECS = [
28445
28611
  },
28446
28612
  includeHidden: {
28447
28613
  type: "boolean",
28448
- ops: ["text"],
28449
- 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."
28450
28616
  },
28451
28617
  metrics: {
28452
28618
  type: "array",
@@ -28463,7 +28629,7 @@ var TOOL_SPECS = [
28463
28629
  requires: []
28464
28630
  },
28465
28631
  find: {
28466
- 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",
28467
28633
  requires: []
28468
28634
  },
28469
28635
  rect: {
@@ -28771,15 +28937,24 @@ var SLOWEST_FRAME_RATE = 20;
28771
28937
  function patienceForFrames(frames, atLeast) {
28772
28938
  return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
28773
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
+ }
28774
28949
  function alive(pid) {
28775
- if (pid === undefined) {
28950
+ if (pid === undefined || pid === null) {
28776
28951
  return false;
28777
28952
  }
28778
28953
  try {
28779
28954
  process.kill(pid, 0);
28780
28955
  return true;
28781
- } catch {
28782
- return false;
28956
+ } catch (error) {
28957
+ return error.code === "EPERM";
28783
28958
  }
28784
28959
  }
28785
28960
  function isOfType(value, kind) {
@@ -28921,13 +29096,13 @@ function aboveTheRunner(entry) {
28921
29096
  }
28922
29097
  function elsewhereIn(projectPath, asked) {
28923
29098
  const wanted = asked.replace(/^res:\/\//, "").replace(/\/+$/, "");
28924
- if (wanted === "" || existsSync7(join9(projectPath, wanted))) {
29099
+ if (wanted === "" || existsSync7(join10(projectPath, wanted))) {
28925
29100
  return "";
28926
29101
  }
28927
29102
  const root = (wanted.split("/")[0] ?? "").toLowerCase();
28928
29103
  let near = [];
28929
29104
  try {
28930
- 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()));
28931
29106
  } catch {
28932
29107
  return "";
28933
29108
  }
@@ -28935,11 +29110,11 @@ function elsewhereIn(projectPath, asked) {
28935
29110
  return near.length === 0 ? "" : ` The project does have ${there}.`;
28936
29111
  }
28937
29112
  function hasMainScene(projectFile) {
28938
- const scene = parseProjectGodot(readFileSync9(projectFile, "utf8"))["application"]?.["run/main_scene"];
29113
+ const scene = parseProjectGodot(readFileSync10(projectFile, "utf8"))["application"]?.["run/main_scene"];
28939
29114
  return typeof scene === "string" && scene !== "";
28940
29115
  }
28941
29116
  function editorPlaysHeadless(projectFile) {
28942
- const runArgs = parseProjectGodot(readFileSync9(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
29117
+ const runArgs = parseProjectGodot(readFileSync10(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
28943
29118
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
28944
29119
  }
28945
29120
  function camelCased(params) {
@@ -28955,7 +29130,7 @@ function camelCased(params) {
28955
29130
  class GodotServer {
28956
29131
  mcp;
28957
29132
  locator = new GodotLocator;
28958
- operationsScript = join9(__dirname2, "godot", "operations", "godot_operations.gd");
29133
+ operationsScript = join10(__dirname2, "godot", "operations", "godot_operations.gd");
28959
29134
  godotBridge;
28960
29135
  tools = buildToolDefinitions();
28961
29136
  activeProcess = null;
@@ -29112,10 +29287,7 @@ class GodotServer {
29112
29287
  this.stopWatchingForASuccessor();
29113
29288
  withdrawBridge(this.announcedAt);
29114
29289
  this.announcedAt = null;
29115
- if (this.activeProcess) {
29116
- this.activeProcess.process?.kill();
29117
- this.activeProcess = null;
29118
- }
29290
+ this.activeProcess = null;
29119
29291
  if (this.lspClient) {
29120
29292
  try {
29121
29293
  await this.lspClient.disconnect();
@@ -29180,14 +29352,7 @@ class GodotServer {
29180
29352
  return;
29181
29353
  }
29182
29354
  this.shutdownInitiated = true;
29183
- if (this.activeProcess) {
29184
- try {
29185
- this.activeProcess.process?.kill();
29186
- } catch (error) {
29187
- console.error("[SERVER] Failed to kill the Godot process on exit:", errorMessage(error));
29188
- }
29189
- this.activeProcess = null;
29190
- }
29355
+ this.activeProcess = null;
29191
29356
  }
29192
29357
  setupToolHandlers() {
29193
29358
  this.mcp.server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: this.tools }));
@@ -29547,7 +29712,7 @@ class GodotServer {
29547
29712
  if (path === undefined) {
29548
29713
  return { ok: false, response: this.createErrorResponse("projectPath is required.") };
29549
29714
  }
29550
- const file = join9(path, "project.godot");
29715
+ const file = join10(path, "project.godot");
29551
29716
  if (!existsSync7(file)) {
29552
29717
  return {
29553
29718
  ok: false,
@@ -29558,6 +29723,21 @@ class GodotServer {
29558
29723
  }
29559
29724
  return { ok: true, value: { path, file } };
29560
29725
  }
29726
+ gameArguments(args) {
29727
+ if (args["args"] === undefined) {
29728
+ return { ok: true, value: [] };
29729
+ }
29730
+ const given = readStringArray(args, "args");
29731
+ if (given === undefined) {
29732
+ return {
29733
+ ok: false,
29734
+ response: this.createErrorResponse("args must be a list of strings.", [
29735
+ `Pass the game's own flags, such as ["--level=2"]`
29736
+ ])
29737
+ };
29738
+ }
29739
+ return { ok: true, value: given };
29740
+ }
29561
29741
  async debuggedGame() {
29562
29742
  const game = this.activeProcess;
29563
29743
  if (game !== null && game.exitCode !== null) {
@@ -29702,7 +29882,7 @@ class GodotServer {
29702
29882
  if (!engine.ok) {
29703
29883
  return engine.response;
29704
29884
  }
29705
- const application = parseProjectGodot(readFileSync9(project.value.file, "utf8"))["application"] ?? {};
29885
+ const application = parseProjectGodot(readFileSync10(project.value.file, "utf8"))["application"] ?? {};
29706
29886
  const name = application["config/name"];
29707
29887
  const mainScene = application["run/main_scene"];
29708
29888
  const info = {
@@ -29765,7 +29945,7 @@ class GodotServer {
29765
29945
  }
29766
29946
  const folder = dirname4(output.absolutePath);
29767
29947
  try {
29768
- mkdirSync4(folder, { recursive: true });
29948
+ mkdirSync5(folder, { recursive: true });
29769
29949
  } catch (error) {
29770
29950
  return this.createErrorResponse(`Cannot create ${dirname4(output.relativePath)} to export into: ${errorMessage(error)}`);
29771
29951
  }
@@ -29827,7 +30007,7 @@ class GodotServer {
29827
30007
  return contained.response;
29828
30008
  }
29829
30009
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
29830
- if (!existsSync7(join9(project.value.path, runner))) {
30010
+ if (!existsSync7(join10(project.value.path, runner))) {
29831
30011
  return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
29832
30012
  "Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
29833
30013
  ]);
@@ -29861,7 +30041,7 @@ class GodotServer {
29861
30041
  ];
29862
30042
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 600000;
29863
30043
  this.logDebug(`Running tests: ${engine.value} ${cmdArgs.join(" ")}`);
29864
- const userData = mkdtempSync2(join9(tmpdir4(), "gdharness-tests-"));
30044
+ const userData = mkdtempSync2(join10(tmpdir4(), "gdharness-tests-"));
29865
30045
  const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
29866
30046
  const hung = await new Promise((resolve) => {
29867
30047
  const timer = setTimeout(() => {
@@ -29877,26 +30057,26 @@ class GodotServer {
29877
30057
  resolve(false);
29878
30058
  });
29879
30059
  });
29880
- const reportsDir = join9(project.value.path, ".godot", "gdharness-reports");
30060
+ const reportsDir = join10(project.value.path, ".godot", "gdharness-reports");
29881
30061
  let report = null;
29882
30062
  let reportProblem = null;
29883
30063
  try {
29884
- 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))) : [];
29885
30065
  const newest = written.at(-1);
29886
30066
  if (newest !== undefined) {
29887
- report = parseJUnit(readFileSync9(join9(reportsDir, newest, "results.xml"), "utf8"));
30067
+ report = parseJUnit(readFileSync10(join10(reportsDir, newest, "results.xml"), "utf8"));
29888
30068
  }
29889
30069
  } catch (error) {
29890
30070
  reportProblem = errorMessage(error);
29891
30071
  } finally {
29892
- rmSync4(reportsDir, { recursive: true, force: true });
29893
- rmSync4(userData, { recursive: true, force: true });
30072
+ rmSync5(reportsDir, { recursive: true, force: true });
30073
+ rmSync5(userData, { recursive: true, force: true });
29894
30074
  }
29895
30075
  const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries.map(aboveTheRunner);
29896
30076
  const verdicts = {
29897
30077
  0: "passed",
29898
30078
  100: "failures",
29899
- 101: "warnings",
30079
+ 101: "warnings: orphan nodes",
29900
30080
  103: "headless not supported by this gdUnit4",
29901
30081
  104: "Godot version not supported by this gdUnit4",
29902
30082
  105: "script errors"
@@ -29928,7 +30108,17 @@ class GodotServer {
29928
30108
  };
29929
30109
  }
29930
30110
  const failed = report.suites.flatMap((suite) => suite.cases.filter((entry) => entry.status === "failed" || entry.status === "error").map((entry) => ({ ...entry, path: suite.path })));
29931
- 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);
29932
30122
  return this.jsonTextResponse({
29933
30123
  passed: !hung && exitCode === 0 && report.failures === 0 && report.errors === 0,
29934
30124
  verdict,
@@ -29939,6 +30129,9 @@ class GodotServer {
29939
30129
  skipped: report.skipped,
29940
30130
  time: report.time,
29941
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,
29942
30135
  suites: unclean.map((suite) => ({
29943
30136
  name: suite.name,
29944
30137
  path: suite.path,
@@ -29946,6 +30139,7 @@ class GodotServer {
29946
30139
  failures: suite.failures,
29947
30140
  errors: suite.errors,
29948
30141
  skipped: suite.skipped,
30142
+ orphans: orphansIn(suite.path) > 0 ? orphansIn(suite.path) : undefined,
29949
30143
  time: suite.time
29950
30144
  })),
29951
30145
  suitesPassed: report.suites.length - unclean.length,
@@ -30072,7 +30266,7 @@ class GodotServer {
30072
30266
  version: godotPath === null ? null : await this.godotVersion(godotPath)
30073
30267
  },
30074
30268
  game: {
30075
- processActive: this.activeProcess?.exitCode === null,
30269
+ processActive: stillRunning(this.currentRun()),
30076
30270
  playingInEditor: playing,
30077
30271
  runtimeConnected: games.some((game) => game.reachable),
30078
30272
  runtimes: games
@@ -30218,6 +30412,10 @@ class GodotServer {
30218
30412
  if (sceneToRun && !sceneToRun.ok) {
30219
30413
  return this.createErrorResponse(sceneToRun.reason, PATH_SOLUTIONS);
30220
30414
  }
30415
+ const given = this.gameArguments(args);
30416
+ if (!given.ok) {
30417
+ return given.response;
30418
+ }
30221
30419
  if (!sceneToRun && !hasMainScene(project.value.file)) {
30222
30420
  return this.createErrorResponse("The project sets no main scene, so there is nothing to run.", [
30223
30421
  "Pass scene to run one scene",
@@ -30234,9 +30432,9 @@ class GodotServer {
30234
30432
  }
30235
30433
  const sceneArgument = sceneToRun?.ok ? sceneToRun.relativePath : null;
30236
30434
  if (op === "check") {
30237
- return await this.checkBoot(engine.value, project.value.path, sceneArgument, args);
30435
+ return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value);
30238
30436
  }
30239
- if (this.activeProcess?.exitCode === null) {
30437
+ if (stillRunning(this.currentRun())) {
30240
30438
  this.logDebug("Ending the running game before starting another");
30241
30439
  await this.endActiveGame();
30242
30440
  }
@@ -30245,17 +30443,20 @@ class GodotServer {
30245
30443
  platform: process.platform,
30246
30444
  variables: process.env
30247
30445
  });
30248
- if (this.godotBridge.isConnected() && (!headless || editorPlaysHeadless(project.value.file))) {
30446
+ const editorWouldPlay = !headless || editorPlaysHeadless(project.value.file);
30447
+ if (this.godotBridge.isConnected() && editorWouldPlay && given.value.length === 0) {
30249
30448
  return await this.playThroughEditor(sceneArgument, refreshed.value, project.value.path, alreadyPlaying);
30250
30449
  }
30251
30450
  const cmdArgs = runArguments({
30252
30451
  projectPath: project.value.path,
30253
30452
  headless,
30254
- scene: sceneArgument
30453
+ scene: sceneArgument,
30454
+ userArgs: given.value
30255
30455
  });
30256
30456
  this.logDebug(`Running Godot project: ${engine.value} ${cmdArgs.join(" ")}`);
30257
- const started = this.spawnGame(engine.value, cmdArgs);
30457
+ const started = this.spawnKeptGame(engine.value, cmdArgs, project.value.path);
30258
30458
  this.activeProcess = started;
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." : "";
30259
30460
  return this.jsonTextResponse({
30260
30461
  started: true,
30261
30462
  through: "gdharness",
@@ -30263,11 +30464,11 @@ class GodotServer {
30263
30464
  arguments: cmdArgs,
30264
30465
  refreshedClasses: refreshed.value,
30265
30466
  runtime: await this.runtimeUp(project.value.path, alreadyPlaying),
30266
- message: "Use editor_output for what it prints and editor_run stop to end it."
30467
+ message: `Use editor_output for what it prints and editor_run stop to end it.${spawnedInstead}`
30267
30468
  });
30268
30469
  }
30269
30470
  async runtimeUp(projectPath, before) {
30270
- if (!existsSync7(join9(projectPath, RUNTIME_AUTOLOAD.path))) {
30471
+ if (!existsSync7(join10(projectPath, RUNTIME_AUTOLOAD.path))) {
30271
30472
  return {
30272
30473
  listening: false,
30273
30474
  note: "this project has no gdharness_runtime addon, so the runtime_* tools have nothing to talk to"
@@ -30302,7 +30503,11 @@ class GodotServer {
30302
30503
  }
30303
30504
  const played = {
30304
30505
  process: null,
30506
+ pid: null,
30305
30507
  log,
30508
+ transcript: null,
30509
+ readOffset: 0,
30510
+ projectPath,
30306
30511
  startedAt: Date.now(),
30307
30512
  exitCode: null,
30308
30513
  throughEditor: true,
@@ -30341,6 +30546,7 @@ class GodotServer {
30341
30546
  async endActiveGame() {
30342
30547
  const running = this.activeProcess;
30343
30548
  this.activeProcess = null;
30549
+ clearRunRecord();
30344
30550
  if (!running) {
30345
30551
  return;
30346
30552
  }
@@ -30348,14 +30554,26 @@ class GodotServer {
30348
30554
  await this.handleViaBridge("stop_playing", {});
30349
30555
  return;
30350
30556
  }
30351
- 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
+ }
30352
30566
  }
30353
30567
  spawnGame(godotPath, cmdArgs, env) {
30354
30568
  const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
30355
30569
  const log = new GameLog;
30356
30570
  const started = {
30357
30571
  process: child,
30572
+ pid: child.pid ?? null,
30358
30573
  log,
30574
+ transcript: null,
30575
+ readOffset: 0,
30576
+ projectPath: null,
30359
30577
  startedAt: Date.now(),
30360
30578
  exitCode: null,
30361
30579
  throughEditor: false,
@@ -30380,10 +30598,121 @@ class GodotServer {
30380
30598
  });
30381
30599
  return started;
30382
30600
  }
30383
- async checkBoot(godotPath, projectPath, scene, args) {
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
+ }
30712
+ async checkBoot(godotPath, projectPath, scene, args, userArgs) {
30384
30713
  const frames = readPositiveNumber(args, "frames") ?? 3;
30385
30714
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 60000;
30386
- const cmdArgs = runArguments({ projectPath, headless: true, scene, quitAfter: frames });
30715
+ const cmdArgs = runArguments({ projectPath, headless: true, scene, quitAfter: frames, userArgs });
30387
30716
  const boot = this.spawnGame(godotPath, cmdArgs);
30388
30717
  const hung = await new Promise((resolve) => {
30389
30718
  const timer = setTimeout(() => {
@@ -30413,37 +30742,47 @@ class GodotServer {
30413
30742
  });
30414
30743
  }
30415
30744
  handleGetDebugOutput(args) {
30416
- if (!this.activeProcess) {
30745
+ const run = this.currentRun();
30746
+ if (!run) {
30417
30747
  return this.createErrorResponse("No game is running. Start one with editor_run.");
30418
30748
  }
30419
- 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
+ }
30420
30755
  const severity = readString(args, "severity");
30421
- const selected = this.activeProcess.log.select({
30756
+ const selected = run.log.select({
30422
30757
  severity: severity === "error" || severity === "warning" ? severity : "info",
30423
30758
  sinceLastCall: readBoolean(args, "sinceLastCall") ?? false,
30424
30759
  contains: readNonEmptyString(args, "contains"),
30425
30760
  limit: readPositiveNumber(args, "limit") ?? 200
30426
30761
  });
30427
- const halt = this.activeProcess.throughEditor ? this.dapClient?.whereItStopped() ?? null : null;
30762
+ const halt = run.throughEditor ? this.dapClient?.whereItStopped() ?? null : null;
30428
30763
  return this.jsonTextResponse({
30429
- running: this.activeProcess.exitCode === null,
30430
- exitCode: this.activeProcess.exitCode,
30431
- through: this.activeProcess.throughEditor ? "editor" : "gdharness",
30432
- pid: this.activeProcess.process?.pid ?? null,
30433
- errors: this.activeProcess.log.count("error"),
30434
- warnings: this.activeProcess.log.count("warning"),
30435
- 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,
30436
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,
30437
30775
  omitted: selected.omitted,
30438
30776
  entries: selected.entries
30439
30777
  });
30440
30778
  }
30441
30779
  async handleStopProject() {
30442
- if (!this.activeProcess) {
30780
+ const stopped = this.currentRun();
30781
+ if (!stopped) {
30443
30782
  return this.createErrorResponse("No game is running. Start one with editor_run.");
30444
30783
  }
30445
- const stopped = this.activeProcess;
30446
30784
  this.drainEditorOutput(stopped);
30785
+ this.drainTranscript(stopped);
30447
30786
  this.logDebug("Stopping the running game");
30448
30787
  await this.endActiveGame();
30449
30788
  return this.jsonTextResponse({
@@ -30549,8 +30888,8 @@ class GodotServer {
30549
30888
  return this.createErrorResponse(choice.problem);
30550
30889
  }
30551
30890
  const expectsScreenshot = command === "capture_screenshot" || command === "capture_viewport";
30552
- const screenshotDir = expectsScreenshot ? mkdtempSync2(join9(tmpdir4(), "gdharness-runtime-screenshot-")) : null;
30553
- 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;
30554
30893
  try {
30555
30894
  const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs);
30556
30895
  if (!reply.ok) {
@@ -30571,12 +30910,12 @@ class GodotServer {
30571
30910
  return {
30572
30911
  content: [
30573
30912
  { type: "text", text: `Screenshot captured: ${dimensions}` },
30574
- { type: "image", data: readFileSync9(screenshotPath).toString("base64"), mimeType: "image/png" }
30913
+ { type: "image", data: readFileSync10(screenshotPath).toString("base64"), mimeType: "image/png" }
30575
30914
  ]
30576
30915
  };
30577
30916
  } finally {
30578
30917
  if (screenshotDir) {
30579
- rmSync4(screenshotDir, { recursive: true, force: true });
30918
+ rmSync5(screenshotDir, { recursive: true, force: true });
30580
30919
  }
30581
30920
  }
30582
30921
  }
@@ -30601,12 +30940,14 @@ class GodotServer {
30601
30940
  return this.createErrorResponse("runtime_inspect find needs at least one of className, script, namePattern, group, says.");
30602
30941
  }
30603
30942
  const property = readNonEmptyString(args, "property");
30943
+ const includeHidden = readBoolean(args, "includeHidden");
30604
30944
  return await this.handleRuntimeCommand("find_nodes", {
30605
30945
  ...filters,
30606
30946
  projectPath: args["projectPath"],
30607
30947
  root: readNonEmptyString(args, "nodePath") ?? "/root",
30608
30948
  limit: readPositiveNumber(args, "limit") ?? 100,
30609
- ...property === undefined ? {} : { property }
30949
+ ...property === undefined ? {} : { property },
30950
+ ...includeHidden === undefined ? {} : { include_hidden: includeHidden }
30610
30951
  });
30611
30952
  }
30612
30953
  async handleRuntimeWait(op, args) {