File Coverage

blib/lib/Lembas.pm
Criterion Covered Total %
statement 32 207 15.4
branch 0 76 0.0
condition 0 15 0.0
subroutine 11 18 61.1
pod 2 3 66.6
total 45 319 14.1


line stmt bran cond sub pod time code
1             package Lembas;
2              
3 1     1   688 use strict;
  1         2  
  1         31  
4 1     1   4 use warnings;
  1         1  
  1         30  
5 1     1   38 use 5.010;
  1         2  
6 1     1   4 use Carp;
  1         2  
  1         181  
7              
8 1     1   5 use Test::Builder;
  1         2  
  1         24  
9 1     1   622 use Params::Validate qw/:types validate/;
  1         7499  
  1         198  
10 1     1   737 use IPC::Run qw/start/;
  1         35966  
  1         48  
11 1     1   7 use List::Util qw/sum/;
  1         1  
  1         67  
12 1     1   416 use Text::ParseWords;
  1         915  
  1         51  
13              
14 1     1   435 use Moo::Lax;
  1         8846  
  1         8  
15              
16             our $VERSION = 0.001;
17              
18             has 'shell' => (is => 'ro',
19             required => 1,
20             isa => sub { ref $_[0]
21             and ref $_[0] eq 'ARRAY'
22             or die 'shell parameter must be an arrayref' },
23             coerce => sub { ref $_[0] ? $_[0] : [ $_[0] ] });
24              
25             has 'debug' => (is => 'ro',
26             default => sub { 0 });
27              
28             has 'input' => (is => 'ro',
29             init_arg => undef,
30             default => sub { my $anonymous = '';
31             \$anonymous });
32             has 'output' => (is => 'ro',
33             init_arg => undef,
34             default => sub { my $anonymous = '';
35             \$anonymous });
36             has 'errput' => (is => 'ro',
37             init_arg => undef,
38             default => sub { my $anonymous = '';
39             \$anonymous });
40              
41             has 'subprocess' => (is => 'ro',
42             lazy => 1,
43             builder => '_build_subprocess');
44              
45             has 'commands' => (is => 'ro',
46             required => 1,
47             isa => sub { ref $_[0]
48             and ref $_[0] eq 'ARRAY'
49             or die 'shell parameter must be an arrayref' });
50              
51             has 'test_builder' => (is => 'ro',
52             default => sub { Test::Builder->new },
53             handles => [ qw/is_passing/ ]);
54              
55             has 'plan_size' => (is => 'ro');
56              
57             has '_ansi_escape' => (is => 'ro',
58             default => sub { qr/\x{1B}\[([0-9]{1,3}((;[0-9]{1,3})*)?)?[m|K]/ });
59              
60             sub _build_subprocess {
61              
62 0     0     my $self = shift;
63             my $subprocess = start($self->shell,
64             '<', $self->input,
65             '1>', $self->output,
66             '2>', $self->errput)
67             or croak(sprintf(q{Could not start subprocess with '%s': return code %d},
68 0 0         join(' ', @{$self->shell}), $?));
  0            
69              
70 0           return $subprocess;
71              
72             }
73              
74             sub ansi_escape {
75              
76 0     0 0   my ($self, $string) = @_;
77 0           my $ansi_escape = $self->_ansi_escape;
78 0           $string =~ s/$ansi_escape//g;
79 0           return $string;
80              
81             }
82              
83             sub new_from_test_spec {
84              
85 0     0 1   my $class = shift;
86 0           my %params = validate(@_,
87             { shell => { type => SCALAR | ARRAYREF, optional => 1 },
88             handle => { isa => 'IO::Handle' },
89             debug => { type => SCALAR, default => 0 }});
90              
91 0           my ($shell, $commands, $plan_size) = $class->_parse_test_spec($params{handle});
92              
93             # shell given from params takes precedence on shell guessed from
94             # shebang
95             return $class->new(shell => $params{shell} || $shell,
96             commands => $commands,
97             debug => $params{debug},
98 0   0       plan_size => $plan_size);
99              
100             }
101              
102             sub _pump_one_line {
103              
104 0     0     my $self = shift;
105              
106 0           my %params = validate(@_,
107             { blocking => { type => SCALAR, default => 1 } });
108              
109 0           my $old_output = ${$self->output};
  0            
110              
111 0           while (${$self->output} !~ /\n/) {
  0            
112              
113 0 0         if (${$self->errput}) {
  0            
114              
115 0           $self->test_builder->diag('STDERR: '.${$self->errput});
  0            
116 0           ${$self->errput} = '';
  0            
117              
118             }
119              
120 0 0         if ($params{blocking}) {
121              
122 0           $self->subprocess->pump;
123              
124             } else {
125              
126 0           $self->subprocess->pump_nb;
127              
128 0 0         if ($old_output eq ${$self->output}) {
  0            
129              
130             # give up, nothing seems to be in the pipe
131 0           last;
132              
133             }
134              
135             }
136              
137             }
138              
139 0           return 1;
140              
141             }
142              
143             sub run {
144              
145 0     0 1   my $self = shift;
146              
147 0           foreach my $command (@{$self->commands}) {
  0            
148              
149 0 0         if (not defined $command->{shell}) {
150              
151             # called command "preamble", need to start matching output
152             # *before* sending input
153              
154 0           $self->test_builder->note('Matching preamble output...');
155              
156             } else {
157              
158 0           $self->test_builder->note($command->{shell});
159 0           ${$self->input} .= $command->{shell} . "\n";
  0            
160              
161             }
162              
163 0           my $fastforwarding = 0;
164 0           my $fastforwarding_buffer;
165             my $fastforwarding_sink;
166 0           my $fastforwarding_output_control = { current_output => $self->test_builder->output,
167             current_failure_output => $self->test_builder->failure_output,
168             current_todo_output => $self->test_builder->todo_output };
169              
170 0           while (my $expected_output = shift @{$command->{outputs}}) {
  0            
171              
172 0 0         if (exists $expected_output->{command}) {
173              
174 0           my @parameters = @{$expected_output->{parameters}};
  0            
175              
176 0 0         if ($expected_output->{command} eq 'fastforward') {
    0          
177             # handle params someday, for now assume "some"
178 0           $self->test_builder->note('Fastforwarding...');
179 0           $fastforwarding = 1;
180 0           $self->test_builder->output(\$fastforwarding_buffer);
181 0           $self->test_builder->failure_output(\$fastforwarding_sink);
182 0           $self->test_builder->todo_output(\$fastforwarding_buffer);
183             } elsif ($expected_output->{command} eq 'wait_less_than') {
184 0           alarm $parameters[0];
185             } else {
186             croak(sprintf(q{unknown command '%s'},
187 0           $expected_output->{command}));
188             }
189              
190 0           next;
191              
192             }
193              
194             $self->test_builder->note(sprintf(q{Waiting for a %s match of %s%s},
195             $expected_output->{match_type},
196             $expected_output->{output},
197             $fastforwarding ? ' (fastforward mode)' : ''))
198 0 0         if $self->{debug};
    0          
199              
200 0           my $had_timeout = eval {
201 0     0     local $SIG{ALRM} = sub { die "alarm\n" };
  0            
202 0           $self->_pump_one_line;
203 0           alarm 0;
204             };
205              
206 0 0         if (my $error = $@) {
207 0 0         die unless $error eq "alarm\n";
208             # timed out
209 0           $self->test_builder->ok(0, "timed out");
210 0           $self->test_builder->BAIL_OUT('Dangerous to continue after a time out');
211             } else {
212 0 0         if ($had_timeout) {
213 0           $self->test_builder->ok(1, "output was present with $had_timeout seconds left before timeout");
214             }
215             }
216              
217 0           ${$self->output} =~ s/^([^\n]*?)\r?\n//;
  0            
218 0           my $output = $1;
219 0           $output = $self->ansi_escape($output);
220              
221 0 0         if ($expected_output->{match_type} eq 'literal') {
    0          
222              
223             $self->test_builder->is_eq($output, $expected_output->{output},
224 0           sprintf(q{literal match of '%s'}, $expected_output->{output}));
225              
226             } elsif ($expected_output->{match_type} eq 'regex') {
227              
228 0           my $regex = $expected_output->{output};
229 1     1   2983 use re 'eval'; # allow delayed interpolation trickery
  1         3  
  1         1396  
230             $self->test_builder->like($output, qr/$regex/,
231 0           sprintf(q{regex match of '%s'}, $expected_output->{output}));
232              
233             } else {
234              
235             croak(sprintf(q{unknown match type '%s'},
236 0           $expected_output->{match_type}));
237              
238             }
239              
240 0 0         if ($fastforwarding) {
241              
242 0           my $tb = $self->test_builder;
243 0           my $current_test = $tb->current_test;
244 0           my @details = $tb->details;
245 0 0         if ($details[$current_test - 1]->{ok}) {
246             # a test passed. stop fastforwarding
247 0           $fastforwarding = 0;
248 0           $tb->output($fastforwarding_output_control->{current_output});
249 0           $tb->failure_output($fastforwarding_output_control->{current_failure_output});
250 0           $tb->todo_output($fastforwarding_output_control->{current_todo_output});
251             # and belatedly output the results of the passing
252             # test, since it's been suppressed with the rest
253 0           $tb->output->print($fastforwarding_buffer);
254 0           $fastforwarding_buffer = '';
255 0           $fastforwarding_sink = '';
256             } else {
257             # didn't pass, but we're fastforwarding. it may
258             # still pass in the future
259              
260             # rewrite TB's history
261 0           $tb->current_test($current_test - 1);
262             # put the test back in the queue
263 0           unshift @{$command->{outputs}}, $expected_output;
  0            
264             # forget about the output of the failing test
265 0           $fastforwarding_buffer = '';
266 0           $fastforwarding_sink = '';
267             }
268              
269             }
270              
271             }
272              
273 0           $self->_pump_one_line(blocking => 0);
274              
275 0 0         if (${$self->output}) {
  0            
276              
277             $self->test_builder->ok(0, sprintf(q{extra unmatched output for '%s'},
278 0 0         defined($command->{shell}) ? $command->{shell} : ''));
279 0           $self->test_builder->diag(map { "'$_'" } split(/\r?\n/, ${$self->output}));
  0            
  0            
280              
281             } else {
282              
283             $self->test_builder->ok(1, sprintf(q{all output tested for '%s'},
284 0 0         defined($command->{shell}) ? $command->{shell} : ''));
285              
286             }
287              
288             # cleanup output to make room for the next command
289 0           ${$self->output} = '';
  0            
290              
291             }
292              
293 0           $self->subprocess->finish;
294 0           return $self;
295              
296             }
297              
298             sub _parse_test_spec {
299              
300 0     0     my ($class, $handle) = @_;
301              
302 0           my $shell;
303 0           my $commands = [];
304 0           my $plan_size = 0;
305 0           my @errors;
306              
307 0           my %valid_output_prefixes = (' ' => 'literal',
308             're: ' => 'regex');
309 0           my $output_prefix_re = join('|', keys %valid_output_prefixes);
310              
311 0           my %valid_commands = ('fastforward' => 1,
312             'wait_less_than' => 1,
313             'preamble' => 1);
314 0           my $commands_re = join('|', keys %valid_commands, '\w+');
315              
316 0           while (defined(my $line = $handle->getline)) {
317              
318 0           chomp($line);
319              
320 0 0         if ($line =~ /^#/) {
    0          
    0          
    0          
    0          
321              
322             # Comment, skip it... unless it's a shebang
323              
324 0 0 0       if ($handle->input_line_number <= 1
325             and $line =~ m/^#!/) {
326              
327 0           $shell = $line;
328 0           $shell =~ s/^#!//;
329 0           $shell = [ shellwords($shell) ];
330              
331             }
332              
333             } elsif ($line =~/^$/) {
334              
335             # Empty line (no starting whitespace) is only for clarity
336              
337             } elsif ($line =~ /^ {4}\$ (.*)$/) {
338              
339             # Shell command, push into the arrayref to create a new
340             # command/output couple
341              
342 0           my $command = $1;
343 0           push @{$commands}, { shell => $command,
  0            
344             outputs => [] };
345 0           $plan_size++; # "no leftover output" test
346              
347             } elsif ($line =~ /^($output_prefix_re)(.*)$/) {
348              
349             # Output line with optional match type
350              
351 0           my ($match_type, $output) = ($valid_output_prefixes{$1}, $2);
352              
353 0 0         unless (grep { exists $_->{shell} } @{$commands}) {
  0            
  0            
354              
355 0           push @errors, { line_number => $handle->input_line_number,
356             error => 'Output before any shell commands' };
357 0           next;
358              
359             }
360              
361 0           push @{$commands->[-1]->{outputs}}, { match_type => $match_type,
  0            
362             output => $output };
363 0           $plan_size++; # a literal or regex match test
364              
365             } elsif ($line =~ /^($commands_re)\s*(.*)$/) {
366              
367             # Lembas command
368 0           my ($command, $parameters) = ($1, $2);
369              
370 0 0         unless (exists $valid_commands{$command}) {
371              
372 0           push @errors, { line_number => $handle->input_line_number,
373             error => sprintf(q{Call to unknown command '%s'},
374             $command) };
375 0           next;
376              
377             }
378              
379 0 0 0       unless ($command eq 'preamble'
380 0           or grep { exists $_->{shell} } @{$commands}) {
  0            
381              
382 0           push @errors, { line_number => $handle->input_line_number,
383             error => 'Call to command before any shell commands' };
384 0           next;
385              
386             }
387              
388 0           my @parameters = quotewords('\s+', 0, $parameters);
389              
390 0 0 0       if ($parameters
391             and not @parameters) {
392              
393             # parsing into quoted words failed, probably
394             # unbalanced delimiters.
395 0           push @errors, { line_number => $handle->input_line_number,
396             error => 'Parameter list appears to contain unbalanced delimiters' };
397 0           next;
398              
399             }
400              
401 0 0         if ($command eq 'preamble') {
    0          
402 0 0         if (@{$commands}) {
  0            
403              
404 0           push @errors, { line_number => $handle->input_line_number,
405             error => 'Call to "preamble" command after shell commands' };
406 0           next;
407              
408             }
409              
410 0           push @{$commands}, { shell => undef,
  0            
411             outputs => [] };
412             # preamble command also ends up generating a "no
413             # leftover output" test
414 0           $plan_size++;
415              
416             } elsif ($command eq 'wait_less_than') {
417              
418 0 0 0       if (@parameters > 2
    0          
419             or @parameters < 1) {
420 0           push @errors, { line_number => $handle->input_line_number,
421             error => 'wait_less_than command accepts 1 or 2 parameters' };
422 0           next;
423             } elsif (@parameters == 2) {
424 0           my ($value, $unit) = @parameters;
425 0 0         if ($unit =~ /second(?:s)?/) {
    0          
426 0           @parameters = ($value);
427             } elsif ($unit =~ /minute(?:s)?/) {
428 0           @parameters = ($value * 60);
429             } else {
430 0           push @errors, { line_number => $handle->input_line_number,
431             error => "unknown time unit $unit" };
432 0           next;
433             }
434             }
435              
436 0           push @{$commands->[-1]->{outputs}}, { command => $command,
  0            
437             parameters => \@parameters };
438             # wait_less_than command generates a failure if the
439             # shell command timed out, then a BAIL_OUT occurs; or
440             # a success if there was no timeout
441 0           $plan_size++;
442              
443             } else {
444              
445 0           push @{$commands->[-1]->{outputs}}, { command => $command,
  0            
446             parameters => \@parameters };
447              
448             }
449              
450             } else {
451              
452 0           push @errors, { line_number => $handle->input_line_number,
453             error => 'Syntax error' };
454              
455             }
456              
457             }
458              
459 0 0         if (@errors) {
460              
461             croak "Errors were found while processing a file:\n"
462 0           .join("\n", map { sprintf(q{l.%d: %s},
463             $_->{line_number},
464 0           $_->{error}) } @errors);
465              
466             }
467              
468 0           return ($shell, $commands, $plan_size);
469              
470             }
471              
472             1;
473              
474             =pod
475              
476             =head1 NAME
477              
478             Lembas -- Testing framework for command line applications inspired by Cram
479              
480             =head1 SYNOPSIS
481              
482             use Test::More;
483             use Lembas;
484            
485             open my $specs, '<', 'hg-for-dummies.lembas'
486             or BAILOUT("can't open Mercurial session test specs: $!");
487            
488             my $lembas = Lembas->new_from_test_spec(handle => $specs);
489             plan tests => $lembas->plan_size;
490             $lembas->run;
491              
492             =head1 DESCRIPTION
493              
494             =head2 WHAT IS LEMBAS
495              
496             It's better than cram :)
497              
498             In short, you write down shell sessions verbatim, allowing for
499             variance such as "this part here should match this regex" or "then
500             there's some output nobody really cares about" or even "this output
501             should be printed within N seconds". The markup is really very simple
502             so you can almost copy-paste real shell sessions and have it work.
503              
504             Then Lembas will spawn a shell process of your choice and pass it the
505             commands and test if the output matches what's expected, thereby
506             turning your shell session into a test suite!
507              
508             You can get the number of tests run for a suite through your Lembas
509             object, so you can plan your tests as usual (or just let
510             C handle it). A design point is that the number of
511             tests should be constant for a given script, no matter how many lines
512             fail to match.
513              
514             An automatic, free test tells you if you had any extra unmatched
515             output after your last matched line for each command. This is not
516             entirely reliable though and may produce false positives. Tuits and
517             patches welcome.
518              
519             =head2 I DON'T GET IT, GIVE ME A REAL USE-CASE
520              
521             This is a simple script suitable to test the very first chapter of
522             your Mercurial Course for Dummkopfs. Whitespace is important.
523              
524             #!/bin/bash
525            
526             # Do all the work in /tmp
527             $ cd /tmp
528             $ pwd
529             /tmp
530            
531             # Testing presence of Mercurial
532             $ hg --version
533             re: Mercurial Distributed SCM \(version [\d.]+\)
534             (see http://mercurial.selenic.com for more information)
535            
536             re: Copyright \(C\) [\d-]+ Matt Mackall and others
537             This is free software; see the source for copying conditions. There is NO
538             warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
539            
540             # Creating a repository
541             $ mkdir foo
542             $ cd foo
543             $ pwd
544             /tmp/foo
545             $ hg init
546             $ echo "this is a file" > content
547             $ cat content
548             this is a file
549             $ hg add content
550             $ hg st
551             A content
552             $ hg commit -m "created repo and added a file"
553            
554             # Checking that everything looks good
555             $ hg log
556             re: changeset: 0:[a-f0-9]{12}
557             tag: tip
558             user: Fabrice Gabolde
559             re: date: .*
560             summary: created repo and added a file
561            
562            
563             # Cleanup
564             $ rm -Rf /tmp/foo
565              
566             Lines with content in the first four characters are assumed to be
567             Lembas commands (or comments). The rest are shell commands (if they
568             start with "$ ") or shell output (otherwise). The syntax for "this
569             line of output should be matched as a regex" is problematic; Cram puts
570             " re" at the end of a line but it seems ugly to me.
571              
572             The shebang-looking line at the top is almost exactly that. It's a
573             user-friendly way of specifying "run this command with these
574             arguments".
575              
576             You'll notice that this works with or without the C extension
577             for Mercurial; Lembas removes ANSI terminal escape characters before
578             matching output.
579              
580             =head1 METHODS
581              
582             =head2 new
583              
584             my $lembas = Lembas->new(shell => [ '/bin/bash' ],
585             commands => [ { shell => 'whoami',
586             outputs => [ 'fgabolde' ] } ]);
587              
588             Creates a new Lembas object with the corresponding settings and
589             expected outputs.
590              
591             =head2 new_from_test_spec
592              
593             my $lembas = Lembas->new_from_test_spec(shell => [ '/bin/bash' ],
594             handle => $iohandle);
595              
596             Same as C, but parses a spec file first and uses it to set the
597             command/outputs list. The C parameter is optional if you have
598             a shebang in the spec file, but still useful in case you want to force
599             a different program.
600              
601             =head2 plan_size
602              
603             plan tests => $lembas->plan_size + $the_rest;
604              
605             If you dislike C, you can use this method to obtain the
606             number of tests that will be run by this Lembas instance.
607              
608             =head2 run
609              
610             $lembas->run;
611              
612             Uses L to run all the tests specified, reporting
613             success, failure and diagnostics in the usual manner.
614              
615             =head1 SEE ALSO
616              
617             L
618              
619             The original inspiration for Lembas: L
620              
621             If you're looking for something more complex and low-level, you
622             probably want L instead.
623              
624             =head1 AUTHOR
625              
626             Fabrice Gabolde
627              
628             =head1 COPYRIGHT AND LICENSE
629              
630             Copyright (C) 2013 Fabrice Gabolde
631              
632             This library is free software; you can redistribute it and/or modify
633             it under the same terms as Perl itself, either Perl version 5.10.0 or,
634             at your option, any later version of Perl 5 you may have available.
635              
636             =cut