Teil 1: Die Browserkonsole als remote Logausgabe verwenden

Der ferngesteuerte Browser
Keine Kommentare

Die Fähigkeiten eines Browsers zu nutzen, auch wenn die eigentliche Applikation gar nicht im Browser läuft, wie kann das gehen? Mittels WebSockets und ein wenig JavaScript lässt sich das im Prinzip einfach umsetzen, um remote Browser-Console-Logging und Browser-Testing zu nutzen.

Der Anteil von webbasierten Applikationen steigt ständig, seien es klassische Browser-Apps, responsive Apps, oder Apps, die über Container wie Cordova oder Electron „nativ verpackt“ werden. Darüber hinaus bieten Browser umfangreiche Funktionalität – zum Beispiel für Logausgaben – an. Es wäre daher überaus nützlich, wenn man Browserfunktionalität als externe Remote-Funktionen in die eigene App einbinden und darüber z. B. die Logausgaben nutzen könnte. Oder wenn man in Testsuites App-Funktionalität, die im Browser läuft, remote aufrufen und deren Ergebnisse automatisiert überprüfen könnte.

Artikelserie
Teil 1: Die Browserkonsole als remote Logausgabe verwenden
Teil 2: Den Browser zum automatisierten Browser-Testing verwenden

Der Browser als Logausgabe

Moderne Browser bieten eine sehr gute und bequeme Möglichkeit, Logausgaben formatiert auszugeben, und zwar über das console Objekt. Besonders mit Chrome lässt sich der Output sehr flexibel formatieren und filtern, z. B. kann man in „error“ und „warn“ unterscheiden, Gruppierungen einführen oder die Ausgabe mittels CSS gestalten (Abb. 1).

Abb. 1: Logausgaben in der Chrome Console

Diese Möglichkeiten hat wahrscheinlich jeder schon genutzt, der im Browser JavaScript verwendet hat. Es wäre doch praktisch, wenn man diese bequeme Art der Logausgabe und -formatierung nicht nur in Browser-Apps, sondern generell verwenden könnte. Wir verwenden es z. B., um komplexe Programmabläufe in Node.js oder .NET-Applikationen zu visualisieren. Wie wir sehen können, lässt sich das mit der Browserfernsteuerung leicht durchführen.
Ein Hinweis für JavaScript-Puristen: Die im Folgenden gezeigten Beispiele verwenden TypeScript. Wer das nicht mag, möge sich bitte die Typangaben wegdenken.

Automatisiertes Testen von Browser-Apps

Automatisiertes Testen von Browser-AppsFür das automatisierte Testen von Browser-Apps gibt es generell mehrere Ansätze, die im Folgenden dargestellt werden.

Unit-Tests: Zunächst einmal sollten die einzelnen UI-Komponenten Unit-getestet werden, was sich mit den üblichen JS-Testframeworks leicht erledigen lässt.

Simulierte Benutzertests mit Recording und Replay: Mit einem Browsertesttool wie Selenium lassen sich Benutzerinteraktionen aufzeichnen, mit Testbedingungen versehen und wieder abspielen. Solche Tests eignen sich sehr gut für „klassische“ Testteams, die die App als Blackbox testen.

 

API Conference 2018

API Management – was braucht man um erfolgreich zu sein?

mit Andre Karalus und Carsten Sensler (ArtOfArc)

Web APIs mit Node.js entwickeln

mit Sebastian Springer (MaibornWolff GmbH)

UI-Integrationstests für DevOps: Im Rahmen von DevOps-Ansätzen hat es sich bewährt, das User Interface einer Art von Integrationstests zu unterziehen. Das bedeutet, dass komplette Use Cases unter Einbeziehung der internen Struktur der App durchgetestet werden. Die App und die UI-Komponenten sind so gebaut, dass sie per JavaScript aufgerufen, verändert und geprüft werden können. Einer unserer Standardtests hierbei ist z. B., dass über den Router der App sämtliche Routen angesprungen werden und pro Route überprüft wird, welche Daten die Komponenten enthalten. Bei React-Apps werden z. B. „Props“ und „State“ überprüft. Im Gegensatz zu den simulierten Benutzertests haben solche UI-Integrationstests einen gewissen Einblick in Interna der App. Sie werden von den Entwicklern ebenso wie Unit-Tests während des Erstellens der App mit aufgebaut. Da sie sich nicht auf das Klicken von UI-Elementen verlassen, sind sie stabiler und langlebiger als simulierte Benutzertests. Genau dafür ist die Browserfernsteuerung hervorragend geeignet, wie wir im nächsten Artikel zeigen werden.

Der Browser als (Performance-)Analysetool

Die verschiedenen Browser-Dev-Tools ermöglichen nicht nur Logausgaben, sondern auch Performance-Profiling. Folgende Beispiele sind wieder aus den Google-Chrome Dev-Tools:

  •  console.profile([label]): Startet ein JS CPU Profile
  • console.time(): Startet einen Timer, z. B. um Methodenaufrufzeiten zu messen
  • console.timeStamp(): Fügt der Timeline ein Event hinzu, während eine time() Messung läuft
  • console.count(): Zählt die Anzahl der Logausgaben gleichen Inhalts

Die Grundidee

Was ist nun die Grundidee, um die obigen Anwendungsfälle der Browserfernsteuerung umzusetzen? Die Grundlage bilden WebSockets (Abb. 2): Die Applikation, von der aus wir den Browser fernsteuern wollen (z. B. Node.js oder .NET) startet einen WebSocket-Server. Der fernzusteuernde Browser öffnet eine Websocket-Verbindung zu diesem Server. Sobald die Verbindung steht, können Daten effizient in beide Richtungen ausgetauscht werden. Um nun dem Browser ein Fernsteuerungskommando zu senden, werden das jeweilige Browserobjekt (z. B. console), eine Funktion (z. B. log) und zugehörige Parameter (z. B. test log) als JSON verpackt und an den Browser gesendet. Dieser entpackt die drei Informationen, holt das Objekt aus dem globalen window-Kontext (z. B. object = window[„console“]), liest von diesem Objekt die gewünschte Funktion aus (z. B. function = object[„log“]) und ruft diese mit den übergebenen Parametern auf (z. B. function.apply(parameters)). Damit ist die Grundfunktionalität der Fernsteuerung gegeben: Wir können jedes Kommando an jedes im Browser über den window-Kontext zugängliche Objekt senden.

Abb. 2: Grundidee der Browserfernsteuerung

Node.js: Die Fernsteuerungsklasse „RemoteControl“

In diesem Beispiel verwenden wir Node.js, um Objekte im Browser fernzusteuern. Unser Fernsteuercode ist in der Klasse RemoteControl umgesetzt. Um Verwirrung zu vermeiden, verwenden wir bei unseren Klassen nicht die Begriffe Server und Client: Aus der Fernsteuerungssicht ist der WebSocket-Server der Client, der die Kommandos an den Browser sendet. Und der Browser, der den WebSocket-Client öffnet, ist eigentlich der Server, der Kommandos empfängt. Der Code für den Aufruf der Fernsteuerung ist denkbar einfach und entspricht genau der oben beschriebenen Grundidee (Listing 1). Damit hätten wir schon unseren ersten Anwendungsfall abgedeckt: Die Verwendung des Browsers als Log-Output und Log-Viewer. Natürlich müsste man die Logaufrufe für den Echteinsatz noch benutzerfreundlicher verpacken, z. B. in eine eigene Klasse RemoteConsole oder in einen Log-Appender für das Logging-Framework der Wahl.

import { RemoteControl } from "../remoteControl/remoteControl";

const webSocketListenToBrowser = 8001;

testRemoteControl();

async function testRemoteControl() {
  // Start listening.
  let remoteControl = new RemoteControl(webSocketListenToBrowser);
  await remoteControl.init();

  // Get a proxy we can use to send
  // commands to the remote object.
  let remoteProxy = remoteControl.getRemoteProxy("console")

  // Send commands to the console in the remote browser.
  remoteProxy.execute("log",
    ["Test log from remote Control"]);
  remoteProxy.execute("warn",
    ["Test warn log from remote Control"]);
  remoteProxy.execute("group",
    ["Test log group from remote Control"]);
  remoteProxy.execute("log",
    ["Test log within group from remote Control"]);
  remoteProxy.execute("groupEnd", []);
  remoteProxy.execute("log",
    ["Test log after group from remote Control"]);
}

In Listing 2 findet sich der Code der Klasse RemoteControl. Zur einfacheren Verwaltung der Nachrichten zwischen der Fernsteuerung und dem ferngesteuerten Browser werden die in Listing 3 gezeigten Interfaces Command und CommandResult verwendet. Der eigentliche Proxy, den wir zum Aufrufen der Remote-Methoden verwenden, ist in der Klasse RemoteProxy umgesetzt (Listing 4). Er repräsentiert genau ein Remote-Objekt im Browser, z. B. das console Objekt.

Die Aufrufe, die wir hier beim ferngesteuerten console-Objekt sehen, funktionieren nach dem Prinzip fire and forget: Wir schicken ein Kommando ab, prüfen den Rückgabewert aber nicht. Im zweiten Teil der Artikelserie werden wir sehen, dass es z. B. bei Testsuiten, die wir remote ausführen, sehr sinnvoll ist, den Rückgabewert zu prüfen.

export class RemoteControl {

  // Keep track of all our proxies.
  proxies = new Map<string, RemoteProxy>();

  // Our websocket server and websocket
  private webSocketServer: WebSocket.Server;
  public webSocket: WebSocket;

  /**
   * Ctor
   * @param port the port for the remote connection
   */
  constructor(public port: number) {
  }

  /**
   * Start listening via web-socket-server.
   */
  async init(): Promise {
    let self = this;

    let prom = new Promise((resolve, reject) => {

      this.webSocketServer = new WebSocket.Server({ port: this.port });

      this.webSocketServer.on('connection', (ws: WebSocket) => {
        console.debug("RemoteControl:onConnection: New connection");
        this.webSocket = ws;
        resolve(ws);

        ws.on('open', () => {
          console.debug('RemoteControl:onOpen: WebSocket opened');
        });
        ws.on('message', (data: any, flags: { binary: boolean }) => {
          let msg = JSON.parse(data);
          if (msg.commandResult) {
            let commandResult = (msg as CommandResult)
            if (!commandResult.proxyId) {
              throw new Error(
                `Command result is missing proxy ID.`);
            }
            if (!commandResult.commandId) {
              throw new Error(
                `Command result is missing command ID`);
            }
            // Look up corresponding proxy.
            let proxy = this.proxies.get(commandResult.proxyId)!;

            // And tell it to call the matching callback.
            proxy.callResultCallBack(commandResult.commandId,
              commandResult);
          }
        });
        ws.on('error', (err) => {
          console.debug('RemoteControl:onError: %s', err);
        });
        ws.on('close', (code: number, message: string) => {
          console.debug(
            `RemoteControl:onClose: WebSocket closed:
            number: ${code}, message: ${message}`);
        });
        ws.on('ping', (data: any, flags: { binary: boolean }) => {
          console.debug('RemoteControl:onPing: received:  %s', data);
        });
        ws.on('pong', (data: any, flags: { binary: boolean }) => {
          console.debug('RemoteControl:onPong: received:  %s', data);
        });

      });
    })
    console.info("Waiting for remote browser to connect via web-socket");
    return prom;
  }

  /**
   * Create a remote proxy that uses our web-socket for communication.
   * In real app, we need to provide a "unregisterRemoteProxy()", too.
   * @param remoteObjectName: The name of the object we want to proxy. 
   */
  getRemoteProxy(remoteObjectName: string): RemoteProxy {
    let remoteProxy = new RemoteProxy(remoteObjectName, this);
    this.proxies.set(remoteProxy.uniqueId, remoteProxy);
    return remoteProxy;
  }

  /**
   * Close the web socket.
   */
  close() {
    if (this.webSocket) {
      this.webSocket.close();
    }
    if (this.webSocketServer) {
      this.webSocketServer.close();
    }
  }
}
export interface Command {
  // Name of the window-property we want
  // to act upon (e.g. 'window["console"]').
  object: string;
  // The id of the proxy that is kept by "RemoteControl"
  proxyId: string;
  // The method to be called on "object", e.g. "log".
  method: string;
  // Is the method async?
  async?: boolean;
  // Parameters to the method
  parameters: any[];
  // The unique id of the command, will be sent back.
  commandId: string;
  // Should browser send back a result-message?
  expectResult: boolean;
  // Callback, not sent to browser, just used by "RemoteControl".
  callBack?: (result: CommandResult) => void;
}

/**
 * See above: Should be in same file as for the client (remoteBrowser.ts).
 */
export interface CommandResult {
    // Must be same ID as sent by "RemoteControl"
    commandId: string;
    // Must always be true.
    commandResult: boolean;
    // Must be same ID as sent by "RemoteControl"
    proxyId: string;
    // Status number
    status: number;
    // Description
    description?: string;
    // The resulting data
    data: any;
}
export class RemoteProxy {
  // All commands sent to the remote browser are kept 
  // until the remote browser sends the result.
  // Note: since this is a Node.js implementation, we
  // do not have to care about locking the Map.
  pendingCommands = new Map<string, Command>();

  // Every proxy gets a unique id.
  uniqueId = createGuid();

  constructor(protected remoteObjectName: string,
    protected remoteControl: RemoteControl) {
}

  /**
   * Execute the method on our remote object.
   * @param method 
   * @param params 
   */
  execute(method: string, params: any,
    expectResult: boolean = false,
    async: boolean = false): Promise | undefined {
    let webSocket = this.remoteControl.webSocket;
    if (!webSocket) {
      throw new Error(`Web-Socket is undefined`);
    }
    let command: Command = {
      object: this.remoteObjectName,
      proxyId: this.uniqueId,
      method: method,
      parameters: params,
      expectResult: expectResult,
      async: async,
      commandId: createGuid()
    };
    let commandStr = JSON.stringify(command);
    let promise = undefined;
    if (expectResult) {
      promise = new Promise(
        (resolve: (value: any) => void,
          reject: (err: any) => void) => {
          // Register the callback to be called when
          // the remote browser sends its result.
          command.callBack = (result: CommandResult) => {
            resolve(result);
          }
        });
      // Register the call in out pending commands.
      // When the remote browser sends its result, 
      // we will call the command's callback.
      this.pendingCommands.set(command.commandId, command);
    }
    webSocket.send(commandStr, (err) => {
      if (err) {
        console.error(`RemoteControl:execute: Error: ${err}`);
      }
    })
    return promise;
  }

  /**
   * Calls the the callback of the command
   * which matches the commandId.
   * @param commandId 
   * @param result 
   */
  callResultCallBack(commandId: string, result: CommandResult) {
    let command = this.pendingCommands.get(commandId);
    if (!command) {
      throw new Error(
        `Cannot find pendig callback for id "${commandId}"`);
    }
    this.pendingCommands.delete(commandId);
    command.callBack!(result);
  }
}

Der Ferngesteuerte: die HTML-Seite

Die App (HTML-Seite), die im fernzusteuernden Browser aufgerufen wird, bindet remoteBrowser.js und app.js ein (Listing 5). app.js ist eine minimale Demo-App (Listing 4), die zeigt, wie man den RemoteBrowser verwendet.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Remote Browser Control</title>
  </head>
  <body>
    <!-- Include remoteBrowser.js directly via script-tag or with any packaging/dependency-tool of your choice. -->
    <script src="./js/remoteBrowser.js"></script>
    <!-- This is just a minimal sample app, could be packaged app-bundle, too. -->
    <script src="./js/app.js"></script>
  </body>
</html>

Der Ferngesteuerte: die App

Die App aus Listing 6 ist möglichst einfach gehalten, um zu zeigen, wie man den RemoteBrowser einbindet und wie man die App und die RemoteUtils im window-Kontext registriert. Im zweiten Artikel zu diesem Thema werden wir einen in Node.js geschriebenen Testtreiber verwenden, um die registrierten Objekte über den RemoteBrowser anzusprechen und zu testen.

    
// Imports if needed …
class App {
  // The port would not be hard-coded in a real app.
  static readonly PORT = 8001;

  // The demo router for our demo app. 
  // Will be used to test drive the app.
  router: Router;

  // Utilities for injecting script code into the page.
  protected remoteUtils = new RemoteUtils();

  /**
   * Start app.
   */
  start() {
    // Connect to the Web-Socket-Server. Retry-timeout == 5 seconds.
    let remoteBrowser = new RemoteBrowser("localhost", 
        App.PORT, 5).waitForCommands();

    // Register app & remote utils in window context.
    // They will be used from our test-suite later on.
    (window as any)["mr_app_context"] = this;
    (window as any)["mr_remote_utils_context"] = this.remoteUtils;

    // Initialize router. 
    // This would of course be more complex in a real app: Set paths etc.
    this.router = new Router();
  }
}
new App().start();

Der Ferngesteuerte: Klasse „RemoteBrowser“

Der RemoteBrowser (Listing 7) übernimmt schließlich die Hauptarbeit im ferngesteuerten Browser: Er öffnet die WebSocket-Verbindung zur RemoteControl und horcht auf Nachrichten von ihr. Sobald eine Nachricht eingetroffen ist, wird das fernzusteuernde Objekt aus dem window-Kontext ausgelesen (window[command.object]). Der Cast auf any ist wegen der statischen Typisierung in TypeScript notwendig. Danach wird die aufzurufende Methode des Objekts ausgelesen (object[command.method). Falls im Command-Objekt vermerkt ist, dass es sich um eine asynchrone Methode handelt, wird den Parametern noch eine Callback-Methode hinzugefügt. Den Aufruf asynchroner Methoden behandeln wir im nächsten Artikel.

class RemoteBrowser {
  public reconnectMilliSecs: number;

  /**
   * @param serverUrl 
   * @param port 
   * @param retryIntervalSec: Try to reconnect every n seconds.
   */
  constructor(public serverUrl: string, public port: number,
    public retryIntervalSec: number) {
    this.reconnectMilliSecs = retryIntervalSec * 1000;
  }

  /**
   * Wait for commands from the remote control (= websocket-server).
   */
  waitForCommands() {
    let socket: WebSocket;
    let selfRb = this;
    try {
      socket = new WebSocket("ws://" +
        this.serverUrl + ":" +
        String(this.port));
      console.info(
        `Starting remote browser, waiting for socket to connect...`);

      socket.onopen = function (event) {
        console.clear();
        socket.send(JSON.stringify("Remote browser connecting now..."));
        console.log(`Remote control connected`);
      };

      socket.onmessage = function (event) {
        // Use same command interface as on web-socket-server.
        let command: Command = (JSON.parse(event.data));

        /**
         * Local function that will send the 
         * response to the server if needed.
         * @param result 
         */
        let sendResult = (result: any) => {
          let commandResult: CommandResult = {
            proxyId: command.proxyId,
            commandId: command.commandId,
            commandResult: true,
            status: 0,
            data: result
          };
          let commandResultJson = JSON.stringify(commandResult);
          socket.send(commandResultJson);
        }

        let result: any;
        try {
          // Get the object to call the methods on.
          let object = (window)[command.object];
          if (!object) {
            throw new Error(
              `Object "${command.object}" not found 
              in window context`);
          }
          // Get the method to be called.
          let method: any = object[command.method];
          if (!method) {
            throw new Error(
              `Method "${command.method} not found 
              in object "${command.object}"`);
          }
          // If the method to be called is async, 
          // add a callback to the parameters of the method. 
          if (command.async && command.expectResult) {
            let callBack = (result: any) => {
              sendResult(result);
            }
            command.parameters.push(callBack);
          }
          result = method.apply(object, command.parameters);
        } catch (e) {
          result = {
            error: true,
            errorName: e.name,
            message: e.message
          };
        }
        let resultJson = JSON.stringify(result);
        if (command.expectResult && !command.async) {
          sendResult(resultJson);
        }
      };

      socket.onerror = function (event) {
        console.error(
          "Socket.onerror: %O, will try to reconnect in 'onClose'",
          event);
      };

      socket.onclose = function (event) {
        console.warn(
          `Socket.onclose: %O, trying to reconnect 
          in ${selfRb.retryIntervalSec} secs`, event);
        if (this.readyState === 3) {
          window.setTimeout(() => {
            console.warn("Trying to reconnect now...");
            selfRb.waitForCommands()
          }, selfRb.reconnectMilliSecs);
        }
      }
    } catch (e) {
      if (e.code !== "ERR_CONNECTION_REFUSED") {
        throw e;
      } else {
        window.setTimeout(this.waitForCommands, 3000);
      }
    }
  }
}

Fazit und Ausblick

Es ist mit wenig Aufwand möglich, die Objekte in einem Browser fernzusteuern. So lässt sich z. B. die Logausgabe und -formatierung des console-Objekts nutzen, auch wenn die App gar nicht im Browser läuft.

Im nächsten Artikel gehen wir mit der Fernsteuerung noch etwas weiter: Wir werden den oben erwähnten UI-Integrationstest mit einem Node.js-Testtreiber umsetzen. Wir injizieren zur Laufzeit eine Testsuite in den Browser, die die App automatisiert steuert, indem sie z. B. verschiedene Routen ansteuert, die Ergebnisse abprüft und asynchron an den Testtreiber zurückliefert.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu:
X
- Gib Deinen Standort ein -
- or -