| <!-- |
| Copyright (C) 2020 Apple Inc. All rights reserved. |
| Copyright (C) 2011 Google Inc. All rights reserved. |
| |
| Redistribution and use in source and binary forms, with or without |
| modification, are permitted provided that the following conditions |
| are met: |
| 1. Redistributions of source code must retain the above copyright |
| notice, this list of conditions and the following disclaimer. |
| 2. Redistributions in binary form must reproduce the above copyright |
| notice, this list of conditions and the following disclaimer in the |
| documentation and/or other materials provided with the distribution. |
| |
| THIS SOFTWARE IS PROVIDED BY APPLE INC. ``AS IS'' AND ANY |
| EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE |
| IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR |
| PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR |
| CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, |
| EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, |
| PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR |
| PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY |
| OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT |
| (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE |
| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| --> |
| <!DOCTYPE html> |
| <html> |
| <title>Test Runtimes</title> |
| <style> |
| html { |
| height: 100%; |
| } |
| |
| body { |
| height: 100%; |
| display: flex; |
| flex-direction: column; |
| font-family: "Helvetica Neue"; |
| } |
| |
| td:first-child { |
| text-align: left; |
| } |
| |
| td { |
| text-align: right; |
| } |
| |
| header { |
| position: relative; |
| height: 4rem; |
| } |
| |
| header a { |
| margin-left: 0.25em |
| } |
| |
| header p { |
| font-size: smaller; |
| } |
| |
| #map { |
| position: relative; |
| z-index: 0; |
| flex-grow: 1; |
| cursor: pointer; |
| -webkit-user-select: none; |
| } |
| |
| .extra-dom { |
| display: none; |
| border: none; |
| border-top: 1px dashed; |
| padding: 4px; |
| margin: 0; |
| overflow: auto; |
| cursor: auto; |
| -webkit-user-select: text; |
| } |
| |
| .error { |
| color: red; |
| font-style: italic; |
| } |
| |
| #dropTarget { |
| font-size: 10pt; |
| font-weight: bold; |
| color: #888; |
| position: absolute; |
| top: 4px; |
| right: 4px; |
| border: 2px solid rgba(0, 0, 0, 0.3); |
| background-color: rgba(0, 0, 0, 0.1); |
| padding: 10px; |
| border-radius: 10px; |
| } |
| |
| #dropTarget.dragOver { |
| border: 2px solid rgba(0, 0, 0, 0.1); |
| background-color: rgba(0, 0, 0, 0.5); |
| color: #ddd; |
| } |
| |
| .webtreemap-node { |
| /* Required attributes. */ |
| position: absolute; |
| overflow: hidden; /* To hide overlong captions. */ |
| background: white; /* Nodes must be opaque for zIndex layering. */ |
| border: solid 1px black; /* Calculations assume 1px border. */ |
| |
| transition: top 0.3s, |
| left 0.3s, |
| width 0.3s, |
| height 0.3s; |
| } |
| |
| /* Optional: highlight nodes on mouseover. */ |
| .webtreemap-node:hover { |
| background: #eee; |
| } |
| |
| /* Optional: Different borders depending on level. */ |
| .webtreemap-level0 { |
| border: solid 1px #444; |
| } |
| .webtreemap-level1 { |
| border: solid 1px #666; |
| } |
| .webtreemap-level2 { |
| border: solid 1px #888; |
| } |
| .webtreemap-level3 { |
| border: solid 1px #aaa; |
| } |
| .webtreemap-level4 { |
| border: solid 1px #ccc; |
| } |
| |
| /* Optional: styling on node captions. */ |
| .webtreemap-caption { |
| font-family: sans-serif; |
| font-size: 11px; |
| padding: 2px; |
| text-align: center; |
| } |
| |
| #slow-tests { |
| position: absolute; |
| z-index: 1; |
| top: 10px; |
| left: 10px; |
| box-sizing: border-box; |
| width: calc(100% - 20px); |
| background-color: rgba(255, 255, 255, 0.9); |
| padding: 2em; |
| border-radius: 10px; |
| border: 1px solid gray; |
| } |
| |
| #slow-tests.hidden { |
| display: none; |
| } |
| </style> |
| |
| <script> |
| |
| class WebTreeMap { |
| static get borderWidth() |
| { |
| return 1; |
| } |
| |
| static get padding() |
| { |
| return 1; |
| } |
| |
| constructor(treeData, containerElement) |
| { |
| this._focusedNode = null; |
| |
| let style = getComputedStyle(containerElement, null); |
| let width = parseInt(style.width); |
| let height = parseInt(style.height); |
| |
| this.makeDom(treeData, 0); |
| containerElement.appendChild(treeData.dom); |
| this.position(treeData.dom, 0, 0, width, height); |
| this.layout(treeData, 0, width, height); |
| } |
| |
| focus(tree) |
| { |
| this._focusedNode = tree; |
| |
| // Hide all visible siblings of all our ancestors by lowering them. |
| let level = 0; |
| let root = tree; |
| while (root.parent) { |
| root = root.parent; |
| level += 1; |
| for (let i = 0, sibling; sibling = root.children[i]; ++i) { |
| if (sibling.dom) |
| sibling.dom.style.zIndex = 0; |
| } |
| } |
| let width = root.dom.offsetWidth; |
| let height = root.dom.offsetHeight; |
| // Unhide (raise) and maximize us and our ancestors. |
| for (let t = tree; t.parent; t = t.parent) { |
| // Shift off by border so we don't get nested borders. |
| // TODO: actually make nested borders work (need to adjust width/height). |
| this.position(t.dom, -WebTreeMap.borderWidth, -WebTreeMap.borderWidth, width, height); |
| t.dom.style.zIndex = 1; |
| } |
| // And layout into the topmost box. |
| this.layout(tree, level, width, height); |
| this.handleFocus(tree); |
| } |
| |
| handleFocus(tree) |
| { |
| // For delegation. |
| } |
| |
| makeDom(tree, level) |
| { |
| let dom = document.createElement('div'); |
| dom.style.zIndex = 1; |
| dom.className = 'webtreemap-node webtreemap-level' + Math.min(level, 4); |
| |
| dom.addEventListener('mousedown', e => { |
| if (e.button == 0) { |
| if (this._focusedNode && tree == this._focusedNode && this._focusedNode.parent) |
| this.focus(this._focusedNode.parent); |
| else |
| this.focus(tree); |
| } |
| e.stopPropagation(); |
| return true; |
| }, false); |
| |
| let caption = document.createElement('div'); |
| caption.className = 'webtreemap-caption'; |
| caption.innerHTML = tree.name; |
| dom.appendChild(caption); |
| |
| tree.dom = dom; |
| return dom; |
| } |
| |
| position(dom, x, y, width, height) |
| { |
| // CSS width/height does not include border. |
| width -= WebTreeMap.borderWidth * 2; |
| height -= WebTreeMap.borderWidth * 2; |
| |
| dom.style.left = x + 'px'; |
| dom.style.top = y + 'px'; |
| dom.style.width = Math.max(width, 0) + 'px'; |
| dom.style.height = Math.max(height, 0) + 'px'; |
| } |
| |
| // Given a list of rectangles |nodes|, the 1-d space available |
| // |space|, and a starting rectangle index |start|, compute an span of |
| // rectangles that optimizes a pleasant aspect ratio. |
| // |
| // Returns [end, sum], where end is one past the last rectangle and sum is the |
| // 2-d sum of the rectangles' areas. |
| selectSpan(nodes, space, start) |
| { |
| // Add rectangle one by one, stopping when aspect ratios begin to go |
| // bad. Result is [start,end) covering the best run for this span. |
| // http://scholar.google.com/scholar?cluster=5972512107845615474 |
| let node = nodes[start]; |
| let rmin = node.data['$area']; // Smallest seen child so far. |
| let rmax = rmin; // Largest child. |
| let rsum = 0; // Sum of children in this span. |
| let last_score = 0; // Best score yet found. |
| let end; |
| for (end = start; node = nodes[end]; ++end) { |
| let size = node.data['$area']; |
| if (size < rmin) |
| rmin = size; |
| if (size > rmax) |
| rmax = size; |
| rsum += size; |
| |
| // This formula is from the paper, but you can easily prove to |
| // yourself it's taking the larger of the x/y aspect ratio or the |
| // y/x aspect ratio. The additional magic fudge constant of 5 |
| // makes us prefer wider rectangles to taller ones. |
| let score = Math.max(5 * space * space * rmax / (rsum * rsum), 1 * rsum * rsum / (space * space * rmin)); |
| if (last_score && score > last_score) { |
| rsum -= size; // Undo size addition from just above. |
| break; |
| } |
| last_score = score; |
| } |
| return [end, rsum]; |
| } |
| |
| layout(tree, level, width, height) |
| { |
| if (!('children' in tree)) |
| return; |
| |
| let total = tree.data['$area']; |
| |
| // XXX why do I need an extra -1/-2 here for width/height to look right? |
| let x1 = 0, y1 = 0, x2 = width - 1, y2 = height - 2; |
| x1 += WebTreeMap.padding; y1 += WebTreeMap.padding; |
| x2 -= WebTreeMap.padding; y2 -= WebTreeMap.padding; |
| y1 += 14; // XXX get first child height for caption spacing |
| |
| let pixels_to_units = Math.sqrt(total / ((x2 - x1) * (y2 - y1))); |
| |
| for (let start = 0, child; child = tree.children[start]; ++start) { |
| if (x2 - x1 < 60 || y2 - y1 < 40) { |
| if (child.dom) { |
| child.dom.style.zIndex = 0; |
| this.position(child.dom, -2, -2, 0, 0); |
| } |
| continue; |
| } |
| |
| // In theory we can dynamically decide whether to split in x or y based |
| // on aspect ratio. In practice, changing split direction with this |
| // layout doesn't look very good. |
| // var ysplit = (y2 - y1) > (x2 - x1); |
| let ysplit = true; |
| |
| let space; // Space available along layout axis. |
| if (ysplit) |
| space = (y2 - y1) * pixels_to_units; |
| else |
| space = (x2 - x1) * pixels_to_units; |
| |
| let span = this.selectSpan(tree.children, space, start); |
| let end = span[0]; |
| let rsum = span[1]; |
| |
| // Now that we've selected a span, lay out rectangles [start,end) in our |
| // available space. |
| let x = x1; |
| let y = y1; |
| for (let i = start; i < end; ++i) { |
| child = tree.children[i]; |
| if (!child.dom) { |
| child.parent = tree; |
| child.dom = this.makeDom(child, level + 1); |
| tree.dom.appendChild(child.dom); |
| } else { |
| child.dom.style.zIndex = 1; |
| } |
| let size = child.data['$area']; |
| let frac = size / rsum; |
| if (ysplit) { |
| width = rsum / space; |
| height = size / width; |
| } else { |
| height = rsum / space; |
| width = size / height; |
| } |
| width /= pixels_to_units; |
| height /= pixels_to_units; |
| width = Math.round(width); |
| height = Math.round(height); |
| this.position(child.dom, x, y, width, height); |
| if ('children' in child) { |
| this.layout(child, level + 1, width, height); |
| } |
| if (ysplit) |
| y += height; |
| else |
| x += width; |
| } |
| |
| // Shrink our available space based on the amount we used. |
| if (ysplit) |
| x1 += Math.round((rsum / space) / pixels_to_units); |
| else |
| y1 += Math.round((rsum / space) / pixels_to_units); |
| |
| // end points one past where we ended, which is where we want to |
| // begin the next iteration, but subtract one to balance the ++ in |
| // the loop. |
| start = end - 1; |
| } |
| } |
| }; |
| |
| class Utils { |
| |
| static humanReadableTime(milliseconds) |
| { |
| if (milliseconds < 1000) |
| return Math.floor(milliseconds) + 'ms'; |
| else if (milliseconds < 60000) |
| return (milliseconds / 1000).toPrecision(2) + 's'; |
| |
| let minutes = Math.floor(milliseconds / 60000); |
| let seconds = Math.floor((milliseconds - minutes * 60000) / 1000); |
| return minutes + 'm' + seconds + 's'; |
| } |
| }; |
| |
| class DataConverter { |
| |
| static convertToWebTreemapFormat(rootNodeName, tree) |
| { |
| return DataConverter._recursiveConvertNode(rootNodeName, tree); |
| } |
| |
| static convertToFlatListSortedByRuntime(tree) |
| { |
| let testList = []; |
| DataConverter._recursiveBuildTestList(tree, '', testList); |
| |
| testList.sort(function(a, b) { |
| let aTime = a.runtime; |
| let bTime = b.runtime; |
| return bTime - aTime; |
| }); |
| |
| return testList; |
| } |
| |
| /* |
| stats.json looks like: |
| { |
| "imported": { |
| "w3c": { |
| "web-platform-tests": { |
| "IndexedDB": { |
| "idbobjectstore_get4.htm": { |
| "results": [ |
| 12, // worker number |
| 260, // test number |
| 41632, // worker pid |
| 50, // test runtime (ms) |
| 54 // total runtime (ms; includes time to run ref test, do pixel comparison etc.) |
| ], |
| ... |
| } |
| } |
| } |
| } |
| } |
| } |
| */ |
| |
| static _recursiveConvertNode(treename, tree, path) |
| { |
| let total = 0; |
| let childCount = 0; |
| let children = []; |
| for (let name in tree) { |
| let treeNode = tree[name]; |
| if ('results' in treeNode) { |
| let times = treeNode.results; |
| if (!times.hasOwnProperty('length')) |
| continue; |
| |
| let test_total_time = treeNode.results[4]; |
| let node = { |
| 'data': { '$area': test_total_time }, |
| 'name': name + " (" + Utils.humanReadableTime(test_total_time) + ")" |
| }; |
| children.push(node); |
| total += test_total_time; |
| childCount++; |
| } else { |
| let newPath = path ? path + '/' + name : name; |
| let subtree = DataConverter._recursiveConvertNode(name, treeNode, newPath); |
| children.push(subtree); |
| total += subtree['data']['$area']; |
| childCount += subtree['childCount']; |
| } |
| } |
| |
| children.sort(function(a, b) { |
| let aTime = a.data['$area'] |
| let bTime = b.data['$area'] |
| return bTime - aTime; |
| }); |
| |
| return { |
| 'data': { '$area': total }, |
| 'name': treename + ' (' + Utils.humanReadableTime(total) + ' - ' + childCount + ' tests)', |
| 'children': children, |
| 'childCount': childCount, |
| 'path': path |
| }; |
| } |
| |
| static _recursiveBuildTestList(tree, path, list) |
| { |
| for (let name in tree) { |
| let treeNode = tree[name]; |
| if ('results' in treeNode) { |
| let times = treeNode.results; |
| if (!times.hasOwnProperty('length')) |
| continue; |
| |
| let test_total_time = treeNode.results[4]; |
| let node = { |
| 'name' : path + '/' + name, |
| 'runtime' : test_total_time, |
| 'human_readable_runtime' : Utils.humanReadableTime(test_total_time), |
| }; |
| list.push(node); |
| } else { |
| let newPath = path ? path + '/' + name : name; |
| let subtree = DataConverter._recursiveBuildTestList(treeNode, newPath, list); |
| } |
| } |
| } |
| }; |
| |
| let treeMapController; |
| class TreeMapController { |
| |
| constructor(jsonData, containerElement) |
| { |
| this.treeMapData = DataConverter.convertToWebTreemapFormat('LayoutTests', jsonData); |
| this.treeMap = new WebTreeMap(this.treeMapData, containerElement); |
| } |
| |
| resetZoom() |
| { |
| focus(this.webTreeMap); |
| } |
| }; |
| |
| let slowTestListController; |
| class SlowTestListController { |
| |
| constructor(jsonData, containerElement) |
| { |
| this.numTestsToShow = 200; |
| this.testList = DataConverter.convertToFlatListSortedByRuntime(jsonData); |
| this.containerElement = containerElement; |
| this.contentsElement = containerElement.querySelector('.content'); |
| this.contentsElement.innerHTML = ''; |
| this._buildTestListTable(); |
| } |
| |
| show() |
| { |
| this.containerElement.classList.remove('hidden'); |
| } |
| |
| hide() |
| { |
| this.containerElement.classList.add('hidden'); |
| } |
| |
| _buildTestListTable() |
| { |
| let tableContainer = document.createElement('div'); |
| |
| tableContainer.innerHTML = `<table> |
| <tr><th>Test</th><th>Runtime</th></tr> |
| </table> |
| `; |
| |
| let table = tableContainer.firstChild; |
| let testList = this.testList.slice(0, this.numTestsToShow); |
| |
| for (let test of testList) { |
| let tr = document.createElement('tr'); |
| let labelCell = document.createElement('td'); |
| let dataCell = document.createElement('td'); |
| |
| labelCell.textContent = test['name']; |
| dataCell.textContent = test['human_readable_runtime']; |
| |
| tr.appendChild(labelCell); |
| tr.appendChild(dataCell); |
| table.appendChild(tr); |
| } |
| |
| this.contentsElement.appendChild(tableContainer); |
| } |
| }; |
| |
| if (window.testRunner) { |
| testRunner.dumpAsText(); |
| testRunner.waitUntilDone(); |
| } |
| |
| function setupControllers(jsonString) |
| { |
| let jsonData = JSON.parse(jsonString); |
| treeMapController = new TreeMapController(jsonData, document.getElementById('map')); |
| slowTestListController = new SlowTestListController(jsonData, document.getElementById('slow-tests')); |
| } |
| |
| function setupInterface() |
| { |
| // See if we have a file to load specified in the query string. |
| let query_parameters = {}; |
| let pairs = window.location.href.slice(window.location.href.indexOf('?') + 1).split('&'); |
| let filename = 'stats.json'; |
| |
| for (let i = 0; i < pairs.length; i++) { |
| let pair = pairs[i].split('='); |
| query_parameters[pair[0]] = decodeURIComponent(pair[1]); |
| } |
| |
| if ('filename' in query_parameters) |
| filename = query_parameters['filename']; |
| |
| // This is used for local files, so we can't use fetch(). |
| var request = new XMLHttpRequest(); |
| request.open("GET", filename, true); |
| request.addEventListener('load', () => { |
| setupControllers(request.responseText); |
| if (window.testRunner) |
| testRunner.notifyDone(); |
| }); |
| request.addEventListener('error', () => { |
| console.log('Failed to load stats.json'); |
| }); |
| request.send(); |
| |
| let drop_target = document.getElementById('dropTarget'); |
| |
| drop_target.addEventListener('dragenter', function (e) { |
| drop_target.className = 'dragOver'; |
| e.stopPropagation(); |
| e.preventDefault(); |
| }, false); |
| |
| drop_target.addEventListener('dragover', function (e) { |
| e.stopPropagation(); |
| e.preventDefault(); |
| }, false); |
| |
| drop_target.addEventListener('dragleave', function (e) { |
| drop_target.className = ''; |
| e.stopPropagation(); |
| e.preventDefault(); |
| }, false); |
| |
| drop_target.addEventListener('drop', function (e) { |
| drop_target.className = ''; |
| e.stopPropagation(); |
| e.preventDefault(); |
| |
| for (let i = 0; i < e.dataTransfer.files.length; ++i) { |
| let file = e.dataTransfer.files[i]; |
| |
| let reader = new FileReader(); |
| reader.filename = file.name; |
| reader.onload = function(e) { |
| setupControllers(reader.result); |
| }; |
| |
| reader.readAsText(file); |
| document.title = 'Test result times: ' + reader.filename; |
| } |
| }, false); |
| } |
| |
| window.addEventListener('load', setupInterface, false); |
| </script> |
| <body> |
| <header> |
| <div id="dropTarget">Drop stats.json file here to load.</div> |
| <p>Click on a box to zoom in. Click on the outermost box to zoom out. <a href="" onclick="treeMapController.resetZoom()">Reset</a> <button onclick="slowTestListController.show()">Show Slowest Tests</button></p> |
| </div> |
| </header> |
| <section id='map'></section> |
| <section id='slow-tests' class="hidden"> |
| <button class='close' onclick="slowTestListController.hide()">Close</button> |
| <div class="content"></div> |
| </section> |
| </body> |
| </html> |