UNPKG

siesta-lite

Version:

Stress-free JavaScript unit testing and functional testing tool, works in NodeJS and browsers

841 lines (621 loc) 31.5 kB
/* Siesta 5.6.1 Copyright(c) 2009-2022 Bryntum AB https://bryntum.com/contact https://bryntum.com/products/siesta/license */ /** @class Siesta.Recorder.Recorder @mixin Siesta.Recorder.Role.CanRecordScroll @mixin Siesta.Recorder.Role.CanRecordWindowResize @mixin Siesta.Recorder.Role.CanRecordPointOfInterest @mixin Siesta.Recorder.Role.CanRecordMouseMovePath @mixin Siesta.Recorder.Role.CanRecordMouseMoveOnIdle This class implements a recorder for user actions. It records the events of the window it's attached to. It has a number of options, defining what should be recorder. Since it's JS based, we cannot record native dialog interactions, such as alert, print or confirm etc. */ Class('Siesta.Recorder.Recorder', { does : [ // "utility" roles JooseX.Observable, Siesta.Util.Role.Dom, Siesta.Util.Role.CanParseOs, Siesta.Util.Role.CanGetType, Siesta.Recorder.Role.CanSwallowException, // "feature" roles Siesta.Recorder.Role.CanRecordMouseDownUp, Siesta.Recorder.Role.CanRecordMouseMove, Siesta.Recorder.Role.CanRecordMouseMoveOnIdle, Siesta.Recorder.Role.CanRecordPointOfInterest, Siesta.Recorder.Role.CanRecordWindowResize, Siesta.Recorder.Role.CanRecordScroll, Siesta.Recorder.Role.CanRecordWheel, // this has to go last (override will be executed first), // to be able to disable the previous roles Siesta.Recorder.Role.CanRecordMouseMovePath ], has : { active : null, extractor : null, extractorClass : Siesta.Recorder.TargetExtractor, extractorConfig : null, /** * @cfg {Array[String]/String} uniqueComponentProperty A string or an array of strings, containing attribute names * that the Recorder will use to identify Ext JS components. */ uniqueComponentProperty : null, /** * @cfg {String} uniqueDomNodeProperty A property that will be used to uniquely identify DOM nodes. By default the `id` * property is used. */ uniqueDomNodeProperty : 'id', /** * @cfg {Function} shouldIgnoreDomElementId If provided, this function will be called to determine if DOM element's * id can be used as part of the queries, created by recorder. Its quite common for various frameworks, to assign * auto-generated ids to DOM elements. Such ids usually changes very often and should not be used in the queries. * * The function should return `true` if element's id should not be used and will be called with the following arguments: * * @cfg {String} shouldIgnoreDomElementId.id The id of the DOM element to check * @cfg {HTMLElement} shouldIgnoreDomElementId.el The DOM element itself */ shouldIgnoreDomElementId : null, /** * @cfg {Array} ignoreCssClasses An array of CSS class strings, which should be ignored by the recorder as it builds CSS selector locators. Use * this to avoid certain CSS selectors which don't provide any useful distinction about a DOM node */ ignoreCssClasses : Joose.I.Array, // logic for offset: // we always record it, except for the "click" where element has not changed during the click, // and it was available at center point during "mousedown" - such clicks are considered "simple" and don't require offset // in the opposite, individually recorded "mousedown" or "mouseup" events are usually part of the drag operation // and for "drag" we want to be precise /** * @cfg {Boolean} recordOffsets Set to `true` to record the offset to each targeted DOM element for recorded * actions, to make sure the recorded action can be played back with exact precision. * Normally Siesta removes the offset of some actions, if target element is reachable at the center point. */ recordOffsets : false, // ignore events generated by Siesta (bypass in normal use, but for testing recorder we need it) ignoreSynthetic : true, // The window this recorder is observing for events window : null, // merge `mousedown+mouseup+click` to just `click` only if timespan between `mousedown` and `mouseup` // is less than this value (ms) clickMergeThreshold : 200, // console.logs all DOM events detected debugMode : false, eventsToRecord : { lazy : 'this.buildEventsToRecord' }, // "raw" log of all dom events events : Joose.I.Array, // Strictly for debugging purposes processingEvent : null, actions : Joose.I.Array, actionsByEventId : Joose.I.Object, dragPixelThreshold : 3, // If mousedown/mouseup position differs by less, we consider it a click actionClass : Siesta.Recorder.Action, lastActionPosition : Joose.I.Array, warnAboutIframeMissingId : true }, methods : { buildEventsToRecord : function () { var events = [ "keydown", "keypress", "keyup", "click", "dblclick", "contextmenu" ] if (window.PointerEvent) events.push( 'pointerdown', 'pointerup' ) else if (window.MSPointerEvent) events.push( 'MSPointerDown', 'MSPointerUp' ) else events.push( 'mousedown', 'mouseup' ) return events }, initialize : function () { this.onUnload = this.onUnload.bind(this); this.onFrameLoad = this.onFrameLoad.bind(this); this.onDomEvent = this.safeBind(this.onDomEvent); var extractorConfig = this.extractorConfig || {} // used as bubble target for `exception` event extractorConfig.recorder = this extractorConfig.uniqueComponentProperty = extractorConfig.uniqueComponentProperty || this.uniqueComponentProperty; extractorConfig.uniqueDomNodeProperty = extractorConfig.uniqueDomNodeProperty || this.uniqueDomNodeProperty; extractorConfig.swallowExceptions = this.swallowExceptions; extractorConfig.shouldIgnoreDomElementId = this.shouldIgnoreDomElementId; extractorConfig.ignoreCssClasses = this.ignoreCssClasses; this.extractor = new this.extractorClass(extractorConfig); }, isSamePoint : function (event1, event2) { return Math.abs(Math.round(event1.x) - Math.round(event2.x)) <= this.dragPixelThreshold && Math.abs(Math.round(event1.y) - Math.round(event2.y)) <= this.dragPixelThreshold; }, isSameTarget : function (event1, event2) { return event1.target == event2.target || this.contains(event1.target, event2.target) || this.contains(event2.target, event1.target); }, clear : function () { var me = this me.events = [] me.actions = [] me.actionsByEventId = {} me.fireEvent('clear', me) }, // We monitor page loads so the recorder can add a waitForPageLoad action onUnload : function () { var actions = this.actions, last = actions.length && actions[ actions.length - 1 ]; if (last && last.target) { last.waitForPageLoad = true; } this.stop(true); }, // After frame has loaded, stop listening to old window and restart on new frame window onFrameLoad : function (event) { // Frame could be violating same-origin policy at this point try { var win = event.target.contentWindow; // will throw if different origin var a = win.location.href; } catch (e) { win = null; } if (win) { this.attach(win); this.start(); } }, /* * Attaches the recorder to a Window object * @param {Window} window The window to attach to. **/ attach : function (window) { if (this.window !== window) { this.stop() } // clear only events, keep the actions this.events = [] this.window = window; }, /* * Starts recording events of the current Window object **/ start : function () { if (this.active) return if (!this.ignoreSynthetic && !this.hasOwnProperty(('clickMergeThreshold'))) this.clickMergeThreshold = Infinity this.stop(); this.active = Date.now(); this.onStart(); this.fireEvent('start', this); }, /* * Stops the recording of events **/ stop : function (keepOnLoadListenerOnIframe) { if (this.active) { this.active = null; this.onStop(keepOnLoadListenerOnIframe); this.fireEvent('stop', this); } }, getRecordedEvents : function () { return this.events; }, getRecordedActions : function () { return this.actions }, getRecordedActionsAsSteps : function () { return Joose.A.map(this.actions, function (action) { return action.asStep() }) }, // main listener onDomEvent : function (e) { var target = e.target this.processingEvent = e; if (this.debugMode && this.window.console && typeof this.window.console.log === 'function') { console.log('[EVENT] : ', e.type, target, e.keyIdentifier || e.key); } // Never trust IE - target may be absent // Ignore events from played back test (if user plays test and records before it's stopped) if (!target || (this.ignoreSynthetic && (e.synthetic || !e.isTrusted))) return; var event = Siesta.Recorder.Event.fromDomEvent(e) this.convertToAction(event) this.events.push(event) // do not store more than 10 events if (this.events.length > 10) this.events.shift() this.fireEvent('domevent', event) }, eventToAction : function (event, onlyXY) { var type = event.type var options = event.options; var actionName if (type === 'wheel') { var rawEvent = event.rawEvent; actionName = 'wheel'; options = Joose.O.extend({ deltaX : rawEvent.deltaX || 0, deltaY : rawEvent.deltaY || 0, deltaZ : rawEvent.deltaZ || 0, deltaMode : rawEvent.deltaMode || 0}, options) } else if (type.match(/^key/)) // convert all key events to type for now actionName = 'type' else if (this.isPointerDownEvent(event)) actionName = 'mousedown' else if (this.isPointerUpEvent(event)) actionName = 'mouseup' else actionName = type var config = { action : actionName, target : this.getPossibleTargets(event, type !== 'wheel', null, onlyXY), options : options, sourceEvent : event, sourceEventTargetReachableAtCenter : this.isElementReachableAtCenter(event.target, false) } // `window` object to which the event target belongs var win = event.target.ownerDocument.defaultView; // Case of nested iframe if (win !== this.window && !onlyXY) { if (!win.frameElement.id && this.warnAboutIframeMissingId) { throw new Error('To record events in a nested iframe, please set an "id" property on your frames'); } // Prepend the frame id to each suggested target config.target = config.target.filter(function (actionTarget) { if (typeof actionTarget.target === 'string') { actionTarget.target = '#' + win.frameElement.id + ' -> ' + actionTarget.target; return true; } // Skip array coordinates for nested iframes, make little sense return false; }); } return new this.actionClass(config) }, recordAsAction : function (event, omitTarget) { var action = this.eventToAction(event, omitTarget) if (action) { this.addAction(action) return action; } }, addAction : function (action) { if (!(action instanceof this.actionClass)) { action = new this.actionClass(action); } this.beforeAddAction(action); this.actions.push(action) if (action.sourceEvent) { this.actionsByEventId[ action.sourceEvent.id ] = action if (typeof action.sourceEvent.x === 'number') { this.lastActionPosition[0] = action.sourceEvent.rawEvent.clientX; this.lastActionPosition[1] = action.sourceEvent.rawEvent.clientY; } } if (this.debugMode && this.window.console && typeof this.window.console.log === 'function') { console.log('[ACTION] : ' + action.action, action.getTarget() && action.asStep().target || '', action); } this.fireEvent('actionadd', action) }, removeAction : function (actionToRemove) { var actions = this.actions; for (var i = 0; i < actions.length; i++) { var action = actions[ i ] if (action == actionToRemove) { actions.splice(i, 1) if (action.sourceEvent) delete this.actionsByEventId[ action.sourceEvent.id ] this.fireEvent('actionremove', actionToRemove) break; } } }, removeActionByEventId : function (eventId) { this.removeAction(this.getActionByEventId(eventId)) }, removeActionByEvent : function (event) { this.removeAction(this.getActionByEventId(event.id)) }, getActionByEvent : function (event) { return this.actionsByEventId[ event.id ] }, getActionByEventId : function (eventId) { return this.actionsByEventId[ eventId ] }, getLastAction : function () { return this.actions[ this.actions.length - 1 ] }, getLastEvent : function () { return this.events[ this.events.length - 1 ] }, canCombineTypeActions : function (prevOptions, curOptions) { return prevOptions.ctrlKey == curOptions.ctrlKey && prevOptions.metaKey == curOptions.metaKey && prevOptions.shiftKey == curOptions.shiftKey && prevOptions.altKey == curOptions.altKey; }, finalizeDragAction : function (mouseUpEvent, mouseDownEvent) { // omit the target queries finding for `mouseup` event, since we'll add coordinate target // manually anyway var action = this.eventToAction(mouseUpEvent, true); // For drag drop operations, we use a simple coordinate for mouseup always action.target = new Siesta.Recorder.Target({ targets : [{ target : [ mouseUpEvent.x, mouseUpEvent.y ], type : 'xy' }] }) this.addAction(action) }, // Method which tries to identify "composite" DOM interactions such as 'click/contextmenu' (3 events), double click // but also complex scenarios such as 'drag' convertToAction : function (event) { var type = event.type var events = this.getRecordedEvents(), length = events.length, tail = this.getLastEvent(); var tailPrev = length >= 2 ? events[ length - 2 ] : null; if (this.shouldIgnoreEvent(event, tail, tailPrev)) { return; } if (type === 'wheel') { this.recordAsAction(event); return } if (type === 'keypress' || type === 'keyup' || type === 'keydown') { this.convertKeyEventToAction(event); return } // if there's no already recorded events - there's nothing to coalesce if (!length) { this.recordAsAction(event) return } if (type === 'dblclick') { // On Android Mobile Safari it seems 'dblclick' can be fired without 2 preceding clicks somehow if (this.getRecordedActions().length > 1) { // removing the last `click` action - one click event will still remain this.removeAction(this.getLastAction()) } this.getLastAction().action = 'dblclick' this.fireEvent('actionupdate', this.getLastAction()) return } // // if mousedown/up happened in a row in different points - this is considered to be a drag operation if ( this.isPointerDownEvent(tail) && this.isPointerUpEvent(event) && event.button === tail.button && !this.isSamePoint(event, tail) ) { this.finalizeDragAction(event, tail) return } if ( tail && this.isPointerUpEvent(event) && this.isPointerDownEvent(tail) && event.button === tail.button && this.isSamePoint(event, tail) // FF57 does not fire `click` when target changes in mousedown, so we need the `mouseup` target and can not // optimize // possibly will be fixed in later FF releases since it contradicts what Chrome does && !bowser.gecko ) { // record `mouseup` which happened in the same point as `mousedown` // w/o target - since it will be removed by the "click" processing anyway // this is to save some CPU cycles (which can be up to ~200-300ms in heavy DOM) this.recordAsAction(event, true) return } var isFF57BrokenOnWindows = bowser.gecko && bowser.windows // Merge events to click action if (tailPrev && type === 'click') { if ( // Verify tail this.isPointerUpEvent(tail) && event.button === tail.button && event.button === tailPrev.button && this.isSamePoint(event, tail) && // Verify previous tail this.isPointerDownEvent(tailPrev) && this.isSameTarget(event, tail) && this.isSameTarget(event, tailPrev) && this.isSamePoint(event, tailPrev) ) { // Convert mousedown action to a click action, and use all context recorded at mousedown since DOM may have changed // at the time 'click' was observed var mouseDownAction = this.getActionByEvent(tailPrev) // Don't merge to 'click' if there is a noticable delay between mousedown/mouseup timestamp // Application could have logic implemented for longpress etc if (Date.now() - mouseDownAction.getTimestamp() > this.clickMergeThreshold) { } else { this.removeActionByEvent(tail) if ( event.target == tail.target && tail.target == tailPrev.target && !this.recordOffsets && mouseDownAction.sourceEventTargetReachableAtCenter ) { // do not completely remove the offset for FF57 on Windows but save it, // since it may be needed for "contextmenu" action, see below mouseDownAction.clearTargetOffset(isFF57BrokenOnWindows) } mouseDownAction.action = 'click'; this.fireEvent('actionupdate', mouseDownAction); } return; } else { // click event is fired after a drag, which should not be recorded as a separate click return; } } else if (type === 'contextmenu') { var lastAction = this.getLastAction() // FF 57 on Windows fires mousedown/up/click/contextmenu for right click (crazy) if (isFF57BrokenOnWindows && tail.type === 'click' && lastAction.action === 'click') { lastAction.action = 'contextmenu' lastAction.restoreTargetOffset() this.fireEvent('actionupdate', lastAction) return } // Verify tail (Mac OSX doesn't fire mouse up) if ( (this.isPointerUpEvent(tail) || this.isPointerDownEvent(tail)) && event.button === tail.button && this.isSamePoint(event, tail) ) { // If on windows, first get rid of pointerup action if (this.isPointerUpEvent(tail)) { this.removeActionByEvent(tail) } // Convert last pointerdown action to contextmenu to maintain original target var lastAction = this.getLastAction(); lastAction.action = 'contextmenu' this.fireEvent('actionupdate', lastAction) return } // Verify previous tail if (this.isPointerUpEvent(tail) && this.isPointerDownEvent(tailPrev) && this.isSameTarget(event, tail) && this.isSameTarget(event, tailPrev) && this.isSamePoint(event, tailPrev) ) { this.removeActionByEvent(tailPrev) } } this.recordAsAction(event) }, shouldIgnoreEvent : function (event, tail, tailPrev) { // In some situations the mouseup event may remove/overwrite the current element and no click will be triggered // so we need to catch drag operation on mouseup (see above) and ignore following "click" event // TODO NEEDED? // if (event.type === 'click' && this.getLastAction() && this.getLastAction().action === 'drag') { // return true // } var eventType = event.type var isKeyEvent = eventType.match(/^key/) var keyCode = event.keyCode var keys = Siesta.Test.UserAgent.KeyCodes().keys // Ignore modifier keys which are used only in combination with other keys if (isKeyEvent && (keyCode === keys.SHIFT || keyCode === keys.CTRL || keyCode === keys.ALT || keyCode === keys.CMD)) return true; // On Mac ignore mouseup happening after contextmenu if (this.isPointerUpEvent(event) && tail && tail.type === 'contextmenu') { return true } // ignore keypress for CTRL+KEY if ( event.type == 'keypress' && tail && tail.type == 'keydown' && tailPrev && tailPrev.type == 'keydown' && event.rawEvent.key == tail.rawEvent.key && tailPrev.rawEvent.key == 'Control' && (event.rawEvent.timeStamp - tail.rawEvent.timeStamp) < 3 ) { return true } // Clicks on a <label> with produces 2 "click" events, just ignore the 2nd event and do not record it as an action // in FF, the 2nd "click" will have 0, 0 coordinates, so we have to disable `isSamePoint` extra sanity check if (event.type == 'click' && tail && tail.type == 'click' && tail.target.nodeName.toLowerCase() === 'label') { return true } }, convertKeyEventToAction : function (event) { var type = event.type var tail = this.getLastEvent(); var KC = Siesta.Test.UserAgent.KeyCodes(); var isSpecial = type == 'keydown' && (KC.isSpecial(event.keyCode) || KC.isNav(event.keyCode)); var isModifier = KC.isModifier(event.keyCode); var options = event.options; var prevType = tail && tail.type; var prevSpecial = type == 'keypress' && prevType == 'keydown' && (KC.isSpecial(tail.keyCode) || KC.isNav(tail.keyCode)); var isWindows = this.parseOS(navigator.platform) === 'Windows'; var isMac = this.parseOS(navigator.platform) === 'MacOS'; // On Windows and Linux, no keypress is triggered if CTRL key is pressed along with a regular char (e.g Ctrl-C). // On Mac, no keypress is triggered if CMD key is pressed along with a regular char (e.g Cmd-C). if (type === 'keypress' && !isSpecial && !prevSpecial || (type === 'keydown' && (isSpecial || isModifier || (!isMac && options.ctrlKey) || (isMac && options.metaKey)))) { var lastAction = this.getLastAction() var text = event.rawEvent.key ? event.rawEvent.key : (isSpecial ? KC.fromCharCode(event.charCode, true) : String.fromCharCode(event.charCode)); text = isSpecial ? '[' + text.toUpperCase() + ']' : text; // Crude check to make sure we don't merge a CTRL-C with the next "normal" keystroke if (lastAction && lastAction.action === 'type' && this.canCombineTypeActions(lastAction.options, event.options)) { if (!KC.isModifier(event.keyCode)) { lastAction.value += text this.fireEvent('actionupdate', lastAction) } } else { this.addAction({ action : 'type', target : this.getPossibleTargets(event), value : text, sourceEvent : event, options : event.options }) } return } // ignore 'keydown' events }, onStart : function () { var me = this, window = me.window, doc = window.document, body = doc.body, resizeTimeout = null, frameWindows = this.getNestedFrames().map(function(frame) { return frame.contentWindow; }); // Listen to test window and any frames nested in it [ window ].concat(frameWindows).forEach(function (win) { me.registerWindowListeners(win); }) window.frameElement && window.frameElement.addEventListener('load', this.onFrameLoad); window.addEventListener('unload', this.onUnload); // To make sure we can record key events immediately window.focus(); }, onStop : function (keepOnLoadListenerOnIframe) { var me = this, window = me.window, doc = window.document, body = doc.body, frameWindows = this.getNestedFrames().map(function (frame) { return frame.contentWindow; }); // Unlisten to test window and any frames nested in it [ window ].concat(frameWindows).forEach(function (win) { me.deregisterWindowListeners(win); }) // if recorder is attached to a iframed window, in the "unload" event, we want to cleanup the listeners // but keep the 'load' listener for the <iframe> element itself, to re-attach recorder to newly loaded page if (!keepOnLoadListenerOnIframe) { window.frameElement && window.frameElement.removeEventListener('load', me.onFrameLoad); } window.removeEventListener('unload', this.onUnload); }, registerWindowListeners : function (win) { if (this.isCrossOriginWindow(win)) return var me = this; me.getEventsToRecord().forEach(function (name) { win.document.addEventListener(name, me.onDomEvent, true); }); }, deregisterWindowListeners : function (win) { if (this.isCrossOriginWindow(win)) return var me = this; me.getEventsToRecord().forEach(function (name) { win.document.removeEventListener(name, me.onDomEvent, true); }); }, // Returns only frames on the same domain getNestedFrames : function() { var me = this return Array.prototype.slice.apply(this.window.document.getElementsByTagName('iframe')).filter(function(frame) { return !me.isCrossOriginWindow(frame.contentWindow) }); }, // Hook called before adding actions to inject 'helping' actions beforeAddAction : function (action) { }, getPossibleTargets : function (event, recordOffsets, targetOverride, onlyXY) { if (this.typeOf(event.target) == 'HTMLDocument') event.target = event.target.body return this.extractor.getTargets(event, recordOffsets, targetOverride, onlyXY); } } // eof methods });