gdharness 0.9.4 → 0.9.5

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
@@ -15393,7 +15393,7 @@ var init_tool_definitions = __esm(() => {
15393
15393
  },
15394
15394
  {
15395
15395
  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.",
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. 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
15397
  parameters: {
15398
15398
  projectPath: PROJECT_PATH,
15399
15399
  path: {
@@ -15668,7 +15668,7 @@ var init_tool_definitions = __esm(() => {
15668
15668
  },
15669
15669
  {
15670
15670
  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.",
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. 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
15672
  parameters: {
15673
15673
  projectPath: PROJECT_PATH,
15674
15674
  scene: { type: "string", description: "A scene to run instead of the main scene." },
@@ -35141,7 +35141,14 @@ function parseJUnit(xml) {
35141
35141
  suites
35142
35142
  };
35143
35143
  }
35144
- var MalformedReportError, ENTITIES, ATTRIBUTE;
35144
+ function whyNoReport(printed, asked) {
35145
+ const missing = printed.map((line) => NO_SUCH_PATH.exec(line)).find((found) => found !== null);
35146
+ if (missing !== undefined) {
35147
+ return `nothing at ${missing[1] ?? asked}`;
35148
+ }
35149
+ return printed.some((line) => NOTHING_RAN.test(line)) ? `no test cases found at ${asked}` : null;
35150
+ }
35151
+ var MalformedReportError, ENTITIES, ATTRIBUTE, NOTHING_RAN, NO_SUCH_PATH;
35145
35152
  var init_junit = __esm(() => {
35146
35153
  MalformedReportError = class MalformedReportError extends Error {
35147
35154
  constructor(message) {
@@ -35151,6 +35158,8 @@ var init_junit = __esm(() => {
35151
35158
  };
35152
35159
  ENTITIES = { lt: "<", gt: ">", amp: "&", quot: '"', apos: "'" };
35153
35160
  ATTRIBUTE = /\s*([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/y;
35161
+ NOTHING_RAN = /no test cases found/i;
35162
+ NO_SUCH_PATH = /given directory or file does not exists:\s*(\S+)/i;
35154
35163
  });
35155
35164
 
35156
35165
  // src/lsp_client.ts
@@ -36084,6 +36093,21 @@ function aboveTheRunner(entry) {
36084
36093
  detail: [...entry.detail.slice(0, runner), `[and ${cut} frames inside ${RUNNER_DIRECTORY}]`]
36085
36094
  };
36086
36095
  }
36096
+ function elsewhereIn(projectPath, asked) {
36097
+ const wanted = asked.replace(/^res:\/\//, "").replace(/\/+$/, "");
36098
+ if (wanted === "" || existsSync12(join15(projectPath, wanted))) {
36099
+ return "";
36100
+ }
36101
+ const root = (wanted.split("/")[0] ?? "").toLowerCase();
36102
+ let near = [];
36103
+ try {
36104
+ 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()));
36105
+ } catch {
36106
+ return "";
36107
+ }
36108
+ const there = near.map((name) => `res://${name}`).join(" and ");
36109
+ return near.length === 0 ? "" : ` The project does have ${there}.`;
36110
+ }
36087
36111
  function hasMainScene(projectFile) {
36088
36112
  const scene = parseProjectGodot(readFileSync13(projectFile, "utf8"))["application"]?.["run/main_scene"];
36089
36113
  return typeof scene === "string" && scene !== "";
@@ -36984,6 +37008,7 @@ class GodotServer {
36984
37008
  }
36985
37009
  const reports = "res://.godot/gdharness-reports";
36986
37010
  const ignored = readStringArray(args, "ignore") ?? [];
37011
+ const asked = readString(contained.value, "path") ?? "res://test";
36987
37012
  const cmdArgs = [
36988
37013
  "--headless",
36989
37014
  "--path",
@@ -36993,7 +37018,7 @@ class GodotServer {
36993
37018
  "--ignoreHeadlessMode",
36994
37019
  ...readBoolean(args, "failFast") === true ? [] : ["-c"],
36995
37020
  "-a",
36996
- readString(contained.value, "path") ?? "res://test",
37021
+ asked,
36997
37022
  ...ignored.flatMap((entry) => ["-i", entry]),
36998
37023
  "-rd",
36999
37024
  reports,
@@ -37043,21 +37068,25 @@ class GodotServer {
37043
37068
  105: "script errors"
37044
37069
  };
37045
37070
  const exitCode = run.exitCode;
37046
- const verdict = hung ? `hung: killed after ${timeoutMs} ms` : verdicts[exitCode ?? -1] ?? `exit ${exitCode ?? "unknown"}`;
37071
+ const printed = run.log.select({ severity: "info", sinceLastCall: false, limit: 200 }).entries;
37072
+ const said = printed.map((entry) => entry.text);
37073
+ const nothingRan = hung ? null : whyNoReport(said, asked);
37074
+ const verdict = hung ? `hung: killed after ${timeoutMs} ms` : nothingRan ?? verdicts[exitCode ?? -1] ?? `exit ${exitCode ?? "unknown"}`;
37047
37075
  if (report === null) {
37076
+ const note = nothingRan === null ? `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).` : `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}`;
37048
37077
  return {
37049
37078
  content: [
37050
- {
37051
- type: "text",
37052
- text: `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).`
37053
- },
37079
+ { type: "text", text: note },
37054
37080
  {
37055
37081
  type: "text",
37056
37082
  text: JSON.stringify({
37083
+ passed: false,
37084
+ verdict,
37085
+ tests: 0,
37057
37086
  exitCode,
37058
37087
  hung,
37059
37088
  arguments: cmdArgs,
37060
- entries: run.log.select({ severity: "info", sinceLastCall: false, limit: 60 }).entries
37089
+ entries: printed.slice(0, 60)
37061
37090
  }, null, 2)
37062
37091
  }
37063
37092
  ],
@@ -38607,6 +38636,16 @@ function disconnect(harness, projectPath) {
38607
38636
  `, "utf8");
38608
38637
  return { harness, path, action: "removed" };
38609
38638
  }
38639
+ function harnessNote(moved, now) {
38640
+ if (moved.length === 0) {
38641
+ return ` 2. Your harness config already named this version, but the server it is running is
38642
+ ` + ` whichever one it spawned when the session started, which this cannot see from here.
38643
+ ` + " Reconnect the MCP server, or restart the harness.";
38644
+ }
38645
+ return ` 2. Your harness is still running the server it spawned from ${moved.join(", ")}.
38646
+ ` + ` Its config names ${now} now.
38647
+ ` + " Reconnect the MCP server, or restart the harness.";
38648
+ }
38610
38649
  function launchedBy(entry) {
38611
38650
  if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
38612
38651
  return;
@@ -39342,6 +39381,7 @@ function reportConnection(group, launch, projectPath) {
39342
39381
  console.log(` ${harness.name}: ${harness.manual}`);
39343
39382
  }
39344
39383
  }
39384
+ return written.wasLaunchedBy;
39345
39385
  }
39346
39386
  function nextSteps(projectPath, runtime) {
39347
39387
  console.log(`
@@ -39475,8 +39515,12 @@ async function upgrade() {
39475
39515
  said2(await runOperation(godot, "refresh_class_cache", {}, projectPath), "rebuilding the class list");
39476
39516
  const launch = launchFor(version, godot.godotPath, projectPath);
39477
39517
  const already = HARNESSES.filter((harness) => registered(harness, projectPath));
39518
+ const moved = [];
39478
39519
  for (const group of groupByFile(already, projectPath)) {
39479
- reportConnection(group, launch, projectPath);
39520
+ const was = reportConnection(group, launch, projectPath);
39521
+ if (was !== undefined && !moved.includes(was)) {
39522
+ moved.push(was);
39523
+ }
39480
39524
  }
39481
39525
  if (already.length === 0) {
39482
39526
  console.log("no harness config names gdharness, so none was re-pinned");
@@ -39488,8 +39532,7 @@ async function upgrade() {
39488
39532
  On ${version}. Two things this could not do for you:`);
39489
39533
  console.log(` 1. The open editor is still running the addons it loaded at startup. Restart it with the
39490
39534
  ` + " editor_launch restart tool, which closes and reopens the window.");
39491
- console.log(` 2. Your harness is still running gdharness ${installed}: its config named that version when
39492
- ` + " the server was spawned. Reconnect the MCP server, or restart the harness.");
39535
+ console.log(harnessNote(moved, [launch.command, ...launch.args].join(" ")));
39493
39536
  }
39494
39537
  function doctorReport(projectPath) {
39495
39538
  const report = inspectProject(projectPath);
package/build/index.js CHANGED
@@ -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";
@@ -27987,7 +27996,7 @@ var TOOL_SPECS = [
27987
27996
  },
27988
27997
  {
27989
27998
  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.",
27999
+ 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
28000
  parameters: {
27992
28001
  projectPath: PROJECT_PATH,
27993
28002
  path: {
@@ -28262,7 +28271,7 @@ var TOOL_SPECS = [
28262
28271
  },
28263
28272
  {
28264
28273
  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.",
28274
+ 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
28275
  parameters: {
28267
28276
  projectPath: PROJECT_PATH,
28268
28277
  scene: { type: "string", description: "A scene to run instead of the main scene." },
@@ -28864,6 +28873,21 @@ function aboveTheRunner(entry) {
28864
28873
  detail: [...entry.detail.slice(0, runner), `[and ${cut} frames inside ${RUNNER_DIRECTORY}]`]
28865
28874
  };
28866
28875
  }
28876
+ function elsewhereIn(projectPath, asked) {
28877
+ const wanted = asked.replace(/^res:\/\//, "").replace(/\/+$/, "");
28878
+ if (wanted === "" || existsSync7(join9(projectPath, wanted))) {
28879
+ return "";
28880
+ }
28881
+ const root = (wanted.split("/")[0] ?? "").toLowerCase();
28882
+ let near = [];
28883
+ try {
28884
+ 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()));
28885
+ } catch {
28886
+ return "";
28887
+ }
28888
+ const there = near.map((name) => `res://${name}`).join(" and ");
28889
+ return near.length === 0 ? "" : ` The project does have ${there}.`;
28890
+ }
28867
28891
  function hasMainScene(projectFile) {
28868
28892
  const scene = parseProjectGodot(readFileSync9(projectFile, "utf8"))["application"]?.["run/main_scene"];
28869
28893
  return typeof scene === "string" && scene !== "";
@@ -29764,6 +29788,7 @@ class GodotServer {
29764
29788
  }
29765
29789
  const reports = "res://.godot/gdharness-reports";
29766
29790
  const ignored = readStringArray(args, "ignore") ?? [];
29791
+ const asked = readString(contained.value, "path") ?? "res://test";
29767
29792
  const cmdArgs = [
29768
29793
  "--headless",
29769
29794
  "--path",
@@ -29773,7 +29798,7 @@ class GodotServer {
29773
29798
  "--ignoreHeadlessMode",
29774
29799
  ...readBoolean(args, "failFast") === true ? [] : ["-c"],
29775
29800
  "-a",
29776
- readString(contained.value, "path") ?? "res://test",
29801
+ asked,
29777
29802
  ...ignored.flatMap((entry) => ["-i", entry]),
29778
29803
  "-rd",
29779
29804
  reports,
@@ -29823,21 +29848,25 @@ class GodotServer {
29823
29848
  105: "script errors"
29824
29849
  };
29825
29850
  const exitCode = run.exitCode;
29826
- const verdict = hung ? `hung: killed after ${timeoutMs} ms` : verdicts[exitCode ?? -1] ?? `exit ${exitCode ?? "unknown"}`;
29851
+ const printed = run.log.select({ severity: "info", sinceLastCall: false, limit: 200 }).entries;
29852
+ const said = printed.map((entry) => entry.text);
29853
+ const nothingRan = hung ? null : whyNoReport(said, asked);
29854
+ const verdict = hung ? `hung: killed after ${timeoutMs} ms` : nothingRan ?? verdicts[exitCode ?? -1] ?? `exit ${exitCode ?? "unknown"}`;
29827
29855
  if (report === null) {
29856
+ const note = nothingRan === null ? `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).` : `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}`;
29828
29857
  return {
29829
29858
  content: [
29830
- {
29831
- type: "text",
29832
- text: `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).`
29833
- },
29859
+ { type: "text", text: note },
29834
29860
  {
29835
29861
  type: "text",
29836
29862
  text: JSON.stringify({
29863
+ passed: false,
29864
+ verdict,
29865
+ tests: 0,
29837
29866
  exitCode,
29838
29867
  hung,
29839
29868
  arguments: cmdArgs,
29840
- entries: run.log.select({ severity: "info", sinceLastCall: false, limit: 60 }).entries
29869
+ entries: printed.slice(0, 60)
29841
29870
  }, null, 2)
29842
29871
  }
29843
29872
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
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",