51Testing软件测试论坛

 找回密码
 (注-册)加入51Testing

QQ登录

只需一步,快速开始

微信登录,快人一步

手机号码,快捷登录

查看: 625|回复: 0
打印 上一主题 下一主题

[转贴] UI 自动化的页面对象管理工具之实现思路

[复制链接]
  • TA的每日心情
    擦汗
    前天 08:59
  • 签到天数: 1021 天

    连续签到: 2 天

    [LV.10]测试总司令

    跳转到指定楼层
    1#
    发表于 2022-7-12 11:13:05 | 只看该作者 回帖奖励 |倒序浏览 |阅读模式
    基本架构

    如图所示,该工具作为 vscode 插件,因为要跟 webpage 的 dom 进行双向沟通,我们将借用 webdriver 里的 js 执行器来进行通讯。
      加载插件
      我们需要启动一个 chromedriver session, 同时加载一个 chrome 插件, 这插件其是基于 selenium IDE recorder 的一个改造版本。
    1. //driver = new Builder().forBrowser('chrome').build();
    2.   const builder = new Builder().withCapabilities({
    3.       browserName: 'chrome',
    4.       'goog:chromeOptions': {
    5.       // Don't set it to headless as extensions dont work in general
    6.       // when used in headless mode
    7.       args: [`load-extension=${path.join(__dirname + '../../build')}`],
    8.       },
    9.   })
    10.   driver = await builder.build()
    复制代码
    然后在每个 dom 里会有下面几个方法:
    1. //在dom中我们将通过给window 对象绑定新函数的方式注入我们需要调用的方法
    2.   window.__side.selectElement = async callback => {
    3.     await window.__side.postMessage(window, {
    4.       action: 'select',
    5.     }).then(callback)
    6.   }
    7.   window.__side.generateElement = async (callback, options) => {
    8.     await window.__side.postMessage(window, {
    9.       action: 'generateElement',
    10.       builderOptions: options
    11.     }).then(callback)
    12.   }
    13.   window.__side.generateElements = async (callback, options) => {
    14.     await window.__side.postMessage(window, {
    15.       action: 'generateElements',
    16.       builderOptions: options
    17.     }).then(callback)
    18.   }
    19.   window.__side.generateAllElements = async (callback, options) => {
    20.     await window.__side.postMessage(window, {
    21.       action: 'generateAllElements',
    22.       builderOptions: options
    23.     }).then(callback)
    24.   }
    复制代码
    vscode 调用
      在 PO-Manager 中我们将通过 jsExecutor 去向对应的 web 页面中执行 js 脚本,你可能会好奇这里为啥要用 executeAsyncScript 而不是 executeScript, 并且还有个 callback,这个其实是因为我们选择页面元素是一个异步过程,所以需要 callback 来保证正确的返回。
    1. executeAsyncScript 的用法可以参考这里:
    2.   async selectElement(): Promise<string>  {
    3.       await this.showBrowser()
    4.       return this.driver.executeAsyncScript(`var callback = arguments[arguments.length - 1];
    5.                                               window.__side.selectElement(callback);`)
    6.                   .then(value => {
    7.                       return value
    8.                   })
    9.   }
    10.   async generateElement(): Promise<string>  {
    11.       await this.showBrowser()
    12.       let options = {pascalCase: this.pascalCase, preserveConsecutiveUppercase: this.preserveConsecutiveUppercase}
    13.       return this.driver.executeAsyncScript(`var callback = arguments[arguments.length - 1];
    14.                                               window.__side.generateElement(callback, arguments[0]);`, options)
    15.                   .then(value => {
    16.                       return value
    17.                   })
    18.   }
    19.   async generateElements(): Promise<string>  {
    20.       await this.showBrowser()
    21.       let options = {pascalCase: this.pascalCase, preserveConsecutiveUppercase: this.preserveConsecutiveUppercase}
    22.       return this.driver.executeAsyncScript(`var callback = arguments[arguments.length - 1];
    23.                                               window.__side.generateElements(callback, arguments[0]);`, options)
    24.                   .then(value => {
    25.                       return value
    26.                   })
    27.   }
    28.   async generateAllElements(): Promise<string>  {
    29.       let options = {pascalCase: this.pascalCase, preserveConsecutiveUppercase: this.preserveConsecutiveUppercase}
    30.       return this.driver.executeAsyncScript(`var callback = arguments[arguments.length - 1];
    31.                                               window.__side.generateAllElements(callback, arguments[0]);`, options)
    32.                   .then(value => {
    33.                       return value
    34.                   })
    35.   }
    复制代码
     选中元素


    从上图中我们可以看到,当我们想添加元素对象的时候,我们要去选择一个页面元素,我们得要想页面注入 mousemove, click, mouseout 的事件监听,并且要有 highlight 的效果显示我们 focus 元素:
      注入监听
    1. //TargetSelector
    2.     constructor(callback, cleanupCallback) {
    3.       this.callback = callback
    4.       this.cleanupCallback = cleanupCallback
    5.       // Instead, we simply assign global content window to this.win
    6.       this.win = window
    7.       const doc = this.win.document
    8.       const div = doc.createElement('div')
    9.       div.setAttribute('style', 'display: none;')
    10.       //doc.body.insertBefore(div, doc.body.firstChild)
    11.       doc.body.appendChild(div)
    12.       this.div = div
    13.       this.e = null
    14.       this.r = null
    15.       if (window === window.top) {
    16.         this.showBanner(doc)
    17.         doc.body.insertBefore(this.banner, div)
    18.       }
    19.       doc.addEventListener('mousemove', this, true)
    20.       doc.addEventListener('click', this, true)
    21.       doc.addEventListener('mouseout', this, true)
    22.     }
    复制代码
    处理 mouseover 和 click 事件:
    1. handleEvent(evt) {
    2.       switch (evt.type) {
    3.         case 'mousemove':
    4.           this.highlight(evt.target.ownerDocument, evt.clientX, evt.clientY)
    5.           break
    6.         case 'click':
    7.           if (evt.button == 0 && this.e && this.callback) {
    8.             this.callback(this.e, this.win)
    9.           } //Right click would cancel the select
    10.           evt.preventDefault()
    11.           evt.stopPropagation()
    12.           this.cleanup()
    13.           break
    14.         case 'mouseout':
    15.           this.removeHighlight()
    16.           this.e = null
    17.           break
    18.       }
    19.     }
    复制代码
    上面部分的 js 都是以 content-script 的形式注入的,但我们调用 js executor 的时候只能去执行在 dom 底下的 function,这时候在里 dom 就只能通过 postMessage 的方式了来调用 content-script 的方法了。
      content-script 介绍具体可以参考chrome 插件开发
      如何生成一个元素对象
     "新闻Link": {
              "type": "xpath",
              "locator": "(//a[contains(text(),'新闻')])[1]"
          }

    当我们选中一个元素后,要生成上面一个对象,我们需要处理 2 个部分
      · 定位器
      下面的代码是按照以找到 id 为唯一标识,然后生成相对路径的 xpath, 这是最通用的策略,当然对于 link 或者 button 我们也可以以文字显示为标识。
      更多的定位器生成方法,可以查看这里的 源码:
    1. LocatorBuilders.add('xpath:idRelative', function xpathIdRelative(e) {
    2.     let path = ''
    3.     let current = e
    4.     while (current != null) {
    5.       if (current.parentNode != null) {
    6.         path = this.relativeXPathFromParent(current) + path
    7.         if (
    8.           1 == current.parentNode.nodeType && // ELEMENT_NODE
    9.           current.parentNode.getAttribute('id')
    10.         ) {
    11.           return this.preciseXPath(
    12.             '//' +
    13.               this.xpathHtmlElement(current.parentNode.nodeName.toLowerCase()) +
    14.               '[@id=' +
    15.               this.attributeValue(current.parentNode.getAttribute('id')) +
    16.               ']' +
    17.               path,
    18.             e
    19.           )
    20.         }
    21.         else if(current.parentNode.nodeName.toLowerCase() == 'body'){
    22.           return this.preciseXPath(
    23.             '//body' + path,
    24.             e
    25.           )
    26.         }
    27.       } else {
    28.         return null
    29.       }
    30.       current = current.parentNode
    31.     }
    32.     return null
    33.   })
    复制代码
    生成元素名 基本上选择了所见即所得的策略,优先选择显示文字,然后 id, class 次之, 这个当然在设定中可以配置。
    1. buildName(element){
    2.     if(element.tagName == "svg") {
    3.       element = element.parentElement
    4.     }
    5.     let attrList = ["id", "text", "class"]
    6.     let originalText = this.nameCandidate(element, attrList)
    7.     let name = ""
    8.     let nameArr = (typeof originalText == "string" && originalText.match(/[a-zA-Z0-9\u4e00-\u9fa5]+/g)) || ["DefaultElement"]
    9.     for(const n of nameArr){
    10.       if(name.length >= 30) break
    11.       name += n + ' '
    12.     }
    13.     name = camelcase(name, this.options)
    14.     name = this.append(element, name)
    15.     return name
    16.   }
    复制代码
    元素筛选
      上面是针对单个元素,如果我们要选择批量添加元素或者整个页面添加的时候,我们会遇到一个问题,dom 元素太多,那我们该如何筛选呢?

    通过对 dom 的研究发现,我们大部分时候,只需要包含文字的叶节点,而且 table select 之类的复杂元素,我们也只需要加入这个父节点就行。
      所以针对这个,我用 xpath 做了如下的过滤规则
    1. xpath=.//*[not(ancestor::table)][normalize-space(translate(text(),' ',' '))][not(ancestor::select)][not(self::sup)][not(self::iframe)][not(self::frame)][not(self::script)]|.//input[not(ancestor::table)][@type!='hidden']|(.//img|.//select|.//i|.//a|.//h1|.//h2|.//h3|.//h4)[not(ancestor::table)]
    复制代码
    然后再对元素进行可见性筛选,基本就能满足大部分的需求了。
      最后对每个元素生成定位器和元素名,然后返回到 PO-Manager 插件并转化成 json 插入到编辑器就行了。
    1. function generateElements(options){
    2.     return new Promise(res => {
    3.       new TargetSelector(function(element, win) {
    4.         if (element && win) {
    5.           //TODO: generateElements
    6.           let elements = {}
    7.           let xpathFilter = `xpath=.//*[not(ancestor::table)][normalize-space(translate(text(),' ',' '))][not(ancestor::select)][not(self::sup)][not(self::iframe)][not(self::frame)][not(self::script)]|.//input[not(ancestor::table)][@type!='hidden']|(.//img|.//select|.//i|.//a|.//h1|.//h2|.//h3|.//h4)[not(ancestor::table)]`
    8.           let elementList = locatorBuilders.findElements(xpathFilter, element)
    9.           for(const ele of elementList){
    10.             if(!isDisplayed(ele)) continue
    11.             const target = locatorBuilders.build(ele)
    12.             nameBuilder.setBuilderOptions(options)
    13.             const elementName = nameBuilder.buildName(ele)
    14.             elements[elementName] = {
    15.               type: target.slice(0,target.indexOf('=')),
    16.               locator: target.slice(target.indexOf('=') + 1)
    17.             }
    18.           }
    19.           if (elements) {
    20.             res(JSON.stringify(elements))
    21.           }
    22.         }
    23.       })
    24.     })
    25.   }
    复制代码
    下面是一个添加选中区域内元素的例子:

    总结一下:
      1. selenium 启动 browser 的时候加载了一个插件,存放一些常驻的 js 方法 (比如元素选择,生成定位器,元素名称等等)。
      2. 当用户选择元素时,通过 driver.executeAsyncScript 这个方法调用后台 js。
      3. new TargetSelector 这个方法会网页注入 js mouseover 和 click 的监听, 当用户 click 的时候,选择的元素然后通过 locatorBuilders.build(element) 生成对应的定位器。
      4. 生成定位器的方式主要是基于当前元素和一些固定属性之间的反推,判定某些属性可以唯一定位到该元素即可。
      5. 然后给元素生成合适的名称,最后组成 json 返回给 vscode 插件,生成 json 插入到编辑器中。









    本帖子中包含更多资源

    您需要 登录 才可以下载或查看,没有帐号?(注-册)加入51Testing

    x
    分享到:  QQ好友和群QQ好友和群 QQ空间QQ空间 腾讯微博腾讯微博 腾讯朋友腾讯朋友
    收藏收藏
    回复

    使用道具 举报

    本版积分规则

    关闭

    站长推荐上一条 /1 下一条

    小黑屋|手机版|Archiver|51Testing软件测试网 ( 沪ICP备05003035号 关于我们

    GMT+8, 2024-9-21 16:48 , Processed in 0.064979 second(s), 25 queries .

    Powered by Discuz! X3.2

    © 2001-2024 Comsenz Inc.

    快速回复 返回顶部 返回列表