diff --git a/packages/jest/README.md b/packages/jest/README.md index 8405d34e..b8b7cfe9 100644 --- a/packages/jest/README.md +++ b/packages/jest/README.md @@ -19,6 +19,7 @@ Accessibility matcher for [Jest](https://jestjs.io) - [JSON result transformation](#json-result-transformation) - [Limitations](#limitations) - [Disabled Checks](#disabled-checks) + - [Jest toMatchSnapshot() API Wrapper](#jest-tomatchsnapshot-api-wrapper) @@ -278,3 +279,7 @@ Automatic checks currently has the following limitations. ### Disabled Checks - @sa11y/jest automatic checks also disabled rules which were disabled in `toBeAccessible` Jest API ([disabled-checks](https://github.com/salesforce/sa11y/tree/master/packages/jest#disabled-checks)) + +### Jest toMatchSnapshot() API Wrapper + +- When Snapshot testing is done, jest's `toMatchSnapshot()` API is altering the document.body element attributes as needed to generate snapshots thereby the a11y checks which happen later on `document.body` isn't running on actual elements. To solve this, a wrapper has been introduced to restore the document.body DOM before jest's `toMatchSnapshot` is invoked and use the restored version of document.body for a11y checks. diff --git a/packages/jest/__tests__/setup.test.ts b/packages/jest/__tests__/setup.test.ts index 1330c17a..df40d504 100644 --- a/packages/jest/__tests__/setup.test.ts +++ b/packages/jest/__tests__/setup.test.ts @@ -13,6 +13,7 @@ describe('jest setup', () => { registerSa11yMatcher(); it('should define matcher on expect object', () => { expect(expect['toBeAccessible']).toBeDefined(); + expect(expect['toMatchSnapshot']).toBeDefined(); }); it.each([extended, base])('should customize %s preset-rule as expected', (config) => { diff --git a/packages/jest/package.json b/packages/jest/package.json index b720aa2a..8383d9b1 100644 --- a/packages/jest/package.json +++ b/packages/jest/package.json @@ -35,7 +35,9 @@ "devDependencies": { "@jest/globals": "28.1.3", "@sa11y/common": "5.1.0", - "@sa11y/test-utils": "5.1.0" + "@sa11y/test-utils": "5.1.0", + "expect": "28.1.3", + "jest-snapshot": "28.1.3" }, "publishConfig": { "access": "public" diff --git a/packages/jest/src/automatic.ts b/packages/jest/src/automatic.ts index e83ffaa7..91e741e6 100644 --- a/packages/jest/src/automatic.ts +++ b/packages/jest/src/automatic.ts @@ -36,6 +36,12 @@ const defaultAutoCheckOpts: AutoCheckOpts = { filesFilter: [], }; +let originalDocumentBodyHtml: string | null = null; + +export const setOriginalDocumentBodyHtml = (bodyHtml: string | null) => { + originalDocumentBodyHtml = bodyHtml ?? null; +}; + /** * Check if current test file needs to be skipped based on any provided filter */ @@ -65,6 +71,9 @@ export async function automaticCheck(opts: AutoCheckOpts = defaultAutoCheckOpts) } const violations: AxeResults = []; + if (originalDocumentBodyHtml) { + document.body.innerHTML = originalDocumentBodyHtml; + } // Create a DOM walker filtering only elements (skipping text, comment nodes etc) const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT); let currNode = walker.firstChild(); @@ -81,6 +90,7 @@ export async function automaticCheck(opts: AutoCheckOpts = defaultAutoCheckOpts) currNode = walker.nextSibling(); } } finally { + setOriginalDocumentBodyHtml(null); if (opts.cleanupAfterEach) document.body.innerHTML = ''; // remove non-element nodes // TODO (spike): Disable stack trace for automatic checks. // Will this affect all errors globally? diff --git a/packages/jest/src/setup.ts b/packages/jest/src/setup.ts index ff8234ec..d7d5f753 100644 --- a/packages/jest/src/setup.ts +++ b/packages/jest/src/setup.ts @@ -7,8 +7,14 @@ import { toBeAccessible } from './matcher'; import { A11yConfig } from '@sa11y/common'; -import { AutoCheckOpts, registerSa11yAutomaticChecks } from './automatic'; +import { AutoCheckOpts, registerSa11yAutomaticChecks, setOriginalDocumentBodyHtml } from './automatic'; import { expect } from '@jest/globals'; +import { toMatchSnapshot, SnapshotState } from 'jest-snapshot'; +import type { MatcherFunctionWithState, MatcherState } from 'expect'; + +interface Context extends MatcherState { + snapshotState: SnapshotState; +} export const disabledRules = [ // Descendancy checks that would fail at unit/component level, but pass at page level @@ -29,6 +35,15 @@ export const disabledRules = [ 'video-caption', ]; +function wrapperSnapshotMatcher(originalMatcher: MatcherFunctionWithState) { + return function (...args: Context[]) { + setOriginalDocumentBodyHtml(document?.body?.innerHTML ?? ''); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return originalMatcher.call(expect.getState(), ...args); + }; +} + /** * Options to be passed on to {@link setup} */ @@ -72,8 +87,9 @@ export function setup(opts: Sa11yOpts = defaultSa11yOpts): void { * Register accessibility helpers toBeAccessible as jest matchers */ export function registerSa11yMatcher(): void { + const wrapper = wrapperSnapshotMatcher(toMatchSnapshot); if (expect !== undefined) { - expect.extend({ toBeAccessible }); + expect.extend({ toBeAccessible, toMatchSnapshot: wrapper }); } else { throw new Error( "Unable to find Jest's expect function." + diff --git a/yarn.lock b/yarn.lock index cbc67887..2407b55d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6598,7 +6598,7 @@ expect-webdriverio@^3.0.0: expect "^28.1.0" jest-matcher-utils "^28.1.0" -expect@^28.0.0, expect@^28.1.0, expect@^28.1.3: +expect@28.1.3, expect@^28.0.0, expect@^28.1.0, expect@^28.1.3: version "28.1.3" resolved "https://registry.yarnpkg.com/expect/-/expect-28.1.3.tgz#90a7c1a124f1824133dd4533cce2d2bdcb6603ec" integrity sha512-eEh0xn8HlsuOBxFgIss+2mX85VAS4Qy3OSkjV7rlBWljtA4oWH37glVGyOZSZvErDT/yBywZdPGwCXuTvSG85g== @@ -8697,7 +8697,7 @@ jest-runtime@^28.1.3: slash "^3.0.0" strip-bom "^4.0.0" -jest-snapshot@^28.1.3: +jest-snapshot@28.1.3, jest-snapshot@^28.1.3: version "28.1.3" resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-28.1.3.tgz#17467b3ab8ddb81e2f605db05583d69388fc0668" integrity sha512-4lzMgtiNlc3DU/8lZfmqxN3AYD6GGLbl+72rdBpXvcV+whX7mDrREzkPdp2RnmfIiWBg1YbuFSkXduF2JcafJg==