Trog-Component.t 7.1 KB

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