utils_baseActions.js

const { uploadPic } = require('./captchaSolve');
const { createMyLogger } = require('./myLogger');
const log = createMyLogger();

/*****所有appium自动化都可以用上的动作*****/

/**
 * wdio基本操作封装类
 * @class
 */
class MyAction {
    constructor(client) {
        /**
         * wdio 创建的实例
         * @param {WebdriverIO.Browser} client
         */
        this.client = client
    }

    /**
     * UiSelector不同形式的选择器,可以组合。
     * 示例['id-class-text','id:aaabbb','android.view.view','文本']
     * @param {string} type
     * @param {Array<string>} opt
     */
    uis(type, opt) {
        let baseStr = 'android=new UiSelector()'
        let typeArr = type.split('-')
        for (let i = 0; i < typeArr.length; i++) {
            switch (typeArr[i]) {
                case 'class':
                    baseStr = baseStr + `.className("${opt[i]}")`
                    break;
                case 'id':
                    baseStr = baseStr + `.resourceId("${opt[i]}")`
                    break;
                case 'text':
                    baseStr = baseStr + `.text("${opt[i]}")`
                    break;
                case 'desc':
                    baseStr = baseStr + `.description("${opt[i]}")`
                    break;
                case 'findText':
                    baseStr = baseStr + `.textContains("${opt[i]}")`
                    break;
                case 'findDesc':
                    baseStr = baseStr + `.descriptionContains("${opt[i]}")`
                    break;
                case 'textReg':
                    baseStr = baseStr + `.textMatches("${opt[i]}")`
                    break;
                case 'descReg':
                    baseStr = baseStr + `.descriptionMatches("${opt[i]}")`
                    break;
                case 'child':
                    baseStr = baseStr + `.instance(${opt[i]})`
                    break;
                case 'index':
                    baseStr = baseStr + `.index(${opt[i]})`
                    break;
                default:
                    throw new Error(`Uis error, unknow type : ${typeArr[i]}`)
            }
        }
        return baseStr
    }

    /**
     * 处理传入的selector。数组通过uis转换为字符串。
     * @param {string | any[]} str
     */
    handleSelector(str) {
        if (typeof str !== 'string') {
            let actionArr = []
            for (let i = 1; i < str.length; i++) {
                actionArr.push(str[i]);
            }
            return this.uis(str[0], actionArr)
        }
        else return str
    }

    /**
     * 等待元素出现,返回该元素
     * 示例  let element = await act.waitForElement(['id-class', 'cn.some:id', 'android.view.view']);
     * @param {string | string[]} selector
     */
    async waitForElement(selector, time = 30000, parent = null) {
        selector = this.handleSelector(selector);
        let target
        if (!parent) {
            target = await this.client.$(selector);
        }
        else {
            target = await parent.$(selector);
        }
        await target.waitForExist({ timeout: time });
        return target
    }

    /**
     * 等待元素出现并点击
     * 示例 await act.clickElement(['id-text', 'cn.some:id', '按钮']);
     * @param {string | string[]} selector
     */
    async clickElement(selector, time = 30000, parent = null) {
        selector = this.handleSelector(selector);
        let target
        if (!parent) {
            target = await this.client.$(selector);
        }
        else {
            target = await parent.$(selector);
        }
        await target.waitForExist({ timeout: time });
        await target.touchAction('tap');
    }


    /**
     * 等待元素出现,返回该元素
     * 示例  let element = await act.waitForElement(['id-class', 'cn.some:id', 'android.view.view']);
     * @param {string | string[]} selector
     * @param {string} type
     */
    async getElementAttribute(selector, type, time = 30000, parent = null) {
        selector = this.handleSelector(selector);
        let target
        if (!parent) {
            target = await this.client.$(selector);
        }
        else {
            target = await parent.$(selector);
        }
        await target.waitForExist({ timeout: time });
        if (type === 'text') {
            return await target.getText();
        }
        else return await target.getAttribute(type);
    }

    /**
     * 等待input出现并输入文字,是否清除原文字可选,不会触发键盘弹出(某些权限响应)
     * 示例 await act.typeInput(["id-text", "cn.some:name", "请填写姓名"],'myName');
     * @param {string | string[]} selector
     * @param {any} value
     */
    async typeInput(selector, value, clear = false, time = 30000) {
        selector = this.handleSelector(selector);

        let target = await this.client.$(selector);
        await target.waitForExist({ timeout: time });
        clear && await target.clearValue();
        await target.addValue(value);
    }

    /**
     * 获取多个内容,可以输入限定区域已确保都能点击到,限制区域x1,x2 ,y1,y2
     * 示例  let ele = await act.getElements(['id', 'com.ss.android.ugc.aweme.lite:id/avatar'],[0,1080,600,1920]);
     * @param {string | string[]} selector
     * @param {number[]} limitArea
     */
    async getElements(selector, limitArea, parent = null, time = 15000) {
        selector = this.handleSelector(selector);
        try {
            await this.waitForElement(selector, time);
        }
        catch {
            return []
        }
        let targets = []
        if (!parent) {
            targets = await this.client.$$(selector);
        }
        else {
            targets = await parent.$$(selector);
        }
        if (limitArea) {
            let result = []
            for (let i = 0; i < targets.length; i++) {
                let box = await this.getElementBox(targets[i])
                let pass = (box[0] > limitArea[0]) && (box[0] < limitArea[1]) && (box[1] > limitArea[2]) && (box[1] < limitArea[3])
                if (pass) result.push(targets[i])
                // else console.log('不在区域内', box, limitArea);
            }
            return result
        }
        else return targets
    }

    /**
     * 通过wdio的属性转换成元素坐标。返货盒子外圈坐标[x1,y1,x2,y2]或者中心点
     * 示例 let box= await act.getElementBox(["id-text", "cn.some:name", "请填写姓名"], true)
     * @param {string | string[]} selector
     * @param {boolean | undefined} [outer]
     */
    async getElementBox(selector, outer) {
        let boxBounds
        if (Array.isArray(selector) || typeof selector === 'string') {
            let boxElement = await this.waitForElement(selector);
            boxBounds = await boxElement.getAttribute('bounds');
        }
        else {
            boxBounds = await selector.getAttribute('bounds');
        }
        let regex = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/;
        let matches = boxBounds.match(regex);
        if (matches) {
            let box = [parseInt(matches[1]), parseInt(matches[2]), parseInt(matches[3]), parseInt(matches[4])];
            if (outer) return box
            else return [(box[0] + box[2]) / 2, (box[1] + box[3]) / 2]
        } else {
            return []
        }
    }

    /**
     * 滑动屏幕,可以分段执行。durations要么只传一个,要么和end坐标数量相同 
     * 示例 await act.screenScroll([startX,startY,[endX1,endY1],[endX2,endY2],[endX3,endY3]...] , [800,700,600]) 
     * @param {string | any[]} axies
     */
    async screenScroll(axies, durations = [700]) {
        let action = [
            {
                "type": "pointer",
                "id": "finger1",
                "parameters": {
                    "pointerType": "touch"
                },
                "actions": [
                    {
                        "type": "pointerMove",
                        "duration": 0,
                        "x": axies[0],
                        "y": axies[1]
                    },
                    {
                        "type": "pointerDown",
                        "button": 0
                    },
                ]
            }
        ]
        for (let i = 2; i < axies.length; i++) {
            action[0].actions.push({
                "type": "pointerMove",
                "duration": durations.length > 1 ? durations[i - 2] : durations[0],
                "origin": "viewport",
                "x": axies[i][0],
                "y": axies[i][1],
            })
        }
        action[0].actions.push({
            "type": "pointerUp",
            "button": 0
        })
        await this.client.performActions(action);
        await this.client.releaseActions();
    }

    /**
     * 滑动元素,从元素底部划到元素顶部,reverse为是否反向,offset正值为向内收缩范围
     * 示例 await act.elementScroll(["id-text", "cn.some:name", "请填写姓名"], true, 1000, [20,20], 'horizontal') 
     * @param {string | string[]} selector
     */
    async elementScroll(selector, reverse = true, durations = 700, offset = [10, 10], direction = 'vertical') {
        let box = await this.getElementBox(selector, true);
        if (direction === 'horizontal') {
            if (reverse) {//右~左
                await this.screenScroll([box[2] - offset[0], (box[3] + box[1]) / 2, [box[0] + offset[1], (box[3] + box[1]) / 2]], [durations]);
            }
            else {//左~右
                await this.screenScroll([box[0] + offset[0], (box[3] + box[1]) / 2, [box[2] + offset[1], (box[3] + box[1]) / 2]], [durations]);
            }
        }
        else {
            if (reverse) {//下~上
                await this.screenScroll([(box[2] + box[0]) / 2, box[3] - offset[0], [(box[2] + box[0]) / 2, box[1] + offset[1]]], [durations]);
            }
            else {//上~下
                await this.screenScroll([(box[2] + box[0]) / 2, box[1] + offset[0], [(box[2] + box[0]) / 2, box[3] - offset[1]]], [durations]);
            }

        }
    }

    /**
     * 获取某个元素的子元素,可选isPlural:第一个/获取所有, 可选超时, 可选useRule自定义规则
     * 示例 await act.getParentChild(["id", 'id:app'], ["class", "android.view.View"], true, 20000, {func: findBtn, param: '123456'})
     * @param {string | string[]} selector
     * @param {string} child
     */
    async getParentChild(selector, child, isPlural = false, time = 30000, useRule = null) {
        selector = this.handleSelector(selector);
        child = this.handleSelector(child);

        let parent = await this.client.$(selector);
        await parent.waitForExist({ timeout: time });

        if (useRule) {
            return useRule.func(parent, child, useRule.param);
        }
        else if (!isPlural) {
            let childElement = await parent.$(child);
            return childElement
        }
        else {
            let childElements = await parent.$$(child);
            return childElements
        }
    }

    /**
     * 获取某个元素的子元素,可选isPlural:第一个/获取所有, 可选超时, 可选useRule自定义规则
     * 示例 await act.getBrother(['desc', '我的订单'], 'className("android.view.View").index(1)');
     * @param {string | string[]} selector
     * @param {any} target
     */
    async getBrother(selector, target, time = 30000) {
        let baseStr = this.handleSelector(selector);
        let finalStr = `${baseStr}.fromParent(${target})`

        let ele = await this.client.$(finalStr);
        await ele.waitForExist({ timeout: time });
        return ele
    }

    /**
     * 通过属性查找元素。
     * 示例 let element= await act.searchWithAttribute(['class', 'android.widget.EditText'], 'hint', '请输入姓名')
     * @param {string | string[]} selector
     * @param {any} attribute
     * @param {any} value
     */
    async searchWithAttribute(selector, attribute, value) {
        selector = this.handleSelector(selector);
        let result = ''
        for (let i = 0; i < 3; i++) {
            result = await this.client.$$(selector)
                .filter(async (el) => {
                    let ee = await el.getAttribute(attribute);
                    return ee === value
                });

            if (result.length === 1) return result[0]
            await sleep(0.5);
        }
        throw new Error(`Get ${value} error: ${result.length} `)
    }
}

/**
 * 延时等待函数,等待基础时间加上随机时间。
 * @async
 * @param {number} baseTimeSec - 基础等待时间(秒)。
 * @param {number} [randomTimeSec=0] - 额外的随机等待时间(秒)。
 * @returns {Promise<number>} - 返回实际等待的总时间(秒)。
 * @description
 * 延时等待,基础时间+随机时间 await sleep(1.2, 4);  1.2s~5.2s
 */
const sleep = async (baseTimeSec, randomTimeSec) => {
    let time = 0
    if (randomTimeSec) time = Math.floor(Math.random() * (randomTimeSec * 1000) + (baseTimeSec * 1000))
    else time = baseTimeSec * 1000
    await new Promise(resolve => setTimeout(resolve, time));
    return time / 1000
}

/**
 * 搜集简化的报错信息上传,主要用来输出选择器的报错。
 * @param {string} tag - 标签,用于标识错误来源。
 * @param {string} str - 原始错误信息。
 * @returns {string} - 简化后的错误信息。
 * @description
 * 搜集简化的报错信息上传,主要用来输出选择器的报错
 */
const getError = (tag, str) => {
    console.log(`${tag}报错==>>`, str);
    if (typeof str !== 'string') return 'Error => message is null'

    if (str.includes('still not existing')) {
        let regex = /element\s(.+?)\sstill not existing after/;
        let match = str.match(regex);

        if (match && match.length > 1) {
            let extractedText = match[1];
            let errorStr = extractedText.replace('("android=new UiSelector().', '').replace('resourceId', '').replace('className', '').replace('text', '')
            return `${tag} Error =>${errorStr}`
        } else {
            return `${tag} Error => unknown error`
        }
    }
    else return `${tag} Error => unknown error`
}

/**
 * 截取元素或整个屏幕的截图,上传成功会返回生成的文件名。
 * @async
 * @param {Object} act - 操作对象。
 * @param {Object} [selector=null] - 要截图的元素选择器。
 * @param {string} fileName - 文件名。
 * @param {string} [suffix='_default'] - 文件名后缀。
 * @returns {Promise<boolean|string>} - 返回上传结果,成功返回文件名,失败返回false。
 * @description
 * 截取元素或整个屏幕的截图,上传成功会返回生成的文件名
 */
const screenShot = async (act, selector, fileName, suffix = '_default') => {
    try {
        if (selector) {
            var flag = false, times = 0, picID = ''
            if (selector?.elementId) picID = selector.elementId
            else {
                while (!flag && times < 10) {
                    await sleep(1);
                    let element = await act.waitForElement(selector);
                    if (element?.elementId) {
                        picID = element.elementId
                        flag = true
                    }
                    else {
                        times++
                    }
                }
                if (!picID) return false
            }
            const elementScreenshot = await act.client.takeElementScreenshot(picID);
            let res = await uploadPic(elementScreenshot, fileName + suffix);
            console.log('图片上传完毕', res);
            return res
        }
        else {
            const elementScreenshot = await act.client.takeScreenshot();
            let res = await uploadPic(elementScreenshot, fileName + suffix);
            console.log('图片上传完毕', res);
            return res
        }
    }
    catch (err) {
        console.log(err);
        return false
    }
}

/**
 * 调用安卓返回键,直到某页。
 * @async
 * @param {Object} act - 操作对象。
 * @param {Object} target - 目标元素选择器,用于判断是否到达目标页面。
 * @param {number} [limit=10] - 最大返回次数。
 * @returns {Promise<boolean>} - 是否成功到达目标页面。
 * @description
 * 调用安卓返回键,直到某页
 */
const getBackAndFind = async (act, target, limit = 10) => {
    //通常按返回的间隔大于5秒可以确保不会退出应用
    let over = false, times = 0;
    while (!over && times < limit) {
        times++;
        try {
            await act.waitForElement(target, 4000);
            over = true;
        }
        catch {
            await act.client.pressKeyCode(4);
        }
    }
    return over;
}

/**
 * 捕获轻提示。
 * @async
 * @param {Object} act - 操作对象。
 * @param {string} [checkString=null] - 要检查的字符串。
 * @returns {Promise<string>} - 捕获到的提示信息。
 * @description
 * 捕获轻提示
 */
const getToast = async (act, checkString) => {
    let result = ''
    try {
        for (let j = 0; j < 5; j++) {
            try {
                let toast = await act.client.$('//hierarchy/android.widget.Toast');
                await toast.waitForExist({ timeout: 800 });
                let toastStr = await toast.getText();
                log.debug(`获取消息: ${toastStr}`)
                result = toastStr
                break
            }
            catch {
                // log.debug('尝试获取toast', j)
            }
        }
    }
    catch (err) {
        console.log('toast error', err);
    }
    if (checkString) {
        log.debug(`消息比对: ${checkString === result}`)
        if (checkString === result) return result
        else return ''
    }
    else return result
}


module.exports = {
    log: log,
    MyAction: MyAction,
    sleep: sleep,
    getToast: getToast,
    getError: getError,
    screenShot: screenShot,
    getBackAndFind: getBackAndFind,
}