Source: selector/methods/FallbackSelector.js

(function () {
    /**
     * Use unique attributes and classes as a fallback
     * - value uniqueness is checked individually
     * - origin in href and src urls is removed
     * @augments Help4.selector.methods.BaseSelector
     */
    Help4.selector.methods.FallbackSelector = class extends Help4.selector.methods.BaseSelector {
        /**
         * block elements using CSS selectors
         * @type {Object}
         */
        static ELEMENTS =  {block: ['[hidden], .hidden']}

        /**
         * don't use these attributes, they're language-based or otherwise not stable / unique
         * across different sessions, stable ids would have been used by other selectors
         * @type {Object}
         */
        static ATTRIBUTES = {
            block: [
                'accessible-name', 'alt', 'aria-label', 'placeholder', 'title', 'value',    // language-based
                'style',
                'active', 'collapsed', 'expanded', 'hidden', 'muted', 'selected', 'visible' // status-based

                // potentially usable values (if not used by previous selectors)
                //'aria-describedby', 'aria-labelledby', 'data-help-id', 'data-help-id2', 'data-testid', 'data-test-id', 'data-ui5-stable', 'id', // id-based
                //'href', 'src', 'name'
            ],
            blockWhitespaceValues: true  // block attributes with values containing whitespace, assuming they are language-based or styles/scripts
        }

        /**
         * don't use these classes, they're status-based or otherwise not stable / unique across different sessions
         * @type {Object}
         */
        static CLASSES = {
            block: ['active', 'collapsed', 'expanded', 'hidden', 'muted', 'selected', 'visible'],
            use: true
        }

        /**
         * @param {string} selector
         * @return {?HTMLElement}
         */
        static getElement(selector) {
            const {methods} = Help4.selector;

            let {nodeName = '', attributes = [], classes = []} = Help4.JSON.parse(selector);
            attributes = attributes.join('');
            classes = classes.join('');

            // check everything, then if something has changes, check individual attributes, then all classes
            const queries = [
                nodeName + attributes + classes,
                nodeName + attributes,
                nodeName + classes
            ];

            for (const query of queries) {
                const elements = methods.$(query);
                if (elements.length === 1) return elements[0];
            }

            return  null;
        }

        /**
         * @param {HTMLElement} element
         * @return {?Help4.selector.methods.Selector}
         */
        static getSelector(element) {
            const {methods, SELECTOR_QUALITY: {other: quality}} = Help4.selector;
            const {ELEMENTS, ATTRIBUTES, CLASSES} = this;

            if (ELEMENTS.block.some(s => element.matches(s))) return null;

            const nodeName = element.nodeName.toUpperCase();
            const blocked = new Set(ATTRIBUTES.block);
            blocked.add('class');

            const attributes = [];
            for (const {name, value} of element.attributes) {
                // reduce search time by removing already checked attributes from search set,
                // block white space values as they are likely language-specific text
                if (blocked.delete(name) || (ATTRIBUTES.blockWhitespaceValues && /\s/.test(value))) continue;

                let s;
                if (!value) {
                    s = `[${name}]`
                } else if (Help4.includes(['href', 'src'], name)) {
                    s = `[${name}$="${methods.$E(_removeOrigin.call(this, value))}"]`;
                } else {
                    s = `[${name}="${methods.$E(value)}"]`;
                }
                // check uniqueness and add to final selector,
                // prioritize values without numbers
                if (methods.$(nodeName + s).length === 1) {
                    /\d/.test(value) ? attributes.push(s) : attributes.unshift(s);
                }
            }

            const classes = [];
            if (CLASSES.use) {
                const blocked = new Set(CLASSES.block);

                let classSelector = '';
                for (const c of element.classList) {
                    if (blocked.delete(c) || /^help4-/.test(c)) continue;

                    const value = '.' + methods.$E(c);
                    classSelector += value;
                    /\d/.test(value) ? classes.push(value) : classes.unshift(value);
                }

                if (methods.$(nodeName + classSelector).length !== 1) {
                    classes.splice(0, classes.length);
                }
            }

            const value = attributes.length > 0 || classes.length > 0
                ? Help4.JSON.stringify({nodeName, attributes, classes})
                : null;

            return value ? {value, quality} : null;
        }
    }

    /**
     * @memberof Help4.selector.methods.FallbackSelector
     * @private
     * @param {string} value - a href or src value
     * @returns {string} - if value is a valid url, returning the value without its origin, otherwise returning the original value
     */
    function _removeOrigin(value) {
        try {
            let url = new URL(value);
            return url.href.replace(url.origin + '/', '');
        } catch {
            return value;
        }
    }
})();