diff --git a/core/src/layout.hpp b/core/src/layout.hpp new file mode 100644 index 0000000000..2e3d2833b4 --- /dev/null +++ b/core/src/layout.hpp @@ -0,0 +1,185 @@ +/* + * Keyman is copyright (C) SIL International. MIT License. + * + * Keyman Keyboard Processor API - On-Screen Keyboard Layout Interfaces + */ + +#pragma once + +#include +#include +#include + +#include "keyman_core_api.h" + +#if defined(__cplusplus) +extern "C" { +#endif + +/** + * Possible directions of a flick + */ +enum keyboard_layout_flick_direction { + /** flick up (north) */ + n = 0, + /** flick down (south) */ + s = 1, + /** flick right (east) */ + e = 2, + /** flick left (west) */ + w = 3, + /** flick up-right (north-east) */ + ne = 4, + /** flick up-left (north-west) */ + nw = 5, + /** flick down-right (south-east) */ + se = 6, + /** flick down-left (south-west) */ + sw = 7 +}; + +/** + * key type like regular key, framekeys, deadkeys, blank, etc. + */ +enum keyboard_layout_key_type { + /** regular key */ + normal = 0, + /** A 'frame' key, such as Shift or Enter */ + special = 1, + /** A 'frame' key, such as Shift or Enter, which is is active, such as + * the shift key on a shift layer */ + specialActive = 2, + /** **KeymanWeb runtime private use:** a variant of `special` with the + * keyboard font rather than 'KeymanwebOsk' font */ + customSpecial = 3, + /** **KeymanWeb runtime private use:** a variant of `specialActive` with the + * keyboard font rather than 'KeymanwebOsk' font. */ + customSpecialActive = 4, + /** A deadkey */ + deadkey = 8, + /** A key which is rendered as a blank keycap, should block any interaction */ + blank = 9, + /** Renders the key only as a gap or spacer, should block any interaction */ + spacer = 10 +}; + +/** + * A key on a touch layout/on-screen keyboard + */ +struct keyboard_layout_key { + /** key id */ + std::u16string id; // TODO-WEB-CORE: perhaps necessary for special keys, Enter, etc? or can we get that from virtualKey? + /** the virtual key code */ + int virtualKey; // TODO-WEB-CORE: do we need this? both id and virtualKey? Or just one of them? + /** text to display on key cap */ + std::u16string display; + /** hint e.g. for longpress */ + std::u16string hint; + /** the type of key */ + keyboard_layout_key_type type; + + /** + * the modifier combination (not layer) that should be used in key events, + * for this key, overriding the layer that the key is a part of. + */ + int modifiersOverride; + /** the next layer to switch to after this key is pressed */ + std::u16string nextLayerId; + + // touch layouts only + + /** padding - space to the left of key (in what units?) */ + int gap; + /** width of the key (in what units?) */ + int width; + + /** longpress keys, also known as subkeys */ + std::vector longpresses; + /** multitaps */ + std::vector multiTaps; + /** flicks */ + std::map flicks; +}; + +/** + * a row of keys on a touch layout/on-screen keyboard + */ +struct keyboard_layout_row { + /** row id */ + int id; // TODO-WEB-CORE: do we need this? Web has it (`TouchLayoutRow`) + /** keys in this row */ + std::vector keys; +}; + +/** + * a layer with rows of keys on a touch layout/on-screen keyboard + */ +struct keyboard_layout_layer { + /** layer id */ + std::u16string id; + /** layer modifiers */ + // TODO-WEB-CORE: we added this during our discussion, but Web doesn't have it. + // Should be an enum if it's needed. + int modifiers; //? 0 = default, n = shift, etc. -1 = unspecified? + /** rows in this layer */ + std::vector rows; +}; + +/** + * layout specification for a specific platform like desktop, phone or tablet + */ +struct keyboard_layout_platform { + /** platform form factor, e.g. 'iso', 'touch', 'ansi', ... (see ldml spec) */ + std::u16string form; + /** width of screen for touch layout */ + int minWidthMm; // we don't have mobile vs tablet, instead use this + /** layers for this platform */ + std::vector layers; + + // TODO-WEB-CORE: Do we need these: + // Web additionally has: + // - font (should be in CSS; we have it in `keyboard_layout`) + // - fontsize (should be in CSS; we have it in `keyboard_layout`) + // - displayUnderlying + // - defaultHint ("none"|"dot"|"longpress"|"multitap"|"flick"|"flick-n"|"flick-ne"| + // "flick-e"|"flick-se"|"flick-s"|"flick-sw"|"flick-w"|"flick-nw") +}; + +/** + * On screen keyboard description consisting of specific layouts for different + * form factors. + */ +struct keyboard_layout { + /** layouts for different form factors */ + std::vector platforms; + /** font face name to use for key caps*/ + std::string fontFacename; + /** font size to use for key caps */ + int fontSizeEm; // TODO-WEB-CORE: em? px? something else? +}; + + +/** + * Get the on-screen keyboard layout for the specified keyboard. + * + * @param keyboard [in] The keyboard to get the layout for. + * @param layout [out] The on-screen keyboard layout. + * @return km_core_status `KM_CORE_STATUS_OK`: On success. + * `KM_CORE_STATUS_INVALID_ARGUMENT`: If `keyboard` is not a valid keyboard or `layout` is null. + */ +km_core_status +keyboard_get_layout( + km_core_keyboard const* keyboard, + keyboard_layout** layout +); + + +/** + * Dispose the on-screen keyboard layout. + */ +void +keyboard_layout_dispose(keyboard_layout* layout); + +#if defined(__cplusplus) +} +#endif diff --git a/core/src/meson.build b/core/src/meson.build index 449dd3644f..030ab0a514 100644 --- a/core/src/meson.build +++ b/core/src/meson.build @@ -122,6 +122,7 @@ api_files = files( 'km_core_state_api.cpp', 'km_core_debug_api.cpp', 'km_core_processevent_api.cpp', + 'wasm.cpp', ) core_files = files( @@ -137,6 +138,16 @@ mock_files = files( 'mock/mock_processor.cpp', ) +if cpp_compiler.get_id() == 'emscripten' + host_links = ['--whole-archive', '-sALLOW_MEMORY_GROWTH=1', '-sMODULARIZE=1', + '-sEXPORT_ES6', '-sENVIRONMENT=web,webview', + '--emit-tsd', 'km-core-interface.d.ts', '-sERROR_ON_UNDEFINED_SYMBOLS=0'] + + links += ['-sEXPORTED_RUNTIME_METHODS=[\'UTF8ToString\',\'stringToNewUTF8\',\'wasmExports\']', + # Forcing inclusion of debug symbols + '-g', '-Wlimited-postlink-optimizations', '--bind'] +endif + lib = library('keymancore', api_files, core_files, @@ -164,3 +175,24 @@ pkg.generate( description: 'Keyman processor for KMN keyboards.', subdirs: headerdirs, libraries: lib) + +if cpp_compiler.get_id() == 'emscripten' + # Build an executable + host = executable('km-core', + cpp_args: defns, + include_directories: inc, + link_args: links + host_links, + objects: lib.extract_all_objects(recursive: false)) + + if get_option('buildtype') == 'release' + # TODO: #12888 + # Split debug symbols into separate wasm file for release builds only + # as the release symbols will be uploaded to sentry + # custom_target('core.wasm', + # depends: host, + # input: host, + # output: 'core.wasm', + # command: ['wasm-split', '@OUTDIR@/core.wasm', '-o', '@OUTPUT@', '--strip', '--debug-out=@OUTDIR@/core.debug.wasm'], + # build_by_default: true) + endif +endif diff --git a/core/src/wasm.cpp b/core/src/wasm.cpp new file mode 100644 index 0000000000..90fc85cda8 --- /dev/null +++ b/core/src/wasm.cpp @@ -0,0 +1,44 @@ +#ifdef __EMSCRIPTEN__ +#ifdef __EMSCRIPTEN__ +#include +#include + +#else +#define EMSCRIPTEN_KEEPALIVE +#endif + +#ifdef __cplusplus +#define EXTERN extern "C" EMSCRIPTEN_KEEPALIVE +#else +#define EXTERN EMSCRIPTEN_KEEPALIVE +#endif + +#include + +constexpr km_core_attr const engine_attrs = { + 256, + KM_CORE_LIB_CURRENT, + KM_CORE_LIB_AGE, + KM_CORE_LIB_REVISION, + KM_CORE_TECH_KMX, + "SIL International" +}; + +EMSCRIPTEN_KEEPALIVE km_core_attr const & tmp_wasm_attributes() { + return engine_attrs; +} + +EMSCRIPTEN_BINDINGS(core_interface) { + + emscripten::value_object("km_core_attr") + .field("max_context", &km_core_attr::max_context) + .field("current", &km_core_attr::current) + .field("revision", &km_core_attr::revision) + .field("age", &km_core_attr::age) + .field("technology", &km_core_attr::technology) + //.field("vendor", &km_core_attr::vendor, emscripten::allow_raw_pointers()) + ; + + emscripten::function("tmp_wasm_attributes", &tmp_wasm_attributes); +} +#endif diff --git a/core/tests/meson.build b/core/tests/meson.build index ce0f68a257..1f686c260d 100644 --- a/core/tests/meson.build +++ b/core/tests/meson.build @@ -10,10 +10,7 @@ cmpfiles = ['-c', 'import sys; a = open(sys.argv[1], \'r\').read(); b = open(sys.argv[2], \'r\').read(); sys.exit(not (a==b))'] stnds = join_paths(meson.current_source_dir(), 'standards') -libsrc = include_directories( - '../src', - '../../common/include' -) +libsrc = include_directories('../src') # kmx_test_source is required for linux builds, so always enable it even when we # disable all other tests diff --git a/core/tests/unit/json/meson.build b/core/tests/unit/json/meson.build index 6289d7db0c..aaed69104e 100644 --- a/core/tests/unit/json/meson.build +++ b/core/tests/unit/json/meson.build @@ -11,7 +11,7 @@ else endif e = executable('jsontest', 'jsontest.cpp', - include_directories: [libsrc], + include_directories: [inc, libsrc], link_args: links + tests_flags, objects: lib.extract_objects('jsonpp.cpp')) test('jsontest', e, args: 'jsontest.json') diff --git a/core/tests/unit/utftest/meson.build b/core/tests/unit/utftest/meson.build index c82b6f98c5..70253739b3 100644 --- a/core/tests/unit/utftest/meson.build +++ b/core/tests/unit/utftest/meson.build @@ -6,5 +6,5 @@ e = executable('utftest', 'utftest.tests.cpp', objects: lib.extract_objects('../../common/cpp/utfcodec.cpp'), - include_directories: [libsrc]) + include_directories: [inc, libsrc]) test('utftest', e) diff --git a/docs/minimum-versions.md b/docs/minimum-versions.md index d30b1e858a..5fb63a2b3d 100644 --- a/docs/minimum-versions.md +++ b/docs/minimum-versions.md @@ -61,7 +61,7 @@ https://help.keyman.com/developer/engine/android/latest-version/ | KEYMAN_MIN_TARGET_VERSION_WINDOWS | 10 | | KEYMAN_MIN_VERSION_ANDROID_SDK | 21 | | KEYMAN_MIN_VERSION_CPP | 17 | -| KEYMAN_MIN_VERSION_EMSCRIPTEN | 3.1.58 | +| KEYMAN_MIN_VERSION_EMSCRIPTEN | 3.1.64 | | KEYMAN_MIN_VERSION_MESON | 1.0.0 | | KEYMAN_MIN_VERSION_NODE_MAJOR | 20 | | KEYMAN_MIN_VERSION_NPM | 10.5.1 | diff --git a/package-lock.json b/package-lock.json index efc2a0557b..808dbc5257 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8763,6 +8763,7 @@ "version": "4.20.0", "resolved": "https://registry.npmjs.org/express/-/express-4.20.0.tgz", "integrity": "sha512-pLdae7I6QqShF5PnNTCVn4hI91Dx0Grkn2+IAsMTgMIKuQVte2dN9PeGSSAME2FR8anOhVA62QDIUaWVfEXVLw==", + "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -15649,6 +15650,7 @@ "@sentry/cli": "^2.31.0", "@zip.js/zip.js": "^2.7.32", "c8": "^7.12.0", + "express": "^4.19.2", "jsdom": "^23.0.1", "mocha": "^10.0.0", "tsx": "^4.19.0" diff --git a/resources/build/minimum-versions.inc.sh b/resources/build/minimum-versions.inc.sh index 198495f0cc..9b303c3105 100644 --- a/resources/build/minimum-versions.inc.sh +++ b/resources/build/minimum-versions.inc.sh @@ -27,7 +27,7 @@ KEYMAN_MIN_TARGET_VERSION_WEB_SAFARI=13.0 # iOS 13.0, macOS 10.13.6+ # Dependency versions KEYMAN_MIN_VERSION_NODE_MAJOR=20 # node version source of truth is /package.json:/engines/node; use KEYMAN_USE_NVM to automatically update KEYMAN_MIN_VERSION_NPM=10.5.1 # 10.5.0 has bug, discussed in #10350 -KEYMAN_MIN_VERSION_EMSCRIPTEN=3.1.58 # Use KEYMAN_USE_EMSDK to automatically update to this version +KEYMAN_MIN_VERSION_EMSCRIPTEN=3.1.64 # Use KEYMAN_USE_EMSDK to automatically update to this version KEYMAN_MIN_VERSION_VISUAL_STUDIO=2019 KEYMAN_MIN_VERSION_MESON=1.0.0 diff --git a/web/README.md b/web/README.md index 7f7415c698..5417309067 100644 --- a/web/README.md +++ b/web/README.md @@ -29,8 +29,9 @@ src/test/auto A Node-driven test suite for automated testing of Key ## Usage -Open **index.html** or **samples/index.html** in your browser. Be sure to -compile Keyman Engine for Web before viewing the pages. +Start the test server by running `./build.sh start`, then open +your browser to http://localhost:3000. Be sure to compile Keyman Engine +for Web before viewing the pages. Refer to the samples for usage details. diff --git a/web/build.sh b/web/build.sh index d01d493517..1fb06436e3 100755 --- a/web/build.sh +++ b/web/build.sh @@ -20,12 +20,14 @@ builder_describe "Builds engine modules for Keyman Engine for Web (KMW)." \ "clean" \ "configure" \ "build" \ + "start Starts the test server" \ "test" \ "coverage Create an HTML page with code coverage" \ ":app/browser The form of Keyman Engine for Web for use on websites" \ ":app/webview A puppetable version of KMW designed for use in a host app's WebView" \ ":app/ui Builds KMW's desktop form-factor keyboard-selection UI modules" \ ":engine/attachment Subset used for detecting valid page contexts for use in text editing " \ + ":engine/core-processor Keyman Core WASM integration" \ ":engine/dom-utils A common subset of function used for DOM calculations, layout, etc" \ ":engine/events Specialized classes utilized to support KMW API events" \ ":engine/element-wrappers Subset used to integrate with website elements" \ @@ -60,6 +62,7 @@ builder_describe_outputs \ build:app/webview "/web/build/app/webview/${config}/keymanweb-webview.js" \ build:app/ui "/web/build/app/ui/${config}/kmwuitoggle.js" \ build:engine/attachment "/web/build/engine/attachment/lib/index.mjs" \ + build:engine/core-processor "/web/build/engine/core-processor/lib/index.mjs" \ build:engine/dom-utils "/web/build/engine/dom-utils/obj/index.js" \ build:engine/events "/web/build/engine/events/lib/index.mjs" \ build:engine/element-wrappers "/web/build/engine/element-wrappers/lib/index.mjs" \ @@ -166,6 +169,8 @@ builder_run_child_actions build:engine/attachment # Uses engine/interfaces (due to resource-path config interface) builder_run_child_actions build:engine/keyboard-storage +builder_run_child_actions build:engine/core-processor + # Uses engine/interfaces, engine/keyboard-storage, & engine/osk builder_run_child_actions build:engine/main @@ -201,3 +206,6 @@ builder_run_action test:help do_test_help # Create coverage report builder_run_action coverage:_all coverage_action + +# Start the test server +builder_run_action start node src/tools/testing/test-server/index.cjs diff --git a/web/package.json b/web/package.json index f30a0b4218..85d1057e4c 100644 --- a/web/package.json +++ b/web/package.json @@ -12,10 +12,10 @@ "types": "./build/engine/attachment/obj/index.d.ts", "import": "./build/engine/attachment/obj/index.js" }, - "./engine/interfaces": { - "es6-bundling": "./src/engine/interfaces/src/index.ts", - "types": "./build/engine/interfaces/obj/index.d.ts", - "import": "./build/engine/interfaces/obj/index.js" + "./engine/core-processor": { + "es6-bundling": "./src/engine/core-processor/src/index.ts", + "types": "./build/engine/core-processor/obj/index.d.ts", + "import": "./build/engine/core-processor/obj/index.js" }, "./engine/dom-utils": { "es6-bundling": "./src/engine/dom-utils/src/index.ts", @@ -32,6 +32,11 @@ "types": "./build/engine/events/obj/index.d.ts", "import": "./build/engine/events/obj/index.js" }, + "./engine/interfaces": { + "es6-bundling": "./src/engine/interfaces/src/index.ts", + "types": "./build/engine/interfaces/obj/index.d.ts", + "import": "./build/engine/interfaces/obj/index.js" + }, "./engine/js-processor": { "es6-bundling": "./src/engine/js-processor/src/index.ts", "types": "./build/engine/js-processor/obj/index.d.ts", @@ -81,6 +86,10 @@ "./engine/osk/internals": { "types": "./build/engine/osk/obj/test-index.d.ts", "import": "./build/engine/osk/obj/test-index.js" + }, + "./tools/testing/test-utils": { + "types": "./build/tools/testing/test-utils/obj/index.d.ts", + "import": "./build/tools/testing/test-utils/obj/index.js" } }, "imports": { @@ -108,6 +117,7 @@ "@sentry/cli": "^2.31.0", "@zip.js/zip.js": "^2.7.32", "c8": "^7.12.0", + "express": "^4.19.2", "jsdom": "^23.0.1", "mocha": "^10.0.0", "tsx": "^4.19.0" diff --git a/web/src/app/browser/build.sh b/web/src/app/browser/build.sh index d4c1023314..538ba82333 100755 --- a/web/src/app/browser/build.sh +++ b/web/src/app/browser/build.sh @@ -72,6 +72,10 @@ compile_and_copy() { mkdir -p "$KEYMAN_ROOT/web/build/app/resources/osk" cp -R "$KEYMAN_ROOT/web/src/resources/osk/." "$KEYMAN_ROOT/web/build/app/resources/osk/" + # Copy Keyman Core build artifacts for local reference + cp "${KEYMAN_ROOT}/web/build/engine/core-processor/obj/import/core/"km-core.{js,wasm} "${KEYMAN_ROOT}/web/build/app/browser/debug/" + cp "${KEYMAN_ROOT}/web/build/engine/core-processor/obj/import/core/"km-core.{js,wasm} "${KEYMAN_ROOT}/web/build/app/browser/release/" + # Update the build/publish copy of our build artifacts prepare diff --git a/web/src/app/webview/build.sh b/web/src/app/webview/build.sh index 371c9325f2..85ae08204c 100755 --- a/web/src/app/webview/build.sh +++ b/web/src/app/webview/build.sh @@ -60,8 +60,16 @@ compile_and_copy() { mkdir -p "$KEYMAN_ROOT/web/build/app/resources/osk" cp -R "$KEYMAN_ROOT/web/src/resources/osk/." "$KEYMAN_ROOT/web/build/app/resources/osk/" + # Copy Keyman Core build artifacts for local reference + cp "${KEYMAN_ROOT}/web/build/engine/core-processor/obj/import/core/"km-core.{js,wasm} "${KEYMAN_ROOT}/web/build/app/webview/debug/" + cp "${KEYMAN_ROOT}/web/build/engine/core-processor/obj/import/core/"km-core.{js,wasm} "${KEYMAN_ROOT}/web/build/app/webview/release/" + # Clean the sourcemaps of .. and . components for script in "$KEYMAN_ROOT/web/build/$SUBPROJECT_NAME/debug/"*.js; do + if [[ "${script}" == *"/km-core.js" ]]; then + continue + fi + sourcemap="$script.map" node "$KEYMAN_ROOT/web/build/tools/building/sourcemap-root/index.js" \ "$script" "$sourcemap" --clean --inline @@ -70,6 +78,9 @@ compile_and_copy() { # Do NOT inline sourcemaps for release builds - we don't want them to affect # load time. for script in "$KEYMAN_ROOT/web/build/$SUBPROJECT_NAME/release/"*.js; do + if [[ "${script}" == *"/km-core.js" ]]; then + continue + fi sourcemap="$script.map" node "$KEYMAN_ROOT/web/build/tools/building/sourcemap-root/index.js" \ "$script" "$sourcemap" --clean diff --git a/web/src/engine/core-processor/.gitignore b/web/src/engine/core-processor/.gitignore new file mode 100644 index 0000000000..282f91762f --- /dev/null +++ b/web/src/engine/core-processor/.gitignore @@ -0,0 +1 @@ +src/import/ \ No newline at end of file diff --git a/web/src/engine/core-processor/build.sh b/web/src/engine/core-processor/build.sh new file mode 100755 index 0000000000..072e5a89f3 --- /dev/null +++ b/web/src/engine/core-processor/build.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash + +## START STANDARD BUILD SCRIPT INCLUDE +# adjust relative paths as necessary +THIS_SCRIPT="$(readlink -f "${BASH_SOURCE[0]}")" +. "${THIS_SCRIPT%/*}/../../../../resources/build/builder.inc.sh" +## END STANDARD BUILD SCRIPT INCLUDE + +SUBPROJECT_NAME=engine/core-processor + +. "${KEYMAN_ROOT}/web/common.inc.sh" +. "${KEYMAN_ROOT}/resources/shellHelperFunctions.sh" + +# ################################ Main script ################################ + +builder_describe "Keyman Core WASM integration" \ + "@/core:wasm" \ + "@/web/src/engine/common/web-utils" \ + "clean" \ + "configure" \ + "build" \ + "test" \ + "--ci+ Set to utilize CI-based test configurations & reporting." + +builder_describe_outputs \ + configure "/web/src/engine/core-processor/src/import/core/km-core-interface.d.ts" \ + build "/web/build/${SUBPROJECT_NAME}/lib/index.mjs" + +builder_parse "$@" + +#### Build action definitions #### + +do_clean() { + rm -rf "${KEYMAN_ROOT}/web/build/${SUBPROJECT_NAME}" + rm -rf "src/import/" +} + +do_configure() { + verify_npm_setup + + mkdir -p "src/import/core/" + # we don't need this file for release builds, but it's nice to have + # for reference and auto-completion + cp "${KEYMAN_ROOT}/core/build/wasm/${BUILDER_CONFIGURATION}/src/km-core-interface.d.ts" "src/import/core/" +} + +copy_deps() { + mkdir -p "${KEYMAN_ROOT}/web/build/${SUBPROJECT_NAME}/obj/import/core/" + cp "${KEYMAN_ROOT}/core/build/wasm/${BUILDER_CONFIGURATION}/src/"km-core-interface.d.ts "${KEYMAN_ROOT}/web/build/${SUBPROJECT_NAME}/obj/import/core/" + cp "${KEYMAN_ROOT}/core/build/wasm/${BUILDER_CONFIGURATION}/src/"km-core{.js,.wasm} "${KEYMAN_ROOT}/web/build/${SUBPROJECT_NAME}/obj/import/core/" +} + +do_build () { + copy_deps + compile "${SUBPROJECT_NAME}" + + ${BUNDLE_CMD} "${KEYMAN_ROOT}/web/build/${SUBPROJECT_NAME}/obj/index.js" \ + --out "${KEYMAN_ROOT}/web/build/${SUBPROJECT_NAME}/lib/index.mjs" \ + --format esm +} + +builder_run_action clean do_clean +builder_run_action configure do_configure +builder_run_action build do_build diff --git a/web/src/engine/core-processor/src/core-processor.ts b/web/src/engine/core-processor/src/core-processor.ts new file mode 100644 index 0000000000..e1584ad1ea --- /dev/null +++ b/web/src/engine/core-processor/src/core-processor.ts @@ -0,0 +1,30 @@ +type km_core_attr = import('./import/core/km-core-interface.js').km_core_attr; + +export class CoreProcessor { + private instance: any; + + /** + * Initialize Core Processor + * @param baseurl - The url where km-core.js is located + */ + public async init(baseurl: string): Promise { + + if (!this.instance) { + try { + const module = await import(baseurl + '/km-core.js'); + this.instance = await module.default({ + locateFile: function (path: string, scriptDirectory: string) { + return baseurl + '/' + path; + } + }); + } catch (e: any) { + return false; + } + } + return !!this.instance; + }; + + public tmp_wasm_attributes(): km_core_attr { + return this.instance.tmp_wasm_attributes(); + } +} diff --git a/web/src/engine/core-processor/src/index.ts b/web/src/engine/core-processor/src/index.ts new file mode 100644 index 0000000000..06f040ce53 --- /dev/null +++ b/web/src/engine/core-processor/src/index.ts @@ -0,0 +1 @@ +export * from './core-processor.js'; \ No newline at end of file diff --git a/web/src/engine/core-processor/tsconfig.json b/web/src/engine/core-processor/tsconfig.json new file mode 100644 index 0000000000..a7b626029f --- /dev/null +++ b/web/src/engine/core-processor/tsconfig.json @@ -0,0 +1,13 @@ +{ + // While the actual references themselves are headless, it compiles against the DOM-reliant OSK module. + "extends": "../../tsconfig.dom.json", + + "compilerOptions": { + "baseUrl": "./", + "outDir": "../../../build/engine/core-processor/obj/", + "tsBuildInfoFile": "../../../build/engine/core-processor/obj/tsconfig.tsbuildinfo", + "rootDir": "./src" + }, + + "include": [ "**/*.ts", "src/import/core/km-core.js" ], +} diff --git a/web/src/engine/interfaces/src/pathConfiguration.ts b/web/src/engine/interfaces/src/pathConfiguration.ts index 0eff8f65f7..18080374e4 100644 --- a/web/src/engine/interfaces/src/pathConfiguration.ts +++ b/web/src/engine/interfaces/src/pathConfiguration.ts @@ -106,6 +106,10 @@ export default class PathConfiguration implements OSKResourcePathConfiguration { return this._root; } + get basePath(): string { + return this.sourcePath; + } + get resources(): string { return this._resources; } diff --git a/web/src/engine/js-processor/build.sh b/web/src/engine/js-processor/build.sh index db00593166..34546a913b 100755 --- a/web/src/engine/js-processor/build.sh +++ b/web/src/engine/js-processor/build.sh @@ -36,7 +36,12 @@ do_build () { --format esm } +do_test() { + test-headless "${SUBPROJECT_NAME}" "" + test-headless-typescript "${SUBPROJECT_NAME}" +} + builder_run_action configure verify_npm_setup builder_run_action clean rm -rf "${KEYMAN_ROOT}/web/build/${SUBPROJECT_NAME}" builder_run_action build do_build -builder_run_action test test-headless "${SUBPROJECT_NAME}" "" +builder_run_action test do_test diff --git a/web/src/engine/keyboard/build.sh b/web/src/engine/keyboard/build.sh index fd15e59bc1..6390243b9a 100755 --- a/web/src/engine/keyboard/build.sh +++ b/web/src/engine/keyboard/build.sh @@ -44,7 +44,7 @@ function do_configure() { BUILD_DIR="${KEYMAN_ROOT}/web/build/${SUBPROJECT_NAME}" -function do_build() { +do_build() { tsc --build "${THIS_SCRIPT_PATH}/tsconfig.all.json" # Base product - the main keyboard processor @@ -73,7 +73,12 @@ function do_build() { tsc --emitDeclarationOnly --outFile "${BUILD_DIR}/lib/node-keyboard-loader.d.ts" -p src/keyboards/loaders/tsconfig.node.json } +do_test() { + test-headless "${SUBPROJECT_NAME}" "" + test-headless-typescript "${SUBPROJECT_NAME}" +} + builder_run_action configure do_configure builder_run_action clean rm -rf "${BUILD_DIR}" builder_run_action build do_build -builder_run_action test test-headless "${SUBPROJECT_NAME}" "" +builder_run_action test do_test diff --git a/web/src/engine/keyboard/src/index.ts b/web/src/engine/keyboard/src/index.ts index 28b6331c21..6047426af9 100644 --- a/web/src/engine/keyboard/src/index.ts +++ b/web/src/engine/keyboard/src/index.ts @@ -3,12 +3,8 @@ export * from "./keyboards/defaultLayouts.js"; export { default as Keyboard } from "./keyboards/keyboard.js"; export * from "./keyboards/keyboard.js"; export { KeyboardHarness, KeyboardKeymanGlobal, MinimalCodesInterface, MinimalKeymanGlobal } from "./keyboards/keyboardHarness.js"; -export { - default as KeyboardLoaderBase, - KeyboardLoadErrorBuilder, - KeyboardMissingError, - KeyboardScriptError -} from "./keyboards/keyboardLoaderBase.js"; +export { KeyboardLoaderBase } from "./keyboards/keyboardLoaderBase.js"; +export { KeyboardLoadErrorBuilder, KeyboardMissingError, KeyboardScriptError, KeyboardDownloadError, InvalidKeyboardError } from './keyboards/keyboardLoadError.js' export { CloudKeyboardFont, internalizeFont, diff --git a/web/src/engine/keyboard/src/keyboards/keyboardLoadError.ts b/web/src/engine/keyboard/src/keyboards/keyboardLoadError.ts new file mode 100644 index 0000000000..b68f03d3a9 --- /dev/null +++ b/web/src/engine/keyboard/src/keyboards/keyboardLoadError.ts @@ -0,0 +1,104 @@ +import { type KeyboardStub } from './keyboardLoaderBase.js'; + +export interface KeyboardLoadErrorBuilder { + scriptError(err?: Error): void; + missingError(err: Error): void; + keyboardDownloadError(err: Error): void; + + invalidKeyboard(err: Error): void; +} + +export class KeyboardScriptError extends Error { + public readonly cause; + + constructor(msg: string, cause?: Error) { + super(msg); + this.cause = cause; + } +} + +export class KeyboardMissingError extends Error { + public readonly cause; + + constructor(msg: string, cause?: Error) { + super(msg); + this.cause = cause; + } +} + +export class KeyboardDownloadError extends Error { + public readonly cause; + + constructor(message: string, cause?: Error) { + super(message); + this.cause = cause; + } +} + +export class InvalidKeyboardError extends Error { + public readonly cause; + + constructor(message: string, cause?: Error) { + super(message); + this.cause = cause; + } +} + +export class UriBasedErrorBuilder implements KeyboardLoadErrorBuilder { + readonly uri: string; + + constructor(uri: string) { + this.uri = uri; + } + + missingError(err: Error) { + const msg = `Cannot find the keyboard at ${this.uri}.`; + return new KeyboardMissingError(msg, err); + } + + scriptError(err: Error) { + const msg = `Error registering the keyboard script at ${this.uri}; it may contain an error.`; + return new KeyboardScriptError(msg, err); + } + + keyboardDownloadError(err: Error) { + const msg = `Unable to download keyboard at ${this.uri}`; + return new KeyboardDownloadError(msg, err); + } + + invalidKeyboard(err: Error) { + const msg = `${this.uri} is not a valid keyboard file`; + return new InvalidKeyboardError(msg, err); + } +} + +export class StubBasedErrorBuilder implements KeyboardLoadErrorBuilder { + readonly stub: KeyboardStub; + + constructor(stub: KeyboardStub) { + this.stub = stub; + } + + missingError(err: Error) { + const stub = this.stub; + const msg = `Cannot find the ${stub.name} keyboard for ${stub.langName} at ${stub.filename}.`; + return new KeyboardMissingError(msg, err); + } + + scriptError(err: Error) { + const stub = this.stub; + const msg = `Error registering the ${stub.name} keyboard for ${stub.langName}; keyboard script at ${stub.filename} may contain an error.`; + return new KeyboardScriptError(msg, err); + } + + keyboardDownloadError(err: Error) { + const msg = `Unable to download ${this.stub.name} keyboard for ${this.stub.langName}`; + return new KeyboardDownloadError(msg, err); + } + + invalidKeyboard(err: Error) { + const msg = `${this.stub.name} is not a valid keyboard`; + return new InvalidKeyboardError(msg, err); + } +} + diff --git a/web/src/engine/keyboard/src/keyboards/keyboardLoaderBase.ts b/web/src/engine/keyboard/src/keyboards/keyboardLoaderBase.ts index d894eeb20c..5ca3d4c8ce 100644 --- a/web/src/engine/keyboard/src/keyboards/keyboardLoaderBase.ts +++ b/web/src/engine/keyboard/src/keyboards/keyboardLoaderBase.ts @@ -1,71 +1,11 @@ import Keyboard from "./keyboard.js"; import { KeyboardHarness } from "./keyboardHarness.js"; import KeyboardProperties from "./keyboardProperties.js"; +import { KeyboardLoadErrorBuilder, StubBasedErrorBuilder, UriBasedErrorBuilder } from './keyboardLoadError.js'; -type KeyboardStub = KeyboardProperties & { filename: string }; +export type KeyboardStub = KeyboardProperties & { filename: string }; -export interface KeyboardLoadErrorBuilder { - scriptError(err?: Error): void; - missingError(err: Error): void; -} - -export class KeyboardScriptError extends Error { - public readonly cause; - - constructor(msg: string, cause?: Error) { - super(msg); - this.cause = cause; - } -} - -export class KeyboardMissingError extends Error { - public readonly cause; - - constructor(msg: string, cause?: Error) { - super(msg); - this.cause = cause; - } -} - -class UriBasedErrorBuilder implements KeyboardLoadErrorBuilder { - readonly uri: string; - - constructor(uri: string) { - this.uri = uri; - } - - missingError(err: Error) { - const msg = `Cannot find the keyboard at ${this.uri}.`; - return new KeyboardMissingError(msg, err); - } - - scriptError(err: Error) { - const msg = `Error registering the keyboard script at ${this.uri}; it may contain an error.`; - return new KeyboardScriptError(msg, err); - } -} - -class StubBasedErrorBuilder implements KeyboardLoadErrorBuilder { - readonly stub: KeyboardStub; - - constructor(stub: KeyboardStub) { - this.stub = stub; - } - - missingError(err: Error) { - const stub = this.stub; - const msg = `Cannot find the ${stub.name} keyboard for ${stub.langName} at ${stub.filename}.`; - return new KeyboardMissingError(msg, err); - } - - scriptError(err: Error) { - const stub = this.stub; - const msg = `Error registering the ${stub.name} keyboard for ${stub.langName}; keyboard script at ${stub.filename} may contain an error.`; - return new KeyboardScriptError(msg, err); - } -} - -export default abstract class KeyboardLoaderBase { +export abstract class KeyboardLoaderBase { private _harness: KeyboardHarness; public get harness(): KeyboardHarness { @@ -76,23 +16,49 @@ export default abstract class KeyboardLoaderBase { this._harness = harness; } + /** + * Load a keyboard from a remote or local URI. + * + * @param uri The URI of the keyboard to load. + * @returns A Promise that resolves to the loaded keyboard. + */ public loadKeyboardFromPath(uri: string): Promise { this.harness.install(); - const promise = this.loadKeyboardInternal(uri, new UriBasedErrorBuilder(uri)); - - return promise; + return this.loadKeyboardInternal(uri, new UriBasedErrorBuilder(uri)); } - public loadKeyboardFromStub(stub: KeyboardStub) { + /** + * Load a keyboard from keyboard stub. + * + * @param stub The stub of the keyboard to load. + * @returns A Promise that resolves to the loaded keyboard. + */ + public async loadKeyboardFromStub(stub: KeyboardStub): Promise { this.harness.install(); - let promise = this.loadKeyboardInternal(stub.filename, new StubBasedErrorBuilder(stub), stub.id); - - return promise; + return this.loadKeyboardInternal(stub.filename, new StubBasedErrorBuilder(stub)); } - protected abstract loadKeyboardInternal( - uri: string, - errorBuilder: KeyboardLoadErrorBuilder, - id?: string - ): Promise; + private async loadKeyboardInternal(uri: string, errorBuilder: KeyboardLoadErrorBuilder): Promise { + const byteArray = await this.loadKeyboardBlob(uri, errorBuilder); + + if (byteArray.slice(0, 4) == Uint8Array.from([0x4b, 0x58, 0x54, 0x53])) { // 'KXTS' + // KMX or LDML (KMX+) keyboard + console.error("KMX keyboard loading is not yet implemented!"); + return null; + } + + let script: string; + try { + script = new TextDecoder('utf-8', { fatal: true }).decode(byteArray); + } catch (e) { + throw errorBuilder.invalidKeyboard(e); + } + + // .js keyboard + return await this.loadKeyboardFromScript(script, errorBuilder); + } + + protected abstract loadKeyboardBlob(uri: string, errorBuilder: KeyboardLoadErrorBuilder): Promise; + + protected abstract loadKeyboardFromScript(scriptSrc: string, errorBuilder: KeyboardLoadErrorBuilder): Promise; } \ No newline at end of file diff --git a/web/src/engine/keyboard/src/keyboards/loaders/domKeyboardLoader.ts b/web/src/engine/keyboard/src/keyboards/loaders/domKeyboardLoader.ts index fcb2c764cd..a6df76aff1 100644 --- a/web/src/engine/keyboard/src/keyboards/loaders/domKeyboardLoader.ts +++ b/web/src/engine/keyboard/src/keyboards/loaders/domKeyboardLoader.ts @@ -2,9 +2,10 @@ /// -import { Keyboard, KeyboardHarness, KeyboardLoaderBase, KeyboardLoadErrorBuilder, MinimalKeymanGlobal } from 'keyman/engine/keyboard'; - -import { ManagedPromise } from '@keymanapp/web-utils'; +import { default as Keyboard } from '../keyboard.js'; +import { KeyboardHarness, MinimalKeymanGlobal } from '../keyboardHarness.js'; +import { KeyboardLoaderBase } from '../keyboardLoaderBase.js'; +import { KeyboardLoadErrorBuilder } from '../keyboardLoadError.js'; export class DOMKeyboardLoader extends KeyboardLoaderBase { public readonly element: HTMLIFrameElement; @@ -28,54 +29,44 @@ export class DOMKeyboardLoader extends KeyboardLoaderBase { this.performCacheBusting = cacheBust || false; } - protected loadKeyboardInternal( - uri: string, - errorBuilder: KeyboardLoadErrorBuilder, - id?: string - ): Promise { - const promise = new ManagedPromise(); - - if(this.performCacheBusting) { + protected async loadKeyboardBlob(uri: string, errorBuilder: KeyboardLoadErrorBuilder): Promise { + if (this.performCacheBusting) { uri = this.cacheBust(uri); } + let response: Response; try { - const document = this.harness._jsGlobal.document; - const script = document.createElement('script'); - if(id) { - script.id = id; - } - document.head.appendChild(script); - script.onerror = (err: any) => { - promise.reject(errorBuilder.missingError(err)); - } - script.onload = () => { - if(this.harness.loadedKeyboard) { - const keyboard = this.harness.loadedKeyboard; - this.harness.loadedKeyboard = null; - promise.resolve(keyboard); - } else { - promise.reject(errorBuilder.scriptError()); - } - } - - // On the oldest mobile devices we support, Promise.finally may not actually exist. - // Fortunately... it's not that hard of an issue to work around. - // Note: es6-shim doesn't polyfill Promise.finally! - promise.then(() => { - // It is safe to remove the script once it has been run (https://stackoverflow.com/a/37393041) - script.remove(); - }).catch(() => { - script.remove(); - }); - - // Now that EVERYTHING ELSE is ready, establish the link to the keyboard's script. - script.src = uri; - } catch (err) { - return Promise.reject(err); + response = await fetch(uri); + } catch (e) { + throw errorBuilder.keyboardDownloadError(e); } - return promise.corePromise; + if (!response.ok) { + throw errorBuilder.keyboardDownloadError(new Error(`HTTP ${response.status} ${response.statusText}`)); + } + + let buffer: ArrayBuffer; + try { + buffer = await response.arrayBuffer(); + } catch (e) { + throw errorBuilder.invalidKeyboard(e); + } + return new Uint8Array(buffer); + } + + protected async loadKeyboardFromScript(script: string, errorBuilder: KeyboardLoadErrorBuilder): Promise { + try { + this.evalScriptInContext(script, this.harness._jsGlobal); + } catch (e) { + throw errorBuilder.scriptError(e); + } + const keyboard = this.harness.loadedKeyboard; + if (!keyboard) { + throw errorBuilder.scriptError(); + } + + this.harness.loadedKeyboard = null; + return keyboard; } private cacheBust(uri: string) { @@ -84,4 +75,14 @@ export class DOMKeyboardLoader extends KeyboardLoaderBase { // being ignored. return uri + "?v=" + (new Date()).getTime(); /*cache buster*/ } + + private evalScriptInContext(script: string, context: any) { + const f = function (s: string) { + // use indirect eval (eval?.() notation doesn't work because of esbuild bundling) + const evalFunc = eval; + return evalFunc(s); + } + f.call(context, script); + } + } \ No newline at end of file diff --git a/web/src/engine/keyboard/src/keyboards/loaders/nodeKeyboardLoader.ts b/web/src/engine/keyboard/src/keyboards/loaders/nodeKeyboardLoader.ts index b9f6a7cdfe..6ec27364fc 100644 --- a/web/src/engine/keyboard/src/keyboards/loaders/nodeKeyboardLoader.ts +++ b/web/src/engine/keyboard/src/keyboards/loaders/nodeKeyboardLoader.ts @@ -1,9 +1,13 @@ -import { Keyboard, KeyboardHarness, KeyboardLoaderBase, KeyboardLoadErrorBuilder, MinimalKeymanGlobal } from 'keyman/engine/keyboard'; +import vm from 'node:vm'; +import { readFile } from 'node:fs/promises'; -import vm from 'vm'; -import fs from 'fs'; import { globalObject } from '@keymanapp/web-utils'; +import { default as Keyboard } from '../keyboard.js'; +import { KeyboardHarness, MinimalKeymanGlobal } from '../keyboardHarness.js'; +import { KeyboardLoaderBase } from '../keyboardLoaderBase.js'; +import { KeyboardLoadErrorBuilder } from '../keyboardLoadError.js'; + export class NodeKeyboardLoader extends KeyboardLoaderBase { constructor() constructor(harness: KeyboardHarness); @@ -22,22 +26,32 @@ export class NodeKeyboardLoader extends KeyboardLoaderBase { } } - protected loadKeyboardInternal(uri: string, errorBuilder: KeyboardLoadErrorBuilder): Promise { + protected async loadKeyboardBlob(uri: string, errorBuilder: KeyboardLoadErrorBuilder): Promise { // `fs` does not like 'file:///'; it IS "File System" oriented, after all, and wants a path, not a URI. - if(uri.indexOf('file:///') == 0) { + if (uri.indexOf('file:///') == 0) { uri = uri.substring('file:///'.length); } + let buffer: Buffer; + try { + buffer = await readFile(uri); + } catch (err) { + throw errorBuilder.keyboardDownloadError(err); + } + return Uint8Array.from(buffer); + } + + protected async loadKeyboardFromScript(scriptSrc: string, errorBuilder: KeyboardLoadErrorBuilder): Promise { let script; try { - script = new vm.Script(fs.readFileSync(uri).toString()); + script = new vm.Script(scriptSrc); } catch (err) { - return Promise.reject(errorBuilder.missingError(err)); + throw errorBuilder.invalidKeyboard(err); } try { script.runInContext(this.harness._jsGlobal); } catch (err) { - return Promise.reject(errorBuilder.scriptError(err)); + throw errorBuilder.scriptError(err); } const keyboard = this.harness.loadedKeyboard; diff --git a/web/src/engine/main/build.sh b/web/src/engine/main/build.sh index 705bfbb902..3958f6f714 100755 --- a/web/src/engine/main/build.sh +++ b/web/src/engine/main/build.sh @@ -14,6 +14,7 @@ SUBPROJECT_NAME=engine/main builder_describe "Builds the Keyman Engine for Web's common top-level base classes." \ "@/common/web/keyman-version" \ + "@/web/src/engine/core-processor" \ "@/web/src/engine/keyboard" \ "@/web/src/engine/interfaces build" \ "@/web/src/engine/js-processor build" \ diff --git a/web/src/engine/main/readme.md b/web/src/engine/main/readme.md index 219e06ad9b..0ddc392f35 100644 --- a/web/src/engine/main/readme.md +++ b/web/src/engine/main/readme.md @@ -1,3 +1,4 @@ -## engine/main +# engine/main -This subproject holds modularized code converted from the old, namespaced version of KMW. \ No newline at end of file +This subproject holds modularized code converted from the old, namespaced version of KMW. +Previously it was called `input-processor`. diff --git a/web/src/engine/main/src/headless/inputProcessor.ts b/web/src/engine/main/src/headless/inputProcessor.ts index c13a0bf00a..c367405af9 100644 --- a/web/src/engine/main/src/headless/inputProcessor.ts +++ b/web/src/engine/main/src/headless/inputProcessor.ts @@ -2,9 +2,10 @@ import ContextWindow from "./contextWindow.js"; import { LanguageProcessor } from "./languageProcessor.js"; -import type { ModelSpec } from "keyman/engine/interfaces"; +import type { ModelSpec, PathConfiguration } from "keyman/engine/interfaces"; import { globalObject, DeviceSpec } from "@keymanapp/web-utils"; +import { CoreProcessor } from "keyman/engine/core-processor"; import { Codes, type Keyboard, type KeyEvent } from "keyman/engine/keyboard"; import { type Alternate, @@ -34,6 +35,7 @@ export class InputProcessor { private contextDevice: DeviceSpec; private kbdProcessor: KeyboardProcessor; private lngProcessor: LanguageProcessor; + private coreProcessor: CoreProcessor; private readonly contextCache = new TranscriptionCache(); @@ -49,6 +51,11 @@ export class InputProcessor { this.contextDevice = device; this.kbdProcessor = new KeyboardProcessor(device, options); this.lngProcessor = new LanguageProcessor(predictiveTextWorker, this.contextCache); + this.coreProcessor = new CoreProcessor(); + } + + public async init(paths: PathConfiguration) { + this.coreProcessor.init(paths.basePath); } public get languageProcessor(): LanguageProcessor { diff --git a/web/src/engine/main/src/keymanEngine.ts b/web/src/engine/main/src/keymanEngine.ts index beced96d18..774235cee0 100644 --- a/web/src/engine/main/src/keymanEngine.ts +++ b/web/src/engine/main/src/keymanEngine.ts @@ -238,6 +238,8 @@ export default class KeymanEngine< // Initialize supplementary plane string extensions String.kmwEnableSupplementaryPlane(true); + await this.core.init(config.paths); + // Since we're not sandboxing keyboard loads yet, we just use `window` as the jsGlobal object. // All components initialized below require a properly-configured `config.paths` or similar. const keyboardLoader = new KeyboardLoader(this.interface, config.applyCacheBusting); diff --git a/web/src/test/auto/dom/cases/core-processor/basic.spec.ts b/web/src/test/auto/dom/cases/core-processor/basic.spec.ts new file mode 100644 index 0000000000..767d2e2fbb --- /dev/null +++ b/web/src/test/auto/dom/cases/core-processor/basic.spec.ts @@ -0,0 +1,21 @@ +import { assert } from 'chai'; +import { CoreProcessor } from 'keyman/engine/core-processor'; + +const coreurl = '/web/build/engine/core-processor/obj/import/core'; + +// Test the CoreProcessor interface. +describe('CoreProcessor', function () { + it('can initialize without errors', async function () { + const kp = new CoreProcessor(); + assert.isTrue(await kp.init(coreurl)); + }); + + it('can call temp function', async function () { + const kp = new CoreProcessor(); + await kp.init(coreurl); + const a = kp.tmp_wasm_attributes(); + assert.isNotNull(a); + assert.isNumber(a.max_context); + console.dir(a); + }); +}); diff --git a/web/src/test/auto/dom/cases/keyboard/domKeyboardLoader.tests.ts b/web/src/test/auto/dom/cases/keyboard/domKeyboardLoader.tests.ts index 95db91b0f8..7f2f48e2c1 100644 --- a/web/src/test/auto/dom/cases/keyboard/domKeyboardLoader.tests.ts +++ b/web/src/test/auto/dom/cases/keyboard/domKeyboardLoader.tests.ts @@ -1,8 +1,9 @@ import { assert } from 'chai'; import { DOMKeyboardLoader } from 'keyman/engine/keyboard/dom-keyboard-loader'; -import { extendString, KeyboardHarness, Keyboard, MinimalKeymanGlobal, DeviceSpec, KeyboardKeymanGlobal } from 'keyman/engine/keyboard'; +import { extendString, KeyboardHarness, Keyboard, MinimalKeymanGlobal, DeviceSpec, KeyboardKeymanGlobal, KeyboardDownloadError, KeyboardScriptError } from 'keyman/engine/keyboard'; import { KeyboardInterface, Mock } from 'keyman/engine/js-processor'; +import { assertThrowsAsync } from 'keyman/tools/testing/test-utils'; declare let window: typeof globalThis; // KeymanEngine from the web/ folder... when available. @@ -27,6 +28,24 @@ describe('Keyboard loading in DOM', function() { } }) + it('throws error when keyboard does not exist', async () => { + const harness = new KeyboardInterface(window, MinimalKeymanGlobal); + const keyboardLoader = new DOMKeyboardLoader(harness); + const nonExisting = '/does/not/exist.js'; + + await assertThrowsAsync(async () => await keyboardLoader.loadKeyboardFromPath(nonExisting), + KeyboardDownloadError, `Unable to download keyboard at ${nonExisting}`); + }); + + it('throws error when keyboard is invalid', async () => { + const harness = new KeyboardInterface(window, MinimalKeymanGlobal); + const keyboardLoader = new DOMKeyboardLoader(harness); + const nonKeyboardPath = '/common/test/resources/index.mjs'; + + await assertThrowsAsync(async () => await keyboardLoader.loadKeyboardFromPath(nonKeyboardPath), + KeyboardScriptError, `Error registering the keyboard script at ${nonKeyboardPath}; it may contain an error.`); + }); + it('`window`, disabled rule processing', async () => { const harness = new KeyboardHarness(window, MinimalKeymanGlobal); let keyboardLoader = new DOMKeyboardLoader(harness); diff --git a/web/src/test/auto/dom/web-test-runner.config.mjs b/web/src/test/auto/dom/web-test-runner.config.mjs index 701eab15a7..ec3d64ac8f 100644 --- a/web/src/test/auto/dom/web-test-runner.config.mjs +++ b/web/src/test/auto/dom/web-test-runner.config.mjs @@ -33,7 +33,7 @@ export default { nodeResolve: true, // Top-level, implicit 'default' group files: [ - 'src/test/auto/dom/init_check.tests.ts', + 'web/src/test/auto/dom/init_check.tests.ts', // '**/*.tests.html' ], groups: [ @@ -41,45 +41,50 @@ export default { name: 'engine/attachment', // Relative, from the containing package.json files: [ - 'build/test/dom/cases/attachment/**/*.tests.html', - 'build/test/dom/cases/attachment/**/*.tests.mjs' + 'web/build/test/dom/cases/attachment/**/*.tests.html', + 'web/build/test/dom/cases/attachment/**/*.tests.mjs' ] }, { name: 'app/browser', // Relative, from the containing package.json - files: ['build/test/dom/cases/browser/**/*.tests.mjs'] + files: ['web/build/test/dom/cases/browser/**/*.tests.mjs'] + }, + { + name: 'engine/core-processor', + // Relative, from the containing package.json + files: ['web/src/test/auto/dom/cases/core-processor/*.tests.ts'] }, { name: 'engine/dom-utils', // Relative, from the containing package.json - files: ['build/test/dom/cases/dom-utils/**/*.tests.mjs'] + files: ['web/build/test/dom/cases/dom-utils/**/*.tests.mjs'] }, { name: 'engine/element-wrappers', // Relative, from the containing package.json - files: ['build/test/dom/cases/element-wrappers/**/*.tests.mjs'] + files: ['web/build/test/dom/cases/element-wrappers/**/*.tests.mjs'] }, { name: 'engine/gesture-processor', // Relative, from the containing package.json // Note: here we use the .tests.html file in the src directory! - files: ['src/test/auto/dom/cases/gesture-processor/**/*.tests.html'] + files: ['web/src/test/auto/dom/cases/gesture-processor/**/*.tests.html'] }, { name: 'engine/keyboard', // Relative, from the containing package.json - files: ['build/test/dom/cases/keyboard/**/*.tests.mjs'] + files: ['web/build/test/dom/cases/keyboard/**/*.tests.mjs'] }, { name: 'engine/keyboard-storage', // Relative, from the containing package.json - files: ['build/test/dom/cases/keyboard-storage/**/*.tests.mjs'] + files: ['web/build/test/dom/cases/keyboard-storage/**/*.tests.mjs'] }, { name: 'engine/osk', // Relative, from the containing package.json - files: ['build/test/dom/cases/osk/**/*.tests.mjs'] + files: ['web/build/test/dom/cases/osk/**/*.tests.mjs'] } ], middleware: [ @@ -89,6 +94,12 @@ export default { context.url = '/web/src/test/auto' + context.url; } + return next(); + }, + function rewriteWasmContentType(context, next) { + if (context.url.endsWith('.wasm')) { + context.headers['content-type'] = 'application/wasm'; + } return next(); } ], diff --git a/web/src/test/auto/headless/engine/js-processor/kbdInterface.tests.js b/web/src/test/auto/headless/engine/js-processor/kbdInterface.tests.ts similarity index 72% rename from web/src/test/auto/headless/engine/js-processor/kbdInterface.tests.js rename to web/src/test/auto/headless/engine/js-processor/kbdInterface.tests.ts index 0d08496442..c58a7d9191 100644 --- a/web/src/test/auto/headless/engine/js-processor/kbdInterface.tests.js +++ b/web/src/test/auto/headless/engine/js-processor/kbdInterface.tests.ts @@ -3,7 +3,7 @@ import { assert } from 'chai'; import { createRequire } from 'module'; const require = createRequire(import.meta.url); -import { MinimalKeymanGlobal } from 'keyman/engine/keyboard'; +import { DeviceSpec, MinimalKeymanGlobal } from 'keyman/engine/keyboard'; import { KeyboardInterface, Mock } from 'keyman/engine/js-processor'; import { NodeKeyboardLoader } from 'keyman/engine/keyboard/node-keyboard-loader'; @@ -11,21 +11,22 @@ describe('Headless keyboard loading', function () { const laoPath = require.resolve('@keymanapp/common-test-resources/keyboards/lao_2008_basic.js'); const khmerPath = require.resolve('@keymanapp/common-test-resources/keyboards/khmer_angkor.js'); const nonKeyboardPath = require.resolve('@keymanapp/common-test-resources/index.mjs'); - const ipaPath = require.resolve('@keymanapp/common-test-resources/keyboards/sil_ipa.js'); + // const ipaPath = require.resolve('@keymanapp/common-test-resources/keyboards/sil_ipa.js'); // Common test suite setup. - let device = { - formFactor: 'desktop', - OS: 'windows', - browser: 'native' + const device = { + formFactor: DeviceSpec.FormFactor.Desktop, + OS: DeviceSpec.OperatingSystem.Windows, + browser: DeviceSpec.Browser.Native, + touchable: false } describe('Full harness loading', () => { it('successfully loads', async function () { // -- START: Standard Recorder-based unit test loading boilerplate -- - let harness = new KeyboardInterface({}, MinimalKeymanGlobal); - let keyboardLoader = new NodeKeyboardLoader(harness); - let keyboard = await keyboardLoader.loadKeyboardFromPath(laoPath); + const harness = new KeyboardInterface({}, MinimalKeymanGlobal); + const keyboardLoader = new NodeKeyboardLoader(harness); + const keyboard = await keyboardLoader.loadKeyboardFromPath(laoPath); harness.activeKeyboard = keyboard; // -- END: Standard Recorder-based unit test loading boilerplate -- @@ -35,9 +36,9 @@ describe('Headless keyboard loading', function () { it('can evaluate rules', async function () { // -- START: Standard Recorder-based unit test loading boilerplate -- - let harness = new KeyboardInterface({}, MinimalKeymanGlobal); - let keyboardLoader = new NodeKeyboardLoader(harness); - let keyboard = await keyboardLoader.loadKeyboardFromPath(laoPath); + const harness = new KeyboardInterface({}, MinimalKeymanGlobal); + const keyboardLoader = new NodeKeyboardLoader(harness); + const keyboard = await keyboardLoader.loadKeyboardFromPath(laoPath); harness.activeKeyboard = keyboard; // -- END: Standard Recorder-based unit test loading boilerplate -- @@ -46,8 +47,8 @@ describe('Headless keyboard loading', function () { }); it('does not change the active kehboard', async function () { - let harness = new KeyboardInterface({}, MinimalKeymanGlobal); - let keyboardLoader = new NodeKeyboardLoader(harness); + const harness = new KeyboardInterface({}, MinimalKeymanGlobal); + const keyboardLoader = new NodeKeyboardLoader(harness); const lao_keyboard = await keyboardLoader.loadKeyboardFromPath(laoPath); assert.isNotOk(harness.activeKeyboard); assert.isOk(lao_keyboard); @@ -66,8 +67,8 @@ describe('Headless keyboard loading', function () { it('throws distinct errors', async function () { const invalidPath = 'totally_invalid_path.js'; - let harness = new KeyboardInterface({}, MinimalKeymanGlobal); - let keyboardLoader = new NodeKeyboardLoader(harness); + const harness = new KeyboardInterface({}, MinimalKeymanGlobal); + const keyboardLoader = new NodeKeyboardLoader(harness); let missingError; try { await keyboardLoader.loadKeyboardFromPath(invalidPath); diff --git a/web/src/test/auto/headless/engine/keyboard/keyboard.tests.ts b/web/src/test/auto/headless/engine/keyboard/keyboard.tests.ts new file mode 100644 index 0000000000..e089b64fe7 --- /dev/null +++ b/web/src/test/auto/headless/engine/keyboard/keyboard.tests.ts @@ -0,0 +1,34 @@ +import { assert } from 'chai'; + +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +import { KeyboardHarness, MinimalKeymanGlobal, DeviceSpec } from 'keyman/engine/keyboard'; +import { NodeKeyboardLoader } from 'keyman/engine/keyboard/node-keyboard-loader'; + + +describe('Keyboard tests', function () { + const khmerPath = require.resolve('@keymanapp/common-test-resources/keyboards/khmer_angkor.js'); + + it('accurately determines layout properties', async () => { + // -- START: Standard Recorder-based unit test loading boilerplate -- + const harness = new KeyboardHarness({}, MinimalKeymanGlobal); + const keyboardLoader = new NodeKeyboardLoader(harness); + const km_keyboard = await keyboardLoader.loadKeyboardFromPath(khmerPath); + // -- END: Standard Recorder-based unit test loading boilerplate -- + + // `khmer_angkor` - supports longpresses, but not flicks or multitaps. + + // Phone supports longpress if the keyboard supports it. + const mobileLayout = km_keyboard.layout(DeviceSpec.FormFactor.Phone); + assert.isTrue(mobileLayout.hasLongpresses); + assert.isFalse(mobileLayout.hasFlicks); + assert.isFalse(mobileLayout.hasMultitaps); + + // Desktop doesn't support longpress even if the keyboard supports it. + const desktopLayout = km_keyboard.layout(DeviceSpec.FormFactor.Desktop); + assert.isFalse(desktopLayout.hasLongpresses); + assert.isFalse(desktopLayout.hasFlicks); + assert.isFalse(desktopLayout.hasMultitaps); + }); +}); diff --git a/web/src/test/auto/headless/engine/keyboard/keyboardLoaderBase.tests.ts b/web/src/test/auto/headless/engine/keyboard/keyboardLoaderBase.tests.ts new file mode 100644 index 0000000000..96699df976 --- /dev/null +++ b/web/src/test/auto/headless/engine/keyboard/keyboardLoaderBase.tests.ts @@ -0,0 +1,89 @@ +import { assert } from 'chai'; + +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +import { KeyboardHarness, MinimalKeymanGlobal, KeyboardDownloadError, DeviceSpec, InvalidKeyboardError } from 'keyman/engine/keyboard'; +import { KeyboardInterface, Mock } from 'keyman/engine/js-processor'; +import { NodeKeyboardLoader } from 'keyman/engine/keyboard/node-keyboard-loader'; +import { assertThrowsAsync, assertThrows } from 'keyman/tools/testing/test-utils'; + +describe('Headless keyboard loading', function() { + const laoPath = require.resolve('@keymanapp/common-test-resources/keyboards/lao_2008_basic.js'); + const nonKeyboardPath = require.resolve('@keymanapp/common-test-resources/index.mjs'); + const ipaPath = require.resolve('@keymanapp/common-test-resources/keyboards/sil_ipa.js'); + const nonExisting = '/does/not/exist.js'; + // Common test suite setup. + + const device = { + formFactor: DeviceSpec.FormFactor.Desktop, + OS: DeviceSpec.OperatingSystem.Windows, + browser: DeviceSpec.Browser.Native, + touchable: false + } + + describe('Minimal harness loading', () => { + it('successfully loads a single keyboard from filesystem', async () => { + // -- START: Standard Recorder-based unit test loading boilerplate -- + const harness = new KeyboardInterface({}, MinimalKeymanGlobal); + const keyboardLoader = new NodeKeyboardLoader(harness); + const keyboard = await keyboardLoader.loadKeyboardFromPath(laoPath); + // -- END: Standard Recorder-based unit test loading boilerplate -- + + // Asserts that the harness's loading field is cleared once the load is complete. + assert.isNotOk(harness.loadedKeyboard); + + // Asserts that the `activeKeyboard` field was not set by the operation. + assert.isNotOk(harness.activeKeyboard); + + // This part provides assurance that the keyboard properly loaded. + assert.equal(keyboard.id, "Keyboard_lao_2008_basic"); + }); + + it('throws error when keyboard does not exist', async () => { + const harness = new KeyboardInterface({}, MinimalKeymanGlobal); + const keyboardLoader = new NodeKeyboardLoader(harness); + + await assertThrowsAsync(async () => await keyboardLoader.loadKeyboardFromPath(nonExisting), + KeyboardDownloadError, `Unable to download keyboard at ${nonExisting}`); + }); + + it('throws error when keyboard is invalid', async () => { + const harness = new KeyboardInterface({}, MinimalKeymanGlobal); + const keyboardLoader = new NodeKeyboardLoader(harness); + + await assertThrowsAsync(async () => await keyboardLoader.loadKeyboardFromPath(nonKeyboardPath), + InvalidKeyboardError, `${nonKeyboardPath} is not a valid keyboard file`); + }); + + it('successfully loads (has variable stores)', async () => { + // -- START: Standard Recorder-based unit test loading boilerplate -- + const harness = new KeyboardInterface({}, MinimalKeymanGlobal); + const keyboardLoader = new NodeKeyboardLoader(harness); + const keyboard = await keyboardLoader.loadKeyboardFromPath(ipaPath); + // -- END: Standard Recorder-based unit test loading boilerplate -- + + // This part provides extra assurance that the keyboard properly loaded. + assert.equal(keyboard.id, "Keyboard_sil_ipa"); + }); + + // TODO-WEB-CORE: figure out what the purpose of this test is + // TODO-WEB-CORE: move to kbdInterface.tests.ts + it('cannot evaluate rules', async function() { + // -- START: Standard Recorder-based unit test loading boilerplate -- + const harness = new KeyboardHarness({}, MinimalKeymanGlobal); + const keyboardLoader = new NodeKeyboardLoader(harness); + const keyboard = await keyboardLoader.loadKeyboardFromPath(laoPath); + // -- END: Standard Recorder-based unit test loading boilerplate -- + + // Runs a blank KeyEvent through the keyboard's rule processing... + // but via separate harness configured with a different captured global. + // This shows an important detail: the 'global' object is effectively + // closure-captured. (Similar constraints may occur when experimenting with + // 'sandboxed' keyboard loading in the DOM!) + const ruleHarness = new KeyboardInterface({}, MinimalKeymanGlobal); + ruleHarness.activeKeyboard = keyboard; + assertThrows(() => ruleHarness.processKeystroke(new Mock(), keyboard.constructNullKeyEvent(device)), 'k.KKM is not a function'); + }); + }); +}); diff --git a/web/src/test/auto/integrated/web-test-runner.config.mjs b/web/src/test/auto/integrated/web-test-runner.config.mjs index 8d937f55af..5aa4c74922 100644 --- a/web/src/test/auto/integrated/web-test-runner.config.mjs +++ b/web/src/test/auto/integrated/web-test-runner.config.mjs @@ -24,7 +24,7 @@ export default { concurrency: 10, nodeResolve: true, files: [ - 'build/test/integrated//**/*.tests.mjs', + 'web/build/test/integrated//**/*.tests.mjs', // '**/*.tests.html' ], middleware: [ diff --git a/web/src/test/manual/README.md b/web/src/test/manual/README.md new file mode 100644 index 0000000000..b875c61710 --- /dev/null +++ b/web/src/test/manual/README.md @@ -0,0 +1,10 @@ +# Manual tests + +To run the the manual tests, start the test web server with: + +```bash +cd "$KEYMAN_ROOT" +web/build.sh start +``` + +Then open in your browser. diff --git a/web/src/test/manual/build.sh b/web/src/test/manual/build.sh index ee75c87710..97894a2c54 100755 --- a/web/src/test/manual/build.sh +++ b/web/src/test/manual/build.sh @@ -57,4 +57,4 @@ function do_copy() { } builder_run_action clean rm -rf "$KEYMAN_ROOT/$DEST" -builder_run_action build do_copy \ No newline at end of file +builder_run_action build do_copy diff --git a/web/src/test/manual/web/keyboard-errors/errorhdr.js b/web/src/test/manual/web/keyboard-errors/errorhdr.js index b784f6a476..9a0ad75939 100644 --- a/web/src/test/manual/web/keyboard-errors/errorhdr.js +++ b/web/src/test/manual/web/keyboard-errors/errorhdr.js @@ -1,17 +1,15 @@ -// JavaScript Document samplehdr.js: Keyboard management for KeymanWeb demonstration pages - -/* +/* This script is designed to test KeymanWeb error message handling. */ - function loadKeyboards() - { + function loadKeyboards() + { var kmw=keyman; - + // We start by adding a keyboard correctly. It's best to include a 'control' in our experiment. kmw.addKeyboards({id:'us',name:'English',languages:{id:'en',name:'English'}, filename:'../us-1.0.js'}); - + // Insert a keyboard that cannot be found. kmw.addKeyboards({id:'lao_2008_basic',name:'wrong-filename', languages:{ @@ -19,9 +17,9 @@ font:{family:'LaoWeb',source:['../font/saysettha_web.ttf','../font/saysettha_web.woff','../font/saysettha_web.eot']} }, filename:'./missing_file.js' // Intentional error - the file doesn't exist, so the +