]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/TestCommon.pm
c861dc5dfbb2add815363ec4ad69259cb6359d98
[public-inbox.git] / lib / PublicInbox / TestCommon.pm
1 # Copyright (C) 2015-2021 all contributors <meta@public-inbox.org>
2 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
3
4 # internal APIs used only for tests
5 package PublicInbox::TestCommon;
6 use strict;
7 use parent qw(Exporter);
8 use v5.10.1;
9 use Fcntl qw(FD_CLOEXEC F_SETFD F_GETFD :seek);
10 use POSIX qw(dup2);
11 use IO::Socket::INET;
12 our @EXPORT;
13 BEGIN {
14         @EXPORT = qw(tmpdir tcp_server tcp_connect require_git require_mods
15                 run_script start_script key2sub xsys xsys_e xqx eml_load tick
16                 have_xapian_compact json_utf8 setup_public_inboxes
17                 test_lei $lei $lei_out $lei_err $lei_opt);
18         require Test::More;
19         my @methods = grep(!/\W/, @Test::More::EXPORT);
20         eval(join('', map { "*$_=\\&Test::More::$_;" } @methods));
21         die $@ if $@;
22         push @EXPORT, @methods;
23 }
24
25 sub eml_load ($) {
26         my ($path, $cb) = @_;
27         open(my $fh, '<', $path) or die "open $path: $!";
28         require PublicInbox::Eml;
29         PublicInbox::Eml->new(\(do { local $/; <$fh> }));
30 }
31
32 sub tmpdir (;$) {
33         my ($base) = @_;
34         require File::Temp;
35         unless (defined $base) {
36                 ($base) = ($0 =~ m!\b([^/]+)\.[^\.]+\z!);
37         }
38         my $tmpdir = File::Temp->newdir("pi-$base-$$-XXXXXX", TMPDIR => 1);
39         ($tmpdir->dirname, $tmpdir);
40 }
41
42 sub tcp_server () {
43         IO::Socket::INET->new(
44                 LocalAddr => '127.0.0.1',
45                 ReuseAddr => 1,
46                 Proto => 'tcp',
47                 Type => Socket::SOCK_STREAM(),
48                 Listen => 1024,
49                 Blocking => 0,
50         ) or BAIL_OUT "failed to create TCP server: $!";
51 }
52
53 sub tcp_connect {
54         my ($dest, %opt) = @_;
55         my $addr = $dest->sockhost . ':' . $dest->sockport;
56         my $s = IO::Socket::INET->new(
57                 Proto => 'tcp',
58                 Type => Socket::SOCK_STREAM(),
59                 PeerAddr => $addr,
60                 %opt,
61         ) or BAIL_OUT "failed to connect to $addr: $!";
62         $s->autoflush(1);
63         $s;
64 }
65
66 sub require_git ($;$) {
67         my ($req, $maybe) = @_;
68         my ($req_maj, $req_min, $req_sub) = split(/\./, $req);
69         my ($cur_maj, $cur_min, $cur_sub) = (xqx([qw(git --version)])
70                         =~ /version (\d+)\.(\d+)(?:\.(\d+))?/);
71
72         my $req_int = ($req_maj << 24) | ($req_min << 16) | ($req_sub // 0);
73         my $cur_int = ($cur_maj << 24) | ($cur_min << 16) | ($cur_sub // 0);
74         if ($cur_int < $req_int) {
75                 return 0 if $maybe;
76                 plan skip_all =>
77                         "git $req+ required, have $cur_maj.$cur_min.$cur_sub";
78         }
79         1;
80 }
81
82 sub require_mods {
83         my @mods = @_;
84         my $maybe = pop @mods if $mods[-1] =~ /\A[0-9]+\z/;
85         my @need;
86         while (my $mod = shift(@mods)) {
87                 if ($mod eq 'json') {
88                         $mod = 'Cpanel::JSON::XS||JSON::MaybeXS||'.
89                                 'JSON||JSON::PP'
90                 }
91                 if ($mod eq 'Search::Xapian') {
92                         if (eval { require PublicInbox::Search } &&
93                                 PublicInbox::Search::load_xapian()) {
94                                 next;
95                         }
96                 } elsif ($mod eq 'Search::Xapian::WritableDatabase') {
97                         if (eval { require PublicInbox::SearchIdx } &&
98                                 PublicInbox::SearchIdx::load_xapian_writable()){
99                                         next;
100                         }
101                 } elsif (index($mod, '||') >= 0) { # "Foo||Bar"
102                         my $ok;
103                         for my $m (split(/\Q||\E/, $mod)) {
104                                 eval "require $m";
105                                 next if $@;
106                                 $ok = $m;
107                                 last;
108                         }
109                         next if $ok;
110                 } else {
111                         eval "require $mod";
112                 }
113                 if ($@) {
114                         push @need, $mod;
115                 } elsif ($mod eq 'IO::Socket::SSL' &&
116                         # old versions of IO::Socket::SSL aren't supported
117                         # by libnet, at least:
118                         # https://rt.cpan.org/Ticket/Display.html?id=100529
119                                 !eval{ IO::Socket::SSL->VERSION(2.007); 1 }) {
120                         push @need, $@;
121                 }
122         }
123         return unless @need;
124         my $m = join(', ', @need)." missing for $0";
125         skip($m, $maybe) if $maybe;
126         plan(skip_all => $m)
127 }
128
129 sub key2script ($) {
130         my ($key) = @_;
131         return $key if ($key eq 'git' || index($key, '/') >= 0);
132         # n.b. we may have scripts which don't start with "public-inbox" in
133         # the future:
134         $key =~ s/\A([-\.])/public-inbox$1/;
135         'blib/script/'.$key;
136 }
137
138 my @io_mode = ([ *STDIN{IO}, '<&' ], [ *STDOUT{IO}, '>&' ],
139                 [ *STDERR{IO}, '>&' ]);
140
141 sub _prepare_redirects ($) {
142         my ($fhref) = @_;
143         my $orig_io = [];
144         for (my $fd = 0; $fd <= $#io_mode; $fd++) {
145                 my $fh = $fhref->[$fd] or next;
146                 my ($oldfh, $mode) = @{$io_mode[$fd]};
147                 open my $orig, $mode, $oldfh or die "$oldfh $mode stash: $!";
148                 $orig_io->[$fd] = $orig;
149                 open $oldfh, $mode, $fh or die "$oldfh $mode redirect: $!";
150         }
151         $orig_io;
152 }
153
154 sub _undo_redirects ($) {
155         my ($orig_io) = @_;
156         for (my $fd = 0; $fd <= $#io_mode; $fd++) {
157                 my $fh = $orig_io->[$fd] or next;
158                 my ($oldfh, $mode) = @{$io_mode[$fd]};
159                 open $oldfh, $mode, $fh or die "$$oldfh $mode redirect: $!";
160         }
161 }
162
163 # $opt->{run_mode} (or $ENV{TEST_RUN_MODE}) allows choosing between
164 # three ways to spawn our own short-lived Perl scripts for testing:
165 #
166 # 0 - (fork|vfork) + execve, the most realistic but slowest
167 # 1 - (not currently implemented)
168 # 2 - preloading and running in current process (slightly faster than 1)
169 #
170 # 2 is not compatible with scripts which use "exit" (which we'll try to
171 # avoid in the future).
172 # The default is 2.
173 our $run_script_exit_code;
174 sub RUN_SCRIPT_EXIT () { "RUN_SCRIPT_EXIT\n" };
175 sub run_script_exit {
176         $run_script_exit_code = $_[0] // 0;
177         die RUN_SCRIPT_EXIT;
178 }
179
180 our %cached_scripts;
181 sub key2sub ($) {
182         my ($key) = @_;
183         $cached_scripts{$key} //= do {
184                 my $f = key2script($key);
185                 open my $fh, '<', $f or die "open $f: $!";
186                 my $str = do { local $/; <$fh> };
187                 my $pkg = (split(m!/!, $f))[-1];
188                 $pkg =~ s/([a-z])([a-z0-9]+)(\.t)?\z/\U$1\E$2/;
189                 $pkg .= "_T" if $3;
190                 $pkg =~ tr/-.//d;
191                 $pkg = "PublicInbox::TestScript::$pkg";
192                 eval <<EOF;
193 package $pkg;
194 use strict;
195 use subs qw(exit);
196
197 *exit = \\&PublicInbox::TestCommon::run_script_exit;
198 sub main {
199 # the below "line" directive is a magic comment, see perlsyn(1) manpage
200 # line 1 "$f"
201 $str
202         0;
203 }
204 1;
205 EOF
206                 $pkg->can('main');
207         }
208 }
209
210 sub _run_sub ($$$) {
211         my ($sub, $key, $argv) = @_;
212         local @ARGV = @$argv;
213         $run_script_exit_code = undef;
214         my $exit_code = eval { $sub->(@$argv) };
215         if ($@ eq RUN_SCRIPT_EXIT) {
216                 $@ = '';
217                 $exit_code = $run_script_exit_code;
218                 $? = ($exit_code << 8);
219         } elsif (defined($exit_code)) {
220                 $? = ($exit_code << 8);
221         } elsif ($@) { # mimic die() behavior when uncaught
222                 warn "E: eval-ed $key: $@\n";
223                 $? = ($! << 8) if $!;
224                 $? = (255 << 8) if $? == 0;
225         } else {
226                 die "BUG: eval-ed $key: no exit code or \$@\n";
227         }
228 }
229
230 sub run_script ($;$$) {
231         my ($cmd, $env, $opt) = @_;
232         my ($key, @argv) = @$cmd;
233         my $run_mode = $ENV{TEST_RUN_MODE} // $opt->{run_mode} // 1;
234         my $sub = $run_mode == 0 ? undef : key2sub($key);
235         my $fhref = [];
236         my $spawn_opt = {};
237         for my $fd (0..2) {
238                 my $redir = $opt->{$fd};
239                 my $ref = ref($redir);
240                 if ($ref eq 'SCALAR') {
241                         open my $fh, '+>', undef or die "open: $!";
242                         $fhref->[$fd] = $fh;
243                         $spawn_opt->{$fd} = $fh;
244                         next if $fd > 0;
245                         $fh->autoflush(1);
246                         print $fh $$redir or die "print: $!";
247                         seek($fh, 0, SEEK_SET) or die "seek: $!";
248                 } elsif ($ref eq 'GLOB') {
249                         $spawn_opt->{$fd} = $fhref->[$fd] = $redir;
250                 } elsif ($ref) {
251                         die "unable to deal with $ref $redir";
252                 }
253         }
254         if ($run_mode == 0) {
255                 # spawn an independent new process, like real-world use cases:
256                 require PublicInbox::Spawn;
257                 my $cmd = [ key2script($key), @argv ];
258                 my $pid = PublicInbox::Spawn::spawn($cmd, $env, $spawn_opt);
259                 if (defined $pid) {
260                         my $r = waitpid($pid, 0) // die "waitpid: $!";
261                         $r == $pid or die "waitpid: expected $pid, got $r";
262                 }
263         } else { # localize and run everything in the same process:
264                 # note: "local *STDIN = *STDIN;" and so forth did not work in
265                 # old versions of perl
266                 local %ENV = $env ? (%ENV, %$env) : %ENV;
267                 local %SIG = %SIG;
268                 local $0 = join(' ', @$cmd);
269                 my $orig_io = _prepare_redirects($fhref);
270                 _run_sub($sub, $key, \@argv);
271                 _undo_redirects($orig_io);
272                 select STDOUT;
273         }
274
275         # slurp the redirects back into user-supplied strings
276         for my $fd (1..2) {
277                 my $fh = $fhref->[$fd] or next;
278                 seek($fh, 0, SEEK_SET) or die "seek: $!";
279                 my $redir = $opt->{$fd};
280                 local $/;
281                 $$redir = <$fh>;
282         }
283         $? == 0;
284 }
285
286 sub tick (;$) {
287         my $tick = shift // 0.1;
288         select undef, undef, undef, $tick;
289         1;
290 }
291
292 sub wait_for_tail ($;$) {
293         my ($tail_pid, $want) = @_;
294         my $wait = 2;
295         if ($^O eq 'linux') { # GNU tail may use inotify
296                 state $tail_has_inotify;
297                 return tick if $want < 0 && $tail_has_inotify;
298                 my $end = time + $wait;
299                 my @ino;
300                 do {
301                         @ino = grep {
302                                 readlink($_) =~ /\binotify\b/
303                         } glob("/proc/$tail_pid/fd/*");
304                 } while (!@ino && time <= $end and tick);
305                 return if !@ino;
306                 $tail_has_inotify = 1;
307                 $ino[0] =~ s!/fd/!/fdinfo/!;
308                 my @info;
309                 do {
310                         if (open my $fh, '<', $ino[0]) {
311                                 local $/ = "\n";
312                                 @info = grep(/^inotify wd:/, <$fh>);
313                         }
314                 } while (scalar(@info) < $want && time <= $end and tick);
315         } else {
316                 sleep($wait);
317         }
318 }
319
320 # like system() built-in, but uses spawn() for env/rdr + vfork
321 sub xsys {
322         my ($cmd, $env, $rdr) = @_;
323         if (ref($cmd)) {
324                 $rdr ||= {};
325         } else {
326                 $cmd = [ @_ ];
327                 $env = undef;
328                 $rdr = {};
329         }
330         run_script($cmd, $env, { %$rdr, run_mode => 0 });
331         $? >> 8
332 }
333
334 sub xsys_e { # like "/bin/sh -e"
335         xsys(@_) == 0 or
336                 BAIL_OUT (ref $_[0] ? "@{$_[0]}" : "@_"). " failed \$?=$?"
337 }
338
339 # like `backtick` or qx{} op, but uses spawn() for env/rdr + vfork
340 sub xqx {
341         my ($cmd, $env, $rdr) = @_;
342         $rdr //= {};
343         run_script($cmd, $env, { %$rdr, run_mode => 0, 1 => \(my $out) });
344         wantarray ? split(/^/m, $out) : $out;
345 }
346
347 sub start_script {
348         my ($cmd, $env, $opt) = @_;
349         my ($key, @argv) = @$cmd;
350         my $run_mode = $ENV{TEST_RUN_MODE} // $opt->{run_mode} // 2;
351         my $sub = $run_mode == 0 ? undef : key2sub($key);
352         my $tail_pid;
353         if (my $tail_cmd = $ENV{TAIL}) {
354                 my @paths;
355                 for (@argv) {
356                         next unless /\A--std(?:err|out)=(.+)\z/;
357                         push @paths, $1;
358                 }
359                 if ($opt) {
360                         for (1, 2) {
361                                 my $f = $opt->{$_} or next;
362                                 if (!ref($f)) {
363                                         push @paths, $f;
364                                 } elsif (ref($f) eq 'GLOB' && $^O eq 'linux') {
365                                         my $fd = fileno($f);
366                                         my $f = readlink "/proc/$$/fd/$fd";
367                                         push @paths, $f if -e $f;
368                                 }
369                         }
370                 }
371                 if (@paths) {
372                         $tail_pid = fork // die "fork: $!";
373                         if ($tail_pid == 0) {
374                                 # make sure files exist, first
375                                 open my $fh, '>>', $_ for @paths;
376                                 open(STDOUT, '>&STDERR') or die "1>&2: $!";
377                                 exec(split(' ', $tail_cmd), @paths);
378                                 die "$tail_cmd failed: $!";
379                         }
380                         wait_for_tail($tail_pid, scalar @paths);
381                 }
382         }
383         my $pid = fork // die "fork: $!\n";
384         if ($pid == 0) {
385                 eval { PublicInbox::DS->Reset };
386                 # pretend to be systemd (cf. sd_listen_fds(3))
387                 # 3 == SD_LISTEN_FDS_START
388                 my $fd;
389                 for ($fd = 0; 1; $fd++) {
390                         my $s = $opt->{$fd};
391                         last if $fd >= 3 && !defined($s);
392                         next unless $s;
393                         my $fl = fcntl($s, F_GETFD, 0);
394                         if (($fl & FD_CLOEXEC) != FD_CLOEXEC) {
395                                 warn "got FD:".fileno($s)." w/o CLOEXEC\n";
396                         }
397                         fcntl($s, F_SETFD, $fl &= ~FD_CLOEXEC);
398                         dup2(fileno($s), $fd) or die "dup2 failed: $!\n";
399                 }
400                 %ENV = (%ENV, %$env) if $env;
401                 my $fds = $fd - 3;
402                 if ($fds > 0) {
403                         $ENV{LISTEN_PID} = $$;
404                         $ENV{LISTEN_FDS} = $fds;
405                 }
406                 $0 = join(' ', @$cmd);
407                 if ($sub) {
408                         eval { PublicInbox::DS->Reset };
409                         _run_sub($sub, $key, \@argv);
410                         POSIX::_exit($? >> 8);
411                 } else {
412                         exec(key2script($key), @argv);
413                         die "FAIL: ",join(' ', $key, @argv), ": $!\n";
414                 }
415         }
416         PublicInboxTestProcess->new($pid, $tail_pid);
417 }
418
419 sub have_xapian_compact () {
420         require PublicInbox::Spawn;
421         # $ENV{XAPIAN_COMPACT} is used by PublicInbox/Xapcmd.pm, too
422         PublicInbox::Spawn::which($ENV{XAPIAN_COMPACT} || 'xapian-compact');
423 }
424
425 our ($err_skip, $lei_opt, $lei_out, $lei_err);
426 our $lei = sub {
427         my ($cmd, $env, $xopt) = @_;
428         $lei_out = $lei_err = '';
429         if (!ref($cmd)) {
430                 ($env, $xopt) = grep { (!defined) || ref } @_;
431                 $cmd = [ grep { defined && !ref } @_ ];
432         }
433         my $res = run_script(['lei', @$cmd], $env, $xopt // $lei_opt);
434         $err_skip and
435                 $lei_err = join('', grep(!/$err_skip/, split(/^/m, $lei_err)));
436         $res;
437 };
438
439 sub json_utf8 () {
440         state $x = ref(PublicInbox::Config->json)->new->utf8->canonical;
441 }
442
443 sub test_lei {
444 SKIP: {
445         my ($cb) = pop @_;
446         my $test_opt = shift // {};
447         require_git(2.6) or skip('git 2.6+ required for lei test', 2);
448         require_mods(qw(json DBD::SQLite Search::Xapian), 2);
449         require PublicInbox::Config;
450         delete local $ENV{XDG_DATA_HOME};
451         delete local $ENV{XDG_CONFIG_HOME};
452         local $ENV{GIT_COMMITTER_EMAIL} = 'lei@example.com';
453         local $ENV{GIT_COMMITTER_NAME} = 'lei user';
454         my (undef, $fn, $lineno) = caller(0);
455         my $t = "$fn:$lineno";
456         require PublicInbox::Spawn;
457         state $lei_daemon = PublicInbox::Spawn->can('send_cmd4') ||
458                                 eval { require Socket::MsgHdr; 1 };
459         # XXX fix and move this inside daemon-only before 1.7 release
460         skip <<'EOM', 1 unless $lei_daemon;
461 Socket::MsgHdr missing or Inline::C is unconfigured/missing
462 EOM
463         $lei_opt = { 1 => \$lei_out, 2 => \$lei_err };
464         my $daemon_pid;
465         my ($tmpdir, $for_destroy) = tmpdir();
466         SKIP: {
467                 skip 'TEST_LEI_ONESHOT set', 1 if $ENV{TEST_LEI_ONESHOT};
468                 my $home = "$tmpdir/lei-daemon";
469                 mkdir($home, 0700) or BAIL_OUT "mkdir: $!";
470                 local $ENV{HOME} = $home;
471                 my $xrd = "$home/xdg_run";
472                 mkdir($xrd, 0700) or BAIL_OUT "mkdir: $!";
473                 local $ENV{XDG_RUNTIME_DIR} = $xrd;
474                 $cb->();
475                 ok($lei->(qw(daemon-pid)), "daemon-pid after $t");
476                 chomp($daemon_pid = $lei_out);
477                 if ($daemon_pid) {
478                         ok(kill(0, $daemon_pid), "daemon running after $t");
479                         ok($lei->(qw(daemon-kill)), "daemon-kill after $t");
480                 } else {
481                         fail("daemon not running after $t");
482                 }
483         }; # SKIP for lei_daemon
484         unless ($test_opt->{daemon_only}) {
485                 require_ok 'PublicInbox::LEI';
486                 my $home = "$tmpdir/lei-oneshot";
487                 mkdir($home, 0700) or BAIL_OUT "mkdir: $!";
488                 local $ENV{HOME} = $home;
489                 # force sun_path[108] overflow:
490                 my $xrd = "$home/1shot-test".('.sun_path' x 108);
491                 local $err_skip = qr!\Q$xrd!; # for $lei->() filtering
492                 local $ENV{XDG_RUNTIME_DIR} = $xrd;
493                 $cb->();
494         }
495         if ($daemon_pid) {
496                 for (0..10) {
497                         kill(0, $daemon_pid) or last;
498                         tick;
499                 }
500                 ok(!kill(0, $daemon_pid), "$t daemon stopped after oneshot");
501         }
502 }; # SKIP if missing git 2.6+ || Xapian || SQLite || json
503 } # /test_lei
504
505 # returns the pathname to a ~/.public-inbox/config in scalar context,
506 # ($test_home, $pi_config_pathname) in list context
507 sub setup_public_inboxes () {
508         my $test_home = "t/home1";
509         my $pi_config = "$test_home/.public-inbox/config";
510         my $stamp = "$test_home/setup-stamp";
511         my @ret = ($test_home, $pi_config);
512         return @ret if -f $stamp;
513
514         require PublicInbox::Lock;
515         my $lk = bless { lock_path => "$test_home/setup.lock" },
516                         'PublicInbox::Lock';
517         my $end = $lk->lock_for_scope;
518         return @ret if -f $stamp;
519
520         require PublicInbox::InboxWritable;
521         local $ENV{PI_CONFIG} = $pi_config;
522         for my $V (1, 2) {
523                 run_script([qw(-init), "-V$V", "t$V",
524                                 '--newsgroup', "t.$V",
525                                 "$test_home/t$V", "http://example.com/t$V",
526                                 "t$V\@example.com" ]) or BAIL_OUT "init v$V";
527         }
528         my $cfg = PublicInbox::Config->new;
529         my $seen = 0;
530         $cfg->each_inbox(sub {
531                 my ($ibx) = @_;
532                 my $im = PublicInbox::InboxWritable->new($ibx)->importer(0);
533                 my $V = $ibx->version;
534                 my @eml = (glob('t/*.eml'), 't/data/0001.patch');
535                 for (@eml) {
536                         next if $_ eq 't/psgi_v2-old.eml'; # dup mid
537                         $im->add(eml_load($_)) or BAIL_OUT "v$V add $_";
538                         $seen++;
539                 }
540                 $im->done;
541                 if ($V == 1) {
542                         run_script(['-index', $ibx->{inboxdir}]) or
543                                 BAIL_OUT 'index v1';
544                 }
545         });
546         $seen or BAIL_OUT 'no imports';
547         open my $fh, '>', $stamp or BAIL_OUT "open $stamp: $!";
548         @ret;
549 };
550
551 package PublicInboxTestProcess;
552 use strict;
553
554 # prevent new threads from inheriting these objects
555 sub CLONE_SKIP { 1 }
556
557 sub new {
558         my ($klass, $pid, $tail_pid) = @_;
559         bless { pid => $pid, tail_pid => $tail_pid, owner => $$ }, $klass;
560 }
561
562 sub kill {
563         my ($self, $sig) = @_;
564         CORE::kill($sig // 'TERM', $self->{pid});
565 }
566
567 sub join {
568         my ($self, $sig) = @_;
569         my $pid = delete $self->{pid} or return;
570         CORE::kill($sig, $pid) if defined $sig;
571         my $ret = waitpid($pid, 0) // die "waitpid($pid): $!";
572         $ret == $pid or die "waitpid($pid) != $ret";
573 }
574
575 sub DESTROY {
576         my ($self) = @_;
577         return if $self->{owner} != $$;
578         if (my $tail_pid = delete $self->{tail_pid}) {
579                 PublicInbox::TestCommon::wait_for_tail($tail_pid, -1);
580                 CORE::kill('TERM', $tail_pid);
581         }
582         $self->join('TERM');
583 }
584
585 package PublicInbox::TestCommon::InboxWakeup;
586 use strict;
587 sub on_inbox_unlock { ${$_[0]}->($_[1]) }
588
589 1;