package Test::Mojo::Role::Phantom;

use Role::Tiny;

use Test::More ();

use Mojo::IOLoop::Delay;
use Mojo::Phantom;

sub phantom_ok {
  my $t = shift;
  my $opts = ref $_[-1] ? pop : {};
  my $js = pop;

  my $base = $t->ua->server->nb_url;

  my $url = $t->app->url_for(@_);
  unless ($url->is_abs) {
    $url = $url->to_abs($base);
  }

  my $phantom = $opts->{phantom} || do {
    my %bind = (
      ok    => 'Test::More::ok',
      is    => 'Test::More::is',
      diag  => 'Test::More::diag',
      note  => 'Test::More::note',
      fail  => 'Test::More::fail',
      %{ $opts->{bind} || {} },
    );

    Mojo::Phantom->new(
      base    => $base,
      bind    => \%bind,
      cookies => $t->ua->cookie_jar->all,
      setup   => $opts->{setup},
      package => $opts->{package} || caller,
      no_exit => $opts->{no_exit},
      note_console => $opts->{note_console} // 1,
      arguments => $opts->{phantom_args} // [],
    );
  };

  my $name = $opts->{name} || 'all phantom tests successful';
  my $block = sub {
    Test::More::plan(tests => $opts->{plan}) if $opts->{plan};
    Mojo::IOLoop::Delay->new->steps(
      sub { $phantom->execute_url($url, $js, shift->begin) },
      sub {
        my ($delay, $err, $status) = @_;
        if ($status) {
          my $exit = $status >> 8;
          my $sig  = $status & 127;
          my $msg  = $exit ? "status: $exit" : "signal: $sig";
          Test::More::diag("phantom exitted with $msg");
        }
        die $err if $err;
      },
    )->catch(sub{ Test::More::fail(pop) })->wait;
  };
  local $Test::Builder::Level = $Test::Builder::Level + 1;
  return $t->success(Test::More::subtest($name => $block));
}

1;

=head1 NAME

Test::Mojo::Role::Phantom - Adds phantom_ok to Test::Mojo

=head1 SYNOPSIS

  use Mojolicious::Lite;

  use Test::More;
  use Test::Mojo::WithRoles qw/Phantom/;

  any '/' => 'index';

  my $t = Test::Mojo::WithRoles->new;

  $t->phantom_ok('/' => <<'JS');
    var text = page.evaluate(function(){
      return document.getElementById('name').innerHTML;
    });
    perl.is(text, 'Bender', 'name changed after loading');
  JS

  done_testing;

  __DATA__

  @@ index.html.ep

  <!DOCTYPE html>
  <html>
    <head></head>
    <body>
      <p id="name">Leela</p>
      <script>
        (function(){ document.getElementById('name').innerHTML = 'Bender' })();
      </script>
    </body>
  </html>

=head1 DESCRIPTION

L<Test::Mojo::Role::Phantom> is a L<Role::Tiny> role which adds a L<phantom_ok> method to L<Test::Mojo> or a L<Test::Mojo> instance.
This method tests the javascript behavior of the app via an external L<PhantomJS|http://phantomjs.org/> process.
You must install that program and it must be in your C<PATH> in order to use this method.

The author recommends using L<Test::Mojo::WithRoles> to manage the role application.
The low level interaction is handled by a L<Mojo::Phantom> instance, but for the most part that is transparent to the test method.

=head1 WARNING

The upstream phantom.js has been retired in favor of headless chrome.
A L<Mojo::Chrome> (and related L<Test::Mojo::Role::Chrome>) is planned and is already in the works (perhaps it is released already who knows?!).
While this module will continue to function, just know that it depends on a project that is defunct.

=head1 METHODS

=head2 phantom_ok

 $t = $t->phantom_ok(@url_for, $js, \%opts)

The arguments are as follows

=head3 url specification

L<phantom_ok> takes a url or arguments for L<Mojolicious::Controller/url_for>, a required string of javascript and and optional hash reference of additional arguments.

=head3 javascript

The javascript string will be executed once the phantom object has loaded the page in question.
At this point, it will have access to all the symbols of a typical phantom process as well as

=over

=item page

The page object.

=item status

The page request status, should be C<success>.

=item perl

A function which takes the name of a perl function and arguments for that function.
The function name and the arguments are serialized as JSON and then executed on the perl side.

If the function dies (or is L<CORE::die>), the test fails.

=back

Since it would be prohibitively expensive to start up a new phantom process for each test in the string, the entire string is executed as a subtest.
The test result will be success if the entire subtest is a success.

If there is a javascript error, the subtest will fail.

=head3 options

The method also takes a hashreference of additional options.
They are as follows:

=over

=item name

The name of the subtest

=item plan

The number of tests that are expected.
While not required, this is more useful than most plans in L<Test::More> since the transport of the commands is volatile.
By specifying a plan in this way, if the process exits (status zero) early or never starts, the test will still fail rather than silently pass assuming there were no tests.

=item package

The package that is searched for Perl functions if the function name is not fully qualified.

=item bind

A hash reference of key-value pairs which then have shortcuts built in the phantom process.
The pairs passed are merged into

  {
    ok    => 'Test::More::ok',
    is    => 'Test::More::is',
    diag  => 'Test::More::diag',
    note  => 'Test::More::note',
    fail  => 'Test::More::fail',
  }

In the phantom process you may then use the shortcut as

  perl.ok(@args)

Which is handy if you are using a certain function often.

Note that if the value is falsey, the key name is use as the target.

=item setup

A pass-through option specifying javascript to be run after the page object is created but before the url is opened.

=item phantom

If you need even more control, you may pass in an instance of L<Mojo::Phantom> and it will be used.

=item no_exit

Do not automatically call C<phantom.exit()> after the provided JavaScript code.  This is useful when testing asynchronous events.

=item note_console

Redirect C<console.log> output to TAP as note events.  This is usually helpful, but can be turned off if it becomes too
verbose.

=item phantom_args

Specifies an array reference of command-line arguments passed directly to the PhantomJS process.

=back

=head1 DESIGN GOALS

Not enough people test their client-side javascript.
The primary goal is make testing js in you L<Mojolicious> app that you actually DO IT.
To accomplish this, I make the following goals:

=over

=item  *

Have the test script not depend on a running mojolicious server (i.e. start one, like L<Test::Mojo> scripts can), whether that be from a js or perl file doesn't matter

=item *

Emit tap in a normal way in a manner that prove -l can collect tests

=item *

Not have to reimplement a large chunk of the test methods in either L<Test::More> or L<Test::Mojo>.
Note: if some javascript library has functionality like Test::* (that emits tap and can be collected subject to the previous goals) then that would be sufficient.

=back

This module is the result of those goals and my limited design ability.
I encourage contribution, whether to this implementation or some other implementation which meets these goals!

=head1 NOTES

The C<phantom_ok> test itself mimics a C<subtest>.
While this outer test behaves correctly, individual tests do not report the correct line and file, instead emitting from inside the IOLoop.
It is hoped that future versions of L<Test::More> will make correct reporting possible, but it is not yet.

=head1 SOURCE REPOSITORY

L<http://github.com/jberger/Test-Mojo-Phantom>

=head1 AUTHOR

Joel Berger, E<lt>joel.a.berger@gmail.comE<gt>

=head1 COPYRIGHT AND LICENSE

Copyright (C) 2015 by Joel Berger

This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.
