diff --git a/.changeset/wild-shrimps-mix.md b/.changeset/wild-shrimps-mix.md new file mode 100644 index 0000000..5e1ac02 --- /dev/null +++ b/.changeset/wild-shrimps-mix.md @@ -0,0 +1,5 @@ +--- +'expect-axe-playwright': minor +--- + +Allow matcher to accept Axe results object diff --git a/README.md b/README.md index a041e8f..eb04028 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,19 @@ Or pass a locator to test part of the page: await expect(page.locator('#my-element')).toPassAxe() ``` +You can also pass an [Axe results +object](https://www.deque.com/axe/core-documentation/api-documentation/#results-object) +to the matcher: + +```js +import { waitForAxeResults } from 'expect-axe-playwright' + +test('should pass common accessibility checks', async ({ page }) => { + const { results } = await waitForAxeResults(page) + await expect(results).toPassAxe() +}) +``` + #### Word of Caution: Limitations to Accessibility Tests ```js diff --git a/src/index.ts b/src/index.ts index 1aa20ef..b02ef04 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ import matchers from './matchers' +export { waitForAxeResults } from './waitForAxeResults' export default matchers diff --git a/src/matchers/toPassAxe/index.spec.ts b/src/matchers/toPassAxe/index.spec.ts index 534975b..bb99ed9 100644 --- a/src/matchers/toPassAxe/index.spec.ts +++ b/src/matchers/toPassAxe/index.spec.ts @@ -129,4 +129,16 @@ test.describe.parallel('toPassAxe', () => { .catch(() => Promise.resolve()) expect(attachmentExists(filename)).toBe(true) }) + + test.describe('with Axe results object', async () => { + test('positive', async () => { + const results = { violations: [] } + await expect(results).toPassAxe() + }) + + test('negative', async () => { + const results = { violations: [{ id: 'foo', nodes: [] }] } + await expect(results).not.toPassAxe() + }) + }) }) diff --git a/src/matchers/toPassAxe/index.ts b/src/matchers/toPassAxe/index.ts index 9268ea5..ae89687 100644 --- a/src/matchers/toPassAxe/index.ts +++ b/src/matchers/toPassAxe/index.ts @@ -1,47 +1,43 @@ import test from '@playwright/test' import type { MatcherState } from '@playwright/test/types/expect-types' -import type { Result, RunOptions } from 'axe-core' +import type { AxeResults, Result } from 'axe-core' +import type { MatcherOptions } from '../../types' +import type { Handle } from '../../utils/locator' +import { getOptions } from '../../utils/options' import createHTMLReport from 'axe-reporter-html' -import merge from 'merge-deep' import { attach } from '../../utils/attachments' -import { injectAxe, runAxe } from '../../utils/axe' -import { Handle, resolveLocator } from '../../utils/matcher' -import { poll } from '../../utils/poll' +import { waitForAxeResults } from '../../waitForAxeResults' const summarize = (violations: Result[]) => violations .map((violation) => `${violation.id}(${violation.nodes.length})`) .join(', ') -interface MatcherOptions extends RunOptions { - timeout?: number - filename?: string +async function getResults(obj: Handle | AxeResults, options: MatcherOptions) { + if ((obj as AxeResults).violations) { + const results = obj as AxeResults + return { + results, + ok: !results.violations.length, + } + } else { + return waitForAxeResults(obj as Handle, options) + } } export async function toPassAxe( this: MatcherState, - handle: Handle, - { timeout, ...options }: MatcherOptions = {} + obj: Handle | AxeResults, + options: MatcherOptions = {} ) { try { - const locator = resolveLocator(handle) - await injectAxe(locator) - - const info = test.info() - const opts = merge(info.project.use.axeOptions, options) - - const { ok, results } = await poll(locator, timeout, async () => { - const results = await runAxe(locator, opts) - - return { - ok: !results.violations.length, - results, - } - }) + const { results, ok } = await getResults(obj, options) // If there are violations, attach an HTML report to the test for additional // visibility into the issue. if (!ok) { + const info = test.info() + const opts = getOptions(options) const html = await createHTMLReport(results) const filename = opts.filename || 'axe-report.html' await attach(info, filename, html) diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..f75f54b --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,6 @@ +import type { RunOptions } from 'axe-core' + +export interface MatcherOptions extends RunOptions { + timeout?: number + filename?: string +} diff --git a/src/utils/matcher.ts b/src/utils/locator.ts similarity index 100% rename from src/utils/matcher.ts rename to src/utils/locator.ts diff --git a/src/utils/options.ts b/src/utils/options.ts new file mode 100644 index 0000000..36e1323 --- /dev/null +++ b/src/utils/options.ts @@ -0,0 +1,12 @@ +import type { MatcherOptions } from '../types' +import test from '@playwright/test' +import merge from 'merge-deep' + +/** + * Overrides the default options with any user-provided options, and + * returns the final options object. + */ +export function getOptions(options: MatcherOptions = {}) { + const info = test.info() + return merge(info.project.use.axeOptions, options) +} diff --git a/src/waitForAxeResults.spec.ts b/src/waitForAxeResults.spec.ts new file mode 100644 index 0000000..a68d172 --- /dev/null +++ b/src/waitForAxeResults.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from '@playwright/test' +import { readFile } from './utils/file' +import { waitForAxeResults } from './waitForAxeResults' + +test.describe('waitForAxeResults', () => { + test('should be ok for page with no axe violations', async ({ page }) => { + const content = await readFile('accessible.html') + await page.setContent(content) + const { ok, results } = await waitForAxeResults(page) + expect(results.violations).toHaveLength(0) + expect(ok).toBe(true) + }) + + test('should not be ok for page with axe violations', async ({ page }) => { + const content = await readFile('inaccessible.html') + await page.setContent(content) + const { ok, results } = await waitForAxeResults(page) + expect(results.violations.length).toBeGreaterThan(0) + expect(ok).toBe(false) + }) +}) diff --git a/src/waitForAxeResults.ts b/src/waitForAxeResults.ts new file mode 100644 index 0000000..e7ecfb5 --- /dev/null +++ b/src/waitForAxeResults.ts @@ -0,0 +1,26 @@ +import type { RunOptions } from 'axe-core' +import { Handle, resolveLocator } from './utils/locator' +import { poll } from './utils/poll' +import { injectAxe, runAxe } from './utils/axe' +import { getOptions } from './utils/options' + +/** + * Injects axe onto page, waits for the page to be ready, then runs axe against + * the provided element handle (which could be the entire page). + */ +export async function waitForAxeResults( + handle: Handle, + { timeout, ...options }: { timeout?: number } & RunOptions = {} +) { + const opts = getOptions(options) + const locator = resolveLocator(handle) + await injectAxe(locator) + return poll(locator, timeout, async () => { + const results = await runAxe(locator, opts) + + return { + ok: !results.violations.length, + results, + } + }) +}