diff --git a/.github/workflows/check_version_readout.js.yml b/.github/workflows/check_version_readout.js.yml deleted file mode 100644 index 49bcb5d..0000000 --- a/.github/workflows/check_version_readout.js.yml +++ /dev/null @@ -1,30 +0,0 @@ -# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs - -name: Check Version - -on: - pull_request: - branches: - - "develop" - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 - with: - node-version: "20.x" - cache: "npm" - - name: Set release version - id: set_version - run: | - echo "version=$(cat package.json | grep -sw '"\bversion\b"' | cut -d '"' -f 4)_nightly" >> "$GITHUB_ENV" - - name: Read release version - id: read_version - run: | - echo "$version" diff --git a/.github/workflows/node.js.yml b/.github/workflows/nightly_release.yml similarity index 100% rename from .github/workflows/node.js.yml rename to .github/workflows/nightly_release.yml diff --git a/webapp/assets/scripts/communication_inject.js b/webapp/assets/scripts/communication_inject.js index 72b20a4..d5c6e03 100644 --- a/webapp/assets/scripts/communication_inject.js +++ b/webapp/assets/scripts/communication_inject.js @@ -278,7 +278,7 @@ } }); - communicationService.post('/pageInfo/disconnected', (req, res) => { + communicationService.post('/pageInfo/disconnected', (_, __) => { if (ui5TestRecorder.recorder) { ui5TestRecorder.recorder.showToast('UI5 Journey Recorder disconnected', { duration: 2000, @@ -287,11 +287,11 @@ } }); - communicationService.get('/pageInfo/connected', (req, res) => { + communicationService.get('/pageInfo/connected', (_, res) => { res({ status: 200, message: 'Connected' }); }); - communicationService.get('/pageInfo/version', (req, res) => { + communicationService.get('/pageInfo/version', (_, res) => { const version = ui5TestRecorder?.recorder?.getUI5Version(); if (version) { res({ status: 200, message: version }); @@ -300,6 +300,26 @@ } }); + communicationService.post('/disableRecordListener', (_, res) => { + const recorderInstance = window.ui5TestRecorder?.recorder; + if (recorderInstance) { + recorderInstance.disableRecording(); + res({ status: 200, message: 'executed' }); + } else { + res({ status: 500, message: 'No recorder inject found!' }); + } + }); + + communicationService.post('/enableRecordListener', (_, res) => { + const recorderInstance = window.ui5TestRecorder?.recorder; + if (recorderInstance) { + recorderInstance.enableRecording(); + res({ status: 200, message: 'executed' }); + } else { + res({ status: 500, message: 'No recorder inject found!' }); + } + }) + communicationService.listen(ext_id); window.ui5TestRecorder = { diff --git a/webapp/assets/scripts/page_inject.js b/webapp/assets/scripts/page_inject.js index f724bff..3c4e6d4 100644 --- a/webapp/assets/scripts/page_inject.js +++ b/webapp/assets/scripts/page_inject.js @@ -20,101 +20,27 @@ } //#region public access points - setupHoverSelectEffect() {//append style class - //append style adding and removement - document.onmouseover = e => { - var e = e || window.event; - var el = e.target || e.srcElement; - var ui5El = this.#getUI5Element(el); - - if (ui5El && ui5El.addStyleClass) { - ui5El.addStyleClass('injectClass'); - } - - if (this.#lastDetectedElement && this.#lastDetectedElement.removeStyleClass && ui5El && this.#lastDetectedElement.getId() !== ui5El.getId()) { - this.#lastDetectedElement.removeStyleClass('injectClass'); - } - this.#lastDetectedElement = ui5El; - } - - document.onmouseout = e => { - var e = e || window.event; - var el = e.target || e.srcElement; - var ui5El = this.#getUI5Element(el); - - if (ui5El && ui5El.removeStyleClass) { - ui5El.removeStyleClass('injectClass'); - } - }; + enableRecording() { + // append style adding and removement + // append the click listener + // check if the binding was already made + this.#mouseoverListener = this.#mouseoverListener.name.startsWith('bound ') ? this.#mouseoverListener : this.#mouseoverListener.bind(this); + this.#mouseoutListener = this.#mouseoutListener.name.startsWith('bound ') ? this.#mouseoutListener : this.#mouseoutListener.bind(this); + this.#clickListener = this.#clickListener.name.startsWith('bound ') ? this.#clickListener : this.#clickListener.bind(this); + document.addEventListener('mouseover', this.#mouseoverListener); + document.addEventListener('mouseout', this.#mouseoutListener); + document.addEventListener('click', this.#clickListener); } - setupClickListener() { - document.onclick = (e) => { - if (!this.#clickListenerActive) { - return; - } - let event = e || window.event; - let el = event.target || event.srcElement; - let ui5El = this.#getUI5Element(el); - - const webSocket = window?.ui5TestRecorder?.communication?.webSocket; - if (webSocket) { - const message = { - type: 'clicked', - control: { - id: ui5El.sId, - type: ui5El.getMetadata().getElementName(), - classes: ui5El.aCustomStyleClasses, - properties: this.#getUI5ElementProperties(ui5El), - bindings: this.#getUI5ElementBindings(ui5El), - view: this.#getViewProperties(ui5El), - events: { - press: ui5El.getMetadata().getEvent('press') !== undefined || ui5El.getMetadata().getEvent('click') !== undefined - } - }, - location: window.location.href - } - this.#rr.findControlSelectorByDOMElement({ domElement: ui5El.getDomRef() }).then((c) => { - message.control.recordReplaySelector = c; - webSocket.send_record_step(message); - }).catch(err => { console.log(err.message) }); - - if (ui5El && ui5El.focus) { - ui5El.focus(); - let childs = ui5El.getDomRef().querySelectorAll('input, select, textarea'); - if (childs.length === 0 && ui5El.getDomRef().shadowRoot) { - childs = ui5El.getDomRef().shadowRoot.querySelectorAll('input, select, textarea'); - } - for (let child of childs) { - child.onkeypress = (e) => { - const key_message = { - type: 'keypress', - key: e.key, - keyCode: e.keyCode, - control: { - id: ui5El.sId, - type: ui5El.getMetadata().getElementName(), - classes: ui5El.aCustomStyleClasses, - properties: this.#getUI5ElementProperties(ui5El), - bindings: this.#getUI5ElementBindings(ui5El), - view: this.#getViewProperties(ui5El), - events: { - press: ui5El.getMetadata().getEvent('press') !== undefined - } - }, - location: window.location.href - } - this.#rr.findControlSelectorByDOMElement({ domElement: ui5El.getDomRef() }).then((c) => { - key_message.control.recordReplaySelector = c; - webSocket.send_record_step(key_message); - }).catch(err => { console.log(err.message) }); - } - } - } - } else { - console.error('UI5-Testrecorder: ', 'No communication websocket found!'); - } + disableRecording() { + if (this.#lastDetectedElement && this.#lastDetectedElement.removeStyleClass) { + this.#lastDetectedElement.removeStyleClass('injectClass'); } + this.#lastDetectedElement = null; + + document.removeEventListener('mouseover', this.#mouseoverListener); + document.removeEventListener('mouseout', this.#mouseoutListener); + document.removeEventListener('click', this.#clickListener); } getElementsForId(id) { @@ -212,121 +138,119 @@ executeAction(oEvent) { this.#clickListenerActive = false; + let result; if (this.#rr) { //only for RecordReplay possible to select to use selectors or not - return this.#executeByRecordReplay(oEvent.step, oEvent.useSelectors); + result = this.#executeByRecordReplay(oEvent.step, oEvent.useSelectors); } else { - return this.#executeByPure(oEvent.step); + result = this.#executeByPure(oEvent.step); } this.#clickListenerActive = true; + return result; } - #executeByRecordReplay(oItem, bUseSelectors) { - const oSelector = bUseSelectors ? this.#createSelectorFromItem(oItem) : oItem.recordReplaySelector; - - switch (oItem.actionType) { - case "clicked": - return this.#rr.interactWithControl({ - selector: oSelector, - interactionType: this.#rr.InteractionType.Press - }) - case 'validate': - return this.#rr.findAllDOMElementsByControlSelector({ - selector: oSelector - }).then(result => { - if (result.length > 1) { - throw new Error(); - } - return; - }); - case 'input': - return this.#rr.interactWithControl({ - selector: oSelector, - interactionType: this.#rr.InteractionType.EnterText, - enterText: oItem.keys.reduce((a, b) => a + b.key_char, '') - }) - default: - return Promise.reject('ActionType not defined'); - } + showToast(sMessage, props) { + this.#toast.show(sMessage, props); } - #createSelectorFromItem(oItem) { - const oSelector = {}; - if (oItem.control.controlId.use) { - oSelector['id'] = oItem.control.controlId.id; - return oSelector; - } - oSelector['controlType'] = oItem.control.type; - if (oItem.control.bindings) { - const bindings = oItem.control.bindings.filter(b => b.use); - if (bindings.length === 1) { - oSelector['bindingPath'] = { - path: bindings[0].modelPath, - propertyPath: bindings[0].propertyPath - } - } - } - if (oItem.control.i18nTexts) { - const i18ns = oItem.control.i18nTexts.filter(b => b.use); - if (i18ns.length === 1) { - oSelector['i18NText'] = { - key: i18ns[0].propertyPath, - propertyName: i18ns[0].propertyName - } - } - } - if (oItem.control.properties) { - const props = oItem.control.properties.filter(b => b.use); - if (props.length > 0 && !oSelector.properties) { - oSelector.properties = {} - } - props.forEach(property => { - oSelector.properties[property.name] = property.value; - }) - } - //just a current workaround - if (oItem.recordReplaySelector.viewId) { - oSelector['viewId'] = oItem.recordReplaySelector.viewId; - } - return oSelector; + getUI5Version() { + return sap.ui.version; } + //#endregion public access points - #executeByPure(oItem) { - let elements = this.#getUI5Elements(); - if (oItem.control.controlId.use) { - elements = elements.filter(el => el.getId() === oItem.control.controlId); - } else { - elements = this.getElementsBySelectors(oItem.control); - } + //#region private + #mouseoverListener = e => { + var e = e || window.event; + var el = e.target || e.srcElement; + var ui5El = this.#getUI5Element(el); - if (elements.length !== 1) { - return Promise.reject(); + if (ui5El && ui5El.addStyleClass) { + ui5El.addStyleClass('injectClass'); } - switch (oItem.action_type) { - case "clicked": - this.#executeClick(elements[0].getDomRef()); - return Promise.resolve(); - case "validate": - return Promise.resolve(); - case "input": - this.#executeTextInput(elements[0], oItem); - return Promise.resolve(); - default: - return Promise.reject(`Action Type (${oItem.actionType}) not defined`); + if (this.#lastDetectedElement && this.#lastDetectedElement.removeStyleClass && ui5El && this.#lastDetectedElement.getId() !== ui5El.getId()) { + this.#lastDetectedElement.removeStyleClass('injectClass'); } + this.#lastDetectedElement = ui5El; } - showToast(sMessage, props) { - this.#toast.show(sMessage, props); - } + #mouseoutListener = e => { + var e = e || window.event; + var el = e.target || e.srcElement; + var ui5El = this.#getUI5Element(el); - getUI5Version() { - return sap.ui.version; + if (ui5El && ui5El.removeStyleClass) { + ui5El.removeStyleClass('injectClass'); + } + }; + + #clickListener = (e) => { + if (!this.#clickListenerActive) { + return; + } + let event = e || window.event; + let el = event.target || event.srcElement; + let ui5El = this.#getUI5Element(el); + + const webSocket = window?.ui5TestRecorder?.communication?.webSocket; + if (webSocket) { + const message = { + type: 'clicked', + control: { + id: ui5El.sId, + type: ui5El.getMetadata().getElementName(), + classes: ui5El.aCustomStyleClasses, + properties: this.#getUI5ElementProperties(ui5El), + bindings: this.#getUI5ElementBindings(ui5El), + view: this.#getViewProperties(ui5El), + events: { + press: ui5El.getMetadata().getEvent('press') !== undefined || ui5El.getMetadata().getEvent('click') !== undefined + } + }, + location: window.location.href + } + this.#rr.findControlSelectorByDOMElement({ domElement: ui5El.getDomRef() }).then((c) => { + message.control.recordReplaySelector = c; + webSocket.send_record_step(message); + }).catch(err => { console.log(err.message) }); + + if (ui5El && ui5El.focus) { + ui5El.focus(); + let childs = ui5El.getDomRef().querySelectorAll('input, select, textarea'); + if (childs.length === 0 && ui5El.getDomRef().shadowRoot) { + childs = ui5El.getDomRef().shadowRoot.querySelectorAll('input, select, textarea'); + } + for (let child of childs) { + child.onkeypress = (e) => { + const key_message = { + type: 'keypress', + key: e.key, + keyCode: e.keyCode, + control: { + id: ui5El.sId, + type: ui5El.getMetadata().getElementName(), + classes: ui5El.aCustomStyleClasses, + properties: this.#getUI5ElementProperties(ui5El), + bindings: this.#getUI5ElementBindings(ui5El), + view: this.#getViewProperties(ui5El), + events: { + press: ui5El.getMetadata().getEvent('press') !== undefined + } + }, + location: window.location.href + } + this.#rr.findControlSelectorByDOMElement({ domElement: ui5El.getDomRef() }).then((c) => { + key_message.control.recordReplaySelector = c; + webSocket.send_record_step(key_message); + }).catch(err => { console.log(err.message) }); + } + } + } + } else { + console.error('UI5-Testrecorder: ', 'No communication websocket found!'); + } } - //#endregion public access points - //#region private #getUI5Element(el) { let UIElements = this.#getUI5Elements(); var ui5El = UIElements[el.id]; @@ -451,6 +375,102 @@ }; } + #executeByRecordReplay(oItem, bUseSelectors) { + const oSelector = bUseSelectors ? this.#createSelectorFromItem(oItem) : oItem.recordReplaySelector; + + switch (oItem.actionType) { + case "clicked": + return this.#rr.interactWithControl({ + selector: oSelector, + interactionType: this.#rr.InteractionType.Press + }) + case 'validate': + return this.#rr.findAllDOMElementsByControlSelector({ + selector: oSelector + }).then(result => { + if (result.length > 1) { + throw new Error(); + } + return; + }); + case 'input': + return this.#rr.interactWithControl({ + selector: oSelector, + interactionType: this.#rr.InteractionType.EnterText, + enterText: oItem.keys.reduce((a, b) => a + b.key_char, '') + }) + default: + return Promise.reject('ActionType not defined'); + } + } + + #createSelectorFromItem(oItem) { + const oSelector = {}; + if (oItem.control.controlId.use) { + oSelector['id'] = oItem.control.controlId.id; + return oSelector; + } + oSelector['controlType'] = oItem.control.type; + if (oItem.control.bindings) { + const bindings = oItem.control.bindings.filter(b => b.use); + if (bindings.length === 1) { + oSelector['bindingPath'] = { + path: bindings[0].modelPath, + propertyPath: bindings[0].propertyPath + } + } + } + if (oItem.control.i18nTexts) { + const i18ns = oItem.control.i18nTexts.filter(b => b.use); + if (i18ns.length === 1) { + oSelector['i18NText'] = { + key: i18ns[0].propertyPath, + propertyName: i18ns[0].propertyName + } + } + } + if (oItem.control.properties) { + const props = oItem.control.properties.filter(b => b.use); + if (props.length > 0 && !oSelector.properties) { + oSelector.properties = {} + } + props.forEach(property => { + oSelector.properties[property.name] = property.value; + }) + } + //just a current workaround + if (oItem.recordReplaySelector.viewId) { + oSelector['viewId'] = oItem.recordReplaySelector.viewId; + } + return oSelector; + } + + #executeByPure(oItem) { + let elements = this.#getUI5Elements(); + if (oItem.control.controlId.use) { + elements = elements.filter(el => el.getId() === oItem.control.controlId); + } else { + elements = this.getElementsBySelectors(oItem.control); + } + + if (elements.length !== 1) { + return Promise.reject(); + } + + switch (oItem.action_type) { + case "clicked": + this.#executeClick(elements[0].getDomRef()); + return Promise.resolve(); + case "validate": + return Promise.resolve(); + case "input": + this.#executeTextInput(elements[0], oItem); + return Promise.resolve(); + default: + return Promise.reject(`Action Type (${oItem.actionType}) not defined`); + } + } + #executeClick(el) { const mouseDownEvent = new MouseEvent('mousedown', { view: window, @@ -506,8 +526,9 @@ } const recorderInstance = new RecorderInject(document, window); - recorderInstance.setupHoverSelectEffect(); - recorderInstance.setupClickListener(); + /* recorderInstance.setupHoverSelectEffect(); + recorderInstance.setupClickListener(); */ + recorderInstance.enableRecording(); recorderInstance.showToast("UI5 Journey Recorder successfully injected", { duration: 2000, autoClose: true diff --git a/webapp/controller/BaseController.ts b/webapp/controller/BaseController.ts index 41e31a3..c3ebc69 100644 --- a/webapp/controller/BaseController.ts +++ b/webapp/controller/BaseController.ts @@ -19,6 +19,9 @@ import { IconColor, ValueState } from "sap/ui/core/library"; import { ButtonType, DialogType } from "sap/m/library"; import Button from "sap/m/Button"; import Text from "sap/m/Text"; +import BusyIndicator from "sap/ui/core/BusyIndicator"; +import { ChromeExtensionService } from "../service/ChromeExtension.service"; +import MessageToast from "sap/m/MessageToast"; /** * @namespace com.ui5.journeyrecorder.controller @@ -211,32 +214,56 @@ export default abstract class BaseController extends Controller { } } - protected _openUnsafedDialog() { - return new Promise((resolve, reject) => { - if (!this._unsafeDialog) { - this._unsafeDialog = new Dialog({ - type: DialogType.Message, - state: ValueState.Warning, - title: 'Unsafed Changes!', - content: new Text({ text: "You have unsafed changes, proceed?" }), - beginButton: new Button({ - type: ButtonType.Attention, - text: 'Proceed', - press: () => { - resolve(); - this._unsafeDialog.close(); + protected _openUnsafedDialog(callbacks: { success?: () => void | Promise; error?: () => void | Promise }) { + if (!this._unsafeDialog) { + this._unsafeDialog = new Dialog({ + type: DialogType.Message, + state: ValueState.Warning, + title: 'Unsafed Changes!', + content: new Text({ text: "You have unsafed changes, proceed?" }), + beginButton: new Button({ + type: ButtonType.Attention, + text: 'Proceed', + press: () => { + if (callbacks.success) { + void callbacks.success(); } - }), - endButton: new Button({ - text: 'Cancel', - press: () => { - reject(); - this._unsafeDialog.close(); + this._unsafeDialog.close(); + } + }), + endButton: new Button({ + text: 'Cancel', + press: () => { + if (callbacks.error) { + void callbacks.error(); } - }) + this._unsafeDialog.close(); + } }) - } - this._unsafeDialog.open(); - }) + }) + } + this._unsafeDialog.open(); + } + + protected async onConnect(url: string) { + BusyIndicator.show(); + this.setConnecting(); + await ChromeExtensionService.getInstance().reconnectToPage(url); + BusyIndicator.hide(); + this.setConnected(); + MessageToast.show('Connected', { duration: 500 }); + } + + protected async onDisconnect() { + try { + await ChromeExtensionService.getInstance().disconnect(); + this.setDisconnected(); + MessageToast.show('Disconnected', { duration: 500 }); + } catch (e) { + console.error(e); + this.setDisconnected(); + ChromeExtensionService.getInstance().setCurrentTab(); + MessageToast.show('Disconnected', { duration: 500 }); + } } } diff --git a/webapp/controller/JourneyPage.controller.ts b/webapp/controller/JourneyPage.controller.ts index 48eef28..bae474e 100644 --- a/webapp/controller/JourneyPage.controller.ts +++ b/webapp/controller/JourneyPage.controller.ts @@ -21,13 +21,14 @@ import { TestFrameworks } from "../model/enum/TestFrameworks"; import { downloadZip } from "client-zip"; import { CodePage } from "../model/class/codeStrategies/CodePage.type"; import { ChromeExtensionService } from "../service/ChromeExtension.service"; -import { RecordEvent, Step } from "../model/class/Step.class"; +import { RecordEvent, Step, UnknownStep } from "../model/class/Step.class"; import { RequestBuilder, RequestMethod } from "../model/class/RequestBuilder.class"; import VBox from "sap/m/VBox"; import CheckBox, { CheckBox$SelectEvent } from "sap/m/CheckBox"; import History from "sap/ui/core/routing/History"; import { ValueState } from "sap/ui/core/library"; import ChangeReason from "sap/ui/model/ChangeReason"; +import { StepType } from "../model/enum/StepType"; type ReplayEnabledStep = Step & { state?: ValueState; @@ -52,19 +53,24 @@ export default class JourneyPage extends BaseController { } onNavBack() { - void JourneyStorageService.isChanged((this.getModel('journey') as JSONModel).getData() as Journey).then((unsafed: boolean) => { + void JourneyStorageService.isChanged(Journey.fromObject((this.getModel('journey') as JSONModel).getData() as Partial)).then((unsafed: boolean) => { if (unsafed) { - void this._openUnsafedDialog().then(() => { - void ChromeExtensionService.getInstance().disconnect().then(() => { - this.setDisconnected(); - const sPreviousHash = History.getInstance().getPreviousHash(); - if (sPreviousHash?.indexOf('recording') > -1) { - this.getRouter().navTo("main"); - } else { - super.onNavBack(); - } - }); - }) + this._openUnsafedDialog({ + success: () => { + void ChromeExtensionService.getInstance().disconnect().then(() => { + this.setDisconnected(); + const sPreviousHash = History.getInstance().getPreviousHash(); + if (sPreviousHash?.indexOf('recording') > -1) { + this.getRouter().navTo("main"); + } else { + super.onNavBack(); + } + }); + }, + error: () => { + this.byId('saveBtn').focus(); + } + }); } else { void ChromeExtensionService.getInstance().disconnect().then(() => { this.setDisconnected(); @@ -79,24 +85,6 @@ export default class JourneyPage extends BaseController { }); } - async onConnect() { - BusyIndicator.show(); - this.setConnecting(); - const url = this.model.getProperty('/startUrl') as string; - await ChromeExtensionService.getInstance().reconnectToPage(url); - BusyIndicator.hide(); - this.setConnected(); - MessageToast.show('Connected', { duration: 500 }); - } - - onDisconnect() { - void ChromeExtensionService.getInstance().disconnect().then(() => { - this.setDisconnected(); - MessageToast.show('Disconnected', { duration: 500 }); - }) - } - - onStepDelete(oEvent: Event) { const sPath = (oEvent.getSource()).getBindingContext('journey')?.sPath as string || ''; if (sPath !== '') { @@ -127,7 +115,27 @@ export default class JourneyPage extends BaseController { async onStartReplay() { this._replayDialog.close(); const replaySettings = (this.getModel('journeyControl') as JSONModel).getProperty('/replaySettings') as { delay: number, manual: boolean, rrSelectorUse: boolean }; - await this.onConnect(); + if (!(this.model.getData() as Journey).startUrl) { + const unknownUrl = new Dialog({ + state: ValueState.Error, + type: DialogType.Message, + title: 'Unknown Url!', + content: new Text({ text: "The current setup don't have a url to start from, this depends on the first step provided.\nRedefine your step setup and try again!" }), + beginButton: new Button({ + type: ButtonType.Critical, + text: 'Ok', + press: () => { + unknownUrl.close(); + unknownUrl.destroy(); + } + }) + }); + unknownUrl.open(); + return + } + + const url = this.model.getProperty('/startUrl') as string; + await this.onConnect(url); (this.getModel('journeyControl') as JSONModel).setProperty('/replayEnabled', true); if (!replaySettings.manual) { @@ -138,10 +146,10 @@ export default class JourneyPage extends BaseController { } } - onStopReplay() { + async onStopReplay() { (this.getModel('journeyControl') as JSONModel).setProperty('/replayEnabled', false); (this.getModel('journeyControl') as JSONModel).setProperty('/manualReplay', true); - this.onDisconnect(); + await this.onDisconnect(); } onChangeReplayDelay(oEvent: Event) { const index = oEvent.getParameter("selectedIndex" as never); @@ -159,27 +167,48 @@ export default class JourneyPage extends BaseController { (this.getModel("journeyControl") as JSONModel).setProperty('/replaySettings/delay', 0.5); } } + + onReorderItems(event: Event) { + const movedId = event.getParameter('draggedControl').getBindingContext('journey').getObject().id as string; + const droppedId = event.getParameter('droppedControl').getBindingContext('journey').getObject().id as string; + this._moveStep(movedId, droppedId); + this._generateCode(Journey.fromObject(this.model.getData() as Partial)); + } + + onAddStep() { + const steps = (this.model.getData() as Journey).steps; + steps.push(new UnknownStep()); + this.model.setProperty('/steps', steps); + } + + private _moveStep(movedStepId: string, anchorStepId: string) { + let steps = (this.model.getData() as Journey).steps; + const movedIndex = steps.findIndex(s => s.id === movedStepId); + const anchorIndex = steps.findIndex(s => s.id === anchorStepId); + steps = Utils.moveInArray(steps, movedIndex, anchorIndex); + this.model.setProperty('/steps', steps); + } + private async _startAutomaticReplay(delay: number, rrSelectorUse: boolean) { BusyIndicator.show(0); const journeySteps = (this.model.getData() as Journey).steps as ReplayEnabledStep[]; for (let index = 0; index < journeySteps.length; index++) { - await this._delay(1000 * delay) + await Utils.delay(1000 * delay) const curStep = journeySteps[index]; try { this.model.setProperty(`/steps/${index}/state`, ValueState.Information); await ChromeExtensionService.getInstance().performAction(curStep, rrSelectorUse); this.model.setProperty(`/steps/${index}/state`, ValueState.Success); } catch (e) { - this.onDisconnect(); this.model.setProperty(`/steps/${index}/state`, ValueState.Error); MessageToast.show('An Error happened during testing', { duration: 3000 }); BusyIndicator.hide(); - this.onStopReplay(); + await this.onStopReplay(); return; } } BusyIndicator.hide(); - this.onStopReplay(); + await this.onStopReplay(); MessageToast.show('All tests executed successfully', { duration: 3000 }); } @@ -207,45 +236,58 @@ export default class JourneyPage extends BaseController { this.model.setProperty(`/steps/${index}/executable`, false); if (index === (journeySteps.length - 1)) { - this.onStopReplay(); + await this.onStopReplay(); MessageToast.show('All tests executed successfully', { duration: 3000 }); return; } + if (journeySteps[index + 1].actionType === StepType.UNKNOWN) { + this.model.setProperty(`/steps/${index + 1}/state`, ValueState.Error); + this.model.setProperty(`/steps/${index + 1}/executable`, false); + const unknownStepDialog = new Dialog({ + state: ValueState.Error, + type: DialogType.Message, + title: 'Unknown Step!', + content: new Text({ text: "The next step defines an unknown action, please redefine the Step and retest again!" }), + beginButton: new Button({ + type: ButtonType.Negative, + text: 'Ok', + press: () => { + unknownStepDialog.close(); + unknownStepDialog.destroy(); + BusyIndicator.hide(); + void this.onStopReplay(); + } + }) + }); + unknownStepDialog.open(); + return; + } + if (index + 1 < journeySteps.length) { this.model.setProperty(`/steps/${index + 1}/executable`, true); } } catch (e) { - this.onDisconnect(); this.model.setProperty(`/steps/${index}/state`, ValueState.Error); this.model.setProperty(`/steps/${index}/executable`, false); MessageToast.show('An Error happened during testing', { duration: 3000 }); BusyIndicator.hide(); - this.onStopReplay(); + await this.onStopReplay(); return; } } - private _delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - - toTitleEdit() { - this.model.setProperty('/titleVisible', false); - this.model.setProperty('/titleInputVisible', true); - } - - toTitleShow() { - this.model.setProperty('/titleVisible', true); - this.model.setProperty('/titleInputVisible', false); - } - navigateToStep(oEvent: Event) { const source: UI5Element = oEvent.getSource(); const bindingCtx = source.getBindingContext('journey'); const journeyId = ((bindingCtx.getModel() as JSONModel).getData() as Partial).id; const stepId = bindingCtx.getProperty("id") as string; - this.getRouter().navTo('step', { id: journeyId, stepId: stepId }); + const stepType = bindingCtx.getProperty("actionType") as StepType; + if (stepType === StepType.UNKNOWN) { + this.getRouter().navTo('step-define', { id: journeyId, stepId: stepId }); + } else { + this.getRouter().navTo('step', { id: journeyId, stepId: stepId }); + } } dateTimeFormatter(value: number) { @@ -261,15 +303,16 @@ export default class JourneyPage extends BaseController { const unsafed = (this.getModel('journeyControl') as JSONModel).getProperty('/unsafed') as boolean; if (unsafed) { - try { - await this._openUnsafedDialog() - await this._export(); - BusyIndicator.hide(); - } - catch (e) { - this.byId('saveBtn').focus(); - BusyIndicator.hide(); - } + this._openUnsafedDialog({ + success: async () => { + await this._export(); + BusyIndicator.hide(); + }, + error: () => { + this.byId('saveBtn').focus(); + BusyIndicator.hide(); + } + }) } else { await this._export(); BusyIndicator.hide(); diff --git a/webapp/controller/StepPage.controller.ts b/webapp/controller/StepPage.controller.ts index 0d1c1ca..f7da234 100644 --- a/webapp/controller/StepPage.controller.ts +++ b/webapp/controller/StepPage.controller.ts @@ -6,7 +6,7 @@ import Fragment from "sap/ui/core/Fragment"; import Menu from "sap/m/Menu"; import Button from "sap/m/Button"; import MenuItem from "sap/m/MenuItem"; -import { Step } from "../model/class/Step.class"; +import { RecordEvent, Step } from "../model/class/Step.class"; import { AppSettings } from "../service/SettingsStorage.service"; import { TestFrameworks } from "../model/enum/TestFrameworks"; import MessageToast from "sap/m/MessageToast"; @@ -14,6 +14,11 @@ import CodeGenerationService from "../service/CodeGeneration.service"; import { Route$MatchedEvent } from "sap/ui/core/routing/Route"; import BusyIndicator from "sap/ui/core/BusyIndicator"; import { ChromeExtensionService } from "../service/ChromeExtension.service"; +import Utils from "../model/class/Utils.class"; +import Dialog from "sap/m/Dialog"; +import Text from "sap/m/Text"; +import { DialogType } from "sap/m/library"; +import { ValueState } from "sap/ui/core/library"; /** * @namespace com.ui5.journeyrecorder.controller @@ -27,7 +32,6 @@ export default class StepPage extends BaseController { onInit() { this.model = new JSONModel({}); this.setModel(this.model, 'step'); - this.model.attachPropertyChange(() => { this._propertyChanged(); }); const settingsModel = (this.getOwnerComponent().getModel('settings') as JSONModel).getData() as AppSettings; this.setupModel = new JSONModel({ codeStyle: 'javascript', @@ -47,6 +51,9 @@ export default class StepPage extends BaseController { this.getRouter().getRoute("step").attachMatched((oEvent: Route$MatchedEvent) => { void this._loadStep(oEvent); }); + this.getRouter().getRoute("step-define").attachMatched((oEvent: Route$MatchedEvent) => { + void this._startStepDefinition(oEvent); + }); } async onSave() { @@ -54,32 +61,9 @@ export default class StepPage extends BaseController { const step = Step.fromObject((this.getModel("step") as JSONModel).getData() as Partial); journey.updateStep(step); await JourneyStorageService.getInstance().save(journey); - (this.getModel('stepSetup') as JSONModel).setProperty('/propertyChanged', false); MessageToast.show('Step saved!'); } - public async onValidate() { - const step = Step.fromObject((this.getModel("step") as JSONModel).getData() as Partial); - BusyIndicator.show(0); - this.setConnecting() - const url = step.actionLocation; - try { - await ChromeExtensionService.getInstance().reconnectToPage(url); - this.setConnected(); - await ChromeExtensionService.getInstance().performAction(step, false); - await ChromeExtensionService.getInstance().disconnect(); - this.setDisconnected(); - BusyIndicator.hide(); - MessageToast.show('Step is valid and executable', { duration: 2000 }); - } catch (e) { - await ChromeExtensionService.getInstance().disconnect(); - this.setDisconnected(); - BusyIndicator.hide(); - MessageToast.show('An Error happened during validation', { duration: 2000 }); - } - - } - async typeChange($event: Event) { const button: Button = $event.getSource(); if (!this.stepMenu) { @@ -141,18 +125,92 @@ export default class StepPage extends BaseController { MessageToast.show("Code copied"); } - private _propertyChanged() { - (this.getModel('stepSetup') as JSONModel).setProperty('/propertyChanged', true); + async onReselect() { + await this._startRedefinition(); } private async _loadStep(oEvent: Event) { const oArgs: { id: string; stepId: string } = oEvent.getParameter("arguments" as never); const step = await JourneyStorageService.getInstance().getStepById({ journeyId: oArgs.id, stepId: oArgs.stepId }); + if (!step) { + this.onNavBack(); + return; + } (this.getModel('stepSetup') as JSONModel).setProperty('/journeyId', oArgs.id); this.model.setData(step); this._generateStepCode(); } + private async _startStepDefinition(oEvent: Event) { + const oArgs: { id: string; stepId: string } = oEvent.getParameter("arguments" as never); + const step = await JourneyStorageService.getInstance().getStepById({ journeyId: oArgs.id, stepId: oArgs.stepId }); + if (!step) { + this.onNavBack(); + return; + } + (this.getModel('stepSetup') as JSONModel).setProperty('/journeyId', oArgs.id); + this.model.setData(step); + + await this._startRedefinition(); + } + + private async _startRedefinition() { + BusyIndicator.show(0); + // 1. get all steps + const jour = await JourneyStorageService.getInstance().getById((this.getModel('stepSetup') as JSONModel).getProperty('/journeyId') as string); + const steps = jour.steps; + const selfIndex = steps.findIndex((s: Step) => s.id === (this.model.getData() as Step).id); + const settings = ((this.getModel('settings') as JSONModel).getData() as AppSettings); + await this.onConnect(jour.startUrl); + BusyIndicator.show(0); + for (let index = 0; index < steps.length; index++) { + await Utils.delay(1000 * settings.replayDelay) + + if (index === selfIndex) { + //set "backend" to record mode + const selectElementDialog = new Dialog({ + state: ValueState.Information, + type: DialogType.Message, + title: 'Waiting for element select...', + content: new Text({ text: "Please select an element at your UI5 application to redefine this step!" }) + }); + + const onStepRerecord = (_1: string, _2: string, recordData: object) => { + const newStep = Step.recordEventToStep(recordData as RecordEvent); + jour.steps[selfIndex] = newStep; + this.model.setData(newStep); + ChromeExtensionService.getInstance().unregisterRecordingWebsocket(onStepRerecord, this); + ChromeExtensionService.getInstance().disableRecording().then(() => { }).catch(() => { }).finally(() => { + selectElementDialog.close(); + selectElementDialog.destroy(); + BusyIndicator.hide(); + this.onDisconnect().then(() => { }).catch(() => { }); + }) + + //assume the journey is call by reference it should work + }; + + ChromeExtensionService.getInstance().registerRecordingWebsocket(onStepRerecord, this); + await ChromeExtensionService.getInstance().enableRecording(); + selectElementDialog.open(); + break; + } else { + const curStep = steps[index]; + try { + await ChromeExtensionService.getInstance().performAction(curStep, settings.useRRSelector); + } catch (e) { + await this.onDisconnect(); + MessageToast.show('An Error happened during replay former steps', { duration: 3000 }); + BusyIndicator.hide(); + return; + } + } + } + // replay the steps before this step by connecting to the page. + // after the first click take the found element and action as new step setting + // store and setup the step accordingly + } + private _generateStepCode(): void { const step = this.model.getData() as Step; let code = ''; diff --git a/webapp/manifest.json b/webapp/manifest.json index aff3bd4..ad14c1f 100644 --- a/webapp/manifest.json +++ b/webapp/manifest.json @@ -142,6 +142,11 @@ "pattern": "journey/{id}/step/{stepId}", "name": "step", "target": "step" + }, + { + "pattern": "journey/{id}/define/{stepId}", + "name": "step-define", + "target": "step" } ], "targets": { diff --git a/webapp/model/class/Utils.class.ts b/webapp/model/class/Utils.class.ts index 9ba393b..657ab37 100644 --- a/webapp/model/class/Utils.class.ts +++ b/webapp/model/class/Utils.class.ts @@ -2,4 +2,25 @@ export default class Utils { public static replaceUnsupportedFileSigns(text: string, replacementSign: string) { return text.replace(/[\s/\\:*?"<>|-]+/gm, replacementSign); } + + public static moveInArray(array: Type[], oldIndex: number, newIndex: number): Type[] { + while (oldIndex < 0) { + oldIndex += array.length; + } + while (newIndex < 0) { + newIndex += array.length; + } + if (newIndex >= array.length) { + let k = newIndex - array.length + 1; + while (k--) { + array.push(undefined); + } + } + array.splice(newIndex, 0, array.splice(oldIndex, 1)[0]); + return array; + } + + public static delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } } \ No newline at end of file diff --git a/webapp/model/class/codeStrategies/opa5/OPA5CodeStrategy.class.ts b/webapp/model/class/codeStrategies/opa5/OPA5CodeStrategy.class.ts index 2aee0f2..4878b67 100644 --- a/webapp/model/class/codeStrategies/opa5/OPA5CodeStrategy.class.ts +++ b/webapp/model/class/codeStrategies/opa5/OPA5CodeStrategy.class.ts @@ -88,8 +88,8 @@ export default class OPA5CodeStrategy { public generatePagedStepCode( step: Step ): string { - if (!this._pages[step.viewInfos.relativeViewName]) { - this._pages[step.viewInfos.relativeViewName] = new ViewPageBuilder(step); + if (!this._pages[step.viewInfos?.relativeViewName]) { + this._pages[step.viewInfos?.relativeViewName] = new ViewPageBuilder(step); } switch (step.actionType) { case StepType.CLICK: diff --git a/webapp/model/class/codeStrategies/wdi5/Wdi5CodeStrategy.class.ts b/webapp/model/class/codeStrategies/wdi5/Wdi5CodeStrategy.class.ts index fe55389..9f13317 100644 --- a/webapp/model/class/codeStrategies/wdi5/Wdi5CodeStrategy.class.ts +++ b/webapp/model/class/codeStrategies/wdi5/Wdi5CodeStrategy.class.ts @@ -16,7 +16,7 @@ export default class Wdi5CodeStrategy { case StepType.INPUT: return Wdi5SingleStepStrategy.generateSingleInputStep(step as InputStep); default: - return 'Unknown StepType'; + return ''; } } @@ -26,6 +26,9 @@ export default class Wdi5CodeStrategy { // we treat each "page" as part of the entire journey and slice it up accordingly journey.steps.forEach((step: Step) => { + if (step.actionType === StepType.UNKNOWN) { + return; + } if (!pages[step.viewInfos.relativeViewName]) { pages[step.viewInfos.relativeViewName] = new Wdi5PageBuilder(step.viewInfos.relativeViewName, `#/${step.viewInfos.relativeViewName}`); } diff --git a/webapp/service/ChromeExtension.service.ts b/webapp/service/ChromeExtension.service.ts index b589d50..c80fba7 100644 --- a/webapp/service/ChromeExtension.service.ts +++ b/webapp/service/ChromeExtension.service.ts @@ -14,6 +14,8 @@ export interface Tab { export type Synchronizer = { success: (value: unknown) => void; error: (error: unknown) => void; + intervalID?: ReturnType; + retryCount: number; }; export interface RequestAnswer { @@ -42,6 +44,10 @@ interface InjectionResult { export class ChromeExtensionService { private static instance: ChromeExtensionService; + // for 30 sec timeout we try to reach the "backend" + // 10 times every 3 seconds + private static readonly INTERVAL_TIME = 3000; + private static readonly RETRY_COUNT = 10; private _currentTab: Tab | undefined; private _internalPort: chrome.runtime.Port | null = null; @@ -292,9 +298,18 @@ export class ChromeExtensionService { public sendSyncMessage(msg: Request): Promise { msg.message_id = ++this._messageId; return new Promise((resolve, reject) => { - const syncObject: Synchronizer = { success: resolve, error: reject }; + const syncObject: Synchronizer = { success: resolve, error: reject, retryCount: 0 }; + + syncObject.intervalID = setInterval(() => { + if (syncObject.retryCount >= ChromeExtensionService.RETRY_COUNT) { + clearInterval(syncObject.intervalID); + reject({ status: 503, message: "Service doesn't respond" }); + } + syncObject.retryCount++; + this._sendMessage(msg); + }, ChromeExtensionService.INTERVAL_TIME); + this._messageMap[this._messageId] = syncObject; - this._sendMessage(msg); }); } @@ -335,6 +350,7 @@ export class ChromeExtensionService { this.setCurrentTab(fittingTab[0]); await this.connectToCurrentTab(true); + await this.disableRecording(); await this.focusTab(fittingTab[0]); } @@ -361,6 +377,26 @@ export class ChromeExtensionService { } } + public async disableRecording() { + const rb = new RequestBuilder(); + rb.setMethod(RequestMethod.POST); + rb.setUrl('/disableRecordListener'); + const result = await this.sendSyncMessage(rb.build()) as { status: number }; + if (result.status !== 200) { + throw new Error(); + } + } + + public async enableRecording() { + const rb = new RequestBuilder(); + rb.setMethod(RequestMethod.POST); + rb.setUrl('/enableRecordListener'); + const result = await this.sendSyncMessage(rb.build()) as { status: number }; + if (result.status !== 200) { + throw new Error(); + } + } + private _waitTabToLoad(iTabNumber: number): Promise { const targetTabId: number = iTabNumber; return new Promise((resolve) => { @@ -462,7 +498,8 @@ export class ChromeExtensionService { messageId && this._messageMap[messageId] ) { - const { success, error } = this._messageMap[messageId]; + const { success, error, intervalID } = this._messageMap[messageId]; + clearInterval(intervalID); delete message.data.message_id; if (message.data.status >= 200 && message.data.status <= 299) { success(message.data); @@ -481,8 +518,9 @@ export class ChromeExtensionService { } private _onInstantMessage(msg: { message_id?: number, code: number, instantType: 'record-token', content?: unknown, data: unknown }): void { - if (msg?.message_id && this._messageMap[msg.message_id]) { + const synchronizer = this._messageMap[msg.message_id]; + clearInterval(synchronizer.intervalID); if (msg.code >= 200 && msg.code <= 299) { this._messageMap[msg.message_id].success(msg.data); } else { diff --git a/webapp/service/JourneyStorage.service.ts b/webapp/service/JourneyStorage.service.ts index 43f56ac..d3528e5 100644 --- a/webapp/service/JourneyStorage.service.ts +++ b/webapp/service/JourneyStorage.service.ts @@ -34,6 +34,10 @@ export default class JourneyStorageService { } public static async isChanged(journey: Journey): Promise { + // if the journey is unsuccessfull we can just return + if (!journey.id && !journey.startUrl) { + return false; + } const storageData: Record = await chrome.storage.local.get(journey.id) as Record; if (Object.keys(storageData).length === 0) { return true; diff --git a/webapp/view/JourneyPage.view.xml b/webapp/view/JourneyPage.view.xml index 03ea968..4bde777 100644 --- a/webapp/view/JourneyPage.view.xml +++ b/webapp/view/JourneyPage.view.xml @@ -6,6 +6,7 @@ xmlns:uxap="sap.uxap" xmlns:recorder="com.ui5.journeyrecorder.control" xmlns:mvc="sap.ui.core.mvc" + xmlns:dnd="sap.ui.core.dnd" core:require="{ formatter: 'com/ui5/journeyrecorder/model/formatter' }" @@ -102,9 +103,43 @@ + + + + + + +