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