gdharness 1.3.6 → 1.3.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
@@ -8776,6 +8776,7 @@ function parseJUnit(xml) {
8776
8776
  throw new MalformedReportError("No <testsuites> or <testsuite> in the report");
8777
8777
  }
8778
8778
  const suiteElements = top.name === "testsuite" ? [top] : top.children.filter((child) => child.name === "testsuite");
8779
+ const where = (suite) => suite.path ?? suite.name;
8779
8780
  const suites = suiteElements.map((suite) => {
8780
8781
  const cases = suite.children.filter((child) => child.name === "testcase").map((element) => caseOf(suite, element));
8781
8782
  const failures = cases.filter(failedAnAssertion).length;
@@ -8795,6 +8796,7 @@ function parseJUnit(xml) {
8795
8796
  cases
8796
8797
  };
8797
8798
  });
8799
+ suites.sort((a, b) => where(a) < where(b) ? -1 : where(a) > where(b) ? 1 : 0);
8798
8800
  const all = suites.flatMap((suite) => suite.cases);
8799
8801
  return {
8800
8802
  tests: all.length,
@@ -11856,7 +11858,7 @@ function inspectProject(projectPath) {
11856
11858
  problems
11857
11859
  };
11858
11860
  }
11859
- var ADDONS, EDITOR_PLUGINS, RUNTIME_AUTOLOAD, SCRIPT_RUNS_SETTING = "gdharness/runtime/serve_script_runs", BIND_ADDRESS_SETTING = "gdharness/runtime/bind_address", PORT_SETTING = "gdharness/runtime/port", DIGEST_MARKER = ".gdharness-digest", RUNTIME_ADDON = "gdharness_runtime", shippedDigest, shippedRuntime, GDUNIT4_CI_RUNNER = "addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd";
11861
+ var ADDONS, EDITOR_PLUGINS, RUNTIME_AUTOLOAD, SCRIPT_RUNS_SETTING = "gdharness/runtime/serve_script_runs", QUIET_SCENES_SETTING = "gdharness/runtime/quiet_scenes_under", BIND_ADDRESS_SETTING = "gdharness/runtime/bind_address", PORT_SETTING = "gdharness/runtime/port", DIGEST_MARKER = ".gdharness-digest", RUNTIME_ADDON = "gdharness_runtime", shippedDigest, shippedRuntime, GDUNIT4_CI_RUNNER = "addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd";
11860
11862
  var init_setup = __esm(() => {
11861
11863
  init_class_cache();
11862
11864
  init_headless();
@@ -12448,14 +12450,14 @@ var init_tool_definitions = __esm(() => {
12448
12450
  name: "project_test",
12449
12451
  title: "Run tests",
12450
12452
  effect: "additive",
12451
- 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, its reports 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. Identical reports fold into one with times, at most twenty different ones are kept (reportsLeftOut counts the rest), and past about a megabyte the largest cases keep only their first message (failedWithoutDetail). 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, not listed, and an engine message keeps the frames above gdUnit4, not the twenty inside it; 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. What changed on disk since it was imported is then imported before any suite runs, and named under importedFirst. On Windows and Linux the run gets a user:// of its own, away from a player's saves; on macOS the engine reads user:// off HOME, so it writes where the player keeps theirs, and savesNote says so. A run that found nothing to run is never called a pass, 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. An autoload that does not compile stops the run at once, its errors listed the same way. A client sending a progress token is told as each suite finishes, and at least every twenty seconds. 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.",
12452
- answer: "time is gdUnit4's own sum of the seconds its cases took, from its report. tookMs is the whole call in milliseconds, from the class rebuild to the engine's exit, which is what a caller waits for. " + IMPORTED_FIRST,
12453
+ 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, its reports 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. Identical reports fold into one with times, at most twenty different ones are kept (reportsLeftOut counts the rest), and past about a megabyte the largest cases keep only their first message (failedWithoutDetail). 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, not listed, and an engine message keeps the frames above gdUnit4, not the twenty inside it; identical engine messages come back once under engineEntries, with times saying how often they were printed. 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. What changed on disk since it was imported is then imported before any suite runs, and named under importedFirst. On Windows and Linux the run gets a user:// of its own, away from a player's saves; on macOS the engine reads user:// off HOME, so it writes where the player keeps theirs, and savesNote says so. A run that found nothing to run is never called a pass, 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. An autoload that does not compile stops the run at once, its errors listed the same way. A client sending a progress token is told as each suite finishes, and at least every twenty seconds. 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 is not listed there, and none are when the project turns off gdUnit4's report/godot/script_error setting; a suite declares them all the same. Needs gdUnit4 under addons/gdUnit4.",
12454
+ answer: "time is gdUnit4's own sum of the seconds its cases took, from its report. tookMs is the whole call in milliseconds, from the class rebuild to the engine's exit, which is what a caller waits for. The run is judged as gdharness test judges it: undeclaredErrors names each engine error that no suite declared in its GDHARNESS_EXPECTED_ERRORS constant, with the suite and the case that printed it, and declaredNotPrinted each declared error its case did not print. Either fails the run, and each suite holding one is listed with its count. engineErrors counts every engine error the run printed but those a suite declared. " + IMPORTED_FIRST,
12453
12455
  parameters: {
12454
12456
  projectPath: PROJECT_PATH,
12455
12457
  path: {
12456
12458
  type: ["string", "array"],
12457
12459
  items: { type: "string" },
12458
- description: "A test directory or suite file inside the project, or a list of them, all run on one engine: a named set of suites boots once rather than once per suite. An entry given twice, or a suite inside a directory also listed, runs once. An entry that is not there is left out and named under missing, the rest run, and such a run is never called passed; when no entry is there the run is refused before the engine starts, naming each one. Default test."
12460
+ description: "A test directory or suite file inside the project, or a list of them, all run on one engine: a named set of suites boots once rather than once per suite. An entry given twice, or a suite inside a directory also listed, runs once. An entry that is not there is left out and named under missing, the rest run, and such a run is never called passed; when no entry is there the run is refused before the engine starts, naming each one. Default tests and test, those the project has, which is where gdharness test looks."
12459
12461
  },
12460
12462
  ignore: {
12461
12463
  type: "array",
@@ -12696,7 +12698,7 @@ var init_tool_definitions = __esm(() => {
12696
12698
  {
12697
12699
  name: "scene_animation",
12698
12700
  title: "Animations",
12699
- description: "Animations in an AnimationPlayer and states in an AnimationTree state machine. read answers with every track of an animation in the shape add_track takes, so a track read off one animation can be added to another. add_track on a path the animation already has a track of that type for adds the keys to that track, as the editor keys a property it already animates, answering existingTrack true and keysAdded beside keys, the track's count; a key at a time that track already has a key at is refused. A key past the animation's end, where it never plays, is written and named under pastLength. remove clears the player's autoplay when it named the animation and names under statesPlayingNothing the states that played it. read and read_states need no editor: one serving the project answers and says whether it holds unsaved changes to the scene, and with none a headless engine reads the file without running the scene's scripts. The ops that write go through the editor when one serves the project; with none, a headless engine runs the editor's own tool and the answer says so under writtenBy. That engine runs every script of the file, where the editor runs only tool scripts, so what a script changes as the file loads is put back before anything is saved, and a file where that cannot be done, such as a scene whose script does not compile, is refused and nothing is written. An editor of the project that is running and has not reached this server is waited for rather than written around.",
12701
+ description: "Animations in an AnimationPlayer and states in an AnimationTree state machine. The player and the tree are nodes, so scene_node add puts one in the scene first. read answers with every track of an animation in the shape add_track takes, so a track read off one animation can be added to another. add_track on a path the animation already has a track of that type for adds the keys to that track, as the editor keys a property it already animates, answering existingTrack true and keysAdded beside keys, the track's count; a key at a time that track already has a key at is refused. A key past the animation's end, where it never plays, is written and named under pastLength. remove clears the player's autoplay when it named the animation and names under statesPlayingNothing the states that played it. read and read_states need no editor: one serving the project answers and says whether it holds unsaved changes to the scene, and with none a headless engine reads the file without running the scene's scripts. The ops that write go through the editor when one serves the project; with none, a headless engine runs the editor's own tool and the answer says so under writtenBy. That engine runs every script of the file, where the editor runs only tool scripts, so what a script changes as the file loads is put back before anything is saved, and a file where that cannot be done, such as a scene whose script does not compile, is refused and nothing is written. An editor of the project that is running and has not reached this server is waited for rather than written around.",
12700
12702
  parameters: {
12701
12703
  projectPath: PROJECT_PATH,
12702
12704
  scenePath: SCENE_PATH,
@@ -13042,7 +13044,7 @@ var init_tool_definitions = __esm(() => {
13042
13044
  name: "editor_launch",
13043
13045
  title: "Open, restart or close the editor, or bind its debug adapter again",
13044
13046
  description: "Opens the Godot editor on a project, or restarts or closes the one already connected, or binds its debug adapter again. close saves open scenes, quits the editor and waits for its process to go, which is the only way to end a hidden one short of killing it; an editor this server opened that has not connected yet has nothing of this server's to save, so close stops its process instead. open answers once the process has started, and the editor dials in after its import; a call needing the editor made before then waits for the one this server opened, for as long as a restart waits, and is refused naming it if it has not arrived. open refuses while an editor of the project is already running and has not reached this server, naming its pid, rather than putting a second on the project. 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. An editor a server opened is started again by the server, visible unless hidden: true is passed again, once the old one has gone: it waits 90 seconds for that, or GDHARNESS_EDITOR_RESTART_TIMEOUT_MS, and an old editor still running after it is not replaced, since the new one would find its ports held. 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. Godot tries each server port once, as the editor starts, so a restart binds a debug adapter that came back without its port again, in the editor that came back. rebind does the same for the connected editor without restarting it, whoever opened it; a debug session open on the adapter ends with it and is opened again by the next debug call. 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 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 in editor_status rather than waiting for an editor that is not coming, and open finishes it. An open is refused when the editor it would start could never reach this server: a project other than the one this server serves, whose editor dials that project's own server; a project without the gdharness editor addon installed and enabled; and a project whose editor a restart is already bringing back.",
13045
- answer: "close answers settingsDropped, naming what its save took out of project.godot and left out when it took nothing, and settingsNote when the file could not be read to tell; an editor stopped before it connected is answered with beforeConnecting. restart answers with the version that came back, and says under settingsDropped which keys its save dropped, 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 answers lspListening and dapListening once the editor's servers are up, and debugAdapterRebound says whether binding the debug adapter again took. serversSaid gives the lines the editor's log panel has about its servers, the one place Godot says why a port would not bind. Godot clears that panel whenever a game is played, unless its run/output/always_clear_output_on_play setting is off, so after a play the lines from the editor's own start are gone. rebind answers rebound, with lspListening, dapListening and serversSaid. A restart that a server's end interrupted shows as restartInterrupted in editor_status, and the open that finishes it says so under finishesRestart.",
13047
+ answer: "close answers settingsDropped, naming what its save took out of project.godot and left out when it took nothing, and settingsNote when the file could not be read to tell; an editor stopped before it connected is answered with beforeConnecting. close and restart name under savedScenes the open scenes that had unsaved changes and were saved on the way out, an empty list when none had; a scene that would not save refuses the close or restart and leaves the editor running. Both answer tookMs, how long the editor took to go or to come back. restart answers with the version that came back, and says under settingsDropped which keys its save dropped, 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 answers lspListening and dapListening once the editor's servers are up. When either came back without its port, serversSaid gives the lines the editor's log panel has about its servers, the one place Godot says why a port would not bind, and when it was the debug adapter, which is bound again in the editor that came back, debugAdapterRebound says whether that took; neither is there when both came up. Godot clears that panel whenever a game is played, unless its run/output/always_clear_output_on_play setting is off, so after a play the lines from the editor's own start are gone. rebind answers rebound, with lspListening, dapListening and serversSaid, the lines that rebind made the panel print. A restart that a server's end interrupted shows as restartInterrupted in editor_status, and the open that finishes it says so under finishesRestart.",
13046
13048
  parameters: {
13047
13049
  projectPath: { ...PROJECT_PATH, ops: ["open"] },
13048
13050
  hidden: {
@@ -13595,7 +13597,11 @@ var init_tool_definitions = __esm(() => {
13595
13597
  },
13596
13598
  outputPath: {
13597
13599
  type: "string",
13598
- description: "Also save the picture as a PNG at this absolute path, for somebody to open later; the answer says where. Refused unless it ends in .png, its directory exists, it is outside the project, and no file is there already."
13600
+ description: "Also save the picture as a lossless PNG at this absolute path, for somebody to open later or a script to compare; the answer says where. Refused unless it ends in .png, its directory exists, it is outside the project, and no file is there already."
13601
+ },
13602
+ inline: {
13603
+ type: "boolean",
13604
+ description: "Send the picture back in the answer. Default true, as a JPEG when that is smaller than the PNG and the picture has no transparency (the text says so, with both sizes), since every picture an answer carries stays in the session for as long as it lasts. false sends none and needs outputPath, for a capture a script compares rather than one somebody looks at."
13599
13605
  }
13600
13606
  },
13601
13607
  requires: [],
@@ -13897,7 +13903,7 @@ var init_tool_definitions = __esm(() => {
13897
13903
  operations: {
13898
13904
  pause: {
13899
13905
  effect: "additive",
13900
- summary: "stop a game this server started where it is, answering with the stack. The game takes it between frames, and in a script every 2048 lines it runs, so a loop that sleeps between few lines can take seconds; the call waits thirty, and past that answers paused false with the request still standing. Stopped between frames, where no script runs, the game is stepped on to the next line of script it runs",
13906
+ summary: "stop a game this server started where it is, answering with the stack. The game takes it between frames, and in a script every 2048 lines it runs, so a loop that sleeps between few lines can take seconds; the call waits thirty, and past that answers paused false with the request still standing. Stopped between frames, where no script runs, the game is stepped on to the next line of script it runs, and stopped in gdharness's runtime addon, an autoload whose _process runs first in every frame, on to the next line of the game's own script, for up to five seconds; one whose stack is still the addon's after them says so in a note",
13901
13907
  requires: []
13902
13908
  },
13903
13909
  continue: { effect: "additive", summary: "resume after a breakpoint", requires: [] },
@@ -14428,6 +14434,316 @@ var init_desktop = __esm(() => {
14428
14434
  DIGEST = createHash4("sha256").update(SOURCE).digest("hex").slice(0, 16);
14429
14435
  });
14430
14436
 
14437
+ // src/suite-judge.ts
14438
+ import { readFileSync as readFileSync15 } from "node:fs";
14439
+ import { stripVTControlCharacters } from "node:util";
14440
+ function secondsOf(time) {
14441
+ let ms = 0;
14442
+ for (const [, amount, unit] of time.matchAll(/(\d+)(h|min|ms|s)\b/g)) {
14443
+ ms += Number(amount) * (unit === "h" ? 3600000 : unit === "min" ? 60000 : unit === "s" ? 1000 : 1);
14444
+ }
14445
+ return ms / 1000;
14446
+ }
14447
+ function funcOf(name) {
14448
+ const colon = name.indexOf(":");
14449
+ return colon < 0 ? name : name.slice(0, colon);
14450
+ }
14451
+ function readRun(printed) {
14452
+ const lines = stripVTControlCharacters(printed).replaceAll(`\r
14453
+ `, `
14454
+ `).split(`
14455
+ `);
14456
+ const suites = new Map;
14457
+ const errors = [];
14458
+ const leaks = [];
14459
+ let executed = null;
14460
+ let open = null;
14461
+ let during = null;
14462
+ let afterCase = [];
14463
+ let lastCase = null;
14464
+ const settleAfterCase = (owner) => {
14465
+ for (const held of afterCase) {
14466
+ errors.push({ ...held, suite: open?.path ?? null, during: owner });
14467
+ }
14468
+ afterCase = [];
14469
+ };
14470
+ for (const [at, line] of lines.entries()) {
14471
+ const opened = SUITE_OPENED.exec(line);
14472
+ if (opened?.[1] !== undefined) {
14473
+ settleAfterCase("after");
14474
+ open = suites.get(opened[1]) ?? { path: opened[1], cases: [], closed: null };
14475
+ suites.set(open.path, open);
14476
+ during = "before";
14477
+ lastCase = null;
14478
+ continue;
14479
+ }
14480
+ const caseLine = CASE_LINE.exec(line);
14481
+ if (caseLine !== null && open !== null) {
14482
+ const name = caseLine[2] ?? "";
14483
+ if (caseLine[3] === "STARTED") {
14484
+ settleAfterCase(lastCase ?? "before");
14485
+ during = name;
14486
+ } else {
14487
+ open.cases.push({
14488
+ name,
14489
+ func: funcOf(name),
14490
+ verdict: caseLine[3] ?? "",
14491
+ seconds: secondsOf(caseLine[4] ?? ""),
14492
+ at
14493
+ });
14494
+ lastCase = name;
14495
+ during = null;
14496
+ }
14497
+ continue;
14498
+ }
14499
+ const statistics = STATISTICS.exec(line);
14500
+ if (statistics !== null && open !== null) {
14501
+ settleAfterCase("after");
14502
+ open.closed = { verdict: statistics[1] ?? "", seconds: secondsOf(statistics[2] ?? "") };
14503
+ open = null;
14504
+ during = null;
14505
+ lastCase = null;
14506
+ continue;
14507
+ }
14508
+ const counted = EXECUTED.exec(line);
14509
+ if (counted?.[1] !== undefined) {
14510
+ executed = Number(counted[1]);
14511
+ continue;
14512
+ }
14513
+ if (LEAK.test(line)) {
14514
+ leaks.push(line.trim());
14515
+ continue;
14516
+ }
14517
+ if (suites.size === 0 && DRIVER_FALLBACK.test(line)) {
14518
+ errors.length = 0;
14519
+ continue;
14520
+ }
14521
+ const error = ERROR_LINE.exec(line);
14522
+ if (error === null) {
14523
+ continue;
14524
+ }
14525
+ const text = error[1] ?? "";
14526
+ if (open === null) {
14527
+ errors.push({ text, at, suite: null, during: null });
14528
+ } else if (during === null) {
14529
+ afterCase.push({ text, at });
14530
+ } else {
14531
+ errors.push({ text, at, suite: open.path, during });
14532
+ }
14533
+ }
14534
+ const last = open;
14535
+ const stoppedIn = last === null ? null : during === null ? `${last.path}, after ${lastCase ?? "its cases"}` : during === "before" ? `${last.path}, in before` : `${last.path} > ${during}`;
14536
+ settleAfterCase("after");
14537
+ const joined = lines.join(`
14538
+ `);
14539
+ const starved = STARVED.flatMap((pattern) => [...joined.matchAll(pattern)].map((found) => found[0]));
14540
+ const unloadable = scriptErrorsPrinted(lines).map((error) => `${error.path}${error.line === null ? "" : `:${String(error.line)}`}: ${error.message}`);
14541
+ return { lines, suites, errors, leaks, starved, executed, unloadable, stoppedIn };
14542
+ }
14543
+ function readExpected(file) {
14544
+ try {
14545
+ const parsed = JSON.parse(readFileSync15(file, "utf8"));
14546
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
14547
+ } catch {
14548
+ return {};
14549
+ }
14550
+ }
14551
+ function exitNamed(code) {
14552
+ const unsigned = code >>> 0;
14553
+ const status = WINDOWS_EXITS.get(unsigned);
14554
+ if (status !== undefined) {
14555
+ return `0x${unsigned.toString(16).toUpperCase()} (${status})`;
14556
+ }
14557
+ const gdunit = GDUNIT_EXITS.get(code);
14558
+ return gdunit === undefined ? String(code) : `${String(code)} (${gdunit})`;
14559
+ }
14560
+ function named(items) {
14561
+ const shown = items.slice(0, MOST_NAMED).join(", ");
14562
+ return items.length > MOST_NAMED ? `${shown} and ${String(items.length - MOST_NAMED)} more` : shown;
14563
+ }
14564
+ function judgeEngine(printed, handed, end) {
14565
+ const faults = [];
14566
+ const failing = new Set;
14567
+ const shownAt = new Set;
14568
+ const explained = end.code === GDUNIT_FAILED && [...printed.suites.values()].some((suite) => suite.cases.some((ran) => ran.verdict === "FAILED"));
14569
+ if (end.ended !== null) {
14570
+ faults.push(end.ended);
14571
+ } else if (end.code !== 0 && !explained) {
14572
+ faults.push(end.code === null ? `the engine was ended by ${end.signal ?? "a signal"}` : `the engine exited ${exitNamed(end.code)}`);
14573
+ }
14574
+ if (printed.unloadable.length > 0) {
14575
+ faults.push(`gdUnit4 could not load ${named(printed.unloadable)}, so it ran nothing`);
14576
+ }
14577
+ if (printed.executed === null || printed.executed === 0) {
14578
+ faults.push(printed.executed === null ? "the run never printed its summary" : "the run executed no test case at all, whatever it exited with");
14579
+ }
14580
+ for (const { path, cases } of handed.suites) {
14581
+ const suite = printed.suites.get(path);
14582
+ if (suite === undefined) {
14583
+ faults.push(`${path} never started`);
14584
+ failing.add(path);
14585
+ continue;
14586
+ }
14587
+ if (suite.closed === null) {
14588
+ faults.push(`${path} never finished`);
14589
+ failing.add(path);
14590
+ } else if (suite.closed.verdict !== "PASSED") {
14591
+ if (suite.cases.every((ran) => ran.verdict !== "FAILED")) {
14592
+ faults.push(`${path} reported ${suite.closed.verdict} with every case passing, so a hook failed`);
14593
+ }
14594
+ failing.add(path);
14595
+ }
14596
+ const finished = new Set(suite.cases.map((ran) => ran.func));
14597
+ const missing = cases.filter((func) => !finished.has(func));
14598
+ if (missing.length > 0 && suite.closed !== null) {
14599
+ faults.push(`${path} closed without running ${named(missing)}`);
14600
+ failing.add(path);
14601
+ }
14602
+ const wanted = new Set(cases);
14603
+ const unasked = [...finished].filter((func) => !wanted.has(func));
14604
+ if (unasked.length > 0) {
14605
+ faults.push(`${path} ran ${named(unasked)}, which gdharness did not read as cases of it to run here, so its ` + "plan of the cases was wrong");
14606
+ failing.add(path);
14607
+ }
14608
+ for (const ran of suite.cases) {
14609
+ if (ran.verdict === "FAILED" || ran.verdict === "FLAKY") {
14610
+ faults.push(ran.verdict === "FLAKY" ? `${path} > ${ran.name} failed before it passed on a retry` : `${path} > ${ran.name} failed`);
14611
+ failing.add(path);
14612
+ for (let at = ran.at;at < printed.lines.length && (at === ran.at || printed.lines[at]?.trim()); at += 1) {
14613
+ shownAt.add(at);
14614
+ }
14615
+ }
14616
+ }
14617
+ }
14618
+ const unexpected = unexpectedErrors(printed, handed.expected, handed.suites.map((suite) => suite.path));
14619
+ for (const error of unexpected.printed) {
14620
+ faults.push(error.suite === null ? `an engine error outside every suite: ${error.text}` : `an engine error in ${error.suite} > ${error.during ?? ""}: ${error.text}; ${declaringAdvice(error.during ?? "")}`);
14621
+ if (error.suite !== null) {
14622
+ failing.add(error.suite);
14623
+ }
14624
+ shownAt.add(error.at);
14625
+ shownAt.add(error.at + 1);
14626
+ }
14627
+ for (const missing of unexpected.neverPrinted) {
14628
+ faults.push(`${missing.suite} > ${missing.during} declares the error "${missing.text}", which it never printed`);
14629
+ failing.add(missing.suite);
14630
+ }
14631
+ for (const leak of printed.leaks) {
14632
+ faults.push(`the engine exited holding what it should have freed: ${leak}`);
14633
+ }
14634
+ const memoryExit = end.code !== null && MEMORY_EXITS.has(end.code >>> 0);
14635
+ const starved = memoryExit || printed.starved.length > 0;
14636
+ if (printed.starved.length > 0) {
14637
+ faults.push("the engine ran out of memory");
14638
+ }
14639
+ printed.lines.forEach((line, at) => {
14640
+ if (/^(?:Overall Summary|Executed test|Total execution time| ?Exit code)/.test(line.trim())) {
14641
+ shownAt.add(at);
14642
+ }
14643
+ });
14644
+ const passed = faults.length === 0;
14645
+ const passedSuites = handed.suites.map((suite) => suite.path).filter((path) => !failing.has(path));
14646
+ const cutShort = end.ended !== null || printed.executed === null || handed.suites.some(({ path }) => printed.suites.get(path)?.closed == null);
14647
+ return {
14648
+ passed,
14649
+ faults,
14650
+ passedSuites,
14651
+ starved,
14652
+ shown: passed ? [] : linesShown(printed.lines, shownAt, cutShort)
14653
+ };
14654
+ }
14655
+ function linesShown(lines, picked, cutShort) {
14656
+ const summary = [...picked].filter((at) => at < lines.length).sort((a, b) => a - b);
14657
+ const telling = summary.some((at) => !/^(?:Overall Summary|Executed test|Total execution|\s*Exit code)/.test(lines[at]?.trim() ?? ""));
14658
+ if (!telling && !cutShort) {
14659
+ return [];
14660
+ }
14661
+ if (!telling) {
14662
+ return lines.slice(Math.max(0, lines.length - TAIL)).filter((line, at, all) => at < all.length - 1 || line !== "");
14663
+ }
14664
+ const shown = [];
14665
+ let previous = -1;
14666
+ for (const at of summary) {
14667
+ if (previous >= 0 && at > previous + 1) {
14668
+ shown.push("...");
14669
+ }
14670
+ shown.push(lines[at] ?? "");
14671
+ previous = at;
14672
+ }
14673
+ return shown;
14674
+ }
14675
+ function declaringAdvice(during) {
14676
+ return `if the suite prints it on purpose, list its text under "${funcOf(during)}" in the suite's GDHARNESS_EXPECTED_ERRORS`;
14677
+ }
14678
+ function unexpectedErrors(printed, expected, paths) {
14679
+ const owed = new Map;
14680
+ for (const path of paths) {
14681
+ const suite = printed.suites.get(path);
14682
+ const declared = expected[path];
14683
+ if (suite === undefined || declared === undefined) {
14684
+ continue;
14685
+ }
14686
+ const ranHooks = suite.closed === null ? ["before"] : ["before", "after"];
14687
+ for (const during of [...ranHooks, ...suite.cases.map((ran) => ran.name)]) {
14688
+ const texts = declared[funcOf(during)];
14689
+ if (Array.isArray(texts) && texts.length > 0) {
14690
+ owed.set(`${path}
14691
+ ${during}`, {
14692
+ suite: path,
14693
+ during,
14694
+ texts: texts.map((text) => String(text))
14695
+ });
14696
+ }
14697
+ }
14698
+ }
14699
+ const unexpected = [];
14700
+ for (const error of printed.errors) {
14701
+ const debt = error.suite === null || error.during === null ? undefined : owed.get(`${error.suite}
14702
+ ${error.during}`);
14703
+ const index = debt?.texts.indexOf(error.text) ?? -1;
14704
+ if (debt === undefined || index < 0) {
14705
+ unexpected.push(error);
14706
+ } else {
14707
+ debt.texts.splice(index, 1);
14708
+ }
14709
+ }
14710
+ const neverPrinted = [...owed.values()].flatMap((debt) => debt.texts.map((text) => ({ suite: debt.suite, during: debt.during, text })));
14711
+ return { printed: unexpected, neverPrinted };
14712
+ }
14713
+ var SUITE_OPENED, TIME, CASE_LINE, STATISTICS, ERROR_LINE, EXECUTED, DRIVER_FALLBACK, LEAK, STARVED, WINDOWS_EXITS, MEMORY_EXITS, GDUNIT_FAILED = 100, GDUNIT_EXITS, TAIL = 60, MOST_NAMED = 8;
14714
+ var init_suite_judge = __esm(() => {
14715
+ init_junit();
14716
+ SUITE_OPENED = /^Run Test Suite:\s*(res:\/\/.+?\.gd)\s*$/;
14717
+ TIME = String.raw`(?:\d+h )?(?:\d+min )?(?:\d+s )?\d+ms`;
14718
+ CASE_LINE = new RegExp(String.raw`^\s*(res://.+?\.gd) > (.+?) (STARTED|PASSED|FAILED|FLAKY|SKIPPED|WARNING)(?: \((?:retry \d+|\d+ retries)\))?(?: (${TIME}))?\s*$`);
14719
+ STATISTICS = new RegExp(String.raw`^Statistics:.*\|\s*([A-Z]+)(?:\s+\(.*?\))?\s+(${TIME})\s*$`);
14720
+ ERROR_LINE = /^\s*(?:SCRIPT |USER )?ERROR: (.*?)\s*$/;
14721
+ EXECUTED = /^Executed test cases\s*:\s*\((\d+)\//;
14722
+ DRIVER_FALLBACK = /^\s*WARNING: Your video card drivers .*, switching to /;
14723
+ LEAK = /ObjectDB instances (?:were )?leaked at exit|resources? still in use at exit|Pages in use exist at exit/;
14724
+ STARVED = [
14725
+ /Parameter "mem(?:_new)?" is null\.?\s*\n\s*at: \w+ \((?:\.\/)?core\/(?:os\/memory\.cpp|templates\/cowdata\.h):/g,
14726
+ /ERROR: Failed decoding WebP image\.\s*\n\s*at: _webp_unpack/g
14727
+ ];
14728
+ WINDOWS_EXITS = new Map([
14729
+ [3221225477, "STATUS_ACCESS_VIOLATION"],
14730
+ [3221225495, "STATUS_NO_MEMORY"],
14731
+ [3221225773, "STATUS_COMMITMENT_LIMIT"],
14732
+ [3221225794, "STATUS_DLL_INIT_FAILED"],
14733
+ [3221226505, "STATUS_STACK_BUFFER_OVERRUN"],
14734
+ [3228369022, "a module that could not be found (VcppException)"],
14735
+ [3228369023, "a procedure that could not be found (VcppException)"]
14736
+ ]);
14737
+ MEMORY_EXITS = new Set([3221225495, 3221225773]);
14738
+ GDUNIT_EXITS = new Map([
14739
+ [GDUNIT_FAILED, "gdUnit4's code for a case that failed or errored"],
14740
+ [101, "gdUnit4's code for orphan nodes left behind"],
14741
+ [103, "gdUnit4's code for a suite that cannot run headless"],
14742
+ [104, "gdUnit4's code for a Godot version it does not support"],
14743
+ [105, "gdUnit4's code for scripts that did not compile"]
14744
+ ]);
14745
+ });
14746
+
14431
14747
  // src/whole-file.ts
14432
14748
  import { renameSync as renameSync2, rmSync as rmSync9, writeFileSync as writeFileSync9 } from "node:fs";
14433
14749
  function writeWhole(path, text) {
@@ -14459,6 +14775,227 @@ var init_whole_file = __esm(() => {
14459
14775
  HELD_BY_A_READER = new Set(["EPERM", "EACCES", "EBUSY"]);
14460
14776
  });
14461
14777
 
14778
+ // src/suite-plan.ts
14779
+ import { existsSync as existsSync17, mkdirSync as mkdirSync7, readFileSync as readFileSync16 } from "node:fs";
14780
+ import { dirname as dirname10, posix as posix3 } from "node:path";
14781
+
14782
+ class ProjectScripts {
14783
+ sources = new Map;
14784
+ classes = new Map;
14785
+ read = new Map;
14786
+ projectPath;
14787
+ constructor(projectPath) {
14788
+ this.projectPath = projectPath;
14789
+ eachScript(projectPath, (script, source) => {
14790
+ this.sources.set(script, source);
14791
+ const declared = declaredClassOf(source);
14792
+ if (declared !== null) {
14793
+ this.classes.set(declared, script);
14794
+ }
14795
+ });
14796
+ }
14797
+ script(path) {
14798
+ const known = this.read.get(path);
14799
+ if (known !== undefined) {
14800
+ return known;
14801
+ }
14802
+ const source = this.sources.get(path);
14803
+ if (source === undefined) {
14804
+ return;
14805
+ }
14806
+ const shape = shapeOf(source);
14807
+ const read = {
14808
+ source,
14809
+ extendsAs: shape.body.extendsAs,
14810
+ funcs: shape.body.declarations.filter((d) => d.kind === "func").map((d) => d.name),
14811
+ code: codeOf(source)
14812
+ };
14813
+ this.read.set(path, read);
14814
+ return read;
14815
+ }
14816
+ base(path) {
14817
+ const extendsAs = this.script(path)?.extendsAs ?? null;
14818
+ if (extendsAs === null) {
14819
+ return null;
14820
+ }
14821
+ const quote = extendsAs[0];
14822
+ if (quote === '"' || quote === "'") {
14823
+ const target = extendsAs.slice(1, -1);
14824
+ return {
14825
+ path: target.startsWith("res://") ? target : `res://${posix3.join(posix3.dirname(path.slice(6)), target)}`
14826
+ };
14827
+ }
14828
+ const name = extendsAs.split(".")[0] ?? extendsAs;
14829
+ const declaring = this.classes.get(name);
14830
+ return declaring === undefined ? { name } : { path: declaring };
14831
+ }
14832
+ suite(path) {
14833
+ const cases = new Set;
14834
+ let windowed = false;
14835
+ const seen = new Set;
14836
+ let at = path;
14837
+ while (!seen.has(at)) {
14838
+ seen.add(at);
14839
+ const read = this.script(at);
14840
+ if (read === undefined) {
14841
+ return null;
14842
+ }
14843
+ for (const func of read.funcs.filter((name) => name.startsWith(CASE_PREFIX))) {
14844
+ cases.add(func);
14845
+ }
14846
+ windowed ||= NEEDS_WINDOW.test(read.code) || SCENE_RUNNER.test(read.code);
14847
+ const base = this.base(at);
14848
+ if (base === null) {
14849
+ return null;
14850
+ }
14851
+ if ("name" in base ? base.name === SUITE_BASE : base.path === this.classes.get(SUITE_BASE)) {
14852
+ return cases.size > 0 ? { path, cases: [...cases].sort(), windowed } : null;
14853
+ }
14854
+ if ("name" in base) {
14855
+ return null;
14856
+ }
14857
+ at = base.path;
14858
+ }
14859
+ return null;
14860
+ }
14861
+ suitesUnder(roots) {
14862
+ const prefixes = roots.map((root) => root.endsWith("/") ? root : `${root}/`);
14863
+ return [...this.sources.keys()].filter((path) => prefixes.some((prefix) => path.startsWith(prefix))).sort().flatMap((path) => this.suite(path) ?? []);
14864
+ }
14865
+ }
14866
+ function readTimes(file) {
14867
+ try {
14868
+ const parsed = JSON.parse(readFileSync16(file, "utf8"));
14869
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
14870
+ } catch {
14871
+ return {};
14872
+ }
14873
+ }
14874
+ function writeJson(file, value) {
14875
+ mkdirSync7(dirname10(file), { recursive: true });
14876
+ writeWhole(file, `${JSON.stringify(value, null, 1)}
14877
+ `);
14878
+ }
14879
+ function timesAfter(times, suites, runs) {
14880
+ const next = { ...times };
14881
+ const declared = new Map(suites.map((suite) => [suite.path, suite]));
14882
+ const measured = new Map;
14883
+ for (const run of runs) {
14884
+ for (const printed of run.suites.values()) {
14885
+ if (printed.closed === null || !declared.has(printed.path)) {
14886
+ continue;
14887
+ }
14888
+ const entry = measured.get(printed.path) ?? { cases: new Map, around: 0 };
14889
+ const own = new Map;
14890
+ for (const ran of printed.cases) {
14891
+ own.set(ran.func, (own.get(ran.func) ?? 0) + ran.seconds);
14892
+ }
14893
+ const inCases = [...own.values()].reduce((sum, seconds) => sum + seconds, 0);
14894
+ entry.around = Math.max(entry.around, printed.closed.seconds - inCases);
14895
+ for (const [func, seconds] of own) {
14896
+ entry.cases.set(func, seconds);
14897
+ }
14898
+ measured.set(printed.path, entry);
14899
+ }
14900
+ }
14901
+ for (const [path, entry] of measured) {
14902
+ const suite = declared.get(path);
14903
+ const cases = {};
14904
+ for (const func of suite?.cases ?? []) {
14905
+ const seconds = entry.cases.get(func) ?? times[path]?.cases[func];
14906
+ if (seconds !== undefined) {
14907
+ cases[func] = round(seconds);
14908
+ }
14909
+ }
14910
+ const inCases = Object.values(cases).reduce((sum, seconds) => sum + seconds, 0);
14911
+ next[path] = { seconds: round(entry.around + inCases), cases };
14912
+ }
14913
+ return next;
14914
+ }
14915
+ function round(seconds) {
14916
+ return Math.round(seconds * 1000) / 1000;
14917
+ }
14918
+ function median(values) {
14919
+ if (values.length === 0) {
14920
+ return;
14921
+ }
14922
+ const sorted = [...values].sort((a, b) => a - b);
14923
+ const middle = Math.floor(sorted.length / 2);
14924
+ return sorted.length % 2 === 1 ? sorted[middle] : ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2;
14925
+ }
14926
+ function unitsOf(suites, times, splitPast = SPLIT_PAST_SECONDS) {
14927
+ const known = Object.values(times);
14928
+ const caseCost = median(known.flatMap((time) => Object.values(time.cases))) ?? UNTIMED_CASE;
14929
+ const aroundCost = median(known.map((time) => time.seconds - Object.values(time.cases).reduce((sum, s) => sum + s, 0))) ?? 0;
14930
+ return suites.flatMap((suite) => {
14931
+ const time = times[suite.path];
14932
+ const costOf = (func) => time?.cases[func] ?? caseCost;
14933
+ const around = time === undefined ? aroundCost : Math.max(0, time.seconds - Object.values(time.cases).reduce((sum, s) => sum + s, 0));
14934
+ const whole = around + suite.cases.reduce((sum, func) => sum + costOf(func), 0);
14935
+ if (suite.windowed || whole <= splitPast || suite.cases.length < 2) {
14936
+ return [{ suite: suite.path, cases: suite.cases, cost: whole, windowed: suite.windowed }];
14937
+ }
14938
+ return suite.cases.map((func) => ({
14939
+ suite: suite.path,
14940
+ cases: [func],
14941
+ cost: around + costOf(func),
14942
+ windowed: false
14943
+ }));
14944
+ });
14945
+ }
14946
+ function dealt(units, declared, most) {
14947
+ const windowed = units.filter((unit) => unit.windowed);
14948
+ const headless = units.filter((unit) => !unit.windowed).sort((a, b) => b.cost - a.cost || a.suite.localeCompare(b.suite) || (a.cases[0] ?? "").localeCompare(b.cases[0] ?? ""));
14949
+ const total = headless.reduce((sum, unit) => sum + unit.cost, 0);
14950
+ const slowest = headless[0]?.cost ?? 0;
14951
+ const room = Math.max(1, most - (windowed.length > 0 && most > 1 ? 1 : 0));
14952
+ const count = headless.length === 0 ? 0 : Math.max(1, Math.min(room, headless.length, Math.ceil(total / Math.max(slowest, ENGINE_START_SECONDS))));
14953
+ const bins = Array.from({ length: count }, () => []);
14954
+ const loads = Array.from({ length: count }, () => 0);
14955
+ for (const unit of headless) {
14956
+ const lightest = loads.indexOf(Math.min(...loads));
14957
+ bins[lightest]?.push(unit);
14958
+ loads[lightest] = (loads[lightest] ?? 0) + unit.cost;
14959
+ }
14960
+ const plans = bins.map((bin) => planOf(bin, declared, false));
14961
+ return windowed.length > 0 ? [planOf(windowed, declared, true), ...plans] : plans;
14962
+ }
14963
+ function inOneEngine(units, declared) {
14964
+ return planOf(units, declared, units.some((unit) => unit.windowed));
14965
+ }
14966
+ function planOf(bin, declared, windowed) {
14967
+ const held = new Map;
14968
+ for (const unit of bin) {
14969
+ held.set(unit.suite, [...held.get(unit.suite) ?? [], ...unit.cases]);
14970
+ }
14971
+ const skipping = [...held].flatMap(([path, cases]) => (declared.get(path) ?? []).filter((func) => !cases.includes(func)).map((func) => `${path}:${func}`));
14972
+ return {
14973
+ suites: [...held].map(([path, cases]) => ({ path, cases: [...cases].sort() })),
14974
+ skipping,
14975
+ cost: bin.reduce((sum, unit) => sum + unit.cost, 0),
14976
+ windowed
14977
+ };
14978
+ }
14979
+ function viewportOf(projectPath) {
14980
+ const file = `${projectPath}/project.godot`;
14981
+ if (!existsSync17(file)) {
14982
+ return null;
14983
+ }
14984
+ const text = readFileSync16(file, "utf8");
14985
+ const width = /^window\/size\/viewport_width\s*=\s*(\d+)/m.exec(text)?.[1];
14986
+ const height = /^window\/size\/viewport_height\s*=\s*(\d+)/m.exec(text)?.[1];
14987
+ return width === undefined || height === undefined ? null : { width: Number(width), height: Number(height) };
14988
+ }
14989
+ var SUITE_BASE = "GdUnitTestSuite", CASE_PREFIX = "test_", NEEDS_WINDOW, TEST_DIRECTORIES, SCENE_RUNNER, UNTIMED_CASE = 0.02, SPLIT_PAST_SECONDS = 10, ENGINE_START_SECONDS = 5;
14990
+ var init_suite_plan = __esm(() => {
14991
+ init_class_cache();
14992
+ init_gdscript_source();
14993
+ init_whole_file();
14994
+ NEEDS_WINDOW = /^const\s+GDHARNESS_NEEDS_WINDOW\b[^=\n]*=\s*true\b/m;
14995
+ TEST_DIRECTORIES = ["tests", "test"];
14996
+ SCENE_RUNNER = /\bscene_runner\s*\(/;
14997
+ });
14998
+
14462
14999
  // node_modules/@modelcontextprotocol/server/dist/chunk-Br0eD_fh.mjs
14463
15000
  var __create2, __defProp2, __getOwnPropDesc, __getOwnPropNames2, __getProtoOf2, __hasOwnProp2, __commonJSMin = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports), __exportAll = (all, symbols) => {
14464
15001
  let target = {};
@@ -36060,13 +36597,13 @@ var init_stdio = __esm(() => {
36060
36597
  });
36061
36598
 
36062
36599
  // src/autoload-failure.ts
36063
- import { existsSync as existsSync19, readFileSync as readFileSync18 } from "node:fs";
36600
+ import { existsSync as existsSync19, readFileSync as readFileSync19 } from "node:fs";
36064
36601
  import { join as join22 } from "node:path";
36065
36602
  function scriptsAutoloaded(projectPath) {
36066
36603
  const scripts = new Map;
36067
36604
  let settings;
36068
36605
  try {
36069
- settings = parseProjectGodot(readFileSync18(join22(projectPath, "project.godot"), "utf8"));
36606
+ settings = parseProjectGodot(readFileSync19(join22(projectPath, "project.godot"), "utf8"));
36070
36607
  } catch {
36071
36608
  return scripts;
36072
36609
  }
@@ -36080,7 +36617,7 @@ function scriptsAutoloaded(projectPath) {
36080
36617
  continue;
36081
36618
  }
36082
36619
  try {
36083
- for (const named of readFileSync18(scene, "utf8").matchAll(/^\[ext_resource\b[^\]]*\btype="Script"[^\]]*\bpath="(res:\/\/[^"]+)"/gm)) {
36620
+ for (const named of readFileSync19(scene, "utf8").matchAll(/^\[ext_resource\b[^\]]*\btype="Script"[^\]]*\bpath="(res:\/\/[^"]+)"/gm)) {
36084
36621
  scripts.set(named[1] ?? "", name);
36085
36622
  }
36086
36623
  } catch {}
@@ -36099,7 +36636,7 @@ var init_autoload_failure = __esm(() => {
36099
36636
  });
36100
36637
 
36101
36638
  // src/runtime-client.ts
36102
- import { existsSync as existsSync20, readdirSync as readdirSync12, readFileSync as readFileSync19, statSync as statSync12, unlinkSync as unlinkSync2 } from "node:fs";
36639
+ import { existsSync as existsSync20, readdirSync as readdirSync12, readFileSync as readFileSync20, statSync as statSync12, unlinkSync as unlinkSync2 } from "node:fs";
36103
36640
  import { createConnection } from "node:net";
36104
36641
  import { tmpdir as tmpdir5 } from "node:os";
36105
36642
  import { dirname as dirname12, join as join23, resolve as resolve5 } from "node:path";
@@ -36154,7 +36691,7 @@ function reportAlreadyInTranscript(transcript, report) {
36154
36691
  function parseAnnouncement(file, pid) {
36155
36692
  let fields;
36156
36693
  try {
36157
- fields = asParams(JSON.parse(readFileSync19(file, "utf8")));
36694
+ fields = asParams(JSON.parse(readFileSync20(file, "utf8")));
36158
36695
  } catch {
36159
36696
  return { kind: "rubbish" };
36160
36697
  }
@@ -36224,7 +36761,7 @@ function discoverRuntimes(directories = runtimeDirectories()) {
36224
36761
  function wentAway(file, pid) {
36225
36762
  let project = "";
36226
36763
  try {
36227
- const fields = asParams(JSON.parse(readFileSync19(file, "utf8")));
36764
+ const fields = asParams(JSON.parse(readFileSync20(file, "utf8")));
36228
36765
  project = readString(readParams(fields, "project") ?? {}, "path") ?? "";
36229
36766
  } catch {
36230
36767
  return;
@@ -36739,14 +37276,14 @@ var init_runtime_client = __esm(() => {
36739
37276
  });
36740
37277
 
36741
37278
  // src/boot-note.ts
36742
- import { mkdirSync as mkdirSync9, readFileSync as readFileSync20, writeFileSync as writeFileSync11 } from "node:fs";
37279
+ import { mkdirSync as mkdirSync9, readFileSync as readFileSync21, writeFileSync as writeFileSync11 } from "node:fs";
36743
37280
  import { dirname as dirname13, join as join24 } from "node:path";
36744
37281
  function bootNotePath(projectPath) {
36745
37282
  return join24(projectPath, ".godot", "gdharness-boot.json");
36746
37283
  }
36747
37284
  function readScenes(projectPath) {
36748
37285
  try {
36749
- return asParams(asParams(JSON.parse(readFileSync20(bootNotePath(projectPath), "utf8")))["scenes"]);
37286
+ return asParams(asParams(JSON.parse(readFileSync21(bootNotePath(projectPath), "utf8")))["scenes"]);
36750
37287
  } catch {
36751
37288
  return {};
36752
37289
  }
@@ -36788,7 +37325,7 @@ var init_boot_note = __esm(() => {
36788
37325
  });
36789
37326
 
36790
37327
  // src/breakpoint-note.ts
36791
- import { mkdirSync as mkdirSync10, readFileSync as readFileSync21, rmSync as rmSync11, writeFileSync as writeFileSync12 } from "node:fs";
37328
+ import { mkdirSync as mkdirSync10, readFileSync as readFileSync22, rmSync as rmSync11, writeFileSync as writeFileSync12 } from "node:fs";
36792
37329
  import { dirname as dirname14, isAbsolute as isAbsolute5, join as join25, relative as relative4 } from "node:path";
36793
37330
  function breakpointNotePath(projectPath) {
36794
37331
  return join25(projectPath, ".godot", "gdharness-breakpoints.json");
@@ -36817,7 +37354,7 @@ function writeBreakpointNote(projectPath, held) {
36817
37354
  }
36818
37355
  function readBreakpointNote(projectPath) {
36819
37356
  try {
36820
- const parsed = JSON.parse(readFileSync21(breakpointNotePath(projectPath), "utf8"));
37357
+ const parsed = JSON.parse(readFileSync22(breakpointNotePath(projectPath), "utf8"));
36821
37358
  if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.breakpoints)) {
36822
37359
  return [];
36823
37360
  }
@@ -36850,14 +37387,14 @@ var init_breakpoint_note = __esm(() => {
36850
37387
  });
36851
37388
 
36852
37389
  // src/class-note.ts
36853
- import { mkdirSync as mkdirSync11, readFileSync as readFileSync22 } from "node:fs";
37390
+ import { mkdirSync as mkdirSync11, readFileSync as readFileSync23 } from "node:fs";
36854
37391
  import { dirname as dirname15, join as join26 } from "node:path";
36855
37392
  function classNotePath(projectPath) {
36856
37393
  return join26(projectPath, ".godot", "gdharness-classes.json");
36857
37394
  }
36858
37395
  function readClassNote(projectPath) {
36859
37396
  try {
36860
- const fields = asParams(JSON.parse(readFileSync22(classNotePath(projectPath), "utf8")));
37397
+ const fields = asParams(JSON.parse(readFileSync23(classNotePath(projectPath), "utf8")));
36861
37398
  const rebuiltWith = readArray(fields, "rebuiltWith");
36862
37399
  if (rebuiltWith === undefined || !rebuiltWith.every((name) => typeof name === "string")) {
36863
37400
  return null;
@@ -36899,7 +37436,7 @@ var init_class_note = __esm(() => {
36899
37436
  });
36900
37437
 
36901
37438
  // src/config-pin.ts
36902
- import { existsSync as existsSync21, readFileSync as readFileSync23 } from "node:fs";
37439
+ import { existsSync as existsSync21, readFileSync as readFileSync24 } from "node:fs";
36903
37440
  import { join as join27 } from "node:path";
36904
37441
  function versionsNamedInConfig(projectPath) {
36905
37442
  const config = join27(projectPath, ".mcp.json");
@@ -36908,7 +37445,7 @@ function versionsNamedInConfig(projectPath) {
36908
37445
  }
36909
37446
  let text;
36910
37447
  try {
36911
- text = readFileSync23(config, "utf8");
37448
+ text = readFileSync24(config, "utf8");
36912
37449
  JSON.parse(text);
36913
37450
  } catch {
36914
37451
  return [];
@@ -36929,7 +37466,7 @@ var init_config_pin = __esm(() => {
36929
37466
  });
36930
37467
 
36931
37468
  // src/cut-test-run.ts
36932
- import { existsSync as existsSync22, mkdirSync as mkdirSync12, readFileSync as readFileSync24, rmSync as rmSync12, writeFileSync as writeFileSync13 } from "node:fs";
37469
+ import { existsSync as existsSync22, mkdirSync as mkdirSync12, readFileSync as readFileSync25, rmSync as rmSync12, writeFileSync as writeFileSync13 } from "node:fs";
36933
37470
  import { dirname as dirname16, join as join28 } from "node:path";
36934
37471
  function keptAt(projectPath) {
36935
37472
  return join28(projectPath, ".godot", "gdharness-reports", "cut-run.json");
@@ -36948,7 +37485,7 @@ function takeCutTestRun(projectPath) {
36948
37485
  return null;
36949
37486
  }
36950
37487
  try {
36951
- const run = JSON.parse(readFileSync24(path, "utf8"));
37488
+ const run = JSON.parse(readFileSync25(path, "utf8"));
36952
37489
  rmSync12(path, { force: true });
36953
37490
  return typeof run.cutAt === "string" && Array.isArray(run.paths) && run.paths.every((path) => typeof path === "string") ? run : null;
36954
37491
  } catch {
@@ -37764,7 +38301,7 @@ import {
37764
38301
  existsSync as existsSync23,
37765
38302
  mkdirSync as mkdirSync13,
37766
38303
  openSync as openSync4,
37767
- readFileSync as readFileSync25,
38304
+ readFileSync as readFileSync26,
37768
38305
  readSync as readSync2,
37769
38306
  rmSync as rmSync13,
37770
38307
  statSync as statSync13,
@@ -37779,7 +38316,7 @@ function editorLogNotePath(projectPath) {
37779
38316
  }
37780
38317
  function readEditorLogNote(projectPath) {
37781
38318
  try {
37782
- const fields = asParams(JSON.parse(readFileSync25(editorLogNotePath(projectPath), "utf8")));
38319
+ const fields = asParams(JSON.parse(readFileSync26(editorLogNotePath(projectPath), "utf8")));
37783
38320
  const pid = readNumber(fields, "pid");
37784
38321
  const startedAt = readString(fields, "startedAt");
37785
38322
  return pid !== undefined && Number.isInteger(pid) && pid > 0 && startedAt !== undefined ? { pid, startedAt } : null;
@@ -37878,7 +38415,7 @@ function foldProgress(text) {
37878
38415
  function theFirstOf(path) {
37879
38416
  const whole = statSync13(path).size;
37880
38417
  if (whole <= MOST_CONSOLE_BYTES) {
37881
- return { text: readFileSync25(path, "utf8") };
38418
+ return { text: readFileSync26(path, "utf8") };
37882
38419
  }
37883
38420
  const handle = openSync4(path, "r");
37884
38421
  try {
@@ -38039,7 +38576,7 @@ var init_editor_log = __esm(() => {
38039
38576
  });
38040
38577
 
38041
38578
  // src/rename.ts
38042
- import { existsSync as existsSync24, mkdirSync as mkdirSync14, readFileSync as readFileSync26, renameSync as renameSync3, unlinkSync as unlinkSync3, writeFileSync as writeFileSync15 } from "node:fs";
38579
+ import { existsSync as existsSync24, mkdirSync as mkdirSync14, readFileSync as readFileSync27, renameSync as renameSync3, unlinkSync as unlinkSync3, writeFileSync as writeFileSync15 } from "node:fs";
38043
38580
  import { dirname as dirname18, posix as posix4 } from "node:path";
38044
38581
 
38045
38582
  class Script {
@@ -38744,7 +39281,7 @@ function rewritesOf(plan, texts, move) {
38744
39281
  }
38745
39282
  function applyRewrites(rewrites) {
38746
39283
  for (const rewrite of rewrites) {
38747
- const now = readFileSync26(rewrite.absolute, "utf8");
39284
+ const now = readFileSync27(rewrite.absolute, "utf8");
38748
39285
  if (now !== rewrite.before) {
38749
39286
  throw new Error(`${rewrite.path} changed on disk while the rename was being worked out; nothing was written`);
38750
39287
  }
@@ -40144,7 +40681,7 @@ import {
40144
40681
  existsSync as existsSync27,
40145
40682
  mkdirSync as mkdirSync16,
40146
40683
  readdirSync as readdirSync14,
40147
- readFileSync as readFileSync27,
40684
+ readFileSync as readFileSync28,
40148
40685
  renameSync as renameSync4,
40149
40686
  statSync as statSync14,
40150
40687
  writeFileSync as writeFileSync17
@@ -40304,7 +40841,7 @@ function planFileMove(projectPath, absoluteFrom, absoluteTo) {
40304
40841
  plan = mentionIgnored(plan, ignoredTexts(projectPath), null, [from]);
40305
40842
  const binaries = [];
40306
40843
  for (const path of binaryResources(projectPath)) {
40307
- const bytes = readFileSync27(join32(projectPath, path.slice("res://".length)));
40844
+ const bytes = readFileSync28(join32(projectPath, path.slice("res://".length)));
40308
40845
  const names = [...moves.keys()].find((moving) => bytes.includes(moving));
40309
40846
  if (names !== undefined) {
40310
40847
  binaries.push({
@@ -40365,7 +40902,7 @@ function planFileMove(projectPath, absoluteFrom, absoluteTo) {
40365
40902
  }
40366
40903
  function applyFileMove(outcome) {
40367
40904
  for (const rewrite of outcome.inPlace) {
40368
- if (readFileSync27(rewrite.absolute, "utf8") !== rewrite.before) {
40905
+ if (readFileSync28(rewrite.absolute, "utf8") !== rewrite.before) {
40369
40906
  throw new Error(`${rewrite.path} changed on disk while the move was being worked out; nothing was written`);
40370
40907
  }
40371
40908
  }
@@ -40373,7 +40910,7 @@ function applyFileMove(outcome) {
40373
40910
  if (!existsSync27(file.absoluteFrom)) {
40374
40911
  throw new Error(`${file.from} is gone from disk; nothing was written`);
40375
40912
  }
40376
- if (file.text !== null && readFileSync27(file.absoluteFrom, "utf8") !== file.text.before) {
40913
+ if (file.text !== null && readFileSync28(file.absoluteFrom, "utf8") !== file.text.before) {
40377
40914
  throw new Error(`${file.from} changed on disk while the move was being worked out; nothing was written`);
40378
40915
  }
40379
40916
  if (existsSync27(file.absoluteTo) && !sameFile(file.absoluteFrom, file.absoluteTo)) {
@@ -40437,7 +40974,7 @@ var init_godot_variant = __esm(() => {
40437
40974
  });
40438
40975
 
40439
40976
  // src/game-debugger.ts
40440
- var PAUSE_MS = 30000, STEP_MS = 1e4, READ_MS = 5000, REPLIES;
40977
+ var PAUSE_MS = 30000, STEP_MS = 1e4, RUNTIME_ADDON2 = "res://addons/gdharness_runtime/", READ_MS = 5000, REPLIES;
40441
40978
  var init_game_debugger = __esm(() => {
40442
40979
  init_godot_variant();
40443
40980
  REPLIES = new Set([
@@ -44045,7 +44582,7 @@ var init_godot_bridge = __esm(() => {
44045
44582
 
44046
44583
  // src/import-freshness.ts
44047
44584
  import { createHash as createHash6 } from "node:crypto";
44048
- import { existsSync as existsSync28, readdirSync as readdirSync15, readFileSync as readFileSync28, statSync as statSync15 } from "node:fs";
44585
+ import { existsSync as existsSync28, readdirSync as readdirSync15, readFileSync as readFileSync29, statSync as statSync15 } from "node:fs";
44049
44586
  import { join as join33 } from "node:path";
44050
44587
  function md5Of(file) {
44051
44588
  let stats;
@@ -44060,7 +44597,7 @@ function md5Of(file) {
44060
44597
  }
44061
44598
  let md5;
44062
44599
  try {
44063
- md5 = createHash6("md5").update(readFileSync28(file)).digest("hex");
44600
+ md5 = createHash6("md5").update(readFileSync29(file)).digest("hex");
44064
44601
  } catch {
44065
44602
  return null;
44066
44603
  }
@@ -44106,7 +44643,7 @@ function behind(projectPath, resPath, sidecar) {
44106
44643
  const record = join33(projectPath, ".godot", "imported", `${name}-${createHash6("md5").update(resPath, "utf8").digest("hex")}.md5`);
44107
44644
  let recorded = "";
44108
44645
  try {
44109
- recorded = /^source_md5="([0-9a-f]*)"/m.exec(readFileSync28(record, "utf8"))?.[1] ?? "";
44646
+ recorded = /^source_md5="([0-9a-f]*)"/m.exec(readFileSync29(record, "utf8"))?.[1] ?? "";
44110
44647
  } catch {
44111
44648
  recorded = "";
44112
44649
  }
@@ -44148,7 +44685,7 @@ function staleImports(projectPath) {
44148
44685
  }
44149
44686
  let sidecar;
44150
44687
  try {
44151
- sidecar = readFileSync28(join33(directory, entry.name), "utf8");
44688
+ sidecar = readFileSync29(join33(directory, entry.name), "utf8");
44152
44689
  } catch {
44153
44690
  continue;
44154
44691
  }
@@ -44636,7 +45173,7 @@ var init_prompts = __esm(() => {
44636
45173
  });
44637
45174
 
44638
45175
  // src/restart-note.ts
44639
- import { mkdirSync as mkdirSync18, readFileSync as readFileSync29, rmSync as rmSync15, writeFileSync as writeFileSync18 } from "node:fs";
45176
+ import { mkdirSync as mkdirSync18, readFileSync as readFileSync30, rmSync as rmSync15, writeFileSync as writeFileSync18 } from "node:fs";
44640
45177
  import { dirname as dirname23, join as join35 } from "node:path";
44641
45178
  function restartNotePath(projectPath) {
44642
45179
  return join35(projectPath, ".godot", "gdharness-restart.json");
@@ -44653,7 +45190,7 @@ function noteRestartBegun(note) {
44653
45190
  }
44654
45191
  function restartOwed(projectPath) {
44655
45192
  try {
44656
- const parsed = JSON.parse(readFileSync29(restartNotePath(projectPath), "utf8"));
45193
+ const parsed = JSON.parse(readFileSync30(restartNotePath(projectPath), "utf8"));
44657
45194
  if (typeof parsed !== "object" || parsed === null) {
44658
45195
  return null;
44659
45196
  }
@@ -44679,7 +45216,7 @@ var init_restart_note = __esm(() => {
44679
45216
 
44680
45217
  // src/run-record.ts
44681
45218
  import { createHash as createHash7 } from "node:crypto";
44682
- import { mkdirSync as mkdirSync19, openSync as openSync5, readdirSync as readdirSync16, readFileSync as readFileSync30, rmSync as rmSync16, statSync as statSync17 } from "node:fs";
45219
+ import { mkdirSync as mkdirSync19, openSync as openSync5, readdirSync as readdirSync16, readFileSync as readFileSync31, rmSync as rmSync16, statSync as statSync17 } from "node:fs";
44683
45220
  import { basename as basename10, join as join36, resolve as resolve8 } from "node:path";
44684
45221
  function runsDirectory() {
44685
45222
  return join36(runtimeDirectory(), "runs");
@@ -44703,7 +45240,7 @@ function writeLatestRun(servedBy, latest) {
44703
45240
  }
44704
45241
  function readLatestRun(servedBy, directory = runtimeDirectory()) {
44705
45242
  try {
44706
- const fields = JSON.parse(readFileSync30(latestRunPath(servedBy, directory), "utf8"));
45243
+ const fields = JSON.parse(readFileSync31(latestRunPath(servedBy, directory), "utf8"));
44707
45244
  if (typeof fields !== "object" || fields === null) {
44708
45245
  return null;
44709
45246
  }
@@ -44752,7 +45289,7 @@ function readEditorRunNote(projectPath) {
44752
45289
  function editorNoteAt(path) {
44753
45290
  let parsed;
44754
45291
  try {
44755
- parsed = JSON.parse(readFileSync30(path, "utf8"));
45292
+ parsed = JSON.parse(readFileSync31(path, "utf8"));
44756
45293
  } catch {
44757
45294
  return null;
44758
45295
  }
@@ -44802,7 +45339,7 @@ function everyRunRecord() {
44802
45339
  function recordAt(path) {
44803
45340
  let parsed;
44804
45341
  try {
44805
- parsed = JSON.parse(readFileSync30(path, "utf8"));
45342
+ parsed = JSON.parse(readFileSync31(path, "utf8"));
44806
45343
  } catch {
44807
45344
  return null;
44808
45345
  }
@@ -44861,7 +45398,7 @@ async function runningAs(pid) {
44861
45398
  const began = await startedAt(pid, null);
44862
45399
  const when = began === null ? {} : { startedAt: began };
44863
45400
  if (process.platform === "linux") {
44864
- const raw = readFileSync30(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
45401
+ const raw = readFileSync31(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
44865
45402
  return raw === "" ? null : { kind: "commandLine", text: raw, ...when };
44866
45403
  }
44867
45404
  const args = (await runHidden("ps", ["-p", String(pid), "-o", "args="], { timeout: 15000 })).stdout.trim();
@@ -45049,10 +45586,10 @@ var init_run_record = __esm(() => {
45049
45586
  });
45050
45587
 
45051
45588
  // src/script-profile.ts
45052
- import { readFileSync as readFileSync31 } from "node:fs";
45589
+ import { readFileSync as readFileSync32 } from "node:fs";
45053
45590
  function readProfileFile(path) {
45054
45591
  try {
45055
- const parsed = JSON.parse(readFileSync31(path, "utf8"));
45592
+ const parsed = JSON.parse(readFileSync32(path, "utf8"));
45056
45593
  return typeof parsed === "object" && typeof parsed.connected === "boolean" ? parsed : null;
45057
45594
  } catch {
45058
45595
  return null;
@@ -45109,7 +45646,7 @@ function writeProfileMark(path, mark) {
45109
45646
  }
45110
45647
  function readProfileMark(path) {
45111
45648
  try {
45112
- const parsed = JSON.parse(readFileSync31(path, "utf8"));
45649
+ const parsed = JSON.parse(readFileSync32(path, "utf8"));
45113
45650
  return typeof parsed === "object" && typeof parsed.totals === "object" ? parsed : null;
45114
45651
  } catch {
45115
45652
  return null;
@@ -45251,8 +45788,9 @@ function callersOf(totals, name, scripts) {
45251
45788
  property
45252
45789
  };
45253
45790
  }
45254
- var QUEUE_SETTING = "limits/debugger/max_queued_messages", QUEUE_DEFAULT = 2048, QUEUE_HEADROOM = 512, UNNAMED_PREFIX = "#", PROFILE_FROM_START = "GDHARNESS_PROFILE_FROM_START", ms = (seconds) => Math.round(seconds * 1e6) / 1000, RUNTIME_SCRIPT = "gdharness_runtime/runtime_autoload.gd", PROFILER_PROBE = "_profiler_probe", PROFILER_CALIBRATOR = "_calibrate_the_profiler", RUNTIME_ADDON2 = "res://addons/gdharness_runtime/", CALL_SITES = 40;
45791
+ var QUEUE_SETTING = "limits/debugger/max_queued_messages", QUEUE_DEFAULT = 2048, QUEUE_HEADROOM = 512, UNNAMED_PREFIX = "#", PROFILE_FROM_START = "GDHARNESS_PROFILE_FROM_START", ms = (seconds) => Math.round(seconds * 1e6) / 1000, RUNTIME_SCRIPT = "gdharness_runtime/runtime_autoload.gd", PROFILER_PROBE = "_profiler_probe", PROFILER_CALIBRATOR = "_calibrate_the_profiler", CALL_SITES = 40;
45255
45792
  var init_script_profile = __esm(() => {
45793
+ init_game_debugger();
45256
45794
  init_gdscript_source();
45257
45795
  init_godot_variant();
45258
45796
  init_resources();
@@ -45276,10 +45814,10 @@ var init_server_instructions = __esm(() => {
45276
45814
  });
45277
45815
 
45278
45816
  // src/test-selection.ts
45279
- import { existsSync as existsSync29, readFileSync as readFileSync32 } from "node:fs";
45817
+ import { existsSync as existsSync29, readFileSync as readFileSync33 } from "node:fs";
45280
45818
  import { basename as basename11, join as join37 } from "node:path";
45281
45819
  function casesIn(projectPath, suite) {
45282
- const text = readFileSync32(join37(projectPath, suite.slice("res://".length)), "utf8");
45820
+ const text = readFileSync33(join37(projectPath, suite.slice("res://".length)), "utf8");
45283
45821
  return [...text.matchAll(/^func\s+(test_\w*)\s*\(/gm)].map((match) => match[1] ?? "");
45284
45822
  }
45285
45823
  function suitesUnder(projectPath, asked) {
@@ -45351,7 +45889,7 @@ import {
45351
45889
  mkdirSync as mkdirSync20,
45352
45890
  openSync as openSync6,
45353
45891
  readdirSync as readdirSync17,
45354
- readFileSync as readFileSync33,
45892
+ readFileSync as readFileSync34,
45355
45893
  readSync as readSync3,
45356
45894
  rmdirSync as rmdirSync2,
45357
45895
  statSync as statSync18,
@@ -45370,6 +45908,25 @@ function waitFromEnv(variable, fallback) {
45370
45908
  function greetingWaitMs() {
45371
45909
  return waitFromEnv("GDHARNESS_GREETING_WAIT_MS", 1e4);
45372
45910
  }
45911
+ function pictureToSend(pngPath, jpegPath) {
45912
+ const png = readFileSync34(pngPath);
45913
+ const beside = pngPath.replace(/\.png$/, ".jpg");
45914
+ const jpeg = jpegPath !== undefined && normalize2(jpegPath) === normalize2(beside) && existsSync30(beside) ? readFileSync34(beside) : null;
45915
+ const sent = jpeg !== null && jpeg.length < png.length ? jpeg : png;
45916
+ return {
45917
+ data: sent.toString("base64"),
45918
+ mimeType: sent === png ? "image/png" : "image/jpeg",
45919
+ bytes: sent.length,
45920
+ pngBytes: png.length
45921
+ };
45922
+ }
45923
+ function kilobytes(bytes) {
45924
+ return `${String(Math.max(1, Math.round(bytes / 1024)))} KB`;
45925
+ }
45926
+ function savedScenesOf(asked, as = "savedScenes") {
45927
+ const saved = readStringArray(asParams(JSON.parse(asked.content[0]?.text ?? "{}")), "saved");
45928
+ return saved === undefined ? {} : { [as]: saved };
45929
+ }
45373
45930
  function restartUnderway(note, now = Date.now()) {
45374
45931
  return alive(note.byPid) && now - Date.parse(note.quitAt) < editorRestartTimeoutMs() + 30000;
45375
45932
  }
@@ -45787,7 +46344,7 @@ function endedToStartThis(ended) {
45787
46344
  return ` ${which} was ended to start this one; its output is no longer what editor_output answers about.`;
45788
46345
  }
45789
46346
  function lineBeyondScript(absolutePath, relativePath, line) {
45790
- const text = readFileSync33(absolutePath, "utf8");
46347
+ const text = readFileSync34(absolutePath, "utf8");
45791
46348
  const lines = text === "" ? 0 : text.split(`
45792
46349
  `).length - (text.endsWith(`
45793
46350
  `) ? 1 : 0);
@@ -45987,7 +46544,7 @@ function notRunNote(notRun, failFast) {
45987
46544
  function exportPresetRefusal(projectPath, preset) {
45988
46545
  let text;
45989
46546
  try {
45990
- text = readFileSync33(join38(projectPath, "export_presets.cfg"), "utf8");
46547
+ text = readFileSync34(join38(projectPath, "export_presets.cfg"), "utf8");
45991
46548
  } catch {
45992
46549
  return `This project has no export_presets.cfg, so it has no preset '${preset}' to export with. project_export add_preset writes one, as the editor's Project > Export does.`;
45993
46550
  }
@@ -46350,7 +46907,7 @@ function profileFrameFunctions(projectPath) {
46350
46907
  let projectGodot = null;
46351
46908
  if (projectPath !== null) {
46352
46909
  try {
46353
- projectGodot = readFileSync33(join38(projectPath, "project.godot"), "utf8");
46910
+ projectGodot = readFileSync34(join38(projectPath, "project.godot"), "utf8");
46354
46911
  } catch {}
46355
46912
  }
46356
46913
  return frameFunctionsFor(queuedMessagesOf(projectGodot));
@@ -46374,7 +46931,7 @@ function projectSourceOf(projectPath) {
46374
46931
  let source = null;
46375
46932
  if (inside.ok) {
46376
46933
  try {
46377
- source = readFileSync33(inside.absolutePath, "utf8");
46934
+ source = readFileSync34(inside.absolutePath, "utf8");
46378
46935
  } catch {}
46379
46936
  }
46380
46937
  read.set(script, source);
@@ -46529,7 +47086,7 @@ function foldedEntriesAnswer(log, floor = "warning") {
46529
47086
  }
46530
47087
  function gdUnitFailsOnScriptErrors(projectPath) {
46531
47088
  try {
46532
- return settingKeys(readFileSync33(join38(projectPath, "project.godot"), "utf8")).get("gdunit4/report/godot/script_error") !== false;
47089
+ return settingKeys(readFileSync34(join38(projectPath, "project.godot"), "utf8")).get("gdunit4/report/godot/script_error") !== false;
46533
47090
  } catch {
46534
47091
  return true;
46535
47092
  }
@@ -46558,12 +47115,13 @@ function elsewhereIn(projectPath, asked) {
46558
47115
  return near.length === 0 ? "" : ` The project does have ${there}.`;
46559
47116
  }
46560
47117
  function testPathsIn(projectPath, given) {
46561
- const entries = given === undefined || given === null || given === "" ? ["test"] : given;
47118
+ const defaults = TEST_DIRECTORIES.filter((directory) => existsSync30(join38(projectPath, directory)));
47119
+ const entries = given === undefined || given === null || given === "" ? defaults.length > 0 ? defaults : TEST_DIRECTORIES : given;
46562
47120
  const listed = Array.isArray(entries) ? entries : [entries];
46563
47121
  if (listed.length === 0) {
46564
47122
  return {
46565
47123
  ok: false,
46566
- reason: "path is an empty list: name a suite file or a directory, or leave path out for test."
47124
+ reason: "path is an empty list: name a suite file or a directory, or leave path out for tests and test."
46567
47125
  };
46568
47126
  }
46569
47127
  const resolved = [];
@@ -46665,11 +47223,11 @@ function scriptsUnder(directory, prefix) {
46665
47223
  return found;
46666
47224
  }
46667
47225
  function mainSceneOf(projectFile) {
46668
- const scene = parseProjectGodot(readFileSync33(projectFile, "utf8"))["application"]?.["run/main_scene"];
47226
+ const scene = parseProjectGodot(readFileSync34(projectFile, "utf8"))["application"]?.["run/main_scene"];
46669
47227
  return typeof scene === "string" && scene !== "" ? scene : null;
46670
47228
  }
46671
47229
  function editorPlaysHeadless(projectFile) {
46672
- const runArgs = parseProjectGodot(readFileSync33(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
47230
+ const runArgs = parseProjectGodot(readFileSync34(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
46673
47231
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
46674
47232
  }
46675
47233
  function ownArguments(params) {
@@ -48496,7 +49054,7 @@ class GodotServer {
48496
49054
  if (!engine.ok) {
48497
49055
  return engine.response;
48498
49056
  }
48499
- const application = parseProjectGodot(readFileSync33(project.value.file, "utf8"))["application"] ?? {};
49057
+ const application = parseProjectGodot(readFileSync34(project.value.file, "utf8"))["application"] ?? {};
48500
49058
  const name = application["config/name"];
48501
49059
  const mainScene = application["run/main_scene"];
48502
49060
  const info = {
@@ -48767,6 +49325,8 @@ class GodotServer {
48767
49325
  const userData = scratchDirectory("tests");
48768
49326
  const suitesFile = join38(userData, "suites.txt");
48769
49327
  const skippingFile = join38(userData, "skipping.txt");
49328
+ const engineLog = join38(userData, "engine.log");
49329
+ const expectedFile = join38(userData, "expected.json");
48770
49330
  writeFileSync19(suitesFile, `${(selection.value?.suites ?? asked).join(`
48771
49331
  `)}
48772
49332
  `);
@@ -48778,6 +49338,8 @@ class GodotServer {
48778
49338
  "--path",
48779
49339
  project.value.path,
48780
49340
  ...flags.value,
49341
+ "--log-file",
49342
+ engineLog,
48781
49343
  "-s",
48782
49344
  this.listedRunner,
48783
49345
  "--ignoreHeadlessMode",
@@ -48791,7 +49353,9 @@ class GodotServer {
48791
49353
  suitesFile,
48792
49354
  "--skipping-in",
48793
49355
  skippingFile,
48794
- "--html-report-off"
49356
+ "--html-report-off",
49357
+ "--expected-errors-out",
49358
+ expectedFile
48795
49359
  ];
48796
49360
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 600000;
48797
49361
  this.logDebug(`Running tests: ${engine.value} ${cmdArgs.join(" ")}`);
@@ -48846,11 +49410,13 @@ class GodotServer {
48846
49410
  const reportsDir = join38(project.value.path, ".godot", ...ours.split("/"));
48847
49411
  let report = null;
48848
49412
  let reportProblem = null;
49413
+ const casePrinted = readRun(existsSync30(engineLog) ? readFileSync34(engineLog, "utf8") : "");
49414
+ const declaredErrors = readExpected(expectedFile);
48849
49415
  try {
48850
49416
  const written = existsSync30(reportsDir) && notCompiled === null ? readdirSync17(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
48851
49417
  const newest = written.at(-1);
48852
49418
  if (newest !== undefined) {
48853
- report = parseJUnit(readFileSync33(join38(reportsDir, newest, "results.xml"), "utf8"));
49419
+ report = parseJUnit(readFileSync34(join38(reportsDir, newest, "results.xml"), "utf8"));
48854
49420
  }
48855
49421
  } catch (error) {
48856
49422
  reportProblem = errorMessage(error);
@@ -48961,7 +49527,44 @@ class GodotServer {
48961
49527
  orphans: suite.orphans,
48962
49528
  message: `${suite.orphans} node${suite.orphans === 1 ? "" : "s"} were still in the tree when this suite finished. A node built in a test and neither freed nor added to the tree is the usual cause; free it, or add it with auto_free().`
48963
49529
  }));
48964
- const unclean = report.suites.filter((suite) => suite.failures > 0 || suite.errors > 0 || suite.hookFailures > 0 || suite.hookErrors > 0 || suite.skipped > 0 || orphansIn(suite.path) > 0);
49530
+ const judgedErrors = unexpectedErrors(casePrinted, declaredErrors, [...casePrinted.suites.keys()]);
49531
+ const uncaughtTexts = new Set(uncaught.map((entry) => entry.text));
49532
+ const undeclaredPrinted = judgedErrors.printed.filter((error) => !uncaughtTexts.has(error.text));
49533
+ const undeclaredFolded = new Map;
49534
+ for (const error of undeclaredPrinted) {
49535
+ const key = `${error.suite ?? ""}
49536
+ ${error.during ?? ""}
49537
+ ${error.text}`;
49538
+ const seen = undeclaredFolded.get(key);
49539
+ if (seen === undefined) {
49540
+ undeclaredFolded.set(key, {
49541
+ suite: error.suite ?? undefined,
49542
+ case: error.during ?? undefined,
49543
+ text: error.text,
49544
+ times: 1
49545
+ });
49546
+ } else {
49547
+ seen.times += 1;
49548
+ }
49549
+ }
49550
+ const undeclaredAll = [...undeclaredFolded.values()].map((error) => ({
49551
+ ...error,
49552
+ times: error.times > 1 ? error.times : undefined
49553
+ }));
49554
+ const undeclaredErrors = undeclaredAll.slice(0, MOST_UNDECLARED_ERRORS);
49555
+ const declaredNotPrinted = judgedErrors.neverPrinted.map((missing) => ({
49556
+ suite: missing.suite,
49557
+ case: missing.during,
49558
+ text: missing.text
49559
+ }));
49560
+ const erring = (path) => ({
49561
+ undeclared: undeclaredPrinted.filter((error) => error.suite === path).length,
49562
+ notPrinted: judgedErrors.neverPrinted.filter((missing) => missing.suite === path).length
49563
+ });
49564
+ const declaredAndPrinted = casePrinted.errors.length - judgedErrors.printed.length;
49565
+ const ranIn = (suite) => suite.path === null ? suite.tests : casePrinted.suites.get(suite.path)?.cases.length ?? suite.tests;
49566
+ const notRunIn = (suite) => Math.max(0, suite.discovered - ranIn(suite));
49567
+ const unclean = report.suites.filter((suite) => suite.failures > 0 || suite.errors > 0 || suite.hookFailures > 0 || suite.hookErrors > 0 || suite.skipped > 0 || orphansIn(suite.path) > 0 || erring(suite.path).undeclared + erring(suite.path).notPrinted > 0);
48965
49568
  const hooksPrinted = hookFailuresPrinted(said);
48966
49569
  const hookFailures = report.suites.filter((suite) => suite.hookFailures + suite.hookErrors > 0).flatMap((suite) => {
48967
49570
  const printedHere = hooksPrinted.filter((hook) => hook.path === suite.path);
@@ -48973,7 +49576,7 @@ class GodotServer {
48973
49576
  }
48974
49577
  ];
48975
49578
  });
48976
- const notRun = report.suites.reduce((sum, suite) => sum + Math.max(0, suite.discovered - suite.tests), 0);
49579
+ const notRun = report.suites.reduce((sum, suite) => sum + notRunIn(suite), 0);
48977
49580
  const uncaughtFolded = foldedRepeats(uncaught.map(aboveTheRunner));
48978
49581
  const listedAsUncaught = new Set(uncaughtFolded.map((entry) => entry.index));
48979
49582
  const notApplied = ignoredYetRan(report, ignored);
@@ -48981,8 +49584,13 @@ class GodotServer {
48981
49584
  ...new Set((selection.value?.selected ?? []).filter((one) => !report.suites.some((suite) => (suite.path === one.suite || suite.name === basename12(one.suite, ".gd")) && suite.cases.some((ran) => ran.name === one.test || new RegExp(`^${one.test}\\W`).test(ran.name)))).map((one) => `${basename12(one.suite, ".gd")}:${one.test}`))
48982
49585
  ];
48983
49586
  return this.jsonTextResponse({
48984
- passed: !timedOut && exitCode === 0 && nothingRan === null && report.tests > 0 && report.failures === 0 && report.errors === 0 && hookFailures.length === 0 && uncaught.length === 0 && missing.length === 0 && testsNotRun.length === 0,
48985
- verdict: testsNotRun.length === 0 ? verdict : `${verdict}; not run: ${testsNotRun.join(", ")}`,
49587
+ passed: !timedOut && exitCode === 0 && nothingRan === null && report.tests > 0 && report.failures === 0 && report.errors === 0 && hookFailures.length === 0 && uncaught.length === 0 && undeclaredPrinted.length === 0 && declaredNotPrinted.length === 0 && missing.length === 0 && testsNotRun.length === 0,
49588
+ verdict: [
49589
+ verdict === "passed" && undeclaredPrinted.length + declaredNotPrinted.length > 0 ? undefined : verdict,
49590
+ undeclaredPrinted.length > 0 ? `${undeclaredPrinted.length === 1 ? "one engine error" : `${String(undeclaredPrinted.length)} engine errors`} no suite declared` : undefined,
49591
+ declaredNotPrinted.length > 0 ? `${declaredNotPrinted.length === 1 ? "one declared error" : `${String(declaredNotPrinted.length)} declared errors`} never printed` : undefined,
49592
+ testsNotRun.length > 0 ? `not run: ${testsNotRun.join(", ")}` : undefined
49593
+ ].filter((part) => part !== undefined).join("; "),
48986
49594
  ...missing.length > 0 ? { missing } : {},
48987
49595
  ...testsNotRun.length > 0 ? { testsNotRun } : {},
48988
49596
  ...timedOut ? { timedOut, hung, silentForMs } : {},
@@ -48998,6 +49606,9 @@ class GodotServer {
48998
49606
  failedWithoutDetail: briefed > 0 ? briefed : undefined,
48999
49607
  hookFailures: hookFailures.length > 0 ? hookFailures : undefined,
49000
49608
  uncaughtScriptErrors: uncaughtFolded.length > 0 ? uncaughtFolded : undefined,
49609
+ undeclaredErrors: undeclaredErrors.length > 0 ? undeclaredErrors : undefined,
49610
+ undeclaredErrorsOmitted: undeclaredAll.length > undeclaredErrors.length ? undeclaredAll.length - undeclaredErrors.length : undefined,
49611
+ declaredNotPrinted: declaredNotPrinted.length > 0 ? declaredNotPrinted : undefined,
49001
49612
  warnings: warnings.length > 0 ? warnings : undefined,
49002
49613
  orphans: orphans.total > 0 ? orphans.total : undefined,
49003
49614
  notRun: notRun > 0 ? notRun : undefined,
@@ -49011,13 +49622,17 @@ class GodotServer {
49011
49622
  briefed > 0 ? `${briefed} of the ${failed.length} failing cases, those with the largest reports, are named with their first message only, since their reports would have made the answer too large to deliver; pass one case's suite path to see its reports.` : undefined,
49012
49623
  notApplied.length > 0 ? "An ignore entry named a case that ran all the same: gdUnit4 leaves a case out only as suite_name:test_name, and reads an entry with no colon as a suite name or a piece of a suite path. ignoreNotApplied gives the form for each case that ran." : undefined,
49013
49624
  uncaughtNote(uncaught),
49625
+ undeclaredErrors.length > 0 ? `An engine error a suite does not declare fails the run, as gdharness test judges it; ${declaringAdvice(undeclaredErrors[0]?.case ?? "test_name")}, once for each time it prints it.` : undefined,
49626
+ declaredNotPrinted.length > 0 ? "A declared error the case did not print fails the run too, since the case no longer reaches what it was written to test." : undefined,
49014
49627
  ranAs.startsWith("warnings") && warnings.length === 0 ? "gdUnit4 exits 101 for orphan nodes when nothing failed, and this run printed no count of them: orphan reporting may be off in the project settings." : notRunNote(notRun, readBoolean(args, "failFast") === true)
49015
49628
  ].filter((part) => part !== undefined).join(" ") || undefined,
49016
49629
  suites: unclean.map((suite) => ({
49017
49630
  name: suite.name,
49018
49631
  path: suite.path,
49019
- tests: suite.tests,
49020
- notRun: suite.discovered > suite.tests ? suite.discovered - suite.tests : undefined,
49632
+ tests: ranIn(suite),
49633
+ notRun: notRunIn(suite) > 0 ? notRunIn(suite) : undefined,
49634
+ undeclaredErrors: erring(suite.path).undeclared > 0 ? erring(suite.path).undeclared : undefined,
49635
+ declaredNotPrinted: erring(suite.path).notPrinted > 0 ? erring(suite.path).notPrinted : undefined,
49021
49636
  failures: suite.failures,
49022
49637
  errors: suite.errors,
49023
49638
  hookFailures: suite.hookFailures + suite.hookErrors > 0 ? suite.hookFailures + suite.hookErrors : undefined,
@@ -49026,7 +49641,7 @@ class GodotServer {
49026
49641
  time: suite.time
49027
49642
  })),
49028
49643
  suitesPassed: report.suites.length - unclean.length,
49029
- engineErrors: run.log.count("error"),
49644
+ engineErrors: Math.max(0, run.log.count("error") - declaredAndPrinted),
49030
49645
  engineWarnings: run.log.count("warning"),
49031
49646
  engineEntries: engineEntriesShown.map((entry) => listedAsUncaught.has(entry.index) ? { ...entry, detail: undefined, detailUnder: "uncaughtScriptErrors" } : entry),
49032
49647
  engineEntriesOmitted: engineEntries.length > engineEntriesShown.length ? engineEntries.length - engineEntriesShown.length : undefined,
@@ -49242,7 +49857,7 @@ class GodotServer {
49242
49857
  return null;
49243
49858
  }
49244
49859
  try {
49245
- return readFileSync33(contained.absolutePath, "utf8");
49860
+ return readFileSync34(contained.absolutePath, "utf8");
49246
49861
  } catch {
49247
49862
  return null;
49248
49863
  }
@@ -49336,7 +49951,7 @@ class GodotServer {
49336
49951
  return null;
49337
49952
  }
49338
49953
  try {
49339
- return readFileSync33(contained.absolutePath, "utf8");
49954
+ return readFileSync34(contained.absolutePath, "utf8");
49340
49955
  } catch {
49341
49956
  return null;
49342
49957
  }
@@ -49772,8 +50387,9 @@ class GodotServer {
49772
50387
  if (servers.dapListening !== false && servers.lspListening !== false) {
49773
50388
  return { servers, report: {} };
49774
50389
  }
49775
- const { rebound, said } = await this.editorServersSaid(servers.dapListening === false);
50390
+ const { rebound, before, since } = await this.editorServersSaid(servers.dapListening === false);
49776
50391
  const after = rebound ? await this.editorServersListening() : servers;
50392
+ const said = [...before, ...since];
49777
50393
  return {
49778
50394
  servers: after,
49779
50395
  report: {
@@ -49791,17 +50407,18 @@ class GodotServer {
49791
50407
  }
49792
50408
  return {
49793
50409
  rebound: readBoolean(answer, "rebound") === true,
49794
- said: [...readStringArray(answer, "said") ?? [], ...readStringArray(answer, "saidSince") ?? []]
50410
+ before: readStringArray(answer, "said") ?? [],
50411
+ since: readStringArray(answer, "saidSince") ?? []
49795
50412
  };
49796
50413
  }
49797
50414
  async handleRebindEditorServers() {
49798
50415
  if (!this.godotBridge.getStatus().connected) {
49799
- return this.createErrorResponse("No editor is connected, so there is no debug adapter to bind.", [
49800
- "editor_launch opens one on a project",
49801
- "editor_status says whether the bridge is up and what has reached it"
49802
- ]);
50416
+ const refused = await this.noEditorYet({}, []);
50417
+ if (refused !== null) {
50418
+ return refused;
50419
+ }
49803
50420
  }
49804
- const { rebound, said } = await this.editorServersSaid(true);
50421
+ const { rebound, since: said } = await this.editorServersSaid(true);
49805
50422
  if (!rebound) {
49806
50423
  return this.createErrorResponse("The editor has no debug adapter to bind again: no DebugAdapterServer is in its tree.", ["editor_status says what the editor serves"]);
49807
50424
  }
@@ -49817,7 +50434,7 @@ class GodotServer {
49817
50434
  return null;
49818
50435
  }
49819
50436
  try {
49820
- return settingKeys(readFileSync33(join38(projectPath, "project.godot"), "utf8"));
50437
+ return settingKeys(readFileSync34(join38(projectPath, "project.godot"), "utf8"));
49821
50438
  } catch {
49822
50439
  return null;
49823
50440
  }
@@ -49865,6 +50482,7 @@ class GodotServer {
49865
50482
  serverVersion: SERVER_VERSION,
49866
50483
  addonIsStale: this.godotBridge.anotherRelease() !== undefined,
49867
50484
  staleNote: this.godotBridge.anotherRelease(),
50485
+ ...savedScenesOf(asked),
49868
50486
  ...this.whatTheEditorDropped(settingsBefore, after),
49869
50487
  ...servers,
49870
50488
  ...rebinding,
@@ -49908,6 +50526,7 @@ class GodotServer {
49908
50526
  closed: true,
49909
50527
  editorPid: pid,
49910
50528
  projectPath: before.projectPath,
50529
+ ...savedScenesOf(asked),
49911
50530
  ...this.whatTheEditorDropped(settingsBefore, this.settingKeysOf(before.projectPath)),
49912
50531
  tookMs: Date.now() - began
49913
50532
  });
@@ -49991,7 +50610,7 @@ class GodotServer {
49991
50610
  if (opened.error !== null) {
49992
50611
  return this.createErrorResponse(`The editor was asked to go and could not be started again: ${opened.error}`, [`editor_launch opens one on ${projectPath}`]);
49993
50612
  }
49994
- return this.jsonTextResponse({ restarting: true, pid: opened.pid });
50613
+ return this.jsonTextResponse({ restarting: true, pid: opened.pid, ...savedScenesOf(asked, "saved") });
49995
50614
  }
49996
50615
  async waitForBridge(reached, deadline) {
49997
50616
  for (;; ) {
@@ -50050,7 +50669,7 @@ class GodotServer {
50050
50669
  dapPort: ports.dap,
50051
50670
  arguments: opened.arguments,
50052
50671
  finishesRestart: unfinished === null ? undefined : `This is the launch half of a restart begun at ${unfinished.quitAt} by server pid ${unfinished.byPid}, which quit editor pid ${unfinished.editorPid ?? "unknown"} and was ended before it could start one.`,
50053
- 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."
50672
+ 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, and leaves settingsDropped out when nothing was."
50054
50673
  });
50055
50674
  }
50056
50675
  async editorsNotYetConnected(projectPath) {
@@ -50542,7 +51161,7 @@ class GodotServer {
50542
51161
  return 0;
50543
51162
  }
50544
51163
  try {
50545
- return reportAlreadyInTranscript(readFileSync33(game.transcript), readFileSync33(report));
51164
+ return reportAlreadyInTranscript(readFileSync34(game.transcript), readFileSync34(report));
50546
51165
  } catch (error) {
50547
51166
  this.logDebug(`Could not compare a picked-up run's report with its transcript: ${errorMessage(error)}`);
50548
51167
  return 0;
@@ -52627,7 +53246,7 @@ class GodotServer {
52627
53246
  }
52628
53247
  const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
52629
53248
  const declared = new RegExp(`^\\s*(?:@\\w+(?:\\([^)]*\\))?\\s+)*class_name\\s+${escaped}\\b`, "m");
52630
- if (declared.test(readFileSync33(scriptPath, "utf8"))) {
53249
+ if (declared.test(readFileSync34(scriptPath, "utf8"))) {
52631
53250
  return null;
52632
53251
  }
52633
53252
  const checked = await this.classesTheEditorCannotSee(args);
@@ -52692,6 +53311,10 @@ class GodotServer {
52692
53311
  "editor_launch open starts another"
52693
53312
  ]);
52694
53313
  }
53314
+ const closed = this.closedHere;
53315
+ if (closed !== null && (project === null || (await this.editorsNotYetConnected(project) ?? []).length === 0)) {
53316
+ return this.createErrorResponse(`Editor not connected: this server closed the editor, pid ${closed.editorPid}, on request at ${closed.at}, so nothing is coming until one is opened.`, [...instead, "editor_launch open starts one on the project"]);
53317
+ }
52695
53318
  return this.createErrorResponse("Editor not connected: no Godot editor with the gdharness editor addon has reached this server.", [
52696
53319
  ...instead,
52697
53320
  "Open the project in the editor with editor_launch, or by hand",
@@ -52840,7 +53463,8 @@ class GodotServer {
52840
53463
  }
52841
53464
  async handleRuntimeCommand(command, args, asked) {
52842
53465
  const timeoutMs = asked.patience ?? this.runtimeTimeoutMs();
52843
- const { op: _op, projectPath, pid, outputPath, ...params } = asParams(args);
53466
+ const callArgs = asParams(args);
53467
+ const { op: _op, projectPath, pid, outputPath, inline: _inline, ...params } = callArgs;
52844
53468
  const waited = typeof pid === "number" ? null : await this.ownRunOnItsWay(typeof projectPath === "string" ? projectPath : this.ownProject, timeoutMs);
52845
53469
  if (waited !== null) {
52846
53470
  return this.createErrorResponse(waited, [
@@ -52873,6 +53497,13 @@ class GodotServer {
52873
53497
  }
52874
53498
  const expectsScreenshot = command === "capture_screenshot" || command === "capture_viewport";
52875
53499
  const saveTo = expectsScreenshot && typeof outputPath === "string" ? outputPath : null;
53500
+ const sendsThePicture = readBoolean(callArgs, "inline") !== false;
53501
+ if (expectsScreenshot && !sendsThePicture && saveTo === null) {
53502
+ return this.createErrorResponse("inline: false sends no picture back, so it needs outputPath to save one to; without it the capture would answer nothing to look at.", [
53503
+ "Pass outputPath, an absolute path ending in .png outside the project",
53504
+ "Or leave inline out to have the picture sent"
53505
+ ]);
53506
+ }
52876
53507
  if (saveTo !== null) {
52877
53508
  const refusal = captureDestinationRefusal(saveTo, [
52878
53509
  choice.endpoint.project.path,
@@ -52940,15 +53571,17 @@ class GodotServer {
52940
53571
  }
52941
53572
  }
52942
53573
  const ranWith = [...onGame.alongside];
53574
+ const sent = sendsThePicture ? pictureToSend(screenshotPath, readString(payload, "jpegPath")) : null;
53575
+ const how = sent === null ? ". Not sent, as inline: false asked" : sent.mimeType === "image/jpeg" ? `. Sent as JPEG, ${kilobytes(sent.bytes)} against the PNG's ${kilobytes(sent.pngBytes)}${saveTo === null ? "" : "; the saved PNG is lossless"}` : "";
52943
53576
  return {
52944
53577
  content: [
52945
53578
  {
52946
53579
  type: "text",
52947
- text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}${captureDetail(payload)}${saveTo === null ? "" : `. Saved to ${saveTo}`}${ranWith.length === 0 ? "" : `
53580
+ text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}${captureDetail(payload)}${saveTo === null ? "" : `. Saved to ${saveTo}`}${how}${ranWith.length === 0 ? "" : `
52948
53581
 
52949
53582
  Ran alongside ${ranWith.join(", ")}. ${ALONGSIDE_NOTE}`}`
52950
53583
  },
52951
- { type: "image", data: readFileSync33(screenshotPath).toString("base64"), mimeType: "image/png" }
53584
+ ...sent === null ? [] : [{ type: "image", data: sent.data, mimeType: sent.mimeType }]
52952
53585
  ]
52953
53586
  };
52954
53587
  } finally {
@@ -53065,7 +53698,7 @@ async function runGodotServer() {
53065
53698
  const server = new GodotServer;
53066
53699
  await server.run();
53067
53700
  }
53068
- var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, FAILED_BUDGET = 1e6, ANSWER_BUDGET = 4000000, __dirname2, PARENT_ASKED_AFTER_MS = 5000, EDITOR_SERVERS_START_MS = 20000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, ALONGSIDE_NOTE = "Another call on this game was running while this one was: calls sent together run together and are answered as each finishes, so this answer is not from after the other. Send a call meant to come after another once that one has answered.", OWN_DEBUGGER = " Its debugger is the keeper holding it rather than the editor, so the debug_* tools answer for it all the same: breakpoints stop it, and debug_control pause breaks into it wherever it is, which the editor offers no way to do.", PLAY_STARTS_WITHIN_MS = 30000, KEEPER_ANSWER_MS = 15000, KEEPER_DEBUG_MS, EXIT_REPORTED_WITHIN_MS = 2000, CLOSE_ASK_MS = 3000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, PROFILE_SEND_MS = 5000, MOST_GROUPS = 20, RUNTIME_LISTENING_LINE = "[gdharness] runtime listening on ", VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, CONSOLE_WATCH_MS = 250, BOUNDARY_WAIT_MS = 2000, HANDOVER_MS, HANDOVER_POLL_MS = 250, CACHE_WRITE_MS = 2000, STREAMS_GRACE_MS = 2000, PLAYING_STATUS_MS = 1000, SCAN_SETTLED_MS = 500, REIMPORT_TIMEOUT_MS = 300000, OPS_ELSEWHERE, AUDIO_BUS_OPS, STALLED_AFTER_MS = 30000, TEST_HEARTBEAT_MS = 20000, DEPENDED_FAILED = "Failed to compile depended scripts", LOOPING_AFTER = 100, WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, OUTPUT_PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/", PROFILE_FUNCTIONS = 30, PROFILER_ON_MS = 3000, PROPERTY_PLACES = "It is a property, so each place says whether it writes it (access write), which runs its set block, or reads it (access read), which runs its get block: withinCalls on a read says nothing of the set block, nor on a write of the get block unless the write is a compound one such as +=. Inside the property's own blocks its name is the value stored, which the engine reads and writes without running them, so those lines are not listed.", CALLERS_FOUND_BY_NAME = "Found by name in the project's scripts, since the engine's profiler counts calls and time per function and records nothing about who made them: a method of the same name on another class is listed too, unless one class was asked about and it is called through another class by name or through a variable declared as one. Each place carries the profile's numbers for the function it is written in.", MOST_ENGINE_ENTRIES = 200, ERROR_ASSERT = "GdUnitGodotErrorAssertImpl.gd", EXTERNAL_MEMBER = 'Could not resolve external class member "', STATIC_LIST_HINT;
53701
+ var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, FAILED_BUDGET = 1e6, ANSWER_BUDGET = 4000000, __dirname2, PARENT_ASKED_AFTER_MS = 5000, EDITOR_SERVERS_START_MS = 20000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, ALONGSIDE_NOTE = "Another call on this game was running while this one was: calls sent together run together and are answered as each finishes, so this answer is not from after the other. Send a call meant to come after another once that one has answered.", OWN_DEBUGGER = " Its debugger is the keeper holding it rather than the editor, so the debug_* tools answer for it all the same: breakpoints stop it, and debug_control pause breaks into it wherever it is, which the editor offers no way to do.", PLAY_STARTS_WITHIN_MS = 30000, KEEPER_ANSWER_MS = 15000, KEEPER_DEBUG_MS, EXIT_REPORTED_WITHIN_MS = 2000, CLOSE_ASK_MS = 3000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, PROFILE_SEND_MS = 5000, MOST_GROUPS = 20, RUNTIME_LISTENING_LINE = "[gdharness] runtime listening on ", VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, CONSOLE_WATCH_MS = 250, BOUNDARY_WAIT_MS = 2000, HANDOVER_MS, HANDOVER_POLL_MS = 250, CACHE_WRITE_MS = 2000, STREAMS_GRACE_MS = 2000, PLAYING_STATUS_MS = 1000, SCAN_SETTLED_MS = 500, REIMPORT_TIMEOUT_MS = 300000, OPS_ELSEWHERE, AUDIO_BUS_OPS, STALLED_AFTER_MS = 30000, TEST_HEARTBEAT_MS = 20000, DEPENDED_FAILED = "Failed to compile depended scripts", LOOPING_AFTER = 100, WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, OUTPUT_PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/", PROFILE_FUNCTIONS = 30, PROFILER_ON_MS = 3000, PROPERTY_PLACES = "It is a property, so each place says whether it writes it (access write), which runs its set block, or reads it (access read), which runs its get block: withinCalls on a read says nothing of the set block, nor on a write of the get block unless the write is a compound one such as +=. Inside the property's own blocks its name is the value stored, which the engine reads and writes without running them, so those lines are not listed.", CALLERS_FOUND_BY_NAME = "Found by name in the project's scripts, since the engine's profiler counts calls and time per function and records nothing about who made them: a method of the same name on another class is listed too, unless one class was asked about and it is called through another class by name or through a variable declared as one. Each place carries the profile's numbers for the function it is written in.", MOST_ENGINE_ENTRIES = 200, MOST_UNDECLARED_ERRORS = 20, ERROR_ASSERT = "GdUnitGodotErrorAssertImpl.gd", EXTERNAL_MEMBER = 'Could not resolve external class member "', STATIC_LIST_HINT;
53069
53702
  var init_server = __esm(() => {
53070
53703
  init_dist();
53071
53704
  init_stdio();
@@ -53123,6 +53756,8 @@ var init_server = __esm(() => {
53123
53756
  init_server_version();
53124
53757
  init_setup();
53125
53758
  init_spawn();
53759
+ init_suite_judge();
53760
+ init_suite_plan();
53126
53761
  init_test_selection();
53127
53762
  init_tool_definitions();
53128
53763
  init_tool_reference();
@@ -54298,6 +54933,11 @@ engines would otherwise announce sixteen games that are not games. Set \`${SCRIP
54298
54933
  true in \`project.godot\` to serve one anyway, which is for driving a \`-s\` script on purpose. Read
54299
54934
  that setting rather than assuming the quiet: it is a default and not a property of script runs.
54300
54935
 
54936
+ A scene that is not the game, such as a bench, is run as a scene and is served like the game unless
54937
+ the project lists its folder in \`${QUIET_SCENES_SETTING}\`, a \`PackedStringArray\` of \`res://\`
54938
+ prefixes, empty by default. A run started on a scene under one of them neither serves nor
54939
+ announces, nor do the engines it starts, unless it was started to be profiled from its start.
54940
+
54301
54941
  The autoload reaches an export. \`gdharness runtime off\` in the project before shipping.
54302
54942
 
54303
54943
  ## At a breakpoint
@@ -54490,13 +55130,13 @@ import {
54490
55130
  mkdirSync as mkdirSync8,
54491
55131
  openSync as openSync3,
54492
55132
  readdirSync as readdirSync11,
54493
- readFileSync as readFileSync17,
55133
+ readFileSync as readFileSync18,
54494
55134
  readSync,
54495
55135
  rmSync as rmSync10,
54496
55136
  statSync as statSync11,
54497
55137
  writeFileSync as writeFileSync10
54498
55138
  } from "node:fs";
54499
- import { availableParallelism, freemem } from "node:os";
55139
+ import { availableParallelism, freemem, totalmem } from "node:os";
54500
55140
  import { isAbsolute as isAbsolute4, join as join21, relative as relative3, resolve as resolve4, sep as sep2 } from "node:path";
54501
55141
  import process12 from "node:process";
54502
55142
  import { setTimeout as delay3 } from "node:timers/promises";
@@ -54785,516 +55425,12 @@ function extensionsOf(projectPath) {
54785
55425
  `).map((line) => line.trim()).filter((line) => line.startsWith("res://")) : [];
54786
55426
  }
54787
55427
 
54788
- // src/suite-judge.ts
54789
- init_junit();
54790
- import { stripVTControlCharacters } from "node:util";
54791
- var SUITE_OPENED = /^Run Test Suite:\s*(res:\/\/.+?\.gd)\s*$/;
54792
- var TIME = String.raw`(?:\d+h )?(?:\d+min )?(?:\d+s )?\d+ms`;
54793
- var CASE_LINE = new RegExp(String.raw`^\s*(res://.+?\.gd) > (.+?) (STARTED|PASSED|FAILED|FLAKY|SKIPPED|WARNING)(?: \((?:retry \d+|\d+ retries)\))?(?: (${TIME}))?\s*$`);
54794
- var STATISTICS = new RegExp(String.raw`^Statistics:.*\|\s*([A-Z]+)(?:\s+\(.*?\))?\s+(${TIME})\s*$`);
54795
- var ERROR_LINE = /^\s*(?:SCRIPT |USER )?ERROR: (.*?)\s*$/;
54796
- var EXECUTED = /^Executed test cases\s*:\s*\((\d+)\//;
54797
- var DRIVER_FALLBACK = /^\s*WARNING: Your video card drivers .*, switching to /;
54798
- var LEAK = /ObjectDB instances (?:were )?leaked at exit|resources? still in use at exit|Pages in use exist at exit/;
54799
- var STARVED = [
54800
- /Parameter "mem(?:_new)?" is null\.?\s*\n\s*at: \w+ \((?:\.\/)?core\/(?:os\/memory\.cpp|templates\/cowdata\.h):/g,
54801
- /ERROR: Failed decoding WebP image\.\s*\n\s*at: _webp_unpack/g
54802
- ];
54803
- function secondsOf(time) {
54804
- let ms = 0;
54805
- for (const [, amount, unit] of time.matchAll(/(\d+)(h|min|ms|s)\b/g)) {
54806
- ms += Number(amount) * (unit === "h" ? 3600000 : unit === "min" ? 60000 : unit === "s" ? 1000 : 1);
54807
- }
54808
- return ms / 1000;
54809
- }
54810
- function funcOf(name) {
54811
- const colon = name.indexOf(":");
54812
- return colon < 0 ? name : name.slice(0, colon);
54813
- }
54814
- function readRun(printed) {
54815
- const lines = stripVTControlCharacters(printed).replaceAll(`\r
54816
- `, `
54817
- `).split(`
54818
- `);
54819
- const suites = new Map;
54820
- const errors = [];
54821
- const leaks = [];
54822
- let executed = null;
54823
- let open = null;
54824
- let during = null;
54825
- let afterCase = [];
54826
- let lastCase = null;
54827
- const settleAfterCase = (owner) => {
54828
- for (const held of afterCase) {
54829
- errors.push({ ...held, suite: open?.path ?? null, during: owner });
54830
- }
54831
- afterCase = [];
54832
- };
54833
- for (const [at, line] of lines.entries()) {
54834
- const opened = SUITE_OPENED.exec(line);
54835
- if (opened?.[1] !== undefined) {
54836
- settleAfterCase("after");
54837
- open = suites.get(opened[1]) ?? { path: opened[1], cases: [], closed: null };
54838
- suites.set(open.path, open);
54839
- during = "before";
54840
- lastCase = null;
54841
- continue;
54842
- }
54843
- const caseLine = CASE_LINE.exec(line);
54844
- if (caseLine !== null && open !== null) {
54845
- const name = caseLine[2] ?? "";
54846
- if (caseLine[3] === "STARTED") {
54847
- settleAfterCase(lastCase ?? "before");
54848
- during = name;
54849
- } else {
54850
- open.cases.push({
54851
- name,
54852
- func: funcOf(name),
54853
- verdict: caseLine[3] ?? "",
54854
- seconds: secondsOf(caseLine[4] ?? ""),
54855
- at
54856
- });
54857
- lastCase = name;
54858
- during = null;
54859
- }
54860
- continue;
54861
- }
54862
- const statistics = STATISTICS.exec(line);
54863
- if (statistics !== null && open !== null) {
54864
- settleAfterCase("after");
54865
- open.closed = { verdict: statistics[1] ?? "", seconds: secondsOf(statistics[2] ?? "") };
54866
- open = null;
54867
- during = null;
54868
- lastCase = null;
54869
- continue;
54870
- }
54871
- const counted = EXECUTED.exec(line);
54872
- if (counted?.[1] !== undefined) {
54873
- executed = Number(counted[1]);
54874
- continue;
54875
- }
54876
- if (LEAK.test(line)) {
54877
- leaks.push(line.trim());
54878
- continue;
54879
- }
54880
- if (suites.size === 0 && DRIVER_FALLBACK.test(line)) {
54881
- errors.length = 0;
54882
- continue;
54883
- }
54884
- const error = ERROR_LINE.exec(line);
54885
- if (error === null) {
54886
- continue;
54887
- }
54888
- const text = error[1] ?? "";
54889
- if (open === null) {
54890
- errors.push({ text, at, suite: null, during: null });
54891
- } else if (during === null) {
54892
- afterCase.push({ text, at });
54893
- } else {
54894
- errors.push({ text, at, suite: open.path, during });
54895
- }
54896
- }
54897
- const last = open;
54898
- const stoppedIn = last === null ? null : during === null ? `${last.path}, after ${lastCase ?? "its cases"}` : during === "before" ? `${last.path}, in before` : `${last.path} > ${during}`;
54899
- settleAfterCase("after");
54900
- const joined = lines.join(`
54901
- `);
54902
- const starved = STARVED.flatMap((pattern) => [...joined.matchAll(pattern)].map((found) => found[0]));
54903
- const unloadable = scriptErrorsPrinted(lines).map((error) => `${error.path}${error.line === null ? "" : `:${String(error.line)}`}: ${error.message}`);
54904
- return { lines, suites, errors, leaks, starved, executed, unloadable, stoppedIn };
54905
- }
54906
- var WINDOWS_EXITS = new Map([
54907
- [3221225477, "STATUS_ACCESS_VIOLATION"],
54908
- [3221225495, "STATUS_NO_MEMORY"],
54909
- [3221225773, "STATUS_COMMITMENT_LIMIT"],
54910
- [3221225794, "STATUS_DLL_INIT_FAILED"],
54911
- [3221226505, "STATUS_STACK_BUFFER_OVERRUN"],
54912
- [3228369022, "a module that could not be found (VcppException)"],
54913
- [3228369023, "a procedure that could not be found (VcppException)"]
54914
- ]);
54915
- var MEMORY_EXITS = new Set([3221225495, 3221225773]);
54916
- function exitNamed(code) {
54917
- const unsigned = code >>> 0;
54918
- const name = WINDOWS_EXITS.get(unsigned);
54919
- return name === undefined ? String(code) : `0x${unsigned.toString(16).toUpperCase()} (${name})`;
54920
- }
54921
- var TAIL = 60;
54922
- var MOST_NAMED = 8;
54923
- function named(items) {
54924
- const shown = items.slice(0, MOST_NAMED).join(", ");
54925
- return items.length > MOST_NAMED ? `${shown} and ${String(items.length - MOST_NAMED)} more` : shown;
54926
- }
54927
- function judgeEngine(printed, handed, end) {
54928
- const faults = [];
54929
- const failing = new Set;
54930
- const shownAt = new Set;
54931
- if (end.ended !== null) {
54932
- faults.push(end.ended);
54933
- } else if (end.code !== 0) {
54934
- faults.push(end.code === null ? `the engine was ended by ${end.signal ?? "a signal"}` : `the engine exited ${exitNamed(end.code)}`);
54935
- }
54936
- if (printed.unloadable.length > 0) {
54937
- faults.push(`gdUnit4 could not load ${named(printed.unloadable)}, so it ran nothing`);
54938
- }
54939
- if (printed.executed === null || printed.executed === 0) {
54940
- faults.push(printed.executed === null ? "the run never printed its summary" : "the run executed no test case at all, whatever it exited with");
54941
- }
54942
- for (const { path, cases } of handed.suites) {
54943
- const suite = printed.suites.get(path);
54944
- if (suite === undefined) {
54945
- faults.push(`${path} never started`);
54946
- failing.add(path);
54947
- continue;
54948
- }
54949
- if (suite.closed === null) {
54950
- faults.push(`${path} never finished`);
54951
- failing.add(path);
54952
- } else if (suite.closed.verdict !== "PASSED") {
54953
- if (suite.cases.every((ran) => ran.verdict !== "FAILED")) {
54954
- faults.push(`${path} reported ${suite.closed.verdict} with every case passing, so a hook failed`);
54955
- }
54956
- failing.add(path);
54957
- }
54958
- const finished = new Set(suite.cases.map((ran) => ran.func));
54959
- const missing = cases.filter((func) => !finished.has(func));
54960
- if (missing.length > 0 && suite.closed !== null) {
54961
- faults.push(`${path} closed without running ${named(missing)}`);
54962
- failing.add(path);
54963
- }
54964
- const wanted = new Set(cases);
54965
- const unasked = [...finished].filter((func) => !wanted.has(func));
54966
- if (unasked.length > 0) {
54967
- faults.push(`${path} ran ${named(unasked)}, which gdharness did not read as cases of it to run here, so its ` + "plan of the cases was wrong");
54968
- failing.add(path);
54969
- }
54970
- for (const ran of suite.cases) {
54971
- if (ran.verdict === "FAILED" || ran.verdict === "FLAKY") {
54972
- faults.push(ran.verdict === "FLAKY" ? `${path} > ${ran.name} failed before it passed on a retry` : `${path} > ${ran.name} failed`);
54973
- failing.add(path);
54974
- for (let at = ran.at;at < printed.lines.length && (at === ran.at || printed.lines[at]?.trim()); at += 1) {
54975
- shownAt.add(at);
54976
- }
54977
- }
54978
- }
54979
- }
54980
- const unexpected = unexpectedErrors(printed, handed);
54981
- for (const error of unexpected.printed) {
54982
- const where = error.suite === null ? "outside every suite" : `in ${error.suite} > ${error.during ?? ""}`;
54983
- faults.push(`an engine error ${where}: ${error.text}`);
54984
- if (error.suite !== null) {
54985
- failing.add(error.suite);
54986
- }
54987
- shownAt.add(error.at);
54988
- shownAt.add(error.at + 1);
54989
- }
54990
- for (const missing of unexpected.neverPrinted) {
54991
- faults.push(`${missing.suite} > ${missing.during} declares the error "${missing.text}", which it never printed`);
54992
- failing.add(missing.suite);
54993
- }
54994
- for (const leak of printed.leaks) {
54995
- faults.push(`the engine exited holding what it should have freed: ${leak}`);
54996
- }
54997
- const memoryExit = end.code !== null && MEMORY_EXITS.has(end.code >>> 0);
54998
- const starved = memoryExit || printed.starved.length > 0;
54999
- if (printed.starved.length > 0) {
55000
- faults.push("the engine ran out of memory");
55001
- }
55002
- printed.lines.forEach((line, at) => {
55003
- if (/^(?:Overall Summary|Executed test|Total execution time| ?Exit code)/.test(line.trim())) {
55004
- shownAt.add(at);
55005
- }
55006
- });
55007
- const passed = faults.length === 0;
55008
- const passedSuites = handed.suites.map((suite) => suite.path).filter((path) => !failing.has(path));
55009
- const cutShort = end.ended !== null || printed.executed === null || handed.suites.some(({ path }) => printed.suites.get(path)?.closed == null);
55010
- return {
55011
- passed,
55012
- faults,
55013
- passedSuites,
55014
- starved,
55015
- shown: passed ? [] : linesShown(printed.lines, shownAt, cutShort)
55016
- };
55017
- }
55018
- function linesShown(lines, picked, cutShort) {
55019
- const summary = [...picked].filter((at) => at < lines.length).sort((a, b) => a - b);
55020
- const telling = summary.some((at) => !/^(?:Overall Summary|Executed test|Total execution|\s*Exit code)/.test(lines[at]?.trim() ?? ""));
55021
- if (!telling && !cutShort) {
55022
- return [];
55023
- }
55024
- if (!telling) {
55025
- return lines.slice(Math.max(0, lines.length - TAIL)).filter((line, at, all) => at < all.length - 1 || line !== "");
55026
- }
55027
- const shown = [];
55028
- let previous = -1;
55029
- for (const at of summary) {
55030
- if (previous >= 0 && at > previous + 1) {
55031
- shown.push("...");
55032
- }
55033
- shown.push(lines[at] ?? "");
55034
- previous = at;
55035
- }
55036
- return shown;
55037
- }
55038
- function unexpectedErrors(printed, handed) {
55039
- const owed = new Map;
55040
- for (const { path } of handed.suites) {
55041
- const suite = printed.suites.get(path);
55042
- const declared = handed.expected[path];
55043
- if (suite === undefined || declared === undefined) {
55044
- continue;
55045
- }
55046
- const ranHooks = suite.closed === null ? ["before"] : ["before", "after"];
55047
- for (const during of [...ranHooks, ...suite.cases.map((ran) => ran.name)]) {
55048
- const texts = declared[funcOf(during)];
55049
- if (Array.isArray(texts) && texts.length > 0) {
55050
- owed.set(`${path}
55051
- ${during}`, {
55052
- suite: path,
55053
- during,
55054
- texts: texts.map((text) => String(text))
55055
- });
55056
- }
55057
- }
55058
- }
55059
- const unexpected = [];
55060
- for (const error of printed.errors) {
55061
- const debt = error.suite === null || error.during === null ? undefined : owed.get(`${error.suite}
55062
- ${error.during}`);
55063
- const index = debt?.texts.indexOf(error.text) ?? -1;
55064
- if (debt === undefined || index < 0) {
55065
- unexpected.push(error);
55066
- } else {
55067
- debt.texts.splice(index, 1);
55068
- }
55069
- }
55070
- const neverPrinted = [...owed.values()].flatMap((debt) => debt.texts.map((text) => ({ suite: debt.suite, during: debt.during, text })));
55071
- return { printed: unexpected, neverPrinted };
55072
- }
55073
-
55074
- // src/suite-plan.ts
55075
- init_class_cache();
55076
- init_gdscript_source();
55077
- init_whole_file();
55078
- import { existsSync as existsSync17, mkdirSync as mkdirSync7, readFileSync as readFileSync15 } from "node:fs";
55079
- import { dirname as dirname10, posix as posix3 } from "node:path";
55080
- var SUITE_BASE = "GdUnitTestSuite";
55081
- var CASE_PREFIX = "test_";
55082
- var NEEDS_WINDOW = /^const\s+GDHARNESS_NEEDS_WINDOW\b[^=\n]*=\s*true\b/m;
55083
- var SCENE_RUNNER = /\bscene_runner\s*\(/;
55084
-
55085
- class ProjectScripts {
55086
- sources = new Map;
55087
- classes = new Map;
55088
- read = new Map;
55089
- projectPath;
55090
- constructor(projectPath) {
55091
- this.projectPath = projectPath;
55092
- eachScript(projectPath, (script, source) => {
55093
- this.sources.set(script, source);
55094
- const declared = declaredClassOf(source);
55095
- if (declared !== null) {
55096
- this.classes.set(declared, script);
55097
- }
55098
- });
55099
- }
55100
- script(path) {
55101
- const known = this.read.get(path);
55102
- if (known !== undefined) {
55103
- return known;
55104
- }
55105
- const source = this.sources.get(path);
55106
- if (source === undefined) {
55107
- return;
55108
- }
55109
- const shape = shapeOf(source);
55110
- const read = {
55111
- source,
55112
- extendsAs: shape.body.extendsAs,
55113
- funcs: shape.body.declarations.filter((d) => d.kind === "func").map((d) => d.name),
55114
- code: codeOf(source)
55115
- };
55116
- this.read.set(path, read);
55117
- return read;
55118
- }
55119
- base(path) {
55120
- const extendsAs = this.script(path)?.extendsAs ?? null;
55121
- if (extendsAs === null) {
55122
- return null;
55123
- }
55124
- const quote = extendsAs[0];
55125
- if (quote === '"' || quote === "'") {
55126
- const target = extendsAs.slice(1, -1);
55127
- return {
55128
- path: target.startsWith("res://") ? target : `res://${posix3.join(posix3.dirname(path.slice(6)), target)}`
55129
- };
55130
- }
55131
- const name = extendsAs.split(".")[0] ?? extendsAs;
55132
- const declaring = this.classes.get(name);
55133
- return declaring === undefined ? { name } : { path: declaring };
55134
- }
55135
- suite(path) {
55136
- const cases = new Set;
55137
- let windowed = false;
55138
- const seen = new Set;
55139
- let at = path;
55140
- while (!seen.has(at)) {
55141
- seen.add(at);
55142
- const read = this.script(at);
55143
- if (read === undefined) {
55144
- return null;
55145
- }
55146
- for (const func of read.funcs.filter((name) => name.startsWith(CASE_PREFIX))) {
55147
- cases.add(func);
55148
- }
55149
- windowed ||= NEEDS_WINDOW.test(read.code) || SCENE_RUNNER.test(read.code);
55150
- const base = this.base(at);
55151
- if (base === null) {
55152
- return null;
55153
- }
55154
- if ("name" in base ? base.name === SUITE_BASE : base.path === this.classes.get(SUITE_BASE)) {
55155
- return cases.size > 0 ? { path, cases: [...cases].sort(), windowed } : null;
55156
- }
55157
- if ("name" in base) {
55158
- return null;
55159
- }
55160
- at = base.path;
55161
- }
55162
- return null;
55163
- }
55164
- suitesUnder(roots) {
55165
- const prefixes = roots.map((root) => root.endsWith("/") ? root : `${root}/`);
55166
- return [...this.sources.keys()].filter((path) => prefixes.some((prefix) => path.startsWith(prefix))).sort().flatMap((path) => this.suite(path) ?? []);
55167
- }
55168
- }
55169
- function readTimes(file) {
55170
- try {
55171
- const parsed = JSON.parse(readFileSync15(file, "utf8"));
55172
- return typeof parsed === "object" && parsed !== null ? parsed : {};
55173
- } catch {
55174
- return {};
55175
- }
55176
- }
55177
- function writeJson(file, value) {
55178
- mkdirSync7(dirname10(file), { recursive: true });
55179
- writeWhole(file, `${JSON.stringify(value, null, 1)}
55180
- `);
55181
- }
55182
- function timesAfter(times, suites, runs) {
55183
- const next = { ...times };
55184
- const declared = new Map(suites.map((suite) => [suite.path, suite]));
55185
- const measured = new Map;
55186
- for (const run of runs) {
55187
- for (const printed of run.suites.values()) {
55188
- if (printed.closed === null || !declared.has(printed.path)) {
55189
- continue;
55190
- }
55191
- const entry = measured.get(printed.path) ?? { cases: new Map, around: 0 };
55192
- const own = new Map;
55193
- for (const ran of printed.cases) {
55194
- own.set(ran.func, (own.get(ran.func) ?? 0) + ran.seconds);
55195
- }
55196
- const inCases = [...own.values()].reduce((sum, seconds) => sum + seconds, 0);
55197
- entry.around = Math.max(entry.around, printed.closed.seconds - inCases);
55198
- for (const [func, seconds] of own) {
55199
- entry.cases.set(func, seconds);
55200
- }
55201
- measured.set(printed.path, entry);
55202
- }
55203
- }
55204
- for (const [path, entry] of measured) {
55205
- const suite = declared.get(path);
55206
- const cases = {};
55207
- for (const func of suite?.cases ?? []) {
55208
- const seconds = entry.cases.get(func) ?? times[path]?.cases[func];
55209
- if (seconds !== undefined) {
55210
- cases[func] = round(seconds);
55211
- }
55212
- }
55213
- const inCases = Object.values(cases).reduce((sum, seconds) => sum + seconds, 0);
55214
- next[path] = { seconds: round(entry.around + inCases), cases };
55215
- }
55216
- return next;
55217
- }
55218
- function round(seconds) {
55219
- return Math.round(seconds * 1000) / 1000;
55220
- }
55221
- var UNTIMED_CASE = 0.02;
55222
- var SPLIT_PAST_SECONDS = 10;
55223
- var ENGINE_START_SECONDS = 5;
55224
- function median(values) {
55225
- if (values.length === 0) {
55226
- return;
55227
- }
55228
- const sorted = [...values].sort((a, b) => a - b);
55229
- const middle = Math.floor(sorted.length / 2);
55230
- return sorted.length % 2 === 1 ? sorted[middle] : ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2;
55231
- }
55232
- function unitsOf(suites, times, splitPast = SPLIT_PAST_SECONDS) {
55233
- const known = Object.values(times);
55234
- const caseCost = median(known.flatMap((time) => Object.values(time.cases))) ?? UNTIMED_CASE;
55235
- const aroundCost = median(known.map((time) => time.seconds - Object.values(time.cases).reduce((sum, s) => sum + s, 0))) ?? 0;
55236
- return suites.flatMap((suite) => {
55237
- const time = times[suite.path];
55238
- const costOf = (func) => time?.cases[func] ?? caseCost;
55239
- const around = time === undefined ? aroundCost : Math.max(0, time.seconds - Object.values(time.cases).reduce((sum, s) => sum + s, 0));
55240
- const whole = around + suite.cases.reduce((sum, func) => sum + costOf(func), 0);
55241
- if (suite.windowed || whole <= splitPast || suite.cases.length < 2) {
55242
- return [{ suite: suite.path, cases: suite.cases, cost: whole, windowed: suite.windowed }];
55243
- }
55244
- return suite.cases.map((func) => ({
55245
- suite: suite.path,
55246
- cases: [func],
55247
- cost: around + costOf(func),
55248
- windowed: false
55249
- }));
55250
- });
55251
- }
55252
- function dealt(units, declared, most) {
55253
- const windowed = units.filter((unit) => unit.windowed);
55254
- const headless = units.filter((unit) => !unit.windowed).sort((a, b) => b.cost - a.cost || a.suite.localeCompare(b.suite) || (a.cases[0] ?? "").localeCompare(b.cases[0] ?? ""));
55255
- const total = headless.reduce((sum, unit) => sum + unit.cost, 0);
55256
- const slowest = headless[0]?.cost ?? 0;
55257
- const room = Math.max(1, most - (windowed.length > 0 && most > 1 ? 1 : 0));
55258
- const count = headless.length === 0 ? 0 : Math.max(1, Math.min(room, headless.length, Math.ceil(total / Math.max(slowest, ENGINE_START_SECONDS))));
55259
- const bins = Array.from({ length: count }, () => []);
55260
- const loads = Array.from({ length: count }, () => 0);
55261
- for (const unit of headless) {
55262
- const lightest = loads.indexOf(Math.min(...loads));
55263
- bins[lightest]?.push(unit);
55264
- loads[lightest] = (loads[lightest] ?? 0) + unit.cost;
55265
- }
55266
- const plans = bins.map((bin) => planOf(bin, declared, false));
55267
- return windowed.length > 0 ? [planOf(windowed, declared, true), ...plans] : plans;
55268
- }
55269
- function inOneEngine(units, declared) {
55270
- return planOf(units, declared, units.some((unit) => unit.windowed));
55271
- }
55272
- function planOf(bin, declared, windowed) {
55273
- const held = new Map;
55274
- for (const unit of bin) {
55275
- held.set(unit.suite, [...held.get(unit.suite) ?? [], ...unit.cases]);
55276
- }
55277
- const skipping = [...held].flatMap(([path, cases]) => (declared.get(path) ?? []).filter((func) => !cases.includes(func)).map((func) => `${path}:${func}`));
55278
- return {
55279
- suites: [...held].map(([path, cases]) => ({ path, cases: [...cases].sort() })),
55280
- skipping,
55281
- cost: bin.reduce((sum, unit) => sum + unit.cost, 0),
55282
- windowed
55283
- };
55284
- }
55285
- function viewportOf(projectPath) {
55286
- const file = `${projectPath}/project.godot`;
55287
- if (!existsSync17(file)) {
55288
- return null;
55289
- }
55290
- const text = readFileSync15(file, "utf8");
55291
- const width = /^window\/size\/viewport_width\s*=\s*(\d+)/m.exec(text)?.[1];
55292
- const height = /^window\/size\/viewport_height\s*=\s*(\d+)/m.exec(text)?.[1];
55293
- return width === undefined || height === undefined ? null : { width: Number(width), height: Number(height) };
55294
- }
55428
+ // src/suite-run.ts
55429
+ init_suite_judge();
55430
+ init_suite_plan();
55295
55431
 
55296
55432
  // src/version.ts
55297
- import { readFileSync as readFileSync16 } from "node:fs";
55433
+ import { readFileSync as readFileSync17 } from "node:fs";
55298
55434
  import { dirname as dirname11, join as join20 } from "node:path";
55299
55435
  import { fileURLToPath as fileURLToPath2 } from "node:url";
55300
55436
  function getLocalVersion() {
@@ -55305,7 +55441,7 @@ function getLocalVersion() {
55305
55441
  ];
55306
55442
  for (const packagePath of packageCandidates) {
55307
55443
  try {
55308
- const value = JSON.parse(readFileSync16(packagePath, "utf-8"));
55444
+ const value = JSON.parse(readFileSync17(packagePath, "utf-8"));
55309
55445
  if (typeof value === "object" && value !== null && "name" in value && value.name === "gdharness" && "version" in value && typeof value.version === "string") {
55310
55446
  return value.version;
55311
55447
  }
@@ -55402,7 +55538,8 @@ function resPathOf(entry, projectPath, cwd) {
55402
55538
  const caseAt = /^(.*\.gd):(\w+)$/.exec(entry);
55403
55539
  const file = caseAt?.[1] ?? entry;
55404
55540
  const fromProject = resolve4(projectPath, file);
55405
- const absolute = isAbsolute4(file) || !existsSync18(fromProject) ? resolve4(cwd, file) : fromProject;
55541
+ const fromHere = resolve4(cwd, file);
55542
+ const absolute = isAbsolute4(file) || !existsSync18(fromProject) && existsSync18(fromHere) ? fromHere : fromProject;
55406
55543
  const inside = relative3(projectPath, absolute);
55407
55544
  if (inside.startsWith("..") || isAbsolute4(inside)) {
55408
55545
  throw new TestUsageError(`${entry} is not inside the project at ${projectPath}.`);
@@ -55414,7 +55551,7 @@ function homeOf(projectPath) {
55414
55551
  return mainCheckoutOf(projectPath) ?? projectPath;
55415
55552
  }
55416
55553
  function selectedSuites(scripts, options) {
55417
- const roots = options.roots.length > 0 ? options.roots : ["res://tests", "res://test"].filter((root) => existsSync18(join21(options.projectPath, root.slice(6))));
55554
+ const roots = options.roots.length > 0 ? options.roots : TEST_DIRECTORIES.filter((root) => existsSync18(join21(options.projectPath, root))).map((root) => `res://${root}`);
55418
55555
  if (options.selected.length === 0) {
55419
55556
  const found = scripts.suitesUnder(roots);
55420
55557
  if (found.length === 0) {
@@ -55429,7 +55566,7 @@ function selectedSuites(scripts, options) {
55429
55566
  const directory = join21(options.projectPath, path.slice(6));
55430
55567
  const suites = existsSync18(directory) && statSync11(directory).isDirectory() ? scripts.suitesUnder([path.replace(/\/$/, "")]) : [scripts.suite(path)].filter((suite) => suite !== null);
55431
55568
  if (suites.length === 0) {
55432
- throw new TestUsageError(existsSync18(directory) ? `${path} holds no gdUnit4 suite: nothing there extends GdUnitTestSuite and declares a test_ function.` : `Nothing at ${path}.`);
55569
+ throw new TestUsageError(existsSync18(directory) ? `${path} holds no gdUnit4 suite: nothing there extends GdUnitTestSuite and declares a test_ function.` : `Nothing at ${path} in the project at ${options.projectPath}.`);
55433
55570
  }
55434
55571
  for (const suite of suites) {
55435
55572
  const picked = chosen.get(suite.path) ?? { suite, cases: new Set };
@@ -55448,9 +55585,32 @@ function selectedSuites(scripts, options) {
55448
55585
  cases: suite.cases.filter((c) => cases.has(c))
55449
55586
  }));
55450
55587
  }
55451
- function roomFor(most) {
55452
- const byMemory = Math.floor((freemem() - MEMORY_KEPT) / MEMORY_PER_ENGINE);
55453
- return Math.max(1, Math.min(most, availableParallelism(), byMemory));
55588
+ function roomFor(most, processors, available) {
55589
+ const byMemory = Math.floor((available - MEMORY_KEPT) / MEMORY_PER_ENGINE);
55590
+ const engines = Math.max(1, Math.min(most, processors, byMemory));
55591
+ if (engines >= most) {
55592
+ return { engines, held: undefined };
55593
+ }
55594
+ const gigabytes = (bytes) => `${(bytes / 1024 ** 3).toFixed(1)} GB`;
55595
+ return {
55596
+ engines,
55597
+ held: byMemory < processors ? `the machine has ${gigabytes(Math.max(0, available))} of memory free, ${gigabytes(MEMORY_KEPT)} of it kept for everything else and ${gigabytes(MEMORY_PER_ENGINE)} taken for each engine` : `the machine has ${plural(processors, "processor")}`
55598
+ };
55599
+ }
55600
+ async function memoryAvailable() {
55601
+ const level = await macMemoryLevel();
55602
+ return level === null ? freemem() : totalmem() * level / 100;
55603
+ }
55604
+ async function macMemoryLevel() {
55605
+ if (process12.platform !== "darwin") {
55606
+ return null;
55607
+ }
55608
+ try {
55609
+ const level = Number((await runHidden("sysctl", ["-n", "kern.memorystatus_level"])).stdout.trim());
55610
+ return Number.isFinite(level) && level > 0 && level <= 100 ? level : null;
55611
+ } catch {
55612
+ return null;
55613
+ }
55454
55614
  }
55455
55615
  function ordered(units, order) {
55456
55616
  const sorted = [...units].sort((a, b) => a.suite.localeCompare(b.suite));
@@ -55522,9 +55682,9 @@ async function runTestCommand(options, godotPath) {
55522
55682
  }
55523
55683
  const fingerprints = new SuiteFingerprints(scripts, [
55524
55684
  getLocalVersion(),
55525
- readFileSync17(shippedListedRunner(), "utf8"),
55685
+ readFileSync18(shippedListedRunner(), "utf8"),
55526
55686
  ...options.reachAlso.map((path) => `${path}
55527
- ${existsSync18(path) ? readFileSync17(path, "latin1") : "missing"}`)
55687
+ ${existsSync18(path) ? readFileSync18(path, "latin1") : "missing"}`)
55528
55688
  ]);
55529
55689
  const printsOf = new Map(suites.map((suite) => [suite.path, fingerprints.of(suite)]));
55530
55690
  if (options.changedOnly) {
@@ -55535,7 +55695,7 @@ ${existsSync18(path) ? readFileSync17(path, "latin1") : "missing"}`)
55535
55695
  });
55536
55696
  suites = suites.filter((suite) => !unchanged.includes(suite));
55537
55697
  if (unchanged.length > 0) {
55538
- console.log(`${plural(unchanged.length, "suite")} passed with everything ${unchanged.length === 1 ? "it reaches" : "they reach"} as it stands now, and ${unchanged.length === 1 ? "is" : "are"} not run.`);
55698
+ console.log(`${plural(unchanged.length, "suite")} passed with everything ${unchanged.length === 1 ? "it reaches" : "they reach"} as it stands now, and ${unchanged.length === 1 ? "is" : "are"} not run: ${named(unchanged.map((suite) => suite.path))}.`);
55539
55699
  }
55540
55700
  if (suites.length === 0) {
55541
55701
  console.log("Nothing has changed since every suite last passed.");
@@ -55545,9 +55705,16 @@ ${existsSync18(path) ? readFileSync17(path, "latin1") : "missing"}`)
55545
55705
  }
55546
55706
  const times = readTimes(timesFile);
55547
55707
  const units = unitsOf(suites, times);
55548
- const plans = options.order === null ? dealt(units, declared, roomFor(options.engines)) : [inOneEngine(ordered(units, options.order), declared)];
55708
+ const room = roomFor(options.engines, availableParallelism(), await memoryAvailable());
55709
+ const plans = options.order === null ? dealt(units, declared, room.engines) : [inOneEngine(ordered(units, options.order), declared)];
55549
55710
  const functions = suites.reduce((sum, suite) => sum + suite.cases.length, 0);
55550
- console.log(`${plural(functions, "test function")} in ${plural(suites.length, "suite")} over ${plural(plans.length, "engine")}${plans.some((plan) => plan.windowed) ? ", one of them windowed" : ""}.`);
55711
+ const held = options.order === null && room.held !== undefined && plans.length === room.engines ? `, of the ${String(options.engines)} asked for, since ${room.held}` : "";
55712
+ const how = options.order === null ? `${plans.some((plan) => plan.windowed) ? ", one of them windowed" : ""}${held}` : options.order === "reversed" ? ", in reverse order of path" : `, shuffled by ${String(options.order)}`;
55713
+ console.log(`${plural(functions, "test function")} in ${plural(suites.length, "suite")} over ${plural(plans.length, "engine")}${how}.`);
55714
+ const split = [...new Set(units.map((unit) => unit.suite))].filter((path) => units.filter((unit) => unit.suite === path).length > 1);
55715
+ if (plans.length > 1 && split.length > 0) {
55716
+ console.log(`Dealt out case by case, since ${split.length === 1 ? "it" : "each"} took more than ${String(SPLIT_PAST_SECONDS)} s last time: ${named(split)}.`);
55717
+ }
55551
55718
  let ran = await Promise.all(plans.map((plan, index) => runEngine2(launch, plan, index, units)));
55552
55719
  if (ran.some((engine) => engine.verdict.starved) && ran.some((engine) => !engine.verdict.passed)) {
55553
55720
  console.log("An engine ran out of memory, so every engine that failed runs again, one at a time.");
@@ -55570,7 +55737,7 @@ function plural(count, noun) {
55570
55737
  }
55571
55738
  function readRecord(file) {
55572
55739
  try {
55573
- const parsed = JSON.parse(readFileSync17(file, "utf8"));
55740
+ const parsed = JSON.parse(readFileSync18(file, "utf8"));
55574
55741
  return typeof parsed === "object" && parsed !== null ? parsed : {};
55575
55742
  } catch {
55576
55743
  return {};
@@ -55673,7 +55840,7 @@ async function runEngine2(launch, plan, index, units, again = "") {
55673
55840
  } finally {
55674
55841
  launch.slots.release(lock);
55675
55842
  }
55676
- const printed = readRun(readFileSync17(log, "utf8"));
55843
+ const printed = readRun(readFileSync18(log, "utf8"));
55677
55844
  const assignment = { suites: plan.suites, expected: readExpected(expectedFile) };
55678
55845
  return {
55679
55846
  index,
@@ -55684,14 +55851,6 @@ async function runEngine2(launch, plan, index, units, again = "") {
55684
55851
  seconds: (Date.now() - began) / 1000
55685
55852
  };
55686
55853
  }
55687
- function readExpected(file) {
55688
- try {
55689
- const parsed = JSON.parse(readFileSync17(file, "utf8"));
55690
- return typeof parsed === "object" && parsed !== null ? parsed : {};
55691
- } catch {
55692
- return {};
55693
- }
55694
- }
55695
55854
  async function watched(launch, args, directory, log, windowed, silenceSeconds) {
55696
55855
  const user = join21(directory, "user");
55697
55856
  const env = { ...process12.env, APPDATA: user, XDG_DATA_HOME: user };
@@ -55813,12 +55972,17 @@ function report(ran, startedAt, runDirectory) {
55813
55972
  const suites = new Set(ran.flatMap((engine) => engine.plan.suites.map((suite) => suite.path))).size;
55814
55973
  const longest = Math.max(0, ...ran.map((engine) => engine.seconds));
55815
55974
  const took = (Date.now() - startedAt) / 1000;
55975
+ const totals = `${plural(cases, "case")} in ${plural(suites, "suite")} across ${plural(ran.length, "engine")}, ${took.toFixed(1)} s; the longest engine took ${longest.toFixed(1)} s.`;
55976
+ console.log("");
55816
55977
  if (failed.length === 0) {
55817
55978
  rmSync10(runDirectory, { recursive: true, force: true });
55979
+ console.log(`Passed: ${totals}`);
55980
+ return 0;
55818
55981
  }
55819
- console.log("");
55820
- console.log(`${failed.length === 0 ? "Passed" : "Failed"}: ${plural(cases, "case")} in ${plural(suites, "suite")} across ${plural(ran.length, "engine")}, ${took.toFixed(1)} s; the longest engine took ${longest.toFixed(1)} s.`);
55821
- return failed.length === 0 ? 0 : 1;
55982
+ const problems = failed.reduce((sum, engine) => sum + engine.verdict.faults.length, 0);
55983
+ console.log(`Failed: ${plural(problems, "problem")}, listed above. Ran ${totals}`);
55984
+ console.log(`Every engine's output is kept under ${runDirectory} until the next run.`);
55985
+ return 1;
55822
55986
  }
55823
55987
 
55824
55988
  // src/cli.ts