diff options
-rw-r--r-- | src/corelib/Qt6WasmMacros.cmake | 4 | ||||
-rw-r--r-- | src/testlib/CMakeLists.txt | 4 | ||||
-rw-r--r-- | util/wasm/batchedtestrunner/batchedtestrunner.html | 1 | ||||
-rw-r--r-- | util/wasm/batchedtestrunner/qtestoutputreporter.css | 89 | ||||
-rw-r--r-- | util/wasm/batchedtestrunner/qtestoutputreporter.js | 366 | ||||
-rw-r--r-- | util/wasm/batchedtestrunner/qwasmtestmain.js | 12 |
6 files changed, 475 insertions, 1 deletions
diff --git a/src/corelib/Qt6WasmMacros.cmake b/src/corelib/Qt6WasmMacros.cmake index d86c7202db..c248331e61 100644 --- a/src/corelib/Qt6WasmMacros.cmake +++ b/src/corelib/Qt6WasmMacros.cmake @@ -29,6 +29,8 @@ function(_qt_internal_wasm_add_target_helpers target) if(is_test) configure_file("${WASM_BUILD_DIR}/libexec/batchedtestrunner.html" "${target_output_directory}/batchedtestrunner.html" COPYONLY) + configure_file("${WASM_BUILD_DIR}/libexec/qtestoutputreporter.css" + "${target_output_directory}/qtestoutputreporter.css" COPYONLY) configure_file("${WASM_BUILD_DIR}/libexec/batchedtestrunner.js" "${target_output_directory}/batchedtestrunner.js" COPYONLY) configure_file("${WASM_BUILD_DIR}/libexec/emrunadapter.js" @@ -37,6 +39,8 @@ function(_qt_internal_wasm_add_target_helpers target) "${target_output_directory}/qwasmjsruntime.js" COPYONLY) configure_file("${WASM_BUILD_DIR}/libexec/qwasmtestmain.js" "${target_output_directory}/qwasmtestmain.js" COPYONLY) + configure_file("${WASM_BUILD_DIR}/libexec/qtestoutputreporter.js" + "${target_output_directory}/qtestoutputreporter.js" COPYONLY) configure_file("${WASM_BUILD_DIR}/libexec/util.js" "${target_output_directory}/util.js" COPYONLY) else() diff --git a/src/testlib/CMakeLists.txt b/src/testlib/CMakeLists.txt index 4b6e2921e7..37f0188006 100644 --- a/src/testlib/CMakeLists.txt +++ b/src/testlib/CMakeLists.txt @@ -178,6 +178,10 @@ if(QT_FEATURE_batch_test_support AND WASM) list(APPEND wasm_support_libexec_files ${QtBase_SOURCE_DIR}/util/wasm/batchedtestrunner/qwasmtestmain.js) list(APPEND wasm_support_libexec_files + ${QtBase_SOURCE_DIR}/util/wasm/batchedtestrunner/qtestoutputreporter.js) + list(APPEND wasm_support_libexec_files + ${QtBase_SOURCE_DIR}/util/wasm/batchedtestrunner/qtestoutputreporter.css) + list(APPEND wasm_support_libexec_files ${QtBase_SOURCE_DIR}/util/wasm/batchedtestrunner/util.js) qt_path_join(libexec_dir ${QT_INSTALL_DIR} ${INSTALL_LIBEXECDIR}) diff --git a/util/wasm/batchedtestrunner/batchedtestrunner.html b/util/wasm/batchedtestrunner/batchedtestrunner.html index 14a9fa1807..0b85e48691 100644 --- a/util/wasm/batchedtestrunner/batchedtestrunner.html +++ b/util/wasm/batchedtestrunner/batchedtestrunner.html @@ -8,6 +8,7 @@ SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only <head> <meta charset="utf-8"> <title>WASM batched test runner (emrun-enabled)</title> + <link rel="stylesheet" href="qtestoutputreporter.css"></link> <script type="module" defer="defer" src="qwasmtestmain.js"></script> </head> <body></body> diff --git a/util/wasm/batchedtestrunner/qtestoutputreporter.css b/util/wasm/batchedtestrunner/qtestoutputreporter.css new file mode 100644 index 0000000000..3cf312b11a --- /dev/null +++ b/util/wasm/batchedtestrunner/qtestoutputreporter.css @@ -0,0 +1,89 @@ +/* + Copyright (C) 2022 The Qt Company Ltd. + SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only +*/ + +:root { + --good-color-light: chartreuse; + --bad-color-light: lightcoral; + --warning-color-light: orange; + --info-color-light: cornflowerblue; + --ignore-color-light: gray; + --good-color-dark: green; + --bad-color-dark: red; + --warning-color-dark: darkorange; + --info-color-dark: blue; + --ignore-color-dark: lightgray; +} + +.zero { + display: none; +} + +.light-background .good { + color: var(--good-color-dark); +} + +.light-background .bad { + color: var(--bad-color-dark); +} + +.light-background .warning { + color: var(--warning-color-dark); +} + +.light-background .info { + color: var(--info-color-dark); +} + +.light-background .ignore { + color: var(--ignore-color-dark); +} + +.output-area { + font-family: monospace; +} + +.output-line { + display: block; + white-space: pre-wrap; +} + +.counter-box { + position: fixed; + width: 100%; + display: flex; + justify-content: center; +} + +.counter-box span { + padding-right: 10px; +} + +.counter-box .pass { + background-color: var(--good-color-light); +} + +.counter-box .fail { + background-color: var(--bad-color-light); +} + +.counter-box .skip { + background-color: var(--info-color-light); +} + +.counter-box .xfail { + background-color: var(--warning-color-light); +} + +.counter-box .xpass { + background-color: var(--bad-color-light); +} + +.counter-box .bpass, +.counter-box .bfail, +.counter-box .bxpass, +.counter-box .bxfail, +.counter-box .other { + background-color: var(--ignore-color-light); +} diff --git a/util/wasm/batchedtestrunner/qtestoutputreporter.js b/util/wasm/batchedtestrunner/qtestoutputreporter.js new file mode 100644 index 0000000000..ad8a373540 --- /dev/null +++ b/util/wasm/batchedtestrunner/qtestoutputreporter.js @@ -0,0 +1,366 @@ +// Copyright (C) 2022 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +import { RunnerStatus, TestStatus } from './batchedtestrunner.js' + +class AttentionType +{ + static None = 1; + static Bad = 2; + static Good = 3; + static Warning = 4; + static Info = 5; + static Ignore = 6; +}; + +export class IncidentType +{ + // See QAbstractTestLogger::IncidentTypes (and keep in sync with it): + static Pass = 'pass'; + static Fail = 'fail'; + static Skip = 'skip'; + static XFail = 'xfail'; + static XPass = 'xpass'; + static BlacklistedPass = 'bpass'; + static BlacklistedFail = 'bfail'; + static BlacklistedXPass = 'bxpass'; + static BlacklistedXFail = 'bxfail'; + + // The following is not mapped from QAbstractTestLogger::IncidentTypes and is used internally: + static None = 'none'; + + static values() + { + return Object.getOwnPropertyNames(IncidentType) + .filter( + propertyName => + ['length', 'prototype', 'values', 'name'].indexOf(propertyName) === -1) + .map(propertyName => IncidentType[propertyName]); + } +} + +class OutputArea +{ + #outputDiv; + + constructor() + { + this.#outputDiv = document.createElement('div'); + this.#outputDiv.classList.add('output-area'); + this.#outputDiv.classList.add('light-background'); + document.querySelector('body').appendChild(this.#outputDiv); + } + + addOutput(text, attentionType) + { + const newContentWrapper = document.createElement('span'); + newContentWrapper.className = 'output-line'; + + newContentWrapper.innerText = text; + + switch (attentionType) { + case AttentionType.Bad: + newContentWrapper.classList.add('bad'); + break; + case AttentionType.Good: + newContentWrapper.classList.add('good'); + break; + case AttentionType.Warning: + newContentWrapper.classList.add('warning'); + break + case AttentionType.Info: + newContentWrapper.classList.add('info'); + break; + case AttentionType.Ignore: + newContentWrapper.classList.add('ignore'); + break; + default: + break; + } + this.#outputDiv.appendChild(newContentWrapper); + } +} + +class Counter +{ + #count = 0; + #decriptionElement; + #counterElement; + + constructor(parentElement, incidentType) + { + this.#decriptionElement = document.createElement('span'); + this.#decriptionElement.classList.add(incidentType); + this.#decriptionElement.classList.add('zero'); + this.#decriptionElement.innerText = Counter.#humanReadableIncidentName(incidentType); + parentElement.appendChild(this.#decriptionElement); + + this.#counterElement = document.createElement('span'); + this.#counterElement.classList.add(incidentType); + this.#counterElement.classList.add('zero'); + parentElement.appendChild(this.#counterElement); + } + + increment() + { + if (!this.#count++) { + this.#decriptionElement.classList.remove('zero'); + this.#counterElement.classList.remove('zero'); + } + this.#counterElement.innerText = this.#count; + } + + static #humanReadableIncidentName(incidentName) + { + switch (incidentName) { + case IncidentType.Pass: + return 'Passed'; + case IncidentType.Fail: + return 'Failed'; + case IncidentType.Skip: + return 'Skipped'; + case IncidentType.XFail: + return 'Known failure'; + case IncidentType.XPass: + return 'Unexpectedly passed'; + case IncidentType.BlacklistedPass: + return 'Blacklisted passed'; + case IncidentType.BlacklistedFail: + return 'Blacklisted failed'; + case IncidentType.BlacklistedXPass: + return 'Blacklisted unexpectedly passed'; + case IncidentType.BlacklistedXFail: + return 'Blacklisted unexpectedly failed'; + case IncidentType.None: + throw new Error('Incident of the None type cannot be displayed'); + } + } +} + +class Counters +{ + #contentsDiv; + #counters; + + constructor(parentElement) + { + this.#contentsDiv = document.createElement('div'); + this.#contentsDiv.className = 'counter-box'; + parentElement.appendChild(this.#contentsDiv); + + const centerDiv = document.createElement('div'); + this.#contentsDiv.appendChild(centerDiv); + + this.#counters = new Map(IncidentType.values() + .filter(incidentType => incidentType !== IncidentType.None) + .map(incidentType => [incidentType, new Counter(centerDiv, incidentType)])); + } + + incrementIncidentCounter(incidentType) + { + this.#counters.get(incidentType).increment(); + } +} + +export class UI +{ + #contentsDiv; + + #counters; + #outputArea; + + constructor(parentElement, hasCounters) + { + this.#contentsDiv = document.createElement('div'); + parentElement.appendChild(this.#contentsDiv); + + if (hasCounters) + this.#counters = new Counters(this.#contentsDiv); + this.#outputArea = new OutputArea(this.#contentsDiv); + } + + get counters() + { + return this.#counters; + } + + get outputArea() + { + return this.#outputArea; + } + + htmlElement() + { + return this.#contentsDiv; + } +} + +class OutputScanner +{ + static #supportedIncidentTypes = IncidentType.values().filter( + incidentType => incidentType !== IncidentType.None); + + static get supportedIncidentTypes() + { + return this.#supportedIncidentTypes; + } + + #regex; + + constructor(regex) + { + this.#regex = regex; + } + + classifyOutputLine(line) + { + const match = this.#regex.exec(line); + if (!match) + return IncidentType.None; + match.splice(0, 1); + // Find the index of the first non-empty matching group and recover an incident type for it. + return OutputScanner.supportedIncidentTypes[match.findIndex(element => !!element)]; + } +} + +class XmlOutputScanner extends OutputScanner +{ + constructor() + { + // Scan for any line with an incident of type from supportedIncidentTypes. The matching + // group at offset n will contain the type. The match type can be preceded by any number of + // whitespace characters to factor in the indentation. + super(new RegExp(`^\\s*<Incident type="${OutputScanner.supportedIncidentTypes + .map(incidentType => `(${incidentType})`).join('|')}"`)); + } +} + +class TextOutputScanner extends OutputScanner +{ + static #incidentNameMap = new Map([ + [IncidentType.Pass, 'PASS'], + [IncidentType.Fail, 'FAIL!'], + [IncidentType.Skip, 'SKIP'], + [IncidentType.XFail, 'XFAIL'], + [IncidentType.XPass, 'XPASS'], + [IncidentType.BlacklistedPass, 'BPASS'], + [IncidentType.BlacklistedFail, 'BFAIL'], + [IncidentType.BlacklistedXPass, 'BXPASS'], + [IncidentType.BlacklistedXFail, 'BXFAIL'] + ]); + + constructor() + { + // Scan for any line with an incident of type from incidentNameMap. The matching group + // at offset n will contain the type. The type can be preceded by any number of whitespace + // characters to factor in the indentation. + super(new RegExp(`^\\s*${OutputScanner.supportedIncidentTypes + .map(incidentType => + `(${TextOutputScanner.#incidentNameMap.get(incidentType)})`).join('|')}\\s`)); + } +} + +export class ScannerFactory +{ + static createScannerForFormat(format) + { + switch (format) { + case 'txt': + return new TextOutputScanner(); + case 'xml': + return new XmlOutputScanner(); + default: + return null; + } + } +} + +export class VisualOutputProducer +{ + #batchedTestRunner; + + #outputArea; + #counters; + #outputScanner; + #processedLines; + + constructor(outputArea, counters, outputScanner, batchedTestRunner) + { + this.#outputArea = outputArea; + this.#counters = counters; + this.#outputScanner = outputScanner; + this.#batchedTestRunner = batchedTestRunner; + this.#processedLines = 0; + } + + run() + { + this.#batchedTestRunner.onStatusChanged.addEventListener( + status => this.#onRunnerStatusChanged(status)); + this.#batchedTestRunner.onTestStatusChanged.addEventListener( + (test, status) => this.#onTestStatusChanged(test, status)); + this.#batchedTestRunner.onTestOutputChanged.addEventListener( + (test, output) => this.#onTestOutputChanged(test, output)); + + const currentTest = [...this.#batchedTestRunner.results.entries()].find( + entry => entry[1].status === TestStatus.Running)?.[0]; + + const output = this.#batchedTestRunner.results.get(currentTest)?.output; + if (output) + this.#onTestOutputChanged(testName, output); + this.#onRunnerStatusChanged(this.#batchedTestRunner.status); + } + + async #onRunnerStatusChanged(status) + { + if (RunnerStatus.Running === status) + return; + + this.#outputArea.addOutput( + `Runner exited with status: ${status}`, + status === RunnerStatus.Passed ? AttentionType.Good : AttentionType.Bad); + if (RunnerStatus.Error === status) + this.#outputArea.addOutput(`The error was: ${this.#batchedTestRunner.errorDetails}`); + } + + async #onTestOutputChanged(_, output) + { + const notSent = output.slice(this.#processedLines); + for (const out of notSent) { + const incidentType = this.#outputScanner?.classifyOutputLine(out); + if (incidentType !== IncidentType.None) + this.#counters.incrementIncidentCounter(incidentType); + this.#outputArea.addOutput( + out, + (() => + { + switch (incidentType) { + case IncidentType.Fail: + case IncidentType.XPass: + return AttentionType.Bad; + case IncidentType.Pass: + return AttentionType.Good; + case IncidentType.XFail: + return AttentionType.Warning; + case IncidentType.Skip: + return AttentionType.Info; + case IncidentType.BlacklistedFail: + case IncidentType.BlacklistedPass: + case IncidentType.BlacklistedXFail: + case IncidentType.BlacklistedXPass: + return AttentionType.Ignore; + case IncidentType.None: + return AttentionType.None; + } + })()); + } + this.#processedLines = output.length; + } + + async #onTestStatusChanged(_, status) + { + if (status === TestStatus.Running) + this.#processedLines = 0; + await new Promise(resolve => window.setTimeout(resolve, 500)); + } +} diff --git a/util/wasm/batchedtestrunner/qwasmtestmain.js b/util/wasm/batchedtestrunner/qwasmtestmain.js index e91ff6799d..644455907f 100644 --- a/util/wasm/batchedtestrunner/qwasmtestmain.js +++ b/util/wasm/batchedtestrunner/qwasmtestmain.js @@ -9,6 +9,7 @@ import { ResourceLocator, } from './qwasmjsruntime.js'; import { parseQuery } from './util.js'; +import { VisualOutputProducer, UI, ScannerFactory } from './qtestoutputreporter.js' (() => { const setPageTitle = (useEmrun, testName, isBatch) => { @@ -23,6 +24,7 @@ import { parseQuery } from './util.js'; } const parsed = parseQuery(location.search); + const outputInPage = parsed.get('qvisualoutput') !== undefined; const testName = parsed.get('qtestname'); const isBatch = parsed.get('qbatchedtest') !== undefined; const useEmrun = parsed.get('quseemrun') !== undefined; @@ -49,10 +51,18 @@ import { parseQuery } from './util.js'; if (useEmrun) { const adapter = new EmrunAdapter(new EmrunCommunication(), testRunner, () => { - window.close(); + if (!outputInPage) + window.close(); }); adapter.run(); } + if (outputInPage) { + const scanner = ScannerFactory.createScannerForFormat(testOutputFormat); + const ui = new UI(document.querySelector('body'), !!scanner); + const adapter = + new VisualOutputProducer(ui.outputArea, ui.counters, scanner, testRunner); + adapter.run(); + } setPageTitle(useEmrun, testName, isBatch); testRunner.run(isBatch, testName, testOutputFormat); |