diff --git a/scripts/harness/run-storybook-a11y.mjs b/scripts/harness/run-storybook-a11y.mjs index 90c08ff..da32fdb 100644 --- a/scripts/harness/run-storybook-a11y.mjs +++ b/scripts/harness/run-storybook-a11y.mjs @@ -3,6 +3,7 @@ import path from "node:path"; import { createRequire } from "node:module"; import { chromium } from "@playwright/test"; +import harnessStoryContract from "../../tests/e2e/support/story-harness-contract.json" with { type: "json" }; import { repoRoot } from "./core.mjs"; @@ -16,65 +17,36 @@ const axeSourcePath = require.resolve("axe-core/axe.min.js"); const reportDir = path.join(repoRoot, ".artifacts", "a11y"); const reportPath = path.join(reportDir, "storybook-a11y.json"); -const stories = [ - { - id: "components-button--playground", - label: "Button playground" +const a11yPrepareHandlers = { + "open-date-picker": async (page) => { + await page.getByRole("combobox", { name: "Launch date" }).click(); + await page.getByRole("dialog", { name: "Launch date calendar" }).waitFor({ state: "visible" }); }, - { - id: "components-combobox--controlled", - label: "Combobox controlled" + "open-dialog": async (page) => { + await page.getByRole("button", { name: "Open approval dialog" }).click(); + await page.getByRole("dialog", { name: "Launch this release?" }).waitFor({ state: "visible" }); }, - { - id: "components-data-table--playground", - label: "Data table playground" + "open-dropdown-menu": async (page) => { + await page.getByRole("button", { name: "Review lane menu" }).click(); + await page.getByRole("menu").waitFor({ state: "visible" }); }, - { - id: "components-datepicker--playground", - label: "Date picker playground", - prepare: async (page) => { - await page.getByRole("combobox", { name: "Launch date" }).click(); - await page.getByRole("dialog", { name: "Launch date calendar" }).waitFor({ state: "visible" }); - } + "open-popover": async (page) => { + await page.getByRole("button", { name: "Inspect summary" }).click(); + await page.getByText("Release health").waitFor({ state: "visible" }); }, - { - id: "components-dialog--playground", - label: "Dialog playground", - prepare: async (page) => { - await page.getByRole("button", { name: "Open approval dialog" }).click(); - await page.getByRole("dialog", { name: "Launch this release?" }).waitFor({ state: "visible" }); - } - }, - { - id: "components-dropdownmenu--states", - label: "Dropdown menu states", - prepare: async (page) => { - await page.getByRole("button", { name: "Review lane menu" }).click(); - await page.getByRole("menu").waitFor({ state: "visible" }); - } - }, - { - id: "components-form--launch-settings", - label: "Form launch settings", - query: "globals=motion:reduced" - }, - { - id: "components-popover--playground", - label: "Popover playground", - prepare: async (page) => { - await page.getByRole("button", { name: "Inspect summary" }).click(); - await page.getByText("Release health").waitFor({ state: "visible" }); - } - }, - { - id: "components-sheet--playground", - label: "Sheet playground", - prepare: async (page) => { - await page.getByRole("button", { name: "Open right sheet" }).click(); - await page.getByRole("dialog", { name: "Launch settings" }).waitFor({ state: "visible" }); - } + "open-sheet": async (page) => { + await page.getByRole("button", { name: "Open right sheet" }).click(); + await page.getByRole("dialog", { name: "Launch settings" }).waitFor({ state: "visible" }); } -]; +}; + +const stories = harnessStoryContract.stories.filter((story) => story.suites.includes("a11y")); + +for (const story of stories) { + if (story.a11yPrepare && !(story.a11yPrepare in a11yPrepareHandlers)) { + throw new Error(`Unknown a11y prepare handler "${story.a11yPrepare}" for ${story.id}.`); + } +} function buildStoryUrl(story) { const params = new URLSearchParams({ @@ -215,8 +187,8 @@ async function main() { try { await gotoStory(page, story); - if (story.prepare) { - await story.prepare(page); + if (story.a11yPrepare) { + await a11yPrepareHandlers[story.a11yPrepare](page); } const result = await runAxe(page); diff --git a/scripts/harness/story-harness-contract.test.ts b/scripts/harness/story-harness-contract.test.ts new file mode 100644 index 0000000..3cc7aa3 --- /dev/null +++ b/scripts/harness/story-harness-contract.test.ts @@ -0,0 +1,35 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; + +import harnessStoryContract from "../../tests/e2e/support/story-harness-contract.json"; + +describe("story harness contract", () => { + it("uses unique story ids", () => { + const storyIds = harnessStoryContract.stories.map((story) => story.id); + + expect(new Set(storyIds).size).toBe(storyIds.length); + }); + + it("requires a reason for every curated story", () => { + expect(harnessStoryContract.stories.every((story) => story.reason.trim().length > 0)).toBe(true); + }); + + it("requires smoke scenarios for docs-smoke coverage", () => { + expect( + harnessStoryContract.stories + .filter((story) => story.suites.includes("docs-smoke")) + .every((story) => Boolean(story.smokeScenario)) + ).toBe(true); + }); + + it("keeps every story on at least one supported suite", () => { + const supportedSuites = new Set(["a11y", "docs-smoke"]); + + expect( + harnessStoryContract.stories.every( + (story) => story.suites.length > 0 && story.suites.every((suite) => supportedSuites.has(suite)) + ) + ).toBe(true); + }); +}); diff --git a/tests/e2e/storybook-smoke.spec.ts b/tests/e2e/storybook-smoke.spec.ts index b72d562..6600dcd 100644 --- a/tests/e2e/storybook-smoke.spec.ts +++ b/tests/e2e/storybook-smoke.spec.ts @@ -1,76 +1,97 @@ import { expect, test, type Page } from "@playwright/test"; +import harnessStoryContract from "./support/story-harness-contract.json"; -async function gotoStory(page: Page, storyId: string) { - await page.goto(`/iframe.html?id=${storyId}&viewMode=story`); - await expect(page).toHaveTitle(new RegExp(storyId, "i"), { timeout: 15_000 }); +type StoryHarnessEntry = (typeof harnessStoryContract.stories)[number]; + +const smokeStories = harnessStoryContract.stories.filter((story) => story.suites.includes("docs-smoke")); + +async function gotoStory(page: Page, story: StoryHarnessEntry) { + const params = new URLSearchParams({ + id: story.id, + viewMode: "story" + }); + + if (story.query) { + for (const [key, value] of new URLSearchParams(story.query).entries()) { + params.set(key, value); + } + } + + await page.goto(`/iframe.html?${params.toString()}`); + await expect(page).toHaveTitle(new RegExp(story.id, "i"), { timeout: 15_000 }); } -test("storybook button, select, and static-motion form stories stay interactive", async ({ - page -}) => { - await page.goto("/"); - await expect(page).toHaveTitle(/storybook/i); +const smokeScenarios: Record Promise> = { + "button-playground": async (page) => { + const button = page.getByRole("button", { name: "Save changes" }); + await expect(button).toBeVisible(); + await button.focus(); + await expect(button).toBeFocused(); + }, + "select-playground": async (page) => { + const selectTrigger = page.locator('[data-slot="trigger"]').first(); + await expect(selectTrigger).toBeVisible(); + await selectTrigger.click(); + await expect(page.getByRole("option", { name: "Legal review" })).toBeVisible(); + }, + "launch-settings-form": async (page) => { + await page.getByRole("textbox", { name: "Email address" }).fill("team@cadence.dev"); + await page.getByRole("combobox", { name: "Review lane" }).click(); + await page.getByRole("option", { name: "Legal" }).click(); + await page.getByRole("textbox", { name: "Launch summary" }).fill( + "This release coordinates approvals, copy, and rollout risks." + ); + await page.getByRole("button", { name: "Save settings" }).click(); + await expect(page.locator("pre code").last()).toContainText('"role": "legal"'); + }, + "data-table-playground": async (page) => { + const table = page.getByRole("table", { name: "Routing lanes" }); + await expect(table).toBeVisible(); + await expect(page.getByRole("searchbox", { name: "Search routing lanes" })).toBeVisible(); - await gotoStory(page, "components-button--playground"); - const button = page.getByRole("button", { name: "Save changes" }); - await expect(button).toBeVisible(); - await button.focus(); - await expect(button).toBeFocused(); + const sortableHeader = page.getByRole("columnheader", { name: /owner/i }); + await sortableHeader.getByRole("button").click(); + await expect(sortableHeader).toHaveAttribute("aria-sort", "ascending"); - await gotoStory(page, "components-select--playground"); - const selectTrigger = page.locator('[data-slot="trigger"]').first(); - await expect(selectTrigger).toBeVisible(); - await selectTrigger.click(); - await expect(page.getByRole("option", { name: "Legal review" })).toBeVisible(); + await page.getByRole("checkbox", { name: /select row/i }).first().click(); + await expect(page.getByRole("button", { name: "Clear selection" })).toBeVisible(); - await page.goto("/iframe.html?id=components-form--launch-settings&viewMode=story&globals=motion:reduced"); - await expect(page).toHaveTitle(/components-form--launch-settings/i, { timeout: 15_000 }); - await page.getByRole("textbox", { name: "Email address" }).fill("team@cadence.dev"); - await page.getByRole("combobox", { name: "Review lane" }).click(); - await page.getByRole("option", { name: "Legal" }).click(); - await page.getByRole("textbox", { name: "Launch summary" }).fill( - "This release coordinates approvals, copy, and rollout risks." - ); - await page.getByRole("button", { name: "Save settings" }).click(); - await expect(page.locator("pre code").last()).toContainText('"role": "legal"'); -}); + const nextButton = page.getByRole("button", { name: "Next" }); + await expect(nextButton).toBeEnabled(); + await nextButton.click(); + await expect(page.getByRole("button", { name: "Previous" })).toBeEnabled(); + }, + "dialog-playground": async (page) => { + await page.getByRole("button", { name: "Open approval dialog" }).click(); + await expect(page.getByRole("dialog", { name: "Launch this release?" })).toBeVisible(); + await page.getByRole("button", { name: "Close dialog" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + }, + "popover-playground": async (page) => { + await page.getByRole("button", { name: "Inspect summary" }).click(); + await expect(page.getByText("Release health")).toBeVisible(); + await page.getByRole("button", { name: "Dismiss" }).click(); + await expect(page.getByText("Release health")).toHaveCount(0); + }, + "sheet-playground": async (page) => { + await page.getByRole("button", { name: "Open right sheet" }).click(); + await expect(page.getByRole("dialog", { name: "Launch settings" })).toBeVisible(); + await page.getByRole("button", { name: "Close sheet" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + } +}; -test("storybook data table story stays interactive", async ({ page }) => { - await gotoStory(page, "components-data-table--playground"); +for (const story of smokeStories) { + const scenario = story.smokeScenario ? smokeScenarios[story.smokeScenario] : null; - const table = page.getByRole("table", { name: "Routing lanes" }); - await expect(table).toBeVisible(); - await expect(page.getByRole("searchbox", { name: "Search routing lanes" })).toBeVisible(); + if (!scenario) { + throw new Error(`Missing smoke scenario "${story.smokeScenario ?? "undefined"}" for ${story.id}.`); + } - const sortableHeader = page.getByRole("columnheader", { name: /owner/i }); - await sortableHeader.getByRole("button").click(); - await expect(sortableHeader).toHaveAttribute("aria-sort", "ascending"); - - await page.getByRole("checkbox", { name: /select row/i }).first().click(); - await expect(page.getByRole("button", { name: "Clear selection" })).toBeVisible(); - - const nextButton = page.getByRole("button", { name: "Next" }); - await expect(nextButton).toBeEnabled(); - await nextButton.click(); - await expect(page.getByRole("button", { name: "Previous" })).toBeEnabled(); -}); - -test("storybook overlay stories stay interactive", async ({ page }) => { - await gotoStory(page, "components-dialog--playground"); - await page.getByRole("button", { name: "Open approval dialog" }).click(); - await expect(page.getByRole("dialog", { name: "Launch this release?" })).toBeVisible(); - await page.getByRole("button", { name: "Close dialog" }).click(); - await expect(page.getByRole("dialog")).toHaveCount(0); - - await gotoStory(page, "components-popover--playground"); - await page.getByRole("button", { name: "Inspect summary" }).click(); - await expect(page.getByText("Release health")).toBeVisible(); - await page.getByRole("button", { name: "Dismiss" }).click(); - await expect(page.getByText("Release health")).toHaveCount(0); - - await gotoStory(page, "components-sheet--playground"); - await page.getByRole("button", { name: "Open right sheet" }).click(); - await expect(page.getByRole("dialog", { name: "Launch settings" })).toBeVisible(); - await page.getByRole("button", { name: "Close sheet" }).click(); - await expect(page.getByRole("dialog")).toHaveCount(0); -}); + test(`storybook ${story.label} stays interactive`, async ({ page }) => { + await page.goto("/"); + await expect(page).toHaveTitle(/storybook/i); + await gotoStory(page, story); + await scenario(page); + }); +} diff --git a/tests/e2e/support/story-harness-contract.json b/tests/e2e/support/story-harness-contract.json new file mode 100644 index 0000000..c8527d5 --- /dev/null +++ b/tests/e2e/support/story-harness-contract.json @@ -0,0 +1,77 @@ +{ + "stories": [ + { + "id": "components-button--playground", + "label": "Button playground", + "reason": "Covers the default trigger and focusable control contract.", + "smokeScenario": "button-playground", + "suites": ["a11y", "docs-smoke"] + }, + { + "id": "components-select--playground", + "label": "Select playground", + "reason": "Covers a representative composed trigger and option list flow.", + "smokeScenario": "select-playground", + "suites": ["docs-smoke"] + }, + { + "id": "components-combobox--controlled", + "label": "Combobox controlled", + "reason": "Covers the searchable command-driven picker surface.", + "suites": ["a11y"] + }, + { + "id": "components-data-table--playground", + "label": "Data table playground", + "reason": "Covers sorting, selection, pagination, and search on a dense composite surface.", + "smokeScenario": "data-table-playground", + "suites": ["a11y", "docs-smoke"] + }, + { + "a11yPrepare": "open-date-picker", + "id": "components-datepicker--playground", + "label": "Date picker playground", + "reason": "Covers the calendar overlay and day-grid interaction surface.", + "suites": ["a11y"] + }, + { + "a11yPrepare": "open-dialog", + "id": "components-dialog--playground", + "label": "Dialog playground", + "reason": "Covers modal focus management and close behavior.", + "smokeScenario": "dialog-playground", + "suites": ["a11y", "docs-smoke"] + }, + { + "a11yPrepare": "open-dropdown-menu", + "id": "components-dropdownmenu--states", + "label": "Dropdown menu states", + "reason": "Covers menu semantics on the current review surface.", + "suites": ["a11y"] + }, + { + "id": "components-form--launch-settings", + "label": "Form launch settings", + "query": "globals=motion:reduced", + "reason": "Covers a representative reduced-motion form flow with validation and submission state.", + "smokeScenario": "launch-settings-form", + "suites": ["a11y", "docs-smoke"] + }, + { + "a11yPrepare": "open-popover", + "id": "components-popover--playground", + "label": "Popover playground", + "reason": "Covers anchored disclosure content and dismiss behavior.", + "smokeScenario": "popover-playground", + "suites": ["a11y", "docs-smoke"] + }, + { + "a11yPrepare": "open-sheet", + "id": "components-sheet--playground", + "label": "Sheet playground", + "reason": "Covers drawer-style overlay semantics and close behavior.", + "smokeScenario": "sheet-playground", + "suites": ["a11y", "docs-smoke"] + } + ] +}