Repository navigation
Expand file tree
/
Copy pathdiagnose.lua
More file actions
2561 lines (2391 loc) · 105 KB
/
Copy pathdiagnose.lua
File metadata and controls
2561 lines (2391 loc) · 105 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- PlatinumSnd diagnostic harness.
--
-- NOT part of the Spoon's runtime. Nothing in this file is loaded until
-- `spoon.PlatinumSnd:diagnose()` is called, nothing it does survives the run,
-- and no other file changes behaviour because it exists.
--
-- WHAT IT IS FOR. Every line of this Spoon that touches Hammerspoon was
-- written on a machine with no macOS. `docs/mac-verification.md` lists 78
-- manual checks; most of them conflate a DECISION with a PERCEPTION --
-- "clicking a checkbox sounds like a checkbox" is really "the probe returned
-- AXCheckBox and the map chose chkp" (machine-checkable) plus "chkp is the
-- right noise" (ears only). This harness automates every first half it can
-- reach and leaves only the genuinely perceptual questions to a human.
--
-- WHY IT IS ASYNCHRONOUS. The interesting properties -- the hover loop's
-- frame elision, a sustained sound still looping, a window filter firing --
-- only exist while the run loop turns. Anything that blocked with
-- hs.timer.usleep would measure a Spoon that was never allowed to run, and a
-- long block is also exactly what makes macOS disable an event tap. So every
-- step is a queued continuation, each one given a watchdog: a step that hangs
-- is reported and the harness moves on rather than taking the run down.
--
-- SAFETY. Every probe is wrapped, because a broken assumption must produce a
-- [FAIL] line rather than a traceback -- a crashed harness tells us nothing.
-- Everything it touches is put back: cursor position, dry-run mode, the
-- engine's play/sustain/release methods, and the Spoon's running state.
local soundmap = dofile(hs.spoons.resourcePath("soundmap.lua"))
local resolver = dofile(hs.spoons.resourcePath("resolver.lua"))
local rolemap = dofile(hs.spoons.resourcePath("rolemap.lua"))
local axpolicy = dofile(hs.spoons.resourcePath("axpolicy.lua"))
-- Every dial the harness turns, in one place. None of these is a Spoon
-- setting: the Spoon's own constants live in obj.tuning and are read from
-- there wherever a check depends on one.
local C = {
reportBaseName = "platinumsnd-diagnostics.txt",
reportDir = "/Desktop", -- relative to $HOME
reportFallbackDir = "", -- $HOME itself, if Desktop is denied
-- A step that has not finished by this is reported and abandoned. Well
-- clear of the longest automatic step (the tree walk, ~6 s) so that only a
-- genuinely stuck call trips it.
stepTimeoutSeconds = 45,
guidedTimeoutSeconds = 90,
-- How long a guided step listens after printing its instruction.
guidedWindowSeconds = 12,
-- Let the run loop turn between two things the harness did itself.
settleSeconds = 0.3,
-- Hover ticks to wait after moving the cursor, in units of
-- tuning.hoverIntervalSeconds. Three ticks is enough for the loop to see
-- the move, probe, and cache the answer.
hoverTicks = 4,
hoverFloorSeconds = 0.2,
-- Accessibility tree walk. Bounded three ways -- nodes, depth and wall
-- clock -- because an app can publish a tree with tens of thousands of
-- elements and this must never become the reason Hammerspoon stalls.
walkMaxNodes = 2500,
walkMaxDepth = 14,
-- A slice is deliberately small. Each node costs at least one accessibility
-- round trip into another process, and a slice that overruns its own
-- interval turns the walk into a run-loop hog -- which is exactly the
-- condition that gets an event tap disabled by the OS.
walkNodesPerSlice = 25,
walkSliceSeconds = 0.02,
walkBudgetSeconds = 6,
-- Ignore elements too small to aim the cursor at with any confidence.
minFrameSide = 6,
overlapPlays = 5,
overlapSpacingSeconds = 0.06,
overlapSampleSeconds = 0.02,
overlapTailSeconds = 0.4,
-- A sustained file is judged still playing well past twice its length.
sustainWaitMultiple = 2,
sustainWaitFloor = 2,
sustainWaitCeiling = 8,
sustainStopSeconds = 0.4,
idleSeconds = 3,
jiggleSeconds = 3,
sweepSeconds = 3,
moveIntervalSeconds = 0.05,
jiggleRadiusPx = 2,
-- A jiggle target must be comfortably bigger than the jiggle itself.
jiggleMinSidePx = 24,
sweepMarginPx = 60,
-- The sweep runs along the menu bar: it is the one horizontal span on any
-- Mac guaranteed to be tiled with many small, distinctly-framed elements,
-- which is what a sweep needs in order to defeat the elision on purpose.
sweepMenuBarOffsetPx = 10,
-- Below this the sweep found nothing to cross and the ratio means nothing.
sweepMinProbes = 5,
-- The sweep should out-probe the jiggle by at least this much.
elisionRatioFloor = 2,
consoleWaitSeconds = 1,
launchWaitSeconds = 4,
finderWaitSeconds = 1.8,
-- How often F8 looks at the tape while its scratch file is on disk. The
-- file comes off as soon as the sound it was created to provoke arrives,
-- so this is what decides how long it exists in the ordinary case: the
-- coalesce window plus one poll, rather than the full wait above.
finderPollSeconds = 0.1,
-- The scratch file guided step F8 creates. ONE FIXED NAME, deliberately:
-- see the scratch section below for why a clock-stamped name could not be
-- swept up again without globbing a directory of the user's.
scratchName = "platinumsnd-diagnostic-scratch.tmp",
scratchMarker = "PlatinumSnd diagnostics scratch file; safe to "
.. "delete",
-- Used only if it is installed and NOT already running, launched WITHOUT
-- activation, and quit again by the exact pid that appeared. Calculator
-- alone, deliberately: it owns no documents, so quitting it can neither
-- prompt nor lose anything. An editor here could sit on unsaved work.
launchBundle = "com.apple.calculator",
-- Hard ceiling on the whole run. If the step chain is ever broken badly
-- enough that it stops advancing, this is what still gives the cursor,
-- the dry-run flag, the engine's methods and the running state back.
deadmanSeconds = 300,
deadmanGuidedExtra = 200,
}
-- The roles Phase C hunts for, in report order. `expect` is the role the
-- probe should hand back once axprobe's refinement has run, which is not
-- always the role the walk matched on: a tab is an AXRadioButton until its
-- parent is read, and a close box is an AXButton until its subrole is.
local WANTED = {
{key = "AXButton", expect = "AXButton"},
{key = "AXCheckBox", expect = "AXCheckBox"},
{key = "AXRadioButton", expect = "AXRadioButton"},
{key = "AXPopUpButton", expect = "AXPopUpButton"},
{key = "AXSlider", expect = "AXSlider"},
{key = "AXScrollBar", expect = "AXScrollBar"},
{key = "AXValueIndicator", expect = "AXValueIndicator"},
{key = "AXIncrementor", expect = "AXIncrementor"},
{key = "AXDisclosureTriangle", expect = "AXDisclosureTriangle"},
{key = "AXStaticText", expect = "AXStaticText"},
{key = "AXTabGroup", expect = "AXTabGroup"},
{key = "AXTab", expect = "AXTab"},
{key = "AXCloseButton", expect = "AXCloseButton"},
}
-- Subroles that make a button something other than a plain button. A window's
-- traffic lights are the first AXButtons in most trees, and the red one
-- refines to AXCloseButton -- so taking the first match would test the close
-- box twice and never test a plain button at all.
local TRAFFIC_LIGHTS = {
AXCloseButton = true, AXMinimizeButton = true, AXZoomButton = true,
AXFullScreenButton = true,
}
-- What each source must have let go of by the time stop() returns. Keyed by
-- the registration order init.lua fixes, which Phase G verifies separately
-- rather than assuming.
local TEARDOWN = {
-- `ctx` on both: the shared context table src_windows publishes
-- windowDragging into and src_pointer reads. A source still holding one
-- after stop() is holding a live channel to a source that has stopped.
{index = 1, name = "src_pointer",
nils = {"tap", "timer", "probe", "cache", "ctx"},
falses = {"thumbDragging", "sliderDragging"}},
{index = 2, name = "src_windows",
nils = {"tap", "dragTimer", "filter", "volumeWatcher", "appWatcher",
"paletteIds", "ctx"},
falses = {"dragging"}},
{index = 3, name = "src_menus", nils = {"appWatcher", "sweeper"}},
{index = 4, name = "src_finder",
nils = {"trashWatcher", "appWatcher", "settleTimer", "observer",
"gate"}},
{index = 5, name = "src_keys", nils = {"tap"}},
}
-- Which live field identifies each source while it is running. Phase G uses
-- this to confirm the documented registration order before tearing down.
local SOURCE_MARKERS = {
{index = 1, name = "src_pointer", field = "probe"},
{index = 2, name = "src_windows", field = "dragTimer"},
{index = 3, name = "src_menus", field = "observers"},
{index = 4, name = "src_finder", field = "gate"},
{index = 5, name = "src_keys", field = "quietUntil"},
}
-- ---------------------------------------------------------------- helpers
local function clock()
local ok, t = pcall(hs.timer.secondsSinceEpoch)
if ok and type(t) == "number" then return t end
return os.time()
end
-- Every accessibility read in this file goes through one of these two, so a
-- dead element or an unimplemented attribute is a nil rather than a
-- traceback. pcall on the method directly rather than through a closure:
-- one fewer allocation per read, and there are thousands of reads in a walk.
local function att(element, name)
if not element then return nil end
local ok, value = pcall(element.attributeValue, element, name)
if ok then return value end
return nil
end
-- A METHOD call: object:method(...), guarded.
local function call(object, method, ...)
if not object or type(object[method]) ~= "function" then return nil end
local ok, value = pcall(object[method], object, ...)
if ok then return value end
return nil
end
-- A plain FUNCTION call, guarded. Deliberately separate from `call` above:
-- Hammerspoon's module-level functions are not methods and reject an extra
-- leading argument outright, so hs.window.focusedWindow() called as if it
-- were hs.window:focusedWindow() fails on argument checking -- silently,
-- through the pcall, which would turn a working API into an empty result.
local function fcall(fn, ...)
if type(fn) ~= "function" then return nil end
local ok, value = pcall(fn, ...)
if ok then return value end
return nil
end
-- A frame is usable when axpolicy could do anything with it: a table whose
-- x/y/w/h read as numbers. Deliberately the same test axpolicy.isInsideFrame
-- applies, by direct indexing rather than by enumeration -- see B5, where a
-- shape that enumerates differently but indexes correctly is still a pass.
local function usableFrame(frame)
if type(frame) ~= "table" then return false end
for _, key in ipairs({"x", "y", "w", "h"}) do
local ok, value = pcall(function() return frame[key] end)
if not ok or type(value) ~= "number" then return false end
end
return true
end
-- Whether this element is a REPRESENTATIVE example of its role rather than
-- one of the two cases the probe refines into something else. A non-plain
-- match is still kept, provisionally, so that a window whose only buttons are
-- traffic lights reports something rather than nothing.
local function isPlainExample(element, role)
if role == "AXButton" then
return TRAFFIC_LIGHTS[att(element, "AXSubrole")] ~= true
end
if role == "AXRadioButton" then
return att(att(element, "AXParent"), "AXRole") ~= "AXTabGroup"
end
return true
end
-- The enumerable shape of a value, as key=type pairs. B5 needs to report
-- what AXFrame actually came back as, and hs.inspect on a deep accessibility
-- table is unreadable.
local function shapeText(value, depth)
depth = depth or 0
if type(value) ~= "table" then return type(value) end
local keys = {}
for k in pairs(value) do table.insert(keys, tostring(k)) end
table.sort(keys)
if #keys == 0 then return "{} (no enumerable keys)" end
local parts = {}
for _, k in ipairs(keys) do
local v = value[k]
if type(v) == "table" and depth < 1 then
table.insert(parts, k .. "=" .. shapeText(v, depth + 1))
else
table.insert(parts, k .. "=" .. type(v))
end
end
return "{" .. table.concat(parts, ", ") .. "}"
end
-- Below shapeText deliberately: an unusable frame is reported by its shape,
-- which is the one thing worth knowing about it.
local function frameText(frame)
if not usableFrame(frame) then return shapeText(frame) end
return string.format("{x=%.0f, y=%.0f, w=%.0f, h=%.0f}",
frame.x, frame.y, frame.w, frame.h)
end
local function mouseGet()
local ok, point = pcall(hs.mouse.absolutePosition)
if ok and type(point) == "table" and type(point.x) == "number" then
return {x = point.x, y = point.y}
end
return nil
end
local function mouseSet(x, y)
return (pcall(hs.mouse.absolutePosition, {x = x, y = y}))
end
local function centreOf(frame)
return frame.x + frame.w / 2, frame.y + frame.h / 2
end
local function round(n)
if type(n) ~= "number" then return n end
return math.floor(n + 0.5)
end
-- ------------------------------------------------------- the scratch file
-- Guided step F8 is the only thing in this harness that writes anywhere but
-- the report, and it writes under the user's Desktop because that is where
-- the Finder source is watching. The run it writes during is one a person is
-- being asked to act in, so BEING INTERRUPTED IS NORMAL -- a reload, a quit,
-- a hand on the wrong key -- and "the run was cut short" is not an excuse for
-- leaving a file of ours behind. It happened once already.
--
-- Four mechanisms, because each closes a hole the others leave open:
--
-- 1. the step itself removes the file in the same callback that first sees
-- the sound it was created to provoke (H:guidedFinderFile);
-- 2. restore() -- the finally block -- removes it on any error, watchdog
-- or dead man's timer that ends the run early;
-- 3. hs.shutdownCallback removes it if the Lua environment is torn down
-- mid-run, chained so that whatever the config had there still runs;
-- 4. the NEXT run sweeps it before anything else, and reports having done
-- so.
--
-- (4) exists because (3) cannot be relied on. Hammerspoon documents
-- hs.shutdownCallback as firing when the Lua environment is destroyed,
-- including on a reload -- but a crash, a force quit, or a config that
-- installs its own handler after this one all skip it, and the run that
-- prompted all this was ended by a reload with a file still on the Desktop.
-- A cleanup that only works when the process is shut down politely is not a
-- cleanup.
--
-- (4) is also why the name is FIXED. Whatever destroys the Lua state takes
-- every in-memory record with it -- this file is dofile'd fresh on every
-- :diagnose(), so even the table below is new each run -- which leaves the
-- file on disk as its own record. A clock-stamped name could only be found
-- again by globbing a directory of the user's, and this harness does not
-- glob: it removes ONE known path, and only after reading the first line back
-- and matching the marker it wrote, so a file of theirs that happens to share
-- the name is recognised as not ours and left exactly where it is.
local scratch = {
path = nil, -- outstanding: on disk right now, ours to remove
shutdown = nil, -- the callback we installed, while one is installed
previousShutdown = nil, -- whatever was there before, called after ours
}
local function scratchPath()
return (os.getenv("HOME") or "") .. C.reportDir .. "/" .. C.scratchName
end
-- Ours, by content rather than by name.
--
-- An EMPTY file at this exact path counts as ours too. io.open creates the
-- file before anything is written to it, and a write that fails -- a full
-- disk is the realistic one -- leaves nothing in it: recognising only the
-- marker would strand a zero-byte file of our own making that no sweep could
-- ever pick up. Nothing of the user's is at risk in that rule, because a
-- zero-byte file is not data.
local function isOurScratch(path)
local file = io.open(path, "r")
if not file then return false end
local ok, first = pcall(function() return file:read("l") end)
pcall(function() file:close() end)
return ok and (first == C.scratchMarker or first == nil)
end
-- Whether anything is at that path at all, ours or not.
local function scratchExists(path)
local file = io.open(path, "r")
if not file then return false end
pcall(function() file:close() end)
return true
end
local function disarmShutdownSweep()
if not scratch.shutdown then return end
-- Only put ours back if it is still ours to put back: a config that
-- installed its own during the run keeps it.
if hs.shutdownCallback == scratch.shutdown then
hs.shutdownCallback = scratch.previousShutdown
end
scratch.shutdown, scratch.previousShutdown = nil, nil
end
local function armShutdownSweep()
if scratch.shutdown then return end
scratch.previousShutdown = hs.shutdownCallback
scratch.shutdown = function()
if scratch.path and isOurScratch(scratch.path) then
pcall(os.remove, scratch.path)
end
if type(scratch.previousShutdown) == "function" then
pcall(scratch.previousShutdown)
end
end
hs.shutdownCallback = scratch.shutdown
end
-- Remove the outstanding scratch file NOW, by exact name. Returns nil when
-- there was nothing of ours to remove, true when it went, or false and the
-- reason when it would not go. Called from the step, from restore(), and
-- from the next run's sweep, so it must be safe to call at any time.
local function takeScratch(path)
path = path or scratch.path
local outstanding = (path ~= nil) and (path == scratch.path)
if not path or not isOurScratch(path) then
if outstanding then scratch.path = nil end
disarmShutdownSweep()
return nil
end
local ok, err = os.remove(path)
if ok then
if outstanding then scratch.path = nil end
disarmShutdownSweep()
return true
end
return false, tostring(err)
end
-- ------------------------------------------------------------- the harness
local H = {}
H.__index = H
function H.new(obj, opts)
return setmetatable({
obj = obj,
opts = opts or {},
lines = {},
problems = {},
counts = {PASS = 0, FAIL = 0, WARN = 0, SKIP = 0, ["----"] = 0},
steps = {},
log = {}, -- the recording shim's tape
found = {}, -- Phase C: role key -> {element, frame, role}
swept = {}, -- scratch files a previous run left behind
scratchLeft = {},-- still on disk at the end, shouted about in the summary
stepIndex = 0,
}, H)
end
-- ---- report primitives
function H:raw(text) table.insert(self.lines, text or "") end
function H:heading(text)
self:raw("")
self:raw(text)
self:raw(string.rep("-", #text))
end
function H:note(text) self:raw(" " .. text) end
function H:record(status, id, text)
local line = string.format("[%s] %-4s %s", status, id, text)
table.insert(self.lines, line)
self.counts[status] = (self.counts[status] or 0) + 1
if status == "FAIL" or status == "WARN" then
table.insert(self.problems, line)
end
return line
end
-- A path this run could not get off disk. Recorded rather than only printed,
-- because the summary shouts about these and the phase body scrolls past.
--
-- False when the path is already on the list, which is how the retry in
-- restore() reports nothing the step has already said: one file, one line,
-- however many times the harness tried to remove it.
function H:scratchStillThere(path, why)
for _, known in ipairs(self.scratchLeft) do
if known == path then return false end
end
table.insert(self.scratchLeft, path)
print("PlatinumSnd diagnose: could not remove " .. path
.. (why and (" (" .. tostring(why) .. ")") or ""))
return true
end
function H:pass(id, text) return self:record("PASS", id, text) end
function H:fail(id, text) return self:record("FAIL", id, text) end
function H:warn(id, text) return self:record("WARN", id, text) end
function H:skip(id, text) return self:record("SKIP", id, text) end
function H:na(id, text) return self:record("----", id, text) end
-- PASS on a true condition, FAIL otherwise, with one text for both. Keeps a
-- check to one line where the only difference is the verdict.
function H:verdict(ok, id, text)
if ok then return self:pass(id, text) end
return self:fail(id, text)
end
-- ---- the recording shim
-- Record what the engine is ASKED to play without changing what it does.
-- Wrapping the instance rather than the Sound metatable keeps the blast
-- radius to this one engine, and rawget remembers whether the instance had a
-- field of its own to put back (it does not, but restoring by assignment
-- would silently leave one behind).
function H:installShim()
local engine = self.obj and self.obj.engine
if not engine then return false end
self.shimmed = engine
self.shimShadow = {
play = rawget(engine, "play"),
sustain = rawget(engine, "sustain"),
release = rawget(engine, "release"),
}
local original = {
play = engine.play, sustain = engine.sustain, release = engine.release,
}
self.shimOriginal = original
local harness = self
for _, kind in ipairs({"play", "sustain", "release"}) do
engine[kind] = function(target, semantic)
table.insert(harness.log, {
at = clock(), kind = kind, semantic = semantic,
base = soundmap.bases[semantic],
})
return original[kind](target, semantic)
end
end
return true
end
function H:removeShim()
if not self.shimmed then return end
for _, kind in ipairs({"play", "sustain", "release"}) do
self.shimmed[kind] = self.shimShadow[kind]
end
self.shimmed = nil
end
function H:mark() return #self.log end
function H:since(mark)
local out = {}
for i = (mark or 0) + 1, #self.log do table.insert(out, self.log[i]) end
return out
end
local function tapeText(entries)
if #entries == 0 then return "nothing" end
local parts = {}
for _, entry in ipairs(entries) do
table.insert(parts, string.format("%s %s(%s)", entry.kind,
tostring(entry.semantic), tostring(entry.base)))
end
return table.concat(parts, ", ")
end
local function tapeHas(entries, semantic)
for _, entry in ipairs(entries) do
if entry.semantic == semantic then return true end
end
return false
end
-- The entries in one semantic family, `finder` picking out `finder.new`,
-- `finder.select` and the rest. For checks that have to judge what the harness
-- did while a human is also using the machine.
local function tapeFamily(entries, family)
local out = {}
local prefix = family .. "."
for _, entry in ipairs(entries) do
if type(entry.semantic) == "string"
and entry.semantic:sub(1, #prefix) == prefix then
table.insert(out, entry)
end
end
return out
end
-- ---- the step queue
function H:add(name, fn, timeout)
table.insert(self.steps, {name = name, fn = fn, timeout = timeout})
end
-- One step, then the next on a fresh run loop turn. The turn matters twice
-- over: it keeps a long chain from becoming one deep recursion, and it gives
-- the Spoon's own timers -- the hover loop above all -- room to run between
-- the things this harness does to them.
function H:runStep(index)
-- Wrapped whole. A failure INSIDE the machinery -- not inside a step, which
-- is guarded below, but in the guarding itself -- would otherwise stop the
-- chain advancing with the cursor moved and the engine still shimmed. Any
-- such failure ends the run properly instead.
local ok, err = pcall(function() self:runStepGuarded(index) end)
if not ok then
pcall(function()
self:fail("!!", "the harness itself failed: " .. tostring(err))
end)
self:finish()
end
end
function H:runStepGuarded(index)
local step = self.steps[index]
if not step then return self:finish() end
local advanced = false
local watchdog
local function advance()
if advanced then return end
advanced = true
if watchdog then pcall(function() watchdog:stop() end) end
hs.timer.doAfter(0, function() self:runStep(index + 1) end)
end
local limit = step.timeout or C.stepTimeoutSeconds
watchdog = hs.timer.doAfter(limit, function()
if advanced then return end
self:fail(step.name, string.format(
"step did not finish within %ds and was abandoned", limit))
advance()
end)
local ok, err = pcall(step.fn, self, advance)
if not ok then
self:fail(step.name, "step errored: " .. tostring(err))
advance()
end
end
local function delayOf(opts)
local delay = tonumber((opts or {}).delay) or 0
if delay < 0 then return 0 end
return delay
end
-- Wait, then continue. Every pause in the harness goes through this so that
-- none of them is ever a blocking sleep.
local function after(seconds, fn)
return hs.timer.doAfter(seconds, function()
local ok, err = pcall(fn)
if not ok then print("PlatinumSnd diagnose: " .. tostring(err)) end
end)
end
-- ===================================================================== A
function H:phaseA(done)
self:heading("Phase A -- environment and assets")
-- What the entry sweep found, if anything. No line at all when there was
-- nothing to sweep: this is a report of something that happened, not a
-- check that can pass.
for _, item in ipairs(self.swept) do
if item.foreign then
self:warn("A0", "a file with this harness's scratch name is on your "
.. "Desktop but was NOT written by it, so it has been left alone: "
.. item.path .. " -- guided step F8 will not run while it is there")
elseif item.removed then
self:warn("A0", "an earlier run was interrupted before it could remove "
.. "its scratch file. This run removed it: " .. item.path)
elseif self:scratchStillThere(item.path, item.err) then
self:fail("A0", "an earlier run left its scratch file behind and this "
.. "run could not remove it either: " .. item.path .. " ("
.. tostring(item.err) .. ") -- delete it yourself")
end
end
local osVersion = self.env.osVersion
self:verdict(osVersion ~= nil, "A1", "macOS version: " .. tostring(osVersion))
self:verdict(self.env.hsVersion ~= nil, "A2",
"Hammerspoon version: " .. tostring(self.env.hsVersion))
self:verdict(self.env.luaVersion ~= nil, "A3",
"Lua version: " .. tostring(self.env.luaVersion))
self:verdict(self.obj.version ~= nil, "A4",
"PlatinumSnd version: " .. tostring(self.obj.version))
local axOk = self.env.accessibility
if axOk then
self:pass("A5", "Accessibility permission granted")
else
self:fail("A5", "Accessibility permission NOT granted -- every "
.. "accessibility check below is meaningless without it")
end
-- Full Disk Access has no API to interrogate, so the proxy is the thing
-- that actually breaks without it: whether the TCC-protected directories
-- the Finder source watches can be enumerated at all.
local home = os.getenv("HOME") or ""
local dirs = {"/Desktop", "/Documents", "/Downloads", "/.Trash"}
for i, suffix in ipairs(dirs) do
local path = home .. suffix
local id = "A" .. tostring(5 + i)
local ok, iter, data = pcall(hs.fs.dir, path)
if not ok or type(iter) ~= "function" then
self:fail(id, string.format("cannot enumerate %s (%s) -- Full Disk "
.. "Access is probably missing", path, tostring(iter)))
else
local n = 0
local counted = pcall(function()
for _ in iter, data do n = n + 1 end
end)
if counted then
self:pass(id, string.format("%s enumerates (%d entries)", path, n))
else
self:fail(id, path .. " opened but could not be walked")
end
end
end
-- The constants that were actually in play, printed in full. A tuning
-- value read back wrong is invisible in every other check here.
self:pass("A10", "obj.tuning dump follows")
local keys = {}
for k in pairs(self.obj.tuning or {}) do table.insert(keys, k) end
table.sort(keys)
for _, k in ipairs(keys) do
local v = self.obj.tuning[k]
if type(v) == "table" then
local parts = {}
for _, item in ipairs(v) do table.insert(parts, tostring(item)) end
v = "{" .. table.concat(parts, ", ") .. "}"
end
self:note(string.format("%-28s %s", k, tostring(v)))
end
-- Every base name against the real filesystem, and which container each
-- one landed in. The claim under test is 67 WAV plus `bevp` to MP3.
local root = hs.spoons.resourcePath("snd")
local total, wav, mp3, unresolved, wrongKind = 0, 0, 0, {}, {}
local names = {}
for semantic in pairs(soundmap.bases) do table.insert(names, semantic) end
table.sort(names)
for _, semantic in ipairs(names) do
local base = soundmap.bases[semantic]
total = total + 1
local path = resolver.resolve(root, base, function(candidate)
return hs.fs.attributes(candidate, "mode") == "file"
end)
if not path then
table.insert(unresolved, semantic .. " (" .. tostring(base) .. ")")
elseif path:sub(-4) == ".wav" then
wav = wav + 1
if base == "bevp" then
table.insert(wrongKind, "bevp resolved to WAV, expected MP3")
end
else
mp3 = mp3 + 1
if base ~= "bevp" then
table.insert(wrongKind, base .. " resolved to MP3, expected WAV")
end
end
end
self:verdict(#unresolved == 0, "A11", string.format(
"%d/%d pack base names resolve against %s", total - #unresolved, total,
root))
for _, name in ipairs(unresolved) do self:note("unresolved: " .. name) end
self:verdict(#wrongKind == 0, "A12", string.format(
"container split: %d WAV, %d MP3 (expected %d WAV and bevp alone as MP3)",
wav, mp3, total - 1))
for _, text in ipairs(wrongKind) do self:note(text) end
-- What the engine actually built out of that, which is a different
-- question: hs.sound.getByFile can return nil for a path that exists, and
-- the engine swallows that with no log and no entry in `missing`.
local engine = self.obj.engine
local pools, sustainers, emptyPools = 0, 0, {}
for semantic, pool in pairs(engine and engine.pools or {}) do
pools = pools + 1
if #(pool.objects or {}) == 0 then table.insert(emptyPools, semantic) end
end
for _ in pairs(engine and engine.sustainers or {}) do
sustainers = sustainers + 1
end
local expectedSustainers = 0
for _ in pairs(soundmap.sustained) do
expectedSustainers = expectedSustainers + 1
end
local built = pools + sustainers
self:verdict(built == total - #unresolved, "A13", string.format(
"engine built %d one-shot pools and %d sustainers (%d of %d resolvable "
.. "names)%s", pools, sustainers, built, total - #unresolved,
self.obj.running and ""
or " -- the Spoon is not running, so load() has never been called"))
self:verdict(sustainers == expectedSustainers, "A14", string.format(
"%d of %d sustained names became looping sounds", sustainers,
expectedSustainers))
for _, semantic in ipairs(emptyPools) do
self:note("pool with no sound objects: " .. semantic)
end
done()
end
-- ===================================================================== B
function H:phaseB1(done)
self:heading("Phase B -- API assumptions that would invalidate the design")
-- B1 to B8 need accessibility; B9 to B11 are sound checks and do not, so
-- every bail-out below falls through to them rather than returning.
local function skipRest(from, why)
for i = from, 8 do self:na("B" .. i, "not tested: " .. why) end
end
if not self.env.accessibility then
skipRest(1, "Accessibility permission is not granted")
return self:phaseB2(done)
end
local ok, sys = pcall(hs.axuielement.systemWideElement)
self.sys = ok and sys or nil
self:verdict(self.sys ~= nil, "B1", string.format(
"hs.axuielement.systemWideElement() returned %s",
ok and tostring(sys) or ("an error: " .. tostring(sys))))
if not self.sys then
skipRest(2, "no system-wide element to ask")
return self:phaseB2(done)
end
-- The AX messaging timeout is a PROCESS-GLOBAL mutation, which makes both
-- halves of this check awkward.
--
-- Setting it is what the check is for, but the harness must not leave one
-- behind. When the Spoon is running the bound is already installed and
-- owned by the Spoon, so setting the same value again changes nothing and
-- there is nothing to give back. When the Spoon is stopped, this call is
-- the only thing that installed it, and restore() hands it back with 0.0.
local timeout = (self.obj.tuning or {}).axTimeoutSeconds
local ownedBySpoon = self.obj.running == true
local setOk, setResult = pcall(self.sys.setTimeout, self.sys, timeout)
if setOk and not ownedBySpoon then self.axTimeoutOurs = true end
self:verdict(setOk, "B2", string.format(
":setTimeout(%s) on the system-wide element %s", tostring(timeout),
setOk and ("returned " .. tostring(setResult))
or ("errored: " .. tostring(setResult))))
-- WHERE the bound is installed, which the whole-branch review flagged: it
-- used to be `axprobe.new`, which worked only because the pointer source
-- happens to be registered first, while three other sources depended on it
-- without knowing. The fix moves it to obj:start(). Both arrangements are
-- recognised here, so this line says which one is actually in the code
-- being diagnosed rather than which one was expected.
local probe = ((self.obj.sources or {})[1] or {}).probe
local ax = self.obj.axprobe
local holders = {}
if type(ax) == "table" and type(ax.installTimeout) == "function"
and type(ax.resetTimeout) == "function" then
table.insert(holders,
"obj:start() via axprobe.installTimeout (the owner it should have)")
end
-- Discriminated on Probe:release, not on probe.sys: the probe still holds a
-- system-wide element for its hit-test under either arrangement, so holding
-- one says nothing. Owning the bound is what release() undoes, and its
-- existence is what says the probe still owns it.
if probe ~= nil and type(probe.release) == "function" then
table.insert(holders,
"sources[1].probe (axprobe.new + Probe:release -- the arrangement the "
.. "review flagged; a pointer source that fails to start unbounds the "
.. "other three)")
end
if #holders == 0 then
table.insert(holders, "nowhere this harness could find")
end
self:verdict(#holders == 1 and (ownedBySpoon or self.axTimeoutOurs == true),
"B2+", string.format("the %ss AX messaging bound is installed by: %s -- "
.. "in force: %s", tostring(timeout), table.concat(holders, " AND "),
tostring(ownedBySpoon or self.axTimeoutOurs == true)))
self:note("process-global: it applies to hs.window, hs.uielement and every "
.. "other AX consumer in Hammerspoon, not just this Spoon. macOS "
.. "publishes no getter, so 'in force' is read from who installed it, "
.. "never from the value")
local point = mouseGet()
if not point then
skipRest(3, "the cursor position could not be read")
return self:phaseB2(done)
end
local hit, element = pcall(self.sys.elementAtPosition, self.sys,
point.x, point.y)
element = hit and element or nil
self:verdict(element ~= nil, "B3", string.format(
"elementAtPosition(%d, %d) returned %s", round(point.x), round(point.y),
element and tostring(element) or "nothing"))
if not element then
skipRest(4, "no element under the cursor")
return self:phaseB2(done)
end
local role = att(element, "AXRole")
self:verdict(type(role) == "string", "B4", string.format(
"AXRole on that element is %s (%s)", tostring(role), type(role)))
-- The whole frame elision assumes a flat table indexable as x/y/w/h. A
-- shape that enumerates as origin/size but indexes correctly would still
-- work; one that does not index is a redesign, so both are reported.
local frame = att(element, "AXFrame")
local indexable = usableFrame(frame)
self:verdict(indexable, "B5", string.format(
"AXFrame indexes as flat numeric x/y/w/h: %s", tostring(indexable)))
self:note("enumerable shape: " .. shapeText(frame))
self:note("direct index: " .. (indexable and frameText(frame)
or string.format("x=%s y=%s w=%s h=%s",
type(type(frame) == "table" and frame.x or nil),
type(type(frame) == "table" and frame.y or nil),
type(type(frame) == "table" and frame.w or nil),
type(type(frame) == "table" and frame.h or nil))))
self:note("metatable present: "
.. tostring(type(frame) == "table" and getmetatable(frame) ~= nil))
-- Coordinate agreement on the screen the cursor is already on.
local inside = axpolicy.isInsideFrame(frame, point.x, point.y)
self:verdict(inside, "B6", string.format(
"cursor (%d, %d) falls inside the AXFrame %s of the element under it",
round(point.x), round(point.y), frameText(frame)))
local pid = call(element, "pid")
self:verdict(type(pid) == "number", "B7", string.format(
"element:pid() returned %s (%s)", tostring(pid), type(pid)))
-- The close box refinement's whole basis: the red traffic light is an
-- AXButton whose AXSubrole is AXCloseButton.
local win = fcall(hs.window.focusedWindow)
local winElement = win and fcall(hs.axuielement.windowElement, win)
local closeBox = winElement and att(winElement, "AXCloseButton")
if not closeBox then
self:na("B8", "the focused window published no AXCloseButton "
.. "(focused window: " .. tostring(win and call(win, "title")) .. ")")
else
local closeRole = att(closeBox, "AXRole")
local closeSubrole = att(closeBox, "AXSubrole")
local refined = axpolicy.refinedRole(closeRole, closeSubrole)
self:verdict(refined == "AXCloseButton", "B8", string.format(
"close box reports AXRole=%s AXSubrole=%s, refines to %s",
tostring(closeRole), tostring(closeSubrole), tostring(refined)))
local frameCB = att(closeBox, "AXFrame")
if usableFrame(frameCB) and closeRole then
self.found["AXCloseButton"] = {
element = closeBox, frame = frameCB, role = closeRole,
}
end
end
return self:phaseB2(done)
end
-- Sound behaviour: the only two checks in the harness that make a noise,
-- because there is no other way to ask NSSound whether it overlapped or
-- whether it looped.
function H:phaseB2(done)
local engine = self.obj.engine
if not engine or not engine.pools then
self:na("B9", "not tested: no engine")
self:na("B10", "not tested: no engine")
self:na("B11", "not tested: no engine")
return done()
end
-- The longest pooled one-shot, because five plays 60 ms apart can only
-- overlap if the file outlasts the spacing. Grading a 78 ms click as a
-- pooling failure would be the harness's own fault.
local best, bestDuration = nil, -1
for semantic, pool in pairs(engine.pools) do
local snd = (pool.objects or {})[1]
local duration = snd and call(snd, "duration")
if type(duration) == "number" and duration > bestDuration then
best, bestDuration = semantic, duration
end
end
if not best then
self:na("B9", "not tested: no pooled sound reported a duration")
return self:phaseB3(done)
end
local pool = engine.pools[best]
local wasDryRun = engine.isDryRun
engine:dryRun(false)
local maxAlive = 0
-- Held on self, like every repeating timer here: the harness itself is
-- rooted on the Spoon for the duration of the run, so nothing the run
-- depends on can be collected out from under it mid-step.
local sampler = hs.timer.doEvery(C.overlapSampleSeconds, function()
local alive = 0
for _, snd in ipairs(pool.objects or {}) do
if call(snd, "isPlaying") then alive = alive + 1 end
end
if alive > maxAlive then maxAlive = alive end
end)
self.timers = {sampler}
for i = 1, C.overlapPlays do
after((i - 1) * C.overlapSpacingSeconds, function()
engine:play(best)
end)
end
local window = C.overlapPlays * C.overlapSpacingSeconds + bestDuration
+ C.overlapTailSeconds
after(window, function()
pcall(function() sampler:stop() end)
engine:dryRun(wasDryRun)
local text = string.format(
"%s (%s, %.2f s) played %d times %d ms apart: at most %d of %d pool "
.. "objects playing at once", best, tostring(soundmap.bases[best]),
bestDuration, C.overlapPlays, round(C.overlapSpacingSeconds * 1000),
maxAlive, #(pool.objects or {}))
if maxAlive >= 2 then
self:pass("B9", text)
elseif maxAlive == 1 and bestDuration <= C.overlapSpacingSeconds then
self:na("B9", text .. " -- inconclusive: the file is shorter than the "
.. "spacing, so no overlap was possible")
elseif maxAlive == 0 then