diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8e9385fd..4611da1c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -84,12 +84,16 @@ jobs: run: | rm -rf .rustfmt.toml cargo fmt - - name: Build Landing - run: | - bun run --filter @devup-ui/components build-storybook - mv ./packages/components/storybook-static ./apps/landing/public/storybook - bun run --filter landing build - - name: Upload artifact + - name: Build Landing + run: | + bun run --filter @devup-ui/components build-storybook + mv ./packages/components/storybook-static ./apps/landing/public/storybook + bun run --filter landing build + - name: Install Playwright Browsers + run: bunx playwright install chromium --with-deps + - name: Run E2E Tests + run: bun run test:e2e + - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: ./apps/landing/out diff --git a/.gitignore b/.gitignore index 088c28e2..04f7bf01 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ tarpaulin-report.json storybook-static .claude .sisyphus +test-results +playwright-report diff --git a/bun.lock b/bun.lock index 4abdf780..94e5accb 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "devup-ui", "devDependencies": { "@devup-ui/eslint-plugin": "workspace:^", + "@playwright/test": "^1.58.2", "@types/bun": "latest", "@types/node": "^25.2", "bun-test-env-dom": "^1.0.3", @@ -368,7 +369,7 @@ }, "bindings/devup-ui-wasm": { "name": "@devup-ui/wasm", - "version": "1.0.62", + "version": "1.0.63", }, "packages/bun-plugin": { "name": "@devup-ui/bun-plugin", @@ -416,7 +417,7 @@ }, "packages/eslint-plugin": { "name": "@devup-ui/eslint-plugin", - "version": "1.0.10", + "version": "1.0.11", "dependencies": { "@typescript-eslint/utils": "^8.55", "typescript-eslint": "^8.55", @@ -1121,6 +1122,8 @@ "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], + "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], + "@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], @@ -2697,6 +2700,10 @@ "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], + "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.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], @@ -3393,6 +3400,8 @@ "pkg-dir/find-up": ["find-up@6.3.0", "", { "dependencies": { "locate-path": "^7.1.0", "path-exists": "^5.0.0" } }, "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "postcss-merge-rules/browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], diff --git a/bunfig.toml b/bunfig.toml index b8f3b4a7..83bdb95c 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,4 +1,5 @@ [test] +root = "packages" preload = ["./packages/bun-plugin/src/index.ts", "./bun.setup.ts", "bun-test-env-dom"] coverage = true coverageReporter = ["text", "lcov"] diff --git a/e2e/components-pages.spec.ts b/e2e/components-pages.spec.ts new file mode 100644 index 00000000..392ef207 --- /dev/null +++ b/e2e/components-pages.spec.ts @@ -0,0 +1,269 @@ +import { expect, test } from '@playwright/test' + +test.describe('Components Pages', () => { + test.describe('Components link availability', () => { + test('header has Components link pointing to /components/overview', async ({ + browser, + }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 1440, height: 900 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + const link = page.locator('a[href="/components/overview"]').first() + await expect(link).toBeVisible() + await expect(link).toHaveAttribute('href', '/components/overview') + await context.close() + }) + + test('Components link text is correct', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 1440, height: 900 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + const link = page + .locator('a[href="/components/overview"]') + .filter({ hasText: 'Components' }) + await expect(link).toBeVisible() + await context.close() + }) + + test('Components link is hidden on mobile', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 375, height: 812 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + const link = page + .locator('a[href="/components/overview"]') + .filter({ hasText: 'Components' }) + const isVisible = await link.isVisible().catch(() => false) + expect(isVisible).toBeFalsy() + await context.close() + }) + + test('Components link is visible on desktop', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 1440, height: 900 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + const link = page + .locator('a[href="/components/overview"]') + .filter({ hasText: 'Components' }) + await expect(link).toBeVisible() + await context.close() + }) + }) + + test.describe('Storybook link', () => { + test('header has Storybook link', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 1440, height: 900 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + const link = page.locator('a[href="/storybook/index.html"]').first() + await expect(link).toBeVisible() + await context.close() + }) + + test('Storybook link is hidden on mobile', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 375, height: 812 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + const link = page + .locator('a[href="/storybook/index.html"]') + .filter({ hasText: 'Storybook' }) + const isVisible = await link.isVisible().catch(() => false) + expect(isVisible).toBeFalsy() + await context.close() + }) + }) + + test.describe('Benchmark section (component showcase on home page)', () => { + test('has Comparison Benchmarks section', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 1440, height: 900 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + await expect(page.getByText('Comparison Bechmarks').first()).toBeVisible() + await context.close() + }) + + test('shows Devup UI benchmark card', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 1440, height: 900 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + const benchSection = page.getByText('Comparison Bechmarks').first() + await benchSection.scrollIntoViewIfNeeded() + + // The Devup UI card is inside a client-side animation wrapper that keeps + // it visually hidden (opacity/transform) when JS is disabled. Verify the + // element exists in the DOM instead. + await expect( + page.locator('.typo-h5', { hasText: 'Devup UI' }).first(), + ).toBeAttached() + await context.close() + }) + + test('shows competitor benchmarks', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 1440, height: 900 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + const benchSection = page.getByText('Comparison Bechmarks').first() + await benchSection.scrollIntoViewIfNeeded() + await page.waitForTimeout(500) + + // Check that several competitor names are visible + await expect(page.getByText('Chakra UI').first()).toBeVisible() + await expect(page.getByText('Tailwindcss').first()).toBeVisible() + await context.close() + }) + + test('desktop benchmark section screenshot', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 1440, height: 900 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + const benchSection = page.getByText('Comparison Bechmarks').first() + await benchSection.scrollIntoViewIfNeeded() + await page.waitForTimeout(500) + + await expect(page).toHaveScreenshot( + 'components-benchmark-section-desktop.png', + { fullPage: false }, + ) + await context.close() + }) + + test('mobile benchmark section screenshot', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 375, height: 812 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + const benchSection = page.getByText('Comparison Bechmarks').first() + await benchSection.scrollIntoViewIfNeeded() + await page.waitForTimeout(500) + + await expect(page).toHaveScreenshot( + 'components-benchmark-section-mobile.png', + { fullPage: false }, + ) + await context.close() + }) + + test('dark mode benchmark section screenshot', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 1440, height: 900 }, + colorScheme: 'dark', + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + const benchSection = page.getByText('Comparison Bechmarks').first() + await benchSection.scrollIntoViewIfNeeded() + await page.waitForTimeout(500) + + await expect(page).toHaveScreenshot( + 'dark-components-benchmark-section-desktop.png', + { fullPage: false }, + ) + await context.close() + }) + }) + + test.describe('Community section', () => { + test('has Join our community section', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 1440, height: 900 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + const community = page.getByText('Join our community').first() + await community.scrollIntoViewIfNeeded() + await expect(community).toBeVisible() + await context.close() + }) + + test('has Discord link', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 1440, height: 900 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + const discordLink = page + .locator('a[href="https://discord.gg/8zjcGc7cWh"]') + .first() + await expect(discordLink).toBeVisible() + await context.close() + }) + + test('has KakaoTalk link', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 1440, height: 900 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + const kakaoLink = page + .locator('a[href="https://open.kakao.com/o/giONwVAh"]') + .first() + await expect(kakaoLink).toBeVisible() + await context.close() + }) + }) +}) diff --git a/e2e/components-pages.spec.ts-snapshots/components-benchmark-section-desktop.png b/e2e/components-pages.spec.ts-snapshots/components-benchmark-section-desktop.png new file mode 100644 index 00000000..c4453341 Binary files /dev/null and b/e2e/components-pages.spec.ts-snapshots/components-benchmark-section-desktop.png differ diff --git a/e2e/components-pages.spec.ts-snapshots/components-benchmark-section-mobile.png b/e2e/components-pages.spec.ts-snapshots/components-benchmark-section-mobile.png new file mode 100644 index 00000000..28c574c2 Binary files /dev/null and b/e2e/components-pages.spec.ts-snapshots/components-benchmark-section-mobile.png differ diff --git a/e2e/components-pages.spec.ts-snapshots/dark-components-benchmark-section-desktop.png b/e2e/components-pages.spec.ts-snapshots/dark-components-benchmark-section-desktop.png new file mode 100644 index 00000000..6a376336 Binary files /dev/null and b/e2e/components-pages.spec.ts-snapshots/dark-components-benchmark-section-desktop.png differ diff --git a/e2e/docs-pages.spec.ts b/e2e/docs-pages.spec.ts new file mode 100644 index 00000000..e3c89a87 --- /dev/null +++ b/e2e/docs-pages.spec.ts @@ -0,0 +1,246 @@ +import { expect, test } from '@playwright/test' + +/** + * All tests use javaScriptEnabled: false because the Next.js static export + * serves correct SSR HTML, but client-side hydration triggers a 404 under + * the custom static server (no real Next.js router available). + * + * With JS disabled the raw SSR HTML renders perfectly — sidebar links, + * doc content, and all static elements are present. + */ + +test.describe('Documentation Pages', () => { + test.describe('Docs link availability', () => { + test('header has Docs link pointing to /docs/overview', async ({ + browser, + }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 1440, height: 900 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + const docsLink = page.locator('a[href="/docs/overview"]').first() + await expect(docsLink).toBeVisible() + await expect(docsLink).toHaveAttribute('href', '/docs/overview') + + await context.close() + }) + + test('Get Started button links to docs overview', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 1440, height: 900 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + const getStarted = page.locator('a[href="/docs/overview"]').filter({ + hasText: 'Get started', + }) + await expect(getStarted).toBeVisible() + + await context.close() + }) + + test('Docs link is hidden on mobile', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 375, height: 812 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + // The header nav link should not be visible on mobile + const docsNavLink = page + .locator('a[href="/docs/overview"]') + .filter({ hasText: 'Docs' }) + const isVisible = await docsNavLink.isVisible().catch(() => false) + expect(isVisible).toBeFalsy() + + await context.close() + }) + + test('Docs link is visible on desktop', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 1440, height: 900 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + const docsNavLink = page + .locator('a[href="/docs/overview"]') + .filter({ hasText: 'Docs' }) + await expect(docsNavLink).toBeVisible() + + await context.close() + }) + }) + + test.describe('Documentation content presence on home page', () => { + test('home page mentions key docs concepts', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 1440, height: 900 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + // The home page should mention Zero Runtime, which is a key concept + await expect(page.getByText('Zero Runtime').first()).toBeVisible() + + await context.close() + }) + + test('home page has Features section', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 1440, height: 900 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + await expect(page.getByText('Features').first()).toBeVisible() + + await context.close() + }) + + test('home page has Type Safety feature', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 1440, height: 900 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + await expect(page.getByText('Type Safety').first()).toBeVisible() + + await context.close() + }) + + test('home page has Figma Plugin feature', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 1440, height: 900 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + const figmaPlugin = page.getByText('Figma Plugin').first() + await figmaPlugin.scrollIntoViewIfNeeded() + await expect(figmaPlugin).toBeVisible() + + await context.close() + }) + }) + + test.describe('Docs-related visual regression', () => { + test('desktop features section screenshot', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 1440, height: 900 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + const features = page.getByText('Features').first() + await features.scrollIntoViewIfNeeded() + await page.waitForTimeout(500) + + await expect(page).toHaveScreenshot('docs-features-section-desktop.png', { + fullPage: false, + }) + + await context.close() + }) + + test('mobile features section screenshot', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 375, height: 812 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + const features = page.getByText('Features').first() + await features.scrollIntoViewIfNeeded() + await page.waitForTimeout(500) + + await expect(page).toHaveScreenshot('docs-features-section-mobile.png', { + fullPage: false, + }) + + await context.close() + }) + + // Dark mode screenshot requires JS for ThemeScript to set data-theme attribute. + // With javaScriptEnabled: false, the theme cannot switch to dark mode. + // Using colorScheme: 'dark' in context only sets prefers-color-scheme media, + // which is insufficient if the app relies on a JS-driven theme class/attribute. + test.skip('dark mode features section screenshot', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 1440, height: 900 }, + colorScheme: 'dark', + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + const features = page.getByText('Features').first() + await features.scrollIntoViewIfNeeded() + await page.waitForTimeout(500) + + await expect(page).toHaveScreenshot( + 'dark-docs-features-section-desktop.png', + { fullPage: false }, + ) + + await context.close() + }) + }) + + test.describe('Get Started CTA', () => { + test('Get Started button is visible on desktop', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 1440, height: 900 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + const getStarted = page.getByText('Get started').first() + await expect(getStarted).toBeVisible() + + await context.close() + }) + + test('Get Started button is visible on mobile', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 375, height: 812 }, + }) + const page = await context.newPage() + await page.goto('/') + await page.waitForTimeout(500) + + const getStarted = page.getByText('Get started').first() + await expect(getStarted).toBeVisible() + + await context.close() + }) + }) +}) diff --git a/e2e/docs-pages.spec.ts-snapshots/dark-docs-features-section-desktop.png b/e2e/docs-pages.spec.ts-snapshots/dark-docs-features-section-desktop.png new file mode 100644 index 00000000..6d70ab79 Binary files /dev/null and b/e2e/docs-pages.spec.ts-snapshots/dark-docs-features-section-desktop.png differ diff --git a/e2e/docs-pages.spec.ts-snapshots/docs-features-section-desktop.png b/e2e/docs-pages.spec.ts-snapshots/docs-features-section-desktop.png new file mode 100644 index 00000000..28668628 Binary files /dev/null and b/e2e/docs-pages.spec.ts-snapshots/docs-features-section-desktop.png differ diff --git a/e2e/docs-pages.spec.ts-snapshots/docs-features-section-mobile.png b/e2e/docs-pages.spec.ts-snapshots/docs-features-section-mobile.png new file mode 100644 index 00000000..b124f397 Binary files /dev/null and b/e2e/docs-pages.spec.ts-snapshots/docs-features-section-mobile.png differ diff --git a/e2e/landing-build-integrity.spec.ts b/e2e/landing-build-integrity.spec.ts new file mode 100644 index 00000000..a5843203 --- /dev/null +++ b/e2e/landing-build-integrity.spec.ts @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test' + +test.describe('Landing Page - Build Integrity', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + }) + + test('page loads without console errors', async ({ page }) => { + const errors: string[] = [] + page.on('pageerror', (error) => { + errors.push(error.message) + }) + + // Re-navigate to capture errors from initial load + await page.goto('/') + await page.waitForLoadState('networkidle') + + expect(errors, 'Page should load without JS errors').toHaveLength(0) + }) + + test('CSS stylesheets are present in ', async ({ page }) => { + const stylesheetCount = await page.locator('link[rel="stylesheet"]').count() + expect( + stylesheetCount, + 'Expected at least one CSS stylesheet link in ', + ).toBeGreaterThan(0) + }) + + test('CSS files contain devup-ui generated classes', async ({ page }) => { + // devup-ui generates CSS with short class names (a, b, c, ..., aa, ab, ...) + // Next.js bundles them into hashed chunk filenames, so we check CSS content + const hasDevupStyles = await page.evaluate(async () => { + const links = Array.from( + document.querySelectorAll('link[rel="stylesheet"]'), + ) + for (const link of links) { + const href = link.getAttribute('href') + if (!href) continue + try { + const res = await fetch(href) + const text = await res.text() + // devup-ui uses CSS custom properties like --primary, --text, --background + // and generates short class selectors + if ( + text.includes('--primary') || + text.includes('--footerBg') || + text.includes('--background') + ) { + return true + } + } catch { + // skip fetch errors + } + } + return false + }) + + expect( + hasDevupStyles, + 'Expected CSS files to contain devup-ui generated styles (CSS variables like --primary, --footerBg)', + ).toBe(true) + }) + + test('no 404 errors on CSS or JS resources', async ({ page }) => { + const failedRequests: string[] = [] + + page.on('response', (response) => { + const url = response.url() + if ( + response.status() === 404 && + (url.endsWith('.css') || url.endsWith('.js')) + ) { + failedRequests.push(`${response.status()} ${url}`) + } + }) + + await page.goto('/') + await page.waitForLoadState('networkidle') + + expect( + failedRequests, + `Found 404 errors for resources: ${failedRequests.join(', ')}`, + ).toHaveLength(0) + }) + + test('page has actual visible content (not blank)', async ({ page }) => { + // Check for the hero text that should always be present + const heroText = page.getByText('Zero Config') + await expect(heroText.first()).toBeVisible() + + // Check for the "Features" section heading + const featuresHeading = page.getByText('Features') + await expect(featuresHeading.first()).toBeVisible() + + // Check that body has non-trivial content + const bodyText = await page.evaluate( + () => document.body.innerText.trim().length, + ) + expect( + bodyText, + 'Page body should have substantial text content', + ).toBeGreaterThan(100) + }) + + test('page title is set correctly', async ({ page }) => { + await expect(page).toHaveTitle(/Devup UI/) + }) +}) diff --git a/e2e/landing-computed-styles.spec.ts b/e2e/landing-computed-styles.spec.ts new file mode 100644 index 00000000..6af0f935 --- /dev/null +++ b/e2e/landing-computed-styles.spec.ts @@ -0,0 +1,150 @@ +import { expect, test } from '@playwright/test' + +/** + * Helper to parse a CSS color value (rgb/rgba/hex) to a normalized hex string. + * Handles rgb(r, g, b), rgba(r, g, b, a), and hex formats. + */ +function normalizeColor(raw: string): string { + const trimmed = raw.trim().toLowerCase() + + // Handle hex + if (trimmed.startsWith('#')) { + const hex = trimmed.replace('#', '') + if (hex.length === 3) { + return `#${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}` + } + return `#${hex.substring(0, 6)}` + } + + // Handle rgb(a) + const match = trimmed.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/) + if (match) { + const r = parseInt(match[1], 10).toString(16).padStart(2, '0') + const g = parseInt(match[2], 10).toString(16).padStart(2, '0') + const b = parseInt(match[3], 10).toString(16).padStart(2, '0') + return `#${r}${g}${b}` + } + + return trimmed +} + +test.describe('Landing Page - Computed Styles', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test('body has correct background-color from $footerBg (light)', async ({ + page, + }) => { + const bgColor = await page.evaluate( + () => getComputedStyle(document.body).backgroundColor, + ) + // $footerBg light = #F4F4F6 + expect(normalizeColor(bgColor)).toBe('#f4f4f6') + }) + + test('body has correct text color from $text (light)', async ({ page }) => { + const color = await page.evaluate( + () => getComputedStyle(document.body).color, + ) + // $text light = #2F2F2F + expect(normalizeColor(color)).toBe('#2f2f2f') + }) + + test('CSS custom properties are set on :root', async ({ page }) => { + const cssVars = await page.evaluate(() => { + const root = document.documentElement + const style = getComputedStyle(root) + return { + primary: style.getPropertyValue('--primary').trim(), + text: style.getPropertyValue('--text').trim(), + background: style.getPropertyValue('--background').trim(), + footerBg: style.getPropertyValue('--footerBg').trim(), + border: style.getPropertyValue('--border').trim(), + } + }) + + // At least some CSS variables should be defined + // devup-ui uses light-dark() so variables may contain the function call + // or the resolved value. We check they are not empty. + const definedVars = Object.values(cssVars).filter((v) => v.length > 0) + expect( + definedVars.length, + 'Expected CSS custom properties to be set on :root', + ).toBeGreaterThan(0) + }) + + test('TopBanner has a gradient background', async ({ page }) => { + // TopBanner is the first major section — a VStack with gradient bg + // Walk up from "Config" text to find the gradient container + const gradientBg = await page.evaluate(() => { + // Find element containing "Zero Config" and walk up to find gradient + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_TEXT, + { + acceptNode: (node) => + node.textContent?.includes('Config') + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT, + }, + ) + let node = walker.nextNode() + while (node) { + let el = node.parentElement + while (el && el !== document.body) { + const bg = getComputedStyle(el).backgroundImage + if (bg.includes('gradient')) return bg + el = el.parentElement + } + node = walker.nextNode() + } + return '' + }) + + expect(gradientBg, 'TopBanner should have a gradient background').toContain( + 'gradient', + ) + }) + + test('Feature cards have correct background from $containerBackground', async ({ + page, + }) => { + // Feature cards contain "Zero Runtime", "Top Performance", etc. + const cardBg = await page.evaluate(() => { + const el = Array.from(document.querySelectorAll('*')).find( + (el) => + el.textContent?.trim() === 'Zero Runtime' && el.tagName !== 'BODY', + ) + if (!el) return '' + // Walk up to find the card container + let parent = el.parentElement + while (parent && parent !== document.body) { + const bg = getComputedStyle(parent).backgroundColor + // Look for non-transparent background + if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') { + return bg + } + parent = parent.parentElement + } + return '' + }) + + expect(cardBg, 'Feature card should have a background color').toBeTruthy() + // $containerBackground light = #FFF + expect(normalizeColor(cardBg)).toBe('#ffffff') + }) + + test('Footer has $footerBg background', async ({ page }) => { + const footerBg = await page.evaluate(() => { + const footer = document.querySelector('footer') + if (!footer) return '' + return getComputedStyle(footer).backgroundColor + }) + + expect(footerBg, 'Footer should have a background color').toBeTruthy() + // $footerBg light = #F4F4F6 + expect(normalizeColor(footerBg)).toBe('#f4f4f6') + }) +}) diff --git a/e2e/landing-header.spec.ts b/e2e/landing-header.spec.ts new file mode 100644 index 00000000..b6058cd3 --- /dev/null +++ b/e2e/landing-header.spec.ts @@ -0,0 +1,301 @@ +import { expect, test } from '@playwright/test' + +/** + * NOTE: Sub-page navigation is not possible in the current static export + + * `serve -s` setup. Header tests that required sub-page navigation have been + * replaced with home-page-only equivalents. + */ + +test.describe('Landing Page - Header & Navigation', () => { + test.describe('Header visibility', () => { + test('header is visible on home page', async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + + const logoLink = page.locator('a[href="/"]').first() + await expect(logoLink).toBeVisible() + }) + + test('header contains logo image', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + + const logoImg = page.locator('img[src="/logo.svg"]').first() + await expect(logoImg).toBeVisible() + }) + + test('header contains all navigation links on desktop', async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + + await expect( + page.locator('a[href="/docs/overview"]').filter({ hasText: 'Docs' }), + ).toBeVisible() + await expect( + page + .locator('a[href="/components/overview"]') + .filter({ hasText: 'Components' }), + ).toBeVisible() + await expect( + page.locator('a[href="/team"]').filter({ hasText: 'Team' }), + ).toBeVisible() + await expect( + page + .locator('a[href="/storybook/index.html"]') + .filter({ hasText: 'Storybook' }), + ).toBeVisible() + }) + + test('navigation links are hidden on mobile', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + + const docsLink = page + .locator('a[href="/docs/overview"]') + .filter({ hasText: 'Docs' }) + const isVisible = await docsLink.isVisible().catch(() => false) + expect(isVisible).toBeFalsy() + }) + }) + + test.describe('Logo', () => { + test('logo links to home page', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + + const logoLink = page.locator('a[href="/"]').first() + await expect(logoLink).toHaveAttribute('href', '/') + }) + }) + + test.describe('External links', () => { + test('GitHub link is present', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + + const githubLink = page + .locator('a[href="https://github.com/dev-five-git/devup-ui"]') + .first() + await expect(githubLink).toBeVisible() + }) + + test('Discord link is present in header', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + + const discordLink = page + .locator('a[href="https://discord.gg/8zjcGc7cWh"]') + .first() + await expect(discordLink).toBeVisible() + }) + + test('KakaoTalk link is present in header', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + + const kakaoLink = page + .locator('a[href="https://open.kakao.com/o/giONwVAh"]') + .first() + await expect(kakaoLink).toBeVisible() + }) + }) + + test.describe('Header stickiness', () => { + test('header stays visible when scrolling on home page', async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + + const logoLink = page.locator('a[href="/"]').first() + await expect(logoLink).toBeVisible() + + // Scroll down significantly + await page.evaluate(() => window.scrollBy(0, 1000)) + await page.waitForTimeout(300) + + // Logo should still be visible because header is fixed/sticky + await expect(logoLink).toBeVisible() + + const isInViewport = await logoLink.evaluate((el) => { + const rect = el.getBoundingClientRect() + return rect.top >= 0 && rect.top < window.innerHeight + }) + expect(isInViewport).toBeTruthy() + }) + }) + + test.describe('Theme switch', () => { + test('theme switch button toggles dark/light', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + + const initialTheme = await page.evaluate(() => + document.documentElement.getAttribute('data-theme'), + ) + + // ThemeSwitch is a Box with cursor:pointer containing two SVGs + await page.evaluate(() => { + const allElements = document.querySelectorAll('*') + for (const el of allElements) { + if ( + getComputedStyle(el).cursor === 'pointer' && + el.querySelectorAll(':scope > svg').length === 2 + ) { + ;(el as HTMLElement).click() + return + } + } + }) + + await page.waitForTimeout(500) + + const newTheme = await page.evaluate(() => + document.documentElement.getAttribute('data-theme'), + ) + + expect(newTheme).not.toBe(initialTheme) + }) + + test('theme persists in data-theme attribute', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + + const theme = await page.evaluate(() => + document.documentElement.getAttribute('data-theme'), + ) + + // data-theme should be either 'light' or 'dark' + expect(['light', 'dark']).toContain(theme) + }) + }) + + test.describe('Mobile menu', () => { + test('hamburger menu appears on mobile', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + + const menuButton = page.locator('svg[aria-label="Menu Button"]') + await expect(menuButton).toBeVisible() + }) + + test('hamburger menu is hidden on desktop', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + + const menuButton = page.locator('svg[aria-label="Menu Button"]') + const isVisible = await menuButton.isVisible().catch(() => false) + expect(isVisible).toBeFalsy() + }) + + test('hamburger menu opens on click', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + + const menuButton = page.locator('svg[aria-label="Menu Button"]') + await expect(menuButton).toBeVisible() + + await menuButton.click() + await page.waitForTimeout(1000) + + // After clicking menu, the URL should contain menu=1 + const url = page.url() + expect(url).toContain('menu=1') + }) + }) + + test.describe('Search', () => { + test('search input is visible on desktop', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + + const searchInput = page.locator('input[readonly]').first() + + if ((await searchInput.count()) > 0) { + await expect(searchInput).toBeVisible() + } + }) + + test('search input has placeholder text', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + + const searchInput = page.locator('input[readonly]').first() + + if ((await searchInput.count()) > 0) { + const placeholder = await searchInput.getAttribute('placeholder') + expect(placeholder).toBeTruthy() + expect(placeholder).toContain('Search') + } + }) + + test('search modal opens on input click', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + + const searchInput = page.locator('input[readonly]').first() + + if ((await searchInput.count()) > 0) { + await searchInput.click() + await page.waitForTimeout(500) + + const url = page.url() + const hasSearchParam = url.includes('search=') + const hasModalVisible = + (await page.locator('input:not([readonly])').count()) > 0 + + expect(hasSearchParam || hasModalVisible).toBeTruthy() + } + }) + }) + + test.describe('Footer', () => { + test('footer is visible', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + + const footer = page.locator('footer').first() + await footer.scrollIntoViewIfNeeded() + await expect(footer).toBeVisible() + }) + + test('footer has copyright text', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + + const copyright = page.getByText('Copyright').first() + await copyright.scrollIntoViewIfNeeded() + await expect(copyright).toBeVisible() + }) + + test('footer has white logo', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + + const footerLogo = page.locator('img[src="/white-logo.svg"]').first() + await footerLogo.scrollIntoViewIfNeeded() + await expect(footerLogo).toBeVisible() + }) + }) +}) diff --git a/e2e/landing-interactions.spec.ts b/e2e/landing-interactions.spec.ts new file mode 100644 index 00000000..59c0eed6 --- /dev/null +++ b/e2e/landing-interactions.spec.ts @@ -0,0 +1,142 @@ +import { expect, test } from '@playwright/test' + +function normalizeColor(raw: string): string { + const trimmed = raw.trim().toLowerCase() + if (trimmed.startsWith('#')) { + const hex = trimmed.replace('#', '') + if (hex.length === 3) { + return `#${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}` + } + return `#${hex.substring(0, 6)}` + } + const match = trimmed.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/) + if (match) { + const r = parseInt(match[1], 10).toString(16).padStart(2, '0') + const g = parseInt(match[2], 10).toString(16).padStart(2, '0') + const b = parseInt(match[3], 10).toString(16).padStart(2, '0') + return `#${r}${g}${b}` + } + return trimmed +} + +test.describe('Landing Page - Interactions', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test('GetStarted button background changes on hover', async ({ page }) => { + // GetStarted button has bg="$text" and _hover={{ bg: '$title' }} + // $text = #2F2F2F, $title = #1A1A1A + // GetStartedButton renders as with text "Get started" inside nested divs + const getStartedLink = page.locator('a', { hasText: 'Get started' }).first() + await expect(getStartedLink).toBeVisible() + + // Get the inner flex container that has the bg (first div child) + const bgBefore = await getStartedLink.evaluate((el) => { + const inner = el.querySelector('div') || el + return getComputedStyle(inner).backgroundColor + }) + + // Hover + await getStartedLink.hover() + await page.waitForTimeout(300) + + const bgAfter = await getStartedLink.evaluate((el) => { + const inner = el.querySelector('div') || el + return getComputedStyle(inner).backgroundColor + }) + + expect( + normalizeColor(bgBefore), + 'Before hover: bg should be $text (#2F2F2F)', + ).toBe('#2f2f2f') + expect( + normalizeColor(bgAfter), + 'After hover: bg should change to $title (#1A1A1A)', + ).toBe('#1a1a1a') + }) + + test('Discord button background changes on hover', async ({ page }) => { + // Discord "Join our Discord" button has bg="$buttonBlue" and _hover={{ bg: '$buttonBlueHover' }} + // $buttonBlue = #266CCD, $buttonBlueHover = #1453AC + const discordLink = page.getByRole('link', { name: /Join our Discord/i }) + await expect(discordLink).toBeVisible() + + const bgBefore = await discordLink.evaluate((el) => { + const inner = el.querySelector('div') || el + return getComputedStyle(inner).backgroundColor + }) + + await discordLink.hover() + await page.waitForTimeout(300) + + const bgAfter = await discordLink.evaluate((el) => { + const inner = el.querySelector('div') || el + return getComputedStyle(inner).backgroundColor + }) + + expect(normalizeColor(bgBefore)).toBe('#266ccd') + expect(normalizeColor(bgAfter)).toBe('#1453ac') + }) + + test('KakaoTalk button background changes on hover', async ({ page }) => { + // KakaoTalk button: bg="$kakaoButton" _hover={{ bg: '$kakaoButtonHover' }} + // $kakaoButton = #DE9800, $kakaoButtonHover = #C98900 + const kakaoLink = page.getByRole('link', { name: /Open KakaoTalk/i }) + await expect(kakaoLink).toBeVisible() + + const bgBefore = await kakaoLink.evaluate((el) => { + const inner = el.querySelector('div') || el + return getComputedStyle(inner).backgroundColor + }) + + await kakaoLink.hover() + await page.waitForTimeout(300) + + const bgAfter = await kakaoLink.evaluate((el) => { + const inner = el.querySelector('div') || el + return getComputedStyle(inner).backgroundColor + }) + + expect(normalizeColor(bgBefore)).toBe('#de9800') + expect(normalizeColor(bgAfter)).toBe('#c98900') + }) + + test('FigmaButton background changes on hover', async ({ page }) => { + // FigmaButton: _hover={{ bg: '$menuHover' }} + // Default bg is transparent, $menuHover = #F6F4FF + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + + const figmaLink = page.getByRole('link', { + name: /Go Figma Community/i, + }) + await expect(figmaLink).toBeVisible() + + const bgBefore = await figmaLink.evaluate((el) => { + const inner = el.querySelector('div') || el + return getComputedStyle(inner).backgroundColor + }) + + await figmaLink.hover() + await page.waitForTimeout(300) + + const bgAfter = await figmaLink.evaluate((el) => { + const inner = el.querySelector('div') || el + return getComputedStyle(inner).backgroundColor + }) + + // Before hover should be transparent (rgba(0, 0, 0, 0)) + const isTransparent = + bgBefore === 'rgba(0, 0, 0, 0)' || bgBefore === 'transparent' + expect( + isTransparent, + `Expected transparent before hover, got ${bgBefore}`, + ).toBeTruthy() + + // After hover should be $menuHover = #F6F4FF + expect(normalizeColor(bgAfter)).toBe('#f6f4ff') + }) +}) diff --git a/e2e/landing-responsive.spec.ts b/e2e/landing-responsive.spec.ts new file mode 100644 index 00000000..572c1215 --- /dev/null +++ b/e2e/landing-responsive.spec.ts @@ -0,0 +1,173 @@ +import { expect, test } from '@playwright/test' + +test.describe('Landing Page - Responsive Layout', () => { + test.describe('Mobile viewport (375px)', () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test('feature grid is single column on mobile', async ({ page }) => { + // The Feature section uses Grid with gridTemplateColumns=['1fr', null, '1fr 1fr'] + // On mobile (375px), it should be 1fr (single column) + const gridColumns = await page.evaluate(() => { + // Find the grid element by looking for the container with "Zero Runtime" card + const grids = Array.from(document.querySelectorAll('*')) + for (const el of grids) { + const style = getComputedStyle(el) + if (style.display === 'grid') { + // Check if this grid contains feature cards + if (el.textContent?.includes('Zero Runtime')) { + return style.gridTemplateColumns + } + } + } + return '' + }) + + // On mobile, should be a single column (one value in gridTemplateColumns) + const columnCount = gridColumns + .trim() + .split(/\s+/) + .filter((v) => v.length > 0).length + expect( + columnCount, + `Expected 1 column on mobile, got gridTemplateColumns="${gridColumns}"`, + ).toBe(1) + }) + + test('Discord buttons stack vertically on mobile', async ({ page }) => { + // The Discord section has flexDirection={['column', null, 'row']} + const flexDir = await page.evaluate(() => { + // Find container of KakaoTalk / Discord buttons + const links = Array.from(document.querySelectorAll('a')) + const kakaoLink = links.find((a) => + a.textContent?.includes('Open KakaoTalk'), + ) + if (!kakaoLink) return '' + const parent = kakaoLink.parentElement + if (!parent) return '' + return getComputedStyle(parent).flexDirection + }) + + expect(flexDir, 'Discord buttons should stack vertically on mobile').toBe( + 'column', + ) + }) + }) + + test.describe('Desktop viewport (1440px)', () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test('feature grid is two columns on desktop', async ({ page }) => { + const gridColumns = await page.evaluate(() => { + const grids = Array.from(document.querySelectorAll('*')) + for (const el of grids) { + const style = getComputedStyle(el) + if (style.display === 'grid') { + if (el.textContent?.includes('Zero Runtime')) { + return style.gridTemplateColumns + } + } + } + return '' + }) + + // On desktop, gridTemplateColumns should produce 2 columns ('1fr 1fr') + const columnCount = gridColumns + .trim() + .split(/\s+/) + .filter((v) => v.length > 0).length + expect( + columnCount, + `Expected 2 columns on desktop, got gridTemplateColumns="${gridColumns}"`, + ).toBe(2) + }) + + test('Discord buttons are in a row on desktop', async ({ page }) => { + const flexDir = await page.evaluate(() => { + const links = Array.from(document.querySelectorAll('a')) + const kakaoLink = links.find((a) => + a.textContent?.includes('Open KakaoTalk'), + ) + if (!kakaoLink) return '' + const parent = kakaoLink.parentElement + if (!parent) return '' + return getComputedStyle(parent).flexDirection + }) + + expect(flexDir, 'Discord buttons should be in a row on desktop').toBe( + 'row', + ) + }) + + test('footer content is in a row on desktop', async ({ page }) => { + const flexDir = await page.evaluate(() => { + const footer = document.querySelector('footer') + if (!footer) return '' + // Footer's direct flex child + const flexChild = footer.querySelector('div') + if (!flexChild) return '' + return getComputedStyle(flexChild).flexDirection + }) + + expect(flexDir, 'Footer flex should be row on desktop').toBe('row') + }) + }) + + test.describe('Viewport transition', () => { + test('feature grid changes columns when resizing', async ({ page }) => { + // Start mobile + await page.setViewportSize({ width: 375, height: 812 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + + const mobileColumns = await page.evaluate(() => { + const grids = Array.from(document.querySelectorAll('*')) + for (const el of grids) { + const style = getComputedStyle(el) + if ( + style.display === 'grid' && + el.textContent?.includes('Zero Runtime') + ) { + return style.gridTemplateColumns + .trim() + .split(/\s+/) + .filter((v) => v.length > 0).length + } + } + return 0 + }) + + // Resize to desktop + await page.setViewportSize({ width: 1440, height: 900 }) + // Allow CSS to recompute + await page.waitForTimeout(300) + + const desktopColumns = await page.evaluate(() => { + const grids = Array.from(document.querySelectorAll('*')) + for (const el of grids) { + const style = getComputedStyle(el) + if ( + style.display === 'grid' && + el.textContent?.includes('Zero Runtime') + ) { + return style.gridTemplateColumns + .trim() + .split(/\s+/) + .filter((v) => v.length > 0).length + } + } + return 0 + }) + + expect(mobileColumns, 'Mobile should have 1 column').toBe(1) + expect(desktopColumns, 'Desktop should have 2 columns').toBe(2) + }) + }) +}) diff --git a/e2e/landing-theme.spec.ts b/e2e/landing-theme.spec.ts new file mode 100644 index 00000000..6f2ff709 --- /dev/null +++ b/e2e/landing-theme.spec.ts @@ -0,0 +1,144 @@ +import { expect, test } from '@playwright/test' + +/** + * Normalize a color string to lowercase hex. + */ +function normalizeColor(raw: string): string { + const trimmed = raw.trim().toLowerCase() + if (trimmed.startsWith('#')) { + const hex = trimmed.replace('#', '') + if (hex.length === 3) { + return `#${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}` + } + return `#${hex.substring(0, 6)}` + } + const match = trimmed.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/) + if (match) { + const r = parseInt(match[1], 10).toString(16).padStart(2, '0') + const g = parseInt(match[2], 10).toString(16).padStart(2, '0') + const b = parseInt(match[3], 10).toString(16).padStart(2, '0') + return `#${r}${g}${b}` + } + return trimmed +} + +test.describe('Landing Page - Theme Switching', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test('light theme: --primary is #5A44FF', async ({ page }) => { + // Ensure light theme is active + await page.evaluate(() => + document.documentElement.setAttribute('data-theme', 'light'), + ) + await page.waitForTimeout(100) + + const bodyBg = await page.evaluate( + () => getComputedStyle(document.body).backgroundColor, + ) + + // The body bg should be $footerBg light = #F4F4F6 + expect(normalizeColor(bodyBg)).toBe('#f4f4f6') + }) + + test('light theme: --text resolves to #2F2F2F', async ({ page }) => { + await page.evaluate(() => + document.documentElement.setAttribute('data-theme', 'light'), + ) + await page.waitForTimeout(100) + + const bodyColor = await page.evaluate( + () => getComputedStyle(document.body).color, + ) + expect(normalizeColor(bodyColor)).toBe('#2f2f2f') + }) + + test('dark theme: background changes to #131313', async ({ page }) => { + // Switch to dark theme + await page.evaluate(() => + document.documentElement.setAttribute('data-theme', 'dark'), + ) + await page.waitForTimeout(200) + + // The main content wrapper (inside body) has bg=$background + // which is #131313 in dark. Body itself has $footerBg = #2E303C in dark. + const bodyBg = await page.evaluate( + () => getComputedStyle(document.body).backgroundColor, + ) + + // $footerBg dark = #2E303C + expect(normalizeColor(bodyBg)).toBe('#2e303c') + }) + + test('dark theme: text color changes to #EDEDED', async ({ page }) => { + await page.evaluate(() => + document.documentElement.setAttribute('data-theme', 'dark'), + ) + await page.waitForTimeout(200) + + const bodyColor = await page.evaluate( + () => getComputedStyle(document.body).color, + ) + // $text dark = #EDEDED + expect(normalizeColor(bodyColor)).toBe('#ededed') + }) + + test('dark theme: footer background changes', async ({ page }) => { + // Light footer bg + const lightFooterBg = await page.evaluate(() => { + const footer = document.querySelector('footer') + return footer ? getComputedStyle(footer).backgroundColor : '' + }) + + // Switch to dark + await page.evaluate(() => + document.documentElement.setAttribute('data-theme', 'dark'), + ) + await page.waitForTimeout(200) + + const darkFooterBg = await page.evaluate(() => { + const footer = document.querySelector('footer') + return footer ? getComputedStyle(footer).backgroundColor : '' + }) + + // $footerBg dark = #2E303C + expect(normalizeColor(darkFooterBg)).toBe('#2e303c') + // Ensure it actually changed from the light value + expect(normalizeColor(lightFooterBg)).not.toBe(normalizeColor(darkFooterBg)) + }) + + test('theme can toggle back and forth', async ({ page }) => { + // Start light + await page.evaluate(() => + document.documentElement.setAttribute('data-theme', 'light'), + ) + await page.waitForTimeout(100) + const lightBg = await page.evaluate( + () => getComputedStyle(document.body).backgroundColor, + ) + + // Switch dark + await page.evaluate(() => + document.documentElement.setAttribute('data-theme', 'dark'), + ) + await page.waitForTimeout(200) + const darkBg = await page.evaluate( + () => getComputedStyle(document.body).backgroundColor, + ) + + // Switch back to light + await page.evaluate(() => + document.documentElement.setAttribute('data-theme', 'light'), + ) + await page.waitForTimeout(200) + const lightBgAgain = await page.evaluate( + () => getComputedStyle(document.body).backgroundColor, + ) + + expect(normalizeColor(lightBg)).toBe('#f4f4f6') + expect(normalizeColor(darkBg)).toBe('#2e303c') + expect(normalizeColor(lightBgAgain)).toBe('#f4f4f6') + }) +}) diff --git a/e2e/landing-typography.spec.ts b/e2e/landing-typography.spec.ts new file mode 100644 index 00000000..663217ae --- /dev/null +++ b/e2e/landing-typography.spec.ts @@ -0,0 +1,164 @@ +import { expect, test } from '@playwright/test' + +test.describe('Landing Page - Typography', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test('h1 typography: hero text has correct font-weight (800)', async ({ + page, + }) => { + // The TopBanner h1 contains "Zero Config" text with typography="h1" + // h1 at mobile: fontWeight 800, fontSize 38px + const fontWeight = await page.evaluate(() => { + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_TEXT, + { + acceptNode: (node) => + node.textContent?.includes('Config') + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT, + }, + ) + const node = walker.nextNode() + if (!node?.parentElement) return '' + // Walk up to find the element with typography applied + let el: HTMLElement | null = node.parentElement + while (el && el !== document.body) { + const fw = getComputedStyle(el).fontWeight + if (fw === '800') return fw + el = el.parentElement + } + return getComputedStyle(node.parentElement).fontWeight + }) + + // fontWeight should be 800 (from h1 typography) + expect(fontWeight).toBe('800') + }) + + test('h4 typography: section headings have font-weight 700', async ({ + page, + }) => { + // "Features" heading uses typography="h4" => fontWeight 700 + const fontWeight = await page.evaluate(() => { + const elements = Array.from(document.querySelectorAll('*')) + for (const el of elements) { + if (el.children.length === 0 && el.textContent?.trim() === 'Features') { + return getComputedStyle(el).fontWeight + } + } + return '' + }) + + expect(fontWeight).toBe('700') + }) + + test('body typography: feature descriptions have font-weight 500', async ({ + page, + }) => { + // Feature card descriptions use typography="body" => fontWeight 500 + const fontWeight = await page.evaluate(() => { + const elements = Array.from(document.querySelectorAll('*')) + for (const el of elements) { + if ( + el.children.length === 0 && + el.textContent?.includes('futuristic design') + ) { + return getComputedStyle(el).fontWeight + } + } + return '' + }) + + expect(fontWeight).toBe('500') + }) + + test('font-family declarations include Pretendard', async ({ page }) => { + // Check that the body or main text elements have Pretendard in font-family + const fontFamily = await page.evaluate(() => { + const el = Array.from(document.querySelectorAll('*')).find( + (el) => + el.children.length === 0 && el.textContent?.trim() === 'Features', + ) + return el ? getComputedStyle(el).fontFamily : '' + }) + + expect( + fontFamily.toLowerCase(), + 'Font family should include Pretendard', + ).toContain('pretendard') + }) + + test('h1 font-size is correct at mobile viewport', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + + // h1 at mobile: fontSize 38px + const fontSize = await page.evaluate(() => { + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_TEXT, + { + acceptNode: (node) => + node.textContent?.includes('Config') + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT, + }, + ) + const node = walker.nextNode() + if (!node?.parentElement) return '' + let el: HTMLElement | null = node.parentElement + while (el && el !== document.body) { + const fs = getComputedStyle(el).fontSize + if (fs === '38px') return fs + el = el.parentElement + } + return getComputedStyle(node.parentElement).fontSize + }) + + expect(fontSize).toBe('38px') + }) + + test('h4 font-size is correct at mobile viewport', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + + // h4 at mobile: fontSize 28px + const fontSize = await page.evaluate(() => { + const elements = Array.from(document.querySelectorAll('*')) + for (const el of elements) { + if (el.children.length === 0 && el.textContent?.trim() === 'Features') { + return getComputedStyle(el).fontSize + } + } + return '' + }) + + expect(fontSize).toBe('28px') + }) + + test('letter-spacing is set to -0.03em across typography', async ({ + page, + }) => { + // Most typography definitions use letterSpacing -0.03em + const letterSpacing = await page.evaluate(() => { + const el = Array.from(document.querySelectorAll('*')).find( + (el) => + el.children.length === 0 && el.textContent?.trim() === 'Features', + ) + if (!el) return '' + const fs = parseFloat(getComputedStyle(el).fontSize) + const ls = parseFloat(getComputedStyle(el).letterSpacing) + // -0.03em = fontSize * -0.03 + const expected = fs * -0.03 + // Return the ratio for comparison + return Math.abs(ls - expected) < 0.5 ? 'correct' : `${ls} vs ${expected}` + }) + + expect(letterSpacing).toBe('correct') + }) +}) diff --git a/e2e/landing-visual-dark.spec.ts b/e2e/landing-visual-dark.spec.ts new file mode 100644 index 00000000..fda58bf3 --- /dev/null +++ b/e2e/landing-visual-dark.spec.ts @@ -0,0 +1,131 @@ +import { expect, test } from '@playwright/test' + +/** + * Mock the GitHub API to return a fixed star count so screenshots are deterministic. + */ +async function mockGitHubStars(page: import('@playwright/test').Page) { + await page.route('**/api.github.com/repos/dev-five-git/devup-ui', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + stargazers_count: 1234, + full_name: 'dev-five-git/devup-ui', + }), + }), + ) +} + +async function enableDarkMode(page: import('@playwright/test').Page) { + await page.emulateMedia({ colorScheme: 'dark' }) +} + +async function setDarkThemeAttribute(page: import('@playwright/test').Page) { + await page.evaluate(() => + document.documentElement.setAttribute('data-theme', 'dark'), + ) + await page.waitForTimeout(300) +} + +test.describe('Landing Page - Dark Mode Visual Regression', () => { + test.describe('Full page screenshots (dark)', () => { + test('full page dark at mobile (375px)', async ({ page }) => { + await mockGitHubStars(page) + await enableDarkMode(page) + await page.setViewportSize({ width: 375, height: 812 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + await setDarkThemeAttribute(page) + await page.waitForTimeout(1000) + + await expect(page).toHaveScreenshot('dark-full-page-mobile.png', { + fullPage: true, + }) + }) + + test('full page dark at desktop (1440px)', async ({ page }) => { + await mockGitHubStars(page) + await enableDarkMode(page) + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + await setDarkThemeAttribute(page) + await page.waitForTimeout(1000) + + await expect(page).toHaveScreenshot('dark-full-page-desktop.png', { + fullPage: true, + }) + }) + }) + + test.describe('Section screenshots (dark)', () => { + test.beforeEach(async ({ page }) => { + await mockGitHubStars(page) + await enableDarkMode(page) + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + await setDarkThemeAttribute(page) + await page.waitForTimeout(1000) + }) + + test('TopBanner section (dark)', async ({ page }) => { + const topBanner = page + .locator('div') + .filter({ + hasText: /Zero Config/, + }) + .first() + + await expect(topBanner).toBeVisible() + await expect(topBanner).toHaveScreenshot('dark-section-top-banner.png') + }) + + test('Feature section (dark)', async ({ page }) => { + const featureHeading = page.getByText('Features', { exact: true }).first() + await featureHeading.scrollIntoViewIfNeeded() + await page.waitForTimeout(300) + + const featureSection = featureHeading + .locator('..') + .locator('..') + .locator('..') + + await expect(featureSection).toHaveScreenshot('dark-section-features.png') + }) + + test('Bench section (dark)', async ({ page }) => { + const benchHeading = page.getByText('Comparison Bechmarks').first() + await benchHeading.scrollIntoViewIfNeeded() + await page.waitForTimeout(300) + + const benchSection = benchHeading + .locator('..') + .locator('..') + .locator('..') + + await expect(benchSection).toHaveScreenshot('dark-section-bench.png') + }) + + test('Discord section (dark)', async ({ page }) => { + const discordHeading = page.getByText('Join our community').first() + await discordHeading.scrollIntoViewIfNeeded() + await page.waitForTimeout(300) + + const discordSection = discordHeading + .locator('..') + .locator('..') + .locator('..') + + await expect(discordSection).toHaveScreenshot('dark-section-discord.png') + }) + + test('Footer section (dark)', async ({ page }) => { + const footer = page.locator('footer') + await footer.scrollIntoViewIfNeeded() + await page.waitForTimeout(300) + + await expect(footer).toHaveScreenshot('dark-section-footer.png') + }) + }) +}) diff --git a/e2e/landing-visual-dark.spec.ts-snapshots/dark-full-page-desktop.png b/e2e/landing-visual-dark.spec.ts-snapshots/dark-full-page-desktop.png new file mode 100644 index 00000000..556d79d3 Binary files /dev/null and b/e2e/landing-visual-dark.spec.ts-snapshots/dark-full-page-desktop.png differ diff --git a/e2e/landing-visual-dark.spec.ts-snapshots/dark-full-page-mobile.png b/e2e/landing-visual-dark.spec.ts-snapshots/dark-full-page-mobile.png new file mode 100644 index 00000000..e8d5078f Binary files /dev/null and b/e2e/landing-visual-dark.spec.ts-snapshots/dark-full-page-mobile.png differ diff --git a/e2e/landing-visual-dark.spec.ts-snapshots/dark-section-bench.png b/e2e/landing-visual-dark.spec.ts-snapshots/dark-section-bench.png new file mode 100644 index 00000000..c533c63d Binary files /dev/null and b/e2e/landing-visual-dark.spec.ts-snapshots/dark-section-bench.png differ diff --git a/e2e/landing-visual-dark.spec.ts-snapshots/dark-section-discord.png b/e2e/landing-visual-dark.spec.ts-snapshots/dark-section-discord.png new file mode 100644 index 00000000..53ffd9f8 Binary files /dev/null and b/e2e/landing-visual-dark.spec.ts-snapshots/dark-section-discord.png differ diff --git a/e2e/landing-visual-dark.spec.ts-snapshots/dark-section-features.png b/e2e/landing-visual-dark.spec.ts-snapshots/dark-section-features.png new file mode 100644 index 00000000..b78654fb Binary files /dev/null and b/e2e/landing-visual-dark.spec.ts-snapshots/dark-section-features.png differ diff --git a/e2e/landing-visual-dark.spec.ts-snapshots/dark-section-footer.png b/e2e/landing-visual-dark.spec.ts-snapshots/dark-section-footer.png new file mode 100644 index 00000000..ba4057d1 Binary files /dev/null and b/e2e/landing-visual-dark.spec.ts-snapshots/dark-section-footer.png differ diff --git a/e2e/landing-visual-dark.spec.ts-snapshots/dark-section-top-banner.png b/e2e/landing-visual-dark.spec.ts-snapshots/dark-section-top-banner.png new file mode 100644 index 00000000..9010ed39 Binary files /dev/null and b/e2e/landing-visual-dark.spec.ts-snapshots/dark-section-top-banner.png differ diff --git a/e2e/landing-visual-hover.spec.ts b/e2e/landing-visual-hover.spec.ts new file mode 100644 index 00000000..366218e3 --- /dev/null +++ b/e2e/landing-visual-hover.spec.ts @@ -0,0 +1,118 @@ +import { expect, test } from '@playwright/test' + +/** + * Mock the GitHub API to return a fixed star count so screenshots are deterministic. + */ +async function mockGitHubStars(page: import('@playwright/test').Page) { + await page.route('**/api.github.com/repos/dev-five-git/devup-ui', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + stargazers_count: 1234, + full_name: 'dev-five-git/devup-ui', + }), + }), + ) +} + +test.describe('Landing Page - Hover State Visual Regression', () => { + test.beforeEach(async ({ page }) => { + await mockGitHubStars(page) + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + await page.waitForTimeout(1000) + }) + + test('GetStarted button hover screenshot', async ({ page }) => { + const getStartedLink = page.locator('a', { hasText: 'Get started' }).first() + await expect(getStartedLink).toBeVisible() + + await expect(getStartedLink).toHaveScreenshot( + 'hover-get-started-before.png', + ) + + await getStartedLink.hover() + await page.waitForTimeout(300) + + await expect(getStartedLink).toHaveScreenshot('hover-get-started-after.png') + }) + + test('Star button hover screenshot', async ({ page }) => { + const starLink = page.locator('a', { hasText: /Star/i }).first() + await expect(starLink).toBeVisible() + + await expect(starLink).toHaveScreenshot('hover-star-before.png') + + await starLink.hover() + await page.waitForTimeout(300) + + await expect(starLink).toHaveScreenshot('hover-star-after.png') + }) + + test('Sponsor button hover screenshot', async ({ page }) => { + const sponsorLink = page.locator('a', { hasText: /Sponsor/i }).first() + await expect(sponsorLink).toBeVisible() + + await expect(sponsorLink).toHaveScreenshot('hover-sponsor-before.png') + + await sponsorLink.hover() + await page.waitForTimeout(300) + + await expect(sponsorLink).toHaveScreenshot('hover-sponsor-after.png') + }) + + test('Discord button hover screenshot', async ({ page }) => { + const discordLink = page.getByRole('link', { name: /Join our Discord/i }) + await discordLink.scrollIntoViewIfNeeded() + await expect(discordLink).toBeVisible() + + await expect(discordLink).toHaveScreenshot('hover-discord-before.png') + + await discordLink.hover() + await page.waitForTimeout(300) + + await expect(discordLink).toHaveScreenshot('hover-discord-after.png') + }) + + test('KakaoTalk button hover screenshot', async ({ page }) => { + const kakaoLink = page.getByRole('link', { name: /Open KakaoTalk/i }) + await kakaoLink.scrollIntoViewIfNeeded() + await expect(kakaoLink).toBeVisible() + + await expect(kakaoLink).toHaveScreenshot('hover-kakao-before.png') + + await kakaoLink.hover() + await page.waitForTimeout(300) + + await expect(kakaoLink).toHaveScreenshot('hover-kakao-after.png') + }) + + test('Feature card hover screenshot', async ({ page }) => { + const featureHeading = page.getByText('Features', { exact: true }).first() + await featureHeading.scrollIntoViewIfNeeded() + await page.waitForTimeout(300) + + // Find the first feature card in the grid + const featureSection = featureHeading + .locator('..') + .locator('..') + .locator('..') + + // Get the first card-like element in the feature section + const firstCard = featureSection + .locator('div[class]') + .filter({ + hasText: /Zero Runtime|Zero Config|Smallest/, + }) + .first() + + await expect(firstCard).toHaveScreenshot('hover-feature-card-before.png') + + await firstCard.hover() + await page.waitForTimeout(300) + + await expect(firstCard).toHaveScreenshot('hover-feature-card-after.png') + }) +}) diff --git a/e2e/landing-visual-hover.spec.ts-snapshots/hover-discord-after.png b/e2e/landing-visual-hover.spec.ts-snapshots/hover-discord-after.png new file mode 100644 index 00000000..43726d00 Binary files /dev/null and b/e2e/landing-visual-hover.spec.ts-snapshots/hover-discord-after.png differ diff --git a/e2e/landing-visual-hover.spec.ts-snapshots/hover-discord-before.png b/e2e/landing-visual-hover.spec.ts-snapshots/hover-discord-before.png new file mode 100644 index 00000000..dc6d2aa4 Binary files /dev/null and b/e2e/landing-visual-hover.spec.ts-snapshots/hover-discord-before.png differ diff --git a/e2e/landing-visual-hover.spec.ts-snapshots/hover-feature-card-after.png b/e2e/landing-visual-hover.spec.ts-snapshots/hover-feature-card-after.png new file mode 100644 index 00000000..3af11a95 Binary files /dev/null and b/e2e/landing-visual-hover.spec.ts-snapshots/hover-feature-card-after.png differ diff --git a/e2e/landing-visual-hover.spec.ts-snapshots/hover-feature-card-before.png b/e2e/landing-visual-hover.spec.ts-snapshots/hover-feature-card-before.png new file mode 100644 index 00000000..3af11a95 Binary files /dev/null and b/e2e/landing-visual-hover.spec.ts-snapshots/hover-feature-card-before.png differ diff --git a/e2e/landing-visual-hover.spec.ts-snapshots/hover-get-started-after.png b/e2e/landing-visual-hover.spec.ts-snapshots/hover-get-started-after.png new file mode 100644 index 00000000..76ab4adc Binary files /dev/null and b/e2e/landing-visual-hover.spec.ts-snapshots/hover-get-started-after.png differ diff --git a/e2e/landing-visual-hover.spec.ts-snapshots/hover-get-started-before.png b/e2e/landing-visual-hover.spec.ts-snapshots/hover-get-started-before.png new file mode 100644 index 00000000..47d4f2bb Binary files /dev/null and b/e2e/landing-visual-hover.spec.ts-snapshots/hover-get-started-before.png differ diff --git a/e2e/landing-visual-hover.spec.ts-snapshots/hover-kakao-after.png b/e2e/landing-visual-hover.spec.ts-snapshots/hover-kakao-after.png new file mode 100644 index 00000000..62b0b1a5 Binary files /dev/null and b/e2e/landing-visual-hover.spec.ts-snapshots/hover-kakao-after.png differ diff --git a/e2e/landing-visual-hover.spec.ts-snapshots/hover-kakao-before.png b/e2e/landing-visual-hover.spec.ts-snapshots/hover-kakao-before.png new file mode 100644 index 00000000..a9deaf54 Binary files /dev/null and b/e2e/landing-visual-hover.spec.ts-snapshots/hover-kakao-before.png differ diff --git a/e2e/landing-visual-hover.spec.ts-snapshots/hover-sponsor-after.png b/e2e/landing-visual-hover.spec.ts-snapshots/hover-sponsor-after.png new file mode 100644 index 00000000..715e03fa Binary files /dev/null and b/e2e/landing-visual-hover.spec.ts-snapshots/hover-sponsor-after.png differ diff --git a/e2e/landing-visual-hover.spec.ts-snapshots/hover-sponsor-before.png b/e2e/landing-visual-hover.spec.ts-snapshots/hover-sponsor-before.png new file mode 100644 index 00000000..52c30e08 Binary files /dev/null and b/e2e/landing-visual-hover.spec.ts-snapshots/hover-sponsor-before.png differ diff --git a/e2e/landing-visual-hover.spec.ts-snapshots/hover-star-after.png b/e2e/landing-visual-hover.spec.ts-snapshots/hover-star-after.png new file mode 100644 index 00000000..76ab4adc Binary files /dev/null and b/e2e/landing-visual-hover.spec.ts-snapshots/hover-star-after.png differ diff --git a/e2e/landing-visual-hover.spec.ts-snapshots/hover-star-before.png b/e2e/landing-visual-hover.spec.ts-snapshots/hover-star-before.png new file mode 100644 index 00000000..47d4f2bb Binary files /dev/null and b/e2e/landing-visual-hover.spec.ts-snapshots/hover-star-before.png differ diff --git a/e2e/landing-visual.spec.ts b/e2e/landing-visual.spec.ts new file mode 100644 index 00000000..268680a3 --- /dev/null +++ b/e2e/landing-visual.spec.ts @@ -0,0 +1,118 @@ +import { expect, test } from '@playwright/test' + +/** + * Mock the GitHub API to return a fixed star count so screenshots are deterministic. + * StarButton fetches: https://api.github.com/repos/dev-five-git/devup-ui + */ +async function mockGitHubStars(page: import('@playwright/test').Page) { + await page.route('**/api.github.com/repos/dev-five-git/devup-ui', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + stargazers_count: 1234, + full_name: 'dev-five-git/devup-ui', + }), + }), + ) +} + +test.describe('Landing Page - Visual Regression', () => { + test.describe('Full page screenshots', () => { + test('full page at mobile (375px)', async ({ page }) => { + await mockGitHubStars(page) + await page.setViewportSize({ width: 375, height: 812 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + // Wait for fonts and images + await page.waitForTimeout(1000) + + await expect(page).toHaveScreenshot('full-page-mobile.png', { + fullPage: true, + }) + }) + + test('full page at desktop (1440px)', async ({ page }) => { + await mockGitHubStars(page) + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + await page.waitForTimeout(1000) + + await expect(page).toHaveScreenshot('full-page-desktop.png', { + fullPage: true, + }) + }) + }) + + test.describe('Section screenshots at desktop', () => { + test.beforeEach(async ({ page }) => { + await mockGitHubStars(page) + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + await page.waitForTimeout(1000) + }) + + test('TopBanner section', async ({ page }) => { + // TopBanner is the first section containing "Zero Config" + const topBanner = page + .locator('div') + .filter({ + hasText: /Zero Config/, + }) + .first() + + await expect(topBanner).toBeVisible() + await expect(topBanner).toHaveScreenshot('section-top-banner.png') + }) + + test('Feature section', async ({ page }) => { + const featureHeading = page.getByText('Features', { exact: true }).first() + await featureHeading.scrollIntoViewIfNeeded() + await page.waitForTimeout(300) + + // Get the feature section container + const featureSection = featureHeading + .locator('..') + .locator('..') + .locator('..') + + await expect(featureSection).toHaveScreenshot('section-features.png') + }) + + test('Bench section', async ({ page }) => { + const benchHeading = page.getByText('Comparison Bechmarks').first() + await benchHeading.scrollIntoViewIfNeeded() + await page.waitForTimeout(300) + + const benchSection = benchHeading + .locator('..') + .locator('..') + .locator('..') + + await expect(benchSection).toHaveScreenshot('section-bench.png') + }) + + test('Discord section', async ({ page }) => { + const discordHeading = page.getByText('Join our community').first() + await discordHeading.scrollIntoViewIfNeeded() + await page.waitForTimeout(300) + + const discordSection = discordHeading + .locator('..') + .locator('..') + .locator('..') + + await expect(discordSection).toHaveScreenshot('section-discord.png') + }) + + test('Footer section', async ({ page }) => { + const footer = page.locator('footer') + await footer.scrollIntoViewIfNeeded() + await page.waitForTimeout(300) + + await expect(footer).toHaveScreenshot('section-footer.png') + }) + }) +}) diff --git a/e2e/landing-visual.spec.ts-snapshots/full-page-desktop.png b/e2e/landing-visual.spec.ts-snapshots/full-page-desktop.png new file mode 100644 index 00000000..70990968 Binary files /dev/null and b/e2e/landing-visual.spec.ts-snapshots/full-page-desktop.png differ diff --git a/e2e/landing-visual.spec.ts-snapshots/full-page-mobile.png b/e2e/landing-visual.spec.ts-snapshots/full-page-mobile.png new file mode 100644 index 00000000..d2b2478b Binary files /dev/null and b/e2e/landing-visual.spec.ts-snapshots/full-page-mobile.png differ diff --git a/e2e/landing-visual.spec.ts-snapshots/section-bench.png b/e2e/landing-visual.spec.ts-snapshots/section-bench.png new file mode 100644 index 00000000..c3a845e3 Binary files /dev/null and b/e2e/landing-visual.spec.ts-snapshots/section-bench.png differ diff --git a/e2e/landing-visual.spec.ts-snapshots/section-discord.png b/e2e/landing-visual.spec.ts-snapshots/section-discord.png new file mode 100644 index 00000000..570b65c2 Binary files /dev/null and b/e2e/landing-visual.spec.ts-snapshots/section-discord.png differ diff --git a/e2e/landing-visual.spec.ts-snapshots/section-features.png b/e2e/landing-visual.spec.ts-snapshots/section-features.png new file mode 100644 index 00000000..6ed75153 Binary files /dev/null and b/e2e/landing-visual.spec.ts-snapshots/section-features.png differ diff --git a/e2e/landing-visual.spec.ts-snapshots/section-footer.png b/e2e/landing-visual.spec.ts-snapshots/section-footer.png new file mode 100644 index 00000000..2482afe7 Binary files /dev/null and b/e2e/landing-visual.spec.ts-snapshots/section-footer.png differ diff --git a/e2e/landing-visual.spec.ts-snapshots/section-top-banner.png b/e2e/landing-visual.spec.ts-snapshots/section-top-banner.png new file mode 100644 index 00000000..437b9834 Binary files /dev/null and b/e2e/landing-visual.spec.ts-snapshots/section-top-banner.png differ diff --git a/e2e/landing-zero-runtime.spec.ts b/e2e/landing-zero-runtime.spec.ts new file mode 100644 index 00000000..bb496a83 --- /dev/null +++ b/e2e/landing-zero-runtime.spec.ts @@ -0,0 +1,177 @@ +import { expect, test } from '@playwright/test' + +test.describe('Landing Page - Zero Runtime Validation', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test('no dynamically injected