1 #!/usr/bin/python 2 # Copyright 2018 The Chromium OS Authors. All rights reserved. 3 # Use of this source code is governed by a BSD-style license that can be 4 # found in the LICENSE file. 5 6 import datetime 7 import json 8 import os 9 import shutil 10 import tempfile 11 import unittest 12 13 import dateutil.parser 14 15 import common 16 import tast 17 18 from autotest_lib.client.common_lib import base_job 19 from autotest_lib.client.common_lib import error 20 from autotest_lib.client.common_lib import utils 21 22 23 # Arbitrary base time to use in tests. 24 BASE_TIME = dateutil.parser.parse('2018-01-01T00:00:00Z') 25 26 # Arbitrary fixed time to use in place of time.time() when running tests. 27 NOW = BASE_TIME + datetime.timedelta(0, 60) 28 29 30 class TastTest(unittest.TestCase): 31 """Tests the tast.tast Autotest server test. 32 33 This unit test verifies interactions between the tast.py Autotest server 34 test and the 'tast' executable that's actually responsible for running 35 individual Tast tests and reporting their results. To do that, it sets up a 36 fake environment in which it can run the Autotest test, including a fake 37 implementation of the 'tast' executable provided by testdata/fake_tast.py. 38 """ 39 40 # Arbitrary data to pass to the tast command. 41 HOST = 'dut.example.net' 42 PORT = 22 43 TEST_PATTERNS = ['(bvt)'] 44 MAX_RUN_SEC = 300 45 46 def setUp(self): 47 self._temp_dir = tempfile.mkdtemp('.tast_unittest') 48 49 def make_subdir(subdir): 50 # pylint: disable=missing-docstring 51 path = os.path.join(self._temp_dir, subdir) 52 os.mkdir(path) 53 return path 54 55 self._job = FakeServerJob(make_subdir('job'), make_subdir('tmp')) 56 self._bin_dir = make_subdir('bin') 57 self._out_dir = make_subdir('out') 58 self._root_dir = make_subdir('root') 59 self._set_up_root() 60 61 self._test = tast.tast(self._job, self._bin_dir, self._out_dir) 62 63 self._test_patterns = [] 64 self._tast_commands = {} 65 66 def tearDown(self): 67 shutil.rmtree(self._temp_dir) 68 69 def _get_path_in_root(self, orig_path): 70 """Appends a path to self._root_dir (which stores Tast-related files). 71 72 @param orig_path: Path to append, e.g. '/usr/bin/tast'. 73 @return: Path within the root dir, e.g. '/path/to/root/usr/bin/tast'. 74 """ 75 return os.path.join(self._root_dir, os.path.relpath(orig_path, '/')) 76 77 def _set_up_root(self, ssp=False): 78 """Creates Tast-related files and dirs within self._root_dir. 79 80 @param ssp: If True, install files to locations used with Server-Side 81 Packaging. Otherwise, install to locations used by Portage packages. 82 """ 83 def create_file(orig_dest, src=None): 84 """Creates a file under self._root_dir. 85 86 @param orig_dest: Original absolute path, e.g. "/usr/bin/tast". 87 @param src: Absolute path to file to copy, or none to create empty. 88 @return: Absolute path to created file. 89 """ 90 dest = self._get_path_in_root(orig_dest) 91 if not os.path.exists(os.path.dirname(dest)): 92 os.makedirs(os.path.dirname(dest)) 93 if src: 94 shutil.copyfile(src, dest) 95 shutil.copymode(src, dest) 96 else: 97 open(dest, 'a').close() 98 return dest 99 100 # Copy fake_tast.py to the usual location for the 'tast' executable. 101 # The remote bundle dir and remote_test_runner just need to exist so 102 # tast.py can find them; their contents don't matter since fake_tast.py 103 # won't actually use them. 104 self._tast_path = create_file( 105 tast.tast._SSP_TAST_PATH if ssp else tast.tast._TAST_PATH, 106 os.path.join(os.path.dirname(os.path.realpath(__file__)), 107 'testdata', 'fake_tast.py')) 108 self._remote_bundle_dir = os.path.dirname( 109 create_file(os.path.join(tast.tast._SSP_REMOTE_BUNDLE_DIR if ssp 110 else tast.tast._REMOTE_BUNDLE_DIR, 111 'fake'))) 112 self._remote_test_runner_path = create_file( 113 tast.tast._SSP_REMOTE_TEST_RUNNER_PATH if ssp 114 else tast.tast._REMOTE_TEST_RUNNER_PATH) 115 116 def _init_tast_commands(self, tests, run_private_tests=False): 117 """Sets fake_tast.py's behavior for 'list' and 'run' commands. 118 119 @param tests: List of TestInfo objects. 120 @param run_private_tests: Whether to run private tests. 121 """ 122 list_args = [ 123 'build=False', 124 'patterns=%s' % self.TEST_PATTERNS, 125 'remotebundledir=' + self._remote_bundle_dir, 126 'remoterunner=' + self._remote_test_runner_path, 127 'sshretries=%d' % tast.tast._SSH_CONNECT_RETRIES, 128 'target=%s:%d' % (self.HOST, self.PORT), 129 'downloadprivatebundles=%s' % run_private_tests, 130 'verbose=True', 131 ] 132 run_args = list_args + ['resultsdir=' + self._test.resultsdir] 133 test_list = json.dumps([t.test() for t in tests]) 134 run_files = { 135 self._results_path(): ''.join( 136 [json.dumps(t.test_result()) + '\n' 137 for t in tests if t.start_time()]), 138 } 139 self._tast_commands = { 140 'list': TastCommand(list_args, stdout=test_list), 141 'run': TastCommand(run_args, files_to_write=run_files), 142 } 143 144 def _results_path(self): 145 """Returns the path where "tast run" writes streamed results. 146 147 @return Path to streamed results file. 148 """ 149 return os.path.join(self._test.resultsdir, 150 tast.tast._STREAMED_RESULTS_FILENAME) 151 152 def _run_test(self, ignore_test_failures=False, run_private_tests=False): 153 """Writes fake_tast.py's configuration and runs the test. 154 155 @param ignore_test_failures: Passed as the identically-named arg to 156 Tast.initialize(). 157 @param run_private_tests: Passed as the identically-named arg to 158 Tast.initialize(). 159 """ 160 self._test.initialize(FakeHost(self.HOST, self.PORT), 161 self.TEST_PATTERNS, 162 ignore_test_failures=ignore_test_failures, 163 max_run_sec=self.MAX_RUN_SEC, 164 install_root=self._root_dir, 165 run_private_tests=run_private_tests) 166 self._test.set_fake_now_for_testing( 167 (NOW - tast._UNIX_EPOCH).total_seconds()) 168 169 cfg = {} 170 for name, cmd in self._tast_commands.iteritems(): 171 cfg[name] = vars(cmd) 172 path = os.path.join(os.path.dirname(self._tast_path), 'config.json') 173 with open(path, 'a') as f: 174 json.dump(cfg, f) 175 176 try: 177 self._test.run_once() 178 finally: 179 if self._job.post_run_hook: 180 self._job.post_run_hook() 181 182 def _run_test_for_failure(self, failed, missing): 183 """Calls _run_test and checks the resulting failure message. 184 185 @param failed: List of TestInfo objects for expected-to-fail tests. 186 @param missing: List of TestInfo objects for expected-missing tests. 187 """ 188 with self.assertRaises(error.TestFail) as cm: 189 self._run_test() 190 191 msg = self._test._get_failure_message([t.name() for t in failed], 192 [t.name() for t in missing]) 193 self.assertEqual(msg, str(cm.exception)) 194 195 def _load_job_keyvals(self): 196 """Loads job keyvals. 197 198 @return Keyvals as a str-to-str dict, or None if keyval file is missing. 199 """ 200 if not os.path.exists(os.path.join(self._job.resultdir, 201 'keyval')): 202 return None 203 return utils.read_keyval(self._job.resultdir) 204 205 def testPassingTests(self): 206 """Tests that passing tests are reported correctly.""" 207 tests = [TestInfo('pkg.Test1', 0, 2), 208 TestInfo('pkg.Test2', 3, 5), 209 TestInfo('pkg.Test3', 6, 8)] 210 self._init_tast_commands(tests) 211 self._run_test() 212 self.assertEqual(status_string(get_status_entries_from_tests(tests)), 213 status_string(self._job.status_entries)) 214 self.assertIs(self._load_job_keyvals(), None) 215 216 def testFailingTests(self): 217 """Tests that failing tests are reported correctly.""" 218 tests = [TestInfo('pkg.Test1', 0, 2, errors=[('failed', 1)]), 219 TestInfo('pkg.Test2', 3, 6), 220 TestInfo('pkg.Test2', 7, 8, errors=[('another', 7)])] 221 self._init_tast_commands(tests) 222 self._run_test_for_failure([tests[0], tests[2]], []) 223 self.assertEqual(status_string(get_status_entries_from_tests(tests)), 224 status_string(self._job.status_entries)) 225 self.assertIs(self._load_job_keyvals(), None) 226 227 def testIgnoreTestFailures(self): 228 """Tests that tast.tast can still pass with Tast test failures.""" 229 tests = [TestInfo('pkg.Test', 0, 2, errors=[('failed', 1)])] 230 self._init_tast_commands(tests) 231 self._run_test(ignore_test_failures=True) 232 self.assertEqual(status_string(get_status_entries_from_tests(tests)), 233 status_string(self._job.status_entries)) 234 235 def testSkippedTest(self): 236 """Tests that skipped tests aren't reported.""" 237 tests = [TestInfo('pkg.Normal', 0, 1), 238 TestInfo('pkg.Skipped', 2, 2, skip_reason='missing deps')] 239 self._init_tast_commands(tests) 240 self._run_test() 241 self.assertEqual(status_string(get_status_entries_from_tests(tests)), 242 status_string(self._job.status_entries)) 243 self.assertIs(self._load_job_keyvals(), None) 244 245 def testSkippedTestWithErrors(self): 246 """Tests that skipped tests are reported if they also report errors.""" 247 tests = [TestInfo('pkg.Normal', 0, 1), 248 TestInfo('pkg.SkippedWithErrors', 2, 2, skip_reason='bad deps', 249 errors=[('bad deps', 2)])] 250 self._init_tast_commands(tests) 251 self._run_test_for_failure([tests[1]], []) 252 self.assertEqual(status_string(get_status_entries_from_tests(tests)), 253 status_string(self._job.status_entries)) 254 self.assertIs(self._load_job_keyvals(), None) 255 256 def testMissingTests(self): 257 """Tests that missing tests are reported when there's another test.""" 258 tests = [TestInfo('pkg.Test1', None, None), 259 TestInfo('pkg.Test2', 0, 2), 260 TestInfo('pkg.Test3', None, None)] 261 self._init_tast_commands(tests) 262 self._run_test_for_failure([], [tests[0], tests[2]]) 263 self.assertEqual(status_string(get_status_entries_from_tests(tests)), 264 status_string(self._job.status_entries)) 265 self.assertEqual(self._load_job_keyvals(), 266 {'tast_missing_test.0': 'pkg.Test1', 267 'tast_missing_test.1': 'pkg.Test3'}) 268 269 def testNoTestsRun(self): 270 """Tests that a missing test is reported when it's the only test.""" 271 tests = [TestInfo('pkg.Test', None, None)] 272 self._init_tast_commands(tests) 273 self._run_test_for_failure([], [tests[0]]) 274 self.assertEqual(status_string(get_status_entries_from_tests(tests)), 275 status_string(self._job.status_entries)) 276 self.assertEqual(self._load_job_keyvals(), 277 {'tast_missing_test.0': 'pkg.Test'}) 278 279 def testHangingTest(self): 280 """Tests that a not-finished test is reported.""" 281 tests = [TestInfo('pkg.Test1', 0, 2), 282 TestInfo('pkg.Test2', 3, None), 283 TestInfo('pkg.Test3', None, None)] 284 self._init_tast_commands(tests) 285 self._run_test_for_failure([tests[1]], [tests[2]]) 286 self.assertEqual( 287 status_string(get_status_entries_from_tests(tests[:2])), 288 status_string(self._job.status_entries)) 289 self.assertEqual(self._load_job_keyvals(), 290 {'tast_missing_test.0': 'pkg.Test3'}) 291 292 def testRunError(self): 293 """Tests that a run error is reported for a non-finished test.""" 294 tests = [TestInfo('pkg.Test1', 0, 2), 295 TestInfo('pkg.Test2', 3, None), 296 TestInfo('pkg.Test3', None, None)] 297 self._init_tast_commands(tests) 298 299 # Simulate the run being aborted due to a lost SSH connection. 300 path = os.path.join(self._test.resultsdir, 301 tast.tast._RUN_ERROR_FILENAME) 302 msg = 'Lost SSH connection to DUT' 303 self._tast_commands['run'].files_to_write[path] = msg 304 self._tast_commands['run'].status = 1 305 306 self._run_test_for_failure([tests[1]], [tests[2]]) 307 self.assertEqual( 308 status_string(get_status_entries_from_tests(tests[:2], msg)), 309 status_string(self._job.status_entries)) 310 self.assertEqual(self._load_job_keyvals(), 311 {'tast_missing_test.0': 'pkg.Test3'}) 312 313 def testNoTestsMatched(self): 314 """Tests that an error is raised if no tests are matched.""" 315 self._init_tast_commands([]) 316 with self.assertRaises(error.TestFail) as _: 317 self._run_test() 318 319 def testListCommandFails(self): 320 """Tests that an error is raised if the list command fails.""" 321 self._init_tast_commands([]) 322 323 # The list subcommand writes log messages to stderr on failure. 324 FAILURE_MSG = "failed to connect" 325 self._tast_commands['list'].status = 1 326 self._tast_commands['list'].stdout = None 327 self._tast_commands['list'].stderr = 'blah blah\n%s\n' % FAILURE_MSG 328 329 # The first line of the exception should include the last line of output 330 # from tast. 331 with self.assertRaises(error.TestFail) as cm: 332 self._run_test() 333 first_line = str(cm.exception).split('\n')[0] 334 self.assertTrue(FAILURE_MSG in first_line, 335 '"%s" not in "%s"' % (FAILURE_MSG, first_line)) 336 337 def testListCommandPrintsGarbage(self): 338 """Tests that an error is raised if the list command prints bad data.""" 339 self._init_tast_commands([]) 340 self._tast_commands['list'].stdout = 'not valid JSON data' 341 with self.assertRaises(error.TestFail) as _: 342 self._run_test() 343 344 def testRunCommandFails(self): 345 """Tests that an error is raised if the run command fails.""" 346 tests = [TestInfo('pkg.Test1', 0, 1), TestInfo('pkg.Test2', 2, 3)] 347 self._init_tast_commands(tests) 348 FAILURE_MSG = "this is the failure" 349 self._tast_commands['run'].status = 1 350 self._tast_commands['run'].stdout = 'blah blah\n%s\n' % FAILURE_MSG 351 352 with self.assertRaises(error.TestFail) as cm: 353 self._run_test() 354 self.assertTrue(FAILURE_MSG in str(cm.exception), 355 '"%s" not in "%s"' % (FAILURE_MSG, str(cm.exception))) 356 self.assertEqual(status_string(get_status_entries_from_tests(tests)), 357 status_string(self._job.status_entries)) 358 359 def testRunCommandWritesTrailingGarbage(self): 360 """Tests that an error is raised if the run command prints bad data.""" 361 tests = [TestInfo('pkg.Test1', 0, 1), TestInfo('pkg.Test2', None, None)] 362 self._init_tast_commands(tests) 363 self._tast_commands['run'].files_to_write[self._results_path()] += \ 364 'not valid JSON data' 365 with self.assertRaises(error.TestFail) as _: 366 self._run_test() 367 self.assertEqual(status_string(get_status_entries_from_tests(tests)), 368 status_string(self._job.status_entries)) 369 370 def testNoResultsFile(self): 371 """Tests that an error is raised if no results file is written.""" 372 tests = [TestInfo('pkg.Test1', None, None)] 373 self._init_tast_commands(tests) 374 self._tast_commands['run'].files_to_write = {} 375 with self.assertRaises(error.TestFail) as _: 376 self._run_test() 377 self.assertEqual(status_string(get_status_entries_from_tests(tests)), 378 status_string(self._job.status_entries)) 379 380 def testNoResultsFileAfterRunCommandFails(self): 381 """Tests that stdout is included in error after missing results.""" 382 tests = [TestInfo('pkg.Test1', None, None)] 383 self._init_tast_commands(tests) 384 FAILURE_MSG = "this is the failure" 385 self._tast_commands['run'].status = 1 386 self._tast_commands['run'].files_to_write = {} 387 self._tast_commands['run'].stdout = 'blah blah\n%s\n' % FAILURE_MSG 388 389 # The first line of the exception should include the last line of output 390 # from tast rather than a message about the missing results file. 391 with self.assertRaises(error.TestFail) as cm: 392 self._run_test() 393 first_line = str(cm.exception).split('\n')[0] 394 self.assertTrue(FAILURE_MSG in first_line, 395 '"%s" not in "%s"' % (FAILURE_MSG, first_line)) 396 self.assertEqual(status_string(get_status_entries_from_tests(tests)), 397 status_string(self._job.status_entries)) 398 399 def testMissingTastExecutable(self): 400 """Tests that an error is raised if the tast command isn't found.""" 401 os.remove(self._get_path_in_root(tast.tast._TAST_PATH)) 402 with self.assertRaises(error.TestFail) as _: 403 self._run_test() 404 405 def testMissingRemoteTestRunner(self): 406 """Tests that an error is raised if remote_test_runner isn't found.""" 407 os.remove(self._get_path_in_root(tast.tast._REMOTE_TEST_RUNNER_PATH)) 408 with self.assertRaises(error.TestFail) as _: 409 self._run_test() 410 411 def testMissingRemoteBundleDir(self): 412 """Tests that an error is raised if remote bundles aren't found.""" 413 shutil.rmtree(self._get_path_in_root(tast.tast._REMOTE_BUNDLE_DIR)) 414 with self.assertRaises(error.TestFail) as _: 415 self._run_test() 416 417 def testSspPaths(self): 418 """Tests that files can be located at their alternate SSP locations.""" 419 for p in os.listdir(self._root_dir): 420 shutil.rmtree(os.path.join(self._root_dir, p)) 421 self._set_up_root(ssp=True) 422 423 tests = [TestInfo('pkg.Test', 0, 1)] 424 self._init_tast_commands(tests) 425 self._run_test() 426 self.assertEqual(status_string(get_status_entries_from_tests(tests)), 427 status_string(self._job.status_entries)) 428 429 def testSumTestTimeouts(self): 430 """Tests that test timeouts are summed for the overall timeout.""" 431 ns_in_sec = 1000000000 432 tests = [TestInfo('pkg.Test1', 0, 0, timeout_ns=(23 * ns_in_sec)), 433 TestInfo('pkg.Test2', 0, 0, timeout_ns=(41 * ns_in_sec))] 434 self._init_tast_commands(tests) 435 self._run_test() 436 self.assertEqual(64 + tast.tast._RUN_OVERHEAD_SEC, 437 self._test._get_run_tests_timeout_sec()) 438 439 def testCapTestTimeout(self): 440 """Tests that excessive test timeouts are capped.""" 441 timeout_ns = 2 * self.MAX_RUN_SEC * 1000000000 442 tests = [TestInfo('pkg.Test', 0, 0, timeout_ns=timeout_ns)] 443 self._init_tast_commands(tests) 444 self._run_test() 445 self.assertEqual(self.MAX_RUN_SEC, 446 self._test._get_run_tests_timeout_sec()) 447 448 def testFailureMessage(self): 449 """Tests that appropriate failure messages are generated.""" 450 # Just do this to initialize the self._test. 451 self._init_tast_commands([TestInfo('pkg.Test', 0, 0)]) 452 self._run_test() 453 454 msg = lambda f, m: self._test._get_failure_message(f, m) 455 self.assertEqual('', msg([], [])) 456 self.assertEqual('1 failed: t1', msg(['t1'], [])) 457 self.assertEqual('2 failed: t1 t2', msg(['t1', 't2'], [])) 458 self.assertEqual('1 missing: t1', msg([], ['t1'])) 459 self.assertEqual('1 failed: t1; 1 missing: t2', msg(['t1'], ['t2'])) 460 461 def testFailureMessageIgnoreTestFailures(self): 462 """Tests that test failures are ignored in messages when requested.""" 463 # Just do this to initialize the self._test. 464 self._init_tast_commands([TestInfo('pkg.Test', 0, 0)]) 465 self._run_test(ignore_test_failures=True) 466 467 msg = lambda f, m: self._test._get_failure_message(f, m) 468 self.assertEqual('', msg([], [])) 469 self.assertEqual('', msg(['t1'], [])) 470 self.assertEqual('1 missing: t1', msg([], ['t1'])) 471 self.assertEqual('1 missing: t2', msg(['t1'], ['t2'])) 472 473 def testRunPrivateTests(self): 474 """Tests running private tests.""" 475 self._init_tast_commands([TestInfo('pkg.Test', 0, 0)], 476 run_private_tests=True) 477 self._run_test(ignore_test_failures=True, run_private_tests=True) 478 479 480 class TestInfo: 481 """Wraps information about a Tast test. 482 483 This struct is used to: 484 - get test definitions printed by fake_tast.py's 'list' command 485 - get test results written by fake_tast.py's 'run' command 486 - get expected base_job.status_log_entry objects that unit tests compare 487 against what tast.Tast actually recorded 488 """ 489 def __init__(self, name, start_offset, end_offset, errors=None, 490 skip_reason=None, attr=None, timeout_ns=0): 491 """ 492 @param name: Name of the test, e.g. 'ui.ChromeLogin'. 493 @param start_offset: Start time as int seconds offset from BASE_TIME, 494 or None to indicate that tast didn't report a result for this test. 495 @param end_offset: End time as int seconds offset from BASE_TIME, or 496 None to indicate that tast reported that this test started but not 497 that it finished. 498 @param errors: List of (string, int) tuples containing reasons and 499 seconds offsets of errors encountered while running the test, or 500 None if no errors were encountered. 501 @param skip_reason: Human-readable reason that the test was skipped, or 502 None to indicate that it wasn't skipped. 503 @param attr: List of string test attributes assigned to the test, or 504 None if no attributes are assigned. 505 @param timeout_ns: Test timeout in nanoseconds. 506 """ 507 def from_offset(offset): 508 """Returns an offset from BASE_TIME. 509 510 @param offset: Offset as integer seconds. 511 @return: datetime.datetime object. 512 """ 513 if offset is None: 514 return None 515 return BASE_TIME + datetime.timedelta(seconds=offset) 516 517 self._name = name 518 self._start_time = from_offset(start_offset) 519 self._end_time = from_offset(end_offset) 520 self._errors = ( 521 [(e[0], from_offset(e[1])) for e in errors] if errors else []) 522 self._skip_reason = skip_reason 523 self._attr = list(attr) if attr else [] 524 self._timeout_ns = timeout_ns 525 526 def name(self): 527 # pylint: disable=missing-docstring 528 return self._name 529 530 def start_time(self): 531 # pylint: disable=missing-docstring 532 return self._start_time 533 534 def test(self): 535 """Returns a test dict printed by the 'list' command. 536 537 @return: dict representing a Tast testing.Test struct. 538 """ 539 return { 540 'name': self._name, 541 'attr': self._attr, 542 'timeout': self._timeout_ns, 543 } 544 545 def test_result(self): 546 """Returns a dict representing a result written by the 'run' command. 547 548 @return: dict representing a Tast TestResult struct. 549 """ 550 return { 551 'name': self._name, 552 'start': to_rfc3339(self._start_time), 553 'end': to_rfc3339(self._end_time), 554 'errors': [{'reason': e[0], 'time': to_rfc3339(e[1])} 555 for e in self._errors], 556 'skipReason': self._skip_reason, 557 'attr': self._attr, 558 'timeout': self._timeout_ns, 559 } 560 561 def status_entries(self, run_error_msg=None): 562 """Returns expected base_job.status_log_entry objects for this test. 563 564 @param run_error_msg: String containing run error message, or None if no 565 run error was encountered. 566 @return: List of Autotest base_job.status_log_entry objects. 567 """ 568 # Deliberately-skipped tests shouldn't have status entries unless errors 569 # were also reported. 570 if self._skip_reason and not self._errors: 571 return [] 572 573 # Tests that weren't even started (e.g. because of an earlier issue) 574 # shouldn't have status entries. 575 if not self._start_time: 576 return [] 577 578 def make(status_code, dt, msg=''): 579 """Makes a base_job.status_log_entry. 580 581 @param status_code: String status code. 582 @param dt: datetime.datetime object containing entry time. 583 @param msg: String message (typically only set for errors). 584 @return: base_job.status_log_entry object. 585 """ 586 timestamp = int((dt - tast._UNIX_EPOCH).total_seconds()) 587 return base_job.status_log_entry( 588 status_code, None, 589 tast.tast._TEST_NAME_PREFIX + self._name, msg, None, 590 timestamp=timestamp) 591 592 entries = [make(tast.tast._JOB_STATUS_START, self._start_time)] 593 594 if self._end_time and not self._errors: 595 entries.append(make(tast.tast._JOB_STATUS_GOOD, self._end_time)) 596 entries.append(make(tast.tast._JOB_STATUS_END_GOOD, self._end_time)) 597 else: 598 for e in self._errors: 599 entries.append(make(tast.tast._JOB_STATUS_FAIL, e[1], e[0])) 600 if not self._end_time: 601 # If the test didn't finish, the run error (if any) should be 602 # included. 603 if run_error_msg: 604 entries.append(make(tast.tast._JOB_STATUS_FAIL, 605 self._start_time, run_error_msg)) 606 entries.append(make(tast.tast._JOB_STATUS_FAIL, 607 self._start_time, 608 tast.tast._TEST_DID_NOT_FINISH_MSG)) 609 entries.append(make(tast.tast._JOB_STATUS_END_FAIL, 610 self._end_time or self._start_time or NOW)) 611 612 return entries 613 614 615 class FakeServerJob: 616 """Fake implementation of server_job from server/server_job.py.""" 617 def __init__(self, result_dir, tmp_dir): 618 self.pkgmgr = None 619 self.autodir = None 620 self.resultdir = result_dir 621 self.tmpdir = tmp_dir 622 self.post_run_hook = None 623 self.status_entries = [] 624 625 def add_post_run_hook(self, hook): 626 """Stub implementation of server_job.add_post_run_hook.""" 627 self.post_run_hook = hook 628 629 def record_entry(self, entry, log_in_subdir=True): 630 """Stub implementation of server_job.record_entry.""" 631 assert(not log_in_subdir) 632 self.status_entries.append(entry) 633 634 635 class FakeHost: 636 """Fake implementation of AbstractSSHHost from server/hosts/abstract_ssh.py. 637 """ 638 def __init__(self, hostname, port): 639 self.hostname = hostname 640 self.port = port 641 642 643 class TastCommand(object): 644 """Args and behavior for fake_tast.py for a given command, e.g. "list".""" 645 646 def __init__(self, required_args, status=0, stdout=None, stderr=None, 647 files_to_write=None): 648 """ 649 @param required_args: List of required args, each specified as 650 'name=value'. Names correspond to argparse-provided names in 651 fake_tast.py (typically just the flag name, e.g. 'build' or 652 'resultsdir'). Values correspond to str() representations of the 653 argparse-provided values. 654 @param status: Status code for fake_tast.py to return. 655 @param stdout: Data to write to stdout. 656 @param stderr: Data to write to stderr. 657 @param files_to_write: Dict mapping from paths of files to write to 658 their contents, or None to not write any files. 659 """ 660 self.required_args = required_args 661 self.status = status 662 self.stdout = stdout 663 self.stderr = stderr 664 self.files_to_write = files_to_write if files_to_write else {} 665 666 667 def to_rfc3339(t): 668 """Returns an RFC3339 timestamp. 669 670 @param t: UTC datetime.datetime object or None for the zero time. 671 @return: String RFC3339 time, e.g. '2018-01-02T02:34:28Z'. 672 """ 673 if t is None: 674 return '0001-01-01T00:00:00Z' 675 assert(not t.utcoffset()) 676 return t.strftime('%Y-%m-%dT%H:%M:%SZ') 677 678 679 def get_status_entries_from_tests(tests, run_error_msg=None): 680 """Returns a flattened list of status entries from TestInfo objects. 681 682 @param tests: List of TestInfo objects. 683 @param run_error_msg: String containing run error message, or None if no 684 run error was encountered. 685 @return: Flattened list of base_job.status_log_entry objects produced by 686 calling status_entries() on each TestInfo object. 687 """ 688 return sum([t.status_entries(run_error_msg) for t in tests], []) 689 690 691 def status_string(entries): 692 """Returns a string describing a list of base_job.status_log_entry objects. 693 694 @param entries: List of base_job.status_log_entry objects. 695 @return: String containing space-separated representations of entries. 696 """ 697 strings = [] 698 for entry in entries: 699 timestamp = entry.fields[base_job.status_log_entry.TIMESTAMP_FIELD] 700 s = '[%s %s %s %s]' % (timestamp, entry.operation, entry.status_code, 701 repr(str(entry.message))) 702 strings.append(s) 703 704 return ' '.join(strings) 705 706 707 if __name__ == '__main__': 708 unittest.main() 709