File Coverage

blib/lib/App/Wallflower.pm
Criterion Covered Total %
statement 113 220 51.3
branch 25 100 25.0
condition 6 31 19.3
subroutine 20 25 80.0
pod 3 3 100.0
total 167 379 44.0


line stmt bran cond sub pod time code
1             package App::Wallflower;
2             $App::Wallflower::VERSION = '1.015';
3 1     1   116303 use strict;
  1         12  
  1         31  
4 1     1   5 use warnings;
  1         4  
  1         28  
5              
6 1     1   785 use Getopt::Long qw( GetOptionsFromArray );
  1         10478  
  1         4  
7 1     1   890 use Pod::Usage;
  1         50718  
  1         117  
8 1     1   9 use Carp;
  1         2  
  1         53  
9 1     1   585 use Plack::Util ();
  1         3028  
  1         25  
10 1     1   562 use URI;
  1         4700  
  1         32  
11 1     1   475 use Wallflower;
  1         2  
  1         35  
12 1     1   437 use Wallflower::Util qw( links_from );
  1         3  
  1         80  
13 1     1   8 use List::Util qw( uniqstr max );
  1         2  
  1         123  
14 1     1   8 use Path::Tiny;
  1         1  
  1         1633  
15              
16             sub _default_options {
17             return (
18 2     2   11 follow => 1,
19             environment => 'deployment',
20             host => ['localhost'],
21             verbose => 1,
22             errors => 1,
23             );
24             }
25              
26             # [ activating option, coderef ]
27             my @callbacks = (
28             [
29             errors => sub {
30             my ( $url, $response ) = @_;
31             my ( $status, $headers, $file ) = @$response;
32             return if $status == 200;
33             if ( $status == 301 ) {
34             my $i = 0;
35             $i += 2
36             while $i < @$headers && lc( $headers->[$i] ) ne 'location';
37             printf "$status %s -> %s\n", $url->path, $headers->[ $i + 1 ] || '?';
38             }
39             else {
40             printf "$status %s\n", $url->path;
41             }
42             },
43             ],
44             [
45             verbose => sub {
46             my ( $url, $response ) = @_;
47             my ( $status, $headers, $file ) = @$response;
48             return if $status != 200;
49             printf "$status %s%s\n", $url->path,
50             $file && " => $file [${\-s $file}]";
51             },
52             ],
53             [
54             tap => sub {
55             my ( $url, $response ) = @_;
56             my ( $status, $headers, $file ) = @$response;
57             if ( $status == 301 ) {
58             my $i = 0;
59             $i += 2
60             while $i < @$headers && lc( $headers->[$i] ) ne 'location';
61             note( "$url => " . ( $headers->[ $i + 1 ] || '?' ) );
62             }
63             elsif ( $status == 304 ) {
64             SKIP: { skip( $url, 1 ); }
65             }
66             else {
67             is( $status, 200, $url->path );
68             }
69             },
70             ],
71             );
72              
73             sub new_with_options {
74 1     1 1 2984 my ( $class, $args ) = @_;
75 1         4 my $input = (caller)[1];
76 1   50     18 $args ||= [];
77              
78             # save previous configuration
79 1         5 my $save = Getopt::Long::Configure();
80              
81             # ensure we use Getopt::Long's default configuration
82 1         20 Getopt::Long::ConfigDefaults();
83              
84             # get the command-line options (modifies $args)
85 1         20 my %option = _default_options();
86 1 50       5 GetOptionsFromArray(
87             $args, \%option,
88             'application=s', 'destination|directory=s',
89             'index=s', 'environment=s',
90             'follow!', 'filter|files|F',
91             'quiet', 'include|INC=s@',
92             'verbose!', 'errors!', 'tap!',
93             'host=s@',
94             'url|uri=s',
95             'parallel=i',
96             'help', 'manual',
97             'tutorial', 'version',
98             ) or pod2usage(
99             -input => $input,
100             -verbose => 1,
101             -exitval => 2,
102             );
103              
104             # restore Getopt::Long configuration
105 1         1214 Getopt::Long::Configure($save);
106              
107             # simple on-line help
108 1 50       26 pod2usage( -verbose => 1, -input => $input ) if $option{help};
109 1 50       5 pod2usage( -verbose => 2, -input => $input ) if $option{manual};
110             pod2usage(
111             -verbose => 2,
112             -input => do {
113 0         0 require Pod::Find;
114 0         0 Pod::Find::pod_where( { -inc => 1 }, 'Wallflower::Tutorial' );
115             },
116 1 50       3 ) if $option{tutorial};
117             print "wallflower version $Wallflower::VERSION\n" and exit
118 1 50 0     4 if $option{version};
119              
120             # application is required
121             pod2usage(
122             -input => $input,
123             -verbose => 1,
124             -exitval => 2,
125             -message => 'Missing required option: application'
126 1 50       10 ) if !exists $option{application};
127              
128             # create the object
129 0         0 return $class->new(
130             option => \%option,
131             args => $args,
132             );
133              
134             }
135              
136             sub new {
137 1     1 1 5176 my ( $class, %args ) = @_;
138 1 50       4 my %option = ( _default_options(), %{ $args{option} || {} } );
  1         11  
139 1   50     8 my $args = $args{args} || [];
140 1 50       2 my @cb = @{ $args{callbacks} || [] };
  1         4  
141              
142             # application is required
143 1 50       4 croak "Option application is required" if !exists $option{application};
144              
145             # setup TAP
146 1 50       3 if ( $option{tap} ) {
147 0         0 require Test::More;
148 0         0 import Test::More;
149 0 0       0 if ( $option{parallel} ) {
150 0         0 my $tb = Test::Builder->new;
151 0         0 $tb->no_plan;
152 0         0 $tb->use_numbers(0);
153             }
154 0         0 $option{quiet} = 1; # --tap = --quiet
155 0 0       0 if ( !exists $option{destination} ) {
156 0         0 $option{destination} = Path::Tiny->tempdir( CLEANUP => 1 );
157             }
158             }
159              
160             # --quiet = --no-verbose --no-errors
161 1 50       5 $option{verbose} = $option{errors} = 0 if $option{quiet};
162              
163             # add the hostname passed via --url to the list built with --host
164 0         0 push @{ $option{host} }, URI->new( $option{url} )->host
165 1 50       3 if $option{url};
166              
167             # pre-defined callbacks
168 1         6 push @cb, map $_->[1], grep $option{ $_->[0] }, @callbacks;
169              
170             # include option
171 1   50     11 my $path_sep = $Config::Config{path_sep} || ';';
172             $option{inc} = [ split /\Q$path_sep\E/, join $path_sep,
173 1 50       15 @{ $option{include} || [] } ];
  1         7  
174              
175 1         10 local $ENV{PLACK_ENV} = $option{environment};
176 1         3 local @INC = ( @{ $option{inc} }, @INC );
  1         8  
177             my $self = {
178             option => \%option,
179             args => $args,
180             callbacks => \@cb,
181             seen => {}, # keyed on $url->path
182             todo => [],
183             wallflower => Wallflower->new(
184             application => ref $option{application}
185             ? $option{application}
186             : Plack::Util::load_psgi( $option{application} ),
187             ( destination => $option{destination} )x!! $option{destination},
188             ( index => $option{index} )x!! $option{index},
189             ( url => $option{url} )x!! $option{url},
190 1 50       18 ),
191             };
192              
193             # setup parallel processing
194 1 50       8 if ( $self->{option}{parallel} ) {
195 0         0 require Fcntl;
196 0         0 import Fcntl qw( :seek :flock );
197 0         0 $self->{_parent_} = $$;
198 0         0 $self->{_forked_} = 0;
199 0         0 $self->{_ipc_dir_} = Path::Tiny->tempdir(
200             CLEANUP => 1,
201             TEMPLATE => 'wallflower-XXXX'
202             );
203             }
204              
205 1         10 return bless $self, $class;
206             }
207              
208             sub run {
209 1     1 1 9 my ($self) = @_;
210 1         5 ( my $args, $self->{args} ) = ( $self->{args}, [] );
211 1 50       6 my $method = $self->{option}{filter} ? '_process_args' : '_process_queue';
212 1         6 $self->$method(@$args);
213 1 50       6 if ( $self->{option}{parallel} ) { $self->_wait_for_kids; }
  0 50       0  
214 0         0 elsif ( $self->{option}{tap} ) { done_testing(); }
215             }
216              
217             sub _push_todo {
218 2     2   7 my ( $self, @items ) = @_;
219 2         4 my $seen = $self->{seen};
220 2         3 my $todo = $self->{todo};
221 2         6 my $host_ok = $self->_host_regexp;
222              
223             # add to the to-do list
224             @items = uniqstr # unique
225             grep !$seen->{$_}, # not already seen
226             map ref() ? $_->path : $_, # paths
227             grep !ref || !$_->scheme # from URI
228 2 50 33     24 || eval { $_->host =~ $host_ok }, # pointing only to expected hosts
229             @items;
230              
231 2         5 push @$todo, @items;
232              
233 2 50       22 if ( $self->{option}{parallel} ) {
234 0 0       0 if ( $self->{_parent_} == $$ ) { $self->_aggregate_todo(@items); }
  0         0  
235 0         0 else { $self->_save_todo; }
236             }
237             }
238              
239             sub _aggregate_todo {
240 0     0   0 my ( $self, @items ) = @_;
241 0         0 my $TODO = $self->{_ipc_dir_}->child('__TODO__');
242 0   0     0 my $latest = ( stat $TODO )[9] || 0;
243              
244             # aggregate all child todo into ours and save it as __TODO__
245 0         0 local *ARGV;
246 0         0 @ARGV = ( $TODO, glob $self->{_ipc_dir_}->child('todo-*') );
247 1     1   10 no warnings 'inplace'; # some files may already be gone
  1         2  
  1         1630  
248             my $fh = File::Temp->new(
249             TEMPLATE => "__TODO__-XXXX",
250             DIR => $self->{_ipc_dir_},
251 0         0 );
252 0 0       0 print $fh uniqstr @ARGV ? <> : (), map "$_\n", @items;
253 0         0 close $fh;
254 0 0       0 rename "$fh", $TODO
255             or die "Can't rename $fh to $TODO: $!";
256              
257             # the parent to-do list is always empty
258 0         0 $self->{todo} = [];
259              
260             # fork all kids
261 0 0       0 if ( !$self->{_forked_} ) {
262 0         0 for ( 1 .. $self->{option}{parallel} ) {
263 0 0       0 if ( not my $pid = fork ) {
    0          
264             $self->{_pidfile_} = Path::Tiny->tempfile(
265             TEMPLATE => "pid-$$-XXXX",
266             DIR => $self->{_ipc_dir_},
267 0         0 );
268 0         0 delete $self->{_seen_fh_}; # will reopen
269 0         0 return;
270             }
271             elsif ( !defined $pid ) {
272 0         0 warn "Couldn't fork: $!";
273             }
274             else {
275 0         0 $self->{_forked_}++;
276             }
277             }
278 0         0 sleep 1; # give them time to settle
279             }
280             }
281              
282             sub _save_todo {
283 0     0   0 my ($self) = @_;
284              
285             # save the child todo
286             my $fh = File::Temp->new(
287             TEMPLATE => "todo-$$-XXXX",
288             DIR => $self->{_ipc_dir_},
289 0         0 );
290 0         0 print $fh map "$_\n", @{ $self->{todo} };
  0         0  
291 0         0 close $fh;
292 0         0 $self->{_todo_fh_} = $fh; # deletes previous one
293             }
294              
295             # returns a boolean indicating if the update can be trusted
296             sub _update_todo {
297 0     0   0 my ($self) = @_;
298 0         0 my $todo = $self->{todo};
299 0         0 my $TODO = $self->{_ipc_dir_}->child('__TODO__');
300 0         0 my $SEEN = $self->{_ipc_dir_}->child('__SEEN__');
301              
302 0 0       0 return if !-e $TODO;
303             my $certainty = # this update can be trusted if __TODO__ is the
304             ( stat $TODO )[9] > max( 0, map +(stat)[9] || 0, # most recent
305 0   0     0 $SEEN, glob $self->{_ipc_dir_}->child('todo-*')); # file of all
306              
307             # read from the shared todo
308 0 0       0 open my $fh, '<', $TODO or die "Can't open $TODO: $!";
309 0         0 @$todo = <$fh>;
310 0         0 chomp(@$todo);
311              
312 0         0 return $certainty;
313             }
314              
315             sub _next_todo {
316 2     2   5 my ($self) = @_;
317 2         5 my $seen = $self->{seen};
318 2         3 my $todo = $self->{todo};
319 2         3 my $next;
320              
321 2 50       5 if ( $self->{option}{parallel} ) {
322              
323             # in parallel mode, the parent does not render anything
324 0 0       0 return if $self->{_parent_} == $$;
325              
326             TODO:
327              
328             # read from the shared seen file
329 0         0 my $SEEN = $self->{_ipc_dir_}->child('__SEEN__');
330 0   0     0 my $seen_fh = $self->{_seen_fh_} ||= do {
331 0 0       0 open my $fh, -e $SEEN ? '+<' : '+>', $SEEN
    0          
332             or die "Can't open $SEEN in read-write mode: $!";
333 0         0 $fh->autoflush(1);
334 0         0 $fh;
335             };
336 0 0       0 flock( $seen_fh, LOCK_EX() ) or die "Cannot lock $SEEN: $!\n";
337 0         0 seek( $seen_fh, 0, SEEK_CUR() );
338 0         0 while (<$seen_fh>) { chomp; $seen->{$_}++; }
  0         0  
  0         0  
339              
340             # find a todo item not seen
341 0         0 ( $next, @$todo ) = uniqstr grep !$seen->{$_}, @$todo;
342              
343             # or update todo and try again
344 0 0       0 if ( !defined $next ) {
345 0         0 my $certain = $self->_update_todo;
346 0         0 ( $next, @$todo ) = uniqstr grep !$seen->{$_}, @$todo;
347              
348             # if we can't trust the update, try the entire thing again
349 0 0 0     0 if ( !defined $next && !$certain ) {
350 0 0       0 flock( $seen_fh, LOCK_UN() ) or die "Cannot unlock $SEEN: $!\n";
351 0         0 sleep 1;
352 0         0 goto TODO;
353             }
354             }
355              
356             # write to the shared seen file
357 0 0       0 if ( defined $next ) { # /!\ NOT ELSE /!\
358 0         0 seek( $seen_fh, 0, SEEK_END() );
359 0         0 print $seen_fh "$next\n";
360             }
361 0 0       0 flock( $seen_fh, LOCK_UN() ) or die "Cannot unlock $SEEN: $!\n";
362             }
363             else {
364 2         8 ( $next, @$todo ) = uniqstr grep !$seen->{$_}, @$todo;
365             }
366              
367             # nothing to do
368 2 100       12 return undef if !defined $next;
369              
370 1         2 $seen->{$next}++;
371 1         5 return URI->new($next);
372             }
373              
374             sub _wait_for_kids {
375 0     0   0 my ($self) = @_;
376 0 0       0 return if $self->{_parent_} != $$;
377 0         0 while ( @{ [ glob( $self->{_ipc_dir_}->child('pid-*') ) ] } ) {
  0         0  
378 0         0 $self->_aggregate_todo;
379 0         0 sleep 1;
380             }
381 0 0       0 if ( $self->{option}{tap} ) {
382 0         0 my $count;
383 0         0 my $SEEN = $self->{_ipc_dir_}->child( '__SEEN__' );
384 0 0       0 open my $fh, '<', $SEEN or die "Can't open $SEEN: $!";
385 0         0 seek $fh, 0, SEEK_SET();
386 0         0 $count++ while <$fh>;
387 0         0 my $tb = Test::Builder->new;
388 0         0 $tb->no_ending(1);
389 0         0 $tb->done_testing($count);
390             }
391             }
392              
393             sub _process_args {
394 0     0   0 my $self = shift;
395 0         0 local *ARGV;
396 0         0 @ARGV = @_;
397 0         0 while (<>) {
398              
399             # ignore blank lines and comments
400 0 0       0 next if /^\s*(#|$)/;
401 0         0 chomp;
402              
403 0         0 $self->_process_queue("$_");
404              
405             # child processes should not process the filter input
406 0 0 0     0 last if $self->{option}{parallel} && $self->{_parent_} != $$;
407             }
408             }
409              
410             sub _process_queue {
411 1     1   12 my ( $self, @queue ) = @_;
412 1         3 my ( $wallflower, $seen ) = @{$self}{qw( wallflower seen )};
  1         7  
413 1         3 my $follow = $self->{option}{follow};
414              
415             # I'm just hanging on to my friend's purse
416 1         6 local $ENV{PLACK_ENV} = $self->{option}{environment};
417 1         4 local @INC = ( @{ $self->{option}{inc} }, @INC );
  1         6  
418 1 50       6 $self->_push_todo( @queue ? @queue : ('/') );
419              
420 1         5 while ( my $url = $self->_next_todo ) {
421              
422             # get the response
423 1         128 my $response = $wallflower->get($url);
424              
425             # run the callbacks
426 1         3 $_->( $url => $response ) for @{ $self->{callbacks} };
  1         6  
427              
428             # obtain links to resources
429 1         337 my ( $status, $headers, $file ) = @$response;
430 1 50 33     9 if ( ( $status == 200 || $status == 304 ) && $follow ) {
    0 33        
431 1         5 $self->_push_todo( links_from( $response => $url ) );
432             }
433              
434             # follow 301 Moved Permanently
435             elsif ( $status == 301 ) {
436 0         0 require HTTP::Headers;
437 0         0 my $l = HTTP::Headers->new(@$headers)->header('Location');
438 0 0       0 $self->_push_todo($l) if $l;
439             }
440             }
441             }
442              
443             sub _host_regexp {
444 2     2   3 my ($self) = @_;
445             my $re = join '|',
446 2         6 map { s/\./\\./g; s/\*/.*/g; $_ }
  2         3  
  2         9  
447 2         5 @{ $self->{option}{host} };
  2         5  
448 2         37 return qr{^(?:$re)$};
449             }
450              
451             1;
452              
453             __END__