| <!doctype html> |
| <meta charset="UTF-8"> |
| <title>Layout Tests</title> |
| <!-- |
| Displays web_tests results. |
| --> |
| <style> |
| body { |
| font-family: sans-serif; |
| min-height: 120vh; |
| } |
| button { |
| margin-top: 4px; |
| } |
| input { |
| vertical-align: middle; |
| margin-top: 0; |
| margin-bottom: 0; |
| } |
| p, h2, h3, h4 { |
| margin: 8px 0 4px 0; |
| } |
| |
| #right-toolbar { |
| position: absolute; |
| top: 8px; |
| right: 0; |
| font-size: smaller; |
| } |
| |
| .good { |
| background-color: rgba(0, 255, 0, 0.1); |
| } |
| |
| .bad { |
| background-color: rgba(255, 0, 0, 0.1); |
| } |
| |
| #help { |
| font-family: sans-serif; |
| box-sizing: border-box; |
| position: fixed; |
| width: 96vw; |
| height: 96vh; |
| top: 2vh; |
| left: 2vw; |
| border: 5px solid black; |
| background-color: white; |
| padding: 16px; |
| box-shadow: 0 0 20px; |
| overflow: auto; |
| z-index: 1; |
| } |
| #summary > p { |
| margin: 0.2em 0 0 0; |
| } |
| #dashboard { |
| user-select: none; |
| } |
| #report { |
| font-family: monospace; |
| } |
| #none { |
| color: green; |
| margin-left: 2em; |
| font-size: x-large; |
| font-weight: bold; |
| font-style: italic; |
| } |
| .fix-width { |
| display: inline-block; |
| width: 7em; |
| text-align: right; |
| margin-right: 1em; |
| } |
| .hidden { |
| display: none; |
| } |
| .warn { |
| color: red; |
| } |
| .h-expect { |
| margin-left: 1.25em; |
| } |
| .expect { |
| line-height: 200%; |
| cursor: zoom-in; |
| } |
| .expect:hover, .expect:focus { |
| background-color: #F4F4F4; |
| } |
| .expect:focus > .details { |
| visibility: visible; |
| } |
| .details { |
| box-sizing: border-box; |
| visibility: hidden; |
| display: inline-block; |
| position: relative; |
| top: 0.2em; |
| width: 1em; |
| height: 1em; |
| border-top: 0.5em solid transparent; |
| border-bottom: 0.5em solid transparent; |
| border-right: none; |
| border-left: 0.5em solid gray; |
| margin-right: .25em; |
| cursor: pointer; |
| } |
| .details.open { |
| visibility: visible !important; |
| top: 0.5em; |
| border-left: 0.5em solid transparent; |
| border-right: 0.5em solid transparent; |
| border-top: 0.5em solid gray; |
| border-bottom: none; |
| } |
| |
| .result-frame { |
| border: 1px solid gray; |
| border-top: 1px solid transparent; |
| margin-left: 2.25em; |
| margin-right: 2.25em; |
| margin-top: 4px; |
| margin-bottom: 16px; |
| } |
| .result-menu { |
| list-style-type: none; |
| margin: 0; |
| padding: 0; |
| } |
| .result-menu li { |
| display: inline-block; |
| min-width: 100px; |
| font-size: larger; |
| border: 1px dotted gray; |
| border-bottom: 1px solid transparent; |
| margin-right: 8px; |
| } |
| .result-output iframe { |
| width: 100%; |
| height: 50vh; |
| max-height: 800px; |
| border: 0px solid gray; |
| resize: both; |
| overflow: auto; |
| } |
| #filters { |
| margin-top: 8px; |
| } |
| #filters label { |
| font-family: sans-serif; |
| font-size: smaller; |
| } |
| |
| .flag { |
| display: inline-block; |
| vertical-align:middle; |
| width: 1.2em; |
| height: 1.2em; |
| border: 1px solid #DDD; |
| cursor: default; |
| user-select: none; |
| margin-right: 8px; |
| } |
| .flag.flagged::after { |
| content: "⚑"; |
| user-select: none; |
| font-size: x-large; |
| position: relative; |
| top: -0.1em; |
| left: 0.1em; |
| line-height: 100%; |
| color: gray; |
| } |
| |
| #copied { |
| color: #4F8A10; |
| margin-left: 5px; |
| } |
| |
| #flag-toolbar { |
| display: inline-block; |
| } |
| #flag-toolbar.hidden { |
| display: none; |
| } |
| </style> |
| <body> |
| <h3>Test run summary <span id="builder_name"></span> <span class="flag_name"></span></h3> |
| |
| <div id="right-toolbar"> |
| <a id="help_button" href="javascript:GUI.toggleVisibility('help')">help</a> |
| <div style="">go back to <a href="legacy-results.html">legacy-results.html</a></div> |
| </div> |
| |
| <div id="help" class="hidden hide-on-esc"> |
| <button style="position:fixed;right:40px;" onclick="GUI.toggleVisibility('help')">Close</button> |
| <h3>Keyboard navigation</h3> |
| |
| <ul> |
| <li><b>Space</b> Show full results of the next test. This is the easiest way to navigate, just hit spacebar (and shift space to go back). |
| <li><b>Tab</b> to select the next test. |
| <li><b>Enter</b> to see test details. This will automatically close other details. |
| <li><b>ctrl A</b> to select text of all tests for easy copying. |
| <li><b>f</b> to flag/unflag a test. |
| <li><b>?</b> to blink error region of a failed image test. |
| <li><b>p</b> prints full record of currently selected test to console. |
| </ul> |
| <p>Modifiers:</p> |
| <ul> |
| <li><b>Shift</b> hold shift key to keep other details open. |
| <li><b>Meta</b> meta means all. Set/unset all flags or open/close all results (max 100). |
| </ul> |
| <p>This page lets you query and display test results.</p> |
| |
| <h3>Querying Results</h3> |
| |
| <p>Select the results you are interested in using "Query" buttons. |
| Narrow down the results further with "Filter" checkboxes.</p> |
| |
| <p>"Did not pass" query will exclude PASS and WONTFIX tests.</p> |
| |
| <h3>Displaying results.</h3> |
| <p>You can view the list of matched tests in several result formats. |
| Select format that works best for what you are trying to accomplish |
| using "format" popup. Following formats are available:</p> |
| <h4>1. Plain text</h4> |
| <pre>fast/forms/<a href="https://cs.chromium.org/chromium/src/third_party/blink/web_tests/fast/forms/validation-bubble-appearance-rtl-ui.html?q=validation-bubble-appearance-rtl-ui.html&dr">validation-bubble-appearance-rtl-ui.html</a></pre> |
| <p>Plain text shows the test path.</p> |
| |
| <h4>2. TestExpectations</a></h4> |
| <pre><a href="#">crbug.com/bug</a> layout/test/path/<a href="#">test.html</a> [ Status ]</pre> |
| <p>TestExpectationsshow lines as they'd appear in <a |
| href="https://chromium.googlesource.com/chromium/src/+/master/docs/testing/web_test_expectations.md">TestExpectations</a> file.</p> |
| |
| <p>The interesting part here is [ Status ]. Inside TestExpectations file, [ Status ] |
| can have multiple values, representing all expected results. For example:</p> |
| |
| <pre>[ Failure Slow Timeout Crash Pass ]</pre> |
| |
| <p>Result lines include existing expected values, and make a guess about what the new |
| test expectation line should look like by merging together expected and actual |
| results. The actual result will be shown in bold. For example:</p> |
| |
| <pre>TestResult(PASS) + TestExpectation(Failure) => [ Pass ] |
| TestResult(CRASH) + TextExpectation(Failure) => [ Failure <b>Crash</b> ]</pre> |
| |
| <p>If you are doing a lot of TestExpectation edits, the hope is that this will make |
| your job as easy as copy and paste.</p> |
| |
| <h4>3. Crash site</h4> |
| <pre> |
| ### Crash site: Internals.cpp(3455) |
| editing/pasteboard/<a href="https://cs.chromium.org/chromium/src/third_party/blink/web_tests/editing/pasteboard/copy-paste-white-space.html?q=copy-paste-white-space.html&dr">copy-paste-white-space.html</a></pre> |
| <p>Crash site groups "Crash" tests with similar stack traces together. For best results, use it while filtering only crashes.</p> |
| <h4>4. Text mismatch</h4> |
| <pre> |
| ### Text mismatch failure: general text mismatch |
| accessibility/dimensions-include-descendants.html |
| ### Text mismatch failure: newlines only |
| accessibility/aria-controls-with-tabs.html |
| ### Text mismatch failure: spaces and tabs only |
| accessibility/aria-describedby-on-input.html |
| </pre> |
| <p>Text mistmatch groups "Text failure" tests together. |
| </p> |
| <h4>5. Rebaseline</h4> |
| <p>Generates a bash script to rebaseline based on new results. @xiaochengh knows the details on how to use it, and @kojii is |
| our one user.</p> |
| <h3>Viewing results of a single test</h3> |
| <h4>Image results</h4> |
| <p>Click on images to zoom in. Select image viewing mode from the popup.</p> |
| <p>When viewing images for the first time, red flash highlights enclosing |
| rectangle that was colored red in the diff. Diff flash and color eyedropper |
| will not be available on file:// urls because of CSP.</p> |
| <h4>Text results</h4> |
| <p>Different panels show the expected, actual text results and differences.</p> |
| <p>For repaint tests, the "repaint" panel visualizes the repaint rectangles.</p> |
| <h3>Flagging</h3> |
| <p>Tests can be flagged by clicking on that square box on the right hand side. View all flagged tests with "Flagged" filter. "F" is the keyboard shortcut. |
| <h3>Bugs</h3> |
| <p>If you are unhappy with results, please file a bug, or fix it <a href="https://cs.chromium.org/chromium/src/third_party/blink/web_tests/fast/harness/results.html">here</a>.</p> |
| <p><code>window.localStorage.setItem("testLocationOverride", "file://path/to/your/web_tests")</code> is a secret preference to redirect test links to a custom url.</p> |
| </div> |
| <div id="summary"> |
| <p><span class="fix-width">Passed</span><span id="summary_passed"></span></p> |
| <p><span class="fix-width">Regressions</span><span id="summary_regressions"></span></p> |
| <p><span class="fix-width">Total</span><span id="summary_total"></span></p> |
| <p><span class="fix-width">Counts</span><span id="summary_details"></span></p> |
| </div> |
| <hr> |
| <div id="dashboard"> |
| <div> |
| <span class="fix-width">Query</span> |
| <button id="button_regressions" onclick="javascript:Query.query('Regressions', Filters.regression, true)"> |
| Regressions |
| <span id="count_regressions"></span> |
| </button> |
| <button onclick="javascript:Query.query('Unexpected passes', Filters.unexpectedPass, true)"> |
| Unexpected Pass |
| <span id="count_unexpected_pass"></span> |
| </button> |
| <button onclick="javascript:Query.query('Did not pass', Filters.notpass, true)"> |
| Did not pass |
| <span id="count_testexpectations"></span> |
| </button> |
| <button onclick="javascript:Query.query('All', Filters.all, true)"> |
| All |
| <span id="count_all"></span> |
| </button> |
| <button onclick="javascript:Query.query('Flaky', Filters.flaky, true)"> |
| Flaky |
| <span id="count_flaky"></span> |
| </button> |
| <button onclick="javascript:Query.query('Unexpected Flaky', Filters.unexpectedFlaky, true)"> |
| Unexpected flaky |
| <span id="count_unexpected_flaky"></span> |
| </button> |
| <button onclick="javascript:Query.query('Flagged', Filters.flagged, true)"> |
| Flagged |
| </button> |
| <div id="flag-toolbar" class="hidden"> |
| <button onclick="javascript:Query.query('Flag failures', Filters.flagFailure, true)"> |
| <span class="flag_name"></span> failures |
| <span id="count_flagfailure"></span> |
| </button> |
| <button onclick="javascript:Query.query('Flag failures', Filters.flagPass, true)"> |
| <span class="flag_name"></span> passes |
| <span id="count_flagpass"></span> |
| </button> |
| </div> |
| </div> |
| <div id="filters"> |
| <span class="fix-width">Filters</span> |
| <input id="text-filter" onchange="Query.filterChanged()" type="text" placeholder="test name/bug#, hit enter"> |
| <label id="CRASH"><input type="checkbox">Crash <span></span></label> |
| <label id="TIMEOUT"><input type="checkbox">Timeout <span></span></label> |
| <label id="TEXT"><input type="checkbox">Text failure <span></span></label> |
| <label id="HARNESS"><input type="checkbox">Harness failure <span></span></label> |
| <label id="IMAGE"><input type="checkbox">Image failure <span></span></label> |
| <label id="IMAGE_TEXT"><input type="checkbox">Image+text failure <span></span></label> |
| <label id="AUDIO"><input type="checkbox">Audio failure <span></span></label> |
| <label id="SKIP"><input type="checkbox">Skipped <span></span></label> |
| <label id="PASS"><input type="checkbox">Pass <span></span></label> |
| <label id="WONTFIX"><input type="checkbox">WontFix <span></span></label> |
| <label id="MISSING"><input type="checkbox">Missing <span></span></label> |
| <label id="LEAK"><input type="checkbox">Leak <span></span></label> |
| </div> |
| </div> |
| |
| <div id="report_header" style="margin-top:8px"> |
| <span class="fix-width">Tests shown</span><span id="report_count"></span> |
| <span id="report_title" style="font-weight:bold"></span> |
| in format: |
| <select id="report_format" onchange="Query.generateReport()"> |
| <option value="plain" selected>Plain text</option> |
| <option value="expectation">TestExpectations</option> |
| <option value="crashsite">Crash site</option> |
| <option value="textmismatch">Text mismatch</option> |
| <option value="rebaseline">Rebaseline script</option> |
| </select> |
| <span style="margin-left: 20px"> |
| <button id="copy_report" title="Copy the shown/flagged tests to clipboard in the current format" |
| onclick="GUI.copyResult(false)" disabled>Copy report</button> |
| <button id="copy_single_line" title="Copy the shown/flagged tests to clipboard in a single line for use in command lines." |
| onclick="GUI.copyResult(true)" disabled>Copy single line</button> |
| <label id="flagged_only" class="hidden"><input id="flagged_only_checkbox" type="checkbox" checked>Flagged only</label> |
| <span id="copied" class="hidden">Copied.</span> |
| </span> |
| </div> |
| |
| <hr id="progress" align="left"> |
| <div id="report" style="margin-top:8px"></div> |
| |
| <script> |
| "use strict"; |
| |
| // Results loaded from full_results_jsonp.js. |
| let globalResults = {}; |
| let globalTestMap = new Map(); // id => test |
| |
| const TestResultInformation = { |
| "CRASH": { index: 1, text: "Crash", isFailure: true, isSuccess: false }, |
| "FAIL": { index: 2, text: "Failure", isFailure: true, isSuccess: false }, |
| "TEXT": { index: 3, text: "Failure", isFailure: true, isSuccess: false }, |
| "IMAGE": { index: 4, text: "Failure", isFailure: true, isSuccess: false }, |
| "IMAGE+TEXT": { index: 5, text: "Failure", isFailure: true, isSuccess: false }, |
| "AUDIO": { index: 6, text: "Failure", isFailure: true, isSuccess: false }, |
| "TIMEOUT": { index: 7, text: "Timeout", isFailure: true, isSuccess: false }, |
| "LEAK": { index: 8, text: "LEAK", isFailure: true, isSuccess: false }, |
| "SLOW": { index: 9, text: "Slow", isFailure: false, isSuccess: true }, |
| "SKIP": { index: 10, text: "Skip", isFailure: false, isSuccess: false }, |
| "MISSING": { index: 11, text: "Missing", isFailure: false, isSuccess: false }, |
| "WONTFIX": { index: 12, text: "WontFix", isFailure: false, isSuccess: false }, |
| "PASS": { index: 13, text: "Pass", isFailure: false, isSuccess: true }, |
| "NOTRUN": { index: 14, text: "NOTRUN", isFailure: false, isSuccess: true } |
| }; |
| |
| // Sorted from worst to best. |
| const TestResultComparator = function (a, b) { |
| if (TestResultInformation[a].index > TestResultInformation[b].index) |
| return 1; |
| else if (TestResultInformation[a].index == TestResultInformation[b].index) |
| return 0; |
| else |
| return -1; |
| }; |
| |
| // Traversal traverses all the tests. |
| // Use Traversal.traverse(filter, action) to perform action on selected tests. |
| class Traversal { |
| constructor(testRoot, textFilter) { |
| this.root = testRoot; |
| this.reset(); |
| } |
| |
| traverse(filter, action) { |
| console.time("traverse"); |
| action = action || function() {}; |
| this._helper(this.root, "", filter, action); |
| console.timeEnd("traverse"); |
| } |
| |
| reset() { |
| this.testCount = 0; |
| this.filteredCount = 0; |
| this.lastDir = ""; |
| this.html = []; |
| return this; |
| } |
| |
| _helper(node, path, filter, action) { |
| if (GUI.isTest(node)) { |
| this.testCount++; |
| if (filter(node, path)) { |
| this.filteredCount++; |
| action(node, path, this); |
| } |
| } else { |
| for (let p of node.keys()) |
| this._helper(node.get(p), path + "/" + p, filter, action); |
| } |
| } |
| } // class Traversal |
| |
| const PathParserGlobals = { |
| layout_tests_dir: null, |
| chromium_revision: null |
| }; |
| |
| class PathParser { |
| constructor(path) { |
| this.path = path; |
| let href; |
| [href, this.dir, this.file] = path.match("/(.*/)?(.*)"); |
| this.dir = this.dir || ""; |
| try { |
| [, this.raw_basename, this.raw_extension] = this.file.match(/(.+)\.(.+)/); |
| } catch { |
| this.raw_basename = "ERROR"; |
| this.raw_extension = "ERROR"; |
| } |
| // blink/tools/blinkpy/web_tests/port/base.py output_filename() |
| let index = this.raw_extension.indexOf("?"); |
| if (index > -1) { |
| let query_string = this.raw_extension.slice(index + 1); |
| query_string = query_string.replace(/[~#%&*{}\:<>?\/|"]/g, "_"); |
| this.basename = this.raw_basename + "_" + query_string; |
| this.extension = this.raw_extension.slice(0, index); |
| } else { |
| [this.basename, this.extension] = [this.raw_basename, this.raw_extension]; |
| } |
| |
| href = href.replace(/^\/virtual\/[^\/]*/, ""); // virtual suites |
| href = href.replace(/\?.*$/, ""); // query strings (variants) |
| this.testHref = this.testBaseHref() + href; |
| } |
| |
| static initGlobals(fullResults) { |
| for (let p in PathParserGlobals) |
| PathParserGlobals[p] = fullResults[p]; |
| } |
| |
| resultLink(resultName) { |
| return this.dir + this.basename + resultName; |
| } |
| |
| repaintOverlayLink() { |
| return this.resultLink("-overlay.html?") + encodeURIComponent(this.testHref); |
| } |
| |
| testBaseHref() { |
| if (window.localStorage.getItem("testLocationOverride")) { |
| // Experimental preference. |
| // Use "window.localStorage.setItem("testLocationOverride", "file://path/to/your/web_tests") |
| return window.localStorage.getItem("testLocationOverride"); |
| } else if (PathParserGlobals.layout_tests_dir) { |
| return PathParserGlobals.layout_tests_dir; |
| } else if (location.toString().indexOf('file://') == 0) { |
| // tests were run locally. |
| return "../../../third_party/blink/web_tests"; |
| } else if (PathParserGlobals.chromium_revision) { |
| // Existing crrev list is incorrect: https://crbug.com/750347 |
| let correctedRevision = PathParserGlobals.chromium_revision.replace("refs/heads/master@{#", "").replace("}", ""); |
| return "https://crrev.com/" + correctedRevision + "/third_party/blink/web_tests"; |
| } else { |
| return "https://chromium.googlesource.com/chromium/src/+/master/third_party/blink/web_tests"; |
| } |
| } |
| } // class PathParser |
| |
| // Report deals with displaying a single test. |
| const Report = { |
| |
| getDefaultPrinter: () => { |
| let val = document.querySelector("#report_format").value; |
| window.localStorage.setItem("reportFormat", val); |
| switch(val) { |
| case "expectation": |
| return {print: Report.printExpectation, render: Report.renderResultList}; |
| case "crashsite": |
| return {print: Report.printCrashSite, render: Report.renderGroupCrashSite}; |
| case "textmismatch": |
| return {print: Report.printTextMismatch, render: Report.renderGroupTextMismatch}; |
| case "rebaseline": |
| return {print: Report.printRebaseline, render: Report.renderResultList}; |
| case "plain": |
| default: |
| return {print: Report.printPlainTest, render: Report.renderResultList}; |
| } |
| }, |
| |
| printFlag: (test) => { |
| return `<div class="flag ${test.flagged ? "flagged" : ""}" |
| title="Hold Meta key to flag/unflag all tests."></div>`; |
| }, |
| |
| printPlainTest: (test, path, traversal) => { |
| let pathParser = new PathParser(path); |
| let html = ` |
| <div class='expect' tabindex='0' data-id='${test.expectId}'> |
| <div class='details'></div>${Report.printFlag(test)} |
| ${pathParser.dir}<a target='test' tabindex='-1' href='${pathParser.testHref}'>${pathParser.file}</a> |
| </div>`; |
| traversal.html.push(html); |
| }, |
| |
| printExpectation: (test, path, traversal) => { |
| // TestExpectations file format is documented at: |
| // https://chromium.googlesource.com/chromium/src/+/master/docs/testing/web_test_expectations.md |
| |
| let pathParser = new PathParser(path); |
| // Print directory header if this test's directory is different from the last. |
| if (pathParser.dir != traversal.lastDir) { |
| traversal.html.push("<br>"); |
| traversal.html.push("<div class='h-expect'>### " + pathParser.dir + "</div>"); |
| traversal.lastDir = pathParser.dir; |
| } |
| |
| let statusMap = new Map(test.expectedMap); |
| if (statusMap.has("PASS") && statusMap.size == 1) |
| statusMap.delete("PASS"); |
| for (let s of test.actualMap.keys()) { |
| let result = s; |
| if (result == "TEXT" || result == "IMAGE" || result == "IMAGE+TEXT") |
| result = "FAIL"; |
| if (result == "SKIP" && test.expectedMap.has("WONTFIX")) |
| result = "WONTFIX"; |
| statusMap.set(result, "bold"); |
| } |
| let status = ""; |
| for (let key of statusMap.keys()) { |
| if (statusMap.get(key) == "bold") |
| status += ` <b>${TestResultInformation[key].text}</b>`; |
| else |
| status += ` ${TestResultInformation[key].text}`; |
| } |
| let bug = test.actualMap.has("PASS") ? "" : "<span class='warn'>NEEDBUG</span>"; |
| if (test.bugs && test.bugs.length > 0) { |
| bug = ""; |
| for (let b of test.bugs) { |
| bug += `<a target='crbug' tabindex='-1' href='https://${b}'>${b}</a> `; |
| } |
| } |
| let html = ` |
| <div class='expect' tabindex='0' data-id='${test.expectId}'><div class='details'></div>${Report.printFlag(test)}${bug} |
| ${pathParser.dir}<a target='test' tabindex='-1' href='${pathParser.testHref}'>${pathParser.file}</a> |
| [ ${status} ] |
| </div> |
| `; |
| traversal.html.push(html); |
| }, |
| |
| printWithKey: (test, path, traversal, key_title) => { |
| let pathParser = new PathParser(path); |
| let key = test[key_title]; |
| let html = "" |
| + `${Report.printFlag(test)}` |
| + pathParser.dir |
| + "<a target='test' tabindex='-1' href='" + pathParser.testHref + "'>" |
| + pathParser.file + "</a>"; |
| html = "<div class='expect' tabindex='0' data-id='"+ test.expectId +"'><div class='details'></div>" + html + "</div>"; |
| traversal.html.push({key: key, html: html}); |
| }, |
| |
| printCrashSite: (test, path, traversal) => { |
| Report.printWithKey(test, path, traversal, "crash_site"); |
| }, |
| |
| printTextMismatch: (test, path, traversal) => { |
| Report.printWithKey(test, path, traversal, "text_mismatch"); |
| }, |
| |
| printRebaseline: (test, path, traversal) => { |
| let parser = new PathParser(path); |
| let actualNames = []; |
| let expectedNames = []; |
| switch (test.actualFinal) { |
| case "IMAGE+TEXT": |
| actualNames.push("-actual.txt"); |
| expectedNames.push("-expected.txt"); |
| // fall through IMAGE |
| case "IMAGE": |
| actualNames.push("-actual.png"); |
| expectedNames.push("-expected.png"); |
| break; |
| case "TEXT": |
| actualNames.push("-actual.txt"); |
| expectedNames.push("-expected.txt"); |
| break; |
| default: |
| return; |
| } |
| // Change directory if in wrong place |
| let dir = parser.dir; |
| if (dir != traversal.rebaselineDir) { |
| if (traversal.rebaselineDir) { |
| let depth = (traversal.rebaselineDir.match(/\//g) || []).length; |
| if (depth) |
| traversal.html.push(`<div>cd ${'..' + '/..'.repeat(depth - 1)};</div>`); |
| } |
| traversal.html.push(`<div>mkdir -p ${dir};</div>`); |
| traversal.html.push(`<div>cd ${dir};</div>`); |
| traversal.rebaselineDir = dir; |
| } |
| for (let i=0; i<actualNames.length; i++) { |
| let url = new URL(parser.resultLink(actualNames[i]), window.location.href); |
| if (url.href.startsWith('file://')) { |
| let actualFile = url.href.substring('file://'.length); |
| traversal.html.push(`<div>cp ${actualFile} ${parser.basename + expectedNames[i]};</div>`); |
| } else { |
| traversal.html.push(`<div>wget ${url.href};</div>`); |
| traversal.html.push(`<div>mv ${parser.basename + actualNames[i]} ${parser.basename + expectedNames[i]};</div>`); |
| } |
| } |
| }, |
| |
| indicateNone: (report) => { |
| let pre = document.createElement("div"); |
| pre.id = "none"; |
| pre.textContent = "None"; |
| report.appendChild(pre); |
| }, |
| |
| renderResultList: (html, report) => { |
| if (report.childNodes.length === 0 && html.length === 0) { |
| Report.indicateNone(report); |
| return; |
| } |
| |
| let pre = document.createElement("div"); |
| pre.innerHTML = html.join("\n"); |
| report.appendChild(pre); |
| }, |
| |
| createContainerForGroup: (report, key, keyed_title, null_title) => { |
| let container = document.createElement("div"); |
| container.setAttribute("key", key); |
| container.innerHTML = "" |
| + "<br><b>" |
| + (key !== 'null' ? `### ${keyed_title}: ${key}` |
| : `### ${null_title}`) |
| + "</b>"; |
| |
| // The containers are sorted by key (except the "null" key) alphabetically. |
| // The "null" container always appears at the last. |
| if (key == "null") { |
| report.appendChild(container); |
| } else { |
| let inserted = false; |
| report.childNodes.forEach(sibling => { |
| if (inserted) |
| return; |
| let siblingKey = sibling.getAttribute("key"); |
| if (siblingKey == "null" || siblingKey > key) { |
| report.insertBefore(container, sibling); |
| inserted = true; |
| } |
| }); |
| if (!inserted) |
| report.appendChild(container); |
| } |
| return container; |
| }, |
| |
| renderGroup: (html, report, keyed_title, null_title) => { |
| if (report.childNodes.length === 0 && html.length === 0) { |
| Report.indicateNone(report); |
| return; |
| } |
| |
| let renderMap = {}; |
| html.forEach(result => { |
| let key = result.key || "null"; |
| if (!(key in renderMap)) { |
| const number_of_items = html.filter(result => (result.key || "null") === key).length; |
| let container = |
| report.querySelector(`div[key="${key}"]`) || |
| Report.createContainerForGroup(report, key, |
| `${keyed_title} ${number_of_items}`, |
| `${null_title} ${number_of_items}`); |
| renderMap[key] = {container: container, html: ""}; |
| } |
| renderMap[key].html += result.html; |
| }); |
| |
| for (let key in renderMap) |
| renderMap[key].container.insertAdjacentHTML('beforeend', renderMap[key].html); |
| }, |
| |
| renderGroupCrashSite: (html, report) => { |
| Report.renderGroup(html, report, "Crash site", "Didn't crash"); |
| }, |
| |
| renderGroupTextMismatch: (html, report) => { |
| Report.renderGroup(html, report, "Text mismatch failure", "Didn't find text mismatch"); |
| }, |
| |
| getTestById: (testId) => { |
| return globalTestMap.get(parseInt(testId)); |
| }, |
| |
| // Returns toolbar DOM |
| getResultToolbars: (test) => { |
| let toolbars = []; |
| let pathParser = new PathParser(test.expectPath); |
| toolbars.push(new PlainHtmlToolbar().createDom(test.actual)); |
| for (let result of test.actualMap.keys()) { |
| switch(result) { |
| case "PASS": |
| case "SLOW": |
| if (Filters.unexpectedPass(test)) |
| toolbars.push(new PlainHtmlToolbar().createDom("Expected: " + test.expected)); |
| if (!test.has_stderr) |
| toolbars.push(new PlainHtmlToolbar().createDom("No errors")); |
| break; |
| case "SKIP": |
| toolbars.push(new PlainHtmlToolbar().createDom("Test did not run.")); |
| break; |
| case "CRASH": |
| toolbars.push(new SimpleLinkToolbar().createDom( |
| pathParser.resultLink("-crash-log.txt"), "Crash log", "crash log")); |
| break; |
| case "TIMEOUT": |
| toolbars.push(new PlainHtmlToolbar().createDom("Test timed out. " |
| + ("time" in test ? `(${test.time}s)` : ""))); |
| if (test.text_mismatch) |
| toolbars.push(new TextResultsToolbar().createDom(test)); |
| break; |
| case "TEXT": |
| toolbars.push(new TextResultsToolbar().createDom(test)); |
| break; |
| case "IMAGE": |
| toolbars.push(new ImageResultsToolbar().createDom(test)); |
| break; |
| case "IMAGE+TEXT": |
| toolbars.push(new ImageResultsToolbar().createDom(test)); |
| toolbars.push(new TextResultsToolbar().createDom(test)); |
| break; |
| case "AUDIO": |
| toolbars.push(new AudioResultsToolbar().createDom(test)); |
| break; |
| case "MISSING": |
| toolbars.push(new PlainHtmlToolbar().createDom("Expectations are missing.")); |
| toolbars.push(new TextResultsToolbar().createDom(test)); |
| toolbars.push(new ImageResultsToolbar().createDom(test)); |
| break; |
| case "LEAK": |
| toolbars.push(new SimpleLinkToolbar().createDom( |
| pathParser.resultLink("-leak-log.txt"), "Leak log", "leak log")); |
| break; |
| default: |
| console.error("unexpected actual", test.actual); |
| } |
| } |
| if (test.has_stderr) { |
| toolbars.push(new SimpleLinkToolbar().createDom( |
| pathParser.resultLink("-stderr.txt"), "standard error", "stderr")); |
| } |
| return toolbars; |
| }, |
| getResultsDiv: (test) => { |
| let div = document.createElement("div"); |
| div.classList.add("result-frame"); |
| div.innerHTML = ` |
| <ul class="result-menu"></ul> |
| <div class="result-output"></div> |
| `; |
| // Initialize the results. |
| let menu = div.querySelector(".result-menu"); |
| for (let toolbar of Report.getResultToolbars(test)) |
| menu.appendChild(toolbar); |
| return div; |
| } |
| }; // Report |
| |
| // Query generates a report for a given query. |
| const Query = { |
| lastReport: null, |
| currentRAF: null, |
| currentPromise: null, |
| currentResolve: null, |
| currentReject: null, |
| |
| createReportPromise: function() { |
| if (this.currentPromise) { |
| this.currentPromise = null; |
| this.currentReject(); |
| } |
| this.currentPromise = new Promise( (resolve, reject) => { |
| this.currentResolve = resolve; |
| this.currentReject = reject; |
| }); |
| this.currentPromise.catch( _ => {}); // stops uncaught rejection errors. |
| }, |
| |
| completeReportPromise: function(traversal) { |
| if (this.currentResolve) |
| this.currentResolve(traversal); |
| this.currentPromise = null; |
| this.currentResolve = null; |
| this.currentReject = null; |
| }, |
| |
| resetFilters: function() { |
| // Reset all filters. |
| for (let el of Array.from( |
| document.querySelectorAll("#filters > label"))) { |
| el.querySelector('input').checked = true; |
| } |
| }, |
| |
| updateFilters: function() { |
| for (let el of Array.from( |
| document.querySelectorAll("#filters > label"))) { |
| let count = this.resultCounts[el.id.replace("_", "+")]; |
| if (count > 0) { |
| el.classList.remove("hidden"); |
| el.querySelector('span').innerText = count; |
| } else { |
| el.classList.add("hidden"); |
| el.querySelector("span").innerText = ""; |
| } |
| } |
| }, |
| |
| filterChanged: function(ev) { |
| this.query(); |
| }, |
| |
| applyFilters: function(queryFilter) { |
| var filterMap = new Map(); |
| for (let el of Array.from( |
| document.querySelectorAll("#filters > label"))) { |
| if (el.querySelector('input').checked) |
| filterMap.set(el.id.replace("_", "+"), true); |
| } |
| let searchText = document.querySelector("#text-filter").value; |
| let textFilter = (!searchText || searchText.length < 1) |
| ? _ => true |
| : test => { |
| if (test.expectPath.includes(searchText)) |
| return true; |
| if (Array.isArray(test.bugs)) { |
| for (let bug of test.bugs) { |
| if (bug.includes(searchText)) |
| return true; |
| } |
| } |
| return false; |
| }; |
| return test => { |
| if (!queryFilter(test) || !textFilter(test)) |
| return false; |
| let resultKey = test.is_testharness_test ? "HARNESS" : test.actualFinal; |
| // Ignore all results except final one, or not? |
| if (!(resultKey in this.resultCounts)) |
| this.resultCounts[resultKey] = 0; |
| this.resultCounts[resultKey]++; |
| if (test.actualFinal == "TEXT" && test.is_testharness_test) { |
| return filterMap.has("HARNESS"); |
| } |
| return filterMap.has(test.actualFinal); |
| }; |
| }, |
| |
| query: function(name, queryFilter, reset) { |
| queryFilter = queryFilter || this.lastQueryFilter; |
| if (reset) { |
| this.resetFilters(); |
| this.lastQueryFilter = queryFilter; |
| } |
| let composedFilter = this.applyFilters(queryFilter); |
| this.generateReport(name, composedFilter); |
| }, |
| |
| // generateReport is async, returns promise. |
| // promise id fullfilled when traversal completes. Display will continue async. |
| generateReport: function(name, filter, report) { |
| console.time("generateReport"); |
| if (this.currentRAF) |
| window.cancelAnimationFrame(this.currentRAF); |
| report = report || Report.getDefaultPrinter(); |
| filter = filter || this.lastReport.filter; |
| name = name || this.lastReport.name; |
| |
| // Store last report to redisplay. |
| this.lastReport = {name: name, filter: filter}; |
| this.createReportPromise(); |
| this.resultCounts = {}; |
| |
| document.querySelector("#report").innerHTML = ""; |
| document.querySelector("#report_title").innerHTML = name; |
| document.querySelector("#progress").style.width = "1%"; |
| document.querySelector("#copy_report").disabled = true; |
| document.querySelector("#copy_single_line").disabled = true; |
| document.querySelector("#report_count").innerText = ""; |
| let traversal = new Traversal(globalResults.tests); |
| let chunkSize = 1000; |
| let index = 0; |
| let callback = _ => { |
| this.currentRAF = null; |
| let html = traversal.html.slice(index, index + chunkSize); |
| report.render(html, document.querySelector("#report")); |
| index += chunkSize; |
| document.querySelector("#progress").style.width = Math.min((index / traversal.html.length * 100), 100) + "%"; |
| if (index < traversal.html.length) { |
| this.currentRAF = window.requestAnimationFrame(callback); |
| } else { |
| document.querySelector("#report_count").innerText = traversal.filteredCount; |
| GUI.updateCopyButtons(); |
| console.timeEnd("generateReport"); |
| } |
| }; |
| window.setTimeout( _ => { |
| traversal.traverse(filter, report.print); |
| this.updateFilters(); |
| this.completeReportPromise(traversal); |
| this.currentRAF = window.requestAnimationFrame(callback); |
| }, 0); |
| return this.currentPromise; |
| } |
| }; // Query |
| |
| // Test filters for queries. |
| const Filters = { |
| containsPass: function (map) { |
| return map.has("PASS") || map.has("SLOW"); |
| }, |
| containsNoPass: function(map) { |
| return map.has("FAIL") |
| || map.has("WONTFIX") |
| || map.has("SKIP") |
| || map.has("CRASH"); |
| }, |
| unexpectedPass: test => { |
| return !Filters.containsPass(test.expectedMap) && Filters.containsPass(test.actualMap); |
| }, |
| regressionFromExpectedMap: (finalResult, expectedMap) => { |
| if (expectedMap.has("NEEDSREBASELINE") |
| || expectedMap.has("WONTFIX")) |
| return false; |
| switch (finalResult) { |
| case "SKIP": |
| return false; |
| case "CRASH": |
| case "TIMEOUT": |
| case "LEAK": |
| if (expectedMap.has(finalResult)) |
| return false; |
| break; |
| case "TEXT": |
| case "IMAGE": |
| case "IMAGE+TEXT": |
| case "AUDIO": |
| if (expectedMap.has("FAIL")) |
| return false; |
| break; |
| case "MISSING": |
| break; |
| default: |
| console.error("Unexpected test result", finalResult); |
| } |
| return true; |
| }, |
| regression: test => { |
| if (Filters.containsPass(test.actualMap)) |
| return false; |
| return Filters.regressionFromExpectedMap(test.actualFinal, test.expectedMap); |
| }, |
| notpass: test => test.actualFinal != "PASS" && test.expected != "WONTFIX", |
| actual: tag => { // Returns comparator for tag. |
| return function(test) { |
| return test.actualMap.has(tag); |
| }; |
| }, |
| wontfix: test => test.expected == "WONTFIX", |
| all: _ => true, |
| flaky: test => test.actualMap.size > 1, |
| unexpectedFlaky: test => test.actualMap.size > 1 && test.expectedMap.size <= 1, |
| flagged: test => test.flagged, |
| flagFailure: test => { // Tests that are failing, but expected to pass in base. |
| if (Filters.containsPass(test.actualMap)) |
| return false; |
| if (test.expectedMap.has("NEEDSREBASELINE") |
| || test.expectedMap.has("WONTFIX")) |
| return false; |
| let baseMap = test.flagMap ? test.baseMap : test.expectedMap; |
| switch (test.actualFinal) { |
| case "SKIP": |
| return false; |
| case "CRASH": |
| case "TIMEOUT": |
| case "LEAK": |
| if (baseMap && baseMap.has(test.actualFinal)) |
| return false; |
| break; |
| case "TEXT": |
| case "IMAGE": |
| case "IMAGE+TEXT": |
| case "AUDIO": |
| if (baseMap && baseMap.has("FAIL")) |
| return false; |
| break; |
| case "MISSING": |
| break; |
| default: |
| console.error("Unexpected test result", test.actualFinal); |
| } |
| return true; |
| }, |
| flagPass: test => { |
| return test.baseMap && (Filters.containsPass(test.actualMap) && !Filters.containsPass(test.baseMap)); |
| } |
| }; // Filters |
| |
| // Event handling, initialization. |
| const GUI = { |
| initPage: function(results) { |
| results.tests = GUI.convertToMap(results.tests); |
| globalResults = results; |
| if (window.localStorage.getItem("reportFormat")) { |
| document.querySelector("#report_format").value = window.localStorage.getItem("reportFormat"); |
| } |
| GUI.optimizeResults(globalResults); |
| GUI.printSummary(globalResults); |
| GUI.initEvents(); |
| PathParser.initGlobals(results); |
| // Show regressions on startup. |
| document.querySelector("#button_regressions").click(); |
| }, |
| |
| hasBaseExpectations : false, |
| |
| isTest: function(o) { |
| return "actual" in o; |
| }, |
| |
| convertToMap: function(o) { |
| if (GUI.isTest(o)) |
| return o; |
| else { |
| let map = new Map(); |
| var keys = Object.keys(o).sort((a, b) => { |
| let a_isTest = GUI.isTest(o[a]); |
| if (a_isTest == GUI.isTest(o[b])) |
| return a < b ? -1 : +(a > b); |
| return a_isTest ? -1 : 1; |
| }); |
| for (let p of keys) |
| map.set(p, GUI.convertToMap(o[p])); |
| return map; |
| } |
| }, |
| |
| optimizeResults: function(fullResults) { |
| // Optimizes fullResults for querying. |
| let t = new Traversal(fullResults.tests); |
| // To all tests add: |
| // - test.expectId, a unique id |
| // - test.expectPath, full path to test |
| // - test.actualMap, map of actual results |
| // - test.actualFinal, last result |
| // - test.expectedMap, maps of all expected results |
| // - test.baseMap, map of base expected results. Can be undefined. |
| // - test.flagMap, map of flag expected results. Can be undefined. |
| // For all crashing tests without crash_site, set crash_site to "Can't identify". |
| let nextId = 1; |
| let baseCount = 0; |
| t.traverse( |
| test => true, |
| (test, path) => { |
| test.expectId = nextId++; |
| globalTestMap.set(test.expectId, test); |
| test.expectPath = path; |
| test.actualMap = new Map(); |
| for (let result of test.actual.split(" ")) { |
| test.actualFinal = result; // last result count as definite. |
| test.actualMap.set(result, true); |
| } |
| test.expectedMap = new Map(); |
| for (let result of test.expected.split(" ")) { |
| test.expectedMap.set(result, true); |
| } |
| if (test.actualMap.has("CRASH") && !test["crash_site"]) { |
| test["crash_site"] = "Can't identify"; |
| } |
| if ("base_expectations" in test) { |
| GUI.hasBaseExpectations = true; |
| baseCount++; |
| test.baseMap = new Map(); |
| test.base_expectations.forEach( result => test.baseMap.set(result, true)); |
| } |
| if ("flag_expectations" in test) { |
| test.flagMap = new Map(); |
| test.flag_expectations.forEach( result => test.flagMap.set(result, true)); |
| } |
| } |
| ); |
| }, |
| |
| nextExpectation: function(expectation) { |
| let nextSiblingWithKeyedParentSkip = function(el) { |
| let sibling = el.nextElementSibling; |
| if (sibling == null && el.parentNode.parentNode.id == "report") { |
| let nextContainer = el.parentNode.nextElementSibling; |
| while (nextContainer != null && nextContainer.firstElementChild == null) |
| nextContainer = nextContainer.nextElementSibling; |
| if (nextContainer) |
| sibling = nextContainer.firstElementChild; |
| } |
| return sibling; |
| }; |
| if (expectation == null) |
| return null; |
| let sibling = nextSiblingWithKeyedParentSkip(expectation); |
| while (sibling) { |
| if (sibling.classList.contains("expect")) |
| return sibling; |
| else |
| sibling = nextSiblingWithKeyedParentSkip(sibling); |
| } |
| }, |
| |
| previousExpectation: function(expectation) { |
| let previousSiblingWithKeyedParentSkip = function(el) { |
| let sibling = el.previousElementSibling; |
| if (sibling == null && el.parentNode.parentNode.id == "report") { |
| let previousContainer = el.parentNode.previousElementSibling; |
| while (previousContainer != null && previousContainer.firstElementChild == null) |
| previousContainer = previousContainer.previousElementSibling; |
| if (previousContainer) |
| sibling = previousContainer.lastElementChild; |
| } |
| return sibling; |
| }; |
| if (expectation == null) |
| return null; |
| let sibling = previousSiblingWithKeyedParentSkip(expectation); |
| while (sibling) { |
| if (sibling.classList.contains("expect")) |
| return sibling; |
| else |
| sibling = previousSiblingWithKeyedParentSkip(sibling); |
| } |
| }, |
| |
| showNextExpectation: function(backward) { |
| let nextExpectation; |
| let activeExpectation = GUI.activeExpectation(); |
| let openExpectation = GUI.openExpectation(); |
| if (openExpectation) |
| GUI.hideResults(openExpectation); |
| if (openExpectation && openExpectation == activeExpectation) { |
| nextExpectation = backward ? |
| GUI.previousExpectation(openExpectation) : |
| GUI.nextExpectation(openExpectation); |
| } else { |
| if (activeExpectation) |
| nextExpectation = activeExpectation; |
| else |
| nextExpectation = document.querySelector(".expect"); |
| } |
| if (nextExpectation) { |
| nextExpectation.focus(); |
| GUI.showResults(nextExpectation); |
| return true; |
| } |
| }, |
| |
| openExpectation: function() { |
| let openDetails = document.querySelector(".details.open"); |
| return openDetails && GUI.getExpectation(openDetails); |
| }, |
| |
| activeExpectation: function() { |
| return GUI.getExpectation(document.activeElement) || GUI.openExpectation(); |
| }, |
| |
| initEvents: function() { |
| document.querySelector("#report").addEventListener("mousedown", |
| (ev) => { if (GUI.isFlag(ev.target)) ev.preventDefault() } |
| ); |
| document.querySelector("#report").addEventListener("click", function(ev) { |
| let expectation = GUI.getExpectation(ev.target); |
| if (ev.target.nodeName == "A" || ev.target.nodeName == "INPUT" || |
| GUI.closest(ev.target, "result-frame")) { |
| ; // Clicks in anchor, input or result-frame should perform default action |
| } else if (GUI.isFlag(ev.target)) { |
| GUI.toggleFlag(ev.target, ev); |
| } else if (expectation && window.getSelection().type != "Range") { |
| GUI.toggleResults(expectation, ev); |
| ev.preventDefault(); |
| ev.stopPropagation(); |
| } |
| }); |
| document.addEventListener("keydown", ev => { |
| { |
| if (ev.target.nodeName == "INPUT") |
| return; |
| switch(ev.key) { |
| case "Escape": { |
| // Close/hide divs. |
| for (let el of Array.from(document.querySelectorAll(".close-on-esc"))) |
| el.remove(); |
| for (let el of Array.from(document.querySelectorAll(".hide-on-esc"))) |
| el.classList.add("hidden"); |
| if (document.activeElement && document.activeElement.classList.contains("expect")) |
| GUI.hideResults(document.activeElement); |
| document.getSelection().removeAllRanges(); |
| } |
| break; |
| case " ": // Scroll to next expectation. |
| if (GUI.showNextExpectation(ev.shiftKey)) |
| ev.preventDefault(); |
| break; |
| case "j": |
| case "J": { |
| let current = GUI.activeExpectation(); |
| let nextExpectation = current ? GUI.nextExpectation(current) : document.querySelector(".expect"); |
| if (nextExpectation) |
| nextExpectation.focus(); |
| } |
| break; |
| case "k": |
| case "K": { |
| let current = GUI.activeExpectation(); |
| let nextExpectation = current ? GUI.previousExpectation(current) : document.querySelector(".expect"); |
| if (nextExpectation) |
| nextExpectation.focus(); |
| } |
| break; |
| case "Enter": |
| let expectation = GUI.getExpectation(ev.target); |
| if (expectation) |
| GUI.toggleResults(expectation, ev); |
| break; |
| case "a": |
| case "A": |
| if (ev.ctrlKey) { |
| GUI.selectText(document.querySelector("#report")); |
| ev.preventDefault(); |
| } |
| break; |
| case "f": |
| case "F": { |
| let expectation = GUI.getExpectation(ev.target); |
| if (expectation) |
| GUI.toggleFlag(expectation.querySelector(".flag"), ev); |
| } |
| break; |
| case "?": |
| case "/": // Blinks currect image error rect |
| if (document.querySelector(".image-viewer")) |
| document.querySelector(".image-viewer").controller.blinkDiffRect(); |
| break; |
| case "p": { |
| let expectation = GUI.getExpectation(ev.target); |
| if (expectation) |
| console.log(Report.getTestById(expectation.getAttribute("data-id"))); |
| break; |
| } |
| } |
| } |
| }); |
| for (let checkbox of Array.from(document.querySelectorAll("#filters input[type=checkbox]"))) { |
| checkbox.addEventListener("change", ev => Query.filterChanged(ev)); |
| } |
| }, |
| |
| selectText: function(el) { |
| let range = document.createRange(); |
| range.setStart(el, 0); |
| range.setEnd(el, el.childNodes.length); |
| let selection = document.getSelection(); |
| selection.removeAllRanges(); |
| selection.addRange(range); |
| }, |
| |
| copyText: function(el) { |
| GUI.selectText(el); |
| document.execCommand("Copy"); |
| document.getSelection().removeAllRanges(); |
| document.querySelector("#copied").classList.remove("hidden"); |
| if (GUI.endCopyTextTimer) |
| window.clearTimeout(GUI.endCopyTextTimer); |
| GUI.endCopyTextTimer = window.setTimeout(_ => { |
| document.querySelector("#copied").classList.add("hidden"); |
| GUI.endCopyTextTimer = null; |
| }, 2000); |
| }, |
| |
| copyResult: function(asSingleLine) { |
| console.time("copyResult"); |
| let flaggedOnly = document.querySelector(".flagged") && |
| document.querySelector("#flagged_only_checkbox").checked; |
| if (!flaggedOnly && !asSingleLine) { |
| GUI.copyText(document.querySelector("#report")); |
| return; |
| } |
| |
| let printer = Report.getDefaultPrinter(); |
| let singleLine = ""; |
| let traversal = new Traversal(globalResults.tests); |
| traversal.traverse(Query.lastReport.filter, (test, path) => { |
| if (flaggedOnly && !test.flagged) |
| return; |
| if (asSingleLine) { |
| let pathParser = new PathParser(path); |
| singleLine += pathParser.dir + pathParser.file + " "; |
| } else { |
| printer.print(test, path, traversal); |
| } |
| }); |
| let div = document.createElement("div"); |
| div.setAttribute("style", "overflow: hidden; color: white; height: 1px"); |
| if (asSingleLine) |
| div.textContent = singleLine; |
| else |
| printer.render(traversal.html, div); |
| document.body.appendChild(div); |
| GUI.copyText(div); |
| document.body.removeChild(div); |
| console.timeEnd("copyResult"); |
| }, |
| |
| updateCopyButtons: function() { |
| let noResult = document.querySelector("#none"); |
| document.querySelector("#copy_report").disabled = noResult; |
| document.querySelector("#copy_single_line").disabled = noResult; |
| |
| let flagged_only = document.querySelector("#flagged_only"); |
| if (document.querySelector(".flagged")) |
| flagged_only.classList.remove("hidden"); |
| else |
| flagged_only.classList.add("hidden"); |
| }, |
| |
| printSummary: function (fullResults) { |
| document.querySelector("#builder_name").innerText = fullResults.builder_name || document.lastModified; |
| document.querySelector("#summary_passed").innerText = fullResults.num_passes; |
| document.querySelector("#summary_regressions").innerText = fullResults.num_regressions; |
| let failures = fullResults["num_failures_by_type"]; |
| var totalFailures = 0; |
| |
| let resultsText = ""; |
| for (let p in failures) { |
| if (failures[p]) |
| resultsText += p.toLowerCase() + ": " + failures[p] + " "; |
| } |
| document.querySelector("#summary_details").innerText = resultsText; |
| |
| // Initialize query counts. |
| let counts = { |
| "count_unexpected_pass": 0, |
| "count_regressions": 0, |
| "count_testexpectations": 0, |
| "count_flaky": 0, |
| "count_unexpected_flaky": 0, |
| "count_all": 0, |
| "count_flagfailure": 0, |
| "count_flagpass" : 0 |
| }; |
| var t = new Traversal(fullResults.tests); |
| t.traverse( test => { |
| counts.count_all++; |
| if (Filters.unexpectedPass(test)) |
| counts.count_unexpected_pass++; |
| if (Filters.regression(test)) |
| counts.count_regressions++; |
| if (Filters.notpass(test)) |
| counts.count_testexpectations++; |
| if (Filters.flaky(test)) |
| counts.count_flaky++; |
| if (Filters.unexpectedFlaky(test)) |
| counts.count_unexpected_flaky++; |
| if (Filters.flagFailure(test)) |
| counts.count_flagfailure++; |
| if (Filters.flagPass(test)) |
| counts.count_flagpass++; |
| }); |
| console.assert( |
| counts.count_regressions == fullResults.num_regressions, |
| "Numbers of regressions mismatch: in fullResult:" + fullResults.num_regressions + |
| " filtered:" + counts.count_regressions); |
| for (let p in counts) |
| document.querySelector("#" + p).innerText = counts[p]; |
| if (counts.count_regressions > 0) |
| document.querySelector("#count_regressions").parentElement.classList.add("bad"); |
| if (counts.count_unexpected_flaky > 0) |
| document.querySelector("#count_unexpected_flaky").parentElement.classList.add("bad"); |
| if (counts.count_unexpected_pass > 0) |
| document.querySelector("#count_unexpected_pass").parentElement.classList.add("good"); |
| |
| document.querySelector("#summary_total").innerText = counts.count_all; |
| if (!GUI.hasBaseExpectations) |
| document.querySelector("#flag-toolbar").classList.add("hidden"); |
| else { |
| document.querySelector("#flag-toolbar").classList.remove("hidden"); |
| let flagName = fullResults.flag_name || ""; |
| flagName = flagName.replace('/', '') |
| .replace('enable-blink-features', '').replace('=', ''); |
| Array.from(document.querySelectorAll(".flag_name")).forEach( el => { |
| el.innerText = flagName; |
| }); |
| } |
| }, |
| |
| getExpectation: function(el) { |
| let result = GUI.closest(el, "expect"); |
| if (result) |
| return result; |
| result = GUI.closest(el, "result-frame"); |
| return result ? result.previousElementSibling : null; |
| }, |
| |
| isFlag: function(el) { |
| return el.classList.contains("flag"); |
| }, |
| |
| toggleVisibility: function(id) { |
| document.querySelector("#" + id).classList.toggle("hidden"); |
| }, |
| |
| toggleFlag: function(el, ev) { |
| let expectation = GUI.getExpectation(el); |
| let test = Report.getTestById(expectation.getAttribute("data-id")); |
| if (!test) |
| throw "could not find test by id"; |
| el.classList.toggle("flagged"); |
| if (el.classList.contains("flagged")) |
| test.flagged = true; |
| else |
| test.flagged = false; |
| if (ev.metaKey) { // apply to all |
| for (let expectation of Array.from(document.querySelectorAll(".expect"))) { |
| let newTest = Report.getTestById(expectation.getAttribute("data-id")); |
| let toggleEl = expectation.querySelector(".flag"); |
| if (test.flagged) { |
| toggleEl.classList.add("flagged"); |
| newTest.flagged = true; |
| } else { |
| toggleEl.classList.remove("flagged"); |
| newTest.flagged = false; |
| } |
| } |
| } |
| GUI.updateCopyButtons(); |
| }, |
| |
| toggleResults: function(expectation, event) { |
| let applyToAll = event && event.metaKey; |
| let closeOthers = !applyToAll && event && !event.shiftKey; |
| let details = expectation.querySelector(".details"); |
| let isOpen = details.classList.contains("open"); |
| if (applyToAll) { |
| let allExpectations = Array.from(document.querySelectorAll(".expect")); |
| if (allExpectations.length > 100) { |
| console.error("Too many details to be shown at once"); |
| } else { |
| for (let e of allExpectations) |
| if (e != expectation) |
| isOpen ? GUI.hideResults(e) : GUI.showResults(e, true); |
| } |
| } |
| if (closeOthers) { |
| for (let el of Array.from(document.querySelectorAll(".details.open"))) |
| GUI.hideResults(el.parentNode); |
| } |
| if (isOpen) { |
| GUI.hideResults(expectation); |
| expectation.focus(); |
| } |
| else { |
| GUI.showResults(expectation); |
| } |
| }, |
| |
| getResultViewer: function(toolbar) { |
| let output = GUI.closest(toolbar, "result-frame").querySelector(".result-output"); |
| if (output && output.children.length > 0) |
| return output.children[0]; |
| }, |
| |
| setResultViewer: function(toolbar, viewer) { |
| let output = GUI.closest(toolbar, "result-frame").querySelector(".result-output"); |
| output.innerHTML = ""; |
| output.appendChild(viewer); |
| }, |
| |
| closest: function (el, className) { |
| while (el && el.classList) { |
| if (el.classList.contains(className)) |
| return el; |
| else |
| el = el.parentNode; |
| } |
| }, |
| |
| showResults: function(expectation, doNotScroll) { |
| let details = expectation.querySelector(".details"); |
| if (details.classList.contains("open")) |
| return; |
| details.classList.add("open"); |
| let testId = parseInt(expectation.getAttribute("data-id")); |
| let test = Report.getTestById(testId); |
| if (!test) |
| console.error("could not find test by id"); |
| let results = Report.getResultsDiv(test); |
| results.classList.add("results"); |
| expectation.parentNode.insertBefore(results, expectation.nextSibling); |
| for (let toolbar of Array.from(results.querySelectorAll(".tx-toolbar"))) { |
| if (toolbar.showDefault) { |
| toolbar.showDefault(); |
| break; |
| } |
| } |
| if (doNotScroll) { |
| return; |
| } |
| // Scroll result into view, leaving space for image zoom, and |
| // test title on top. |
| let zoomHeight = window.innerWidth / 3 + 10; |
| let resultHeight = Math.min(window.innerHeight - 80, 630) + 34; |
| let overflow = zoomHeight + resultHeight + expectation.offsetHeight - window.innerHeight; |
| if (overflow > 0) |
| zoomHeight = Math.max(0, zoomHeight - overflow); |
| window.scrollTo(0, expectation.offsetTop - zoomHeight); |
| }, |
| |
| hideResults: function(expectation) { |
| let details = expectation.querySelector(".details"); |
| if (!details.classList.contains("open")) |
| return; |
| expectation.querySelector(".details").classList.remove("open"); |
| expectation.nextSibling.remove(); |
| } |
| }; // GUI |
| |
| </script> |
| |
| <script> |
| // test-expectation components |
| // These components are in a separate script tag. |
| // They are independent of the rest of the page so they can be reused |
| // in different pages in the future. |
| // |
| // Current components include toolbars, and viewers. |
| // Toolbars control viewers. |
| // Viewers present different views into test results. |
| class TXToolbar { |
| importStyle() { |
| if (document.querySelector("#TXToolbarCSS")) |
| return; |
| let style = document.createElement("style"); |
| style.setAttribute("type", "text/css"); |
| style.setAttribute("id", "TXToolbarCSS"); |
| style.innerText = |
| `.tx-toolbar a[data-selected] { |
| background-color: #DDD; |
| text-decoration-color: aquamarine; |
| } |
| .tx-toolbar a { |
| padding-left: 4px; |
| padding-right: 4px; |
| } |
| `; |
| document.head.appendChild(style); |
| } |
| |
| getViewer() { |
| let viewer = GUI.getResultViewer(this.toolbar); |
| return (viewer && viewer.owner == this) ? viewer : null; |
| } |
| |
| defaultExtendSelection(anchor) { |
| return false; |
| } |
| |
| selectAnchor(target, extendSelection) { |
| let toggle = false; |
| if (extendSelection === undefined) { |
| toggle = true; |
| extendSelection = this.defaultExtendSelection(target); |
| } |
| for (let anchor of Array.from(this.toolbar.querySelectorAll("a"))) { |
| if (anchor == target) { |
| if (toggle) { |
| if (anchor.hasAttribute("data-selected")) |
| anchor.removeAttribute("data-selected"); |
| else |
| anchor.setAttribute("data-selected", ""); |
| } else { |
| anchor.setAttribute("data-selected", ""); |
| } |
| } else if (!extendSelection && !this.defaultExtendSelection(anchor)) { |
| anchor.removeAttribute("data-selected"); |
| } |
| } |
| this.updateViewer(target); |
| } |
| } // class TXToolbar |
| |
| class ImageResultsToolbar extends TXToolbar { |
| constructor() { |
| super(); |
| this.boundViewOptionsChangeHandler = this.viewOptionsChangeHandler.bind(this); |
| } |
| |
| createDom(test) { |
| this.importStyle(); |
| let pathParser = new PathParser(test.expectPath); |
| this.toolbar = document.createElement("li"); |
| this.toolbar.showDefault = _ => this.viewOptionsChangeHandler({target: this.toolbar.querySelector(".view-options")}); |
| this.toolbar.classList.add("image-toolbar"); |
| this.toolbar.classList.add("tx-toolbar"); |
| let missing = test.actual == "MISSING"; |
| let html = ` |
| image: |
| <a class="actual" href="${pathParser.resultLink("-actual.png")}" title="Actual result">actual</a>`; |
| if (!missing) |
| html += ` |
| <a class="expected" href="${pathParser.resultLink("-expected.png")}" title="Expected result">expected</a> |
| <a class="diff" href="${pathParser.resultLink("-diff.png")}" title="Difference">diff</a> |
| `; |
| |
| html += ` |
| <label><select class="view-options"> |
| <option value="single">Single view</option> |
| <option value="animated">Animated view</option> |
| <option value="multiple">Side by side view</option> |
| </label> |
| `; |
| this.toolbar.innerHTML = html; |
| this.toolbar.addEventListener("click", ev => { |
| if (ev.target.tagName == "A") { |
| this.selectAnchor(ev.target); |
| ev.preventDefault(); |
| if (this.animationIntervalId) { |
| // Suspend animation to show the clicked view for one second. |
| this.animationPaused = true; |
| window.setTimeout(_ => { this.animationPaused = false }, 1000); |
| } |
| } |
| }); |
| |
| let viewOptions = this.toolbar.querySelector(".view-options"); |
| viewOptions.addEventListener("change", this.boundViewOptionsChangeHandler); |
| if (window.localStorage.getItem("ImageToolbarView")) |
| viewOptions.value = window.localStorage.getItem("ImageToolbarView"); |
| else |
| viewOptions.value = "animated"; |
| return this.toolbar; |
| } |
| |
| viewOptionsChangeHandler(ev) { |
| let viewOptions = ev.target; |
| try { |
| window.localStorage.setItem("ImageToolbarView", viewOptions.value); |
| } catch(ex) {} |
| |
| switch(viewOptions.value) { |
| case "animated": { |
| let selectedAnchors = Array.from(this.toolbar.querySelectorAll("a[data-selected]")); |
| if (selectedAnchors.length == 0) |
| this.selectAnchor(this.toolbar.querySelector("a")); |
| this.setAnimation(true); |
| } |
| break; |
| case "single": { |
| this.setAnimation(false); |
| // Make sure only one is selected. |
| let selectedAnchors = Array.from(this.toolbar.querySelectorAll("a[data-selected]")); |
| if (selectedAnchors.length == 0) |
| this.selectAnchor(this.toolbar.querySelector("a")); |
| else |
| this.selectAnchor(selectedAnchors[0]); |
| } |
| break; |
| case "multiple": { |
| this.setAnimation(false); |
| for (let anchor of Array.from(this.toolbar.querySelectorAll("a"))) |
| this.selectAnchor(anchor, true); |
| } |
| break; |
| default: |
| console.error("unknown view option"); |
| } |
| } |
| |
| defaultExtendSelection(anchor) { |
| return this.toolbar.querySelector(".view-options").value == "multiple"; |
| } |
| |
| updateViewer(element) { |
| // Find currently selected anchor. |
| let selectedAnchors = Array.from(this.toolbar.querySelectorAll("a[data-selected]")); |
| if (selectedAnchors.length == 0) { |
| selectedAnchors.push(this.toolbar.querySelector("a")); |
| selectedAnchors[0].setAttribute("data-selected", ""); |
| } |
| let viewer = this.getViewer(); |
| if (!viewer) { |
| let imgInfo = Array.from(this.toolbar.querySelectorAll("a")).map( |
| anchor => { return {src: anchor.href, title: anchor.title}; }); |
| viewer = (new ImageResultsViewer()).createDom(imgInfo, this); |
| this.setAnimation(this.toolbar.querySelector(".view-options").value == "animated"); |
| GUI.setResultViewer(element, viewer); |
| } |
| viewer.showImages(selectedAnchors.map(anchor => anchor.href)); |
| } |
| |
| setAnimation(animate) { |
| if (animate) { |
| if (!this.animationIntervalId) |
| this.animationIntervalId = window.setInterval(_ => { |
| if (!this.getViewer()) { |
| console.log("element is gone"); |
| window.clearInterval(this.animationIntervalId); |
| this.animationIntervalId = null; |
| return; |
| } |
| if (this.animationPaused) |
| return; |
| // Find next anchor. |
| let allAnchors = Array.from(this.toolbar.querySelectorAll("a")); |
| let nextAnchor = allAnchors[0]; |
| for (let i = 0; i < allAnchors.length; i++) { |
| if (allAnchors[i].hasAttribute("data-selected")) { |
| nextAnchor = allAnchors[(i + 1) % (allAnchors.length - 1)]; |
| break; |
| } |
| } |
| this.selectAnchor(nextAnchor, false); |
| }, 400); |
| } else { |
| this.animationPaused = false; |
| if (this.animationIntervalId) { |
| window.clearInterval(this.animationIntervalId); |
| this.animationIntervalId = null; |
| } |
| } |
| } |
| } // class ImageResultsToolbar |
| |
| class ImageResultsViewer { |
| constructor() { |
| this.boundEventMoveHandler = this.eventMoveHandler.bind(this); |
| this.boundTileScrollHandler = this.tileScrollHandler.bind(this); |
| this.boundWheelHandler = this.tileWheelHandler.bind(this); |
| } |
| |
| importStyle() { |
| if (document.querySelector("#ImageViewerCSS")) |
| return; |
| let style = document.createElement("style"); |
| style.setAttribute("type", "text/css"); |
| style.setAttribute("id", "ImageViewerCSS"); |
| style.innerText = |
| ` .image-viewer { |
| position: relative; |
| display: flex; |
| } |
| .image-viewer-tile { |
| flex-shrink: 1; |
| border: 1px solid #EEE; |
| overflow: auto; |
| max-height: calc(100vh - 80px); |
| margin-right: 8px; |
| min-height: 605px; |
| } |
| .image-viewer-highlight { |
| position: absolute; |
| background-color: red; |
| opacity: 0.5; |
| box-shadow: 0px 0px 8px 2px rgba(255,0,0,1); |
| } |
| .image-viewer-highlight.animate { |
| animation-name: highlight-animation; |
| animation-duration: 0.15s; |
| animation-timing-function: ease-out; |
| animation-delay: 0s; |
| animation-direction: alternate; |
| animation-iteration-count: 2; |
| } |
| .image-viewer-highlight.animate-long { |
| animation-iteration-count: 4; |
| } |
| @keyframes highlight-animation { |
| 0% { |
| display: block; |
| opacity: 0; |
| transform: scale(1.0); |
| } |
| 100% { |
| display: block; |
| opacity: 0.5; |
| transform: scale(1.1); |
| } |
| }`; |
| document.head.appendChild(style); |
| } |
| |
| /* ImageResultsViewer dom |
| <div class="image-viewer"> |
| <div class="image-viewer-tile"> |
| <img src="...-actual" title="Actual result"> |
| </div> |
| <div class="image-viewer-tile"> |
| <img src="...-expected" title="Expected result"> |
| </div> |
| <div class="image-viewer-tile"> |
| <img src="...-diff" title="Difference"> |
| </div> |
| </div> |
| */ |
| createDom(imgInfo, owner) { |
| this.importStyle(); |
| this.viewer = document.createElement("div"); |
| // Viewer DOM API |
| this.viewer.showImages = this.showImages.bind(this); |
| this.viewer.owner = owner; |
| this.viewer.controller = this; |
| this.viewer.classList.add("image-viewer"); |
| this.viewer.addEventListener("click", (ev) => { |
| this.toggleZoom({x: ev.offsetX, y: ev.offsetY}, ev.target); |
| }); |
| for (let info of imgInfo) { |
| let imgTile = document.createElement("div"); |
| imgTile.classList.add("image-viewer-tile"); |
| imgTile.classList.add("hidden"); |
| imgTile.innerHTML = `<img src="${info.src}" title="${info.title}">`; |
| imgTile.addEventListener("scroll", this.boundTileScrollHandler, {passive: true}); |
| imgTile.addEventListener("wheel", this.boundWheelHandler); |
| this.viewer.appendChild(imgTile); |
| if (info.title == "Difference") { |
| this.diffImage = new Image(); |
| this.diffImage.addEventListener("load", function(ev) { |
| this.computeDiffRect(ev.target); |
| }.bind(this), false); |
| this.diffImage.src = info.src; |
| } |
| } |
| return this.viewer; |
| } |
| |
| blinkDiffRect() { |
| this.computeDiffRect(this.diffImage); |
| } |
| |
| // public API |
| showImages(sources) { |
| let tiles = Array.from(this.viewer.querySelectorAll(".image-viewer-tile")); |
| // Newly shown tiles should have the same scroll position as existing tiles. |
| let scrollPosition; |
| for (let tile of tiles) { |
| if (!tile.classList.contains("hidden")) { |
| scrollPosition = {top: tile.scrollTop, left: tile.scrollLeft}; |
| break; |
| } |
| } |
| for (let tile of tiles) { |
| let tileImage = tile.querySelector("img"); |
| let visible = sources.includes(tileImage.src); |
| if (visible) { |
| tile.classList.remove("hidden"); |
| if (scrollPosition) { |
| tile.scrollTop = scrollPosition.top; |
| tile.scrollLeft = scrollPosition.left; |
| } |
| } |
| else |
| tile.classList.add("hidden"); |
| } |
| } |
| |
| findVisibleImage(preferredImage) { |
| if (preferredImage && !preferredImage.parentNode.classList.contains("hidden")) |
| return preferredImage; |
| for (let image of Array.from(this.viewer.querySelectorAll("img"))) |
| if (!image.parentNode.classList.contains("hidden") && image) |
| return image; |
| } |
| |
| tileScrollHandler(ev) { |
| let sourceTile = ev.target; |
| if (sourceTile.classList.contains("hidden")) |
| return; |
| for (let tile of Array.from(this.viewer.querySelectorAll(".image-viewer-tile"))) { |
| if (tile != sourceTile) { |
| tile.scrollTop = sourceTile.scrollTop; |
| tile.scrollLeft = sourceTile.scrollLeft; |
| } |
| } |
| } |
| |
| tileWheelHandler(ev) { |
| // Prevent page back/forward gestures when scrolling inside tiles. |
| if (Math.abs(ev.deltaY) > Math.abs(ev.deltaX)) |
| return; |
| let target = ev.currentTarget; |
| let blockScrollRight = (target.scrollLeft + target.clientWidth) == target.scrollWidth; |
| let blockScrollLeft = target.scrollLeft == 0; |
| if (blockScrollRight && ev.deltaX > 0) { |
| ev.preventDefault(); |
| } |
| if (blockScrollLeft && ev.deltaX < 0) { |
| ev.preventDefault(); |
| } |
| } |
| |
| eventMoveHandler(ev) { |
| let imageRect = this.findVisibleImage(this.zoomTarget).getBoundingClientRect(); |
| let zoom = this.viewer.querySelector(".image-viewer-zoom"); |
| if (zoom) |
| zoom.showLocation({ |
| x: ev.pageX - (imageRect.left + window.scrollX), |
| y: ev.pageY - (imageRect.top + window.scrollY) |
| }); |
| } |
| |
| toggleZoom(location, zoomTarget) { |
| let zoom = this.viewer.querySelector(".image-viewer-zoom"); |
| if (zoom) { |
| zoom.remove(); |
| delete this.zoomTarget; |
| this.viewer.removeEventListener("mousemove", this.boundEventMoveHandler); |
| this.viewer.removeEventListener("touchmove", this.boundEventMoveHandler); |
| } else { |
| this.zoomTarget = zoomTarget; |
| let zoomElement = (new ImageViewerZoom()).createDom(this.viewer, this.viewer.querySelectorAll("img")); |
| this.viewer.addEventListener("mousemove", this.boundEventMoveHandler); |
| this.viewer.addEventListener("touchmove", this.boundEventMoveHandler); |
| zoomElement.showLocation(location); |
| } |
| } |
| |
| computeDiffRect(image) { |
| let canvas = document.createElement("canvas"); |
| canvas.width = image.naturalWidth; |
| canvas.height = image.naturalHeight; |
| let ctx = canvas.getContext("2d"); |
| ctx.drawImage(image, 0, 0); |
| let top = image.naturalHeight; |
| let bottom = 0; |
| let left = image.naturalWidth; |
| let right = 0; |
| try { |
| let imageData = ctx.getImageData(0, 0, image.naturalWidth, image.naturalHeight); |
| for (let x = 0; x < imageData.width; x++) |
| for (let y = 0; y < imageData.height; y++) { |
| let offset = y * imageData.width * 4 + x * 4; |
| if (imageData.data[offset] == 255 && imageData.data[offset+1] == 0 && imageData.data[offset+2] == 0) { |
| if (x < left) left = x; |
| if (x > right) right = x; |
| if (y < top) top = y; |
| if (y > bottom) bottom = y; |
| } |
| } |
| if (bottom != 0 && right != 0) |
| this.animateDiffRect({top: top, left: left, width: Math.max(0, right - left), height: Math.max(0, bottom - top)}, image); |
| } catch(ex) { |
| console.error("cannot show error rect on local files because of cross origin taint"); |
| } |
| } |
| |
| animateDiffRect(diffRect, image) { |
| if (!image) |
| return; |
| if (!image.complete) { |
| image.addEventListener("load", _ => this.animateDiffRect(diffRect, image)); |
| return; |
| } |
| let highlight = document.createElement("div"); |
| highlight.classList.add("image-viewer-highlight"); |
| highlight.style.top = image.offsetTop + diffRect.top + "px"; |
| highlight.style.left = image.offsetLeft + diffRect.left + "px"; |
| highlight.style.width = Math.max(5, diffRect.width) + "px"; |
| highlight.style.height = Math.max(5, diffRect.height) + "px"; |
| this.viewer.appendChild(highlight); |
| highlight.addEventListener("animationend", _ => highlight.remove()); |
| if (diffRect.width < 100 || diffRect.height < 100) { |
| highlight.classList.add("animate-long"); |
| } |
| highlight.classList.add("animate"); |
| } |
| } // class ImageResultsViewer |
| |
| class ImageViewerZoom { |
| constructor(zoomFactor = 6) { |
| this.zoomFactor = zoomFactor; |
| } |
| |
| importStyle() { |
| if (document.querySelector("#ImageViewerZoomCSS")) |
| return; |
| let style = document.createElement("style"); |
| style.setAttribute("type", "text/css"); |
| style.setAttribute("id", "ImageViewerZoomCSS"); |
| style.innerText = ` |
| .image-viewer-zoom { |
| display: flex; |
| background-color: #555; |
| position: fixed; |
| padding-left: 4px; |
| padding-right: 4px; |
| top: 20px; |
| left: 16px; |
| bottom: 16px; |
| right: 16px; |
| height: calc((100vw - 32px) / 3); |
| max-height: 50vh; |
| box-shadow: 0px 0px 10px 2px rgba(0,0,0,0.68); |
| } |
| .image-viewer-zoom-tile { |
| position: relative; |
| flex-grow: 1; |
| flex-shrink: 1; |
| margin: 16px 8px 16px 8px; |
| } |
| .image-viewer-zoom-tile > canvas { |
| width: 100%; |
| height: 100%; |
| } |
| .image-viewer-zoom-tile > .title { |
| position: absolute; |
| top: -36px; |
| left: -px; |
| background-color: white; |
| white-space: nowrap; |
| overflow: hidden; |
| } |
| .image-viewer-zoom-color { |
| font-size: smaller; |
| } |
| `; |
| document.head.appendChild(style); |
| } |
| /* |
| <div class="image-viewer-zoom"> |
| <div class="image-viewer-zoom-tile"> // repeat for each image |
| <canvas></canvas> |
| <div class="title"><span class="color"></span></div> |
| </div> |
| */ |
| createDom(container, images) { |
| this.importStyle(); |
| this.images = Array.from(images); |
| this.zoomElement = document.createElement("div"); |
| // ImageViewer DOM API |
| this.zoomElement.showLocation = this.showLocation.bind(this); |
| |
| this.zoomElement.classList.add("image-viewer-zoom"); |
| this.zoomElement.classList.add("close-on-esc"); |
| container.appendChild(this.zoomElement); |
| // Add canvas for each image. |
| for (let img of this.images) { |
| let tile = document.createElement("div"); |
| tile.classList.add("image-viewer-zoom-tile"); |
| tile.innerHTML = `<canvas></canvas><div class="title">${img.title} <span class="image-viewer-zoom-color"></span></div>`; |
| this.zoomElement.appendChild(tile); |
| } |
| return this.zoomElement; |
| } |
| |
| showLocation(location) { |
| let canvases = Array.from(this.zoomElement.querySelectorAll("canvas")); |
| let naturalSize; |
| // Find non-hidden image to determine size. |
| for (let image of this.images) { |
| if (image.complete && !image.classList.contains("hidden")) { |
| naturalSize = {width: image.naturalWidth, height: image.naturalHeight}; |
| break; |
| } |
| } |
| if (!naturalSize) { |
| console.warn("image not loaded"); |
| return; |
| } |
| for (let i = 0; i < canvases.length; i++) { |
| // Set canvas to right size if window resized. |
| let canvasWidth = canvases[i].clientWidth; |
| let canvasHeight = canvases[i].clientHeight; |
| canvases[i].width = canvasWidth; |
| canvases[i].height = canvasHeight; |
| let ctx = canvases[i].getContext("2d"); |
| ctx.imageSmoothingEnabled = false; |
| // Copy over zoomed image. |
| let sWidth = canvasWidth / this.zoomFactor; |
| let sHeight = canvasHeight / this.zoomFactor; |
| let pad = 20; |
| let sx = Math.floor(Math.min(Math.max(-pad, location.x - sWidth / 2), naturalSize.width + pad - sWidth)); |
| let sy = Math.floor(Math.min(Math.max(-pad, location.y - sHeight / 2), naturalSize.height + pad - sHeight)); |
| |
| ctx.drawImage(this.images[i], sx, sy, sWidth, sHeight, 0, 0, canvasWidth, canvasHeight); |
| // Draw grid. |
| ctx.strokeStyle = "rgba(0,0,0,0.05)"; |
| let pixelSize = canvasWidth / sWidth; |
| ctx.beginPath(); |
| for (let y = 1; y < sHeight; y++) { |
| ctx.moveTo(0, y * pixelSize); |
| ctx.lineTo(canvasWidth, y * pixelSize); |
| } |
| for (let x = 1; x < sWidth; x++) { |
| ctx.moveTo(x*pixelSize, 0); |
| ctx.lineTo(x*pixelSize, canvasHeight); |
| } |
| ctx.closePath(); |
| ctx.stroke(); |
| |
| // Highlight middle pixel whose color is measured. |
| |
| try { // getImageData throws on local file system. |
| let middleX = Math.floor(sWidth / 2); |
| let middleY = Math.floor(sHeight / 2); |
| let imageData = ctx.getImageData(middleX * pixelSize + 2, middleY * pixelSize + 2, 1, 1); |
| let r = imageData.data[0]; |
| let g = imageData.data[1]; |
| let b = imageData.data[2]; |
| let a = imageData.data[3]; |
| let colorSpan = canvases[i].parentNode.querySelector(".image-viewer-zoom-color"); |
| let color = `rgba(${r}, ${g}, ${b}, ${a})`; |
| colorSpan.innerText = `(${sx + middleX}, ${sy + middleY}) ${color}`; |
| colorSpan.style.backgroundColor = color; |
| colorSpan.style.color = ((r + g + b) > 300 ) ? "black" : "white"; |
| ctx.beginPath(); |
| ctx.moveTo(middleX * pixelSize, middleY * pixelSize); |
| ctx.lineTo((middleX + 1) * pixelSize, middleY * pixelSize); |
| ctx.lineTo((middleX + 1) * pixelSize, (middleY + 1) * pixelSize); |
| ctx.lineTo(middleX * pixelSize, (middleY + 1) * pixelSize); |
| ctx.lineTo(middleX * pixelSize, middleY * pixelSize); |
| ctx.strokeStyle = "rgba(0,0,0,0.2)"; |
| ctx.closePath(); |
| ctx.stroke(); |
| } catch(ex) {} |
| } |
| } |
| } // class ImageViewerZoom |
| |
| class TextResultsToolbar extends TXToolbar { |
| constructor() { |
| super(); |
| } |
| |
| defaultExtendSelection(anchor) { |
| return anchor.className == "repaint"; |
| } |
| |
| createDom(test) { |
| this.importStyle(); |
| let pathParser = new PathParser(test.expectPath); |
| this.toolbar = document.createElement("li"); |
| this.toolbar.showDefault = (_ => { |
| this.selectAnchor( |
| this.toolbar.querySelector("a.pretty") || this.toolbar.querySelector("a.actual")); |
| let repaintOverlay = this.toolbar.querySelector("a.repaint"); |
| if (repaintOverlay) |
| this.selectAnchor(repaintOverlay, true); |
| }).bind(this); |
| this.toolbar.classList.add("text-results-toolbar"); |
| this.toolbar.classList.add("tx-toolbar"); |
| let missing = test.actual == "MISSING"; |
| let html = "text:"; |
| if (!test.is_testharness_test && !missing) |
| html += `<a class="pretty" href="${pathParser.resultLink("-pretty-diff.html")}" title="Pretty difference">pretty</a>`; |
| html += `<a class="actual" href="${pathParser.resultLink("-actual.txt")}" title="Actual text">actual</a>`; |
| if (!test.is_testharness_test && !missing) { |
| html += `<a class="expected" href="${pathParser.resultLink("-expected.txt")}" title="Expected result">expected</a><a |
| class="diff" href="${pathParser.resultLink("-diff.txt")}" title="Difference">diff</a>`; |
| } |
| if (test.has_repaint_overlay) |
| html += ` <a class="repaint" href="${pathParser.repaintOverlayLink()}" title="Repaint overlay">repaint</a>`; |
| this.toolbar.innerHTML = html + "\n"; |
| this.toolbar.addEventListener("click", ev => { |
| if (ev.target.tagName == "A") { |
| this.selectAnchor(ev.target); |
| ev.preventDefault(); |
| } |
| }); |
| return this.toolbar; |
| } |
| |
| updateViewer() { |
| // Find currently selected anchor. |
| let selectedAnchors = Array.from(this.toolbar.querySelectorAll("a[data-selected]")); |
| if (selectedAnchors.length == 0) { |
| selectedAnchors.push(this.toolbar.querySelector("a")); |
| selectedAnchors[0].setAttribute("data-selected", ""); |
| } |
| let viewer = this.getViewer(); |
| if (!viewer) { |
| let fileInfo = Array.from(this.toolbar.querySelectorAll("a")).map( |
| anchor => { return {src: anchor.href, title: anchor.title}; }); |
| viewer = (new TextFileViewer()).createDom(fileInfo, this); |
| GUI.setResultViewer(this.toolbar, viewer); |
| } |
| viewer.showFile(selectedAnchors.map( anchor => anchor.href)); |
| } |
| } // class TextResultsToolbar |
| |
| class TextFileViewer { |
| importStyle() { |
| if (document.querySelector("#TextFileViewerCSS")) |
| return; |
| let style = document.createElement("style"); |
| style.setAttribute("type", "text/css"); |
| style.setAttribute("id", "TextFileViewerCSS"); |
| style.innerText = |
| ` .text-file-viewer { |
| position: relative; |
| display: flex; |
| } |
| .text-file-viewer > iframe { |
| flex-shrink: 1; |
| flex-grow: 1; |
| border: 1px solid #888; |
| overflow: scroll; |
| max-height: calc(100vh - 80px); |
| margin-right: 8px; |
| height: 600px; |
| } |
| `; |
| document.head.appendChild(style); |
| } |
| |
| /* TextFileViewer dom |
| <div class="text-file-viewer"> |
| <iframe class="text-file-viewer-iframe" src="file.."> |
| <iframe class="text-file-viewer-iframe" src="file.."> |
| <iframe class="text-file-viewer-iframe" src="file.."> |
| </div> |
| */ |
| createDom(fileInfo, owner) { |
| this.importStyle(); |
| this.viewer = document.createElement("div"); |
| // TextFileViewer DOM API |
| this.viewer.owner = owner; |
| this.viewer.showFile = this.showFile.bind(this); |
| |
| this.viewer.classList.add("text-file-viewer"); |
| for (let info of fileInfo) { |
| let iframe = document.createElement("iframe"); |
| iframe.classList.add("text-file-viewer-iframe"); |
| iframe.classList.add("hidden"); |
| iframe.src = info.src; |
| iframe.setAttribute("tabindex", -1); |
| this.viewer.appendChild(iframe); |
| } |
| return this.viewer; |
| } |
| |
| showFile(sources) { |
| let iframes = Array.from(this.viewer.querySelectorAll(".text-file-viewer-iframe")); |
| for (let iframe of iframes) { |
| let visible = sources.includes(iframe.src); |
| if (visible) { |
| iframe.classList.remove("hidden"); |
| } else { |
| iframe.classList.add("hidden"); |
| } |
| } |
| } |
| } // class TextFileViewer |
| |
| class PlainHtmlToolbar extends TXToolbar { |
| createDom(html) { |
| super.importStyle(); |
| let dom = document.createElement("li"); |
| dom.classList.add("tx-toolbar"); |
| dom.innerHTML = html; |
| return dom; |
| } |
| } // class PlainHtmlToolbar |
| |
| class SimpleLinkToolbar extends TXToolbar { |
| createDom(href, title, text) { |
| super.importStyle(); |
| this.toolbar = document.createElement("li"); |
| // Toolbar DOM API |
| this.toolbar.showDefault = (_ => {this.selectAnchor(this.toolbar.querySelector("a"));}).bind(this); |
| |
| this.toolbar.classList.add("tx-toolbar"); |
| this.toolbar.innerHTML = `<a href="${href}" title="${title}">${text}</a>`; |
| this.toolbar.addEventListener("click", ev => { |
| if (ev.target.tagName == "A") { |
| this.selectAnchor(ev.target); |
| ev.preventDefault(); |
| } |
| }); |
| return this.toolbar; |
| } |
| |
| updateViewer() { |
| // Find currently selected anchor. |
| let selectedAnchors = Array.from(this.toolbar.querySelectorAll("a[data-selected]")); |
| if (selectedAnchors.length == 0) { |
| selectedAnchors.push(this.toolbar.querySelector("a")); |
| selectedAnchors[0].setAttribute("data-selected", ""); |
| } |
| let viewer = this.getViewer(); |
| if (!viewer) { |
| let fileInfo = Array.from(this.toolbar.querySelectorAll("a")).map( |
| anchor => { return {src: anchor.href, title: anchor.title};}); |
| viewer = (new TextFileViewer()).createDom(fileInfo, this); |
| GUI.setResultViewer(this.toolbar, viewer); |
| } |
| viewer.showFile(selectedAnchors.map( anchor => anchor.href)); |
| } |
| } // class SimpleLinkToolbar |
| |
| // Audio results. |
| class AudioResultsToolbar extends TXToolbar { |
| |
| createDom(test) { |
| this.importStyle(); |
| this.test = test; |
| let pathParser = new PathParser(test.expectPath); |
| this.toolbar = document.createElement("li"); |
| this.toolbar.showDefault = (_ => { |
| this.selectAnchor(this.toolbar.querySelector("a")); |
| }).bind(this); |
| this.toolbar.classList.add("tx-toolbar"); |
| this.toolbar.innerHTML = `<a href="#audioresults">audio results</a>\n`; |
| this.toolbar.addEventListener("click", ev => { |
| if (ev.target.tagName == "A") { |
| this.selectAnchor(ev.target); |
| ev.preventDefault(); |
| } |
| }); |
| return this.toolbar; |
| } |
| |
| updateViewer() { |
| let viewer = this.getViewer(); |
| if (!viewer) { |
| viewer = (new AudioResultsViewer()).createDom(this.test); |
| GUI.setResultViewer(this.toolbar, viewer); |
| } |
| } |
| } |
| |
| class AudioResultsViewer { |
| createDom(test) { |
| this.viewer = document.createElement("div"); |
| this.viewer.classList.add("audio-results-viewer"); |
| let pathParser = new PathParser(test.expectPath); |
| this.viewer.innerHTML = ` |
| <p>Actual: |
| <audio controls src="${pathParser.resultLink('-actual.wav')}"></audio> |
| <a download="actual.wav" href="${pathParser.resultLink('-actual.wav')}">download</a> |
| </p> |
| <p>Expected: |
| <audio controls src="${pathParser.resultLink('-expected.wav')}"></audio> |
| <a download="expected.wav" href="${pathParser.resultLink('-expected.wav')}">download</a> |
| </p> |
| `; |
| return this.viewer; |
| } |
| } // class AudioResultsViewer |
| |
| </script> |
| <script> |
| // jsonp callback |
| function ADD_FULL_RESULTS(results) { |
| GUI.initPage(results); |
| } |
| </script> |
| <script src="full_results_jsonp.js"></script> |