gdharness 1.1.6 → 1.1.7
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 +64 -28
- package/build/index.js +61 -24
- package/build/keeper.js +89 -22
- package/package.json +1 -1
package/build/cli.js
CHANGED
|
@@ -385,10 +385,10 @@ function runArguments(options) {
|
|
|
385
385
|
}
|
|
386
386
|
return args;
|
|
387
387
|
}
|
|
388
|
-
function editorArguments(projectPath, ports, logFile) {
|
|
388
|
+
function editorArguments(projectPath, ports, logFile, hidden) {
|
|
389
389
|
return [
|
|
390
390
|
"-e",
|
|
391
|
-
"--headless",
|
|
391
|
+
...hidden ? ["--headless"] : [],
|
|
392
392
|
"--path",
|
|
393
393
|
projectPath,
|
|
394
394
|
"--lsp-port",
|
|
@@ -16312,8 +16312,15 @@ var init_tool_definitions = __esm(() => {
|
|
|
16312
16312
|
},
|
|
16313
16313
|
{
|
|
16314
16314
|
name: "editor_launch",
|
|
16315
|
-
description: "Opens the Godot editor on a project, or restarts the one already connected.
|
|
16316
|
-
parameters: {
|
|
16315
|
+
description: "Opens the Godot editor on a project, or restarts the one already connected. The editor is an ordinary visible one on the desktop in use, and on Windows it is shown without being activated, so opening or reopening it does not take the keyboard from whatever the user is typing in. hidden: true opens it headless instead and, on Windows, on a desktop of its own, where the games it plays go too; a hidden editor serves the bridge, the language server and the debug adapter as a visible one does. A game a visible editor plays opens as a window on the desktop in use. An editor goes on serving the addon it read at startup, so restart is what puts a gdharness upgrade into effect, and nothing else here restarts one; it saves open scenes on the way out and answers with the version that came back. An editor a server opened is started again by the server, visible unless hidden: true is passed again. One opened by hand restarts itself, which only one with a window can do, because the engine hands back none of the arguments it was started with. editor_status says which editor is connected and whether it is holding an old addon. Opening a project is a save and so is closing one, which is the part that is not obvious from outside: the editor imports the project on open and writes project.godot back, and Godot writes only what differs from its own defaults, so a key named deliberately at its default value is dropped either way. A restart says which under settingsDropped in its own answer because it waits for the editor to return. An open cannot, since it answers as soon as the process exists and the save happens during the import minutes later, so editor_status carries that reading once the editor has connected. Nothing else will say a key went until something depends on one. A restart of an editor this server opened is a quit and then a launch, and this server can be ended between them, usually by the reconnect the restart was being taken for: the editor is gone and nothing replaces it. That is written down before the quit, so the next server says so under restartInterrupted in editor_status rather than waiting for an editor that is not coming, and open finishes it, saying so under finishesRestart.",
|
|
16316
|
+
parameters: {
|
|
16317
|
+
projectPath: { ...PROJECT_PATH, ops: ["open"] },
|
|
16318
|
+
hidden: {
|
|
16319
|
+
type: "boolean",
|
|
16320
|
+
ops: ["open", "restart"],
|
|
16321
|
+
description: "Open the editor headless and, on Windows, on a desktop of its own, so no window of it or of the games it plays appears. Default false: one visible editor, shown without taking the focus."
|
|
16322
|
+
}
|
|
16323
|
+
},
|
|
16317
16324
|
requires: [],
|
|
16318
16325
|
operations: {
|
|
16319
16326
|
open: { summary: "open the editor on a project", requires: ["projectPath"] },
|
|
@@ -16341,7 +16348,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
16341
16348
|
visible: {
|
|
16342
16349
|
type: "boolean",
|
|
16343
16350
|
ops: ["start"],
|
|
16344
|
-
description: "start: on Windows, show the window on the desktop in use. By default a windowed run this server starts goes on a desktop of its own, where it renders and runtime_capture works but the window never shows and never takes the keyboard. A game
|
|
16351
|
+
description: "start: on Windows, show the window on the desktop in use. By default a windowed run this server starts goes on a desktop of its own, where it renders and runtime_capture works but the window never shows and never takes the keyboard. A game a visible editor plays opens on the desktop in use, and one a hidden editor (editor_launch hidden: true) plays goes on its desktop. Elsewhere than Windows the window shows either way. Default false."
|
|
16345
16352
|
},
|
|
16346
16353
|
savesIn: {
|
|
16347
16354
|
type: "string",
|
|
@@ -37148,6 +37155,7 @@ function withActualsPrinted(failed, printed) {
|
|
|
37148
37155
|
return entry;
|
|
37149
37156
|
}
|
|
37150
37157
|
let detail = entry.detail;
|
|
37158
|
+
let recovered = false;
|
|
37151
37159
|
for (const diff of diffs) {
|
|
37152
37160
|
const wrong = ` but was
|
|
37153
37161
|
'${diff.merged.replaceAll("\r", "")}'
|
|
@@ -37156,12 +37164,13 @@ function withActualsPrinted(failed, printed) {
|
|
|
37156
37164
|
detail = detail.replace(wrong, () => ` but was
|
|
37157
37165
|
'${diff.actual}'
|
|
37158
37166
|
at ${diff.at}`);
|
|
37167
|
+
recovered = true;
|
|
37159
37168
|
}
|
|
37160
37169
|
}
|
|
37161
|
-
|
|
37162
|
-
|
|
37170
|
+
if (!recovered && /Expecting:\n '[\s\S]*'\n but was\n '[\s\S]*'\n\tat /.test(detail)) {
|
|
37171
|
+
const alike = /Expecting:\n '([\s\S]*)'\n but was\n '\1'\n\tat /.test(detail);
|
|
37163
37172
|
detail = `${detail}
|
|
37164
|
-
${BOTH_SIDES_ALIKE_NOTE}`;
|
|
37173
|
+
${alike ? BOTH_SIDES_ALIKE_NOTE : VALUE_NOT_RECOVERED_NOTE}`;
|
|
37165
37174
|
}
|
|
37166
37175
|
return detail === entry.detail ? entry : { ...entry, detail };
|
|
37167
37176
|
});
|
|
@@ -37196,7 +37205,7 @@ function orphansPrinted(printed) {
|
|
|
37196
37205
|
suites
|
|
37197
37206
|
};
|
|
37198
37207
|
}
|
|
37199
|
-
var MalformedReportError, ENTITIES, ATTRIBUTE, NOTHING_RAN, NO_SUCH_PATH, ESCAPE, 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.]", RUNNING_SUITE, ORPHANS, OVERALL = "Overall Summary";
|
|
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";
|
|
37200
37209
|
var init_junit = __esm(() => {
|
|
37201
37210
|
MalformedReportError = class MalformedReportError extends Error {
|
|
37202
37211
|
constructor(message) {
|
|
@@ -37209,7 +37218,7 @@ var init_junit = __esm(() => {
|
|
|
37209
37218
|
NOTHING_RAN = /no test cases found/i;
|
|
37210
37219
|
NO_SUCH_PATH = /given directory or file does not exists:\s*(\S+)/i;
|
|
37211
37220
|
ESCAPE = String.fromCharCode(27);
|
|
37212
|
-
PRINTED_ACTUAL = new RegExp(` but was\\n '([\\s\\S]*?)'((?:${ESCAPE}\\[[0-9;]*m)*)\\tat (
|
|
37221
|
+
PRINTED_ACTUAL = new RegExp(` but was\\n '((?:(?!\\tat ${LOCATION})[\\s\\S])*?)'((?:${ESCAPE}\\[[0-9;]*m)*)\\tat (${LOCATION})`, "g");
|
|
37213
37222
|
COLOUR2 = new RegExp(`${ESCAPE}\\[([0-9;]*)m`, "g");
|
|
37214
37223
|
SPELLED = {
|
|
37215
37224
|
"<LF>": `
|
|
@@ -37807,7 +37816,8 @@ async function launchOutsideTheTree(spec, runtime = process.execPath, keeper = K
|
|
|
37807
37816
|
args: spec.args,
|
|
37808
37817
|
envChanges: changesFrom(spec.env, process.env),
|
|
37809
37818
|
...spec.run === undefined ? {} : { run: spec.run },
|
|
37810
|
-
...spec.desktop === undefined ? {} : { desktop: spec.desktop }
|
|
37819
|
+
...spec.desktop === undefined ? {} : { desktop: spec.desktop },
|
|
37820
|
+
...spec.noActivate === true ? { noActivate: true } : {}
|
|
37811
37821
|
});
|
|
37812
37822
|
let printed = "";
|
|
37813
37823
|
let complained = "";
|
|
@@ -38818,6 +38828,16 @@ function changedSettings(before, after) {
|
|
|
38818
38828
|
const names = new Set([...before.keys(), ...after.keys()]);
|
|
38819
38829
|
return [...names].filter((name) => JSON.stringify(before.get(name)) !== JSON.stringify(after.get(name))).sort();
|
|
38820
38830
|
}
|
|
38831
|
+
function timedOutVerdict(timeoutMs, silentForMs) {
|
|
38832
|
+
const quiet = Math.round(silentForMs);
|
|
38833
|
+
return quiet >= Math.min(STALLED_AFTER_MS, timeoutMs / 2) ? {
|
|
38834
|
+
verdict: `hung: killed after ${timeoutMs} ms, having printed nothing for the last ${quiet} ms`,
|
|
38835
|
+
hung: true
|
|
38836
|
+
} : {
|
|
38837
|
+
verdict: `timed out after ${timeoutMs} ms while still running: it last printed ${quiet} ms before it was killed, so pass a longer timeoutMs or a narrower path`,
|
|
38838
|
+
hung: false
|
|
38839
|
+
};
|
|
38840
|
+
}
|
|
38821
38841
|
function importStatuses(payload) {
|
|
38822
38842
|
const listed = payload["resources"];
|
|
38823
38843
|
if (!Array.isArray(listed)) {
|
|
@@ -39196,9 +39216,12 @@ class GodotServer {
|
|
|
39196
39216
|
try {
|
|
39197
39217
|
return await this.dispatch(tool, op, args);
|
|
39198
39218
|
} catch (error) {
|
|
39199
|
-
if (error instanceof McpError
|
|
39219
|
+
if (error instanceof McpError) {
|
|
39200
39220
|
throw error;
|
|
39201
39221
|
}
|
|
39222
|
+
if (error instanceof Refusal) {
|
|
39223
|
+
return this.createErrorResponse(error.message);
|
|
39224
|
+
}
|
|
39202
39225
|
const where = op === "" ? tool : `${tool} op=${op}`;
|
|
39203
39226
|
console.error(`[SERVER] Unmodelled failure in ${where}:`, error);
|
|
39204
39227
|
const seenBefore = this.defectsSeen.before(where, errorMessage(error));
|
|
@@ -39478,7 +39501,7 @@ class GodotServer {
|
|
|
39478
39501
|
return await bridge("set_theme_font_size", { themePath: args["resourcePath"] });
|
|
39479
39502
|
}
|
|
39480
39503
|
case "editor_launch":
|
|
39481
|
-
return op === "restart" ? await this.handleRestartEditor() : await this.handleLaunchEditor(args);
|
|
39504
|
+
return op === "restart" ? await this.handleRestartEditor(args) : await this.handleLaunchEditor(args);
|
|
39482
39505
|
case "editor_run":
|
|
39483
39506
|
if (op === "stop") {
|
|
39484
39507
|
return await this.handleStopProject(args);
|
|
@@ -40329,11 +40352,18 @@ class GodotServer {
|
|
|
40329
40352
|
const userData = mkdtempSync3(join24(tmpdir5(), "gdharness-tests-"));
|
|
40330
40353
|
const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
|
|
40331
40354
|
const console2 = [];
|
|
40355
|
+
let lastSaid = Date.now();
|
|
40332
40356
|
run.process.stdout?.on("data", (data) => {
|
|
40333
40357
|
console2.push(data);
|
|
40358
|
+
lastSaid = Date.now();
|
|
40334
40359
|
});
|
|
40335
|
-
|
|
40360
|
+
run.process.stderr?.on("data", () => {
|
|
40361
|
+
lastSaid = Date.now();
|
|
40362
|
+
});
|
|
40363
|
+
let silentForMs = 0;
|
|
40364
|
+
const timedOut = await new Promise((resolve) => {
|
|
40336
40365
|
const timer = setTimeout(() => {
|
|
40366
|
+
silentForMs = Date.now() - lastSaid;
|
|
40337
40367
|
run.process.kill();
|
|
40338
40368
|
resolve(true);
|
|
40339
40369
|
}, timeoutMs);
|
|
@@ -40375,8 +40405,10 @@ class GodotServer {
|
|
|
40375
40405
|
const exitCode = run.exitCode;
|
|
40376
40406
|
const printed = run.log.select({ severity: "info", sinceLastCall: false, limit: 200 }).entries;
|
|
40377
40407
|
const said = printed.map((entry) => entry.text);
|
|
40378
|
-
const nothingRan =
|
|
40379
|
-
const
|
|
40408
|
+
const nothingRan = timedOut ? null : whyNoReport(said, asked);
|
|
40409
|
+
const cutShort = timedOut ? timedOutVerdict(timeoutMs, silentForMs) : null;
|
|
40410
|
+
const hung = cutShort?.hung ?? false;
|
|
40411
|
+
const verdict = cutShort !== null ? cutShort.verdict : nothingRan ?? (exitCode === null ? undefined : verdicts[exitCode]) ?? (run.exitSignal === null ? `exit ${exitCode ?? "unknown"}` : howItExited(run));
|
|
40380
40412
|
const savesNote = savesStayPut() && nothingRan === null ? SAVES_NOT_MOVED_NOTE : undefined;
|
|
40381
40413
|
if (report === null) {
|
|
40382
40414
|
const note = nothingRan === null ? `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).` : `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}`;
|
|
@@ -40392,6 +40424,7 @@ class GodotServer {
|
|
|
40392
40424
|
exitCode,
|
|
40393
40425
|
exitSignal: run.exitSignal ?? undefined,
|
|
40394
40426
|
hung,
|
|
40427
|
+
...timedOut ? { timedOut, silentForMs } : {},
|
|
40395
40428
|
arguments: cmdArgs,
|
|
40396
40429
|
entries: forAnswer(printed.slice(0, 60)),
|
|
40397
40430
|
savesNote,
|
|
@@ -40416,8 +40449,9 @@ class GodotServer {
|
|
|
40416
40449
|
const unclean = report.suites.filter((suite) => suite.failures > 0 || suite.errors > 0 || suite.skipped > 0 || orphansIn(suite.path) > 0);
|
|
40417
40450
|
const notRun = report.suites.reduce((sum, suite) => sum + Math.max(0, suite.discovered - suite.tests), 0);
|
|
40418
40451
|
return this.jsonTextResponse({
|
|
40419
|
-
passed: !
|
|
40452
|
+
passed: !timedOut && exitCode === 0 && report.failures === 0 && report.errors === 0,
|
|
40420
40453
|
verdict,
|
|
40454
|
+
...timedOut ? { timedOut, hung, silentForMs } : {},
|
|
40421
40455
|
exitCode,
|
|
40422
40456
|
exitSignal: run.exitSignal ?? undefined,
|
|
40423
40457
|
tests: report.tests,
|
|
@@ -40828,7 +40862,7 @@ class GodotServer {
|
|
|
40828
40862
|
return null;
|
|
40829
40863
|
}
|
|
40830
40864
|
}
|
|
40831
|
-
async handleRestartEditor() {
|
|
40865
|
+
async handleRestartEditor(args) {
|
|
40832
40866
|
const before = this.godotBridge.getStatus();
|
|
40833
40867
|
if (!before.connected) {
|
|
40834
40868
|
return this.createErrorResponse("No editor is connected, so there is nothing to restart.", [
|
|
@@ -40838,7 +40872,7 @@ class GodotServer {
|
|
|
40838
40872
|
}
|
|
40839
40873
|
const settingsBefore = this.settingKeysOf(before.projectPath);
|
|
40840
40874
|
const mine = before.openedByAServer === true && before.projectPath !== undefined;
|
|
40841
|
-
const asked = mine ? await this.startItAgain(before.projectPath ?? "", before.editorPid) : await this.handleViaBridge("restart_editor", {});
|
|
40875
|
+
const asked = mine ? await this.startItAgain(before.projectPath ?? "", before.editorPid, args["hidden"] === true) : await this.handleViaBridge("restart_editor", {});
|
|
40842
40876
|
if (asked.isError === true) {
|
|
40843
40877
|
return asked;
|
|
40844
40878
|
}
|
|
@@ -40876,7 +40910,7 @@ class GodotServer {
|
|
|
40876
40910
|
whatTheEditorDropped(before, after) {
|
|
40877
40911
|
return settingsDroppedReport(before, after);
|
|
40878
40912
|
}
|
|
40879
|
-
async startItAgain(projectPath, editorPid) {
|
|
40913
|
+
async startItAgain(projectPath, editorPid, hidden) {
|
|
40880
40914
|
const engine = await this.engine();
|
|
40881
40915
|
if (!engine.ok) {
|
|
40882
40916
|
return engine.response;
|
|
@@ -40898,7 +40932,7 @@ class GodotServer {
|
|
|
40898
40932
|
return asked;
|
|
40899
40933
|
}
|
|
40900
40934
|
await this.waitForBridge(() => !alive(editorPid), Date.now() + EDITOR_RESTART_TIMEOUT_MS);
|
|
40901
|
-
const opened = await this.openAnEditor(engine.value, projectPath, ports);
|
|
40935
|
+
const opened = await this.openAnEditor(engine.value, projectPath, ports, hidden);
|
|
40902
40936
|
restartSettled(projectPath);
|
|
40903
40937
|
if (opened.error !== null) {
|
|
40904
40938
|
return this.createErrorResponse(`The editor was asked to go and could not be started again: ${opened.error}`, [`editor_launch opens one on ${projectPath}`]);
|
|
@@ -40936,7 +40970,7 @@ class GodotServer {
|
|
|
40936
40970
|
const ports = await this.portsForAnEditor();
|
|
40937
40971
|
const before = this.settingKeysOf(project.value.path);
|
|
40938
40972
|
const unfinished = restartOwed(project.value.path);
|
|
40939
|
-
const opened = await this.openAnEditor(engine.value, project.value.path, ports);
|
|
40973
|
+
const opened = await this.openAnEditor(engine.value, project.value.path, ports, args["hidden"] === true);
|
|
40940
40974
|
if (opened.error !== null) {
|
|
40941
40975
|
return this.createErrorResponse(`Could not start the editor: ${opened.error}`);
|
|
40942
40976
|
}
|
|
@@ -40952,11 +40986,11 @@ class GodotServer {
|
|
|
40952
40986
|
settingsNote: before === null ? "project.godot could not be read before this editor was opened, so nothing here will be able to say what the import saved away. Opening a project rewrites the file and drops any key sitting at its own default." : before.size === 0 ? undefined : "Opening a project imports it and saves project.godot, and Godot drops any key sitting at its own default. editor_status names anything lost under settingsDropped once this editor has connected."
|
|
40953
40987
|
});
|
|
40954
40988
|
}
|
|
40955
|
-
async openAnEditor(engine, projectPath, ports) {
|
|
40989
|
+
async openAnEditor(engine, projectPath, ports, hidden) {
|
|
40956
40990
|
clearEditorLog(projectPath);
|
|
40957
40991
|
const launched = await launchOutsideTheTree({
|
|
40958
40992
|
command: engine,
|
|
40959
|
-
args: editorArguments(projectPath, ports, editorLogPath(projectPath)),
|
|
40993
|
+
args: editorArguments(projectPath, ports, editorLogPath(projectPath), hidden),
|
|
40960
40994
|
env: {
|
|
40961
40995
|
...process.env,
|
|
40962
40996
|
GDHARNESS_RUNTIME_DIR: runtimeDirectory(),
|
|
@@ -40964,7 +40998,7 @@ class GodotServer {
|
|
|
40964
40998
|
GDHARNESS_DAP_PORT: String(ports.dap),
|
|
40965
40999
|
[OPENED_BY_A_SERVER]: "1"
|
|
40966
41000
|
},
|
|
40967
|
-
desktop: HIDDEN_DESKTOP
|
|
41001
|
+
...hidden ? { desktop: HIDDEN_DESKTOP } : { noActivate: true }
|
|
40968
41002
|
});
|
|
40969
41003
|
if ("error" in launched) {
|
|
40970
41004
|
return { pid: null, error: launched.error };
|
|
@@ -42624,7 +42658,7 @@ async function runGodotServer() {
|
|
|
42624
42658
|
const server = new GodotServer;
|
|
42625
42659
|
await server.run();
|
|
42626
42660
|
}
|
|
42627
|
-
var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run7, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, PLAY_STARTS_WITHIN_MS = 30000, STOP_WAIT_MS = 1e4, EXIT_REPORTED_WITHIN_MS = 2000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, MOST_GROUPS = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, HANDOVER_MS, HANDOVER_POLL_MS = 250, CACHE_WRITE_MS = 2000, SCAN_WAIT_MS = 30000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, SCAN_SETTLED_MS = 500, REIMPORT_TIMEOUT_MS = 300000, AUDIO_BUS_OPS, WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/";
|
|
42661
|
+
var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run7, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, PLAY_STARTS_WITHIN_MS = 30000, STOP_WAIT_MS = 1e4, EXIT_REPORTED_WITHIN_MS = 2000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, MOST_GROUPS = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, HANDOVER_MS, HANDOVER_POLL_MS = 250, CACHE_WRITE_MS = 2000, SCAN_WAIT_MS = 30000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, SCAN_SETTLED_MS = 500, REIMPORT_TIMEOUT_MS = 300000, AUDIO_BUS_OPS, STALLED_AFTER_MS = 30000, WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/";
|
|
42628
42662
|
var init_server3 = __esm(() => {
|
|
42629
42663
|
init_mcp();
|
|
42630
42664
|
init_stdio2();
|
|
@@ -44346,7 +44380,9 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
|
|
|
44346
44380
|
| A picture, for a person who asked to see one | \`runtime_capture\` |
|
|
44347
44381
|
|
|
44348
44382
|
These need the runtime autoload, which \`gdharness setup\` registers. \`runtime_capture\` needs a
|
|
44349
|
-
window and refuses headless rather than handing back the last frame anything drew.
|
|
44383
|
+
window and refuses headless rather than handing back the last frame anything drew. On Windows a
|
|
44384
|
+
windowed run this server starts is on a desktop of its own, where it draws and can be captured but
|
|
44385
|
+
never shows or takes the keyboard.
|
|
44350
44386
|
|
|
44351
44387
|
The autoload reads these settings and no others. \`${BIND_ADDRESS_SETTING}\`
|
|
44352
44388
|
is \`127.0.0.1\` and should stay there: the command set includes \`call_method\`, \`set_property\`
|
|
@@ -44398,7 +44434,7 @@ them. \`editor_rescan\` asks it to scan and names any class it still cannot reso
|
|
|
44398
44434
|
\`unseenByEditor\`: its walk skips a file a headless engine has already imported, so the
|
|
44399
44435
|
declaration and the cache can both be right while the editor stays blind to it. Rescan again on
|
|
44400
44436
|
its own, which is the measured cure and needs no change to the declaring script; \`editor_launch
|
|
44401
|
-
restart\` also does it and costs a
|
|
44437
|
+
restart\` also does it and costs a restart of the editor.
|
|
44402
44438
|
|
|
44403
44439
|
The scan writes \`.godot/global_script_class_cache.cfg\` from the list the editor is holding, so
|
|
44404
44440
|
a class it cannot resolve would go out of that file with it. The answer names that loss under
|
package/build/index.js
CHANGED
|
@@ -24808,10 +24808,10 @@ function runArguments(options) {
|
|
|
24808
24808
|
}
|
|
24809
24809
|
return args;
|
|
24810
24810
|
}
|
|
24811
|
-
function editorArguments(projectPath, ports, logFile) {
|
|
24811
|
+
function editorArguments(projectPath, ports, logFile, hidden) {
|
|
24812
24812
|
return [
|
|
24813
24813
|
"-e",
|
|
24814
|
-
"--headless",
|
|
24814
|
+
...hidden ? ["--headless"] : [],
|
|
24815
24815
|
"--path",
|
|
24816
24816
|
projectPath,
|
|
24817
24817
|
"--lsp-port",
|
|
@@ -28256,7 +28256,8 @@ function whyNoReport(printed, asked) {
|
|
|
28256
28256
|
return printed.some((line) => NOTHING_RAN.test(line)) ? `no test cases found at ${asked}` : null;
|
|
28257
28257
|
}
|
|
28258
28258
|
var ESCAPE = String.fromCharCode(27);
|
|
28259
|
-
var
|
|
28259
|
+
var LOCATION = `'[^'\\n]*' in \\S+:\\d+`;
|
|
28260
|
+
var PRINTED_ACTUAL = new RegExp(` but was\\n '((?:(?!\\tat ${LOCATION})[\\s\\S])*?)'((?:${ESCAPE}\\[[0-9;]*m)*)\\tat (${LOCATION})`, "g");
|
|
28260
28261
|
var COLOUR = new RegExp(`${ESCAPE}\\[([0-9;]*)m`, "g");
|
|
28261
28262
|
var SPELLED = {
|
|
28262
28263
|
"<LF>": `
|
|
@@ -28300,6 +28301,7 @@ function readDiff(printed) {
|
|
|
28300
28301
|
return { merged, actual };
|
|
28301
28302
|
}
|
|
28302
28303
|
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
|
+
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.]';
|
|
28303
28305
|
function withActualsPrinted(failed, printed) {
|
|
28304
28306
|
const diffs = [...printed.replaceAll(`\r
|
|
28305
28307
|
`, `
|
|
@@ -28312,6 +28314,7 @@ function withActualsPrinted(failed, printed) {
|
|
|
28312
28314
|
return entry;
|
|
28313
28315
|
}
|
|
28314
28316
|
let detail = entry.detail;
|
|
28317
|
+
let recovered = false;
|
|
28315
28318
|
for (const diff of diffs) {
|
|
28316
28319
|
const wrong = ` but was
|
|
28317
28320
|
'${diff.merged.replaceAll("\r", "")}'
|
|
@@ -28320,12 +28323,13 @@ function withActualsPrinted(failed, printed) {
|
|
|
28320
28323
|
detail = detail.replace(wrong, () => ` but was
|
|
28321
28324
|
'${diff.actual}'
|
|
28322
28325
|
at ${diff.at}`);
|
|
28326
|
+
recovered = true;
|
|
28323
28327
|
}
|
|
28324
28328
|
}
|
|
28325
|
-
|
|
28326
|
-
|
|
28329
|
+
if (!recovered && /Expecting:\n '[\s\S]*'\n but was\n '[\s\S]*'\n\tat /.test(detail)) {
|
|
28330
|
+
const alike = /Expecting:\n '([\s\S]*)'\n but was\n '\1'\n\tat /.test(detail);
|
|
28327
28331
|
detail = `${detail}
|
|
28328
|
-
${BOTH_SIDES_ALIKE_NOTE}`;
|
|
28332
|
+
${alike ? BOTH_SIDES_ALIKE_NOTE : VALUE_NOT_RECOVERED_NOTE}`;
|
|
28329
28333
|
}
|
|
28330
28334
|
return detail === entry.detail ? entry : { ...entry, detail };
|
|
28331
28335
|
});
|
|
@@ -28940,7 +28944,8 @@ async function launchOutsideTheTree(spec, runtime = process.execPath, keeper = K
|
|
|
28940
28944
|
args: spec.args,
|
|
28941
28945
|
envChanges: changesFrom(spec.env, process.env),
|
|
28942
28946
|
...spec.run === undefined ? {} : { run: spec.run },
|
|
28943
|
-
...spec.desktop === undefined ? {} : { desktop: spec.desktop }
|
|
28947
|
+
...spec.desktop === undefined ? {} : { desktop: spec.desktop },
|
|
28948
|
+
...spec.noActivate === true ? { noActivate: true } : {}
|
|
28944
28949
|
});
|
|
28945
28950
|
let printed = "";
|
|
28946
28951
|
let complained = "";
|
|
@@ -30462,8 +30467,15 @@ var TOOL_SPECS = [
|
|
|
30462
30467
|
},
|
|
30463
30468
|
{
|
|
30464
30469
|
name: "editor_launch",
|
|
30465
|
-
description: "Opens the Godot editor on a project, or restarts the one already connected.
|
|
30466
|
-
parameters: {
|
|
30470
|
+
description: "Opens the Godot editor on a project, or restarts the one already connected. The editor is an ordinary visible one on the desktop in use, and on Windows it is shown without being activated, so opening or reopening it does not take the keyboard from whatever the user is typing in. hidden: true opens it headless instead and, on Windows, on a desktop of its own, where the games it plays go too; a hidden editor serves the bridge, the language server and the debug adapter as a visible one does. A game a visible editor plays opens as a window on the desktop in use. An editor goes on serving the addon it read at startup, so restart is what puts a gdharness upgrade into effect, and nothing else here restarts one; it saves open scenes on the way out and answers with the version that came back. An editor a server opened is started again by the server, visible unless hidden: true is passed again. One opened by hand restarts itself, which only one with a window can do, because the engine hands back none of the arguments it was started with. editor_status says which editor is connected and whether it is holding an old addon. Opening a project is a save and so is closing one, which is the part that is not obvious from outside: the editor imports the project on open and writes project.godot back, and Godot writes only what differs from its own defaults, so a key named deliberately at its default value is dropped either way. A restart says which under settingsDropped in its own answer because it waits for the editor to return. An open cannot, since it answers as soon as the process exists and the save happens during the import minutes later, so editor_status carries that reading once the editor has connected. Nothing else will say a key went until something depends on one. A restart of an editor this server opened is a quit and then a launch, and this server can be ended between them, usually by the reconnect the restart was being taken for: the editor is gone and nothing replaces it. That is written down before the quit, so the next server says so under restartInterrupted in editor_status rather than waiting for an editor that is not coming, and open finishes it, saying so under finishesRestart.",
|
|
30471
|
+
parameters: {
|
|
30472
|
+
projectPath: { ...PROJECT_PATH, ops: ["open"] },
|
|
30473
|
+
hidden: {
|
|
30474
|
+
type: "boolean",
|
|
30475
|
+
ops: ["open", "restart"],
|
|
30476
|
+
description: "Open the editor headless and, on Windows, on a desktop of its own, so no window of it or of the games it plays appears. Default false: one visible editor, shown without taking the focus."
|
|
30477
|
+
}
|
|
30478
|
+
},
|
|
30467
30479
|
requires: [],
|
|
30468
30480
|
operations: {
|
|
30469
30481
|
open: { summary: "open the editor on a project", requires: ["projectPath"] },
|
|
@@ -30491,7 +30503,7 @@ var TOOL_SPECS = [
|
|
|
30491
30503
|
visible: {
|
|
30492
30504
|
type: "boolean",
|
|
30493
30505
|
ops: ["start"],
|
|
30494
|
-
description: "start: on Windows, show the window on the desktop in use. By default a windowed run this server starts goes on a desktop of its own, where it renders and runtime_capture works but the window never shows and never takes the keyboard. A game
|
|
30506
|
+
description: "start: on Windows, show the window on the desktop in use. By default a windowed run this server starts goes on a desktop of its own, where it renders and runtime_capture works but the window never shows and never takes the keyboard. A game a visible editor plays opens on the desktop in use, and one a hidden editor (editor_launch hidden: true) plays goes on its desktop. Elsewhere than Windows the window shows either way. Default false."
|
|
30495
30507
|
},
|
|
30496
30508
|
savesIn: {
|
|
30497
30509
|
type: "string",
|
|
@@ -31530,6 +31542,17 @@ function changedSettings(before, after) {
|
|
|
31530
31542
|
const names = new Set([...before.keys(), ...after.keys()]);
|
|
31531
31543
|
return [...names].filter((name) => JSON.stringify(before.get(name)) !== JSON.stringify(after.get(name))).sort();
|
|
31532
31544
|
}
|
|
31545
|
+
var STALLED_AFTER_MS = 30000;
|
|
31546
|
+
function timedOutVerdict(timeoutMs, silentForMs) {
|
|
31547
|
+
const quiet = Math.round(silentForMs);
|
|
31548
|
+
return quiet >= Math.min(STALLED_AFTER_MS, timeoutMs / 2) ? {
|
|
31549
|
+
verdict: `hung: killed after ${timeoutMs} ms, having printed nothing for the last ${quiet} ms`,
|
|
31550
|
+
hung: true
|
|
31551
|
+
} : {
|
|
31552
|
+
verdict: `timed out after ${timeoutMs} ms while still running: it last printed ${quiet} ms before it was killed, so pass a longer timeoutMs or a narrower path`,
|
|
31553
|
+
hung: false
|
|
31554
|
+
};
|
|
31555
|
+
}
|
|
31533
31556
|
function importStatuses(payload) {
|
|
31534
31557
|
const listed = payload["resources"];
|
|
31535
31558
|
if (!Array.isArray(listed)) {
|
|
@@ -31939,9 +31962,12 @@ class GodotServer {
|
|
|
31939
31962
|
try {
|
|
31940
31963
|
return await this.dispatch(tool, op, args);
|
|
31941
31964
|
} catch (error) {
|
|
31942
|
-
if (error instanceof McpError
|
|
31965
|
+
if (error instanceof McpError) {
|
|
31943
31966
|
throw error;
|
|
31944
31967
|
}
|
|
31968
|
+
if (error instanceof Refusal) {
|
|
31969
|
+
return this.createErrorResponse(error.message);
|
|
31970
|
+
}
|
|
31945
31971
|
const where = op === "" ? tool : `${tool} op=${op}`;
|
|
31946
31972
|
console.error(`[SERVER] Unmodelled failure in ${where}:`, error);
|
|
31947
31973
|
const seenBefore = this.defectsSeen.before(where, errorMessage(error));
|
|
@@ -32221,7 +32247,7 @@ class GodotServer {
|
|
|
32221
32247
|
return await bridge("set_theme_font_size", { themePath: args["resourcePath"] });
|
|
32222
32248
|
}
|
|
32223
32249
|
case "editor_launch":
|
|
32224
|
-
return op === "restart" ? await this.handleRestartEditor() : await this.handleLaunchEditor(args);
|
|
32250
|
+
return op === "restart" ? await this.handleRestartEditor(args) : await this.handleLaunchEditor(args);
|
|
32225
32251
|
case "editor_run":
|
|
32226
32252
|
if (op === "stop") {
|
|
32227
32253
|
return await this.handleStopProject(args);
|
|
@@ -33072,11 +33098,18 @@ class GodotServer {
|
|
|
33072
33098
|
const userData = mkdtempSync2(join17(tmpdir4(), "gdharness-tests-"));
|
|
33073
33099
|
const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
|
|
33074
33100
|
const console2 = [];
|
|
33101
|
+
let lastSaid = Date.now();
|
|
33075
33102
|
run.process.stdout?.on("data", (data) => {
|
|
33076
33103
|
console2.push(data);
|
|
33104
|
+
lastSaid = Date.now();
|
|
33077
33105
|
});
|
|
33078
|
-
|
|
33106
|
+
run.process.stderr?.on("data", () => {
|
|
33107
|
+
lastSaid = Date.now();
|
|
33108
|
+
});
|
|
33109
|
+
let silentForMs = 0;
|
|
33110
|
+
const timedOut = await new Promise((resolve) => {
|
|
33079
33111
|
const timer = setTimeout(() => {
|
|
33112
|
+
silentForMs = Date.now() - lastSaid;
|
|
33080
33113
|
run.process.kill();
|
|
33081
33114
|
resolve(true);
|
|
33082
33115
|
}, timeoutMs);
|
|
@@ -33118,8 +33151,10 @@ class GodotServer {
|
|
|
33118
33151
|
const exitCode = run.exitCode;
|
|
33119
33152
|
const printed = run.log.select({ severity: "info", sinceLastCall: false, limit: 200 }).entries;
|
|
33120
33153
|
const said = printed.map((entry) => entry.text);
|
|
33121
|
-
const nothingRan =
|
|
33122
|
-
const
|
|
33154
|
+
const nothingRan = timedOut ? null : whyNoReport(said, asked);
|
|
33155
|
+
const cutShort = timedOut ? timedOutVerdict(timeoutMs, silentForMs) : null;
|
|
33156
|
+
const hung = cutShort?.hung ?? false;
|
|
33157
|
+
const verdict = cutShort !== null ? cutShort.verdict : nothingRan ?? (exitCode === null ? undefined : verdicts[exitCode]) ?? (run.exitSignal === null ? `exit ${exitCode ?? "unknown"}` : howItExited(run));
|
|
33123
33158
|
const savesNote = savesStayPut() && nothingRan === null ? SAVES_NOT_MOVED_NOTE : undefined;
|
|
33124
33159
|
if (report === null) {
|
|
33125
33160
|
const note = nothingRan === null ? `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).` : `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}`;
|
|
@@ -33135,6 +33170,7 @@ class GodotServer {
|
|
|
33135
33170
|
exitCode,
|
|
33136
33171
|
exitSignal: run.exitSignal ?? undefined,
|
|
33137
33172
|
hung,
|
|
33173
|
+
...timedOut ? { timedOut, silentForMs } : {},
|
|
33138
33174
|
arguments: cmdArgs,
|
|
33139
33175
|
entries: forAnswer(printed.slice(0, 60)),
|
|
33140
33176
|
savesNote,
|
|
@@ -33159,8 +33195,9 @@ class GodotServer {
|
|
|
33159
33195
|
const unclean = report.suites.filter((suite) => suite.failures > 0 || suite.errors > 0 || suite.skipped > 0 || orphansIn(suite.path) > 0);
|
|
33160
33196
|
const notRun = report.suites.reduce((sum, suite) => sum + Math.max(0, suite.discovered - suite.tests), 0);
|
|
33161
33197
|
return this.jsonTextResponse({
|
|
33162
|
-
passed: !
|
|
33198
|
+
passed: !timedOut && exitCode === 0 && report.failures === 0 && report.errors === 0,
|
|
33163
33199
|
verdict,
|
|
33200
|
+
...timedOut ? { timedOut, hung, silentForMs } : {},
|
|
33164
33201
|
exitCode,
|
|
33165
33202
|
exitSignal: run.exitSignal ?? undefined,
|
|
33166
33203
|
tests: report.tests,
|
|
@@ -33571,7 +33608,7 @@ class GodotServer {
|
|
|
33571
33608
|
return null;
|
|
33572
33609
|
}
|
|
33573
33610
|
}
|
|
33574
|
-
async handleRestartEditor() {
|
|
33611
|
+
async handleRestartEditor(args) {
|
|
33575
33612
|
const before = this.godotBridge.getStatus();
|
|
33576
33613
|
if (!before.connected) {
|
|
33577
33614
|
return this.createErrorResponse("No editor is connected, so there is nothing to restart.", [
|
|
@@ -33581,7 +33618,7 @@ class GodotServer {
|
|
|
33581
33618
|
}
|
|
33582
33619
|
const settingsBefore = this.settingKeysOf(before.projectPath);
|
|
33583
33620
|
const mine = before.openedByAServer === true && before.projectPath !== undefined;
|
|
33584
|
-
const asked = mine ? await this.startItAgain(before.projectPath ?? "", before.editorPid) : await this.handleViaBridge("restart_editor", {});
|
|
33621
|
+
const asked = mine ? await this.startItAgain(before.projectPath ?? "", before.editorPid, args["hidden"] === true) : await this.handleViaBridge("restart_editor", {});
|
|
33585
33622
|
if (asked.isError === true) {
|
|
33586
33623
|
return asked;
|
|
33587
33624
|
}
|
|
@@ -33619,7 +33656,7 @@ class GodotServer {
|
|
|
33619
33656
|
whatTheEditorDropped(before, after) {
|
|
33620
33657
|
return settingsDroppedReport(before, after);
|
|
33621
33658
|
}
|
|
33622
|
-
async startItAgain(projectPath, editorPid) {
|
|
33659
|
+
async startItAgain(projectPath, editorPid, hidden) {
|
|
33623
33660
|
const engine = await this.engine();
|
|
33624
33661
|
if (!engine.ok) {
|
|
33625
33662
|
return engine.response;
|
|
@@ -33641,7 +33678,7 @@ class GodotServer {
|
|
|
33641
33678
|
return asked;
|
|
33642
33679
|
}
|
|
33643
33680
|
await this.waitForBridge(() => !alive(editorPid), Date.now() + EDITOR_RESTART_TIMEOUT_MS);
|
|
33644
|
-
const opened = await this.openAnEditor(engine.value, projectPath, ports);
|
|
33681
|
+
const opened = await this.openAnEditor(engine.value, projectPath, ports, hidden);
|
|
33645
33682
|
restartSettled(projectPath);
|
|
33646
33683
|
if (opened.error !== null) {
|
|
33647
33684
|
return this.createErrorResponse(`The editor was asked to go and could not be started again: ${opened.error}`, [`editor_launch opens one on ${projectPath}`]);
|
|
@@ -33679,7 +33716,7 @@ class GodotServer {
|
|
|
33679
33716
|
const ports = await this.portsForAnEditor();
|
|
33680
33717
|
const before = this.settingKeysOf(project.value.path);
|
|
33681
33718
|
const unfinished = restartOwed(project.value.path);
|
|
33682
|
-
const opened = await this.openAnEditor(engine.value, project.value.path, ports);
|
|
33719
|
+
const opened = await this.openAnEditor(engine.value, project.value.path, ports, args["hidden"] === true);
|
|
33683
33720
|
if (opened.error !== null) {
|
|
33684
33721
|
return this.createErrorResponse(`Could not start the editor: ${opened.error}`);
|
|
33685
33722
|
}
|
|
@@ -33695,11 +33732,11 @@ class GodotServer {
|
|
|
33695
33732
|
settingsNote: before === null ? "project.godot could not be read before this editor was opened, so nothing here will be able to say what the import saved away. Opening a project rewrites the file and drops any key sitting at its own default." : before.size === 0 ? undefined : "Opening a project imports it and saves project.godot, and Godot drops any key sitting at its own default. editor_status names anything lost under settingsDropped once this editor has connected."
|
|
33696
33733
|
});
|
|
33697
33734
|
}
|
|
33698
|
-
async openAnEditor(engine, projectPath, ports) {
|
|
33735
|
+
async openAnEditor(engine, projectPath, ports, hidden) {
|
|
33699
33736
|
clearEditorLog(projectPath);
|
|
33700
33737
|
const launched = await launchOutsideTheTree({
|
|
33701
33738
|
command: engine,
|
|
33702
|
-
args: editorArguments(projectPath, ports, editorLogPath(projectPath)),
|
|
33739
|
+
args: editorArguments(projectPath, ports, editorLogPath(projectPath), hidden),
|
|
33703
33740
|
env: {
|
|
33704
33741
|
...process.env,
|
|
33705
33742
|
GDHARNESS_RUNTIME_DIR: runtimeDirectory(),
|
|
@@ -33707,7 +33744,7 @@ class GodotServer {
|
|
|
33707
33744
|
GDHARNESS_DAP_PORT: String(ports.dap),
|
|
33708
33745
|
[OPENED_BY_A_SERVER]: "1"
|
|
33709
33746
|
},
|
|
33710
|
-
desktop: HIDDEN_DESKTOP
|
|
33747
|
+
...hidden ? { desktop: HIDDEN_DESKTOP } : { noActivate: true }
|
|
33711
33748
|
});
|
|
33712
33749
|
if ("error" in launched) {
|
|
33713
33750
|
return { pid: null, error: launched.error };
|
package/build/keeper.js
CHANGED
|
@@ -18,6 +18,8 @@ using System.Text;
|
|
|
18
18
|
public static class GdharnessDesktop {
|
|
19
19
|
const uint GenericAll = 0x10000000;
|
|
20
20
|
const int UseStdHandles = 0x100;
|
|
21
|
+
const int UseShowWindow = 0x1;
|
|
22
|
+
const short ShowNoActivate = 4;
|
|
21
23
|
// No console at all, rather than a hidden one: a console's host would sit on the desktop too.
|
|
22
24
|
const uint DetachedProcess = 0x00000008;
|
|
23
25
|
const uint Infinite = 0xFFFFFFFF;
|
|
@@ -50,26 +52,90 @@ public static class GdharnessDesktop {
|
|
|
50
52
|
[DllImport("kernel32.dll")] static extern bool GetExitCodeProcess(IntPtr handle, out uint code);
|
|
51
53
|
[DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
delegate bool EachWindow(IntPtr window, IntPtr unused);
|
|
56
|
+
[DllImport("user32.dll")] static extern bool EnumWindows(EachWindow each, IntPtr unused);
|
|
57
|
+
[DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr window);
|
|
58
|
+
[DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId);
|
|
59
|
+
[DllImport("user32.dll")] static extern bool LockSetForegroundWindow(uint code);
|
|
60
|
+
const uint LockForeground = 1;
|
|
61
|
+
const uint UnlockForeground = 2;
|
|
62
|
+
const uint TimedOut = 0x102;
|
|
63
|
+
|
|
64
|
+
static bool ShowsAWindow(int processId) {
|
|
65
|
+
bool shown = false;
|
|
66
|
+
EnumWindows(delegate(IntPtr window, IntPtr unused) {
|
|
67
|
+
uint owner;
|
|
68
|
+
GetWindowThreadProcessId(window, out owner);
|
|
69
|
+
if (owner == (uint)processId && IsWindowVisible(window)) {
|
|
70
|
+
shown = true;
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}, IntPtr.Zero);
|
|
75
|
+
return shown;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Keeps the foreground where it is while the process starts: its own call to take it fails while
|
|
79
|
+
// the foreground is locked, and the user's own Alt+Tab or click still moves it. Released once its
|
|
80
|
+
// window has been showing for a while, since a program takes the foreground as it shows its window
|
|
81
|
+
// and not afterwards, or when it exits, or after two minutes whatever happens.
|
|
82
|
+
static void HoldTheForeground(Started started) {
|
|
83
|
+
DateTime until = DateTime.UtcNow.AddMinutes(2);
|
|
84
|
+
DateTime? shownAt = null;
|
|
85
|
+
while (DateTime.UtcNow < until) {
|
|
86
|
+
if (WaitForSingleObject(started.process, 100) != TimedOut) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (shownAt == null && ShowsAWindow(started.processId)) {
|
|
90
|
+
shownAt = DateTime.UtcNow;
|
|
91
|
+
}
|
|
92
|
+
if (shownAt != null && DateTime.UtcNow - shownAt.Value > TimeSpan.FromSeconds(10)) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
60
95
|
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Starts the command line, both of its streams on this process's output, writes its pid, and
|
|
99
|
+
// waits for it: the answer is its exit code. On the named desktop when there is one, whose handle
|
|
100
|
+
// is held until then, since a desktop goes when the last handle to it does, and on this process's
|
|
101
|
+
// own otherwise. With noActivate its first window is shown without being activated, which is
|
|
102
|
+
// what a program's first ShowWindow does whatever it asks for when the start names a show state,
|
|
103
|
+
// and the foreground is held while it starts, since Godot also asks for it outright.
|
|
104
|
+
public static int Run(string desktop, string commandLine, string pidFile, bool noActivate) {
|
|
61
105
|
StartupInfo startup = new StartupInfo();
|
|
62
106
|
startup.cb = Marshal.SizeOf(typeof(StartupInfo));
|
|
63
|
-
|
|
107
|
+
if (desktop.Length > 0) {
|
|
108
|
+
IntPtr held = CreateDesktopW(desktop, IntPtr.Zero, IntPtr.Zero, 0, GenericAll, IntPtr.Zero);
|
|
109
|
+
if (held == IntPtr.Zero) {
|
|
110
|
+
throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateDesktopW " + desktop);
|
|
111
|
+
}
|
|
112
|
+
startup.desktop = desktop;
|
|
113
|
+
}
|
|
64
114
|
startup.flags = UseStdHandles;
|
|
115
|
+
if (noActivate) {
|
|
116
|
+
startup.flags |= UseShowWindow;
|
|
117
|
+
startup.show = ShowNoActivate;
|
|
118
|
+
}
|
|
65
119
|
startup.input = GetStdHandle(-10);
|
|
66
120
|
startup.output = GetStdHandle(-11);
|
|
67
121
|
startup.error = startup.output;
|
|
68
122
|
Started started;
|
|
123
|
+
if (noActivate) {
|
|
124
|
+
LockSetForegroundWindow(LockForeground);
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
69
127
|
if (!CreateProcessW(null, new StringBuilder(commandLine), IntPtr.Zero, IntPtr.Zero, true, DetachedProcess, IntPtr.Zero, null, ref startup, out started)) {
|
|
70
128
|
throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateProcessW " + commandLine);
|
|
71
129
|
}
|
|
72
130
|
File.WriteAllText(pidFile, started.processId.ToString());
|
|
131
|
+
if (noActivate) {
|
|
132
|
+
HoldTheForeground(started);
|
|
133
|
+
}
|
|
134
|
+
} finally {
|
|
135
|
+
if (noActivate) {
|
|
136
|
+
LockSetForegroundWindow(UnlockForeground);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
73
139
|
WaitForSingleObject(started.process, Infinite);
|
|
74
140
|
uint code;
|
|
75
141
|
GetExitCodeProcess(started.process, out code);
|
|
@@ -98,9 +164,10 @@ function quoted(argument) {
|
|
|
98
164
|
function windowsCommandLine(command, args) {
|
|
99
165
|
return [command, ...args].map(quoted).join(" ");
|
|
100
166
|
}
|
|
101
|
-
function
|
|
167
|
+
function throughHelper(how, command, args, pidFile, errorFile) {
|
|
102
168
|
const spec = {
|
|
103
|
-
desktop,
|
|
169
|
+
desktop: how.desktop ?? "",
|
|
170
|
+
noActivate: how.noActivate === true,
|
|
104
171
|
commandLine: windowsCommandLine(command, args),
|
|
105
172
|
pidFile,
|
|
106
173
|
errorFile,
|
|
@@ -119,7 +186,7 @@ function onDesktop(desktop, command, args, pidFile, errorFile) {
|
|
|
119
186
|
" try { Move-Item -LiteralPath $partial -Destination $assembly } catch { Remove-Item -LiteralPath $partial -ErrorAction SilentlyContinue }",
|
|
120
187
|
" }",
|
|
121
188
|
" Add-Type -LiteralPath $assembly",
|
|
122
|
-
" $code = [GdharnessDesktop]::Run($spec.desktop, $spec.commandLine, $spec.pidFile)",
|
|
189
|
+
" $code = [GdharnessDesktop]::Run($spec.desktop, $spec.commandLine, $spec.pidFile, [bool]$spec.noActivate)",
|
|
123
190
|
"} catch {",
|
|
124
191
|
" [IO.File]::WriteAllText($spec.errorFile, $_.Exception.Message)",
|
|
125
192
|
" exit 1",
|
|
@@ -311,12 +378,13 @@ async function startDirectly(spec, output, hidden, detached = true, errors = out
|
|
|
311
378
|
});
|
|
312
379
|
});
|
|
313
380
|
}
|
|
314
|
-
async function
|
|
381
|
+
async function startThroughHelper(spec, output) {
|
|
315
382
|
const scratch = mkdtempSync(join3(tmpdir2(), "gdharness-desktop-"));
|
|
316
383
|
const pidFile = join3(scratch, "pid");
|
|
317
384
|
const errorFile = join3(scratch, "error");
|
|
385
|
+
const where = spec.desktop === undefined ? "without activation" : `on the ${spec.desktop} desktop`;
|
|
318
386
|
try {
|
|
319
|
-
const wrapper =
|
|
387
|
+
const wrapper = throughHelper(spec, spec.command, spec.args, pidFile, errorFile);
|
|
320
388
|
const started = await startDirectly({ ...spec, command: wrapper.command, args: wrapper.args }, output, true, false, "ignore");
|
|
321
389
|
if ("error" in started) {
|
|
322
390
|
return started;
|
|
@@ -332,27 +400,26 @@ async function startOnDesktop(spec, output) {
|
|
|
332
400
|
}
|
|
333
401
|
if (exited.now) {
|
|
334
402
|
const why = existsSync(errorFile) ? readFileSync2(errorFile, "utf8").trim() : "it exited saying nothing";
|
|
335
|
-
return { error: `it could not be started
|
|
403
|
+
return { error: `it could not be started ${where}: ${why}` };
|
|
336
404
|
}
|
|
337
405
|
await delay(50);
|
|
338
406
|
}
|
|
339
407
|
started.child.kill();
|
|
340
|
-
return { error: `it was not started
|
|
408
|
+
return { error: `it was not started ${where} within ${DESKTOP_START_MS} ms` };
|
|
341
409
|
} finally {
|
|
342
410
|
rmSync2(scratch, { recursive: true, force: true });
|
|
343
411
|
}
|
|
344
412
|
}
|
|
345
413
|
var DESKTOP_START_MS = 60000;
|
|
346
414
|
async function startDetached(spec, output, hidden) {
|
|
347
|
-
|
|
348
|
-
return desktop !== undefined && process.platform === "win32" ? await startOnDesktop({ ...spec, desktop }, output) : await startDirectly(spec, output, hidden);
|
|
415
|
+
return process.platform === "win32" && (spec.desktop !== undefined || spec.noActivate === true) ? await startThroughHelper(spec, output) : await startDirectly(spec, output, hidden);
|
|
349
416
|
}
|
|
350
|
-
function
|
|
351
|
-
return spec.desktop !== undefined
|
|
417
|
+
function needsItsHelperKept(spec) {
|
|
418
|
+
return process.platform === "win32" && (spec.desktop !== undefined || spec.noActivate === true);
|
|
352
419
|
}
|
|
353
420
|
async function launch() {
|
|
354
421
|
const spec = await receivedSpec();
|
|
355
|
-
if (spec.run === undefined && !
|
|
422
|
+
if (spec.run === undefined && !needsItsHelperKept(spec)) {
|
|
356
423
|
const started = await startDetached(spec, "ignore", true);
|
|
357
424
|
if ("error" in started) {
|
|
358
425
|
process.stdout.write(`error ${started.error}
|
|
@@ -402,8 +469,8 @@ async function launch() {
|
|
|
402
469
|
async function keep() {
|
|
403
470
|
const spec = await receivedSpec();
|
|
404
471
|
const run = spec.run;
|
|
405
|
-
if (run === undefined && !
|
|
406
|
-
process.stdout.write(`error a keeper was started without a run or a
|
|
472
|
+
if (run === undefined && !needsItsHelperKept(spec)) {
|
|
473
|
+
process.stdout.write(`error a keeper was started without a run or a helper to keep
|
|
407
474
|
`);
|
|
408
475
|
process.exit(1);
|
|
409
476
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gdharness",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.7",
|
|
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",
|