@wdio/image-comparison-core
Version:
Image comparison core module for @wdio/visual-service - WebdriverIO visual testing framework
276 lines (275 loc) • 11 kB
JavaScript
/**
* This method is based on this blog post
* https://vivrichards.co.uk/accessibility/automating-page-tab-flows-using-visual-testing-and-javascript
* by Viv Richards and optimized for using Canvas
*/
export default function drawTabbableOnCanvas(drawOptions) {
// 1. Scroll to top of page
window.scrollTo(0, 0);
// 2. Insert canvas
const width = window.innerWidth;
const height = getDocumentScrollHeight();
const canvasNode = `<canvas id="wic-tabbable-canvas" width="${width}" height="${height}" style="position:absolute;top:0;left:0;z-index:999999;">`;
document.body.insertAdjacentHTML('afterbegin', canvasNode);
// 3. Get all the elements
const accessibleElements = tabbable();
// 4a. Iterate over all accessibleElements and get the coordinates
const elementCoordinates = accessibleElements.map((node) => {
const currentElement = node.getBoundingClientRect();
return {
x: currentElement.left + currentElement.width / 2,
y: currentElement.top + currentElement.height / 2,
};
});
// 4b. Add the starting coordinates
elementCoordinates.unshift({ x: 0, y: 0 });
// 4c. Iterate over all coordinates and draw lines and circles
elementCoordinates.forEach((elementCoordinate, i) => {
if (i === 0) {
return;
}
drawLine(drawOptions.line, elementCoordinates[i - 1], elementCoordinate);
drawCircleAndNumber(drawOptions.circle, elementCoordinate, i);
});
/**
* Draw a line
*/
function drawLine(options, start, end) {
const tabbableCanvasContext = document.getElementById('wic-tabbable-canvas').getContext('2d');
if (!tabbableCanvasContext) {
return;
}
// Draw the line
tabbableCanvasContext.beginPath();
tabbableCanvasContext.globalCompositeOperation = 'destination-over';
tabbableCanvasContext.lineWidth = options.width;
tabbableCanvasContext.strokeStyle = options.color;
tabbableCanvasContext.moveTo(start.x, start.y);
tabbableCanvasContext.lineTo(end.x, end.y);
tabbableCanvasContext.stroke();
}
/**
* Draw a circle
*/
function drawCircleAndNumber(options, position, i) {
const tabbableCanvasContext = document.getElementById('wic-tabbable-canvas').getContext('2d');
if (!tabbableCanvasContext) {
return;
}
// Draw circle
tabbableCanvasContext.beginPath();
tabbableCanvasContext.globalCompositeOperation = 'source-over';
tabbableCanvasContext.fillStyle = options.backgroundColor;
tabbableCanvasContext.arc(position.x, position.y, options.size, 0, Math.PI * 2, true);
tabbableCanvasContext.fill();
// Draw border
tabbableCanvasContext.lineWidth = options.borderWidth;
tabbableCanvasContext.strokeStyle = options.borderColor;
tabbableCanvasContext.stroke();
if (options.showNumber) {
// Set the text
tabbableCanvasContext.font = `${options.fontSize}px ${options.fontFamily}`;
tabbableCanvasContext.textAlign = 'center';
tabbableCanvasContext.textBaseline = 'middle';
tabbableCanvasContext.fillStyle = options.fontColor;
tabbableCanvasContext.fillText(i.toString(), position.x, position.y);
}
}
/**
* Below code is coming from https://github.com/davidtheclark/tabbable
* and is modified a bit to work inside the browser.
* The original module couldn't be used for injection and didn't support TypeScript
*/
/**
* Get all tabbable elements based on tabindex and then regular dom order
*/
function tabbable() {
const regularTabbables = [];
const orderedTabbables = [];
const candidateSelectors = [
'input',
'select',
'textarea',
'a[href]',
'button',
'[tabindex]',
'audio[controls]',
'video[controls]',
'[contenteditable]:not([contenteditable="false"])',
].join(',');
const candidates = document.querySelectorAll(candidateSelectors);
for (let i = 0; i < candidates.length; i++) {
const candidate = candidates[i];
if (!isNodeMatchingSelectorTabbable(candidate)) {
continue;
}
const candidateTabindex = getTabindex(candidate);
if (candidateTabindex === 0) {
regularTabbables.push(candidate);
}
else {
orderedTabbables.push({
documentOrder: i,
tabIndex: candidateTabindex,
node: candidate,
});
}
}
return Array.prototype.slice.call(orderedTabbables
.sort(sortOrderedTabbables)
.map((a) => a.node)
.concat(regularTabbables));
}
/**
* Is the node tabbable
*/
function isNodeMatchingSelectorTabbable(node) {
return !(!isNodeMatchingSelectorFocusable(node) || isNonTabbableRadio(node) || getTabindex(node) < 0);
}
/**
* Check if the node has a focused state
*/
function isNodeMatchingSelectorFocusable(node) {
return !(node.hasAttribute('disabled') || node.getAttribute('disabled') || isHiddenInput(node) || isHidden(node));
}
/**
* Get the tab index of the node
*/
function getTabindex(node) {
const tabindexAttr = parseInt(node.getAttribute('tabindex'), 10);
if (!isNaN(tabindexAttr)) {
return tabindexAttr;
}
// Browsers do not return `tabIndex` correctly for contentEditable nodes;
// so if they don't have a tabindex attribute specifically set, assume it's 0.
// TODO: Lines 173-174 are currently untestable with the current setup
// The radio input with no name case is hard to test through the public API
if (isContentEditable(node)) {
return 0;
}
return node.tabIndex;
}
/**
* Return ordered tabbable nodes
*/
function sortOrderedTabbables(nodeA, nodeB) {
// TODO: Lines 187-191 are currently untestable with the current setup
// The findHighestNode function is hard to test through the public API
return nodeA.tabIndex === nodeB.tabIndex
? // This is so bad :(, fix this!
nodeA.documentOrder - nodeB.documentOrder
: nodeA.tabIndex - nodeB.tabIndex;
}
/**
* Is the content editable
*/
function isContentEditable(node) {
return node.contentEditable === 'true';
}
/**
* Is the node an input
*/
function isInput(node) {
return node.tagName === 'INPUT';
}
/**
* Is the input hidden
*/
function isHiddenInput(node) {
return isInput(node) && node.type === 'hidden';
}
/**
* Is the node a radio input
*/
function isRadio(node) {
return isInput(node) && node.type === 'radio';
}
/**
* Is the node a radio input and can it be tabbed
*/
function isNonTabbableRadio(node) {
return isRadio(node) && !isTabbableRadio(node);
}
/**
* Get the checked radio input
*/
// @ts-ignore
function getCheckedRadio(nodes) {
for (let i = 0; i < nodes.length; i++) {
if (nodes[i].checked) {
return nodes[i];
}
}
}
/**
* Is the radio input tabbable
*/
function isTabbableRadio(node) {
if (!node.name) {
return true;
}
// This won't account for the edge case where you have radio groups with the same
// in separate forms on the same page.
// This is bad :(, but don't know how to fix this typing
const radioSet = node.ownerDocument.querySelectorAll(`input[type="radio"][name="${node.name}"]`);
const checked = getCheckedRadio(radioSet);
return !checked || checked === node;
}
/**
* Is the node hidden
*/
function isHidden(node) {
// offsetParent being null will allow detecting cases where an element is invisible or inside an invisible element,
// as long as the element does not use position: fixed. For them, their visibility has to be checked directly as well.
return node.offsetParent === null || getComputedStyle(node).visibility === 'hidden';
}
/**
* Get the document scroll height
*/
function getDocumentScrollHeight() {
const viewPortHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
const scrollHeight = document.documentElement.scrollHeight;
const bodyScrollHeight = document.body.scrollHeight;
// In some situations the default scrollheight can be equal to the viewport height
// but the body scroll height can be different, then return that one
if (viewPortHeight === scrollHeight && bodyScrollHeight > scrollHeight) {
return bodyScrollHeight;
}
// In some cases we can have a challenge determining the height of the page
// due to for example a `vh` property on the body element.
// If that is the case we need to walk over all the elements and determine the highest element
// this is a very time consuming thing, so our last hope :(
let pageHeight = 0;
let largestNodeElement = document.querySelector('body');
// TODO: Lines 288-293 are currently untestable with the current setup
if (bodyScrollHeight === scrollHeight && bodyScrollHeight === viewPortHeight) {
findHighestNode(document.documentElement.childNodes);
// There could be some elements above this largest element,
// add that on top
return pageHeight + largestNodeElement?.getBoundingClientRect().top;
}
// The scrollHeight is good enough
return scrollHeight;
/**
* Find the largest html element on the page
*/
// This is so bad :(, fix the typings!!!
function findHighestNode(nodesList) {
// TODO: Lines 304-319 are currently untestable with the current setup
for (let i = nodesList.length - 1; i >= 0; i--) {
const currentNode = nodesList[i];
/* istanbul ignore next */
if (currentNode.scrollHeight && currentNode.clientHeight) {
const elHeight = Math.max(currentNode.scrollHeight, currentNode.clientHeight);
pageHeight = Math.max(elHeight, pageHeight);
if (elHeight === pageHeight) {
largestNodeElement = currentNode;
}
}
if (currentNode.childNodes.length) {
findHighestNode(currentNode.childNodes);
}
}
}
}
}