diff --git a/apple/snapshot-bridge/README.md b/apple/snapshot-bridge/README.md index fb6852eb80..676cbd6b6b 100644 --- a/apple/snapshot-bridge/README.md +++ b/apple/snapshot-bridge/README.md @@ -63,3 +63,19 @@ use non-launch failure codes, so the route falls back without launch re-polling. At each native fragment boundary, an absent or invalid child count means unknown completeness and fails closed. Natural leaves above that boundary need no continuation evidence. Unchanged native dictionaries and child arrays are reused. + +## Recovery conformance + +`contracts/fixtures/ios-ax-recovery-conformance.json` is the shared, executable +recovery contract for this bridge and the XCTest runner's private AX bridge. +`packages/platform-apple/src/snapshot-source/fixtures/recovery-conformance.m` +replays each case through `captureSnapshotTree`; the runner adapter that +replays the same cases through the XCTest runner's private AX bridge is +pending in [#2428](https://github.com/callstack/agent-device/pull/2428), so at +this revision only the fixture's `host-bridge` column is executed. Each +expectation names the +outcome, the native request accounting, and the delivered tree as a canonical +preorder signature with its retained node count, so a producer that drops, +duplicates, reorders, or re-parents nodes cannot pass as complete. The fixture +records each producer's expectation and documents the intentional differences +(depth vocabulary, ladders, completeness evidence, budgets, hint lifetime). diff --git a/contracts/fixtures/ios-ax-recovery-conformance.json b/contracts/fixtures/ios-ax-recovery-conformance.json new file mode 100644 index 0000000000..85df4ed565 --- /dev/null +++ b/contracts/fixtures/ios-ax-recovery-conformance.json @@ -0,0 +1,567 @@ +{ + "version": 1, + "description": "Shared, executable recovery contract for the two iOS Simulator accessibility producers: the host AX bridge (apple/snapshot-bridge) and the XCTest runner's private AX bridge (apple/runner). Each producer runs every case through its own adapter and interprets the synthetic native world in its own model; expectations are recorded per producer so intentional differences stay visible instead of being averaged away.", + "nativeModel": { + "tree": "A chain of `chain` nodes at levels 0..chain-1 (labels are the level number). An optional `fan` gives the last chain node `count` children at level `at + 1`, each heading a further chain of `chain` nodes; fan labels are `.`.", + "request": "A native request rooted at level r for L node levels returns levels r..r+L-1 that exist. `rejectLevelsAbove` rejects any request asking for more node levels than that with the producer's depth-rejection code.", + "frontierEvidence": "`counted` gives every returned node its true child count (host only; the runner has no counts). `unknown` omits the count on the deepest level of every returned fragment.", + "vanishAtFrontier": "The deepest level of every returned fragment has no live accessibility element, so no continuation can be rooted there.", + "ownerChangesAfterRequests": "The primary foreground owner changes after that many native requests (host only).", + "deadlineAfterRequests": "The capture deadline is spent after that many native requests (runner only; the host deadline is the guest watchdog and the host request timeout).", + "signature": "`tree` is the delivered tree in preorder: every node's identity, with ` + +static NSString *const kAttributes = @"UIAccessibilitySnapshotKeyAttributes"; +static NSString *const kChildren = @"UIAccessibilitySnapshotKeyChildren"; +static NSString *const kChildCount = @"UIAccessibilitySnapshotKeyChildrenCount"; +static NSString *const kElement = @"UIAccessibilitySnapshotKeyElement"; +static NSString *const kIdentity = @"identity"; + +@interface FixtureNode : NSObject +@property(nonatomic) NSUInteger level; +@property(nonatomic, copy) NSString *identity; +@property(nonatomic, weak) FixtureNode *parent; +@property(nonatomic, strong) NSMutableArray *children; +@end +@implementation FixtureNode +@end + +static FixtureNode *makeNode(NSUInteger level, NSString *identity) +{ + FixtureNode *node = [FixtureNode new]; + node.level = level; + node.identity = identity; + node.children = [NSMutableArray array]; + return node; +} + +static void buildChain(FixtureNode *parent, NSUInteger count, NSString *branch) +{ + FixtureNode *current = parent; + for (NSUInteger index = 0; index < count; index++) { + NSUInteger level = current.level + 1; + FixtureNode *next = makeNode(level, branch ? [NSString stringWithFormat:@"%lu.%@", (unsigned long)level, branch] + : @(level).stringValue); + next.parent = current; + [current.children addObject:next]; + current = next; + } +} + +static FixtureNode *buildTree(NSDictionary *tree, NSMutableDictionary *index) +{ + FixtureNode *root = makeNode(0, @"0"); + buildChain(root, [tree[@"chain"] unsignedIntegerValue] - 1, nil); + NSDictionary *fan = tree[@"fan"]; + if ([fan isKindOfClass:NSDictionary.class]) { + FixtureNode *at = root; + while (at.children.count) at = at.children.firstObject; + for (NSUInteger branch = 0; branch < [fan[@"count"] unsignedIntegerValue]; branch++) { + NSString *name = @(branch).stringValue; + FixtureNode *head = makeNode(at.level + 1, [NSString stringWithFormat:@"%lu.%@", (unsigned long)at.level + 1, name]); + head.parent = at; + [at.children addObject:head]; + buildChain(head, [fan[@"chain"] unsignedIntegerValue], name); + } + } + void (^visit)(FixtureNode *) = ^(FixtureNode *node) { + index[node.identity] = node; + }; + NSMutableArray *queue = [NSMutableArray arrayWithObject:root]; + while (queue.count) { + FixtureNode *node = queue.firstObject; + [queue removeObjectAtIndex:0]; + visit(node); + [queue addObjectsFromArray:node.children]; + } + return root; +} + +static NSDictionary *fragment(FixtureNode *node, NSUInteger remainingLevels, BOOL unknownAtBoundary, BOOL vanishAtBoundary) +{ + BOOL boundary = remainingLevels == 1; + NSMutableArray *children = [NSMutableArray array]; + if (!boundary) { + for (FixtureNode *child in node.children) { + [children addObject:fragment(child, remainingLevels - 1, unknownAtBoundary, vanishAtBoundary)]; + } + } + NSMutableDictionary *result = [@{kAttributes: @{kIdentity: node.identity}, kChildren: children} mutableCopy]; + if (!(boundary && unknownAtBoundary)) result[kChildCount] = @(node.children.count); + if (!(boundary && vanishAtBoundary)) result[kElement] = node.identity; + return result; +} + +static NSUInteger identityLevel(NSString *identity) +{ + return (NSUInteger)[[identity componentsSeparatedByString:@"."].firstObject integerValue]; +} + +static NSString *identityBranch(NSString *identity) +{ + NSArray *parts = [identity componentsSeparatedByString:@"."]; + return parts.count > 1 ? parts[1] : @""; +} + +/// Bounded by the bridge's node limit: a producer that emits a cyclic or shared subtree must read +/// as an oversized signature, not as a hang. +static const NSUInteger maximumSignatureNodes = 10000; + +static void collectPreorder(NSDictionary *node, NSString *parent, NSMutableArray *> *out) +{ + if (out.count >= maximumSignatureNodes) return; + NSString *identity = node[kAttributes][kIdentity]; + [out addObject:@[identity, parent ?: @""]]; + for (NSDictionary *child in node[kChildren]) collectPreorder(child, identity, out); +} + +/// The canonical signature described by the fixture's `nativeModel.signature`: preorder identities, +/// ` *index, NSUInteger *count) +{ + NSMutableArray *> *preorder = [NSMutableArray array]; + collectPreorder(tree, nil, preorder); + *count = preorder.count; + NSMutableArray *tokens = [NSMutableArray array]; + __block NSString *runStart = nil; + __block NSString *runLast = nil; + void (^flush)(void) = ^{ + if (!runStart) return; + [tokens addObject:[runStart isEqual:runLast] ? runStart : [NSString stringWithFormat:@"%@-%@", runStart, runLast]]; + }; + for (NSArray *entry in preorder) { + NSString *identity = entry[0]; + NSString *observedParent = entry[1]; + NSString *canonicalParent = index[identity].parent.identity ?: @""; + if (![observedParent isEqual:canonicalParent]) { + flush(); + runStart = runLast = nil; + [tokens addObject:[NSString stringWithFormat:@"%@<%@", identity, observedParent]]; + continue; + } + BOOL extends = runLast && [observedParent isEqual:runLast] && + [identityBranch(identity) isEqual:identityBranch(runLast)] && identityLevel(identity) == identityLevel(runLast) + 1; + if (extends) { + runLast = identity; + continue; + } + flush(); + runStart = runLast = identity; + } + flush(); + return [tokens componentsJoinedByString:@","]; +} + +static NSUInteger deepestLevel(NSDictionary *node) +{ + NSUInteger deepest = identityLevel(node[kAttributes][kIdentity]); + for (NSDictionary *child in node[kChildren]) deepest = MAX(deepest, deepestLevel(child)); + return deepest; +} + +static NSString *failureName(NSError *error) +{ + if ([error.domain isEqualToString:@"agent-device.snapshot"]) { + switch (error.code) { + case 1: return @"request-budget-exhausted"; + case 3: return @"continuation-element-missing"; + case 5: return @"owner-changed"; + case 6: return @"boundary-evidence-missing"; + default: return [NSString stringWithFormat:@"capture-%ld", (long)error.code]; + } + } + return @"rejected"; +} + +static int fail(NSString *caseName, NSString *message) +{ + fprintf(stderr, "%s: %s\n", caseName.UTF8String, message.UTF8String); + return 1; +} + +int main(int argc, const char *argv[]) +{ + @autoreleasepool { + if (argc != 3) { + fprintf(stderr, "usage: recovery-conformance \n"); + return 2; + } + NSData *data = [NSData dataWithContentsOfFile:@(argv[1])]; + NSDictionary *fixture = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:NULL] : nil; + NSString *caseName = @(argv[2]); + NSDictionary *recoveryCase = nil; + for (NSDictionary *candidate in fixture[@"recoveryCases"]) { + if ([candidate[@"name"] isEqual:caseName]) recoveryCase = candidate; + } + if (!recoveryCase) return fail(caseName, @"unknown recovery case"); + NSDictionary *expected = recoveryCase[@"expected"][@"host-bridge"]; + if ([expected[@"outcome"] isEqual:@"not-applicable"]) { + fprintf(stdout, "%s: not applicable to host-bridge (%s)\n", caseName.UTF8String, [expected[@"reason"] UTF8String]); + return 0; + } + + NSDictionary *request = recoveryCase[@"request"]; + NSDictionary *native = recoveryCase[@"native"]; + NSMutableDictionary *index = [NSMutableDictionary dictionary]; + FixtureNode *root = buildTree(native[@"tree"], index); + NSNumber *rejectAbove = [native[@"rejectLevelsAbove"] isKindOfClass:NSNumber.class] ? native[@"rejectLevelsAbove"] : nil; + NSNumber *ownerChangesAfter = [native[@"ownerChangesAfterRequests"] isKindOfClass:NSNumber.class] ? native[@"ownerChangesAfterRequests"] : nil; + BOOL unknownAtBoundary = [native[@"frontierEvidence"] isEqual:@"unknown"]; + BOOL vanishAtBoundary = [native[@"vanishAtFrontier"] boolValue]; + __block NSUInteger requests = 0; + __block NSUInteger rejected = 0; + + BOOL truncated = NO; + NSError *error = nil; + NSDictionary *result = captureSnapshotTree(root.identity, [request[@"traversalDepth"] unsignedIntegerValue], + [request[@"nodeBudget"] unsignedIntegerValue], + ^id(id element, NSUInteger levels, NSUInteger nodes, NSError **readError) { + requests++; + if (ownerChangesAfter && requests > ownerChangesAfter.unsignedIntegerValue) { + *readError = [NSError errorWithDomain:@"agent-device.snapshot" code:5 userInfo:nil]; + return nil; + } + if (rejectAbove && levels > rejectAbove.unsignedIntegerValue) { + rejected++; + *readError = [NSError errorWithDomain:@"AX" code:-25201 userInfo:@{@"accessibility-error": @(-25201)}]; + return nil; + } + FixtureNode *node = index[element]; + if (!node) { + *readError = [NSError errorWithDomain:@"fixture" code:0 userInfo:nil]; + return nil; + } + return fragment(node, levels, unknownAtBoundary, vanishAtBoundary); + }, &truncated, &error); + + // Continuations are the native requests beyond the root's accepted request: every request + // that reached the reader, minus the rejected ones, minus the root read that produced a tree. + NSMutableDictionary *actual = [NSMutableDictionary dictionary]; + actual[@"requests"] = @(requests); + actual[@"rejected"] = @(rejected); + actual[@"continuations"] = @(requests - rejected - (requests > rejected ? 1 : 0)); + if (result) { + NSUInteger nodes = 0; + actual[@"outcome"] = truncated ? @"incomplete" : @"complete"; + actual[@"deepestLevel"] = @(deepestLevel(result)); + actual[@"tree"] = treeSignature(result, index, &nodes); + actual[@"nodes"] = @(nodes); + } else { + actual[@"outcome"] = @"failed"; + actual[@"failure"] = failureName(error); + } + int status = 0; + for (NSString *key in expected) { + if ([key isEqual:@"reason"]) continue; + if (![expected[key] isEqual:actual[key]]) { + status = fail(caseName, [NSString stringWithFormat:@"%@ expected %@ but observed %@", key, expected[key], actual[key] ?: @"(absent)"]); + } + } + return status; + } +} diff --git a/packages/platform-apple/src/snapshot-source/native-runtime.test.ts b/packages/platform-apple/src/snapshot-source/native-runtime.test.ts index 21912f0449..1086cfdb58 100644 --- a/packages/platform-apple/src/snapshot-source/native-runtime.test.ts +++ b/packages/platform-apple/src/snapshot-source/native-runtime.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import path from 'node:path'; import { beforeAll, describe, test } from 'vitest'; import { runCmd } from '@agent-device/host-kit/command'; @@ -72,3 +73,54 @@ describe.skipIf(process.platform !== 'darwin')('native snapshot capture', () => assert.equal(result.exitCode, 0, result.stderr); }); }); + +const recoveryFixturePath = path.resolve( + import.meta.dirname, + '../../../../contracts/fixtures/ios-ax-recovery-conformance.json', +); +const recoveryFixture = JSON.parse(readFileSync(recoveryFixturePath, 'utf8')) as { + version: number; + recoveryCases: readonly { name: string }[]; +}; + +describe.skipIf(process.platform !== 'darwin')( + 'shared AX recovery conformance (host bridge)', + () => { + let binary: string; + beforeAll(async () => { + binary = path.join(await mkdtempForTest('snapshot-recovery-'), 'recovery-conformance'); + const nativeRoot = path.resolve(import.meta.dirname, '../../../../apple/snapshot-bridge'); + const compiled = await runCmd( + 'xcrun', + [ + '--sdk', + 'macosx', + 'clang', + '-fobjc-arc', + '-framework', + 'Foundation', + '-I', + nativeRoot, + path.join(nativeRoot, 'SnapshotBridgeCapture.m'), + path.join(import.meta.dirname, 'fixtures/recovery-conformance.m'), + '-o', + binary, + ], + { allowFailure: true, timeoutMs: 45_000 }, + ); + assert.equal(compiled.exitCode, 0, compiled.stderr); + }, 60_000); + + assert.equal(recoveryFixture.version, 1); + test.each(recoveryFixture.recoveryCases.map((recoveryCase) => recoveryCase.name))( + 'host bridge recovery matches the shared fixture: %s', + async (name) => { + const result = await runCmd(binary, [recoveryFixturePath, name], { + allowFailure: true, + timeoutMs: 5_000, + }); + assert.equal(result.exitCode, 0, result.stderr); + }, + ); + }, +);