本文主要讲解利用有限状态机实现一套小程序JS版自动化测试SDK选择器,以便更便利、更灵活地实现对小程序元素的选取功能。
什么是自动化测试SDK?能做什么?
自动化测试SDKminiprogram-automator是小程序官方实现,用来实现对小程序操作的仿真模拟。利用该SDK,可以实现对元素的选取,元素信息获取,点击,文本输入,页面滑动,截屏等等一系列操作。一般会用来对小程序做端到端测试。
一句话就是,用这个工具可以实现类似真人操作的模拟。
当前SDK有什么问题?
无论什么操作,一切的基础都是定位和获取元素。所以,元素易得性应该成为这样一个SDK的最基本要求。那么miniprogram-automator表现如何呢?很抱歉,我认为它做的还不够。它支持的语法太有限了,具体的选择器语法戳这里
具体来说,有两方面问题:
第一,能力限制。比如不支持通过属性值选择器筛选元素,不支持通过文本内容筛选元素等,只能通过简单的类名/ID/标签名进行选择。
第二,便捷性问题。对于复杂的情况,想通过一个选择器字符串直达目标元素是不可能的,需要分多步操作,每次自己手动筛选符合条件的元素。
举个例子:
下面分别是index页面和一个自定义组件custom-comp的结构:
<!-- index页面 page/index/index -->
<scroll-view class="scrollarea">
<view class="container" id="container">
<custom-comp></custom-comp>
</view>
</scroll-view>
<!-- 自定义组件 custom-comp -->
<view class="custom-comp-warp">
<view class="btn-wrap">
<button type="primary" class="btn">JS</button>
<button type="warn" class="btn">WXML</button>
</view>
<view class="btn-wrap">
<button type="primary" class="btn">JS</button>
<button type="warn" class="btn">WXML</button>
</view>
</view>
假设我想选择第二个.btn-wrap下type属性为warn的button元素,我可能需要这么操作:
// 假设我们已经获取到了index页面,命名为page
// 选择器不能跨自定义组件选中元素,所以要加上自定义组件的标签
const btnWraps = page.$$('custom-comp .btn-wrap')
const secondBtnWrap = btnWraps[1]
const wxmlBtns = secondBtnWrap.$$('button')
const btn = wxmlBtns[1]
这还是相对简单的情况,情况再复杂一点,我可能在选中元素这一步就已经疯掉了。
所以,我打算利用SDK提供的基础功能实现一套自定义的选择器。
我准备实现一套什么样的选择器?
最终目标是,无论多么复杂的情况,只需要写一个选择器字符串就能获取到目标元素。下面是实现构想:
假设我将要实现的选择器函数名称为customSelector
async function customSelector(page: Page, selector: string): Element[] {
/** 逻辑实现 */
}
函数第一个参数是当前页面实例,第二个参数是自定义的选择器字符串,返回一个小程序标签元素的数组。这里无论选中的是一个还是多个,统统使用数组返回。
下面的代码示例均基于上面提供的标签结构示例。
标签/类名/ID 选择器
这部分功能,原SDK提供了,直接使用page.$$()即可实现。
// 选中自定义组件元素,注意返回的是数组,数组第一项是目标元素
const ele1 = await customSelector(page, 'custom-comp')
// 选中id为container的view标签
const ele2 = await customSelector(page, '#container')
后代选择器
后代选择器是指用空格分隔的两个选择器,后面的选择器是前面选择器的后代元素。
比如:#container custom-comp,必须是在选中#container的基础上,再继续寻找custom-comp。
这个功能也是原SDK提供了,可直接使用。
// 选中自定义组件
const ele1 = await customSelector(page, '#container custom-comp')
属性值选择器 [attr=value]
属性选择器采用方括号形式,比如button[type=primary],表明选中具有type属性,且属性值为primary的button元素。
// 在上面的标签示例基础上,这个选择器实际上返回的是空数组,因为无法跨自定义组件选中元素
const ele1 = await customSelector(page, 'button[type=primary]')
// 正确的用法需要结合后代选择器,这里会返回两个数组项的数组
const ele2 = await customSelector(page, 'custom-comp button[type=primary]')
文本内容选择器 :text(txt)
文本选择器选择元素内文本包含文本参数的元素。比如button:text(wxml),表示要选中文本包含WXML字符串的button元素。
// 返回两个元素的数组
const ele1 = await customSelector(page, 'custom-comp button:text(WXML)')
正向下标选择器 :nth(n)
正向下标选择器是指,在被选中元素列表的基础上,选择下标为n的元素,n从0开始计数。比如button:nth(0),表示选中第一个button元素。
// 这个例子结合了文本选择器,'custom-comp button:text(WXML)'返回了两个button元素,然后`:nth(1)`表示选中第二个button元素
const ele1 = await customSelector(page, 'custom-comp button:text(WXML):nth(1)')
逆向下标选择器 :nth-r(-n)
逆向下标选择器是指,在被选中的元素列表基础上,从后往前选择第n个元素,n从1开始计数。比如button:nth-r(-1),表示选中最后一个button元素。
// 选中最后一个button元素
const ele1 = await customSelector(page, 'custom-comp button:nth(-1)')
复杂条件选择器 {consition-selector}
其实上面提到的[attr=value]、:text(txt)、:nth(n)、:nth-r(-n)都是对已经选中的元素进行近一步筛选,但是这可能远远不够,对于更复杂的情况,上面的筛选器可能会无法满足需求。
举个例子,对上面的自定义组件进行改造,由一个列表array渲染多个.btn-wrap,同时,每个button都有一个条件判断用来控制是否显示。也就是说,.btn-wrap的个数不确定,每个.btn-wrap里面的button也不确定。
<!-- 自定义组件 custom-comp -->
<view class="custom-comp-warp">
<view class="btn-wrap" wx:for="{{array}}">
<button wx:if="{{/* 条件判断 */}}" type="primary" class="btn">JS</button>
<button wx:if="{{/* 条件判断 */}}" type="warn" class="btn">WXML</button>
<view class="target"></view>
</view>
</view>
接下来,假设我们要选中,第一个渲染了type=primarybutton元素的.btn-wrap下面的view.target元素。
我们也不知道到底第几个.btn-wrap元素渲染了type=primary的button元素,所以不能用下标选择器。直接用view.target也不行,因为不知道父级.btn-wrap有没有渲染符合要求的button元素。
所以,需要一个能够实现复杂条件筛选的语法,我决定用selector{condition-selector}的形式来实现。condition-selector是一个合法的自定义选择器字符串,只要在selector选中的元素后代中,存在可以被condition-selector选中的元素,就认为selector选中元素应该被保留,否则表示元素不符合要求,应该被过滤掉。也就是说,括号中的选择器只是用来判断外部选择器选中的元素是否应该被保留的条件。
那么,回到本小节开头的问题,如何写选择器字符串,选择符合要求的view.target?
可以用这个字符串:custom-comp .btn-wrap{button[type=primary]}:nth(0) view.target
custom-comp .btn-wrap选中所有的.btn-wrap元素,button[type=primary]则对上一步选中的.btn-wrap做进一步筛选,然后:nth(0)选中第一个,最后在此基础上,选中后代元素view.target。
当然,复杂条件选择器还需要支持嵌套,形如selector-1{selector-2[attr=value]{selector-3:text(txt)}}这样的语法,其中两个花括号是嵌套结构。
好了,目前为止,自定义选择器实现的目标说完了,接下来就是如何实现customSelector函数?
选择器的实现
完整的实现请查看测试项目下的scripts/custom-selector.js文件。
基本结构实现
忽略筛选器细节,一个选择器字符串是由多个空格分割的子选择器组成,从前往后,后面的选择器是在前面选中的列表基础上,继续做筛选。
假设有一个选择器的结构是A B C,其执行的步骤如下:
第一步:执行A选择器。假设返回的元素是[A1 A2 A3]
第二步:执行B选择器。此时需要在A返回的结果上继续筛选,而A返回了一个数组,所以需要在每个数组元素的范围下,执行B选择器。也就是,这一步的执行结果是A1 B,A2 B,A3 B三个选择器结果的集合。假设A1 B的结果是[A1B1 A1B2 A1B3],A2 B的结果是[A2B1 A2B2],A3 B的结果是[A3B1]。那么,A B的筛选结果就是[A1B1 A1B2 A1B3 A2B1 A2B2 A3B1]。
第三步:执行C选择器。在第二步返回的基础上,重复步骤二的操作。
实现如下,customSelector是整个选择器函数的最终实现:
// 该函数将选择器字符串按照最外层空格分割成子选择器数组
function parseSelectorString(selectorString) {
// 篇幅所限,函数实现不在这里列出了。具体代码可以查看项目链接。
// 注意一点就是,不能直接用Array.split方法处理,因为复杂筛选器`{...}`里面可能有空格
// ...
}
// 在上次返回结果的基础上,执行下一个单结构选择器
// 参数elements是上一个选择器返回的结果列表
async function applySelector(elements, selector) {
// 这个要在后面重点实现
}
// 自定义选择器函数的实现
async function customSelector(page, selector) {
const s = selectorString.trim()
let elements = [page]
if (s.length === 0) return elements
const selectors = parseSelectorString(s)
for (const selector of selectors) {
elements = await applySelector(elements, selector)
}
return Array.from(new Set(elements))
}
接下来就是如何实现单一结构选择器函数applySelector。
单一结构选择器实现
所谓单一结构选择器是指,暂时不考虑后代选择器,比如tagname,.className[attr=value]:nth(n),.className[attr=value]{tagname .className},它们的共同特点是,在{}之外,没有空格存在,也即,最外层结构不用考虑后代选择器情况。
对于每个单一结构来说,最前面部分一定是标签/类名/ID三者的组合,后面部分是对前面选中元素的筛选条件,为了后面方便表述,我们将这些筛选条件都叫做筛选器:
| 筛选器 | 表达式 |
|---|---|
| 属性筛选器 | [attr=value] |
| 下标筛选器 | :nth(n) |
| 逆下标筛选器 | :nth(-n) |
| 文本筛选器 | :text(txt) |
| 选择器筛选器 | {/* selector */} |
在单一结构选择器中,后面的筛选条件,一定是在前面筛选结果的基础上,做进一步筛选。比如,.className[attr=value]:nth(n),首先选中所有.className元素(记为列表A),然后用[attr=value]筛选属性符合条件的元素(记为列表B),接着用:nth(n)筛选符合条件的元素(记为列表C)。
C是B的子集,B又是A的子集
所以,函数执行的思路自然是将选择器字符串从前到后每次获取并消耗一种选择器,执行对应的操作,直到对应的选择器字符串被消耗完。
根据字符串从前往后消耗,以及每种选择器语法都很好识别的特点,可以通过状态机状态转移的方式,获取并执行对应的操作。
状态机状态转移
每个单一结构选择器最初始的状态,一定是标签/类名/ID三者的组合,在miniprogram-automator下,它们的处理方式是一样的,所以统一命名为tagname。之后就是各种筛选器。每种状态都有对应的完成态,我们需要在完成态执行对应的操作:
| 筛选器 | 状态名 | 完成态 |
|---|---|---|
| 属性筛选器 | attribute | attribute-complete |
| 下标筛选器 | nth-index | nth-index-complete |
| 逆下标筛选器 | nthR-index | nthR-index-complete |
| 文本筛选器 | text-contains | text-contains-complete |
| 选择器筛选器 | curly-braces | curly-braces-complete |
上面的状态图没有画完整,最下层所有筛选器的complete状态的下一状态,可能是5中筛选器状态的任何一个,具体需要根据下一个筛选器的初始字符串确定。(全部画上的话,图会变得混乱)
接下来着手实现,我们将单一选择器的执行函数命名为applySelector:
async function applySelector(elements, selector) {
if (selector.trim() === '') return elements
// 单一结构选择器,初始状态都是tagname
let currentState = 'tagname'
let currentElements = elements
let tagName = '' // 标签/类名/ID 的组合
let text = '' // :text(txt) 的文本参数
let attributeStr = '' // [attr=value] 括号内字符串
let nthIndex = null // :nth(n)的参数n
let nthRIndex = null // :nth-r(-n)的参数n
let curlyBracesSelector = '' // {/* selector */} 的selector字符串
// 遍历选择器字符串的每个字符
for (let i = 0; i < selector.length; i++) {
const char = selector[i]
switch (currentState) {
case 'tagname': // 标签,class,id
if (['[', ':', '{'].includes(char)) {
currentState = 'tagname-complete'
i-- // 不消费当前字符
} else {
tagName += char
// tagname后面没有其它筛选器
if (i === selector.length - 1) {
currentState = 'tagname-complete'
}
}
break
case 'curly-braces': {
// 定位到花括号结束的位置
const next = curlyBracesTransition(selector, i)
curlyBracesSelector = selector.slice(i, next.nextI)
currentState = next.nextState
i = next.nextI
}
break
case 'text-contains': // 正在解析文本
if (char === ')') {
currentState = 'text-complete'
} else {
text += char
}
break
case 'attribute': // 属性选择器
// 如果当前字符是属性选择器的结束字符,转移至属性选择器完成状态
if (char === ']') {
currentState = 'attribute-complete'
} else {
attributeStr += char
}
break
case 'nth-index': // 正向下标选择器
// 如果当前字符是下标选择器的结束字符,转移至下标选择器完成状态
if (char === ')') {
currentState = 'nth-index-complete'
} else {
nthIndex = (nthIndex ?? 0) * 10 + parseInt(char, 10)
}
break
case 'nthR-index':
// 如果当前字符是逆向下标选择器的结束字符,转移至逆向下标选择器完成状态
if (char === ')') {
currentState = 'nthR-index-complete'
} else {
nthRIndex = (nthRIndex ?? 0) * 10 + parseInt(char, 10)
}
break
case 'tagname-complete':
case 'curly-braces-complete':
case 'nthR-index-complete':
case 'nth-index-complete':
case 'text-complete':
case 'attribute-complete': {
// 根据后面的字符串,确定下一状态
const next = nextFilterTransition(selector, i)
currentState = next.nextState
i = next.nextI
break
}
}
// 如果当前元素集合为空,直接返回空数组
if (currentElements.length === 0) {
return []
}
if (currentState === 'tagname-complete') {
// 已获得完整的 tagName
}
if (currentState === 'curly-braces-complete') {
// 已获得完整的 curlyBracesSelector
}
if (currentState === 'text-complete') {
// 已获得完整的 text
}
// 如果当前状态是属性选择器完成状态,进行筛选
if (currentState === 'attribute-complete') {
// 已获得完整的 attributeStr
}
// 如果当前状态是正向下标选择器完成状态,进行筛选
if (currentState === 'nth-index-complete') {
// 已获得完整的 nthIndex
}
// 如果当前状态是逆向下标选择器完成状态,进行筛选
if (currentState === 'nthR-index-complete') {
// 已获得完整的 nthRIndex
}
}
return currentElements
}
function curlyBracesTransition(selector, currI) {
// 花括号的嵌套深度
let layer = 1
for (let i = currI; i < selector.length; i++) {
const char = selector[i]
if (char === '{') {
layer++
} else if (char === '}') {
if (layer === 1) {
return {
nextI: i,
nextState: 'curly-braces-complete'
}
} else {
layer--
}
}
}
throw new Error(`${selector}选择器没有结束花括号`)
}
// 完成态下,根据当前字符串,确定下一状态
function nextFilterTransition(selector, currI) {
const char = selector[currI]
if (char === '{') {
return {
nextI: currI,
nextState: 'curly-braces'
}
} else if (char === '[') {
return {
nextI: currI,
nextState: 'attribute'
}
} else if (char === ':') {
// 遇到冒号,可能是:text,:nth(n),:nth-r(n),需要进一步确认
return colonTransition(selector, currI)
}
throw new Error(`选择器解析错误,无法识别${selector}中的${char}字符`)
}
// 冒号状态转移函数
function colonTransition(selector, currI) {
const nextChar = selector[currI + 1]
let nextState = ''
if (nextChar === 'n') {
if (selector.slice(currI + 1, currI + 5) === 'nth(') {
currI = currI + 4 // 跳过'nth('这四个字符串
nextState = 'nth-index'
} else if (selector.slice(currI + 1, currI + 7) === 'nth-r(') {
if (selector[currI + 7] !== '-') {
throw new Error('nth-r(n)选择器语法错误,n必须是负数')
}
currI = currI + 7 // 同时跳过了负号
nextState = 'nthR-index'
} else {
throw new Error('无法解析选择器语法:' + selector)
}
} else if (selector.slice(currI + 1, currI + 6) === 'text(') {
currI = currI + 5 // 跳过'text('
nextState = 'text-contains'
}
return {
nextI: currI,
nextState
}
}
上面就是所有状态之间的转移情况,接下来就是补充所有complete状态下的筛选逻辑。
tagname-complete逻辑实现
currentElements是处理到当前筛选器时,已经获得的元素列表。
element.$$(tagname)是获取element元素下所有tagname后代元素的方法(异步方法)。
async function applySelector(elements, selector) {
// ...
for (let i = 0; i < selector.length; i++) {
// switch() {} // 状态转移和相关变量收集
if (currentState === 'tagname-complete') {
currentElements = await Promise
.all(currentElements.map(element => element.$$(tagName)))
.then(res => res.flat()) // res是一个二维数组,扁平化
tagName = '' // 处理完成后,清空标签名
}
}
}
attribute-complete逻辑实现
element.attribute(attr)是获取element元素的attr属性值的方法(异步)。
async function applySelector(elements, selector) {
// ...
for (let i = 0; i < selector.length; i++) {
// switch() {} // 状态转移和相关变量收集
// 如果当前状态是属性选择器完成状态,进行筛选
if (currentState === 'attribute-complete') {
currentElements = await applyAttributeFilters(currentElements, attributeStr)
attributeStr = '' // 清空属性选择器数组
}
}
}
// 应用属性选择器,筛选出符合条件的元素
async function applyAttributeFilters(elements, attributeString) {
const [attr, value] = attributeString.split('=').map(str => str.trim())
// 过滤符合所有属性条件的元素
const validArr = await Promise
.all(elements.map(ele => ele.attribute(attr)))
.then(res => res.map(val => val === value))
return elements.filter((_, index) => validArr[index])
}
nth-index-complete逻辑实现
async function applySelector(elements, selector) {
// ...
for (let i = 0; i < selector.length; i++) {
// switch() {} // 状态转移和相关变量收集
// 如果当前状态是正向下标选择器完成状态,进行筛选
if (currentState === 'nth-index-complete') {
currentElements = await applyNthIndex(currentElements, nthIndex)
nthIndex = null // 清空下标选择器
}
}
}
// 应用正向下标选择器,筛选出指定下标的元素
async function applyNthIndex(elements, nthIndex) {
return [elements[nthIndex]].filter(Boolean) // 过滤掉空元素
}
nthR-index-complete逻辑实现
async function applySelector(elements, selector) {
// ...
for (let i = 0; i < selector.length; i++) {
// switch() {} // 状态转移和相关变量收集
// 如果当前状态是逆向下标选择器完成状态,进行筛选
if (currentState === 'nthR-index-complete') {
currentElements = await applyNthRIndex(currentElements, nthRIndex)
nthRIndex = null // 清空逆向下标选择器
}
}
}
// 应用逆向下标选择器,筛选出指定逆向下标的元素
async function applyNthRIndex(elements, nthRIndex) {
return [elements[elements.length - nthRIndex]].filter(Boolean) // 过滤掉空元素
}
text-complete逻辑实现
element.text()是获取element元素的文本值的方法(异步)。
async function applySelector(elements, selector) {
// ...
for (let i = 0; i < selector.length; i++) {
// switch() {} // 状态转移和相关变量收集
if (currentState === 'text-complete') {
const flags = await Promise
.all(currentElements.map(element => element.text()))
.then(res => res.map(t => t.indexOf(text) !== -1))
currentElements = currentElements.filter((_, index) => flags[index])
text = '' // 清空文本
}
}
}
curly-braces-complete逻辑实现
花括号表示的选择器筛选条件,是一个嵌套结构下,完整的自定义选择器字符串。既然我们已经实现了自定义选择器函数customSelector,那么这里就可以直接使用它(递归逻辑)。
async function applySelector(elements, selector) {
// ...
for (let i = 0; i < selector.length; i++) {
// switch() {} // 状态转移和相关变量收集
if (currentState === 'curly-braces-complete') {
const flags = await Promise
.all(currentElements.map(element => customSelector(element, curlyBracesSelector)))
.then(res => res.map(t => t.length > 0)) // 如果花括号表示的选择器筛有对应的元素,则表示被修饰的元素可以保留
currentElements = currentElements.filter((_, index) => flags[index])
curlyBracesSelector = '' // 清空花括号选择器
}
}
}
至此,整个选择器逻辑已经完成。最终,currentElements里面剩下的元素,就是我们的筛选目标。
测试
example-for-miniprogram-automator-selector是我为本文准备的一个小程序用例。
customSelector的实现在scripts/custom-selector文件中。
测试用例在pages/index/__test__/index.spec.js中。
你可以修改小程序组件和页面,以及测试用例,对customSelector进行调试和测试。