siesta-lite
Version:
Stress-free JavaScript unit testing and functional testing tool, works in NodeJS and browsers
841 lines (621 loc) • 31.5 kB
JavaScript
/*
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
});