cboulanger / eventrecorder

A qooxdoo package that allows to record user interaction for replay in testing or presentations
https://cboulanger.github.io/eventrecorder/eventrecorder/
1 stars 5 forks source link
qooxdoo-contrib qooxdoo-package

Qooxoodo Event Recorder

Build Status

Eventrecorder screenshot

This library allows a) to record user interaction for replay in tests, or for use in a presentation/screencast/"take-a-tour" scenario, b) to modify the scripts that are generated by the recorder and c) to write and run scripts manually using a simple script language.

The library consists of

  1. a object id generator which crawls the entire qooxdoo widget hierarchy to assign unique qxObjectId values. This means you don't have to manually assign these ids beforehand. It also allows you to script applications of which you cannot change the source code. In fact, you can script any qooxdoo application this way as long as you can compile its source code;

  2. a recorder that registers qooxdoo events and saves them together with the object ids and data in a simple human readable and editable intermediate bash-like DSL (see the language reference);

  3. several players which can translate this DSL into code that runs in the browser or by a browser automation tool (such as Puppeteer, Selenium, TestCafé, etc.) on the server;

  4. an UI to control recording/replaying, editing, and loading/saving the generated scripts.

Please note: The recorder is recording the qooxdoo reactions to actual cursor movements and clicks/taps, not the native DOM events themselves. That means that in many cases, the recorder does not know whether an event is caused by human interaction with the user interface, or by the application code that is triggered by these interactions. Also, the recorder will catch a lot of events, many of which are not relevant for testing your application. That is why in most cases, you will have to manually and extensively edit and shorten the recorded script. The recorder therefore includes an editor that offers autocomplete and other amenities to make this as comfortable as possible.

Demos

Installation

As a standalone demo

npm install -g @qooxdoo/compiler
git clone https://github.com/cboulanger/eventrecorder.git
cd eventrecorder
qx serve

As an addition to an existing project

The event recorder can be added to any application without having to change anything in the application itself. To do this, configure a few environment variables and include the required classes in the application directives of compile.json (See this example). Typically, that would be "cboulanger.eventrecorder.UiController" and "cboulanger.eventrecorder.ObjectIdGenerator", but it is also possible to use the ID generator and a player without the GUI, or a player with or without GUI if you assign the qxObjectIds yourself.

If you use the UI Controller, you must include "qookery.ace.*" as a depencency. A general requirement is to set the environment variables "module.objectId" and "eventrecorder.enabled" to true.

Here's a example of the applications section of compile.json

{
 "applications": [
    {
      "title": "Foo",
      "name": "foo",
      "theme": "foo.theme.Theme",
      "class": "foo.Application",
      "bootPath": "source/boot",
      "include": [
        "cboulanger.eventrecorder.UiController",
        "cboulanger.eventrecorder.ObjectIdGenerator",
        "cboulanger.eventrecorder.ObjectIdTooltip",
        "qookery.ace.*" // required for the UIController
      ],
      "environment": {
        "module.objectId": true, // required, the event recorder won't work without this setting
        "eventrecorder.enabled": true,
        "eventrecorder.mode": "presentation", // or "test"
        "eventrecorder.autoplay": false 
      }
    },
    ...
}

API Viewer

The API Viewer app is here.

ID generation

By including "cboulanger.eventrecorder.ObjectIdGenerator" in your compile.json (see above), IDs are automatically generated for a large number of widgets. However, these ID are long and not descriptive of the actual widget. If you want to have readable and easily editable replay scripts, you should assign a semantically meaningful qxObjectId property to each widget that is used in the script.

Loading a script from a file / a gist

To load scripts, you have several options. During development, you can use locally stored files, using the "Load" button on the GUI. In a testing pipeline, you can use scripts stored in Gists (loading from a URL can be added if need arises). The gists can be loaded by either setting the environment variable eventrecorder.gist_id in the application section of compile.json. This will load the script published with this id at gist.github.com. Alternatively, you can provide the eventrecorder_gist_id parameter in the querystring, like so:

https://cboulanger.github.io/eventrecorder/widgetbrowser_recorder/?eventrecorder_gist_id=2ce4d5f7107661f1c53b146c498560aa

Script language reference

The available commands can be gleaned from the methods of the cboulanger.eventrecorder.IPlayer interface.

The methods that start with cmd_ provide the implementation of commands: cmd_execute will generate code for the execute command, cmd_open_tree_node for the open-tree-node command.

Most commands will be autogenerated by the recorder and are therefore probably not of interest to the library user. However, you might need to insert wait commands manually in order to deal with network or rendering latency (see below).

You also need to convert the "set-*" commands into "await-" commands manually because (as stated above) the event recorder doesn't always know whether a change event is produced by a user action or by code.

await-* commands and async issues

In many cases, because of network latency and other issues, events can occur in a different order than they were fired when the script was recorded. The await-* commands wait for promises to resolve, so when this happens, timeouts are the result. Thus, when dealing with multiple await-* commands that all have to resolve before the test/presentation proceeds, you need to wrap them into a await-all block like so:

execute toolbar/logout
await-all
    # all events caused by code on user logout, causing server requests
    # that might introduce latency issues
    await-value user-name#label "Guest User"
    await-selection user-list user-list/list-item-guest
    await-selection-from-selectables table-view 0
    await-selection item-view/stack item-view/table
end

Matching runtime values with await-match-json

Very often the event data you receive will contain runtime values which you cannot know in advance such as tokens or session ids.

For this situation, the special commands exist, which will check for regular expressions enclosed by <! and !> to replace data like so: {token:"<![A-Za-z0-9]{32}!>" will match {"token":"OnBHqQd59VHZYcphVADPhX74q0Sc6ERR"}. Currently the commands await-property-match-json and await-event-match-json support this.

For example, the await-property-match-json command checks the value of an qx object property which has an object id. In the following example, we have a jsonrpc client which has a response property that contains the last response:

  execute windows/login/buttons/login
  await-all 
    await-match-json application/jsonrpc/access response {"method":"plaintext"}
    await-match-json application/jsonrpc/access response {"message":"Welcome, Administrator!","token":"<![A-Za-z0-9]{32}!>","sessionId":"<![A-Za-z0-9]{26}!>","error":null}
  end

Note that the expression to be matched is a JSON expression, which will be evaluated and converted to data for comparison, not a string. That is why whitespace outside string literals does not matter.

Variables and macros

If you manually edit/write scripts, there are two rudimentary scripting features that might be useful: variables and macros. They can be seen in the following code:

info "This demostrates the use of macros.."
wait 2000

# shorten long, autogenerated ID
FORM=Composite/Scroll/TabView/TabPage/Form/FormItems/GroupBox/Composite/Single

define fill-form "This is a description of the macro"
    set-value $FORM/TextField $1
    set-value $FORM/PasswordField $2
    set-value $FORM/TextArea $3
end

fill-form "User 1" "Password 1" "Text for User 1"
wait 2000
fill-form "User 2" "Password 2" "This is a different text for User 2"
wait 2000
fill-form "User 3" "Password 3" "Yet another text for User 3"

Variables roughly work like in Bash (without any of the more advanced features of variable expansion): they are simply placeholders for text which get replaced by that text before the script is evaluated.

Macros are enclosed in "define" and "end" and are evaluated during execution. Inside macros, "$1", "$2" etc. will be replaced with the value of the the nth argument passed to the name of the macro in the script.

Imports

You can modularize your scripts by importing other scripts. This is particularly useful to store often-used macros in separate files. Currently, only importing from Gists is supported. To import, for example, this Gist, just put the line

import gist:dee7efecbb9a2e4268c15395849e30e5

at the beginning of the script.

Scripts are cached using qx.bom.storage.Web. If you need to clear the cache because on of the imported script has changed, preceed the imports by the clear-imports command. See this example.

Player mode

The player instances can be used in two modes:

Environment variables

See here.

Use the eventrecorder for testing your application with TestCafé

TestCafé is an excellent tool to automatically test your UI. As most if not all testing frameworks, it is focussed on HTML-centered web applications, but can be easily set up to work with the singel-page-app-design of qooxdoo, where the HTML is generated rather than authored.

Here's an example which is used to test the event recorder demo application with bash run-test.sh <build target (defaults to 'source')>

run-test.sh \<target>

#!/usr/bin/env bash
export QX_TARGET=${QX_TARGET:-source}

# stop any running server
if [[ $(pgrep "qx serve") ]]; then kill -9 $(pgrep "qx serve"); fi

# install testcafe if not already installed
command -v testcafe || npm install -g testcafe

echo
echo "Running tests in ${QX_TARGET} mode..."

# start the server and wait for "Web server started" message
( qx serve --target=$QX_TARGET & ) | while read output; do
  echo "$output"
  if [[ $output == *"Web server started"* ]]; then break; fi;
done

# run tests
testcafe chrome tests/testcafe.js  --app-init-delay 20000
testcafe firefox tests/testcafe.js --app-init-delay 20000
testcafe safari tests/testcafe.js  --app-init-delay 20000

# stop the server
kill -9 $(pgrep "qx serve")

testcafe.js

import process from "process";

const target = process.env.QX_TARGET;
if (!target) {
  throw new Error("Missing QX_TARGET environment variable");
}

fixture `Testing eventrecorder demo application`
  .page `http://127.0.0.1:8080/compiled/${target}/eventrecorder/index.html`;

test('Test the eventrecorder presentation', async t => {
  await t.wait(10000);
  await t.eval(()=>{qx.core.Id.getQxObject("eventrecorder/record").fireEvent("execute");});
  await t.wait(1000);
  await t.eval(()=>{qx.core.Id.getQxObject("button1").fireEvent("execute");});
  await t.wait(500);
  await t.eval(()=>qx.core.Assert.assertTrue(qx.core.Id.getQxObject("button1/window").isVisible(),"Failed: Object with id button1/window is not visible."));
  await t.eval(()=>{qx.core.Id.getQxObject("button1/window/button2").fireEvent("execute");});
  await t.wait(500);
  await t.eval(()=>qx.core.Assert.assertFalse(qx.core.Id.getQxObject("button1/window").isVisible(),"Failed: Object with id button1/window is visible."));
  await t.eval(()=>{qx.core.Id.getQxObject("eventrecorder/stop").fireEvent("execute");});
  await t.eval(()=>{qx.core.Id.getQxObject("eventrecorder/edit").fireEvent("execute");});
  await t.wait(500);
  await t.eval(()=>qx.core.Assert.assertTrue(qx.core.Id.getQxObject("eventrecorder/editor/window").isVisible(),"Failed: Object with id eventrecorder/editor/window is not visible."));
  await t.eval(()=>{qx.core.Id.getQxObject("eventrecorder/editor/translateButton").fireEvent("execute");});
});

Issues