things-api 0.20.1 → 0.20.2
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 +1 -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/capability.d.ts +20 -0
- package/dist/capability.js +24 -0
- package/dist/capability.js.map +1 -1
- package/dist/cli/skill.d.ts +5 -3
- package/dist/cli/skill.js +6 -2
- package/dist/cli/skill.js.map +1 -1
- package/dist/contracts.d.ts +1 -1
- package/dist/contracts.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/rescue.d.ts +15 -2
- package/dist/rescue.js +74 -11
- package/dist/rescue.js.map +1 -1
- package/dist/sync-health.js +22 -1
- package/dist/sync-health.js.map +1 -1
- package/dist/write/vectors/ui-drag.d.ts +52 -6
- package/dist/write/vectors/ui-drag.js +375 -87
- package/dist/write/vectors/ui-drag.js.map +1 -1
- package/dist/write/vectors/ui.d.ts +7 -0
- package/dist/write/vectors/ui.js +3 -1
- package/dist/write/vectors/ui.js.map +1 -1
- package/package.json +1 -1
- package/skills/things-cli/SKILL.md +4 -3
- package/skills/things-cli/references/bug-reports.md +56 -0
|
@@ -88,58 +88,181 @@ function pidOf(n){ return Application('System Events').processes.byName(n).unixI
|
|
|
88
88
|
function sleep(ms){ $.NSThread.sleepForTimeInterval(ms/1000) }
|
|
89
89
|
function attr(el,name){ var out=Ref(); if($.AXUIElementCopyAttributeValue(el,$(name),out)!==0) return null; return ObjC.castRefToObject(out[0]) }
|
|
90
90
|
function sv(el,name){ var v=attr(el,name); return v? v.js : '' }
|
|
91
|
-
function
|
|
91
|
+
function rectOf(p,z){ if(!p||!z) return null;
|
|
92
92
|
var pd=ObjC.castRefToObject($.CFCopyDescription(p)).js, zd=ObjC.castRefToObject($.CFCopyDescription(z)).js;
|
|
93
93
|
var pm=pd.match(/x:([-0-9.]+) y:([-0-9.]+)/), zm=zd.match(/w:([-0-9.]+) h:([-0-9.]+)/);
|
|
94
94
|
return (pm&&zm)?{x:+pm[1],y:+pm[2],w:+zm[1],h:+zm[2]}:null }
|
|
95
|
+
function frame(el){ return rectOf(attr(el,'AXPosition'), attr(el,'AXSize')) }
|
|
95
96
|
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 }
|
|
96
97
|
function findAll(el, wantRole, depth, acc){ acc=acc||[]; if(depth<0) return acc; var ch=kids(el);
|
|
97
98
|
for(var i=0;i<ch.length;i++){ if(sv(ch[i],'AXRole')===wantRole) acc.push(ch[i]); findAll(ch[i], wantRole, depth-1, acc) } return acc }
|
|
98
99
|
function appEl(){ return $.AXUIElementCreateApplication(pidOf('Things3')) }
|
|
99
|
-
function
|
|
100
|
-
|
|
101
|
-
for(var
|
|
102
|
-
return
|
|
100
|
+
function mainWindow(){ var ws=kids(appEl()), std=[];
|
|
101
|
+
for(var i=0;i<ws.length;i++){ if(sv(ws[i],'AXRole')==='AXWindow' && sv(ws[i],'AXSubrole')==='AXStandardWindow') std.push(ws[i]) }
|
|
102
|
+
for(var k=0;k<std.length;k++){ if(sv(std[k],'AXMain')===true) return std[k] }
|
|
103
|
+
return std.length? std[0] : null }
|
|
104
|
+
var NODE_ATTRS=$(['AXValue','AXDescription','AXTitle','AXChildren','AXPosition','AXSize','AXRole']);
|
|
105
|
+
function node(el){ var out=Ref();
|
|
106
|
+
if($.AXUIElementCopyMultipleAttributeValues(el,NODE_ATTRS,0,out)!==0) return null;
|
|
107
|
+
var a=ObjC.castRefToObject(out[0]); if(!a||Number(a.count)<7) return null;
|
|
108
|
+
function s(i){ var v=a.objectAtIndex(i); if(!v) return ''; var j; try{ j=v.js }catch(e){ return '' } return typeof j==='string'? j:'' }
|
|
109
|
+
var ch=[], c=a.objectAtIndex(3);
|
|
110
|
+
try{ var n=Number(c.count); for(var i=0;i<n;i++) ch.push(c.objectAtIndex(i)) }catch(e){ ch=[] }
|
|
111
|
+
var f=null; try{ f=rectOf(a.objectAtIndex(4), a.objectAtIndex(5)) }catch(e){ f=null }
|
|
112
|
+
return { value:s(0), desc:s(1), title:s(2), children:ch, frame:f, role:s(6) } }
|
|
113
|
+
function textOf(n, acc, depth){ if(n===null||depth<0) return acc;
|
|
114
|
+
if(n.value) acc.push(n.value); if(n.desc) acc.push(n.desc); if(n.title) acc.push(n.title);
|
|
115
|
+
for(var i=0;i<n.children.length;i++) textOf(node(n.children[i]), acc, depth-1); return acc }
|
|
116
|
+
function isList(role){ return role==='AXTable'||role==='AXOutline'||role==='AXList' }
|
|
117
|
+
function listPanes(el, depth, acc, sa){ if(depth<0) return acc; var ch=kids(el);
|
|
118
|
+
for(var i=0;i<ch.length;i++){ var role=sv(ch[i],'AXRole');
|
|
119
|
+
if(isList(role)){ acc.push({table:ch[i], scroll:sa}); continue }
|
|
120
|
+
if(role==='AXRow'||role==='AXCell') continue;
|
|
121
|
+
listPanes(ch[i], depth-1, acc, role==='AXScrollArea'? ch[i] : sa) }
|
|
122
|
+
return acc }
|
|
123
|
+
function harvestRows(tableEl, depth){ var out=[], ch=kids(tableEl);
|
|
124
|
+
for(var i=0;i<ch.length;i++){ var n=node(ch[i]); if(n===null) continue;
|
|
125
|
+
if(n.role!=='AXRow'&&n.role!=='AXTableRow') continue;
|
|
126
|
+
var f=n.frame;
|
|
127
|
+
out.push({ text: textOf(n,[],depth).join('|'), x:f?f.x:null, y:f?f.y:null, w:f?f.w:null, h:f?f.h:null }) }
|
|
128
|
+
return out }
|
|
129
|
+
function segMatch(text, title){ var segs=text.split('|');
|
|
130
|
+
for(var j=0;j<segs.length;j++){ if(segs[j]===title||segs[j]===title+'.') return true } return false }
|
|
131
|
+
function countTitles(rows, titles){ var n=0;
|
|
132
|
+
for(var t=0;t<titles.length;t++){ for(var r=0;r<rows.length;r++){ if(segMatch(rows[r].text,titles[t])){ n++; break } } }
|
|
133
|
+
return n }
|
|
134
|
+
function overlapPx(a,b){ if(!a||!b) return 0; return Math.min(a.x+a.w,b.x+b.w) - Math.max(a.x,b.x) }
|
|
135
|
+
/*
|
|
136
|
+
* The scroll bar is a DIRECT child of the scroll area (measured), so this reads
|
|
137
|
+
* the children and stops. It used to be a findAll to depth 4, which walked the
|
|
138
|
+
* whole table and every row underneath it — the last full-subtree enumeration
|
|
139
|
+
* in the snapshot, and worth ~0.8s of its ~1.2s on an 85-row sidebar.
|
|
140
|
+
*/
|
|
141
|
+
function scrollFraction(sa){ if(!sa) return null; var ch=kids(sa);
|
|
142
|
+
for(var b=0;b<ch.length;b++){ if(sv(ch[b],'AXRole')!=='AXScrollBar') continue;
|
|
143
|
+
var v=attr(ch[b],'AXValue'); if(v===null) continue;
|
|
144
|
+
var d=ObjC.castRefToObject($.CFCopyDescription(v)).js; var m=d.match(/value = ([+\\-0-9.]+)/);
|
|
145
|
+
if(m) return +m[1] }
|
|
146
|
+
return null }
|
|
147
|
+
/*
|
|
148
|
+
* THE SIDEBAR LOCATOR (SBRES1). Structural + semantic, never geometric:
|
|
149
|
+
* - the window is the one carrying AXMain (measured: exactly one does, and it
|
|
150
|
+
* is the front one) — never the 40x40 untitled placeholder that always sits
|
|
151
|
+
* in the app's AXChildren beside the menu bar;
|
|
152
|
+
* - candidate lists are collected by a walk that STOPS at every list container
|
|
153
|
+
* and never enters a row, so its cost is a function of window chrome rather
|
|
154
|
+
* than of the user's data (measured 125 AX calls vs the old walk's ~3,900);
|
|
155
|
+
* - the sidebar is the candidate whose rows carry the caller's own AREA TITLES,
|
|
156
|
+
* which is what a sidebar IS. No width threshold: the old w < 400 test
|
|
157
|
+
* silently unresolved every sidebar dragged past 400pt (issues #665/#651).
|
|
158
|
+
*/
|
|
159
|
+
function resolveSidebar(titles, depth){
|
|
160
|
+
var w = mainWindow();
|
|
161
|
+
if (w === null) return { ok:false, why:'no-window' };
|
|
162
|
+
var panes = listPanes(w, 8, [], null);
|
|
163
|
+
if (panes.length === 0) return { ok:false, why:'no-list-candidates', windowFrame:frame(w) };
|
|
164
|
+
var scored = [], i;
|
|
165
|
+
for (i=0;i<panes.length;i++){
|
|
166
|
+
var rows = harvestRows(panes[i].table, depth);
|
|
167
|
+
scored.push({ pane:panes[i], rows:rows, hits:countTitles(rows,titles), frame:frame(panes[i].table) });
|
|
168
|
+
}
|
|
169
|
+
var best=null, tie=false;
|
|
170
|
+
for (i=0;i<scored.length;i++){
|
|
171
|
+
if (best===null || scored[i].hits>best.hits){ best=scored[i]; tie=false }
|
|
172
|
+
else if (scored[i].hits===best.hits && best.hits>0) tie=true;
|
|
173
|
+
}
|
|
174
|
+
if (best===null || best.hits===0){
|
|
175
|
+
var seen=[]; for(i=0;i<scored.length;i++) seen.push({frame:scored[i].frame, rows:scored[i].rows.length});
|
|
176
|
+
return { ok:false, why:'no-title-match', searched:seen, titles:titles.length };
|
|
177
|
+
}
|
|
178
|
+
if (tie) return { ok:false, why:'ambiguous-sidebar', titles:titles.length };
|
|
179
|
+
// HIDDEN-SIDEBAR SIGNATURE (measured): View > Hide Sidebar leaves the sidebar
|
|
180
|
+
// scroll area in the tree with its old frame while the content pane slides
|
|
181
|
+
// over it, so the two list panes OVERLAP horizontally by the sidebar's width.
|
|
182
|
+
// Visible (and full-screen) states never overlap. No AX attribute marks it.
|
|
183
|
+
var vp = best.pane.scroll===null? null : frame(best.pane.scroll);
|
|
184
|
+
for (i=0;i<scored.length;i++){
|
|
185
|
+
if (scored[i]===best || scored[i].pane.scroll===null) continue;
|
|
186
|
+
if (overlapPx(vp, frame(scored[i].pane.scroll)) > 1) return { ok:false, why:'sidebar-hidden' };
|
|
187
|
+
}
|
|
188
|
+
if (vp === null) return { ok:false, why:'no-viewport' };
|
|
189
|
+
if (best.rows.length === 0) return { ok:false, why:'no-rows' };
|
|
190
|
+
return { ok:true, table:best.pane.table, scroll:best.pane.scroll, viewport:vp, rows:best.rows, hits:best.hits };
|
|
191
|
+
}
|
|
103
192
|
var MOVED=5, DOWN=1, UP=2, DRAG=6;
|
|
104
193
|
function mev(t,x,y,cs){ var e=$.CGEventCreateMouseEvent($(), t, $.CGPointMake(x,y), 0); if(cs) $.CGEventSetIntegerValueField(e,1,cs); return e }
|
|
105
194
|
function postHID(ev){ $.CGEventPost($.kCGHIDEventTap, ev) }`;
|
|
106
|
-
/**
|
|
107
|
-
|
|
195
|
+
/**
|
|
196
|
+
* How deep the per-row text walk goes on the FAST path, and on the fallback.
|
|
197
|
+
*
|
|
198
|
+
* MEASURED (SBRES1, 84-row sidebar): the driver consumes a row's text in exactly
|
|
199
|
+
* two ways — `text === ''` (spacer detection) and an exact segment match against
|
|
200
|
+
* a known area title — and depth 2 agrees with the old depth-6 walk on BOTH for
|
|
201
|
+
* every row, at 235 AX calls / 197ms instead of 3,376 / 1,675ms. Depth 4 and up
|
|
202
|
+
* is byte-identical to the old output, so the escalation below can never lose
|
|
203
|
+
* information the ladder used to have.
|
|
204
|
+
*/
|
|
205
|
+
const ROW_TEXT_DEPTH_FAST = 2;
|
|
206
|
+
const ROW_TEXT_DEPTH_FULL = 6;
|
|
207
|
+
/**
|
|
208
|
+
* Snapshot: sidebar rows (text + frames), viewport rect, scroll fraction.
|
|
209
|
+
*
|
|
210
|
+
* `areaTitles` is what makes the locator semantic — the sidebar is identified as
|
|
211
|
+
* the list that holds the caller's own areas. It is also the ESCALATION oracle:
|
|
212
|
+
* every area always renders a sidebar row (AXDRAG1: even off-viewport rows
|
|
213
|
+
* expose valid virtualized frames), so a shallow harvest that finds fewer titles
|
|
214
|
+
* than the database holds is re-run at full depth before the ladder sees it.
|
|
215
|
+
*/
|
|
216
|
+
export function jxaSidebarSnapshotScript(areaTitles) {
|
|
108
217
|
return `${JXA_PRELUDE}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if(m){ out.scroll = +m[1]; break } }
|
|
122
|
-
break } }
|
|
123
|
-
var ch = kids(t);
|
|
124
|
-
for (var r=0;r<ch.length;r++){ var role=sv(ch[r],'AXRole');
|
|
125
|
-
if (role==='AXRow'||role==='AXTableRow'){ var rf=frame(ch[r]);
|
|
126
|
-
out.rows.push({ text: allText(ch[r],[],6).join('|'), x: rf?rf.x:null, y: rf?rf.y:null, w: rf?rf.w:null, h: rf?rf.h:null }) } }
|
|
218
|
+
var TITLES = ${JSON.stringify([...areaTitles])};
|
|
219
|
+
var r = resolveSidebar(TITLES, ${ROW_TEXT_DEPTH_FAST});
|
|
220
|
+
var out;
|
|
221
|
+
if (r.ok !== true) { out = { ok:false, why:r.why, searched:r.searched||null, titles:r.titles||null, windowFrame:r.windowFrame||null } }
|
|
222
|
+
else {
|
|
223
|
+
var deep = false;
|
|
224
|
+
if (r.hits < TITLES.length) {
|
|
225
|
+
var full = resolveSidebar(TITLES, ${ROW_TEXT_DEPTH_FULL});
|
|
226
|
+
if (full.ok === true && full.hits > r.hits) { r = full; deep = true }
|
|
227
|
+
}
|
|
228
|
+
out = { ok:true, viewport:r.viewport, scroll:scrollFraction(r.scroll), rows:r.rows,
|
|
229
|
+
deep:deep, matched:r.hits, expected:TITLES.length };
|
|
127
230
|
}
|
|
128
231
|
JSON.stringify(out)`;
|
|
129
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* Show or hide the sidebar through Things' own View menu (SBRES1 normalization
|
|
235
|
+
* rung). English-pinned and fail-closed: a menu without the item — a localized
|
|
236
|
+
* app, or a Things update that moved it — refuses and names what it did find,
|
|
237
|
+
* rather than clicking whatever sits in that position (UIC1 precedent).
|
|
238
|
+
*/
|
|
239
|
+
export function jxaSidebarVisibilityScript(want) {
|
|
240
|
+
const wanted = want === "show" ? "Show Sidebar" : "Hide Sidebar";
|
|
241
|
+
return `var se = Application('System Events');
|
|
242
|
+
var out = { clicked:false, why:'', items:[] };
|
|
243
|
+
try {
|
|
244
|
+
var items = se.processes.byName('Things3').menuBars[0].menuBarItems.byName('View').menus[0].menuItems;
|
|
245
|
+
for (var i = 0; i < items.length; i++) {
|
|
246
|
+
var name = items[i].name();
|
|
247
|
+
if (name) out.items.push(name);
|
|
248
|
+
if (name === ${JSON.stringify(wanted)}) { items[i].click(); out.clicked = true }
|
|
249
|
+
}
|
|
250
|
+
if (!out.clicked) out.why = 'the View menu has no "${wanted}" item';
|
|
251
|
+
} catch (e) { out.why = 'the View menu did not respond: ' + e }
|
|
252
|
+
JSON.stringify(out)`;
|
|
253
|
+
}
|
|
130
254
|
/**
|
|
131
255
|
* Scroll: move the pointer over the sidebar center (wheel events target the
|
|
132
256
|
* surface under the cursor), then post `clicks` line-unit wheel events.
|
|
133
257
|
* Positive clicks move the CONTENT down (earlier rows return, row y grows);
|
|
134
258
|
* negative clicks reveal lower rows (row y shrinks) — AXDRAG1-b.
|
|
135
259
|
*/
|
|
136
|
-
export function jxaSidebarScrollScript(clicks) {
|
|
260
|
+
export function jxaSidebarScrollScript(clicks, areaTitles) {
|
|
137
261
|
const n = Math.trunc(clicks);
|
|
138
262
|
return `${JXA_PRELUDE}
|
|
139
|
-
var
|
|
140
|
-
var
|
|
141
|
-
|
|
142
|
-
for (var i=0;i<sas.length;i++){ var f=frame(sas[i]); if(f && f.w<400){ sb=f; break } } }
|
|
263
|
+
var TITLES = ${JSON.stringify([...areaTitles])};
|
|
264
|
+
var r = resolveSidebar(TITLES, ${ROW_TEXT_DEPTH_FAST});
|
|
265
|
+
var sb = r.ok === true ? r.viewport : null;
|
|
143
266
|
if (sb === null) { 'NO_SIDEBAR' } else {
|
|
144
267
|
postHID(mev(MOVED, sb.x + sb.w/2, sb.y + sb.h/2, 0)); sleep(50);
|
|
145
268
|
var n = ${n}, dir = n < 0 ? -1 : 1;
|
|
@@ -168,20 +291,28 @@ postHID(mev(DRAG, tx, ty, 1)); sleep(400);
|
|
|
168
291
|
postHID(mev(UP, tx, ty, 1));
|
|
169
292
|
'DONE'`;
|
|
170
293
|
}
|
|
171
|
-
function snapshotCommand() {
|
|
294
|
+
function snapshotCommand(areaTitles) {
|
|
172
295
|
return {
|
|
173
296
|
primitive: "sidebar-snapshot",
|
|
174
297
|
label: "read the sidebar rows and viewport",
|
|
175
298
|
lang: "javascript",
|
|
176
|
-
script: jxaSidebarSnapshotScript(),
|
|
299
|
+
script: jxaSidebarSnapshotScript(areaTitles),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function sidebarVisibilityCommand(want) {
|
|
303
|
+
return {
|
|
304
|
+
primitive: "sidebar-visibility",
|
|
305
|
+
label: want === "show" ? "show the sidebar (View menu)" : "hide the sidebar again (View menu)",
|
|
306
|
+
lang: "javascript",
|
|
307
|
+
script: jxaSidebarVisibilityScript(want),
|
|
177
308
|
};
|
|
178
309
|
}
|
|
179
|
-
function scrollCommand(clicks) {
|
|
310
|
+
function scrollCommand(clicks, areaTitles) {
|
|
180
311
|
return {
|
|
181
312
|
primitive: "sidebar-scroll",
|
|
182
313
|
label: `scroll the sidebar (${clicks} clicks)`,
|
|
183
314
|
lang: "javascript",
|
|
184
|
-
script: jxaSidebarScrollScript(clicks),
|
|
315
|
+
script: jxaSidebarScrollScript(clicks, areaTitles),
|
|
185
316
|
};
|
|
186
317
|
}
|
|
187
318
|
function dragCommand(sx, sy, tx, ty) {
|
|
@@ -203,22 +334,19 @@ function dragCommand(sx, sy, tx, ty) {
|
|
|
203
334
|
* Escape-aborts (AXDRAG1-d: byte-identical index vector) and reports it.
|
|
204
335
|
*/
|
|
205
336
|
export function jxaSidebarHeldScrollDragScript(sx, sy, anchorTitle, // null = drop below the last row (to-last)
|
|
206
|
-
maxTicks) {
|
|
337
|
+
maxTicks, areaTitles) {
|
|
207
338
|
const [a, b] = [sx, sy].map(Math.round);
|
|
208
339
|
const anchor = JSON.stringify(anchorTitle);
|
|
209
340
|
return `${JXA_PRELUDE}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
var
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if(role==='AXRow'||role==='AXTableRow'){ var f=frame(ch[r]);
|
|
216
|
-
if(f) out.push({text:allText(ch[r],[],6).join('|'), f:f}) } }
|
|
341
|
+
var TITLES = ${JSON.stringify([...areaTitles])};
|
|
342
|
+
function liveRows(){ var r=resolveSidebar(TITLES, ${ROW_TEXT_DEPTH_FAST}); if(r.ok!==true) return [];
|
|
343
|
+
var out=[]; for(var i=0;i<r.rows.length;i++){ var w=r.rows[i];
|
|
344
|
+
if(w.y===null||w.h===null) continue;
|
|
345
|
+
out.push({text:w.text, f:{x:w.x,y:w.y,w:w.w,h:w.h}}) }
|
|
217
346
|
out.sort(function(p,q){ return p.f.y-q.f.y }); return out }
|
|
218
347
|
function matches(text, title){ var segs=text.split('|');
|
|
219
348
|
for(var j=0;j<segs.length;j++){ if(segs[j]===title||segs[j]===title+'.') return true } return false }
|
|
220
|
-
function viewportRect(){ var
|
|
221
|
-
for(var i=0;i<sas.length;i++){ var f=frame(sas[i]); if(f && f.w<400) return f } return null }
|
|
349
|
+
function viewportRect(){ var r=resolveSidebar(TITLES, ${ROW_TEXT_DEPTH_FAST}); return r.ok===true? r.viewport : null }
|
|
222
350
|
var sx=${a}, sy=${b}, anchorTitle=${anchor}, maxTicks=${Math.trunc(maxTicks)};
|
|
223
351
|
var vp = viewportRect();
|
|
224
352
|
if (vp === null) { JSON.stringify({aborted:true, why:'no sidebar viewport'}) } else {
|
|
@@ -265,8 +393,8 @@ if (b0 === null) {
|
|
|
265
393
|
// Post-wheel SETTLE: the list can drift a few px after the last tick
|
|
266
394
|
// (AXDRAG2-a saw ~8px). Wait until the live boundary is stable across
|
|
267
395
|
// two consecutive reads before aiming.
|
|
268
|
-
var stable = 0, lastY = null,
|
|
269
|
-
for (
|
|
396
|
+
var stable = 0, lastY = null, settleTick = 0;
|
|
397
|
+
for (settleTick = 0; settleTick < 12 && stable < 2; settleTick++) {
|
|
270
398
|
postHID(mev(DRAG, sx, sy, 1)); sleep(140);
|
|
271
399
|
var bs = boundaryNow(); if (bs === null) break;
|
|
272
400
|
if (lastY !== null && Math.abs(bs.y - lastY) < 1) stable++;
|
|
@@ -313,10 +441,11 @@ JSON.stringify(result)
|
|
|
313
441
|
* disambiguation the rest of the driver uses; -1 means "the only row with this
|
|
314
442
|
* title", and an ambiguous match refuses.
|
|
315
443
|
*/
|
|
316
|
-
export function jxaSidebarChevronClickScript(title, ordinal) {
|
|
444
|
+
export function jxaSidebarChevronClickScript(title, ordinal, areaTitles) {
|
|
317
445
|
const want = JSON.stringify(title);
|
|
318
446
|
const ord = Math.trunc(ordinal);
|
|
319
447
|
return `${JXA_PRELUDE}
|
|
448
|
+
var TITLES = ${JSON.stringify([...areaTitles])};
|
|
320
449
|
function allText(el, acc, depth){ acc=acc||[]; depth=depth==null?6:depth; if(depth<0) return acc;
|
|
321
450
|
var v=sv(el,'AXValue'); if(v) acc.push(v); var d=sv(el,'AXDescription'); if(d) acc.push(d);
|
|
322
451
|
var t=sv(el,'AXTitle'); if(t) acc.push(t); var ch=kids(el); for(var i=0;i<ch.length;i++) allText(ch[i],acc,depth-1); return acc }
|
|
@@ -328,11 +457,9 @@ function chevronOf(el, depth){ if(depth<0) return null; var ch=kids(el);
|
|
|
328
457
|
var r=chevronOf(ch[i], depth-1); if(r) return r }
|
|
329
458
|
return null }
|
|
330
459
|
var want=${want}, ord=${ord};
|
|
331
|
-
var
|
|
332
|
-
if (
|
|
333
|
-
var
|
|
334
|
-
if (w !== null) { var sas=findAll(w,'AXScrollArea',12,[]);
|
|
335
|
-
for (var i=0;i<sas.length;i++){ var vf=frame(sas[i]); if(vf && vf.w<400){ vp=vf; break } } }
|
|
460
|
+
var sb = resolveSidebar(TITLES, ${ROW_TEXT_DEPTH_FAST});
|
|
461
|
+
if (sb.ok !== true) { JSON.stringify({clicked:false, why:'the sidebar did not resolve (' + sb.why + ')'}) } else {
|
|
462
|
+
var t = sb.table, vp = sb.viewport;
|
|
336
463
|
if (vp === null) { JSON.stringify({clicked:false, why:'the sidebar viewport did not resolve'}) } else {
|
|
337
464
|
var ch = kids(t), hits = [];
|
|
338
465
|
for (var r=0;r<ch.length;r++){ var role=sv(ch[r],'AXRole');
|
|
@@ -366,21 +493,21 @@ if (pick === null) {
|
|
|
366
493
|
}
|
|
367
494
|
}}`;
|
|
368
495
|
}
|
|
369
|
-
function chevronClickCommand(title, ordinal) {
|
|
496
|
+
function chevronClickCommand(title, ordinal, areaTitles) {
|
|
370
497
|
return {
|
|
371
498
|
primitive: "sidebar-chevron",
|
|
372
499
|
label: `toggle the disclosure arrow on the area row "${title}"`,
|
|
373
500
|
lang: "javascript",
|
|
374
|
-
script: jxaSidebarChevronClickScript(title, ordinal),
|
|
501
|
+
script: jxaSidebarChevronClickScript(title, ordinal, areaTitles),
|
|
375
502
|
meta: { title, ordinal },
|
|
376
503
|
};
|
|
377
504
|
}
|
|
378
|
-
function heldScrollDragCommand(sx, sy, anchorTitle, maxTicks) {
|
|
505
|
+
function heldScrollDragCommand(sx, sy, anchorTitle, maxTicks, areaTitles) {
|
|
379
506
|
return {
|
|
380
507
|
primitive: "sidebar-held-drag",
|
|
381
508
|
label: "held-scroll drag toward the destination",
|
|
382
509
|
lang: "javascript",
|
|
383
|
-
script: jxaSidebarHeldScrollDragScript(sx, sy, anchorTitle, maxTicks),
|
|
510
|
+
script: jxaSidebarHeldScrollDragScript(sx, sy, anchorTitle, maxTicks, areaTitles),
|
|
384
511
|
meta: { sx, sy, anchorTitle, maxTicks },
|
|
385
512
|
};
|
|
386
513
|
}
|
|
@@ -452,20 +579,51 @@ export function boundaryBelowLast(allRows) {
|
|
|
452
579
|
const half = (medianSpacerHeight(allRows) ?? last.h / 2) / 2;
|
|
453
580
|
return last.y + last.h + half;
|
|
454
581
|
}
|
|
582
|
+
const SNAPSHOT_FAILURES = new Set([
|
|
583
|
+
"no-window",
|
|
584
|
+
"no-list-candidates",
|
|
585
|
+
"no-title-match",
|
|
586
|
+
"ambiguous-sidebar",
|
|
587
|
+
"sidebar-hidden",
|
|
588
|
+
"no-viewport",
|
|
589
|
+
"no-rows",
|
|
590
|
+
]);
|
|
455
591
|
export function parseSidebarSnapshot(stdout) {
|
|
592
|
+
let parsed;
|
|
456
593
|
try {
|
|
457
|
-
|
|
458
|
-
if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.rows))
|
|
459
|
-
return null;
|
|
460
|
-
return {
|
|
461
|
-
viewport: parsed.viewport ?? null,
|
|
462
|
-
scroll: parsed.scroll ?? null,
|
|
463
|
-
rows: parsed.rows.filter((r) => typeof r.y === "number" && typeof r.x === "number" && typeof r.h === "number"),
|
|
464
|
-
};
|
|
594
|
+
parsed = JSON.parse(stdout.trim());
|
|
465
595
|
}
|
|
466
596
|
catch {
|
|
467
|
-
return
|
|
597
|
+
return { ok: false, why: "unparsable" };
|
|
598
|
+
}
|
|
599
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
600
|
+
return { ok: false, why: "unparsable" };
|
|
601
|
+
const obj = parsed;
|
|
602
|
+
if (obj["ok"] !== true) {
|
|
603
|
+
const why = typeof obj["why"] === "string" && SNAPSHOT_FAILURES.has(obj["why"]) ? obj["why"] : null;
|
|
604
|
+
if (why === null)
|
|
605
|
+
return { ok: false, why: "unparsable" };
|
|
606
|
+
return {
|
|
607
|
+
ok: false,
|
|
608
|
+
why: why,
|
|
609
|
+
...(Array.isArray(obj["searched"]) && {
|
|
610
|
+
searched: obj["searched"],
|
|
611
|
+
}),
|
|
612
|
+
...(typeof obj["titles"] === "number" && { titles: obj["titles"] }),
|
|
613
|
+
};
|
|
468
614
|
}
|
|
615
|
+
if (!Array.isArray(obj["rows"]))
|
|
616
|
+
return { ok: false, why: "unparsable" };
|
|
617
|
+
const rows = obj["rows"].filter((r) => typeof r.y === "number" && typeof r.x === "number" && typeof r.h === "number");
|
|
618
|
+
const viewport = obj["viewport"] ?? null;
|
|
619
|
+
if (viewport === null)
|
|
620
|
+
return { ok: false, why: "no-viewport" };
|
|
621
|
+
if (rows.length === 0)
|
|
622
|
+
return { ok: false, why: "no-rows" };
|
|
623
|
+
return {
|
|
624
|
+
ok: true,
|
|
625
|
+
snapshot: { viewport, scroll: obj["scroll"] ?? null, rows },
|
|
626
|
+
};
|
|
469
627
|
}
|
|
470
628
|
/**
|
|
471
629
|
* Does a row's static-text carry this exact title as a segment? Sidebar row
|
|
@@ -759,13 +917,61 @@ async function runCmd(ctx, cmd) {
|
|
|
759
917
|
return ctx.run(cmd, STEP_TIMEOUT_MS);
|
|
760
918
|
}
|
|
761
919
|
async function takeSnapshot(ctx) {
|
|
762
|
-
const res = await runCmd(ctx, snapshotCommand());
|
|
763
|
-
if (
|
|
764
|
-
return
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
return
|
|
920
|
+
const res = await runCmd(ctx, snapshotCommand(ctx.areaTitles));
|
|
921
|
+
if (res.timedOut === true)
|
|
922
|
+
return { ok: false, why: "timeout" };
|
|
923
|
+
if (!res.ok) {
|
|
924
|
+
return { ok: false, why: "dispatch-failed", ...(res.stderr.trim() && { stderr: res.stderr }) };
|
|
925
|
+
}
|
|
926
|
+
return parseSidebarSnapshot(res.stdout);
|
|
927
|
+
}
|
|
928
|
+
/** The snapshot when only its presence matters (scroll loops, re-censuses). */
|
|
929
|
+
async function snapshotOrNull(ctx) {
|
|
930
|
+
const out = await takeSnapshot(ctx);
|
|
931
|
+
return out.ok ? out.snapshot : null;
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* One honest sentence per real cause, each naming the thing to change. The
|
|
935
|
+
* remediation matters as much as the diagnosis: the old copy told a user with a
|
|
936
|
+
* plainly-open sidebar to check whether the sidebar was open.
|
|
937
|
+
*/
|
|
938
|
+
export function describeSnapshotFailure(refusal) {
|
|
939
|
+
switch (refusal.why) {
|
|
940
|
+
case "timeout":
|
|
941
|
+
return (`reading the sidebar took longer than ${Math.round(STEP_TIMEOUT_MS / 1000)}s and was ` +
|
|
942
|
+
"stopped — nothing was dragged. This is a very large sidebar, a busy Mac, or both; " +
|
|
943
|
+
"collapse some areas or close other windows and re-run");
|
|
944
|
+
case "dispatch-failed":
|
|
945
|
+
return `the sidebar read did not run${refusal.stderr ? `: ${refusal.stderr.trim()}` : ""}`;
|
|
946
|
+
case "unparsable":
|
|
947
|
+
return "the sidebar read returned output this version cannot read";
|
|
948
|
+
case "no-window":
|
|
949
|
+
return ("Things is running but has no open window — only the placeholder it keeps in the " +
|
|
950
|
+
"background. Open the Things window (click its Dock icon) and re-run");
|
|
951
|
+
case "no-list-candidates":
|
|
952
|
+
return ("the Things window exposes no list at all — it may still be opening. Wait for it to " +
|
|
953
|
+
"finish drawing and re-run");
|
|
954
|
+
case "sidebar-hidden":
|
|
955
|
+
return ("the sidebar is hidden in this Things window — show it with View ▸ Show Sidebar (⌘/) " +
|
|
956
|
+
"and re-run");
|
|
957
|
+
case "no-title-match": {
|
|
958
|
+
const where = refusal.searched === undefined
|
|
959
|
+
? ""
|
|
960
|
+
: ` (searched ${refusal.searched.length} list(s): ${refusal.searched
|
|
961
|
+
.map((s) => `${s.rows} row(s)${s.frame ? ` at ${Math.round(s.frame.w)}pt wide` : ""}`)
|
|
962
|
+
.join(", ")})`;
|
|
963
|
+
return (`none of the lists in the Things window holds a row for any of your ${refusal.titles ?? 0} ` +
|
|
964
|
+
`area(s)${where} — the sidebar may be scrolled inside a different window, or a Things ` +
|
|
965
|
+
"update may have changed how it exposes rows");
|
|
966
|
+
}
|
|
967
|
+
case "ambiguous-sidebar":
|
|
968
|
+
return ("two lists in the Things window both look like the sidebar, so nothing was dragged — " +
|
|
969
|
+
"close the extra Things window (File ▸ Close) and re-run");
|
|
970
|
+
case "no-viewport":
|
|
971
|
+
return "the sidebar's scrolling container did not resolve";
|
|
972
|
+
case "no-rows":
|
|
973
|
+
return "the sidebar resolved but exposed no rows — quit and reopen Things, then retry";
|
|
974
|
+
}
|
|
769
975
|
}
|
|
770
976
|
/**
|
|
771
977
|
* Scroll until `wanted(snapshot)` returns a zero-ish error, re-resolving
|
|
@@ -782,7 +988,7 @@ async function scrollUntil(ctx, wanted, goodEnough) {
|
|
|
782
988
|
let stalls = 0;
|
|
783
989
|
for (let iter = 0; iter < MAX_SCROLL_ITER; iter++) {
|
|
784
990
|
// each scroll must observe the frames the previous scroll produced
|
|
785
|
-
const snap = await
|
|
991
|
+
const snap = await snapshotOrNull(ctx);
|
|
786
992
|
if (snap === null)
|
|
787
993
|
return null;
|
|
788
994
|
const err = wanted(snap);
|
|
@@ -811,7 +1017,7 @@ async function scrollUntil(ctx, wanted, goodEnough) {
|
|
|
811
1017
|
const clicks = Math.max(-12, Math.min(12, Math.round(err / pxPerClick) || Math.sign(err))) * dirFactor;
|
|
812
1018
|
lastClicks = clicks;
|
|
813
1019
|
// strictly sequential scroll-and-remeasure loop
|
|
814
|
-
const res = await runCmd(ctx, scrollCommand(clicks));
|
|
1020
|
+
const res = await runCmd(ctx, scrollCommand(clicks, ctx.areaTitles));
|
|
815
1021
|
if (!res.ok)
|
|
816
1022
|
return null;
|
|
817
1023
|
}
|
|
@@ -873,7 +1079,7 @@ async function toggleDisclosure(ctx, areaTitles, title, ordinal, want) {
|
|
|
873
1079
|
return { clicked: false, ok: false, why: `"${title}"'s row did not resolve` };
|
|
874
1080
|
}
|
|
875
1081
|
// the gesture must land before the re-census that judges it
|
|
876
|
-
const res = await runCmd(ctx, chevronClickCommand(title, ordinal));
|
|
1082
|
+
const res = await runCmd(ctx, chevronClickCommand(title, ordinal, ctx.areaTitles));
|
|
877
1083
|
let clicked = false;
|
|
878
1084
|
let why = "the disclosure arrow did not respond";
|
|
879
1085
|
if (res.ok) {
|
|
@@ -901,10 +1107,10 @@ async function toggleDisclosure(ctx, areaTitles, title, ordinal, want) {
|
|
|
901
1107
|
// still never allowed to carry the ladder forward.
|
|
902
1108
|
await ctx.sleep(600);
|
|
903
1109
|
const after = await takeSnapshot(ctx);
|
|
904
|
-
if (after
|
|
905
|
-
return { clicked: true, ok: false, why:
|
|
1110
|
+
if (!after.ok) {
|
|
1111
|
+
return { clicked: true, ok: false, why: describeSnapshotFailure(after) };
|
|
906
1112
|
}
|
|
907
|
-
const rowsAfter = sectionRowCount(after, areaTitles, title, ordinal);
|
|
1113
|
+
const rowsAfter = sectionRowCount(after.snapshot, areaTitles, title, ordinal);
|
|
908
1114
|
if (rowsAfter === null) {
|
|
909
1115
|
return { clicked: true, ok: false, why: `"${title}"'s row did not resolve after the click` };
|
|
910
1116
|
}
|
|
@@ -1065,26 +1271,91 @@ export async function driveSidebarAreaReorder(spec, run, aux, sleep = (ms) => ne
|
|
|
1065
1271
|
"only run through the full client",
|
|
1066
1272
|
};
|
|
1067
1273
|
}
|
|
1068
|
-
const
|
|
1274
|
+
const areaTitlesForRestore = aux.areaState().areas.map((a) => a.title);
|
|
1275
|
+
const ctx = {
|
|
1276
|
+
run,
|
|
1277
|
+
state: aux.areaState,
|
|
1278
|
+
sleep,
|
|
1279
|
+
areaTitles: areaTitlesForRestore,
|
|
1280
|
+
};
|
|
1069
1281
|
// The collapse rung's ledger, owned OUT HERE so the restore epilogue covers
|
|
1070
1282
|
// every way the ladder can end — including a throw (FGRD2 cleanup shape).
|
|
1071
1283
|
const collapsed = [];
|
|
1072
|
-
|
|
1284
|
+
// The chrome ledger, same shape and for the same reason: a sidebar this drive
|
|
1285
|
+
// revealed is hidden again on EVERY exit path. The user's window chrome is
|
|
1286
|
+
// theirs; a move must not silently leave it changed (SBCOL1 precedent).
|
|
1287
|
+
const chrome = { revealedSidebar: false };
|
|
1073
1288
|
try {
|
|
1074
|
-
const result = await runDragLadder(ctx, spec, collapsed);
|
|
1289
|
+
const result = await runDragLadder(ctx, spec, collapsed, chrome);
|
|
1075
1290
|
// the ladder (and any recovery drag inside it) must finish before the
|
|
1076
1291
|
// sidebar is folded back — the recovery needs the cleared path too
|
|
1077
1292
|
const restoreFailed = await restoreDisclosure(ctx, areaTitlesForRestore, collapsed);
|
|
1078
|
-
|
|
1293
|
+
const chromeNote = await restoreChrome(ctx, chrome);
|
|
1294
|
+
return withChromeOutcome(withCollapseOutcome(result, collapsed, restoreFailed), chromeNote);
|
|
1079
1295
|
}
|
|
1080
1296
|
catch (err) {
|
|
1081
1297
|
// the sidebar is put back even when the ladder blew up
|
|
1082
1298
|
await restoreDisclosure(ctx, areaTitlesForRestore, collapsed);
|
|
1299
|
+
await restoreChrome(ctx, chrome);
|
|
1083
1300
|
throw err;
|
|
1084
1301
|
}
|
|
1085
1302
|
}
|
|
1303
|
+
/**
|
|
1304
|
+
* NORMALIZATION RUNG (SBRES1): a hidden sidebar is not a dead end.
|
|
1305
|
+
*
|
|
1306
|
+
* Things' View ▸ Show Sidebar is a real, documented command, so the drive uses
|
|
1307
|
+
* it — and then CLOSES THE LOOP, exactly as the determinism doctrine requires:
|
|
1308
|
+
* the reveal only counts once a fresh snapshot resolves. A click that went out
|
|
1309
|
+
* is ledgered whether or not it verified, because the app has already changed.
|
|
1310
|
+
*/
|
|
1311
|
+
async function revealSidebar(ctx, chrome) {
|
|
1312
|
+
const res = await runCmd(ctx, sidebarVisibilityCommand("show"));
|
|
1313
|
+
let clicked = false;
|
|
1314
|
+
let why = "the View menu did not respond";
|
|
1315
|
+
if (res.ok) {
|
|
1316
|
+
try {
|
|
1317
|
+
const parsed = JSON.parse(res.stdout.trim());
|
|
1318
|
+
clicked = parsed.clicked === true;
|
|
1319
|
+
if (typeof parsed.why === "string" && parsed.why !== "")
|
|
1320
|
+
why = parsed.why;
|
|
1321
|
+
}
|
|
1322
|
+
catch {
|
|
1323
|
+
/* keep the default reason */
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
if (!clicked)
|
|
1327
|
+
return { ok: false, why };
|
|
1328
|
+
chrome.revealedSidebar = true;
|
|
1329
|
+
await ctx.sleep(600);
|
|
1330
|
+
const after = await takeSnapshot(ctx);
|
|
1331
|
+
if (after.ok)
|
|
1332
|
+
return { ok: true, snapshot: after.snapshot };
|
|
1333
|
+
return { ok: false, why: describeSnapshotFailure(after) };
|
|
1334
|
+
}
|
|
1335
|
+
/** Put the window chrome back. Runs on every exit path. */
|
|
1336
|
+
async function restoreChrome(ctx, chrome) {
|
|
1337
|
+
if (!chrome.revealedSidebar)
|
|
1338
|
+
return null;
|
|
1339
|
+
const res = await runCmd(ctx, sidebarVisibilityCommand("hide"));
|
|
1340
|
+
let clicked = false;
|
|
1341
|
+
if (res.ok) {
|
|
1342
|
+
try {
|
|
1343
|
+
clicked = JSON.parse(res.stdout.trim()).clicked === true;
|
|
1344
|
+
}
|
|
1345
|
+
catch {
|
|
1346
|
+
clicked = false;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
return clicked
|
|
1350
|
+
? "the sidebar was shown to run the move and hidden again afterwards"
|
|
1351
|
+
: "the sidebar was shown to run the move and could NOT be hidden again — hide it with " +
|
|
1352
|
+
"View ▸ Hide Sidebar (⌘/)";
|
|
1353
|
+
}
|
|
1354
|
+
function withChromeOutcome(result, note) {
|
|
1355
|
+
return note === null ? result : { ...result, detail: `${result.detail} (${note})` };
|
|
1356
|
+
}
|
|
1086
1357
|
/** The ladder proper. Its caller owns the collapse ledger and the restore. */
|
|
1087
|
-
async function runDragLadder(ctx, spec, collapsed) {
|
|
1358
|
+
async function runDragLadder(ctx, spec, collapsed, chrome) {
|
|
1088
1359
|
const pre = ctx.state();
|
|
1089
1360
|
const preTies = hasRankTies(pre);
|
|
1090
1361
|
const areaTitles = pre.areas.map((a) => a.title);
|
|
@@ -1168,10 +1439,27 @@ async function runDragLadder(ctx, spec, collapsed) {
|
|
|
1168
1439
|
let hopCap = Math.min(MAX_HOPS_CEILING, pre.areas.length + 2);
|
|
1169
1440
|
for (let attempt = 0; attempt <= MAX_HOPS_CEILING; attempt++) {
|
|
1170
1441
|
// every hop depends on the layout the previous hop produced
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1442
|
+
let snapOutcome = await takeSnapshot(ctx);
|
|
1443
|
+
// A HIDDEN sidebar is normalized, not refused: Things' own View menu shows
|
|
1444
|
+
// it, the epilogue hides it again, and the refusal below is reached only
|
|
1445
|
+
// when that fails. Everything else is reported as the cause it actually is.
|
|
1446
|
+
if (!snapOutcome.ok && snapOutcome.why === "sidebar-hidden") {
|
|
1447
|
+
const revealed = await revealSidebar(ctx, chrome);
|
|
1448
|
+
snapOutcome = revealed.ok
|
|
1449
|
+
? { ok: true, snapshot: revealed.snapshot }
|
|
1450
|
+
: {
|
|
1451
|
+
ok: false,
|
|
1452
|
+
why: "sidebar-hidden",
|
|
1453
|
+
...{ stderr: revealed.why },
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
if (!snapOutcome.ok) {
|
|
1457
|
+
const why = snapOutcome.why === "sidebar-hidden" && snapOutcome.stderr !== undefined
|
|
1458
|
+
? `the sidebar is hidden in this Things window and showing it did not work (${snapOutcome.stderr})`
|
|
1459
|
+
: describeSnapshotFailure(snapOutcome);
|
|
1460
|
+
return refuseOrRecover(ctx, pre, spec, hops, why);
|
|
1174
1461
|
}
|
|
1462
|
+
const snap = snapOutcome.snapshot;
|
|
1175
1463
|
const viewport = snap.viewport;
|
|
1176
1464
|
{
|
|
1177
1465
|
// ceil(areas / visible-slots) + 2, from the frame-derived slot pitch.
|
|
@@ -1319,7 +1607,7 @@ async function runDragLadder(ctx, spec, collapsed) {
|
|
|
1319
1607
|
continue;
|
|
1320
1608
|
const maxTicks = Math.min(400, Math.max(20, Math.ceil(travel / 15)));
|
|
1321
1609
|
// the held gesture must complete before its DB assert
|
|
1322
|
-
const res = await runCmd(ctx, heldScrollDragCommand(g.x, g.y, anchor, maxTicks));
|
|
1610
|
+
const res = await runCmd(ctx, heldScrollDragCommand(g.x, g.y, anchor, maxTicks, ctx.areaTitles));
|
|
1323
1611
|
const parsed = parseHeldDragResult(res);
|
|
1324
1612
|
if (parsed.dropped) {
|
|
1325
1613
|
// the final assert gates success
|