/** * Copyright (c) 2015-present, Facebook, Inc. * All rights reserved. * * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. */ #import "FBXPath.h" #import "FBConfiguration.h" #import "FBExceptions.h" #import "FBElementUtils.h" #import "FBLogger.h" #import "FBMacros.h" #import "FBXMLGenerationOptions.h" #import "FBXCElementSnapshotWrapper+Helpers.h" #import "NSString+FBXMLSafeString.h" #import "XCUIApplication.h" #import "XCUIElement.h" #import "XCUIElement+FBCaching.h" #import "XCUIElement+FBUtilities.h" #import "XCUIElement+FBWebDriverAttributes.h" #import "XCTestPrivateSymbols.h" #import "FBElementHelpers.h" #import "FBXCAXClientProxy.h" #import "FBXCAccessibilityElement.h" @interface FBElementAttribute : NSObject @property (nonatomic, readonly) id element; + (nonnull NSString *)name; + (nullable NSString *)valueForElement:(id)element; + (int)recordWithWriter:(xmlTextWriterPtr)writer forElement:(id)element; + (int)recordWithWriter:(xmlTextWriterPtr)writer forValue:(nullable NSString *)value; + (NSArray *)supportedAttributes; @end @interface FBTypeAttribute : FBElementAttribute @end @interface FBValueAttribute : FBElementAttribute @end @interface FBNameAttribute : FBElementAttribute @end @interface FBLabelAttribute : FBElementAttribute @end @interface FBEnabledAttribute : FBElementAttribute @end @interface FBVisibleAttribute : FBElementAttribute @end @interface FBAccessibleAttribute : FBElementAttribute @end @interface FBDimensionAttribute : FBElementAttribute @end @interface FBXAttribute : FBDimensionAttribute @end @interface FBYAttribute : FBDimensionAttribute @end @interface FBWidthAttribute : FBDimensionAttribute @end @interface FBHeightAttribute : FBDimensionAttribute @end @interface FBIndexAttribute : FBElementAttribute @end @interface FBHittableAttribute : FBElementAttribute @end @interface FBInternalIndexAttribute : FBElementAttribute @property (nonatomic, nonnull, readonly) NSString* indexValue; @end @interface FBApplicationBundleIdAttribute : FBElementAttribute @end @interface FBApplicationPidAttribute : FBElementAttribute @end @interface FBPlaceholderValueAttribute : FBElementAttribute @end @interface FBNativeFrameAttribute : FBElementAttribute @end @interface FBTraitsAttribute : FBElementAttribute @end @interface FBMinValueAttribute : FBElementAttribute @end @interface FBMaxValueAttribute : FBElementAttribute @end #if TARGET_OS_TV @interface FBFocusedAttribute : FBElementAttribute @end #endif const static char *_UTF8Encoding = "UTF-8"; static NSString *const kXMLIndexPathKey = @"private_indexPath"; static NSString *const topNodeIndexPath = @"top"; @implementation FBXPath + (id)throwException:(NSString *)name forQuery:(NSString *)xpathQuery { NSString *reason = [NSString stringWithFormat:@"Cannot evaluate results for XPath expression \"%@\"", xpathQuery]; @throw [NSException exceptionWithName:name reason:reason userInfo:@{}]; return nil; } + (nullable NSString *)xmlStringWithRootElement:(id)root options:(nullable FBXMLGenerationOptions *)options { xmlDocPtr doc; xmlTextWriterPtr writer = xmlNewTextWriterDoc(&doc, 0); int rc = xmlTextWriterStartDocument(writer, NULL, _UTF8Encoding, NULL); if (rc < 0) { [FBLogger logFmt:@"Failed to invoke libxml2>xmlTextWriterStartDocument. Error code: %d", rc]; } else { BOOL hasScope = nil != options.scope && [options.scope length] > 0; if (hasScope) { rc = xmlTextWriterStartElement(writer, (xmlChar *)[[self safeXmlStringWithString:options.scope] UTF8String]); if (rc < 0) { [FBLogger logFmt:@"Failed to invoke libxml2>xmlTextWriterStartElement for the tag value '%@'. Error code: %d", options.scope, rc]; } } if (rc >= 0) { [self waitUntilStableWithElement:root]; // If 'includeHittableInPageSource' setting is enabled, then use native snapshots // to calculate a more accurate value for the 'hittable' attribute. rc = [self xmlRepresentationWithRootElement:[self snapshotWithRoot:root useNative:FBConfiguration.includeHittableInPageSource] writer:writer elementStore:nil query:nil excludingAttributes:options.excludedAttributes]; } if (rc >= 0 && hasScope) { rc = xmlTextWriterEndElement(writer); if (rc < 0) { [FBLogger logFmt:@"Failed to invoke libxml2>xmlTextWriterEndElement. Error code: %d", rc]; } } if (rc >= 0) { rc = xmlTextWriterEndDocument(writer); if (rc < 0) { [FBLogger logFmt:@"Failed to invoke libxml2>xmlXPathNewContext. Error code: %d", rc]; } } } if (rc < 0) { xmlFreeTextWriter(writer); xmlFreeDoc(doc); return nil; } int buffersize; xmlChar *xmlbuff; xmlDocDumpFormatMemory(doc, &xmlbuff, &buffersize, 1); xmlFreeTextWriter(writer); xmlFreeDoc(doc); NSString *result = [NSString stringWithCString:(const char *)xmlbuff encoding:NSUTF8StringEncoding]; xmlFree(xmlbuff); return result; } + (NSArray> *)matchesWithRootElement:(id)root forQuery:(NSString *)xpathQuery { xmlDocPtr doc; xmlTextWriterPtr writer = xmlNewTextWriterDoc(&doc, 0); if (NULL == writer) { [FBLogger logFmt:@"Failed to invoke libxml2>xmlNewTextWriterDoc for XPath query \"%@\"", xpathQuery]; return [self throwException:FBXPathQueryEvaluationException forQuery:xpathQuery]; } NSMutableDictionary *elementStore = [NSMutableDictionary dictionary]; int rc = xmlTextWriterStartDocument(writer, NULL, _UTF8Encoding, NULL); id lookupScopeSnapshot = nil; id contextRootSnapshot = nil; BOOL useNativeSnapshot = nil == xpathQuery ? NO : [[self.class elementAttributesWithXPathQuery:xpathQuery] containsObject:FBHittableAttribute.class]; if (rc < 0) { [FBLogger logFmt:@"Failed to invoke libxml2>xmlTextWriterStartDocument. Error code: %d", rc]; } else { [self waitUntilStableWithElement:root]; if (FBConfiguration.limitXpathContextScope) { lookupScopeSnapshot = [self snapshotWithRoot:root useNative:useNativeSnapshot]; } else { if ([root isKindOfClass:XCUIElement.class]) { lookupScopeSnapshot = [self snapshotWithRoot:[(XCUIElement *)root application] useNative:useNativeSnapshot]; contextRootSnapshot = [root isKindOfClass:XCUIApplication.class] ? nil : ([(XCUIElement *)root lastSnapshot] ?: [self snapshotWithRoot:(XCUIElement *)root useNative:useNativeSnapshot]); } else { lookupScopeSnapshot = (id)root; contextRootSnapshot = nil == lookupScopeSnapshot.parent ? nil : (id)root; while (nil != lookupScopeSnapshot.parent) { lookupScopeSnapshot = lookupScopeSnapshot.parent; } } } rc = [self xmlRepresentationWithRootElement:lookupScopeSnapshot writer:writer elementStore:elementStore query:xpathQuery excludingAttributes:nil]; if (rc >= 0) { rc = xmlTextWriterEndDocument(writer); if (rc < 0) { [FBLogger logFmt:@"Failed to invoke libxml2>xmlTextWriterEndDocument. Error code: %d", rc]; } } } if (rc < 0) { xmlFreeTextWriter(writer); xmlFreeDoc(doc); return [self throwException:FBXPathQueryEvaluationException forQuery:xpathQuery]; } xmlXPathObjectPtr contextNodeQueryResult = [self matchNodeInDocument:doc elementStore:elementStore.copy forSnapshot:contextRootSnapshot]; xmlNodePtr contextNode = NULL; if (NULL != contextNodeQueryResult) { xmlNodeSetPtr nodeSet = contextNodeQueryResult->nodesetval; if (!xmlXPathNodeSetIsEmpty(nodeSet)) { contextNode = nodeSet->nodeTab[0]; } } xmlXPathObjectPtr queryResult = [self evaluate:xpathQuery document:doc contextNode:contextNode]; if (NULL != contextNodeQueryResult) { xmlXPathFreeObject(contextNodeQueryResult); } if (NULL == queryResult) { xmlFreeTextWriter(writer); xmlFreeDoc(doc); return [self throwException:FBInvalidXPathException forQuery:xpathQuery]; } NSArray *matchingSnapshots = [self collectMatchingSnapshots:queryResult->nodesetval elementStore:elementStore]; xmlXPathFreeObject(queryResult); xmlFreeTextWriter(writer); xmlFreeDoc(doc); if (nil == matchingSnapshots) { return [self throwException:FBXPathQueryEvaluationException forQuery:xpathQuery]; } return matchingSnapshots; } + (NSArray *)collectMatchingSnapshots:(xmlNodeSetPtr)nodeSet elementStore:(NSMutableDictionary *)elementStore { if (xmlXPathNodeSetIsEmpty(nodeSet)) { return @[]; } NSMutableArray *matchingSnapshots = [NSMutableArray array]; const xmlChar *indexPathKeyName = (xmlChar *)[kXMLIndexPathKey UTF8String]; for (NSInteger i = 0; i < nodeSet->nodeNr; i++) { xmlNodePtr currentNode = nodeSet->nodeTab[i]; xmlChar *attrValue = xmlGetProp(currentNode, indexPathKeyName); if (NULL == attrValue) { [FBLogger log:@"Failed to invoke libxml2>xmlGetProp"]; return nil; } id element = [elementStore objectForKey:(id)[NSString stringWithCString:(const char *)attrValue encoding:NSUTF8StringEncoding]]; if (element) { [matchingSnapshots addObject:element]; } xmlFree(attrValue); } return matchingSnapshots.copy; } + (nullable xmlXPathObjectPtr)matchNodeInDocument:(xmlDocPtr)doc elementStore:(NSDictionary> *)elementStore forSnapshot:(nullable id)snapshot { if (nil == snapshot) { return NULL; } NSString *contextRootUid = [FBElementUtils uidWithAccessibilityElement:[(id)snapshot accessibilityElement]]; if (nil == contextRootUid) { return NULL; } for (NSString *key in elementStore) { id value = [elementStore objectForKey:key]; NSString *snapshotUid = [FBElementUtils uidWithAccessibilityElement:[value accessibilityElement]]; if (nil == snapshotUid || ![snapshotUid isEqualToString:contextRootUid]) { continue; } NSString *indexQuery = [NSString stringWithFormat:@"//*[@%@=\"%@\"]", kXMLIndexPathKey, key]; xmlXPathObjectPtr queryResult = [self evaluate:indexQuery document:doc contextNode:NULL]; if (NULL != queryResult) { return queryResult; } } return NULL; } + (NSSet *)elementAttributesWithXPathQuery:(NSString *)query { if ([query rangeOfString:@"[^\\w@]@\\*[^\\w]" options:NSRegularExpressionSearch].location != NSNotFound) { // read all element attributes if 'star' attribute name pattern is used in xpath query return [NSSet setWithArray:FBElementAttribute.supportedAttributes]; } NSMutableSet *result = [NSMutableSet set]; for (Class attributeCls in FBElementAttribute.supportedAttributes) { if ([query rangeOfString:[NSString stringWithFormat:@"[^\\w@]@%@[^\\w]", [attributeCls name]] options:NSRegularExpressionSearch].location != NSNotFound) { [result addObject:attributeCls]; } } return result.copy; } + (int)xmlRepresentationWithRootElement:(id)root writer:(xmlTextWriterPtr)writer elementStore:(nullable NSMutableDictionary *)elementStore query:(nullable NSString*)query excludingAttributes:(nullable NSArray *)excludedAttributes { // Trying to be smart here and only including attributes, that were asked in the query, to the resulting document. // This may speed up the lookup significantly in some cases NSMutableSet *includedAttributes; if (nil == query) { includedAttributes = [NSMutableSet setWithArray:FBElementAttribute.supportedAttributes]; if (!FBConfiguration.includeHittableInPageSource) { // The hittable attribute is expensive to calculate for each snapshot item // thus we only include it when requested explicitly [includedAttributes removeObject:FBHittableAttribute.class]; } if (!FBConfiguration.includeNativeFrameInPageSource) { // Include nativeFrame only when requested [includedAttributes removeObject:FBNativeFrameAttribute.class]; } if (!FBConfiguration.includeMinMaxValueInPageSource) { // minValue/maxValue are retrieved from private APIs and may be slow on deep trees [includedAttributes removeObject:FBMinValueAttribute.class]; [includedAttributes removeObject:FBMaxValueAttribute.class]; } if (nil != excludedAttributes) { for (NSString *excludedAttributeName in excludedAttributes) { for (Class supportedAttribute in FBElementAttribute.supportedAttributes) { if ([[supportedAttribute name] caseInsensitiveCompare:excludedAttributeName] == NSOrderedSame) { [includedAttributes removeObject:supportedAttribute]; break; } } } } } else { includedAttributes = [self.class elementAttributesWithXPathQuery:query].mutableCopy; } [FBLogger logFmt:@"The following attributes were requested to be included into the XML: %@", includedAttributes]; int rc = [self writeXmlWithRootElement:root indexPath:(elementStore != nil ? topNodeIndexPath : nil) elementStore:elementStore includedAttributes:includedAttributes.copy writer:writer]; if (rc < 0) { [FBLogger log:@"Failed to generate XML presentation of a screen element"]; return rc; } return 0; } + (xmlXPathObjectPtr)evaluate:(NSString *)xpathQuery document:(xmlDocPtr)doc contextNode:(nullable xmlNodePtr)contextNode { xmlXPathContextPtr xpathCtx = xmlXPathNewContext(doc); if (NULL == xpathCtx) { [FBLogger logFmt:@"Failed to invoke libxml2>xmlXPathNewContext for XPath query \"%@\"", xpathQuery]; return NULL; } xpathCtx->node = NULL == contextNode ? doc->children : contextNode; xmlXPathObjectPtr xpathObj = xmlXPathEvalExpression((const xmlChar *)[xpathQuery UTF8String], xpathCtx); if (NULL == xpathObj) { xmlXPathFreeContext(xpathCtx); [FBLogger logFmt:@"Failed to invoke libxml2>xmlXPathEvalExpression for XPath query \"%@\"", xpathQuery]; return NULL; } xmlXPathFreeContext(xpathCtx); return xpathObj; } + (nullable NSString *)safeXmlStringWithString:(NSString *)str { return [str fb_xmlSafeStringWithReplacement:@""]; } + (int)recordElementAttributes:(xmlTextWriterPtr)writer forElement:(id)element indexPath:(nullable NSString *)indexPath includedAttributes:(nullable NSSet *)includedAttributes { for (Class attributeCls in FBElementAttribute.supportedAttributes) { // include all supported attributes by default unless enumerated explicitly if (includedAttributes && ![includedAttributes containsObject:attributeCls]) { continue; } // Text-input placeholder (only for elements that support inner text) if ((attributeCls == FBPlaceholderValueAttribute.class) && !FBDoesElementSupportInnerText(element.elementType)) { continue; } // Only for elements that support min/max value if ((attributeCls == FBMinValueAttribute.class || attributeCls == FBMaxValueAttribute.class) && !FBDoesElementSupportMinMaxValue(element.elementType)) { continue; } int rc = [attributeCls recordWithWriter:writer forElement:[FBXCElementSnapshotWrapper ensureWrapped:element]]; if (rc < 0) { return rc; } } if (nil != indexPath) { // index path is the special case return [FBInternalIndexAttribute recordWithWriter:writer forValue:indexPath]; } if (element.elementType == XCUIElementTypeApplication) { // only record process identifier and bundle identifier for the application element int pid = [element.accessibilityElement processIdentifier]; if (pid > 0) { int rc = [FBApplicationPidAttribute recordWithWriter:writer forValue:[NSString stringWithFormat:@"%d", pid]]; if (rc < 0) { return rc; } XCUIApplication *app = [[FBXCAXClientProxy sharedClient] monitoredApplicationWithProcessIdentifier:pid]; NSString *bundleID = [app bundleID]; if (nil != bundleID) { rc = [FBApplicationBundleIdAttribute recordWithWriter:writer forValue:bundleID]; if (rc < 0) { return rc; } } } } return 0; } + (int)writeXmlWithRootElement:(id)root indexPath:(nullable NSString *)indexPath elementStore:(nullable NSMutableDictionary *)elementStore includedAttributes:(nullable NSSet *)includedAttributes writer:(xmlTextWriterPtr)writer { NSAssert((indexPath == nil && elementStore == nil) || (indexPath != nil && elementStore != nil), @"Either both or none of indexPath and elementStore arguments should be equal to nil", nil); NSArray> *children = root.children; if (elementStore != nil && indexPath != nil && [indexPath isEqualToString:topNodeIndexPath]) { [elementStore setObject:root forKey:topNodeIndexPath]; } FBXCElementSnapshotWrapper *wrappedSnapshot = [FBXCElementSnapshotWrapper ensureWrapped:root]; int rc = xmlTextWriterStartElement(writer, (xmlChar *)[wrappedSnapshot.wdType UTF8String]); if (rc < 0) { [FBLogger logFmt:@"Failed to invoke libxml2>xmlTextWriterStartElement for the tag value '%@'. Error code: %d", wrappedSnapshot.wdType, rc]; return rc; } rc = [self recordElementAttributes:writer forElement:root indexPath:indexPath includedAttributes:includedAttributes]; if (rc < 0) { return rc; } for (NSUInteger i = 0; i < [children count]; i++) { @autoreleasepool { id childSnapshot = [children objectAtIndex:i]; NSString *newIndexPath = (indexPath != nil) ? [indexPath stringByAppendingFormat:@",%lu", (unsigned long)i] : nil; if (elementStore != nil && newIndexPath != nil) { [elementStore setObject:childSnapshot forKey:(id)newIndexPath]; } rc = [self writeXmlWithRootElement:[FBXCElementSnapshotWrapper ensureWrapped:childSnapshot] indexPath:newIndexPath elementStore:elementStore includedAttributes:includedAttributes writer:writer]; if (rc < 0) { return rc; } } } rc = xmlTextWriterEndElement(writer); if (rc < 0) { [FBLogger logFmt:@"Failed to invoke libxml2>xmlTextWriterEndElement. Error code: %d", rc]; return rc; } return 0; } + (id)snapshotWithRoot:(id)root useNative:(BOOL)useNative { if (![root isKindOfClass:XCUIElement.class]) { return (id)root; } if (useNative) { return [(XCUIElement *)root fb_nativeSnapshot]; } return [root isKindOfClass:XCUIApplication.class] ? [(XCUIElement *)root fb_standardSnapshot] : [(XCUIElement *)root fb_customSnapshot]; } + (void)waitUntilStableWithElement:(id)root { if ([root isKindOfClass:XCUIElement.class]) { // If the app is not idle state while we retrieve the visiblity state // then the snapshot retrieval operation might freeze and time out [[(XCUIElement *)root application] fb_waitUntilStableWithTimeout:FBConfiguration.animationCoolOffTimeout]; } } @end static NSString *const FBAbstractMethodInvocationException = @"AbstractMethodInvocationException"; @implementation FBElementAttribute - (instancetype)initWithElement:(id)element { self = [super init]; if (self) { _element = element; } return self; } + (NSString *)name { NSString *errMsg = [NSString stringWithFormat:@"The abstract method +(NSString *)name is expected to be overriden by %@", NSStringFromClass(self.class)]; @throw [NSException exceptionWithName:FBAbstractMethodInvocationException reason:errMsg userInfo:nil]; } + (NSString *)valueForElement:(id)element { NSString *errMsg = [NSString stringWithFormat:@"The abstract method -(NSString *)value is expected to be overriden by %@", NSStringFromClass(self.class)]; @throw [NSException exceptionWithName:FBAbstractMethodInvocationException reason:errMsg userInfo:nil]; } + (int)recordWithWriter:(xmlTextWriterPtr)writer forElement:(id)element { NSString *value = [self valueForElement:element]; return [self recordWithWriter:writer forValue:value]; } + (int)recordWithWriter:(xmlTextWriterPtr)writer forValue:(nullable NSString *)value { if (nil == value) { // Skip the attribute if the value equals to nil return 0; } int rc = xmlTextWriterWriteAttribute(writer, (xmlChar *)[[FBXPath safeXmlStringWithString:[self name]] UTF8String], (xmlChar *)[[FBXPath safeXmlStringWithString:value] UTF8String]); if (rc < 0) { [FBLogger logFmt:@"Failed to invoke libxml2>xmlTextWriterWriteAttribute(%@='%@'). Error code: %d", [self name], value, rc]; } return rc; } + (NSArray *)supportedAttributes { // The list of attributes to be written for each XML node // The enumeration order does matter here return @[FBTypeAttribute.class, FBValueAttribute.class, FBNameAttribute.class, FBLabelAttribute.class, FBEnabledAttribute.class, FBVisibleAttribute.class, FBAccessibleAttribute.class, #if TARGET_OS_TV FBFocusedAttribute.class, #endif FBXAttribute.class, FBYAttribute.class, FBWidthAttribute.class, FBHeightAttribute.class, FBIndexAttribute.class, FBHittableAttribute.class, FBPlaceholderValueAttribute.class, FBTraitsAttribute.class, FBNativeFrameAttribute.class, FBMinValueAttribute.class, FBMaxValueAttribute.class, ]; } @end @implementation FBTypeAttribute + (NSString *)name { return @"type"; } + (NSString *)valueForElement:(id)element { return element.wdType; } @end @implementation FBValueAttribute + (NSString *)name { return @"value"; } + (NSString *)valueForElement:(id)element { id idValue = element.wdValue; if ([idValue isKindOfClass:[NSValue class]]) { return [idValue stringValue]; } else if ([idValue isKindOfClass:[NSString class]]) { return idValue; } return [idValue description]; } @end @implementation FBNameAttribute + (NSString *)name { return @"name"; } + (NSString *)valueForElement:(id)element { return element.wdName; } @end @implementation FBLabelAttribute + (NSString *)name { return @"label"; } + (NSString *)valueForElement:(id)element { return element.wdLabel; } @end @implementation FBEnabledAttribute + (NSString *)name { return @"enabled"; } + (NSString *)valueForElement:(id)element { return FBBoolToString(element.wdEnabled); } @end @implementation FBVisibleAttribute + (NSString *)name { return @"visible"; } + (NSString *)valueForElement:(id)element { return FBBoolToString(element.wdVisible); } @end @implementation FBAccessibleAttribute + (NSString *)name { return @"accessible"; } + (NSString *)valueForElement:(id)element { return FBBoolToString(element.wdAccessible); } @end #if TARGET_OS_TV @implementation FBFocusedAttribute + (NSString *)name { return @"focused"; } + (NSString *)valueForElement:(id)element { return FBBoolToString(element.wdFocused); } @end #endif @implementation FBDimensionAttribute + (NSString *)valueForElement:(id)element { return [NSString stringWithFormat:@"%@", [element.wdRect objectForKey:[self name]]]; } @end @implementation FBXAttribute + (NSString *)name { return @"x"; } @end @implementation FBYAttribute + (NSString *)name { return @"y"; } @end @implementation FBWidthAttribute + (NSString *)name { return @"width"; } @end @implementation FBHeightAttribute + (NSString *)name { return @"height"; } @end @implementation FBIndexAttribute + (NSString *)name { return @"index"; } + (NSString *)valueForElement:(id)element { return [NSString stringWithFormat:@"%lu", element.wdIndex]; } @end @implementation FBHittableAttribute + (NSString *)name { return @"hittable"; } + (NSString *)valueForElement:(id)element { return FBBoolToString(element.wdHittable); } @end @implementation FBInternalIndexAttribute + (NSString *)name { return kXMLIndexPathKey; } @end @implementation FBApplicationBundleIdAttribute : FBElementAttribute + (NSString *)name { return @"bundleId"; } @end @implementation FBApplicationPidAttribute : FBElementAttribute + (NSString *)name { return @"processId"; } @end @implementation FBPlaceholderValueAttribute + (NSString *)name { return @"placeholderValue"; } + (NSString *)valueForElement:(id)element { return element.wdPlaceholderValue; } @end @implementation FBNativeFrameAttribute + (NSString *)name { return @"nativeFrame"; } + (NSString *)valueForElement:(id)element { return NSStringFromCGRect(element.wdNativeFrame); } @end @implementation FBTraitsAttribute + (NSString *)name { return @"traits"; } + (NSString *)valueForElement:(id)element { return element.wdTraits; } @end @implementation FBMinValueAttribute + (NSString *)name { return @"minValue"; } + (NSString *)valueForElement:(id)element { return [element.wdMinValue stringValue]; } @end @implementation FBMaxValueAttribute + (NSString *)name { return @"maxValue"; } + (NSString *)valueForElement:(id)element { return [element.wdMaxValue stringValue]; } @end