Core commit. Compile and run on the OpenPandora
[mupen64plus-pandora.git] / source / mupen64plus-core / tools / regtests / regression-video.py
1 #!/usr/bin/env python
2
3 #/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
4 # *   Mupen64plus - regression-video.py                                     *
5 # *   Mupen64Plus homepage: http://code.google.com/p/mupen64plus/           *
6 # *   Copyright (C) 2008-2012 Richard Goedeken                              *
7 # *                                                                         *
8 # *   This program is free software; you can redistribute it and/or modify  *
9 # *   it under the terms of the GNU General Public License as published by  *
10 # *   the Free Software Foundation; either version 2 of the License, or     *
11 # *   (at your option) any later version.                                   *
12 # *                                                                         *
13 # *   This program is distributed in the hope that it will be useful,       *
14 # *   but WITHOUT ANY WARRANTY; without even the implied warranty of        *
15 # *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
16 # *   GNU General Public License for more details.                          *
17 # *                                                                         *
18 # *   You should have received a copy of the GNU General Public License     *
19 # *   along with this program; if not, write to the                         *
20 # *   Free Software Foundation, Inc.,                                       *
21 # *   51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.          *
22 # * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
23
24 from optparse import OptionParser
25 from threading import Thread
26 from datetime import date
27 import subprocess
28 import commands
29 import shutil
30 import stat
31 import sys
32 import os
33
34 # set global report string
35 report = "Mupen64Plus Regression Test report\n----------------------------------\n"
36
37 #******************************************************************************
38 # main functions
39 #
40
41 def main(rootdir, cfgfile, nobuild, noemail):
42     global report
43     # set up child directory paths
44     srcdir = os.path.join(rootdir, "source")
45     shotdir = os.path.join(rootdir, "current")
46     refdir = os.path.join(rootdir, "reference")
47     archivedir = os.path.join(rootdir, "archive")
48     # run the test procedure
49     tester = RegTester(rootdir, srcdir, shotdir)
50     rval = 0
51     while True:
52         # Step 1: load the test config file
53         if not tester.LoadConfig(cfgfile):
54             rval = 1
55             break
56         # Step 2: check out from Mercurial
57         if not nobuild:
58             if not tester.CheckoutSource(srcdir):
59                 rval = 2
60                 break
61         # Step 3: run test builds
62         if not nobuild:
63             for modname in tester.modulesAndParams:
64                 module = tester.modulesAndParams[modname]
65                 if "testbuilds" not in module:
66                     continue
67                 modurl = module["url"]
68                 modfilename = modurl.split('/')[-1]
69                 testlist = [ name.strip() for name in module["testbuilds"].split(',') ]
70                 makeparams = [ params.strip() for params in module["testbuildparams"].split(',') ]
71                 if len(testlist) != len(makeparams):
72                     report += "Config file error for test builds in %s.  Build name list and makefile parameter list have different lengths.\n" % modname
73                 testbuilds = min(len(testlist), len(makeparams))
74                 for i in range(testbuilds):
75                     buildname = testlist[i]
76                     buildmake = makeparams[i]
77                     BuildSource(srcdir, modfilename, modname, buildname, buildmake, module["outputfiles"], True)
78         # Step 4: build the binary for the video regression test
79         if not nobuild:
80             for modname in tester.modulesAndParams:
81                 module = tester.modulesAndParams[modname]
82                 modurl = module["url"]
83                 modfilename = modurl.split('/')[-1]
84                 videobuild = module["videobuild"]
85                 videomake = module["videobuildparams"]
86                 if not BuildSource(srcdir, modfilename, modname, videobuild, videomake, module["outputfiles"], False):
87                     rval = 3
88                     break
89             if rval != 0:
90                 break
91         # Step 5: run the tests, check the results
92         if not tester.RunTests():
93             rval = 4
94             break
95         if not tester.CheckResults(refdir):
96             rval = 5
97             break
98         # test procedure is finished
99         break
100     # Step 6: send email report and archive the results
101     if not noemail:
102         if not tester.SendReport():
103                 rval = 6
104     if not tester.ArchiveResults(archivedir):
105         rval = 7
106     # all done with test process
107     return rval
108
109 #******************************************************************************
110 # Checkout & build functions
111 #
112
113 def BuildSource(srcdir, moddir, modname, buildname, buildmake, outputfiles, istest):
114     global report
115     makepath = os.path.join(srcdir, moddir,  "projects", "unix")
116     # print build report message and clear counters
117     testbuildcommand = "make -C %s %s" % (makepath, buildmake)
118     if istest:
119         report += "Running %s test build \"%s\"\n" % (modname, buildname)
120     else:
121         report += "Building %s \"%s\" for video test\n" % (modname, buildname)
122     warnings = 0
123     errors = 0
124     # run make and capture the output
125     output = commands.getoutput(testbuildcommand)
126     makelines = output.split("\n")
127     # print warnings and errors
128     for line in makelines:
129         if "error:" in line:
130             report += "    " + line + "\n"
131             errors += 1
132         if "warning:" in line:
133             report += "    " + line + "\n"
134             warnings += 1
135     report += "%i errors. %i warnings.\n" % (errors, warnings)
136     if errors > 0 and not istest:
137         return False
138     # check for output files
139     for filename in outputfiles.split(','):
140         if not os.path.exists(os.path.join(makepath, filename)):
141             report += "Build failed: '%s' not found\n" % filename
142             errors += 1
143     if errors > 0 and not istest:
144         return False
145     # clean up if this was a test
146     if istest:
147         os.system("make -C %s clean" % makepath)
148     # if this wasn't a test, then copy our output files and data files
149     if not istest:
150         for filename in outputfiles.split(','):
151             shutil.move(os.path.join(makepath, filename), srcdir)
152         datapath = os.path.join(srcdir, moddir, "data")
153         if os.path.isdir(datapath):
154             copytree(datapath, os.path.join(srcdir, "data"))
155     # build was successful!
156     return True
157
158 #******************************************************************************
159 # Test execution classes
160 #
161 class RegTester:
162     def __init__(self, rootdir, bindir, screenshotdir):
163         self.rootdir = rootdir
164         self.bindir = bindir
165         self.screenshotdir = screenshotdir
166         self.generalParams = { }
167         self.gamesAndParams = { }
168         self.modulesAndParams = { }
169         self.videoplugins = [ "mupen64plus-video-rice.so" ]
170         self.thisdate = str(date.today())
171
172     def LoadConfig(self, filename):
173         global report
174         # read the config file
175         report += "\nLoading regression test configuration.\n"
176         try:
177             cfgfile = open(os.path.join(self.rootdir, filename), "r")
178             cfglines = cfgfile.read().split("\n")
179             cfgfile.close()
180         except Exception, e:
181             report += "Error in RegTestConfigParser::LoadConfig(): %s" % e
182             return False
183         # parse the file
184         GameFilename = None
185         ModuleName = None
186         for line in cfglines:
187             # strip leading and trailing whitespace
188             line = line.strip()
189             # test for comment
190             if len(line) == 0 or line[0] == '#':
191                 continue
192             # test for new game filename
193             if line[0] == '[' and line [-1] == ']':
194                 GameFilename = line[1:-1]
195                 if GameFilename in self.gamesAndParams:
196                     report += "    Warning: Config file '%s' contains duplicate game entry '%s'\n" % (filename, GameFilename)
197                 else:
198                     self.gamesAndParams[GameFilename] = { }
199                 continue
200             # test for new source module build
201             if line[0] == '{' and line [-1] == '}':
202                 ModuleName = line[1:-1]
203                 if ModuleName in self.modulesAndParams:
204                     report += "    Warning: Config file '%s' contains duplicate source module '%s'\n" % (filename, ModuleName)
205                 else:
206                     self.modulesAndParams[ModuleName] = { }
207                 continue
208             # print warning and continue if it's not a (key = value) pair
209             pivot = line.find('=')
210             if pivot == -1:
211                 report += "    Warning: Config file '%s' contains unrecognized line: '%s'\n" % (filename, line)
212                 continue
213             # parse key, value
214             key = line[:pivot].strip().lower()
215             value = line[pivot+1:].strip()
216             if ModuleName is None:
217                 paramDict = self.generalParams
218             elif GameFilename is None:
219                 paramDict = self.modulesAndParams[ModuleName]
220             else:
221                 paramDict = self.gamesAndParams[GameFilename]
222             if key in paramDict:
223                 report += "    Warning: duplicate key '%s'\n" % key
224                 continue
225             paramDict[key] = value
226         # check for required parameters
227         if "rompath" not in self.generalParams:
228             report += "    Error: rompath is not given in config file\n"
229             return False
230         # config is loaded
231         return True
232
233     def CheckoutSource(self, srcdir):
234         global report
235         # remove any current source directory
236         if not deltree(srcdir):
237             return False
238         os.mkdir(srcdir)
239         os.mkdir(os.path.join(srcdir, "data"))
240         # loop through all of the source modules
241         for modname in self.modulesAndParams:
242             module = self.modulesAndParams[modname]
243             if "url" not in module:
244                 report += "Error: no Hg repository URL for module %s\n\n" % modname
245                 return False
246             modurl = module["url"]
247             modfilename = modurl.split("/")[-1]
248             # call Hg to checkout Mupen64Plus source module
249             output = commands.getoutput("hg clone --cwd %s %s" % (srcdir, modurl))
250             # parse the output
251             lastline = output.split("\n")[-1]
252             if "0 files unresolved" not in lastline:
253                 report += "Hg Error: %s\n\n" % lastline
254                 return False
255             # get the revision info
256             RevFound = False
257             output = commands.getoutput("hg tip -R %s" % os.path.join(srcdir, modfilename))
258             for line in output.split('\n'):
259                 words = line.split()
260                 if len(words) == 2 and words[0] == 'changeset:':
261                     report += "Hg Checkout %s: changeset %s\n" % (modfilename, words[1])
262                     RevFound = True
263             if not RevFound:
264                 report += "Hg Error: couldn't find revision information\n\n"
265                 return False
266         return True
267
268     def RunTests(self):
269         global report
270         rompath = self.generalParams["rompath"]
271         if not os.path.exists(rompath):
272             report += "    Error: ROM directory '%s' does not exist!\n" % rompath
273             return False
274         # Remove any current screenshot directory
275         if not deltree(self.screenshotdir):
276             return False
277         # Data initialization and start message
278         os.mkdir(self.screenshotdir)
279         for plugin in self.videoplugins:
280             videoname = plugin[:plugin.find('.')]
281             os.mkdir(os.path.join(self.screenshotdir, videoname))
282         report += "\nRunning regression tests on %i games.\n" % len(self.gamesAndParams)
283         # loop over each game filename given in regtest config file
284         for GameFilename in self.gamesAndParams:
285             GameParams = self.gamesAndParams[GameFilename]
286             # if no screenshots parameter given for this game then skip it
287             if "screenshots" not in GameParams:
288                 report += "    Warning: no screenshots taken for game '%s'\n" % GameFilename
289                 continue
290             # make a list of screenshots and check it
291             shotlist = [ str(int(framenum.strip())) for framenum in GameParams["screenshots"].split(',') ]
292             if len(shotlist) < 1 or (len(shotlist) == 1 and shotlist[0] == '0'):
293                 report += "    Warning: invalid screenshot list for game '%s'\n" % GameFilename
294                 continue
295             # run a test for each video plugin
296             for plugin in self.videoplugins:
297                 videoname = plugin[:plugin.find('.')]
298                 # check if this plugin should be skipped
299                 if "skipvideo" in GameParams:
300                     skipit = False
301                     skiplist = [ name.strip() for name in GameParams["skipvideo"].split(',') ]
302                     for skiptag in skiplist:
303                         if skiptag.lower() in plugin.lower():
304                             skipit = True
305                     if skipit:
306                         continue
307                 # construct the command line
308                 exepath = os.path.join(self.bindir, "mupen64plus")
309                 exeparms = [ "--corelib", os.path.join(self.bindir, "libmupen64plus.so.2") ]
310                 exeparms += [ "--testshots",  ",".join(shotlist) ]
311                 exeparms += [ "--sshotdir", os.path.join(self.screenshotdir, videoname) ]
312                 exeparms += [ "--plugindir", self.bindir ]
313                 exeparms += [ "--datadir", os.path.join(self.bindir, "data") ]
314                 myconfig = os.path.join(self.rootdir, "config")
315                 exeparms += [ "--configdir", myconfig ]
316                 exeparms += [ "--gfx", plugin ]
317                 exeparms += [ "--emumode", "2" ]
318                 exeparms += [ os.path.join(rompath, GameFilename) ]
319                 # run it, but if it takes too long print an error and kill it
320                 testrun = RegTestRunner(exepath, exeparms)
321                 testrun.start()
322                 testrun.join(60.0)
323                 if testrun.isAlive():
324                     report += "    Error: Test run timed out after 60 seconds:  '%s'\n" % " ".join(exeparms)
325                     os.kill(testrun.pid, 9)
326                     testrun.join(10.0)
327                 
328         # all tests have been run
329         return True                
330
331     def CheckResults(self, refdir):
332         global report
333         # print message
334         warnings = 0
335         errors = 0
336         report += "\nChecking regression test results\n"
337         # get lists of files in the reference folders
338         refshots = { }
339         if not os.path.exists(refdir):
340             os.mkdir(refdir)
341         for plugin in self.videoplugins:
342             videoname = plugin[:plugin.find('.')]
343             videodir = os.path.join(refdir, videoname)
344             if not os.path.exists(videodir):
345                 os.mkdir(videodir)
346                 refshots[videoname] = [ ]
347             else:
348                 refshots[videoname] = [ filename for filename in os.listdir(videodir) ]
349         # get lists of files produced by current test runs
350         newshots = { }
351         for plugin in self.videoplugins:
352             videoname = plugin[:plugin.find('.')]
353             videodir = os.path.join(self.screenshotdir, videoname)
354             if not os.path.exists(videodir):
355                 newshots[videoname] = [ ]
356             else:
357                 newshots[videoname] = [ filename for filename in os.listdir(videodir) ]
358         # make list of matching ref/test screenshots, and look for missing reference screenshots
359         checklist = { }
360         for plugin in self.videoplugins:
361             videoname = plugin[:plugin.find('.')]
362             checklist[videoname] = [ ]
363             for filename in newshots[videoname]:
364                 if filename in refshots[videoname]:
365                     checklist[videoname] += [ filename ]
366                 else:
367                     report += "    Warning: reference screenshot '%s/%s' missing. Copying from current test run\n" % (videoname, filename)
368                     shutil.copy(os.path.join(self.screenshotdir, videoname, filename), os.path.join(refdir, videoname))
369                     warnings += 1
370         # look for missing test screenshots
371         for plugin in self.videoplugins:
372             videoname = plugin[:plugin.find('.')]
373             for filename in refshots[videoname]:
374                 if filename not in newshots[videoname]:
375                     report += "    Error: Test screenshot '%s/%s' missing.\n" % (videoname, filename)
376                     errors += 1
377         # do image comparisons
378         for plugin in self.videoplugins:
379             videoname = plugin[:plugin.find('.')]
380             for filename in checklist[videoname]:
381                 refimage = os.path.join(refdir, videoname, filename)
382                 testimage = os.path.join(self.screenshotdir, videoname, filename)
383                 diffimage = os.path.join(self.screenshotdir, videoname, os.path.splitext(filename)[0] + "_DIFF.png")
384                 cmd = ("/usr/bin/compare", "-metric", "PSNR", refimage, testimage, diffimage)
385                 pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT).stdout
386                 similarity = pipe.read().strip()
387                 pipe.close()
388                 try:
389                     db = float(similarity)
390                 except:
391                     db = 0
392                 if db > 60.0:
393                     os.unlink(diffimage)
394                 else:
395                     report += "    Warning: test image '%s/%s' does not match reference.  PSNR = %s\n" % (videoname, filename, similarity)
396                     warnings += 1
397         # give report and return
398         report += "%i errors. %i warnings.\n" % (errors, warnings)
399         return True
400
401     def SendReport(self):
402         global report
403         # if there are no email addresses in the config file, then just we're done
404         if "sendemail" not in self.generalParams:
405             return True
406         if len(self.generalParams["sendemail"]) < 5:
407             return True
408         # construct the email message header
409         emailheader = "To: %s\n" % self.generalParams["sendemail"]
410         emailheader += "From: Mupen64Plus-Tester@fascination.homelinux.net\n"
411         emailheader += "Subject: %s Regression Test Results for Mupen64Plus\n" % self.thisdate
412         emailheader += "Reply-to: do-not-reply@fascination.homelinux.net\n"
413         emailheader += "Content-Type: text/plain; charset=UTF-8\n"
414         emailheader += "Content-Transfer-Encoding: 8bit\n\n"
415         # open a pipe to sendmail and dump our report
416         try:
417             pipe = subprocess.Popen(("/usr/sbin/sendmail", "-t"), stdin=subprocess.PIPE).stdin
418             pipe.write(emailheader)
419             pipe.write(report)
420             pipe.close()
421         except Exception, e:
422             report += "Exception encountered when calling sendmail: '%s'\n" % e
423             report += "Email header:\n%s\n" % emailheader
424             return False
425         return True
426
427     def ArchiveResults(self, archivedir):
428         global report
429         # create archive dir if it doesn't exist
430         if not os.path.exists(archivedir):
431             os.mkdir(archivedir)
432         # move the images into a subdirectory of 'archive' given by date
433         subdir = os.path.join(archivedir, self.thisdate)
434         if os.path.exists(subdir):
435             if not deltree(subdir):
436                 return False
437         if os.path.exists(self.screenshotdir):
438             shutil.move(self.screenshotdir, subdir)
439         # copy the report into the archive directory
440         f = open(os.path.join(archivedir, "report_%s.txt" % self.thisdate), "w")
441         f.write(report)
442         f.close()
443         # archival is complete
444         return True
445
446
447 class RegTestRunner(Thread):
448     def __init__(self, exepath, exeparms):
449         self.exepath = exepath
450         self.exeparms = exeparms
451         self.pid = 0
452         self.returnval = None
453         Thread.__init__(self)
454
455     def run(self):
456         # start the process
457         testprocess = subprocess.Popen([self.exepath] + self.exeparms)
458         # get the PID of the new test process
459         self.pid = testprocess.pid
460         # wait for the test to complete
461         self.returnval = testprocess.wait()
462
463
464 #******************************************************************************
465 # Generic helper functions
466 #
467
468 def deltree(dirname):
469     global report
470     if not os.path.exists(dirname):
471         return True
472     try:
473         for path in (os.path.join(dirname, filename) for filename in os.listdir(dirname)):
474             if os.path.isdir(path):
475                 if not deltree(path):
476                     return False
477             else:
478                 os.unlink(path)
479         os.rmdir(dirname)
480     except Exception, e:
481         report += "Error in deltree(): %s\n" % e
482         return False
483
484     return True
485
486 def copytree(srcpath, dstpath):
487     if not os.path.isdir(srcpath) or not os.path.isdir(dstpath):
488         return False
489     for filename in os.listdir(srcpath):
490         filepath = os.path.join(srcpath, filename)
491         if os.path.isdir(filepath):
492             subdstpath = os.path.join(dstpath, filename)
493             os.mkdir(subdstpath)
494             copytree(filepath, subdstpath)
495         else:
496             shutil.copy(filepath, dstpath)
497     return True
498
499 #******************************************************************************
500 # main function call for standard script execution
501 #
502
503 if __name__ == "__main__":
504     # parse the command-line arguments
505     parser = OptionParser()
506     parser.add_option("-n", "--nobuild", dest="nobuild", default=False, action="store_true",
507                       help="Assume source code is present; don't check out and build")
508     parser.add_option("-e", "--noemail", dest="noemail", default=False, action="store_true",
509                       help="don't send email or archive results")
510     parser.add_option("-t", "--testpath", dest="testpath",
511                       help="Set root of testing directory to PATH", metavar="PATH")
512     parser.add_option("-c", "--cfgfile", dest="cfgfile", default="daily-tests.cfg",
513                       help="Use regression test config file FILE", metavar="FILE")
514     (opts, args) = parser.parse_args()
515     # check test path
516     if opts.testpath is None:
517         # change directory to the directory containing this script and set root test path to "."
518         scriptdir = os.path.dirname(sys.argv[0])
519         os.chdir(scriptdir)
520         rootdir = "."
521     else:
522         rootdir = opts.testpath
523     # call the main function
524     rval = main(rootdir, opts.cfgfile, opts.nobuild, opts.noemail)
525     sys.exit(rval)
526
527