Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .claude/rules/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Rule: testing — every component ships a functional browser-mode suite

Every webkit component ships a co-located `*.test.ts` that proves it **works**, exercised in a **real browser**. The spec-compliance hooks prove a `.vue` *declares* what its `.specs/<name>.md` promises; they do not prove a prop is read, an event is emitted, a focus ring is reachable, or an overlay closes on `Escape`. This rule closes that gap with the smallest per-component cost, and fixes the **floor** (not the ceiling) every component must clear.

## The rule

> Every component under `packages/webkit/src/components/<category>/<name>/` ships one `<name>.test.ts` next to the `.vue`. It runs in **Vitest browser mode** (Playwright Chromium — never jsdom), reuses the component's Storybook story as the fixture via `composeStories`, asserts the functional surface below, and runs `axe-core` against the rendered tree. Composition sub-components are tested **through their root**; only the root gets a `.test.ts` (unless the spec promises behavior the root test cannot reach).

## Why browser mode, never jsdom

jsdom returns no-ops for `focus`, `document.activeElement`, layout/`getBoundingClientRect`, and does not surface `<Teleport>`d content — so a test that "passes" there is a false positive for exactly the behaviors that break in production (keyboard, focus trap, overlays, positioning, contrast). We run in real Chromium so those are real.

- **No mocks for layout / positioning / focus / `<Teleport>`.** If a test "needs" one of those mocks, the test is wrong. Real browser makes them real.
- Teleported overlay content escapes the render container — query it from `document.body`, not the `render()` result.

## The stack (already wired — do not reinvent)

- `packages/webkit/vitest.config.ts` — `@vitejs/plugin-vue`, `browser: { provider: 'playwright', instances: [{ browser: 'chromium' }], headless: true }`, `define: { 'process.env.NODE_ENV': ... }` (so `@testing-library/vue`'s `fireEvent` runs in the browser), `resolve.alias['@aziontech/webkit'] = '@aziontech/webkit.dev'` (self-reference so story imports resolve), `retry: process.env.CI ? 2 : 0`.
- `packages/webkit/src/test/setup.ts` — imports `@aziontech/theme/globals.css` (styled DOM ⇒ axe contrast is real) + `cleanup()`.
- `packages/webkit/src/test/axe.ts` — `expectNoA11yViolations(container)`.
- `.github/workflows/test.yml` — sharded (×4) + retry, runs only when webkit/storybook changes.
- Publish-safety: `packages/webkit/package.json#files` negates `*.test.ts` and `src/test/**` (verified with `pnpm --filter webkit pack:dry`). Test files never ship to npm.

## Conventions

- **Location:** `packages/webkit/src/components/<category>/<name>/<name>.test.ts` (sibling of the `.vue` / `index.ts`).
- **Imports:** `describe`/`it`/`expect` from `vitest` (explicit — no `globals`); `render`/`fireEvent`/`screen` from `@testing-library/vue`; `userEvent` from `@storybook/test` for realistic keyboard/pointer; `composeStories` from `@storybook/vue3`; `expectNoA11yViolations` from the relative `test/axe`; the component via relative `./...`.
- **Story import is a RELATIVE path**, never a `@stories` alias — `validate-references.mjs` cannot resolve a vite alias and will block the write. If a component has no story, test it directly with `render(Component, { props })`.
- **Local runs** (when node_modules is symlinked into a worktree) set `PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false CI=` so pnpm's deps check doesn't abort against symlinks.

## What every `<name>.test.ts` must cover

| # | Surface | Assertion |
|---|---|---|
| 1 | Render | mounts without throwing; the `data-testid` fallback is present; consumer `data-testid` override wins |
| 2 | Props / variants | each variant prop (`kind`, `size`, …) maps to its `data-*` / attribute / rendered state |
| 3 | Events | every event in the spec's Events table fires with the right payload on the real user action |
| 4 | Suppression | when `disabled` / `loading` / `readonly`, the action is **not** emitted |
| 5 | v-model | drive the input, assert `update:modelValue` (and `update:open` / `update:*`) with the exact value |
| 6 | ARIA | `role`, `aria-expanded`, `aria-busy`, `aria-disabled`, `aria-selected`… as the template declares |
| 7 | a11y | `expectNoA11yViolations(container)` on the default render + any variant whose semantics differ |
| 8 | Composition | a context-aware sub-component reflects/drives the root's `provide`/`inject` state with no manual wiring |
| 9 | Overlay | open/close (trigger + second click), `Escape` closes and returns focus, panel Teleports to `body`, scroll-lock while open |
| 10 | Recursive | nested instances ≥2 levels deep render and propagate context (active item, open submenu, orientation) |

A tiny `it.each` smoke over enum variants ("mounts without throwing") is a **floor**, never the substance.

## The functional bar — no false positives, no filler

- Assert **only what you read** in the source. Never invent props/events/testids/aria/sub-components.
- **Forbidden:** assertions on Tailwind/class strings, pixel positions, animation timing, or internal component state.
- **If a test only passes when the implementation is written one specific way, delete it.** It traps refactors and adds no signal.
- If a test reveals a real component defect you cannot satisfy without changing the `.vue`, **`it.skip` it with a one-line reason** — never fake a pass or weaken an assertion into meaninglessness. Record the gap in the PR.

## Composition, overlay, recursive — how to reach them

- **Composition:** import the compound root (default export of `index.ts`) and its sub-components; render a realistic composed tree; assert dot-notation resolves (`Root.Sub`), `provide`/`inject` delivers state, events fire, `v-model` round-trips. Data-driven roots (`data` + `columns`) render via props and assert rows/cells render through the sub-components.
- **Overlay** (`data-state="open|closed"` + `<Teleport>` + trigger): use `userEvent`; query the panel from `document.body`; assert `role`/`aria-*`, `Escape` + focus restoration, backdrop/close-button dismissal, scroll-lock.
- **Recursive** (`navigation-menu`, `breadcrumb`): build a ≥2-level tree; assert nested render + context propagation; exercise keyboard where supported.

## Hard prohibitions

- No jsdom; no mocking layout/positioning/focus/`<Teleport>`.
- No `@stories` alias in a test import — use a relative path (the reference hook blocks the alias).
- No class-string / pixel / animation-timing / internal-state assertions.
- No test file outside the co-located `<name>.test.ts` convention; sub-components do not get their own test unless the root cannot reach the behavior.
- Do not edit a `.vue` to make a test pass — fix the test, or `it.skip` + document.

## Enforcement

- `.github/workflows/test.yml` runs `pnpm webkit:test` (sharded browser mode) on every PR/push touching webkit.
- `validate-references.mjs` blocks a test whose imports don't resolve (including a mistaken `@stories` alias).
- `pnpm --filter webkit pack:dry` must list **no** `*.test.ts` — the `files` negation keeps tests out of the published package.

## Why this rule exists

A prop can be declared and never read; an event typed and never emitted; a focus ring rendered and never reached by `Tab`; an overlay that never closes on `Escape`. Lint, types and the Storybook build catch none of these. A real-browser functional suite, reusing the story as its fixture, catches all of them at one file per component — and refuses to pass on the jsdom no-ops that would otherwise give false confidence.
78 changes: 78 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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"
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,6 @@ CLAUDE.md
packages/icons/tmp/
tmp
.tmp

# Vitest browser-mode failure screenshots (regenerated on demand)
**/__screenshots__/
93 changes: 93 additions & 0 deletions PLANEJAMENTO-TESTES.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion apps/storybook/.storybook/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ const config = {
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-themes',
'@whitespace/storybook-addon-html'
'@whitespace/storybook-addon-html',
'@storybook/experimental-addon-test'
],
framework: {
name: '@storybook/vue3-vite',
Expand Down
10 changes: 10 additions & 0 deletions apps/storybook/.storybook/vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { setProjectAnnotations } from '@storybook/vue3'
import { beforeAll } from 'vitest'

// Applies the Storybook preview (theme decorators, global parameters) to every
// story run as a test by @storybook/experimental-addon-test.
import * as previewAnnotations from './preview'

const annotations = setProjectAnnotations([previewAnnotations])

beforeAll(annotations.beforeAll)
7 changes: 6 additions & 1 deletion apps/storybook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,20 @@
"@storybook/addon-essentials": "^8.6.0",
"@storybook/addon-links": "^8.6.0",
"@storybook/addon-themes": "^8.6.0",
"@storybook/experimental-addon-test": "^8.6.18",
"@storybook/test": "^8.6.15",
"@storybook/vue3": "^8.6.0",
"@storybook/vue3-vite": "^8.6.0",
"@vitejs/plugin-vue": "^5.2.4",
"@vitest/browser": "^3.2.6",
"@vitest/coverage-v8": "^3.2.6",
"autoprefixer": "^10.4.27",
"http-server": "^14.1.1",
"playwright": "^1.61.1",
"sass": "^1.86.0",
"storybook": "^8.6.0",
"vite": "^6.4.3"
"vite": "^6.4.3",
"vitest": "^3.2.6"
},
"engines": {
"node": ">=22.18.0",
Expand Down
26 changes: 26 additions & 0 deletions apps/storybook/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { fileURLToPath } from 'node:url'

import { storybookTest } from '@storybook/experimental-addon-test/vitest-plugin'
import { defineConfig } from 'vitest/config'

// Runs every story as a test in a real browser (Playwright Chromium) and powers
// the Storybook "Testing" sidebar. The storybookTest plugin reuses .storybook
// (main.js viteFinal aliases + the vue plugin), so `@aziontech/webkit/*` resolves
// exactly as in the running Storybook.
export default defineConfig({
plugins: [
storybookTest({
configDir: fileURLToPath(new URL('./.storybook', import.meta.url))
})
],
test: {
name: 'storybook',
setupFiles: ['./.storybook/vitest.setup.ts'],
browser: {
enabled: true,
provider: 'playwright',
headless: true,
instances: [{ browser: 'chromium' }]
}
}
})
Loading
Loading