import pexpect import string import random import re import traceback import unittest from datetime import datetime, timezone from hashlib import sha1 from os import environ, rename from os.path import exists, join from pathlib import Path from ptyprocess import PtyProcessError from shutil import copy, rmtree from subprocess import (Popen, call, check_call, check_output, DEVNULL, STDOUT, TimeoutExpired, CalledProcessError) from sys import exit, stdout, stderr, version_info from tarfile import open as topen from time import sleep from unittest.util import strclass BINSDIR = "test-binaries" IMAGEDIR = "test-imagedir" PASS = 0 SKIP = 1 KNOWNFAIL = 2 UNSUPPORTED = 3 IPROMPT = "Interactive Prompt!" # hardcoded in mount helper VFAT_FIMAGE = "/img/dosemu.img" VFAT_HELPER = "/bin/dosemu_fat_mount.sh" VFAT_MNTPNT = "/mnt/dosemu" TAP_HELPER = "/bin/dosemu_tap_interface.sh" # Get any test binaries we need TEST_BINARY_HOST = "http://www.spheresystems.co.uk/test-binaries" TEST_BINARIES = ( 'DR-DOS-7.01.tar', 'FR-DOS-1.20.tar', 'FR-DOS-1.30.tar', 'MS-DOS-6.22.tar', 'MS-DOS-7.00.tar', 'MS-DOS-7.10.tar', 'VARIOUS.tar', 'TEST_CRYNWR.tar', 'TEST_DOSLFN.tar', 'TEST_EMM286.tar', 'TEST_JAPHETH.tar', 'TEST_MTCP.tar', ) def mkstring(length): return ''.join(random.choice(string.hexdigits) for x in range(length)) def setup_vfat_mounted_image(self): if not exists(VFAT_HELPER): self.skipTest("mount helper not installed") call(["sudo", VFAT_HELPER, "umount"], stderr=DEVNULL) call(["sudo", VFAT_HELPER, "setup"], stderr=STDOUT) with open(VFAT_FIMAGE, "wb") as f: f.write(b'\x00' * 1474560) check_call(["mkfs", "-t", "vfat", VFAT_FIMAGE], stdout=DEVNULL, stderr=DEVNULL) try: check_call(["sudo", VFAT_HELPER, "mount"], stderr=STDOUT) except (CalledProcessError, TimeoutExpired): self.skipTest("mount helper ineffective") def teardown_vfat_mounted_image(self): if not exists(VFAT_HELPER): self.skipTest("mount helper not installed") check_call(["sudo", VFAT_HELPER, "umount"], stderr=STDOUT) def setup_tap_interface(self): if not exists(TAP_HELPER): self.skipTest("tap interface helper not installed") check_call(["sudo", TAP_HELPER, "setup"], stderr=STDOUT) def teardown_tap_interface(self): if not exists(TAP_HELPER): self.skipTest("tap interface helper not installed") check_call(["sudo", TAP_HELPER, "teardown"], stderr=STDOUT) def get_test_binaries(): tbindir = Path('.').resolve() / BINSDIR if tbindir.is_symlink(): tbindir = tbindir.resolve() if not tbindir.exists(): tbindir.mkdir() for tfile in TEST_BINARIES: if not Path(tbindir / tfile).exists(): check_call([ "wget", "--no-verbose", TEST_BINARY_HOST + '/' + tfile, ], stderr=STDOUT, cwd=tbindir) class BaseTestCase(object): attrs = [] @classmethod def setUpClass(cls): cls.topdir = Path('.').resolve() idir = cls.topdir / IMAGEDIR if idir.is_symlink(): target = idir.resolve() if target.name != idir.name: raise ValueError("Imagedir link target name '%s' != '%s'" % (target.name, idir.name)) cls.imagedir = target else: cls.imagedir = idir if not cls.imagedir.exists(): cls.imagedir.mkdir() if not cls.imagedir.is_dir(): raise ValueError("Imagedir must be non-existent, a directory or a link to a directory '%s'" % str(cls.imagedir)) cls.version = "BaseTestCase default" cls.prettyname = "NoPrettyNameSet" cls.tarfile = None cls.files = [(None, None)] cls.actions = {} cls.systype = None cls.bootblocks = [(None, None)] cls.images = [(None, None)] cls.autoexec = "autoexec.bat" cls.confsys = "config.sys" cls.logfiles = {} cls.msg = None @classmethod def setUpClassPost(cls): if cls.tarfile is None: cls.tarfile = cls.prettyname + ".tar" if cls.tarfile != "": if cls.tarfile not in TEST_BINARIES: exit("\nUpdate tuple TEST_BINARIES for '%s'\n" % cls.prettyname) if not exists(join(BINSDIR, cls.tarfile)): exit("\nMissing test binary file, please run test/test_dos.py --get-test-binaries\n") @classmethod def tearDownClass(cls): pass def setUp(self): # Process and skip actions for key, value in self.actions.items(): if re.match(key, self._testMethodName): d = { SKIP: "", KNOWNFAIL: "known failure", UNSUPPORTED: "unsupported", } self.skipTest(d.get(value, "unknown key")) for p in self.imagedir.iterdir(): if p.is_dir(): rmtree(str(p), ignore_errors=True) else: p.unlink() self.workdir = self.imagedir / "dXXXXs" / "c" self.workdir.mkdir(parents=True) # Extract the boot files if self.tarfile != "": self.unTarOrSkip(self.tarfile, self.files) # Empty dosemu.conf for default values self.mkfile("dosemu.conf", """\n""", self.imagedir) # Create startup files self.setUpDosAutoexec() self.setUpDosConfig() self.setUpDosVersion() # Tag the end of autoexec.bat for runDosemu() self.mkfile(self.autoexec, "\r\n@echo " + IPROMPT + "\r\n", mode="a") def setUpDosAutoexec(self): # Use the standard shipped autoexec copy(self.topdir / "src" / "bindist" / self.autoexec, self.workdir) def setUpDosConfig(self): # Use the standard shipped config copy(self.topdir / "src" / "bindist" / self.confsys, self.workdir) def setUpDosVersion(self): # FreeCom / Comcom32 compatible self.mkfile("version.bat", "ver /r\r\nrem end\r\n") def tearDown(self): pass def shortDescription(self): doc = super(BaseTestCase, self).shortDescription() return "Test %-11s %s" % (self.prettyname, doc) def setMessage(self, msg): self.msg = msg # helpers def utcnow(self): return datetime.now(timezone.utc) def mkcom_with_ia16(self, fname, content, dname=None): if dname is None: p = self.workdir else: p = Path(dname).resolve() basename = str(p / fname) with open(basename + ".c", "w") as f: f.write(content) check_call(["ia16-elf-gcc", "-mcmodel=tiny", "-o", basename + ".com", basename + ".c", "-li86"]) def mkexe_with_djgpp(self, fname, content, dname=None): if dname is None: p = self.workdir else: p = Path(dname).resolve() basename = str(p / fname) with open(basename + ".c", "w") as f: f.write(content) check_call(["i586-pc-msdosdjgpp-gcc", "-o", basename + ".exe", basename + ".c"]) def mkcom_with_nasm(self, fname, content, dname=None): if dname is None: p = self.workdir else: p = Path(dname).resolve() basename = p / fname sfile = basename.with_suffix('.asm') sfile.write_text(content) ofile = basename.with_suffix('.com') check_call(["nasm", "-f", "bin", "-o", str(ofile), str(sfile)]) def mkfile(self, fname, content, dname=None, mode="w", newline=None): if dname is None: p = self.workdir / fname else: p = Path(dname).resolve() / fname with p.open(mode=mode, newline=newline) as f: f.write(content) def mkworkdir(self, name, dname=None): if dname is None: testdir = self.workdir.with_name(name) else: testdir = Path(dname).resolve() / name if testdir.is_dir(): rmtree(testdir) elif testdir.exists(): testdir.unlink() testdir.mkdir() return testdir def unTarOrSkip(self, tname, files): tfile = self.topdir / BINSDIR / tname try: with topen(tfile) as tar: for f in files: try: tar.extract(f[0], path=self.workdir) with open(self.workdir / f[0], "rb") as g: s1 = sha1(g.read()).hexdigest() self.assertEqual( f[1], s1, "Sha1sum mismatch file (%s), %s != %s" % (f[0], f[1], s1) ) except KeyError: self.skipTest("File (%s) not found in archive" % f[0]) except IOError: self.skipTest("Archive not found or unreadable(%s)" % tfile) def unTarBootBlockOrSkip(self, name, mv=False): bootblock = [x for x in self.bootblocks if re.match(name, x[0])] if not len(bootblock) == 1: self.skipTest("Boot block signature not available") self.unTarOrSkip(self.tarfile, bootblock) if mv: rename(self.workdir / bootblock[0][0], self.workdir / "boot.blk") def unTarImageOrSkip(self, name): image = [x for x in self.images if name == x[0]] if not len(image) == 1: self.skipTest("Image signature not available") self.unTarOrSkip(self.tarfile, image) rename(self.workdir / name, self.imagedir / name) def mkimage(self, fat, files=None, bootblk=False, cwd=None): if fat == "12": tnum = "306" hnum = "4" regx = "3.." elif fat == "16": tnum = "615" hnum = "4" regx = "6.." else: # 16B tnum = "900" hnum = "15" regx = "[89].." if bootblk: blkname = "boot-%s-%s-17.blk" % (regx, hnum) self.unTarBootBlockOrSkip(blkname, True) blkarg = ["-b", "boot.blk"] else: blkarg = [] if cwd is None: cwd = self.workdir if files is None: xfiles = [x.name for x in cwd.iterdir()] else: xfiles = [x[0] for x in files] name = "fat%s.img" % fat # mkfatimage [-b bsectfile] [{-t tracks | -k Kbytes}] # [-l volume-label] [-f outfile] [-p ] [file...] result = Popen( [str(self.topdir / "bin" / "mkfatimage16"), "-t", tnum, "-h", hnum, "-f", str(self.imagedir / name), "-p" ] + blkarg + xfiles, cwd=cwd ) result.wait() return name def mkimage_vbr(self, fat, lfn=False, cwd=None): if fat == "12": bcount = 306 * 4 * 17 # type 1 elif fat == "16": bcount = 615 * 4 * 17 # type 2 elif fat == "16b": bcount = 900 * 15 * 17 # type 9 elif fat == "32": bcount = 1048576 # 1 GiB else: raise ValueError name = "fat%s.img" % fat # mkfs.fat [OPTIONS] DEVICE [BLOCK-COUNT] check_call( ["mkfs", "-t", ("fat", "vfat")[lfn], "-C", "-F", fat[0:2], str(self.imagedir / name), str(bcount)], stdout=DEVNULL, stderr=DEVNULL) if cwd is None: cwd = self.workdir # mcopy -i ../fat32.img -s -v * ::/ srcs = [str(f) for f in cwd.glob('*')] if srcs: # copy files args = ["mcopy", "-i", str(self.imagedir / name), "-s"] args += srcs args += ["::/",] check_call(args, cwd=cwd, stdout=DEVNULL, stderr=DEVNULL) return name def patch(self, fname, changes, cwd=None): if cwd is None: cwd = self.workdir with open(cwd / fname, "r+b") as f: for c in changes: if len(c[1]) != len(c[2]): raise ValueError("Old and new lengths differ") f.seek(c[0]) old = f.read(len(c[1])) if old != c[1]: raise ValueError("Old sequence not found") f.seek(c[0]) f.write(c[2]) def runDosemu(self, cmd, opts=None, outfile=None, config=None, timeout=5, eofisok=False, interactions=[]): # Note: if debugging is turned on then times increase 10x dbin = "bin/dosemu" args = ["-f", str(self.imagedir / "dosemu.conf"), "-n", "-o", str(self.topdir / self.logfiles['log'][0]), "-td", # "-Da", "--Fimagedir", str(self.imagedir)] if opts is not None: args.extend(["-I", opts]) if config is not None: self.mkfile("dosemu.conf", config, dname=self.imagedir, mode="a") child = pexpect.spawn(dbin, args) ret = '' with open(self.logfiles['xpt'][0], "wb") as fout: child.logfile = fout child.setecho(False) try: prompt = r'(system -e|unix -e|' + IPROMPT + ')' child.expect([prompt + '[\r\n]*'], timeout=10) child.expect(['>[\r\n]*', pexpect.TIMEOUT], timeout=1) child.send(cmd + '\r\n') for resp in interactions: child.expect(resp[0]) child.send(resp[1]) if outfile is None: ret += child.before.decode('ASCII', 'replace') trms = ['rem end',] if eofisok: trms += [pexpect.EOF,] child.expect(trms, timeout=timeout) if outfile is None: ret += child.before.decode('ASCII', 'replace') else: with open(self.workdir / outfile, "r") as f: ret = f.read() except pexpect.TIMEOUT: ret = 'Timeout' tlog = self.logfiles['log'][0].read_text() if '(gdb) Attaching to program' in tlog: sleep(60) self.shouldStop = True except pexpect.EOF: ret = 'EndOfFile' try: child.close(force=True) except PtyProcessError: pass return ret def runDosemuCmdline(self, xargs, cwd=None, config=None, timeout=30): args = [str(self.topdir / "bin" / "dosemu"), "--Fimagedir", str(self.imagedir), "-f", str(self.imagedir / "dosemu.conf"), "-n", "-o", str(self.topdir / self.logfiles['log'][0]), "-td", "-ks"] args.extend(xargs) if config is not None: self.mkfile("dosemu.conf", config, dname=self.imagedir, mode="a") self.logfiles['xpt'][1] = "output.log" try: ret = check_output(args, cwd=cwd, timeout=timeout, stderr=STDOUT).decode('ASCII') self.logfiles['xpt'][0].write_text(ret) except CalledProcessError as e: ret = e.output.decode('ASCII') ret += '\nNonZeroReturn:%d\n' % e.returncode self.logfiles['xpt'][0].write_text(ret) except TimeoutExpired as e: ret = e.output.decode('ASCII') ret += '\nTimeout:%d seconds\n' % timeout self.logfiles['xpt'][0].write_text(ret) return ret class MyTestResult(unittest.TextTestResult): def getDescription(self, test): if 'SubTest' in strclass(test.__class__): return str(test) return '%-80s' % test.shortDescription() def startTest(self, test): super(MyTestResult, self).startTest(test) self.starttime = test.utcnow() name = test.id().replace('__main__', test.pname) test.logfiles = { 'log': [test.topdir / str(name + ".log"), "dosemu.log"], 'xpt': [test.topdir / str(name + ".xpt"), "expect.log"], } test.firstsub = True test.msg = None def _is_relevant_tb_level(self, tb): return '__unittest' in tb.tb_frame.f_globals def _count_relevant_tb_levels(self, tb): length = 0 while tb and not self._is_relevant_tb_level(tb): length += 1 tb = tb.tb_next return length def gather_info_for_failure(self, err, test): """Gather traceback, stdout, stderr, dosemu and expect logs""" TITLE_NAME_FMT = '{:^16}' TITLE_BANNER_FMT = '{:-^70}\n' STDOUT_LINE = '\nStdout:\n%s' STDERR_LINE = '\nStderr:\n%s' # Traceback exctype, value, tb = err while tb and self._is_relevant_tb_level(tb): # Skip test runner traceback levels tb = tb.tb_next if exctype is test.failureException: # Skip assert*() traceback levels length = self._count_relevant_tb_levels(tb) else: length = None tb_e = traceback.TracebackException( exctype, value, tb, limit=length, capture_locals=self.tb_locals) msgLines = list(tb_e.format()) # Stdout, Stderr if self.buffer: output = stdout.getvalue() error = stderr.getvalue() if output: name = TITLE_NAME_FMT.format('stdout') msgLines.append(TITLE_BANNER_FMT.format(name)) if not output.endswith('\n'): output += '\n' msgLines.append(STDOUT_LINE % output) if error: name = TITLE_NAME_FMT.format('stderr') msgLines.append(TITLE_BANNER_FMT.format(name)) if not error.endswith('\n'): error += '\n' msgLines.append(STDERR_LINE % error) # Our logs for _, l in test.logfiles.items(): if not environ.get("CI"): msgLines.append("Further info in file '%s'\n" % l[0]) continue name = TITLE_NAME_FMT.format(l[1]) msgLines.append(TITLE_BANNER_FMT.format(name)) try: cnt = l[0].read_text() if not cnt.endswith('\n'): cnt += '\n' msgLines.append(cnt) except FileNotFoundError: msgLines.append("File not present\n") return ''.join(msgLines) def addFailure(self, test, err): if self.showAll: self.stream.writeln("FAIL") elif self.dots: self.stream.write('F') self.stream.flush() self.failures.append((test, self.gather_info_for_failure(err, test))) self._mirrorOutput = True if getattr(test, 'shouldStop', None) is not None: self.shouldStop = test.shouldStop def addSuccess(self, test): super(unittest.TextTestResult, self).addSuccess(test) if self.showAll: if self.starttime is not None: duration = test.utcnow() - self.starttime msg = (" " + test.msg) if test.msg else "" self.stream.writeln("ok ({:>6.2f}s){}".format(duration.total_seconds(), msg)) else: self.stream.writeln("ok") elif self.dots: self.stream.write('.') self.stream.flush() for _, l in test.logfiles.items(): l[0].unlink(missing_ok=True) def addSubTest(self, test, subtest, err): super(MyTestResult, self).addSubTest(test, subtest, err) if err is not None: if test.firstsub: test.firstsub = False self.stream.writeln("FAIL (one or more subtests)") self.stream.writeln(" %-76s ... FAIL" % subtest._subDescription()) class MyTestRunner(unittest.TextTestRunner): resultclass = MyTestResult def main(argv=None): if version_info < (3, 0): exit("Python 3.0 or later is required.") unittest.main(testRunner=MyTestRunner, argv=argv, verbosity=2)