gdharness 1.1.7 → 1.1.9

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
@@ -7836,6 +7836,30 @@ function cachedClasses(projectPath) {
7836
7836
  }
7837
7837
  return listed;
7838
7838
  }
7839
+ function withAddonClassesCounted(rebuilt, listed) {
7840
+ const counted = { ...rebuilt };
7841
+ for (const key of ["added", "changed"]) {
7842
+ const names = rebuilt[key];
7843
+ if (!Array.isArray(names)) {
7844
+ continue;
7845
+ }
7846
+ const own = [];
7847
+ const perAddon = {};
7848
+ for (const name of names) {
7849
+ const addon = /^res:\/\/addons\/([^/]+)\//.exec(listed.get(String(name)) ?? "")?.[1];
7850
+ if (addon === undefined) {
7851
+ own.push(name);
7852
+ } else {
7853
+ perAddon[addon] = (perAddon[addon] ?? 0) + 1;
7854
+ }
7855
+ }
7856
+ if (Object.keys(perAddon).length > 0) {
7857
+ counted[key] = own;
7858
+ counted[`${key}InAddons`] = perAddon;
7859
+ }
7860
+ }
7861
+ return counted;
7862
+ }
7839
7863
  function missingMemberIn(message) {
7840
7864
  const found = /The (method|property) "([^"(]+)(?:\(\))?" is not present on the inferred type "([^"]+)"/.exec(message);
7841
7865
  if (found === null) {
@@ -15554,6 +15578,7 @@ import {
15554
15578
  mkdirSync as mkdirSync5,
15555
15579
  readdirSync as readdirSync4,
15556
15580
  readFileSync as readFileSync9,
15581
+ rmdirSync as rmdirSync2,
15557
15582
  rmSync as rmSync5,
15558
15583
  statSync as statSync4,
15559
15584
  writeFileSync as writeFileSync7
@@ -16044,7 +16069,7 @@ var init_tool_definitions = __esm(() => {
16044
16069
  },
16045
16070
  {
16046
16071
  name: "project_test",
16047
- 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, and the rebuild's answer is under classes: a class it puts back that the cache had after the last rebuild is named under classes.lostSinceLastRebuild, which is the editor writing its shorter list over the file on every save until restarted. 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. On macOS the engine reads user:// off HOME and the run writes where the player keeps theirs, which the answer says under savesNote rather than leaving to be found in a save list. 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. A run where nothing failed but nodes were left in the tree comes back under warnings, with the count per suite: gdUnit4 decides its verdict on those and keeps them out of its report, so they are read off what it printed. Needs gdUnit4 under addons/gdUnit4.",
16072
+ 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, and the rebuild's answer is under classes, with the classes each addon declares counted under addedInAddons and changedInAddons rather than named: a class it puts back that the cache had after the last rebuild is named under classes.lostSinceLastRebuild, which is the editor writing its shorter list over the file on every save until restarted. 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. On macOS the engine reads user:// off HOME and the run writes where the player keeps theirs, which the answer says under savesNote rather than leaving to be found in a save list. 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. A run where nothing failed but nodes were left in the tree comes back under warnings, with the count per suite: gdUnit4 decides its verdict on those and keeps them out of its report, so they are read off what it printed. Needs gdUnit4 under addons/gdUnit4.",
16048
16073
  parameters: {
16049
16074
  projectPath: PROJECT_PATH,
16050
16075
  path: {
@@ -16063,7 +16088,7 @@ var init_tool_definitions = __esm(() => {
16063
16088
  timeoutMs: {
16064
16089
  type: "integer",
16065
16090
  minimum: 1,
16066
- description: "How long the run may take before it is killed. Default 600000."
16091
+ 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."
16067
16092
  }
16068
16093
  },
16069
16094
  requires: ["projectPath"]
@@ -37114,6 +37139,26 @@ function whyNoReport(printed, asked) {
37114
37139
  }
37115
37140
  return printed.some((line) => NOTHING_RAN.test(line)) ? `no test cases found at ${asked}` : null;
37116
37141
  }
37142
+ function scriptErrorsPrinted(printed) {
37143
+ const start = printed.findIndex((line) => line.includes(DISCOVERY_FAILED));
37144
+ if (start < 0) {
37145
+ return [];
37146
+ }
37147
+ const found = [];
37148
+ let message = null;
37149
+ for (const line of printed.slice(start + 1)) {
37150
+ const at = /^\s*at (res:\/\/.+):(\d+)\s*$/.exec(line);
37151
+ if (at !== null && message !== null) {
37152
+ found.push({ path: at[1] ?? "", line: Number(at[2]), message });
37153
+ message = null;
37154
+ } else if (at === null && message === null && /^ {2}\S/.test(line)) {
37155
+ message = line.trim();
37156
+ } else {
37157
+ break;
37158
+ }
37159
+ }
37160
+ return found.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : a.line - b.line);
37161
+ }
37117
37162
  function unspelled(text) {
37118
37163
  return text.replace(/<(?:LF|CR|TAB|BS|FF|VT|BEL|ESC|DEL|0x[0-9A-F]{2})>/g, (spelling) => spelling.startsWith("<0x") ? String.fromCharCode(Number.parseInt(spelling.slice(3, 5), 16)) : SPELLED[spelling] ?? spelling);
37119
37164
  }
@@ -37147,7 +37192,8 @@ function withActualsPrinted(failed, printed) {
37147
37192
  const diffs = [...printed.replaceAll(`\r
37148
37193
  `, `
37149
37194
  `).matchAll(PRINTED_ACTUAL)].map((match) => ({
37150
- at: match[3] ?? "",
37195
+ suffix: match[3] ?? "",
37196
+ at: match[5] ?? "",
37151
37197
  ...readDiff(match[1] ?? "")
37152
37198
  }));
37153
37199
  return failed.map((entry) => {
@@ -37158,23 +37204,34 @@ function withActualsPrinted(failed, printed) {
37158
37204
  let recovered = false;
37159
37205
  for (const diff of diffs) {
37160
37206
  const wrong = ` but was
37161
- '${diff.merged.replaceAll("\r", "")}'
37207
+ '${diff.merged.replaceAll("\r", "")}'${diff.suffix}
37162
37208
  at ${diff.at}`;
37163
37209
  if (detail.includes(wrong)) {
37164
37210
  detail = detail.replace(wrong, () => ` but was
37165
- '${diff.actual}'
37211
+ '${diff.actual}'${diff.suffix}
37166
37212
  at ${diff.at}`);
37167
37213
  recovered = true;
37168
37214
  }
37169
37215
  }
37170
- if (!recovered && /Expecting:\n '[\s\S]*'\n but was\n '[\s\S]*'\n\tat /.test(detail)) {
37171
- const alike = /Expecting:\n '([\s\S]*)'\n but was\n '\1'\n\tat /.test(detail);
37172
- detail = `${detail}
37173
- ${alike ? BOTH_SIDES_ALIKE_NOTE : VALUE_NOT_RECOVERED_NOTE}`;
37216
+ const quoted = QUOTED_EQUALITY.exec(detail);
37217
+ if (quoted !== null) {
37218
+ const alike = quoted[1] === quoted[2];
37219
+ const note = recovered ? alike ? RENDERED_AWAY_NOTE : null : alike ? BOTH_SIDES_ALIKE_NOTE : VALUE_NOT_RECOVERED_NOTE;
37220
+ detail = note === null ? detail : `${detail}
37221
+ ${note}`;
37222
+ }
37223
+ if (quoted !== null && masked(quoted[1] ?? "", quoted[2] ?? "")) {
37224
+ const end = quoted[0].length - `
37225
+ at `.length;
37226
+ detail = detail.slice(0, end).replaceAll(MASKED_BRACKET, "[") + detail.slice(end);
37174
37227
  }
37175
37228
  return detail === entry.detail ? entry : { ...entry, detail };
37176
37229
  });
37177
37230
  }
37231
+ function masked(expected, actual) {
37232
+ const allSpelled = (value) => !PRINTED_DICTIONARY.test(value) && value.split("[").length === value.split(MASKED_BRACKET).length;
37233
+ return `${expected}${actual}`.includes(MASKED_BRACKET) && allSpelled(expected) && allSpelled(actual);
37234
+ }
37178
37235
  function orphansPrinted(printed) {
37179
37236
  const suites = [];
37180
37237
  let total = null;
@@ -37205,7 +37262,7 @@ function orphansPrinted(printed) {
37205
37262
  suites
37206
37263
  };
37207
37264
  }
37208
- var MalformedReportError, ENTITIES, ATTRIBUTE, NOTHING_RAN, NO_SUCH_PATH, ESCAPE, LOCATION = `'[^'\\n]*' in \\S+:\\d+`, PRINTED_ACTUAL, COLOUR2, SPELLED, BOTH_SIDES_ALIKE_NOTE = "[gdharness: the expected and the actual printed alike because gdUnit4 drops the marks that tell them apart when it writes its report, and the runner printed no copy of this one, so neither side above is the value the assertion found.]", VALUE_NOT_RECOVERED_NOTE = '[gdharness: the value after "but was" may not be the one the assertion found. gdUnit4 writes a failing string into its report as a character diff against the expected one with the marks dropped, which merges the two, and the runner printed no copy of this one to read the value back from.]', RUNNING_SUITE, ORPHANS, OVERALL = "Overall Summary";
37265
+ var MalformedReportError, ENTITIES, ATTRIBUTE, NOTHING_RAN, NO_SUCH_PATH, DISCOVERY_FAILED = "Script errors were detected during test discovery!", ESCAPE, LOCATION = `'[^'\\n]*' in \\S+:\\d+`, IGNORING_CASE = " \\(ignoring case\\)", PRINTED_ACTUAL, QUOTED_EQUALITY, COLOUR2, SPELLED, BOTH_SIDES_ALIKE_NOTE = "[gdharness: the expected and the actual printed alike because gdUnit4 drops the marks that tell them apart when it writes its report, and the runner printed no copy of this one, so neither side above is the value the assertion found.]", VALUE_NOT_RECOVERED_NOTE = '[gdharness: the value after "but was" may not be the one the assertion found. gdUnit4 writes a failing string into its report as a character diff against the expected one with the marks dropped, which merges the two, and the runner printed no copy of this one to read the value back from.]', RENDERED_AWAY_NOTE = '[gdharness: the two sides print alike, so what tells them apart is something gdUnit4 does not print. The usual cause is BBCode in the expected string, which gdUnit4 renders as formatting instead of showing; the value after "but was" is the one the assertion found.]', MASKED_BRACKET = "[lb]", PRINTED_DICTIONARY, RUNNING_SUITE, ORPHANS, OVERALL = "Overall Summary";
37209
37266
  var init_junit = __esm(() => {
37210
37267
  MalformedReportError = class MalformedReportError extends Error {
37211
37268
  constructor(message) {
@@ -37218,7 +37275,8 @@ var init_junit = __esm(() => {
37218
37275
  NOTHING_RAN = /no test cases found/i;
37219
37276
  NO_SUCH_PATH = /given directory or file does not exists:\s*(\S+)/i;
37220
37277
  ESCAPE = String.fromCharCode(27);
37221
- PRINTED_ACTUAL = new RegExp(` but was\\n '((?:(?!\\tat ${LOCATION})[\\s\\S])*?)'((?:${ESCAPE}\\[[0-9;]*m)*)\\tat (${LOCATION})`, "g");
37278
+ PRINTED_ACTUAL = new RegExp(` but was\\n '((?:(?!\\tat ${LOCATION})[\\s\\S])*?)'((?:${ESCAPE}\\[[0-9;]*m)*)(${IGNORING_CASE})?((?:${ESCAPE}\\[[0-9;]*m)*)\\tat (${LOCATION})`, "g");
37279
+ QUOTED_EQUALITY = new RegExp(`^Expecting(?: error message)?:\\n '([\\s\\S]*)'\\n but was\\n '([\\s\\S]*)'(?:${IGNORING_CASE})?\\n\\tat `);
37222
37280
  COLOUR2 = new RegExp(`${ESCAPE}\\[([0-9;]*)m`, "g");
37223
37281
  SPELLED = {
37224
37282
  "<LF>": `
@@ -37232,6 +37290,7 @@ var init_junit = __esm(() => {
37232
37290
  "<ESC>": ESCAPE,
37233
37291
  "<DEL>": ""
37234
37292
  };
37293
+ PRINTED_DICTIONARY = /^\{\n\t[\s\S]*\n {2}\}$/;
37235
37294
  RUNNING_SUITE = /Run Test Suite:\s*(\S+)/;
37236
37295
  ORPHANS = /(\d+)\s+orphans/;
37237
37296
  });
@@ -38437,7 +38496,7 @@ import {
38437
38496
  readdirSync as readdirSync9,
38438
38497
  readFileSync as readFileSync22,
38439
38498
  readSync as readSync2,
38440
- rmdirSync,
38499
+ rmdirSync as rmdirSync3,
38441
38500
  statSync as statSync9
38442
38501
  } from "node:fs";
38443
38502
  import { tmpdir as tmpdir5 } from "node:os";
@@ -38828,6 +38887,17 @@ function changedSettings(before, after) {
38828
38887
  const names = new Set([...before.keys(), ...after.keys()]);
38829
38888
  return [...names].filter((name) => JSON.stringify(before.get(name)) !== JSON.stringify(after.get(name))).sort();
38830
38889
  }
38890
+ function scriptErrorsNote(errors) {
38891
+ const first = new Map;
38892
+ for (const error of errors) {
38893
+ if (!first.has(error.path)) {
38894
+ first.set(error.path, error);
38895
+ }
38896
+ }
38897
+ const scripts = [...first.values()].map((error) => `${error.path}:${error.line} ${error.message}${error.message.endsWith(".") ? "" : "."}`).join(" ");
38898
+ const more = errors.length - first.size;
38899
+ 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.` : ""}`;
38900
+ }
38831
38901
  function timedOutVerdict(timeoutMs, silentForMs) {
38832
38902
  const quiet = Math.round(silentForMs);
38833
38903
  return quiet >= Math.min(STALLED_AFTER_MS, timeoutMs / 2) ? {
@@ -40390,7 +40460,7 @@ class GodotServer {
40390
40460
  } finally {
40391
40461
  discard(reportsDir, userData);
40392
40462
  try {
40393
- rmdirSync(dirname17(reportsDir));
40463
+ rmdirSync3(dirname17(reportsDir));
40394
40464
  } catch {}
40395
40465
  }
40396
40466
  const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries.map(aboveTheRunner);
@@ -40408,10 +40478,12 @@ class GodotServer {
40408
40478
  const nothingRan = timedOut ? null : whyNoReport(said, asked);
40409
40479
  const cutShort = timedOut ? timedOutVerdict(timeoutMs, silentForMs) : null;
40410
40480
  const hung = cutShort?.hung ?? false;
40411
- const verdict = cutShort !== null ? cutShort.verdict : nothingRan ?? (exitCode === null ? undefined : verdicts[exitCode]) ?? (run.exitSignal === null ? `exit ${exitCode ?? "unknown"}` : howItExited(run));
40481
+ const scriptErrors = timedOut || exitCode !== 105 ? [] : scriptErrorsPrinted(said);
40482
+ const brokenScripts = new Set(scriptErrors.map((error) => error.path)).size;
40483
+ 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));
40412
40484
  const savesNote = savesStayPut() && nothingRan === null ? SAVES_NOT_MOVED_NOTE : undefined;
40413
40485
  if (report === null) {
40414
- const note = nothingRan === null ? `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).` : `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}`;
40486
+ 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}` : ""}).`;
40415
40487
  return {
40416
40488
  content: [
40417
40489
  { type: "text", text: note },
@@ -40425,8 +40497,9 @@ class GodotServer {
40425
40497
  exitSignal: run.exitSignal ?? undefined,
40426
40498
  hung,
40427
40499
  ...timedOut ? { timedOut, silentForMs } : {},
40500
+ ...scriptErrors.length > 0 ? { scriptErrors } : {},
40428
40501
  arguments: cmdArgs,
40429
- entries: forAnswer(printed.slice(0, 60)),
40502
+ entries: forAnswer(printed.slice(0, 60).map(aboveTheRunner)),
40430
40503
  savesNote,
40431
40504
  ...scanned
40432
40505
  }, null, 2)
@@ -40481,7 +40554,7 @@ class GodotServer {
40481
40554
  engineErrors: run.log.count("error"),
40482
40555
  engineWarnings: run.log.count("warning"),
40483
40556
  engineEntries,
40484
- classes: classes.payload
40557
+ classes: withAddonClassesCounted(classes.payload, cachedClasses(project.value.path) ?? new Map)
40485
40558
  });
40486
40559
  }
40487
40560
  async godotVersion(godotPath) {
@@ -43980,6 +44053,7 @@ import {
43980
44053
  mkdirSync as mkdirSync4,
43981
44054
  readdirSync as readdirSync3,
43982
44055
  readFileSync as readFileSync8,
44056
+ rmdirSync,
43983
44057
  rmSync as rmSync4,
43984
44058
  statSync as statSync3,
43985
44059
  writeFileSync as writeFileSync6
@@ -44062,6 +44136,11 @@ function removeAddons(projectPath) {
44062
44136
  removed.push(target);
44063
44137
  }
44064
44138
  }
44139
+ const addons = join9(projectPath, "addons");
44140
+ if (removed.length > 0 && existsSync7(addons) && readdirSync3(addons).length === 0) {
44141
+ rmdirSync(addons);
44142
+ removed.push(addons);
44143
+ }
44065
44144
  return removed;
44066
44145
  }
44067
44146
  async function disablePlugins(engine, projectPath, names) {
@@ -44607,7 +44686,7 @@ async function registerRuntime(godot, projectPath) {
44607
44686
  }
44608
44687
  if (autoloadIsOurs(named)) {
44609
44688
  said2(await setRuntime(godot, projectPath, true), "registering the runtime autoload");
44610
- console.log(`${RUNTIME_AUTOLOAD.name} autoload registered at res://${RUNTIME_AUTOLOAD.path}`);
44689
+ console.log(`${RUNTIME_AUTOLOAD.name} autoload ${named === null ? "registered" : "already registered"} at res://${RUNTIME_AUTOLOAD.path}`);
44611
44690
  return;
44612
44691
  }
44613
44692
  console.log(`${RUNTIME_AUTOLOAD.name} autoload left at ${named}, which this project registers itself`);
@@ -149,12 +149,13 @@ func add_autoload(params: Dictionary) -> Dictionary:
149
149
  if not FileAccess.file_exists(path):
150
150
  return _log.failure("Autoload file does not exist: " + path)
151
151
 
152
- # Through ProjectSettings so the file is saved the way the editor saves it, header and
153
- # every other line kept; a ConfigFile of project.godot drops the comments on the way out.
152
+ # Through ProjectSettings so the file is saved the way the editor saves it, which includes
153
+ # putting Godot's header back and dropping any comment somebody wrote into the file by hand.
154
154
  var setting: String = "autoload/" + name
155
155
  var was_updated: bool = ProjectSettings.has_setting(setting)
156
156
 
157
- # An asterisk in front of the path is how project.godot marks an autoload as enabled.
157
+ # An asterisk in front of the path makes the autoload a global name as well. Without it the
158
+ # node still comes up under the root; only the name stops resolving.
158
159
  ProjectSettings.set_setting(setting, ("*" if enabled else "") + path)
159
160
  var err: Error = ProjectSettings.save()
160
161
  if err != OK:
package/build/index.js CHANGED
@@ -10198,7 +10198,7 @@ import {
10198
10198
  readdirSync as readdirSync7,
10199
10199
  readFileSync as readFileSync17,
10200
10200
  readSync as readSync2,
10201
- rmdirSync,
10201
+ rmdirSync as rmdirSync2,
10202
10202
  statSync as statSync8
10203
10203
  } from "node:fs";
10204
10204
  import { tmpdir as tmpdir4 } from "node:os";
@@ -25847,6 +25847,30 @@ function cachedClasses(projectPath) {
25847
25847
  }
25848
25848
  return listed;
25849
25849
  }
25850
+ function withAddonClassesCounted(rebuilt, listed) {
25851
+ const counted = { ...rebuilt };
25852
+ for (const key of ["added", "changed"]) {
25853
+ const names = rebuilt[key];
25854
+ if (!Array.isArray(names)) {
25855
+ continue;
25856
+ }
25857
+ const own = [];
25858
+ const perAddon = {};
25859
+ for (const name of names) {
25860
+ const addon = /^res:\/\/addons\/([^/]+)\//.exec(listed.get(String(name)) ?? "")?.[1];
25861
+ if (addon === undefined) {
25862
+ own.push(name);
25863
+ } else {
25864
+ perAddon[addon] = (perAddon[addon] ?? 0) + 1;
25865
+ }
25866
+ }
25867
+ if (Object.keys(perAddon).length > 0) {
25868
+ counted[key] = own;
25869
+ counted[`${key}InAddons`] = perAddon;
25870
+ }
25871
+ }
25872
+ return counted;
25873
+ }
25850
25874
  function missingMemberIn(message) {
25851
25875
  const found = /The (method|property) "([^"(]+)(?:\(\))?" is not present on the inferred type "([^"]+)"/.exec(message);
25852
25876
  if (found === null) {
@@ -28255,9 +28279,32 @@ function whyNoReport(printed, asked) {
28255
28279
  }
28256
28280
  return printed.some((line) => NOTHING_RAN.test(line)) ? `no test cases found at ${asked}` : null;
28257
28281
  }
28282
+ var DISCOVERY_FAILED = "Script errors were detected during test discovery!";
28283
+ function scriptErrorsPrinted(printed) {
28284
+ const start = printed.findIndex((line) => line.includes(DISCOVERY_FAILED));
28285
+ if (start < 0) {
28286
+ return [];
28287
+ }
28288
+ const found = [];
28289
+ let message = null;
28290
+ for (const line of printed.slice(start + 1)) {
28291
+ const at = /^\s*at (res:\/\/.+):(\d+)\s*$/.exec(line);
28292
+ if (at !== null && message !== null) {
28293
+ found.push({ path: at[1] ?? "", line: Number(at[2]), message });
28294
+ message = null;
28295
+ } else if (at === null && message === null && /^ {2}\S/.test(line)) {
28296
+ message = line.trim();
28297
+ } else {
28298
+ break;
28299
+ }
28300
+ }
28301
+ return found.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : a.line - b.line);
28302
+ }
28258
28303
  var ESCAPE = String.fromCharCode(27);
28259
28304
  var LOCATION = `'[^'\\n]*' in \\S+:\\d+`;
28260
- var PRINTED_ACTUAL = new RegExp(` but was\\n '((?:(?!\\tat ${LOCATION})[\\s\\S])*?)'((?:${ESCAPE}\\[[0-9;]*m)*)\\tat (${LOCATION})`, "g");
28305
+ var IGNORING_CASE = " \\(ignoring case\\)";
28306
+ var PRINTED_ACTUAL = new RegExp(` but was\\n '((?:(?!\\tat ${LOCATION})[\\s\\S])*?)'((?:${ESCAPE}\\[[0-9;]*m)*)(${IGNORING_CASE})?((?:${ESCAPE}\\[[0-9;]*m)*)\\tat (${LOCATION})`, "g");
28307
+ var QUOTED_EQUALITY = new RegExp(`^Expecting(?: error message)?:\\n '([\\s\\S]*)'\\n but was\\n '([\\s\\S]*)'(?:${IGNORING_CASE})?\\n\\tat `);
28261
28308
  var COLOUR = new RegExp(`${ESCAPE}\\[([0-9;]*)m`, "g");
28262
28309
  var SPELLED = {
28263
28310
  "<LF>": `
@@ -28302,11 +28349,13 @@ function readDiff(printed) {
28302
28349
  }
28303
28350
  var BOTH_SIDES_ALIKE_NOTE = "[gdharness: the expected and the actual printed alike because gdUnit4 drops the marks that tell them apart when it writes its report, and the runner printed no copy of this one, so neither side above is the value the assertion found.]";
28304
28351
  var VALUE_NOT_RECOVERED_NOTE = '[gdharness: the value after "but was" may not be the one the assertion found. gdUnit4 writes a failing string into its report as a character diff against the expected one with the marks dropped, which merges the two, and the runner printed no copy of this one to read the value back from.]';
28352
+ var RENDERED_AWAY_NOTE = '[gdharness: the two sides print alike, so what tells them apart is something gdUnit4 does not print. The usual cause is BBCode in the expected string, which gdUnit4 renders as formatting instead of showing; the value after "but was" is the one the assertion found.]';
28305
28353
  function withActualsPrinted(failed, printed) {
28306
28354
  const diffs = [...printed.replaceAll(`\r
28307
28355
  `, `
28308
28356
  `).matchAll(PRINTED_ACTUAL)].map((match) => ({
28309
- at: match[3] ?? "",
28357
+ suffix: match[3] ?? "",
28358
+ at: match[5] ?? "",
28310
28359
  ...readDiff(match[1] ?? "")
28311
28360
  }));
28312
28361
  return failed.map((entry) => {
@@ -28317,23 +28366,36 @@ function withActualsPrinted(failed, printed) {
28317
28366
  let recovered = false;
28318
28367
  for (const diff of diffs) {
28319
28368
  const wrong = ` but was
28320
- '${diff.merged.replaceAll("\r", "")}'
28369
+ '${diff.merged.replaceAll("\r", "")}'${diff.suffix}
28321
28370
  at ${diff.at}`;
28322
28371
  if (detail.includes(wrong)) {
28323
28372
  detail = detail.replace(wrong, () => ` but was
28324
- '${diff.actual}'
28373
+ '${diff.actual}'${diff.suffix}
28325
28374
  at ${diff.at}`);
28326
28375
  recovered = true;
28327
28376
  }
28328
28377
  }
28329
- if (!recovered && /Expecting:\n '[\s\S]*'\n but was\n '[\s\S]*'\n\tat /.test(detail)) {
28330
- const alike = /Expecting:\n '([\s\S]*)'\n but was\n '\1'\n\tat /.test(detail);
28331
- detail = `${detail}
28332
- ${alike ? BOTH_SIDES_ALIKE_NOTE : VALUE_NOT_RECOVERED_NOTE}`;
28378
+ const quoted = QUOTED_EQUALITY.exec(detail);
28379
+ if (quoted !== null) {
28380
+ const alike = quoted[1] === quoted[2];
28381
+ const note = recovered ? alike ? RENDERED_AWAY_NOTE : null : alike ? BOTH_SIDES_ALIKE_NOTE : VALUE_NOT_RECOVERED_NOTE;
28382
+ detail = note === null ? detail : `${detail}
28383
+ ${note}`;
28384
+ }
28385
+ if (quoted !== null && masked(quoted[1] ?? "", quoted[2] ?? "")) {
28386
+ const end = quoted[0].length - `
28387
+ at `.length;
28388
+ detail = detail.slice(0, end).replaceAll(MASKED_BRACKET, "[") + detail.slice(end);
28333
28389
  }
28334
28390
  return detail === entry.detail ? entry : { ...entry, detail };
28335
28391
  });
28336
28392
  }
28393
+ var MASKED_BRACKET = "[lb]";
28394
+ function masked(expected, actual) {
28395
+ const allSpelled = (value) => !PRINTED_DICTIONARY.test(value) && value.split("[").length === value.split(MASKED_BRACKET).length;
28396
+ return `${expected}${actual}`.includes(MASKED_BRACKET) && allSpelled(expected) && allSpelled(actual);
28397
+ }
28398
+ var PRINTED_DICTIONARY = /^\{\n\t[\s\S]*\n {2}\}$/;
28337
28399
  var RUNNING_SUITE = /Run Test Suite:\s*(\S+)/;
28338
28400
  var ORPHANS = /(\d+)\s+orphans/;
28339
28401
  var OVERALL = "Overall Summary";
@@ -29781,6 +29843,7 @@ import {
29781
29843
  mkdirSync as mkdirSync9,
29782
29844
  readdirSync as readdirSync6,
29783
29845
  readFileSync as readFileSync16,
29846
+ rmdirSync,
29784
29847
  rmSync as rmSync8,
29785
29848
  statSync as statSync7,
29786
29849
  writeFileSync as writeFileSync11
@@ -30199,7 +30262,7 @@ var TOOL_SPECS = [
30199
30262
  },
30200
30263
  {
30201
30264
  name: "project_test",
30202
- 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, and the rebuild's answer is under classes: a class it puts back that the cache had after the last rebuild is named under classes.lostSinceLastRebuild, which is the editor writing its shorter list over the file on every save until restarted. 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. On macOS the engine reads user:// off HOME and the run writes where the player keeps theirs, which the answer says under savesNote rather than leaving to be found in a save list. 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. A run where nothing failed but nodes were left in the tree comes back under warnings, with the count per suite: gdUnit4 decides its verdict on those and keeps them out of its report, so they are read off what it printed. Needs gdUnit4 under addons/gdUnit4.",
30265
+ 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, and the rebuild's answer is under classes, with the classes each addon declares counted under addedInAddons and changedInAddons rather than named: a class it puts back that the cache had after the last rebuild is named under classes.lostSinceLastRebuild, which is the editor writing its shorter list over the file on every save until restarted. 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. On macOS the engine reads user:// off HOME and the run writes where the player keeps theirs, which the answer says under savesNote rather than leaving to be found in a save list. 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. A run where nothing failed but nodes were left in the tree comes back under warnings, with the count per suite: gdUnit4 decides its verdict on those and keeps them out of its report, so they are read off what it printed. Needs gdUnit4 under addons/gdUnit4.",
30203
30266
  parameters: {
30204
30267
  projectPath: PROJECT_PATH,
30205
30268
  path: {
@@ -30218,7 +30281,7 @@ var TOOL_SPECS = [
30218
30281
  timeoutMs: {
30219
30282
  type: "integer",
30220
30283
  minimum: 1,
30221
- description: "How long the run may take before it is killed. Default 600000."
30284
+ 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."
30222
30285
  }
30223
30286
  },
30224
30287
  requires: ["projectPath"]
@@ -31543,6 +31606,17 @@ function changedSettings(before, after) {
31543
31606
  return [...names].filter((name) => JSON.stringify(before.get(name)) !== JSON.stringify(after.get(name))).sort();
31544
31607
  }
31545
31608
  var STALLED_AFTER_MS = 30000;
31609
+ function scriptErrorsNote(errors) {
31610
+ const first = new Map;
31611
+ for (const error of errors) {
31612
+ if (!first.has(error.path)) {
31613
+ first.set(error.path, error);
31614
+ }
31615
+ }
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.` : ""}`;
31619
+ }
31546
31620
  function timedOutVerdict(timeoutMs, silentForMs) {
31547
31621
  const quiet = Math.round(silentForMs);
31548
31622
  return quiet >= Math.min(STALLED_AFTER_MS, timeoutMs / 2) ? {
@@ -33136,7 +33210,7 @@ class GodotServer {
33136
33210
  } finally {
33137
33211
  discard(reportsDir, userData);
33138
33212
  try {
33139
- rmdirSync(dirname11(reportsDir));
33213
+ rmdirSync2(dirname11(reportsDir));
33140
33214
  } catch {}
33141
33215
  }
33142
33216
  const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries.map(aboveTheRunner);
@@ -33154,10 +33228,12 @@ class GodotServer {
33154
33228
  const nothingRan = timedOut ? null : whyNoReport(said, asked);
33155
33229
  const cutShort = timedOut ? timedOutVerdict(timeoutMs, silentForMs) : null;
33156
33230
  const hung = cutShort?.hung ?? false;
33157
- const verdict = cutShort !== null ? cutShort.verdict : nothingRan ?? (exitCode === null ? undefined : verdicts[exitCode]) ?? (run.exitSignal === null ? `exit ${exitCode ?? "unknown"}` : howItExited(run));
33231
+ const scriptErrors = timedOut || exitCode !== 105 ? [] : scriptErrorsPrinted(said);
33232
+ const brokenScripts = new Set(scriptErrors.map((error) => error.path)).size;
33233
+ 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));
33158
33234
  const savesNote = savesStayPut() && nothingRan === null ? SAVES_NOT_MOVED_NOTE : undefined;
33159
33235
  if (report === null) {
33160
- const note = nothingRan === null ? `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).` : `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}`;
33236
+ 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}` : ""}).`;
33161
33237
  return {
33162
33238
  content: [
33163
33239
  { type: "text", text: note },
@@ -33171,8 +33247,9 @@ class GodotServer {
33171
33247
  exitSignal: run.exitSignal ?? undefined,
33172
33248
  hung,
33173
33249
  ...timedOut ? { timedOut, silentForMs } : {},
33250
+ ...scriptErrors.length > 0 ? { scriptErrors } : {},
33174
33251
  arguments: cmdArgs,
33175
- entries: forAnswer(printed.slice(0, 60)),
33252
+ entries: forAnswer(printed.slice(0, 60).map(aboveTheRunner)),
33176
33253
  savesNote,
33177
33254
  ...scanned
33178
33255
  }, null, 2)
@@ -33227,7 +33304,7 @@ class GodotServer {
33227
33304
  engineErrors: run.log.count("error"),
33228
33305
  engineWarnings: run.log.count("warning"),
33229
33306
  engineEntries,
33230
- classes: classes.payload
33307
+ classes: withAddonClassesCounted(classes.payload, cachedClasses(project.value.path) ?? new Map)
33231
33308
  });
33232
33309
  }
33233
33310
  async godotVersion(godotPath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.1.7",
3
+ "version": "1.1.9",
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",