From 2a1e326a9316e79e6f1a3e18b4f0a81b8e6c3ea2 Mon Sep 17 00:00:00 2001 From: HerbertJulio Date: Wed, 1 Jul 2026 10:41:59 -0300 Subject: [PATCH 01/11] [NO-ISSUE] test(webkit): add browser-mode test foundation Vitest browser mode (Playwright Chromium) + @testing-library/vue + composeStories, axe-core a11y helper, theme CSS loaded in setup so contrast checks are real, publish-safe files negation (no test files in the tarball), sharded CI workflow, and root passthrough scripts. Adopts the direction validated by the #704 proposal; hardens the gaps (publish leak, unstyled-DOM a11y, pure-module coverage, CI scale). --- .github/workflows/test.yml | 78 ++++ PLANEJAMENTO-TESTES.md | 93 +++++ eslint.config.js | 4 + package.json | 2 + packages/webkit/package.json | 11 + packages/webkit/src/test/axe.ts | 12 + packages/webkit/src/test/setup.ts | 13 + packages/webkit/src/test/smoke.test.ts | 18 + packages/webkit/vitest.config.ts | 32 ++ pnpm-lock.yaml | 513 ++++++++++++++++++++++++- 10 files changed, 773 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 PLANEJAMENTO-TESTES.md create mode 100644 packages/webkit/src/test/axe.ts create mode 100644 packages/webkit/src/test/setup.ts create mode 100644 packages/webkit/src/test/smoke.test.ts create mode 100644 packages/webkit/vitest.config.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..5e2fa40cd --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,78 @@ +name: Tests + +on: + pull_request: + branches: [main, dev] + push: + branches: [main, dev] + +permissions: + contents: read + pull-requests: read + +jobs: + # Short-circuit when webkit sources did not change. + changes: + name: Detect Changes + runs-on: ubuntu-latest + outputs: + webkit: ${{ steps.filter.outputs.webkit }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + webkit: + - 'packages/webkit/**' + - 'apps/storybook/**' + + # Vitest browser mode (real Chromium). Sharded by 4 so the ~150-component + # suite parallelises; retry:2 absorbs the rare browser-launch flake. + test: + name: Vitest browser (shard ${{ matrix.shard }}/4) + needs: changes + if: needs.changes.outputs.webkit == 'true' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright Chromium + run: pnpm exec playwright install --with-deps chromium + working-directory: packages/webkit + + - name: Run tests (shard ${{ matrix.shard }}) + run: pnpm exec vitest run --shard=${{ matrix.shard }}/4 --retry=2 + working-directory: packages/webkit + + # Gate: passes when every shard succeeded or the suite was cleanly skipped. + test-check: + name: Tests Gate + runs-on: ubuntu-latest + needs: [changes, test] + if: always() + steps: + - name: Check test result + run: | + result="${{ needs.test.result }}" + if [[ "$result" != "success" && "$result" != "skipped" ]]; then + echo "❌ tests: $result" + exit 1 + fi + echo "✅ tests: $result" diff --git a/PLANEJAMENTO-TESTES.md b/PLANEJAMENTO-TESTES.md new file mode 100644 index 000000000..7ec240dcf --- /dev/null +++ b/PLANEJAMENTO-TESTES.md @@ -0,0 +1,93 @@ +# Planejamento: Cobertura de testes funcionais do webkit + +> Artefato de planejamento (rascunho pra revisão). A versão canônica fica em +> `~/.claude/plans/twinkly-moseying-zebra.md`. Docs que forem pro repo webkit +> (`testing.md`, `TESTING_STRATEGY.md`) permanecem **em inglês** (regra do projeto). + +## Status atual da execução (Fase 0 em andamento) + +- [x] `packages/webkit/vitest.config.ts` — Vitest **browser mode** (Playwright Chromium), alias `@aziontech/webkit` + `@stories`. +- [x] `packages/webkit/src/test/setup.ts` — importa `@aziontech/theme/globals.css` (DOM **estilizado** → axe confiável) + cleanup. +- [x] `packages/webkit/package.json` — deps (`vitest`, `@vitest/browser`, `@testing-library/vue`, `@storybook/vue3`, `axe-core`, `playwright`, `@vitejs/plugin-vue`), scripts `test`/`test:watch`, e `files` com negação (`!src/**/*.test.{ts,js}`, `!src/test/**`) pra **não vazar teste no publish**. +- [x] root `package.json` — `webkit:test` / `webkit:test:watch`. +- [x] `eslint.config.js` — globals de DOM pros testes. +- [x] `pnpm install` (40 pacotes) + `playwright install chromium` (binário baixado). +- [ ] `packages/webkit/src/test/axe.ts` — helper `expectNoA11yViolations`. +- [ ] `.github/workflows/test.yml` — job com **sharding por categoria + retry**. +- [ ] smoke test provando que o browser-mode roda + `pack:dry` sem arquivo de teste. + +> ⚠️ **Achado**: o filtro `pnpm --filter webkit` **não casa** nada no pnpm 11 (o nome do pacote é `@aziontech/webkit.dev`) — os scripts `webkit:*` existentes dependem desse filtro quebrado. Vou usar `-C packages/webkit` / `--filter @aziontech/webkit.dev` pra garantir que rode. (Vale corrigir os scripts existentes num PR à parte.) +> +> ⚠️ **Branch**: estamos na `dev` (tree limpa). O Calendar rico (sub-componentes + `format.ts`/`parse-period.ts`) está só na `feat/ENG-46317-calendar`, **não na dev** — por isso os exemplares da Fase 1 foram adaptados pra componentes que existem na dev (Dialog, navigation-menu, Select). O Calendar ganha testes quando a branch dele mergear. + +--- + +## Sobre a PR #704 (sugestão de outro autor — NÃO é a fonte da verdade) + +Estudei a fundo. A **ideia central é boa e adotamos**; o resto avaliamos e corrigimos. + +### O que NÃO está bom na #704 (não copiar como está) + +1. **Testes vazam no publish** — `*.test.ts` em `src/` + `files: ["src"]` inalterado → vão no tarball do npm. +2. **Import de story com caminho relativo de 6 níveis** (`../../../../../../apps/storybook/...`) — frágil, acopla ao layout do storybook. +3. **a11y em DOM sem estilo** — `setup.ts` não carrega CSS de tema → `color-contrast` do axe não-confiável (falso negativo). +4. **Módulos de lógica pura de fora** — a regra "todo teste importa `composeStories`" não cobre `format.ts`/`parse-period.ts` (sem story). +5. **"Monta sem erro" (`it.each` smoke) como substância** — teste bobo, passa sempre, não pega regressão. Só serve de piso. +6. **Só provou o Button — o caso trivial** — sem overlay, composição ou recursão. A estratégia não foi validada onde estressa. +7. **Sem pensar em escala/CI** — 157 componentes × Chromium num job só = lento e flaky; falta sharding/retry. +8. **Skew de versão** — vitest 2.1.9 na #704 vs 4.1 no icons-gallery. +9. **axe ≠ a11y de verdade** — axe pega violação estática, não a11y comportamental (foco, Tab, Escape). A a11y real está na Camada 2 (`play()`), que a #704 só exercitou no Button. + +### O que aproveitar (a ideia boa) + +- **Vitest browser mode (Chromium real) em vez de jsdom** — decisão acertada; elimina falsos positivos de foco/`Teleport`/layout. +- **Story como fixture única (`composeStories`)** — uma fonte pra doc + interaction + unit. +- **Hook de governança** + whitelist de legados + scaffold que emite `.test.ts`. +- **A régua anti-bobo já escrita**: sem assert em string de classe; apagar teste que só passa pra uma implementação; asserir role/ARIA/`data-`/foco, nunca estado interno; proibido mockar layout. + +--- + +## Contexto + +`packages/webkit` tem **157 componentes / 77 stories / 76 specs / zero testes**. A CI roda lint + type-check + storybook:build; os hooks provam que o `.vue` *declara* o que o spec promete, mas não que **funciona**. Objetivo: cobertura **funcional** da biblioteca — componentes, UX, eventos, recursão — **sem teste trivial, sem falso positivo.** + +Camadas (versão corrigida da ideia da #704): +- **C1 Unit** — Vitest browser mode + `@testing-library/vue` + `composeStories`: props, eventos, slots, ARIA, `disabled`/`loading`. **+ módulos puros por import direto** (corrige #4). +- **C2 Integração** — `play()` nas stories (`userEvent`/`expect`), na CI via `Story.run()`: Escape fecha overlay, focus trap, Enter/Space, scroll lock. **A a11y de verdade mora aqui.** +- **C3 a11y estática** — `axe-core` no browser mode, **com CSS de tema carregado** (corrige #3); complemento, não "a camada de a11y". +- **C4 Governança** — hook + whitelist + scaffold. + +--- + +## Plano de execução + +### Fase 0 — Fundação endurecida (na `dev`, sem commit até você pedir) +Infra browser-mode já corrigindo as lacunas: `files` com negação (#1), `setup.ts` com CSS de tema (#3), alias de story no vitest (#2), versão reconciliada (#8), carve-out de módulo puro no `testing.md` (#4), régua "smoke é piso" (#5), `test.yml` com sharding + retry (#7). + +### Fase 1 — Provar os exemplares DIFÍCEIS (asserts comportamentais, não smoke) +- **Dialog** (overlay) — abrir/fechar, **Escape**, **focus trap + restauração**, **scroll lock**, `Teleport`. +- **navigation-menu / breadcrumb** (recursivo) — aninhamento ≥2 níveis + propagação de contexto. +- **Select / Table** (composição) — provide/inject, eventos, `v-model`. +- **Calendar rico** — quando a branch `feat/ENG-46317-calendar` mergear (composição + popover + grid + `format`/`parse-period`). + +### Fase 2 — Rollout funcional em waves (157 componentes) +Por categoria, paralelizável com agentes em worktree. Wave final ativa o hook + vira o axe pra hard-fail. + +**Régua funcional por componente — o contrato "sem bobo":** +- **Obrigatório:** todo evento da tabela de Events dispara com payload certo na ação real; `disabled`/`loading`/`readonly` **suprimem** a ação; `v-model` faz round-trip; ARIA/`data-state` reflete estado; interativo ganha `play()` com teclado + foco. +- **Permitido mas insuficiente:** smoke `it.each(variants)` "monta sem erro" (piso). +- **Proibido (falso positivo):** assert em string de classe/Tailwind, pixel/animação, estado interno, mock de layout/posicionamento, ou assert que só passa pra uma implementação. + +Ordem: actions(6) · content(8) · feedback(7) · inputs-simples(~18) · inputs-compostos(select, multi-select) · data(table, paginator) · layout(5) · navigation(breadcrumb, link, menu-item, tab-view, dropdown, navigation-menu) · overlay(dialog, drawer, panel, tooltip) · templates(4) · misc/utils(spinner, svg/*, `cn`/`use-controllable`, `use-focus-trap`/`use-placement`). + +--- + +## Verificação +1. `pnpm -C packages/webkit test` → verde no Chromium headless. +2. `pnpm -C packages/webkit pack:dry` → **nenhum `*.test.ts` no tarball**. +3. axe passa contra DOM **estilizado** (contraste real). +4. `test.yml` verde em PR de webkit; job pula limpo em PR não-webkit. +5. Painel Interactions do Storybook com os `play()` verdes. + +## Higiene de commit +Branches/PRs via `/create-branch` + `/open-pr`, base `dev` nunca `main`; **sem `Co-Authored-By` / "Generated with Claude"**; nunca `--no-verify`; commitar só quando você pedir. Docs/regras (`testing.md`, `TESTING_STRATEGY.md`, em inglês) em PR própria. diff --git a/eslint.config.js b/eslint.config.js index 79336da54..587d07964 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -34,10 +34,14 @@ export default [ setInterval: 'readonly', clearInterval: 'readonly', ResizeObserver: 'readonly', + Element: 'readonly', HTMLElement: 'readonly', + HTMLAnchorElement: 'readonly', + HTMLButtonElement: 'readonly', HTMLSelectElement: 'readonly', HTMLInputElement: 'readonly', MouseEvent: 'readonly', + KeyboardEvent: 'readonly', Event: 'readonly', Node: 'readonly', // Node globals diff --git a/package.json b/package.json index 3e6ce5a5c..7122982f4 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "webkit:lint:style:fix": "pnpm --filter webkit lint:style:fix", "webkit:type-check": "pnpm --filter webkit type-check", "webkit:type-coverage": "pnpm --filter webkit type-coverage", + "webkit:test": "pnpm --filter webkit test", + "webkit:test:watch": "pnpm --filter webkit test:watch", "webkit:format": "prettier --write packages/webkit/**/*.{js,ts,vue,css,json,md}", "webkit:format:check": "prettier --check packages/webkit/**/*.{js,ts,vue,css,json,md}", "security:audit": "pnpm audit --audit-level=high", diff --git a/packages/webkit/package.json b/packages/webkit/package.json index c275c5a9e..78b4943d1 100644 --- a/packages/webkit/package.json +++ b/packages/webkit/package.json @@ -23,6 +23,8 @@ "lint:style:fix": "stylelint \"src/**/*.{css,scss,vue}\" --fix", "type-check": "vue-tsc --noEmit", "type-coverage": "type-coverage -p . --at-least 95 --detail", + "test": "vitest run", + "test:watch": "vitest", "figma:parse": "figma connect parse", "figma:publish": "figma connect publish", "figma:unpublish": "figma connect unpublish" @@ -38,6 +40,13 @@ }, "devDependencies": { "@figma/code-connect": "^1.4.5", + "@storybook/vue3": "^8.6.15", + "@testing-library/vue": "^8.1.0", + "@vitejs/plugin-vue": "^5.2.4", + "@vitest/browser": "^3.2.4", + "axe-core": "^4.10.3", + "playwright": "^1.49.1", + "vitest": "^3.2.4", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^10.0.1", "@semantic-release/exec": "^6.0.3", @@ -50,6 +59,8 @@ }, "files": [ "src", + "!src/**/*.test.{ts,js}", + "!src/test/**", "package.json" ], "sideEffects": [ diff --git a/packages/webkit/src/test/axe.ts b/packages/webkit/src/test/axe.ts new file mode 100644 index 000000000..34faf6ec9 --- /dev/null +++ b/packages/webkit/src/test/axe.ts @@ -0,0 +1,12 @@ +import axe from 'axe-core' +import { expect } from 'vitest' + +/** + * Runs axe-core against a rendered container and asserts zero WCAG 2.1 AA + * violations. Meaningful only because tests render with the theme CSS loaded + * (see setup.ts) — contrast checks would be unreliable on unstyled DOM. + */ +export async function expectNoA11yViolations(container: Element): Promise { + const results = await axe.run(container) + expect(results.violations).toEqual([]) +} diff --git a/packages/webkit/src/test/setup.ts b/packages/webkit/src/test/setup.ts new file mode 100644 index 000000000..d89ad5149 --- /dev/null +++ b/packages/webkit/src/test/setup.ts @@ -0,0 +1,13 @@ +// Vitest global setup for @aziontech/webkit (browser mode). +// +// The theme globals are imported so components render with their real design +// tokens/CSS — this is what makes axe color-contrast and any visual-semantic +// assertion trustworthy (unstyled DOM would silently pass/skip contrast). +import '@aziontech/theme/globals.css' + +import { cleanup } from '@testing-library/vue' +import { afterEach } from 'vitest' + +afterEach(() => { + cleanup() +}) diff --git a/packages/webkit/src/test/smoke.test.ts b/packages/webkit/src/test/smoke.test.ts new file mode 100644 index 000000000..89c381e29 --- /dev/null +++ b/packages/webkit/src/test/smoke.test.ts @@ -0,0 +1,18 @@ +import { render } from '@testing-library/vue' +import { defineComponent, h } from 'vue' +import { describe, expect, it } from 'vitest' + +// Proves the browser-mode stack boots end to end: real Chromium (Playwright), +// @testing-library/vue, and the Vue plugin. Not a component test — it is the +// foundation health check and is removed once real suites exist. +const Probe = defineComponent({ + props: { label: { type: String, default: 'ok' } }, + setup: (props) => () => h('button', { type: 'button' }, props.label) +}) + +describe('browser-mode smoke', () => { + it('renders a Vue component in real Chromium and reads the DOM', () => { + const { getByRole } = render(Probe, { props: { label: 'hi' } }) + expect(getByRole('button', { name: 'hi' }).tagName).toBe('BUTTON') + }) +}) diff --git a/packages/webkit/vitest.config.ts b/packages/webkit/vitest.config.ts new file mode 100644 index 000000000..ba84e7ba9 --- /dev/null +++ b/packages/webkit/vitest.config.ts @@ -0,0 +1,32 @@ +import { fileURLToPath, URL } from 'node:url' + +import vue from '@vitejs/plugin-vue' +import { defineConfig } from 'vitest/config' + +// Webkit runs its tests in a REAL browser (Playwright Chromium) via +// @vitest/browser — never jsdom. Focus, , layout/getBoundingClientRect +// and keyboard behave like production, so tests can't pass on jsdom no-ops. +// See .claude/rules/testing.md. +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + // Self-reference the public entry so `@aziontech/webkit/` in stories + // resolves to this package's source during tests. + '@aziontech/webkit': '@aziontech/webkit.dev', + // Stable handle for the Storybook stories dir, so co-located tests import + // stories via `@stories/...` instead of brittle ../../../../../ paths. + '@stories': fileURLToPath(new URL('../../apps/storybook/src/stories', import.meta.url)) + } + }, + test: { + include: ['src/**/*.test.{ts,js}'], + setupFiles: ['./src/test/setup.ts'], + browser: { + enabled: true, + provider: 'playwright', + headless: true, + instances: [{ browser: 'chromium' }] + } + } +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91b124c69..3f20cbbdb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -386,12 +386,33 @@ importers: '@semantic-release/release-notes-generator': specifier: ^12.1.0 version: 12.1.0(semantic-release@23.1.1(typescript@6.0.3)) + '@storybook/vue3': + specifier: ^8.6.15 + version: 8.6.18(storybook@8.6.18(prettier@3.8.3))(vue@3.5.33(typescript@6.0.3)) + '@testing-library/vue': + specifier: ^8.1.0 + version: 8.1.0(@vue/compiler-dom@3.5.33)(@vue/compiler-sfc@3.5.33)(@vue/server-renderer@3.5.33(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3)) + '@vitejs/plugin-vue': + specifier: ^5.2.4 + version: 5.2.4(vite@6.4.3(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3))(vue@3.5.33(typescript@6.0.3)) + '@vitest/browser': + specifier: ^3.2.4 + version: 3.2.6(playwright@1.61.1)(vite@6.4.3(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3))(vitest@3.2.6) + axe-core: + specifier: ^4.10.3 + version: 4.12.1 conventional-changelog-conventionalcommits: specifier: ^7.0.0 version: 7.0.2 + playwright: + specifier: ^1.49.1 + version: 1.61.1 semantic-release: specifier: ^23.0.0 version: 23.1.1(typescript@6.0.3) + vitest: + specifier: ^3.2.4 + version: 3.2.6(@types/node@25.6.0)(@vitest/browser@3.2.6)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@24.1.3)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3) packages: @@ -1798,6 +1819,9 @@ packages: '@octokit/types@14.1.0': resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==} + '@one-ini/wasm@0.1.1': + resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + '@oxc-parser/binding-android-arm64@0.76.0': resolution: {integrity: sha512-1XJW/16CDmF5bHE7LAyPPmEEVnxSadDgdJz+xiLqBrmC4lfAeuAfRw3HlOygcPGr+AJsbD4Z5sFJMkwjbSZlQg==} engines: {node: '>=20.0.0'} @@ -2000,6 +2024,9 @@ packages: resolution: {integrity: sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==} engines: {node: '>=12'} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -2432,6 +2459,10 @@ packages: resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} engines: {node: '>=18'} + '@testing-library/dom@9.3.4': + resolution: {integrity: sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==} + engines: {node: '>=14'} + '@testing-library/jest-dom@6.5.0': resolution: {integrity: sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} @@ -2442,6 +2473,22 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@testing-library/vue@8.1.0': + resolution: {integrity: sha512-ls4RiHO1ta4mxqqajWRh8158uFObVrrtAPoxk7cIp4HrnQUj/ScKzqz53HxYpG3X6Zb7H2v+0eTGLSoy8HQ2nA==} + engines: {node: '>=14'} + peerDependencies: + '@vue/compiler-sfc': '>= 3' + vue: '>= 3' + peerDependenciesMeta: + '@vue/compiler-sfc': + optional: true + '@ts-morph/common@0.28.1': resolution: {integrity: sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==} @@ -2671,12 +2718,41 @@ packages: vite: '>=6.4.3' vue: ^3.2.25 + '@vitest/browser@3.2.6': + resolution: {integrity: sha512-CNjSynGBtAVOMTfQITv6Bc8da4/XTU1izorocbDStjUsynXcgx2FHVssh+10a8bKd/BxoqDdQtuSbYHfk302Wg==} + peerDependencies: + playwright: '*' + safaridriver: '*' + vitest: 3.2.6 + webdriverio: ^7.0.0 || ^8.0.0 || ^9.0.0 + peerDependenciesMeta: + playwright: + optional: true + safaridriver: + optional: true + webdriverio: + optional: true + '@vitest/expect@2.0.5': resolution: {integrity: sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==} + '@vitest/expect@3.2.6': + resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==} + '@vitest/expect@4.1.8': resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} + '@vitest/mocker@3.2.6': + resolution: {integrity: sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==} + peerDependencies: + msw: ^2.4.9 + vite: '>=6.4.3' + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/mocker@4.1.8': resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} peerDependencies: @@ -2694,18 +2770,30 @@ packages: '@vitest/pretty-format@2.1.9': resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + '@vitest/pretty-format@3.2.6': + resolution: {integrity: sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==} + '@vitest/pretty-format@4.1.8': resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} + '@vitest/runner@3.2.6': + resolution: {integrity: sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==} + '@vitest/runner@4.1.8': resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} + '@vitest/snapshot@3.2.6': + resolution: {integrity: sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==} + '@vitest/snapshot@4.1.8': resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} '@vitest/spy@2.0.5': resolution: {integrity: sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==} + '@vitest/spy@3.2.6': + resolution: {integrity: sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==} + '@vitest/spy@4.1.8': resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} @@ -2715,6 +2803,9 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@vitest/utils@3.2.6': + resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==} + '@vitest/utils@4.1.8': resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} @@ -2805,6 +2896,16 @@ packages: '@vue/shared@3.5.33': resolution: {integrity: sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==} + '@vue/test-utils@2.4.11': + resolution: {integrity: sha512-GDqaqZsA6m2E5vNzej0aYiIb6BX8xV9pNSbbbXKOfEYwg7ZNblVX8suyqmUBThq8VIrgAJNxn+z72hVtUeiWHA==} + peerDependencies: + '@vue/compiler-dom': 3.x + '@vue/server-renderer': 3.x + vue: 3.x + peerDependenciesMeta: + '@vue/server-renderer': + optional: true + '@vueuse/core@10.11.1': resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==} @@ -2878,6 +2979,10 @@ packages: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true + abbrev@2.0.0: + resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + abbrev@3.0.1: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} engines: {node: ^18.17.0 || >=20.5.0} @@ -3004,6 +3109,9 @@ packages: argv-formatter@1.0.0: resolution: {integrity: sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw==} + aria-query@5.1.3: + resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -3083,6 +3191,10 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + axe-core@4.12.1: + resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==} + engines: {node: '>=4'} + axios@1.16.1: resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} @@ -3254,6 +3366,10 @@ packages: builtin-status-codes@3.0.0: resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + cacache@19.0.1: resolution: {integrity: sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -3445,6 +3561,10 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + commander@11.1.0: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} @@ -3674,6 +3794,10 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-equal@2.2.3: + resolution: {integrity: sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==} + engines: {node: '>= 0.4'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -3775,6 +3899,11 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + editorconfig@1.0.7: + resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==} + engines: {node: '>=14'} + hasBin: true + electron-to-chromium@1.5.345: resolution: {integrity: sha512-F9JXQGiMrz6yVNPI2qOVPvB9HzjH5cGzhs8oJ6A28V5L/YnzN/0KsuiibqF+F1Fd9qxFzD1BUnYSd8JfULxTwg==} @@ -3845,6 +3974,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-get-iterator@1.1.3: + resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -4296,6 +4428,11 @@ packages: resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4938,6 +5075,14 @@ packages: js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} + js-beautify@1.15.4: + resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} + engines: {node: '>=14'} + hasBin: true + + js-cookie@3.0.8: + resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==} + js-stringify@1.0.2: resolution: {integrity: sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==} @@ -5320,6 +5465,10 @@ packages: resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==} engines: {node: '>=0.10.0'} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -5385,6 +5534,11 @@ packages: node-releases@2.0.38: resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + nopt@7.2.1: + resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + nopt@8.1.0: resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} engines: {node: ^18.17.0 || >=20.5.0} @@ -5766,6 +5920,16 @@ packages: resolution: {integrity: sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==} engines: {node: '>=4'} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + pnpm@11.9.0: resolution: {integrity: sha512-vWgtXQP+Ul73yf1ngMaITR51asTJyf4AxTh4KCQxDc+Q493E9Tg18G3669UIXkGFXgvLs7YN4qxburieUDbwOw==} engines: {node: '>=22.13'} @@ -6297,6 +6461,10 @@ packages: resolution: {integrity: sha512-iuh+gPf28RkltuJC7W5MRi6XAjTDCAPC/prJUpQoG4vIP3MJZ+GTydVnodXA7pwvTKb2cA0m9OFZW/cdWy/I/w==} engines: {node: '>=6'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -6387,6 +6555,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} @@ -6493,6 +6664,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + stylelint-config-html@1.1.0: resolution: {integrity: sha512-IZv4IVESjKLumUGi+HWeb7skgO6/g4VMuAYrJdlqQFndgbj6WJAXPhaysvBiXefX79upBdQVumgYcdd17gCpjQ==} engines: {node: ^12 || >=14} @@ -6719,6 +6893,9 @@ packages: tinycolor2@1.6.0: resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.1.2: resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} engines: {node: '>=18'} @@ -6727,10 +6904,18 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + tinyrainbow@1.2.0: resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} engines: {node: '>=14.0.0'} + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + tinyrainbow@3.1.0: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} @@ -6739,6 +6924,10 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + to-buffer@1.2.2: resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} engines: {node: '>= 0.4'} @@ -6750,6 +6939,10 @@ packages: token-stream@1.0.0: resolution: {integrity: sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tough-cookie@4.1.4: resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} engines: {node: '>=6'} @@ -7016,6 +7209,11 @@ packages: peerDependencies: vue: ^3.4.26 + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite@6.4.3: resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -7056,6 +7254,34 @@ packages: yaml: optional: true + vitest@3.2.6: + resolution: {integrity: sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.6 + '@vitest/ui': 3.2.6 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@4.1.8: resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -8145,7 +8371,7 @@ snapshots: '@babel/template@7.28.6': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/parser': 7.29.2 '@babel/types': 7.29.0 @@ -8157,7 +8383,7 @@ snapshots: '@babel/traverse@7.29.0': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.1 '@babel/helper-globals': 7.28.0 '@babel/parser': 7.29.2 @@ -9006,6 +9232,8 @@ snapshots: dependencies: '@octokit/openapi-types': 25.1.0 + '@one-ini/wasm@0.1.1': {} + '@oxc-parser/binding-android-arm64@0.76.0': optional: true @@ -9131,6 +9359,8 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 + '@polka/url@1.0.0-next.29': {} + '@popperjs/core@2.11.8': {} '@rollup/rollup-android-arm-eabi@4.60.2': @@ -9619,6 +9849,20 @@ snapshots: vue: 3.5.33(typescript@5.9.3) vue-component-type-helpers: 3.3.5 + '@storybook/vue3@8.6.18(storybook@8.6.18(prettier@3.8.3))(vue@3.5.33(typescript@6.0.3))': + dependencies: + '@storybook/components': 8.6.18(storybook@8.6.18(prettier@3.8.3)) + '@storybook/global': 5.0.0 + '@storybook/manager-api': 8.6.18(storybook@8.6.18(prettier@3.8.3)) + '@storybook/preview-api': 8.6.18(storybook@8.6.18(prettier@3.8.3)) + '@storybook/theming': 8.6.18(storybook@8.6.18(prettier@3.8.3)) + '@vue/compiler-core': 3.5.33 + storybook: 8.6.18(prettier@3.8.3) + ts-dedent: 2.2.0 + type-fest: 2.19.0 + vue: 3.5.33(typescript@6.0.3) + vue-component-type-helpers: 3.3.5 + '@tailwindcss/typography@0.5.19(tailwindcss@3.4.19(yaml@2.8.3))': dependencies: postcss-selector-parser: 6.0.10 @@ -9642,6 +9886,17 @@ snapshots: lz-string: 1.5.0 pretty-format: 27.5.1 + '@testing-library/dom@9.3.4': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 + aria-query: 5.1.3 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + '@testing-library/jest-dom@6.5.0': dependencies: '@adobe/css-tools': 4.5.0 @@ -9656,6 +9911,22 @@ snapshots: dependencies: '@testing-library/dom': 10.4.0 + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.0)': + dependencies: + '@testing-library/dom': 10.4.0 + + '@testing-library/vue@8.1.0(@vue/compiler-dom@3.5.33)(@vue/compiler-sfc@3.5.33)(@vue/server-renderer@3.5.33(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3))': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 9.3.4 + '@vue/test-utils': 2.4.11(@vue/compiler-dom@3.5.33)(@vue/server-renderer@3.5.33(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3)) + vue: 3.5.33(typescript@6.0.3) + optionalDependencies: + '@vue/compiler-sfc': 3.5.33 + transitivePeerDependencies: + - '@vue/compiler-dom' + - '@vue/server-renderer' + '@ts-morph/common@0.28.1': dependencies: minimatch: 10.2.5 @@ -9880,6 +10151,25 @@ snapshots: vite: 6.4.3(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3) vue: 3.5.33(typescript@6.0.3) + '@vitest/browser@3.2.6(playwright@1.61.1)(vite@6.4.3(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3))(vitest@3.2.6)': + dependencies: + '@testing-library/dom': 10.4.0 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.0) + '@vitest/mocker': 3.2.6(vite@6.4.3(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3)) + '@vitest/utils': 3.2.6 + magic-string: 0.30.21 + sirv: 3.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.6(@types/node@25.6.0)(@vitest/browser@3.2.6)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@24.1.3)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3) + ws: 8.21.0 + optionalDependencies: + playwright: 1.61.1 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + '@vitest/expect@2.0.5': dependencies: '@vitest/spy': 2.0.5 @@ -9887,6 +10177,14 @@ snapshots: chai: 5.3.3 tinyrainbow: 1.2.0 + '@vitest/expect@3.2.6': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 + chai: 5.3.3 + tinyrainbow: 2.0.0 + '@vitest/expect@4.1.8': dependencies: '@standard-schema/spec': 1.1.0 @@ -9896,6 +10194,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@3.2.6(vite@6.4.3(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 3.2.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.3(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3) + '@vitest/mocker@4.1.8(vite@6.4.3(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.8 @@ -9912,15 +10218,31 @@ snapshots: dependencies: tinyrainbow: 1.2.0 + '@vitest/pretty-format@3.2.6': + dependencies: + tinyrainbow: 2.0.0 + '@vitest/pretty-format@4.1.8': dependencies: tinyrainbow: 3.1.0 + '@vitest/runner@3.2.6': + dependencies: + '@vitest/utils': 3.2.6 + pathe: 2.0.3 + strip-literal: 3.1.0 + '@vitest/runner@4.1.8': dependencies: '@vitest/utils': 4.1.8 pathe: 2.0.3 + '@vitest/snapshot@3.2.6': + dependencies: + '@vitest/pretty-format': 3.2.6 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/snapshot@4.1.8': dependencies: '@vitest/pretty-format': 4.1.8 @@ -9932,6 +10254,10 @@ snapshots: dependencies: tinyspy: 3.0.2 + '@vitest/spy@3.2.6': + dependencies: + tinyspy: 4.0.4 + '@vitest/spy@4.1.8': {} '@vitest/utils@2.0.5': @@ -9947,6 +10273,12 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + '@vitest/utils@3.2.6': + dependencies: + '@vitest/pretty-format': 3.2.6 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + '@vitest/utils@4.1.8': dependencies: '@vitest/pretty-format': 4.1.8 @@ -9992,7 +10324,7 @@ snapshots: '@vue/compiler-sfc@3.5.33': dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.7 '@vue/compiler-core': 3.5.33 '@vue/compiler-dom': 3.5.33 '@vue/compiler-ssr': 3.5.33 @@ -10103,6 +10435,15 @@ snapshots: '@vue/shared@3.5.33': {} + '@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.33)(@vue/server-renderer@3.5.33(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3))': + dependencies: + '@vue/compiler-dom': 3.5.33 + js-beautify: 1.15.4 + vue: 3.5.33(typescript@6.0.3) + vue-component-type-helpers: 3.3.5 + optionalDependencies: + '@vue/server-renderer': 3.5.33(vue@3.5.33(typescript@6.0.3)) + '@vueuse/core@10.11.1(vue@3.5.33(typescript@6.0.3))': dependencies: '@types/web-bluetooth': 0.0.20 @@ -10213,6 +10554,8 @@ snapshots: jsonparse: 1.3.1 through: 2.3.8 + abbrev@2.0.0: {} + abbrev@3.0.1: {} accepts@1.3.8: @@ -10322,6 +10665,10 @@ snapshots: argv-formatter@1.0.0: {} + aria-query@5.1.3: + dependencies: + deep-equal: 2.2.3 + aria-query@5.3.0: dependencies: dequal: 2.0.3 @@ -10424,6 +10771,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + axe-core@4.12.1: {} + axios@1.16.1: dependencies: follow-redirects: 1.16.0 @@ -10670,6 +11019,8 @@ snapshots: builtin-status-codes@3.0.0: {} + cac@6.7.14: {} + cacache@19.0.1: dependencies: '@npmcli/fs': 4.0.0 @@ -10874,6 +11225,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@10.0.1: {} + commander@11.1.0: {} commander@12.1.0: {} @@ -11128,6 +11481,27 @@ snapshots: deep-eql@5.0.2: {} + deep-equal@2.2.3: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + es-get-iterator: 1.1.3 + get-intrinsic: 1.3.0 + is-arguments: 1.2.0 + is-array-buffer: 3.0.5 + is-date-object: 1.1.0 + is-regex: 1.2.1 + is-shared-array-buffer: 1.0.4 + isarray: 2.0.5 + object-is: 1.1.6 + object-keys: 1.1.1 + object.assign: 4.1.7 + regexp.prototype.flags: 1.5.4 + side-channel: 1.1.0 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + deep-extend@0.6.0: {} deep-is@0.1.4: {} @@ -11226,6 +11600,13 @@ snapshots: eastasianwidth@0.2.0: {} + editorconfig@1.0.7: + dependencies: + '@one-ini/wasm': 0.1.1 + commander: 10.0.1 + minimatch: 10.2.5 + semver: 7.7.4 + electron-to-chromium@1.5.345: {} elliptic@6.6.1: @@ -11342,6 +11723,18 @@ snapshots: es-errors@1.3.0: {} + es-get-iterator@1.1.3: + dependencies: + call-bind: 1.0.9 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + is-arguments: 1.2.0 + is-map: 2.0.3 + is-set: 2.0.3 + is-string: 1.1.1 + isarray: 2.0.5 + stop-iteration-iterator: 1.1.0 + es-module-lexer@1.7.0: {} es-module-lexer@2.1.0: {} @@ -11946,6 +12339,9 @@ snapshots: dependencies: minipass: 7.1.3 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -12593,6 +12989,16 @@ snapshots: js-base64@3.7.8: {} + js-beautify@1.15.4: + dependencies: + config-chain: 1.1.13 + editorconfig: 1.0.7 + glob: 10.5.0 + js-cookie: 3.0.8 + nopt: 7.2.1 + + js-cookie@3.0.8: {} + js-stringify@1.0.2: {} js-tokens@4.0.0: {} @@ -12969,6 +13375,8 @@ snapshots: modify-values@1.0.1: {} + mrmime@2.0.1: {} + ms@2.1.3: {} muggle-string@0.4.1: {} @@ -13033,6 +13441,10 @@ snapshots: node-releases@2.0.38: {} + nopt@7.2.1: + dependencies: + abbrev: 2.0.0 + nopt@8.1.0: dependencies: abbrev: 3.0.1 @@ -13356,6 +13768,14 @@ snapshots: find-up: 2.1.0 load-json-file: 4.0.0 + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + pnpm@11.9.0: {} polished@4.3.1: @@ -13992,6 +14412,12 @@ snapshots: figures: 2.0.0 pkg-conf: 2.1.0 + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + sisteransi@1.0.5: {} skin-tone@2.0.0: @@ -14076,6 +14502,8 @@ snapshots: stackback@0.0.2: {} + std-env@3.10.0: {} + std-env@4.1.0: {} stdin-discarder@0.2.2: {} @@ -14196,6 +14624,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + stylelint-config-html@1.1.0(postcss-html@1.8.1)(stylelint@17.9.1(typescript@6.0.3)): dependencies: postcss-html: 1.8.1 @@ -14497,6 +14929,8 @@ snapshots: tinycolor2@1.6.0: {} + tinyexec@0.3.2: {} + tinyexec@1.1.2: {} tinyglobby@0.2.16: @@ -14504,12 +14938,18 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinypool@1.1.1: {} + tinyrainbow@1.2.0: {} + tinyrainbow@2.0.0: {} + tinyrainbow@3.1.0: {} tinyspy@3.0.2: {} + tinyspy@4.0.4: {} + to-buffer@1.2.2: dependencies: isarray: 2.0.5 @@ -14522,6 +14962,8 @@ snapshots: token-stream@1.0.0: {} + totalist@3.0.1: {} + tough-cookie@4.1.4: dependencies: psl: 1.15.0 @@ -14822,6 +15264,27 @@ snapshots: type-fest: 4.41.0 vue: 3.5.33(typescript@6.0.3) + vite-node@3.2.4(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.3(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite@6.4.3(@types/node@25.6.0)(jiti@1.21.7)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3): dependencies: esbuild: 0.25.12 @@ -14854,6 +15317,50 @@ snapshots: terser: 5.46.2 yaml: 2.8.3 + vitest@3.2.6(@types/node@25.6.0)(@vitest/browser@3.2.6)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@24.1.3)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.6 + '@vitest/mocker': 3.2.6(vite@6.4.3(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3)) + '@vitest/pretty-format': 3.2.6 + '@vitest/runner': 3.2.6 + '@vitest/snapshot': 3.2.6 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.16 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.4.3(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3) + vite-node: 3.2.4(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.6.0 + '@vitest/browser': 3.2.6(playwright@1.61.1)(vite@6.4.3(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3))(vitest@3.2.6) + happy-dom: 20.9.0 + jsdom: 24.1.3 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@4.1.8(@types/node@25.6.0)(happy-dom@20.9.0)(jsdom@24.1.3)(vite@6.4.3(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.8 From 14f65276222e923c0d87c8556bbee7e43bb5c9da Mon Sep 17 00:00:00 2001 From: HerbertJulio Date: Wed, 1 Jul 2026 10:55:09 -0300 Subject: [PATCH 02/11] [NO-ISSUE] test(webkit): add Button suite and finalize browser-mode config Functional Button coverage (polymorphism, click emission, disabled/loading click-suppression, aria-busy/disabled, spinner, axe a11y) plus composeStories fixtures. Config: define process.env.NODE_ENV for @testing-library/vue in the browser, CI retry, drop the unusable @stories alias (tests use relative story paths so validate-references resolves them). Removes the bootstrap smoke test. --- ...-click-with-a-MouseEvent-when-active-1.png | Bin 0 -> 2703 bytes ...-disabled-state-and-suppresses-click-1.png | Bin 0 -> 2739 bytes ...resses-click--not-natively-disabled--1.png | Bin 0 -> 3674 bytes .../components/actions/button/button.test.ts | 107 ++++++++++++++++++ packages/webkit/src/test/smoke.test.ts | 18 --- packages/webkit/vitest.config.ts | 17 +-- 6 files changed, 116 insertions(+), 26 deletions(-) create mode 100644 packages/webkit/src/components/actions/button/__screenshots__/button.test.ts/Button-click-emission-emits-click-with-a-MouseEvent-when-active-1.png create mode 100644 packages/webkit/src/components/actions/button/__screenshots__/button.test.ts/Button-disabled-sets-the-native---aria-disabled-state-and-suppresses-click-1.png create mode 100644 packages/webkit/src/components/actions/button/__screenshots__/button.test.ts/Button-loading-exposes-aria-busy--shows-the-spinner--and-suppresses-click--not-natively-disabled--1.png create mode 100644 packages/webkit/src/components/actions/button/button.test.ts delete mode 100644 packages/webkit/src/test/smoke.test.ts diff --git a/packages/webkit/src/components/actions/button/__screenshots__/button.test.ts/Button-click-emission-emits-click-with-a-MouseEvent-when-active-1.png b/packages/webkit/src/components/actions/button/__screenshots__/button.test.ts/Button-click-emission-emits-click-with-a-MouseEvent-when-active-1.png new file mode 100644 index 0000000000000000000000000000000000000000..fc13cca29f2545aef0bb823db8a6f20c7d58d748 GIT binary patch literal 2703 zcmeAS@N?(olHy`uVBq!ia0y~yVDx2RV7kD;1QeOwv~@BA1DA`Zi(^Q|oHw_9yZzrX zANY7b!{Fixl~l7$jwjtVZkjNIwK8JkMujr7xQ0(`5t`CV-%R1nPt4IUo4m-y>xrU{ zO{(G3`hRELzT1A^Td1G5uhy49p~=#&-vx;?rhJ$zb*In zx|p3s&(6#Q+HG6+r-DVtzq`cF-oF0dpPxWWWvxmwa&mGa&a98yyQ}i^v(}sP_H{P3 zzksI5cbEM8^z`)e^Yh=|-Ch3h+wJZ7@%!s+3mzQ!{{DXcy*-t0Z*3LVkCU;hsi;^Y z&v^Qv(p;dSl{?l&Z_j)9?eX#c^z-xP&YW5K{oUQA-qZc&+toffF>$VS`Mq7G*(^Hj z-6i{Ke}8*urSp<|d-S%PoBQkk&$F$r`}fCE=S^^M@ZksV?(R-cPj^31w87)~>+9?H z7d$-l^z`)hRrT`sHzpsy@aItDopyftK3QusrfJPb@2`&CQ&CuvCm*+j_xFdX+TnH; zA0Av;8GLx=9fAM7(&qDQDvSRA`}?pU4`_Gt_4V=gg^!LbD&AlBw+blyJy#ICyEG!HRCCkcBPiC2ByB!EJGMh8Mzro~p z@r%orUtY@mD&Yf0B+!ug=N9@uzp#8%cr;8#6U1n47%d7$i^tKrVTe|Y|Ct#W{{Lrl j-!2JkRdIB?5z5^^Eo~ny#GJzzj%Uqr0x9q_Vxd6Zcg8xcee^?m(BST zi{1O@*;E>>yuY(JJ?~KZr1|ZczmNCJ7yq?W{`|D0?)SI1J6)q2?xvJJFTP&;_SV*! z+w<@2Fg&RL-|y~nzqzwEY~Q!1;^QNe{~sS8-=2RzZb!kxv$M^Ouby95x9-9``}%zh zyH9qVOYi#k;o;$jhuhc3?X`OSZRQH~AAf&;x3Bn+@c!Q3==Wz1?<{`)Y)u~5{HcFd zhp&Ieevqa9>8YtVuj(Jj+qCF&WXt{i_4Qfv+b660pR233+F$+sU8k^m+OByY*Ir-t z`Rv)VC#&c6%UYX#{*!a%`Ps;0AZ?G%@1KwR|NXX2Q@39~*RHmTvFy+*>kVsT>icVQRR8`ye(v9oDYs9ZXq0{a zJ#L}@^9$RbU;p{@+sxYE-~P&SKd4`O{_M?|&fNR@tIsW~J@>Fih^_3xtvU1c9cDc> z`SMccR|#LORlD6>=lSPAnwJ;9xNJEpJQ^mW31T!ij1~o>#p7t*FeEF+ef7W==l}n^ jZfoBHGT1b#kP8;M#0UWbi6Mjle<1f{4?D9Tc0cb&zP@wjJ?Fg7 z?|J5&JAXNI!tK2e--95?E%xNGvktg>$UJK`so!xiK^^dWqf)0PzzXz2}dUQBwA*<~Bqo^M~ z6gGTE5*2z2M8S4`nt}TJ$W@h2+(RQWrGp#t;+Cc^^U5ZU-@bJT#+u*$Q|Mcq`J#MJ zEnn6lCvb0!KOsv{=_7%hMyO#lE&mNks9`aA;&A!r_ELQhEb+oqNUy(Q#odg%4&QS1 zBGeC6#%xU~pSJMop@s>S=idl+0nx0aVM}eBlL3j%l(}18gx<}Gu#Aaio@K?wCuj6I z*m9*y9dxPfx|1jtF>X3pWLjTTjux5MU!FiR?(r=vT+&d?gEd0WngBF5-#;Dn5F$%& zt96P}iw;5T3Gp;#ZM;0;kk^~0qOs&oQn3*!ge#^5kE0 zu+y1}5<`jVVS3uuf=A58Xw?y34-EPr7cEBX-vWWnmU~7$Hs98<4-PtxmgYnSEIe34U5o3#g~;nk1LMwj92_v zD**{?y1Pose-zGP4b4Qv%kuF{U@b zS1mR8Mwjp!w;W8GzS6hoExncs7LMg7v_!z`20;@==#$?Fkwv%lh_JKUe9-%-W(|v4 zZz$j6ng>(~8Afu=dxH&`%O!uvPa-y^AY7LC%%5ljn}bze@i$S+*iMBu)z&P(gtjV^ z4pd?oN12kp$E!zKu|#!@%O6ZJp`9HDEm zI#klBZYn<1hn-+E_$E~YvSj0Bgo4tVqxn6fr8f`AQM<{N1Rg(GWr0B)Mj3Z$^C36V6Eitf*(te3k69XMxrA3pANxotcF$0(S;v+W8s zlZttV4~XB7%Os7(MFb{2o83+X;8A3$aqe7vOsKndGJ?C(trbMc ziF_OIOn25?e>}#tP~m}zv>TPQsFhC8vvhetSiseFaA8SQ{!F17Oo`8#`8Q+M>3qdB zN$8^o9sxZ{oUM{5ef4c*!ED*$NFB|Ygp%z>T9IjW_ zA;*AQwP#H7fbW!I6Rf6%b$ww0aK~J91V4EGg6>pJEOR~5vr40qHE{~C*?HyBO-q5tyrQe(RUvz4` z%{lHR=>LUwr@S+h9Zvoe;X7{Fal?)qcH977bN|;U;5l|fki+pNKOhDC5Z~GU8t#Tz Xet%v*cWQ$RDp2h4Gsl`gODX>sN`fdS literal 0 HcmV?d00001 diff --git a/packages/webkit/src/components/actions/button/button.test.ts b/packages/webkit/src/components/actions/button/button.test.ts new file mode 100644 index 000000000..836627d56 --- /dev/null +++ b/packages/webkit/src/components/actions/button/button.test.ts @@ -0,0 +1,107 @@ +import { composeStories } from '@storybook/vue3' +import { fireEvent, render } from '@testing-library/vue' +import { describe, expect, it } from 'vitest' + +import * as stories from '../../../../../../apps/storybook/src/stories/components/actions/button/Button.stories' + +import { expectNoA11yViolations } from '../../../test/axe' +import Button from './button.vue' + +const { Default, Disabled, Loading } = composeStories(stories) + +describe('Button', () => { + describe('rendering & polymorphism', () => { + it('renders a ' } + }) + // header region shows (header-action present) but no title element. + expect(queryByTestId(`${TESTID}__header`)).toBeTruthy() + expect(queryByTestId(`${TESTID}__title`)).toBeNull() + }) + + it('renders the header-action region in the default header when its slot is provided', () => { + const { getByTestId, getByText } = render(CardBox, { + props: { title: 'Overview' }, + slots: { 'header-action': '' } + }) + const action = getByTestId(`${TESTID}__header-action`) + expect(action.contains(getByText('Edit'))).toBe(true) + }) + + it('omits the header-action region when its slot is absent', () => { + const { queryByTestId } = render(CardBox, { props: { title: 'Overview' } }) + expect(queryByTestId(`${TESTID}__header-action`)).toBeNull() + }) + + it('uses the header slot verbatim and suppresses the default title layout', () => { + const { getByTestId, getByText, queryByTestId } = render(CardBox, { + props: { title: 'Ignored' }, + slots: { header: 'Custom header' } + }) + const header = getByTestId(`${TESTID}__header`) + expect(header.contains(getByText('Custom header'))).toBe(true) + // Default title layout is suppressed when the header slot is used. + expect(queryByTestId(`${TESTID}__title`)).toBeNull() + expect(header.textContent).not.toContain('Ignored') + }) + }) + + describe('footer region', () => { + it('omits the footer region when the footer slot is absent', () => { + const { queryByTestId } = render(CardBox) + expect(queryByTestId(`${TESTID}__footer`)).toBeNull() + }) + + it('renders the footer region with slot content when the footer slot is provided', () => { + const { getByTestId, getByText } = render(CardBox, { + slots: { footer: '' } + }) + const footer = getByTestId(`${TESTID}__footer`) + expect(footer.tagName).toBe('FOOTER') + expect(footer.contains(getByText('Save'))).toBe(true) + }) + }) + + describe('accessibility', () => { + it('has no violations for the content-only card', async () => { + const { container } = render(CardBox, { + slots: { content: '

Body copy for the card region.

' } + }) + await expectNoA11yViolations(container) + }) + + it('has no violations with the default header (title) and footer', async () => { + const { container } = render(CardBox, { + props: { title: 'Account settings' }, + slots: { + 'header-action': '', + content: '

Body copy for the card region.

', + footer: '' + } + }) + await expectNoA11yViolations(container) + }) + }) + + it('renders the composed Default story fixture', () => { + const { getByTestId, getByText } = render(Default) + expect(getByTestId(TESTID)).toBeTruthy() + // The story sets title "Card Title" and fills the content slot. + expect(getByTestId(`${TESTID}__title`).textContent).toContain('Card Title') + expect(getByText(/Card body content/)).toBeTruthy() + }) +}) diff --git a/packages/webkit/src/components/content/card-pricing/card-pricing.test.ts b/packages/webkit/src/components/content/card-pricing/card-pricing.test.ts new file mode 100644 index 000000000..2baac9ef5 --- /dev/null +++ b/packages/webkit/src/components/content/card-pricing/card-pricing.test.ts @@ -0,0 +1,202 @@ +import { composeStories } from '@storybook/vue3' +import { render } from '@testing-library/vue' +import { describe, expect, it } from 'vitest' + +import * as stories from '../../../../../../apps/storybook/src/stories/components/content/card-pricing/CardPricing.stories' +import { expectNoA11yViolations } from '../../../test/axe' +import CardPricing from './card-pricing.vue' + +const { SlotPositionBottomContained } = composeStories(stories) + +const SLOT_POSITIONS = ['bottom', 'middle'] as const +const CARD_STYLES = ['contained', 'transparent'] as const + +const ROOT = 'content-card-pricing' + +describe('CardPricing', () => { + it('renders the article root with the fallback data-testid', () => { + const { getByTestId } = render(CardPricing, { props: {} }) + + const root = getByTestId(ROOT) + expect(root).toBeInTheDocument() + expect(root.tagName).toBe('ARTICLE') + }) + + it('renders planTitle inside the title sub-element', () => { + const { getByTestId } = render(CardPricing, { props: { planTitle: 'Enterprise' } }) + + const title = getByTestId(`${ROOT}__title`) + expect(title).toHaveTextContent('Enterprise') + expect(getByTestId(`${ROOT}__header`)).toContainElement(title) + }) + + it('uses the default planTitle "Pro" when unset', () => { + const { getByTestId } = render(CardPricing, { props: {} }) + expect(getByTestId(`${ROOT}__title`)).toHaveTextContent('Pro') + }) + + it('omits the description sub-element by default (empty string)', () => { + const { queryByTestId } = render(CardPricing, { props: {} }) + // description default is '' -> v-if="description" is falsy + expect(queryByTestId(`${ROOT}__description`)).toBeNull() + }) + + it('renders the description sub-element when provided', () => { + const { getByTestId } = render(CardPricing, { + props: { description: 'Best for growing teams.' } + }) + expect(getByTestId(`${ROOT}__description`)).toHaveTextContent('Best for growing teams.') + }) + + it('hides the tag by default (showTag false)', () => { + const { queryByTestId } = render(CardPricing, { props: {} }) + expect(queryByTestId(`${ROOT}__tag`)).toBeNull() + }) + + it('shows the tag with tagLabel when showTag is true', () => { + const { getByTestId } = render(CardPricing, { + props: { showTag: true, tagLabel: 'Best value' } + }) + expect(getByTestId(`${ROOT}__tag`)).toHaveTextContent('Best value') + }) + + it('shows pricing-details when showPricingDetails is true and text is non-empty', () => { + const { getByTestId } = render(CardPricing, { + props: { showPricingDetails: true, pricingDetails: 'Billed annually.' } + }) + expect(getByTestId(`${ROOT}__pricing-details`)).toHaveTextContent('Billed annually.') + }) + + it('hides pricing-details when the text is empty even if showPricingDetails is true', () => { + const { queryByTestId } = render(CardPricing, { + props: { showPricingDetails: true, pricingDetails: '' } + }) + expect(queryByTestId(`${ROOT}__pricing-details`)).toBeNull() + }) + + it('hides pricing-details when showPricingDetails is false even if text is present', () => { + const { queryByTestId } = render(CardPricing, { + props: { showPricingDetails: false, pricingDetails: 'Billed annually.' } + }) + expect(queryByTestId(`${ROOT}__pricing-details`)).toBeNull() + }) + + it('passes value/prefix/suffix through to the composed Currency sub-testids', () => { + const { getByTestId } = render(CardPricing, { + props: { value: '49', prefix: '€', suffix: 'per year' } + }) + + const currency = getByTestId(`${ROOT}__currency`) + expect(getByTestId(`${ROOT}`)).toContainElement(currency) + expect(getByTestId(`${ROOT}__currency__value`)).toHaveTextContent('49') + expect(getByTestId(`${ROOT}__currency__prefix`)).toHaveTextContent('€') + expect(getByTestId(`${ROOT}__currency__suffix`)).toHaveTextContent('per year') + }) + + it('renders the default action button with actionLabel when the actions slot is empty', () => { + const { getByTestId } = render(CardPricing, { props: { actionLabel: 'Choose plan' } }) + + const actions = getByTestId(`${ROOT}__actions`) + const action = getByTestId(`${ROOT}__action`) + expect(actions).toContainElement(action) + expect(action).toHaveTextContent('Choose plan') + }) + + it('suppresses the default action button when actionLabel is empty', () => { + const { getByTestId, queryByTestId } = render(CardPricing, { props: { actionLabel: '' } }) + + // actions container still renders (it holds the slot), but the default button is guarded by v-if="actionLabel" + expect(getByTestId(`${ROOT}__actions`)).toBeInTheDocument() + expect(queryByTestId(`${ROOT}__action`)).toBeNull() + }) + + it('renders default slot content inside the slot region', () => { + const { getByTestId } = render(CardPricing, { + props: {}, + slots: { default: '

Feature copy

' } + }) + + const slot = getByTestId(`${ROOT}__slot`) + expect(slot).toHaveTextContent('Feature copy') + expect(slot).toContainElement(getByTestId('feature-list')) + }) + + it('overrides the default action button when the actions slot is filled', () => { + const { getByTestId, queryByTestId } = render(CardPricing, { + props: { actionLabel: 'Should be replaced' }, + slots: { actions: '' } + }) + + const actions = getByTestId(`${ROOT}__actions`) + expect(actions).toContainElement(getByTestId('custom-cta')) + // named-slot content replaces the fallback default button entirely + expect(queryByTestId(`${ROOT}__action`)).toBeNull() + expect(actions).not.toHaveTextContent('Should be replaced') + }) + + it('renders currency, pricing, actions and slot regions for both slot positions', () => { + for (const slotPosition of SLOT_POSITIONS) { + const { getByTestId, unmount } = render(CardPricing, { + props: { slotPosition, pricingDetails: 'x', showPricingDetails: true } + }) + + expect(getByTestId(ROOT)).toBeInTheDocument() + expect(getByTestId(`${ROOT}__currency`)).toBeInTheDocument() + expect(getByTestId(`${ROOT}__pricing`)).toBeInTheDocument() + expect(getByTestId(`${ROOT}__pricing-details`)).toBeInTheDocument() + expect(getByTestId(`${ROOT}__actions`)).toBeInTheDocument() + expect(getByTestId(`${ROOT}__slot`)).toBeInTheDocument() + unmount() + } + }) + + it('honors a consumer-supplied data-testid on the root and derives every sub-testid from it', () => { + const { getByTestId } = render(CardPricing, { + props: { showTag: true, tagLabel: 'Popular', pricingDetails: 'details' }, + attrs: { 'data-testid': 'my-card' } + }) + + expect(getByTestId('my-card')).toBeInTheDocument() + expect(getByTestId('my-card__title')).toHaveTextContent('Pro') + expect(getByTestId('my-card__tag')).toHaveTextContent('Popular') + expect(getByTestId('my-card__currency')).toBeInTheDocument() + expect(getByTestId('my-card__pricing-details')).toHaveTextContent('details') + expect(getByTestId('my-card__action')).toBeInTheDocument() + }) + + it.each(SLOT_POSITIONS)('renders slotPosition=%s without dropping the root', (slotPosition) => { + const { getByTestId } = render(CardPricing, { props: { slotPosition } }) + expect(getByTestId(ROOT)).toBeInTheDocument() + }) + + it.each(CARD_STYLES)('renders cardStyle=%s without dropping the root', (cardStyle) => { + const { getByTestId } = render(CardPricing, { props: { cardStyle } }) + expect(getByTestId(ROOT)).toBeInTheDocument() + }) + + it('has no accessibility violations for the default (bottom, contained) render', async () => { + const { container } = render(CardPricing, { + props: { description: 'A plan description.', pricingDetails: 'Billed annually.' } + }) + await expectNoA11yViolations(container) + }) + + it('has no accessibility violations for the middle slot position with a filled slot', async () => { + const { container } = render(CardPricing, { + props: { slotPosition: 'middle', showTag: true, description: 'Middle layout.' }, + slots: { default: '
  • Feature one
  • Feature two
' } + }) + await expectNoA11yViolations(container) + }) + + it('renders the composed SlotPositionBottomContained story fixture', () => { + const { getByTestId } = render(SlotPositionBottomContained) + + const root = getByTestId(ROOT) + expect(root).toBeInTheDocument() + // story args: planTitle "Pro", value "20", prefix "$", suffix "per month", actionLabel "Label" + expect(getByTestId(`${ROOT}__title`)).toHaveTextContent('Pro') + expect(getByTestId(`${ROOT}__currency__value`)).toHaveTextContent('20') + expect(getByTestId(`${ROOT}__action`)).toHaveTextContent('Label') + }) +}) diff --git a/packages/webkit/src/components/content/currency/currency.test.ts b/packages/webkit/src/components/content/currency/currency.test.ts new file mode 100644 index 000000000..a3efcf4f7 --- /dev/null +++ b/packages/webkit/src/components/content/currency/currency.test.ts @@ -0,0 +1,108 @@ +import { composeStories } from '@storybook/vue3' +import { render } from '@testing-library/vue' +import { describe, expect, it } from 'vitest' + +import { expectNoA11yViolations } from '../../../test/axe' +import * as stories from '../../../../../../apps/storybook/src/stories/components/content/currency/Currency.stories' +import Currency from './currency.vue' + +const { Default, Sizes } = composeStories(stories) + +describe('Currency', () => { + it('renders the default testid on the root', () => { + const { getByTestId } = render(Currency) + // No consumer data-testid -> the component's fallback. + expect(getByTestId('content-currency')).toBeTruthy() + }) + + it('renders the value inside the value span', () => { + const { getByTestId } = render(Currency, { props: { value: '20' } }) + expect(getByTestId('content-currency__value').textContent?.trim()).toBe('20') + }) + + it('renders the default prefix ($) and shows it in the prefix span', () => { + // prefix defaults to '$' and prefix span is v-if="prefix". + const { getByTestId } = render(Currency, { props: { value: '20' } }) + expect(getByTestId('content-currency__prefix').textContent?.trim()).toBe('$') + }) + + it('renders a custom prefix', () => { + const { getByTestId } = render(Currency, { props: { value: '99', prefix: 'R$' } }) + expect(getByTestId('content-currency__prefix').textContent?.trim()).toBe('R$') + }) + + it('omits the prefix span when prefix is empty (v-if="prefix")', () => { + const { queryByTestId, getByTestId } = render(Currency, { + props: { value: '20', prefix: '' } + }) + expect(queryByTestId('content-currency__prefix')).toBeNull() + // value still renders regardless of prefix. + expect(getByTestId('content-currency__value').textContent?.trim()).toBe('20') + }) + + it('renders the suffix span only when suffix is provided (v-if="suffix")', () => { + const { queryByTestId } = render(Currency, { props: { value: '20' } }) + // suffix defaults to '' -> suffix span absent. + expect(queryByTestId('content-currency__suffix')).toBeNull() + + const withSuffix = render(Currency, { props: { value: '20', suffix: 'per month' } }) + expect( + withSuffix.getByTestId('content-currency__suffix').textContent?.trim() + ).toBe('per month') + }) + + it('honors a consumer-provided data-testid on the root and derives the part ids from it', () => { + // testId = attrs['data-testid'] ?? 'content-currency'; parts use `${testId}__*`. + const { getByTestId } = render(Currency, { + props: { value: '20', suffix: 'per month' }, + attrs: { 'data-testid': 'plan-price' } + }) + expect(getByTestId('plan-price')).toBeTruthy() + expect(getByTestId('plan-price__prefix').textContent?.trim()).toBe('$') + expect(getByTestId('plan-price__value').textContent?.trim()).toBe('20') + expect(getByTestId('plan-price__suffix').textContent?.trim()).toBe('per month') + }) + + it.each(['small', 'large'] as const)( + 'renders the same value/prefix/suffix content for size "%s"', + (size) => { + const { getByTestId } = render(Currency, { + props: { value: '20', suffix: 'per month', size } + }) + // Size is a purely visual token; the content contract holds for every size. + expect(getByTestId('content-currency__prefix').textContent?.trim()).toBe('$') + expect(getByTestId('content-currency__value').textContent?.trim()).toBe('20') + expect(getByTestId('content-currency__suffix').textContent?.trim()).toBe('per month') + } + ) + + it('has no a11y violations in the default render', async () => { + const { container } = render(Currency, { + props: { value: '20', prefix: '$', suffix: 'per month', size: 'small' } + }) + await expectNoA11yViolations(container) + }) + + it('has no a11y violations at size large', async () => { + const { container } = render(Currency, { + props: { value: '20', prefix: '$', suffix: 'per month', size: 'large' } + }) + await expectNoA11yViolations(container) + }) + + it('composes the Default story', () => { + const { getByTestId } = render(Default()) + // Default args: value '20', prefix '$', suffix 'per month'. + expect(getByTestId('content-currency__value').textContent?.trim()).toBe('20') + expect(getByTestId('content-currency__prefix').textContent?.trim()).toBe('$') + expect(getByTestId('content-currency__suffix').textContent?.trim()).toBe('per month') + }) + + it('composes the Sizes story rendering both size variants', () => { + const { getAllByTestId } = render(Sizes()) + // Two instances (small + large) -> two value spans, same content. + const values = getAllByTestId('content-currency__value') + expect(values).toHaveLength(2) + values.forEach((v) => expect(v.textContent?.trim()).toBe('20')) + }) +}) diff --git a/packages/webkit/src/components/empty-results-block/empty-results-block.test.ts b/packages/webkit/src/components/empty-results-block/empty-results-block.test.ts new file mode 100644 index 000000000..1c8c7e72c --- /dev/null +++ b/packages/webkit/src/components/empty-results-block/empty-results-block.test.ts @@ -0,0 +1,149 @@ +import { composeStories } from '@storybook/vue3' +import { fireEvent, render } from '@testing-library/vue' +import { describe, expect, it, vi } from 'vitest' + +import * as stories from '../../../../../apps/storybook/src/stories/components/EmptyResultsBlock.stories' + +import { expectNoA11yViolations } from '../../test/axe' +import EmptyResultsBlock from './empty-results-block.vue' + +const { Default, DisabledButton, NoBorder } = composeStories(stories) + +const REQUIRED = { + title: 'No results found', + description: 'Try adjusting your filters or create a new item.' +} + +describe('EmptyResultsBlock', () => { + describe('rendering (required content)', () => { + it('renders the title and description text', () => { + const { getByText } = render(EmptyResultsBlock, { props: { ...REQUIRED } }) + expect(getByText('No results found')).toBeTruthy() + expect(getByText('Try adjusting your filters or create a new item.')).toBeTruthy() + }) + + it('does not render the create button when createButtonLabel is empty (default)', () => { + const { queryByRole } = render(EmptyResultsBlock, { props: { ...REQUIRED } }) + expect(queryByRole('button')).toBeNull() + }) + + it('does not render the documentation link when documentationService is null (default)', () => { + const { queryByText } = render(EmptyResultsBlock, { props: { ...REQUIRED } }) + expect(queryByText('View Documentation')).toBeNull() + }) + }) + + describe('create button (createButtonLabel)', () => { + it('renders the create button with the label and its derived testid', () => { + const { getByRole, getByTestId } = render(EmptyResultsBlock, { + props: { ...REQUIRED, createButtonLabel: 'Create Item' } + }) + const node = getByRole('button', { name: 'Create Item' }) + expect(node.tagName).toBe('BUTTON') + expect(getByTestId('create_Create Item_button')).toBe(node) + }) + + it('emits click-to-create when the create button is clicked', async () => { + const { getByRole, emitted } = render(EmptyResultsBlock, { + props: { ...REQUIRED, createButtonLabel: 'Create Item' } + }) + await fireEvent.click(getByRole('button', { name: 'Create Item' })) + expect(emitted()['click-to-create']).toHaveLength(1) + }) + }) + + describe('disabledList suppresses the create action', () => { + it('natively disables the create button and does not emit click-to-create', async () => { + const { getByRole, emitted } = render(EmptyResultsBlock, { + props: { ...REQUIRED, createButtonLabel: 'Create Item', disabledList: true } + }) + const node = getByRole('button', { name: 'Create Item' }) as HTMLButtonElement + expect(node.disabled).toBe(true) + await fireEvent.click(node) + expect(emitted()['click-to-create']).toBeUndefined() + }) + }) + + describe('documentationService', () => { + it('renders the documentation link and invokes the service on click', async () => { + const documentationService = vi.fn() + const { getByText } = render(EmptyResultsBlock, { + props: { ...REQUIRED, documentationService } + }) + const link = getByText('View Documentation') + await fireEvent.click(link) + expect(documentationService).toHaveBeenCalledTimes(1) + }) + }) + + describe('slots', () => { + it('renders the illustration slot content', () => { + const { getByTestId } = render(EmptyResultsBlock, { + props: { ...REQUIRED }, + slots: { illustration: 'art' } + }) + expect(getByTestId('illo')).toBeTruthy() + }) + + it('renders extraActionsLeft and extraActionsRight slot content', () => { + const { getByTestId } = render(EmptyResultsBlock, { + props: { ...REQUIRED }, + slots: { + extraActionsLeft: 'L', + extraActionsRight: 'R' + } + }) + expect(getByTestId('left')).toBeTruthy() + expect(getByTestId('right')).toBeTruthy() + }) + + it('default slot overrides the built-in create button', () => { + const { getByTestId, queryByRole } = render(EmptyResultsBlock, { + props: { ...REQUIRED, createButtonLabel: 'Create Item' }, + slots: { default: 'custom' } + }) + expect(getByTestId('custom-action')).toBeTruthy() + // The built-in create button lives inside the default slot fallback, + // so a provided default slot replaces it entirely. + expect(queryByRole('button', { name: 'Create Item' })).toBeNull() + }) + }) + + describe('a11y (axe against styled DOM)', () => { + it('Default (title + description + create button) has no violations', async () => { + const { container } = render(EmptyResultsBlock, { + props: { ...REQUIRED, createButtonLabel: 'Create Item' } + }) + await expectNoA11yViolations(container) + }) + + it('With documentation link has no violations', async () => { + const { container } = render(EmptyResultsBlock, { + props: { ...REQUIRED, documentationService: () => {} } + }) + await expectNoA11yViolations(container) + }) + }) + + describe('composeStories (the story fixtures run in-test)', () => { + it('Default story renders its title and an enabled create button', () => { + const { getByText, getByRole } = render(Default) + expect(getByText('No items yet')).toBeTruthy() + expect((getByRole('button', { name: 'Create Item' }) as HTMLButtonElement).disabled).toBe( + false + ) + }) + + it('DisabledButton story disables the create button', () => { + const { getByRole } = render(DisabledButton) + expect((getByRole('button', { name: 'Create Item' }) as HTMLButtonElement).disabled).toBe( + true + ) + }) + + it('NoBorder story renders its title', () => { + const { getByText } = render(NoBorder) + expect(getByText('No border variant')).toBeTruthy() + }) + }) +}) diff --git a/packages/webkit/src/components/feedback/empty-state/empty-state.test.ts b/packages/webkit/src/components/feedback/empty-state/empty-state.test.ts new file mode 100644 index 000000000..af084360a --- /dev/null +++ b/packages/webkit/src/components/feedback/empty-state/empty-state.test.ts @@ -0,0 +1,159 @@ +import { composeStories } from '@storybook/vue3' +import { render, within } from '@testing-library/vue' +import { describe, expect, it } from 'vitest' + +import { expectNoA11yViolations } from '../../../test/axe' +import * as stories from '../../../../../../apps/storybook/src/stories/components/feedback/empty-state/EmptyState.stories' +import EmptyState from './empty-state.vue' + +const { Default, Bordered, Sizes } = composeStories(stories) + +describe('EmptyState', () => { + it('renders the title and defaults the testid to feedback-empty-state', () => { + const { getByTestId } = within( + render(EmptyState, { props: { title: 'No resource yet' } }).container + ) + + const root = getByTestId('feedback-empty-state') + expect(root).toBeTruthy() + expect(getByTestId('feedback-empty-state__title').textContent).toBe('No resource yet') + }) + + it('exposes role=status on the root for assistive announcement', () => { + const { getByTestId } = within(render(EmptyState, { props: { title: 'Empty' } }).container) + + expect(getByTestId('feedback-empty-state').getAttribute('role')).toBe('status') + }) + + it('honours a consumer-supplied data-testid across the whole subtree', () => { + const { getByTestId } = within( + render(EmptyState, { + props: { title: 'Empty' }, + attrs: { 'data-testid': 'custom-empty' } + }).container + ) + + expect(getByTestId('custom-empty').getAttribute('role')).toBe('status') + expect(getByTestId('custom-empty__title').textContent).toBe('Empty') + expect(getByTestId('custom-empty__icon')).toBeTruthy() + }) + + it('renders the description only when provided', () => { + const withDesc = within( + render(EmptyState, { props: { title: 'Empty', description: 'Add your first item.' } }).container + ) + expect(withDesc.getByTestId('feedback-empty-state__description').textContent).toBe( + 'Add your first item.' + ) + + const withoutDesc = within(render(EmptyState, { props: { title: 'Empty' } }).container) + expect(withoutDesc.queryByTestId('feedback-empty-state__description')).toBeNull() + }) + + it('marks the icon container aria-hidden and renders the default illustration', () => { + const { getByTestId } = within(render(EmptyState, { props: { title: 'Empty' } }).container) + + const icon = getByTestId('feedback-empty-state__icon') + expect(icon.getAttribute('aria-hidden')).toBe('true') + // Default slot renders the bundled illustration (an svg/element inside the tile). + expect(icon.children.length).toBeGreaterThan(0) + }) + + it('renders custom icon slot content in place of the default illustration', () => { + const { getByTestId } = within( + render(EmptyState, { + props: { title: 'Empty' }, + slots: { icon: 'glyph' } + }).container + ) + + const icon = getByTestId('feedback-empty-state__icon') + expect(icon.getAttribute('aria-hidden')).toBe('true') + expect(getByTestId('my-icon').textContent).toBe('glyph') + }) + + it('renders the actions region only when the actions slot is supplied', () => { + const withActions = within( + render(EmptyState, { + props: { title: 'Empty' }, + slots: { actions: '' } + }).container + ) + expect(withActions.getByTestId('feedback-empty-state__actions')).toBeTruthy() + expect(withActions.getByTestId('cta').textContent).toBe('Create') + + const withoutActions = within(render(EmptyState, { props: { title: 'Empty' } }).container) + expect(withoutActions.queryByTestId('feedback-empty-state__actions')).toBeNull() + }) + + it('does not set data-bordered by default and sets it when bordered', () => { + const plain = within(render(EmptyState, { props: { title: 'Empty' } }).container) + expect(plain.getByTestId('feedback-empty-state').hasAttribute('data-bordered')).toBe(false) + + const bordered = within( + render(EmptyState, { props: { title: 'Empty', bordered: true } }).container + ) + expect(bordered.getByTestId('feedback-empty-state').getAttribute('data-bordered')).toBe('true') + }) + + it('defaults data-size to medium and reflects the size prop', () => { + const def = within(render(EmptyState, { props: { title: 'Empty' } }).container) + expect(def.getByTestId('feedback-empty-state').getAttribute('data-size')).toBe('medium') + + const large = within(render(EmptyState, { props: { title: 'Empty', size: 'large' } }).container) + expect(large.getByTestId('feedback-empty-state').getAttribute('data-size')).toBe('large') + }) + + it.each(['small', 'medium', 'large'] as const)( + 'mirrors size=%s onto data-size of the root and the icon tile', + (size) => { + const { getByTestId } = within( + render(EmptyState, { props: { title: 'Empty', size } }).container + ) + expect(getByTestId('feedback-empty-state').getAttribute('data-size')).toBe(size) + expect(getByTestId('feedback-empty-state__icon').getAttribute('data-size')).toBe(size) + } + ) + + it('has no accessibility violations on the default (transparent) render', async () => { + const { container } = render(EmptyState, { + props: { title: 'No resource yet', description: 'Get started by creating your first resource.' } + }) + await expectNoA11yViolations(container) + }) + + it('has no accessibility violations when bordered with an actions region', async () => { + const { container } = render(EmptyState, { + props: { + title: 'No resource yet', + description: 'Get started by creating your first resource.', + bordered: true + }, + slots: { actions: '' } + }) + await expectNoA11yViolations(container) + }) + + describe('composed stories', () => { + it('renders the Default story fixture', () => { + const { getByTestId } = within(render(Default).container) + expect(getByTestId('feedback-empty-state__title').textContent).toBe('No resource yet') + expect(getByTestId('feedback-empty-state__actions')).toBeTruthy() + }) + + it('renders the Bordered story with data-bordered set', () => { + const { getByTestId } = within(render(Bordered).container) + expect(getByTestId('feedback-empty-state').getAttribute('data-bordered')).toBe('true') + }) + + it('renders the Sizes story with all three size variants', () => { + const { getAllByTestId } = within(render(Sizes).container) + const roots = getAllByTestId('feedback-empty-state') + expect(roots.map((el) => el.getAttribute('data-size'))).toEqual([ + 'small', + 'medium', + 'large' + ]) + }) + }) +}) diff --git a/packages/webkit/src/components/feedback/message/message.test.ts b/packages/webkit/src/components/feedback/message/message.test.ts new file mode 100644 index 000000000..6f05fa855 --- /dev/null +++ b/packages/webkit/src/components/feedback/message/message.test.ts @@ -0,0 +1,212 @@ +import { composeStories } from '@storybook/vue3' +import { fireEvent, render, waitFor } from '@testing-library/vue' +import { describe, expect, it } from 'vitest' + +import * as stories from '../../../../../../apps/storybook/src/stories/components/feedback/message/Message.stories' +import { expectNoA11yViolations } from '../../../test/axe' +import Message from './message.vue' + +const { Default } = composeStories(stories) + +// @testing-library/vue mounts through @vue/test-utils, which stubs +// by default. The stub skips the JS transition lifecycle, so the component's +// `@after-leave` (which emits `close`) never fires. Rendering the REAL transition +// lets the browser run the fade-out and fire `@after-leave` as it does in production. +const realTransition = { global: { stubs: { transition: false } } } + +describe('Message', () => { + it('renders the root with the default testid, status role, and title/description', () => { + const { getByTestId } = render(Message, { + props: { severity: 'info', title: 'Info title', description: 'Some details' } + }) + + const root = getByTestId('feedback-message') + expect(root).toBeTruthy() + expect(root.getAttribute('role')).toBe('status') + expect(root.getAttribute('data-severity')).toBe('info') + expect(getByTestId('feedback-message__title').textContent).toContain('Info title') + expect(getByTestId('feedback-message__description').textContent).toContain('Some details') + }) + + it('omits the description node when description is empty', () => { + const { queryByTestId } = render(Message, { + props: { title: 'Only a title' } + }) + + expect(queryByTestId('feedback-message__title')).toBeTruthy() + expect(queryByTestId('feedback-message__description')).toBeNull() + }) + + it('normalizes severity="error" to danger and uses the alert role', () => { + const { getByTestId } = render(Message, { + props: { severity: 'error', title: 'Boom' } + }) + + const root = getByTestId('feedback-message') + expect(root.getAttribute('data-severity')).toBe('danger') + expect(root.getAttribute('role')).toBe('alert') + }) + + it('uses the alert role for warning severity', () => { + const { getByTestId } = render(Message, { + props: { severity: 'warning', title: 'Careful' } + }) + + const root = getByTestId('feedback-message') + expect(root.getAttribute('data-severity')).toBe('warning') + expect(root.getAttribute('role')).toBe('alert') + }) + + it('applies an icon override on the leading glyph', () => { + const { getByTestId } = render(Message, { + props: { title: 'With icon', icon: 'pi pi-star' } + }) + + const glyph = getByTestId('feedback-message').querySelector('i') + expect(glyph).toBeTruthy() + expect(glyph?.className).toContain('pi-star') + expect(glyph?.getAttribute('aria-hidden')).toBe('true') + }) + + describe('action button', () => { + it('renders the action button when actionLabel is set and emits "action" with the click event on click', async () => { + const { getByTestId, emitted } = render(Message, { + props: { title: 'Has action', actionLabel: 'Retry' } + }) + + const action = getByTestId('feedback-message__action') + expect(action.textContent).toContain('Retry') + + await fireEvent.click(action) + + expect(emitted('action')).toHaveLength(1) + // Payload is the native MouseEvent forwarded from the button click. + expect(emitted('action')[0][0]).toBeInstanceOf(Event) + }) + + it('does not render the action button when actionLabel is empty', () => { + const { queryByTestId } = render(Message, { + props: { title: 'No action' } + }) + + expect(queryByTestId('feedback-message__action')).toBeNull() + }) + }) + + describe('close / dismiss', () => { + it('renders a close control when closable and emits "close" after the dismiss click', async () => { + const { getByTestId, queryByTestId, emitted } = render(Message, { + props: { title: 'Dismiss me', closable: true }, + ...realTransition + }) + + const close = getByTestId('feedback-message__close') + expect(close).toBeTruthy() + + await fireEvent.click(close) + + // "close" is emitted after the leave transition completes (@after-leave). + await waitFor(() => { + expect(emitted('close')).toHaveLength(1) + }) + // Once dismissed and unmounted, the root is gone. + await waitFor(() => { + expect(queryByTestId('feedback-message')).toBeNull() + }) + }) + + it('does not render a close control when not closable', () => { + const { queryByTestId } = render(Message, { + props: { title: 'Persistent' } + }) + + expect(queryByTestId('feedback-message__close')).toBeNull() + }) + + it('dismisses on Escape when closable, emitting "close"', async () => { + const { getByTestId, emitted } = render(Message, { + props: { title: 'Escapable', closable: true }, + ...realTransition + }) + + const root = getByTestId('feedback-message') + await fireEvent.keyDown(root, { key: 'Escape' }) + + await waitFor(() => { + expect(emitted('close')).toHaveLength(1) + }) + }) + + it('does not dismiss on Escape when not closable', async () => { + const { getByTestId, emitted } = render(Message, { + props: { title: 'No escape' } + }) + + const root = getByTestId('feedback-message') + await fireEvent.keyDown(root, { key: 'Escape' }) + + // Give any pending transition a chance; the component must stay mounted and silent. + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(emitted('close')).toBeUndefined() + expect(getByTestId('feedback-message')).toBeTruthy() + }) + }) + + it('renders default-slot content in place of the title/description block', () => { + const { getByText, queryByTestId } = render(Message, { + props: { title: 'Ignored title', description: 'Ignored description' }, + slots: { default: 'Custom slot body' } + }) + + expect(getByText('Custom slot body')).toBeTruthy() + expect(queryByTestId('feedback-message__title')).toBeNull() + expect(queryByTestId('feedback-message__description')).toBeNull() + }) + + describe('accessibility', () => { + it('has no a11y violations for the default status message', async () => { + const { container } = render(Message, { + props: { + severity: 'info', + title: 'Accessible info', + description: 'A brief description of the message.', + actionLabel: 'Label', + closable: true + } + }) + + await expectNoA11yViolations(container) + }) + + it('has no a11y violations for the danger alert variant', async () => { + const { container } = render(Message, { + props: { + severity: 'danger', + title: 'Accessible alert', + description: 'Something went wrong.' + } + }) + + await expectNoA11yViolations(container) + }) + }) + + it.each(['info', 'success', 'warning', 'danger', 'error'] as const)( + 'renders severity=%s with a data-severity attribute', + (severity) => { + const { getByTestId } = render(Message, { + props: { severity, title: `Severity ${severity}` } + }) + + const expected = severity === 'error' ? 'danger' : severity + expect(getByTestId('feedback-message').getAttribute('data-severity')).toBe(expected) + } + ) + + it('composes the Default story fixture', () => { + const { getByTestId } = render(Default()) + + expect(getByTestId('feedback-message')).toBeTruthy() + expect(getByTestId('feedback-message__title').textContent).toContain('Info message') + }) +}) diff --git a/packages/webkit/src/components/feedback/progress-bar/progress-bar.test.ts b/packages/webkit/src/components/feedback/progress-bar/progress-bar.test.ts new file mode 100644 index 000000000..1c0732680 --- /dev/null +++ b/packages/webkit/src/components/feedback/progress-bar/progress-bar.test.ts @@ -0,0 +1,142 @@ +import { composeStories } from '@storybook/vue3' +import { render } from '@testing-library/vue' +import { describe, expect, it } from 'vitest' + +import { expectNoA11yViolations } from '../../../test/axe' +import * as stories from '../../../../../../apps/storybook/src/stories/components/feedback/progress-bar/ProgressBar.stories' +import ProgressBar from './progress-bar.vue' + +const { Default, Indeterminate } = composeStories(stories) + +describe('ProgressBar', () => { + it('renders the progressbar role with the default testid', () => { + const { getByTestId, getByRole } = render(ProgressBar) + + const bar = getByTestId('feedback-progress-bar') + expect(bar).toBe(getByRole('progressbar')) + }) + + it('honors a consumer-provided data-testid', () => { + const { getByTestId } = render(ProgressBar, { + attrs: { 'data-testid': 'custom-progress' } + }) + + expect(getByTestId('custom-progress').getAttribute('role')).toBe('progressbar') + }) + + it('exposes the determinate aria value range from value and max', () => { + const { getByRole } = render(ProgressBar, { + props: { value: 40, max: 200 } + }) + + const bar = getByRole('progressbar') + expect(bar.getAttribute('aria-valuemin')).toBe('0') + expect(bar.getAttribute('aria-valuemax')).toBe('200') + expect(bar.getAttribute('aria-valuenow')).toBe('40') + // Determinate is not busy. + expect(bar.getAttribute('aria-busy')).toBeNull() + expect(bar.getAttribute('data-indeterminate')).toBeNull() + }) + + it('defaults to value 0 / max 100', () => { + const { getByRole } = render(ProgressBar) + + const bar = getByRole('progressbar') + expect(bar.getAttribute('aria-valuenow')).toBe('0') + expect(bar.getAttribute('aria-valuemax')).toBe('100') + expect(bar.getAttribute('aria-valuemin')).toBe('0') + }) + + it('drops the aria value range and marks busy when indeterminate', () => { + const { getByRole } = render(ProgressBar, { + props: { indeterminate: true, value: 50 } + }) + + const bar = getByRole('progressbar') + expect(bar.getAttribute('aria-valuemin')).toBeNull() + expect(bar.getAttribute('aria-valuemax')).toBeNull() + expect(bar.getAttribute('aria-valuenow')).toBeNull() + expect(bar.getAttribute('aria-busy')).toBe('true') + expect(bar.getAttribute('data-indeterminate')).toBe('true') + }) + + it('clamps the fill width to 100% when value exceeds max', () => { + const { getByRole } = render(ProgressBar, { + props: { value: 150, max: 100 } + }) + + const fill = getByRole('progressbar').firstElementChild as HTMLElement + expect(fill.style.width).toBe('100%') + }) + + it('clamps the fill width to 0% when value is negative', () => { + const { getByRole } = render(ProgressBar, { + props: { value: -20, max: 100 } + }) + + const fill = getByRole('progressbar').firstElementChild as HTMLElement + expect(fill.style.width).toBe('0%') + }) + + it('sets the fill width from the value / max percentage', () => { + const { getByRole } = render(ProgressBar, { + props: { value: 30, max: 120 } + }) + + const fill = getByRole('progressbar').firstElementChild as HTMLElement + expect(fill.style.width).toBe('25%') + }) + + it('does not set an inline fill width when indeterminate', () => { + const { getByRole } = render(ProgressBar, { + props: { indeterminate: true } + }) + + const fill = getByRole('progressbar').firstElementChild as HTMLElement + expect(fill.style.width).toBe('') + expect(fill.getAttribute('data-indeterminate')).toBe('true') + }) + + it.each(['rounded', 'flat'] as const)('reflects shape=%s on data-shape', (shape) => { + const { getByRole } = render(ProgressBar, { props: { shape } }) + expect(getByRole('progressbar').getAttribute('data-shape')).toBe(shape) + }) + + it.each(['small', 'medium', 'large'] as const)('reflects size=%s on data-size', (size) => { + const { getByRole } = render(ProgressBar, { props: { size } }) + expect(getByRole('progressbar').getAttribute('data-size')).toBe(size) + }) + + // role="progressbar" requires an accessible name; the consumer supplies it + // (contextual to the task), and it must flow through v-bind="$attrs" onto the root. + it('has no a11y violations for a labeled determinate bar', async () => { + const { container, getByRole } = render(ProgressBar, { + props: { value: 60, max: 100 }, + attrs: { 'aria-label': 'Upload progress' } + }) + expect(getByRole('progressbar').getAttribute('aria-label')).toBe('Upload progress') + await expectNoA11yViolations(container) + }) + + it('has no a11y violations for a labeled indeterminate bar', async () => { + const { container, getByRole } = render(ProgressBar, { + props: { indeterminate: true }, + attrs: { 'aria-label': 'Loading' } + }) + expect(getByRole('progressbar').getAttribute('aria-label')).toBe('Loading') + await expectNoA11yViolations(container) + }) + + it('renders the composed Default story', () => { + const { getByRole } = render(Default) + const bar = getByRole('progressbar') + expect(bar.getAttribute('aria-valuenow')).toBe('60') + }) + + it('renders the composed Indeterminate story as busy', () => { + const { getByRole } = render(Indeterminate) + const bar = getByRole('progressbar') + expect(bar.getAttribute('aria-busy')).toBe('true') + expect(bar.getAttribute('data-indeterminate')).toBe('true') + }) +}) diff --git a/packages/webkit/src/components/feedback/skeleton/skeleton.test.ts b/packages/webkit/src/components/feedback/skeleton/skeleton.test.ts new file mode 100644 index 000000000..ec1c1dc4b --- /dev/null +++ b/packages/webkit/src/components/feedback/skeleton/skeleton.test.ts @@ -0,0 +1,114 @@ +import { composeStories } from '@storybook/vue3' +import { render } from '@testing-library/vue' +import { describe, expect, it } from 'vitest' + +import * as stories from '../../../../../../apps/storybook/src/stories/components/feedback/skeleton/Skeleton.stories' +import { expectNoA11yViolations } from '../../../test/axe' +import Skeleton from './skeleton.vue' + +const { Default, Types, Static } = composeStories(stories) + +describe('Skeleton', () => { + it('renders with the default data-testid and presentational a11y semantics', () => { + const { getByTestId } = render(Skeleton) + const el = getByTestId('feedback-skeleton') + + expect(el.getAttribute('role')).toBe('presentation') + expect(el.getAttribute('aria-hidden')).toBe('true') + }) + + it('honors a consumer-provided data-testid over the fallback', () => { + const { getByTestId } = render(Skeleton, { + attrs: { 'data-testid': 'my-placeholder' } + }) + + expect(getByTestId('my-placeholder')).toBeTruthy() + }) + + it('defaults to the shape geometry', () => { + const { getByTestId } = render(Skeleton) + + expect(getByTestId('feedback-skeleton').getAttribute('data-kind')).toBe('shape') + }) + + it('reflects kind="circle" on data-kind', () => { + const { getByTestId } = render(Skeleton, { props: { kind: 'circle' } }) + + expect(getByTestId('feedback-skeleton').getAttribute('data-kind')).toBe('circle') + }) + + it('exposes data-animated when animated is true (the default)', () => { + const { getByTestId } = render(Skeleton) + + expect(getByTestId('feedback-skeleton').hasAttribute('data-animated')).toBe(true) + expect(getByTestId('feedback-skeleton').getAttribute('data-animated')).toBe('true') + }) + + it('omits data-animated when animated is false', () => { + const { getByTestId } = render(Skeleton, { props: { animated: false } }) + + expect(getByTestId('feedback-skeleton').hasAttribute('data-animated')).toBe(false) + }) + + it('applies width and height as inline styles', () => { + const { getByTestId } = render(Skeleton, { + props: { width: '240px', height: '100px' } + }) + const el = getByTestId('feedback-skeleton') as HTMLElement + + expect(el.style.width).toBe('240px') + expect(el.style.height).toBe('100px') + }) + + it('defaults width to 100% and height to 1rem', () => { + const { getByTestId } = render(Skeleton) + const el = getByTestId('feedback-skeleton') as HTMLElement + + expect(el.style.width).toBe('100%') + expect(el.style.height).toBe('1rem') + }) + + it.each(['shape', 'circle'] as const)('renders the %s geometry', (kind) => { + const { getByTestId } = render(Skeleton, { props: { kind } }) + + expect(getByTestId('feedback-skeleton').getAttribute('data-kind')).toBe(kind) + }) + + it('has no a11y violations for the default shape', async () => { + const { container } = render(Skeleton, { + props: { width: '240px', height: '100px' } + }) + + await expectNoA11yViolations(container) + }) + + it('has no a11y violations for the circle geometry', async () => { + const { container } = render(Skeleton, { + props: { kind: 'circle', width: '100px', height: '100px' } + }) + + await expectNoA11yViolations(container) + }) + + it('renders the composed Default story', () => { + const { getByTestId } = render(Default()) + const el = getByTestId('feedback-skeleton') + + expect(el.getAttribute('data-kind')).toBe('shape') + expect(el.hasAttribute('data-animated')).toBe(true) + }) + + it('renders the composed Types story with both geometries', () => { + const { getAllByTestId } = render(Types()) + const kinds = getAllByTestId('feedback-skeleton').map((el) => el.getAttribute('data-kind')) + + expect(kinds).toContain('shape') + expect(kinds).toContain('circle') + }) + + it('renders the composed Static story without the animated attribute', () => { + const { getByTestId } = render(Static()) + + expect(getByTestId('feedback-skeleton').hasAttribute('data-animated')).toBe(false) + }) +}) diff --git a/packages/webkit/src/components/feedback/status-indicator/status-indicator.test.ts b/packages/webkit/src/components/feedback/status-indicator/status-indicator.test.ts new file mode 100644 index 000000000..929318a26 --- /dev/null +++ b/packages/webkit/src/components/feedback/status-indicator/status-indicator.test.ts @@ -0,0 +1,135 @@ +import { composeStories } from '@storybook/vue3' +import { render } from '@testing-library/vue' +import { describe, expect, it } from 'vitest' + +import * as stories from '../../../../../../apps/storybook/src/stories/components/feedback/status-indicator/StatusIndicator.stories' +import { expectNoA11yViolations } from '../../../test/axe' +import StatusIndicator from './status-indicator.vue' + +const { Default, Status, Loading } = composeStories(stories) + +const STATUSES = ['positive', 'info', 'neutral', 'warning', 'alt', 'danger'] as const + +describe('StatusIndicator', () => { + it('renders with the default testid, default status and default label', () => { + const { getByTestId } = render(StatusIndicator) + + const root = getByTestId('feedback-status-indicator') + expect(root).toBeTruthy() + // Defaults from withDefaults: status positive, label "Status", loading false. + expect(root.getAttribute('data-status')).toBe('positive') + expect(root.getAttribute('role')).toBe('status') + // loading defaults to false → aria-busy is undefined (omitted) and data-loading null. + expect(root.hasAttribute('aria-busy')).toBe(false) + expect(root.getAttribute('data-loading')).toBeNull() + expect(getByTestId('feedback-status-indicator__label').textContent?.trim()).toBe('Status') + }) + + it('honors a consumer-supplied data-testid on the root and its parts', () => { + const { getByTestId } = render(StatusIndicator, { + attrs: { 'data-testid': 'my-status' } + }) + + expect(getByTestId('my-status')).toBeTruthy() + // Child testids are derived from the root testid. + expect(getByTestId('my-status__dot')).toBeTruthy() + expect(getByTestId('my-status__label')).toBeTruthy() + }) + + it('reflects the status prop on data-status for every option', () => { + for (const status of STATUSES) { + const { getByTestId, unmount } = render(StatusIndicator, { props: { status } }) + expect(getByTestId('feedback-status-indicator').getAttribute('data-status')).toBe(status) + // The dot mirrors the status too. + expect(getByTestId('feedback-status-indicator__dot').getAttribute('data-status')).toBe( + status + ) + unmount() + } + }) + + it('falls back to positive when status is undefined', () => { + const { getByTestId } = render(StatusIndicator, { props: { status: undefined } }) + + expect(getByTestId('feedback-status-indicator').getAttribute('data-status')).toBe('positive') + }) + + it('renders the label prop inside the label span', () => { + const { getByTestId } = render(StatusIndicator, { props: { label: 'Online' } }) + + expect(getByTestId('feedback-status-indicator__label').textContent?.trim()).toBe('Online') + }) + + it('renders the dot (hidden from a11y) and no spinner when not loading', () => { + const { getByTestId, queryByTestId } = render(StatusIndicator, { props: { loading: false } }) + + const dot = getByTestId('feedback-status-indicator__dot') + expect(dot).toBeTruthy() + expect(dot.getAttribute('aria-hidden')).toBe('true') + expect(queryByTestId('feedback-status-indicator__spinner')).toBeNull() + }) + + it('swaps the dot for a spinner, sets aria-busy and appends an ellipsis to the label when loading', () => { + const { getByTestId, queryByTestId } = render(StatusIndicator, { + props: { loading: true, label: 'Deploying' } + }) + + const root = getByTestId('feedback-status-indicator') + // aria-busy is true and data-loading is present when loading. + expect(root.getAttribute('aria-busy')).toBe('true') + expect(root.hasAttribute('data-loading')).toBe(true) + // Spinner replaces the dot. + expect(getByTestId('feedback-status-indicator__spinner')).toBeTruthy() + expect(queryByTestId('feedback-status-indicator__dot')).toBeNull() + // Label appends the ellipsis in loading state. + expect(getByTestId('feedback-status-indicator__label').textContent?.trim()).toBe( + 'Deploying...' + ) + }) + + it('merges a consumer-supplied class onto the root', () => { + const { getByTestId } = render(StatusIndicator, { + attrs: { class: 'consumer-class' } + }) + + expect(getByTestId('feedback-status-indicator').classList.contains('consumer-class')).toBe( + true + ) + }) + + it.each([...STATUSES])('has no a11y violations for status "%s"', async (status) => { + const { container } = render(StatusIndicator, { props: { status, label: 'Status' } }) + + await expectNoA11yViolations(container) + }) + + it('has no a11y violations in the loading state', async () => { + const { container } = render(StatusIndicator, { + props: { loading: true, label: 'Loading' } + }) + + await expectNoA11yViolations(container) + }) + + it('renders the Default story fixture cleanly', async () => { + const { getByTestId, container } = render(Default()) + + expect(getByTestId('feedback-status-indicator')).toBeTruthy() + await expectNoA11yViolations(container) + }) + + it('renders the Status story fixture with all variants', () => { + const { getAllByTestId } = render(Status()) + + // The composite story renders one indicator per status option. + expect(getAllByTestId('feedback-status-indicator').length).toBe(STATUSES.length) + }) + + it('renders the Loading story fixture with a spinner and busy state', async () => { + const { getByTestId, container } = render(Loading()) + + expect(getByTestId('feedback-status-indicator').getAttribute('aria-busy')).toBe('true') + expect(getByTestId('feedback-status-indicator__spinner')).toBeTruthy() + await expectNoA11yViolations(container) + }) +}) diff --git a/packages/webkit/src/components/inputs/chip/chip.test.ts b/packages/webkit/src/components/inputs/chip/chip.test.ts new file mode 100644 index 000000000..45a843460 --- /dev/null +++ b/packages/webkit/src/components/inputs/chip/chip.test.ts @@ -0,0 +1,175 @@ +import { composeStories } from '@storybook/vue3' +import { fireEvent, render, waitFor } from '@testing-library/vue' +import { describe, expect, it } from 'vitest' + +import * as stories from '../../../../../../apps/storybook/src/stories/components/inputs/chip/Chip.stories' +import { expectNoA11yViolations } from '../../../test/axe' +import Chip from './chip.vue' + +const { Default, Sizes, Removable } = composeStories(stories) + +// @testing-library/vue mounts through @vue/test-utils, which stubs +// by default. The stub skips the JS transition lifecycle, so the component's +// `@after-leave` (which emits `remove`) never fires. Rendering the REAL +// transition lets the fade-out run and fire `@after-leave` as it does in prod. +const realTransition = { global: { stubs: { transition: false } } } + +describe('Chip', () => { + it('renders a root carrying the default data-testid and default size', () => { + const { getByTestId } = render(Chip, { props: { label: 'Filter' } }) + + const root = getByTestId('input-chip') + expect(root.tagName).toBe('SPAN') + expect(root).toHaveAttribute('data-size', 'medium') + }) + + it('shows the label prop text via the label sub-node', () => { + const { getByTestId } = render(Chip, { props: { label: 'Production' } }) + + expect(getByTestId('input-chip__label')).toHaveTextContent('Production') + }) + + it('renders default-slot content in place of the label fallback', () => { + const { getByTestId, queryByTestId } = render(Chip, { + props: { label: 'Fallback' }, + slots: { default: 'Slotted' } + }) + + // Slot wins: the label sub-node is not rendered when the slot is present. + expect(queryByTestId('input-chip__label')).toBeNull() + expect(getByTestId('input-chip')).toHaveTextContent('Slotted') + expect(getByTestId('input-chip')).not.toHaveTextContent('Fallback') + }) + + it('sets data-size to the provided size token', () => { + const { getByTestId } = render(Chip, { props: { label: 'Small', size: 'small' } }) + + expect(getByTestId('input-chip')).toHaveAttribute('data-size', 'small') + }) + + it('honours a consumer-supplied data-testid on the root and derived sub-nodes', () => { + const { getByTestId } = render(Chip, { + props: { label: 'Env', removable: true }, + attrs: { 'data-testid': 'my-chip' } + }) + + expect(getByTestId('my-chip').tagName).toBe('SPAN') + expect(getByTestId('my-chip__label')).toHaveTextContent('Env') + expect(getByTestId('my-chip__remove')).toBeInTheDocument() + }) + + it('forwards arbitrary attributes onto the root via $attrs', () => { + const { getByTestId } = render(Chip, { + props: { label: 'Attr' }, + attrs: { id: 'chip-id', title: 'a chip' } + }) + + const root = getByTestId('input-chip') + expect(root).toHaveAttribute('id', 'chip-id') + expect(root).toHaveAttribute('title', 'a chip') + }) + + describe('removable', () => { + it('renders no remove button and no data-removable by default', () => { + const { getByTestId, queryByTestId } = render(Chip, { props: { label: 'Static' } }) + + expect(getByTestId('input-chip')).not.toHaveAttribute('data-removable') + expect(queryByTestId('input-chip__remove')).toBeNull() + }) + + it('renders a remove button with the Remove aria-label and an aria-hidden icon when removable', () => { + const { getByTestId } = render(Chip, { props: { label: 'Removable', removable: true } }) + + expect(getByTestId('input-chip')).toHaveAttribute('data-removable') + + const removeBtn = getByTestId('input-chip__remove') + expect(removeBtn.tagName).toBe('BUTTON') + expect(removeBtn).toHaveAttribute('type', 'button') + expect(removeBtn).toHaveAttribute('aria-label', 'Remove') + + const icon = getByTestId('input-chip__remove-icon') + expect(icon).toHaveAttribute('aria-hidden', 'true') + }) + + it('emits "remove" with the native MouseEvent after the dismiss fade completes', async () => { + const { getByTestId, queryByTestId, emitted } = render(Chip, { + props: { label: 'Dismiss me', removable: true }, + ...realTransition + }) + + await fireEvent.click(getByTestId('input-chip__remove')) + + // "remove" is emitted from @after-leave, once the leave transition ends. + await waitFor(() => { + expect(emitted('remove')).toHaveLength(1) + }) + // Payload is the native MouseEvent forwarded from the button click. + expect(emitted('remove')[0][0]).toBeInstanceOf(Event) + + // After the transition, the chip is unmounted. + await waitFor(() => { + expect(queryByTestId('input-chip')).toBeNull() + }) + }) + + it('does not emit "remove" a second time on a repeated click after dismissal starts', async () => { + const { getByTestId, emitted } = render(Chip, { + props: { label: 'Once', removable: true }, + ...realTransition + }) + + const removeBtn = getByTestId('input-chip__remove') + await fireEvent.click(removeBtn) + // Second click while already leaving: onRemove guards on visible.value. + await fireEvent.click(removeBtn) + + await waitFor(() => { + expect(emitted('remove')).toHaveLength(1) + }) + }) + }) + + it.each([ + ['small', { label: 'S', size: 'small' as const }], + ['medium', { label: 'M', size: 'medium' as const }] + ])('renders the %s size variant', (size, props) => { + const { getByTestId } = render(Chip, { props }) + expect(getByTestId('input-chip')).toHaveAttribute('data-size', size) + }) + + describe('accessibility', () => { + it('has no a11y violations for a plain labelled chip', async () => { + const { container } = render(Chip, { props: { label: 'Accessible' } }) + await expectNoA11yViolations(container) + }) + + it('has no a11y violations for a removable chip (labelled remove button)', async () => { + const { container } = render(Chip, { props: { label: 'Accessible', removable: true } }) + await expectNoA11yViolations(container) + }) + }) + + it('composes the Default story fixture', () => { + const { getByTestId } = render(Default()) + + expect(getByTestId('input-chip')).toHaveTextContent('Label') + expect(getByTestId('input-chip')).toHaveAttribute('data-size', 'medium') + expect(getByTestId('input-chip')).not.toHaveAttribute('data-removable') + }) + + it('composes the Sizes story fixture with both size tokens', () => { + const { getAllByTestId } = render(Sizes()) + + const chips = getAllByTestId('input-chip') + const sizes = chips.map((chip) => chip.getAttribute('data-size')) + expect(sizes).toContain('small') + expect(sizes).toContain('medium') + }) + + it('composes the Removable story fixture with the remove button', () => { + const { getByTestId } = render(Removable()) + + expect(getByTestId('input-chip')).toHaveAttribute('data-removable') + expect(getByTestId('input-chip__remove')).toHaveAttribute('aria-label', 'Remove') + }) +}) diff --git a/packages/webkit/src/components/inputs/helper-text/helper-text.test.ts b/packages/webkit/src/components/inputs/helper-text/helper-text.test.ts new file mode 100644 index 000000000..3b23cbe73 --- /dev/null +++ b/packages/webkit/src/components/inputs/helper-text/helper-text.test.ts @@ -0,0 +1,100 @@ +import { composeStories } from '@storybook/vue3' +import { render } from '@testing-library/vue' +import { describe, expect, it } from 'vitest' + +import * as stories from '../../../../../../apps/storybook/src/stories/components/inputs/HelperText.stories' +import { expectNoA11yViolations } from '../../../test/axe' +import HelperText from './helper-text.vue' + +const { Default, Types } = composeStories(stories) + +const KINDS = ['helper', 'invalid', 'required', 'disabled'] as const + +describe('HelperText', () => { + it('renders a

root with the default data-testid and default kind', () => { + const { getByTestId } = render(HelperText, { props: { value: 'Guidance' } }) + + const root = getByTestId('input-helper-text') + expect(root.tagName).toBe('P') + // Default kind is 'helper' per withDefaults. + expect(root).toHaveAttribute('data-kind', 'helper') + }) + + it('renders the value prop inside the __text span when no slot is provided', () => { + const { getByTestId } = render(HelperText, { props: { value: 'Enter a valid email address.' } }) + + expect(getByTestId('input-helper-text__text')).toHaveTextContent('Enter a valid email address.') + }) + + it('prefers the default slot over the value prop', () => { + const { getByTestId } = render(HelperText, { + props: { value: 'fallback value' }, + slots: { default: 'Slot content wins' } + }) + + const text = getByTestId('input-helper-text__text') + expect(text).toHaveTextContent('Slot content wins') + expect(text).not.toHaveTextContent('fallback value') + }) + + it('mirrors the kind prop onto data-kind for every variant', () => { + for (const kind of KINDS) { + const { getByTestId, unmount } = render(HelperText, { props: { kind, value: 'x' } }) + expect(getByTestId('input-helper-text')).toHaveAttribute('data-kind', kind) + unmount() + } + }) + + it('renders the pi pi-lock icon only for the disabled kind', () => { + const disabled = render(HelperText, { props: { kind: 'disabled', value: 'Locked' } }) + const icon = disabled.getByTestId('input-helper-text__icon') + expect(icon).toHaveClass('pi', 'pi-lock') + // The icon is decorative and must be hidden from assistive tech. + expect(icon).toHaveAttribute('aria-hidden', 'true') + disabled.unmount() + + for (const kind of ['helper', 'invalid', 'required'] as const) { + const { queryByTestId, unmount } = render(HelperText, { props: { kind, value: 'x' } }) + expect(queryByTestId('input-helper-text__icon')).toBeNull() + unmount() + } + }) + + it('honors a consumer-supplied data-testid and derives child testids from it', () => { + const { getByTestId } = render(HelperText, { + props: { kind: 'disabled', value: 'Locked' }, + attrs: { 'data-testid': 'field-hint' } + }) + + expect(getByTestId('field-hint').tagName).toBe('P') + expect(getByTestId('field-hint__text')).toHaveTextContent('Locked') + expect(getByTestId('field-hint__icon')).toHaveClass('pi-lock') + }) + + it('has no accessibility violations in the default helper variant', async () => { + const { container } = render(HelperText, { props: { value: 'Helper guidance text' } }) + await expectNoA11yViolations(container) + }) + + it('has no accessibility violations in the disabled variant (icon present)', async () => { + const { container } = render(HelperText, { props: { kind: 'disabled', value: 'Locked field' } }) + await expectNoA11yViolations(container) + }) + + it('renders the Default story fixture', () => { + const { getByTestId } = render(Default) + expect(getByTestId('input-helper-text__text')).toHaveTextContent('Helper Text') + }) + + it('renders the Types story fixture with one root per kind', () => { + const { getAllByTestId } = render(Types) + const roots = getAllByTestId('input-helper-text') + expect(roots).toHaveLength(4) + expect(roots.map((el) => el.getAttribute('data-kind'))).toEqual([ + 'helper', + 'invalid', + 'required', + 'disabled' + ]) + }) +}) diff --git a/packages/webkit/src/components/inputs/label/label.test.ts b/packages/webkit/src/components/inputs/label/label.test.ts new file mode 100644 index 000000000..fd948e0bd --- /dev/null +++ b/packages/webkit/src/components/inputs/label/label.test.ts @@ -0,0 +1,102 @@ +import { composeStories } from '@storybook/vue3' +import { render } from '@testing-library/vue' +import { describe, expect, it } from 'vitest' + +import * as stories from '../../../../../../apps/storybook/src/stories/components/inputs/Label.stories' +import { expectNoA11yViolations } from '../../../test/axe' +import Label from './label.vue' + +const { Default, Required } = composeStories(stories) + +describe('Label', () => { + it('renders a

root carrying the default data-testid', () => { + const { getByTestId } = render(FieldPassword, { props: { label: 'Password' } }) + + const root = getByTestId('input-field-password') + expect(root.tagName).toBe('DIV') + }) + + it('renders the label sub-node with the label text and wires its "for" to the input id', () => { + const { getByTestId } = render(FieldPassword, { + props: { label: 'Password', inputId: 'pw-1' } + }) + + const label = getByTestId('input-field-password__label') + expect(label).toHaveTextContent('Password') + // The label targets the input via `for`, and the input carries that id. + expect(label).toHaveAttribute('for', 'pw-1') + + const input = getByTestId('input-field-password__input') + expect(input).toHaveAttribute('id', 'pw-1') + }) + + it('omits the label row when no label prop is given', () => { + const { queryByTestId } = render(FieldPassword, { props: {} }) + + expect(queryByTestId('input-field-password__label')).toBeNull() + }) + + it('renders the underlying input as a password field with the placeholder', () => { + const { getByTestId } = render(FieldPassword, { + props: { label: 'Password', placeholder: 'Enter your password' } + }) + + const input = getByTestId('input-field-password__input') + expect(input.tagName).toBe('INPUT') + expect(input).toHaveAttribute('type', 'password') + expect(input).toHaveAttribute('placeholder', 'Enter your password') + }) + + it('forwards maxLength to the native input as maxlength', () => { + const { getByTestId } = render(FieldPassword, { + props: { label: 'Password', maxLength: 12 } + }) + + expect(getByTestId('input-field-password__input')).toHaveAttribute('maxlength', '12') + }) + + it('renders the helper sub-node with the helperText and points aria-describedby at it', () => { + const { getByTestId } = render(FieldPassword, { + props: { label: 'Password', helperText: 'At least 8 characters.' } + }) + + const helper = getByTestId('input-field-password__helper') + expect(helper).toHaveTextContent('At least 8 characters.') + + // The input is described by the helper element (same id on both sides). + const input = getByTestId('input-field-password__input') + const describedBy = input.getAttribute('aria-describedby') + expect(describedBy).toBeTruthy() + expect(helper).toHaveAttribute('id', describedBy as string) + }) + + it('omits the helper row when there is no helperText and the field is enabled', () => { + const { queryByTestId, getByTestId } = render(FieldPassword, { + props: { label: 'Password' } + }) + + expect(queryByTestId('input-field-password__helper')).toBeNull() + // With no helper rendered, the input is not described by anything. + expect(getByTestId('input-field-password__input')).not.toHaveAttribute('aria-describedby') + }) + + describe('update:modelValue', () => { + it('re-emits update:modelValue with the typed value when the user types', async () => { + const { getByTestId, emitted } = render(FieldPassword, { props: { label: 'Password' } }) + + const input = getByTestId('input-field-password__input') + await fireEvent.update(input, 'hunter2') + + expect(emitted('update:modelValue')).toBeTruthy() + expect(emitted('update:modelValue')).toHaveLength(1) + expect(emitted('update:modelValue')[0]).toEqual(['hunter2']) + }) + + it('reflects the modelValue prop as the input value', () => { + const { getByTestId } = render(FieldPassword, { + props: { label: 'Password', modelValue: 'seeded' } + }) + + expect(getByTestId('input-field-password__input')).toHaveValue('seeded') + }) + + it('does not emit update:modelValue while disabled (input is disabled)', () => { + const { getByTestId, emitted } = render(FieldPassword, { + props: { label: 'Password', disabled: true } + }) + + const input = getByTestId('input-field-password__input') as HTMLInputElement + expect(input).toBeDisabled() + expect(emitted('update:modelValue')).toBeUndefined() + }) + }) + + describe('disabled', () => { + it('disables the input, marks the root data-disabled, and shows the locked helper fallback', () => { + const { getByTestId } = render(FieldPassword, { + props: { label: 'Password', disabled: true } + }) + + expect(getByTestId('input-field-password')).toHaveAttribute('data-disabled') + expect(getByTestId('input-field-password__input')).toBeDisabled() + // No explicit helperText → disabled fallback copy is rendered. + expect(getByTestId('input-field-password__helper')).toHaveTextContent('This field is locked.') + }) + }) + + describe('required', () => { + it('sets native required and aria-required on the input and data-required on the root', () => { + const { getByTestId } = render(FieldPassword, { + props: { label: 'Password', required: true } + }) + + expect(getByTestId('input-field-password')).toHaveAttribute('data-required') + + const input = getByTestId('input-field-password__input') + expect(input).toBeRequired() + expect(input).toHaveAttribute('aria-required', 'true') + }) + }) + + describe('invalid', () => { + it('sets aria-invalid on the input and data-invalid on the root', () => { + const { getByTestId } = render(FieldPassword, { + props: { label: 'Password', invalid: true } + }) + + expect(getByTestId('input-field-password')).toHaveAttribute('data-invalid') + expect(getByTestId('input-field-password__input')).toHaveAttribute('aria-invalid', 'true') + }) + }) + + describe('toggleable', () => { + it('renders the visibility toggle by default and reveals/hides the value on click', async () => { + const { getByTestId, getByRole } = render(FieldPassword, { + props: { label: 'Password', modelValue: 'secret' } + }) + + const input = getByTestId('input-field-password__input') + // Masked by default: the value is rendered as a password field. + expect(input).toHaveAttribute('type', 'password') + + const toggle = getByRole('button', { name: 'Show password' }) + await fireEvent.click(toggle) + + // After revealing, the input becomes a text field and the control's accessible name flips. + expect(input).toHaveAttribute('type', 'text') + const hideToggle = getByRole('button', { name: 'Hide password' }) + + await fireEvent.click(hideToggle) + // Toggling again re-masks the value and restores the original accessible name. + expect(input).toHaveAttribute('type', 'password') + expect(getByRole('button', { name: 'Show password' })).toBeInTheDocument() + }) + + it('renders no visibility toggle when toggleable is false', () => { + const { queryByRole, getByTestId } = render(FieldPassword, { + props: { label: 'Password', toggleable: false, modelValue: 'secret' } + }) + + expect(queryByRole('button', { name: 'Show password' })).toBeNull() + // The field stays a plain password input. + expect(getByTestId('input-field-password__input')).toHaveAttribute('type', 'password') + }) + }) + + it('honours a consumer-supplied data-testid on the root and derived sub-nodes', () => { + const { getByTestId } = render(FieldPassword, { + props: { label: 'Password', helperText: 'Hint' }, + attrs: { 'data-testid': 'my-field' } + }) + + expect(getByTestId('my-field').tagName).toBe('DIV') + expect(getByTestId('my-field__label')).toHaveTextContent('Password') + expect(getByTestId('my-field__input').tagName).toBe('INPUT') + expect(getByTestId('my-field__helper')).toHaveTextContent('Hint') + }) + + it('forwards arbitrary attributes onto the root via $attrs', () => { + const { getByTestId } = render(FieldPassword, { + props: { label: 'Password' }, + attrs: { id: 'field-id', title: 'a field' } + }) + + const root = getByTestId('input-field-password') + expect(root).toHaveAttribute('id', 'field-id') + expect(root).toHaveAttribute('title', 'a field') + }) + + describe('accessibility', () => { + // Passing inputId is the documented wiring: Label's `for` targets the input's `id`, + // giving axe a real label association to validate. + it('has no a11y violations for a labelled field with helper text', async () => { + const { container } = render(FieldPassword, { + props: { label: 'Password', helperText: 'At least 8 characters.', inputId: 'pw-a11y' } + }) + await expectNoA11yViolations(container) + }) + + it('has no a11y violations in the invalid state', async () => { + const { container } = render(FieldPassword, { + props: { + label: 'Password', + invalid: true, + helperText: 'Password too short.', + inputId: 'pw-a11y-invalid' + } + }) + await expectNoA11yViolations(container) + }) + + it('has no a11y violations in the disabled state', async () => { + const { container } = render(FieldPassword, { + props: { label: 'Password', disabled: true, inputId: 'pw-a11y-disabled' } + }) + await expectNoA11yViolations(container) + }) + }) + + it('composes the Default story fixture', () => { + const { getByTestId } = render(Default()) + + expect(getByTestId('input-field-password__label')).toHaveTextContent('Password') + expect(getByTestId('input-field-password__input')).toHaveAttribute('type', 'password') + expect(getByTestId('input-field-password__helper')).toHaveTextContent('At least 8 characters.') + }) + + it('composes the Invalid story fixture', () => { + const { getByTestId } = render(Invalid()) + + expect(getByTestId('input-field-password')).toHaveAttribute('data-invalid') + expect(getByTestId('input-field-password__input')).toHaveAttribute('aria-invalid', 'true') + }) + + it('composes the Disabled story fixture', () => { + const { getByTestId } = render(Disabled()) + + expect(getByTestId('input-field-password')).toHaveAttribute('data-disabled') + expect(getByTestId('input-field-password__input')).toBeDisabled() + }) + + it('composes the Toggle story fixture (one field with toggle, one without)', () => { + const { getAllByTestId, getAllByRole } = render(Toggle()) + + // Two FieldPassword instances render two inputs. + expect(getAllByTestId('input-field-password__input')).toHaveLength(2) + // Only the toggleable field contributes a visibility toggle button. + expect(getAllByRole('button', { name: 'Show password' })).toHaveLength(1) + }) +}) diff --git a/packages/webkit/src/components/inputs/field-radio-block/field-radio-block.test.ts b/packages/webkit/src/components/inputs/field-radio-block/field-radio-block.test.ts new file mode 100644 index 000000000..aec9db072 --- /dev/null +++ b/packages/webkit/src/components/inputs/field-radio-block/field-radio-block.test.ts @@ -0,0 +1,256 @@ +import { composeStories } from '@storybook/vue3' +import { fireEvent, render } from '@testing-library/vue' +import { describe, expect, it } from 'vitest' + +import * as stories from '../../../../../../apps/storybook/src/stories/components/inputs/FieldRadioBlock.stories' +import { expectNoA11yViolations } from '../../../test/axe' +import FieldRadioBlock from './field-radio-block.vue' + +const { Default, Disabled } = composeStories(stories) + +describe('FieldRadioBlock', () => { + it('renders the label root, the card, the composed control and the default testid', () => { + const { getByTestId } = render(FieldRadioBlock, { + props: { value: 'a', label: 'Plan A' } + }) + + const root = getByTestId('input-field-radio-block') + expect(root.tagName).toBe('LABEL') + + expect(getByTestId('input-field-radio-block__card')).toBeTruthy() + expect(getByTestId('input-field-radio-block__control')).toBeTruthy() + expect(getByTestId('input-field-radio-block__texts')).toBeTruthy() + }) + + it('honors a consumer-supplied data-testid and derives the child testids from it', () => { + const { getByTestId } = render(FieldRadioBlock, { + props: { value: 'a', label: 'Plan A' }, + attrs: { 'data-testid': 'my-block' } + }) + + expect(getByTestId('my-block').tagName).toBe('LABEL') + expect(getByTestId('my-block__card')).toBeTruthy() + expect(getByTestId('my-block__control')).toBeTruthy() + expect(getByTestId('my-block__texts')).toBeTruthy() + }) + + it('links the label to the native input via the provided inputId (for/id)', () => { + const { getByTestId } = render(FieldRadioBlock, { + props: { value: 'a', label: 'Plan A', inputId: 'radio-a' } + }) + + const root = getByTestId('input-field-radio-block') + expect(root.getAttribute('for')).toBe('radio-a') + + const input = getByTestId('input-field-radio-block__control__input') as HTMLInputElement + expect(input.getAttribute('id')).toBe('radio-a') + }) + + it('generates a matching for/id pair when no inputId is supplied', () => { + const { getByTestId } = render(FieldRadioBlock, { + props: { value: 'a', label: 'Plan A' } + }) + + const root = getByTestId('input-field-radio-block') + const input = getByTestId('input-field-radio-block__control__input') as HTMLInputElement + + const forAttr = root.getAttribute('for') + expect(forAttr).toBeTruthy() + expect(input.getAttribute('id')).toBe(forAttr) + }) + + it('renders the label and description texts from props', () => { + const { getByTestId } = render(FieldRadioBlock, { + props: { value: 'a', label: 'Plan A', description: 'Best value' } + }) + + expect(getByTestId('input-field-radio-block__label').textContent?.trim()).toBe('Plan A') + expect(getByTestId('input-field-radio-block__description').textContent?.trim()).toBe('Best value') + }) + + it('omits the label and description nodes when those props are empty', () => { + const { queryByTestId } = render(FieldRadioBlock, { + props: { value: 'a' } + }) + + expect(queryByTestId('input-field-radio-block__label')).toBeNull() + expect(queryByTestId('input-field-radio-block__description')).toBeNull() + }) + + it('forwards value and name onto the composed native radio input', () => { + const { getByTestId } = render(FieldRadioBlock, { + props: { value: 'plan-a', name: 'group-1', label: 'Plan A' } + }) + + const input = getByTestId('input-field-radio-block__control__input') as HTMLInputElement + expect(input.getAttribute('type')).toBe('radio') + expect(input.getAttribute('value')).toBe('plan-a') + expect(input.getAttribute('name')).toBe('group-1') + }) + + it('marks the card as selected and checks the input when modelValue matches value', () => { + const { getByTestId } = render(FieldRadioBlock, { + props: { modelValue: 'a', value: 'a', label: 'Plan A' } + }) + + expect(getByTestId('input-field-radio-block__card').hasAttribute('data-selected')).toBe(true) + + const input = getByTestId('input-field-radio-block__control__input') as HTMLInputElement + expect(input.checked).toBe(true) + expect(input.getAttribute('aria-checked')).toBe('true') + }) + + it('leaves the card unselected and the input unchecked when modelValue does not match', () => { + const { getByTestId } = render(FieldRadioBlock, { + props: { modelValue: 'b', value: 'a', label: 'Plan A' } + }) + + expect(getByTestId('input-field-radio-block__card').getAttribute('data-selected')).toBeNull() + + const input = getByTestId('input-field-radio-block__control__input') as HTMLInputElement + expect(input.checked).toBe(false) + expect(input.getAttribute('aria-checked')).toBe('false') + }) + + it('emits update:modelValue with its own value when the native radio changes', async () => { + const { getByTestId, emitted } = render(FieldRadioBlock, { + props: { modelValue: 'b', value: 'a', label: 'Plan A' } + }) + + await fireEvent.change(getByTestId('input-field-radio-block__control__input')) + + const events = emitted()['update:modelValue'] + expect(events).toBeTruthy() + expect(events).toHaveLength(1) + expect(events[0]).toEqual(['a']) + }) + + it('drives v-model: the emitted value is this instance own option value', async () => { + const { getByTestId, emitted } = render(FieldRadioBlock, { + props: { modelValue: undefined, value: 'option-x', label: 'Option X' } + }) + + await fireEvent.change(getByTestId('input-field-radio-block__control__input')) + + const events = emitted()['update:modelValue'] + expect(events[0]).toEqual(['option-x']) + }) + + it('does NOT emit update:modelValue when disabled', async () => { + const { getByTestId, emitted } = render(FieldRadioBlock, { + props: { modelValue: 'b', value: 'a', disabled: true, label: 'Plan A' } + }) + + const input = getByTestId('input-field-radio-block__control__input') as HTMLInputElement + expect(input.disabled).toBe(true) + + await fireEvent.change(input) + + expect(emitted()['update:modelValue']).toBeUndefined() + }) + + it('reflects data-disabled on the label, card and texts only when disabled', () => { + const enabled = render(FieldRadioBlock, { props: { value: 'a', label: 'Plan A' } }) + expect(enabled.getByTestId('input-field-radio-block').getAttribute('data-disabled')).toBeNull() + expect(enabled.getByTestId('input-field-radio-block__card').getAttribute('data-disabled')).toBeNull() + expect(enabled.getByTestId('input-field-radio-block__texts').getAttribute('data-disabled')).toBeNull() + enabled.unmount() + + const disabled = render(FieldRadioBlock, { + props: { value: 'a', label: 'Plan A', disabled: true } + }) + expect(disabled.getByTestId('input-field-radio-block').hasAttribute('data-disabled')).toBe(true) + expect(disabled.getByTestId('input-field-radio-block__card').hasAttribute('data-disabled')).toBe(true) + expect(disabled.getByTestId('input-field-radio-block__texts').hasAttribute('data-disabled')).toBe(true) + }) + + it('renders the helper badge only when both disabled and helperText are present', () => { + const noHelperEnabled = render(FieldRadioBlock, { + props: { value: 'a', label: 'Plan A', helperText: 'Locked' } + }) + expect(noHelperEnabled.queryByTestId('input-field-radio-block__helper')).toBeNull() + noHelperEnabled.unmount() + + const disabledNoText = render(FieldRadioBlock, { + props: { value: 'a', label: 'Plan A', disabled: true } + }) + expect(disabledNoText.queryByTestId('input-field-radio-block__helper')).toBeNull() + disabledNoText.unmount() + + const withHelper = render(FieldRadioBlock, { + props: { value: 'a', label: 'Plan A', disabled: true, helperText: 'Locked' } + }) + expect(withHelper.getByTestId('input-field-radio-block__helper')).toBeTruthy() + expect(withHelper.getByTestId('input-field-radio-block__helper-text').textContent?.trim()).toBe( + 'Locked' + ) + expect( + withHelper.getByTestId('input-field-radio-block__helper-icon').getAttribute('aria-hidden') + ).toBe('true') + }) + + it('merges a consumer-supplied class onto the label root', () => { + const { getByTestId } = render(FieldRadioBlock, { + props: { value: 'a', label: 'Plan A' }, + attrs: { class: 'consumer-class' } + }) + + expect(getByTestId('input-field-radio-block').classList.contains('consumer-class')).toBe(true) + }) + + it.each([ + ['unselected', { modelValue: 'b', value: 'a' }], + ['selected', { modelValue: 'a', value: 'a' }] + ])('renders the %s state with the expected selected flag', (_label, props) => { + const { getByTestId } = render(FieldRadioBlock, { + props: { ...props, name: 'g', inputId: 'radio-a', label: 'Plan A' } + }) + + const selected = getByTestId('input-field-radio-block__card').hasAttribute('data-selected') + expect(selected).toBe(props.modelValue === props.value) + }) + + it('has no a11y violations when unselected', async () => { + const { container } = render(FieldRadioBlock, { + props: { modelValue: 'b', value: 'a', name: 'g', inputId: 'radio-a', label: 'Plan A' } + }) + + await expectNoA11yViolations(container) + }) + + it('has no a11y violations when selected', async () => { + const { container } = render(FieldRadioBlock, { + props: { modelValue: 'a', value: 'a', name: 'g', inputId: 'radio-a', label: 'Plan A' } + }) + + await expectNoA11yViolations(container) + }) + + it('has no a11y violations when disabled with a helper badge', async () => { + const { container } = render(FieldRadioBlock, { + props: { + modelValue: 'a', + value: 'a', + name: 'g', + inputId: 'radio-a', + label: 'Plan A', + disabled: true, + helperText: 'Locked' + } + }) + + await expectNoA11yViolations(container) + }) + + it('renders the Default story fixture cleanly', async () => { + const { container } = render(Default()) + + await expectNoA11yViolations(container) + }) + + it('renders the Disabled story fixture cleanly', async () => { + const { container } = render(Disabled()) + + await expectNoA11yViolations(container) + }) +}) diff --git a/packages/webkit/src/components/inputs/field-radio/field-radio.test.ts b/packages/webkit/src/components/inputs/field-radio/field-radio.test.ts new file mode 100644 index 000000000..0d0fbd04f --- /dev/null +++ b/packages/webkit/src/components/inputs/field-radio/field-radio.test.ts @@ -0,0 +1,212 @@ +import { composeStories } from '@storybook/vue3' +import { fireEvent, render } from '@testing-library/vue' +import { describe, expect, it } from 'vitest' + +import * as stories from '../../../../../../apps/storybook/src/stories/components/inputs/FieldRadio.stories' +import { expectNoA11yViolations } from '../../../test/axe' +import FieldRadio from './field-radio.vue' + +const { Default, Disabled } = composeStories(stories) + +describe('FieldRadio', () => { + it('renders the root label with the default testid and wires the label for/input id', () => { + const { getByTestId } = render(FieldRadio, { + props: { value: 'a', inputId: 'radio-a', label: 'Option A' } + }) + + const root = getByTestId('input-field-radio') + expect(root.tagName).toBe('LABEL') + expect(root.getAttribute('for')).toBe('radio-a') + + const input = getByTestId('input-field-radio__control__input') as HTMLInputElement + expect(input.tagName).toBe('INPUT') + expect(input.getAttribute('type')).toBe('radio') + expect(input.getAttribute('id')).toBe('radio-a') + }) + + it('honors a consumer-supplied data-testid across the root and derived parts', () => { + const { getByTestId } = render(FieldRadio, { + props: { value: 'a', label: 'Option A', description: 'desc' }, + attrs: { 'data-testid': 'my-field' } + }) + + expect(getByTestId('my-field').tagName).toBe('LABEL') + expect(getByTestId('my-field__control').tagName).toBe('SPAN') + expect(getByTestId('my-field__texts')).toBeTruthy() + expect(getByTestId('my-field__label').textContent).toContain('Option A') + expect(getByTestId('my-field__description').textContent).toContain('desc') + }) + + it('renders label and description text when provided', () => { + const { getByTestId } = render(FieldRadio, { + props: { value: 'a', label: 'Primary', description: 'Secondary' } + }) + + expect(getByTestId('input-field-radio__label').textContent).toContain('Primary') + expect(getByTestId('input-field-radio__description').textContent).toContain('Secondary') + }) + + it('omits label and description nodes when those props are empty', () => { + const { queryByTestId } = render(FieldRadio, { + props: { value: 'a' } + }) + + expect(queryByTestId('input-field-radio__label')).toBeNull() + expect(queryByTestId('input-field-radio__description')).toBeNull() + }) + + it('forwards value and name to the underlying native radio input', () => { + const { getByTestId } = render(FieldRadio, { + props: { value: 'opt-a', name: 'group-1', inputId: 'radio-a' } + }) + + const input = getByTestId('input-field-radio__control__input') as HTMLInputElement + expect(input.getAttribute('value')).toBe('opt-a') + expect(input.getAttribute('name')).toBe('group-1') + }) + + it('reflects checked state through the control when modelValue matches value', () => { + const checked = render(FieldRadio, { + props: { modelValue: 'a', value: 'a', label: 'A' } + }) + const checkedInput = checked.getByTestId('input-field-radio__control__input') as HTMLInputElement + expect(checkedInput.checked).toBe(true) + expect(checkedInput.getAttribute('aria-checked')).toBe('true') + checked.unmount() + + const unchecked = render(FieldRadio, { + props: { modelValue: 'b', value: 'a', label: 'A' } + }) + const uncheckedInput = unchecked.getByTestId( + 'input-field-radio__control__input' + ) as HTMLInputElement + expect(uncheckedInput.checked).toBe(false) + expect(uncheckedInput.getAttribute('aria-checked')).toBe('false') + }) + + it('emits update:modelValue with its own value when the input changes', async () => { + const { getByTestId, emitted } = render(FieldRadio, { + props: { modelValue: 'b', value: 'a' } + }) + + await fireEvent.change(getByTestId('input-field-radio__control__input')) + + const events = emitted()['update:modelValue'] + expect(events).toBeTruthy() + expect(events).toHaveLength(1) + expect(events[0]).toEqual(['a']) + }) + + it('drives v-model: emits the option value from an undefined starting selection', async () => { + const { getByTestId, emitted } = render(FieldRadio, { + props: { modelValue: undefined, value: 'option-x' } + }) + + await fireEvent.change(getByTestId('input-field-radio__control__input')) + + const events = emitted()['update:modelValue'] + expect(events[0]).toEqual(['option-x']) + }) + + it('does NOT emit update:modelValue when disabled', async () => { + const { getByTestId, emitted } = render(FieldRadio, { + props: { modelValue: 'b', value: 'a', disabled: true } + }) + + const input = getByTestId('input-field-radio__control__input') as HTMLInputElement + expect(input.disabled).toBe(true) + + await fireEvent.change(input) + + expect(emitted()['update:modelValue']).toBeUndefined() + }) + + it('sets data-disabled on the root and texts only when disabled is true', () => { + const enabled = render(FieldRadio, { props: { value: 'a', label: 'A' } }) + expect(enabled.getByTestId('input-field-radio').getAttribute('data-disabled')).toBeNull() + expect(enabled.getByTestId('input-field-radio__texts').getAttribute('data-disabled')).toBeNull() + enabled.unmount() + + const disabled = render(FieldRadio, { props: { value: 'a', label: 'A', disabled: true } }) + expect(disabled.getByTestId('input-field-radio').hasAttribute('data-disabled')).toBe(true) + expect(disabled.getByTestId('input-field-radio__texts').hasAttribute('data-disabled')).toBe( + true + ) + }) + + it('shows the disabled helper badge only when disabled and helperText are both set', () => { + const withoutDisabled = render(FieldRadio, { + props: { value: 'a', label: 'A', helperText: 'Locked' } + }) + expect(withoutDisabled.queryByTestId('input-field-radio__helper')).toBeNull() + withoutDisabled.unmount() + + const disabledNoHelper = render(FieldRadio, { + props: { value: 'a', label: 'A', disabled: true } + }) + expect(disabledNoHelper.queryByTestId('input-field-radio__helper')).toBeNull() + disabledNoHelper.unmount() + + const withBoth = render(FieldRadio, { + props: { value: 'a', label: 'A', disabled: true, helperText: 'Locked' } + }) + expect(withBoth.getByTestId('input-field-radio__helper')).toBeTruthy() + expect(withBoth.getByTestId('input-field-radio__helper-text').textContent).toContain('Locked') + expect(withBoth.getByTestId('input-field-radio__helper-icon').getAttribute('aria-hidden')).toBe( + 'true' + ) + }) + + it('merges a consumer-supplied class onto the root label', () => { + const { getByTestId } = render(FieldRadio, { + props: { value: 'a' }, + attrs: { class: 'consumer-class' } + }) + + expect(getByTestId('input-field-radio').classList.contains('consumer-class')).toBe(true) + }) + + it('has no a11y violations for an unchecked radio with a label', async () => { + const { container } = render(FieldRadio, { + props: { modelValue: 'b', value: 'a', name: 'g', inputId: 'r-a', label: 'Option A' } + }) + + await expectNoA11yViolations(container) + }) + + it('has no a11y violations for a checked radio with a label', async () => { + const { container } = render(FieldRadio, { + props: { modelValue: 'a', value: 'a', name: 'g', inputId: 'r-a', label: 'Option A' } + }) + + await expectNoA11yViolations(container) + }) + + it('has no a11y violations for a disabled radio with helper text', async () => { + const { container } = render(FieldRadio, { + props: { + modelValue: 'a', + value: 'a', + disabled: true, + name: 'g', + inputId: 'r-a', + label: 'Option A', + helperText: 'Locked' + } + }) + + await expectNoA11yViolations(container) + }) + + it('renders the Default story fixture cleanly', async () => { + const { container } = render(Default()) + + await expectNoA11yViolations(container) + }) + + it('renders the Disabled story fixture cleanly', async () => { + const { container } = render(Disabled()) + + await expectNoA11yViolations(container) + }) +}) diff --git a/packages/webkit/src/components/inputs/field-switch-block/field-switch-block.test.ts b/packages/webkit/src/components/inputs/field-switch-block/field-switch-block.test.ts new file mode 100644 index 000000000..b53016ed2 --- /dev/null +++ b/packages/webkit/src/components/inputs/field-switch-block/field-switch-block.test.ts @@ -0,0 +1,311 @@ +import { composeStories } from '@storybook/vue3' +import { fireEvent, render } from '@testing-library/vue' +import { describe, expect, it } from 'vitest' + +import * as stories from '../../../../../../apps/storybook/src/stories/components/inputs/FieldSwitchBlock.stories' +import { expectNoA11yViolations } from '../../../test/axe' +import FieldSwitchBlock from './field-switch-block.vue' + +const { Default, Disabled } = composeStories(stories) + +// The inner Switch receives data-testid `${testId}__control`; the Switch renders a +//