gdharness 0.6.8 → 0.7.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
@@ -15034,6 +15034,13 @@ function readParams(params, key) {
15034
15034
  }
15035
15035
 
15036
15036
  // src/tool-definitions.ts
15037
+ function opTakes(spec, op, name) {
15038
+ const ops = spec.parameters[name]?.ops;
15039
+ return ops === undefined || ops.includes(op);
15040
+ }
15041
+ function argumentsOf(spec, op) {
15042
+ return Object.keys(spec.parameters).filter((name) => opTakes(spec, op, name));
15043
+ }
15037
15044
  function toolSpec(name) {
15038
15045
  return SPECS_BY_NAME[name];
15039
15046
  }
@@ -15059,7 +15066,8 @@ function buildToolDefinitions() {
15059
15066
  };
15060
15067
  }
15061
15068
  for (const [name, schema] of Object.entries(spec.parameters)) {
15062
- properties[name] = schema;
15069
+ const { ops: _ops, ...carried } = schema;
15070
+ properties[name] = carried;
15063
15071
  }
15064
15072
  const required = [...spec.requires];
15065
15073
  if (spec.operations && !spec.defaultOperation) {
@@ -15380,7 +15388,7 @@ var init_tool_definitions = __esm(() => {
15380
15388
  },
15381
15389
  {
15382
15390
  name: "project_test",
15383
- description: "Runs the project's gdUnit4 tests headless and answers with every case: which failed, where, and what the assertion said. 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. Needs gdUnit4 under addons/gdUnit4.",
15391
+ 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. Needs gdUnit4 under addons/gdUnit4.",
15384
15392
  parameters: {
15385
15393
  projectPath: PROJECT_PATH,
15386
15394
  path: {
@@ -15749,35 +15757,46 @@ var init_tool_definitions = __esm(() => {
15749
15757
  projectPath: RUNNING_PROJECT_PATH,
15750
15758
  nodePath: {
15751
15759
  type: "string",
15760
+ ops: ["tree", "text", "find", "rect", "property"],
15752
15761
  description: "tree, find, text: where to start, default /root. rect: the node to place. property: the node to read."
15753
15762
  },
15754
15763
  property: {
15755
15764
  type: "string",
15765
+ ops: ["property", "find"],
15756
15766
  description: "property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label."
15757
15767
  },
15758
- depth: { type: "number", description: "tree: levels to descend. Default 3." },
15768
+ depth: { type: "number", ops: ["tree"], description: "tree: levels to descend. Default 3." },
15759
15769
  includeProperties: {
15760
15770
  type: "boolean",
15771
+ ops: ["tree"],
15761
15772
  description: "tree: include each node's properties. Default false."
15762
15773
  },
15763
15774
  className: {
15764
15775
  type: "string",
15776
+ ops: ["find"],
15765
15777
  description: "find: a native class, matching its subclasses too, or a class_name."
15766
15778
  },
15767
- script: { type: "string", description: "find: the script file the node carries." },
15779
+ script: { type: "string", ops: ["find"], description: "find: the script file the node carries." },
15768
15780
  namePattern: {
15769
15781
  type: "string",
15782
+ ops: ["find"],
15770
15783
  description: 'find: a case-insensitive glob on the node name, such as "Enemy*".'
15771
15784
  },
15772
- group: { type: "string", description: "find: a group the node is in." },
15773
- limit: { type: "number", description: "find: the most nodes to answer with. Default 100." },
15785
+ group: { type: "string", ops: ["find"], description: "find: a group the node is in." },
15786
+ limit: {
15787
+ type: "number",
15788
+ ops: ["find", "text"],
15789
+ description: "find: the most nodes to answer with, default 100. text: the most lines, default 500, with truncated saying whether there were more."
15790
+ },
15774
15791
  includeHidden: {
15775
15792
  type: "boolean",
15793
+ ops: ["text"],
15776
15794
  description: "text: read hidden nodes as well, for checking that something is not showing. Default false."
15777
15795
  },
15778
15796
  metrics: {
15779
15797
  type: "array",
15780
15798
  items: { type: "string" },
15799
+ ops: ["metrics"],
15781
15800
  description: "metrics: which to read. Default all."
15782
15801
  }
15783
15802
  },
@@ -15810,10 +15829,14 @@ var init_tool_definitions = __esm(() => {
15810
15829
  parameters: {
15811
15830
  projectPath: RUNNING_PROJECT_PATH,
15812
15831
  nodePath: { type: "string", description: 'Absolute node path, such as "/root/Main/Player".' },
15813
- property: { type: "string" },
15814
- value: { description: "set: the value, fitted to the property's type." },
15815
- method: { type: "string" },
15816
- args: { type: "array", description: "call: the arguments, fitted to the method's parameter types." }
15832
+ property: { type: "string", ops: ["set"] },
15833
+ value: { ops: ["set"], description: "set: the value, fitted to the property's type." },
15834
+ method: { type: "string", ops: ["call"] },
15835
+ args: {
15836
+ type: "array",
15837
+ ops: ["call"],
15838
+ description: "call: the arguments, fitted to the method's parameter types."
15839
+ }
15817
15840
  },
15818
15841
  requires: ["nodePath"],
15819
15842
  operations: {
@@ -15828,6 +15851,7 @@ var init_tool_definitions = __esm(() => {
15828
15851
  projectPath: RUNNING_PROJECT_PATH,
15829
15852
  viewportPath: {
15830
15853
  type: "string",
15854
+ ops: ["viewport"],
15831
15855
  description: "viewport: the Viewport node. Default the root viewport."
15832
15856
  },
15833
15857
  width: { type: "number", description: "Scale the image to this width." },
@@ -15847,38 +15871,69 @@ var init_tool_definitions = __esm(() => {
15847
15871
  projectPath: RUNNING_PROJECT_PATH,
15848
15872
  nodePath: {
15849
15873
  type: "string",
15874
+ ops: ["click", "choose"],
15850
15875
  description: "click: the Control to click, at its centre, or the 3D node to click, where it is drawn. choose: the PopupMenu, or the OptionButton or MenuButton in front of one."
15851
15876
  },
15852
- action: { type: "string", description: "action: the InputMap action name." },
15877
+ action: { type: "string", ops: ["action"], description: "action: the InputMap action name." },
15853
15878
  pressed: {
15854
15879
  type: "boolean",
15855
- description: "action, key: leave it out and the press is a whole one, down and up a frame apart. true holds it down, false lets go of one being held."
15880
+ ops: ["action", "key", "mouse_click"],
15881
+ description: "action, key: leave it out and the press is a whole one, down and up a frame apart. true holds it down, false lets go of one being held. mouse_click is one raw event, so it is down unless you say false."
15856
15882
  },
15857
- strength: { type: "number", description: "action: 0 to 1. Default 1." },
15883
+ strength: { type: "number", ops: ["action"], description: "action: 0 to 1. Default 1." },
15858
15884
  keycode: {
15859
15885
  type: ["string", "number"],
15886
+ ops: ["key"],
15860
15887
  description: 'key: the key name, such as "Space" or "A", or its Godot keycode.'
15861
15888
  },
15862
15889
  text: {
15863
15890
  type: "string",
15891
+ ops: ["text", "choose"],
15864
15892
  description: "text: what to type. A newline is Enter and a tab is Tab. choose: the item to take, by what it says."
15865
15893
  },
15894
+ replace: {
15895
+ type: "boolean",
15896
+ ops: ["text"],
15897
+ description: 'text: true writes over what the field already says, which is what filling one in means. Default false types at the caret, so a field reading 2.1 typed "0.3" at reads 2.10.3. The answer says what the field holds afterwards either way.'
15898
+ },
15866
15899
  index: {
15867
15900
  type: "number",
15901
+ ops: ["choose"],
15868
15902
  description: "choose: the item to take, by where it is in the list, when text will not do."
15869
15903
  },
15870
- shift: { type: "boolean" },
15871
- ctrl: { type: "boolean" },
15872
- alt: { type: "boolean" },
15873
- x: { type: "number", description: "mouse_click, mouse_motion: window pixels." },
15874
- y: { type: "number", description: "mouse_click, mouse_motion: window pixels." },
15904
+ shift: { type: "boolean", ops: ["key"] },
15905
+ ctrl: { type: "boolean", ops: ["key"] },
15906
+ alt: { type: "boolean", ops: ["key"] },
15907
+ x: {
15908
+ type: "number",
15909
+ ops: ["mouse_click", "mouse_motion"],
15910
+ description: "mouse_click, mouse_motion: window pixels."
15911
+ },
15912
+ y: {
15913
+ type: "number",
15914
+ ops: ["mouse_click", "mouse_motion"],
15915
+ description: "mouse_click, mouse_motion: window pixels."
15916
+ },
15875
15917
  button: {
15876
15918
  type: ["string", "number"],
15919
+ ops: ["click", "mouse_click"],
15877
15920
  description: "click, mouse_click: left, right, middle, wheel_up or wheel_down, or a button number. Default left."
15878
15921
  },
15879
- doubleClick: { type: "boolean", description: "click, mouse_click: default false." },
15880
- relativeX: { type: "number", description: "mouse_motion: movement since the last event." },
15881
- relativeY: { type: "number", description: "mouse_motion: movement since the last event." }
15922
+ doubleClick: {
15923
+ type: "boolean",
15924
+ ops: ["click", "mouse_click"],
15925
+ description: "click, mouse_click: default false."
15926
+ },
15927
+ relativeX: {
15928
+ type: "number",
15929
+ ops: ["mouse_motion"],
15930
+ description: "mouse_motion: movement since the last event."
15931
+ },
15932
+ relativeY: {
15933
+ type: "number",
15934
+ ops: ["mouse_motion"],
15935
+ description: "mouse_motion: movement since the last event."
15936
+ }
15882
15937
  },
15883
15938
  requires: [],
15884
15939
  operations: {
@@ -15907,14 +15962,16 @@ var init_tool_definitions = __esm(() => {
15907
15962
  projectPath: RUNNING_PROJECT_PATH,
15908
15963
  frames: {
15909
15964
  type: "number",
15965
+ ops: ["frames"],
15910
15966
  description: "frames: how many to let pass, 1 to 600. More than that is refused."
15911
15967
  },
15912
- nodePath: { type: "string", description: "signal, until: the node." },
15913
- signal: { type: "string", description: "signal: the signal name." },
15914
- property: { type: "string", description: "until: the property name." },
15915
- value: { description: "until: the value to wait for, fitted to the property's type." },
15968
+ nodePath: { type: "string", ops: ["signal", "until"], description: "signal, until: the node." },
15969
+ signal: { type: "string", ops: ["signal"], description: "signal: the signal name." },
15970
+ property: { type: "string", ops: ["until"], description: "until: the property name." },
15971
+ value: { ops: ["until"], description: "until: the value to wait for, fitted to the property's type." },
15916
15972
  timeoutMs: {
15917
15973
  type: "number",
15974
+ ops: ["signal", "until"],
15918
15975
  description: "signal, until: how long to wait before answering anyway, 1 to 120000. Default 5000."
15919
15976
  }
15920
15977
  },
@@ -15962,6 +16019,7 @@ var init_tool_definitions = __esm(() => {
15962
16019
  parameters: {
15963
16020
  frameId: {
15964
16021
  type: "number",
16022
+ ops: ["variables"],
15965
16023
  description: "variables: which frame, from a stack answer. Default the innermost."
15966
16024
  }
15967
16025
  },
@@ -35923,6 +35981,17 @@ function realPathOr(path) {
35923
35981
  return path;
35924
35982
  }
35925
35983
  }
35984
+ function aboveTheRunner(entry) {
35985
+ const runner = entry.detail.findIndex((line) => line.includes(RUNNER_DIRECTORY));
35986
+ if (runner < 0) {
35987
+ return entry;
35988
+ }
35989
+ const cut = entry.detail.length - runner;
35990
+ return {
35991
+ ...entry,
35992
+ detail: [...entry.detail.slice(0, runner), `[and ${cut} frames inside ${RUNNER_DIRECTORY}]`]
35993
+ };
35994
+ }
35926
35995
  function hasMainScene(projectFile) {
35927
35996
  const scene = parseProjectGodot(readFileSync12(projectFile, "utf8"))["application"]?.["run/main_scene"];
35928
35997
  return typeof scene === "string" && scene !== "";
@@ -36265,6 +36334,15 @@ class GodotServer {
36265
36334
  };
36266
36335
  }
36267
36336
  }
36337
+ if (op !== null) {
36338
+ const elsewhere = Object.keys(args).filter((key) => key !== "op" && !opTakes(spec, op, key));
36339
+ if (elsewhere.length > 0) {
36340
+ return {
36341
+ ok: false,
36342
+ response: this.createErrorResponse(`${spec.name} ${op} does not take ${elsewhere.join(", ")}. ${op} takes: ${argumentsOf(spec, op).join(", ")}.`)
36343
+ };
36344
+ }
36345
+ }
36268
36346
  const required = [...spec.requires, ...op !== null ? spec.operations?.[op]?.requires ?? [] : []];
36269
36347
  const missing = required.filter((field) => {
36270
36348
  const value = Object.hasOwn(args, field) ? args[field] : undefined;
@@ -36389,7 +36467,8 @@ class GodotServer {
36389
36467
  return await this.handleRuntimeCommand("read_text", {
36390
36468
  projectPath: args["projectPath"],
36391
36469
  root: readNonEmptyString(args, "nodePath") ?? "/root",
36392
- include_hidden: readBoolean(args, "includeHidden") ?? false
36470
+ include_hidden: readBoolean(args, "includeHidden") ?? false,
36471
+ limit: readPositiveNumber(args, "limit") ?? 500
36393
36472
  });
36394
36473
  case "rect":
36395
36474
  return await this.handleRuntimeCommand("get_rect", {
@@ -36815,7 +36894,7 @@ class GodotServer {
36815
36894
  rmSync8(reportsDir, { recursive: true, force: true });
36816
36895
  rmSync8(userData, { recursive: true, force: true });
36817
36896
  }
36818
- const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries;
36897
+ const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries.map(aboveTheRunner);
36819
36898
  const verdicts = {
36820
36899
  0: "passed",
36821
36900
  100: "failures",
@@ -36847,6 +36926,7 @@ class GodotServer {
36847
36926
  };
36848
36927
  }
36849
36928
  const failed = report.suites.flatMap((suite) => suite.cases.filter((entry) => entry.status === "failed" || entry.status === "error").map((entry) => ({ ...entry, path: suite.path })));
36929
+ const unclean = report.suites.filter((suite) => suite.failures > 0 || suite.errors > 0 || suite.skipped > 0);
36850
36930
  return this.jsonTextResponse({
36851
36931
  passed: !hung && exitCode === 0 && report.failures === 0 && report.errors === 0,
36852
36932
  verdict,
@@ -36857,7 +36937,7 @@ class GodotServer {
36857
36937
  skipped: report.skipped,
36858
36938
  time: report.time,
36859
36939
  failed,
36860
- suites: report.suites.map((suite) => ({
36940
+ suites: unclean.map((suite) => ({
36861
36941
  name: suite.name,
36862
36942
  path: suite.path,
36863
36943
  tests: suite.tests,
@@ -36866,6 +36946,7 @@ class GodotServer {
36866
36946
  skipped: suite.skipped,
36867
36947
  time: suite.time
36868
36948
  })),
36949
+ suitesPassed: report.suites.length - unclean.length,
36869
36950
  engineErrors: run.log.count("error"),
36870
36951
  engineWarnings: run.log.count("warning"),
36871
36952
  engineEntries,
@@ -37431,38 +37512,33 @@ class GodotServer {
37431
37512
  });
37432
37513
  }
37433
37514
  async handleRuntimeWait(op, args) {
37515
+ if (op === "frames") {
37516
+ const frames = readPositiveNumber(args, "frames") ?? 1;
37517
+ const waited = patienceForFrames(frames, this.runtimeTimeoutMs());
37518
+ return await this.handleRuntimeCommand("wait_frames", { projectPath: args["projectPath"], frames }, waited);
37519
+ }
37434
37520
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 5000;
37435
37521
  const nodePath = readNonEmptyString(args, "nodePath") ?? "";
37436
37522
  const patience = Math.max(this.runtimeTimeoutMs(), timeoutMs + 5000);
37437
- switch (op) {
37438
- case "frames": {
37439
- const frames = readPositiveNumber(args, "frames") ?? 1;
37440
- const waited = patienceForFrames(frames, patience);
37441
- return await this.handleRuntimeCommand("wait_frames", { projectPath: args["projectPath"], frames }, waited);
37442
- }
37443
- case "signal":
37444
- return await this.handleRuntimeCommand("wait_signal", {
37445
- projectPath: args["projectPath"],
37446
- path: nodePath,
37447
- signal: readString(args, "signal") ?? "",
37448
- timeout_ms: timeoutMs
37449
- }, patience);
37450
- default:
37451
- return await this.handleRuntimeCommand("wait_until", {
37452
- projectPath: args["projectPath"],
37453
- path: nodePath,
37454
- property: readString(args, "property") ?? "",
37455
- value: args["value"],
37456
- timeout_ms: timeoutMs
37457
- }, patience);
37458
- }
37523
+ return op === "signal" ? await this.handleRuntimeCommand("wait_signal", {
37524
+ projectPath: args["projectPath"],
37525
+ path: nodePath,
37526
+ signal: readString(args, "signal") ?? "",
37527
+ timeout_ms: timeoutMs
37528
+ }, patience) : await this.handleRuntimeCommand("wait_until", {
37529
+ projectPath: args["projectPath"],
37530
+ path: nodePath,
37531
+ property: readString(args, "property") ?? "",
37532
+ value: args["value"],
37533
+ timeout_ms: timeoutMs
37534
+ }, patience);
37459
37535
  }
37460
37536
  }
37461
37537
  async function runGodotServer() {
37462
37538
  const server = new GodotServer;
37463
37539
  await server.run();
37464
37540
  }
37465
- 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, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, HEADLESS_OPERATIONS, PROJECT_INFO_SECTIONS;
37541
+ 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, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, HEADLESS_OPERATIONS, PROJECT_INFO_SECTIONS, RUNNER_DIRECTORY = "addons/gdUnit4/";
37466
37542
  var init_server3 = __esm(() => {
37467
37543
  init_mcp();
37468
37544
  init_stdio2();
@@ -38766,7 +38842,9 @@ function renderToolsMarkdown() {
38766
38842
  const needs = [...tool.requires, ...spec.requires];
38767
38843
  const isDefault = tool.defaultOperation === op ? " (default)" : "";
38768
38844
  const wants = needs.length > 0 ? ` Needs: ${needs.map((name) => `\`${name}\``).join(", ")}.` : "";
38769
- lines.push(`- \`op: ${op}\`${isDefault}: ${spec.summary}.${wants}`);
38845
+ const spare = argumentsOf(tool, op).filter((name) => !needs.includes(name) && name !== "projectPath");
38846
+ const takes = spare.length > 0 ? ` Takes: ${spare.map((name) => `\`${name}\``).join(", ")}.` : "";
38847
+ lines.push(`- \`op: ${op}\`${isDefault}: ${spec.summary}.${wants}${takes}`);
38770
38848
  }
38771
38849
  lines.push("");
38772
38850
  } else if (tool.requires.length > 0) {
@@ -38830,7 +38908,7 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
38830
38908
  | Where a control is, and whether it is visible | \`runtime_inspect\` \`find\`, \`rect\` |
38831
38909
  | What a property reads right now | \`runtime_inspect\` \`property\`, \`runtime_invoke\` |
38832
38910
  | Press a button | \`runtime_input click\`, which says what was under the pointer |
38833
- | Fill in a field | \`runtime_input click\` on it, then \`runtime_input text\`; a submitted field has to be clicked again |
38911
+ | Fill in a field | \`runtime_input click\` on it, then \`runtime_input text\` with \`replace\`; a submitted field has to be clicked again |
38834
38912
  | Click somebody standing in a 3D room | \`runtime_input click\` on the Node3D, which aims at what it draws |
38835
38913
  | Pick something out of a dropdown | \`runtime_input choose\` on it, by what the item says |
38836
38914
  | Answer a dialog, or press a bound key | \`runtime_input action\` or \`key\`, a whole press unless you hold it |
@@ -155,6 +155,11 @@ func inject_key(params: Dictionary) -> Dictionary:
155
155
  ## Pushed into the viewport for the reason [method click] is, and it matters more here: the focus
156
156
  ## is what decides where a character lands, so a caller that clicked a field and then typed would
157
157
  ## otherwise have both waiting in the same queue with nothing said about the order.
158
+ ##
159
+ ## [code]replace[/code] is for a field that already says something, which is most of them: typing
160
+ ## lands at the caret, so a spin box reading 2.1 typed "0.3" at reads 2.10.3 and parses back to
161
+ ## 2.1, and the answer said four characters had gone in. What the field holds afterwards is read
162
+ ## back now rather than echoed from the request, so a call that typed somewhere unhelpful says so.
158
163
  func inject_text(params: Dictionary) -> Dictionary:
159
164
  var text: String = String(params.get("text", ""))
160
165
  if text.is_empty():
@@ -166,6 +171,7 @@ func inject_text(params: Dictionary) -> Dictionary:
166
171
  if not shut.is_empty():
167
172
  return {"type": "error", "message": shut}
168
173
 
174
+ var replaced: bool = bool(params.get("replace", false)) and _select_everything(focused)
169
175
  for index: int in text.length():
170
176
  var down: InputEventKey = _typed(text.unicode_at(index))
171
177
  viewport.push_input(down)
@@ -186,9 +192,44 @@ func inject_text(params: Dictionary) -> Dictionary:
186
192
  "text": text,
187
193
  "characters": text.length(),
188
194
  "into": into,
195
+ "replaced": replaced,
196
+ "holds": _what_it_holds(focused),
189
197
  }
190
198
 
191
199
 
200
+ ## Selects everything in [param focused] so the next character typed writes over it, and answers
201
+ ## whether there was a field to select in.
202
+ ##
203
+ ## Through the control rather than through a Ctrl+A, which is the one part of filling a field that
204
+ ## cannot honestly be sent as a key: the shortcut is Cmd+A on macOS and is an [InputMap] action a
205
+ ## project is free to unbind, and a caller asking for the field to be replaced would then be typing
206
+ ## on the end of what was there. The replacement itself is still every character a player types.
207
+ static func _select_everything(focused: Control) -> bool:
208
+ var field: LineEdit = focused as LineEdit
209
+ if field != null:
210
+ field.select_all()
211
+ return true
212
+ var box: TextEdit = focused as TextEdit
213
+ if box == null:
214
+ return false
215
+ box.select_all()
216
+ return true
217
+
218
+
219
+ ## What the field says now, or null where the focus is not a field at all.
220
+ ##
221
+ ## A spin box rewrites its own text out of the number it parsed, so this is the only thing that
222
+ ## says whether what was typed became what the field means.
223
+ static func _what_it_holds(focused: Control) -> Variant:
224
+ var field: LineEdit = focused as LineEdit
225
+ if field != null:
226
+ return field.text
227
+ var box: TextEdit = focused as TextEdit
228
+ if box == null:
229
+ return null
230
+ return box.text
231
+
232
+
192
233
  ## Why nothing typed would reach [param focused], or "" when it would.
193
234
  ##
194
235
  ## Having the focus is not the same as being edited. Since Godot 4.4 a field is focused and shut
@@ -10,8 +10,8 @@ const Values = preload("runtime_values.gd")
10
10
  const FIND_LIMIT: int = 100
11
11
  const FIND_LIMIT_CEILING: int = 1000
12
12
 
13
- ## The most lines one read answers with. A screen is a few dozen; a thousand is a tree somebody
14
- ## pointed this at by mistake.
13
+ ## The most lines one read answers with, unless asked for fewer. A screen is a few dozen; a
14
+ ## thousand is a tree somebody pointed this at by mistake.
15
15
  const READ_LIMIT: int = 500
16
16
 
17
17
  var _host: Node
@@ -153,22 +153,32 @@ func _matches(
153
153
  ## A hidden node is left out and so is everything under it, because what a player reads is what is
154
154
  ## drawn. [param include_hidden] asks for the lot instead, which is what a caller checking that
155
155
  ## something is not showing wants.
156
+ ##
157
+ ## [param limit] is the first few lines rather than all of them, which is how the top of a screen
158
+ ## is read without the hall under it: the bar along the top of a guild is eleven lines and the
159
+ ## panel it sits on is two hundred.
156
160
  func read_text(params: Dictionary) -> Dictionary:
157
161
  var root_path: String = str(params.get("root", "/root"))
158
162
  var include_hidden: bool = bool(params.get("include_hidden", false))
163
+ var limit: int = clampi(int(params.get("limit", READ_LIMIT)), 1, READ_LIMIT)
159
164
 
160
165
  var root: Node = _host.get_tree().root.get_node_or_null(root_path)
161
166
  if root == null:
162
167
  return {"type": "error", "message": "Node not found: " + root_path}
163
168
 
164
- var lines: PackedStringArray = PackedStringArray()
165
- _read_into(root, include_hidden, lines)
169
+ # One line further than asked for, so that whether anything was left behind is read off the
170
+ # walk rather than guessed at from the count: a panel of exactly as many lines as the caller
171
+ # asked for is one they have read all of, and saying otherwise sends them back for nothing.
172
+ var read: PackedStringArray = PackedStringArray()
173
+ _read_into(root, include_hidden, limit + 1, read)
174
+ var more: bool = read.size() > limit
175
+ var lines: PackedStringArray = read.slice(0, limit) if more else read
166
176
  return {
167
177
  "type": "text",
168
178
  "root": root_path,
169
179
  "lines": lines,
170
180
  "count": lines.size(),
171
- "truncated": lines.size() >= READ_LIMIT,
181
+ "truncated": more,
172
182
  }
173
183
 
174
184
 
@@ -180,8 +190,8 @@ func read_text(params: Dictionary) -> Dictionary:
180
190
  ## screen full of forms answered with every label on it and none of the values in it. The hidden
181
191
  ## check covers a [Window] for the same walk: a dropdown's popup is a child that is not drawn
182
192
  ## until it is opened, and reading a closed menu would put every item on the screen.
183
- func _read_into(node: Node, include_hidden: bool, into: PackedStringArray) -> void:
184
- if into.size() >= READ_LIMIT:
193
+ func _read_into(node: Node, include_hidden: bool, most: int, into: PackedStringArray) -> void:
194
+ if into.size() >= most:
185
195
  return
186
196
  if not include_hidden and not _drawn(node):
187
197
  return
@@ -189,7 +199,7 @@ func _read_into(node: Node, include_hidden: bool, into: PackedStringArray) -> vo
189
199
  if not said.is_empty():
190
200
  into.append(said)
191
201
  for child: Node in node.get_children(true):
192
- _read_into(child, include_hidden, into)
202
+ _read_into(child, include_hidden, most, into)
193
203
 
194
204
 
195
205
  ## Whether [param node] is on the screen at all, for the two kinds of thing that can be hidden.
package/build/index.js CHANGED
@@ -27633,6 +27633,13 @@ function installedAddonVersion(projectPath) {
27633
27633
  }
27634
27634
 
27635
27635
  // src/tool-definitions.ts
27636
+ function opTakes(spec, op, name) {
27637
+ const ops = spec.parameters[name]?.ops;
27638
+ return ops === undefined || ops.includes(op);
27639
+ }
27640
+ function argumentsOf(spec, op) {
27641
+ return Object.keys(spec.parameters).filter((name) => opTakes(spec, op, name));
27642
+ }
27636
27643
  var PROJECT_PATH = {
27637
27644
  type: "string",
27638
27645
  description: "Absolute path to the project directory, the one holding project.godot."
@@ -27934,7 +27941,7 @@ var TOOL_SPECS = [
27934
27941
  },
27935
27942
  {
27936
27943
  name: "project_test",
27937
- description: "Runs the project's gdUnit4 tests headless and answers with every case: which failed, where, and what the assertion said. 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. Needs gdUnit4 under addons/gdUnit4.",
27944
+ 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. Needs gdUnit4 under addons/gdUnit4.",
27938
27945
  parameters: {
27939
27946
  projectPath: PROJECT_PATH,
27940
27947
  path: {
@@ -28303,35 +28310,46 @@ var TOOL_SPECS = [
28303
28310
  projectPath: RUNNING_PROJECT_PATH,
28304
28311
  nodePath: {
28305
28312
  type: "string",
28313
+ ops: ["tree", "text", "find", "rect", "property"],
28306
28314
  description: "tree, find, text: where to start, default /root. rect: the node to place. property: the node to read."
28307
28315
  },
28308
28316
  property: {
28309
28317
  type: "string",
28318
+ ops: ["property", "find"],
28310
28319
  description: "property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label."
28311
28320
  },
28312
- depth: { type: "number", description: "tree: levels to descend. Default 3." },
28321
+ depth: { type: "number", ops: ["tree"], description: "tree: levels to descend. Default 3." },
28313
28322
  includeProperties: {
28314
28323
  type: "boolean",
28324
+ ops: ["tree"],
28315
28325
  description: "tree: include each node's properties. Default false."
28316
28326
  },
28317
28327
  className: {
28318
28328
  type: "string",
28329
+ ops: ["find"],
28319
28330
  description: "find: a native class, matching its subclasses too, or a class_name."
28320
28331
  },
28321
- script: { type: "string", description: "find: the script file the node carries." },
28332
+ script: { type: "string", ops: ["find"], description: "find: the script file the node carries." },
28322
28333
  namePattern: {
28323
28334
  type: "string",
28335
+ ops: ["find"],
28324
28336
  description: 'find: a case-insensitive glob on the node name, such as "Enemy*".'
28325
28337
  },
28326
- group: { type: "string", description: "find: a group the node is in." },
28327
- limit: { type: "number", description: "find: the most nodes to answer with. Default 100." },
28338
+ group: { type: "string", ops: ["find"], description: "find: a group the node is in." },
28339
+ limit: {
28340
+ type: "number",
28341
+ ops: ["find", "text"],
28342
+ description: "find: the most nodes to answer with, default 100. text: the most lines, default 500, with truncated saying whether there were more."
28343
+ },
28328
28344
  includeHidden: {
28329
28345
  type: "boolean",
28346
+ ops: ["text"],
28330
28347
  description: "text: read hidden nodes as well, for checking that something is not showing. Default false."
28331
28348
  },
28332
28349
  metrics: {
28333
28350
  type: "array",
28334
28351
  items: { type: "string" },
28352
+ ops: ["metrics"],
28335
28353
  description: "metrics: which to read. Default all."
28336
28354
  }
28337
28355
  },
@@ -28364,10 +28382,14 @@ var TOOL_SPECS = [
28364
28382
  parameters: {
28365
28383
  projectPath: RUNNING_PROJECT_PATH,
28366
28384
  nodePath: { type: "string", description: 'Absolute node path, such as "/root/Main/Player".' },
28367
- property: { type: "string" },
28368
- value: { description: "set: the value, fitted to the property's type." },
28369
- method: { type: "string" },
28370
- args: { type: "array", description: "call: the arguments, fitted to the method's parameter types." }
28385
+ property: { type: "string", ops: ["set"] },
28386
+ value: { ops: ["set"], description: "set: the value, fitted to the property's type." },
28387
+ method: { type: "string", ops: ["call"] },
28388
+ args: {
28389
+ type: "array",
28390
+ ops: ["call"],
28391
+ description: "call: the arguments, fitted to the method's parameter types."
28392
+ }
28371
28393
  },
28372
28394
  requires: ["nodePath"],
28373
28395
  operations: {
@@ -28382,6 +28404,7 @@ var TOOL_SPECS = [
28382
28404
  projectPath: RUNNING_PROJECT_PATH,
28383
28405
  viewportPath: {
28384
28406
  type: "string",
28407
+ ops: ["viewport"],
28385
28408
  description: "viewport: the Viewport node. Default the root viewport."
28386
28409
  },
28387
28410
  width: { type: "number", description: "Scale the image to this width." },
@@ -28401,38 +28424,69 @@ var TOOL_SPECS = [
28401
28424
  projectPath: RUNNING_PROJECT_PATH,
28402
28425
  nodePath: {
28403
28426
  type: "string",
28427
+ ops: ["click", "choose"],
28404
28428
  description: "click: the Control to click, at its centre, or the 3D node to click, where it is drawn. choose: the PopupMenu, or the OptionButton or MenuButton in front of one."
28405
28429
  },
28406
- action: { type: "string", description: "action: the InputMap action name." },
28430
+ action: { type: "string", ops: ["action"], description: "action: the InputMap action name." },
28407
28431
  pressed: {
28408
28432
  type: "boolean",
28409
- description: "action, key: leave it out and the press is a whole one, down and up a frame apart. true holds it down, false lets go of one being held."
28433
+ ops: ["action", "key", "mouse_click"],
28434
+ description: "action, key: leave it out and the press is a whole one, down and up a frame apart. true holds it down, false lets go of one being held. mouse_click is one raw event, so it is down unless you say false."
28410
28435
  },
28411
- strength: { type: "number", description: "action: 0 to 1. Default 1." },
28436
+ strength: { type: "number", ops: ["action"], description: "action: 0 to 1. Default 1." },
28412
28437
  keycode: {
28413
28438
  type: ["string", "number"],
28439
+ ops: ["key"],
28414
28440
  description: 'key: the key name, such as "Space" or "A", or its Godot keycode.'
28415
28441
  },
28416
28442
  text: {
28417
28443
  type: "string",
28444
+ ops: ["text", "choose"],
28418
28445
  description: "text: what to type. A newline is Enter and a tab is Tab. choose: the item to take, by what it says."
28419
28446
  },
28447
+ replace: {
28448
+ type: "boolean",
28449
+ ops: ["text"],
28450
+ description: 'text: true writes over what the field already says, which is what filling one in means. Default false types at the caret, so a field reading 2.1 typed "0.3" at reads 2.10.3. The answer says what the field holds afterwards either way.'
28451
+ },
28420
28452
  index: {
28421
28453
  type: "number",
28454
+ ops: ["choose"],
28422
28455
  description: "choose: the item to take, by where it is in the list, when text will not do."
28423
28456
  },
28424
- shift: { type: "boolean" },
28425
- ctrl: { type: "boolean" },
28426
- alt: { type: "boolean" },
28427
- x: { type: "number", description: "mouse_click, mouse_motion: window pixels." },
28428
- y: { type: "number", description: "mouse_click, mouse_motion: window pixels." },
28457
+ shift: { type: "boolean", ops: ["key"] },
28458
+ ctrl: { type: "boolean", ops: ["key"] },
28459
+ alt: { type: "boolean", ops: ["key"] },
28460
+ x: {
28461
+ type: "number",
28462
+ ops: ["mouse_click", "mouse_motion"],
28463
+ description: "mouse_click, mouse_motion: window pixels."
28464
+ },
28465
+ y: {
28466
+ type: "number",
28467
+ ops: ["mouse_click", "mouse_motion"],
28468
+ description: "mouse_click, mouse_motion: window pixels."
28469
+ },
28429
28470
  button: {
28430
28471
  type: ["string", "number"],
28472
+ ops: ["click", "mouse_click"],
28431
28473
  description: "click, mouse_click: left, right, middle, wheel_up or wheel_down, or a button number. Default left."
28432
28474
  },
28433
- doubleClick: { type: "boolean", description: "click, mouse_click: default false." },
28434
- relativeX: { type: "number", description: "mouse_motion: movement since the last event." },
28435
- relativeY: { type: "number", description: "mouse_motion: movement since the last event." }
28475
+ doubleClick: {
28476
+ type: "boolean",
28477
+ ops: ["click", "mouse_click"],
28478
+ description: "click, mouse_click: default false."
28479
+ },
28480
+ relativeX: {
28481
+ type: "number",
28482
+ ops: ["mouse_motion"],
28483
+ description: "mouse_motion: movement since the last event."
28484
+ },
28485
+ relativeY: {
28486
+ type: "number",
28487
+ ops: ["mouse_motion"],
28488
+ description: "mouse_motion: movement since the last event."
28489
+ }
28436
28490
  },
28437
28491
  requires: [],
28438
28492
  operations: {
@@ -28461,14 +28515,16 @@ var TOOL_SPECS = [
28461
28515
  projectPath: RUNNING_PROJECT_PATH,
28462
28516
  frames: {
28463
28517
  type: "number",
28518
+ ops: ["frames"],
28464
28519
  description: "frames: how many to let pass, 1 to 600. More than that is refused."
28465
28520
  },
28466
- nodePath: { type: "string", description: "signal, until: the node." },
28467
- signal: { type: "string", description: "signal: the signal name." },
28468
- property: { type: "string", description: "until: the property name." },
28469
- value: { description: "until: the value to wait for, fitted to the property's type." },
28521
+ nodePath: { type: "string", ops: ["signal", "until"], description: "signal, until: the node." },
28522
+ signal: { type: "string", ops: ["signal"], description: "signal: the signal name." },
28523
+ property: { type: "string", ops: ["until"], description: "until: the property name." },
28524
+ value: { ops: ["until"], description: "until: the value to wait for, fitted to the property's type." },
28470
28525
  timeoutMs: {
28471
28526
  type: "number",
28527
+ ops: ["signal", "until"],
28472
28528
  description: "signal, until: how long to wait before answering anyway, 1 to 120000. Default 5000."
28473
28529
  }
28474
28530
  },
@@ -28516,6 +28572,7 @@ var TOOL_SPECS = [
28516
28572
  parameters: {
28517
28573
  frameId: {
28518
28574
  type: "number",
28575
+ ops: ["variables"],
28519
28576
  description: "variables: which frame, from a stack answer. Default the innermost."
28520
28577
  }
28521
28578
  },
@@ -28554,7 +28611,8 @@ function buildToolDefinitions() {
28554
28611
  };
28555
28612
  }
28556
28613
  for (const [name, schema] of Object.entries(spec.parameters)) {
28557
- properties[name] = schema;
28614
+ const { ops: _ops, ...carried } = schema;
28615
+ properties[name] = carried;
28558
28616
  }
28559
28617
  const required = [...spec.requires];
28560
28618
  if (spec.operations && !spec.defaultOperation) {
@@ -28703,6 +28761,18 @@ function realPathOr(path) {
28703
28761
  return path;
28704
28762
  }
28705
28763
  }
28764
+ var RUNNER_DIRECTORY = "addons/gdUnit4/";
28765
+ function aboveTheRunner(entry) {
28766
+ const runner = entry.detail.findIndex((line) => line.includes(RUNNER_DIRECTORY));
28767
+ if (runner < 0) {
28768
+ return entry;
28769
+ }
28770
+ const cut = entry.detail.length - runner;
28771
+ return {
28772
+ ...entry,
28773
+ detail: [...entry.detail.slice(0, runner), `[and ${cut} frames inside ${RUNNER_DIRECTORY}]`]
28774
+ };
28775
+ }
28706
28776
  function hasMainScene(projectFile) {
28707
28777
  const scene = parseProjectGodot(readFileSync8(projectFile, "utf8"))["application"]?.["run/main_scene"];
28708
28778
  return typeof scene === "string" && scene !== "";
@@ -29045,6 +29115,15 @@ class GodotServer {
29045
29115
  };
29046
29116
  }
29047
29117
  }
29118
+ if (op !== null) {
29119
+ const elsewhere = Object.keys(args).filter((key) => key !== "op" && !opTakes(spec, op, key));
29120
+ if (elsewhere.length > 0) {
29121
+ return {
29122
+ ok: false,
29123
+ response: this.createErrorResponse(`${spec.name} ${op} does not take ${elsewhere.join(", ")}. ${op} takes: ${argumentsOf(spec, op).join(", ")}.`)
29124
+ };
29125
+ }
29126
+ }
29048
29127
  const required = [...spec.requires, ...op !== null ? spec.operations?.[op]?.requires ?? [] : []];
29049
29128
  const missing = required.filter((field) => {
29050
29129
  const value = Object.hasOwn(args, field) ? args[field] : undefined;
@@ -29169,7 +29248,8 @@ class GodotServer {
29169
29248
  return await this.handleRuntimeCommand("read_text", {
29170
29249
  projectPath: args["projectPath"],
29171
29250
  root: readNonEmptyString(args, "nodePath") ?? "/root",
29172
- include_hidden: readBoolean(args, "includeHidden") ?? false
29251
+ include_hidden: readBoolean(args, "includeHidden") ?? false,
29252
+ limit: readPositiveNumber(args, "limit") ?? 500
29173
29253
  });
29174
29254
  case "rect":
29175
29255
  return await this.handleRuntimeCommand("get_rect", {
@@ -29595,7 +29675,7 @@ class GodotServer {
29595
29675
  rmSync4(reportsDir, { recursive: true, force: true });
29596
29676
  rmSync4(userData, { recursive: true, force: true });
29597
29677
  }
29598
- const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries;
29678
+ const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries.map(aboveTheRunner);
29599
29679
  const verdicts = {
29600
29680
  0: "passed",
29601
29681
  100: "failures",
@@ -29627,6 +29707,7 @@ class GodotServer {
29627
29707
  };
29628
29708
  }
29629
29709
  const failed = report.suites.flatMap((suite) => suite.cases.filter((entry) => entry.status === "failed" || entry.status === "error").map((entry) => ({ ...entry, path: suite.path })));
29710
+ const unclean = report.suites.filter((suite) => suite.failures > 0 || suite.errors > 0 || suite.skipped > 0);
29630
29711
  return this.jsonTextResponse({
29631
29712
  passed: !hung && exitCode === 0 && report.failures === 0 && report.errors === 0,
29632
29713
  verdict,
@@ -29637,7 +29718,7 @@ class GodotServer {
29637
29718
  skipped: report.skipped,
29638
29719
  time: report.time,
29639
29720
  failed,
29640
- suites: report.suites.map((suite) => ({
29721
+ suites: unclean.map((suite) => ({
29641
29722
  name: suite.name,
29642
29723
  path: suite.path,
29643
29724
  tests: suite.tests,
@@ -29646,6 +29727,7 @@ class GodotServer {
29646
29727
  skipped: suite.skipped,
29647
29728
  time: suite.time
29648
29729
  })),
29730
+ suitesPassed: report.suites.length - unclean.length,
29649
29731
  engineErrors: run.log.count("error"),
29650
29732
  engineWarnings: run.log.count("warning"),
29651
29733
  engineEntries,
@@ -30211,31 +30293,26 @@ class GodotServer {
30211
30293
  });
30212
30294
  }
30213
30295
  async handleRuntimeWait(op, args) {
30296
+ if (op === "frames") {
30297
+ const frames = readPositiveNumber(args, "frames") ?? 1;
30298
+ const waited = patienceForFrames(frames, this.runtimeTimeoutMs());
30299
+ return await this.handleRuntimeCommand("wait_frames", { projectPath: args["projectPath"], frames }, waited);
30300
+ }
30214
30301
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 5000;
30215
30302
  const nodePath = readNonEmptyString(args, "nodePath") ?? "";
30216
30303
  const patience = Math.max(this.runtimeTimeoutMs(), timeoutMs + 5000);
30217
- switch (op) {
30218
- case "frames": {
30219
- const frames = readPositiveNumber(args, "frames") ?? 1;
30220
- const waited = patienceForFrames(frames, patience);
30221
- return await this.handleRuntimeCommand("wait_frames", { projectPath: args["projectPath"], frames }, waited);
30222
- }
30223
- case "signal":
30224
- return await this.handleRuntimeCommand("wait_signal", {
30225
- projectPath: args["projectPath"],
30226
- path: nodePath,
30227
- signal: readString(args, "signal") ?? "",
30228
- timeout_ms: timeoutMs
30229
- }, patience);
30230
- default:
30231
- return await this.handleRuntimeCommand("wait_until", {
30232
- projectPath: args["projectPath"],
30233
- path: nodePath,
30234
- property: readString(args, "property") ?? "",
30235
- value: args["value"],
30236
- timeout_ms: timeoutMs
30237
- }, patience);
30238
- }
30304
+ return op === "signal" ? await this.handleRuntimeCommand("wait_signal", {
30305
+ projectPath: args["projectPath"],
30306
+ path: nodePath,
30307
+ signal: readString(args, "signal") ?? "",
30308
+ timeout_ms: timeoutMs
30309
+ }, patience) : await this.handleRuntimeCommand("wait_until", {
30310
+ projectPath: args["projectPath"],
30311
+ path: nodePath,
30312
+ property: readString(args, "property") ?? "",
30313
+ value: args["value"],
30314
+ timeout_ms: timeoutMs
30315
+ }, patience);
30239
30316
  }
30240
30317
  }
30241
30318
  async function runGodotServer() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.6.8",
3
+ "version": "0.7.0",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",