gdharness 1.2.0 → 1.2.2

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
@@ -17977,7 +17977,7 @@ var init_tool_definitions = __esm(() => {
17977
17977
  },
17978
17978
  {
17979
17979
  name: "project_test",
17980
- 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; identical engine messages come back once under engineEntries, with times saying how often they were printed, and engineErrors counts every one. The class list is rebuilt first, so a suite written a moment ago is found, and the rebuild's answer is under classes, with the classes each addon declares counted under addedInAddons and changedInAddons rather than named: a class it puts back that the cache had after the last rebuild is named under classes.lostSinceLastRebuild, which is the editor writing its shorter list over the file on every save until restarted. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. On macOS the engine reads user:// off HOME and the run writes where the player keeps theirs, which the answer says under savesNote rather than leaving to be found in a save list. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. A script that does not load stops gdUnit4 before any suite runs: its errors come back in full under scriptErrors, and the scripts that failed only because one they depend on did are listed by path under failedThroughDependency. When they failed on a class whose own script has a parser error, which gdUnit4 never names, that script is checked by the engine and its error comes first under scriptErrors, with the class it declares under declares, and the errors a script has only because that class did not resolve, which the engine reads as Variant, are kept apart under followFromUnresolved. A client that sends a progress token is told as each suite finishes, and at least every twenty seconds through a long one, so an idle limit does not end a long tier. A run that ends without a report, at its timeout or otherwise, names the suites it finished under suitesFinished, the failing ones with their failed cases under suitesFailing, and the one it was in under suiteRunning. When a client cuts a run off, that is kept in the project and the next project_test there carries it under previousRunCut. 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. A run whose every case passed is still not called passed when it printed a script error that no case failed on: gdUnit4 fails a case on the script errors its logger sees, and it cannot see a stack overflow, so the cases pass on whatever the faulted calls returned. Those errors come back under uncaughtScriptErrors, and under engineEntries by index with detailUnder rather than with their backtrace a second time; a backtrace that repeats, as a recursion's does, has each repeated frame or cycle of frames written once with the frames it covers and how many times. One raised inside assert_error(...).is_runtime_error is the test's own and does not count, and none count when the project turns off gdUnit4's report/godot/script_error setting. Needs gdUnit4 under addons/gdUnit4.",
17980
+ 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. Each entry under failed carries its first report as message and detail, and, when it has more than one, every report under reports in the order the case met them: gdUnit4 goes on past a failed assertion, and a case can fail several and then stop on a runtime error, which makes its status error. failures counts the cases that failed an assertion and errors the cases that hit an error, so such a case counts in both. 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; identical engine messages come back once under engineEntries, with times saying how often they were printed, and engineErrors counts every one. The class list is rebuilt first, so a suite written a moment ago is found, and the rebuild's answer is under classes, with the classes each addon declares counted under addedInAddons and changedInAddons rather than named: a class it puts back that the cache had after the last rebuild is named under classes.lostSinceLastRebuild, which is the editor writing its shorter list over the file on every save until restarted. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. On macOS the engine reads user:// off HOME and the run writes where the player keeps theirs, which the answer says under savesNote rather than leaving to be found in a save list. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. A script that does not load stops gdUnit4 before any suite runs: its errors come back in full under scriptErrors, and the scripts that failed only because one they depend on did are listed by path under failedThroughDependency. When they failed on a class whose own script has a parser error, which gdUnit4 never names, that script is checked by the engine and its error comes first under scriptErrors, with the class it declares under declares, and the errors a script has only because that class did not resolve, which the engine reads as Variant, are kept apart under followFromUnresolved. A client that sends a progress token is told as each suite finishes, and at least every twenty seconds through a long one, so an idle limit does not end a long tier. A run that ends without a report, at its timeout or otherwise, names the suites it finished under suitesFinished, the failing ones with their failed cases under suitesFailing, and the one it was in under suiteRunning. When a client cuts a run off, that is kept in the project and the next project_test there carries it under previousRunCut. 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. A run whose every case passed is still not called passed when it printed a script error that no case failed on: gdUnit4 fails a case on the script errors its logger sees, and it cannot see a stack overflow, so the cases pass on whatever the faulted calls returned. Those errors come back under uncaughtScriptErrors, and under engineEntries by index with detailUnder rather than with their backtrace a second time; a backtrace that repeats, as a recursion's does, has each repeated frame or cycle of frames written once with the frames it covers and how many times. One raised inside assert_error(...).is_runtime_error is the test's own and does not count, and none count when the project turns off gdUnit4's report/godot/script_error setting. Needs gdUnit4 under addons/gdUnit4.",
17981
17981
  parameters: {
17982
17982
  projectPath: PROJECT_PATH,
17983
17983
  path: {
@@ -18421,7 +18421,7 @@ var init_tool_definitions = __esm(() => {
18421
18421
  },
18422
18422
  {
18423
18423
  name: "editor_output",
18424
- description: "What the project started by editor_run has printed, as entries with a severity: the errors and warnings the engine reported, each with where it happened, and everything else as info. For a run the editor plays the reports come from the game itself, through the runtime addon, since the editor's debugger relays what a game prints and not what it reports; a played game without the runtime addon answers here with its prints alone, with errorsUnread true and no clean, since what it raised reaches nothing here. Answers with the counts and the verdict as well as the entries. heldAt is three answers: null for a run this server knows is not held, running or over, an object for one it knows is held at a breakpoint, whether the adapter said so or the runtime did by accepting a connection and never answering, and absent with heldUnknown beside it for a server that connected to the debugger after the stop and has no runtime to ask, since the adapter reports a stop only to the sessions connected when it happened. A run that has quit still answers here, with running false and its exit code, until the next one starts; a run ended by a signal has no exit code, and exitSignal names the signal instead. A run this server started also answers with transcript, the file both its streams are written to: uncapped, written while the run is going, and the thing to read or tail for a long run rather than the engine's own log, which every engine start rotates away. Read the file for output and this answer for state: endedBy says whether gdharness ended the run, and a transcript that has stopped growing is a run between prints rather than a run that is over. running is asked of the operating system whenever there is a process to ask about, which is every run this server started and every editor-played run whose game announced its runtime. For an editor-played run that never announced, there is no process id here and running is the editor's answer, which can lag: one measured elsewhere reported a game as playing for fifteen seconds after its process had been ended from outside. editor_status names that game under runtimes, which does not depend on the editor answering. op: \"editor\" answers about the editor itself instead, which is a different log and the only way anything here can read what an editor printed: its console reaches no plugin, so the addon cannot scrape it, and an editor gdharness opened is asked for a log file as it starts. That covers the whole session from the editor's first line, which is where the output worth reading usually is, since the class cache, the plugins and the language server all say what they have to say before any harness has connected. The answer carries the entries in the order they were printed and, beside them under repeated, each message shape several lines share: how many, what varied in each slot of it and how many different values stood there, the first occurrence, and the ordinary lines above the group, found by walking back past the group rather than by counting lines. An editor somebody opened by hand has no console here and is refused saying so rather than answered empty, because an editor that printed nothing and an editor nobody captured are not the same thing.",
18424
+ description: "What the project started by editor_run has printed, as entries with a severity: the errors and warnings the engine reported, each with where it happened, and everything else as info. For a run the editor plays the reports come from the game itself, through the runtime addon, since the editor's debugger relays what a game prints and not what it reports; a played game without the runtime addon answers here with its prints alone, with errorsUnread true and no clean, since what it raised reaches nothing here. Answers with the counts and the verdict as well as the entries. heldAt is three answers: null for a run this server knows is not held, running or over, an object for one it knows is held at a breakpoint, whether the adapter said so or the runtime did by accepting a connection and never answering, and absent with heldUnknown beside it for a server that connected to the debugger after the stop and has no runtime to ask, since the adapter reports a stop only to the sessions connected when it happened. A run that has quit still answers here, with running false and its exit code, until the next one starts; a run ended by a signal has no exit code, and exitSignal names the signal instead. A run this server started also answers with transcript, the file both its streams are written to: uncapped, written while the run is going, and the thing to read or tail for a long run rather than the engine's own log, which every engine start rotates away. Read the file for output and this answer for state: endedBy says whether gdharness ended the run, and a transcript that has stopped growing is a run between prints rather than a run that is over. running is asked of the operating system whenever there is a process to ask about, which is every run this server started and every editor-played run whose game announced its runtime. For an editor-played run that never announced, there is no process id here and running is the editor's answer, which can lag: one measured elsewhere reported a game as playing for fifteen seconds after its process had been ended from outside. editor_status names that game under runtimes, which does not depend on the editor answering. op: \"editor\" answers about the editor itself instead, which is a different log and the only way anything here can read what an editor printed: its console reaches no plugin, so the addon cannot scrape it, and an editor gdharness opened is asked for a log file as it starts. That covers the whole session from the editor's first line, which is where the output worth reading usually is, since the class cache, the plugins and the language server all say what they have to say before any harness has connected. The answer carries the entries in the order they were printed and, beside them under repeated, each message shape several lines share: how many, what varied in each slot of it and how many different values stood there, the first occurrence, and the ordinary lines above the group, found by walking back past the group rather than by counting lines. An editor somebody opened by hand has no console here and is refused saying so rather than answered empty, because an editor that printed nothing and an editor nobody captured are not the same thing. startedAt is when the run began and elapsedMs how long it has been going; for a run that is over, endedAt is when it ended and elapsedMs how long it ran, and both are left out when nobody saw the end, as for a game closed in the editor or a run picked up from a note written before ends were recorded.",
18425
18425
  parameters: {
18426
18426
  severity: {
18427
18427
  type: "string",
@@ -18469,7 +18469,7 @@ var init_tool_definitions = __esm(() => {
18469
18469
  },
18470
18470
  {
18471
18471
  name: "editor_status",
18472
- description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable: runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. game.run names the run processActive is about, the one editor_output and editor_run stop answer for: its pid, its project, and adopted true when this server read it back from another server rather than starting it. It stays named after the run ends, beside processActive false, so editor_output can still read what that run printed; null when there is no run at all. editor.lspListening and editor.dapListening say whether anything accepts connections on the ports the editor names, lspPort and dapPort: Godot binds each once, as the editor starts, and reports a failure only in the log panel of the editor itself, so a false comes with serversNote, and editor_launch restart binds it again. editor.scan says whether the editor is scanning or importing and how many milliseconds ago its last scan finished (finishedMsAgo), or is null when it did not answer: the editor rewrites the class cache just after a scan, and editor_run waits that out before starting a game, saying so under waitedForEditorScanMs. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. Each game also says which runtime addon it loaded, under addonVersion (null when it cannot say); addonIsStale is true when that code is not what this server ships, and staleNote says whether the project needs gdharness upgrade, the game a restart, or this server a reconnect. A second checkout of a project, such as a git worktree, carries addons of its own and is where this shows. Every runtime_* answer from such a game carries addonIsStale and staleNote too. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. True beside restartUnderway is a restart still in progress, its server alive and within the wait for the old editor to go: the editor is coming, and opening another would put two on one project. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. Any editor of this project running and not yet connected, whoever opened it, makes it true as well and is named under editorsNotYetConnected with its pid and start time, so a restart a reconnect interrupted after its launch is not read as nothing being there. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved and, when it was the server this one replaced, which pid kept it. A server replacing another of its project that holds the configured port waits for it to stand down and takes the port over, which takes a few seconds; bridgeHandover says so while it waits, and it moves only if that server has not let go within the wait. An editor that has connected and not yet greeted, which lasts a frame and longer while it imports, reports nothing about itself: greeting says so, addonIsStale is absent rather than guessed, and a restart asked then waits for the greeting to learn who opened it. startedByAnEditor is an editor another editor started, by Godot's own restart or the project manager, which has none of the arguments a server gave the one before it; startedByAnEditorNote says what that costs. An engine run that loads the editor addon to do one job and quit, such as a gate's godot --headless --import, an export or a script, is not taken as the editor: a process that greets without a server's mark has its command line read first, and while that is read identifying is true and connected false; one started with such an option is turned away, and turnedAway names the last one, its pid and the option. Every other call made while an editor is arriving, before its greeting or while it is looked up, waits up to ten seconds for it to settle rather than acting on connected as it reads in that window.",
18472
+ description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable: runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. game.run names the run processActive is about, the one editor_output and editor_run stop answer for: its pid, its project, and adopted true when this server read it back from another server rather than starting it. It stays named after the run ends, beside processActive false, so editor_output can still read what that run printed; null when there is no run at all. startedAt says when it began, and endedAt when it ended, wherever something saw the end: a run adopted from a note another server wrote can have ended hours before this server started. editor.lspListening and editor.dapListening say whether anything accepts connections on the ports the editor names, lspPort and dapPort: Godot binds each once, as the editor starts, and reports a failure only in the log panel of the editor itself, so a false comes with serversNote, and editor_launch restart binds it again. editor.scan says whether the editor is scanning or importing and how many milliseconds ago its last scan finished (finishedMsAgo), or is null when it did not answer: the editor rewrites the class cache just after a scan, and editor_run waits that out before starting a game, saying so under waitedForEditorScanMs. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. Each game also says which runtime addon it loaded, under addonVersion (null when it cannot say); addonIsStale is true when that code is not what this server ships, and staleNote says whether the project needs gdharness upgrade, the game a restart, or this server a reconnect. A second checkout of a project, such as a git worktree, carries addons of its own and is where this shows. Every runtime_* answer from such a game carries addonIsStale and staleNote too. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. True beside restartUnderway is a restart still in progress, its server alive and within the wait for the old editor to go: the editor is coming, and opening another would put two on one project. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. Any editor of this project running and not yet connected, whoever opened it, makes it true as well and is named under editorsNotYetConnected with its pid and start time, so a restart a reconnect interrupted after its launch is not read as nothing being there. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved and, when it was the server this one replaced, which pid kept it. A server replacing another of its project that holds the configured port waits for it to stand down and takes the port over, which takes a few seconds; bridgeHandover says so while it waits, and it moves only if that server has not let go within the wait. An editor that has connected and not yet greeted, which lasts a frame and longer while it imports, reports nothing about itself: greeting says so, addonIsStale is absent rather than guessed, and a restart asked then waits for the greeting to learn who opened it. startedByAnEditor is an editor another editor started, by Godot's own restart or the project manager, which has none of the arguments a server gave the one before it; startedByAnEditorNote says what that costs. An engine run that loads the editor addon to do one job and quit, such as a gate's godot --headless --import, an export or a script, is not taken as the editor: a process that greets without a server's mark has its command line read first, and while that is read identifying is true and connected false; one started with such an option is turned away, and turnedAway names the last one, its pid and the option. Every other call made while an editor is arriving, before its greeting or while it is looked up, waits up to ten seconds for it to settle rather than acting on connected as it reads in that window.",
18473
18473
  parameters: {},
18474
18474
  requires: []
18475
18475
  },
@@ -39250,18 +39250,25 @@ function count(attributes, name) {
39250
39250
  return Number.isFinite(value) ? value : 0;
39251
39251
  }
39252
39252
  function caseOf(suite, element) {
39253
- const outcome = element.children.find((child) => ["failure", "error", "skipped"].includes(child.name));
39254
- const status = outcome === undefined ? "passed" : outcome.name === "failure" ? "failed" : outcome.name === "error" ? "error" : "skipped";
39255
- const detail = outcome?.text.trim() ?? "";
39253
+ const of = (kind) => element.children.filter((child) => child.name === kind).map((child) => {
39254
+ const detail = child.text.trim();
39255
+ return { kind, message: child.attributes["message"] ?? null, detail: detail === "" ? null : detail };
39256
+ });
39257
+ const reports = [...of("failure"), ...of("error"), ...of("skipped")];
39258
+ const status = reports.some((report) => report.kind === "error") ? "error" : reports.some((report) => report.kind === "failure") ? "failed" : reports.length > 0 ? "skipped" : "passed";
39256
39259
  return {
39257
39260
  suite: suite.attributes["name"] ?? "",
39258
39261
  name: element.attributes["name"] ?? "",
39259
39262
  status,
39260
39263
  time: count(element.attributes, "time"),
39261
- message: outcome?.attributes["message"] ?? null,
39262
- detail: detail === "" ? null : detail
39264
+ message: reports[0]?.message ?? null,
39265
+ detail: reports[0]?.detail ?? null,
39266
+ reports
39263
39267
  };
39264
39268
  }
39269
+ function failedAnAssertion(entry) {
39270
+ return entry.reports.some((report) => report.kind === "failure");
39271
+ }
39265
39272
  function pathOf(suite) {
39266
39273
  const directory = suite.attributes["package"];
39267
39274
  const name = suite.attributes["name"];
@@ -39276,7 +39283,7 @@ function parseJUnit(xml) {
39276
39283
  const suiteElements = top.name === "testsuite" ? [top] : top.children.filter((child) => child.name === "testsuite");
39277
39284
  const suites = suiteElements.map((suite) => {
39278
39285
  const cases = suite.children.filter((child) => child.name === "testcase").map((element) => caseOf(suite, element));
39279
- const failures = cases.filter((entry) => entry.status === "failed").length;
39286
+ const failures = cases.filter(failedAnAssertion).length;
39280
39287
  const errors = cases.filter((entry) => entry.status === "error").length;
39281
39288
  const written = (kind) => suite.children.filter((child) => child.name === "testcase").reduce((sum, element) => sum + element.children.filter((child) => child.name === kind).length, 0);
39282
39289
  return {
@@ -39296,7 +39303,7 @@ function parseJUnit(xml) {
39296
39303
  const all = suites.flatMap((suite) => suite.cases);
39297
39304
  return {
39298
39305
  tests: all.length,
39299
- failures: all.filter((entry) => entry.status === "failed").length,
39306
+ failures: all.filter(failedAnAssertion).length,
39300
39307
  errors: all.filter((entry) => entry.status === "error").length,
39301
39308
  hookFailures: suites.reduce((sum, suite) => sum + suite.hookFailures, 0),
39302
39309
  hookErrors: suites.reduce((sum, suite) => sum + suite.hookErrors, 0),
@@ -39418,6 +39425,25 @@ ${note}`;
39418
39425
  return detail === entry.detail ? entry : { ...entry, detail };
39419
39426
  });
39420
39427
  }
39428
+ function failedCases(report, printed) {
39429
+ const cases = report.suites.flatMap((suite) => suite.cases.filter((entry) => entry.status === "failed" || entry.status === "error").map((entry) => ({ entry, path: suite.path })));
39430
+ const read = withActualsPrinted(cases.flatMap(({ entry }) => entry.reports), printed);
39431
+ let next = 0;
39432
+ return cases.map(({ entry, path }) => {
39433
+ const reports = read.slice(next, next + entry.reports.length);
39434
+ next += entry.reports.length;
39435
+ return {
39436
+ suite: entry.suite,
39437
+ name: entry.name,
39438
+ status: entry.status,
39439
+ time: entry.time,
39440
+ path,
39441
+ message: reports[0]?.message ?? null,
39442
+ detail: reports[0]?.detail ?? null,
39443
+ ...reports.length > 1 ? { reports } : {}
39444
+ };
39445
+ });
39446
+ }
39421
39447
  function masked(expected, actual) {
39422
39448
  const allSpelled = (value) => !PRINTED_DICTIONARY.test(value) && value.split("[").length === value.split(MASKED_BRACKET).length;
39423
39449
  return `${expected}${actual}`.includes(MASKED_BRACKET) && allSpelled(expected) && allSpelled(actual);
@@ -42202,7 +42228,8 @@ function recordAt(path) {
42202
42228
  arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : [],
42203
42229
  ...typeof fields["command"] === "string" ? { command: fields["command"] } : {},
42204
42230
  ...typeof fields["exitCode"] === "number" ? { exitCode: fields["exitCode"] } : {},
42205
- ...typeof fields["exitSignal"] === "string" ? { exitSignal: fields["exitSignal"] } : {}
42231
+ ...typeof fields["exitSignal"] === "string" ? { exitSignal: fields["exitSignal"] } : {},
42232
+ ...typeof fields["endedAt"] === "number" ? { endedAt: fields["endedAt"] } : {}
42206
42233
  };
42207
42234
  }
42208
42235
  async function runningAs(pid) {
@@ -43482,7 +43509,7 @@ function profileAnswer(reading, limit) {
43482
43509
  notes.push(`${table.superCallers === 1 ? "One function calls" : `${table.superCallers} functions call`} through super, marked selfIncludesSuper: the engine takes every other call out of the caller's self time, and not a super call, so ${table.superCallers === 1 ? "its" : "their"} selfMs holds the overridden function's time, which that function's own row counts again. Self time therefore cannot be added along an override chain, and scriptMs counts the time under a super call once more for each level above it.`);
43483
43510
  }
43484
43511
  if (table.overheadDominated > 0 && table.perCallUs !== null) {
43485
- notes.push(`The profiler costs about ${table.perCallUs}µs a call in this run, measured on an empty function the runtime addon calls as it goes on, and that time lands in the self time of the function called. ${table.overheadDominated === 1 ? "One function has" : `${table.overheadDominated} functions have`} at least half ${table.overheadDominated === 1 ? "its" : "their"} selfMs in it, under overheadMs: a function that does little and is called often looks expensive here and costs that much less once the profiler is off, so inlining it gains little.`);
43512
+ notes.push(`The profiler costs about ${table.perCallUs}µs a call in this run, measured on an empty function the runtime addon calls as it goes on, and that time lands in the self time of the function called. ${table.overheadDominated === 1 ? "One function has" : `${table.overheadDominated} functions have`} at least half ${table.overheadDominated === 1 ? "its" : "their"} selfMs in it, under overheadMs: that much of its time is the profiler's and goes once the profiler is off, whether the function is inlined or not. What inlining saves is the call itself, which costs something without the profiler too, not the work inside it.`);
43486
43513
  }
43487
43514
  notes.push(...cappedNotes(totals));
43488
43515
  if (totals.unreadable > 0) {
@@ -45431,7 +45458,7 @@ class GodotServer {
45431
45458
  isError: true
45432
45459
  };
45433
45460
  }
45434
- const failed = withActualsPrinted(report.suites.flatMap((suite) => suite.cases.filter((entry) => entry.status === "failed" || entry.status === "error").map((entry) => ({ ...entry, path: suite.path }))), Buffer.concat(console2).toString("utf8"));
45461
+ const failed = failedCases(report, Buffer.concat(console2).toString("utf8"));
45435
45462
  const orphans = orphansPrinted(said);
45436
45463
  const leftBehind = new Map(orphans.suites.map((suite) => [suite.path, suite.orphans]));
45437
45464
  const orphansIn = (path) => path === null ? 0 : leftBehind.get(path) ?? 0;
@@ -45932,7 +45959,9 @@ class GodotServer {
45932
45959
  return {
45933
45960
  pid: run.pid ?? this.announcedPidOf(run) ?? run.announcedPid ?? null,
45934
45961
  ...run.throughEditor || run.projectPath === null ? {} : { project: run.projectPath },
45935
- ...run.pickedUp === true ? { adopted: true } : {}
45962
+ ...run.pickedUp === true ? { adopted: true } : {},
45963
+ startedAt: new Date(run.startedAt).toISOString(),
45964
+ ...run.endedAt === undefined ? {} : { endedAt: new Date(run.endedAt).toISOString() }
45936
45965
  };
45937
45966
  }
45938
45967
  async handleEditorStatus() {
@@ -46705,10 +46734,11 @@ class GodotServer {
46705
46734
  return "unreached";
46706
46735
  }
46707
46736
  running.endedThrough = "editor";
46708
- if (announced === undefined) {
46709
- return await this.untilTheEditorStopsPlaying() ? "gone" : "lingering";
46737
+ const stopped = announced === undefined ? await this.untilTheEditorStopsPlaying() : await untilGone([announced]);
46738
+ if (stopped) {
46739
+ running.endedAt = Date.now();
46710
46740
  }
46711
- return await untilGone([announced]) ? "gone" : "lingering";
46741
+ return stopped ? "gone" : "lingering";
46712
46742
  }
46713
46743
  if (running.pid === null) {
46714
46744
  return "gone";
@@ -46757,6 +46787,7 @@ class GodotServer {
46757
46787
  if (landed && gone) {
46758
46788
  running.exitCode = null;
46759
46789
  running.exitSignal = "SIGTERM";
46790
+ running.endedAt ??= Date.now();
46760
46791
  }
46761
46792
  if (landed) {
46762
46793
  running.endedThrough = "signal";
@@ -46781,6 +46812,7 @@ class GodotServer {
46781
46812
  if (asked === "signalled" && gone) {
46782
46813
  run.exitCode = null;
46783
46814
  run.exitSignal = "SIGTERM";
46815
+ run.endedAt ??= Date.now();
46784
46816
  }
46785
46817
  run.log.record(gone ? "info" : "warning", !gone ? `gdharness had the keeper holding pid ${pid} end it, and it was still running ${stopWaitMs() / 1000} seconds later.` : asked === "signalled" ? `gdharness ended pid ${pid} through the keeper holding it.` : `gdharness asked the keeper holding pid ${pid} to end it, and it had already gone.`);
46786
46818
  return gone ? "gone" : "lingering";
@@ -46828,6 +46860,7 @@ class GodotServer {
46828
46860
  this.logDebug(`Godot process exited with code ${code ?? "none"}, signal ${signal ?? "none"}`);
46829
46861
  started.exitCode = code;
46830
46862
  started.exitSignal = code === null ? signal : null;
46863
+ started.endedAt = Date.now();
46831
46864
  });
46832
46865
  child.on("error", (err) => {
46833
46866
  console.error("Failed to start Godot process:", err);
@@ -47142,6 +47175,9 @@ class GodotServer {
47142
47175
  adopted.log.finish();
47143
47176
  adopted.exitCode = record.exitCode ?? null;
47144
47177
  adopted.exitSignal = record.exitSignal ?? null;
47178
+ if (record.endedAt !== undefined) {
47179
+ adopted.endedAt = record.endedAt;
47180
+ }
47145
47181
  const exitMayBeComing = !alive(record.pid) && !exited(adopted);
47146
47182
  if (!exitMayBeComing) {
47147
47183
  adopted.endedUnwatched = !exited(adopted);
@@ -47435,6 +47471,9 @@ class GodotServer {
47435
47471
  if (record !== null && record.pid === run.pid && (record.exitCode !== undefined || record.exitSignal !== undefined)) {
47436
47472
  run.exitCode = record.exitCode ?? null;
47437
47473
  run.exitSignal = record.exitSignal ?? null;
47474
+ if (record.endedAt !== undefined) {
47475
+ run.endedAt = record.endedAt;
47476
+ }
47438
47477
  return true;
47439
47478
  }
47440
47479
  if (Date.now() >= until) {
@@ -47581,7 +47620,8 @@ class GodotServer {
47581
47620
  project: run.throughEditor ? undefined : run.projectPath ?? undefined,
47582
47621
  transcript: run.transcript ?? undefined,
47583
47622
  startedAt: new Date(run.startedAt).toISOString(),
47584
- elapsedMs: Date.now() - run.startedAt,
47623
+ endedAt: stillRunning(run) || run.endedAt === undefined ? undefined : new Date(run.endedAt).toISOString(),
47624
+ elapsedMs: stillRunning(run) ? Date.now() - run.startedAt : run.endedAt === undefined ? undefined : run.endedAt - run.startedAt,
47585
47625
  cpuSeconds,
47586
47626
  note: notes.length > 0 ? notes.join(" ") : undefined,
47587
47627
  omitted: selected.omitted,
@@ -201,8 +201,8 @@ func _send_the_profile() -> void:
201
201
  ## What the script profiler costs per call in this game, measured once each time it is switched on.
202
202
  ##
203
203
  ## The profiler times every call it counts, and that bookkeeping lands in the self time of the
204
- ## function called: a function doing almost nothing, called six hundred thousand times, read as a
205
- ## second of its own, and inlining it saved nothing once the profiler was off. So an empty function
204
+ ## function called, so a function doing almost nothing, called six hundred thousand times, can read
205
+ ## as a second of its own that is mostly the profiler's. So an empty function
206
206
  ## is called here [constant PROFILER_PROBE_CALLS] times while the profiler watches, under the load
207
207
  ## the game is under, and the server reads its row as the cost of one call.
208
208
  func _calibrate_the_profiler() -> void:
@@ -560,7 +560,7 @@ func click(params: Dictionary) -> Dictionary:
560
560
  ## that does not process while the game is paused is hovered and never handed the event.
561
561
  func _takes_no_click(control: Control, node_path: String) -> String:
562
562
  if not control.is_visible_in_tree():
563
- return "%s is not visible, so nothing can click it" % node_path
563
+ return "%s is not visible%s, so nothing can click it" % [node_path, Targets.why_hidden(control)]
564
564
  var button: BaseButton = control as BaseButton
565
565
  if button != null and button.disabled:
566
566
  return "%s is disabled, so clicking it presses nothing" % node_path
@@ -681,7 +681,10 @@ func _press_button(viewport: Viewport, event: InputEventMouseButton) -> void:
681
681
  ## anything the engine can be asked, so it is not claimed.
682
682
  func _click_in_the_world(node_path: String, item: Node3D, params: Dictionary) -> Dictionary:
683
683
  if not item.is_visible_in_tree():
684
- return {"type": "error", "message": "%s is not visible, so nothing can click it" % node_path}
684
+ return {
685
+ "type": "error",
686
+ "message": "%s is not visible%s, so nothing can click it" % [node_path, Targets.why_hidden(item)]
687
+ }
685
688
 
686
689
  var found: Dictionary = Queries.in_frame(item)
687
690
  if found.is_empty():
@@ -376,6 +376,93 @@ static func drawn_over(target: Control) -> Node:
376
376
  return _cover_of(target, drawn, _areas_of(drawn))
377
377
 
378
378
 
379
+ ## Why [param node], which is not visible in the tree, is not, as a parenthesis to follow its path:
380
+ ## which nodes hide it, nearest first, and what is shown beside the nearest. Reading visible off
381
+ ## the node itself says true when only an ancestor is hidden, so without this a caller had nothing
382
+ ## to unhide; every hidden one is named because unhiding only the nearest leaves the rest hiding it.
383
+ ## "" when nothing on the way up says so.
384
+ static func why_hidden(node: Node) -> String:
385
+ var hiding: Array[Node] = _hiding(node)
386
+ if hiding.is_empty():
387
+ return ""
388
+ var named: Array[String] = []
389
+ for one: Node in hiding:
390
+ if one == node:
391
+ named.append("its own visible is false")
392
+ else:
393
+ named.append("%s is hidden%s" % [one.get_path(), "" if named.is_empty() else " too"])
394
+ var beside: Array[String] = []
395
+ var parent: Node = hiding[0].get_parent()
396
+ if parent != null:
397
+ for sibling: Node in parent.get_children():
398
+ if sibling != hiding[0] and _shown(sibling):
399
+ beside.append(str(sibling.get_path()))
400
+ return " (%s%s)" % [", and ".join(named), _shown_beside(beside)]
401
+
402
+
403
+ ## "; A, B and C beside it are shown" for [param beside], naming three at most and counting the rest.
404
+ static func _shown_beside(beside: Array[String]) -> String:
405
+ if beside.is_empty():
406
+ return ""
407
+ if beside.size() == 1:
408
+ return "; %s beside it is shown" % beside[0]
409
+ var listed: Array[String] = beside.slice(0, 3)
410
+ var rest: int = beside.size() - listed.size()
411
+ if rest > 0:
412
+ return "; %s and %d more beside it are shown" % [", ".join(listed), rest]
413
+ var last: String = listed.pop_back()
414
+ return "; %s and %s beside it are shown" % [", ".join(listed), last]
415
+
416
+
417
+ ## The nodes whose visible is false and keeps [param node] from being drawn, nearest first. Walked
418
+ ## the way the engine inherits visibility rather than by parent: an item under a plain Node is drawn
419
+ ## from its canvas layer, whatever a control above that Node says, so naming that control would send
420
+ ## a caller to unhide something that hides nothing.
421
+ static func _hiding(node: Node) -> Array[Node]:
422
+ var hiding: Array[Node] = []
423
+ var at: Node = node
424
+ while at != null:
425
+ var item: CanvasItem = at as CanvasItem
426
+ var spatial: Node3D = at as Node3D
427
+ if item != null:
428
+ if not item.visible:
429
+ hiding.append(item)
430
+ var above: CanvasItem = item.get_parent() as CanvasItem
431
+ if above != null:
432
+ at = above
433
+ continue
434
+ var layer: CanvasLayer = item.get_canvas_layer_node()
435
+ if layer != null and not layer.visible:
436
+ hiding.append(layer)
437
+ at = item.get_viewport()
438
+ elif spatial != null:
439
+ if not spatial.visible:
440
+ hiding.append(spatial)
441
+ at = spatial.get_parent_node_3d()
442
+ if at == null:
443
+ at = spatial.get_viewport()
444
+ else:
445
+ var window: Window = at as Window
446
+ if window != null and not window.visible:
447
+ hiding.append(window)
448
+ at = null
449
+ return hiding
450
+
451
+
452
+ ## Whether [param node] is something drawn and on show, for naming what stands beside a hidden one.
453
+ static func _shown(node: Node) -> bool:
454
+ var item: CanvasItem = node as CanvasItem
455
+ if item != null:
456
+ return item.is_visible_in_tree()
457
+ var spatial: Node3D = node as Node3D
458
+ if spatial != null:
459
+ return spatial.is_visible_in_tree()
460
+ var layer: CanvasLayer = node as CanvasLayer
461
+ if layer != null:
462
+ return layer.visible
463
+ return false
464
+
465
+
379
466
  ## Everything in the tree [param root] heads that a pointer can land on: each control on screen
380
467
  ## that does not let the pointer through, and each window embedded in another.
381
468
  static func _pointer_takers(root: Node) -> Array[Node]:
@@ -5,6 +5,7 @@ extends RefCounted
5
5
 
6
6
  const Menus = preload("runtime_menus.gd")
7
7
  const Read = preload("reading.gd")
8
+ const Targets = preload("runtime_targets.gd")
8
9
  const Values = preload("runtime_values.gd")
9
10
  const Words = preload("runtime_words.gd")
10
11
 
@@ -251,7 +252,11 @@ func choose(params: Dictionary) -> Dictionary:
251
252
  if holder != null and holder.disabled:
252
253
  return {"type": "error", "message": "%s is disabled, so its menu cannot be opened" % node_path}
253
254
  if holder != null and not holder.is_visible_in_tree():
254
- return {"type": "error", "message": "%s is not visible, so its menu cannot be opened" % node_path}
255
+ return {
256
+ "type": "error",
257
+ "message":
258
+ "%s is not visible%s, so its menu cannot be opened" % [node_path, Targets.why_hidden(holder)]
259
+ }
255
260
 
256
261
  # Shown first, because a menu nobody has opened has no focus to move and the press would go to
257
262
  # whatever is behind it, and before the item is looked for, because opening is when a game fills
package/build/index.js CHANGED
@@ -29817,18 +29817,25 @@ function count(attributes, name) {
29817
29817
  return Number.isFinite(value) ? value : 0;
29818
29818
  }
29819
29819
  function caseOf(suite, element) {
29820
- const outcome = element.children.find((child) => ["failure", "error", "skipped"].includes(child.name));
29821
- const status = outcome === undefined ? "passed" : outcome.name === "failure" ? "failed" : outcome.name === "error" ? "error" : "skipped";
29822
- const detail = outcome?.text.trim() ?? "";
29820
+ const of = (kind) => element.children.filter((child) => child.name === kind).map((child) => {
29821
+ const detail = child.text.trim();
29822
+ return { kind, message: child.attributes["message"] ?? null, detail: detail === "" ? null : detail };
29823
+ });
29824
+ const reports = [...of("failure"), ...of("error"), ...of("skipped")];
29825
+ const status = reports.some((report) => report.kind === "error") ? "error" : reports.some((report) => report.kind === "failure") ? "failed" : reports.length > 0 ? "skipped" : "passed";
29823
29826
  return {
29824
29827
  suite: suite.attributes["name"] ?? "",
29825
29828
  name: element.attributes["name"] ?? "",
29826
29829
  status,
29827
29830
  time: count(element.attributes, "time"),
29828
- message: outcome?.attributes["message"] ?? null,
29829
- detail: detail === "" ? null : detail
29831
+ message: reports[0]?.message ?? null,
29832
+ detail: reports[0]?.detail ?? null,
29833
+ reports
29830
29834
  };
29831
29835
  }
29836
+ function failedAnAssertion(entry) {
29837
+ return entry.reports.some((report) => report.kind === "failure");
29838
+ }
29832
29839
  function pathOf(suite) {
29833
29840
  const directory = suite.attributes["package"];
29834
29841
  const name = suite.attributes["name"];
@@ -29843,7 +29850,7 @@ function parseJUnit(xml) {
29843
29850
  const suiteElements = top.name === "testsuite" ? [top] : top.children.filter((child) => child.name === "testsuite");
29844
29851
  const suites = suiteElements.map((suite) => {
29845
29852
  const cases = suite.children.filter((child) => child.name === "testcase").map((element) => caseOf(suite, element));
29846
- const failures = cases.filter((entry) => entry.status === "failed").length;
29853
+ const failures = cases.filter(failedAnAssertion).length;
29847
29854
  const errors = cases.filter((entry) => entry.status === "error").length;
29848
29855
  const written = (kind) => suite.children.filter((child) => child.name === "testcase").reduce((sum, element) => sum + element.children.filter((child) => child.name === kind).length, 0);
29849
29856
  return {
@@ -29863,7 +29870,7 @@ function parseJUnit(xml) {
29863
29870
  const all = suites.flatMap((suite) => suite.cases);
29864
29871
  return {
29865
29872
  tests: all.length,
29866
- failures: all.filter((entry) => entry.status === "failed").length,
29873
+ failures: all.filter(failedAnAssertion).length,
29867
29874
  errors: all.filter((entry) => entry.status === "error").length,
29868
29875
  hookFailures: suites.reduce((sum, suite) => sum + suite.hookFailures, 0),
29869
29876
  hookErrors: suites.reduce((sum, suite) => sum + suite.hookErrors, 0),
@@ -30010,6 +30017,25 @@ ${note}`;
30010
30017
  return detail === entry.detail ? entry : { ...entry, detail };
30011
30018
  });
30012
30019
  }
30020
+ function failedCases(report, printed) {
30021
+ const cases = report.suites.flatMap((suite) => suite.cases.filter((entry) => entry.status === "failed" || entry.status === "error").map((entry) => ({ entry, path: suite.path })));
30022
+ const read = withActualsPrinted(cases.flatMap(({ entry }) => entry.reports), printed);
30023
+ let next = 0;
30024
+ return cases.map(({ entry, path }) => {
30025
+ const reports = read.slice(next, next + entry.reports.length);
30026
+ next += entry.reports.length;
30027
+ return {
30028
+ suite: entry.suite,
30029
+ name: entry.name,
30030
+ status: entry.status,
30031
+ time: entry.time,
30032
+ path,
30033
+ message: reports[0]?.message ?? null,
30034
+ detail: reports[0]?.detail ?? null,
30035
+ ...reports.length > 1 ? { reports } : {}
30036
+ };
30037
+ });
30038
+ }
30013
30039
  var MASKED_BRACKET = "[lb]";
30014
30040
  function masked(expected, actual) {
30015
30041
  const allSpelled = (value) => !PRINTED_DICTIONARY.test(value) && value.split("[").length === value.split(MASKED_BRACKET).length;
@@ -33008,7 +33034,8 @@ function recordAt(path) {
33008
33034
  arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : [],
33009
33035
  ...typeof fields["command"] === "string" ? { command: fields["command"] } : {},
33010
33036
  ...typeof fields["exitCode"] === "number" ? { exitCode: fields["exitCode"] } : {},
33011
- ...typeof fields["exitSignal"] === "string" ? { exitSignal: fields["exitSignal"] } : {}
33037
+ ...typeof fields["exitSignal"] === "string" ? { exitSignal: fields["exitSignal"] } : {},
33038
+ ...typeof fields["endedAt"] === "number" ? { endedAt: fields["endedAt"] } : {}
33012
33039
  };
33013
33040
  }
33014
33041
  async function runningAs(pid) {
@@ -34053,7 +34080,7 @@ var TOOL_SPECS = [
34053
34080
  },
34054
34081
  {
34055
34082
  name: "project_test",
34056
- 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; identical engine messages come back once under engineEntries, with times saying how often they were printed, and engineErrors counts every one. The class list is rebuilt first, so a suite written a moment ago is found, and the rebuild's answer is under classes, with the classes each addon declares counted under addedInAddons and changedInAddons rather than named: a class it puts back that the cache had after the last rebuild is named under classes.lostSinceLastRebuild, which is the editor writing its shorter list over the file on every save until restarted. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. On macOS the engine reads user:// off HOME and the run writes where the player keeps theirs, which the answer says under savesNote rather than leaving to be found in a save list. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. A script that does not load stops gdUnit4 before any suite runs: its errors come back in full under scriptErrors, and the scripts that failed only because one they depend on did are listed by path under failedThroughDependency. When they failed on a class whose own script has a parser error, which gdUnit4 never names, that script is checked by the engine and its error comes first under scriptErrors, with the class it declares under declares, and the errors a script has only because that class did not resolve, which the engine reads as Variant, are kept apart under followFromUnresolved. A client that sends a progress token is told as each suite finishes, and at least every twenty seconds through a long one, so an idle limit does not end a long tier. A run that ends without a report, at its timeout or otherwise, names the suites it finished under suitesFinished, the failing ones with their failed cases under suitesFailing, and the one it was in under suiteRunning. When a client cuts a run off, that is kept in the project and the next project_test there carries it under previousRunCut. 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. A run whose every case passed is still not called passed when it printed a script error that no case failed on: gdUnit4 fails a case on the script errors its logger sees, and it cannot see a stack overflow, so the cases pass on whatever the faulted calls returned. Those errors come back under uncaughtScriptErrors, and under engineEntries by index with detailUnder rather than with their backtrace a second time; a backtrace that repeats, as a recursion's does, has each repeated frame or cycle of frames written once with the frames it covers and how many times. One raised inside assert_error(...).is_runtime_error is the test's own and does not count, and none count when the project turns off gdUnit4's report/godot/script_error setting. Needs gdUnit4 under addons/gdUnit4.",
34083
+ 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. Each entry under failed carries its first report as message and detail, and, when it has more than one, every report under reports in the order the case met them: gdUnit4 goes on past a failed assertion, and a case can fail several and then stop on a runtime error, which makes its status error. failures counts the cases that failed an assertion and errors the cases that hit an error, so such a case counts in both. 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; identical engine messages come back once under engineEntries, with times saying how often they were printed, and engineErrors counts every one. The class list is rebuilt first, so a suite written a moment ago is found, and the rebuild's answer is under classes, with the classes each addon declares counted under addedInAddons and changedInAddons rather than named: a class it puts back that the cache had after the last rebuild is named under classes.lostSinceLastRebuild, which is the editor writing its shorter list over the file on every save until restarted. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. On macOS the engine reads user:// off HOME and the run writes where the player keeps theirs, which the answer says under savesNote rather than leaving to be found in a save list. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. A script that does not load stops gdUnit4 before any suite runs: its errors come back in full under scriptErrors, and the scripts that failed only because one they depend on did are listed by path under failedThroughDependency. When they failed on a class whose own script has a parser error, which gdUnit4 never names, that script is checked by the engine and its error comes first under scriptErrors, with the class it declares under declares, and the errors a script has only because that class did not resolve, which the engine reads as Variant, are kept apart under followFromUnresolved. A client that sends a progress token is told as each suite finishes, and at least every twenty seconds through a long one, so an idle limit does not end a long tier. A run that ends without a report, at its timeout or otherwise, names the suites it finished under suitesFinished, the failing ones with their failed cases under suitesFailing, and the one it was in under suiteRunning. When a client cuts a run off, that is kept in the project and the next project_test there carries it under previousRunCut. 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. A run whose every case passed is still not called passed when it printed a script error that no case failed on: gdUnit4 fails a case on the script errors its logger sees, and it cannot see a stack overflow, so the cases pass on whatever the faulted calls returned. Those errors come back under uncaughtScriptErrors, and under engineEntries by index with detailUnder rather than with their backtrace a second time; a backtrace that repeats, as a recursion's does, has each repeated frame or cycle of frames written once with the frames it covers and how many times. One raised inside assert_error(...).is_runtime_error is the test's own and does not count, and none count when the project turns off gdUnit4's report/godot/script_error setting. Needs gdUnit4 under addons/gdUnit4.",
34057
34084
  parameters: {
34058
34085
  projectPath: PROJECT_PATH,
34059
34086
  path: {
@@ -34497,7 +34524,7 @@ var TOOL_SPECS = [
34497
34524
  },
34498
34525
  {
34499
34526
  name: "editor_output",
34500
- description: "What the project started by editor_run has printed, as entries with a severity: the errors and warnings the engine reported, each with where it happened, and everything else as info. For a run the editor plays the reports come from the game itself, through the runtime addon, since the editor's debugger relays what a game prints and not what it reports; a played game without the runtime addon answers here with its prints alone, with errorsUnread true and no clean, since what it raised reaches nothing here. Answers with the counts and the verdict as well as the entries. heldAt is three answers: null for a run this server knows is not held, running or over, an object for one it knows is held at a breakpoint, whether the adapter said so or the runtime did by accepting a connection and never answering, and absent with heldUnknown beside it for a server that connected to the debugger after the stop and has no runtime to ask, since the adapter reports a stop only to the sessions connected when it happened. A run that has quit still answers here, with running false and its exit code, until the next one starts; a run ended by a signal has no exit code, and exitSignal names the signal instead. A run this server started also answers with transcript, the file both its streams are written to: uncapped, written while the run is going, and the thing to read or tail for a long run rather than the engine's own log, which every engine start rotates away. Read the file for output and this answer for state: endedBy says whether gdharness ended the run, and a transcript that has stopped growing is a run between prints rather than a run that is over. running is asked of the operating system whenever there is a process to ask about, which is every run this server started and every editor-played run whose game announced its runtime. For an editor-played run that never announced, there is no process id here and running is the editor's answer, which can lag: one measured elsewhere reported a game as playing for fifteen seconds after its process had been ended from outside. editor_status names that game under runtimes, which does not depend on the editor answering. op: \"editor\" answers about the editor itself instead, which is a different log and the only way anything here can read what an editor printed: its console reaches no plugin, so the addon cannot scrape it, and an editor gdharness opened is asked for a log file as it starts. That covers the whole session from the editor's first line, which is where the output worth reading usually is, since the class cache, the plugins and the language server all say what they have to say before any harness has connected. The answer carries the entries in the order they were printed and, beside them under repeated, each message shape several lines share: how many, what varied in each slot of it and how many different values stood there, the first occurrence, and the ordinary lines above the group, found by walking back past the group rather than by counting lines. An editor somebody opened by hand has no console here and is refused saying so rather than answered empty, because an editor that printed nothing and an editor nobody captured are not the same thing.",
34527
+ description: "What the project started by editor_run has printed, as entries with a severity: the errors and warnings the engine reported, each with where it happened, and everything else as info. For a run the editor plays the reports come from the game itself, through the runtime addon, since the editor's debugger relays what a game prints and not what it reports; a played game without the runtime addon answers here with its prints alone, with errorsUnread true and no clean, since what it raised reaches nothing here. Answers with the counts and the verdict as well as the entries. heldAt is three answers: null for a run this server knows is not held, running or over, an object for one it knows is held at a breakpoint, whether the adapter said so or the runtime did by accepting a connection and never answering, and absent with heldUnknown beside it for a server that connected to the debugger after the stop and has no runtime to ask, since the adapter reports a stop only to the sessions connected when it happened. A run that has quit still answers here, with running false and its exit code, until the next one starts; a run ended by a signal has no exit code, and exitSignal names the signal instead. A run this server started also answers with transcript, the file both its streams are written to: uncapped, written while the run is going, and the thing to read or tail for a long run rather than the engine's own log, which every engine start rotates away. Read the file for output and this answer for state: endedBy says whether gdharness ended the run, and a transcript that has stopped growing is a run between prints rather than a run that is over. running is asked of the operating system whenever there is a process to ask about, which is every run this server started and every editor-played run whose game announced its runtime. For an editor-played run that never announced, there is no process id here and running is the editor's answer, which can lag: one measured elsewhere reported a game as playing for fifteen seconds after its process had been ended from outside. editor_status names that game under runtimes, which does not depend on the editor answering. op: \"editor\" answers about the editor itself instead, which is a different log and the only way anything here can read what an editor printed: its console reaches no plugin, so the addon cannot scrape it, and an editor gdharness opened is asked for a log file as it starts. That covers the whole session from the editor's first line, which is where the output worth reading usually is, since the class cache, the plugins and the language server all say what they have to say before any harness has connected. The answer carries the entries in the order they were printed and, beside them under repeated, each message shape several lines share: how many, what varied in each slot of it and how many different values stood there, the first occurrence, and the ordinary lines above the group, found by walking back past the group rather than by counting lines. An editor somebody opened by hand has no console here and is refused saying so rather than answered empty, because an editor that printed nothing and an editor nobody captured are not the same thing. startedAt is when the run began and elapsedMs how long it has been going; for a run that is over, endedAt is when it ended and elapsedMs how long it ran, and both are left out when nobody saw the end, as for a game closed in the editor or a run picked up from a note written before ends were recorded.",
34501
34528
  parameters: {
34502
34529
  severity: {
34503
34530
  type: "string",
@@ -34545,7 +34572,7 @@ var TOOL_SPECS = [
34545
34572
  },
34546
34573
  {
34547
34574
  name: "editor_status",
34548
- description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable: runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. game.run names the run processActive is about, the one editor_output and editor_run stop answer for: its pid, its project, and adopted true when this server read it back from another server rather than starting it. It stays named after the run ends, beside processActive false, so editor_output can still read what that run printed; null when there is no run at all. editor.lspListening and editor.dapListening say whether anything accepts connections on the ports the editor names, lspPort and dapPort: Godot binds each once, as the editor starts, and reports a failure only in the log panel of the editor itself, so a false comes with serversNote, and editor_launch restart binds it again. editor.scan says whether the editor is scanning or importing and how many milliseconds ago its last scan finished (finishedMsAgo), or is null when it did not answer: the editor rewrites the class cache just after a scan, and editor_run waits that out before starting a game, saying so under waitedForEditorScanMs. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. Each game also says which runtime addon it loaded, under addonVersion (null when it cannot say); addonIsStale is true when that code is not what this server ships, and staleNote says whether the project needs gdharness upgrade, the game a restart, or this server a reconnect. A second checkout of a project, such as a git worktree, carries addons of its own and is where this shows. Every runtime_* answer from such a game carries addonIsStale and staleNote too. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. True beside restartUnderway is a restart still in progress, its server alive and within the wait for the old editor to go: the editor is coming, and opening another would put two on one project. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. Any editor of this project running and not yet connected, whoever opened it, makes it true as well and is named under editorsNotYetConnected with its pid and start time, so a restart a reconnect interrupted after its launch is not read as nothing being there. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved and, when it was the server this one replaced, which pid kept it. A server replacing another of its project that holds the configured port waits for it to stand down and takes the port over, which takes a few seconds; bridgeHandover says so while it waits, and it moves only if that server has not let go within the wait. An editor that has connected and not yet greeted, which lasts a frame and longer while it imports, reports nothing about itself: greeting says so, addonIsStale is absent rather than guessed, and a restart asked then waits for the greeting to learn who opened it. startedByAnEditor is an editor another editor started, by Godot's own restart or the project manager, which has none of the arguments a server gave the one before it; startedByAnEditorNote says what that costs. An engine run that loads the editor addon to do one job and quit, such as a gate's godot --headless --import, an export or a script, is not taken as the editor: a process that greets without a server's mark has its command line read first, and while that is read identifying is true and connected false; one started with such an option is turned away, and turnedAway names the last one, its pid and the option. Every other call made while an editor is arriving, before its greeting or while it is looked up, waits up to ten seconds for it to settle rather than acting on connected as it reads in that window.",
34575
+ description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable: runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. game.run names the run processActive is about, the one editor_output and editor_run stop answer for: its pid, its project, and adopted true when this server read it back from another server rather than starting it. It stays named after the run ends, beside processActive false, so editor_output can still read what that run printed; null when there is no run at all. startedAt says when it began, and endedAt when it ended, wherever something saw the end: a run adopted from a note another server wrote can have ended hours before this server started. editor.lspListening and editor.dapListening say whether anything accepts connections on the ports the editor names, lspPort and dapPort: Godot binds each once, as the editor starts, and reports a failure only in the log panel of the editor itself, so a false comes with serversNote, and editor_launch restart binds it again. editor.scan says whether the editor is scanning or importing and how many milliseconds ago its last scan finished (finishedMsAgo), or is null when it did not answer: the editor rewrites the class cache just after a scan, and editor_run waits that out before starting a game, saying so under waitedForEditorScanMs. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. Each game also says which runtime addon it loaded, under addonVersion (null when it cannot say); addonIsStale is true when that code is not what this server ships, and staleNote says whether the project needs gdharness upgrade, the game a restart, or this server a reconnect. A second checkout of a project, such as a git worktree, carries addons of its own and is where this shows. Every runtime_* answer from such a game carries addonIsStale and staleNote too. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. True beside restartUnderway is a restart still in progress, its server alive and within the wait for the old editor to go: the editor is coming, and opening another would put two on one project. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. Any editor of this project running and not yet connected, whoever opened it, makes it true as well and is named under editorsNotYetConnected with its pid and start time, so a restart a reconnect interrupted after its launch is not read as nothing being there. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved and, when it was the server this one replaced, which pid kept it. A server replacing another of its project that holds the configured port waits for it to stand down and takes the port over, which takes a few seconds; bridgeHandover says so while it waits, and it moves only if that server has not let go within the wait. An editor that has connected and not yet greeted, which lasts a frame and longer while it imports, reports nothing about itself: greeting says so, addonIsStale is absent rather than guessed, and a restart asked then waits for the greeting to learn who opened it. startedByAnEditor is an editor another editor started, by Godot's own restart or the project manager, which has none of the arguments a server gave the one before it; startedByAnEditorNote says what that costs. An engine run that loads the editor addon to do one job and quit, such as a gate's godot --headless --import, an export or a script, is not taken as the editor: a process that greets without a server's mark has its command line read first, and while that is read identifying is true and connected false; one started with such an option is turned away, and turnedAway names the last one, its pid and the option. Every other call made while an editor is arriving, before its greeting or while it is looked up, waits up to ten seconds for it to settle rather than acting on connected as it reads in that window.",
34549
34576
  parameters: {},
34550
34577
  requires: []
34551
34578
  },
@@ -35976,7 +36003,7 @@ function profileAnswer(reading, limit) {
35976
36003
  notes.push(`${table.superCallers === 1 ? "One function calls" : `${table.superCallers} functions call`} through super, marked selfIncludesSuper: the engine takes every other call out of the caller's self time, and not a super call, so ${table.superCallers === 1 ? "its" : "their"} selfMs holds the overridden function's time, which that function's own row counts again. Self time therefore cannot be added along an override chain, and scriptMs counts the time under a super call once more for each level above it.`);
35977
36004
  }
35978
36005
  if (table.overheadDominated > 0 && table.perCallUs !== null) {
35979
- notes.push(`The profiler costs about ${table.perCallUs}µs a call in this run, measured on an empty function the runtime addon calls as it goes on, and that time lands in the self time of the function called. ${table.overheadDominated === 1 ? "One function has" : `${table.overheadDominated} functions have`} at least half ${table.overheadDominated === 1 ? "its" : "their"} selfMs in it, under overheadMs: a function that does little and is called often looks expensive here and costs that much less once the profiler is off, so inlining it gains little.`);
36006
+ notes.push(`The profiler costs about ${table.perCallUs}µs a call in this run, measured on an empty function the runtime addon calls as it goes on, and that time lands in the self time of the function called. ${table.overheadDominated === 1 ? "One function has" : `${table.overheadDominated} functions have`} at least half ${table.overheadDominated === 1 ? "its" : "their"} selfMs in it, under overheadMs: that much of its time is the profiler's and goes once the profiler is off, whether the function is inlined or not. What inlining saves is the call itself, which costs something without the profiler too, not the work inside it.`);
35980
36007
  }
35981
36008
  notes.push(...cappedNotes(totals));
35982
36009
  if (totals.unreadable > 0) {
@@ -37928,7 +37955,7 @@ class GodotServer {
37928
37955
  isError: true
37929
37956
  };
37930
37957
  }
37931
- const failed = withActualsPrinted(report.suites.flatMap((suite) => suite.cases.filter((entry) => entry.status === "failed" || entry.status === "error").map((entry) => ({ ...entry, path: suite.path }))), Buffer.concat(console2).toString("utf8"));
37958
+ const failed = failedCases(report, Buffer.concat(console2).toString("utf8"));
37932
37959
  const orphans = orphansPrinted(said);
37933
37960
  const leftBehind = new Map(orphans.suites.map((suite) => [suite.path, suite.orphans]));
37934
37961
  const orphansIn = (path) => path === null ? 0 : leftBehind.get(path) ?? 0;
@@ -38429,7 +38456,9 @@ class GodotServer {
38429
38456
  return {
38430
38457
  pid: run.pid ?? this.announcedPidOf(run) ?? run.announcedPid ?? null,
38431
38458
  ...run.throughEditor || run.projectPath === null ? {} : { project: run.projectPath },
38432
- ...run.pickedUp === true ? { adopted: true } : {}
38459
+ ...run.pickedUp === true ? { adopted: true } : {},
38460
+ startedAt: new Date(run.startedAt).toISOString(),
38461
+ ...run.endedAt === undefined ? {} : { endedAt: new Date(run.endedAt).toISOString() }
38433
38462
  };
38434
38463
  }
38435
38464
  async handleEditorStatus() {
@@ -39202,10 +39231,11 @@ class GodotServer {
39202
39231
  return "unreached";
39203
39232
  }
39204
39233
  running.endedThrough = "editor";
39205
- if (announced === undefined) {
39206
- return await this.untilTheEditorStopsPlaying() ? "gone" : "lingering";
39234
+ const stopped = announced === undefined ? await this.untilTheEditorStopsPlaying() : await untilGone([announced]);
39235
+ if (stopped) {
39236
+ running.endedAt = Date.now();
39207
39237
  }
39208
- return await untilGone([announced]) ? "gone" : "lingering";
39238
+ return stopped ? "gone" : "lingering";
39209
39239
  }
39210
39240
  if (running.pid === null) {
39211
39241
  return "gone";
@@ -39254,6 +39284,7 @@ class GodotServer {
39254
39284
  if (landed && gone) {
39255
39285
  running.exitCode = null;
39256
39286
  running.exitSignal = "SIGTERM";
39287
+ running.endedAt ??= Date.now();
39257
39288
  }
39258
39289
  if (landed) {
39259
39290
  running.endedThrough = "signal";
@@ -39278,6 +39309,7 @@ class GodotServer {
39278
39309
  if (asked === "signalled" && gone) {
39279
39310
  run.exitCode = null;
39280
39311
  run.exitSignal = "SIGTERM";
39312
+ run.endedAt ??= Date.now();
39281
39313
  }
39282
39314
  run.log.record(gone ? "info" : "warning", !gone ? `gdharness had the keeper holding pid ${pid} end it, and it was still running ${stopWaitMs() / 1000} seconds later.` : asked === "signalled" ? `gdharness ended pid ${pid} through the keeper holding it.` : `gdharness asked the keeper holding pid ${pid} to end it, and it had already gone.`);
39283
39315
  return gone ? "gone" : "lingering";
@@ -39325,6 +39357,7 @@ class GodotServer {
39325
39357
  this.logDebug(`Godot process exited with code ${code ?? "none"}, signal ${signal ?? "none"}`);
39326
39358
  started.exitCode = code;
39327
39359
  started.exitSignal = code === null ? signal : null;
39360
+ started.endedAt = Date.now();
39328
39361
  });
39329
39362
  child.on("error", (err) => {
39330
39363
  console.error("Failed to start Godot process:", err);
@@ -39639,6 +39672,9 @@ class GodotServer {
39639
39672
  adopted.log.finish();
39640
39673
  adopted.exitCode = record.exitCode ?? null;
39641
39674
  adopted.exitSignal = record.exitSignal ?? null;
39675
+ if (record.endedAt !== undefined) {
39676
+ adopted.endedAt = record.endedAt;
39677
+ }
39642
39678
  const exitMayBeComing = !alive(record.pid) && !exited(adopted);
39643
39679
  if (!exitMayBeComing) {
39644
39680
  adopted.endedUnwatched = !exited(adopted);
@@ -39932,6 +39968,9 @@ class GodotServer {
39932
39968
  if (record !== null && record.pid === run.pid && (record.exitCode !== undefined || record.exitSignal !== undefined)) {
39933
39969
  run.exitCode = record.exitCode ?? null;
39934
39970
  run.exitSignal = record.exitSignal ?? null;
39971
+ if (record.endedAt !== undefined) {
39972
+ run.endedAt = record.endedAt;
39973
+ }
39935
39974
  return true;
39936
39975
  }
39937
39976
  if (Date.now() >= until) {
@@ -40078,7 +40117,8 @@ class GodotServer {
40078
40117
  project: run.throughEditor ? undefined : run.projectPath ?? undefined,
40079
40118
  transcript: run.transcript ?? undefined,
40080
40119
  startedAt: new Date(run.startedAt).toISOString(),
40081
- elapsedMs: Date.now() - run.startedAt,
40120
+ endedAt: stillRunning(run) || run.endedAt === undefined ? undefined : new Date(run.endedAt).toISOString(),
40121
+ elapsedMs: stillRunning(run) ? Date.now() - run.startedAt : run.endedAt === undefined ? undefined : run.endedAt - run.startedAt,
40082
40122
  cpuSeconds,
40083
40123
  note: notes.length > 0 ? notes.join(" ") : undefined,
40084
40124
  omitted: selected.omitted,
package/build/keeper.js CHANGED
@@ -421,7 +421,8 @@ function recordRunEnded(projectPath, pid, ending) {
421
421
  const written = {
422
422
  ...record,
423
423
  ...ending.exitCode === null ? {} : { exitCode: ending.exitCode },
424
- ...ending.exitSignal === null ? {} : { exitSignal: ending.exitSignal }
424
+ ...ending.exitSignal === null ? {} : { exitSignal: ending.exitSignal },
425
+ endedAt: Date.now()
425
426
  };
426
427
  try {
427
428
  writeFileSync2(path, JSON.stringify(written, null, 2), "utf8");
@@ -457,7 +458,8 @@ function recordAt(path) {
457
458
  arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : [],
458
459
  ...typeof fields["command"] === "string" ? { command: fields["command"] } : {},
459
460
  ...typeof fields["exitCode"] === "number" ? { exitCode: fields["exitCode"] } : {},
460
- ...typeof fields["exitSignal"] === "string" ? { exitSignal: fields["exitSignal"] } : {}
461
+ ...typeof fields["exitSignal"] === "string" ? { exitSignal: fields["exitSignal"] } : {},
462
+ ...typeof fields["endedAt"] === "number" ? { endedAt: fields["endedAt"] } : {}
461
463
  };
462
464
  }
463
465
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
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",