#
# Usage:
#   import runtest
#   runtest.run(testcommands, outputfile,
#               menuprompt=None,	default "OS/161 kernel [? for menu]: "
#               shellprompt=None,	default "OS/161$ "
#               conf=None,		default is sys161 default behavior
#               ram=None, 		default is per sys161 config
#               cpus=None,		default is per sys161 config
#               doom=None,		default is no doom counter
#               progress=30,		default is 30 seconds
#               timeout=300,		default is 300 seconds
#               kernel=None)		default is "kernel"
#
# Returns None on success or a (string) message if something apparently
# went wrong in the middle. (XXX: should it throw exceptions instead?)
#
# * The testcommands argument is a string containing a list of commands
# separated by semicolons.  These can be either kernel menu commands
# or shell commands; the command 's' is recognized for switching from
# the menu to the shell and 'exit' for switching back to the menu.
# (This affects waiting for prompts - running the shell via 'p' or
# crashing out of the shell will confuse things.)
#
# The command 'q' from the menu is also recognized as causing a
# shutdown. This will be done automatically after everything else if
# not issued explicitly.
#
# The following commands are interpreted as macros:
#    DOMOUNT		expands to "mount sfs lhd1:; cd lhd1:"
#    DOUNMOUNT		expands to "cd /; unmount lhd1:"
#    WAIT		sleeps 3 seconds and just presses return
#
# * The outputfile argument should be a python file (e.g. sys.stdout)
# and receives a copy of the System/161 output.
#
# * The menuprompt and shellprompt arguments can be used to change the
# menu and shell prompt strings looked for. For the moment these can
# only be fixed strings, not regular expressions. (This is probably
# easy to improve, but I ran into some mysterious problems when I
# tried, so YMMV.) By default if you pass None prompt strings matching
# what OS/161 issues by default are used.
#
# * The conf argument can be used to supply an alternate sys161.conf
# file. If None is given (the default), sys161 will use its default
# config file.
#
# * The ram and cpus arguments can be used to override the RAM size
# and number-of-cpus settings in the sys161 config file. The number of
# cpus must be an integer, but any RAM size specification understood
# by sys161 can be used. Note: this feature requires System/161 2.0.5
# or higher.
#
# * The doom argument can be used to set the doom counter. If None is
# given (the default) the doom counter is not engaged.
#
# * The progress and timeout arguments can be used to set the timeouts
# for System/161 progress monitoring and pexpect-level global timeout,
# respectively. The defaults (somewhat arbitraily chosen) are 30 and
# 300 seconds. Passing progress=None disables progress monitoring; this
# is necessary for nontrivial tests that run within the kernel, as
# progress monitoring measures userland progress. Passing timeout=None
# probably either disables the global timeout or makes pexpect crash;
# I haven't tested it. I don't recommend trying: it is your defense
# against test runs hanging forever.
#
# Note that no-debugger unattended mode (sys161 -X) is always used.
# The purpose of this script is specifically to support unattended
# test runs...
#
# Depends on pexpect, which you may need to install specifically
# depending on your OS.
#

import time
import pexpect

#
# Macro commands
#
macros = {
	"MOUNT" : ["mount sfs lhd1:", "cd lhd1:"],
	"UNMOUNT" : ["cd /", "unmount lhd1:"],
	# "WAIT" special-cased below
}

#
# Wait for a prompt; returns True if we got it, False if we need to
# bail.
#
def getprompt(proc, prompt):
	which = proc.expect_exact([
			prompt,
			"panic: ",		# panic message
			"sys161: No progress in ", # sys161 deadman print
			"sys161: Elapsed ",	# sys161 shutdown print
			pexpect.EOF,
			pexpect.TIMEOUT
		])
	if which == 0:
		# got the prompt
		return None
	if which == 1:
		proc.expect_exact([pexpect.EOF, pexpect.TIMEOUT])
		return "panic"
	if which == 2:
		proc.expect_exact([pexpect.EOF, pexpect.TIMEOUT])
		return "progress timeout"
	if which == 3:
		proc.expect_exact([pexpect.EOF, pexpect.TIMEOUT])
		return "unexpected shutdown"
	if which == 4:
		return "unexpected end of input"
	if which == 5:
		return "top-level timeout"
	return "runtest: Internal error: pexpect returned out-of-range result"
# end getprompt

#
# main test function
#
def run(testcommands, outputfile,
		menuprompt=None, shellprompt=None,
		conf=None, ram=None, cpus=None,
		doom=None,
		progress=30, timeout=300,
		kernel=None):
	if menuprompt is None:
		menuprompt = "OS/161 kernel [? for menu]: "
	if shellprompt is None:
		shellprompt = "OS/161$ "
	if kernel is None:
		kernel = "kernel"

	args = ["-X"]
	if conf is not None:
		args.append("-c")
		args.append(conf)
	if cpus is not None:
		args.append("-C")
		args.append("31:cpus=%d" % cpus)
	if doom is not None:
		args.append("-D")
		args.append("%d" % doom)
	if progress is not None:
		args.append("-Z")
		args.append("%d" % progress)
	if ram is not None:
		args.append("-C")
		args.append("31:ramsize=%s" % ram)
	args.append(kernel)

	proc = pexpect.spawn("sys161", args, timeout=timeout,
				ignore_sighup=False)
	proc.logfile_read = outputfile

	commands = testcommands.split(";")
	commands = [macros[c] if c in macros else [c] for c in commands]
	# Apparently list flatten() is unpythonic...
	commands = [c for sublist in commands for c in sublist]

	prompts = { True: shellprompt, False: menuprompt }
	inshell = False
	quit = False
	for cmd in commands:
		msg = getprompt(proc, prompts[inshell])
		if msg is not None:
			return msg
		if cmd == "WAIT":
			time.sleep(3)
			cmd = ""
		proc.send("%s\r" % cmd)
		if not inshell and cmd == "q":
			quit = True
		if not inshell and cmd == "s":
			inshell = True
		if inshell and cmd == "exit":
			inshell = False
	if not quit:
		if inshell:
			msg = getprompt(proc, prompts[inshell])
			if msg is not None:
				return msg
			proc.send("exit\r")
			inshell = False
		msg = getprompt(proc, prompts[inshell])
		if msg is not None:
			return msg
		proc.send("q\r")
		quit = True

	proc.expect_exact([pexpect.EOF, pexpect.TIMEOUT])

	# Apparently if you call pexpect.wait() you must have
	# explicitly read all the input, or it hangs; and the process
	# can't be already dead, or it crashes. Therefore it appears
	# to be entirely useless. I hope not calling it doesn't cause
	# zombies to accumulate.
	#proc.wait()

	return None
# end run
