Skip to content

Commit

Permalink
Add {request,cancel}AnimationFrame and pretendToBeVisual
Browse files Browse the repository at this point in the history
Fixes #1963.
  • Loading branch information
SimenB authored and domenic committed Oct 29, 2017
1 parent a3594bc commit 8a6894c
Show file tree
Hide file tree
Showing 11 changed files with 199 additions and 19 deletions.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,26 @@ Note that we strongly advise against trying to "execute scripts" by mashing toge

Finally, for advanced use cases you can use the `dom.runVMScript(script)` method, documented below.

### Pretending to be a visual browser

jsdom does not have the capability to render visual content, and will act like a headless browser by default. It provides hints to web pages through APIs such as `document.hidden` that their content is not visible.

When the `pretendToBeVisual` option is set to `true`, jsdom will pretend that it is rendering and displaying content. It does this by:

* Changing `document.hidden` to return `true` instead of `false`
* Changing `document.visibilityState` to return `"visible"` instead of `"prerender"`
* Enabling `window.requestAnimationFrame()` and `window.cancelAnimationFrame()` methods, which otherwise do not exist

```js
const window = (new JSDOM(``, { pretendToBeVisual: true })).window;

window.requestAnimationFrame(timestamp => {
console.log(timestamp > 0);
});
```

Note that jsdom still [does not do any layout or rendering](#unimplemented-parts-of-the-web-platform), so this is really just about _pretending_ to be visual, not about implementing the parts of the platform a real, visual web browser would implement.

### Loading subresources

By default, jsdom will not load any subresources such as scripts, stylesheets, images, or iframes. If you'd like jsdom to load such resources, you can pass the `resources: "usable"` option, which will load all usable resources. Those are:
Expand Down
5 changes: 5 additions & 0 deletions lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ function transformOptions(options, encoding) {
parseOptions: { locationInfo: false },
runScripts: undefined,
encoding,
pretendToBeVisual: false,

// Defaults filled in later
virtualConsole: undefined,
Expand Down Expand Up @@ -310,6 +311,10 @@ function transformOptions(options, encoding) {
transformed.beforeParse = options.beforeParse;
}

if (options.pretendToBeVisual !== undefined) {
transformed.windowOptions.pretendToBeVisual = Boolean(options.pretendToBeVisual);
}

// concurrentNodeIterators??

return transformed;
Expand Down
73 changes: 56 additions & 17 deletions lib/jsdom/browser/Window.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ const { matchesDontThrow } = require("../living/helpers/selectors");
const SessionHistory = require("../living/window/SessionHistory");
const { contextifyWindow } = require("./documentfeatures.js");

// Browserify's process implementation doesn't have hrtime, and this package is small so not much of a burden for
// Node.js users.
const hrtime = require("browser-process-hrtime");

const GlobalEventHandlersImpl = require("../living/nodes/GlobalEventHandlers-impl").implementation;
const WindowEventHandlersImpl = require("../living/nodes/WindowEventHandlers-impl").implementation;

Expand All @@ -43,6 +47,8 @@ dom.Window = Window;
function Window(options) {
EventTarget.setup(this);

const windowInitialized = hrtime();

this._initGlobalEvents();

const window = this;
Expand All @@ -62,11 +68,12 @@ function Window(options) {

///// PRIVATE DATA PROPERTIES

// vm initialization is defered until script processing is activated
// vm initialization is deferred until script processing is activated
this._globalProxy = this;
Object.defineProperty(idlUtils.implForWrapper(this), idlUtils.wrapperSymbol, { get: () => this._globalProxy });

this.__timers = Object.create(null);
let timers = Object.create(null);
let animationFrameCallbacks = Object.create(null);

// List options explicitly to be clear which are passed through
this._document = Document.create([], {
Expand Down Expand Up @@ -127,6 +134,8 @@ function Window(options) {
// HTMLFrameElement implementation.
this._length = 0;

this._pretendToBeVisual = options.pretendToBeVisual;

///// GETTERS

const external = External.create();
Expand Down Expand Up @@ -182,24 +191,55 @@ function Window(options) {
///// METHODS

let latestTimerId = 0;
let latestAnimationFrameCallbackId = 0;

this.setTimeout = function (fn, ms) {
const args = [];
for (let i = 2; i < arguments.length; ++i) {
args[i - 2] = arguments[i];
}
return startTimer(window, setTimeout, clearTimeout, ++latestTimerId, fn, ms, args);
return startTimer(window, setTimeout, clearTimeout, ++latestTimerId, fn, ms, timers, args);
};
this.setInterval = function (fn, ms) {
const args = [];
for (let i = 2; i < arguments.length; ++i) {
args[i - 2] = arguments[i];
}
return startTimer(window, setInterval, clearInterval, ++latestTimerId, fn, ms, args);
return startTimer(window, setInterval, clearInterval, ++latestTimerId, fn, ms, timers, args);
};
this.clearInterval = stopTimer.bind(this, timers);
this.clearTimeout = stopTimer.bind(this, timers);

if (this._pretendToBeVisual) {
this.requestAnimationFrame = fn => {
const hr = hrtime(windowInitialized);
const hrInMicro = hr[0] * 1e3 + hr[1] / 1e6;
const fps = 1000 / 60;

return startTimer(
window,
setTimeout,
clearTimeout,
++latestAnimationFrameCallbackId,
fn,
fps,
animationFrameCallbacks,
[hrInMicro]
);
};
this.cancelAnimationFrame = stopTimer.bind(this, animationFrameCallbacks);
}

this.__stopAllTimers = function () {
stopAllTimers(timers);
stopAllTimers(animationFrameCallbacks);

latestTimerId = 0;
latestAnimationFrameCallbackId = 0;

timers = Object.create(null);
animationFrameCallbacks = Object.create(null);
};
this.clearInterval = stopTimer.bind(this, window);
this.clearTimeout = stopTimer.bind(this, window);
this.__stopAllTimers = stopAllTimers.bind(this, window);

function Option(text, value, defaultSelected, selected) {
if (text === undefined) {
Expand Down Expand Up @@ -377,7 +417,7 @@ function Window(options) {
delete this._document;
}

stopAllTimers(currentWindow);
this.__stopAllTimers();
};

this.getComputedStyle = function (node) {
Expand Down Expand Up @@ -527,7 +567,7 @@ Object.defineProperty(Window.prototype, Symbol.toStringTag, {
configurable: true
});

function startTimer(window, startFn, stopFn, timerId, callback, ms, args) {
function startTimer(window, startFn, stopFn, timerId, callback, ms, timerStorage, args) {
if (!window || !window._document) {
return undefined;
}
Expand All @@ -546,24 +586,23 @@ function startTimer(window, startFn, stopFn, timerId, callback, ms, args) {
};

const res = startFn(callback, ms);
window.__timers[timerId] = [res, stopFn];
timerStorage[timerId] = [res, stopFn];
return timerId;
}

function stopTimer(window, id) {
const timer = window.__timers[id];
function stopTimer(timerStorage, id) {
const timer = timerStorage[id];
if (timer) {
// Need to .call() with undefined to ensure the thisArg is not timer itself
timer[1].call(undefined, timer[0]);
delete window.__timers[id];
delete timerStorage[id];
}
}

function stopAllTimers(window) {
Object.keys(window.__timers).forEach(key => {
const timer = window.__timers[key];
function stopAllTimers(timers) {
Object.keys(timers).forEach(key => {
const timer = timers[key];
// Need to .call() with undefined to ensure the thisArg is not timer itself
timer[1].call(undefined, timer[0]);
});
window.__timers = Object.create(null);
}
8 changes: 8 additions & 0 deletions lib/jsdom/living/nodes/Document-impl.js
Original file line number Diff line number Diff line change
Expand Up @@ -806,10 +806,18 @@ class DocumentImpl extends NodeImpl {
}

get hidden() {
if (this._defaultView && this._defaultView._pretendToBeVisual) {
return false;
}

return true;
}

get visibilityState() {
if (this._defaultView && this._defaultView._pretendToBeVisual) {
return "visible";
}

return "prerender";
}
}
Expand Down
9 changes: 8 additions & 1 deletion lib/old-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ exports.jsdom = function (html, options) {
options.runScripts = "dangerously";
}

if (options.pretendToBeVisual !== undefined) {
options.pretendToBeVisual = Boolean(options.pretendToBeVisual);
} else {
options.pretendToBeVisual = false;
}

// List options explicitly to be clear which are passed through
const window = new Window({
parsingMode: options.parsingMode,
Expand All @@ -143,7 +149,8 @@ exports.jsdom = function (html, options) {
strictSSL: options.strictSSL,
proxy: options.proxy,
userAgent: options.userAgent,
runScripts: options.runScripts
runScripts: options.runScripts,
pretendToBeVisual: options.pretendToBeVisual
});

const documentImpl = idlUtils.implForWrapper(window.document);
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"acorn": "^5.1.2",
"acorn-globals": "^4.0.0",
"array-equal": "^1.0.0",
"browser-process-hrtime": "^0.1.2",
"content-type-parser": "^1.0.1",
"cssom": ">= 0.3.2 < 0.4.0",
"cssstyle": ">= 0.2.37 < 0.3.0",
Expand Down
37 changes: 37 additions & 0 deletions test/api/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -241,4 +241,41 @@ describe("API: constructor options", () => {
assert.strictEqual(windowPassed, dom.window);
});
});

describe("pretendToBeVisual", () => {
describe("not set", () => {
it("document should be hidden and in prerender", () => {
const { document } = (new JSDOM(``)).window;

assert.strictEqual(document.hidden, true);
assert.strictEqual(document.visibilityState, "prerender");
});

it("document should not have rAF", () => {
const { window } = new JSDOM(``);

assert.isUndefined(window.requestAnimationFrame);
assert.isUndefined(window.cancelAnimationFrame);
});
});

describe("set to true", () => {
it("document should be not be hidden and be visible", () => {
const { document } = (new JSDOM(``, { pretendToBeVisual: true })).window;

assert.strictEqual(document.hidden, false);
assert.strictEqual(document.visibilityState, "visible");
});

it("document should call rAF", { async: true }, context => {
const { window } = new JSDOM(``, { pretendToBeVisual: true });

window.requestAnimationFrame(() => {
context.done();
});

// Further functionality tests are in web platform tests
});
});
});
});
3 changes: 2 additions & 1 deletion test/web-platform-tests/run-single-wpt.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,8 @@ function createJSDOM(urlPrefix, testPath) {
if (error) {
doneErrors.push(error);
}
}
},
pretendToBeVisual: true
});
});

Expand Down
6 changes: 6 additions & 0 deletions test/web-platform-tests/to-run.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,12 @@ serializing.html: [fail, Unknown]

---

DIR: html/webappapis/animation-frames

idlharness.html: [fail, "Unknown ('ReferenceError: WebIDL2 is not defined', but why?)"]

---

DIR: html/webappapis/atob

---
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<meta charset="utf-8">
<title>requestAnimationFrame/cancelAnimationFrame: must not interfere with timers, or vice-versa</title>
<link rel="help" href="https://html.spec.whatwg.org/multipage/#dom-window-requestanimationframe">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>

<script>
"use strict";

test(() => {
// https://html.spec.whatwg.org/multipage/imagebitmap-and-animations.html#animation-frames
// requires incrementing animation frame callback identifiers by 1 each time, and starting at 1.
// We can thus test that pretty rigorously.
//
// However https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timers
// only requires "a user-agent-defined integer that is greater than zero" for timers, so we can't
// test much there.
//
// The main content of the test is that the RAF IDs don't get perturbed by interleaving with
// setTimeout/setInterval calls, anyway.

window.setTimeout(() => {}, 0);
const firstRafId = window.requestAnimationFrame(() => {});

window.setTimeout(() => {}, 0);
const rafId1 = window.requestAnimationFrame(() => {});
window.setTimeout(() => {}, 0);
window.setInterval(() => {}, 0);
const rafId2 = window.requestAnimationFrame(() => {});

assert_equals(firstRafId, 1);
assert_equals(rafId1, 2);
assert_equals(rafId2, 3);
}, "Animation frame IDs must be unaffected by timer IDs");

async_test(t => {
window.clearTimeout(window.requestAnimationFrame(t.step_func_done()));
}, "Animation frame callbacks must still be invoked even if passed to clearTimeout");

async_test(t => {
window.clearInterval(window.requestAnimationFrame(t.step_func_done()));
}, "Animation frame callbacks must still be invoked even if passed to clearInterval");

async_test(t => {
window.cancelAnimationFrame(window.setTimeout(t.step_func_done()));
}, "setTimeout callbacks must still be invoked even if passed to cancelAnimationFrame");

async_test(t => {
window.cancelAnimationFrame(window.setInterval(t.step_func_done()));
}, "setInterval callbacks must still be invoked even if passed to cancelAnimationFrame");
</script>
4 changes: 4 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,10 @@ browser-pack@^6.0.1:
through2 "^2.0.0"
umd "^3.0.0"

browser-process-hrtime@^0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-0.1.2.tgz#425d68a58d3447f02a04aa894187fce8af8b7b8e"

browser-resolve@^1.11.0, browser-resolve@^1.7.0:
version "1.11.2"
resolved "https://registry.yarnpkg.com/browser-resolve/-/browser-resolve-1.11.2.tgz#8ff09b0a2c421718a1051c260b32e48f442938ce"
Expand Down

0 comments on commit 8a6894c

Please sign in to comment.