diff --git a/detox/detox.d.ts b/detox/detox.d.ts index d34da14bac..8ddfd3a1b5 100644 --- a/detox/detox.d.ts +++ b/detox/detox.d.ts @@ -431,6 +431,8 @@ declare global { readonly web: WebFacade; + readonly system: SystemFacade; + readonly DetoxConstants: { userNotificationTriggers: { push: 'push'; @@ -1038,6 +1040,12 @@ declare global { * Collection of web matchers */ readonly web: ByWebFacade; + + /** + * Collection of system-level matchers + * @note System APIs are still in experimental phase and are subject to changes in the near future. + */ + readonly system: BySystemFacade; } interface ByWebFacade { @@ -1106,6 +1114,24 @@ declare global { tag(tagName: string): WebMatcher; } + interface BySystemFacade { + /** + * Find an element on the System-level by its label + * @note System APIs are still in experimental phase and are subject to changes in the near future. + * @example + * system.element(by.system.text('Allow')) + */ + label(text: string): SystemMatcher; + + /** + * Find an element on the System-level by its type + * @note System APIs are still in experimental phase and are subject to changes in the near future. + * @example + * system.element(by.system.type('button')) + */ + type(type: string): SystemMatcher; + } + interface NativeMatcher { /** * Find an element satisfying all the matchers @@ -1127,13 +1153,19 @@ declare global { } interface WebMatcher { - __web__: any; // prevent type coersion + __web__: any; // prevent type coercion + } + + interface SystemMatcher { + __system__: any; // prevent type coercion } interface ExpectFacade { (element: NativeElement): Expect; (webElement: WebElement): WebExpect; + + (systemElement: SystemElement): SystemExpect; } interface WebViewElement { @@ -1164,6 +1196,35 @@ declare global { (matcher?: NativeMatcher): WebViewElement; } + interface SystemFacade { + /** + * Find an element on the System-level using a system matcher. + * @param systemMatcher a system matcher for the system element. + * @note System APIs are still in experimental phase and are subject to changes in the near future. + * @example + * system.element(by.system.label('Allow')) + */ + element(systemMatcher: SystemMatcher): IndexableSystemElement; + } + + interface IndexableSystemElement extends SystemElement { + /** + * Choose from multiple elements matching the same matcher using index + * @note System APIs are still in experimental phase and are subject to changes in the near future. + * @example await system.element(by.system.type('button')).atIndex(1).tap(); + */ + atIndex(index: number): SystemElement; + } + + interface SystemElement { + /** + * Simulate a tap on the element. + * @note System APIs are still in experimental phase and are subject to changes in the near future. + * @example await system.element(by.system.label('Allow')).tap(); + */ + tap(): Promise; + } + interface Expect> { /** @@ -1531,6 +1592,22 @@ declare global { toExist(): R; } + interface SystemExpect> { + /** + * Negate the expectation. + * @note System APIs are still in experimental phase and are subject to changes in the near future. + * @example await expect(system.element(by.system.text('Allow'))).not.toExist(); + */ + not: this; + + /** + * Expect the view to exist in the system-level. + * @note System APIs are still in experimental phase and are subject to changes in the near future. + * @example await expect(system.element(by.system.text('Allow'))).toExist(); + */ + toExist(): R; + } + interface IndexableWebElement extends WebElement { /** * Choose from multiple elements matching the same matcher using index. diff --git a/detox/globals.d.ts b/detox/globals.d.ts index f58f993fbb..3da05b8e21 100644 --- a/detox/globals.d.ts +++ b/detox/globals.d.ts @@ -8,6 +8,7 @@ declare global { const expect: Detox.DetoxExportWrapper['expect']; const by: Detox.DetoxExportWrapper['by']; const web: Detox.DetoxExportWrapper['web']; + const system: Detox.DetoxExportWrapper['system']; namespace NodeJS { interface Global { @@ -18,6 +19,7 @@ declare global { expect: Detox.DetoxExportWrapper['expect']; by: Detox.DetoxExportWrapper['by']; web: Detox.DetoxExportWrapper['web']; + system: Detox.DetoxExportWrapper['system']; } } } diff --git a/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner.xcodeproj/project.pbxproj b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..2c4bc4cc8d --- /dev/null +++ b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner.xcodeproj/project.pbxproj @@ -0,0 +1,556 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 602735AA2BCC0ACF00A9EE22 /* DetoxXCUITestRunnerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 602735A92BCC0ACF00A9EE22 /* DetoxXCUITestRunnerApp.swift */; }; + 602735AC2BCC0ACF00A9EE22 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 602735AB2BCC0ACF00A9EE22 /* ContentView.swift */; }; + 602735AE2BCC0AD000A9EE22 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 602735AD2BCC0AD000A9EE22 /* Assets.xcassets */; }; + 602735B12BCC0AD000A9EE22 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 602735B02BCC0AD000A9EE22 /* Preview Assets.xcassets */; }; + 602735BE2BCC0C1500A9EE22 /* DetoxXCUITestRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 602735BD2BCC0C1500A9EE22 /* DetoxXCUITestRunner.swift */; }; + 602735CA2BCC1C4500A9EE22 /* InvocationParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 602735C92BCC1C4500A9EE22 /* InvocationParams.swift */; }; + 602735CD2BCC1F9C00A9EE22 /* InvocationParamsReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 602735CC2BCC1F9C00A9EE22 /* InvocationParamsReader.swift */; }; + 602735D22BCC22E400A9EE22 /* XCUIApplication+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 602735D12BCC22E400A9EE22 /* XCUIApplication+Extensions.swift */; }; + 602735D52BCC24EE00A9EE22 /* ElementType+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 602735D42BCC24EE00A9EE22 /* ElementType+Extensions.swift */; }; + 602735E12BD049C700A9EE22 /* PredicateHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 602735E02BD049C700A9EE22 /* PredicateHandler.swift */; }; + 602735E32BD049FB00A9EE22 /* ActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 602735E22BD049FB00A9EE22 /* ActionHandler.swift */; }; + 602735E52BD04A1A00A9EE22 /* ExpectationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 602735E42BD04A1A00A9EE22 /* ExpectationHandler.swift */; }; + 602735E92BD4F24600A9EE22 /* TimeInterval+defaultTimeout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 602735E82BD4F24600A9EE22 /* TimeInterval+defaultTimeout.swift */; }; + 602735EB2BD5052600A9EE22 /* InvocationParams+matcherDescription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 602735EA2BD5052600A9EE22 /* InvocationParams+matcherDescription.swift */; }; + 602735EE2BD5357D00A9EE22 /* DTXAssert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 602735ED2BD5357D00A9EE22 /* DTXAssert.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 602735C12BCC0C1500A9EE22 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 6027359E2BCC0ACF00A9EE22 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 602735A52BCC0ACF00A9EE22; + remoteInfo = DetoxXCUITestRunnerApp; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 602735A62BCC0ACF00A9EE22 /* DetoxXCUITestRunnerApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DetoxXCUITestRunnerApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 602735A92BCC0ACF00A9EE22 /* DetoxXCUITestRunnerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetoxXCUITestRunnerApp.swift; sourceTree = ""; }; + 602735AB2BCC0ACF00A9EE22 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 602735AD2BCC0AD000A9EE22 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 602735B02BCC0AD000A9EE22 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 602735BB2BCC0C1500A9EE22 /* DetoxXCUITestRunner.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DetoxXCUITestRunner.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 602735BD2BCC0C1500A9EE22 /* DetoxXCUITestRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetoxXCUITestRunner.swift; sourceTree = ""; }; + 602735C92BCC1C4500A9EE22 /* InvocationParams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvocationParams.swift; sourceTree = ""; }; + 602735CC2BCC1F9C00A9EE22 /* InvocationParamsReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvocationParamsReader.swift; sourceTree = ""; }; + 602735D12BCC22E400A9EE22 /* XCUIApplication+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCUIApplication+Extensions.swift"; sourceTree = ""; }; + 602735D42BCC24EE00A9EE22 /* ElementType+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ElementType+Extensions.swift"; sourceTree = ""; }; + 602735DE2BD0114000A9EE22 /* DetoxXCUITestRunner.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = DetoxXCUITestRunner.xctestplan; sourceTree = ""; }; + 602735E02BD049C700A9EE22 /* PredicateHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PredicateHandler.swift; sourceTree = ""; }; + 602735E22BD049FB00A9EE22 /* ActionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionHandler.swift; sourceTree = ""; }; + 602735E42BD04A1A00A9EE22 /* ExpectationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpectationHandler.swift; sourceTree = ""; }; + 602735E82BD4F24600A9EE22 /* TimeInterval+defaultTimeout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+defaultTimeout.swift"; sourceTree = ""; }; + 602735EA2BD5052600A9EE22 /* InvocationParams+matcherDescription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InvocationParams+matcherDescription.swift"; sourceTree = ""; }; + 602735ED2BD5357D00A9EE22 /* DTXAssert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DTXAssert.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 602735A32BCC0ACF00A9EE22 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 602735B82BCC0C1500A9EE22 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 6027359D2BCC0ACF00A9EE22 = { + isa = PBXGroup; + children = ( + 602735DE2BD0114000A9EE22 /* DetoxXCUITestRunner.xctestplan */, + 602735A82BCC0ACF00A9EE22 /* DetoxXCUITestRunner */, + 602735BC2BCC0C1500A9EE22 /* DetoxXCUITestRunner */, + 602735A72BCC0ACF00A9EE22 /* Products */, + ); + sourceTree = ""; + }; + 602735A72BCC0ACF00A9EE22 /* Products */ = { + isa = PBXGroup; + children = ( + 602735A62BCC0ACF00A9EE22 /* DetoxXCUITestRunnerApp.app */, + 602735BB2BCC0C1500A9EE22 /* DetoxXCUITestRunner.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 602735A82BCC0ACF00A9EE22 /* DetoxXCUITestRunner */ = { + isa = PBXGroup; + children = ( + 602735A92BCC0ACF00A9EE22 /* DetoxXCUITestRunnerApp.swift */, + 602735AB2BCC0ACF00A9EE22 /* ContentView.swift */, + 602735AD2BCC0AD000A9EE22 /* Assets.xcassets */, + 602735AF2BCC0AD000A9EE22 /* Preview Content */, + ); + path = DetoxXCUITestRunner; + sourceTree = ""; + }; + 602735AF2BCC0AD000A9EE22 /* Preview Content */ = { + isa = PBXGroup; + children = ( + 602735B02BCC0AD000A9EE22 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + 602735BC2BCC0C1500A9EE22 /* DetoxXCUITestRunner */ = { + isa = PBXGroup; + children = ( + 602735EC2BD534DD00A9EE22 /* Utils */, + 602735DF2BD049BC00A9EE22 /* Handlers */, + 602735D02BCC22C300A9EE22 /* Extensions */, + 602735CB2BCC1F7300A9EE22 /* Params Reader */, + 602735BD2BCC0C1500A9EE22 /* DetoxXCUITestRunner.swift */, + ); + path = DetoxXCUITestRunner; + sourceTree = ""; + }; + 602735CB2BCC1F7300A9EE22 /* Params Reader */ = { + isa = PBXGroup; + children = ( + 602735C92BCC1C4500A9EE22 /* InvocationParams.swift */, + 602735CC2BCC1F9C00A9EE22 /* InvocationParamsReader.swift */, + ); + path = "Params Reader"; + sourceTree = ""; + }; + 602735D02BCC22C300A9EE22 /* Extensions */ = { + isa = PBXGroup; + children = ( + 602735D42BCC24EE00A9EE22 /* ElementType+Extensions.swift */, + 602735EA2BD5052600A9EE22 /* InvocationParams+matcherDescription.swift */, + 602735E82BD4F24600A9EE22 /* TimeInterval+defaultTimeout.swift */, + 602735D12BCC22E400A9EE22 /* XCUIApplication+Extensions.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 602735DF2BD049BC00A9EE22 /* Handlers */ = { + isa = PBXGroup; + children = ( + 602735E02BD049C700A9EE22 /* PredicateHandler.swift */, + 602735E22BD049FB00A9EE22 /* ActionHandler.swift */, + 602735E42BD04A1A00A9EE22 /* ExpectationHandler.swift */, + ); + path = Handlers; + sourceTree = ""; + }; + 602735EC2BD534DD00A9EE22 /* Utils */ = { + isa = PBXGroup; + children = ( + 602735ED2BD5357D00A9EE22 /* DTXAssert.swift */, + ); + path = Utils; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 602735A52BCC0ACF00A9EE22 /* DetoxXCUITestRunnerApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 602735B42BCC0AD000A9EE22 /* Build configuration list for PBXNativeTarget "DetoxXCUITestRunnerApp" */; + buildPhases = ( + 602735A22BCC0ACF00A9EE22 /* Sources */, + 602735A32BCC0ACF00A9EE22 /* Frameworks */, + 602735A42BCC0ACF00A9EE22 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = DetoxXCUITestRunnerApp; + productName = DetoxXCUITestRunner; + productReference = 602735A62BCC0ACF00A9EE22 /* DetoxXCUITestRunnerApp.app */; + productType = "com.apple.product-type.application"; + }; + 602735BA2BCC0C1500A9EE22 /* DetoxXCUITestRunner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 602735C32BCC0C1500A9EE22 /* Build configuration list for PBXNativeTarget "DetoxXCUITestRunner" */; + buildPhases = ( + 602735B72BCC0C1500A9EE22 /* Sources */, + 602735B82BCC0C1500A9EE22 /* Frameworks */, + 602735B92BCC0C1500A9EE22 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 602735C22BCC0C1500A9EE22 /* PBXTargetDependency */, + ); + name = DetoxXCUITestRunner; + productName = DetoxXCUITestRunner; + productReference = 602735BB2BCC0C1500A9EE22 /* DetoxXCUITestRunner.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 6027359E2BCC0ACF00A9EE22 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1530; + LastUpgradeCheck = 1530; + TargetAttributes = { + 602735A52BCC0ACF00A9EE22 = { + CreatedOnToolsVersion = 15.3; + }; + 602735BA2BCC0C1500A9EE22 = { + CreatedOnToolsVersion = 15.3; + TestTargetID = 602735A52BCC0ACF00A9EE22; + }; + }; + }; + buildConfigurationList = 602735A12BCC0ACF00A9EE22 /* Build configuration list for PBXProject "DetoxXCUITestRunner" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 6027359D2BCC0ACF00A9EE22; + productRefGroup = 602735A72BCC0ACF00A9EE22 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 602735A52BCC0ACF00A9EE22 /* DetoxXCUITestRunnerApp */, + 602735BA2BCC0C1500A9EE22 /* DetoxXCUITestRunner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 602735A42BCC0ACF00A9EE22 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 602735B12BCC0AD000A9EE22 /* Preview Assets.xcassets in Resources */, + 602735AE2BCC0AD000A9EE22 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 602735B92BCC0C1500A9EE22 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 602735A22BCC0ACF00A9EE22 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 602735AC2BCC0ACF00A9EE22 /* ContentView.swift in Sources */, + 602735AA2BCC0ACF00A9EE22 /* DetoxXCUITestRunnerApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 602735B72BCC0C1500A9EE22 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 602735D22BCC22E400A9EE22 /* XCUIApplication+Extensions.swift in Sources */, + 602735EB2BD5052600A9EE22 /* InvocationParams+matcherDescription.swift in Sources */, + 602735E32BD049FB00A9EE22 /* ActionHandler.swift in Sources */, + 602735D52BCC24EE00A9EE22 /* ElementType+Extensions.swift in Sources */, + 602735E12BD049C700A9EE22 /* PredicateHandler.swift in Sources */, + 602735BE2BCC0C1500A9EE22 /* DetoxXCUITestRunner.swift in Sources */, + 602735E92BD4F24600A9EE22 /* TimeInterval+defaultTimeout.swift in Sources */, + 602735E52BD04A1A00A9EE22 /* ExpectationHandler.swift in Sources */, + 602735CD2BCC1F9C00A9EE22 /* InvocationParamsReader.swift in Sources */, + 602735CA2BCC1C4500A9EE22 /* InvocationParams.swift in Sources */, + 602735EE2BD5357D00A9EE22 /* DTXAssert.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 602735C22BCC0C1500A9EE22 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 602735A52BCC0ACF00A9EE22 /* DetoxXCUITestRunnerApp */; + targetProxy = 602735C12BCC0C1500A9EE22 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 602735B22BCC0AD000A9EE22 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 602735B32BCC0AD000A9EE22 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 602735B52BCC0AD000A9EE22 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"DetoxXCUITestRunner/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.wix.DetoxXCUITestRunnerApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 602735B62BCC0AD000A9EE22 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"DetoxXCUITestRunner/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.wix.DetoxXCUITestRunnerApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 602735C42BCC0C1500A9EE22 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.wix.DetoxXCUITestRunner; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = DetoxXCUITestRunnerApp; + }; + name = Debug; + }; + 602735C52BCC0C1500A9EE22 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.wix.DetoxXCUITestRunner; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = DetoxXCUITestRunnerApp; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 602735A12BCC0ACF00A9EE22 /* Build configuration list for PBXProject "DetoxXCUITestRunner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 602735B22BCC0AD000A9EE22 /* Debug */, + 602735B32BCC0AD000A9EE22 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 602735B42BCC0AD000A9EE22 /* Build configuration list for PBXNativeTarget "DetoxXCUITestRunnerApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 602735B52BCC0AD000A9EE22 /* Debug */, + 602735B62BCC0AD000A9EE22 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 602735C32BCC0C1500A9EE22 /* Build configuration list for PBXNativeTarget "DetoxXCUITestRunner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 602735C42BCC0C1500A9EE22 /* Debug */, + 602735C52BCC0C1500A9EE22 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 6027359E2BCC0ACF00A9EE22 /* Project object */; +} diff --git a/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner.xcodeproj/xcshareddata/xcschemes/DetoxXCUITestRunner.xcscheme b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner.xcodeproj/xcshareddata/xcschemes/DetoxXCUITestRunner.xcscheme new file mode 100644 index 0000000000..c0d31b01d1 --- /dev/null +++ b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner.xcodeproj/xcshareddata/xcschemes/DetoxXCUITestRunner.xcscheme @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner.xctestplan b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner.xctestplan new file mode 100644 index 0000000000..ea4bc42aff --- /dev/null +++ b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner.xctestplan @@ -0,0 +1,28 @@ +{ + "configurations" : [ + { + "id" : "C80674F4-8FF4-44FC-9217-568D50BF2A27", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "targetForVariableExpansion" : { + "containerPath" : "container:DetoxXCUITestRunner.xcodeproj", + "identifier" : "602735A52BCC0ACF00A9EE22", + "name" : "DetoxXCUITestRunnerApp" + } + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:DetoxXCUITestRunner.xcodeproj", + "identifier" : "602735BA2BCC0C1500A9EE22", + "name" : "DetoxXCUITestRunner" + } + } + ], + "version" : 1 +} diff --git a/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Assets.xcassets/AccentColor.colorset/Contents.json b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000000..eb87897008 --- /dev/null +++ b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Assets.xcassets/AppIcon.appiconset/Contents.json b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..13613e3ee1 --- /dev/null +++ b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Assets.xcassets/Contents.json b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/ContentView.swift b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/ContentView.swift new file mode 100644 index 0000000000..1db09c2aab --- /dev/null +++ b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/ContentView.swift @@ -0,0 +1,23 @@ +// +// ContentView.swift (DetoxXCUITestRunnerApp) +// Created by Asaf Korem (Wix.com) on 2024. +// + +import SwiftUI + +struct ContentView: View { + var body: some View { + VStack { + Image(systemName: "xmark") + .imageScale(.large) + .foregroundColor(.red) + Text("Not a real app") + .font(.title) + Text("Detox XCUITest Runner") + .font(.subheadline) + } + .padding() + .border(.gray) + .shadow(radius: 3, x: 2, y: 2) + } +} diff --git a/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/DetoxXCUITestRunner.swift b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/DetoxXCUITestRunner.swift new file mode 100644 index 0000000000..7d5ac53884 --- /dev/null +++ b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/DetoxXCUITestRunner.swift @@ -0,0 +1,31 @@ +// +// DetoxXCUITestRunner.swift (DetoxXCUITestRunner) +// Created by Asaf Korem (Wix.com) on 2024. +// + +import XCTest + +final class DetoxXCUITestRunner: XCTestCase { + var actionHandler: ActionHandler! + var expectationHandler: ExpectationHandler! + + override func setUpWithError() throws { + continueAfterFailure = false + actionHandler = ActionHandler() + expectationHandler = ExpectationHandler() + } + + func testRunner() throws { + let params = try InvocationParamsReader.readParams() + let predicateHandler = PredicateHandler(springboardApp: XCUIApplication.springboard) + let element = predicateHandler.findElement(using: params) + + switch params.type { + case .action: + try actionHandler.handle(from: params, on: element) + + case .expectation: + try expectationHandler.handle(from: params, on: element) + } + } +} diff --git a/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/DetoxXCUITestRunnerApp.swift b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/DetoxXCUITestRunnerApp.swift new file mode 100644 index 0000000000..c8aa43b07d --- /dev/null +++ b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/DetoxXCUITestRunnerApp.swift @@ -0,0 +1,15 @@ +// +// DetoxXCUITestRunnerApp.swift (DetoxXCUITestRunnerApp) +// Created by Asaf Korem (Wix.com) on 2024. +// + +import SwiftUI + +@main +struct DetoxXCUITestRunnerApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Extensions/ElementType+Extensions.swift b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Extensions/ElementType+Extensions.swift new file mode 100644 index 0000000000..47dab82554 --- /dev/null +++ b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Extensions/ElementType+Extensions.swift @@ -0,0 +1,303 @@ +// +// ElementType+Extensions.swift (DetoxXCUITestRunner) +// Created by Asaf Korem (Wix.com) on 2024. +// + +import Foundation +import XCTest + +extension XCUIElement.ElementType { + /// Returns the `XCUIElement.ElementType` as a `String`. + private func asString() -> String { + switch self { + case .switch: + return "switch" + + case .tabBar: + return "tabBar" + + case .staticText: + return "staticText" + + case .searchField: + return "searchField" + + case .link: + return "link" + + case .key: + return "key" + + case .image: + return "image" + + case .button: + return "button" + + case .application: + return "application" + + case .group: + return "group" + + case .slider: + return "slider" + + case .map: + return "map" + + case .window: + return "window" + + case .sheet: + return "sheet" + + case .drawer: + return "drawer" + + case .alert: + return "alert" + + case .dialog: + return "dialog" + + case .radioButton: + return "radioButton" + + case .radioGroup: + return "radioGroup" + + case .checkBox: + return "checkBox" + + case .disclosureTriangle: + return "disclosureTriangle" + + case .popUpButton: + return "popUpButton" + + case .comboBox: + return "comboBox" + + case .menuButton: + return "menuButton" + + case .toolbarButton: + return "toolbarButton" + + case .popover: + return "popover" + + case .keyboard: + return "keyboard" + + case .navigationBar: + return "navigationBar" + + case .tabGroup: + return "tabGroup" + + case .toolbar: + return "toolbar" + + case .statusBar: + return "statusBar" + + case .table: + return "table" + + case .tableRow: + return "tableRow" + + case .tableColumn: + return "tableColumn" + + case .outline: + return "outline" + + case .outlineRow: + return "outlineRow" + + case .browser: + return "browser" + + case .collectionView: + return "collectionView" + + case .pageIndicator: + return "pageIndicator" + + case .progressIndicator: + return "progressIndicator" + + case .activityIndicator: + return "activityIndicator" + + case .segmentedControl: + return "segmentedControl" + + case .picker: + return "picker" + + case .pickerWheel: + return "pickerWheel" + + case .toggle: + return "toggle" + + case .icon: + return "icon" + + case .scrollView: + return "scrollView" + + case .scrollBar: + return "scrollBar" + + case .textField: + return "textField" + + case .secureTextField: + return "secureTextField" + + case .datePicker: + return "datePicker" + + case .textView: + return "textView" + + case .menu: + return "menu" + + case .menuItem: + return "menuItem" + + case .menuBar: + return "menuBar" + + case .menuBarItem: + return "menuBarItem" + + case .webView: + return "webView" + + case .incrementArrow: + return "incrementArrow" + + case .decrementArrow: + return "decrementArrow" + + case .timeline: + return "timeline" + + case .ratingIndicator: + return "ratingIndicator" + + case .valueIndicator: + return "valueIndicator" + + case .splitGroup: + return "splitGroup" + + case .splitter: + return "splitter" + + case .relevanceIndicator: + return "relevanceIndicator" + + case .colorWell: + return "colorWell" + + case .helpTag: + return "helpTag" + + case .matte: + return "matte" + + case .dockItem: + return "dockItem" + + case .ruler: + return "ruler" + + case .rulerMarker: + return "rulerMarker" + + case .grid: + return "grid" + + case .levelIndicator: + return "levelIndicator" + + case .cell: + return "cell" + + case .layoutArea: + return "layoutArea" + + case .layoutItem: + return "layoutItem" + + case .handle: + return "handle" + + case .stepper: + return "stepper" + + case .tab: + return "tab" + + case .touchBar: + return "touchBar" + + case .statusItem: + return "statusItem" + + case .any: + return "any" + + case .other: + return "other" + + @unknown default: + return "unknown" + } + } + + static func from(string typeString: String) throws -> XCUIElement.ElementType { + var type: XCUIElement.ElementType? + + var index: UInt = 0 + while let ithType = XCUIElement.ElementType(rawValue: index) { + let ithTypeString = ithType.asString() + + guard ithTypeString != "unknown" else { + break + } + + if ithTypeString == typeString { + type = ithType + break + } + + index += 1 + } + + guard let type else { + throw Error.unknownElementTypeString(typeString) + } + + return type + } + + enum Error: Swift.Error, LocalizedError { + case unknownElementTypeString(_ typeString: String) + + var errorDescription: String? { + switch self { + case .unknownElementTypeString(let typeString): + return "Unknown element type: \(typeString)" + } + } + } +} diff --git a/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Extensions/InvocationParams+matcherDescription.swift b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Extensions/InvocationParams+matcherDescription.swift new file mode 100644 index 0000000000..49eb9caf50 --- /dev/null +++ b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Extensions/InvocationParams+matcherDescription.swift @@ -0,0 +1,12 @@ +// +// InvocationParams+matcherDescription.swift (DetoxXCUITestRunner) +// Created by Asaf Korem (Wix.com) on 2024. +// + +import Foundation + +extension InvocationParams { + var matcherDescription: String { + return predicate.description + } +} diff --git a/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Extensions/TimeInterval+defaultTimeout.swift b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Extensions/TimeInterval+defaultTimeout.swift new file mode 100644 index 0000000000..f9ad460ac2 --- /dev/null +++ b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Extensions/TimeInterval+defaultTimeout.swift @@ -0,0 +1,14 @@ +// +// TimeInterval+defaultTimeout.swift (DetoxXCUITestRunner) +// Created by Asaf Korem (Wix.com) on 2024. +// + +import Foundation +import XCTest + +extension TimeInterval { + /// Default timeout for assertions. + static var defaultTimeout: TimeInterval { + return 1 + } +} diff --git a/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Extensions/XCUIApplication+Extensions.swift b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Extensions/XCUIApplication+Extensions.swift new file mode 100644 index 0000000000..7053fe8ac9 --- /dev/null +++ b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Extensions/XCUIApplication+Extensions.swift @@ -0,0 +1,13 @@ +// +// XCUIApplication+Extensions.swift (DetoxXCUITestRunner) +// Created by Asaf Korem (Wix.com) on 2024. +// + +import Foundation +import XCTest + +extension XCUIApplication { + static var springboard: XCUIApplication { + return XCUIApplication(bundleIdentifier: "com.apple.springboard") + } +} diff --git a/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Handlers/ActionHandler.swift b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Handlers/ActionHandler.swift new file mode 100644 index 0000000000..1a16c74174 --- /dev/null +++ b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Handlers/ActionHandler.swift @@ -0,0 +1,23 @@ +// +// ActionHandler.swift (DetoxXCUITestRunner) +// Created by Asaf Korem (Wix.com) on 2024. +// + +import Foundation +import XCTest + +class ActionHandler { + func handle(from params: InvocationParams, on element: XCUIElement) throws { + guard let action = params.action else { return } + switch action { + case .tap: + let exists = element.waitForExistence(timeout: .defaultTimeout) + DTXAssert( + exists, + "Tap failed, element with matcher `\(params.matcherDescription)` does not exist" + ) + + element.tap() + } + } +} diff --git a/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Handlers/ExpectationHandler.swift b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Handlers/ExpectationHandler.swift new file mode 100644 index 0000000000..c85aae1755 --- /dev/null +++ b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Handlers/ExpectationHandler.swift @@ -0,0 +1,59 @@ +// +// ExpectationHandler.swift (DetoxXCUITestRunner) +// Created by Asaf Korem (Wix.com) on 2024. +// + +import Foundation +import XCTest + +class ExpectationHandler { + func handle(from params: InvocationParams, on element: XCUIElement) throws { + guard let expectation = params.expectation else { + throw Error.invalidInvocationParams("Expectation type is missing") + } + + let expectedEvaluation = expectedEvaluation(params) + + switch expectation { + case .exists: + assert( + expected: expectedEvaluation, + actual: element.waitForExistence(timeout: .defaultTimeout), + matcherDescription: params.matcherDescription, + evaluationDescription: "exist" + ) + } + } + + private func expectedEvaluation(_ params: InvocationParams) -> Bool { + let modifiers = params.expectationModifiers ?? [] + let shouldNegate = modifiers.contains(.not) + return !shouldNegate + } + + private func assert( + expected: Bool, + actual: Bool, + matcherDescription: String, + evaluationDescription: String + ) { + DTXAssert( + expected == actual, + "Expectation failed, element with matcher `\(matcherDescription)` " + + "does \(actual ? "" : "not ")\(evaluationDescription)" + ) + } +} + +extension ExpectationHandler { + enum Error: Swift.Error, LocalizedError { + case invalidInvocationParams(String) + + var errorDescription: String? { + switch self { + case .invalidInvocationParams(let message): + return "Invalid invocation parameters: \(message)" + } + } + } +} diff --git a/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Handlers/PredicateHandler.swift b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Handlers/PredicateHandler.swift new file mode 100644 index 0000000000..f4f642f2c0 --- /dev/null +++ b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Handlers/PredicateHandler.swift @@ -0,0 +1,32 @@ +// +// PredicateHandler.swift (DetoxXCUITestRunner) +// Created by Asaf Korem (Wix.com) on 2024. +// + +import Foundation +import XCTest + +class PredicateHandler { + let springboardApp: XCUIApplication + + init(springboardApp: XCUIApplication) { + self.springboardApp = springboardApp + } + + func findElement(using params: InvocationParams) -> XCUIElement { + let predicate = params.predicate + let query: XCUIElementQuery + + switch predicate.type { + case .label: + query = springboardApp.descendants(matching: .any).matching(identifier: predicate.value) + + case .type: + let elementType = try! XCUIElement.ElementType.from(string: predicate.value) + query = springboardApp.descendants(matching: elementType) + } + + let atIndex = params.atIndex ?? 0 + return query.element(boundBy: atIndex) + } +} diff --git a/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Params Reader/InvocationParams.swift b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Params Reader/InvocationParams.swift new file mode 100644 index 0000000000..65f1a3c441 --- /dev/null +++ b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Params Reader/InvocationParams.swift @@ -0,0 +1,69 @@ +// +// InvocationParams.swift (DetoxXCUITestRunner) +// Created by Asaf Korem (Wix.com) on 2024. +// + +import Foundation + +struct InvocationParams: Codable { + let type: InvocationType + let predicate: Predicate + let atIndex: Int? + let action: Action? + let expectation: Expectation? + let expectationModifiers: [Expectation.Modifiers]? + let params: [String: String]? + + enum CodingKeys: String, CodingKey { + case type = "type" + case predicate = "systemPredicate" + case atIndex = "systemAtIndex" + case action = "systemAction" + case expectation = "systemExpectation" + case expectationModifiers = "systemModifiers" + case params = "params" + } +} + +extension InvocationParams { + enum InvocationType: String, Codable { + case action = "systemAction" + case expectation = "systemExpectation" + } +} + +extension InvocationParams { + struct Predicate: Codable, CustomStringConvertible { + let type: Predicate.PredicateType + let value: String + + var description: String { + return "\(type.rawValue) == \"\(value)\"" + } + } +} + +extension InvocationParams.Predicate { + enum PredicateType: String, Codable { + case label + case type + } +} + +extension InvocationParams { + enum Action: String, Codable { + case tap + } +} + +extension InvocationParams { + enum Expectation: String, Codable { + case exists = "toExist" + } +} + +extension InvocationParams.Expectation { + enum Modifiers: String, Codable { + case not + } +} diff --git a/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Params Reader/InvocationParamsReader.swift b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Params Reader/InvocationParamsReader.swift new file mode 100644 index 0000000000..48da907fb5 --- /dev/null +++ b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Params Reader/InvocationParamsReader.swift @@ -0,0 +1,41 @@ +// +// InvocationParamsReader.swift (DetoxXCUITestRunner) +// Created by Asaf Korem (Wix.com) on 2024. +// + +import Foundation + +class InvocationParamsReader { + static func readParams() throws -> InvocationParams { + do { + let environment = ProcessInfo.processInfo.environment + + guard let base64Params = environment["PARAMS"] else { + throw Error.missingRunnerParams + } + + guard let data = Data(base64Encoded: base64Params) else { + throw Error.failedToDecodeParams(base64Params) + } + + return try JSONDecoder().decode(InvocationParams.self, from: data) + } + } +} + +extension InvocationParamsReader { + enum Error: Swift.Error, LocalizedError { + case missingRunnerParams + case failedToDecodeParams(_ base64Params: String) + + var errorDescription: String? { + switch self { + case .missingRunnerParams: + return "Missing runner params (`TEST_RUNNER_PARAMS`) in environment variables" + + case .failedToDecodeParams(let base64Params): + return "Failed to decode runner params from base64 string: \(base64Params)" + } + } + } +} diff --git a/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Preview Content/Preview Assets.xcassets/Contents.json b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Utils/DTXAssert.swift b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Utils/DTXAssert.swift new file mode 100644 index 0000000000..e567b2f8e0 --- /dev/null +++ b/detox/ios/DetoxXCUITestRunner/DetoxXCUITestRunner/Utils/DTXAssert.swift @@ -0,0 +1,14 @@ +// +// DTXAssert.swift (DetoxXCUITestRunner) +// Created by Asaf Korem (Wix.com) on 2024. +// + +import Foundation +import XCTest + +public func DTXAssert(_ assertion: Bool, _ message: String) { + XCTAssert( + assertion, + "DTXError: \(message)" + ) +} diff --git a/detox/local-cli/build-framework-cache.js b/detox/local-cli/build-framework-cache.js index 44e27b707b..77dfc473f5 100644 --- a/detox/local-cli/build-framework-cache.js +++ b/detox/local-cli/build-framework-cache.js @@ -1,16 +1,24 @@ -const cp = require('child_process'); -const os = require('os'); -const path = require('path'); +const { build } = require('./utils/frameworkUtils'); -const detox = require('../internals'); +module.exports = { + command: 'build-framework-cache', + desc: 'Builds cached versions of the Detox framework and XCUITest-runner in ~/Library/Detox. ' + + 'Use the `--detox` and `--xcuitest` flags to selectively build the framework components. ' + + 'By default, both the injected Detox library and the XCUITest test runner are built. (MacOS only)', -module.exports.command = 'build-framework-cache'; -module.exports.desc = 'Builds a cached Detox framework for the current environment in ~/Library/Detox. The cached framework is unique for each combination of Xcode and Detox version. (macOS only)'; + builder: yargs => yargs + .option('detox', { + describe: 'Build only the injected Detox library', + type: 'boolean', + default: false + }) + .option('xcuitest', { + describe: 'Build only the XCUITest test runner', + type: 'boolean', + default: false + }), -module.exports.handler = async function buildFrameworkCache() { - if (os.platform() === 'darwin') { - cp.execSync(path.join(__dirname, '../scripts/build_framework.ios.sh'), { stdio: 'inherit' }); - } else { - detox.log.info(`The command is supported only on macOS, skipping the execution.`); + handler: async function(argv) { + await build(argv.detox, argv.xcuitest); } }; diff --git a/detox/local-cli/clean-framework-cache.js b/detox/local-cli/clean-framework-cache.js index 0b57ab10e5..81f7cb2a67 100644 --- a/detox/local-cli/clean-framework-cache.js +++ b/detox/local-cli/clean-framework-cache.js @@ -1,19 +1,24 @@ -const os = require('os'); -const path = require('path'); +const { clean } = require('./utils/frameworkUtils'); -const fs = require('fs-extra'); +module.exports = { + command: 'clean-framework-cache', + desc: 'Cleans cached versions of the Detox framework and XCUITest-runner in ~/Library/Detox. ' + + 'Use the `--detox` and `--xcuitest` flags to selectively clean the framework components. ' + + 'By default, both the injected Detox library and the XCUITest test runner are cleaned. (MacOS only)', -const detox = require('../internals'); + builder: yargs => yargs + .option('detox', { + describe: 'Clean only the injected Detox library', + type: 'boolean', + default: false + }) + .option('xcuitest', { + describe: 'Clean only the XCUITest test runner', + type: 'boolean', + default: false + }), -module.exports.command = 'clean-framework-cache'; -module.exports.desc = "Deletes all Detox cached frameworks from ~/Library/Detox. Cached framework can be rebuilt using the 'build-framework-cache' command. (macOS only)"; - -module.exports.handler = async function cleanFrameworkCache() { - if (os.platform() === 'darwin') { - const frameworkPath = path.join(os.homedir(), '/Library/Detox'); - detox.log.info(`Removing framework binaries from ${frameworkPath}`); - await fs.remove(frameworkPath); - } else { - detox.log.info(`The command is supported only on macOS, skipping the execution.`); + handler: async function(argv) { + await clean(argv.detox, argv.xcuitest); } }; diff --git a/detox/local-cli/rebuild-framework-cache.js b/detox/local-cli/rebuild-framework-cache.js index a150041a5e..7e83d2fd40 100644 --- a/detox/local-cli/rebuild-framework-cache.js +++ b/detox/local-cli/rebuild-framework-cache.js @@ -1,21 +1,25 @@ -const cp = require('child_process'); -const os = require('os'); -const path = require('path'); +const { build, clean } = require('./utils/frameworkUtils'); -const fs = require('fs-extra'); +module.exports = { + command: 'rebuild-framework-cache', + desc: 'Rebuilds cached versions of the Detox framework and XCUITest-runner in ~/Library/Detox. ' + + 'Use the `--detox` and `--xcuitest` flags to selectively rebuild the framework components. ' + + 'By default, both the injected Detox library and the XCUITest test runner are rebuilt. (MacOS only)', -const log = require('../src/utils/logger').child({ cat: 'cli' }); + builder: yargs => yargs + .option('detox', { + describe: 'Rebuild only the injected Detox library', + type: 'boolean', + default: false + }) + .option('xcuitest', { + describe: 'Rebuild only the XCUITest test runner', + type: 'boolean', + default: false + }), -module.exports.command = 'rebuild-framework-cache'; -module.exports.desc = 'Rebuilds a cached Detox framework for the current environment in ~/Library/Detox. The cached framework is unique for each combination of Xcode and Detox version. (macOS only)'; - -module.exports.handler = async function buildFrameworkCache() { - if (os.platform() === 'darwin') { - const frameworkPath = path.join(os.homedir(), '/Library/Detox'); - log.info(`Removing framework binaries from ${frameworkPath}`); - await fs.remove(frameworkPath); - cp.execSync(path.join(__dirname, '../scripts/build_framework.ios.sh'), { stdio: 'inherit' }); - } else { - log.info(`The command is supported only on macOS, skipping the execution.`); + handler: async function(argv) { + await clean(argv.detox, argv.xcuitest); + await build(argv.detox, argv.xcuitest); } }; diff --git a/detox/local-cli/utils/frameworkUtils.js b/detox/local-cli/utils/frameworkUtils.js new file mode 100644 index 0000000000..f8b6786830 --- /dev/null +++ b/detox/local-cli/utils/frameworkUtils.js @@ -0,0 +1,77 @@ +const os = require('os'); +const path = require('path'); + +const { spawn } = require('child-process-promise'); +const fs = require('fs-extra'); + +const detox = require('../../internals'); +const { getFrameworkDirPath, getXCUITestRunnerDirPath } = require('../../src/utils/environment'); + + +const frameworkBuildScript = '../../scripts/build_local_framework.ios.sh'; +const xcuitestBuildScript = '../../scripts/build_local_xcuitest.ios.sh'; + +function shouldSkipExecution() { + if (os.platform() !== 'darwin') { + detox.log.info('The command is supported only on macOS, skipping the execution.'); + return true; + } + + return false; +} + +async function execBuildScript(targetPath, scriptPath, descriptor) { + detox.log.info(`Building ${descriptor} cache at ${targetPath}..`); + + const scriptFullPath = path.join(__dirname, scriptPath); + + try { + await spawn(scriptFullPath, [], { stdio: 'inherit' }); + } catch (error) { + detox.log.error(`Error while building ${descriptor}:\n${error}`); + throw error; + } +} + +async function removeTarget(targetPath, descriptor) { + detox.log.info(`Cleaning ${descriptor} cache at ${targetPath}..`); + await fs.remove(targetPath); + detox.log.info(`Done\n`); +} + +async function build(framework, xcuitest) { + if (shouldSkipExecution()) { + return; + } + + const shouldBuildBoth = !framework && !xcuitest; + + if (framework || shouldBuildBoth) { + await execBuildScript(getFrameworkDirPath, frameworkBuildScript, 'Detox framework'); + } + + if (xcuitest || shouldBuildBoth) { + await execBuildScript(getXCUITestRunnerDirPath, xcuitestBuildScript, 'XCUITest runner'); + } +} + +async function clean(framework, xcuitest) { + if (shouldSkipExecution()) { + return; + } + + const shouldCleanBoth = !framework && !xcuitest; + + if (framework || shouldCleanBoth) { + await removeTarget(getXCUITestRunnerDirPath, 'Detox framework'); + } + + if (xcuitest || shouldCleanBoth) { + await removeTarget(getXCUITestRunnerDirPath, 'XCUITest runner'); + } +} + +module.exports = { + build, + clean +}; diff --git a/detox/scripts/build_framework.ios.sh b/detox/scripts/build_framework.ios.sh index 77cdcca5e4..5a658cb0ca 100755 --- a/detox/scripts/build_framework.ios.sh +++ b/detox/scripts/build_framework.ios.sh @@ -1,62 +1,28 @@ #!/bin/bash -e -# Ensure Xcode is installed or print a warning message and return. -xcodebuild -version &>/dev/null || { echo "WARNING: Xcode is not installed on this machine. Skipping iOS framework build phase"; exit 0; } - -detoxRootPath="$(dirname "$(dirname "$0")")" -detoxVersion=`node -p "require('${detoxRootPath}/package.json').version"` - -sha1=`(echo "${detoxVersion}" && xcodebuild -version) | shasum | awk '{print $1}' #"${2}"` -detoxFrameworkDirPath="$HOME/Library/Detox/ios/${sha1}" -detoxFrameworkPath="${detoxFrameworkDirPath}/Detox.framework" - - -function prepareAndBuildFramework () { - if [ -d "$detoxRootPath"/ios ]; then - detoxSourcePath="${detoxRootPath}"/ios - echo "Dev mode, building from ${detoxSourcePath}" - buildFramework "${detoxSourcePath}" - else - extractFramework - fi -} - -function extractFramework () { - echo "Extracting Detox framework..." - mkdir -p "${detoxFrameworkDirPath}" - tar -xjf "${detoxRootPath}"/Detox-ios.tbz -C "${detoxFrameworkDirPath}" -} - -function buildFramework () { - detoxSourcePath="${1}" - echo "Building Detox.framework from ${detoxSourcePath} into ${detoxFrameworkDirPath}" - mkdir -p "${detoxFrameworkDirPath}" - logPath="${detoxFrameworkDirPath}"/detox_ios.log - echo "Build log: ${logPath}" - echo -n "" > "${logPath}" - "${detoxRootPath}"/scripts/build_universal_framework.sh "${detoxSourcePath}"/Detox.xcodeproj "${detoxFrameworkDirPath}" &> "${logPath}" || { - echo -e "#################################\nError building Detox.framework:\n----------------------------------\n" - cat "${logPath}" - echo "#################################" - exit 1 - } -} - -function main () { - if [ -d "${detoxFrameworkDirPath}" ]; then - if [ ! -d "${detoxFrameworkPath}" ]; then - echo "${detoxFrameworkDirPath} was found, but could not find Detox.framework inside it. This means that the Detox framework build process was interrupted. - deleting ${detoxFrameworkDirPath} and trying to rebuild." - rm -rf "${detoxFrameworkDirPath}" - prepareAndBuildFramework - else - echo "Detox.framework exists, skipping..." - fi - else - prepareAndBuildFramework - fi - - echo "Done" -} - -main +PROJECT=$1 +FRAMEWORK_OUTPUT_DIR=$2 +CONFIGURATION=Release +PROJECT_NAME=Detox + +# Make sure the output directory exists + +rm -fr "${FRAMEWORK_OUTPUT_DIR}" +mkdir -p "${FRAMEWORK_OUTPUT_DIR}" + +# Step 0. Xcode version + +USE_NEW_BUILD_SYSTEM="YES" +echo "Using -UseNewBuildSystem=${USE_NEW_BUILD_SYSTEM}" + +# Step 1. Build Device and Simulator versions + +BUILD_SIM=`xcodebuild -project "${PROJECT}" -scheme "Detox" -UseNewBuildSystem=${USE_NEW_BUILD_SYSTEM} -configuration "${CONFIGURATION}" -sdk iphonesimulator -destination "generic/platform=iOS Simulator" build -showBuildSettings | awk -F= '/TARGET_BUILD_DIR/{x=$NF; gsub(/^[ \t]+|[ \t]+$/,"",x); print x}'` + +echo ${BUILD_SIM} + +xcodebuild -project "${PROJECT}" -scheme "Detox" -UseNewBuildSystem=${USE_NEW_BUILD_SYSTEM} -configuration "${CONFIGURATION}" -sdk iphonesimulator -destination "generic/platform=iOS Simulator" build -quiet + +# Step 2. Copy the framework to output folder + +cp -fR "${BUILD_SIM}/${PROJECT_NAME}.framework" "${FRAMEWORK_OUTPUT_DIR}"/ diff --git a/detox/scripts/build_local_framework.ios.sh b/detox/scripts/build_local_framework.ios.sh new file mode 100755 index 0000000000..76658fbc3b --- /dev/null +++ b/detox/scripts/build_local_framework.ios.sh @@ -0,0 +1,62 @@ +#!/bin/bash -e + +# Ensure Xcode is installed or print a warning message and return. +xcodebuild -version &>/dev/null || { echo "WARNING: Xcode is not installed on this machine. Skipping iOS framework build phase"; exit 0; } + +detoxRootPath="$(dirname "$(dirname "$0")")" +detoxVersion=`node -p "require('${detoxRootPath}/package.json').version"` + +sha1=`(echo "${detoxVersion}" && xcodebuild -version) | shasum | awk '{print $1}' #"${2}"` +detoxFrameworkDirPath="$HOME/Library/Detox/ios/framework/${sha1}" +detoxFrameworkPath="${detoxFrameworkDirPath}/Detox.framework" + + +function prepareAndBuildFramework () { + if [ -d "$detoxRootPath"/ios ]; then + detoxSourcePath="${detoxRootPath}"/ios + echo "Dev mode, building from ${detoxSourcePath}" + buildFramework "${detoxSourcePath}" + else + extractFramework + fi +} + +function extractFramework () { + echo "Extracting Detox framework..." + mkdir -p "${detoxFrameworkDirPath}" + tar -xjf "${detoxRootPath}"/Detox-ios-framework.tbz -C "${detoxFrameworkDirPath}" +} + +function buildFramework () { + detoxSourcePath="${1}" + echo "Building Detox.framework from ${detoxSourcePath} into ${detoxFrameworkDirPath}" + mkdir -p "${detoxFrameworkDirPath}" + logPath="${detoxFrameworkDirPath}"/detox_ios.log + echo "Build log: ${logPath}" + echo -n "" > "${logPath}" + "${detoxRootPath}"/scripts/build_framework.ios.sh "${detoxSourcePath}"/Detox.xcodeproj "${detoxFrameworkDirPath}" &> "${logPath}" || { + echo -e "#################################\nError building Detox.framework:\n----------------------------------\n" + cat "${logPath}" + echo "#################################" + exit 1 + } +} + +function main () { + if [ -d "${detoxFrameworkDirPath}" ]; then + if [ ! -d "${detoxFrameworkPath}" ]; then + echo "${detoxFrameworkDirPath} was found, but could not find Detox.framework inside it. This means that the Detox framework build process was interrupted. + deleting ${detoxFrameworkDirPath} and trying to rebuild." + rm -rf "${detoxFrameworkDirPath}" + prepareAndBuildFramework + else + echo "Detox.framework exists, skipping..." + fi + else + prepareAndBuildFramework + fi + + echo "Done" +} + +main diff --git a/detox/scripts/build_local_xcuitest.ios.sh b/detox/scripts/build_local_xcuitest.ios.sh new file mode 100755 index 0000000000..90740c94a8 --- /dev/null +++ b/detox/scripts/build_local_xcuitest.ios.sh @@ -0,0 +1,53 @@ +#!/bin/bash -e + +# Ensure Xcode is installed or print a warning message and return. +xcodebuild -version &>/dev/null || { echo "WARNING: Xcode is not installed on this machine. Skipping iOS xctest runner build phase"; exit 0; } + +detoxRootPath="$(dirname "$(dirname "$0")")" +detoxVersion=`node -p "require('${detoxRootPath}/package.json').version"` + +sha1=`(echo "${detoxVersion}" && xcodebuild -version) | shasum | awk '{print $1}' #"${2}"` +detoxXctestRunnerDirPath="$HOME/Library/Detox/ios/xcuitest-runner/${sha1}" + +function prepareAndBuildXctestRunner () { + if [ -d "$detoxRootPath"/ios ]; then + detoxSourcePath="${detoxRootPath}"/ios + echo "Dev mode, building XCUITest runner from ${detoxSourcePath}" + buildXctestRunner "${detoxSourcePath}" + else + extractXctestRunner + fi +} + +function extractXctestRunner () { + echo "Extracting Detox XCUITest runner..." + mkdir -p "${detoxXctestRunnerDirPath}" + tar -xjf "${detoxRootPath}"/Detox-ios-xcuitest.tbz -C "${detoxXctestRunnerDirPath}" +} + +function buildXctestRunner () { + detoxSourcePath="${1}" + echo "Building XCUITest runner from ${detoxSourcePath} into ${detoxXctestRunnerDirPath}" + mkdir -p "${detoxXctestRunnerDirPath}" + logPath="${detoxXctestRunnerDirPath}"/detox_ios_xcuitest.log + echo "Build log: ${logPath}" + echo -n "" > "${logPath}" + "${detoxRootPath}"/scripts/build_xcuitest.ios.sh "${detoxSourcePath}"/DetoxXCUITestRunner/DetoxXCUITestRunner.xcodeproj "${detoxXctestRunnerDirPath}" &> "${logPath}" || { + echo -e "#################################\nError building DetoxXCUITestRunner.xctestrun:\n----------------------------------\n" + cat "${logPath}" + echo "#################################" + exit 1 + } +} + +function main () { + if [ ! -d "${detoxXctestRunnerDirPath}" ]; then + prepareAndBuildXctestRunner + else + echo "XCUITest-runner exists, skipping..." + fi + + echo "Done" +} + +main diff --git a/detox/scripts/build_universal_framework.sh b/detox/scripts/build_universal_framework.sh deleted file mode 100755 index 9b53799877..0000000000 --- a/detox/scripts/build_universal_framework.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash -e - -SCRIPTPATH="$( cd "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" - -XCODEVERSION=$(xcodebuild -version | grep -oEi "([0-9]*(\.[0-9]*)+)") -if [ "${XCODEVERSION}" == "`echo -e "${XCODEVERSION}\n12.0" | sort --version-sort -r | head -n1`" ]; then - echo "Xcode 12 and above; using modern script for building the framework to support Apple Silicon" - FRAMEWORK_SCRIPT="build_universal_framework_modern.sh" -else - echo "Xcode 11 and below; using legacy script for building" - FRAMEWORK_SCRIPT="build_universal_framework_legacy.sh" -fi - -"${SCRIPTPATH}/${FRAMEWORK_SCRIPT}" "$@" \ No newline at end of file diff --git a/detox/scripts/build_universal_framework_legacy.sh b/detox/scripts/build_universal_framework_legacy.sh deleted file mode 100755 index b410c673a9..0000000000 --- a/detox/scripts/build_universal_framework_legacy.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/bin/bash -e - -PROJECT=$1 -OUTPUT_DIR=$2 -CONFIGURATION=Release -PROJECT_NAME=Detox - -set -e - -function remove_arch() { - lipo -create "${1}" "${2}" -output "${3}" -} - -# Make sure the output directory exists - -mkdir -p "${OUTPUT_DIR}" -rm -fr "${OUTPUT_DIR}/${PROJECT_NAME}.framework" - -TEMP_DIR=$(mktemp -d "$TMPDIR"DetoxBuild.XXXX) -echo TEMP_DIR = "${TEMP_DIR}" - -# Step 0. Xcode version - -XCODEVERSION=$(xcodebuild -version | grep -oEi "([0-9]*(\.[0-9]*)+)") -echo "Xcode ${XCODEVERSION}" -USE_NEW_BUILD_SYSTEM="YES" -if [ "${XCODEVERSION}" != "`echo -e "${XCODEVERSION}\n11.0" | sort --version-sort -r | head -n1`" ]; then - USE_NEW_BUILD_SYSTEM="NO" -fi -echo "Using -UseNewBuildSystem=${USE_NEW_BUILD_SYSTEM}" - -# Step 1. Build Device and Simulator versions - -BUILD_IOS=`xcodebuild -project "${PROJECT}" -UseNewBuildSystem=${USE_NEW_BUILD_SYSTEM} -scheme Detox -configuration "${CONFIGURATION}" -arch arm64 -sdk iphoneos ONLY_ACTIVE_ARCH=NO VALID_ARCHS=arm64 -showBuildSettings | awk -F= '/TARGET_BUILD_DIR/{x=$NF; gsub(/^[ \t]+|[ \t]+$/,"",x); print x}'` -BUILD_SIM=`xcodebuild -project "${PROJECT}" -UseNewBuildSystem=${USE_NEW_BUILD_SYSTEM} -scheme Detox -configuration "${CONFIGURATION}" -arch x86_64 -sdk iphonesimulator ONLY_ACTIVE_ARCH=NO VALID_ARCHS=x86_64 -showBuildSettings | awk -F= '/TARGET_BUILD_DIR/{x=$NF; gsub(/^[ \t]+|[ \t]+$/,"",x); print x}'` - -echo ${BUILD_IOS} -echo ${BUILD_SIM} - -xcodebuild -project "${PROJECT}" -UseNewBuildSystem=${USE_NEW_BUILD_SYSTEM} -scheme Detox -configuration "${CONFIGURATION}" -arch arm64 -sdk iphoneos ONLY_ACTIVE_ARCH=NO clean build VALID_ARCHS=arm64 -quiet -xcodebuild -project "${PROJECT}" -UseNewBuildSystem=${USE_NEW_BUILD_SYSTEM} -scheme Detox -configuration "${CONFIGURATION}" -arch x86_64 -sdk iphonesimulator ONLY_ACTIVE_ARCH=NO build VALID_ARCHS=x86_64 -quiet - -# Step 2. Copy the framework structure (from iphoneos build) to the universal folder - -cp -fR "${BUILD_IOS}/${PROJECT_NAME}.framework" "${TEMP_DIR}/" - -# Step 3. Copy Swift modules from iphonesimulator build (if it exists) to the copied framework directory - -SIMULATOR_SWIFT_MODULES_DIR="${BUILD_SIM}/${PROJECT_NAME}.framework/Modules/${PROJECT_NAME}.swiftmodule/." -if [ -d "${SIMULATOR_SWIFT_MODULES_DIR}" ]; then -cp -fR "${SIMULATOR_SWIFT_MODULES_DIR}" "${TEMP_DIR}/${PROJECT_NAME}.framework/Modules/${PROJECT_NAME}.swiftmodule" -fi - -# Step 4. Create universal binary file using lipo and place the combined executable in the copied framework directory - -remove_arch "${BUILD_SIM}/${PROJECT_NAME}.framework/${PROJECT_NAME}" "${BUILD_IOS}/${PROJECT_NAME}.framework/${PROJECT_NAME}" "${TEMP_DIR}/${PROJECT_NAME}.framework/${PROJECT_NAME}" - -# Step 5. Create universal binaries for embedded frameworks - -for SUB_FRAMEWORK in $( ls "${TEMP_DIR}/${PROJECT_NAME}.framework/Frameworks" ); do -if [ -d "${TEMP_DIR}/${PROJECT_NAME}.framework/Frameworks/$SUB_FRAMEWORK" ]; then -echo "Processing ${SUB_FRAMEWORK} as a dir" -BINARY_NAME="${SUB_FRAMEWORK%.*}" - -remove_arch "${BUILD_SIM}/${PROJECT_NAME}.framework/Frameworks/${SUB_FRAMEWORK}/${BINARY_NAME}" "${BUILD_IOS}/${PROJECT_NAME}.framework/Frameworks/${SUB_FRAMEWORK}/${BINARY_NAME}" "${TEMP_DIR}/${PROJECT_NAME}.framework/Frameworks/${SUB_FRAMEWORK}/${BINARY_NAME}" - -else -echo "Processing ${SUB_FRAMEWORK} as a file" - -remove_arch "${BUILD_SIM}/${PROJECT_NAME}.framework/Frameworks/${SUB_FRAMEWORK}" "${BUILD_IOS}/${PROJECT_NAME}.framework/Frameworks/${SUB_FRAMEWORK}" "${TEMP_DIR}/${PROJECT_NAME}.framework/Frameworks/${SUB_FRAMEWORK}" - -fi -done - -mv "${TEMP_DIR}/${PROJECT_NAME}.framework" "${OUTPUT_DIR}"/ -rm -fr "${TEMP_DIR}" diff --git a/detox/scripts/build_universal_framework_modern.sh b/detox/scripts/build_universal_framework_modern.sh deleted file mode 100755 index 764daf2559..0000000000 --- a/detox/scripts/build_universal_framework_modern.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/bash -e - -PROJECT=$1 -OUTPUT_DIR=$2 -CONFIGURATION=Release -PROJECT_NAME=Detox - -# Make sure the output directory exists - -mkdir -p "${OUTPUT_DIR}" -rm -fr "${OUTPUT_DIR}/${PROJECT_NAME}.framework" - -# Step 0. Xcode version - -USE_NEW_BUILD_SYSTEM="YES" -echo "Using -UseNewBuildSystem=${USE_NEW_BUILD_SYSTEM}" - -# Step 1. Build Device and Simulator versions - -BUILD_SIM=`xcodebuild -project "${PROJECT}" -scheme "Detox" -UseNewBuildSystem=${USE_NEW_BUILD_SYSTEM} -configuration "${CONFIGURATION}" -sdk iphonesimulator -destination "generic/platform=iOS Simulator" build -showBuildSettings | awk -F= '/TARGET_BUILD_DIR/{x=$NF; gsub(/^[ \t]+|[ \t]+$/,"",x); print x}'` - -echo ${BUILD_SIM} - -xcodebuild -project "${PROJECT}" -scheme "Detox" -UseNewBuildSystem=${USE_NEW_BUILD_SYSTEM} -configuration "${CONFIGURATION}" -sdk iphonesimulator -destination "generic/platform=iOS Simulator" build -quiet - -# Step 2. Copy the framework to output folder - -cp -fR "${BUILD_SIM}/${PROJECT_NAME}.framework" "${OUTPUT_DIR}"/ \ No newline at end of file diff --git a/detox/scripts/build_xcuitest.ios.sh b/detox/scripts/build_xcuitest.ios.sh new file mode 100755 index 0000000000..f5431395a4 --- /dev/null +++ b/detox/scripts/build_xcuitest.ios.sh @@ -0,0 +1,18 @@ +#!/bin/bash -e + +XCODEPROJ=$1 +XCUITEST_OUTPUT_DIR=$2 +CONFIGURATION=Release +PROJECT_NAME=DetoxXCUITestRunner + +# Clean up the output directory + +rm -fr "${XCUITEST_OUTPUT_DIR}" + +# Make sure the output directory exists + +mkdir -p "${XCUITEST_OUTPUT_DIR}" + +# Build Simulator version + +xcodebuild -project "${XCODEPROJ}" -scheme "${PROJECT_NAME}" -UseNewBuildSystem="YES" -configuration "${CONFIGURATION}" -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -derivedDataPath "${XCUITEST_OUTPUT_DIR}" build-for-testing -quiet diff --git a/detox/scripts/pack_ios.sh b/detox/scripts/pack_ios.sh index 9fe8cfe1d4..83ce31dd14 100755 --- a/detox/scripts/pack_ios.sh +++ b/detox/scripts/pack_ios.sh @@ -1,23 +1,32 @@ #!/bin/bash -e rm -rf Detox-ios-src.tbz -rm -rf Detox-ios.tbz +rm -rf Detox-ios-framework.tbz +rm -rf Detox-ios-xcuitest.tbz rm -rf build_temp find ./ios -name Build -type d -exec rm -rf {} \; -#Package sources +# Package sources pushd . &> /dev/null cd ios tar --exclude-from=.tbzignore -cjf ../Detox-ios-src.tbz . popd &> /dev/null -#Package prebuilt framework +# Package prebuilt framework mkdir build_temp -scripts/build_universal_framework.sh "ios/Detox.xcodeproj" "build_temp" &> build_temp/detox_ios.log +scripts/build_framework.ios.sh "ios/Detox.xcodeproj" "build_temp" &> build_temp/detox_ios.log pushd . &> /dev/null cd build_temp -tar --exclude-from=../ios/.tbzignore -cjf ../Detox-ios.tbz . +tar --exclude-from=../ios/.tbzignore -cjf ../Detox-ios-framework.tbz . popd &> /dev/null +# Package prebuilt xcuitest runner +scripts/build_xcuitest.ios.sh "ios/DetoxXCUITestRunner/DetoxXCUITestRunner.xcworkspace" "build_temp" &> build_temp/detox_ios_xcuitest.log +pushd . &> /dev/null +cd build_temp +tar --exclude-from=../ios/.tbzignore -cjf ../Detox-ios-xcuitest.tbz . +popd &> /dev/null + +# Cleanup rm -fr build_temp diff --git a/detox/scripts/postinstall.js b/detox/scripts/postinstall.js index 83e1b65872..b03fbedd51 100755 --- a/detox/scripts/postinstall.js +++ b/detox/scripts/postinstall.js @@ -1,8 +1,15 @@ +const { platform, env } = process; + const { setGradleVersionByRNVersion } = require('./updateGradle'); -if (process.platform === 'darwin' && !process.env.DETOX_DISABLE_POSTINSTALL) { - require('child_process').execFileSync(`${__dirname}/build_framework.ios.sh`, { - stdio: 'inherit' - }); +const isDarwin = platform === 'darwin'; +const shouldInstallDetox = !env.DETOX_DISABLE_POSTINSTALL; + +if (isDarwin && shouldInstallDetox) { + const execFileSync = require('child_process').execFileSync; + + execFileSync(`${__dirname}/build_local_framework.ios.sh`, { stdio: 'inherit' }); + execFileSync(`${__dirname}/build_local_xcuitest.ios.sh`, { stdio: 'inherit' }); } + setGradleVersionByRNVersion(); diff --git a/detox/src/android/AndroidExpect.js b/detox/src/android/AndroidExpect.js index a2bb4df410..93a224a40c 100644 --- a/detox/src/android/AndroidExpect.js +++ b/detox/src/android/AndroidExpect.js @@ -21,6 +21,7 @@ class AndroidExpect { this.waitFor = this.waitFor.bind(this); this.web = this.web.bind(this); this.web.element = (...args) => this.web().element(...args); + this.system = { element: (...args) => this.systemElement(...args) }; } element(matcher) { @@ -45,6 +46,10 @@ class AndroidExpect { throw new DetoxRuntimeError(`web() argument is invalid, expected a native matcher, but got ${typeof element}`); } + systemElement(_matcher) { + throw new DetoxRuntimeError('System interactions are not supported on Android, use UiDevice APIs directly instead'); + } + expect(element) { if (element instanceof WebElement) return new WebExpectElement(this._invocationManager, element); if (element instanceof NativeElement) return new NativeExpectElement(this._invocationManager, element); diff --git a/detox/src/android/AndroidExpect.test.js b/detox/src/android/AndroidExpect.test.js index 059d27d276..bf4e7d6d75 100644 --- a/detox/src/android/AndroidExpect.test.js +++ b/detox/src/android/AndroidExpect.test.js @@ -1,7 +1,5 @@ // @ts-nocheck -const jestExpect = require('expect').default; // eslint-disable-line - describe('AndroidExpect', () => { let e; @@ -122,34 +120,34 @@ describe('AndroidExpect', () => { const stubMatcher = e.element(e.by.label('test')); const expectedErrorMsg = 'must be an integer between 1 and 100'; - await jestExpect(() => e.expect(stubMatcher).toBeVisible(0)).rejects.toThrow(expectedErrorMsg); - await jestExpect(() => e.expect(stubMatcher).not.toBeVisible(0)).rejects.toThrow(expectedErrorMsg); - await jestExpect(() => e.expect(stubMatcher).toBeVisible(101)).rejects.toThrow(expectedErrorMsg); - await jestExpect(() => e.expect(stubMatcher).not.toBeVisible(101)).rejects.toThrow(expectedErrorMsg); + await expect(() => e.expect(stubMatcher).toBeVisible(0)).rejects.toThrow(expectedErrorMsg); + await expect(() => e.expect(stubMatcher).not.toBeVisible(0)).rejects.toThrow(expectedErrorMsg); + await expect(() => e.expect(stubMatcher).toBeVisible(101)).rejects.toThrow(expectedErrorMsg); + await expect(() => e.expect(stubMatcher).not.toBeVisible(101)).rejects.toThrow(expectedErrorMsg); - jestExpect(() => e.waitFor(stubMatcher).toBeVisible(0)).toThrow(expectedErrorMsg); - jestExpect(() => e.waitFor(stubMatcher).toBeVisible(101)).toThrow(expectedErrorMsg); + expect(() => e.waitFor(stubMatcher).toBeVisible(0)).toThrow(expectedErrorMsg); + expect(() => e.waitFor(stubMatcher).toBeVisible(101)).toThrow(expectedErrorMsg); }); it(`expect with wrong parameters should throw`, async () => { - jestExpect(() => e.expect('notAnElement')).toThrow(); - jestExpect(() => e.expect(e.element('notAMatcher'))).toThrow(); + expect(() => e.expect('notAnElement')).toThrow(); + expect(() => e.expect(e.element('notAMatcher'))).toThrow(); }); it(`matchers with wrong parameters should throw`, async () => { - jestExpect(() => e.by.label(5)).toThrow(); - jestExpect(() => e.by.accessibilityLabel(5)).toThrow(); - jestExpect(() => e.by.id(5)).toThrow(); - jestExpect(() => e.by.type(0)).toThrow(); - jestExpect(() => e.by.traits(1)).toThrow(); - jestExpect(() => e.by.value(0)).toThrow(); - jestExpect(() => e.by.text(0)).toThrow(); + expect(() => e.by.label(5)).toThrow(); + expect(() => e.by.accessibilityLabel(5)).toThrow(); + expect(() => e.by.id(5)).toThrow(); + expect(() => e.by.type(0)).toThrow(); + expect(() => e.by.traits(1)).toThrow(); + expect(() => e.by.value(0)).toThrow(); + expect(() => e.by.text(0)).toThrow(); const expectedErrorMsg = 'Expected a matcher, got: \'notAMatcher\''; - jestExpect(() => e.element(e.by.id('test').withAncestor('notAMatcher'))).toThrow(expectedErrorMsg); - jestExpect(() => e.element(e.by.id('test').withDescendant('notAMatcher'))).toThrow(expectedErrorMsg); - jestExpect(() => e.element(e.by.id('test').and('notAMatcher'))).toThrow(expectedErrorMsg); - jestExpect(() => e.element(e.by.id('test').or('notAMatcher'))).toThrow(expectedErrorMsg); + expect(() => e.element(e.by.id('test').withAncestor('notAMatcher'))).toThrow(expectedErrorMsg); + expect(() => e.element(e.by.id('test').withDescendant('notAMatcher'))).toThrow(expectedErrorMsg); + expect(() => e.element(e.by.id('test').and('notAMatcher'))).toThrow(expectedErrorMsg); + expect(() => e.element(e.by.id('test').or('notAMatcher'))).toThrow(expectedErrorMsg); }); it(`waitFor (element)`, async () => { @@ -189,15 +187,15 @@ describe('AndroidExpect', () => { }); it(`waitFor (element) with wrong parameters should throw`, async () => { - jestExpect(() => e.waitFor('notAnElement')).toThrow(); - jestExpect(() => e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement('notAnElement')).toThrow(); + expect(() => e.waitFor('notAnElement')).toThrow(); + expect(() => e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement('notAnElement')).toThrow(); - await jestExpect(() => e.waitFor(e.element(e.by.id('id'))).toExist().withTimeout('notANumber')).rejects.toThrow(); - await jestExpect(() => e.waitFor(e.element(e.by.id('id'))).toExist().withTimeout(-1)).rejects.toThrow(); + await expect(() => e.waitFor(e.element(e.by.id('id'))).toExist().withTimeout('notANumber')).rejects.toThrow(); + await expect(() => e.waitFor(e.element(e.by.id('id'))).toExist().withTimeout(-1)).rejects.toThrow(); }); it(`waitFor (element) with non-elements should throw`, async () => { - jestExpect(() => e.waitFor('notAnElement')).toThrow(); + expect(() => e.waitFor('notAnElement')).toThrow(); }); it('toHaveSliderPosition', async () => { @@ -221,13 +219,13 @@ describe('AndroidExpect', () => { it('should not tap and long-press given bad args', async () => { await [null, undefined, 0, -1, 'NaN'].forEach(item => { - jestExpect(() => e.element(e.by.id('UniqueId819')).multiTap(item)).rejects.toThrow(); + expect(() => e.element(e.by.id('UniqueId819')).multiTap(item)).rejects.toThrow(); }); - await jestExpect(() => e.element(e.by.label('Tap Me')).longPress('NaN')).rejects.toThrow(); - await jestExpect(() => e.element(e.by.label('Tap Me')).longPress('NaN', 1000)).rejects.toThrow(); - await jestExpect(() => e.element(e.by.label('Tap Me')).longPress({ x: 'NaN', y: 10 }, 1000)).rejects.toThrow(); - await jestExpect(() => e.element(e.by.label('Tap Me')).longPress({ x: 10, y: 'NaN' }, 1000)).rejects.toThrow(); + await expect(() => e.element(e.by.label('Tap Me')).longPress('NaN')).rejects.toThrow(); + await expect(() => e.element(e.by.label('Tap Me')).longPress('NaN', 1000)).rejects.toThrow(); + await expect(() => e.element(e.by.label('Tap Me')).longPress({ x: 'NaN', y: 10 }, 1000)).rejects.toThrow(); + await expect(() => e.element(e.by.label('Tap Me')).longPress({ x: 10, y: 'NaN' }, 1000)).rejects.toThrow(); }); it('should press special keys', async () => { @@ -242,8 +240,8 @@ describe('AndroidExpect', () => { }); it('should not edit text given bad args', async () => { - await jestExpect(() => e.element(e.by.id('UniqueId937')).typeText(0)).rejects.toThrow(); - await jestExpect(() => e.element(e.by.id('UniqueId005')).replaceText(3)).rejects.toThrow(); + await expect(() => e.element(e.by.id('UniqueId937')).typeText(0)).rejects.toThrow(); + await expect(() => e.element(e.by.id('UniqueId005')).replaceText(3)).rejects.toThrow(); }); it('should scroll', async () => { @@ -260,11 +258,11 @@ describe('AndroidExpect', () => { }); it('should not scroll given bad args', async () => { - await jestExpect(() => e.element(e.by.id('ScrollView161')).scroll('NaN', 'down')).rejects.toThrow(); - await jestExpect(() => e.element(e.by.id('ScrollView161')).scroll(100, 'noDirection')).rejects.toThrow(); - await jestExpect(() => e.element(e.by.id('ScrollView161')).scroll(100, 0)).rejects.toThrow(); - await jestExpect(() => e.element(e.by.id('ScrollView161')).scrollTo(0)).rejects.toThrow(); - await jestExpect(() => e.element(e.by.id('ScrollView161')).scrollTo('noDirection')).rejects.toThrow(); + await expect(() => e.element(e.by.id('ScrollView161')).scroll('NaN', 'down')).rejects.toThrow(); + await expect(() => e.element(e.by.id('ScrollView161')).scroll(100, 'noDirection')).rejects.toThrow(); + await expect(() => e.element(e.by.id('ScrollView161')).scroll(100, 0)).rejects.toThrow(); + await expect(() => e.element(e.by.id('ScrollView161')).scrollTo(0)).rejects.toThrow(); + await expect(() => e.element(e.by.id('ScrollView161')).scrollTo('noDirection')).rejects.toThrow(); }); it('should setDatePickerDate', async () => { @@ -274,8 +272,8 @@ describe('AndroidExpect', () => { }); it('should not setDatePickerDate given bad args', async () => { - await jestExpect(() => e.element(e.by.type('android.widget.DatePicker')).setDatePickerDate('2019-02-06T05:10:00-08:00')).rejects.toThrow(); - await jestExpect(() => e.element(e.by.type('android.widget.DatePicker')).setDatePickerDate(2019, 'ISO8601')).rejects.toThrow(); + await expect(() => e.element(e.by.type('android.widget.DatePicker')).setDatePickerDate('2019-02-06T05:10:00-08:00')).rejects.toThrow(); + await expect(() => e.element(e.by.type('android.widget.DatePicker')).setDatePickerDate(2019, 'ISO8601')).rejects.toThrow(); }); it('should swipe', async () => { @@ -298,11 +296,11 @@ describe('AndroidExpect', () => { }); it('should not swipe given bad args', async () => { - await jestExpect(() => e.element(e.by.id('ScrollView799')).swipe(4, 'fast')).rejects.toThrow(); - await jestExpect(() => e.element(e.by.id('ScrollView799')).swipe('noDirection', 0)).rejects.toThrow(); - await jestExpect(() => e.element(e.by.id('ScrollView799')).swipe('noDirection', 'fast')).rejects.toThrow(); - await jestExpect(() => e.element(e.by.id('ScrollView799')).swipe('down', 'NotFastNorSlow')).rejects.toThrow(); - await jestExpect(() => e.element(e.by.id('ScrollView799')).swipe('down', 'NotFastNorSlow', 0.9)).rejects.toThrow(); + await expect(() => e.element(e.by.id('ScrollView799')).swipe(4, 'fast')).rejects.toThrow(); + await expect(() => e.element(e.by.id('ScrollView799')).swipe('noDirection', 0)).rejects.toThrow(); + await expect(() => e.element(e.by.id('ScrollView799')).swipe('noDirection', 'fast')).rejects.toThrow(); + await expect(() => e.element(e.by.id('ScrollView799')).swipe('down', 'NotFastNorSlow')).rejects.toThrow(); + await expect(() => e.element(e.by.id('ScrollView799')).swipe('down', 'NotFastNorSlow', 0.9)).rejects.toThrow(); }); it('should allow for index-based element discrepancy resolution', async () => { @@ -310,7 +308,7 @@ describe('AndroidExpect', () => { }); it('should fail to find index-based element given invalid args', async () => { - jestExpect(() => e.element(e.by.id('ScrollView799')).atIndex('NaN')).toThrow(); + expect(() => e.element(e.by.id('ScrollView799')).atIndex('NaN')).toThrow(); }); it('should retrieve attributes', async () => { @@ -404,27 +402,27 @@ describe('AndroidExpect', () => { }); it(`with wrong matcher arguments - should throw`, async () => { - jestExpect(() => e.web(e.by.web.className('webMatcher'))).toThrow(); - jestExpect(() => e.web(e.by.web.cssSelector('webMatcher'))).toThrow(); - jestExpect(() => e.web(e.by.web.id('webMatcher'))).toThrow(); - jestExpect(() => e.web(e.by.web.href('webMatcher'))).toThrow(); - jestExpect(() => e.web(e.by.web.name('webMatcher'))).toThrow(); - jestExpect(() => e.web(e.by.web.hrefContains('webMatcher'))).toThrow(); - jestExpect(() => e.web(e.by.web.tag('webMatcher'))).toThrow(); - jestExpect(() => e.web(e.by.web.xpath('webMatcher'))).toThrow(); + expect(() => e.web(e.by.web.className('webMatcher'))).toThrow(); + expect(() => e.web(e.by.web.cssSelector('webMatcher'))).toThrow(); + expect(() => e.web(e.by.web.id('webMatcher'))).toThrow(); + expect(() => e.web(e.by.web.href('webMatcher'))).toThrow(); + expect(() => e.web(e.by.web.name('webMatcher'))).toThrow(); + expect(() => e.web(e.by.web.hrefContains('webMatcher'))).toThrow(); + expect(() => e.web(e.by.web.tag('webMatcher'))).toThrow(); + expect(() => e.web(e.by.web.xpath('webMatcher'))).toThrow(); }); it('with at-index should throw', async () => { - jestExpect(() => e.web(e.by.id('webview_id')).atIndex(1).element(e.by.web.id('id')).tap()).toThrow(); + expect(() => e.web(e.by.id('webview_id')).atIndex(1).element(e.by.web.id('id')).tap()).toThrow(); }); it(`inner element with wrong matcher should throw`, async () => { - jestExpect(() => e.web.element(e.by.accessibilityLabel('nativeMatcher'))).toThrow(); - jestExpect(() => e.web.element(e.by.id('nativeMatcher'))).toThrow(); - jestExpect(() => e.web.element(e.by.label('nativeMatcher'))).toThrow(); - jestExpect(() => e.web.element(e.by.text('nativeMatcher'))).toThrow(); - jestExpect(() => e.web.element(e.by.traits('nativeMatcher'))).toThrow(); - jestExpect(() => e.web.element(e.by.value('nativeMatcher'))).toThrow(); + expect(() => e.web.element(e.by.accessibilityLabel('nativeMatcher'))).toThrow(); + expect(() => e.web.element(e.by.id('nativeMatcher'))).toThrow(); + expect(() => e.web.element(e.by.label('nativeMatcher'))).toThrow(); + expect(() => e.web.element(e.by.text('nativeMatcher'))).toThrow(); + expect(() => e.web.element(e.by.traits('nativeMatcher'))).toThrow(); + expect(() => e.web.element(e.by.value('nativeMatcher'))).toThrow(); }); }); @@ -665,6 +663,23 @@ describe('AndroidExpect', () => { }); }); }); + + describe('System (unsupported)', () => { + const unsupportedErrorMessage = + /System interactions are not supported on Android, use UiDevice APIs directly instead/; + + it('should throw for system element call', async () => { + await expect(async () => e.system.element('anything')).rejects.toThrow(unsupportedErrorMessage); + }); + + it('should throw for system type matcher', async () => { + await expect(async () => e.by.system.type('type')).rejects.toThrow(unsupportedErrorMessage); + }); + + it('should throw for system label matcher', async () => { + await expect(async () => e.by.system.label('label')).rejects.toThrow(unsupportedErrorMessage); + }); + }); }); class MockExecutor { diff --git a/detox/src/android/matchers/index.js b/detox/src/android/matchers/index.js index 66859d13f4..e5e337b2eb 100644 --- a/detox/src/android/matchers/index.js +++ b/detox/src/android/matchers/index.js @@ -1,3 +1,5 @@ +const { DetoxRuntimeError } = require('../../errors'); + const native = require('./native'); const web = require('./web'); @@ -20,4 +22,9 @@ module.exports = { href: (value) => new web.LinkTextMatcher(value), hrefContains: (value) => new web.PartialLinkTextMatcher(value), }, + + system: { + label: (_value) => { throw new DetoxRuntimeError('System interactions are not supported on Android, use UiDevice APIs directly instead'); }, + type: (_value) => { throw new DetoxRuntimeError('System interactions are not supported on Android, use UiDevice APIs directly instead'); }, + } }; diff --git a/detox/src/ios/XCUITestRunner.js b/detox/src/ios/XCUITestRunner.js new file mode 100644 index 0000000000..102d578858 --- /dev/null +++ b/detox/src/ios/XCUITestRunner.js @@ -0,0 +1,52 @@ +const { exec } = require('child-process-promise'); + +const DetoxRuntimeError = require('../errors/DetoxRuntimeError'); +const environment = require('../utils/environment'); +const log = require('../utils/logger').child({ cat: 'xcuitest-runner' }); + +class XCUITestRunner { + constructor({ simulatorId }) { + this.simulatorId = simulatorId; + } + + async execute(invocationParams) { + log.trace( + { event: 'XCUITEST_RUNNER' }, + 'invocation params: %j, simulator id: %s', invocationParams, this.simulatorId + ); + + const base64InvocationParams = Buffer.from(JSON.stringify(invocationParams)).toString('base64'); + + const runnerPath = await environment.getXCUITestRunnerPath(); + if (!runnerPath) { + throw new DetoxRuntimeError({ + message: 'XCUITest runner path could not be found', + hint: DetoxRuntimeError.reportIssue, + }); + } + + const flags = [ + '-xctestrun', runnerPath, + '-sdk', 'iphonesimulator', + '-destination', `"platform=iOS Simulator,id=${this.simulatorId}"`, + 'test-without-building', + ]; + + try { + return await exec(`TEST_RUNNER_PARAMS="${base64InvocationParams}" xcodebuild ${flags.join(' ')}`); + } catch (e) { + const stdout = e.stdout.toString(); + const innerError = this.findInnerError(stdout); + throw new DetoxRuntimeError(innerError); + } + } + + findInnerError(stdout) { + const match = stdout.match(/DTXError: .*/); + return match ? + match[0].split('DTXError: ')[1] : + `XCUITest runner failed with no error message. Runner stdout: ${stdout}`; + } +} + +module.exports = XCUITestRunner; diff --git a/detox/src/ios/XCUITestRunner.test.js b/detox/src/ios/XCUITestRunner.test.js new file mode 100644 index 0000000000..5aa19a5653 --- /dev/null +++ b/detox/src/ios/XCUITestRunner.test.js @@ -0,0 +1,55 @@ +const XCUITestRunner = require('./XCUITestRunner'); + +jest.mock('child-process-promise', () => { + return { + exec: jest.fn(), + }; +}); + +const { exec } = jest.requireMock('child-process-promise'); +const environment = jest.requireMock('../utils/environment'); + +jest.mock('../utils/environment'); + +describe('XCUITestRunner', () => { + const simulatorId = 'simulator-id'; + const runner = new XCUITestRunner({ simulatorId }); + const invocationParams = { key: 'value' }; + const base64InvocationParams = Buffer.from(JSON.stringify(invocationParams)).toString('base64'); + const runnerPath = '/path/to/xcuitest-runner'; + + beforeEach(() => { + environment.getXCUITestRunnerPath.mockResolvedValue(runnerPath); + exec.mockClear(); + }); + + it('should execute XCUITest runner with given invocation params', async () => { + const command = `TEST_RUNNER_PARAMS="${base64InvocationParams}" xcodebuild -xctestrun ${runnerPath} -sdk iphonesimulator -destination "platform=iOS Simulator,id=${simulatorId}" test-without-building`; + exec.mockResolvedValue({ stdout: 'success' }); + + await runner.execute(invocationParams); + + expect(exec).toHaveBeenCalledWith(command); + }); + + it('should throw error when runner path is not found', async () => { + environment.getXCUITestRunnerPath.mockResolvedValue(null); + + await expect(runner.execute(invocationParams)).rejects.toThrow(/XCUITest runner path could not be found/); + }); + + it('should handle execution errors and throw error with an extracted inner error', async () => { + const errorOutput = 'DTXError: Test failure'; + exec.mockRejectedValue({ stdout: Buffer.from(errorOutput) }); + + await expect(runner.execute(invocationParams)).rejects.toThrow(/Test failure/); + }); + + it('should handle execution errors with no specific error message', async () => { + const errorOutput = 'Unknown error occurred'; + exec.mockRejectedValue({ stdout: Buffer.from(errorOutput) }); + + await expect(runner.execute(invocationParams)).rejects + .toThrow(/XCUITest runner failed with no error message. Runner stdout: Unknown error occurred/); + }); +}); diff --git a/detox/src/ios/expectTwo.js b/detox/src/ios/expectTwo.js index e1ced99c3e..a59b9d2719 100644 --- a/detox/src/ios/expectTwo.js +++ b/detox/src/ios/expectTwo.js @@ -1,13 +1,11 @@ // @ts-nocheck -const assert = require('assert'); const path = require('path'); const fs = require('fs-extra'); const _ = require('lodash'); const tempfile = require('tempfile'); - -const { assertEnum, assertNormalized } = require('../utils/assertArgument'); +const { assertTraceDescription, assertEnum, assertNormalized } = require('../utils/assertArgument'); const { removeMilliseconds } = require('../utils/dateUtils'); const { actionDescription, expectDescription } = require('../utils/invocationTraceDescriptions'); const { isRegExp } = require('../utils/isRegExp'); @@ -15,8 +13,10 @@ const log = require('../utils/logger').child({ cat: 'ws-client, ws' }); const mapLongPressArguments = require('../utils/mapLongPressArguments'); const traceInvocationCall = require('../utils/traceInvocationCall').bind(null, log); +const { systemElement, systemMatcher, systemExpect, isSystemElement } = require('./system'); const { webElement, webMatcher, webExpect, isWebElement } = require('./web'); + const assertDirection = assertEnum(['left', 'right', 'up', 'down']); const assertSpeed = assertEnum(['fast', 'slow']); @@ -27,6 +27,11 @@ class Expect { this.modifiers = []; } + get not() { + this.modifiers.push('not'); + return this; + } + toBeVisible(percent) { if (percent !== undefined && (!Number.isSafeInteger(percent) || percent < 1 || percent > 100)) { throw new Error('`percent` must be an integer between 1 and 100, but got ' @@ -104,11 +109,6 @@ class Expect { return this.toHaveValue(`${Number(value)}`); } - get not() { - this.modifiers.push('not'); - return this; - } - createInvocation(expectation, ...params) { const definedParams = _.without(params, undefined); return { @@ -122,7 +122,7 @@ class Expect { } expect(expectation, traceDescription, ...params) { - assert(traceDescription, `must provide trace description for expectation: \n ${JSON.stringify(expectation)}`); + assertTraceDescription(traceDescription); const invocation = this.createInvocation(expectation, ...params); traceDescription = expectDescription.full(traceDescription, this.modifiers.includes('not')); @@ -374,6 +374,14 @@ class InternalElement extends Element { } class By { + get web() { + return webMatcher(); + } + + get system() { + return systemMatcher(); + } + id(id) { return new Matcher().id(id); } @@ -401,13 +409,18 @@ class By { value(value) { return new Matcher().value(value); } - - get web() { - return webMatcher(); - } } class Matcher { + /** @private */ + static *predicates(matcher) { + if (matcher.predicate.type === 'and') { + yield* matcher.predicate.predicates; + } else { + yield matcher.predicate; + } + } + accessibilityLabel(label) { return this.label(label); } @@ -477,15 +490,6 @@ class Matcher { return result; } - - /** @private */ - static *predicates(matcher) { - if (matcher.predicate.type === 'and') { - yield* matcher.predicate.predicates; - } else { - yield matcher.predicate; - } - } } class WaitFor { @@ -496,6 +500,11 @@ class WaitFor { this._emitter = emitter; } + get not() { + this.expectation.not; + return this; + } + toBeVisible(percent) { this.expectation = this.expectation.toBeVisible(percent); return this; @@ -566,11 +575,6 @@ class WaitFor { return this; } - get not() { - this.expectation.not; - return this; - } - withTimeout(timeout) { if (typeof timeout !== 'number') throw new Error('text should be a number, but got ' + (timeout + (' (' + (typeof timeout + ')')))); if (timeout < 0) throw new Error('timeout must be larger than 0'); @@ -735,6 +739,7 @@ function element(invocationManager, emitter, matcher) { if (!(matcher instanceof Matcher)) { throwMatcherError(matcher); } + return new Element(invocationManager, emitter, matcher); } @@ -742,6 +747,7 @@ function expect(invocationManager, element) { if (!(element instanceof Element)) { throwMatcherError(element); } + return new Expect(invocationManager, element); } @@ -753,8 +759,9 @@ function waitFor(invocationManager, emitter, element) { } class IosExpect { - constructor({ invocationManager, emitter }) { + constructor({ invocationManager, xcuitestRunner, emitter }) { this._invocationManager = invocationManager; + this._xcuitestRunner = xcuitestRunner; this._emitter = emitter; this.element = this.element.bind(this); this.expect = this.expect.bind(this); @@ -762,6 +769,8 @@ class IosExpect { this.by = new By(); this.web = this.web.bind(this); this.web.element = this.web().element; + this.system = this.system.bind(this); + this.system.element = this.system().element; } element(matcher) { @@ -769,6 +778,10 @@ class IosExpect { } expect(element) { + if (isSystemElement(element)) { + return systemExpect(this._xcuitestRunner, element); + } + if (isWebElement(element)) { return webExpect(this._invocationManager, element); } @@ -798,6 +811,14 @@ class IosExpect { } }; } + + system() { + return { + element: systemMatcher => { + return systemElement(this._xcuitestRunner, systemMatcher); + } + }; + } } function _assertValidPoint(point) { diff --git a/detox/src/ios/expectTwo.test.js b/detox/src/ios/expectTwo.test.js index 0e18e3a9be..0ad7229107 100644 --- a/detox/src/ios/expectTwo.test.js +++ b/detox/src/ios/expectTwo.test.js @@ -6,6 +6,7 @@ describe('expectTwo', () => { let e; let emitter; let invocationManager; + let xcuitestRunner; let fs; beforeEach(() => { @@ -17,10 +18,12 @@ describe('expectTwo', () => { const IosExpect = require('./expectTwo'); const AsyncEmitter = jest.genMockFromModule('../utils/AsyncEmitter'); invocationManager = new MockExecutor(); + xcuitestRunner = new MockExecutor(); emitter = new AsyncEmitter(); e = new IosExpect({ invocationManager, + xcuitestRunner, emitter, }); }); @@ -614,16 +617,16 @@ describe('expectTwo', () => { const testCall = await e.waitFor(e.element(e.by.id('createdAndVisibleText'))).toExist().withTimeout(2000); const jsonOutput = { invocation: - { - type: 'expectation', - predicate: { - type: 'id', - value: 'createdAndVisibleText', - isRegex: false, - }, - expectation: 'toExist', - timeout: 2000 - } + { + type: 'expectation', + predicate: { + type: 'id', + value: 'createdAndVisibleText', + isRegex: false, + }, + expectation: 'toExist', + timeout: 2000 + } }; expect(testCall).toDeepEqual(jsonOutput); @@ -633,17 +636,17 @@ describe('expectTwo', () => { const testCall = await e.waitFor(e.element(e.by.text('Item')).atIndex(1)).toExist().withTimeout(2000); const jsonOutput = { invocation: - { - type: 'expectation', - atIndex: 1, - predicate: { - type: 'text', - value: 'Item', - isRegex: false, - }, - expectation: 'toExist', - timeout: 2000 - } + { + type: 'expectation', + atIndex: 1, + predicate: { + type: 'text', + value: 'Item', + isRegex: false, + }, + expectation: 'toExist', + timeout: 2000 + } }; expect(testCall).toDeepEqual(jsonOutput); @@ -754,6 +757,42 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); + describe('system', () => { + it(`should parse system.element(by.system.label('tapMe')).atIndex(1).tap()`, async () => { + const testCall = await e.system.element(e.by.system.label('tapMe')).atIndex(1).tap(); + const jsonOutput = { + invocation: { + type: 'systemAction', + systemAction: 'tap', + systemPredicate: { + type: 'label', + value: 'tapMe' + }, + systemAtIndex: 1 + } + }; + + expect(testCall).toDeepEqual(jsonOutput); + }); + + it(`should parse expect(system.element(by.system.type('button'))).not.toExist()`, async () => { + const testCall = await e.expect(e.system.element(e.by.system.type('button'))).not.toExist(); + const jsonOutput = { + invocation: { + type: 'systemExpectation', + systemExpectation: 'toExist', + systemModifiers: ['not'], + systemPredicate: { + type: 'type', + value: 'button' + } + } + }; + + expect(testCall).toDeepEqual(jsonOutput); + }); + }); + describe('web views', () => { it(`should parse expect(web(by.id('webViewId').element(web(by.label('tapMe')))).toExist()`, async () => { const testCall = await e.expect(e.web(e.by.id('webViewId')).atIndex(1).element(e.by.web.label('tapMe')).atIndex(2)).toExist(); @@ -804,11 +843,11 @@ describe('expectTwo', () => { }); it('should throw when passing non-web-element matcher to element()', async () => { - const expectedErrorMsg = 'is not a Detox web-view matcher'; + const expectedErrorMsg = 'is not a Detox web-view matcher'; - jestExpect(() => e.expect( - e.web(e.by.id('webViewId')).element(e.by.label('tapMe')) - ).toExist()).toThrow(expectedErrorMsg); + jestExpect(() => e.expect( + e.web(e.by.id('webViewId')).element(e.by.label('tapMe')) + ).toExist()).toThrow(expectedErrorMsg); }); it('should throw when not passing matcher to web()', async () => { @@ -1104,7 +1143,7 @@ describe('expectTwo', () => { it('should throw when invocation returns an error', async () => { invocationManager.execute.mockResolvedValueOnce({ - error: 'some error' + error: 'some error' }); await expect(() => e.web.element(e.by.web.id('uniqueId')).getTitle()).rejects.toThrow('some error'); @@ -1112,7 +1151,7 @@ describe('expectTwo', () => { it('should extract return value (`return`) when exists on getter', async () => { invocationManager.execute.mockResolvedValueOnce({ - result: 'some result' + result: 'some result' }); const result = await e.web.element(e.by.web.id('uniqueId')).getTitle(); diff --git a/detox/src/ios/expectTwoApiCoverage.test.js b/detox/src/ios/expectTwoApiCoverage.test.js index 10e32e053d..dcf07714f8 100644 --- a/detox/src/ios/expectTwoApiCoverage.test.js +++ b/detox/src/ios/expectTwoApiCoverage.test.js @@ -7,6 +7,7 @@ describe('expectTwo API Coverage', () => { e = new IosExpect({ invocationManager: new MockExecutor(), + xcuitestRunner: new MockExecutor(), }); }); @@ -98,6 +99,17 @@ describe('expectTwo API Coverage', () => { await expectToThrow(() =>e.waitFor(e.element(e.by.accessibilityLabel('test'))).toBeVisible(0)); await expectToThrow(() =>e.e.waitFor(e.element(e.by.accessibilityLabel('test'))).toBeVisible(120)); }); + + describe('System', () => { + it('should throw for invalid matcher parameters', async () => { + await expectToThrow(() => e.system.element(e.by.system.label(5))); + await expectToThrow(() => e.system.element(e.by.system.type(5))); + }); + + it('should throw for invalid matchers', async () => { + await expectToThrow(() => e.system.element(e.by.value('test'))); + }); + }); }); describe('Expect', () => { @@ -111,10 +123,14 @@ describe('expectTwo API Coverage', () => { await expectToThrow(() => e.expect(e.web.element('notAMatcher'))); await expectToThrow(() => e.expect(e.web.element(e.by.web.id('id'))).toHaveText(0)); }); + + it('should not throw on system assertions', async () => { + await e.expect(e.system.element(e.by.system.label('Tap Me')).atIndex(2)).toExist(); + await e.expect(e.system.element(e.by.system.type('button'))).not.toExist(); + }); }); describe('Actions', () => { - it(`setColumnToValue()`, async () => { await e.element(e.by.id('pickerView')).setColumnToValue(1, '6'); await expectToThrow(() => e.element(e.by.id('pickerView')).setColumnToValue('notAColumn', 1)); @@ -232,6 +248,13 @@ describe('expectTwo API Coverage', () => { await expectToThrow(() => e.element(e.by.id('someId')).performAccessibilityAction()); }); + it('should properly call system interactions', async () => { + await e.system.element(e.by.system.label('Tap Me')).atIndex(2).tap(); + }); + + it('should throw for invalid element-index for system interactions', async () => { + await expectToThrow(() => e.system.element(e.by.system.label('tapMe')).atIndex('NaN')); + }); }); describe('WaitFor', () => { diff --git a/detox/src/ios/system.js b/detox/src/ios/system.js new file mode 100644 index 0000000000..e89fca367d --- /dev/null +++ b/detox/src/ios/system.js @@ -0,0 +1,124 @@ +const { DetoxRuntimeError } = require('../errors'); +const { assertTraceDescription } = require('../utils/assertArgument'); +const { systemActionDescription, expectDescription } = require('../utils/invocationTraceDescriptions'); +const log = require('../utils/logger').child({ cat: 'ws-client, ws' }); +const traceInvocationCall = require('../utils/traceInvocationCall').bind(null, log); + + +class SystemExpect { + constructor(xcuitestRunner, element) { + this._xcuitestRunner = xcuitestRunner; + this.element = element; + this.modifiers = []; + } + + toExist() { + const traceDescription = expectDescription.toExist(); + return this.expect('toExist', traceDescription); + } + + get not() { + this.modifiers.push('not'); + return this; + } + + createInvocation(systemExpectation) { + return { + type: 'systemExpectation', + systemPredicate: this.element.matcher.predicate, + ...(this.element.index !== undefined && { systemAtIndex: this.element.index }), + ...(this.modifiers.length !== 0 && { systemModifiers: this.modifiers }), + systemExpectation + }; + } + + expect(expectation, traceDescription) { + assertTraceDescription(traceDescription); + + const invocation = this.createInvocation(expectation); + traceDescription = expectDescription.full(traceDescription, this.modifiers.includes('not')); + return _executeInvocation(this._xcuitestRunner, invocation, traceDescription); + } +} + +class SystemElement { + constructor(xcuitestRunner, matcher, index) { + this._xcuitestRunner = xcuitestRunner; + this.matcher = matcher; + this.index = index; + } + + atIndex(index) { + if (typeof index !== 'number' || index < 0) throw new DetoxRuntimeError(`index should be an integer, got ${index} (${typeof index})`); + this.index = index; + return this; + } + + tap() { + const traceDescription = systemActionDescription.tap(); + return this.withAction('tap', traceDescription); + } + + withAction(action, traceDescription) { + assertTraceDescription(traceDescription); + + const invocation = { + type: 'systemAction', + systemPredicate: this.matcher.predicate, + ...(this.index !== undefined && { systemAtIndex: this.index }), + systemAction: action + }; + traceDescription = systemActionDescription.full(traceDescription); + return _executeInvocation(this._xcuitestRunner, invocation, traceDescription); + } +} + +class SystemElementMatcher { + label(label) { + if (typeof label !== 'string') throw new DetoxRuntimeError('label should be a string, but got ' + (label + (' (' + typeof label + ')'))); + this.predicate = { type: 'label', value: label.toString() }; + return this; + } + + type(type) { + if (typeof type !== 'string') throw new DetoxRuntimeError('type should be a string, but got ' + (type + (' (' + typeof type + ')'))); + this.predicate = { type: 'type', value: type.toString() }; + return this; + } +} + +function systemMatcher() { + return new SystemElementMatcher(); +} + +function systemElement(xcuitestRunner, matcher) { + if (!(matcher instanceof SystemElementMatcher)) { + throwSystemMatcherError(matcher); + } + + return new SystemElement(xcuitestRunner, matcher); +} + +function throwSystemMatcherError(param) { + const paramDescription = JSON.stringify(param); + throw new DetoxRuntimeError(`${paramDescription} is not a Detox system matcher. More about system matchers here: https://wix.github.io/Detox/docs/api/system`); +} + +function systemExpect(xcuitestRunner, element) { + return new SystemExpect(xcuitestRunner, element); +} + +function _executeInvocation(xcuitestRunner, invocation, traceDescription) { + return traceInvocationCall(traceDescription, invocation, xcuitestRunner.execute(invocation)); +} + +function isSystemElement(element) { + return element instanceof SystemElement; +} + +module.exports = { + systemMatcher, + systemElement, + systemExpect, + isSystemElement +}; diff --git a/detox/src/ios/web.js b/detox/src/ios/web.js index 946951c2cf..349d9cec96 100644 --- a/detox/src/ios/web.js +++ b/detox/src/ios/web.js @@ -1,8 +1,7 @@ -const assert = require('assert'); - const _ = require('lodash'); const { DetoxRuntimeError } = require('../errors'); +const { assertTraceDescription } = require('../utils/assertArgument'); const { webViewActionDescription, expectDescription } = require('../utils/invocationTraceDescriptions'); const log = require('../utils/logger').child({ cat: 'ws-client, ws' }); const traceInvocationCall = require('../utils/traceInvocationCall').bind(null, log); @@ -48,7 +47,7 @@ class WebExpect { } expect(expectation, traceDescription, ...params) { - assert(traceDescription, `must provide trace description for expectation: \n ${JSON.stringify(expectation)}`); + assertTraceDescription(traceDescription); const invocation = this.createInvocation(expectation, ...params); traceDescription = expectDescription.full(traceDescription, this.modifiers.includes('not')); @@ -170,7 +169,7 @@ class WebElement { } withAction(action, traceDescription, ...params) { - assert(traceDescription, `must provide trace description for action: \n ${JSON.stringify(action)}`); + assertTraceDescription(traceDescription); const invocation = { type: 'webAction', diff --git a/detox/src/matchers/factories/index.js b/detox/src/matchers/factories/index.js index a756c9f39e..e2ddcda91c 100644 --- a/detox/src/matchers/factories/index.js +++ b/detox/src/matchers/factories/index.js @@ -12,19 +12,20 @@ class Android extends MatchersFactory { } class Ios extends MatchersFactory { - createMatchers({ invocationManager, eventEmitter }) { + createMatchers({ invocationManager, runtimeDevice, eventEmitter }) { const IosExpect = require('../../ios/expectTwo'); - return new IosExpect({ invocationManager, emitter: eventEmitter }); + const XCUITestRunner = require('../../ios/XCUITestRunner'); + const xcuitestRunner = new XCUITestRunner({ simulatorId: runtimeDevice.id }); + + return new IosExpect({ + invocationManager, + xcuitestRunner, + emitter: eventEmitter + }); } } class External extends MatchersFactory { - static validateModule(module, path) { - if (!module.ExpectClass) { - throw new DetoxRuntimeError(`The custom driver at '${path}' does not export the ExpectClass property`); - } - } - constructor(module, path) { super(); External.validateModule(module, path); @@ -32,6 +33,12 @@ class External extends MatchersFactory { this._module = module; } + static validateModule(module, path) { + if (!module.ExpectClass) { + throw new DetoxRuntimeError(`The custom driver at '${path}' does not export the ExpectClass property`); + } + } + createMatchers(deps) { return new this._module.ExpectClass(deps); } diff --git a/detox/src/utils/__snapshots__/assertArgument.test.js.snap b/detox/src/utils/__snapshots__/assertArgument.test.js.snap index 1d5c300011..70c48a934a 100644 --- a/detox/src/utils/__snapshots__/assertArgument.test.js.snap +++ b/detox/src/utils/__snapshots__/assertArgument.test.js.snap @@ -34,6 +34,12 @@ exports[`assertString should throw for 123 1`] = `"invalidString should be a str exports[`assertString should throw for undefined 1`] = `"invalidString should be a string, but got undefined (undefined)"`; +exports[`assertTraceDescription should throw for undefined 1`] = ` +"traceDescription expected to be defined, but got undefined +Please report this issue on our GitHub tracker: +https://github.com/wix/Detox/issues" +`; + exports[`assertUndefined should throw for "str" 1`] = `"0 expected to be undefined, but got s (string)"`; exports[`assertUndefined should throw for {"key":"val"} 1`] = `"key expected to be undefined, but got val (string)"`; diff --git a/detox/src/utils/assertArgument.js b/detox/src/utils/assertArgument.js index 9a618d264a..187d7c9310 100644 --- a/detox/src/utils/assertArgument.js +++ b/detox/src/utils/assertArgument.js @@ -1,4 +1,4 @@ -const DetoxRuntimeError = require('../errors/DetoxRuntimeError'); +const { DetoxInternalError, DetoxRuntimeError } = require('../errors'); function firstEntry(obj) { return Object.entries(obj)[0]; @@ -61,6 +61,14 @@ function assertUndefined(arg) { throw new DetoxRuntimeError(`${key} expected to be undefined, but got ${value} (${typeof value})`); } +function assertTraceDescription(arg) { + if (arg !== undefined) { + return true; + } + + throw new DetoxInternalError(`traceDescription expected to be defined, but got undefined`); +} + module.exports = { assertEnum, assertNormalized, @@ -68,5 +76,6 @@ module.exports = { assertString, assertDuration, assertPoint, - assertUndefined + assertUndefined, + assertTraceDescription }; diff --git a/detox/src/utils/assertArgument.test.js b/detox/src/utils/assertArgument.test.js index 749312c838..1e83379285 100644 --- a/detox/src/utils/assertArgument.test.js +++ b/detox/src/utils/assertArgument.test.js @@ -125,3 +125,19 @@ describe('assertUndefined', () => { expect(() => assertUndefined(definedValue)).toThrowErrorMatchingSnapshot(); }); }); + +describe('assertTraceDescription', () => { + const { assertTraceDescription } = assertions; + + it.each([ + 'str', + 1, + { key: 'val' } + ])('should return true for defined %j', (definedValue) => { + expect(assertTraceDescription(definedValue)).toBe(true); + }); + + it('should throw for undefined', () => { + expect(() => assertTraceDescription(undefined)).toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/detox/src/utils/environment.js b/detox/src/utils/environment.js index 5bbf942cf9..aa53771efb 100644 --- a/detox/src/utils/environment.js +++ b/detox/src/utils/environment.js @@ -1,3 +1,4 @@ +const crypto = require('crypto'); const fs = require('fs'); const os = require('os'); const path = require('path'); @@ -171,24 +172,40 @@ function throwMissingGmsaasError() { throw new DetoxRuntimeError(`Failed to locate Genymotion's gmsaas executable. Please add it to your $PATH variable!\nPATH is currently set to: ${process.env.PATH}`); } -function getDetoxVersion() { +const getDetoxVersion = _.once(() => { return require(path.join(__dirname, '../../package.json')).version; -} +}); -let _iosFrameworkPath; -async function getFrameworkPath() { - if (!_iosFrameworkPath) { - _iosFrameworkPath = _doGetFrameworkPath(); - } +const getBuildFolderName = _.once(async () => { + const detoxVersion = getDetoxVersion(); + const xcodeVersion = await exec('xcodebuild -version').then(result => result.stdout.trim()); - return _iosFrameworkPath; -} + return crypto.createHash('sha1') + .update(`${detoxVersion}\n${xcodeVersion}\n`) + .digest('hex'); +}); -async function _doGetFrameworkPath() { - const detoxVersion = getDetoxVersion(); - const sha1 = (await exec(`(echo "${detoxVersion}" && xcodebuild -version) | shasum | awk '{print $1}'`)).stdout.trim(); - return `${DETOX_LIBRARY_ROOT_PATH}/ios/${sha1}/Detox.framework`; -} +const getFrameworkDirPath = `${DETOX_LIBRARY_ROOT_PATH}/ios/framework`; + +const getFrameworkPath = _.once(async () => { + const buildFolder = await getBuildFolderName(); + return `${getFrameworkDirPath}/${buildFolder}/Detox.framework`; +}); + +const getXCUITestRunnerDirPath = `${DETOX_LIBRARY_ROOT_PATH}/ios/xcuitest-runner`; + +const getXCUITestRunnerPath = _.once(async () => { + const buildFolder = await getBuildFolderName(); + const derivedDataPath = `${getXCUITestRunnerDirPath}/${buildFolder}`; + const xctestrunPath = await exec(`find ${derivedDataPath} -name "*.xctestrun" -print -quit`) + .then(result => result.stdout.trim()); + + if (!xctestrunPath) { + throw new DetoxRuntimeError(`Failed to find .xctestrun file in ${derivedDataPath}`); + } + + return xctestrunPath; +}); function getDetoxLibraryRootPath() { return DETOX_LIBRARY_ROOT_PATH; @@ -219,7 +236,10 @@ module.exports = { getAndroidSdkManagerPath, getGmsaasPath, getDetoxVersion, + getFrameworkDirPath, getFrameworkPath, + getXCUITestRunnerDirPath, + getXCUITestRunnerPath, getAndroidSDKPath, getAndroidEmulatorPath, getDetoxLibraryRootPath, diff --git a/detox/src/utils/invocationTraceDescriptions.js b/detox/src/utils/invocationTraceDescriptions.js index 475ad14c39..ebad2a78e3 100644 --- a/detox/src/utils/invocationTraceDescriptions.js +++ b/detox/src/utils/invocationTraceDescriptions.js @@ -43,6 +43,10 @@ module.exports = { getTitle: () => 'get title', full: (actionDescription) => `perform web view action: ${actionDescription}` }, + systemActionDescription: { + tap: () => `tap`, + full: (actionDescription) => `perform system action: ${actionDescription}` + }, expectDescription: { waitFor: (actionDescription) => `wait for expectation while ${actionDescription}`, waitForWithTimeout: (expectDescription, timeout) => `${expectDescription} with timeout (${timeout} ms)`, diff --git a/detox/test/e2e/36.system.test.js b/detox/test/e2e/36.system.test.js new file mode 100644 index 0000000000..82a0e2c34d --- /dev/null +++ b/detox/test/e2e/36.system.test.js @@ -0,0 +1,79 @@ +const {expectToThrow} = require('./utils/custom-expects'); + +describe('System Dialogs', () => { + describe(':ios: supported', () => { + beforeAll(async () => { + await device.reloadReactNative(); + }); + + describe('request permission dialog', () => { + beforeEach(async () => { + await device.launchApp({ + delete: true, + newInstance: true, + }); + + await element(by.text('System Dialogs')).tap(); + }); + + const permissionStatus = element(by.id('permissionStatus')); + const requestPermissionButton = element(by.id('requestPermissionButton')); + + it('should start with `denied` permission status', async () => { + await expect(permissionStatus).toHaveText('denied'); + }); + + it('should tap on permission request alert button by label ("Allow")', async () => { + await requestPermissionButton.tap(); + + const allowButton = system.element(by.system.label('Allow')); + + await expect(allowButton).toExist(); + await allowButton.tap(); + + await expect(permissionStatus).toHaveText('granted'); + }); + + it('should tap on permission request alert button by type and index ("Deny")', async () => { + await requestPermissionButton.tap(); + + const denyButton = system.element(by.system.type('button')).atIndex(0); + + await expect(denyButton).toExist(); + await denyButton.tap(); + + await expect(permissionStatus).toHaveText('blocked'); + }); + }); + + it('should not find elements that does not exist', async () => { + await expect(system.element(by.system.label('NonExistent'))).not.toExist(); + }); + + it('should raise when trying to match system element that does not exist', async () => { + await expectToThrow(async () => { + await expect(system.element(by.system.label('NonExistent'))).toExist(); + }, 'Expectation failed, element with matcher `label == "NonExistent"` does not exist'); + }); + + it('should raise when trying to tap on system element that does not exist', async () => { + await expectToThrow(async () => { + await system.element(by.system.label('NonExistent')).tap(); + }, 'Tap failed, element with matcher `label == "NonExistent"` does not exist'); + }); + }); + + describe(':android: not supported on Android', () => { + it('should throw on expectation call', async () => { + await expectToThrow(async () => { + await expect(system.element(by.system.label('Allow'))).toExist(); + }, 'System interactions are not supported on Android, use UiDevice APIs directly instead'); + }); + + it('should throw on action call', async () => { + await expectToThrow(async () => { + await system.element(by.system.type('button')).atIndex(0).tap(); + }, 'System interactions are not supported on Android, use UiDevice APIs directly instead'); + }); + }); +}); diff --git a/detox/test/src/Screens/SystemDialogsScreen.js b/detox/test/src/Screens/SystemDialogsScreen.js new file mode 100644 index 0000000000..de19b2063c --- /dev/null +++ b/detox/test/src/Screens/SystemDialogsScreen.js @@ -0,0 +1,46 @@ +import React, {Component} from 'react'; +import { + View, + Text, Button, +} from 'react-native'; + +import {request, PERMISSIONS, RESULTS, check} from 'react-native-permissions'; + +export default class SystemDialogsScreen extends Component { + constructor(props) { + super(props); + + this.state = { + userTrackingStatus: RESULTS.UNAVAILABLE, + }; + } + + async updateStatus() { + const status = await check(PERMISSIONS.IOS.APP_TRACKING_TRANSPARENCY); + + this.setState({ + userTrackingStatus: status, + }); + } + + async requestPermission() { + const status = await request(PERMISSIONS.IOS.APP_TRACKING_TRANSPARENCY); + + this.setState({ + userTrackingStatus: status, + }); + } + + render() { + const status = this.state.userTrackingStatus; + + return ( + + User Tracking Status + {status} +