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
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;
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);
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;
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.
Simple event recorder demo in "tour" mode: Very simple demo that shows how you can use the event recorder to walk the user through your app, using this gist
Simple event recorder demo with object id tooltips This demo displays the object ids which are automatically assigned to the widgets by showing a tooltip when hovering over them.
WidgetBrowser with autogenerated object ids, id tooltips and event recorder, showcasing the use of variables and macros (Also playing a gist).
npm install -g @qooxdoo/compiler
git clone https://github.com/cboulanger/eventrecorder.git
cd eventrecorder
qx serve
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 qxObjectId
s 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
}
},
...
}
The API Viewer app is here.
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.
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:
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.
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
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.
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.
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.
The player instances can be used in two modes:
See here.
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')>
#!/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")
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");});
});
wait 1000
command into
the script. This is because the objects might not yet ready when the next command will
be executed. The same is true for content that depends on network traffic.