test(frontend): run Storybook stories and a11y checks in Chromium via addon-vitest (#5153)

This commit is contained in:
Sam Dornan
2026-10-06 12:59:12 -04:00
committed by GitHub
parent dcfb4ec86a
commit 208bfa7889
43 changed files with 263 additions and 147 deletions
+1 -1
View File
@@ -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`.
+6 -1
View File
@@ -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
+3 -1
View File
@@ -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
+1
View File
@@ -7,6 +7,7 @@ const config: StorybookConfig = {
"@storybook/addon-docs",
"@storybook/addon-a11y",
"@storybook/addon-themes",
"@storybook/addon-vitest",
],
framework: {
name: "@storybook/vue3-vite",
+1 -1
View File
@@ -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)
+116 -3
View File
@@ -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",
+10 -2
View File
@@ -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",
@@ -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<StoryArgs> = {
title: "GameDetails/SaveDataTab",
component: SaveDataTab,
parameters: {
layout: "fullscreen",
},
parameters: { ...CONTRAST_TODO_PARAMETERS, layout: "fullscreen" },
args: {
subtab: "saves",
rom: storyDetailedRom(),
@@ -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<Args> = {
title: "Player/ResumePanel (composition)",
parameters: CONTRAST_TODO_PARAMETERS,
decorators: [
() => ({
template: `
@@ -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<typeof AssetChips> = {
title: "Shared/AssetChips",
component: AssetChips,
parameters: CONTRAST_TODO_PARAMETERS,
decorators: [
() => ({
template: `
@@ -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<typeof AssetList> = {
title: "Shared/AssetList",
component: AssetList,
parameters: CONTRAST_TODO_PARAMETERS,
decorators: [
() => ({
template: `
@@ -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<typeof AssetStrip> = {
title: "Shared/AssetStrip",
component: AssetStrip,
parameters: CONTRAST_TODO_PARAMETERS,
decorators: [
() => ({
template: `
@@ -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<typeof MetadataProviderCard> = {
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: [
@@ -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<typeof PlatformSelect<number | null>> = {
title: "Shared/PlatformSelect",
component: PlatformSelect as never,
parameters: {
layout: "padded",
},
parameters: { ...CONTRAST_TODO_PARAMETERS, layout: "padded" },
decorators: [
() => ({
template: `<div style="width:min(360px,100%);padding-top:8px"><story /></div>`,
@@ -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() {
@@ -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<typeof VisualLanguage> = {
title: "Visual Language",
component: VisualLanguage,
parameters: {
layout: "fullscreen",
},
parameters: { ...CONTRAST_TODO_PARAMETERS, layout: "fullscreen" },
};
export default meta;
@@ -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<typeof RComboboxField> = {
title: "Forms/RComboboxField",
component: RComboboxField,
parameters: CONTRAST_TODO_PARAMETERS,
argTypes: {
prefixLabel: {
control: "inline-radio",
@@ -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<typeof RForm> = {
title: "Forms/RForm",
component: RForm,
parameters: CONTRAST_TODO_PARAMETERS,
};
export default meta;
@@ -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<typeof RSelect<unknown, unknown>> = {
title: "Forms/RSelect",
component: RSelect as never,
parameters: CONTRAST_TODO_PARAMETERS,
argTypes: {
variant: {
control: "inline-radio",
@@ -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<typeof RSlider> = {
title: "Forms/RSlider",
component: RSlider,
parameters: CONTRAST_TODO_PARAMETERS,
// RSlider has no visible label, so every instance needs `ariaLabel`.
args: { ariaLabel: "Value" },
argTypes: {
@@ -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<typeof RTextField> = {
title: "Forms/RTextField",
component: RTextField,
parameters: CONTRAST_TODO_PARAMETERS,
argTypes: {
variant: {
control: "inline-radio",
@@ -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 <a>");
// 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(
@@ -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 (`<T>`), 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;
@@ -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<typeof RDrawer> = {
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: () => ({
@@ -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<typeof RAlert> = {
title: "Primitives/RAlert",
component: RAlert,
parameters: CONTRAST_TODO_PARAMETERS,
argTypes: {
type: {
control: "select",
@@ -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<typeof RAvatar> = {
title: "Primitives/RAvatar",
component: RAvatar,
parameters: CONTRAST_TODO_PARAMETERS,
argTypes: {
size: { control: "text" },
color: { control: "text" },
@@ -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<typeof RBadge> = {
title: "Primitives/RBadge",
component: RBadge,
parameters: CONTRAST_TODO_PARAMETERS,
argTypes: {
content: { control: "text" },
color: { control: "text" },
@@ -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<typeof RBtn> = {
title: "Primitives/RBtn",
component: RBtn,
parameters: CONTRAST_TODO_PARAMETERS,
argTypes: {
color: { control: "text" },
size: {
@@ -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<typeof RCard> = {
title: "Primitives/RCard",
component: RCard,
parameters: CONTRAST_TODO_PARAMETERS,
argTypes: {
color: { control: "text" },
elevation: { control: "number" },
@@ -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<typeof RChip> = {
title: "Primitives/RChip",
component: RChip,
parameters: CONTRAST_TODO_PARAMETERS,
argTypes: {
size: {
control: "select",
@@ -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<typeof RDivider> = {
title: "Primitives/RDivider",
component: RDivider,
parameters: CONTRAST_TODO_PARAMETERS,
argTypes: {
thickness: { control: "text" },
},
@@ -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<typeof RImg> = {
title: "Primitives/RImg",
component: RImg,
parameters: CONTRAST_TODO_PARAMETERS,
argTypes: {
width: { control: "text" },
height: { control: "text" },
@@ -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<typeof RSteps> = {
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 } },
@@ -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<typeof RTabNav<string>> = {
title: "Primitives/RTabNav",
component: RTabNav as never,
parameters: CONTRAST_TODO_PARAMETERS,
argTypes: {
size: {
control: "select",
@@ -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<typeof RTag> = {
title: "Primitives/RTag",
component: RTag,
parameters: CONTRAST_TODO_PARAMETERS,
argTypes: {
text: { control: "text" },
size: {
@@ -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<typeof RCollapsible> = {
title: "Structural/RCollapsible",
component: RCollapsible,
parameters: CONTRAST_TODO_PARAMETERS,
render: (args) => ({
components: { RCollapsible },
setup: () => {
@@ -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<typeof RList> = {
title: "Structural/RList",
component: RList,
parameters: CONTRAST_TODO_PARAMETERS,
argTypes: {
density: {
control: "select",
@@ -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<typeof RListItem> = {
title: "Structural/RListItem",
component: RListItem,
parameters: CONTRAST_TODO_PARAMETERS,
argTypes: {
rounded: { control: "text" },
},
@@ -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<typeof RToolbar> = {
title: "Structural/RToolbar",
component: RToolbar,
parameters: CONTRAST_TODO_PARAMETERS,
argTypes: {
color: { control: "text" },
density: {
@@ -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<typeof RVirtualScroller> = {
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 },
+13
View File
@@ -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");
-127
View File
@@ -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<string, unknown>;
// 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<void>;
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<void> {
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<typeof setProjectAnnotations>[0][number],
]);
const storyModules = import.meta.glob<StoryModule>(
"../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 <body>, 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,
);
}
});
}
+6 -1
View File
@@ -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,
+26
View File
@@ -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: {