Modernize the qtloader

This is a minimal version of qtloader. The load function accepts
the same arguments as emscripten runtime with a few additions:
- qt.environment
- qt.onExit
- qt.containerElements
- qt.fontDpi
- qt.onLoaded
- qt.entryFunction

State handling has been removed in favor of making the load async
(assume loading when the promise is live).

Public APIs getting crashed status, exit text and code have been
refactored into the new qt.onExit event fed to load. No need for
keeping the state in the loader.

The loader is integration-tested. A test module with test APIs
has been created as a test harness.

The runtime APIs exposed by Qt (font dpi and screen API) are handled
by the qtloader seamlessly.

Change-Id: Iaee65702667da0349a475feae6b83244d966d98d
Reviewed-by: Morten Johan Sørvig <morten.sorvig@qt.io>
(cherry picked from commit b9491daad0ed1c4b9c74e0c3b23f87eb7ad4f37d)
This commit is contained in:
Mikolaj Boc 2023-02-23 10:14:23 +01:00 committed by Morten Sørvig
parent f1fa472c9f
commit f5e52d209e
10 changed files with 974 additions and 635 deletions

View File

@ -1,588 +1,134 @@
// Copyright (C) 2018 The Qt Company Ltd.
// Copyright (C) 2023 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
// QtLoader provides javascript API for managing Qt application modules.
//
// QtLoader provides API on top of Emscripten which supports common lifecycle
// tasks such as displaying placeholder content while the module downloads,
// handing application exits, and checking for browser wasm support.
//
// There are two usage modes:
// * Managed: QtLoader owns and manages the HTML display elements like
// the loader and canvas.
// * External: The embedding HTML page owns the display elements. QtLoader
// provides event callbacks which the page reacts to.
//
// Managed mode usage:
//
// var config = {
// containerElements : [$("container-id")];
// }
// var qtLoader = new QtLoader(config);
// qtLoader.loadEmscriptenModule("applicationName");
//
// External mode usage:
//
// var config = {
// canvasElements : [$("canvas-id")],
// showLoader: function() {
// loader.style.display = 'block'
// canvas.style.display = 'hidden'
// },
// showCanvas: function() {
// loader.style.display = 'hidden'
// canvas.style.display = 'block'
// return canvas;
// }
// }
// var qtLoader = new QtLoader(config);
// qtLoader.loadEmscriptenModule("applicationName");
//
// Config keys
//
// moduleConfig : {}
// Emscripten module configuration
// containerElements : [container-element, ...]
// One or more HTML elements. QtLoader will display loader elements
// on these while loading the application, and replace the loader with a
// canvas on load complete.
// canvasElements : [canvas-element, ...]
// One or more canvas elements.
// showLoader : function(status, containerElement)
// Optional loading element constructor function. Implement to create
// a custom loading screen. This function may be called multiple times,
// while preparing the application binary. "status" is a string
// containing the loading sub-status, and may be either "Downloading",
// or "Compiling". The browser may be using streaming compilation, in
// which case the wasm module is compiled during downloading and the
// there is no separate compile step.
// showCanvas : function(containerElement)
// Optional canvas constructor function. Implement to create custom
// canvas elements.
// showExit : function(crashed, exitCode, containerElement)
// Optional exited element constructor function.
// showError : function(crashed, exitCode, containerElement)
// Optional error element constructor function.
// statusChanged : function(newStatus)
// Optional callback called when the status of the app has changed
//
// path : <string>
// Prefix path for wasm file, realative to the loading HMTL file.
// restartMode : "DoNotRestart", "RestartOnExit", "RestartOnCrash"
// Controls whether the application should be reloaded on exits. The default is "DoNotRestart"
// restartType : "RestartModule", "ReloadPage"
// restartLimit : <int>
// Restart attempts limit. The default is 10.
// stdoutEnabled : <bool>
// stderrEnabled : <bool>
// environment : <object>
// key-value environment variable pairs.
//
// QtLoader object API
//
// webAssemblySupported : bool
// webGLSupported : bool
// canLoadQt : bool
// Reports if WebAssembly and WebGL are supported. These are requirements for
// running Qt applications.
// loadEmscriptenModule(applicationName)
// Loads the application from the given emscripten javascript module file and wasm file
// status
// One of "Created", "Loading", "Running", "Exited".
// crashed
// Set to true if there was an unclean exit.
// exitCode
// main()/emscripten_force_exit() return code. Valid on status change to
// "Exited", iff crashed is false.
// exitText
// Abort/exit message.
// addCanvasElement
// Add canvas at run-time. Adds a corresponding QScreen,
// removeCanvasElement
// Remove canvas at run-time. Removes the corresponding QScreen.
// resizeCanvasElement
// Signals to the application that a canvas has been resized.
// setFontDpi
// Sets the logical font dpi for the application.
// module
// Returns the Emscripten module object, or undefined if the module
// has not been created yet. Note that the module object becomes available
// at the very end of the loading sequence, _after_ the transition from
// Loading to Running occurs.
// Forces the use of constructor on QtLoader instance.
// This passthrough makes both the old-style:
//
// const loader = QtLoader(config);
//
// and the new-style:
//
// const loader = new QtLoader(config);
//
// instantiation types work.
function QtLoader(config)
/**
* Loads the instance of a WASM module.
*
* @param config May contain any key normally accepted by emscripten and the 'qt' extra key, with
* the following sub-keys:
* - environment: { [name:string] : string }
* environment variables set on the instance
* - onExit: (exitStatus: { text: string, code?: number, crashed: bool }) => void
* called when the application has exited for any reason. exitStatus.code is defined in
* case of a normal application exit. This is not called on exit with return code 0, as
* the program does not shutdown its runtime and technically keeps running async.
* - containerElements: HTMLDivElement[]
* Array of host elements for Qt screens. Each of these elements is mapped to a QScreen on
* launch.
* - fontDpi: number
* Specifies font DPI for the instance
* - onLoaded: () => void
* Called when the module has loaded.
* - entryFunction: (emscriptenConfig: object) => Promise<EmscriptenModule>
* Qt always uses emscripten's MODULARIZE option. This is the MODULARIZE entry function.
*
* @return Promise<{
* instance: EmscriptenModule,
* exitStatus?: { text: string, code?: number, crashed: bool }
* }>
* The promise is resolved when the module has been instantiated and its main function has been
* called. The returned exitStatus is defined if the application crashed or exited immediately
* after its entry function has been called. Otherwise, config.onExit will get called at a
* later time when (and if) the application exits.
*
* @see https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/emscripten for
* EmscriptenModule
*/
async function qtLoad(config)
{
return new _QtLoader(config);
}
const throwIfEnvUsedButNotExported = (instance, config) =>
{
const environment = config.environment;
if (!environment || Object.keys(environment).length === 0)
return;
const isEnvExported = typeof instance.ENV === 'object';
if (!isEnvExported)
throw new Error('ENV must be exported if environment variables are passed');
};
function _QtLoader(config)
{
const self = this;
if (typeof config !== 'object')
throw new Error('config is required, expected an object');
if (typeof config.qt !== 'object')
throw new Error('config.qt is required, expected an object');
if (typeof config.qt.entryFunction !== 'function')
config.qt.entryFunction = window.createQtAppInstance;
// The Emscripten module and module configuration object. The module
// object is created in completeLoadEmscriptenModule().
self.module = undefined;
self.moduleConfig = config.moduleConfig || {};
config.qtContainerElements = config.qt.containerElements;
delete config.qt.containerElements;
config.qtFontDpi = config.qt.fontDpi;
delete config.qt.fontDpi;
// Qt properties. These are propagated to the Emscripten module after
// it has been created.
self.qtContainerElements = undefined;
self.qtFontDpi = 96;
// Used for rejecting a failed load's promise where emscripten itself does not allow it,
// like in instantiateWasm below. This allows us to throw in case of a load error instead of
// hanging on a promise to entry function, which emscripten unfortunately does.
let circuitBreakerReject;
const circuitBreaker = new Promise((_, reject) => { circuitBreakerReject = reject; });
function webAssemblySupported() {
return typeof WebAssembly !== "undefined"
}
function webGLSupported() {
// We expect that WebGL is supported if WebAssembly is; however
// the GPU may be blacklisted.
try {
var canvas = document.createElement("canvas");
return !!(window.WebGLRenderingContext && (canvas.getContext("webgl") || canvas.getContext("experimental-webgl")));
} catch (e) {
return false;
}
}
function canLoadQt() {
// The current Qt implementation requires WebAssembly (asm.js is not in use),
// and also WebGL (there is no raster fallback).
return webAssemblySupported() && webGLSupported();
}
function removeChildren(element) {
while (element.firstChild) element.removeChild(element.firstChild);
}
// Set default state handler functions
if (config.containerElements !== undefined) {
config.showError = config.showError || function(errorText, container) {
removeChildren(container);
var errorTextElement = document.createElement("text");
errorTextElement.className = "QtError"
errorTextElement.innerHTML = errorText;
return errorTextElement;
}
config.showLoader = config.showLoader || function(loadingState, container) {
removeChildren(container);
var loadingText = document.createElement("text");
loadingText.className = "QtLoading"
loadingText.innerHTML = "<p><center>" + loadingState + "</center><p>";
return loadingText;
};
config.showCanvas = config.showCanvas || function(canvas, container) {
removeChildren(container);
}
config.showExit = config.showExit || function(crashed, exitCode, container) {
if (!crashed)
return undefined;
removeChildren(container);
var fontSize = 54;
var crashSymbols = ["\u{1F615}", "\u{1F614}", "\u{1F644}", "\u{1F928}", "\u{1F62C}",
"\u{1F915}", "\u{2639}", "\u{1F62E}", "\u{1F61E}", "\u{1F633}"];
var symbolIndex = Math.floor(Math.random() * crashSymbols.length);
var errorHtml = `<font size='${fontSize}'> ${crashSymbols[symbolIndex]} </font>`
var errorElement = document.createElement("text");
errorElement.className = "QtExit"
errorElement.innerHTML = errorHtml;
return errorElement;
}
} else {
config.containerElements = config.canvasElements
}
config.restartMode = config.restartMode || "DoNotRestart";
config.restartLimit = config.restartLimit || 10;
if (config.stdoutEnabled === undefined) config.stdoutEnabled = true;
if (config.stderrEnabled === undefined) config.stderrEnabled = true;
// Make sure config.path is defined and ends with "/" if needed
if (config.path === undefined)
config.path = "";
if (config.path.length > 0 && !config.path.endsWith("/"))
config.path = config.path.concat("/");
if (config.environment === undefined)
config.environment = {};
var publicAPI = {};
publicAPI.webAssemblySupported = webAssemblySupported();
publicAPI.webGLSupported = webGLSupported();
publicAPI.canLoadQt = canLoadQt();
publicAPI.canLoadApplication = canLoadQt();
publicAPI.status = undefined;
publicAPI.loadEmscriptenModule = loadEmscriptenModule;
publicAPI.addCanvasElement = addCanvasElement;
publicAPI.removeCanvasElement = removeCanvasElement;
publicAPI.resizeCanvasElement = resizeCanvasElement;
publicAPI.setFontDpi = setFontDpi;
publicAPI.fontDpi = fontDpi;
publicAPI.module = module;
self.restartCount = 0;
function handleError(error) {
self.error = error;
setStatus("Error");
console.error(error);
}
function fetchResource(filePath) {
var fullPath = config.path + filePath;
return fetch(fullPath).then(function(response) {
if (!response.ok) {
let err = response.status + " " + response.statusText + " " + response.url;
handleError(err);
return Promise.reject(err)
} else {
return response;
// If module async getter is present, use it so that module reuse is possible.
if (config.qt.modulePromise) {
config.instantiateWasm = async (imports, successCallback) =>
{
try {
const module = await config.qt.modulePromise;
successCallback(
await WebAssembly.instantiate(module, imports), module);
} catch (e) {
circuitBreakerReject(e);
}
});
}
function fetchText(filePath) {
return fetchResource(filePath).then(function(response) {
return response.text();
});
}
function fetchThenCompileWasm(response) {
return response.arrayBuffer().then(function(data) {
self.loaderSubState = "Compiling";
setStatus("Loading") // trigger loaderSubState update
return WebAssembly.compile(data);
});
}
function fetchCompileWasm(filePath) {
return fetchResource(filePath).then(function(response) {
if (typeof WebAssembly.compileStreaming !== "undefined") {
self.loaderSubState = "Downloading/Compiling";
setStatus("Loading");
return WebAssembly.compileStreaming(response).catch(function(error) {
// compileStreaming may/will fail if the server does not set the correct
// mime type (application/wasm) for the wasm file. Fall back to fetch,
// then compile in this case.
return fetchThenCompileWasm(response);
});
} else {
// Fall back to fetch, then compile if compileStreaming is not supported
return fetchThenCompileWasm(response);
}
});
}
function loadEmscriptenModule(applicationName) {
// Loading in qtloader.js goes through four steps:
// 1) Check prerequisites
// 2) Download resources
// 3) Configure the emscripten Module object
// 4) Start the emcripten runtime, after which emscripten takes over
// Check for Wasm & WebGL support; set error and return before downloading resources if missing
if (!webAssemblySupported()) {
handleError("Error: WebAssembly is not supported");
return;
}
if (!webGLSupported()) {
handleError("Error: WebGL is not supported");
return;
}
// Continue waiting if loadEmscriptenModule() is called again
if (publicAPI.status == "Loading")
return;
self.loaderSubState = "Downloading";
setStatus("Loading");
// Fetch emscripten generated javascript runtime
var emscriptenModuleSource = undefined
var emscriptenModuleSourcePromise = fetchText(applicationName + ".js").then(function(source) {
emscriptenModuleSource = source
});
// Fetch and compile wasm module
var wasmModule = undefined;
var wasmModulePromise = fetchCompileWasm(applicationName + ".wasm").then(function (module) {
wasmModule = module;
});
// Wait for all resources ready
Promise.all([emscriptenModuleSourcePromise, wasmModulePromise]).then(function(){
completeLoadEmscriptenModule(applicationName, emscriptenModuleSource, wasmModule);
}).catch(function(error) {
handleError(error);
// An error here is fatal, abort
self.moduleConfig.onAbort(error)
});
}
function completeLoadEmscriptenModule(applicationName, emscriptenModuleSource, wasmModule) {
const originalPreRun = config.preRun;
config.preRun = instance =>
{
originalPreRun?.();
// The wasm binary has been compiled into a module during resource download,
// and is ready to be instantiated. Define the instantiateWasm callback which
// emscripten will call to create the instance.
self.moduleConfig.instantiateWasm = function(imports, successCallback) {
WebAssembly.instantiate(wasmModule, imports).then(function(instance) {
successCallback(instance, wasmModule);
}, function(error) {
handleError(error)
throwIfEnvUsedButNotExported(instance, config);
for (const [name, value] of Object.entries(config.qt.environment ?? {}))
instance.ENV[name] = value;
};
config.onRuntimeInitialized = () => config.qt.onLoaded?.();
// This is needed for errors which occur right after resolving the instance promise but
// before exiting the function (i.e. on call to main before stack unwinding).
let loadTimeException = undefined;
// We don't want to issue onExit when aborted
let aborted = false;
const originalQuit = config.quit;
config.quit = (code, exception) =>
{
originalQuit?.(code, exception);
if (exception)
loadTimeException = exception;
if (!aborted && code !== 0) {
config.qt.onExit?.({
text: exception.message,
code,
crashed: false
});
return {};
};
}
};
self.moduleConfig.locateFile = self.moduleConfig.locateFile || function(filename) {
return config.path + filename;
};
const originalOnAbort = config.onAbort;
config.onAbort = text =>
{
originalOnAbort?.();
// Attach status callbacks
self.moduleConfig.setStatus = self.moduleConfig.setStatus || function(text) {
// Currently the only usable status update from this function
// is "Running..."
if (text.startsWith("Running"))
setStatus("Running");
};
self.moduleConfig.monitorRunDependencies = self.moduleConfig.monitorRunDependencies || function(left) {
// console.log("monitorRunDependencies " + left)
};
// Attach standard out/err callbacks.
self.moduleConfig.print = self.moduleConfig.print || function(text) {
if (config.stdoutEnabled)
console.log(text)
};
self.moduleConfig.printErr = self.moduleConfig.printErr || function(text) {
if (config.stderrEnabled)
console.warn(text)
};
// Error handling: set status to "Exited", update crashed and
// exitCode according to exit type.
// Emscripten will typically call printErr with the error text
// as well. Note that emscripten may also throw exceptions from
// async callbacks. These should be handled in window.onerror by user code.
self.moduleConfig.onAbort = self.moduleConfig.onAbort || function(text) {
publicAPI.crashed = true;
publicAPI.exitText = text;
setStatus("Exited");
};
self.moduleConfig.quit = self.moduleConfig.quit || function(code, exception) {
// Emscripten (and Qt) supports exiting from main() while keeping the app
// running. Don't transition into the "Exited" state for clean exits.
if (code == 0)
return;
if (exception.name == "ExitStatus") {
// Clean exit with code
publicAPI.exitText = undefined
publicAPI.exitCode = code;
} else {
publicAPI.exitText = exception.toString();
publicAPI.crashed = true;
// Print stack trace to console
console.log(exception);
}
setStatus("Exited");
};
self.moduleConfig.preRun = self.moduleConfig.preRun || []
self.moduleConfig.preRun.push(function(module) {
// Set environment variables
for (var [key, value] of Object.entries(config.environment)) {
module.ENV[key.toUpperCase()] = value;
}
// Propagate Qt module properties
module.qtContainerElements = self.qtContainerElements;
module.qtFontDpi = self.qtFontDpi;
aborted = true;
config.qt.onExit?.({
text,
crashed: true
});
};
self.moduleConfig.mainScriptUrlOrBlob = new Blob([emscriptenModuleSource], {type: 'text/javascript'});
// Call app/emscripten module entry function. It may either come from the emscripten
// runtime script or be customized as needed.
const instance = await Promise.race(
[circuitBreaker, config.qt.entryFunction(config)]);
if (loadTimeException && loadTimeException.name !== 'ExitStatus')
throw loadTimeException;
self.qtContainerElements = config.containerElements;
config.restart = function() {
// Restart by reloading the page. This will wipe all state which means
// reload loops can't be prevented.
if (config.restartType == "ReloadPage") {
location.reload();
}
// Restart by readling the emscripten app module.
++self.restartCount;
if (self.restartCount > config.restartLimit) {
handleError("Error: This application has crashed too many times and has been disabled. Reload the page to try again.");
return;
}
loadEmscriptenModule(applicationName);
};
publicAPI.exitCode = undefined;
publicAPI.exitText = undefined;
publicAPI.crashed = false;
// Load the Emscripten application module. This is done by eval()'ing the
// javascript runtime generated by Emscripten, and then calling
// createQtAppInstance(), which was added to the global scope.
eval(emscriptenModuleSource);
createQtAppInstance(self.moduleConfig).then(function(module) {
self.module = module;
});
}
function setErrorContent() {
if (config.containerElements === undefined) {
if (config.showError !== undefined)
config.showError(self.error);
return;
}
for (container of config.containerElements) {
var errorElement = config.showError(self.error, container);
container.appendChild(errorElement);
}
}
function setLoaderContent() {
if (config.containerElements === undefined) {
if (config.showLoader !== undefined)
config.showLoader(self.loaderSubState);
return;
}
for (container of config.containerElements) {
var loaderElement = config.showLoader(self.loaderSubState, container);
if (loaderElement !== undefined)
container.appendChild(loaderElement);
}
}
function setCanvasContent() {
if (config.containerElements === undefined) {
if (config.showCanvas !== undefined)
config.showCanvas();
return;
}
for (var i = 0; i < config.containerElements.length; ++i) {
var container = config.containerElements[i];
var canvas = undefined;
if (config.canvasElements !== undefined)
canvas = config.canvasElements[i];
config.showCanvas(canvas, container);
}
}
function setExitContent() {
// publicAPI.crashed = true;
if (publicAPI.status != "Exited")
return;
if (config.containerElements === undefined) {
if (config.showExit !== undefined)
config.showExit(publicAPI.crashed, publicAPI.exitCode);
return;
}
if (!publicAPI.crashed)
return;
for (container of config.containerElements) {
var loaderElement = config.showExit(publicAPI.crashed, publicAPI.exitCode, container);
if (loaderElement !== undefined)
container.appendChild(loaderElement);
}
}
var committedStatus = undefined;
function handleStatusChange() {
if (publicAPI.status != "Loading" && committedStatus == publicAPI.status)
return;
committedStatus = publicAPI.status;
if (publicAPI.status == "Error") {
setErrorContent();
} else if (publicAPI.status == "Loading") {
setLoaderContent();
} else if (publicAPI.status == "Running") {
setCanvasContent();
} else if (publicAPI.status == "Exited") {
if (config.restartMode == "RestartOnExit" ||
config.restartMode == "RestartOnCrash" && publicAPI.crashed) {
committedStatus = undefined;
config.restart();
} else {
setExitContent();
}
}
// Send status change notification
if (config.statusChanged)
config.statusChanged(publicAPI.status);
}
function setStatus(status) {
if (status != "Loading" && publicAPI.status == status)
return;
publicAPI.status = status;
window.setTimeout(function() { handleStatusChange(); }, 0);
}
function addCanvasElement(element) {
if (publicAPI.status == "Running")
self.module.qtAddContainerElement(element);
else
console.log("Error: addCanvasElement can only be called in the Running state");
}
function removeCanvasElement(element) {
if (publicAPI.status == "Running")
self.module.qtRemoveContainerElement(element);
else
console.log("Error: removeCanvasElement can only be called in the Running state");
}
function resizeCanvasElement(element) {
if (publicAPI.status == "Running")
self.module.qtResizeContainerElement(element);
}
function setFontDpi(dpi) {
self.qtFontDpi = dpi;
if (publicAPI.status == "Running")
self.module.qtUpdateDpi();
}
function fontDpi() {
return self.qtFontDpi;
}
function module() {
return self.module;
}
setStatus("Created");
return publicAPI;
return instance;
}

View File

@ -38,14 +38,19 @@ using namespace emscripten;
using namespace Qt::StringLiterals;
static void setContainerElements(emscripten::val elementArray)
{
QWasmIntegration::get()->setContainerElements(elementArray);
}
static void addContainerElement(emscripten::val element)
{
QWasmIntegration::get()->addScreen(element);
QWasmIntegration::get()->addContainerElement(element);
}
static void removeContainerElement(emscripten::val element)
{
QWasmIntegration::get()->removeScreen(element);
QWasmIntegration::get()->removeContainerElement(element);
}
static void resizeContainerElement(emscripten::val element)
@ -66,6 +71,7 @@ static void resizeAllScreens(emscripten::val event)
EMSCRIPTEN_BINDINGS(qtQWasmIntegraton)
{
function("qtSetContainerElements", &setContainerElements);
function("qtAddContainerElement", &addContainerElement);
function("qtRemoveContainerElement", &removeContainerElement);
function("qtResizeContainerElement", &resizeContainerElement);
@ -94,6 +100,7 @@ QWasmIntegration::QWasmIntegration()
// div element. Qt historically supported supplying canvas for screen elements - these elements
// will be transformed into divs and warnings about deprecation will be printed. See
// QWasmScreen ctor.
emscripten::val filtered = emscripten::val::array();
emscripten::val qtContainerElements = val::module_property("qtContainerElements");
if (qtContainerElements.isArray()) {
for (int i = 0; i < qtContainerElements["length"].as<int>(); ++i) {
@ -101,13 +108,14 @@ QWasmIntegration::QWasmIntegration()
if (element.isNull() || element.isUndefined())
qWarning() << "Skipping null or undefined element in qtContainerElements";
else
addScreen(element);
filtered.call<void>("push", element);
}
} else {
// No screens, which may or may not be intended
qWarning() << "The qtContainerElements module property was not set or is invalid. "
"Proceeding with no screens.";
}
setContainerElements(filtered);
// install browser window resize handler
emscripten_set_resize_callback(EMSCRIPTEN_EVENT_TARGET_WINDOW, nullptr, EM_TRUE,
@ -149,7 +157,7 @@ QWasmIntegration::~QWasmIntegration()
#endif
for (const auto &elementAndScreen : m_screens)
elementAndScreen.second->deleteScreen();
elementAndScreen.wasmScreen->deleteScreen();
m_screens.clear();
@ -285,37 +293,93 @@ QPlatformAccessibility *QWasmIntegration::accessibility() const
}
#endif
void QWasmIntegration::addScreen(const emscripten::val &element)
void QWasmIntegration::setContainerElements(emscripten::val elementArray)
{
QWasmScreen *screen = new QWasmScreen(element);
m_screens.append(qMakePair(element, screen));
QWindowSystemInterface::handleScreenAdded(screen);
const auto *primaryScreenBefore = m_screens.isEmpty() ? nullptr : m_screens[0].wasmScreen;
QList<ScreenMapping> newScreens;
QList<QWasmScreen *> screensToDelete;
std::transform(m_screens.begin(), m_screens.end(), std::back_inserter(screensToDelete),
[](const ScreenMapping &mapping) { return mapping.wasmScreen; });
for (int i = 0; i < elementArray["length"].as<int>(); ++i) {
const auto element = elementArray[i];
const auto it = std::find_if(
m_screens.begin(), m_screens.end(),
[&element](const ScreenMapping &screen) { return screen.emscriptenVal == element; });
QWasmScreen *screen;
if (it != m_screens.end()) {
screen = it->wasmScreen;
screensToDelete.erase(std::remove_if(screensToDelete.begin(), screensToDelete.end(),
[screen](const QWasmScreen *removedScreen) {
return removedScreen == screen;
}),
screensToDelete.end());
} else {
screen = new QWasmScreen(element);
QWindowSystemInterface::handleScreenAdded(screen);
}
newScreens.push_back({element, screen});
}
std::for_each(screensToDelete.begin(), screensToDelete.end(),
[](QWasmScreen *removed) { removed->deleteScreen(); });
m_screens = newScreens;
auto *primaryScreenAfter = m_screens.isEmpty() ? nullptr : m_screens[0].wasmScreen;
if (primaryScreenAfter && primaryScreenAfter != primaryScreenBefore)
QWindowSystemInterface::handlePrimaryScreenChanged(primaryScreenAfter);
}
void QWasmIntegration::removeScreen(const emscripten::val &element)
void QWasmIntegration::addContainerElement(emscripten::val element)
{
auto it = std::find_if(m_screens.begin(), m_screens.end(),
[&] (const QPair<emscripten::val, QWasmScreen *> &candidate) { return candidate.first.equals(element); });
Q_ASSERT_X(m_screens.end()
== std::find_if(m_screens.begin(), m_screens.end(),
[&element](const ScreenMapping &screen) {
return screen.emscriptenVal == element;
}),
Q_FUNC_INFO, "Double-add of an element");
QWasmScreen *screen = new QWasmScreen(element);
QWindowSystemInterface::handleScreenAdded(screen);
m_screens.push_back({element, screen});
}
void QWasmIntegration::removeContainerElement(emscripten::val element)
{
const auto *primaryScreenBefore = m_screens.isEmpty() ? nullptr : m_screens[0].wasmScreen;
const auto it =
std::find_if(m_screens.begin(), m_screens.end(),
[&element](const ScreenMapping &screen) { return screen.emscriptenVal == element; });
if (it == m_screens.end()) {
qWarning() << "Attempting to remove non-existing screen for element"
<< QString::fromJsString(element["id"]);
qWarning() << "Attempt to remove a nonexistent screen.";
return;
}
it->second->deleteScreen();
m_screens.erase(it);
QWasmScreen *removedScreen = it->wasmScreen;
removedScreen->deleteScreen();
m_screens.erase(std::remove_if(m_screens.begin(), m_screens.end(),
[removedScreen](const ScreenMapping &mapping) {
return removedScreen == mapping.wasmScreen;
}),
m_screens.end());
auto *primaryScreenAfter = m_screens.isEmpty() ? nullptr : m_screens[0].wasmScreen;
if (primaryScreenAfter && primaryScreenAfter != primaryScreenBefore)
QWindowSystemInterface::handlePrimaryScreenChanged(primaryScreenAfter);
}
void QWasmIntegration::resizeScreen(const emscripten::val &element)
{
auto it = std::find_if(m_screens.begin(), m_screens.end(),
[&] (const QPair<emscripten::val, QWasmScreen *> &candidate) { return candidate.first.equals(element); });
[&] (const ScreenMapping &candidate) { return candidate.emscriptenVal.equals(element); });
if (it == m_screens.end()) {
qWarning() << "Attempting to resize non-existing screen for element"
<< QString::fromJsString(element["id"]);
return;
}
it->second->updateQScreenAndCanvasRenderSize();
it->wasmScreen->updateQScreenAndCanvasRenderSize();
}
void QWasmIntegration::updateDpi()
@ -325,13 +389,13 @@ void QWasmIntegration::updateDpi()
return;
qreal dpiValue = dpi.as<qreal>();
for (const auto &elementAndScreen : m_screens)
QWindowSystemInterface::handleScreenLogicalDotsPerInchChange(elementAndScreen.second->screen(), dpiValue, dpiValue);
QWindowSystemInterface::handleScreenLogicalDotsPerInchChange(elementAndScreen.wasmScreen->screen(), dpiValue, dpiValue);
}
void QWasmIntegration::resizeAllScreens()
{
for (const auto &elementAndScreen : m_screens)
elementAndScreen.second->updateQScreenAndCanvasRenderSize();
elementAndScreen.wasmScreen->updateQScreenAndCanvasRenderSize();
}
quint64 QWasmIntegration::getTimestamp()

View File

@ -70,8 +70,9 @@ public:
QWasmInputContext *getWasmInputContext() { return m_platformInputContext; }
static QWasmIntegration *get() { return s_instance; }
void addScreen(const emscripten::val &canvas);
void removeScreen(const emscripten::val &canvas);
void setContainerElements(emscripten::val elementArray);
void addContainerElement(emscripten::val elementArray);
void removeContainerElement(emscripten::val elementArray);
void resizeScreen(const emscripten::val &canvas);
void resizeAllScreens();
void updateDpi();
@ -81,10 +82,15 @@ public:
int touchPoints;
private:
struct ScreenMapping {
emscripten::val emscriptenVal;
QWasmScreen *wasmScreen;
};
mutable QWasmFontDatabase *m_fontDb;
mutable QWasmServices *m_desktopServices;
mutable QHash<QWindow *, QWasmBackingStore *> m_backingStores;
QList<QPair<emscripten::val, QWasmScreen *>> m_screens;
QList<ScreenMapping> m_screens;
mutable QWasmClipboard *m_clipboard;
mutable QWasmAccessibility *m_accessibility;

View File

@ -27,42 +27,48 @@
</figure>
<div id="screen"></div>
<script type='text/javascript'>
let qtLoader = undefined;
function init() {
var spinner = document.querySelector('#qtspinner');
var canvas = document.querySelector('#screen');
var status = document.querySelector('#qtstatus')
<script type="text/javascript">
async function init()
{
const spinner = document.querySelector('#qtspinner');
const screen = document.querySelector('#screen');
const status = document.querySelector('#qtstatus');
qtLoader = new QtLoader({
canvasElements : [canvas],
showLoader: function(loaderStatus) {
spinner.style.display = 'block';
canvas.style.display = 'none';
status.innerHTML = loaderStatus + "...";
},
showError: function(errorText) {
status.innerHTML = errorText;
spinner.style.display = 'block';
canvas.style.display = 'none';
},
showExit: function() {
status.innerHTML = "Application exit";
if (qtLoader.exitCode !== undefined)
status.innerHTML += " with code " + qtLoader.exitCode;
if (qtLoader.exitText !== undefined)
status.innerHTML += " (" + qtLoader.exitText + ")";
spinner.style.display = 'block';
canvas.style.display = 'none';
},
showCanvas: function() {
spinner.style.display = 'none';
canvas.style.display = 'block';
},
});
qtLoader.loadEmscriptenModule("@APPNAME@");
}
const showUi = (ui) => {
[spinner, screen].forEach(element => element.style.display = 'none');
if (screen === ui)
screen.style.position = 'default';
ui.style.display = 'block';
}
try {
showUi(spinner);
status.innerHTML = 'Loading...';
const instance = await qtLoad({
qt: {
onLoaded: () => showUi(screen),
onExit: exitData =>
{
status.innerHTML = 'Application exit';
status.innerHTML +=
exitData.code !== undefined ? ` with code ${exitData.code}` : '';
status.innerHTML +=
exitData.text !== undefined ? ` (${exitData.text})` : '';
showUi(spinner);
},
entryFunction: window.createQtAppInstance,
containerElements: [screen],
}
});
} catch (e) {
console.error(e);
status.innerHTML = e.message;
showUi(spinner);
}
}
</script>
<script src="@APPNAME@.js"></script>
<script type="text/javascript" src="qtloader.js"></script>
</body>
</html>

View File

@ -94,10 +94,8 @@ public:
QWasmCompositorTest() : m_window(val::global("window")), m_testSupport(val::object())
{
m_window.set("testSupport", m_testSupport);
m_testSupport.set("qtAddContainerElement",
emscripten::val::module_property("qtAddContainerElement"));
m_testSupport.set("qtRemoveContainerElement",
emscripten::val::module_property("qtRemoveContainerElement"));
m_testSupport.set("qtSetContainerElements",
emscripten::val::module_property("qtSetContainerElements"));
}
~QWasmCompositorTest() noexcept
@ -118,12 +116,12 @@ private:
});
m_cleanup.emplace_back([]() mutable {
EM_ASM({
testSupport.qtRemoveContainerElement(testSupport.screenElement);
testSupport.qtSetContainerElements([]);
testSupport.screenElement.parentElement.removeChild(testSupport.screenElement);
});
});
EM_ASM({ testSupport.qtAddContainerElement(testSupport.screenElement); });
EM_ASM({ testSupport.qtSetContainerElements([testSupport.screenElement]); });
}
template<class T>

View File

@ -0,0 +1,39 @@
# Copyright (C) 2023 The Qt Company Ltd.
# SPDX-License-Identifier: BSD-3-Clause
qt_internal_add_manual_test(tst_qtloader_integration
GUI
SOURCES
main.cpp
LIBRARIES
Qt::Core
Qt::Gui
Qt::GuiPrivate
Qt::Widgets
)
set_target_properties(tst_qtloader_integration PROPERTIES QT_WASM_EXTRA_EXPORTED_METHODS "ENV")
add_custom_command(
TARGET tst_qtloader_integration POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy
${CMAKE_CURRENT_SOURCE_DIR}/tst_qtloader_integration.html
${CMAKE_CURRENT_BINARY_DIR}/tst_qtloader_integration.html)
add_custom_command(
TARGET tst_qtloader_integration POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy
${CMAKE_CURRENT_SOURCE_DIR}/../../../../src/plugins/platforms/wasm/qtloader.js
${CMAKE_CURRENT_BINARY_DIR}/qtloader.js)
add_custom_command(
TARGET tst_qtloader_integration POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy
${CMAKE_CURRENT_SOURCE_DIR}/../shared/testrunner.js
${CMAKE_CURRENT_BINARY_DIR}/testrunner.js)
add_custom_command(
TARGET tst_qtloader_integration POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy
${CMAKE_CURRENT_SOURCE_DIR}/test_body.js
${CMAKE_CURRENT_BINARY_DIR}/test_body.js)

View File

@ -0,0 +1,155 @@
// Copyright (C) 2023 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
#include <QtWidgets/QtWidgets>
#include <iostream>
#include <sstream>
#include <emscripten/bind.h>
#include <emscripten/val.h>
#include <emscripten.h>
#include <QtGui/qpa/qplatformscreen.h>
namespace {
constexpr int ExitValueImmediateReturn = 42;
constexpr int ExitValueFromExitApp = 22;
std::string screenInformation()
{
auto screens = qGuiApp->screens();
std::ostringstream out;
out << "[";
const char *separator = "";
for (const auto &screen : screens) {
out << separator;
out << "[" << std::to_string(screen->geometry().x()) << ","
<< std::to_string(screen->geometry().y()) << ","
<< std::to_string(screen->geometry().width()) << ","
<< std::to_string(screen->geometry().height()) << "]";
separator = ",";
}
out << "]";
return out.str();
}
std::string logicalDpi()
{
auto screens = qGuiApp->screens();
std::ostringstream out;
out << "[";
const char *separator = "";
for (const auto &screen : screens) {
out << separator;
out << "[" << std::to_string(screen->handle()->logicalDpi().first) << ", "
<< std::to_string(screen->handle()->logicalDpi().second) << "]";
separator = ",";
}
out << "]";
return out.str();
}
void crash()
{
std::abort();
}
void exitApp()
{
exit(ExitValueFromExitApp);
}
void produceOutput()
{
fprintf(stdout, "Sample output!\n");
}
std::string retrieveArguments()
{
auto arguments = QApplication::arguments();
std::ostringstream out;
out << "[";
const char *separator = "";
for (const auto &argument : arguments) {
out << separator;
out << "'" << argument.toStdString() << "'";
separator = ",";
}
out << "]";
return out.str();
}
std::string getEnvironmentVariable(std::string name) {
return QString::fromLatin1(qgetenv(name.c_str())).toStdString();
}
} // namespace
class AppWindow : public QObject
{
Q_OBJECT
public:
AppWindow() : m_layout(new QVBoxLayout(&m_ui))
{
addWidget<QLabel>("Qt Loader integration tests");
m_ui.setLayout(m_layout);
}
void show() { m_ui.show(); }
~AppWindow() = default;
private:
template<class T, class... Args>
T *addWidget(Args... args)
{
T *widget = new T(std::forward<Args>(args)..., &m_ui);
m_layout->addWidget(widget);
return widget;
}
QWidget m_ui;
QVBoxLayout *m_layout;
};
int main(int argc, char **argv)
{
QApplication application(argc, argv);
const auto arguments = application.arguments();
const bool exitImmediately =
std::find(arguments.begin(), arguments.end(), QStringLiteral("--exit-immediately"))
!= arguments.end();
if (exitImmediately)
return ExitValueImmediateReturn;
const bool crashImmediately =
std::find(arguments.begin(), arguments.end(), QStringLiteral("--crash-immediately"))
!= arguments.end();
if (crashImmediately)
crash();
const bool noGui = std::find(arguments.begin(), arguments.end(), QStringLiteral("--no-gui"))
!= arguments.end();
if (!noGui) {
AppWindow window;
window.show();
return application.exec();
}
return application.exec();
}
EMSCRIPTEN_BINDINGS(qtLoaderIntegrationTest)
{
emscripten::constant("EXIT_VALUE_IMMEDIATE_RETURN", ExitValueImmediateReturn);
emscripten::constant("EXIT_VALUE_FROM_EXIT_APP", ExitValueFromExitApp);
emscripten::function("screenInformation", &screenInformation);
emscripten::function("logicalDpi", &logicalDpi);
emscripten::function("crash", &crash);
emscripten::function("exitApp", &exitApp);
emscripten::function("produceOutput", &produceOutput);
emscripten::function("retrieveArguments", &retrieveArguments);
emscripten::function("getEnvironmentVariable", &getEnvironmentVariable);
}
#include "main.moc"

View File

@ -0,0 +1,445 @@
// Copyright (C) 2023 The Qt Company Ltd.
// SPDXLicenseIdentifier: LicenseRefQtCommercial OR GPL3.0only
import { Mock, assert, TestRunner } from './testrunner.js';
export class QtLoaderIntegrationTests
{
#testScreenContainers = []
async beforeEach()
{
this.#addScreenContainer('screen-container-0', { width: '200px', height: '300px' });
}
async afterEach()
{
this.#testScreenContainers.forEach(screenContainer =>
{
document.body.removeChild(screenContainer);
});
this.#testScreenContainers = [];
}
async missingConfig()
{
let caughtException;
try {
await qtLoad();
} catch (e) {
caughtException = e;
}
assert.isNotUndefined(caughtException);
assert.equal('config is required, expected an object', caughtException.message);
}
async missingQtSection()
{
let caughtException;
try {
await qtLoad({});
} catch (e) {
caughtException = e;
}
assert.isNotUndefined(caughtException);
assert.equal(
'config.qt is required, expected an object', caughtException.message);
}
async useDefaultOnMissingEntryFunction()
{
const instance = await qtLoad({ arguments: ['--no-gui'], qt: {}});
assert.isNotUndefined(instance);
}
async environmentVariables()
{
const instance = await qtLoad({
qt: {
environment: {
variable1: 'value1',
variable2: 'value2'
},
entryFunction: createQtAppInstance,
containerElements: [this.#testScreenContainers[0]]
}
});
assert.isTrue(instance.getEnvironmentVariable('variable1') === 'value1');
assert.isTrue(instance.getEnvironmentVariable('variable2') === 'value2');
}
async screenContainerManipulations()
{
// ... (do other things), then call addContainerElement() to add a new container/screen.
// This can happen either before or after load() is called - loader will route the
// call to instance when it's ready.
this.#addScreenContainer('appcontainer1', { width: '100px', height: '100px' })
const instance = await qtLoad({
qt: {
entryFunction: createQtAppInstance,
containerElements: this.#testScreenContainers
}
});
{
const screenInformation = this.#getScreenInformation(instance);
assert.equal(2, screenInformation.length);
assert.equal(200, screenInformation[0].width);
assert.equal(300, screenInformation[0].height);
assert.equal(100, screenInformation[1].width);
assert.equal(100, screenInformation[1].height);
}
this.#addScreenContainer('appcontainer2', { width: '234px', height: '99px' })
instance.qtSetContainerElements(this.#testScreenContainers);
{
const screenInformation = this.#getScreenInformation(instance);
assert.equal(3, screenInformation.length);
assert.equal(200, screenInformation[0].width);
assert.equal(300, screenInformation[0].height);
assert.equal(100, screenInformation[1].width);
assert.equal(100, screenInformation[1].height);
assert.equal(234, screenInformation[2].width);
assert.equal(99, screenInformation[2].height);
}
document.body.removeChild(this.#testScreenContainers.splice(2, 1)[0]);
instance.qtSetContainerElements(this.#testScreenContainers);
{
const screenInformation = this.#getScreenInformation(instance);
assert.equal(2, screenInformation.length);
assert.equal(200, screenInformation[0].width);
assert.equal(300, screenInformation[0].height);
assert.equal(100, screenInformation[1].width);
assert.equal(100, screenInformation[1].height);
}
}
async primaryScreenIsAlwaysFirst()
{
const instance = await qtLoad({
qt: {
entryFunction: createQtAppInstance,
containerElements: this.#testScreenContainers,
}
});
this.#addScreenContainer(
'appcontainer3', { width: '12px', height: '24px' },
container => this.#testScreenContainers.splice(0, 0, container));
this.#addScreenContainer(
'appcontainer4', { width: '34px', height: '68px' },
container => this.#testScreenContainers.splice(1, 0, container));
instance.qtSetContainerElements(this.#testScreenContainers);
{
const screenInformation = this.#getScreenInformation(instance);
assert.equal(3, screenInformation.length);
// The primary screen (at position 0) is always at 0
assert.equal(12, screenInformation[0].width);
assert.equal(24, screenInformation[0].height);
// Other screens are pushed at the back
assert.equal(200, screenInformation[1].width);
assert.equal(300, screenInformation[1].height);
assert.equal(34, screenInformation[2].width);
assert.equal(68, screenInformation[2].height);
}
this.#testScreenContainers.forEach(screenContainer =>
{
document.body.removeChild(screenContainer);
});
this.#testScreenContainers = [
this.#addScreenContainer('appcontainer5', { width: '11px', height: '12px' }),
this.#addScreenContainer('appcontainer6', { width: '13px', height: '14px' }),
];
instance.qtSetContainerElements(this.#testScreenContainers);
{
const screenInformation = this.#getScreenInformation(instance);
assert.equal(2, screenInformation.length);
assert.equal(11, screenInformation[0].width);
assert.equal(12, screenInformation[0].height);
assert.equal(13, screenInformation[1].width);
assert.equal(14, screenInformation[1].height);
}
}
async multipleInstances()
{
// Fetch/Compile the module once; reuse for each instance. This is also if the page wants to
// initiate the .wasm file download fetch as early as possible, before the browser has
// finished fetching and parsing testapp.js and qtloader.js
const modulePromise = WebAssembly.compileStreaming(fetch('tst_qtloader_integration.wasm'));
const instances = await Promise.all([1, 2, 3].map(i => qtLoad({
qt: {
entryFunction: createQtAppInstance,
containerElements: [this.#addScreenContainer(`screen-container-${i}`, {
width: `${i * 10}px`,
height: `${i * 10}px`,
})],
modulePromise,
}
})));
// Confirm the identity of instances by querying their screen widths and heights
{
const screenInformation = this.#getScreenInformation(instances[0]);
console.log();
assert.equal(1, screenInformation.length);
assert.equal(10, screenInformation[0].width);
assert.equal(10, screenInformation[0].height);
}
{
const screenInformation = this.#getScreenInformation(instances[1]);
assert.equal(1, screenInformation.length);
assert.equal(20, screenInformation[0].width);
assert.equal(20, screenInformation[0].height);
}
{
const screenInformation = this.#getScreenInformation(instances[2]);
assert.equal(1, screenInformation.length);
assert.equal(30, screenInformation[0].width);
assert.equal(30, screenInformation[0].height);
}
}
async consoleMode()
{
// 'Console mode' for autotesting type scenarios
let accumulatedStdout = '';
const instance = await qtLoad({
arguments: ['--no-gui'],
print: output =>
{
accumulatedStdout += output;
},
qt: {
entryFunction: createQtAppInstance,
}
});
this.#callTestInstanceApi(instance, 'produceOutput');
assert.equal('Sample output!', accumulatedStdout);
}
async moduleProvided()
{
await qtLoad({
qt: {
entryFunction: createQtAppInstance,
containerElements: [this.#testScreenContainers[0]],
modulePromise: WebAssembly.compileStreaming(
await fetch('tst_qtloader_integration.wasm'))
}
});
}
async arguments()
{
const instance = await qtLoad({
arguments: ['--no-gui', 'arg1', 'other', 'yetanotherarg'],
qt: {
entryFunction: createQtAppInstance,
}
});
const args = this.#callTestInstanceApi(instance, 'retrieveArguments');
assert.equal(5, args.length);
assert.isTrue('arg1' === args[2]);
assert.equal('other', args[3]);
assert.equal('yetanotherarg', args[4]);
}
async moduleProvided_exceptionThrownInFactory()
{
let caughtException;
try {
await qtLoad({
qt: {
entryFunction: createQtAppInstance,
containerElements: [this.#testScreenContainers[0]],
modulePromise: Promise.reject(new Error('Failed to load')),
}
});
} catch (e) {
caughtException = e;
}
assert.isTrue(caughtException !== undefined);
assert.equal('Failed to load', caughtException.message);
}
async abort()
{
const onExitMock = new Mock();
const instance = await qtLoad({
arguments: ['--no-gui'],
qt: {
onExit: onExitMock,
entryFunction: createQtAppInstance,
}
});
try {
instance.crash();
} catch { }
assert.equal(1, onExitMock.calls.length);
const exitStatus = onExitMock.calls[0][0];
assert.isTrue(exitStatus.crashed);
assert.isUndefined(exitStatus.code);
assert.isNotUndefined(exitStatus.text);
}
async abortImmediately()
{
const onExitMock = new Mock();
let caughtException;
try {
await qtLoad({
arguments: ['--no-gui', '--crash-immediately'],
qt: {
onExit: onExitMock,
entryFunction: createQtAppInstance,
}
});
} catch (e) {
caughtException = e;
}
// An exception should have been thrown from load()
assert.equal('RuntimeError', caughtException.name);
assert.equal(1, onExitMock.calls.length);
const exitStatus = onExitMock.calls[0][0];
assert.isTrue(exitStatus.crashed);
assert.isUndefined(exitStatus.code);
assert.isNotUndefined(exitStatus.text);
}
async userAbortCallbackCalled()
{
const onAbortMock = new Mock();
let instance = await qtLoad({
arguments: ['--no-gui'],
onAbort: onAbortMock,
qt: {
entryFunction: createQtAppInstance,
}
});
try {
instance.crash();
} catch (e) {
// emscripten throws an 'Aborted' error here, which we ignore for the sake of the test
}
assert.equal(1, onAbortMock.calls.length);
}
async exit()
{
const onExitMock = new Mock();
let instance = await qtLoad({
arguments: ['--no-gui'],
qt: {
onExit: onExitMock,
entryFunction: createQtAppInstance,
}
});
// The module is running. onExit should not have been called.
assert.equal(0, onExitMock.calls.length);
try {
instance.exitApp();
} catch (e) {
// emscripten throws a 'Runtime error: unreachable' error here. We ignore it for the
// sake of the test.
}
assert.equal(1, onExitMock.calls.length);
const exitStatus = onExitMock.calls[0][0];
assert.isFalse(exitStatus.crashed);
assert.equal(instance.EXIT_VALUE_FROM_EXIT_APP, exitStatus.code);
assert.isNotUndefined(exitStatus.text);
}
async exitImmediately()
{
const onExitMock = new Mock();
const instance = await qtLoad({
arguments: ['--no-gui', '--exit-immediately'],
qt: {
onExit: onExitMock,
entryFunction: createQtAppInstance,
}
});
assert.equal(1, onExitMock.calls.length);
const exitStatusFromOnExit = onExitMock.calls[0][0];
assert.isFalse(exitStatusFromOnExit.crashed);
assert.equal(instance.EXIT_VALUE_IMMEDIATE_RETURN, exitStatusFromOnExit.code);
assert.isNotUndefined(exitStatusFromOnExit.text);
}
async userQuitCallbackCalled()
{
const quitMock = new Mock();
let instance = await qtLoad({
arguments: ['--no-gui'],
quit: quitMock,
qt: {
entryFunction: createQtAppInstance,
}
});
try {
instance.exitApp();
} catch (e) {
// emscripten throws a 'Runtime error: unreachable' error here. We ignore it for the
// sake of the test.
}
assert.equal(1, quitMock.calls.length);
const [exitCode, exception] = quitMock.calls[0];
assert.equal(instance.EXIT_VALUE_FROM_EXIT_APP, exitCode);
assert.equal('ExitStatus', exception.name);
}
#callTestInstanceApi(instance, apiName)
{
return eval(instance[apiName]());
}
#getScreenInformation(instance)
{
return this.#callTestInstanceApi(instance, 'screenInformation').map(elem => ({
x: elem[0],
y: elem[1],
width: elem[2],
height: elem[3],
}));
}
#addScreenContainer(id, style, inserter)
{
const container = (() =>
{
const container = document.createElement('div');
container.id = id;
container.style.width = style.width;
container.style.height = style.height;
document.body.appendChild(container);
return container;
})();
inserter ? inserter(container) : this.#testScreenContainers.push(container);
return container;
}
}
(async () =>
{
const runner = new TestRunner(new QtLoaderIntegrationTests(), {
timeoutSeconds: 10
});
await runner.runAll();
})();

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en-us">
<head>
<title>tst_qtloader_integration</title>
<script src='tst_qtloader_integration.js'></script>
<script src="qtloader.js" defer></script>
<script type="module" src="test_body.js" defer></script>
</head>
<body></body>
</html>

View File

@ -1,6 +1,71 @@
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
export class assert
{
static isFalse(value)
{
if (value !== false)
throw new Error(`Assertion failed, expected to be false, was ${value}`);
}
static isTrue(value)
{
if (value !== true)
throw new Error(`Assertion failed, expected to be true, was ${value}`);
}
static isUndefined(value)
{
if (typeof value !== 'undefined')
throw new Error(`Assertion failed, expected to be undefined, was ${value}`);
}
static isNotUndefined(value)
{
if (typeof value === 'undefined')
throw new Error(`Assertion failed, expected not to be undefined, was ${value}`);
}
static equal(expected, actual)
{
if (expected !== actual)
throw new Error(`Assertion failed, expected to be ${expected}, was ${actual}`);
}
static notEqual(expected, actual)
{
if (expected === actual)
throw new Error(`Assertion failed, expected not to be ${expected}`);
}
}
export class Mock extends Function
{
#calls = [];
constructor()
{
super()
const proxy = new Proxy(this, {
apply: (target, _, args) => target.onCall(...args)
});
proxy.thisMock = this;
return proxy;
}
get calls()
{
return this.thisMock.#calls;
}
onCall(...args)
{
this.#calls.push(args);
}
}
function output(message)
{
const outputLine = document.createElement('div');
@ -15,10 +80,12 @@ function output(message)
export class TestRunner
{
#testClassInstance
#timeoutSeconds
constructor(testClassInstance)
constructor(testClassInstance, config)
{
this.#testClassInstance = testClassInstance;
this.#timeoutSeconds = config?.timeoutSeconds ?? 2;
}
async run(testCase)
@ -39,8 +106,8 @@ export class TestRunner
const timeout = window.setTimeout(() =>
{
rejected = true;
reject(new Error('Timeout after 2 seconds'));
}, 2000);
reject(new Error(`Timeout after ${this.#timeoutSeconds} seconds`));
}, this.#timeoutSeconds * 1000);
prototype[testCase].apply(this.#testClassInstance).then(() =>
{
if (!rejected) {