File Coverage

blib/lib/App/Wallflower.pm
Criterion Covered Total %
statement 113 220 51.3
branch 25 100 25.0
condition 5 28 17.8
subroutine 20 25 80.0
pod 3 3 100.0
total 166 376 44.1


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