fbxctest/FBXCTestKit/Configuration/FBXCTestCommandLine.m (297 lines of code) (raw):

/* * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ #import "FBXCTestCommandLine.h" #import <FBControlCore/FBControlCore.h> #import <XCTestBootstrap/XCTestBootstrap.h> #import "FBXCTestDestination.h" @implementation FBXCTestCommandLine #pragma mark Initializers + (instancetype)commandLineWithConfiguration:(FBXCTestConfiguration *)configuration destination:(FBXCTestDestination *)destination { return [[self alloc] initWithConfiguration:configuration destination:destination]; } - (instancetype)initWithConfiguration:(FBXCTestConfiguration *)configuration destination:(FBXCTestDestination *)destination { self = [super init]; if (!self) { return nil; } _configuration = configuration; _destination = destination; return self; } #pragma mark Parsing + (nullable instancetype)commandLineFromArguments:(NSArray<NSString *> *)arguments processUnderTestEnvironment:(NSDictionary<NSString *, NSString *> *)environment workingDirectory:(NSString *)workingDirectory error:(NSError **)error { return [self commandLineFromArguments:arguments processUnderTestEnvironment:environment workingDirectory:workingDirectory timeout:0 error:nil]; } + (nullable instancetype)commandLineFromArguments:(NSArray<NSString *> *)arguments processUnderTestEnvironment:(NSDictionary<NSString *, NSString *> *)environment workingDirectory:(NSString *)workingDirectory timeout:(NSTimeInterval)timeout error:(NSError **)error { FBXCTestDestination *destination = [self destinationWithArguments:arguments error:error]; if (!destination) { return nil; } NSString *testBundlePath = nil; NSString *runnerAppPath = nil; NSString *testFilter = nil; NSString *testTargetPathOut = nil; BOOL waitForDebugger = NO; if (![FBXCTestCommandLine loadWithArguments:arguments testBundlePathOut:&testBundlePath runnerAppPathOut:&runnerAppPath testTargetPathOut:&testTargetPathOut testFilterOut:&testFilter waitForDebuggerOut:&waitForDebugger error:error]) { return nil; } NSSet<NSString *> *argumentSet = [NSSet setWithArray:arguments]; FBXCTestConfiguration *configuration = nil; if ([argumentSet containsObject:@"-listTestsOnly"]) { if (![argumentSet containsObject:@"-appTest"]) { runnerAppPath = nil; } configuration = [FBListTestConfiguration configurationWithEnvironment:environment workingDirectory:workingDirectory testBundlePath:testBundlePath runnerAppPath:runnerAppPath waitForDebugger:waitForDebugger timeout:timeout]; } else if ([argumentSet containsObject:@"-logicTest"]) { configuration = [FBLogicTestConfiguration configurationWithEnvironment:environment workingDirectory:workingDirectory testBundlePath:testBundlePath waitForDebugger:waitForDebugger timeout:timeout testFilter:testFilter mirroring:FBLogicTestMirrorFileLogs coverageConfiguration:nil binaryPath:nil logDirectoryPath:nil]; } else if ([argumentSet containsObject:@"-appTest"]) { NSMutableDictionary<NSString *, NSString *> *allEnvironment = [NSProcessInfo.processInfo.environment mutableCopy]; [allEnvironment addEntriesFromDictionary:environment]; NSString *videoRecordingPath = allEnvironment[@"FBXCTEST_VIDEO_RECORDING_PATH"]; NSString *testArtifactsFilenameGlob = allEnvironment[@"FBXCTEST_TEST_ARTIFACTS_FILENAME_GLOB"]; NSArray<NSString *> *testArtifactsFilenameGlobs = testArtifactsFilenameGlob != nil ? @[testArtifactsFilenameGlob] : nil; NSString *osLogPath = allEnvironment[@"FBXCTEST_OS_LOG_PATH"]; configuration = [FBTestManagerTestConfiguration configurationWithEnvironment:environment workingDirectory:workingDirectory testBundlePath:testBundlePath waitForDebugger:waitForDebugger timeout:timeout runnerAppPath:runnerAppPath testTargetAppPath:nil testFilter:testFilter videoRecordingPath:videoRecordingPath testArtifactsFilenameGlobs:testArtifactsFilenameGlobs osLogPath:osLogPath]; } else if ([argumentSet containsObject:@"-uiTest"]) { configuration = [FBTestManagerTestConfiguration configurationWithEnvironment:environment workingDirectory:workingDirectory testBundlePath:testBundlePath waitForDebugger:waitForDebugger timeout:timeout runnerAppPath:runnerAppPath testTargetAppPath:testTargetPathOut testFilter:nil videoRecordingPath:nil testArtifactsFilenameGlobs:nil osLogPath:nil]; } if (!configuration) { return [[FBControlCoreError describeFormat:@"Could not determine test runner type from %@", [FBCollectionInformation oneLineDescriptionFromArray:arguments]] fail:error]; } return [[FBXCTestCommandLine alloc] initWithConfiguration:configuration destination:destination]; } + (BOOL)loadWithArguments:(NSArray<NSString *> *)arguments testBundlePathOut:(NSString **)testBundlePathOut runnerAppPathOut:(NSString **)runnerAppPathOut testTargetPathOut:(NSString **)testTargetPathOut testFilterOut:(NSString **)testFilterOut waitForDebuggerOut:(BOOL *)waitForDebuggerOut error:(NSError **)error { NSUInteger nextArgument = 0; NSString *testFilter = nil; while (nextArgument < arguments.count) { NSString *argument = arguments[nextArgument++]; if ([argument isEqualToString:@"run-tests"]) { // Ignore. This is the only action we support. continue; } else if ([argument isEqualToString:@"-listTestsOnly"]) { // Ignore. This is handled by the configuration class. continue; } else if ([argument isEqualToString:@"-waitForDebugger"]) { *waitForDebuggerOut = YES; continue; } if (nextArgument >= arguments.count) { return [[FBXCTestError describeFormat:@"The last option is missing a parameter: %@", argument] failBool:error]; } NSString *parameter = arguments[nextArgument++]; if ([argument isEqualToString:@"-reporter"]) { if (![self checkReporter:parameter error:error]) { return NO; } } else if ([argument isEqualToString:@"-sdk"]) { // Ignore. This is handled when extracting the destination } else if ([argument isEqualToString:@"-destination"]) { // Ignore. This is handled when extracting the destination } else if ([argument isEqualToString:@"-logicTest"]) { if (*testBundlePathOut != nil) { return [[FBXCTestError describe:@"Only a single -logicTest or -appTest argument expected"] failBool:error]; } *testBundlePathOut = parameter; } else if ([argument isEqualToString:@"-appTest"]) { NSRange colonRange = [parameter rangeOfString:@":"]; if (colonRange.length == 0) { return [[FBXCTestError describeFormat:@"Test specifier should contain a colon: %@", parameter] failBool:error]; } NSString *testBundlePath = [parameter substringToIndex:colonRange.location]; NSString *testRunnerPath = [parameter substringFromIndex:colonRange.location + 1]; NSString *testRunnerAppPath = [self extractBundlePathFromString:testRunnerPath]; if (*testBundlePathOut != nil) { return [[FBXCTestError describe:@"Only a single -logicTest or -appTest argument expected"] failBool:error]; } *testBundlePathOut = testBundlePath; *runnerAppPathOut = testRunnerAppPath; } else if ([argument isEqualToString:@"-uiTest"]) { NSArray *components = [parameter componentsSeparatedByString:@":"]; if (components.count != 3) { return [[FBXCTestError describeFormat:@"Test specifier should contain three colon separated components: %@", parameter] failBool:error]; } NSString *testBundlePath = components[0]; NSString *testRunnerPath = [self extractBundlePathFromString:components[1]]; NSString *testTargetPath = [self extractBundlePathFromString:components[2]]; if (*testBundlePathOut != nil) { return [[FBXCTestError describe:@"Only a single -logicTest or -appTest argument expected"] failBool:error]; } *testBundlePathOut = testBundlePath; *runnerAppPathOut = testRunnerPath; *testTargetPathOut = testTargetPath; } else if ([argument isEqualToString:@"-only"]) { if (testFilter != nil) { return [[FBXCTestError describeFormat:@"Multiple -only options specified: %@, %@", testFilter, parameter] failBool:error]; } testFilter = parameter; } else { return [[FBXCTestError describeFormat:@"Unrecognized option: %@", argument] failBool:error]; } } if (testFilter != nil) { NSString *expectedPrefix = [*testBundlePathOut stringByAppendingString:@":"]; if (![testFilter hasPrefix:expectedPrefix]) { return [[FBXCTestError describeFormat:@"Test filter '%@' does not apply to the test bundle '%@'", testFilter, *testBundlePathOut] failBool:error]; } *testFilterOut = [testFilter substringFromIndex:expectedPrefix.length]; } return YES; } + (BOOL)checkReporter:(NSString *)reporter error:(NSError **)error { if (![reporter isEqualToString:@"json-stream"]) { return [[FBXCTestError describeFormat:@"Unsupported reporter: %@", reporter] failBool:error]; } return YES; } + (FBXCTestDestination *)destinationWithArguments:(NSArray<NSString *> *)arguments error:(NSError **)error { NSOrderedSet<NSString *> *argumentSet = [NSOrderedSet orderedSetWithArray:arguments]; NSMutableOrderedSet<NSString *> *subset = [NSMutableOrderedSet orderedSetWithArray:arguments]; NSArray<NSString *> *macOSXSDKArguments = @[@"-sdk", @"macosx"]; NSArray<NSString *> *iPhoneSimulatorSDKArguments = @[@"-sdk", @"iphonesimulator"]; // Check for a macosx destination, return early and ignore -destination argument. [subset intersectOrderedSet:[NSOrderedSet orderedSetWithArray:macOSXSDKArguments]]; if ([subset.array isEqualToArray:macOSXSDKArguments]) { return [FBXCTestDestinationMacOSX new]; } // Check for an iPhoneSimulator Destination. subset = [NSMutableOrderedSet orderedSetWithArray:arguments]; [subset intersectOrderedSet:[NSOrderedSet orderedSetWithArray:iPhoneSimulatorSDKArguments]]; NSString *destination = [self destinationArgumentFromArguments:argumentSet]; if (![subset.array isEqualToArray:iPhoneSimulatorSDKArguments] && !destination) { return [[FBXCTestError describeFormat:@"No valid SDK or Destination provided in %@", [FBCollectionInformation oneLineDescriptionFromArray:arguments]] fail:error]; } // No Destination exists so return early. if (!destination) { return [[FBXCTestDestinationiPhoneSimulator alloc] initWithModel:nil version:nil]; } // Extract the destination. FBOSVersionName os = nil; FBDeviceModel model = nil; if (![self parseSimulatorConfigurationFromDestination:destination osOut:&os modelOut:&model error:error]) { return nil; } return [[FBXCTestDestinationiPhoneSimulator alloc] initWithModel:model version:os]; } + (NSString *)destinationArgumentFromArguments:(NSOrderedSet<NSString *> *)arguments { NSUInteger index = [arguments indexOfObject:@"-destination"]; if (index == NSNotFound) { return nil; } index += 1; if (index >= arguments.count) { return nil; } return arguments[index]; } + (BOOL)parseSimulatorConfigurationFromDestination:(NSString *)destination osOut:(FBOSVersionName *)osOut modelOut:(FBDeviceModel *)modelOut error:(NSError **)error { NSArray<NSString *> *parts = [destination componentsSeparatedByString:@","]; for (NSString *part in parts) { if ([part length] == 0) { continue; } NSRange equalsRange = [part rangeOfString:@"="]; if (equalsRange.length == 0) { return [[FBXCTestError describeFormat:@"Destination specifier should contain '=': %@", part] failBool:error]; } NSString *key = [part substringToIndex:equalsRange.location]; NSString *value = [part substringFromIndex:equalsRange.location + 1]; if ([key isEqualToString:@"name"]) { FBDeviceModel model = value; if (modelOut) { *modelOut = model; } } else if ([key isEqualToString:@"OS"]) { FBOSVersionName os = value; if (osOut) { *osOut = os; } } else { return [[FBXCTestError describeFormat:@"Unrecognized destination specifier: %@", key] failBool:error]; } } return YES; } + (NSString *)extractBundlePathFromString:(NSString *)path { while (![path hasSuffix:@"app"] && path.length != 0) { path = path.stringByDeletingLastPathComponent; } return path; } #pragma mark NSObject - (BOOL)isEqual:(FBXCTestCommandLine *)object { if (![object isKindOfClass:self.class]) { return NO; } return [object.configuration isEqual:self.configuration] && [object.destination isEqual:self.destination]; } - (NSUInteger)hash { return self.configuration.hash ^ self.destination.hash; } #pragma mark Properties static NSTimeInterval FetchTotalTestProportion = 0.8; // Fetching cannot take greater than 80% of the total test timeout. static NSTimeInterval AdditionalGlobalTimeoutFromTest = 10; // Give tests 10 seconds to handle their own timeouts gracefully. - (NSTimeInterval)testPreparationTimeout { return self.globalTimeout * FetchTotalTestProportion; } - (NSTimeInterval)globalTimeout { return self.configuration.testTimeout + AdditionalGlobalTimeoutFromTest; } @end