chromium/headless/test/data/protocol/helpers/virtual-time-controller.js

// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * A helper class to manage virtual time and automatically generate animation
 * frames within the granted virtual time interval.
 */
(class VirtualTimeController {
  /**
   * @param {!TestRunner} testRunner Host TestRunner instance.
   * @param {!Proxy} dp DevTools session protocol instance.
   * @param {?number} animationFrameInterval in milliseconds, integer.
   * @param {?number} maxTaskStarvationCount Specifies the maximum number of
   *     tasks that can be run before virtual time is forced forward to prevent
   *     deadlock.
   */
  constructor(testRunner, dp, animationFrameInterval, maxTaskStarvationCount) {
    this.testRunner_ = testRunner;
    this.dp_ = dp;
    this.animationFrameInterval_ = animationFrameInterval || 16;
    this.maxTaskStarvationCount_ = maxTaskStarvationCount || 100 * 1000;
    this.virtualTimeBase_ = 0;
    this.remainingBudget_ = 0;
    this.lastGrantedChunk_ = 0;
    this.totalElapsedTime_ = 0;
    this.chunkExpired_ = null;

    this.dp_.Emulation.onVirtualTimeBudgetExpired(async data => {
      this.totalElapsedTime_ += this.lastGrantedChunk_;
      this.remainingBudget_ -= this.lastGrantedChunk_;
      if (this.remainingBudget_ === 0) {
        if (this.onExpired_) {
          this.onExpired_(this.totalElapsedTime_);
        }
      } else {
        await this.issueAnimationFrameAndScheduleNextChunk_();
      }
    });
  }

  /**
   * Grants initial portion of virtual time.
   * @param {number} initialVirtualTime Initial virtual time in milliseconds.
   * @return {number} virtual time base
   */
  async initialize(initialVirtualTime) {
    // Pause for the first time and remember base virtual time.
    this.virtualTimeBase_ = (await this.dp_.Emulation.setVirtualTimePolicy(
        {initialVirtualTime, policy: 'pause'}))
        .result.virtualTimeTicksBase;
    // Renderer wants the very first frame to be fully updated.
    await this.dp_.HeadlessExperimental.beginFrame({
        noDisplayUpdates: false,
        frameTimeTicks: this.virtualTimeBase_});

    return this.virtualTimeBase_;
  }

  /**
   * Grants additional virtual time.
   * @param {number} budget Virtual time budget in milliseconds.
   * @return {Promise} promise that resolves when chunk expires
   */
  async grantTime(budget) {
    this.remainingBudget_ = budget;
    const chunkExpired = new Promise(fulfill => {this.onExpired_ = fulfill});
    await this.issueAnimationFrameAndScheduleNextChunk_();
    await chunkExpired;
    return this.totalElapsedTime_;
  }

  /**
   * Retrieves current frame time to be used in beginFrame calls.
   * @return {number} Frame time in milliseconds.
   */
  currentFrameTime() {
    return this.virtualTimeBase_ + this.totalElapsedTime_;
  }

  /**
   * @return {number} Total virtual time elapsed.
   */
  elapsedTime() {
    return this.totalElapsedTime_;
  }

  /**
   * Revokes any granted virtual time, resulting in no more animation frames
   * being issued and final OnExpired call being made.
   */
  stopVirtualTimeGracefully() {
    if (this.remainingBudget_) {
      this.remainingBudget_ = 0;
    }
  }

  /**
   * Capture screenshot of the entire screen and return a 2d graphics context
   * that has the resulting screenshot painted.
   */
  async captureScreenshot() {
    const frameTimeTicks = this.currentFrameTime();
    const screenshotData =
        (await this.dp_.HeadlessExperimental.beginFrame(
            {frameTimeTicks, screenshot: {format: 'png'}}))
        .result.screenshotData;
    // Advance virtual time a bit so that next frame timestamp is greater.
    if (!screenshotData)
      return null;
    this.virtualTimeBase_ += 0.01;
    const image = new Image();
    await new Promise(fulfill => {
      image.onload = fulfill;
      image.src = `data:image/png;base64,${screenshotData}`;
    });
    this.testRunner_.log(
        `Screenshot size: ${image.naturalWidth} x ${image.naturalHeight}`);
    const canvas = document.createElement('canvas');
    canvas.width = image.naturalWidth;
    canvas.height = image.naturalHeight;
    const ctx = canvas.getContext('2d');
    ctx.drawImage(image, 0, 0);
    return ctx;
  }

  async issueAnimationFrameAndScheduleNextChunk_() {
    if (this.totalElapsedTime_ > 0 && this.remainingBudget_ > 0) {
      const remainder = this.totalElapsedTime_ % this.animationFrameInterval_;
      if (remainder === 0) {  // at the frame boundary?
        const frameTimeTicks = this.virtualTimeBase_ + this.totalElapsedTime_;
        await this.dp_.HeadlessExperimental.beginFrame(
            {frameTimeTicks, noDisplayUpdates: true});
      }
    }
    await this.scheduleNextChunk_();
  }

  async scheduleNextChunk_() {
    const lastFrame = this.totalElapsedTime_ % this.animationFrameInterval_;
    const nextAnimationFrame = this.animationFrameInterval_ - lastFrame;
    const chunk = Math.min(nextAnimationFrame, this.remainingBudget_);
    await this.dp_.Emulation.setVirtualTimePolicy(
        {policy: 'pauseIfNetworkFetchesPending', budget: chunk,
        maxVirtualTimeTaskStarvationCount: this.maxTaskStarvationCount_});
    this._chunkExpired = this.dp_.Emulation.onceVirtualTimeBudgetExpired();
    this.lastGrantedChunk_ = chunk;
  }
});