mirror of
https://github.com/crystalidea/qt6windows7.git
synced 2025-07-01 23:02:23 +08:00
qt 6.5.1 original
This commit is contained in:
60
util/wasm/batchedtestrunner/README.md
Normal file
60
util/wasm/batchedtestrunner/README.md
Normal file
@ -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.
|
15
util/wasm/batchedtestrunner/batchedtestrunner.html
Normal file
15
util/wasm/batchedtestrunner/batchedtestrunner.html
Normal file
@ -0,0 +1,15 @@
|
||||
<!--
|
||||
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 (emrun-enabled)</title>
|
||||
<link rel="stylesheet" href="qtestoutputreporter.css"></link>
|
||||
<script type="module" defer="defer" src="qwasmtestmain.js"></script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
177
util/wasm/batchedtestrunner/batchedtestrunner.js
Normal file
177
util/wasm/batchedtestrunner/batchedtestrunner.js
Normal file
@ -0,0 +1,177 @@
|
||||
// Copyright (C) 2022 The Qt Company Ltd.
|
||||
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
|
||||
|
||||
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, testOutputFormat) {
|
||||
try {
|
||||
await this.#doRun(targetIsBatch, testName, 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, 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] : []),
|
||||
'-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);
|
||||
}
|
||||
}
|
119
util/wasm/batchedtestrunner/emrunadapter.js
Normal file
119
util/wasm/batchedtestrunner/emrunadapter.js
Normal file
@ -0,0 +1,119 @@
|
||||
// Copyright (C) 2022 The Qt Company Ltd.
|
||||
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
|
||||
|
||||
import { RunnerStatus, TestStatus } from './batchedtestrunner.js';
|
||||
|
||||
// Sends messages to the running emrun instance via POST requests.
|
||||
export class EmrunCommunication {
|
||||
#indexOfMessage = 0;
|
||||
#postOutputPromises = [];
|
||||
|
||||
#post(body) {
|
||||
return fetch('stdio.html', {
|
||||
method: 'POST',
|
||||
body
|
||||
});
|
||||
}
|
||||
|
||||
// Returns a promise whose resolution signals that all outstanding traffic to the emrun instance
|
||||
// has been completed.
|
||||
waitUntilAllSent() {
|
||||
return Promise.all(this.#postOutputPromises);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const newPromise = this.#post(`^out^${this.#indexOfMessage++}^${output}`);
|
||||
this.#postOutputPromises.push(newPromise);
|
||||
newPromise.finally(() => {
|
||||
this.#postOutputPromises.splice(this.#postOutputPromises.indexOf(newPromise), 1);
|
||||
});
|
||||
return newPromise;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
89
util/wasm/batchedtestrunner/qtestoutputreporter.css
Normal file
89
util/wasm/batchedtestrunner/qtestoutputreporter.css
Normal file
@ -0,0 +1,89 @@
|
||||
/*
|
||||
Copyright (C) 2022 The Qt Company Ltd.
|
||||
SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
|
||||
*/
|
||||
|
||||
: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);
|
||||
}
|
366
util/wasm/batchedtestrunner/qtestoutputreporter.js
Normal file
366
util/wasm/batchedtestrunner/qtestoutputreporter.js
Normal file
@ -0,0 +1,366 @@
|
||||
// Copyright (C) 2022 The Qt Company Ltd.
|
||||
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
229
util/wasm/batchedtestrunner/qwasmjsruntime.js
Normal file
229
util/wasm/batchedtestrunner/qwasmjsruntime.js
Normal file
@ -0,0 +1,229 @@
|
||||
// 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) => {
|
||||
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);
|
||||
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), 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.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);
|
||||
}
|
||||
}
|
69
util/wasm/batchedtestrunner/qwasmtestmain.js
Normal file
69
util/wasm/batchedtestrunner/qwasmtestmain.js
Normal file
@ -0,0 +1,69 @@
|
||||
// Copyright (C) 2022 The Qt Company Ltd.
|
||||
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
|
||||
|
||||
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 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.get('qvisualoutput') !== undefined;
|
||||
const testName = parsed.get('qtestname');
|
||||
const isBatch = parsed.get('qbatchedtest') !== undefined;
|
||||
const useEmrun = parsed.get('quseemrun') !== undefined;
|
||||
|
||||
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('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, testOutputFormat);
|
||||
})();
|
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));
|
||||
}
|
||||
}
|
12
util/wasm/qtwasmserver/Pipfile
Normal file
12
util/wasm/qtwasmserver/Pipfile
Normal file
@ -0,0 +1,12 @@
|
||||
[[source]]
|
||||
url = "https://pypi.org/simple"
|
||||
verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
netifaces = "*"
|
||||
|
||||
[dev-packages]
|
||||
|
||||
[requires]
|
||||
python_version = "3.9"
|
124
util/wasm/qtwasmserver/qtwasmserver.py
Normal file
124
util/wasm/qtwasmserver/qtwasmserver.py
Normal file
@ -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()
|
13
util/wasm/wasmtestrunner/Pipfile
Normal file
13
util/wasm/wasmtestrunner/Pipfile
Normal file
@ -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"
|
10
util/wasm/wasmtestrunner/README.md
Normal file
10
util/wasm/wasmtestrunner/README.md
Normal file
@ -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.
|
331
util/wasm/wasmtestrunner/qt-wasmtestrunner.py
Normal file
331
util/wasm/wasmtestrunner/qt-wasmtestrunner.py
Normal file
@ -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())
|
Reference in New Issue
Block a user