gdharness 1.1.7 → 1.1.8

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) {
@@ -16044,7 +16068,7 @@ var init_tool_definitions = __esm(() => {
16044
16068
  },
16045
16069
  {
16046
16070
  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.",
16071
+ 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
16072
  parameters: {
16049
16073
  projectPath: PROJECT_PATH,
16050
16074
  path: {
@@ -16063,7 +16087,7 @@ var init_tool_definitions = __esm(() => {
16063
16087
  timeoutMs: {
16064
16088
  type: "integer",
16065
16089
  minimum: 1,
16066
- description: "How long the run may take before it is killed. Default 600000."
16090
+ 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
16091
  }
16068
16092
  },
16069
16093
  requires: ["projectPath"]
@@ -37114,6 +37138,26 @@ function whyNoReport(printed, asked) {
37114
37138
  }
37115
37139
  return printed.some((line) => NOTHING_RAN.test(line)) ? `no test cases found at ${asked}` : null;
37116
37140
  }
37141
+ function scriptErrorsPrinted(printed) {
37142
+ const start = printed.findIndex((line) => line.includes(DISCOVERY_FAILED));
37143
+ if (start < 0) {
37144
+ return [];
37145
+ }
37146
+ const found = [];
37147
+ let message = null;
37148
+ for (const line of printed.slice(start + 1)) {
37149
+ const at = /^\s*at (res:\/\/.+):(\d+)\s*$/.exec(line);
37150
+ if (at !== null && message !== null) {
37151
+ found.push({ path: at[1] ?? "", line: Number(at[2]), message });
37152
+ message = null;
37153
+ } else if (at === null && message === null && /^ {2}\S/.test(line)) {
37154
+ message = line.trim();
37155
+ } else {
37156
+ break;
37157
+ }
37158
+ }
37159
+ return found.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : a.line - b.line);
37160
+ }
37117
37161
  function unspelled(text) {
37118
37162
  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
37163
  }
@@ -37147,7 +37191,8 @@ function withActualsPrinted(failed, printed) {
37147
37191
  const diffs = [...printed.replaceAll(`\r
37148
37192
  `, `
37149
37193
  `).matchAll(PRINTED_ACTUAL)].map((match) => ({
37150
- at: match[3] ?? "",
37194
+ suffix: match[3] ?? "",
37195
+ at: match[5] ?? "",
37151
37196
  ...readDiff(match[1] ?? "")
37152
37197
  }));
37153
37198
  return failed.map((entry) => {
@@ -37158,23 +37203,34 @@ function withActualsPrinted(failed, printed) {
37158
37203
  let recovered = false;
37159
37204
  for (const diff of diffs) {
37160
37205
  const wrong = ` but was
37161
- '${diff.merged.replaceAll("\r", "")}'
37206
+ '${diff.merged.replaceAll("\r", "")}'${diff.suffix}
37162
37207
  at ${diff.at}`;
37163
37208
  if (detail.includes(wrong)) {
37164
37209
  detail = detail.replace(wrong, () => ` but was
37165
- '${diff.actual}'
37210
+ '${diff.actual}'${diff.suffix}
37166
37211
  at ${diff.at}`);
37167
37212
  recovered = true;
37168
37213
  }
37169
37214
  }
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}`;
37215
+ const quoted = QUOTED_EQUALITY.exec(detail);
37216
+ if (quoted !== null) {
37217
+ const alike = quoted[1] === quoted[2];
37218
+ const note = recovered ? alike ? RENDERED_AWAY_NOTE : null : alike ? BOTH_SIDES_ALIKE_NOTE : VALUE_NOT_RECOVERED_NOTE;
37219
+ detail = note === null ? detail : `${detail}
37220
+ ${note}`;
37221
+ }
37222
+ if (quoted !== null && masked(quoted[1] ?? "", quoted[2] ?? "")) {
37223
+ const end = quoted[0].length - `
37224
+ at `.length;
37225
+ detail = detail.slice(0, end).replaceAll(MASKED_BRACKET, "[") + detail.slice(end);
37174
37226
  }
37175
37227
  return detail === entry.detail ? entry : { ...entry, detail };
37176
37228
  });
37177
37229
  }
37230
+ function masked(expected, actual) {
37231
+ const allSpelled = (value) => !PRINTED_DICTIONARY.test(value) && value.split("[").length === value.split(MASKED_BRACKET).length;
37232
+ return `${expected}${actual}`.includes(MASKED_BRACKET) && allSpelled(expected) && allSpelled(actual);
37233
+ }
37178
37234
  function orphansPrinted(printed) {
37179
37235
  const suites = [];
37180
37236
  let total = null;
@@ -37205,7 +37261,7 @@ function orphansPrinted(printed) {
37205
37261
  suites
37206
37262
  };
37207
37263
  }
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";
37264
+ 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
37265
  var init_junit = __esm(() => {
37210
37266
  MalformedReportError = class MalformedReportError extends Error {
37211
37267
  constructor(message) {
@@ -37218,7 +37274,8 @@ var init_junit = __esm(() => {
37218
37274
  NOTHING_RAN = /no test cases found/i;
37219
37275
  NO_SUCH_PATH = /given directory or file does not exists:\s*(\S+)/i;
37220
37276
  ESCAPE = String.fromCharCode(27);
37221
- PRINTED_ACTUAL = new RegExp(` but was\\n '((?:(?!\\tat ${LOCATION})[\\s\\S])*?)'((?:${ESCAPE}\\[[0-9;]*m)*)\\tat (${LOCATION})`, "g");
37277
+ PRINTED_ACTUAL = new RegExp(` but was\\n '((?:(?!\\tat ${LOCATION})[\\s\\S])*?)'((?:${ESCAPE}\\[[0-9;]*m)*)(${IGNORING_CASE})?((?:${ESCAPE}\\[[0-9;]*m)*)\\tat (${LOCATION})`, "g");
37278
+ QUOTED_EQUALITY = new RegExp(`^Expecting(?: error message)?:\\n '([\\s\\S]*)'\\n but was\\n '([\\s\\S]*)'(?:${IGNORING_CASE})?\\n\\tat `);
37222
37279
  COLOUR2 = new RegExp(`${ESCAPE}\\[([0-9;]*)m`, "g");
37223
37280
  SPELLED = {
37224
37281
  "<LF>": `
@@ -37232,6 +37289,7 @@ var init_junit = __esm(() => {
37232
37289
  "<ESC>": ESCAPE,
37233
37290
  "<DEL>": ""
37234
37291
  };
37292
+ PRINTED_DICTIONARY = /^\{\n\t[\s\S]*\n {2}\}$/;
37235
37293
  RUNNING_SUITE = /Run Test Suite:\s*(\S+)/;
37236
37294
  ORPHANS = /(\d+)\s+orphans/;
37237
37295
  });
@@ -38828,6 +38886,17 @@ function changedSettings(before, after) {
38828
38886
  const names = new Set([...before.keys(), ...after.keys()]);
38829
38887
  return [...names].filter((name) => JSON.stringify(before.get(name)) !== JSON.stringify(after.get(name))).sort();
38830
38888
  }
38889
+ function scriptErrorsNote(errors) {
38890
+ const first = new Map;
38891
+ for (const error of errors) {
38892
+ if (!first.has(error.path)) {
38893
+ first.set(error.path, error);
38894
+ }
38895
+ }
38896
+ const scripts = [...first.values()].map((error) => `${error.path}:${error.line} ${error.message}${error.message.endsWith(".") ? "" : "."}`).join(" ");
38897
+ const more = errors.length - first.size;
38898
+ 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.` : ""}`;
38899
+ }
38831
38900
  function timedOutVerdict(timeoutMs, silentForMs) {
38832
38901
  const quiet = Math.round(silentForMs);
38833
38902
  return quiet >= Math.min(STALLED_AFTER_MS, timeoutMs / 2) ? {
@@ -40408,10 +40477,12 @@ class GodotServer {
40408
40477
  const nothingRan = timedOut ? null : whyNoReport(said, asked);
40409
40478
  const cutShort = timedOut ? timedOutVerdict(timeoutMs, silentForMs) : null;
40410
40479
  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));
40480
+ const scriptErrors = timedOut || exitCode !== 105 ? [] : scriptErrorsPrinted(said);
40481
+ const brokenScripts = new Set(scriptErrors.map((error) => error.path)).size;
40482
+ 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
40483
  const savesNote = savesStayPut() && nothingRan === null ? SAVES_NOT_MOVED_NOTE : undefined;
40413
40484
  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)}`;
40485
+ 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
40486
  return {
40416
40487
  content: [
40417
40488
  { type: "text", text: note },
@@ -40425,8 +40496,9 @@ class GodotServer {
40425
40496
  exitSignal: run.exitSignal ?? undefined,
40426
40497
  hung,
40427
40498
  ...timedOut ? { timedOut, silentForMs } : {},
40499
+ ...scriptErrors.length > 0 ? { scriptErrors } : {},
40428
40500
  arguments: cmdArgs,
40429
- entries: forAnswer(printed.slice(0, 60)),
40501
+ entries: forAnswer(printed.slice(0, 60).map(aboveTheRunner)),
40430
40502
  savesNote,
40431
40503
  ...scanned
40432
40504
  }, null, 2)
@@ -40481,7 +40553,7 @@ class GodotServer {
40481
40553
  engineErrors: run.log.count("error"),
40482
40554
  engineWarnings: run.log.count("warning"),
40483
40555
  engineEntries,
40484
- classes: classes.payload
40556
+ classes: withAddonClassesCounted(classes.payload, cachedClasses(project.value.path) ?? new Map)
40485
40557
  });
40486
40558
  }
40487
40559
  async godotVersion(godotPath) {
package/build/index.js CHANGED
@@ -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";
@@ -30199,7 +30261,7 @@ var TOOL_SPECS = [
30199
30261
  },
30200
30262
  {
30201
30263
  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.",
30264
+ 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
30265
  parameters: {
30204
30266
  projectPath: PROJECT_PATH,
30205
30267
  path: {
@@ -30218,7 +30280,7 @@ var TOOL_SPECS = [
30218
30280
  timeoutMs: {
30219
30281
  type: "integer",
30220
30282
  minimum: 1,
30221
- description: "How long the run may take before it is killed. Default 600000."
30283
+ 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
30284
  }
30223
30285
  },
30224
30286
  requires: ["projectPath"]
@@ -31543,6 +31605,17 @@ function changedSettings(before, after) {
31543
31605
  return [...names].filter((name) => JSON.stringify(before.get(name)) !== JSON.stringify(after.get(name))).sort();
31544
31606
  }
31545
31607
  var STALLED_AFTER_MS = 30000;
31608
+ function scriptErrorsNote(errors) {
31609
+ const first = new Map;
31610
+ for (const error of errors) {
31611
+ if (!first.has(error.path)) {
31612
+ first.set(error.path, error);
31613
+ }
31614
+ }
31615
+ const scripts = [...first.values()].map((error) => `${error.path}:${error.line} ${error.message}${error.message.endsWith(".") ? "" : "."}`).join(" ");
31616
+ const more = errors.length - first.size;
31617
+ 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.` : ""}`;
31618
+ }
31546
31619
  function timedOutVerdict(timeoutMs, silentForMs) {
31547
31620
  const quiet = Math.round(silentForMs);
31548
31621
  return quiet >= Math.min(STALLED_AFTER_MS, timeoutMs / 2) ? {
@@ -33154,10 +33227,12 @@ class GodotServer {
33154
33227
  const nothingRan = timedOut ? null : whyNoReport(said, asked);
33155
33228
  const cutShort = timedOut ? timedOutVerdict(timeoutMs, silentForMs) : null;
33156
33229
  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));
33230
+ const scriptErrors = timedOut || exitCode !== 105 ? [] : scriptErrorsPrinted(said);
33231
+ const brokenScripts = new Set(scriptErrors.map((error) => error.path)).size;
33232
+ 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
33233
  const savesNote = savesStayPut() && nothingRan === null ? SAVES_NOT_MOVED_NOTE : undefined;
33159
33234
  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)}`;
33235
+ 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
33236
  return {
33162
33237
  content: [
33163
33238
  { type: "text", text: note },
@@ -33171,8 +33246,9 @@ class GodotServer {
33171
33246
  exitSignal: run.exitSignal ?? undefined,
33172
33247
  hung,
33173
33248
  ...timedOut ? { timedOut, silentForMs } : {},
33249
+ ...scriptErrors.length > 0 ? { scriptErrors } : {},
33174
33250
  arguments: cmdArgs,
33175
- entries: forAnswer(printed.slice(0, 60)),
33251
+ entries: forAnswer(printed.slice(0, 60).map(aboveTheRunner)),
33176
33252
  savesNote,
33177
33253
  ...scanned
33178
33254
  }, null, 2)
@@ -33227,7 +33303,7 @@ class GodotServer {
33227
33303
  engineErrors: run.log.count("error"),
33228
33304
  engineWarnings: run.log.count("warning"),
33229
33305
  engineEntries,
33230
- classes: classes.payload
33306
+ classes: withAddonClassesCounted(classes.payload, cachedClasses(project.value.path) ?? new Map)
33231
33307
  });
33232
33308
  }
33233
33309
  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.8",
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",