diff --git a/detox/detox.d.ts b/detox/detox.d.ts index aba5e45540..978fcf0dbe 100644 --- a/detox/detox.d.ts +++ b/detox/detox.d.ts @@ -1137,7 +1137,21 @@ declare global { } interface WebViewElement { + /** + * Find a web element by a matcher. + * @param webMatcher a web matcher for the web element. + */ element(webMatcher: WebMatcher): IndexableWebElement; + + /** + * Returns the index-th web-view in the UI hierarchy that is matched by the given matcher. + * @param index the index of the web-view. + * + * @note Currently, supported only for iOS. + * + * @example await web(by.id('webview')).atIndex(1); + */ + atIndex(index: number): WebViewElement; } interface WebFacade extends WebViewElement { @@ -1507,8 +1521,8 @@ declare global { interface IndexableWebElement extends WebElement { /** - * Choose from multiple elements matching the same matcher using index - * @example await web.element(by.web.hrefContains('Details')).atIndex(2).tap(); + * Choose from multiple elements matching the same matcher using index. + * @example await web.element(by.web.tag('p')).atIndex(2).tap(); */ atIndex(index: number): WebElement; } @@ -1520,24 +1534,27 @@ declare global { tap(): Promise; /** + * Type text into a web element. * @param text to type - * @param isContentEditable whether its a ContentEditable element, default is false. + * @param isContentEditable whether the element is content-editable, default is false. Ignored on iOS. */ typeText(text: string, isContentEditable: boolean): Promise; /** - * At the moment not working on content-editable + * Replaces the input content with the new text. + * @note On Android, not working for content-editable elements. * @param text to replace with the old content. */ replaceText(text: string): Promise; /** - * At the moment not working on content-editable + * Clears the input content. + * @note On Android, not working for content-editable elements. */ clearText(): Promise; /** - * scrolling to the view, the element top position will be at the top of the screen. + * Scrolling to the view, the element top position will be at the top of the screen. */ scrollToView(): Promise; @@ -1552,12 +1569,14 @@ declare global { focus(): Promise; /** - * Selects all the input content, works on ContentEditable at the moment. + * Selects all the input content. + * @note On Android, it works only for content-editable elements. */ selectAllText(): Promise; /** - * Moves the input cursor / caret to the end of the content, works on ContentEditable at the moment. + * Moves the input cursor to the end of the content. + * @note On Android, it works only for content-editable elements. */ moveCursorToEnd(): Promise; diff --git a/detox/ios/Detox.xcodeproj/project.pbxproj b/detox/ios/Detox.xcodeproj/project.pbxproj index ad6d8e9334..fce7908622 100644 --- a/detox/ios/Detox.xcodeproj/project.pbxproj +++ b/detox/ios/Detox.xcodeproj/project.pbxproj @@ -106,6 +106,30 @@ 39EECB7C24C0A5AF009C3364 /* NSThread+DetoxUtils.h in Headers */ = {isa = PBXBuildFile; fileRef = 39EECB7A24C0A5AE009C3364 /* NSThread+DetoxUtils.h */; settings = {ATTRIBUTES = (Private, ); }; }; 39EECB7D24C0A5AF009C3364 /* NSThread+DetoxUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 39EECB7B24C0A5AF009C3364 /* NSThread+DetoxUtils.m */; }; 39FFD9471FD730A600C97030 /* DetoxCrashHandler.mm in Sources */ = {isa = PBXBuildFile; fileRef = 39FFD9451FD730A600C97030 /* DetoxCrashHandler.mm */; }; + 600A0A762B923B1F00937051 /* WebExpectation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600A0A752B923B1F00937051 /* WebExpectation.swift */; }; + 600A0A792B93097200937051 /* WKWebView+findView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600A0A782B93097200937051 /* WKWebView+findView.swift */; }; + 600A0A7B2B9376D600937051 /* WebCodeBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600A0A7A2B9376D600937051 /* WebCodeBuilder.swift */; }; + 600A0A852B966F9000937051 /* WebAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600A0A842B966F9000937051 /* WebAction.swift */; }; + 600A0A872B966FBE00937051 /* WebPredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600A0A862B966FBE00937051 /* WebPredicate.swift */; }; + 600A0A892B966FE200937051 /* WebPredicateType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600A0A882B966FE200937051 /* WebPredicateType.swift */; }; + 600A0AA62B998B2700937051 /* WebActionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600A0AA52B998B2700937051 /* WebActionType.swift */; }; + 600A0AA82B998B4200937051 /* WebExpectationType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600A0AA72B998B4200937051 /* WebExpectationType.swift */; }; + 600A0AAA2B998B5500937051 /* WebExpectationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600A0AA92B998B5500937051 /* WebExpectationModifier.swift */; }; + 600A0AAC2B998C1800937051 /* WebInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600A0AAB2B998C1800937051 /* WebInteraction.swift */; }; + 600A0AAE2B999D2300937051 /* WKWebView+evaluateJSAfterLoading.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600A0AAD2B999D2300937051 /* WKWebView+evaluateJSAfterLoading.swift */; }; + 600A0AB22B99B1D100937051 /* WebCodeBuilder+createAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600A0AB12B99B1D100937051 /* WebCodeBuilder+createAction.swift */; }; + 600A0AB42B99B1DF00937051 /* WebCodeBuilder+createExpectation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600A0AB32B99B1DF00937051 /* WebCodeBuilder+createExpectation.swift */; }; + 600A0AB62B99B23800937051 /* WebCodeBuilder+createSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600A0AB52B99B23800937051 /* WebCodeBuilder+createSelector.swift */; }; + 600A0ACE2B9B60F800937051 /* WebCodeBuilder+createTapAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600A0ACD2B9B60F800937051 /* WebCodeBuilder+createTapAction.swift */; }; + 600A0AD02B9B625600937051 /* WebCodeBuilder+createFocusAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600A0ACF2B9B625600937051 /* WebCodeBuilder+createFocusAction.swift */; }; + 600A0AD22B9C39EE00937051 /* WebCodeBuilder+createTypeAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600A0AD12B9C39EE00937051 /* WebCodeBuilder+createTypeAction.swift */; }; + 600A0AD42B9C407900937051 /* WebCodeBuilder+createGetURLAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600A0AD32B9C407900937051 /* WebCodeBuilder+createGetURLAction.swift */; }; + 600A0AD62B9C410900937051 /* WebCodeBuilder+createGetTextAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600A0AD52B9C410900937051 /* WebCodeBuilder+createGetTextAction.swift */; }; + 600A0AD82B9C41C400937051 /* WebCodeBuilder+createGetTitleAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600A0AD72B9C41C400937051 /* WebCodeBuilder+createGetTitleAction.swift */; }; + 600A0ADA2B9C7E5900937051 /* WebCodeBuilder+createMoveCursorToEndAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600A0AD92B9C7E5900937051 /* WebCodeBuilder+createMoveCursorToEndAction.swift */; }; + 600A0ADC2B9C7F9D00937051 /* WebCodeBuilder+createRunScriptAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600A0ADB2B9C7F9D00937051 /* WebCodeBuilder+createRunScriptAction.swift */; }; + 600A0ADE2B9C817500937051 /* WebCodeBuilder+createSelectAllTextAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600A0ADD2B9C817500937051 /* WebCodeBuilder+createSelectAllTextAction.swift */; }; + 600A0AE02B9C835800937051 /* WebCodeBuilder+createScrollIntoViewAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600A0ADF2B9C835800937051 /* WebCodeBuilder+createScrollIntoViewAction.swift */; }; 6062B5E12720323700CBDBF0 /* DTXAddressInfo.h in Headers */ = {isa = PBXBuildFile; fileRef = 6062B5DB2720323600CBDBF0 /* DTXAddressInfo.h */; }; 6062B5E22720323700CBDBF0 /* DTXSwizzlingHelper.h in Headers */ = {isa = PBXBuildFile; fileRef = 6062B5DC2720323600CBDBF0 /* DTXSwizzlingHelper.h */; }; 6062B5E32720323700CBDBF0 /* NSArray+Utils.h in Headers */ = {isa = PBXBuildFile; fileRef = 6062B5DD2720323700CBDBF0 /* NSArray+Utils.h */; }; @@ -304,6 +328,30 @@ 39F6422A1FDD5EEC00468FED /* Detox.pch */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Detox.pch; sourceTree = ""; }; 39F6422B1FDD5F3300468FED /* DTXLoggingSubsystem.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DTXLoggingSubsystem.h; sourceTree = ""; }; 39FFD9451FD730A600C97030 /* DetoxCrashHandler.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = DetoxCrashHandler.mm; sourceTree = ""; }; + 600A0A752B923B1F00937051 /* WebExpectation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebExpectation.swift; sourceTree = ""; }; + 600A0A782B93097200937051 /* WKWebView+findView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WKWebView+findView.swift"; sourceTree = ""; }; + 600A0A7A2B9376D600937051 /* WebCodeBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebCodeBuilder.swift; sourceTree = ""; }; + 600A0A842B966F9000937051 /* WebAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAction.swift; sourceTree = ""; }; + 600A0A862B966FBE00937051 /* WebPredicate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebPredicate.swift; sourceTree = ""; }; + 600A0A882B966FE200937051 /* WebPredicateType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebPredicateType.swift; sourceTree = ""; }; + 600A0AA52B998B2700937051 /* WebActionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebActionType.swift; sourceTree = ""; }; + 600A0AA72B998B4200937051 /* WebExpectationType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebExpectationType.swift; sourceTree = ""; }; + 600A0AA92B998B5500937051 /* WebExpectationModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebExpectationModifier.swift; sourceTree = ""; }; + 600A0AAB2B998C1800937051 /* WebInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebInteraction.swift; sourceTree = ""; }; + 600A0AAD2B999D2300937051 /* WKWebView+evaluateJSAfterLoading.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WKWebView+evaluateJSAfterLoading.swift"; sourceTree = ""; }; + 600A0AB12B99B1D100937051 /* WebCodeBuilder+createAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebCodeBuilder+createAction.swift"; sourceTree = ""; }; + 600A0AB32B99B1DF00937051 /* WebCodeBuilder+createExpectation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebCodeBuilder+createExpectation.swift"; sourceTree = ""; }; + 600A0AB52B99B23800937051 /* WebCodeBuilder+createSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebCodeBuilder+createSelector.swift"; sourceTree = ""; }; + 600A0ACD2B9B60F800937051 /* WebCodeBuilder+createTapAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebCodeBuilder+createTapAction.swift"; sourceTree = ""; }; + 600A0ACF2B9B625600937051 /* WebCodeBuilder+createFocusAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebCodeBuilder+createFocusAction.swift"; sourceTree = ""; }; + 600A0AD12B9C39EE00937051 /* WebCodeBuilder+createTypeAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebCodeBuilder+createTypeAction.swift"; sourceTree = ""; }; + 600A0AD32B9C407900937051 /* WebCodeBuilder+createGetURLAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebCodeBuilder+createGetURLAction.swift"; sourceTree = ""; }; + 600A0AD52B9C410900937051 /* WebCodeBuilder+createGetTextAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebCodeBuilder+createGetTextAction.swift"; sourceTree = ""; }; + 600A0AD72B9C41C400937051 /* WebCodeBuilder+createGetTitleAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebCodeBuilder+createGetTitleAction.swift"; sourceTree = ""; }; + 600A0AD92B9C7E5900937051 /* WebCodeBuilder+createMoveCursorToEndAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebCodeBuilder+createMoveCursorToEndAction.swift"; sourceTree = ""; }; + 600A0ADB2B9C7F9D00937051 /* WebCodeBuilder+createRunScriptAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebCodeBuilder+createRunScriptAction.swift"; sourceTree = ""; }; + 600A0ADD2B9C817500937051 /* WebCodeBuilder+createSelectAllTextAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebCodeBuilder+createSelectAllTextAction.swift"; sourceTree = ""; }; + 600A0ADF2B9C835800937051 /* WebCodeBuilder+createScrollIntoViewAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebCodeBuilder+createScrollIntoViewAction.swift"; sourceTree = ""; }; 6062B5DB2720323600CBDBF0 /* DTXAddressInfo.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = DTXAddressInfo.h; path = DetoxSync/DetoxSync/DTXObjectiveCHelpers/DTXAddressInfo.h; sourceTree = SOURCE_ROOT; }; 6062B5DC2720323600CBDBF0 /* DTXSwizzlingHelper.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = DTXSwizzlingHelper.h; path = DetoxSync/DetoxSync/DTXObjectiveCHelpers/DTXSwizzlingHelper.h; sourceTree = SOURCE_ROOT; }; 6062B5DD2720323700CBDBF0 /* NSArray+Utils.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "NSArray+Utils.h"; path = "DetoxSync/DetoxSync/DTXObjectiveCHelpers/NSArray+Utils.h"; sourceTree = SOURCE_ROOT; }; @@ -577,6 +625,7 @@ 3980D13C244C44C3004812DD /* Invocation */ = { isa = PBXGroup; children = ( + 600A0A772B92FD5800937051 /* WebViews */, 3980D155244C45E9004812DD /* Action.swift */, 3980D153244C45E9004812DD /* Element.swift */, 3980D156244C45E9004812DD /* Expectation.swift */, @@ -614,6 +663,101 @@ path = ReactNativeSupport; sourceTree = ""; }; + 600A0A772B92FD5800937051 /* WebViews */ = { + isa = PBXGroup; + children = ( + 600A0AA02B998A2200937051 /* Action */, + 600A0AA22B998A2E00937051 /* Expectation */, + 600A0AA42B998A9100937051 /* Shared */, + ); + name = WebViews; + sourceTree = ""; + }; + 600A0AA02B998A2200937051 /* Action */ = { + isa = PBXGroup; + children = ( + 600A0AC82B9B5E4F00937051 /* CodeBuilder */, + 600A0A842B966F9000937051 /* WebAction.swift */, + 600A0AA52B998B2700937051 /* WebActionType.swift */, + ); + name = Action; + sourceTree = ""; + }; + 600A0AA22B998A2E00937051 /* Expectation */ = { + isa = PBXGroup; + children = ( + 600A0AC72B9B5E4200937051 /* CodeBuilder */, + 600A0A752B923B1F00937051 /* WebExpectation.swift */, + 600A0AA72B998B4200937051 /* WebExpectationType.swift */, + 600A0AA92B998B5500937051 /* WebExpectationModifier.swift */, + ); + name = Expectation; + sourceTree = ""; + }; + 600A0AA32B998A5E00937051 /* CodeBuilder */ = { + isa = PBXGroup; + children = ( + 600A0A7A2B9376D600937051 /* WebCodeBuilder.swift */, + 600A0AB52B99B23800937051 /* WebCodeBuilder+createSelector.swift */, + ); + name = CodeBuilder; + sourceTree = ""; + }; + 600A0AA42B998A9100937051 /* Shared */ = { + isa = PBXGroup; + children = ( + 600A0AA32B998A5E00937051 /* CodeBuilder */, + 600A0AB02B999E5E00937051 /* Interaction */, + 600A0AAF2B999E3900937051 /* WKWebView */, + ); + name = Shared; + sourceTree = ""; + }; + 600A0AAF2B999E3900937051 /* WKWebView */ = { + isa = PBXGroup; + children = ( + 600A0A782B93097200937051 /* WKWebView+findView.swift */, + 600A0AAD2B999D2300937051 /* WKWebView+evaluateJSAfterLoading.swift */, + ); + name = WKWebView; + sourceTree = ""; + }; + 600A0AB02B999E5E00937051 /* Interaction */ = { + isa = PBXGroup; + children = ( + 600A0AAB2B998C1800937051 /* WebInteraction.swift */, + 600A0A862B966FBE00937051 /* WebPredicate.swift */, + 600A0A882B966FE200937051 /* WebPredicateType.swift */, + ); + name = Interaction; + sourceTree = ""; + }; + 600A0AC72B9B5E4200937051 /* CodeBuilder */ = { + isa = PBXGroup; + children = ( + 600A0AB32B99B1DF00937051 /* WebCodeBuilder+createExpectation.swift */, + ); + name = CodeBuilder; + sourceTree = ""; + }; + 600A0AC82B9B5E4F00937051 /* CodeBuilder */ = { + isa = PBXGroup; + children = ( + 600A0AB12B99B1D100937051 /* WebCodeBuilder+createAction.swift */, + 600A0ACF2B9B625600937051 /* WebCodeBuilder+createFocusAction.swift */, + 600A0AD52B9C410900937051 /* WebCodeBuilder+createGetTextAction.swift */, + 600A0AD72B9C41C400937051 /* WebCodeBuilder+createGetTitleAction.swift */, + 600A0AD32B9C407900937051 /* WebCodeBuilder+createGetURLAction.swift */, + 600A0AD92B9C7E5900937051 /* WebCodeBuilder+createMoveCursorToEndAction.swift */, + 600A0ADB2B9C7F9D00937051 /* WebCodeBuilder+createRunScriptAction.swift */, + 600A0ADF2B9C835800937051 /* WebCodeBuilder+createScrollIntoViewAction.swift */, + 600A0ADD2B9C817500937051 /* WebCodeBuilder+createSelectAllTextAction.swift */, + 600A0ACD2B9B60F800937051 /* WebCodeBuilder+createTapAction.swift */, + 600A0AD12B9C39EE00937051 /* WebCodeBuilder+createTypeAction.swift */, + ); + name = CodeBuilder; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -834,22 +978,33 @@ 39DC985724BB904D00FFB224 /* ReactNativeSupport.m in Sources */, 6062B5E42720323700CBDBF0 /* NSArray+Utils.m in Sources */, 3980D15C244C45EA004812DD /* Action.swift in Sources */, + 600A0AE02B9C835800937051 /* WebCodeBuilder+createScrollIntoViewAction.swift in Sources */, AD4781092636F7CF006774CD /* NSURL+DetoxUtils.m in Sources */, + 600A0AA82B998B4200937051 /* WebExpectationType.swift in Sources */, + 600A0ADE2B9C817500937051 /* WebCodeBuilder+createSelectAllTextAction.swift in Sources */, 3976BF02246AC7DE00AA20C7 /* TimeInterval+DetoxUtils.swift in Sources */, + 600A0A892B966FE200937051 /* WebPredicateType.swift in Sources */, + 600A0AAA2B998B5500937051 /* WebExpectationModifier.swift in Sources */, 39996B4D248555AB009B8E45 /* DTXDurationFormatter.m in Sources */, 399BF36921933F0C00F96D50 /* ExternalLogging.m in Sources */, + 600A0A872B966FBE00937051 /* WebPredicate.swift in Sources */, 3980D16A244DD837004812DD /* UIDatePicker+DetoxActions.m in Sources */, 3980D11B2448B52C004812DD /* DTXRunLoopSpinner.m in Sources */, 3980D166244DC8F0004812DD /* UIPickerView+DetoxActions.m in Sources */, + 600A0AD22B9C39EE00937051 /* WebCodeBuilder+createTypeAction.swift in Sources */, + 600A0AAE2B999D2300937051 /* WKWebView+evaluateJSAfterLoading.swift in Sources */, 3990BA162457248600B608C8 /* UIView+DetoxUtils.m in Sources */, 397CA7A324840449005E8A71 /* NSException+DetoxUtils.swift in Sources */, + 600A0AD62B9C410900937051 /* WebCodeBuilder+createGetTextAction.swift in Sources */, 39DC984824BB8E8900FFB224 /* DTXLogging.m in Sources */, 39AB2D31205ABBD90029CD1F /* DetoxUserActivityDispatcher.swift in Sources */, 397CA78D248010B5005E8A71 /* UISlider+DetoxUtils.m in Sources */, + 600A0AD42B9C407900937051 /* WebCodeBuilder+createGetURLAction.swift in Sources */, 3980D15D244C45EA004812DD /* Expectation.swift in Sources */, 3980D11A2448B52C004812DD /* DTXTouchInjector.m in Sources */, 3946CD3F2566EB9E000A3606 /* NSObject+DetoxActions.m in Sources */, 395B06E5256D5A5600941716 /* UIView+DetoxSpeedup.m in Sources */, + 600A0AA62B998B2700937051 /* WebActionType.swift in Sources */, 3980D15B244C45EA004812DD /* InvocationManager.swift in Sources */, 39DC984924BB8E8900FFB224 /* DTXLogging.swift in Sources */, 3946CD452566EBC2000A3606 /* NSObject+DontCrash.m in Sources */, @@ -860,20 +1015,31 @@ 390DED84248906FC00E27BE8 /* UIWindow+DetoxUtils.m in Sources */, 3980D158244C45EA004812DD /* Modifier.swift in Sources */, 39EECB4324BF4FDA009C3364 /* ReactNativeSupport.m in Sources */, + 600A0ADC2B9C7F9D00937051 /* WebCodeBuilder+createRunScriptAction.swift in Sources */, 3980D15A244C45EA004812DD /* Element.swift in Sources */, + 600A0AD82B9C41C400937051 /* WebCodeBuilder+createGetTitleAction.swift in Sources */, 3980D1122448B52C004812DD /* UIApplication+DTXAdditions.m in Sources */, + 600A0AD02B9B625600937051 /* WebCodeBuilder+createFocusAction.swift in Sources */, + 600A0ADA2B9C7E5900937051 /* WebCodeBuilder+createMoveCursorToEndAction.swift in Sources */, + 600A0A792B93097200937051 /* WKWebView+findView.swift in Sources */, 39CF46212A2E1F50004A0CA3 /* String+matchesJSRegex.swift in Sources */, + 600A0A762B923B1F00937051 /* WebExpectation.swift in Sources */, 396D454425238B780096E7FA /* DetoxPolicy.m in Sources */, 3980D15E244C45EA004812DD /* Predicate.swift in Sources */, 397CA79F2483F07D005E8A71 /* ApproximateEquality.swift in Sources */, 6062B5E62720323700CBDBF0 /* DTXAddressInfo.mm in Sources */, 39CEFCDB1E34E91B00A09124 /* DetoxUserNotificationDispatcher.swift in Sources */, + 600A0AB22B99B1D100937051 /* WebCodeBuilder+createAction.swift in Sources */, 39FFD9471FD730A600C97030 /* DetoxCrashHandler.mm in Sources */, 3980D136244C4373004812DD /* UIView+DetoxMatchers.m in Sources */, 392324DF24781CBD00A3D119 /* WebSocket.swift in Sources */, + 600A0AB42B99B1DF00937051 /* WebCodeBuilder+createExpectation.swift in Sources */, 60C1961B271F11C4000172DD /* fishhook.c in Sources */, + 600A0A7B2B9376D600937051 /* WebCodeBuilder.swift in Sources */, + 600A0ACE2B9B60F800937051 /* WebCodeBuilder+createTapAction.swift in Sources */, 390DEDDC248D56F600E27BE8 /* String+LocalizedError.swift in Sources */, 3946CD4A2566EBCB000A3606 /* NSObject+DetoxUtils.m in Sources */, + 600A0AAC2B998C1800937051 /* WebInteraction.swift in Sources */, 3990BA36245AC98C00B608C8 /* DTXAssertionHandler+Swift.swift in Sources */, 12BDDA602971652D00FDBBA8 /* UIApplication+DetoxActions.swift in Sources */, 39CA978D245B13CB00A7FC43 /* UIDevice+DetoxActions.m in Sources */, @@ -884,6 +1050,8 @@ 3980D1102448B52C004812DD /* DTXSyntheticEvents.m in Sources */, 3980D16E244DDCD7004812DD /* UIScrollView+DetoxActions.m in Sources */, 392324D72477D87D00A3D119 /* DetoxManager.swift in Sources */, + 600A0A852B966F9000937051 /* WebAction.swift in Sources */, + 600A0AB62B99B23800937051 /* WebCodeBuilder+createSelector.swift in Sources */, 3980D1192448B52C004812DD /* UITouch+DTXAdditions.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/detox/ios/Detox/Invocation/Element.swift b/detox/ios/Detox/Invocation/Element.swift index 378608b8af..70f8d8a426 100644 --- a/detox/ios/Detox/Invocation/Element.swift +++ b/detox/ios/Detox/Invocation/Element.swift @@ -53,7 +53,7 @@ class Element : NSObject { return array } - private var view : NSObject { + var view : NSObject { let array = self.views let element : NSObject diff --git a/detox/ios/Detox/Invocation/InvocationManager.swift b/detox/ios/Detox/Invocation/InvocationManager.swift index 546d1fe102..f89b31de25 100644 --- a/detox/ios/Detox/Invocation/InvocationManager.swift +++ b/detox/ios/Detox/Invocation/InvocationManager.swift @@ -18,6 +18,9 @@ final class InvocationManager { internal struct Types { static let action = "action" static let expectation = "expectation" + + static let webAction = "webAction" + static let webExpectation = "webExpectation" } class func invoke(dictionaryRepresentation: [String: Any], completionHandler: @escaping ([String: Any]?, Error?) -> Void) { @@ -33,14 +36,32 @@ final class InvocationManager { switch kind { case Types.action: let action = try Action.with(dictionaryRepresentation: dictionaryRepresentation) - os_signpost(.begin, log: log.osLog, name: "Action Invocation", signpostID: signpostID, "%{public}s", action.description) + os_signpost(.begin, log: log.osLog, name: "Action Invocation", + signpostID: signpostID, "%{public}s", action.description) action.perform(completionHandler: signpostCompletionHandler) + case Types.expectation: let expectation = try Expectation.with(dictionaryRepresentation: dictionaryRepresentation) - os_signpost(.begin, log: log.osLog, name: "Expectation Invocation", signpostID: signpostID, "%{public}s", expectation.description) + os_signpost(.begin, log: log.osLog, name: "Expectation Invocation", + signpostID: signpostID, "%{public}s", expectation.description) expectation.evaluate { error in signpostCompletionHandler(nil, error) } + + case Types.webAction: + let action = try WebAction.init(json: dictionaryRepresentation) + os_signpost(.begin, log: log.osLog, name: "Web action Invocation", + signpostID: signpostID, "%{public}s", action.description) + action.perform(completionHandler: signpostCompletionHandler) + + case Types.webExpectation: + let expectation = try WebExpectation.init(json: dictionaryRepresentation) + os_signpost(.begin, log: log.osLog, name: "Web expectation Invocation", + signpostID: signpostID, "%{public}s", expectation.description) + expectation.evaluate { error in + signpostCompletionHandler(nil, error) + } + default: fatalError("Unknown invocation type “\(kind)”") } diff --git a/detox/ios/Detox/Invocation/WKWebView+evaluateJSAfterLoading.swift b/detox/ios/Detox/Invocation/WKWebView+evaluateJSAfterLoading.swift new file mode 100644 index 0000000000..f0b1c876e7 --- /dev/null +++ b/detox/ios/Detox/Invocation/WKWebView+evaluateJSAfterLoading.swift @@ -0,0 +1,40 @@ +// +// WKWebView+evaluateJSAfterLoading.swift (Detox) +// Created by Asaf Korem (Wix.com) on 2024. +// + +import WebKit + +fileprivate let log = DetoxLog(category: "WebView") + +/// Extends WKWebView with the ability to evaluate JavaScript after the web view has +/// finished loading. +extension WKWebView { + func evaluateJSAfterLoading( + _ javaScriptString: String, + completionHandler: ((Any?, Error?) -> Void)? = nil + ) { + let cleanJavaScriptString = replaceConsecutiveSpacesAndTabs(in: javaScriptString) + log.debug("Evaluating JavaScript after loading: `\(cleanJavaScriptString)`") + + var observation: NSKeyValueObservation? + observation = self.observe( + \.isLoading, options: [.new, .old, .initial] + ) { (webView, change) in + guard change.newValue == false else { return } + + observation?.invalidate() + + log.debug("Evaluating JavaScript on web-view: `\(cleanJavaScriptString)`") + webView.evaluateJavaScript(cleanJavaScriptString, completionHandler: completionHandler) + } + } + + private func replaceConsecutiveSpacesAndTabs(in input: String) -> String { + let pattern = "[ \\t\\r\\n]+" + let regex = try! NSRegularExpression(pattern: pattern, options: []) + let range = NSRange(location: 0, length: input.utf16.count) + let modifiedString = regex.stringByReplacingMatches(in: input, options: [], range: range, withTemplate: " ") + return modifiedString + } +} diff --git a/detox/ios/Detox/Invocation/WKWebView+findView.swift b/detox/ios/Detox/Invocation/WKWebView+findView.swift new file mode 100644 index 0000000000..9577577bc8 --- /dev/null +++ b/detox/ios/Detox/Invocation/WKWebView+findView.swift @@ -0,0 +1,65 @@ +// +// WKWebView+findView.swift (Detox) +// Created by Asaf Korem (Wix.com) on 2024. +// + +import WebKit + +/// Extends WKWebView with the ability to find a web view element. +extension WKWebView { + /// Finds a web view element by the given `predicate` at the given `index`. + class func findView( + by predicate: Predicate?, + atIndex index: Int? + ) throws -> WKWebView { + let webView: WKWebView? + + if let predicate = predicate { + guard let ancestor = Element(predicate: predicate, index: index).view as? UIView else { + throw dtx_errorForFatalError( + "Failed to find web view with predicate: \(predicate.description)") + } + + webView = try findWebViewDescendant(in: ancestor) + } else { + webView = try findWebViewDescendant() + } + + guard let webView = webView else { + throw dtx_errorForFatalError( + "Failed to find web view with predicate: `\(predicate?.description ?? "")` " + + "at index: `\(index ?? 0)`") + } + + return webView + } + + fileprivate class func findWebViewDescendant( + in ancestor: UIView? = nil + ) throws -> WKWebView? { + let predicate = NSPredicate.init { (view, _) -> Bool in + return view is WKWebView + } + + var webViews: [WKWebView] + if let ancestor = ancestor { + webViews = UIView.dtx_findViews(inHierarchy: ancestor, passing: predicate).compactMap { + $0 as? WKWebView + } + } else { + webViews = UIView.dtx_findViewsInAllWindows(passing: predicate).compactMap { + $0 as? WKWebView + } + } + + if webViews.count == 0 { + return nil + } else if webViews.count > 1 { + throw dtx_errorForFatalError( + "Found more than one matching web view in the hierarchy. " + + "Please specify a predicate to find the correct web view.") + } else { + return webViews.first + } + } +} diff --git a/detox/ios/Detox/Invocation/WebAction.swift b/detox/ios/Detox/Invocation/WebAction.swift new file mode 100644 index 0000000000..639f3b49ed --- /dev/null +++ b/detox/ios/Detox/Invocation/WebAction.swift @@ -0,0 +1,61 @@ +// +// WebAction.swift (Detox) +// Created by Asaf Korem (Wix.com) on 2024. +// + +import WebKit + +/// Represents a web action to be performed on a web view. +class WebAction: WebInteraction { + var webAction: WebActionType + var params: [Any]? + + override init(json: [String: Any]) throws { + self.webAction = WebActionType(rawValue: json["webAction"] as! String)! + self.params = json["params"] as? [Any] + try super.init(json: json) + } + + override var description: String { + return "WebAction: \(webAction.rawValue)" + } + + func perform(completionHandler: @escaping ([String: Any]?, Error?) -> Void) { + var jsString: String + var webView: WKWebView + + do { + jsString = try WebCodeBuilder() + .with(predicate: webPredicate, atIndex: webAtIndex) + .with(action: webAction, params: params) + .build() + + webView = try WKWebView.findView(by: predicate, atIndex: atIndex) + } catch { + completionHandler(nil, error) + return + } + + webView.evaluateJSAfterLoading(jsString) { (result, error) in + if let error = error { + completionHandler( + ["result": false, "error": error.localizedDescription], + dtx_errorForFatalError( + "Failed to evaluate JavaScript on web view: \(webView.debugDescription). " + + "Error: \(error.localizedDescription)") + ) + } else if let jsError = (result as? [String: Any])?["error"] as? String { + completionHandler( + ["result": false, "error": jsError], + dtx_errorForFatalError( + "Failed to evaluate JavaScript on web view: \(webView.debugDescription). " + + "JS exception: \(jsError)") + ) + } else if let result = (result as? [String: Any])?["result"] as? String { + completionHandler(["result": result], nil) + } else { + completionHandler(nil, nil) + } + } + } +} diff --git a/detox/ios/Detox/Invocation/WebActionType.swift b/detox/ios/Detox/Invocation/WebActionType.swift new file mode 100644 index 0000000000..51cc7991a5 --- /dev/null +++ b/detox/ios/Detox/Invocation/WebActionType.swift @@ -0,0 +1,20 @@ +// +// WebActionType.swift (Detox) +// Created by Asaf Korem (Wix.com) on 2024. +// + +enum WebActionType: String, Codable { + case tap = "tap" + case typeText = "typeText" + case replaceText = "replaceText" + case clearText = "clearText" + case selectAllText = "selectAllText" + case getText = "getText" + case scrollToView = "scrollToView" + case focus = "focus" + case moveCursorToEnd = "moveCursorToEnd" + case runScript = "runScript" + case runScriptWithArgs = "runScriptWithArgs" + case getCurrentUrl = "getCurrentUrl" + case getTitle = "getTitle" +} diff --git a/detox/ios/Detox/Invocation/WebCodeBuilder+createAction.swift b/detox/ios/Detox/Invocation/WebCodeBuilder+createAction.swift new file mode 100644 index 0000000000..26fc7e5563 --- /dev/null +++ b/detox/ios/Detox/Invocation/WebCodeBuilder+createAction.swift @@ -0,0 +1,78 @@ +// +// WebCodeBuilder+createAction.swift (Detox) +// Created by Asaf Korem (Wix.com) on 2024. +// + +/// Extends `WebCodeBuilder` with the ability to create a web action JS code. +extension WebCodeBuilder { + func createAction( + forAction action: WebActionType, + params: [Any]?, + onSelector selector: String + ) throws -> String { + switch action { + case .tap: + return createTapAction(selector: selector) + + case .clearText: + return createTypeAction(selector: selector, text: "", replace: true) + + case .typeText: + let text = try extractValueParam(action, params) + return createTypeAction(selector: selector, text: text, replace: false) + + case .replaceText: + let text = try extractValueParam(action, params) + return createTypeAction(selector: selector, text: text, replace: true) + + case .focus: + return createFocusAction(selector: selector) + + case .getCurrentUrl: + return createGetURLAction() + + case .getText: + return createGetTextAction(selector: selector) + + case .getTitle: + return createGetTitleAction() + + case .moveCursorToEnd: + return createMoveCursorToEndAction(selector: selector) + + case .runScript, .runScriptWithArgs: + return try createRunScriptAction(assertedParams(action, params, 1), selector: selector) + + case .selectAllText: + return createSelectAllTextAction(selector: selector) + + case .scrollToView: + return createScrollIntoViewAction(selector: selector) + } + } + + private func extractValueParam( _ action: WebActionType, _ params: [Any]?) throws -> String { + let params = try assertedParams(action, params, 1) + + guard let value = params.first as? String else { + throw dtx_errorForFatalError( + "Value param for action \(action.rawValue.uppercased()) is not a string (got: \(params))" + ) + } + + return value + } + + private func assertedParams( + _ action: WebActionType, _ params: [Any]?, _ expectedMinCount: Int + ) throws -> [Any] { + guard let params = params, params.count >= expectedMinCount else { + throw dtx_errorForFatalError( + "Expected at-least \(expectedMinCount) params for action " + + "\(action.rawValue.uppercased()) (got: \(params?.description ?? "none"))" + ) + } + + return params + } +} diff --git a/detox/ios/Detox/Invocation/WebCodeBuilder+createExpectation.swift b/detox/ios/Detox/Invocation/WebCodeBuilder+createExpectation.swift new file mode 100644 index 0000000000..6c52f33cf9 --- /dev/null +++ b/detox/ios/Detox/Invocation/WebCodeBuilder+createExpectation.swift @@ -0,0 +1,46 @@ +// +// WebCodeBuilder+createExpectation.swift (Detox) +// Created by Asaf Korem (Wix.com) on 2024. +// + +/// Extends `WebCodeBuilder` with the ability to create a web expectation JS code. +extension WebCodeBuilder { + func createExpectation(expectation: WebExpectationType, params: [String]?, modifiers: [WebModifier]?) -> String { + let expectationScript: String + + switch expectation { + case .toExist: + expectationScript = "element != null" + + case .toHaveText: + let expectedText = params?.first ?? "" + + let escapedExpectedText = expectedText + .replacingOccurrences(of: "`", with: "\\`") + .replacingOccurrences(of: "'", with: "\'") + .trimmingCharacters(in: .whitespacesAndNewlines) + + expectationScript = "(element.textContent && element.textContent.trim() == `\(escapedExpectedText)`) || " + + "(element.innerText && element.innerText.trim() == `\(escapedExpectedText)`) || " + + "(element.value && element.value.trim() == `\(escapedExpectedText)`) || " + + "`\(escapedExpectedText)` == ''" + } + + return modifyExpectation(script: expectationScript, modifiers: modifiers) + } + + private func modifyExpectation( + script expectationScript: String, modifiers: [WebModifier]? + ) -> String { + guard let modifiers = modifiers else { + return expectationScript + } + + return modifiers.reduce(expectationScript) { (expectationScript, modifier) in + switch modifier { + case .not: + return "!(" + expectationScript + ")" + } + } + } +} diff --git a/detox/ios/Detox/Invocation/WebCodeBuilder+createFocusAction.swift b/detox/ios/Detox/Invocation/WebCodeBuilder+createFocusAction.swift new file mode 100644 index 0000000000..6ce5fec233 --- /dev/null +++ b/detox/ios/Detox/Invocation/WebCodeBuilder+createFocusAction.swift @@ -0,0 +1,26 @@ +// +// WebCodeBuilder+createFocusAction.swift (Detox) +// Created by Asaf Korem (Wix.com) on 2024. +// + +/// Extends `WebCodeBuilder` with the ability to create a web focus action JS code. +extension WebCodeBuilder { + /// Creates a JS code that focuses on the given element. + func createFocusAction(selector: String) -> String { + return """ +((element) => { + if (!element) { + throw new Error('Element not found'); + } + + if (typeof element.focus !== 'function') { + throw new Error('Element is not focusable'); + } + + \(createScrollIntoViewAction(selector: selector)) + + element.focus(); +})(\(selector)); +""" + } +} diff --git a/detox/ios/Detox/Invocation/WebCodeBuilder+createGetTextAction.swift b/detox/ios/Detox/Invocation/WebCodeBuilder+createGetTextAction.swift new file mode 100644 index 0000000000..6b6c086f18 --- /dev/null +++ b/detox/ios/Detox/Invocation/WebCodeBuilder+createGetTextAction.swift @@ -0,0 +1,20 @@ +// +// WebCodeBuilder+createGetTextAction.swift (Detox) +// Created by Asaf Korem (Wix.com) on 2024. +// + +/// Extends `WebCodeBuilder` with the ability to create a web get text action JS code. +extension WebCodeBuilder { + /// Creates a JS code that gets the text of an given element. + func createGetTextAction(selector: String) -> String { + return """ +((element) => { + if (!element) { + throw new Error('Element not found'); + } + + return element.textContent.length > 0 ? element.textContent : element.value; +})(\(selector)); +""" + } +} diff --git a/detox/ios/Detox/Invocation/WebCodeBuilder+createGetTitleAction.swift b/detox/ios/Detox/Invocation/WebCodeBuilder+createGetTitleAction.swift new file mode 100644 index 0000000000..d755d46ed3 --- /dev/null +++ b/detox/ios/Detox/Invocation/WebCodeBuilder+createGetTitleAction.swift @@ -0,0 +1,16 @@ +// +// WebCodeBuilder+createGetTitleAction.swift (Detox) +// Created by Asaf Korem (Wix.com) on 2024. +// + +/// Extends `WebCodeBuilder` with the ability to create a web get title action JS code. +extension WebCodeBuilder { + /// Creates a JS code that gets the title of the current page. + func createGetTitleAction() -> String { + return """ +(() => { + return document.title; +})(); +""" + } +} diff --git a/detox/ios/Detox/Invocation/WebCodeBuilder+createGetURLAction.swift b/detox/ios/Detox/Invocation/WebCodeBuilder+createGetURLAction.swift new file mode 100644 index 0000000000..be5ec0d8fa --- /dev/null +++ b/detox/ios/Detox/Invocation/WebCodeBuilder+createGetURLAction.swift @@ -0,0 +1,16 @@ +// +// WebCodeBuilder+createGetURLAction.swift (Detox) +// Created by Asaf Korem (Wix.com) on 2024. +// + +/// Extends `WebCodeBuilder` with the ability to create a web get URL action JS code. +extension WebCodeBuilder { + /// Creates a JS code that gets the URL of the current page. + func createGetURLAction() -> String { + return """ +(() => { + return window.location.href; +})(); +""" + } +} diff --git a/detox/ios/Detox/Invocation/WebCodeBuilder+createMoveCursorToEndAction.swift b/detox/ios/Detox/Invocation/WebCodeBuilder+createMoveCursorToEndAction.swift new file mode 100644 index 0000000000..56e770991b --- /dev/null +++ b/detox/ios/Detox/Invocation/WebCodeBuilder+createMoveCursorToEndAction.swift @@ -0,0 +1,48 @@ +// +// WebCodeBuilder+createMoveCursorToEndAction.swift (Detox) +// Created by Asaf Korem (Wix.com) on 2024. +// + +/// Extends `WebCodeBuilder` with the ability to create a web move cursor to end action JS code. +extension WebCodeBuilder { + /// Creates a JS code that moves the cursor to the end of the given element. + func createMoveCursorToEndAction(selector: String) -> String { + return """ +((element) => { + if (!element) { + throw new Error('Element not found'); + } + + \(createFocusAction(selector: selector)) + + const getLength = (element) => { + if (element.value) { + return element.value.length; + } else if (element.innerText) { + return element.innerText.length; + } else if (element.textContent) { + return element.textContent.length; + } else { + return 0; + } + }; + + + if (typeof element.setSelectionRange === 'function') { + const length = getLength(element); + element.setSelectionRange(length, length); + } else { + var range = document.createRange(); + + range.selectNodeContents(element); + range.collapse(false); + + var selection = window.getSelection(); + + selection.removeAllRanges(); + selection.addRange(range); + } +})(\(selector)); +""" + } +} diff --git a/detox/ios/Detox/Invocation/WebCodeBuilder+createRunScriptAction.swift b/detox/ios/Detox/Invocation/WebCodeBuilder+createRunScriptAction.swift new file mode 100644 index 0000000000..51ab164688 --- /dev/null +++ b/detox/ios/Detox/Invocation/WebCodeBuilder+createRunScriptAction.swift @@ -0,0 +1,27 @@ +// +// WebCodeBuilder+createRunScriptAction.swift (Detox) +// Created by Asaf Korem (Wix.com) on 2024. +// + +/// Extends `WebCodeBuilder` with the ability to create a web run script action JS code. +extension WebCodeBuilder { + /// Creates a JS code that runs a script on the given element. + func createRunScriptAction(_ params: [Any], selector: String) throws -> String { + guard let script = params.first else { + throw dtx_errorForFatalError( + "Missing script parameter for runScript action, got: \(String(describing: params))") + } + + let extraParamsOrNil = params.dropFirst().compactMap({ param -> String? in + guard let data = try? JSONSerialization.data(withJSONObject: param, options: []), + let param = String(data: data, encoding: .utf8) else { + return nil + } + return param + }).joined(separator: ",") + + let extraParams = extraParamsOrNil.isEmpty ? "" : ",...\(extraParamsOrNil)" + + return "(\(script))(\(selector)\(extraParams));" + } +} diff --git a/detox/ios/Detox/Invocation/WebCodeBuilder+createScrollIntoViewAction.swift b/detox/ios/Detox/Invocation/WebCodeBuilder+createScrollIntoViewAction.swift new file mode 100644 index 0000000000..3dcfe3d430 --- /dev/null +++ b/detox/ios/Detox/Invocation/WebCodeBuilder+createScrollIntoViewAction.swift @@ -0,0 +1,24 @@ +// +// WebCodeBuilder+createScrollIntoViewAction.swift (Detox) +// Created by Asaf Korem (Wix.com) on 2024. +// + +/// Extends `WebCodeBuilder` with the ability to create a web scroll into view action JS code. +extension WebCodeBuilder { + /// Creates a JS code that scrolls into view the given element. + func createScrollIntoViewAction(selector: String) -> String { + return """ +((element) => { + if (!element) { + throw new Error('Element not found'); + } + + if (typeof element.scrollIntoViewIfNeeded === 'function') { + element.scrollIntoViewIfNeeded(true); + } else if (typeof element.scrollIntoView === 'function') { + element.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); + } +})(\(selector)); +""" + } +} diff --git a/detox/ios/Detox/Invocation/WebCodeBuilder+createSelectAllTextAction.swift b/detox/ios/Detox/Invocation/WebCodeBuilder+createSelectAllTextAction.swift new file mode 100644 index 0000000000..f164a7da4b --- /dev/null +++ b/detox/ios/Detox/Invocation/WebCodeBuilder+createSelectAllTextAction.swift @@ -0,0 +1,48 @@ +// +// WebCodeBuilder+createSelectAllTextAction.swift (Detox) +// Created by Asaf Korem (Wix.com) on 2024. +// + +/// Extends `WebCodeBuilder` with the ability to create a web select all text action JS code. +extension WebCodeBuilder { + /// Creates a JS code that selects all text of an given element. + func createSelectAllTextAction(selector: String) -> String { + return """ +((element) => { + if (!element) { + throw new Error('Element not found'); + } + + \(createFocusAction(selector: selector)) + + const isContentEditable = element.contentEditable === 'true'; + const isInputField = (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA'); + + if (!isContentEditable && !isInputField) { + throw new Error('Element is not editable'); + } + + if (element.readOnly) { + throw new Error('Element is read-only'); + } + + if (element.disabled) { + throw new Error('Element is disabled'); + } + + if (isContentEditable) { + var range = document.createRange(); + range.selectNodeContents(element); + var selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + } else if (isInputField) { + element.focus(); + element.select(); + } else { + throw new Error('Element text is not selectable'); + } +})(\(selector)); +""" + } +} diff --git a/detox/ios/Detox/Invocation/WebCodeBuilder+createSelector.swift b/detox/ios/Detox/Invocation/WebCodeBuilder+createSelector.swift new file mode 100644 index 0000000000..bf68ec6097 --- /dev/null +++ b/detox/ios/Detox/Invocation/WebCodeBuilder+createSelector.swift @@ -0,0 +1,56 @@ +// +// WebCodeBuilder+createSelector.swift (Detox) +// Created by Asaf Korem (Wix.com) on 2024. +// + +/// This extension is responsible for creating the JavaScript selector for the given predicate type +/// and value. +extension WebCodeBuilder { + func createSelector(forType type: WebPredicateType, value: String, index: Int?) -> String { + let index = index ?? 0 + let indexStatement = ".item(\(index))" + + let value = value.replacingOccurrences(of: "'", with: "\'") + + switch type { + case .id: + return "document.querySelectorAll('#\(value)')\(indexStatement)" + + case .className: + return "document.getElementsByClassName('\(value)')\(indexStatement)" + + case .cssSelector: + return "document.querySelectorAll('\(value)')\(indexStatement)" + + case .name: + return "document.getElementsByName('\(value)')\(indexStatement)" + + case .xpath: + if index > 0 { + return "document.evaluate('(\(value))[\(index + 1)]', document, null, " + + "XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue" + } + + return "document.evaluate('\(value)', document, null, " + + "XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue" + + case .href: + return "document.querySelectorAll('a[href=\"\(value)\"]')\(indexStatement)" + + case .hrefContains: + return "document.querySelectorAll('a[href*=\"\(value)\"]')\(indexStatement)" + + case .tag: + return "document.getElementsByTagName('\(value)')\(indexStatement)" + + case .label: + return "document.querySelectorAll('[aria-label=\"\(value)\"]')\(indexStatement)" + + case .value: + return "document.querySelectorAll('[value=\"\(value)\"]')\(indexStatement)" + + case .accessibilityType: + return "document.querySelectorAll('[role=\"\(value)\"]')\(indexStatement)" + } + } +} diff --git a/detox/ios/Detox/Invocation/WebCodeBuilder+createTapAction.swift b/detox/ios/Detox/Invocation/WebCodeBuilder+createTapAction.swift new file mode 100644 index 0000000000..f5362fa9f3 --- /dev/null +++ b/detox/ios/Detox/Invocation/WebCodeBuilder+createTapAction.swift @@ -0,0 +1,42 @@ +// +// WebCodeBuilder+createTapAction.swift (Detox) +// Created by Asaf Korem (Wix.com) on 2024. +// + +/// Extends `WebCodeBuilder` with the ability to create a web tap action JS code. +extension WebCodeBuilder { + /// Creates a JS code that taps on the given element. + func createTapAction(selector: String) -> String { + return """ +((element) => { + if (!element) { + throw new Error('Element not found'); + } + + \(createScrollIntoViewAction(selector: selector)) + + var mouseDownEvent = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + view: window + }); + + var mouseUpEvent = new MouseEvent('mouseup', { + bubbles: true, + cancelable: true, + view: window + }); + + var clickEvent = new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window + }); + + element.dispatchEvent(mouseDownEvent); + element.dispatchEvent(mouseUpEvent); + element.dispatchEvent(clickEvent); +})(\(selector)); +""" + } +} diff --git a/detox/ios/Detox/Invocation/WebCodeBuilder+createTypeAction.swift b/detox/ios/Detox/Invocation/WebCodeBuilder+createTypeAction.swift new file mode 100644 index 0000000000..4d6dc177a3 --- /dev/null +++ b/detox/ios/Detox/Invocation/WebCodeBuilder+createTypeAction.swift @@ -0,0 +1,85 @@ +// +// WebCodeBuilder+createTypeAction.swift (Detox) +// Created by Asaf Korem (Wix.com) on 2024. +// + +/// Extends `WebCodeBuilder` with the ability to create a web type action JS code. +extension WebCodeBuilder { + /// The delay between typing each character. + private var typeCharacterDelay: Int { 200 } + + /// Creates a web type action JS code. + func createTypeAction( + selector: String, text textToType: String, replace shouldReplaceCurrentText: Bool + ) -> String { + return """ +((element, textToType, shouldReplaceCurrentText) => { + if (!element) { + throw new Error('Element not found'); + } + + \(createMoveCursorToEndAction(selector: selector)) + + const isContentEditable = element.contentEditable === 'true'; + const isInputField = (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA'); + + if (!isContentEditable && !isInputField) { + throw new Error('Element is not editable'); + } + + if (element.readOnly) { + throw new Error('Element is read-only'); + } + + if (element.disabled) { + throw new Error('Element is disabled'); + } + + if (shouldReplaceCurrentText) { + const range = document.createRange(); + const sel = window.getSelection(); + + if (isContentEditable) { + range.selectNodeContents(element); + sel.removeAllRanges(); + sel.addRange(range); + + document.execCommand('delete', false, null); + } else if (isInputField) { + element.select(); + + document.execCommand('cut'); + } + } + + if (textToType.length === 0) { + return; + } + + let currentIndex = 0; + const delay = \(typeCharacterDelay); + const typeCharacters = () => { + if (currentIndex < textToType.length) { + if (isContentEditable) { + element.textContent += textToType.charAt(currentIndex); + } else if (isInputField) { + element.value += textToType.charAt(currentIndex); + } + + currentIndex++; + + typeCharacters(); + + const startTime = new Date().getTime(); + const finishTime = startTime + delay; + while (new Date().getTime() < finishTime) { + /* Synchronically wait for type delay to pass */ + } + } + }; + + typeCharacters(); +})(\(selector), '\(textToType)', \(shouldReplaceCurrentText ? "true" : "false")); +""" + } +} diff --git a/detox/ios/Detox/Invocation/WebCodeBuilder.swift b/detox/ios/Detox/Invocation/WebCodeBuilder.swift new file mode 100644 index 0000000000..9ca5408517 --- /dev/null +++ b/detox/ios/Detox/Invocation/WebCodeBuilder.swift @@ -0,0 +1,81 @@ +// +// WebCodeBuilder.swift (Detox) +// Created by Asaf Korem (Wix.com) on 2024. +// + +/// Responsible for building the JavaScript code that will be evaluated on a web view. +class WebCodeBuilder { + private var predicate: WebPredicate? + private var expectation: WebExpectationType? + private var expectationParams: [String]? + private var expectationModifiers: [WebModifier]? + private var index: Int? + private var action: WebActionType? + private var actionParams: [Any]? + + func with(predicate: WebPredicate, atIndex: Int?) -> WebCodeBuilder { + self.predicate = predicate + self.index = atIndex + + return self + } + + func with( + expectation: WebExpectationType, + params: [String]?, + modifiers: [WebModifier]? + ) -> WebCodeBuilder { + self.expectation = expectation + self.expectationParams = params + self.expectationModifiers = modifiers + + return self + } + + func with(action: WebActionType, params: [Any]?) -> WebCodeBuilder { + self.action = action + self.actionParams = params + + return self + } + + func build() throws -> String { + guard let predicate = predicate else { + return "return false;" + } + + let selector = createSelector(forType: predicate.type, value: predicate.value, index: index) + + if let expectation = expectation { + let expectationScript = createExpectation(expectation: expectation, params: expectationParams, modifiers: expectationModifiers); + + return "(() => {" + + "const truncateString = (string = '', maxLength = 124) =>" + + "string.length > maxLength ? `${string.substring(0, maxLength)}…`: string;" + + "let element = \(selector);" + + "let result = \(expectationScript);" + + "let elementInfo = element ? {" + + "'html': truncateString(element.outerHTML)," + + "'value': element.value, " + + "'textContent': element.textContent," + + "'innerText': element.innerText" + + "} : null;" + + "return {'result': result, 'element': elementInfo};" + + "})();" + } else if let action = action { + let actionScript = try createAction( + forAction: action, params: actionParams, onSelector: selector) + + return "(() => {" + + "try {" + + "const result = \(actionScript)" + + "return {'result': result};" + + "} catch (error) {" + + "return {'error': error.message};" + + "}" + + "})();" + } else { + dtx_fatalError("No expectation or action was set") + } + } +} diff --git a/detox/ios/Detox/Invocation/WebExpectation.swift b/detox/ios/Detox/Invocation/WebExpectation.swift new file mode 100644 index 0000000000..c784267f57 --- /dev/null +++ b/detox/ios/Detox/Invocation/WebExpectation.swift @@ -0,0 +1,69 @@ +// +// WebExpectation.swift (Detox) +// Created by Asaf Korem (Wix.com) on 2024. +// + +import WebKit + +/// The expectation type to evaluate on a web view. +class WebExpectation: WebInteraction { + var webModifiers: [WebModifier]? + var webExpectation: WebExpectationType + var params: [String]? + + override init(json: [String: Any]) throws { + self.webExpectation = WebExpectationType(rawValue: json["webExpectation"] as! String)! + self.params = json["params"] as? [String] + self.webModifiers = (json["webModifiers"] as? [String])?.compactMap { WebModifier(rawValue: $0) } + try super.init(json: json) + } + + override var description: String { + return "WebExpectation: \(webExpectation.rawValue)" + } + + func evaluate(completionHandler: @escaping (Error?) -> Void) { + var jsString: String + var webView: WKWebView + + do { + jsString = try WebCodeBuilder() + .with(predicate: webPredicate, atIndex: webAtIndex) + .with( + expectation: webExpectation, params: params, modifiers: webModifiers) + .build() + + webView = try WKWebView.findView(by: predicate, atIndex: atIndex) + } catch { + completionHandler(error) + return + } + + webView.evaluateJSAfterLoading(jsString) { [self] (result, error) in + let valueResult = (result as? [String: Any])?["result"] + let elementResult = (result as? [String: Any])?["element"] + let elementInfo: String = + elementResult != nil ? + "info: `\(String(describing: elementResult!))`" : + "not found" + + if let error = error { + completionHandler(dtx_errorForFatalError( + "Failed to evaluate JavaScript on web view: \(webView.debugDescription). " + + "Error: \(error.localizedDescription)")) + } else if valueResult as? Bool != true { + completionHandler(dtx_errorForFatalError( + "Failed on web expectation: \(webModifiers?.description.uppercased() ?? "") " + + "\(webExpectation.rawValue.uppercased()) " + + "with params \(params?.description ?? "") " + + "on element with \(webPredicate.type.rawValue.uppercased()) == " + + "'\(webPredicate.value)', web-view: \(webView.debugDescription). " + + "Got evaluation result: " + + "\(valueResult as? Bool == false ? "false" : String(describing: valueResult)). " + + "Element \(elementInfo)")) + } else { + completionHandler(nil) + } + } + } +} diff --git a/detox/ios/Detox/Invocation/WebExpectationModifier.swift b/detox/ios/Detox/Invocation/WebExpectationModifier.swift new file mode 100644 index 0000000000..640caa18d9 --- /dev/null +++ b/detox/ios/Detox/Invocation/WebExpectationModifier.swift @@ -0,0 +1,8 @@ +// +// WebExpectationModifier.swift (Detox) +// Created by Asaf Korem (Wix.com) on 2024. +// + +enum WebModifier: String, Codable { + case not = "not" +} diff --git a/detox/ios/Detox/Invocation/WebExpectationType.swift b/detox/ios/Detox/Invocation/WebExpectationType.swift new file mode 100644 index 0000000000..70d661497b --- /dev/null +++ b/detox/ios/Detox/Invocation/WebExpectationType.swift @@ -0,0 +1,9 @@ +// +// WebExpectationType.swift (Detox) +// Created by Asaf Korem (Wix.com) on 2024. +// + +enum WebExpectationType: String, Codable { + case toExist = "toExist" + case toHaveText = "toHaveText" +} diff --git a/detox/ios/Detox/Invocation/WebInteraction.swift b/detox/ios/Detox/Invocation/WebInteraction.swift new file mode 100644 index 0000000000..95ecd634de --- /dev/null +++ b/detox/ios/Detox/Invocation/WebInteraction.swift @@ -0,0 +1,42 @@ +// +// WebInteraction.swift (Detox) +// Created by Asaf Korem (Wix.com) on 2024. +// + +import WebKit + +/// Represents a web interaction base class. +class WebInteraction: CustomStringConvertible { + var predicate: Predicate? + var atIndex: Int? + var webPredicate: WebPredicate + var webAtIndex: Int? + + init(json: [String: Any]) throws { + let webPredicateJSON = json["webPredicate"] as? [String: Any] + + guard + let webPredicateJSON = webPredicateJSON, + let webPredicateData = + try? JSONSerialization.data(withJSONObject: webPredicateJSON), + let decodedWebPredicate = + try? JSONDecoder().decode(WebPredicate.self, from: webPredicateData) + else { + throw dtx_errorForFatalError( + "Failed to decode WebPredicate \(String(describing: webPredicateJSON))") + } + + self.webPredicate = decodedWebPredicate + if let predicateJSON = json["predicate"] as? [String: Any] { + self.predicate = try Predicate.with(dictionaryRepresentation: predicateJSON) + } + + self.atIndex = json["atIndex"] as? Int + self.webAtIndex = json["webAtIndex"] as? Int + } + + var description: String { + return "WebInteraction" + } +} + diff --git a/detox/ios/Detox/Invocation/WebPredicate.swift b/detox/ios/Detox/Invocation/WebPredicate.swift new file mode 100644 index 0000000000..48689217a5 --- /dev/null +++ b/detox/ios/Detox/Invocation/WebPredicate.swift @@ -0,0 +1,9 @@ +// +// WebPredicate.swift (Detox) +// Created by Asaf Korem (Wix.com) on 2024. +// + +class WebPredicate: Codable { + let type: WebPredicateType + let value: String +} diff --git a/detox/ios/Detox/Invocation/WebPredicateType.swift b/detox/ios/Detox/Invocation/WebPredicateType.swift new file mode 100644 index 0000000000..e30992b429 --- /dev/null +++ b/detox/ios/Detox/Invocation/WebPredicateType.swift @@ -0,0 +1,18 @@ +// +// WebPredicateType.swift (Detox) +// Created by Asaf Korem (Wix.com) on 2024. +// + +enum WebPredicateType: String, Codable { + case id = "id" + case className = "class" + case cssSelector = "css" + case name = "name" + case xpath = "xpath" + case href = "href" + case hrefContains = "hrefContains" + case tag = "tag" + case label = "label" + case value = "value" + case accessibilityType = "accessibilityType" +} diff --git a/detox/src/android/AndroidExpect.test.js b/detox/src/android/AndroidExpect.test.js index 667a931179..dd5014c020 100644 --- a/detox/src/android/AndroidExpect.test.js +++ b/detox/src/android/AndroidExpect.test.js @@ -378,7 +378,6 @@ describe('AndroidExpect', () => { }); describe('web', () => { - it('default', async () => { await e.web.element(e.by.web.id('id')).tap(); }); @@ -402,6 +401,10 @@ describe('AndroidExpect', () => { jestExpect(() => 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(); + }); + 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(); diff --git a/detox/src/android/core/WebElement.js b/detox/src/android/core/WebElement.js index fd2d00bdf3..7d41b8ddd6 100644 --- a/detox/src/android/core/WebElement.js +++ b/detox/src/android/core/WebElement.js @@ -22,6 +22,7 @@ class WebElement { this[_invocationManager] = invocationManager; this[_webMatcher] = webMatcher; this[_webViewElement] = webViewElement; + this.atIndex(0); } @@ -33,64 +34,70 @@ class WebElement { return this; } - // At the moment not working on content-editable + async executeAction(action) { + const result = await new ActionInteraction(this[_invocationManager], action).execute(); + // Workaround since Detox doesn't wait for the action to complete. + await new Promise(resolve => setTimeout(resolve, 500)); + return result; + } + async tap() { - return await new ActionInteraction(this[_invocationManager], new actions.WebTapAction(this)).execute(); + return await this.executeAction(new actions.WebTapAction(this)); } async typeText(text, isContentEditable = false) { if (isContentEditable) { return await this[_device]._typeText(text); } - return await new ActionInteraction(this[_invocationManager], new actions.WebTypeTextAction(this, text)).execute(); + return await this.executeAction(new actions.WebTypeTextAction(this, text)); } // At the moment not working on content-editable async replaceText(text) { - return await new ActionInteraction(this[_invocationManager], new actions.WebReplaceTextAction(this, text)).execute(); + return await this.executeAction(new actions.WebReplaceTextAction(this, text)); } // At the moment not working on content-editable async clearText() { - return await new ActionInteraction(this[_invocationManager], new actions.WebClearTextAction(this)).execute(); + return await this.executeAction(new actions.WebClearTextAction(this)); } async scrollToView() { - return await new ActionInteraction(this[_invocationManager], new actions.WebScrollToViewAction(this)).execute(); + return await this.executeAction(new actions.WebScrollToViewAction(this)); } async getText() { - return await new ActionInteraction(this[_invocationManager], new actions.WebGetTextAction(this)).execute(); + return await this.executeAction(new actions.WebGetTextAction(this)); } async focus() { - return await new ActionInteraction(this[_invocationManager], new actions.WebFocusAction(this)).execute(); + return await this.executeAction(new actions.WebFocusAction(this)); } async selectAllText() { - return await new ActionInteraction(this[_invocationManager], new actions.WebSelectAllText(this)).execute(); + return await this.executeAction(new actions.WebSelectAllText(this)); } async moveCursorToEnd() { - return await new ActionInteraction(this[_invocationManager], new actions.WebMoveCursorEnd(this)).execute(); + return await this.executeAction(new actions.WebMoveCursorEnd(this)); } async runScript(maybeFunction, args) { const script = stringifyScript(maybeFunction); if (args) { - return await new ActionInteraction(this[_invocationManager], new actions.WebRunScriptWithArgsAction(this, script, args)).execute(); + return await this.executeAction(new actions.WebRunScriptWithArgsAction(this, script, args)); } else { - return await new ActionInteraction(this[_invocationManager], new actions.WebRunScriptAction(this, script)).execute(); + return await this.executeAction(new actions.WebRunScriptAction(this, script)); } } async getCurrentUrl() { - return await new ActionInteraction(this[_invocationManager], new actions.WebGetCurrentUrlAction(this)).execute(); + return await this.executeAction(new actions.WebGetCurrentUrlAction(this)); } async getTitle() { - return await new ActionInteraction(this[_invocationManager], new actions.WebGetTitleAction(this)).execute(); + return await this.executeAction(new actions.WebGetTitleAction(this)); } } @@ -105,7 +112,7 @@ class WebViewElement { this._call = invoke.callDirectly(EspressoWebDetoxApi.getWebView(matcher._call.value)); } else { this._call = invoke.callDirectly(EspressoWebDetoxApi.getWebView()); - } + } this.element = this.element.bind(this); } @@ -122,6 +129,11 @@ class WebViewElement { throw new DetoxRuntimeError(`element() argument is invalid, expected a web matcher, but got ${typeof webMatcher}`); } + + atIndex(_index) { + // Not implemented yet + throw new Error('atIndex() is not supported for Android WebViewElement'); + } } function stringifyScript(maybeFunction) { diff --git a/detox/src/ios/expectTwo.js b/detox/src/ios/expectTwo.js index f89b7f26ae..c89b497ba9 100644 --- a/detox/src/ios/expectTwo.js +++ b/detox/src/ios/expectTwo.js @@ -6,6 +6,7 @@ const fs = require('fs-extra'); const _ = require('lodash'); const tempfile = require('tempfile'); + const { assertEnum, assertNormalized } = require('../utils/assertArgument'); const { removeMilliseconds } = require('../utils/dateUtils'); const { actionDescription, expectDescription } = require('../utils/invocationTraceDescriptions'); @@ -13,6 +14,8 @@ const { isRegExp } = require('../utils/isRegExp'); const log = require('../utils/logger').child({ cat: 'ws-client, ws' }); const traceInvocationCall = require('../utils/traceInvocationCall').bind(null, log); +const { webElement, webMatcher, webExpect, isWebElement } = require('./web'); + const assertDirection = assertEnum(['left', 'right', 'up', 'down']); const assertSpeed = assertEnum(['fast', 'slow']); @@ -26,7 +29,7 @@ class Expect { 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 ' - + (percent + (' (' + (typeof percent + ')')))); + + (percent + (' (' + (typeof percent + ')')))); } const traceDescription = expectDescription.toBeVisible(percent); @@ -170,7 +173,7 @@ class Element { } longPressAndDrag(duration, normalizedPositionX, normalizedPositionY, targetElement, - normalizedTargetPositionX = NaN, normalizedTargetPositionY = NaN, speed = 'fast', holdDuration = 1000) { + normalizedTargetPositionX = NaN, normalizedTargetPositionY = NaN, speed = 'fast', holdDuration = 1000) { if (typeof duration !== 'number') throw new Error('duration should be a number, but got ' + (duration + (' (' + (typeof duration + ')')))); if (!(targetElement instanceof Element)) throwElementError(targetElement); @@ -403,7 +406,7 @@ class By { } get web() { - throw new Error('Detox does not support by.web matchers on iOS.'); + return webMatcher(); } } @@ -758,7 +761,7 @@ class IosExpect { this.waitFor = this.waitFor.bind(this); this.by = new By(); this.web = this.web.bind(this); - this.web.element = this.web; + this.web.element = this.web().element; } element(matcher) { @@ -766,6 +769,10 @@ class IosExpect { } expect(element) { + if (isWebElement(element)) { + return webExpect(this._invocationManager, element); + } + return expect(this._invocationManager, element); } @@ -773,8 +780,23 @@ class IosExpect { return waitFor(this._invocationManager, this._emitter, element); } - web(_matcher) { - throw new Error('Detox does not support web(), web.element() API on iOS.'); + web(matcher) { + return { + atIndex: index => { + if (typeof index !== 'number' || index < 0) throw new Error('index should be an integer, got ' + (index + (' (' + (typeof index + ')')))); + if (!(matcher instanceof Matcher)) throw new Error('cannot apply atIndex to a non-matcher'); + matcher.index = index; + return this.web(matcher); + }, + element: webMatcher => { + if (!(matcher instanceof Matcher) && matcher !== undefined) { + throwMatcherError(matcher); + } + + const webViewElement = matcher ? element(this._invocationManager, this._emitter, matcher) : undefined; + return webElement(this._invocationManager, this._emitter, webViewElement, webMatcher); + } + }; } } diff --git a/detox/src/ios/expectTwo.test.js b/detox/src/ios/expectTwo.test.js index 85a007d246..eb78f66b4b 100644 --- a/detox/src/ios/expectTwo.test.js +++ b/detox/src/ios/expectTwo.test.js @@ -25,7 +25,7 @@ describe('expectTwo', () => { }); }); - it(`should produce correct JSON for tap action`, async () => { + it(`should parse correct JSON for tap action`, async () => { const testCall = await e.element(e.by.text('tapMe')).tap(); const jsonOutput = { invocation: { @@ -42,7 +42,7 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); - it(`should produce correct JSON for RegExp text matcher`, async () => { + it(`should parse correct JSON for RegExp text matcher`, async () => { const testCall = await e.element(e.by.text(/tapMe/)).tap(); const jsonOutput = { invocation: { @@ -59,7 +59,7 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); - it(`should produce correct JSON for tap action with parameters`, async () => { + it(`should parse correct JSON for tap action with parameters`, async () => { const testCall = await e.element(e.by.text('tapMe')).tap({ x: 1, y: 2 }); const jsonOutput = { invocation: { @@ -82,7 +82,7 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); - it(`should produce correct JSON for element with id and text matchers`, async () => { + it(`should parse correct JSON for element with id and text matchers`, async () => { const testCall = await e.element(e.by.id('uniqueId').and(e.by.text('some text'))).tap(); const jsonOutput = { invocation: { @@ -109,7 +109,7 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); - it(`should produce correct JSON for element with RegExp id and text matchers`, async () => { + it(`should parse correct JSON for element with RegExp id and text matchers`, async () => { const testCall = await e.element(e.by.id(/uniqueId/).and(e.by.text(/some text/))).tap(); const jsonOutput = { invocation: { @@ -136,7 +136,7 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); - it(`should produce correct JSON for element with ancestor matcher`, async () => { + it(`should parse correct JSON for element with ancestor matcher`, async () => { const testCall = await e.element(e.by.id('child').withAncestor(e.by.id('parent'))).tap(); const jsonOutput = { invocation: { @@ -166,7 +166,7 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); - it(`should produce correct JSON for element with regex ancestor matcher`, async () => { + it(`should parse correct JSON for element with regex ancestor matcher`, async () => { const testCall = await e.element(e.by.id('child').withAncestor(e.by.id(/parent/))).tap(); const jsonOutput = { invocation: { @@ -196,7 +196,7 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); - it(`should produce correct JSON for element with regex label`, async () => { + it(`should parse correct JSON for element with regex label`, async () => { const testCall = await e.element(e.by.label(/tapMe/)).tap(); const jsonOutput = { invocation: { @@ -217,7 +217,7 @@ describe('expectTwo', () => { ['withAncestor'], ['withDescendant'], ['and'], - ])(`should produce immutable objects when combining matchers: %s`, async (combineMethodName) => { + ])(`should parse immutable objects when combining matchers: %s`, async (combineMethodName) => { const base = e.by.id('abc'); const modifier = e.by.id('def'); @@ -226,7 +226,7 @@ describe('expectTwo', () => { expect(modifier).toEqual(e.by.id('def')); }); - it(`should produce correct JSON for element with ancestor and index matchers`, async () => { + it(`should parse correct JSON for element with ancestor and index matchers`, async () => { const testCall = await e.element(e.by.id('child').withAncestor(e.by.id('parent'))).atIndex(0).tap(); const jsonOutput = { invocation: { @@ -256,7 +256,7 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); - it(`should produce correct JSON for element with ancestor and test matchers`, async () => { + it(`should parse correct JSON for element with ancestor and test matchers`, async () => { const testCall = await e.element(e.by.id('child').withAncestor(e.by.id('parent').and(e.by.text('text')))).tap(); const jsonOutput = { invocation: { @@ -296,7 +296,7 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); - it(`should produce correct JSON for element with id, text and value matchers`, async () => { + it(`should parse correct JSON for element with id, text and value matchers`, async () => { const testCall = await e.element(e.by.id('child').and(e.by.text('text').and(e.by.value('value')))).tap(); const jsonOutput = { invocation: { @@ -327,7 +327,7 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); - it(`should produce correct JSON for tap at point action`, async () => { + it(`should parse correct JSON for tap at point action`, async () => { const testCall = await e.element(e.by.id('tappable')).tapAtPoint({ x: 5, y: 10 }); const jsonOutput = { invocation: { @@ -350,7 +350,7 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); - it(`should produce correct JSON for long-press and drag action`, async () => { + it(`should parse correct JSON for long-press and drag action`, async () => { const testCall = await e.element(e.by.id('elementToDrag')).longPressAndDrag(1000, 0.5, 0.5, e.element(e.by.id('targetElement'))); const jsonOutput = { invocation: { @@ -374,7 +374,7 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); - it(`should produce correct JSON for visibility expectation`, async () => { + it(`should parse correct JSON for visibility expectation`, async () => { const testCall = await e.expect(e.element(e.by.text('Tap Working!!!'))).toBeVisible(); const jsonOutput = { invocation: { @@ -391,7 +391,7 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); - it(`should produce correct JSON for toBeVisible expectation with parameter`, async () => { + it(`should parse correct JSON for toBeVisible expectation with parameter`, async () => { const testCall = await e.expect(e.element(e.by.id('foo'))).toBeVisible(25); const jsonOutput = { invocation: { @@ -409,7 +409,7 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); - it(`should produce correct JSON for toBeNotVisible expectation`, async () => { + it(`should parse correct JSON for toBeNotVisible expectation`, async () => { const testCall = await e.expect(e.element(e.by.text('Tap Working!!!'))).toBeNotVisible(); const jsonOutput = { invocation: { @@ -427,7 +427,7 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); - it(`should produce correct JSON for toBeFocused expectation`, async () => { + it(`should parse correct JSON for toBeFocused expectation`, async () => { const testCall = await e.expect(e.element(e.by.text('Tap Working!!!'))).toBeFocused(); const jsonOutput = { invocation: { @@ -444,7 +444,7 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); - it(`should produce correct JSON for notToBeFocused expectation`, async () => { + it(`should parse correct JSON for notToBeFocused expectation`, async () => { const testCall = await e.expect(e.element(e.by.text('Tap Working!!!'))).toBeNotFocused(); const jsonOutput = { invocation: { @@ -462,7 +462,7 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); - it(`should produce correct JSON for toHaveText expectation`, async () => { + it(`should parse correct JSON for toHaveText expectation`, async () => { const testCall = await e.expect(e.element(e.by.id('UniqueId204'))).toHaveText('I contain some text'); const jsonOutput = { invocation: { @@ -480,7 +480,7 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); - it(`should produce correct JSON for toHaveId expectation`, async () => { + it(`should parse correct JSON for toHaveId expectation`, async () => { const testCall = await e.expect(e.element(e.by.text('Product')).atIndex(2)).toHaveId('ProductId002'); const jsonOutput = { 'invocation': { @@ -499,7 +499,7 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); - it(`should produce correct JSON for toHaveSliderPosition expectation`, async () => { + it(`should parse correct JSON for toHaveSliderPosition expectation`, async () => { const testCall = await e.expect(e.element(e.by.id('slider'))).toHaveSliderPosition(0.5, 1); const jsonOutput = { 'invocation': { @@ -517,7 +517,7 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); - it(`should produce correct JSON for toHaveToggleValue expectation`, async () => { + it(`should parse correct JSON for toHaveToggleValue expectation`, async () => { const testCall = await e.expect(e.element(e.by.id('switch'))).toHaveToggleValue(true); const jsonOutput = { 'invocation': { @@ -535,7 +535,7 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); - it(`should produce correct JSON for swipe action`, async () => { + it(`should parse correct JSON for swipe action`, async () => { const testCall = await e.element(e.by.id('ScrollView100')).swipe('up', 'fast', undefined, undefined, 0.5); const jsonOutput = { invocation: { @@ -582,7 +582,7 @@ describe('expectTwo', () => { }); describe(`waitFor`, () => { - it(`should produce correct JSON for toBeNotVisible expectation`, async () => { + it(`should parse correct JSON for toBeNotVisible expectation`, async () => { const testCall = await e.waitFor(e.element(e.by.text('Text5'))).toBeNotVisible().whileElement(e.by.id('ScrollView630')).scroll(50, 'down'); const jsonOutput = { invocation: { @@ -610,7 +610,7 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); - it(`should produce correct JSON for toExist expectation`, async () => { + it(`should parse correct JSON for toExist expectation`, async () => { const testCall = await e.waitFor(e.element(e.by.id('createdAndVisibleText'))).toExist().withTimeout(2000); const jsonOutput = { invocation: @@ -629,7 +629,7 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); - it(`should produce correct JSON for text and index matchers`, async () => { + it(`should parse correct JSON for text and index matchers`, async () => { const testCall = await e.waitFor(e.element(e.by.text('Item')).atIndex(1)).toExist().withTimeout(2000); const jsonOutput = { invocation: @@ -649,7 +649,7 @@ describe('expectTwo', () => { expect(testCall).toDeepEqual(jsonOutput); }); - it(`should produce correct JSON for toBeNotVisible expectation`, async () => { + it(`should parse correct JSON for toBeNotVisible expectation`, async () => { const testCall = await e.waitFor(e.element(e.by.id('uniqueId'))).not.toBeVisible().withTimeout(2000); const jsonOutput = { invocation: { @@ -734,18 +734,6 @@ describe('expectTwo', () => { jestExpect(() => e.waitFor(stubMatcher).toBeVisible(101)).toThrow(expectedErrorMsg); }); - it('by.web should throw', async () => { - expect(() => e.by.web).toThrowError(/not support/); - }); - - it('web() should throw', async () => { - expect(() => e.web(e.by.id('someId'))).toThrowError(/not support/); - }); - - it('web.element() should throw', async () => { - expect(() => e.web.element(e.by.id('someId'))).toThrowError(/not support/); - }); - it(`element(e.by.text('tapMe')).performAccessibilityAction('activate')`, async () => { const testCall = await e.element(e.by.text('tapMe')).performAccessibilityAction('activate'); const jsonOutput = { @@ -765,6 +753,388 @@ describe('expectTwo', () => { 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(); + + const jsonOutput = { + invocation: { + type: 'webExpectation', + webExpectation: 'toExist', + predicate: { + type: 'id', + value: 'webViewId', + isRegex: false + }, + atIndex: 1, + webPredicate: { + type: 'label', + value: 'tapMe' + }, + webAtIndex: 2 + } + }; + + expect(testCall).toDeepEqual(jsonOutput); + }); + + it(`should parse web(by.id('webViewId').element(web(by.label('tapMe')))).not.toHaveText('Hey')`, async () => { + const testCall = await e.expect(e.web(e.by.id('webViewId')).element(e.by.web.label('tapMe'))).not.toHaveText('Hey'); + + const jsonOutput = { + invocation: { + type: 'webExpectation', + webExpectation: 'toHaveText', + params: ['Hey'], + predicate: { + type: 'id', + value: 'webViewId', + isRegex: false + }, + webModifiers: ['not'], + webPredicate: { + type: 'label', + value: 'tapMe' + } + } + }; + + expect(testCall).toDeepEqual(jsonOutput); + }); + + it('should throw when passing non-web-element matcher to element()', async () => { + 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); + }); + + it('should throw when not passing matcher to web()', async () => { + const expectedErrorMsg = 'invalid is not a Detox matcher'; + jestExpect(() => e.web('invalid').element(e.by.label('tapMe')).toExist()).toThrow(expectedErrorMsg); + }); + + it('should throw when passing at-index to a non-matcher', async () => { + const expectedErrorMsg = 'cannot apply atIndex to a non-matcher'; + jestExpect(() => e.web('invalid').atIndex(1).element(e.by.web.label('tapMe')).toExist()).toThrow(expectedErrorMsg); + }); + + it(`should parse web(by.id('webViewId')).atIndex(2).element(web.by.label('tapMe')).atIndex(1).clearText()`, async () => { + const testCall = + await e.web(e.by.id('webViewId')).atIndex(2).element(e.by.web.label('tapMe')).atIndex(1).clearText(); + + const jsonOutput = { + invocation: { + type: 'webAction', + webAction: 'clearText', + webAtIndex: 1, + predicate: { + type: 'id', + value: 'webViewId', + isRegex: false + }, + atIndex: 2, + webPredicate: { + type: 'label', + value: 'tapMe' + } + } + }; + + expect(testCall).toDeepEqual(jsonOutput); + }); + + it('should raise on invalid at-index', async () => { + const expectedErrorMsg = 'index should be an integer, got -1 (number)'; + jestExpect(() => e.web(e.by.id('webViewId')).atIndex(-1).element(e.by.web.label('tapMe')).atIndex(1).clearText()).toThrow(expectedErrorMsg); + }); + + it('should raise on invalid web-matcher at-index', async () => { + const expectedErrorMsg = 'index should be an integer, got -1 (number)'; + jestExpect(() => e.web(e.by.id('webViewId')).element(e.by.web.label('tapMe')).atIndex(-1).clearText()).toThrow(expectedErrorMsg); + }); + + it(`should parse web.element(by.web.label('tapMe')).tap()`, async () => { + const testCall = await e.web.element(e.by.web.label('tapMe')).tap(); + + const jsonOutput = { + invocation: { + type: 'webAction', + webAction: 'tap', + webPredicate: { + type: 'label', + value: 'tapMe' + } + } + }; + + expect(testCall).toDeepEqual(jsonOutput); + }); + + it(`should parse web.element(by.web.id('someValue')).typeText('text')`, async () => { + const testCall = await e.web.element(e.by.web.id('someValue')).atIndex(3).typeText('text'); + + const jsonOutput = { + invocation: { + type: 'webAction', + webAction: 'typeText', + webAtIndex: 3, + params: ['text'], + webPredicate: { + type: 'id', + value: 'someValue' + } + } + }; + + expect(testCall).toDeepEqual(jsonOutput); + }); + + it(`should parse web.element(by.web.id('someValue')).typeText('text', true)`, async () => { + const testCall = await e.web.element(e.by.web.id('someValue')).atIndex(3).typeText('text', true); + + const jsonOutput = { + invocation: { + type: 'webAction', + webAction: 'typeText', + webAtIndex: 3, + params: ['text'], + webPredicate: { + type: 'id', + value: 'someValue' + } + } + }; + + expect(testCall).toDeepEqual(jsonOutput); + }); + + it(`should parse web.element(by.web.className('someValue')).replaceText('text')`, async () => { + const testCall = await e.web.element(e.by.web.className('someValue')).replaceText('text'); + + const jsonOutput = { + invocation: { + type: 'webAction', + webAction: 'replaceText', + params: ['text'], + webPredicate: { + type: 'class', + value: 'someValue' + } + } + }; + + expect(testCall).toDeepEqual(jsonOutput); + }); + + it(`should parse web.element(by.web.cssSelector('someValue')).focus()`, async () => { + const testCall = await e.web.element(e.by.web.cssSelector('someValue')).focus(); + + const jsonOutput = { + invocation: { + type: 'webAction', + webAction: 'focus', + webPredicate: { + type: 'css', + value: 'someValue' + } + } + }; + + expect(testCall).toDeepEqual(jsonOutput); + }); + + it(`should parse web.element(by.web.xpath('someValue')).getCurrentUrl()`, async () => { + const testCall = await e.web.element(e.by.web.xpath('someValue')).getCurrentUrl(); + + const jsonOutput = { + invocation: { + type: 'webAction', + webAction: 'getCurrentUrl', + webPredicate: { + type: 'xpath', + value: 'someValue' + } + } + }; + + expect(testCall).toDeepEqual(jsonOutput); + }); + + it(`should parse web.element(by.web.name('someValue')).getText()`, async () => { + const testCall = await e.web.element(e.by.web.name('someValue')).getText(); + + const jsonOutput = { + invocation: { + type: 'webAction', + webAction: 'getText', + webPredicate: { + type: 'name', + value: 'someValue' + } + } + }; + + expect(testCall).toDeepEqual(jsonOutput); + }); + + it(`should parse web.element(by.web.href('someValue')).getTitle()`, async () => { + const testCall = await e.web.element(e.by.web.href('someValue')).getTitle(); + + const jsonOutput = { + invocation: { + type: 'webAction', + webAction: 'getTitle', + webPredicate: { + type: 'href', + value: 'someValue' + } + } + }; + + expect(testCall).toDeepEqual(jsonOutput); + }); + + it(`should parse web.element(by.web.hrefContains('someValue')).moveCursorToEnd()`, async () => { + const testCall = await e.web.element(e.by.web.hrefContains('someValue')).moveCursorToEnd(); + + const jsonOutput = { + invocation: { + type: 'webAction', + webAction: 'moveCursorToEnd', + webPredicate: { + type: 'hrefContains', + value: 'someValue' + } + } + }; + + expect(testCall).toDeepEqual(jsonOutput); + }); + + it(`should parse web.element(by.web.tag('someValue')).runScript('script')`, async () => { + const testCall = await e.web.element(e.by.web.tag('someValue')).runScript('script'); + + const jsonOutput = { + invocation: { + type: 'webAction', + webAction: 'runScript', + params: ['script'], + webPredicate: { + type: 'tag', + value: 'someValue' + } + } + }; + + expect(testCall).toDeepEqual(jsonOutput); + }); + + it(`should parse web.element(by.web.value('someValue')).runScript(() => {}, ['arg'])`, async () => { + const testCall = await e.web.element(e.by.web.value('someValue')).runScript(() => {}, ['arg']); + + const jsonOutput = { + invocation: { + type: 'webAction', + webAction: 'runScriptWithArgs', + params: ['() => {}', ['arg']], + webPredicate: { + type: 'value', + value: 'someValue' + } + } + }; + + expect(testCall).toDeepEqual(jsonOutput); + }); + + it(`should parse web.element(by.web.value('someValue')).runScript('() => {}', ['arg'])`, async () => { + const testCall = await e.web.element(e.by.web.value('someValue')).runScript('() => {}', ['arg']); + + const jsonOutput = { + invocation: { + type: 'webAction', + webAction: 'runScriptWithArgs', + params: ['() => {}', ['arg']], + webPredicate: { + type: 'value', + value: 'someValue' + } + } + }; + + expect(testCall).toDeepEqual(jsonOutput); + }); + + it(`should parse web.element(by.web.accessibilityType('someValue')).selectAllText()`, async () => { + const testCall = await e.web.element(e.by.web.accessibilityType('someValue')).selectAllText(); + + const jsonOutput = { + invocation: { + type: 'webAction', + webAction: 'selectAllText', + webPredicate: { + type: 'accessibilityType', + value: 'someValue' + } + } + }; + + expect(testCall).toDeepEqual(jsonOutput); + }); + + it(`should parse web.element(by.web.id('webViewId')).scrollToView()`, async () => { + const testCall = await e.web.element(e.by.web.id('webViewId')).scrollToView(); + + const jsonOutput = { + invocation: { + type: 'webAction', + webAction: 'scrollToView', + webPredicate: { + type: 'id', + value: 'webViewId' + } + } + }; + + expect(testCall).toDeepEqual(jsonOutput); + }); + + it('should throw when invocation returns an error', async () => { + invocationManager.execute.mockResolvedValueOnce({ + error: 'some error' + }); + + await expect(() => e.web.element(e.by.web.id('uniqueId')).getTitle()).rejects.toThrow('some error'); + }); + + it('should extract return value (`return`) when exists on getter', async () => { + invocationManager.execute.mockResolvedValueOnce({ + result: 'some result' + }); + + const result = await e.web.element(e.by.web.id('uniqueId')).getTitle(); + expect(result).toBe('some result'); + }); + + it('should extract return value (`title`) when exists on getter', async () => { + invocationManager.execute.mockResolvedValueOnce({ + title: 'some result' + }); + + const result = await e.web.element(e.by.web.id('uniqueId')).getTitle(); + expect(result).toBe('some result'); + }); + + it('should return undefined value when no return value exists and undefined allowed', async () => { + invocationManager.execute.mockResolvedValueOnce({}); + + const result = await e.web.element(e.by.web.id('uniqueId')).runScript(() => {}); + expect(result).toBe(undefined); + }); + }); }); expect.extend({ diff --git a/detox/src/ios/expectTwoApiCoverage.test.js b/detox/src/ios/expectTwoApiCoverage.test.js index 442366d75b..49bf9bbbd8 100644 --- a/detox/src/ios/expectTwoApiCoverage.test.js +++ b/detox/src/ios/expectTwoApiCoverage.test.js @@ -78,6 +78,19 @@ describe('expectTwo API Coverage', () => { await expectToThrow(() => e.element(e.by.id('test').withAncestor('notAMatcher'))); await expectToThrow(() => e.element(e.by.id('test').withDescendant('notAMatcher'))); // await expectToThrow(() => e.element(e.by.id('test').and('notAMatcher'))); + + // Web matchers + await expectToThrow(() => e.web.element(e.by.web.id(1))); + await expectToThrow(() => e.web.element(e.by.web.label(1))); + await expectToThrow(() => e.web.element(e.by.web.className(1))); + await expectToThrow(() => e.web.element(e.by.web.cssSelector(1))); + await expectToThrow(() => e.web.element(e.by.web.name(1))); + await expectToThrow(() => e.web.element(e.by.web.xpath(1))); + await expectToThrow(() => e.web.element(e.by.web.href(1))); + await expectToThrow(() => e.web.element(e.by.web.hrefContains(1))); + await expectToThrow(() => e.web.element(e.by.web.tag(1))); + await expectToThrow(() => e.web.element(e.by.web.value(1))); + await expectToThrow(() => e.web.element(e.by.web.accessibilityType(1))); }); it(`should throw for invalid toBeVisible parameters`, async () => { @@ -96,6 +109,8 @@ describe('expectTwo API Coverage', () => { it(`expect with wrong parameters should throw`, async () => { await expectToThrow(() => e.expect('notAnElement')); await expectToThrow(() => e.expect(e.element('notAMatcher'))); + await expectToThrow(() => e.expect(e.web.element('notAMatcher'))); + await expectToThrow(() => e.expect(e.web.element(e.by.web.id('id'))).toHaveText(0)); }); }); diff --git a/detox/src/ios/web.js b/detox/src/ios/web.js new file mode 100644 index 0000000000..aabd4a4579 --- /dev/null +++ b/detox/src/ios/web.js @@ -0,0 +1,293 @@ +const assert = require('assert'); + +const _ = require('lodash'); + +const { DetoxRuntimeError } = require('../errors'); +const { webViewActionDescription, expectDescription } = require('../utils/invocationTraceDescriptions'); +const log = require('../utils/logger').child({ cat: 'ws-client, ws' }); +const traceInvocationCall = require('../utils/traceInvocationCall').bind(null, log); + + +class WebExpect { + constructor(invocationManager, element) { + this._invocationManager = invocationManager; + this.element = element; + this.modifiers = []; + } + + toHaveText(text) { + if (typeof text !== 'string') throw new DetoxRuntimeError('text should be a string, but got ' + (text + (' (' + (typeof text + ')')))); + const traceDescription = expectDescription.toHaveText(text); + return this.expect('toHaveText', traceDescription, text); + } + + toExist() { + const traceDescription = expectDescription.toExist(); + return this.expect('toExist', traceDescription); + } + + get not() { + this.modifiers.push('not'); + return this; + } + + createInvocation(webExpectation, ...params) { + const definedParams = _.without(params, undefined); + return { + type: 'webExpectation', + ...(this.element.webViewElement !== undefined) && { + predicate: this.element.webViewElement.matcher.predicate, + ...(this.element.webViewElement.matcher.index !== undefined && { atIndex: this.element.webViewElement.matcher.index }), + }, + webPredicate: this.element.matcher.predicate, + ...(this.element.index !== undefined && { webAtIndex: this.element.index }), + ...(this.modifiers.length !== 0 && { webModifiers: this.modifiers }), + webExpectation, + ...(definedParams.length !== 0 && { params: definedParams }) + }; + } + + expect(expectation, traceDescription, ...params) { + assert(traceDescription, `must provide trace description for expectation: \n ${JSON.stringify(expectation)}`); + + const invocation = this.createInvocation(expectation, ...params); + traceDescription = expectDescription.full(traceDescription, this.modifiers.includes('not')); + return _executeInvocation(this._invocationManager, invocation, traceDescription); + } +} + +class WebElement { + constructor(invocationManager, emitter, webViewElement, matcher, index) { + this._invocationManager = invocationManager; + this._emitter = emitter; + this.webViewElement = webViewElement; + 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 = webViewActionDescription.tap(); + return this.withAction('tap', traceDescription); + } + + typeText(text, isContentEditable = false) { + const traceDescription = webViewActionDescription.typeText(text, isContentEditable); + return this.withAction('typeText', traceDescription, text); + } + + replaceText(text) { + const traceDescription = webViewActionDescription.replaceText(text); + return this.withAction('replaceText', traceDescription, text); + } + + clearText() { + const traceDescription = webViewActionDescription.clearText(); + return this.withAction('clearText', traceDescription); + } + + selectAllText() { + const traceDescription = webViewActionDescription.selectAllText(); + return this.withAction('selectAllText', traceDescription); + } + + async getText() { + const traceDescription = webViewActionDescription.getText(); + let result = await this.withAction('getText', traceDescription); + return this.extractResult(result, { type: 'text' }); + } + + extractResult(result, options) { + // iOS returns the result under `result` key, while Android returns it under the action `type` key. + if (result['error']) { + throw new DetoxRuntimeError(`Error thrown in web action: ${result['error']}`); + } else if (options.type && result[options.type]) { + return result[options.type]; + } else if (result['result']) { + return result['result']; + } else if (options.allowUndefined && Object.keys(result).length === 0) { + return undefined; + } else { + log.warn(`Failed to extract ${options.type ?? 'result'} from result: ${JSON.stringify(result)}`); + return result; + } + } + + scrollToView() { + const traceDescription = webViewActionDescription.scrollToView(); + return this.withAction('scrollToView', traceDescription); + } + + focus() { + const traceDescription = webViewActionDescription.focus(); + return this.withAction('focus', traceDescription); + } + + moveCursorToEnd() { + const traceDescription = webViewActionDescription.moveCursorToEnd(); + return this.withAction('moveCursorToEnd', traceDescription); + } + + async runScript(script, args) { + if (args !== undefined && args.length !== 0) { + return await this.runScriptWithArgs(script, args); + } + + if (typeof script === 'function') { + script = script.toString(); + } + + const traceDescription = webViewActionDescription.runScript(script); + const result = await this.withAction('runScript', traceDescription, script); + return this.extractResult(result, { allowUndefined: true }); + } + + async runScriptWithArgs(script, args) { + if (typeof script === 'function') { + script = script.toString(); + } + + const traceDescription = webViewActionDescription.runScriptWithArgs(script, args); + const result = await this.withAction('runScriptWithArgs', traceDescription, script, args); + return this.extractResult(result, { allowUndefined: true }); + } + + async getCurrentUrl() { + const traceDescription = webViewActionDescription.getCurrentUrl(); + let result = await this.withAction('getCurrentUrl', traceDescription); + return this.extractResult(result, { type: 'url' }); + } + + async getTitle() { + const traceDescription = webViewActionDescription.getTitle(); + let result = await this.withAction('getTitle', traceDescription); + return this.extractResult(result, { type: 'title' }); + } + + withAction(action, traceDescription, ...params) { + assert(traceDescription, `must provide trace description for action: \n ${JSON.stringify(action)}`); + + const invocation = { + type: 'webAction', + ...(this.webViewElement !== undefined) && { + predicate: this.webViewElement.matcher.predicate, + ...(this.webViewElement.matcher.index !== undefined && { atIndex: this.webViewElement.matcher.index }), + }, + webPredicate: this.matcher.predicate, + ...(this.index !== undefined && { webAtIndex: this.index }), + webAction: action, + ...(params.length !== 0 && { params }), + }; + traceDescription = webViewActionDescription.full(traceDescription); + return _executeInvocation(this._invocationManager, invocation, traceDescription); + } +} + +class WebElementMatcher { + id(id) { + if (typeof id !== 'string') throw new DetoxRuntimeError('id should be a string, but got ' + (id + (' (' + (typeof id + ')')))); + this.predicate = { type: 'id', value: id.toString() }; + return this; + } + + className(className) { + if (typeof className !== 'string') throw new DetoxRuntimeError('className should be a string, but got ' + (className + (' (' + (typeof className + ')')))); + this.predicate = { type: 'class', value: className.toString() }; + return this; + } + + cssSelector(cssSelector) { + if (typeof cssSelector !== 'string') throw new DetoxRuntimeError('cssSelector should be a string, but got ' + (cssSelector + (' (' + (typeof cssSelector + ')')))); + this.predicate = { type: 'css', value: cssSelector.toString() }; + return this; + } + + name(name) { + if (typeof name !== 'string') throw new DetoxRuntimeError('name should be a string, but got ' + (name + (' (' + (typeof name + ')')))); + this.predicate = { type: 'name', value: name.toString() }; + return this; + } + + xpath(xpath) { + if (typeof xpath !== 'string') throw new DetoxRuntimeError('xpath should be a string, but got ' + (xpath + (' (' + (typeof xpath + ')')))); + this.predicate = { type: 'xpath', value: xpath.toString() }; + return this; + } + + href(href) { + if (typeof href !== 'string') throw new DetoxRuntimeError('href should be a string, but got ' + (href + (' (' + (typeof href + ')')))); + this.predicate = { type: 'href', value: href.toString() }; + return this; + } + + hrefContains(href) { + if (typeof href !== 'string') throw new DetoxRuntimeError('href should be a string, but got ' + (href + (' (' + (typeof href + ')')))); + this.predicate = { type: 'hrefContains', value: href.toString() }; + return this; + } + + tag(tag) { + if (typeof tag !== 'string') throw new DetoxRuntimeError('tag should be a string, but got ' + (tag + (' (' + (typeof tag + ')')))); + this.predicate = { type: 'tag', value: tag.toString() }; + return this; + } + + 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; + } + + value(value) { + if (typeof value !== 'string') throw new DetoxRuntimeError('value should be a string, but got ' + (value + (' (' + (typeof value + ')')))); + this.predicate = { type: 'value', value: value.toString() }; + return this; + } + + accessibilityType(type) { + if (typeof type !== 'string') throw new DetoxRuntimeError('accessibilityType should be a string, but got ' + (type + (' (' + (typeof type + ')')))); + this.predicate = { type: 'accessibilityType', value: type.toString() }; + return this; + } +} + +function webMatcher() { + return new WebElementMatcher(); +} + +function webElement(invocationManager, emitter, webViewElement, matcher) { + if (!(matcher instanceof WebElementMatcher)) { + throwWebViewMatcherError(matcher); + } + + return new WebElement(invocationManager, emitter, webViewElement, matcher); +} + +function throwWebViewMatcherError(param) { + const paramDescription = JSON.stringify(param); + throw new DetoxRuntimeError(`${paramDescription} is not a Detox web-view matcher. More about web-view matchers here: https://wix.github.io/Detox/docs/api/webviews`); +} + +function webExpect(invocationManager, element) { + return new WebExpect(invocationManager, element); +} + +function _executeInvocation(invocationManager, invocation, traceDescription) { + return traceInvocationCall(traceDescription, invocation, invocationManager.execute(invocation)); +} + +function isWebElement(element) { + return element instanceof WebElement; +} + +module.exports = { + webMatcher, + webElement, + webExpect, + isWebElement +}; diff --git a/detox/src/utils/invocationTraceDescriptions.js b/detox/src/utils/invocationTraceDescriptions.js index 63969bf635..bf9e4302fe 100644 --- a/detox/src/utils/invocationTraceDescriptions.js +++ b/detox/src/utils/invocationTraceDescriptions.js @@ -27,6 +27,22 @@ module.exports = { tapReturnKey: () => 'tap on return key', typeText: (value) => `type input text: "${value}"`, }, + webViewActionDescription: { + tap: () => `tap`, + typeText: (value, isContentEditable) => `type input text: "${value}"${isContentEditable ? ' in content editable' : ''}`, + replaceText: (value) => `replace input text: "${value}"`, + clearText: () => 'clear input text', + selectAllText: () => 'select all input text', + getText: () => 'get input text', + scrollToView: () => 'scroll to view', + focus: () => 'focus', + moveCursorToEnd: () => 'move cursor to end', + runScript: (script) => `run script: "${script}"`, + runScriptWithArgs: (script, ...args) => `run script: "${script}" with args: "${args}"`, + getCurrentUrl: () => 'get current url', + getTitle: () => 'get title', + full: (actionDescription) => `perform web view action: ${actionDescription}` + }, expectDescription: { waitFor: (actionDescription) => `wait for expectation while ${actionDescription}`, waitForWithTimeout: (expectDescription, timeout) => `${expectDescription} with timeout (${timeout} ms)`, diff --git a/detox/test/e2e/26.element-screenshots.test.js b/detox/test/e2e/26.element-screenshots.test.js index d801654aa0..e4854ec4b0 100644 --- a/detox/test/e2e/26.element-screenshots.test.js +++ b/detox/test/e2e/26.element-screenshots.test.js @@ -1,33 +1,20 @@ -const fs = require('fs'); +const {expectElementSnapshotToMatch} = require("./utils/snapshot"); describe('Element screenshots', () => { + let fancyElement; beforeEach(async () => { await device.reloadReactNative(); await element(by.text('Element-Screenshots')).tap(); + fancyElement = element(by.id('fancyElement')); }); it('should take a screenshot of a vertically-clipped element', async () => { - const screenshotAssetPath = `./e2e/assets/elementScreenshot.${device.getPlatform()}.vert.png`; - - const bitmapPath = await element(by.id('fancyElement')).takeScreenshot(); - expectBitmapsToBeEqual(bitmapPath, screenshotAssetPath); + await expectElementSnapshotToMatch(fancyElement, 'elementScreenshot.vert'); }); it('should take a screenshot of a horizontally-clipped element', async () => { - const screenshotAssetPath = `./e2e/assets/elementScreenshot.${device.getPlatform()}.horiz.png`; - await element(by.id('switchOrientation')).tap(); - - const bitmapPath = await element(by.id('fancyElement')).takeScreenshot('fancy-element'); - expectBitmapsToBeEqual(bitmapPath, screenshotAssetPath); + await expectElementSnapshotToMatch(fancyElement, 'elementScreenshot.horiz'); }); - - function expectBitmapsToBeEqual(bitmapPath, expectedBitmapPath) { - const bitmapBuffer = fs.readFileSync(bitmapPath); - const expectedBitmapBuffer = fs.readFileSync(expectedBitmapPath); - if (!bitmapBuffer.equals(expectedBitmapBuffer)) { - throw new Error(`Expected bitmap at ${bitmapPath} to be equal to ${expectedBitmapPath}, but it is different!`); - } - } }); diff --git a/detox/test/e2e/29.webview.test.js b/detox/test/e2e/29.webview.test.js index f05921d685..7f176ff137 100644 --- a/detox/test/e2e/29.webview.test.js +++ b/detox/test/e2e/29.webview.test.js @@ -1,194 +1,304 @@ +const {expectElementSnapshotToMatch} = require("./utils/snapshot"); +const {waitForCondition} = require("./utils/waitForCondition"); const jestExpect = require('expect').default; -const MOCK_TEXT = 'Mock Text'; - -describe(':ios: WebView', () => { - it('should throw a runtime error on attempt to use', () => { - jestExpect(() => web(by.id('webview_1'))).toThrowError(/Detox does not support .* on iOS/); - }); -}); - -describe(':android: WebView', () => { - /** @type {Detox.WebViewElement} */ - let webview_1; +describe('Web View', () => { beforeEach(async () => { await device.reloadReactNative(); await element(by.text('WebView')).tap(); - webview_1 = web(by.id('webview_1')); }); - describe('Expectations',() => { - it('expect element to exists', async () => { - await expect(webview_1.element(by.web.id('testingPar'))).toExist(); + describe('single web-view scenario', () => { + const expectWebViewToMatchSnapshot = async (snapshotName) => { + const webViewElement = element(by.id('webViewFormWithScrolling')); + await expectElementSnapshotToMatch(webViewElement, snapshotName); + }; + + describe('matchers', () => { + describe(':ios:', () => { + it('should not find element by invalid index', async () => { + await expect(web.element(by.web.tag('p')).atIndex(100)).not.toExist(); + }); + + it('should find element by hrefContains', async () => { + await expect(web.element(by.web.hrefContains('w3schools'))).toExist(); + }); + + it('should find element by href', async () => { + await expect(web.element(by.web.href('https://www.w3schools.com'))).toExist(); + }); + + it('should raise an error when element does not exists but expect to exist', async () => { + await jestExpect(async () => { + await expect(web.element(by.web.id('nonExistentElement'))).toExist(); + }).rejects.toThrowError(); + }); + + it('should raise an error when does element not exists at index', async () => { + await jestExpect(async () => { + await expect(web.element(by.web.tag('p')).atIndex(100)).toExist(); + }).rejects.toThrowError(); + }); + }); + + it('should find element by id', async () => { + await expect(web.element(by.web.id('pageHeadline'))).toExist(); + }); + + it('should find element by tag', async () => { + await expect(web.element(by.web.tag('body'))).toExist(); + }); + + it('should find element by index', async () => { + await expect(web.element(by.web.tag('p')).atIndex(0)).toExist(); + }); + + it('should find element by class name', async () => { + await expect(web.element(by.web.className('specialParagraph'))).toExist(); + }); + + it('should find element by css selector', async () => { + await expect(web.element(by.web.cssSelector('.specialParagraph'))).toExist(); + }); + + it('should find element by xpath', async () => { + await expect(web.element(by.web.xpath('//p[@class="specialParagraph"]'))).toExist(); + }); + + it('should find element by name', async () => { + await expect(web.element(by.web.name('fname'))).toExist(); + }); + + it('should assert that an element is not visible', async () => { + await expect(web.element(by.web.id('nonExistentElement'))).not.toExist(); + }); }); - it('expect element to NOT exists', async () => { - await expect(webview_1.element(by.web.id('not_found'))).not.toExist(); - }); + describe('actions', () => { + describe('input', () => { + const inputElement = web.element(by.web.id('fname')); - it('expect element to have text', async () => { - await expect(webview_1.element(by.web.id('testingPar'))).toHaveText('Message'); - }); + describe(':ios:', () => { + it('should type text in input regardless of content-editable parameter on ios', async () => { + await inputElement.typeText('Test', false); + await inputElement.typeText('er', true); - it('expect element to NOT have text', async () => { - await expect(webview_1.element(by.web.id('testingPar'))).not.toHaveText(MOCK_TEXT); - }); - }); + await expect(inputElement).toHaveText('Tester'); + }); - describe('Element Matchers',() => { - it('expect to find element by id', async () => { - await expect(webview_1.element(by.web.id('testingh1'))).toExist(); - }); + it('should type text in input', async () => { + await inputElement.typeText('Test'); + await inputElement.typeText('er'); - it('expect to find element by class name', async () => { - await expect(webview_1.element(by.web.className('a'))).toExist(); - }); + await expect(inputElement).toHaveText('Tester'); + }); - it('expect to find element by css selector', async () => { - await expect(webview_1.element(by.web.cssSelector('#cssSelector'))).toExist(); - }); + it('should clear text in input', async () => { + await inputElement.typeText('Test'); + await inputElement.clearText(); - it('expect to find element by name', async () => { - await expect(webview_1.element(by.web.name('sec_input'))).toExist(); - }); + await expect(inputElement).toHaveText(''); + }); - it('expect to find element by xpath', async () => { - await expect(webview_1.element(by.web.xpath('//*[@id="testingh1-1"]'))).toExist(); - }); + it('should replace text in input', async () => { + await inputElement.typeText('Temp'); + await inputElement.replaceText('Tester'); - it('expect to find element by href', async () => { - await expect(webview_1.element(by.web.href('disney.com'))).toExist(); - }); + await expect(inputElement).toHaveText('Tester'); + }); - it('expect to find element by hrefContains', async () => { - await expect(webview_1.element(by.web.hrefContains('disney'))).toExist(); - }); + it('should tap on submit button and update result', async () => { + await inputElement.typeText('Tester'); + await web.element(by.web.id('submit')).tap(); - it('expect to find element by tag name', async () => { - await expect(webview_1.element(by.web.tag('mark'))).toExist(); - }); - }); + await expect(inputElement).toHaveText('Tester'); + }); + }); - describe('Script injection', () => { - it('should execute script', async () => { - const link = webview_1.element(by.web.cssSelector('#cssSelector')); - await link.runScript(' \n (el) => { el.textContent = "Changed"; }'); - await expect(link).toHaveText('Changed'); - }); + it('should select all text in input', async () => { + await inputElement.typeText('Tester'); + await inputElement.selectAllText(); - it('should throw error if script fails', async () => { - const link = webview_1.element(by.web.cssSelector('#cssSelector')); + await expectWebViewToMatchSnapshot('select-all-text-in-webview'); + }); - function throwError(_, msg = 'Simulated Error') { - throw new Error(msg); - } + it('should focus on input', async () => { + await inputElement.focus(); - await jestExpect(link.runScript(throwError)).rejects.toThrowError(/Simulated Error/); - await jestExpect(link.runScript(throwError, ['Custom Error'])).rejects.toThrowError(/Custom Error/); - }); + await expectWebViewToMatchSnapshot('focus-on-input-webview'); + }); - it('should evaluate a script with complex args', async () => { - const link = webview_1.element(by.web.cssSelector('#cssSelector')); - const evaluationResult = await link.runScript(function (element, a, b, c, d) { - const newText = a[0] + b.a + c[0].b + d.c[0]; - element.textContent = newText; - return a.concat({ ...b, ...c[0], ...d }); - }.toString(), [['1'], {a: 8}, [{b: 4}], {c: [3]}]); - jestExpect(evaluationResult).toEqual(['1', { a: 8, b: 4, c: [3] }]); + it('should move cursor to end', async () => { + await inputElement.typeText('Tester'); + await inputElement.moveCursorToEnd(); - await expect(link).toHaveText('1843'); - }); - }); + await expectWebViewToMatchSnapshot('move-cursor-to-end-webview'); + }); + }); - describe('ContentEditable', () => { + describe('content-editable', () => { + const contentEditableElement = web.element(by.web.id('contentEditable')); - it('should replace text by selecting all text', async () => { - const editable = await webview_1.element(by.web.className('public-DraftEditor-content')); - const text = await editable.getText(); + describe(':ios:', () => { + it('should type text in content-editable regardless of content-editable parameter on ios', async () => { + await contentEditableElement.typeText('Tes', false); + await contentEditableElement.typeText('te', true); + await contentEditableElement.typeText('r'); - await editable.scrollToView(); - await editable.selectAllText(); + await expect(contentEditableElement).toHaveText('Name: Tester'); + }); - //tapping, (at the moment not working on content-editable) - const uiDevice = device.getUiDevice(); - await uiDevice.click(40, 150); + it('should clear text in content-editable', async () => { + await contentEditableElement.clearText(); - await editable.typeText(MOCK_TEXT, true); + await expect(contentEditableElement).toHaveText(''); + }); - await expect(editable).not.toHaveText(text); - await expect(editable).toHaveText(MOCK_TEXT); - }); + it('should replace text in content-editable', async () => { + await contentEditableElement.replaceText('Tester'); - it('move cursor to end and add text', async () => { - const editable = await webview_1.element(by.web.className('public-DraftEditor-content')); - await editable.scrollToView(); + await expect(contentEditableElement).toHaveText('Tester'); + }); - //tapping, (at the moment not working on content-editable) - const uiDevice = device.getUiDevice(); - await uiDevice.click(40, 150); + it('should type text in content-editable', async () => { + await contentEditableElement.typeText('Test', true); + await contentEditableElement.typeText('er', true); - //Initial Text - await editable.selectAllText(); - await editable.typeText(MOCK_TEXT, true); + await expect(contentEditableElement).toHaveText('Name: Tester'); + }); + }); - //Addition Text - const ADDITION_TEXT = ' AdditionText'; - await editable.moveCursorToEnd(); - await editable.typeText(ADDITION_TEXT, true); + it('should select all text in content-editable', async () => { + await contentEditableElement.selectAllText(); - await expect(editable).toHaveText(MOCK_TEXT + ADDITION_TEXT); - }); - }); + await expectWebViewToMatchSnapshot('select-all-text-in-content-editable-webview'); + }); - it('should set input and change text', async () => { - // Verify initial value - const para = webview_1.element(by.web.id('testingPar')); - await expect(para).toHaveText('Message'); + it('should focus on content-editable', async () => { + await contentEditableElement.focus(); - const textInput = await webview_1.element(by.web.id('textInput')); - await textInput.scrollToView(); - await textInput.tap(); - await textInput.typeText(MOCK_TEXT); + await expectWebViewToMatchSnapshot('focus-on-content-editable-webview'); + }); - await webview_1.element(by.web.id('changeTextBtn')).tap(); - // Verify text updated - await expect(para).toHaveText(MOCK_TEXT); - }); + it('should move cursor to end', async () => { + await contentEditableElement.moveCursorToEnd(); - it('should header get text and verify its value', async () => { - const text = await webview_1.element(by.web.id('testingh1')).getText(); + await expectWebViewToMatchSnapshot('move-cursor-to-end-content-editable-webview'); + }); + }); - const textInput = await webview_1.element(by.web.id('textInput')); - await textInput.scrollToView(); - await textInput.tap(); - await textInput.typeText(text); + it('should scroll to view', async () => { + await web.element(by.web.id('bottomParagraph')).scrollToView(); - await webview_1.element(by.web.id('changeTextBtn')).tap(); + await expectWebViewToMatchSnapshot('scroll-to-view-webview'); + }); - // Verify text is the title text - await expect(webview_1.element(by.web.id('testingPar'))).toHaveText(text); + it('should run script', async () => { + const headline = web.element(by.web.id('pageHeadline')); + await headline.runScript('(el) => { el.textContent = "Changed"; }'); - }); + await expect(headline).toHaveText('Changed'); + }); - it('should replace text', async () => { - const textInput = await webview_1.element(by.web.id('textInput')); - await textInput.scrollToView(); - await textInput.tap(); - await textInput.typeText('first text'); + it('should run script with arguments', async () => { + const headline = web.element(by.web.id('pageHeadline')); + await headline.runScript('(el, text) => { el.textContent = text; }', ['Changed']); - await webview_1.element(by.web.id('changeTextBtn')).tap(); - await expect(webview_1.element(by.web.id('testingPar'))).toHaveText('first text'); + await expect(headline).toHaveText('Changed'); + }); - await textInput.replaceText(MOCK_TEXT); - await webview_1.element(by.web.id('changeTextBtn')).tap(); + it('should return value from run script', async () => { + const headline = web.element(by.web.id('pageHeadline')); + const textContent = await headline.runScript('(el) => { return el.textContent; }'); - // Verify param value is the latest changed text - await expect(webview_1.element(by.web.id('testingPar'))).toHaveText(MOCK_TEXT); - }); + await jestExpect(textContent).toBe('First Webview'); + }); + + it('should raise error when script fails', async () => { + const headline = web.element(by.web.id('pageHeadline')); - it('getWebView with matcher id', async () => { - const webview_2 = await web(by.id('webview_2')); - await expect(webview_2.element(by.web.tag('p'))).toHaveText('Second Webview'); + await jestExpect(async () => { + await headline.runScript('(el) => { el.textContent = "Changed"; throw new Error("Error"); }'); + }).rejects.toThrowError(); + }); + }); + + describe('getters', () => { + it(':ios: should get the web page url', async () => { + await web.element(by.web.id('w3link')).tap(); + + await waitForCondition( + () => web.element(by.web.tag('body')).getCurrentUrl(), + (result) => result === 'https://www.w3schools.com/', + 5000 + ); + }); + + it('should get the web page title', async () => { + const title = await web.element(by.web.tag('body')).getTitle(); + await jestExpect(title).toBe('First Webview'); + }); + + it('should get text from element', async () => { + const source = await web.element(by.web.id('pageHeadline')).getText(); + await jestExpect(source).toBe('First Webview'); + }); + }); }); + describe('multiple web-views scenario',() => { + /** @type {Detox.WebViewElement} */ + let webview; + + beforeEach(async () => { + await element(by.id('toggleDummyWebViewButton')).tap(); + + webview = web(by.id('dummyWebView')); + }); + + it('should have a title', async () => { + const title = await webview.element(by.web.tag('body')).getTitle(); + await jestExpect(title).toBe('Dummy Webview'); + }); + + it('should have a paragraph', async () => { + await expect(webview.element(by.web.id('message'))).toExist(); + await expect(webview.element(by.web.id('message'))).toHaveText('This is a dummy webview.'); + }); + + it('should throw on multiple matches', async () => { + await element(by.id('toggleDummyWebView2Button')).tap(); + + await jestExpect(async () => { + await expect(web(by.id('dummyWebView')).element(by.web.id('message'))).toExist(); + }).rejects.toThrowError(); + + await device.launchApp(); + }); + + describe('at-index support', () => { + beforeEach(async () => { + await element(by.id('toggleDummyWebView2Button')).tap(); + }); + + describe(':ios:', () => { + it('should find web-view by index', async () => { + await expect(web(by.id('dummyWebView')).atIndex(0).element(by.web.id('message'))).toExist(); + await expect(web(by.id('dummyWebView')).atIndex(1).element(by.web.id('message'))).toExist(); + }); + + it('should throw on index out of bounds', async () => { + await jestExpect(async () => { + await expect(web(by.id('dummyWebView')).atIndex(2).element(by.web.id('message'))).toExist(); + }).rejects.toThrowError(); + }); + }); + + it(':android: should throw on usage of atIndex', async () => { + await jestExpect(async () => { + await expect(web(by.id('dummyWebView')).atIndex(0).element(by.web.id('message'))).toExist(); + }).rejects.toThrowError(); + }); + }); + }); }); diff --git a/detox/test/e2e/assets/focus-on-content-editable-webview.android.png b/detox/test/e2e/assets/focus-on-content-editable-webview.android.png new file mode 100644 index 0000000000..b5a89c688e Binary files /dev/null and b/detox/test/e2e/assets/focus-on-content-editable-webview.android.png differ diff --git a/detox/test/e2e/assets/focus-on-content-editable-webview.ios.png b/detox/test/e2e/assets/focus-on-content-editable-webview.ios.png new file mode 100644 index 0000000000..078129c11b Binary files /dev/null and b/detox/test/e2e/assets/focus-on-content-editable-webview.ios.png differ diff --git a/detox/test/e2e/assets/focus-on-input-webview.android.png b/detox/test/e2e/assets/focus-on-input-webview.android.png new file mode 100644 index 0000000000..b5a89c688e Binary files /dev/null and b/detox/test/e2e/assets/focus-on-input-webview.android.png differ diff --git a/detox/test/e2e/assets/focus-on-input-webview.ios.png b/detox/test/e2e/assets/focus-on-input-webview.ios.png new file mode 100644 index 0000000000..3dc7663d91 Binary files /dev/null and b/detox/test/e2e/assets/focus-on-input-webview.ios.png differ diff --git a/detox/test/e2e/assets/move-cursor-to-end-content-editable-webview.android.png b/detox/test/e2e/assets/move-cursor-to-end-content-editable-webview.android.png new file mode 100644 index 0000000000..b5a89c688e Binary files /dev/null and b/detox/test/e2e/assets/move-cursor-to-end-content-editable-webview.android.png differ diff --git a/detox/test/e2e/assets/move-cursor-to-end-content-editable-webview.ios.png b/detox/test/e2e/assets/move-cursor-to-end-content-editable-webview.ios.png new file mode 100644 index 0000000000..1eb5f94f7a Binary files /dev/null and b/detox/test/e2e/assets/move-cursor-to-end-content-editable-webview.ios.png differ diff --git a/detox/test/e2e/assets/move-cursor-to-end-webview.android.png b/detox/test/e2e/assets/move-cursor-to-end-webview.android.png new file mode 100644 index 0000000000..308bae0aa5 Binary files /dev/null and b/detox/test/e2e/assets/move-cursor-to-end-webview.android.png differ diff --git a/detox/test/e2e/assets/move-cursor-to-end-webview.ios.png b/detox/test/e2e/assets/move-cursor-to-end-webview.ios.png new file mode 100644 index 0000000000..50614230cd Binary files /dev/null and b/detox/test/e2e/assets/move-cursor-to-end-webview.ios.png differ diff --git a/detox/test/e2e/assets/scroll-to-view-webview.android.png b/detox/test/e2e/assets/scroll-to-view-webview.android.png new file mode 100644 index 0000000000..010f7abc8d Binary files /dev/null and b/detox/test/e2e/assets/scroll-to-view-webview.android.png differ diff --git a/detox/test/e2e/assets/scroll-to-view-webview.ios.png b/detox/test/e2e/assets/scroll-to-view-webview.ios.png new file mode 100644 index 0000000000..13ca9579ad Binary files /dev/null and b/detox/test/e2e/assets/scroll-to-view-webview.ios.png differ diff --git a/detox/test/e2e/assets/select-all-text-in-content-editable-webview.android.png b/detox/test/e2e/assets/select-all-text-in-content-editable-webview.android.png new file mode 100644 index 0000000000..87625e5ca0 Binary files /dev/null and b/detox/test/e2e/assets/select-all-text-in-content-editable-webview.android.png differ diff --git a/detox/test/e2e/assets/select-all-text-in-content-editable-webview.ios.png b/detox/test/e2e/assets/select-all-text-in-content-editable-webview.ios.png new file mode 100644 index 0000000000..27e97b958f Binary files /dev/null and b/detox/test/e2e/assets/select-all-text-in-content-editable-webview.ios.png differ diff --git a/detox/test/e2e/assets/select-all-text-in-webview.android.png b/detox/test/e2e/assets/select-all-text-in-webview.android.png new file mode 100644 index 0000000000..308bae0aa5 Binary files /dev/null and b/detox/test/e2e/assets/select-all-text-in-webview.android.png differ diff --git a/detox/test/e2e/assets/select-all-text-in-webview.ios.png b/detox/test/e2e/assets/select-all-text-in-webview.ios.png new file mode 100644 index 0000000000..88c6f5d30c Binary files /dev/null and b/detox/test/e2e/assets/select-all-text-in-webview.ios.png differ diff --git a/detox/test/e2e/utils/snapshot.js b/detox/test/e2e/utils/snapshot.js new file mode 100644 index 0000000000..1d09739916 --- /dev/null +++ b/detox/test/e2e/utils/snapshot.js @@ -0,0 +1,58 @@ +const fs = require('fs-extra'); +const { ssim } = require('ssim.js'); +const { PNG } = require('pngjs'); + +// Threshold for SSIM comparison, if two images have SSIM score below this threshold, they are considered different. +const SSIM_SCORE_THRESHOLD = 0.997; + +async function expectElementSnapshotToMatch (elementOrDevice, snapshotName) { + const bitmapPath = await elementOrDevice.takeScreenshot(snapshotName); + const expectedBitmapPath = `./e2e/assets/${snapshotName}.${device.getPlatform()}.png`; + + if (await fs.pathExists(expectedBitmapPath) === false || process.env.UPDATE_SNAPSHOTS === 'true') { + await fs.copy(bitmapPath, expectedBitmapPath, {overwrite: true}); + } else { + await expectSSIMToBeClose(bitmapPath, expectedBitmapPath); + } +} + +async function expectDeviceSnapshotToMatch (snapshotName) { + // Set status bar to consistent state for snapshot. Currently, doesn't work on iOS 17. + await device.setStatusBar({time: '2024-03-08T09:41:00-07:00'}); + + await expectElementSnapshotToMatch(device, snapshotName); +} + +async function expectSSIMToBeClose (bitmapPath, expectedBitmapPath) { + const image = loadImage(bitmapPath); + const expectedImage = loadImage(expectedBitmapPath); + + const { mssim, performance } = ssim(image, expectedImage); + + if (mssim < SSIM_SCORE_THRESHOLD) { + throw new Error( + `Expected bitmaps at '${bitmapPath}' and '${expectedBitmapPath}' to have an SSIM score ` + + `of at least ${SSIM_SCORE_THRESHOLD}, but got ${mssim}. This means the snapshots are different ` + + `(comparison took ${performance}ms)` + ) + } +} + +function loadImage (path) { + const imageBuffer = fs.readFileSync(path); + const image = PNG.sync.read(imageBuffer); + return convertToSSIMFormat(image); +} + +function convertToSSIMFormat (image) { + return { + data: new Uint8Array(image.data), + width: image.width, + height: image.height, + }; +} + +module.exports = { + expectElementSnapshotToMatch, + expectDeviceSnapshotToMatch +}; diff --git a/detox/test/e2e/utils/waitForCondition.js b/detox/test/e2e/utils/waitForCondition.js new file mode 100644 index 0000000000..1723b102bf --- /dev/null +++ b/detox/test/e2e/utils/waitForCondition.js @@ -0,0 +1,22 @@ +const jestExpect = require('expect').default; + + +async function waitForCondition (func, condition, timeout = 5000) { + let isFulfilled = false; + + const start = Date.now(); + while (Date.now() - start < timeout) { + if (condition(await func())) { + isFulfilled = true; + break; + } + + await new Promise(resolve => setTimeout(resolve, 100)); + } + + jestExpect(isFulfilled).toBe(true); +} + +module.exports = { + waitForCondition +}; diff --git a/detox/test/package.json b/detox/test/package.json index 761118e8c0..745b5c7601 100644 --- a/detox/test/package.json +++ b/detox/test/package.json @@ -43,7 +43,8 @@ "react-native": "0.73.2", "react-native-launch-arguments": "^4.0.0", "react-native-permissions": "^4.0.2", - "react-native-webview": "^11.18.1" + "react-native-webview": "^11.18.1", + "ssim.js": "^3.5.0" }, "devDependencies": { "@babel/core": "^7.20.0", diff --git a/detox/test/src/Screens/NativeAnimationsScreen.js b/detox/test/src/Screens/NativeAnimationsScreen.js index a406fc86fb..cbbfc4ebf6 100644 --- a/detox/test/src/Screens/NativeAnimationsScreen.js +++ b/detox/test/src/Screens/NativeAnimationsScreen.js @@ -3,7 +3,7 @@ import { requireNativeComponent, View } from 'react-native'; const NativeAnimatingView = requireNativeComponent('DetoxNativeAnimatingView'); -class NativeAnimationsScreen extends Component { +export default class NativeAnimationsScreen extends Component { render() { return ( @@ -12,5 +12,3 @@ class NativeAnimationsScreen extends Component { ); } } - -module.exports = NativeAnimationsScreen; diff --git a/detox/test/src/Screens/WebViewScreen.js b/detox/test/src/Screens/WebViewScreen.js index 5ba8c71fc9..8c5e90bb2b 100644 --- a/detox/test/src/Screens/WebViewScreen.js +++ b/detox/test/src/Screens/WebViewScreen.js @@ -1,141 +1,154 @@ -import React, { Component } from 'react'; -import { View } from 'react-native'; +import React from 'react'; +import {Button, View} from 'react-native'; import { WebView } from 'react-native-webview'; -export default class WebViewScreen extends Component { - render() { - // const debugSource = require('../assets/html/test.html'); - // const releaseSourcePrefix = Platform.OS === 'android' ? 'file:///android_asset' : './assets'; - // const releaseSource = { uri: `${releaseSourcePrefix}/assets/html/test.html` }; - // const webViewSource = Image.resolveAssetSource(global.__DEV__ ? debugSource : releaseSource); +export default function WebViewScreen() { + const [isDummyWebViewVisible, setIsDummyWebViewVisible] = React.useState(false); + const [isDummyWebView2Visible, setIsDummyWebView2Visible] = React.useState(false); + return ( - - - - - - - + + + +