use 5.040; use re '/aasx'; use warnings FATAL => 'all'; use Test::More; use Test::Fatal qw{exception}; use FindBin::libs; use Text::Xslate (); require_ok('Trog::Component') or BAIL_OUT("Can't find SUT"); # Fixtures, registered in %INC so that _load()'s require() is satisfied without # anything landing on disk in the real lib tree. BEGIN { $INC{'Trog/Component/TestFixture.pm'} = 1; $INC{'Trog/Component/TestSilent.pm'} = 1; $INC{'Trog/Component/TestTriplet.pm'} = 1; $INC{'Trog/Component/TestBoom.pm'} = 1; $INC{'Trog/Component/TestRecurse.pm'} = 1; $INC{'Trog/Component/TestNoRender.pm'} = 1; } our @ARGS_SEEN; { package Trog::Component::TestFixture; sub render { @ARGS_SEEN = @_; return 'fixture' } package Trog::Component::TestSilent; sub render { return undef } package Trog::Component::TestTriplet; sub render { return [ 500, [], ['boom'] ] } package Trog::Component::TestBoom; sub render { die "kaboom\n" } package Trog::Component::TestRecurse; sub render { return Trog::Component::component('TestRecurse') } package Trog::Component::TestNoRender; sub nope { return 1 } } subtest 'name validation' => sub { # Every one of these reaches require() as a path if it gets past the check. my %bad = ( 'traversal' => '../../etc/passwd', 'separator' => 'Foo/Bar', 'package sep' => 'Foo::Bar', 'leading digit' => '9Lives', 'empty' => '', 'space' => 'Emoji Picker', 'dot' => 'Foo.pm', 'nul' => "Foo\0Bar", ); foreach my $case ( sort keys %bad ) { like( exception { Trog::Component::component( $bad{$case} ) }, qr/component[ ]name/i, "$case rejected before anything is loaded" ); } like( exception { Trog::Component::component(undef) }, qr/component[ ]name/i, "undef rejected" ); # Nothing above should have made it as far as %INC. is( scalar( grep { m{^Trog/Component/} && !m{^Trog/Component/Test} } keys %INC ), 0, "no stray component was loaded" ); }; subtest 'loading' => sub { like( exception { Trog::Component::component('NoSuchComponentHere') }, qr/NoSuchComponentHere/, "unknown component dies naming the component" ); like( exception { Trog::Component::component('TestNoRender') }, qr/no[ ]render/i, "a module without render() is not a component" ); }; subtest 'render' => sub { local @ARGS_SEEN = (); my $out = Trog::Component::component('TestFixture'); is( "$out", 'fixture', "component output comes back" ); isa_ok( $out, 'Text::Xslate::Type::Raw', "output is marked raw so callers need no mark_raw" ); is_deeply( \@ARGS_SEEN, [], "no args passed means render() sees none" ); Trog::Component::component( 'TestFixture', { album => 'holiday', cols => 3 } ); is_deeply( {@ARGS_SEEN}, { album => 'holiday', cols => 3 }, "args hash is flattened into render()" ); Trog::Component::component( 'TestFixture', 'not a hashref' ); is_deeply( \@ARGS_SEEN, [], "a non-hashref second arg is ignored rather than fatal" ); }; subtest 'failure modes' => sub { # Trog::Renderer::render() answers failure with a PSGI triplet, which is # meaningless halfway through a template. like( exception { Trog::Component::component('TestTriplet') }, qr/TestTriplet\N*ARRAY/, "a PSGI triplet is caught rather than leaking into the page" ); like( exception { Trog::Component::component('TestSilent') }, qr/rendered[ ]nothing/, "an undef render is a failure, not an empty component" ); like( exception { Trog::Component::component('TestBoom') }, qr/TestBoom.*kaboom/s, "a component's own die is re-thrown naming the component" ); }; subtest 'recursion guard' => sub { no warnings qw{once}; local $Trog::Component::MAX_DEPTH = 5; my $err = exception { Trog::Component::component('TestRecurse') }; like( $err, qr/recursed[ ]more[ ]than[ ]5[ ]deep/, "self-referential component dies bounded" ); is( $Trog::Component::depth, 0, "depth unwinds after the die" ); }; subtest 'as an xslate function' => sub { # Same shape as the injection in Trog::Renderer::Base::render(). my $tx = Text::Xslate->new( function => { component => \&Trog::Component::component } ); is( $tx->render_string( q{<: component('TestFixture') :>}, {} ), 'fixture', "markup survives unescaped without | mark_raw" ); is( $tx->render_string( q{<: component('TestFixture', { cols => 3 }) :>}, {} ), 'fixture', "the args form parses in a template" ); }; subtest 'a failure inside a template is not swallowed' => sub { no warnings qw{once}; my $tx = Text::Xslate->new( function => { component => \&Trog::Component::component } ); # Xslate catches the die, warns, and renders on with a hole where the call # was -- which is exactly why $error exists. Muffle the warning so the test # output stays readable. my $render = sub { my ($template) = @_; local $Trog::Component::error; local $SIG{__WARN__} = sub { }; my $out = $tx->render_string( $template, {} ); return ( $out, $Trog::Component::error ); }; my ( $out, $err ) = $render->(q{before<: component('../../etc/passwd') :>after}); is( $out, 'beforeafter', "Xslate really does render on past a dead component" ); like( $err, qr/component[ ]name/i, "...and the failure is recorded for the renderer to find" ); ( $out, $err ) = $render->(q{<: component('TestBoom') :>}); like( $err, qr/TestBoom/, "a component's own death is recorded" ); ( $out, $err ) = $render->(q{<: component('TestFixture') :>}); is( $err, undef, "a good render leaves no error behind" ); # Trog::Renderer::Base localises $error per render, so a nested render # cannot inherit a stale one. ( $out, $err ) = $render->(q{<: component('TestBoom') :><: component('TestFixture') :>}); like( $err, qr/TestBoom/, "the first failure wins, not the last" ); }; subtest 'through the real renderer' => sub { require Trog::Renderer::Base; # posts.tx is the live call site; render it as an editor with no posts and # the EmojiPicker component has to come back through the whole stack. my $body = eval { Trog::Renderer::Base::render( template => 'posts.tx', contenttype => 'text/html', component => 1, data => { can_edit => 1, direct => 1, failure => -1, posts => [], forms => [], }, ); }; my $err = $@; SKIP: { skip( "no emoji list on disk -- run make prereq-frontend", 2 ) if $err && $err =~ m/list\.min\.json|prereq-frontend/; is( $err, '', "posts.tx renders" ) or diag($err); like( $body, qr/id="emoji-container"/, "component('EmojiPicker') reached the page" ); } }; done_testing();