chiark / gitweb /
configure.ac, debian/: Set up correct dependencies for GStreamer.
[disorder] / tests / dtest.py
1 #-*-python-*-
2 #
3 # This file is part of DisOrder.
4 # Copyright (C) 2007-2012 Richard Kettlewell
5 #
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 # GNU General Public License for more details.
15
16 # You should have received a copy of the GNU General Public License
17 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
18 #
19
20 """Utility module used by tests"""
21
22 import os,os.path,subprocess,sys,re,time,unicodedata,random,socket,traceback
23 import atexit,base64,errno
24
25 homelink = None
26
27 def fatal(s):
28     """Write an error message and exit"""
29     sys.stderr.write("ERROR: %s\n" % s)
30     sys.exit(1)
31
32 @atexit.register
33 def cleanup():
34     if homelink is not None:
35         os.unlink(homelink)
36
37 # Identify the top build directory
38 cwd = os.getcwd()
39 if os.path.exists("config.h"):
40     top_builddir = cwd
41 elif os.path.exists("../config.h"):
42     top_builddir = os.path.dirname(cwd)
43 else:
44     fatal("cannot identify build directory")
45
46 # Make sure the Python build directory is on the module search path
47 sys.path.insert(0, os.path.join(top_builddir, "python"))
48 import disorder
49
50 # Make sure the build directories are on the executable search path
51 ospath = os.environ["PATH"].split(os.pathsep)
52 ospath.insert(0, os.path.join(top_builddir, "server"))
53 ospath.insert(0, os.path.join(top_builddir, "clients"))
54 ospath.insert(0, os.path.join(top_builddir, "tests"))
55 os.environ["PATH"] = os.pathsep.join(ospath)
56
57 # Parse the makefile in the current directory to identify the source directory
58 top_srcdir = None
59 for l in file("Makefile"):
60     r = re.match("top_srcdir *= *(.*)",  l)
61     if r:
62         top_srcdir = r.group(1)
63         break
64 if not top_srcdir:
65     fatal("cannot identify source directory")
66
67 # The tests source directory must be on the module search path already since
68 # we found dtest.py
69
70 # -----------------------------------------------------------------------------
71
72 def copyfile(a,b):
73     """copyfile(A, B)
74 Copy A to B."""
75     open(b,"w").write(open(a).read())
76
77 def to_unicode(s):
78     """Convert UTF-8 to unicode.  A no-op if already unicode."""
79     if type(s) == unicode:
80         return s
81     else:
82         return unicode(s, "UTF-8")
83
84 def nfc(s):
85     """Convert UTF-8 string or unicode to NFC unicode."""
86     return  unicodedata.normalize("NFC", to_unicode(s))
87
88 def maketrack(s):
89     """maketrack(S)
90
91 Make track with relative path S exist"""
92     trackpath = "%s/%s" % (tracks, s)
93     trackdir = os.path.dirname(trackpath)
94     if not os.path.exists(trackdir):
95         os.makedirs(trackdir)
96     copyfile("%s/sounds/long.ogg" % top_srcdir, trackpath)
97     # We record the tracks we created so they can be tested against
98     # server responses.  We put them into NFC since that's what the server
99     # uses internally.
100     bits = nfc(s).split('/')
101     dp = tracks
102     for d in bits [0:-1]:
103         dd = "%s/%s" % (dp,  d)
104         if dp not in dirs_by_dir:
105             dirs_by_dir[dp] = []
106         if dd not in dirs_by_dir[dp]:
107             dirs_by_dir[dp].append(dd)
108         dp = "%s/%s" % (dp, d)
109     if dp not in files_by_dir:
110         files_by_dir[dp] = []
111     files_by_dir[dp].append("%s/%s" % (dp, bits[-1]))
112
113 def stdtracks():
114     # We create some tracks with non-ASCII characters in the name and
115     # we (currently) force UTF-8.
116     #
117     # On a traditional UNIX filesystem, that treats filenames as byte strings
118     # with special significant for '/', this should just work, though the
119     # names will look wrong to ls(1) in a non UTF-8 locale.
120     #
121     # On Apple HFS+ filenames normalized to a decomposed form that isn't quite
122     # NFD, so our attempts to have both normalized and denormalized filenames
123     # is frustrated.  Provided we test on traditional filesytsems too this
124     # shouldn't be a problem.
125     # (See http://developer.apple.com/qa/qa2001/qa1173.html)
126
127     global dirs_by_dir, files_by_dir
128     dirs_by_dir={}
129     files_by_dir={}
130     
131     # C3 8C = 00CC LATIN CAPITAL LETTER I WITH GRAVE
132     # (in NFC)
133     maketrack("Joe Bloggs/First Album/01:F\xC3\x8Crst track.ogg")
134
135     maketrack("Joe Bloggs/First Album/02:Second track.ogg")
136
137     # CC 81 = 0301 COMBINING ACUTE ACCENT
138     # (giving an NFD i-acute)
139     maketrack("Joe Bloggs/First Album/03:ThI\xCC\x81rd track.ogg")
140     # ...hopefuly giving C3 8D = 00CD LATIN CAPITAL LETTER I WITH ACUTE
141     maketrack("Joe Bloggs/First Album/04:Fourth track.ogg")
142     maketrack("Joe Bloggs/First Album/05:Fifth track.ogg")
143     maketrack("Joe Bloggs/Second Album/01:First track.ogg")
144     maketrack("Joe Bloggs/Second Album/02:Second track.ogg")
145     maketrack("Joe Bloggs/Second Album/03:Third track.ogg")
146     maketrack("Joe Bloggs/Second Album/04:Fourth track.ogg")
147     maketrack("Joe Bloggs/Second Album/05:Fifth track.ogg")
148     maketrack("Joe Bloggs/Third Album/01:First_track.ogg")
149     maketrack("Joe Bloggs/Third Album/02:Second_track.ogg")
150     maketrack("Joe Bloggs/Third Album/03:Third_track.ogg")
151     maketrack("Joe Bloggs/Third Album/04:Fourth_track.ogg")
152     maketrack("Joe Bloggs/Third Album/05:Fifth_track.ogg")
153     maketrack("Fred Smith/Boring/01:Dull.ogg")
154     maketrack("Fred Smith/Boring/02:Tedious.ogg")
155     maketrack("Fred Smith/Boring/03:Drum Solo.ogg")
156     maketrack("Fred Smith/Boring/04:Yawn.ogg")
157     maketrack("misc/blahblahblah.ogg")
158     maketrack("Various/Greatest Hits/01:Jim Whatever - Spong.ogg")
159     maketrack("Various/Greatest Hits/02:Joe Bloggs - Yadda.ogg")
160
161 def bindable(p):
162     """bindable(P)
163
164     Return True iff UDP port P is bindable, else False"""
165     s = socket.socket(socket.AF_INET,
166                       socket.SOCK_DGRAM,
167                       socket.IPPROTO_UDP)
168     try:
169         s.bind(("127.0.0.1", p))
170         s.close()
171         return True
172     except:
173         return False
174
175 def default_config(encoding="UTF-8"):
176     """Write the default config"""
177     open("%s/config" % testroot, "w").write(
178     """home %s
179 collection fs %s %s/tracks
180 scratch %s/scratch.ogg
181 queue_pad 5
182 stopword 01 02 03 04 05 06 07 08 09 10
183 stopword 1 2 3 4 5 6 7 8 9
184 stopword 11 12 13 14 15 16 17 18 19 20
185 stopword 21 22 23 24 25 26 27 28 29 30
186 stopword the a an and to too in on of we i am as im for is
187 username fred
188 password fredpass
189 plugins
190 plugins %s/plugins
191 plugins %s/plugins/.libs
192 player *.mp3 execraw disorder-decode
193 player *.ogg execraw disorder-decode
194 player *.wav execraw disorder-decode
195 player *.flac execraw disorder-decode
196 tracklength *.mp3 disorder-tracklength
197 tracklength *.ogg disorder-tracklength
198 tracklength *.wav disorder-tracklength
199 tracklength *.flac disorder-tracklength
200 api rtp
201 broadcast 127.0.0.1 %d
202 broadcast_from 127.0.0.1 %d
203 mail_sender no.such.user.sorry@greenend.org.uk
204 """ % (homelink, encoding, testroot, testroot, top_builddir, top_builddir,
205        port, port + 1))
206
207 def common_setup():
208     global homelink
209     remove_dir(testroot)
210     os.makedirs(testroot)
211     os.makedirs("%s/home" % testroot)
212     # Establish a symlink to the home directory, to keep the socket pathnames
213     # short enough.
214     tmpdir = "/tmp"
215     for v in ["TMPDIR", "TMP"]:
216         try: tmpdir = os.environ[v]
217         except KeyError: pass
218         else: break
219     for i in xrange(1024):
220         r = base64.b64encode(os.urandom(9)).replace("/", "_")
221         f = "%s/disorder-home.%s" % (tmpdir, r)
222         try:
223             os.symlink("%s/home" % testroot, f)
224         except OSError, e:
225             if e.errno != errno.EEXIST: raise
226         else:
227             homelink = f
228             break
229     else:
230         fatal("failed to make home link")
231     # Choose a port
232     global port
233     port = random.randint(49152, 65530)
234     while not bindable(port + 1):
235         print "port %d is not bindable, trying another" % (port + 1)
236         port = random.randint(49152, 65530)
237     # Log anything sent to that port
238     packetlog = "%s/packetlog" % testroot
239     subprocess.Popen(["disorder-udplog",
240                       "--output", packetlog,
241                       "127.0.0.1", "%d" % port])
242     # disorder-udplog will quit when its parent process terminates
243     copyfile("%s/sounds/scratch.ogg" % top_srcdir,
244              "%s/scratch.ogg" % testroot)
245     default_config()
246
247 def start_daemon():
248     """start_daemon()
249
250 Start the daemon."""
251     global daemon, errs, port
252     assert daemon == None, "no daemon running"
253     if not bindable(port + 1):
254         print "waiting for port %d to become bindable again..." % (port + 1)
255         time.sleep(1)
256         while not bindable(port + 1):
257             time.sleep(1)
258     print " starting daemon"
259     # remove the socket if it exists
260     socket = "%s/socket" % homelink
261     if os.path.exists(socket):
262         os.remove(socket)
263     daemon = subprocess.Popen(["disorderd",
264                                "--foreground",
265                                "--config", "%s/config" % testroot],
266                               stderr=errs)
267     # Wait for the socket to be created
268     waited = 0
269     sleep_resolution = 0.125
270     while not os.path.exists(socket):
271         rc = daemon.poll()
272         if rc is not None:
273             print "FATAL: daemon failed to start up"
274             sys.exit(1)
275         waited += sleep_resolution
276         if sleep_resolution < 1:
277             sleep_resolution *= 2
278         if waited == 1:
279             print "  waiting for socket..."
280         elif waited >= 60:
281             print "FATAL: took too long for socket to appear"
282             sys.exit(1)
283         time.sleep(sleep_resolution)
284     if waited > 0:
285         print "  took about %ss for socket to appear" % waited
286     # Wait for root user to be created
287     command(["disorderd",
288              "--config", disorder._configfile,
289              "--wait-for-root"])
290
291 def create_user(username="fred", password="fredpass"):
292     """create_user(USERNAME, PASSWORD)
293
294     Create a user, abusing direct database access to do so.  Gives the
295     user rights 'all', allowing them to do anything."""
296     print " creating user %s" % username
297     command(["disorder",
298              "--config", disorder._configfile, "--no-per-user-config",
299              "--user", "root", "adduser", username, password])
300     command(["disorder",
301              "--config", disorder._configfile, "--no-per-user-config",
302              "--user", "root", "edituser", username, "rights", "all"])
303
304 def rescan(c=None):
305     print " initiating rescan"
306     if c is None:
307         c = disorder.client()
308     c.rescan('wait')
309     print " rescan completed"
310
311 def stop_daemon():
312     """stop_daemon()
313
314 Stop the daemon if it has not stopped already"""
315     global daemon
316     if daemon == None:
317         print " (daemon not running)"
318         return
319     rc = daemon.poll()
320     if rc == None:
321         print " stopping daemon"
322         os.kill(daemon.pid, 15)
323         print "  waiting for daemon"
324         rc = daemon.wait()
325         print "  daemon has stopped (rc=%d)" % rc
326     else:
327         print "  daemon already stopped"
328     daemon = None
329
330 def run(module=None, report=True):
331     """dtest.run(MODULE)
332
333     Run the test in MODULE.  This can be a string (in which case the module
334     will be imported) or a module object."""
335     global tests, failures
336     tests += 1
337     # Locate the test module
338     if module is None:
339         # We're running a test stand-alone
340         import __main__
341         module = __main__
342         name = os.path.splitext(os.path.basename(sys.argv[0]))[0]
343     else:
344         # We've been passed a module or a module name
345         if type(module) == str:
346             module = __import__(module)
347         name = module.__name__
348     print "--- %s ---" % name
349     # Open the error log
350     global errs
351     logfile = "%s.log" % name
352     try:
353         os.remove(logfile)
354     except:
355         pass
356     errs = open(logfile, "a")
357     # Ensure that disorder.py uses the test installation
358     disorder._configfile = "%s/config" % testroot
359     disorder._userconf = False
360     # Make config file etc
361     common_setup()
362     # Create some standard tracks
363     stdtracks()
364     try:
365         module.test()
366     except Exception, e:
367         traceback.print_exc(None, sys.stderr)
368         failures += 1
369     finally:
370         stop_daemon()
371         os.system("ps -ef | grep disorderd")
372     if report:
373         if failures:
374             print " FAILED"
375             sys.exit(1)
376         else:
377             print " OK"
378
379 def remove_dir(d):
380     """remove_dir(D)
381
382 Recursively delete directory D"""
383     if os.path.lexists(d):
384         if os.path.isdir(d):
385             for dd in os.listdir(d):
386                 remove_dir("%s/%s" % (d, dd))
387             os.rmdir(d)
388         else:
389             os.remove(d)
390
391 def lists_have_same_contents(l1, l2):
392     """lists_have_same_contents(L1, L2)
393
394     Return True if L1 and L2 have equal members, in any order; else False."""
395     s1 = []
396     s1.extend(l1)
397     s1.sort()
398     s2 = []
399     s2.extend(l2)
400     s2.sort()
401     return map(nfc, s1) == map(nfc, s2)
402
403 def check_files(chatty=True):
404     c = disorder.client()
405     failures = 0
406     for d in dirs_by_dir:
407         xdirs = dirs_by_dir[d]
408         dirs = c.directories(d)
409         if not lists_have_same_contents(xdirs, dirs):
410             if chatty:
411                 print
412                 print "directory: %s" % d
413                 print "expected:  %s" % xdirs
414                 print "got:       %s" % dirs
415             failures += 1
416     for d in files_by_dir:
417         xfiles = files_by_dir[d]
418         files = c.files(d)
419         if not lists_have_same_contents(xfiles, files):
420             if chatty:
421                 print
422                 print "directory: %s" % d
423                 print "expected:  %s" % xfiles
424                 print "got:       %s" % files
425             failures += 1
426     return failures
427
428 def command(args):
429     """Execute a command given as a list and return its stdout"""
430     p = subprocess.Popen(args, stdout=subprocess.PIPE)
431     lines = p.stdout.readlines()
432     rc = p.wait()
433     assert rc == 0, ("%s returned status %s" % (args, rc))
434     return lines
435
436 # -----------------------------------------------------------------------------
437 # Common setup
438
439 tests = 0
440 failures = 0
441 daemon = None
442 testroot = "%s/tests/testroot/%s" % \
443            (top_builddir, os.path.basename(sys.argv[0]))
444 tracks = "%s/tracks" % testroot