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