3 minute read

Gaining access to Command-line from Maestro

What’s this?

The command line is quite a powerful tool. It can be used to automate a bunch of things here and there. Obviously, it’s not only about Shell scripts, you can run any other tool from it as well. But using it directly is not always possible.

Recently, I showed how to gain access to the command line from XCTest. This time it’s Maestro’s turn, as it also works inside a sandbox (more precisely, a JavaScript sandbox). Let’s bypass this limitation and learn how to gain access to the command line from Maestro 🎹

So, basically, the same dish is on the menu today, just with a slightly different serving 👨‍🍳

And this is useful because…?

  1. This uncovers a whole new world of simulator/emulator-related features. For example:
  2. This opens up the possibility to create and use a standalone mock server that can completely replace the real backend for the testing purposes
  3. This allows executing any macOS UI actions through Applescript from your Maestro tests
  4. This gives the option to automate web-related actions via playwright or selenium instead of opening Safari/Chrome on iOS/Android
  5. This offers a pretty flexible way to export anything you want from the test to the host machine (screenshots, mocks, test data, etc.)
  6. And much much more 🤠

Cool, what’s the trick?

Even though Maestro does support a minimal subset of vanilla JavaScript APIs, it does not allow to access the filesystem. But we can still use the HTTP protocol to communicate with the host machine.

So, let’s create an HTTP server that will be listening for requests. Then we can send a request from Maestro to the server and run whatever we want. The server can even return the output back to Maestro if required.

Okay, show me how to do this

Proof of concept

GIVEN we have a server running on localhost:4567
AND we sent a request to /terminal endpoint from Maestro with the desired command
WHEN server receives the request
AND server executes the command
THEN server returns the result back to Maestro
AND Maestro verifies the result

Walkthrough

  1. Install Maestro

     brew install facebook/fb/idb-companion
     curl -Ls "https://get.maestro.mobile.dev" | bash
    
  2. Choose any web framework of your choice (they are pretty much all the same for our use case)

     {
       "js": "express", // https://github.com/expressjs/express
       "rb": "sinatra", // https://github.com/sinatra/sinatra
       "py": "flask", // https://github.com/pallets/flask
       "go": "gin", // https://github.com/gin-gonic/gin
       "..": "..." // ...
     }
    
  3. Create an HTTP server (I picked up { "js": "express" } as an example)

    • Command-line:

        npm install express
        touch server.js
        open server.js
      
    • Source code:

        // server.js
      
        const process = require("child_process");
        const express = require("express");
        const app = express();
        const port = 4567;
      
        app.use(express.json());
      
        app.listen(port);
      
        app.post("/terminal", (req, res) => {
          const output = exec(req.body.command, req.query.async).toString("utf8").trim();
          res.send(output);
        });
      
        function exec(command, async) {
          if (async === "true") {
            return process.exec(command);
          } else {
            return process.execSync(command);
          }
        }
      
  4. Run the server

     node server.js
    
  5. Create a tiny layer between the server and the test

     // terminal.js
    
     var response = http.post(`http://localhost:4567/terminal?async=${async}`, {
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ command: exec })
     })
    
     output.terminal = response.body;
    
  6. Create the test file

     # test.yaml
    
     appId: ${app_id}
     ---
    
     - launchApp
     - runScript:
         file: terminal.js
         env:
           exec: "sw_vers -productName"
           async: false
     - assertTrue: ${output.terminal == "macOS"}
    
  7. Launch the emulator/simulator
  8. Run the test ✅

    • on iOS:

        udid="7E0929F4-CFB5-41E7-9943-85CA9370EE51" # This is a unique iOS Simulator id
        app_id="com.apple.Preferences" # This is a Settings app, just as an example
        maestro --udid ${udid} test --env app_id=${app_id} test.yaml
      
    • on Android:

        udid="emulator-5554" # This is a default id for the first running Android Emulator
        app_id="com.android.settings" # This is a Settings app, just as an example
        maestro --udid ${udid} test --env app_id=${app_id} test.yaml
      
  9. Adjust the scripts to your needs and enjoy hacking!

Does this work on real devices?

Yup, but not on iOS, at least at the moment.

Alrighty, does this work on CI?

Sure thing, works like a charm!

Just make sure to run the server in the background on CI:

node server.js &

Thanks a mill!

No probs! Check out this GitHub repo for more details 🙂

Updated: