diff --git a/.github/workflows/comment-pr.js b/.github/workflows/comment-pr.js new file mode 100644 index 0000000..e4bdc0c --- /dev/null +++ b/.github/workflows/comment-pr.js @@ -0,0 +1,36 @@ +// Called by the "Comment on PR" step in playwright-e2e.yml via actions/github-script. +// Required env vars (injected by the workflow): +// JOB_STATUS – the value of ${{ job.status }} + +module.exports = async ({ github, context }) => { + const { owner, repo } = context.repo; + const runId = context.runId; + const runUrl = `https://github.com/${owner}/${repo}/actions/runs/${runId}`; + const status = process.env.JOB_STATUS; + const emoji = status === 'success' ? '✅' : '❌'; + const statusText = status === 'success' ? 'All tests passed' : 'Some tests failed'; + + const body = [ + `## ${emoji} Playwright E2E — ${statusText}`, + '', + `| Detail | Link |`, + `|--------|------|`, + `| Workflow run | [View run](${runUrl}) |`, + `| HTML report | Download the **playwright-report** artifact from the run page above |`, + `| Traces & videos | Download the **playwright-results** artifact (attached when tests fail) |`, + '', + `> **How to view the HTML report locally:**`, + `> 1. Download and unzip the \`playwright-report\` artifact.`, + `> 2. Run \`bunx playwright show-report \` to open it in your browser.`, + '', + `> Traces and videos are captured **on first retry** (failures only).`, + ].join('\n'); + + // Always post a new comment so each commit push gets its own status entry + await github.rest.issues.createComment({ + owner, + repo, + issue_number: context.issue.number, + body, + }); +}; diff --git a/.github/workflows/playwright-e2e.yml b/.github/workflows/playwright-e2e.yml new file mode 100644 index 0000000..f3e7990 --- /dev/null +++ b/.github/workflows/playwright-e2e.yml @@ -0,0 +1,78 @@ +name: Playwright E2E + +on: + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + +concurrency: + group: e2e-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e: + timeout-minutes: 30 + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write # needed for the PR comment step + + steps: + - uses: actions/checkout@v4 + + # Astro requires Node >=22.12.0 (see .node-version / package.json engines) + - uses: actions/setup-node@v4 + with: + node-version-file: ".node-version" + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + # Install only Chromium in CI – matches playwright.config.ts project list + - name: Install Playwright browsers + run: bunx playwright install --with-deps chromium + + # Build the Astro site and start the preview server on port 4321 + - name: Build Astro site + run: bun run build + + - name: Run Playwright E2E tests + run: bunx playwright test + env: + CI: "true" + PLAYWRIGHT_BASE_URL: "http://localhost:4321" + + # Always upload artifacts so failures can be investigated + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 14 + + - name: Upload test results (traces, videos, screenshots) + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-results + path: test-results/ + retention-days: 14 + + # Comment on the PR with run status and artifact links. + # Uses github-script so it works from fork PRs without leaking secrets + # (GITHUB_TOKEN is scoped to the target repo). + - name: Comment on PR + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + env: + JOB_STATUS: ${{ job.status }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const script = require('./.github/workflows/comment-pr.js') + await script({ github, context, core }) diff --git a/.gitignore b/.gitignore index 30bfc78..400724f 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,7 @@ pnpm-debug.log* # Maestro Setup .maestro/ + +# E2E Tests - Playwright +test-results/ +playwright-report/ diff --git a/.opencode/learnings/registry.md b/.opencode/learnings/registry.md index a83bb03..79ffde9 100644 --- a/.opencode/learnings/registry.md +++ b/.opencode/learnings/registry.md @@ -38,4 +38,13 @@ All captured learnings from the AI Diamond Chain are registered here. - **Source:** User request for tmux skill for detached session control - **Summary:** Created tmux-automation skill with commands for creating detached sessions, sending keys programmatically, capturing output, and session management - **Location:** `.opencode/skills/tmux-automation/SKILL.md` +- **Confidence:** 🟢 High + +--- + +## 2026-04-18 - DaisyUI v5 Custom Theme Configuration +- **Type:** skill +- **Source:** Implementation Diamond - Custom theme creation for PodCodar brand +- **Summary:** Learned DaisyUI v5's new `@plugin "daisyui/theme"` syntax with OKLCH color format, created light/dark theme pair with PodCodar brand colors (tech blues + warm amber accents), disabled default themes for clean implementation +- **Location:** `.opencode/skills/daisyui-v5-themes/SKILL.md`, `src/styles/global.css` - **Confidence:** 🟢 High \ No newline at end of file diff --git a/.opencode/skills/daisyui-v5-themes/SKILL.md b/.opencode/skills/daisyui-v5-themes/SKILL.md new file mode 100644 index 0000000..efee808 --- /dev/null +++ b/.opencode/skills/daisyui-v5-themes/SKILL.md @@ -0,0 +1,191 @@ +# DaisyUI v5 Custom Theme Configuration + +Creating custom themes in DaisyUI v5 (2026 best practices). + +## Overview + +DaisyUI v5 uses a new `@plugin` syntax for defining custom themes with OKLCH color format for better perceptual uniformity. + +## Important: Verify Brand Assets First! + +⚠️ **Always check actual brand assets (logos, mascots, brand guidelines) before defining colors.** + +Don't assume colors based on industry stereotypes (e.g., "tech company = blue"). Extract colors from: +- Logo files (check for fill attributes or visual colors) +- Brand mascot images +- Existing brand guidelines +- Official website colors + +**Example:** PodCodar is a tech community, but their llama mascot is **purple/violet**, not blue! + +## Syntax + +```css +@import "tailwindcss"; + +/* Disable default themes */ +@plugin "daisyui" { + themes: false; +} + +/* Define custom theme */ +@plugin "daisyui/theme" { + name: "my-theme"; + default: true; + prefersdark: false; + color-scheme: light; + + /* Colors */ + --color-base-100: oklch(98% 0.005 260); + --color-base-200: oklch(96% 0.01 260); + --color-base-300: oklch(90% 0.015 260); + --color-base-content: oklch(20% 0.02 260); + + --color-primary: oklch(55% 0.18 250); + --color-primary-content: oklch(98% 0.005 260); + + --color-secondary: oklch(70% 0.15 70); + --color-secondary-content: oklch(20% 0.02 70); + + --color-accent: oklch(65% 0.18 195); + --color-accent-content: oklch(98% 0.005 260); + + --color-neutral: oklch(40% 0.03 260); + --color-neutral-content: oklch(98% 0.005 260); + + --color-info: oklch(70% 0.15 240); + --color-info-content: oklch(20% 0.02 240); + + --color-success: oklch(65% 0.15 145); + --color-success-content: oklch(98% 0.005 145); + + --color-warning: oklch(75% 0.15 85); + --color-warning-content: oklch(20% 0.02 85); + + --color-error: oklch(60% 0.18 25); + --color-error-content: oklch(98% 0.005 25); + + /* Design tokens */ + --radius-selector: 0.5rem; + --radius-field: 0.375rem; + --radius-box: 0.75rem; + + --size-selector: 0.25rem; + --size-field: 0.25rem; + + --border: 1px; + --depth: 1; + --noise: 0; +} +``` + +## Key Concepts + +### Color Format: OKLCH + +OKLCH is preferred over hex/RGB for: +- Better perceptual uniformity +- Easier to adjust lightness without changing perceived hue +- Consistent contrast ratios across color wheel + +Format: `oklch(L% C H)` where: +- L = Lightness (0-100%) +- C = Chroma (0-0.4 typical range) +- H = Hue (0-360 degrees) + +### Theme Properties + +| Property | Description | +|----------|-------------| +| `name` | Theme identifier (kebab-case) | +| `default` | Set as default theme | +| `prefersdark` | Auto-activate on `prefers-color-scheme: dark` | +| `color-scheme` | Browser UI color (light/dark) | + +### Color Variables + +**Base Layers (Backgrounds)** +- `--color-base-100`: Main background +- `--color-base-200`: Secondary background (cards, hover) +- `--color-base-300`: Tertiary background (borders, dividers) +- `--color-base-content`: Primary text color + +**Semantic Colors** +- `--color-primary`: Main brand color (buttons, links) +- `--color-secondary`: Complementary accent +- `--color-accent`: Highlight/CTA color +- `--color-neutral`: Grayscale base + +**Status Colors** +- `--color-info`: Informational states +- `--color-success`: Success states +- `--color-warning`: Warning states +- `--color-error`: Error states + +Each semantic/status color has a `-content` variant for text/icons on that background. + +### Design Tokens + +| Token | Description | +|-------|-------------| +| `--radius-selector` | Checkboxes, toggles, badges | +| `--radius-field` | Buttons, inputs, tabs | +| `--radius-box` | Cards, modals, alerts | +| `--size-selector` | Checkbox/toggle base size | +| `--size-field` | Button/input base size | +| `--border` | Global border width | +| `--depth` | 3D depth effect (0-1) | +| `--noise` | Noise texture overlay (0-1) | + +## Best Practices + +1. **Disable default themes** when using custom themes (`themes: false`) +2. **Define both light and dark variants** for complete coverage +3. **Use consistent hue families** across base layers (e.g., all blue-tinted) +4. **Ensure contrast ratios** meet WCAG AA (4.5:1 for text) +5. **Test with real components** using the DaisyUI theme generator +6. **Keep chroma low** for base layers (0.005-0.03) to avoid color casts +7. **Higher chroma** for primary/accent colors (0.15-0.25) for vibrancy + +## Dark Mode Pattern + +```css +/* Light theme (default) */ +@plugin "daisyui/theme" { + name: "my-light"; + default: true; + prefersdark: false; + color-scheme: light; + /* ... */ +} + +/* Dark theme (auto-switch) */ +@plugin "daisyui/theme" { + name: "my-dark"; + default: false; + prefersdark: true; + color-scheme: dark; + /* Reverse base layers, adjust for dark */ + --color-base-100: oklch(18% 0.02 260); + --color-base-200: oklch(23% 0.025 260); + --color-base-300: oklch(30% 0.03 260); + --color-base-content: oklch(95% 0.015 260); + /* Brighter primaries for dark mode */ + --color-primary: oklch(70% 0.15 250); + /* ... */ +} +``` + +## Theme Generator Tool + +Use https://daisyui.com/theme-generator/ to: +- Visually design themes +- Export OKLCH values +- Preview component variants +- Copy-paste ready CSS + +## References + +- [DaisyUI Themes Docs](https://daisyui.com/docs/themes/) +- [DaisyUI Theme Generator](https://daisyui.com/theme-generator/) +- [OKLCH Color Picker](https://oklch.com/) diff --git a/biome.json b/biome.json index 3f21f7e..23e01ac 100644 --- a/biome.json +++ b/biome.json @@ -22,6 +22,7 @@ }, "formatter": { "enabled": true, + "useEditorconfig": true, "indentStyle": "tab", "indentWidth": 2, "lineWidth": 100, diff --git a/bun.lock b/bun.lock index 28c7239..f6852dd 100644 --- a/bun.lock +++ b/bun.lock @@ -20,7 +20,9 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.12", + "@playwright/test": "^1.59.1", "lefthook": "^2.1.6", + "playwright": "^1.59.1", "vitest": "^4.1.4", }, }, @@ -228,6 +230,8 @@ "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], + "@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="], + "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="], @@ -906,6 +910,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.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], + + "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], + "postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], diff --git a/e2e/blog.spec.ts b/e2e/blog.spec.ts new file mode 100644 index 0000000..329e89a --- /dev/null +++ b/e2e/blog.spec.ts @@ -0,0 +1,72 @@ +import { expect, test } from '@playwright/test'; + +// ────────────────────────────────────────────────────────────────────────────── +// Blog +// ────────────────────────────────────────────────────────────────────────────── + +test.describe('Blog', () => { + test('navigates to blog homepage via nav link', async ({ page }) => { + await page.goto('/'); + + // Primary nav has a "Blog" link + await page + .getByRole('navigation', { name: 'Primary' }) + .getByRole('link', { name: /^blog$/i }) + .click(); + + await expect(page).toHaveURL(/\/blog/); + + // The blog index page should list at least one post + const postLinks = page.getByRole('link').filter({ hasText: /./u }); + await expect(postLinks.first()).toBeVisible(); + }); + + test('blog homepage lists posts', async ({ page }) => { + await page.goto('/blog'); + + await expect(page).toHaveTitle(/PodCodar/i); + + // There should be at least one article/post link on the listing page + const firstPost = page.locator('section ul li a').first(); + await expect(firstPost).toBeVisible(); + }); + + test('opens the first blog post from the listing', async ({ page }) => { + await page.goto('/blog'); + + // Click the very first post link in the listing + const firstPost = page.locator('section ul li a').first(); + await expect(firstPost).toBeVisible(); + + const postTitle = await firstPost.textContent(); + await firstPost.click(); + + // We should be on a blog post URL + await expect(page).toHaveURL(/\/blog\/.+/); + + // The post page should have a

matching the post title text + if (postTitle) { + await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible(); + } + }); + + test('scrolls down the first blog post', async ({ page }) => { + await page.goto('/blog'); + + // Open the first post + await page.locator('section ul li a').first().click(); + await expect(page).toHaveURL(/\/blog\/.+/); + + // Confirm the post heading is visible + await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible(); + + // Scroll to the bottom of the post + await page.evaluate(() => + window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }) + ); + + // Footer should come into view + const footer = page.locator('footer'); + await expect(footer).toBeInViewport(); + }); +}); diff --git a/e2e/site.spec.ts b/e2e/site.spec.ts new file mode 100644 index 0000000..68f6a45 --- /dev/null +++ b/e2e/site.spec.ts @@ -0,0 +1,112 @@ +import { expect, test } from '@playwright/test'; + +// ────────────────────────────────────────────────────────────────────────────── +// Homepage +// ────────────────────────────────────────────────────────────────────────────── + +test.describe('Homepage', () => { + test('has page title and hero section', async ({ page }) => { + await page.goto('/'); + + // The document title should mention PodCodar + await expect(page).toHaveTitle(/PodCodar/i); + + // Hero heading is visible + const heroHeading = page.locator('#hero-heading'); + await expect(heroHeading).toBeVisible(); + }); + + test('scrolls down the homepage', async ({ page }) => { + await page.goto('/'); + + // Confirm hero is visible before scrolling + await expect(page.locator('#hero-heading')).toBeVisible(); + + // Scroll to the bottom of the page + await page.evaluate(() => + window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }) + ); + + // Footer is now in view + const footer = page.locator('footer'); + await expect(footer).toBeInViewport(); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// Navigation: Contributing page (/contributing) +// ────────────────────────────────────────────────────────────────────────────── + +test.describe('Contributing page', () => { + test('navigates to Contributing page via header CTA', async ({ page }) => { + await page.goto('/'); + + // The header CTA "Como posso ajudar?" links to /contributing + await page + .getByRole('link', { name: /como posso ajudar/i }) + .first() + .click(); + + await expect(page).toHaveURL(/\/contributing/); + await expect(page.getByRole('heading', { name: /como posso ajudar/i, level: 1 })).toBeVisible(); + }); + + test('can navigate directly to /contributing', async ({ page }) => { + await page.goto('/contributing'); + + await expect(page).toHaveTitle(/PodCodar/i); + await expect(page.getByRole('heading', { name: /como posso ajudar/i, level: 1 })).toBeVisible(); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// Navigation: Join Us page (/join-us) +// ────────────────────────────────────────────────────────────────────────────── + +test.describe('Join Us page', () => { + test('navigates to Join Us page via header CTA', async ({ page }) => { + await page.goto('/'); + + // The header primary CTA "Faça parte!" links to /join-us + await page + .getByRole('link', { name: /faça parte/i }) + .first() + .click(); + + await expect(page).toHaveURL(/\/join-us/); + await expect(page.getByRole('heading', { name: /faça parte/i, level: 1 })).toBeVisible(); + }); + + test('can navigate directly to /join-us', async ({ page }) => { + await page.goto('/join-us'); + + await expect(page).toHaveTitle(/PodCodar/i); + await expect(page.getByRole('heading', { name: /faça parte/i, level: 1 })).toBeVisible(); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// Navigation: Contact page (/contact) +// ────────────────────────────────────────────────────────────────────────────── + +test.describe('Contact page', () => { + test('navigates to Contact page via nav link', async ({ page }) => { + await page.goto('/'); + + // Primary nav has a "Contato" link + await page + .getByRole('navigation', { name: 'Primary' }) + .getByRole('link', { name: /contato/i }) + .click(); + + await expect(page).toHaveURL(/\/contact/); + await expect(page.getByRole('heading', { name: /contato|entre em contato/i })).toBeVisible(); + }); + + test('can navigate directly to /contact', async ({ page }) => { + await page.goto('/contact'); + + await expect(page).toHaveTitle(/PodCodar/i); + await expect(page.getByRole('heading', { name: /contato|entre em contato/i })).toBeVisible(); + }); +}); diff --git a/lefthook.yml b/lefthook.yml index e19fd6c..cc7a68f 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -17,6 +17,18 @@ pre-commit: glob: "*.{js,mjs,cjs,ts,tsx,jsx,json,jsonc}" run: bunx biome format --staged -- {files} +# Manual commands for working with staged files +staged: + parallel: true + jobs: + - name: List staged files + run: git diff --cached --name-only --diff-filter=ACM + + - name: Format staged files + files: git diff --cached --name-only --diff-filter=ACM + glob: "*.{js,mjs,cjs,ts,tsx,jsx,json,jsonc}" + run: bunx biome format --staged -- {files} + # Pre-push hook runs before pushing to remote pre-push: parallel: true diff --git a/package.json b/package.json index dc9465f..9f3c667 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,5 @@ { "name": "webapp", - "type": "module", "version": "0.0.1", "engines": { "node": ">=22.12.0" @@ -10,6 +9,7 @@ "build": "astro build", "preview": "astro preview", "astro": "astro", + "postinstall": "lefthook install", "update": "bun update --all", "lint": "biome check", "lint:fix": "biome check --write && astro check --fix", @@ -18,6 +18,7 @@ "typecheck": "astro sync", "test": "vitest run", "test:watch": "vitest", + "e2e": "playwright test", "lefthook": "lefthook" }, "dependencies": { @@ -36,7 +37,9 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.12", + "@playwright/test": "^1.59.1", "lefthook": "^2.1.6", + "playwright": "^1.59.1", "vitest": "^4.1.4" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..7f581ee --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,45 @@ +import { defineConfig, devices } from '@playwright/test'; + +const targetUrl = 'http://localhost:4321'; + +export default defineConfig({ + // Look for test files in the "tests" directory, relative to this configuration file. + testDir: 'e2e', + + // Run all tests in parallel. + fullyParallel: true, + + // Fail the build on CI if you accidentally left test.only in the source code. + forbidOnly: !!process.env.CI, + + // Retry on CI only. + retries: process.env.CI ? 2 : 0, + + // Opt out of parallel tests on CI. + workers: process.env.CI ? 1 : undefined, + + // Reporter to use + reporter: 'html', + + use: { + // Base URL to use in actions like `await page.goto('/')`. + baseURL: targetUrl, + + // Collect trace when retrying the failed test. + trace: 'on-first-retry', + }, + // Configure projects for major browsers. + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + // Run your local dev server before starting the tests. + webServer: { + command: 'bun run dev', + url: targetUrl, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/src/components/FormattedDate.astro b/src/components/FormattedDate.astro index 4b77ad6..ad194a5 100644 --- a/src/components/FormattedDate.astro +++ b/src/components/FormattedDate.astro @@ -1,7 +1,7 @@ --- -interface Props { +type Props = { date: Date; -} +}; const { date } = Astro.props; --- diff --git a/src/components/FormattedDate.test.ts b/src/components/FormattedDate.test.ts index 506bf6b..d695ff2 100644 --- a/src/components/FormattedDate.test.ts +++ b/src/components/FormattedDate.test.ts @@ -3,12 +3,12 @@ import { expect, test } from 'vitest'; import FormattedDate from './FormattedDate.astro'; test('FormattedDate renders a