test/py: HTML awesome!
authorStephen Warren <swarren@nvidia.com>
Wed, 3 Feb 2016 23:46:34 +0000 (16:46 -0700)
committerSimon Glass <sjg@chromium.org>
Tue, 9 Feb 2016 22:41:19 +0000 (15:41 -0700)
Implement three improvements to the HTML log file:
- Ability to expand/contract sections. All passing sections are contracted
  at file load time so the user can concentrate on issues requiring
  action.
- The overall status report is copied to the top of the log for easy
  access.
- Add links from the status report to the test logs, for easy navigation.

This all relies on Javascript and the jquery library. If the user doesn't
have Javascript enabled, or jquery can't be downloaded, the log should
look and behave identically to how it did before this patch.

A few notes on the diff:

- A few more 'with log.section("xxx")' were added, so that all stream
  blocks are kept within a section block for consistent HTML entity
  nesting structure. This changed indentation in a few places, making
  the diff look slightly larger.
- HTML entity IDs are cleaned up. We assign simple incrementing integer
  IDs now, rather than using mangled test names which were possibly
  invalid.
- Sections and streams now use common CSS class names (in addition to the
  current separate class names) to more easily share the new behaviour.
  This also reduces the CSS file size since rules don't need to be
  duplicated.
- An "OK" status is logged after some external command executions so that
  make and flash steps are auto-contracted at log file load time, assuming
  they passed.

Signed-off-by: Stephen Warren <swarren@nvidia.com>
test/py/conftest.py
test/py/multiplexed_log.css
test/py/multiplexed_log.py
test/py/u_boot_console_exec_attach.py

index 09638e64a3e3c60f66f74c734460db341f12a334..3012c8e4951341a8b7fdeb50d853964fa2aaad40 100644 (file)
@@ -129,10 +129,12 @@ def pytest_configure(config):
             ['make', o_opt, '-s', board_type + '_defconfig'],
             ['make', o_opt, '-s', '-j8'],
         )
-        runner = log.get_runner('make', sys.stdout)
-        for cmd in cmds:
-            runner.run(cmd, cwd=source_dir)
-        runner.close()
+        with log.section('make'):
+            runner = log.get_runner('make', sys.stdout)
+            for cmd in cmds:
+                runner.run(cmd, cwd=source_dir)
+            runner.close()
+            log.status_pass('OK')
 
     class ArbitraryAttributeContainer(object):
         pass
@@ -255,6 +257,7 @@ def u_boot_console(request):
     console.ensure_spawned()
     return console
 
+anchors = {}
 tests_not_run = set()
 tests_failed = set()
 tests_xpassed = set()
@@ -294,27 +297,33 @@ def cleanup():
     if console:
         console.close()
     if log:
-        log.status_pass('%d passed' % len(tests_passed))
-        if tests_skipped:
-            log.status_skipped('%d skipped' % len(tests_skipped))
-            for test in tests_skipped:
-                log.status_skipped('... ' + test)
-        if tests_xpassed:
-            log.status_xpass('%d xpass' % len(tests_xpassed))
-            for test in tests_xpassed:
-                log.status_xpass('... ' + test)
-        if tests_xfailed:
-            log.status_xfail('%d xfail' % len(tests_xfailed))
-            for test in tests_xfailed:
-                log.status_xfail('... ' + test)
-        if tests_failed:
-            log.status_fail('%d failed' % len(tests_failed))
-            for test in tests_failed:
-                log.status_fail('... ' + test)
-        if tests_not_run:
-            log.status_fail('%d not run' % len(tests_not_run))
-            for test in tests_not_run:
-                log.status_fail('... ' + test)
+        with log.section('Status Report', 'status_report'):
+            log.status_pass('%d passed' % len(tests_passed))
+            if tests_skipped:
+                log.status_skipped('%d skipped' % len(tests_skipped))
+                for test in tests_skipped:
+                    anchor = anchors.get(test, None)
+                    log.status_skipped('... ' + test, anchor)
+            if tests_xpassed:
+                log.status_xpass('%d xpass' % len(tests_xpassed))
+                for test in tests_xpassed:
+                    anchor = anchors.get(test, None)
+                    log.status_xpass('... ' + test, anchor)
+            if tests_xfailed:
+                log.status_xfail('%d xfail' % len(tests_xfailed))
+                for test in tests_xfailed:
+                    anchor = anchors.get(test, None)
+                    log.status_xfail('... ' + test, anchor)
+            if tests_failed:
+                log.status_fail('%d failed' % len(tests_failed))
+                for test in tests_failed:
+                    anchor = anchors.get(test, None)
+                    log.status_fail('... ' + test, anchor)
+            if tests_not_run:
+                log.status_fail('%d not run' % len(tests_not_run))
+                for test in tests_not_run:
+                    anchor = anchors.get(test, None)
+                    log.status_fail('... ' + test, anchor)
         log.close()
 atexit.register(cleanup)
 
@@ -380,7 +389,7 @@ def pytest_runtest_setup(item):
         Nothing.
     """
 
-    log.start_section(item.name)
+    anchors[item.name] = log.start_section(item.name)
     setup_boardspec(item)
     setup_buildconfigspec(item)
 
index f6240d52da661a3c75c60da3a8af567a5767137d..f135b10a24c92e98f317bfae5f8e181aa46fcdbd 100644 (file)
@@ -25,37 +25,24 @@ pre {
     color: #808080;
 }
 
-.section {
+.block {
     border-style: solid;
     border-color: #303030;
     border-width: 0px 0px 0px 5px;
     padding-left: 5px
 }
 
-.section-header {
+.block-header {
     background-color: #303030;
     margin-left: -5px;
     margin-top: 5px;
 }
 
-.section-trailer {
-    display: none;
+.block-header:hover {
+    text-decoration: underline;
 }
 
-.stream {
-    border-style: solid;
-    border-color: #303030;
-    border-width: 0px 0px 0px 5px;
-    padding-left: 5px
-}
-
-.stream-header {
-    background-color: #303030;
-    margin-left: -5px;
-    margin-top: 5px;
-}
-
-.stream-trailer {
+.block-trailer {
     display: none;
 }
 
@@ -94,3 +81,21 @@ pre {
 .status-fail {
     color: #ff0000
 }
+
+.hidden {
+    display: none;
+}
+
+a:link {
+    text-decoration: inherit;
+    color: inherit;
+}
+
+a:visited {
+    text-decoration: inherit;
+    color: inherit;
+}
+
+a:hover {
+    text-decoration: underline;
+}
index 69a577e5772053a3c6aa315281be1fe66b61d1c9..68917eb0ea9644d4e922ebcba2a07065a6e8e7bf 100644 (file)
@@ -168,12 +168,13 @@ class SectionCtxMgr(object):
     Objects of this type should be created by factory functions in the Logfile
     class rather than directly."""
 
-    def __init__(self, log, marker):
+    def __init__(self, log, marker, anchor):
         """Initialize a new object.
 
         Args:
             log: The Logfile object to log to.
             marker: The name of the nested log section.
+            anchor: The anchor value to pass to start_section().
 
         Returns:
             Nothing.
@@ -181,9 +182,10 @@ class SectionCtxMgr(object):
 
         self.log = log
         self.marker = marker
+        self.anchor = anchor
 
     def __enter__(self):
-        self.log.start_section(self.marker)
+        self.anchor = self.log.start_section(self.marker, self.anchor)
 
     def __exit__(self, extype, value, traceback):
         self.log.end_section(self.marker)
@@ -206,11 +208,70 @@ class Logfile(object):
         self.last_stream = None
         self.blocks = []
         self.cur_evt = 1
+        self.anchor = 0
+
         shutil.copy(mod_dir + '/multiplexed_log.css', os.path.dirname(fn))
         self.f.write('''\
 <html>
 <head>
 <link rel="stylesheet" type="text/css" href="multiplexed_log.css">
+<script src="http://code.jquery.com/jquery.min.js"></script>
+<script>
+$(document).ready(function () {
+    // Copy status report HTML to start of log for easy access
+    sts = $(".block#status_report")[0].outerHTML;
+    $("tt").prepend(sts);
+
+    // Add expand/contract buttons to all block headers
+    btns = "<span class=\\\"block-expand hidden\\\">[+] </span>" +
+        "<span class=\\\"block-contract\\\">[-] </span>";
+    $(".block-header").prepend(btns);
+
+    // Pre-contract all blocks which passed, leaving only problem cases
+    // expanded, to highlight issues the user should look at.
+    // Only top-level blocks (sections) should have any status
+    passed_bcs = $(".block-content:has(.status-pass)");
+    // Some blocks might have multiple status entries (e.g. the status
+    // report), so take care not to hide blocks with partial success.
+    passed_bcs = passed_bcs.not(":has(.status-fail)");
+    passed_bcs = passed_bcs.not(":has(.status-xfail)");
+    passed_bcs = passed_bcs.not(":has(.status-xpass)");
+    passed_bcs = passed_bcs.not(":has(.status-skipped)");
+    // Hide the passed blocks
+    passed_bcs.addClass("hidden");
+    // Flip the expand/contract button hiding for those blocks.
+    bhs = passed_bcs.parent().children(".block-header")
+    bhs.children(".block-expand").removeClass("hidden");
+    bhs.children(".block-contract").addClass("hidden");
+
+    // Add click handler to block headers.
+    // The handler expands/contracts the block.
+    $(".block-header").on("click", function (e) {
+        var header = $(this);
+        var content = header.next(".block-content");
+        var expanded = !content.hasClass("hidden");
+        if (expanded) {
+            content.addClass("hidden");
+            header.children(".block-expand").first().removeClass("hidden");
+            header.children(".block-contract").first().addClass("hidden");
+        } else {
+            header.children(".block-contract").first().removeClass("hidden");
+            header.children(".block-expand").first().addClass("hidden");
+            content.removeClass("hidden");
+        }
+    });
+
+    // When clicking on a link, expand the target block
+    $("a").on("click", function (e) {
+        var block = $($(this).attr("href"));
+        var header = block.children(".block-header");
+        var content = block.children(".block-content").first();
+        header.children(".block-contract").first().removeClass("hidden");
+        header.children(".block-expand").first().addClass("hidden");
+        content.removeClass("hidden");
+    });
+});
+</script>
 </head>
 <body>
 <tt>
@@ -273,45 +334,60 @@ class Logfile(object):
         if not self.last_stream:
             return
         self.f.write('</pre>\n')
-        self.f.write('<div class="stream-trailer" id="' +
-                     self.last_stream.name + '">End stream: ' +
+        self.f.write('<div class="stream-trailer block-trailer">End stream: ' +
                      self.last_stream.name + '</div>\n')
         self.f.write('</div>\n')
+        self.f.write('</div>\n')
         self.last_stream = None
 
-    def _note(self, note_type, msg):
+    def _note(self, note_type, msg, anchor=None):
         """Write a note or one-off message to the log file.
 
         Args:
             note_type: The type of note. This must be a value supported by the
                 accompanying multiplexed_log.css.
             msg: The note/message to log.
+            anchor: Optional internal link target.
 
         Returns:
             Nothing.
         """
 
         self._terminate_stream()
-        self.f.write('<div class="' + note_type + '">\n<pre>')
+        self.f.write('<div class="' + note_type + '">\n')
+        if anchor:
+            self.f.write('<a href="#%s">\n' % anchor)
+        self.f.write('<pre>')
         self.f.write(self._escape(msg))
-        self.f.write('\n</pre></div>\n')
+        self.f.write('\n</pre>\n')
+        if anchor:
+            self.f.write('</a>\n')
+        self.f.write('</div>\n')
 
-    def start_section(self, marker):
+    def start_section(self, marker, anchor=None):
         """Begin a new nested section in the log file.
 
         Args:
             marker: The name of the section that is starting.
+            anchor: The value to use for the anchor. If None, a unique value
+              will be calculated and used
 
         Returns:
-            Nothing.
+            Name of the HTML anchor emitted before section.
         """
 
         self._terminate_stream()
         self.blocks.append(marker)
+        if not anchor:
+            self.anchor += 1
+            anchor = str(self.anchor)
         blk_path = '/'.join(self.blocks)
-        self.f.write('<div class="section" id="' + blk_path + '">\n')
-        self.f.write('<div class="section-header" id="' + blk_path +
-                     '">Section: ' + blk_path + '</div>\n')
+        self.f.write('<div class="section block" id="' + anchor + '">\n')
+        self.f.write('<div class="section-header block-header">Section: ' +
+                     blk_path + '</div>\n')
+        self.f.write('<div class="section-content block-content">\n')
+
+        return anchor
 
     def end_section(self, marker):
         """Terminate the current nested section in the log file.
@@ -331,12 +407,13 @@ class Logfile(object):
                             (marker, '/'.join(self.blocks)))
         self._terminate_stream()
         blk_path = '/'.join(self.blocks)
-        self.f.write('<div class="section-trailer" id="section-trailer-' +
-                     blk_path + '">End section: ' + blk_path + '</div>\n')
+        self.f.write('<div class="section-trailer block-trailer">' +
+                     'End section: ' + blk_path + '</div>\n')
+        self.f.write('</div>\n')
         self.f.write('</div>\n')
         self.blocks.pop()
 
-    def section(self, marker):
+    def section(self, marker, anchor=None):
         """Create a temporary section in the log file.
 
         This function creates a context manager for Python's "with" statement,
@@ -349,12 +426,13 @@ class Logfile(object):
 
         Args:
             marker: The name of the nested section.
+            anchor: The anchor value to pass to start_section().
 
         Returns:
             A context manager object.
         """
 
-        return SectionCtxMgr(self, marker)
+        return SectionCtxMgr(self, marker, anchor)
 
     def error(self, msg):
         """Write an error note to the log file.
@@ -404,65 +482,70 @@ class Logfile(object):
 
         self._note("action", msg)
 
-    def status_pass(self, msg):
+    def status_pass(self, msg, anchor=None):
         """Write a note to the log file describing test(s) which passed.
 
         Args:
             msg: A message describing the passed test(s).
+            anchor: Optional internal link target.
 
         Returns:
             Nothing.
         """
 
-        self._note("status-pass", msg)
+        self._note("status-pass", msg, anchor)
 
-    def status_skipped(self, msg):
+    def status_skipped(self, msg, anchor=None):
         """Write a note to the log file describing skipped test(s).
 
         Args:
             msg: A message describing the skipped test(s).
+            anchor: Optional internal link target.
 
         Returns:
             Nothing.
         """
 
-        self._note("status-skipped", msg)
+        self._note("status-skipped", msg, anchor)
 
-    def status_xfail(self, msg):
+    def status_xfail(self, msg, anchor=None):
         """Write a note to the log file describing xfailed test(s).
 
         Args:
             msg: A message describing the xfailed test(s).
+            anchor: Optional internal link target.
 
         Returns:
             Nothing.
         """
 
-        self._note("status-xfail", msg)
+        self._note("status-xfail", msg, anchor)
 
-    def status_xpass(self, msg):
+    def status_xpass(self, msg, anchor=None):
         """Write a note to the log file describing xpassed test(s).
 
         Args:
             msg: A message describing the xpassed test(s).
+            anchor: Optional internal link target.
 
         Returns:
             Nothing.
         """
 
-        self._note("status-xpass", msg)
+        self._note("status-xpass", msg, anchor)
 
-    def status_fail(self, msg):
+    def status_fail(self, msg, anchor=None):
         """Write a note to the log file describing failed test(s).
 
         Args:
             msg: A message describing the failed test(s).
+            anchor: Optional internal link target.
 
         Returns:
             Nothing.
         """
 
-        self._note("status-fail", msg)
+        self._note("status-fail", msg, anchor)
 
     def get_stream(self, name, chained_file=None):
         """Create an object to log a single stream's data into the log file.
@@ -519,9 +602,10 @@ class Logfile(object):
 
         if stream != self.last_stream:
             self._terminate_stream()
-            self.f.write('<div class="stream" id="%s">\n' % stream.name)
-            self.f.write('<div class="stream-header" id="' + stream.name +
-                         '">Stream: ' + stream.name + '</div>\n')
+            self.f.write('<div class="stream block">\n')
+            self.f.write('<div class="stream-header block-header">Stream: ' +
+                         stream.name + '</div>\n')
+            self.f.write('<div class="stream-content block-content">\n')
             self.f.write('<pre>')
         if implicit:
             self.f.write('<span class="implicit">')
index 19520cb3b9072eceac7cb2f47385b9b9a8441d31..1be27c1930794d244f34c1f745653a4bdfd459f9 100644 (file)
@@ -35,11 +35,13 @@ class ConsoleExecAttach(ConsoleBase):
         # HW flow control would mean this could be infinite.
         super(ConsoleExecAttach, self).__init__(log, config, max_fifo_fill=16)
 
-        self.log.action('Flashing U-Boot')
-        cmd = ['u-boot-test-flash', config.board_type, config.board_identity]
-        runner = self.log.get_runner(cmd[0], sys.stdout)
-        runner.run(cmd)
-        runner.close()
+        with self.log.section('flash'):
+            self.log.action('Flashing U-Boot')
+            cmd = ['u-boot-test-flash', config.board_type, config.board_identity]
+            runner = self.log.get_runner(cmd[0], sys.stdout)
+            runner.run(cmd)
+            runner.close()
+            self.log.status_pass('OK')
 
     def get_spawn(self):
         """Connect to a fresh U-Boot instance.