Skip to content

Commit

Permalink
(Android) Friendly-fail test runner is app crashes on launch
Browse files Browse the repository at this point in the history
  • Loading branch information
d4vidi committed Feb 6, 2020
1 parent 311d82e commit 22d4f49
Show file tree
Hide file tree
Showing 13 changed files with 280 additions and 39 deletions.
17 changes: 5 additions & 12 deletions detox/android/detox/src/main/java/com/wix/detox/Detox.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import androidx.annotation.NonNull;
import android.util.Base64;
import android.util.Log;

import java.util.Arrays;
import java.util.List;

import androidx.annotation.NonNull;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.rule.ActivityTestRule;

Expand Down Expand Up @@ -118,7 +118,7 @@ public static void runTests(ActivityTestRule activityTestRule, @NonNull final Co
sActivityTestRule = activityTestRule;

Intent intent = extractInitialIntent();
activityTestRule.launchActivity(intent);
sActivityTestRule.launchActivity(intent);

// Kicks off another thread and attaches a Looper to that.
// The goal is to keep the test thread intact,
Expand All @@ -127,14 +127,7 @@ public static void runTests(ActivityTestRule activityTestRule, @NonNull final Co
@Override
public void run() {
Looper.prepare();
Handler handler = new Handler();
handler.post(new Runnable() {
@Override
public void run() {
DetoxManager detoxManager = new DetoxManager(context);
detoxManager.start();
}
});
new DetoxManager(context).start();
Looper.loop();
}
}, "com.wix.detox.manager");
Expand All @@ -144,7 +137,7 @@ public void run() {
t.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Got interrupted", e);
throw new RuntimeException("Detox got interrupted prematurely", e);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,15 @@ class DetoxManager implements WebSocketClient.ActionHandler {

void start() {
if (detoxServerUrl != null && detoxSessionId != null) {
initReactNativeIfNeeded();
initWSClient();
initCrashHandler();
initActionHandlers();
handler.post(new Runnable() {
@Override
public void run() {
initReactNativeIfNeeded();
initWSClient();
initCrashHandler();
initActionHandlers();
}
});
}
}

Expand Down
30 changes: 30 additions & 0 deletions detox/src/devices/drivers/AndroidDriver.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const sleep = require('../../utils/sleep');
const retry = require('../../utils/retry');
const { interruptProcess, spawnAndLog } = require('../../utils/exec');
const AndroidExpect = require('../../android/expect');
const { InstrumentationLogsParser } = require('./InstrumentationLogsParser');

const reservedInstrumentationArgs = ['class', 'package', 'func', 'unit', 'size', 'perf', 'debug', 'log', 'emma', 'coverageFile'];
const isReservedInstrumentationArg = (arg) => reservedInstrumentationArgs.includes(arg);
Expand All @@ -31,6 +32,10 @@ class AndroidDriver extends DeviceDriverBase {
constructor(config) {
super(config);

this.instrumentationLogsParser = new InstrumentationLogsParser();
this.instrumentationProcess = null;
this.instrumentationStackTrace = '';

this.invocationManager = new InvocationManager(this.client);
this.matchers = new AndroidExpect(this.invocationManager);
this.uiDevice = new UiDeviceProxy(this.invocationManager).getUIDevice();
Expand Down Expand Up @@ -125,6 +130,24 @@ class AndroidDriver extends DeviceDriverBase {
// Other payload content types are not yet supported.
}

async waitUntilReady() {
let intervalId;
try {
await Promise.race([
super.waitUntilReady(),
new Promise((resolve, reject) => {
intervalId = setInterval(() => {
if (!this.instrumentationProcess) {
reject(new Error('Failed to run application on the device; Stack-trace dump:\n' + this.instrumentationStackTrace));
}
}, 100);
}),
]);
} finally {
!_.isUndefined(intervalId) && clearInterval(intervalId);
}
}

async sendToHome(deviceId, params) {
await this.uiDevice.pressHome();
}
Expand Down Expand Up @@ -217,12 +240,19 @@ class AndroidDriver extends DeviceDriverBase {
const spawnFlags = [`-s`, `${deviceId}`, `shell`, `am`, `instrument`, `-w`, `-r`, ...launchArgs, ...additionalLaunchArgs, testRunner];

this.instrumentationProcess = spawnAndLog(this.adb.adbBin, spawnFlags, { detached: false });
this.instrumentationProcess.childProcess.stdout.on('data', (raw) => this._extractStackTraceFromInstrumLogs(raw.toString()));
this.instrumentationProcess.childProcess.on('close', async () => {
await this._terminateInstrumentation();
await this.adb.reverseRemove(deviceId, serverPort);
});
}

_extractStackTraceFromInstrumLogs(logsDump) {
if (this.instrumentationLogsParser.hasStackTraceLog(logsDump)) {
this.instrumentationStackTrace = this.instrumentationLogsParser.getStackTrace(logsDump);
}
}

async _queryPID(deviceId, bundleId, waitAtStart = true) {
if (waitAtStart) {
await sleep(500);
Expand Down
91 changes: 85 additions & 6 deletions detox/src/devices/drivers/AndroidDriver.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ describe('Android driver', () => {
const bundleId = 'bundle-id-mock';

let logger;
let client;
let mockInstrumLogsParser;
let exec;
beforeEach(() => {
jest.mock('../../utils/encoding', () => ({
Expand All @@ -15,6 +17,10 @@ describe('Android driver', () => {
jest.mock('../../utils/sleep', () => jest.fn().mockResolvedValue(''));
jest.mock('../../utils/retry', () => jest.fn().mockResolvedValue(''));

jest.mock('./InstrumentationLogsParser', () => ({
InstrumentationLogsParser: mockInstrumentationLogsParserClass,
}));

const mockLogger = {
warn: jest.fn(),
};
Expand All @@ -28,25 +34,89 @@ describe('Android driver', () => {
spawnAndLog: jest.fn().mockReturnValue({
childProcess: {
on: jest.fn(),
stdout: {
on: jest.fn(),
}
}
}),
interruptProcess: jest.fn(),
}));
exec = require('../../utils/exec');

client = {
configuration: {
server: 'ws://localhost:1234'
},
waitUntilReady: jest.fn(),
};
});

let uut;
beforeEach(() => {
const AndroidDriver = require('./AndroidDriver');
uut = new AndroidDriver({
client: {
configuration: {
server: 'ws://localhost:1234'
}
}
client,
});
});

describe('launch args', () => {
describe('Instrumentation bootstrap', () => {
it('should launch instrumentation upon app launch', async () => {
await uut.launchApp(deviceId, bundleId, {}, '');

expect(exec.spawnAndLog).toHaveBeenCalledWith(
expect.anything(),
expect.arrayContaining(['shell', 'am', 'instrument']),
expect.anything(),
);
});
});

describe('Device ready-wait', () => {
beforeEach(async () => {
await uut.launchApp(deviceId, bundleId, {}, '');
});

it('should delegate wait to device being ready via client api', async () => {
await uut.waitUntilReady();
expect(client.waitUntilReady).toHaveBeenCalled();
}, 2000);

it('should fail if instrumentation dies prematurely while waiting for device-ready resolution', async () => {
const clientWaitResolve = mockDeviceReadyPromise();

const promise = uut.waitUntilReady();
setTimeout(async () => await killInstrumentation(exec.spawnAndLog()), 1);

try {
await promise;
fail('Expected an error and none was thrown');
} catch (e) {
expect(e.message).toEqual('Failed to run application on the device; Stack-trace dump:\nStacktrace mock');
} finally {
clientWaitResolve();
}
}, 2000);

const mockDeviceReadyPromise = () => {
let clientResolve;
client.waitUntilReady.mockReturnValue(new Promise((resolve) => clientResolve = resolve));
return clientResolve;
};

const killInstrumentation = async (instrumProcess) => {
const { childProcess } = instrumProcess;

const stdoutSubscribeCallArgs = childProcess.stdout.on.mock.calls[0];
const stdoutListenerFn = stdoutSubscribeCallArgs[1];
stdoutListenerFn('Doesnt matter what we put here');

const closeEvCallArgs = childProcess.on.mock.calls[0];
const closeEvListenerFn = closeEvCallArgs[1];
await closeEvListenerFn();
};
});

describe('Launch args', () => {
const expectSpawnedFlag = (spawnedFlags) => ({
startingIndex: (index) => ({
toBe: ({key, value}) => {
Expand Down Expand Up @@ -163,6 +233,8 @@ class mockADBClass {
this.getInstrumentationRunner = jest.fn();
this.reverse = jest.fn();
this.reverseRemove = jest.fn();

this.adbBin = 'ADB binary mock';
}
}

Expand All @@ -171,3 +243,10 @@ class mockAsyncEmitter {
this.emit = jest.fn();
}
}

class mockInstrumentationLogsParserClass {
constructor() {
this.hasStackTraceLog = () => true;
this.getStackTrace = () => 'Stacktrace mock';
}
}
42 changes: 42 additions & 0 deletions detox/src/devices/drivers/InstrumentationLogsParser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
const INSTRUMENTATION_LOGS_PREFIX = 'INSTRUMENTATION_STATUS';
const STACKTRACE_PREFIX_TEXT = INSTRUMENTATION_LOGS_PREFIX + ': stack=';

class InstrumentationLogsParser {
hasStackTraceLog(logsDump) {
return logsDump.includes(STACKTRACE_PREFIX_TEXT);
}

getStackTrace(logsDump) {
const logLines = logsDump.split('\n');

const index = this._findStackTraceLog(logLines);
const stackTrace = this._extractStackTrace(logLines, index);
return stackTrace;
}

_findStackTraceLog(logLines) {
let i;
for (i = 0; i < logLines.length && !logLines[i].includes(STACKTRACE_PREFIX_TEXT); i++) {}
return i;
}

_extractStackTrace(logLines, i) {
if (i < logLines.length) {
logLines[i] = logLines[i].replace(STACKTRACE_PREFIX_TEXT, '');
}

let stackTrace = '';
for (
; i < logLines.length
&& logLines[i].trim()
&& !logLines[i].includes(INSTRUMENTATION_LOGS_PREFIX)
; i++) {
stackTrace = stackTrace.concat(logLines[i], '\n');
}
return stackTrace;
}
}

module.exports = {
InstrumentationLogsParser
};
48 changes: 48 additions & 0 deletions detox/src/devices/drivers/InstrumentationLogsParser.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
describe('Instrumentation logs parser', () => {
describe('stack trace parser', () => {
let uut;
beforeEach(() => {
const { InstrumentationLogsParser } = require('./InstrumentationLogsParser');
uut = new InstrumentationLogsParser();
});

it('should query stacktrace for false for a no-string', () => {
const logsDump = '';
expect(uut.hasStackTraceLog(logsDump)).toEqual(false);
});

it('should query stacktrace for true for a log matching the stacktrace prefix', () => {
const logsDump = 'INSTRUMENTATION_STATUS: stack=';
expect(uut.hasStackTraceLog(logsDump)).toEqual(true);
});

it('should query stacktrack for true for a log that holds the stacktrace prefix alongside other stuff', () => {
const logsDump = [
'INSTRUMENTATION_STATUS: stream=\ncom.example.DetoxTest',
'INSTRUMENTATION_STATUS: stack=stackFrame1\n stackFrame2',
'INSTRUMENTATION_STATUS: id=AndroidJUnitRunner',
].join('\n');
expect(uut.hasStackTraceLog(logsDump)).toEqual(true);
});

it('should return empty stacktrace for a no-string', () => {
const logsDump = '';
expect(uut.getStackTrace(logsDump)).toEqual('');
});

it('should return stacktrace for a stack-trace logs dump', () => {
const logsDump = 'INSTRUMENTATION_STATUS: stack=stackFrame1\n stackFrame2\n';
expect(uut.getStackTrace(logsDump)).toEqual('stackFrame1\n stackFrame2\n');
});

it('should return stacktrace for a multi-content logs dump', () => {
const logsDump = [
'INSTRUMENTATION_STATUS: stream=\ncom.example.DetoxTest',
'INSTRUMENTATION_STATUS: stack=stackFrame1\n stackFrame2',
'INSTRUMENTATION_STATUS_CODE: 1',
'INSTRUMENTATION_STATUS: id=AndroidJUnitRunner',
].join('\n');
expect(uut.getStackTrace(logsDump)).toEqual('stackFrame1\n stackFrame2\n');
});
});
});
Loading

0 comments on commit 22d4f49

Please sign in to comment.