From f5e52d209eb8e65a0c316a7fa50928111612d64d Mon Sep 17 00:00:00 2001 From: Mikolaj Boc Date: Thu, 23 Feb 2023 10:14:23 +0100 Subject: [PATCH] Modernize the qtloader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (cherry picked from commit b9491daad0ed1c4b9c74e0c3b23f87eb7ad4f37d) --- src/plugins/platforms/wasm/qtloader.js | 684 +++--------------- .../platforms/wasm/qwasmintegration.cpp | 104 ++- src/plugins/platforms/wasm/qwasmintegration.h | 12 +- src/plugins/platforms/wasm/wasm_shell.html | 74 +- .../wasm/qstdweb/qwasmcompositor_main.cpp | 10 +- .../wasm/qtloader_integration/CMakeLists.txt | 39 + .../manual/wasm/qtloader_integration/main.cpp | 155 ++++ .../wasm/qtloader_integration/test_body.js | 445 ++++++++++++ .../tst_qtloader_integration.html | 13 + tests/manual/wasm/shared/testrunner.js | 73 +- 10 files changed, 974 insertions(+), 635 deletions(-) create mode 100644 tests/manual/wasm/qtloader_integration/CMakeLists.txt create mode 100644 tests/manual/wasm/qtloader_integration/main.cpp create mode 100644 tests/manual/wasm/qtloader_integration/test_body.js create mode 100644 tests/manual/wasm/qtloader_integration/tst_qtloader_integration.html diff --git a/src/plugins/platforms/wasm/qtloader.js b/src/plugins/platforms/wasm/qtloader.js index e0f78646274..8ec4cb0ca49 100644 --- a/src/plugins/platforms/wasm/qtloader.js +++ b/src/plugins/platforms/wasm/qtloader.js @@ -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 : -// 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 : -// Restart attempts limit. The default is 10. -// stdoutEnabled : -// stderrEnabled : -// environment : -// 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 + * 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 = "

" + loadingState + "

"; - 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 = ` ${crashSymbols[symbolIndex]} ` - 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; } diff --git a/src/plugins/platforms/wasm/qwasmintegration.cpp b/src/plugins/platforms/wasm/qwasmintegration.cpp index 5dc96e0f3c4..c8f5072cb83 100644 --- a/src/plugins/platforms/wasm/qwasmintegration.cpp +++ b/src/plugins/platforms/wasm/qwasmintegration.cpp @@ -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(); ++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("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 newScreens; + + QList 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(); ++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 &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 &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(); 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() diff --git a/src/plugins/platforms/wasm/qwasmintegration.h b/src/plugins/platforms/wasm/qwasmintegration.h index decf25009e6..8dfa065d9c0 100644 --- a/src/plugins/platforms/wasm/qwasmintegration.h +++ b/src/plugins/platforms/wasm/qwasmintegration.h @@ -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 m_backingStores; - QList> m_screens; + QList m_screens; mutable QWasmClipboard *m_clipboard; mutable QWasmAccessibility *m_accessibility; diff --git a/src/plugins/platforms/wasm/wasm_shell.html b/src/plugins/platforms/wasm/wasm_shell.html index aaa121981db..89666776da0 100644 --- a/src/plugins/platforms/wasm/wasm_shell.html +++ b/src/plugins/platforms/wasm/wasm_shell.html @@ -27,42 +27,48 @@

- + diff --git a/tests/manual/wasm/qstdweb/qwasmcompositor_main.cpp b/tests/manual/wasm/qstdweb/qwasmcompositor_main.cpp index 6866fe229f6..df6df5f4533 100644 --- a/tests/manual/wasm/qstdweb/qwasmcompositor_main.cpp +++ b/tests/manual/wasm/qstdweb/qwasmcompositor_main.cpp @@ -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 diff --git a/tests/manual/wasm/qtloader_integration/CMakeLists.txt b/tests/manual/wasm/qtloader_integration/CMakeLists.txt new file mode 100644 index 00000000000..24c5607cd88 --- /dev/null +++ b/tests/manual/wasm/qtloader_integration/CMakeLists.txt @@ -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) diff --git a/tests/manual/wasm/qtloader_integration/main.cpp b/tests/manual/wasm/qtloader_integration/main.cpp new file mode 100644 index 00000000000..ed8a57bcc6e --- /dev/null +++ b/tests/manual/wasm/qtloader_integration/main.cpp @@ -0,0 +1,155 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause +#include + +#include +#include + +#include +#include +#include + +#include + +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("Qt Loader integration tests"); + + m_ui.setLayout(m_layout); + } + + void show() { m_ui.show(); } + + ~AppWindow() = default; + +private: + template + T *addWidget(Args... args) + { + T *widget = new T(std::forward(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" diff --git a/tests/manual/wasm/qtloader_integration/test_body.js b/tests/manual/wasm/qtloader_integration/test_body.js new file mode 100644 index 00000000000..f23db3a978a --- /dev/null +++ b/tests/manual/wasm/qtloader_integration/test_body.js @@ -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(); +})(); diff --git a/tests/manual/wasm/qtloader_integration/tst_qtloader_integration.html b/tests/manual/wasm/qtloader_integration/tst_qtloader_integration.html new file mode 100644 index 00000000000..7aa7528a1db --- /dev/null +++ b/tests/manual/wasm/qtloader_integration/tst_qtloader_integration.html @@ -0,0 +1,13 @@ + + + + + tst_qtloader_integration + + + + + + + + diff --git a/tests/manual/wasm/shared/testrunner.js b/tests/manual/wasm/shared/testrunner.js index da87026b03c..82259b0620b 100644 --- a/tests/manual/wasm/shared/testrunner.js +++ b/tests/manual/wasm/shared/testrunner.js @@ -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) {