summaryrefslogtreecommitdiffstats
path: root/util/wasm/batchedtestrunner
diff options
context:
space:
mode:
Diffstat (limited to 'util/wasm/batchedtestrunner')
-rw-r--r--util/wasm/batchedtestrunner/README.md60
-rw-r--r--util/wasm/batchedtestrunner/batchedtestrunner.html15
-rw-r--r--util/wasm/batchedtestrunner/batchedtestrunner.js178
-rw-r--r--util/wasm/batchedtestrunner/emrunadapter.js137
-rw-r--r--util/wasm/batchedtestrunner/qtestoutputreporter.css89
-rw-r--r--util/wasm/batchedtestrunner/qtestoutputreporter.js366
-rw-r--r--util/wasm/batchedtestrunner/qwasmjsruntime.js231
-rw-r--r--util/wasm/batchedtestrunner/qwasmtestmain.js86
-rw-r--r--util/wasm/batchedtestrunner/util.js31
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));
+ }
+}