gdharness 0.12.17 → 0.13.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.
Files changed (36) hide show
  1. package/build/cli.js +174 -56
  2. package/build/godot/addons/auto_reload/auto_reload.gd +9 -2
  3. package/build/godot/addons/gdharness_editor/bridge_client.gd +11 -5
  4. package/build/godot/addons/gdharness_editor/plugin.gd +17 -4
  5. package/build/godot/addons/gdharness_editor/reading.gd +64 -0
  6. package/build/godot/addons/gdharness_editor/reading.gd.uid +1 -0
  7. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +30 -12
  8. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +5 -2
  9. package/build/godot/addons/gdharness_editor/tools/play_tools.gd +3 -1
  10. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +50 -27
  11. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +92 -42
  12. package/build/godot/addons/gdharness_runtime/reading.gd +64 -0
  13. package/build/godot/addons/gdharness_runtime/reading.gd.uid +1 -0
  14. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +24 -10
  15. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +7 -5
  16. package/build/godot/addons/gdharness_runtime/runtime_input.gd +24 -22
  17. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +16 -13
  18. package/build/godot/addons/gdharness_runtime/runtime_values.gd +24 -8
  19. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +7 -4
  20. package/build/godot/operations/audio_buses.gd +7 -6
  21. package/build/godot/operations/class_cache.gd +14 -9
  22. package/build/godot/operations/classdb_queries.gd +4 -3
  23. package/build/godot/operations/dependencies.gd +19 -16
  24. package/build/godot/operations/file_walk.gd +4 -2
  25. package/build/godot/operations/gdscript_analysis.gd +2 -3
  26. package/build/godot/operations/gdscript_authoring.gd +20 -8
  27. package/build/godot/operations/godot_operations.gd +7 -2
  28. package/build/godot/operations/import_pipeline.gd +5 -4
  29. package/build/godot/operations/input_actions.gd +13 -9
  30. package/build/godot/operations/patterns.gd +22 -0
  31. package/build/godot/operations/plugins.gd +10 -6
  32. package/build/godot/operations/project_config.gd +41 -3
  33. package/build/godot/operations/reading.gd +66 -0
  34. package/build/godot/operations/serialisation.gd +41 -9
  35. package/build/index.js +147 -53
  36. package/package.json +1 -1
package/build/cli.js CHANGED
@@ -7187,6 +7187,9 @@ function announced(line) {
7187
7187
  }
7188
7188
  return { severity: match[2] === "WARNING" ? "warning" : "error", text: match[3] ?? "" };
7189
7189
  }
7190
+ function forAnswer(entries) {
7191
+ return entries.map((entry) => entry.detail.length === 0 ? { index: entry.index, severity: entry.severity, source: entry.source, text: entry.text } : entry);
7192
+ }
7190
7193
 
7191
7194
  class GameLog {
7192
7195
  entries = [];
@@ -15037,6 +15040,11 @@ function readPositiveNumber(params, key) {
15037
15040
  const number = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
15038
15041
  return typeof number === "number" && Number.isFinite(number) && number > 0 ? number : undefined;
15039
15042
  }
15043
+ function readNonNegativeNumber(params, key) {
15044
+ const value = params[key];
15045
+ const number = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
15046
+ return typeof number === "number" && Number.isFinite(number) && number >= 0 ? number : undefined;
15047
+ }
15040
15048
  function readBoolean(params, key) {
15041
15049
  const value = params[key];
15042
15050
  if (typeof value === "boolean") {
@@ -15291,6 +15299,11 @@ var init_tool_definitions = __esm(() => {
15291
15299
  parameters: {
15292
15300
  projectPath: PROJECT_PATH,
15293
15301
  setting: { type: "string", description: 'Setting path, such as "display/window/size/viewport_width".' },
15302
+ prefix: {
15303
+ type: "string",
15304
+ ops: ["get"],
15305
+ description: 'get: answer with every setting whose name starts with this, and the type the engine registers for each, instead of one setting by name. "debug/gdscript/warnings/" answers the whole family. The type matters: a family of levels can hold a setting that is a bool, and writing a level over it looks like it worked.'
15306
+ },
15294
15307
  value: {
15295
15308
  blank: true,
15296
15309
  description: 'The value to write. Engine types may be tagged, {"_type": "Vector2", "x": 1, "y": 2}. "" writes an empty string.'
@@ -15312,7 +15325,7 @@ var init_tool_definitions = __esm(() => {
15312
15325
  },
15313
15326
  requires: ["projectPath"],
15314
15327
  operations: {
15315
- get: { summary: "read one setting", requires: ["setting"] },
15328
+ get: { summary: "read one setting, or every setting under prefix", requires: [] },
15316
15329
  set: { summary: "write one setting", requires: ["setting", "value"] },
15317
15330
  add_autoload: { summary: "register an autoload singleton", requires: ["name", "path"] },
15318
15331
  remove_autoload: { summary: "unregister an autoload", requires: ["name"] },
@@ -15719,7 +15732,7 @@ var init_tool_definitions = __esm(() => {
15719
15732
  runtimeWaitMs: {
15720
15733
  type: "number",
15721
15734
  ops: ["start"],
15722
- description: "start: how long to wait for the game to announce its runtime before answering. Default 5000. Worth raising for a project that takes longer than that to reach its first frame, which is what leaves runtime listening false on a game whose runtime arrives a moment later."
15735
+ description: "start: how long to wait for the game to announce its runtime before answering. Default 5000. Worth raising for a project that takes longer than that to reach its first frame, which is what leaves runtime listening false on a game whose runtime arrives a moment later. 0 does not wait at all, which is the one to pass for a scene that announces nothing by construction, such as a bench that prints and quits."
15723
15736
  },
15724
15737
  frames: {
15725
15738
  type: "number",
@@ -15728,15 +15741,16 @@ var init_tool_definitions = __esm(() => {
15728
15741
  },
15729
15742
  timeoutMs: {
15730
15743
  type: "number",
15731
- ops: ["check"],
15732
- description: "check: how long to give the boot before it is called hung. Default 60000."
15744
+ ops: ["check", "wait"],
15745
+ description: "check: how long to give the boot before it is called hung. Default 60000. wait: how long to wait for the run to end before answering anyway. Default 600000."
15733
15746
  }
15734
15747
  },
15735
15748
  requires: ["projectPath"],
15736
15749
  operations: {
15737
15750
  start: { summary: "run the project until it quits or is stopped", requires: [] },
15738
15751
  stop: { summary: "end the run and answer with what it printed last", requires: [] },
15739
- check: { summary: "boot headless, quit after a few frames, and report the verdict", requires: [] }
15752
+ check: { summary: "boot headless, quit after a few frames, and report the verdict", requires: [] },
15753
+ wait: { summary: "wait for the run to end, then answer as editor_output does", requires: [] }
15740
15754
  },
15741
15755
  defaultOperation: "start"
15742
15756
  },
@@ -15753,6 +15767,10 @@ var init_tool_definitions = __esm(() => {
15753
15767
  type: "boolean",
15754
15768
  description: "Only entries printed since the previous editor_output. Default false."
15755
15769
  },
15770
+ cpu: {
15771
+ type: "boolean",
15772
+ description: "Also answer with cpuSeconds, the processor time the run has used. Default false, because asking costs a subprocess. Worth it when elapsedMs is climbing and nothing is being printed: processor time standing still is a run that has stopped doing anything, rather than one that is slow."
15773
+ },
15756
15774
  contains: {
15757
15775
  type: "string",
15758
15776
  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."
@@ -16132,6 +16150,43 @@ var init_tool_definitions = __esm(() => {
16132
16150
  SPECS_BY_NAME = dictionary(Object.fromEntries(TOOL_SPECS.map((spec) => [spec.name, spec])));
16133
16151
  });
16134
16152
 
16153
+ // src/headless-operations.ts
16154
+ var HEADLESS_OPERATIONS;
16155
+ var init_headless_operations = __esm(() => {
16156
+ HEADLESS_OPERATIONS = dictionary({
16157
+ project_settings: dictionary({
16158
+ get: "get_project_setting",
16159
+ set: "set_project_setting",
16160
+ add_autoload: "add_autoload",
16161
+ remove_autoload: "remove_autoload",
16162
+ set_main_scene: "set_main_scene",
16163
+ add_input_action: "add_input_action",
16164
+ enable_plugin: "enable_plugin",
16165
+ disable_plugin: "disable_plugin",
16166
+ add_audio_bus: "create_audio_bus",
16167
+ set_audio_bus_effect: "set_audio_bus_effect",
16168
+ set_audio_bus_volume: "set_audio_bus_volume"
16169
+ }),
16170
+ project_import: dictionary({
16171
+ status: "get_import_status",
16172
+ options: "get_import_options",
16173
+ set_options: "set_import_options",
16174
+ reimport: "reimport_resource",
16175
+ uid: "get_uid",
16176
+ refresh_uids: "resave_resources",
16177
+ refresh_classes: "refresh_class_cache"
16178
+ }),
16179
+ project_export: dictionary({ list: "list_export_presets" }),
16180
+ script_edit: dictionary({ create: "create_script", modify: "modify_script" }),
16181
+ script_info: dictionary({ structure: "get_script_info" }),
16182
+ editor_classes: dictionary({
16183
+ query: "query_classes",
16184
+ info: "query_class_info",
16185
+ inheritance: "inspect_inheritance"
16186
+ })
16187
+ });
16188
+ });
16189
+
16135
16190
  // node_modules/zod/v3/helpers/util.js
16136
16191
  var util, objectUtil, ZodParsedType, getParsedType = (data) => {
16137
16192
  const t = typeof data;
@@ -35800,6 +35855,45 @@ var init_lsp_client = __esm(() => {
35800
35855
  init_ports();
35801
35856
  });
35802
35857
 
35858
+ // src/process-time.ts
35859
+ import { execFile as execFile5 } from "node:child_process";
35860
+ import { promisify as promisify5 } from "node:util";
35861
+ async function cpuSecondsOf(pid) {
35862
+ try {
35863
+ return process.platform === "win32" ? await askWindows(pid) : await askPosix(pid);
35864
+ } catch {
35865
+ return;
35866
+ }
35867
+ }
35868
+ async function askWindows(pid) {
35869
+ const { stdout } = await run5("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `(Get-Process -Id ${pid}).CPU`], { timeout: ASK_TIMEOUT_MS, windowsHide: true });
35870
+ const seconds = Number(stdout.trim().replace(",", "."));
35871
+ return Number.isFinite(seconds) ? seconds : undefined;
35872
+ }
35873
+ async function askPosix(pid) {
35874
+ const { stdout } = await run5("ps", ["-o", "time=", "-p", String(pid)], { timeout: ASK_TIMEOUT_MS });
35875
+ return secondsFromClock(stdout.trim());
35876
+ }
35877
+ function secondsFromClock(printed) {
35878
+ if (printed === "") {
35879
+ return;
35880
+ }
35881
+ const dashed = printed.includes("-");
35882
+ const days = dashed ? printed.slice(0, printed.indexOf("-")) : null;
35883
+ const clock = dashed ? printed.slice(printed.indexOf("-") + 1) : printed;
35884
+ const parts = clock.split(":").map((part) => Number(part));
35885
+ if (parts.length === 0 || parts.some((part) => !Number.isFinite(part))) {
35886
+ return;
35887
+ }
35888
+ const seconds = parts.reduce((total, part) => total * 60 + part, 0);
35889
+ const extra = days === null ? 0 : Number(days) * 86400;
35890
+ return Number.isFinite(extra) ? seconds + extra : undefined;
35891
+ }
35892
+ var run5, ASK_TIMEOUT_MS = 2000;
35893
+ var init_process_time = __esm(() => {
35894
+ run5 = promisify5(execFile5);
35895
+ });
35896
+
35803
35897
  // src/project-scan.ts
35804
35898
  import { existsSync as existsSync10, readdirSync as readdirSync4, readFileSync as readFileSync10 } from "node:fs";
35805
35899
  import { join as join12 } from "node:path";
@@ -36355,7 +36449,7 @@ var init_setup = __esm(() => {
36355
36449
  });
36356
36450
 
36357
36451
  // src/server.ts
36358
- import { execFile as execFile5, spawn } from "node:child_process";
36452
+ import { execFile as execFile6, spawn } from "node:child_process";
36359
36453
  import { randomUUID as randomUUID2 } from "node:crypto";
36360
36454
  import {
36361
36455
  closeSync,
@@ -36374,7 +36468,7 @@ import { tmpdir as tmpdir5 } from "node:os";
36374
36468
  import { basename as basename3, dirname as dirname9, join as join16, normalize as normalize3 } from "node:path";
36375
36469
  import { setTimeout as delay3 } from "node:timers/promises";
36376
36470
  import { fileURLToPath as fileURLToPath4 } from "node:url";
36377
- import { promisify as promisify5 } from "node:util";
36471
+ import { promisify as promisify6 } from "node:util";
36378
36472
  function patienceForFrames(frames, atLeast) {
36379
36473
  return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
36380
36474
  }
@@ -37003,9 +37097,15 @@ class GodotServer {
37003
37097
  case "editor_launch":
37004
37098
  return op === "restart" ? await this.handleRestartEditor() : await this.handleLaunchEditor(args);
37005
37099
  case "editor_run":
37006
- return op === "stop" ? await this.handleStopProject() : await this.handleRunProject(args, op);
37100
+ if (op === "stop") {
37101
+ return await this.handleStopProject();
37102
+ }
37103
+ if (op === "wait") {
37104
+ return await this.handleWaitForRun(args);
37105
+ }
37106
+ return await this.handleRunProject(args, op);
37007
37107
  case "editor_output":
37008
- return this.handleGetDebugOutput(args);
37108
+ return await this.handleGetDebugOutput(args);
37009
37109
  case "editor_status":
37010
37110
  return await this.handleEditorStatus();
37011
37111
  case "editor_rescan":
@@ -37376,7 +37476,7 @@ class GodotServer {
37376
37476
  const log = new GameLog;
37377
37477
  let exitCode = 0;
37378
37478
  try {
37379
- const { stdout, stderr } = await run5(engine.value, exportArgs, { timeout: 300000 });
37479
+ const { stdout, stderr } = await run6(engine.value, exportArgs, { timeout: 300000 });
37380
37480
  log.append("stdout", stdout);
37381
37481
  log.append("stderr", stderr);
37382
37482
  } catch (error) {
@@ -37400,7 +37500,7 @@ class GodotServer {
37400
37500
  exitCode,
37401
37501
  errors: log.count("error"),
37402
37502
  warnings: log.count("warning"),
37403
- entries: problems.entries
37503
+ entries: forAnswer(problems.entries)
37404
37504
  };
37405
37505
  if (!verdict.exported) {
37406
37506
  return {
@@ -37517,7 +37617,7 @@ class GodotServer {
37517
37617
  exitCode,
37518
37618
  hung,
37519
37619
  arguments: cmdArgs,
37520
- entries: printed.slice(0, 60)
37620
+ entries: forAnswer(printed.slice(0, 60))
37521
37621
  }, null, 2)
37522
37622
  }
37523
37623
  ],
@@ -37571,7 +37671,7 @@ class GodotServer {
37571
37671
  }
37572
37672
  async godotVersion(godotPath) {
37573
37673
  try {
37574
- const { stdout } = await run5(godotPath, ["--version"], { timeout: 1e4 });
37674
+ const { stdout } = await run6(godotPath, ["--version"], { timeout: 1e4 });
37575
37675
  return stdout.trim();
37576
37676
  } catch (error) {
37577
37677
  this.logDebug(`Godot did not answer --version: ${errorMessage(error)}`);
@@ -37866,7 +37966,7 @@ class GodotServer {
37866
37966
  platform: process.platform,
37867
37967
  variables: process.env
37868
37968
  });
37869
- const runtimeWaitMs = readPositiveNumber(args, "runtimeWaitMs") ?? ANNOUNCE_BUDGET_MS;
37969
+ const runtimeWaitMs = readNonNegativeNumber(args, "runtimeWaitMs") ?? ANNOUNCE_BUDGET_MS;
37870
37970
  const editorWouldPlay = !headless || editorPlaysHeadless(project.value.file);
37871
37971
  if (this.godotBridge.isConnected() && editorWouldPlay && given.value.length === 0) {
37872
37972
  return await this.playThroughEditor(sceneArgument, refreshed.value, project.value.path, alreadyPlaying, runtimeWaitMs, ended);
@@ -38188,10 +38288,24 @@ class GodotServer {
38188
38288
  frames,
38189
38289
  errors,
38190
38290
  warnings,
38191
- entries: boot.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries
38291
+ entries: forAnswer(boot.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
38192
38292
  });
38193
38293
  }
38194
- handleGetDebugOutput(args) {
38294
+ async handleWaitForRun(args) {
38295
+ const run = this.currentRun();
38296
+ if (!run) {
38297
+ return this.createErrorResponse(this.nothingOfOursIsRunning());
38298
+ }
38299
+ const budgetMs = readPositiveNumber(args, "timeoutMs") ?? WAIT_FOR_RUN_MS;
38300
+ const until = Date.now() + budgetMs;
38301
+ while (stillRunning(run) && Date.now() < until) {
38302
+ this.drainEditorOutput(run);
38303
+ this.drainTranscript(run);
38304
+ await delay3(RUN_POLL_MS);
38305
+ }
38306
+ return await this.handleGetDebugOutput(args, stillRunning(run) ? budgetMs : undefined);
38307
+ }
38308
+ async handleGetDebugOutput(args, waitedMs) {
38195
38309
  const run = this.currentRun();
38196
38310
  if (!run) {
38197
38311
  return this.createErrorResponse(this.nothingOfOursIsRunning());
@@ -38210,14 +38324,19 @@ class GodotServer {
38210
38324
  limit: readPositiveNumber(args, "limit") ?? 200
38211
38325
  });
38212
38326
  const halt = run.throughEditor ? this.dapClient?.whereItStopped() ?? null : null;
38327
+ const wanted = readBoolean(args, "cpu") ?? false;
38328
+ const cpuSeconds = wanted && run.pid !== null && stillRunning(run) ? await cpuSecondsOf(run.pid) : undefined;
38213
38329
  const notes = [];
38330
+ if (waitedMs !== undefined) {
38331
+ notes.push(`The wait of ${waitedMs}ms ran out and the run is still going, so this is what it had printed by then rather than everything it will print. editor_run wait again to keep waiting.`);
38332
+ }
38214
38333
  if (run.endedUnwatched === true) {
38215
38334
  notes.push("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.");
38216
38335
  }
38217
38336
  if (typeof run.endedHere === "string") {
38218
38337
  notes.push(`This run was ended here, by ${run.endedHere}.`);
38219
38338
  } else if (!stillRunning(run) && !run.throughEditor && run.exitCode !== null) {
38220
- notes.push("Nothing here ended this run: it stopped on its own or something outside this server stopped it.");
38339
+ notes.push(run.exitCode === 0 ? "This run quit on its own, cleanly: exit code 0." : "Nothing here ended this run: it stopped on its own or something outside this server stopped it.");
38221
38340
  }
38222
38341
  if (run.transcript !== null && selected.omitted > 0) {
38223
38342
  notes.push(`Everything this run has printed is in ${run.transcript}, uncapped and still being written. Read it for output, not for whether the run is alive: running above is asked of the operating system, and a transcript that stops growing is a run between prints.`);
@@ -38235,9 +38354,12 @@ class GodotServer {
38235
38354
  endedBy: stillRunning(run) ? undefined : run.endedHere ?? null,
38236
38355
  project: !run.throughEditor && run.process === null ? run.projectPath ?? undefined : undefined,
38237
38356
  transcript: run.transcript ?? undefined,
38357
+ startedAt: new Date(run.startedAt).toISOString(),
38358
+ elapsedMs: Date.now() - run.startedAt,
38359
+ cpuSeconds,
38238
38360
  note: notes.length > 0 ? notes.join(" ") : undefined,
38239
38361
  omitted: selected.omitted,
38240
- entries: selected.entries
38362
+ entries: forAnswer(selected.entries)
38241
38363
  });
38242
38364
  }
38243
38365
  async handleStopProject() {
@@ -38257,7 +38379,7 @@ class GodotServer {
38257
38379
  errors: stopped.log.count("error"),
38258
38380
  warnings: stopped.log.count("warning"),
38259
38381
  clean: stopped.log.count("error") === 0,
38260
- entries: stopped.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries
38382
+ entries: forAnswer(stopped.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
38261
38383
  });
38262
38384
  }
38263
38385
  async handleRescanFilesystem(args) {
@@ -38442,7 +38564,7 @@ async function runGodotServer() {
38442
38564
  const server = new GodotServer;
38443
38565
  await server.run();
38444
38566
  }
38445
- var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run5, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, SLOWEST_FRAME_RATE = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, HEADLESS_OPERATIONS, PROJECT_INFO_SECTIONS, RUNNER_DIRECTORY = "addons/gdUnit4/";
38567
+ var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run6, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, SLOWEST_FRAME_RATE = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS, RUNNER_DIRECTORY = "addons/gdUnit4/";
38446
38568
  var init_server3 = __esm(() => {
38447
38569
  init_mcp();
38448
38570
  init_stdio2();
@@ -38455,11 +38577,13 @@ var init_server3 = __esm(() => {
38455
38577
  init_godot_bridge();
38456
38578
  init_godot_path();
38457
38579
  init_headless();
38580
+ init_headless_operations();
38458
38581
  init_issues();
38459
38582
  init_junit();
38460
38583
  init_lsp_client();
38461
38584
  init_paths();
38462
38585
  init_ports();
38586
+ init_process_time();
38463
38587
  init_project_scan();
38464
38588
  init_resources();
38465
38589
  init_run_record();
@@ -38468,7 +38592,7 @@ var init_server3 = __esm(() => {
38468
38592
  init_setup();
38469
38593
  init_tool_definitions();
38470
38594
  init_update_check();
38471
- run5 = promisify5(execFile5);
38595
+ run6 = promisify6(execFile6);
38472
38596
  __dirname2 = dirname9(fileURLToPath4(import.meta.url));
38473
38597
  VALUE_NAMES = {
38474
38598
  string: "a string",
@@ -38480,44 +38604,19 @@ var init_server3 = __esm(() => {
38480
38604
  'Give the path relative to the project, such as "scenes/main.tscn" or "res://scenes/main.tscn"',
38481
38605
  "Point projectPath at the project the file belongs to"
38482
38606
  ];
38483
- PROJECT_FILE_ARGUMENTS = ["scenePath", "scriptPath", "resourcePath", "newPath", "path", "script"];
38607
+ PROJECT_FILE_ARGUMENTS = [
38608
+ "scenePath",
38609
+ "scriptPath",
38610
+ "resourcePath",
38611
+ "newPath",
38612
+ "path",
38613
+ "script"
38614
+ ];
38484
38615
  DEBUG_STATE_CALLS = dictionary({
38485
38616
  stack: "dap_get_stack_trace",
38486
38617
  output: "dap_get_output",
38487
38618
  variables: "dap_get_variables"
38488
38619
  });
38489
- HEADLESS_OPERATIONS = dictionary({
38490
- project_settings: dictionary({
38491
- get: "get_project_setting",
38492
- set: "set_project_setting",
38493
- add_autoload: "add_autoload",
38494
- remove_autoload: "remove_autoload",
38495
- set_main_scene: "set_main_scene",
38496
- add_input_action: "add_input_action",
38497
- enable_plugin: "enable_plugin",
38498
- disable_plugin: "disable_plugin",
38499
- add_audio_bus: "create_audio_bus",
38500
- set_audio_bus_effect: "set_audio_bus_effect",
38501
- set_audio_bus_volume: "set_audio_bus_volume"
38502
- }),
38503
- project_import: dictionary({
38504
- status: "get_import_status",
38505
- options: "get_import_options",
38506
- set_options: "set_import_options",
38507
- reimport: "reimport_resource",
38508
- uid: "get_uid",
38509
- refresh_uids: "resave_resources",
38510
- refresh_classes: "refresh_class_cache"
38511
- }),
38512
- project_export: dictionary({ list: "list_export_presets" }),
38513
- script_edit: dictionary({ create: "create_script", modify: "modify_script" }),
38514
- script_info: dictionary({ structure: "get_script_info" }),
38515
- editor_classes: dictionary({
38516
- query: "query_classes",
38517
- info: "query_class_info",
38518
- inheritance: "inspect_inheritance"
38519
- })
38520
- });
38521
38620
  PROJECT_INFO_SECTIONS = dictionary({
38522
38621
  autoloads: { operation: "list_autoloads", params: () => ({}) },
38523
38622
  plugins: { operation: "list_plugins", params: () => ({}) },
@@ -39840,6 +39939,7 @@ import { existsSync as existsSync8, mkdirSync as mkdirSync4, readdirSync as read
39840
39939
  import { basename, dirname as dirname4, join as join9 } from "node:path";
39841
39940
 
39842
39941
  // src/tool-reference.ts
39942
+ init_headless_operations();
39843
39943
  init_tool_definitions();
39844
39944
  function namedType(declared) {
39845
39945
  if (typeof declared === "string") {
@@ -39850,6 +39950,22 @@ function namedType(declared) {
39850
39950
  }
39851
39951
  return "any";
39852
39952
  }
39953
+ function headlessSection() {
39954
+ const byTool = Object.entries(HEADLESS_OPERATIONS).map(([tool, ops]) => `\`${tool}\` (${Object.keys(ops).join(", ")})`).sort();
39955
+ return [
39956
+ "## What a gate can call",
39957
+ "",
39958
+ "These answer from a short-lived headless engine: no editor window, no port, no running game,",
39959
+ "and nothing left behind. They are the only calls worth putting in a push gate, because every",
39960
+ "other tool needs an editor somebody has open or a game somebody is running.",
39961
+ "",
39962
+ ...byTool.map((line) => `- ${line}`),
39963
+ "",
39964
+ "Everything else needs the editor (`editor_*`, `debug_*`, `scene_*`, `resource_*`) or a running",
39965
+ "game (`runtime_*`).",
39966
+ ""
39967
+ ];
39968
+ }
39853
39969
  function renderToolsMarkdown() {
39854
39970
  const lines = [
39855
39971
  "# Tools",
@@ -39860,7 +39976,8 @@ function renderToolsMarkdown() {
39860
39976
  "Every call takes `projectPath`, except the `runtime_*` and `debug_*` tools, where it picks",
39861
39977
  "between running games. Answers are read from the engine after the change, not echoed from the",
39862
39978
  "request. Engine stderr comes back under `engine_messages`.",
39863
- ""
39979
+ "",
39980
+ ...headlessSection()
39864
39981
  ];
39865
39982
  for (const tool of TOOL_SPECS) {
39866
39983
  lines.push(`## ${tool.name}`, "", tool.description, "");
@@ -39951,7 +40068,8 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
39951
40068
  | Answer a dialog | \`runtime_input click\` on its button, or \`key\` Escape to dismiss it: an [AcceptDialog] reads that key itself and never asks the InputMap, so \`ui_cancel\` leaves it standing |
39952
40069
  | Press a bound key | \`runtime_input action\` or \`key\`, a whole press unless you hold it |
39953
40070
  | Where a 3D node is on screen | \`runtime_inspect\` \`rect\`, rather than unprojecting by hand |
39954
- | Wait for something | \`runtime_wait\`, never a sleep; \`until\` with \`says\` for a panel that rebuilds its own labels |
40071
+ | Wait for something in a running game | \`runtime_wait\`, never a sleep; \`until\` with \`says\` for a panel that rebuilds its own labels |
40072
+ | Wait for a run to finish | \`editor_run wait\`, never a sleep or a shell loop on the pid: it answers as \`editor_output\` does, once the run is over |
39955
40073
  | A picture, for a person who asked to see one | \`runtime_capture\` |
39956
40074
 
39957
40075
  These need the runtime autoload, which \`gdharness setup\` registers. \`runtime_capture\` needs a
@@ -16,7 +16,12 @@ var _watched_files: Dictionary = {}
16
16
  func _enter_tree() -> void:
17
17
  _timer = Timer.new()
18
18
  _timer.wait_time = CHECK_INTERVAL_SECONDS
19
- _timer.timeout.connect(_check_for_changes)
19
+ # int rather than Error: Signal.connect answers with a plain int, where Object.connect answers
20
+ # with the enum, and a project holding int_as_enum_without_cast at error level refuses the
21
+ # assignment that conflates them.
22
+ var watching: int = _timer.timeout.connect(_check_for_changes)
23
+ if watching != OK:
24
+ push_error("gdharness: could not start watching for changes on disk")
20
25
  add_child(_timer)
21
26
  _timer.start()
22
27
  _update_watched_files()
@@ -79,7 +84,9 @@ func _check_for_changes() -> void:
79
84
 
80
85
  func _reload_script(path: String) -> void:
81
86
  print("[gdharness] Script changed on disk, reloading: ", path)
82
- ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_REPLACE)
87
+ var reloaded: Resource = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_REPLACE)
88
+ if reloaded == null:
89
+ push_error("gdharness: could not reload " + path + " from disk")
83
90
 
84
91
 
85
92
  func _reload_scene(path: String) -> void:
@@ -8,6 +8,8 @@ signal connected
8
8
  signal disconnected
9
9
  signal tool_requested(request_id: String, tool_name: String, args: Dictionary)
10
10
 
11
+ const Read = preload("reading.gd")
12
+
11
13
  const DEFAULT_URL: String = "ws://127.0.0.1:6505/godot"
12
14
  ## Written beside the addon by the install, so it names the version this copy came from.
13
15
  const VERSION_MARKER: String = "res://addons/gdharness_editor/.gdharness-version"
@@ -88,7 +90,9 @@ func _ready() -> void:
88
90
 
89
91
  _reconnect_timer = Timer.new()
90
92
  _reconnect_timer.one_shot = true
91
- _reconnect_timer.timeout.connect(_on_reconnect_timer)
93
+ var waiting: int = _reconnect_timer.timeout.connect(_on_reconnect_timer)
94
+ if waiting != OK:
95
+ push_error("gdharness: could not arm the reconnect timer")
92
96
  add_child(_reconnect_timer)
93
97
 
94
98
  set_process(true)
@@ -227,9 +231,9 @@ func announced_url() -> String:
227
231
  return ""
228
232
 
229
233
  var announcement: Dictionary = said
230
- if int(announcement.get("protocol", 0)) != ANNOUNCE_PROTOCOL:
234
+ if Read.as_int(announcement.get("protocol", 0)) != ANNOUNCE_PROTOCOL:
231
235
  return ""
232
- var port: int = int(announcement.get("port", 0))
236
+ var port: int = Read.as_int(announcement.get("port", 0))
233
237
  if port < 1 or port > 65535:
234
238
  return ""
235
239
  var host: String = str(announcement.get("host", "127.0.0.1"))
@@ -332,7 +336,7 @@ func _serving(setting: String) -> int:
332
336
  var settings: EditorSettings = EditorInterface.get_editor_settings()
333
337
  if settings == null or not settings.has_setting(setting):
334
338
  return 0
335
- return int(settings.get_setting(setting))
339
+ return Read.as_int(settings.get_setting(setting))
336
340
 
337
341
 
338
342
  ## The version marker beside this addon, or "" when the copy was not installed by gdharness.
@@ -434,7 +438,9 @@ func send_tool_result(request_id: String, success: bool, result: Variant = null,
434
438
 
435
439
  func _send_message(message: Dictionary) -> void:
436
440
  if socket.get_ready_state() == WebSocketPeer.STATE_OPEN:
437
- socket.send_text(JSON.stringify(message))
441
+ var sent: Error = socket.send_text(JSON.stringify(message))
442
+ if sent != OK:
443
+ push_error("gdharness: could not send to the server: " + error_string(sent))
438
444
 
439
445
 
440
446
  func is_connected_to_server() -> bool:
@@ -23,9 +23,9 @@ func _enter_tree() -> void:
23
23
  add_child(_tool_executor)
24
24
  _tool_executor.set_editor_plugin(self)
25
25
 
26
- _client.connected.connect(_on_connected)
27
- _client.disconnected.connect(_on_disconnected)
28
- _client.tool_requested.connect(_on_tool_requested)
26
+ _listen(_client.connected, _on_connected)
27
+ _listen(_client.disconnected, _on_disconnected)
28
+ _listen(_client.tool_requested, _on_tool_requested)
29
29
 
30
30
  _setup_status_indicator()
31
31
  _client.connect_to_server()
@@ -53,6 +53,18 @@ func _exit_tree() -> void:
53
53
  _status_label = null
54
54
 
55
55
 
56
+ # A failed connect is a mistake in this plugin rather than anything the editor did, so it goes to
57
+ # the error stream instead of being dropped: a project holding return_value_discarded at error level
58
+ # refuses to compile a script that throws the answer away.
59
+ func _listen(source: Signal, handler: Callable) -> void:
60
+ # int rather than Error: Signal.connect answers with a plain int, where Object.connect answers
61
+ # with the enum, and a project holding int_as_enum_without_cast at error level refuses the
62
+ # assignment that conflates them.
63
+ var joined: int = source.connect(handler)
64
+ if joined != OK:
65
+ push_error("gdharness: could not listen to " + source.get_name())
66
+
67
+
56
68
  func _setup_status_indicator() -> void:
57
69
  _status_label = Label.new()
58
70
  _status_label.text = "gdharness: connecting"
@@ -82,7 +94,8 @@ func _on_tool_requested(request_id: String, tool_name: String, args: Dictionary)
82
94
 
83
95
  if success:
84
96
  var payload: Dictionary = result.duplicate(true)
85
- payload.erase("ok")
97
+ if not payload.erase("ok"):
98
+ push_error("gdharness: a tool answered ok without an ok field to remove")
86
99
  _client.send_tool_result(request_id, true, payload, "")
87
100
  else:
88
101
  _client.send_tool_result(request_id, false, null, str(result.get("error", "Unknown error")))
@@ -0,0 +1,64 @@
1
+ extends RefCounted
2
+
3
+ # A value out of a request that arrived as JSON, as the type the caller wants.
4
+ #
5
+ # `int(value)`, `float(value)` and `bool(value)` take a Variant, and a project holding
6
+ # `unsafe_call_argument` at error level will not compile a script that hands one over. The
7
+ # conversion is the same; what changes is that the type is established first, on a typed local, and
8
+ # the conversion happens from that.
9
+ #
10
+ # A copy rather than a preload of the one beside the operations, because an addon is installed as a
11
+ # directory and has to hold everything it uses. Reaching into the runtime addon instead would tie an
12
+ # editor tool to a directory that ships inside exported games.
13
+ #
14
+ # `fallback` answers a value that is there and is null, which is a caller sending JSON null where a
15
+ # number was expected. Absent keys never reach here: the callers pass their own default to
16
+ # `Dictionary.get`, so what arrives is that default.
17
+
18
+
19
+ static func as_int(value: Variant, fallback: int = 0) -> int:
20
+ if value is int:
21
+ var already: int = value
22
+ return already
23
+ if value is float:
24
+ var real: float = value
25
+ return int(real)
26
+ if value is bool:
27
+ var flag: bool = value
28
+ return 1 if flag else 0
29
+ if value is String:
30
+ var text: String = value
31
+ return text.to_int()
32
+ return fallback
33
+
34
+
35
+ static func as_float(value: Variant, fallback: float = 0.0) -> float:
36
+ if value is float:
37
+ var already: float = value
38
+ return already
39
+ if value is int:
40
+ var whole: int = value
41
+ return float(whole)
42
+ if value is bool:
43
+ var flag: bool = value
44
+ return 1.0 if flag else 0.0
45
+ if value is String:
46
+ var text: String = value
47
+ return text.to_float()
48
+ return fallback
49
+
50
+
51
+ static func as_bool(value: Variant, fallback: bool = false) -> bool:
52
+ if value is bool:
53
+ var already: bool = value
54
+ return already
55
+ if value is int:
56
+ var whole: int = value
57
+ return whole != 0
58
+ if value is float:
59
+ var real: float = value
60
+ return not is_zero_approx(real)
61
+ if value is String:
62
+ var text: String = value
63
+ return not text.is_empty()
64
+ return fallback
@@ -0,0 +1 @@
1
+ uid://xl0fokpr7xfd