diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.h b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.h index 33493bec41..3330817e67 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.h +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.h @@ -4,27 +4,25 @@ NS_ASSUME_NONNULL_BEGIN -/// Keys of the `deepExtension` dictionary in the snapshot response, shared -/// with the Swift consumer so the response shape has one spelling. +/// Keys of the `deepExtension` dictionary in the snapshot response, shared with the Swift consumer +/// so the response shape has one spelling. FOUNDATION_EXPORT NSString *const RunnerAXSnapshotDeepExtensionKey; FOUNDATION_EXPORT NSString *const RunnerAXSnapshotDeepExtensionCallsKey; FOUNDATION_EXPORT NSString *const RunnerAXSnapshotDeepExtensionNodesAddedKey; FOUNDATION_EXPORT NSString *const RunnerAXSnapshotDeepExtensionPendingKey; FOUNDATION_EXPORT NSString *const RunnerAXSnapshotDeepExtensionMissedKey; -/// Keys of the `customActions` dictionary in the snapshot response: how many -/// merged elements were eligible for an action read, and how many the bounded -/// pass actually reached. Present only when the capture asked for actions. +/// Keys of the `customActions` dictionary in the snapshot response: how many merged elements were +/// eligible for an action read, and how many the bounded pass actually reached. Present only when +/// the capture asked for actions. FOUNDATION_EXPORT NSString *const RunnerAXSnapshotCustomActionsKey; FOUNDATION_EXPORT NSString *const RunnerAXSnapshotCustomActionsReadKey; FOUNDATION_EXPORT NSString *const RunnerAXSnapshotCustomActionsCandidatesKey; FOUNDATION_EXPORT NSString *const RunnerAXSnapshotCustomActionsTruncatedKey; FOUNDATION_EXPORT NSString *const RunnerAXSnapshotCustomActionsBlockedKey; -/// A depth-capped childless node awaiting an element-rooted follow-up request. -/// Public so the runner unit bundle can drive the extension's miss paths with -/// fabricated snapshots — the executed contract that missed frontiers are -/// counted, never silently dropped. +/// A depth-capped childless node awaiting an element-rooted follow-up request. Public so the runner +/// unit bundle can drive the extension's miss paths with fabricated snapshots. @interface RunnerAXSnapshotFrontier : NSObject @property(nonatomic, strong, nullable) id snapshot; @property(nonatomic, strong, nullable) NSMutableDictionary *node; @@ -33,15 +31,12 @@ FOUNDATION_EXPORT NSString *const RunnerAXSnapshotCustomActionsBlockedKey; @interface RunnerAXSnapshotBridge : NSObject -/// Captures the app's accessibility tree, then chains element-rooted follow-up -/// requests from depth-capped frontier nodes (the AX depth limit is -/// per-request, so re-rooting reaches content the app-rooted request could -/// not). The response gains a `deepExtension` {calls, nodesAdded, -/// pendingFrontiers, missedFrontiers} dictionary when any frontier existed and -/// the call limit allowed extension. Pass 0 to disable extension entirely. -/// -/// `customActionLimit` bounds the per-element custom-action reads described on -/// `customActionNamesForElement:axClient:`; pass 0 to skip them entirely. +/// Captures the app's accessibility tree, then chains element-rooted follow-up requests from +/// depth-capped frontier nodes: the AX depth limit is per-request, so re-rooting reaches content +/// the app-rooted request could not. The response gains a `deepExtension` {calls, nodesAdded, +/// pendingFrontiers, missedFrontiers} dictionary when any frontier existed and the call limit +/// allowed extension. Pass 0 to disable extension entirely. `customActionLimit` bounds the +/// per-element custom-action reads; pass 0 to skip them entirely. + (NSDictionary *)snapshotTreeForApplication:(XCUIApplication *)application maxDepth:(NSInteger)maxDepth maxNodes:(NSInteger)maxNodes @@ -49,10 +44,9 @@ FOUNDATION_EXPORT NSString *const RunnerAXSnapshotCustomActionsBlockedKey; customActionLimit:(NSInteger)customActionLimit deadline:(nullable NSDate *)deadline; -/// The capture behind `snapshotTreeForApplication:`, for an AX client and target -/// application element that are already resolved. Every request, extension, and -/// custom-action decision happens here; the application entry point only resolves -/// the two identities. +/// The capture behind `snapshotTreeForApplication:`, for an AX client and target application +/// element that are already resolved. Every request, extension, and custom-action decision happens +/// here; the application entry point only resolves the two identities. + (NSDictionary *)snapshotTreeWithClient:(id)axClient target:(id)target maxDepth:(NSInteger)maxDepth @@ -61,37 +55,25 @@ FOUNDATION_EXPORT NSString *const RunnerAXSnapshotCustomActionsBlockedKey; customActionLimit:(NSInteger)customActionLimit deadline:(nullable NSDate *)deadline; -/// Names of the element's UIAccessibilityCustomActions, or nil when it has -/// none. -/// -/// One element per call on purpose. The bulk snapshot request cannot carry this -/// attribute: adding it makes testmanagerd's reply decoder reject the nested -/// arrays a custom action serializes into ("value for key 'NS.objects' was of -/// unexpected class 'NSArray'"), drop the reply, and time the request out after -/// ~65s instead of the usual ~110ms. Per-element reads use a different XPC -/// message and answer normally. -/// -/// Reading is all we can do: the actions are not invocable from the runner. -/// XCUIElement exposes no custom-action API; XCAXClient_iOS's -/// performAction:onElement:value: answers kAXErrorCannotComplete for every AX -/// action number; setAttribute: on the custom-actions attribute reports success -/// and does nothing; parameterizedAttribute: answers kAXErrorNoValue; and the -/// raw AXUIElement C API cannot be aimed at app elements because -/// XCAccessibilityElement is token-based and answers nil for AXUIElement. +/// Names of the element's UIAccessibilityCustomActions, or nil when it has none. One element per +/// call on purpose: the bulk snapshot request cannot carry this attribute — its nested arrays make +/// testmanagerd's reply decoder drop the reply and time the request out — while per-element reads +/// use a different XPC message and answer normally. Reading is all this does: none of the +/// invocation routes work from the runner (XCUIElement exposes no API, XCAXClient_iOS +/// performAction:onElement:value: answers kAXErrorCannotComplete, setAttribute: silently no-ops, +/// and the raw AXUIElement C API cannot be aimed at app elements). + (nullable NSArray *)customActionNamesForElement:(id)element axClient:(id)axClient completed:(BOOL *)completed; -/// Bounds one element's contribution to the response (max actions, max name -/// length). Reports whether anything was clipped so truncation is disclosed -/// rather than silently presented as a complete list. +/// Bounds one element's action list (max actions, max name length) and reports whether anything +/// was clipped, so a clipped list is disclosed rather than silently presented as a complete list. + (NSArray *)cappedActionNames:(NSArray *)names truncated:(BOOL *)truncated; -/// The bounded read pass over merged leaves, exposed for the runner unit -/// bundle: the containment contract (a hung read blocks the pass and is -/// disclosed, and adds no further dispatches) is only executable from here. -/// Returns {read, candidates, truncated, blocked}. +/// The bounded read pass over merged leaves, exposed for the runner unit bundle: the containment +/// contract (a hung read blocks the pass, is disclosed, and adds no further dispatches) is only +/// executable from here. Returns {read, candidates, truncated, blocked}. + (NSDictionary *) annotateCustomActionsOnMergedLeaves:(nullable NSArray *)leaves axClient:(id)axClient @@ -99,21 +81,19 @@ FOUNDATION_EXPORT NSString *const RunnerAXSnapshotCustomActionsBlockedKey; rootFrame:(CGRect)rootFrame deadline:(nullable NSDate *)deadline; -/// Reads dispatched but not yet returned. The AX call cannot be cancelled, so a -/// read that blew its deadline stays counted here until it finally returns — -/// which is precisely the state in which further reads must be refused rather -/// than queued behind it. Consumed by the read pass to disclose the skip. +/// Reads dispatched but not yet returned. The AX call cannot be cancelled, so a read that blew its +/// deadline stays counted here until it returns — the state in which further reads are refused +/// rather than queued behind it. + (NSInteger)customActionReadsInFlight; -/// Total reads ever dispatched. The containment invariant is "a blocked capture -/// adds no work", which is only observable as this counter standing still. +/// Total reads ever dispatched. + (NSInteger)customActionReadDispatchCount; /// Total reads refused by single-flight admission. + (NSInteger)customActionReadBlockedCount; -/// The shared AX client (`XCUIDevice.accessibilityInterface`), or nil when the -/// private interface is unavailable. +/// The shared AX client (`XCUIDevice.accessibilityInterface`), or nil when the private interface is +/// unavailable. + (nullable id)accessibilityClient; + (NSInteger)processIdentifierForApplication:(XCUIApplication *)application; @@ -122,10 +102,8 @@ FOUNDATION_EXPORT NSString *const RunnerAXSnapshotCustomActionsBlockedKey; /// snapshot target match uses; pids only — the client resolves no bundle id for an arbitrary app. + (NSArray *)activeApplicationProcessIdentifiers; -/// The frontier-extension loop, exposed for the runner unit bundle: a frontier -/// whose element vanished or whose re-rooted request fails must count as -/// missed (see the deepExtension keys above) — an all-miss extension reporting -/// itself drained would present a capped capture as complete. +/// The frontier-extension loop, exposed for the runner unit bundle: a frontier whose element +/// vanished or whose re-rooted request fails must count as missed, never as drained. + (nullable NSDictionary *)extendSnapshotFrontiers:(NSMutableArray *)frontiers axClient:(id)axClient attributes:(NSArray *)attributes diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m index 81d1692e13..10e4eae9ff 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m @@ -21,28 +21,19 @@ NSString *const RunnerAXSnapshotCustomActionsTruncatedKey = @"truncated"; NSString *const RunnerAXSnapshotCustomActionsBlockedKey = @"blocked"; -/// The AX server's name for UIAccessibilityCustomActions (React Native's -/// `accessibilityActions` prop lands here too). +// The AX server's name for UIAccessibilityCustomActions (React Native's `accessibilityActions` +// prop lands here too). static NSString *const RunnerAXCustomActionsAttribute = @"XC_kAXXCAttributeCustomActions"; -/// A single read costs ~100ms against a responsive app. This bound exists for -/// the unresponsive case: without it one wedged element would swallow the whole -/// capture's budget and starve every remaining candidate, turning a bounded -/// enrichment into a capture-length stall. +// Bounds one read. It exists for the unresponsive case: without it one wedged element would +// swallow the whole capture's budget and starve every remaining candidate. static const NSTimeInterval RunnerAXCustomActionReadTimeout = 1.0; -/// The AX call is a synchronous XPC round trip that cannot be cancelled once -/// issued, so the deadline above only frees the CALLER — the call itself keeps -/// running. Containment therefore has two parts, and both are needed: -/// -/// 1. every read runs on this one serial queue, so a wedged call can never be -/// joined by a second concurrent user of the shared XCAXClient; -/// 2. a single-flight guard (below) refuses to dispatch while an abandoned -/// read is still outstanding, so repeated captures cannot pile up work -/// behind it. -/// -/// Reads resume by themselves once the hung call returns (or the runner -/// restarts); nothing has to be reset by hand. +// The AX call is a synchronous XPC round trip that cannot be cancelled once issued, so the +// timeout above only frees the caller — the call keeps running. Containment needs both parts: +// every read runs on this one serial queue because the shared XCAXClient has no concurrent user, +// and the single-flight guard refuses to dispatch while an abandoned read is outstanding. Reads +// resume by themselves once the hung call returns; nothing has to be reset by hand. static dispatch_queue_t RunnerAXCustomActionReadQueue(void) { static dispatch_queue_t queue; @@ -54,21 +45,19 @@ static dispatch_queue_t RunnerAXCustomActionReadQueue(void) return queue; } -/// Reads dispatched but not yet returned. Non-zero after a read blew its -/// deadline, which is exactly the condition the single-flight guard refuses on. +// Reads dispatched but not yet returned. Non-zero after a read blew its deadline, which is the +// condition the single-flight guard refuses on. static atomic_int RunnerAXCustomActionReadsInFlight = 0; -/// Total dispatches. The invariant the containment fix exists to protect is -/// "a blocked capture adds no work", which is only observable as this counter -/// staying put, so it is recorded rather than inferred. +// Total dispatches. The invariant "a blocked capture adds no work" is observable only as this +// counter staying put, so it is recorded rather than inferred. static atomic_long RunnerAXCustomActionReadDispatches = 0; -/// Total single-flight admission refusals. +// Total single-flight admission refusals. static atomic_long RunnerAXCustomActionReadBlocked = 0; -/// Output caps. The element budget bounds how many elements we read; these -/// bound what any ONE element can put in the response, so a pathological app -/// cannot spend the whole snapshot on one node's action list. +// Output caps. The element budget bounds how many elements are read; these bound ONE element's +// action list, so a pathological app cannot spend the whole snapshot on one node's list. static const NSUInteger RunnerAXCustomActionsMaxPerElement = 8; static const NSUInteger RunnerAXCustomActionNameMaxLength = 80; @@ -76,12 +65,6 @@ static dispatch_queue_t RunnerAXCustomActionReadQueue(void) typedef NSInteger (*RunnerAXIntegerMsgSend)(id, SEL); typedef id (*RunnerAXSnapshotMsgSend)(id, SEL, id, id, id, NSError **); -// A childless serialized node remembered with its depth (interface in the -// header for the unit bundle). Nodes at the deepest observed level of a -// depth-capped capture are the truncation frontier: the AX server withholds -// children uniformly at its per-request cap, so every capped branch ends at -// the same level (observed one BELOW the requested maxDepth — the server -// counts node levels, not edges). @implementation RunnerAXSnapshotFrontier @end @@ -139,8 +122,8 @@ @implementation RunnerAXSnapshotBridge BOOL truncated = NO; NSInteger nodeCount = 0; - // nil disables candidate collection entirely — exact --depth captures pay - // zero extension bookkeeping. + // nil disables candidate collection entirely — exact --depth captures pay zero extension + // bookkeeping. NSMutableArray *candidates = deepExtensionCallLimit > 0 ? [NSMutableArray array] : nil; NSMutableArray *merged = @@ -199,12 +182,11 @@ @implementation RunnerAXSnapshotBridge } } -/// Chains element-rooted snapshot requests from depth-capped frontier nodes. -/// The AX server's depth limit is per-request (kAXErrorIllegalArgument above a -/// tree-size-dependent value), so re-rooting the SAME request at a frontier -/// node's live accessibility element reaches content the app-rooted request -/// could not, without ever passing a larger depth parameter. Bounded by call -/// count, the shared node budget, and the capture-plan deadline. +// Chains element-rooted snapshot requests from depth-capped frontier nodes. The AX server's depth +// limit is per-request (kAXErrorIllegalArgument above a tree-size-dependent value), so re-rooting +// the same request at a frontier node's live element reaches content the app-rooted request could +// not, without passing a larger depth parameter. Bounded by call count, the shared node budget, +// and the capture deadline checked between requests. + (nullable NSDictionary *)extendSnapshotFrontiers:(NSMutableArray *)frontiers axClient:(id)axClient attributes:(NSArray *)attributes @@ -221,10 +203,8 @@ + (nullable NSDictionary *)extendSnapshotFrontiers:(NSMutableArray 0) { if (callsUsed >= callsAllowed || *nodeCount >= maxNodes @@ -254,8 +234,8 @@ + (nullable NSDictionary *)extendSnapshotFrontiers:(NSMutableArray *subCandidates = [NSMutableArray array]; NSMutableArray *children = [NSMutableArray array]; @@ -291,10 +271,9 @@ + (nullable NSDictionary *)extendSnapshotFrontiers:(NSMutableArray *)cappedFrontiersFromCandidates: (NSArray *)candidates maxDepth:(NSInteger)maxDepth @@ -372,9 +351,9 @@ + (NSArray *)snapshotAttributes @"hasFocus", @"children", ]; - // The AX server expects real accessibility attribute identifiers, not snapshot keypath - // strings; passing raw keypaths silently drops attributes it does not recognize (frame - // came back zeroed). XCElementSnapshot owns the keypath -> AX attribute mapping. + // The AX server expects real accessibility attribute identifiers, not snapshot keypath strings; + // raw keypaths silently drop attributes (frame came back zeroed). XCElementSnapshot owns the + // keypath -> AX attribute mapping. NSArray *attributes = keyPaths; Class snapshotClass = NSClassFromString(@"XCElementSnapshot"); SEL mapSelector = NSSelectorFromString(@"axAttributesForElementSnapshotKeyPaths:isMacOS:"); @@ -386,9 +365,8 @@ + (NSArray *)snapshotAttributes mapped = [(NSSet *)mapped allObjects]; } if ([mapped isKindOfClass:NSArray.class] && [(NSArray *)mapped count] > 0) { - // The mapper expands keypaths with extra attributes (automation type, window display - // id, base type) that are disproportionately expensive for the AX server to compute - // on large React Native trees. Keep only the attributes we actually consume. + // The mapper expands keypaths with extra attributes that are disproportionately expensive + // for the AX server to compute on large trees. Keep only what is consumed here. NSArray *needed = @[ @"ElementType", @"Identifier", @"Label", @"Value", @"PlaceholderValue", @"Frame", @"Enabled", @"Selected", @"Focus" ]; NSMutableArray *filtered = [NSMutableArray array]; @@ -407,29 +385,15 @@ + (NSArray *)snapshotAttributes return attributes; } -/// Reads custom actions for the merged leaves in traversal order until the -/// limit or the capture deadline is spent. Each read is its own AX round trip -/// (~100ms on an idle simulator), so this is strictly opt-in and strictly -/// bounded. -/// -/// Returns {read, candidates} so the response can DISCLOSE an incomplete pass. -/// An unread element is byte-identical to one that genuinely has no actions, so -/// silence here would teach a reader that later cards have no affordances — -/// exactly the mis-inference this capture exists to prevent. -/// -/// `candidates` counts only elements still eligible at read time: a leaf that -/// deep extension filled in since serialization is not a merged element, so -/// counting it would inflate the denominator with nodes that never needed a -/// read. A leaf whose live element vanished stays counted and unread, because -/// that one really is an element we failed to answer for. -/// -/// On-screen candidates are read first. The budget is smaller than a long -/// feed's card count, so the order decides which elements an agent can act on -/// at all — and it also makes the disclosure's remedy true: scrolling changes -/// the on-screen set, so a re-run reads elements the previous pass could not. -/// Note that `--scope` deliberately is NOT the ordering key: scope is applied -/// when the Swift walk builds nodes, long after this pass, so it cannot narrow -/// the read budget without duplicating the scope predicate here. +// Reads custom actions for the merged leaves in traversal order until the limit or the deadline is +// spent; each read is its own AX round trip, so this is strictly opt-in and strictly bounded. +// The counts exist so the response can disclose an incomplete pass: an unread element is +// byte-identical to one with no actions. `candidates` counts only elements still eligible at read +// time; a leaf whose live element vanished stays counted and unread. On-screen candidates are read +// first: the budget is smaller than a long feed's card count, so the order decides which elements +// an agent can act on, and scrolling changes the on-screen set so a re-run reaches what the +// previous pass could not. `--scope` is deliberately not the ordering key: the Swift walk applies +// it long after this pass, so it cannot narrow the read budget without duplicating the predicate. + (NSDictionary *) annotateCustomActionsOnMergedLeaves:(nullable NSArray *)leaves axClient:(id)axClient @@ -460,10 +424,8 @@ + (NSArray *)snapshotAttributes if (attempts >= limit || (nil != deadline && deadline.timeIntervalSinceNow <= 0)) { break; } - // An abandoned read from an earlier capture is still outstanding. Stop the - // pass here rather than spending a deadline per element on reads that will - // all be refused, and record why so the response does not present the - // unread elements as "has no actions". + // An abandoned read from an earlier capture is still outstanding: stop here rather than spend + // a deadline per element on reads that will all be refused, and record why. if ([self customActionReadsInFlight] > 0) { blocked = YES; break; @@ -472,9 +434,8 @@ + (NSArray *)snapshotAttributes if (nil == element) { continue; } - // The attempt is what the budget buys; only a completed read counts as - // read, so a timed-out element stays in the undisclosed remainder instead - // of being reported as "read, and it has no actions". + // The budget buys attempts; only a completed read counts as read, so a timed-out element stays + // in the undisclosed remainder. attempts += 1; BOOL completed = NO; NSArray *names = [self customActionNamesForElement:element @@ -507,8 +468,8 @@ + (NSArray *)snapshotAttributes }; } -/// An empty root frame means the capture has no viewport to judge against, so -/// every candidate counts as on-screen and the order stays document order. +// An empty root frame means the capture has no viewport to judge against, so every candidate +// counts as on-screen and the order stays document order. + (BOOL)isFrameOnScreen:(id)frameValue rootFrame:(CGRect)rootFrame { if (CGRectIsEmpty(rootFrame)) { @@ -544,9 +505,9 @@ + (BOOL)isFrameOnScreen:(id)frameValue rootFrame:(CGRect)rootFrame atomic_fetch_add(&RunnerAXCustomActionReadBlocked, 1); return nil; } - // The AX call is a synchronous XPC round trip with no timeout of its own, so - // it runs off-thread behind a bounded wait. On timeout the result box is - // never read, so a late-returning wedged call cannot race this thread. + // The AX call is a synchronous XPC round trip with no timeout of its own, so it runs off-thread + // behind a bounded wait. On timeout the result box is never read, so a late-returning wedged + // call cannot race this thread. atomic_fetch_add(&RunnerAXCustomActionReadDispatches, 1); NSMutableArray *box = [NSMutableArray array]; dispatch_semaphore_t finished = dispatch_semaphore_create(0); @@ -563,8 +524,8 @@ + (BOOL)isFrameOnScreen:(id)frameValue rootFrame:(CGRect)rootFrame if (nil != result) { [box addObject:result]; } - // Clear in-flight BEFORE signalling, so a waiter that wakes on this signal - // never observes its own completed read as still outstanding. + // Clear in-flight BEFORE signalling, so a waiter that wakes here never observes its own + // completed read as still outstanding. atomic_store(&RunnerAXCustomActionReadsInFlight, 0); dispatch_semaphore_signal(finished); }); @@ -599,11 +560,9 @@ + (BOOL)isFrameOnScreen:(id)frameValue rootFrame:(CGRect)rootFrame return names.count > 0 ? names.copy : nil; } -/// Bounds what one element can contribute to the response. The names come from -/// the app, so their count and length are the app's choice, not ours — a list -/// long enough to dominate a snapshot is a plausible accident (generated rows) -/// as well as a hostile case. Truncation is reported, never silent: a clipped -/// list is exactly as misleading as an unread element if it looks complete. +// Bounds what one element can contribute to the response. The names come from the app, so their +// count and length are the app's choice; truncation is reported, never silent, because a clipped +// list is as misleading as an unread element if it looks complete. + (NSArray *)cappedActionNames:(NSArray *)names truncated:(BOOL *)truncated { @@ -689,9 +648,8 @@ + (NSInteger)integerFrom:(id)target selectorName:(NSString *)selectorName if (![target respondsToSelector:selector]) { return 0; } - // processID/processIdentifier return pid_t (int32); reading them through an - // NSInteger-returning cast is not upper-32-bit safe on arm64. Use the method - // signature to pick the correctly sized call. + // processID/processIdentifier return pid_t (int32); reading them through an NSInteger-returning + // cast is not upper-32-bit safe on arm64. Use the method signature to pick the sized call. NSMethodSignature *signature = [target methodSignatureForSelector:selector]; const char *returnType = signature.methodReturnType; if (returnType != NULL && strcmp(returnType, @encode(int)) == 0) { @@ -794,9 +752,9 @@ + (nullable NSMutableDictionary *)dictionaryForSnapshot:(id)snapshot if (children.count == 0 && nil != mergedLeaves && [result[@"label"] isKindOfClass:NSString.class] && [(NSString *)result[@"label"] length] > 0) { - // A labelled childless node is the shape a merged card takes: the container - // is marked accessible, so its real controls never appear as children. Only - // these are worth an action read — unlabelled leaves are decoration. + // A labelled childless node is the shape a merged card takes: the container is marked + // accessible, so its real controls never appear as children. Only these are worth an action + // read — unlabelled leaves are decoration. RunnerAXSnapshotFrontier *candidate = [[RunnerAXSnapshotFrontier alloc] init]; candidate.snapshot = snapshot; candidate.node = result; @@ -804,12 +762,9 @@ + (nullable NSMutableDictionary *)dictionaryForSnapshot:(id)snapshot [mergedLeaves addObject:candidate]; } if (children.count == 0 && nil != frontiers && depth >= maxDepth - 1) { - // Childless node at the cap boundary: either a real leaf or a branch whose - // children the AX server withheld. The two are indistinguishable here, so - // boundary childless nodes become candidates; the caller keeps only the - // deepest observed level, where every capped branch necessarily ends. - // Shallower childless nodes are provably real leaves and are never - // collected. + // Childless node at the cap boundary: either a real leaf or a branch whose children the AX + // server withheld. The two are indistinguishable here, so boundary childless nodes become + // candidates; shallower childless nodes are provably real leaves and are never collected. RunnerAXSnapshotFrontier *frontier = [[RunnerAXSnapshotFrontier alloc] init]; frontier.snapshot = snapshot; frontier.node = result;