diff options
Diffstat (limited to 'util/wasm')
-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 | ||||
-rwxr-xr-x | util/wasm/preload/preload_qml_imports.py | 101 | ||||
-rwxr-xr-x | util/wasm/preload/preload_qt_plugins.py | 54 | ||||
-rw-r--r-- | util/wasm/preload/wasm_binary_tools.py | 78 | ||||
-rw-r--r-- | util/wasm/qtwasmserver/Pipfile | 12 | ||||
-rwxr-xr-x | util/wasm/qtwasmserver/qtwasmserver.py | 124 | ||||
-rw-r--r-- | util/wasm/wasmtestrunner/Pipfile | 13 | ||||
-rw-r--r-- | util/wasm/wasmtestrunner/README.md | 10 | ||||
-rwxr-xr-x | util/wasm/wasmtestrunner/qt-wasmtestrunner.py | 331 |
17 files changed, 1916 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)); + } +} diff --git a/util/wasm/preload/preload_qml_imports.py b/util/wasm/preload/preload_qml_imports.py new file mode 100755 index 0000000000..9af4fa2a28 --- /dev/null +++ b/util/wasm/preload/preload_qml_imports.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +# Copyright (C) 2023 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 + +import os +import sys +import subprocess +import json + +# Paths to shared libraries and qml imports on the Qt installation on the web server. +# "$QTDIR" is replaced by qtloader.js at load time (defaults to "qt"), and makes +# possible to relocate the application build relative to the Qt build on the web server. +qt_lib_path = "$QTDIR/lib" +qt_qml_path = "$QTDIR/qml" + +# Path to QML imports on the in-memory file system provided by Emscripten. This script emits +# preload commands which copies QML imports to this directory. In addition, preload_qt_plugins.py +# creates (and preloads) a qt.conf file which makes Qt load QML plugins from this location. +qt_deploy_qml_path = "/qt/qml" + + +def eprint(*args, **kwargs): + print(*args, file=sys.stderr, **kwargs) + + +def preload_file(source, destination): + preload_files.append({"source": source, "destination": destination}) + + +def extract_preload_files_from_imports(imports): + libraries = [] + for qml_import in imports: + try: + relative_path = qml_import["relativePath"] + plugin = qml_import["plugin"] + + # plugin .so + plugin_filename = "lib" + plugin + ".so" + so_plugin_source_path = os.path.join( + qt_qml_path, relative_path, plugin_filename + ) + so_plugin_destination_path = os.path.join( + qt_deploy_qml_path, relative_path, plugin_filename + ) + + preload_file(so_plugin_source_path, so_plugin_destination_path) + so_plugin_qt_install_path = os.path.join( + qt_wasm_path, "qml", relative_path, plugin_filename + ) + + # qmldir file + qmldir_source_path = os.path.join(qt_qml_path, relative_path, "qmldir") + qmldir_destination_path = os.path.join( + qt_deploy_qml_path, relative_path, "qmldir" + ) + preload_file(qmldir_source_path, qmldir_destination_path) + except Exception as e: + eprint(e) + continue + return libraries + + +if __name__ == "__main__": + if len(sys.argv) != 4: + print("Usage: python preload_qml_imports.py <qml-source-path> <qt-host-path> <qt-wasm-path>") + sys.exit(1) + + qml_source_path = sys.argv[1] + qt_host_path = sys.argv[2] + qt_wasm_path = sys.argv[3] + + qml_import_path = os.path.join(qt_wasm_path, "qml") + qmlimportsscanner_path = os.path.join(qt_host_path, "libexec/qmlimportscanner") + + eprint("runing qmlimportsscanner") + command = [qmlimportsscanner_path, "-rootPath", qml_source_path, "-importPath", qml_import_path] + result = subprocess.run(command, stdout=subprocess.PIPE) + imports = json.loads(result.stdout) + + preload_files = [] + libraries = extract_preload_files_from_imports(imports) + + # Deploy plugin dependencies, that is, shared libraries used by the plugins. + # Skip some of the obvious libraries which will be + skip_libraries = [ + "libQt6Core.so", + "libQt6Gui.so", + "libQt6Quick.so", + "libQt6Qml.so" "libQt6Network.so", + "libQt6OpenGL.so", + ] + + libraries = set(libraries) - set(skip_libraries) + for library in libraries: + source = os.path.join(qt_lib_path, library) + # Emscripten looks for shared libraries on "/", shared libraries + # most be deployed there instead of at /qt/lib + destination = os.path.join("/", library) + preload_file(source, destination) + + print(json.dumps(preload_files, indent=2)) diff --git a/util/wasm/preload/preload_qt_plugins.py b/util/wasm/preload/preload_qt_plugins.py new file mode 100755 index 0000000000..362d129732 --- /dev/null +++ b/util/wasm/preload/preload_qt_plugins.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +# Copyright (C) 2023 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 + +import os +import sys +import json + +# Path to plugins on the Qt installation on the web server. "$QTPATH" is replaced by qtloader.js +# at load time (defaults to "qt"), which makes it possible to relocate the application build relative +# to the Qt build on the web server. +qt_plugins_path = "$QTDIR/plugins" + +# Path to plugins on the in-memory file system provided by Emscripten. This script emits +# preload commands which copies plugins to this directory. +qt_deploy_plugins_path = "/qt/plugins" + + +def find_so_files(directory): + so_files = [] + for root, dirs, files in os.walk(directory): + for file in files: + if file.endswith(".so"): + relative_path = os.path.relpath(os.path.join(root, file), directory) + so_files.append(relative_path) + return so_files + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python make_qt_symlinks.py <qt-wasm-path>") + sys.exit(1) + + qt_wasm_path = sys.argv[1] + + # preload all plugins + plugins = find_so_files(os.path.join(qt_wasm_path, "plugins")) + preload = [ + { + "source": os.path.join(qt_plugins_path, plugin), + "destination": os.path.join(qt_deploy_plugins_path, plugin), + } + for plugin in plugins + ] + + # Create and preload qt.conf which will tell Qt to look for plugins + # and QML imports in /qt/plugins and /qt/qml. The qt.conf file is + # written to the current directory. + qtconf = "[Paths]\nPrefix = /qt\n" + with open("qt.conf", "w") as f: + f.write(qtconf) + preload.append({"source": "qt.conf", "destination": "/qt.conf"}) + + print(json.dumps(preload, indent=2)) diff --git a/util/wasm/preload/wasm_binary_tools.py b/util/wasm/preload/wasm_binary_tools.py new file mode 100644 index 0000000000..9a2150b964 --- /dev/null +++ b/util/wasm/preload/wasm_binary_tools.py @@ -0,0 +1,78 @@ + +#!/usr/bin/env python3 +# Copyright (C) 2024 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 + +import sys +import struct + + +class WasmBinary: + """For reference of binary format see Emscripten source code, especially library_dylink.js.""" + + def __init__(self, filepath): + self._offset = 0 + self._end = 0 + self._dependencies = [] + with open(filepath, 'rb') as file: + self._binary = file.read() + self._check_preamble() + self._parse_subsections() + + def get_dependencies(self): + return self._dependencies + + def _get_leb(self): + ret = 0 + mul = 1 + while True: + byte = self._binary[self._offset] + self._offset += 1 + ret += (byte & 0x7f) * mul + mul *= 0x80 + if not (byte & 0x80): + break + return ret + + def _get_string(self): + length = self._get_leb() + self._offset += length + return self._binary[self._offset - length:self._offset].decode('utf-8') + + def _check_preamble(self): + preamble = memoryview(self._binary)[:24] + int32View = struct.unpack('<6I', preamble) + assert int32View[0] == 0x6d736100, "magic number not found" + assert self._binary[8] == 0, "dynlink section needs to be first" + self._offset = 9 + section_size = self._get_leb() + self._end = self._offset + section_size + name = self._get_string() + assert name == "dylink.0", "section dylink.0 not found" + + def _parse_subsections(self): + WASM_DYLINK_NEEDED = 0x2 + + while self._offset < self._end: + subsection_type = self._binary[self._offset] + self._offset += 1 + subsection_size = self._get_leb() + + if subsection_type == WASM_DYLINK_NEEDED: + needed_dynlibs_count = self._get_leb() + for _ in range(needed_dynlibs_count): + self._dependencies.append(self._get_string()) + else: + self._offset += subsection_size # we don't care about other sections for now + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python wasm_binary_tools.py <shared_object>") + sys.exit(1) + + file_path = sys.argv[1] + binary = WasmBinary(file_path) + dependencies = binary.get_dependencies() + for d in dependencies: + print(d) diff --git a/util/wasm/qtwasmserver/Pipfile b/util/wasm/qtwasmserver/Pipfile new file mode 100644 index 0000000000..ef542c1048 --- /dev/null +++ b/util/wasm/qtwasmserver/Pipfile @@ -0,0 +1,12 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +netifaces = "*" + +[dev-packages] + +[requires] +python_version = "3.9" diff --git a/util/wasm/qtwasmserver/qtwasmserver.py b/util/wasm/qtwasmserver/qtwasmserver.py new file mode 100755 index 0000000000..31dc74539d --- /dev/null +++ b/util/wasm/qtwasmserver/qtwasmserver.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +# Copyright (C) 2021 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 + +import os +import socket +import ssl +import sys +import threading +from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler +from subprocess import run +import netifaces as ni +import argparse + +# This script implements a web server which serves the content of the current +# working directory using the http and secure https protocols. The server is +# intented to be used as a development server. +# +# Https certificates are generated using the 'mkcert' utility. You should generate +# a certificate authority first, see the mkcert documentation at +# https://github.com/FiloSottile/mkcert +# +# The server sets the COOP and COEP headers, which are required to enable multithreading. + +def main(): + parser = argparse.ArgumentParser( + description="Run a minimal HTTP(S) server to test Qt for WebAssembly applications.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "--port", + "-p", + help="Port on which to listen for HTTP and HTTPS (PORT + 1)", + type=int, + default=8000, + ) + parser.add_argument( + "--address", + "-a", + help="Address on which to listen for HTTP and HTTPS, in addition to localhost", + action="append", + ) + parser.add_argument( + "--all", + help="Start web server which binds to all local interfaces, instead of locahost only", + action="store_true", + ) + parser.add_argument( + "path", help="The directory to serve", nargs="?", default=os.getcwd() + ) + + args = parser.parse_args() + http_port = args.port + https_port = http_port + 1 + all_addresses = args.all + cmd_addresses = args.address or [] + serve_path = args.path + + addresses = ["127.0.0.1"] + cmd_addresses + if all_addresses: + addresses += [ + addr[ni.AF_INET][0]["addr"] + for addr in map(ni.ifaddresses, ni.interfaces()) + if ni.AF_INET in addr + ] + addresses = sorted(set(addresses)) # deduplicate + + # Generate a https certificate for "localhost" and selected addresses. This + # requires that the mkcert utility is installed, and that a certificate + # authority key pair (rootCA-key.pem and rootCA.pem) has been generated. The + # certificates are written to /tmp, where the https server can find them + # later on. + cert_base_path = "/tmp/qtwasmserver-certificate" + cert_file = f"{cert_base_path}.pem" + cert_key_file = f"{cert_base_path}-key.pem" + addresses_string = f"localhost {' '.join(addresses)}" + ret = run( + f"mkcert -cert-file {cert_file} -key-file {cert_key_file} {addresses_string}", + shell=True, + ) + has_certificate = ret.returncode == 0 + if not has_certificate: + print( + "Warning: mkcert is not installed or was unable to create a certificate. Will not start HTTPS server." + ) + + # Http request handler which sends headers required to enable multithreading using SharedArrayBuffer. + class MyHTTPRequestHandler(SimpleHTTPRequestHandler): + def __init__(self, request, client_address, server): + super().__init__(request, client_address, server, directory=serve_path) + + def end_headers(self): + self.send_header("Cross-Origin-Opener-Policy", "same-origin") + self.send_header("Cross-Origin-Embedder-Policy", "require-corp") + self.send_header("Cross-Origin-Resource-Policy", "cross-origin") + SimpleHTTPRequestHandler.end_headers(self) + + # Serve cwd from http(s)://address:port, with certificates from certdir if set + def serve_on_thread(address, port, secure): + httpd = ThreadingHTTPServer((address, port), MyHTTPRequestHandler) + if secure: + httpd.socket = ssl.wrap_socket( + httpd.socket, + certfile=cert_file, + keyfile=cert_key_file, + server_side=True, + ) + thread = threading.Thread(target=httpd.serve_forever) + thread.start() + + # Start servers + print(f"Serving at:") + for address in addresses: + print(f" http://{address}:{http_port}") + serve_on_thread(address, http_port, False) + + if has_certificate: + for address in addresses: + print(f" https://{address}:{https_port}") + serve_on_thread(address, https_port, True) + + +if __name__ == "__main__": + main() diff --git a/util/wasm/wasmtestrunner/Pipfile b/util/wasm/wasmtestrunner/Pipfile new file mode 100644 index 0000000000..851778a313 --- /dev/null +++ b/util/wasm/wasmtestrunner/Pipfile @@ -0,0 +1,13 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +selenium = "*" +argparse = "*" + +[dev-packages] + +[requires] +python_version = "3.9" diff --git a/util/wasm/wasmtestrunner/README.md b/util/wasm/wasmtestrunner/README.md new file mode 100644 index 0000000000..0beb3f0c12 --- /dev/null +++ b/util/wasm/wasmtestrunner/README.md @@ -0,0 +1,10 @@ +# qtwasmtestrunner +This is a utility that launches a small webserver and\ +either a browser or a webdriver (only chrome/chromedriver at the time of writing)\ +This allows running wasm tests and printing the output to stdout like a normal test. + +chromedriver must be installed: https://chromedriver.chromium.org/ \ +to use it with chromedriver (default operation), and it must be in PATH\ +unless --chromedriver_path is passed with full path to chromedriver + +Run the script with --help for more info. diff --git a/util/wasm/wasmtestrunner/qt-wasmtestrunner.py b/util/wasm/wasmtestrunner/qt-wasmtestrunner.py new file mode 100755 index 0000000000..7eb840f1cb --- /dev/null +++ b/util/wasm/wasmtestrunner/qt-wasmtestrunner.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +# Copyright (C) 2021 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 + +import argparse +from selenium.webdriver.chrome.service import Service +from selenium.webdriver.common.desired_capabilities import DesiredCapabilities +from selenium.webdriver.support import expected_conditions +from selenium.webdriver.support.ui import WebDriverWait +from selenium import webdriver +from pathlib import Path +import typing +import http.server +import subprocess +import threading +import psutil +import re +import os +from signal import SIGINT + +import sys + + +class StdoutOutputSink(object): + def __init__(self): + pass + + def write(self, data: str): + print(data) + + def __enter__(self): + pass + + def __exit__(self, _, __, ___): + pass + + +class FileOutputSink(object): + def __init__(self, filename: str): + self.__filename = filename + self.__file = None + + def write(self, data: str): + self.__file.write(data) + + def __enter__(self): + self.__file = open(self.__filename, 'w') + + def __exit__(self, _, __, ___): + self.__file.close() + + +class OutputMulticast(object): + def __init__(self, destinations: typing.List[str]): + self.__sinks: typing.List[typing.Union[StdoutOutputSink, FileOutputSink]] = [ + ] + self.__destinations = [ + 'stdout'] if destinations is None else destinations + number_of_stdout_sinks = sum( + [1 if destination == 'stdout' else 0 for destination in self.__destinations]) + if number_of_stdout_sinks > 1: + raise Exception('Maximum allowed number of stdout sinks is 1') + + def write(self, data: str): + for sink in self.__sinks: + sink.write(data) + + def _makeSink(self, destination: str): + return StdoutOutputSink() if 'stdout' == destination else FileOutputSink(destination) + + def __enter__(self): + for destination in self.__destinations: + sink = self._makeSink(destination) + sink.__enter__() + self.__sinks.append(sink) + return self + + def __exit__(self, _, __, ___): + for sink in reversed(self.__sinks): + sink.__exit__(_, __, ___) + + +class WasmTestRunner: + def __init__(self, args: dict): + self.server_process = None + self.browser_process = None + self.python_path = Path(sys.executable) + self.script_dir = Path(os.path.dirname(os.path.realpath(__file__))) + self.host = 'localhost' + self.webserver = None + self.webthread = None + + paths = ['html_path', 'browser_path', 'chromedriver_path', 'tmp_dir'] + + for key, value in args.items(): + if value is None: + continue + if key in paths: + value = Path(value) + value.resolve() + setattr(self, key, value) + + if not self.html_path.exists(): + raise FileNotFoundError(self.html_path) + + self.webroot = self.html_path.parent + + if hasattr(self, 'browser_path') and not self.browser_path.exists(): + raise FileNotFoundError(self.browser_path) + + def run(self): + self.run_threaded_webserver() + + with OutputMulticast( + self.output if hasattr(self, 'output') else ['stdout']) as output_multicast: + try: + if self.use_browser: + return self.run_wasm_browser() + else: + return self.run_wasm_webdriver(output_multicast) + finally: + self.cleanup() + + def run_webserver(self): + webroot = self.html_path.parent.resolve() + self.server_process =\ + subprocess.Popen([ + str(self.python_path), + '-m', 'http.server', + '--directory', str(webroot), + self.port + ]) + + def run_threaded_webserver(self): + self.webserver = http.server.ThreadingHTTPServer( + (self.host, int(self.port)), self.get_http_handler_class()) + + self.webthread = threading.Thread(target=self.webserver.serve_forever) + self.webthread.start() + + def shutdown_threaded_webserver(self): + if self.webserver is not None: + self.webserver.shutdown() + if self.webthread is not None: + self.webthread.join() + + def run_wasm_webdriver(self, output_multicast: OutputMulticast): + url = f'http://localhost:{self.port}/{self.html_path.name}' + if (self.batched_test is not None): + url = f'{url}?qtestname={self.batched_test}&qtestoutputformat={self.format}' + + d = DesiredCapabilities.CHROME + d['goog:loggingPrefs'] = {'browser': 'ALL'} + ser = Service(executable_path=self.chromedriver_path) + driver = webdriver.Chrome(desired_capabilities=d, service=ser) + driver.get(url) + driver.execute_script( + """ const status = qtTestRunner.status; + const onFinished = status => { + if (status === 'Completed' || status === 'Error') + document.title = 'qtFinished'; + }; + onFinished(status); + qtTestRunner.onStatusChanged.addEventListener(onFinished); + """) + + WebDriverWait(driver, self.timeout).until( + expected_conditions.title_is('qtFinished')) + + runner_status = driver.execute_script(f"return qtTestRunner.status") + if runner_status == 'Error': + output_multicast.write(driver.execute_script( + "return qtTestRunner.errorDetails")) + return -1 + else: + assert runner_status == 'Completed' + output_multicast.write(driver.execute_script( + f"return qtTestRunner.results.get('{self.batched_test}').textOutput")) + return driver.execute_script( + f"return qtTestRunner.results.get('{self.batched_test}').exitCode") + + def run_wasm_browser(self): + if not hasattr(self, 'browser_path'): + print('Error: browser path must be set to run with browser') + return + + if not hasattr(self, 'tmp_dir'): + print('Error: tmp_dir must be set to run with browser') + return + + self.create_tmp_dir() + self.browser_process =\ + subprocess.Popen([ + str(self.browser_path), + '--user-data-dir=' + str(self.tmp_dir), + '--enable-logging=stderr', + f'http://localhost:{self.port}/{self.html_path.name}' + ], + stderr=subprocess.PIPE + ) + + # Only capture the console content + regex = re.compile(r'[^"]*CONSOLE[^"]*"(.*)"[.\w]*') + + for line in self.browser_process.stderr: + str_line = line.decode('utf-8') + + match = regex.match(str_line) + + # Error condition, this should have matched + if 'CONSOLE' in str_line and match is None: + print('Error: did not match console line:') + print(str_line) + + if match is not None: + console_line = match.group(1) + print(console_line) + + if 'Finished testing' in str_line: + self.browser_process.kill() + break + + @staticmethod + def get_loader_variable(driver, varname: str): + return driver.execute_script('return qtLoader.' + varname) + + def create_tmp_dir(self): + if not self.tmp_dir.exists(): + self.tmp_dir.mkdir() + + if not self.tmp_dir.is_dir(): + raise NotADirectoryError(self.tmp_dir) + + # Needed to bypass the "Welcome to Chrome" prompt + first_run = Path(self.tmp_dir, 'First Run') + first_run.touch() + + def get_http_handler_class(self): + wtr = self + + class OriginIsolationHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, request, client_address, server): + super().__init__(request, client_address, server, directory=wtr.webroot) + + # Headers required to enable SharedArrayBuffer + # See https://web.dev/cross-origin-isolation-guide/ + def end_headers(self): + self.send_header("Cross-Origin-Opener-Policy", "same-origin") + self.send_header( + "Cross-Origin-Embedder-Policy", "require-corp") + self.send_header( + "Cross-Origin-Resource-Policy", "cross-origin") + http.server.SimpleHTTPRequestHandler.end_headers(self) + + # We usually don't care that much about what the webserver is logging + def log_message(self, format_, *args): + return + + return OriginIsolationHTTPRequestHandler + + def cleanup(self): + if self.browser_process is not None: + self.browser_process.kill() + if self.server_process is not None: + self.server_process.kill() + self.shutdown_threaded_webserver() + + +class BackendProcess: + def __init__(self) -> None: + self.__process = subprocess.Popen( + [sys.executable, *sys.argv, '--backend'], shell=False, stdout=subprocess.PIPE) + + def abort(self): + current_process = psutil.Process(self.__process.pid) + children = current_process.children(recursive=True) + for child in [*children, current_process]: + os.kill(child.pid, SIGINT) + + def communicate(self, timeout): + return self.__process.communicate(timeout)[0].decode('utf-8') + + def returncode(self): + return self.__process.returncode + + +def main(): + parser = argparse.ArgumentParser(description='WASM testrunner') + parser.add_argument('html_path', help='Path to the HTML file to request') + parser.add_argument( + '--batched_test', help='Specifies a batched test to run') + parser.add_argument('--timeout', help='Test timeout', + type=int, default=120) + parser.add_argument( + '--port', help='Port to run the webserver on', default='8000') + parser.add_argument('--use_browser', action='store_true') + parser.add_argument('--browser_path', help='Path to the browser to use') + parser.add_argument('--chromedriver_path', help='Absolute path to chromedriver', + default='chromedriver') + parser.add_argument('--tmp_dir', help='Path to the tmpdir to use when using a browser', + default='/tmp/wasm-testrunner') + parser.add_argument( + '-o', help='filename. Filename may be "stdout" to write to stdout.', + action='append', dest='output') + parser.add_argument( + '--format', help='Output format', choices=['txt', 'xml', 'lightxml', 'junitxml', 'tap'], + default='txt') + parser.add_argument( + '--backend', help='Run as a backend process. There are two types of test runner processes - ' + 'the main monitoring process and the backend processes launched by it. The tests are ' + 'run on the backend to avoid any undesired behavior, like deadlocks in browser main process, ' + 'spilling over across test cases.', + action='store_true') + + args = parser.parse_args() + if not args.backend: + backend_process = BackendProcess() + try: + stdout = backend_process.communicate(args.timeout) + print(stdout) + return backend_process.returncode() + except Exception as e: + print(f"Exception while executing test {e}") + backend_process.abort() + return -1 + + return WasmTestRunner(vars(args)).run() + + +if __name__ == '__main__': + sys.exit(main()) |