diff --git a/.gitattributes b/.gitattributes index b177a4913d..51298ca8a8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,6 @@ # Git LFS tracking for binary/media files *.gif filter=lfs diff=lfs merge=lfs -text *.mp4 filter=lfs diff=lfs merge=lfs -text + +# Visual regression baseline snapshots +packages/kilo-ui/tests/**/*.png filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml new file mode 100644 index 0000000000..ae9f759a1d --- /dev/null +++ b/.github/workflows/visual-regression.yml @@ -0,0 +1,100 @@ +name: Visual Regression Tests + +on: + pull_request: + paths: + - "packages/kilo-ui/**" + - "packages/ui/**" + - "packages/util/**" + - "packages/sdk/js/**" + - ".github/workflows/visual-regression.yml" + +jobs: + visual-regression: + name: Visual Regression (kilo-ui) + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + lfs: true + token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ github.head_ref }} + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Cache Bun modules + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: bun-${{ hashFiles('bun.lock') }} + + - name: Install dependencies + run: bun install + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ hashFiles('packages/kilo-ui/package.json') }} + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: bunx playwright install chromium + working-directory: packages/kilo-ui + + - name: Install Playwright system deps + run: bunx playwright install-deps chromium + working-directory: packages/kilo-ui + + - name: Cache Storybook build + id: storybook-cache + uses: actions/cache@v4 + with: + path: packages/kilo-ui/storybook-static + key: storybook-${{ hashFiles('packages/kilo-ui/src/**', 'packages/kilo-ui/.storybook/**', 'packages/ui/src/**') }} + + - name: Build Storybook + if: steps.storybook-cache.outputs.cache-hit != 'true' + run: bun run build-storybook + working-directory: packages/kilo-ui + + - name: Generate missing baselines and run visual regression tests + run: | + # Write any missing platform-specific baselines (new stories, first run on linux, etc.) + bun run test:visual:update + # Compare — runs against the files now on disk; will pass if rendering is stable + bun run test:visual + working-directory: packages/kilo-ui + env: + CI: true + PLAYWRIGHT_WORKERS: "4" + + - name: Commit and push new baselines (if any) + # Run even if tests fail so we can inspect what changed + if: always() + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/ + if git diff --cached --quiet; then + echo "No new baselines — nothing to commit." + else + git commit -m "chore: add visual regression baselines for $(uname -s | tr '[:upper:]' '[:lower:]')" + git lfs push --all origin + git push + fi + + - name: Upload test results on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: visual-regression-results + path: packages/kilo-ui/test-results/ + retention-days: 7 diff --git a/bun.lock b/bun.lock index c90cc491d2..813f3defbc 100644 --- a/bun.lock +++ b/bun.lock @@ -206,6 +206,7 @@ "@kobalte/core": "0.13.11", }, "devDependencies": { + "@playwright/test": "1.58.2", "@storybook/addon-a11y": "10.2.10", "@storybook/addon-docs": "10.2.10", "@storybook/addon-themes": "10.2.10", @@ -1294,7 +1295,7 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], - "@playwright/test": ["@playwright/test@1.57.0", "", { "dependencies": { "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" } }, "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA=="], + "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], "@posthog/core": ["@posthog/core@1.23.1", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-GViD5mOv/mcbZcyzz3z9CS0R79JzxVaqEz4sP5Dsea178M/j3ZWe6gaHDZB9yuyGfcmIMQ/8K14yv+7QrK4sQQ=="], @@ -3118,9 +3119,9 @@ "planck": ["planck@1.4.2", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-mNbhnV3g8X2rwGxzcesjmN8BDA6qfXgQxXVMkWau9MCRlQY0RLNEkyHlVp6yFy/X6qrzAXyNONCnZ1cGDLrNew=="], - "playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], - "playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], + "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], @@ -4178,6 +4179,8 @@ "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], + "@opencode-ai/app/@playwright/test": ["@playwright/test@1.57.0", "", { "dependencies": { "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" } }, "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA=="], + "@opencode-ai/app/tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="], "@opencode-ai/app/vite": ["vite@7.1.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw=="], @@ -5200,6 +5203,8 @@ "@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], + "@opencode-ai/app/@playwright/test/playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], + "@opencode-ai/app/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], @@ -6072,6 +6077,10 @@ "@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + "@opencode-ai/app/@playwright/test/playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "@opencode-ai/app/@playwright/test/playwright/playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], + "@opencode-ai/app/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@opencode-ai/app/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], diff --git a/packages/kilo-ui/.gitattributes b/packages/kilo-ui/.gitattributes new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/kilo-ui/.gitignore b/packages/kilo-ui/.gitignore new file mode 100644 index 0000000000..de95c927d8 --- /dev/null +++ b/packages/kilo-ui/.gitignore @@ -0,0 +1,3 @@ +storybook-static/ +test-results/ +playwright-report/ diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index dfcc3af248..989da98913 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -84,6 +84,7 @@ "solid-js": "catalog:" }, "devDependencies": { + "@playwright/test": "1.58.2", "@storybook/addon-a11y": "10.2.10", "@storybook/addon-docs": "10.2.10", "@storybook/addon-themes": "10.2.10", @@ -97,7 +98,9 @@ }, "scripts": { "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "test:visual": "playwright test", + "test:visual:update": "playwright test --update-snapshots" }, "dependencies": { "@kobalte/core": "0.13.11" diff --git a/packages/kilo-ui/playwright.config.ts b/packages/kilo-ui/playwright.config.ts new file mode 100644 index 0000000000..1f026f3acc --- /dev/null +++ b/packages/kilo-ui/playwright.config.ts @@ -0,0 +1,41 @@ +import { defineConfig, devices } from "@playwright/test" + +export default defineConfig({ + testDir: "./tests", + testMatch: "**/*.spec.ts", + fullyParallel: true, + forbidOnly: !!process.env["CI"], + retries: process.env["CI"] ? 1 : 0, + // Number of parallel workers — defaults to half the CPU count locally, + // override with PLAYWRIGHT_WORKERS env var or --workers CLI flag + workers: process.env["PLAYWRIGHT_WORKERS"] + ? Number(process.env["PLAYWRIGHT_WORKERS"]) + : undefined, + reporter: [["html", { open: "never" }], ["list"]], + use: { + baseURL: "http://localhost:6006", + viewport: { width: 1280, height: 720 }, + reducedMotion: "reduce", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + command: + "bunx storybook build -o ./storybook-static && bunx http-server ./storybook-static -p 6006 --silent", + url: "http://localhost:6006", + reuseExistingServer: !process.env["CI"], + timeout: 300_000, + }, + timeout: 60_000, + expect: { + timeout: 10_000, + toHaveScreenshot: { + maxDiffPixelRatio: 0.01, + }, + }, +}) diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts b/packages/kilo-ui/tests/visual-regression.spec.ts new file mode 100644 index 0000000000..26f57d6085 --- /dev/null +++ b/packages/kilo-ui/tests/visual-regression.spec.ts @@ -0,0 +1,75 @@ +import { test, expect, type Page } from "@playwright/test" + +type Story = { + id: string + title: string + name: string +} + +type StoriesIndex = { + stories?: Record + entries?: Record +} + +const STORYBOOK_URL = "http://localhost:6006" + +// Fetched once per worker process — cheap HTTP call to the already-running Storybook +async function fetchStories(): Promise { + const res = await fetch(`${STORYBOOK_URL}/index.json`).catch(() => + fetch(`${STORYBOOK_URL}/stories.json`), + ) + const data = (await res.json()) as StoriesIndex + const map = data.entries ?? data.stories ?? {} + return Object.values(map).filter((s) => s.id && !s.id.endsWith("--docs")) +} + +async function disableAnimations(page: Page) { + await page.addStyleTag({ + content: ` + *, *::before, *::after { + animation-duration: 0s !important; + animation-delay: 0s !important; + transition-duration: 0s !important; + transition-delay: 0s !important; + } + `, + }) +} + +// Stories to skip from visual regression: +// - Font/Favicon: inject into , no visible content in #storybook-root +// - Typewriter: uses JS setTimeout + Math.random(), inherently non-deterministic +const SKIP = new Set([ + "components-font--default", + "components-font--nerd-fonts", + "components-favicon--default", + "components-typewriter--default", + "components-typewriter--short", + "components-typewriter--long", + "components-typewriter--as-heading", + "components-typewriter--with-class", +]) + +// Generate one test() per story so Playwright's scheduler can distribute +// them freely across workers — no manual sharding needed. +const stories = (await fetchStories()).filter((s) => !SKIP.has(s.id)) + +for (const story of stories) { + test(`${story.title} / ${story.name}`, async ({ page }) => { + await page.goto( + `/iframe.html?id=${story.id}&viewMode=story&globals=colorScheme:dark;theme:kilo`, + { waitUntil: "load" }, + ) + await disableAnimations(page) + // Wait for Kobalte/SolidJS to finish hydrating interactive components + await page.waitForSelector("#storybook-root *", { state: "attached" }) + + // Screenshot just the story content, not the full 1280x720 canvas. + // Use [component, variant] path so snapshots are grouped per component dir. + const [component, variant] = story.id.split("--") + const root = page.locator("#storybook-root") + await expect(root).toHaveScreenshot([component, `${variant}.png`], { + maxDiffPixelRatio: 0.01, + }) + }) +}