No description
- Perl 92.3%
- Raku 7.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| lib/Test/Mojo | ||
| t | ||
| .gitignore | ||
| .ship.conf | ||
| Changes | ||
| cpanfile | ||
| MANIFEST.SKIP | ||
| README.pod | ||
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>