Trog-Component.t 8.7 KB

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