gdharness 1.1.46 → 1.1.47
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 +158 -30
- package/build/godot/addons/gdharness_runtime/runtime_menus.gd +1 -1
- package/build/godot/addons/gdharness_runtime/runtime_says.gd +26 -2
- package/build/godot/addons/gdharness_runtime/runtime_targets.gd +100 -15
- package/build/godot/addons/gdharness_runtime/runtime_words.gd +8 -1
- package/build/index.js +160 -38
- package/package.json +1 -1
package/build/cli.js
CHANGED
|
@@ -17588,7 +17588,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
17588
17588
|
},
|
|
17589
17589
|
{
|
|
17590
17590
|
name: "project_test",
|
|
17591
|
-
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 script that does not load stops gdUnit4 before any suite runs: its errors come back in full under scriptErrors, and the scripts that failed only because one they depend on did are listed by path under failedThroughDependency. 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.",
|
|
17591
|
+
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 script that does not load stops gdUnit4 before any suite runs: its errors come back in full under scriptErrors, and the scripts that failed only because one they depend on did are listed by path under failedThroughDependency. When they failed on a class whose own script has a parser error, which gdUnit4 never names, that script is checked by the engine and its error comes first under scriptErrors, with the class it declares under declares. 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.",
|
|
17592
17592
|
parameters: {
|
|
17593
17593
|
projectPath: PROJECT_PATH,
|
|
17594
17594
|
path: {
|
|
@@ -18158,7 +18158,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
18158
18158
|
says: {
|
|
18159
18159
|
type: "string",
|
|
18160
18160
|
ops: ["find"],
|
|
18161
|
-
description: 'find: part of what the node has written on it as drawn, the way the text op reads it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern, and a glob that finds nothing says under note how many nodes it would find open at both ends. A label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Alternatives separated by | match when any one of them does, each by these rules ("*won*|*lost*"); write \\| for a bar that is part of the words.'
|
|
18161
|
+
description: 'find: part of what the node has written on it as drawn, the way the text op reads it, case-insensitively and with a typed space matching any space the game draws (a non-breaking one included), which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern, and a glob that finds nothing says under note how many nodes it would find open at both ends. A label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Alternatives separated by | match when any one of them does, each by these rules ("*won*|*lost*"); write \\| for a bar that is part of the words.'
|
|
18162
18162
|
},
|
|
18163
18163
|
limit: {
|
|
18164
18164
|
type: "integer",
|
|
@@ -18336,7 +18336,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
18336
18336
|
says: {
|
|
18337
18337
|
type: "string",
|
|
18338
18338
|
ops: ["click"],
|
|
18339
|
-
description: "click: the words on the control to click, matched the way runtime_inspect find matches says (a plain word is contained in the text, a pattern with * or ? is a glob over it, and | separates alternatives), among the controls on screen, and pressed in the same frame it was found in, so a panel rebuilding between a find and a click cannot free it first. Words on a label inside a button press the button. A control with something drawn over its centre that would take the pointer, such as a screen shown over it or a dialog stacked above its own, is not on screen; nor is one on a hidden canvas layer, one outside the viewport, or one a container clips away that a click does not scroll. The refusal counts those apart. The matches are ranked the way a person picks what to press: an enabled button before anything else, a disabled one included, then a control whose whole text, or one whole line of it such as the title on a card, is the words before one saying them as part of more, and the best one is pressed; found.picked says why. Several matches at the best rank are refused with each one listed, and index picks one. A match saying the words only as part of more is named under found.partOf, and is refused when
|
|
18339
|
+
description: "click: the words on the control to click, matched the way runtime_inspect find matches says (a plain word is contained in the text, a pattern with * or ? is a glob over it, and | separates alternatives), among the controls on screen, and pressed in the same frame it was found in, so a panel rebuilding between a find and a click cannot free it first. Words on a label inside a button press the button. A control with something drawn over its centre that would take the pointer, such as a screen shown over it or a dialog stacked above its own, is not on screen; nor is one on a hidden canvas layer, one outside the viewport, or one a container clips away that a click does not scroll. The refusal counts those apart. The matches are ranked the way a person picks what to press: an enabled button before anything else, a disabled one included, then a control whose whole text, or one whole line of it such as the title on a card, is the words before one saying them as part of more, and the best one is pressed; found.picked says why. Several matches at the best rank are refused with each one listed, and index picks one. A match saying the words only as part of more is named under found.partOf, and is refused when another control says exactly the words (hidden, off the screen, covered, or on screen taking clicks without being a button), since it would stand in for that one, and when it lets the pointer through to what is under it; index presses it anyway. Words found only inside longer words (\"out\" in \"Scout\") rank after every other match and are refused unless index is given; words starting a longer word (\"Recruit\" in \"Recruits\") count. An empty field says its placeholder, and a control with no text of its own, a picture on a button, says its accessibility name. An item of an open menu is drawn by the menu rather than held as a control, so a click finds none, and the refusal names the menu and the choose call that picks it."
|
|
18340
18340
|
},
|
|
18341
18341
|
action: {
|
|
18342
18342
|
type: "string",
|
|
@@ -38708,12 +38708,16 @@ function whyNoReport(printed, asked) {
|
|
|
38708
38708
|
return printed.some((line) => NOTHING_RAN.test(line)) ? `no test cases found at ${asked}` : null;
|
|
38709
38709
|
}
|
|
38710
38710
|
function scriptErrorsPrinted(printed) {
|
|
38711
|
+
return discoveryListing(printed)?.errors ?? [];
|
|
38712
|
+
}
|
|
38713
|
+
function discoveryListing(printed) {
|
|
38711
38714
|
const start = printed.findIndex((line) => line.includes(DISCOVERY_FAILED));
|
|
38712
38715
|
if (start < 0) {
|
|
38713
|
-
return
|
|
38716
|
+
return null;
|
|
38714
38717
|
}
|
|
38715
38718
|
const found = [];
|
|
38716
38719
|
let message = null;
|
|
38720
|
+
let to = start + 1;
|
|
38717
38721
|
for (const line of printed.slice(start + 1)) {
|
|
38718
38722
|
const at = /^\s*at (res:\/\/.+):(\d+)\s*$/.exec(line);
|
|
38719
38723
|
if (at !== null && message !== null) {
|
|
@@ -38724,8 +38728,10 @@ function scriptErrorsPrinted(printed) {
|
|
|
38724
38728
|
} else {
|
|
38725
38729
|
break;
|
|
38726
38730
|
}
|
|
38731
|
+
to += 1;
|
|
38727
38732
|
}
|
|
38728
|
-
|
|
38733
|
+
const errors = found.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : (a.line ?? 0) - (b.line ?? 0));
|
|
38734
|
+
return { errors, from: start, to };
|
|
38729
38735
|
}
|
|
38730
38736
|
function unspelled(text) {
|
|
38731
38737
|
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);
|
|
@@ -41562,6 +41568,104 @@ var init_run_record = __esm(() => {
|
|
|
41562
41568
|
execFileAsync = promisify4(execFile4);
|
|
41563
41569
|
});
|
|
41564
41570
|
|
|
41571
|
+
// src/script-origins.ts
|
|
41572
|
+
import { join as join24 } from "node:path";
|
|
41573
|
+
function unresolvedClassIn(message) {
|
|
41574
|
+
return UNRESOLVED.exec(message)?.[1] ?? null;
|
|
41575
|
+
}
|
|
41576
|
+
async function originsOf(errors, declared, check) {
|
|
41577
|
+
const listed = new Set(errors.map((error) => error.path));
|
|
41578
|
+
const origins = [];
|
|
41579
|
+
const unconfirmed = [];
|
|
41580
|
+
const asked = new Set;
|
|
41581
|
+
const waiting = errors.map((error) => unresolvedClassIn(error.message)).filter((name) => name !== null);
|
|
41582
|
+
while (waiting.length > 0 && asked.size < MOST_CHECKED) {
|
|
41583
|
+
const name = waiting.shift() ?? "";
|
|
41584
|
+
const path = declared.get(name);
|
|
41585
|
+
if (path === undefined || listed.has(path) || asked.has(path)) {
|
|
41586
|
+
continue;
|
|
41587
|
+
}
|
|
41588
|
+
asked.add(path);
|
|
41589
|
+
const checked = await check(path);
|
|
41590
|
+
const own = checked.errors.filter((error) => error.path === path);
|
|
41591
|
+
if (own.length === 0) {
|
|
41592
|
+
unconfirmed.push({
|
|
41593
|
+
path,
|
|
41594
|
+
declares: name,
|
|
41595
|
+
why: checked.failure === null ? "the engine's check of it found no error" : `the engine's check of it did not finish (${checked.failure})`
|
|
41596
|
+
});
|
|
41597
|
+
continue;
|
|
41598
|
+
}
|
|
41599
|
+
for (const error of own) {
|
|
41600
|
+
origins.push({ ...error, declares: name });
|
|
41601
|
+
const deeper = unresolvedClassIn(error.message);
|
|
41602
|
+
if (deeper !== null) {
|
|
41603
|
+
waiting.push(deeper);
|
|
41604
|
+
}
|
|
41605
|
+
}
|
|
41606
|
+
}
|
|
41607
|
+
return { origins, unconfirmed };
|
|
41608
|
+
}
|
|
41609
|
+
function reloadedAt(entry) {
|
|
41610
|
+
for (const line of entry.detail) {
|
|
41611
|
+
const at = RELOADED_AT.exec(line.trim());
|
|
41612
|
+
if (at !== null) {
|
|
41613
|
+
return { path: at[1] ?? "", line: Number(at[2]) === 0 ? null : Number(at[2]) };
|
|
41614
|
+
}
|
|
41615
|
+
}
|
|
41616
|
+
return null;
|
|
41617
|
+
}
|
|
41618
|
+
function checkWithEngine(godotPath, projectPath) {
|
|
41619
|
+
return async (path) => {
|
|
41620
|
+
const logDir = scratchDirectory("tests");
|
|
41621
|
+
try {
|
|
41622
|
+
const ran = await runEngine(godotPath, [
|
|
41623
|
+
"--headless",
|
|
41624
|
+
"--log-file",
|
|
41625
|
+
join24(logDir, "engine.log"),
|
|
41626
|
+
"--path",
|
|
41627
|
+
projectPath,
|
|
41628
|
+
"--check-only",
|
|
41629
|
+
"-s",
|
|
41630
|
+
path
|
|
41631
|
+
], { timeout: { ms: 60000, said: "minute" } });
|
|
41632
|
+
const errors = ran.log.select({ severity: "error", sinceLastCall: false, limit: 50 }).entries.flatMap((entry) => {
|
|
41633
|
+
const at = reloadedAt(entry);
|
|
41634
|
+
return at === null ? [] : [{ ...at, message: entry.text }];
|
|
41635
|
+
});
|
|
41636
|
+
return { errors, failure: ran.failure === null ? null : howItEnded(ran) };
|
|
41637
|
+
} finally {
|
|
41638
|
+
discard(logDir);
|
|
41639
|
+
}
|
|
41640
|
+
};
|
|
41641
|
+
}
|
|
41642
|
+
function withoutListed(entries, errors) {
|
|
41643
|
+
if (errors.length === 0) {
|
|
41644
|
+
return [...entries];
|
|
41645
|
+
}
|
|
41646
|
+
const listing = discoveryListing(entries.map((entry) => entry.text));
|
|
41647
|
+
const paths = new Set(errors.map((error) => error.path));
|
|
41648
|
+
return entries.filter((entry, at) => {
|
|
41649
|
+
if (listing !== null && at >= listing.from && at < listing.to) {
|
|
41650
|
+
return false;
|
|
41651
|
+
}
|
|
41652
|
+
const reloaded = reloadedAt(entry);
|
|
41653
|
+
if (reloaded !== null && errors.some((error) => error.path === reloaded.path && error.line === reloaded.line && error.message === entry.text)) {
|
|
41654
|
+
return false;
|
|
41655
|
+
}
|
|
41656
|
+
const failedToLoad = /^Failed to load script "(res:\/\/[^"]+)" with error "[^"]+"\.$/.exec(entry.text);
|
|
41657
|
+
return failedToLoad === null || !paths.has(failedToLoad[1] ?? "");
|
|
41658
|
+
});
|
|
41659
|
+
}
|
|
41660
|
+
var UNRESOLVED, MOST_CHECKED = 8, RELOADED_AT;
|
|
41661
|
+
var init_script_origins = __esm(() => {
|
|
41662
|
+
init_engine_run();
|
|
41663
|
+
init_junit();
|
|
41664
|
+
init_scratch();
|
|
41665
|
+
UNRESOLVED = /Could not resolve class "([A-Za-z_][A-Za-z0-9_]*)", because of a parser error/;
|
|
41666
|
+
RELOADED_AT = /GDScript::reload \((res:\/\/.+):(\d+)\)$/;
|
|
41667
|
+
});
|
|
41668
|
+
|
|
41565
41669
|
// src/server.ts
|
|
41566
41670
|
import { execFile as execFile5, spawn as spawn4 } from "node:child_process";
|
|
41567
41671
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
@@ -41580,7 +41684,7 @@ import {
|
|
|
41580
41684
|
rmdirSync as rmdirSync2,
|
|
41581
41685
|
statSync as statSync9
|
|
41582
41686
|
} from "node:fs";
|
|
41583
|
-
import { basename as basename7, dirname as dirname18, extname as extname2, isAbsolute as isAbsolute3, join as
|
|
41687
|
+
import { basename as basename7, dirname as dirname18, extname as extname2, isAbsolute as isAbsolute3, join as join25, normalize as normalize2, relative as relative5 } from "node:path";
|
|
41584
41688
|
import { setTimeout as delay3 } from "node:timers/promises";
|
|
41585
41689
|
import { fileURLToPath as fileURLToPath5 } from "node:url";
|
|
41586
41690
|
import { promisify as promisify5 } from "node:util";
|
|
@@ -41605,7 +41709,7 @@ function captureDestinationRefusal(outputPath, projects) {
|
|
|
41605
41709
|
if (!existsSync19(directory)) {
|
|
41606
41710
|
return `outputPath's directory ${dirname18(outputPath)} does not exist; a capture creates no directories.`;
|
|
41607
41711
|
}
|
|
41608
|
-
const where =
|
|
41712
|
+
const where = join25(directory, basename7(outputPath));
|
|
41609
41713
|
const inside = projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
|
|
41610
41714
|
if (inside !== undefined) {
|
|
41611
41715
|
return `outputPath ${outputPath} is inside the project at ${inside}; a capture is saved outside it, so it never becomes part of the game.`;
|
|
@@ -42093,7 +42197,13 @@ function foldedScriptErrors(errors) {
|
|
|
42093
42197
|
function nonEmpty(lists) {
|
|
42094
42198
|
return Object.fromEntries(Object.entries(lists).filter(([, list]) => list.length > 0));
|
|
42095
42199
|
}
|
|
42096
|
-
function scriptErrorsNote(errors
|
|
42200
|
+
function scriptErrorsNote(errors, {
|
|
42201
|
+
origins,
|
|
42202
|
+
unconfirmed
|
|
42203
|
+
} = {
|
|
42204
|
+
origins: [],
|
|
42205
|
+
unconfirmed: []
|
|
42206
|
+
}) {
|
|
42097
42207
|
const { scriptErrors: own, failedThroughDependency: dependents } = foldedScriptErrors(errors);
|
|
42098
42208
|
const causes = new Map;
|
|
42099
42209
|
for (const error of own) {
|
|
@@ -42102,7 +42212,22 @@ function scriptErrorsNote(errors) {
|
|
|
42102
42212
|
}
|
|
42103
42213
|
}
|
|
42104
42214
|
const scripts = causes.size + dependents.length;
|
|
42105
|
-
const
|
|
42215
|
+
const at = (error) => error.line === null ? error.path : `${error.path}:${error.line}`;
|
|
42216
|
+
const sentence = (text) => `${text}${/[.!?]\)?$/.test(text) ? "" : "."}`;
|
|
42217
|
+
const traced = new Set;
|
|
42218
|
+
const failedOn = (declares) => {
|
|
42219
|
+
const through = [...causes.values()].filter((error) => unresolvedClassIn(error.message) === declares);
|
|
42220
|
+
for (const error of through) {
|
|
42221
|
+
traced.add(error.path);
|
|
42222
|
+
}
|
|
42223
|
+
return through.length === 0 ? "" : ` ${through.map(at).join(", ")} could not resolve ${declares} because of it.`;
|
|
42224
|
+
};
|
|
42225
|
+
const first = [...new Map(origins.map((origin) => [origin.path, origin])).values()].sort((a, b) => Number(unresolvedClassIn(a.message) !== null) - Number(unresolvedClassIn(b.message) !== null));
|
|
42226
|
+
const named = [
|
|
42227
|
+
...first.map((origin) => `${at(origin)} ${sentence(origin.message)} gdUnit4 did not name this script, which declares ${origin.declares}; the engine's own check of it found the error.${failedOn(origin.declares)}`),
|
|
42228
|
+
...unconfirmed.map((origin) => `${origin.path} declares ${origin.declares}, which gdUnit4 did not name, and ${origin.why}.${failedOn(origin.declares)}`),
|
|
42229
|
+
...[...causes.values()].filter((error) => !traced.has(error.path)).map((error) => `${at(error)} ${sentence(error.message)}`)
|
|
42230
|
+
].join(" ");
|
|
42106
42231
|
const following = dependents.length === 0 ? "" : causes.size === 0 ? `${dependents.join(", ")} ${dependents.length === 1 ? "depends" : "depend"} on a script that did not compile, and gdUnit4 named none with an error of its own: script_diagnostics on one of them finds it.` : `${dependents.length === 1 ? "One more" : `${dependents.length} more`} failed only because a script ${dependents.length === 1 ? "it depends" : "they depend"} on did: ${dependents.join(", ")}.`;
|
|
42107
42232
|
const more = own.length - causes.size;
|
|
42108
42233
|
const rest = more > 0 ? `${more === 1 ? "One more error" : `${more} more errors`} in ${causes.size === 1 ? "that script" : "those scripts"} ${more === 1 ? "is" : "are"} under scriptErrors.` : "";
|
|
@@ -42169,7 +42294,7 @@ function aboveTheRunner(entry) {
|
|
|
42169
42294
|
}
|
|
42170
42295
|
function elsewhereIn(projectPath, asked) {
|
|
42171
42296
|
const wanted = asked.replace(/^res:\/\//, "").replace(/\/+$/, "");
|
|
42172
|
-
if (wanted === "" || existsSync19(
|
|
42297
|
+
if (wanted === "" || existsSync19(join25(projectPath, wanted))) {
|
|
42173
42298
|
return "";
|
|
42174
42299
|
}
|
|
42175
42300
|
const root = (wanted.split("/")[0] ?? "").toLowerCase();
|
|
@@ -42221,7 +42346,7 @@ function runtimeAddonAnnouncesEditors(projectPath) {
|
|
|
42221
42346
|
return false;
|
|
42222
42347
|
}
|
|
42223
42348
|
try {
|
|
42224
|
-
return readFileSync21(
|
|
42349
|
+
return readFileSync21(join25(projectPath, RUNTIME_AUTOLOAD.path), "utf8").includes('"editor_pid"');
|
|
42225
42350
|
} catch {
|
|
42226
42351
|
return false;
|
|
42227
42352
|
}
|
|
@@ -42246,7 +42371,7 @@ function needsItsOwnProcess(asked) {
|
|
|
42246
42371
|
class GodotServer {
|
|
42247
42372
|
mcp;
|
|
42248
42373
|
locator = new GodotLocator;
|
|
42249
|
-
operationsScript =
|
|
42374
|
+
operationsScript = join25(__dirname2, "godot", "operations", "godot_operations.gd");
|
|
42250
42375
|
godotBridge;
|
|
42251
42376
|
tools = buildToolDefinitions();
|
|
42252
42377
|
activeProcess = null;
|
|
@@ -42549,7 +42674,7 @@ class GodotServer {
|
|
|
42549
42674
|
return checked.response;
|
|
42550
42675
|
}
|
|
42551
42676
|
const named = args["projectPath"];
|
|
42552
|
-
if (typeof named === "string" && existsSync19(
|
|
42677
|
+
if (typeof named === "string" && existsSync19(join25(named, "project.godot"))) {
|
|
42553
42678
|
this.lastProjectPath = named;
|
|
42554
42679
|
}
|
|
42555
42680
|
this.updates.refresh();
|
|
@@ -43023,7 +43148,7 @@ class GodotServer {
|
|
|
43023
43148
|
if (path === undefined) {
|
|
43024
43149
|
return { ok: false, response: this.createErrorResponse("projectPath is required.") };
|
|
43025
43150
|
}
|
|
43026
|
-
const file =
|
|
43151
|
+
const file = join25(path, "project.godot");
|
|
43027
43152
|
if (!existsSync19(file)) {
|
|
43028
43153
|
return {
|
|
43029
43154
|
ok: false,
|
|
@@ -43702,7 +43827,7 @@ class GodotServer {
|
|
|
43702
43827
|
const exportArgs = [
|
|
43703
43828
|
"--headless",
|
|
43704
43829
|
"--log-file",
|
|
43705
|
-
|
|
43830
|
+
join25(exportLogs, "engine.log"),
|
|
43706
43831
|
"--path",
|
|
43707
43832
|
project.value.path,
|
|
43708
43833
|
debug ? "--export-debug" : "--export-release",
|
|
@@ -43732,7 +43857,7 @@ class GodotServer {
|
|
|
43732
43857
|
if (!paths.ok) {
|
|
43733
43858
|
return this.createErrorResponse(paths.reason, PATH_SOLUTIONS);
|
|
43734
43859
|
}
|
|
43735
|
-
const missing = paths.value.filter((path) => !existsSync19(
|
|
43860
|
+
const missing = paths.value.filter((path) => !existsSync19(join25(project.value.path, path.slice("res://".length))));
|
|
43736
43861
|
const asked = paths.value.filter((path) => !missing.includes(path));
|
|
43737
43862
|
if (asked.length === 0) {
|
|
43738
43863
|
const verdict = `nothing at ${missing.join(" and ")}`;
|
|
@@ -43748,7 +43873,7 @@ class GodotServer {
|
|
|
43748
43873
|
};
|
|
43749
43874
|
}
|
|
43750
43875
|
const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
|
|
43751
|
-
if (!existsSync19(
|
|
43876
|
+
if (!existsSync19(join25(project.value.path, runner))) {
|
|
43752
43877
|
return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
|
|
43753
43878
|
"Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
|
|
43754
43879
|
]);
|
|
@@ -43813,14 +43938,14 @@ class GodotServer {
|
|
|
43813
43938
|
await untilGone([run.process.pid]);
|
|
43814
43939
|
}
|
|
43815
43940
|
await run.settled;
|
|
43816
|
-
const reportsDir =
|
|
43941
|
+
const reportsDir = join25(project.value.path, ".godot", ...ours.split("/"));
|
|
43817
43942
|
let report = null;
|
|
43818
43943
|
let reportProblem = null;
|
|
43819
43944
|
try {
|
|
43820
43945
|
const written = existsSync19(reportsDir) ? readdirSync10(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
|
|
43821
43946
|
const newest = written.at(-1);
|
|
43822
43947
|
if (newest !== undefined) {
|
|
43823
|
-
report = parseJUnit(readFileSync21(
|
|
43948
|
+
report = parseJUnit(readFileSync21(join25(reportsDir, newest, "results.xml"), "utf8"));
|
|
43824
43949
|
}
|
|
43825
43950
|
} catch (error) {
|
|
43826
43951
|
reportProblem = errorMessage(error);
|
|
@@ -43849,7 +43974,9 @@ class GodotServer {
|
|
|
43849
43974
|
const nothingRan = timedOut ? null : whyNoReport(said, asked.join(" and "));
|
|
43850
43975
|
const cutShort = timedOut ? timedOutVerdict(timeoutMs, silentForMs) : null;
|
|
43851
43976
|
const hung = cutShort?.hung ?? false;
|
|
43852
|
-
const
|
|
43977
|
+
const printedErrors = timedOut || exitCode !== 105 ? [] : scriptErrorsPrinted(said);
|
|
43978
|
+
const declaring = printedErrors.some((error) => unresolvedClassIn(error.message) !== null) ? await originsOf(printedErrors, declaredClasses(project.value.path), checkWithEngine(engine.value, project.value.path)) : { origins: [], unconfirmed: [] };
|
|
43979
|
+
const scriptErrors = [...declaring.origins, ...printedErrors];
|
|
43853
43980
|
const brokenScripts = new Set(scriptErrors.map((error) => error.path)).size;
|
|
43854
43981
|
const ranAs = 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));
|
|
43855
43982
|
const nothingAt = missing.join(" and ");
|
|
@@ -43863,7 +43990,7 @@ class GodotServer {
|
|
|
43863
43990
|
...echoed.omitted > 0 ? { printedOmitted: echoed.omitted } : {}
|
|
43864
43991
|
};
|
|
43865
43992
|
if (report === null) {
|
|
43866
|
-
const said = nothingRan !== null ? `No tests ran: ${ranAs}.` : scriptErrors.length > 0 ? scriptErrorsNote(
|
|
43993
|
+
const said = nothingRan !== null ? `No tests ran: ${ranAs}.` : scriptErrors.length > 0 ? scriptErrorsNote(printedErrors, declaring) : reportProblem !== null ? `The test run's report could not be read (${reportProblem}); the engine's own verdict was ${ranAs}.` : `The test run wrote no report (${ranAs}).`;
|
|
43867
43994
|
const note = missingNote === undefined ? said : `${said} ${missingNote}`;
|
|
43868
43995
|
return {
|
|
43869
43996
|
content: [
|
|
@@ -43881,7 +44008,7 @@ class GodotServer {
|
|
|
43881
44008
|
...missing.length > 0 ? { missing } : {},
|
|
43882
44009
|
...nonEmpty(foldedScriptErrors(scriptErrors)),
|
|
43883
44010
|
arguments: cmdArgs,
|
|
43884
|
-
entries: forAnswer(printed.slice(0, 60).map(aboveTheRunner)),
|
|
44011
|
+
entries: forAnswer(withoutListed(printed, scriptErrors).slice(0, 60).map(aboveTheRunner)),
|
|
43885
44012
|
...printedLines,
|
|
43886
44013
|
savesNote,
|
|
43887
44014
|
...scanned
|
|
@@ -44389,7 +44516,7 @@ class GodotServer {
|
|
|
44389
44516
|
return null;
|
|
44390
44517
|
}
|
|
44391
44518
|
try {
|
|
44392
|
-
return settingKeys(readFileSync21(
|
|
44519
|
+
return settingKeys(readFileSync21(join25(projectPath, "project.godot"), "utf8"));
|
|
44393
44520
|
} catch {
|
|
44394
44521
|
return null;
|
|
44395
44522
|
}
|
|
@@ -44711,7 +44838,7 @@ class GodotServer {
|
|
|
44711
44838
|
}
|
|
44712
44839
|
async runtimeUp(projectPath, before, budget, withArgs) {
|
|
44713
44840
|
const budgetMs = budget.ms;
|
|
44714
|
-
if (!existsSync19(
|
|
44841
|
+
if (!existsSync19(join25(projectPath, RUNTIME_AUTOLOAD.path))) {
|
|
44715
44842
|
return runtimeVerdict(null, {
|
|
44716
44843
|
addon: false,
|
|
44717
44844
|
budgetMs,
|
|
@@ -45728,7 +45855,7 @@ class GodotServer {
|
|
|
45728
45855
|
notes.push(endedWithoutACode({
|
|
45729
45856
|
throughEditor: run.throughEditor,
|
|
45730
45857
|
announced: announced !== undefined || run.announcedPid !== undefined,
|
|
45731
|
-
couldAnnounce: run.projectPath !== null && existsSync19(
|
|
45858
|
+
couldAnnounce: run.projectPath !== null && existsSync19(join25(run.projectPath, RUNTIME_AUTOLOAD.path)),
|
|
45732
45859
|
endedHere: typeof run.endedHere === "string",
|
|
45733
45860
|
pickedUp: run.pickedUp === true
|
|
45734
45861
|
}));
|
|
@@ -46577,7 +46704,7 @@ class GodotServer {
|
|
|
46577
46704
|
async ownRunOnItsWay(projectPath, budgetMs) {
|
|
46578
46705
|
const run = await this.currentRun();
|
|
46579
46706
|
const project = projectPath ?? run?.projectPath ?? null;
|
|
46580
|
-
if (run === null || project === null || run.announcedPid !== undefined || run.projectPath !== null && !isSameDirectory(run.projectPath, project) || !existsSync19(
|
|
46707
|
+
if (run === null || project === null || run.announcedPid !== undefined || run.projectPath !== null && !isSameDirectory(run.projectPath, project) || !existsSync19(join25(project, RUNTIME_AUTOLOAD.path)) || !await this.runStillGoing(run)) {
|
|
46581
46708
|
return null;
|
|
46582
46709
|
}
|
|
46583
46710
|
const already = runtimesAnnounced().running;
|
|
@@ -46661,7 +46788,7 @@ class GodotServer {
|
|
|
46661
46788
|
}
|
|
46662
46789
|
}
|
|
46663
46790
|
const screenshotDir = expectsScreenshot ? scratchDirectory("runtime-screenshot") : null;
|
|
46664
|
-
const screenshotPath = screenshotDir ?
|
|
46791
|
+
const screenshotPath = screenshotDir ? join25(screenshotDir, "capture.png") : null;
|
|
46665
46792
|
const staleNote = this.runtimeStaleNote(choice.endpoint);
|
|
46666
46793
|
const stale = staleNote === undefined ? {} : { addonIsStale: true, staleNote };
|
|
46667
46794
|
const refused = (message) => this.createErrorResponse(staleNote === undefined ? message : `${message}
|
|
@@ -46852,6 +46979,7 @@ var init_server3 = __esm(() => {
|
|
|
46852
46979
|
init_run_record();
|
|
46853
46980
|
init_runtime_client();
|
|
46854
46981
|
init_scratch();
|
|
46982
|
+
init_script_origins();
|
|
46855
46983
|
init_server_version();
|
|
46856
46984
|
init_setup();
|
|
46857
46985
|
init_tool_definitions();
|
|
@@ -46908,7 +47036,7 @@ init_bridge_announce();
|
|
|
46908
47036
|
init_errors();
|
|
46909
47037
|
init_godot_path();
|
|
46910
47038
|
import { existsSync as existsSync20 } from "node:fs";
|
|
46911
|
-
import { join as
|
|
47039
|
+
import { join as join26, resolve as resolve8 } from "node:path";
|
|
46912
47040
|
|
|
46913
47041
|
// src/harnesses.ts
|
|
46914
47042
|
import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync as rmSync2, statSync as statSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
@@ -48239,7 +48367,7 @@ class UsageError extends Refusal {
|
|
|
48239
48367
|
function projectArgument(at) {
|
|
48240
48368
|
const named = args.slice(at).find((value) => !value.startsWith("--"));
|
|
48241
48369
|
const projectPath = resolve8(named ?? ".");
|
|
48242
|
-
if (!existsSync20(
|
|
48370
|
+
if (!existsSync20(join26(projectPath, "project.godot"))) {
|
|
48243
48371
|
throw new UsageError(`Not a Godot project: ${projectPath} holds no project.godot.`);
|
|
48244
48372
|
}
|
|
48245
48373
|
return projectPath;
|
|
@@ -48284,7 +48412,7 @@ async function registerRuntime(godot, projectPath) {
|
|
|
48284
48412
|
return;
|
|
48285
48413
|
}
|
|
48286
48414
|
console.log(`${RUNTIME_AUTOLOAD.name} autoload left at ${named}, which this project registers itself`);
|
|
48287
|
-
if (!existsSync20(
|
|
48415
|
+
if (!existsSync20(join26(projectPath, (named ?? "").replace(/^res:\/\//, "")))) {
|
|
48288
48416
|
console.log(` that file is not in this project, so it boots with a missing script: restore it, or point the entry at res://${RUNTIME_AUTOLOAD.path}`);
|
|
48289
48417
|
}
|
|
48290
48418
|
}
|
|
@@ -61,7 +61,7 @@ static func wanted_items(menu: PopupMenu, params: Dictionary) -> Array[int]:
|
|
|
61
61
|
if words[at] == wanted:
|
|
62
62
|
return _items([order[at]])
|
|
63
63
|
for at: int in words.size():
|
|
64
|
-
if words[at].nocasecmp_to(wanted) == 0:
|
|
64
|
+
if Says.spaced(words[at]).nocasecmp_to(Says.spaced(wanted)) == 0:
|
|
65
65
|
return _items([order[at]])
|
|
66
66
|
# Separators are left out here: a heading is refused when named in full, and a pattern that
|
|
67
67
|
# matched one beside the item it heads would tie the two.
|
|
@@ -16,6 +16,9 @@ const SPLITS_KEPT: int = 8
|
|
|
16
16
|
## Each `says` split into alternatives, by what was written; see [method alternatives].
|
|
17
17
|
static var _splits: Dictionary[String, Array] = {}
|
|
18
18
|
|
|
19
|
+
## Every space separator Unicode has besides the plain space, built once; see [method spaced].
|
|
20
|
+
static var _spaces: RegEx = null
|
|
21
|
+
|
|
19
22
|
|
|
20
23
|
## Whether [param node]'s own words are what [param wanted] asks for.
|
|
21
24
|
static func says(node: Node, wanted: String) -> bool:
|
|
@@ -35,12 +38,33 @@ static func says(node: Node, wanted: String) -> bool:
|
|
|
35
38
|
## first, and read as one glob with the bars in it, it waited out its whole timeout for words no
|
|
36
39
|
## screen says.
|
|
37
40
|
static func matches(said: String, wanted: String) -> bool:
|
|
41
|
+
var shown: String = spaced(said)
|
|
38
42
|
for words: String in alternatives(wanted):
|
|
39
|
-
if
|
|
43
|
+
if shown.containsn(words) if is_plain(words) else shown.matchn(words):
|
|
40
44
|
return true
|
|
41
45
|
return false
|
|
42
46
|
|
|
43
47
|
|
|
48
|
+
## [param text] with every Unicode space separator written as a plain space, which is what a caller
|
|
49
|
+
## types for any of them. A game keeping a name on one line writes it with a non-breaking space, and
|
|
50
|
+
## "Deskanem Bonituk" typed with a plain one found nothing while the screen showed exactly those
|
|
51
|
+
## words. One character for one, so a place found in the result is the same place in [param text].
|
|
52
|
+
static func spaced(text: String) -> String:
|
|
53
|
+
if _spaces == null:
|
|
54
|
+
var separators: String = (
|
|
55
|
+
String.chr(0x00A0)
|
|
56
|
+
+ String.chr(0x1680)
|
|
57
|
+
+ String.chr(0x2000)
|
|
58
|
+
+ "-"
|
|
59
|
+
+ String.chr(0x200A)
|
|
60
|
+
+ String.chr(0x202F)
|
|
61
|
+
+ String.chr(0x205F)
|
|
62
|
+
+ String.chr(0x3000)
|
|
63
|
+
)
|
|
64
|
+
_spaces = RegEx.create_from_string("[%s]" % separators)
|
|
65
|
+
return _spaces.sub(text, " ", true)
|
|
66
|
+
|
|
67
|
+
|
|
44
68
|
## The alternatives [param wanted] names, each as words on a screen: split at every `|` not written
|
|
45
69
|
## as `\|`, which stays a bar in the words. An empty alternative, a stray bar at either end, is
|
|
46
70
|
## dropped: it names no words, and a find that came back empty widened it to `**` and suggested a
|
|
@@ -110,4 +134,4 @@ static func widened(wanted: String) -> String:
|
|
|
110
134
|
## which reads as a control that is not on the screen. Nothing on a screen says a backslash and
|
|
111
135
|
## an n, so the two characters mean the break to everybody who writes them.
|
|
112
136
|
static func as_said(wanted: String) -> String:
|
|
113
|
-
return wanted.replace("\\n", "\n")
|
|
137
|
+
return spaced(wanted.replace("\\n", "\n"))
|
|
@@ -41,6 +41,8 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
|
|
|
41
41
|
# The words of the control that matched, which a button pressed on a label's behalf has none of.
|
|
42
42
|
var said: Array[String] = []
|
|
43
43
|
var exact: Array[bool] = []
|
|
44
|
+
# Whether the words are only inside longer words there, "out" in "Scout".
|
|
45
|
+
var inside: Array[bool] = []
|
|
44
46
|
var hidden: int = 0
|
|
45
47
|
# Controls saying exactly the words that a click cannot reach, kept to say what a match on screen
|
|
46
48
|
# that only says them as part of more stands in for.
|
|
@@ -60,14 +62,17 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
|
|
|
60
62
|
var target: Control = _pressed_through(control)
|
|
61
63
|
var words: String = Words.said_by(node)
|
|
62
64
|
var whole: bool = _says_exactly(words, wanted)
|
|
65
|
+
var buried: bool = not whole and _inside_a_word(words, wanted)
|
|
63
66
|
var at: int = matched.find(target)
|
|
64
67
|
if at == -1:
|
|
65
68
|
matched.append(target)
|
|
66
69
|
said.append(words)
|
|
67
70
|
exact.append(whole)
|
|
68
|
-
|
|
71
|
+
inside.append(buried)
|
|
72
|
+
elif (whole and not exact[at]) or (inside[at] and not buried):
|
|
69
73
|
said[at] = words
|
|
70
|
-
exact[at] =
|
|
74
|
+
exact[at] = whole
|
|
75
|
+
inside[at] = buried
|
|
71
76
|
var children: Array[Node] = node.get_children(true)
|
|
72
77
|
for index: int in range(children.size() - 1, -1, -1):
|
|
73
78
|
pending.push_front(children[index])
|
|
@@ -95,9 +100,13 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
|
|
|
95
100
|
out_of_reach_as.append("under %s, which is drawn over it" % over.get_path())
|
|
96
101
|
if cover == null:
|
|
97
102
|
cover = over
|
|
98
|
-
# Stable, so matches of one rank keep the order they are drawn in.
|
|
103
|
+
# Stable, so matches of one rank keep the order they are drawn in. Words only inside longer words
|
|
104
|
+
# come after everything else: ranked as a button saying them as part of more, "Scout, 90" was
|
|
105
|
+
# pressed for "Out" over a control saying exactly "Out", and spent a game's silver.
|
|
99
106
|
open.sort_custom(
|
|
100
107
|
func(a: int, b: int) -> bool:
|
|
108
|
+
if inside[a] != inside[b]:
|
|
109
|
+
return inside[b]
|
|
101
110
|
var first: int = _rank(matched[a], exact[a])
|
|
102
111
|
var second: int = _rank(matched[b], exact[b])
|
|
103
112
|
return first > second or (first == second and a < b)
|
|
@@ -106,11 +115,13 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
|
|
|
106
115
|
var ranks: Array[int] = []
|
|
107
116
|
var lines: Array[String] = []
|
|
108
117
|
var wholes: Array[bool] = []
|
|
118
|
+
var insides: Array[bool] = []
|
|
109
119
|
for index: int in open:
|
|
110
120
|
targets.append(matched[index])
|
|
111
121
|
ranks.append(_rank(matched[index], exact[index]))
|
|
112
122
|
lines.append(said[index])
|
|
113
123
|
wholes.append(exact[index])
|
|
124
|
+
insides.append(inside[index])
|
|
114
125
|
|
|
115
126
|
var under: String = "" if root_path == "/root" else " under %s" % root_path
|
|
116
127
|
if targets.is_empty():
|
|
@@ -129,7 +140,10 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
|
|
|
129
140
|
]
|
|
130
141
|
)
|
|
131
142
|
}
|
|
132
|
-
var best: int =
|
|
143
|
+
var best: int = 0
|
|
144
|
+
for at: int in targets.size():
|
|
145
|
+
if ranks[at] == ranks[0] and insides[at] == insides[0]:
|
|
146
|
+
best += 1
|
|
133
147
|
if which == null and best > 1:
|
|
134
148
|
var order: String = "" if best == targets.size() else ", best matches first"
|
|
135
149
|
return {
|
|
@@ -151,30 +165,74 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
|
|
|
151
165
|
% [str(which), targets.size(), plural, under, wanted, _candidates(targets, lines)]
|
|
152
166
|
)
|
|
153
167
|
}
|
|
154
|
-
# A
|
|
155
|
-
#
|
|
156
|
-
#
|
|
157
|
-
#
|
|
158
|
-
|
|
168
|
+
# A match saying the words as part of more, while another control says exactly them, is a stand-in
|
|
169
|
+
# for that one, not the control meant: a tab reading "Out" sat in a collapsed drawer and a dispatch
|
|
170
|
+
# saying somebody "fell out in the hall" was pressed, and with the tab's own words on screen in a
|
|
171
|
+
# control that is not a button, a card reading "Scout, 90" was. Pressed only when asked for by
|
|
172
|
+
# index, which is the caller saying the match is what they meant.
|
|
173
|
+
var exact_elsewhere: Array[Node] = exact_out_of_reach.duplicate()
|
|
174
|
+
var elsewhere_as: Array[String] = out_of_reach_as.duplicate()
|
|
175
|
+
# On screen only when it takes clicks itself: a heading saying exactly "Onward" beside a button
|
|
176
|
+
# saying "Onward now" is not what a click on "Onward" is for, and the button is pressed.
|
|
177
|
+
for other: int in targets.size():
|
|
178
|
+
var taking: bool = targets[other].get_mouse_filter_with_override() != Control.MOUSE_FILTER_IGNORE
|
|
179
|
+
if other != index and wholes[other] and taking:
|
|
180
|
+
exact_elsewhere.append(targets[other])
|
|
181
|
+
elsewhere_as.append("on screen, taking clicks but not an enabled button")
|
|
182
|
+
var best_one: String = "the one on screen," if targets.size() == 1 else "the best match on screen,"
|
|
183
|
+
if which == null and not wholes[index] and not exact_elsewhere.is_empty():
|
|
159
184
|
return {
|
|
160
185
|
"type": "error",
|
|
161
186
|
"message":
|
|
162
187
|
(
|
|
163
188
|
(
|
|
164
|
-
'no
|
|
165
|
-
+ ' "%s", while %s; pass index %d to press that one anyway
|
|
189
|
+
'no %s on screen%s says exactly "%s", so nothing was clicked: %s %s says it as part of'
|
|
190
|
+
+ ' "%s", while %s; pass index %d to press that one anyway'
|
|
166
191
|
)
|
|
167
192
|
% [
|
|
193
|
+
"button" if exact_elsewhere.size() > exact_out_of_reach.size() else "control",
|
|
168
194
|
under,
|
|
169
195
|
wanted,
|
|
170
|
-
|
|
196
|
+
best_one,
|
|
171
197
|
targets[index].get_path(),
|
|
172
198
|
_shortened(lines[index]),
|
|
173
|
-
_out_of_reach_exactly(
|
|
199
|
+
_out_of_reach_exactly(exact_elsewhere, elsewhere_as),
|
|
174
200
|
index,
|
|
175
201
|
]
|
|
176
202
|
)
|
|
177
203
|
}
|
|
204
|
+
if which == null and insides[index]:
|
|
205
|
+
return {
|
|
206
|
+
"type": "error",
|
|
207
|
+
"message":
|
|
208
|
+
(
|
|
209
|
+
(
|
|
210
|
+
'no control on screen%s says "%s" as a word of its own, so nothing was clicked: %s %s has'
|
|
211
|
+
+ ' it only inside a longer word, "%s"; pass index %d to press that one anyway'
|
|
212
|
+
)
|
|
213
|
+
% [under, wanted, best_one, targets[index].get_path(), _shortened(lines[index]), index]
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
# Words in a sentence on a control that lets the pointer through: the press lands on whatever is
|
|
217
|
+
# under it, which is nothing the words name. A line saying what "is out there" was the best match
|
|
218
|
+
# left for "Out" once the cards saying "Scout" were passed over.
|
|
219
|
+
if (
|
|
220
|
+
which == null
|
|
221
|
+
and not wholes[index]
|
|
222
|
+
and targets[index].get_mouse_filter_with_override() == Control.MOUSE_FILTER_IGNORE
|
|
223
|
+
):
|
|
224
|
+
return {
|
|
225
|
+
"type": "error",
|
|
226
|
+
"message":
|
|
227
|
+
(
|
|
228
|
+
(
|
|
229
|
+
'no control on screen%s that takes a click says "%s", so nothing was clicked: %s %s says it'
|
|
230
|
+
+ ' as part of "%s" and lets the pointer through to what is under it; pass index %d to'
|
|
231
|
+
+ " click there anyway"
|
|
232
|
+
)
|
|
233
|
+
% [under, wanted, best_one, targets[index].get_path(), _shortened(lines[index]), index]
|
|
234
|
+
)
|
|
235
|
+
}
|
|
178
236
|
var found: Dictionary = {"says": wanted, "index": index, "of": targets.size()}
|
|
179
237
|
if not wholes[index]:
|
|
180
238
|
found["partOf"] = _shortened(lines[index])
|
|
@@ -205,7 +263,8 @@ static func _rank(target: Control, whole: bool) -> int:
|
|
|
205
263
|
## A line counts because a card is a title over a description: "WARD" over "wards 3" is the card
|
|
206
264
|
## named WARD, and read as its words run together it said WARD only as part of more, level with
|
|
207
265
|
## "WARDSPITE" over its own description, and a click on WARD was refused as not clear.
|
|
208
|
-
static func _says_exactly(
|
|
266
|
+
static func _says_exactly(written: String, wanted: String) -> bool:
|
|
267
|
+
var said: String = Says.spaced(written)
|
|
209
268
|
for words: String in Says.alternatives(wanted):
|
|
210
269
|
if not Says.is_plain(words):
|
|
211
270
|
if said.matchn(words):
|
|
@@ -220,6 +279,32 @@ static func _says_exactly(said: String, wanted: String) -> bool:
|
|
|
220
279
|
return false
|
|
221
280
|
|
|
222
281
|
|
|
282
|
+
## Whether [param written] has the plain words [param wanted] asks for only inside longer words,
|
|
283
|
+
## never starting where a word starts: "out" in "Scout". Words starting a longer one count as said,
|
|
284
|
+
## "Recruit" in "Recruits", and so does anything matched by a pattern.
|
|
285
|
+
static func _inside_a_word(written: String, wanted: String) -> bool:
|
|
286
|
+
var said: String = Says.spaced(written).to_lower()
|
|
287
|
+
var found: bool = false
|
|
288
|
+
for words: String in Says.alternatives(wanted):
|
|
289
|
+
var looked_for: String = words.to_lower()
|
|
290
|
+
if not Says.is_plain(words) or not _in_a_word(looked_for.unicode_at(0)):
|
|
291
|
+
return false
|
|
292
|
+
var at: int = said.find(looked_for)
|
|
293
|
+
while at >= 0:
|
|
294
|
+
found = true
|
|
295
|
+
if at == 0 or not _in_a_word(said.unicode_at(at - 1)):
|
|
296
|
+
return false
|
|
297
|
+
at = said.find(looked_for, at + 1)
|
|
298
|
+
return found
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
## Whether the character [param code] is part of a word: a letter, a digit or an underscore. A letter
|
|
302
|
+
## is told by having a case, which leaves scripts without one counted as breaks between words.
|
|
303
|
+
static func _in_a_word(code: int) -> bool:
|
|
304
|
+
var character: String = String.chr(code)
|
|
305
|
+
return character.to_lower() != character.to_upper() or character.is_valid_int() or character == "_"
|
|
306
|
+
|
|
307
|
+
|
|
223
308
|
## [param count] matches of [param rank], as the start of a sentence.
|
|
224
309
|
static func _described(count: int, rank: int, wanted: String, under: String) -> String:
|
|
225
310
|
var what: String = "button" if rank >= 2 else "control"
|
|
@@ -509,7 +594,7 @@ static func _clipped_away(
|
|
|
509
594
|
## words. A line in another font or holding an image measures off from what is drawn, which a wide
|
|
510
595
|
## line with nothing between the words and its start hardly does.
|
|
511
596
|
static func point_on_words(label: RichTextLabel, wanted: String) -> Dictionary:
|
|
512
|
-
var text: String = Words.said_by(label)
|
|
597
|
+
var text: String = Says.spaced(Words.said_by(label))
|
|
513
598
|
var start: int = -1
|
|
514
599
|
var length: int = 0
|
|
515
600
|
for words: String in Says.alternatives(wanted):
|
|
@@ -25,7 +25,14 @@ static func said_by(node: Node) -> String:
|
|
|
25
25
|
return _label_says(label)
|
|
26
26
|
if _draws_a_list(node):
|
|
27
27
|
return "\n".join(lines_said_by(node))
|
|
28
|
-
|
|
28
|
+
var own: String = _text_of(node)
|
|
29
|
+
# A control showing a picture rather than words is named for a player who cannot see it, and that
|
|
30
|
+
# name is the one the words a caller asks by are meant for: the rail's "Out" tab is a square with
|
|
31
|
+
# an icon on it, and a click on "Out" found only the words around it.
|
|
32
|
+
if own.is_empty() and node is Control:
|
|
33
|
+
var control: Control = node
|
|
34
|
+
return control.atr(control.accessibility_name).strip_edges()
|
|
35
|
+
return own
|
|
29
36
|
|
|
30
37
|
|
|
31
38
|
## Every line [param node] draws for a player to read, one to an entry: a label's one line, or each
|
package/build/index.js
CHANGED
|
@@ -10259,7 +10259,7 @@ import {
|
|
|
10259
10259
|
rmdirSync as rmdirSync2,
|
|
10260
10260
|
statSync as statSync8
|
|
10261
10261
|
} from "node:fs";
|
|
10262
|
-
import { basename as basename5, dirname as dirname14, extname as extname2, isAbsolute as isAbsolute3, join as
|
|
10262
|
+
import { basename as basename5, dirname as dirname14, extname as extname2, isAbsolute as isAbsolute3, join as join21, normalize as normalize2, relative as relative4 } from "node:path";
|
|
10263
10263
|
import { setTimeout as delay3 } from "node:timers/promises";
|
|
10264
10264
|
import { fileURLToPath as fileURLToPath4 } from "node:url";
|
|
10265
10265
|
import { promisify as promisify5 } from "node:util";
|
|
@@ -29420,12 +29420,16 @@ function whyNoReport(printed, asked) {
|
|
|
29420
29420
|
}
|
|
29421
29421
|
var DISCOVERY_FAILED = "Script errors were detected during test discovery!";
|
|
29422
29422
|
function scriptErrorsPrinted(printed) {
|
|
29423
|
+
return discoveryListing(printed)?.errors ?? [];
|
|
29424
|
+
}
|
|
29425
|
+
function discoveryListing(printed) {
|
|
29423
29426
|
const start = printed.findIndex((line) => line.includes(DISCOVERY_FAILED));
|
|
29424
29427
|
if (start < 0) {
|
|
29425
|
-
return
|
|
29428
|
+
return null;
|
|
29426
29429
|
}
|
|
29427
29430
|
const found = [];
|
|
29428
29431
|
let message = null;
|
|
29432
|
+
let to = start + 1;
|
|
29429
29433
|
for (const line of printed.slice(start + 1)) {
|
|
29430
29434
|
const at = /^\s*at (res:\/\/.+):(\d+)\s*$/.exec(line);
|
|
29431
29435
|
if (at !== null && message !== null) {
|
|
@@ -29436,8 +29440,10 @@ function scriptErrorsPrinted(printed) {
|
|
|
29436
29440
|
} else {
|
|
29437
29441
|
break;
|
|
29438
29442
|
}
|
|
29443
|
+
to += 1;
|
|
29439
29444
|
}
|
|
29440
|
-
|
|
29445
|
+
const errors = found.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : (a.line ?? 0) - (b.line ?? 0));
|
|
29446
|
+
return { errors, from: start, to };
|
|
29441
29447
|
}
|
|
29442
29448
|
var ESCAPE = String.fromCharCode(27);
|
|
29443
29449
|
var LOCATION = `'[^'\\n]*' in \\S+:\\d+`;
|
|
@@ -32498,6 +32504,99 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
|
|
|
32498
32504
|
}
|
|
32499
32505
|
}
|
|
32500
32506
|
|
|
32507
|
+
// src/script-origins.ts
|
|
32508
|
+
import { join as join19 } from "node:path";
|
|
32509
|
+
var UNRESOLVED = /Could not resolve class "([A-Za-z_][A-Za-z0-9_]*)", because of a parser error/;
|
|
32510
|
+
function unresolvedClassIn(message) {
|
|
32511
|
+
return UNRESOLVED.exec(message)?.[1] ?? null;
|
|
32512
|
+
}
|
|
32513
|
+
var MOST_CHECKED = 8;
|
|
32514
|
+
async function originsOf(errors, declared, check) {
|
|
32515
|
+
const listed = new Set(errors.map((error) => error.path));
|
|
32516
|
+
const origins = [];
|
|
32517
|
+
const unconfirmed = [];
|
|
32518
|
+
const asked = new Set;
|
|
32519
|
+
const waiting = errors.map((error) => unresolvedClassIn(error.message)).filter((name) => name !== null);
|
|
32520
|
+
while (waiting.length > 0 && asked.size < MOST_CHECKED) {
|
|
32521
|
+
const name = waiting.shift() ?? "";
|
|
32522
|
+
const path = declared.get(name);
|
|
32523
|
+
if (path === undefined || listed.has(path) || asked.has(path)) {
|
|
32524
|
+
continue;
|
|
32525
|
+
}
|
|
32526
|
+
asked.add(path);
|
|
32527
|
+
const checked = await check(path);
|
|
32528
|
+
const own = checked.errors.filter((error) => error.path === path);
|
|
32529
|
+
if (own.length === 0) {
|
|
32530
|
+
unconfirmed.push({
|
|
32531
|
+
path,
|
|
32532
|
+
declares: name,
|
|
32533
|
+
why: checked.failure === null ? "the engine's check of it found no error" : `the engine's check of it did not finish (${checked.failure})`
|
|
32534
|
+
});
|
|
32535
|
+
continue;
|
|
32536
|
+
}
|
|
32537
|
+
for (const error of own) {
|
|
32538
|
+
origins.push({ ...error, declares: name });
|
|
32539
|
+
const deeper = unresolvedClassIn(error.message);
|
|
32540
|
+
if (deeper !== null) {
|
|
32541
|
+
waiting.push(deeper);
|
|
32542
|
+
}
|
|
32543
|
+
}
|
|
32544
|
+
}
|
|
32545
|
+
return { origins, unconfirmed };
|
|
32546
|
+
}
|
|
32547
|
+
var RELOADED_AT = /GDScript::reload \((res:\/\/.+):(\d+)\)$/;
|
|
32548
|
+
function reloadedAt(entry) {
|
|
32549
|
+
for (const line of entry.detail) {
|
|
32550
|
+
const at = RELOADED_AT.exec(line.trim());
|
|
32551
|
+
if (at !== null) {
|
|
32552
|
+
return { path: at[1] ?? "", line: Number(at[2]) === 0 ? null : Number(at[2]) };
|
|
32553
|
+
}
|
|
32554
|
+
}
|
|
32555
|
+
return null;
|
|
32556
|
+
}
|
|
32557
|
+
function checkWithEngine(godotPath, projectPath) {
|
|
32558
|
+
return async (path) => {
|
|
32559
|
+
const logDir = scratchDirectory("tests");
|
|
32560
|
+
try {
|
|
32561
|
+
const ran = await runEngine(godotPath, [
|
|
32562
|
+
"--headless",
|
|
32563
|
+
"--log-file",
|
|
32564
|
+
join19(logDir, "engine.log"),
|
|
32565
|
+
"--path",
|
|
32566
|
+
projectPath,
|
|
32567
|
+
"--check-only",
|
|
32568
|
+
"-s",
|
|
32569
|
+
path
|
|
32570
|
+
], { timeout: { ms: 60000, said: "minute" } });
|
|
32571
|
+
const errors = ran.log.select({ severity: "error", sinceLastCall: false, limit: 50 }).entries.flatMap((entry) => {
|
|
32572
|
+
const at = reloadedAt(entry);
|
|
32573
|
+
return at === null ? [] : [{ ...at, message: entry.text }];
|
|
32574
|
+
});
|
|
32575
|
+
return { errors, failure: ran.failure === null ? null : howItEnded(ran) };
|
|
32576
|
+
} finally {
|
|
32577
|
+
discard(logDir);
|
|
32578
|
+
}
|
|
32579
|
+
};
|
|
32580
|
+
}
|
|
32581
|
+
function withoutListed(entries, errors) {
|
|
32582
|
+
if (errors.length === 0) {
|
|
32583
|
+
return [...entries];
|
|
32584
|
+
}
|
|
32585
|
+
const listing = discoveryListing(entries.map((entry) => entry.text));
|
|
32586
|
+
const paths = new Set(errors.map((error) => error.path));
|
|
32587
|
+
return entries.filter((entry, at) => {
|
|
32588
|
+
if (listing !== null && at >= listing.from && at < listing.to) {
|
|
32589
|
+
return false;
|
|
32590
|
+
}
|
|
32591
|
+
const reloaded = reloadedAt(entry);
|
|
32592
|
+
if (reloaded !== null && errors.some((error) => error.path === reloaded.path && error.line === reloaded.line && error.message === entry.text)) {
|
|
32593
|
+
return false;
|
|
32594
|
+
}
|
|
32595
|
+
const failedToLoad = /^Failed to load script "(res:\/\/[^"]+)" with error "[^"]+"\.$/.exec(entry.text);
|
|
32596
|
+
return failedToLoad === null || !paths.has(failedToLoad[1] ?? "");
|
|
32597
|
+
});
|
|
32598
|
+
}
|
|
32599
|
+
|
|
32501
32600
|
// src/setup.ts
|
|
32502
32601
|
import { spawnSync } from "node:child_process";
|
|
32503
32602
|
import { createHash as createHash4 } from "node:crypto";
|
|
@@ -32512,7 +32611,7 @@ import {
|
|
|
32512
32611
|
statSync as statSync7,
|
|
32513
32612
|
writeFileSync as writeFileSync13
|
|
32514
32613
|
} from "node:fs";
|
|
32515
|
-
import { dirname as dirname13, join as
|
|
32614
|
+
import { dirname as dirname13, join as join20 } from "node:path";
|
|
32516
32615
|
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
32517
32616
|
var ADDONS = ["gdharness_editor", "gdharness_runtime", "auto_reload"];
|
|
32518
32617
|
var EDITOR_PLUGINS = ["gdharness_editor", "auto_reload"];
|
|
@@ -32536,7 +32635,7 @@ function addonsDigest(names, from) {
|
|
|
32536
32635
|
if (name === VERSION_MARKER || name === DIGEST_MARKER) {
|
|
32537
32636
|
continue;
|
|
32538
32637
|
}
|
|
32539
|
-
const path =
|
|
32638
|
+
const path = join20(directory, name);
|
|
32540
32639
|
const inside = `${relative}/${name}`;
|
|
32541
32640
|
if (statSync7(path).isDirectory()) {
|
|
32542
32641
|
walk(path, inside);
|
|
@@ -32550,7 +32649,7 @@ function addonsDigest(names, from) {
|
|
|
32550
32649
|
}
|
|
32551
32650
|
};
|
|
32552
32651
|
for (const name of names) {
|
|
32553
|
-
walk(
|
|
32652
|
+
walk(join20(from, name), name);
|
|
32554
32653
|
}
|
|
32555
32654
|
return hash.digest("hex");
|
|
32556
32655
|
}
|
|
@@ -32583,7 +32682,7 @@ function installedRuntimeDigest(projectPath) {
|
|
|
32583
32682
|
return digestMarker(projectPath, RUNTIME_ADDON);
|
|
32584
32683
|
}
|
|
32585
32684
|
function digestMarker(projectPath, addon) {
|
|
32586
|
-
const marker =
|
|
32685
|
+
const marker = join20(projectPath, "addons", addon, DIGEST_MARKER);
|
|
32587
32686
|
if (!existsSync15(marker)) {
|
|
32588
32687
|
return null;
|
|
32589
32688
|
}
|
|
@@ -32591,10 +32690,10 @@ function digestMarker(projectPath, addon) {
|
|
|
32591
32690
|
return said === "" ? null : said;
|
|
32592
32691
|
}
|
|
32593
32692
|
function shippedAddonsDirectory() {
|
|
32594
|
-
return
|
|
32693
|
+
return join20(dirname13(fileURLToPath3(import.meta.url)), "godot", "addons");
|
|
32595
32694
|
}
|
|
32596
32695
|
function installedAddonVersion(projectPath) {
|
|
32597
|
-
const marker =
|
|
32696
|
+
const marker = join20(projectPath, "addons", ADDONS[0], VERSION_MARKER);
|
|
32598
32697
|
if (!existsSync15(marker)) {
|
|
32599
32698
|
return null;
|
|
32600
32699
|
}
|
|
@@ -32609,7 +32708,7 @@ function loaderAmong(projectPath, named) {
|
|
|
32609
32708
|
}
|
|
32610
32709
|
let source = null;
|
|
32611
32710
|
try {
|
|
32612
|
-
source = readFileSync18(
|
|
32711
|
+
source = readFileSync18(join20(projectPath, path), "utf8");
|
|
32613
32712
|
} catch {}
|
|
32614
32713
|
if (source?.includes(RUNTIME_AUTOLOAD.path)) {
|
|
32615
32714
|
return { name: entry, path, exists: true };
|
|
@@ -32651,8 +32750,8 @@ function enabledPlugins(settings) {
|
|
|
32651
32750
|
function inspectProject(projectPath) {
|
|
32652
32751
|
const problems = [];
|
|
32653
32752
|
const addons = ADDONS.map((name) => {
|
|
32654
|
-
const directory =
|
|
32655
|
-
const marker =
|
|
32753
|
+
const directory = join20(projectPath, "addons", name);
|
|
32754
|
+
const marker = join20(directory, VERSION_MARKER);
|
|
32656
32755
|
const installed = existsSync15(directory);
|
|
32657
32756
|
const version = existsSync15(marker) ? readFileSync18(marker, "utf8").trim() : null;
|
|
32658
32757
|
const current = version === SERVER_VERSION;
|
|
@@ -32663,7 +32762,7 @@ function inspectProject(projectPath) {
|
|
|
32663
32762
|
}
|
|
32664
32763
|
return { name, installed, version, current };
|
|
32665
32764
|
});
|
|
32666
|
-
const settings = parseProjectGodot(readFileSync18(
|
|
32765
|
+
const settings = parseProjectGodot(readFileSync18(join20(projectPath, "project.godot"), "utf8"));
|
|
32667
32766
|
const pluginsEnabled = enabledPlugins(settings);
|
|
32668
32767
|
for (const name of EDITOR_PLUGINS) {
|
|
32669
32768
|
if (!pluginsEnabled.includes(name)) {
|
|
@@ -32679,7 +32778,7 @@ function inspectProject(projectPath) {
|
|
|
32679
32778
|
const tracked = trackedByGit(projectPath, [...named.values()]);
|
|
32680
32779
|
const addonMissing = (path) => addons.some((addon) => !addon.installed && path.startsWith(`addons/${addon.name}/`));
|
|
32681
32780
|
for (const [name, path] of named) {
|
|
32682
|
-
if (!existsSync15(
|
|
32781
|
+
if (!existsSync15(join20(projectPath, path))) {
|
|
32683
32782
|
if (!addonMissing(path)) {
|
|
32684
32783
|
problems.push(`the ${name} autoload names res://${path}, which is not in this project, so it boots with a missing script; restore that file, or point the autoload at one that is here`);
|
|
32685
32784
|
}
|
|
@@ -33085,7 +33184,7 @@ var TOOL_SPECS = [
|
|
|
33085
33184
|
},
|
|
33086
33185
|
{
|
|
33087
33186
|
name: "project_test",
|
|
33088
|
-
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 script that does not load stops gdUnit4 before any suite runs: its errors come back in full under scriptErrors, and the scripts that failed only because one they depend on did are listed by path under failedThroughDependency. 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.",
|
|
33187
|
+
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 script that does not load stops gdUnit4 before any suite runs: its errors come back in full under scriptErrors, and the scripts that failed only because one they depend on did are listed by path under failedThroughDependency. When they failed on a class whose own script has a parser error, which gdUnit4 never names, that script is checked by the engine and its error comes first under scriptErrors, with the class it declares under declares. 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.",
|
|
33089
33188
|
parameters: {
|
|
33090
33189
|
projectPath: PROJECT_PATH,
|
|
33091
33190
|
path: {
|
|
@@ -33655,7 +33754,7 @@ var TOOL_SPECS = [
|
|
|
33655
33754
|
says: {
|
|
33656
33755
|
type: "string",
|
|
33657
33756
|
ops: ["find"],
|
|
33658
|
-
description: 'find: part of what the node has written on it as drawn, the way the text op reads it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern, and a glob that finds nothing says under note how many nodes it would find open at both ends. A label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Alternatives separated by | match when any one of them does, each by these rules ("*won*|*lost*"); write \\| for a bar that is part of the words.'
|
|
33757
|
+
description: 'find: part of what the node has written on it as drawn, the way the text op reads it, case-insensitively and with a typed space matching any space the game draws (a non-breaking one included), which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern, and a glob that finds nothing says under note how many nodes it would find open at both ends. A label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Alternatives separated by | match when any one of them does, each by these rules ("*won*|*lost*"); write \\| for a bar that is part of the words.'
|
|
33659
33758
|
},
|
|
33660
33759
|
limit: {
|
|
33661
33760
|
type: "integer",
|
|
@@ -33833,7 +33932,7 @@ var TOOL_SPECS = [
|
|
|
33833
33932
|
says: {
|
|
33834
33933
|
type: "string",
|
|
33835
33934
|
ops: ["click"],
|
|
33836
|
-
description: "click: the words on the control to click, matched the way runtime_inspect find matches says (a plain word is contained in the text, a pattern with * or ? is a glob over it, and | separates alternatives), among the controls on screen, and pressed in the same frame it was found in, so a panel rebuilding between a find and a click cannot free it first. Words on a label inside a button press the button. A control with something drawn over its centre that would take the pointer, such as a screen shown over it or a dialog stacked above its own, is not on screen; nor is one on a hidden canvas layer, one outside the viewport, or one a container clips away that a click does not scroll. The refusal counts those apart. The matches are ranked the way a person picks what to press: an enabled button before anything else, a disabled one included, then a control whose whole text, or one whole line of it such as the title on a card, is the words before one saying them as part of more, and the best one is pressed; found.picked says why. Several matches at the best rank are refused with each one listed, and index picks one. A match saying the words only as part of more is named under found.partOf, and is refused when
|
|
33935
|
+
description: "click: the words on the control to click, matched the way runtime_inspect find matches says (a plain word is contained in the text, a pattern with * or ? is a glob over it, and | separates alternatives), among the controls on screen, and pressed in the same frame it was found in, so a panel rebuilding between a find and a click cannot free it first. Words on a label inside a button press the button. A control with something drawn over its centre that would take the pointer, such as a screen shown over it or a dialog stacked above its own, is not on screen; nor is one on a hidden canvas layer, one outside the viewport, or one a container clips away that a click does not scroll. The refusal counts those apart. The matches are ranked the way a person picks what to press: an enabled button before anything else, a disabled one included, then a control whose whole text, or one whole line of it such as the title on a card, is the words before one saying them as part of more, and the best one is pressed; found.picked says why. Several matches at the best rank are refused with each one listed, and index picks one. A match saying the words only as part of more is named under found.partOf, and is refused when another control says exactly the words (hidden, off the screen, covered, or on screen taking clicks without being a button), since it would stand in for that one, and when it lets the pointer through to what is under it; index presses it anyway. Words found only inside longer words (\"out\" in \"Scout\") rank after every other match and are refused unless index is given; words starting a longer word (\"Recruit\" in \"Recruits\") count. An empty field says its placeholder, and a control with no text of its own, a picture on a button, says its accessibility name. An item of an open menu is drawn by the menu rather than held as a control, so a click finds none, and the refusal names the menu and the choose call that picks it."
|
|
33837
33936
|
},
|
|
33838
33937
|
action: {
|
|
33839
33938
|
type: "string",
|
|
@@ -34114,7 +34213,7 @@ function captureDestinationRefusal(outputPath, projects) {
|
|
|
34114
34213
|
if (!existsSync16(directory)) {
|
|
34115
34214
|
return `outputPath's directory ${dirname14(outputPath)} does not exist; a capture creates no directories.`;
|
|
34116
34215
|
}
|
|
34117
|
-
const where =
|
|
34216
|
+
const where = join21(directory, basename5(outputPath));
|
|
34118
34217
|
const inside = projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
|
|
34119
34218
|
if (inside !== undefined) {
|
|
34120
34219
|
return `outputPath ${outputPath} is inside the project at ${inside}; a capture is saved outside it, so it never becomes part of the game.`;
|
|
@@ -34635,7 +34734,13 @@ function foldedScriptErrors(errors) {
|
|
|
34635
34734
|
function nonEmpty(lists) {
|
|
34636
34735
|
return Object.fromEntries(Object.entries(lists).filter(([, list]) => list.length > 0));
|
|
34637
34736
|
}
|
|
34638
|
-
function scriptErrorsNote(errors
|
|
34737
|
+
function scriptErrorsNote(errors, {
|
|
34738
|
+
origins,
|
|
34739
|
+
unconfirmed
|
|
34740
|
+
} = {
|
|
34741
|
+
origins: [],
|
|
34742
|
+
unconfirmed: []
|
|
34743
|
+
}) {
|
|
34639
34744
|
const { scriptErrors: own, failedThroughDependency: dependents } = foldedScriptErrors(errors);
|
|
34640
34745
|
const causes = new Map;
|
|
34641
34746
|
for (const error of own) {
|
|
@@ -34644,7 +34749,22 @@ function scriptErrorsNote(errors) {
|
|
|
34644
34749
|
}
|
|
34645
34750
|
}
|
|
34646
34751
|
const scripts = causes.size + dependents.length;
|
|
34647
|
-
const
|
|
34752
|
+
const at = (error) => error.line === null ? error.path : `${error.path}:${error.line}`;
|
|
34753
|
+
const sentence = (text) => `${text}${/[.!?]\)?$/.test(text) ? "" : "."}`;
|
|
34754
|
+
const traced = new Set;
|
|
34755
|
+
const failedOn = (declares) => {
|
|
34756
|
+
const through = [...causes.values()].filter((error) => unresolvedClassIn(error.message) === declares);
|
|
34757
|
+
for (const error of through) {
|
|
34758
|
+
traced.add(error.path);
|
|
34759
|
+
}
|
|
34760
|
+
return through.length === 0 ? "" : ` ${through.map(at).join(", ")} could not resolve ${declares} because of it.`;
|
|
34761
|
+
};
|
|
34762
|
+
const first = [...new Map(origins.map((origin) => [origin.path, origin])).values()].sort((a, b) => Number(unresolvedClassIn(a.message) !== null) - Number(unresolvedClassIn(b.message) !== null));
|
|
34763
|
+
const named = [
|
|
34764
|
+
...first.map((origin) => `${at(origin)} ${sentence(origin.message)} gdUnit4 did not name this script, which declares ${origin.declares}; the engine's own check of it found the error.${failedOn(origin.declares)}`),
|
|
34765
|
+
...unconfirmed.map((origin) => `${origin.path} declares ${origin.declares}, which gdUnit4 did not name, and ${origin.why}.${failedOn(origin.declares)}`),
|
|
34766
|
+
...[...causes.values()].filter((error) => !traced.has(error.path)).map((error) => `${at(error)} ${sentence(error.message)}`)
|
|
34767
|
+
].join(" ");
|
|
34648
34768
|
const following = dependents.length === 0 ? "" : causes.size === 0 ? `${dependents.join(", ")} ${dependents.length === 1 ? "depends" : "depend"} on a script that did not compile, and gdUnit4 named none with an error of its own: script_diagnostics on one of them finds it.` : `${dependents.length === 1 ? "One more" : `${dependents.length} more`} failed only because a script ${dependents.length === 1 ? "it depends" : "they depend"} on did: ${dependents.join(", ")}.`;
|
|
34649
34769
|
const more = own.length - causes.size;
|
|
34650
34770
|
const rest = more > 0 ? `${more === 1 ? "One more error" : `${more} more errors`} in ${causes.size === 1 ? "that script" : "those scripts"} ${more === 1 ? "is" : "are"} under scriptErrors.` : "";
|
|
@@ -34743,7 +34863,7 @@ function aboveTheRunner(entry) {
|
|
|
34743
34863
|
}
|
|
34744
34864
|
function elsewhereIn(projectPath, asked) {
|
|
34745
34865
|
const wanted = asked.replace(/^res:\/\//, "").replace(/\/+$/, "");
|
|
34746
|
-
if (wanted === "" || existsSync16(
|
|
34866
|
+
if (wanted === "" || existsSync16(join21(projectPath, wanted))) {
|
|
34747
34867
|
return "";
|
|
34748
34868
|
}
|
|
34749
34869
|
const root = (wanted.split("/")[0] ?? "").toLowerCase();
|
|
@@ -34795,7 +34915,7 @@ function runtimeAddonAnnouncesEditors(projectPath) {
|
|
|
34795
34915
|
return false;
|
|
34796
34916
|
}
|
|
34797
34917
|
try {
|
|
34798
|
-
return readFileSync19(
|
|
34918
|
+
return readFileSync19(join21(projectPath, RUNTIME_AUTOLOAD.path), "utf8").includes('"editor_pid"');
|
|
34799
34919
|
} catch {
|
|
34800
34920
|
return false;
|
|
34801
34921
|
}
|
|
@@ -34820,7 +34940,7 @@ function needsItsOwnProcess(asked) {
|
|
|
34820
34940
|
class GodotServer {
|
|
34821
34941
|
mcp;
|
|
34822
34942
|
locator = new GodotLocator;
|
|
34823
|
-
operationsScript =
|
|
34943
|
+
operationsScript = join21(__dirname2, "godot", "operations", "godot_operations.gd");
|
|
34824
34944
|
godotBridge;
|
|
34825
34945
|
tools = buildToolDefinitions();
|
|
34826
34946
|
activeProcess = null;
|
|
@@ -35123,7 +35243,7 @@ class GodotServer {
|
|
|
35123
35243
|
return checked.response;
|
|
35124
35244
|
}
|
|
35125
35245
|
const named = args["projectPath"];
|
|
35126
|
-
if (typeof named === "string" && existsSync16(
|
|
35246
|
+
if (typeof named === "string" && existsSync16(join21(named, "project.godot"))) {
|
|
35127
35247
|
this.lastProjectPath = named;
|
|
35128
35248
|
}
|
|
35129
35249
|
this.updates.refresh();
|
|
@@ -35597,7 +35717,7 @@ class GodotServer {
|
|
|
35597
35717
|
if (path === undefined) {
|
|
35598
35718
|
return { ok: false, response: this.createErrorResponse("projectPath is required.") };
|
|
35599
35719
|
}
|
|
35600
|
-
const file =
|
|
35720
|
+
const file = join21(path, "project.godot");
|
|
35601
35721
|
if (!existsSync16(file)) {
|
|
35602
35722
|
return {
|
|
35603
35723
|
ok: false,
|
|
@@ -36276,7 +36396,7 @@ class GodotServer {
|
|
|
36276
36396
|
const exportArgs = [
|
|
36277
36397
|
"--headless",
|
|
36278
36398
|
"--log-file",
|
|
36279
|
-
|
|
36399
|
+
join21(exportLogs, "engine.log"),
|
|
36280
36400
|
"--path",
|
|
36281
36401
|
project.value.path,
|
|
36282
36402
|
debug ? "--export-debug" : "--export-release",
|
|
@@ -36306,7 +36426,7 @@ class GodotServer {
|
|
|
36306
36426
|
if (!paths.ok) {
|
|
36307
36427
|
return this.createErrorResponse(paths.reason, PATH_SOLUTIONS);
|
|
36308
36428
|
}
|
|
36309
|
-
const missing = paths.value.filter((path) => !existsSync16(
|
|
36429
|
+
const missing = paths.value.filter((path) => !existsSync16(join21(project.value.path, path.slice("res://".length))));
|
|
36310
36430
|
const asked = paths.value.filter((path) => !missing.includes(path));
|
|
36311
36431
|
if (asked.length === 0) {
|
|
36312
36432
|
const verdict = `nothing at ${missing.join(" and ")}`;
|
|
@@ -36322,7 +36442,7 @@ class GodotServer {
|
|
|
36322
36442
|
};
|
|
36323
36443
|
}
|
|
36324
36444
|
const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
|
|
36325
|
-
if (!existsSync16(
|
|
36445
|
+
if (!existsSync16(join21(project.value.path, runner))) {
|
|
36326
36446
|
return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
|
|
36327
36447
|
"Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
|
|
36328
36448
|
]);
|
|
@@ -36387,14 +36507,14 @@ class GodotServer {
|
|
|
36387
36507
|
await untilGone([run.process.pid]);
|
|
36388
36508
|
}
|
|
36389
36509
|
await run.settled;
|
|
36390
|
-
const reportsDir =
|
|
36510
|
+
const reportsDir = join21(project.value.path, ".godot", ...ours.split("/"));
|
|
36391
36511
|
let report = null;
|
|
36392
36512
|
let reportProblem = null;
|
|
36393
36513
|
try {
|
|
36394
36514
|
const written = existsSync16(reportsDir) ? readdirSync9(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
|
|
36395
36515
|
const newest = written.at(-1);
|
|
36396
36516
|
if (newest !== undefined) {
|
|
36397
|
-
report = parseJUnit(readFileSync19(
|
|
36517
|
+
report = parseJUnit(readFileSync19(join21(reportsDir, newest, "results.xml"), "utf8"));
|
|
36398
36518
|
}
|
|
36399
36519
|
} catch (error) {
|
|
36400
36520
|
reportProblem = errorMessage(error);
|
|
@@ -36423,7 +36543,9 @@ class GodotServer {
|
|
|
36423
36543
|
const nothingRan = timedOut ? null : whyNoReport(said, asked.join(" and "));
|
|
36424
36544
|
const cutShort = timedOut ? timedOutVerdict(timeoutMs, silentForMs) : null;
|
|
36425
36545
|
const hung = cutShort?.hung ?? false;
|
|
36426
|
-
const
|
|
36546
|
+
const printedErrors = timedOut || exitCode !== 105 ? [] : scriptErrorsPrinted(said);
|
|
36547
|
+
const declaring = printedErrors.some((error) => unresolvedClassIn(error.message) !== null) ? await originsOf(printedErrors, declaredClasses(project.value.path), checkWithEngine(engine.value, project.value.path)) : { origins: [], unconfirmed: [] };
|
|
36548
|
+
const scriptErrors = [...declaring.origins, ...printedErrors];
|
|
36427
36549
|
const brokenScripts = new Set(scriptErrors.map((error) => error.path)).size;
|
|
36428
36550
|
const ranAs = 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));
|
|
36429
36551
|
const nothingAt = missing.join(" and ");
|
|
@@ -36437,7 +36559,7 @@ class GodotServer {
|
|
|
36437
36559
|
...echoed.omitted > 0 ? { printedOmitted: echoed.omitted } : {}
|
|
36438
36560
|
};
|
|
36439
36561
|
if (report === null) {
|
|
36440
|
-
const said = nothingRan !== null ? `No tests ran: ${ranAs}.` : scriptErrors.length > 0 ? scriptErrorsNote(
|
|
36562
|
+
const said = nothingRan !== null ? `No tests ran: ${ranAs}.` : scriptErrors.length > 0 ? scriptErrorsNote(printedErrors, declaring) : reportProblem !== null ? `The test run's report could not be read (${reportProblem}); the engine's own verdict was ${ranAs}.` : `The test run wrote no report (${ranAs}).`;
|
|
36441
36563
|
const note = missingNote === undefined ? said : `${said} ${missingNote}`;
|
|
36442
36564
|
return {
|
|
36443
36565
|
content: [
|
|
@@ -36455,7 +36577,7 @@ class GodotServer {
|
|
|
36455
36577
|
...missing.length > 0 ? { missing } : {},
|
|
36456
36578
|
...nonEmpty(foldedScriptErrors(scriptErrors)),
|
|
36457
36579
|
arguments: cmdArgs,
|
|
36458
|
-
entries: forAnswer(printed.slice(0, 60).map(aboveTheRunner)),
|
|
36580
|
+
entries: forAnswer(withoutListed(printed, scriptErrors).slice(0, 60).map(aboveTheRunner)),
|
|
36459
36581
|
...printedLines,
|
|
36460
36582
|
savesNote,
|
|
36461
36583
|
...scanned
|
|
@@ -36963,7 +37085,7 @@ class GodotServer {
|
|
|
36963
37085
|
return null;
|
|
36964
37086
|
}
|
|
36965
37087
|
try {
|
|
36966
|
-
return settingKeys(readFileSync19(
|
|
37088
|
+
return settingKeys(readFileSync19(join21(projectPath, "project.godot"), "utf8"));
|
|
36967
37089
|
} catch {
|
|
36968
37090
|
return null;
|
|
36969
37091
|
}
|
|
@@ -37285,7 +37407,7 @@ class GodotServer {
|
|
|
37285
37407
|
}
|
|
37286
37408
|
async runtimeUp(projectPath, before, budget, withArgs) {
|
|
37287
37409
|
const budgetMs = budget.ms;
|
|
37288
|
-
if (!existsSync16(
|
|
37410
|
+
if (!existsSync16(join21(projectPath, RUNTIME_AUTOLOAD.path))) {
|
|
37289
37411
|
return runtimeVerdict(null, {
|
|
37290
37412
|
addon: false,
|
|
37291
37413
|
budgetMs,
|
|
@@ -38302,7 +38424,7 @@ class GodotServer {
|
|
|
38302
38424
|
notes.push(endedWithoutACode({
|
|
38303
38425
|
throughEditor: run.throughEditor,
|
|
38304
38426
|
announced: announced !== undefined || run.announcedPid !== undefined,
|
|
38305
|
-
couldAnnounce: run.projectPath !== null && existsSync16(
|
|
38427
|
+
couldAnnounce: run.projectPath !== null && existsSync16(join21(run.projectPath, RUNTIME_AUTOLOAD.path)),
|
|
38306
38428
|
endedHere: typeof run.endedHere === "string",
|
|
38307
38429
|
pickedUp: run.pickedUp === true
|
|
38308
38430
|
}));
|
|
@@ -39151,7 +39273,7 @@ class GodotServer {
|
|
|
39151
39273
|
async ownRunOnItsWay(projectPath, budgetMs) {
|
|
39152
39274
|
const run = await this.currentRun();
|
|
39153
39275
|
const project = projectPath ?? run?.projectPath ?? null;
|
|
39154
|
-
if (run === null || project === null || run.announcedPid !== undefined || run.projectPath !== null && !isSameDirectory(run.projectPath, project) || !existsSync16(
|
|
39276
|
+
if (run === null || project === null || run.announcedPid !== undefined || run.projectPath !== null && !isSameDirectory(run.projectPath, project) || !existsSync16(join21(project, RUNTIME_AUTOLOAD.path)) || !await this.runStillGoing(run)) {
|
|
39155
39277
|
return null;
|
|
39156
39278
|
}
|
|
39157
39279
|
const already = runtimesAnnounced().running;
|
|
@@ -39235,7 +39357,7 @@ class GodotServer {
|
|
|
39235
39357
|
}
|
|
39236
39358
|
}
|
|
39237
39359
|
const screenshotDir = expectsScreenshot ? scratchDirectory("runtime-screenshot") : null;
|
|
39238
|
-
const screenshotPath = screenshotDir ?
|
|
39360
|
+
const screenshotPath = screenshotDir ? join21(screenshotDir, "capture.png") : null;
|
|
39239
39361
|
const staleNote = this.runtimeStaleNote(choice.endpoint);
|
|
39240
39362
|
const stale = staleNote === undefined ? {} : { addonIsStale: true, staleNote };
|
|
39241
39363
|
const refused = (message) => this.createErrorResponse(staleNote === undefined ? message : `${message}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gdharness",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.47",
|
|
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",
|