diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000000..1e41f73cb7 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,28 @@ +name: Playwright Tests +on: [push, pull_request] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Install Playwright Browsers + run: bun x playwright install --with-deps + + - name: Run Playwright tests + run: bun tests:playwright test:pw + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 36cfc663c8..6b1cd16361 100644 --- a/.gitignore +++ b/.gitignore @@ -311,4 +311,7 @@ vite.config.mts.timestamp-* *.prompt.md # Solid Start -.vinxi \ No newline at end of file +.vinxi + +# Storybook +*storybook.log diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000000..ebe727d7f3 Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json index 1742fb8710..4b42362a37 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "workspaces": [ "packages/*", "templates/**", + "tests/*", "scripts", "website" ], @@ -18,6 +19,7 @@ "format": "bun prettier --write .", "lint": "turbo run lint", "test": "turbo run test:ci --concurrency=1", + "tests:playwright": "bun run --cwd tests/playwright", "typecheck": "turbo run typecheck", "react": "bun run --cwd packages/react", "scripts": "bun run --cwd scripts", diff --git a/packages/react/src/components/avatar/examples/basic.tsx b/packages/react/src/components/avatar/examples/basic.tsx index 96b4fbffc0..915de46c38 100644 --- a/packages/react/src/components/avatar/examples/basic.tsx +++ b/packages/react/src/components/avatar/examples/basic.tsx @@ -3,6 +3,6 @@ import { Avatar } from '@ark-ui/react/avatar' export const Basic = () => ( PA - + ) diff --git a/packages/react/src/components/avatar/examples/events.tsx b/packages/react/src/components/avatar/examples/events.tsx index 520b34dede..0fd655590d 100644 --- a/packages/react/src/components/avatar/examples/events.tsx +++ b/packages/react/src/components/avatar/examples/events.tsx @@ -7,7 +7,7 @@ export const Events = () => { return ( PA - + ) } diff --git a/packages/react/src/components/avatar/examples/root-provider.tsx b/packages/react/src/components/avatar/examples/root-provider.tsx index 60d48637e0..72556a7155 100644 --- a/packages/react/src/components/avatar/examples/root-provider.tsx +++ b/packages/react/src/components/avatar/examples/root-provider.tsx @@ -11,7 +11,7 @@ export const RootProvider = () => { PA - + ) diff --git a/packages/solid/src/components/avatar/examples/basic.tsx b/packages/solid/src/components/avatar/examples/basic.tsx index c35ba5ce23..5c23771155 100644 --- a/packages/solid/src/components/avatar/examples/basic.tsx +++ b/packages/solid/src/components/avatar/examples/basic.tsx @@ -3,6 +3,6 @@ import { Avatar } from '@ark-ui/solid/avatar' export const Basic = () => ( PA - + ) diff --git a/packages/svelte/src/lib/components/avatar/examples/basic.svelte b/packages/svelte/src/lib/components/avatar/examples/basic.svelte index fe7ce4684b..f0130720bd 100644 --- a/packages/svelte/src/lib/components/avatar/examples/basic.svelte +++ b/packages/svelte/src/lib/components/avatar/examples/basic.svelte @@ -4,5 +4,5 @@ PA - + diff --git a/packages/vue/.storybook/main.ts b/packages/vue/.storybook/main.ts index 42ef82044a..5a1144d740 100644 --- a/packages/vue/.storybook/main.ts +++ b/packages/vue/.storybook/main.ts @@ -1 +1,24 @@ -import './main.css' +import type { StorybookConfig } from '@storybook/vue3-vite' + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.ts'], + addons: [ + { + name: '@storybook/addon-essentials', + options: { backgrounds: false, controls: false, actions: false }, + }, + '@storybook/addon-a11y', + ], + framework: { + name: '@storybook/vue3-vite', + options: {}, + }, + core: { + disableTelemetry: true, + }, + docs: { + autodocs: false, + }, +} + +export default config diff --git a/packages/vue/.storybook/preview.ts b/packages/vue/.storybook/preview.ts new file mode 100644 index 0000000000..a81e8b569b --- /dev/null +++ b/packages/vue/.storybook/preview.ts @@ -0,0 +1,16 @@ +import type { Preview } from '@storybook/vue3' +import './main.css' + +const preview: Preview = { + parameters: { + options: { + storySort: { + method: 'alphabetical', + }, + }, + layout: 'padded', + }, + tags: ['autodocs'], +} + +export default preview diff --git a/packages/vue/package.json b/packages/vue/package.json index e8e9fedee7..9304169299 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -72,7 +72,8 @@ "test": "vitest", "test:ci": "vitest --run", "typecheck": "vue-tsc", - "storybook": "histoire dev", + "histoire": "histoire dev", + "storybook": "storybook dev -p 6009", "release-it": "release-it --config ../../release-it.json", "prepack": "clean-package", "postpack": "clean-package restore" @@ -137,8 +138,16 @@ }, "devDependencies": { "@biomejs/biome": "1.9.4", + "@chromatic-com/storybook": "^3.2.5", "@histoire/plugin-vue": "0.17.17", "@release-it/keep-a-changelog": "6.0.0", + "@storybook/addon-essentials": "^8.6.4", + "@storybook/addon-onboarding": "^8.6.4", + "@storybook/blocks": "^8.6.4", + "@storybook/experimental-addon-test": "^8.6.4", + "@storybook/test": "^8.6.4", + "@storybook/vue3": "^8.6.4", + "@storybook/vue3-vite": "^8.6.4", "@testing-library/dom": "10.4.0", "@testing-library/jest-dom": "6.6.3", "@testing-library/user-event": "14.6.1", @@ -146,14 +155,18 @@ "@types/jsdom": "21.1.7", "@vitejs/plugin-vue": "5.2.1", "@vitejs/plugin-vue-jsx": "4.1.1", + "@vitest/browser": "3.0.8", + "@vitest/coverage-v8": "3.0.8", "@vue/compiler-sfc": "3.5.13", "clean-package": "2.2.0", "globby": "14.1.0", "histoire": "0.17.17", "jsdom": "26.0.0", "lucide-vue-next": "0.479.0", + "playwright": "1.51.0", "release-it": "18.1.2", "resize-observer-polyfill": "1.5.1", + "storybook": "^8.6.4", "typescript": "5.8.2", "vite": "6.2.0", "vite-plugin-dts": "4.5.3", diff --git a/packages/vue/src/components/avatar/avatar.stories.ts b/packages/vue/src/components/avatar/avatar.stories.ts new file mode 100644 index 0000000000..4f95cdfb2d --- /dev/null +++ b/packages/vue/src/components/avatar/avatar.stories.ts @@ -0,0 +1,32 @@ +import type { Meta } from '@storybook/react' + +import BasicExample from './examples/basic.vue' +import EventsExample from './examples/events.vue' +import RootProviderExample from './examples/root-provider.vue' + +const meta = { + title: 'Components / Avatar', +} as Meta + +export default meta + +export const Basic = { + render: () => ({ + components: { BasicExample }, + template: '', + }), +} + +export const Events = { + render: () => ({ + components: { EventsExample }, + template: '', + }), +} + +export const RootProvider = { + render: () => ({ + components: { RootProviderExample }, + template: '', + }), +} diff --git a/packages/vue/src/components/avatar/examples/basic.vue b/packages/vue/src/components/avatar/examples/basic.vue index b4245ae3e9..41007ea549 100644 --- a/packages/vue/src/components/avatar/examples/basic.vue +++ b/packages/vue/src/components/avatar/examples/basic.vue @@ -5,6 +5,6 @@ import { Avatar } from '@ark-ui/vue/avatar' diff --git a/packages/vue/src/components/avatar/examples/events.vue b/packages/vue/src/components/avatar/examples/events.vue index 93009e3f5c..23f1e6fc67 100644 --- a/packages/vue/src/components/avatar/examples/events.vue +++ b/packages/vue/src/components/avatar/examples/events.vue @@ -5,6 +5,6 @@ import { Avatar } from '@ark-ui/vue/avatar' diff --git a/packages/vue/src/components/avatar/examples/root-provider.vue b/packages/vue/src/components/avatar/examples/root-provider.vue index 694f4a9748..d10a5405c5 100644 --- a/packages/vue/src/components/avatar/examples/root-provider.vue +++ b/packages/vue/src/components/avatar/examples/root-provider.vue @@ -5,10 +5,10 @@ const avatar = useAvatar() diff --git a/tests/playwright/.gitignore b/tests/playwright/.gitignore new file mode 100644 index 0000000000..58786aac75 --- /dev/null +++ b/tests/playwright/.gitignore @@ -0,0 +1,7 @@ + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/tests/playwright/package.json b/tests/playwright/package.json new file mode 100644 index 0000000000..53c80b6ba4 --- /dev/null +++ b/tests/playwright/package.json @@ -0,0 +1,22 @@ +{ + "name": "playwright", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test:pw": "playwright test", + "test:pw:ui": "playwright test --ui", + "test:pw:update": "playwright test --trace on --update-snapshots", + "react:storybook": "bun run --cwd ../../packages/react storybook", + "vue:storybook": "bun run --cwd ../../packages/vue storybook", + "solid:storybook": "bun run --cwd ../../packages/solid storybook" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@axe-core/playwright": "4.10.1", + "@playwright/test": "^1.51.0", + "@types/node": "^22.13.10" + } +} diff --git a/tests/playwright/playwright.config.ts b/tests/playwright/playwright.config.ts new file mode 100644 index 0000000000..412672ef9e --- /dev/null +++ b/tests/playwright/playwright.config.ts @@ -0,0 +1,98 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests/', + + snapshotPathTemplate: '{testDir}/{testFileDir}/__screenshots__/{projectName}/{arg}{ext}', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + expect: { + toHaveScreenshot: { + maxDiffPixelRatio: 0.02, + }, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, + webServer: [ + { + command: 'bun react:storybook', + url: 'http://localhost:6006', + timeout: 120 * 1000, + reuseExistingServer: !process.env.CI, + }, + { + command: 'bun vue:storybook', + url: 'http://localhost:6009', + timeout: 120 * 1000, + reuseExistingServer: !process.env.CI, + }, + ], +}) diff --git a/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/react-basic-variant-has-avatar-image-1.png b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/react-basic-variant-has-avatar-image-1.png new file mode 100644 index 0000000000..482263c1a5 Binary files /dev/null and b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/react-basic-variant-has-avatar-image-1.png differ diff --git a/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/react-events-variant-has-avatar-image-1.png b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/react-events-variant-has-avatar-image-1.png new file mode 100644 index 0000000000..482263c1a5 Binary files /dev/null and b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/react-events-variant-has-avatar-image-1.png differ diff --git a/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/react-root-provider-variant-changes-avatar-source-when-out-of-component-button-is-clicked-1.png b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/react-root-provider-variant-changes-avatar-source-when-out-of-component-button-is-clicked-1.png new file mode 100644 index 0000000000..95d130ddef Binary files /dev/null and b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/react-root-provider-variant-changes-avatar-source-when-out-of-component-button-is-clicked-1.png differ diff --git a/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/react-root-provider-variant-has-avatar-image-1.png b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/react-root-provider-variant-has-avatar-image-1.png new file mode 100644 index 0000000000..7632d7a4d0 Binary files /dev/null and b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/react-root-provider-variant-has-avatar-image-1.png differ diff --git a/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/vue-basic-variant-has-avatar-image-1.png b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/vue-basic-variant-has-avatar-image-1.png new file mode 100644 index 0000000000..482263c1a5 Binary files /dev/null and b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/vue-basic-variant-has-avatar-image-1.png differ diff --git a/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/vue-events-variant-has-avatar-image-1.png b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/vue-events-variant-has-avatar-image-1.png new file mode 100644 index 0000000000..482263c1a5 Binary files /dev/null and b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/vue-events-variant-has-avatar-image-1.png differ diff --git a/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/vue-root-provider-variant-changes-avatar-source-when-out-of-component-button-is-clicked-1.png b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/vue-root-provider-variant-changes-avatar-source-when-out-of-component-button-is-clicked-1.png new file mode 100644 index 0000000000..95d130ddef Binary files /dev/null and b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/vue-root-provider-variant-changes-avatar-source-when-out-of-component-button-is-clicked-1.png differ diff --git a/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/vue-root-provider-variant-has-avatar-image-1.png b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/vue-root-provider-variant-has-avatar-image-1.png new file mode 100644 index 0000000000..7632d7a4d0 Binary files /dev/null and b/tests/playwright/tests/packages/components/avatar/__screenshots__/chromium/vue-root-provider-variant-has-avatar-image-1.png differ diff --git a/tests/playwright/tests/packages/components/avatar/avatar.pw.test.ts b/tests/playwright/tests/packages/components/avatar/avatar.pw.test.ts new file mode 100644 index 0000000000..7a089b2fa4 --- /dev/null +++ b/tests/playwright/tests/packages/components/avatar/avatar.pw.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from '@playwright/test' +import { type PackageName, gotoStory, testA11yWithAttachedResults } from '../../components/utils' + +const packages: PackageName[] = ['react', 'vue'] + +for (const packageName of packages) { + test.describe(`${packageName}: basic variant`, () => { + test.beforeEach(async ({ page }) => { + await gotoStory('avatar', 'basic', page, packageName) + await page.getByAltText('avatar').waitFor() + }) + test('has avatar image', async ({ page }) => { + await expect(page.getByAltText('avatar')).toHaveAttribute( + 'src', + 'https://i.pravatar.cc/300?u=a042581f4e29026704d', + ) + await expect(page.locator('#storybook-root')).toHaveScreenshot() + }) + test('has no a11y violations', async ({ page }, testInfo) => { + const accessibilityScanResults = await testA11yWithAttachedResults(page, testInfo, 'avatar') + await expect(accessibilityScanResults.violations).toEqual([]) + }) + }) + + test.describe(`${packageName}: events variant`, () => { + test.beforeEach(async ({ page }) => { + await gotoStory('avatar', 'events', page, packageName) + await page.getByAltText('avatar').waitFor() + }) + test('has avatar image', async ({ page }) => { + await expect(page.getByAltText('avatar')).toHaveAttribute( + 'src', + 'https://i.pravatar.cc/300?u=a042581f4e29026704d', + ) + await expect(page.locator('#storybook-root')).toHaveScreenshot() + }) + test('has no a11y violations', async ({ page }, testInfo) => { + const accessibilityScanResults = await testA11yWithAttachedResults(page, testInfo, 'avatar') + await expect(accessibilityScanResults.violations).toEqual([]) + }) + test('emits status-change event and logs to console', async ({ page }) => { + const consoleMessages: string[] = [] + page.on('console', (msg) => { + if (msg.type() === 'log') { + consoleMessages.push(msg.text()) + } + }) + + await gotoStory('avatar', 'events', page, packageName) + await page.getByAltText('avatar').waitFor() + + const hasStatusChangeEvent = consoleMessages.some((msg) => msg.includes('loaded')) + expect(hasStatusChangeEvent).toBeTruthy() + }) + }) + + test.describe(`${packageName}: root-provider variant`, () => { + test.beforeEach(async ({ page }) => { + await gotoStory('avatar', 'root-provider', page, packageName) + await page.getByAltText('avatar').waitFor() + }) + test('has avatar image', async ({ page }) => { + await expect(page.getByAltText('avatar')).toHaveAttribute( + 'src', + 'https://i.pravatar.cc/300?u=a042581f4e29026704d', + ) + await expect(page.locator('#storybook-root')).toHaveScreenshot() + }) + test('has no a11y violations', async ({ page }, testInfo) => { + const accessibilityScanResults = await testA11yWithAttachedResults(page, testInfo, 'avatar') + await expect(accessibilityScanResults.violations).toEqual([]) + }) + test('changes avatar source when out of component button is clicked', async ({ page }) => { + await expect(page.getByAltText('avatar')).toHaveAttribute( + 'src', + 'https://i.pravatar.cc/300?u=a042581f4e29026704d', + ) + await page.getByRole('button', { name: 'Change Source' }).click() + await expect(page.getByAltText('avatar')).toHaveAttribute( + 'src', + 'https://avatars.githubusercontent.com/u/6916170?v=4', + ) + await expect(page.locator('#storybook-root')).toHaveScreenshot() + }) + }) +} diff --git a/tests/playwright/tests/packages/components/utils.ts b/tests/playwright/tests/packages/components/utils.ts new file mode 100644 index 0000000000..2e1f7f16d9 --- /dev/null +++ b/tests/playwright/tests/packages/components/utils.ts @@ -0,0 +1,36 @@ +import AxeBuilder from '@axe-core/playwright' +import type { Page, TestInfo } from '@playwright/test' + +export type PackageName = 'react' | 'vue' + +export const gotoStory = async (storyId: string, variantId: string, page: Page, packageName: PackageName) => { + if (!storyId || !variantId || !packageName) await page.goto('/') + + // example urls: + // Vue: http://localhost:6007/iframe.html?globals=&id=components-avatar--basic&viewMode=story + // React: http://localhost:6007/iframe.html?args=&globals=&id=components-avatar--basic&viewMode=story + switch (packageName) { + case 'react': + await page.goto(`http://localhost:6006/iframe.html?id=components-${storyId}--${variantId}&viewMode=story`) + break + case 'vue': + await page.goto(`http://localhost:6009/iframe.html?id=components-${storyId}--${variantId}&viewMode=story`) + break + default: + await page.goto('/') + break + } +} + +export const testA11yWithAttachedResults = async (page: Page, testInfo: TestInfo, componentName: string) => { + const accessibilityScanResults = await new AxeBuilder({ page }) + .include(`[data-scope="${componentName}"][data-part="root"]`) + .disableRules('color-contrast') // This rule is not relevant since Ark components are not styled by default. + .analyze() + + await testInfo.attach('accessibility-scan-results', { + body: JSON.stringify(accessibilityScanResults, null, 2), + contentType: 'application/json', + }) + return accessibilityScanResults +}