ada.2: typo
[jackhill/mal.git] / runtest.py
CommitLineData
31690700
JM
1#!/usr/bin/env python
2
97e27599 3from __future__ import print_function
31690700 4import os, sys, re
7907cd90 5import argparse, time
b3c30da9 6import signal, atexit
31690700 7
7907cd90
JM
8from subprocess import Popen, STDOUT, PIPE
9from select import select
31690700 10
b3c30da9
JM
11# Pseudo-TTY and terminal manipulation
12import pty, array, fcntl, termios
13
8e4628da 14IS_PY_3 = sys.version_info[0] == 3
15
97e27599
JM
16debug_file = None
17log_file = None
18
19def debug(data):
20 if debug_file:
21 debug_file.write(data)
22 debug_file.flush()
23
24def log(data, end='\n'):
25 if log_file:
26 log_file.write(data + end)
27 log_file.flush()
28 print(data, end=end)
29 sys.stdout.flush()
30
ab7ecbbc 31sep = "\n"
31690700
JM
32rundir = None
33
34parser = argparse.ArgumentParser(
35 description="Run a test file against a Mal implementation")
36parser.add_argument('--rundir',
37 help="change to the directory before running tests")
38parser.add_argument('--start-timeout', default=10, type=int,
39 help="default timeout for initial prompt")
40parser.add_argument('--test-timeout', default=20, type=int,
41 help="default timeout for each individual test action")
cc021efe
JM
42parser.add_argument('--pre-eval', default=None, type=str,
43 help="Mal code to evaluate prior to running the test")
ab01be18
JM
44parser.add_argument('--no-pty', action='store_true',
45 help="Use direct pipes instead of pseudo-tty")
5abaa3dc 46parser.add_argument('--log-file', type=str,
97e27599
JM
47 help="Write messages to the named file in addition the screen")
48parser.add_argument('--debug-file', type=str,
5abaa3dc 49 help="Write all test interaction the named file")
f3ea3be3 50parser.add_argument('--hard', action='store_true',
108d07a2 51 help="Turn soft tests (soft, deferrable, optional) into hard failures")
31690700 52
a1eb30fc
JM
53# Control whether deferrable and optional tests are executed
54parser.add_argument('--deferrable', dest='deferrable', action='store_true',
55 help="Enable deferrable tests that follow a ';>>> deferrable=True'")
56parser.add_argument('--no-deferrable', dest='deferrable', action='store_false',
57 help="Disable deferrable tests that follow a ';>>> deferrable=True'")
58parser.set_defaults(deferrable=True)
46e25689
JM
59parser.add_argument('--optional', dest='optional', action='store_true',
60 help="Enable optional tests that follow a ';>>> optional=True'")
61parser.add_argument('--no-optional', dest='optional', action='store_false',
62 help="Disable optional tests that follow a ';>>> optional=True'")
63parser.set_defaults(optional=True)
64
18616b10 65parser.add_argument('test_file', type=str,
31690700
JM
66 help="a test file formatted as with mal test data")
67parser.add_argument('mal_cmd', nargs="*",
68 help="Mal implementation command line. Use '--' to "
69 "specify a Mal command line with dashed options.")
9d4138ed
AMP
70parser.add_argument('--crlf', dest='crlf', action='store_true',
71 help="Write \\r\\n instead of \\n to the input")
31690700 72
7907cd90 73class Runner():
9d4138ed 74 def __init__(self, args, no_pty=False, line_break="\n"):
612bfe4a 75 #print "args: %s" % repr(args)
ab01be18 76 self.no_pty = no_pty
612bfe4a
JM
77
78 # Cleanup child process on exit
79 atexit.register(self.cleanup)
80
8caf6211
JM
81 self.p = None
82 env = os.environ
83 env['TERM'] = 'dumb'
92dcc815 84 env['INPUTRC'] = '/dev/null'
82acd3de 85 env['PERL_RL'] = 'false'
ab01be18 86 if no_pty:
612bfe4a
JM
87 self.p = Popen(args, bufsize=0,
88 stdin=PIPE, stdout=PIPE, stderr=STDOUT,
8caf6211 89 preexec_fn=os.setsid,
e3615597 90 env=env)
7907cd90
JM
91 self.stdin = self.p.stdin
92 self.stdout = self.p.stdout
93 else:
94 # provide tty to get 'interactive' readline to work
95 master, slave = pty.openpty()
b3c30da9
JM
96
97 # Set terminal size large so that readline will not send
98 # ANSI/VT escape codes when the lines are long.
99 buf = array.array('h', [100, 200, 0, 0])
100 fcntl.ioctl(master, termios.TIOCSWINSZ, buf, True)
101
612bfe4a
JM
102 self.p = Popen(args, bufsize=0,
103 stdin=slave, stdout=slave, stderr=STDOUT,
8caf6211 104 preexec_fn=os.setsid,
e3615597 105 env=env)
b020aa3e
JM
106 # Now close slave so that we will get an exception from
107 # read when the child exits early
108 # http://stackoverflow.com/questions/11165521
109 os.close(slave)
7907cd90
JM
110 self.stdin = os.fdopen(master, 'r+b', 0)
111 self.stdout = self.stdin
112
113 #print "started"
114 self.buf = ""
115 self.last_prompt = ""
116
9d4138ed
AMP
117 self.line_break = line_break
118
7907cd90
JM
119 def read_to_prompt(self, prompts, timeout):
120 end_time = time.time() + timeout
121 while time.time() < end_time:
96deb6a9
JM
122 [outs,_,_] = select([self.stdout], [], [], 1)
123 if self.stdout in outs:
124 new_data = self.stdout.read(1)
8e4628da 125 new_data = new_data.decode("utf-8") if IS_PY_3 else new_data
97e27599
JM
126 #print("new_data: '%s'" % new_data)
127 debug(new_data)
e17aef04 128 # Perform newline cleanup
ab7ecbbc 129 self.buf += new_data.replace("\r", "")
7907cd90
JM
130 for prompt in prompts:
131 regexp = re.compile(prompt)
132 match = regexp.search(self.buf)
133 if match:
134 end = match.end()
96c09dcd 135 buf = self.buf[0:match.start()]
7907cd90
JM
136 self.buf = self.buf[end:]
137 self.last_prompt = prompt
d7f5f54c 138 return buf
7907cd90
JM
139 return None
140
96deb6a9 141 def writeline(self, str):
8e4628da 142 def _to_bytes(s):
143 return bytes(s, "utf-8") if IS_PY_3 else s
144
9d4138ed 145 self.stdin.write(_to_bytes(str.replace('\r', '\x16\r') + self.line_break))
7907cd90 146
f6c83b2b 147 def cleanup(self):
612bfe4a 148 #print "cleaning up"
f6c83b2b 149 if self.p:
10034e82
JM
150 try:
151 os.killpg(self.p.pid, signal.SIGTERM)
152 except OSError:
153 pass
f6c83b2b
JM
154 self.p = None
155
98af2ae3 156class TestReader:
46e25689 157 def __init__(self, test_file):
98af2ae3 158 self.line_num = 0
18616b10
JM
159 f = open(test_file, newline='') if IS_PY_3 else open(test_file)
160 self.data = f.read().split('\n')
98af2ae3 161 self.soft = False
a1eb30fc 162 self.deferrable = False
46e25689 163 self.optional = False
98af2ae3
JM
164
165 def next(self):
46e25689 166 self.msg = None
98af2ae3
JM
167 self.form = None
168 self.out = ""
169 self.ret = None
170
171 while self.data:
172 self.line_num += 1
173 line = self.data.pop(0)
174 if re.match(r"^\s*$", line): # blank line
175 continue
176 elif line[0:3] == ";;;": # ignore comment
177 continue
178 elif line[0:2] == ";;": # output comment
46e25689
JM
179 self.msg = line[3:]
180 return True
98af2ae3
JM
181 elif line[0:5] == ";>>> ": # settings/commands
182 settings = {}
183 exec(line[5:], {}, settings)
46e25689
JM
184 if 'soft' in settings:
185 self.soft = settings['soft']
a1eb30fc
JM
186 if 'deferrable' in settings and settings['deferrable']:
187 self.deferrable = "\nSkipping deferrable and optional tests"
46e25689
JM
188 return True
189 if 'optional' in settings and settings['optional']:
190 self.optional = "\nSkipping optional tests"
191 return True
98af2ae3
JM
192 continue
193 elif line[0:1] == ";": # unexpected comment
18f0ec21 194 raise Exception("Test data error at line %d:\n%s" % (self.line_num, line))
98af2ae3
JM
195 self.form = line # the line is a form to send
196
197 # Now find the output and return value
198 while self.data:
199 line = self.data[0]
200 if line[0:3] == ";=>":
8d78bc26 201 self.ret = line[3:]
98af2ae3
JM
202 self.line_num += 1
203 self.data.pop(0)
204 break
f6f5d4f2 205 elif line[0:2] == ";/":
98af2ae3
JM
206 self.out = self.out + line[2:] + sep
207 self.line_num += 1
208 self.data.pop(0)
209 else:
f6f5d4f2 210 self.ret = ""
98af2ae3 211 break
f6f5d4f2 212 if self.ret != None: break
98af2ae3 213
ab7ecbbc 214 if self.out[-1:] == sep and not self.ret:
f6f5d4f2
JM
215 # If there is no return value, output should not end in
216 # separator
ab7ecbbc 217 self.out = self.out[0:-1]
98af2ae3
JM
218 return self.form
219
31690700 220args = parser.parse_args(sys.argv[1:])
406761e7
JM
221# Workaround argparse issue with two '--' on command line
222if sys.argv.count('--') > 0:
223 args.mal_cmd = sys.argv[sys.argv.index('--')+1:]
31690700
JM
224
225if args.rundir: os.chdir(args.rundir)
226
97e27599
JM
227if args.log_file: log_file = open(args.log_file, "a")
228if args.debug_file: debug_file = open(args.debug_file, "a")
229
9d4138ed 230r = Runner(args.mal_cmd, no_pty=args.no_pty, line_break="\r\n" if args.crlf else "\n")
98af2ae3 231t = TestReader(args.test_file)
53beaa0a 232
31690700 233
98af2ae3 234def assert_prompt(runner, prompts, timeout):
cc021efe 235 # Wait for the initial prompt
98af2ae3 236 header = runner.read_to_prompt(prompts, timeout=timeout)
7907cd90
JM
237 if not header == None:
238 if header:
97e27599 239 log("Started with:\n%s" % header)
7907cd90 240 else:
0821b2d4 241 log("Did not receive one of following prompt(s): %s" % repr(prompts))
97e27599 242 log(" Got : %s" % repr(r.buf))
cc021efe
JM
243 sys.exit(1)
244
31690700
JM
245
246# Wait for the initial prompt
16d5b0c3 247try:
96c09dcd 248 assert_prompt(r, ['[^\s()<>]+> '], args.start_timeout)
16d5b0c3
JM
249except:
250 _, exc, _ = sys.exc_info()
251 log("\nException: %s" % repr(exc))
252 log("Output before exception:\n%s" % r.buf)
253 sys.exit(1)
cc021efe
JM
254
255# Send the pre-eval code if any
256if args.pre_eval:
257 sys.stdout.write("RUNNING pre-eval: %s" % args.pre_eval)
c2b6285e
NB
258 r.writeline(args.pre_eval)
259 assert_prompt(r, ['[^\s()<>]+> '], args.test_timeout)
31690700 260
97e27599
JM
261test_cnt = 0
262pass_cnt = 0
31690700 263fail_cnt = 0
98af2ae3 264soft_fail_cnt = 0
00724049 265failures = []
31690700 266
9d7fb0fd
BH
267class TestTimeout(Exception):
268 pass
269
98af2ae3 270while t.next():
a1eb30fc
JM
271 if args.deferrable == False and t.deferrable:
272 log(t.deferrable)
46e25689
JM
273 break
274
275 if args.optional == False and t.optional:
276 log(t.optional)
277 break
278
279 if t.msg != None:
280 log(t.msg)
281 continue
282
283 if t.form == None: continue
284
18616b10 285 log("TEST: %s -> [%s,%s]" % (repr(t.form), repr(t.out), t.ret), end='')
d2f0f672
JM
286
287 # The repeated form is to get around an occasional OS X issue
288 # where the form is repeated.
289 # https://github.com/kanaka/mal/issues/30
d7f5f54c
BH
290 expects = [".*%s%s%s" % (sep, t.out, re.escape(t.ret)),
291 ".*%s.*%s%s%s" % (sep, sep, t.out, re.escape(t.ret))]
31690700 292
98af2ae3 293 r.writeline(t.form)
31690700 294 try:
97e27599 295 test_cnt += 1
96c09dcd 296 res = r.read_to_prompt(['\r\n[^\s()<>]+> ', '\n[^\s()<>]+> '],
7907cd90 297 timeout=args.test_timeout)
31690700 298 #print "%s,%s,%s" % (idx, repr(p.before), repr(p.after))
9d7fb0fd
BH
299 if (res == None):
300 log(" -> TIMEOUT (line %d)" % t.line_num)
301 raise TestTimeout("TIMEOUT (line %d)" % t.line_num)
302 elif (t.ret == "" and t.out == ""):
f6f5d4f2
JM
303 log(" -> SUCCESS (result ignored)")
304 pass_cnt += 1
305 elif (re.search(expects[0], res, re.S) or
306 re.search(expects[1], res, re.S)):
97e27599
JM
307 log(" -> SUCCESS")
308 pass_cnt += 1
31690700 309 else:
f3ea3be3 310 if t.soft and not args.hard:
97e27599 311 log(" -> SOFT FAIL (line %d):" % t.line_num)
98af2ae3 312 soft_fail_cnt += 1
00724049 313 fail_type = "SOFT "
98af2ae3 314 else:
97e27599 315 log(" -> FAIL (line %d):" % t.line_num)
98af2ae3 316 fail_cnt += 1
00724049 317 fail_type = ""
f6f5d4f2 318 log(" Expected : %s" % repr(expects[0]))
97e27599 319 log(" Got : %s" % repr(res))
00724049
DM
320 failed_test = """%sFAILED TEST (line %d): %s -> [%s,%s]:
321 Expected : %s
f6f5d4f2
JM
322 Got : %s""" % (fail_type, t.line_num, t.form, repr(t.out),
323 t.ret, repr(expects[0]), repr(res))
00724049 324 failures.append(failed_test)
7907cd90 325 except:
10034e82 326 _, exc, _ = sys.exc_info()
97e27599
JM
327 log("\nException: %s" % repr(exc))
328 log("Output before exception:\n%s" % r.buf)
31690700
JM
329 sys.exit(1)
330
00724049
DM
331if len(failures) > 0:
332 log("\nFAILURES:")
333 for f in failures:
334 log(f)
335
336results = """
337TEST RESULTS (for %s):
97e27599
JM
338 %3d: soft failing tests
339 %3d: failing tests
340 %3d: passing tests
341 %3d: total tests
18616b10 342""" % (args.test_file, soft_fail_cnt, fail_cnt,
97e27599
JM
343 pass_cnt, test_cnt)
344log(results)
345
346debug("\n") # add some separate to debug log
347
31690700 348if fail_cnt > 0:
97e27599 349 sys.exit(1)
31690700 350sys.exit(0)