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/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: {
@@ -15720,8 +15725,14 @@ var init_tool_definitions = __esm(() => {
15720
15725
  type: "boolean",
15721
15726
  description: "Only entries printed since the previous editor_output. Default false."
15722
15727
  },
15723
- contains: { type: "string", description: "Only entries mentioning this text." },
15724
- limit: { type: "number", description: "The most entries to answer with, newest kept. Default 200." }
15728
+ contains: {
15729
+ type: "string",
15730
+ 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."
15731
+ },
15732
+ limit: {
15733
+ type: "number",
15734
+ 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."
15735
+ }
15725
15736
  },
15726
15737
  requires: []
15727
15738
  },
@@ -15820,8 +15831,8 @@ var init_tool_definitions = __esm(() => {
15820
15831
  },
15821
15832
  includeHidden: {
15822
15833
  type: "boolean",
15823
- ops: ["text"],
15824
- description: "text: read hidden nodes as well, for checking that something is not showing. Default false."
15834
+ ops: ["text", "find"],
15835
+ 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
15836
  },
15826
15837
  metrics: {
15827
15838
  type: "array",
@@ -15838,7 +15849,7 @@ var init_tool_definitions = __esm(() => {
15838
15849
  requires: []
15839
15850
  },
15840
15851
  find: {
15841
- summary: "the paths of every node matching className, script, namePattern, group or says, with property read off each",
15852
+ 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
15853
  requires: []
15843
15854
  },
15844
15855
  rect: {
@@ -35168,7 +35179,37 @@ function whyNoReport(printed, asked) {
35168
35179
  }
35169
35180
  return printed.some((line) => NOTHING_RAN.test(line)) ? `no test cases found at ${asked}` : null;
35170
35181
  }
35171
- var MalformedReportError, ENTITIES, ATTRIBUTE, NOTHING_RAN, NO_SUCH_PATH;
35182
+ function orphansPrinted(printed) {
35183
+ const suites = [];
35184
+ let total = null;
35185
+ let running = null;
35186
+ for (const line of printed) {
35187
+ const started = RUNNING_SUITE.exec(line);
35188
+ if (started?.[1] !== undefined) {
35189
+ running = started[1];
35190
+ continue;
35191
+ }
35192
+ const counted = ORPHANS.exec(line);
35193
+ if (counted?.[1] === undefined) {
35194
+ continue;
35195
+ }
35196
+ const orphans = Number(counted[1]);
35197
+ if (line.includes(OVERALL)) {
35198
+ total = orphans;
35199
+ running = null;
35200
+ continue;
35201
+ }
35202
+ if (running !== null && orphans > 0) {
35203
+ suites.push({ path: running, orphans });
35204
+ }
35205
+ running = null;
35206
+ }
35207
+ return {
35208
+ total: total ?? suites.reduce((sum, suite) => sum + suite.orphans, 0),
35209
+ suites
35210
+ };
35211
+ }
35212
+ var MalformedReportError, ENTITIES, ATTRIBUTE, NOTHING_RAN, NO_SUCH_PATH, RUNNING_SUITE, ORPHANS, OVERALL = "Overall Summary";
35172
35213
  var init_junit = __esm(() => {
35173
35214
  MalformedReportError = class MalformedReportError extends Error {
35174
35215
  constructor(message) {
@@ -35180,6 +35221,8 @@ var init_junit = __esm(() => {
35180
35221
  ATTRIBUTE = /\s*([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/y;
35181
35222
  NOTHING_RAN = /no test cases found/i;
35182
35223
  NO_SUCH_PATH = /given directory or file does not exists:\s*(\S+)/i;
35224
+ RUNNING_SUITE = /Run Test Suite:\s*(\S+)/;
35225
+ ORPHANS = /(\d+)\s+orphans/;
35183
35226
  });
35184
35227
 
35185
35228
  // src/lsp_client.ts
@@ -35483,6 +35526,14 @@ class GodotLSPClient {
35483
35526
  }
35484
35527
  return uri;
35485
35528
  }
35529
+ closeDocument(uri) {
35530
+ if (!this.documentVersions.delete(uri)) {
35531
+ return;
35532
+ }
35533
+ try {
35534
+ this.sendNotification("textDocument/didClose", { textDocument: { uri } });
35535
+ } catch {}
35536
+ }
35486
35537
  async initialize(rootPath) {
35487
35538
  await this.ensureConnected();
35488
35539
  const resolvedRootPath = resolve3(rootPath);
@@ -35536,8 +35587,9 @@ class GodotLSPClient {
35536
35587
  timer
35537
35588
  });
35538
35589
  });
35590
+ let opened = null;
35539
35591
  try {
35540
- this.syncDocument(filePath, content);
35592
+ opened = this.syncDocument(filePath, content);
35541
35593
  return await diagnosticsPromise;
35542
35594
  } catch (error) {
35543
35595
  const waiter = this.diagnosticsWaiters.get(key);
@@ -35546,16 +35598,25 @@ class GodotLSPClient {
35546
35598
  this.diagnosticsWaiters.delete(key);
35547
35599
  }
35548
35600
  throw error;
35601
+ } finally {
35602
+ if (opened !== null) {
35603
+ this.closeDocument(opened);
35604
+ }
35549
35605
  }
35550
35606
  }
35551
35607
  async getCompletions(filePath, content, line, character) {
35552
35608
  await this.ensureConnected();
35553
35609
  await this.ensureInitializedForFile(filePath);
35554
35610
  const uri = this.syncDocument(filePath, content);
35555
- const result = await this.sendRequest("textDocument/completion", {
35556
- textDocument: { uri },
35557
- position: { line, character }
35558
- });
35611
+ let result;
35612
+ try {
35613
+ result = await this.sendRequest("textDocument/completion", {
35614
+ textDocument: { uri },
35615
+ position: { line, character }
35616
+ });
35617
+ } finally {
35618
+ this.closeDocument(uri);
35619
+ }
35559
35620
  if (Array.isArray(result)) {
35560
35621
  return result;
35561
35622
  }
@@ -35571,18 +35632,27 @@ class GodotLSPClient {
35571
35632
  await this.ensureConnected();
35572
35633
  await this.ensureInitializedForFile(filePath);
35573
35634
  const uri = this.syncDocument(filePath, content);
35574
- return this.sendRequest("textDocument/hover", {
35575
- textDocument: { uri },
35576
- position: { line, character }
35577
- });
35635
+ try {
35636
+ return await this.sendRequest("textDocument/hover", {
35637
+ textDocument: { uri },
35638
+ position: { line, character }
35639
+ });
35640
+ } finally {
35641
+ this.closeDocument(uri);
35642
+ }
35578
35643
  }
35579
35644
  async getDocumentSymbols(filePath, content) {
35580
35645
  await this.ensureConnected();
35581
35646
  await this.ensureInitializedForFile(filePath);
35582
35647
  const uri = this.syncDocument(filePath, content);
35583
- const result = await this.sendRequest("textDocument/documentSymbol", {
35584
- textDocument: { uri }
35585
- });
35648
+ let result;
35649
+ try {
35650
+ result = await this.sendRequest("textDocument/documentSymbol", {
35651
+ textDocument: { uri }
35652
+ });
35653
+ } finally {
35654
+ this.closeDocument(uri);
35655
+ }
35586
35656
  return Array.isArray(result) ? result : [];
35587
35657
  }
35588
35658
  isConnected() {
@@ -36024,15 +36094,115 @@ var init_runtime_client = __esm(() => {
36024
36094
  ANNOUNCEMENT_PATTERN = /^runtime-(\d+)\.json$/;
36025
36095
  });
36026
36096
 
36097
+ // src/run-record.ts
36098
+ import { mkdirSync as mkdirSync6, openSync, readdirSync as readdirSync6, readFileSync as readFileSync12, rmSync as rmSync7, statSync as statSync2, writeFileSync as writeFileSync8 } from "node:fs";
36099
+ import { join as join14 } from "node:path";
36100
+ function runsDirectory() {
36101
+ return join14(runtimeDirectory(), "runs");
36102
+ }
36103
+ function recordPath() {
36104
+ return join14(runsDirectory(), "run.json");
36105
+ }
36106
+ function recordPaths() {
36107
+ return runtimeDirectories().map((directory) => join14(directory, "runs", "run.json"));
36108
+ }
36109
+ function openTranscript(startedAt) {
36110
+ mkdirSync6(runsDirectory(), { recursive: true });
36111
+ const path = join14(runsDirectory(), `run-${startedAt}.log`);
36112
+ return { path, fd: openSync(path, "a") };
36113
+ }
36114
+ function writeRunRecord(record) {
36115
+ mkdirSync6(runsDirectory(), { recursive: true });
36116
+ writeFileSync8(recordPath(), JSON.stringify(record, null, 2), "utf8");
36117
+ }
36118
+ function readRunRecord() {
36119
+ for (const path of recordPaths()) {
36120
+ const record = recordAt(path);
36121
+ if (record !== null) {
36122
+ return record;
36123
+ }
36124
+ }
36125
+ return null;
36126
+ }
36127
+ function recordAt(path) {
36128
+ let parsed;
36129
+ try {
36130
+ parsed = JSON.parse(readFileSync12(path, "utf8"));
36131
+ } catch {
36132
+ return null;
36133
+ }
36134
+ if (!parsed || typeof parsed !== "object") {
36135
+ return null;
36136
+ }
36137
+ const fields = parsed;
36138
+ const pid = fields["pid"];
36139
+ const transcript = fields["transcript"];
36140
+ const startedAt = fields["startedAt"];
36141
+ if (typeof pid !== "number" || typeof transcript !== "string" || typeof startedAt !== "number") {
36142
+ return null;
36143
+ }
36144
+ const args = fields["arguments"];
36145
+ return {
36146
+ pid,
36147
+ transcript,
36148
+ startedAt,
36149
+ projectPath: typeof fields["projectPath"] === "string" ? fields["projectPath"] : "",
36150
+ arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : []
36151
+ };
36152
+ }
36153
+ function clearRunRecord(ours = () => true) {
36154
+ for (const path of recordPaths()) {
36155
+ const record = recordAt(path);
36156
+ if (record === null || !ours(record)) {
36157
+ continue;
36158
+ }
36159
+ try {
36160
+ rmSync7(path);
36161
+ } catch {}
36162
+ }
36163
+ }
36164
+ function ageMs(path, now) {
36165
+ try {
36166
+ return now - statSync2(path).mtimeMs;
36167
+ } catch {
36168
+ return 0;
36169
+ }
36170
+ }
36171
+ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
36172
+ const directory = runsDirectory();
36173
+ const keep = readRunRecord()?.transcript;
36174
+ let entries;
36175
+ try {
36176
+ entries = readdirSync6(directory);
36177
+ } catch {
36178
+ return;
36179
+ }
36180
+ for (const entry of entries) {
36181
+ if (!entry.startsWith("run-") || !entry.endsWith(".log")) {
36182
+ continue;
36183
+ }
36184
+ const path = join14(directory, entry);
36185
+ if (path === keep || ageMs(path, now) < keepMs) {
36186
+ continue;
36187
+ }
36188
+ try {
36189
+ rmSync7(path);
36190
+ } catch {}
36191
+ }
36192
+ }
36193
+ var init_run_record = __esm(() => {
36194
+ init_runtime_client();
36195
+ });
36196
+
36027
36197
  // 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";
36198
+ import { cpSync as cpSync2, existsSync as existsSync11, mkdirSync as mkdirSync7, readFileSync as readFileSync13, rmSync as rmSync8, writeFileSync as writeFileSync9 } from "node:fs";
36199
+ import { dirname as dirname8, join as join15 } from "node:path";
36030
36200
  function installedAddonVersion(projectPath) {
36031
- const marker = join14(projectPath, "addons", ADDONS2[0], VERSION_MARKER2);
36201
+ const marker = join15(projectPath, "addons", ADDONS2[0], VERSION_MARKER2);
36032
36202
  if (!existsSync11(marker)) {
36033
36203
  return null;
36034
36204
  }
36035
- const said = readFileSync12(marker, "utf8").trim();
36205
+ const said = readFileSync13(marker, "utf8").trim();
36036
36206
  return said === "" ? null : said;
36037
36207
  }
36038
36208
  var ADDONS2, RUNTIME_AUTOLOAD2, VERSION_MARKER2 = ".gdharness-version";
@@ -36050,24 +36220,45 @@ var init_setup = __esm(() => {
36050
36220
 
36051
36221
  // src/server.ts
36052
36222
  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";
36223
+ import {
36224
+ closeSync,
36225
+ existsSync as existsSync12,
36226
+ fstatSync,
36227
+ mkdirSync as mkdirSync8,
36228
+ mkdtempSync as mkdtempSync3,
36229
+ openSync as openSync2,
36230
+ readdirSync as readdirSync7,
36231
+ readFileSync as readFileSync14,
36232
+ readSync,
36233
+ realpathSync as realpathSync2,
36234
+ rmSync as rmSync9
36235
+ } from "node:fs";
36054
36236
  import { tmpdir as tmpdir5 } from "node:os";
36055
- import { basename as basename2, dirname as dirname9, join as join15, normalize as normalize3 } from "node:path";
36237
+ import { basename as basename2, dirname as dirname9, join as join16, normalize as normalize3 } from "node:path";
36056
36238
  import { setTimeout as delay3 } from "node:timers/promises";
36057
36239
  import { fileURLToPath as fileURLToPath4 } from "node:url";
36058
36240
  import { promisify as promisify5 } from "node:util";
36059
36241
  function patienceForFrames(frames, atLeast) {
36060
36242
  return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
36061
36243
  }
36244
+ function stillRunning(run) {
36245
+ if (run?.exitCode !== null || run.endedUnwatched === true) {
36246
+ return false;
36247
+ }
36248
+ if (!run.throughEditor && run.process === null && run.pid !== null) {
36249
+ return alive(run.pid);
36250
+ }
36251
+ return true;
36252
+ }
36062
36253
  function alive(pid) {
36063
- if (pid === undefined) {
36254
+ if (pid === undefined || pid === null) {
36064
36255
  return false;
36065
36256
  }
36066
36257
  try {
36067
36258
  process.kill(pid, 0);
36068
36259
  return true;
36069
- } catch {
36070
- return false;
36260
+ } catch (error) {
36261
+ return error.code === "EPERM";
36071
36262
  }
36072
36263
  }
36073
36264
  function isOfType(value, kind) {
@@ -36147,13 +36338,13 @@ function aboveTheRunner(entry) {
36147
36338
  }
36148
36339
  function elsewhereIn(projectPath, asked) {
36149
36340
  const wanted = asked.replace(/^res:\/\//, "").replace(/\/+$/, "");
36150
- if (wanted === "" || existsSync12(join15(projectPath, wanted))) {
36341
+ if (wanted === "" || existsSync12(join16(projectPath, wanted))) {
36151
36342
  return "";
36152
36343
  }
36153
36344
  const root = (wanted.split("/")[0] ?? "").toLowerCase();
36154
36345
  let near = [];
36155
36346
  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()));
36347
+ 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
36348
  } catch {
36158
36349
  return "";
36159
36350
  }
@@ -36161,11 +36352,11 @@ function elsewhereIn(projectPath, asked) {
36161
36352
  return near.length === 0 ? "" : ` The project does have ${there}.`;
36162
36353
  }
36163
36354
  function hasMainScene(projectFile) {
36164
- const scene = parseProjectGodot(readFileSync13(projectFile, "utf8"))["application"]?.["run/main_scene"];
36355
+ const scene = parseProjectGodot(readFileSync14(projectFile, "utf8"))["application"]?.["run/main_scene"];
36165
36356
  return typeof scene === "string" && scene !== "";
36166
36357
  }
36167
36358
  function editorPlaysHeadless(projectFile) {
36168
- const runArgs = parseProjectGodot(readFileSync13(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
36359
+ const runArgs = parseProjectGodot(readFileSync14(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
36169
36360
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
36170
36361
  }
36171
36362
  function camelCased(params) {
@@ -36181,7 +36372,7 @@ function camelCased(params) {
36181
36372
  class GodotServer {
36182
36373
  mcp;
36183
36374
  locator = new GodotLocator2;
36184
- operationsScript = join15(__dirname2, "godot", "operations", "godot_operations.gd");
36375
+ operationsScript = join16(__dirname2, "godot", "operations", "godot_operations.gd");
36185
36376
  godotBridge;
36186
36377
  tools = buildToolDefinitions();
36187
36378
  activeProcess = null;
@@ -36338,10 +36529,7 @@ class GodotServer {
36338
36529
  this.stopWatchingForASuccessor();
36339
36530
  withdrawBridge(this.announcedAt);
36340
36531
  this.announcedAt = null;
36341
- if (this.activeProcess) {
36342
- this.activeProcess.process?.kill();
36343
- this.activeProcess = null;
36344
- }
36532
+ this.activeProcess = null;
36345
36533
  if (this.lspClient) {
36346
36534
  try {
36347
36535
  await this.lspClient.disconnect();
@@ -36406,14 +36594,7 @@ class GodotServer {
36406
36594
  return;
36407
36595
  }
36408
36596
  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
- }
36597
+ this.activeProcess = null;
36417
36598
  }
36418
36599
  setupToolHandlers() {
36419
36600
  this.mcp.server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: this.tools }));
@@ -36773,7 +36954,7 @@ class GodotServer {
36773
36954
  if (path === undefined) {
36774
36955
  return { ok: false, response: this.createErrorResponse("projectPath is required.") };
36775
36956
  }
36776
- const file = join15(path, "project.godot");
36957
+ const file = join16(path, "project.godot");
36777
36958
  if (!existsSync12(file)) {
36778
36959
  return {
36779
36960
  ok: false,
@@ -36943,7 +37124,7 @@ class GodotServer {
36943
37124
  if (!engine.ok) {
36944
37125
  return engine.response;
36945
37126
  }
36946
- const application = parseProjectGodot(readFileSync13(project.value.file, "utf8"))["application"] ?? {};
37127
+ const application = parseProjectGodot(readFileSync14(project.value.file, "utf8"))["application"] ?? {};
36947
37128
  const name = application["config/name"];
36948
37129
  const mainScene = application["run/main_scene"];
36949
37130
  const info = {
@@ -37006,7 +37187,7 @@ class GodotServer {
37006
37187
  }
37007
37188
  const folder = dirname9(output.absolutePath);
37008
37189
  try {
37009
- mkdirSync7(folder, { recursive: true });
37190
+ mkdirSync8(folder, { recursive: true });
37010
37191
  } catch (error) {
37011
37192
  return this.createErrorResponse(`Cannot create ${dirname9(output.relativePath)} to export into: ${errorMessage(error)}`);
37012
37193
  }
@@ -37068,7 +37249,7 @@ class GodotServer {
37068
37249
  return contained.response;
37069
37250
  }
37070
37251
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
37071
- if (!existsSync12(join15(project.value.path, runner))) {
37252
+ if (!existsSync12(join16(project.value.path, runner))) {
37072
37253
  return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
37073
37254
  "Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
37074
37255
  ]);
@@ -37102,7 +37283,7 @@ class GodotServer {
37102
37283
  ];
37103
37284
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 600000;
37104
37285
  this.logDebug(`Running tests: ${engine.value} ${cmdArgs.join(" ")}`);
37105
- const userData = mkdtempSync3(join15(tmpdir5(), "gdharness-tests-"));
37286
+ const userData = mkdtempSync3(join16(tmpdir5(), "gdharness-tests-"));
37106
37287
  const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
37107
37288
  const hung = await new Promise((resolve) => {
37108
37289
  const timer = setTimeout(() => {
@@ -37118,26 +37299,26 @@ class GodotServer {
37118
37299
  resolve(false);
37119
37300
  });
37120
37301
  });
37121
- const reportsDir = join15(project.value.path, ".godot", "gdharness-reports");
37302
+ const reportsDir = join16(project.value.path, ".godot", "gdharness-reports");
37122
37303
  let report = null;
37123
37304
  let reportProblem = null;
37124
37305
  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))) : [];
37306
+ 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
37307
  const newest = written.at(-1);
37127
37308
  if (newest !== undefined) {
37128
- report = parseJUnit(readFileSync13(join15(reportsDir, newest, "results.xml"), "utf8"));
37309
+ report = parseJUnit(readFileSync14(join16(reportsDir, newest, "results.xml"), "utf8"));
37129
37310
  }
37130
37311
  } catch (error) {
37131
37312
  reportProblem = errorMessage(error);
37132
37313
  } finally {
37133
- rmSync8(reportsDir, { recursive: true, force: true });
37134
- rmSync8(userData, { recursive: true, force: true });
37314
+ rmSync9(reportsDir, { recursive: true, force: true });
37315
+ rmSync9(userData, { recursive: true, force: true });
37135
37316
  }
37136
37317
  const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries.map(aboveTheRunner);
37137
37318
  const verdicts = {
37138
37319
  0: "passed",
37139
37320
  100: "failures",
37140
- 101: "warnings",
37321
+ 101: "warnings: orphan nodes",
37141
37322
  103: "headless not supported by this gdUnit4",
37142
37323
  104: "Godot version not supported by this gdUnit4",
37143
37324
  105: "script errors"
@@ -37169,7 +37350,17 @@ class GodotServer {
37169
37350
  };
37170
37351
  }
37171
37352
  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);
37353
+ const orphans = orphansPrinted(said);
37354
+ const leftBehind = new Map(orphans.suites.map((suite) => [suite.path, suite.orphans]));
37355
+ const orphansIn = (path) => path === null ? 0 : leftBehind.get(path) ?? 0;
37356
+ const warnings = orphans.suites.map((suite) => ({
37357
+ kind: "orphans",
37358
+ path: suite.path,
37359
+ name: report.suites.find((entry) => entry.path === suite.path)?.name ?? suite.path,
37360
+ orphans: suite.orphans,
37361
+ 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().`
37362
+ }));
37363
+ const unclean = report.suites.filter((suite) => suite.failures > 0 || suite.errors > 0 || suite.skipped > 0 || orphansIn(suite.path) > 0);
37173
37364
  return this.jsonTextResponse({
37174
37365
  passed: !hung && exitCode === 0 && report.failures === 0 && report.errors === 0,
37175
37366
  verdict,
@@ -37180,6 +37371,9 @@ class GodotServer {
37180
37371
  skipped: report.skipped,
37181
37372
  time: report.time,
37182
37373
  failed,
37374
+ warnings: warnings.length > 0 ? warnings : undefined,
37375
+ orphans: orphans.total > 0 ? orphans.total : undefined,
37376
+ 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
37377
  suites: unclean.map((suite) => ({
37184
37378
  name: suite.name,
37185
37379
  path: suite.path,
@@ -37187,6 +37381,7 @@ class GodotServer {
37187
37381
  failures: suite.failures,
37188
37382
  errors: suite.errors,
37189
37383
  skipped: suite.skipped,
37384
+ orphans: orphansIn(suite.path) > 0 ? orphansIn(suite.path) : undefined,
37190
37385
  time: suite.time
37191
37386
  })),
37192
37387
  suitesPassed: report.suites.length - unclean.length,
@@ -37313,7 +37508,7 @@ class GodotServer {
37313
37508
  version: godotPath === null ? null : await this.godotVersion(godotPath)
37314
37509
  },
37315
37510
  game: {
37316
- processActive: this.activeProcess?.exitCode === null,
37511
+ processActive: stillRunning(this.currentRun()),
37317
37512
  playingInEditor: playing,
37318
37513
  runtimeConnected: games.some((game) => game.reachable),
37319
37514
  runtimes: games
@@ -37481,7 +37676,7 @@ class GodotServer {
37481
37676
  if (op === "check") {
37482
37677
  return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value);
37483
37678
  }
37484
- if (this.activeProcess?.exitCode === null) {
37679
+ if (stillRunning(this.currentRun())) {
37485
37680
  this.logDebug("Ending the running game before starting another");
37486
37681
  await this.endActiveGame();
37487
37682
  }
@@ -37501,7 +37696,7 @@ class GodotServer {
37501
37696
  userArgs: given.value
37502
37697
  });
37503
37698
  this.logDebug(`Running Godot project: ${engine.value} ${cmdArgs.join(" ")}`);
37504
- const started = this.spawnGame(engine.value, cmdArgs);
37699
+ const started = this.spawnKeptGame(engine.value, cmdArgs, project.value.path);
37505
37700
  this.activeProcess = started;
37506
37701
  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
37702
  return this.jsonTextResponse({
@@ -37515,7 +37710,7 @@ class GodotServer {
37515
37710
  });
37516
37711
  }
37517
37712
  async runtimeUp(projectPath, before) {
37518
- if (!existsSync12(join15(projectPath, RUNTIME_AUTOLOAD2.path))) {
37713
+ if (!existsSync12(join16(projectPath, RUNTIME_AUTOLOAD2.path))) {
37519
37714
  return {
37520
37715
  listening: false,
37521
37716
  note: "this project has no gdharness_runtime addon, so the runtime_* tools have nothing to talk to"
@@ -37550,7 +37745,11 @@ class GodotServer {
37550
37745
  }
37551
37746
  const played = {
37552
37747
  process: null,
37748
+ pid: null,
37553
37749
  log,
37750
+ transcript: null,
37751
+ readOffset: 0,
37752
+ projectPath,
37554
37753
  startedAt: Date.now(),
37555
37754
  exitCode: null,
37556
37755
  throughEditor: true,
@@ -37589,6 +37788,7 @@ class GodotServer {
37589
37788
  async endActiveGame() {
37590
37789
  const running = this.activeProcess;
37591
37790
  this.activeProcess = null;
37791
+ clearRunRecord((record) => this.couldBeOurs(record.projectPath));
37592
37792
  if (!running) {
37593
37793
  return;
37594
37794
  }
@@ -37596,14 +37796,26 @@ class GodotServer {
37596
37796
  await this.handleViaBridge("stop_playing", {});
37597
37797
  return;
37598
37798
  }
37599
- running.process?.kill();
37799
+ if (running.process !== null) {
37800
+ running.process.kill();
37801
+ return;
37802
+ }
37803
+ if (running.pid !== null) {
37804
+ try {
37805
+ process.kill(running.pid);
37806
+ } catch {}
37807
+ }
37600
37808
  }
37601
37809
  spawnGame(godotPath, cmdArgs, env) {
37602
37810
  const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
37603
37811
  const log = new GameLog;
37604
37812
  const started = {
37605
37813
  process: child,
37814
+ pid: child.pid ?? null,
37606
37815
  log,
37816
+ transcript: null,
37817
+ readOffset: 0,
37818
+ projectPath: null,
37607
37819
  startedAt: Date.now(),
37608
37820
  exitCode: null,
37609
37821
  throughEditor: false,
@@ -37628,6 +37840,117 @@ class GodotServer {
37628
37840
  });
37629
37841
  return started;
37630
37842
  }
37843
+ spawnKeptGame(godotPath, cmdArgs, projectPath, env) {
37844
+ const startedAt = Date.now();
37845
+ const transcript = openTranscript(startedAt);
37846
+ let child;
37847
+ try {
37848
+ child = spawn(godotPath, cmdArgs, {
37849
+ stdio: ["ignore", transcript.fd, transcript.fd],
37850
+ detached: true,
37851
+ ...env ? { env } : {}
37852
+ });
37853
+ } finally {
37854
+ closeSync(transcript.fd);
37855
+ }
37856
+ child.unref();
37857
+ const log = new GameLog;
37858
+ const started = {
37859
+ process: child,
37860
+ pid: child.pid ?? null,
37861
+ log,
37862
+ transcript: transcript.path,
37863
+ readOffset: 0,
37864
+ projectPath,
37865
+ startedAt,
37866
+ exitCode: null,
37867
+ throughEditor: false,
37868
+ brokeOn: null
37869
+ };
37870
+ child.on("exit", (code) => {
37871
+ this.logDebug(`Godot process exited with code ${code ?? "none"}`);
37872
+ this.drainTranscript(started);
37873
+ log.finish();
37874
+ started.exitCode = code ?? -1;
37875
+ });
37876
+ child.on("error", (err) => {
37877
+ console.error("Failed to start Godot process:", err);
37878
+ log.append("stderr", `${err.message}
37879
+ `);
37880
+ started.exitCode = -1;
37881
+ });
37882
+ if (started.pid !== null) {
37883
+ writeRunRecord({
37884
+ pid: started.pid,
37885
+ transcript: transcript.path,
37886
+ startedAt,
37887
+ projectPath,
37888
+ arguments: cmdArgs
37889
+ });
37890
+ }
37891
+ sweepTranscripts();
37892
+ return started;
37893
+ }
37894
+ drainTranscript(game) {
37895
+ if (game.transcript === null) {
37896
+ return;
37897
+ }
37898
+ let handle;
37899
+ try {
37900
+ handle = openSync2(game.transcript, "r");
37901
+ } catch {
37902
+ return;
37903
+ }
37904
+ try {
37905
+ const size = fstatSync(handle).size;
37906
+ if (size <= game.readOffset) {
37907
+ return;
37908
+ }
37909
+ const buffer = Buffer.alloc(size - game.readOffset);
37910
+ const read = readSync(handle, buffer, 0, buffer.length, game.readOffset);
37911
+ game.readOffset += read;
37912
+ game.log.append("transcript", buffer.subarray(0, read));
37913
+ } finally {
37914
+ closeSync(handle);
37915
+ }
37916
+ }
37917
+ adoptRecordedRun() {
37918
+ const record = readRunRecord();
37919
+ if (record === null || !this.couldBeOurs(record.projectPath)) {
37920
+ return null;
37921
+ }
37922
+ const adopted = {
37923
+ process: null,
37924
+ pid: record.pid,
37925
+ log: new GameLog,
37926
+ transcript: record.transcript,
37927
+ readOffset: 0,
37928
+ projectPath: record.projectPath === "" ? null : record.projectPath,
37929
+ startedAt: record.startedAt,
37930
+ exitCode: null,
37931
+ throughEditor: false,
37932
+ brokeOn: null
37933
+ };
37934
+ this.drainTranscript(adopted);
37935
+ if (!alive(record.pid)) {
37936
+ adopted.log.finish();
37937
+ adopted.exitCode = null;
37938
+ adopted.endedUnwatched = true;
37939
+ }
37940
+ this.activeProcess = adopted;
37941
+ return adopted;
37942
+ }
37943
+ couldBeOurs(project) {
37944
+ if (project === "") {
37945
+ return true;
37946
+ }
37947
+ const status = this.godotBridge.getStatus();
37948
+ const mine = this.ownProject ?? (status.connected ? status.projectPath ?? null : null);
37949
+ return mine === null || samePath(mine, project);
37950
+ }
37951
+ currentRun() {
37952
+ return this.activeProcess ?? this.adoptRecordedRun();
37953
+ }
37631
37954
  async checkBoot(godotPath, projectPath, scene, args, userArgs) {
37632
37955
  const frames = readPositiveNumber(args, "frames") ?? 3;
37633
37956
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 60000;
@@ -37661,37 +37984,47 @@ class GodotServer {
37661
37984
  });
37662
37985
  }
37663
37986
  handleGetDebugOutput(args) {
37664
- if (!this.activeProcess) {
37987
+ const run = this.currentRun();
37988
+ if (!run) {
37665
37989
  return this.createErrorResponse("No game is running. Start one with editor_run.");
37666
37990
  }
37667
- this.drainEditorOutput(this.activeProcess);
37991
+ this.drainEditorOutput(run);
37992
+ this.drainTranscript(run);
37993
+ if (run.endedUnwatched !== true && !stillRunning(run)) {
37994
+ run.log.finish();
37995
+ run.endedUnwatched = run.exitCode === null;
37996
+ }
37668
37997
  const severity = readString(args, "severity");
37669
- const selected = this.activeProcess.log.select({
37998
+ const selected = run.log.select({
37670
37999
  severity: severity === "error" || severity === "warning" ? severity : "info",
37671
38000
  sinceLastCall: readBoolean(args, "sinceLastCall") ?? false,
37672
38001
  contains: readNonEmptyString(args, "contains"),
37673
38002
  limit: readPositiveNumber(args, "limit") ?? 200
37674
38003
  });
37675
- const halt = this.activeProcess.throughEditor ? this.dapClient?.whereItStopped() ?? null : null;
38004
+ const halt = run.throughEditor ? this.dapClient?.whereItStopped() ?? null : null;
37676
38005
  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,
38006
+ running: stillRunning(run),
38007
+ exitCode: run.exitCode,
38008
+ through: run.throughEditor ? "editor" : "gdharness",
38009
+ pid: run.pid,
38010
+ errors: run.log.count("error"),
38011
+ warnings: run.log.count("warning"),
38012
+ clean: run.log.count("error") === 0,
37684
38013
  heldAt: halt,
38014
+ endedUnwatched: run.endedUnwatched === true ? true : undefined,
38015
+ project: !run.throughEditor && run.process === null ? run.projectPath ?? undefined : undefined,
38016
+ 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
38017
  omitted: selected.omitted,
37686
38018
  entries: selected.entries
37687
38019
  });
37688
38020
  }
37689
38021
  async handleStopProject() {
37690
- if (!this.activeProcess) {
38022
+ const stopped = this.currentRun();
38023
+ if (!stopped) {
37691
38024
  return this.createErrorResponse("No game is running. Start one with editor_run.");
37692
38025
  }
37693
- const stopped = this.activeProcess;
37694
38026
  this.drainEditorOutput(stopped);
38027
+ this.drainTranscript(stopped);
37695
38028
  this.logDebug("Stopping the running game");
37696
38029
  await this.endActiveGame();
37697
38030
  return this.jsonTextResponse({
@@ -37797,8 +38130,8 @@ class GodotServer {
37797
38130
  return this.createErrorResponse(choice.problem);
37798
38131
  }
37799
38132
  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;
38133
+ const screenshotDir = expectsScreenshot ? mkdtempSync3(join16(tmpdir5(), "gdharness-runtime-screenshot-")) : null;
38134
+ const screenshotPath = screenshotDir ? join16(screenshotDir, "capture.png") : null;
37802
38135
  try {
37803
38136
  const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs);
37804
38137
  if (!reply.ok) {
@@ -37819,12 +38152,12 @@ class GodotServer {
37819
38152
  return {
37820
38153
  content: [
37821
38154
  { type: "text", text: `Screenshot captured: ${dimensions}` },
37822
- { type: "image", data: readFileSync13(screenshotPath).toString("base64"), mimeType: "image/png" }
38155
+ { type: "image", data: readFileSync14(screenshotPath).toString("base64"), mimeType: "image/png" }
37823
38156
  ]
37824
38157
  };
37825
38158
  } finally {
37826
38159
  if (screenshotDir) {
37827
- rmSync8(screenshotDir, { recursive: true, force: true });
38160
+ rmSync9(screenshotDir, { recursive: true, force: true });
37828
38161
  }
37829
38162
  }
37830
38163
  }
@@ -37849,12 +38182,14 @@ class GodotServer {
37849
38182
  return this.createErrorResponse("runtime_inspect find needs at least one of className, script, namePattern, group, says.");
37850
38183
  }
37851
38184
  const property = readNonEmptyString(args, "property");
38185
+ const includeHidden = readBoolean(args, "includeHidden");
37852
38186
  return await this.handleRuntimeCommand("find_nodes", {
37853
38187
  ...filters,
37854
38188
  projectPath: args["projectPath"],
37855
38189
  root: readNonEmptyString(args, "nodePath") ?? "/root",
37856
38190
  limit: readPositiveNumber(args, "limit") ?? 100,
37857
- ...property === undefined ? {} : { property }
38191
+ ...property === undefined ? {} : { property },
38192
+ ...includeHidden === undefined ? {} : { include_hidden: includeHidden }
37858
38193
  });
37859
38194
  }
37860
38195
  async handleRuntimeWait(op, args) {
@@ -37905,6 +38240,7 @@ var init_server3 = __esm(() => {
37905
38240
  init_ports();
37906
38241
  init_project_scan();
37907
38242
  init_resources();
38243
+ init_run_record();
37908
38244
  init_runtime_client();
37909
38245
  init_server_version();
37910
38246
  init_setup();
@@ -37975,7 +38311,7 @@ var init_server3 = __esm(() => {
37975
38311
 
37976
38312
  // src/cli.ts
37977
38313
  import { existsSync as existsSync13 } from "fs";
37978
- import { join as join16, resolve as resolve5 } from "path";
38314
+ import { join as join17, resolve as resolve5 } from "path";
37979
38315
 
37980
38316
  // src/errors.ts
37981
38317
  class Refusal extends Error {
@@ -39366,6 +39702,11 @@ them. \`editor_rescan\` asks it to scan and names any class it still cannot reso
39366
39702
  declaration and the cache can both be right while the editor stays blind to it. Change the
39367
39703
  declaring script, or restart the editor.
39368
39704
 
39705
+ A diagnostic about a member rather than a type, \`Static function "x()" not found in base "Y"\`
39706
+ about something the engine compiles, is a different thing and is no longer stale here: each ask
39707
+ gives the document back when its answer arrives, so the next one is read off disk. If one turns
39708
+ up anyway, believe the run over the diagnostic and say so.
39709
+
39369
39710
  ## Refusals are useful
39370
39711
 
39371
39712
  An unknown op, an argument the tool does not name, or a missing required one is refused with the
@@ -39478,7 +39819,7 @@ class UsageError extends Refusal {
39478
39819
  function projectArgument(at) {
39479
39820
  const named = args.slice(at).find((value) => !value.startsWith("--"));
39480
39821
  const projectPath = resolve5(named ?? ".");
39481
- if (!existsSync13(join16(projectPath, "project.godot"))) {
39822
+ if (!existsSync13(join17(projectPath, "project.godot"))) {
39482
39823
  throw new UsageError(`Not a Godot project: ${projectPath} holds no project.godot.`);
39483
39824
  }
39484
39825
  return projectPath;