diff options
author | Mikolaj Boc <mikolaj.boc@qt.io> | 2022-07-08 12:40:49 +0200 |
---|---|---|
committer | Qt Cherry-pick Bot <cherrypick_bot@qt-project.org> | 2022-08-24 17:11:24 +0000 |
commit | 80201e21c93089086d5388c56701ceec6d301051 (patch) | |
tree | c2f9763b162cf41027f06fe144c14bb39daaa50c /util | |
parent | b92f6dac20a9ea595ba41e00ab20215f33f99dc2 (diff) | |
download | qtbase-80201e21c93089086d5388c56701ceec6d301051.tar.gz |
Create a driver for running batched tests on WASM
A driver application has been prepared in js for running batched tests.
There is a convenient public API defined for reading the current test
status & subscribing to changes thereof.
The solution is modular - the module qwasmjsruntime can be used for any
wasm instantiation, e.g. in the next iteration of qtloader.
Change-Id: I00df88188c46a42f86d431285ca96d60d89b3f05
Reviewed-by: David Skoland <david.skoland@qt.io>
(cherry picked from commit ad1980cd4326acca891ed0fa4326ed1b22828324)
Reviewed-by: Qt Cherry-pick Bot <cherrypick_bot@qt-project.org>
Diffstat (limited to 'util')
-rw-r--r-- | util/wasm/batchedtestrunner/README.md | 41 | ||||
-rw-r--r-- | util/wasm/batchedtestrunner/batchedtestrunner.html | 14 | ||||
-rw-r--r-- | util/wasm/batchedtestrunner/batchedtestrunner.js | 162 | ||||
-rw-r--r-- | util/wasm/batchedtestrunner/qwasmjsruntime.js | 230 | ||||
-rw-r--r-- | util/wasm/batchedtestrunner/util.js | 31 |
5 files changed, 478 insertions, 0 deletions
diff --git a/util/wasm/batchedtestrunner/README.md b/util/wasm/batchedtestrunner/README.md new file mode 100644 index 0000000000..5098cd405d --- /dev/null +++ b/util/wasm/batchedtestrunner/README.md @@ -0,0 +1,41 @@ +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 scripts in the page will load the wasm file called 'test_batch.wasm' with its corresponding +js script 'test_batch.js'. + +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), and in case of a terminal status, also the test's exit code +(exitCode) and xml text output (textOutput), if available. + +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 diff --git a/util/wasm/batchedtestrunner/batchedtestrunner.html b/util/wasm/batchedtestrunner/batchedtestrunner.html new file mode 100644 index 0000000000..123c24890b --- /dev/null +++ b/util/wasm/batchedtestrunner/batchedtestrunner.html @@ -0,0 +1,14 @@ +<!-- +Copyright (C) 2022 The Qt Company Ltd. +SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only +--> + +<!doctype html> +<html> +<head> + <meta charset="utf-8"> + <title>WASM batched test runner</title> + <script type="module" defer="defer" src="batchedtestrunner.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..9a7597b7b8 --- /dev/null +++ b/util/wasm/batchedtestrunner/batchedtestrunner.js @@ -0,0 +1,162 @@ +// Copyright (C) 2022 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +import { + AbortedError, + ModuleLoader, + ResourceFetcher, + ResourceLocator, +} from './qwasmjsruntime.js'; + +import { parseQuery, EventSource } from './util.js'; + +class ProgramError extends Error { + constructor(exitCode) { + super(`The program reported an exit code of ${exitCode}`) + } +} + +class RunnerStatus { + static Running = 'Running'; + static Completed = 'Completed'; + static Error = 'Error'; +} + +class TestStatus { + static Pending = 'Pending'; + static Running = 'Running'; + static Completed = 'Completed'; + static Error = 'Error'; + static Crashed = 'Crashed'; +} + +// Represents the public API of the runner. +class WebApi { + #results = new Map(); + #status = RunnerStatus.Running; + #statusChangedEventPrivate; + #testStatusChangedEventPrivate; + + onStatusChanged = + new EventSource((privateInterface) => this.#statusChangedEventPrivate = privateInterface); + onTestStatusChanged = + new EventSource((privateInterface) => + this.#testStatusChangedEventPrivate = privateInterface); + + // The callback receives the private interface of this object, meant not to be used by the + // end user on the web side. + constructor(receivePrivateInterface) { + receivePrivateInterface({ + registerTest: testName => this.#registerTest(testName), + setTestStatus: (testName, status) => this.#setTestStatus(testName, status), + setTestResultData: (testName, testStatus, exitCode, textOutput) => + this.#setTestResultData(testName, testStatus, exitCode, textOutput), + setTestRunnerStatus: status => this.#setTestRunnerStatus(status), + }); + } + + get results() { return this.#results; } + get status() { return this.#status; } + + #registerTest(testName) { this.#results.set(testName, { status: TestStatus.Pending }); } + + #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, textOutput) { + const testData = this.#results.get(testName); + const statusChanged = testStatus !== testData.status; + testData.status = testStatus; + testData.exitCode = exitCode; + testData.textOutput = textOutput; + if (statusChanged) + this.#testStatusChangedEventPrivate.fireEvent(testName, testStatus); + } + + #setTestRunnerStatus(status) { + if (status === this.#status) + return; + this.#status = status; + this.#statusChangedEventPrivate.fireEvent(status); + } +} + +class BatchedTestRunner { + static #TestBatchModuleName = 'test_batch'; + + #loader; + #privateWebApi; + + constructor(loader, privateWebApi) { + this.#loader = loader; + this.#privateWebApi = privateWebApi; + } + + async #doRun(testName) { + const module = await this.#loader.loadEmscriptenModule( + BatchedTestRunner.#TestBatchModuleName, + () => { } + ); + + const testsToExecute = testName ? [testName] : await this.#getTestClassNames(module); + testsToExecute.forEach(testClassName => this.#privateWebApi.registerTest(testClassName)); + for (const testClassName of testsToExecute) { + let result = {}; + this.#privateWebApi.setTestStatus(testClassName, TestStatus.Running); + + try { + const LogToStdoutSpecialFilename = '-'; + result = await module.exec({ + args: [testClassName, '-o', `${LogToStdoutSpecialFilename},xml`], + }); + + if (result.exitCode < 0) + throw new ProgramError(result.exitCode); + result.status = TestStatus.Completed; + } catch (e) { + result.status = e instanceof ProgramError ? TestStatus.Error : TestStatus.Crashed; + result.stdout = e instanceof AbortedError ? e.stdout : result.stdout; + } + this.#privateWebApi.setTestResultData( + testClassName, result.status, result.exitCode, result.stdout); + } + } + + async run(testName) { + try { + await this.#doRun(testName); + this.#privateWebApi.setTestRunnerStatus(RunnerStatus.Completed); + } catch (e) { + this.#privateWebApi.setTestRunnerStatus(RunnerStatus.Error); + } + } + + async #getTestClassNames(module) { + return (await module.exec()).stdout.trim().split(' '); + } +} + +(() => { + let privateWebApi; + window.qtTestRunner = new WebApi(privateApi => privateWebApi = privateApi); + + const parsed = parseQuery(location.search); + const testName = parsed['qtestname']; + if (typeof testName !== 'undefined' && (typeof testName !== 'string' || testName === '')) { + console.error('The testName parameter is incorrect'); + return; + } + + const resourceLocator = new ResourceLocator(''); + const testRunner = new BatchedTestRunner( + new ModuleLoader(new ResourceFetcher(resourceLocator), resourceLocator), + privateWebApi + ); + + testRunner.run(testName); +})(); diff --git a/util/wasm/batchedtestrunner/qwasmjsruntime.js b/util/wasm/batchedtestrunner/qwasmjsruntime.js new file mode 100644 index 0000000000..e167c87d4a --- /dev/null +++ b/util/wasm/batchedtestrunner/qwasmjsruntime.js @@ -0,0 +1,230 @@ +// Copyright (C) 2022 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +// 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, resourceLocator + ) { + const exports = {}; + eval(js); + if (!exports.createQtAppInstance) { + throw new Error( + 'createQtAppInstance has not been exported by the main script' + ); + } + + return new CompiledModule( + exports.createQtAppInstance, js, wasm, resourceLocator + ); + } + + async exec(parameters) { + return await new Promise(async (resolve, reject) => { + let instance = undefined; + let result = undefined; + const continuation = () => { + if (!(instance && result)) + return; + resolve({ + stdout: result.stdout, + exitCode: result.exitCode, + instance, + }); + }; + + instance = await this.#createQtAppInstanceFn((() => { + const params = this.#makeDefaultExecParams({ + onInstantiationError: (error) => { reject(error); }, + }); + params.arguments = parameters?.args; + let data = ''; + params.print = (out) => { + if (parameters?.printStdout === true) + console.log(out); + data += `${out}\n`; + }; + params.printErr = () => { }; + params.onAbort = () => reject(new AbortedError(data)); + params.quit = (code, exception) => { + if (exception && exception.name !== 'ExitStatus') + reject(exception); + result = { stdout: data, exitCode: code }; + continuation(); + }; + return params; + })()); + continuation(); + }); + } + + #makeDefaultExecParams(params) { + const instanceParams = {}; + instanceParams.instantiateWasm = async (imports, onDone) => { + try { + onDone(await WebAssembly.instantiate(this.#wasm, imports)); + } 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.preRun = [ + (instance) => { + const env = {}; + instance.ENV = env; + }, + ]; + + 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, this.#resourceLocator); + } +} diff --git a/util/wasm/batchedtestrunner/util.js b/util/wasm/batchedtestrunner/util.js new file mode 100644 index 0000000000..07a0e73e1a --- /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 + +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)); + } +} |