selenium-state-machine
Version:
Write Selenium tests using state machines
465 lines (414 loc) • 16.7 kB
text/typescript
import * as crypto from 'crypto';
import { error } from 'selenium-webdriver';
import { DependencyMap, StaleDependencyReferenceError } from './Dependency';
import { CriticalError, TimeoutError } from './Error';
import { State } from './State';
import { ProvideComplete, ProvideFunction, ProvidePublic } from './Provide';
import { logger } from './Logger';
import winston = require('winston');
import { WebElementDependency } from './WebElementDependency';
import { Timer } from './Timer';
/*
* Context for fsm
*/
export interface BaseContext {
/**
* Remaining time for fsm
*/
timeout: number
/**
* The machine name
*/
name?: string;
}
interface InternalContext<T extends BaseContext> {
logger: winston.Logger,
userContext: T,
timeout: number,
timers: { [name: string]: Timer }
}
/**
* State machine implementation which is capable of recovering stale dependencies.
* The first added state is considered as starting state. The last added is finish state.
* To add states call a {@link state} method.
*/
export class StateMachine<TContext extends BaseContext, TDependencyMap extends DependencyMap> {
/**
* State machine context
*/
private _context: InternalContext<TContext>;
/**
* Current state index
*/
private _i: number;
/**
* Number of iterations on state
*/
private _stateCounter: number;
/**
* Map state name => state index
*/
private _nameMap: Map<string, number>;
/**
* Promise which is resolved when state machines is done
*/
private _promise: Promise<void> | undefined;
/**
* Set of reached states
*/
private _reachedStates: Set<string>;
/**
* Running flag
*/
private _running: boolean;
/**
* List of all states
*/
private _states: State<TContext, TDependencyMap>[];
/**
* Spent time on current state
*/
private _timeOnState: number;
/**
* List of transition callbacks
*/
private _transitionCallbacks: ((machine: StateMachine<TContext, TDependencyMap>, logger: winston.Logger) => void | PromiseLike<void>)[];
constructor(context: TContext, private dependencies: TDependencyMap) {
this._context = {
logger: logger(`${context.name ?? crypto.randomBytes(16).toString('hex')}-${new Date().toISOString()}`),
userContext: context,
timeout: context.timeout,
timers: {}
};
this._i = 0;
this._stateCounter = 0;
this._nameMap = new Map();
this._promise = undefined;
this._reachedStates = new Set();
this._running = false;
this._states = [];
this._timeOnState = 0;
this._transitionCallbacks = [];
}
/**
* Get context values. Please take in mind timeout will be the same during run.
*/
public get context() : TContext {
return this._context.userContext;
}
/**
* Update context values. Make sure you are using immutable types.
* @param data which will take part in new context
*/
public updateContext(data: Partial<TContext>): void {
this._context.userContext = {
...this._context.userContext,
...data
};
}
/**
* Get name of current state
*/
public get currentState(): string {
return this._i < this._states.length ? this._states[this._i].name : 'end';
}
/**
* Get time spent on current state
*/
public get timeOnCurrentState(): number {
return this._timeOnState;
}
/**
* Get remaining timeout
*/
public get timeout(): number {
return this._context.timeout;
}
/**
* Set timeout. Please note this cannot be done when state machine is running.
*/
public set timeout(v: number) {
if (this._running) {
throw new CriticalError('cannot change timeout when pipeline is running');
}
this._context.timeout = v;
}
/**
* Create new timer. Useful when it is not desirable perform WebElement click every state transition.
* @param name new name of the timer
* @param timeout time after timer will be in state 'elapsed'
*/
public createTimer(name: string | ProvideFunction<never, never>, timeout: number): void {
const stringName = typeof name === 'string' ? name : name.name;
this._context.timers[stringName] = new Timer(this._context.timeout, timeout);
}
/**
* Clear set timer with name.
* @param name name of the timer
*/
public clearTimer(name: string | ProvideFunction<never, never>): void {
const stringName = typeof name === 'string' ? name : name.name;
delete this._context.timers[stringName];
}
/**
* Check if timer is set.
* @param name name of the timer in question
* @returns boolean signalling availability
*/
public hasTimer(name: string | ProvideFunction<never, never>): boolean {
const stringName = typeof name === 'string' ? name : name.name;
return this._context.timers[stringName] !== undefined;
}
/**
* Check if timer has elapsed.
* @param name name of the timer in question
* @returns boolean signaling its state
*/
public hasElapsedTimer(name: string | ProvideFunction<never, never>): boolean {
const stringName = typeof name === 'string' ? name : name.name;
const timer = this._context.timers[stringName];
if (timer !== undefined) {
return timer.elapsed(this._context.timeout);
}
throw new CriticalError(`unknown timer ${stringName}`);
}
/**
* Add new state
* @param state state to be added
* @returns self
*/
private addState(state: State<TContext, TDependencyMap>): this {
this._states.push(state);
this._nameMap.set(state.name, this._states.length - 1);
return this;
}
/**
* Notify all transition listeners
* @returns
*/
private notify(): Promise<void[]> {
return Promise.all(this._transitionCallbacks.map((x) => x(this, this._context.logger)));
}
/**
* Register new on transition callback
* @param callback function to be called
*/
public onTransition(callback: typeof this._transitionCallbacks[0]) {
this._transitionCallbacks.push(callback);
}
/**
* Wait until state has been reached. It may return result from past so check {@link currentState} as well.
* @param name name of state or function which is called
* @param timeout timeout in ms
*/
public async waitUntilReached(name: string | ProvideFunction<TContext, TDependencyMap>, timeout?: number): Promise<void> {
const stringName = typeof name === 'string' ? name : name.name;
timeout = timeout !== undefined ? timeout : Number.POSITIVE_INFINITY;
const end = Date.now() + timeout;
while (!this._reachedStates.has(stringName) && Date.now() < end) {
await new Promise((resolve) => setImmediate(resolve));
}
}
/**
* Add new state to the state machine and infer state name.
* @param f State functions which is called each tick. The function must return ProvideComplete object by using given DSL.
* In case no dependencies are provides, use provide.nothing() otherwise use provide.dependency(dependencyObject, value).
* After that select one of next, previous or transition. If nothing was provides tryAgain is available. Depending on selected option
* the state machine will perform transition to next/previous/selected state or repeat itself.
* @param timeout
* @returns self
*/
public state(f: ((provide: ProvidePublic<TContext, TDependencyMap>, dependencies: TDependencyMap) => Promise<ProvideComplete<TContext, TDependencyMap>> | ProvideComplete<TContext, TDependencyMap>
), timeout?: number): this {
const state = new State<TContext, TDependencyMap>({ f, timeout }, this._states.length, {
context: this.context,
logger: this._context.logger,
timeout: this.timeout,
timers: this._context.timers
});
return this.addState(state);
}
/**
* Add new state to the state machine.
* @param name Name of the state
* @param f State functions which is called each tick. The function must return ProvideComplete object by using given DSL.
* In case no dependencies are provides, use provide.nothing() otherwise use provide.dependency(dependencyObject, value).
* After that select one of next, previous or transition. If nothing was provides tryAgain is available. Depending on selected option
* the state machine will perform transition to next/previous/selected state or repeat itself.
* @param timeout timeout on the state
* @returns self
*/
public namedState(name: string, f: ((provide: ProvidePublic<TContext, TDependencyMap>, dependencies: TDependencyMap) => Promise<ProvideComplete<TContext, TDependencyMap>> | ProvideComplete<TContext, TDependencyMap>
), timeout?: number): this {
const state = new State<TContext, TDependencyMap>({ f, name, timeout }, this._states.length, {
context: this.context,
logger: this._context.logger,
timeout: this.timeout,
timers: this._context.timers
});
return this.addState(state);
}
/**
* Perform transition.
* @param i new state index
* @returns void
*/
private changeIndex(i: number): void {
if (this._i === i) {
return;
}
if (i < 0) {
throw new CriticalError('cannot go to previous checkpoint');
}
const newStateName = i < this._states.length ? this._states[i].name : 'end';
this._context.logger.info(`executed function in state ${this.currentState} x${this._stateCounter} times and spent ${this._timeOnState}ms`);
this._context.logger.info(`transition from ${this._states[this._i].name} to ${newStateName}`);
this._i = i;
this._timeOnState = 0;
this._stateCounter = 0;
this._reachedStates.add(newStateName);
this.notify();
}
/**
* Stop the state machine.
*/
public stop(): void {
this._running = false;
}
/**
* Start the state machine.
* @returns promise which resolved when the state machine is on end state
*/
public async start(): Promise<void> {
this._promise = this.helperStart();
const newStateName = this._i < this._states.length ? this._states[this._i].name : 'end';
this._reachedStates.add(newStateName);
return this._promise;
}
/**
* Wait until the end state is reached.
* @returns
*/
public async wait(): Promise<void> {
if (this._promise === undefined) {
throw new CriticalError('state machine is not running');
}
return this._promise;
}
private async helperStart(): Promise<void> {
if (this._running) {
throw new CriticalError('state machine is already running');
}
this._running = true;
process.on('SIGINT', () => this._running = false);
process.on('uncaughtException', () => this._running = false);
while (this._running && this._context.timeout > 0) {
if (this._i >= this._states.length) {
this._context.logger.info('state machine has reached the end state');
return;
}
const state = this._states[this._i];
if (state.timeout <= this._timeOnState) {
throw new TimeoutError(`timed out on checkpoint number ${this._i + 1} // (indexing from 1)`);
}
const started = Date.now();
try {
const provide = await state.execute(this.dependencies);
const delta = Date.now() - started;
this._timeOnState += delta;
this._stateCounter += 1;
this._context.timeout -= delta;
for (const key of Object.keys(provide.updateMap)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.dependencies as any)[key] = provide.updateMap[key];
}
for (const timer of provide.staleTimers) {
this.clearTimer(timer);
}
for (const timer of provide.newTimers) {
this.createTimer(timer.name, timer.timeout);
}
this.updateContext(provide.context);
if (provide.doesRepeat()) {
continue;
}
else if (provide.doesGoNext()) {
this.changeIndex(this._i + 1);
}
else if (provide.doesGoPrevious()) {
this.changeIndex(this._i - 1);
}
else if (provide.doesTransition()) {
const index = this._nameMap.get(provide.transitionState);
if (index === undefined) {
throw new CriticalError(`state "${provide.transitionState}" does not exist`);
}
this.changeIndex(index);
}
else {
throw new CriticalError('unknown state transition');
}
}
catch (e) {
if (e instanceof StaleDependencyReferenceError) {
if (e.dependency instanceof WebElementDependency) {
this._context.logger.info(`stale WebElement with name ${e.dependency.name} located in ${this.currentState}`,
{
name: e.dependency.debugElement?.constructor.name ?? 'unknown WebElement',
element: e.dependency.debugElement,
provider: e.dependency.provider?.name ?? 'missing provider'
});
}
else {
this._context.logger.info(`stale dependency with name ${e.dependency.name} located in ${this.currentState}`, {
provider: e.dependency.provider?.name ?? 'missing provider'
});
}
if (e.dependency.provider !== undefined) {
this.changeIndex(e.dependency.provider.index);
}
else {
this._context.logger.error(`cannot recover WebElement from stale state in state ${this.currentState}`);
throw e;
}
}
else if (e instanceof error.NoSuchElementError || e instanceof error.ElementClickInterceptedError) {
// continue
}
else if (e instanceof error.StaleElementReferenceError) {
// warn user it might be error
this._context.logger.warn(`unprotected WebElement is located in ${this.currentState}`);
}
else {
this._context.logger.error(`non fixable unknown error in ${this.currentState}`,
{
error
});
throw e;
}
const delta = Date.now() - started;
this._context.timeout -= delta;
}
}
this._context.logger.info(`executed function in state ${this.currentState} x${this._stateCounter} times and spent ${this._timeOnState}ms`);
if (!this._running && this._context.timeout > 0) {
this._context.logger.info(`stopped the state machine on state ${this.currentState}`);
return;
}
if (this._i !== this._states.length) {
this._context.logger.error(`timed out the state machine on state ${this.currentState}`);
throw new TimeoutError(`timed out the state machine on state ${this.currentState}`);
}
}
}
/**
* Declare the state machine dependencies. It is capable of inferring names.
* @param dependencies
* @returns the same dependencies but with name set as their key
*/
export function declareDependencies<T extends DependencyMap>(dependencies: T): T {
for (const key of Object.keys(dependencies)) {
dependencies[key].name = key;
}
return dependencies;
}