1# BEGIN BPS TAGGED BLOCK {{{ 2# 3# COPYRIGHT: 4# 5# This software is Copyright (c) 1996-2021 Best Practical Solutions, LLC 6# <sales@bestpractical.com> 7# 8# (Except where explicitly superseded by other copyright notices) 9# 10# 11# LICENSE: 12# 13# This work is made available to you under the terms of Version 2 of 14# the GNU General Public License. A copy of that license should have 15# been provided with this software, but in any event can be snarfed 16# from www.gnu.org. 17# 18# This work is distributed in the hope that it will be useful, but 19# WITHOUT ANY WARRANTY; without even the implied warranty of 20# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 21# General Public License for more details. 22# 23# You should have received a copy of the GNU General Public License 24# along with this program; if not, write to the Free Software 25# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 26# 02110-1301 or visit their web page on the internet at 27# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html. 28# 29# 30# CONTRIBUTION SUBMISSION POLICY: 31# 32# (The following paragraph is not intended to limit the rights granted 33# to you to modify and distribute this software under the terms of 34# the GNU General Public License and is only of importance to you if 35# you choose to contribute your changes and enhancements to the 36# community by submitting them to Best Practical Solutions, LLC.) 37# 38# By intentionally submitting any modifications, corrections or 39# derivatives to this work, or any other work intended for use with 40# Request Tracker, to Best Practical Solutions, LLC, you confirm that 41# you are the copyright holder for those contributions and you grant 42# Best Practical Solutions, LLC a nonexclusive, worldwide, irrevocable, 43# royalty-free, perpetual, license to use, copy, create derivative 44# works based on those contributions, and sublicense and distribute 45# those contributions and any derivatives thereof. 46# 47# END BPS TAGGED BLOCK }}} 48 49package RT::Test::Web; 50 51use strict; 52use warnings; 53 54use base qw(Test::WWW::Mechanize); 55use MIME::Base64 qw//; 56use Encode 'encode_utf8'; 57use Storable 'thaw'; 58use HTTP::Status qw(); 59 60BEGIN { require RT::Test; } 61require Test::More; 62 63$RT::Test::Web::INSTANCES = undef; 64 65sub new { 66 my ($class, @args) = @_; 67 68 push @args, app => $RT::Test::TEST_APP if $RT::Test::TEST_APP; 69 my $self = $class->SUPER::new(@args); 70 $self->cookie_jar(HTTP::Cookies->new); 71 # Clear our caches of anything that the server process may have done 72 $self->add_handler( 73 response_done => sub { 74 RT::Record->FlushCache; 75 }, 76 ) if RT::Record->can( "FlushCache" ); 77 78 $RT::Test::Web::INSTANCES++; 79 return $self; 80} 81 82sub clone { 83 my $self = shift; 84 $RT::Test::Web::INSTANCES++ if defined $RT::Test::Web::INSTANCES; 85 return $self->SUPER::clone(); 86} 87 88sub get_ok { 89 my $self = shift; 90 my $url = shift; 91 if ( $url =~ s!^/!! ) { 92 $url = $self->rt_base_url . $url; 93 } 94 95 local $Test::Builder::Level = $Test::Builder::Level + 1; 96 my $rv = $self->SUPER::get_ok($url, @_); 97 Test::More::diag( "Couldn't get $url" ) unless $rv; 98 return $rv; 99} 100 101sub rt_base_url { 102 return $RT::Test::existing_server if $RT::Test::existing_server; 103 return "http://localhost:" . RT->Config->Get('WebPort') . RT->Config->Get('WebPath') . "/"; 104} 105 106sub login { 107 my $self = shift; 108 my $user = shift || 'root'; 109 my $pass = shift || 'password'; 110 my %args = @_; 111 112 $self->logout if $args{logout}; 113 114 my $url = $self->rt_base_url; 115 $self->get($url . "?user=$user;pass=$pass"); 116 117 return 0 unless $self->logged_in_as($user); 118 119 unless ( $self->content =~ m/Logout/i ) { 120 Test::More::diag("error: page has no Logout"); 121 return 0; 122 } 123 return 1; 124} 125 126sub logged_in_as { 127 my $self = shift; 128 my $user = shift || ''; 129 130 if ( $user =~ /\@/ ) { 131 my $user_object = RT::User->new( RT->SystemUser ); 132 $user_object->LoadByEmail($user); 133 if ( $user_object->Id ) { 134 $user = $user_object->Name; 135 } 136 } 137 138 unless ( $self->status == HTTP::Status::HTTP_OK ) { 139 Test::More::diag( "error: status is ". $self->status ); 140 return 0; 141 } 142 RT::Interface::Web::EscapeHTML(\$user); 143 unless ( $self->content =~ m{<span class="current-user">\Q$user\E</span>}i ) { 144 Test::More::diag("Page has no user name"); 145 return 0; 146 } 147 return 1; 148} 149 150sub logout { 151 my $self = shift; 152 153 my $url = $self->rt_base_url; 154 $self->get($url); 155 Test::More::diag( "error: status is ". $self->status ) 156 unless $self->status == HTTP::Status::HTTP_OK; 157 158 if ( $self->content =~ /Logout/i ) { 159 $self->follow_link( text => 'Logout' ); 160 Test::More::diag( "error: status is ". $self->status ." when tried to logout" ) 161 unless $self->status == HTTP::Status::HTTP_OK; 162 } 163 else { 164 return 1; 165 } 166 167 $self->get($url); 168 if ( $self->content =~ /Logout/i ) { 169 Test::More::diag( "error: couldn't logout" ); 170 return 0; 171 } 172 return 1; 173} 174 175sub goto_ticket { 176 my $self = shift; 177 my $id = shift; 178 my $view = shift || 'Display'; 179 my $status = shift || HTTP::Status::HTTP_OK; 180 unless ( $id && int $id ) { 181 Test::More::diag( "error: wrong id ". defined $id? $id : '(undef)' ); 182 return 0; 183 } 184 185 my $url = $self->rt_base_url; 186 $url .= "Ticket/${ view }.html?id=$id"; 187 $self->get($url); 188 unless ( $self->status == $status ) { 189 Test::More::diag( "error: status is ". $self->status ); 190 return 0; 191 } 192 return 1; 193} 194 195sub goto_create_ticket { 196 my $self = shift; 197 my $queue = shift; 198 199 my $id; 200 if ( ref $queue ) { 201 $id = $queue->id; 202 } elsif ( $queue =~ /^\d+$/ ) { 203 $id = $queue; 204 } else { 205 my $queue_obj = RT::Queue->new(RT->SystemUser); 206 my ($ok, $msg) = $queue_obj->Load($queue); 207 die "Unable to load queue '$queue': $msg" if !$ok; 208 $id = $queue_obj->id; 209 } 210 211 $self->get($self->rt_base_url . 'Ticket/Create.html?Queue='.$id); 212 213 return 1; 214} 215 216sub get_warnings { 217 my $self = shift; 218 local $Test::Builder::Level = $Test::Builder::Level + 1; 219 220 # We clone here so that when we fetch warnings, we don't disrupt the state 221 # of the test's mech. If we reuse the original mech then you can't 222 # test warnings immediately after fetching page XYZ, then fill out 223 # forms on XYZ. This is because the most recently fetched page has changed 224 # from XYZ to /__test_warnings, which has no form. 225 my $clone = $self->clone; 226 227 return unless $clone->get_ok('/__test_warnings'); 228 return @{ thaw $clone->content }; 229} 230 231sub warning_like { 232 my $self = shift; 233 my $re = shift; 234 my $name = shift; 235 236 local $Test::Builder::Level = $Test::Builder::Level + 1; 237 238 my @warnings = $self->get_warnings; 239 if (@warnings == 0) { 240 Test::More::fail("no warnings emitted; expected 1"); 241 return 0; 242 } 243 elsif (@warnings > 1) { 244 Test::More::fail(scalar(@warnings) . " warnings emitted; expected 1"); 245 for (@warnings) { 246 Test::More::diag("got warning: $_"); 247 } 248 return 0; 249 } 250 251 return Test::More::like($warnings[0], $re, $name); 252} 253 254sub next_warning_like { 255 my $self = shift; 256 my $re = shift; 257 my $name = shift; 258 259 local $Test::Builder::Level = $Test::Builder::Level + 1; 260 261 if (@{ $self->{stashed_server_warnings} || [] } == 0) { 262 my @warnings = $self->get_warnings; 263 if (@warnings == 0) { 264 Test::More::fail("no warnings emitted; expected 1"); 265 return 0; 266 } 267 $self->{stashed_server_warnings} = \@warnings; 268 } 269 270 my $warning = shift @{ $self->{stashed_server_warnings} }; 271 return Test::More::like($warning, $re, $name); 272} 273 274sub no_warnings_ok { 275 my $self = shift; 276 my $name = shift || "no warnings emitted"; 277 278 local $Test::Builder::Level = $Test::Builder::Level + 1; 279 280 my @warnings = $self->get_warnings; 281 282 Test::More::is(@warnings, 0, $name); 283 for (@warnings) { 284 Test::More::diag("got warning: $_"); 285 } 286 287 return @warnings == 0 ? 1 : 0; 288} 289 290sub no_leftover_warnings_ok { 291 my $self = shift; 292 293 my $name = shift || "no leftover warnings"; 294 295 local $Test::Builder::Level = $Test::Builder::Level + 1; 296 297 # we clear the warnings because we don't want to break later tests 298 # in case there *are* leftover warnings 299 my @warnings = splice @{ $self->{stashed_server_warnings} || [] }; 300 301 Test::More::is(@warnings, 0, $name); 302 for (@warnings) { 303 Test::More::diag("leftover warning: $_"); 304 } 305 306 return @warnings == 0 ? 1 : 0; 307} 308 309sub ticket_status { 310 my $self = shift; 311 my $id = shift; 312 313 $self->display_ticket( $id); 314 my ($got) = ($self->content =~ m{Status:\s*</div>\s*<div.*?>\s*<span.*?>\s*([\w ]+?)\s*</span>}ism); 315 unless ( $got ) { 316 Test::More::diag("Error: couldn't find status value on the page, may be regexp problem"); 317 } 318 return $got; 319} 320 321sub ticket_status_is { 322 my $self = shift; 323 my $id = shift; 324 my $status = shift; 325 my $desc = shift || "Status of the ticket #$id is '$status'"; 326 local $Test::Builder::Level = $Test::Builder::Level + 1; 327 return Test::More::is($self->ticket_status( $id), $status, $desc); 328} 329 330sub get_ticket_id { 331 my $self = shift; 332 my $content = $self->content; 333 my $id = 0; 334 if ($content =~ /.*Ticket (\d+) created.*/g) { 335 $id = $1; 336 } 337 elsif ($content =~ /.*No permission to view newly created ticket #(\d+).*/g) { 338 Test::More::diag("\nNo permissions to view the ticket.\n") if($ENV{'TEST_VERBOSE'}); 339 $id = $1; 340 } 341 return $id; 342} 343 344sub set_custom_field { 345 my $self = shift; 346 my $queue = shift; 347 my $cf_name = shift; 348 my $val = shift; 349 350 my $field_name = $self->custom_field_input( $queue, $cf_name ) 351 or return 0; 352 353 $self->field($field_name, $val); 354 return 1; 355} 356 357sub custom_field_input { 358 my $self = shift; 359 my $queue = shift; 360 my $cf_name = shift; 361 362 my $cf_obj = RT::CustomField->new( $RT::SystemUser ); 363 $cf_obj->LoadByName( 364 Name => $cf_name, 365 LookupType => RT::Ticket->CustomFieldLookupType, 366 ObjectId => $queue, 367 ); 368 unless ( $cf_obj->id ) { 369 Test::More::diag("Can not load custom field '$cf_name' in queue '$queue'"); 370 return undef; 371 } 372 my $cf_id = $cf_obj->id; 373 374 my ($res) = 375 grep /^Object-RT::Ticket-\d*-CustomField(?::\w+)?-$cf_id-Values?$/, 376 map $_->name, 377 $self->current_form->inputs; 378 unless ( $res ) { 379 Test::More::diag("Can not find input for custom field '$cf_name' #$cf_id"); 380 return undef; 381 } 382 return $res; 383} 384 385sub value_name { 386 my $self = shift; 387 my $field = shift; 388 389 my $input = $self->current_form->find_input( $field ) 390 or return undef; 391 392 my @names = $input->value_names; 393 return $input->value unless @names; 394 395 my @values = $input->possible_values; 396 for ( my $i = 0; $i < @values; $i++ ) { 397 return $names[ $i ] if $values[ $i ] eq $input->value; 398 } 399 return undef; 400} 401 402 403sub check_links { 404 my $self = shift; 405 my %args = @_; 406 407 my %has = map {$_ => 1} @{ $args{'has'} }; 408 my %has_no = map {$_ => 1} @{ $args{'has_no'} }; 409 410 local $Test::Builder::Level = $Test::Builder::Level + 1; 411 412 my @found; 413 414 my @links = $self->followable_links; 415 foreach my $text ( grep defined && length, map $_->text, @links ) { 416 push @found, $text if $has_no{ $text }; 417 delete $has{ $text }; 418 } 419 if ( @found || keys %has ) { 420 Test::More::ok( 0, "expected links" ); 421 Test::More::diag( "didn't expect, but found: ". join ', ', map "'$_'", @found ) 422 if @found; 423 Test::More::diag( "didn't find, but expected: ". join ', ', map "'$_'", keys %has ) 424 if keys %has; 425 return 0; 426 } 427 return Test::More::ok( 1, "expected links" ); 428} 429 430sub auth { 431 my $self = shift; 432 $self->default_header( $self->auth_header(@_) ); 433} 434 435sub auth_header { 436 my $self = shift; 437 return Authorization => "Basic " . 438 MIME::Base64::encode( join(":", @_) ); 439} 440 441sub dom { 442 my $self = shift; 443 Carp::croak("Can not get DOM, not HTML repsone") 444 unless $self->is_html; 445 require Mojo::DOM; 446 return Mojo::DOM->new( $self->content ); 447} 448 449# override content_* and text_* methods in Test::Mech to dump the content 450# on failure, to speed investigation 451for my $method_name (qw/ 452 content_is content_contains content_lacks content_like content_unlike 453 text_contains text_lacks text_like text_unlike 454/) { 455 my $super_method = __PACKAGE__->SUPER::can($method_name); 456 my $implementation = sub { 457 local $Test::Builder::Level = $Test::Builder::Level + 1; 458 459 my $self = shift; 460 my $ok = $self->$super_method(@_); 461 if (!$ok) { 462 my $dir = RT::Test->temp_directory; 463 my ($name) = $self->uri->path =~ m{/([^/]+)$}; 464 $name ||= 'index.html'; 465 466 my $file = $dir . '/' . RT::Test->builder->current_test . '-' . $name; 467 468 open my $handle, '>', $file or die $!; 469 print $handle encode_utf8($self->content) or die $!; 470 close $handle or die $!; 471 472 Test::More::diag("Dumped failing test page content to $file"); 473 } 474 return $ok; 475 }; 476 477 no strict 'refs'; 478 *{$method_name} = $implementation; 479} 480 481sub DESTROY { 482 my $self = shift; 483 484 if (defined $RT::Test::Web::INSTANCES) { 485 $RT::Test::Web::INSTANCES--; 486 if ($RT::Test::Web::INSTANCES == 0 ) { 487 # Ordering matters -- clean out INSTANCES before we check 488 # warnings, so the clone therein sees that we've already begun 489 # cleanups. 490 undef $RT::Test::Web::INSTANCES; 491 $self->no_warnings_ok; 492 } 493 } 494} 495 496END { 497 return if RT::Test->builder->{Original_Pid} != $$; 498 if (defined $RT::Test::Web::INSTANCES and $RT::Test::Web::INSTANCES == 0 ) { 499 # Ordering matters -- clean out INSTANCES after the `new` 500 # bumps it up to 1. 501 my $cleanup = RT::Test::Web->new; 502 undef $RT::Test::Web::INSTANCES; 503 $cleanup->no_warnings_ok; 504 } 505} 506 5071; 508