Playwright.pm 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. package Playwright;
  2. use strict;
  3. use warnings;
  4. use sigtrap qw/die normal-signals/;
  5. use File::Basename();
  6. use Cwd();
  7. use LWP::UserAgent();
  8. use Sub::Install();
  9. use Net::EmptyPort();
  10. use JSON::MaybeXS();
  11. use File::Slurper();
  12. use File::Which();
  13. use Capture::Tiny qw{capture_stderr};
  14. use Carp qw{confess};
  15. use Playwright::Base();
  16. use Playwright::Util();
  17. #ABSTRACT: Perl client for Playwright
  18. no warnings 'experimental';
  19. use feature qw{signatures state};
  20. =head1 SYNOPSIS
  21. use JSON::PP;
  22. use Playwright;
  23. my $handle = Playwright->new();
  24. my $browser = $handle->launch( headless => JSON::PP::false, type => 'chrome' );
  25. my $page = $browser->newPage();
  26. my $res = $page->goto('http://google.com', { waitUntil => 'networkidle' });
  27. my $frameset = $page->mainFrame();
  28. my $kidframes = $frameset->childFrames();
  29. =head1 DESCRIPTION
  30. Perl interface to a lightweight node.js webserver that proxies commands runnable by Playwright.
  31. Checks and automatically installs a copy of the node dependencies in the local folder if needed.
  32. Currently understands commands you can send to all the playwright classes defined in api.json.
  33. See L<https://playwright.dev/#version=master&path=docs%2Fapi.md&q=>
  34. for what the classes do, and their usage.
  35. There are two major exceptions in how things work versus the documentation.
  36. =head2 Selectors
  37. The selector functions have to be renamed from starting with $ for obvious reasons.
  38. The renamed functions are as follows:
  39. =over 4
  40. =item $ => select
  41. =item $$ => selectMulti
  42. =item $eval => eval
  43. =item $$eval => evalMulti
  44. =back
  45. These functions are present as part of the Page, Frame and ElementHandle classes.
  46. =head2 Scripts
  47. The evaluate() and evaluateHandle() functions can only be run in string mode.
  48. To maximize the usefulness of these, I have wrapped the string passed with the following function:
  49. const fun = new Function (toEval);
  50. args = [
  51. fun,
  52. ...args
  53. ];
  54. As such you can effectively treat the script string as a function body.
  55. The same restriction on only being able to pass one arg remains from the upstream:
  56. L<https://playwright.dev/#version=master&path=docs%2Fapi.md&q=pageevaluatepagefunction-arg>
  57. You will have to refer to the arguments array as described here:
  58. L<https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/arguments>
  59. =head2 Asynchronous operations
  60. The waitFor* methods defined on various classes will return an instance of L<AsyncData>, a part of the L<Async> module.
  61. You will then need to wait on the result of the backgrounded action with the await() method documented below.
  62. # Assuming $handle is a Playwright object
  63. my $async = $page->waitForEvent('console');
  64. $page->evaluate('console.log("whee")');
  65. my $result = $handle->await( $async );
  66. my $logged = $result->text();
  67. =head1 CONSTRUCTOR
  68. =head2 new(HASH) = (Playwright)
  69. Creates a new browser and returns a handle to interact with it.
  70. =head3 INPUT
  71. debug (BOOL) : Print extra messages from the Playwright server process
  72. =cut
  73. our ($spec, $server_bin, $node_bin, %mapper, %methods_to_rename);
  74. sub _check_node($path2here, $decoder) {
  75. # Make sure it's possible to start the server
  76. $server_bin = "$path2here/../bin/playwright.js";
  77. confess("Can't locate Playwright server in '$server_bin'!") unless -f $server_bin;
  78. #TODO make this portable with File::Which etc
  79. # Check that node and npm are installed
  80. $node_bin = File::Which::which('node');
  81. confess("node must exist and be executable") unless -x $node_bin;
  82. # Check for the necessary modules, this relies on package.json
  83. my $npm_bin = File::Which::which('npm');
  84. confess("npm must exist and be executable") unless -x $npm_bin;
  85. my $dep_raw;
  86. capture_stderr { $dep_raw = qx{$npm_bin list --json} };
  87. confess("Could not list available node modules!") unless $dep_raw;
  88. chomp $dep_raw;
  89. my $deptree = $decoder->decode($dep_raw);
  90. my @deps = map { $deptree->{dependencies}{$_} } keys(%{$deptree->{dependencies}});
  91. if ( grep { $_->{missing} } @deps ) {
  92. my $err = capture_stderr { qx{npm i} };
  93. my $exit = $? >> 8;
  94. # Ignore failing for bogus reasons
  95. if ($err !~ m/package-lock/) {
  96. confess("Error installing node dependencies:\n$err") if $exit;
  97. }
  98. }
  99. }
  100. sub _check_and_build_spec {
  101. my $path2here = File::Basename::dirname(Cwd::abs_path($INC{'Playwright.pm'}));
  102. my $specfile = "$path2here/../api.json";
  103. confess("Can't locate Playwright specification in '$specfile'!") unless -f $specfile;
  104. my $spec_raw = File::Slurper::read_text($specfile);
  105. my $decoder = JSON::MaybeXS->new();
  106. $spec = $decoder->decode($spec_raw);
  107. return ($path2here, $decoder);
  108. }
  109. sub _build_classes {
  110. $mapper{mouse} = sub { my ($self, $res) = @_; return Playwright::Mouse->new( handle => $self, id => $res->{_guid}, type => 'Mouse' ) };
  111. $mapper{keyboard} = sub { my ($self, $res) = @_; return Playwright::Keyboard->new( handle => $self, id => $res->{_guid}, type => 'Keyboard' ) };
  112. %methods_to_rename = (
  113. '$' => 'select',
  114. '$$' => 'selectMulti',
  115. '$eval' => 'eval',
  116. '$$eval' => 'evalMulti',
  117. );
  118. foreach my $class (keys(%$spec)) {
  119. $mapper{$class} = sub {
  120. my ($self, $res) = @_;
  121. my $class = "Playwright::$class";
  122. return $class->new( handle => $self, id => $res->{_guid}, type => $class );
  123. };
  124. #All of the Playwright::* Classes are made by this MAGIC
  125. Sub::Install::install_sub({
  126. code => sub ($classname,%options) {
  127. @class::ISA = qw{Playwright::Base};
  128. $options{type} = $class;
  129. return Playwright::Base::new($classname,%options);
  130. },
  131. as => 'new',
  132. into => "Playwright::$class",
  133. });
  134. # Hack in mouse and keyboard objects for the Page class
  135. if ($class eq 'Page') {
  136. foreach my $hid (qw{keyboard mouse}) {
  137. Sub::Install::install_sub({
  138. code => sub {
  139. my $self = shift;
  140. $Playwright::mapper{$hid}->($self, { _type => $self->{type}, _guid => $self->{guid} }) if exists $Playwright::mapper{$hid};
  141. },
  142. as => $hid,
  143. into => "Playwright::$class",
  144. });
  145. }
  146. }
  147. # Install the subroutines if they aren't already
  148. foreach my $method ((keys(%{$spec->{$class}{members}}), 'on')) {
  149. next if grep { $_ eq $method } qw{keyboard mouse};
  150. my $renamed = exists $methods_to_rename{$method} ? $methods_to_rename{$method} : $method;
  151. Sub::Install::install_sub({
  152. code => sub {
  153. my $self = shift;
  154. Playwright::Base::_request($self, args => [@_], command => $method, object => $self->{guid}, type => $self->{type} );
  155. },
  156. as => $renamed,
  157. into => "Playwright::$class",
  158. });
  159. }
  160. }
  161. }
  162. BEGIN {
  163. our $SKIP_BEGIN;
  164. if (! $SKIP_BEGIN ) {
  165. my ($path2here, $decoder) = _check_and_build_spec();
  166. _build_classes();
  167. _check_node($path2here, $decoder);
  168. }
  169. }
  170. sub new ($class, %options) {
  171. #XXX yes, this is a race, so we need retries in _start_server
  172. my $port = Net::EmptyPort::empty_port();
  173. my $self = bless({
  174. ua => $options{ua} // LWP::UserAgent->new(),
  175. port => $port,
  176. debug => $options{debug},
  177. pid => _start_server( $port, $options{debug}),
  178. parent => $$,
  179. }, $class);
  180. return $self;
  181. }
  182. =head1 METHODS
  183. =head2 launch(HASH) = Playwright::Browser
  184. The Argument hash here is essentially those you'd see from browserType.launch(). See:
  185. L<https://playwright.dev/#version=v1.5.1&path=docs%2Fapi.md&q=browsertypelaunchoptions>
  186. There is an additional "special" argument, that of 'type', which is used to specify what type of browser to use, e.g. 'firefox'.
  187. =cut
  188. sub launch ($self, %args) {
  189. Playwright::Base::_coerce($spec->{BrowserType}{members}, args => [\%args], command => 'launch' );
  190. delete $args{command};
  191. my $msg = Playwright::Util::request ('POST', 'session', $self->{port}, $self->{ua}, type => delete $args{type}, args => [\%args] );
  192. return $Playwright::mapper{$msg->{_type}}->($self,$msg) if (ref $msg eq 'HASH') && $msg->{_type} && exists $Playwright::mapper{$msg->{_type}};
  193. return $msg;
  194. }
  195. =head2 await (AsyncData) = Object
  196. Waits for an asynchronous operation returned by the waitFor* methods to complete and returns the value.
  197. =cut
  198. sub await ($self, $promise) {
  199. confess("Input must be an AsyncData") unless $promise->isa('AsyncData');
  200. my $obj = $promise->result(1);
  201. return $obj unless $obj->{_type};
  202. my $class = "Playwright::$obj->{_type}";
  203. return $class->new( type => $obj->{_type}, id => $obj->{_guid}, handle => $self );
  204. }
  205. =head2 quit, DESTROY
  206. Terminate the browser session and wait for the Playwright server to terminate.
  207. Automatically called when the Playwright object goes out of scope.
  208. =cut
  209. sub quit ($self) {
  210. #Prevent destructor from firing in child processes so we can do things like async()
  211. return unless $$ == $self->{parent};
  212. Playwright::Util::request ('GET', 'shutdown', $self->{port}, $self->{ua} );
  213. return waitpid($self->{pid},0);
  214. }
  215. sub DESTROY ($self) {
  216. $self->quit();
  217. }
  218. sub _start_server($port, $debug) {
  219. $debug = $debug ? '-d' : '';
  220. $ENV{DEBUG} = 'pw:api' if $debug;
  221. my $pid = fork // confess("Could not fork");
  222. if ($pid) {
  223. print "Waiting for port to come up..." if $debug;
  224. Net::EmptyPort::wait_port($port,30) or confess("Server never came up after 30s!");
  225. print "done\n" if $debug;
  226. return $pid;
  227. }
  228. exec( $node_bin, $server_bin, "-p", $port, $debug);
  229. }
  230. 1;