gunhawk

gunhawk

Frontend Developer

Coding is part of my life, 加藤恵は大好き=。=

使用puppeteer对网页应用进行性能测试

作者: gunhawk时间: 2022-01-29nodejs

提前注意

使用性能分析时, 不能使用headless模式, 否则录制效果会不准确

解决应用鉴权

由于我们的性能测试是针对用户操作文件体验的测量, 要访问用户的文件, 肯定绕不过权限的校验. 现在的网页应用也不再是单纯使用token或者cookie就能模拟真实用户的访问(其实我不太理解为什么要这样, 安全性问题?), 所以只能使用puppeteer模拟真实用户的登录操作. 所幸所有竞品网站都还支持使用账户和密码登录.

这种模拟登录其实相当简单拉, 只要找到输入账号和密码的input元素, 输入对应的信息, 然后提交就好拉. 不过有竞品为了实现一些酷炫的效果, input元素不能直接查找, 以pixso为例:

await page.goto('https://pixso.cn/user/login/')

const $btnLoginTypes = await page.$$(
  '.sign-in-by-account--tabs .text-header5'
)
const $btnLoginByPassword = $btnLoginTypes[$btnLoginTypes.length - 1]

await $btnLoginByPassword.click()

const $signContainer = await page.waitForSelector(
  '.sign-in-by-account--pw-box'
)
const $inputAccount = await $signContainer?.$('input.input--box')
const $inputPassword = await $signContainer?.$('input[type="password"]')
const $btnSign = await $signContainer?.$('.btn--next')

await $inputAccount?.type(this._account.name)
await $inputPassword?.type(this._account.password)
await $btnSign?.click()
await page.waitForNavigation()

值得注意上述代码有个page.waitForSelector, 这里实际上就是等待真实的input元素

首次展示文件内容的时间测量

我们需要评估用户通过访问文件链接, 直到其首次看到文件内容这整个过程的耗时. 所幸所有竞品都有动画loading的展示, 当loading动画不再展示, 文件内容就可以呈现给用户看了. 因此我们可以根据loading动画不再展示作为结束点进行测量. 同样的, page.waitForSelector可以满足我们的需求. 以mastergo为例:

await this.ready()

const page = await this.getMainPage()
const startTime = performance.now()

await page.goto(url, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('.skeleton_screen_editpage')
await page.waitForSelector('.skeleton_screen_editpage', { hidden: true })

return {
  costSecond: (performance.now() - startTime) / 1000,
}

通过domcontentloaded事件来保证dom加载即可, page.goto默认结束的时机是onload, 这显然不符合我们的首屏展示需求. dom加载完成后, 让puppeteer等待loading动画的dom元素, 最后以loading动画的消失为结束点(注意第二次waitForSelector有个hidden参数)

考虑到网络等不可抗力的外部因素, 单纯只测试一次首屏展示可能会存在误差, 所以我使用了中位数来决定首屏展示的时间

async function getMedianForTasks(
  runTimes: number,
  task: () => Promise<CanvasFirstPaintedResult>
) {
  const times: number[] = []

  if (runTimes % 2 === 0) {
    runTimes += 1
  }

  for (let i = 0; i < runTimes; i++) {
    const { costSecond } = await task()

    times.push(costSecond)
  }

  return times[Math.ceil(times.length / 2)]
}

移动全选图形的性能测试

这个也很简单, 直接上代码了.

await this.waitForCanvasReady(url, { zoom: options.zoom })

const page = await this.getMainPage()
const keyboard = page.keyboard
const mouse = page.mouse
const pageSettings = this.options.pageSettings

await keyboard.down('ControlLeft')
await keyboard.press('A')
await keyboard.up('ControlLeft')

const x = pageSettings.width / 2
const y = pageSettings.height / 2
const testFn = () =>
  mousemoveInRetanglePath(mouse, {
    start: { x, y },
    steps: options.mousemoveSteps,
    delta: options.mousemoveDelta,
  })

await this.recordPerformance(page, testFn, {
  screenshots: true,
  filename: 'xiaopiu-move-select-all.json',
})

通过keyboarddown, press, up可以模拟用户的Ctrl+A的全选行为. 而后我在页面的中心点模拟拖曳的过程并录制. mousemoveInRetanglePath实现如下:

export async function mousemoveInRetanglePath(
  mouse: Mouse,
  options: {
    start: Point
    steps: number
    delta: number
  }
) {
  const { start: startPoint, steps, delta } = options
  let x = startPoint.x
  let y = startPoint.y

  await mouse.move(x, y)
  await mouse.down()

  // to topLeft
  for (let i = 0; i < steps; i++) {
    x -= delta
    y -= delta
    await mouse.move(x, y)
  }

  // to topRight
  for (let i = 0; i < steps; i++) {
    x += delta
    await mouse.move(x, y)
  }

  // to bottomRight
  for (let i = 0; i < steps; i++) {
    y += delta
    await mouse.move(x, y)
  }

  // to bottomLeft
  for (let i = 0; i < steps; i++) {
    x -= delta
    await mouse.move(x, y)
  }

  // to topLeft
  for (let i = 0; i < steps; i++) {
    y -= delta
    await mouse.move(x, y)
  }

  // to origin
  for (let i = 0; i < steps; i++) {
    x += delta
    y += delta
    await mouse.move(x, y)
  }

  await mouse.up()
}

看完之后是不是很简单? 哈哈哈.

鼠标框选图形的性能测试

这个是通过puppeteer的鼠标从画布的左上角逐步拖动到画布的右下角, 来模拟用户框选图形的过程:

await this.waitForCanvasReady(url, { zoom: options.zoom })

const canvasBoundingRect = await this.getCanvasBoundingRect('#canvas')

if (!canvasBoundingRect) {
  console.error('canvas boundings not found!')

  return
}

const page = await this.getMainPage()
const mouse = page.mouse
const rulerWidth = 0
const rulerHeight = 0
const startX = canvasBoundingRect.left + rulerWidth
const startY = canvasBoundingRect.top + rulerHeight
const endX = startX + canvasBoundingRect.width
const endY = startY + canvasBoundingRect.height
const testFn = () =>
  mousemoveInDiagonalPath(mouse, {
    start: { x: startX, y: startY },
    end: { x: endX, y: endY },
    delta: options.mousemoveDelta,
  })

await this.recordPerformance(page, testFn, {
  screenshots: true,
  filename: 'mastergo-move-for-select-shapes.json',
})

既然上面提及了画布的***, 那不可避免要获取画布的相关信息, 这个画布其实就是个canvas. 简单看下this.getCanvasBoundingRect的实现:

async getCanvasBoundingRect(domSelector: string, page?: Page) {
  if (typeof page === 'undefined') {
    page = await this.getMainPage()
  }

  return await page.evaluate(function getBoundingRect(selector: string) {
    const canvas = document.querySelector(selector)
    const rect = canvas?.getBoundingClientRect()
    let r: Omit<DOMRect, 'toJSON'> | undefined

    if (rect) {
      r = {
        width: rect.width,
        height: rect.height,
        x: rect.x,
        y: rect.y,
        top: rect.top,
        left: rect.left,
        bottom: rect.bottom,
        right: rect.right,
      }
    }

    return r
  }, domSelector)
}

通过page.evalute来获取画布的信息. 不过值得注意的是, puppeteernodejs进程和chromium进程只能交换可序列化的数据. 因此如果直接传递DOMRect类型的数据是不行的.

获取到画布信息之后就简单拉, 以下是mousemoveInDiagonalPath的实现:

export async function mousemoveInDiagonalPath(
  mouse: Mouse,
  options: {
    start: Point
    end: Point
    delta: number
  }
) {
  const { start: startPoint, delta, end: endPoint } = options
  let x = startPoint.x
  let y = startPoint.y
  const dx = endPoint.x - x
  const dy = endPoint.y - y
  const xSteps = dx / delta
  const ySteps = dy / delta
  const steps = Math.min(xSteps, ySteps)
  const deltaX = dx / steps
  const deltaY = dy / steps

  await mouse.move(x, y)
  await mouse.down()

  for (let i = 0; i < steps; i++) {
    x += deltaX
    y += deltaY
    await mouse.move(x, y)
  }

  await mouse.up()
}

画布中心点缩放的性能测试

这个本来我以为是最简单的, 不就鼠标移动到画布中心点, 然后控制滚轮吗. 然而事实是, 如果wheel事件是注册在canvas本身, 则模拟滚动无效(即使我再让canvas聚焦). 这个原因未明, 有待考究

因此我使用了一个比较搓的方法实现了这个测试: 让页面发送wheel事件给注册wheel的宿主, 通过这种方式来达到缩放的效果. 代码如下:

async zoomCanvasByMockWheel(registedWheelSelector: string = '') {
  const page = await this.getMainPage()
  const mouse = page.mouse
  const pageSettings = this.options.pageSettings

  await mouse.move(pageSettings.width / 2, pageSettings.height / 2)

  return page.evaluate(
    ({
      clientX,
      clientY,
      ctrlKey = true,
      selector,
    }: Partial<WheelEventInit> & { selector: string }) => {
      let node = selector
        ? (document.querySelector(selector) as Element)
        : document

      const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
      const makeMockWheel = (zoomType: 'large' | 'small') => {
        return () => {
          const seed = zoomType === 'large' ? -1 : 1
          const initOptions: WheelEventInit = {
            deltaY: seed * 125,
            ctrlKey,
            clientX,
            clientY,
          }
          const mouseWheelEvent = new WheelEvent('mousewheel', initOptions)
          const wheelEvent = new WheelEvent('wheel', initOptions)

          node.dispatchEvent(mouseWheelEvent)
          node.dispatchEvent(wheelEvent)

          return sleep(16.67)
        }
      }
      const queue: Array<() => Promise<unknown>> = []
      const loop = (): Promise<void> => {
        const fn = queue.shift()

        if (fn) {
          return fn().then(loop)
        }

        return Promise.resolve()
      }

      for (let i = 0, l = 20; i < l; i++) {
        queue.push(makeMockWheel('large'))
      }

      for (let i = 0, l = 20; i < l; i++) {
        queue.push(makeMockWheel('small'))
      }

      return loop()
    },
    {
      selector: registedWheelSelector,
      clientX: pageSettings.width / 2,
      clientY: pageSettings.height / 2,
    }
  )
}

收集性能数据

说了这么多, 到底如何收集性能数据呢? 正如前面所说, 使用page.tracing相关的api即可:

async recordPerformance(
  page: Page,
  fn: () => Promise<void>,
  options: TracingOptions & { filename: string }
) {
  await page.tracing.start({
    screenshots: options.screenshots,
    path: options.path || `${fileSaver.performancesDir}/${options.filename}`,
  })
  await fn()
  await page.tracing.stop()
}

代码运行结束后, 就会在指定路径生成*.json的文件. 你可以通过devtools -> performance -> load file来加载保存的json文件, 这样就可以分析程序性能情况拉.

Puppeteer大法好

以前用puppeteer主要是用来完成一些自动化相关的功能, 万万没想到还能做性能测试(在下才疏学浅, 认知不足). 对chromium掌控力越强, 能做的事情越多, puppeteer大法好!