From 5e330dfdeade6d5ef24e8367ef70debda6a95321 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Wed, 16 Sep 2026 11:54:27 -0500 Subject: [PATCH] refactor(web): fully unbundles worker with engine-init configurable source path --- web/src/app/browser/src/main.ts | 4 +- web/src/app/webview/src/main.ts | 4 +- web/src/engine/build.sh | 3 + .../predictive-text/worker-main/src/index.ts | 6 +- .../worker-main/src/node/index.ts | 4 +- .../worker-main/src/node/mappedWorker.ts | 87 +++++++++---------- .../worker-main/src/node/node-worker.ts | 6 +- .../worker-main/src/web/index.ts | 4 +- .../worker-main/src/web/web-worker.ts | 13 +-- .../worker-dummy-integration.tests.js | 5 +- .../headless/worker-trie-integration.tests.js | 5 +- .../cases/top-level-lmlayer.tests.ts | 35 +------- .../cases/worker-dummy-integration.tests.ts | 13 +-- .../cases/worker-trie-integration.tests.ts | 10 +-- .../unit_tests/in_browser/helpers.mjs | 4 +- .../tests/test-runner/cases/worker.tests.ts | 2 +- .../test-runner/web-test-runner.config.mjs | 2 +- .../src/main/headless/languageProcessor.ts | 49 ++++++----- web/src/engine/src/main/keymanEngineBase.ts | 1 + .../prediction/predictionContext.tests.ts | 7 +- .../engine/main/inputProcessor.tests.ts | 57 ++++++++---- .../engine/main/languageProcessor.tests.ts | 7 +- web/src/test/auto/resources/index.ts | 4 + web/src/test/auto/tsconfig.json | 3 +- 24 files changed, 172 insertions(+), 163 deletions(-) diff --git a/web/src/app/browser/src/main.ts b/web/src/app/browser/src/main.ts index 3e3a168ccd9..2adf7faef39 100644 --- a/web/src/app/browser/src/main.ts +++ b/web/src/app/browser/src/main.ts @@ -1,5 +1,5 @@ import { KeymanEngine } from './keymanEngine.js' -import { WebWorker } from '@keymanapp/lexical-model-layer/web' +import { WebWorkerFactory } from '@keymanapp/lexical-model-layer/web' /** * Determine path and protocol of executing script, setting them as @@ -9,4 +9,4 @@ const ss = (document.currentScript as HTMLScriptElement)?.src; const sPath = ss ? ss.substring(0, ss.lastIndexOf('/') + 1) : './'; // @ts-ignore -window['keyman'] = new KeymanEngine(WebWorker, sPath); \ No newline at end of file +window['keyman'] = new KeymanEngine(new WebWorkerFactory(), sPath); \ No newline at end of file diff --git a/web/src/app/webview/src/main.ts b/web/src/app/webview/src/main.ts index 3e3a168ccd9..2adf7faef39 100644 --- a/web/src/app/webview/src/main.ts +++ b/web/src/app/webview/src/main.ts @@ -1,5 +1,5 @@ import { KeymanEngine } from './keymanEngine.js' -import { WebWorker } from '@keymanapp/lexical-model-layer/web' +import { WebWorkerFactory } from '@keymanapp/lexical-model-layer/web' /** * Determine path and protocol of executing script, setting them as @@ -9,4 +9,4 @@ const ss = (document.currentScript as HTMLScriptElement)?.src; const sPath = ss ? ss.substring(0, ss.lastIndexOf('/') + 1) : './'; // @ts-ignore -window['keyman'] = new KeymanEngine(WebWorker, sPath); \ No newline at end of file +window['keyman'] = new KeymanEngine(new WebWorkerFactory(), sPath); \ No newline at end of file diff --git a/web/src/engine/build.sh b/web/src/engine/build.sh index 70150fe681c..c2958b35458 100755 --- a/web/src/engine/build.sh +++ b/web/src/engine/build.sh @@ -61,6 +61,9 @@ do_build () { } run_tests() { + # Ensure test resources are properly built. + tsc -b ../test/auto/resources + # Run javascript tests # # Trying to run languageProcessor.tests.js with c8 coverage fails with: diff --git a/web/src/engine/predictive-text/worker-main/src/index.ts b/web/src/engine/predictive-text/worker-main/src/index.ts index bdee0489174..47b10f772a9 100644 --- a/web/src/engine/predictive-text/worker-main/src/index.ts +++ b/web/src/engine/predictive-text/worker-main/src/index.ts @@ -1,7 +1,7 @@ export { LMLayer } from './lmlayer.js'; -export { NodeWorker } from './node/node-worker.js'; -export { WebWorker } from './web/web-worker.js'; +export { NodeWorkerFactory } from './node/node-worker.js'; +export { WebWorkerFactory } from './web/web-worker.js'; export interface WorkerFactory { - constructInstance(): Worker + constructInstance(workerSourcePath: string): Worker } \ No newline at end of file diff --git a/web/src/engine/predictive-text/worker-main/src/node/index.ts b/web/src/engine/predictive-text/worker-main/src/node/index.ts index d9156a9ef5b..9bafefa86e8 100644 --- a/web/src/engine/predictive-text/worker-main/src/node/index.ts +++ b/web/src/engine/predictive-text/worker-main/src/node/index.ts @@ -1,6 +1,6 @@ export { LMLayer } from '../lmlayer.js'; -export { NodeWorker } from './node-worker.js'; +export { NodeWorkerFactory } from './node-worker.js'; export interface WorkerFactory { - constructInstance(): Worker + constructInstance(workerSource: string): Worker } \ No newline at end of file diff --git a/web/src/engine/predictive-text/worker-main/src/node/mappedWorker.ts b/web/src/engine/predictive-text/worker-main/src/node/mappedWorker.ts index a2acbd68da4..9ab418b90ff 100644 --- a/web/src/engine/predictive-text/worker-main/src/node/mappedWorker.ts +++ b/web/src/engine/predictive-text/worker-main/src/node/mappedWorker.ts @@ -8,58 +8,57 @@ import { Buffer } from 'node:buffer'; import { URL } from 'node:url'; /** - * Defines mappings from Node Worker signatures to WebWorker signatures - * - * TODO: move this to a separate module, no need for it to be embedded string + * Uses the Node version of Workers to provide proper, authentic separate-thread + * 'sandboxing'. Also intercepts and interprets certain WebWorker method signatures + * necessary to run the WebWorker-oriented worker code. */ -const nodeWorkerToWebWorkerMappingSource = ` -import { parentPort } from 'node:worker_threads'; -import fs from 'node:fs'; -import vm from 'node:vm'; +export class MappedWorker extends worker.Worker implements Worker { + constructor(sourcePathString: string) { + /** + * Defines mappings from Node Worker signatures to WebWorker signatures + * + * TODO: move this to a separate module, no need for it to be embedded string + */ + const nodeWorkerToWebWorkerMappingSource = ` + import { parentPort } from 'node:worker_threads'; + import fs from 'node:fs'; + import vm from 'node:vm'; -function postMessage(...args) { - parentPort.postMessage.call(parentPort, args); -} + // Useful for diagnosis, but leads to noisiness in test logs + // console.dir(import.meta); -parentPort.on('message', (ev) => { - onmessage({data: ev}); -}); + function postMessage(...args) { + parentPort.postMessage.call(parentPort, args); + } -function importScripts(...args) { - function loadScriptInContext(scriptPath) { - let scriptStr = fs.readFileSync(scriptPath); - var script = new vm.Script(scriptStr, { filename: scriptPath }); - script.runInThisContext(); - } - - for(let arg of args) { - loadScriptInContext(arg); - } -} + parentPort.on('message', (ev) => { + onmessage({data: ev}); + }); -/* - * You'd think the method signature mapping would be implied from the first line, - * but all three lines must be explicitly specified or the emulation will fail. - */ -const self = globalThis; -self.postMessage = postMessage; -self.importScripts = importScripts; -self.self = self; // make it global! -// Start off by importing the main worker itself -// importScripts('${import.meta.dirname}/../../../worker-thread/build/lib/worker-main.js'); -console.dir(import.meta); -importScripts('${import.meta.dirname}/worker-main.js'); -`; + function importScripts(...args) { + function loadScriptInContext(scriptPath) { + let scriptStr = fs.readFileSync(scriptPath); + var script = new vm.Script(scriptStr, { filename: scriptPath }); + script.runInThisContext(); + } + for(let arg of args) { + loadScriptInContext(arg); + } + } + /* + * You'd think the method signature mapping would be implied from the first line, + * but all three lines must be explicitly specified or the emulation will fail. + */ + const self = globalThis; + self.postMessage = postMessage; + self.importScripts = importScripts; + self.self = self; // make it global! + // Start off by importing the main worker itself + importScripts('${sourcePathString}'); + `; -/** - * Uses the Node version of Workers to provide proper, authentic separate-thread - * 'sandboxing'. Also intercepts and interprets certain WebWorker method signatures - * necessary to run the WebWorker-oriented worker code. - */ -export class MappedWorker extends worker.Worker implements Worker { - constructor() { const buffer = Buffer.from(nodeWorkerToWebWorkerMappingSource); const dataSrc = "data:text/javascript;base64," + buffer.toString('base64'); //@ts-ignore diff --git a/web/src/engine/predictive-text/worker-main/src/node/node-worker.ts b/web/src/engine/predictive-text/worker-main/src/node/node-worker.ts index 8d18badf0de..9afbe1b3336 100644 --- a/web/src/engine/predictive-text/worker-main/src/node/node-worker.ts +++ b/web/src/engine/predictive-text/worker-main/src/node/node-worker.ts @@ -4,8 +4,8 @@ import { MappedWorker } from "./mappedWorker.js"; // TODO: eliminate MappedWorker as its own thing -export class NodeWorker { - static constructInstance(): Worker { - return new MappedWorker(); +export class NodeWorkerFactory { + constructInstance(workerSource: string): Worker { + return new MappedWorker(workerSource); } } \ No newline at end of file diff --git a/web/src/engine/predictive-text/worker-main/src/web/index.ts b/web/src/engine/predictive-text/worker-main/src/web/index.ts index 25bdb2522ff..cea3ae507b2 100644 --- a/web/src/engine/predictive-text/worker-main/src/web/index.ts +++ b/web/src/engine/predictive-text/worker-main/src/web/index.ts @@ -1,6 +1,6 @@ export { LMLayer } from '../lmlayer.js'; -export { WebWorker } from './web-worker.js'; +export { WebWorkerFactory } from './web-worker.js'; export interface WorkerFactory { - constructInstance(): Worker + constructInstance(workerSource: string): Worker } diff --git a/web/src/engine/predictive-text/worker-main/src/web/web-worker.ts b/web/src/engine/predictive-text/worker-main/src/web/web-worker.ts index c55a5cd630f..f3bd3305f75 100644 --- a/web/src/engine/predictive-text/worker-main/src/web/web-worker.ts +++ b/web/src/engine/predictive-text/worker-main/src/web/web-worker.ts @@ -2,15 +2,8 @@ * Keyman is copyright (C) SIL Global. MIT License. */ -export class WebWorker { - static constructInstance(): Worker { - return new Worker(this.workerURI()); - } - - static workerURI(): string { - // TODO: worker-thread generates worker-main.js,... whaaa - // TODO: worker-main.min.js? - // TODO: paths - return './worker-main.js'; +export class WebWorkerFactory { + constructInstance(workerSource: string): Worker { + return new Worker(workerSource); } } \ No newline at end of file diff --git a/web/src/engine/predictive-text/worker-main/unit_tests/headless/worker-dummy-integration.tests.js b/web/src/engine/predictive-text/worker-main/unit_tests/headless/worker-dummy-integration.tests.js index 7fcce07e5db..87e8da45371 100644 --- a/web/src/engine/predictive-text/worker-main/unit_tests/headless/worker-dummy-integration.tests.js +++ b/web/src/engine/predictive-text/worker-main/unit_tests/headless/worker-dummy-integration.tests.js @@ -4,8 +4,9 @@ import fs from 'fs'; import { createRequire } from 'module'; const require = createRequire(import.meta.url); -import { LMLayer, NodeWorker as Worker } from '#./node/index.js'; +import { LMLayer, NodeWorkerFactory } from '#./node/index.js'; import { capabilities, iGotDistractedByHazel } from '@keymanapp/common-test-resources/model-helpers.mjs'; +import { getWorkerPath } from 'keyman/test/resources'; /* * Shows off the LMLayer API, using the full prediction interface. @@ -20,7 +21,7 @@ describe('LMLayer using dummy model', function () { let worker; beforeEach(function() { - worker = Worker.constructInstance(); + worker = (new NodeWorkerFactory()).constructInstance(getWorkerPath()); lmLayer = new LMLayer(capabilities(), worker); }); diff --git a/web/src/engine/predictive-text/worker-main/unit_tests/headless/worker-trie-integration.tests.js b/web/src/engine/predictive-text/worker-main/unit_tests/headless/worker-trie-integration.tests.js index aa248a3c443..38adb7dcaea 100644 --- a/web/src/engine/predictive-text/worker-main/unit_tests/headless/worker-trie-integration.tests.js +++ b/web/src/engine/predictive-text/worker-main/unit_tests/headless/worker-trie-integration.tests.js @@ -3,8 +3,9 @@ import { assert } from 'chai'; import { createRequire } from 'module'; const require = createRequire(import.meta.url); -import { LMLayer, NodeWorker as Worker } from '#./node/index.js'; +import { LMLayer, NodeWorkerFactory } from '#./node/index.js'; import { capabilities } from '@keymanapp/common-test-resources/model-helpers.mjs'; +import { getWorkerPath } from 'keyman/test/resources'; /* * How to run the worlist @@ -14,7 +15,7 @@ describe('LMLayer using the trie model', function () { let worker; beforeEach(function() { - worker = Worker.constructInstance(); + worker = (new NodeWorkerFactory()).constructInstance(getWorkerPath()); lmLayer = new LMLayer(capabilities(), worker, true); }); diff --git a/web/src/engine/predictive-text/worker-main/unit_tests/in_browser/cases/top-level-lmlayer.tests.ts b/web/src/engine/predictive-text/worker-main/unit_tests/in_browser/cases/top-level-lmlayer.tests.ts index df5cb45e3bc..c8eb9a217f7 100644 --- a/web/src/engine/predictive-text/worker-main/unit_tests/in_browser/cases/top-level-lmlayer.tests.ts +++ b/web/src/engine/predictive-text/worker-main/unit_tests/in_browser/cases/top-level-lmlayer.tests.ts @@ -1,47 +1,18 @@ import { assert } from 'chai'; -import { LMLayer, WebWorker } from "@keymanapp/lexical-model-layer/web"; +import { LMLayer, WebWorkerFactory } from "@keymanapp/lexical-model-layer/web"; import { DEFAULT_BROWSER_TIMEOUT } from '@keymanapp/common-test-resources/test-timeouts.mjs'; -import { defaultCapabilities } from '../helpers.mjs'; +import { defaultCapabilities, workerPath } from '../helpers.mjs'; describe('LMLayer', function () { this.timeout(DEFAULT_BROWSER_TIMEOUT); describe('[[constructor]]', function () { it('should construct with a single argument', function () { - let lmLayer = new LMLayer(defaultCapabilities, WebWorker.constructInstance(), true); + let lmLayer = new LMLayer(defaultCapabilities, (new WebWorkerFactory()).constructInstance(workerPath), true); assert.instanceOf(lmLayer, LMLayer); lmLayer.shutdown(); }); }); - - describe('#asBlobURI()', function () { - // #asBlobURI() requires browser APIs, hence why it cannot be tested headless in Node. - it('should take a function and convert it into a blob function', function (done) { - function dummyHandler() { - // Post something weird, so we can be reasonably certain the Web Worker is... - // well, working. - // WARNING: Do NOT refactor this string as a variable. It **MUST** remain a string - // in this function body, because the code in this function's body gets - // stringified! - postMessage('fhqwhgads'); - } - - // Note: the full declaration exists; the code we want is wrapped within the func. - // So... let's just call the func. - const workerSrc = dummyHandler.toString() + "\ndummyHandler()"; - let uri = WebWorker.asBlobURI(workerSrc); - assert.match(uri, /^blob:/); - - let worker = new Worker(uri); - worker.onmessage = function thisShouldBeCalled(event) { - assert.propertyVal(event, 'data', 'fhqwhgads'); - worker.terminate(); - done(); - }; - - worker.postMessage('test'); - }) - }) }); diff --git a/web/src/engine/predictive-text/worker-main/unit_tests/in_browser/cases/worker-dummy-integration.tests.ts b/web/src/engine/predictive-text/worker-main/unit_tests/in_browser/cases/worker-dummy-integration.tests.ts index 04af7ec8dcc..a5e410ace62 100644 --- a/web/src/engine/predictive-text/worker-main/unit_tests/in_browser/cases/worker-dummy-integration.tests.ts +++ b/web/src/engine/predictive-text/worker-main/unit_tests/in_browser/cases/worker-dummy-integration.tests.ts @@ -1,9 +1,9 @@ import { assert } from 'chai'; -import { LMLayer, WebWorker } from "@keymanapp/lexical-model-layer/web"; +import { LMLayer, WebWorkerFactory } from "@keymanapp/lexical-model-layer/web"; import { DEFAULT_BROWSER_TIMEOUT } from '@keymanapp/common-test-resources/test-timeouts.mjs'; -import { defaultCapabilities } from '../helpers.mjs'; +import { defaultCapabilities, workerPath } from '../helpers.mjs'; // Import assertions, even using 'with', aren't yet supported in Firefox's engine. // import hazelModel from '@keymanapp/common-test-resources/json/models/future_suggestions/i_got_distracted_by_hazel.json' with { type: 'json' }; @@ -28,13 +28,14 @@ describe('LMLayer using dummy model', function () { let loc = document.location; // config.testFile generally starts with a '/', with the path resembling the actual full local // filesystem for the drive. - domain = `${loc.protocol}/${loc.host}` + domain = `${loc.protocol}//${loc.host}` // Test-config setups will take care of the rest; the server-path will be rooted at the repo root. // With aliasing for resources/. // Since Firefox can't do JSON imports quite yet. - const hazelFixture = await fetch(new URL(`${domain}/resources/json/models/future_suggestions/i_got_distracted_by_hazel.json`)); + console.log(new URL(`${domain}/common/test/resources/json/models/future_suggestions/i_got_distracted_by_hazel.json`)); + const hazelFixture = await fetch(new URL(`${domain}/common/test/resources/json/models/future_suggestions/i_got_distracted_by_hazel.json`)); hazelModel = await hazelFixture.json(); hazelModel = hazelModel.map((set) => set.map((entry) => { return { @@ -52,7 +53,7 @@ describe('LMLayer using dummy model', function () { describe('Prediction', function () { it('will predict future suggestions', function () { - var lmLayer = new LMLayer(defaultCapabilities, WebWorker.constructInstance(), true); + var lmLayer = new LMLayer(defaultCapabilities, (new WebWorkerFactory()).constructInstance(workerPath), true); var stripIDs = function(suggestions) { suggestions.forEach(function(suggestion) { @@ -93,7 +94,7 @@ describe('LMLayer using dummy model', function () { describe('Wordbreaking', function () { it('will perform (default) wordbreaking and return word at caret', function () { - var lmLayer = new LMLayer(defaultCapabilities, WebWorker.constructInstance()); + var lmLayer = new LMLayer(defaultCapabilities, (new WebWorkerFactory()).constructInstance(workerPath)); // We're testing many as asynchronous messages in a row. // this would be cleaner using async/await syntax, but diff --git a/web/src/engine/predictive-text/worker-main/unit_tests/in_browser/cases/worker-trie-integration.tests.ts b/web/src/engine/predictive-text/worker-main/unit_tests/in_browser/cases/worker-trie-integration.tests.ts index bf8d0aef5c8..bb02249b7a7 100644 --- a/web/src/engine/predictive-text/worker-main/unit_tests/in_browser/cases/worker-trie-integration.tests.ts +++ b/web/src/engine/predictive-text/worker-main/unit_tests/in_browser/cases/worker-trie-integration.tests.ts @@ -1,8 +1,8 @@ import { assert } from 'chai'; -import { LMLayer, WebWorker } from "@keymanapp/lexical-model-layer/web"; +import { LMLayer, WebWorkerFactory } from "@keymanapp/lexical-model-layer/web"; import { DEFAULT_BROWSER_TIMEOUT } from '@keymanapp/common-test-resources/test-timeouts.mjs'; -import { defaultCapabilities } from '../helpers.mjs'; +import { defaultCapabilities, workerPath } from '../helpers.mjs'; // Import assertions, even using 'with', aren't yet supported in Firefox's engine. // import hazelModel from '@keymanapp/common-test-resources/json/models/future_suggestions/i_got_distracted_by_hazel.json' with { type: 'json' }; @@ -17,7 +17,7 @@ describe('LMLayer using the trie model', function () { before(async () => { let loc = document.location; - domain = `${loc.protocol}/${loc.host}`; + domain = `${loc.protocol}//${loc.host}`; // Test-config setups will take care of the rest; the server-path will be rooted at the repo root. // With aliasing for resources/. @@ -29,7 +29,7 @@ describe('LMLayer using the trie model', function () { // Parameter 3 = true: enables 'test mode', disables correction-search timeout. // This helps prevent the correction-search timeout from flaking out periodically during unit tests in // CI, since remote servers / devices are involved. - var lmLayer = new LMLayer(defaultCapabilities, WebWorker.constructInstance(), true); + var lmLayer = new LMLayer(defaultCapabilities, (new WebWorkerFactory()).constructInstance(workerPath), true); // We're testing many as asynchronous messages in a row. // this would be cleaner using async/await syntax, but @@ -71,7 +71,7 @@ describe('LMLayer using the trie model', function () { // // https://community.software.sil.org/t/search-term-to-key-in-lexical-model-not-working-both-ways-by-default/3133 it('should use the default searchTermToKey()', function () { - var lmLayer = new LMLayer(defaultCapabilities, WebWorker.constructInstance(), /* testMode */ true); + var lmLayer = new LMLayer(defaultCapabilities, (new WebWorkerFactory()).constructInstance(workerPath), /* testMode */ true); let loc = document.location; return lmLayer.loadModel( diff --git a/web/src/engine/predictive-text/worker-main/unit_tests/in_browser/helpers.mjs b/web/src/engine/predictive-text/worker-main/unit_tests/in_browser/helpers.mjs index 385379bde64..caae9d9c374 100644 --- a/web/src/engine/predictive-text/worker-main/unit_tests/in_browser/helpers.mjs +++ b/web/src/engine/predictive-text/worker-main/unit_tests/in_browser/helpers.mjs @@ -1,3 +1,5 @@ export let defaultCapabilities = { maxLeftContextCodeUnits: 64 -}; \ No newline at end of file +}; + +export const workerPath = 'web/build/publish/debug/worker-main.js'; \ No newline at end of file diff --git a/web/src/engine/predictive-text/worker-thread/src/tests/test-runner/cases/worker.tests.ts b/web/src/engine/predictive-text/worker-thread/src/tests/test-runner/cases/worker.tests.ts index 9aa9b450226..fa9c7e461a8 100644 --- a/web/src/engine/predictive-text/worker-thread/src/tests/test-runner/cases/worker.tests.ts +++ b/web/src/engine/predictive-text/worker-thread/src/tests/test-runner/cases/worker.tests.ts @@ -6,7 +6,7 @@ describe('LMLayerWorker', function () { describe('Usage within a Web Worker', function () { it('should install itself in the worker context', function (done) { - let worker = new Worker(document.location.protocol + '//' + document.location.host + "/worker-main.js"); + let worker = new Worker(document.location.protocol + '//' + document.location.host + "/web/src/engine/predictive-text/worker-thread/build/lib/worker-main.js"); worker.onmessage = function thisShouldBeCalled(message) { done(); worker.terminate(); diff --git a/web/src/engine/predictive-text/worker-thread/src/tests/test-runner/web-test-runner.config.mjs b/web/src/engine/predictive-text/worker-thread/src/tests/test-runner/web-test-runner.config.mjs index 64ed2763db5..f2007293625 100644 --- a/web/src/engine/predictive-text/worker-thread/src/tests/test-runner/web-test-runner.config.mjs +++ b/web/src/engine/predictive-text/worker-thread/src/tests/test-runner/web-test-runner.config.mjs @@ -8,7 +8,7 @@ import { dirname, resolve } from 'path'; import { LauncherWrapper, sessionStabilityReporter } from '@keymanapp/common-test-resources/test-runner-stability-reporter.mjs'; const dir = dirname(fileURLToPath(import.meta.url)); -const KEYMAN_ROOT = resolve(dir, '../../../../../../'); +const KEYMAN_ROOT = resolve(dir, '../../../../../../../../'); /** @type {import('@web/test-runner').TestRunnerConfig} */ export default { diff --git a/web/src/engine/src/main/headless/languageProcessor.ts b/web/src/engine/src/main/headless/languageProcessor.ts index 20515c22885..703c4cf063e 100644 --- a/web/src/engine/src/main/headless/languageProcessor.ts +++ b/web/src/engine/src/main/headless/languageProcessor.ts @@ -25,26 +25,48 @@ export class LanguageProcessor extends EventEmitter { private _state: StateChangeEnum = 'inactive'; + readonly workerFactory: WorkerFactory; + readonly supportsRightDeletions: boolean; + private workerPath: string; + public constructor(predictiveWorkerFactory: WorkerFactory, transcriptionCache: TranscriptionCache, supportsRightDeletions: boolean = false) { super(); + this.workerFactory = predictiveWorkerFactory; this.recentTranscriptions = transcriptionCache; + this.supportsRightDeletions = supportsRightDeletions; + } + + public get activeModel(): ModelSpec { + return this.currentModel; + } + + public get isConfigured(): boolean { + return !!this.configuration; + } + + public get state(): StateChangeEnum { + return this._state; + } + + public init(path: string) { + if(!this.workerFactory || this.workerPath) { + return; + } + + this.workerPath = path; // Establishes KMW's platform 'capabilities', which limit the range of context a LMLayer // model may expect. const capabilities: Capabilities = { maxLeftContextCodePoints: 64, // Since the apps don't yet support right-deletions. - maxRightContextCodePoints: supportsRightDeletions ? 0 : 64 - } - - if(!predictiveWorkerFactory) { - return; + maxRightContextCodePoints: this.supportsRightDeletions ? 0 : 64 } let workerInstance: Worker; try { - workerInstance = predictiveWorkerFactory?.constructInstance(); + workerInstance = this.workerFactory?.constructInstance(this.workerPath); } catch(e) { // We can condition on `lmEngine` being null/undefined. console.warn('Web workers are not available: ' + (e ?? '').toString()); @@ -55,18 +77,6 @@ export class LanguageProcessor extends EventEmitter { } } - public get activeModel(): ModelSpec { - return this.currentModel; - } - - public get isConfigured(): boolean { - return !!this.configuration; - } - - public get state(): StateChangeEnum { - return this._state; - } - public unloadModel() { if(!this.canEnable) { return; @@ -429,7 +439,6 @@ export class LanguageProcessor extends EventEmitter { public get isActive(): boolean { if(!this.canEnable) { - this._mayPredict = false; return false; } return (this.activeModel || false) && this._mayPredict; @@ -441,7 +450,7 @@ export class LanguageProcessor extends EventEmitter { } public get mayPredict() { - return this._mayPredict; + return this.canEnable ? !!this._mayPredict : false; } public set mayPredict(flag: boolean) { diff --git a/web/src/engine/src/main/keymanEngineBase.ts b/web/src/engine/src/main/keymanEngineBase.ts index 2ee22a2b370..0812e687630 100644 --- a/web/src/engine/src/main/keymanEngineBase.ts +++ b/web/src/engine/src/main/keymanEngineBase.ts @@ -240,6 +240,7 @@ export class KeymanEngineBase< } config.initialize(optionSpec); + this.core.languageProcessor.init(config.sourcePath + '/worker-main.js'); // Initialize supplementary plane string extensions KMWString.enableSupplementaryPlane(true); diff --git a/web/src/test/auto/headless/engine/interfaces/prediction/predictionContext.tests.ts b/web/src/test/auto/headless/engine/interfaces/prediction/predictionContext.tests.ts index 15faf2189fd..92e5ad9e5f3 100644 --- a/web/src/test/auto/headless/engine/interfaces/prediction/predictionContext.tests.ts +++ b/web/src/test/auto/headless/engine/interfaces/prediction/predictionContext.tests.ts @@ -5,9 +5,11 @@ import { LexicalModelTypes } from '@keymanapp/common-types'; import { LanguageProcessor, TranscriptionCache } from 'keyman/engine/main'; import { PredictionContext } from 'keyman/engine/interfaces'; -import { NodeWorker } from "@keymanapp/lexical-model-layer/node"; +import { NodeWorkerFactory } from "@keymanapp/lexical-model-layer/node"; import { SyntheticTextStore } from 'keyman/engine/keyboard'; +import { getWorkerPath } from 'keyman/test/resources'; + import Suggestion = LexicalModelTypes.Suggestion; function compileDummyModel(suggestionSets: Suggestion[][]) { @@ -70,7 +72,8 @@ describe("PredictionContext", () => { let langProcessor: LanguageProcessor; beforeEach(function() { - langProcessor = new LanguageProcessor(NodeWorker, new TranscriptionCache()); + langProcessor = new LanguageProcessor(new NodeWorkerFactory(), new TranscriptionCache()); + langProcessor.init(getWorkerPath()); }); afterEach(function() { diff --git a/web/src/test/auto/headless/engine/main/inputProcessor.tests.ts b/web/src/test/auto/headless/engine/main/inputProcessor.tests.ts index ac9926467df..b36f02352b2 100644 --- a/web/src/test/auto/headless/engine/main/inputProcessor.tests.ts +++ b/web/src/test/auto/headless/engine/main/inputProcessor.tests.ts @@ -7,8 +7,8 @@ import * as sinon from 'sinon'; import { LexicalModelTypes } from '@keymanapp/common-types'; import { KeyboardTest, RecordedPhysicalKeystroke, RecordedSequenceTestSet } from '@keymanapp/recorder-core'; -import { NodeWorker } from '@keymanapp/lexical-model-layer/node'; -import { DeviceSpec, KMWString } from 'keyman/common/web-utils'; +import { NodeWorkerFactory } from '@keymanapp/lexical-model-layer/node'; +import { DeviceSpec, KMWString, timedPromise } from 'keyman/common/web-utils'; import { InputProcessor } from 'keyman/engine/main'; import { JSKeyboardInterface } from 'keyman/engine/js-processor'; @@ -17,6 +17,8 @@ import { PredictionContext } from 'keyman/engine/interfaces'; import { DEFAULT_PROCESSOR_INIT_OPTIONS, NodeKeyboardLoader } from 'keyman/test/resources'; import { VariableStoreTestSerializer } from 'keyman/test/headless-resources'; +import { getWorkerPath } from 'keyman/test/resources'; + import Context = LexicalModelTypes.Context; import Suggestion = LexicalModelTypes.Suggestion; @@ -62,13 +64,10 @@ describe('InputProcessor', function() { assert.isNotNull(core); }); - it('has expected default values after initialization', function () { - let core; + it('has expected default values after initialization', async function () { + let core: InputProcessor; try { - // Can construct without the second parameter; if so, the final assertion - .mayPredict - // will be invalidated. (No worker, no ability to predict.) - // @ts-ignore - core = new InputProcessor(device, NodeWorker, { + core = new InputProcessor(device, new NodeWorkerFactory(), { baseLayout: 'us', keyboardInterface: new JSKeyboardInterface({}, null, new VariableStoreTestSerializer()), defaultOutputRules: new DefaultOutputRules() @@ -88,7 +87,17 @@ describe('InputProcessor', function() { assert.equal('default', core.keyboardProcessor.layerId, 'Default layer is not set to "default"'); assert.isUndefined(core.keyboardProcessor.activeKeyboard, 'Initialized with already-active keyboard'); - // Lifted from languageProcessor.js - the core should not be changing these with its init. + // Before the language-processor is initialized, the worker is unconstructed. + assert.isUndefined(core.languageProcessor.activeModel); + assert.isFalse(core.languageProcessor.isActive); + assert.isFalse(core.languageProcessor.mayPredict); + assert.isFalse(core.languageProcessor.canEnable); + + core.languageProcessor.init(getWorkerPath()); + + await timedPromise(250); + + // The worker should, after a wait, be initialized in a state without a loaded model. assert.isUndefined(core.languageProcessor.activeModel); assert.isFalse(core.languageProcessor.isActive); assert.isTrue(core.languageProcessor.mayPredict); @@ -309,8 +318,9 @@ describe('InputProcessor', function() { }); it('replaces appended whitespace when a manually-applied suggestion is followed by a K_SPACE (dummy models)', async () => { - const core = new InputProcessor(device, NodeWorker, {...DEFAULT_PROCESSOR_INIT_OPTIONS, keyboardInterface: keyboardWithHarness}); + const core = new InputProcessor(device, new NodeWorkerFactory(), {...DEFAULT_PROCESSOR_INIT_OPTIONS, keyboardInterface: keyboardWithHarness}); const langProcessor = core.languageProcessor; + langProcessor.init(getWorkerPath()); try { await langProcessor.loadModel(simpleTestingDummyModel); @@ -366,8 +376,9 @@ describe('InputProcessor', function() { }); it('replaces appended whitespace when a manually-applied suggestion is followed by a K_SPACE (trie models)', async () => { - const core = new InputProcessor(device, NodeWorker, {...DEFAULT_PROCESSOR_INIT_OPTIONS, keyboardInterface: keyboardWithHarness}); + const core = new InputProcessor(device, new NodeWorkerFactory(), {...DEFAULT_PROCESSOR_INIT_OPTIONS, keyboardInterface: keyboardWithHarness}); const langProcessor = core.languageProcessor; + langProcessor.init(getWorkerPath()); try { await langProcessor.loadModel(simpleTrieModel); @@ -424,8 +435,9 @@ describe('InputProcessor', function() { }); it('auto-applies a suggestion properly when available and triggered appropriately', async () => { - const core = new InputProcessor(device, NodeWorker, {...DEFAULT_PROCESSOR_INIT_OPTIONS, keyboardInterface: keyboardWithHarness}); + const core = new InputProcessor(device, new NodeWorkerFactory(), {...DEFAULT_PROCESSOR_INIT_OPTIONS, keyboardInterface: keyboardWithHarness}); const langProcessor = core.languageProcessor; + langProcessor.init(getWorkerPath()); try { await langProcessor.loadModel(simpleTestingDummyModel); @@ -476,8 +488,9 @@ describe('InputProcessor', function() { }); it('displays a reversion after manually applying a suggestion and immediately backspacing', async () => { - const core = new InputProcessor(device, NodeWorker, {...DEFAULT_PROCESSOR_INIT_OPTIONS, keyboardInterface: keyboardWithHarness}); + const core = new InputProcessor(device, new NodeWorkerFactory(), {...DEFAULT_PROCESSOR_INIT_OPTIONS, keyboardInterface: keyboardWithHarness}); const langProcessor = core.languageProcessor; + langProcessor.init(getWorkerPath()); try { // This feature only activates with 14.0+ models. @@ -533,8 +546,9 @@ describe('InputProcessor', function() { }); it('displays a reversion after returning to the whitespace after a manually-applied suggestion', async () => { - const core = new InputProcessor(device, NodeWorker, {...DEFAULT_PROCESSOR_INIT_OPTIONS, keyboardInterface: keyboardWithHarness}); + const core = new InputProcessor(device, new NodeWorkerFactory(), {...DEFAULT_PROCESSOR_INIT_OPTIONS, keyboardInterface: keyboardWithHarness}); const langProcessor = core.languageProcessor; + langProcessor.init(getWorkerPath()); try { // This feature only activates with 14.0+ models. @@ -594,8 +608,9 @@ describe('InputProcessor', function() { }); it("displays a reversion after returning to the end of a manually-applied suggestion's body", async () => { - const core = new InputProcessor(device, NodeWorker, {...DEFAULT_PROCESSOR_INIT_OPTIONS, keyboardInterface: keyboardWithHarness}); + const core = new InputProcessor(device, new NodeWorkerFactory(), {...DEFAULT_PROCESSOR_INIT_OPTIONS, keyboardInterface: keyboardWithHarness}); const langProcessor = core.languageProcessor; + langProcessor.init(getWorkerPath()); try { // This feature only activates with 14.0+ models. @@ -656,8 +671,9 @@ describe('InputProcessor', function() { }); it("does not display a reversion after backspacing part of an applied suggestion", async () => { - const core = new InputProcessor(device, NodeWorker, {...DEFAULT_PROCESSOR_INIT_OPTIONS, keyboardInterface: keyboardWithHarness}); + const core = new InputProcessor(device, new NodeWorkerFactory(), {...DEFAULT_PROCESSOR_INIT_OPTIONS, keyboardInterface: keyboardWithHarness}); const langProcessor = core.languageProcessor; + langProcessor.init(getWorkerPath()); try { // This feature only activates with 14.0+ models. @@ -720,8 +736,9 @@ describe('InputProcessor', function() { }); it('displays a reversion after auto-applying a suggestion and immediately backspacing', async () => { - const core = new InputProcessor(device, NodeWorker, {...DEFAULT_PROCESSOR_INIT_OPTIONS, keyboardInterface: keyboardWithHarness}); + const core = new InputProcessor(device, new NodeWorkerFactory(), {...DEFAULT_PROCESSOR_INIT_OPTIONS, keyboardInterface: keyboardWithHarness}); const langProcessor = core.languageProcessor; + langProcessor.init(getWorkerPath()); try { // This feature only activates with 14.0+ models. @@ -775,8 +792,9 @@ describe('InputProcessor', function() { }); it('displays a reversion after returning to the whitespace after a auto-applied suggestion', async () => { - const core = new InputProcessor(device, NodeWorker, {...DEFAULT_PROCESSOR_INIT_OPTIONS, keyboardInterface: keyboardWithHarness}); + const core = new InputProcessor(device, new NodeWorkerFactory(), {...DEFAULT_PROCESSOR_INIT_OPTIONS, keyboardInterface: keyboardWithHarness}); const langProcessor = core.languageProcessor; + langProcessor.init(getWorkerPath()); try { // This feature only activates with 14.0+ models. @@ -829,8 +847,9 @@ describe('InputProcessor', function() { }); it("displays a reversion after returning to the end of an auto-applied suggestion's body", async () => { - const core = new InputProcessor(device, NodeWorker, {...DEFAULT_PROCESSOR_INIT_OPTIONS, keyboardInterface: keyboardWithHarness}); + const core = new InputProcessor(device, new NodeWorkerFactory(), {...DEFAULT_PROCESSOR_INIT_OPTIONS, keyboardInterface: keyboardWithHarness}); const langProcessor = core.languageProcessor; + langProcessor.init(getWorkerPath()); try { // This feature only activates with 14.0+ models. diff --git a/web/src/test/auto/headless/engine/main/languageProcessor.tests.ts b/web/src/test/auto/headless/engine/main/languageProcessor.tests.ts index 3c22f90f500..4386a9f3449 100644 --- a/web/src/test/auto/headless/engine/main/languageProcessor.tests.ts +++ b/web/src/test/auto/headless/engine/main/languageProcessor.tests.ts @@ -8,7 +8,7 @@ import path from 'node:path'; import { assert } from 'chai'; -import { NodeWorker as LMWorker } from "@keymanapp/lexical-model-layer/node"; +import { NodeWorkerFactory } from "@keymanapp/lexical-model-layer/node"; import { LexicalModelCompiler } from '@keymanapp/kmc-model'; import { TestCompilerCallbacks } from '@keymanapp/developer-test-helpers'; import { SyntheticTextStore } from 'keyman/engine/keyboard'; @@ -17,6 +17,8 @@ import { LanguageProcessor, TranscriptionCache } from 'keyman/engine/main'; import { ModelSpec } from 'keyman/engine/interfaces'; import { MinimalKeymanGlobal } from 'keyman/engine/keyboard'; +import { getWorkerPath } from 'keyman/test/resources'; + const KEYMAN_ROOT = env.KEYMAN_ROOT; declare global { @@ -35,7 +37,8 @@ describe('LanguageProcessor', function() { const callbacks = new TestCompilerCallbacks(this); beforeEach(function() { - languageProcessor = new LanguageProcessor(LMWorker, new TranscriptionCache()); + languageProcessor = new LanguageProcessor(new NodeWorkerFactory(), new TranscriptionCache()); + languageProcessor.init(getWorkerPath()); }); afterEach(function() { diff --git a/web/src/test/auto/resources/index.ts b/web/src/test/auto/resources/index.ts index 7a770a9f3df..e2fd2e7e422 100644 --- a/web/src/test/auto/resources/index.ts +++ b/web/src/test/auto/resources/index.ts @@ -19,3 +19,7 @@ export function getKeymanRoot(): string { export function getWebTestResourcesPath(): string { return getKeymanRoot() + '/web/src/test/auto/resources'; } + +export function getWorkerPath(): string { + return `${getKeymanRoot()}web/build/publish/release/worker-main.js`; +} diff --git a/web/src/test/auto/tsconfig.json b/web/src/test/auto/tsconfig.json index 3c4f46fb4c3..cdd4d9aedbf 100644 --- a/web/src/test/auto/tsconfig.json +++ b/web/src/test/auto/tsconfig.json @@ -17,12 +17,11 @@ "headless/**/*.ts", "integrated/**/*.ts", "resources/@types/*.d.ts", - "predictive-text/**/*.ts", + "resources/**/.ts", ], "references": [ { "path": "../../engine" }, - { "path": "resources" }, { "path": "headless-resources" }, ] }