Trog-Component.t 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. use 5.040;
  2. use re '/aasx';
  3. use warnings FATAL => 'all';
  4. use Test::More;
  5. use Test::Fatal qw{exception};
  6. use FindBin::libs;
  7. use Text::Xslate ();
  8. require_ok('Trog::Component') or BAIL_OUT("Can't find SUT");
  9. # Fixtures, registered in %INC so that _load()'s require() is satisfied without
  10. # anything landing on disk in the real lib tree.
  11. BEGIN {
  12. $INC{'Trog/Component/TestFixture.pm'} = 1;
  13. $INC{'Trog/Component/TestSilent.pm'} = 1;
  14. $INC{'Trog/Component/TestTriplet.pm'} = 1;
  15. $INC{'Trog/Component/TestBoom.pm'} = 1;
  16. $INC{'Trog/Component/TestRecurse.pm'} = 1;
  17. $INC{'Trog/Component/TestNoRender.pm'} = 1;
  18. }
  19. our @ARGS_SEEN;
  20. {
  21. package Trog::Component::TestFixture;
  22. sub render { @ARGS_SEEN = @_; return '<b>fixture</b>' }
  23. package Trog::Component::TestSilent;
  24. sub render { return undef }
  25. package Trog::Component::TestTriplet;
  26. sub render { return [ 500, [], ['boom'] ] }
  27. package Trog::Component::TestBoom;
  28. sub render { die "kaboom\n" }
  29. package Trog::Component::TestRecurse;
  30. sub render { return Trog::Component::component('TestRecurse') }
  31. package Trog::Component::TestNoRender;
  32. sub nope { return 1 }
  33. }
  34. subtest 'name validation' => sub {
  35. # Every one of these reaches require() as a path if it gets past the check.
  36. my %bad = (
  37. 'traversal' => '../../etc/passwd',
  38. 'separator' => 'Foo/Bar',
  39. 'package sep' => 'Foo::Bar',
  40. 'leading digit' => '9Lives',
  41. 'empty' => '',
  42. 'space' => 'Emoji Picker',
  43. 'dot' => 'Foo.pm',
  44. 'nul' => "Foo\0Bar",
  45. );
  46. foreach my $case ( sort keys %bad ) {
  47. like(
  48. exception { Trog::Component::component( $bad{$case} ) },
  49. qr/component[ ]name/i,
  50. "$case rejected before anything is loaded"
  51. );
  52. }
  53. like( exception { Trog::Component::component(undef) }, qr/component[ ]name/i, "undef rejected" );
  54. # Nothing above should have made it as far as %INC.
  55. is( scalar( grep { m{^Trog/Component/} && !m{^Trog/Component/Test} } keys %INC ), 0, "no stray component was loaded" );
  56. };
  57. subtest 'loading' => sub {
  58. like(
  59. exception { Trog::Component::component('NoSuchComponentHere') },
  60. qr/NoSuchComponentHere/,
  61. "unknown component dies naming the component"
  62. );
  63. like(
  64. exception { Trog::Component::component('TestNoRender') },
  65. qr/no[ ]render/i,
  66. "a module without render() is not a component"
  67. );
  68. };
  69. subtest 'render' => sub {
  70. local @ARGS_SEEN = ();
  71. my $out = Trog::Component::component('TestFixture');
  72. is( "$out", '<b>fixture</b>', "component output comes back" );
  73. isa_ok( $out, 'Text::Xslate::Type::Raw', "output is marked raw so callers need no mark_raw" );
  74. is_deeply( \@ARGS_SEEN, [], "no args passed means render() sees none" );
  75. Trog::Component::component( 'TestFixture', { album => 'holiday', cols => 3 } );
  76. is_deeply( {@ARGS_SEEN}, { album => 'holiday', cols => 3 }, "args hash is flattened into render()" );
  77. Trog::Component::component( 'TestFixture', 'not a hashref' );
  78. is_deeply( \@ARGS_SEEN, [], "a non-hashref second arg is ignored rather than fatal" );
  79. };
  80. subtest 'failure modes' => sub {
  81. # Trog::Renderer::render() answers failure with a PSGI triplet, which is
  82. # meaningless halfway through a template.
  83. like(
  84. exception { Trog::Component::component('TestTriplet') },
  85. qr/TestTriplet\N*ARRAY/,
  86. "a PSGI triplet is caught rather than leaking into the page"
  87. );
  88. like(
  89. exception { Trog::Component::component('TestSilent') },
  90. qr/rendered[ ]nothing/,
  91. "an undef render is a failure, not an empty component"
  92. );
  93. like(
  94. exception { Trog::Component::component('TestBoom') },
  95. qr/TestBoom.*kaboom/s,
  96. "a component's own die is re-thrown naming the component"
  97. );
  98. };
  99. subtest 'recursion guard' => sub {
  100. no warnings qw{once};
  101. local $Trog::Component::MAX_DEPTH = 5;
  102. my $err = exception { Trog::Component::component('TestRecurse') };
  103. like( $err, qr/recursed[ ]more[ ]than[ ]5[ ]deep/, "self-referential component dies bounded" );
  104. is( $Trog::Component::depth, 0, "depth unwinds after the die" );
  105. };
  106. subtest 'as an xslate function' => sub {
  107. # Same shape as the injection in Trog::Renderer::Base::render().
  108. my $tx = Text::Xslate->new( function => { component => \&Trog::Component::component } );
  109. is(
  110. $tx->render_string( q{<: component('TestFixture') :>}, {} ),
  111. '<b>fixture</b>',
  112. "markup survives unescaped without | mark_raw"
  113. );
  114. is(
  115. $tx->render_string( q{<: component('TestFixture', { cols => 3 }) :>}, {} ),
  116. '<b>fixture</b>',
  117. "the args form parses in a template"
  118. );
  119. };
  120. subtest 'a failure inside a template is not swallowed' => sub {
  121. no warnings qw{once};
  122. my $tx = Text::Xslate->new( function => { component => \&Trog::Component::component } );
  123. # Xslate catches the die, warns, and renders on with a hole where the call
  124. # was -- which is exactly why $error exists. Muffle the warning so the test
  125. # output stays readable.
  126. my $render = sub {
  127. my ($template) = @_;
  128. local $Trog::Component::error;
  129. local $SIG{__WARN__} = sub { };
  130. my $out = $tx->render_string( $template, {} );
  131. return ( $out, $Trog::Component::error );
  132. };
  133. my ( $out, $err ) = $render->(q{before<: component('../../etc/passwd') :>after});
  134. is( $out, 'beforeafter', "Xslate really does render on past a dead component" );
  135. like( $err, qr/component[ ]name/i, "...and the failure is recorded for the renderer to find" );
  136. ( $out, $err ) = $render->(q{<: component('TestBoom') :>});
  137. like( $err, qr/TestBoom/, "a component's own death is recorded" );
  138. ( $out, $err ) = $render->(q{<: component('TestFixture') :>});
  139. is( $err, undef, "a good render leaves no error behind" );
  140. # Trog::Renderer::Base localises $error per render, so a nested render
  141. # cannot inherit a stale one.
  142. ( $out, $err ) = $render->(q{<: component('TestBoom') :><: component('TestFixture') :>});
  143. like( $err, qr/TestBoom/, "the first failure wins, not the last" );
  144. };
  145. subtest 'through the real renderer' => sub {
  146. require Trog::Renderer::Base;
  147. # posts.tx is the live call site; render it as an editor with no posts and
  148. # the EmojiPicker component has to come back through the whole stack.
  149. my $body = eval {
  150. Trog::Renderer::Base::render(
  151. template => 'posts.tx',
  152. contenttype => 'text/html',
  153. component => 1,
  154. data => {
  155. can_edit => 1,
  156. direct => 1,
  157. failure => -1,
  158. posts => [],
  159. forms => [],
  160. },
  161. );
  162. };
  163. my $err = $@;
  164. SKIP: {
  165. skip( "no emoji list on disk -- run make prereq-frontend", 2 )
  166. if $err && $err =~ m/list\.min\.json|prereq-frontend/;
  167. is( $err, '', "posts.tx renders" ) or diag($err);
  168. like( $body, qr/id="emoji-container"/, "component('EmojiPicker') reached the page" );
  169. }
  170. };
  171. done_testing();