From 1827fc25ab13e38dbb362b65009762e3a32bb64a Mon Sep 17 00:00:00 2001 From: Greg Clayton Date: Sat, 19 Sep 2015 00:39:09 +0000 Subject: [PATCH] Added a curses based way to see the test suite running. Works only where curses is implemented. Try it out with: ./dotest.py --results-formatter=test_results.Curses --results-file=/dev/stdout llvm-svn: 248072 --- lldb/test/dosep.py | 39 +++--- lldb/test/lldbcurses.py | 275 ++++++++++++++++++++++++++++++++++++++ lldb/test/test_results.py | 116 ++++++++++++++++ 3 files changed, 413 insertions(+), 17 deletions(-) create mode 100644 lldb/test/lldbcurses.py diff --git a/lldb/test/dosep.py b/lldb/test/dosep.py index 9feed710ab4e..eac4050ad4c6 100755 --- a/lldb/test/dosep.py +++ b/lldb/test/dosep.py @@ -44,7 +44,7 @@ import signal import subprocess import sys import threading - +import test_results import dotest_channels import dotest_args @@ -75,7 +75,6 @@ total_tests = None test_name_len = None dotest_options = None output_on_success = False - RESULTS_FORMATTER = None RUNNER_PROCESS_ASYNC_MAP = None RESULTS_LISTENER_CHANNEL = None @@ -114,20 +113,22 @@ def setup_global_variables( def report_test_failure(name, command, output): global output_lock with output_lock: - print >> sys.stderr - print >> sys.stderr, output - print >> sys.stderr, "[%s FAILED]" % name - print >> sys.stderr, "Command invoked: %s" % ' '.join(command) + if not (RESULTS_FORMATTER and RESULTS_FORMATTER.is_using_terminal()): + print >> sys.stderr + print >> sys.stderr, output + print >> sys.stderr, "[%s FAILED]" % name + print >> sys.stderr, "Command invoked: %s" % ' '.join(command) update_progress(name) def report_test_pass(name, output): global output_lock, output_on_success with output_lock: - if output_on_success: - print >> sys.stderr - print >> sys.stderr, output - print >> sys.stderr, "[%s PASSED]" % name + if not (RESULTS_FORMATTER and RESULTS_FORMATTER.is_using_terminal()): + if output_on_success: + print >> sys.stderr + print >> sys.stderr, output + print >> sys.stderr, "[%s PASSED]" % name update_progress(name) @@ -135,10 +136,11 @@ def update_progress(test_name=""): global output_lock, test_counter, total_tests, test_name_len with output_lock: counter_len = len(str(total_tests)) - sys.stderr.write( - "\r%*d out of %d test suites processed - %-*s" % - (counter_len, test_counter.value, total_tests, - test_name_len.value, test_name)) + if not (RESULTS_FORMATTER and RESULTS_FORMATTER.is_using_terminal()): + sys.stderr.write( + "\r%*d out of %d test suites processed - %-*s" % + (counter_len, test_counter.value, total_tests, + test_name_len.value, test_name)) if len(test_name) > test_name_len.value: test_name_len.value = len(test_name) test_counter.value += 1 @@ -434,11 +436,13 @@ def find_test_files_in_dir_tree(dir_root, found_func): def initialize_global_vars_common(num_threads, test_work_items): global total_tests, test_counter, test_name_len + total_tests = sum([len(item[1]) for item in test_work_items]) test_counter = multiprocessing.Value('i', 0) test_name_len = multiprocessing.Value('i', 0) - print >> sys.stderr, "Testing: %d test suites, %d thread%s" % ( - total_tests, num_threads, (num_threads > 1) * "s") + if not (RESULTS_FORMATTER and RESULTS_FORMATTER.is_using_terminal()): + print >> sys.stderr, "Testing: %d test suites, %d thread%s" % ( + total_tests, num_threads, (num_threads > 1) * "s") update_progress() @@ -1162,9 +1166,10 @@ def main(print_details_on_success, num_threads, test_subdir, dotest_argv = sys.argv[1:] - global output_on_success, RESULTS_FORMATTER + global output_on_success, RESULTS_FORMATTER, output_lock output_on_success = print_details_on_success RESULTS_FORMATTER = results_formatter + RESULTS_FORMATTER.set_lock(output_lock) # We can't use sys.path[0] to determine the script directory # because it doesn't work under a debugger diff --git a/lldb/test/lldbcurses.py b/lldb/test/lldbcurses.py new file mode 100644 index 000000000000..f1530c295439 --- /dev/null +++ b/lldb/test/lldbcurses.py @@ -0,0 +1,275 @@ +import time +import curses, curses.panel + +class Point(object): + def __init__(self, x, y): + self.x = x + self.y = y + + def __repr__(self): + return str(self) + + def __str__(self): + return "(x=%u, y=%u)" % (self.x, self.y) + + def is_valid_coordinate(self): + return self.x >= 0 and self.y >= 0 + +class Size(object): + def __init__(self, w, h): + self.w = w + self.h = h + + def __repr__(self): + return str(self) + + def __str__(self): + return "(w=%u, h=%u)" % (self.w, self.h) + +class Rect(object): + def __init__(self, x=0, y=0, w=0, h=0): + self.origin = Point(x, y) + self.size = Size(w, h) + + def __repr__(self): + return str(self) + + def __str__(self): + return "{ %s, %s }" % (str(self.origin), str(self.size)) + + def get_min_x(self): + return self.origin.x + + def get_max_x(self): + return self.origin.x + self.size.w + + def get_min_y(self): + return self.origin.y + + def get_max_y(self): + return self.origin.y + self.size.h + + def contains_point(self, pt): + if pt.x < self.get_max_x(): + if pt.y < self.get_max_y(): + if pt.x >= self.get_min_y(): + return pt.y >= self.get_min_y() + return False + +class Window(object): + def __init__(self, window): + self.window = window + + def point_in_window(self, pt): + size = self.get_size() + return pt.x >= 0 and pt.x < size.w and pt.y >= 0 and pt.y < size.h + + def addstr(self, pt, str): + try: + self.window.addstr(pt.y, pt.x, str) + except: + pass + + def addnstr(self, pt, str, n): + try: + self.window.addnstr(pt.y, pt.x, str, n) + except: + pass + + def box(self): + self.window.box() + + def get_contained_rect(self, top_inset=0, bottom_inset=0, left_inset=0, right_inset=0, height=-1, width=-1): + '''Get a rectangle based on the top "height" lines of this window''' + rect = self.get_frame() + x = rect.origin.x + left_inset + y = rect.origin.y + top_inset + if height == -1: + h = rect.size.h - (top_inset + bottom_inset) + else: + h = height + if width == -1: + w = rect.size.w - (left_inset + right_inset) + else: + w = width + return Rect (x = x, y = y, w = w, h = h) + + def erase(self): + self.window.erase() + + def get_frame(self): + position = self.get_position() + size = self.get_size() + return Rect(x=position.x, y=position.y, w=size.w, h=size.h) + + def get_position(self): + (y, x) = self.window.getbegyx() + return Point(x, y) + + def get_size(self): + (y, x) = self.window.getmaxyx() + return Size(w=x, h=y) + + def refresh(self): + curses.panel.update_panels() + return self.window.refresh() + + def resize(self, size): + return window.resize(size.h, size.w) + +class Panel(Window): + def __init__(self, frame): + window = curses.newwin(frame.size.h,frame.size.w, frame.origin.y, frame.origin.x) + super(Panel, self).__init__(window) + self.panel = curses.panel.new_panel(window) + + def top(self): + self.panel.top() + + def set_position(self, pt): + self.panel.move(pt.y, pt.x) + + def slide_position(self, pt): + new_position = self.get_position() + new_position.x = new_position.x + pt.x + new_position.y = new_position.y + pt.y + self.set_position(new_position) + +class BoxedPanel(Panel): + def __init__(self, frame, title): + super(BoxedPanel, self).__init__(frame) + self.title = title + self.lines = list() + self.first_visible_idx = 0 + self.update() + + def get_usable_width(self): + '''Valid usable width is 0 to (width - 3) since the left and right lines display the box around + this frame and we skip a leading space''' + w = self.get_size().w + if w > 3: + return w-3 + else: + return 0 + + def get_usable_height(self): + '''Valid line indexes are 0 to (height - 2) since the top and bottom lines display the box around this frame.''' + h = self.get_size().h + if h > 2: + return h-2 + else: + return 0 + + def get_point_for_line(self, global_line_idx): + '''Returns the point to use when displaying a line whose index is "line_idx"''' + line_idx = global_line_idx - self.first_visible_idx + num_lines = self.get_usable_height() + if line_idx < num_lines: + return Point(x=2, y=1+line_idx) + else: + return Point(x=-1, y=-1) # return an invalid coordinate if the line index isn't valid + + def set_title (self, title, update=True): + self.title = title + if update: + self.update() + + def _adjust_first_visible_line(self): + num_lines = len(self.lines) + max_visible_lines = self.get_usable_height() + if (num_lines - self.first_visible_idx) > max_visible_lines: + self.first_visible_idx = num_lines - max_visible_lines + + def append_line(self, s, update=True): + self.lines.append(s) + self._adjust_first_visible_line() + if update: + self.update() + + def set_line(self, line_idx, s, update=True): + '''Sets a line "line_idx" within the boxed panel to be "s"''' + if line_idx < 0: + return + while line_idx >= len(self.lines): + self.lines.append('') + self.lines[line_idx] = s + self._adjust_first_visible_line() + if update: + self.update() + + def update(self): + self.erase() + self.box() + if self.title: + self.addstr(Point(x=2, y=0), ' ' + self.title + ' ') + max_width = self.get_usable_width() + for line_idx in range(self.first_visible_idx, len(self.lines)): + pt = self.get_point_for_line(line_idx) + if pt.is_valid_coordinate(): + self.addnstr(pt, self.lines[line_idx], max_width) + else: + return + +class StatusPanel(Panel): + def __init__(self, frame): + super(StatusPanel, self).__init__(frame) + self.status_items = list() + self.status_dicts = dict() + self.next_status_x = 1 + + def add_status_item(self, name, title, format, width, value, update=True): + status_item_dict = { 'name': name, + 'title' : title, + 'width' : width, + 'format' : format, + 'value' : value, + 'x' : self.next_status_x } + index = len(self.status_items) + self.status_items.append(status_item_dict) + self.status_dicts[name] = index + self.next_status_x += width + 2; + if update: + self.update() + + def increment_status(self, name, update=True): + if name in self.status_dicts: + status_item_idx = self.status_dicts[name] + status_item_dict = self.status_items[status_item_idx] + status_item_dict['value'] = status_item_dict['value'] + 1 + if update: + self.update() + + def update_status(self, name, value, update=True): + if name in self.status_dicts: + status_item_idx = self.status_dicts[name] + status_item_dict = self.status_items[status_item_idx] + status_item_dict['value'] = status_item_dict['format'] % (value) + if update: + self.update() + def update(self): + self.erase(); + for status_item_dict in self.status_items: + self.addnstr(Point(x=status_item_dict['x'], y=0), '%s: %s' % (status_item_dict['title'], status_item_dict['value']), status_item_dict['width']) + +stdscr = None + +def intialize_curses(): + global stdscr + stdscr = curses.initscr() + curses.noecho() + curses.cbreak() + stdscr.keypad(1) + try: + curses.start_color() + except: + pass + return Window(stdscr) + +def terminate_curses(): + global stdscr + if stdscr: + stdscr.keypad(0) + curses.echo() + curses.nocbreak() + curses.endwin() + diff --git a/lldb/test/test_results.py b/lldb/test/test_results.py index 4ed0d53cdb74..441a4bf4aee3 100644 --- a/lldb/test/test_results.py +++ b/lldb/test/test_results.py @@ -382,6 +382,8 @@ class ResultsFormatter(object): super(ResultsFormatter, self).__init__() self.out_file = out_file self.options = options + self.using_terminal = False + self.lock = None # used when coordinating output is needed if not self.out_file: raise Exception("ResultsFormatter created with no file object") self.start_time_by_test = {} @@ -392,6 +394,9 @@ class ResultsFormatter(object): # entirely consistent from the outside. self.lock = threading.Lock() + def set_lock(self, lock): + self.lock = lock + def handle_event(self, test_event): """Handles the test event for collection into the formatter output. @@ -436,6 +441,9 @@ class ResultsFormatter(object): del self.start_time_by_test[test_key] return end_time - start_time + def is_using_terminal(self): + """Returns True if this results formatter is using the terminal and output should be avoided""" + return self.using_terminal class XunitFormatter(ResultsFormatter): """Provides xUnit-style formatted output. @@ -881,6 +889,114 @@ class RawPickledFormatter(ResultsFormatter): self.out_file.send( "{}#{}".format(len(pickled_message), pickled_message)) +class Curses(ResultsFormatter): + """Receives live results from tests that are running and reports them to the terminal in a curses GUI""" + + def clear_line(self, y): + self.out_file.write("\033[%u;0H\033[2K" % (y)) + self.out_file.flush() + + def print_line(self, y, str): + self.out_file.write("\033[%u;0H\033[2K%s" % (y, str)) + self.out_file.flush() + + def __init__(self, out_file, options): + # Initialize the parent + super(Curses, self).__init__(out_file, options) + self.using_terminal = True + self.have_curses = True + self.initialize_event = None + self.jobs = [None] * 64 + self.job_tests = [None] * 64 + try: + import lldbcurses + self.main_window = lldbcurses.intialize_curses() + num_jobs = 8 # TODO: need to dynamically determine this + job_frame = self.main_window.get_contained_rect(height=num_jobs+2) + fail_frame = self.main_window.get_contained_rect(top_inset=num_jobs+2, bottom_inset=1) + status_frame = self.main_window.get_contained_rect(height=1, top_inset=self.main_window.get_size().h-1) + self.job_panel = lldbcurses.BoxedPanel(job_frame, "Jobs") + self.fail_panel = lldbcurses.BoxedPanel(fail_frame, "Failures") + self.status_panel = lldbcurses.StatusPanel(status_frame) + self.status_panel.add_status_item(name="success", title="Success (%s)" % self.status_to_short_str('success'), format="%u", width=20, value=0, update=False) + self.status_panel.add_status_item(name="failure", title="Failure (%s)" % self.status_to_short_str('failure'), format="%u", width=20, value=0, update=False) + self.status_panel.add_status_item(name="error", title="Error (%s)" % self.status_to_short_str('error'), format="%u", width=20, value=0, update=False) + self.status_panel.add_status_item(name="skip", title="Skipped (%s)" % self.status_to_short_str('skip'), format="%u", width=20, value=0, update=True) + self.status_panel.add_status_item(name="expected_failure", title="Expected Failure (%s)" % self.status_to_short_str('expected_failure'), format="%u", width=30, value=0, update=False) + self.status_panel.add_status_item(name="unexpected_success", title="Unexpected Success (%s)" % self.status_to_short_str('unexpected_success'), format="%u", width=30, value=0, update=False) + self.main_window.refresh() + except: + self.have_curses = False + lldbcurses.terminate_curses() + self.using_terminal = False + print "Unexpected error:", sys.exc_info()[0] + raise + + + self.line_dict = dict() + self.events_file = open("/tmp/events.txt", "w") + # self.formatters = list() + # if tee_results_formatter: + # self.formatters.append(tee_results_formatter) + + def status_to_short_str(self, status): + if status == 'success': + return '.' + elif status == 'failure': + return 'F' + elif status == 'unexpected_success': + return '?' + elif status == 'expected_failure': + return 'X' + elif status == 'skip': + return 'S' + elif status == 'error': + return 'E' + else: + return status + def handle_event(self, test_event): + if self.lock: + self.lock.acquire() + super(Curses, self).handle_event(test_event) + # for formatter in self.formatters: + # formatter.process_event(test_event) + if self.have_curses: + worker_index = -1 + if 'worker_index' in test_event: + worker_index = test_event['worker_index'] + if 'event' in test_event: + print >>self.events_file, str(test_event) + event = test_event['event'] + if event == 'test_start': + name = test_event['test_class'] + '.' + test_event['test_name'] + self.job_tests[worker_index] = test_event + if 'pid' in test_event: + line = 'pid: ' + str(test_event['pid']) + ' ' + name + else: + line = name + self.job_panel.set_line(worker_index, line) + self.main_window.refresh() + elif event == 'test_result': + status = test_event['status'] + self.status_panel.increment_status(status) + self.job_panel.set_line(worker_index, '') + # if status != 'success' and status != 'skip' and status != 'expect_failure': + name = test_event['test_class'] + '.' + test_event['test_name'] + time = test_event['event_time'] - self.job_tests[worker_index]['event_time'] + self.fail_panel.append_line('%s (%6.2f sec) %s' % (self.status_to_short_str(status), time, name)) + self.main_window.refresh() + self.job_tests[worker_index] = '' + elif event == 'job_begin': + self.jobs[worker_index] = test_event + elif event == 'job_end': + self.jobs[worker_index] = '' + elif event == 'initialize': + self.initialize_event = test_event + num_jobs = test_event['worker_count'] + self.main_window.refresh() + + if self.lock: + self.lock.release() class DumpFormatter(ResultsFormatter): """Formats events to the file as their raw python dictionary format."""