gdharness 0.6.9 → 0.8.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
@@ -15388,7 +15388,7 @@ var init_tool_definitions = __esm(() => {
15388
15388
  },
15389
15389
  {
15390
15390
  name: "project_test",
15391
- 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.",
15392
15392
  parameters: {
15393
15393
  projectPath: PROJECT_PATH,
15394
15394
  path: {
@@ -15783,6 +15783,11 @@ var init_tool_definitions = __esm(() => {
15783
15783
  description: 'find: a case-insensitive glob on the node name, such as "Enemy*".'
15784
15784
  },
15785
15785
  group: { type: "string", ops: ["find"], description: "find: a group the node is in." },
15786
+ says: {
15787
+ type: "string",
15788
+ ops: ["find"],
15789
+ description: "find: part of what the node has written on it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match."
15790
+ },
15786
15791
  limit: {
15787
15792
  type: "number",
15788
15793
  ops: ["find", "text"],
@@ -15808,7 +15813,7 @@ var init_tool_definitions = __esm(() => {
15808
15813
  requires: []
15809
15814
  },
15810
15815
  find: {
15811
- summary: "the paths of every node matching className, script, namePattern or group, with property read off each",
15816
+ summary: "the paths of every node matching className, script, namePattern, group or says, with property read off each",
15812
15817
  requires: []
15813
15818
  },
15814
15819
  rect: {
@@ -15874,7 +15879,11 @@ var init_tool_definitions = __esm(() => {
15874
15879
  ops: ["click", "choose"],
15875
15880
  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."
15876
15881
  },
15877
- action: { type: "string", ops: ["action"], description: "action: the InputMap action name." },
15882
+ action: {
15883
+ type: "string",
15884
+ ops: ["action"],
15885
+ description: "action: the InputMap action name. An engine dialog is not answered this way: AcceptDialog reads the Escape key itself and never asks the InputMap, so ui_cancel goes in and the question stays up. Dismiss one with key Escape, or click its button."
15886
+ },
15878
15887
  pressed: {
15879
15888
  type: "boolean",
15880
15889
  ops: ["action", "key", "mouse_click"],
@@ -15891,6 +15900,11 @@ var init_tool_definitions = __esm(() => {
15891
15900
  ops: ["text", "choose"],
15892
15901
  description: "text: what to type. A newline is Enter and a tab is Tab. choose: the item to take, by what it says."
15893
15902
  },
15903
+ replace: {
15904
+ type: "boolean",
15905
+ ops: ["text"],
15906
+ 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.'
15907
+ },
15894
15908
  index: {
15895
15909
  type: "number",
15896
15910
  ops: ["choose"],
@@ -35976,6 +35990,17 @@ function realPathOr(path) {
35976
35990
  return path;
35977
35991
  }
35978
35992
  }
35993
+ function aboveTheRunner(entry) {
35994
+ const runner = entry.detail.findIndex((line) => line.includes(RUNNER_DIRECTORY));
35995
+ if (runner < 0) {
35996
+ return entry;
35997
+ }
35998
+ const cut = entry.detail.length - runner;
35999
+ return {
36000
+ ...entry,
36001
+ detail: [...entry.detail.slice(0, runner), `[and ${cut} frames inside ${RUNNER_DIRECTORY}]`]
36002
+ };
36003
+ }
35979
36004
  function hasMainScene(projectFile) {
35980
36005
  const scene = parseProjectGodot(readFileSync12(projectFile, "utf8"))["application"]?.["run/main_scene"];
35981
36006
  return typeof scene === "string" && scene !== "";
@@ -36878,7 +36903,7 @@ class GodotServer {
36878
36903
  rmSync8(reportsDir, { recursive: true, force: true });
36879
36904
  rmSync8(userData, { recursive: true, force: true });
36880
36905
  }
36881
- const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries;
36906
+ const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries.map(aboveTheRunner);
36882
36907
  const verdicts = {
36883
36908
  0: "passed",
36884
36909
  100: "failures",
@@ -36910,6 +36935,7 @@ class GodotServer {
36910
36935
  };
36911
36936
  }
36912
36937
  const failed = report.suites.flatMap((suite) => suite.cases.filter((entry) => entry.status === "failed" || entry.status === "error").map((entry) => ({ ...entry, path: suite.path })));
36938
+ const unclean = report.suites.filter((suite) => suite.failures > 0 || suite.errors > 0 || suite.skipped > 0);
36913
36939
  return this.jsonTextResponse({
36914
36940
  passed: !hung && exitCode === 0 && report.failures === 0 && report.errors === 0,
36915
36941
  verdict,
@@ -36920,7 +36946,7 @@ class GodotServer {
36920
36946
  skipped: report.skipped,
36921
36947
  time: report.time,
36922
36948
  failed,
36923
- suites: report.suites.map((suite) => ({
36949
+ suites: unclean.map((suite) => ({
36924
36950
  name: suite.name,
36925
36951
  path: suite.path,
36926
36952
  tests: suite.tests,
@@ -36929,6 +36955,7 @@ class GodotServer {
36929
36955
  skipped: suite.skipped,
36930
36956
  time: suite.time
36931
36957
  })),
36958
+ suitesPassed: report.suites.length - unclean.length,
36932
36959
  engineErrors: run.log.count("error"),
36933
36960
  engineWarnings: run.log.count("warning"),
36934
36961
  engineEntries,
@@ -37473,6 +37500,7 @@ class GodotServer {
37473
37500
  const script = readNonEmptyString(args, "script");
37474
37501
  const namePattern = readNonEmptyString(args, "namePattern");
37475
37502
  const group = readNonEmptyString(args, "group");
37503
+ const says = readNonEmptyString(args, "says");
37476
37504
  if (className !== undefined)
37477
37505
  filters["class"] = className;
37478
37506
  if (script !== undefined)
@@ -37481,8 +37509,10 @@ class GodotServer {
37481
37509
  filters["name"] = namePattern;
37482
37510
  if (group !== undefined)
37483
37511
  filters["group"] = group;
37512
+ if (says !== undefined)
37513
+ filters["says"] = says;
37484
37514
  if (Object.keys(filters).length === 0) {
37485
- return this.createErrorResponse("runtime_inspect find needs at least one of className, script, namePattern, group.");
37515
+ return this.createErrorResponse("runtime_inspect find needs at least one of className, script, namePattern, group, says.");
37486
37516
  }
37487
37517
  const property = readNonEmptyString(args, "property");
37488
37518
  return await this.handleRuntimeCommand("find_nodes", {
@@ -37520,7 +37550,7 @@ async function runGodotServer() {
37520
37550
  const server = new GodotServer;
37521
37551
  await server.run();
37522
37552
  }
37523
- 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;
37553
+ 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/";
37524
37554
  var init_server3 = __esm(() => {
37525
37555
  init_mcp();
37526
37556
  init_stdio2();
@@ -38887,13 +38917,15 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
38887
38917
  | Ask | Tool |
38888
38918
  | --- | --- |
38889
38919
  | What a screen says | \`runtime_inspect text\`, which reads what is drawn and leaves out what is hidden |
38920
+ | Find a control by the word on it | \`runtime_inspect find\` with \`says\`, rather than listing a screen and reading each one |
38890
38921
  | Where a control is, and whether it is visible | \`runtime_inspect\` \`find\`, \`rect\` |
38891
38922
  | What a property reads right now | \`runtime_inspect\` \`property\`, \`runtime_invoke\` |
38892
38923
  | Press a button | \`runtime_input click\`, which says what was under the pointer |
38893
- | Fill in a field | \`runtime_input click\` on it, then \`runtime_input text\`; a submitted field has to be clicked again |
38924
+ | Fill in a field | \`runtime_input click\` on it, then \`runtime_input text\` with \`replace\`; a submitted field has to be clicked again |
38894
38925
  | Click somebody standing in a 3D room | \`runtime_input click\` on the Node3D, which aims at what it draws |
38895
38926
  | Pick something out of a dropdown | \`runtime_input choose\` on it, by what the item says |
38896
- | Answer a dialog, or press a bound key | \`runtime_input action\` or \`key\`, a whole press unless you hold it |
38927
+ | 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 |
38928
+ | Press a bound key | \`runtime_input action\` or \`key\`, a whole press unless you hold it |
38897
38929
  | Where a 3D node is on screen | \`runtime_inspect\` \`rect\`, rather than unprojecting by hand |
38898
38930
  | Wait for something | \`runtime_wait\`, never a sleep |
38899
38931
  | A picture, for a person who asked to see one | \`runtime_capture\` |
@@ -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,6 +10,10 @@ const Values = preload("runtime_values.gd")
10
10
  const FIND_LIMIT: int = 100
11
11
  const FIND_LIMIT_CEILING: int = 1000
12
12
 
13
+ ## What a find can be narrowed by, which is also what it refuses to answer without. Named once so
14
+ ## the refusal lists the same set the walk reads.
15
+ const FILTERS: PackedStringArray = ["class", "script", "name", "group", "says"]
16
+
13
17
  ## The most lines one read answers with, unless asked for fewer. A screen is a few dozen; a
14
18
  ## thousand is a tree somebody pointed this at by mistake.
15
19
  const READ_LIMIT: int = 500
@@ -39,7 +43,8 @@ func get_tree(params: Dictionary) -> Dictionary:
39
43
 
40
44
  ## Nodes matching every filter given, as paths, so a caller can name what it wants without
41
45
  ## reading the whole tree to find it. `class` matches native classes and their subclasses, and
42
- ## the global name of a script class; `name` is a case-insensitive glob; `script` is a path.
46
+ ## the global name of a script class; `name` is a case-insensitive glob; `script` is a path;
47
+ ## `says` is what the node has written on it.
43
48
  ##
44
49
  ## `property` names one to read off each of them, which is the difference between one question and
45
50
  ## one call per answer. A panel of a dozen labels took thirteen calls to read, and a tree that
@@ -48,22 +53,16 @@ func get_tree(params: Dictionary) -> Dictionary:
48
53
  ## because null is what a node holding null answers.
49
54
  func find_nodes(params: Dictionary) -> Dictionary:
50
55
  var root_path: String = str(params.get("root", "/root"))
51
- var wanted_class: String = str(params.get("class", ""))
52
- var wanted_script: String = str(params.get("script", ""))
53
- var wanted_name: String = str(params.get("name", ""))
54
- var wanted_group: String = str(params.get("group", ""))
56
+ var wanted: Dictionary[String, String] = {}
57
+ for filter: String in FILTERS:
58
+ wanted[filter] = str(params.get(filter, ""))
55
59
  var wanted_property: String = str(params.get("property", ""))
56
60
  var limit: int = clampi(int(params.get("limit", FIND_LIMIT)), 1, FIND_LIMIT_CEILING)
57
61
 
58
- if (
59
- wanted_class.is_empty()
60
- and wanted_script.is_empty()
61
- and wanted_name.is_empty()
62
- and wanted_group.is_empty()
63
- ):
64
- return {"type": "error", "message": "find_nodes needs at least one of class, script, name, group"}
65
- if not wanted_script.is_empty() and not wanted_script.begins_with("res://"):
66
- wanted_script = "res://" + wanted_script
62
+ if not _anything_asked(wanted):
63
+ return {"type": "error", "message": "find_nodes needs at least one of " + ", ".join(FILTERS)}
64
+ if not wanted["script"].is_empty() and not wanted["script"].begins_with("res://"):
65
+ wanted["script"] = "res://" + wanted["script"]
67
66
 
68
67
  var root: Node = _host.get_tree().root.get_node_or_null(root_path)
69
68
  if root == null:
@@ -74,7 +73,7 @@ func find_nodes(params: Dictionary) -> Dictionary:
74
73
  var truncated: bool = false
75
74
  while not pending.is_empty():
76
75
  var node: Node = pending.pop_front()
77
- if _matches(node, wanted_class, wanted_script, wanted_name, wanted_group):
76
+ if _matches(node, wanted):
78
77
  if found.size() >= limit:
79
78
  truncated = true
80
79
  break
@@ -115,25 +114,37 @@ func _found(node: Node, wanted_property: String) -> Dictionary:
115
114
  return entry
116
115
 
117
116
 
118
- func _matches(
119
- node: Node, wanted_class: String, wanted_script: String, wanted_name: String, wanted_group: String
120
- ) -> bool:
121
- if not wanted_group.is_empty() and not node.is_in_group(wanted_group):
117
+ ## Whether the caller named anything to match on, since every filter left out matches everything
118
+ ## and a find with none of them is the whole tree by another name.
119
+ static func _anything_asked(wanted: Dictionary[String, String]) -> bool:
120
+ for filter: String in wanted:
121
+ if not wanted[filter].is_empty():
122
+ return true
123
+ return false
124
+
125
+
126
+ func _matches(node: Node, wanted: Dictionary[String, String]) -> bool:
127
+ if not wanted["group"].is_empty() and not node.is_in_group(wanted["group"]):
128
+ return false
129
+ if not wanted["name"].is_empty() and not str(node.name).matchn(wanted["name"]):
122
130
  return false
123
- if not wanted_name.is_empty() and not str(node.name).matchn(wanted_name):
131
+ # What this node says, rather than everything said underneath it. A row is then found by the
132
+ # label in it, and the path answered is that label's, which is where the words a caller is
133
+ # looking at actually are: matching every container above it would answer with the screen.
134
+ if not wanted["says"].is_empty() and not _said_by(node).containsn(wanted["says"]):
124
135
  return false
125
136
  var script: Variant = node.get_script()
126
- if not wanted_script.is_empty():
137
+ if not wanted["script"].is_empty():
127
138
  if not script is Script:
128
139
  return false
129
140
  var attached: Script = script
130
- if attached.resource_path != wanted_script:
141
+ if attached.resource_path != wanted["script"]:
131
142
  return false
132
- if not wanted_class.is_empty() and not node.is_class(wanted_class):
143
+ if not wanted["class"].is_empty() and not node.is_class(wanted["class"]):
133
144
  if not script is Script:
134
145
  return false
135
146
  var attached: Script = script
136
- if attached.get_global_name() != wanted_class:
147
+ if attached.get_global_name() != wanted["class"]:
137
148
  return false
138
149
  return true
139
150
 
package/build/index.js CHANGED
@@ -27941,7 +27941,7 @@ var TOOL_SPECS = [
27941
27941
  },
27942
27942
  {
27943
27943
  name: "project_test",
27944
- 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.",
27945
27945
  parameters: {
27946
27946
  projectPath: PROJECT_PATH,
27947
27947
  path: {
@@ -28336,6 +28336,11 @@ var TOOL_SPECS = [
28336
28336
  description: 'find: a case-insensitive glob on the node name, such as "Enemy*".'
28337
28337
  },
28338
28338
  group: { type: "string", ops: ["find"], description: "find: a group the node is in." },
28339
+ says: {
28340
+ type: "string",
28341
+ ops: ["find"],
28342
+ description: "find: part of what the node has written on it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match."
28343
+ },
28339
28344
  limit: {
28340
28345
  type: "number",
28341
28346
  ops: ["find", "text"],
@@ -28361,7 +28366,7 @@ var TOOL_SPECS = [
28361
28366
  requires: []
28362
28367
  },
28363
28368
  find: {
28364
- summary: "the paths of every node matching className, script, namePattern or group, with property read off each",
28369
+ summary: "the paths of every node matching className, script, namePattern, group or says, with property read off each",
28365
28370
  requires: []
28366
28371
  },
28367
28372
  rect: {
@@ -28427,7 +28432,11 @@ var TOOL_SPECS = [
28427
28432
  ops: ["click", "choose"],
28428
28433
  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."
28429
28434
  },
28430
- action: { type: "string", ops: ["action"], description: "action: the InputMap action name." },
28435
+ action: {
28436
+ type: "string",
28437
+ ops: ["action"],
28438
+ description: "action: the InputMap action name. An engine dialog is not answered this way: AcceptDialog reads the Escape key itself and never asks the InputMap, so ui_cancel goes in and the question stays up. Dismiss one with key Escape, or click its button."
28439
+ },
28431
28440
  pressed: {
28432
28441
  type: "boolean",
28433
28442
  ops: ["action", "key", "mouse_click"],
@@ -28444,6 +28453,11 @@ var TOOL_SPECS = [
28444
28453
  ops: ["text", "choose"],
28445
28454
  description: "text: what to type. A newline is Enter and a tab is Tab. choose: the item to take, by what it says."
28446
28455
  },
28456
+ replace: {
28457
+ type: "boolean",
28458
+ ops: ["text"],
28459
+ 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.'
28460
+ },
28447
28461
  index: {
28448
28462
  type: "number",
28449
28463
  ops: ["choose"],
@@ -28756,6 +28770,18 @@ function realPathOr(path) {
28756
28770
  return path;
28757
28771
  }
28758
28772
  }
28773
+ var RUNNER_DIRECTORY = "addons/gdUnit4/";
28774
+ function aboveTheRunner(entry) {
28775
+ const runner = entry.detail.findIndex((line) => line.includes(RUNNER_DIRECTORY));
28776
+ if (runner < 0) {
28777
+ return entry;
28778
+ }
28779
+ const cut = entry.detail.length - runner;
28780
+ return {
28781
+ ...entry,
28782
+ detail: [...entry.detail.slice(0, runner), `[and ${cut} frames inside ${RUNNER_DIRECTORY}]`]
28783
+ };
28784
+ }
28759
28785
  function hasMainScene(projectFile) {
28760
28786
  const scene = parseProjectGodot(readFileSync8(projectFile, "utf8"))["application"]?.["run/main_scene"];
28761
28787
  return typeof scene === "string" && scene !== "";
@@ -29658,7 +29684,7 @@ class GodotServer {
29658
29684
  rmSync4(reportsDir, { recursive: true, force: true });
29659
29685
  rmSync4(userData, { recursive: true, force: true });
29660
29686
  }
29661
- const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries;
29687
+ const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries.map(aboveTheRunner);
29662
29688
  const verdicts = {
29663
29689
  0: "passed",
29664
29690
  100: "failures",
@@ -29690,6 +29716,7 @@ class GodotServer {
29690
29716
  };
29691
29717
  }
29692
29718
  const failed = report.suites.flatMap((suite) => suite.cases.filter((entry) => entry.status === "failed" || entry.status === "error").map((entry) => ({ ...entry, path: suite.path })));
29719
+ const unclean = report.suites.filter((suite) => suite.failures > 0 || suite.errors > 0 || suite.skipped > 0);
29693
29720
  return this.jsonTextResponse({
29694
29721
  passed: !hung && exitCode === 0 && report.failures === 0 && report.errors === 0,
29695
29722
  verdict,
@@ -29700,7 +29727,7 @@ class GodotServer {
29700
29727
  skipped: report.skipped,
29701
29728
  time: report.time,
29702
29729
  failed,
29703
- suites: report.suites.map((suite) => ({
29730
+ suites: unclean.map((suite) => ({
29704
29731
  name: suite.name,
29705
29732
  path: suite.path,
29706
29733
  tests: suite.tests,
@@ -29709,6 +29736,7 @@ class GodotServer {
29709
29736
  skipped: suite.skipped,
29710
29737
  time: suite.time
29711
29738
  })),
29739
+ suitesPassed: report.suites.length - unclean.length,
29712
29740
  engineErrors: run.log.count("error"),
29713
29741
  engineWarnings: run.log.count("warning"),
29714
29742
  engineEntries,
@@ -30253,6 +30281,7 @@ class GodotServer {
30253
30281
  const script = readNonEmptyString(args, "script");
30254
30282
  const namePattern = readNonEmptyString(args, "namePattern");
30255
30283
  const group = readNonEmptyString(args, "group");
30284
+ const says = readNonEmptyString(args, "says");
30256
30285
  if (className !== undefined)
30257
30286
  filters["class"] = className;
30258
30287
  if (script !== undefined)
@@ -30261,8 +30290,10 @@ class GodotServer {
30261
30290
  filters["name"] = namePattern;
30262
30291
  if (group !== undefined)
30263
30292
  filters["group"] = group;
30293
+ if (says !== undefined)
30294
+ filters["says"] = says;
30264
30295
  if (Object.keys(filters).length === 0) {
30265
- return this.createErrorResponse("runtime_inspect find needs at least one of className, script, namePattern, group.");
30296
+ return this.createErrorResponse("runtime_inspect find needs at least one of className, script, namePattern, group, says.");
30266
30297
  }
30267
30298
  const property = readNonEmptyString(args, "property");
30268
30299
  return await this.handleRuntimeCommand("find_nodes", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.6.9",
3
+ "version": "0.8.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",