File Coverage

blib/lib/Lembas.pm
Criterion Covered Total %
statement 176 228 77.1
branch 45 80 56.2
condition 6 15 40.0
subroutine 16 17 94.1
pod 2 3 66.6
total 245 343 71.4


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