gdharness 1.1.9 → 1.1.10

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.
@@ -10,6 +10,7 @@ const Values = preload("runtime_values.gd")
10
10
  ## reports are the same place by construction rather than by agreement.
11
11
  const Queries = preload("runtime_queries.gd")
12
12
  const Read = preload("reading.gd")
13
+ const Targets = preload("runtime_targets.gd")
13
14
  const Words = preload("runtime_words.gd")
14
15
 
15
16
  ## The distance from a capital letter to its small one in Unicode. A keycode holds the capital.
@@ -484,10 +485,25 @@ static func _scroll_into_view(control: Control) -> bool:
484
485
  ## A control out of sight inside a ScrollContainer is scrolled to first; the answer says so under
485
486
  ## `scrolled_into_view`, because the view having moved is a thing that happened to the screen and
486
487
  ## the caller is the only one who can tell whether that matters.
488
+ ##
489
+ ## `says` names the control by the words on it instead, under `path` when that is given as well,
490
+ ## found and pressed in the same frame. A find and then a click by path was two round trips over a
491
+ ## generated path copied whole, and a panel that rebuilt itself between them freed the button, so
492
+ ## the click landed on nothing.
487
493
  func click(params: Dictionary) -> Dictionary:
488
494
  var node_path: String = str(params.get("path", ""))
495
+ var wanted: String = str(params.get("says", ""))
496
+ var found: Variant = null
497
+ if not wanted.is_empty():
498
+ var picked: Dictionary = Targets.control_saying(
499
+ _host.get_tree().root, "/root" if node_path.is_empty() else node_path, wanted, params.get("index")
500
+ )
501
+ if picked.has("message"):
502
+ return picked
503
+ node_path = picked["path"]
504
+ found = picked["found"]
489
505
  if node_path.is_empty():
490
- return {"type": "error", "message": "Node path required"}
506
+ return {"type": "error", "message": "click needs a path, or says to find the control by its words"}
491
507
 
492
508
  var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
493
509
  if standing.has("message"):
@@ -582,7 +598,7 @@ func click(params: Dictionary) -> Dictionary:
582
598
  if is_instance_valid(control):
583
599
  afterwards = "in_tree" if control.is_inside_tree() else "removed"
584
600
 
585
- return {
601
+ var answer: Dictionary = {
586
602
  "type": "clicked",
587
603
  "path": node_path,
588
604
  "position": _values.serialize(position),
@@ -593,6 +609,9 @@ func click(params: Dictionary) -> Dictionary:
593
609
  "control_afterwards": afterwards,
594
610
  "scrolled_into_view": scrolled,
595
611
  }
612
+ if found != null:
613
+ answer["found"] = found
614
+ return answer
596
615
 
597
616
 
598
617
  ## What to add to a refusal about a point outside the viewport, when the reason is that nobody
@@ -259,6 +259,12 @@ static func _says(said: String, wanted: String) -> bool:
259
259
  return said.containsn(words) if _is_literal(words) else said.matchn(words)
260
260
 
261
261
 
262
+ ## Whether [param node]'s own words are what [param wanted] asks for, by the rules a find uses, so a
263
+ ## click that names its control by its words finds the one a find would have answered with.
264
+ static func says(node: Node, wanted: String) -> bool:
265
+ return _says(Words.said_by(node), wanted)
266
+
267
+
262
268
  ## [param wanted], a glob, open at both ends so it matches its words anywhere in a text; "" when
263
269
  ## it is not a glob, or already open at both ends, and so has nothing to suggest.
264
270
  static func _widened(wanted: String) -> String:
@@ -0,0 +1,113 @@
1
+ extends RefCounted
2
+
3
+ ## Which control a click means when it names the control by the words on it rather than by path.
4
+
5
+ const Queries = preload("runtime_queries.gd")
6
+ const Read = preload("reading.gd")
7
+ const Values = preload("runtime_values.gd")
8
+ const Words = preload("runtime_words.gd")
9
+
10
+ ## How many of the controls saying the same words a refusal names, with the index for each.
11
+ const LISTED_CANDIDATES: int = 10
12
+
13
+
14
+ ## The control a click by [param wanted] words presses, found under [param root_path] in the tree
15
+ ## [param root] heads, or the refusal saying why there is none or which of several:
16
+ ## {"path", "found"} or {"type", "message"}.
17
+ ##
18
+ ## Matched the way a find matches `says`, against a control's own words, and only among controls
19
+ ## the player can see. A match inside a button presses the button, because the label is what says
20
+ ## the words and the button is what takes the click: aimed at the label, the pointer lands on the
21
+ ## button and the answer would call the click a miss. Several matches are refused rather than
22
+ ## guessed, each named with the [param which] that picks it, counting from 0.
23
+ static func control_saying(root: Node, root_path: String, wanted: String, which: Variant) -> Dictionary:
24
+ var reached: Dictionary = Values.node_at(root, root_path)
25
+ if reached.has("message"):
26
+ return reached
27
+ var targets: Array[Control] = []
28
+ # The words of the control that matched, which a button pressed on a label's behalf has none of.
29
+ var said: Array[String] = []
30
+ var hidden: int = 0
31
+ var pending: Array[Node] = [reached["node"]]
32
+ while not pending.is_empty():
33
+ var node: Node = pending.pop_front()
34
+ if node is Control and Queries.says(node, wanted):
35
+ if not Queries.shown(node):
36
+ hidden += 1
37
+ else:
38
+ var matched: Control = node
39
+ var target: Control = _pressed_through(matched)
40
+ if not targets.has(target):
41
+ targets.append(target)
42
+ said.append(Words.said_by(node))
43
+ var children: Array[Node] = node.get_children(true)
44
+ for index: int in range(children.size() - 1, -1, -1):
45
+ pending.push_front(children[index])
46
+
47
+ var under: String = "" if root_path == "/root" else " under %s" % root_path
48
+ if targets.is_empty():
49
+ return {
50
+ "type": "error",
51
+ "message":
52
+ (
53
+ 'no control on screen%s says "%s", so nothing was clicked%s'
54
+ % [under, wanted, _hidden_note(hidden)]
55
+ )
56
+ }
57
+ if which == null and targets.size() > 1:
58
+ return {
59
+ "type": "error",
60
+ "message":
61
+ (
62
+ '%d controls on screen%s say "%s", so which to click is not clear; pass index, counting from 0: %s'
63
+ % [targets.size(), under, wanted, _candidates(targets, said)]
64
+ )
65
+ }
66
+ var index: int = 0 if which == null else Read.as_int(which, -1)
67
+ if index < 0 or index >= targets.size():
68
+ var plural: String = "" if targets.size() == 1 else "s"
69
+ return {
70
+ "type": "error",
71
+ "message":
72
+ (
73
+ 'index %s is not one of the %d control%s on screen%s saying "%s": %s'
74
+ % [str(which), targets.size(), plural, under, wanted, _candidates(targets, said)]
75
+ )
76
+ }
77
+ return {
78
+ "path": str(targets[index].get_path()),
79
+ "found": {"says": wanted, "index": index, "of": targets.size()},
80
+ }
81
+
82
+
83
+ ## What to add to a refusal that found nothing on screen, when words matched controls out of sight.
84
+ static func _hidden_note(hidden: int) -> String:
85
+ if hidden == 0:
86
+ return ""
87
+ return "; %d hidden control%s" % [hidden, " says it" if hidden == 1 else "s say it"]
88
+
89
+
90
+ ## [param control], or the button holding it when it is not one itself, which is what a click on
91
+ ## its words means.
92
+ static func _pressed_through(control: Control) -> Control:
93
+ var walk: Node = control
94
+ while walk != null:
95
+ if walk is BaseButton:
96
+ var pressed: BaseButton = walk
97
+ return pressed
98
+ walk = walk.get_parent()
99
+ return control
100
+
101
+
102
+ ## The controls a click by words could mean, each with its index, its path and the words that
103
+ ## matched, [param said] holding those in the same order.
104
+ static func _candidates(targets: Array[Control], said: Array[String]) -> String:
105
+ var named: Array[String] = []
106
+ for index: int in mini(targets.size(), LISTED_CANDIDATES):
107
+ var words: String = said[index].replace("\n", " ")
108
+ if words.length() > 60:
109
+ words = words.substr(0, 57) + "..."
110
+ named.append('%d %s ("%s")' % [index, targets[index].get_path(), words])
111
+ if targets.size() > LISTED_CANDIDATES:
112
+ named.append("and %d more" % (targets.size() - LISTED_CANDIDATES))
113
+ return "; ".join(named)
@@ -0,0 +1 @@
1
+ uid://be1ydcqg2ag4t
package/build/index.js CHANGED
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
- // @bun
3
2
  import { createRequire } from "node:module";
4
3
  var __create = Object.create;
5
4
  var __getProtoOf = Object.getPrototypeOf;
@@ -10085,6 +10084,14 @@ function markIfStale(answer, addonVersion, serverVersion, addonDigest, shippedDi
10085
10084
  // src/issues.ts
10086
10085
  var NEW_ISSUE = "https://github.com/Aureliolo/gdharness/issues/new";
10087
10086
  var ENHANCEMENT_URL = `${NEW_ISSUE}?template=feature_request.md`;
10087
+ function feedbackNotice() {
10088
+ return JSON.stringify({
10089
+ gdharness_feedback: {
10090
+ what_to_do: "If this session has run into something gdharness cannot do, or a tool that should " + "exist and does not, tell the user what is missing and offer to open an enhancement " + "issue for them. Only if they say yes, and only for a real gap: a call you got wrong " + "is not one. If you cannot open an issue yourself, give them the link and what to " + "write in it.",
10091
+ enhancement_url: ENHANCEMENT_URL
10092
+ }
10093
+ }, null, 2);
10094
+ }
10088
10095
  function runtime() {
10089
10096
  const versions = process3.versions;
10090
10097
  const bun = versions["bun"];
@@ -10140,6 +10147,16 @@ function filledTemplate(where, message, godotVersion) {
10140
10147
  ].join(`
10141
10148
  `);
10142
10149
  }
10150
+
10151
+ class DefectsSeen {
10152
+ counts = new Map;
10153
+ before(where, message) {
10154
+ const signature = defectSignature(where, message);
10155
+ const seen = this.counts.get(signature) ?? 0;
10156
+ this.counts.set(signature, seen + 1);
10157
+ return seen;
10158
+ }
10159
+ }
10143
10160
  function defectReport(where, error, context = {}) {
10144
10161
  const { godotVersion, seenBefore = 0 } = context;
10145
10162
  const message = errorMessage(error);
@@ -26896,19 +26913,21 @@ class GameLog {
26896
26913
  this.seen[severity] = this.entries.length;
26897
26914
  }
26898
26915
  }
26899
- const matching = this.entries.slice(floor).filter((entry) => {
26900
- if (!wanted.has(entry.severity)) {
26901
- return false;
26902
- }
26903
- if (needle === undefined) {
26904
- return true;
26905
- }
26906
- return [entry.text, ...entry.detail].some((line) => line.toLowerCase().includes(needle));
26907
- });
26916
+ const matching = this.entries.slice(floor).filter((entry) => answersTo(entry, options.severity, options.contains));
26908
26917
  const omitted = Math.max(0, matching.length - options.limit);
26909
26918
  return { entries: matching.slice(omitted), omitted };
26910
26919
  }
26911
26920
  }
26921
+ function answersTo(entry, severity, contains) {
26922
+ if (!ADMITTED[severity].has(entry.severity)) {
26923
+ return false;
26924
+ }
26925
+ if (contains === undefined) {
26926
+ return true;
26927
+ }
26928
+ const needle = contains.toLowerCase();
26929
+ return [entry.text, ...entry.detail].some((line) => line.toLowerCase().includes(needle));
26930
+ }
26912
26931
 
26913
26932
  // src/editor-log.ts
26914
26933
  function editorLogPath(projectPath) {
@@ -27018,7 +27037,7 @@ function shapeOf(text) {
27018
27037
  }
27019
27038
  return { shape, values };
27020
27039
  }
27021
- function bursts(entries, before) {
27040
+ function bursts(entries, before, keep) {
27022
27041
  const shapes = new Map;
27023
27042
  const shaped = entries.map((entry) => shapeOf(entry.text));
27024
27043
  for (const [at, entry] of entries.entries()) {
@@ -27046,6 +27065,9 @@ function bursts(entries, before) {
27046
27065
  if (entry === undefined) {
27047
27066
  continue;
27048
27067
  }
27068
+ if (keep !== undefined && !group.indices.some((at) => entries[at] !== undefined && keep(entries[at]))) {
27069
+ continue;
27070
+ }
27049
27071
  repeated.push({
27050
27072
  shape: key.slice(key.indexOf("\x00") + 1),
27051
27073
  count: group.indices.length,
@@ -27968,126 +27990,6 @@ var EDITOR_READS = dictionary({
27968
27990
  project_settings: dictionary({ get: "get_project_setting" })
27969
27991
  });
27970
27992
 
27971
- // src/issues.ts
27972
- import { createHash as createHash2 } from "node:crypto";
27973
- import process5 from "node:process";
27974
- var NEW_ISSUE2 = "https://github.com/Aureliolo/gdharness/issues/new";
27975
- var ENHANCEMENT_URL2 = `${NEW_ISSUE2}?template=feature_request.md`;
27976
- function feedbackNotice() {
27977
- return JSON.stringify({
27978
- gdharness_feedback: {
27979
- what_to_do: "If this session has run into something gdharness cannot do, or a tool that should " + "exist and does not, tell the user what is missing and offer to open an enhancement " + "issue for them. Only if they say yes, and only for a real gap: a call you got wrong " + "is not one. If you cannot open an issue yourself, give them the link and what to " + "write in it.",
27980
- enhancement_url: ENHANCEMENT_URL2
27981
- }
27982
- }, null, 2);
27983
- }
27984
- function runtime2() {
27985
- const versions = process5.versions;
27986
- const bun = versions["bun"];
27987
- return bun === undefined ? `node ${process5.versions.node}` : `bun ${bun}`;
27988
- }
27989
- function defectSignature2(where, message) {
27990
- const normalised = `${where}
27991
- ${message}`.toLowerCase().replaceAll("\\", "/").replaceAll(/(?:[a-z]:)?(?:\/+[\w.-]+){2,}/gu, "<path>").replaceAll(/\d+/gu, "0");
27992
- return createHash2("sha256").update(normalised).digest("hex").slice(0, 8);
27993
- }
27994
- function facts2(where, message, godotVersion) {
27995
- return [
27996
- ["Where", where],
27997
- ["Error", message],
27998
- ["Version", `gdharness ${SERVER_VERSION}`],
27999
- ["Runtime", `${runtime2()}, ${process5.platform} ${process5.arch}`],
28000
- ["Godot", godotVersion ?? "not known at the point this failed"],
28001
- ["Signature", defectSignature2(where, message)]
28002
- ];
28003
- }
28004
- function filledTemplate2(where, message, godotVersion) {
28005
- return [
28006
- `**gdharness version**: ${SERVER_VERSION}`,
28007
- `**Godot version**: ${godotVersion ?? ""}`,
28008
- `**Bun version**: ${runtime2()}`,
28009
- `**OS**: ${process5.platform} ${process5.arch}`,
28010
- "**MCP client**:",
28011
- "",
28012
- "## What you did",
28013
- "",
28014
- where,
28015
- "",
28016
- "## What you expected",
28017
- "",
28018
- "The call to answer, or to refuse with a reason.",
28019
- "",
28020
- "## What happened",
28021
- "",
28022
- "gdharness failed in a way it does not model.",
28023
- "",
28024
- "```text",
28025
- message,
28026
- "```",
28027
- "",
28028
- `Signature: \`${defectSignature2(where, message)}\``,
28029
- "",
28030
- "If the real cause turned out to be the project, the environment or the call, say so here:",
28031
- "reaching you as a defect rather than as a refusal naming what would have worked is then the",
28032
- "thing to fix.",
28033
- "",
28034
- "## Anything that makes it reproducible",
28035
- ""
28036
- ].join(`
28037
- `);
28038
- }
28039
-
28040
- class DefectsSeen {
28041
- counts = new Map;
28042
- before(where, message) {
28043
- const signature = defectSignature2(where, message);
28044
- const seen = this.counts.get(signature) ?? 0;
28045
- this.counts.set(signature, seen + 1);
28046
- return seen;
28047
- }
28048
- }
28049
- function defectReport2(where, error, context = {}) {
28050
- const { godotVersion, seenBefore = 0 } = context;
28051
- const message = errorMessage(error);
28052
- const title = `Defect ${defectSignature2(where, message)}: ${where}`;
28053
- const url = `${NEW_ISSUE2}?template=bug_report.md&title=${encodeURIComponent(title)}` + `&body=${encodeURIComponent(filledTemplate2(where, message, godotVersion))}`;
28054
- const width = Math.max(...facts2(where, message, godotVersion).map(([label]) => (label ?? "").length));
28055
- return [
28056
- "gdharness failed in a way it does not model. Whatever went wrong underneath, it reached you",
28057
- "as a crash rather than as a refusal naming what would have worked, and that much is a defect",
28058
- "in gdharness however the rest of it turns out.",
28059
- "",
28060
- ...seenBefore === 0 ? [
28061
- "Send the call once more before anything else. Some of what reaches this line is a file",
28062
- "another process had open, or a connection that dropped, and those are gone by the next",
28063
- "attempt. If the same Signature comes back, it is not one of those, and repeating it",
28064
- "further only spends turns."
28065
- ] : [
28066
- `You have had this exact failure ${seenBefore === 1 ? "once" : `${seenBefore} times`} in this session already, so it is a`,
28067
- "state rather than a passing one, and sending the call again will not change it."
28068
- ],
28069
- "",
28070
- ...facts2(where, message, godotVersion).map(([label, value]) => ` ${(label ?? "").padEnd(width)} ${value ?? ""}`),
28071
- "",
28072
- "Do not open an issue on your own initiative. Tell the person whose machine this is what",
28073
- "broke, and ask whether you may report it to gdharness on their behalf. What the report",
28074
- "carries is the lines above and nothing else, and whether that goes into a public tracker",
28075
- "is theirs to decide.",
28076
- "",
28077
- "If they say yes, this link carries the report already filled in:",
28078
- url,
28079
- "",
28080
- "If they say yes and you have no way to open an issue yourself, give them that link and",
28081
- "those lines, and stay with them while they file it.",
28082
- "",
28083
- "Worth reporting either way. If you can see that what actually went wrong was the project,",
28084
- "the environment or the call, then this message is the defect: a failure gdharness knows",
28085
- "about is meant to arrive as a refusal naming what would have worked, not as this. Say that",
28086
- "in the report and it is the more useful of the two."
28087
- ].join(`
28088
- `);
28089
- }
28090
-
28091
27993
  // src/junit.ts
28092
27994
  class MalformedReportError extends Error {
28093
27995
  constructor(message) {
@@ -28290,7 +28192,7 @@ function scriptErrorsPrinted(printed) {
28290
28192
  for (const line of printed.slice(start + 1)) {
28291
28193
  const at = /^\s*at (res:\/\/.+):(\d+)\s*$/.exec(line);
28292
28194
  if (at !== null && message !== null) {
28293
- found.push({ path: at[1] ?? "", line: Number(at[2]), message });
28195
+ found.push({ path: at[1] ?? "", line: Number(at[2]) === 0 ? null : Number(at[2]), message });
28294
28196
  message = null;
28295
28197
  } else if (at === null && message === null && /^ {2}\S/.test(line)) {
28296
28198
  message = line.trim();
@@ -28298,7 +28200,7 @@ function scriptErrorsPrinted(printed) {
28298
28200
  break;
28299
28201
  }
28300
28202
  }
28301
- return found.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : a.line - b.line);
28203
+ return found.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : (a.line ?? 0) - (b.line ?? 0));
28302
28204
  }
28303
28205
  var ESCAPE = String.fromCharCode(27);
28304
28206
  var LOCATION = `'[^'\\n]*' in \\S+:\\d+`;
@@ -29193,7 +29095,7 @@ function searchProject(projectPath, options) {
29193
29095
  }
29194
29096
 
29195
29097
  // src/reimport.ts
29196
- import { createHash as createHash3 } from "node:crypto";
29098
+ import { createHash as createHash2 } from "node:crypto";
29197
29099
  import { existsSync as existsSync9, readFileSync as readFileSync12, rmSync as rmSync5, statSync as statSync5, utimesSync, writeFileSync as writeFileSync8 } from "node:fs";
29198
29100
  import { basename as basename2, join as join13 } from "node:path";
29199
29101
  function forceNextImport(projectPath, resourcePath) {
@@ -29208,7 +29110,7 @@ function forceNextImport(projectPath, resourcePath) {
29208
29110
  rmSync5(importRecord(projectPath, resourcePath), { force: true });
29209
29111
  }
29210
29112
  function importRecord(projectPath, resourcePath) {
29211
- const digest = createHash3("md5").update(resourcePath).digest("hex");
29113
+ const digest = createHash2("md5").update(resourcePath).digest("hex");
29212
29114
  return join13(projectPath, ".godot", "imported", `${basename2(resourcePath)}-${digest}.md5`);
29213
29115
  }
29214
29116
 
@@ -29494,7 +29396,7 @@ function restartSettled(projectPath) {
29494
29396
 
29495
29397
  // src/run-record.ts
29496
29398
  import { execFileSync as execFileSync2 } from "node:child_process";
29497
- import { createHash as createHash4 } from "node:crypto";
29399
+ import { createHash as createHash3 } from "node:crypto";
29498
29400
  import {
29499
29401
  mkdirSync as mkdirSync8,
29500
29402
  openSync as openSync2,
@@ -29528,7 +29430,7 @@ function realAsFarAsItExists(path) {
29528
29430
  function projectKey(projectPath) {
29529
29431
  const real = realAsFarAsItExists(resolve5(projectPath)).replace(/[\\/]+$/, "");
29530
29432
  const folded = process.platform === "win32" || process.platform === "darwin" ? real.toLowerCase() : real;
29531
- return createHash4("sha256").update(folded.replaceAll("\\", "/")).digest("hex").slice(0, 16);
29433
+ return createHash3("sha256").update(folded.replaceAll("\\", "/")).digest("hex").slice(0, 16);
29532
29434
  }
29533
29435
  function runRecordPath(projectPath, directory = runtimeDirectory()) {
29534
29436
  return join15(directory, "runs", `note-${projectKey(projectPath)}.json`);
@@ -29836,7 +29738,7 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
29836
29738
  }
29837
29739
 
29838
29740
  // src/setup.ts
29839
- import { createHash as createHash5 } from "node:crypto";
29741
+ import { createHash as createHash4 } from "node:crypto";
29840
29742
  import {
29841
29743
  cpSync,
29842
29744
  existsSync as existsSync10,
@@ -29859,7 +29761,7 @@ var RUNTIME_AUTOLOAD = {
29859
29761
  var VERSION_MARKER = ".gdharness-version";
29860
29762
  var DIGEST_MARKER = ".gdharness-digest";
29861
29763
  function editorAddonDigest(from = shippedAddonsDirectory()) {
29862
- const hash = createHash5("sha256");
29764
+ const hash = createHash4("sha256");
29863
29765
  const walk = (directory, relative) => {
29864
29766
  for (const name of readdirSync6(directory).sort()) {
29865
29767
  if (name === VERSION_MARKER || name === DIGEST_MARKER) {
@@ -30282,6 +30184,10 @@ var TOOL_SPECS = [
30282
30184
  type: "integer",
30283
30185
  minimum: 1,
30284
30186
  description: "How long the run may take before it is killed. Default 600000. A run killed at the limit answers timedOut and silentForMs, how long it had printed nothing; it is called hung only when that was half the limit, or thirty seconds of a longer one, and otherwise it was still running and wants a longer timeoutMs or a narrower path."
30187
+ },
30188
+ printed: {
30189
+ type: "string",
30190
+ description: "Also answer with every line the run printed that contains this, under printed, whether it passed or not: a suite that prints a marker beside the numbers it measured carries them back without failing an assertion to do it. Matched case aside, in the text and the backtrace, at most 200 lines, the newest kept; printedOmitted counts the rest."
30285
30191
  }
30286
30192
  },
30287
30193
  requires: ["projectPath"]
@@ -30931,14 +30837,19 @@ var TOOL_SPECS = [
30931
30837
  },
30932
30838
  {
30933
30839
  name: "runtime_input",
30934
- description: "Input to the running game: a whole click on a Control or a 3D node named by path, an item chosen out of a menu, typing into whatever has the focus, or a raw action, key, mouse button or mouse motion. All of it works headless, where the window is 64 by 64 and the GUI only takes what is inside it.",
30840
+ description: "Input to the running game: a whole click on a Control or a 3D node named by path or on a control named by the words on it, an item chosen out of a menu, typing into whatever has the focus, or a raw action, key, mouse button or mouse motion. All of it works headless, where the window is 64 by 64 and the GUI only takes what is inside it.",
30935
30841
  parameters: {
30936
30842
  projectPath: RUNNING_PROJECT_PATH,
30937
30843
  pid: RUNNING_PID,
30938
30844
  nodePath: {
30939
30845
  type: "string",
30940
30846
  ops: ["click", "choose"],
30941
- 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."
30847
+ description: "click: the Control to click, at its centre, or the 3D node to click, where it is drawn; with says, where to look for the control instead, default the whole tree. choose: the PopupMenu, or the OptionButton or MenuButton in front of one."
30848
+ },
30849
+ says: {
30850
+ type: "string",
30851
+ ops: ["click"],
30852
+ description: "click: the words on the control to click, matched the way runtime_inspect find matches says (a plain word is contained in the text, a pattern with * or ? is a glob over it), among the controls on screen, and pressed in the same frame it was found in, so a panel rebuilding between a find and a click cannot free it first. Words on a label inside a button press the button. Several matches are refused with each one listed, and index picks one."
30942
30853
  },
30943
30854
  action: {
30944
30855
  type: "string",
@@ -30975,8 +30886,8 @@ var TOOL_SPECS = [
30975
30886
  },
30976
30887
  index: {
30977
30888
  type: "number",
30978
- ops: ["choose"],
30979
- description: "choose: the item to take, by where it is in the list, when text will not do."
30889
+ ops: ["click", "choose"],
30890
+ description: "click: which of several controls saying the words, counting from 0 in the order a refusal lists them. choose: the item to take, by where it is in the list, when text will not do."
30980
30891
  },
30981
30892
  shift: { type: "boolean", ops: ["key"], description: "key: hold Shift with it. Default false." },
30982
30893
  ctrl: { type: "boolean", ops: ["key"], description: "key: hold Ctrl with it. Default false." },
@@ -31015,8 +30926,8 @@ var TOOL_SPECS = [
31015
30926
  requires: [],
31016
30927
  operations: {
31017
30928
  click: {
31018
- summary: "press and release on a Control, a frame apart, and answer with what was under the pointer and what became of the control: in_tree, removed or freed. A control out of sight inside a ScrollContainer is scrolled to first, and scrolled_into_view says whether the view moved. A 3D node is clicked where it is drawn, and landed then says the interface did not swallow the press",
31019
- requires: ["nodePath"]
30929
+ summary: "press and release on a Control, a frame apart, and answer with what was under the pointer and what became of the control: in_tree, removed or freed. The control is named by nodePath, or by says with the words on it, found under nodePath when both are given, and the answer then says under found which match it was. A control out of sight inside a ScrollContainer is scrolled to first, and scrolled_into_view says whether the view moved. A 3D node is clicked where it is drawn, and landed then says the interface did not swallow the press",
30930
+ requires: []
31020
30931
  },
31021
30932
  choose: {
31022
30933
  summary: "take an item out of a menu, by what it says or by where it is in the list. A menu's items are drawn rather than built, so there is nothing to click: the item takes the focus and Enter presses it, which is the engine's own path and needs no window. Answers with what was chosen and what the button in front of it shows now",
@@ -31606,16 +31517,26 @@ function changedSettings(before, after) {
31606
31517
  return [...names].filter((name) => JSON.stringify(before.get(name)) !== JSON.stringify(after.get(name))).sort();
31607
31518
  }
31608
31519
  var STALLED_AFTER_MS = 30000;
31520
+ var DEPENDED_FAILED = "Failed to compile depended scripts";
31609
31521
  function scriptErrorsNote(errors) {
31610
- const first = new Map;
31522
+ const causes = new Map;
31611
31523
  for (const error of errors) {
31612
- if (!first.has(error.path)) {
31613
- first.set(error.path, error);
31524
+ if (!error.message.includes(DEPENDED_FAILED) && !causes.has(error.path)) {
31525
+ causes.set(error.path, error);
31614
31526
  }
31615
31527
  }
31616
- const scripts = [...first.values()].map((error) => `${error.path}:${error.line} ${error.message}${error.message.endsWith(".") ? "" : "."}`).join(" ");
31617
- const more = errors.length - first.size;
31618
- return `No tests ran: gdUnit4 could not load ${first.size === 1 ? "one script" : `${first.size} scripts`} while looking for suites, and it runs no suite at all when one fails to load. ${scripts}${more > 0 ? ` ${more === 1 ? "One more error" : `${more} more errors`} in ${first.size === 1 ? "that script" : "those scripts"} ${more === 1 ? "is" : "are"} under scriptErrors.` : ""}`;
31528
+ const dependents = [...new Set(errors.map((error) => error.path))].filter((path) => !causes.has(path));
31529
+ const scripts = causes.size + dependents.length;
31530
+ const named = [...causes.values()].map((error) => `${error.line === null ? error.path : `${error.path}:${error.line}`} ${error.message}${/[.!?]\)?$/.test(error.message) ? "" : "."}`).join(" ");
31531
+ const following = dependents.length === 0 ? "" : causes.size === 0 ? `${dependents.join(", ")} ${dependents.length === 1 ? "depends" : "depend"} on a script that did not compile, and gdUnit4 named none with an error of its own: script_diagnostics on one of them finds it.` : `${dependents.length === 1 ? "One more" : `${dependents.length} more`} failed only because a script ${dependents.length === 1 ? "it depends" : "they depend"} on did: ${dependents.join(", ")}.`;
31532
+ const more = errors.filter((error) => !error.message.includes(DEPENDED_FAILED)).length - causes.size;
31533
+ const rest = more > 0 ? `${more === 1 ? "One more error" : `${more} more errors`} in ${causes.size === 1 ? "that script" : "those scripts"} ${more === 1 ? "is" : "are"} under scriptErrors.` : "";
31534
+ return [
31535
+ `No tests ran: gdUnit4 could not load ${scripts === 1 ? "one script" : `${scripts} scripts`} while looking for suites, and it runs no suite at all when one fails to load.`,
31536
+ named,
31537
+ following,
31538
+ rest
31539
+ ].filter((part) => part !== "").join(" ");
31619
31540
  }
31620
31541
  function timedOutVerdict(timeoutMs, silentForMs) {
31621
31542
  const quiet = Math.round(silentForMs);
@@ -32049,7 +31970,7 @@ class GodotServer {
32049
31970
  content: [
32050
31971
  {
32051
31972
  type: "text",
32052
- text: defectReport2(where, error, {
31973
+ text: defectReport(where, error, {
32053
31974
  seenBefore,
32054
31975
  godotVersion: this.lastGodotVersion ?? undefined
32055
31976
  })
@@ -32391,11 +32312,16 @@ class GodotServer {
32391
32312
  return await this.handleRuntimeCommand(op === "screenshot" ? "capture_screenshot" : "capture_viewport", args);
32392
32313
  case "runtime_input":
32393
32314
  if (op === "click") {
32315
+ if (readNonEmptyString(args, "nodePath") === undefined && readNonEmptyString(args, "says") === undefined) {
32316
+ return this.createErrorResponse("runtime_input click needs nodePath, or says with the words on the control to click.");
32317
+ }
32394
32318
  return await this.handleRuntimeCommand("click", {
32395
32319
  ...whichGame(args),
32396
32320
  path: readNonEmptyString(args, "nodePath") ?? "",
32397
32321
  button: readString(args, "button") ?? "left",
32398
- double: readBoolean(args, "doubleClick") ?? false
32322
+ double: readBoolean(args, "doubleClick") ?? false,
32323
+ ...readNonEmptyString(args, "says") === undefined ? {} : { says: readNonEmptyString(args, "says") },
32324
+ ...args["index"] === undefined ? {} : { index: args["index"] }
32399
32325
  });
32400
32326
  }
32401
32327
  if (op === "choose") {
@@ -33232,6 +33158,12 @@ class GodotServer {
33232
33158
  const brokenScripts = new Set(scriptErrors.map((error) => error.path)).size;
33233
33159
  const verdict = cutShort !== null ? cutShort.verdict : nothingRan ?? (brokenScripts > 0 ? `script errors in ${brokenScripts === 1 ? "one script" : `${brokenScripts} scripts`}, so no suite ran` : undefined) ?? (exitCode === null ? undefined : verdicts[exitCode]) ?? (run.exitSignal === null ? `exit ${exitCode ?? "unknown"}` : howItExited(run));
33234
33160
  const savesNote = savesStayPut() && nothingRan === null ? SAVES_NOT_MOVED_NOTE : undefined;
33161
+ const marker = readNonEmptyString(args, "printed");
33162
+ const echoed = marker === undefined ? undefined : run.log.select({ severity: "info", sinceLastCall: false, contains: marker, limit: 200 });
33163
+ const printedLines = echoed === undefined ? {} : {
33164
+ printed: echoed.entries.map((entry) => entry.text),
33165
+ ...echoed.omitted > 0 ? { printedOmitted: echoed.omitted } : {}
33166
+ };
33235
33167
  if (report === null) {
33236
33168
  const note = nothingRan !== null ? `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}` : scriptErrors.length > 0 ? scriptErrorsNote(scriptErrors) : `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).`;
33237
33169
  return {
@@ -33250,6 +33182,7 @@ class GodotServer {
33250
33182
  ...scriptErrors.length > 0 ? { scriptErrors } : {},
33251
33183
  arguments: cmdArgs,
33252
33184
  entries: forAnswer(printed.slice(0, 60).map(aboveTheRunner)),
33185
+ ...printedLines,
33253
33186
  savesNote,
33254
33187
  ...scanned
33255
33188
  }, null, 2)
@@ -33286,6 +33219,7 @@ class GodotServer {
33286
33219
  warnings: warnings.length > 0 ? warnings : undefined,
33287
33220
  orphans: orphans.total > 0 ? orphans.total : undefined,
33288
33221
  notRun: notRun > 0 ? notRun : undefined,
33222
+ ...printedLines,
33289
33223
  savesNote,
33290
33224
  ...scanned,
33291
33225
  note: verdict.startsWith("warnings") && warnings.length === 0 ? "gdUnit4 exits 101 for orphan nodes when nothing failed, and this run printed no count of them: orphan reporting may be off in the project settings." : notRun > 0 ? `This run stopped at the first failure in each suite it failed in, so ${notRun} case${notRun === 1 ? "" : "s"} never ran and count as neither passed nor failed. Leave failFast out to run every case.` : undefined,
@@ -34671,15 +34605,18 @@ class GodotServer {
34671
34605
  "editor_output with no op answers about the game a run is playing, which is a different log"
34672
34606
  ]);
34673
34607
  }
34674
- const severity = readString(args, "severity");
34608
+ const asked = readString(args, "severity");
34609
+ const severity = asked === "error" || asked === "warning" ? asked : "info";
34610
+ const contains = readNonEmptyString(args, "contains");
34675
34611
  const selected = console2.log.select({
34676
- severity: severity === "error" || severity === "warning" ? severity : "info",
34612
+ severity,
34677
34613
  sinceLastCall: false,
34678
- contains: readNonEmptyString(args, "contains"),
34614
+ contains,
34679
34615
  limit: readPositiveNumber(args, "limit") ?? 200
34680
34616
  });
34681
34617
  const everything = console2.log.everything();
34682
- const grouped = bursts(everything, readNonNegativeNumber(args, "before") ?? 3);
34618
+ const filtered = severity !== "info" || contains !== undefined;
34619
+ const grouped = bursts(everything, readNonNegativeNumber(args, "before") ?? 3, filtered ? (entry) => answersTo(entry, severity, contains) : undefined);
34683
34620
  const shown = grouped.slice(0, MOST_GROUPS);
34684
34621
  return this.jsonTextResponse({
34685
34622
  editorPid: editorPid ?? undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.1.9",
3
+ "version": "1.1.10",
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",