things-api 0.19.0 → 0.19.1
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/README.md +3 -1
- package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
- package/dist/audit/schema.d.ts +19 -2
- package/dist/audit/schema.js.map +1 -1
- package/dist/capability.d.ts +166 -4
- package/dist/capability.js +303 -13
- package/dist/capability.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts +13 -1
- package/dist/cli/commands/doctor.js +24 -14
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/helpers.js +14 -3
- package/dist/cli/commands/helpers.js.map +1 -1
- package/dist/cli/commands/mcp.js +8 -0
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/repeat-flags.js +34 -2
- package/dist/cli/commands/repeat-flags.js.map +1 -1
- package/dist/cli/commands/setup.js +16 -3
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/writes.js +102 -67
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/interrupt.d.ts +54 -6
- package/dist/cli/interrupt.js +78 -12
- package/dist/cli/interrupt.js.map +1 -1
- package/dist/cli/main.js +8 -5
- package/dist/cli/main.js.map +1 -1
- package/dist/client.d.ts +1 -1
- package/dist/contracts.d.ts +3 -3
- package/dist/contracts.js +1 -1
- package/dist/deputy/install.d.ts +15 -1
- package/dist/deputy/install.js +46 -6
- package/dist/deputy/install.js.map +1 -1
- package/dist/deputy/routing.d.ts +16 -0
- package/dist/deputy/routing.js +70 -0
- package/dist/deputy/routing.js.map +1 -1
- package/dist/deputy/wake.d.ts +61 -0
- package/dist/deputy/wake.js +149 -0
- package/dist/deputy/wake.js.map +1 -0
- package/dist/diagnose.d.ts +6 -4
- package/dist/diagnose.js +20 -4
- package/dist/diagnose.js.map +1 -1
- package/dist/direct-setup.d.ts +31 -5
- package/dist/direct-setup.js +200 -26
- package/dist/direct-setup.js.map +1 -1
- package/dist/host-access.d.ts +12 -0
- package/dist/host-access.js +12 -0
- package/dist/host-access.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +23 -9
- package/dist/mcp/server.js.map +1 -1
- package/dist/op-result.js +8 -1
- package/dist/op-result.js.map +1 -1
- package/dist/session-grant.js +16 -1
- package/dist/session-grant.js.map +1 -1
- package/dist/surface-copy.d.ts +11 -3
- package/dist/surface-copy.js +11 -3
- package/dist/surface-copy.js.map +1 -1
- package/dist/wizard.js +8 -1
- package/dist/wizard.js.map +1 -1
- package/dist/write/availability.d.ts +0 -15
- package/dist/write/availability.js +7 -62
- package/dist/write/availability.js.map +1 -1
- package/dist/write/batch.js +17 -6
- package/dist/write/batch.js.map +1 -1
- package/dist/write/commands.d.ts +10 -6
- package/dist/write/commands.js +120 -31
- package/dist/write/commands.js.map +1 -1
- package/dist/write/failure-hints.d.ts +29 -6
- package/dist/write/failure-hints.js +9 -14
- package/dist/write/failure-hints.js.map +1 -1
- package/dist/write/guards.js +3 -2
- package/dist/write/guards.js.map +1 -1
- package/dist/write/move.js +9 -12
- package/dist/write/move.js.map +1 -1
- package/dist/write/operations.d.ts +15 -8
- package/dist/write/operations.js +9 -0
- package/dist/write/operations.js.map +1 -1
- package/dist/write/opid.d.ts +67 -23
- package/dist/write/opid.js +141 -19
- package/dist/write/opid.js.map +1 -1
- package/dist/write/param-schema.d.ts +0 -44
- package/dist/write/param-schema.js +10 -3
- package/dist/write/param-schema.js.map +1 -1
- package/dist/write/pipeline.d.ts +38 -7
- package/dist/write/pipeline.js +104 -18
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/pre-state.d.ts +30 -16
- package/dist/write/pre-state.js +88 -20
- package/dist/write/pre-state.js.map +1 -1
- package/dist/write/promote-clone.js +132 -18
- package/dist/write/promote-clone.js.map +1 -1
- package/dist/write/repeat-collateral.d.ts +31 -0
- package/dist/write/repeat-collateral.js +153 -0
- package/dist/write/repeat-collateral.js.map +1 -0
- package/dist/write/resolution-timestamps.js +25 -2
- package/dist/write/resolution-timestamps.js.map +1 -1
- package/dist/write/template-mutation.js +163 -16
- package/dist/write/template-mutation.js.map +1 -1
- package/dist/write/vectors/applescript.js +3 -10
- package/dist/write/vectors/applescript.js.map +1 -1
- package/dist/write/vectors/simulator.js +8 -2
- package/dist/write/vectors/simulator.js.map +1 -1
- package/dist/write/vectors/types.d.ts +211 -1
- package/dist/write/vectors/ui-certification.js +63 -13
- package/dist/write/vectors/ui-certification.js.map +1 -1
- package/dist/write/vectors/ui-chord.d.ts +144 -0
- package/dist/write/vectors/ui-chord.js +428 -0
- package/dist/write/vectors/ui-chord.js.map +1 -0
- package/dist/write/vectors/ui-drag.d.ts +8 -4
- package/dist/write/vectors/ui-drag.js +2 -0
- package/dist/write/vectors/ui-drag.js.map +1 -1
- package/dist/write/vectors/ui-recipes.d.ts +51 -3
- package/dist/write/vectors/ui-recipes.js +452 -44
- package/dist/write/vectors/ui-recipes.js.map +1 -1
- package/dist/write/vectors/ui.d.ts +267 -10
- package/dist/write/vectors/ui.js +1006 -64
- package/dist/write/vectors/ui.js.map +1 -1
- package/dist/write/vectors/url-scheme.js +3 -0
- package/dist/write/vectors/url-scheme.js.map +1 -1
- package/dist/write/verify/delta.d.ts +41 -0
- package/dist/write/verify/delta.js +36 -2
- package/dist/write/verify/delta.js.map +1 -1
- package/dist/write/verify/poller.d.ts +4 -1
- package/dist/write/verify/poller.js +14 -0
- package/dist/write/verify/poller.js.map +1 -1
- package/package.json +1 -1
- package/schema/envelope.schema.json +1 -1
- package/skills/things-cli/SKILL.md +1 -1
package/dist/write/vectors/ui.js
CHANGED
|
@@ -33,6 +33,7 @@ import { UI_DRIVE_OPS } from "../operations.js";
|
|
|
33
33
|
import { escapeAppleScript } from "./applescript.js";
|
|
34
34
|
import { createReachabilityCache, H_UI_SESSION_UNREACHABLE, probeSessionReachability, } from "./session-reachability.js";
|
|
35
35
|
import { certificationOf } from "./ui-certification.js";
|
|
36
|
+
import { chordCommand, driveHeadingChordReorder } from "./ui-chord.js";
|
|
36
37
|
import { driveSidebarAreaReorder, jxaSidebarSnapshotScript } from "./ui-drag.js";
|
|
37
38
|
/** GUI driving can stall on an unanswered sheet; give each step headroom. */
|
|
38
39
|
const STEP_TIMEOUT_MS = 15_000;
|
|
@@ -72,6 +73,197 @@ const SETTLE_AFTER_REVEAL_MS = 1000;
|
|
|
72
73
|
*/
|
|
73
74
|
const SHAPE_UNPROBED = "the Repeat dialog's shape was never measured, so this control's address is unknown (recipe bug)";
|
|
74
75
|
const SE = `tell application "System Events" to tell process "Things3"`;
|
|
76
|
+
/**
|
|
77
|
+
* The cadence group's re-layout settle budget (BEEP1) — how many times
|
|
78
|
+
* `set-group-number` re-reads the group's shape looking for two consecutive
|
|
79
|
+
* identical reads, and how long it waits between reads. ~4s of headroom against
|
|
80
|
+
* a re-layout measured to finish well inside 1.5s; the gate exits on the FIRST
|
|
81
|
+
* agreeing pair, so the common (already-settled) case costs one extra read.
|
|
82
|
+
*/
|
|
83
|
+
const SETTLE_READS = 40;
|
|
84
|
+
const SETTLE_POLL_S = 0.1;
|
|
85
|
+
/**
|
|
86
|
+
* Row tolerance (points) for every LABEL-ANCHORED field address here — how far a
|
|
87
|
+
* control's y may sit from its label's y and still count as the same row.
|
|
88
|
+
* Measured on Things 3.23 (CGRD1 §A census): `Every`@286 / interval@283,
|
|
89
|
+
* `Ends:`@375 / count@372, `days earlier`@413 / start-offset@409 — a 3–4pt
|
|
90
|
+
* baseline offset, so 8 is ~2× the observed worst case and well under the
|
|
91
|
+
* ~45pt row pitch of the cadence group.
|
|
92
|
+
*/
|
|
93
|
+
const ROW_TOLERANCE = 8;
|
|
94
|
+
/**
|
|
95
|
+
* How many of the Next: menu's own item titles a MISS reports back (NEXTPOP1).
|
|
96
|
+
* A refusal that names only the date the dialog lacked cannot distinguish "the
|
|
97
|
+
* rule genuinely does not produce that date" from "the menu was not the rule's"
|
|
98
|
+
* — the two failures read identically, and the second one cost a whole campaign
|
|
99
|
+
* to tell apart. Naming the pop-up's current value plus the first few options it
|
|
100
|
+
* offered makes the refusal self-diagnosing. Bounded so a 100-item level cannot
|
|
101
|
+
* flood the message.
|
|
102
|
+
*/
|
|
103
|
+
const SAMPLE_ITEMS = 5;
|
|
104
|
+
/**
|
|
105
|
+
* The `Next:` occurrence pop-up's asynchronous-recompute budget (NEXTPOP1) — how
|
|
106
|
+
* long {@link axSettleOccurrencesScript} waits for the dialog to absorb a rule
|
|
107
|
+
* change before the drive touches it again, and how often it looks. MEASURED at
|
|
108
|
+
* **0.4s** on golden-v4 / Things 3.23 (DIAG4: the control flipped from the seed's
|
|
109
|
+
* first occurrence to the anchor's between t+0.3s and t+0.4s), so the budget is
|
|
110
|
+
* ~3× the observed latency; the poll exits early the moment the control moves,
|
|
111
|
+
* which is the only case that costs anything.
|
|
112
|
+
*/
|
|
113
|
+
const OCCURRENCE_SETTLE_MS = 1200;
|
|
114
|
+
const OCCURRENCE_POLL_MS = 100;
|
|
115
|
+
/**
|
|
116
|
+
* The shared AppleScript handler prelude for every LABEL-ANCHORED field address
|
|
117
|
+
* in the Repeat dialog — the HXPC1 discrimination law in ONE place, so the
|
|
118
|
+
* pre-commit audit re-reads each field through the SAME address the drive wrote
|
|
119
|
+
* it through (a self-referential read-back is exactly what let #589's wrong-field
|
|
120
|
+
* write report OK).
|
|
121
|
+
*
|
|
122
|
+
* The laws, all measured on Things 3.23 / build 32300036 (CGRD1 §A census, and
|
|
123
|
+
* HXPC1 §A before it — docs/lab/cgrd1-precommit-audit.md, hxpc1-picker-assert.md):
|
|
124
|
+
*
|
|
125
|
+
* - The cadence group's numeric fields are identified by the LABEL ROW they sit
|
|
126
|
+
* on, never by index among the group's text fields. Selecting an "Ends: after"
|
|
127
|
+
* bound INSERTS the count AHEAD of the interval, so index 1 is a different
|
|
128
|
+
* control at different moments.
|
|
129
|
+
* - The interval is matched POSITIVELY, on the `Every` label's row. Every fixed
|
|
130
|
+
* frequency (daily/weekly/monthly/yearly) carries that label at y=286 with the
|
|
131
|
+
* interval at y=283. An AFTER-COMPLETION cadence group carries NEITHER an
|
|
132
|
+
* `Every` nor an `Ends:` label (census: its only static text is "after previous
|
|
133
|
+
* item is checked off.") and offers exactly ONE text field — so that shape falls
|
|
134
|
+
* to the sole-field rule, which is itself a uniqueness check, not an index.
|
|
135
|
+
* - Anything else FAILS CLOSED reporting the whole numeric-field inventory rather
|
|
136
|
+
* than typing a number into a field it cannot vouch for. An AX tree is an
|
|
137
|
+
* undocumented private surface: an unrecognized shape is a refusal, never a
|
|
138
|
+
* best guess.
|
|
139
|
+
*/
|
|
140
|
+
const AX_CADENCE_HANDLERS = `on cgLabelY(g, want)
|
|
141
|
+
tell application "System Events"
|
|
142
|
+
set outY to missing value
|
|
143
|
+
repeat with i from 1 to (count of static texts of g)
|
|
144
|
+
set sv to ""
|
|
145
|
+
try
|
|
146
|
+
set sv to (value of static text i of g) as text
|
|
147
|
+
end try
|
|
148
|
+
if sv is want then
|
|
149
|
+
-- bind the position, THEN index it: System Events refuses indexing a
|
|
150
|
+
-- position read inline from a specifier (-1700).
|
|
151
|
+
set labelPos to position of static text i of g
|
|
152
|
+
set outY to item 2 of labelPos
|
|
153
|
+
end if
|
|
154
|
+
end repeat
|
|
155
|
+
return outY
|
|
156
|
+
end tell
|
|
157
|
+
end cgLabelY
|
|
158
|
+
|
|
159
|
+
on cgInventory(g)
|
|
160
|
+
tell application "System Events"
|
|
161
|
+
set inv to ""
|
|
162
|
+
repeat with i from 1 to (count of text fields of g)
|
|
163
|
+
set fp to position of text field i of g
|
|
164
|
+
set inv to inv & " #" & i & "(y=" & (item 2 of fp) & ",shows=" & ((value of text field i of g) as text) & ")"
|
|
165
|
+
end repeat
|
|
166
|
+
if inv is "" then set inv to " (none)"
|
|
167
|
+
return inv
|
|
168
|
+
end tell
|
|
169
|
+
end cgInventory
|
|
170
|
+
|
|
171
|
+
on cgOnRow(g, y, tol, want)
|
|
172
|
+
tell application "System Events"
|
|
173
|
+
set hits to {}
|
|
174
|
+
repeat with i from 1 to (count of text fields of g)
|
|
175
|
+
set fp to position of text field i of g
|
|
176
|
+
set dy to (item 2 of fp) - y
|
|
177
|
+
if dy < 0 then set dy to -dy
|
|
178
|
+
set onRow to (dy <= tol)
|
|
179
|
+
if onRow is want then set end of hits to text field i of g
|
|
180
|
+
end repeat
|
|
181
|
+
return hits
|
|
182
|
+
end tell
|
|
183
|
+
end cgOnRow
|
|
184
|
+
|
|
185
|
+
on cgSettle(g)
|
|
186
|
+
-- SETTLE ON THE GROUP'S OWN SHAPE, never on a clock (determinism doctrine).
|
|
187
|
+
-- A frequency switch REBUILDS the cadence group. Two things go wrong when a
|
|
188
|
+
-- read starts too early: the row discrimination reads positions from controls
|
|
189
|
+
-- that are still moving, and keystrokes land on a field being torn down —
|
|
190
|
+
-- unhandled, so macOS beeps (BEEP1). Poll until two consecutive reads of the
|
|
191
|
+
-- group's label + field-position signature agree, then proceed.
|
|
192
|
+
tell application "System Events"
|
|
193
|
+
set sig to ""
|
|
194
|
+
set prevSig to "<none>"
|
|
195
|
+
repeat ${SETTLE_READS} times
|
|
196
|
+
set prevSig to sig
|
|
197
|
+
set sig to ""
|
|
198
|
+
repeat with i from 1 to (count of static texts of g)
|
|
199
|
+
set sv to ""
|
|
200
|
+
try
|
|
201
|
+
set sv to (value of static text i of g) as text
|
|
202
|
+
end try
|
|
203
|
+
set sig to sig & "|s:" & sv
|
|
204
|
+
end repeat
|
|
205
|
+
repeat with i from 1 to (count of text fields of g)
|
|
206
|
+
set fp to position of text field i of g
|
|
207
|
+
set sig to sig & "|f:" & (item 2 of fp)
|
|
208
|
+
end repeat
|
|
209
|
+
if sig is prevSig then return true
|
|
210
|
+
delay ${SETTLE_POLL_S}
|
|
211
|
+
end repeat
|
|
212
|
+
error "the Repeat dialog's cadence group is still re-laying out — its shape changed on every read; last seen" & sig
|
|
213
|
+
end tell
|
|
214
|
+
end cgSettle
|
|
215
|
+
|
|
216
|
+
on cgField(g, target, tol)
|
|
217
|
+
tell application "System Events"
|
|
218
|
+
set endsY to my cgLabelY(g, "Ends:")
|
|
219
|
+
set everyY to my cgLabelY(g, "Every")
|
|
220
|
+
set nf to (count of text fields of g)
|
|
221
|
+
if target is "ends-count" then
|
|
222
|
+
if endsY is missing value then error "the Repeat dialog's cadence group carries no \\"Ends:\\" label, so the ends-after count field cannot be identified — numeric fields:" & my cgInventory(g)
|
|
223
|
+
set hits to my cgOnRow(g, endsY, tol, true)
|
|
224
|
+
if (count of hits) is not 1 then error "the Repeat dialog offers " & (count of hits) & " field(s) on the \\"Ends:\\" row, expected exactly 1 — numeric fields:" & my cgInventory(g)
|
|
225
|
+
return item 1 of hits
|
|
226
|
+
end if
|
|
227
|
+
if everyY is not missing value then
|
|
228
|
+
set hits to my cgOnRow(g, everyY, tol, true)
|
|
229
|
+
if (count of hits) is not 1 then error "the Repeat dialog offers " & (count of hits) & " field(s) on the \\"Every\\" row, expected exactly 1 — numeric fields:" & my cgInventory(g)
|
|
230
|
+
return item 1 of hits
|
|
231
|
+
end if
|
|
232
|
+
if endsY is not missing value then
|
|
233
|
+
set hits to my cgOnRow(g, endsY, tol, false)
|
|
234
|
+
if (count of hits) is not 1 then error "the Repeat dialog offers " & (count of hits) & " field(s) off the \\"Ends:\\" row, expected exactly 1 — numeric fields:" & my cgInventory(g)
|
|
235
|
+
return item 1 of hits
|
|
236
|
+
end if
|
|
237
|
+
if nf is not 1 then error "the Repeat dialog's cadence group carries neither an \\"Every\\" nor an \\"Ends:\\" label and offers " & nf & " numeric field(s), so the interval cannot be identified — numeric fields:" & my cgInventory(g)
|
|
238
|
+
-- positional-ok: reached ONLY after the line above proved the group holds
|
|
239
|
+
-- exactly one text field, so this is a uniqueness statement, not an index.
|
|
240
|
+
-- The after-completion cadence group is that shape (MEASURED, CGRD1 §A: its
|
|
241
|
+
-- only static text is "after previous item is checked off.", one field).
|
|
242
|
+
return text field 1 of g
|
|
243
|
+
end tell
|
|
244
|
+
end cgField
|
|
245
|
+
|
|
246
|
+
on rfInventory(c)
|
|
247
|
+
tell application "System Events"
|
|
248
|
+
set inv to ""
|
|
249
|
+
repeat with i from 1 to (count of text fields of c)
|
|
250
|
+
set fp to position of text field i of c
|
|
251
|
+
set inv to inv & " #" & i & "(y=" & (item 2 of fp) & ",shows=" & ((value of text field i of c) as text) & ")"
|
|
252
|
+
end repeat
|
|
253
|
+
if inv is "" then set inv to " (none)"
|
|
254
|
+
return inv
|
|
255
|
+
end tell
|
|
256
|
+
end rfInventory
|
|
257
|
+
|
|
258
|
+
on rfField(c, rowLabel, tol)
|
|
259
|
+
tell application "System Events"
|
|
260
|
+
set labelY to my cgLabelY(c, rowLabel)
|
|
261
|
+
if labelY is missing value then error "the Repeat dialog shows no \\"" & rowLabel & "\\" label, so the field beside it cannot be identified — text fields:" & my rfInventory(c)
|
|
262
|
+
set hits to my cgOnRow(c, labelY, tol, true)
|
|
263
|
+
if (count of hits) is not 1 then error "the Repeat dialog offers " & (count of hits) & " field(s) on the \\"" & rowLabel & "\\" row, expected exactly 1 — text fields:" & my rfInventory(c)
|
|
264
|
+
return item 1 of hits
|
|
265
|
+
end tell
|
|
266
|
+
end rfField`;
|
|
75
267
|
/** resolve-element: does the element exist right now? Returns "true"/"false". */
|
|
76
268
|
export function axResolveScript(path) {
|
|
77
269
|
return `${SE} to return (exists (${path}))`;
|
|
@@ -82,33 +274,49 @@ export function axPressScript(path) {
|
|
|
82
274
|
}
|
|
83
275
|
/**
|
|
84
276
|
* set-field-value: enter a value into the dialog's numeric text field (interval,
|
|
85
|
-
* ends-count, start-days-earlier)
|
|
86
|
-
* value, and Tabs to commit — because
|
|
87
|
-
*
|
|
88
|
-
* number (the field shows "5" but the
|
|
89
|
-
* exactly like `set value` on a pop-up,
|
|
90
|
-
* case used the default interval 1).
|
|
91
|
-
* needs; Tab (not Return, which would
|
|
92
|
-
*
|
|
93
|
-
* reveal/activate preamble puts Things
|
|
277
|
+
* ends-count, start-days-earlier) — and into the Move… picker's filter field. It
|
|
278
|
+
* FOCUSES the field, TYPES the value, and Tabs to commit — because
|
|
279
|
+
* `set value of <field>` writes the field's displayed text WITHOUT firing the
|
|
280
|
+
* edit, so the app's binding keeps the old number (the field shows "5" but the
|
|
281
|
+
* rule stays interval 1 — a silent no-op exactly like `set value` on a pop-up,
|
|
282
|
+
* UIC6; it went unnoticed while every base case used the default interval 1).
|
|
283
|
+
* Real keystrokes fire the change the binding needs; Tab (not Return, which would
|
|
284
|
+
* fire the default OK button) commits and moves focus. Foreground-bound
|
|
285
|
+
* (keystrokes reach the frontmost app) — the reveal/activate preamble puts Things
|
|
286
|
+
* there. One stable command shape.
|
|
287
|
+
*
|
|
288
|
+
* NO SELECT-ALL KEYSTROKE (BEEP1, 2026-08-25, docs/lab/beep1-numeric-field-beep.md).
|
|
289
|
+
* The primitive used to send ⌘A before typing, and that ONE keystroke was the
|
|
290
|
+
* audible macOS alert beep every numeric-field drive fired on the live host:
|
|
291
|
+
* Things' `Edit ▸ Select All` menu item exists and is DISABLED while the Repeat
|
|
292
|
+
* sheet is up, AppKit dispatches ⌘A as a menu key equivalent FIRST, the disabled
|
|
293
|
+
* item swallows it, nothing handles it → NSBeep. It is a menu-dispatch fact, not
|
|
294
|
+
* a focus race: the beep survives a verified first responder and a 1.5 s settle,
|
|
295
|
+
* while Tab and the digits themselves are silent. ⌘A was also REDUNDANT —
|
|
296
|
+
* `set focused of tf to true` installs the field editor with the ENTIRE content
|
|
297
|
+
* selected (measured: `AXSelectedTextRange` length goes 0 → the full value
|
|
298
|
+
* length, on both a 1- and a 2-character value), so typing replaces the old value
|
|
299
|
+
* outright, including the shrinking case (12 → 3) that a stale caret would have
|
|
300
|
+
* corrupted into "123". Dropping the keystroke is therefore silent AND correct on
|
|
301
|
+
* all three fields.
|
|
302
|
+
*
|
|
303
|
+
* CLOSED-LOOP (determinism doctrine): type, Tab-commit, then READ THE FIELD BACK
|
|
304
|
+
* and retry if it did not hold — the interval field, when it is the first numeric
|
|
305
|
+
* field after a frequency/type switch, races the dialog's group re-layout and
|
|
306
|
+
* reverts to 1 (UIC7, oddities §8l). Re-focus + re-type after a settle lands it
|
|
307
|
+
* once the re-layout has finished, and the re-focus re-selects the whole value,
|
|
308
|
+
* so a retry starts from a clean field without ⌘A. Fail-closed (an `error`, i.e.
|
|
309
|
+
* a transport failure the pipeline re-verifies) if it never holds — the
|
|
310
|
+
* create/reschedule delta's rule assertion is the final DB-level authority.
|
|
94
311
|
*/
|
|
95
312
|
export function axSetValueScript(path, value, attempts = 3) {
|
|
96
313
|
const v = escapeAppleScript(value);
|
|
97
314
|
const n = Math.max(1, Math.trunc(attempts));
|
|
98
|
-
// CLOSED-LOOP (determinism doctrine): type, Tab-commit, then READ THE FIELD
|
|
99
|
-
// BACK and retry if it did not hold — the interval field, when it is the first
|
|
100
|
-
// numeric field after a frequency/type switch, races the dialog's group
|
|
101
|
-
// re-layout and reverts to 1 (UIC7, oddities §8l). Re-focus + re-type after a
|
|
102
|
-
// settle lands it once the re-layout has finished. Fail-closed (an `error`,
|
|
103
|
-
// i.e. a transport failure the pipeline re-verifies) if it never holds — the
|
|
104
|
-
// create/reschedule delta's rule assertion is the final DB-level authority.
|
|
105
315
|
return `${SE}
|
|
106
316
|
set tf to (${path})
|
|
107
317
|
repeat ${n} times
|
|
108
318
|
set focused of tf to true
|
|
109
319
|
delay 0.15
|
|
110
|
-
keystroke "a" using command down
|
|
111
|
-
delay 0.1
|
|
112
320
|
keystroke "${v}"
|
|
113
321
|
delay 0.1
|
|
114
322
|
key code 48
|
|
@@ -121,6 +329,309 @@ export function axSetValueScript(path, value, attempts = 3) {
|
|
|
121
329
|
error "field did not hold value \\"${v}\\" after ${n} attempt(s); last shown: " & ((value of tf) as text)
|
|
122
330
|
end tell`;
|
|
123
331
|
}
|
|
332
|
+
/**
|
|
333
|
+
* set-group-number: drive ONE of the Repeat dialog's two numeric fields —
|
|
334
|
+
* the cadence INTERVAL or the ENDS-AFTER COUNT — addressed by the LABEL ROW it
|
|
335
|
+
* sits on (HXPC1, docs/lab/hxpc1-picker-assert.md §A; hardened by CGRD1,
|
|
336
|
+
* docs/lab/cgrd1-precommit-audit.md §A).
|
|
337
|
+
*
|
|
338
|
+
* Both fields used to be spelled `text field 1 of group 1`, which is the same
|
|
339
|
+
* control at different moments. Measured on Things 3.23 (build 32300036):
|
|
340
|
+
*
|
|
341
|
+
* Ends: never → group text fields = 1 · tf1 = interval @[311,283]
|
|
342
|
+
* Ends: after N → group text fields = 2 · tf1 = COUNT @[402,372]
|
|
343
|
+
* tf2 = interval @[311,283]
|
|
344
|
+
*
|
|
345
|
+
* i.e. selecting the "after" bound INSERTS the count ahead of the interval. The
|
|
346
|
+
* create path got away with it by driving the interval while it was still the
|
|
347
|
+
* sole field, but a RESCHEDULE opens the dialog pre-populated: a rule that
|
|
348
|
+
* already ends after N presents both fields from the first step, so the interval
|
|
349
|
+
* drive wrote the requested interval into the count field, the count drive then
|
|
350
|
+
* overwrote it, and the interval silently never changed.
|
|
351
|
+
*
|
|
352
|
+
* The addresses are the {@link AX_CADENCE_HANDLERS} laws — POSITIVE label-row
|
|
353
|
+
* matches wherever the app offers a label to match, a uniqueness check where it
|
|
354
|
+
* does not, and a fail-closed refusal naming the whole numeric-field inventory
|
|
355
|
+
* otherwise. The write itself is the {@link axSetValueScript} closed loop —
|
|
356
|
+
* focus, type, Tab-commit, read back, bounded retries — and, like it, sends NO
|
|
357
|
+
* select-all keystroke: the ⌘A that used to open it is the macOS alert beep
|
|
358
|
+
* (BEEP1, docs/lab/beep1-numeric-field-beep.md), and focusing the field already
|
|
359
|
+
* selects its whole content.
|
|
360
|
+
*
|
|
361
|
+
* The read-back this loop performs is SELF-REFERENTIAL by construction — it
|
|
362
|
+
* re-reads the field it addressed, so it can only prove the keystrokes landed
|
|
363
|
+
* where they were aimed, never that they were aimed at the right control. The
|
|
364
|
+
* PRE-COMMIT AUDIT ({@link axAuditDialogScript}) is what closes that: it re-reads
|
|
365
|
+
* every control through these same handlers just before the OK press.
|
|
366
|
+
*/
|
|
367
|
+
export function axSetGroupNumberScript(groupPath, target, value, attempts = 3, rowTolerance = ROW_TOLERANCE) {
|
|
368
|
+
const v = escapeAppleScript(value);
|
|
369
|
+
const n = Math.max(1, Math.trunc(attempts));
|
|
370
|
+
const tol = Math.max(1, Math.trunc(rowTolerance));
|
|
371
|
+
return `${AX_CADENCE_HANDLERS}
|
|
372
|
+
|
|
373
|
+
${SE}
|
|
374
|
+
set g to (${groupPath})
|
|
375
|
+
my cgSettle(g)
|
|
376
|
+
set tf to my cgField(g, "${target}", ${tol})
|
|
377
|
+
repeat ${n} times
|
|
378
|
+
set focused of tf to true
|
|
379
|
+
delay 0.15
|
|
380
|
+
keystroke "${v}"
|
|
381
|
+
delay 0.1
|
|
382
|
+
key code 48
|
|
383
|
+
delay 0.2
|
|
384
|
+
try
|
|
385
|
+
if ((value of tf) as text) is "${v}" then return "OK"
|
|
386
|
+
end try
|
|
387
|
+
delay 0.3
|
|
388
|
+
end repeat
|
|
389
|
+
error "the ${target} field did not hold value \\"${v}\\" after ${n} attempt(s); last shown: " & ((value of tf) as text)
|
|
390
|
+
end tell`;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* set-row-field: drive a Repeat-dialog text field addressed by the pinned English
|
|
394
|
+
* LABEL sharing its row — the same discrimination law as
|
|
395
|
+
* {@link axSetGroupNumberScript}, applied to a field that lives on the dialog
|
|
396
|
+
* SHELL rather than in the cadence group.
|
|
397
|
+
*
|
|
398
|
+
* Its one caller is the "and start [N] days earlier" offset the "Add deadlines"
|
|
399
|
+
* checkbox reveals, which shipped as `text field 1` of the shell. That address
|
|
400
|
+
* was the HXPC1 error class exactly: a value-bearing text field picked by index
|
|
401
|
+
* out of a STATE-DEPENDENT tree (the field does not exist at all until the
|
|
402
|
+
* checkbox is ticked), verified only by re-reading the same index it wrote. It
|
|
403
|
+
* happened to be right on 3.23 — measured, the shell carries 0 direct text fields
|
|
404
|
+
* with deadlines off and exactly 1 with them on, whether or not reminders are also
|
|
405
|
+
* on (CGRD1 §B census) — but nothing in the address said so, and an AX tree is an
|
|
406
|
+
* undocumented private surface that may add a second field in any release.
|
|
407
|
+
*
|
|
408
|
+
* The label anchor is `days earlier` (y=413 against the field's y=409, CGRD1 §B).
|
|
409
|
+
* A missing label, or anything other than exactly one field on its row, FAILS
|
|
410
|
+
* CLOSED naming the shell's whole text-field inventory. The write is the
|
|
411
|
+
* {@link axSetValueScript} closed loop.
|
|
412
|
+
*/
|
|
413
|
+
export function axSetRowFieldScript(containerPath, rowLabel, value, attempts = 3, rowTolerance = ROW_TOLERANCE) {
|
|
414
|
+
const v = escapeAppleScript(value);
|
|
415
|
+
const label = escapeAppleScript(rowLabel);
|
|
416
|
+
const n = Math.max(1, Math.trunc(attempts));
|
|
417
|
+
const tol = Math.max(1, Math.trunc(rowTolerance));
|
|
418
|
+
return `${AX_CADENCE_HANDLERS}
|
|
419
|
+
|
|
420
|
+
${SE}
|
|
421
|
+
set c to (${containerPath})
|
|
422
|
+
set tf to my rfField(c, "${label}", ${tol})
|
|
423
|
+
repeat ${n} times
|
|
424
|
+
set focused of tf to true
|
|
425
|
+
delay 0.15
|
|
426
|
+
keystroke "${v}"
|
|
427
|
+
delay 0.1
|
|
428
|
+
key code 48
|
|
429
|
+
delay 0.2
|
|
430
|
+
try
|
|
431
|
+
if ((value of tf) as text) is "${v}" then return "OK"
|
|
432
|
+
end try
|
|
433
|
+
delay 0.3
|
|
434
|
+
end repeat
|
|
435
|
+
error "the \\"${label}\\" field did not hold value \\"${v}\\" after ${n} attempt(s); last shown: " & ((value of tf) as text)
|
|
436
|
+
end tell`;
|
|
437
|
+
}
|
|
438
|
+
/** AppleScript list literal of quoted strings. */
|
|
439
|
+
function asList(values) {
|
|
440
|
+
return `{${values.map((v) => `"${escapeAppleScript(v)}"`).join(", ")}}`;
|
|
441
|
+
}
|
|
442
|
+
/** The intended value(s) as the mismatch report should read them. */
|
|
443
|
+
function intendedText(control) {
|
|
444
|
+
if (control.expectedLabel !== undefined)
|
|
445
|
+
return control.expectedLabel;
|
|
446
|
+
return control.expected.map((v) => `\\"${escapeAppleScript(v)}\\"`).join(" or ");
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* audit-dialog: RE-READ EVERY CONTROL THIS DRIVE SET, through each control's own
|
|
450
|
+
* discriminated address, and refuse to commit if any one of them does not hold
|
|
451
|
+
* the value the drive intended.
|
|
452
|
+
*
|
|
453
|
+
* This exists because a per-step read-back is SELF-REFERENTIAL. Every setter here
|
|
454
|
+
* confirms its write by re-reading the element it addressed, so it proves the
|
|
455
|
+
* keystrokes landed where they were aimed — and nothing else. The #589 wrong-field
|
|
456
|
+
* write reported OK for exactly that reason: the interval drive typed into the
|
|
457
|
+
* ends-count field, then read the ends-count field back and found its own number
|
|
458
|
+
* sitting there. The address was wrong; a read-back through the same address
|
|
459
|
+
* cannot see that.
|
|
460
|
+
*
|
|
461
|
+
* The audit is the outside view. It is assembled from the recipe's OWN step list
|
|
462
|
+
* (so no control the recipe drives can be left out of the audit by omission) and
|
|
463
|
+
* runs as the last step before the OK press, comparing the dialog's complete
|
|
464
|
+
* intended state against what the dialog actually shows: frequency, the
|
|
465
|
+
* after-completion cadence unit, interval, ends bound and its count, the
|
|
466
|
+
* deadline/reminder checkboxes, the start-days-earlier offset, the weekday set,
|
|
467
|
+
* the monthly/yearly anchor pop-ups and the 3.23 first-occurrence pop-up. A
|
|
468
|
+
* mismatch is an `error` naming EVERY differing control with both values, which
|
|
469
|
+
* aborts the drive fail-closed BEFORE the commit and runs the standard clean-abort
|
|
470
|
+
* path — nothing reaches the database.
|
|
471
|
+
*
|
|
472
|
+
* Deterministic throughout: the cadence group is settled on its own shape
|
|
473
|
+
* signature (the BEEP1 two-agreeing-reads gate), never on a sleep, and every field
|
|
474
|
+
* is found by its label row rather than by index ({@link AX_CADENCE_HANDLERS}).
|
|
475
|
+
*
|
|
476
|
+
* The dialog's three `AXDateTimeArea` controls are audited separately — their
|
|
477
|
+
* values are NSDates no System Events read can reach, so they ride
|
|
478
|
+
* {@link axAuditDateAreasScript} through the same ObjC bridge that writes them.
|
|
479
|
+
*/
|
|
480
|
+
export function axAuditDialogScript(spec, rowTolerance = ROW_TOLERANCE) {
|
|
481
|
+
const tol = Math.max(1, Math.trunc(rowTolerance));
|
|
482
|
+
const body = spec.controls
|
|
483
|
+
.map((c, i) => {
|
|
484
|
+
const name = escapeAppleScript(c.label);
|
|
485
|
+
const want = asList(c.expected);
|
|
486
|
+
const intended = intendedText(c);
|
|
487
|
+
const miss = `set end of bad to "${name} (intended ${intended}, dialog shows \\"" & v${i} & "\\")"`;
|
|
488
|
+
switch (c.kind) {
|
|
489
|
+
case "popup":
|
|
490
|
+
return ` set v${i} to "(unreadable)"
|
|
491
|
+
try
|
|
492
|
+
set v${i} to (value of (${c.path ?? ""})) as text
|
|
493
|
+
end try
|
|
494
|
+
if not (my aqAny(v${i}, ${want})) then ${miss}`;
|
|
495
|
+
case "occurrence-popup":
|
|
496
|
+
return ` set v${i} to "(unreadable)"
|
|
497
|
+
try
|
|
498
|
+
set v${i} to (value of (${c.path ?? ""})) as text
|
|
499
|
+
end try
|
|
500
|
+
set d${i} to my aqYMD(v${i})
|
|
501
|
+
if d${i} is missing value then
|
|
502
|
+
${miss}
|
|
503
|
+
else if not (my aqAny(d${i}, ${want})) then
|
|
504
|
+
set end of bad to "${name} (intended ${intended}, dialog shows \\"" & v${i} & "\\" = " & d${i} & ")"
|
|
505
|
+
end if`;
|
|
506
|
+
case "checkbox":
|
|
507
|
+
return ` set v${i} to "(unreadable)"
|
|
508
|
+
try
|
|
509
|
+
set v${i} to ((value of (${c.path ?? ""})) as integer) as text
|
|
510
|
+
end try
|
|
511
|
+
if not (my aqAny(v${i}, ${want})) then set end of bad to "${name} (intended ${intended}, dialog shows " & (my aqTick(v${i})) & ")"`;
|
|
512
|
+
case "group-number":
|
|
513
|
+
return ` set v${i} to "(unreadable)"
|
|
514
|
+
try
|
|
515
|
+
set v${i} to ((value of (my cgField(g, "${c.numberTarget ?? "interval"}", ${tol}))) as text)
|
|
516
|
+
end try
|
|
517
|
+
if not (my aqAny(v${i}, ${want})) then ${miss}`;
|
|
518
|
+
case "row-field":
|
|
519
|
+
return ` set v${i} to "(unreadable)"
|
|
520
|
+
try
|
|
521
|
+
set v${i} to ((value of (my rfField(sh, "${escapeAppleScript(c.rowLabel ?? "")}", ${tol}))) as text)
|
|
522
|
+
end try
|
|
523
|
+
if not (my aqAny(v${i}, ${want})) then ${miss}`;
|
|
524
|
+
case "weekdays":
|
|
525
|
+
return ` set got${i} to {}
|
|
526
|
+
repeat with k from ${Math.max(1, Math.trunc(c.weekdayBase ?? 2))} to (count of pop up buttons of g)
|
|
527
|
+
set end of got${i} to ((value of pop up button k of g) as text)
|
|
528
|
+
end repeat
|
|
529
|
+
set v${i} to my aqJoin(got${i}, ",")
|
|
530
|
+
set off${i} to false
|
|
531
|
+
repeat with w in ${want}
|
|
532
|
+
if not (my aqAny(w as text, got${i})) then set off${i} to true
|
|
533
|
+
end repeat
|
|
534
|
+
repeat with w in got${i}
|
|
535
|
+
if not (my aqAny(w as text, ${want})) then set off${i} to true
|
|
536
|
+
end repeat
|
|
537
|
+
if off${i} then ${miss}`;
|
|
538
|
+
}
|
|
539
|
+
})
|
|
540
|
+
.join("\n");
|
|
541
|
+
return `${AX_CADENCE_HANDLERS}
|
|
542
|
+
|
|
543
|
+
on aqAny(v, lst)
|
|
544
|
+
repeat with c in lst
|
|
545
|
+
if (v as text) is (c as text) then return true
|
|
546
|
+
end repeat
|
|
547
|
+
return false
|
|
548
|
+
end aqAny
|
|
549
|
+
|
|
550
|
+
on aqJoin(lst, sep)
|
|
551
|
+
set out to ""
|
|
552
|
+
repeat with x in lst
|
|
553
|
+
if out is not "" then set out to out & sep
|
|
554
|
+
set out to out & (x as text)
|
|
555
|
+
end repeat
|
|
556
|
+
return out
|
|
557
|
+
end aqJoin
|
|
558
|
+
|
|
559
|
+
on aqTick(v)
|
|
560
|
+
if (v as text) is "1" then return "checked"
|
|
561
|
+
if (v as text) is "0" then return "unchecked"
|
|
562
|
+
return "\\"" & (v as text) & "\\""
|
|
563
|
+
end aqTick
|
|
564
|
+
|
|
565
|
+
on aqPad2(n)
|
|
566
|
+
set s to (n as integer) as text
|
|
567
|
+
if (length of s) < 2 then set s to "0" & s
|
|
568
|
+
return s
|
|
569
|
+
end aqPad2
|
|
570
|
+
|
|
571
|
+
on aqYMD(t)
|
|
572
|
+
-- Occurrence-pop-up titles are LOCALIZED ("Sun, Jul 12, 2026"), so the match is
|
|
573
|
+
-- made by PARSING the title to a date and comparing calendar components — never
|
|
574
|
+
-- by rebuilding the app's display string (the axSelectNextOccurrenceScript law).
|
|
575
|
+
set s to t as text
|
|
576
|
+
try
|
|
577
|
+
set d to date s
|
|
578
|
+
return ((year of d) as text) & "-" & my aqPad2((month of d) as integer) & "-" & my aqPad2(day of d)
|
|
579
|
+
end try
|
|
580
|
+
try
|
|
581
|
+
set ofs to offset of ", " in s
|
|
582
|
+
if ofs > 0 then
|
|
583
|
+
set d to date (text (ofs + 2) thru -1 of s)
|
|
584
|
+
return ((year of d) as text) & "-" & my aqPad2((month of d) as integer) & "-" & my aqPad2(day of d)
|
|
585
|
+
end if
|
|
586
|
+
end try
|
|
587
|
+
return missing value
|
|
588
|
+
end aqYMD
|
|
589
|
+
|
|
590
|
+
${SE}
|
|
591
|
+
set sh to (${spec.shell})
|
|
592
|
+
set g to (${spec.group})
|
|
593
|
+
my cgSettle(g)
|
|
594
|
+
set bad to {}
|
|
595
|
+
${body}
|
|
596
|
+
if (count of bad) is 0 then return "OK"
|
|
597
|
+
error "the Repeat dialog does not hold what this drive entered — " & (count of bad) & " control(s) differ: " & my aqJoin(bad, "; ")
|
|
598
|
+
end tell`;
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* The pre-commit audit's DATE-AREA leg. The dialog's first-occurrence, ends-on and
|
|
602
|
+
* reminder controls are `AXDateTimeArea`s whose value is an NSDate — unreachable
|
|
603
|
+
* from System Events — so they are re-read through the SAME ObjC bridge, the same
|
|
604
|
+
* shell-scoped walk and the SAME deterministic `pick` discriminator that
|
|
605
|
+
* {@link axSetDateTimeScript} writes them through. A control the audit cannot find,
|
|
606
|
+
* or one holding a different date/time, throws naming every area the dialog does
|
|
607
|
+
* present (y-position + time-of-day), so the drive aborts before the OK press.
|
|
608
|
+
*/
|
|
609
|
+
export function axAuditDateAreasScript(areas) {
|
|
610
|
+
return `${AX_DATE_AREA_PRELUDE}
|
|
611
|
+
function run(){
|
|
612
|
+
var apps=$.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.culturedcode.ThingsMac');
|
|
613
|
+
if(!apps || apps.count===0) throw new Error('Things not running');
|
|
614
|
+
var app=$.AXUIElementCreateApplication(apps.objectAtIndex(0).processIdentifier);
|
|
615
|
+
var wanted=${JSON.stringify(areas)};
|
|
616
|
+
var cal=$.NSCalendar.currentCalendar;
|
|
617
|
+
var found=[]; try{ var shell=findShell(app); if(shell) collect(shell,'AXDateTimeArea',16,found); }catch(e){ found=[]; }
|
|
618
|
+
var bad=[];
|
|
619
|
+
for(var i=0;i<wanted.length;i++){
|
|
620
|
+
var w=wanted[i];
|
|
621
|
+
var dt=pick(found,w.target);
|
|
622
|
+
if(!dt){ bad.push(w.label+' (intended '+w.spec+', but this dialog state presents no '+w.target+' control among ['+inv(found)+'])'); continue; }
|
|
623
|
+
if(w.spec.indexOf('date:')===0){
|
|
624
|
+
var got=ymdStr(dt,cal), want=w.spec.slice(5);
|
|
625
|
+
if(got!==want) bad.push(w.label+' (intended '+want+', dialog shows '+(got||'(no value)')+')');
|
|
626
|
+
} else {
|
|
627
|
+
var gott=hmStr(dt,cal), p=w.spec.slice(5).split(':'), wantt=(+p[0])+':'+('0'+(+p[1])).slice(-2);
|
|
628
|
+
if(gott!==wantt) bad.push(w.label+' (intended '+wantt+', dialog shows '+(gott||'(no value)')+')');
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if(bad.length) throw new Error('the Repeat dialog does not hold what this drive entered — '+bad.length+' control(s) differ: '+bad.join('; '));
|
|
632
|
+
return 'OK';
|
|
633
|
+
}`;
|
|
634
|
+
}
|
|
124
635
|
/**
|
|
125
636
|
* ensure-checkbox: converge a dialog checkbox to a target state through a
|
|
126
637
|
* DETERMINISTIC CLOSED LOOP (RRD1, determinism doctrine) — never a blind toggle.
|
|
@@ -310,6 +821,9 @@ ${SE}
|
|
|
310
821
|
set theMenu to menu 1 of pu
|
|
311
822
|
set clickedTitle to ""
|
|
312
823
|
set levelsSeen to 0
|
|
824
|
+
set opener to (value of pu) as text
|
|
825
|
+
set sample to ""
|
|
826
|
+
set sampled to 0
|
|
313
827
|
if isToday then
|
|
314
828
|
set nms to name of every menu item of theMenu
|
|
315
829
|
if (count of nms) > 0 then
|
|
@@ -330,6 +844,13 @@ ${SE}
|
|
|
330
844
|
repeat with i from 1 to (count of nms)
|
|
331
845
|
set nm to item i of nms
|
|
332
846
|
if nm is not missing value then
|
|
847
|
+
-- Keep a short sample of what THIS menu actually offered, so a miss can
|
|
848
|
+
-- report the dates the dialog had rather than only the one it lacked.
|
|
849
|
+
if sampled < ${SAMPLE_ITEMS} then
|
|
850
|
+
if sample is not "" then set sample to sample & ", "
|
|
851
|
+
set sample to sample & (nm as text)
|
|
852
|
+
set sampled to sampled + 1
|
|
853
|
+
end if
|
|
333
854
|
set ymd to my parsedYMD(nm)
|
|
334
855
|
if ymd is not missing value then
|
|
335
856
|
if (item 1 of ymd) is wantY and (item 2 of ymd) is wantM and (item 3 of ymd) is wantD then
|
|
@@ -363,7 +884,7 @@ ${SE}
|
|
|
363
884
|
end repeat
|
|
364
885
|
if clickedTitle is "" then
|
|
365
886
|
key code 53
|
|
366
|
-
error "select-next-occurrence: this Repeat dialog offers only the rule's own upcoming occurrences (and today) as the first occurrence, and ${isoDate} is not one of them — searched " & levelsSeen & " level(s) of the Next: menu. Ask for a date the rule actually produces, or change the rule."
|
|
887
|
+
error "select-next-occurrence: this Repeat dialog offers only the rule's own upcoming occurrences (and today) as the first occurrence, and ${isoDate} is not one of them — searched " & levelsSeen & " level(s) of the Next: menu, which opened on \\"" & opener & "\\" and led with: " & sample & ". Ask for a date the rule actually produces, or change the rule."
|
|
367
888
|
end if
|
|
368
889
|
delay 0.4
|
|
369
890
|
set shown to (value of pu) as text
|
|
@@ -373,6 +894,48 @@ ${SE}
|
|
|
373
894
|
return "OK"
|
|
374
895
|
end tell`;
|
|
375
896
|
}
|
|
897
|
+
/**
|
|
898
|
+
* settle-occurrences: let the 3.23 `Next:` pop-up ABSORB the rule change the
|
|
899
|
+
* preceding steps made, before the drive touches the dialog again (NEXTPOP1).
|
|
900
|
+
*
|
|
901
|
+
* MEASURED (golden-v4 / Things 3.23, `research-nextpop1.sh` DIAG3/DIAG4): the
|
|
902
|
+
* dialog recomputes the first-occurrence pop-up — its displayed value AND the
|
|
903
|
+
* menu of occurrences behind it — ASYNCHRONOUSLY. After the yearly anchor was
|
|
904
|
+
* moved from Aug 6 to Aug 20 the control flipped at **t+0.4s** with nothing else
|
|
905
|
+
* driven; when the very next step (the "Add deadlines" checkbox) was pressed
|
|
906
|
+
* inside that window instead, the control NEVER caught up — it still read
|
|
907
|
+
* `Thu, Aug 6, 2026`, and its menu still enumerated the Aug-6 series, six
|
|
908
|
+
* seconds later. A cancelled recompute does not retry.
|
|
909
|
+
*
|
|
910
|
+
* That is what made every deadlined monthly/yearly promote fail closed on 3.23:
|
|
911
|
+
* the anchor drive is followed immediately by the deadline controls, so by the
|
|
912
|
+
* time `select-next-occurrence` opened the menu it was the SEED's series, and
|
|
913
|
+
* the requested date — the rule's own first due date — was genuinely not in it
|
|
914
|
+
* (VMRES1 §4.3, reproduced and explained in NEXTPOP1).
|
|
915
|
+
*
|
|
916
|
+
* The wait is closed-loop in the direction that matters: it exits the moment the
|
|
917
|
+
* control MOVES, which is the case that needs waiting for. When the rule change
|
|
918
|
+
* did not move the first occurrence there is nothing to observe, so the budget
|
|
919
|
+
* bounds it — deliberately over-cautious, since the cost is a fraction of a
|
|
920
|
+
* second and the alternative is a series that starts on the wrong date.
|
|
921
|
+
*/
|
|
922
|
+
export function axSettleOccurrencesScript(popupPath, budgetMs = OCCURRENCE_SETTLE_MS, pollMs = OCCURRENCE_POLL_MS) {
|
|
923
|
+
const poll = Math.max(50, Math.trunc(pollMs)) / 1000;
|
|
924
|
+
const reads = Math.max(1, Math.ceil(Math.max(1, Math.trunc(budgetMs)) / Math.max(50, pollMs)));
|
|
925
|
+
// `before` and `after` are AppleScript's own positional keywords and `now` is
|
|
926
|
+
// taken too — `set before to …` does not even COMPILE (osacompile: "Expected
|
|
927
|
+
// expression but found “to”"), and osascript reports that as a drive failure at
|
|
928
|
+
// run time, mid-dialog. Hence the deliberately dull variable names.
|
|
929
|
+
return `${SE}
|
|
930
|
+
set wasValue to (value of ${popupPath}) as text
|
|
931
|
+
repeat ${reads} times
|
|
932
|
+
delay ${poll}
|
|
933
|
+
set curValue to (value of ${popupPath}) as text
|
|
934
|
+
if curValue is not wasValue then return "moved: " & wasValue & " -> " & curValue
|
|
935
|
+
end repeat
|
|
936
|
+
return "unchanged: " & wasValue
|
|
937
|
+
end tell`;
|
|
938
|
+
}
|
|
376
939
|
/**
|
|
377
940
|
* converge-weekdays: drive the weekly dialog's weekday ROWS onto an exact target
|
|
378
941
|
* set through a deterministic closed loop (RDLG2 — the RRD1 fix).
|
|
@@ -599,6 +1162,141 @@ export function axKeyScript(keys) {
|
|
|
599
1162
|
: `keystroke "${escapeAppleScript(k)}"`);
|
|
600
1163
|
return `tell application "System Events" to tell process "Things3"\n ${lines.join("\n ")}\nend tell`;
|
|
601
1164
|
}
|
|
1165
|
+
/**
|
|
1166
|
+
* type-text: send literal text to whatever control holds focus (HXPC1). The
|
|
1167
|
+
* Move… picker focuses its own filter field the instant it opens, and that field
|
|
1168
|
+
* is NOT addressable as a direct child of the picker window — so there is no
|
|
1169
|
+
* element to hand `set-value`, whose select-all + Tab commit would be wrong for a
|
|
1170
|
+
* search field regardless (a popover filter has no next key view for Tab to move
|
|
1171
|
+
* to). Unlike {@link axKeyScript}, which splits its spec on whitespace and would
|
|
1172
|
+
* drop the spaces out of a multi-word project title, this sends the string as
|
|
1173
|
+
* ONE keystroke. It is deliberately not self-verifying: the `click-picker-row`
|
|
1174
|
+
* step that follows resolves the destination row by name and fails closed when
|
|
1175
|
+
* the filter did not produce it, so a keystroke that landed elsewhere can never
|
|
1176
|
+
* be committed. One stable command shape.
|
|
1177
|
+
*/
|
|
1178
|
+
export function axTypeTextScript(text) {
|
|
1179
|
+
return `${SE}
|
|
1180
|
+
keystroke "${escapeAppleScript(text)}"
|
|
1181
|
+
end tell`;
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* resolve-frame for a control nested inside a CONTENT-TABLE ROW: walk the
|
|
1185
|
+
* table's rows → cells → cell children and return the frame of the one whose
|
|
1186
|
+
* `AXDescription` equals `description` (HXPC1, docs/lab/hxpc1-picker-assert.md
|
|
1187
|
+
* §B0). Same "x y w h" contract as {@link axFrameScript}.
|
|
1188
|
+
*
|
|
1189
|
+
* This exists because the heading row's `…` button — the only content-row
|
|
1190
|
+
* control that carries its own title (`"More. <heading title>"`, the HEADXPROJ
|
|
1191
|
+
* enabler) — sits at `UI element N of cell 1 of row M of the table`, and
|
|
1192
|
+
* `first UI element of <table> whose description is …` searches the table's
|
|
1193
|
+
* DIRECT children only. Those are the rows, which carry no description, so the
|
|
1194
|
+
* shipped one-level spelling matched nothing and the ellipsis drives
|
|
1195
|
+
* (`project.move-heading-to-project`, `project.dissolve-heading`) died at their
|
|
1196
|
+
* own frame resolution before any click — measured on Things 3.23 against a
|
|
1197
|
+
* heading whose button the raw Accessibility API resolves at the same instant.
|
|
1198
|
+
* The row/cell indices are never guessed: every row is walked and the match is
|
|
1199
|
+
* exact, so a heading whose title changed under us fails closed by name.
|
|
1200
|
+
*/
|
|
1201
|
+
export function axRowCellFrameScript(tablePath, description) {
|
|
1202
|
+
const d = escapeAppleScript(description);
|
|
1203
|
+
return `${SE}
|
|
1204
|
+
set t to (${tablePath})
|
|
1205
|
+
repeat with r in rows of t
|
|
1206
|
+
repeat with c in UI elements of r
|
|
1207
|
+
repeat with e in UI elements of c
|
|
1208
|
+
try
|
|
1209
|
+
if ((description of e) as text) is "${d}" then
|
|
1210
|
+
set _p to position of e
|
|
1211
|
+
set _s to size of e
|
|
1212
|
+
return ((item 1 of _p) as text) & " " & ((item 2 of _p) as text) & " " & ((item 1 of _s) as text) & " " & ((item 2 of _s) as text)
|
|
1213
|
+
end if
|
|
1214
|
+
end try
|
|
1215
|
+
end repeat
|
|
1216
|
+
end repeat
|
|
1217
|
+
end repeat
|
|
1218
|
+
error "no row of this project's list exposes \\"${d}\\" — the heading may have been renamed, moved or deleted since it was read"
|
|
1219
|
+
end tell`;
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* resolve-frame for the Move… picker ROW carrying an exact project title — the
|
|
1223
|
+
* step that replaced the recipe's blind Return (HXPC1,
|
|
1224
|
+
* docs/lab/hxpc1-picker-assert.md §B).
|
|
1225
|
+
*
|
|
1226
|
+
* The picker exposes no `AXSelected` / `AXFocused` / `AXHighlighted` on any row
|
|
1227
|
+
* (measured — only its filter field is focused), so there is nothing to read
|
|
1228
|
+
* back from a keyboard commit and no way to assert what Return would take. What
|
|
1229
|
+
* it does expose is one `AXUnknown` per row whose `AXDescription` IS the project
|
|
1230
|
+
* title, and — whenever the filter holds text — a trailing
|
|
1231
|
+
* `New Project "<typed text>"` row that CREATES a project when committed. That
|
|
1232
|
+
* row is what the blind Return took whenever the destination was missing from
|
|
1233
|
+
* the picker, which an ordinary database-resolved destination reaches: a
|
|
1234
|
+
* COMPLETED or CANCELED project appears nowhere in the picker, so the drive
|
|
1235
|
+
* minted a second project of the same title and moved the heading into it
|
|
1236
|
+
* (measured 3.23: projects 14 → 15, heading re-parented to the new row).
|
|
1237
|
+
*
|
|
1238
|
+
* So the commit is addressed instead of guessed. The script requires:
|
|
1239
|
+
* - the picker to be the window it claims (its `AXIdentifier` begins
|
|
1240
|
+
* `MovePopUpDialog-`) — a positive identity check, so a different detached
|
|
1241
|
+
* window can never be clicked into;
|
|
1242
|
+
* - EXACTLY ONE row whose description equals the destination title (the
|
|
1243
|
+
* New-Project row's description is the quoted form, so an exact match cannot
|
|
1244
|
+
* hit it);
|
|
1245
|
+
* - that row's centre to lie inside the picker's own scroll area — the CNCAC1
|
|
1246
|
+
* off-screen hazard, where a row scrolled past the fold still resolves a
|
|
1247
|
+
* frame and a click at it lands on the desktop.
|
|
1248
|
+
* Any miss FAILS CLOSED naming the destination and listing every row the picker
|
|
1249
|
+
* actually offered, so the caller learns what the app was willing to move to.
|
|
1250
|
+
*/
|
|
1251
|
+
export function axPickerRowFrameScript(pickerPath, title) {
|
|
1252
|
+
const t = escapeAppleScript(title);
|
|
1253
|
+
return `${SE}
|
|
1254
|
+
set w to (${pickerPath})
|
|
1255
|
+
set pickerId to ""
|
|
1256
|
+
try
|
|
1257
|
+
set pickerId to (value of attribute "AXIdentifier" of w) as text
|
|
1258
|
+
end try
|
|
1259
|
+
if pickerId does not start with "MovePopUpDialog-" then
|
|
1260
|
+
error "the front dialog is not the Move… project picker (window id \\"" & pickerId & "\\") — nothing was committed"
|
|
1261
|
+
end if
|
|
1262
|
+
-- positional-ok: the picker window holds exactly one scroll area (MEASURED,
|
|
1263
|
+
-- HXPC1 §B2: "direct text fields=0 scroll areas=1"), and the window's own
|
|
1264
|
+
-- AXIdentifier was checked above, so this is a container handle inside an
|
|
1265
|
+
-- already-identified window — the ROW is addressed by exact title below.
|
|
1266
|
+
set sa to scroll area 1 of w
|
|
1267
|
+
set saPos to position of sa
|
|
1268
|
+
set saSize to size of sa
|
|
1269
|
+
set saTop to item 2 of saPos
|
|
1270
|
+
set saBottom to saTop + (item 2 of saSize)
|
|
1271
|
+
set hits to {}
|
|
1272
|
+
set offered to ""
|
|
1273
|
+
repeat with i from 1 to (count of UI elements of sa)
|
|
1274
|
+
set e to UI element i of sa
|
|
1275
|
+
set d to ""
|
|
1276
|
+
try
|
|
1277
|
+
set d to (description of e) as text
|
|
1278
|
+
end try
|
|
1279
|
+
if d is not "" and (role of e) is "AXUnknown" then
|
|
1280
|
+
set offered to offered & " [" & d & "]"
|
|
1281
|
+
if d is "${t}" then set end of hits to e
|
|
1282
|
+
end if
|
|
1283
|
+
end repeat
|
|
1284
|
+
if (count of hits) is 0 then
|
|
1285
|
+
error "the Move… picker offers no project named \\"${t}\\" — it offered:" & offered & ". Committing here would have created a new project with that name instead of moving into the existing one. A completed or canceled project is not offered by this picker."
|
|
1286
|
+
end if
|
|
1287
|
+
if (count of hits) > 1 then
|
|
1288
|
+
error "the Move… picker offers " & (count of hits) & " rows named \\"${t}\\" — it offered:" & offered
|
|
1289
|
+
end if
|
|
1290
|
+
set row1 to item 1 of hits
|
|
1291
|
+
set rp to position of row1
|
|
1292
|
+
set rs to size of row1
|
|
1293
|
+
set cy to (item 2 of rp) + ((item 2 of rs) / 2)
|
|
1294
|
+
if cy < saTop or cy > saBottom then
|
|
1295
|
+
error "the \\"${t}\\" row is scrolled out of the Move… picker's visible list, so clicking it would land outside the picker — narrow the destination or scroll it into view"
|
|
1296
|
+
end if
|
|
1297
|
+
return ((item 1 of rp) as text) & " " & ((item 2 of rp) as text) & " " & ((item 1 of rs) as text) & " " & ((item 2 of rs) as text)
|
|
1298
|
+
end tell`;
|
|
1299
|
+
}
|
|
602
1300
|
/** The abort keystroke sent to dismiss a half-open sheet/popover on failure. */
|
|
603
1301
|
export function axAbortScript() {
|
|
604
1302
|
return `tell application "System Events" to key code 53`; // Escape
|
|
@@ -619,6 +1317,9 @@ export function axAbortScript() {
|
|
|
619
1317
|
export function axCloseReopenActivateScript() {
|
|
620
1318
|
return `tell application "Things3"
|
|
621
1319
|
try
|
|
1320
|
+
-- positional-ok: an APP-LEVEL command to the Things scripting dictionary, not
|
|
1321
|
+
-- an Accessibility element path — "close the front window", whichever it is,
|
|
1322
|
+
-- which is the whole intent of the maneuver (a stuck sheet goes with it).
|
|
622
1323
|
close window 1
|
|
623
1324
|
end try
|
|
624
1325
|
reopen
|
|
@@ -640,6 +1341,8 @@ export function axSheetOpenScript() {
|
|
|
640
1341
|
return `${SE}
|
|
641
1342
|
set sheetOpen to false
|
|
642
1343
|
try
|
|
1344
|
+
-- positional-ok: an EXISTENCE probe over the one attached sheet a window can
|
|
1345
|
+
-- present; no element is read or written through this path.
|
|
643
1346
|
if (exists sheet 1 of (first window whose subrole is "AXStandardWindow")) then set sheetOpen to true
|
|
644
1347
|
end try
|
|
645
1348
|
try
|
|
@@ -807,45 +1510,7 @@ function clickPointCommand(x, y, label) {
|
|
|
807
1510
|
* calendar date at midnight). One stable JXA shape.
|
|
808
1511
|
*/
|
|
809
1512
|
export function axSetDateTimeScript(spec, target) {
|
|
810
|
-
return
|
|
811
|
-
function attr(el,name){ var out=Ref(); if($.AXUIElementCopyAttributeValue(el,$(name),out)!==0) return null; return ObjC.castRefToObject(out[0]); }
|
|
812
|
-
function rolestr(el){ var v=attr(el,'AXRole'); return v? v.js : ''; }
|
|
813
|
-
function kids(el){ var c=attr(el,'AXChildren'); if(!c) return []; var a=[]; for(var i=0;i<c.count;i++) a.push(c.objectAtIndex(i)); return a; }
|
|
814
|
-
function collect(el,role,depth,out){ if(depth<0) return; if(rolestr(el)===role) out.push(el); var ks=kids(el); for(var i=0;i<ks.length;i++) collect(ks[i],role,depth-1,out); }
|
|
815
|
-
function subrole(el){ var v=attr(el,'AXSubrole'); return v? v.js : ''; }
|
|
816
|
-
function windowsOf(el){ var c=attr(el,'AXWindows'); if(!c) return []; var a=[]; for(var i=0;i<c.count;i++) a.push(c.objectAtIndex(i)); return a; }
|
|
817
|
-
function sizeWH(el){ var s=attr(el,'AXSize'); if(!s) return null; var d=ObjC.castRefToObject($.CFCopyDescription(s)).js; var mw=String(d).match(/w:([-0-9.]+)/); var mh=String(d).match(/h:([-0-9.]+)/); return (mw&&mh)? {w:+mw[1], h:+mh[1]} : null; }
|
|
818
|
-
// Resolve the Repeat-dialog SHELL so the AXDateTimeArea collect walks only its
|
|
819
|
-
// small subtree — never the app-wide tree, whose main-window list content is the
|
|
820
|
-
// 4.4s app-root descent PERF2 removed (docs/lab/perf2-step-latency.md). The dialog
|
|
821
|
-
// presents in TWO shapes (ui-recipes DIALOG_SHELLS, UIC4-a), tried in the SAME
|
|
822
|
-
// priority order the System-Events pathCandidates use: an attached AXSheet on the
|
|
823
|
-
// standard window (Things frontmost), then a detached top-level AXUnknown window
|
|
824
|
-
// that is not the 40x40 utility window (Things backgrounded). null when neither is
|
|
825
|
-
// present — the caller then falls through to the same named "presents 0 date
|
|
826
|
-
// area(s)" error the app-root walk threw when the dialog was absent.
|
|
827
|
-
function findShell(app){
|
|
828
|
-
var wins=windowsOf(app);
|
|
829
|
-
for(var i=0;i<wins.length;i++){ if(subrole(wins[i])==='AXStandardWindow'){ var sh=[]; collect(wins[i],'AXSheet',3,sh); if(sh.length) return sh[0]; } }
|
|
830
|
-
for(var i=0;i<wins.length;i++){ if(subrole(wins[i])==='AXUnknown'){ var wh=sizeWH(wins[i]); if(!wh || !(wh.w===40 && wh.h===40)) return wins[i]; } }
|
|
831
|
-
return null;
|
|
832
|
-
}
|
|
833
|
-
function posY(el){ var p=attr(el,'AXPosition'); if(!p) return 0; var d=ObjC.castRefToObject($.CFCopyDescription(p)).js; var m=String(d).match(/y:([-0-9.]+)/); return m? +m[1] : 0; }
|
|
834
|
-
function timeOfDay(el){ var v=attr(el,'AXValue'); if(!v) return -1; var cal=$.NSCalendar.currentCalendar; return cal.componentFromDate($.NSCalendarUnitHour,v)*60 + cal.componentFromDate($.NSCalendarUnitMinute,v); }
|
|
835
|
-
function pick(areas,target){
|
|
836
|
-
if(areas.length===0) return null;
|
|
837
|
-
var sorted=areas.slice().sort(function(a,b){ return posY(a)-posY(b); });
|
|
838
|
-
if(target==='reminder'){
|
|
839
|
-
var timed=sorted.filter(function(a){ return timeOfDay(a)>0; });
|
|
840
|
-
return timed.length? timed[timed.length-1] : sorted[sorted.length-1];
|
|
841
|
-
}
|
|
842
|
-
var midnight=sorted.filter(function(a){ return timeOfDay(a)===0; });
|
|
843
|
-
if(midnight.length===0) midnight=sorted;
|
|
844
|
-
return target==='ends' ? midnight[midnight.length-1] : midnight[0];
|
|
845
|
-
}
|
|
846
|
-
function inv(areas){ var s=[]; for(var i=0;i<areas.length;i++){ s.push('#'+i+'(y='+Math.round(posY(areas[i]))+',tod='+timeOfDay(areas[i])+')'); } return areas.length? s.join(' ') : '(none)'; }
|
|
847
|
-
function ymdStr(el,cal){ var v=attr(el,'AXValue'); if(!v) return null; var y=cal.componentFromDate($.NSCalendarUnitYear,v), m=cal.componentFromDate($.NSCalendarUnitMonth,v), dd=cal.componentFromDate($.NSCalendarUnitDay,v); return y+'-'+('0'+m).slice(-2)+'-'+('0'+dd).slice(-2); }
|
|
848
|
-
function hmStr(el,cal){ var v=attr(el,'AXValue'); if(!v) return null; var h=cal.componentFromDate($.NSCalendarUnitHour,v), mi=cal.componentFromDate($.NSCalendarUnitMinute,v); return h+':'+('0'+mi).slice(-2); }
|
|
1513
|
+
return `${AX_DATE_AREA_PRELUDE}
|
|
849
1514
|
function run(){
|
|
850
1515
|
var apps=$.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.culturedcode.ThingsMac');
|
|
851
1516
|
if(!apps || apps.count===0) throw new Error('Things not running');
|
|
@@ -883,7 +1548,9 @@ function run(){
|
|
|
883
1548
|
$.NSThread.sleepForTimeInterval(0.2);
|
|
884
1549
|
// READ-BACK: a control can accept the AX write (err 0) yet reject the value —
|
|
885
1550
|
// the macOS error beep — leaving its prior/default value. Fail the step loudly
|
|
886
|
-
// rather than let a garbled commit verify as ok (YANCH1 #493).
|
|
1551
|
+
// rather than let a garbled commit verify as ok (YANCH1 #493). Like every
|
|
1552
|
+
// per-step read-back here it is SELF-REFERENTIAL (it re-reads the area it just
|
|
1553
|
+
// picked), so the pre-commit audit re-checks it from the outside.
|
|
887
1554
|
if(spec.indexOf('date:')===0){
|
|
888
1555
|
var got=ymdStr(dt,cal); var want=spec.slice(5);
|
|
889
1556
|
if(got!==want) throw new Error('set-datetime '+target+' rejected: the control committed '+(got||'(no value)')+', not the requested '+want+' — the write did not take');
|
|
@@ -894,6 +1561,55 @@ function run(){
|
|
|
894
1561
|
return 'OK';
|
|
895
1562
|
}`;
|
|
896
1563
|
}
|
|
1564
|
+
/**
|
|
1565
|
+
* The shared ObjC-bridge prelude for every `AXDateTimeArea` read or write: the
|
|
1566
|
+
* attribute helpers, the DIALOG-SHELL resolver (so the walk stays inside the
|
|
1567
|
+
* dialog's small subtree — the app-root descent PERF2 removed cost ~4.4s on a busy
|
|
1568
|
+
* host), and the deterministic {@link pick} target discriminator. The write
|
|
1569
|
+
* ({@link axSetDateTimeScript}) and the pre-commit read
|
|
1570
|
+
* ({@link axAuditDateAreasScript}) MUST agree on which area is which, so they
|
|
1571
|
+
* share one definition rather than two that can drift apart.
|
|
1572
|
+
*/
|
|
1573
|
+
const AX_DATE_AREA_PRELUDE = `ObjC.import('Foundation'); ObjC.import('AppKit'); ObjC.import('ApplicationServices');
|
|
1574
|
+
function attr(el,name){ var out=Ref(); if($.AXUIElementCopyAttributeValue(el,$(name),out)!==0) return null; return ObjC.castRefToObject(out[0]); }
|
|
1575
|
+
function rolestr(el){ var v=attr(el,'AXRole'); return v? v.js : ''; }
|
|
1576
|
+
function kids(el){ var c=attr(el,'AXChildren'); if(!c) return []; var a=[]; for(var i=0;i<c.count;i++) a.push(c.objectAtIndex(i)); return a; }
|
|
1577
|
+
function collect(el,role,depth,out){ if(depth<0) return; if(rolestr(el)===role) out.push(el); var ks=kids(el); for(var i=0;i<ks.length;i++) collect(ks[i],role,depth-1,out); }
|
|
1578
|
+
function subrole(el){ var v=attr(el,'AXSubrole'); return v? v.js : ''; }
|
|
1579
|
+
function windowsOf(el){ var c=attr(el,'AXWindows'); if(!c) return []; var a=[]; for(var i=0;i<c.count;i++) a.push(c.objectAtIndex(i)); return a; }
|
|
1580
|
+
function sizeWH(el){ var s=attr(el,'AXSize'); if(!s) return null; var d=ObjC.castRefToObject($.CFCopyDescription(s)).js; var mw=String(d).match(/w:([-0-9.]+)/); var mh=String(d).match(/h:([-0-9.]+)/); return (mw&&mh)? {w:+mw[1], h:+mh[1]} : null; }
|
|
1581
|
+
// Resolve the Repeat-dialog SHELL so the AXDateTimeArea collect walks only its
|
|
1582
|
+
// small subtree — never the app-wide tree, whose main-window list content is the
|
|
1583
|
+
// 4.4s app-root descent PERF2 removed (docs/lab/perf2-step-latency.md). The dialog
|
|
1584
|
+
// presents in TWO shapes (ui-recipes DIALOG_SHELLS, UIC4-a), tried in the SAME
|
|
1585
|
+
// priority order the System-Events pathCandidates use: an attached AXSheet on the
|
|
1586
|
+
// standard window (Things frontmost), then a detached top-level AXUnknown window
|
|
1587
|
+
// that is not the 40x40 utility window (Things backgrounded). null when neither is
|
|
1588
|
+
// present — the caller then falls through to the same named "presents 0 date
|
|
1589
|
+
// area(s)" error the app-root walk threw when the dialog was absent.
|
|
1590
|
+
function findShell(app){
|
|
1591
|
+
var wins=windowsOf(app);
|
|
1592
|
+
for(var i=0;i<wins.length;i++){ if(subrole(wins[i])==='AXStandardWindow'){ var sh=[]; collect(wins[i],'AXSheet',3,sh); if(sh.length) return sh[0]; } }
|
|
1593
|
+
for(var i=0;i<wins.length;i++){ if(subrole(wins[i])==='AXUnknown'){ var wh=sizeWH(wins[i]); if(!wh || !(wh.w===40 && wh.h===40)) return wins[i]; } }
|
|
1594
|
+
return null;
|
|
1595
|
+
}
|
|
1596
|
+
function posY(el){ var p=attr(el,'AXPosition'); if(!p) return 0; var d=ObjC.castRefToObject($.CFCopyDescription(p)).js; var m=String(d).match(/y:([-0-9.]+)/); return m? +m[1] : 0; }
|
|
1597
|
+
function timeOfDay(el){ var v=attr(el,'AXValue'); if(!v) return -1; var cal=$.NSCalendar.currentCalendar; return cal.componentFromDate($.NSCalendarUnitHour,v)*60 + cal.componentFromDate($.NSCalendarUnitMinute,v); }
|
|
1598
|
+
function pick(areas,target){
|
|
1599
|
+
if(areas.length===0) return null;
|
|
1600
|
+
var sorted=areas.slice().sort(function(a,b){ return posY(a)-posY(b); });
|
|
1601
|
+
if(target==='reminder'){
|
|
1602
|
+
var timed=sorted.filter(function(a){ return timeOfDay(a)>0; });
|
|
1603
|
+
return timed.length? timed[timed.length-1] : sorted[sorted.length-1];
|
|
1604
|
+
}
|
|
1605
|
+
var midnight=sorted.filter(function(a){ return timeOfDay(a)===0; });
|
|
1606
|
+
if(midnight.length===0) midnight=sorted;
|
|
1607
|
+
return target==='ends' ? midnight[midnight.length-1] : midnight[0];
|
|
1608
|
+
}
|
|
1609
|
+
function inv(areas){ var s=[]; for(var i=0;i<areas.length;i++){ s.push('#'+i+'(y='+Math.round(posY(areas[i]))+',tod='+timeOfDay(areas[i])+')'); } return areas.length? s.join(' ') : '(none)'; }
|
|
1610
|
+
function ymdStr(el,cal){ var v=attr(el,'AXValue'); if(!v) return null; var y=cal.componentFromDate($.NSCalendarUnitYear,v), m=cal.componentFromDate($.NSCalendarUnitMonth,v), dd=cal.componentFromDate($.NSCalendarUnitDay,v); return y+'-'+('0'+m).slice(-2)+'-'+('0'+dd).slice(-2); }
|
|
1611
|
+
function hmStr(el,cal){ var v=attr(el,'AXValue'); if(!v) return null; var h=cal.componentFromDate($.NSCalendarUnitHour,v), mi=cal.componentFromDate($.NSCalendarUnitMinute,v); return h+':'+('0'+mi).slice(-2); }
|
|
1612
|
+
`;
|
|
897
1613
|
/**
|
|
898
1614
|
* The converge-weekdays step encodes both of its inputs in `value` as
|
|
899
1615
|
* `"<base>|<Weekday>,<Weekday>…"`: the base is the group pop-up index of the
|
|
@@ -1051,6 +1767,33 @@ export function commandForStep(step, targetUuid) {
|
|
|
1051
1767
|
label: step.label,
|
|
1052
1768
|
script: axSetValueScript(step.path ?? "", step.value ?? ""),
|
|
1053
1769
|
};
|
|
1770
|
+
case "set-group-number":
|
|
1771
|
+
return {
|
|
1772
|
+
primitive: "set-group-number",
|
|
1773
|
+
label: step.label,
|
|
1774
|
+
script: axSetGroupNumberScript(step.path ?? "", step.numberTarget ?? "interval", step.value ?? ""),
|
|
1775
|
+
};
|
|
1776
|
+
case "set-row-field":
|
|
1777
|
+
return {
|
|
1778
|
+
primitive: "set-row-field",
|
|
1779
|
+
label: step.label,
|
|
1780
|
+
script: axSetRowFieldScript(step.path ?? "", step.rowLabel ?? "", step.value ?? ""),
|
|
1781
|
+
};
|
|
1782
|
+
case "audit-dialog":
|
|
1783
|
+
// Compiled by driveDialogAudit, which resolves the live dialog shell and the
|
|
1784
|
+
// measured shape first (a control's path and its weekday base both depend on
|
|
1785
|
+
// them). This shape exists only so the step renders/compiles uniformly.
|
|
1786
|
+
return {
|
|
1787
|
+
primitive: "audit-dialog",
|
|
1788
|
+
label: step.label,
|
|
1789
|
+
script: "",
|
|
1790
|
+
};
|
|
1791
|
+
case "type-text":
|
|
1792
|
+
return {
|
|
1793
|
+
primitive: "type-text",
|
|
1794
|
+
label: step.label,
|
|
1795
|
+
script: axTypeTextScript(step.value ?? ""),
|
|
1796
|
+
};
|
|
1054
1797
|
case "select-popup":
|
|
1055
1798
|
return {
|
|
1056
1799
|
primitive: "select-popup",
|
|
@@ -1084,6 +1827,12 @@ export function commandForStep(step, targetUuid) {
|
|
|
1084
1827
|
label: step.label,
|
|
1085
1828
|
script: axSelectNextOccurrenceScript(step.path ?? "", step.value ?? ""),
|
|
1086
1829
|
};
|
|
1830
|
+
case "settle-occurrences":
|
|
1831
|
+
return {
|
|
1832
|
+
primitive: "settle-occurrences",
|
|
1833
|
+
label: step.label,
|
|
1834
|
+
script: axSettleOccurrencesScript(step.path ?? ""),
|
|
1835
|
+
};
|
|
1087
1836
|
case "converge-weekdays":
|
|
1088
1837
|
return {
|
|
1089
1838
|
primitive: "converge-weekdays",
|
|
@@ -1117,11 +1866,26 @@ export function commandForStep(step, targetUuid) {
|
|
|
1117
1866
|
case "click-element":
|
|
1118
1867
|
// Phase 1 of the click: read the target's frame. driveClickElement runs
|
|
1119
1868
|
// this, then posts the click at the resolved center and asserts the outcome.
|
|
1869
|
+
// A `rowCellDescription` step resolves its target by walking the addressed
|
|
1870
|
+
// content table's rows/cells instead (the heading `…` button, which sits
|
|
1871
|
+
// three levels below the table a `whose` clause can reach).
|
|
1872
|
+
return {
|
|
1873
|
+
primitive: "resolve-frame",
|
|
1874
|
+
label: step.label,
|
|
1875
|
+
lang: "applescript",
|
|
1876
|
+
script: step.rowCellDescription !== undefined
|
|
1877
|
+
? axRowCellFrameScript(step.path ?? "", step.rowCellDescription)
|
|
1878
|
+
: axFrameScript(step.path ?? ""),
|
|
1879
|
+
};
|
|
1880
|
+
case "click-picker-row":
|
|
1881
|
+
// Phase 1 of the picker commit: resolve the row carrying the destination's
|
|
1882
|
+
// exact title (identity-checked, uniqueness-checked, on-screen-checked).
|
|
1883
|
+
// driveClickElement then clicks it — the recipe never presses Return.
|
|
1120
1884
|
return {
|
|
1121
1885
|
primitive: "resolve-frame",
|
|
1122
1886
|
label: step.label,
|
|
1123
1887
|
lang: "applescript",
|
|
1124
|
-
script:
|
|
1888
|
+
script: axPickerRowFrameScript(step.path ?? "", step.value ?? ""),
|
|
1125
1889
|
};
|
|
1126
1890
|
case "drag-reorder":
|
|
1127
1891
|
// Composite step: drive() hands it to the sidebar drag driver, which
|
|
@@ -1133,8 +1897,32 @@ export function commandForStep(step, targetUuid) {
|
|
|
1133
1897
|
lang: "javascript",
|
|
1134
1898
|
script: jxaSidebarSnapshotScript(),
|
|
1135
1899
|
};
|
|
1900
|
+
case "chord-reorder":
|
|
1901
|
+
// Composite step: drive() hands it to the heading-chord driver, which
|
|
1902
|
+
// dispatches its own select/chord commands through `run` and asserts the
|
|
1903
|
+
// database between them. This shape only exists so the step renders and
|
|
1904
|
+
// compiles uniformly; the chord it names is the FIRST hop's, and the
|
|
1905
|
+
// driver recomputes every subsequent one from the live order.
|
|
1906
|
+
return chordCommand("up-one");
|
|
1136
1907
|
}
|
|
1137
1908
|
}
|
|
1909
|
+
/**
|
|
1910
|
+
* Recover the message an AppleScript `error "…"` raised from osascript's stderr,
|
|
1911
|
+
* dropping the wrapper osascript adds around it (`<line>:<col>: execution error:
|
|
1912
|
+
* <message> (-1728)`). Returns null when stderr carries no such message, so a
|
|
1913
|
+
* caller can fall back to its own wording. This is what lets a resolver script
|
|
1914
|
+
* REFUSE with a sentence the operator can act on — "the Move… picker offers no
|
|
1915
|
+
* project named X — it offered […]" — instead of the driver's generic guess.
|
|
1916
|
+
*/
|
|
1917
|
+
function scriptErrorText(stderr) {
|
|
1918
|
+
const raw = stderr.trim();
|
|
1919
|
+
if (raw === "")
|
|
1920
|
+
return null;
|
|
1921
|
+
const marker = raw.lastIndexOf("execution error:");
|
|
1922
|
+
const body = marker >= 0 ? raw.slice(marker + "execution error:".length) : raw;
|
|
1923
|
+
const trimmed = body.replace(/\s*\(-?\d+\)\s*$/, "").trim();
|
|
1924
|
+
return trimmed === "" ? null : trimmed;
|
|
1925
|
+
}
|
|
1138
1926
|
/**
|
|
1139
1927
|
* Execute a `click-element` step: resolve the target's AX frame, synthesize a
|
|
1140
1928
|
* mouse click at its center, then verify the declared post-click outcome. Fails
|
|
@@ -1146,10 +1934,17 @@ async function driveClickElement(step, run) {
|
|
|
1146
1934
|
const frameRes = await run(commandForStep(step, ""), STEP_TIMEOUT_MS);
|
|
1147
1935
|
const center = frameRes.ok ? parseFrameCenter(frameRes.stdout) : null;
|
|
1148
1936
|
if (center === null) {
|
|
1937
|
+
// A resolver that REFUSED (rather than merely failing to find an element)
|
|
1938
|
+
// carries the diagnosis — which row it wanted, and what the surface offered
|
|
1939
|
+
// instead. Prefer it over the generic guess: that text is the whole point of
|
|
1940
|
+
// the picker-row and heading-button resolvers (HXPC1).
|
|
1941
|
+
const named = scriptErrorText(frameRes.stderr);
|
|
1149
1942
|
return {
|
|
1150
1943
|
ok: false,
|
|
1151
|
-
why:
|
|
1152
|
-
|
|
1944
|
+
why: named !== null
|
|
1945
|
+
? `${named} — no click was sent`
|
|
1946
|
+
: "its on-screen position did not resolve — a Things update may have moved the control, " +
|
|
1947
|
+
"or the app is not in the expected state; no click was sent",
|
|
1153
1948
|
};
|
|
1154
1949
|
}
|
|
1155
1950
|
const clickRes = await run(clickPointCommand(center.x, center.y, step.label), STEP_TIMEOUT_MS);
|
|
@@ -1178,6 +1973,121 @@ async function driveClickElement(step, run) {
|
|
|
1178
1973
|
}
|
|
1179
1974
|
return { ok: true };
|
|
1180
1975
|
}
|
|
1976
|
+
/**
|
|
1977
|
+
* Execute the PRE-COMMIT FULL-DIALOG AUDIT step (CGRD1): re-read every control the
|
|
1978
|
+
* drive set — through each control's own discriminated address — and refuse the
|
|
1979
|
+
* commit if any of them does not hold the intended value.
|
|
1980
|
+
*
|
|
1981
|
+
* Three resolutions happen here rather than in the recipe, because only the driver
|
|
1982
|
+
* knows them: WHICH dialog shell is live (attached sheet vs detached editor), which
|
|
1983
|
+
* SHAPE the dialog measured (the +1 group-index fork, and which first-occurrence
|
|
1984
|
+
* control class exists), and therefore which candidate path each control has. A
|
|
1985
|
+
* shell that does not resolve, or a shape-dependent control with no shape probed,
|
|
1986
|
+
* fails closed exactly like every other address here — an unaudited commit is not
|
|
1987
|
+
* an option, because the audit is the only non-self-referential check the drive has.
|
|
1988
|
+
*
|
|
1989
|
+
* The System Events sweep and the ObjC date-area sweep are separate commands
|
|
1990
|
+
* because the dialog's date/time controls hold NSDates that System Events cannot
|
|
1991
|
+
* read; both must pass.
|
|
1992
|
+
*/
|
|
1993
|
+
async function driveDialogAudit(step, run, dialogShape) {
|
|
1994
|
+
const plan = step.audit;
|
|
1995
|
+
if (plan === undefined)
|
|
1996
|
+
return { ok: false, why: "no audit plan compiled (recipe bug)" };
|
|
1997
|
+
// Which of the two dialog shells is live, in the SAME priority order the drive's
|
|
1998
|
+
// own candidate resolution used — so the audit reads the dialog the drive wrote.
|
|
1999
|
+
let shellIndex = -1;
|
|
2000
|
+
for (let i = 0; i < plan.shells.length; i += 1) {
|
|
2001
|
+
const res = await run({
|
|
2002
|
+
primitive: "resolve",
|
|
2003
|
+
label: step.label,
|
|
2004
|
+
script: axResolveScript(plan.shells[i]),
|
|
2005
|
+
}, STEP_TIMEOUT_MS);
|
|
2006
|
+
if (res.ok && res.stdout.trim() === "true") {
|
|
2007
|
+
shellIndex = i;
|
|
2008
|
+
break;
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
if (shellIndex < 0) {
|
|
2012
|
+
return {
|
|
2013
|
+
ok: false,
|
|
2014
|
+
why: "the Repeat dialog could not be re-read before committing (neither the attached sheet " +
|
|
2015
|
+
"nor the detached repeat editor window resolved), so what it holds could not be checked",
|
|
2016
|
+
};
|
|
2017
|
+
}
|
|
2018
|
+
const shell = plan.shells[shellIndex];
|
|
2019
|
+
const group = (plan.groups[shellIndex] ?? plan.groups[0]);
|
|
2020
|
+
const scriptControls = [];
|
|
2021
|
+
const dateAreas = [];
|
|
2022
|
+
for (const raw of plan.controls) {
|
|
2023
|
+
if (raw.onlyShape !== undefined || raw.shaped !== undefined) {
|
|
2024
|
+
if (dialogShape === null)
|
|
2025
|
+
return { ok: false, why: SHAPE_UNPROBED };
|
|
2026
|
+
if (raw.onlyShape !== undefined && raw.onlyShape !== dialogShape)
|
|
2027
|
+
continue;
|
|
2028
|
+
}
|
|
2029
|
+
const override = raw.shaped === undefined ? undefined : raw.shaped[dialogShape ?? "next-popup"];
|
|
2030
|
+
if (raw.shaped !== undefined && override === undefined) {
|
|
2031
|
+
return {
|
|
2032
|
+
ok: false,
|
|
2033
|
+
why: `the audit has no check for "${raw.label}" under the "${dialogShape ?? "unmeasured"}" Repeat dialog (recipe bug)`,
|
|
2034
|
+
};
|
|
2035
|
+
}
|
|
2036
|
+
const control = { ...raw, ...override };
|
|
2037
|
+
if (control.kind === "date-area") {
|
|
2038
|
+
dateAreas.push({
|
|
2039
|
+
label: control.label,
|
|
2040
|
+
target: control.dtTarget ?? "next",
|
|
2041
|
+
spec: control.dtSpec ?? "",
|
|
2042
|
+
});
|
|
2043
|
+
continue;
|
|
2044
|
+
}
|
|
2045
|
+
const candidates = control.pathCandidates;
|
|
2046
|
+
scriptControls.push({
|
|
2047
|
+
label: control.label,
|
|
2048
|
+
kind: control.kind,
|
|
2049
|
+
...(candidates !== undefined && {
|
|
2050
|
+
path: (candidates[shellIndex] ?? candidates[0]),
|
|
2051
|
+
}),
|
|
2052
|
+
...(control.numberTarget !== undefined && { numberTarget: control.numberTarget }),
|
|
2053
|
+
...(control.rowLabel !== undefined && { rowLabel: control.rowLabel }),
|
|
2054
|
+
...(control.weekdayBase !== undefined && { weekdayBase: control.weekdayBase }),
|
|
2055
|
+
expected: control.expected ?? [],
|
|
2056
|
+
...(control.expectedLabel !== undefined && { expectedLabel: control.expectedLabel }),
|
|
2057
|
+
});
|
|
2058
|
+
}
|
|
2059
|
+
if (scriptControls.length > 0) {
|
|
2060
|
+
const res = await run({
|
|
2061
|
+
primitive: "audit-dialog",
|
|
2062
|
+
label: step.label,
|
|
2063
|
+
script: axAuditDialogScript({ shell, group, controls: scriptControls }),
|
|
2064
|
+
}, STEP_TIMEOUT_MS);
|
|
2065
|
+
if (!res.ok || res.stdout.trim() !== "OK") {
|
|
2066
|
+
return { ok: false, why: auditFailureText(res) };
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
if (dateAreas.length > 0) {
|
|
2070
|
+
const res = await run({
|
|
2071
|
+
primitive: "audit-dialog",
|
|
2072
|
+
label: step.label,
|
|
2073
|
+
lang: "javascript",
|
|
2074
|
+
script: axAuditDateAreasScript(dateAreas),
|
|
2075
|
+
}, STEP_TIMEOUT_MS);
|
|
2076
|
+
if (!res.ok || res.stdout.trim() !== "OK") {
|
|
2077
|
+
return { ok: false, why: auditFailureText(res) };
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
return { ok: true };
|
|
2081
|
+
}
|
|
2082
|
+
/** The audit's own refusal text, preferred over the driver's generic guess. */
|
|
2083
|
+
function auditFailureText(res) {
|
|
2084
|
+
const named = scriptErrorText(res.stderr);
|
|
2085
|
+
if (named !== null)
|
|
2086
|
+
return `${named} — nothing was committed`;
|
|
2087
|
+
if (res.timedOut === true)
|
|
2088
|
+
return "the pre-commit dialog audit timed out; nothing was committed";
|
|
2089
|
+
return "the Repeat dialog could not be re-read before committing; nothing was committed";
|
|
2090
|
+
}
|
|
1181
2091
|
async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, reachCache = createReachabilityCache()) {
|
|
1182
2092
|
const done = [];
|
|
1183
2093
|
// The overall-drive WATCHDOG (TRACE1 #487). A drive can outlast the caller's
|
|
@@ -1398,6 +2308,21 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
1398
2308
|
done.push(`${step.label} (${outcome.detail})`);
|
|
1399
2309
|
continue;
|
|
1400
2310
|
}
|
|
2311
|
+
if (step.primitive === "chord-reorder") {
|
|
2312
|
+
// The heading-chord driver runs its own select → chord → DB-assert loop
|
|
2313
|
+
// (ui-chord.ts): every chord is computed from the order it just read, and
|
|
2314
|
+
// a chord that moves nothing (or moves the wrong row) stops the drive
|
|
2315
|
+
// rather than being re-sent. No sheet is involved, so no dismissal clause.
|
|
2316
|
+
if (step.chord === undefined)
|
|
2317
|
+
return partial(step.label, "no chord spec compiled");
|
|
2318
|
+
const spec = step.chord;
|
|
2319
|
+
// the chord ladder depends on the UI state the reveal produced
|
|
2320
|
+
const outcome = await driveHeadingChordReorder(spec, run, aux.headingOrder, (ordinal) => axSelectHeadingRowScript(spec.tablePath, ordinal));
|
|
2321
|
+
if (!outcome.ok)
|
|
2322
|
+
return partial(step.label, outcome.detail);
|
|
2323
|
+
done.push(`${step.label} (${outcome.detail})`);
|
|
2324
|
+
continue;
|
|
2325
|
+
}
|
|
1401
2326
|
// Resolve a candidate-addressed step's effective element before dispatch
|
|
1402
2327
|
// (the sheet-vs-detached-window disjunction). A miss fails closed.
|
|
1403
2328
|
if (step.pathCandidates !== undefined) {
|
|
@@ -1479,9 +2404,26 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
1479
2404
|
done.push(step.label);
|
|
1480
2405
|
continue;
|
|
1481
2406
|
}
|
|
1482
|
-
if (step.primitive === "
|
|
2407
|
+
if (step.primitive === "audit-dialog") {
|
|
2408
|
+
// The last thing before the commit: re-read EVERY control this drive set,
|
|
2409
|
+
// through each control's own discriminated address, and abort fail-closed if
|
|
2410
|
+
// any of them disagrees with what was requested (CGRD1). Every per-step
|
|
2411
|
+
// read-back before this point is self-referential — it re-reads the element it
|
|
2412
|
+
// addressed — so a wrong ADDRESS is invisible until here. A mismatch clears
|
|
2413
|
+
// the dialog through the standard clean-abort path, so nothing is committed.
|
|
2414
|
+
const outcome = await driveDialogAudit(step, run, dialogShape);
|
|
2415
|
+
if (!outcome.ok) {
|
|
2416
|
+
const clear = await clearDialog(run);
|
|
2417
|
+
return partial(step.label, outcome.why ?? "the pre-commit dialog audit failed", clear);
|
|
2418
|
+
}
|
|
2419
|
+
done.push(step.label);
|
|
2420
|
+
continue;
|
|
2421
|
+
}
|
|
2422
|
+
if (step.primitive === "click-element" || step.primitive === "click-picker-row") {
|
|
1483
2423
|
// A mouse click at an AX-resolved frame center (the NATIVE1 primitive),
|
|
1484
|
-
// used only where AXPress is inert (Things' custom `…`/repeat-bar popover)
|
|
2424
|
+
// used only where AXPress is inert (Things' custom `…`/repeat-bar popover)
|
|
2425
|
+
// — and, for `click-picker-row`, where committing by keyboard would take
|
|
2426
|
+
// whatever the app highlighted, including the row that CREATES a project.
|
|
1485
2427
|
// the click depends on the UI state the previous step produced
|
|
1486
2428
|
const outcome = await driveClickElement(step, run);
|
|
1487
2429
|
if (!outcome.ok) {
|