Edition 55

Using Mobile Execution Commands to Continuously Stream Device Logs with Appium

I recently wrote about how to access Android Logcat logs using the getLogs() command, but Appium also supports subscribing to device logs as a stream of events via a WebSocket connection. Rather than getting a chunk of recent log lines every time a synchronous call to getLogs() is made, you can assign a function to be called every time a new log line is generated. Overall, this results in a better programming model for most cases, and only adds a little more code depending on the language.

Appium needs to be told to start its WebSocket server before we can begin listening for log messages. This is done using one of the special mobile: commands.

driver.executeScript("mobile:startLogsBroadcast");

Appium will start a WebSocket server and accept connections on the same host and port to which you connected to start the test session. The path is different though; instead of /wd/hub the path starts with /ws:

  • /ws/session/{sessionId}/appium/device/logcat for Android logcat logs
  • /ws/session/{sessionId}/appium/device/syslog for iOS device logs

WebSocket URLs start with ws:// instead of http://. Running locally with the default Appium port, the WebSocket URLs would be:

  • ws://localhost:4723/ws/session/{sessionId}/appium/device/logcat on Android
  • ws://localhost:4723/ws/session/{sessionId}/appium/device/syslog on iOS

Using a WebSocket client and connecting to these URLs, we can assign a function to perform whatever action we want whenever a new log message is received.

There are several advantages of streaming logs this way. By using streams, we are only holding a single log message in memory at a time. If the device logs are very long, calling getLogs() and loading all the device logs could take a while and even consume too much memory on our test servers. We are also able to make sure that we have the latest logs available. If an exception is thrown in our test code, or a crash occurs in the app, device, or Appium server, we can be sure that we have the last log message which made its way through our system (which is most likely to contain information about the crash!). This will work better than catching exceptions and then calling getLogs(), for by then it may be too late to reach the device. We can also abstract the log-handling code further from our test code. By writing multiple WebSocket clients, we can change how logs are handled depending on the system environment.

For our Java example, I used TooTallNates's java-websocket package. I'm also including a Javascript example using websocket-stream

import io.appium.java_client.AppiumDriver;
import org.java_websocket.client.WebSocketClient;
import org.java_websocket.handshake.ServerHandshake;
import org.junit.Test;
import org.openqa.selenium.remote.DesiredCapabilities;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;



public class Edition055_ADB_Logcat_Streaming {

    private String ANDROID_APP = "https://github.com/cloudgrey-io/the-app/releases/download/v1.8.1/TheApp-v1.8.1.apk";
    private String IOS_APP = "https://github.com/cloudgrey-io/the-app/releases/download/v1.6.1/TheApp-v1.6.1.app.zip";

    private AppiumDriver driver;

    public class LogClient extends WebSocketClient {

        public LogClient( URI serverURI ) {
            super(serverURI);
        }

        @Override
        public void onOpen(ServerHandshake handshakedata) {
            System.out.println("WEBSOCKET OPENED");
        }

        @Override
        public void onMessage(String message) {
            System.out.println(message);
        }

        @Override
        public void onClose(int code, String reason, boolean remote) {
            System.out.println("Connection closed, log streaming has stopped");
        }

        @Override
        public void onError(Exception ex) {
            ex.printStackTrace();
            // if the error is fatal then onClose will be called additionally
        }
    }

    @Test
    public void streamAndroidLogs() throws URISyntaxException, MalformedURLException {
        DesiredCapabilities caps = new DesiredCapabilities();
        caps.setCapability("platformName", "Android");
        caps.setCapability("deviceName", "Android Emulator");
        caps.setCapability("automationName", "UiAutomator2");
        caps.setCapability("app", ANDROID_APP);

        driver = new AppiumDriver(new URL("http://localhost:4723/wd/hub"), caps);

        LogClient logClient = new LogClient(new URI( "ws://localhost:4723/ws/session/" + driver.getSessionId() + "/appium/device/logcat"));

        driver.executeScript("mobile:startLogsBroadcast");

        logClient.connect();

        try { Thread.sleep(5000); } catch (Exception ign) {} // logs printed to stdout while we're sleeping.

        driver.executeScript("mobile:stopLogsBroadcast");
        driver.quit();
    }

    @Test
    public void streamIOSLogs() throws URISyntaxException, MalformedURLException {
        DesiredCapabilities caps = new DesiredCapabilities();
        caps.setCapability("platformName", "iOS");
        caps.setCapability("platformVersion", "12.1");
        caps.setCapability("deviceName", "iPhone XS");
        caps.setCapability("automationName", "XCUITest");
        caps.setCapability("app", IOS_APP);

        driver = new AppiumDriver(new URL("http://localhost:4723/wd/hub"), caps);

        LogClient logClient = new LogClient( new URI("ws://localhost:4723/ws/session/" + driver.getSessionId() + "/appium/device/syslog"));

        driver.executeScript("mobile:startLogsBroadcast");

        logClient.connect();

        try { Thread.sleep(5000); } catch (Exception ign) {} // logs printed to stdout while we're sleeping.

        driver.executeScript("mobile:stopLogsBroadcast");
        driver.quit();
    }
}

And the Javascript example:

let test = require('ava')
let { remote } = require('webdriverio')
let B = require('bluebird')
let websocket = require('websocket-stream')

let driver

test('stream Android logcat logs', async t => {
  driver = await remote({
    hostname: 'localhost',
    port: 4723,
    path: '/wd/hub',
    capabilities: {
      platformName: 'Android',
      deviceName: 'Android Emulator',
      automationName: 'UiAutomator2',
      app: 'https://github.com/cloudgrey-io/the-app/releases/download/v1.8.1/TheApp-v1.8.1.apk'
    },
    logLevel: 'error'
  })

  let logStream = websocket(`ws://localhost:4723/ws/session/${driver.sessionId}/appium/device/logcat`)
  logStream.pipe(process.stdout)

  logStream.on('finish', () => {
    console.log('Connection closed, log streaming has stopped')
  })

  driver.executeScript('mobile:startLogsBroadcast', [])

  await B.delay(5000)

  driver.executeScript('mobile:stopLogsBroadcast', [])

  await driver.deleteSession()
  t.pass()
})

test('stream iOS system logs', async t => {
  driver = await remote({
    hostname: 'localhost',
    port: 4723,
    path: '/wd/hub',
    capabilities: {
      platformName: 'iOS',
      platformVersion: '12.1',
      deviceName: 'iPhone XS',
      automationName: 'XCUITest',
      app: 'https://github.com/cloudgrey-io/the-app/releases/download/v1.6.1/TheApp-v1.6.1.app.zip'
    },
    logLevel: 'error'
  })

  let logStream = websocket(`ws://localhost:4723/ws/session/${driver.sessionId}/appium/device/syslog`)
  logStream.pipe(process.stdout)

  logStream.on('finish', () => {
    console.log('Connection closed, log streaming has stopped')
  })

  driver.executeScript('mobile:startLogsBroadcast', [])

  await B.delay(5000)

  driver.executeScript('mobile:stopLogsBroadcast', [])

  await driver.deleteSession()
  t.pass()
})

(Both versions of the full code demonstration are available on GitHub)