feat(web): add error handling and tests

Addresses code review comments.
This commit is contained in:
Eberhard Beilharz 2024-09-11 18:26:13 +02:00
parent cbbe971cb3
commit 1dd6989747
No known key found for this signature in database
GPG key ID: E9140597606020D3
7 changed files with 221 additions and 117 deletions

View file

@ -3,8 +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, } from "./keyboards/keyboardLoaderBase.js";
export { KeyboardLoadErrorBuilder, KeyboardMissingError, KeyboardScriptError } from './keyboards/keyboardLoadError.js'
export { KeyboardLoaderBase } from "./keyboards/keyboardLoaderBase.js";
export { KeyboardLoadErrorBuilder, KeyboardMissingError, KeyboardScriptError, KeyboardDownloadError } from './keyboards/keyboardLoadError.js'
export {
CloudKeyboardFont,
internalizeFont,

View file

@ -3,6 +3,8 @@ import { type KeyboardStub } from './keyboardLoaderBase.js';
export interface KeyboardLoadErrorBuilder {
scriptError(err?: Error): void;
missingError(err: Error): void;
missingKeyboardError(msg: string, err: Error): void;
keyboardDownloadError(msg: string, err: Error): void;
}
export class KeyboardScriptError extends Error {
@ -23,6 +25,15 @@ export class KeyboardMissingError extends Error {
}
}
export class KeyboardDownloadError extends Error {
public readonly cause;
constructor(message: string, cause?: Error) {
super(message);
this.cause = cause;
}
}
export class UriBasedErrorBuilder implements KeyboardLoadErrorBuilder {
readonly uri: string;
@ -35,10 +46,18 @@ export class UriBasedErrorBuilder implements KeyboardLoadErrorBuilder {
return new KeyboardMissingError(msg, err);
}
missingKeyboardError(msg: string, err: Error) {
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(msg: string, err: Error) {
return new KeyboardDownloadError(msg, err);
}
}
export class StubBasedErrorBuilder implements KeyboardLoadErrorBuilder {
@ -54,10 +73,18 @@ export class StubBasedErrorBuilder implements KeyboardLoadErrorBuilder {
return new KeyboardMissingError(msg, err);
}
missingKeyboardError(msg: string, err: Error) {
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(msg: string, err: Error) {
return new KeyboardDownloadError(msg, err);
}
}

View file

@ -5,7 +5,7 @@ import { KeyboardLoadErrorBuilder, StubBasedErrorBuilder, UriBasedErrorBuilder }
export type KeyboardStub = KeyboardProperties & { filename: string };
export default abstract class KeyboardLoaderBase {
export abstract class KeyboardLoaderBase {
private _harness: KeyboardHarness;
public get harness(): KeyboardHarness {
@ -16,20 +16,38 @@ 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();
return this.loadKeyboardInternal(uri, new UriBasedErrorBuilder(uri));
}
/**
* 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 loadKeyboardFromStub(stub: KeyboardStub) {
this.harness.install();
return this.loadKeyboardInternal(stub.filename, new StubBasedErrorBuilder(stub));
}
private async loadKeyboardInternal(uri: string, errorBuilder: KeyboardLoadErrorBuilder): Promise<Keyboard> {
const blob = await this.loadKeyboardBlob(uri);
const blob = await this.loadKeyboardBlob(uri, errorBuilder);
let script: string;
try {
script = await blob.text();
} catch (e) {
throw errorBuilder.missingKeyboardError('The keyboard has an invalid encoding.', e);
}
const script = await blob.text();
if (script.startsWith('KXTS', 0)) {
// KMX or LDML (KMX+) keyboard
console.error("KMX keyboard loading is not yet implemented!");
@ -40,7 +58,7 @@ export default abstract class KeyboardLoaderBase {
return await this.loadKeyboardFromScript(script, errorBuilder);
}
protected abstract loadKeyboardBlob(uri: string): Promise<Blob>;
protected abstract loadKeyboardBlob(uri: string, errorBuilder: KeyboardLoadErrorBuilder): Promise<Blob>;
protected abstract loadKeyboardFromScript(scriptSrc: string, errorBuilder: KeyboardLoadErrorBuilder): Promise<Keyboard>;
}

View file

@ -4,8 +4,8 @@
import { default as Keyboard } from '../keyboard.js';
import { KeyboardHarness, MinimalKeymanGlobal } from '../keyboardHarness.js';
import { default as KeyboardLoaderBase } from '../keyboardLoaderBase.js';
import { KeyboardLoadErrorBuilder, KeyboardMissingError } from '../keyboardLoadError.js';
import { KeyboardLoaderBase } from '../keyboardLoaderBase.js';
import { KeyboardLoadErrorBuilder } from '../keyboardLoadError.js';
export class DOMKeyboardLoader extends KeyboardLoaderBase {
public readonly element: HTMLIFrameElement;
@ -29,20 +29,35 @@ export class DOMKeyboardLoader extends KeyboardLoaderBase {
this.performCacheBusting = cacheBust || false;
}
protected async loadKeyboardBlob(uri: string): Promise<Blob> {
protected async loadKeyboardBlob(uri: string, errorBuilder: KeyboardLoadErrorBuilder): Promise<Blob> {
if (this.performCacheBusting) {
uri = this.cacheBust(uri);
}
const response = await fetch(uri);
if (!response.ok) {
throw new KeyboardMissingError(`Cannot find the keyboard at ${uri}.`, new Error(`HTTP ${response.status} ${response.statusText}`));
let response: Response;
try {
response = await fetch(uri);
} catch (e) {
throw errorBuilder.keyboardDownloadError(`Unable to fetch fetch keyboard at ${uri}`, e);
}
if (!response.ok) {
throw errorBuilder.missingError(new Error(`HTTP ${response.status} ${response.statusText}`));
}
try {
return await response.blob();
} catch (e) {
throw errorBuilder.keyboardDownloadError(`Unable to retrieve blob from keyboard at ${uri}`, e);
}
return response.blob();
}
protected async loadKeyboardFromScript(script: string, errorBuilder: KeyboardLoadErrorBuilder): Promise<Keyboard> {
this.evalScriptInContext(script, this.harness._jsGlobal);
try {
this.evalScriptInContext(script, this.harness._jsGlobal);
} catch (e) {
throw errorBuilder.scriptError(e);
}
const keyboard = this.harness.loadedKeyboard;
this.harness.loadedKeyboard = null;
return keyboard;

View file

@ -1,12 +1,13 @@
import vm from 'node:vm';
import { readFile } from 'node:fs/promises';
import { globalObject } from '@keymanapp/web-utils';
import { default as Keyboard } from '../keyboard.js';
import { KeyboardHarness, MinimalKeymanGlobal } from '../keyboardHarness.js';
import { default as KeyboardLoaderBase } from '../keyboardLoaderBase.js';
import { KeyboardLoaderBase } from '../keyboardLoaderBase.js';
import { KeyboardLoadErrorBuilder } from '../keyboardLoadError.js';
import vm from 'vm';
import { readFile } from 'fs/promises';
import { globalObject } from '@keymanapp/web-utils';
export class NodeKeyboardLoader extends KeyboardLoaderBase {
constructor()
constructor(harness: KeyboardHarness);
@ -25,13 +26,18 @@ export class NodeKeyboardLoader extends KeyboardLoaderBase {
}
}
protected async loadKeyboardBlob(uri: string): Promise<Blob> {
protected async loadKeyboardBlob(uri: string, errorBuilder: KeyboardLoadErrorBuilder): Promise<Blob> {
// `fs` does not like 'file:///'; it IS "File System" oriented, after all, and wants a path, not a URI.
if (uri.indexOf('file:///') == 0) {
uri = uri.substring('file:///'.length);
}
const buffer = await readFile(uri);
let buffer: Buffer;
try {
buffer = await readFile(uri);
} catch (err) {
throw errorBuilder.keyboardDownloadError(`Unable to read keyboard file at ${uri}`, err);
}
return new Blob([buffer]);
}
@ -40,12 +46,12 @@ export class NodeKeyboardLoader extends KeyboardLoaderBase {
try {
script = new vm.Script(scriptSrc);
} catch (err) {
return Promise.reject(errorBuilder.missingError(err));
throw errorBuilder.missingError(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

@ -1,94 +0,0 @@
import { assert } from 'chai';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
import { KeyboardHarness, MinimalKeymanGlobal } from 'keyman/engine/keyboard';
import { KeyboardInterface, Mock } from 'keyman/engine/js-processor';
import { NodeKeyboardLoader } from 'keyman/engine/keyboard/node-keyboard-loader';
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');
// Common test suite setup.
let device = {
formFactor: 'desktop',
OS: 'windows',
browser: 'native'
}
describe('Minimal harness loading', () => {
it('successfully loads', async function() {
// -- START: Standard Recorder-based unit test loading boilerplate --
let harness = new KeyboardHarness({}, MinimalKeymanGlobal);
let keyboardLoader = new NodeKeyboardLoader(harness);
let 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('successfully loads (has variable stores)', async () => {
// -- START: Standard Recorder-based unit test loading boilerplate --
let harness = new KeyboardHarness({}, MinimalKeymanGlobal);
let keyboardLoader = new NodeKeyboardLoader(harness);
let 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");
});
it('cannot evaluate rules', async function() {
// -- START: Standard Recorder-based unit test loading boilerplate --
let harness = new KeyboardHarness({}, MinimalKeymanGlobal);
let keyboardLoader = new NodeKeyboardLoader(harness);
let 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.
let ruleHarness = new KeyboardInterface({}, MinimalKeymanGlobal);
ruleHarness.activeKeyboard = keyboard;
try {
ruleHarness.processKeystroke(new Mock(), keyboard.constructNullKeyEvent(device));
assert.fail();
} catch (err) {
// Drives home an important detail: the 'global' object is effectively
// closure-captured. (Similar constraints may occur when experimenting with
// 'sandboxed' keyboard loading in the DOM!)
assert.equal(err.message, 'k.KKM is not a function');
}
});
it('accurately determines supported gesture types', async () => {
// -- START: Standard Recorder-based unit test loading boilerplate --
let harness = new KeyboardHarness({}, MinimalKeymanGlobal);
let keyboardLoader = new NodeKeyboardLoader(harness);
let km_keyboard = await keyboardLoader.loadKeyboardFromPath(khmerPath);
// -- END: Standard Recorder-based unit test loading boilerplate --
// `khmer_angkor` - supports longpresses, but not flicks or multitaps.
const desktopLayout = km_keyboard.layout('desktop');
assert.isFalse(desktopLayout.hasFlicks);
assert.isFalse(desktopLayout.hasLongpresses);
assert.isFalse(desktopLayout.hasMultitaps);
const mobileLayout = km_keyboard.layout('phone');
assert.isFalse(mobileLayout.hasFlicks);
assert.isTrue(mobileLayout.hasLongpresses);
assert.isFalse(mobileLayout.hasMultitaps);
});
});
});

View file

@ -0,0 +1,132 @@
import { assert } from 'chai';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
import { KeyboardHarness, MinimalKeymanGlobal, KeyboardDownloadError, DeviceSpec, KeyboardMissingError } from 'keyman/engine/keyboard';
import { KeyboardInterface, Mock } from 'keyman/engine/js-processor';
import { NodeKeyboardLoader } from 'keyman/engine/keyboard/node-keyboard-loader';
// async function assertThrowsAsync(fn: () => Promise<any>, message?: string): Promise<void>;
async function assertThrowsAsync(fn: () => Promise<any>, type?: any, message?: string): Promise<void> {
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);
}
}
}
function assertThrows(fn: () => any, message?: string): void;
function assertThrows(fn: () => any, type?: any, message?: string): void {
if (typeof (type) === 'string') {
message = type;
type = undefined;
}
assert.throws(fn, type, message);
}
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 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', async function() {
// -- 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 function () {
const harness = new KeyboardInterface({}, MinimalKeymanGlobal);
const keyboardLoader = new NodeKeyboardLoader(harness);
await assertThrowsAsync(() => keyboardLoader.loadKeyboardFromPath(nonExisting),
KeyboardDownloadError, `Unable to read keyboard file at ${nonExisting}`);
});
it('throws error when keyboard is invalid', async function () {
const harness = new KeyboardInterface({}, MinimalKeymanGlobal);
const keyboardLoader = new NodeKeyboardLoader(harness);
await assertThrowsAsync(() => keyboardLoader.loadKeyboardFromPath(nonKeyboardPath),
KeyboardMissingError, `Cannot find the keyboard at ${nonKeyboardPath}.`);
});
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");
});
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.
const ruleHarness = new KeyboardInterface({}, MinimalKeymanGlobal);
ruleHarness.activeKeyboard = keyboard;
assertThrows(() => ruleHarness.processKeystroke(new Mock(), keyboard.constructNullKeyEvent(device)), 'k.KKM is not a function');
});
it('accurately determines supported gesture types', 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.
const desktopLayout = km_keyboard.layout(DeviceSpec.FormFactor.Desktop);
assert.isFalse(desktopLayout.hasFlicks);
assert.isFalse(desktopLayout.hasLongpresses);
assert.isFalse(desktopLayout.hasMultitaps);
const mobileLayout = km_keyboard.layout(DeviceSpec.FormFactor.Phone);
assert.isFalse(mobileLayout.hasFlicks);
assert.isTrue(mobileLayout.hasLongpresses);
assert.isFalse(mobileLayout.hasMultitaps);
});
});
});