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:
|
|
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
|
|
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: {
|
|
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:
|
|
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
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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,
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
) -> bool:
|
|
121
|
-
|
|
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
|
-
|
|
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
|
|
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 !=
|
|
141
|
+
if attached.resource_path != wanted["script"]:
|
|
131
142
|
return false
|
|
132
|
-
if not
|
|
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() !=
|
|
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:
|
|
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
|
|
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: {
|
|
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:
|
|
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.
|
|
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",
|