diff --git a/.claude/skills/review-polish/SKILL.md b/.claude/skills/review-polish/SKILL.md index dc8db5089..5ff8836f8 100644 --- a/.claude/skills/review-polish/SKILL.md +++ b/.claude/skills/review-polish/SKILL.md @@ -158,7 +158,7 @@ Run from `frontend/`: 1. `npm run typecheck`: zero errors (`vue-tsc --build`, the app and the Node tooling). 2. `trunk check`: ESLint clean on the changed files. -3. `npm run test`: zero failures (Vitest + happy-dom; runs unit tests **and** story `play()` functions via `composeStories`). +3. `npm run test`: zero failures (unit tests in happy-dom, and every story's `play()` and axe check in headless Chromium; needs `npx playwright install chromium` once). 4. `npm run build`: zero failures (CI sanity check). **If you touched the backend API:** start the backend, run `npm run generate`, then re-`typecheck`. diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 95a17f21b..b48568cb7 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -33,7 +33,12 @@ jobs: - name: Set up the frontend uses: ./.github/actions/setup-frontend - - name: Run tests (vitest, includes Storybook play() tests) + # Stories run in browser mode, which needs only the headless shell. + - name: Install Playwright browser + run: npx playwright install --with-deps --only-shell chromium + working-directory: frontend + + - name: Run tests (vitest, includes Storybook play() and a11y tests) run: npm test working-directory: frontend diff --git a/DEVELOPER_SETUP.md b/DEVELOPER_SETUP.md index 19987ce47..37486f4d7 100644 --- a/DEVELOPER_SETUP.md +++ b/DEVELOPER_SETUP.md @@ -185,9 +185,11 @@ Component docs and visual QA for `frontend/src/v2/` (port 6006): ```sh npm run storybook -npm run storybook:test # composeStories + play() + a11y (every v2 story) +npm run storybook:test # play() + axe on every v2 story, in headless Chromium ``` +`storybook:test` (and `npm run test`, which includes it) needs Playwright's Chromium: run `npx playwright install chromium` once. + For responsive layouts, use the viewport toolbar presets from `.storybook/rommViewports.ts`. #### - Optional: local frontend against a remote RomM diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index 678d44e02..1b3ace5c0 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -7,6 +7,7 @@ const config: StorybookConfig = { "@storybook/addon-docs", "@storybook/addon-a11y", "@storybook/addon-themes", + "@storybook/addon-vitest", ], framework: { name: "@storybook/vue3-vite", diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 09b750ad9..c545096ab 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -14,7 +14,7 @@ npm install # install (Node 24) npm run dev # dev server :3000 npm run typecheck # vue-tsc --build: the app, the Node tooling and the e2e suite npm run typecheck:scripts # Node only: scripts/, eslint-plugin-romm/, tool configs, the e2e suite -npm run test # vitest (+ Storybook play() tests) +npm run test # vitest (+ Storybook play() and a11y tests in Chromium) npm run test:e2e # playwright, see e2e/README.md npm run build # production build npm run generate # regenerate types from backend OpenAPI (backend must be running) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7b00a07fa..7e3af8823 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -45,6 +45,7 @@ "@storybook/addon-a11y": "^10.2.19", "@storybook/addon-docs": "^10.2.19", "@storybook/addon-themes": "^10.2.19", + "@storybook/addon-vitest": "^10.6.0", "@storybook/vue3-vite": "^10.2.19", "@tailwindcss/vite": "^4.3.1", "@types/js-cookie": "^3.0.6", @@ -54,6 +55,7 @@ "@types/semver": "^7.7.0", "@types/spark-md5": "^3.0.5", "@vitejs/plugin-vue": "^6.0.7", + "@vitest/browser-playwright": "^5.0.2", "@vitest/ui": "^5.0.1", "@vue/eslint-config-typescript": "^14.9.0", "@vue/test-utils": "^2.4.9", @@ -1705,6 +1707,13 @@ "node": ">=6.9.0" } }, + "node_modules/@blazediff/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.10.0.tgz", + "integrity": "sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@cacheable/memory": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", @@ -4754,6 +4763,42 @@ "storybook": "^10.6.0" } }, + "node_modules/@storybook/addon-vitest": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.6.0.tgz", + "integrity": "sha512-GY2SwSXLNgyMqjQ59J7YTkEQTHdEbBMggOvbbuhuPUBhWo97OGSDCmn1UW3nXDZvkoWyWhF9T9628m1uei7FbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/icons": "^2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "@vitest/browser": "^3.0.0 || ^4.0.0", + "@vitest/browser-playwright": "^4.0.0", + "@vitest/runner": "^3.0.0 || ^4.0.0", + "storybook": "^10.6.0", + "vitest": "^3.0.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/runner": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, "node_modules/@storybook/builder-vite": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.6.0.tgz", @@ -5456,9 +5501,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "dev": true, "license": "MIT", "peer": true, @@ -6125,6 +6170,74 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/browser": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-5.0.2.tgz", + "integrity": "sha512-nX9xXZ/j8YfU7KTeFa+tpSP331SbI81qBQTASRlcopYeaTgBerNmJryzHXHrFOW0NYldd3Dn5gw651zGnmaQzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@blazediff/core": "1.10.0", + "@vitest/mocker": "5.0.2", + "@vitest/ui": "5.0.2", + "@vitest/utils": "5.0.2", + "magic-string": "^1.2.3", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.1.1", + "ws": "^8.21.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "5.0.2" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-5.0.2.tgz", + "integrity": "sha512-wU0B1Zy9+a55fdUT7JBCcnnjpTx0HHvUlQT6yFnuwCSY244BkPPwabNX+9PAljc96gRELcfm5APtnsn/3qiaPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "5.0.2", + "@vitest/mocker": "5.0.2", + "tinyrainbow": "^3.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "5.0.2" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, + "node_modules/@vitest/browser/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/browser/node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index a573a65e8..988b9edac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,7 +34,7 @@ "test:ui": "vitest --ui", "storybook": "storybook dev -p 6006", "storybook:build": "storybook build", - "storybook:test": "vitest run test/storybook.test.ts", + "storybook:test": "vitest run --project storybook", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:report": "playwright show-report e2e/.output/specs/report" @@ -76,6 +76,7 @@ "@storybook/addon-a11y": "^10.2.19", "@storybook/addon-docs": "^10.2.19", "@storybook/addon-themes": "^10.2.19", + "@storybook/addon-vitest": "^10.6.0", "@storybook/vue3-vite": "^10.2.19", "@tailwindcss/vite": "^4.3.1", "@types/js-cookie": "^3.0.6", @@ -85,6 +86,7 @@ "@types/semver": "^7.7.0", "@types/spark-md5": "^3.0.5", "@vitejs/plugin-vue": "^6.0.7", + "@vitest/browser-playwright": "^5.0.2", "@vitest/ui": "^5.0.1", "@vue/eslint-config-typescript": "^14.9.0", "@vue/test-utils": "^2.4.9", @@ -116,7 +118,13 @@ "overrides": { "rolldown": "1.2.11", "esbuild": "^0.28.1", - "form-data": "^4.0.6" + "form-data": "^4.0.6", + "@storybook/addon-vitest": { + "vitest": "$vitest", + "@vitest/browser": "$vitest", + "@vitest/runner": "$vitest", + "@vitest/browser-playwright": "$@vitest/browser-playwright" + } }, "engines": { "node": "24", diff --git a/frontend/src/v2/components/GameDetails/SaveDataTab.stories.ts b/frontend/src/v2/components/GameDetails/SaveDataTab.stories.ts index a29d62b59..089efebad 100644 --- a/frontend/src/v2/components/GameDetails/SaveDataTab.stories.ts +++ b/frontend/src/v2/components/GameDetails/SaveDataTab.stories.ts @@ -13,6 +13,7 @@ import { downloadButtons, pickSaveDataSubtab, } from "@/v2/utils/saveStates.plays"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import SaveDataTab from "./SaveDataTab.vue"; type Subtab = "saves" | "states"; @@ -46,9 +47,7 @@ async function waitForSubtab(root: HTMLElement, subtab: Subtab) { const meta: Meta = { title: "GameDetails/SaveDataTab", component: SaveDataTab, - parameters: { - layout: "fullscreen", - }, + parameters: { ...CONTRAST_TODO_PARAMETERS, layout: "fullscreen" }, args: { subtab: "saves", rom: storyDetailedRom(), diff --git a/frontend/src/v2/components/Player/ResumePanel.stories.ts b/frontend/src/v2/components/Player/ResumePanel.stories.ts index c883aeffc..eb3f86aaf 100644 --- a/frontend/src/v2/components/Player/ResumePanel.stories.ts +++ b/frontend/src/v2/components/Player/ResumePanel.stories.ts @@ -6,6 +6,7 @@ import type { SliderBtnGroupItem } from "@v2/lib/primitives/RSliderBtnGroup/type import { computed, ref } from "vue"; import type { SaveSchema, StateSchema } from "@/__generated__"; import { makeSave, manyStates } from "@/v2/utils/saveStates.fixtures"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import AssetList from "../shared/AssetList.vue"; import AssetStrip from "../shared/AssetStrip.vue"; import AssetPreview from "./AssetPreview.vue"; @@ -36,6 +37,7 @@ interface Args { const meta: Meta = { title: "Player/ResumePanel (composition)", + parameters: CONTRAST_TODO_PARAMETERS, decorators: [ () => ({ template: ` diff --git a/frontend/src/v2/components/shared/AssetChips.stories.ts b/frontend/src/v2/components/shared/AssetChips.stories.ts index afec56a7f..55f3790f4 100644 --- a/frontend/src/v2/components/shared/AssetChips.stories.ts +++ b/frontend/src/v2/components/shared/AssetChips.stories.ts @@ -1,11 +1,13 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; import { makeSave } from "@/v2/utils/saveStates.fixtures"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import AssetChips from "./AssetChips.vue"; // Chip content per prop is covered by AssetChips.test.ts. const meta: Meta = { title: "Shared/AssetChips", component: AssetChips, + parameters: CONTRAST_TODO_PARAMETERS, decorators: [ () => ({ template: ` diff --git a/frontend/src/v2/components/shared/AssetList.stories.ts b/frontend/src/v2/components/shared/AssetList.stories.ts index 338647ba6..ec47653b4 100644 --- a/frontend/src/v2/components/shared/AssetList.stories.ts +++ b/frontend/src/v2/components/shared/AssetList.stories.ts @@ -18,11 +18,13 @@ import { downloadButtons, selectableItems, } from "@/v2/utils/saveStates.plays"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import AssetList from "./AssetList.vue"; const meta: Meta = { title: "Shared/AssetList", component: AssetList, + parameters: CONTRAST_TODO_PARAMETERS, decorators: [ () => ({ template: ` diff --git a/frontend/src/v2/components/shared/AssetStrip.stories.ts b/frontend/src/v2/components/shared/AssetStrip.stories.ts index 9bc987f1b..1ace99ae3 100644 --- a/frontend/src/v2/components/shared/AssetStrip.stories.ts +++ b/frontend/src/v2/components/shared/AssetStrip.stories.ts @@ -11,11 +11,13 @@ import { screenshotFixture, } from "@/v2/utils/saveStates.fixtures"; import { downloadButtons, selectableItems } from "@/v2/utils/saveStates.plays"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import AssetStrip from "./AssetStrip.vue"; const meta: Meta = { title: "Shared/AssetStrip", component: AssetStrip, + parameters: CONTRAST_TODO_PARAMETERS, decorators: [ () => ({ template: ` diff --git a/frontend/src/v2/components/shared/MetadataProviderCard/MetadataProviderCard.stories.ts b/frontend/src/v2/components/shared/MetadataProviderCard/MetadataProviderCard.stories.ts index e6e9b1b00..a0e3a341a 100644 --- a/frontend/src/v2/components/shared/MetadataProviderCard/MetadataProviderCard.stories.ts +++ b/frontend/src/v2/components/shared/MetadataProviderCard/MetadataProviderCard.stories.ts @@ -6,6 +6,7 @@ // footer pinned to the bottom edge. import type { Meta, StoryObj } from "@storybook/vue3-vite"; import { RBtn } from "@v2/lib"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import MetadataProviderCard from "./MetadataProviderCard.vue"; import type { ProviderCardStatus } from "./types"; @@ -36,6 +37,7 @@ const checking: ProviderCardStatus = { const meta: Meta = { title: "Shared/MetadataProviderCard", component: MetadataProviderCard, + parameters: CONTRAST_TODO_PARAMETERS, // The canvas body is not theme-painted; give the cards the app's // background so the light theme reads correctly. decorators: [ diff --git a/frontend/src/v2/components/shared/PlatformSelect.stories.ts b/frontend/src/v2/components/shared/PlatformSelect.stories.ts index 141c52b02..3e8353c4e 100644 --- a/frontend/src/v2/components/shared/PlatformSelect.stories.ts +++ b/frontend/src/v2/components/shared/PlatformSelect.stories.ts @@ -3,6 +3,7 @@ import { expect, userEvent, within, waitFor } from "storybook/test"; import { ref } from "vue"; import type { Platform } from "@/stores/platforms"; import { platformFixture } from "@/utils/platform.fixtures"; +import { a11yTodoRules, CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import PlatformSelect from "./PlatformSelect.vue"; import { formatPlatformRomCount, @@ -122,14 +123,18 @@ async function openMenu(canvasElement: HTMLElement) { }); } +// The open panel's option list scrolls but can't take keyboard focus. +const OPEN_PANEL_A11Y_TODO = a11yTodoRules( + "color-contrast", + "scrollable-region-focusable", +); + // Generic components can't fill Storybook's `component` slot, so it's cast; // the instantiation types the stories' args. const meta: Meta> = { title: "Shared/PlatformSelect", component: PlatformSelect as never, - parameters: { - layout: "padded", - }, + parameters: { ...CONTRAST_TODO_PARAMETERS, layout: "padded" }, decorators: [ () => ({ template: `
`, @@ -160,6 +165,7 @@ export const PromotedOnPage: Story = { export const PromotedOpenMenu: Story = { name: "Promotion on — open menu, do not type", + parameters: OPEN_PANEL_A11Y_TODO, render: promoteFilledRender(), play: async ({ canvasElement, step }) => { await step("open menu (do not type in search)", async () => { @@ -252,6 +258,7 @@ export const PromotedTypingInSearch: Story = { export const PromotedSearchDisabled: Story = { name: "Promotion on — search field disabled", + parameters: OPEN_PANEL_A11Y_TODO, render: () => ({ components: { PlatformSelect }, setup() { @@ -297,6 +304,7 @@ export const PromotedAllLibrariesEmpty: Story = { export const ScanPagePicker: Story = { name: "Scan page picker, promotion on", + parameters: OPEN_PANEL_A11Y_TODO, render: () => ({ components: { PlatformSelect }, setup() { diff --git a/frontend/src/v2/lib/VisualLanguage.stories.ts b/frontend/src/v2/lib/VisualLanguage.stories.ts index a08fd140b..c2ecf1aa6 100644 --- a/frontend/src/v2/lib/VisualLanguage.stories.ts +++ b/frontend/src/v2/lib/VisualLanguage.stories.ts @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; import { defineComponent, h } from "vue"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; // Intro / design-token preview story. Exists to verify the Storybook pipeline // end-to-end (tokens + theme switcher) before any R-components land. @@ -122,9 +123,7 @@ const VisualLanguage = defineComponent({ const meta: Meta = { title: "Visual Language", component: VisualLanguage, - parameters: { - layout: "fullscreen", - }, + parameters: { ...CONTRAST_TODO_PARAMETERS, layout: "fullscreen" }, }; export default meta; diff --git a/frontend/src/v2/lib/forms/RComboboxField/RComboboxField.stories.ts b/frontend/src/v2/lib/forms/RComboboxField/RComboboxField.stories.ts index d09e9e5e2..02a6d6655 100644 --- a/frontend/src/v2/lib/forms/RComboboxField/RComboboxField.stories.ts +++ b/frontend/src/v2/lib/forms/RComboboxField/RComboboxField.stories.ts @@ -2,11 +2,13 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; import { RBtn, RForm } from "@v2/lib"; import { expect, userEvent } from "storybook/test"; import { ref } from "vue"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import RComboboxField from "./RComboboxField.vue"; const meta: Meta = { title: "Forms/RComboboxField", component: RComboboxField, + parameters: CONTRAST_TODO_PARAMETERS, argTypes: { prefixLabel: { control: "inline-radio", diff --git a/frontend/src/v2/lib/forms/RForm/RForm.stories.ts b/frontend/src/v2/lib/forms/RForm/RForm.stories.ts index 8357fd0bb..b305a47a8 100644 --- a/frontend/src/v2/lib/forms/RForm/RForm.stories.ts +++ b/frontend/src/v2/lib/forms/RForm/RForm.stories.ts @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; import { ref } from "vue"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import RBtn from "../../primitives/RBtn/RBtn.vue"; import RSelect from "../RSelect/RSelect.vue"; import RTextField from "../RTextField/RTextField.vue"; @@ -8,6 +9,7 @@ import RForm from "./RForm.vue"; const meta: Meta = { title: "Forms/RForm", component: RForm, + parameters: CONTRAST_TODO_PARAMETERS, }; export default meta; diff --git a/frontend/src/v2/lib/forms/RSelect/RSelect.stories.ts b/frontend/src/v2/lib/forms/RSelect/RSelect.stories.ts index 58656315a..1dadea034 100644 --- a/frontend/src/v2/lib/forms/RSelect/RSelect.stories.ts +++ b/frontend/src/v2/lib/forms/RSelect/RSelect.stories.ts @@ -5,6 +5,7 @@ import RMenu from "@/v2/lib/menus/RMenu/RMenu.vue"; import RMenuItem from "@/v2/lib/menus/RMenuItem/RMenuItem.vue"; import RBtn from "@/v2/lib/primitives/RBtn/RBtn.vue"; import RIcon from "@/v2/lib/primitives/RIcon/RIcon.vue"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import RSelect from "./RSelect.vue"; const ROLES = [ @@ -33,6 +34,7 @@ const PLATFORMS = [ const meta: Meta> = { title: "Forms/RSelect", component: RSelect as never, + parameters: CONTRAST_TODO_PARAMETERS, argTypes: { variant: { control: "inline-radio", diff --git a/frontend/src/v2/lib/forms/RSlider/RSlider.stories.ts b/frontend/src/v2/lib/forms/RSlider/RSlider.stories.ts index 42c04240c..107ca1838 100644 --- a/frontend/src/v2/lib/forms/RSlider/RSlider.stories.ts +++ b/frontend/src/v2/lib/forms/RSlider/RSlider.stories.ts @@ -1,10 +1,12 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; import { ref } from "vue"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import RSlider from "./RSlider.vue"; const meta: Meta = { title: "Forms/RSlider", component: RSlider, + parameters: CONTRAST_TODO_PARAMETERS, // RSlider has no visible label, so every instance needs `ariaLabel`. args: { ariaLabel: "Value" }, argTypes: { diff --git a/frontend/src/v2/lib/forms/RTextField/RTextField.stories.ts b/frontend/src/v2/lib/forms/RTextField/RTextField.stories.ts index 8176c29c5..83467c764 100644 --- a/frontend/src/v2/lib/forms/RTextField/RTextField.stories.ts +++ b/frontend/src/v2/lib/forms/RTextField/RTextField.stories.ts @@ -1,11 +1,13 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; import { ref } from "vue"; import RBtn from "@/v2/lib/primitives/RBtn/RBtn.vue"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import RTextField from "./RTextField.vue"; const meta: Meta = { title: "Forms/RTextField", component: RTextField, + parameters: CONTRAST_TODO_PARAMETERS, argTypes: { variant: { control: "inline-radio", diff --git a/frontend/src/v2/lib/menus/RMenuItem/RMenuItem.stories.ts b/frontend/src/v2/lib/menus/RMenuItem/RMenuItem.stories.ts index 779cb81e8..a37fb680b 100644 --- a/frontend/src/v2/lib/menus/RMenuItem/RMenuItem.stories.ts +++ b/frontend/src/v2/lib/menus/RMenuItem/RMenuItem.stories.ts @@ -116,6 +116,9 @@ export const NewTabGesture: Story = { const canvas = within(canvasElement); const link = canvas.getByText("Open in new tab").closest("a"); if (!link) throw new Error("expected the item to render as an "); + // In a real browser the Ctrl-click would open the link and tear down the + // test page; cancel it after the item's own handler has seen the event. + canvasElement.addEventListener("click", (event) => event.preventDefault()); await step("plain click activates the item", async () => { link.dispatchEvent( diff --git a/frontend/src/v2/lib/overlays/RCarousel/RCarousel.stories.ts b/frontend/src/v2/lib/overlays/RCarousel/RCarousel.stories.ts index d0114a4fc..1a09c2305 100644 --- a/frontend/src/v2/lib/overlays/RCarousel/RCarousel.stories.ts +++ b/frontend/src/v2/lib/overlays/RCarousel/RCarousel.stories.ts @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; import { expect, fn, userEvent, within } from "storybook/test"; import { type Component, ref } from "vue"; import RBtn from "@/v2/lib/primitives/RBtn/RBtn.vue"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import RCarousel from "./RCarousel.vue"; // RCarousel is generic (``), so its component-typed shape doesn't fit @@ -26,6 +27,7 @@ const SAMPLES = [ const meta: Meta = { title: "Overlays/RCarousel", component: RCarousel as never, + parameters: CONTRAST_TODO_PARAMETERS, }; export default meta; diff --git a/frontend/src/v2/lib/overlays/RDrawer/RDrawer.stories.ts b/frontend/src/v2/lib/overlays/RDrawer/RDrawer.stories.ts index e7320ab7a..deb1132ac 100644 --- a/frontend/src/v2/lib/overlays/RDrawer/RDrawer.stories.ts +++ b/frontend/src/v2/lib/overlays/RDrawer/RDrawer.stories.ts @@ -2,11 +2,13 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; import { ref } from "vue"; import RTextField from "@/v2/lib/forms/RTextField/RTextField.vue"; import RBtn from "@/v2/lib/primitives/RBtn/RBtn.vue"; +import { a11yTodoRules, CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import RDrawer from "./RDrawer.vue"; const meta: Meta = { title: "Overlays/RDrawer", component: RDrawer, + parameters: CONTRAST_TODO_PARAMETERS, argTypes: { side: { control: "inline-radio", options: ["left", "right"] }, width: { control: "text" }, @@ -73,10 +75,14 @@ export const LeftSide: Story = { export const TallContent: Story = { name: "Tall content (scrolling body)", + // The scrolling body can't take keyboard focus. args: { side: "right", width: 380, icon: "mdi-format-list-bulleted" }, // Opens on mount, so the docs page renders it in its own iframe to keep // the fixed drawer from covering the page. - parameters: { docs: { story: { inline: false, height: "560px" } } }, + parameters: { + ...a11yTodoRules("color-contrast", "scrollable-region-focusable"), + docs: { story: { inline: false, height: "560px" } }, + }, render: (args) => ({ components: { RDrawer, RBtn, RTextField }, setup: () => ({ diff --git a/frontend/src/v2/lib/primitives/RAlert/RAlert.stories.ts b/frontend/src/v2/lib/primitives/RAlert/RAlert.stories.ts index 97f8fb33e..cee04e4ba 100644 --- a/frontend/src/v2/lib/primitives/RAlert/RAlert.stories.ts +++ b/frontend/src/v2/lib/primitives/RAlert/RAlert.stories.ts @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; import { ref } from "vue"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import RBtn from "../RBtn/RBtn.vue"; import "./RAlert.stories.css"; import RAlert from "./RAlert.vue"; @@ -7,6 +8,7 @@ import RAlert from "./RAlert.vue"; const meta: Meta = { title: "Primitives/RAlert", component: RAlert, + parameters: CONTRAST_TODO_PARAMETERS, argTypes: { type: { control: "select", diff --git a/frontend/src/v2/lib/primitives/RAvatar/RAvatar.stories.ts b/frontend/src/v2/lib/primitives/RAvatar/RAvatar.stories.ts index 73232a10c..70c98cd7d 100644 --- a/frontend/src/v2/lib/primitives/RAvatar/RAvatar.stories.ts +++ b/frontend/src/v2/lib/primitives/RAvatar/RAvatar.stories.ts @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import RAvatar from "./RAvatar.vue"; const meta: Meta = { title: "Primitives/RAvatar", component: RAvatar, + parameters: CONTRAST_TODO_PARAMETERS, argTypes: { size: { control: "text" }, color: { control: "text" }, diff --git a/frontend/src/v2/lib/primitives/RBadge/RBadge.stories.ts b/frontend/src/v2/lib/primitives/RBadge/RBadge.stories.ts index 26c67a40b..bc3b25991 100644 --- a/frontend/src/v2/lib/primitives/RBadge/RBadge.stories.ts +++ b/frontend/src/v2/lib/primitives/RBadge/RBadge.stories.ts @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; import { ref } from "vue"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import RAvatar from "../RAvatar/RAvatar.vue"; import RIcon from "../RIcon/RIcon.vue"; import RBadge from "./RBadge.vue"; @@ -7,6 +8,7 @@ import RBadge from "./RBadge.vue"; const meta: Meta = { title: "Primitives/RBadge", component: RBadge, + parameters: CONTRAST_TODO_PARAMETERS, argTypes: { content: { control: "text" }, color: { control: "text" }, diff --git a/frontend/src/v2/lib/primitives/RBtn/RBtn.stories.ts b/frontend/src/v2/lib/primitives/RBtn/RBtn.stories.ts index bb1dc840b..e556a2ab8 100644 --- a/frontend/src/v2/lib/primitives/RBtn/RBtn.stories.ts +++ b/frontend/src/v2/lib/primitives/RBtn/RBtn.stories.ts @@ -1,11 +1,13 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; import { expect, userEvent, within } from "storybook/test"; import { ref } from "vue"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import RBtn from "./RBtn.vue"; const meta: Meta = { title: "Primitives/RBtn", component: RBtn, + parameters: CONTRAST_TODO_PARAMETERS, argTypes: { color: { control: "text" }, size: { diff --git a/frontend/src/v2/lib/primitives/RCard/RCard.stories.ts b/frontend/src/v2/lib/primitives/RCard/RCard.stories.ts index cf80befc4..df54876bd 100644 --- a/frontend/src/v2/lib/primitives/RCard/RCard.stories.ts +++ b/frontend/src/v2/lib/primitives/RCard/RCard.stories.ts @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import RCard from "./RCard.vue"; const meta: Meta = { title: "Primitives/RCard", component: RCard, + parameters: CONTRAST_TODO_PARAMETERS, argTypes: { color: { control: "text" }, elevation: { control: "number" }, diff --git a/frontend/src/v2/lib/primitives/RChip/RChip.stories.ts b/frontend/src/v2/lib/primitives/RChip/RChip.stories.ts index 4f5f18c5b..6395cb7a5 100644 --- a/frontend/src/v2/lib/primitives/RChip/RChip.stories.ts +++ b/frontend/src/v2/lib/primitives/RChip/RChip.stories.ts @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; import { ref } from "vue"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import RChip from "./RChip.vue"; /** Strings that expose descenders, ascenders, and mixed scripts in tight pills. */ @@ -18,6 +19,7 @@ const CHIP_SIZES = ["x-small", "small", "default", "large", "x-large"] as const; const meta: Meta = { title: "Primitives/RChip", component: RChip, + parameters: CONTRAST_TODO_PARAMETERS, argTypes: { size: { control: "select", diff --git a/frontend/src/v2/lib/primitives/RDivider/RDivider.stories.ts b/frontend/src/v2/lib/primitives/RDivider/RDivider.stories.ts index 069d33374..eb86833bc 100644 --- a/frontend/src/v2/lib/primitives/RDivider/RDivider.stories.ts +++ b/frontend/src/v2/lib/primitives/RDivider/RDivider.stories.ts @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import RDivider from "./RDivider.vue"; const meta: Meta = { title: "Primitives/RDivider", component: RDivider, + parameters: CONTRAST_TODO_PARAMETERS, argTypes: { thickness: { control: "text" }, }, diff --git a/frontend/src/v2/lib/primitives/RImg/RImg.stories.ts b/frontend/src/v2/lib/primitives/RImg/RImg.stories.ts index b0215c029..99191eb11 100644 --- a/frontend/src/v2/lib/primitives/RImg/RImg.stories.ts +++ b/frontend/src/v2/lib/primitives/RImg/RImg.stories.ts @@ -1,11 +1,13 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; import { onMounted, onUnmounted, ref } from "vue"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import "./RImg.stories.css"; import RImg from "./RImg.vue"; const meta: Meta = { title: "Primitives/RImg", component: RImg, + parameters: CONTRAST_TODO_PARAMETERS, argTypes: { width: { control: "text" }, height: { control: "text" }, diff --git a/frontend/src/v2/lib/primitives/RSteps/RSteps.stories.ts b/frontend/src/v2/lib/primitives/RSteps/RSteps.stories.ts index 40a46d232..a5d9250f9 100644 --- a/frontend/src/v2/lib/primitives/RSteps/RSteps.stories.ts +++ b/frontend/src/v2/lib/primitives/RSteps/RSteps.stories.ts @@ -1,11 +1,13 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; import { ref } from "vue"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import RBtn from "../RBtn/RBtn.vue"; import RSteps from "./RSteps.vue"; const meta: Meta = { title: "Primitives/RSteps", component: RSteps, + parameters: CONTRAST_TODO_PARAMETERS, argTypes: { current: { control: { type: "number", min: 1, max: 5 } }, total: { control: { type: "number", min: 2, max: 6 } }, diff --git a/frontend/src/v2/lib/primitives/RTabNav/RTabNav.stories.ts b/frontend/src/v2/lib/primitives/RTabNav/RTabNav.stories.ts index 0fd76439c..4058af73f 100644 --- a/frontend/src/v2/lib/primitives/RTabNav/RTabNav.stories.ts +++ b/frontend/src/v2/lib/primitives/RTabNav/RTabNav.stories.ts @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; import { expect, userEvent, within } from "storybook/test"; import { ref } from "vue"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import RTabNav from "./RTabNav.vue"; // Generic components can't fill Storybook's `component` slot, so it's cast; @@ -8,6 +9,7 @@ import RTabNav from "./RTabNav.vue"; const meta: Meta> = { title: "Primitives/RTabNav", component: RTabNav as never, + parameters: CONTRAST_TODO_PARAMETERS, argTypes: { size: { control: "select", diff --git a/frontend/src/v2/lib/primitives/RTag/RTag.stories.ts b/frontend/src/v2/lib/primitives/RTag/RTag.stories.ts index 5cb155223..3dd2d5f2e 100644 --- a/frontend/src/v2/lib/primitives/RTag/RTag.stories.ts +++ b/frontend/src/v2/lib/primitives/RTag/RTag.stories.ts @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import RTag from "./RTag.vue"; const meta: Meta = { title: "Primitives/RTag", component: RTag, + parameters: CONTRAST_TODO_PARAMETERS, argTypes: { text: { control: "text" }, size: { diff --git a/frontend/src/v2/lib/structural/RCollapsible/RCollapsible.stories.ts b/frontend/src/v2/lib/structural/RCollapsible/RCollapsible.stories.ts index fd6176815..ae7350370 100644 --- a/frontend/src/v2/lib/structural/RCollapsible/RCollapsible.stories.ts +++ b/frontend/src/v2/lib/structural/RCollapsible/RCollapsible.stories.ts @@ -1,11 +1,13 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; import { ref } from "vue"; import RTag from "@/v2/lib/primitives/RTag/RTag.vue"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import RCollapsible from "./RCollapsible.vue"; const meta: Meta = { title: "Structural/RCollapsible", component: RCollapsible, + parameters: CONTRAST_TODO_PARAMETERS, render: (args) => ({ components: { RCollapsible }, setup: () => { diff --git a/frontend/src/v2/lib/structural/RList/RList.stories.ts b/frontend/src/v2/lib/structural/RList/RList.stories.ts index aefd44162..fbba1b007 100644 --- a/frontend/src/v2/lib/structural/RList/RList.stories.ts +++ b/frontend/src/v2/lib/structural/RList/RList.stories.ts @@ -1,10 +1,12 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import RListItem from "../RListItem/RListItem.vue"; import RList from "./RList.vue"; const meta: Meta = { title: "Structural/RList", component: RList, + parameters: CONTRAST_TODO_PARAMETERS, argTypes: { density: { control: "select", diff --git a/frontend/src/v2/lib/structural/RListItem/RListItem.stories.ts b/frontend/src/v2/lib/structural/RListItem/RListItem.stories.ts index 92ef93155..1edb188dc 100644 --- a/frontend/src/v2/lib/structural/RListItem/RListItem.stories.ts +++ b/frontend/src/v2/lib/structural/RListItem/RListItem.stories.ts @@ -1,11 +1,13 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; import { ref } from "vue"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import RList from "../RList/RList.vue"; import RListItem from "./RListItem.vue"; const meta: Meta = { title: "Structural/RListItem", component: RListItem, + parameters: CONTRAST_TODO_PARAMETERS, argTypes: { rounded: { control: "text" }, }, diff --git a/frontend/src/v2/lib/structural/RToolbar/RToolbar.stories.ts b/frontend/src/v2/lib/structural/RToolbar/RToolbar.stories.ts index 515aa2246..6fbf80883 100644 --- a/frontend/src/v2/lib/structural/RToolbar/RToolbar.stories.ts +++ b/frontend/src/v2/lib/structural/RToolbar/RToolbar.stories.ts @@ -1,11 +1,13 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; import RBtn from "@/v2/lib/primitives/RBtn/RBtn.vue"; import RIcon from "@/v2/lib/primitives/RIcon/RIcon.vue"; +import { CONTRAST_TODO_PARAMETERS } from "@/v2/utils/storyA11y"; import RToolbar from "./RToolbar.vue"; const meta: Meta = { title: "Structural/RToolbar", component: RToolbar, + parameters: CONTRAST_TODO_PARAMETERS, argTypes: { color: { control: "text" }, density: { diff --git a/frontend/src/v2/lib/structural/RVirtualScroller/RVirtualScroller.stories.ts b/frontend/src/v2/lib/structural/RVirtualScroller/RVirtualScroller.stories.ts index 958d085fe..4aca45f3e 100644 --- a/frontend/src/v2/lib/structural/RVirtualScroller/RVirtualScroller.stories.ts +++ b/frontend/src/v2/lib/structural/RVirtualScroller/RVirtualScroller.stories.ts @@ -1,9 +1,12 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; +import { a11yTodoRules } from "@/v2/utils/storyA11y"; import RVirtualScroller from "./RVirtualScroller.vue"; const meta: Meta = { title: "Structural/RVirtualScroller", component: RVirtualScroller, + // The scroll viewport isn't focusable, so keyboard users can't scroll it. + parameters: a11yTodoRules("scrollable-region-focusable"), argTypes: { // An object control would print all 5000 rows into the docs table. items: { control: false }, diff --git a/frontend/src/v2/utils/storyA11y.ts b/frontend/src/v2/utils/storyA11y.ts new file mode 100644 index 000000000..7d56c1e8b --- /dev/null +++ b/frontend/src/v2/utils/storyA11y.ts @@ -0,0 +1,13 @@ +type AxeRuleId = "color-contrast" | "scrollable-region-focusable"; + +// Storybook replaces arrays when merging parameters, so a story lists every +// rule it skips, including those its meta skips. +export function a11yTodoRules(...ids: AxeRuleId[]) { + return { + a11y: { config: { rules: ids.map((id) => ({ id, enabled: false })) } }, + }; +} + +// The brand-purple and on-brand text tokens fall short of 4.5:1. Remove from a +// story file once its contrast passes, so the rule guards it from then on. +export const CONTRAST_TODO_PARAMETERS = a11yTodoRules("color-contrast"); diff --git a/frontend/test/storybook.test.ts b/frontend/test/storybook.test.ts deleted file mode 100644 index fa537b1e2..000000000 --- a/frontend/test/storybook.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { composeStories, setProjectAnnotations } from "@storybook/vue3-vite"; -import axe from "axe-core"; -import { describe, expect, it } from "vitest"; -import * as previewAnnotations from "../.storybook/preview"; - -type StoryModule = Record; - -// Per-story addon-a11y config. `test`: "error" fails on violations, "todo" -// warns (ratchet escape hatch), "off" skips. `options` forwards to axe.run. -type A11yParameters = { - test?: "error" | "todo" | "off"; - options?: axe.RunOptions; -}; - -type ComposedStory = { - run?: (context?: { canvasElement?: HTMLElement }) => Promise; - parameters?: { a11y?: A11yParameters }; -}; - -// axe over a full composite takes about a second locally and several times -// that on a shared CI runner, past Vitest's 5s default. -const STORY_TIMEOUT_MS = 20_000; - -// color-contrast needs real layout, which happy-dom lacks; disable it here -// (covered by the a11y panel / a future browser-mode run). -const DEFAULT_AXE_OPTIONS: axe.RunOptions = { - rules: { "color-contrast": { enabled: false } }, -}; - -function formatViolations(violations: axe.Result[]): string { - return violations - .map((v) => { - const nodes = v.nodes - .map((n) => ` ${n.html}\n ${n.failureSummary ?? ""}`) - .join("\n"); - return ` [${v.impact ?? "n/a"}] ${v.id}: ${v.help}\n${nodes}\n ${v.helpUrl}`; - }) - .join("\n\n"); -} - -async function checkA11y( - elements: HTMLElement[], - params: A11yParameters, - storyName: string, -): Promise { - const mode = params.test ?? "error"; - if (mode === "off") return; - - const options: axe.RunOptions = { - ...DEFAULT_AXE_OPTIONS, - ...params.options, - rules: { ...DEFAULT_AXE_OPTIONS.rules, ...params.options?.rules }, - }; - - const results = await axe.run({ include: elements }, options); - if (results.violations.length === 0) return; - - const report = formatViolations(results.violations); - if (mode === "todo") { - console.warn( - `a11y (todo) "${storyName}" has ${results.violations.length} violation(s):\n${report}`, - ); - return; - } - - expect( - results.violations, - `Accessibility violations in "${storyName}":\n${report}`, - ).toHaveLength(0); -} - -// Kept out of vitest.setup.ts so only the story test loads full Vuetify. -setProjectAnnotations([ - previewAnnotations as Parameters[0][number], -]); - -const storyModules = import.meta.glob( - "../src/v2/**/*.stories.ts", - { - eager: true, - }, -); - -for (const [path, module] of Object.entries(storyModules)) { - const composed = composeStories(module); - describe(path.replace(/^\.\.\//, ""), () => { - for (const [name, story] of Object.entries(composed)) { - const s = story as ComposedStory; - it( - name, - async () => { - if (typeof s.run !== "function") { - throw new Error( - `Story "${name}" has no run() helper — composeStories incompatible.`, - ); - } - // Render into an owned canvas so axe scans exactly this story's - // subtree, then clean it up so stories stay isolated. - const canvasElement = document.createElement("div"); - const before = new Set(document.body.children); - document.body.appendChild(canvasElement); - // Overlays teleport to , so whatever the story left there is - // its own, scanned with the canvas and removed after it. - const teleported = () => - [...document.body.children].filter( - (el): el is HTMLElement => - el instanceof HTMLElement && - el !== canvasElement && - !before.has(el), - ); - try { - await s.run({ canvasElement }); - await checkA11y( - [canvasElement, ...teleported()], - s.parameters?.a11y ?? {}, - `${path.replace(/^\.\.\//, "")} > ${name}`, - ); - } finally { - canvasElement.remove(); - for (const el of teleported()) el.remove(); - } - }, - STORY_TIMEOUT_MS, - ); - } - }); -} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index c23e9e7e1..fc3737e24 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -1,6 +1,11 @@ { "extends": "@vue/tsconfig/tsconfig.dom.json", - "include": ["src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts"], + "include": [ + "src/**/*.ts", + "src/**/*.vue", + "src/**/*.d.ts", + ".storybook/**/*.ts" + ], "exclude": ["node_modules"], "compilerOptions": { "incremental": true, diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index a5da93d3b..2612b1643 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -1,4 +1,6 @@ +import { storybookTest } from "@storybook/addon-vitest/vitest-plugin"; import vue from "@vitejs/plugin-vue"; +import { playwright } from "@vitest/browser-playwright"; import vuetify from "vite-plugin-vuetify"; import { defineConfig } from "vitest/config"; import { appAliases } from "./scripts/aliases"; @@ -32,6 +34,30 @@ export default defineConfig({ include: ["src/**/*.{test,spec}.ts", "test/**/*.{test,spec}.ts"], }, }, + // Every story renders in real Chromium, where addon-a11y runs axe on it + // with layout (so color-contrast is checked) and honours `parameters.a11y`. + { + extends: true, + plugins: [storybookTest({ configDir: ".storybook" })], + // A dependency found mid-run reloads the page and silently drops the + // tests in flight, so pre-bundle everything stories reach up front. + optimizeDeps: { + include: ["vuetify/components/*", "country-flag-emoji-polyfill"], + }, + // Mirrors vite.config.js, which the router's `process.env.BASE_URL` needs. + define: { "process.env": {} }, + test: { + name: "storybook", + browser: { + enabled: true, + headless: true, + provider: playwright(), + // Storybook's default `rommDesktopMd` viewport. + viewport: { width: 1024, height: 768 }, + instances: [{ browser: "chromium" }], + }, + }, + }, // Lint rules parse source text, so they skip the app's DOM and Storybook setup. { test: {