Returning screenshots in Gitlab CI (and Travis CI)

ORIGINALLY POSTED ON IXA.IO

Our code base includes a large suite of functional tests using Lettuce, Selenium and PhantomJS. When a test fails, we have a hook that captures the current screen contents and writes it to a file. In an ideal CI system these would be collected as a failure artifact (along with the stack trace, etc.) but that’s not currently possible withGitlab CI (hint hint Gitlab team, investigate Subunit for streaming test output).

Instead what we do on Gitlab is output our images as base64 encoded text:

if $SUCCESS; then
    echo "Success"
else
    echo "Failed ------------------------------------"
    for i in Test*.html; do echo $i; cat "$i"; done
    for i in Test*.png; do echo $i; base64 "$i"; echo "EOF"; done
fi

$SUCCESS[/bash]
Of course now you have test output full of meaningless base64’ed data. Enter Greasemonkey.

// ==UserScript==
// @name View CI Images
// @namespace io.ixa.ci
// @description View CI Images
// @version 1
// @match https://gitlabci/projects/*/builds/*
// ==/UserScript==

(function ($) {
    var text = $('#build-trace').html();
    text = text.replace(/(Test_.+\.png)\n([A-Za-z0-9\n\/\+=]+)\nEOF\n/g,
                        '<h2>$1</h2><img src="data:image/png;base64,$2" ' +
                        'style="display:block; max-width:800px; width: auto; height: auto;" ' +
                        '/>');
 
    $('#build-trace').html(text);
})(jQuery);

Web browsers (handily) can already display base64’ed images. This little user script will match builds on the CI server you specify and then replace those huge chunks of base64 with the image they represent.

This technique could easily be replicated on Travis CI by updating the jQuery selector.

Extending Selenium with jQuery

Last week I wrote about combining Selenium and py.test and I promised to also talk about my function find_elements_by_jquery().

Selenium by default can find elements by id, CSS selector and XPath, but I often find I already know the query as a jQuery selector, and so frequently it’s easiest just to use that.

We start by overloading the Selenium webdriver. Since the webdriver is exposed through several classes (one per web browser), we do this in a particularly meta way.

from selenium.webdriver.remote.webdriver import WebElement
from selenium.common.exceptions import InvalidSelectorException

def MyWebDriver(base, **kwargs):
    return type('MyWebDriver', (_MyWebDriver, base), kwargs)

class _MyWebDriver(object):
    def create_web_element(self, element_id):
        return MyWebElement(self, element_id)

    def find_elements_by_jquery(self, jq):
        return self.execute_script('''return $('%s').get();''' % jq)

    def find_element_by_jquery(self, jq):
        elems = self.find_elements_by_jquery(jq)
        if len(elems) == 1:
            return elems[0]
        else:
            raise InvalidSelectorException(
                "jQuery selector returned %i elements, expected 1" % len(elems))

We then do a similar implementation for the webelement:

class MyWebElement(WebElement):
    def __repr__(self):
        """Return a pretty name for an element"""

        id = self.get_attribute('id')
        class_ = self.get_attribute('class')

        if len(id) > 0:
            return '#' + id
        elif len(class_) > 0:
            return '.'.join([self.tag_name] + class_.split(' '))
        else:
            return self.tag_name

    def find_elements_by_jquery(self, jq):
        return self.parent.execute_script(
            '''return $(arguments[0]).find('%s').get();''' % jq, self)

    def find_element_by_jquery(self, jq):
        elems = self.find_elements_by_jquery(jq)
        if len(elems) == 1:
            return elems[0]
        else:
            raise InvalidSelectorException(
                "jQuery selector returned %i elements, expected 1" % len(elems))

We can now pass in jQuery selectors for instance b.find_element_by_jquery('#region option:selected'). Or form.find_elements_by_jquery(':input'). It’s especially incredibly powerful when all of your DOM manipulation already works in terms of jQuery selectors.

As an added bonus, overloading the classes lets us add functionality like Firebug style element names (MyWebElement.__repr__) or wrap things like the Wait utility into the webdriver, e.g.

from selenium.webdriver.support.ui import WebDriverWait as Wait
from selenium.common.exceptions import TimeoutException

class FrontendError(Exception):
    pass

# class _MyWebDriver...
    def wait(self, event, timeout=10):
        try:
            Wait(self, timeout).until(event)
        except (TimeoutException, FrontendError) as e:
            # do we have an error dialog
            dialog = self.find_element_by_id('error-dialog')
            if dialog.is_displayed():
                content = dialog.find_element_by_id('error-dialog-content')
                raise FrontendError(content.text)
            else:
                raise e

sort of in love with jQuery

After a bunch of years of hacking code with GTK+ and Clutter, doing a web app is pretty sweet. It’s the first time I’ve ever really used jQuery properly (not just animating a little toggle), and I have to say, I’m sort of in love with it ((I am however, not in love with ExtJS)).

I can’t show you the app I’m working on yet, there’s a beta online but it’s still undergoing scientific verification. Also my latest work, with animated transitions and everything, which feels like a much more high quality app, is not yet pushed to the production server.

Still, it’s so straightforward to use, I rewrote the skip next/prev post feature I have on Planet VegMel to be in jquery, which makes it about 50x smaller and much less brittle. If you want to add it to your own planet, grab the Javascript.

classes, interfaces and properties in Javascript/gjs

Thought I’d share some more Javascript snippets with you zany kids.

Firstly, how to implement an interface (specifically a GInterface), for example an Mx.ItemFactory:

const ButtonFactory = new Lang.Class({
  Name: 'ButtonFactory',
  Extends: GObject.Object,
  Implements: [ Mx.ItemFactory ],
 
  _init : function (callback)
  {
    this.parent();
    this._callback = callback;
  },
 
  vfunc_create : function ()
  {
    let button = new Button();
    button.connect('clicked', Lang.bind(this, function ()
      {
        this._callback(button);
      }));
 
    return button;
  },
});

The important thing to notice is the vfunc_ that gets prepended to any calls you’re overriding from the library. Also be aware of your terminating commas, semicolons and braces. ((Obviously the entire class system here is a hack, which is what makes it so reminiscent of Perl.))

Of course, we want to bind extra attributes into the classes built by our factory (I’m not sure why Mx.ItemFactorys don’t just pass a row so that we can do whatever we like in the factory). Anyway, classes are easy in Javascript so we can just extend Mx.Button to include a property we can bind:

const Button = new Lang.Class({
  Name: 'Button',
  Extends: Mx.Button,
  Properties: {
    'id': GObject.ParamSpec.string('id', '', '',
        GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE,
        ''),
  },
 
  _init : function (params)
  {
    this.parent(params);
    this._id = '';
  },
 
  get id ()
  {
    return this._id;
  },
 
  set id (val)
  {
    this._id = val;
  },
});

Again be aware of missing commas.

For more examples take a look at testGObjectClass.js. You should also know that much of this requires a recent gjs, so it’ll be available in GNOME 3.4, but not 3.2.

If you’re interested, here’s the code that uses this factory:

let factory = new ButtonFactory(Lang.bind(this, function (button)
  {
    ...
  }));
 
let view = new Mx.ListView({
    'model': model,
    'factory': factory,
  });
 
view.add_attribute('label', 0);
view.add_attribute('id', 1);

Thanks to Jasper St. Pierre for the help.

Gtk.ListStores and Clutter.ListModels in Javascript/gjs

It’s surprisingly hard to find this, and the generated documentation is actually misleaingly wrong ((The n_columns parameter is a lie, and will be inferred by gjs from the array size.)), so here’s how to create ListStores and ListModels in Javascript with gjs.

let store = new Gtk.ListStore();
store.set_column_types([GObject.TYPE_STRING, GObject.TYPE_INT]);
store.insert_with_valuesv(-1, [ 0, 1 ], [ "test", 42 ]);
let model = Clutter.ListModel.newv(
    [ GObject.TYPE_STRING, GObject.TYPE_STRING ],
    [ 'Column Name 1', 'Column Name 2' ]);
model.appendv([ 0, 1 ], [ "String 1", "String 2" ]);

The first array is the column numbers you wish to assign, the second array is the values for those columns.

Fundamental GTypes are available as GObject.TYPE_*. You can specify non-basic types using the class, e.g. Clutter.Text.

There are other variations possible, but this should provide the basics required to figure out the rest. ((You’re welcome.))

telepathy-glib GObject-Introspection and TpBaseClient

In Telepathy libraries such as telepathy-glib, telepathy-python and telepathy-qt4 there are two kinds of API. There's what we call low-level API, which is a direct wrapping of the D-Bus API exposed by Telepathy components and automatically generated from the Telepathy specification; and high-level API, which is hand-written and calls the low-level API internally with the goal of making it easier to implement things with Telepathy.

It's relatively easy to expose the low-level API into your language by writing an output module on top of our specparser and using your language's native D-Bus support. However writing a high-level API is a pain in the neck, and requires you to redo a lot of work that's already been done once.

Basically this sucks, so over the last couple of weeks a few of us have been working to support GObject-Introspection for the telepathy-glib high-level API. This means that we can now expose telepathy-glib functionality into languages such as Javascript (via GJS) and Python (via PyGI). We're choosing not to bind the low-level API that telepathy-glib exports, mostly because it's not very pretty and languages have better ways to expose this (N.B. this does create possible ordering problems because you are likely to have two separate DBusConnections). We're also working on extending telepathy-glib to make more things easier.

One such extension is TpBaseClient and its subclass TpSimpleObserver, which are designed to make it significantly easier to write a Telepathy clients such as Observers (TpSimpleHandler is coming soon).

The introspected bindings are not yet production ready, we're still going through and checking everything is annotated correctly, and we've also exposed a few limitations in GJS and PyGI (e.g. GArray support) that don't yet have fixes merged.

Still, putting this all together, it's now possible to write the basis of a Telepathy Observer in Javascript:

const Tp = imports.gi.TelepathyGLib;
const Mainloop = imports.mainloop;

function observe_channels(observer, account, connection, channels,
                          dispatch_op, requests, context, user_data)
{
    print("observe_channels");

    print("account = " + account.get_object_path());
    print("connection = " + connection.get_object_path());

    for (let i in channels) {
        let channel = channels[i];

        print("channel = " + channel.get_object_path());
    }

    if (dispatch_op != null)
      print("dispatch_op = " + dispatch_op.get_object_path());
    else
      print("dispatch_op = (null)");

    for (let i in requests) {
      let request = requests[i];

      print("request = " + request.get_object_path());
    }

    context.accept();
}

let dbus = Tp.DBusDaemon.dup();
let observer = Tp.SimpleObserver.new(dbus, true, "JSObserver", true,
                                     observe_channels);

observer.add_observer_filter({
  "org.freedesktop.Telepathy.Channel.ChannelType": Tp.IFACE_CHANNEL_TYPE_TEXT,
});

try {
    observer.register();
    Mainloop.run("");
} catch (e) {
    print("ERROR: " + e);
}

All of this work is now merged into telepathy-glib master, and will be released as part of the telepathy-glib 0.12.

Creative Commons Attribution-ShareAlike 2.5 Australia
This work by Danielle Madeley is licensed under a Creative Commons Attribution-ShareAlike 2.5 Australia.