Well, I spent the weekend digging for gold with my sleeping baby at home, reading an article about Puppeteer, and thinking about the Client refactoring that’s going on, what can I do with Sleep sleep?

Because this massive refactoring uses resource permissions instead of hard-coded authentication in older versions, all of the archived resources are collected by human judgment on old code (thanks to the previous Hive architecture, each module refactoring is independently responsible for gathering resources).

However, human eyes are always not reliable. QA needs to compare new and old interface resources with naked eyes for the first 20 reconstruction functions of 30+ customers, with an average of about 8 roles. A rough calculation of time: 5 minutes for each function,

30 (Customer) * 8 (role) * 2 (old and new accounts) * 20 (functions) * 5 (minutes) = 48000 (minutes)Copy the code

That’s 800 hours, 33 days without food, water or sleep per person.

Then my mind began to wander and wonder what I could do with puppeteer for the QA girls.

What is Puppeteer?

Puppeteer is a Node library that provides a high-level API for controlling Chromium or Chrome via the DevTools protocol. Puppeteer runs in headless mode by default, but can be run in headless mode by modifying the configuration file.

It’s pretty exciting to think that Google made it for Chrome, and it’s implemented with NodeJS.

What can Puppeteer do?

Most of the things you can do manually in a browser can be done using Puppeteer! Here are some examples:

Generate page PDF.

Grab SPA (single-page application) and generate pre-rendered content (that is, “SSR” (server-side rendering)).

Automatic form submission, UI testing, keyboard input, etc.

Create an automated test environment that is constantly updated. Perform tests directly in the latest version of Chrome using the latest JavaScript and browser features.

Capture the Timeline trace for the site to help analyze performance issues.

Test the browser extension.

My vision

Hive – based sub-project management functions, encapsulation of a similar distributed automation testing framework.

  • The primary purpose is to solve the problem of resource comparison;
  • The framework can automatically help each sub-project to log in the new and old accounts, and automatically navigate to the sub-project page;
  • After distribution, the person in charge of the sub-project realizes its own resource collection and screenshot operation;
  • Management of eachFunction - Customer - RoleCorresponding resource data of;
  • inFunction - Customer - RoleGenerate test reports and screenshots at the corresponding location of, especially reports, which contain the resource comparison between the old and new functionsThe redAnd you can calculateMatching percentage;
  • Provide some apis for subprojects to use, such as:
    • Screen capture, puppeteer’s native screen capture API requires manually specifying the build location, and I provide a pre-built build location that greatly simplifies the API, for exampleapi.screenshot(name)Generates a sheet namedname_neworname_oldThe screenshot file to the appropriate location;
    • Add resources. When the operation right is handed over to sub-projects, they can use Puppeteer to grab the specified resource elements on the page, call the API for adding resources to add them, and finally generate a comparison report of new and old resources.
    • Other simplified apis, etc.
  • Like a distributed system, the framework calls the implementation of each subproject one by one, eventually generating detailed and outline reports

Puppeteer Installation Guide

Puppeteer requires node version V7.6.0 or later to support Async /await. You are advised to install the latest stable version.

When Puppeteer is installed, it automatically downloads the latest Chromium(~71Mb Mac, ~90Mb Linux, ~110Mb Win), which is very unfriendly to the local network.

To avoid automatic Chromium download, run the following command before installation:

npm config set puppeteer_skip_chromium_download true
Copy the code

Install Puppeteer and typescript support.

npm install puppeteer @types/puppeteer --save-dev
Copy the code

Method of use

  const browser = await puppeteer.launch({
    executablePath: 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe'.headless: false.// Headless mode
    timeout: 0.// The timeout period
    // devTools: true, // Automatically opens the DevTools panel
    defaultViewport: defaultViewport, // Default window size
    args: ['--start-maximized'].// Browser default parameters [full screen]
  });
Copy the code

For those who skipped the Chromium kernel download, you can manually specify the local Chrome execution file location by adding the executablePath property.

The headless attribute defaults to true, indicating whether headless mode is enabled (testing directly without using the browser interface)

Let’s take a look at the Puppeteer API

With the browser instance of launch above, create a new TAB:

  const page = browser.newPage();
Copy the code

Most of the time we are dealing with page objects, such as:

Go to a page

  await page.goto(`${context}/Account/Login`, {
    waitUntil: "networkidle0"
  });
Copy the code

The second is optional, taking waitUntil as an example, which means to await the code until there is no network request to continue.

It has four optional values, as follows:

  • Load – When the page load event is triggered
  • Domcontentloaded – When the page’s domContentLoaded event is triggered
  • Networkidle0 – Triggered when there is no more network connection (after at least 500 ms)
  • Networkidle2 – Triggered when there are only 2 network connections (at least 500 ms later)

On the login page, we need to manipulate the form and enter something

  await page.type('#userName', account.username);
  await page.type('#password', account.password);
  await page.click('button[type=submit]');
Copy the code

The type method means that you type, the first argument being the element selector (input element) and the second argument being what you want to input

The click method represents the click event that triggers an element specified by a selector (besides click, there are hover, focus, press, etc.)

Puppeteer has several characteristics:

1. All operations are asynchronous and need to be called with async and await. Because it is based on the Chrome DevTools protocol, calls to and from Chrome are made by sending asynchronous messages.

2. A lot of apis rely on selectors, so CSS selectors need to be understood.

3. Some hidden APIS are not reflected in the document, such as how to open stealth mode and how to clear cookies.

After entering the form and clicking submit, we need to jump to the subproject page, but we need to wait for the login operation to complete before doing so

  await page.waitForNavigation({
    waitUntil: "domcontentloaded"
  });
Copy the code

Here, we see that the second enumeration of waitUntil values domContentLoaded, which means the same thing as the Document domContentLoaded event. Those of you who are familiar with front-end development probably know how it differs from the Onload event, which I won’t go into here, but is much earlier than the onload event.

Getting page elements

  const input = await page.$('input.form-input');
  const buttons = awaitpage.? ('button');
Copy the code

Page.$can be interpreted as our usual document.querySelector, while page.? The corresponding document. QuerySelectorAll.

  // Get window information
  const dimensions = await page.evaluate((a)= > {
      return {
          width: document.documentElement.clientWidth,
          height: document.documentElement.clientHeight,
          deviceScaleFactor: window.devicePixelRatio
      };
  });
  const value = await page.$eval('input[name=search]', input => input.value);
Copy the code

Page. Evaluate means to execute the script in the browser environment, passing in a second argument as a handle, while Page.$eval performs an operation on a selected DOM element.

There’s another interesting featurepage.exposeFunctionExposure to function

  const puppeteer = require('puppeteer');
  const crypto = require('crypto');
  
  puppeteer.launch().then(async browser => {
    const page = await browser.newPage();
    page.on('console', msg => console.log(msg.text));
    await page.exposeFunction('md5', text =>
      crypto.createHash('md5').update(text).digest('hex'));await page.evaluate(async() = > {// use window.md5 to compute hashes
      const myString = 'PUPPETEER';
      const myHash = await window.md5(myString);
      console.log(`md5 of ${myString} is ${myHash}`);
    });
    await browser.close();
  });

Copy the code

The above example shows how to expose an MD5 method to a window object

Nodejs has many toolkits that can easily implement complex functions, such as md5 encryption function, which is not very convenient to implement with pure JS, and nodeJS is a matter of a few lines of code.

Start designing our framework

Step post

  • Prepare a list of accounts (both old and new) and get the list;
  • uselerna lsGet a list of all the subprojects and look for the ones that are implementedxxxx/autotest/index.tsFile;
  • Circular account, each account log in, recycle all subprojects, one by one navigation into, will operate right and readypagewithapiObjects are handed to subprojects.
    • Sub-project collection of resources and screenshots
  • After the operation is complete, click all the collected resourcesFunction - Customer - RoleLocation generates old and new comparison reports
  • Finally generate outline report

The steps are pretty straightforward.

Post the final report screenshot first

Details of the report

Outline of the report

The difference between old and new resources is marked in red, and the total number of resources and matching percentage are given, similar to the test report generated by Jest unit test.

Post the main implementation code again

  const doAutoTest = async (page: puppeteer.Page, resource: IAllProjectResource, account: IAccount, projectAutoTestList: IAutoTest[], newVersion: boolean) => {
    await login(page, account);
    const clientCode = await getCookie(page, 'ClientCode');
    const clientDevRole = await getCookie(page, 'ClientCurrentRole');
    for(const autotest of projectAutoTestList) {
      await page.goto(`${context}${autotest.url}`, {
        waitUntil: "domcontentloaded"
      });
      const api: IpuppeteerApi = {
        logger: logger,
        screenshot: screenshot({ page, newVersion, clientCode, clientDevRole, subProjectName: autotest.name }),
        addResource: addResource({ newVersion, clientCode, clientDevRole, subProjectName: autotest.name, projectResource: resource, username: account.username }),
        isExist: isExist({ page }) }; ! newVersion ?await autotest.oldVersionTest(page, api) : awaitautotest.newVersionTest(page, api); }};(async () = > {
    const browser = await launch();
    const projectAutoTestList = getSubProjectAutotests();
    const accounts = getAccounts();
    const resource = getResource();
    const page = await createPage();
    for(const accountGroup of accounts) {
      await doAutoTest(page, resource, accountGroup.oldVersionAccount, projectAutoTestList, false);
      await doAutoTest(page, resource, accountGroup.newVersionAccount, projectAutoTestList, true);
    }
    
    exportHtml(resource);
    awaitbrowser.close(); }) ();Copy the code

Does this end there?

No, I’m not a clicker. As a tech geek who doesn’t do anything about it, I think I can go one step further and do it to perfection!

Currently our automated tests are log-in by account and function by function. Of course, this is fine for this process, and humans have also tested it this way, but the machine doesn’t rest and the “hand speed” is faster…

But,

Is the CPU running full?

Is the memory full?

Is the machine working as well as it should?

Not at all.

Yes, as you might have guessed, multitasking + concurrency

At first I thought, for Chrome, we open the browser to share the session, just look at the official documentation and there is no API for creating a new session…

That we can still go to achieve a single login account, open multiple tabs at the same time test multiple functions!

The answer is yes, but I did a bit of Googling and found a hidden API

  const { browserContextId } = await browser._connection.send('Target.createBrowserContext');
  _page = await browser._createPageInContext(browserContextId);
  _page.browserContextId = browserContextId;
Copy the code

By sending a Target. CreateBrowserContext instruction (call this instruction), you can create a new context (using the browser’s function is to create a stealth mode). Then browser._createpageInContext (browserContextId) will get the new incognito window object!

With this API, I can create countless session-isolated Page objects!

With this API, I can implement N * M 2d concurrency (concurrent login with multiple accounts, multiple functional tests per account)

First, I need to tweak the code a little bit

Most of the code doesn’t need to be changed, it needs to be treated as tasks, we need to add the task scheduling logic.

Implement a TaskQueue


export default class Task<T = any> {

  executor: (a)= > Promise<T>;

  constructor(executor? : () => Promise<T>) {this.executor = executor;
  }

  async execute() {
    return this.executor && await this.executor(); }}export default class TaskQueue<T extends Task> {

  concurrence: number;
  queue: T[] = [];

  constructor(concurrence: number = 1) {
    this.concurrence = concurrence;
  }

  addTask(task: T | T[]) {
    if (Object.prototype.toString.call([]) === '[object Array]') {
      this.queue = [...this.queue, ...task as T[]];
    } else {
      this.queue.push(task as T);
    }
    return this;
  }

  async run() {
    const todos = this.queue.splice(0.this.concurrence);
    if (todos.length === 0) return;
    await Promise.all(todos.map(task= > task.execute()));
    return await this.run(); }}Copy the code

Implement an AccountTask class

  import Task from "./task";
import puppetter from 'puppeteer';
import ProjectTask from "./projectTask";
import TaskQueue from "./taskQueue";
import { IAccount, IAutoTest, getCookie, IpuppeteerApi, screenshot, addResource, isExist } from ".. /utils/api";
import { max_page_number, context } from ".. /utils/constant";
import logger from ".. /utils/logger";
import login from ".. /login";
import { createPage, pool } from ".. /browser";
import { getResource } from ".. /config/config";

const resource = getResource();

export default class AccountTask extends Task {
  
  account: IAccount;
  page: puppetter.Page;
  projects: IAutoTest[];
  taskQueue: TaskQueue<ProjectTask>;
  newVersion: boolean;

  constructor(account: IAccount, projects: IAutoTest[], newVersion: boolean) {
    super(a);this.account = account;
    this.projects = projects;
    this.taskQueue = new TaskQueue(max_page_number);
    this.newVersion = newVersion;
    this.initTaskQueue();
  }

  initTaskQueue() {
    this.taskQueue.addTask(this.projects.map((autotest, index) = > new ProjectTask(async() = > {const page = await createPage(true.this.page);
      const clientCode = await getCookie(this.page, 'ClientCode');
      const clientDevRole = await getCookie(this.page, 'ClientCurrentRole');
      await page.goto(`${context}${autotest.url}`, {
        waitUntil: "domcontentloaded"
      });
      const api: IpuppeteerApi = {
        logger: logger,
        screenshot: screenshot({ page, newVersion: this.newVersion, clientCode, clientDevRole, subProjectName: autotest.name }),
        addResource: addResource({ newVersion: this.newVersion, clientCode, clientDevRole, subProjectName: autotest.name, projectResource: resource, username: this.account.username }),
        isExist: isExist({ page })
      };
      !this.newVersion ? await autotest.oldVersionTest(page, api) : await autotest.newVersionTest(page, api);
      await page.close();
    })));
  }
  
  async execute() {
    this.page = await createPage(true);
    await login(this.page, this.account);
    await this.taskQueue.run();
    this.page.close(); }}Copy the code

And then I’m going to change the pivot function

  if (argv.mode === 'crazy') {
    const accountTaskQueue = new TaskQueue(max_isolation_number);
    for(const accountGroup of accounts) {
      accountTaskQueue.addTask([
        new AccountTask(accountGroup.oldVersionAccount, projectAutoTestList, false),
        new AccountTask(accountGroup.newVersionAccount, projectAutoTestList, true)]); }await accountTaskQueue.run();
  } else {
    const page = await createPage();
    for(const accountGroup of accounts) {
      logger.info(`start old version test`);
      await doAutoTest(page, resource, accountGroup.oldVersionAccount, projectAutoTestList, false);
      logger.info(`start new version test`);
      await doAutoTest(page, resource, accountGroup.newVersionAccount, projectAutoTestList, true); }}Copy the code

Two constants are configured to control the concurrency threshold

  export const max_isolation_number = 5; // session Specifies the maximum number of concurrent requests to be isolated

  export const max_page_number = 5; // Maximum number of concurrent subprojects
Copy the code

If the mode is Crazy, create an accountTaskQueue queue and load the account login task. Another queue, taskQueue, is initialized internally by AccountTask to process subproject task queues.

I call it blood, because it’s actually a little scary to run.

Then start the account queue.

As expected, when accountTaskQueue starts, five stealth modes start immediately. The Taskqueues within AccountTask are tested with five subproject tasks each.

Very fast, completing a test at least 10 times faster than just now!!

However, after careful thought, found that there is still a small problem, we can observe the following section of queue control code

  async run() {
    const todos = this.queue.splice(0.this.concurrence);
    if (todos.length === 0) return;
    await Promise.all(todos.map(task= > task.execute()));
    return await this.run();
  }
Copy the code

The biggest problem with this code is to await promise.all, get the maximum number of concurrent executions (5) at the same time, but I didn’t think about dynamically adding the rest to the execution, instead I foolishly waited until all 5 executions were finished and then got 5 executions.

This wastes time and CPU performance.

But this is an asynchronous concurrent queue, and dynamic replenishment is not easy to handle.

After weighing it up for a while, I decided not to build wheels, and I went to a library called P-Limit, written by Sindresorhus, to reprocess the place

import pLimit, { Limit } from 'p-limit';

export default class TaskQueue<T extends Task> {

  concurrence: number;
  queue: T[] = [];
  limit: Limit;

  constructor(concurrence: number = 1) {
    this.concurrence = concurrence;
    this.limit = pLimit(concurrence);
  }

  addTask(task: T | T[]) {
    if (Object.prototype.toString.call([]) === '[object Array]') {
      this.queue = [...this.queue, ...task as T[]];
    } else {
      this.queue.push(task as T);
    }
    return this;
  }

  async run() {
    return await Promise.all(this.queue.map(task= > this.limit(async() = >awaittask.execute()))); }}Copy the code

Its main function is to help us play a queue concurrency limit function, in effect handing over the function of the queue to it.

At this point we run the code again to see, initially five, a window after the automatic closure of a new one in. Well, that’s what we wanted.

Is there room for improvement?

There are! We shut down every stealth mode mission and then re-created it, wasting resources and time.

What to do?

I think of connection pooling for databases

I can drop these incognito page objects into the pool and retrieve them whenever I need them, and then just put them back in the pool.

Modify the execute function of AccountTask as follows

  async execute() {
    this.page = await pool.getMainPage();
    await login(this.page, this.account);
    await this.taskQueue.run();
    pool.recycle(this.page);
  }
Copy the code

I ran it again, calculated the time, ran 8 accounts and 2 functions, the time increased from 70 seconds to 48 seconds!

At this point, we have not only completed our initial goal, but also provided both normal and Chicken mode.

The normal mode is used for development and debugging, and the chicken blood mode is used for practical application.

In particular, the “chicken blood” mode is the essence of this article, which implements a two-dimensional concurrent queue, which front-end engineers are less exposed to in their daily work.