当前位置: 首页 > 工具软件 > WebdriverCSS > 使用案例 >

Webdrivercss原理分析-截图生成篇

翁文康
2023-12-01

截图生成部分

webdrivercss.js

返回一个WebdriverCSS实例,这个实例接受一个Webdriverio的实例和一个配置相关的Object

webdrivercss.js:213

module.exports.init = function(webdriverInstance, options) {
    return new WebdriverCSS(webdriverInstance, options);
};

webdrivercss.js:21

var WebdriverCSS = function(webdriverInstance, options) {
    options = options || {};

    if(!webdriverInstance) {
        throw new Error('A WebdriverIO instance is needed to initialise WebdriverCSS');
    }
    ...
}

配置初始化完成,工作流程开始,并添加截图相关Command

注意下面代码中的this.instance实际为Webdriverio的实例。因此下面代码通过WebdriverIO中的addCommand方法向WebdriverIO的指令集中添加了四条指令:[***saveViewportScreenshot***, saveDocumentScreenshot, webdrivercss, sync]和其对应的操作。下面我们先看webdrivercss的工作流程里面都做了什么。

webdrivercss.js:79

var WebdriverCSS = function(webdriverInstance, options) {
    ...
    this.instance = webdriverInstance;
    ...
    this.instance.addCommand('saveViewportScreenshot', viewportScreenshot.bind(this)); // 这里通过WebdriverIO的API
    this.instance.addCommand('saveDocumentScreenshot', documentScreenshot.bind(this));
    this.instance.addCommand('webdrivercss', workflow.bind(this));
    this.instance.addCommand('sync', syncImages.bind(this));

    return this;
}

workflow.js

接受一个pageName,配置Object,一个callback。pageName用来对对比的page进行命名,配置必须为含有name属性的对象,callback会被送到后续的每一步流程中,后续讲解

workflow.js:7 ~ workflow.js:38

module.exports = function(pagename, args) {
    /*!
     * make sure that callback contains chainit callback
     */
    var cb = arguments[arguments.length - 1];

    this.needToSync = true;

    /*istanbul ignore next*/
    if (typeof args === 'function') {
        args = {};
    }

    if (!(args instanceof Array)) {
        args = [args];
    }

    /**
     * parameter type check
     */
    /*istanbul ignore next*/
    if (typeof pagename === 'function') {
        throw new Error('A pagename is required');
    }
    /*istanbul ignore next*/
    if (typeof args[0].name !== 'string') {
        throw new Error('You need to specify a name for your visual regression component');
    }

    var queuedShots = JSON.parse(JSON.stringify(args)),
        currentArgs = args.shift();
    ...
}

这里要着重讲解一下workflow.js的当前上下文以及他封装的context。由于上面贴出过的代码段中有这种写法this.instance.addCommand('webdrivercss', workflow.bind(this));,因此此时workflow.js的上下文为Webddrivercss的实例对象,包含以下配置的属性及其原型链

webdrivercss.js:43 ~ webdrivercss.js:71

var WebdriverCSS = function(webdriverInstance, options) {
    ...
    this.screenshotRoot = options.screenshotRoot || 'webdrivercss';
    this.failedComparisonsRoot = options.failedComparisonsRoot || (this.screenshotRoot + '/diff');
    this.misMatchTolerance = options.misMatchTolerance || 0.05;
    this.screenWidth = options.screenWidth || [];
    this.warning = [];
    this.resultObject = {};
    this.instance = webdriverInstance;
    this.updateBaseline = (typeof options.updateBaseline === 'boolean') ? options.updateBaseline : false;

    /**
     * sync options
     */
    this.key = options.key;
    this.applitools = {
        apiKey: options.key,
        saveNewTests: true, // currently will always happen.
        saveFailedTests: this.updateBaseline,
        batchId: _generateUUID()
    };
    this.host = 'https://eyessdk.applitools.com';
    this.headers = {
        'Accept': 'application/json',
        'Content-Type': 'application/json'
    };
    this.reqTimeout = 5 * 60 * 1000;
    this.user = options.user;
    this.api  = options.api;
    this.usesApplitools = typeof this.applitools.apiKey === 'string' && !this.api;
    this.saveImages = options.saveImages || !this.usesApplitools;
    ...
}

workflow.js中的context设置如下(注意this指的是上面说的Webddrivercss的实例对象):

workflow.js:40 ~ workflow.js:72

module.exports = function(pagename, args) {
    ...
    var context = {
        self: this,

        /**
         * default attributes
         */
        misMatchTolerance:      this.misMatchTolerance,
        screenshotRoot:         this.screenshotRoot,
        failedComparisonsRoot:  this.failedComparisonsRoot,

        instance:       this.instance, // 这里需要注意,后面会用到很多
        pagename:       pagename,
        applitools:     {
            apiKey: this.applitools.apiKey,
            appName: pagename,
            saveNewTests: this.applitools.saveNewTests,
            saveFailedTests: this.applitools.saveFailedTests,
            batchId: this.applitools.batchId // Group all sessions for this instance together.
        },
        currentArgs:    currentArgs,
        queuedShots:    queuedShots,
        baselinePath:   this.screenshotRoot + '/' + pagename + '.' + currentArgs.name + '.baseline.png',
        regressionPath: this.screenshotRoot + '/' + pagename + '.' + currentArgs.name + '.regression.png',
        diffPath:       this.failedComparisonsRoot + '/' + pagename + '.' + currentArgs.name + '.diff.png',
        screenshot:     this.screenshotRoot + '/' + pagename + '.png',
        isComparable:   false,
        warnings:       [],
        newScreenSize:  0,
        pageInfo:       null,
        updateBaseline: (typeof currentArgs.updateBaseline === 'boolean') ? currentArgs.updateBaseline : this.updateBaseline,
        screenWidth:    currentArgs.screenWidth || [].concat(this.screenWidth), // create a copy of the origin default screenWidth
        cb:             cb
    };
    ...
}

继续读会发现workflow的工作流程:
- startSession.js
- setScreenWidth.js
- makeScreenshot.js
- renameFiles.js
- getPageInfo.js
- cropImage.js
- compareImages.js
- saveImageDiff.js
- asyncCallback.js

注意:源码中将每个过程的工作全部绑定context为上下文;由于使用了async.waterfall(),因此每一个模块都会接受一个callback参数,在源码中被命名成了done(有关这个回调函数具体的使用细则请参考async.waterfall)

workflow.js:81 ~ workflow.js:128

module.exports = function(pagename, args) {
    ...
    async.waterfall([
        /**
         * initialize session
         */
        require('./startSession.js').bind(context),

        /**
         * if multiple screen width are given resize browser dimension
         */
        require('./setScreenWidth.js').bind(context),

        /**
         * make screenshot via [GET] /session/:sessionId/screenshot
         */
        require('./makeScreenshot.js').bind(context),

        /**
         * check if files with id already exists
         */
        require('./renameFiles.js').bind(context),

        /**
         * get page informations
         */
        require('./getPageInfo.js').bind(context),

        /**
         * crop image according to user arguments and its position on screen and save it
         */
        require('./cropImage.js').bind(context),

        /**
         * compare images
         */
        require('./compareImages.js').bind(context),

        /**
         * save image diff
         */
        require('./saveImageDiff.js').bind(context)
    ],
        /**
         * run workflow again or execute callback function
         */
        require('./asyncCallback.js').bind(context)

    );
}

由于这部分是截图功能的解读,因此我们直接跳过其他去查看makeScreenshot.js做了什么

makeScreenshot.js

makeScreenshot模块首先会接收Webdrivercss的第二个参数中的元素信息,并不会在截图时考虑这些元素(屏蔽这些元素的比较)

makeScreenshot.js:7 ~ makeScreenshot.js:44

module.exports = function(done) { // async.waterfall中每一层级传递的回调函数
    /**
     * take actual screenshot in given screensize just once
     */
    if(this.self.takeScreenshot === false) { // context.self(Webdrivercss实例).takeScreenshot,保证截图一次
        return done();
    }

    this.self.takeScreenshot = false; 

    /**
     * gather all elements to hide
     */
    var hiddenElements = [];
    this.queuedShots.forEach(function(args) { // Webdrivercss中的第二个配置参数如:[{ name: 'header', elem: '#header' }, { name: 'hero', elem: '//*[@id="hero"]/div[2]' }]
        if(typeof args.hide === 'string') {
            hiddenElements.push(args.hide);
        }
        if(args.hide instanceof Array) {
            hiddenElements = hiddenElements.concat(args.hide);
        }
    });

    /**
     * hide elements
     */
    if(hiddenElements.length) { // 将Webdrivercss中的第二个配置参数中标注的元素的visibility设置为hidden。这里是为了忽略这些元素
        this.instance.selectorExecute(hiddenElements, function() {

            for(var i = 0; i < arguments.length; ++i) {
                for(var j = 0; j < arguments[i].length; ++j) {
                    arguments[i][j].style.visibility = 'hidden';
                }
            }

        }, function(){});
    }
    ...
}

继续刚才的代码,下面部分的代码首先会调用Webdriverio.pause()方法让浏览器等待100ms,然后在调用由Webdrivercss初始化进去的saveDocumentScreenshot指令,随后会将刚才不可见(忽略)的元素重新变的可见

makeScreenshot.js:49 ~ makeScreenshot.js:64

module.exports = function(done) {
    ...
    // 这里相当于调用了Webdriverio实例的pause方法
    // this.screenshot === context.screenshot (就是screenshotRoot + '/' + pagename + '.png')
    this.instance.pause(100).saveDocumentScreenshot(this.screenshot, done);

    /**
     * make hidden elements visible again
     */
    if(hiddenElements.length) { // 将刚才忽略的元素的显示属性在设置回来
        this.instance.selectorExecute(hiddenElements, function() {

            for(var i = 0; i < arguments.length; ++i) {
                for(var j = 0; j < arguments[i].length; ++j) {
                    arguments[i][j].style.visibility = 'visible';
                }
            }

        }, function(){});
    }
}

下面我们需要了解saveDocumentScreenshot()方法到底做了什么

documentScreenshot.js:30 ~ documentScreenshot.js:75

module.exports = function documentScreenshot(fileName) { // 接受一个文件名作为参数

    var ErrorHandler = this.instance.ErrorHandler; // 缓存一个Webdriverio的ErrorHandler

    /*!
     * make sure that callback contains chainit callback
     */
    var callback = arguments[arguments.length - 1]; // 取到回调函数done,下方存在:callback === done

    /*!
     * parameter check
     */
    if (typeof fileName !== 'string') { // 如果文件名不是String通过done函数回调错误信息,并结束整个waterfall流程
        return callback(new ErrorHandler.CommandError('number or type of arguments don\'t agree with saveScreenshot command'));
    }

    var self = this.instance,
        response = {
            execute: [],
            screenshot: []
        },
        tmpDir = __dirname + '/../.tmp',
        cropImages = [],
        currentXPos = 0,
        currentYPos = 0,
        screenshot = null,
        scrollFn = function(w, h) { // 屏幕滚动函数,就不说其原理了,看注释也知道IE6、7不行
            /**
             * IE8 or older
             */
            if(document.all && !document.addEventListener) {
                /**
                 * this still might not work
                 * seems that IE8 scroll back to 0,0 before taking screenshots
                 */
                document.body.style.marginTop = '-' + h + 'px';
                document.body.style.marginLeft = '-' + w + 'px';
                return;
            }

            document.body.style.webkitTransform = 'translate(-' + w + 'px, -' + h + 'px)';
            document.body.style.mozTransform = 'translate(-' + w + 'px, -' + h + 'px)';
            document.body.style.msTransform = 'translate(-' + w + 'px, -' + h + 'px)';
            document.body.style.oTransform = 'translate(-' + w + 'px, -' + h + 'px)';
            document.body.style.transform = 'translate(-' + w + 'px, -' + h + 'px)';
        };
    ...
}

下面部分的代码基本看注释也能理解什么意思,需要注意的是下面代码调用了Webdriverio.execute,这个函数会在当前浏览器中运行javascript代码并在完成时触发回调函数。此外async.whilst得到功能类似于while循环,直到第一个参数返回false才停止执行第二个参数函数

module.exports = function documentScreenshot(fileName) {
    ...
    async.waterfall([
        /*!
         * create tmp directory to cache viewport shots
         */
        function(cb) {
            fs.exists(tmpDir, function(exists) {
                return exists ? cb() : fs.mkdir(tmpDir, 0755, cb);
            });
        },

        /*!
         * prepare page scan
         */
        function() {
            var cb = arguments[arguments.length - 1]; // 获取async每一级的callback函数
            // 通过Webdriverio.execute()执行js代码来保证截图时不会出现滚动条,以及全屏滚动
            // 注意cb接受的是execute回调传入的两个参数(err, ret),err即运行js时的错误,若发生则整个Webdrivercss工作结束
            // ret为一个含有value属性的对象,value的值是所执行js的返回值,下例中的ret即为:
            /*  {
             *      value: {
             *          screenWidth: Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
             *          screenHeight: Math.max(document.documentElement.clientHeight, window.innerHeight || 0),
             *          documentWidth: document.documentElement.scrollWidth,
             *          documentHeight: document.documentElement.scrollHeight
             *      }
             *  }
             */
            self.execute(function() { 
                /**
                 * remove scrollbars
                 */
                document.body.style.overflow = "hidden";

                /**
                 * scroll back to start scanning
                 */
                window.scrollTo(0, 0);

                /**
                 * get viewport width/height and total width/height
                 */
                return {
                    screenWidth: Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
                    screenHeight: Math.max(document.documentElement.clientHeight, window.innerHeight || 0),
                    documentWidth: document.documentElement.scrollWidth,
                    documentHeight: document.documentElement.scrollHeight
                };
            }, cb);
        },

        /*!
         * take viewport shots and cache them into tmp dir
         */
        function(res, cb) {
            // res为上一部分cb传给这里的ret参数,即:
            /*  {
             *      value: {
             *          screenWidth: Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
             *          screenHeight: Math.max(document.documentElement.clientHeight, window.innerHeight || 0),
             *          documentWidth: document.documentElement.scrollWidth,
             *          documentHeight: document.documentElement.scrollHeight
             *      }
             *  }
             */
            // 这里将执行js后的document和screen大小缓存在response.executes数组中
            response.execute.push(res);

            /*!
             * run scan
             */
            async.whilst( // 循环执行直到第一个参数返回false

                /*!
                 * while expression
                 */
                function(callback) { // 循环判断函数:判断当前X是否到达页面最右侧
                    return (currentXPos < (response.execute[0].value.documentWidth / response.execute[0].value.screenWidth));
                },

                /*!
                 * loop function
                 */
                function(finishedScreenshot) { // finishedScreenshot等同于上面的循环判断函数
                    response.screenshot = [];

                    async.waterfall([

                        /*!
                         * take screenshot of viewport
                         */
                        self.screenshot.bind(self), // 给Webdriverio.screenshot()绑定之前的context上下文并执行

                        /*!
                         * cache image into tmp dir
                         */
                        function(res, cb) {
                            // 这里的res等同于这种写法得到得到res:Webdriverio.screenshot(function(res){}),
                            // 因此res为所接图像的base64编码
                            var file = tmpDir + '/' + currentXPos + '-' + currentYPos + '.png';
                            gm(new Buffer(res.value, 'base64')).crop(response.execute[0].value.screenWidth, response.execute[0].value.screenHeight, 0, 0).write(file, cb);
                            response.screenshot.push(res);

                            if (!cropImages[currentXPos]) {
                                cropImages[currentXPos] = [];
                            }

                            cropImages[currentXPos][currentYPos] = file;

                            currentYPos++;
                            if (currentYPos > Math.floor(response.execute[0].value.documentHeight / response.execute[0].value.screenHeight)) {
                                currentYPos = 0;
                                currentXPos++;
                            }
                        },

                        /*!
                         * scroll to next area
                         */
                        function() {
                            self.execute(scrollFn,
                                currentXPos * response.execute[0].value.screenWidth,
                                currentYPos * response.execute[0].value.screenHeight,
                                function(val, res) {
                                    response.execute.push(res);
                                }
                            ).pause(100).call(arguments[arguments.length - 1]);
                        }

                    ], finishedScreenshot);
                },
                cb
            );
        },

        /*!
         * concats all shots
         */
        function(cb) {
            var subImg = 0;

            async.eachSeries(cropImages, function(x, cb) {
                var col = gm(x.shift());
                col.append.apply(col, x);

                if (!screenshot) {
                    screenshot = col;
                    col.write(fileName, cb);
                } else {
                    col.write(tmpDir + '/' + (++subImg) + '.png', function() {
                        gm(fileName).append(tmpDir + '/' + subImg + '.png', true).write(fileName, cb);
                    });
                }
            }, cb);
        },

        /*!
         * crop screenshot regarding page size
         */
        function() {
            gm(fileName).crop(response.execute[0].value.documentWidth, response.execute[0].value.documentHeight, 0, 0).write(fileName, arguments[arguments.length - 1]);
        },

        /*!
         * remove tmp dir
         */
        function() {
            rimraf(tmpDir, arguments[arguments.length - 1]);
        },

        /*!
         * scroll back to start position
         */
        function(cb) {
            self.execute(scrollFn, 0, 0, cb);
        },

        /**
         * enable scrollbars again
         */
        function(res, cb) {
            response.execute.push(res);
            self.execute(function() {
                document.body.style.overflow = "visible";
            }, cb);
        }
    ], function(err) {
        callback(err, null, response);
    });
}

至此,关于截图部分的源头已经找到,使用了Webdriverio.screenshot()来截图,那么这个截图函数到底是基于什么原理做的呢?

Webdriverio.screenshot()

Webdriverio.screenshot()是基于JsonWireProtocol协议(WebDriver Wire Protocol)实现的一个功能

webdriverio/lib/protocol/screenshot.js

/**
 *
 * Take a screenshot of the current viewport. To get the screenshot of the whole page
 * use the action command `saveScreenshot`
 *
 * @returns {String} screenshot   The screenshot as a base64 encoded PNG.
 * @callbackParameter error, response
 *
 * @see  https://code.google.com/p/selenium/wiki/JsonWireProtocol#/session/:sessionId/screenshot
 * @type protocol
 *
 */

module.exports = function screenshot () {

    this.requestHandler.create(
        '/session/:sessionId/screenshot',
        arguments[arguments.length - 1]
    );
};

因此我们需要去了解JsonWireProtocol协议对于screenshot的定义和实现

我们都知道这个/session/:sessionId/screenshot这个GET请求是发给selenium服务器的,因此通过反编译selenium-server-standalone-2.37.0.jar得到其JAVA源码

org.openqa.selenium.server.SeleniumServer,处理了有关GET/POST请求部分

org.openqa.selenium.server.SeleniumDriverResourceHandler,中有关于不同的command获取的处理(/session/:sessionId/command),其中包含了对screenshot命令的处理

org.openqa.selenium.server.commands.CaptureScreenshotToStringCommand

package org.openqa.selenium.server.commands;

import java.awt.Rectangle;
import java.awt.Robot;
import java.awt.Toolkit;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.imageio.ImageIO;
import org.openqa.selenium.internal.Base64Encoder;
import org.openqa.selenium.server.RobotRetriever;

public class CaptureScreenshotToStringCommand
{
  public static final String ID = "captureScreenshotToString";
  private static final Logger log = Logger.getLogger(CaptureScreenshotToStringCommand.class
    .getName());

  public String execute()
  {
    try
    {
      return "OK," + captureAndEncodeSystemScreenshot();
    } catch (Exception e) {
      log.log(Level.SEVERE, "Problem capturing a screenshot to string", e);
      return "ERROR: Problem capturing a screenshot to string: " + e.getMessage();
    }
  }

  public String captureAndEncodeSystemScreenshot()
    throws InterruptedException, ExecutionException, TimeoutException, IOException
  {
    Robot robot = RobotRetriever.getRobot();
    Rectangle captureSize = new Rectangle(Toolkit.getDefaultToolkit().getScreenSize());
    BufferedImage bufferedImage = robot.createScreenCapture(captureSize);
    ByteArrayOutputStream outStream = new ByteArrayOutputStream();
    ImageIO.write(bufferedImage, "png", outStream);

    return new Base64Encoder().encode(outStream.toByteArray()); // 这里返回了Base64编码,印证了之前得到返回值
  }
}

org.openqa.selenium.server.RobotRetriever

package org.openqa.selenium.server;

import java.awt.Robot;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.logging.Logger;

public class RobotRetriever
{
  private static final Logger log = Logger.getLogger(RobotRetriever.class.getName());
  private static Robot robot;

  public static synchronized Robot getRobot()
    throws InterruptedException, ExecutionException, TimeoutException
  {
    if (robot != null) {
      return robot;
    }
    FutureTask robotRetriever = new FutureTask(new Retriever(null));
    log.info("Creating Robot");
    Thread retrieverThread = new Thread(robotRetriever, "robotRetriever");
    retrieverThread.start();
    robot = (Robot)robotRetriever.get(10L, TimeUnit.SECONDS);

    return robot;
  }

  private static class Retriever
    implements Callable<Robot>
  {
    public Robot call()
      throws Exception
    {
      return new Robot();
    }
  }
}

到此为止我们已经了解到Webdrivercss/Webdriverio的截图原理,最底层是使用java.awt.Robot.createScreenCapture截图,根本原理还是对桌面截图!解密完毕,其他待续~

 类似资料: