gdharness 1.3.10 → 1.3.12

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.
@@ -15,6 +15,16 @@ extends SceneTree
15
15
 
16
16
  const LISTED: String = "--suites-in"
17
17
  const SKIPPING: String = "--skipping-in"
18
+ ## The file of the parameterised cases dealt by their rows, one a line and tab-separated:
19
+ ## `res://suite.gd:test_case`, then `only` and the rows this engine runs, or `besides` and the rows
20
+ ## it leaves to the others, for the engine that takes every row dealt nowhere else. Apart from
21
+ ## [constant SKIPPING], since gdUnit4 reads `suite:case:row` there as the case and leaves out every
22
+ ## row of it.
23
+ const ROWS: String = "--rows-in"
24
+ ## Printed before each row an engine leaves out, as `res://suite.gd > test_case:row`, -1 for a case
25
+ ## that is not parameterised. gdUnit4 prints nothing for a case it skips, and the caller needs every
26
+ ## row an engine found to tell one that no engine ran.
27
+ const LEFT_OUT: String = "gdharness left out"
18
28
  ## The flags turning off a report, with the gdUnit4 hook that writes it. A gate reads what a run
19
29
  ## prints, and each report finds every case it hears of in the session to write them all out as the
20
30
  ## run ends; project_test reads the JUnit one and turns off only the HTML one.
@@ -54,16 +64,33 @@ class GdhRunner:
54
64
  extends GdUnitTestCIRunner
55
65
 
56
66
  var own_path: String = ""
67
+ ## The cases dealt by their rows, by `res://suite.gd:test_case`.
68
+ var row_shares: Dictionary[String, GdhRowShare] = {}
69
+ ## The cases gdUnit4 said it finished, which [GdhSessionCheck] holds the session's lookups against.
70
+ var cases_ended: int = 0
57
71
 
58
72
  func get_cmdline_args() -> PackedStringArray:
59
73
  return options_of(OS.get_cmdline_args(), own_path)
60
74
 
75
+ ## gdUnit4 asks this of every case it finds, a parameterised case once a row, each row carrying
76
+ ## its index.
77
+ func is_skipped(test: GdUnitTestCase) -> bool:
78
+ if super(test):
79
+ return true
80
+ var share: GdhRowShare = row_shares.get("%s:%s" % [test.suite_resource_path, test.test_name], null)
81
+ if share == null or not share.leaves_out(test.attribute_index):
82
+ return false
83
+ print("%s %s > %s:%d" % [LEFT_OUT, test.suite_resource_path, test.test_name, test.attribute_index])
84
+ return true
85
+
61
86
  ## The session gdUnit4 has just made, swapped for a [GdhIndexedSession] of the same cases as it
62
87
  ## is announced: everything that reads the session, the hooks, the cases and the console printing
63
88
  ## every case, is handed it after this.
64
89
  func _on_gdunit_event(event: GdUnitEvent) -> void:
65
90
  if event.type() == GdUnitEvent.SESSION_START:
66
91
  _test_session = GdhIndexedSession.of(_test_session)
92
+ elif event.type() == GdUnitEvent.TESTCASE_AFTER:
93
+ cases_ended += 1
67
94
  super(event)
68
95
 
69
96
  ## [param args] with everything up to the script at [param own] given as gdUnit4's tool name. The
@@ -87,6 +114,19 @@ class GdhRunner:
87
114
  return forward.to_lower() if OS.get_name() == "Windows" else forward
88
115
 
89
116
 
117
+ ## The rows of one parameterised case an engine runs: those named, or every row but those.
118
+ class GdhRowShare:
119
+ extends RefCounted
120
+
121
+ var only: bool = true
122
+ var rows: PackedInt32Array = []
123
+
124
+ ## Whether the engine leaves out [param row], which is -1 for a case that is not parameterised
125
+ ## and so runs only in the engine taking the rows besides.
126
+ func leaves_out(row: int) -> bool:
127
+ return rows.has(row) != only
128
+
129
+
90
130
  ## gdUnit4's session finding a case by its id in a dictionary, where its own walks every case of the
91
131
  ## session, `GdUnitTestSession.find_test_by_id`: the console asks it twice a case and each report
92
132
  ## twice more, so a run's time in it grew with the square of its cases (17.9 s of processor time
@@ -94,6 +134,8 @@ class GdhRunner:
94
134
  class GdhIndexedSession:
95
135
  extends GdUnitTestSession
96
136
 
137
+ ## The cases found by id, which [GdhSessionCheck] reads to tell that the console used this session.
138
+ var lookups: int = 0
97
139
  var _by_id: Dictionary[String, GdUnitTestCase] = {}
98
140
 
99
141
  func _init(cases: Array[GdUnitTestCase], reports_in: String) -> void:
@@ -102,6 +144,7 @@ class GdhIndexedSession:
102
144
  _by_id[str(test.guid)] = test
103
145
 
104
146
  func find_test_by_id(id: GdUnitGUID) -> GdUnitTestCase:
147
+ lookups += 1
105
148
  var found: GdUnitTestCase = _by_id.get(str(id), null)
106
149
  return found
107
150
 
@@ -113,6 +156,50 @@ class GdhIndexedSession:
113
156
  return GdhIndexedSession.new(made._test_cases, made.report_path)
114
157
 
115
158
 
159
+ ## A session hook of the runner's own, failing the run when the [GdhIndexedSession] is not the
160
+ ## session gdUnit4 works from. The swap holds only while gdUnit4 announces a session before it hands
161
+ ## it to the hooks and the console, and a gdUnit4 that changed that order would otherwise run every
162
+ ## suite as before, only slower, with nothing said. Checked in the gdUnit4 the game installed, on
163
+ ## every run, rather than in the one gdharness tests against.
164
+ class GdhSessionCheck:
165
+ extends GdUnitTestSessionHook
166
+
167
+ var _runner: GdhRunner = null
168
+
169
+ func _init(runner: GdhRunner) -> void:
170
+ super("gdharness session check", "Fails the run when gdUnit4 works from another session.")
171
+ _runner = runner
172
+
173
+ func startup(session: GdUnitTestSession) -> GdUnitResult:
174
+ if session is GdhIndexedSession:
175
+ return GdUnitResult.success()
176
+ return GdUnitResult.error(
177
+ (
178
+ (
179
+ "gdharness test: gdUnit4 %s handed its session hooks the session before announcing it, so"
180
+ + " the runner's indexed session is not the one they read. This gdUnit4 starts a session"
181
+ + " differently from the ones gdharness knows; report it to gdharness with the version."
182
+ )
183
+ % GdUnit4Version.current()
184
+ )
185
+ )
186
+
187
+ func shutdown(session: GdUnitTestSession) -> GdUnitResult:
188
+ var indexed: GdhIndexedSession = session as GdhIndexedSession
189
+ if indexed == null or _runner.cases_ended == 0 or indexed.lookups > 0:
190
+ return GdUnitResult.success()
191
+ return GdUnitResult.error(
192
+ (
193
+ (
194
+ "gdharness test: gdUnit4 %s ran %d cases without looking one up in the runner's indexed"
195
+ + " session, so its console reads another session. This gdUnit4 starts a session"
196
+ + " differently from the ones gdharness knows; report it to gdharness with the version."
197
+ )
198
+ % [GdUnit4Version.current(), _runner.cases_ended]
199
+ )
200
+ )
201
+
202
+
116
203
  func _initialize() -> void:
117
204
  OS.low_processor_usage_mode_sleep_usec = FRAME_SLEEP_USEC
118
205
  var asked: PackedStringArray = OS.get_cmdline_user_args()
@@ -120,8 +207,19 @@ func _initialize() -> void:
120
207
  for flag: String in REPORTS_OFF:
121
208
  if asked.has(flag):
122
209
  var _unread: bool = unread.append(REPORTS_OFF[flag])
123
- if not unread.is_empty():
124
- var _off: PackedStringArray = unread_off(GdUnitTestSessionHookService.instance(), unread)
210
+ var hooks: GdUnitTestSessionHookService = GdUnitTestSessionHookService.instance()
211
+ var off: PackedStringArray = unread_off(hooks, unread)
212
+ for path: String in unread:
213
+ if not off.has(path):
214
+ push_error(
215
+ (
216
+ (
217
+ "gdharness test: gdUnit4 %s registers no session hook %s, so the report it writes is not"
218
+ + " turned off; report it to gdharness with the version."
219
+ )
220
+ % [GdUnit4Version.current(), path]
221
+ )
222
+ )
125
223
  var suites: PackedStringArray = listed(asked, LISTED)
126
224
  if asked.has(TOGETHER):
127
225
  _together = compiled_together(suites)
@@ -132,6 +230,10 @@ func _initialize() -> void:
132
230
  _runner.own_path = get_script().resource_path
133
231
  _runner.add_test_suites(suites)
134
232
  _runner.skip_test_suites(listed(asked, SKIPPING))
233
+ _runner.row_shares = row_shares_of(listed(asked, ROWS))
234
+ # Added to the list itself rather than through `register`, which saves every hook it holds into
235
+ # `project.godot`.
236
+ hooks.enigne_hooks.append(GdhSessionCheck.new(_runner))
135
237
  root.add_child(_runner)
136
238
 
137
239
 
@@ -213,6 +315,22 @@ static func listed(args: PackedStringArray, flag: String) -> PackedStringArray:
213
315
  return named
214
316
 
215
317
 
318
+ ## The row shares [param lines] of a [constant ROWS] file give, by case.
319
+ static func row_shares_of(lines: PackedStringArray) -> Dictionary[String, GdhRowShare]:
320
+ var shares: Dictionary[String, GdhRowShare] = {}
321
+ for line: String in lines:
322
+ var parts: PackedStringArray = line.split("\t")
323
+ if parts.size() < 2:
324
+ continue
325
+ var share: GdhRowShare = GdhRowShare.new()
326
+ share.only = parts[1] == "only"
327
+ for row: String in parts.slice(2):
328
+ if row.is_valid_int():
329
+ var _added: bool = share.rows.append(row.to_int())
330
+ shares[parts[0]] = share
331
+ return shares
332
+
333
+
216
334
  ## The argument after [param flag] in [param args], or "" when there is none.
217
335
  static func argument_after(args: PackedStringArray, flag: String) -> String:
218
336
  var at: int = args.find(flag)
package/build/keeper.js CHANGED
@@ -119,6 +119,9 @@ public static class GdharnessDesktop {
119
119
  [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
120
120
  [DllImport("kernel32.dll")] static extern bool TerminateProcess(IntPtr handle, uint code);
121
121
  [DllImport("kernel32.dll")] static extern uint SetErrorMode(uint mode);
122
+ [DllImport("kernel32.dll", SetLastError = true)] static extern bool SetProcessAffinityMask(IntPtr process, UIntPtr mask);
123
+ [DllImport("kernel32.dll")] static extern uint ResumeThread(IntPtr thread);
124
+ const uint CreateSuspended = 0x4;
122
125
  const uint FailCriticalErrors = 0x1;
123
126
  const uint NoOpenFileErrorBox = 0x8000;
124
127
 
@@ -180,7 +183,8 @@ public static class GdharnessDesktop {
180
183
  public static int RunSpec(string specFile) {
181
184
  string[] lines = File.ReadAllLines(specFile);
182
185
  Func<int, string> field = delegate(int index) { return Encoding.UTF8.GetString(Convert.FromBase64String(lines[index])); };
183
- return Run(field(0), field(1), field(2), field(3) == "1", field(4), field(5), field(6));
186
+ string affinity = lines.Length > 7 ? field(7) : "";
187
+ return Run(field(0), field(1), field(2), field(3) == "1", field(4), field(5), field(6), affinity.Length == 0 ? 0UL : ulong.Parse(affinity));
184
188
  }
185
189
 
186
190
  static void Step(string stepFile, string step) {
@@ -189,7 +193,9 @@ public static class GdharnessDesktop {
189
193
  }
190
194
  }
191
195
 
192
- public static int Run(string desktop, string commandLine, string pidFile, bool noActivate, string stopFile, string directory, string stepFile) {
196
+ // With an affinity, the process starts suspended and is held to those processors before its
197
+ // first thread runs, so every thread it starts afterwards is held to them as well.
198
+ public static int Run(string desktop, string commandLine, string pidFile, bool noActivate, string stopFile, string directory, string stepFile, ulong affinity) {
193
199
  StartupInfo startup = new StartupInfo();
194
200
  startup.cb = Marshal.SizeOf(typeof(StartupInfo));
195
201
  if (desktop.Length > 0) {
@@ -219,9 +225,19 @@ public static class GdharnessDesktop {
219
225
  }
220
226
  try {
221
227
  Step(stepFile, "starting the process");
222
- if (!CreateProcessW(null, new StringBuilder(commandLine), IntPtr.Zero, IntPtr.Zero, true, DetachedProcess, IntPtr.Zero, directory, ref startup, out started)) {
228
+ uint creation = DetachedProcess | (affinity == 0 ? 0u : CreateSuspended);
229
+ if (!CreateProcessW(null, new StringBuilder(commandLine), IntPtr.Zero, IntPtr.Zero, true, creation, IntPtr.Zero, directory, ref startup, out started)) {
223
230
  throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateProcessW " + commandLine);
224
231
  }
232
+ if (affinity != 0) {
233
+ Step(stepFile, "holding the process to its processors");
234
+ if (!SetProcessAffinityMask(started.process, new UIntPtr(affinity))) {
235
+ int error = Marshal.GetLastWin32Error();
236
+ TerminateProcess(started.process, 1);
237
+ throw new Win32Exception(error, "SetProcessAffinityMask " + affinity);
238
+ }
239
+ ResumeThread(started.thread);
240
+ }
225
241
  File.WriteAllText(pidFile, started.processId.ToString());
226
242
  if (noActivate) {
227
243
  HoldTheForeground(started);
@@ -381,7 +397,8 @@ function throughHelper(how, command, args, files, assembly = helperAssembly()) {
381
397
  how.noActivate === true ? "1" : "0",
382
398
  files.stop,
383
399
  how.directory,
384
- files.step
400
+ files.step,
401
+ how.affinity === undefined ? "" : how.affinity.toString()
385
402
  ];
386
403
  writeFileSync(files.spec, fields.map((field) => Buffer.from(field, "utf8").toString("base64")).join(`
387
404
  `), "utf8");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.3.10",
3
+ "version": "1.3.12",
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",