gdharness 0.9.4 → 0.9.6
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
|
@@ -15382,7 +15382,10 @@ var init_tool_definitions = __esm(() => {
|
|
|
15382
15382
|
parameters: {
|
|
15383
15383
|
projectPath: PROJECT_PATH,
|
|
15384
15384
|
preset: { type: "string", description: "Preset name from export_presets.cfg." },
|
|
15385
|
-
outputPath: {
|
|
15385
|
+
outputPath: {
|
|
15386
|
+
type: "string",
|
|
15387
|
+
description: `Where the export is written, inside the project. The directory is created if it is not there: Godot's command-line exporter refuses a missing one with "The given export path doesn't exist", which reads as a wrong path in the preset.`
|
|
15388
|
+
},
|
|
15386
15389
|
debug: { type: "boolean", description: "run: a debug export. Default false." }
|
|
15387
15390
|
},
|
|
15388
15391
|
requires: ["projectPath"],
|
|
@@ -15393,7 +15396,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
15393
15396
|
},
|
|
15394
15397
|
{
|
|
15395
15398
|
name: "project_test",
|
|
15396
|
-
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.",
|
|
15399
|
+
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. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. Needs gdUnit4 under addons/gdUnit4.",
|
|
15397
15400
|
parameters: {
|
|
15398
15401
|
projectPath: PROJECT_PATH,
|
|
15399
15402
|
path: {
|
|
@@ -15668,7 +15671,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
15668
15671
|
},
|
|
15669
15672
|
{
|
|
15670
15673
|
name: "editor_run",
|
|
15671
|
-
description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way.",
|
|
15674
|
+
description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way.",
|
|
15672
15675
|
parameters: {
|
|
15673
15676
|
projectPath: PROJECT_PATH,
|
|
15674
15677
|
scene: { type: "string", description: "A scene to run instead of the main scene." },
|
|
@@ -15791,7 +15794,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
15791
15794
|
says: {
|
|
15792
15795
|
type: "string",
|
|
15793
15796
|
ops: ["find"],
|
|
15794
|
-
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."
|
|
15797
|
+
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. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern."
|
|
15795
15798
|
},
|
|
15796
15799
|
limit: {
|
|
15797
15800
|
type: "number",
|
|
@@ -35141,7 +35144,14 @@ function parseJUnit(xml) {
|
|
|
35141
35144
|
suites
|
|
35142
35145
|
};
|
|
35143
35146
|
}
|
|
35144
|
-
|
|
35147
|
+
function whyNoReport(printed, asked) {
|
|
35148
|
+
const missing = printed.map((line) => NO_SUCH_PATH.exec(line)).find((found) => found !== null);
|
|
35149
|
+
if (missing !== undefined) {
|
|
35150
|
+
return `nothing at ${missing[1] ?? asked}`;
|
|
35151
|
+
}
|
|
35152
|
+
return printed.some((line) => NOTHING_RAN.test(line)) ? `no test cases found at ${asked}` : null;
|
|
35153
|
+
}
|
|
35154
|
+
var MalformedReportError, ENTITIES, ATTRIBUTE, NOTHING_RAN, NO_SUCH_PATH;
|
|
35145
35155
|
var init_junit = __esm(() => {
|
|
35146
35156
|
MalformedReportError = class MalformedReportError extends Error {
|
|
35147
35157
|
constructor(message) {
|
|
@@ -35151,6 +35161,8 @@ var init_junit = __esm(() => {
|
|
|
35151
35161
|
};
|
|
35152
35162
|
ENTITIES = { lt: "<", gt: ">", amp: "&", quot: '"', apos: "'" };
|
|
35153
35163
|
ATTRIBUTE = /\s*([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/y;
|
|
35164
|
+
NOTHING_RAN = /no test cases found/i;
|
|
35165
|
+
NO_SUCH_PATH = /given directory or file does not exists:\s*(\S+)/i;
|
|
35154
35166
|
});
|
|
35155
35167
|
|
|
35156
35168
|
// src/lsp_client.ts
|
|
@@ -35993,7 +36005,7 @@ var init_setup = __esm(() => {
|
|
|
35993
36005
|
|
|
35994
36006
|
// src/server.ts
|
|
35995
36007
|
import { execFile as execFile5, spawn } from "node:child_process";
|
|
35996
|
-
import { existsSync as existsSync12, mkdtempSync as mkdtempSync3, readdirSync as readdirSync6, readFileSync as readFileSync13, realpathSync as realpathSync2, rmSync as rmSync8 } from "node:fs";
|
|
36008
|
+
import { existsSync as existsSync12, mkdirSync as mkdirSync7, mkdtempSync as mkdtempSync3, readdirSync as readdirSync6, readFileSync as readFileSync13, realpathSync as realpathSync2, rmSync as rmSync8 } from "node:fs";
|
|
35997
36009
|
import { tmpdir as tmpdir5 } from "node:os";
|
|
35998
36010
|
import { basename as basename2, dirname as dirname9, join as join15, normalize as normalize3 } from "node:path";
|
|
35999
36011
|
import { setTimeout as delay2 } from "node:timers/promises";
|
|
@@ -36084,6 +36096,21 @@ function aboveTheRunner(entry) {
|
|
|
36084
36096
|
detail: [...entry.detail.slice(0, runner), `[and ${cut} frames inside ${RUNNER_DIRECTORY}]`]
|
|
36085
36097
|
};
|
|
36086
36098
|
}
|
|
36099
|
+
function elsewhereIn(projectPath, asked) {
|
|
36100
|
+
const wanted = asked.replace(/^res:\/\//, "").replace(/\/+$/, "");
|
|
36101
|
+
if (wanted === "" || existsSync12(join15(projectPath, wanted))) {
|
|
36102
|
+
return "";
|
|
36103
|
+
}
|
|
36104
|
+
const root = (wanted.split("/")[0] ?? "").toLowerCase();
|
|
36105
|
+
let near = [];
|
|
36106
|
+
try {
|
|
36107
|
+
near = readdirSync6(projectPath, { withFileTypes: true }).filter((entry) => entry.isDirectory() && entry.name.toLowerCase() !== root).map((entry) => entry.name).filter((name) => name.toLowerCase().startsWith("test") || root.startsWith(name.toLowerCase()));
|
|
36108
|
+
} catch {
|
|
36109
|
+
return "";
|
|
36110
|
+
}
|
|
36111
|
+
const there = near.map((name) => `res://${name}`).join(" and ");
|
|
36112
|
+
return near.length === 0 ? "" : ` The project does have ${there}.`;
|
|
36113
|
+
}
|
|
36087
36114
|
function hasMainScene(projectFile) {
|
|
36088
36115
|
const scene = parseProjectGodot(readFileSync13(projectFile, "utf8"))["application"]?.["run/main_scene"];
|
|
36089
36116
|
return typeof scene === "string" && scene !== "";
|
|
@@ -36911,6 +36938,12 @@ class GodotServer {
|
|
|
36911
36938
|
if (!engine.ok) {
|
|
36912
36939
|
return engine.response;
|
|
36913
36940
|
}
|
|
36941
|
+
const folder = dirname9(output.absolutePath);
|
|
36942
|
+
try {
|
|
36943
|
+
mkdirSync7(folder, { recursive: true });
|
|
36944
|
+
} catch (error) {
|
|
36945
|
+
return this.createErrorResponse(`Cannot create ${dirname9(output.relativePath)} to export into: ${errorMessage(error)}`);
|
|
36946
|
+
}
|
|
36914
36947
|
const debug = readBoolean(args, "debug") ?? false;
|
|
36915
36948
|
const exportArgs = [
|
|
36916
36949
|
"--headless",
|
|
@@ -36984,6 +37017,7 @@ class GodotServer {
|
|
|
36984
37017
|
}
|
|
36985
37018
|
const reports = "res://.godot/gdharness-reports";
|
|
36986
37019
|
const ignored = readStringArray(args, "ignore") ?? [];
|
|
37020
|
+
const asked = readString(contained.value, "path") ?? "res://test";
|
|
36987
37021
|
const cmdArgs = [
|
|
36988
37022
|
"--headless",
|
|
36989
37023
|
"--path",
|
|
@@ -36993,7 +37027,7 @@ class GodotServer {
|
|
|
36993
37027
|
"--ignoreHeadlessMode",
|
|
36994
37028
|
...readBoolean(args, "failFast") === true ? [] : ["-c"],
|
|
36995
37029
|
"-a",
|
|
36996
|
-
|
|
37030
|
+
asked,
|
|
36997
37031
|
...ignored.flatMap((entry) => ["-i", entry]),
|
|
36998
37032
|
"-rd",
|
|
36999
37033
|
reports,
|
|
@@ -37043,21 +37077,25 @@ class GodotServer {
|
|
|
37043
37077
|
105: "script errors"
|
|
37044
37078
|
};
|
|
37045
37079
|
const exitCode = run.exitCode;
|
|
37046
|
-
const
|
|
37080
|
+
const printed = run.log.select({ severity: "info", sinceLastCall: false, limit: 200 }).entries;
|
|
37081
|
+
const said = printed.map((entry) => entry.text);
|
|
37082
|
+
const nothingRan = hung ? null : whyNoReport(said, asked);
|
|
37083
|
+
const verdict = hung ? `hung: killed after ${timeoutMs} ms` : nothingRan ?? verdicts[exitCode ?? -1] ?? `exit ${exitCode ?? "unknown"}`;
|
|
37047
37084
|
if (report === null) {
|
|
37085
|
+
const note = nothingRan === null ? `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).` : `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}`;
|
|
37048
37086
|
return {
|
|
37049
37087
|
content: [
|
|
37050
|
-
{
|
|
37051
|
-
type: "text",
|
|
37052
|
-
text: `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).`
|
|
37053
|
-
},
|
|
37088
|
+
{ type: "text", text: note },
|
|
37054
37089
|
{
|
|
37055
37090
|
type: "text",
|
|
37056
37091
|
text: JSON.stringify({
|
|
37092
|
+
passed: false,
|
|
37093
|
+
verdict,
|
|
37094
|
+
tests: 0,
|
|
37057
37095
|
exitCode,
|
|
37058
37096
|
hung,
|
|
37059
37097
|
arguments: cmdArgs,
|
|
37060
|
-
entries:
|
|
37098
|
+
entries: printed.slice(0, 60)
|
|
37061
37099
|
}, null, 2)
|
|
37062
37100
|
}
|
|
37063
37101
|
],
|
|
@@ -38607,6 +38645,16 @@ function disconnect(harness, projectPath) {
|
|
|
38607
38645
|
`, "utf8");
|
|
38608
38646
|
return { harness, path, action: "removed" };
|
|
38609
38647
|
}
|
|
38648
|
+
function harnessNote(moved, now) {
|
|
38649
|
+
if (moved.length === 0) {
|
|
38650
|
+
return ` 2. Your harness config already named this version, but the server it is running is
|
|
38651
|
+
` + ` whichever one it spawned when the session started, which this cannot see from here.
|
|
38652
|
+
` + " Reconnect the MCP server, or restart the harness.";
|
|
38653
|
+
}
|
|
38654
|
+
return ` 2. Your harness is still running the server it spawned from ${moved.join(", ")}.
|
|
38655
|
+
` + ` Its config names ${now} now.
|
|
38656
|
+
` + " Reconnect the MCP server, or restart the harness.";
|
|
38657
|
+
}
|
|
38610
38658
|
function launchedBy(entry) {
|
|
38611
38659
|
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
38612
38660
|
return;
|
|
@@ -38891,6 +38939,7 @@ init_class_cache();
|
|
|
38891
38939
|
init_headless();
|
|
38892
38940
|
init_resources();
|
|
38893
38941
|
init_server_version();
|
|
38942
|
+
import { spawnSync } from "node:child_process";
|
|
38894
38943
|
import { cpSync, existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync7, rmSync as rmSync4, writeFileSync as writeFileSync5 } from "node:fs";
|
|
38895
38944
|
import { dirname as dirname3, join as join8 } from "node:path";
|
|
38896
38945
|
import { fileURLToPath } from "node:url";
|
|
@@ -38953,6 +39002,29 @@ async function enablePlugins(engine, projectPath, names) {
|
|
|
38953
39002
|
async function setRuntime(engine, projectPath, on) {
|
|
38954
39003
|
return on ? await runOperation2(engine, "add_autoload", { name: RUNTIME_AUTOLOAD.name, path: `res://${RUNTIME_AUTOLOAD.path}`, enabled: true }, projectPath) : await runOperation2(engine, "remove_autoload", { name: RUNTIME_AUTOLOAD.name }, projectPath);
|
|
38955
39004
|
}
|
|
39005
|
+
function autoloadPaths(settings) {
|
|
39006
|
+
const named = new Map;
|
|
39007
|
+
for (const [name, value] of Object.entries(settings["autoload"] ?? {})) {
|
|
39008
|
+
const said = typeof value === "string" ? value.replace(/^\*/, "") : "";
|
|
39009
|
+
if (said.startsWith("res://")) {
|
|
39010
|
+
named.set(name, said.slice("res://".length));
|
|
39011
|
+
}
|
|
39012
|
+
}
|
|
39013
|
+
return named;
|
|
39014
|
+
}
|
|
39015
|
+
function trackedByGit(projectPath, wanted) {
|
|
39016
|
+
if (wanted.length === 0) {
|
|
39017
|
+
return new Set;
|
|
39018
|
+
}
|
|
39019
|
+
const asked = spawnSync("git", ["ls-files", "-z", "--", ...wanted], {
|
|
39020
|
+
cwd: projectPath,
|
|
39021
|
+
encoding: "utf8"
|
|
39022
|
+
});
|
|
39023
|
+
if (asked.status !== 0) {
|
|
39024
|
+
return null;
|
|
39025
|
+
}
|
|
39026
|
+
return new Set(asked.stdout.split("\x00").filter(Boolean));
|
|
39027
|
+
}
|
|
38956
39028
|
function enabledPlugins(settings) {
|
|
38957
39029
|
const raw = settings["editor_plugins"]?.["enabled"];
|
|
38958
39030
|
if (typeof raw !== "string") {
|
|
@@ -38984,6 +39056,13 @@ function inspectProject(projectPath) {
|
|
|
38984
39056
|
}
|
|
38985
39057
|
const autoload = settings["autoload"];
|
|
38986
39058
|
const runtimeAutoload = autoload !== undefined && typeof readString(autoload, RUNTIME_AUTOLOAD.name) === "string";
|
|
39059
|
+
const named = autoloadPaths(settings);
|
|
39060
|
+
const tracked = trackedByGit(projectPath, [...named.values()]);
|
|
39061
|
+
for (const [name, path] of named) {
|
|
39062
|
+
if (tracked !== null && !tracked.has(path) && existsSync7(join8(projectPath, path))) {
|
|
39063
|
+
problems.push(`the ${name} autoload names res://${path}, which git does not carry, so a clone boots with a missing script; commit that file, or point the autoload at one the project tracks`);
|
|
39064
|
+
}
|
|
39065
|
+
}
|
|
38987
39066
|
const cached = cachedClasses(projectPath);
|
|
38988
39067
|
const staleClasses = cached === null ? [] : staleAgainst(cached, projectPath);
|
|
38989
39068
|
if (cached === null) {
|
|
@@ -39118,6 +39197,9 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
|
|
|
39118
39197
|
These need the runtime autoload, which \`gdharness setup\` registers. \`runtime_capture\` needs a
|
|
39119
39198
|
window and refuses headless rather than handing back the last frame anything drew.
|
|
39120
39199
|
|
|
39200
|
+
A headless game answers; a \`godot -s\` script run does not. It has no game in it, so the autoload
|
|
39201
|
+
stays quiet there rather than announcing a test tier as the project.
|
|
39202
|
+
|
|
39121
39203
|
The autoload reaches an export. \`gdharness runtime off\` in the project before shipping.
|
|
39122
39204
|
|
|
39123
39205
|
## At a breakpoint
|
|
@@ -39342,6 +39424,7 @@ function reportConnection(group, launch, projectPath) {
|
|
|
39342
39424
|
console.log(` ${harness.name}: ${harness.manual}`);
|
|
39343
39425
|
}
|
|
39344
39426
|
}
|
|
39427
|
+
return written.wasLaunchedBy;
|
|
39345
39428
|
}
|
|
39346
39429
|
function nextSteps(projectPath, runtime) {
|
|
39347
39430
|
console.log(`
|
|
@@ -39475,8 +39558,12 @@ async function upgrade() {
|
|
|
39475
39558
|
said2(await runOperation(godot, "refresh_class_cache", {}, projectPath), "rebuilding the class list");
|
|
39476
39559
|
const launch = launchFor(version, godot.godotPath, projectPath);
|
|
39477
39560
|
const already = HARNESSES.filter((harness) => registered(harness, projectPath));
|
|
39561
|
+
const moved = [];
|
|
39478
39562
|
for (const group of groupByFile(already, projectPath)) {
|
|
39479
|
-
reportConnection(group, launch, projectPath);
|
|
39563
|
+
const was = reportConnection(group, launch, projectPath);
|
|
39564
|
+
if (was !== undefined && !moved.includes(was)) {
|
|
39565
|
+
moved.push(was);
|
|
39566
|
+
}
|
|
39480
39567
|
}
|
|
39481
39568
|
if (already.length === 0) {
|
|
39482
39569
|
console.log("no harness config names gdharness, so none was re-pinned");
|
|
@@ -39488,8 +39575,7 @@ async function upgrade() {
|
|
|
39488
39575
|
On ${version}. Two things this could not do for you:`);
|
|
39489
39576
|
console.log(` 1. The open editor is still running the addons it loaded at startup. Restart it with the
|
|
39490
39577
|
` + " editor_launch restart tool, which closes and reopens the window.");
|
|
39491
|
-
console.log(
|
|
39492
|
-
` + " the server was spawned. Reconnect the MCP server, or restart the harness.");
|
|
39578
|
+
console.log(harnessNote(moved, [launch.command, ...launch.args].join(" ")));
|
|
39493
39579
|
}
|
|
39494
39580
|
function doctorReport(projectPath) {
|
|
39495
39581
|
const report = inspectProject(projectPath);
|
|
@@ -23,6 +23,12 @@ const BIND_ADDRESS_SETTING: String = "gdharness/runtime/bind_address"
|
|
|
23
23
|
## expects. A fixed port is for a client that cannot read the announcement.
|
|
24
24
|
const PORT_SETTING: String = "gdharness/runtime/port"
|
|
25
25
|
|
|
26
|
+
## How the engine is told to run a script instead of the game: `godot -s thing.gd`.
|
|
27
|
+
const SCRIPT_FLAGS: PackedStringArray = ["-s", "--script"]
|
|
28
|
+
|
|
29
|
+
## Serve a script run anyway, for somebody driving a `-s` script rather than a game.
|
|
30
|
+
const SCRIPT_RUNS_SETTING: String = "gdharness/runtime/serve_script_runs"
|
|
31
|
+
|
|
26
32
|
var values: Values = Values.new()
|
|
27
33
|
|
|
28
34
|
# The modules are members and not locals of _init, because a Callable holds its object by id
|
|
@@ -161,6 +167,15 @@ func _start_server() -> void:
|
|
|
161
167
|
_enabled = false
|
|
162
168
|
return
|
|
163
169
|
|
|
170
|
+
# And a script run has nothing to serve. Autoloads come up for `godot -s` as well, so a test
|
|
171
|
+
# tier or a batch tool binds a loopback port and writes an announcement under the project's
|
|
172
|
+
# own path: a gate that starts sixteen engines at once announces sixteen games that are not
|
|
173
|
+
# games, and a client asking the runtime anything while they run can be answered by whichever
|
|
174
|
+
# of them replies first. Measured on two projects before it was written here.
|
|
175
|
+
if _script_run() and not bool(ProjectSettings.get_setting(SCRIPT_RUNS_SETTING, false)):
|
|
176
|
+
_enabled = false
|
|
177
|
+
return
|
|
178
|
+
|
|
164
179
|
_server = TCPServer.new()
|
|
165
180
|
# listen() defaults bind_address to "*", which exposes the game to the whole network.
|
|
166
181
|
var bind_address: String = str(ProjectSettings.get_setting(BIND_ADDRESS_SETTING, DEFAULT_BIND_ADDRESS))
|
|
@@ -180,6 +195,15 @@ func _start_server() -> void:
|
|
|
180
195
|
print("[gdharness] runtime listening on %s:%d, announced at %s" % [bind_address, _port, _announcement])
|
|
181
196
|
|
|
182
197
|
|
|
198
|
+
## Whether the engine was told to run a script rather than the game.
|
|
199
|
+
func _script_run() -> bool:
|
|
200
|
+
var given: PackedStringArray = OS.get_cmdline_args()
|
|
201
|
+
for flag: String in SCRIPT_FLAGS:
|
|
202
|
+
if given.has(flag):
|
|
203
|
+
return true
|
|
204
|
+
return false
|
|
205
|
+
|
|
206
|
+
|
|
183
207
|
## Where the announcement goes. The server derives the same path with the same precedence, so
|
|
184
208
|
## the two only meet if this stays in step with `runtimeDirectory` in src/runtime-client.ts.
|
|
185
209
|
func _announcement_directory() -> String:
|
|
@@ -160,6 +160,17 @@ static func _is_literal(pattern: String) -> bool:
|
|
|
160
160
|
return not pattern.is_empty() and not pattern.contains("*") and not pattern.contains("?")
|
|
161
161
|
|
|
162
162
|
|
|
163
|
+
## Whether [param said] is what a find asked for in [param wanted].
|
|
164
|
+
##
|
|
165
|
+
## A plain word is a contains, which is what somebody looking for the row about a person means. A
|
|
166
|
+
## pattern is a glob, because [code]namePattern[/code] beside it is one and nobody writes `*Still*`
|
|
167
|
+
## in one field meaning a glob and in the other meaning those characters. Written as a contains
|
|
168
|
+
## only, a glob matched nothing at all and an empty answer reads as a control that is not on the
|
|
169
|
+
## screen: twice in one session here, over a button that was.
|
|
170
|
+
static func _says(said: String, wanted: String) -> bool:
|
|
171
|
+
return said.containsn(wanted) if _is_literal(wanted) else said.matchn(wanted)
|
|
172
|
+
|
|
173
|
+
|
|
163
174
|
## Every filter but the name, so a find that came back empty can say how many nodes the name was
|
|
164
175
|
## the only thing standing between it and.
|
|
165
176
|
func _matches_apart_from_name(node: Node, wanted: Dictionary[String, String]) -> bool:
|
|
@@ -168,7 +179,7 @@ func _matches_apart_from_name(node: Node, wanted: Dictionary[String, String]) ->
|
|
|
168
179
|
# What this node says, rather than everything said underneath it. A row is then found by the
|
|
169
180
|
# label in it, and the path answered is that label's, which is where the words a caller is
|
|
170
181
|
# looking at actually are: matching every container above it would answer with the screen.
|
|
171
|
-
if not wanted["says"].is_empty() and not said_by(node)
|
|
182
|
+
if not wanted["says"].is_empty() and not _says(said_by(node), wanted["says"]):
|
|
172
183
|
return false
|
|
173
184
|
var script: Variant = node.get_script()
|
|
174
185
|
if not wanted["script"].is_empty():
|
package/build/index.js
CHANGED
|
@@ -10126,7 +10126,7 @@ function defectReport(where, error, godotVersion) {
|
|
|
10126
10126
|
|
|
10127
10127
|
// src/server.ts
|
|
10128
10128
|
import { execFile as execFile3, spawn } from "node:child_process";
|
|
10129
|
-
import { existsSync as existsSync7, mkdtempSync as mkdtempSync2, readdirSync as readdirSync5, readFileSync as readFileSync9, realpathSync as realpathSync2, rmSync as rmSync4 } from "node:fs";
|
|
10129
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync as mkdtempSync2, readdirSync as readdirSync5, readFileSync as readFileSync9, realpathSync as realpathSync2, rmSync as rmSync4 } from "node:fs";
|
|
10130
10130
|
import { tmpdir as tmpdir4 } from "node:os";
|
|
10131
10131
|
import { basename, dirname as dirname4, join as join9, normalize as normalize2 } from "node:path";
|
|
10132
10132
|
import { setTimeout as delay2 } from "node:timers/promises";
|
|
@@ -26583,6 +26583,15 @@ function parseJUnit(xml) {
|
|
|
26583
26583
|
suites
|
|
26584
26584
|
};
|
|
26585
26585
|
}
|
|
26586
|
+
var NOTHING_RAN = /no test cases found/i;
|
|
26587
|
+
var NO_SUCH_PATH = /given directory or file does not exists:\s*(\S+)/i;
|
|
26588
|
+
function whyNoReport(printed, asked) {
|
|
26589
|
+
const missing = printed.map((line) => NO_SUCH_PATH.exec(line)).find((found) => found !== null);
|
|
26590
|
+
if (missing !== undefined) {
|
|
26591
|
+
return `nothing at ${missing[1] ?? asked}`;
|
|
26592
|
+
}
|
|
26593
|
+
return printed.some((line) => NOTHING_RAN.test(line)) ? `no test cases found at ${asked}` : null;
|
|
26594
|
+
}
|
|
26586
26595
|
|
|
26587
26596
|
// src/lsp_client.ts
|
|
26588
26597
|
import { realpathSync } from "node:fs";
|
|
@@ -27976,7 +27985,10 @@ var TOOL_SPECS = [
|
|
|
27976
27985
|
parameters: {
|
|
27977
27986
|
projectPath: PROJECT_PATH,
|
|
27978
27987
|
preset: { type: "string", description: "Preset name from export_presets.cfg." },
|
|
27979
|
-
outputPath: {
|
|
27988
|
+
outputPath: {
|
|
27989
|
+
type: "string",
|
|
27990
|
+
description: `Where the export is written, inside the project. The directory is created if it is not there: Godot's command-line exporter refuses a missing one with "The given export path doesn't exist", which reads as a wrong path in the preset.`
|
|
27991
|
+
},
|
|
27980
27992
|
debug: { type: "boolean", description: "run: a debug export. Default false." }
|
|
27981
27993
|
},
|
|
27982
27994
|
requires: ["projectPath"],
|
|
@@ -27987,7 +27999,7 @@ var TOOL_SPECS = [
|
|
|
27987
27999
|
},
|
|
27988
28000
|
{
|
|
27989
28001
|
name: "project_test",
|
|
27990
|
-
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.",
|
|
28002
|
+
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. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. Needs gdUnit4 under addons/gdUnit4.",
|
|
27991
28003
|
parameters: {
|
|
27992
28004
|
projectPath: PROJECT_PATH,
|
|
27993
28005
|
path: {
|
|
@@ -28262,7 +28274,7 @@ var TOOL_SPECS = [
|
|
|
28262
28274
|
},
|
|
28263
28275
|
{
|
|
28264
28276
|
name: "editor_run",
|
|
28265
|
-
description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way.",
|
|
28277
|
+
description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way.",
|
|
28266
28278
|
parameters: {
|
|
28267
28279
|
projectPath: PROJECT_PATH,
|
|
28268
28280
|
scene: { type: "string", description: "A scene to run instead of the main scene." },
|
|
@@ -28385,7 +28397,7 @@ var TOOL_SPECS = [
|
|
|
28385
28397
|
says: {
|
|
28386
28398
|
type: "string",
|
|
28387
28399
|
ops: ["find"],
|
|
28388
|
-
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."
|
|
28400
|
+
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. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern."
|
|
28389
28401
|
},
|
|
28390
28402
|
limit: {
|
|
28391
28403
|
type: "number",
|
|
@@ -28864,6 +28876,21 @@ function aboveTheRunner(entry) {
|
|
|
28864
28876
|
detail: [...entry.detail.slice(0, runner), `[and ${cut} frames inside ${RUNNER_DIRECTORY}]`]
|
|
28865
28877
|
};
|
|
28866
28878
|
}
|
|
28879
|
+
function elsewhereIn(projectPath, asked) {
|
|
28880
|
+
const wanted = asked.replace(/^res:\/\//, "").replace(/\/+$/, "");
|
|
28881
|
+
if (wanted === "" || existsSync7(join9(projectPath, wanted))) {
|
|
28882
|
+
return "";
|
|
28883
|
+
}
|
|
28884
|
+
const root = (wanted.split("/")[0] ?? "").toLowerCase();
|
|
28885
|
+
let near = [];
|
|
28886
|
+
try {
|
|
28887
|
+
near = readdirSync5(projectPath, { withFileTypes: true }).filter((entry) => entry.isDirectory() && entry.name.toLowerCase() !== root).map((entry) => entry.name).filter((name) => name.toLowerCase().startsWith("test") || root.startsWith(name.toLowerCase()));
|
|
28888
|
+
} catch {
|
|
28889
|
+
return "";
|
|
28890
|
+
}
|
|
28891
|
+
const there = near.map((name) => `res://${name}`).join(" and ");
|
|
28892
|
+
return near.length === 0 ? "" : ` The project does have ${there}.`;
|
|
28893
|
+
}
|
|
28867
28894
|
function hasMainScene(projectFile) {
|
|
28868
28895
|
const scene = parseProjectGodot(readFileSync9(projectFile, "utf8"))["application"]?.["run/main_scene"];
|
|
28869
28896
|
return typeof scene === "string" && scene !== "";
|
|
@@ -29691,6 +29718,12 @@ class GodotServer {
|
|
|
29691
29718
|
if (!engine.ok) {
|
|
29692
29719
|
return engine.response;
|
|
29693
29720
|
}
|
|
29721
|
+
const folder = dirname4(output.absolutePath);
|
|
29722
|
+
try {
|
|
29723
|
+
mkdirSync4(folder, { recursive: true });
|
|
29724
|
+
} catch (error) {
|
|
29725
|
+
return this.createErrorResponse(`Cannot create ${dirname4(output.relativePath)} to export into: ${errorMessage(error)}`);
|
|
29726
|
+
}
|
|
29694
29727
|
const debug = readBoolean(args, "debug") ?? false;
|
|
29695
29728
|
const exportArgs = [
|
|
29696
29729
|
"--headless",
|
|
@@ -29764,6 +29797,7 @@ class GodotServer {
|
|
|
29764
29797
|
}
|
|
29765
29798
|
const reports = "res://.godot/gdharness-reports";
|
|
29766
29799
|
const ignored = readStringArray(args, "ignore") ?? [];
|
|
29800
|
+
const asked = readString(contained.value, "path") ?? "res://test";
|
|
29767
29801
|
const cmdArgs = [
|
|
29768
29802
|
"--headless",
|
|
29769
29803
|
"--path",
|
|
@@ -29773,7 +29807,7 @@ class GodotServer {
|
|
|
29773
29807
|
"--ignoreHeadlessMode",
|
|
29774
29808
|
...readBoolean(args, "failFast") === true ? [] : ["-c"],
|
|
29775
29809
|
"-a",
|
|
29776
|
-
|
|
29810
|
+
asked,
|
|
29777
29811
|
...ignored.flatMap((entry) => ["-i", entry]),
|
|
29778
29812
|
"-rd",
|
|
29779
29813
|
reports,
|
|
@@ -29823,21 +29857,25 @@ class GodotServer {
|
|
|
29823
29857
|
105: "script errors"
|
|
29824
29858
|
};
|
|
29825
29859
|
const exitCode = run.exitCode;
|
|
29826
|
-
const
|
|
29860
|
+
const printed = run.log.select({ severity: "info", sinceLastCall: false, limit: 200 }).entries;
|
|
29861
|
+
const said = printed.map((entry) => entry.text);
|
|
29862
|
+
const nothingRan = hung ? null : whyNoReport(said, asked);
|
|
29863
|
+
const verdict = hung ? `hung: killed after ${timeoutMs} ms` : nothingRan ?? verdicts[exitCode ?? -1] ?? `exit ${exitCode ?? "unknown"}`;
|
|
29827
29864
|
if (report === null) {
|
|
29865
|
+
const note = nothingRan === null ? `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).` : `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}`;
|
|
29828
29866
|
return {
|
|
29829
29867
|
content: [
|
|
29830
|
-
{
|
|
29831
|
-
type: "text",
|
|
29832
|
-
text: `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).`
|
|
29833
|
-
},
|
|
29868
|
+
{ type: "text", text: note },
|
|
29834
29869
|
{
|
|
29835
29870
|
type: "text",
|
|
29836
29871
|
text: JSON.stringify({
|
|
29872
|
+
passed: false,
|
|
29873
|
+
verdict,
|
|
29874
|
+
tests: 0,
|
|
29837
29875
|
exitCode,
|
|
29838
29876
|
hung,
|
|
29839
29877
|
arguments: cmdArgs,
|
|
29840
|
-
entries:
|
|
29878
|
+
entries: printed.slice(0, 60)
|
|
29841
29879
|
}, null, 2)
|
|
29842
29880
|
}
|
|
29843
29881
|
],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gdharness",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.6",
|
|
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",
|