Bug 26986: Prevent Selenium's StaleElementReferenceException
[koha.git] / t / lib / Selenium.pm
1 package t::lib::Selenium;
2
3 # This file is part of Koha.
4 #
5 # Koha is free software; you can redistribute it and/or modify it
6 # under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 3 of the License, or
8 # (at your option) any later version.
9 #
10 # Koha is distributed in the hope that it will be useful, but
11 # WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with Koha; if not, see <http://www.gnu.org/licenses>.
17
18
19 use Modern::Perl;
20 use Carp qw( croak );
21 use JSON qw( from_json );
22 use File::Slurp qw( write_file );
23
24 use C4::Context;
25
26 use base qw(Class::Accessor);
27 __PACKAGE__->mk_accessors(qw(login password base_url opac_base_url selenium_addr selenium_port driver));
28
29 sub capture {
30     my ( $class, $driver ) = @_;
31
32     $driver->get_page_source;
33     write_file('/tmp/page_source_from_selenium', {binmode => ':utf8'}, $driver->get_page_source );
34     my $gdf3_url = qx(cat /tmp/page_source_from_selenium | curl --data-binary \@- https://gdf3.com);
35     print STDERR "\nPage source pasted at $gdf3_url";
36
37     my $lutim_server = q|https://pic.infini.fr/|; # Thanks Infini!
38     $driver->capture_screenshot('selenium_failure.png');
39     my $from_json = from_json qx{curl -s -F "format=json" -F "file=\@selenium_failure.png" -F "delete-day=1" $lutim_server};
40     if ( $from_json ) {
41         print STDERR "\nSCREENSHOT: $lutim_server/" . $from_json->{msg}->{short} . "\n";
42     }
43 }
44
45 sub new {
46     my ( $class, $params ) = @_;
47     my $self   = {};
48     my $config = $class->config;
49     $self->{login}    = $params->{login}    || $config->{login};
50     $self->{password} = $params->{password} || $config->{password};
51     $self->{base_url} = $params->{base_url} || $config->{base_url};
52     $self->{opac_base_url} = $params->{opac_base_url} || $config->{opac_base_url};
53     $self->{selenium_addr} = $params->{selenium_addr} || $config->{selenium_addr};
54     $self->{selenium_port} = $params->{selenium_port} || $config->{selenium_port};
55     $self->{driver} = Selenium::Remote::Driver->new(
56         port               => $self->{selenium_port},
57         remote_server_addr => $self->{selenium_addr},
58     );
59     bless $self, $class;
60     $self->add_error_handler;
61     return $self;
62 }
63
64 sub add_error_handler {
65     my ( $self ) = @_;
66     $self->{driver}->error_handler(
67         sub {
68             my ( $driver, $selenium_error ) = @_;
69             print STDERR "\nSTRACE:";
70             my $i = 1;
71             while ( (my @call_details = (caller($i++))) ){
72                 print STDERR "\t" . $call_details[1]. ":" . $call_details[2] . " in " . $call_details[3]."\n";
73             }
74             print STDERR "\n";
75             $self->capture( $driver );
76             croak $selenium_error;
77         }
78     );
79 }
80
81 sub remove_error_handler {
82     my ( $self ) = @_;
83     $self->{driver}->error_handler( sub {} );
84 }
85
86 sub config {
87     return {
88         login    => $ENV{KOHA_USER} || 'koha',
89         password => $ENV{KOHA_PASS} || 'koha',
90         base_url => ( $ENV{KOHA_INTRANET_URL} || C4::Context->preference("staffClientBaseURL") ) . "/cgi-bin/koha/",
91         opac_base_url => ( $ENV{KOHA_OPAC_URL} || C4::Context->preference("OPACBaseURL") ) . "/cgi-bin/koha/",
92         selenium_addr => $ENV{SELENIUM_ADDR} || 'localhost',
93         selenium_port => $ENV{SELENIUM_PORT} || 4444,
94     };
95 }
96
97 sub auth {
98     my ( $self, $login, $password ) = @_;
99
100     $login ||= $self->login;
101     $password ||= $self->password;
102     my $mainpage = $self->base_url . 'mainpage.pl';
103
104     $self->driver->get($mainpage);
105     $self->fill_form( { userid => $login, password => $password } );
106     my $login_button = $self->driver->find_element('//input[@id="submit"]');
107     $login_button->submit();
108 }
109
110 sub opac_auth {
111     my ( $self, $login, $password ) = @_;
112
113     $login ||= $self->login;
114     $password ||= $self->password;
115     my $mainpage = $self->opac_base_url . 'opac-main.pl';
116
117     $self->driver->get($mainpage . q|?logout.x=1|); # Logout before, to make sure we will see the login form
118     $self->driver->get($mainpage);
119     $self->fill_form( { userid => $login, password => $password } );
120     $self->submit_form;
121 }
122
123 sub fill_form {
124     my ( $self, $values ) = @_;
125     while ( my ( $id, $value ) = each %$values ) {
126         my $element = $self->driver->find_element('//*[@id="'.$id.'"]');
127         my $tag = $element->get_tag_name();
128         if ( $tag eq 'input' ) {
129             $self->driver->find_element('//input[@id="'.$id.'"]')->send_keys($value);
130         } elsif ( $tag eq 'select' ) {
131             $self->driver->find_element('//select[@id="'.$id.'"]/option[@value="'.$value.'"]')->click;
132         }
133     }
134 }
135
136 sub submit_form {
137     my ( $self ) = @_;
138
139     my $default_submit_selector = '//fieldset[@class="action"]/input[@type="submit"]';
140     $self->click_when_visible( $default_submit_selector );
141 }
142
143 sub click {
144     my ( $self, $params ) = @_;
145     my $xpath_selector;
146     if ( exists $params->{main} ) {
147         $xpath_selector = '//div[@id="'.$params->{main}.'"]';
148     } elsif ( exists $params->{main_class} ) {
149         $xpath_selector = '//div[@class="'.$params->{main_class}.'"]';
150     }
151     if ( exists $params->{href} ) {
152         if ( ref( $params->{href} ) ) {
153             for my $k ( keys %{ $params->{href} } ) {
154                 if ( $k eq 'ends-with' ) {
155                     # ends-with version for xpath version 1
156                     my $ends_with = $params->{href}{"ends-with"};
157                     $xpath_selector .= '//a[substring(@href, string-length(@href) - string-length("'.$ends_with.'") + 1 ) = "'.$ends_with.'"]';
158                     # ends-with version for xpath version 2
159                     #$xpath_selector .= '//a[ends-with(@href, "'.$ends_with.'") ]';
160
161             } else {
162                     die "Only ends-with is supported so far ($k)";
163                 }
164             }
165         } else {
166             $xpath_selector .= '//a[contains(@href, "'.$params->{href}.'")]';
167         }
168     }
169     if ( exists $params->{id} ) {
170         $xpath_selector .= '//*[@id="'.$params->{id}.'"]';
171     }
172     $self->click_when_visible( $xpath_selector );
173 }
174
175 sub wait_for_element_visible {
176     my ( $self, $xpath_selector ) = @_;
177
178     $self->driver->set_implicit_wait_timeout(20000);
179     my ($visible, $elt);
180     $self->remove_error_handler;
181     while ( not $visible ) {
182         $elt = eval {$self->driver->find_element($xpath_selector) };
183         $visible = $elt && $elt->is_displayed;
184         $self->driver->pause(1000) unless $visible;
185     }
186     $self->add_error_handler;
187     return $elt;
188 }
189
190 sub show_all_entries {
191     my ( $self, $xpath_selector ) = @_;
192
193     $self->driver->find_element( $xpath_selector
194           . '//div[@class="dataTables_length"]/label/select/option[@value="-1"]'
195     )->click;
196     my ($all_displayed, $i);
197     my $max_retries = $self->max_retries;
198     while ( not $all_displayed ) {
199         my $dt_infos = $self->driver->get_text(
200             $xpath_selector . '//div[@class="dataTables_info"]' );
201
202         if ( $dt_infos =~ m|Showing 1 to (\d+) of (\d+) entries| ) {
203             $all_displayed = 1 if $1 == $2;
204         }
205
206         $self->driver->pause(1000) unless $all_displayed;
207
208         die "Cannot show all entries from table $xpath_selector"
209             if $max_retries <= ++$i
210     }
211 }
212
213 sub click_when_visible {
214     my ( $self, $xpath_selector ) = @_;
215
216     my $elt = $self->wait_for_element_visible( $xpath_selector );
217
218     my $clicked;
219     $self->remove_error_handler;
220     while ( not $clicked ) {
221         eval { $self->driver->find_element($xpath_selector)->click };
222         $clicked = !$@;
223         $self->driver->pause(1000) unless $clicked;
224     }
225     $self->add_error_handler;
226     $elt->click unless $clicked; # finally Raise the error
227 }
228
229 sub max_retries { 10 }
230
231 =head1 NAME
232
233 t::lib::Selenium - Selenium helper module
234
235 =head1 SYNOPSIS
236
237     my $s = t::lib::Selenium->new;
238     my $driver = $s->driver;
239     my $base_url = $s->base_url;
240     $s->auth;
241     $driver->get($s->base_url . 'mainpage.pl');
242     $s->fill_form({ input_id => 'value' });
243
244 =head1 DESCRIPTION
245
246 The goal of this module is to group the different actions we need
247 when we use automation test using Selenium
248
249 =head1 METHODS
250
251 =head2 new
252
253     my $s = t::lib::Selenium->new;
254
255     Constructor - Returns the object Selenium
256     You can pass login, password, base_url, selenium_addr, selenium_port
257     If not passed, the environment variables will be used
258     KOHA_USER, KOHA_PASS, KOHA_INTRANET_URL, SELENIUM_ADDR SELENIUM_PORT
259     Or koha, koha, syspref staffClientBaseURL, localhost, 4444
260
261 =head2 auth
262
263     $s->auth;
264
265     Will login into Koha.
266
267 =head2 fill_form
268
269     $driver->get($url)
270     $s->fill_form({
271         input_id => 'value',
272         element_id => 'other_value',
273     });
274
275     Will fill the different elements of a form.
276     The keys must be element ids (input and select are supported so far)
277     The values must a string.
278
279 =head2 submit_form
280
281     $s->submit_form;
282
283     It will submit the form using the submit button present in in the fieldset with a clas="action".
284     It should be the default way. If it does not work you should certainly fix the Koha interface.
285
286 =head2 click
287
288     $s->click
289
290     This is a bit dirty for now but will evolve depending on the needs
291     3 parameters possible but only the following 2 forms are used:
292     $s->click({ href => '/module/script.pl?foo=bar', main => 'doc3' }); # Sometimes we have doc or doc3. To make sure we are not going to hit a link in the header
293     $s->click({ id => 'element_id });
294
295 =head2 click_when_visible
296
297     $c->click_when_visible
298
299     Should always be called to avoid the "An element could not be located on the page" error
300
301 =head2 capture
302     $c->capture
303
304 Capture a screenshot and upload it using the excellent lut.im service provided by framasoft
305 The url of the image will be printed on STDERR (it should be better to return it instead)
306
307 =head2 add_error_handler
308     $c->add_error_handler
309
310 Add our specific error handler to the driver.
311 It will displayed a trace as well as capture a screenshot of the current screen.
312 So only case you should need it is after you called remove_error_handler
313
314 =head2 remove_error_handler
315     $c->remove_error_handler
316
317 Do *not* call this method if you are not aware of what it will do!
318 It will remove any kinds of error raised by the driver.
319 It can be useful in some cases, for instance if you want to make sure something will not happen and that could make the driver exploses otherwise.
320 You certainly should call it for only one statement then must call add_error_handler right after.
321
322 =head1 AUTHORS
323
324 Jonathan Druart <jonathan.druart@bugs.koha-community.org>
325
326 Alex Buckley <alexbuckley@catalyst.net.nz>
327
328 Koha Development Team
329
330 =head1 COPYRIGHT
331
332 Copyright 2017 - Koha Development Team
333
334 =head1 LICENSE
335
336 This file is part of Koha.
337
338 Koha is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
339 the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
340
341 Koha is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
342
343 You should have received a copy of the GNU General Public License along with Koha; if not, see <http://www.gnu.org/licenses>.
344
345 =cut
346
347 1;