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 +87 -15
- package/build/index.js +90 -14
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
37171
|
-
|
|
37172
|
-
|
|
37173
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
28330
|
-
|
|
28331
|
-
|
|
28332
|
-
|
|
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
|
|
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
|
|
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.
|
|
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",
|