UNPKG

@skyux-sdk/testing

Version:

This library was generated with [Nx](https://nx.dev).

628 lines (621 loc) 23.7 kB
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