From 5e49b1369ce9d7acd2b60e050a1968c2a5d0afe2 Mon Sep 17 00:00:00 2001 From: Andrew Huth Date: Mon, 12 Feb 2024 10:25:37 -0500 Subject: [PATCH 1/2] Add "config" parameter, which is passed to axe.configure Resolves https://github.com/chanzuckerberg/axe-storybook-testing/issues/87 --- CHANGELOG.md | 2 + README.md | 22 ++++++- demo/src/advanced.stories.jsx | 23 +++++++ index.d.ts | 8 ++- src/ProcessedStory.test.ts | 20 ++++++ src/ProcessedStory.ts | 24 ++++++- src/Result.ts | 7 ++- src/browser/AxePage.ts | 31 ++++++++-- .../__snapshots__/integration.test.ts.snap | 62 +++++++++++++------ 9 files changed, 170 insertions(+), 29 deletions(-) create mode 100644 demo/src/advanced.stories.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 2650394..e3584d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- [new] Add `config` parameter, which is passed to `axe.configure` + # 7.1.4 (2024-02-09) - [fix] Update all deps diff --git a/README.md b/README.md index 7a5ba43..80831c1 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,8 @@ yarn storybook:axe --headless false --browser firefox Stories can use parameters to configure how axe-storybook-testing handles them. +You can provide these wherever Storybook accepts [parameters](https://storybook.js.org/docs/writing-stories/parameters) (story, component, or global). + ### disabledRules Prevent axe-storybook-testing from running specific Axe rules on a story by using the `disabledRules` parameter. @@ -160,7 +162,25 @@ export const SomeStory = { ... } } - }; + } +} +``` + +### config + +Axe configuration, which is passed to [axe.configure](https://www.deque.com/axe/core-documentation/api-documentation/#api-name-axeconfigure). + +```jsx +export const SomeStory = { + parameters: { + axe: { + config: { + checks: [...], + ... + } + } + } +} ``` ### skip diff --git a/demo/src/advanced.stories.jsx b/demo/src/advanced.stories.jsx new file mode 100644 index 0000000..72194ce --- /dev/null +++ b/demo/src/advanced.stories.jsx @@ -0,0 +1,23 @@ +import React from 'react'; + +export default { + title: 'advanced', + component: 'button', +}; + +// Testing out passing config to `axe.configure`. +// See https://github.com/chanzuckerberg/axe-storybook-testing/issues/87. +export const branding = { + render: () => ( + + ), + parameters: { + axe: { + config: { + branding: 'my-branding', + }, + }, + }, +}; diff --git a/index.d.ts b/index.d.ts index 7ae93e7..940a213 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,4 +1,4 @@ -import type {RunOptions} from 'axe-core'; +import type {RunOptions, Spec} from 'axe-core'; export declare type AxeParams = { /** @@ -22,10 +22,14 @@ export declare type AxeParams = { */ timeout?: number; /** - * Allows use of optional axe.run options for a given story + * Run options passed to `axe.run`. * @see https://www.deque.com/axe/core-documentation/api-documentation/#options-parameter */ runOptions?: RunOptions; + /** + * Config passed to `axe.configure`. + */ + config?: Spec; /** * @deprecated * Legacy way of waiting for a selector before running Axe. diff --git a/src/ProcessedStory.test.ts b/src/ProcessedStory.test.ts index 3627de1..728118b 100644 --- a/src/ProcessedStory.test.ts +++ b/src/ProcessedStory.test.ts @@ -206,3 +206,23 @@ describe('runOptions', () => { ); }); }); + +describe('config', () => { + it('is undefined when the config parameter is missing', () => { + const parameters = {axe: {}}; + const rawStory = {id: 'button--a', title: 'button', name: 'a', parameters}; + const processedStory = new ProcessedStory(rawStory); + expect(processedStory.config).toBeUndefined(); + }); + + it('parses config', () => { + const parameters = { + axe: {config: {branding: 'hi'}}, + }; + const rawStory = {id: 'button--a', title: 'button', name: 'a', parameters}; + const processedStory = new ProcessedStory(rawStory); + expect(processedStory.config).toEqual({ + branding: 'hi', + }); + }); +}); diff --git a/src/ProcessedStory.ts b/src/ProcessedStory.ts index 4e44683..d844cbd 100644 --- a/src/ProcessedStory.ts +++ b/src/ProcessedStory.ts @@ -1,4 +1,4 @@ -import type {RunOptions} from 'axe-core'; +import type {RunOptions, Spec} from 'axe-core'; import {z as zod} from 'zod'; import type {StorybookStory} from './browser/StorybookPage'; @@ -6,6 +6,7 @@ type Params = { disabledRules: string[]; mode: 'off' | 'warn' | 'error'; runOptions?: RunOptions; + config?: Spec; skip: boolean; timeout: number; /** @deprecated */ @@ -41,6 +42,7 @@ export default class ProcessedStory { rawStory.parameters?.axe?.runOptions, rawStory, ), + config: normalizeConfig(rawStory.parameters?.axe?.config, rawStory), }; } @@ -66,13 +68,21 @@ export default class ProcessedStory { } /** - * All optional run options used for a given story + * All optional run options used for a given story. Passed to `axe.run`. * @see https://www.deque.com/axe/core-documentation/api-documentation/#options-parameter */ get runOptions() { return this.parameters.runOptions; } + /** + * All optional config used to configure axe-core. Passed to `axe.configure`. + * @see https://www.deque.com/axe/core-documentation/api-documentation/#api-name-axeconfigure + */ + get config() { + return this.parameters.config; + } + /** * Timeout override for a test triggered in runSuite() */ @@ -128,6 +138,8 @@ const runOptionsSchema = zod.optional( }), ); +const configSchema = zod.object({}).passthrough().optional(); + function normalizeSkip(skip: unknown, rawStory: StorybookStory) { return parseWithFriendlyError( () => skipSchema.parse(skip) || false, @@ -163,6 +175,14 @@ function normalizeRunOptions(runOptions: unknown, rawStory: StorybookStory) { ); } +function normalizeConfig(config: unknown, rawStory: StorybookStory) { + return parseWithFriendlyError( + () => configSchema.parse(config), + rawStory, + 'config', + ); +} + function normalizeWaitForSelector( waitForSelector: unknown, rawStory: StorybookStory, diff --git a/src/Result.ts b/src/Result.ts index 1abbc6a..5addd57 100644 --- a/src/Result.ts +++ b/src/Result.ts @@ -25,7 +25,12 @@ export default class Result { */ static async fromPage(page: Page, story: ProcessedStory) { const disabledRules = [...defaultDisabledRules, ...story.disabledRules]; - const axeResults = await analyze(page, disabledRules, story.runOptions); + const axeResults = await analyze( + page, + disabledRules, + story.runOptions, + story.config, + ); return new Result(axeResults.violations); } diff --git a/src/browser/AxePage.ts b/src/browser/AxePage.ts index bf28c69..ba215ea 100644 --- a/src/browser/AxePage.ts +++ b/src/browser/AxePage.ts @@ -1,4 +1,4 @@ -import type {AxeResults, RuleObject, RunOptions} from 'axe-core'; +import type {AxeResults, RuleObject, RunOptions, Spec} from 'axe-core'; import type {Page} from 'playwright'; /** @@ -17,14 +17,37 @@ export function analyze( page: Page, disabledRules: string[] = [], runOptions: RunOptions = {}, + config?: Spec, ): Promise { - return page.evaluate(runAxe, getRunOptions(runOptions, disabledRules)); + return page.evaluate(runAxe, { + options: getRunOptions(runOptions, disabledRules), + config, + }); } -function runAxe(options: RunOptions): Promise { +function runAxe({ + options, + config, +}: { + options: RunOptions; + config?: Spec; +}): Promise { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore This function executes in a browser context. - return window.axeQueue.add(() => window.axe.run(document, options)); + return window.axeQueue.add(() => { + if (config) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore This function executes in a browser context. + window.axe.configure(config); + } else { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore This function executes in a browser context. + window.axe.reset(); + } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore This function executes in a browser context. + return window.axe.run(document, options); + }); } export function getRunOptions( diff --git a/tests/integration/__snapshots__/integration.test.ts.snap b/tests/integration/__snapshots__/integration.test.ts.snap index 3344f63..585caeb 100644 --- a/tests/integration/__snapshots__/integration.test.ts.snap +++ b/tests/integration/__snapshots__/integration.test.ts.snap @@ -8,6 +8,9 @@ exports[`fails only specific impact levels if specified 1`] = ` Serving up 🍕 static storybook build at: http://127.0.0.1:8000 [chromium] accessibility + advanced + ✔ Branding + autoTitles ✔ No Failures @@ -52,7 +55,7 @@ Serving up 🍕 static storybook build at: http://127.0.0.1:8000 Element has no title attribute Element's default semantics were not overridden with role="none" or role="presentation" - 7 passing + 8 passing 6 pending 7 failing @@ -178,6 +181,9 @@ exports[`filters the components to run 1`] = ` Serving up 🍕 static storybook build at: http://127.0.0.1:8000 [chromium] accessibility + advanced + - Branding + autoTitles - No Failures @@ -235,7 +241,7 @@ Error: simple / Failure No Discernible Text Warn / Detected the following access Element's default semantics were not overridden with role="none" or role="presentation" 3 passing - 13 pending + 14 pending 4 failing 1) simple @@ -333,22 +339,25 @@ exports[`outputs accessibility violation information for the demo app 1`] = ` Serving up 🍕 static storybook build at: http://127.0.0.1:8000 [chromium] accessibility + advanced + 1) Branding + autoTitles ✔ No Failures delays ✔ Short Delay And Pass - 1) Short Delay And Fail + 2) Short Delay And Fail ✔ Medium Delay And Pass - 2) Medium Delay And Fail - 3) Medium Delay And Short Timeout Fail - 4) Long Delay And Timeout + 3) Medium Delay And Fail + 4) Medium Delay And Short Timeout Fail + 5) Long Delay And Timeout simple ✔ No Failures - 5) Failure No Discernible Text - 6) Failure Color Contrast - 7) Failure No Discernible Text And Invalid Role + 6) Failure No Discernible Text + 7) Failure Color Contrast + 8) Failure No Discernible Text And Invalid Role - Failure Color Contrast Skipped - No Failures Warn - Failure Color Contrast Warn @@ -356,7 +365,7 @@ Serving up 🍕 static storybook build at: http://127.0.0.1:8000 - Failure Color Contrast Off - Failure No Discernible Text And Invalid Role Skipped ✔ Failure Color Contrast Disabled Rule - 8) Failure No Discernible Text And Invalid Role Disabled One Rule + 9) Failure No Discernible Text And Invalid Role Disabled One Rule ✔ Failure No Discernible Text And Invalid Role Disabled Rules 2 violations were detected in stories with "mode" set to "warn", so did not fail the test suite: @@ -391,9 +400,24 @@ Error: simple / Failure No Discernible Text Warn / Detected the following access 6 passing 6 pending - 8 failing + 9 failing - 1) delays + 1) advanced + Branding: + Detected the following accessibility violations! + + 1. color-contrast (Elements must meet minimum color contrast ratio thresholds) + + For more info, visit https://dequeuniversity.com/rules/axe/4.8/color-contrast?application=my-branding. + + Check these nodes: + + - html: + summary: Fix any of the following: + Element has insufficient color contrast of 1.51 (foreground color: #ff69b4, background color: #ff0000, font size: 10.0pt (13.3333px), font weight: normal). Expected contrast ratio of 4.5:1 + + + 2) delays Short Delay And Fail: Detected the following accessibility violations! @@ -408,7 +432,7 @@ Error: simple / Failure No Discernible Text Warn / Detected the following access Role must be one of the valid ARIA roles: wut-the-wut - 2) delays + 3) delays Medium Delay And Fail: Detected the following accessibility violations! @@ -423,19 +447,19 @@ Error: simple / Failure No Discernible Text Warn / Detected the following access Role must be one of the valid ARIA roles: wut-the-wut - 3) delays + 4) delays Medium Delay And Short Timeout Fail: Error: Timeout of 100ms exceeded. For async tests and hooks, ensure "done()" is called; if returning a Promise, ensure it resolves. at listOnTimeout (node:internal/timers) at processTimers (node:internal/timers) - 4) delays + 5) delays Long Delay And Timeout: Error: Timeout of 2000ms exceeded. For async tests and hooks, ensure "done()" is called; if returning a Promise, ensure it resolves. at listOnTimeout (node:internal/timers) at processTimers (node:internal/timers) - 5) simple + 6) simple Failure No Discernible Text: Detected the following accessibility violations! @@ -454,7 +478,7 @@ Error: simple / Failure No Discernible Text Warn / Detected the following access Element's default semantics were not overridden with role="none" or role="presentation" - 6) simple + 7) simple Failure Color Contrast: Detected the following accessibility violations! @@ -469,7 +493,7 @@ Error: simple / Failure No Discernible Text Warn / Detected the following access Element has insufficient color contrast of 1.51 (foreground color: #ff69b4, background color: #ff0000, font size: 10.0pt (13.3333px), font weight: normal). Expected contrast ratio of 4.5:1 - 7) simple + 8) simple Failure No Discernible Text And Invalid Role: Detected the following accessibility violations! @@ -498,7 +522,7 @@ Error: simple / Failure No Discernible Text Warn / Detected the following access Element's default semantics were not overridden with role="none" or role="presentation" - 8) simple + 9) simple Failure No Discernible Text And Invalid Role Disabled One Rule: Detected the following accessibility violations! From 418ee670a1b0ca12ad90588b039848757336cd7e Mon Sep 17 00:00:00 2001 From: Andrew Huth Date: Mon, 12 Feb 2024 10:36:01 -0500 Subject: [PATCH 2/2] Always reset axe config --- src/browser/AxePage.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/browser/AxePage.ts b/src/browser/AxePage.ts index ba215ea..5eb578f 100644 --- a/src/browser/AxePage.ts +++ b/src/browser/AxePage.ts @@ -35,15 +35,18 @@ function runAxe({ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore This function executes in a browser context. return window.axeQueue.add(() => { + // Always reset the axe config, so if one story sets its own config it doesn't affect the + // others. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore This function executes in a browser context. + window.axe.reset(); + if (config) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore This function executes in a browser context. window.axe.configure(config); - } else { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore This function executes in a browser context. - window.axe.reset(); } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore This function executes in a browser context. return window.axe.run(document, options);