File: | blib/lib/App/Test/Generator.pm |
Coverage: | 66.9% |
line | stmt | bran | cond | sub | time | code |
---|---|---|---|---|---|---|
1 | package App::Test::Generator; | |||||
2 | ||||||
3 | # TODO: Support routines that take more than one unnamed parameter | |||||
4 | ||||||
5 | 3 3 3 | 170354 2 39 | use strict; | |||
6 | 3 3 3 | 5 2 54 | use warnings; | |||
7 | 3 3 3 | 584 17727 5 | use autodie qw(:all); | |||
8 | ||||||
9 | 3 3 3 | 19361 277 7 | use utf8; | |||
10 | binmode STDOUT, ':utf8'; | |||||
11 | binmode STDERR, ':utf8'; | |||||
12 | ||||||
13 | 3 3 3 | 586 1476 6 | use open qw(:std :encoding(UTF-8)); | |||
14 | ||||||
15 | 3 3 3 | 15067 2 64 | use Carp qw(carp croak); | |||
16 | 3 3 3 | 646 58218 48 | use Config::Abstraction; | |||
17 | 3 3 3 | 500 5773 69 | use Data::Dumper; | |||
18 | 3 3 3 | 524 842 68 | use Data::Section::Simple; | |||
19 | 3 3 3 | 7 2 73 | use File::Basename qw(basename); | |||
20 | 3 3 3 | 6 1 21 | use File::Spec; | |||
21 | 3 3 3 | 692 22399 49 | use Template; | |||
22 | 3 3 3 | 380 2335 72 | use YAML::XS qw(LoadFile); | |||
23 | ||||||
24 | 3 3 3 | 7 3 1480 | use Exporter 'import'; | |||
25 | ||||||
26 | our @EXPORT_OK = qw(generate); | |||||
27 | ||||||
28 | our $VERSION = '0.06'; | |||||
29 | ||||||
30 - 402 | =head1 NAME App::Test::Generator - Generate fuzz and corpus-driven test harnesses =head1 SYNOPSIS From the command line: fuzz-harness-generator t/conf/add.conf > t/add_fuzz.t From Perl: use App::Test::Generator qw(generate); # Generate to STDOUT App::Test::Generator::generate("t/conf/add.conf"); # Generate directly to a file App::Test::Generator::generate('t/conf/add.conf', 't/add_fuzz.t'); =head1 OVERVIEW This module takes a formal input/output specification for a routine or method and automatically generates test cases. In effect, it allows you to easily add comprehensive black-box tests in addition to the more common white-box tests that are typically written for CPAN modules and other subroutines. The generated tests combine: =over 4 =item * Random fuzzing based on input types =item * Deterministic edge cases for min/max constraints =item * Static corpus tests defined in Perl or YAML =back This approach strengthens your test suite by probing both expected and unexpected inputs, helping you to catch boundary errors, invalid data handling, and regressions without manually writing every case. =head1 DESCRIPTION This module implements the logic behind L<fuzz-harness-generator>. It parses configuration files (fuzz and/or corpus YAML), and produces a ready-to-run F<.t> test script using L<Test::Most>. It reads configuration files (Perl C<.conf> with C<our> variables, and optional YAML corpus files), and generates a L<Test::Most>-based fuzzing harness combining: =over 4 =item * Randomized fuzzing of inputs (with edge cases) =item * Optional static corpus tests from Perl C<%cases> or YAML file (C<yaml_cases> key) =item * Functional or OO mode (via C<$new>) =item * Reproducible runs via C<$seed> and configurable iterations via C<$iterations> =back =head2 EDGE CASE GENERATION In addition to purely random fuzz cases, the harness generates deterministic edge cases for parameters that declare C<min>, C<max> or C<len> in their schema definitions. For each constraint, three edge cases are added: =over 4 =item * Just inside the allowable range This case should succeed, since it lies strictly within the bounds. =item * Exactly on the boundary This case should succeed, since it meets the constraint exactly. =item * Just outside the boundary This case is annotated with C<_STATUS = 'DIES'> in the corpus and should cause the harness to fail validation or croak. =back Supported constraint types: =over 4 =item * C<number>, C<integer> Uses numeric values one below, equal to, and one above the boundary. =item * C<string> Uses strings of lengths one below, equal to, and one above the boundary. =item * C<arrayref> Uses references to arrays of with the number of elements one below, equal to, and one above the boundary. =item * C<hashref> Uses hashes with key counts one below, equal to, and one above the boundary (C<min> = minimum number of keys, C<max> = maximum number of keys). =item * C<memberof> - arrayref of allowed values for a parameter: our %input = ( status => { type => 'string', memberof => [ 'ok', 'error', 'pending' ] }, level => { type => 'integer', memberof => [ 1, 2, 3 ] }, ); The generator will automatically create test cases for each allowed value (inside the member list), and at least one value outside the list (which should die, C<_STATUS = 'DIES'>). This works for strings, integers, and numbers. =item * C<boolean> - automatic boundary tests for boolean fields our %input = ( flag => { type => 'boolean' }, ); The generator will automatically create test cases for 0 and 1; true and false; off and on, and values that should trigger C<_STATUS = 'DIES'>. =back These edge cases are inserted automatically, in addition to the random fuzzing inputs, so each run will reliably probe boundary conditions without relying solely on randomness. =head1 CONFIGURATION The configuration file is either a file that can be read by L<Config::Abstraction> or a B<trusted input> Perl file that should set variables with C<our>. The documentation here covers the old trusted input style input, but that will go away so you are recommended to use Config::Abstraction files. Example: the generator expects your config to use C<our %input>, C<our $function>, etc. Recognized items: =over 4 =item * C<%input> - input params with keys => type/optional specs: When using named parameters our %input = ( name => { type => 'string', optional => 0 }, age => { type => 'integer', optional => 1 }, ); Supported basic types used by the fuzzer: C<string>, C<integer>, C<number>, C<boolean>, C<arrayref>, C<hashref>. (You can add more types; they will default to C<undef> unless extended.) For routines with one unnamed parameter our %input = ( type => 'string' ); Currently, routines with more than one unnamed parameter are not supported. =item * C<%output> - output param types for Return::Set checking: our %output = ( type => 'string' ); If the output hash contains the key _STATUS, and if that key is set to DIES, the routine should die with the given arguments; otherwise, it should live. If it's set to WARNS, the routine should warn with the given arguments =item * C<$module> - module name (optional). If omitted, the generator will guess from the config filename: C<My-Widget.conf> -> C<My::Widget>. =item * C<$function> - function/method to test (defaults to C<run>). =item * C<$new> - optional hashref of args to pass to the module's constructor (object mode): our $new = { api_key => 'ABC123', verbose => 1 }; To ensure new is called with no arguments, you still need to define new, thus: our $new = ''; =item * C<%cases> - optional Perl static corpus, when the output is a simple string (expected => [ args... ]): Maps the expected output string to the input and _STATUS our %cases = ( 'ok' => { input => 'ping', status => 'OK', 'error' => input => '', status => 'DIES' ); =item * C<$yaml_cases> - optional path to a YAML file with the same shape as C<%cases>. =item * C<$seed> - optional integer. When provided, the generated C<t/fuzz.t> will call C<srand($seed)> so fuzz runs are reproducible. =item * C<$iterations> - optional integer controlling how many fuzz iterations to perform (default 50). =item * C<%edge_cases> - optional hash mapping of extra values to inject: # Two named parameters our %edge_cases = ( name => [ '', 'a' x 1024, \"\x{263A}" ], age => [ -1, 0, 99999999 ], ); # Takes a string input our %edge_cases ( 'foo', 'bar' ); (Values can be strings or numbers; strings will be properly quoted.) Note that this only works with routines that take named parameters. =item * C<%type_edge_cases> - optional hash mapping types to arrayrefs of extra values to try for any field of that type: our %type_edge_cases = ( string => [ '', ' ', "\t", "\n", "\0", 'long' x 1024, chr(0x1F600) ], number => [ 0, 1.0, -1.0, 1e308, -1e308, 1e-308, -1e-308, 'NaN', 'Infinity' ], integer => [ 0, 1, -1, 2**31-1, -(2**31), 2**63-1, -(2**63) ], ); =item * C<%config> - optional hash of configuration. The current supported variables are =over 4 =item * C<test_nuls>, inject NUL bytes into strings (default: 1) =item * C<test_undef>, test with undefined value (default: 1) =item * C<dedup>, fuzzing can create duplicate tests, go some way to remove duplicates (default: 1) =back =back =head1 EXAMPLES =head2 Math::Simple::add() Functional fuzz + Perl corpus + seed: our $module = 'Math::Simple'; our $function = 'add'; our %input = ( a => { type => 'integer' }, b => { type => 'integer' } ); our %output = ( type => 'integer' ); our %cases = ( '3' => [1, 2], '0' => [0, 0], '-1' => [-2, 1], '_STATUS:DIES' => [ 'a', 'b' ], # non-numeric args should die '_STATUS:WARNS' => [ undef, undef ], # undef args should warn ); our $seed = 12345; our $iterations = 100; =head2 Adding YAML file to generate tests OO fuzz + YAML corpus + edge cases: our %input = ( query => { type => 'string' } ); our %output = ( type => 'string' ); our $function = 'search'; our $new = { api_key => 'ABC123' }; our $yaml_cases = 't/corpus.yml'; our %edge_cases = ( query => [ '', ' ', '<script>' ] ); our %type_edge_cases = ( string => [ \"\\0", "\x{FFFD}" ] ); our $seed = 999; =head3 YAML Corpus Example (t/corpus.yml) A YAML mapping of expected -> args array: "success": - "Alice" - 30 "failure": - "Bob" =head2 Example with arrayref + hashref our %input = ( tags => { type => 'arrayref', optional => 1 }, config => { type => 'hashref' }, ); our %output = ( type => 'hashref' ); =head2 Example with memberof our %input = ( status => { type => 'string', memberof => [ 'ok', 'error', 'pending' ] }, ); our %output = ( type => 'string' ); our %config = ( test_nuls => 0, test_undef => 1 ); This will generate fuzz cases for 'ok', 'error', 'pending', and one invalid string that should die. =head2 New format input Testing L<HTML::Genealogy::Map>: --- module: HTML::Genealogy::Map function: onload_render input: gedcom: type: object can: individuals geocoder: type: object can: geocode debug: type: boolean optional: true google_key: type: string optional: true min: 39 max: 39 matches: "^AIza[0-9A-Za-z_-]{35}$" config: test_undef: 0 =head1 OUTPUT By default, writes C<t/fuzz.t>. The generated test: =over 4 =item * Seeds RNG (if configured) for reproducible fuzz runs =item * Uses edge cases (per-field and per-type) with configurable probability =item * Runs C<$iterations> fuzz cases plus appended edge-case runs =item * Validates inputs with Params::Get / Params::Validate::Strict =item * Validates outputs with L<Return::Set> =item * Runs static C<is(... )> corpus tests from Perl and/or YAML corpus =back =head1 NOTES =over 4 =item * The conf file must use C<our> declarations so variables are visible to the generator via C<require>. =back =cut | |||||
403 | ||||||
404 | sub generate | |||||
405 | { | |||||
406 | 3 | 75076 | my ($conf_file, $outfile) = @_; | |||
407 | ||||||
408 | # --- Globals exported by the user's conf (all optional except function maybe) --- | |||||
409 | # Ensure data don't persist across calls, which would allow | |||||
410 | 3 | 5 | local our (%input, %output, %config, $module, $function, $new, %cases, $yaml_cases); | |||
411 | 3 | 1 | local our ($seed, $iterations); | |||
412 | 3 | 3 | local our (%edge_cases, @edge_case_array, %type_edge_cases); | |||
413 | ||||||
414 | 3 | 3 | @edge_case_array = (); | |||
415 | ||||||
416 | 3 | 3 | if(defined($conf_file)) { | |||
417 | # --- Load configuration safely (require so config can use 'our' variables) --- | |||||
418 | # FIXME: would be better to use Config::Abstraction, since requiring the user's config could execute arbitrary code | |||||
419 | # my $abs = $conf_file; | |||||
420 | # $abs = "./$abs" unless $abs =~ m{^/}; | |||||
421 | # require $abs; | |||||
422 | ||||||
423 | 3 | 3 | my $config; | |||
424 | 3 | 10 | if($config = Config::Abstraction->new(config_dirs => ['.', ''], config_file => $conf_file)) { | |||
425 | 3 | 17387 | $config = $config->all(); | |||
426 | 3 | 21 | if(defined($config->{'$module'}) || defined($config->{'our $module'}) || !defined($config->{'module'})) { | |||
427 | # Legacy file format. This will go away. | |||||
428 | # TODO: remove this code | |||||
429 | 2 | 13 | $config = _load_conf(File::Spec->rel2abs($conf_file)); | |||
430 | } | |||||
431 | } | |||||
432 | ||||||
433 | 3 | 10 | if($config) { | |||
434 | 3 2 | 31 3 | %input = %{$config->{input}} if(exists($config->{input})); | |||
435 | 3 | 5 | if(exists($config->{output})) { | |||
436 | 3 | 3 | croak("$conf_file: output should be a hash") unless(ref($config->{output}) eq 'HASH'); | |||
437 | 3 3 | 1 5 | %output = %{$config->{output}} | |||
438 | } | |||||
439 | 3 0 | 4 0 | %config = %{$config->{config}} if(exists($config->{config})); | |||
440 | 3 2 | 3 3 | %cases = %{$config->{cases}} if(exists($config->{cases})); | |||
441 | 3 0 | 3 0 | %edge_cases = %{$config->{edge_cases}} if(exists($config->{edge_cases})); | |||
442 | 3 0 | 2 0 | %type_edge_cases = %{$config->{type_edge_cases}} if(exists($config->{type_edge_cases})); | |||
443 | ||||||
444 | 3 | 4 | $module = $config->{module} if(exists($config->{module})); | |||
445 | 3 | 3 | $function = $config->{function} if(exists($config->{function})); | |||
446 | 3 | 2 | $new = $config->{new} if(exists($config->{new})); | |||
447 | 3 | 3 | $yaml_cases = $config->{yaml_cases} if(exists($config->{yaml_cases})); | |||
448 | 3 | 3 | $seed = $config->{seed} if(exists($config->{seed})); | |||
449 | 3 | 3 | $iterations = $config->{iterations} if(exists($config->{iterations})); | |||
450 | ||||||
451 | 3 2 | 3 3 | @edge_case_array = @{$config->{edge_case_array}} if(exists($config->{edge_case_array})); | |||
452 | } | |||||
453 | 3 | 3 | _validate_config($config); | |||
454 | } else { | |||||
455 | 0 | 0 | croak 'Usage: generate(conf_file [, outfile])'; | |||
456 | } | |||||
457 | ||||||
458 | # --- Globals exported by the user's conf (all optional except function maybe) --- | |||||
459 | # our (%input, %output, %config, $module, $function, $new, %cases, $yaml_cases); | |||||
460 | # our ($seed, $iterations); | |||||
461 | # our (%edge_cases, @edge_case_array, %type_edge_cases); | |||||
462 | ||||||
463 | # sensible defaults | |||||
464 | 2 | 2 | $function ||= 'run'; | |||
465 | 2 | 2 | $iterations ||= 50; # default fuzz runs if not specified | |||
466 | 2 | 3 | $seed = undef if defined $seed && $seed eq ''; # treat empty as undef | |||
467 | 2 | 4 | $config{'test_nuls'} //= 1; # By default, test for embedded NULs | |||
468 | 2 | 4 | $config{'test_undef'} //= 1; # By default, see what happens when passed undef | |||
469 | 2 | 2 | $config{'dedup'} //= 1; # fuzzing can easily generate repeats, default is to remove duplicates | |||
470 | ||||||
471 | # Guess module name from config file if not set | |||||
472 | 2 | 2 | if (!$module) { | |||
473 | 0 | 0 | (my $guess = basename($conf_file)) =~ s/\.(conf|pl|pm|yml|yaml)$//; | |||
474 | 0 | 0 | $guess =~ s/-/::/g; | |||
475 | 0 | 0 | $module = $guess || 'Unknown::Module'; | |||
476 | } | |||||
477 | ||||||
478 | # FIXME: Always fails with "Can't locate" - either method | |||||
479 | # eval "require \"$module\"; \"$module\"->import()"; | |||||
480 | # eval { require $module }; | |||||
481 | # if($@) { | |||||
482 | # carp(__PACKAGE__, ' (', __LINE__, "): $@"); | |||||
483 | # } | |||||
484 | ||||||
485 | # --- YAML corpus support (yaml_cases is filename string) --- | |||||
486 | 2 | 9 | my %yaml_corpus_data; | |||
487 | 2 | 2 | if (defined $yaml_cases) { | |||
488 | 2 | 8 | croak("$yaml_cases file not found") if(!-f $yaml_cases); | |||
489 | ||||||
490 | 2 | 4 | my $yaml_data = LoadFile(Encode::decode('utf8', $yaml_cases)); | |||
491 | 2 | 114 | if ($yaml_data && ref($yaml_data) eq 'HASH') { | |||
492 | # Validate that the corpus inputs are arrayrefs | |||||
493 | # e.g: "FooBar": ["foo_bar"] | |||||
494 | 2 | 1 | my $valid_input = 1; | |||
495 | 2 2 | 1 2 | for my $expected (keys %{$yaml_data}) { | |||
496 | 2 | 2 | my $outputs = $yaml_data->{$expected}; | |||
497 | 2 | 4 | unless($outputs && (ref $outputs eq 'ARRAY')) { | |||
498 | 0 | 0 | carp("$yaml_cases: $expected does not point to an array ref, ignoring"); | |||
499 | 0 | 0 | $valid_input = 0; | |||
500 | } | |||||
501 | } | |||||
502 | ||||||
503 | 2 | 4 | %yaml_corpus_data = %$yaml_data if($valid_input); | |||
504 | } | |||||
505 | } | |||||
506 | ||||||
507 | # Merge Perl %cases and YAML corpus safely | |||||
508 | # my %all_cases = (%cases, %yaml_corpus_data); | |||||
509 | 2 | 2 | my %all_cases = (%yaml_corpus_data, %cases); | |||
510 | 2 | 2 | for my $k (keys %yaml_corpus_data) { | |||
511 | 2 | 4 | if (exists $cases{$k} && ref($cases{$k}) eq 'ARRAY' && ref($yaml_corpus_data{$k}) eq 'ARRAY') { | |||
512 | 0 0 0 | 0 0 0 | $all_cases{$k} = [ @{$yaml_corpus_data{$k}}, @{$cases{$k}} ]; | |||
513 | } | |||||
514 | } | |||||
515 | ||||||
516 | # --- Helpers for rendering data structures into Perl code for the generated test --- | |||||
517 | ||||||
518 | sub _load_conf { | |||||
519 | 2 | 1 | my $file = $_[0]; | |||
520 | ||||||
521 | 2 | 2 | my $pkg = 'ConfigLoader'; | |||
522 | ||||||
523 | # eval in a separate package | |||||
524 | { | |||||
525 | 2 | 2 | package ConfigLoader; | |||
526 | 3 3 3 | 8 13 126 | no strict 'refs'; | |||
527 | 2 | 274 | do $file or die "Error loading $file: ", ($@ || $!); | |||
528 | } | |||||
529 | ||||||
530 | # Now pull variables from ConfigLoader | |||||
531 | 2 | 5 | my @vars = qw( | |||
532 | module new edge_cases function input output cases yaml_cases | |||||
533 | seed iterations edge_case_array type_edge_cases config | |||||
534 | ); | |||||
535 | ||||||
536 | 2 | 2 | my %conf; | |||
537 | 3 3 3 | 6 1 4117 | no strict 'refs'; # allow symbolic references here | |||
538 | 2 | 2 | for my $v (@vars) { | |||
539 | 26 | 19 | if(my $full = "${pkg}::$v") { | |||
540 | 26 26 | 13 25 | if (defined ${$full}) { # scalar | |||
541 | 8 8 | 6 11 | $conf{$v} = ${$full}; | |||
542 | 18 | 18 | } elsif (@{$full}) { # array | |||
543 | 2 2 | 1 3 | $conf{$v} = [ @{$full} ]; | |||
544 | 16 | 19 | } elsif (%{$full}) { # hash | |||
545 | 6 6 | 1 10 | $conf{$v} = { %{$full} }; | |||
546 | } | |||||
547 | } | |||||
548 | } | |||||
549 | ||||||
550 | 2 | 4 | return \%conf; | |||
551 | } | |||||
552 | ||||||
553 | # Input validation for configuration | |||||
554 | sub _validate_config { | |||||
555 | 3 | 2 | my $config = $_[0]; | |||
556 | ||||||
557 | 3 | 2 | for my $key('module', 'function', 'input') { | |||
558 | 9 | 16 | croak "Missing required '$key' specification" unless $config->{$key}; | |||
559 | } | |||||
560 | 2 | 2 | croak 'Invalid input specification' unless(ref $config->{input} eq 'HASH'); | |||
561 | ||||||
562 | # Validate types, constraints, etc. | |||||
563 | 2 2 | 2 2 | for my $param (keys %{$config->{input}}) { | |||
564 | 2 | 2 | my $spec = $config->{input}{$param}; | |||
565 | 2 | 1 | if(ref($spec)) { | |||
566 | 1 | 2 | croak "Invalid type for parameter '$param'" unless _valid_type($spec->{type}); | |||
567 | } else { | |||||
568 | 1 | 1 | croak "Invalid type $spec for parameter '$param'" unless _valid_type($spec); | |||
569 | } | |||||
570 | } | |||||
571 | } | |||||
572 | ||||||
573 | sub _valid_type | |||||
574 | { | |||||
575 | 2 | 2 | my $type = $_[0]; | |||
576 | ||||||
577 | 2 | 7 | return(($type eq 'string') || | |||
578 | ($type eq 'boolean') || | |||||
579 | ($type eq 'integer') || | |||||
580 | ($type eq 'number') || | |||||
581 | ($type eq 'float') || | |||||
582 | ($type eq 'object')); | |||||
583 | } | |||||
584 | ||||||
585 | sub perl_sq { | |||||
586 | 21 | 11 | my $s = $_[0]; | |||
587 | 21 21 21 21 21 | 12 10 12 11 10 | $s =~ s/\\/\\\\/g; $s =~ s/'/\\'/g; $s =~ s/\n/\\n/g; $s =~ s/\r/\\r/g; $s =~ s/\t/\\t/g; | |||
588 | 21 | 28 | return $s; | |||
589 | } | |||||
590 | ||||||
591 | sub perl_quote { | |||||
592 | 22 | 10 | my $v = $_[0]; | |||
593 | 22 | 15 | return 'undef' unless defined $v; | |||
594 | 21 | 15 | if(ref($v) eq 'ARRAY') { | |||
595 | 0 0 0 | 0 0 0 | my @quoted_v = map { perl_quote($_) } @{$v}; | |||
596 | 0 | 0 | return '[ ' . join(', ', @quoted_v) . ' ]'; | |||
597 | } | |||||
598 | 21 | 14 | return Dumper($v) if(ref($v) && (ref($v) ne 'Regexp')); # Generic fallback | |||
599 | 21 | 12 | $v =~ s/\\/\\\\/g; | |||
600 | # return $v =~ /^-?\d+(\.\d+)?$/ ? $v : "'" . ( $v =~ s/'/\\'/gr ) . "'"; | |||||
601 | 21 | 24 | return $v =~ /^-?\d+(\.\d+)?$/ ? $v : "'" . perl_sq($v) . "'"; | |||
602 | } | |||||
603 | ||||||
604 | sub render_hash { | |||||
605 | 2 | 1 | my $href = $_[0]; | |||
606 | 2 | 6 | return '' unless $href && ref($href) eq 'HASH'; | |||
607 | 2 | 1 | my @lines; | |||
608 | 2 | 4 | for my $k (sort keys %$href) { | |||
609 | 2 | 3 | my $def = $href->{$k} || {}; | |||
610 | 2 | 2 | next unless ref $def eq 'HASH'; | |||
611 | 1 | 6 | my @pairs; | |||
612 | 1 | 2 | for my $subk (sort keys %$def) { | |||
613 | 1 | 1 | next unless defined $def->{$subk}; | |||
614 | 1 | 1 | if(ref($def->{$subk})) { | |||
615 | 0 | 0 | unless((ref($def->{$subk}) eq 'ARRAY') || (ref($def->{$subk}) eq 'Regexp')) { | |||
616 | 0 | 0 | croak(__PACKAGE__, ": conf_file, $subk is a nested element, not yet supported (", ref($def->{$subk}), ')'); | |||
617 | } | |||||
618 | } | |||||
619 | 1 | 2 | push @pairs, "$subk => " . perl_quote($def->{$subk}); | |||
620 | } | |||||
621 | 1 | 1 | push @lines, ' ' . perl_quote($k) . " => { " . join(", ", @pairs) . " }"; | |||
622 | } | |||||
623 | 2 | 3 | return join(",\n", @lines); | |||
624 | } | |||||
625 | ||||||
626 | sub render_args_hash { | |||||
627 | 2 | 1 | my $href = $_[0]; | |||
628 | 2 | 3 | return '' unless $href && ref($href) eq 'HASH'; | |||
629 | 2 2 | 2 2 | my @pairs = map { perl_quote($_) . ' => ' . perl_quote($href->{$_}) } sort keys %$href; | |||
630 | 2 | 2 | return join(', ', @pairs); | |||
631 | } | |||||
632 | ||||||
633 | sub render_arrayref_map { | |||||
634 | 4 | 3 | my $href = $_[0]; | |||
635 | 4 | 6 | return '()' unless $href && ref($href) eq 'HASH'; | |||
636 | 4 | 2 | my @entries; | |||
637 | 4 | 4 | for my $k (sort keys %$href) { | |||
638 | 0 | 0 | my $aref = $href->{$k}; | |||
639 | 0 | 0 | next unless ref $aref eq 'ARRAY'; | |||
640 | 0 0 | 0 0 | my $vals = join(', ', map { perl_quote($_) } @$aref); | |||
641 | 0 | 0 | push @entries, ' ' . perl_quote($k) . " => [ $vals ]"; | |||
642 | } | |||||
643 | 4 | 6 | return join(",\n", @entries); | |||
644 | } | |||||
645 | ||||||
646 | # Robustly quote a string (GitHub#1) | |||||
647 | sub q_wrap { | |||||
648 | 10 | 4 | my $s = $_[0]; | |||
649 | 10 | 15 | for my $p ( ['{','}'], ['(',')'], ['[',']'], ['<','>'] ) { | |||
650 | 10 | 9 | my ($l,$r) = @$p; | |||
651 | 10 | 39 | return "q$l$s$r" unless $s =~ /\Q$l\E|\Q$r\E/; | |||
652 | } | |||||
653 | 0 | for my $d ('~', '!', '%', '^', '=', '+', ':', ',', ';', '|', '/', '#') { | ||||
654 | 0 | return "q$d$s$d" unless index($s, $d) >= 0; | ||||
655 | } | |||||
656 | 0 | (my $esc = $s) =~ s/'/\\'/g; | ||||
657 | 0 | return "'$esc'"; | ||||
658 | } | |||||
659 | ||||||
660 | # render edge case maps for inclusion in the .t | |||||
661 | 2 | 4 | my $edge_cases_code = render_arrayref_map(\%edge_cases); | |||
662 | 2 | 1 | my $type_edge_cases_code = render_arrayref_map(\%type_edge_cases); | |||
663 | ||||||
664 | 2 | 1 | my $edge_case_array_code = ''; | |||
665 | 2 | 2 | if(scalar(@edge_case_array)) { | |||
666 | 2 6 | 2 5 | $edge_case_array_code = join(', ', map { q_wrap($_) } @edge_case_array); | |||
667 | } | |||||
668 | ||||||
669 | # Render configuration | |||||
670 | 2 | 2 | my $config_code = ''; | |||
671 | 2 | 4 | foreach my $key (sort keys %config) { | |||
672 | 6 | 5 | $config_code .= "'$key' => '$config{$key}',\n"; | |||
673 | } | |||||
674 | ||||||
675 | # Render input/output | |||||
676 | 2 | 2 | my $input_code = ''; | |||
677 | 2 | 5 | if(((scalar keys %input) == 1) && exists($input{'type'}) && !ref($input{'type'})) { | |||
678 | # our %input = ( type => 'string' ); | |||||
679 | 0 | 0 | foreach my $key (sort keys %input) { | |||
680 | 0 | 0 | $input_code .= "'$key' => '$input{$key}',\n"; | |||
681 | } | |||||
682 | } else { | |||||
683 | # our %input = ( str => { type => 'string' } ); | |||||
684 | 2 | 1 | $input_code = render_hash(\%input); | |||
685 | } | |||||
686 | 2 | 2 | my $output_code = render_args_hash(\%output); | |||
687 | 2 | 5 | my $new_code = ($new && (ref $new eq 'HASH')) ? render_args_hash($new) : ''; | |||
688 | ||||||
689 | # Setup / call code (always load module) | |||||
690 | 2 | 2 | my $setup_code = "BEGIN { use_ok('$module') }"; | |||
691 | 2 | 1 | my $call_code; | |||
692 | 2 | 2 | if(defined($new)) { | |||
693 | # keep use_ok regardless (user found earlier issue) | |||||
694 | 0 | 0 | if($new_code eq '') { | |||
695 | 0 | 0 | $setup_code .= "\nmy \$obj = new_ok('$module');"; | |||
696 | } else { | |||||
697 | 0 | 0 | $setup_code .= "\nmy \$obj = new_ok('$module' => [ { $new_code } ] );"; | |||
698 | } | |||||
699 | 0 | 0 | $call_code = "\$result = \$obj->$function(\$input);"; | |||
700 | } else { | |||||
701 | 2 | 2 | $call_code = "\$result = $module\->$function(\$input);"; | |||
702 | } | |||||
703 | ||||||
704 | # Build static corpus code | |||||
705 | 2 | 1 | my $corpus_code = ''; | |||
706 | 2 | 1 | if (%all_cases) { | |||
707 | 2 | 2 | $corpus_code = "\n# --- Static Corpus Tests ---\n"; | |||
708 | 2 | 4 | for my $expected (sort keys %all_cases) { | |||
709 | 4 | 4 | my $inputs = $all_cases{$expected}; | |||
710 | 4 | 5 | next unless($inputs && (ref $inputs eq 'ARRAY')); | |||
711 | ||||||
712 | 4 | 6 | my $expected_str = perl_quote($expected); | |||
713 | 4 | 5 | my $status = ((ref($inputs) eq 'HASH') && $inputs->{'_STATUS'}) // 'OK'; | |||
714 | 4 | 5 | if($expected_str eq "'_STATUS:DIES'") { | |||
715 | 0 | 0 | $status = 'DIES'; | |||
716 | } elsif($expected_str eq "'_STATUS:WARNS'") { | |||||
717 | 0 | 0 | $status = 'WARNS'; | |||
718 | } | |||||
719 | ||||||
720 | 4 | 1 | if(ref($inputs) eq 'HASH') { | |||
721 | 0 | 0 | $inputs = $inputs->{'input'}; | |||
722 | } | |||||
723 | 4 8 | 6 5 | my $input_str = join(', ', map { perl_quote($_) } @$inputs); | |||
724 | 4 | 4 | if ($new) { | |||
725 | 0 | 0 | if($status eq 'DIES') { | |||
726 | $corpus_code .= "dies_ok { \$obj->$function($input_str) } " . | |||||
727 | 0 0 | 0 0 | "'$function(" . join(", ", map { $_ // '' } @$inputs ) . ") dies';\n"; | |||
728 | } elsif($status eq 'WARNS') { | |||||
729 | $corpus_code .= "warnings_exist { \$obj->$function($input_str) } qr/./, " . | |||||
730 | 0 0 | 0 0 | "'$function(" . join(", ", map { $_ // '' } @$inputs ) . ") warns';\n"; | |||
731 | } else { | |||||
732 | my $desc = sprintf("$function(%s) returns %s", | |||||
733 | 0 0 | 0 0 | perl_quote(join(', ', map { $_ // '' } @$inputs )), | |||
734 | $expected_str | |||||
735 | ); | |||||
736 | 0 | 0 | $corpus_code .= "is(\$obj->$function($input_str), $expected_str, " . q_wrap($desc) . ");\n"; | |||
737 | } | |||||
738 | } else { | |||||
739 | 4 | 4 | if($status eq 'DIES') { | |||
740 | $corpus_code .= "dies_ok { $module\::$function($input_str) } " . | |||||
741 | 0 0 | 0 0 | "'$function(" . join(", ", map { $_ // '' } @$inputs ) . ") dies';\n"; | |||
742 | } elsif($status eq 'WARNS') { | |||||
743 | $corpus_code .= "warnings_exist { $module\::$function($input_str) } qr/./, " . | |||||
744 | 0 0 | 0 0 | "'$function(" . join(", ", map { $_ // '' } @$inputs ) . ") warns';\n"; | |||
745 | } else { | |||||
746 | my $desc = sprintf("$function(%s) returns %s", | |||||
747 | 4 8 | 3 10 | perl_quote(join(', ', map { $_ // '' } @$inputs )), | |||
748 | $expected_str | |||||
749 | ); | |||||
750 | 4 | 6 | $corpus_code .= "is($module\::$function($input_str), $expected_str, " . q_wrap($desc) . ");\n"; | |||
751 | } | |||||
752 | } | |||||
753 | } | |||||
754 | } | |||||
755 | ||||||
756 | # Prepare seed/iterations code fragment for the generated test | |||||
757 | 2 | 3 | my $seed_code = ''; | |||
758 | 2 | 2 | if (defined $seed) { | |||
759 | # ensure integer-ish | |||||
760 | 0 | 0 | $seed = int($seed); | |||
761 | 0 | 0 | $seed_code = "srand($seed);\n"; | |||
762 | } | |||||
763 | # Generate the test content | |||||
764 | 2 | 6 | my $tt = Template->new({ ENCODING => 'utf8', TRIM => 1 }); | |||
765 | ||||||
766 | # Read template from DATA handle | |||||
767 | 2 | 9481 | my $template = Data::Section::Simple::get_data_section('test.tt'); | |||
768 | ||||||
769 | 2 | 1427 | my $vars = { | |||
770 | setup_code => $setup_code, | |||||
771 | edge_cases_code => $edge_cases_code, | |||||
772 | edge_case_array_code => $edge_case_array_code, | |||||
773 | type_edge_cases_code => $type_edge_cases_code, | |||||
774 | config_code => $config_code, | |||||
775 | seed_code => $seed_code, | |||||
776 | input_code => $input_code, | |||||
777 | output_code => $output_code, | |||||
778 | corpus_code => $corpus_code, | |||||
779 | call_code => $call_code, | |||||
780 | function => $function, | |||||
781 | iterations_code => int($iterations), | |||||
782 | module => $module | |||||
783 | }; | |||||
784 | ||||||
785 | 2 | 2 | my $test; | |||
786 | 2 | 2 | $tt->process(\$template, $vars, \$test) or die $tt->error(); | |||
787 | ||||||
788 | 2 | 23367 | if ($outfile) { | |||
789 | 2 | 4 | open my $fh, '>:encoding(UTF-8)', $outfile or die "Cannot open $outfile: $!"; | |||
790 | 2 | 1110 | print $fh $test; | |||
791 | 2 | 3 | close $fh; | |||
792 | 2 | 442 | print "Generated $outfile for $module\::$function with fuzzing + corpus support\n"; | |||
793 | } else { | |||||
794 | 0 | 0 | print $test; | |||
795 | } | |||||
796 | } | |||||
797 | ||||||
798 | 1; | |||||
799 | ||||||
800 - 818 | =head1 SEE ALSO =over 4 =item * Test coverage report: L<https://nigelhorne.github.io/App-Test-Generator/coverage/> =item * L<Test::Most>, L<Params::Get>, L<Params::Validate::Strict>, L<Return::Set>, L<YAML::XS> =back =head1 AUTHOR Nigel Horne, C<< <njh at nigelhorne.com> >> Portions of this module's design and documentation were created with the assistance of L<ChatGPT|https://openai.com/> (GPT-5), with final curation and authorship by Nigel Horne. =cut | |||||
819 |