gdharness 1.1.6 → 1.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/cli.js CHANGED
@@ -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",
@@ -7836,6 +7836,30 @@ function cachedClasses(projectPath) {
7836
7836
  }
7837
7837
  return listed;
7838
7838
  }
7839
+ function withAddonClassesCounted(rebuilt, listed) {
7840
+ const counted = { ...rebuilt };
7841
+ for (const key of ["added", "changed"]) {
7842
+ const names = rebuilt[key];
7843
+ if (!Array.isArray(names)) {
7844
+ continue;
7845
+ }
7846
+ const own = [];
7847
+ const perAddon = {};
7848
+ for (const name of names) {
7849
+ const addon = /^res:\/\/addons\/([^/]+)\//.exec(listed.get(String(name)) ?? "")?.[1];
7850
+ if (addon === undefined) {
7851
+ own.push(name);
7852
+ } else {
7853
+ perAddon[addon] = (perAddon[addon] ?? 0) + 1;
7854
+ }
7855
+ }
7856
+ if (Object.keys(perAddon).length > 0) {
7857
+ counted[key] = own;
7858
+ counted[`${key}InAddons`] = perAddon;
7859
+ }
7860
+ }
7861
+ return counted;
7862
+ }
7839
7863
  function missingMemberIn(message) {
7840
7864
  const found = /The (method|property) "([^"(]+)(?:\(\))?" is not present on the inferred type "([^"]+)"/.exec(message);
7841
7865
  if (found === null) {
@@ -16044,7 +16068,7 @@ var init_tool_definitions = __esm(() => {
16044
16068
  },
16045
16069
  {
16046
16070
  name: "project_test",
16047
- description: "Runs the project's gdUnit4 tests headless and answers with every case that did not pass: where it is, and what the assertion said. Suites where everything passed are counted rather than listed, and an engine message keeps the frames above gdUnit4 rather than the twenty inside it, so a clean tier answers in a few lines. The class list is rebuilt first, so a suite written a moment ago is found, and the rebuild's answer is under classes: a class it puts back that the cache had after the last rebuild is named under classes.lostSinceLastRebuild, which is the editor writing its shorter list over the file on every save until restarted. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. On macOS the engine reads user:// off HOME and the run writes where the player keeps theirs, which the answer says under savesNote rather than leaving to be found in a save list. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. A run where nothing failed but nodes were left in the tree comes back under warnings, with the count per suite: gdUnit4 decides its verdict on those and keeps them out of its report, so they are read off what it printed. Needs gdUnit4 under addons/gdUnit4.",
16071
+ description: "Runs the project's gdUnit4 tests headless and answers with every case that did not pass: where it is, and what the assertion said. Suites where everything passed are counted rather than listed, and an engine message keeps the frames above gdUnit4 rather than the twenty inside it, so a clean tier answers in a few lines. The class list is rebuilt first, so a suite written a moment ago is found, and the rebuild's answer is under classes, with the classes each addon declares counted under addedInAddons and changedInAddons rather than named: a class it puts back that the cache had after the last rebuild is named under classes.lostSinceLastRebuild, which is the editor writing its shorter list over the file on every save until restarted. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. On macOS the engine reads user:// off HOME and the run writes where the player keeps theirs, which the answer says under savesNote rather than leaving to be found in a save list. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. A run where nothing failed but nodes were left in the tree comes back under warnings, with the count per suite: gdUnit4 decides its verdict on those and keeps them out of its report, so they are read off what it printed. Needs gdUnit4 under addons/gdUnit4.",
16048
16072
  parameters: {
16049
16073
  projectPath: PROJECT_PATH,
16050
16074
  path: {
@@ -16063,7 +16087,7 @@ var init_tool_definitions = __esm(() => {
16063
16087
  timeoutMs: {
16064
16088
  type: "integer",
16065
16089
  minimum: 1,
16066
- description: "How long the run may take before it is killed. Default 600000."
16090
+ description: "How long the run may take before it is killed. Default 600000. A run killed at the limit answers timedOut and silentForMs, how long it had printed nothing; it is called hung only when that was half the limit, or thirty seconds of a longer one, and otherwise it was still running and wants a longer timeoutMs or a narrower path."
16067
16091
  }
16068
16092
  },
16069
16093
  requires: ["projectPath"]
@@ -16312,8 +16336,15 @@ var init_tool_definitions = __esm(() => {
16312
16336
  },
16313
16337
  {
16314
16338
  name: "editor_launch",
16315
- description: "Opens the Godot editor on a project, or restarts the one already connected. An editor this opens is headless: it shows no window and takes no focus, and it serves the bridge, the language server, the debug adapter and the games it plays as a windowed one does. An editor goes on serving the addon it read at startup, so restart is what puts a gdharness upgrade into effect; 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, headless as before. 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: { projectPath: { ...PROJECT_PATH, ops: ["open"] } },
16339
+ 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.",
16340
+ parameters: {
16341
+ projectPath: { ...PROJECT_PATH, ops: ["open"] },
16342
+ hidden: {
16343
+ type: "boolean",
16344
+ ops: ["open", "restart"],
16345
+ 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."
16346
+ }
16347
+ },
16317
16348
  requires: [],
16318
16349
  operations: {
16319
16350
  open: { summary: "open the editor on a project", requires: ["projectPath"] },
@@ -16341,7 +16372,7 @@ var init_tool_definitions = __esm(() => {
16341
16372
  visible: {
16342
16373
  type: "boolean",
16343
16374
  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 the editor plays goes there too, since an editor this server opens is on that desktop. Elsewhere than Windows the window shows either way. Default false."
16375
+ 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
16376
  },
16346
16377
  savesIn: {
16347
16378
  type: "string",
@@ -37107,6 +37138,26 @@ function whyNoReport(printed, asked) {
37107
37138
  }
37108
37139
  return printed.some((line) => NOTHING_RAN.test(line)) ? `no test cases found at ${asked}` : null;
37109
37140
  }
37141
+ function scriptErrorsPrinted(printed) {
37142
+ const start = printed.findIndex((line) => line.includes(DISCOVERY_FAILED));
37143
+ if (start < 0) {
37144
+ return [];
37145
+ }
37146
+ const found = [];
37147
+ let message = null;
37148
+ for (const line of printed.slice(start + 1)) {
37149
+ const at = /^\s*at (res:\/\/.+):(\d+)\s*$/.exec(line);
37150
+ if (at !== null && message !== null) {
37151
+ found.push({ path: at[1] ?? "", line: Number(at[2]), message });
37152
+ message = null;
37153
+ } else if (at === null && message === null && /^ {2}\S/.test(line)) {
37154
+ message = line.trim();
37155
+ } else {
37156
+ break;
37157
+ }
37158
+ }
37159
+ return found.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : a.line - b.line);
37160
+ }
37110
37161
  function unspelled(text) {
37111
37162
  return text.replace(/<(?:LF|CR|TAB|BS|FF|VT|BEL|ESC|DEL|0x[0-9A-F]{2})>/g, (spelling) => spelling.startsWith("<0x") ? String.fromCharCode(Number.parseInt(spelling.slice(3, 5), 16)) : SPELLED[spelling] ?? spelling);
37112
37163
  }
@@ -37140,7 +37191,8 @@ function withActualsPrinted(failed, printed) {
37140
37191
  const diffs = [...printed.replaceAll(`\r
37141
37192
  `, `
37142
37193
  `).matchAll(PRINTED_ACTUAL)].map((match) => ({
37143
- at: match[3] ?? "",
37194
+ suffix: match[3] ?? "",
37195
+ at: match[5] ?? "",
37144
37196
  ...readDiff(match[1] ?? "")
37145
37197
  }));
37146
37198
  return failed.map((entry) => {
@@ -37148,24 +37200,37 @@ function withActualsPrinted(failed, printed) {
37148
37200
  return entry;
37149
37201
  }
37150
37202
  let detail = entry.detail;
37203
+ let recovered = false;
37151
37204
  for (const diff of diffs) {
37152
37205
  const wrong = ` but was
37153
- '${diff.merged.replaceAll("\r", "")}'
37206
+ '${diff.merged.replaceAll("\r", "")}'${diff.suffix}
37154
37207
  at ${diff.at}`;
37155
37208
  if (detail.includes(wrong)) {
37156
37209
  detail = detail.replace(wrong, () => ` but was
37157
- '${diff.actual}'
37210
+ '${diff.actual}'${diff.suffix}
37158
37211
  at ${diff.at}`);
37212
+ recovered = true;
37159
37213
  }
37160
37214
  }
37161
- const alike = /Expecting:\n '([\s\S]*)'\n but was\n '\1'\n\tat /.test(detail);
37162
- if (alike) {
37163
- detail = `${detail}
37164
- ${BOTH_SIDES_ALIKE_NOTE}`;
37215
+ const quoted = QUOTED_EQUALITY.exec(detail);
37216
+ if (quoted !== null) {
37217
+ const alike = quoted[1] === quoted[2];
37218
+ const note = recovered ? alike ? RENDERED_AWAY_NOTE : null : alike ? BOTH_SIDES_ALIKE_NOTE : VALUE_NOT_RECOVERED_NOTE;
37219
+ detail = note === null ? detail : `${detail}
37220
+ ${note}`;
37221
+ }
37222
+ if (quoted !== null && masked(quoted[1] ?? "", quoted[2] ?? "")) {
37223
+ const end = quoted[0].length - `
37224
+ at `.length;
37225
+ detail = detail.slice(0, end).replaceAll(MASKED_BRACKET, "[") + detail.slice(end);
37165
37226
  }
37166
37227
  return detail === entry.detail ? entry : { ...entry, detail };
37167
37228
  });
37168
37229
  }
37230
+ function masked(expected, actual) {
37231
+ const allSpelled = (value) => !PRINTED_DICTIONARY.test(value) && value.split("[").length === value.split(MASKED_BRACKET).length;
37232
+ return `${expected}${actual}`.includes(MASKED_BRACKET) && allSpelled(expected) && allSpelled(actual);
37233
+ }
37169
37234
  function orphansPrinted(printed) {
37170
37235
  const suites = [];
37171
37236
  let total = null;
@@ -37196,7 +37261,7 @@ function orphansPrinted(printed) {
37196
37261
  suites
37197
37262
  };
37198
37263
  }
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";
37264
+ var MalformedReportError, ENTITIES, ATTRIBUTE, NOTHING_RAN, NO_SUCH_PATH, DISCOVERY_FAILED = "Script errors were detected during test discovery!", ESCAPE, LOCATION = `'[^'\\n]*' in \\S+:\\d+`, IGNORING_CASE = " \\(ignoring case\\)", PRINTED_ACTUAL, QUOTED_EQUALITY, COLOUR2, SPELLED, BOTH_SIDES_ALIKE_NOTE = "[gdharness: the expected and the actual printed alike because gdUnit4 drops the marks that tell them apart when it writes its report, and the runner printed no copy of this one, so neither side above is the value the assertion found.]", VALUE_NOT_RECOVERED_NOTE = '[gdharness: the value after "but was" may not be the one the assertion found. gdUnit4 writes a failing string into its report as a character diff against the expected one with the marks dropped, which merges the two, and the runner printed no copy of this one to read the value back from.]', RENDERED_AWAY_NOTE = '[gdharness: the two sides print alike, so what tells them apart is something gdUnit4 does not print. The usual cause is BBCode in the expected string, which gdUnit4 renders as formatting instead of showing; the value after "but was" is the one the assertion found.]', MASKED_BRACKET = "[lb]", PRINTED_DICTIONARY, RUNNING_SUITE, ORPHANS, OVERALL = "Overall Summary";
37200
37265
  var init_junit = __esm(() => {
37201
37266
  MalformedReportError = class MalformedReportError extends Error {
37202
37267
  constructor(message) {
@@ -37209,7 +37274,8 @@ var init_junit = __esm(() => {
37209
37274
  NOTHING_RAN = /no test cases found/i;
37210
37275
  NO_SUCH_PATH = /given directory or file does not exists:\s*(\S+)/i;
37211
37276
  ESCAPE = String.fromCharCode(27);
37212
- PRINTED_ACTUAL = new RegExp(` but was\\n '([\\s\\S]*?)'((?:${ESCAPE}\\[[0-9;]*m)*)\\tat ('[^'\\n]*' in \\S+:\\d+)`, "g");
37277
+ PRINTED_ACTUAL = new RegExp(` but was\\n '((?:(?!\\tat ${LOCATION})[\\s\\S])*?)'((?:${ESCAPE}\\[[0-9;]*m)*)(${IGNORING_CASE})?((?:${ESCAPE}\\[[0-9;]*m)*)\\tat (${LOCATION})`, "g");
37278
+ QUOTED_EQUALITY = new RegExp(`^Expecting(?: error message)?:\\n '([\\s\\S]*)'\\n but was\\n '([\\s\\S]*)'(?:${IGNORING_CASE})?\\n\\tat `);
37213
37279
  COLOUR2 = new RegExp(`${ESCAPE}\\[([0-9;]*)m`, "g");
37214
37280
  SPELLED = {
37215
37281
  "<LF>": `
@@ -37223,6 +37289,7 @@ var init_junit = __esm(() => {
37223
37289
  "<ESC>": ESCAPE,
37224
37290
  "<DEL>": ""
37225
37291
  };
37292
+ PRINTED_DICTIONARY = /^\{\n\t[\s\S]*\n {2}\}$/;
37226
37293
  RUNNING_SUITE = /Run Test Suite:\s*(\S+)/;
37227
37294
  ORPHANS = /(\d+)\s+orphans/;
37228
37295
  });
@@ -37807,7 +37874,8 @@ async function launchOutsideTheTree(spec, runtime = process.execPath, keeper = K
37807
37874
  args: spec.args,
37808
37875
  envChanges: changesFrom(spec.env, process.env),
37809
37876
  ...spec.run === undefined ? {} : { run: spec.run },
37810
- ...spec.desktop === undefined ? {} : { desktop: spec.desktop }
37877
+ ...spec.desktop === undefined ? {} : { desktop: spec.desktop },
37878
+ ...spec.noActivate === true ? { noActivate: true } : {}
37811
37879
  });
37812
37880
  let printed = "";
37813
37881
  let complained = "";
@@ -38818,6 +38886,27 @@ function changedSettings(before, after) {
38818
38886
  const names = new Set([...before.keys(), ...after.keys()]);
38819
38887
  return [...names].filter((name) => JSON.stringify(before.get(name)) !== JSON.stringify(after.get(name))).sort();
38820
38888
  }
38889
+ function scriptErrorsNote(errors) {
38890
+ const first = new Map;
38891
+ for (const error of errors) {
38892
+ if (!first.has(error.path)) {
38893
+ first.set(error.path, error);
38894
+ }
38895
+ }
38896
+ const scripts = [...first.values()].map((error) => `${error.path}:${error.line} ${error.message}${error.message.endsWith(".") ? "" : "."}`).join(" ");
38897
+ const more = errors.length - first.size;
38898
+ return `No tests ran: gdUnit4 could not load ${first.size === 1 ? "one script" : `${first.size} scripts`} while looking for suites, and it runs no suite at all when one fails to load. ${scripts}${more > 0 ? ` ${more === 1 ? "One more error" : `${more} more errors`} in ${first.size === 1 ? "that script" : "those scripts"} ${more === 1 ? "is" : "are"} under scriptErrors.` : ""}`;
38899
+ }
38900
+ function timedOutVerdict(timeoutMs, silentForMs) {
38901
+ const quiet = Math.round(silentForMs);
38902
+ return quiet >= Math.min(STALLED_AFTER_MS, timeoutMs / 2) ? {
38903
+ verdict: `hung: killed after ${timeoutMs} ms, having printed nothing for the last ${quiet} ms`,
38904
+ hung: true
38905
+ } : {
38906
+ 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`,
38907
+ hung: false
38908
+ };
38909
+ }
38821
38910
  function importStatuses(payload) {
38822
38911
  const listed = payload["resources"];
38823
38912
  if (!Array.isArray(listed)) {
@@ -39196,9 +39285,12 @@ class GodotServer {
39196
39285
  try {
39197
39286
  return await this.dispatch(tool, op, args);
39198
39287
  } catch (error) {
39199
- if (error instanceof McpError || error instanceof Refusal) {
39288
+ if (error instanceof McpError) {
39200
39289
  throw error;
39201
39290
  }
39291
+ if (error instanceof Refusal) {
39292
+ return this.createErrorResponse(error.message);
39293
+ }
39202
39294
  const where = op === "" ? tool : `${tool} op=${op}`;
39203
39295
  console.error(`[SERVER] Unmodelled failure in ${where}:`, error);
39204
39296
  const seenBefore = this.defectsSeen.before(where, errorMessage(error));
@@ -39478,7 +39570,7 @@ class GodotServer {
39478
39570
  return await bridge("set_theme_font_size", { themePath: args["resourcePath"] });
39479
39571
  }
39480
39572
  case "editor_launch":
39481
- return op === "restart" ? await this.handleRestartEditor() : await this.handleLaunchEditor(args);
39573
+ return op === "restart" ? await this.handleRestartEditor(args) : await this.handleLaunchEditor(args);
39482
39574
  case "editor_run":
39483
39575
  if (op === "stop") {
39484
39576
  return await this.handleStopProject(args);
@@ -40329,11 +40421,18 @@ class GodotServer {
40329
40421
  const userData = mkdtempSync3(join24(tmpdir5(), "gdharness-tests-"));
40330
40422
  const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
40331
40423
  const console2 = [];
40424
+ let lastSaid = Date.now();
40332
40425
  run.process.stdout?.on("data", (data) => {
40333
40426
  console2.push(data);
40427
+ lastSaid = Date.now();
40334
40428
  });
40335
- const hung = await new Promise((resolve) => {
40429
+ run.process.stderr?.on("data", () => {
40430
+ lastSaid = Date.now();
40431
+ });
40432
+ let silentForMs = 0;
40433
+ const timedOut = await new Promise((resolve) => {
40336
40434
  const timer = setTimeout(() => {
40435
+ silentForMs = Date.now() - lastSaid;
40337
40436
  run.process.kill();
40338
40437
  resolve(true);
40339
40438
  }, timeoutMs);
@@ -40375,11 +40474,15 @@ class GodotServer {
40375
40474
  const exitCode = run.exitCode;
40376
40475
  const printed = run.log.select({ severity: "info", sinceLastCall: false, limit: 200 }).entries;
40377
40476
  const said = printed.map((entry) => entry.text);
40378
- const nothingRan = hung ? null : whyNoReport(said, asked);
40379
- const verdict = hung ? `hung: killed after ${timeoutMs} ms` : nothingRan ?? (exitCode === null ? undefined : verdicts[exitCode]) ?? (run.exitSignal === null ? `exit ${exitCode ?? "unknown"}` : howItExited(run));
40477
+ const nothingRan = timedOut ? null : whyNoReport(said, asked);
40478
+ const cutShort = timedOut ? timedOutVerdict(timeoutMs, silentForMs) : null;
40479
+ const hung = cutShort?.hung ?? false;
40480
+ const scriptErrors = timedOut || exitCode !== 105 ? [] : scriptErrorsPrinted(said);
40481
+ const brokenScripts = new Set(scriptErrors.map((error) => error.path)).size;
40482
+ const verdict = cutShort !== null ? cutShort.verdict : nothingRan ?? (brokenScripts > 0 ? `script errors in ${brokenScripts === 1 ? "one script" : `${brokenScripts} scripts`}, so no suite ran` : undefined) ?? (exitCode === null ? undefined : verdicts[exitCode]) ?? (run.exitSignal === null ? `exit ${exitCode ?? "unknown"}` : howItExited(run));
40380
40483
  const savesNote = savesStayPut() && nothingRan === null ? SAVES_NOT_MOVED_NOTE : undefined;
40381
40484
  if (report === null) {
40382
- const note = nothingRan === null ? `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).` : `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}`;
40485
+ const note = nothingRan !== null ? `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}` : scriptErrors.length > 0 ? scriptErrorsNote(scriptErrors) : `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).`;
40383
40486
  return {
40384
40487
  content: [
40385
40488
  { type: "text", text: note },
@@ -40392,8 +40495,10 @@ class GodotServer {
40392
40495
  exitCode,
40393
40496
  exitSignal: run.exitSignal ?? undefined,
40394
40497
  hung,
40498
+ ...timedOut ? { timedOut, silentForMs } : {},
40499
+ ...scriptErrors.length > 0 ? { scriptErrors } : {},
40395
40500
  arguments: cmdArgs,
40396
- entries: forAnswer(printed.slice(0, 60)),
40501
+ entries: forAnswer(printed.slice(0, 60).map(aboveTheRunner)),
40397
40502
  savesNote,
40398
40503
  ...scanned
40399
40504
  }, null, 2)
@@ -40416,8 +40521,9 @@ class GodotServer {
40416
40521
  const unclean = report.suites.filter((suite) => suite.failures > 0 || suite.errors > 0 || suite.skipped > 0 || orphansIn(suite.path) > 0);
40417
40522
  const notRun = report.suites.reduce((sum, suite) => sum + Math.max(0, suite.discovered - suite.tests), 0);
40418
40523
  return this.jsonTextResponse({
40419
- passed: !hung && exitCode === 0 && report.failures === 0 && report.errors === 0,
40524
+ passed: !timedOut && exitCode === 0 && report.failures === 0 && report.errors === 0,
40420
40525
  verdict,
40526
+ ...timedOut ? { timedOut, hung, silentForMs } : {},
40421
40527
  exitCode,
40422
40528
  exitSignal: run.exitSignal ?? undefined,
40423
40529
  tests: report.tests,
@@ -40447,7 +40553,7 @@ class GodotServer {
40447
40553
  engineErrors: run.log.count("error"),
40448
40554
  engineWarnings: run.log.count("warning"),
40449
40555
  engineEntries,
40450
- classes: classes.payload
40556
+ classes: withAddonClassesCounted(classes.payload, cachedClasses(project.value.path) ?? new Map)
40451
40557
  });
40452
40558
  }
40453
40559
  async godotVersion(godotPath) {
@@ -40828,7 +40934,7 @@ class GodotServer {
40828
40934
  return null;
40829
40935
  }
40830
40936
  }
40831
- async handleRestartEditor() {
40937
+ async handleRestartEditor(args) {
40832
40938
  const before = this.godotBridge.getStatus();
40833
40939
  if (!before.connected) {
40834
40940
  return this.createErrorResponse("No editor is connected, so there is nothing to restart.", [
@@ -40838,7 +40944,7 @@ class GodotServer {
40838
40944
  }
40839
40945
  const settingsBefore = this.settingKeysOf(before.projectPath);
40840
40946
  const mine = before.openedByAServer === true && before.projectPath !== undefined;
40841
- const asked = mine ? await this.startItAgain(before.projectPath ?? "", before.editorPid) : await this.handleViaBridge("restart_editor", {});
40947
+ const asked = mine ? await this.startItAgain(before.projectPath ?? "", before.editorPid, args["hidden"] === true) : await this.handleViaBridge("restart_editor", {});
40842
40948
  if (asked.isError === true) {
40843
40949
  return asked;
40844
40950
  }
@@ -40876,7 +40982,7 @@ class GodotServer {
40876
40982
  whatTheEditorDropped(before, after) {
40877
40983
  return settingsDroppedReport(before, after);
40878
40984
  }
40879
- async startItAgain(projectPath, editorPid) {
40985
+ async startItAgain(projectPath, editorPid, hidden) {
40880
40986
  const engine = await this.engine();
40881
40987
  if (!engine.ok) {
40882
40988
  return engine.response;
@@ -40898,7 +41004,7 @@ class GodotServer {
40898
41004
  return asked;
40899
41005
  }
40900
41006
  await this.waitForBridge(() => !alive(editorPid), Date.now() + EDITOR_RESTART_TIMEOUT_MS);
40901
- const opened = await this.openAnEditor(engine.value, projectPath, ports);
41007
+ const opened = await this.openAnEditor(engine.value, projectPath, ports, hidden);
40902
41008
  restartSettled(projectPath);
40903
41009
  if (opened.error !== null) {
40904
41010
  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 +41042,7 @@ class GodotServer {
40936
41042
  const ports = await this.portsForAnEditor();
40937
41043
  const before = this.settingKeysOf(project.value.path);
40938
41044
  const unfinished = restartOwed(project.value.path);
40939
- const opened = await this.openAnEditor(engine.value, project.value.path, ports);
41045
+ const opened = await this.openAnEditor(engine.value, project.value.path, ports, args["hidden"] === true);
40940
41046
  if (opened.error !== null) {
40941
41047
  return this.createErrorResponse(`Could not start the editor: ${opened.error}`);
40942
41048
  }
@@ -40952,11 +41058,11 @@ class GodotServer {
40952
41058
  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
41059
  });
40954
41060
  }
40955
- async openAnEditor(engine, projectPath, ports) {
41061
+ async openAnEditor(engine, projectPath, ports, hidden) {
40956
41062
  clearEditorLog(projectPath);
40957
41063
  const launched = await launchOutsideTheTree({
40958
41064
  command: engine,
40959
- args: editorArguments(projectPath, ports, editorLogPath(projectPath)),
41065
+ args: editorArguments(projectPath, ports, editorLogPath(projectPath), hidden),
40960
41066
  env: {
40961
41067
  ...process.env,
40962
41068
  GDHARNESS_RUNTIME_DIR: runtimeDirectory(),
@@ -40964,7 +41070,7 @@ class GodotServer {
40964
41070
  GDHARNESS_DAP_PORT: String(ports.dap),
40965
41071
  [OPENED_BY_A_SERVER]: "1"
40966
41072
  },
40967
- desktop: HIDDEN_DESKTOP
41073
+ ...hidden ? { desktop: HIDDEN_DESKTOP } : { noActivate: true }
40968
41074
  });
40969
41075
  if ("error" in launched) {
40970
41076
  return { pid: null, error: launched.error };
@@ -42624,7 +42730,7 @@ async function runGodotServer() {
42624
42730
  const server = new GodotServer;
42625
42731
  await server.run();
42626
42732
  }
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/";
42733
+ 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
42734
  var init_server3 = __esm(() => {
42629
42735
  init_mcp();
42630
42736
  init_stdio2();
@@ -44346,7 +44452,9 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
44346
44452
  | A picture, for a person who asked to see one | \`runtime_capture\` |
44347
44453
 
44348
44454
  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.
44455
+ window and refuses headless rather than handing back the last frame anything drew. On Windows a
44456
+ windowed run this server starts is on a desktop of its own, where it draws and can be captured but
44457
+ never shows or takes the keyboard.
44350
44458
 
44351
44459
  The autoload reads these settings and no others. \`${BIND_ADDRESS_SETTING}\`
44352
44460
  is \`127.0.0.1\` and should stay there: the command set includes \`call_method\`, \`set_property\`
@@ -44398,7 +44506,7 @@ them. \`editor_rescan\` asks it to scan and names any class it still cannot reso
44398
44506
  \`unseenByEditor\`: its walk skips a file a headless engine has already imported, so the
44399
44507
  declaration and the cache can both be right while the editor stays blind to it. Rescan again on
44400
44508
  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 window.
44509
+ restart\` also does it and costs a restart of the editor.
44402
44510
 
44403
44511
  The scan writes \`.godot/global_script_class_cache.cfg\` from the list the editor is holding, so
44404
44512
  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",
@@ -25847,6 +25847,30 @@ function cachedClasses(projectPath) {
25847
25847
  }
25848
25848
  return listed;
25849
25849
  }
25850
+ function withAddonClassesCounted(rebuilt, listed) {
25851
+ const counted = { ...rebuilt };
25852
+ for (const key of ["added", "changed"]) {
25853
+ const names = rebuilt[key];
25854
+ if (!Array.isArray(names)) {
25855
+ continue;
25856
+ }
25857
+ const own = [];
25858
+ const perAddon = {};
25859
+ for (const name of names) {
25860
+ const addon = /^res:\/\/addons\/([^/]+)\//.exec(listed.get(String(name)) ?? "")?.[1];
25861
+ if (addon === undefined) {
25862
+ own.push(name);
25863
+ } else {
25864
+ perAddon[addon] = (perAddon[addon] ?? 0) + 1;
25865
+ }
25866
+ }
25867
+ if (Object.keys(perAddon).length > 0) {
25868
+ counted[key] = own;
25869
+ counted[`${key}InAddons`] = perAddon;
25870
+ }
25871
+ }
25872
+ return counted;
25873
+ }
25850
25874
  function missingMemberIn(message) {
25851
25875
  const found = /The (method|property) "([^"(]+)(?:\(\))?" is not present on the inferred type "([^"]+)"/.exec(message);
25852
25876
  if (found === null) {
@@ -28255,8 +28279,32 @@ function whyNoReport(printed, asked) {
28255
28279
  }
28256
28280
  return printed.some((line) => NOTHING_RAN.test(line)) ? `no test cases found at ${asked}` : null;
28257
28281
  }
28282
+ var DISCOVERY_FAILED = "Script errors were detected during test discovery!";
28283
+ function scriptErrorsPrinted(printed) {
28284
+ const start = printed.findIndex((line) => line.includes(DISCOVERY_FAILED));
28285
+ if (start < 0) {
28286
+ return [];
28287
+ }
28288
+ const found = [];
28289
+ let message = null;
28290
+ for (const line of printed.slice(start + 1)) {
28291
+ const at = /^\s*at (res:\/\/.+):(\d+)\s*$/.exec(line);
28292
+ if (at !== null && message !== null) {
28293
+ found.push({ path: at[1] ?? "", line: Number(at[2]), message });
28294
+ message = null;
28295
+ } else if (at === null && message === null && /^ {2}\S/.test(line)) {
28296
+ message = line.trim();
28297
+ } else {
28298
+ break;
28299
+ }
28300
+ }
28301
+ return found.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : a.line - b.line);
28302
+ }
28258
28303
  var ESCAPE = String.fromCharCode(27);
28259
- var PRINTED_ACTUAL = new RegExp(` but was\\n '([\\s\\S]*?)'((?:${ESCAPE}\\[[0-9;]*m)*)\\tat ('[^'\\n]*' in \\S+:\\d+)`, "g");
28304
+ var LOCATION = `'[^'\\n]*' in \\S+:\\d+`;
28305
+ var IGNORING_CASE = " \\(ignoring case\\)";
28306
+ var PRINTED_ACTUAL = new RegExp(` but was\\n '((?:(?!\\tat ${LOCATION})[\\s\\S])*?)'((?:${ESCAPE}\\[[0-9;]*m)*)(${IGNORING_CASE})?((?:${ESCAPE}\\[[0-9;]*m)*)\\tat (${LOCATION})`, "g");
28307
+ var QUOTED_EQUALITY = new RegExp(`^Expecting(?: error message)?:\\n '([\\s\\S]*)'\\n but was\\n '([\\s\\S]*)'(?:${IGNORING_CASE})?\\n\\tat `);
28260
28308
  var COLOUR = new RegExp(`${ESCAPE}\\[([0-9;]*)m`, "g");
28261
28309
  var SPELLED = {
28262
28310
  "<LF>": `
@@ -28300,11 +28348,14 @@ function readDiff(printed) {
28300
28348
  return { merged, actual };
28301
28349
  }
28302
28350
  var BOTH_SIDES_ALIKE_NOTE = "[gdharness: the expected and the actual printed alike because gdUnit4 drops the marks that tell them apart when it writes its report, and the runner printed no copy of this one, so neither side above is the value the assertion found.]";
28351
+ var VALUE_NOT_RECOVERED_NOTE = '[gdharness: the value after "but was" may not be the one the assertion found. gdUnit4 writes a failing string into its report as a character diff against the expected one with the marks dropped, which merges the two, and the runner printed no copy of this one to read the value back from.]';
28352
+ var RENDERED_AWAY_NOTE = '[gdharness: the two sides print alike, so what tells them apart is something gdUnit4 does not print. The usual cause is BBCode in the expected string, which gdUnit4 renders as formatting instead of showing; the value after "but was" is the one the assertion found.]';
28303
28353
  function withActualsPrinted(failed, printed) {
28304
28354
  const diffs = [...printed.replaceAll(`\r
28305
28355
  `, `
28306
28356
  `).matchAll(PRINTED_ACTUAL)].map((match) => ({
28307
- at: match[3] ?? "",
28357
+ suffix: match[3] ?? "",
28358
+ at: match[5] ?? "",
28308
28359
  ...readDiff(match[1] ?? "")
28309
28360
  }));
28310
28361
  return failed.map((entry) => {
@@ -28312,24 +28363,39 @@ function withActualsPrinted(failed, printed) {
28312
28363
  return entry;
28313
28364
  }
28314
28365
  let detail = entry.detail;
28366
+ let recovered = false;
28315
28367
  for (const diff of diffs) {
28316
28368
  const wrong = ` but was
28317
- '${diff.merged.replaceAll("\r", "")}'
28369
+ '${diff.merged.replaceAll("\r", "")}'${diff.suffix}
28318
28370
  at ${diff.at}`;
28319
28371
  if (detail.includes(wrong)) {
28320
28372
  detail = detail.replace(wrong, () => ` but was
28321
- '${diff.actual}'
28373
+ '${diff.actual}'${diff.suffix}
28322
28374
  at ${diff.at}`);
28375
+ recovered = true;
28323
28376
  }
28324
28377
  }
28325
- const alike = /Expecting:\n '([\s\S]*)'\n but was\n '\1'\n\tat /.test(detail);
28326
- if (alike) {
28327
- detail = `${detail}
28328
- ${BOTH_SIDES_ALIKE_NOTE}`;
28378
+ const quoted = QUOTED_EQUALITY.exec(detail);
28379
+ if (quoted !== null) {
28380
+ const alike = quoted[1] === quoted[2];
28381
+ const note = recovered ? alike ? RENDERED_AWAY_NOTE : null : alike ? BOTH_SIDES_ALIKE_NOTE : VALUE_NOT_RECOVERED_NOTE;
28382
+ detail = note === null ? detail : `${detail}
28383
+ ${note}`;
28384
+ }
28385
+ if (quoted !== null && masked(quoted[1] ?? "", quoted[2] ?? "")) {
28386
+ const end = quoted[0].length - `
28387
+ at `.length;
28388
+ detail = detail.slice(0, end).replaceAll(MASKED_BRACKET, "[") + detail.slice(end);
28329
28389
  }
28330
28390
  return detail === entry.detail ? entry : { ...entry, detail };
28331
28391
  });
28332
28392
  }
28393
+ var MASKED_BRACKET = "[lb]";
28394
+ function masked(expected, actual) {
28395
+ const allSpelled = (value) => !PRINTED_DICTIONARY.test(value) && value.split("[").length === value.split(MASKED_BRACKET).length;
28396
+ return `${expected}${actual}`.includes(MASKED_BRACKET) && allSpelled(expected) && allSpelled(actual);
28397
+ }
28398
+ var PRINTED_DICTIONARY = /^\{\n\t[\s\S]*\n {2}\}$/;
28333
28399
  var RUNNING_SUITE = /Run Test Suite:\s*(\S+)/;
28334
28400
  var ORPHANS = /(\d+)\s+orphans/;
28335
28401
  var OVERALL = "Overall Summary";
@@ -28940,7 +29006,8 @@ async function launchOutsideTheTree(spec, runtime = process.execPath, keeper = K
28940
29006
  args: spec.args,
28941
29007
  envChanges: changesFrom(spec.env, process.env),
28942
29008
  ...spec.run === undefined ? {} : { run: spec.run },
28943
- ...spec.desktop === undefined ? {} : { desktop: spec.desktop }
29009
+ ...spec.desktop === undefined ? {} : { desktop: spec.desktop },
29010
+ ...spec.noActivate === true ? { noActivate: true } : {}
28944
29011
  });
28945
29012
  let printed = "";
28946
29013
  let complained = "";
@@ -30194,7 +30261,7 @@ var TOOL_SPECS = [
30194
30261
  },
30195
30262
  {
30196
30263
  name: "project_test",
30197
- description: "Runs the project's gdUnit4 tests headless and answers with every case that did not pass: where it is, and what the assertion said. Suites where everything passed are counted rather than listed, and an engine message keeps the frames above gdUnit4 rather than the twenty inside it, so a clean tier answers in a few lines. The class list is rebuilt first, so a suite written a moment ago is found, and the rebuild's answer is under classes: a class it puts back that the cache had after the last rebuild is named under classes.lostSinceLastRebuild, which is the editor writing its shorter list over the file on every save until restarted. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. On macOS the engine reads user:// off HOME and the run writes where the player keeps theirs, which the answer says under savesNote rather than leaving to be found in a save list. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. A run where nothing failed but nodes were left in the tree comes back under warnings, with the count per suite: gdUnit4 decides its verdict on those and keeps them out of its report, so they are read off what it printed. Needs gdUnit4 under addons/gdUnit4.",
30264
+ description: "Runs the project's gdUnit4 tests headless and answers with every case that did not pass: where it is, and what the assertion said. Suites where everything passed are counted rather than listed, and an engine message keeps the frames above gdUnit4 rather than the twenty inside it, so a clean tier answers in a few lines. The class list is rebuilt first, so a suite written a moment ago is found, and the rebuild's answer is under classes, with the classes each addon declares counted under addedInAddons and changedInAddons rather than named: a class it puts back that the cache had after the last rebuild is named under classes.lostSinceLastRebuild, which is the editor writing its shorter list over the file on every save until restarted. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. On macOS the engine reads user:// off HOME and the run writes where the player keeps theirs, which the answer says under savesNote rather than leaving to be found in a save list. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. A run where nothing failed but nodes were left in the tree comes back under warnings, with the count per suite: gdUnit4 decides its verdict on those and keeps them out of its report, so they are read off what it printed. Needs gdUnit4 under addons/gdUnit4.",
30198
30265
  parameters: {
30199
30266
  projectPath: PROJECT_PATH,
30200
30267
  path: {
@@ -30213,7 +30280,7 @@ var TOOL_SPECS = [
30213
30280
  timeoutMs: {
30214
30281
  type: "integer",
30215
30282
  minimum: 1,
30216
- description: "How long the run may take before it is killed. Default 600000."
30283
+ description: "How long the run may take before it is killed. Default 600000. A run killed at the limit answers timedOut and silentForMs, how long it had printed nothing; it is called hung only when that was half the limit, or thirty seconds of a longer one, and otherwise it was still running and wants a longer timeoutMs or a narrower path."
30217
30284
  }
30218
30285
  },
30219
30286
  requires: ["projectPath"]
@@ -30462,8 +30529,15 @@ var TOOL_SPECS = [
30462
30529
  },
30463
30530
  {
30464
30531
  name: "editor_launch",
30465
- description: "Opens the Godot editor on a project, or restarts the one already connected. An editor this opens is headless: it shows no window and takes no focus, and it serves the bridge, the language server, the debug adapter and the games it plays as a windowed one does. An editor goes on serving the addon it read at startup, so restart is what puts a gdharness upgrade into effect; 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, headless as before. 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.",
30466
- parameters: { projectPath: { ...PROJECT_PATH, ops: ["open"] } },
30532
+ 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.",
30533
+ parameters: {
30534
+ projectPath: { ...PROJECT_PATH, ops: ["open"] },
30535
+ hidden: {
30536
+ type: "boolean",
30537
+ ops: ["open", "restart"],
30538
+ 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."
30539
+ }
30540
+ },
30467
30541
  requires: [],
30468
30542
  operations: {
30469
30543
  open: { summary: "open the editor on a project", requires: ["projectPath"] },
@@ -30491,7 +30565,7 @@ var TOOL_SPECS = [
30491
30565
  visible: {
30492
30566
  type: "boolean",
30493
30567
  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 the editor plays goes there too, since an editor this server opens is on that desktop. Elsewhere than Windows the window shows either way. Default false."
30568
+ 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
30569
  },
30496
30570
  savesIn: {
30497
30571
  type: "string",
@@ -31530,6 +31604,28 @@ function changedSettings(before, after) {
31530
31604
  const names = new Set([...before.keys(), ...after.keys()]);
31531
31605
  return [...names].filter((name) => JSON.stringify(before.get(name)) !== JSON.stringify(after.get(name))).sort();
31532
31606
  }
31607
+ var STALLED_AFTER_MS = 30000;
31608
+ function scriptErrorsNote(errors) {
31609
+ const first = new Map;
31610
+ for (const error of errors) {
31611
+ if (!first.has(error.path)) {
31612
+ first.set(error.path, error);
31613
+ }
31614
+ }
31615
+ const scripts = [...first.values()].map((error) => `${error.path}:${error.line} ${error.message}${error.message.endsWith(".") ? "" : "."}`).join(" ");
31616
+ const more = errors.length - first.size;
31617
+ return `No tests ran: gdUnit4 could not load ${first.size === 1 ? "one script" : `${first.size} scripts`} while looking for suites, and it runs no suite at all when one fails to load. ${scripts}${more > 0 ? ` ${more === 1 ? "One more error" : `${more} more errors`} in ${first.size === 1 ? "that script" : "those scripts"} ${more === 1 ? "is" : "are"} under scriptErrors.` : ""}`;
31618
+ }
31619
+ function timedOutVerdict(timeoutMs, silentForMs) {
31620
+ const quiet = Math.round(silentForMs);
31621
+ return quiet >= Math.min(STALLED_AFTER_MS, timeoutMs / 2) ? {
31622
+ verdict: `hung: killed after ${timeoutMs} ms, having printed nothing for the last ${quiet} ms`,
31623
+ hung: true
31624
+ } : {
31625
+ 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`,
31626
+ hung: false
31627
+ };
31628
+ }
31533
31629
  function importStatuses(payload) {
31534
31630
  const listed = payload["resources"];
31535
31631
  if (!Array.isArray(listed)) {
@@ -31939,9 +32035,12 @@ class GodotServer {
31939
32035
  try {
31940
32036
  return await this.dispatch(tool, op, args);
31941
32037
  } catch (error) {
31942
- if (error instanceof McpError || error instanceof Refusal) {
32038
+ if (error instanceof McpError) {
31943
32039
  throw error;
31944
32040
  }
32041
+ if (error instanceof Refusal) {
32042
+ return this.createErrorResponse(error.message);
32043
+ }
31945
32044
  const where = op === "" ? tool : `${tool} op=${op}`;
31946
32045
  console.error(`[SERVER] Unmodelled failure in ${where}:`, error);
31947
32046
  const seenBefore = this.defectsSeen.before(where, errorMessage(error));
@@ -32221,7 +32320,7 @@ class GodotServer {
32221
32320
  return await bridge("set_theme_font_size", { themePath: args["resourcePath"] });
32222
32321
  }
32223
32322
  case "editor_launch":
32224
- return op === "restart" ? await this.handleRestartEditor() : await this.handleLaunchEditor(args);
32323
+ return op === "restart" ? await this.handleRestartEditor(args) : await this.handleLaunchEditor(args);
32225
32324
  case "editor_run":
32226
32325
  if (op === "stop") {
32227
32326
  return await this.handleStopProject(args);
@@ -33072,11 +33171,18 @@ class GodotServer {
33072
33171
  const userData = mkdtempSync2(join17(tmpdir4(), "gdharness-tests-"));
33073
33172
  const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
33074
33173
  const console2 = [];
33174
+ let lastSaid = Date.now();
33075
33175
  run.process.stdout?.on("data", (data) => {
33076
33176
  console2.push(data);
33177
+ lastSaid = Date.now();
33077
33178
  });
33078
- const hung = await new Promise((resolve) => {
33179
+ run.process.stderr?.on("data", () => {
33180
+ lastSaid = Date.now();
33181
+ });
33182
+ let silentForMs = 0;
33183
+ const timedOut = await new Promise((resolve) => {
33079
33184
  const timer = setTimeout(() => {
33185
+ silentForMs = Date.now() - lastSaid;
33080
33186
  run.process.kill();
33081
33187
  resolve(true);
33082
33188
  }, timeoutMs);
@@ -33118,11 +33224,15 @@ class GodotServer {
33118
33224
  const exitCode = run.exitCode;
33119
33225
  const printed = run.log.select({ severity: "info", sinceLastCall: false, limit: 200 }).entries;
33120
33226
  const said = printed.map((entry) => entry.text);
33121
- const nothingRan = hung ? null : whyNoReport(said, asked);
33122
- const verdict = hung ? `hung: killed after ${timeoutMs} ms` : nothingRan ?? (exitCode === null ? undefined : verdicts[exitCode]) ?? (run.exitSignal === null ? `exit ${exitCode ?? "unknown"}` : howItExited(run));
33227
+ const nothingRan = timedOut ? null : whyNoReport(said, asked);
33228
+ const cutShort = timedOut ? timedOutVerdict(timeoutMs, silentForMs) : null;
33229
+ const hung = cutShort?.hung ?? false;
33230
+ const scriptErrors = timedOut || exitCode !== 105 ? [] : scriptErrorsPrinted(said);
33231
+ const brokenScripts = new Set(scriptErrors.map((error) => error.path)).size;
33232
+ const verdict = cutShort !== null ? cutShort.verdict : nothingRan ?? (brokenScripts > 0 ? `script errors in ${brokenScripts === 1 ? "one script" : `${brokenScripts} scripts`}, so no suite ran` : undefined) ?? (exitCode === null ? undefined : verdicts[exitCode]) ?? (run.exitSignal === null ? `exit ${exitCode ?? "unknown"}` : howItExited(run));
33123
33233
  const savesNote = savesStayPut() && nothingRan === null ? SAVES_NOT_MOVED_NOTE : undefined;
33124
33234
  if (report === null) {
33125
- const note = nothingRan === null ? `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).` : `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}`;
33235
+ const note = nothingRan !== null ? `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}` : scriptErrors.length > 0 ? scriptErrorsNote(scriptErrors) : `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).`;
33126
33236
  return {
33127
33237
  content: [
33128
33238
  { type: "text", text: note },
@@ -33135,8 +33245,10 @@ class GodotServer {
33135
33245
  exitCode,
33136
33246
  exitSignal: run.exitSignal ?? undefined,
33137
33247
  hung,
33248
+ ...timedOut ? { timedOut, silentForMs } : {},
33249
+ ...scriptErrors.length > 0 ? { scriptErrors } : {},
33138
33250
  arguments: cmdArgs,
33139
- entries: forAnswer(printed.slice(0, 60)),
33251
+ entries: forAnswer(printed.slice(0, 60).map(aboveTheRunner)),
33140
33252
  savesNote,
33141
33253
  ...scanned
33142
33254
  }, null, 2)
@@ -33159,8 +33271,9 @@ class GodotServer {
33159
33271
  const unclean = report.suites.filter((suite) => suite.failures > 0 || suite.errors > 0 || suite.skipped > 0 || orphansIn(suite.path) > 0);
33160
33272
  const notRun = report.suites.reduce((sum, suite) => sum + Math.max(0, suite.discovered - suite.tests), 0);
33161
33273
  return this.jsonTextResponse({
33162
- passed: !hung && exitCode === 0 && report.failures === 0 && report.errors === 0,
33274
+ passed: !timedOut && exitCode === 0 && report.failures === 0 && report.errors === 0,
33163
33275
  verdict,
33276
+ ...timedOut ? { timedOut, hung, silentForMs } : {},
33164
33277
  exitCode,
33165
33278
  exitSignal: run.exitSignal ?? undefined,
33166
33279
  tests: report.tests,
@@ -33190,7 +33303,7 @@ class GodotServer {
33190
33303
  engineErrors: run.log.count("error"),
33191
33304
  engineWarnings: run.log.count("warning"),
33192
33305
  engineEntries,
33193
- classes: classes.payload
33306
+ classes: withAddonClassesCounted(classes.payload, cachedClasses(project.value.path) ?? new Map)
33194
33307
  });
33195
33308
  }
33196
33309
  async godotVersion(godotPath) {
@@ -33571,7 +33684,7 @@ class GodotServer {
33571
33684
  return null;
33572
33685
  }
33573
33686
  }
33574
- async handleRestartEditor() {
33687
+ async handleRestartEditor(args) {
33575
33688
  const before = this.godotBridge.getStatus();
33576
33689
  if (!before.connected) {
33577
33690
  return this.createErrorResponse("No editor is connected, so there is nothing to restart.", [
@@ -33581,7 +33694,7 @@ class GodotServer {
33581
33694
  }
33582
33695
  const settingsBefore = this.settingKeysOf(before.projectPath);
33583
33696
  const mine = before.openedByAServer === true && before.projectPath !== undefined;
33584
- const asked = mine ? await this.startItAgain(before.projectPath ?? "", before.editorPid) : await this.handleViaBridge("restart_editor", {});
33697
+ const asked = mine ? await this.startItAgain(before.projectPath ?? "", before.editorPid, args["hidden"] === true) : await this.handleViaBridge("restart_editor", {});
33585
33698
  if (asked.isError === true) {
33586
33699
  return asked;
33587
33700
  }
@@ -33619,7 +33732,7 @@ class GodotServer {
33619
33732
  whatTheEditorDropped(before, after) {
33620
33733
  return settingsDroppedReport(before, after);
33621
33734
  }
33622
- async startItAgain(projectPath, editorPid) {
33735
+ async startItAgain(projectPath, editorPid, hidden) {
33623
33736
  const engine = await this.engine();
33624
33737
  if (!engine.ok) {
33625
33738
  return engine.response;
@@ -33641,7 +33754,7 @@ class GodotServer {
33641
33754
  return asked;
33642
33755
  }
33643
33756
  await this.waitForBridge(() => !alive(editorPid), Date.now() + EDITOR_RESTART_TIMEOUT_MS);
33644
- const opened = await this.openAnEditor(engine.value, projectPath, ports);
33757
+ const opened = await this.openAnEditor(engine.value, projectPath, ports, hidden);
33645
33758
  restartSettled(projectPath);
33646
33759
  if (opened.error !== null) {
33647
33760
  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 +33792,7 @@ class GodotServer {
33679
33792
  const ports = await this.portsForAnEditor();
33680
33793
  const before = this.settingKeysOf(project.value.path);
33681
33794
  const unfinished = restartOwed(project.value.path);
33682
- const opened = await this.openAnEditor(engine.value, project.value.path, ports);
33795
+ const opened = await this.openAnEditor(engine.value, project.value.path, ports, args["hidden"] === true);
33683
33796
  if (opened.error !== null) {
33684
33797
  return this.createErrorResponse(`Could not start the editor: ${opened.error}`);
33685
33798
  }
@@ -33695,11 +33808,11 @@ class GodotServer {
33695
33808
  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
33809
  });
33697
33810
  }
33698
- async openAnEditor(engine, projectPath, ports) {
33811
+ async openAnEditor(engine, projectPath, ports, hidden) {
33699
33812
  clearEditorLog(projectPath);
33700
33813
  const launched = await launchOutsideTheTree({
33701
33814
  command: engine,
33702
- args: editorArguments(projectPath, ports, editorLogPath(projectPath)),
33815
+ args: editorArguments(projectPath, ports, editorLogPath(projectPath), hidden),
33703
33816
  env: {
33704
33817
  ...process.env,
33705
33818
  GDHARNESS_RUNTIME_DIR: runtimeDirectory(),
@@ -33707,7 +33820,7 @@ class GodotServer {
33707
33820
  GDHARNESS_DAP_PORT: String(ports.dap),
33708
33821
  [OPENED_BY_A_SERVER]: "1"
33709
33822
  },
33710
- desktop: HIDDEN_DESKTOP
33823
+ ...hidden ? { desktop: HIDDEN_DESKTOP } : { noActivate: true }
33711
33824
  });
33712
33825
  if ("error" in launched) {
33713
33826
  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
- // Starts the command line on the named desktop, both of its streams on this process's output,
54
- // writes its pid, and waits for it: the answer is its exit code. The desktop's handle is held until then, since a
55
- // desktop goes when the last handle to it does.
56
- public static int Run(string desktop, string commandLine, string pidFile) {
57
- IntPtr held = CreateDesktopW(desktop, IntPtr.Zero, IntPtr.Zero, 0, GenericAll, IntPtr.Zero);
58
- if (held == IntPtr.Zero) {
59
- throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateDesktopW " + desktop);
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
- startup.desktop = desktop;
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 onDesktop(desktop, command, args, pidFile, errorFile) {
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 startOnDesktop(spec, output) {
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 = onDesktop(spec.desktop, spec.command, spec.args, pidFile, errorFile);
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 on the ${spec.desktop} desktop: ${why}` };
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 on the ${spec.desktop} desktop within ${DESKTOP_START_MS} ms` };
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
- const desktop = spec.desktop;
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 onItsOwnDesktop(spec) {
351
- return spec.desktop !== undefined && process.platform === "win32";
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 && !onItsOwnDesktop(spec)) {
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 && !onItsOwnDesktop(spec)) {
406
- process.stdout.write(`error a keeper was started without a run or a desktop
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.6",
3
+ "version": "1.1.8",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",