siesta-lite
Version:
Stress-free JavaScript unit testing and functional testing tool, works in NodeJS and browsers
975 lines (748 loc) • 30.2 kB
JavaScript
/*
Siesta 5.6.1
Copyright(c) 2009-2022 Bryntum AB
https://bryntum.com/contact
https://bryntum.com/products/siesta/license
*/
Ext.define('Siesta.Recorder.UI.RecorderPanel', {
extend : 'Ext.tree.Panel',
alias : 'widget.recorderpanel',
requires : [
'Ext.grid.plugin.CellEditing',
'Siesta.Recorder.UI.ActionColumn',
'Siesta.Recorder.UI.ActionIconColumn',
'Siesta.Recorder.UI.TargetColumn',
'Siesta.Recorder.UI.ContextMenu',
'Siesta.Recorder.UI.Store.Action'
],
mixins : [
'Siesta.Project.Browser.UI.CanCopyToClipboard'
],
buttonAlign : 'left',
border : false,
cls : 'siesta-recorderpanel',
selModel : {
mode : 'MULTI'
},
store : {
type : 'actionstore'
},
rootVisible : false,
viewConfig : {
markDirty : false,
stripeRows : false,
allowCopy : true,
plugins : {
ptype : 'treeviewdragdrop'
}
},
newActionDefaults : {
action : 'click'
},
lines : false,
test : null,
recorder : null,
project : null,
domContainer : null,
recorderConfig : null,
editing : null,
bufferedRenderer : false,
enableColumnMove : false,
enableContextMenu : true,
enableEditing : true,
showToolbars : true,
playbackOnly : false,
/**
* @event startrecord
* Fires when a recording is started
* @param {Siesta.Recorder.UI.RecorderPanel} this
* @param {Siesta.Test} test The test instance to which the recorder is attached
*/
/**
* @event stoprecord
* Fires when a recording is stopped
* @param {Siesta.Recorder.UI.RecorderPanel} this
*/
/**
* @event play
* Fires when a recording is being played back
* @param {Siesta.Recorder.UI.RecorderPanel} this
*/
initComponent : function () {
var me = this;
var R = Siesta.Resource('Siesta.Recorder.UI.RecorderPanel');
var recorderConfig = this.recorderConfig || {};
if (!this.playbackOnly) {
var recorderClass = this.project && this.project.recorderClass || recorderConfig.recorderClass || Siesta.Recorder.ExtJS;
if (typeof recorderClass === 'string') {
recorderClass = Joose.S.strToClass(recorderClass);
}
var recorder = me.recorder = me.recorder || new recorderClass(recorderConfig);
recorder.on("actionadd", this.onActionAdded, this)
recorder.on("actionremove", this.onActionRemoved, this)
recorder.on("actionupdate", this.onActionUpdated, this)
recorder.on("clear", this.onRecorderClear, this)
recorder.on("start", this.onRecorderStart, this)
recorder.on("stop", this.onRecorderStop, this)
}
if (this.enableEditing) {
me.plugins = me.plugins ? [].concat(me.plugins) : [];
me.editing = me.editing || new Ext.grid.plugin.CellEditing({
clicksToEdit : 1
});
me.editing.on({
beforeedit : me.onBeforeEdit,
validateedit : me.onValidateEdit,
edit : me.afterEdit,
canceledit : me.afterEdit,
scope : me
});
this.relayEvents(me.editing, ['beforeedit', 'afteredit', 'validateedit'])
me.plugins.push(me.editing);
this.on('hide', function () {
if (this.editing) {
this.editing.completeEdit();
}
});
this.on({
afteredit : this.onAfterEdit,
validateedit : this.onAfterEdit,
canceledit : this.onAfterEdit,
scope : this,
buffer : 200
});
}
Ext.applyIf(me, {
columns : [
{
xtype : 'recorderactioniconcolumn'
},
{
xtype : 'recorderactioncolumn'
},
{
xtype : 'targetcolumn',
highlightTarget : this.highlightTarget.bind(this)
}
].concat(
{
header : R.get('offsetColumnHeader'),
dataIndex : '__offset__',
width : 60,
sortable : false,
menuDisabled : true,
tdCls : 'siesta-recorderpanel-offsetcolumn',
renderer : function (value, meta, record) {
var target = record.getTarget()
if (target && target.offset) {
return target.offset + '<div class="siesta-recorderpanel-clearoffset fa fa-close"></div>'
}
},
editor : 'textfield'
}
).concat({
xtype : 'actioncolumn',
width : 55,
align : 'center',
sortable : false,
menuDisabled : true,
tdCls : 'siesta-recorderpanel-actioncolumn',
items : [
{
iconCls : 'step-icon fa fa-close icon-delete-row',
tooltip : 'Delete this action',
handler : this.onDeleteStepClick,
scope : this
},
{
iconCls : 'step-icon fa fa-play icon-play-row',
tooltip : 'Play this action',
handler : this.onPlaySingleStepClick,
scope : this
},
{
iconCls : 'step-icon fa fa-forward icon-play-from-row',
tooltip : 'Play from this action',
handler : this.onPlayFromStepClick,
scope : this
}
]
})
});
if (this.showToolbars) {
me.createToolbars();
}
me.callParent();
this.mon(Ext.getBody(), 'mousedown', this.onBodyMouseDown, this, { delegate : '.target-inspector-label' })
if (this.enableContextMenu) {
this.contextMenu = new Siesta.Recorder.UI.ContextMenu({ panel : this });
}
},
onAfterEdit : function () {
if (!this.editing.editing) {
this.hideHighlighter();
}
},
onBodyMouseDown : function (e, t) {
var focusedEl = document.activeElement;
if (Ext.fly(focusedEl).up('.siesta-targeteditor')) {
e.stopEvent();
e.preventDefault();
focusedEl.value = Ext.htmlDecode(t.innerHTML);
}
},
onRecorderStart : function () {
this.fireEvent('startrecord', this, this.test);
this.addCls('recorder-recording');
},
onRecorderStop : function () {
this.fireEvent('stoprecord', this);
this.removeCls('recorder-recording');
},
hideHighlighter : function () {
if (this.test && this.domContainer) {
this.domContainer.clearHighlight();
}
},
highlightTarget : function (target, offset) {
if (!target) {
// Pass no target => simply hide highlighter
this.hideHighlighter();
return;
}
var test = this.test;
if (!test) {
this.hideHighlighter();
return { success : true }
}
var R = Siesta.Resource('Siesta.Recorder.UI.RecorderPanel');
var resolved, el
try {
resolved = this.test.normalizeElement(target, true, true, true);
el = resolved.el
} catch (e) {
// sizzle may break on various characters in the query (=, $, etc)
} finally {
if (!el) {
return { success : false, warning : R.get('queryMatchesNothing') }
}
}
var warning = resolved.matchingMultiple ? R.get('queryMatchesMultiple') : ''
if (test.isElementVisible(el) && this.domContainer) {
var pointToVisualize = Ext.isArray(target) ? target : (offset || ['50%', '50%']);
this.domContainer.highlightTarget(el, '<span class="target-inspector-label">' + target + '</span>', pointToVisualize);
} else {
// If target was provided but no element could be located, return false so
// caller can get a hint there is potential trouble
warning = warning || R.get('noVisibleElsFound')
}
return {
success : !warning,
message : warning
};
},
createToolbars : function () {
var me = this;
var R = Siesta.Resource('Siesta.Recorder.UI.RecorderPanel');
me.dockedItems = [
{
xtype : 'toolbar',
padding : '3 5',
cls : 'siesta-toolbar recorder-toolbar',
dock : 'top',
height : 45,
style : 'border-color:transparent',
items : [
{
xtype : 'textfield',
itemId : 'recording-name',
fieldLabel : 'Name',
height : 30,
width : 200,
labelWidth : 50,
value : R.get('newRecording')
},
{
xtype : 'textfield',
itemId : 'pageUrl',
height : 30,
flex : 1,
labelWidth : 70,
fieldLabel : R.get('pageUrl'),
enableKeyEvents : true,
listeners : {
keyup : function (field, e) {
if (e.getKey() == e.ENTER) {
this.onPageUrlFieldEnterKey();
}
},
scope : this
}
}
]
},
{
xtype : 'toolbar',
cls : 'siesta-toolbar siesta-recorder-button-toolbar',
dock : 'top',
height : 45,
defaults : {
scale : 'medium',
tooltipType : 'title',
scope : me
},
items : [
{
iconCls : 'fa fa-circle icon-record',
action : 'recorder-start',
cls : 'recorder-tool',
whenIdle : true,
tooltip : R.get('recordTooltip'),
handler : me.onRecordClick
},
{
iconCls : 'fa fa-square',
cls : 'recorder-tool',
action : 'recorder-stop',
handler : me.stop,
tooltip : R.get('stopTooltip')
},
{
iconCls : 'fa fa-play',
action : 'recorder-play',
cls : 'recorder-tool',
handler : me.onPlayClick,
tooltip : R.get('playTooltip')
},
{
iconCls : 'fa fa-close',
action : 'recorder-remove-all',
cls : 'recorder-tool icon-clear',
handler : function () {
var me = this;
if (me.store.getCount() === 0) return;
Ext.Msg.confirm(R.get('removeAllPromptTitle'), R.get('removeAllPromptMessage'), function (btn) {
if (btn == 'yes') {
// process text value and close...
me.clear();
}
});
},
tooltip : R.get('clearTooltip')
},
{
iconCls : 'fa fa-plus',
action : 'recorder-add-step',
tooltip : R.get('addNewTooltip'),
cls : 'recorder-tool',
tooltipType : 'title',
scope : me,
handler : function () {
var store = me.store;
var selected = me.getSelectionModel().selected.first();
var model = new store.model(Ext.apply({}, this.newActionDefaults));
if (selected && selected.isVisible()) {
selected.parentNode.insertChild(selected.get('index') + 1, model);
} else {
store.getRootNode().appendChild(model);
}
me.editing.startEdit(model, 1);
}
},
'->',
{
xtype : 'splitbutton',
text : 'Show source',
cls : 'recorder-tool',
action : 'recorder-generate-code',
handler : this.onGenerateCodeClick,
scope : this,
menu : {
items : [
{
text : R.get('showSourceInNewWindow'),
scope : this,
handler : function () {
var win = window.open(null);
var body = win.document.body;
var recordingName = this.getRecordingName();
var code = this.store.generateCode(recordingName);
body.innerHTML = '<pre>' + code + '</pre>';
}
}
]
}
},
window.document.queryCommandSupported('copy')
?
{
iconCls : 'fa fa-clipboard',
cls : 'recorder-tool',
action : 'recorder-copy-to-clipboard',
handler : me.onCopyToClipboard,
tooltip : 'Copy generated code to clipboard'
}
:
null,
me.closeButton
]
}];
me.bbar = {
xtype : 'component',
cls : 'cheatsheet',
// height : 70,
html : this.getCheatSheetText()
};
},
getCheatSheetText : function () {
var text = '<table><tr><td class="cheatsheet-type">CSS Query:</td><td class="cheatsheet-sample"> .x-btn</td></tr>'
if (this.recorder instanceof Siesta.Recorder.ExtJS) {
text += '<tr><td class="cheatsheet-type">Component Query:</td><td class="cheatsheet-sample"> >>toolbar button</td></tr>' +
'<tr><td class="cheatsheet-type">Composite Query:</td><td class="cheatsheet-sample"> toolbar => .x-btn</td></tr>'
}
text += '</table>'
return text
},
// Attach to a test (and optionally a specific iframe, only used for testing)
attachTo : function (test, iframe) {
var me = this;
var doClear = me.test && me.test.url !== test.url;
var recorder = this.recorder
this.setTest(test);
var recWindow = (iframe && iframe.contentWindow) || (test.scopeProvider && test.scopeProvider.scope);
if (recWindow) {
me.recorder.attach(recWindow);
}
if (doClear) me.clear();
},
onPageUrlFieldEnterKey : function () {
var descriptor = this.getRecorderTestDescriptor();
if (descriptor && (descriptor.hostPageUrl || descriptor.pageUrl)) {
this.domContainer.expand();
this.project.startSingle(descriptor);
this.project.on('teststart', function (event, test) {
if (test.url === descriptor.url) {
// To ensure test is visible
this.fireEvent('play', this, test, 0);
}
}, this, { single : true });
}
},
onRecordClick : function () {
var test = this.test
if (!test) return;
var R = Siesta.Resource('Siesta.Recorder.UI.RecorderPanel');
var descriptor = this.getRecorderTestDescriptor();
if (descriptor) {
if (this.isRecording()) {
this.stop();
} else {
var project = this.project;
var pageUrl = this.down('#pageUrl').getValue();
// Grab a new test reference from project in case test was rerun while recorder was open
test = project.getTestByURL(test.url) || test;
this.setTest(test);
var scopeProvider = test.scopeProvider;
// If we're recording a new URL, or the test has no window (already was cleaned up) -
// first launch the test to load the URL into the test iframe
if (!scopeProvider || pageUrl && scopeProvider.sourceURL !== pageUrl) {
project.on('teststart', function (event, test) {
if (test.url === descriptor.url) {
this.attachTo(test);
this.recorder.start();
}
}, this, { single : true });
project.startSingle(descriptor);
} else if (test && test.global) {
this.attachTo(test);
this.recorder.start();
}
}
} else {
Ext.Msg.alert('Error', R.get('noTestStarted'))
}
},
onPlayClick : function () {
var me = this;
var descriptor = this.getRecorderTestDescriptor();
if (descriptor) {
me.stop();
var testStartListener = function (ev, runningTestInstance) {
if (runningTestInstance.url === descriptor.url) {
runningTestInstance.on('beforetestfinalizeearly', testFinalizeListener, null, { single : true });
}
};
var testFinalizeListener = function (ev, test2) {
// important, need to update our reference to the test
me.setTest(test2);
// Run test first, and before it ends - fire off the recorded steps
me.playSteps();
};
var project = me.project;
project.on('teststart', testStartListener, null, { single : true });
if (this.store.getCount() > 0) {
this.scrollRecordIntoView( this.store.getAt(0));
}
project.startSingle(descriptor);
}
},
onCopyToClipboard : function (button) {
var recordingName = this.getRecordingName()
var success = this.copyToClipboard(this.store.generateCode(recordingName))
Ext.Msg.show({
animateTarget : button.getEl(),
message : success ? 'Code copied successfully' : 'Something went wrong, code was not copied',
title : 'Copy to clipboard',
modal : false
})
setTimeout(function () {
Ext.Msg.hide()
}, 800)
},
stop : function () {
this.recorder.stop();
},
clear : function () {
this.recorder.clear();
},
onRecorderClear : function () {
this.store.getRootNode().removeAll();
},
getRecorderTestDescriptor : function () {
var project = this.project;
var pageUrl = this.down('#pageUrl').getValue();
var test = this.test;
var descriptor = test && project.getScriptDescriptor(test.url);
var testHostUrl = descriptor && project.getDescriptorConfig(descriptor, 'pageUrl');
// If user changes target page URL - use an empty virtual test descriptor
return pageUrl && testHostUrl !== pageUrl ? {
url : '/',
enablePageRedirect : true,
testCode : 'StartTest(function(t) {})',
pageUrl : pageUrl
} : (test ? descriptor : null);
},
setTest : function (test) {
if (this.test) {
this.test.un('beforechainstep', this.onBeforeActionExecute, this)
}
this.test = test
if (test) {
test.on('beforechainstep', this.onBeforeActionExecute, this)
var field = this.down('#pageUrl')
if (field && test.scopeProvider) field.setValue(test.scopeProvider.sourceURL || '')
test.on('testfinalize', function () {
if (test.scopeProvider && test.scopeProvider.crossOriginFailed) {
Ext.Msg.alert('Error', 'Recorder attached to the page from different origin - can\'t record anything')
}
})
}
},
generateSteps : function (events) {
var me = this;
var t = me.test;
var steps = (events || this.store.getRange()).map(function (action, index) {
if (action.isLeaf()) {
var step = action.asStep(t);
// wait for targets when playing entire test,
// and
// when playing actions manually from a certain step, wait for all steps but the first one
if (action.getTarget()) step.waitForTarget = !events || index > 0;
return [
function (next) {
index = events ? me.store.indexOf(action) : index;
t.fireEvent('beforechainstep', t, index, step);
next();
},
step
]
} else {
return me.generateSteps(action.childNodes);
}
});
return steps;
},
onBeforeActionExecute : function(event, test, index, step) {
var count = this.store.getCount();
this.getSelectionModel().select(index);
if (index < count - 2) {
this.scrollRecordIntoView( this.store.getAt(index + 2));
}
},
onActionAdded : function (event, action) {
var root = this.store.getRootNode();
var targetParent = (root.lastChild && root.lastChild.parentNode) || root;
var newRecord = targetParent.appendChild(action);
this.scrollRecordIntoView(newRecord)
},
onActionRemoved : function (event, action) {
this.store.getNodeById(action.id).remove();
},
onActionUpdated : function (event, action) {
var model = this.store.getNodeById(action.id);
model.callJoined('afterEdit', [['target', 'action', '__offset__']])
},
getActions : function (asJooseInstances) {
var actionModels = this.store.getRange()
return asJooseInstances ? Ext.Array.pluck(actionModels, 'data') : actionModels
},
onDestroy : function () {
if (this.recorder) {
this.recorder.stop();
}
this.callParent(arguments);
},
scrollRecordIntoView : function (record) {
if (this.view.rendered) {
this.ensureVisible(record, { animate : { duration : 100 } });
}
},
onBeforeEdit : function (cellEditing, editingContext) {
var column = editingContext.column;
var editor;
if (column.xtype === "targetcolumn") {
cellEditing.completeEdit();
column.setTargetEditor(editingContext.record);
editingContext.value = editingContext.record.get(column.dataIndex);
} else {
editor = editingContext.column.getEditor();
if (editor.xtype === 'typeeditor') {
// Can't populate until we have a test bound
editor.populate(this.test);
}
}
if (editingContext.column.dataIndex === 'target' && this.domContainer) {
this.domContainer.startInspection(false);
}
// Offset only relevant for mouseinput actions
return editingContext.field !== '__offset__' || editingContext.record.isMouseAction();
},
afterEdit : function (plug, e) {
if (e.field === 'action') {
var store = e.column.field.store;
store.clearFilter();
if (store.getById(e.value).get('type') !== store.getById(e.originalValue).get('type')) {
e.record.resetValues();
}
}
},
onValidateEdit : function (plug, e) {
var value = e.value;
if (e.field === 'action' && !value) return false;
if (e.field === '__offset__') {
e.cancel = true;
if (value) {
var parsed = e.record.parseOffset(value);
if (parsed) {
e.record.setTargetOffset(parsed);
}
} else {
e.record.clearTargetOffset();
}
} else if (e.column.getEditor().applyChanges) {
e.cancel = true;
e.column.getEditor().applyChanges(e.record);
}
// Trigger manual refresh of node when 'set' operation is more complex
if (e.cancel) {
this.afterEdit(plug, e);
this.getView().refreshNode(e.record);
}
},
afterRender : function () {
this.callParent(arguments);
var view = this.getView();
view.el.on({
mousedown : function (e, t) {
var record = view.getRecord(view.findItemByChild(t));
record.clearTargetOffset()
view.refreshNode(record);
e.stopEvent();
},
delegate : '.siesta-recorderpanel-clearoffset'
})
},
isRecording : function () {
return this.recorder.active;
},
onGenerateCodeClick : function () {
var R = Siesta.Resource('Siesta.Recorder.UI.RecorderPanel');
var win = new Ext.Window({
title : R.get('codeWindowTitle'),
layout : 'fit',
id : 'codeWindow',
itemId : 'codeWindow',
cls : 'si-recorder-sourcewindow',
height : 400,
width : 600,
autoScroll : true,
autoShow : true,
constrain : true,
modal : true,
closeAction : 'destroy',
stateful : true,
items : {
xtype : 'jseditor',
mode : 'text/javascript'
}
});
var field = win.items.first();
var recordingName = this.getRecordingName();
var code = this.store.generateCode(recordingName);
field.setValue(code);
field.editor.focus();
},
getRecordingName : function () {
return this.down('#recording-name').getValue();
},
onDeleteStepClick : function (grid, rowIndex, colIndex, item, e, record) {
this.editing && this.editing.completeEdit();
record.remove();
},
onPlaySingleStepClick : function (cmp, rowIndex) {
this.playSingle(rowIndex);
},
onPlayFromStepClick : function (cmp, rowIndex) {
this.playFromStep(rowIndex);
},
playSingle : function(index) {
if (this.test) {
var action = this.store.getAt(index);
var steps = this.generateSteps([action]);
this.playSteps(steps);
}
},
playFromStep : function (startIndex) {
if (this.test) {
this.playRange(startIndex);
}
},
playRange : function (startIndex, endIndex) {
if (this.test) {
var steps = this.generateSteps(this.store.getRange(startIndex, endIndex));
this.playSteps(steps);
}
},
playSteps : function (steps) {
var test = this.test
if (test) {
var me = this;
steps = steps || this.generateSteps();
steps = steps instanceof Array ? steps : [ steps ];
test.chain(
function (next) {
me.fireEvent('play', me, test);
next();
},
steps,
function (next) {
me.fireEvent('stop', me, test);
next();
}
);
}
},
getRecorder : function () {
return this.recorder;
}
});