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/cli.js CHANGED
@@ -7190,8 +7190,12 @@ function announced(line) {
7190
7190
 
7191
7191
  class GameLog {
7192
7192
  entries = [];
7193
- decoders = { stdout: new StringDecoder("utf8"), stderr: new StringDecoder("utf8") };
7194
- partial = { stdout: "", stderr: "" };
7193
+ decoders = {
7194
+ stdout: new StringDecoder("utf8"),
7195
+ stderr: new StringDecoder("utf8"),
7196
+ transcript: new StringDecoder("utf8")
7197
+ };
7198
+ partial = { stdout: "", stderr: "", transcript: "" };
7195
7199
  lastHeadline = null;
7196
7200
  cursor = 0;
7197
7201
  append(source, chunk) {
@@ -7204,7 +7208,7 @@ class GameLog {
7204
7208
  }
7205
7209
  }
7206
7210
  finish() {
7207
- for (const source of ["stdout", "stderr"]) {
7211
+ for (const source of STREAMS) {
7208
7212
  const tail = this.partial[source] + this.decoders[source].end();
7209
7213
  this.partial[source] = "";
7210
7214
  if (tail !== "") {
@@ -7267,10 +7271,11 @@ class GameLog {
7267
7271
  return { entries: matching.slice(omitted), omitted };
7268
7272
  }
7269
7273
  }
7270
- var HEADLINE, COLOUR_CODE;
7274
+ var HEADLINE, COLOUR_CODE, STREAMS;
7271
7275
  var init_game_log = __esm(() => {
7272
7276
  HEADLINE = /^(USER )?(SCRIPT ERROR|ERROR|WARNING):\s?(.*)$/;
7273
7277
  COLOUR_CODE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
7278
+ STREAMS = ["stdout", "stderr", "transcript"];
7274
7279
  });
7275
7280
 
7276
7281
  // src/update-check.ts
@@ -15408,7 +15413,7 @@ var init_tool_definitions = __esm(() => {
15408
15413
  },
15409
15414
  {
15410
15415
  name: "project_test",
15411
- 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.",
15416
+ 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.",
15412
15417
  parameters: {
15413
15418
  projectPath: PROJECT_PATH,
15414
15419
  path: {
@@ -15820,8 +15825,8 @@ var init_tool_definitions = __esm(() => {
15820
15825
  },
15821
15826
  includeHidden: {
15822
15827
  type: "boolean",
15823
- ops: ["text"],
15824
- description: "text: read hidden nodes as well, for checking that something is not showing. Default false."
15828
+ ops: ["text", "find"],
15829
+ 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."
15825
15830
  },
15826
15831
  metrics: {
15827
15832
  type: "array",
@@ -15838,7 +15843,7 @@ var init_tool_definitions = __esm(() => {
15838
15843
  requires: []
15839
15844
  },
15840
15845
  find: {
15841
- summary: "the paths of every node matching className, script, namePattern, group or says, with property read off each",
15846
+ 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",
15842
15847
  requires: []
15843
15848
  },
15844
15849
  rect: {
@@ -35168,7 +35173,37 @@ function whyNoReport(printed, asked) {
35168
35173
  }
35169
35174
  return printed.some((line) => NOTHING_RAN.test(line)) ? `no test cases found at ${asked}` : null;
35170
35175
  }
35171
- var MalformedReportError, ENTITIES, ATTRIBUTE, NOTHING_RAN, NO_SUCH_PATH;
35176
+ function orphansPrinted(printed) {
35177
+ const suites = [];
35178
+ let total = null;
35179
+ let running = null;
35180
+ for (const line of printed) {
35181
+ const started = RUNNING_SUITE.exec(line);
35182
+ if (started?.[1] !== undefined) {
35183
+ running = started[1];
35184
+ continue;
35185
+ }
35186
+ const counted = ORPHANS.exec(line);
35187
+ if (counted?.[1] === undefined) {
35188
+ continue;
35189
+ }
35190
+ const orphans = Number(counted[1]);
35191
+ if (line.includes(OVERALL)) {
35192
+ total = orphans;
35193
+ running = null;
35194
+ continue;
35195
+ }
35196
+ if (running !== null && orphans > 0) {
35197
+ suites.push({ path: running, orphans });
35198
+ }
35199
+ running = null;
35200
+ }
35201
+ return {
35202
+ total: total ?? suites.reduce((sum, suite) => sum + suite.orphans, 0),
35203
+ suites
35204
+ };
35205
+ }
35206
+ var MalformedReportError, ENTITIES, ATTRIBUTE, NOTHING_RAN, NO_SUCH_PATH, RUNNING_SUITE, ORPHANS, OVERALL = "Overall Summary";
35172
35207
  var init_junit = __esm(() => {
35173
35208
  MalformedReportError = class MalformedReportError extends Error {
35174
35209
  constructor(message) {
@@ -35180,6 +35215,8 @@ var init_junit = __esm(() => {
35180
35215
  ATTRIBUTE = /\s*([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/y;
35181
35216
  NOTHING_RAN = /no test cases found/i;
35182
35217
  NO_SUCH_PATH = /given directory or file does not exists:\s*(\S+)/i;
35218
+ RUNNING_SUITE = /Run Test Suite:\s*(\S+)/;
35219
+ ORPHANS = /(\d+)\s+orphans/;
35183
35220
  });
35184
35221
 
35185
35222
  // src/lsp_client.ts
@@ -35483,6 +35520,14 @@ class GodotLSPClient {
35483
35520
  }
35484
35521
  return uri;
35485
35522
  }
35523
+ closeDocument(uri) {
35524
+ if (!this.documentVersions.delete(uri)) {
35525
+ return;
35526
+ }
35527
+ try {
35528
+ this.sendNotification("textDocument/didClose", { textDocument: { uri } });
35529
+ } catch {}
35530
+ }
35486
35531
  async initialize(rootPath) {
35487
35532
  await this.ensureConnected();
35488
35533
  const resolvedRootPath = resolve3(rootPath);
@@ -35536,8 +35581,9 @@ class GodotLSPClient {
35536
35581
  timer
35537
35582
  });
35538
35583
  });
35584
+ let opened = null;
35539
35585
  try {
35540
- this.syncDocument(filePath, content);
35586
+ opened = this.syncDocument(filePath, content);
35541
35587
  return await diagnosticsPromise;
35542
35588
  } catch (error) {
35543
35589
  const waiter = this.diagnosticsWaiters.get(key);
@@ -35546,16 +35592,25 @@ class GodotLSPClient {
35546
35592
  this.diagnosticsWaiters.delete(key);
35547
35593
  }
35548
35594
  throw error;
35595
+ } finally {
35596
+ if (opened !== null) {
35597
+ this.closeDocument(opened);
35598
+ }
35549
35599
  }
35550
35600
  }
35551
35601
  async getCompletions(filePath, content, line, character) {
35552
35602
  await this.ensureConnected();
35553
35603
  await this.ensureInitializedForFile(filePath);
35554
35604
  const uri = this.syncDocument(filePath, content);
35555
- const result = await this.sendRequest("textDocument/completion", {
35556
- textDocument: { uri },
35557
- position: { line, character }
35558
- });
35605
+ let result;
35606
+ try {
35607
+ result = await this.sendRequest("textDocument/completion", {
35608
+ textDocument: { uri },
35609
+ position: { line, character }
35610
+ });
35611
+ } finally {
35612
+ this.closeDocument(uri);
35613
+ }
35559
35614
  if (Array.isArray(result)) {
35560
35615
  return result;
35561
35616
  }
@@ -35571,18 +35626,27 @@ class GodotLSPClient {
35571
35626
  await this.ensureConnected();
35572
35627
  await this.ensureInitializedForFile(filePath);
35573
35628
  const uri = this.syncDocument(filePath, content);
35574
- return this.sendRequest("textDocument/hover", {
35575
- textDocument: { uri },
35576
- position: { line, character }
35577
- });
35629
+ try {
35630
+ return await this.sendRequest("textDocument/hover", {
35631
+ textDocument: { uri },
35632
+ position: { line, character }
35633
+ });
35634
+ } finally {
35635
+ this.closeDocument(uri);
35636
+ }
35578
35637
  }
35579
35638
  async getDocumentSymbols(filePath, content) {
35580
35639
  await this.ensureConnected();
35581
35640
  await this.ensureInitializedForFile(filePath);
35582
35641
  const uri = this.syncDocument(filePath, content);
35583
- const result = await this.sendRequest("textDocument/documentSymbol", {
35584
- textDocument: { uri }
35585
- });
35642
+ let result;
35643
+ try {
35644
+ result = await this.sendRequest("textDocument/documentSymbol", {
35645
+ textDocument: { uri }
35646
+ });
35647
+ } finally {
35648
+ this.closeDocument(uri);
35649
+ }
35586
35650
  return Array.isArray(result) ? result : [];
35587
35651
  }
35588
35652
  isConnected() {
@@ -36024,15 +36088,97 @@ var init_runtime_client = __esm(() => {
36024
36088
  ANNOUNCEMENT_PATTERN = /^runtime-(\d+)\.json$/;
36025
36089
  });
36026
36090
 
36091
+ // src/run-record.ts
36092
+ import { mkdirSync as mkdirSync6, openSync, readdirSync as readdirSync6, readFileSync as readFileSync12, rmSync as rmSync7, statSync as statSync2, writeFileSync as writeFileSync8 } from "node:fs";
36093
+ import { join as join14 } from "node:path";
36094
+ function runsDirectory() {
36095
+ return join14(runtimeDirectory(), "runs");
36096
+ }
36097
+ function recordPath() {
36098
+ return join14(runsDirectory(), "run.json");
36099
+ }
36100
+ function openTranscript(startedAt) {
36101
+ mkdirSync6(runsDirectory(), { recursive: true });
36102
+ const path = join14(runsDirectory(), `run-${startedAt}.log`);
36103
+ return { path, fd: openSync(path, "a") };
36104
+ }
36105
+ function writeRunRecord(record) {
36106
+ mkdirSync6(runsDirectory(), { recursive: true });
36107
+ writeFileSync8(recordPath(), JSON.stringify(record, null, 2), "utf8");
36108
+ }
36109
+ function readRunRecord() {
36110
+ let parsed;
36111
+ try {
36112
+ parsed = JSON.parse(readFileSync12(recordPath(), "utf8"));
36113
+ } catch {
36114
+ return null;
36115
+ }
36116
+ if (!parsed || typeof parsed !== "object") {
36117
+ return null;
36118
+ }
36119
+ const fields = parsed;
36120
+ const pid = fields["pid"];
36121
+ const transcript = fields["transcript"];
36122
+ const startedAt = fields["startedAt"];
36123
+ if (typeof pid !== "number" || typeof transcript !== "string" || typeof startedAt !== "number") {
36124
+ return null;
36125
+ }
36126
+ const args = fields["arguments"];
36127
+ return {
36128
+ pid,
36129
+ transcript,
36130
+ startedAt,
36131
+ projectPath: typeof fields["projectPath"] === "string" ? fields["projectPath"] : "",
36132
+ arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : []
36133
+ };
36134
+ }
36135
+ function clearRunRecord() {
36136
+ try {
36137
+ rmSync7(recordPath());
36138
+ } catch {}
36139
+ }
36140
+ function ageMs(path, now) {
36141
+ try {
36142
+ return now - statSync2(path).mtimeMs;
36143
+ } catch {
36144
+ return 0;
36145
+ }
36146
+ }
36147
+ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
36148
+ const directory = runsDirectory();
36149
+ const keep = readRunRecord()?.transcript;
36150
+ let entries;
36151
+ try {
36152
+ entries = readdirSync6(directory);
36153
+ } catch {
36154
+ return;
36155
+ }
36156
+ for (const entry of entries) {
36157
+ if (!entry.startsWith("run-") || !entry.endsWith(".log")) {
36158
+ continue;
36159
+ }
36160
+ const path = join14(directory, entry);
36161
+ if (path === keep || ageMs(path, now) < keepMs) {
36162
+ continue;
36163
+ }
36164
+ try {
36165
+ rmSync7(path);
36166
+ } catch {}
36167
+ }
36168
+ }
36169
+ var init_run_record = __esm(() => {
36170
+ init_runtime_client();
36171
+ });
36172
+
36027
36173
  // src/setup.ts
36028
- import { cpSync as cpSync2, existsSync as existsSync11, mkdirSync as mkdirSync6, readFileSync as readFileSync12, rmSync as rmSync7, writeFileSync as writeFileSync8 } from "node:fs";
36029
- import { dirname as dirname8, join as join14 } from "node:path";
36174
+ import { cpSync as cpSync2, existsSync as existsSync11, mkdirSync as mkdirSync7, readFileSync as readFileSync13, rmSync as rmSync8, writeFileSync as writeFileSync9 } from "node:fs";
36175
+ import { dirname as dirname8, join as join15 } from "node:path";
36030
36176
  function installedAddonVersion(projectPath) {
36031
- const marker = join14(projectPath, "addons", ADDONS2[0], VERSION_MARKER2);
36177
+ const marker = join15(projectPath, "addons", ADDONS2[0], VERSION_MARKER2);
36032
36178
  if (!existsSync11(marker)) {
36033
36179
  return null;
36034
36180
  }
36035
- const said = readFileSync12(marker, "utf8").trim();
36181
+ const said = readFileSync13(marker, "utf8").trim();
36036
36182
  return said === "" ? null : said;
36037
36183
  }
36038
36184
  var ADDONS2, RUNTIME_AUTOLOAD2, VERSION_MARKER2 = ".gdharness-version";
@@ -36050,24 +36196,45 @@ var init_setup = __esm(() => {
36050
36196
 
36051
36197
  // src/server.ts
36052
36198
  import { execFile as execFile5, spawn } from "node:child_process";
36053
- import { existsSync as existsSync12, mkdirSync as mkdirSync7, mkdtempSync as mkdtempSync3, readdirSync as readdirSync6, readFileSync as readFileSync13, realpathSync as realpathSync2, rmSync as rmSync8 } from "node:fs";
36199
+ import {
36200
+ closeSync,
36201
+ existsSync as existsSync12,
36202
+ fstatSync,
36203
+ mkdirSync as mkdirSync8,
36204
+ mkdtempSync as mkdtempSync3,
36205
+ openSync as openSync2,
36206
+ readdirSync as readdirSync7,
36207
+ readFileSync as readFileSync14,
36208
+ readSync,
36209
+ realpathSync as realpathSync2,
36210
+ rmSync as rmSync9
36211
+ } from "node:fs";
36054
36212
  import { tmpdir as tmpdir5 } from "node:os";
36055
- import { basename as basename2, dirname as dirname9, join as join15, normalize as normalize3 } from "node:path";
36213
+ import { basename as basename2, dirname as dirname9, join as join16, normalize as normalize3 } from "node:path";
36056
36214
  import { setTimeout as delay3 } from "node:timers/promises";
36057
36215
  import { fileURLToPath as fileURLToPath4 } from "node:url";
36058
36216
  import { promisify as promisify5 } from "node:util";
36059
36217
  function patienceForFrames(frames, atLeast) {
36060
36218
  return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
36061
36219
  }
36220
+ function stillRunning(run) {
36221
+ if (run?.exitCode !== null || run.endedUnwatched === true) {
36222
+ return false;
36223
+ }
36224
+ if (!run.throughEditor && run.process === null && run.pid !== null) {
36225
+ return alive(run.pid);
36226
+ }
36227
+ return true;
36228
+ }
36062
36229
  function alive(pid) {
36063
- if (pid === undefined) {
36230
+ if (pid === undefined || pid === null) {
36064
36231
  return false;
36065
36232
  }
36066
36233
  try {
36067
36234
  process.kill(pid, 0);
36068
36235
  return true;
36069
- } catch {
36070
- return false;
36236
+ } catch (error) {
36237
+ return error.code === "EPERM";
36071
36238
  }
36072
36239
  }
36073
36240
  function isOfType(value, kind) {
@@ -36147,13 +36314,13 @@ function aboveTheRunner(entry) {
36147
36314
  }
36148
36315
  function elsewhereIn(projectPath, asked) {
36149
36316
  const wanted = asked.replace(/^res:\/\//, "").replace(/\/+$/, "");
36150
- if (wanted === "" || existsSync12(join15(projectPath, wanted))) {
36317
+ if (wanted === "" || existsSync12(join16(projectPath, wanted))) {
36151
36318
  return "";
36152
36319
  }
36153
36320
  const root = (wanted.split("/")[0] ?? "").toLowerCase();
36154
36321
  let near = [];
36155
36322
  try {
36156
- 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()));
36323
+ near = readdirSync7(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()));
36157
36324
  } catch {
36158
36325
  return "";
36159
36326
  }
@@ -36161,11 +36328,11 @@ function elsewhereIn(projectPath, asked) {
36161
36328
  return near.length === 0 ? "" : ` The project does have ${there}.`;
36162
36329
  }
36163
36330
  function hasMainScene(projectFile) {
36164
- const scene = parseProjectGodot(readFileSync13(projectFile, "utf8"))["application"]?.["run/main_scene"];
36331
+ const scene = parseProjectGodot(readFileSync14(projectFile, "utf8"))["application"]?.["run/main_scene"];
36165
36332
  return typeof scene === "string" && scene !== "";
36166
36333
  }
36167
36334
  function editorPlaysHeadless(projectFile) {
36168
- const runArgs = parseProjectGodot(readFileSync13(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
36335
+ const runArgs = parseProjectGodot(readFileSync14(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
36169
36336
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
36170
36337
  }
36171
36338
  function camelCased(params) {
@@ -36181,7 +36348,7 @@ function camelCased(params) {
36181
36348
  class GodotServer {
36182
36349
  mcp;
36183
36350
  locator = new GodotLocator2;
36184
- operationsScript = join15(__dirname2, "godot", "operations", "godot_operations.gd");
36351
+ operationsScript = join16(__dirname2, "godot", "operations", "godot_operations.gd");
36185
36352
  godotBridge;
36186
36353
  tools = buildToolDefinitions();
36187
36354
  activeProcess = null;
@@ -36338,10 +36505,7 @@ class GodotServer {
36338
36505
  this.stopWatchingForASuccessor();
36339
36506
  withdrawBridge(this.announcedAt);
36340
36507
  this.announcedAt = null;
36341
- if (this.activeProcess) {
36342
- this.activeProcess.process?.kill();
36343
- this.activeProcess = null;
36344
- }
36508
+ this.activeProcess = null;
36345
36509
  if (this.lspClient) {
36346
36510
  try {
36347
36511
  await this.lspClient.disconnect();
@@ -36406,14 +36570,7 @@ class GodotServer {
36406
36570
  return;
36407
36571
  }
36408
36572
  this.shutdownInitiated = true;
36409
- if (this.activeProcess) {
36410
- try {
36411
- this.activeProcess.process?.kill();
36412
- } catch (error) {
36413
- console.error("[SERVER] Failed to kill the Godot process on exit:", errorMessage(error));
36414
- }
36415
- this.activeProcess = null;
36416
- }
36573
+ this.activeProcess = null;
36417
36574
  }
36418
36575
  setupToolHandlers() {
36419
36576
  this.mcp.server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: this.tools }));
@@ -36773,7 +36930,7 @@ class GodotServer {
36773
36930
  if (path === undefined) {
36774
36931
  return { ok: false, response: this.createErrorResponse("projectPath is required.") };
36775
36932
  }
36776
- const file = join15(path, "project.godot");
36933
+ const file = join16(path, "project.godot");
36777
36934
  if (!existsSync12(file)) {
36778
36935
  return {
36779
36936
  ok: false,
@@ -36943,7 +37100,7 @@ class GodotServer {
36943
37100
  if (!engine.ok) {
36944
37101
  return engine.response;
36945
37102
  }
36946
- const application = parseProjectGodot(readFileSync13(project.value.file, "utf8"))["application"] ?? {};
37103
+ const application = parseProjectGodot(readFileSync14(project.value.file, "utf8"))["application"] ?? {};
36947
37104
  const name = application["config/name"];
36948
37105
  const mainScene = application["run/main_scene"];
36949
37106
  const info = {
@@ -37006,7 +37163,7 @@ class GodotServer {
37006
37163
  }
37007
37164
  const folder = dirname9(output.absolutePath);
37008
37165
  try {
37009
- mkdirSync7(folder, { recursive: true });
37166
+ mkdirSync8(folder, { recursive: true });
37010
37167
  } catch (error) {
37011
37168
  return this.createErrorResponse(`Cannot create ${dirname9(output.relativePath)} to export into: ${errorMessage(error)}`);
37012
37169
  }
@@ -37068,7 +37225,7 @@ class GodotServer {
37068
37225
  return contained.response;
37069
37226
  }
37070
37227
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
37071
- if (!existsSync12(join15(project.value.path, runner))) {
37228
+ if (!existsSync12(join16(project.value.path, runner))) {
37072
37229
  return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
37073
37230
  "Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
37074
37231
  ]);
@@ -37102,7 +37259,7 @@ class GodotServer {
37102
37259
  ];
37103
37260
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 600000;
37104
37261
  this.logDebug(`Running tests: ${engine.value} ${cmdArgs.join(" ")}`);
37105
- const userData = mkdtempSync3(join15(tmpdir5(), "gdharness-tests-"));
37262
+ const userData = mkdtempSync3(join16(tmpdir5(), "gdharness-tests-"));
37106
37263
  const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
37107
37264
  const hung = await new Promise((resolve) => {
37108
37265
  const timer = setTimeout(() => {
@@ -37118,26 +37275,26 @@ class GodotServer {
37118
37275
  resolve(false);
37119
37276
  });
37120
37277
  });
37121
- const reportsDir = join15(project.value.path, ".godot", "gdharness-reports");
37278
+ const reportsDir = join16(project.value.path, ".godot", "gdharness-reports");
37122
37279
  let report = null;
37123
37280
  let reportProblem = null;
37124
37281
  try {
37125
- const written = existsSync12(reportsDir) ? readdirSync6(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
37282
+ const written = existsSync12(reportsDir) ? readdirSync7(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
37126
37283
  const newest = written.at(-1);
37127
37284
  if (newest !== undefined) {
37128
- report = parseJUnit(readFileSync13(join15(reportsDir, newest, "results.xml"), "utf8"));
37285
+ report = parseJUnit(readFileSync14(join16(reportsDir, newest, "results.xml"), "utf8"));
37129
37286
  }
37130
37287
  } catch (error) {
37131
37288
  reportProblem = errorMessage(error);
37132
37289
  } finally {
37133
- rmSync8(reportsDir, { recursive: true, force: true });
37134
- rmSync8(userData, { recursive: true, force: true });
37290
+ rmSync9(reportsDir, { recursive: true, force: true });
37291
+ rmSync9(userData, { recursive: true, force: true });
37135
37292
  }
37136
37293
  const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries.map(aboveTheRunner);
37137
37294
  const verdicts = {
37138
37295
  0: "passed",
37139
37296
  100: "failures",
37140
- 101: "warnings",
37297
+ 101: "warnings: orphan nodes",
37141
37298
  103: "headless not supported by this gdUnit4",
37142
37299
  104: "Godot version not supported by this gdUnit4",
37143
37300
  105: "script errors"
@@ -37169,7 +37326,17 @@ class GodotServer {
37169
37326
  };
37170
37327
  }
37171
37328
  const failed = report.suites.flatMap((suite) => suite.cases.filter((entry) => entry.status === "failed" || entry.status === "error").map((entry) => ({ ...entry, path: suite.path })));
37172
- const unclean = report.suites.filter((suite) => suite.failures > 0 || suite.errors > 0 || suite.skipped > 0);
37329
+ const orphans = orphansPrinted(said);
37330
+ const leftBehind = new Map(orphans.suites.map((suite) => [suite.path, suite.orphans]));
37331
+ const orphansIn = (path) => path === null ? 0 : leftBehind.get(path) ?? 0;
37332
+ const warnings = orphans.suites.map((suite) => ({
37333
+ kind: "orphans",
37334
+ path: suite.path,
37335
+ name: report.suites.find((entry) => entry.path === suite.path)?.name ?? suite.path,
37336
+ orphans: suite.orphans,
37337
+ 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().`
37338
+ }));
37339
+ const unclean = report.suites.filter((suite) => suite.failures > 0 || suite.errors > 0 || suite.skipped > 0 || orphansIn(suite.path) > 0);
37173
37340
  return this.jsonTextResponse({
37174
37341
  passed: !hung && exitCode === 0 && report.failures === 0 && report.errors === 0,
37175
37342
  verdict,
@@ -37180,6 +37347,9 @@ class GodotServer {
37180
37347
  skipped: report.skipped,
37181
37348
  time: report.time,
37182
37349
  failed,
37350
+ warnings: warnings.length > 0 ? warnings : undefined,
37351
+ orphans: orphans.total > 0 ? orphans.total : undefined,
37352
+ 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,
37183
37353
  suites: unclean.map((suite) => ({
37184
37354
  name: suite.name,
37185
37355
  path: suite.path,
@@ -37187,6 +37357,7 @@ class GodotServer {
37187
37357
  failures: suite.failures,
37188
37358
  errors: suite.errors,
37189
37359
  skipped: suite.skipped,
37360
+ orphans: orphansIn(suite.path) > 0 ? orphansIn(suite.path) : undefined,
37190
37361
  time: suite.time
37191
37362
  })),
37192
37363
  suitesPassed: report.suites.length - unclean.length,
@@ -37313,7 +37484,7 @@ class GodotServer {
37313
37484
  version: godotPath === null ? null : await this.godotVersion(godotPath)
37314
37485
  },
37315
37486
  game: {
37316
- processActive: this.activeProcess?.exitCode === null,
37487
+ processActive: stillRunning(this.currentRun()),
37317
37488
  playingInEditor: playing,
37318
37489
  runtimeConnected: games.some((game) => game.reachable),
37319
37490
  runtimes: games
@@ -37481,7 +37652,7 @@ class GodotServer {
37481
37652
  if (op === "check") {
37482
37653
  return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value);
37483
37654
  }
37484
- if (this.activeProcess?.exitCode === null) {
37655
+ if (stillRunning(this.currentRun())) {
37485
37656
  this.logDebug("Ending the running game before starting another");
37486
37657
  await this.endActiveGame();
37487
37658
  }
@@ -37501,7 +37672,7 @@ class GodotServer {
37501
37672
  userArgs: given.value
37502
37673
  });
37503
37674
  this.logDebug(`Running Godot project: ${engine.value} ${cmdArgs.join(" ")}`);
37504
- const started = this.spawnGame(engine.value, cmdArgs);
37675
+ const started = this.spawnKeptGame(engine.value, cmdArgs, project.value.path);
37505
37676
  this.activeProcess = started;
37506
37677
  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." : "";
37507
37678
  return this.jsonTextResponse({
@@ -37515,7 +37686,7 @@ class GodotServer {
37515
37686
  });
37516
37687
  }
37517
37688
  async runtimeUp(projectPath, before) {
37518
- if (!existsSync12(join15(projectPath, RUNTIME_AUTOLOAD2.path))) {
37689
+ if (!existsSync12(join16(projectPath, RUNTIME_AUTOLOAD2.path))) {
37519
37690
  return {
37520
37691
  listening: false,
37521
37692
  note: "this project has no gdharness_runtime addon, so the runtime_* tools have nothing to talk to"
@@ -37550,7 +37721,11 @@ class GodotServer {
37550
37721
  }
37551
37722
  const played = {
37552
37723
  process: null,
37724
+ pid: null,
37553
37725
  log,
37726
+ transcript: null,
37727
+ readOffset: 0,
37728
+ projectPath,
37554
37729
  startedAt: Date.now(),
37555
37730
  exitCode: null,
37556
37731
  throughEditor: true,
@@ -37589,6 +37764,7 @@ class GodotServer {
37589
37764
  async endActiveGame() {
37590
37765
  const running = this.activeProcess;
37591
37766
  this.activeProcess = null;
37767
+ clearRunRecord();
37592
37768
  if (!running) {
37593
37769
  return;
37594
37770
  }
@@ -37596,14 +37772,26 @@ class GodotServer {
37596
37772
  await this.handleViaBridge("stop_playing", {});
37597
37773
  return;
37598
37774
  }
37599
- running.process?.kill();
37775
+ if (running.process !== null) {
37776
+ running.process.kill();
37777
+ return;
37778
+ }
37779
+ if (running.pid !== null) {
37780
+ try {
37781
+ process.kill(running.pid);
37782
+ } catch {}
37783
+ }
37600
37784
  }
37601
37785
  spawnGame(godotPath, cmdArgs, env) {
37602
37786
  const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
37603
37787
  const log = new GameLog;
37604
37788
  const started = {
37605
37789
  process: child,
37790
+ pid: child.pid ?? null,
37606
37791
  log,
37792
+ transcript: null,
37793
+ readOffset: 0,
37794
+ projectPath: null,
37607
37795
  startedAt: Date.now(),
37608
37796
  exitCode: null,
37609
37797
  throughEditor: false,
@@ -37628,6 +37816,117 @@ class GodotServer {
37628
37816
  });
37629
37817
  return started;
37630
37818
  }
37819
+ spawnKeptGame(godotPath, cmdArgs, projectPath, env) {
37820
+ const startedAt = Date.now();
37821
+ const transcript = openTranscript(startedAt);
37822
+ let child;
37823
+ try {
37824
+ child = spawn(godotPath, cmdArgs, {
37825
+ stdio: ["ignore", transcript.fd, transcript.fd],
37826
+ detached: true,
37827
+ ...env ? { env } : {}
37828
+ });
37829
+ } finally {
37830
+ closeSync(transcript.fd);
37831
+ }
37832
+ child.unref();
37833
+ const log = new GameLog;
37834
+ const started = {
37835
+ process: child,
37836
+ pid: child.pid ?? null,
37837
+ log,
37838
+ transcript: transcript.path,
37839
+ readOffset: 0,
37840
+ projectPath,
37841
+ startedAt,
37842
+ exitCode: null,
37843
+ throughEditor: false,
37844
+ brokeOn: null
37845
+ };
37846
+ child.on("exit", (code) => {
37847
+ this.logDebug(`Godot process exited with code ${code ?? "none"}`);
37848
+ this.drainTranscript(started);
37849
+ log.finish();
37850
+ started.exitCode = code ?? -1;
37851
+ });
37852
+ child.on("error", (err) => {
37853
+ console.error("Failed to start Godot process:", err);
37854
+ log.append("stderr", `${err.message}
37855
+ `);
37856
+ started.exitCode = -1;
37857
+ });
37858
+ if (started.pid !== null) {
37859
+ writeRunRecord({
37860
+ pid: started.pid,
37861
+ transcript: transcript.path,
37862
+ startedAt,
37863
+ projectPath,
37864
+ arguments: cmdArgs
37865
+ });
37866
+ }
37867
+ sweepTranscripts();
37868
+ return started;
37869
+ }
37870
+ drainTranscript(game) {
37871
+ if (game.transcript === null) {
37872
+ return;
37873
+ }
37874
+ let handle;
37875
+ try {
37876
+ handle = openSync2(game.transcript, "r");
37877
+ } catch {
37878
+ return;
37879
+ }
37880
+ try {
37881
+ const size = fstatSync(handle).size;
37882
+ if (size <= game.readOffset) {
37883
+ return;
37884
+ }
37885
+ const buffer = Buffer.alloc(size - game.readOffset);
37886
+ const read = readSync(handle, buffer, 0, buffer.length, game.readOffset);
37887
+ game.readOffset += read;
37888
+ game.log.append("transcript", buffer.subarray(0, read));
37889
+ } finally {
37890
+ closeSync(handle);
37891
+ }
37892
+ }
37893
+ adoptRecordedRun() {
37894
+ const record = readRunRecord();
37895
+ if (record === null || !this.couldBeOurs(record.projectPath)) {
37896
+ return null;
37897
+ }
37898
+ const adopted = {
37899
+ process: null,
37900
+ pid: record.pid,
37901
+ log: new GameLog,
37902
+ transcript: record.transcript,
37903
+ readOffset: 0,
37904
+ projectPath: record.projectPath === "" ? null : record.projectPath,
37905
+ startedAt: record.startedAt,
37906
+ exitCode: null,
37907
+ throughEditor: false,
37908
+ brokeOn: null
37909
+ };
37910
+ this.drainTranscript(adopted);
37911
+ if (!alive(record.pid)) {
37912
+ adopted.log.finish();
37913
+ adopted.exitCode = null;
37914
+ adopted.endedUnwatched = true;
37915
+ }
37916
+ this.activeProcess = adopted;
37917
+ return adopted;
37918
+ }
37919
+ couldBeOurs(project) {
37920
+ if (project === "") {
37921
+ return true;
37922
+ }
37923
+ const status = this.godotBridge.getStatus();
37924
+ const mine = this.ownProject ?? (status.connected ? status.projectPath ?? null : null);
37925
+ return mine === null || samePath(mine, project);
37926
+ }
37927
+ currentRun() {
37928
+ return this.activeProcess ?? this.adoptRecordedRun();
37929
+ }
37631
37930
  async checkBoot(godotPath, projectPath, scene, args, userArgs) {
37632
37931
  const frames = readPositiveNumber(args, "frames") ?? 3;
37633
37932
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 60000;
@@ -37661,37 +37960,47 @@ class GodotServer {
37661
37960
  });
37662
37961
  }
37663
37962
  handleGetDebugOutput(args) {
37664
- if (!this.activeProcess) {
37963
+ const run = this.currentRun();
37964
+ if (!run) {
37665
37965
  return this.createErrorResponse("No game is running. Start one with editor_run.");
37666
37966
  }
37667
- this.drainEditorOutput(this.activeProcess);
37967
+ this.drainEditorOutput(run);
37968
+ this.drainTranscript(run);
37969
+ if (run.endedUnwatched !== true && !stillRunning(run)) {
37970
+ run.log.finish();
37971
+ run.endedUnwatched = run.exitCode === null;
37972
+ }
37668
37973
  const severity = readString(args, "severity");
37669
- const selected = this.activeProcess.log.select({
37974
+ const selected = run.log.select({
37670
37975
  severity: severity === "error" || severity === "warning" ? severity : "info",
37671
37976
  sinceLastCall: readBoolean(args, "sinceLastCall") ?? false,
37672
37977
  contains: readNonEmptyString(args, "contains"),
37673
37978
  limit: readPositiveNumber(args, "limit") ?? 200
37674
37979
  });
37675
- const halt = this.activeProcess.throughEditor ? this.dapClient?.whereItStopped() ?? null : null;
37980
+ const halt = run.throughEditor ? this.dapClient?.whereItStopped() ?? null : null;
37676
37981
  return this.jsonTextResponse({
37677
- running: this.activeProcess.exitCode === null,
37678
- exitCode: this.activeProcess.exitCode,
37679
- through: this.activeProcess.throughEditor ? "editor" : "gdharness",
37680
- pid: this.activeProcess.process?.pid ?? null,
37681
- errors: this.activeProcess.log.count("error"),
37682
- warnings: this.activeProcess.log.count("warning"),
37683
- clean: this.activeProcess.log.count("error") === 0,
37982
+ running: stillRunning(run),
37983
+ exitCode: run.exitCode,
37984
+ through: run.throughEditor ? "editor" : "gdharness",
37985
+ pid: run.pid,
37986
+ errors: run.log.count("error"),
37987
+ warnings: run.log.count("warning"),
37988
+ clean: run.log.count("error") === 0,
37684
37989
  heldAt: halt,
37990
+ endedUnwatched: run.endedUnwatched === true ? true : undefined,
37991
+ project: !run.throughEditor && run.process === null ? run.projectPath ?? undefined : undefined,
37992
+ 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,
37685
37993
  omitted: selected.omitted,
37686
37994
  entries: selected.entries
37687
37995
  });
37688
37996
  }
37689
37997
  async handleStopProject() {
37690
- if (!this.activeProcess) {
37998
+ const stopped = this.currentRun();
37999
+ if (!stopped) {
37691
38000
  return this.createErrorResponse("No game is running. Start one with editor_run.");
37692
38001
  }
37693
- const stopped = this.activeProcess;
37694
38002
  this.drainEditorOutput(stopped);
38003
+ this.drainTranscript(stopped);
37695
38004
  this.logDebug("Stopping the running game");
37696
38005
  await this.endActiveGame();
37697
38006
  return this.jsonTextResponse({
@@ -37797,8 +38106,8 @@ class GodotServer {
37797
38106
  return this.createErrorResponse(choice.problem);
37798
38107
  }
37799
38108
  const expectsScreenshot = command === "capture_screenshot" || command === "capture_viewport";
37800
- const screenshotDir = expectsScreenshot ? mkdtempSync3(join15(tmpdir5(), "gdharness-runtime-screenshot-")) : null;
37801
- const screenshotPath = screenshotDir ? join15(screenshotDir, "capture.png") : null;
38109
+ const screenshotDir = expectsScreenshot ? mkdtempSync3(join16(tmpdir5(), "gdharness-runtime-screenshot-")) : null;
38110
+ const screenshotPath = screenshotDir ? join16(screenshotDir, "capture.png") : null;
37802
38111
  try {
37803
38112
  const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs);
37804
38113
  if (!reply.ok) {
@@ -37819,12 +38128,12 @@ class GodotServer {
37819
38128
  return {
37820
38129
  content: [
37821
38130
  { type: "text", text: `Screenshot captured: ${dimensions}` },
37822
- { type: "image", data: readFileSync13(screenshotPath).toString("base64"), mimeType: "image/png" }
38131
+ { type: "image", data: readFileSync14(screenshotPath).toString("base64"), mimeType: "image/png" }
37823
38132
  ]
37824
38133
  };
37825
38134
  } finally {
37826
38135
  if (screenshotDir) {
37827
- rmSync8(screenshotDir, { recursive: true, force: true });
38136
+ rmSync9(screenshotDir, { recursive: true, force: true });
37828
38137
  }
37829
38138
  }
37830
38139
  }
@@ -37849,12 +38158,14 @@ class GodotServer {
37849
38158
  return this.createErrorResponse("runtime_inspect find needs at least one of className, script, namePattern, group, says.");
37850
38159
  }
37851
38160
  const property = readNonEmptyString(args, "property");
38161
+ const includeHidden = readBoolean(args, "includeHidden");
37852
38162
  return await this.handleRuntimeCommand("find_nodes", {
37853
38163
  ...filters,
37854
38164
  projectPath: args["projectPath"],
37855
38165
  root: readNonEmptyString(args, "nodePath") ?? "/root",
37856
38166
  limit: readPositiveNumber(args, "limit") ?? 100,
37857
- ...property === undefined ? {} : { property }
38167
+ ...property === undefined ? {} : { property },
38168
+ ...includeHidden === undefined ? {} : { include_hidden: includeHidden }
37858
38169
  });
37859
38170
  }
37860
38171
  async handleRuntimeWait(op, args) {
@@ -37905,6 +38216,7 @@ var init_server3 = __esm(() => {
37905
38216
  init_ports();
37906
38217
  init_project_scan();
37907
38218
  init_resources();
38219
+ init_run_record();
37908
38220
  init_runtime_client();
37909
38221
  init_server_version();
37910
38222
  init_setup();
@@ -37975,7 +38287,7 @@ var init_server3 = __esm(() => {
37975
38287
 
37976
38288
  // src/cli.ts
37977
38289
  import { existsSync as existsSync13 } from "fs";
37978
- import { join as join16, resolve as resolve5 } from "path";
38290
+ import { join as join17, resolve as resolve5 } from "path";
37979
38291
 
37980
38292
  // src/errors.ts
37981
38293
  class Refusal extends Error {
@@ -39366,6 +39678,11 @@ them. \`editor_rescan\` asks it to scan and names any class it still cannot reso
39366
39678
  declaration and the cache can both be right while the editor stays blind to it. Change the
39367
39679
  declaring script, or restart the editor.
39368
39680
 
39681
+ A diagnostic about a member rather than a type, \`Static function "x()" not found in base "Y"\`
39682
+ about something the engine compiles, is a different thing and is no longer stale here: each ask
39683
+ gives the document back when its answer arrives, so the next one is read off disk. If one turns
39684
+ up anyway, believe the run over the diagnostic and say so.
39685
+
39369
39686
  ## Refusals are useful
39370
39687
 
39371
39688
  An unknown op, an argument the tool does not name, or a missing required one is refused with the
@@ -39478,7 +39795,7 @@ class UsageError extends Refusal {
39478
39795
  function projectArgument(at) {
39479
39796
  const named = args.slice(at).find((value) => !value.startsWith("--"));
39480
39797
  const projectPath = resolve5(named ?? ".");
39481
- if (!existsSync13(join16(projectPath, "project.godot"))) {
39798
+ if (!existsSync13(join17(projectPath, "project.godot"))) {
39482
39799
  throw new UsageError(`Not a Godot project: ${projectPath} holds no project.godot.`);
39483
39800
  }
39484
39801
  return projectPath;