Skip to content

Commit

Permalink
Support newest AT-Driver specification (#16)
Browse files Browse the repository at this point in the history
* empty js file to force actions testing

* focus forcing tests for gh-actions build

* swap back to firefox for a test

* testing pressing alt-tab a few times in a weird spot

* remove alt-tabs

* temporary works for go server testing

* switching to the pac at-driver provider for testing

* debugging, full connection seems stable now

* hacky keys mapping proto

* full keys map

* messed up on rebase, restoring this file to upstream/main

* Take pathname from command line args, log at-driver comms

* minor fixes from review

* updates to work with gnarf patches to pac driver

* rename webdriver code points for keys, add a thrown error for invalid keys

* add test for key codes in validateKeysFromCommand

* toString before logging (was logging buffers)

* Add flag for "VERBOSE" logs (logs that are only for printing while running, not storing in test logs)

* Trying to skip writing these verbose messages to the test plan output

* Reverting "verbose" concept for now
  • Loading branch information
gnarf authored Sep 21, 2023
1 parent cb23119 commit 9b67b40
Show file tree
Hide file tree
Showing 6 changed files with 133 additions and 20 deletions.
Empty file added force-test-run.js
Empty file.
126 changes: 110 additions & 16 deletions src/agent/at-driver.js
Original file line number Diff line number Diff line change
@@ -1,52 +1,65 @@
import ws from 'ws';

import { iterateEmitter } from '../shared/iterate-emitter.js';
import { AgentMessage } from './messages.js';

/**
* @param {object} options
* @param {object} [options.url]
* @param {string} [options.url.hostname]
* @param {string} [options.url.pathname]
* @param {number | string} [options.url.port]
* @param {object} options.abortSignal
* @returns {Promise<ATDriver>}
*/
export async function createATDriver({
url: { hostname = 'localhost', port = 4382 } = {},
url: { hostname = 'localhost', port = 4382, pathname = '/session' } = {},
abortSignal,
log,
} = {}) {
if (!abortSignal) process.exit(1);
const socket = new ws(`ws://${hostname}:${port}`, ['v1.aria-at.bocoup.com']);
const driver = new ATDriver({ socket });
const url = `ws://${hostname}:${port}${pathname}`;
log(AgentMessage.AT_DRIVER_COMMS, { direction: 'connect', message: url });
const socket = new ws(url);
const driver = new ATDriver({ socket, log });
await driver.ready;
abortSignal.then(() => driver.quit());
return driver;
}

export class ATDriver {
constructor({ socket }) {
constructor({ socket, log }) {
this.socket = socket;
this.ready = new Promise(resolve => socket.once('open', () => resolve()));
this.log = log;
this.ready = new Promise(resolve => socket.once('open', () => resolve())).then(() =>
this._send({ method: 'session.new', params: { capabilities: {} } })
);
this.closed = new Promise(resolve => socket.once('close', () => resolve()));

this._nextId = 0;
}

async quit() {
this.log(AgentMessage.AT_DRIVER_COMMS, { direction: 'close' });
this.socket.close();
await this.closed;
}

async *_messages() {
for await (const rawMessage of iterateEmitter(this.socket, 'message', 'close', 'error')) {
yield JSON.parse(rawMessage.toString());
const message = rawMessage.toString();
this.log(AgentMessage.AT_DRIVER_COMMS, { direction: 'inbound', message });
yield JSON.parse(message);
}
}

async _send(command) {
const id = this._nextId++;
this.socket.send(JSON.stringify({ id, ...command }));
const rawMessage = JSON.stringify({ id, ...command });
this.log(AgentMessage.AT_DRIVER_COMMS, { direction: 'outbound', message: rawMessage });
this.socket.send(rawMessage);
for await (const message of this._messages()) {
if (message.type === 'response' && message.id === id) {
if (message.id === id) {
if (message.error) {
throw new Error(message.error);
}
Expand All @@ -59,13 +72,14 @@ export class ATDriver {
* @param {...(ATKey | ATKeyChord | ATKeySequence)} keys
*/
async sendKeys(...keys) {
// the sequence can be like ['UP', ATChord(['SHIFT', 'P'])]
// the we loop over each "chord" (combo of keys to press) asking the driver
// to press it, waiting for that keypress to finish, then pressing the next.
for (const chord of ATKey.sequence(...keys)) {
for (const { key } of chord) {
await this._send({ type: 'command', name: 'pressKey', params: [key] });
}
for (const { key } of Array.from(chord).reverse()) {
await this._send({ type: 'command', name: 'releaseKey', params: [key] });
}
await this._send({
method: 'interaction.pressKeys',
params: { keys: chord.toAtDriverKeyCodes() },
});
}
}

Expand All @@ -74,20 +88,96 @@ export class ATDriver {
*/
async *speeches() {
for await (const message of this._messages()) {
if (message.type === 'event' && message.name === 'speech') {
yield message.data;
if (message.method === 'interaction.capturedOutput') {
yield message.params.data;
}
}
}
}

// https://w3c.github.io/webdriver/#keyboard-actions
export const webDriverCodePoints = {
NULL: '\ue000',
UNIDENTIFIED: '\ue000',
CANCEL: '\ue001',
HELP: '\ue002',
BACKSPACE: '\ue003',
TAB: '\ue004',
CLEAR: '\ue005',
RETURN: '\ue006',
ENTER: '\ue007',
SHIFT: '\ue008',
CONTROL: '\ue009',
ALT: '\ue00a',
PAUSE: '\ue00b',
ESCAPE: '\ue00c',
SPACE: '\ue00d',
' ': '\ue00d',
PAGE_UP: '\ue00e',
PAGEUP: '\ue00e',
PAGE_DOWN: '\ue00f',
PAGEDOWN: '\ue00f',
END: '\ue010',
HOME: '\ue011',
LEFT: '\ue012',
ARROWLEFT: '\ue012',
UP: '\ue013',
ARROWUP: '\ue013',
RIGHT: '\ue014',
ARROWRIGHT: '\ue014',
DOWN: '\ue015',
ARROWDOWN: '\ue015',
INSERT: '\ue016',
DELETE: '\ue017',
SEMICOLON: '\ue018',
EQUALS: '\ue019',

NUMPAD0: '\ue01a',
NUMPAD1: '\ue01b',
NUMPAD2: '\ue01c',
NUMPAD3: '\ue01d',
NUMPAD4: '\ue01e',
NUMPAD5: '\ue01f',
NUMPAD6: '\ue020',
NUMPAD7: '\ue021',
NUMPAD8: '\ue022',
NUMPAD9: '\ue023',
MULTIPLY: '\ue024',
ADD: '\ue025',
SEPARATOR: '\ue026',
SUBTRACT: '\ue027',
DECIMAL: '\ue028',
DIVIDE: '\ue029',

F1: '\ue031',
F2: '\ue032',
F3: '\ue033',
F4: '\ue034',
F5: '\ue035',
F6: '\ue036',
F7: '\ue037',
F8: '\ue038',
F9: '\ue039',
F10: '\ue03a',
F11: '\ue03b',
F12: '\ue03c',

META: '\ue03d',
COMMAND: '\ue03d',
ZENKAKU_HANKAKU: '\ue040',
};

export class ATKey {
/**
* @param {string} key
*/
constructor(key) {
this.type = 'key';
this.key = key;
this.codePoint = webDriverCodePoints[this.key.toUpperCase()] ?? this.key;
if (this.codePoint.length > 1) {
throw new Error(`Unknown key: ${this.key} - should be a single character, or a special key`);
}
}
toString() {
return this.key;
Expand Down Expand Up @@ -145,6 +235,10 @@ export class ATKeyChord {
toString() {
return this.keys.join(' + ');
}

toAtDriverKeyCodes() {
return this.keys.map(({ codePoint }) => codePoint);
}
}

export class ATKeySequence {
Expand Down
11 changes: 9 additions & 2 deletions src/agent/create-test-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import { MockTestRunner } from './mock-test-runner.js';
import { DriverTestRunner } from './driver-test-runner.js';
import { createWebDriver } from './web-driver.js';
import { createATDriver } from './at-driver.js';
import { AgentMessage } from './messages.js';

/**
* @param {object} options
* @param {Promise<void>} options.abortSignal resolves when runner should stop
* @param {{hostname: string, port: number | string}} options.atDriverUrl
* @param {{hostname: string, port: number | string, pathname: string}} options.atDriverUrl
* @param {AriaATCIShared.BaseURL} options.baseUrl
* @param {AriaATCIAgent.Log} options.log
* @param {AriaATCIAgent.MockOptions} [options.mock]
Expand All @@ -28,13 +29,19 @@ export async function createRunner(options) {
if (options.mock) {
return new MockTestRunner(options);
}
await new Promise(resolve => setTimeout(resolve, 1000));
const [webDriver, atDriver] = await Promise.all([
createWebDriver({
url: options.webDriverUrl,
browser: options.webDriverBrowser,
abortSignal: options.abortSignal,
log: options.log,
}),
createATDriver({
url: options.atDriverUrl,
abortSignal: options.abortSignal,
log: options.log,
}),
createATDriver({ url: options.atDriverUrl, abortSignal: options.abortSignal }),
]);
return new DriverTestRunner({ ...options, webDriver, atDriver });
}
12 changes: 11 additions & 1 deletion src/agent/driver-test-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { WebDriver, until, By } from 'selenium-webdriver';

import { startJob } from '../shared/job.js';

import { ATDriver, ATKey } from './at-driver.js';
import { ATDriver, ATKey, webDriverCodePoints } from './at-driver.js';
import { AgentMessage } from './messages.js';

/**
Expand Down Expand Up @@ -157,14 +157,18 @@ export class DriverTestRunner {
});

await asyncOperation();
// this.log(AgentMessage.DEBUG, {msg: '_collectSpeech - operation completed'});

let i = 0;
do {
i = spoken.length;
// this.log(AgentMessage.DEBUG, {msg: `collected ${i} speech events so far - delay ${debounceDelay}`});
await timeout(debounceDelay);
} while (i < spoken.length);

// this.log(AgentMessage.DEBUG, {msg: 'canceling speech job'});
await speechJob.cancel();
// this.log(AgentMessage.DEBUG, {msg: 'done collecting speech'});
return spoken;
}

Expand Down Expand Up @@ -195,6 +199,12 @@ export function validateKeysFromCommand(command) {
if (/\bfollowed\b/.test(keystroke)) {
errors.push(`'${keystroke}' cannot contain 'followed' or 'followed by'.`);
}

if (keystroke.length != 1 && !webDriverCodePoints[keystroke.toUpperCase()]) {
errors.push(
`'${keystroke}' is not a recognized key - use single characters or "Normalized" values from https://w3c.github.io/webdriver/#keyboard-actions`
);
}
}

if (errors.length > 0) {
Expand Down
3 changes: 3 additions & 0 deletions src/agent/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export const AgentMessage = {
SPEECH_EVENT: 'speechEvent',
/** @type {'noRunTestSetup'} */
NO_RUN_TEST_SETUP: 'noRunTestSetup',
/** @type {'atDriverComms'} */
AT_DRIVER_COMMS: 'atDriverComms',
};

export const AGENT_TEMPLATES = {
Expand All @@ -41,6 +43,7 @@ export const AGENT_TEMPLATES = {
[AgentMessage.SPEECH_EVENT]: ({ spokenText }) => `Speech event: '${spokenText}'.`,
[AgentMessage.NO_RUN_TEST_SETUP]: ({ referencePage }) =>
`Test reference, ${referencePage}, does not have a Run Test Setup button.`,
[AgentMessage.AT_DRIVER_COMMS]: ({ direction, message }) => `AT-Driver: ${direction}: ${message}`,
};

export function createAgentLogger(messages = AGENT_TEMPLATES) {
Expand Down
1 change: 0 additions & 1 deletion src/host/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,6 @@ class AgentDeveloperProtocol extends AgentProtocol {
const abortSignal = new Promise(resolve => {
this._testEmitter.once('stop', () => resolve());
});

this.exited = agentMain({
runner: await createRunner({
abortSignal,
Expand Down

0 comments on commit 9b67b40

Please sign in to comment.