blob: 698a2eed721c2dcef9b0ad0f79d7de04b34eef54 [file]
// Copyright 2026 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "ios/testing/test_expectations.h"
#import <TargetConditionals.h>
#import <UIKit/UIKit.h>
#import "base/strings/sys_string_conversions.h"
#import "base/system/sys_info.h"
#import "build/build_config.h"
#import "ui/base/device_form_factor.h"
namespace {
// Message used by EarlGrey in `_XCTFailureHandler` when halting a test due to
// an assertion failure.
NSString* const kEarlGreyHaltExecutionMessage =
@"Immediately halt execution of testcase";
// Exception name used by EarlGrey when a host application crash occurs.
NSString* const kEarlGreyInterruptExceptionName =
@"EarlGreyInternalTestInterruptException";
} // namespace
@implementation TestExpectationEntry
- (NSString*)expectedOutcomeDescription {
NSMutableArray<NSString*>* outcomes = [NSMutableArray array];
if (self.type & TestExpectationTypePass) {
[outcomes addObject:@"pass"];
}
if (self.type & TestExpectationTypeFailure) {
[outcomes addObject:@"fail"];
}
if (self.type & TestExpectationTypeCrash) {
[outcomes addObject:@"crash"];
}
if (self.type & TestExpectationTypeSkip) {
[outcomes addObject:@"skip"];
}
if (outcomes.count == 0) {
return @"pass";
}
NSMutableString* result = [NSMutableString string];
for (NSUInteger i = 0; i < outcomes.count; ++i) {
if (i > 0) {
if (i == outcomes.count - 1) {
[result appendString:(outcomes.count > 2) ? @", or " : @" or "];
} else {
[result appendString:@", "];
}
}
[result appendString:outcomes[i]];
}
return result;
}
// Returns a formatted string describing the line number and file path.
- (NSString*)locationDescription {
if (self.filePath.length > 0) {
return [NSString stringWithFormat:@"line %lu in %@",
(unsigned long)self.lineNumber,
self.filePath];
}
return
[NSString stringWithFormat:@"line %lu", (unsigned long)self.lineNumber];
}
- (NSString*)documentationMessage {
NSString* outcome = [self expectedOutcomeDescription];
return [NSString stringWithFormat:@"This test is expected to %@ (%@).",
outcome, [self locationDescription]];
}
- (NSString*)unmetExpectationMessageWithActualOutcome:(NSString*)actualOutcome {
NSString* expectedOutcome = [self expectedOutcomeDescription];
return [NSString
stringWithFormat:@"Unmet test expectation (%@): expected %@, actual %@.",
[self locationDescription], expectedOutcome,
actualOutcome];
}
- (TestExpectationMatchResult)matchesIssueType:(XCTIssueType)issueType
compactDescription:(NSString*)compactDescription {
if (issueType == XCTIssueTypeUnmatchedExpectedFailure) {
return TestExpectationMatchResult::kUnmatched;
}
BOOL didCrash = (issueType == XCTIssueTypeUncaughtException ||
issueType == XCTIssueTypeThrownError);
if (didCrash) {
if ([compactDescription containsString:kEarlGreyHaltExecutionMessage] &&
![compactDescription containsString:kEarlGreyInterruptExceptionName]) {
didCrash = NO;
}
}
BOOL matches = didCrash ? ((self.type & TestExpectationTypeCrash) != 0)
: ((self.type & TestExpectationTypeFailure) != 0);
if (!matches) {
NSString* actualOutcome = didCrash ? @"Crash" : @"Failure";
NSLog(@"%@", [self unmetExpectationMessageWithActualOutcome:actualOutcome]);
return TestExpectationMatchResult::kMismatched;
}
return TestExpectationMatchResult::kMatched;
}
@end
@interface TestExpectations ()
- (instancetype)initWithFilePath:(NSString*)path;
// Evaluates whether the expectation's tag string matches active tags.
- (BOOL)doTagsMatch:(NSString*)tagsStr activeTags:(NSSet<NSString*>*)activeTags;
@end
namespace {
TestExpectations* g_shared_instance = nil;
enum class TagCategory {
kOS,
kDevice,
kOther,
};
TagCategory GetTagCategory(NSString* tag) {
if ([tag hasPrefix:@"ipad"] || [tag hasPrefix:@"iphone"]) {
return TagCategory::kDevice;
}
if ([tag hasPrefix:@"ios"] || [tag hasPrefix:@"build-"]) {
return TagCategory::kOS;
}
return TagCategory::kOther;
}
} // namespace
@implementation TestExpectations {
NSMutableDictionary<NSString*, TestExpectationEntry*>* _expectations;
// Override for active tags (used in tests).
NSSet<NSString*>* _activeTagsOverride;
// Store original content for reparsing if tags are overridden.
NSString* _content;
// Path to the expectations file.
NSString* _filePath;
}
- (instancetype)initWithFilePath:(NSString*)path {
NSError* error = nil;
NSString* content = [NSString stringWithContentsOfFile:path
encoding:NSUTF8StringEncoding
error:&error];
CHECK(content && !error) << "Error reading expectations file at " << path
<< ": " << error;
return [self initWithContent:content filePath:path];
}
+ (instancetype)sharedInstance {
if (!g_shared_instance) {
NSBundle* bundle = [NSBundle bundleForClass:[TestExpectations class]];
NSString* path = [bundle pathForResource:@"test_expectations"
ofType:@"txt"];
CHECK(path) << "Failed to find test_expectations.txt in bundle " << bundle;
g_shared_instance = [[TestExpectations alloc] initWithFilePath:path];
}
return g_shared_instance;
}
- (instancetype)initWithContent:(NSString*)content {
return [self initWithContent:content filePath:nil];
}
- (instancetype)initWithContent:(NSString*)content
filePath:(NSString*)filePath {
self = [super init];
if (self) {
_content = [content copy];
_filePath = [filePath copy];
_expectations = [NSMutableDictionary dictionary];
[self parseExpectations:_content];
}
return self;
}
- (NSSet<NSString*>*)activeTags {
if (_activeTagsOverride) {
return _activeTagsOverride;
}
NSMutableSet<NSString*>* tags = [NSMutableSet set];
[tags addObject:@"ios"];
NSString* systemVersion = [UIDevice currentDevice].systemVersion;
NSArray<NSString*>* components =
[systemVersion componentsSeparatedByString:@"."];
if (components.count > 0) {
NSString* major = components[0];
[tags addObject:[NSString stringWithFormat:@"ios%@", major]];
if (components.count > 1) {
NSString* minor = components[1];
[tags addObject:[NSString stringWithFormat:@"ios%@.%@", major, minor]];
if (components.count > 2) {
NSString* patch = components[2];
[tags addObject:[NSString stringWithFormat:@"ios%@.%@.%@", major, minor,
patch]];
}
}
}
// Add build number
std::string buildVersion = base::SysInfo::OperatingSystemBuildVersion();
if (!buildVersion.empty()) {
NSString* buildNSString = base::SysUTF8ToNSString(buildVersion);
NSString* prefixedBuild =
[NSString stringWithFormat:@"build-%@", buildNSString];
[tags addObject:prefixedBuild.lowercaseString];
}
#if TARGET_OS_SIMULATOR
[tags addObject:@"simulator"];
#else
[tags addObject:@"device"];
#endif
#if defined(ADDRESS_SANITIZER)
[tags addObject:@"asan"];
#endif
#if BUILDFLAG(IS_IOS_MACCATALYST)
[tags addObject:@"catalyst"];
#endif
#if defined(NDEBUG)
[tags addObject:@"release"];
#else
[tags addObject:@"debug"];
#endif
std::string hardwareModel = base::SysInfo::HardwareModelName();
NSString* modelNSString = base::SysUTF8ToNSString(hardwareModel);
NSSet<NSString*>* deviceTags =
[TestExpectations deviceTagsForHardwareModel:modelNSString
formFactor:ui::GetDeviceFormFactor()];
[tags unionSet:deviceTags];
return [tags copy];
}
- (void)parseExpectations:(NSString*)content {
NSSet<NSString*>* activeTags = [self activeTags];
NSError* error = nil;
NSRegularExpression* regex =
[NSRegularExpression regularExpressionWithPattern:
@"^(?:([^\\s\\[]\\S*)\\s+)?(?:\\[([^\\]]+)\\]"
@"\\s+)?(\\S+)\\s+\\[([^\\]]+)\\](?:\\s*#.*)?$"
options:0
error:&error];
CHECK(!error) << "Failed to compile regex: " << error;
NSArray<NSString*>* lines = [content componentsSeparatedByString:@"\n"];
NSUInteger lineNumber = 0;
for (NSString* line in lines) {
lineNumber++;
NSString* trimmed =
[line stringByTrimmingCharactersInSet:[NSCharacterSet
whitespaceCharacterSet]];
if (trimmed.length == 0 || [trimmed hasPrefix:@"#"]) {
continue;
}
NSTextCheckingResult* match =
[regex firstMatchInString:trimmed
options:0
range:NSMakeRange(0, trimmed.length)];
if (!match) {
continue;
}
NSString* bug = nil;
if ([match rangeAtIndex:1].location != NSNotFound) {
bug = [trimmed substringWithRange:[match rangeAtIndex:1]];
}
NSString* tagsStr = nil;
if ([match rangeAtIndex:2].location != NSNotFound) {
tagsStr = [trimmed substringWithRange:[match rangeAtIndex:2]];
}
NSString* testId = nil;
if ([match rangeAtIndex:3].location != NSNotFound) {
testId = [trimmed substringWithRange:[match rangeAtIndex:3]];
}
NSString* expectationsStr = nil;
if ([match rangeAtIndex:4].location != NSNotFound) {
expectationsStr = [trimmed substringWithRange:[match rangeAtIndex:4]];
}
// Validate tags
if (![self doTagsMatch:tagsStr activeTags:activeTags]) {
continue;
}
// Validate expectations
TestExpectationType type = TestExpectationTypeNone;
if (expectationsStr) {
NSArray<NSString*>* expectations =
[expectationsStr componentsSeparatedByString:@" "];
for (NSString* exp in expectations) {
NSString* trimmedExp =
[exp stringByTrimmingCharactersInSet:[NSCharacterSet
whitespaceCharacterSet]];
NSString* lowercaseExp = [trimmedExp lowercaseString];
if ([lowercaseExp isEqualToString:@"failure"]) {
type |= TestExpectationTypeFailure;
} else if ([lowercaseExp isEqualToString:@"pass"]) {
type |= TestExpectationTypePass;
} else if ([lowercaseExp isEqualToString:@"skip"]) {
type |= TestExpectationTypeSkip;
} else if ([lowercaseExp isEqualToString:@"crash"]) {
type |= TestExpectationTypeCrash;
}
}
}
if (type == TestExpectationTypeNone) {
type = TestExpectationTypePass;
}
// Normalize test ID and store
NSString* normalizedTestId = [self normalizeTestIdentifier:testId];
TestExpectationEntry* entry = [[TestExpectationEntry alloc] init];
entry.bug = bug ? bug : @"Expected failure";
entry.type = type;
entry.lineNumber = lineNumber;
entry.filePath = _filePath;
_expectations[normalizedTestId] = entry;
}
}
- (TestExpectationEntry*)expectationEntryForTestCase:(NSString*)testClassName
methodName:(NSString*)methodName {
NSString* methodId =
[NSString stringWithFormat:@"%@/%@", testClassName, methodName];
NSString* normalizedMethodId = [self normalizeTestIdentifier:methodId];
TestExpectationEntry* entry = _expectations[normalizedMethodId];
if (entry) {
return entry;
}
// Try class-level expectation
NSString* normalizedClassId = [self normalizeTestIdentifier:testClassName];
return _expectations[normalizedClassId];
}
- (NSString*)normalizeTestIdentifier:(NSString*)identifier {
return [identifier stringByReplacingOccurrencesOfString:@"." withString:@"/"];
}
// Evaluates whether the expectation's tag string matches active tags.
//
// Note on tag matching semantics:
// - OS and Device categories use "ANY" (OR) semantics: if an expectation
// specifies
// multiple tags within the OS or Device category (e.g., [ ios-17 ios-18 ] or
// [ iphone ipad ]), matching ANY tag within that category satisfies the
// requirement.
// - Other tags use "ALL" (AND) semantics: EVERY tag in the kOther category
// present in the
// expectation (e.g., [ debug asan ]) must be present in the active tags for a
// match.
//
// This divergence between ANY and ALL semantics is intentional to allow
// expectations that span multiple OS versions or device families while
// requiring specific build configurations.
- (BOOL)doTagsMatch:(NSString*)tagsStr
activeTags:(NSSet<NSString*>*)activeTags {
if (!tagsStr) {
return YES;
}
NSArray<NSString*>* tags = [tagsStr componentsSeparatedByString:@" "];
bool expectation_has_os_tag = false;
bool os_matched = false;
bool expectation_has_device_tag = false;
bool device_matched = false;
for (NSString* tag in tags) {
NSString* trimmedTag =
[tag stringByTrimmingCharactersInSet:[NSCharacterSet
whitespaceCharacterSet]];
if (trimmedTag.length > 0) {
NSString* lowercaseTag = [trimmedTag lowercaseString];
bool is_match = [activeTags containsObject:lowercaseTag];
TagCategory category = GetTagCategory(lowercaseTag);
switch (category) {
case TagCategory::kOS:
expectation_has_os_tag = true;
if (is_match) {
os_matched = true;
}
break;
case TagCategory::kDevice:
expectation_has_device_tag = true;
if (is_match) {
device_matched = true;
}
break;
case TagCategory::kOther:
if (!is_match) {
return NO;
}
break;
}
}
}
// For the expectation to match, all kOther tags must have matched in the loop
// above (ALL semantics), and if any OS or Device tags were specified, at
// least one tag from each specified category must match (ANY semantics).
return (os_matched || !expectation_has_os_tag) &&
(device_matched || !expectation_has_device_tag);
}
@end
@implementation TestExpectations (Testing)
- (void)setOverrideActiveTagsForTesting:(NSSet<NSString*>*)tags {
// Store tags normalized to lowercase for testing consistency.
NSMutableSet<NSString*>* normalizedTags = [NSMutableSet set];
for (NSString* tag in tags) {
[normalizedTags addObject:[tag lowercaseString]];
}
_activeTagsOverride = [normalizedTags copy];
// Reparse expectations with the new tags.
[_expectations removeAllObjects];
if (_content) {
[self parseExpectations:_content];
}
}
+ (instancetype)sharedInstanceForTesting:(NSString*)content {
CHECK(g_shared_instance == nil);
g_shared_instance = [[TestExpectations alloc] initWithContent:content];
return g_shared_instance;
}
+ (void)resetForTesting {
g_shared_instance = nil;
}
// Generates device tags given a hardware model string and UI device form
// factor.
+ (NSSet<NSString*>*)deviceTagsForHardwareModel:(NSString*)hardwareModel
formFactor:
(ui::DeviceFormFactor)formFactor {
NSMutableSet<NSString*>* tags = [NSMutableSet set];
NSString* lowercaseModel = nil;
if (hardwareModel.length > 0) {
NSString* modelNSString = hardwareModel;
// On iOS simulators, base::SysInfo::HardwareModelName() returns a
// formatted string like "iOS Simulator (iPhone16,1)". Extract the actual
// model identifier inside the parentheses so that tag matching and
// classification work consistently across both simulator builds and
// physical hardware.
NSRange openParen = [modelNSString rangeOfString:@"("];
NSRange closeParen = [modelNSString rangeOfString:@")"
options:NSBackwardsSearch];
if (openParen.location != NSNotFound && closeParen.location != NSNotFound &&
closeParen.location > openParen.location + 1) {
NSRange modelRange = NSMakeRange(
openParen.location + 1, closeParen.location - openParen.location - 1);
modelNSString = [modelNSString substringWithRange:modelRange];
}
lowercaseModel = [modelNSString lowercaseString];
if (lowercaseModel.length > 0) {
// Add both the exact hardware model identifier and its general model
// prefix (stripped of the revision/generation suffix after the comma).
// For example, if the extracted model name is "iPhone16,1", this
// generates and adds both "iphone16,1" and "iphone16" as active device
// tags.
[tags addObject:lowercaseModel];
NSRange commaRange = [lowercaseModel rangeOfString:@","];
if (commaRange.location != NSNotFound) {
[tags addObject:[lowercaseModel substringToIndex:commaRange.location]];
}
}
}
// Derive the general device family tag directly from the model identifier.
if ([lowercaseModel hasPrefix:@"ipad"]) {
[tags addObject:@"ipad"];
} else if ([lowercaseModel hasPrefix:@"iphone"]) {
[tags addObject:@"iphone"];
} else {
// Fallback if model string was unavailable or unrecognized.
if (formFactor == ui::DEVICE_FORM_FACTOR_TABLET) {
[tags addObject:@"ipad"];
} else if (formFactor == ui::DEVICE_FORM_FACTOR_PHONE) {
[tags addObject:@"iphone"];
}
}
return [tags copy];
}
@end