From f947eb5a0f3d1ef03e820705a5f7c64fb4cd4b1c Mon Sep 17 00:00:00 2001 From: Asaf Korem Date: Sun, 14 Apr 2024 14:17:01 +0300 Subject: [PATCH 01/43] feat(ios, system): add system APIs, no implementation. --- detox/detox.d.ts | 70 +++++++++- detox/globals.d.ts | 3 + detox/src/android/AndroidExpect.js | 10 ++ detox/src/android/matchers/index.js | 7 + detox/src/ios/expectTwo.js | 19 +++ detox/src/ios/system.js | 131 ++++++++++++++++++ .../src/utils/invocationTraceDescriptions.js | 3 + 7 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 detox/src/ios/system.js diff --git a/detox/detox.d.ts b/detox/detox.d.ts index d34da14bac..354352adf5 100644 --- a/detox/detox.d.ts +++ b/detox/detox.d.ts @@ -431,6 +431,8 @@ declare global { readonly web: WebFacade; + readonly system: SystemFacade; + readonly DetoxConstants: { userNotificationTriggers: { push: 'push'; @@ -1038,6 +1040,11 @@ declare global { * Collection of web matchers */ readonly web: ByWebFacade; + + /** + * Collection of system-level matchers + */ + readonly system: BySystemFacade; } interface ByWebFacade { @@ -1106,6 +1113,22 @@ declare global { tag(tagName: string): WebMatcher; } + interface BySystemFacade { + /** + * Find an element on the System-level by its label + * @example + * system.element(by.system.text('Allow')) + */ + label(text: string): SystemMatcher; + + /** + * Find an element on the System-level by its type + * @example + * system.element(by.system.type('button')) + */ + type(type: string): SystemMatcher; + } + interface NativeMatcher { /** * Find an element satisfying all the matchers @@ -1127,13 +1150,19 @@ declare global { } interface WebMatcher { - __web__: any; // prevent type coersion + __web__: any; // prevent type coercion + } + + interface SystemMatcher { + __system__: any; // prevent type coercion } interface ExpectFacade { (element: NativeElement): Expect; (webElement: WebElement): WebExpect; + + (systemElement: SystemElement): SystemExpect; } interface WebViewElement { @@ -1164,6 +1193,31 @@ declare global { (matcher?: NativeMatcher): WebViewElement; } + interface SystemFacade { + /** + * Find an element on the System-level using a system matcher. + * @param systemMatcher a system matcher for the system element. + * @example + * system.element(by.system.label('Allow')) + */ + element(systemMatcher: SystemMatcher): IndexableSystemElement; + } + + interface IndexableSystemElement extends SystemElement { + /** + * Choose from multiple elements matching the same matcher using index + * @example await system.element(by.system.type('button')).atIndex(1).tap(); + */ + atIndex(index: number): SystemElement; + } + + interface SystemElement { + /** + * Simulate a tap on the element. + */ + tap(): Promise; + } + interface Expect> { /** @@ -1531,6 +1585,20 @@ declare global { toExist(): R; } + interface SystemExpect> { + /** + * Negate the expectation. + * @example await expect(system.element(by.system.text('Allow'))).not.toExist(); + */ + not: this; + + /** + * Expect the view to exist in the system-level. + * @example await expect(system.element(by.system.text('Allow'))).toExist(); + */ + toExist(): R; + } + interface IndexableWebElement extends WebElement { /** * Choose from multiple elements matching the same matcher using index. diff --git a/detox/globals.d.ts b/detox/globals.d.ts index f58f993fbb..39acc5c0e1 100644 --- a/detox/globals.d.ts +++ b/detox/globals.d.ts @@ -9,6 +9,8 @@ declare global { const by: Detox.DetoxExportWrapper['by']; const web: Detox.DetoxExportWrapper['web']; + const system: Detox.DetoxExportWrapper['system']; + namespace NodeJS { interface Global { detox: Detox.DetoxExportWrapper; @@ -18,6 +20,7 @@ declare global { expect: Detox.DetoxExportWrapper['expect']; by: Detox.DetoxExportWrapper['by']; web: Detox.DetoxExportWrapper['web']; + system: Detox.DetoxExportWrapper['system']; } } } diff --git a/detox/src/android/AndroidExpect.js b/detox/src/android/AndroidExpect.js index a2bb4df410..8382a2c7a6 100644 --- a/detox/src/android/AndroidExpect.js +++ b/detox/src/android/AndroidExpect.js @@ -21,6 +21,8 @@ class AndroidExpect { this.waitFor = this.waitFor.bind(this); this.web = this.web.bind(this); this.web.element = (...args) => this.web().element(...args); + this.system = this.system.bind(this); + this.system.element = (...args) => this.system().element(...args); } element(matcher) { @@ -45,6 +47,14 @@ class AndroidExpect { throw new DetoxRuntimeError(`web() argument is invalid, expected a native matcher, but got ${typeof element}`); } + system() { + return { + element: (_) => { + throw new DetoxRuntimeError(`System interactions are not supported on Android, use UiDevice APIs directly instead`); + }, + }; + } + expect(element) { if (element instanceof WebElement) return new WebExpectElement(this._invocationManager, element); if (element instanceof NativeElement) return new NativeExpectElement(this._invocationManager, element); diff --git a/detox/src/android/matchers/index.js b/detox/src/android/matchers/index.js index 66859d13f4..510eb59875 100644 --- a/detox/src/android/matchers/index.js +++ b/detox/src/android/matchers/index.js @@ -1,3 +1,5 @@ +const { DetoxRuntimeError } = require('../../errors'); + const native = require('./native'); const web = require('./web'); @@ -20,4 +22,9 @@ module.exports = { href: (value) => new web.LinkTextMatcher(value), hrefContains: (value) => new web.PartialLinkTextMatcher(value), }, + + system: { + label: (_) => { throw new DetoxRuntimeError('System interactions are not supported on Android, use UiDevice APIs directly instead'); }, + type: (_) => { throw new DetoxRuntimeError('System interactions are not supported on Android, use UiDevice APIs directly instead'); }, + } }; diff --git a/detox/src/ios/expectTwo.js b/detox/src/ios/expectTwo.js index e1ced99c3e..1f80852839 100644 --- a/detox/src/ios/expectTwo.js +++ b/detox/src/ios/expectTwo.js @@ -15,6 +15,7 @@ const log = require('../utils/logger').child({ cat: 'ws-client, ws' }); const mapLongPressArguments = require('../utils/mapLongPressArguments'); const traceInvocationCall = require('../utils/traceInvocationCall').bind(null, log); +const { systemElement, systemMatcher, systemExpect, isSystemElement } = require('./system'); const { webElement, webMatcher, webExpect, isWebElement } = require('./web'); const assertDirection = assertEnum(['left', 'right', 'up', 'down']); @@ -405,6 +406,10 @@ class By { get web() { return webMatcher(); } + + get system() { + return systemMatcher(); + } } class Matcher { @@ -762,6 +767,8 @@ class IosExpect { this.by = new By(); this.web = this.web.bind(this); this.web.element = this.web().element; + this.system = this.system.bind(this); + this.system.element = this.system().element; } element(matcher) { @@ -769,6 +776,10 @@ class IosExpect { } expect(element) { + if (isSystemElement(element)) { + return systemExpect(this._invocationManager, element); + } + if (isWebElement(element)) { return webExpect(this._invocationManager, element); } @@ -798,6 +809,14 @@ class IosExpect { } }; } + + system() { + return { + element: systemMatcher => { + return systemElement(this._invocationManager, this._emitter, systemMatcher); + } + }; + } } function _assertValidPoint(point) { diff --git a/detox/src/ios/system.js b/detox/src/ios/system.js new file mode 100644 index 0000000000..cffdc5fbb5 --- /dev/null +++ b/detox/src/ios/system.js @@ -0,0 +1,131 @@ +const assert = require('assert'); + +const _ = require('lodash'); + +const { DetoxRuntimeError } = require('../errors'); +const { systemActionDescription, expectDescription } = require('../utils/invocationTraceDescriptions'); +const log = require('../utils/logger').child({ cat: 'ws-client, ws' }); +const traceInvocationCall = require('../utils/traceInvocationCall').bind(null, log); + + +class SystemExpect { + constructor(invocationManager, element) { + this._invocationManager = invocationManager; + this.element = element; + this.modifiers = []; + } + + toExist() { + const traceDescription = expectDescription.toExist(); + return this.expect('toExist', traceDescription); + } + + get not() { + this.modifiers.push('not'); + return this; + } + + createInvocation(systemExpectation, ...params) { + const definedParams = _.without(params, undefined); + return { + type: 'systemExpectation', + systemPredicate: this.element.matcher.predicate, + ...(this.element.index !== undefined && { systemAtIndex: this.element.index }), + ...(this.modifiers.length !== 0 && { systemModifiers: this.modifiers }), + systemExpectation, + ...(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 SystemElement { + constructor(invocationManager, emitter, matcher, index) { + this._invocationManager = invocationManager; + this._emitter = emitter; + this.matcher = matcher; + this.index = index; + } + + atIndex(index) { + if (typeof index !== 'number' || index < 0) throw new DetoxRuntimeError(`index should be an integer, got ${index} (${typeof index})`); + this.index = index; + return this; + } + + tap() { + const traceDescription = systemActionDescription.tap(); + return this.withAction('tap', traceDescription); + } + + withAction(action, traceDescription, ...params) { + assert(traceDescription, `must provide trace description for action: \n ${JSON.stringify(action)}`); + + const invocation = { + type: 'systemAction', + systemPredicate: this.matcher.predicate, + ...(this.index !== undefined && { systemAtIndex: this.index }), + systemAction: action, + ...(params.length !== 0 && { params }), + }; + traceDescription = systemActionDescription.full(traceDescription); + return _executeInvocation(this._invocationManager, invocation, traceDescription); + } +} + +class SystemElementMatcher { + label(label) { + if (typeof label !== 'string') throw new DetoxRuntimeError('label should be a string, but got ' + (label + (' (' + typeof label + ')'))); + this.predicate = { type: 'label', value: label.toString() }; + return this; + } + + type(type) { + if (typeof type !== 'string') throw new DetoxRuntimeError('type should be a string, but got ' + (type + (' (' + typeof type + ')'))); + this.predicate = { type: 'type', value: type.toString() }; + return this; + } +} + +function systemMatcher() { + return new SystemElementMatcher(); +} + +function systemElement(invocationManager, emitter, matcher) { + if (!(matcher instanceof SystemElementMatcher)) { + throwSystemMatcherError(matcher); + } + + return new SystemElement(invocationManager, emitter, matcher); +} + +function throwSystemMatcherError(param) { + const paramDescription = JSON.stringify(param); + throw new DetoxRuntimeError(`${paramDescription} is not a Detox system matcher. More about system matchers here: https://wix.github.io/Detox/docs/api/system`); +} + +function systemExpect(invocationManager, element) { + return new SystemExpect(invocationManager, element); +} + +function _executeInvocation(invocationManager, invocation, traceDescription) { + return traceInvocationCall(traceDescription, invocation, invocationManager.execute(invocation)); +} + +function isSystemElement(element) { + return element instanceof SystemElement; +} + +module.exports = { + systemMatcher, + systemElement, + systemExpect, + isSystemElement +}; diff --git a/detox/src/utils/invocationTraceDescriptions.js b/detox/src/utils/invocationTraceDescriptions.js index 475ad14c39..7ad81af0a5 100644 --- a/detox/src/utils/invocationTraceDescriptions.js +++ b/detox/src/utils/invocationTraceDescriptions.js @@ -43,6 +43,9 @@ module.exports = { getTitle: () => 'get title', full: (actionDescription) => `perform web view action: ${actionDescription}` }, + systemActionDescription: { + tap: () => `tap`, + }, expectDescription: { waitFor: (actionDescription) => `wait for expectation while ${actionDescription}`, waitForWithTimeout: (expectDescription, timeout) => `${expectDescription} with timeout (${timeout} ms)`, From 1b5860cbe5b9dd1667246aa105756050bd9a62dd Mon Sep 17 00:00:00 2001 From: Asaf Korem Date: Sun, 14 Apr 2024 14:32:28 +0300 Subject: [PATCH 02/43] test(ios, system): initial acceptance tests. --- detox/test/e2e/36.system.test.js | 75 +++++++++++++++++++ detox/test/src/Screens/SystemDialogsScreen.js | 46 ++++++++++++ detox/test/src/Screens/index.js | 4 +- detox/test/src/app.js | 1 + 4 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 detox/test/e2e/36.system.test.js create mode 100644 detox/test/src/Screens/SystemDialogsScreen.js diff --git a/detox/test/e2e/36.system.test.js b/detox/test/e2e/36.system.test.js new file mode 100644 index 0000000000..be4b82a7cd --- /dev/null +++ b/detox/test/e2e/36.system.test.js @@ -0,0 +1,75 @@ +const {expectToThrow} = require('./utils/custom-expects'); + +describe('System Dialogs', () => { + describe(':ios:', () => { + beforeEach(async () => { + await device.launchApp({delete: true}); + await element(by.text('System Dialogs')).tap(); + }); + + afterEach(async () => { + await device.terminateApp(); + }); + + describe('request permission dialog', () => { + const permissionStatus = element(by.id('permissionStatus')); + const requestPermissionButton = element(by.id('requestPermissionButton')); + + it('should start with unavailable permission status', async () => { + await expect(permissionStatus).toHaveText('unavailable'); + }); + + it('should find system alert button', async () => { + await requestPermissionButton.tap(); + + await expect(system.element(by.system.label('Allow'))).toExist(); + }); + + it('should tap on permission request alert button by label ("Allow")', async () => { + await requestPermissionButton.tap(); + + await system.element(by.system.label('Allow')).tap(); + + await expect(permissionStatus).toHaveText('granted'); + }); + + it('should tap on permission request alert button by type and index ("Deny")', async () => { + await requestPermissionButton.tap(); + + await system.element(by.system.type('button')).atIndex(1).tap(); + + await expect(permissionStatus).toHaveText('denied'); + }); + }); + + it('should not find elements that does not exist', async () => { + await expect(system.element(by.system.label('NonExistent'))).not.toExist(); + }); + + it('should raise when trying to match system element that does not exist', async () => { + await expectToThrow(async () => { + await expect(system.element(by.system.label('NonExistent'))).toExist(); + }, 'Error: Cannot find system element by label: NonExistent'); + }); + + it('should raise when trying to tap on system element that does not exist', async () => { + await expectToThrow(async () => { + await system.element(by.system.label('NonExistent')).tap(); + }, 'Error: Cannot find system element by label: NonExistent'); + }); + }); + + describe(':android: not supported on Android', () => { + it('should throw on expectation call', async () => { + await expectToThrow(async () => { + await expect(system.element(by.system.label('Allow'))).toExist(); + }, 'System interactions are not supported on Android, use UiDevice APIs directly instead'); + }); + + it('should throw on action call', async () => { + await expectToThrow(async () => { + await system.element(by.system.type('button')).atIndex(0).tap(); + }, 'System interactions are not supported on Android, use UiDevice APIs directly instead'); + }); + }); +}); diff --git a/detox/test/src/Screens/SystemDialogsScreen.js b/detox/test/src/Screens/SystemDialogsScreen.js new file mode 100644 index 0000000000..de19b2063c --- /dev/null +++ b/detox/test/src/Screens/SystemDialogsScreen.js @@ -0,0 +1,46 @@ +import React, {Component} from 'react'; +import { + View, + Text, Button, +} from 'react-native'; + +import {request, PERMISSIONS, RESULTS, check} from 'react-native-permissions'; + +export default class SystemDialogsScreen extends Component { + constructor(props) { + super(props); + + this.state = { + userTrackingStatus: RESULTS.UNAVAILABLE, + }; + } + + async updateStatus() { + const status = await check(PERMISSIONS.IOS.APP_TRACKING_TRANSPARENCY); + + this.setState({ + userTrackingStatus: status, + }); + } + + async requestPermission() { + const status = await request(PERMISSIONS.IOS.APP_TRACKING_TRANSPARENCY); + + this.setState({ + userTrackingStatus: status, + }); + } + + render() { + const status = this.state.userTrackingStatus; + + return ( + + User Tracking Status + {status} +