chore: Merge remote-tracking branch 'origin/epic/web-core' into chore/merge-master-into-web-core

This commit is contained in:
Marc Durdin 2024-12-05 15:04:11 +07:00
commit e6cf6186c2
49 changed files with 1020 additions and 220 deletions

185
core/src/layout.hpp Normal file
View file

@ -0,0 +1,185 @@
/*
* Keyman is copyright (C) SIL International. MIT License.
*
* Keyman Keyboard Processor API - On-Screen Keyboard Layout Interfaces
*/
#pragma once
#include <string>
#include <map>
#include <vector>
#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<keyboard_layout_key> longpresses;
/** multitaps */
std::vector<keyboard_layout_key> multiTaps;
/** flicks */
std::map<keyboard_layout_flick_direction, keyboard_layout_key> 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<keyboard_layout_key> 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<keyboard_layout_row> 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<keyboard_layout_layer> 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<keyboard_layout_platform> 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

View file

@ -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

44
core/src/wasm.cpp Normal file
View file

@ -0,0 +1,44 @@
#ifdef __EMSCRIPTEN__
#ifdef __EMSCRIPTEN__
#include <emscripten/emscripten.h>
#include <emscripten/bind.h>
#else
#define EMSCRIPTEN_KEEPALIVE
#endif
#ifdef __cplusplus
#define EXTERN extern "C" EMSCRIPTEN_KEEPALIVE
#else
#define EXTERN EMSCRIPTEN_KEEPALIVE
#endif
#include <keyman_core.h>
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>("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

View file

@ -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

View file

@ -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')

View file

@ -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)

View file

@ -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 |

2
package-lock.json generated
View file

@ -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"

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -0,0 +1 @@
src/import/

View file

@ -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

View file

@ -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<boolean> {
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();
}
}

View file

@ -0,0 +1 @@
export * from './core-processor.js';

View file

@ -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" ],
}

View file

@ -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;
}

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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);
}
}

View file

@ -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<Keyboard> {
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<Keyboard> {
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<Keyboard>;
private async loadKeyboardInternal(uri: string, errorBuilder: KeyboardLoadErrorBuilder): Promise<Keyboard> {
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<Uint8Array>;
protected abstract loadKeyboardFromScript(scriptSrc: string, errorBuilder: KeyboardLoadErrorBuilder): Promise<Keyboard>;
}

View file

@ -2,9 +2,10 @@
///<reference lib="dom" />
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<Keyboard> {
const promise = new ManagedPromise<Keyboard>();
if(this.performCacheBusting) {
protected async loadKeyboardBlob(uri: string, errorBuilder: KeyboardLoadErrorBuilder): Promise<Uint8Array> {
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<Keyboard> {
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);
}
}

View file

@ -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<Keyboard> {
protected async loadKeyboardBlob(uri: string, errorBuilder: KeyboardLoadErrorBuilder): Promise<Uint8Array> {
// `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<Keyboard> {
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;

View file

@ -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" \

View file

@ -1,3 +1,4 @@
## engine/main
# engine/main
This subproject holds modularized code converted from the old, namespaced version of KMW.
This subproject holds modularized code converted from the old, namespaced version of KMW.
Previously it was called `input-processor`.

View file

@ -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 {

View file

@ -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);

View file

@ -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);
});
});

View file

@ -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);

View file

@ -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();
}
],

View file

@ -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);

View file

@ -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);
});
});

View file

@ -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');
});
});
});

View file

@ -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: [

View file

@ -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 <http://localhost:3000> in your browser.

View file

@ -57,4 +57,4 @@ function do_copy() {
}
builder_run_action clean rm -rf "$KEYMAN_ROOT/$DEST"
builder_run_action build do_copy
builder_run_action build do_copy

View file

@ -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 <script> tag will raise an error event.
});
// Insert a keyboard that will generate a timing error.
});
// Insert a keyboard that will generate a timing error.
kmw.addKeyboards({id:'unparsable',name:'non-parsable',
languages:{
id:'lo',name:'debugging',region:'Asia',
@ -29,14 +27,16 @@
},
filename:'./unparsable.js' // Intentional error - the file has no parsable keyboard, so while the <script> tag will load,
// registration will fail.
});
});
// Insert a keyboard that will generate a timing error.
// Insert a keyboard that will generate a timing error. `timeout.js` doesn't
// exist, but the test server (web/src/tools/testing/test-server/index.cjs)
// has special handling for that URL and times out after 10 seconds.
kmw.addKeyboards({id:'timeout',name:'timeout',
languages:{
id:'lo',name:'debugging',region:'Asia',
font:{family:'LaoWeb',source:['../font/saysettha_web.ttf','../font/saysettha_web.woff','../font/saysettha_web.eot']}
},
filename:'./timeout.js' // Intentional (simulated) error - the file never loads, simulating a server timeout.
});
});
}

View file

@ -65,9 +65,32 @@
<h3>or in this input field:</h3>
<input class='test' value='' placeholder='or here'/>
<h2>Expected error messages:</h2>
<ul>
<li><i>wrong-filename</i> keyboard: "Unable to download wrong-filename keyboard for debugging"</li>
<li><i>non-parsable</i> keyboard: "Error registering the non-parsable keyboard for debugging; keyboard script at ./unparsable.js may contain an error."</li>
<li><i>timeout</i> keyboard: "Sorry, the timeout keyboard for debugging is not currently available."</li>
</ul>
<h3><a href="../index.html">Return to testing home page</a></h3>
</div>
<script>
if (!window.location.href.startsWith('http://localhost:3000')) {
const body = document.getElementsByTagName('body')[0];
body.innerHTML = `<h1>KeymanWeb Sample Page - Error Testing page</h1>
<h2>Unable to load this test page!</h2>
<p>This page has to be loaded through the local test server.</p>
<p>Do the following:</p>
<ol>
<li>Open a terminal and navigate to the Keyman source root directory</li>
<li>Start the test server with <code>web/build.sh start</code></li>
<li>Open <a href="http://localhost:3000/src/test/manual/web/keyboard-errors/index.html">http://localhost:3000/src/test/manual/web/keyboard-errors/index.html</a> in a browser</li>
</ol>
`;
}
</script>
</body>
<!--

View file

@ -1,21 +0,0 @@
(function() {
var me = document.currentScript;
console.log(me);
var onload = me.onload;
if(onload) {
me.onload = null;
}
document.body.addEventListener('load', function(e) {
// Prevent the element's onload event from firing
if(e.srcElement == me || e.target == me) {
e.cancelBubble = true;
}
}, {capture: true});
window.setTimeout(function () {
// Restores the function after a slight delay.
me.onload = onload;
}, 1);
})();

View file

@ -22,7 +22,8 @@ builder_describe "Builds the Keyman Engine for Web's development & unit-testing
"--ci Does nothing for this script" \
":bulk_rendering=testing/bulk_rendering Builds the bulk-rendering tool used to validate changes to OSK display code" \
":recorder=testing/recorder Builds the KMW recorder tool used for development of unit-test resources" \
":sourcemap-root=building/sourcemap-root Builds the sourcemap-cleaning tool used during minification of app/ builds"
":sourcemap-root=building/sourcemap-root Builds the sourcemap-cleaning tool used during minification of app/ builds" \
":test-utils=testing/test-utils Builds the test-utils module"
builder_parse "$@"

View file

@ -0,0 +1,25 @@
const express = require('express')
const path = require('path')
const app = express()
const port = 3000
app.use(express.static(path.join(__dirname, '../../../../')))
// for testing timeout error in web/src/test/manual/web/keyboard-errors
const router = express.Router()
router.get('/src/test/manual/web/keyboard-errors/timeout.js', async (req, res, next) => {
console.log('timeout.js requested')
return new Promise(() => {
setTimeout(() => {
res.set('Content-Type', 'application/json');
res.status(200);
next();
}, 10500); // > ContextManagerBase.TIMEOUT_THRESHOLD (10 seconds)
});
})
app.use(router)
app.listen(port, () => {
console.log(`Keyman test app listening on port ${port}`)
})

View file

@ -0,0 +1,34 @@
#!/usr/bin/env bash
#
# Compile KeymanWeb's automated js/ts test utilities
## 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=tools/testing/test-utils
. "${KEYMAN_ROOT}/web/common.inc.sh"
. "${KEYMAN_ROOT}/resources/shellHelperFunctions.sh"
################################ Main script ################################
builder_describe "Automated js/ts test utilities for KeymanWeb" \
"clean" \
"configure" \
"build"
builder_describe_outputs \
configure /node_modules \
build "/web/build/${SUBPROJECT_NAME}/obj/index.js"
builder_parse "$@"
do_build ( ) {
compile "${SUBPROJECT_NAME}"
}
builder_run_action configure verify_npm_setup
builder_run_action clean rm -rf "../../../../build/${SUBPROJECT_NAME}/"
builder_run_action build do_build

View file

@ -0,0 +1,31 @@
import { assert } from 'chai';
// export async function assertThrowsAsync(fn: () => Promise<any>, message?: string): Promise<void>;
export async function assertThrowsAsync(fn: () => Promise<any>, type?: any, message?: string): Promise<void> {
assert(!!type || !!message, 'at least one of type or message must be specified');
if (typeof(type) === 'string') {
message = type;
type = undefined;
}
try {
await fn();
assert.fail('Expected function to throw an error, but it did not.');
} catch (err) {
if (type) {
assert.isTrue(err instanceof type, `Expected error to be of type ${type.name}, but got ${err.constructor.name}`);
}
if (message) {
assert.equal((err as Error).message, message);
}
}
}
export function assertThrows(fn: () => any, message?: string): void;
export function assertThrows(fn: () => any, type?: any, message?: string): void {
assert(!!type || !!message, 'at least one of type or message must be specified');
if (typeof(type) === 'string') {
message = type;
type = undefined;
}
assert.throws(fn, type, message);
}

View file

@ -0,0 +1,11 @@
{
"extends": "../../../tsconfig.dom.json",
"compilerOptions": {
"baseUrl": "./",
"outDir": "../../../../build/tools/testing/test-utils/obj/",
"rootDir": ".",
"tsBuildInfoFile": "../../../../build/tools/testing/test-utils/obj/tsconfig.tsbuildinfo"
},
"include": [ "*.ts" ],
}

View file

@ -41,6 +41,8 @@ fi
# End common configs.
builder_run_action test:dom web-test-runner --config "src/test/auto/dom/web-test-runner${WTR_CONFIG}.config.mjs" ${WTR_DEBUG}
cd "${KEYMAN_ROOT}"
builder_run_action test:integrated web-test-runner --config "src/test/auto/integrated/web-test-runner${WTR_CONFIG}.config.mjs" ${WTR_DEBUG}
builder_run_action test:dom web-test-runner --config "web/src/test/auto/dom/web-test-runner${WTR_CONFIG}.config.mjs" ${WTR_DEBUG}
builder_run_action test:integrated web-test-runner --config "web/src/test/auto/integrated/web-test-runner${WTR_CONFIG}.config.mjs" ${WTR_DEBUG}