gdharness 1.2.23 → 1.2.24

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
@@ -651,18 +651,18 @@ function engineFlags(given) {
651
651
  const setBy = Object.hasOwn(SET_BY_ARGUMENT, flag) ? SET_BY_ARGUMENT[flag] : undefined;
652
652
  return {
653
653
  ok: false,
654
- reason: setBy !== undefined ? `${flag} is set by this tool's own argument, ${setBy}, rather than through engineArgs.` : `${flag} is not one of the engine flags engineArgs takes, which are those that change what the engine logs, draws for debugging, checks on the GPU or how it times frames: ${offered}. A value goes as the next entry, as in ["--fixed-fps", "60"].`
654
+ reason: setBy !== undefined ? `${flag} is set by this tool's own argument, ${setBy}, rather than through engineArgs.` : `${flag} is not one of the engine flags engineArgs takes, which are those that change what the engine logs, draws for debugging, checks on the GPU or how it times frames, and Movie Maker's --write-movie: ${offered}. A value goes as the next entry, as in ["--fixed-fps", "60"].`
655
655
  };
656
656
  }
657
657
  if (takes === "none") {
658
658
  continue;
659
659
  }
660
660
  const value = given[at + 1];
661
- const fits = value !== undefined && (takes === "number" ? /^\d+(?:\.\d+)?$/.test(value) : /^\d+x\d+$/.test(value));
662
- if (!fits) {
661
+ const kind = VALUE_KIND[takes];
662
+ if (value === undefined || !kind.pattern.test(value)) {
663
663
  return {
664
664
  ok: false,
665
- reason: `${flag} takes ${takes === "number" ? "a number" : "a size such as 1280x720"} as the next entry, and ${value === undefined ? "nothing followed it" : `${JSON.stringify(value)} is not one`}.`
665
+ reason: `${flag} takes ${kind.named} as the next entry, and ${value === undefined ? "nothing followed it" : `${JSON.stringify(value)} is not one`}.`
666
666
  };
667
667
  }
668
668
  at += 1;
@@ -725,7 +725,7 @@ function environmentFor(options, variables = process.env) {
725
725
  const base = options.savesIn === undefined ? { ...variables } : userDataIn(options.savesIn, variables);
726
726
  return { ...base, ...options.env, GDHARNESS_RUNTIME_DIR: options.runtimeDirectory };
727
727
  }
728
- var ENGINE_FLAGS, SET_BY_ARGUMENT, OPENED_BY_A_SERVER = "GDHARNESS_OPENED_BY_A_SERVER", EDITOR_PID_VARIABLE = "GDHARNESS_EDITOR_PID", RESERVED_VARIABLE_PREFIX = "GDHARNESS_", SAVES_NOT_MOVED_NOTE = "On macOS the engine reads user:// off HOME and ignores the variables that move it elsewhere, measured on Godot 4.7.2, so this run wrote its saves where the player keeps theirs. A suite that saves a game has written into the same folder as the copy being played, and one that tidies up after itself may have removed real saves.";
728
+ var ENGINE_FLAGS, VALUE_KIND, SET_BY_ARGUMENT, OPENED_BY_A_SERVER = "GDHARNESS_OPENED_BY_A_SERVER", EDITOR_PID_VARIABLE = "GDHARNESS_EDITOR_PID", RESERVED_VARIABLE_PREFIX = "GDHARNESS_", SAVES_NOT_MOVED_NOTE = "On macOS the engine reads user:// off HOME and ignores the variables that move it elsewhere, measured on Godot 4.7.2, so this run wrote its saves where the player keeps theirs. A suite that saves a game has written into the same folder as the copy being played, and one that tidies up after itself may have removed real saves.";
729
729
  var init_launch = __esm(() => {
730
730
  ENGINE_FLAGS = Object.freeze({
731
731
  "--verbose": "none",
@@ -746,8 +746,14 @@ var init_launch = __esm(() => {
746
746
  "--time-scale": "number",
747
747
  "--fixed-fps": "number",
748
748
  "--max-fps": "number",
749
- "--resolution": "size"
749
+ "--resolution": "size",
750
+ "--write-movie": "file"
750
751
  });
752
+ VALUE_KIND = {
753
+ number: { pattern: /^\d+(?:\.\d+)?$/, named: "a number" },
754
+ size: { pattern: /^\d+x\d+$/, named: "a size such as 1280x720" },
755
+ file: { pattern: /^[^-]/, named: "a file path" }
756
+ };
751
757
  SET_BY_ARGUMENT = Object.freeze({
752
758
  "--headless": "headless",
753
759
  "--audio-driver": "silent",
@@ -11372,7 +11378,12 @@ var init_tool_definitions = __esm(() => {
11372
11378
  ignore: {
11373
11379
  type: "array",
11374
11380
  items: { type: "string" },
11375
- description: 'Suites or cases to leave out, as "suite_name" or "suite_name:test_name".'
11381
+ description: 'Suites or cases to leave out, as "suite_name" or "suite_name:test_name". A bare test name is read as a suite name and leaves out nothing; an entry that named a case which ran all the same comes back under ignoreNotApplied with the form that would have left it out.'
11382
+ },
11383
+ engineArgs: {
11384
+ type: "array",
11385
+ items: { type: "string" },
11386
+ description: `The engine's own flags for the run, such as ["--verbose"], a value going as the next entry. Only those that change what the engine logs, draws for debugging, checks on the GPU or how it times frames: ${Object.keys(ENGINE_FLAGS).join(", ")}. Any other is refused. With --verbose, engineEntries holds the ordinary lines the run printed as well, which is where the engine names the instances leaked and the resources still in use at exit.`
11376
11387
  },
11377
11388
  failFast: {
11378
11389
  type: "boolean",
@@ -11849,7 +11860,7 @@ var init_tool_definitions = __esm(() => {
11849
11860
  type: "array",
11850
11861
  items: { type: "string" },
11851
11862
  ops: ["start", "check"],
11852
- description: `The engine's own flags, before the separator, such as ["--verbose"] or ["--fixed-fps", "60"], a value going as the next entry. Only those that change what the engine logs, draws for debugging, checks on the GPU or how it times frames: ${Object.keys(ENGINE_FLAGS).join(", ")}. Any other is refused, since the engine's command line can also run scripts, open other projects and write files. A run with any is started by this server, as one with args is. A check given --verbose answers with everything printed rather than only errors and warnings, since what verbose adds, the instances leaked at exit among it, is printed as ordinary lines.`
11863
+ description: `The engine's own flags, before the separator, such as ["--verbose"] or ["--fixed-fps", "60"], a value going as the next entry. Only those that change what the engine logs, draws for debugging, checks on the GPU or how it times frames, and Movie Maker's --write-movie: ${Object.keys(ENGINE_FLAGS).join(", ")}. Any other is refused, since the engine's command line can also run scripts, open other projects and write files. --write-movie takes an absolute path outside the project, in a directory that exists, ending in .avi (one file, video and sound) or .png (one numbered PNG per frame and a .wav beside them), where nothing it would write is there already; it needs a start with headless: false, since the engine crashes writing a movie headless, and it records at the project's editor/movie_writer/fps unless --fixed-fps says otherwise. A run with any is started by this server, as one with args is. A check given --verbose answers with everything printed rather than only errors and warnings, since what verbose adds, the instances leaked at exit among it, is printed as ordinary lines.`
11853
11864
  },
11854
11865
  headless: { type: "boolean", ops: ["start"], description: "start: force a window or no window." },
11855
11866
  visible: {
@@ -39747,6 +39758,23 @@ function count(attributes, name) {
39747
39758
  const value = Number(attributes[name] ?? "0");
39748
39759
  return Number.isFinite(value) ? value : 0;
39749
39760
  }
39761
+ function ignoredYetRan(report, ignored) {
39762
+ const found = [];
39763
+ for (const entry of ignored) {
39764
+ const bare = entry.replace("res://", "");
39765
+ const at = bare.lastIndexOf(":");
39766
+ const suiteNamed = at === -1 ? null : bare.slice(0, at);
39767
+ const testNamed = at === -1 ? bare : bare.slice(at + 1);
39768
+ for (const suite of report.suites) {
39769
+ const suiteMatches = suiteNamed === null || suite.name === suiteNamed || (suite.path ?? "").includes(suiteNamed);
39770
+ const ran = suiteMatches ? suite.cases.find((one) => one.name === testNamed) : undefined;
39771
+ if (ran !== undefined) {
39772
+ found.push({ entry, ran: `${suite.name}:${ran.name}` });
39773
+ }
39774
+ }
39775
+ }
39776
+ return found;
39777
+ }
39750
39778
  function caseOf(suite, element) {
39751
39779
  const of = (kind) => element.children.filter((child) => child.name === kind).map((child) => {
39752
39780
  const detail = child.text.trim();
@@ -43244,29 +43272,62 @@ function restartUnderway(note, now = Date.now()) {
43244
43272
  return alive(note.byPid) && now - Date.parse(note.quitAt) < editorRestartTimeoutMs() + 30000;
43245
43273
  }
43246
43274
  function captureDestinationRefusal(outputPath, projects) {
43247
- if (/^(res|user):\/\//.test(outputPath)) {
43248
- return `outputPath ${outputPath} is a Godot path; a capture is saved outside the project, at an absolute path on this machine.`;
43275
+ return writtenFileRefusal(outputPath, {
43276
+ named: "outputPath",
43277
+ what: "a capture",
43278
+ extensions: [".png"],
43279
+ projects,
43280
+ writes: (where) => [where]
43281
+ });
43282
+ }
43283
+ function writtenFileRefusal(path, rule) {
43284
+ const { named, what } = rule;
43285
+ if (/^(res|user):\/\//.test(path)) {
43286
+ return `${named} ${path} is a Godot path; ${what} is saved outside the project, at an absolute path on this machine.`;
43249
43287
  }
43250
- if (!isAbsolute3(outputPath)) {
43251
- return `outputPath ${outputPath} is not absolute; name the whole path, so where it is written does not depend on this server's directory.`;
43288
+ if (!isAbsolute3(path)) {
43289
+ return `${named} ${path} is not absolute; name the whole path, so where it is written does not depend on this server's directory.`;
43252
43290
  }
43253
- if (extname3(outputPath).toLowerCase() !== ".png") {
43254
- return `outputPath ${outputPath} does not end in .png, which is what a capture is.`;
43291
+ if (!rule.extensions.includes(extname3(path).toLowerCase())) {
43292
+ return `${named} ${path} does not end in ${rule.extensions.join(" or ")}, which is what ${what} is.`;
43255
43293
  }
43256
- const directory = realPathOr(dirname19(outputPath));
43294
+ const directory = realPathOr(dirname19(path));
43257
43295
  if (!existsSync20(directory)) {
43258
- return `outputPath's directory ${dirname19(outputPath)} does not exist; a capture creates no directories.`;
43296
+ return `${named}'s directory ${dirname19(path)} does not exist; ${what} creates no directories.`;
43259
43297
  }
43260
- const where = join26(directory, basename7(outputPath));
43261
- const inside = projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
43298
+ const where = join26(directory, basename7(path));
43299
+ const inside = rule.projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
43262
43300
  if (inside !== undefined) {
43263
- return `outputPath ${outputPath} is inside the project at ${inside}; a capture is saved outside it, so it never becomes part of the game.`;
43301
+ return `${named} ${path} is inside the project at ${inside}; ${what} is saved outside it, so it never becomes part of the game.`;
43264
43302
  }
43265
- if (existsSync20(where)) {
43266
- return `outputPath ${outputPath} already exists; a capture is not written over a file. Name a new one.`;
43303
+ const taken = rule.writes(where).find((file) => existsSync20(file));
43304
+ if (taken !== undefined) {
43305
+ return taken === where ? `${named} ${path} already exists; ${what} is not written over a file. Name a new one.` : `${named} ${path} would write ${taken}, which already exists; ${what} is not written over a file. Name a new one.`;
43267
43306
  }
43268
43307
  return null;
43269
43308
  }
43309
+ function movieRefusal(engineArgs, projects, headless) {
43310
+ const at = engineArgs.indexOf("--write-movie");
43311
+ if (at === -1) {
43312
+ return null;
43313
+ }
43314
+ if (headless) {
43315
+ return "--write-movie needs a run that draws, and this one is headless: Movie Maker reads every frame back from the renderer, and the engine crashed on the first frame when asked headless. Start the run with headless: false; it is drawn on a desktop of its own and does not show.";
43316
+ }
43317
+ return writtenFileRefusal(engineArgs[at + 1] ?? "", {
43318
+ named: "--write-movie",
43319
+ what: "a movie",
43320
+ extensions: [".avi", ".png"],
43321
+ projects,
43322
+ writes: (where) => {
43323
+ if (extname3(where).toLowerCase() !== ".png") {
43324
+ return [where];
43325
+ }
43326
+ const base = where.slice(0, -".png".length);
43327
+ return [`${base}00000000.png`, `${base}.wav`];
43328
+ }
43329
+ });
43330
+ }
43270
43331
  function captureDetail(payload) {
43271
43332
  const parts = [];
43272
43333
  const region = payload["region"];
@@ -45899,6 +45960,14 @@ class GodotServer {
45899
45960
  if (!paths.ok) {
45900
45961
  return this.createErrorResponse(paths.reason, PATH_SOLUTIONS);
45901
45962
  }
45963
+ const flags = this.engineArguments(args);
45964
+ if (!flags.ok) {
45965
+ return flags.response;
45966
+ }
45967
+ const movie = movieRefusal(flags.value, [project.value.path], true);
45968
+ if (movie !== null) {
45969
+ return this.createErrorResponse(movie);
45970
+ }
45902
45971
  const missing = paths.value.filter((path) => !existsSync20(join26(project.value.path, path.slice("res://".length))));
45903
45972
  const asked = paths.value.filter((path) => !missing.includes(path));
45904
45973
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
@@ -45946,6 +46015,7 @@ class GodotServer {
45946
46015
  "--headless",
45947
46016
  "--path",
45948
46017
  project.value.path,
46018
+ ...flags.value,
45949
46019
  "-s",
45950
46020
  `res://${runner}`,
45951
46021
  "--ignoreHeadlessMode",
@@ -46009,7 +46079,11 @@ class GodotServer {
46009
46079
  rmdirSync2(dirname19(reportsDir));
46010
46080
  } catch {}
46011
46081
  }
46012
- const engineEntries = foldedRepeats(run.log.select({ severity: "warning", sinceLastCall: false, limit: Number.POSITIVE_INFINITY }).entries.map(aboveTheRunner));
46082
+ const engineEntries = foldedRepeats(run.log.select({
46083
+ severity: flags.value.includes("--verbose") ? "info" : "warning",
46084
+ sinceLastCall: false,
46085
+ limit: Number.POSITIVE_INFINITY
46086
+ }).entries.map(aboveTheRunner));
46013
46087
  const engineEntriesShown = engineEntries.slice(-MOST_ENGINE_ENTRIES);
46014
46088
  const verdicts = {
46015
46089
  0: "passed",
@@ -46120,6 +46194,7 @@ class GodotServer {
46120
46194
  const notRun = report.suites.reduce((sum, suite) => sum + Math.max(0, suite.discovered - suite.tests), 0);
46121
46195
  const uncaughtFolded = foldedRepeats(uncaught.map(aboveTheRunner));
46122
46196
  const listedAsUncaught = new Set(uncaughtFolded.map((entry) => entry.index));
46197
+ const notApplied = ignoredYetRan(report, ignored);
46123
46198
  return this.jsonTextResponse({
46124
46199
  passed: !timedOut && exitCode === 0 && nothingRan === null && report.tests > 0 && report.failures === 0 && report.errors === 0 && hookFailures.length === 0 && uncaught.length === 0 && missing.length === 0,
46125
46200
  verdict,
@@ -46138,11 +46213,13 @@ class GodotServer {
46138
46213
  warnings: warnings.length > 0 ? warnings : undefined,
46139
46214
  orphans: orphans.total > 0 ? orphans.total : undefined,
46140
46215
  notRun: notRun > 0 ? notRun : undefined,
46216
+ ignoreNotApplied: notApplied.length > 0 ? notApplied : undefined,
46141
46217
  ...printedLines,
46142
46218
  savesNote,
46143
46219
  ...scanned,
46144
46220
  note: [
46145
46221
  missingNote,
46222
+ 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,
46146
46223
  uncaughtNote(uncaught),
46147
46224
  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)
46148
46225
  ].filter((part) => part !== undefined).join(" ") || undefined,
@@ -47002,6 +47079,10 @@ class GodotServer {
47002
47079
  if (!flags.ok) {
47003
47080
  return flags.response;
47004
47081
  }
47082
+ const movie = movieRefusal(flags.value, [project.value.path], op === "check" || resolveHeadless(args["headless"], { platform: process.platform, variables: process.env }));
47083
+ if (movie !== null) {
47084
+ return this.createErrorResponse(movie);
47085
+ }
47005
47086
  const asked = this.runEnvironment(args);
47006
47087
  if (!asked.ok) {
47007
47088
  return asked.response;
package/build/index.js CHANGED
@@ -24932,8 +24932,14 @@ var ENGINE_FLAGS = Object.freeze({
24932
24932
  "--time-scale": "number",
24933
24933
  "--fixed-fps": "number",
24934
24934
  "--max-fps": "number",
24935
- "--resolution": "size"
24935
+ "--resolution": "size",
24936
+ "--write-movie": "file"
24936
24937
  });
24938
+ var VALUE_KIND = {
24939
+ number: { pattern: /^\d+(?:\.\d+)?$/, named: "a number" },
24940
+ size: { pattern: /^\d+x\d+$/, named: "a size such as 1280x720" },
24941
+ file: { pattern: /^[^-]/, named: "a file path" }
24942
+ };
24937
24943
  var SET_BY_ARGUMENT = Object.freeze({
24938
24944
  "--headless": "headless",
24939
24945
  "--audio-driver": "silent",
@@ -24948,18 +24954,18 @@ function engineFlags(given) {
24948
24954
  const setBy = Object.hasOwn(SET_BY_ARGUMENT, flag) ? SET_BY_ARGUMENT[flag] : undefined;
24949
24955
  return {
24950
24956
  ok: false,
24951
- reason: setBy !== undefined ? `${flag} is set by this tool's own argument, ${setBy}, rather than through engineArgs.` : `${flag} is not one of the engine flags engineArgs takes, which are those that change what the engine logs, draws for debugging, checks on the GPU or how it times frames: ${offered}. A value goes as the next entry, as in ["--fixed-fps", "60"].`
24957
+ reason: setBy !== undefined ? `${flag} is set by this tool's own argument, ${setBy}, rather than through engineArgs.` : `${flag} is not one of the engine flags engineArgs takes, which are those that change what the engine logs, draws for debugging, checks on the GPU or how it times frames, and Movie Maker's --write-movie: ${offered}. A value goes as the next entry, as in ["--fixed-fps", "60"].`
24952
24958
  };
24953
24959
  }
24954
24960
  if (takes === "none") {
24955
24961
  continue;
24956
24962
  }
24957
24963
  const value = given[at + 1];
24958
- const fits = value !== undefined && (takes === "number" ? /^\d+(?:\.\d+)?$/.test(value) : /^\d+x\d+$/.test(value));
24959
- if (!fits) {
24964
+ const kind = VALUE_KIND[takes];
24965
+ if (value === undefined || !kind.pattern.test(value)) {
24960
24966
  return {
24961
24967
  ok: false,
24962
- reason: `${flag} takes ${takes === "number" ? "a number" : "a size such as 1280x720"} as the next entry, and ${value === undefined ? "nothing followed it" : `${JSON.stringify(value)} is not one`}.`
24968
+ reason: `${flag} takes ${kind.named} as the next entry, and ${value === undefined ? "nothing followed it" : `${JSON.stringify(value)} is not one`}.`
24963
24969
  };
24964
24970
  }
24965
24971
  at += 1;
@@ -29963,6 +29969,23 @@ function count(attributes, name) {
29963
29969
  const value = Number(attributes[name] ?? "0");
29964
29970
  return Number.isFinite(value) ? value : 0;
29965
29971
  }
29972
+ function ignoredYetRan(report, ignored) {
29973
+ const found = [];
29974
+ for (const entry of ignored) {
29975
+ const bare = entry.replace("res://", "");
29976
+ const at = bare.lastIndexOf(":");
29977
+ const suiteNamed = at === -1 ? null : bare.slice(0, at);
29978
+ const testNamed = at === -1 ? bare : bare.slice(at + 1);
29979
+ for (const suite of report.suites) {
29980
+ const suiteMatches = suiteNamed === null || suite.name === suiteNamed || (suite.path ?? "").includes(suiteNamed);
29981
+ const ran = suiteMatches ? suite.cases.find((one) => one.name === testNamed) : undefined;
29982
+ if (ran !== undefined) {
29983
+ found.push({ entry, ran: `${suite.name}:${ran.name}` });
29984
+ }
29985
+ }
29986
+ }
29987
+ return found;
29988
+ }
29966
29989
  function caseOf(suite, element) {
29967
29990
  const of = (kind) => element.children.filter((child) => child.name === kind).map((child) => {
29968
29991
  const detail = child.text.trim();
@@ -34404,7 +34427,12 @@ var TOOL_SPECS = [
34404
34427
  ignore: {
34405
34428
  type: "array",
34406
34429
  items: { type: "string" },
34407
- description: 'Suites or cases to leave out, as "suite_name" or "suite_name:test_name".'
34430
+ description: 'Suites or cases to leave out, as "suite_name" or "suite_name:test_name". A bare test name is read as a suite name and leaves out nothing; an entry that named a case which ran all the same comes back under ignoreNotApplied with the form that would have left it out.'
34431
+ },
34432
+ engineArgs: {
34433
+ type: "array",
34434
+ items: { type: "string" },
34435
+ description: `The engine's own flags for the run, such as ["--verbose"], a value going as the next entry. Only those that change what the engine logs, draws for debugging, checks on the GPU or how it times frames: ${Object.keys(ENGINE_FLAGS).join(", ")}. Any other is refused. With --verbose, engineEntries holds the ordinary lines the run printed as well, which is where the engine names the instances leaked and the resources still in use at exit.`
34408
34436
  },
34409
34437
  failFast: {
34410
34438
  type: "boolean",
@@ -34881,7 +34909,7 @@ var TOOL_SPECS = [
34881
34909
  type: "array",
34882
34910
  items: { type: "string" },
34883
34911
  ops: ["start", "check"],
34884
- description: `The engine's own flags, before the separator, such as ["--verbose"] or ["--fixed-fps", "60"], a value going as the next entry. Only those that change what the engine logs, draws for debugging, checks on the GPU or how it times frames: ${Object.keys(ENGINE_FLAGS).join(", ")}. Any other is refused, since the engine's command line can also run scripts, open other projects and write files. A run with any is started by this server, as one with args is. A check given --verbose answers with everything printed rather than only errors and warnings, since what verbose adds, the instances leaked at exit among it, is printed as ordinary lines.`
34912
+ description: `The engine's own flags, before the separator, such as ["--verbose"] or ["--fixed-fps", "60"], a value going as the next entry. Only those that change what the engine logs, draws for debugging, checks on the GPU or how it times frames, and Movie Maker's --write-movie: ${Object.keys(ENGINE_FLAGS).join(", ")}. Any other is refused, since the engine's command line can also run scripts, open other projects and write files. --write-movie takes an absolute path outside the project, in a directory that exists, ending in .avi (one file, video and sound) or .png (one numbered PNG per frame and a .wav beside them), where nothing it would write is there already; it needs a start with headless: false, since the engine crashes writing a movie headless, and it records at the project's editor/movie_writer/fps unless --fixed-fps says otherwise. A run with any is started by this server, as one with args is. A check given --verbose answers with everything printed rather than only errors and warnings, since what verbose adds, the instances leaked at exit among it, is printed as ordinary lines.`
34885
34913
  },
34886
34914
  headless: { type: "boolean", ops: ["start"], description: "start: force a window or no window." },
34887
34915
  visible: {
@@ -35911,29 +35939,62 @@ function restartUnderway(note, now = Date.now()) {
35911
35939
  var HOLD_PING_MS = 2000;
35912
35940
  var SLOWEST_FRAME_RATE = 20;
35913
35941
  function captureDestinationRefusal(outputPath, projects) {
35914
- if (/^(res|user):\/\//.test(outputPath)) {
35915
- return `outputPath ${outputPath} is a Godot path; a capture is saved outside the project, at an absolute path on this machine.`;
35942
+ return writtenFileRefusal(outputPath, {
35943
+ named: "outputPath",
35944
+ what: "a capture",
35945
+ extensions: [".png"],
35946
+ projects,
35947
+ writes: (where) => [where]
35948
+ });
35949
+ }
35950
+ function writtenFileRefusal(path, rule) {
35951
+ const { named, what } = rule;
35952
+ if (/^(res|user):\/\//.test(path)) {
35953
+ return `${named} ${path} is a Godot path; ${what} is saved outside the project, at an absolute path on this machine.`;
35916
35954
  }
35917
- if (!isAbsolute3(outputPath)) {
35918
- return `outputPath ${outputPath} is not absolute; name the whole path, so where it is written does not depend on this server's directory.`;
35955
+ if (!isAbsolute3(path)) {
35956
+ return `${named} ${path} is not absolute; name the whole path, so where it is written does not depend on this server's directory.`;
35919
35957
  }
35920
- if (extname3(outputPath).toLowerCase() !== ".png") {
35921
- return `outputPath ${outputPath} does not end in .png, which is what a capture is.`;
35958
+ if (!rule.extensions.includes(extname3(path).toLowerCase())) {
35959
+ return `${named} ${path} does not end in ${rule.extensions.join(" or ")}, which is what ${what} is.`;
35922
35960
  }
35923
- const directory = realPathOr(dirname15(outputPath));
35961
+ const directory = realPathOr(dirname15(path));
35924
35962
  if (!existsSync17(directory)) {
35925
- return `outputPath's directory ${dirname15(outputPath)} does not exist; a capture creates no directories.`;
35963
+ return `${named}'s directory ${dirname15(path)} does not exist; ${what} creates no directories.`;
35926
35964
  }
35927
- const where = join22(directory, basename5(outputPath));
35928
- const inside = projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
35965
+ const where = join22(directory, basename5(path));
35966
+ const inside = rule.projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
35929
35967
  if (inside !== undefined) {
35930
- return `outputPath ${outputPath} is inside the project at ${inside}; a capture is saved outside it, so it never becomes part of the game.`;
35968
+ return `${named} ${path} is inside the project at ${inside}; ${what} is saved outside it, so it never becomes part of the game.`;
35931
35969
  }
35932
- if (existsSync17(where)) {
35933
- return `outputPath ${outputPath} already exists; a capture is not written over a file. Name a new one.`;
35970
+ const taken = rule.writes(where).find((file) => existsSync17(file));
35971
+ if (taken !== undefined) {
35972
+ return taken === where ? `${named} ${path} already exists; ${what} is not written over a file. Name a new one.` : `${named} ${path} would write ${taken}, which already exists; ${what} is not written over a file. Name a new one.`;
35934
35973
  }
35935
35974
  return null;
35936
35975
  }
35976
+ function movieRefusal(engineArgs, projects, headless) {
35977
+ const at = engineArgs.indexOf("--write-movie");
35978
+ if (at === -1) {
35979
+ return null;
35980
+ }
35981
+ if (headless) {
35982
+ return "--write-movie needs a run that draws, and this one is headless: Movie Maker reads every frame back from the renderer, and the engine crashed on the first frame when asked headless. Start the run with headless: false; it is drawn on a desktop of its own and does not show.";
35983
+ }
35984
+ return writtenFileRefusal(engineArgs[at + 1] ?? "", {
35985
+ named: "--write-movie",
35986
+ what: "a movie",
35987
+ extensions: [".avi", ".png"],
35988
+ projects,
35989
+ writes: (where) => {
35990
+ if (extname3(where).toLowerCase() !== ".png") {
35991
+ return [where];
35992
+ }
35993
+ const base = where.slice(0, -".png".length);
35994
+ return [`${base}00000000.png`, `${base}.wav`];
35995
+ }
35996
+ });
35997
+ }
35937
35998
  function captureDetail(payload) {
35938
35999
  const parts = [];
35939
36000
  const region = payload["region"];
@@ -38647,6 +38708,14 @@ class GodotServer {
38647
38708
  if (!paths.ok) {
38648
38709
  return this.createErrorResponse(paths.reason, PATH_SOLUTIONS);
38649
38710
  }
38711
+ const flags = this.engineArguments(args);
38712
+ if (!flags.ok) {
38713
+ return flags.response;
38714
+ }
38715
+ const movie = movieRefusal(flags.value, [project.value.path], true);
38716
+ if (movie !== null) {
38717
+ return this.createErrorResponse(movie);
38718
+ }
38650
38719
  const missing = paths.value.filter((path) => !existsSync17(join22(project.value.path, path.slice("res://".length))));
38651
38720
  const asked = paths.value.filter((path) => !missing.includes(path));
38652
38721
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
@@ -38694,6 +38763,7 @@ class GodotServer {
38694
38763
  "--headless",
38695
38764
  "--path",
38696
38765
  project.value.path,
38766
+ ...flags.value,
38697
38767
  "-s",
38698
38768
  `res://${runner}`,
38699
38769
  "--ignoreHeadlessMode",
@@ -38757,7 +38827,11 @@ class GodotServer {
38757
38827
  rmdirSync2(dirname15(reportsDir));
38758
38828
  } catch {}
38759
38829
  }
38760
- const engineEntries = foldedRepeats(run.log.select({ severity: "warning", sinceLastCall: false, limit: Number.POSITIVE_INFINITY }).entries.map(aboveTheRunner));
38830
+ const engineEntries = foldedRepeats(run.log.select({
38831
+ severity: flags.value.includes("--verbose") ? "info" : "warning",
38832
+ sinceLastCall: false,
38833
+ limit: Number.POSITIVE_INFINITY
38834
+ }).entries.map(aboveTheRunner));
38761
38835
  const engineEntriesShown = engineEntries.slice(-MOST_ENGINE_ENTRIES);
38762
38836
  const verdicts = {
38763
38837
  0: "passed",
@@ -38868,6 +38942,7 @@ class GodotServer {
38868
38942
  const notRun = report.suites.reduce((sum, suite) => sum + Math.max(0, suite.discovered - suite.tests), 0);
38869
38943
  const uncaughtFolded = foldedRepeats(uncaught.map(aboveTheRunner));
38870
38944
  const listedAsUncaught = new Set(uncaughtFolded.map((entry) => entry.index));
38945
+ const notApplied = ignoredYetRan(report, ignored);
38871
38946
  return this.jsonTextResponse({
38872
38947
  passed: !timedOut && exitCode === 0 && nothingRan === null && report.tests > 0 && report.failures === 0 && report.errors === 0 && hookFailures.length === 0 && uncaught.length === 0 && missing.length === 0,
38873
38948
  verdict,
@@ -38886,11 +38961,13 @@ class GodotServer {
38886
38961
  warnings: warnings.length > 0 ? warnings : undefined,
38887
38962
  orphans: orphans.total > 0 ? orphans.total : undefined,
38888
38963
  notRun: notRun > 0 ? notRun : undefined,
38964
+ ignoreNotApplied: notApplied.length > 0 ? notApplied : undefined,
38889
38965
  ...printedLines,
38890
38966
  savesNote,
38891
38967
  ...scanned,
38892
38968
  note: [
38893
38969
  missingNote,
38970
+ 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,
38894
38971
  uncaughtNote(uncaught),
38895
38972
  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)
38896
38973
  ].filter((part) => part !== undefined).join(" ") || undefined,
@@ -39750,6 +39827,10 @@ class GodotServer {
39750
39827
  if (!flags.ok) {
39751
39828
  return flags.response;
39752
39829
  }
39830
+ const movie = movieRefusal(flags.value, [project.value.path], op === "check" || resolveHeadless(args["headless"], { platform: process.platform, variables: process.env }));
39831
+ if (movie !== null) {
39832
+ return this.createErrorResponse(movie);
39833
+ }
39753
39834
  const asked = this.runEnvironment(args);
39754
39835
  if (!asked.ok) {
39755
39836
  return asked.response;
package/build/keeper.js CHANGED
@@ -382,7 +382,8 @@ var ENGINE_FLAGS = Object.freeze({
382
382
  "--time-scale": "number",
383
383
  "--fixed-fps": "number",
384
384
  "--max-fps": "number",
385
- "--resolution": "size"
385
+ "--resolution": "size",
386
+ "--write-movie": "file"
386
387
  });
387
388
  var SET_BY_ARGUMENT = Object.freeze({
388
389
  "--headless": "headless",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.2.23",
3
+ "version": "1.2.24",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",