diff options
Diffstat (limited to 'util/wasm/batchedtestrunner')
-rw-r--r-- | util/wasm/batchedtestrunner/README.md | 60 | ||||
-rw-r--r-- | util/wasm/batchedtestrunner/batchedtestrunner.html | 15 | ||||
-rw-r--r-- | util/wasm/batchedtestrunner/batchedtestrunner.js | 178 | ||||
-rw-r--r-- | util/wasm/batchedtestrunner/emrunadapter.js | 137 | ||||
-rw-r--r-- | util/wasm/batchedtestrunner/qtestoutputreporter.css | 89 | ||||
-rw-r--r-- | util/wasm/batchedtestrunner/qtestoutputreporter.js | 366 | ||||
-rw-r--r-- | util/wasm/batchedtestrunner/qwasmjsruntime.js | 231 | ||||
-rw-r--r-- | util/wasm/batchedtestrunner/qwasmtestmain.js | 86 | ||||
-rw-r--r-- | util/wasm/batchedtestrunner/util.js | 31 |
9 files changed, 1193 insertions, 0 deletions
diff --git a/util/wasm/batchedtestrunner/README.md b/util/wasm/batchedtestrunner/README.md new file mode 100644 index 0000000000..a5d165c630 --- /dev/null +++ b/util/wasm/batchedtestrunner/README.md @@ -0,0 +1,60 @@ +This package contains sources for a webpage whose scripts run batched WASM tests - a single +executable with a number of linked test classes. +The webpage operates on an assumption that the test program, when run without arguments, +prints out a list of test classes inside its module. Then, when run with the first argument +equal to the name of one of the test classes, the test program will execute all tests within +that single class. + +The following query parameters are recognized by the webpage: + +qtestname=testname - the test case to run. When batched test module is used, the test is assumed to + be a part of the batch. If a standalone test module is used, this is assumed to be the name of + the wasm module. + +quseemrun - if specified, the test communicates with the emrun instance via the protocol expected + by emrun. + +qtestoutputformat=txt|xml|lightxml|junitxml|tap - specifies the output format for the test case. + +qbatchedtest - if specified, the script will load the test_batch.wasm module and either run all + testcases in it or a specific test case, depending on the existence of the qtestname parameter. + Otherwise, the test is assumed to be a standalone binary whose name is determined by the + qtestname parameter. + +The scripts in the page will load the wasm file as specified by a combination of qbatchedtest and +qtestname. + +Public interface for querying the test execution status is accessible via the global object +'qtTestRunner': + +qtTestRunner.status - this contains the status of the test runner itself, of the enumeration type +RunnerStatus. + +qtTestRunner.results - a map of test class name to test result. The result contains a test status +(status, of the enumeration TestStatus), text output chunks (output), and in case of a terminal +status, also the test's exit code (exitCode) + +qtTestRunner.onStatusChanged - an event for changes in state of the runner itself. The possible +values are those of the enumeration RunnerStatus. + +qtTestRunner.onTestStatusChanged - an event for changes in state of a single tests class. The +possible values are those of the enumeration TestStatus. When a terminal state is reached +(Completed, Error, Crashed), the text results and exit code are filled in, if available, and +will not change. + +Typical usage: +Run all tests in a batch: + - load the webpage batchedtestrunner.html + +Run a single test in a batch: + - load the webpage batchedtestrunner.html?qtestname=tst_mytest + +Query for test execution state: + - qtTestRunner.onStatusChanged.addEventListener((runnerStatus) => (...))) + - qtTestRunner.onTestStatusChanged.addEventListener((testName, status) => (...)) + - qtTestRunner.status === (...) + - qtTestRunner.results['tst_mytest'].status === (...) + - qtTestRunner.results['tst_mytest'].textOutput + +When queseemrun is specified, the built-in emrun support module will POST the test output to the +emrun instance and will report ^exit^ with a suitable exit code to it when testing is finished. diff --git a/util/wasm/batchedtestrunner/batchedtestrunner.html b/util/wasm/batchedtestrunner/batchedtestrunner.html new file mode 100644 index 0000000000..147cf34376 --- /dev/null +++ b/util/wasm/batchedtestrunner/batchedtestrunner.html @@ -0,0 +1,15 @@ +<!-- +Copyright (C) 2022 The Qt Company Ltd. +SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 +--> + +<!doctype html> +<html> +<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> +</html> diff --git a/util/wasm/batchedtestrunner/batchedtestrunner.js b/util/wasm/batchedtestrunner/batchedtestrunner.js new file mode 100644 index 0000000000..453865a935 --- /dev/null +++ b/util/wasm/batchedtestrunner/batchedtestrunner.js @@ -0,0 +1,178 @@ +// Copyright (C) 2022 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 + +import { + AbortedError, +} from './qwasmjsruntime.js'; + +import { EventSource } from './util.js'; + +class ProgramError extends Error { + constructor(exitCode) { + super(`The program reported an exit code of ${exitCode}`) + } +} + +export class RunnerStatus { + static Running = 'Running'; + static Passed = 'Passed'; + static Error = 'Error'; + static TestCrashed = 'TestCrashed'; + static TestsFailed = 'TestsFailed'; +} + +export class TestStatus { + static Pending = 'Pending'; + static Running = 'Running'; + static Completed = 'Completed'; + static Error = 'Error'; + static Failed = 'Failed'; + static Crashed = 'Crashed'; +} + +export class BatchedTestRunner { + static #TestBatchModuleName = 'test_batch'; + + #loader; + + #results = new Map(); + #status = RunnerStatus.Running; + #numberOfFailed = 0; + #statusChangedEventPrivate; + #testStatusChangedEventPrivate; + #testOutputChangedEventPrivate; + #errorDetails; + + onStatusChanged = + new EventSource((privateInterface) => this.#statusChangedEventPrivate = privateInterface); + onTestStatusChanged = + new EventSource((privateInterface) => + this.#testStatusChangedEventPrivate = privateInterface); + onTestOutputChanged = + new EventSource( + (privateInterface) => this.#testOutputChangedEventPrivate = privateInterface); + + constructor(loader) { + this.#loader = loader; + } + + get results() { return this.#results; } + + get status() { return this.#status; } + + get numberOfFailed() { + if (this.#status !== RunnerStatus.TestsFailed) + throw new Error(`numberOfFailed called with status=${this.#status}`); + return this.#numberOfFailed; + } + + get errorDetails() { return this.#errorDetails; } + + async run(targetIsBatch, testName, functions, testOutputFormat) { + try { + await this.#doRun(targetIsBatch, testName, functions, testOutputFormat); + } catch (e) { + this.#setTestRunnerError(e.message); + return; + } + + const status = (() => { + const hasAnyCrashedTest = + !![...window.qtTestRunner.results.values()].find( + result => result.status === TestStatus.Crashed); + if (hasAnyCrashedTest) + return { code: RunnerStatus.TestCrashed }; + const numberOfFailed = [...window.qtTestRunner.results.values()].reduce( + (previous, current) => previous + current.exitCode, 0); + return { + code: (numberOfFailed ? RunnerStatus.TestsFailed : RunnerStatus.Passed), + numberOfFailed + }; + })(); + + this.#setTestRunnerStatus(status.code, status.numberOfFailed); + } + + async #doRun(targetIsBatch, testName, functions, testOutputFormat) { + const module = await this.#loader.loadEmscriptenModule( + targetIsBatch ? BatchedTestRunner.#TestBatchModuleName : testName, + () => { } + ); + + const testsToExecute = (testName || !targetIsBatch) + ? [testName] : await this.#getTestClassNames(module); + testsToExecute.forEach(testClassName => this.#registerTest(testClassName)); + + for (const testClassName of testsToExecute) { + let result = {}; + this.#setTestStatus(testClassName, TestStatus.Running); + + try { + const LogToStdoutSpecialFilename = '-'; + result = await module.exec({ + args: [...(targetIsBatch ? [testClassName] : []), + ...(functions ?? []), + '-o', `${LogToStdoutSpecialFilename},${testOutputFormat}`], + onStdout: (output) => { + this.#addTestOutput(testClassName, output); + } + }); + + if (result.exitCode < 0) + throw new ProgramError(result.exitCode); + result.status = result.exitCode > 0 ? TestStatus.Failed : TestStatus.Completed; + // Yield to other tasks on the main thread. + await new Promise(resolve => window.setTimeout(resolve, 0)); + } catch (e) { + result.status = e instanceof ProgramError ? TestStatus.Error : TestStatus.Crashed; + result.stdout = e instanceof AbortedError ? e.stdout : result.stdout; + } + this.#setTestResultData(testClassName, result.status, result.exitCode); + } + } + + async #getTestClassNames(module) { + return (await module.exec()).stdout.trim().split(' '); + } + + #registerTest(testName) { + this.#results.set(testName, { status: TestStatus.Pending, output: [] }); + } + + #setTestStatus(testName, status) { + const testData = this.#results.get(testName); + if (testData.status === status) + return; + this.#results.get(testName).status = status; + this.#testStatusChangedEventPrivate.fireEvent(testName, status); + } + + #setTestResultData(testName, testStatus, exitCode) { + const testData = this.#results.get(testName); + const statusChanged = testStatus !== testData.status; + testData.status = testStatus; + testData.exitCode = exitCode; + if (statusChanged) + this.#testStatusChangedEventPrivate.fireEvent(testName, testStatus); + } + + #setTestRunnerStatus(status, numberOfFailed) { + if (status === this.#status) + return; + this.#status = status; + this.#numberOfFailed = numberOfFailed; + this.#statusChangedEventPrivate.fireEvent(status); + } + + #setTestRunnerError(details) { + this.#status = RunnerStatus.Error; + this.#errorDetails = details; + this.#statusChangedEventPrivate.fireEvent(this.#status); + } + + #addTestOutput(testName, output) { + const testData = this.#results.get(testName); + testData.output.push(output); + this.#testOutputChangedEventPrivate.fireEvent(testName, testData.output); + } +} diff --git a/util/wasm/batchedtestrunner/emrunadapter.js b/util/wasm/batchedtestrunner/emrunadapter.js new file mode 100644 index 0000000000..5b4284e18f --- /dev/null +++ b/util/wasm/batchedtestrunner/emrunadapter.js @@ -0,0 +1,137 @@ +// Copyright (C) 2022 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 + +import { RunnerStatus, TestStatus } from './batchedtestrunner.js'; + +// Sends messages to the running emrun instance via POST requests. +export class EmrunCommunication { + static #BATCHING_DELAY = 300; + + #indexOfMessage = 0; + #postOutputPromise; + // Accumulate output in a batch that gets sent with a delay so that the emrun http server + // does not get pounded with requests. + #nextOutputBatch = null; + + #post(body) { + return fetch('stdio.html', { + method: 'POST', + body + }); + } + + // Waits for the output sending to finish, if any output transfer is still in progress. + async waitUntilAllSent() + { + if (this.#postOutputPromise) + await this.#postOutputPromise; + } + + // Posts the exit status to the running emrun instance. Emrun will drop connection unless it is + // run with --serve_after_exit, therefore this method will throw most of the times. + postExit(status) { + return this.#post(`^exit^${status}`); + } + + // Posts an indexed output chunk to the running emrun instance. Each consecutive call to this + // method increments the output index by 1. + postOutput(output) + { + if (this.#nextOutputBatch) { + this.#nextOutputBatch += output; + } else { + this.#nextOutputBatch = output; + this.#postOutputPromise = new Promise(resolve => + { + window.setTimeout(() => + { + const toSend = this.#nextOutputBatch; + this.#nextOutputBatch = null; + this.#post(`^out^${this.#indexOfMessage++}^${toSend}$`) + .finally(resolve); + }, EmrunCommunication.#BATCHING_DELAY); + }); + } + + return this.#postOutputPromise; + } +} + +// Wraps a test module runner; forwards its output and resolution state to the running emrun +// instance. +export class EmrunAdapter { + #communication; + #batchedTestRunner; + #sentLines = 0; + #onExitSent; + + constructor(communication, batchedTestRunner, onExitSent) { + this.#communication = communication; + this.#batchedTestRunner = batchedTestRunner; + this.#onExitSent = onExitSent; + } + + // Starts listening to test module runner's state changes. When the test module runner finishes + // or reports output, sends suitable messages to the emrun instance. + 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); + } + + #toExitCode(status) { + switch (status) { + case RunnerStatus.Error: + return -1; + case RunnerStatus.Passed: + return 0; + case RunnerStatus.Running: + throw new Error('No exit code when still running'); + case RunnerStatus.TestCrashed: + return -2; + case RunnerStatus.TestsFailed: + return this.#batchedTestRunner.numberOfFailed; + } + } + + async #onRunnerStatusChanged(status) { + if (RunnerStatus.Running === status) + return; + + const exit = this.#toExitCode(status); + if (RunnerStatus.Error === status) + this.#communication.postOutput(this.#batchedTestRunner.errorDetails); + + await this.#communication.waitUntilAllSent(); + try { + await this.#communication.postExit(exit); + } catch { + // no-op: The remote end will drop connection on exit. + } finally { + this.#onExitSent?.(); + } + } + + async #onTestOutputChanged(_, output) { + const notSent = output.slice(this.#sentLines); + for (const out of notSent) + this.#communication.postOutput(out); + this.#sentLines = output.length; + } + + async #onTestStatusChanged(_, status) { + if (status === TestStatus.Running) + this.#sentLines = 0; + } +} diff --git a/util/wasm/batchedtestrunner/qtestoutputreporter.css b/util/wasm/batchedtestrunner/qtestoutputreporter.css new file mode 100644 index 0000000000..aefb867b81 --- /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 WITH Qt-GPL-exception-1.0 +*/ + +: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..7af288b8f0 --- /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 WITH Qt-GPL-exception-1.0 + +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/qwasmjsruntime.js b/util/wasm/batchedtestrunner/qwasmjsruntime.js new file mode 100644 index 0000000000..3f2d421181 --- /dev/null +++ b/util/wasm/batchedtestrunner/qwasmjsruntime.js @@ -0,0 +1,231 @@ +// Copyright (C) 2022 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 + +// Exposes platform capabilities as static properties + +export class AbortedError extends Error { + constructor(stdout) { + super(`The program has been aborted`) + + this.stdout = stdout; + } +} +export class Platform { + static #webAssemblySupported = typeof WebAssembly !== 'undefined'; + + static #canCompileStreaming = WebAssembly.compileStreaming !== 'undefined'; + + static #webGLSupported = (() => { + // We expect that WebGL is supported if WebAssembly is; however + // the GPU may be blacklisted. + try { + const canvas = document.createElement('canvas'); + return !!( + window.WebGLRenderingContext && + (canvas.getContext('webgl') || canvas.getContext('experimental-webgl')) + ); + } catch (e) { + return false; + } + })(); + + static #canLoadQt = Platform.#webAssemblySupported && Platform.#webGLSupported; + + static get webAssemblySupported() { + return this.#webAssemblySupported; + } + static get canCompileStreaming() { + return this.#canCompileStreaming; + } + static get webGLSupported() { + return this.#webGLSupported; + } + static get canLoadQt() { + return this.#canLoadQt; + } +} + +// Locates a resource, based on its relative path +export class ResourceLocator { + #rootPath; + + constructor(rootPath) { + this.#rootPath = rootPath; + if (rootPath.length > 0 && !rootPath.endsWith('/')) rootPath += '/'; + } + + locate(relativePath) { + return this.#rootPath + relativePath; + } +} + +// Allows fetching of resources, such as text resources or wasm modules. +export class ResourceFetcher { + #locator; + + constructor(locator) { + this.#locator = locator; + } + + async fetchText(filePath) { + return (await this.#fetchRawResource(filePath)).text(); + } + + async fetchCompileWasm(filePath, onFetched) { + const fetchResponse = await this.#fetchRawResource(filePath); + onFetched?.(); + + if (Platform.canCompileStreaming) { + try { + return await WebAssembly.compileStreaming(fetchResponse); + } catch { + // NOOP - fallback to sequential fetching below + } + } + return WebAssembly.compile(await fetchResponse.arrayBuffer()); + } + + async #fetchRawResource(filePath) { + const response = await fetch(this.#locator.locate(filePath)); + if (!response.ok) + throw new Error( + `${response.status} ${response.statusText} ${response.url}` + ); + return response; + } +} + +// Represents a WASM module, wrapping the instantiation and execution thereof. +export class CompiledModule { + #createQtAppInstanceFn; + #js; + #wasm; + #resourceLocator; + + constructor(createQtAppInstanceFn, js, wasm, resourceLocator) { + this.#createQtAppInstanceFn = createQtAppInstanceFn; + this.#js = js; + this.#wasm = wasm; + this.#resourceLocator = resourceLocator; + } + + static make(js, wasm, entryFunctionName, resourceLocator) + { + const exports = {}; + const module = {}; + eval(js); + if (!module.exports) { + throw new Error( + '${entryFunctionName} has not been exported by the main script' + ); + } + + return new CompiledModule( + module.exports, js, wasm, resourceLocator + ); + } + + async exec(parameters) { + return await new Promise(async (resolve, reject) => { + let instance = undefined; + let result = undefined; + + let testFinished = false; + const testFinishedEvent = new CustomEvent('testFinished'); + instance = await this.#createQtAppInstanceFn((() => { + const params = this.#makeDefaultExecParams({ + onInstantiationError: (error) => { reject(error); }, + }); + params.arguments = parameters?.args; + let data = ''; + params.print = (out) => { + parameters?.onStdout?.(out); + data += `${out}\n`; + }; + params.printErr = () => { }; + params.onAbort = () => reject(new AbortedError(data)); + params.quit = (code, exception) => { + if (exception && exception.name !== 'ExitStatus') + reject(exception); + }; + params.notifyTestFinished = (code) => { + result = { stdout: data, exitCode: code }; + testFinished = true; + window.dispatchEvent(testFinishedEvent); + }; + return params; + })()); + if (!testFinished) { + await new Promise((resolve) => { + window.addEventListener('testFinished', () => { + resolve(); + }); + }); + } + resolve({ + stdout: result.stdout, + exitCode: result.exitCode, + instance, + }); + }); + } + + #makeDefaultExecParams(params) { + const instanceParams = {}; + instanceParams.instantiateWasm = async (imports, onDone) => { + try { + onDone(await WebAssembly.instantiate(this.#wasm, imports), this.#wasm); + } catch (e) { + params?.onInstantiationError?.(e); + } + }; + instanceParams.locateFile = (filename) => + this.#resourceLocator.locate(filename); + instanceParams.monitorRunDependencies = (name) => { }; + instanceParams.print = (text) => true && console.log(text); + instanceParams.printErr = (text) => true && console.warn(text); + + instanceParams.mainScriptUrlOrBlob = new Blob([this.#js], { + type: 'text/javascript', + }); + return instanceParams; + } +} + +// Streamlines loading of WASM modules. +export class ModuleLoader { + #fetcher; + #resourceLocator; + + constructor( + fetcher, + resourceLocator + ) { + this.#fetcher = fetcher; + this.#resourceLocator = resourceLocator; + } + + // Loads an emscripten module named |moduleName| from the main resource path. Provides + // progress of 'downloading' and 'compiling' to the caller using the |onProgress| callback. + async loadEmscriptenModule( + moduleName, onProgress + ) { + if (!Platform.webAssemblySupported) + throw new Error('Web assembly not supported'); + if (!Platform.webGLSupported) + throw new Error('WebGL is not supported'); + + onProgress('downloading'); + + const jsLoadPromise = this.#fetcher.fetchText(`${moduleName}.js`); + const wasmLoadPromise = this.#fetcher.fetchCompileWasm( + `${moduleName}.wasm`, + () => { + onProgress('compiling'); + } + ); + + const [js, wasm] = await Promise.all([jsLoadPromise, wasmLoadPromise]); + return CompiledModule.make(js, wasm, `${moduleName}_entry`, this.#resourceLocator); + } +} diff --git a/util/wasm/batchedtestrunner/qwasmtestmain.js b/util/wasm/batchedtestrunner/qwasmtestmain.js new file mode 100644 index 0000000000..a92a3a4b30 --- /dev/null +++ b/util/wasm/batchedtestrunner/qwasmtestmain.js @@ -0,0 +1,86 @@ +// Copyright (C) 2022 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 + +import { BatchedTestRunner } from './batchedtestrunner.js' +import { EmrunAdapter, EmrunCommunication } from './emrunadapter.js' +import { + ModuleLoader, + ResourceFetcher, + ResourceLocator, +} from './qwasmjsruntime.js'; +import { parseQuery } from './util.js'; +import { VisualOutputProducer, UI, ScannerFactory } from './qtestoutputreporter.js' + +const StandardArg = { + qVisualOutput: 'qvisualoutput', + qTestName: 'qtestname', + qBatchedTest: 'qbatchedtest', + qUseEmrun: 'quseemrun', + qTestOutputFormat: 'qtestoutputformat', +} + +const allArgs = new Set(Object.getOwnPropertyNames(StandardArg).map(arg => StandardArg[arg])); +Object.defineProperty(StandardArg, 'isKnown', { + get() + { + return name => allArgs.has(name); + }, +}); + +(() => { + const setPageTitle = (useEmrun, testName, isBatch) => { + document.title = 'Qt WASM test runner'; + if (useEmrun || testName || isBatch) { + document.title += `(${[ + ...[useEmrun ? ['emrun'] : []], + ...[testName ? ['test=' + testName] : []], + ...[isBatch ? ['batch'] : []] + ].flat().join(", ")})`; + } + } + + const parsed = parseQuery(location.search); + const outputInPage = parsed.has(StandardArg.qVisualOutput); + const testName = parsed.get(StandardArg.qTestName); + const isBatch = parsed.has(StandardArg.qBatchedTest); + const useEmrun = parsed.has(StandardArg.qUseEmrun); + const functions = [...parsed.keys()].filter(arg => !StandardArg.isKnown(arg)); + + if (testName === undefined) { + if (!isBatch) + throw new Error('The qtestname parameter is required if not running a batch'); + } else if (testName === '') { + throw new Error(`The qtestname=${testName} parameter is incorrect`); + } + + const testOutputFormat = (() => { + const format = parsed.get(StandardArg.qTestOutputFormat) ?? 'txt'; + if (-1 === ['txt', 'xml', 'lightxml', 'junitxml', 'tap'].indexOf(format)) + throw new Error(`Bad file format: ${format}`); + return format; + })(); + + const resourceLocator = new ResourceLocator(''); + const testRunner = new BatchedTestRunner( + new ModuleLoader(new ResourceFetcher(resourceLocator), resourceLocator), + ); + window.qtTestRunner = testRunner; + + if (useEmrun) { + const adapter = new EmrunAdapter(new EmrunCommunication(), testRunner, () => { + 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, functions, testOutputFormat); +})(); diff --git a/util/wasm/batchedtestrunner/util.js b/util/wasm/batchedtestrunner/util.js new file mode 100644 index 0000000000..a297baf6b2 --- /dev/null +++ b/util/wasm/batchedtestrunner/util.js @@ -0,0 +1,31 @@ +// Copyright (C) 2022 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 + +export function parseQuery() { + const trimmed = window.location.search.substring(1); + return new Map( + trimmed.length === 0 ? + [] : + trimmed.split('&').map(paramNameAndValue => { + const [name, value] = paramNameAndValue.split('='); + return [decodeURIComponent(name), value ? decodeURIComponent(value) : '']; + })); +} + +export class EventSource { + #listeners = []; + + constructor(receivePrivateInterface) { + receivePrivateInterface({ + fireEvent: (arg0, arg1) => this.#fireEvent(arg0, arg1) + }); + } + + addEventListener(listener) { + this.#listeners.push(listener); + } + + #fireEvent(arg0, arg1) { + this.#listeners.forEach(listener => listener(arg0, arg1)); + } +} |