Source: engine/ur/UrHarmonizationEngine.js

(function() {
    // Unified Rendering Help Harmonization Engine
    // Used for WDA and WebGUI: XRAY-2097

    /**
     * @namespace ur
     * @memberof Help4.engine
     */
    Help4.engine.ur = {};

    /**
     * Elements in postMessage API version 1
     * @typedef {Object} Help4.engine.ur.UrHarmonizationEngine.UrElement
     * @property {Object} id - key for backend query
     * @property {DOMRect} position
     * @property {string} ctrlId
     * @property {string} [labelText] - to use instead of backend label_text
     */

    /**
     * Hotspots instead of elements in postMessage API version 2
     * @typedef {Object} Help4.engine.ur.UrHarmonizationEngine.UrHotspot
     * @property {Object} backendHelpKey - backend query key
     * @property {string} [backendHelpCDSQuery] - backend query name
     * @property {DOMRect} [position] - not sent in UI5, using element from hotspotId
     * @property {string} hotspotId
     * @property {string} [labelText] - to use instead of backend label_text
     */

    /**
     * @typedef {Object} Help4.engine.ur.UrHarmonizationEngine.UrDataV1
     * @property {string} technology - GUI | WDA
     * @property {Help4.engine.ur.UrHarmonizationEngine.UrElement[]} elements
     */

    /***
     * @typedef {Object} Help4.engine.ur.UrHarmonizationEngine.UrDataV2
     * @property {Help4.engine.ur.UrHarmonizationEngine.UrHotspot[]} hotspots
     */

    /**
     * @typedef {Object} Help4.engine.ur.UrHarmonizationEngine.BackendQueryData
     * @property {string} _type - name of the query
     * @property {string} _urId - key / parameters of the query
     */

    /**
     * @typedef {Object} Help4.engine.ur.UrHarmonizationEngine.UrTextResponse
     * @property {string} [esid] - key name SAPGUI
     * @property {string} [dtel] - key name WDA
     * @property {string} label_text - tile title text
     * @property {string} short_text - tile text
     * @property {string} long_text - bubble text
     * @property {string} [res_langu] - result language of long_text; important in case the requested language wasn't found
     * @property {string} [poh] - Process On Help; dynamic help based on current context
     */

    /**
     * @typedef {Object} Help4.engine.ur.UrHarmonizationEngine.UrLinkResponse
     * @property {string} esid - key name SAPGUI (WDA sends url directly in postMessage, no query needed)
     * @property {string} help_link - url to be used as link target in What's This App? tile (WTA)
     */

    /**
     * @typedef {Object} Help4.engine.ur.UrHarmonizationEngine.QueryException
     * @property {string} @SAP__common.ExceptionCategory
     * @property {string} code
     * @property {Object} innererror
     * @property {string} message
     */

    /**
     * @typedef {Object} Help4.engine.ur.UrHarmonizationEngine.ErrorResponse
     * @property {Help4.engine.ur.UrHarmonizationEngine.QueryException} error
     */

    /**
     * @typedef {Object} Help4.engine.ur.UrHarmonizationEngine.UrMigrationRequest
     * @property {string} rule - selector used
     * @property {string} value - actual selector value
     */

    /**
     * @typedef {Object} Help4.engine.ur.UrHarmonizationEngine.UrMigrationResponse
     * @property {Help4.engine.ur.UrHarmonizationEngine.UrMigrationRequest} selector - {rule, value}, the migrated selector
     * @property {string} [hotspotId] - hotspot id of the element the selector maps to (updated with UpdateHotspots)
     * @property {DOMRect} [position] - alternative value, if the selector doesn't map to an existing help element
     * @property {string} [selectorString] - JSON.stringified selector value
     */

    /**
     * @typedef {Object} Help4.engine.ur.UrHarmonizationEngine.Params
     * @property {Help4.controller.Controller} controller
     * @property {Help4.EventBus} eventBus
     */

    /**
     * UR Harmonization Engine
     * @augments Help4.jscore.ControlBase
     * @property {Help4.controller.Controller} controller
     * @property {Help4.EventBus} eventBus
     * @property {boolean} _started
     * @property {?HTMLElement} _appFrame
     * @property {?string} _currentApp
     * @property {boolean} _connected
     * @property {?Generator<number>} _connections
     * @property {?number} _apiVersion
     *
     * updated from controller configuration each navigation
     * @property {boolean} _useURHotspots    - help and tour assignments
     * @property {boolean} _useABAPHelpTexts - What's This App? and help tiles with ABAP help texts
     * @property {boolean} _useUI5HelpTexts  - help tiles with ABAP help texts in UI5 apps
     *
     * @property {boolean} _running
     * @property {boolean} _runClosed
     * @property {boolean} _assignMode
     * @property {?string} _failedIconBase
     * @property {boolean} _mock
     * @property {?Object} _filter
     * @property {Map<string, Help4.widget.help.ProjectTile>} _tiles
     * @property {boolean} _ignoreLongText
     * @property {?number} _useFixedVersion
     * @property {Map<string, Object>} _urHotspots
     * @property {Object} _texts
     * @property {Object} _docu_texts - linked documentation in long_text
     * @property {Object} _tileStatus
     * @property {Help4.engine.ur.UrHarmonizationEngine.UrMigrationResponse[]} _migratedHotspots
     * @property {?string} _wtaUrl
     * @property {?Help4.widget.help.ProjectTile} _wtaTile
     * @property {Help4.observer.EventBusObserver} _eventBusObserver
     * @property {Help4.observer.MessageEventObserver} _messageObserver
     * @property {Help4.observer.MutationObserver} _mutationObserver
     * @property {?Function} _log - using UI5 logging {@link Help4.engine.ur.Connection.connect} */

    Help4.engine.ur.UrHarmonizationEngine = class extends Help4.jscore.ControlBase {
        /**
         * @constructor
         * @param {Help4.engine.ur.UrHarmonizationEngine.Params} params
         */
        constructor(params) {
            const {
                engine: {ur: {Connection}},
                jscore: {ControlBase: {TYPES: T}},
                observer: {MessageEventObserver, EventBusObserver, MutationObserver}
            } = Help4;

            super(params, {
                params: {
                    controller: {type: T.instance},
                    eventBus:   {type: T.instance}
                },
                statics: {
                    _started:          {init: false, destroy: false},
                    _appFrame:         {init: null, destroy: false},
                    _currentApp:       {init: null, destroy: false},
                    _connected:        {init: false, destroy: false},
                    _connections:      {init: null, destroy: false},
                    _apiVersion:       {init: null, destroy: false},
                    _useABAPHelpTexts: {init: false, destroy: false},
                    _useUI5HelpTexts:  {init: false, destroy: false},
                    _useURHotspots:    {init: false, destroy: false},
                    _running:          {init: false, destroy: false},
                    _runClosed:        {init: false, destroy: false},
                    _assignMode:       {init: false, destroy: false},  // no tiles during assign mode
                    _failedIconBase:   {init: null, destroy: false},
                    _mock:             {init: false, destroy: false},
                    _filter:           {init: null, destroy: false},
                    _tiles:            {init: new Map(), destroy: false},
                    _ignoreLongText:   {init: false, destroy: false},
                    _useFixedVersion:  {init: null, destroy: false},

                    _urHotspots:       {init: new Map(), destroy: false},
                    _texts:            {init: {}, destroy: false},
                    _docu_texts:       {init: {}, destroy: false},
                    _tileStatus:       {init: {}, destroy: false},

                    _migratedHotspots: {init: [], destroy: false},

                    _wtaUrl:           {init: null, destroy: false},
                    _wtaTile:          {init: null, destroy: false},

                    _eventBusObserver: {init: new EventBusObserver(event => _onEventBus.call(this, event))},
                    _messageObserver:  {init: new MessageEventObserver(event => Connection.onMessage(this, event))},
                    _mutationObserver: {init: new MutationObserver(record => _onMutation.call(this, record))},

                    _log:              {init: null, destroy: false}
                }
            });
        }

        static ICON_LIB_ID = 'SAPGUI-icons-h4';

        // XRAY-6098: FLP configures application frame containers in two different ways - URL query sap-post=true/false
        static APP_FRAME_SELECTOR_POST = '.sapUShellApplicationContainer:not(.sapUShellApplicationContainerIframeHidden) iframe';
        static APP_FRAME_SELECTOR = 'iframe.sapUShellApplicationContainer:not(.sapUShellApplicationContainerIframeHidden)';
        static MAX_DELAY = 10000;

        static MSG_TYPE = {
            v1: {
                // sending
                ping: 'sap.webassistant.services.ping',  // connection request
                start: 'sap.ls.services.startWebAssistantUpdates',
                stop: 'sap.ls.services.stopWebAssistantUpdates',
                // receiving
                update: 'sap.webassistant.services.updateHotspots',  // update hotspot data
                app: 'sap.webassistant.services.whatsThisApp'
            },
            v2: {  // version 2: https://github.wdf.sap.corp/pages/ur/docs/documents/sapcompanion/HelpHarmonization_v2_postmessage/
                // sending
                start: 'sap.companion.services.StartCompanion',
                stop: 'sap.companion.services.StopCompanion',

                startAssign: 'sap.companion.services.StartHotspotAssignment',
                stopAssign: 'sap.companion.services.StopHotspotAssignment',

                startTour: 'sap.companion.services.StartTour',
                stopTour: 'sap.companion.services.StopTour',
                scrollIntoView: 'sap.companion.services.ScrollIntoView',

                migrate: 'sap.companion.services.MigrateSelectors',
                // receiving
                update: 'sap.companion.services.UpdateHotspots',
                app: 'sap.companion.services.WhatsThisApp',

                activateElement: 'sap.companion.services.ActivateElement',
                hoverElement: 'sap.companion.services.HoverElement',
                leaveElement: 'sap.companion.services.LeaveElement',
                interactOnElement: 'sap.companion.services.InteractOnElement',
                hotkeyPress: 'sap.companion.services.HotkeyPress',

                fromSelectors: 'sap.companion.services.HotspotsFromSelectors'
            }
        };

        static QUERIES = {
            WDA: 'SDOC_HLP_CE_WD_DTEL',       // - Help texts for WDA
            GUI: 'SDOC_HLP_CE_WG_ESID',       // - Help texts for GUI
            GUI_AH: 'SDOC_HLP_CE_WG_ESID_AH', // - App Help / What's This App? for GUI
            UI5: 'SDOC_HLP_CE_UI5_ESID',      // - Help texts for UI5
            UI5_AH: 'SDOC_HLP_CE_UI5_AH',     // - App Help / What's This App? for UI5
            DOC: 'SDOC_HLP_CE_ANY_DOCU'       // - Linked documentation in long_text - XRAY-4837
        };

        /** starts the engine */
        start() {
            if (this._started) return;
            this._started = true;

            const {controller, eventBus, _mock, _eventBusObserver, _mutationObserver} = this;

            const {help: {
                useABAPHelpTexts,
                useURHotspots,
                useUI5HelpTexts
            }} = controller.getConfiguration();
            this._useABAPHelpTexts = useABAPHelpTexts;
            this._useUI5HelpTexts = useUI5HelpTexts;
            this._useURHotspots = useURHotspots;
            if (!_mock && !useURHotspots && !useABAPHelpTexts) return;

            const {TYPES: T} = eventBus;
            _eventBusObserver
            .observe(eventBus, {type: [
                T.controllerOpen,
                T.controllerClose,
                T.controllerAfterNavigate,
                T.hotspotAssignStart,
                T.hotspotAssignPostpone,
                T.hotspotAssignResume,
                T.hotspotAssignStop,
                T.tourOpen,
                T.tourClose
            ]});

            const viewport = document.getElementById('viewPortContainer');
            viewport && _mutationObserver.observe(viewport, {childList: true});

            Help4.engine.ur.IconTemplate.prepare(this);
            this.run();
        }

        /** stops the engine */
        stop() {
            if (!this._started) return;
            this._started = false;

            this.run(false);
            _clean.call(this);

            const {_eventBusObserver, _mutationObserver, _messageObserver} = this;
            [_eventBusObserver, _mutationObserver, _messageObserver]
            .forEach(observer => observer.disconnect?.());

            Help4.engine.ur.Mock.stop(this);
        }

        /** start mock */
        startMock() {
            Help4.engine.ur.Mock.start(this);
        }

        /** update mock element positions or send specific guided tour message */
        updateMock({messageType, hotspotId, key, shift, ctrl, alt}) {
            if (messageType && (hotspotId || key)) {
                Help4.engine.ur.Mock.sendTourMessage(this, messageType, {hotspotId, key, shift, ctrl, alt});
            } else {
                Help4.engine.ur.Mock.update(this);
            }
        }

        /** @param {?Object} filter */
        setFilter(filter) {
            Help4.engine.ur.Tile.setFilter(this, filter);
        }

        /**
         * @param {?(string|number)} [id = null] - id or index
         * @returns {?(Help4.widget.help.ProjectTile|Help4.widget.help.ProjectTile[])}
         */
        getTile(id = null) {
            return Help4.engine.ur.Tile.getTile(this, id);
        }

        /** @returns {boolean} */
        hasTiles() {
            return Help4.engine.ur.Tile.hasTiles(this);
        }

        /**
         * @param {{point: Help4.control2.PositionXY}} param
         * @returns {Promise<Object>}
         */
        pointToSelector({point}) {
            return Help4.engine.ur.Selector.pointToSelector(this, {point});
        }

        /**
         * @param {{selector: Object, useOffset: boolean}} param
         * @returns {Promise<Object>}
         */
        selectorToInfo({selector, useOffset}) {
            return Help4.engine.ur.Selector.selectorToInfo(this, {selector, useOffset});
        }

        /**
         * @param {string} hotspotId
         */
        scrollIntoView(hotspotId) {
            Help4.engine.ur.Tour.scrollIntoView(this, hotspotId);
        }

        /**
         * Collect non-UR selectors to migrate to UR hotspots / positions
         * @param {Help4.engine.ur.UrHarmonizationEngine.UrMigrationRequest} selector
         * @returns {boolean} - true if selector was added, false if it already existed
         */
        addSelectorToMigrate(selector) {
            return Help4.engine.ur.Migration.addSelector(this, selector);
        }

        /**
         * Get hotspot values for migrated selector
         * @param {Help4.engine.ur.UrHarmonizationEngine.UrMigrationRequest} selector
         * @returns {Help4.engine.ur.UrHarmonizationEngine.UrMigrationResponse} - contains {string} hotspotId or {string} position
         */
        getMigratedHotspot(selector) {
            return Help4.engine.ur.Migration.selectorToUrHotspot(this, selector);
        }

        /**
         * Run
         * general entry point for each state
         * 1a. specific state change (boolean argument) or
         * 1b. determine if we are in an accepted fiori app (HTMLGUI | WDA | UI5)
         * 2. find the correct frame and connect or try running again
         * @private
         * @param {false|undefined} [run = null]
         */
        run(run = undefined) {
            const {controller, _running, _runClosed, _useABAPHelpTexts, _useUI5HelpTexts, _useURHotspots, _started} = this;
            if (!_started) return;

            const {Connection} = Help4.engine.ur;
            if (typeof run !== 'boolean') {
                this._currentApp = Connection.getUI5AppType(this);
                const {isOpen = false} = this._currentApp && controller?.getConfiguration() || {};

                run = (_useABAPHelpTexts || _useURHotspots) && (isOpen || _runClosed);
            }

            if (!run) {
                if (_running) _halt.call(this);
                return;
            }

            // XRAY-4251 assumptions:
            // - in mock case we only have one mock frame with fixed id
            // - in live case we can have multiple frames, but only one is visible at a time, others are hidden
            const frame = _findAppFrame.call(this);
            if (_running) {
                // XRAY-5437: navigation from one app directly into another while CMP is running
                if (frame !== this._appFrame) {
                    _halt.call(this);
                    this.run();
                }
            } else {
                if (frame) {
                    // try to connect to the frame
                    this._running = true;
                    this._appFrame = frame;
                    this._messageObserver.observe();
                    Connection.connect(this);
                } else {
                    // retry later when a frame is found
                    _halt.call(this);
                    setTimeout(() => this.run(), 500);
                }
            }
        }

        /**
         * support use of UR hotspots even when the panel is closed,
         * e.g. for instant hotspots or callouts or
         * for {@link Help4.API.record}
         * @param {boolean} run - toggle
         * @returns {boolean} - previous state
         */
        runClosed(run = true) {
            const {_runClosed, _running, _started} = this;
            if (!_started) return false;

            const previous = _runClosed;
            if (run) {
                this._runClosed = true;
                this.run();
            } else if (_running) {
                _halt.call(this);
            }

            return previous;
        }

        /**
         * check if appropriate hotspots are available
         * a) check for any hotspots passing no selector in @link{Help4.service.recording.IFrameService.pointToSelector} or
         *  if the selector is a UrHarmonizationSelector in @link{Help4.service.recording.IFrameService.selectorToInfo}
         * b) check if the specific non-UR selector is available in the migrated hotspots in @link{Help4.service.recording.IFrameService.selectorToInfo}
         * @param {string} [rule = 'UrHarmonizationSelector']
         * @param {string} [value]
         * @returns {boolean}
         */
        hasHotspots({rule = 'UrHarmonizationSelector', value} = {}) {
            const {_migratedHotspots, _started, _urHotspots} = this;
            if (!_started) return false;

            return rule === 'UrHarmonizationSelector'
                ? _urHotspots.size > 0
                : _migratedHotspots.some(({selector: {rule: r, value: v}}) => rule === r && value === v);
        }

        /** @returns {?HTMLElement} */
        getAppFrame() {
            return this._started ? this._appFrame : null;
        }

        /** @param {boolean} ignore */
        ignoreLongText(ignore) {
            this._ignoreLongText = ignore;
            this._texts = {};  // reset
            this._docu_texts = {};  // reset
        }

        /** @param {number} version */
        useFixedVersion(version) {
            this._useFixedVersion = version;
        }

        /**
         * Status for backend tile playback in WDA and HTMLGUI apps;
         * in UI5 apps we are using the DOM to check the tile status (same-origin access)
         * @param {string} tileId
         * @returns {*|null}
         */
        getTileStatus(tileId) {
            const appFrame = this.getAppFrame();
            if (!appFrame) return null;

            const {
                engine: {ur: {Tile}},
                selector: {Selector},
                service: {recording: {SameWindowPlayback}}
            } = Help4;

            const tile = Tile.getTile(this, tileId);
            const selector = tile && Selector.base64ToUtf8(tile.hotspotAnchor);
            if (!selector) return null;

            // XRAY-4854: update from previous status, only to check if appFrame is still topmost
            const {_tileStatus} = this;
            return _tileStatus[tileId] = /** @type {Help4.data.HotspotStatusData} */ SameWindowPlayback.urSelectorToInfo({
                appFrame,
                previousStatus: _tileStatus[tileId],
                selector,
                window: {document: appFrame.ownerDocument}
            });
        }

        /** return DOCU_LINK texts
         * @param {string} docuLink
         * @returns {?{content:string, res_langu:string}}
         */
        getDocuLinkContent(docuLink) {
            // get content from DOCU_LINK

            const texts = this._docu_texts[docuLink] || {};
            if (!texts.content) {
                this._log?.('CMP: DOCU_LINK not found', undefined, ()=>({docuLink: docuLink}));
                return null;
            }

            return texts;
        }

        /**
         * @private
         * @returns {Object}
         */
        _getDefaults() {
            const {DEFAULTS} = Help4;
            const def = {};
            for (const key of Object.keys(DEFAULTS)) {
                def[key] = DEFAULTS[key].d;
            }
            return def;
        }

        /**
         * @private
         * @param {{structural: boolean, moved: boolean}}
         * @returns {Promise<void>}
         */
        async _sendUpdateNotification({structural, moved, clean}) {
            // new help texts could mean that new tiles have to be shown
            // without new texts the current tiles might need to be updated
            const {controller} = this;
            const {isEditMode, mode, CMP4} = controller.getConfiguration();
            if (isEditMode) return;

            const {
                controller: {MODES: {help}},
                CAROUSEL_MODES: {help: CM_help},
                widget: {help: {project: {UR}}}
            } = Help4;

            if (CMP4) {
                const tiles = this.getTile() || [];
                await UR.setData(tiles, structural || clean, moved);
                return;
            }

            // CMP3
            if (structural && mode === help) {  // only when a tile update is needed and only in help playback mode
                const handler = controller.getHandler();
                // help tab can be hidden when no standard content is available; enforce recalculation so that it is shown
                // help tab could be wrongly displayed in case UR help is gone; enforce recalculation to hide it
                handler?.setCarouselTab({force: true, reason: 'urHarmonization'});

                // enforce a complete redraw of the help tiles to add ours
                const isHelpTab = handler?.getCarouselTab() === CM_help;
                isHelpTab && handler?.setTiles();
            }
        }
    }


    /**
     * @memberof Help4.engine.ur.UrHarmonizationEngine#
     * @returns {?HTMLElement}
     * @private
     */
    function _findAppFrame(target = document) {
        const {
            UrHarmonizationEngine: {
                APP_FRAME_SELECTOR_POST,
                APP_FRAME_SELECTOR
            },
            Connection: {APP_TYPE},
            Mock: {MOCK_FRAME_ID}
        } = Help4.engine.ur;
        const {
            _currentApp,
            _useUI5HelpTexts
        } = this;

        let frame = null;
        switch(_currentApp) {
            case APP_TYPE.MOCK:
                frame = target.getElementById(MOCK_FRAME_ID);
                break;
            case APP_TYPE.UI5:
                if (!_useUI5HelpTexts) {
                    this._log?.('CMP: Help Texts disabled for UI5', undefined, () => ({useUI5HelpTexts: _useUI5HelpTexts}));
                    break;
                }

                // treat the top window as content window for UI5 apps
                const shell = Help4.getShell();
                const service = shell.getUI5Service('AppLifeCycle');
                const {componentInstance} = service.getCurrentApplication() || {};
                const app = componentInstance?.oContainer?.getDomRef().closest(':not(.sapUShellApplicationContainer)>.sapUShellApplicationContainer');
                frame = /** @type{?HTMLElement} */ app && {id: app.id, contentWindow: window};
                break;
            default:
                frame = target.querySelector(APP_FRAME_SELECTOR_POST) || target.querySelector(APP_FRAME_SELECTOR);
        }
        return frame;
    }

    /**
     * @memberof Help4.engine.ur.UrHarmonizationEngine#
     * @private
     */
    function _halt() {
        Help4.engine.ur.Connection.disconnect(this);
        this._running = false;
        this._appFrame = null;
        this._currentApp = null;
        _cleanData.call(this);
    }

    /**
     * @memberof Help4.engine.ur.UrHarmonizationEngine#
     * @private
     */
    function _cleanData() {
        this._urHotspots.clear();
        this._tiles.clear();
        this._tileStatus = {};

        this._migratedHotspots.length = 0;

        this._wtaUrl = null;
        this._wtaTile = null;
        this._sendUpdateNotification({clean: true});
    }

    /**
     * @memberof Help4.engine.ur.UrHarmonizationEngine#
     * @private
     */
    function _clean() {
        _cleanData.call(this);
        this._texts = {};
        this._docu_texts = {};
        this._connections = null;
        this._apiVersion = null;
    }

    /**
     * @memberof Help4.engine.ur.UrHarmonizationEngine#
     * @private
     * @param {{type: string}} event
     */
    function _onEventBus({type}) {
        const {IconTemplate, Connection} = Help4.engine.ur;
        const {
            _currentApp,
            controller,
            constructor: {MSG_TYPE: {v2: M}},
            eventBus: {TYPES: T}
        } = this;

        // in UI5 only some events are supported
        if (_currentApp === Connection.APP_TYPE.UI5 && ![T.controllerOpen, T.controllerClose, T.controllerAfterNavigate].includes(type)) {
            return;
        }

        switch (type) {
            case T.controllerOpen:
                IconTemplate.prepare(this);
                // intentional fallthrough, no break
            case T.controllerClose:
                this.run();
                break;

            case T.controllerAfterNavigate:
                // update "use" settings from controller configuration
                const {useABAPHelpTexts, useUI5HelpTexts, useURHotspots} = controller.getConfiguration();
                this._useABAPHelpTexts = useABAPHelpTexts;
                this._useUI5HelpTexts = useUI5HelpTexts;
                this._useURHotspots = useURHotspots;

                this.run();
                _clean.call(this);

                if (this._mock && this._connected) {
                    this._connected = false;
                    Connection.connect(this);
                }
                break;

            // XRAY-5206
            case T.hotspotAssignStart:
            case T.hotspotAssignResume:
                /** Send a message to the app frame that assigning has started.
                 * The app should then respond with UpdateHotspot messages. */
                this._log?.('CMP: sending message', '⏺️' + M.startAssign);
                Connection.messageAppFrame(this, M.startAssign, true);
                break;

            case T.hotspotAssignStop:
            case T.hotspotAssignPostpone:
                /** Send a message to the app frame that assigning has stopped. */
                this._log?.('CMP: sending message', '⏹️' + M.stopAssign);
                Connection.messageAppFrame(this, M.stopAssign, false);
                break;

            // XRAY-5250
            case T.tourOpen:
                this._log?.('CMP: sending message', '▶️' + M.startTour);
                Connection.messageAppFrame(this, M.startTour);
                break;
            case T.tourClose:
                this._log?.('CMP: sending message', '⏹️' + M.stopTour);
                Connection.messageAppFrame(this, M.stopTour);
                break;
        }
    }

    /**
     * Handle mutations of viewport element
     * update running status if the frame has changed
     * @private
     * @param {MutationRecord} record
     */
    function _onMutation(record) {
        const frame = _findAppFrame.call(this, record[0]?.target);
        if (frame && frame.id !== this._appFrame?.id) this.run();
    }
})();