Merge branch 'master' into cvs
[ikiwiki.git] / t / cvs.t
1 #!/usr/bin/perl
2 use warnings;
3 use strict;
4 use Test::More; my $total_tests = 69;
5 use IkiWiki;
6
7 my $default_test_methods = '^test_*';
8 my @required_programs = qw(
9         cvs
10         cvsps
11 );
12 my @required_modules = qw(
13         File::chdir
14         File::MimeInfo
15         Date::Parse
16         File::Temp
17         File::ReadBackwards
18 );
19 my $dir = "/tmp/ikiwiki-test-cvs.$$";
20
21 # TESTS FOR GENERAL META-BEHAVIOR
22
23 sub test_web_comments {
24         # how much of the web-edit workflow are we actually testing?
25         # because we want to test comments:
26         # - when the first comment for page.mdwn is added, and page/ is
27         #   created to hold the comment, page/ isn't added to CVS control,
28         #   so the comment isn't either
29         #   - can't reproduce after chmod g+s ikiwiki.cgi (20120204)
30         # - side effect for moderated comments: after approval they
31         #   show up normally AND are still pending, too
32         # - comments.pm treats rcs_commit_staged() as returning conflicts?
33 }
34
35 sub test_chdir_magic {
36         # cvs.pm operations are always occurring inside $config{srcdir}
37         # other ikiwiki operations are occurring wherever, and are unaffected
38         # when are we bothering with "local $CWD" and when aren't we?
39         # after commit, presumably only with post-commit hook enabled:
40         #> Use of chdir('') or chdir(undef) as chdir() is deprecated at
41         #> /usr/pkg/lib/perl5/vendor_perl/5.14.0/File/chdir.pm line 45.
42 }
43
44 sub test_cvs_info {
45         # inspect "Repository revision" (used in code)
46         # inspect "Sticky Options" (used in tests to verify existence of "-kb")
47 }
48
49 sub test_cvs_run_cvs {
50         # extract the stdout-redirect thing
51         # - prove that it silences stdout
52         # - prove that stderr comes through just fine
53         # prove that when cvs exits nonzero (fail), function exits false
54         # prove that when cvs exits zero (success), function exits true
55         # always pass -f, just in case
56         # steal from git.pm: safe_git(), run_or_{die,cry,non}
57         # - open() instead of system()
58         # always call cvs_run_cvs(), don't ever run 'cvs' directly
59         # - for cvs_info(), make it respect wantarray
60 }
61
62 sub test_cvs_run_cvsps {
63         # parameterize command like run_cvs()
64         # expose config vars for e.g. "--cvs-direct -z 30"
65         # always pass -x (unless proven otherwise)
66         # - but diff doesn't! optimization alert
67         # always pass -b HEAD (configurable like gitmaster_branch?)
68 }
69
70 sub test_cvs_parse_cvsps {
71         # extract method from rcs_recentchanges
72         # document expected changeset format
73         # document expected changeset delimiter
74         # try: cvsps -q -x -p && ls | sort -rn | head -100
75         # - benchmark against current impl (that uses File::ReadBackwards)
76 }
77
78 sub test_cvs_parse_log_accum {
79         # add new, preferred method for rcs_recentchanges to use
80         # teach log_accum to record commits (into transient?)
81         # script cvsps to bootstrap (or replace?) commit history
82         # teach ikiwiki-makerepo to set up log_accum and commit_prep
83         # why are NetBSD commit mails unreliable?
84         # - is it working for CVS commits and failing for web commits?
85 }
86
87 sub test_cvs_is_controlling {
88         # with no args:
89         # - if srcdir is in CVS, return true
90         # - else, return false
91         # with a dir arg:
92         # - if dir is in CVS, return true
93         # - else, return false
94         # with a file arg:
95         # - is there anything that wants the answer? if so, answer
96         # - else, die
97 }
98
99
100 # TESTS FOR GENERAL PLUGIN API CALLS
101
102 sub test_checkconfig {
103         my $default_cvspath = 'ikiwiki';
104
105         undef $config{cvspath}; IkiWiki::checkconfig();
106         is(
107                 $config{cvspath}, $default_cvspath,
108                 q{can provide default cvspath},
109         );
110
111         $config{cvspath} = "/$default_cvspath/"; IkiWiki::checkconfig();
112         is(
113                 $config{cvspath}, $default_cvspath,
114                 q{can set typical cvspath and strip well-meaning slashes},
115         );
116
117         $config{cvspath} = "/$default_cvspath//subdir"; IkiWiki::checkconfig();
118         is(
119                 $config{cvspath}, "$default_cvspath/subdir",
120                 q{can really sanitize cvspath as assumed by rcs_recentchanges},
121         );
122
123         my $default_num_wrappers = @{$config{wrappers}};
124         undef $config{cvs_wrapper}; IkiWiki::checkconfig();
125         is(
126                 @{$config{wrappers}}, $default_num_wrappers,
127                 q{can start with no wrappers configured},
128         );
129
130         $config{cvs_wrapper} = $config{cvsrepo} . "/CVSROOT/post-commit";
131         IkiWiki::checkconfig();
132         is(
133                 @{$config{wrappers}}, ++$default_num_wrappers,
134                 q{can add cvs_wrapper},
135         );
136
137         undef $config{cvs_wrapper};
138         $config{cvspath} = $default_cvspath;
139         IkiWiki::checkconfig();
140 }
141
142 sub test_getsetup {
143         # anything worth testing?
144 }
145
146 sub test_genwrapper {
147         # testable directly? affects rcs_add, but are we exercising this?
148 }
149
150
151 # TESTS FOR VCS PLUGIN API CALLS
152
153 sub test_rcs_update {
154         # can it assume we're under CVS control? or must it check?
155         # anything else worth testing?
156 }
157
158 sub test_rcs_prepedit {
159         # can it assume we're under CVS control? or must it check?
160         # for existing file, returns latest revision in repo
161         # - what's this used for? should it return latest revision in checkout?
162         # for new file, returns empty string
163
164         # netbsd web log says "could not open lock file"
165         # XXX does this work right?
166         # how about with un-added dirs in the srcdir?
167         # how about with cvsps.core lying around?
168 }
169
170 sub test_rcs_commit {
171         # can it assume we're under CVS control? or must it check?
172         # if someone else changed the page since rcs_prepedit was called:
173         # - try to merge into our working copy
174         # - if merge succeeds, proceed to commit
175         # - else, return page content with the conflict markers in it
176         # commit:
177         # - if success, return undef
178         # - else, revert + return content with the conflict markers in it
179         # git.pm receives "session" param -- useful here?
180         # web commits start with "web commit {by,from} "
181         # seeing File::chdir errors on commit?
182
183         # XXX commit can fail due to "could not open lock file"
184 }
185
186 sub test_rcs_commit_staged {
187         # if commit succeeds, return undef
188         # else, warn and return error message (really? or just non-undef?)
189 }
190
191 sub test_rcs_add {
192         my @changes = IkiWiki::rcs_recentchanges(3);
193         is_total_number_of_changes(\@changes, 0);
194
195         my $message = "add a top-level ASCII (non-UTF-8) page via VCS API";
196         my $file = q{test0.mdwn};
197         add_and_commit($file, $message, qq{# \$Id\$\n* some plain ASCII text});
198         is_newly_added($file);
199         is_in_keyword_substitution_mode($file, q{-kkv});
200         like(
201                 readfile($config{srcdir} . "/$file"),
202                 qr/^# \$Id: $file,v 1.1 .+\$$/m,
203                 q{can expand RCS Id keyword},
204         );
205         @changes = IkiWiki::rcs_recentchanges(3);
206         is_total_number_of_changes(\@changes, 1);
207         is_most_recent_change(\@changes, stripext($file), $message);
208
209         $message = "add a top-level dir via VCS API";
210         my $dir1 = q{test3};
211         can_mkdir($dir1);
212         IkiWiki::rcs_add($dir1);
213         # XXX test that the wrapper hangs here without our genwrapper()
214         # XXX test that the wrapper doesn't hang here with it
215         @changes = IkiWiki::rcs_recentchanges(3);
216         is_total_number_of_changes(\@changes, 1);       # despite the dir add
217         IkiWiki::rcs_commit(
218                 file => $dir1,
219                 message => $message,
220                 token => "oom",
221         );
222         @changes = IkiWiki::rcs_recentchanges(3);
223         is_total_number_of_changes(\@changes, 1);       # dirs aren't tracked
224
225         $message = "add a non-ASCII (UTF-8) text file in an un-added dir";
226         can_mkdir($_) for (qw(test4 test4/test5));
227         $file = q{test4/test5/test1.mdwn};
228         add_and_commit($file, $message, readfile("t/test1.mdwn"));
229         is_newly_added($file);
230         is_in_keyword_substitution_mode($file, q{-kkv});
231         @changes = IkiWiki::rcs_recentchanges(3);
232         is_total_number_of_changes(\@changes, 2);
233         is_most_recent_change(\@changes, stripext($file), $message);
234
235         $message = "add a binary file in an un-added dir, and commit_staged";
236         can_mkdir(q{test6});
237         $file = q{test6/test7.ico};
238         my $bindata_in = readfile("doc/favicon.ico", 1);
239         my $bindata_out = sub { readfile($config{srcdir} . "/$file", 1) };
240         writefile($file, $config{srcdir}, $bindata_in, 1);
241         is(&$bindata_out(), $bindata_in, q{binary files match before commit});
242         IkiWiki::rcs_add($file);
243         IkiWiki::rcs_commit_staged(message => $message);
244         is_newly_added($file);
245         is_in_keyword_substitution_mode($file, q{-kb});
246         is(&$bindata_out(), $bindata_in, q{binary files match after commit});
247         @changes = IkiWiki::rcs_recentchanges(3);
248         is_total_number_of_changes(\@changes, 3);
249         is_most_recent_change(\@changes, $file, $message);
250         ok(
251                 unlink($config{srcdir} . "/$file"),
252                 q{can remove file in order to re-fetch it from repo},
253         );
254         ok(! -e $config{srcdir} . "/$file", q{really removed file});
255         IkiWiki::rcs_update();
256         is(&$bindata_out(), $bindata_in, q{binary files match after re-fetch});
257
258         $message = "add a UTF-8 and a binary file in different dirs";
259         my $file1 = "test8/test9.mdwn";
260         my $file2 = "test10/test11.ico";
261         can_mkdir($_) for (qw(test8 test10));
262         writefile($file1, $config{srcdir}, readfile('t/test2.mdwn'));
263         writefile($file2, $config{srcdir}, $bindata_in, 1);
264         IkiWiki::rcs_add($_) for ($file1, $file2);
265         IkiWiki::rcs_commit_staged(message => $message);
266         is_newly_added($_) for ($file1, $file2);
267         is_in_keyword_substitution_mode($file1, q{-kkv});
268         is_in_keyword_substitution_mode($file2, q{-kb});
269         @changes = IkiWiki::rcs_recentchanges(3);
270         is_total_number_of_changes(\@changes, 3);
271         @changes = IkiWiki::rcs_recentchanges(4);
272         is_total_number_of_changes(\@changes, 4);
273         # XXX test for both files in the commit, and no other files
274         is_most_recent_change(\@changes, $file2, $message);
275
276         $message = "remove the UTF-8 and binary files we just added";
277         IkiWiki::rcs_remove($_) for ($file1, $file2);
278         IkiWiki::rcs_commit_staged(message => $message);
279         ok(-d "$config{srcdir}/test8", q{empty dir not pruned (1)});
280         @changes = IkiWiki::rcs_recentchanges(11);
281         ok(-d "$config{srcdir}/test8", q{empty dir not pruned (2)});
282         is_total_number_of_changes(\@changes, 5);
283         # XXX test for both files in the commit, and no other files
284         is_most_recent_change(\@changes, $file2, $message);
285
286         $message = "re-add UTF-8 and binary files with names swapped";
287         writefile($file2, $config{srcdir}, readfile('t/test2.mdwn'));
288         writefile($file1, $config{srcdir}, $bindata_in, 1);
289         IkiWiki::rcs_add($_) for ($file1, $file2);
290         IkiWiki::rcs_commit_staged(message => $message);
291         isnt_newly_added($_) for ($file1, $file2);
292         is_in_keyword_substitution_mode($file2, q{-kkv});
293         is_in_keyword_substitution_mode($file1, q{-kb});
294         @changes = IkiWiki::rcs_recentchanges(11);
295         is_total_number_of_changes(\@changes, 6);
296         # XXX test for both files in the commit, and no other files
297         is_most_recent_change(\@changes, $file2, $message);
298
299         # prevent web edits from attempting to create .../CVS/foo.mdwn
300         # on case-insensitive filesystems, also prevent .../cvs/foo.mdwn
301         # unless your "CVS" is something else and we've made it configurable
302         # also want a pre-commit hook for this?
303
304         # pre-commit hook:
305         # - lcase filenames
306         # - ?
307
308         # can it assume we're under CVS control? or must it check?
309 }
310
311 sub test_rcs_remove {
312         # can it assume we're under CVS control? or must it check?
313         # remove a top-level file
314         # - rcs_commit
315         # - inspect recentchanges: one new change, file removed
316         # remove two files (in different dirs)
317         # - rcs_commit_staged
318         # - inspect recentchanges: one new change, both files removed
319 }
320
321 sub test_rcs_rename {
322         # can it assume we're under CVS control? or must it check?
323         # rename a file in the same dir
324         # - rcs_commit_staged
325         # - inspect recentchanges: one new change, one file removed, one added
326         # rename a file into a different dir
327         # - rcs_commit_staged
328         # - inspect recentchanges: one new change, one file removed, one added
329         # rename a file into a not-yet-existing dir
330         # - rcs_commit_staged
331         # - inspect recentchanges: one new change, one file removed, one added
332         # is it safe to use "mv"? what if $dest is somehow outside the wiki?
333 }
334
335 sub test_rcs_recentchanges {
336         my @changes = IkiWiki::rcs_recentchanges(3);
337         is_total_number_of_changes(\@changes, 0);
338
339         my $message = "Add a page via CVS directly";
340         my $file = q{test2.mdwn};
341         writefile($file, $config{srcdir}, readfile(q{t/test2.mdwn}));
342         system "cd $config{srcdir}"
343                 . " && cvs add $file >/dev/null 2>&1";
344         system "cd $config{srcdir}"
345                 . " && cvs commit -m \"$message\" $file >/dev/null";
346
347         @changes = IkiWiki::rcs_recentchanges(3);
348         is_total_number_of_changes(\@changes, 1);
349         is_most_recent_change(\@changes, stripext($file), $message);
350
351         # CVS commits run ikiwiki once for every committed file (!)
352         # - commit_prep alone should fix this
353         # CVS multi-dir commits show only the first dir in recentchanges
354         # - commit_prep might also fix this?
355         # CVS post-commit hook is amped off to avoid locking against itself
356         # - commit_prep probably doesn't fix this... but maybe?
357         # can it assume we're under CVS control? or must it check?
358         # don't worry whether we're called with a number (we always are)
359         # other rcs tests already inspect much of the returned structure
360         # CVS commits say "cvs" and get the right committer
361         # web commits say "web" and get the right committer
362         # - and don't start with "web commit {by,from} "
363         # "nickname" -- can we ever meaningfully set this?
364
365         # prefer log_accum, then cvsps, else die
366         # run the high-level recentchanges tests 2x (once for each method)
367         # - including in other test subs that check recentchanges?
368 }
369
370 sub test_rcs_diff {
371         my @changes = IkiWiki::rcs_recentchanges(3);
372         is_total_number_of_changes(\@changes, 0);
373
374         my $message = "add a UTF-8 and an ASCII file in different dirs";
375         my $file1 = "rcsdiff1/utf8.mdwn";
376         my $file2 = "rcsdiff2/ascii.mdwn";
377         my $contents2 = ''; $contents2 .= "$_. foo\n" for (1..11);
378         can_mkdir($_) for (qw(rcsdiff1 rcsdiff2));
379         writefile($file1, $config{srcdir}, readfile('t/test2.mdwn'));
380         writefile($file2, $config{srcdir}, $contents2);
381         IkiWiki::rcs_add($_) for ($file1, $file2);
382         IkiWiki::rcs_commit_staged(message => $message);
383
384         # XXX we rely on rcs_recentchanges() to be called first!
385         # XXX or else for no cvsps cache to exist yet...
386         # XXX because rcs_diff() doesn't pass -x (as an optimization)
387         @changes = IkiWiki::rcs_recentchanges(3);
388         is_total_number_of_changes(\@changes, 1);
389
390         my $changeset = 1;
391
392         my $maxlines = undef;
393         my $scalar_diffs = IkiWiki::rcs_diff($changeset, $maxlines);
394         like(
395                 $scalar_diffs,
396                 qr/^\+11\. foo$/m,
397                 q{unbounded scalar diffs go all the way to 11},
398         );
399         my @array_diffs = IkiWiki::rcs_diff($changeset, $maxlines);
400         is(
401                 $array_diffs[$#array_diffs],
402                 "+11. foo\n",
403                 q{unbounded array diffs go all the way to 11},
404         );
405
406         $maxlines = 8;
407         $scalar_diffs = IkiWiki::rcs_diff($changeset, $maxlines);
408         unlike(
409                 $scalar_diffs,
410                 qr/^\+11\. foo$/m,
411                 q{bounded scalar diffs don't go all the way to 11},
412         );
413         @array_diffs = IkiWiki::rcs_diff($changeset, $maxlines);
414         isnt(
415                 $array_diffs[$#array_diffs],
416                 "+11. foo\n",
417                 q{bounded array diffs don't go all the way to 11},
418         );
419         is(
420                 scalar @array_diffs,
421                 $maxlines,
422                 q{bounded array diffs contain expected maximum number of lines},
423         );
424
425         # can it assume we're under CVS control? or must it check?
426 }
427
428 sub test_rcs_getctime {
429         # can it assume we're under CVS control? or must it check?
430         # given a file, find its creation time, else return 0
431         # first implement in the obvious way
432         # then cache
433 }
434
435 sub test_rcs_getmtime {
436         # can it assume we're under CVS control? or must it check?
437         # given a file, find its modification time, else return 0
438         # first implement in the obvious way
439         # then cache
440 }
441
442 sub test_rcs_receive {
443         my $description = q{rcs_receive doesn't make sense for CVS};
444         exists $IkiWiki::hooks{rcs}{rcs_receive}
445                 ? fail($description)
446                 : pass($description);
447 }
448
449 sub test_rcs_preprevert {
450         # can it assume we're under CVS control? or must it check?
451         # given a patchset number, return structure describing what'd happen:
452         # - see doc/plugins/write.mdwn:rcs_receive()
453         # don't forget about attachments
454 }
455
456 sub test_rcs_revert {
457         # test rcs_recentchanges() real darn well
458         # extract read-backwards patchset parser from rcs_recentchanges()
459         # recentchanges: given max, return list of changeset/files/etc.
460         # revert: given changeset ID, return list of file/rev/action
461         #
462         # can it assume we're under CVS control? or must it check?
463         # given a patchset number, stage the revert for rcs_commit_staged()
464         # if commit succeeds, return undef
465         # else, warn and return error message (really? or just non-undef?)
466 }
467
468 sub main {
469         my $test_methods = defined $ENV{TEST_METHOD} 
470                          ? $ENV{TEST_METHOD}
471                          : $default_test_methods;
472
473         _startup($test_methods eq $default_test_methods);
474         _runtests(_get_matching_test_subs($test_methods));
475         _shutdown($test_methods eq $default_test_methods);
476 }
477
478 main();
479
480
481 # INTERNAL SUPPORT ROUTINES
482
483 sub _plan_for_test_more {
484         my $can_plan = shift;
485
486         foreach my $program (@required_programs) {
487                 my $program_path = `which $program`;
488                 chomp $program_path;
489                 return plan(skip_all => "$program not available")
490                         unless -x $program_path;
491         }
492
493         foreach my $module (@required_modules) {
494                 eval qq{use $module};
495                 return plan(skip_all => "$module not available")
496                         if $@;
497         }
498
499         return plan(skip_all => "can't create $dir: $!")
500                 unless mkdir($dir);
501         return plan(skip_all => "can't remove $dir: $!")
502                 unless rmdir($dir);
503
504         return unless $can_plan;
505
506         return plan(tests => $total_tests);
507 }
508
509 # http://stackoverflow.com/questions/607282/whats-the-best-way-to-discover-all-subroutines-a-perl-module-has
510
511 use B qw/svref_2object/;
512
513 sub in_package {
514         my ($coderef, $package) = @_;
515         my $cv = svref_2object($coderef);
516         return if not $cv->isa('B::CV') or $cv->GV->isa('B::SPECIAL');
517         return $cv->GV->STASH->NAME eq $package;
518 }
519
520 sub list_module {
521         my $module = shift;
522         no strict 'refs';
523         return grep {
524                 defined &{"$module\::$_"} and in_package(\&{*$_}, $module)
525         } keys %{"$module\::"};
526 }
527
528
529 # support for xUnit-style testing, a la Test::Class
530
531 sub _startup {
532         my $can_plan = shift;
533         _plan_for_test_more($can_plan);
534         _generate_test_config();
535 }
536
537 sub _shutdown {
538         my $had_plan = shift;
539         done_testing() unless $had_plan;
540 }
541
542 sub _setup {
543         _generate_test_repo();
544 }
545
546 sub _teardown {
547         system "rm -rf $dir";
548 }
549
550 sub _runtests {
551         my @coderefs = (@_);
552         for (@coderefs) {
553                 _setup();
554                 $_->();
555                 _teardown();
556         }
557 }
558
559 sub _get_matching_test_subs {
560         my $re = shift;
561         no strict 'refs';
562         return map { \&{*$_} } grep { /$re/ } sort(list_module('main'));
563 }
564
565 sub _generate_test_config {
566         %config = IkiWiki::defaultconfig();
567         $config{rcs} = "cvs";
568         $config{srcdir} = "$dir/src";
569         $config{cvsrepo} = "$dir/repo";
570         $config{cvspath} = "ikiwiki";
571         IkiWiki::loadplugins();
572         IkiWiki::checkconfig();
573 }
574
575 sub _generate_test_repo {
576         die "can't create $dir: $!"
577                 unless mkdir($dir);
578
579         my $cvs = "cvs -d $config{cvsrepo}";
580         my $dn = ">/dev/null";
581         system "$cvs init $dn";
582         system "mkdir $dir/$config{cvspath} $dn";
583         system "cd $dir/$config{cvspath} && "
584                 . "$cvs import -m import $config{cvspath} VENDOR RELEASE $dn";
585         system "rm -rf $dir/$config{cvspath} $dn";
586         system "$cvs co -d $config{srcdir} $config{cvspath} $dn";
587 }
588
589 sub add_and_commit {
590         my ($file, $message, $contents) = @_;
591         writefile($file, $config{srcdir}, $contents);
592         IkiWiki::rcs_add($file);
593         IkiWiki::rcs_commit(
594                 file => $file,
595                 message => $message,
596                 token => "moo",
597         );
598 }
599
600 sub can_mkdir {
601         my $dir = shift;
602         ok(
603                 mkdir($config{srcdir} . "/$dir"),
604                 qq{can mkdir $dir},
605         );
606 }
607
608 sub is_newly_added { _newly_added_or_not(shift, 1) }
609 sub isnt_newly_added { _newly_added_or_not(shift, 0) }
610 sub _newly_added_or_not {
611         my ($file, $expected_new) = @_;
612         my ($func, $word);
613         if ($expected_new) {
614                 $func = \&Test::More::is;
615                 $word = q{is};
616         }
617         else {
618                 $func = \&Test::More::isnt;
619                 $word = q{isn't};
620         }
621         $func->(
622                 IkiWiki::Plugin::cvs::cvs_info("Repository revision", $file),
623                 '1.1',
624                 qq{$file $word newly added to CVS},
625         );
626 }
627
628 sub is_in_keyword_substitution_mode {
629         my ($file, $mode) = @_;
630         is(
631                 IkiWiki::Plugin::cvs::cvs_info("Sticky Options", $file),
632                 $mode,
633                 qq{$file is in CVS with expected keyword substitution mode},
634         );
635 }
636
637 sub is_total_number_of_changes {
638         my ($changes, $expected_total) = @_;
639         is(
640                 $#{$changes},
641                 $expected_total - 1,
642                 qq{total commits == $expected_total},
643         );
644 }
645
646 sub is_most_recent_change {
647         my ($changes, $page, $message) = @_;
648         is(
649                 $changes->[0]{message}[0]{"line"},
650                 $message,
651                 q{most recent commit's first message line matches},
652         );
653         is(
654                 $changes->[0]{pages}[0]{"page"},
655                 $page,
656                 q{most recent commit's first pagename matches},
657         );
658 }
659
660 sub stripext {
661         my ($file, $extension) = @_;
662         $extension = '\..+?' unless defined $extension;
663         $file =~ s|$extension$||g;
664         return $file;
665 }