No description
This repository has been archived on 2026-08-22. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
  • Perl 92.3%
  • Raku 7.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2014-07-29 21:24:02 +02:00
lib/Test/Mojo Add custom browser lookup 2014-07-29 21:24:02 +02:00
t Add custom browser lookup 2014-07-29 21:24:02 +02:00
.gitignore Initialized 2014-07-29 11:24:42 +02:00
.ship.conf Initialized 2014-07-29 11:24:42 +02:00
Changes Add deps 2014-07-29 14:18:30 +02:00
cpanfile Add custom browser lookup 2014-07-29 21:24:02 +02:00
MANIFEST.SKIP Initialized 2014-07-29 11:24:42 +02:00
README.pod Initialized 2014-07-29 11:24:42 +02:00

package Test::Mojo::Browser;

=head1 NAME

Test::Mojo::Browser - Test your Mojolicious application in a real browser

=head1 VERSION

0.01

=head1 DESCRIPTION

L<Test::Mojo::Browser> is a tool for testing your L<Mojolicious> application
in a live browser.

This module (for now) require to be run on a desktop which can open a
I<modern> browser supporting WebSockets and a modern DOM.
Suggested browsers: Google Chrome or Firefox.

THIS MODULE IS EXPERIMENTAL AND UNDER CONSTRUCTION. Please contact me
if you have any feedback.

=head1 SYNOPSIS

  use Test::Mojo::Browser;
  use Test::More;
  my $t = Test::Mojo::Browser->new("YourApp");

  # will skip the test file unless a browser can be opened
  $t->initialize;

  # load the browser with a given URL
  $t->get_ok("/resource");

  # check if a element exists in the browser
  $t->element_exists("#foo");

  # click on a link
  $t->dispatch_event("a#link", "click");

=head2 Headless

On Linux, you can run the tests "headless" using L<Xvfb|http://en.wikipedia.org/wiki/Xvfb>:

  $ Xvfb :42 -screen 0 1024x768x24 &
  $ DISPLAY=:42 prove -vl t/your-test.t

=head2 Environment variables

=over 4

=item * DISPLAY

The name of the X display to use.

=item * TEST_MOJO_BROWSER

Used to force a browser. It can be either "chromium-browser" or "firefox".

=back

=cut

use Mojo::Base 'Test::Mojo';
use Mojo::JSON 'j';
use Mojo::Util 'encode';
use File::Spec ();
use Test::More ();

our $VERSION = '0.01';

# TODO: 'google-chrome' => [qw( --incognito )] # Does not work because it starts with a popup dialog on new profile
# TODO: 'opera-next' => [qw( --nomail )] # does not work since it throws NOT_SUPPORTED_ERR when trying to create MouseEvent
# TODO: 'phanthomjs' => [qw( no-idea-how )];

my @BROWSERS = qw( chromium-browser firefox );
my %BROWSER_ARGS = (
  'chromium-browser' => [qw( --incognito --temp-profile )],
  'firefox' => [qw( -private -no-remote )],
);

my %MOUSE_EVENTS = map { $_ => 1 } qw( click mouseover mouseout mousedown mouseup dblclick mousemove mouseenter mouseleave );
my %TOUCH_EVENTS = map { $_ => 1 } qw( touchstart touchend touchcancel touchleave touchmove );

sub DESTROY {
  my $self = shift;
  return unless $self->{pid} and $self->{pid} == $$;
  my $log = eval { $self->app->log };
  $log->debug("Cleaning up browser $self->{browser_pid}") if $log;
  kill 15, $self->{browser_pid};
}

=head1 ATTRIBUTES

=head2 browsers

  $hash = $self->browsers;
  $self = $self->browsers($hash);

Used to look up browsers. The hash keys are the browser binaries to
look for when calling L</initialize>. The values will be the path
to the executable afterwards.

Do this to force a special browser:

  # Detect path and arguments
  Test::Mojo::Browser->new(browsers => { firefox => undef });

  # Force path and arguments
  Test::Mojo::Browser->new(
    browsers => {
      chrome => [ qw( /opt/bin/google-chrome --incognito ) ],
    },
  );

=head2 websocket_endpoint

  $path = $self->websocket_endpoint;

Holds the path (a string) to the websocket endpoint, where this test
communicates with the web browser.

=cut

has browsers => sub { +{ map { $_ => undef } @BROWSERS }; };
has websocket_endpoint => '/test-mojo-browser-websocket-endpoint';

=head1 METHODS

=head2 element_exists

  $self = $self->element_exists($selector);

Checks if a given element exists in the browser.

=cut

sub element_exists {
  my ($self, $selector, $desc) = @_;
  $self->_send({ selector => $selector, element_exists => 1 }, $desc);
}

=head2 dispatch_event

  $self = $self->dispatch_event($selector, $event);

Used to dispatch an event to an element in the browser. Returns true if the
element exists.

Examples:

  $self->dispatch_event("a.some-button", "click");
  $self->dispatch_event("a.some-button", "mousedown");
  $self->dispatch_event("a.some-button", "touchstart");
  $self->dispatch_event("a.some-button", "some_custom_event");
  $self->dispatch_event("a.some-button", {
    type => "MouseEvent",
    name => "click",
    args => {
      cancelable => Mojo::JSON->false,
      screenY => 100,
      screenX => 200,
    },
  });

=cut

sub dispatch_event {
  my ($self, $selector, $event, $desc) = @_;

  if ($MOUSE_EVENTS{$event}) {
    $event = { type => 'MouseEvent', name => $event };
  }
  elsif ($TOUCH_EVENTS{$event}) {
    $event = { type => 'TouchEvent', name => $event };
  }
  elsif (ref $event eq '') {
    $event = { type => 'CustomEvent', name => $event };
  }

  $self->_send({ selector => $selector, dispatch_event => $event }, $desc);
}

=head2 get_ok

  $self = $self->get_ok("/path");

Perform a "GET" request and check for transport errors. Will load the given
path in the browser if no transport errors was emitted.

=head2 initialize

  $self->initialize;

Used to either set up the unit test or L<skip|Test::More/skip_all> the whole
test, if a browser command could be found.

=cut

sub initialize {
  my $self = shift;
  my $browsers = $self->browsers;
  my @look_for = $ENV{TEST_MOJO_BROWSER} ? ($ENV{TEST_MOJO_BROWSER}) : keys %$browsers;

  if (!defined $ENV{DISPLAY}) {
    Test::More::plan(skip_all => 'Cannot start browser without environment variable "DISPLAY" set.');
  }

  for my $path (File::Spec->path) {
    for my $bin (@look_for) {
      $browsers->{$bin} ||= [ "$path/$bin", @{ $BROWSER_ARGS{$bin} || [] } ] if -x "$path/$bin";
    }
  }

  if (grep { $_ } values %$browsers) {
    $self->{pid} = $$;
    $self->_add_after_render_hook;
  }
  else {
    Test::More::plan(skip_all => 'Could not find browser command');
  }

  return $self;
}

sub _add_after_render_hook {
  my $self = shift;

  push @{ $self->app->renderer->classes }, __PACKAGE__;

  $self->app->hook(after_render => sub {
    my ($c, $output, $format) = @_;
    return if $format ne 'html';
    my $js = $c->render_to_string(template => 'test-mojo-browser-javascript', t => $self);
    $$output =~ s!<head>!<head>\n$js!;
  });

  Scalar::Util::weaken($self);
  $self->app->routes->websocket($self->websocket_endpoint => sub {
    my $c = shift;
    $self->{websocket_connection} = $c->tx;
    $c->inactivity_timeout(0);
    $c->on(message => sub {
      $self->{websocket_data} = j $_[1];
      $self->app->log->debug("BROWSER >>> $_[1]");
      Mojo::IOLoop->stop;
    });
  });
}

sub _build_ok {
  my ($self, $method, $url) = (shift, shift, shift);
  local $Test::Builder::Level = $Test::Builder::Level + 1;
  return $self->_request_ok($self->ua->build_tx($method, $url, @_), $url);
}

sub _send {
  my ($self, $json, $desc) = @_;

  $desc ||= j $json;
  $self->{websocket_data} = {};

  if ($self->{websocket_connection}) {
    my $data = j $json;
    $self->app->log->debug("BROWSER <<< $data");
    $self->{websocket_connection}->send($data);
    Mojo::IOLoop->start;
  }

  $self->_test(
    'is_deeply',
    $self->{websocket_data},
    {
      selector => $json->{selector},
      ok => 1,
    },
    $desc,
  );
}

sub _request_ok {
  my ($self, $tx, $url) = @_;

  local $Test::Builder::Level = $Test::Builder::Level + 1;

  die "WebSockets are not supported" if $tx->req->is_handshake;

  # Perform request
  $self->tx($self->ua->start($tx));
  my $err = $self->tx->error;
  Test::More::diag $err->{message}
    if !(my $ok = !$err->{message} || $err->{code}) && $err;
  my $desc = encode 'UTF-8', "@{[uc $tx->req->method]} $url";

  if ($ok) {
    $self->_start_browser($tx->req->url->to_abs) unless $self->{browser_pid};
    Mojo::IOLoop->start;
  }

  return $self->_test('ok', $ok, $desc);
}

sub _start_browser {
  my ($self, @url) = @_;
  my $browsers = $self->browsers;
  my @program;

  for (@BROWSERS, keys %$browsers) {
    next unless ref $browsers->{$_};
    @program = @{ $browsers->{$_} };
    last;
  }

  @program or die "Did you forget to call $self->initialize()?";
  defined($self->{browser_pid} //= fork) or die "Could not fork: $!";
  return if $self->{browser_pid}; # parent

  $self->app->log->debug("Start browser: @program @url");
  exec @program, @url;
  die $!;
}

sub _test {
  my ($self, $name, @args) = @_;
  local $Test::Builder::Level = $Test::Builder::Level + 2;
  return $self->success(!!Test::More->can($name)->(@args));
}

=head1 COPYRIGHT AND LICENSE

Copyright (C) 2014, Jan Henning Thorsen

This program is free software, you can redistribute it and/or modify it under
the terms of the Artistic License version 2.0.

=head1 AUTHOR

Jan Henning Thorsen - C<jhthorsen@cpan.org>

=cut

1;
__DATA__
@@ test-mojo-browser-javascript.html.ep
% my $url = $c->url_for($t->websocket_endpoint)->to_abs;
% $url =~ s!^http!ws!;
<script type="text/javascript">
window.addEventListener('load', function(e) {
  var ws = new WebSocket('<%= $url %>');
  var send = function(d) { ws.send(JSON.stringify(d)); };
  ws.onopen = function() { send({ selector: 'window', ready: location.href }); };
  ws.onmessage = function(e) {
    var req = JSON.parse(e.data);
    var el = window.jQuery ? jQuery(req.selector || 'body').get(0) : document.querySelector(req.selector || 'body');

    if (!el) {
      send({ selector: req.selector || 'body', found: 0 });
    }
    else if (req.dispatch_event) {
      try {
        var e = new window[req.dispatch_event.type](req.dispatch_event.name);
        setTimeout(function() { el.dispatchEvent(e); }, 2);
        send({ selector: req.selector, ok: 1 });
      } catch(err) {
        send({ selector: req.selector, ok: 0, error: err });
      };
    }
    else if (req.element_exists) {
      send({ selector: req.selector, ok: 1 });
    }
    else if (req.get_text) {
      send({ selector: req.selector, text: el.textContent });
    }
    else {
      send({ selector: req.selector, error: 'Unknown request' });
    }
  };
});
</script>