Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WiP] feat: add device.backdoor() API #4208

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ object DetoxMain {
associateActionHandler("isReady", readyHandler)
associateActionHandler("reactNativeReload", rnReloadHandler)
associateActionHandler("invoke", InvokeActionHandler(MethodInvocation(), serverAdapter))
associateActionHandler("backdoor", BackdoorActionHandler(rnHostHolder, serverAdapter, testEngineFacade))
associateActionHandler("cleanup", CleanupActionHandler(serverAdapter, testEngineFacade) {
dispatchAction(TERMINATION_ACTION, "", 0)
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.wix.detox.TestEngineFacade
import com.wix.detox.common.extractRootCause
import com.wix.detox.instruments.DetoxInstrumentsException
import com.wix.detox.instruments.DetoxInstrumentsManager
import com.wix.detox.reactnative.ReactNativeExtension
import com.wix.invoke.MethodInvocation
import org.json.JSONObject
import java.lang.reflect.InvocationTargetException
Expand Down Expand Up @@ -40,6 +41,17 @@ open class ReactNativeReloadActionHandler(
}
}

class BackdoorActionHandler(
private val appContext: Context,
private val outboundServerAdapter: OutboundServerAdapter,
private val testEngineFacade: TestEngineFacade)
: DetoxActionHandler {

override fun handle(params: String, messageId: Long) {
ReactNativeExtension.emitBackdoorEvent(appContext, params)
outboundServerAdapter.sendMessage("backdoorDone", emptyMap(), messageId)
}
}

class InvokeActionHandler @JvmOverloads constructor(
private val methodInvocation: MethodInvocation,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@ import android.util.Log
import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.ReactApplication
import com.facebook.react.ReactInstanceManager
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReactContext
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
import com.wix.detox.LaunchArgs
import com.wix.detox.common.JsonConverter
import org.json.JSONObject


private const val LOG_TAG = "DetoxRNExt"

Expand Down Expand Up @@ -119,6 +124,19 @@ object ReactNativeExtension {
}
}

@JvmStatic
fun emitBackdoorEvent(applicationContext: Context, params: String) {
if (!ReactNativeInfo.isReactNativeApp()) {
return
}

val bundle = JsonConverter(JSONObject(params)).toBundle()
val payload = Arguments.fromBundle(bundle)

val reactContext = getCurrentReactContextSafe(applicationContext as ReactApplication) ?: return
reactContext.getJSModule(RCTDeviceEventEmitter::class.java).emit("detoxBackdoor", payload)
}

private fun reloadReactNativeInBackground(reactApplication: ReactApplication) {
val rnReloader = ReactNativeReLoader(InstrumentationRegistry.getInstrumentation(), reactApplication)
rnReloader.reloadInBackground()
Expand Down
8 changes: 8 additions & 0 deletions detox/detox.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -885,6 +885,14 @@ declare global {
*/
takeScreenshot(name: string): Promise<string>;

/**
* Sends a backdoor message to the app being tested.
* The message should be an object with `action` property and any other custom data.
* For more information, see {@link https://wix.github.io/Detox/docs/guide/mocking}
* and {@link https://wix.github.io/Detox/docs/api/device#devicebackdoormessage}
*/
backdoor(message: { action: string; [key: string]: any }): Promise<void>;

/**
* (iOS only) Saves a view hierarchy snapshot (*.viewhierarchy) of the currently opened application
* to a temporary folder and schedules putting it to the artifacts folder upon the completion of
Expand Down
7 changes: 5 additions & 2 deletions detox/ios/Detox/DetoxManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -402,8 +402,11 @@ public class DetoxManager : NSObject, WebSocketDelegate {
rvParams["captureViewHierarchyError"] = "User ran process with -detoxDisableHierarchyDump YES"
}
self.webSocket.sendAction(done, params: rvParams, messageId: messageId)
default:
fatalError("Unknown action type received: \(type)")
case "backdoor":
ReactNativeSupport.emitBackdoorEvent(params)
self.safeSend(action: done, messageId: messageId)
default:
fatalError("Unknown action type received: \(type)")
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ typedef void (^RN_RCTJavaScriptCallback)(id json, NSError *error);
- (id<RN_RCTUIManager>) uiManager;
- (id)moduleForName:(NSString *)moduleName;
- (id)moduleForClass:(Class)moduleClass;
- (void)enqueueJSCall:(NSString *)module
method:(NSString *)method
args:(NSArray *)args
completion:(dispatch_block_t)completion;

@property (nonatomic, readonly, getter=isLoading) BOOL loading;
@property (nonatomic, readonly, getter=isValid) BOOL valid;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

+ (void)reloadApp;
+ (void)waitForReactNativeLoadWithCompletionHandler:(void(^)(void))handler;
+ (void)emitBackdoorEvent:(id)data;

@end

Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,15 @@ + (void)waitForReactNativeLoadWithCompletionHandler:(void (^)(void))handler
[DTXReactNativeSupport waitForReactNativeLoadWithCompletionHandler:handler];
}

+ (void)emitBackdoorEvent:(id)data
{
if([DTXReactNativeSupport hasReactNative] == NO)
{
return;
}

id<RN_RCTBridge> bridge = [NSClassFromString(@"RCTBridge") valueForKey:@"currentBridge"];
[bridge enqueueJSCall:@"RCTNativeAppEventEmitter" method:@"emit" args:@[@"detoxBackdoor", data] completion:nil];
}

@end
144 changes: 144 additions & 0 deletions detox/react-native/BackdoorEmitter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/* eslint-disable node/no-unsupported-features/es-syntax */

export class BackdoorEmitter {
constructor(emitter) {
this._emitter = emitter;
this._handlers = {};
this._listeners = {};
this._subscription = this._emitter.addListener('detoxBackdoor', this._onBackdoorEvent);
}

/**
* Strict mode prevents interference errors when action handlers are not properly removed
* or overwritten one by another. It is enabled by default.
*/
strict = true;

/**
* Registers a handler for a backdoor action.
* Note that there can only be one handler per action to avoid confusion,
* because in future versions the handlers will be able to return a promise or a value.
*
* @param {string} actionName
* @param {function} handler
* @throws {Error} if a handler for this action has already been set
* @throws {Error} if handler is not a function
* @example
* detoxBackdoor.registerActionHandler('displayText', ({ text }) => {
* setText(text);
* });
*/
registerActionHandler(actionName, handler) {
if (typeof actionName !== 'string') {
throw new Error('Detox backdoor action name must be a string');
}

if (typeof handler !== 'function') {
throw new Error(`Detox backdoor handler for action "${actionName}" must be a function`);
}

if (this.strict && this._handlers[actionName]) {
throw new Error(`Detox backdoor handler for action "${actionName}" has already been set`);
}

this._handlers[actionName] = handler;
}

/**
* Removes a handler for a backdoor action.
* By default, unremoved handlers will prevent new handlers from being set.
*/
clearActionHandler(actionName) {
delete this._handlers[actionName];
}

/**
* Adds a listener for a backdoor action.
* There can be multiple listeners per action, but you cannot return a value from a listener.
* @param {string} actionName
* @param {function} listener
* @throws {Error} if actionName is not a string
* @throws {Error} if listener is not a function
* @example
* detoxBackdoor.addActionListener('logMessage', ({ message }) => {
* console.log(message);
* });
*/
addActionListener(actionName, listener) {
if (typeof actionName !== 'string') {
throw new Error('Detox backdoor action name must be a string');
}

if (typeof listener !== 'function') {
throw new Error(`Detox backdoor listener for action "${actionName}" must be a function`);
}

if (!this._listeners[actionName]) {
this._listeners[actionName] = [];
}

this._listeners[actionName].push(listener);
}

/**
* Removes a listener for a backdoor action.
* @param {string} actionName
* @param {function} listener
* @throws {Error} if actionName is not a string
* @throws {Error} if listener is not a function
*/
removeActionListener(actionName, listener) {
if (typeof actionName !== 'string') {
throw new Error('Detox backdoor action name must be a string');
}

if (typeof listener !== 'function') {
throw new Error(`Detox backdoor listener for action "${actionName}" must be a function`);
}

const listeners = this._listeners[actionName];
if (listeners) {
const index = listeners.indexOf(listener);
if (index !== -1) {
listeners.splice(index, 1);
}
}
}

/**
* This fallback handler is called when no handler is set for a backdoor action.
* By default, it throws an error in strict mode and logs a warning otherwise.
* You can override it to provide a custom behavior.
* @param {object} event
* @param {string} event.action
*
* @example
* detoxBackdoor.onUnhandledAction = ({ action, ...args }) => { /* noop *\/ };
*/
onUnhandledAction = ({ action }) => {
const message = `Failed to find Detox backdoor handler for action "${action}"`;

if (this.strict) {
throw new Error(message);
} else {
console.warn(message);
}
};

/** @private */
_onBackdoorEvent = (event) => {
const listeners = this._listeners[event.action];
if (listeners) {
for (const listener of listeners) {
listener(event);
}
}

const handler = this._handlers[event.action];
if (handler) {
handler(event);
} else if (!listeners) {
this.onUnhandledAction(event);
}
};
}
10 changes: 10 additions & 0 deletions detox/react-native/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/* eslint-disable node/no-unsupported-features/es-syntax, import/namespace, node/no-unpublished-import */
import { DeviceEventEmitter, NativeAppEventEmitter, Platform } from 'react-native';

import { BackdoorEmitter } from './BackdoorEmitter';

export const detoxBackdoor = new BackdoorEmitter(
Platform.OS === 'ios'
? NativeAppEventEmitter
: DeviceEventEmitter
);
4 changes: 4 additions & 0 deletions detox/src/client/Client.js
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ class Client {
await this.sendAction(new actions.Shake());
}

async backdoor(data) {
return await this.sendAction(new actions.Backdoor(data));
}

async setOrientation(orientation) {
await this.sendAction(new actions.SetOrientation(orientation));
}
Expand Down
1 change: 1 addition & 0 deletions detox/src/client/Client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ describe('Client', () => {
['waitForActive', 'waitForActiveDone', actions.WaitForActive],
['waitUntilReady', 'ready', actions.Ready],
['currentStatus', 'currentStatusResult', actions.CurrentStatus, {}, { status: { app_status: 'idle' } }],
['backdoor', 'backdoorDone', actions.Backdoor],
])('.%s', (methodName, expectedResponseType, Action, params, expectedResponseParams) => {
beforeEach(async () => {
await client.connect();
Expand Down
20 changes: 20 additions & 0 deletions detox/src/client/actions/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -320,8 +320,28 @@ class CaptureViewHierarchy extends Action {
}
}

class Backdoor extends Action {
constructor(params) {
super ('backdoor', params);
}

get isAtomic() {
return true;
}

get timeout() {
return 0;
}

async handle(response) {
this.expectResponseOfType(response, 'backdoorDone');
return response;
}
}

module.exports = {
Action,
Backdoor,
Login,
WaitForBackground,
WaitForActive,
Expand Down
5 changes: 5 additions & 0 deletions detox/src/devices/runtime/RuntimeDevice.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class RuntimeDevice {
runtimeErrorComposer,
}, deviceDriver) {
const methodNames = [
'backdoor',
'captureViewHierarchy',
'clearKeychain',
'disableSynchronization',
Expand Down Expand Up @@ -191,6 +192,10 @@ class RuntimeDevice {
return this.deviceDriver.takeScreenshot(name);
}

async backdoor(data) {
await this.deviceDriver.backdoor(data);
}

async captureViewHierarchy(name = 'capture') {
return this.deviceDriver.captureViewHierarchy(name);
}
Expand Down
7 changes: 7 additions & 0 deletions detox/src/devices/runtime/RuntimeDevice.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,13 @@ describe('Device', () => {
});
});

it(`backdoor() should pass to device driver`, async () => {
const device = await aValidDevice();
await device.backdoor({ action: 'test' });
expect(driverMock.driver.backdoor).toHaveBeenCalledWith({ action: 'test' });
expect(driverMock.driver.backdoor).toHaveBeenCalledTimes(1);
});

it(`sendToHome() should pass to device driver`, async () => {
const device = await aValidDevice();
await device.sendToHome();
Expand Down
4 changes: 4 additions & 0 deletions detox/src/devices/runtime/drivers/DeviceDriverBase.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ class DeviceDriverBase {
return await this.client.reloadReactNative();
}

async backdoor(data) {
return await this.client.backdoor(data);
}

createPayloadFile(notification) {
const notificationFilePath = path.join(this.createRandomDirectory(), `payload.json`);
fs.writeFileSync(notificationFilePath, JSON.stringify(notification, null, 2));
Expand Down
5 changes: 5 additions & 0 deletions detox/test/e2e/03.actions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@
await driver.tapsElement.assertTappedOnce();
});

it('should be able to send backdoor commands', async () => {
await device.backdoor({ action: 'greet', text: 'Arbitrary Text' });
await expect(element(by.text('Arbitrary Text!!!'))).toBeVisible();
});

it.each([
'activate',
'magicTap',
Expand Down Expand Up @@ -82,7 +87,7 @@
});
});

it.skip(':android: should throw if tap handling is too slow', async () => {

Check warning on line 90 in detox/test/e2e/03.actions.test.js

View workflow job for this annotation

GitHub Actions / Linux

Disabled test

Check warning on line 90 in detox/test/e2e/03.actions.test.js

View workflow job for this annotation

GitHub Actions / Linux

Disabled test
try {
await driver.sluggishTapElement.tap();
} catch (e) {
Expand Down
Loading
Loading