gdharness 1.2.26 → 1.2.28
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 +625 -350
- package/build/godot/addons/gdharness_runtime/runtime_accessibility.gd +152 -6
- package/build/godot/addons/gdharness_runtime/runtime_queries.gd +22 -0
- package/build/index.js +494 -224
- package/build/keeper.js +1207 -753
- package/package.json +1 -1
|
@@ -41,12 +41,45 @@ try {
|
|
|
41
41
|
if ($element.TryGetCurrentPattern($pattern, [ref]$found)) { return $found }
|
|
42
42
|
return $null
|
|
43
43
|
}
|
|
44
|
+
# FullDescription is where the description is put, and the managed client refuses every property
|
|
45
|
+
# newer than itself, so it is asked through the core call the client makes for the rest.
|
|
46
|
+
$script:unread = $null
|
|
47
|
+
try {
|
|
48
|
+
$client = [System.Windows.Automation.AutomationElement]
|
|
49
|
+
$core = $client.Assembly.GetType('MS.Internal.Automation.UiaCoreApi')
|
|
50
|
+
$ask = $core.GetMethod('UiaGetPropertyValue', [System.Reflection.BindingFlags]'NonPublic,Public,Static')
|
|
51
|
+
$nodeField = $client.GetField('_hnode', [System.Reflection.BindingFlags]'NonPublic,Instance')
|
|
52
|
+
if ($null -eq $ask -or $null -eq $nodeField) {
|
|
53
|
+
$script:unread = 'this Windows has no UiaGetPropertyValue to ask it through'
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
$script:unread = "$_"
|
|
57
|
+
}
|
|
58
|
+
function FullDescription($element) {
|
|
59
|
+
if ($null -ne $script:unread) { return '' }
|
|
60
|
+
try {
|
|
61
|
+
$asked = @($nodeField.GetValue($element), [int]30159, $null)
|
|
62
|
+
$null = $ask.Invoke($null, $asked)
|
|
63
|
+
if ($asked[2] -is [string]) { return $asked[2] }
|
|
64
|
+
} catch {
|
|
65
|
+
$script:unread = "$_"
|
|
66
|
+
}
|
|
67
|
+
return ''
|
|
68
|
+
}
|
|
44
69
|
function Describe($element, $ox, $oy, $depth) {
|
|
45
70
|
$c = $element.Current
|
|
46
71
|
$role = $c.ControlType.ProgrammaticName -replace '^ControlType[.]', ''
|
|
47
72
|
$role = ($role -creplace '(?<=[a-z])(?=[A-Z])', '_').ToLowerInvariant()
|
|
48
73
|
$node = [ordered]@{ role = $role; name = $c.Name }
|
|
49
|
-
|
|
74
|
+
$description = FullDescription $element
|
|
75
|
+
if ($description) { $node.description = $description }
|
|
76
|
+
if ($c.HelpText) { $node.placeholder = $c.HelpText }
|
|
77
|
+
# The element's own id, the last four parts of its runtime id, which the game reads back as the
|
|
78
|
+
# node that made it.
|
|
79
|
+
$runtime = $element.GetRuntimeId()
|
|
80
|
+
if ($null -ne $runtime -and $runtime.Length -ge 4) {
|
|
81
|
+
$node.element_id = @($runtime[-4], $runtime[-3], $runtime[-2], $runtime[-1])
|
|
82
|
+
}
|
|
50
83
|
$r = $c.BoundingRectangle
|
|
51
84
|
if (-not $r.IsEmpty) {
|
|
52
85
|
$node.bounds = [ordered]@{ x = [int]($r.X - $ox); y = [int]($r.Y - $oy) }
|
|
@@ -100,15 +133,19 @@ try {
|
|
|
100
133
|
$windows = @()
|
|
101
134
|
foreach ($handle in $Handles) {
|
|
102
135
|
$root = [System.Windows.Automation.AutomationElement]::FromHandle([IntPtr][long]$handle)
|
|
103
|
-
$r = $root.Current.BoundingRectangle
|
|
104
136
|
$deadline = [DateTime]::UtcNow.AddMilliseconds($SettleMs)
|
|
105
137
|
$empty = [DateTime]::UtcNow.AddMilliseconds($EmptyMs)
|
|
138
|
+
# The window's corner is read on every walk: before the engine builds its tree the window is
|
|
139
|
+
# its frame, and after it the client area inside it, so a corner read once placed everything
|
|
140
|
+
# by the frame's.
|
|
141
|
+
$r = $root.Current.BoundingRectangle
|
|
106
142
|
$tree = Describe $root $r.X $r.Y 0
|
|
107
143
|
$last = Count $tree
|
|
108
144
|
# Walked again until two walks in a row agree, since the first question is what makes the
|
|
109
145
|
# engine build the tree, which arrives over the frames after it.
|
|
110
146
|
while ([DateTime]::UtcNow -lt $deadline) {
|
|
111
147
|
Start-Sleep -Milliseconds 150
|
|
148
|
+
$r = $root.Current.BoundingRectangle
|
|
112
149
|
$tree = Describe $root $r.X $r.Y 0
|
|
113
150
|
$count = Count $tree
|
|
114
151
|
if ($count -gt 1 -and $count -eq $last) { break }
|
|
@@ -117,7 +154,9 @@ try {
|
|
|
117
154
|
}
|
|
118
155
|
$windows += , $tree
|
|
119
156
|
}
|
|
120
|
-
|
|
157
|
+
$answer = @{ windows = $windows }
|
|
158
|
+
if ($null -ne $script:unread) { $answer.description_unread = $script:unread }
|
|
159
|
+
Answer $answer
|
|
121
160
|
} catch {
|
|
122
161
|
Answer @{ error = "$_" }
|
|
123
162
|
}
|
|
@@ -207,14 +246,41 @@ func get_accessibility(_params: Dictionary) -> Dictionary:
|
|
|
207
246
|
return _refused("UI Automation could not read the game's windows: %s" % str(answer["error"]))
|
|
208
247
|
|
|
209
248
|
var windows: Array = answer.get("windows", [])
|
|
249
|
+
var made_by: Dictionary = {}
|
|
250
|
+
_note_elements(_host.get_tree().root, made_by)
|
|
251
|
+
var placing: Placing = Placing.new()
|
|
210
252
|
var elements: int = 0
|
|
211
253
|
var unnamed: int = 0
|
|
212
254
|
for at: int in windows.size():
|
|
213
255
|
var window: Dictionary = windows[at]
|
|
214
|
-
|
|
256
|
+
var window_id: int = ids[at] if at < ids.size() else -1
|
|
257
|
+
window["window_id"] = window_id
|
|
258
|
+
var holder: Window = _window_with(_host.get_tree().root, window_id)
|
|
259
|
+
var holder_path: String = ""
|
|
260
|
+
if holder != null:
|
|
261
|
+
holder_path = str(holder.get_path())
|
|
262
|
+
window["node"] = holder_path
|
|
263
|
+
_place(window, holder_path, made_by, placing, true)
|
|
215
264
|
elements += _count(window)
|
|
216
265
|
unnamed += _unnamed_text(window)
|
|
217
266
|
var result: Dictionary = {"windows": windows, "elements": elements}
|
|
267
|
+
if placing.tooltips > 0:
|
|
268
|
+
result["tooltip_note"] = (
|
|
269
|
+
"tooltip is the node's tooltip_text, which the engine puts in the tree, and UI Automation, as"
|
|
270
|
+
+ " AccessKit fills it in, has no property that carries it (measured on 4.7.2), so a screen"
|
|
271
|
+
+ " reader on Windows is not told it. Say what a reader needs in accessibility_description."
|
|
272
|
+
)
|
|
273
|
+
if placing.asked > 0 and placing.placed == 0:
|
|
274
|
+
# Nothing at all matched: the element ids are not the engine's element RIDs any more, which
|
|
275
|
+
# an AccessKit that numbers its elements another way would do, and every node would read null.
|
|
276
|
+
result["node_note"] = (
|
|
277
|
+
"No element could be matched to the node that made it: UI Automation's runtime ids did not"
|
|
278
|
+
+ " carry the engine's element ids, so node and part_of are the windows' alone."
|
|
279
|
+
)
|
|
280
|
+
if answer.has("description_unread"):
|
|
281
|
+
result["description_note"] = (
|
|
282
|
+
"Descriptions could not be read, so none is shown: %s" % str(answer["description_unread"])
|
|
283
|
+
)
|
|
218
284
|
if elements <= windows.size():
|
|
219
285
|
var setting: String = _setting()
|
|
220
286
|
# The command line's --accessibility is consumed by the engine and not handed on, so only
|
|
@@ -272,8 +338,88 @@ static func _count(element: Dictionary) -> int:
|
|
|
272
338
|
return total
|
|
273
339
|
|
|
274
340
|
|
|
275
|
-
##
|
|
276
|
-
##
|
|
341
|
+
## Every node's element by its id, for the walk's elements to be read back as the node that made
|
|
342
|
+
## them. Internal children too: a dialog's buttons are elements like any other.
|
|
343
|
+
static func _note_elements(node: Node, made_by: Dictionary) -> void:
|
|
344
|
+
var element: RID = node.get_accessibility_element()
|
|
345
|
+
if element.is_valid():
|
|
346
|
+
made_by[element.get_id()] = node
|
|
347
|
+
for child: Node in node.get_children(true):
|
|
348
|
+
_note_elements(child, made_by)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
static func _window_with(node: Node, window_id: int) -> Window:
|
|
352
|
+
if node is Window:
|
|
353
|
+
var window: Window = node
|
|
354
|
+
if window.get_window_id() == window_id:
|
|
355
|
+
return window
|
|
356
|
+
for child: Node in node.get_children(true):
|
|
357
|
+
var found: Window = _window_with(child, window_id)
|
|
358
|
+
if found != null:
|
|
359
|
+
return found
|
|
360
|
+
return null
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
## Gives each child of [param element] the node that made it, or, for an element a node made as a
|
|
364
|
+
## part of itself (a line of a text box, a window's announcements), the node it is a part of.
|
|
365
|
+
##
|
|
366
|
+
## UI Automation's runtime id for an element ends in the four halves of AccessKit's id for it, and
|
|
367
|
+
## Godot gives AccessKit the element's RID: measured on 4.7.2, /root/Main's element answered
|
|
368
|
+
## 114,1,0,0 with RID 114 << 32 | 1. Which pair holds it is not assumed, so both are tried.
|
|
369
|
+
static func _place(
|
|
370
|
+
element: Dictionary, holder: String, made_by: Dictionary, placing: Placing, is_window: bool
|
|
371
|
+
) -> void:
|
|
372
|
+
var children: Array = element.get("children", [])
|
|
373
|
+
for child: Variant in children:
|
|
374
|
+
if not child is Dictionary:
|
|
375
|
+
continue
|
|
376
|
+
var inner: Dictionary = child
|
|
377
|
+
var parts: Array = inner.get("element_id", [])
|
|
378
|
+
var _dropped: bool = inner.erase("element_id")
|
|
379
|
+
var node: Node = null
|
|
380
|
+
if parts.size() == 4:
|
|
381
|
+
placing.asked += 1
|
|
382
|
+
node = _made(parts, made_by)
|
|
383
|
+
var path: String = holder
|
|
384
|
+
if node != null:
|
|
385
|
+
placing.placed += 1
|
|
386
|
+
path = str(node.get_path())
|
|
387
|
+
inner["node"] = path
|
|
388
|
+
if node is Control:
|
|
389
|
+
var control: Control = node
|
|
390
|
+
if not control.tooltip_text.is_empty():
|
|
391
|
+
inner["tooltip"] = control.tooltip_text
|
|
392
|
+
placing.tooltips += 1
|
|
393
|
+
else:
|
|
394
|
+
inner["part_of"] = holder
|
|
395
|
+
# The one static text a window makes of itself: where it speaks what is announced, named
|
|
396
|
+
# only while something is.
|
|
397
|
+
if is_window and inner.get("role") == "text":
|
|
398
|
+
inner["part"] = "announcement"
|
|
399
|
+
_place(inner, path, made_by, placing, false)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
static func _made(parts: Array, made_by: Dictionary) -> Node:
|
|
403
|
+
var halves: Array[int] = []
|
|
404
|
+
for part: Variant in parts:
|
|
405
|
+
halves.append(Read.as_int(part, 0) & 0xFFFFFFFF)
|
|
406
|
+
for at: int in [0, 2]:
|
|
407
|
+
var id: int = (halves[at] << 32) | halves[at + 1]
|
|
408
|
+
if made_by.has(id):
|
|
409
|
+
return made_by[id]
|
|
410
|
+
return null
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
## Counts kept while the elements are placed.
|
|
414
|
+
class Placing:
|
|
415
|
+
extends RefCounted
|
|
416
|
+
var asked: int = 0
|
|
417
|
+
var placed: int = 0
|
|
418
|
+
var tooltips: int = 0
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
## Text elements with bounds and no name. The window's announcement element has no bounds and is
|
|
422
|
+
## left out with them: it is empty until something is announced.
|
|
277
423
|
static func _unnamed_text(element: Dictionary) -> int:
|
|
278
424
|
var total: int = 0
|
|
279
425
|
if element.get("role") == "text" and str(element.get("name", "")).is_empty() and element.has("bounds"):
|
|
@@ -35,7 +35,10 @@ var _host: Node
|
|
|
35
35
|
var _values: Values
|
|
36
36
|
## How long each of the last frames took, oldest first, in microseconds, as the game timed them.
|
|
37
37
|
var _frame_usec: PackedInt64Array = PackedInt64Array()
|
|
38
|
+
## How many physics steps ran in each of those frames, in the same order.
|
|
39
|
+
var _frame_physics: PackedInt64Array = PackedInt64Array()
|
|
38
40
|
var _last_frame_usec: int = 0
|
|
41
|
+
var _last_physics_frames: int = 0
|
|
39
42
|
|
|
40
43
|
|
|
41
44
|
## The host is the autoload, which is how the tree is reached: it is not in one when the
|
|
@@ -662,12 +665,21 @@ func get_property(params: Dictionary) -> Dictionary:
|
|
|
662
665
|
## read 45ms for a second at a time, and a change to the game showed only on the next second's
|
|
663
666
|
## worst frame. The time between two of these calls is the frame as it passed, physics and the wait
|
|
664
667
|
## for the display included.
|
|
668
|
+
##
|
|
669
|
+
## The physics steps are counted beside it because the engine steps physics from the wall clock and
|
|
670
|
+
## nothing else reports how many it took: a game drawing sixty frames a second whose brew advanced
|
|
671
|
+
## by physics steps moved a tenth as far as the frames promised, and every reading available said
|
|
672
|
+
## the game was running well.
|
|
665
673
|
func note_frame(now_usec: int) -> void:
|
|
674
|
+
var physics: int = Engine.get_physics_frames()
|
|
666
675
|
if _last_frame_usec > 0:
|
|
667
676
|
var _grew: bool = _frame_usec.push_back(now_usec - _last_frame_usec)
|
|
677
|
+
var _stepped: bool = _frame_physics.push_back(physics - _last_physics_frames)
|
|
668
678
|
if _frame_usec.size() > FRAMES_TIMED:
|
|
669
679
|
_frame_usec.remove_at(0)
|
|
680
|
+
_frame_physics.remove_at(0)
|
|
670
681
|
_last_frame_usec = now_usec
|
|
682
|
+
_last_physics_frames = physics
|
|
671
683
|
|
|
672
684
|
|
|
673
685
|
## Whether the frame waits for the display, which sets its floor: a game with vsync on cannot
|
|
@@ -695,16 +707,26 @@ func get_metrics(params: Dictionary) -> Dictionary:
|
|
|
695
707
|
total_usec += one
|
|
696
708
|
slowest_usec = maxi(slowest_usec, one)
|
|
697
709
|
# Null rather than zero before a frame has been timed: no frame took no time.
|
|
710
|
+
var steps: int = 0
|
|
711
|
+
for one: int in _frame_physics:
|
|
712
|
+
steps += one
|
|
698
713
|
var frame_time: Variant = null
|
|
699
714
|
var frame_time_max: Variant = null
|
|
715
|
+
var physics_steps_per_second: Variant = null
|
|
700
716
|
if timed > 0:
|
|
701
717
|
frame_time = float(total_usec) / timed / 1000000.0
|
|
702
718
|
frame_time_max = slowest_usec / 1000000.0
|
|
719
|
+
if total_usec > 0:
|
|
720
|
+
physics_steps_per_second = steps * 1000000.0 / total_usec
|
|
703
721
|
var all: Dictionary = {
|
|
704
722
|
"fps": Engine.get_frames_per_second(),
|
|
705
723
|
"frame_time": frame_time,
|
|
706
724
|
"frame_time_max": frame_time_max,
|
|
707
725
|
"frames_timed": timed,
|
|
726
|
+
"physics_steps_per_second": physics_steps_per_second,
|
|
727
|
+
"physics_ticks_per_second": Engine.physics_ticks_per_second,
|
|
728
|
+
"max_physics_steps_per_frame": Engine.max_physics_steps_per_frame,
|
|
729
|
+
"time_scale": Engine.time_scale,
|
|
708
730
|
"process_time_max": Performance.get_monitor(Performance.TIME_PROCESS),
|
|
709
731
|
"physics_time": Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS),
|
|
710
732
|
"vsync": _vsync(),
|