@skyux-sdk/testing
Version:
This library was generated with [Nx](https://nx.dev).
628 lines (621 loc) • 23.7 kB
JavaScript
import axe from 'axe-core';
import { TestBed } from '@angular/core/testing';
import { SkyAppResourcesService, SkyLibResourcesService } from '@skyux/i18n';
import { firstValueFrom } from 'rxjs';
import { By } from '@angular/platform-browser';
function parseMessage(violations) {
let message = 'Expected element to pass accessibility checks.\n\n';
violations.forEach((violation) => {
const wcagTags = violation.tags
.filter((tag) => tag.match(/wcag\d{3}|^best*/gi))
.join(', ');
const nodeResults = violation.nodes.filter(filterViolationNodeResults(violation));
const html = nodeResults.reduce((accumulator, node) => {
const related = [...node.all, ...node.none, ...node.any]
.map((checkResult) => {
const relatedNodes = checkResult.relatedNodes || [];
let relatedHtml = relatedNodes
.map((relatedNode) => relatedNode.html.split(`\n`).join(`\n `))
.join(`\n\n `);
if (relatedHtml) {
relatedHtml = `\n Related Nodes:\n ${relatedHtml}`;
}
return ` - [${checkResult.id}] ${checkResult.message}${relatedHtml}`;
})
.join(`\n`);
const newInformation = [
node.failureSummary
? `[${node.impact?.toUpperCase()}] ${node.failureSummary
.split(/\n */g)
.join(`\n - `)}`
: '',
node.ancestry ? `Ancestry: ${node.ancestry.join(', ')}` : '',
`Target: ${node.target.join(', ')}`,
node.html ? `HTML: ${node.html}` : '',
related,
].filter((info) => !!info);
return `${accumulator}\n\n${newInformation.join(`\n`)}`;
}, '');
const error = [
`aXe - [Rule: '${violation.id}'] ${violation.help} - WCAG: ${wcagTags}`,
` Get help at: ${violation.helpUrl}\n`,
`${html}\n\n`,
].join('\n');
message += `${error}\n`;
});
return message;
}
function filterViolationNodeResults(result) {
if ([
'aria-hidden-focus', // AG Grid uses aria-hidden on elements before they are ready
'aria-required-children', // AG Grid uses some aria-hidden elements that axe doesn't like
'aria-valid-attr', // AG Grid uses aria-description, which is still in draft
'scrollable-region-focusable', // AG Grid handles scrolling
].includes(result.id)) {
return (node) => !node.html.includes('class="ag-');
}
else if (result.id === 'aria-allowed-role') {
const fieldsetRadiogroupRegex = new RegExp(/<fieldset[^>]+role="radiogroup"/);
return (node) => !fieldsetRadiogroupRegex.test(node.html);
}
else {
return () => true;
}
}
class SkyA11yAnalyzer {
static { this.analyzer = axe; }
static run(element, config) {
if (element === undefined) {
throw new Error('No element was specified for accessibility checking.');
}
SkyA11yAnalyzer.analyzer.reset();
const defaults = {
rules: {},
};
// Disable autocomplete-valid
// Chrome browsers ignore autocomplete="off", which forces us to use non-standard values
// to disable the browser's native autofill.
// https://bugs.chromium.org/p/chromium/issues/detail?id=468153#c164
defaults.rules['autocomplete-valid'] = { enabled: false };
return new Promise((resolve, reject) => {
const callback = (error, results) => {
if (error?.message) {
reject(error);
return;
}
const violations = results.violations.filter((violation) => violation.nodes.some(filterViolationNodeResults(violation)));
if (violations.length > 0) {
const message = parseMessage(violations);
reject(new Error(message));
}
resolve();
};
SkyA11yAnalyzer.analyzer.run(element, { ...defaults, ...config }, callback);
});
}
}
const windowRef = window;
function getResources(name, args = []) {
const resourcesService = TestBed.inject(SkyAppResourcesService);
return firstValueFrom(resourcesService.getString(name, ...args));
}
function getLibResources(name, args = []) {
const resourcesService = TestBed.inject(SkyLibResourcesService);
return firstValueFrom(resourcesService.getString(name, ...args));
}
function isTemplateMatch(sample, template) {
let matches = true;
const templateTokens = template.split(new RegExp('{\\d+}')).reverse();
let currentToken = templateTokens.pop();
let lastPosition = 0;
while (currentToken !== undefined && matches) {
const tokenPosition = sample.indexOf(currentToken, lastPosition);
matches = tokenPosition >= lastPosition;
lastPosition = tokenPosition + currentToken.length;
currentToken = templateTokens.pop();
}
return matches;
}
const matchers = {
toBeAccessible() {
return {
compare(element, callback, config) {
SkyA11yAnalyzer.run(element, config)
.then(() => {
/*istanbul ignore else*/
if (callback) {
callback();
}
})
.catch((err) => {
windowRef.fail(err.message);
/*istanbul ignore else*/
if (callback) {
callback();
}
});
// Asynchronous matchers are currently unsupported, but
// the method above works to fail the specific test in the
// callback manually, if checks do not pass.
// ---
// A side effect of this technique is the matcher cannot be
// paired with a `.not.toBeAccessible` operator (since the returned
// result is always `true`). For this particular matcher,
// checking if an element is not accessible may be irrelevant.
return {
message: '',
pass: true,
};
},
};
},
toBeVisible() {
return {
compare(el, options) {
const defaults = {
checkCssDisplay: true,
checkCssVisibility: false,
checkDimensions: false,
checkExists: false,
};
const settings = { ...defaults, ...options };
const result = {
pass: true,
message: '',
};
if (settings.checkExists) {
result.pass = !!el;
}
if (result.pass) {
const computedStyle = window.getComputedStyle(el);
if (settings.checkCssDisplay) {
result.pass = computedStyle.display !== 'none';
}
if (settings.checkCssVisibility) {
result.pass = computedStyle.visibility !== 'hidden';
}
if (settings.checkDimensions) {
const box = el.getBoundingClientRect();
result.pass = box.width > 0 && box.height > 0;
}
}
result.message = result.pass
? 'Expected element to not be visible'
: 'Expected element to be visible';
return result;
},
};
},
toExist() {
return {
compare(el) {
const result = {
pass: false,
message: '',
};
result.pass = !!el;
result.message = result.pass
? 'Expected element not to exist'
: 'Expected element to exist';
return result;
},
};
},
toHaveCssClass() {
return {
compare(el, expectedClassName) {
const result = {
pass: false,
message: '',
};
if (expectedClassName.indexOf('.') === 0) {
throw new Error('Please remove the leading dot from your class name.');
}
result.pass = el.classList.contains(expectedClassName);
result.message = result.pass
? `Expected element not to have CSS class "${expectedClassName}"`
: `Expected element to have CSS class "${expectedClassName}"`;
return result;
},
};
},
toHaveStyle() {
return {
compare(el, expectedStyles) {
const message = [];
let hasFailure = false;
Object.keys(expectedStyles).forEach((styleName) => {
const styles = windowRef.getComputedStyle(el);
const actualStyle = styles[styleName];
const expectedStyle = expectedStyles[styleName];
if (actualStyle !== expectedStyle) {
if (!hasFailure) {
hasFailure = true;
}
message.push(`Expected element not to have CSS style "${styleName}: ${expectedStyle}"`);
}
else {
message.push(`Expected element to have CSS style "${styleName}: ${expectedStyle}"`);
}
message.push(`Actual styles are: "${styleName}: ${actualStyle}"`);
});
const result = {
pass: !hasFailure,
message: message.join('\n'),
};
return result;
},
};
},
toHaveText() {
return {
compare(el, expectedText, trimWhitespace = true) {
const result = {
pass: false,
message: '',
};
let actualText = el.textContent;
if (trimWhitespace) {
actualText = actualText.trim();
}
result.pass = actualText === expectedText;
result.message = result.pass
? `Expected element's inner text "${actualText}" not to be: "${expectedText}"`
: `Expected element's inner text to be: "${expectedText}"\n` +
`Actual element's inner text was: "${actualText}"`;
return result;
},
};
},
toEqualResourceText() {
return {
compare(actual, name, args, callback) {
void getResources(name, args).then((message) => {
/*istanbul ignore else*/
if (actual !== message) {
windowRef.fail(`Expected "${actual}" to equal "${message}"`);
}
/*istanbul ignore else*/
if (callback) {
callback();
}
});
// Asynchronous matchers are currently unsupported, but
// the method above works to fail the specific test in the
// callback manually, if checks do not pass.
// ---
// A side effect of this technique is the matcher cannot be
// paired with a `.not.toHaveResourceText` operator (since the returned
// result is always `true`).
return {
message: '',
pass: true,
};
},
};
},
toHaveResourceText() {
return {
compare(el, name, args, trimWhitespace = true, callback) {
let actual = el.textContent;
if (trimWhitespace) {
actual = actual.trim();
}
void getResources(name, args).then((message) => {
if (actual !== message) {
windowRef.fail(`Expected element's inner text "${el.textContent}" to be "${message}"`);
}
/*istanbul ignore else*/
if (callback) {
callback();
}
});
// Asynchronous matchers are currently unsupported, but
// the method above works to fail the specific test in the
// callback manually, if checks do not pass.
// ---
// A side effect of this technique is the matcher cannot be
// paired with a `.not.toHaveResourceText` operator (since the returned
// result is always `true`).
return {
message: '',
pass: true,
};
},
};
},
toMatchResourceTemplate() {
return {
compare(el, name, callback) {
const actual = el.textContent;
void getResources(name).then((message) => {
if (!isTemplateMatch(actual, message)) {
windowRef.fail(`Expected element's text "${actual}" to match "${message}"`);
}
/*istanbul ignore else*/
if (callback) {
callback();
}
});
// Asynchronous matchers are currently unsupported, but
// the method above works to fail the specific test in the
// callback manually, if checks do not pass.
// ---
// A side effect of this technique is the matcher cannot be
// paired with a `.not.toHaveResourceText` operator (since the returned
// result is always `true`).
return {
message: '',
pass: true,
};
},
};
},
};
const asyncMatchers = {
toBeAccessible() {
return {
compare(element, config) {
return new Promise((resolve) => {
SkyA11yAnalyzer.run(element, config)
.then(() => {
resolve({
pass: true,
});
})
.catch((err) => {
resolve({
pass: false,
message: err.message,
});
});
});
},
};
},
toEqualResourceText() {
return {
compare(actual, name, args) {
return getResources(name, args).then((message) => {
if (actual === message) {
return {
pass: true,
};
}
else {
return {
pass: false,
message: `Expected "${actual}" to equal "${message}"`,
};
}
});
},
};
},
toEqualLibResourceText() {
return {
compare(actual, name, args) {
return getLibResources(name, args).then((message) => {
if (actual === message) {
return {
pass: true,
};
}
else {
return {
pass: false,
message: `Expected "${actual}" to equal "${message}"`,
};
}
});
},
};
},
toHaveResourceText() {
return {
compare(element, name, args, trimWhitespace = true) {
return getResources(name, args).then((message) => {
let actual = element.textContent;
if (trimWhitespace) {
actual = actual.trim();
}
if (actual === message) {
return {
pass: true,
};
}
else {
return {
pass: false,
message: `Expected element's inner text "${actual}" to be "${message}"`,
};
}
});
},
};
},
toHaveLibResourceText() {
return {
compare(element, name, args, trimWhitespace = true) {
return getLibResources(name, args).then((message) => {
let actual = element.textContent;
if (trimWhitespace) {
actual = actual.trim();
}
if (actual === message) {
return {
pass: true,
};
}
else {
return {
pass: false,
message: `Expected element's inner text "${actual}" to be "${message}"`,
};
}
});
},
};
},
toMatchResourceTemplate() {
return {
compare(element, name) {
return getResources(name).then((message) => {
const actual = element.textContent;
if (isTemplateMatch(actual, message)) {
return {
pass: true,
};
}
else {
return {
pass: false,
message: `Expected element's text "${actual}" to match "${message}"`,
};
}
});
},
};
},
toMatchLibResourceTemplate() {
return {
compare(element, name) {
return getLibResources(name).then((message) => {
const actual = element.textContent;
if (isTemplateMatch(actual, message)) {
return {
pass: true,
};
}
else {
return {
pass: false,
message: `Expected element's text "${actual}" to match "${message}"`,
};
}
});
},
};
},
};
windowRef.beforeEach(() => {
jasmine.addMatchers(matchers);
jasmine.addAsyncMatchers(asyncMatchers);
});
/**
* Create an expectation for a spec.
* @param actual Actual computed value to test expectations against.
*/
function expect(actual) {
return windowRef.expect(actual);
}
/**
* Create an async expectation for a spec.
* @param actual Actual computed value to test expectations against.
*/
function expectAsync(actual) {
return windowRef.expectAsync(actual);
}
class SkyBy {
static dataSkyId(skyId) {
return By.css(`[data-sky-id="${skyId}"]`);
}
}
function getNativeEl(el) {
if (!el) {
return undefined;
}
if (el.nativeElement) {
return el.nativeElement;
}
return el;
}
class SkyAppTestUtility {
static fireDomEvent(element, eventName, options) {
if (!element) {
throw new Error(`Event \`${eventName}\` could not be fired because the element is not defined.`);
}
const defaults = {
bubbles: true,
cancelable: true,
keyboardEventInit: {},
};
const settings = Object.assign({}, defaults, options);
// Apply keyboard event options.
const event = Object.assign(document.createEvent('CustomEvent'), settings.keyboardEventInit, settings.customEventInit);
event.initEvent(eventName, settings.bubbles, settings.cancelable);
element.dispatchEvent(event);
}
/**
* Returns the inner text content of an element.
*/
static getText(element) {
const nativeEl = getNativeEl(element);
if (nativeEl) {
return nativeEl.innerText.trim();
}
return undefined;
}
/**
* Returns true if the element exists on the page.
*/
static isVisible(element) {
const nativeEl = getNativeEl(element);
if (nativeEl) {
return getComputedStyle(nativeEl).display !== 'none';
}
return undefined;
}
/**
* Sets the value of an input element and triggers its 'input' and 'change' events.
*/
static setInputValue(element, value) {
const inputEvent = document.createEvent('Event');
inputEvent.initEvent('input', false, false);
const changeEvent = document.createEvent('Event');
changeEvent.initEvent('change', false, false);
element.value = value;
element.dispatchEvent(inputEvent);
}
/**
* Returns the URL of an element's background image, if it exists.
*/
static getBackgroundImageUrl(el) {
const nativeEl = getNativeEl(el);
if (nativeEl) {
const backgroundImageUrl = getComputedStyle(nativeEl).backgroundImage;
/* istanbul ignore else */
// Browser will likely not return an empty value for the computed style,
// but leave the if statement here anyway as a sanity check.
if (backgroundImageUrl) {
const matches = /url\(('|")([^'"]+)('|")\)/gi.exec(backgroundImageUrl);
if (matches && matches.length > 0) {
return matches[2];
}
}
}
return undefined;
}
/**
* Returns a DebugElement representing a SKY UX component.
* @internal
* @param fixture The ComponentFixture where the SKY UX component resides.
* @param skyTestId The value of the `data-sky-id` property specified on the SKY UX component.
* @param componentSelector The selector name for the SKY UX component (e.g. 'sky-alert').
*/
static getDebugElementByTestId(fixture, skyTestId, componentSelector) {
const skyEl = fixture.debugElement.query(By.css(`[data-sky-id="${skyTestId}"]`));
if (!skyEl) {
throw new Error(`No element was found with a \`data-sky-id\` value of "${skyTestId}".`);
}
if (skyEl.name !== componentSelector) {
throw new Error(`The element with the test ID "${skyTestId}" is not a component of type ${componentSelector}."`);
}
return skyEl;
}
}
/**
* Generated bundle index. Do not edit.
*/
export { SkyA11yAnalyzer, SkyAppTestUtility, SkyBy, expect, expectAsync };
//# sourceMappingURL=skyux-sdk-testing.mjs.map