Merge pull request #9719 from keymanapp/fix/web/integration-test-fixes

fix(web): gesture-engine integration - automated test patchup 🐵
This commit is contained in:
Joshua Horton 2023-10-20 16:17:48 +07:00 committed by GitHub
commit 3ef85f34e7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 259 additions and 112 deletions

View file

@ -108,7 +108,8 @@ export class GestureSequence<Type> extends EventEmitter<EventMap<Type>> {
this.selector = selector;
this.selector.on('rejectionwithaction', this.modelResetHandler);
this.once('complete', () => {
this.selector.off('rejectionwithaction', this.modelResetHandler)
this.selector.off('rejectionwithaction', this.modelResetHandler);
this.selector.dropSourcesWithIds(this.allSourceIds);
// Dropping the reference here gives us two benefits:
// 1. Allows garbage collection to do its thing; this might be the last reference left to the selector instance.
@ -130,7 +131,9 @@ export class GestureSequence<Type> extends EventEmitter<EventMap<Type>> {
}
public get allSourceIds(): string[] {
return this.stageReports[this.stageReports.length - 1]?.allSourceIds;
// Note: there is a brief window of time - between construction & the deferred first
// 'stage' event - during which this array may be of length 0.
return this.stageReports[this.stageReports.length - 1]?.allSourceIds ?? [];
}
private get baseGestureSetId(): string {

View file

@ -5,6 +5,7 @@ import { ManagedPromise } from "@keymanapp/web-utils";
import { GestureSource, GestureSourceSubview } from "../../gestureSource.js";
import { GestureMatcher, MatchResult, PredecessorMatch } from "./gestureMatcher.js";
import { GestureModel } from "../specs/gestureModel.js";
import { GestureSequence } from "./index.js";
interface GestureSourceTracker<Type> {
/**
@ -149,6 +150,14 @@ export class MatcherSelector<Type> extends EventEmitter<EventMap<Type>> {
? [source instanceof GestureSourceSubview ? source.baseSource : source]
: (source.sources as GestureSourceSubview<Type>[]).map((source) => source.baseSource);
if(sourceNotYetStaged) {
// Cancellation before a first stage is possible; in this case, there's no sequence
// to trigger cleanup. We can do that here.
source.path.on('invalidated', () => {
this.dropSourcesWithIds([source.identifier]);
})
}
const matchPromise = new ManagedPromise<MatcherSelection<Type>>();
/*
@ -221,7 +230,10 @@ export class MatcherSelector<Type> extends EventEmitter<EventMap<Type>> {
matcher: null,
result: {
matched: false,
action: null
action: {
type: 'complete',
item: null
}
}
});
}
@ -280,6 +292,27 @@ export class MatcherSelector<Type> extends EventEmitter<EventMap<Type>> {
this._sourceSelector.forEach((entry) => resetHooks(entry.source));
}
public dropSourcesWithIds(idsToClean: string[]) {
for(const id of idsToClean) {
const index = this._sourceSelector.findIndex((entry) => entry.source.identifier);
if(index > -1) {
// Ensure that any pending MatcherSelector and/or GestureSequence promises dependent
// on the source fully resolve (with cancellation).
const droppedSelector = this._sourceSelector.splice(index, 1)[0];
droppedSelector.matchPromise.resolve({
matcher: null,
result: {
matched: false,
action: {
type: 'none',
item: null
}
}
});
}
}
}
private matchersForSource(source: GestureSource<Type>) {
return this.potentialMatchers.filter((matcher) => {
return !!matcher.sources.find((src) => src.identifier == source.identifier)

View file

@ -306,9 +306,14 @@ describe("TouchpointCoordinator", () => {
});
const runnerPromise = fakeClock.runToLastAsync();
const sequence = await sequencePromise;
const completeStub = sinon.fake();
sequence.on('complete', completeStub); // was not called! Confirms a suspicion.
await Promise.all([runnerPromise, completionPromise1, completionPromise2]);
assert.isTrue(completeStub.calledOnce);
// Verify that all sources and sequences are cleared.
assert.sameOrderedMembers(touchpointCoordinator.activeSources, []);
assert.sameOrderedMembers(touchpointCoordinator.activeGestures, []);

View file

@ -42,10 +42,10 @@ describe('Engine - Basic Simulation', function() {
if(!proctor.compatibleWithSuite(testSuite)) {
it.skip(set.toTestName() + " - Cannot run this test suite on Node.");
} else {
it(set.toTestName(), function() {
it(set.toTestName(), async function() {
// Refresh the proctor instance at runtime.
let proctor = new NodeProctor(keyboardWithHarness, device, assert.equal);
set.test(proctor);
await set.test(proctor);
});
}
}

View file

@ -42,10 +42,10 @@ describe('Engine - Chirality', function() {
if(!proctor.compatibleWithSuite(testSuite)) {
it.skip(set.toTestName() + " - Cannot run this test suite on Node.");
} else if(set.constraint.target == 'hardware') {
it(set.toTestName(), function() {
it(set.toTestName(), async function() {
// Refresh the proctor instance at runtime.
let proctor = new NodeProctor(keyboardWithHarness, device, assert.equal);
set.test(proctor);
await set.test(proctor);
});
} else {
it.skip(set.toTestName() + " - modifier state simulation for OSK not yet supported in headless KeyboardProcessor");

View file

@ -42,10 +42,10 @@ describe('Engine - Deadkeys', function() {
if(!proctor.compatibleWithSuite(testSuite)) {
it.skip(set.toTestName() + " - Cannot run this test suite on Node.");
} else {
it(set.toTestName(), function() {
it(set.toTestName(), async function() {
// Refresh the proctor instance at runtime.
let proctor = new NodeProctor(keyboardWithHarness, device, assert.equal);
set.test(proctor);
await set.test(proctor);
});
}
}

View file

@ -212,7 +212,7 @@ export abstract class TestSequence<KeyRecord extends RecordedKeystroke | InputEv
abstract hasOSKInteraction(): boolean;
test(proctor: Proctor, target?: OutputTarget): {success: boolean, result: string} {
async test(proctor: Proctor, target?: OutputTarget): Promise<{success: boolean, result: string}> {
// Start with an empty OutputTarget and a fresh KeyboardProcessor.
if(!target) {
target = new Mock();
@ -220,7 +220,7 @@ export abstract class TestSequence<KeyRecord extends RecordedKeystroke | InputEv
proctor.before();
let result = proctor.simulateSequence(this, target);
let result = await proctor.simulateSequence(this, target);
proctor.assertEquals(result, this.output, this.msg);
return {success: (result == this.output), result: result};
@ -527,7 +527,7 @@ export interface TestSet<Sequence extends TestSequence<any>> {
addTest(seq: Sequence): void;
isValidForDevice(device: utils.DeviceSpec, usingOSK?: boolean): boolean;
test(proctor: Proctor): TestFailure[];
test(proctor: Proctor): Promise<TestFailure[]>;
}
/**
@ -563,13 +563,13 @@ export class EventSpecTestSet implements TestSet<InputEventSpecSequence> {
}
// Validity should be checked before calling this method.
test(proctor: Proctor): TestFailure[] {
async test(proctor: Proctor): Promise<TestFailure[]> {
var failures: TestFailure[] = [];
let testSet = this.testSet;
for(var i=0; i < testSet.length; i++) {
var testSeq = this[i];
var simResult = testSet[i].test(proctor);
var simResult = await testSet[i].test(proctor);
if(!simResult.success) {
// Failed test!
failures.push(new TestFailure(this.constraint, testSeq, simResult.result));
@ -613,13 +613,13 @@ export class RecordedSequenceTestSet implements TestSet<RecordedKeystrokeSequenc
}
// Validity should be checked before calling this method.
test(proctor: Proctor): TestFailure[] {
async test(proctor: Proctor): Promise<TestFailure[]> {
var failures: TestFailure[] = [];
let testSet = this.testSet;
for(var i=0; i < testSet.length; i++) {
var testSeq = this[i];
var simResult = testSet[i].test(proctor);
var simResult = await testSet[i].test(proctor);
if(!simResult.success) {
// Failed test!
failures.push(new TestFailure(this.constraint, testSeq, simResult.result));
@ -729,11 +729,11 @@ export class KeyboardTest {
newSet.addTest(seq);
}
test(proctor: Proctor) {
async test(proctor: Proctor) {
var setHasRun = false;
var failures: TestFailure[] = [];
proctor.beforeAll();
await proctor.beforeAll();
// The original test spec requires a browser environment and thus requires its own `.run` implementation.
if(!(proctor.compatibleWithSuite(this))) {
@ -745,7 +745,7 @@ export class KeyboardTest {
var testSet = this.inputTestSets[i];
if(proctor.matchesTestSet(testSet)) {
var testFailures = testSet.test(proctor);
var testFailures = await testSet.test(proctor);
if(testFailures) {
failures = failures.concat(testFailures);
}

View file

@ -21,7 +21,7 @@ export default class NodeProctor extends Proctor {
this.keyboardWithHarness = kbdHarness;
}
beforeAll() {
async beforeAll() {
//
}
@ -47,7 +47,7 @@ export default class NodeProctor extends Proctor {
return true;
}
simulateSequence(sequence: TestSequence<any>, target?: OutputTarget): string {
async simulateSequence(sequence: TestSequence<any>, target?: OutputTarget): Promise<string> {
// Start with an empty OutputTarget and a fresh KeyboardProcessor.
if(!target) {
target = new Mock();

View file

@ -29,7 +29,7 @@ export default abstract class Proctor {
}
// Performs global test prep.
abstract beforeAll();
abstract beforeAll(): Promise<void>;
// Performs per-test setup
abstract before();
@ -49,5 +49,5 @@ export default abstract class Proctor {
* Simulates the specified test sequence for use in testing.
* @param sequence The recorded sequence, generally provided by a test set.
*/
abstract simulateSequence(sequence: TestSequence<any>, target?: OutputTarget);
abstract simulateSequence(sequence: TestSequence<any>, target?: OutputTarget): Promise<string>;
}

View file

@ -184,7 +184,17 @@ export default class ContextManager extends ContextManagerBase<BrowserConfigurat
public deactivateCurrentTarget() {
const priorTarget = this.activeTarget || this.lastActiveTarget;
if(priorTarget) {
/* During integrated tests, it was possible in the past for a `beforeAll`
* -initialized KMW to reach this state between tests. The target fixture
* got cleared, but the `mostRecentTarget` / `lastActiveTarget` was not
* - just the `currentTarget` / `activeTarget`. See #9718.
*
* Newly-added code in `forgetActiveTarget` seeks to prevent this scenario,
* but as there's no consistent repro to prove it sufficient, an appropriate
* guard-condition has been added here too.
*/
if(priorTarget && this.page.isAttached(priorTarget.getElement())) {
this._BlurKeyboardSettings(priorTarget.getElement());
}
@ -198,12 +208,19 @@ export default class ContextManager extends ContextManagerBase<BrowserConfigurat
this.focusAssistant.maintainingFocus = false;
this.focusAssistant.restoringFocus = false;
const priorTarget = this.activeTarget || this.lastActiveTarget;
const priorTarget = this.activeTarget || this.mostRecentTarget;
if(priorTarget) {
this._BlurKeyboardSettings(priorTarget.getElement());
}
// Will ensure that the element is no longer active. Does not erase
// it from being the `lastActiveTarget`, though.
this.setActiveTarget(null, true);
// So we erase it here.
if(priorTarget == this.lastActiveTarget) {
this.mostRecentTarget = null;
}
}
public setActiveTarget(target: OutputTarget<any>, sendEvents: boolean) {

View file

@ -1,4 +1,4 @@
import { DeviceSpec } from '@keymanapp/web-utils';
import { DeviceSpec, ManagedPromise } from '@keymanapp/web-utils';
import { type InternalKeyboardFont as KeyboardFont } from '@keymanapp/keyboard-processor';
type FontFamilyStyleMap = {[family: string]: HTMLStyleElement};
@ -28,6 +28,27 @@ export class StylesheetManager {
this.linkNode.appendChild(sheet);
}
/**
* Provides a `Promise` that resolves when all currently-linked stylesheets have loaded.
* Any change to the set of linked sheets after the initial call will be ignored.
*/
async allLoadedPromise() {
const promises: Promise<void>[] = [];
for(const sheetElem of this.linkedSheets) {
// Based on https://stackoverflow.com/a/21147238
if(sheetElem.sheet?.cssRules) {
promises.push(Promise.resolve());
} else {
const promise = new ManagedPromise<void>();
sheetElem.addEventListener('load', () => promise.resolve());
promises.push(promise.corePromise);
}
}
await Promise.all(promises);
}
/**
* Build a stylesheet with a font-face CSS descriptor for the embedded font appropriate
* for the browser being used
@ -212,6 +233,8 @@ export class StylesheetManager {
sheet.parentNode.removeChild(sheet);
}
}
this.linkedSheets.splice(0, this.linkedSheets.length);
}
}

View file

@ -104,28 +104,17 @@ export default class OSKLayerGroup {
private nearestKey(coord: Omit<InputSample<KeyElement>, 'item'>, layer: OSKLayer): KeyElement {
const baseRect = this.element.getBoundingClientRect();
/**
* Transforms the client rect of child elements to use a coordinate system where the top-left
* of the layer group's bounding rectangle serves as the origin - the same coordinate
* system output by the gesture engine.
* @param childRect
* @returns
*/
const translation = (childRect: DOMRect) => {
return new DOMRect(childRect.x - baseRect.x, childRect.y - baseRect.y, childRect.width, childRect.height);
}
let row: OSKRow = null;
let bestMatchDistance = Number.MAX_VALUE;
// Find the row that the touch-coordinate lies within.
for(const r of layer.rows) {
const rowRect = translation(r.element.getBoundingClientRect());
if(rowRect.top <= coord.targetY && coord.targetY < rowRect.bottom) {
const rowRect = r.element.getBoundingClientRect();
if(rowRect.top <= coord.clientY && coord.clientY < rowRect.bottom) {
row = r;
break;
} else {
const distance = rowRect.top > coord.targetY ? rowRect.top - coord.targetY : coord.targetY - rowRect.bottom;
const distance = rowRect.top > coord.clientY ? rowRect.top - coord.clientY : coord.clientY - rowRect.bottom;
if(distance < bestMatchDistance) {
bestMatchDistance = distance;
@ -144,12 +133,12 @@ export default class OSKLayerGroup {
let dxMax = 24;
let dxMin = 100000;
const x = coord.targetX;
const x = coord.clientX;
for (let k = 0; k < row.keys.length; k++) {
// Second-biggest, though documentation suggests this is probably right.
const keySquare = row.keys[k].square as HTMLElement; // gets the .kmw-key-square containing a key
const squareRect = translation(keySquare.getBoundingClientRect());
const squareRect = keySquare.getBoundingClientRect();
// Find the actual key element.
let childNode = keySquare.firstChild ? keySquare.firstChild as HTMLElement : keySquare;
@ -179,7 +168,7 @@ export default class OSKLayerGroup {
if (dxMin < 100000) {
const t = <HTMLElement>row.keys[closestKeyIndex].square;
const squareRect = translation(t.getBoundingClientRect());
const squareRect = t.getBoundingClientRect();
const x1 = squareRect.left;
const x2 = squareRect.right;

View file

@ -698,14 +698,17 @@ export default abstract class OSKView extends EventEmitter<EventMap> implements
// Instantly resets the OSK container, erasing / delinking the previously-loaded keyboard.
this._Box.innerHTML = '';
// Since we cleared all inner HTML, that means we cleared the stylesheets, too.
this.uiStyleSheetManager.unlinkAll();
this.kbdStyleSheetManager.unlinkAll();
// Install the default OSK stylesheets - but don't have it managed by the keyboard-specific stylesheet manager.
// We wish to maintain kmwosk.css whenever keyboard-specific styles are reset/removed.
// Temp-hack: embedded products prefer their stylesheet, etc linkages without the /osk path component.
let subpath = 'osk/';
if(this.config.isEmbedded) {
subpath = '';
}
// Install the default OSK stylesheet - but don't have it managed by the keyboard-specific stylesheet manager.
// We wish to maintain kmwosk.css whenever keyboard-specific styles are reset/removed.
for(let sheetFile of OSKView.STYLESHEET_FILES) {
const sheetHref = `${this.config.pathConfig.resources}/${subpath}${sheetFile}`;
this.uiStyleSheetManager.linkExternalSheet(sheetHref);

View file

@ -178,31 +178,31 @@ describe('Engine - Browser Interactions', function() {
assert.equal(inputElem.value, "ຫ");
});
it('Simple OSK click', function() {
it('Simple OSK click', async function() {
var inputElem = document.getElementById('singleton');
var lao_s_osk_json = {"type": "osk", "keyID": 'shift-K_S'};
var lao_s_event = new KMWRecorder.OSKInputEventSpec(lao_s_osk_json);
let eventDriver = new KMWRecorder.BrowserDriver(inputElem);
eventDriver.simulateEvent(lao_s_event);
await eventDriver.simulateEvent(lao_s_event);
if(inputElem['base']) {
inputElem = inputElem['base'];
}
assert.equal(inputElem.value, ";");
});
})
});
describe('Sequence Simulation Checks', function() {
this.timeout(testconfig.timeouts.scriptLoad);
it('Keyboard simulation', function() {
return runKeyboardTestFromJSON('/engine_tests/basic_lao_simulation.json', {usingOSK: false}, assert.equal, testconfig.timeouts.scriptLoad);
it('Keyboard simulation', async function() {
return await runKeyboardTestFromJSON('/engine_tests/basic_lao_simulation.json', {usingOSK: false}, assert.equal, testconfig.timeouts.scriptLoad);
});
it('OSK simulation', function() {
return runKeyboardTestFromJSON('/engine_tests/basic_lao_simulation.json', {usingOSK: true}, assert.equal, testconfig.timeouts.scriptLoad);
it('OSK simulation', async function() {
return await runKeyboardTestFromJSON('/engine_tests/basic_lao_simulation.json', {usingOSK: true}, assert.equal, testconfig.timeouts.scriptLoad);
})
});
});
@ -231,10 +231,10 @@ describe('Unmatched Final Groups', function() {
fixture.cleanup();
});
it('matches rule from early group AND performs default behavior', function() {
it('matches rule from early group AND performs default behavior', async function() {
// While a TAB-oriented version would be nice, it's much harder to write the test
// to detect change in last input element.
return runKeyboardTestFromJSON('/engine_tests/ghp_enter.json', {usingOSK: true}, assert.equal, testconfig.timeouts.scriptLoad);
return await runKeyboardTestFromJSON('/engine_tests/ghp_enter.json', {usingOSK: true}, assert.equal, testconfig.timeouts.scriptLoad);
});
});

View file

@ -30,23 +30,23 @@ describe('Engine - Chirality', function() {
fixture.cleanup();
});
it('Keyboard + OSK simulation', function() {
it('Keyboard + OSK simulation', async function() {
this.timeout(testconfig.timeouts.scriptLoad * (testconfig.mobile ? 1 : 2));
/* Interestingly, this still works on iOS, probably because we're able to force-set
* the 'location' property in the simulated event on mobile devices, even when iOS neglects to
* set it for real events.
*/
return runKeyboardTestFromJSON('/engine_tests/chirality.json',
return await runKeyboardTestFromJSON('/engine_tests/chirality.json',
{usingOSK: false},
assert.equal,
testconfig.timeouts.scriptLoad).then(() => {
testconfig.timeouts.scriptLoad).then(async () => {
/* We only really care to test the 'desktop' OSK because of how it directly models the modifier keys.
*
* The 'phone' and 'layout' versions take shortcuts that bypass any tricky chiral logic;
* a better test for those would be to ensure the touch OSK is constructed properly.
*/
if(!testconfig.mobile) {
return runKeyboardTestFromJSON('/engine_tests/chirality.json', {usingOSK: true}, assert.equal, testconfig.timeouts.scriptLoad);
return await runKeyboardTestFromJSON('/engine_tests/chirality.json', {usingOSK: true}, assert.equal, testconfig.timeouts.scriptLoad);
}
});
});

View file

@ -2,6 +2,7 @@ var assert = chai.assert;
import {
loadKeyboardFromJSON,
oskResourceLoadPromise,
setupKMW,
teardownKMW
} from "../test_utils.js";
@ -49,7 +50,7 @@ describe('Event Management', function() {
assert.isNull(ele.onchange, '`onchange` handler was not called');
});
it('OSK-based onChange event generation', function() {
it('OSK-based onChange event generation', async function() {
var simple_A = {"type":"osk","keyID":"default-K_A"};
var event = new KMWRecorder.OSKInputEventSpec(simple_A);
@ -61,8 +62,13 @@ describe('Event Management', function() {
keyman.setActiveElement(ele);
// Browsers will only start loading OSK resources (the CSS) once both a keyboard and target
// are set... and that's an async operation.
await oskResourceLoadPromise();
// OSK CSS is needed for successful simulation for integration tests involving the gesture engine.
let eventDriver = new KMWRecorder.BrowserDriver(ele);
eventDriver.simulateEvent(event);
await eventDriver.simulateEvent(event);
let focusEvent = new FocusEvent('blur', {relatedTarget: ele});
ele.dispatchEvent(focusEvent);
@ -94,7 +100,7 @@ describe('Event Management', function() {
assert.equal(counterObj.i, fin, "Event handler not called the expected number of times");
});
it('OSK-based onInput event generation', function() {
it('OSK-based onInput event generation', async function() {
var simple_A = {"type":"osk","keyID":"default-K_A"};
var event = new KMWRecorder.OSKInputEventSpec(simple_A);
@ -109,9 +115,9 @@ describe('Event Management', function() {
});
let eventDriver = new KMWRecorder.BrowserDriver(ele);
eventDriver.simulateEvent(event);
eventDriver.simulateEvent(event);
eventDriver.simulateEvent(event);
await eventDriver.simulateEvent(event);
await eventDriver.simulateEvent(event);
await eventDriver.simulateEvent(event);
assert.equal(counterObj.i, fin, "Event handler not called the expected number of times");
});

View file

@ -201,31 +201,32 @@ export async function loadKeyboardFromJSON(jsonPath, timeout, params) {
return loadKeyboardStub(stub, timeout, params);
}
function runLoadedKeyboardTest(testDef, device, usingOSK, assertCallback) {
async function runLoadedKeyboardTest(testDef, device, usingOSK, assertCallback) {
var inputElem = document.getElementById('singleton');
let proctor = new KMWRecorder.BrowserProctor(inputElem, device, usingOSK, assertCallback);
testDef.test(proctor);
await testDef.test(proctor);
}
export function runKeyboardTestFromJSON(jsonPath, params, assertCallback, timeout) {
export async function runKeyboardTestFromJSON(jsonPath, params, assertCallback, timeout) {
var testSpec = new KMWRecorder.KeyboardTest(fixture.load(jsonPath, true));
let device = new Device();
device.detect();
return loadKeyboardStub(testSpec.keyboard, timeout).then(() => {
runLoadedKeyboardTest(testSpec, device.coreSpec, params.usingOSK, assertCallback);
return runLoadedKeyboardTest(testSpec, device.coreSpec, params.usingOSK, assertCallback);
}).finally(() => {
keyman.removeKeyboards(testSpec.keyboard.id);
});
}
// function retrieveAndReset(Pelem) {
// let val = Pelem.value;
// Pelem.value = "";
// return val;
// }
export async function oskResourceLoadPromise() {
// If the CSS isn't fully loaded, the element positions will not match their expected
// locations in the keyboard layout and OSK keys won't be triggered properly by the
// gesture engine.
const styleManager = keyman.osk['uiStyleSheetManager']; // is private
await styleManager.allLoadedPromise();
}
// Useful for tests related to strings with supplementary pairs.
export function toSupplementaryPairString(code) {

View file

@ -4,11 +4,23 @@ import {
OSKInputEventSpec,
PhysicalInputEventSpec
} from "@keymanapp/recorder-core";
import { ManagedPromise, timedPromise } from "@keymanapp/web-utils";
import { type KeymanEngine } from 'keyman/app/browser';
declare var keyman: KeymanEngine;
function asTouchList(arr: any[]) {
return {
get length() {
return arr.length;
},
item(index: number) {
return arr[index];
}
}
}
export class BrowserDriver {
static readonly physicalEventClass: string = "KeyboardEvent";
static readonly physicalEventType: string = "keydown";
@ -26,13 +38,13 @@ export class BrowserDriver {
this.target = target;
}
simulateEvent(eventSpec: InputEventSpec) {
async simulateEvent(eventSpec: InputEventSpec) {
switch(eventSpec.type) {
case "key":
this.simulateHardwareEvent(eventSpec as PhysicalInputEventSpec);
break;
case "osk":
this.simulateOSKEvent(eventSpec as OSKInputEventSpec);
await this.simulateOSKEvent(eventSpec as OSKInputEventSpec);
break;
}
}
@ -49,48 +61,96 @@ export class BrowserDriver {
this.target.dispatchEvent(event);
}
simulateOSKEvent(eventSpec: OSKInputEventSpec) {
let target = this.target;
let oskKeyElement = document.getElementById(eventSpec.keyID);
async simulateOSKEvent(eventSpec: OSKInputEventSpec) {
const originalLayer = keyman.osk.vkbd.layerId;
if(!oskKeyElement) {
console.error('Could not find OSK key "' + eventSpec.keyID + '"!');
// The following lines will throw an appropriate-enough error.
return;
// Calculations go wrong if the key's layer is not visible.
const keyID = eventSpec.keyID;
const targetLayer = keyID.indexOf('-') == -1 ? keyID : keyID.substring(0, keyID.lastIndexOf('-'));
if(targetLayer != originalLayer) {
keyman.osk.vkbd.layerGroup.layers[originalLayer].element.style.display = 'none';
keyman.osk.vkbd.layerGroup.layers[targetLayer].element.style.display = 'block';
keyman.osk.vkbd.layerId = targetLayer;
// Only the "current" layer of the OSK is laid out on refresh; a non-default
// layer won't have proper layout before this!
keyman.osk.vkbd.refreshLayout();
}
// To be safe, we replicate the MouseEvent similarly to the keystroke event.
var downEvent;
var upEvent;
if(keyman.config.hostDevice.touchable) {
downEvent = new Event(BrowserDriver.oskDownTouchType);
upEvent = new Event(BrowserDriver.oskUpTouchType);
downEvent['touches'] = [{"target": oskKeyElement}];
upEvent['touches'] = [{"target": oskKeyElement}];
downEvent['changedTouches'] = [{"target": oskKeyElement}];
upEvent['changedTouches'] = [{"target": oskKeyElement}];
} else {
downEvent = new Event(BrowserDriver.oskDownMouseType);
upEvent = new Event(BrowserDriver.oskUpMouseType);
downEvent['relatedTarget'] = target;
upEvent['relatedTarget'] = target;
// Mouse-click driven OSK use involves use of at least one mouse button.
downEvent['button'] = upEvent['button'] = 0;
downEvent['buttons'] = 1;
upEvent['buttons'] = 0;
}
try {
let target = this.target;
let oskKeyElement = document.getElementById(eventSpec.keyID);
const boundingBox = oskKeyElement.getBoundingClientRect();
const center = {
clientX: boundingBox.left + boundingBox.width/2,
clientY: boundingBox.top + boundingBox.height/2
}
oskKeyElement.dispatchEvent(downEvent);
oskKeyElement.dispatchEvent(upEvent);
if(!oskKeyElement) {
console.error('Could not find OSK key "' + eventSpec.keyID + '"!');
// The following lines will throw an appropriate-enough error.
return;
}
// To be safe, we replicate the MouseEvent similarly to the keystroke event.
var downEvent;
var upEvent;
if(keyman.config.hostDevice.touchable) {
downEvent = new Event(BrowserDriver.oskDownTouchType);
upEvent = new Event(BrowserDriver.oskUpTouchType);
downEvent['touches'] = asTouchList([{"target": oskKeyElement, ...center}]);
upEvent['touches'] = asTouchList([{"target": oskKeyElement, ...center}]);
downEvent['changedTouches'] = asTouchList([{"target": oskKeyElement, ...center}]);
upEvent['changedTouches'] = asTouchList([{"target": oskKeyElement, ...center}]);
} else {
downEvent = new Event(BrowserDriver.oskDownMouseType);
upEvent = new Event(BrowserDriver.oskUpMouseType);
downEvent.clientX = center.clientX;
downEvent.clientY = center.clientY;
downEvent['relatedTarget'] = target;
upEvent.clientX = center.clientX;
upEvent.clientY = center.clientY;
upEvent['relatedTarget'] = target;
// Mouse-click driven OSK use involves use of at least one mouse button.
downEvent['button'] = upEvent['button'] = 0;
downEvent['buttons'] = 1;
upEvent['buttons'] = 0;
}
oskKeyElement.dispatchEvent(downEvent);
oskKeyElement.dispatchEvent(upEvent);
// Note: our gesture engine's internal structure means that even simple keystrokes like this
// involve async processing. We'll need to sync up.
const defermentPromise = new ManagedPromise<void>();
// Easiest way to resolve? Just wait for the key event. Currently, our integration tests
// at this level only use simple-taps, so there shouldn't be any cases that emit multiple
// key events at this time.
keyman.osk.on('keyevent', () => { defermentPromise.resolve() });
// Just in case something goes wrong and no event occurs, we apply a timeout to keep
// the tests moving along.
await Promise.race([defermentPromise.corePromise, timedPromise(50)]);
} finally {
// The alt-layer needs to be maintained until the key is generated.
if(targetLayer != originalLayer) {
keyman.osk.vkbd.layerGroup.layers[targetLayer].element.style.display = 'none';
keyman.osk.vkbd.layerGroup.layers[originalLayer].element.style.display = 'block';
keyman.osk.vkbd.layerId = originalLayer;
keyman.osk.vkbd.refreshLayout();
}
}
}
// Execution of a test sequence depends on the testing environment; integrated
// testing requires browser-specific code.
simulateSequence(sequence: InputEventSpecSequence): string {
async simulateSequence(sequence: InputEventSpecSequence): Promise<string> {
let ele = this.target;
for(var i=0; i < sequence.inputs.length; i++) {
this.simulateEvent(sequence.inputs[i]);
await this.simulateEvent(sequence.inputs[i]);
}
if(ele instanceof HTMLInputElement || ele instanceof HTMLTextAreaElement) {

View file

@ -20,6 +20,7 @@ import {
TestSet
} from "@keymanapp/recorder-core";
import { BrowserDriver } from "./browserDriver.js";
import { type StylesheetManager } from "keyman/engine/dom-utils";
type AssertCallback = (s1: any, s2: any, msg?: string) => void;
@ -51,9 +52,15 @@ export class BrowserProctor extends Proctor {
}
// Performs browser-specific global test prep.
beforeAll() {
async beforeAll() {
let ele = this.target;
(window['keyman'] as any).setActiveElement(ele['base'] ? ele['base'] : ele);
keyman.setActiveElement(ele, true);
// If the CSS isn't fully loaded, the element positions will not match their expected
// locations in the keyboard layout and OSK keys won't be triggered properly by the
// gesture engine.
const styleManager = keyman.osk['uiStyleSheetManager'] as StylesheetManager;
await styleManager.allLoadedPromise();
}
before() {
@ -74,12 +81,12 @@ export class BrowserProctor extends Proctor {
// Execution of a test sequence depends on the testing environment; this handles
// the browser-specific aspects.
simulateSequence(sequence: TestSequence<any>, outputTarget?: OutputTarget): string {
async simulateSequence(sequence: TestSequence<any>, outputTarget?: OutputTarget): Promise<string> {
let driver = new BrowserDriver(this.target);
// For the version 10.0 spec
if(sequence instanceof InputEventSpecSequence) {
return driver.simulateSequence(sequence);
return await driver.simulateSequence(sequence);
// For the version 14.0+ spec
} else if(sequence instanceof RecordedKeystrokeSequence) {