gdharness 1.1.40 → 1.1.42

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
@@ -17592,8 +17592,9 @@ var init_tool_definitions = __esm(() => {
17592
17592
  parameters: {
17593
17593
  projectPath: PROJECT_PATH,
17594
17594
  path: {
17595
- type: "string",
17596
- description: "A test directory or one suite file inside the project. Default test."
17595
+ type: ["string", "array"],
17596
+ items: { type: "string" },
17597
+ 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. A run where any entry is not there is refused before the engine starts, naming each one. Default test."
17597
17598
  },
17598
17599
  ignore: {
17599
17600
  type: "array",
@@ -42085,6 +42086,36 @@ function elsewhereIn(projectPath, asked) {
42085
42086
  const there = near.map((name) => `res://${name}`).join(" and ");
42086
42087
  return near.length === 0 ? "" : ` The project does have ${there}.`;
42087
42088
  }
42089
+ function testPathsIn(projectPath, given) {
42090
+ const entries = given === undefined || given === null || given === "" ? ["test"] : given;
42091
+ const listed = Array.isArray(entries) ? entries : [entries];
42092
+ if (listed.length === 0) {
42093
+ return {
42094
+ ok: false,
42095
+ reason: "path is an empty list: name a suite file or a directory, or leave path out for test."
42096
+ };
42097
+ }
42098
+ const resolved = [];
42099
+ for (const [index, entry] of listed.entries()) {
42100
+ if (typeof entry !== "string" || entry.trim() === "") {
42101
+ return {
42102
+ ok: false,
42103
+ reason: `path${Array.isArray(entries) ? `[${index}]` : ""} is ${typeof entry === "string" ? "empty" : describeValue(entry)}, not a suite file or a directory in the project.`
42104
+ };
42105
+ }
42106
+ const location = resolveWithinProject(projectPath, entry);
42107
+ if (!location.ok) {
42108
+ return { ok: false, reason: location.reason };
42109
+ }
42110
+ resolved.push(location.relativePath.replace(/\/+$/, ""));
42111
+ }
42112
+ const unique = [...new Set(resolved)];
42113
+ const within = (path, directory) => path.startsWith(`${directory}/`);
42114
+ return {
42115
+ ok: true,
42116
+ value: unique.filter((path) => !unique.some((other) => other !== path && within(path, other))).map((path) => `res://${path}`)
42117
+ };
42118
+ }
42088
42119
  function hasMainScene(projectFile) {
42089
42120
  const scene = parseProjectGodot(readFileSync21(projectFile, "utf8"))["application"]?.["run/main_scene"];
42090
42121
  return typeof scene === "string" && scene !== "";
@@ -43600,9 +43631,24 @@ class GodotServer {
43600
43631
  if (!project.ok) {
43601
43632
  return project.response;
43602
43633
  }
43603
- const contained = this.containProjectFiles({ ...args, path: readNonEmptyString(args, "path") ?? "test" });
43604
- if (!contained.ok) {
43605
- return contained.response;
43634
+ const paths = testPathsIn(project.value.path, args["path"]);
43635
+ if (!paths.ok) {
43636
+ return this.createErrorResponse(paths.reason, PATH_SOLUTIONS);
43637
+ }
43638
+ const asked = paths.value;
43639
+ const missing = asked.filter((path) => !existsSync19(join24(project.value.path, path.slice("res://".length))));
43640
+ if (missing.length > 0) {
43641
+ const verdict = `nothing at ${missing.join(" and ")}`;
43642
+ return {
43643
+ content: [
43644
+ {
43645
+ type: "text",
43646
+ text: `No tests ran: ${verdict}.${elsewhereIn(project.value.path, missing[0] ?? "")}`
43647
+ },
43648
+ { type: "text", text: answerJson({ passed: false, verdict, tests: 0 }) }
43649
+ ],
43650
+ isError: true
43651
+ };
43606
43652
  }
43607
43653
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
43608
43654
  if (!existsSync19(join24(project.value.path, runner))) {
@@ -43622,7 +43668,6 @@ class GodotServer {
43622
43668
  const ours = `gdharness-reports/${randomUUID2()}`;
43623
43669
  const reports = `res://.godot/${ours}`;
43624
43670
  const ignored = readStringArray(args, "ignore") ?? [];
43625
- const asked = readString(contained.value, "path") ?? "res://test";
43626
43671
  const cmdArgs = [
43627
43672
  "--headless",
43628
43673
  "--path",
@@ -43631,8 +43676,7 @@ class GodotServer {
43631
43676
  `res://${runner}`,
43632
43677
  "--ignoreHeadlessMode",
43633
43678
  ...readBoolean(args, "failFast") === true ? [] : ["-c"],
43634
- "-a",
43635
- asked,
43679
+ ...asked.flatMap((path) => ["-a", path]),
43636
43680
  ...ignored.flatMap((entry) => ["-i", entry]),
43637
43681
  "-rd",
43638
43682
  reports,
@@ -43705,7 +43749,7 @@ class GodotServer {
43705
43749
  limit: Number.POSITIVE_INFINITY
43706
43750
  }).entries;
43707
43751
  const said = printed.map((entry) => entry.text);
43708
- const nothingRan = timedOut ? null : whyNoReport(said, asked);
43752
+ const nothingRan = timedOut ? null : whyNoReport(said, asked.join(" and "));
43709
43753
  const cutShort = timedOut ? timedOutVerdict(timeoutMs, silentForMs) : null;
43710
43754
  const hung = cutShort?.hung ?? false;
43711
43755
  const scriptErrors = timedOut || exitCode !== 105 ? [] : scriptErrorsPrinted(said);
@@ -43719,7 +43763,7 @@ class GodotServer {
43719
43763
  ...echoed.omitted > 0 ? { printedOmitted: echoed.omitted } : {}
43720
43764
  };
43721
43765
  if (report === null) {
43722
- const note = nothingRan !== null ? `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}` : scriptErrors.length > 0 ? scriptErrorsNote(scriptErrors) : reportProblem !== null ? `The test run's report could not be read (${reportProblem}); the engine's own verdict was ${verdict}.` : `The test run wrote no report (${verdict}).`;
43766
+ const note = nothingRan !== null ? `No tests ran: ${verdict}.` : scriptErrors.length > 0 ? scriptErrorsNote(scriptErrors) : reportProblem !== null ? `The test run's report could not be read (${reportProblem}); the engine's own verdict was ${verdict}.` : `The test run wrote no report (${verdict}).`;
43723
43767
  return {
43724
43768
  content: [
43725
43769
  { type: "text", text: note },
@@ -288,6 +288,10 @@ static func _areas_of(drawn: Array[Node]) -> Array[Rect2]:
288
288
  ## the click scrolls it into view first: what is drawn over its place now is not what it will land
289
289
  ## under. Judged not covered at all instead, a button below the fold of a roster that a full-screen
290
290
  ## page covered was picked over the ones in view, scrolled up under the page and pressed there.
291
+ ## Whatever else the scroll holds moves with it and is judged where it goes. Judged where it sat, the
292
+ ## card at the top of a tray scrolled down was drawn over a button above the view, because a later
293
+ ## card is drawn over an earlier one, and a button below the view was passed by the same card only
294
+ ## because the cards beside the bottom edge come before it.
291
295
  ##
292
296
  ## [param areas] holds the box around each of [param drawn], so everything whose box misses the
293
297
  ## point is passed over before anything costlier is asked of it. A window's box is in the space of
@@ -295,7 +299,8 @@ static func _areas_of(drawn: Array[Node]) -> Array[Rect2]:
295
299
  ## so a window is judged by [method _window_over] instead.
296
300
  static func _cover_of(target: Control, drawn: Array[Node], areas: Array[Rect2]) -> Node:
297
301
  var viewport: Viewport = target.get_viewport()
298
- var point: Vector2 = _where_it_is_clicked(target)
302
+ var moves: Dictionary[ScrollContainer, Vector2] = _scrolls_for(target)
303
+ var point: Vector2 = _centre_of(target) + _moved_by(target, moves)
299
304
  var layer: int = _layer_of(target)
300
305
  for at: int in drawn.size():
301
306
  var other: Node = drawn[at]
@@ -304,9 +309,11 @@ static func _cover_of(target: Control, drawn: Array[Node], areas: Array[Rect2])
304
309
  if _window_over(target, point, window):
305
310
  return window
306
311
  continue
307
- if not areas[at].has_point(point):
308
- continue
309
312
  var control: Control = other
313
+ # The point where this control is now, which is where the scroll will carry it to the point.
314
+ var before: Vector2 = point - _moved_by(control, moves)
315
+ if not areas[at].has_point(before):
316
+ continue
310
317
  if control == target or control.is_ancestor_of(target) or target.is_ancestor_of(control):
311
318
  continue
312
319
  if control.get_viewport() != viewport:
@@ -314,18 +321,34 @@ static func _cover_of(target: Control, drawn: Array[Node], areas: Array[Rect2])
314
321
  var its_layer: int = _layer_of(control)
315
322
  if its_layer < layer or (its_layer == layer and not control.is_greater_than(target)):
316
323
  continue
317
- if _holds(control, point) and not _clipped_away(control, point):
324
+ if _holds(control, before) and not _clipped_away(control, point, moves):
318
325
  return control
319
326
  return null
320
327
 
321
328
 
322
- ## Where a click at [param target] lands in its viewport: its centre, or where scrolling will bring
323
- ## the centre when a ScrollContainer holding it clips it away, as the click scrolls it first.
324
- static func _where_it_is_clicked(target: Control) -> Vector2:
325
- var point: Vector2 = target.get_global_transform_with_canvas() * (target.size * 0.5)
326
- if _clipped_away(target, point):
327
- point = _where_it_lands(target, point)
328
- return point
329
+ ## [param target]'s centre in its viewport, where it is now.
330
+ static func _centre_of(target: Control) -> Vector2:
331
+ return target.get_global_transform_with_canvas() * (target.size * 0.5)
332
+
333
+
334
+ ## How far the click moves what each ScrollContainer holding [param target] holds, to bring it into
335
+ ## view: empty when no container clips it away, as nothing is scrolled then.
336
+ static func _scrolls_for(target: Control) -> Dictionary[ScrollContainer, Vector2]:
337
+ var none: Dictionary[ScrollContainer, Vector2] = {}
338
+ var centre: Vector2 = _centre_of(target)
339
+ if not _clipped_away(target, centre, none):
340
+ return none
341
+ return _where_it_lands(target, centre)
342
+
343
+
344
+ ## How far the scrolls in [param moves] carry [param node]: the sum over the containers holding it,
345
+ ## and nothing for a container itself, which stays where it is while what it holds moves.
346
+ static func _moved_by(node: Node, moves: Dictionary[ScrollContainer, Vector2]) -> Vector2:
347
+ var moved: Vector2 = Vector2.ZERO
348
+ for holder: ScrollContainer in moves:
349
+ if holder.is_ancestor_of(node):
350
+ moved += moves[holder]
351
+ return moved
329
352
 
330
353
 
331
354
  ## Whether the embedded [param window] is drawn over [param point], [param target]'s centre in its
@@ -366,14 +389,15 @@ static func _holds(control: Control, point: Vector2) -> bool:
366
389
  return Rect2(Vector2.ZERO, control.size).has_point(placed.affine_inverse() * point)
367
390
 
368
391
 
369
- ## Where [param target]'s centre, now at [param point], will be once the click has scrolled it into
370
- ## view: moved as little as puts the whole of it inside each ScrollContainer holding it, innermost
371
- ## first, which is how one brings a control into view. A target larger than a container is centred
372
- ## in it. Only a ScrollContainer, because that is all a click scrolls: another container clipping
373
- ## what it holds, a collapsed section for one, leaves the target where it is.
374
- static func _where_it_lands(target: Control, point: Vector2) -> Vector2:
392
+ ## How far each ScrollContainer holding [param target] moves what it holds once the click has
393
+ ## scrolled [param target], centred at [param point], into view: as little as puts the whole of it
394
+ ## inside each, innermost first, which is how one brings a control into view. A target larger than a
395
+ ## container is centred in it. Only a ScrollContainer, because that is all a click scrolls: another
396
+ ## container clipping what it holds, a collapsed section for one, leaves the target where it is.
397
+ static func _where_it_lands(target: Control, point: Vector2) -> Dictionary[ScrollContainer, Vector2]:
375
398
  var drawn: Transform2D = target.get_global_transform_with_canvas()
376
399
  var half: Vector2 = (drawn * Rect2(Vector2.ZERO, target.size)).size * 0.5
400
+ var moves: Dictionary[ScrollContainer, Vector2] = {}
377
401
  var landed: Vector2 = point
378
402
  var walk: Node = target.get_parent()
379
403
  while walk != null and not (walk is Viewport):
@@ -382,12 +406,15 @@ static func _where_it_lands(target: Control, point: Vector2) -> Vector2:
382
406
  var shown: Rect2 = holder.get_global_transform_with_canvas() * Rect2(Vector2.ZERO, holder.size)
383
407
  var low: Vector2 = shown.position + half
384
408
  var high: Vector2 = shown.end - half
385
- landed = Vector2(
409
+ var next: Vector2 = Vector2(
386
410
  shown.get_center().x if low.x > high.x else clampf(landed.x, low.x, high.x),
387
411
  shown.get_center().y if low.y > high.y else clampf(landed.y, low.y, high.y)
388
412
  )
413
+ if next != landed:
414
+ moves[holder] = next - landed
415
+ landed = next
389
416
  walk = walk.get_parent()
390
- return landed
417
+ return moves
391
418
 
392
419
 
393
420
  ## Whether a click could not reach [param target] however it scrolled: its centre, where the click
@@ -395,8 +422,9 @@ static func _where_it_lands(target: Control, point: Vector2) -> Vector2:
395
422
  ## container the click does not scroll. A drawer parked off the side of the screen, or anything
396
423
  ## outside the 64 by 64 viewport of a game with no window, was listed among the controls on screen.
397
424
  static func _off_screen(target: Control) -> bool:
398
- var point: Vector2 = _where_it_is_clicked(target)
399
- if _clipped_away(target, point) or not target.get_viewport().get_visible_rect().has_point(point):
425
+ var moves: Dictionary[ScrollContainer, Vector2] = _scrolls_for(target)
426
+ var point: Vector2 = _centre_of(target) + _moved_by(target, moves)
427
+ if _clipped_away(target, point, moves) or not target.get_viewport().get_visible_rect().has_point(point):
400
428
  return true
401
429
  var reached: Dictionary = Screen.reach(target.get_viewport(), point)
402
430
  var viewport: Viewport = reached["viewport"]
@@ -404,16 +432,19 @@ static func _off_screen(target: Control) -> bool:
404
432
  return not viewport.get_visible_rect().has_point(there)
405
433
 
406
434
 
407
- ## Whether a container clipping what it holds cuts [param point] off from [param control].
435
+ ## Whether a container clipping what it holds cuts [param point] off from [param control], once the
436
+ ## scrolls in [param moves] have carried each container wherever they carry it.
408
437
  ##
409
438
  ## Only up to the viewport [param control] is in. A dialog is a window, and the containers the game
410
439
  ## added it under are in another space: judged against a clipping one of those, every button of a
411
440
  ## confirmation was refused as off the screen while it sat in plain view.
412
- static func _clipped_away(control: Control, point: Vector2) -> bool:
441
+ static func _clipped_away(
442
+ control: Control, point: Vector2, moves: Dictionary[ScrollContainer, Vector2]
443
+ ) -> bool:
413
444
  var walk: Node = control.get_parent()
414
445
  while walk != null and not (walk is Viewport):
415
446
  var holder: Control = walk as Control
416
- if holder != null and holder.clip_contents and not _holds(holder, point):
447
+ if holder != null and holder.clip_contents and not _holds(holder, point - _moved_by(holder, moves)):
417
448
  return true
418
449
  walk = walk.get_parent()
419
450
  return false
package/build/index.js CHANGED
@@ -33013,8 +33013,9 @@ var TOOL_SPECS = [
33013
33013
  parameters: {
33014
33014
  projectPath: PROJECT_PATH,
33015
33015
  path: {
33016
- type: "string",
33017
- description: "A test directory or one suite file inside the project. Default test."
33016
+ type: ["string", "array"],
33017
+ items: { type: "string" },
33018
+ 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. A run where any entry is not there is refused before the engine starts, naming each one. Default test."
33018
33019
  },
33019
33020
  ignore: {
33020
33021
  type: "array",
@@ -34659,6 +34660,36 @@ function elsewhereIn(projectPath, asked) {
34659
34660
  const there = near.map((name) => `res://${name}`).join(" and ");
34660
34661
  return near.length === 0 ? "" : ` The project does have ${there}.`;
34661
34662
  }
34663
+ function testPathsIn(projectPath, given) {
34664
+ const entries = given === undefined || given === null || given === "" ? ["test"] : given;
34665
+ const listed = Array.isArray(entries) ? entries : [entries];
34666
+ if (listed.length === 0) {
34667
+ return {
34668
+ ok: false,
34669
+ reason: "path is an empty list: name a suite file or a directory, or leave path out for test."
34670
+ };
34671
+ }
34672
+ const resolved = [];
34673
+ for (const [index, entry] of listed.entries()) {
34674
+ if (typeof entry !== "string" || entry.trim() === "") {
34675
+ return {
34676
+ ok: false,
34677
+ reason: `path${Array.isArray(entries) ? `[${index}]` : ""} is ${typeof entry === "string" ? "empty" : describeValue(entry)}, not a suite file or a directory in the project.`
34678
+ };
34679
+ }
34680
+ const location = resolveWithinProject(projectPath, entry);
34681
+ if (!location.ok) {
34682
+ return { ok: false, reason: location.reason };
34683
+ }
34684
+ resolved.push(location.relativePath.replace(/\/+$/, ""));
34685
+ }
34686
+ const unique = [...new Set(resolved)];
34687
+ const within = (path, directory) => path.startsWith(`${directory}/`);
34688
+ return {
34689
+ ok: true,
34690
+ value: unique.filter((path) => !unique.some((other) => other !== path && within(path, other))).map((path) => `res://${path}`)
34691
+ };
34692
+ }
34662
34693
  function hasMainScene(projectFile) {
34663
34694
  const scene = parseProjectGodot(readFileSync19(projectFile, "utf8"))["application"]?.["run/main_scene"];
34664
34695
  return typeof scene === "string" && scene !== "";
@@ -36174,9 +36205,24 @@ class GodotServer {
36174
36205
  if (!project.ok) {
36175
36206
  return project.response;
36176
36207
  }
36177
- const contained = this.containProjectFiles({ ...args, path: readNonEmptyString(args, "path") ?? "test" });
36178
- if (!contained.ok) {
36179
- return contained.response;
36208
+ const paths = testPathsIn(project.value.path, args["path"]);
36209
+ if (!paths.ok) {
36210
+ return this.createErrorResponse(paths.reason, PATH_SOLUTIONS);
36211
+ }
36212
+ const asked = paths.value;
36213
+ const missing = asked.filter((path) => !existsSync16(join20(project.value.path, path.slice("res://".length))));
36214
+ if (missing.length > 0) {
36215
+ const verdict = `nothing at ${missing.join(" and ")}`;
36216
+ return {
36217
+ content: [
36218
+ {
36219
+ type: "text",
36220
+ text: `No tests ran: ${verdict}.${elsewhereIn(project.value.path, missing[0] ?? "")}`
36221
+ },
36222
+ { type: "text", text: answerJson({ passed: false, verdict, tests: 0 }) }
36223
+ ],
36224
+ isError: true
36225
+ };
36180
36226
  }
36181
36227
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
36182
36228
  if (!existsSync16(join20(project.value.path, runner))) {
@@ -36196,7 +36242,6 @@ class GodotServer {
36196
36242
  const ours = `gdharness-reports/${randomUUID2()}`;
36197
36243
  const reports = `res://.godot/${ours}`;
36198
36244
  const ignored = readStringArray(args, "ignore") ?? [];
36199
- const asked = readString(contained.value, "path") ?? "res://test";
36200
36245
  const cmdArgs = [
36201
36246
  "--headless",
36202
36247
  "--path",
@@ -36205,8 +36250,7 @@ class GodotServer {
36205
36250
  `res://${runner}`,
36206
36251
  "--ignoreHeadlessMode",
36207
36252
  ...readBoolean(args, "failFast") === true ? [] : ["-c"],
36208
- "-a",
36209
- asked,
36253
+ ...asked.flatMap((path) => ["-a", path]),
36210
36254
  ...ignored.flatMap((entry) => ["-i", entry]),
36211
36255
  "-rd",
36212
36256
  reports,
@@ -36279,7 +36323,7 @@ class GodotServer {
36279
36323
  limit: Number.POSITIVE_INFINITY
36280
36324
  }).entries;
36281
36325
  const said = printed.map((entry) => entry.text);
36282
- const nothingRan = timedOut ? null : whyNoReport(said, asked);
36326
+ const nothingRan = timedOut ? null : whyNoReport(said, asked.join(" and "));
36283
36327
  const cutShort = timedOut ? timedOutVerdict(timeoutMs, silentForMs) : null;
36284
36328
  const hung = cutShort?.hung ?? false;
36285
36329
  const scriptErrors = timedOut || exitCode !== 105 ? [] : scriptErrorsPrinted(said);
@@ -36293,7 +36337,7 @@ class GodotServer {
36293
36337
  ...echoed.omitted > 0 ? { printedOmitted: echoed.omitted } : {}
36294
36338
  };
36295
36339
  if (report === null) {
36296
- const note = nothingRan !== null ? `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}` : scriptErrors.length > 0 ? scriptErrorsNote(scriptErrors) : reportProblem !== null ? `The test run's report could not be read (${reportProblem}); the engine's own verdict was ${verdict}.` : `The test run wrote no report (${verdict}).`;
36340
+ const note = nothingRan !== null ? `No tests ran: ${verdict}.` : scriptErrors.length > 0 ? scriptErrorsNote(scriptErrors) : reportProblem !== null ? `The test run's report could not be read (${reportProblem}); the engine's own verdict was ${verdict}.` : `The test run wrote no report (${verdict}).`;
36297
36341
  return {
36298
36342
  content: [
36299
36343
  { type: "text", text: note },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.1.40",
3
+ "version": "1.1.42",
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",