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 Pick-to: 6.4 Reviewed-by: David Skoland <david.skoland@qt.io>
This commit is contained in:
parent
a021b5e09f
commit
ad1980cd43
41
util/wasm/batchedtestrunner/README.md
Normal file
41
util/wasm/batchedtestrunner/README.md
Normal file
@ -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
|
14
util/wasm/batchedtestrunner/batchedtestrunner.html
Normal file
14
util/wasm/batchedtestrunner/batchedtestrunner.html
Normal file
@ -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>
|
162
util/wasm/batchedtestrunner/batchedtestrunner.js
Normal file
162
util/wasm/batchedtestrunner/batchedtestrunner.js
Normal file
@ -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);
|
||||||
|
})();
|
230
util/wasm/batchedtestrunner/qwasmjsruntime.js
Normal file
230
util/wasm/batchedtestrunner/qwasmjsruntime.js
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
31
util/wasm/batchedtestrunner/util.js
Normal file
31
util/wasm/batchedtestrunner/util.js
Normal file
@ -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));
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user