diff --git a/.agents/skills/browser-tests/SKILL.md b/.agents/skills/browser-tests/SKILL.md index 01762ed89..d280a83d7 100644 --- a/.agents/skills/browser-tests/SKILL.md +++ b/.agents/skills/browser-tests/SKILL.md @@ -118,4 +118,5 @@ pnpm eslint packages/design-system/src/components/ds-{name}/ - **Migrate from Storybook play**: [migrate-story-tests](../migrate-story-tests/SKILL.md) - **New component**: [component-scaffold](../component-scaffold/SKILL.md) - **React state in tests**: [react-patterns](../react-patterns/SKILL.md) +- **Docs snippet tests**: [docs-tests](../docs-tests/SKILL.md) - **Examples**: `packages/design-system/src/components/*/__tests__/*.browser.test.tsx` diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md index 9f47e4064..599082fc2 100644 --- a/.agents/skills/code-review/SKILL.md +++ b/.agents/skills/code-review/SKILL.md @@ -41,6 +41,7 @@ Refactor card to use CSS modules and fix hover state selector specificity | `ds-*.tsx` (not stories/tests) | [react-patterns](../react-patterns/SKILL.md); [ark-ui](../ark-ui/SKILL.md) if Ark | | `*.stories.tsx` | [storybook](../storybook/SKILL.md), [react-patterns](../react-patterns/SKILL.md) | | `*.browser.test.tsx` | [browser-tests](../browser-tests/SKILL.md) | +| `*.docs.test.ts` | [docs-tests](../docs-tests/SKILL.md) | | `*.module.scss` | [scss](../scss/SKILL.md) | 3. Flag only clear, high-severity issues (max 10 inline comments) diff --git a/.agents/skills/docs-tests/SKILL.md b/.agents/skills/docs-tests/SKILL.md new file mode 100644 index 000000000..2175f90f8 --- /dev/null +++ b/.agents/skills/docs-tests/SKILL.md @@ -0,0 +1,122 @@ +--- +name: docs-tests +description: Write and run Storybook docs snippet tests (`*.docs.test.ts`) for Show code and MCP manifest verification against production storybook-static. Use when adding or editing docs tests or verifying Autodocs snippets after story changes. +--- + +# Docs Tests Skill + +Verify Autodocs **Show code** panel text and MCP manifest snippets in `ds-{name}/__tests__/ds-{name}.docs.test.ts` — not in `*.browser.test.tsx` ([browser-tests](../browser-tests/SKILL.md)). + +Story authoring rules: [storybook](../storybook/SKILL.md) "Docs source snippets" + "Snippet verification". + +## What docs tests verify + +| Output | Mechanism | Data source | +| ---------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| **Show code** | Playwright opens Autodocs, clicks **Show code**, reads `pre` text | [`read-show-code.ts`](../../../packages/design-system/tests/storybook/read-show-code.ts) | +| **MCP manifest** | HTTP `fetch` of `components.json` | [`read-manifest-snippet.ts`](../../../packages/design-system/tests/storybook/read-manifest-snippet.ts) | + +Both use the same `STORYBOOK_URL` from global setup — not filesystem reads, not the Storybook `/mcp` JSON-RPC endpoint. + +## Production target + +- `pnpm test:storybook-docs` runs `build:storybook` then vitest `--project=storybook-docs`. +- Global setup ([`setup.storybook-docs.ts`](../../../packages/design-system/vitest/setup.storybook-docs.ts)) serves `storybook-static/` on an ephemeral port (same artifact as GitHub Pages). +- Do **not** run docs tests against `pnpm start` (dev server). Show code snippets differ there because React Compiler is not applied the same way as in the production Storybook build. + +## File layout + +``` +ds-{name}/__tests__/ +├── ds-{name}.docs.test.ts +└── __snapshots__/ds-{name}.docs.test.ts.snap +``` + +## Quick start + +```tsx +import { chromium, type Browser, type Page } from 'playwright'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { readManifestSnippet } from '../../../../tests/storybook/read-manifest-snippet'; +import { readShowCodeSnippet } from '../../../../tests/storybook/read-show-code'; + +const DOCS_STORY_ID = 'components-breadcrumb--docs'; +const COMPONENT_ID = 'components-breadcrumb'; + +describe('DsBreadcrumb docs snippets', () => { + let browser: Browser; + let page: Page; + + beforeAll(async () => { + browser = await chromium.launch({ headless: true }); + page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + }); + + afterAll(async () => { + await browser.close(); + }); + + describe('Default', () => { + it('Show code panel matches staged authoring rules', async () => { + const snippet = await readShowCodeSnippet(page, { + docsStoryId: DOCS_STORY_ID, + storyName: 'Default', + }); + + expect(snippet).toContain("type: 'code'"); + expect(snippet).toMatchSnapshot(); + }); + + it('MCP manifest snippet matches staged authoring rules', async () => { + const snippet = await readManifestSnippet(COMPONENT_ID, 'Default'); + + expect(snippet).toMatchSnapshot(); + }); + }); +}); +``` + +Examples: [`ds-breadcrumb.docs.test.ts`](../../../packages/design-system/src/components/ds-breadcrumb/__tests__/ds-breadcrumb.docs.test.ts), [`ds-button-v3.docs.test.ts`](../../../packages/design-system/src/components/ds-button-v3/__tests__/ds-button-v3.docs.test.ts). + +## IDs + +| Constant | Derivation | Example | +| --------------- | ------------------------------------ | --------------------------------------------------------- | +| `DOCS_STORY_ID` | Story `title` kebab-cased + `--docs` | `Components/ButtonV3` → `components-buttonv3--docs` | +| `COMPONENT_ID` | Key in built `components.json` | `components-buttonv3` (not always `components-button-v3`) | + +Confirm `COMPONENT_ID` after `pnpm build:storybook`: + +```bash +node -e "const m=require('./packages/design-system/storybook-static/manifests/components.json'); console.log(Object.keys(m.components).filter(k=>k.includes('your-component')))" +``` + +Story names in tests match Autodocs `h3` titles (`IconOnly` → `"Icon Only"`). + +## Assertions + +- **Snapshots** — `expect(snippet).toMatchSnapshot()` for full-text regression. +- **Story-specific `toContain`** — when a variant has a distinguishing prop or pattern (e.g. `loading`, `type: 'code'`). No shared assertion helper; no generic `` tag check. + +| Rule | Show code test | MCP test | +| -------------------------------------- | ----------------------------------------- | -------------------- | +| Story-specific props or patterns | `toContain` for the distinguishing detail | same when applicable | +| `decorators` visible in Show code only | `toContain('decorator')` when applicable | do not assert in MCP | + +## Skip + +Do not add `*.docs.test.ts` coverage for stories with `docs.canvas.sourceState: 'none'` or showcase matrices tagged `!manifest`. + +## Commands + +```bash +pnpm --filter @drivenets/design-system test:storybook-docs packages/design-system/src/components/ds-{name}/__tests__/ds-{name}.docs.test.ts --run +``` + +Update snapshots after verifying output: add `--run -u`. + +## Related + +- Story authoring: [storybook](../storybook/SKILL.md) +- Behavioral tests: [browser-tests](../browser-tests/SKILL.md) +- PR snippet check: [pr-prep](../pr-prep/SKILL.md) diff --git a/.agents/skills/pr-prep/SKILL.md b/.agents/skills/pr-prep/SKILL.md index 9a6a6aa83..d145cdf49 100644 --- a/.agents/skills/pr-prep/SKILL.md +++ b/.agents/skills/pr-prep/SKILL.md @@ -30,6 +30,15 @@ Use [AGENTS.md#code-quality-checkers](../../../AGENTS.md#code-quality-checkers) For new components or substantial interaction/behavior changes, confirm an updated `*.browser.test.tsx` exists under the component's `__tests__/` when appropriate. +### Step 2b: Verify story snippets (when `*.stories.tsx` changed) + +Per [storybook](../storybook/SKILL.md) Snippet verification and [docs-tests](../docs-tests/SKILL.md): + +1. **Show code + manifest (primary)** — `pnpm --filter @drivenets/design-system test:storybook-docs --run`. +2. **MCP agent tooling (secondary)** — if Storybook is on port 6006, `get-documentation-for-story` via local MCP; else **SKIP**. + +Add `*.docs.test.ts` when introducing stories without one. + ### Step 3: Review changed files against project rules Read the matching skill(s) from the [code-review file → skill table](../code-review/SKILL.md#code-review-process) for paths in the diff. Flag violations found in the diff. @@ -76,6 +85,8 @@ PR Preparation Report [PASS/FAIL] Lint .................. {details} [PASS/FAIL] Typecheck ............. {details} [PASS/FAIL] Tests ................. {details} +[PASS/FAIL] Show code (docs tests) . {details} +[PASS/FAIL] MCP manifest snippets . {details} [PASS/FAIL] Rule violations ....... {details} [PASS/FAIL] No !important ........ {details} [PASS/FAIL] No hardcoded colors .. {details} @@ -88,7 +99,7 @@ PR Preparation Report [PASS/FAIL] No AI test slop .... {details} [PASS/FAIL] Changeset ............ {details} -{N}/14 checks passed. +{N}/16 checks passed. ``` If all pass, the PR is ready for submission. If any fail, list the specific files and lines that need fixing. diff --git a/.agents/skills/storybook/SKILL.md b/.agents/skills/storybook/SKILL.md index 7fa0c0690..3fc433c49 100644 --- a/.agents/skills/storybook/SKILL.md +++ b/.agents/skills/storybook/SKILL.md @@ -98,6 +98,19 @@ Storybook's default `source.type` is `auto`. Args-only stories (no `render`) alw - **`render` that goes straight to `return`** (compound sub-components, no hooks/handlers): rely on `auto`/`dynamic`; do **not** add `type: 'code'`. - **Showcase matrices**: hide the panel with `docs.canvas.sourceState: 'none'` (see below). +## Snippet verification + +Show code (Autodocs panel) and MCP manifest snippets are **different outputs** — verify both when `type: 'code'` is set. + +| Output | What it shows | +| ----------------- | -------------------------------------------------------------------------------------------------- | +| **Show code** | Serialized story (`parameters`, `decorators`, `render`) when `type: 'code'`; clean JSX when `auto` | +| **MCP `snippet`** | Evaluated `render` body only | + +Verify both via `*.docs.test.ts` — see [docs-tests](../docs-tests/SKILL.md). For ad-hoc agent MCP checks during story authoring, use `pnpm start` + `--manifests-url http://localhost:6006` (separate from docs tests). + +Add or update `*.docs.test.ts` when changing stories that expose Show code — see [docs-tests](../docs-tests/SKILL.md). + ## Showcase / matrix stories For visual-only grids (size/variant matrices) that aren't real usage examples: @@ -115,10 +128,12 @@ Stories and MDX feed the DS MCP server (`packages/mcp`). Follow [Storybook AI be - **MDX guidelines** — put token values and rules in the file body, not only in runtime `{map}` loops agents cannot see. - **Exclude noise** — `tags: ['!manifest']` on anti-pattern or human-only stories/docs. - **Exclude performance panel** — opt a component out of the ⚡ performance panel with `parameters: { performancePanel: { disable: true } }`. -- **Verify locally** — with Storybook running (`pnpm start`), the dev server exposes a built-in MCP at `http://localhost:6006/mcp`. Use whichever configured MCP server points at that URL (the alias is per-developer) to check your changes: `list-all-documentation` (confirm `!manifest` stories are absent) and `get-documentation` / `get-documentation-for-story` (confirm snippets render `` not the HOC name, descriptions have no `@summary`, props look right). This is not `packages/mcp`, which serves published docs to DS _consumers_. +- **Verify manifest snippets** — with Storybook running (`pnpm start`), use local MCP (`http://localhost:6006/mcp` or `--manifests-url http://localhost:6006`): `list-all-documentation`, `get-documentation` / `get-documentation-for-story`. Confirm manifest render snippets use `` not HOC names, descriptions have no `@summary`, `!manifest` stories are absent. This checks **agent-facing** snippets only — not the Autodocs Show code panel. Not `packages/mcp`, which serves published docs to DS _consumers_. +- **Verify Show code** — run `*.docs.test.ts` per [docs-tests](../docs-tests/SKILL.md). Published `@drivenets/design-system-mcp` serves DS consumers and may lag local edits. ## Related - New component: [component-scaffold](../component-scaffold/SKILL.md) - Behavioral tests: [browser-tests](../browser-tests/SKILL.md) +- Show code / MCP snippet tests: [docs-tests](../docs-tests/SKILL.md) - React in stories: [react-patterns](../react-patterns/SKILL.md) diff --git a/.cursor/agents/ds-verifier.md b/.cursor/agents/ds-verifier.md index f15ddeaeb..a92fd1393 100644 --- a/.cursor/agents/ds-verifier.md +++ b/.cursor/agents/ds-verifier.md @@ -14,13 +14,15 @@ You are a skeptical verifier for `@drivenets/design-system`. Do not accept claim ## Run (changed files only) -| Change | Command | -| -------------------------------------- | --------------------------------------------------------------------------------- | -| `packages/design-system/**/*.tsx` etc. | `pnpm eslint ` | -| Any design-system source | `pnpm --filter @drivenets/design-system typecheck` | -| `*.browser.test.tsx` | `pnpm --filter @drivenets/design-system test --run` | -| `packages/eslint-plugin/**` | Per [eslint-plugin](../../AGENTS.md#drivenetseslint-plugin-design-system) section | -| `packages/vite-plugin/**` | Per [vite-plugin](../../AGENTS.md#drivenetsvite-plugin-design-system) section | +| Change | Command | +| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `packages/design-system/**/*.tsx` etc. | `pnpm eslint ` | +| Any design-system source | `pnpm --filter @drivenets/design-system typecheck` | +| `*.browser.test.tsx` | `pnpm --filter @drivenets/design-system test --run` | +| `*.docs.test.ts` | `pnpm --filter @drivenets/design-system test:storybook-docs --run` — see [docs-tests skill](../../.agents/skills/docs-tests/SKILL.md) | +| `*.stories.tsx` (snippet check) | Run matching `*.docs.test.ts` per [docs-tests skill](../../.agents/skills/docs-tests/SKILL.md); local MCP manifest per [storybook skill](../../.agents/skills/storybook/SKILL.md) | +| `packages/eslint-plugin/**` | Per [eslint-plugin](../../AGENTS.md#drivenetseslint-plugin-design-system) section | +| `packages/vite-plugin/**` | Per [vite-plugin](../../AGENTS.md#drivenetsvite-plugin-design-system) section | Skip commands when no relevant files changed. Say what you skipped and why. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index eef9162da..43af09d31 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -120,6 +120,15 @@ jobs: - name: Run Tests run: pnpm test:coverage --affected + - name: Build Storybook for docs snippet tests + run: pnpm build:storybook --affected + + - name: Install Playwright Chromium + run: pnpm --filter @drivenets/design-system install:playwright + + - name: Run Storybook docs snippet tests + run: pnpm --filter @drivenets/design-system exec vitest --project=storybook-docs --run + build: name: Build runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index d176f18bb..2a6a1fe0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,18 +28,20 @@ pnpm --filter @drivenets/vite-plugin-design-system test --run -t "snapshot" ## When to Run What -| Changed | Run | -| -------------------------- | --------------------------------------- | -| `*.test.ts` file | `pnpm --filter test --run` | -| Source file with tests | Lint the file + run related tests | -| Source file, no tests | Lint the file + typecheck the package | -| SCSS file in design-system | Lint the file | +| Changed | Run | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `*.test.ts` file | `pnpm --filter test --run` | +| `*.docs.test.ts` file | `pnpm --filter @drivenets/design-system test:storybook-docs --run` — see [`docs-tests`](.agents/skills/docs-tests/SKILL.md) | +| Source file with tests | Lint the file + run related tests | +| Source file, no tests | Lint the file + typecheck the package | +| SCSS file in design-system | Lint the file | ## Notes - `--run` flag prevents vitest watch mode - design-system typecheck auto-generates SCSS type defs - Prefer file-level lint, package-level typecheck, file-level test +- `*.docs.test.ts` uses the `storybook-docs` vitest project (excluded from default `pnpm test`, like `requires-build`); see [`docs-tests`](.agents/skills/docs-tests/SKILL.md) for workflow. --- @@ -80,6 +82,7 @@ ds-{name}/ - `*.module.scss` — [`scss`](.agents/skills/scss/SKILL.md) - `*.stories.tsx` — [`storybook`](.agents/skills/storybook/SKILL.md) - `*.browser.test.tsx` — [`browser-tests`](.agents/skills/browser-tests/SKILL.md) +- `*.docs.test.ts` — [`docs-tests`](.agents/skills/docs-tests/SKILL.md) - Storybook `play` → tests — [`migrate-story-tests`](.agents/skills/migrate-story-tests/SKILL.md) - PR / review — [`code-review`](.agents/skills/code-review/SKILL.md), [`pr-prep`](.agents/skills/pr-prep/SKILL.md) - Plan / debug / TDD — [`grill-me`](.agents/skills/grill-me/SKILL.md), [`to-plan`](.agents/skills/to-plan/SKILL.md), [`diagnose`](.agents/skills/diagnose/SKILL.md), [`tdd`](.agents/skills/tdd/SKILL.md) @@ -115,7 +118,7 @@ Use skill [`ts-standards`](.agents/skills/ts-standards/SKILL.md) for full rules. ## Additional rules - **Domain glossary:** [CONTEXT.md](CONTEXT.md) · **ADRs:** [docs/adr/](docs/adr/) -- **DS MCP:** [packages/mcp/README.md](packages/mcp/README.md) — Storybook docs for agents (`list-all-documentation`, `get-documentation`); local dev uses `pnpm start` + `--manifests-url http://localhost:6006` +- **DS MCP:** [packages/mcp/README.md](packages/mcp/README.md) — Storybook docs for agents (`list-all-documentation`, `get-documentation`); local dev uses `pnpm start` + `--manifests-url http://localhost:6006`. Story edits: verify snippets via [`docs-tests`](.agents/skills/docs-tests/SKILL.md) and [storybook Snippet verification](.agents/skills/storybook/SKILL.md#snippet-verification). - **Flows:** [docs/agents/skills.md](docs/agents/skills.md) - **Subagents:** [docs/agents/subagents.md](docs/agents/subagents.md) @@ -142,6 +145,7 @@ File → skill routing: [Design-system package](#design-system-package). Skill b - [`scss`](.agents/skills/scss/SKILL.md) — edit `*.scss`; tokens, focus/disabled, CSS modules - [`ts-standards`](.agents/skills/ts-standards/SKILL.md) — edit `.ts` / `.tsx`; JSDoc, exports, Object.freeze - [`browser-tests`](.agents/skills/browser-tests/SKILL.md) — add/edit `*.browser.test.tsx`; Vitest browser patterns, a11y queries, Ark locators +- [`docs-tests`](.agents/skills/docs-tests/SKILL.md) — add/edit `*.docs.test.ts`; Show code + MCP manifest snippet verification - [`storybook`](.agents/skills/storybook/SKILL.md) — edit `*.stories.tsx`; story variants, args, MCP-friendly docs, no play functions - [`component-scaffold`](.agents/skills/component-scaffold/SKILL.md) — "scaffold a new component"; orchestrator for files, exports, skill read order - [`figma-to-component`](.agents/skills/figma-to-component/SKILL.md) — Figma URL; trust boundary, Figma/DS MCP orchestration, then component-scaffold diff --git a/cspell/local-words.txt b/cspell/local-words.txt index ecca23b2f..7ee7d05b6 100644 --- a/cspell/local-words.txt +++ b/cspell/local-words.txt @@ -4,6 +4,7 @@ artipacked autoboot autodocs borderless +buttonv clickability controlify cooldown @@ -17,6 +18,7 @@ gnmi matchall netgen netural +networkidle noninteractive ondark onwarn diff --git a/docs/agents/skills.md b/docs/agents/skills.md index 829cf89ad..baf22c9b6 100644 --- a/docs/agents/skills.md +++ b/docs/agents/skills.md @@ -18,7 +18,7 @@ All project skills live in **`.agents/skills/`**. Convention guidance is skill-o ## Typical flows -**Feature:** `grill-me` → `to-plan` → `component-scaffold` / `figma-to-component` → `tdd` + `storybook` + `browser-tests` → `pr-prep` + `code-review` +**Feature:** `grill-me` → `to-plan` → `component-scaffold` / `figma-to-component` → `tdd` + `storybook` + `browser-tests` + `docs-tests` → `pr-prep` + `code-review` **Bug:** `diagnose` → `tdd` (regression) → checkers in `AGENTS.md` diff --git a/packages/design-system/package.json b/packages/design-system/package.json index ce70e2d58..3533ae840 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -46,11 +46,12 @@ "generate-scss-dts": "typed-scss-modules ./src -o ./.scss-dts --nameFormat all --exportType default > /dev/null 2>&1", "figma:lint": "figma connect parse --exit-on-unreadable-files", "figma:publish": "figma connect publish --exit-on-unreadable-files", - "test": "vitest --project=\"!requires-build\"", + "test": "vitest --project=unit --project=browser --project=storybook", "test:coverage": "pnpm test --coverage", "test:unit": "vitest --project=unit", "test:browser": "vitest --project=browser", "test:requires-build": "vitest --project=requires-build", + "test:storybook-docs": "pnpm build:storybook && vitest --project=storybook-docs", "build": "tsdown", "build:watch": "pnpm build --watch", "build:storybook": "storybook build" diff --git a/packages/design-system/src/components/ds-breadcrumb/__tests__/__snapshots__/ds-breadcrumb.docs.test.ts.snap b/packages/design-system/src/components/ds-breadcrumb/__tests__/__snapshots__/ds-breadcrumb.docs.test.ts.snap new file mode 100644 index 000000000..930c9aa99 --- /dev/null +++ b/packages/design-system/src/components/ds-breadcrumb/__tests__/__snapshots__/ds-breadcrumb.docs.test.ts.snap @@ -0,0 +1,163 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`DsBreadcrumb docs snippets > Default > MCP manifest snippet matches staged authoring rules 1`] = ` +"const Default = function Render(args) { + const navigate = useNavigate(); + const location = useLocation(); + const items: DsBreadcrumbItem[] = [ + { type: 'link', label: 'Home', href: '/', icon: 'home' }, + { type: 'link', label: 'Inventory', href: '/inventory', icon: 'settings' }, + { type: 'link', label: 'Catalog', href: '/inventory/catalog', icon: 'newspaper' }, + ]; + const depth = location.pathname.split('/').filter(Boolean).length; + const visibleItems = items.slice(0, depth + 1); + + return ( + { + args.onSelect?.(href); + void navigate({ to: href }); + }} + /> + ); +};" +`; + +exports[`DsBreadcrumb docs snippets > Default > Show code panel matches staged authoring rules 1`] = ` +"{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + decorators: [Story => withTanStackRouter(Story, '/inventory/catalog')], + render: function Render(args) { + const navigate = useNavigate(); + const location = useLocation(); + const items: DsBreadcrumbItem[] = [{ + type: 'link', + label: 'Home', + href: '/', + icon: 'home' + }, { + type: 'link', + label: 'Inventory', + href: '/inventory', + icon: 'settings' + }, { + type: 'link', + label: 'Catalog', + href: '/inventory/catalog', + icon: 'newspaper' + }]; + const depth = location.pathname.split('/').filter(Boolean).length; + const visibleItems = items.slice(0, depth + 1); + return { + args.onSelect?.(href); + void navigate({ + to: href + }); + }} />; + } +}" +`; + +exports[`DsBreadcrumb docs snippets > With Dropdown > MCP manifest snippet matches staged authoring rules 1`] = ` +"const WithDropdown = function Render(args) { + const navigate = useNavigate(); + const location = useLocation(); + const items: DsBreadcrumbItem[] = [ + { type: 'link', label: 'Home', href: '/' }, + { type: 'link', label: 'Network Visibility', href: '/network' }, + { + type: 'dropdown', + label: 'Vienna HQ', + icon: 'location_on', + options: [ + { label: 'Vienna HQ', href: '/network/vienna' }, + { label: 'Paris Office', href: '/network/paris' }, + ], + }, + { + type: 'dropdown', + label: 'Router A', + icon: 'device_hub', + options: [ + { label: 'Router A', href: '/network/vienna/router-a' }, + { label: 'Switch B', href: '/network/vienna/switch-b' }, + ], + }, + ]; + const depth = location.pathname.split('/').filter(Boolean).length; + const visibleItems = items.slice(0, depth + 1); + + return ( + { + args.onSelect?.(href); + void navigate({ to: href }); + }} + /> + ); +};" +`; + +exports[`DsBreadcrumb docs snippets > With Dropdown > Show code panel matches staged authoring rules 1`] = ` +"{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + decorators: [Story => withTanStackRouter(Story, '/network/vienna/router-a')], + render: function Render(args) { + const navigate = useNavigate(); + const location = useLocation(); + const items: DsBreadcrumbItem[] = [{ + type: 'link', + label: 'Home', + href: '/' + }, { + type: 'link', + label: 'Network Visibility', + href: '/network' + }, { + type: 'dropdown', + label: 'Vienna HQ', + icon: 'location_on', + options: [{ + label: 'Vienna HQ', + href: '/network/vienna' + }, { + label: 'Paris Office', + href: '/network/paris' + }] + }, { + type: 'dropdown', + label: 'Router A', + icon: 'device_hub', + options: [{ + label: 'Router A', + href: '/network/vienna/router-a' + }, { + label: 'Switch B', + href: '/network/vienna/switch-b' + }] + }]; + const depth = location.pathname.split('/').filter(Boolean).length; + const visibleItems = items.slice(0, depth + 1); + return { + args.onSelect?.(href); + void navigate({ + to: href + }); + }} />; + } +}" +`; diff --git a/packages/design-system/src/components/ds-breadcrumb/__tests__/ds-breadcrumb.docs.test.ts b/packages/design-system/src/components/ds-breadcrumb/__tests__/ds-breadcrumb.docs.test.ts new file mode 100644 index 000000000..b3b1d67c6 --- /dev/null +++ b/packages/design-system/src/components/ds-breadcrumb/__tests__/ds-breadcrumb.docs.test.ts @@ -0,0 +1,60 @@ +import { chromium, type Browser, type Page } from 'playwright'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { readManifestSnippet } from '../../../../tests/storybook/read-manifest-snippet'; +import { readShowCodeSnippet } from '../../../../tests/storybook/read-show-code'; + +const DOCS_STORY_ID = 'components-breadcrumb--docs'; +const COMPONENT_ID = 'components-breadcrumb'; + +describe('DsBreadcrumb docs snippets', () => { + let browser: Browser; + let page: Page; + + beforeAll(async () => { + browser = await chromium.launch({ headless: true }); + page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + }); + + afterAll(async () => { + await browser.close(); + }); + + describe('Default', () => { + it('Show code panel matches staged authoring rules', async () => { + const snippet = await readShowCodeSnippet(page, { + docsStoryId: DOCS_STORY_ID, + storyName: 'Default', + }); + + expect(snippet).toContain("type: 'code'"); + expect(snippet).toContain('withTanStackRouter'); + expect(snippet).toMatchSnapshot(); + }); + + it('MCP manifest snippet matches staged authoring rules', async () => { + const snippet = await readManifestSnippet(COMPONENT_ID, 'Default'); + + expect(snippet).not.toContain('withTanStackRouter'); + expect(snippet).toMatchSnapshot(); + }); + }); + + describe('With Dropdown', () => { + it('Show code panel matches staged authoring rules', async () => { + const snippet = await readShowCodeSnippet(page, { + docsStoryId: DOCS_STORY_ID, + storyName: 'With Dropdown', + }); + + expect(snippet).toContain("type: 'dropdown'"); + expect(snippet).toMatchSnapshot(); + }); + + it('MCP manifest snippet matches staged authoring rules', async () => { + const snippet = await readManifestSnippet(COMPONENT_ID, 'With Dropdown'); + + expect(snippet).toContain("type: 'dropdown'"); + expect(snippet).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/design-system/src/components/ds-button-v3/__tests__/__snapshots__/ds-button-v3.docs.test.ts.snap b/packages/design-system/src/components/ds-button-v3/__tests__/__snapshots__/ds-button-v3.docs.test.ts.snap new file mode 100644 index 000000000..b44324745 --- /dev/null +++ b/packages/design-system/src/components/ds-button-v3/__tests__/__snapshots__/ds-button-v3.docs.test.ts.snap @@ -0,0 +1,128 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`DsButtonV3 docs snippets > Default > MCP manifest snippet matches staged authoring rules 1`] = ` +"const Default = () => Button;" +`; + +exports[`DsButtonV3 docs snippets > Default > Show code panel matches staged authoring rules 1`] = ` +" + Button +" +`; + +exports[`DsButtonV3 docs snippets > Disabled > MCP manifest snippet matches staged authoring rules 1`] = `"const Disabled = () => ;"`; + +exports[`DsButtonV3 docs snippets > Disabled > Show code panel matches staged authoring rules 1`] = ` +" + Button +" +`; + +exports[`DsButtonV3 docs snippets > Icon Only > MCP manifest snippet matches staged authoring rules 1`] = ` +"const IconOnly = () => ;" +`; + +exports[`DsButtonV3 docs snippets > Icon Only > Show code panel matches staged authoring rules 1`] = ` +"" +`; + +exports[`DsButtonV3 docs snippets > Loading > MCP manifest snippet matches staged authoring rules 1`] = `"const Loading = () => ;"`; + +exports[`DsButtonV3 docs snippets > Loading > Show code panel matches staged authoring rules 1`] = ` +" + Button +" +`; + +exports[`DsButtonV3 docs snippets > On Dark > MCP manifest snippet matches staged authoring rules 1`] = `"const OnDark = () => ;"`; + +exports[`DsButtonV3 docs snippets > On Dark > Show code panel matches staged authoring rules 1`] = ` +" + Button +" +`; + +exports[`DsButtonV3 docs snippets > Responsive Size > MCP manifest snippet matches staged authoring rules 1`] = ` +"const ResponsiveSize = () => ( + + lg: large / md: small + lg: medium / md: tiny + static: medium + +);" +`; + +exports[`DsButtonV3 docs snippets > Responsive Size > Show code panel matches staged authoring rules 1`] = ` +"{ + render: () => + lg: large / md: small + lg: medium / md: tiny + static: medium + +}" +`; + +exports[`DsButtonV3 docs snippets > Selected > MCP manifest snippet matches staged authoring rules 1`] = `"const Selected = () => ;"`; + +exports[`DsButtonV3 docs snippets > Selected > Show code panel matches staged authoring rules 1`] = ` +" + Button +" +`; diff --git a/packages/design-system/src/components/ds-button-v3/__tests__/ds-button-v3.docs.test.ts b/packages/design-system/src/components/ds-button-v3/__tests__/ds-button-v3.docs.test.ts new file mode 100644 index 000000000..174a3daf0 --- /dev/null +++ b/packages/design-system/src/components/ds-button-v3/__tests__/ds-button-v3.docs.test.ts @@ -0,0 +1,150 @@ +import { chromium, type Browser, type Page } from 'playwright'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { readManifestSnippet } from '../../../../tests/storybook/read-manifest-snippet'; +import { readShowCodeSnippet } from '../../../../tests/storybook/read-show-code'; + +const DOCS_STORY_ID = 'components-buttonv3--docs'; +const COMPONENT_ID = 'components-buttonv3'; + +describe('DsButtonV3 docs snippets', () => { + let browser: Browser; + let page: Page; + + beforeAll(async () => { + browser = await chromium.launch({ headless: true }); + page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + }); + + afterAll(async () => { + await browser.close(); + }); + + describe('Default', () => { + it('Show code panel matches staged authoring rules', async () => { + const snippet = await readShowCodeSnippet(page, { + docsStoryId: DOCS_STORY_ID, + storyName: 'Default', + }); + + expect(snippet).toContain('Button'); + expect(snippet).toContain('check_circle'); + expect(snippet).toMatchSnapshot(); + }); + + it('MCP manifest snippet matches staged authoring rules', async () => { + const snippet = await readManifestSnippet(COMPONENT_ID, 'Default'); + + expect(snippet).toMatchSnapshot(); + }); + }); + + describe('Loading', () => { + it('Show code panel matches staged authoring rules', async () => { + const snippet = await readShowCodeSnippet(page, { + docsStoryId: DOCS_STORY_ID, + storyName: 'Loading', + }); + + expect(snippet).toContain('loading'); + expect(snippet).toMatchSnapshot(); + }); + + it('MCP manifest snippet matches staged authoring rules', async () => { + const snippet = await readManifestSnippet(COMPONENT_ID, 'Loading'); + + expect(snippet).toMatchSnapshot(); + }); + }); + + describe('Disabled', () => { + it('Show code panel matches staged authoring rules', async () => { + const snippet = await readShowCodeSnippet(page, { + docsStoryId: DOCS_STORY_ID, + storyName: 'Disabled', + }); + + expect(snippet).toContain('disabled'); + expect(snippet).toMatchSnapshot(); + }); + + it('MCP manifest snippet matches staged authoring rules', async () => { + const snippet = await readManifestSnippet(COMPONENT_ID, 'Disabled'); + + expect(snippet).toMatchSnapshot(); + }); + }); + + describe('Icon Only', () => { + it('Show code panel matches staged authoring rules', async () => { + const snippet = await readShowCodeSnippet(page, { + docsStoryId: DOCS_STORY_ID, + storyName: 'Icon Only', + }); + + expect(snippet).toContain('Confirm'); + expect(snippet).toMatchSnapshot(); + }); + + it('MCP manifest snippet matches staged authoring rules', async () => { + const snippet = await readManifestSnippet(COMPONENT_ID, 'Icon Only'); + + expect(snippet).toMatchSnapshot(); + }); + }); + + describe('Selected', () => { + it('Show code panel matches staged authoring rules', async () => { + const snippet = await readShowCodeSnippet(page, { + docsStoryId: DOCS_STORY_ID, + storyName: 'Selected', + }); + + expect(snippet).toContain('selected'); + expect(snippet).toMatchSnapshot(); + }); + + it('MCP manifest snippet matches staged authoring rules', async () => { + const snippet = await readManifestSnippet(COMPONENT_ID, 'Selected'); + + expect(snippet).toMatchSnapshot(); + }); + }); + + describe('On Dark', () => { + it('Show code panel matches staged authoring rules', async () => { + const snippet = await readShowCodeSnippet(page, { + docsStoryId: DOCS_STORY_ID, + storyName: 'On Dark', + }); + + expect(snippet).toContain('light'); + expect(snippet).toMatchSnapshot(); + }); + + it('MCP manifest snippet matches staged authoring rules', async () => { + const snippet = await readManifestSnippet(COMPONENT_ID, 'On Dark'); + + expect(snippet).toContain('light'); + expect(snippet).toMatchSnapshot(); + }); + }); + + describe('Responsive Size', () => { + it('Show code panel matches staged authoring rules', async () => { + const snippet = await readShowCodeSnippet(page, { + docsStoryId: DOCS_STORY_ID, + storyName: 'Responsive Size', + }); + + expect(snippet).toContain(' { + const snippet = await readManifestSnippet(COMPONENT_ID, 'Responsive Size'); + + expect(snippet).toContain('; +} + +export async function readManifestSnippet(componentId: string, storyName: string): Promise { + const storybookUrl = getStorybookUrl(); + const response = await fetch(`${storybookUrl}/manifests/components.json`); + + if (!response.ok) { + throw new Error(`Failed to fetch components manifest (${String(response.status)}) from ${storybookUrl}`); + } + + const manifest = (await response.json()) as ComponentsManifest; + const component = manifest.components[componentId]; + + if (!component) { + throw new Error(`Component "${componentId}" not found in components manifest`); + } + + const story = component.stories?.find((entry) => entry.name === storyName); + + if (!story?.snippet?.trim()) { + throw new Error(`Manifest snippet not found for "${componentId}" story "${storyName}"`); + } + + return story.snippet.trim(); +} diff --git a/packages/design-system/tests/storybook/read-show-code.ts b/packages/design-system/tests/storybook/read-show-code.ts new file mode 100644 index 000000000..bb78f1646 --- /dev/null +++ b/packages/design-system/tests/storybook/read-show-code.ts @@ -0,0 +1,53 @@ +import type { Page } from 'playwright'; +import { getStorybookUrl } from './storybook-url'; + +export interface ReadShowCodeSnippetOptions { + docsStoryId: string; + storyName: string; +} + +function getDocsIframe(page: Page) { + const frame = page.frames().find((f) => f.url().includes('iframe')); + if (!frame) { + throw new Error('Storybook docs iframe not found'); + } + + return frame; +} + +export async function readShowCodeSnippet( + page: Page, + { docsStoryId, storyName }: ReadShowCodeSnippetOptions, +): Promise { + const storybookUrl = getStorybookUrl(); + await page.goto(`${storybookUrl}/?path=/docs/${docsStoryId}`, { + waitUntil: 'networkidle', + timeout: 60_000, + }); + + const frame = getDocsIframe(page); + const section = frame.locator('h3', { hasText: new RegExp(`^${storyName}$`) }).locator('..'); + const showCodeButton = section.locator('button', { hasText: 'Show code' }); + + if ((await showCodeButton.count()) === 0) { + throw new Error(`Show code button not found for story "${storyName}" in ${docsStoryId}`); + } + + await showCodeButton.click(); + + const source = section.locator('pre'); + await source.waitFor({ state: 'visible', timeout: 10_000 }); + + const deadline = Date.now() + 10_000; + let text = (await source.innerText()).trim(); + + while (!text && Date.now() < deadline) { + await page.waitForTimeout(100); + text = (await source.innerText()).trim(); + } + if (!text) { + throw new Error(`Show code panel is empty for story "${storyName}" in ${docsStoryId}`); + } + + return text; +} diff --git a/packages/design-system/tests/storybook/storybook-url.ts b/packages/design-system/tests/storybook/storybook-url.ts new file mode 100644 index 000000000..1a7d0cf7c --- /dev/null +++ b/packages/design-system/tests/storybook/storybook-url.ts @@ -0,0 +1,11 @@ +export function getStorybookUrl(): string { + const storybookUrl = process.env.STORYBOOK_URL; + + if (!storybookUrl) { + throw new Error( + 'STORYBOOK_URL is not set. Storybook docs tests must run via the storybook-docs vitest global setup.', + ); + } + + return storybookUrl; +} diff --git a/packages/design-system/vitest.config.ts b/packages/design-system/vitest.config.ts index 0e8cd96f0..4f67ece66 100644 --- a/packages/design-system/vitest.config.ts +++ b/packages/design-system/vitest.config.ts @@ -98,10 +98,19 @@ export default defineConfig({ setupFiles: ['.storybook/vitest.setup.ts'], }, }, + { + extends: true, + test: { + name: 'storybook-docs', + include: [testPattern('docs')], + globalSetup: ['./vitest/setup.storybook-docs.ts'], + testTimeout: 60_000, + }, + }, ], }, }); -function testPattern(type: 'unit' | 'requires-build' | 'browser') { +function testPattern(type: 'unit' | 'requires-build' | 'browser' | 'docs') { return `**/*.${type}.test.{ts,tsx}`; } diff --git a/packages/design-system/vitest/setup.storybook-docs.ts b/packages/design-system/vitest/setup.storybook-docs.ts new file mode 100644 index 000000000..460784c2a --- /dev/null +++ b/packages/design-system/vitest/setup.storybook-docs.ts @@ -0,0 +1,100 @@ +import { createServer, type Server } from 'node:http'; +import { existsSync, statSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); +const STORYBOOK_STATIC_DIR = path.join(dirname, '../storybook-static'); + +const MIME_TYPES: Record = { + '.html': 'text/html', + '.js': 'text/javascript', + '.mjs': 'text/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.woff2': 'font/woff2', +}; + +function createStaticServer(root: string): Server { + return createServer(async (request, response) => { + const url = new URL(request.url ?? '/', 'http://localhost'); + let filePath = path.join(root, decodeURIComponent(url.pathname)); + + if (url.pathname.endsWith('/')) { + filePath = path.join(filePath, 'index.html'); + } + + if (!existsSync(filePath) || statSync(filePath).isDirectory()) { + filePath = path.join(root, 'index.html'); + } + + try { + const content = await readFile(filePath); + const extension = path.extname(filePath); + response.writeHead(200, { + 'Content-Type': MIME_TYPES[extension] ?? 'application/octet-stream', + }); + response.end(content); + } catch { + response.writeHead(404); + response.end('Not found'); + } + }); +} + +async function isStorybookReachable(url: string): Promise { + try { + const response = await fetch(`${url}/index.html`, { signal: AbortSignal.timeout(2_000) }); + return response.ok; + } catch { + return false; + } +} + +export default async function globalSetup() { + const configuredUrl = process.env.STORYBOOK_URL; + + if (configuredUrl) { + const reachable = await isStorybookReachable(configuredUrl); + if (!reachable) { + throw new Error(`Storybook at ${configuredUrl} is not reachable`); + } + + process.env.STORYBOOK_URL = configuredUrl; + return; + } + + if (!existsSync(STORYBOOK_STATIC_DIR)) { + throw new Error('storybook-static/ not found. Run `pnpm build:storybook` or `pnpm test:storybook-docs`.'); + } + + const server = createStaticServer(STORYBOOK_STATIC_DIR); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve()); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to start storybook-static server'); + } + + process.env.STORYBOOK_URL = `http://127.0.0.1:${String(address.port)}`; + + return async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + }; +}