kunit_tool: update to support kunit_watcher

Return objects when building config, building kernel, and parsing
test log in order to provide full context of kunit run.

Change-Id: If410ac7e2b747c80c952fd2a9983bb2f4577fd33
Google-Bug-Id: 116626752
Signed-off-by: Avi Kondareddy <avikr@google.com>
diff --git a/tools/testing/kunit/kunit.py b/tools/testing/kunit/kunit.py
index b9bacd0..7651d38 100755
--- a/tools/testing/kunit/kunit.py
+++ b/tools/testing/kunit/kunit.py
@@ -13,34 +13,47 @@
 import kunit_new_template
 import kunit_parser
 
-def run_tests(cli_args, linux):
+from collections import namedtuple
+
+KunitRequest = namedtuple('KunitRequest', ['raw_output','timeout'])
+
+KunitResult = namedtuple('KunitResult', ['status','result'])
+
+class KunitStatus(object):
+	SUCCESS = 'SUCCESS'
+	CONFIG_FAILURE = 'CONFIG_FAILURE'
+	BUILD_FAILURE = 'BUILD_FAILURE'
+	TEST_FAILURE = 'TEST_FAILURE'
+
+def run_tests(linux: kunit_kernel.LinuxSourceTree,
+	      request: KunitRequest) -> KunitResult:
 	config_start = time.time()
-	success = linux.build_reconfig()
+	config_result = linux.build_reconfig()
 	config_end = time.time()
-	if not success:
-		return
+	if config_result.status != kunit_kernel.ConfigStatus.SUCCESS:
+		return KunitResult(KunitStatus.CONFIG_FAILURE, config_result)
 
 	print(kunit_parser.timestamp('Building KUnit Kernel ...'))
 
 	build_start = time.time()
-	success = linux.build_um_kernel()
+	build_result = linux.build_um_kernel()
 	build_end = time.time()
-	if not success:
-		return
+	if build_result.status != kunit_kernel.BuildStatus.SUCCESS:
+		return KunitResult(KunitStatus.BUILD_FAILURE, build_result)
 
 	print(kunit_parser.timestamp('Starting KUnit Kernel ...'))
 	test_start = time.time()
 
-	if cli_args.raw_output:
+	test_result = kunit_parser.TestResult(kunit_parser.TestStatus.SUCCESS,
+					      [],
+					      'Tests not Parsed.')
+	if request.raw_output:
 		kunit_parser.raw_output(
-			linux.run_kernel(timeout=cli_args.timeout))
+			linux.run_kernel(timeout=request.timeout))
 	else:
-		for line in kunit_parser.parse_run_tests(
-			kunit_parser.isolate_kunit_output(
-				linux.run_kernel(
-					timeout=cli_args.timeout))):
-			print(line)
-
+		test_result = kunit_parser.parse_run_tests(
+				kunit_parser.isolate_kunit_output(
+				  linux.run_kernel(timeout=request.timeout)))
 	test_end = time.time()
 
 	print(kunit_parser.timestamp((
@@ -51,13 +64,18 @@
 				build_end - build_start,
 				test_end - test_start)))
 
+	if test_result.status != kunit_parser.TestStatus.SUCCESS:
+		return KunitResult(KunitStatus.TEST_FAILURE, test_result)
+	else:
+		return KunitResult(KunitStatus.SUCCESS, test_result)
+
 def print_test_skeletons(cli_args):
 	kunit_new_template.create_skeletons_from_path(
 			cli_args.path,
 			namespace_prefix=cli_args.namespace_prefix,
 			print_test_only=cli_args.print_test_only)
 
-def main(argv, linux=kunit_kernel.LinuxSourceTree()):
+def main(argv, linux):
 	parser = argparse.ArgumentParser(
 			description='Helps writing and running KUnit tests.')
 	subparser = parser.add_subparsers(dest='subcommand')
@@ -94,9 +112,10 @@
 	if cli_args.subcommand == 'new':
 		print_test_skeletons(cli_args)
 	elif cli_args.subcommand == 'run':
-		run_tests(cli_args, linux)
+		request = KunitRequest(cli_args.raw_output, cli_args.timeout)
+		run_tests(linux, request)
 	else:
 		parser.print_help()
 
 if __name__ == '__main__':
-	main(sys.argv[1:])
+	main(sys.argv[1:], kunit_kernel.LinuxSourceTree())
diff --git a/tools/testing/kunit/kunit_config.py b/tools/testing/kunit/kunit_config.py
index 183bd5e..4196966 100644
--- a/tools/testing/kunit/kunit_config.py
+++ b/tools/testing/kunit/kunit_config.py
@@ -58,3 +58,10 @@
 	def read_from_file(self, path: str) -> None:
 		with open(path, 'r') as f:
 			self.parse_from_string(f.read())
+
+class KunitConfigProvider(object):
+
+  def get_kconfig(self) -> Kconfig:
+    kconfig = Kconfig()
+    kconfig.read_from_file('kunitconfig')
+    return kconfig
diff --git a/tools/testing/kunit/kunit_kernel.py b/tools/testing/kunit/kunit_kernel.py
index 5ef8fcd..8a51ba3 100644
--- a/tools/testing/kunit/kunit_kernel.py
+++ b/tools/testing/kunit/kunit_kernel.py
@@ -5,9 +5,24 @@
 import os
 
 import kunit_config
+import kunit_parser
 
 KCONFIG_PATH = '.config'
 
+from collections import namedtuple
+
+ConfigResult = namedtuple('ConfigResult', ['status','info'])
+
+BuildResult = namedtuple('BuildResult', ['status','info'])
+
+class ConfigStatus(object):
+	SUCCESS = 'SUCCESS'
+	FAILURE = 'FAILURE'
+
+class BuildStatus(object):
+	SUCCESS = 'SUCCESS'
+	FAILURE = 'FAILURE'
+
 class ConfigError(Exception):
 	"""Represents an error trying to configure the Linux kernel."""
 
@@ -56,16 +71,22 @@
 		except subprocess.TimeoutExpired:
 			process.terminate()
 			timed_out = True
-		return process, timed_out
+		output, _ = process.communicate()
+		output = output.decode('ascii')
 
+		if timed_out:
+			output += kunit_parser.TIMED_OUT_LOG_ENTRY + '\n'
+
+		return output
 
 class LinuxSourceTree(object):
 	"""Represents a Linux kernel source tree with KUnit tests."""
 
-	def __init__(self):
-		self._kconfig = kunit_config.Kconfig()
-		self._kconfig.read_from_file('kunitconfig')
-		self._ops = LinuxSourceTreeOperations()
+	def __init__(self,
+			     kconfig_provider=kunit_config.KunitConfigProvider(),
+				 linux_build_operations=LinuxSourceTreeOperations()):
+		self._kconfig = kconfig_provider.get_kconfig()
+		self._ops = linux_build_operations
 
 	def clean(self):
 		try:
@@ -81,13 +102,14 @@
 			self._ops.make_olddefconfig()
 		except ConfigError as e:
 			logging.error(e)
-			return False
+			return ConfigResult(ConfigStatus.FAILURE, e.message)
 		validated_kconfig = kunit_config.Kconfig()
 		validated_kconfig.read_from_file(KCONFIG_PATH)
 		if not self._kconfig.is_subset_of(validated_kconfig):
-			logging.error('Provided Kconfig is not contained in validated .config!')
-			return False
-		return True
+			message = 'Provided Kconfig is not contained in validated .config!'
+			logging.error(message)
+			return ConfigResult(ConfigStatus.FAILURE, message)
+		return ConfigResult(ConfigStatus.SUCCESS, 'Build config!')
 
 	def build_reconfig(self):
 		"""Creates a new .config if it is not a subset of the kunitconfig."""
@@ -99,7 +121,7 @@
 				os.remove(KCONFIG_PATH)
 				return self.build_config()
 			else:
-				return True
+				return ConfigResult(ConfigStatus.SUCCESS, 'Already built.')
 		else:
 			print('Generating .config ...')
 			return self.build_config()
@@ -110,21 +132,19 @@
 			self._ops.make()
 		except (ConfigError, BuildError) as e:
 			logging.error(e)
-			return False
+			return BuildResult(BuildStatus.FAILURE, e.message)
 		used_kconfig = kunit_config.Kconfig()
 		used_kconfig.read_from_file(KCONFIG_PATH)
 		if not self._kconfig.is_subset_of(used_kconfig):
-			logging.error('Provided Kconfig is not contained in final config!')
-			return False
-		return True
+			message = 'Provided Kconfig is not contained in final config!'
+			logging.error(message)
+			return BuildResult(BuildStatus.FAILURE, message)
+		return BuildResult(BuildStatus.SUCCESS, 'Built kernel!')
 
 	def run_kernel(self, args=[], timeout=None):
 		args.extend(['mem=256M'])
-		process, timed_out = self._ops.linux_bin(args, timeout)
+		raw_log = self._ops.linux_bin(args, timeout)
 		with open('test.log', 'w') as f:
-			for line in process.stdout:
-				f.write(line.rstrip().decode('ascii') + '\n')
-				yield line.rstrip().decode('ascii')
-			if timed_out:
-				f.write('Timeout Reached - Process Terminated\n')
-				yield 'Timeout Reached - Process Terminated\n'
+			for line in raw_log.split('\n'):
+				f.write(line.rstrip() + '\n')
+				yield line.rstrip()
diff --git a/tools/testing/kunit/kunit_parser.py b/tools/testing/kunit/kunit_parser.py
index d94b144..02b4abe 100644
--- a/tools/testing/kunit/kunit_parser.py
+++ b/tools/testing/kunit/kunit_parser.py
@@ -1,9 +1,26 @@
 import re
 from datetime import datetime
 
+from collections import namedtuple
+
+TestResult = namedtuple('TestResult', ['status','modules','log'])
+
+TestModule = namedtuple('TestModule', ['status','name','cases'])
+
+TestCase = namedtuple('TestCase', ['status','name','log'])
+
+class TestStatus(object):
+	SUCCESS = 'SUCCESS'
+	FAILURE = 'FAILURE'
+	TEST_CRASHED = 'TEST_CRASHED'
+	TIMED_OUT = 'TIMED_OUT'
+	KERNEL_CRASHED = 'KERNEL_CRASHED'
+
 kunit_start_re = re.compile('console .* enabled')
 kunit_end_re = re.compile('List of all partitions:')
 
+TIMED_OUT_LOG_ENTRY = 'Timeout Reached - Process Terminated'
+
 class KernelCrashException(Exception):
 	pass
 
@@ -47,8 +64,8 @@
 def parse_run_tests(kernel_output):
 	test_case_output = re.compile('^kunit .*?: (.*)$')
 
-	test_module_success = re.compile('^kunit .*: all tests passed')
-	test_module_fail = re.compile('^kunit .*: one or more tests failed')
+	test_module_success = re.compile('^kunit (.*): all tests passed')
+	test_module_fail = re.compile('^kunit (.*): one or more tests failed')
 
 	test_case_success = re.compile('^kunit (.*): (.*) passed')
 	test_case_fail = re.compile('^kunit (.*): (.*) failed')
@@ -58,30 +75,59 @@
 	failed_tests = set()
 	crashed_tests = set()
 
+	test_status = TestStatus.SUCCESS
+	modules = []
+	log_list = []
+
+	def get_test_module_name(match):
+		return match.group(1)
+	def get_test_case_name(match):
+		return match.group(2)
+
 	def get_test_name(match):
 		return match.group(1) + ":" + match.group(2)
 
 	current_case_log = []
+	current_module_cases = []
 	did_kernel_crash = False
 	did_timeout = False
 
 	def end_one_test(match, log):
-		log.clear()
+		del log[:]
 		total_tests.add(get_test_name(match))
 
-	yield timestamp(DIVIDER)
+	print(timestamp(DIVIDER))
 	try:
 		for line in kernel_output:
+			log_list.append(line)
+
 			# Ignore module output:
-			if (test_module_success.match(line) or
-			test_module_fail.match(line)):
-				yield timestamp(DIVIDER)
+			module_success = test_module_success.match(line)
+			if module_success:
+				print(timestamp(DIVIDER))
+				modules.append(
+				  TestModule(TestStatus.SUCCESS,
+					     get_test_module_name(module_success),
+					     current_module_cases))
+				current_module_cases = []
+				continue
+			module_fail = test_module_fail.match(line)
+			if module_fail:
+				print(timestamp(DIVIDER))
+				modules.append(
+				  TestModule(TestStatus.FAILURE,
+					     get_test_module_name(module_fail),
+					     current_module_cases))
+				current_module_cases = []
 				continue
 
 			match = re.match(test_case_success, line)
 			if match:
-				yield timestamp(green("[PASSED] ") +
-						get_test_name(match))
+				print(timestamp(green("[PASSED] ") + get_test_name(match)))
+				current_module_cases.append(
+					TestCase(TestStatus.SUCCESS,
+						 get_test_case_name(match),
+						 '\n'.join(current_case_log)))
 				end_one_test(match, current_case_log)
 				continue
 
@@ -90,26 +136,38 @@
 			# want to show and count it once.
 			if match and get_test_name(match) not in crashed_tests:
 				failed_tests.add(get_test_name(match))
-				yield timestamp(red("[FAILED] " +
-							get_test_name(match)))
-				yield from timestamp_log(map(yellow, current_case_log))
-				yield timestamp("")
+				print(timestamp(red("[FAILED] " + get_test_name(match))))
+				for out in timestamp_log(map(yellow, current_case_log)):
+					print(out)
+				print(timestamp(""))
+				current_module_cases.append(
+					TestCase(TestStatus.FAILURE,
+						 get_test_case_name(match),
+						 '\n'.join(current_case_log)))
+				if test_status != TestStatus.TEST_CRASHED:
+					test_status = TestStatus.FAILURE
 				end_one_test(match, current_case_log)
 				continue
 
 			match = re.match(test_case_crash, line)
 			if match:
 				crashed_tests.add(get_test_name(match))
-				yield timestamp(yellow("[CRASH] " +
-							get_test_name(match)))
-				yield from timestamp_log(current_case_log)
-				yield timestamp("")
+				print(timestamp(yellow("[CRASH] " + get_test_name(match))))
+				for out in timestamp_log(current_case_log):
+					print(out)
+				print(timestamp(""))
+				current_module_cases.append(
+					TestCase(TestStatus.TEST_CRASHED,
+						 get_test_case_name(match),
+						 '\n'.join(current_case_log)))
+				test_status = TestStatus.TEST_CRASHED
 				end_one_test(match, current_case_log)
 				continue
 
-			if line == 'Timeout Reached - Process Terminated\n':
-				yield timestamp(red("[TIMED-OUT] Process Terminated"))
+			if line.strip() == TIMED_OUT_LOG_ENTRY:
+				print(timestamp(red("[TIMED-OUT] Process Terminated")))
 				did_timeout = True
+				test_status = TestStatus.TIMED_OUT
 				break
 
 			# Strip off the `kunit module-name:` prefix
@@ -120,14 +178,15 @@
 				current_case_log.append(line)
 	except KernelCrashException:
 		did_kernel_crash = True
-		yield timestamp(
-			red("The KUnit kernel crashed unexpectedly and was " +
-			    "unable to finish running tests!"))
-		yield timestamp(red("These are the logs from the most " +
-					 "recently running test:"))
-		yield timestamp(DIVIDER)
-		yield from timestamp_log(current_case_log)
-		yield timestamp(DIVIDER)
+		test_status = TestStatus.KERNEL_CRASHED
+		print(timestamp(red("The KUnit kernel crashed unexpectedly and was " +
+							"unable to finish running tests!")))
+		print(timestamp(red("These are the logs from the most " +
+							"recently running test:")))
+		print(timestamp(DIVIDER))
+		for out in timestamp_log(current_case_log):
+			print(out)
+		print(timestamp(DIVIDER))
 
 	fmt = green if (len(failed_tests) + len(crashed_tests) == 0
 			and not did_kernel_crash and not did_timeout) else red
@@ -137,7 +196,7 @@
 	elif did_timeout:
 		message = "Before timing out:"
 
-	yield timestamp(
-		fmt(message + " %d tests run. %d failed. %d crashed." %
-		    (len(total_tests), len(failed_tests), len(crashed_tests))))
+	print(timestamp(fmt(message + " %d tests run. %d failed. %d crashed." %
+				(len(total_tests), len(failed_tests), len(crashed_tests)))))
 
+	return TestResult(test_status, modules, '\n'.join(log_list))
diff --git a/tools/testing/kunit/kunit_test.py b/tools/testing/kunit/kunit_test.py
index e7baf2868..f143358 100755
--- a/tools/testing/kunit/kunit_test.py
+++ b/tools/testing/kunit/kunit_test.py
@@ -9,6 +9,7 @@
 
 import kunit_config
 import kunit_parser
+import kunit_kernel
 import kunit
 
 test_tmpdir = ''
@@ -104,9 +105,9 @@
 			'test_data/test_is_test_passed-all_passed.log')
 		file = open(all_passed_log)
 		result = kunit_parser.parse_run_tests(file.readlines())
-		self.assertContains(
-			'Testing complete. 3 tests run. 0 failed. 0 crashed.',
-			result)
+		self.assertEqual(
+			kunit_parser.TestStatus.SUCCESS,
+			result.status)
 		file.close()
 
 	def test_parse_failed_test_log(self):
@@ -114,9 +115,9 @@
 			'test_data/test_is_test_passed-failure.log')
 		file = open(failed_log)
 		result = kunit_parser.parse_run_tests(file.readlines())
-		self.assertContains(
-			'Testing complete. 3 tests run. 1 failed. 0 crashed.',
-			result)
+		self.assertEqual(
+			kunit_parser.TestStatus.FAILURE,
+			result.status)
 		file.close()
 
 	def test_broken_test(self):
@@ -125,9 +126,9 @@
 		file = open(broken_log)
 		result = kunit_parser.parse_run_tests(
 			kunit_parser.isolate_kunit_output(file.readlines()))
-		self.assertContains(
-			'Before the crash: 3 tests run. 0 failed. 0 crashed.',
-			result)
+		self.assertEqual(
+			kunit_parser.TestStatus.KERNEL_CRASHED,
+			result.status)
 		file.close()
 
 	def test_no_tests(self):
@@ -136,9 +137,10 @@
 		file = open(empty_log)
 		result = kunit_parser.parse_run_tests(
 			kunit_parser.isolate_kunit_output(file.readlines()))
-		self.assertContains(
-			'Testing complete. 0 tests run. 0 failed. 0 crashed.',
-			result)
+		self.assertEqual(0, len(result.modules))
+		self.assertEqual(
+			kunit_parser.TestStatus.SUCCESS,
+			result.status)
 		file.close()
 
 	def test_crashed_test(self):
@@ -146,9 +148,9 @@
 			'test_data/test_is_test_passed-crash.log')
 		file = open(crashed_log)
 		result = kunit_parser.parse_run_tests(file.readlines())
-		self.assertContains(
-			'Testing complete. 3 tests run. 0 failed. 1 crashed.',
-			result)
+		self.assertEqual(
+			kunit_parser.TestStatus.TEST_CRASHED,
+			result.status)
 		file.close()
 
 	def test_timed_out_test(self):
@@ -156,9 +158,9 @@
 			'test_data/test_is_test_passed-timed_out.log')
 		file = open(timed_out_log)
 		result = kunit_parser.parse_run_tests(file.readlines())
-		self.assertContains(
-			'Before timing out: 3 tests run. 0 failed. 0 crashed.',
-			result)
+		self.assertEqual(
+			kunit_parser.TestStatus.TIMED_OUT,
+			result.status)
 		file.close()
 
 class StrContains(str):
@@ -170,7 +172,12 @@
 		self.print_patch = mock.patch('builtins.print')
 		self.print_mock = self.print_patch.start()
 		self.linux_source_mock = mock.Mock()
-		self.linux_source_mock.build_reconfig = mock.Mock()
+		self.linux_source_mock.build_reconfig = mock.Mock(
+			return_value=kunit_kernel.ConfigResult(
+							kunit_kernel.ConfigStatus.SUCCESS, ''))
+		self.linux_source_mock.build_um_kernel = mock.Mock(
+			return_value=kunit_kernel.BuildResult(
+							kunit_kernel.BuildStatus.SUCCESS, ''))
 		self.linux_source_mock.run_kernel = mock.Mock(return_value=[
 				'console 0 enabled',
 				'List of all partitions:'])