Skip to content
This repository has been archived by the owner on Jun 11, 2024. It is now read-only.

Commit

Permalink
Merge pull request #163 from Galooshi/no-crop
Browse files Browse the repository at this point in the history
Wait for images to load
  • Loading branch information
trotzig authored Oct 19, 2016
2 parents b361095 + 5c5550a commit 7e88aac
Show file tree
Hide file tree
Showing 11 changed files with 400 additions and 341 deletions.
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
Gemfile.lock
node_modules
lib/happo/public/HappoApp.bundle.js
lib/happo/public/HappoApp.bundle.js.map
lib/happo/public/*.bundle.*
lib/happo/public/*.worker.*
12 changes: 11 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,18 @@ rvm:
- 2.1
- 2.2

env:
- TRAVIS_NODE_VERSION="6"

before_install:
- "export DISPLAY=:99.0"
- "sh -e /etc/init.d/xvfb start"

script: bundle exec ruby headless_rspec.rb
install:
- 'rm -rf ~/.nvm && git clone https://github.com/creationix/nvm.git ~/.nvm && (cd ~/.nvm && git checkout `git describe --abbrev=0 --tags`) && source ~/.nvm/nvm.sh && nvm install $TRAVIS_NODE_VERSION'
- 'npm install && npm run build'
- bundle install

script:
- npm test
- bundle exec ruby headless_rspec.rb
10 changes: 8 additions & 2 deletions happo.gemspec
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
require './lib/happo/version'

system('npm', 'install')
system('npm', 'run', 'build')
puts
puts '====================================================='
puts
puts 'Hey! Are you publishing a new version of the gem? '
puts 'Remember to run `npm install && npm run build` first.'
puts
puts '====================================================='
puts

Gem::Specification.new do |s|
s.name = 'happo'
Expand Down
220 changes: 220 additions & 0 deletions js/src/HappoRunner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import getFullRect from './getFullRect';
import waitForImagesToRender from './waitForImagesToRender';

function handleError(currentExample, error) {
console.error(error.stack); // eslint-disable-line no-console
return {
description: currentExample.description,
error: error.message,
};
}

/**
* @param {Function} func The happo.describe function from the current
* example being rendered. This function takes a callback as an argument
* that is called when it is done.
* @return {Promise}
*/
function tryAsync(func) {
return new Promise((resolve, reject) => {
// Safety valve: if the function does not finish after 3s, then something
// went haywire and we need to move on.
const timeout = setTimeout(() => {
reject(new Error('Async callback was not invoked within timeout.'));
}, 3000);

// This function is called by the example when it is done executing.
const doneCallback = () => {
clearTimeout(timeout);
resolve();
};

func(doneCallback);
});
}

window.happo = {
defined: {},
fdefined: [],
errors: [],

define(description, func, options) {
// Make sure we don't have a duplicate description
if (this.defined[description]) {
throw new Error(
`Error while defining "${description}": Duplicate description detected`);
}
this.defined[description] = {
description,
func,
options: options || {},
};
},

fdefine(description, func, options) {
this.define(description, func, options); // add the example
this.fdefined.push(description);
},

/**
* @return {Array.<Object>}
*/
getAllExamples() {
const descriptions = this.fdefined.length ?
this.fdefined :
Object.keys(this.defined);

return descriptions.map((description) => {
const example = this.defined[description];
// We return a subset of the properties of an example (only those relevant
// for happo_runner.rb).
return {
description: example.description,
options: example.options,
};
});
},

/**
* Clean up the DOM for a rendered element that has already been processed.
* This can be overridden by consumers to define their own clean out method,
* which can allow for this to be used to unmount React components, for
* example.
*/
cleanOutElement() {
},

/**
* This function is called from Ruby asynchronously. Therefore, we need to
* call doneFunc when the method has completed so that Ruby knows to continue.
*
* @param {String} exampleDescription
* @param {Function} doneFunc injected by driver.execute_async_script in
* happo/runner.rb
*/
renderExample(exampleDescription, doneFunc) {
const currentExample = this.defined[exampleDescription];

try {
if (!currentExample) {
throw new Error(
`No example found with description "${exampleDescription}"`);
}

// Clear out the body of the document
while (document.body.firstChild) {
if (document.body.firstChild instanceof Element) {
this.cleanOutElement(document.body.firstChild);
}
document.body.removeChild(document.body.firstChild);
}

const { func } = currentExample;
if (func.length) {
// The function takes an argument, which is a callback that is called
// once it is done executing. This can be used to write functions that
// have asynchronous code in them.
tryAsync(func).then(() => {
this.processExample(currentExample).then(doneFunc).catch(doneFunc);
}).catch((error) => {
doneFunc(handleError(currentExample, error));
});
} else {
// The function does not take an argument, so we can run it
// synchronously.
const result = func();

if (result instanceof Promise) {
// The function returned a promise, so we need to wait for it to
// resolve before proceeding.
result.then(() => {
this.processExample(currentExample).then(doneFunc).catch(doneFunc);
}).catch((error) => {
doneFunc(handleError(currentExample, error));
});
} else {
// The function did not return a promise, so we assume it gave us an
// element that we can process immediately.
this.processExample(currentExample).then(doneFunc).catch(doneFunc);
}
}
} catch (error) {
doneFunc(handleError(currentExample, error));
}
},

/**
* Gets the DOM elements that we will use as source for the snapshot. The
* default version simply gets the direct children of document.body, but you
* can override this method to better control the root nodes.
*
* @return {Array|NodeList}
*/
getRootNodes() {
return document.body.children;
},

/**
* @return {Promise}
*/
processExample(currentExample) {
return new Promise((resolve, reject) => {
waitForImagesToRender().then(() => {
try {
const rootNodes = this.getRootNodes();
const {
height,
left,
top,
width,
} = getFullRect(rootNodes);

resolve({
description: currentExample.description,
height,
left,
top,
width,
});
} catch (error) {
reject(handleError(currentExample, error));
}
}).catch((error) => {
reject(handleError(currentExample, error));
});
});
},
};

window.addEventListener('load', () => {
const matches = window.location.search.match(/description=([^&]*)/);
if (!matches) {
return;
}
const example = decodeURIComponent(matches[1]);
window.happo.renderExample(example, () => {});
});

// We need to redefine a few global functions that halt execution. Without this,
// there's a chance that the Ruby code can't communicate with the browser.
window.alert = (message) => {
console.log('`window.alert` called', message); // eslint-disable-line
};

window.confirm = (message) => {
console.log('`window.confirm` called', message); // eslint-disable-line
return true;
};

window.prompt = (message, value) => {
console.log('`window.prompt` called', message, value); // eslint-disable-line
return null;
};

window.onerror = (message, url, lineNumber) => {
window.happo.errors.push({
message,
url,
lineNumber,
});
};
88 changes: 88 additions & 0 deletions js/src/getFullRect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import removeScrollbars from './removeScrollbars';

/**
* Wrapper around Math.min to handle undefined values.
*/
function min(a, b) {
if (a === undefined) {
return b;
}
return Math.min(a, b);
}

// This function takes a node and a box object that we will mutate.
function getFullRectRecursive(node, box) {
// Since we are already traversing through every node, let's piggyback on
// that work and remove scrollbars to prevent spurious diffs.
removeScrollbars(node);

const rect = node.getBoundingClientRect();

/* eslint-disable no-param-reassign */
box.bottom = Math.max(box.bottom, rect.bottom);
box.left = min(box.left, rect.left);
box.right = Math.max(box.right, rect.right);
box.top = min(box.top, rect.top);
/* eslint-enable no-param-reassign */

for (let i = 0; i < node.children.length; i++) {
getFullRectRecursive(node.children[i], box);
}
}

// This function gets the full size of children in the document body,
// including all descendent nodes. This allows us to ensure that the
// screenshot includes absolutely positioned elements. It is important that
// this is fast, since we may be iterating over a high number of nodes.
export default function getFullRect(rootNodes) {
// Set up the initial object that we will mutate in our recursive function.
const box = {
bottom: 0,
left: undefined,
right: 0,
top: undefined,
};

// If there are any children, we want to iterate over them recursively,
// mutating our box object along the way to expand to include all descendent
// nodes.
// Remember! rootNodes can be either an Array or a NodeList.
for (let i = 0; i < rootNodes.length; i++) {
const node = rootNodes[i];

getFullRectRecursive(node, box);

// getBoundingClientRect does not include margin, so we need to use
// getComputedStyle. Since this is slow and the margin of descendent
// elements is significantly less likely to matter, let's include the
// margin only from the topmost nodes.
const computedStyle = window.getComputedStyle(node);
box.bottom += parseFloat(computedStyle.getPropertyValue('margin-bottom'));
box.left -= parseFloat(computedStyle.getPropertyValue('margin-left'));
box.right += parseFloat(computedStyle.getPropertyValue('margin-right'));
box.top -= parseFloat(computedStyle.getPropertyValue('margin-top'));
}

// Since getBoundingClientRect() and margins can contain subpixel values, we
// want to round everything before calculating the width and height to
// ensure that we will take a screenshot of the entire component.
box.bottom = Math.ceil(box.bottom);
box.left = Math.floor(box.left);
box.right = Math.ceil(box.right);
box.top = Math.floor(box.top);

// As the last step, we calculate the width and height for the box. This is
// to avoid having to do them for every node. Before we do that however, we
// cut off things that render off the screen to the top or left, since those
// won't be in the screenshot file that we then crop from. If you're
// wondering why right and bottom isn't "fixed" here too, it's because we
// don't have to since the screenshot already includes overflowing content
// on the bottom and right.
box.left = Math.max(box.left, 0);
box.top = Math.max(box.top, 0);

box.width = box.right - box.left;
box.height = box.bottom - box.top;

return box;
}
30 changes: 30 additions & 0 deletions js/src/removeScrollbars.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
function isAutoOrScroll(overflow) {
return overflow === 'auto' || overflow === 'scroll';
}

// Scrollbars inside of elements may cause spurious visual diffs. To avoid
// this issue, we can hide them automatically by styling the overflow to be
// hidden.
export default function removeScrollbars(node) {
const isOverflowing =
node.scrollHeight !== node.clientHeight
|| node.scrollWidth !== node.clientWidth;

if (!isOverflowing) {
// This node has no overflowing content. We're returning early to prevent
// calling getComputedStyle down below (which is an expensive operation).
return;
}

const style = window.getComputedStyle(node);
if (
isAutoOrScroll(style.getPropertyValue('overflow-y'))
|| isAutoOrScroll(style.getPropertyValue('overflow-x'))
|| isAutoOrScroll(style.getPropertyValue('overflow'))
) {
// We style this via node.style.cssText so that we can override any styles
// that might already be `!important`.
// eslint-disable-next-line no-param-reassign
node.style.cssText += 'overflow: hidden !important';
}
}
Loading

0 comments on commit 7e88aac

Please sign in to comment.