diff --git a/.claude/skills/add-composites-test-bed/SKILL.md b/.claude/skills/add-composites-test-bed/SKILL.md new file mode 100644 index 000000000..f2d080d21 --- /dev/null +++ b/.claude/skills/add-composites-test-bed/SKILL.md @@ -0,0 +1,149 @@ +--- +name: add-composites-test-bed +description: packages/composites의 대상 컴포넌트에 시각적 회귀용 `Test Bed` 스토리를 추가한다. +disable-model-invocation: true +--- + +# Add Composites Test Bed + +`packages/composites` 컴포넌트의 시각적 회귀 diff를 위한 정적 `Test Bed` 스토리 하나를 대상 `*.stories.tsx`에 추가한다. + +- 회귀 하네스 필터: `story.name`이 `'Test Bed'`로 시작 + `title`이 `'Composites/'`로 시작. (`packages/composites/__tests__/regressions.test.ts:12`) — `Test Bed Light`, `Test Bed Dark` 두 스토리 모두 매칭. +- 컴포넌트당 스토리 2개: **`TestBed_Light`** (라이트 모드) + **`TestBed_Dark`** (다크 모드). 동일한 회귀 조건 테이블을 두 appearance에서 각각 렌더한다. +- 테이블 구조: 회귀 조건별 컬럼(현재는 각 variant prop 하나) + 마지막 `render` 컬럼(조건 적용된 컴포넌트 렌더 결과). +- 테이블 렌더는 `packages/composites/src/utils/regressions.tsx`의 `Regression.Table` 편의 컴포넌트를 재사용한다. `conditions`(각 조건의 `key`·`label`·`values`·`format`), `render`(row + container → 컴포넌트) 2개 prop만 넘기면 카테시안 곱 rows·ColumnGroup·Header·Condition 셀·Render 셀이 자동 생성된다. 세밀한 커스텀이 필요하면 하위 컴파운드(`Regression.Root / ColumnGroup / ConditionColumn / RenderColumn / Header / Body / Row / Heading / Condition / Render`)를 직접 조립한다. Vapor `Table` 위에 얹혀 있고, 스타일은 `$css`로 붙는다. + +## Steps + +### 1. 대상 컴포넌트 확정 + +사용자가 지정하지 않았다면 `AskUserQuestion`으로 묻는다. 대상은 `packages/composites/src/components//` 하위. + +완료: 폴더 경로가 확정되어 있고, 해당 폴더에 `.tsx`와 `.stories.tsx`가 존재한다. + +### 2. Default 스토리 확인 + +`.stories.tsx`에서 `export const Default`를 찾는다. + +- 부재 시 즉시 종료. 사용자에게 "Default 스토리를 먼저 추가하라"라고 안내. +- 존재 시 `Default.args`와 `Default.render`를 완전히 읽고, 각 슬롯 자리에 넘긴 JSX 값과 args를 목록으로 적어 둔다. + +완료: 각 슬롯 이름 → JSX 값, 각 arg 이름 → 값의 매핑이 목록으로 정리돼 있다. + +### 3. 구현체 파악 + +`.tsx`를 읽고 컴포넌트의 모든 prop을 나열한다. `createSlots(...)` 호출 인자로 슬롯 이름을 확정한다. 필수 슬롯은 `SlotProps`의 두 번째 제네릭 인자로 식별한다. + +완료: 프로젝트 규약이 `packages/composites/**` 진입 시 `.claude/rules/composites.md`로 자동 로드됨을 확인했고, 컴포넌트의 모든 prop을 흠 없이 열거할 수 있다. + +### 4. Prop 분류 + +각 prop을 세 카테고리 중 정확히 하나로 배타 분류한다. + +- **variants** — 시각 표현을 바꾸는 prop. 값 도메인이 유한한 리터럴 유니온이거나 boolean. 예: `size: 'md' | 'lg' | 'xl'`, `disabled: boolean`. +- **slots** — `createSlots`로 선언된 슬롯. `children`이 슬롯 역할이면 슬롯. variants 셀에서 재사용할 defaultSlots 상수의 원본이 된다. +- **functional** — 제어 상태(`open`/`defaultOpen`/`onOpenChange`), `ref`, `container`, `keepMounted`, `ariaLabels`, `closeOnClick` 등. Test Bed는 다루지 않는다. + +각 variant prop의 값 리스트를 확정한다. 값 도메인이 무한하거나 임의 문자열이면 variants에서 제외한다. + +완료: 세 카테고리에 분배되지 않은 prop이 0개이고, 각 variant prop에 대응하는 값 배열이 리터럴 상수로 확정돼 있다. + +### 5. Variants 테이블 렌더 + +모든 variant prop 값의 **카테시안 곱**을 `Regression` 컴파운드로 렌더한다. 조건 컬럼은 각 variant prop 하나씩 + 마지막 `render` 컬럼. variants가 0개면 스토리 자체가 무의미 — 사용자에게 확인 요청 후 종료. + +- `conditions`는 `{ key, label, values, format }[]` 배열. `key`는 row 필드명, `label`은 헤더 텍스트, `values`는 그 조건의 후보 값 배열(카테시안 곱 재료), `format`은 셀 표시용 값 변환기(예: `(v) => \`description = ${v ? 'O' : 'X'}\``). Boolean/null 인코딩된 조건은 반드시 `format`으로 O/X·리터럴 등으로 변환한다. +- `Regression.Table`이 `values`들로부터 카테시안 곱 rows를 자동 생성한다. rows를 밖에서 만들 필요 없다. +- 마크업: ` } />` 한 줄로 rows·헤더·ColGroup·조건 컬럼·render 셀이 자동 생성된다. +- 슬롯은 파일 최상단 `defaultSlots` 상수에서 spread. functional prop(예: `defaultOpen`)은 `render` 함수 안에서 지정. +- 컬럼 폭·셀 스타일·wrapper overflow·portal reset은 `Regression.Table` 내부가 담당. 스토리에서 손대지 않는다. +- 컴포넌트별 우회(예: Dialog overlay 숨김)는 스토리 자체 ` + `size = ${v}`, + }, + { + key: 'hasDescription', + label: 'description', + values: [ + '유사도 검사 결과를 다운받으시겠어요? 결과는 현재 표에 적용된 필터 기준으로 다운로드 됩니다.', + null, + ], + format: (v) => `description = ${v ? 'O' : 'X'}`, + }, + { + key: 'hasScroll', + label: 'scroll', + values: [true, false], + format: (v) => `scroll = ${v ? 'O' : 'X'}`, + }, + { + key: 'footer', + label: 'footer', + values: ['none', 'action', 'action + assistive'] as const, + format: (v) => `footer = ${v}`, + }, + ]} + render={(row, container) => ( + 확인} + assistive={ + row.footer !== 'action + assistive' ? null : ( + 보조 + ) + } + size={row.size} + > + + {Array.from({ length: row.hasScroll ? 10 : 2 }, (_, index) => ( + + {index + 1}. 내용물입니다. + + ))} + + + )} + /> + + ); +}; + +export const TestBed_Light: Story = { + render: () => , +}; + +export const TestBed_Dark: Story = { + globals: { appearance: 'dark' }, + render: () => , +}; diff --git a/packages/composites/src/utils/create-slots.ts b/packages/composites/src/utils/create-slots.ts index 245418521..34148abaf 100644 --- a/packages/composites/src/utils/create-slots.ts +++ b/packages/composites/src/utils/create-slots.ts @@ -1,5 +1,3 @@ -'use client'; - import type { ComponentProps, ElementType, ReactElement, ReactNode } from 'react'; import { Fragment, createElement, isValidElement } from 'react'; diff --git a/packages/composites/src/utils/regressions.tsx b/packages/composites/src/utils/regressions.tsx new file mode 100644 index 000000000..da66e259d --- /dev/null +++ b/packages/composites/src/utils/regressions.tsx @@ -0,0 +1,293 @@ +import type { ReactNode } from 'react'; +import { useState } from 'react'; + +import { Box, Table } from '@vapor-ui/core'; + +/* ----------------------------------------------------------------------------------------------- + * Regression.Root + * -----------------------------------------------------------------------------------------------*/ + +const RegressionRoot = ({ children }: { children: ReactNode }) => { + return ( + <> + + + + {children} + + + + ); +}; + +const portalResetStyles = ` +.regression-cell [data-slots$=".Portal"] { + position: static !important; + display: contents !important; +} +.regression-cell [data-slots$=".Positioner"] { + position: static !important; + inset: auto !important; + top: auto !important; + left: auto !important; + right: auto !important; + bottom: auto !important; + transform: none !important; + max-width: 100% !important; + max-height: 100% !important; + box-sizing: border-box !important; +} +`; + +/* ----------------------------------------------------------------------------------------------- + * Regression.ColumnGroup + * -----------------------------------------------------------------------------------------------*/ + +const RegressionColumnGroup = ({ children }: { children: ReactNode }) => { + return {children}; +}; + +/* ----------------------------------------------------------------------------------------------- + * Regression.ConditionColumn + * -----------------------------------------------------------------------------------------------*/ + +const RegressionConditionColumn = () => { + return ; +}; + +/* ----------------------------------------------------------------------------------------------- + * Regression.RenderColumn + * -----------------------------------------------------------------------------------------------*/ + +const RegressionRenderColumn = () => { + return ; +}; + +/* ----------------------------------------------------------------------------------------------- + * Regression.Header + * -----------------------------------------------------------------------------------------------*/ + +const RegressionHeader = ({ children }: { children: ReactNode }) => { + return {children}; +}; + +/* ----------------------------------------------------------------------------------------------- + * Regression.Body + * -----------------------------------------------------------------------------------------------*/ + +const RegressionBody = ({ children }: { children: ReactNode }) => { + return {children}; +}; + +/* ----------------------------------------------------------------------------------------------- + * Regression.Row + * -----------------------------------------------------------------------------------------------*/ + +const RegressionRow = ({ children }: { children: ReactNode }) => { + return {children}; +}; + +/* ----------------------------------------------------------------------------------------------- + * Regression.Heading + * -----------------------------------------------------------------------------------------------*/ + +const RegressionHeading = ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ); +}; + +/* ----------------------------------------------------------------------------------------------- + * Regression.Condition + * -----------------------------------------------------------------------------------------------*/ + +const RegressionCondition = ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ); +}; + +/* ----------------------------------------------------------------------------------------------- + * Regression.Render + * -----------------------------------------------------------------------------------------------*/ + +const RegressionRender = ({ + children: childrenProp, +}: { + children: ((container: HTMLElement | null) => ReactNode) | ReactNode; +}) => { + const [container, setContainer] = useState(null); + const children = typeof childrenProp === 'function' ? childrenProp(container) : childrenProp; + + return ( + + + {children} + + + ); +}; + +/* ----------------------------------------------------------------------------------------------- + * cartesianRows + * -----------------------------------------------------------------------------------------------*/ + +export type CartesianRows> = Array<{ + [K in keyof T]: T[K][number]; +}>; + +export const cartesianRows = >( + conditions: T, +): CartesianRows => { + const keys = Object.keys(conditions) as (keyof T)[]; + if (keys.length === 0) return [{}] as CartesianRows; + + return keys.reduce>>( + (acc, key) => + acc.flatMap((row) => conditions[key].map((value) => ({ ...row, [key]: value }))), + [{}], + ) as CartesianRows; +}; + +/* ----------------------------------------------------------------------------------------------- + * Regression.Table + * -----------------------------------------------------------------------------------------------*/ + +type RegressionCondition = { + key: Key; + label: ReactNode; + values: readonly Value[]; + format: (value: Value) => ReactNode; +}; + +type AnyRegressionCondition = RegressionCondition; + +type RegressionRow = { + [K in C[number]['key']]: Extract['values'][number]; +}; + +type RegressionTableProps = { + conditions: C; + render: (row: RegressionRow, container: HTMLElement | null) => ReactNode; +}; + +const RegressionTable = ({ + conditions, + render, +}: RegressionTableProps) => { + const conditionMap = Object.fromEntries( + conditions.map((condition) => [condition.key, condition.values]), + ); + + const rows = cartesianRows(conditionMap) as RegressionRow[]; + + return ( + + + {conditions.map((_, colIdx) => ( + + ))} + + + + + {conditions.map((condition, colIdx) => ( + {condition.label} + ))} + render + + + + {rows.map((row, rowIdx) => ( + + {conditions.map((condition, colIdx) => ( + + {condition.format(row[condition.key as keyof RegressionRow])} + + ))} + {(container) => render(row, container)} + + ))} + + + + + 총 {rows.length}개 케이스 + + + + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +export const Regression = { + Root: RegressionRoot, + ColumnGroup: RegressionColumnGroup, + ConditionColumn: RegressionConditionColumn, + RenderColumn: RegressionRenderColumn, + Header: RegressionHeader, + Body: RegressionBody, + Row: RegressionRow, + Heading: RegressionHeading, + Condition: RegressionCondition, + Render: RegressionRender, + Table: RegressionTable, +}; diff --git a/packages/composites/vitest.config.ts b/packages/composites/vitest.config.ts index c77c97e88..5f17a8518 100644 --- a/packages/composites/vitest.config.ts +++ b/packages/composites/vitest.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ noExternal: ['@vapor-ui/icons', '@vapor-ui/core'], }, test: { - setupFiles: ['./__tests__/setup-tests.ts'], + setupFiles: ['./vitest.setup.ts'], environment: 'happy-dom', exclude: ['node_modules', 'dist'], include: ['src/**/*.{test,spec}.{js,jsx,ts,tsx}'], diff --git a/packages/composites/__tests__/setup-tests.ts b/packages/composites/vitest.setup.ts similarity index 100% rename from packages/composites/__tests__/setup-tests.ts rename to packages/composites/vitest.setup.ts diff --git a/packages/core/package.json b/packages/core/package.json index a44e5ea64..8a6e1eb11 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -61,7 +61,6 @@ "lint": "eslint ./src", "test": "vitest --run", "test:coverage": "vitest --coverage", - "test:regressions": "playwright test -c ./playwright.config.ts", "test:watch": "vitest", "typecheck": "tsc --noEmit" }, @@ -79,7 +78,6 @@ "rainbow-sprinkles": "^1.0.0" }, "devDependencies": { - "@playwright/test": "^1.61.1", "@repo/eslint-config": "workspace:*", "@repo/rolldown-config": "workspace:*", "@repo/typescript-config": "workspace:*", diff --git a/packages/core/playwright.merge.ts b/packages/core/playwright.merge.ts deleted file mode 100644 index 3f411d7b2..000000000 --- a/packages/core/playwright.merge.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default { - reporter: [ - ['html', { outputFolder: './__tests__/report' }], - ['json', { outputFile: './__tests__/report/index.json' }], - ], -}; diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 33826e7ac..250c45564 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ noExternal: ['@vapor-ui/icons'], }, test: { - setupFiles: ['./__tests__/setup-tests.ts'], + setupFiles: ['./vitest.setup.ts'], environment: 'happy-dom', exclude: ['node_modules', 'dist'], include: ['src/**/*.{test,spec}.{js,jsx,ts,tsx}'], diff --git a/packages/core/__tests__/setup-tests.ts b/packages/core/vitest.setup.ts similarity index 100% rename from packages/core/__tests__/setup-tests.ts rename to packages/core/vitest.setup.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c69f9d2e7..ed82ce212 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,6 +181,9 @@ importers: '@eslint/js': specifier: ^9.39.5 version: 9.39.5 + '@playwright/test': + specifier: ^1.61.1 + version: 1.62.1 '@repo/eslint-config': specifier: workspace:* version: link:../../packages/eslint-config @@ -497,9 +500,6 @@ importers: specifier: ^2.1.1 version: 2.1.1 devDependencies: - '@playwright/test': - specifier: ^1.60.0 - version: 1.60.0 '@repo/eslint-config': specifier: workspace:^ version: link:../eslint-config @@ -606,9 +606,6 @@ importers: specifier: ^1.0.0 version: 1.0.0(@vanilla-extract/css@1.21.2)(@vanilla-extract/dynamic@2.1.5) devDependencies: - '@playwright/test': - specifier: ^1.61.1 - version: 1.62.1 '@repo/eslint-config': specifier: workspace:* version: link:../eslint-config @@ -3291,11 +3288,6 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@playwright/test@1.60.0': - resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} - engines: {node: '>=18'} - hasBin: true - '@playwright/test@1.62.1': resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} engines: {node: '>=20'} @@ -8223,21 +8215,11 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - playwright-core@1.60.0: - resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} - engines: {node: '>=18'} - hasBin: true - playwright-core@1.62.1: resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} engines: {node: '>=20'} hasBin: true - playwright@1.60.0: - resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} - engines: {node: '>=18'} - hasBin: true - playwright@1.62.1: resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} engines: {node: '>=20'} @@ -12702,10 +12684,6 @@ snapshots: '@pkgr/core@0.2.9': {} - '@playwright/test@1.60.0': - dependencies: - playwright: 1.60.0 - '@playwright/test@1.62.1': dependencies: playwright: 1.62.1 @@ -15796,7 +15774,7 @@ snapshots: '@typescript-eslint/parser': 8.59.4(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.17.1(@typescript-eslint/utils@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.5(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.17.1(@typescript-eslint/utils@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.5(jiti@2.7.0)))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)) @@ -15829,7 +15807,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.17.1(@typescript-eslint/utils@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.5(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.17.1(@typescript-eslint/utils@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.5(jiti@2.7.0)))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -15852,7 +15830,7 @@ snapshots: '@typescript-eslint/parser': 8.59.4(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.17.1(@typescript-eslint/utils@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.5(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.17.1(@typescript-eslint/utils@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.5(jiti@2.7.0)))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color @@ -18483,16 +18461,8 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 - playwright-core@1.60.0: {} - playwright-core@1.62.1: {} - playwright@1.60.0: - dependencies: - playwright-core: 1.60.0 - optionalDependencies: - fsevents: 2.3.2 - playwright@1.62.1: dependencies: playwright-core: 1.62.1