1#!/usr/bin/perl -w
2
3# Copyright (C) 2007, 2008 Apple Inc.  All rights reserved.
4#
5# Redistribution and use in source and binary forms, with or without
6# modification, are permitted provided that the following conditions
7# are met:
8#
9# 1.  Redistributions of source code must retain the above copyright
10#     notice, this list of conditions and the following disclaimer. 
11# 2.  Redistributions in binary form must reproduce the above copyright
12#     notice, this list of conditions and the following disclaimer in the
13#     documentation and/or other materials provided with the distribution. 
14# 3.  Neither the name of Apple Computer, Inc. ("Apple") nor the names of
15#     its contributors may be used to endorse or promote products derived
16#     from this software without specific prior written permission. 
17#
18# THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY
19# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21# DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY
22# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
25# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
27# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28
29# This script attempts to find the point at which a regression (or progression)
30# of behavior occurred by searching WebKit nightly builds.
31
32# To override the location where the nightly builds are downloaded or the path
33# to the Safari web browser, create a ~/.bisect-buildsrc file with one or more of
34# the following lines (use "~/" to specify a path from your home directory):
35#
36# $branch = "branch-name";
37# $nightlyDownloadDirectory = "~/path/to/nightly/downloads";
38# $safariPath = "/path/to/Safari.app";
39
40use strict;
41
42use File::Basename;
43use File::Path;
44use File::Spec;
45use File::Temp qw(tempfile);
46use Getopt::Long;
47use Time::HiRes qw(usleep);
48
49sub createTempFile($);
50sub downloadNightly($$$);
51sub findMacOSXVersion();
52sub findNearestNightlyIndex(\@$$);
53sub findSafariVersion($);
54sub loadSettings();
55sub makeNightlyList($$$$);
56sub max($$) { return $_[0] > $_[1] ? $_[0] : $_[1]; }
57sub mountAndRunNightly($$$$);
58sub parseRevisions($$;$);
59sub printStatus($$$);
60sub promptForTest($);
61
62loadSettings();
63
64my %validBranches = map { $_ => 1 } qw(feature-branch trunk);
65my $branch = $Settings::branch;
66my $nightlyDownloadDirectory = $Settings::nightlyDownloadDirectory;
67my $safariPath = $Settings::safariPath;
68
69my @nightlies;
70
71my $isProgression;
72my $localOnly;
73my @revisions;
74my $sanityCheck;
75my $showHelp;
76my $testURL;
77
78# Fix up -r switches in @ARGV
79@ARGV = map { /^(-r)(.+)$/ ? ($1, $2) : $_ } @ARGV;
80
81my $result = GetOptions(
82    "b|branch=s"             => \$branch,
83    "d|download-directory=s" => \$nightlyDownloadDirectory,
84    "h|help"                 => \$showHelp,
85    "l|local!"               => \$localOnly,
86    "p|progression!"         => \$isProgression,
87    "r|revisions=s"          => \&parseRevisions,
88    "safari-path=s"          => \$safariPath,
89    "s|sanity-check!"        => \$sanityCheck,
90);
91$testURL = shift @ARGV;
92
93$branch = "feature-branch" if $branch eq "feature";
94if (!exists $validBranches{$branch}) {
95    print STDERR "ERROR: Invalid branch '$branch'\n";
96    $showHelp = 1;
97}
98
99if (!$result || $showHelp || scalar(@ARGV) > 0) {
100    print STDERR "Search WebKit nightly builds for changes in behavior.\n";
101    print STDERR "Usage: " . basename($0) . " [options] [url]\n";
102    print STDERR <<END;
103  [-b|--branch name]             name of the nightly build branch (default: trunk)
104  [-d|--download-directory dir]  nightly build download directory (default: ~/Library/Caches/WebKit-Nightlies)
105  [-h|--help]                    show this help message
106  [-l|--local]                   only use local (already downloaded) nightlies
107  [-p|--progression]             searching for a progression, not a regression
108  [-r|--revision M[:N]]          specify starting (and optional ending) revisions to search
109  [--safari-path path]           path to Safari application bundle (default: /Applications/Safari.app)
110  [-s|--sanity-check]            verify both starting and ending revisions before bisecting
111END
112    exit 1;
113}
114
115my $nightlyWebSite = "http://nightly.webkit.org";
116my $nightlyBuildsURLBase = $nightlyWebSite . File::Spec->catdir("/builds", $branch, "mac");
117my $nightlyFilesURLBase = $nightlyWebSite . File::Spec->catdir("/files", $branch, "mac");
118
119$nightlyDownloadDirectory = glob($nightlyDownloadDirectory) if $nightlyDownloadDirectory =~ /^~/;
120$safariPath = glob($safariPath) if $safariPath =~ /^~/;
121$safariPath = File::Spec->catdir($safariPath, "Contents/MacOS/Safari") if $safariPath =~ m#\.app/*#;
122
123$nightlyDownloadDirectory = File::Spec->catdir($nightlyDownloadDirectory, $branch);
124if (! -d $nightlyDownloadDirectory) {
125    mkpath($nightlyDownloadDirectory, 0, 0755) || die "Could not create $nightlyDownloadDirectory: $!";
126}
127
128@nightlies = makeNightlyList($localOnly, $nightlyDownloadDirectory, findMacOSXVersion(), findSafariVersion($safariPath));
129
130my $startIndex = $revisions[0] ? findNearestNightlyIndex(@nightlies, $revisions[0], 'ceil') : 0;
131my $endIndex = $revisions[1] ? findNearestNightlyIndex(@nightlies, $revisions[1], 'floor') : $#nightlies;
132
133my $tempFile = createTempFile($testURL);
134
135if ($sanityCheck) {
136    my $didReproduceBug;
137
138    do {
139        printf "\nChecking starting revision r%s...\n",
140            $nightlies[$startIndex]->{rev};
141        downloadNightly($nightlies[$startIndex]->{file}, $nightlyFilesURLBase, $nightlyDownloadDirectory);
142        mountAndRunNightly($nightlies[$startIndex]->{file}, $nightlyDownloadDirectory, $safariPath, $tempFile);
143        $didReproduceBug = promptForTest($nightlies[$startIndex]->{rev});
144        $startIndex-- if $didReproduceBug < 0;
145    } while ($didReproduceBug < 0);
146    die "ERROR: Bug reproduced in starting revision!  Do you need to test an earlier revision or for a progression?"
147        if $didReproduceBug && !$isProgression;
148    die "ERROR: Bug not reproduced in starting revision!  Do you need to test an earlier revision or for a regression?"
149        if !$didReproduceBug && $isProgression;
150
151    do {
152        printf "\nChecking ending revision r%s...\n",
153            $nightlies[$endIndex]->{rev};
154        downloadNightly($nightlies[$endIndex]->{file}, $nightlyFilesURLBase, $nightlyDownloadDirectory);
155        mountAndRunNightly($nightlies[$endIndex]->{file}, $nightlyDownloadDirectory, $safariPath, $tempFile);
156        $didReproduceBug = promptForTest($nightlies[$endIndex]->{rev});
157        $endIndex++ if $didReproduceBug < 0;
158    } while ($didReproduceBug < 0);
159    die "ERROR: Bug NOT reproduced in ending revision!  Do you need to test a later revision or for a progression?"
160        if !$didReproduceBug && !$isProgression;
161    die "ERROR: Bug reproduced in ending revision!  Do you need to test a later revision or for a regression?"
162        if $didReproduceBug && $isProgression;
163}
164
165printStatus($nightlies[$startIndex]->{rev}, $nightlies[$endIndex]->{rev}, $isProgression);
166
167my %brokenRevisions = ();
168while (abs($endIndex - $startIndex) > 1) {
169    my $index = $startIndex + int(($endIndex - $startIndex) / 2);
170
171    my $didReproduceBug;
172    do {
173        if (exists $nightlies[$index]) {
174            my $buildsLeft = max(max(0, $endIndex - $index - 1), max(0, $index - $startIndex - 1));
175            my $plural = $buildsLeft == 1 ? "" : "s";
176            printf "\nChecking revision r%s (%d build%s left to test after this)...\n", $nightlies[$index]->{rev}, $buildsLeft, $plural;
177            downloadNightly($nightlies[$index]->{file}, $nightlyFilesURLBase, $nightlyDownloadDirectory);
178            mountAndRunNightly($nightlies[$index]->{file}, $nightlyDownloadDirectory, $safariPath, $tempFile);
179            $didReproduceBug = promptForTest($nightlies[$index]->{rev});
180        }
181        if ($didReproduceBug < 0) {
182            $brokenRevisions{$nightlies[$index]->{rev}} = $nightlies[$index]->{file};
183            delete $nightlies[$index];
184            $endIndex--;
185            $index = $startIndex + int(($endIndex - $startIndex) / 2);
186        }
187    } while ($didReproduceBug < 0);
188
189    if ($didReproduceBug && !$isProgression || !$didReproduceBug && $isProgression) {
190        $endIndex = $index;
191    } else {
192        $startIndex = $index;
193    }
194
195    print "\nBroken revisions skipped: r" . join(", r", keys %brokenRevisions) . "\n"
196        if scalar keys %brokenRevisions > 0;
197    printStatus($nightlies[$startIndex]->{rev}, $nightlies[$endIndex]->{rev}, $isProgression);
198}
199
200unlink $tempFile if $tempFile;
201
202exit 0;
203
204sub createTempFile($)
205{
206    my ($url) = @_;
207
208    return undef if !$url;
209
210    my ($fh, $tempFile) = tempfile(
211        basename($0) . "-XXXXXXXX",
212        DIR => File::Spec->tmpdir(),
213        SUFFIX => ".html",
214        UNLINK => 0,
215    );
216    print $fh "<meta http-equiv=\"refresh\" content=\"0; $url\">\n";
217    close($fh);
218
219    return $tempFile;
220}
221
222sub downloadNightly($$$)
223{
224    my ($filename, $urlBase, $directory) = @_;
225    my $path = File::Spec->catfile($directory, $filename);
226    if (! -f $path) {
227        print "Downloading $filename to $directory...\n";
228        `curl -# -o '$path' '$urlBase/$filename'`;
229    }
230}
231
232sub findMacOSXVersion()
233{
234    my $version;
235    open(SW_VERS, "-|", "/usr/bin/sw_vers") || die;
236    while (<SW_VERS>) {
237        $version = $1 if /^ProductVersion:\s+([^\s]+)/;
238    }
239    close(SW_VERS);
240    return $version;
241}
242
243sub findNearestNightlyIndex(\@$$)
244{
245    my ($nightlies, $revision, $round) = @_;
246
247    my $lowIndex = 0;
248    my $highIndex = $#{$nightlies};
249
250    return $highIndex if uc($revision) eq 'HEAD' || $revision >= $nightlies->[$highIndex]->{rev};
251    return $lowIndex if $revision <= $nightlies->[$lowIndex]->{rev};
252
253    while (abs($highIndex - $lowIndex) > 1) {
254        my $index = $lowIndex + int(($highIndex - $lowIndex) / 2);
255        if ($revision < $nightlies->[$index]->{rev}) {
256            $highIndex = $index;
257        } elsif ($revision > $nightlies->[$index]->{rev}) {
258            $lowIndex = $index;
259        } else {
260            return $index;
261        }
262    }
263
264    return ($round eq "floor") ? $lowIndex : $highIndex;
265}
266
267sub findSafariVersion($)
268{
269    my ($path) = @_;
270    my $versionPlist = File::Spec->catdir(dirname(dirname($path)), "version.plist");
271    my $version;
272    open(PLIST, "< $versionPlist") || die;
273    while (<PLIST>) {
274        if (m#^\s*<key>CFBundleShortVersionString</key>#) {
275            $version = <PLIST>;
276            $version =~ s#^\s*<string>([0-9.]+)[^<]*</string>\s*[\r\n]*#$1#;
277        }
278    }
279    close(PLIST);
280    return $version;
281}
282
283sub loadSettings()
284{
285    package Settings;
286
287    our $branch = "trunk";
288    our $nightlyDownloadDirectory = File::Spec->catdir($ENV{HOME}, "Library/Caches/WebKit-Nightlies");
289    our $safariPath = "/Applications/Safari.app";
290
291    my $rcfile = File::Spec->catdir($ENV{HOME}, ".bisect-buildsrc");
292    return if !-f $rcfile;
293
294    my $result = do $rcfile;
295    die "Could not parse $rcfile: $@" if $@;
296}
297
298sub makeNightlyList($$$$)
299{
300    my ($useLocalFiles, $localDirectory, $macOSXVersion, $safariVersion) = @_;
301    my @files;
302
303    if ($useLocalFiles) {
304        opendir(DIR, $localDirectory) || die "$!";
305        foreach my $file (readdir(DIR)) {
306            if ($file =~ /^WebKit-SVN-r([0-9]+)\.dmg$/) {
307                push(@files, +{ rev => $1, file => $file });
308            }
309        }
310        closedir(DIR);
311    } else {
312        open(NIGHTLIES, "curl -s $nightlyBuildsURLBase/all |") || die;
313
314        while (my $line = <NIGHTLIES>) {
315            chomp $line;
316            my ($revision, $timestamp, $url) = split(/,/, $line);
317            my $nightly = basename($url);
318            push(@files, +{ rev => $revision, file => $nightly });
319        }
320        close(NIGHTLIES);
321    }
322
323    if (eval "v$macOSXVersion" ge v10.5) {
324        if ($safariVersion eq "4 Public Beta") {
325            @files = grep { $_->{rev} >= 39682 } @files;
326        } elsif (eval "v$safariVersion" ge v3.2) {
327            @files = grep { $_->{rev} >= 37348 } @files;
328        } elsif (eval "v$safariVersion" ge v3.1) {
329            @files = grep { $_->{rev} >= 29711 } @files;
330        } elsif (eval "v$safariVersion" ge v3.0) {
331            @files = grep { $_->{rev} >= 25124 } @files;
332        } elsif (eval "v$safariVersion" ge v2.0) {
333            @files = grep { $_->{rev} >= 19594 } @files;
334        } else {
335            die "Requires Safari 2.0 or newer";
336        }
337    } elsif (eval "v$macOSXVersion" ge v10.4) {
338        if ($safariVersion eq "4 Public Beta") {
339            @files = grep { $_->{rev} >= 39682 } @files;
340        } elsif (eval "v$safariVersion" ge v3.2) {
341            @files = grep { $_->{rev} >= 37348 } @files;
342        } elsif (eval "v$safariVersion" ge v3.1) {
343            @files = grep { $_->{rev} >= 29711 } @files;
344        } elsif (eval "v$safariVersion" ge v3.0) {
345            @files = grep { $_->{rev} >= 19992 } @files;
346        } elsif (eval "v$safariVersion" ge v2.0) {
347            @files = grep { $_->{rev} >= 11976 } @files;
348        } else {
349            die "Requires Safari 2.0 or newer";
350        }
351    } else {
352        die "Requires Mac OS X 10.4 (Tiger) or 10.5 (Leopard)";
353    }
354
355    my $nightlycmp = sub { return $a->{rev} <=> $b->{rev}; };
356
357    return sort $nightlycmp @files;
358}
359
360sub mountAndRunNightly($$$$)
361{
362    my ($filename, $directory, $safari, $tempFile) = @_;
363    my $mountPath = "/Volumes/WebKit";
364    my $webkitApp = File::Spec->catfile($mountPath, "WebKit.app");
365    my $diskImage = File::Spec->catfile($directory, $filename);
366    my $devNull = File::Spec->devnull();
367
368    my $i = 0;
369    while (-e $mountPath) {
370        $i++;
371        usleep 100 if $i > 1;
372        `hdiutil detach '$mountPath' 2> $devNull`;
373        die "Could not unmount $diskImage at $mountPath" if $i > 100;
374    }
375    die "Can't mount $diskImage: $mountPath already exists!" if -e $mountPath;
376
377    print "Mounting disk image and running WebKit...\n";
378    `hdiutil attach '$diskImage'`;
379    $i = 0;
380    while (! -e $webkitApp) {
381        usleep 100;
382        $i++;
383        die "Could not mount $diskImage at $mountPath" if $i > 100;
384    }
385
386    my $frameworkPath;
387    if (-d "/Volumes/WebKit/WebKit.app/Contents/Frameworks") {
388        my $osXVersion = join('.', (split(/\./, findMacOSXVersion()))[0..1]);
389        $frameworkPath = "/Volumes/WebKit/WebKit.app/Contents/Frameworks/$osXVersion";
390    } else {
391        $frameworkPath = "/Volumes/WebKit/WebKit.app/Contents/Resources";
392    }
393
394    $tempFile ||= "";
395    `DYLD_FRAMEWORK_PATH=$frameworkPath WEBKIT_UNSET_DYLD_FRAMEWORK_PATH=YES $safari $tempFile`;
396
397    `hdiutil detach '$mountPath' 2> $devNull`;
398}
399
400sub parseRevisions($$;$)
401{
402    my ($optionName, $value, $ignored) = @_;
403
404    if ($value =~ /^r?([0-9]+|HEAD):?$/i) {
405        push(@revisions, $1);
406        die "Too many revision arguments specified" if scalar @revisions > 2;
407    } elsif ($value =~ /^r?([0-9]+):?r?([0-9]+|HEAD)$/i) {
408        $revisions[0] = $1;
409        $revisions[1] = $2;
410    } else {
411        die "Unknown revision '$value':  expected 'M' or 'M:N'";
412    }
413}
414
415sub printStatus($$$)
416{
417    my ($startRevision, $endRevision, $isProgression) = @_;
418    printf "\n%s: r%s  %s: r%s\n",
419        $isProgression ? "Fails" : "Works", $startRevision,
420        $isProgression ? "Works" : "Fails", $endRevision;
421}
422
423sub promptForTest($)
424{
425    my ($revision) = @_;
426    print "Did the bug reproduce in r$revision (yes/no/broken)? ";
427    my $answer = <STDIN>;
428    return 1 if $answer =~ /^(1|y.*)$/i;
429    return -1 if $answer =~ /^(-1|b.*)$/i; # Broken
430    return 0;
431}
432
433