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 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. 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"] } },
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 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."
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
- const alike = /Expecting:\n '([\s\S]*)'\n but was\n '\1'\n\tat /.test(detail);
37162
- if (alike) {
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 ('[^'\\n]*' in \\S+:\\d+)`, "g");
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 || error instanceof Refusal) {
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
- const hung = await new Promise((resolve) => {
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 = 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));
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: !hung && exitCode === 0 && report.failures === 0 && report.errors === 0,
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 window.
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 PRINTED_ACTUAL = new RegExp(` but was\\n '([\\s\\S]*?)'((?:${ESCAPE}\\[[0-9;]*m)*)\\tat ('[^'\\n]*' in \\S+:\\d+)`, "g");
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
- const alike = /Expecting:\n '([\s\S]*)'\n but was\n '\1'\n\tat /.test(detail);
28326
- if (alike) {
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. 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"] } },
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 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."
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 || error instanceof Refusal) {
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
- const hung = await new Promise((resolve) => {
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 = 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));
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: !hung && exitCode === 0 && report.failures === 0 && report.errors === 0,
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
- // 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.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",