diff --git a/IntegrationTests/IntegrationTests.xcodeproj/project.pbxproj b/IntegrationTests/IntegrationTests.xcodeproj/project.pbxproj index c850360b1..3c64d8f3d 100644 --- a/IntegrationTests/IntegrationTests.xcodeproj/project.pbxproj +++ b/IntegrationTests/IntegrationTests.xcodeproj/project.pbxproj @@ -18,11 +18,11 @@ 580C37631AB0F62C0015E709 /* libRCTImage.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 580C37551AB0F56E0015E709 /* libRCTImage.a */; }; 580C37641AB0F6350015E709 /* libRCTNetwork.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 580C375A1AB0F5970015E709 /* libRCTNetwork.a */; }; 580C37651AB0F63E0015E709 /* libRCTText.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 580C375F1AB0F5D10015E709 /* libRCTText.a */; }; - 580C37921AB1090B0015E709 /* libRCTTest.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 580C378F1AB104B00015E709 /* libRCTTest.a */; }; + 58B80D5F1ABA4147004008FB /* libRCTTest.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 580C378F1AB104B00015E709 /* libRCTTest.a */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - 004D28A41AAF61C70097A701 /* PBXContainerItemProxy */ = { + 58005BCB1ABA44F10062E044 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; proxyType = 1; @@ -117,8 +117,8 @@ 580C37621AB0F6260015E709 /* libRCTGeolocation.a in Frameworks */, 580C37631AB0F62C0015E709 /* libRCTImage.a in Frameworks */, 580C37641AB0F6350015E709 /* libRCTNetwork.a in Frameworks */, + 58B80D5F1ABA4147004008FB /* libRCTTest.a in Frameworks */, 580C37651AB0F63E0015E709 /* libRCTText.a in Frameworks */, - 580C37921AB1090B0015E709 /* libRCTTest.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -258,7 +258,7 @@ buildRules = ( ); dependencies = ( - 004D28A51AAF61C70097A701 /* PBXTargetDependency */, + 58005BCC1ABA44F10062E044 /* PBXTargetDependency */, ); name = IntegrationTestsTests; productName = IntegrationTestsTests; @@ -438,10 +438,10 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 004D28A51AAF61C70097A701 /* PBXTargetDependency */ = { + 58005BCC1ABA44F10062E044 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 13B07F861A680F5B00A75B9A /* IntegrationTests */; - targetProxy = 004D28A41AAF61C70097A701 /* PBXContainerItemProxy */; + targetProxy = 58005BCB1ABA44F10062E044 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ @@ -468,6 +468,7 @@ ); GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", + "FB_REFERENCE_IMAGE_DIR=\"\\\"$(SOURCE_ROOT)/$(PROJECT_NAME)Tests/ReferenceImages\\\"\"", "$(inherited)", ); INFOPLIST_FILE = IntegrationTestsTests/Info.plist; diff --git a/IntegrationTests/IntegrationTestsApp.js b/IntegrationTests/IntegrationTestsApp.js index ccd69460a..de1703528 100644 --- a/IntegrationTests/IntegrationTestsApp.js +++ b/IntegrationTests/IntegrationTestsApp.js @@ -25,6 +25,7 @@ var TESTS = [ require('./IntegrationTestHarnessTest'), require('./TimersTest'), require('./AsyncStorageTest'), + require('./SimpleSnapshotTest'), ]; TESTS.forEach( diff --git a/IntegrationTests/IntegrationTestsTests/IntegrationTestsTests.m b/IntegrationTests/IntegrationTestsTests/IntegrationTestsTests.m index e049b8e70..578d3915f 100644 --- a/IntegrationTests/IntegrationTestsTests/IntegrationTestsTests.m +++ b/IntegrationTests/IntegrationTestsTests/IntegrationTestsTests.m @@ -24,34 +24,57 @@ - (void)setUp { - _runner = [[RCTTestRunner alloc] initWithApp:@"IntegrationTests/IntegrationTestsApp"]; +#ifdef __LP64__ + RCTAssert(!__LP64__, @"Tests should be run on 32-bit device simulators (e.g. iPhone 5)"); +#endif + NSString *version = [[UIDevice currentDevice] systemVersion]; + RCTAssert([version isEqualToString:@"8.1"], @"Tests should be run on iOS 8.1, found %@", version); + _runner = initRunnerForApp(@"IntegrationTests/IntegrationTestsApp"); + + // If tests have changes, set recordMode = YES below and run the affected tests on an iPhone5, iOS 8.1 simulator. + _runner.recordMode = NO; } +#pragma mark Logic Tests + - (void)testTheTester { - [_runner runTest:@"IntegrationTestHarnessTest"]; + [_runner runTest:_cmd module:@"IntegrationTestHarnessTest"]; } - (void)testTheTester_waitOneFrame { - [_runner runTest:@"IntegrationTestHarnessTest" initialProps:@{@"waitOneFrame": @YES} expectErrorBlock:nil]; + [_runner runTest:_cmd module:@"IntegrationTestHarnessTest" initialProps:@{@"waitOneFrame": @YES} expectErrorBlock:nil]; } - (void)testTheTester_ExpectError { - [_runner runTest:@"IntegrationTestHarnessTest" + [_runner runTest:_cmd + module:@"IntegrationTestHarnessTest" initialProps:@{@"shouldThrow": @YES} expectErrorRegex:[NSRegularExpression regularExpressionWithPattern:@"because shouldThrow" options:0 error:nil]]; } - (void)testTimers { - [_runner runTest:@"TimersTest"]; + [_runner runTest:_cmd module:@"TimersTest"]; } - (void)testAsyncStorage { - [_runner runTest:@"AsyncStorageTest"]; + [_runner runTest:_cmd module:@"AsyncStorageTest"]; +} + +#pragma mark Snapshot Tests + +- (void)testSimpleSnapshot +{ + [_runner runTest:_cmd module:@"SimpleSnapshotTest"]; +} + +- (void)testZZZ_NotInRecordMode +{ + RCTAssert(_runner.recordMode == NO, @"Don't forget to turn record mode back to NO before commit."); } @end diff --git a/IntegrationTests/IntegrationTestsTests/ReferenceImages/IntegrationTests-IntegrationTestsApp/testSimpleSnapshot_1@2x.png b/IntegrationTests/IntegrationTestsTests/ReferenceImages/IntegrationTests-IntegrationTestsApp/testSimpleSnapshot_1@2x.png new file mode 100644 index 000000000..fd91abf42 Binary files /dev/null and b/IntegrationTests/IntegrationTestsTests/ReferenceImages/IntegrationTests-IntegrationTestsApp/testSimpleSnapshot_1@2x.png differ diff --git a/IntegrationTests/SimpleSnapshotTest.js b/IntegrationTests/SimpleSnapshotTest.js new file mode 100644 index 000000000..1715f093f --- /dev/null +++ b/IntegrationTests/SimpleSnapshotTest.js @@ -0,0 +1,56 @@ +/** + * 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. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +'use strict'; + +var React = require('react-native'); +var { + StyleSheet, + View, +} = React; + +var { TestModule } = React.addons; + +var SimpleSnapshotTest = React.createClass({ + componentDidMount() { + if (!TestModule.verifySnapshot) { + throw new Error('TestModule.verifySnapshot not defined.'); + } + requestAnimationFrame(() => TestModule.verifySnapshot(this.done)); + }, + + done() { + TestModule.markTestCompleted(); + }, + + render() { + return ( + + + + + ); + } +}); + +var styles = StyleSheet.create({ + box1: { + width: 80, + height: 50, + backgroundColor: 'red', + }, + box2: { + top: -10, + left: 20, + width: 70, + height: 90, + backgroundColor: 'blue', + }, +}); + +module.exports = SimpleSnapshotTest; diff --git a/Libraries/RCTTest/FBSnapshotTestCase/FBSnapshotTestCase.h b/Libraries/RCTTest/FBSnapshotTestCase/FBSnapshotTestCase.h new file mode 100644 index 000000000..063e32a48 --- /dev/null +++ b/Libraries/RCTTest/FBSnapshotTestCase/FBSnapshotTestCase.h @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2013, 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. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +#import + +#import + +#import + +#ifndef FB_REFERENCE_IMAGE_DIR +#define FB_REFERENCE_IMAGE_DIR "\"$(SOURCE_ROOT)/$(PROJECT_NAME)Tests/ReferenceImages\"" +#endif + +/** + Similar to our much-loved XCTAssert() macros. Use this to perform your test. No need to write an explanation, though. + @param view The view to snapshot + @param identifier An optional identifier, used if there are multiple snapshot tests in a given -test method. + @param referenceImageDirectorySuffix An optional suffix, appended to the reference image directory path, such as "_iOS8" + */ +#define FBSnapshotVerifyViewWithReferenceDirectorySuffix(view__, identifier__, referenceImagesDirectorySuffix__) \ +{ \ +NSError *error__ = nil; \ +NSString *referenceImagesDirectory__ = [NSString stringWithFormat:@"%s%@", FB_REFERENCE_IMAGE_DIR, referenceImagesDirectorySuffix__]; \ +BOOL comparisonSuccess__ = [self compareSnapshotOfView:(view__) referenceImagesDirectory:referenceImagesDirectory__ identifier:(identifier__) error:&error__]; \ +XCTAssertTrue(comparisonSuccess__, @"Snapshot comparison failed: %@", error__); \ +} + +#define FBSnapshotVerifyView(view__, identifier__) \ +{ \ +FBSnapshotVerifyViewWithReferenceDirectorySuffix(view__, identifier__, @""); \ +} + +/** + Similar to our much-loved XCTAssert() macros. Use this to perform your test. No need to write an explanation, though. + @param layer The layer to snapshot + @param identifier An optional identifier, used is there are multiple snapshot tests in a given -test method. + @param referenceImageDirectorySuffix An optional suffix, appended to the reference image directory path, such as "_iOS8" + */ +#define FBSnapshotVerifyLayerWithReferenceDirectorySuffix(layer__, identifier__, referenceImagesDirectorySuffix__) \ +{ \ +NSError *error__ = nil; \ +NSString *referenceImagesDirectory__ = [NSString stringWithFormat:@"%s%@", FB_REFERENCE_IMAGE_DIR, referenceImagesDirectorySuffix__]; \ +BOOL comparisonSuccess__ = [self compareSnapshotOfLayer:(layer__) referenceImagesDirectory:referenceImagesDirectory__ identifier:(identifier__) error:&error__]; \ +XCTAssertTrue(comparisonSuccess__, @"Snapshot comparison failed: %@", error__); \ +} + +#define FBSnapshotVerifyLayer(layer__, identifier__) \ +{ \ +FBSnapshotVerifyLayerWithReferenceDirectorySuffix(layer__, identifier__, @""); \ +} + +/** + The base class of view snapshotting tests. If you have small UI component, it's often easier to configure it in a test + and compare an image of the view to a reference image that write lots of complex layout-code tests. + + In order to flip the tests in your subclass to record the reference images set `recordMode` to YES before calling + -[super setUp]. + */ +@interface FBSnapshotTestCase : XCTestCase + +/** + When YES, the test macros will save reference images, rather than performing an actual test. + */ +@property (readwrite, nonatomic, assign) BOOL recordMode; + +/** + Performs the comparisong or records a snapshot of the layer if recordMode is YES. + @param layer The Layer to snapshot + @param referenceImagesDirectory The directory in which reference images are stored. + @param identifier An optional identifier, used if there are multiple snapshot tests in a given -test method. + @param error An error to log in an XCTAssert() macro if the method fails (missing reference image, images differ, etc). + @returns YES if the comparison (or saving of the reference image) succeeded. + */ +- (BOOL)compareSnapshotOfLayer:(CALayer *)layer + referenceImagesDirectory:(NSString *)referenceImagesDirectory + identifier:(NSString *)identifier + error:(NSError **)errorPtr; + +/** + Performs the comparisong or records a snapshot of the view if recordMode is YES. + @param view The view to snapshot + @param referenceImagesDirectory The directory in which reference images are stored. + @param identifier An optional identifier, used if there are multiple snapshot tests in a given -test method. + @param error An error to log in an XCTAssert() macro if the method fails (missing reference image, images differ, etc). + @returns YES if the comparison (or saving of the reference image) succeeded. + */ +- (BOOL)compareSnapshotOfView:(UIView *)view + referenceImagesDirectory:(NSString *)referenceImagesDirectory + identifier:(NSString *)identifier + error:(NSError **)errorPtr; + +@end diff --git a/Libraries/RCTTest/FBSnapshotTestCase/FBSnapshotTestCase.m b/Libraries/RCTTest/FBSnapshotTestCase/FBSnapshotTestCase.m new file mode 100644 index 000000000..ebd311c64 --- /dev/null +++ b/Libraries/RCTTest/FBSnapshotTestCase/FBSnapshotTestCase.m @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2013, 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. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +#import "FBSnapshotTestCase.h" + +#import "FBSnapshotTestController.h" + +@interface FBSnapshotTestCase () + +@property (readwrite, nonatomic, retain) FBSnapshotTestController *snapshotController; + +@end + +@implementation FBSnapshotTestCase + +- (void)setUp +{ + [super setUp]; + self.snapshotController = [[FBSnapshotTestController alloc] initWithTestName:NSStringFromClass([self class])]; +} + +- (void)tearDown +{ + self.snapshotController = nil; + [super tearDown]; +} + +- (BOOL)recordMode +{ + return self.snapshotController.recordMode; +} + +- (void)setRecordMode:(BOOL)recordMode +{ + self.snapshotController.recordMode = recordMode; +} + +- (BOOL)compareSnapshotOfLayer:(CALayer *)layer + referenceImagesDirectory:(NSString *)referenceImagesDirectory + identifier:(NSString *)identifier + error:(NSError **)errorPtr +{ + return [self _compareSnapshotOfViewOrLayer:layer + referenceImagesDirectory:referenceImagesDirectory + identifier:identifier + error:errorPtr]; +} + +- (BOOL)compareSnapshotOfView:(UIView *)view + referenceImagesDirectory:(NSString *)referenceImagesDirectory + identifier:(NSString *)identifier + error:(NSError **)errorPtr +{ + return [self _compareSnapshotOfViewOrLayer:view + referenceImagesDirectory:referenceImagesDirectory + identifier:identifier + error:errorPtr]; +} + +#pragma mark - +#pragma mark Private API + +- (BOOL)_compareSnapshotOfViewOrLayer:(id)viewOrLayer + referenceImagesDirectory:(NSString *)referenceImagesDirectory + identifier:(NSString *)identifier + error:(NSError **)errorPtr +{ + _snapshotController.referenceImagesDirectory = referenceImagesDirectory; + return [_snapshotController compareSnapshotOfViewOrLayer:viewOrLayer + selector:self.invocation.selector + identifier:identifier + error:errorPtr]; +} + +@end diff --git a/Libraries/RCTTest/FBSnapshotTestCase/FBSnapshotTestController.h b/Libraries/RCTTest/FBSnapshotTestCase/FBSnapshotTestController.h new file mode 100644 index 000000000..349384d8f --- /dev/null +++ b/Libraries/RCTTest/FBSnapshotTestCase/FBSnapshotTestController.h @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2013, 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. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +#import +#import + +typedef NS_ENUM(NSInteger, FBSnapshotTestControllerErrorCode) { + FBSnapshotTestControllerErrorCodeUnknown, + FBSnapshotTestControllerErrorCodeNeedsRecord, + FBSnapshotTestControllerErrorCodePNGCreationFailed, + FBSnapshotTestControllerErrorCodeImagesDifferentSizes, + FBSnapshotTestControllerErrorCodeImagesDifferent, +}; +/** + Errors returned by the methods of FBSnapshotTestController use this domain. + */ +extern NSString *const FBSnapshotTestControllerErrorDomain; + +/** + Errors returned by the methods of FBSnapshotTestController sometimes contain this key in the `userInfo` dictionary. + */ +extern NSString *const FBReferenceImageFilePathKey; + +/** + Provides the heavy-lifting for FBSnapshotTestCase. It loads and saves images, along with performing the actual pixel- + by-pixel comparison of images. + Instances are initialized with the test class, and directories to read and write to. + */ +@interface FBSnapshotTestController : NSObject + +/** + Record snapshots. + **/ +@property(readwrite, nonatomic, assign) BOOL recordMode; + +/** + @param testClass The subclass of FBSnapshotTestCase that is using this controller. + @param referenceImagesDirectory The directory where the reference images are stored. + @returns An instance of FBSnapshotTestController. + */ +- (id)initWithTestClass:(Class)testClass; + +/** + Designated initializer. + @param testName The name of the tests. + @param referenceImagesDirectory The directory where the reference images are stored. + @returns An instance of FBSnapshotTestController. + */ +- (id)initWithTestName:(NSString *)testName; + + +/** + Performs the comparison of the layer. + @param layer The Layer to snapshot. + @param referenceImagesDirectory The directory in which reference images are stored. + @param identifier An optional identifier, used is there are muliptle snapshot tests in a given -test method. + @param error An error to log in an XCTAssert() macro if the method fails (missing reference image, images differ, etc). + @returns YES if the comparison (or saving of the reference image) succeeded. + */ +- (BOOL)compareSnapshotOfLayer:(CALayer *)layer + selector:(SEL)selector + identifier:(NSString *)identifier + error:(NSError **)errorPtr; + +/** + Performs the comparison of the view. + @param view The view to snapshot. + @param referenceImagesDirectory The directory in which reference images are stored. + @param identifier An optional identifier, used is there are muliptle snapshot tests in a given -test method. + @param error An error to log in an XCTAssert() macro if the method fails (missing reference image, images differ, etc). + @returns YES if the comparison (or saving of the reference image) succeeded. + */ +- (BOOL)compareSnapshotOfView:(UIView *)view + selector:(SEL)selector + identifier:(NSString *)identifier + error:(NSError **)errorPtr; + +/** + Performs the comparison of a view or layer. + @param view The view or layer to snapshot. + @param referenceImagesDirectory The directory in which reference images are stored. + @param identifier An optional identifier, used is there are muliptle snapshot tests in a given -test method. + @param error An error to log in an XCTAssert() macro if the method fails (missing reference image, images differ, etc). + @returns YES if the comparison (or saving of the reference image) succeeded. + */ +- (BOOL)compareSnapshotOfViewOrLayer:(id)viewOrLayer + selector:(SEL)selector + identifier:(NSString *)identifier + error:(NSError **)errorPtr; + + +/** + The directory in which referfence images are stored. + */ +@property (readwrite, nonatomic, copy) NSString *referenceImagesDirectory; + +/** + Loads a reference image. + @param selector The test method being run. + @param identifier The optional identifier, used when multiple images are tested in a single -test method. + @param error An error, if this methods returns nil, the error will be something useful. + @returns An image. + */ +- (UIImage *)referenceImageForSelector:(SEL)selector + identifier:(NSString *)identifier + error:(NSError **)error; + +/** + Saves a reference image. + @param selector The test method being run. + @param identifier The optional identifier, used when multiple images are tested in a single -test method. + @param error An error, if this methods returns NO, the error will be something useful. + @returns An image. + */ +- (BOOL)saveReferenceImage:(UIImage *)image + selector:(SEL)selector + identifier:(NSString *)identifier + error:(NSError **)errorPtr; + +/** + Performs a pixel-by-pixel comparison of the two images. + @param referenceImage The reference (correct) image. + @param image The image to test against the reference. + @param error An error that indicates why the comparison failed if it does. + @param YES if the comparison succeeded and the images are the same. + */ +- (BOOL)compareReferenceImage:(UIImage *)referenceImage + toImage:(UIImage *)image + error:(NSError **)errorPtr; + +/** + Saves the reference image and the test image to `failedOutputDirectory`. + @param referenceImage The reference (correct) image. + @param testImage The image to test against the reference. + @param selector The test method being run. + @param identifier The optional identifier, used when multiple images are tested in a single -test method. + @param error An error that indicates why the comparison failed if it does. + @param YES if the save succeeded. + */ +- (BOOL)saveFailedReferenceImage:(UIImage *)referenceImage + testImage:(UIImage *)testImage + selector:(SEL)selector + identifier:(NSString *)identifier + error:(NSError **)errorPtr; +@end diff --git a/Libraries/RCTTest/FBSnapshotTestCase/FBSnapshotTestController.m b/Libraries/RCTTest/FBSnapshotTestCase/FBSnapshotTestController.m new file mode 100644 index 000000000..7d12736d1 --- /dev/null +++ b/Libraries/RCTTest/FBSnapshotTestCase/FBSnapshotTestController.m @@ -0,0 +1,392 @@ +/* + * Copyright (c) 2013, 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. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +#import "FBSnapshotTestController.h" + +#import "UIImage+Compare.h" +#import "UIImage+Diff.h" + +#import + +#import + +NSString *const FBSnapshotTestControllerErrorDomain = @"FBSnapshotTestControllerErrorDomain"; + +NSString *const FBReferenceImageFilePathKey = @"FBReferenceImageFilePathKey"; + +typedef struct RGBAPixel { + char r; + char g; + char b; + char a; +} RGBAPixel; + +@interface FBSnapshotTestController () + +@property (readonly, nonatomic, copy) NSString *testName; + +@end + +@implementation FBSnapshotTestController +{ + NSFileManager *_fileManager; +} + +#pragma mark - +#pragma mark Lifecycle + +- (id)initWithTestClass:(Class)testClass; +{ + return [self initWithTestName:NSStringFromClass(testClass)]; +} + +- (id)initWithTestName:(NSString *)testName +{ + if ((self = [super init])) { + _testName = [testName copy]; + _fileManager = [[NSFileManager alloc] init]; + } + return self; +} + +#pragma mark - +#pragma mark Properties + +- (NSString *)description +{ + return [NSString stringWithFormat:@"%@ %@", [super description], _referenceImagesDirectory]; +} + +#pragma mark - +#pragma mark Public API + +- (UIImage *)referenceImageForSelector:(SEL)selector + identifier:(NSString *)identifier + error:(NSError **)errorPtr +{ + NSString *filePath = [self _referenceFilePathForSelector:selector identifier:identifier]; + UIImage *image = [UIImage imageWithContentsOfFile:filePath]; + if (nil == image && NULL != errorPtr) { + BOOL exists = [_fileManager fileExistsAtPath:filePath]; + if (!exists) { + *errorPtr = [NSError errorWithDomain:FBSnapshotTestControllerErrorDomain + code:FBSnapshotTestControllerErrorCodeNeedsRecord + userInfo:@{ + FBReferenceImageFilePathKey: filePath, + NSLocalizedDescriptionKey: @"Unable to load reference image.", + NSLocalizedFailureReasonErrorKey: @"Reference image not found. You need to run the test in record mode", + }]; + } else { + *errorPtr = [NSError errorWithDomain:FBSnapshotTestControllerErrorDomain + code:FBSnapshotTestControllerErrorCodeUnknown + userInfo:nil]; + } + } + return image; +} + +- (BOOL)saveReferenceImage:(UIImage *)image + selector:(SEL)selector + identifier:(NSString *)identifier + error:(NSError **)errorPtr +{ + BOOL didWrite = NO; + if (nil != image) { + NSString *filePath = [self _referenceFilePathForSelector:selector identifier:identifier]; + NSData *pngData = UIImagePNGRepresentation(image); + if (nil != pngData) { + NSError *creationError = nil; + BOOL didCreateDir = [_fileManager createDirectoryAtPath:[filePath stringByDeletingLastPathComponent] + withIntermediateDirectories:YES + attributes:nil + error:&creationError]; + if (!didCreateDir) { + if (NULL != errorPtr) { + *errorPtr = creationError; + } + return NO; + } + didWrite = [pngData writeToFile:filePath options:NSDataWritingAtomic error:errorPtr]; + if (didWrite) { + NSLog(@"Reference image save at: %@", filePath); + } + } else { + if (nil != errorPtr) { + *errorPtr = [NSError errorWithDomain:FBSnapshotTestControllerErrorDomain + code:FBSnapshotTestControllerErrorCodePNGCreationFailed + userInfo:@{ + FBReferenceImageFilePathKey: filePath, + }]; + } + } + } + return didWrite; +} + +- (BOOL)saveFailedReferenceImage:(UIImage *)referenceImage + testImage:(UIImage *)testImage + selector:(SEL)selector + identifier:(NSString *)identifier + error:(NSError **)errorPtr +{ + NSData *referencePNGData = UIImagePNGRepresentation(referenceImage); + NSData *testPNGData = UIImagePNGRepresentation(testImage); + + NSString *referencePath = [self _failedFilePathForSelector:selector + identifier:identifier + fileNameType:FBTestSnapshotFileNameTypeFailedReference]; + + NSError *creationError = nil; + BOOL didCreateDir = [_fileManager createDirectoryAtPath:[referencePath stringByDeletingLastPathComponent] + withIntermediateDirectories:YES + attributes:nil + error:&creationError]; + if (!didCreateDir) { + if (NULL != errorPtr) { + *errorPtr = creationError; + } + return NO; + } + + if (![referencePNGData writeToFile:referencePath options:NSDataWritingAtomic error:errorPtr]) { + return NO; + } + + NSString *testPath = [self _failedFilePathForSelector:selector + identifier:identifier + fileNameType:FBTestSnapshotFileNameTypeFailedTest]; + + if (![testPNGData writeToFile:testPath options:NSDataWritingAtomic error:errorPtr]) { + return NO; + } + + NSString *diffPath = [self _failedFilePathForSelector:selector + identifier:identifier + fileNameType:FBTestSnapshotFileNameTypeFailedTestDiff]; + + UIImage *diffImage = [referenceImage diffWithImage:testImage]; + NSData *diffImageData = UIImagePNGRepresentation(diffImage); + + if (![diffImageData writeToFile:diffPath options:NSDataWritingAtomic error:errorPtr]) { + return NO; + } + + NSLog(@"If you have Kaleidoscope installed you can run this command to see an image diff:\n" + @"ksdiff \"%@\" \"%@\"", referencePath, testPath); + + return YES; +} + +- (BOOL)compareReferenceImage:(UIImage *)referenceImage toImage:(UIImage *)image error:(NSError **)errorPtr +{ + if (CGSizeEqualToSize(referenceImage.size, image.size)) { + + BOOL imagesEqual = [referenceImage compareWithImage:image]; + if (NULL != errorPtr) { + *errorPtr = [NSError errorWithDomain:FBSnapshotTestControllerErrorDomain + code:FBSnapshotTestControllerErrorCodeImagesDifferent + userInfo:@{ + NSLocalizedDescriptionKey: @"Images different", + }]; + } + return imagesEqual; + } + if (NULL != errorPtr) { + *errorPtr = [NSError errorWithDomain:FBSnapshotTestControllerErrorDomain + code:FBSnapshotTestControllerErrorCodeImagesDifferentSizes + userInfo:@{ + NSLocalizedDescriptionKey: @"Images different sizes", + NSLocalizedFailureReasonErrorKey: [NSString stringWithFormat:@"referenceImage:%@, image:%@", + NSStringFromCGSize(referenceImage.size), + NSStringFromCGSize(image.size)], + }]; + } + return NO; +} + +#pragma mark - +#pragma mark Private API + +typedef NS_ENUM(NSUInteger, FBTestSnapshotFileNameType) { + FBTestSnapshotFileNameTypeReference, + FBTestSnapshotFileNameTypeFailedReference, + FBTestSnapshotFileNameTypeFailedTest, + FBTestSnapshotFileNameTypeFailedTestDiff, +}; + +- (NSString *)_fileNameForSelector:(SEL)selector + identifier:(NSString *)identifier + fileNameType:(FBTestSnapshotFileNameType)fileNameType +{ + NSString *fileName = nil; + switch (fileNameType) { + case FBTestSnapshotFileNameTypeFailedReference: + fileName = @"reference_"; + break; + case FBTestSnapshotFileNameTypeFailedTest: + fileName = @"failed_"; + break; + case FBTestSnapshotFileNameTypeFailedTestDiff: + fileName = @"diff_"; + break; + default: + fileName = @""; + break; + } + fileName = [fileName stringByAppendingString:NSStringFromSelector(selector)]; + if (0 < identifier.length) { + fileName = [fileName stringByAppendingFormat:@"_%@", identifier]; + } + if ([[UIScreen mainScreen] scale] > 1.0) { + fileName = [fileName stringByAppendingFormat:@"@%.fx", [[UIScreen mainScreen] scale]]; + } + fileName = [fileName stringByAppendingPathExtension:@"png"]; + return fileName; +} + +- (NSString *)_referenceFilePathForSelector:(SEL)selector identifier:(NSString *)identifier +{ + NSString *fileName = [self _fileNameForSelector:selector + identifier:identifier + fileNameType:FBTestSnapshotFileNameTypeReference]; + NSString *filePath = [_referenceImagesDirectory stringByAppendingPathComponent:_testName]; + filePath = [filePath stringByAppendingPathComponent:fileName]; + return filePath; +} + +- (NSString *)_failedFilePathForSelector:(SEL)selector + identifier:(NSString *)identifier + fileNameType:(FBTestSnapshotFileNameType)fileNameType +{ + NSString *fileName = [self _fileNameForSelector:selector + identifier:identifier + fileNameType:fileNameType]; + NSString *folderPath = NSTemporaryDirectory(); + if (getenv("IMAGE_DIFF_DIR")) { + folderPath = @(getenv("IMAGE_DIFF_DIR")); + } + NSString *filePath = [folderPath stringByAppendingPathComponent:_testName]; + filePath = [filePath stringByAppendingPathComponent:fileName]; + return filePath; +} + +- (BOOL)compareSnapshotOfLayer:(CALayer *)layer + selector:(SEL)selector + identifier:(NSString *)identifier + error:(NSError **)errorPtr +{ + return [self compareSnapshotOfViewOrLayer:layer + selector:selector + identifier:identifier + error:errorPtr]; +} + +- (BOOL)compareSnapshotOfView:(UIView *)view + selector:(SEL)selector + identifier:(NSString *)identifier + error:(NSError **)errorPtr +{ + return [self compareSnapshotOfViewOrLayer:view + selector:selector + identifier:identifier + error:errorPtr]; +} + +- (BOOL)compareSnapshotOfViewOrLayer:(id)viewOrLayer + selector:(SEL)selector + identifier:(NSString *)identifier + error:(NSError **)errorPtr +{ + if (self.recordMode) { + return [self _recordSnapshotOfViewOrLayer:viewOrLayer selector:selector identifier:identifier error:errorPtr]; + } else { + return [self _performPixelComparisonWithViewOrLayer:viewOrLayer selector:selector identifier:identifier error:errorPtr]; + } +} + +#pragma mark - +#pragma mark Private API + +- (BOOL)_performPixelComparisonWithViewOrLayer:(UIView *)viewOrLayer + selector:(SEL)selector + identifier:(NSString *)identifier + error:(NSError **)errorPtr +{ + UIImage *referenceImage = [self referenceImageForSelector:selector identifier:identifier error:errorPtr]; + if (nil != referenceImage) { + UIImage *snapshot = [self _snapshotViewOrLayer:viewOrLayer]; + BOOL imagesSame = [self compareReferenceImage:referenceImage toImage:snapshot error:errorPtr]; + if (!imagesSame) { + [self saveFailedReferenceImage:referenceImage + testImage:snapshot + selector:selector + identifier:identifier + error:errorPtr]; + } + return imagesSame; + } + return NO; +} + +- (BOOL)_recordSnapshotOfViewOrLayer:(id)viewOrLayer + selector:(SEL)selector + identifier:(NSString *)identifier + error:(NSError **)errorPtr +{ + UIImage *snapshot = [self _snapshotViewOrLayer:viewOrLayer]; + return [self saveReferenceImage:snapshot selector:selector identifier:identifier error:errorPtr]; +} + +- (UIImage *)_snapshotViewOrLayer:(id)viewOrLayer +{ + CALayer *layer = nil; + + if ([viewOrLayer isKindOfClass:[UIView class]]) { + return [self _renderView:viewOrLayer]; + } else if ([viewOrLayer isKindOfClass:[CALayer class]]) { + layer = (CALayer *)viewOrLayer; + [layer layoutIfNeeded]; + return [self _renderLayer:layer]; + } else { + [NSException raise:@"Only UIView and CALayer classes can be snapshotted" format:@"%@", viewOrLayer]; + } + return nil; +} + +- (UIImage *)_renderLayer:(CALayer *)layer +{ + CGRect bounds = layer.bounds; + + NSAssert1(CGRectGetWidth(bounds), @"Zero width for layer %@", layer); + NSAssert1(CGRectGetHeight(bounds), @"Zero height for layer %@", layer); + + UIGraphicsBeginImageContextWithOptions(bounds.size, NO, 0); + CGContextRef context = UIGraphicsGetCurrentContext(); + NSAssert1(context, @"Could not generate context for layer %@", layer); + + CGContextSaveGState(context); + { + [layer renderInContext:context]; + } + CGContextRestoreGState(context); + + UIImage *snapshot = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + return snapshot; +} + +- (UIImage *)_renderView:(UIView *)view +{ + [view layoutIfNeeded]; + return [self _renderLayer:view.layer]; +} + +@end diff --git a/Libraries/RCTTest/FBSnapshotTestCase/UIImage+Compare.h b/Libraries/RCTTest/FBSnapshotTestCase/UIImage+Compare.h new file mode 100644 index 000000000..11c6fa638 --- /dev/null +++ b/Libraries/RCTTest/FBSnapshotTestCase/UIImage+Compare.h @@ -0,0 +1,37 @@ +// +// Created by Gabriel Handford on 3/1/09. +// Copyright 2009-2013. All rights reserved. +// Created by John Boiles on 10/20/11. +// Copyright (c) 2011. All rights reserved +// Modified by Felix Schulze on 2/11/13. +// Copyright 2013. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#import + +@interface UIImage (Compare) + +- (BOOL)compareWithImage:(UIImage *)image; + +@end diff --git a/Libraries/RCTTest/FBSnapshotTestCase/UIImage+Compare.m b/Libraries/RCTTest/FBSnapshotTestCase/UIImage+Compare.m new file mode 100644 index 000000000..e38c6e4e8 --- /dev/null +++ b/Libraries/RCTTest/FBSnapshotTestCase/UIImage+Compare.m @@ -0,0 +1,91 @@ +// +// Created by Gabriel Handford on 3/1/09. +// Copyright 2009-2013. All rights reserved. +// Created by John Boiles on 10/20/11. +// Copyright (c) 2011. All rights reserved +// Modified by Felix Schulze on 2/11/13. +// Copyright 2013. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#import "UIImage+Compare.h" + +@implementation UIImage (Compare) + +- (BOOL)compareWithImage:(UIImage *)image +{ + NSAssert(CGSizeEqualToSize(self.size, image.size), @"Images must be same size."); + + // The images have the equal size, so we could use the smallest amount of bytes because of byte padding + size_t minBytesPerRow = MIN(CGImageGetBytesPerRow(self.CGImage), CGImageGetBytesPerRow(image.CGImage)); + size_t referenceImageSizeBytes = CGImageGetHeight(self.CGImage) * minBytesPerRow; + void *referenceImagePixels = calloc(1, referenceImageSizeBytes); + void *imagePixels = calloc(1, referenceImageSizeBytes); + + if (!referenceImagePixels || !imagePixels) { + free(referenceImagePixels); + free(imagePixels); + return NO; + } + + CGContextRef referenceImageContext = CGBitmapContextCreate(referenceImagePixels, + CGImageGetWidth(self.CGImage), + CGImageGetHeight(self.CGImage), + CGImageGetBitsPerComponent(self.CGImage), + minBytesPerRow, + CGImageGetColorSpace(self.CGImage), + (CGBitmapInfo)kCGImageAlphaPremultipliedLast + ); + CGContextRef imageContext = CGBitmapContextCreate(imagePixels, + CGImageGetWidth(image.CGImage), + CGImageGetHeight(image.CGImage), + CGImageGetBitsPerComponent(image.CGImage), + minBytesPerRow, + CGImageGetColorSpace(image.CGImage), + (CGBitmapInfo)kCGImageAlphaPremultipliedLast + ); + + CGFloat scaleFactor = [[UIScreen mainScreen] scale]; + CGContextScaleCTM(referenceImageContext, scaleFactor, scaleFactor); + CGContextScaleCTM(imageContext, scaleFactor, scaleFactor); + + if (!referenceImageContext || !imageContext) { + CGContextRelease(referenceImageContext); + CGContextRelease(imageContext); + free(referenceImagePixels); + free(imagePixels); + return NO; + } + + CGContextDrawImage(referenceImageContext, CGRectMake(0.0f, 0.0f, self.size.width, self.size.height), self.CGImage); + CGContextDrawImage(imageContext, CGRectMake(0.0f, 0.0f, image.size.width, image.size.height), image.CGImage); + CGContextRelease(referenceImageContext); + CGContextRelease(imageContext); + + BOOL imageEqual = (memcmp(referenceImagePixels, imagePixels, referenceImageSizeBytes) == 0); + free(referenceImagePixels); + free(imagePixels); + return imageEqual; +} + +@end diff --git a/Libraries/RCTTest/FBSnapshotTestCase/UIImage+Diff.h b/Libraries/RCTTest/FBSnapshotTestCase/UIImage+Diff.h new file mode 100644 index 000000000..35595843f --- /dev/null +++ b/Libraries/RCTTest/FBSnapshotTestCase/UIImage+Diff.h @@ -0,0 +1,37 @@ +// +// Created by Gabriel Handford on 3/1/09. +// Copyright 2009-2013. All rights reserved. +// Created by John Boiles on 10/20/11. +// Copyright (c) 2011. All rights reserved +// Modified by Felix Schulze on 2/11/13. +// Copyright 2013. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#import + +@interface UIImage (Diff) + +- (UIImage *)diffWithImage:(UIImage *)image; + +@end diff --git a/Libraries/RCTTest/FBSnapshotTestCase/UIImage+Diff.m b/Libraries/RCTTest/FBSnapshotTestCase/UIImage+Diff.m new file mode 100644 index 000000000..44ecb59ee --- /dev/null +++ b/Libraries/RCTTest/FBSnapshotTestCase/UIImage+Diff.m @@ -0,0 +1,56 @@ +// +// Created by Gabriel Handford on 3/1/09. +// Copyright 2009-2013. All rights reserved. +// Created by John Boiles on 10/20/11. +// Copyright (c) 2011. All rights reserved +// Modified by Felix Schulze on 2/11/13. +// Copyright 2013. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#import "UIImage+Diff.h" + +@implementation UIImage (Diff) + +- (UIImage *)diffWithImage:(UIImage *)image +{ + if (!image) { + return nil; + } + CGSize imageSize = CGSizeMake(MAX(self.size.width, image.size.width), MAX(self.size.height, image.size.height)); + UIGraphicsBeginImageContextWithOptions(imageSize, YES, 0.0); + CGContextRef context = UIGraphicsGetCurrentContext(); + [self drawInRect:CGRectMake(0, 0, self.size.width, self.size.height)]; + CGContextSetAlpha(context, 0.5f); + CGContextBeginTransparencyLayer(context, NULL); + [image drawInRect:CGRectMake(0, 0, image.size.width, image.size.height)]; + CGContextSetBlendMode(context, kCGBlendModeDifference); + CGContextSetFillColorWithColor(context,[UIColor whiteColor].CGColor); + CGContextFillRect(context, CGRectMake(0, 0, self.size.width, self.size.height)); + CGContextEndTransparencyLayer(context); + UIImage *returnImage = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + return returnImage; +} + +@end diff --git a/Libraries/RCTTest/RCTTest.xcodeproj/project.pbxproj b/Libraries/RCTTest/RCTTest.xcodeproj/project.pbxproj index f377b4c98..bb4457b1f 100644 --- a/Libraries/RCTTest/RCTTest.xcodeproj/project.pbxproj +++ b/Libraries/RCTTest/RCTTest.xcodeproj/project.pbxproj @@ -10,6 +10,9 @@ 585135371AB3C56F00882537 /* RCTTestModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 585135341AB3C56F00882537 /* RCTTestModule.m */; }; 585135381AB3C57000882537 /* RCTTestRunner.m in Sources */ = {isa = PBXBuildFile; fileRef = 585135361AB3C56F00882537 /* RCTTestRunner.m */; }; 585135391AB3C59A00882537 /* RCTTestRunner.h in CopyFiles */ = {isa = PBXBuildFile; fileRef = 585135351AB3C56F00882537 /* RCTTestRunner.h */; }; + 58E64FED1AB964CD007446E2 /* FBSnapshotTestController.m in Sources */ = {isa = PBXBuildFile; fileRef = 58E64FE71AB964CD007446E2 /* FBSnapshotTestController.m */; }; + 58E64FEE1AB964CD007446E2 /* UIImage+Compare.m in Sources */ = {isa = PBXBuildFile; fileRef = 58E64FE91AB964CD007446E2 /* UIImage+Compare.m */; }; + 58E64FEF1AB964CD007446E2 /* UIImage+Diff.m in Sources */ = {isa = PBXBuildFile; fileRef = 58E64FEB1AB964CD007446E2 /* UIImage+Diff.m */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -31,6 +34,14 @@ 585135341AB3C56F00882537 /* RCTTestModule.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTTestModule.m; sourceTree = ""; }; 585135351AB3C56F00882537 /* RCTTestRunner.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTTestRunner.h; sourceTree = ""; }; 585135361AB3C56F00882537 /* RCTTestRunner.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTTestRunner.m; sourceTree = ""; }; + 58E64FE41AB964CD007446E2 /* FBSnapshotTestCase.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBSnapshotTestCase.h; sourceTree = ""; }; + 58E64FE51AB964CD007446E2 /* FBSnapshotTestCase.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSnapshotTestCase.m; sourceTree = ""; }; + 58E64FE61AB964CD007446E2 /* FBSnapshotTestController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBSnapshotTestController.h; sourceTree = ""; }; + 58E64FE71AB964CD007446E2 /* FBSnapshotTestController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSnapshotTestController.m; sourceTree = ""; }; + 58E64FE81AB964CD007446E2 /* UIImage+Compare.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "UIImage+Compare.h"; sourceTree = ""; }; + 58E64FE91AB964CD007446E2 /* UIImage+Compare.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UIImage+Compare.m"; sourceTree = ""; }; + 58E64FEA1AB964CD007446E2 /* UIImage+Diff.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "UIImage+Diff.h"; sourceTree = ""; }; + 58E64FEB1AB964CD007446E2 /* UIImage+Diff.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UIImage+Diff.m"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -51,6 +62,7 @@ 585135341AB3C56F00882537 /* RCTTestModule.m */, 585135351AB3C56F00882537 /* RCTTestRunner.h */, 585135361AB3C56F00882537 /* RCTTestRunner.m */, + 58E64FE31AB964CD007446E2 /* FBSnapshotTestCase */, 580C37701AB104AF0015E709 /* Products */, ); sourceTree = ""; @@ -63,6 +75,21 @@ name = Products; sourceTree = ""; }; + 58E64FE31AB964CD007446E2 /* FBSnapshotTestCase */ = { + isa = PBXGroup; + children = ( + 58E64FE41AB964CD007446E2 /* FBSnapshotTestCase.h */, + 58E64FE51AB964CD007446E2 /* FBSnapshotTestCase.m */, + 58E64FE61AB964CD007446E2 /* FBSnapshotTestController.h */, + 58E64FE71AB964CD007446E2 /* FBSnapshotTestController.m */, + 58E64FE81AB964CD007446E2 /* UIImage+Compare.h */, + 58E64FE91AB964CD007446E2 /* UIImage+Compare.m */, + 58E64FEA1AB964CD007446E2 /* UIImage+Diff.h */, + 58E64FEB1AB964CD007446E2 /* UIImage+Diff.m */, + ); + path = FBSnapshotTestCase; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -119,7 +146,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 58E64FEE1AB964CD007446E2 /* UIImage+Compare.m in Sources */, 585135371AB3C56F00882537 /* RCTTestModule.m in Sources */, + 58E64FEF1AB964CD007446E2 /* UIImage+Diff.m in Sources */, + 58E64FED1AB964CD007446E2 /* FBSnapshotTestController.m in Sources */, 585135381AB3C57000882537 /* RCTTestRunner.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Libraries/RCTTest/RCTTestModule.h b/Libraries/RCTTest/RCTTestModule.h index 1b50f5db2..0f5adcd2c 100644 --- a/Libraries/RCTTest/RCTTestModule.h +++ b/Libraries/RCTTest/RCTTestModule.h @@ -7,10 +7,20 @@ * of patent rights can be found in the PATENTS file in the same directory. */ +#import + #import "RCTBridgeModule.h" +@class FBSnapshotTestController; + @interface RCTTestModule : NSObject +// This is typically polled while running the runloop until true @property (nonatomic, readonly, getter=isDone) BOOL done; +// This is used to give meaningful names to snapshot image files. +@property (nonatomic, assign) SEL testSelector; + +- (instancetype)initWithSnapshotController:(FBSnapshotTestController *)controller view:(UIView *)view; + @end diff --git a/Libraries/RCTTest/RCTTestModule.m b/Libraries/RCTTest/RCTTestModule.m index a02d7759c..03f025f20 100644 --- a/Libraries/RCTTest/RCTTestModule.m +++ b/Libraries/RCTTest/RCTTestModule.m @@ -9,7 +9,46 @@ #import "RCTTestModule.h" -@implementation RCTTestModule +#import "FBSnapshotTestController.h" +#import "RCTAssert.h" +#import "RCTLog.h" + +@implementation RCTTestModule { + __weak FBSnapshotTestController *_snapshotController; + __weak UIView *_view; + NSMutableDictionary *_snapshotCounter; +} + +- (instancetype)initWithSnapshotController:(FBSnapshotTestController *)controller view:(UIView *)view +{ + if ((self = [super init])) { + _snapshotController = controller; + _view = view; + _snapshotCounter = [NSMutableDictionary new]; + } + return self; +} + +- (void)verifySnapshot:(RCTResponseSenderBlock)callback +{ + RCT_EXPORT(); + + if (!_snapshotController) { + RCTLogWarn(@"No snapshot controller configured."); + callback(@[]); + return; + } + + NSError *error = nil; + NSString *testName = NSStringFromSelector(_testSelector); + _snapshotCounter[testName] = @([_snapshotCounter[testName] integerValue] + 1); + BOOL success = [_snapshotController compareSnapshotOfView:_view + selector:_testSelector + identifier:[_snapshotCounter[testName] stringValue] + error:&error]; + RCTAssert(success, @"Snapshot comparison failed: %@", error); + callback(@[]); +} - (void)markTestCompleted { diff --git a/Libraries/RCTTest/RCTTestRunner.h b/Libraries/RCTTest/RCTTestRunner.h index 592afa479..9d56202ce 100644 --- a/Libraries/RCTTest/RCTTestRunner.h +++ b/Libraries/RCTTest/RCTTestRunner.h @@ -9,13 +9,63 @@ #import +/** + * Use the initRunnerForApp macro for typical usage. + * + * Add this to your test target's gcc preprocessor macros: + * + * FB_REFERENCE_IMAGE_DIR="\"$(SOURCE_ROOT)/$(PROJECT_NAME)Tests/ReferenceImages\"" + */ +#define initRunnerForApp(app__) [[RCTTestRunner alloc] initWithApp:(app__) referenceDir:@FB_REFERENCE_IMAGE_DIR] + @interface RCTTestRunner : NSObject +@property (nonatomic, assign) BOOL recordMode; @property (nonatomic, copy) NSString *script; -- (instancetype)initWithApp:(NSString *)app; -- (void)runTest:(NSString *)moduleName; -- (void)runTest:(NSString *)moduleName initialProps:(NSDictionary *)initialProps expectErrorRegex:(NSRegularExpression *)expectErrorRegex; -- (void)runTest:(NSString *)moduleName initialProps:(NSDictionary *)initialProps expectErrorBlock:(BOOL(^)(NSString *error))expectErrorBlock; +/** + * Initialize a runner. It's recommended that you use the initRunnerForApp macro instead of calling this directly. + * + * @param app The path to the app bundle without suffixes, e.g. IntegrationTests/IntegrationTestsApp + * @param referencesDir The path for snapshot references images. The initRunnerForApp macro uses + * FB_REFERENCE_IMAGE_DIR for this automatically. + */ +- (instancetype)initWithApp:(NSString *)app referenceDir:(NSString *)referenceDir; + +/** + * Simplest runTest function simply mounts the specified JS module with no initialProps and waits for it to call + * + * RCTTestModule.markTestCompleted() + * + * JS errors/exceptions and timeouts will fail the test. Snapshot tests call RCTTestModule.verifySnapshot whenever they + * want to verify what has been rendered (typically via requestAnimationFrame to make sure the latest state has been + * rendered in native. + * + * @param test Selector of the test, usually just `_cmd`. + * @param moduleName Name of the JS component as registered by `AppRegistry.registerComponent` in JS. + */ +- (void)runTest:(SEL)test module:(NSString *)moduleName; + +/** + * Same as runTest:, but allows for passing initialProps for providing mock data or requesting different behaviors, and + * expectErrorRegex verifies that the error you expected was thrown. + * + * @param test Selector of the test, usually just `_cmd`. + * @param moduleName Name of the JS component as registered by `AppRegistry.registerComponent` in JS. + * @param initialProps props that are passed into the component when rendered. + * @param expectErrorRegex A regex that must match the error thrown. If no error is thrown, the test fails. + */ +- (void)runTest:(SEL)test module:(NSString *)moduleName initialProps:(NSDictionary *)initialProps expectErrorRegex:(NSRegularExpression *)expectErrorRegex; + +/** + * Same as runTest:, but allows for passing initialProps for providing mock data or requesting different behaviors, and + * expectErrorBlock provides arbitrary logic for processing errors (nil will cause any error to fail the test). + * + * @param test Selector of the test, usually just `_cmd`. + * @param moduleName Name of the JS component as registered by `AppRegistry.registerComponent` in JS. + * @param initialProps props that are passed into the component when rendered. + * @param expectErrorBlock A block that takes the error message and returns NO to fail the test. + */ +- (void)runTest:(SEL)test module:(NSString *)moduleName initialProps:(NSDictionary *)initialProps expectErrorBlock:(BOOL(^)(NSString *error))expectErrorBlock; @end diff --git a/Libraries/RCTTest/RCTTestRunner.m b/Libraries/RCTTest/RCTTestRunner.m index 71459e51b..a6b710e3b 100644 --- a/Libraries/RCTTest/RCTTestRunner.m +++ b/Libraries/RCTTest/RCTTestRunner.m @@ -9,6 +9,7 @@ #import "RCTTestRunner.h" +#import "FBSnapshotTestController.h" #import "RCTRedBox.h" #import "RCTRootView.h" #import "RCTTestModule.h" @@ -17,33 +18,55 @@ #define TIMEOUT_SECONDS 240 @implementation RCTTestRunner - -- (instancetype)initWithApp:(NSString *)app { - if (self = [super init]) { + FBSnapshotTestController *_snapshotController; +} + +- (instancetype)initWithApp:(NSString *)app referenceDir:(NSString *)referenceDir +{ + if ((self = [super init])) { + NSString *sanitizedAppName = [app stringByReplacingOccurrencesOfString:@"/" withString:@"-"]; + sanitizedAppName = [sanitizedAppName stringByReplacingOccurrencesOfString:@"\\" withString:@"-"]; + _snapshotController = [[FBSnapshotTestController alloc] initWithTestName:sanitizedAppName]; + _snapshotController.referenceImagesDirectory = referenceDir; _script = [NSString stringWithFormat:@"http://localhost:8081/%@.includeRequire.runModule.bundle?dev=true", app]; } return self; } -- (void)runTest:(NSString *)moduleName +- (void)setRecordMode:(BOOL)recordMode { - [self runTest:moduleName initialProps:nil expectErrorBlock:nil]; + _snapshotController.recordMode = recordMode; } -- (void)runTest:(NSString *)moduleName initialProps:(NSDictionary *)initialProps expectErrorRegex:(NSRegularExpression *)errorRegex +- (BOOL)recordMode { - [self runTest:moduleName initialProps:initialProps expectErrorBlock:^BOOL(NSString *error){ + return _snapshotController.recordMode; +} + +- (void)runTest:(SEL)test module:(NSString *)moduleName +{ + [self runTest:test module:moduleName initialProps:nil expectErrorBlock:nil]; +} + +- (void)runTest:(SEL)test module:(NSString *)moduleName initialProps:(NSDictionary *)initialProps expectErrorRegex:(NSRegularExpression *)errorRegex +{ + [self runTest:test module:moduleName initialProps:initialProps expectErrorBlock:^BOOL(NSString *error){ return [errorRegex numberOfMatchesInString:error options:0 range:NSMakeRange(0, [error length])] > 0; }]; } -- (void)runTest:(NSString *)moduleName initialProps:(NSDictionary *)initialProps expectErrorBlock:(BOOL(^)(NSString *error))expectErrorBlock +- (void)runTest:(SEL)test module:(NSString *)moduleName initialProps:(NSDictionary *)initialProps expectErrorBlock:(BOOL(^)(NSString *error))expectErrorBlock { - RCTTestModule *testModule = [[RCTTestModule alloc] init]; - RCTRootView *rootView = [[RCTRootView alloc] init]; UIViewController *vc = [[[[UIApplication sharedApplication] delegate] window] rootViewController]; - vc.view = rootView; + if ([vc.view isKindOfClass:[RCTRootView class]]) { + [(RCTRootView *)vc.view invalidate]; // Make sure the normal app view doesn't interfere + } + vc.view = [[UIView alloc] init]; + RCTRootView *rootView = [[RCTRootView alloc] initWithFrame:CGRectMake(0, 0, 320, 2000)]; // Constant size for testing on multiple devices + RCTTestModule *testModule = [[RCTTestModule alloc] initWithSnapshotController:_snapshotController view:rootView]; + testModule.testSelector = test; + [vc.view addSubview:rootView]; // Add as subview so it doesn't get resized rootView.moduleProvider = ^(void){ return @[testModule]; }; @@ -58,9 +81,13 @@ [[NSRunLoop mainRunLoop] runMode:NSRunLoopCommonModes beforeDate:date]; error = [[RCTRedBox sharedInstance] currentErrorMessage]; } + [rootView invalidate]; + [rootView removeFromSuperview]; + RCTAssert(vc.view.subviews.count == 0, @"There shouldn't be any other views: %@", vc.view); + vc.view = nil; [[RCTRedBox sharedInstance] dismiss]; if (expectErrorBlock) { - RCTAssert(expectErrorBlock(error), @"Expected an error but got none."); + RCTAssert(expectErrorBlock(error), @"Expected an error but nothing matched."); } else if (error) { RCTAssert(error == nil, @"RedBox error: %@", error); } else { diff --git a/Libraries/react-native/react-native.js b/Libraries/react-native/react-native.js index 2ad33b5d4..a6f25c9f1 100644 --- a/Libraries/react-native/react-native.js +++ b/Libraries/react-native/react-native.js @@ -62,11 +62,12 @@ var ReactNative = Object.assign(Object.create(require('React')), { NativeModules: require('NativeModules'), addons: { - batchedUpdates: require('ReactUpdates').batchedUpdates, LinkedStateMixin: require('LinkedStateMixin'), Perf: undefined, PureRenderMixin: require('ReactComponentWithPureRenderMixin'), + TestModule: require('NativeModules').TestModule, TestUtils: undefined, + batchedUpdates: require('ReactUpdates').batchedUpdates, cloneWithProps: require('cloneWithProps'), update: require('update'), }, diff --git a/ReactKit/Base/RCTRootView.h b/ReactKit/Base/RCTRootView.h index 0ac716f3a..15f99fdee 100644 --- a/ReactKit/Base/RCTRootView.h +++ b/ReactKit/Base/RCTRootView.h @@ -11,7 +11,7 @@ #import "RCTBridge.h" -@interface RCTRootView : UIView +@interface RCTRootView : UIView /** * The URL of the bundled application script (required). diff --git a/ReactKit/Base/RCTRootView.m b/ReactKit/Base/RCTRootView.m index e7aea0f43..cf3c9da49 100644 --- a/ReactKit/Base/RCTRootView.m +++ b/ReactKit/Base/RCTRootView.m @@ -106,12 +106,32 @@ static Class _globalExecutorClass; [_bridge enqueueJSCall:@"ReactIOS.unmountComponentAtNodeAndRemoveContainer" args:@[self.reactTag]]; + [self invalidate]; +} + +#pragma mark - RCTInvalidating + +- (BOOL)isValid +{ + return [_bridge isValid]; +} + +- (void)invalidate +{ + // Clear view + [self.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)]; + + [self removeGestureRecognizer:_touchHandler]; + [_touchHandler invalidate]; + [_executor invalidate]; // TODO: eventually we'll want to be able to share the bridge between // multiple rootviews, in which case we'll need to move this elsewhere [_bridge invalidate]; } +#pragma mark Bundle loading + - (void)bundleFinishedLoading:(NSError *)error { if (error != nil) { @@ -137,19 +157,12 @@ static Class _globalExecutorClass; - (void)loadBundle { - // Clear view - [self.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)]; + [self invalidate]; if (!_scriptURL) { return; } - // Clean up - [self removeGestureRecognizer:_touchHandler]; - [_touchHandler invalidate]; - [_executor invalidate]; - [_bridge invalidate]; - // Choose local executor if specified, followed by global, followed by default _executor = [[_executorClass ?: _globalExecutorClass ?: [RCTContextExecutor class] alloc] init]; _bridge = [[RCTBridge alloc] initWithExecutor:_executor moduleProvider:_moduleProvider]; @@ -209,6 +222,9 @@ static Class _globalExecutorClass; [self bundleFinishedLoading:error]; return; } + if (!_bridge.isValid) { + return; // Bridge was invalidated in the meanwhile + } // Success! RCTSourceCode *sourceCodeModule = _bridge.modules[NSStringFromClass([RCTSourceCode class])]; @@ -217,7 +233,9 @@ static Class _globalExecutorClass; [_bridge enqueueApplicationScript:rawText url:_scriptURL onComplete:^(NSError *error) { dispatch_async(dispatch_get_main_queue(), ^{ - [self bundleFinishedLoading:error]; + if (_bridge.isValid) { + [self bundleFinishedLoading:error]; + } }); }]; diff --git a/runXcodeTests.sh b/runXcodeTests.sh new file mode 100755 index 000000000..ae3255fb5 --- /dev/null +++ b/runXcodeTests.sh @@ -0,0 +1,19 @@ +#!/bin/sh + +# Run from react-native root + +set -e + +xctool \ + -project IntegrationTests/IntegrationTests.xcodeproj \ + -scheme IntegrationTests \ + -sdk iphonesimulator8.1 \ + -destination "platform=iOS Simulator,OS=${1},name=iPhone 5" \ + build test + +xctool \ + -project Examples/UIExplorer/UIExplorer.xcodeproj \ + -scheme UIExplorer \ + -sdk iphonesimulator8.1 \ + -destination "platform=iOS Simulator,OS=${1},name=iPhone 5" \ + build test