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 +97 -18
- package/build/godot/operations/project_config.gd +4 -3
- package/build/index.js +93 -16
- 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) {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
37171
|
-
|
|
37172
|
-
|
|
37173
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
153
|
-
#
|
|
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
|
|
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
|
|
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";
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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",
|