UNPKG

selenium-state-machine

Version:
356 lines (355 loc) 14.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.declareDependencies = exports.StateMachine = void 0; const crypto = require("crypto"); const selenium_webdriver_1 = require("selenium-webdriver"); const Dependency_1 = require("./Dependency"); const Error_1 = require("./Error"); const State_1 = require("./State"); const Logger_1 = require("./Logger"); const WebElementDependency_1 = require("./WebElementDependency"); const Timer_1 = require("./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. */ class StateMachine { constructor(context, dependencies) { this.dependencies = dependencies; this._context = { logger: (0, Logger_1.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. */ get context() { return this._context.userContext; } /** * Update context values. Make sure you are using immutable types. * @param data which will take part in new context */ updateContext(data) { this._context.userContext = { ...this._context.userContext, ...data }; } /** * Get name of current state */ get currentState() { return this._i < this._states.length ? this._states[this._i].name : 'end'; } /** * Get time spent on current state */ get timeOnCurrentState() { return this._timeOnState; } /** * Get remaining timeout */ get timeout() { return this._context.timeout; } /** * Set timeout. Please note this cannot be done when state machine is running. */ set timeout(v) { if (this._running) { throw new Error_1.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' */ createTimer(name, timeout) { const stringName = typeof name === 'string' ? name : name.name; this._context.timers[stringName] = new Timer_1.Timer(this._context.timeout, timeout); } /** * Clear set timer with name. * @param name name of the timer */ clearTimer(name) { 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 */ hasTimer(name) { 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 */ hasElapsedTimer(name) { 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 Error_1.CriticalError(`unknown timer ${stringName}`); } /** * Add new state * @param state state to be added * @returns self */ addState(state) { this._states.push(state); this._nameMap.set(state.name, this._states.length - 1); return this; } /** * Notify all transition listeners * @returns */ notify() { return Promise.all(this._transitionCallbacks.map((x) => x(this, this._context.logger))); } /** * Register new on transition callback * @param callback function to be called */ onTransition(callback) { 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 */ async waitUntilReached(name, timeout) { 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 */ state(f, timeout) { const state = new State_1.State({ 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 */ namedState(name, f, timeout) { const state = new State_1.State({ 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 */ changeIndex(i) { if (this._i === i) { return; } if (i < 0) { throw new Error_1.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. */ stop() { this._running = false; } /** * Start the state machine. * @returns promise which resolved when the state machine is on end state */ async start() { 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 */ async wait() { if (this._promise === undefined) { throw new Error_1.CriticalError('state machine is not running'); } return this._promise; } async helperStart() { if (this._running) { throw new Error_1.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 Error_1.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[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 Error_1.CriticalError(`state "${provide.transitionState}" does not exist`); } this.changeIndex(index); } else { throw new Error_1.CriticalError('unknown state transition'); } } catch (e) { if (e instanceof Dependency_1.StaleDependencyReferenceError) { if (e.dependency instanceof WebElementDependency_1.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 selenium_webdriver_1.error.NoSuchElementError || e instanceof selenium_webdriver_1.error.ElementClickInterceptedError) { // continue } else if (e instanceof selenium_webdriver_1.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: selenium_webdriver_1.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 Error_1.TimeoutError(`timed out the state machine on state ${this.currentState}`); } } } exports.StateMachine = StateMachine; /** * Declare the state machine dependencies. It is capable of inferring names. * @param dependencies * @returns the same dependencies but with name set as their key */ function declareDependencies(dependencies) { for (const key of Object.keys(dependencies)) { dependencies[key].name = key; } return dependencies; } exports.declareDependencies = declareDependencies;