Compare commits

...

14 Commits

Author SHA1 Message Date
Lluis Agusti
20c9277f63 Merge 'dev' into 'chore/storybook-test-setup' 2025-07-04 19:38:23 +04:00
Lluis Agusti
0fd3c2daae Merge 'dev' into 'chore/storybook-test-setup' 2025-06-27 15:11:31 +04:00
Lluis Agusti
8b0888b5aa chore: tests 2025-06-26 17:33:16 +04:00
Lluis Agusti
0961aed731 Merge 'dev' into 'chore/storybook-test-setup' 2025-06-26 17:30:23 +04:00
Lluis Agusti
b272b79652 chore: fix formatting in AGENTS.md 2025-06-26 17:30:07 +04:00
Lluis Agusti
cb9fda0f1d chore: update command for ci... 2025-06-25 20:21:51 +04:00
Lluis Agusti
1ed1af8ca0 chore: ci tests 2025-06-25 20:19:14 +04:00
Ubbe
0882a277b1 Merge branch 'dev' into chore/storybook-test-setup 2025-06-25 20:16:16 +04:00
Lluis Agusti
d29f086dec chore: update readme 2025-06-25 20:08:51 +04:00
Lluis Agusti
2c6b9c7c27 chore: more changes 2025-06-25 20:00:45 +04:00
Lluis Agusti
1029ee5c45 chore: more 2025-06-25 20:00:32 +04:00
Lluis Agusti
4e817c8d8a chore: add unit tests 2025-06-25 20:00:03 +04:00
Lluis Agusti
7c3e8ec221 Merge 'dev' into 'chore/storybook-test-setup' 2025-06-25 19:19:30 +04:00
Lluis Agusti
996103d1e1 chore: add tests 2025-06-25 19:19:01 +04:00
21 changed files with 2143 additions and 355 deletions

View File

@@ -108,7 +108,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Run tsc check
run: pnpm type-check
run: pnpm types
chromatic:
runs-on: ubuntu-latest
@@ -148,6 +148,27 @@ jobs:
onlyChanged: true
workingDir: autogpt_platform/frontend
token: ${{ secrets.GITHUB_TOKEN }}
buildScriptName: storybook:build
test-unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "21"
- name: Enable corepack
run: corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run unit tests
run: pnpm test:unit
test:
runs-on: ubuntu-latest
@@ -211,7 +232,7 @@ jobs:
run: pnpm playwright install --with-deps ${{ matrix.browser }}
- name: Run Playwright tests
run: pnpm test:no-build --project=${{ matrix.browser }}
run: pnpm playwright test --project=${{ matrix.browser }}
- name: Print Final Docker Compose logs
if: always()

View File

@@ -235,7 +235,7 @@ repos:
hooks:
- id: tsc
name: Typecheck - AutoGPT Platform - Frontend
entry: bash -c 'cd autogpt_platform/frontend && pnpm type-check'
entry: bash -c 'cd autogpt_platform/frontend && pnpm types'
files: ^autogpt_platform/frontend/
types: [file]
language: system

View File

@@ -19,7 +19,7 @@ See `docs/content/platform/getting-started.md` for setup instructions.
## Testing
- Backend: `poetry run test` (runs pytest with a docker based postgres + prisma).
- Frontend: `pnpm test` or `pnpm test-ui` for Playwright tests. See `docs/content/platform/contributing/tests.md` for tips.
- Frontend: `pnpm test` or `pnpm test:ui` for Playwright tests. See `docs/content/platform/contributing/tests.md` for tips.
Always run the relevant linters and tests before committing.
Use conventional commit messages for all commits (e.g. `feat(backend): add API`).

View File

@@ -5,6 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Repository Overview
AutoGPT Platform is a monorepo containing:
- **Backend** (`/backend`): Python FastAPI server with async support
- **Frontend** (`/frontend`): Next.js React application
- **Shared Libraries** (`/autogpt_libs`): Common Python utilities
@@ -12,6 +13,7 @@ AutoGPT Platform is a monorepo containing:
## Essential Commands
### Backend Development
```bash
# Install dependencies
cd backend && poetry install
@@ -36,6 +38,7 @@ poetry run pytest path/to/test_file.py::test_function_name
poetry run format # Black + isort
poetry run lint # ruff
```
More details can be found in TESTING.md
#### Creating/Updating Snapshots
@@ -48,8 +51,8 @@ poetry run pytest path/to/test.py --snapshot-update
⚠️ **Important**: Always review snapshot changes before committing! Use `git diff` to verify the changes are expected.
### Frontend Development
```bash
# Install dependencies
cd frontend && npm install
@@ -67,12 +70,13 @@ npm run storybook
npm run build
# Type checking
npm run type-check
npm run types
```
## Architecture Overview
### Backend Architecture
- **API Layer**: FastAPI with REST and WebSocket endpoints
- **Database**: PostgreSQL with Prisma ORM, includes pgvector for embeddings
- **Queue System**: RabbitMQ for async task processing
@@ -81,6 +85,7 @@ npm run type-check
- **Security**: Cache protection middleware prevents sensitive data caching in browsers/proxies
### Frontend Architecture
- **Framework**: Next.js App Router with React Server Components
- **State Management**: React hooks + Supabase client for real-time updates
- **Workflow Builder**: Visual graph editor using @xyflow/react
@@ -88,6 +93,7 @@ npm run type-check
- **Feature Flags**: LaunchDarkly integration
### Key Concepts
1. **Agent Graphs**: Workflow definitions stored as JSON, executed by the backend
2. **Blocks**: Reusable components in `/backend/blocks/` that perform specific tasks
3. **Integrations**: OAuth and API connections stored per user
@@ -95,13 +101,16 @@ npm run type-check
5. **Virus Scanning**: ClamAV integration for file upload security
### Testing Approach
- Backend uses pytest with snapshot testing for API responses
- Test files are colocated with source files (`*_test.py`)
- Frontend uses Playwright for E2E tests
- Component testing via Storybook
### Database Schema
Key models (defined in `/backend/schema.prisma`):
- `User`: Authentication and profile data
- `AgentGraph`: Workflow definitions with version control
- `AgentGraphExecution`: Execution history and results
@@ -109,6 +118,7 @@ Key models (defined in `/backend/schema.prisma`):
- `StoreListing`: Marketplace listings for sharing agents
### Environment Configuration
- Backend: `.env` file in `/backend`
- Frontend: `.env.local` file in `/frontend`
- Both require Supabase credentials and API keys for various services
@@ -116,6 +126,7 @@ Key models (defined in `/backend/schema.prisma`):
### Common Development Tasks
**Adding a new block:**
1. Create new file in `/backend/backend/blocks/`
2. Inherit from `Block` base class
3. Define input/output schemas
@@ -124,12 +135,14 @@ Key models (defined in `/backend/schema.prisma`):
6. Generate the block uuid using `uuid.uuid4()`
**Modifying the API:**
1. Update route in `/backend/backend/server/routers/`
2. Add/update Pydantic models in same directory
3. Write tests alongside the route file
4. Run `poetry run test` to verify
**Frontend feature development:**
1. Components go in `/frontend/src/components/`
2. Use existing UI components from `/frontend/src/components/ui/`
3. Add Storybook stories for new components
@@ -138,10 +151,11 @@ Key models (defined in `/backend/schema.prisma`):
### Security Implementation
**Cache Protection Middleware:**
- Located in `/backend/backend/server/middleware/security.py`
- Default behavior: Disables caching for ALL endpoints with `Cache-Control: no-store, no-cache, must-revalidate, private`
- Uses an allow list approach - only explicitly permitted paths can be cached
- Cacheable paths include: static assets (`/static/*`, `/_next/static/*`), health checks, public store pages, documentation
- Prevents sensitive data (auth tokens, API keys, user data) from being cached by browsers/proxies
- To allow caching for a new endpoint, add it to `CACHEABLE_PATHS` in the middleware
- Applied to both main API server and external API applications
- Applied to both main API server and external API applications

View File

@@ -13,6 +13,7 @@ const config: StorybookConfig = {
"@storybook/addon-onboarding",
"@storybook/addon-links",
"@storybook/addon-docs",
"@storybook/addon-interactions",
],
features: {
experimentalRSC: true,

View File

@@ -60,10 +60,11 @@ Every time a new Front-end dependency is added by you or others, you will need t
- `pnpm start` - Start production server
- `pnpm lint` - Run ESLint and Prettier checks
- `pnpm format` - Format code with Prettier
- `pnpm type-check` - Run TypeScript type checking
- `pnpm types` - Run TypeScript type checking
- `pnpm test` - Run Playwright tests
- `pnpm test-ui` - Run Playwright tests with UI
- `pnpm fetch:openapi` - Fetch OpenAPI spec from backend
- `pnpm test:ui` - Run Playwright tests with UI
- `pnpm test:unit` - Run unit tests (Vitest)
- `pnpm test:unit:watch` - Run unit tests (Vitest) in watch mode
- `pnpm generate:api-client` - Generate API client from OpenAPI spec
- `pnpm generate:api-all` - Fetch OpenAPI spec and generate API client
@@ -237,17 +238,10 @@ Storybook is a powerful development environment for UI components. It allows you
To build a static version of Storybook for deployment, use:
```bash
pnpm build-storybook
pnpm storybook:build
```
3. **Running Storybook Tests**:
Storybook tests can be run using:
```bash
pnpm test-storybook
```
4. **Writing Stories**:
3. **Writing Stories**:
Create `.stories.tsx` files alongside your components to define different states and variations of your components.
By integrating Storybook into our development workflow, we can streamline UI development, improve component reusability, and maintain a consistent design system across the project.

View File

@@ -9,15 +9,14 @@
"start:standalone": "cd .next/standalone && node server.js",
"lint": "next lint && prettier --check .",
"format": "prettier --write .",
"type-check": "tsc --noEmit",
"types": "tsc --noEmit",
"test": "next build --turbo && playwright test",
"test-ui": "next build --turbo && playwright test --ui",
"test:no-build": "playwright test",
"test:ui": "next build --turbo && playwright test --ui",
"test:unit": "vitest --config vitest.config.mjs --run",
"test:unit:watch": "vitest --config vitest.config.mjs --watch",
"gentests": "playwright codegen http://localhost:3000",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test-storybook": "test-storybook",
"test-storybook:ci": "concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"pnpm run build-storybook -- --quiet && npx http-server storybook-static --port 6006 --silent\" \"wait-on tcp:6006 && pnpm run test-storybook\"",
"storybook:build": "storybook build",
"fetch:openapi": "curl http://localhost:8006/openapi.json > ./src/app/api/openapi.json && prettier --write ./src/app/api/openapi.json",
"generate:api-client": "orval --config ./orval.config.ts",
"generate:api-all": "pnpm run fetch:openapi && pnpm run generate:api-client"
@@ -28,7 +27,7 @@
"dependencies": {
"@faker-js/faker": "9.8.0",
"@hookform/resolvers": "5.1.1",
"@next/third-parties": "15.3.3",
"@next/third-parties": "15.3.4",
"@phosphor-icons/react": "2.1.10",
"@radix-ui/react-alert-dialog": "1.1.14",
"@radix-ui/react-avatar": "1.1.10",
@@ -49,13 +48,13 @@
"@radix-ui/react-tabs": "1.1.12",
"@radix-ui/react-toast": "1.2.14",
"@radix-ui/react-tooltip": "1.2.7",
"@sentry/nextjs": "9.27.0",
"@sentry/nextjs": "9.33.0",
"@supabase/ssr": "0.6.1",
"@supabase/supabase-js": "2.50.0",
"@tanstack/react-query": "5.80.7",
"@supabase/supabase-js": "2.50.2",
"@tanstack/react-query": "5.81.2",
"@tanstack/react-table": "8.21.3",
"@types/jaro-winkler": "0.2.4",
"@xyflow/react": "12.6.4",
"@xyflow/react": "12.8.0",
"ajv": "8.17.1",
"boring-avatars": "1.11.2",
"class-variance-authority": "0.7.1",
@@ -63,24 +62,24 @@
"cmdk": "1.1.1",
"cookie": "1.0.2",
"date-fns": "4.1.0",
"dotenv": "16.5.0",
"dotenv": "16.6.0",
"elliptic": "6.6.1",
"embla-carousel-react": "8.6.0",
"framer-motion": "12.16.0",
"framer-motion": "12.19.2",
"geist": "1.4.2",
"jaro-winkler": "0.2.8",
"launchdarkly-react-client-sdk": "3.8.1",
"lodash": "4.17.21",
"lucide-react": "0.513.0",
"lucide-react": "0.524.0",
"moment": "2.30.1",
"next": "15.3.3",
"next": "15.3.4",
"next-themes": "0.4.6",
"party-js": "2.2.0",
"react": "18.3.1",
"react-day-picker": "9.7.0",
"react-dom": "18.3.1",
"react-drag-drop-files": "2.4.0",
"react-hook-form": "7.57.0",
"react-hook-form": "7.58.1",
"react-icons": "5.5.0",
"react-markdown": "9.0.3",
"react-modal": "3.16.3",
@@ -91,7 +90,7 @@
"tailwindcss-animate": "1.0.7",
"uuid": "11.1.0",
"vaul": "1.1.2",
"zod": "3.25.56"
"zod": "3.25.67"
},
"devDependencies": {
"@chromatic-com/storybook": "4.0.1",
@@ -103,6 +102,9 @@
"@storybook/nextjs": "9.0.14",
"@tanstack/eslint-plugin-query": "5.81.2",
"@tanstack/react-query-devtools": "5.81.5",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/canvas-confetti": "1.9.0",
"@types/lodash": "4.17.19",
"@types/negotiator": "0.6.4",
@@ -110,6 +112,7 @@
"@types/react": "18.3.17",
"@types/react-dom": "18.3.5",
"@types/react-modal": "3.16.3",
"@vitest/browser": "3.2.4",
"axe-playwright": "2.1.0",
"chromatic": "11.25.2",
"concurrently": "9.2.0",
@@ -118,6 +121,7 @@
"eslint-config-next": "15.3.4",
"eslint-plugin-storybook": "9.0.14",
"import-in-the-middle": "1.14.2",
"jsdom": "26.1.0",
"msw": "2.10.2",
"msw-storybook-addon": "2.0.5",
"orval": "7.10.0",
@@ -127,7 +131,9 @@
"require-in-the-middle": "7.5.2",
"storybook": "9.0.14",
"tailwindcss": "3.4.17",
"typescript": "5.8.3"
"typescript": "5.8.3",
"vite": "7.0.0",
"vitest": "3.2.4"
},
"msw": {
"workerDirectory": [

File diff suppressed because it is too large Load Diff

View File

@@ -1,81 +1,81 @@
// import { render, screen } from "@testing-library/react";
// import { describe, expect, it } from "vitest";
// import { Badge } from "./Badge";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Badge } from "./Badge";
// describe("Badge Component", () => {
// it("renders badge with content", () => {
// render(<Badge variant="success">Success</Badge>);
describe("Badge Component", () => {
it("renders badge with content", () => {
render(<Badge variant="success">Success</Badge>);
// expect(screen.getByText("Success")).toBeInTheDocument();
// });
expect(screen.getByText("Success")).toBeInTheDocument();
});
// it("applies correct variant styles", () => {
// const { rerender } = render(<Badge variant="success">Success</Badge>);
// let badge = screen.getByText("Success");
// expect(badge).toHaveClass("bg-green-100", "text-green-800");
it("applies correct variant styles", () => {
const { rerender } = render(<Badge variant="success">Success</Badge>);
let badge = screen.getByText("Success");
expect(badge).toHaveClass("bg-green-100", "text-green-800");
// rerender(<Badge variant="error">Error</Badge>);
// badge = screen.getByText("Error");
// expect(badge).toHaveClass("bg-red-100", "text-red-800");
rerender(<Badge variant="error">Error</Badge>);
badge = screen.getByText("Error");
expect(badge).toHaveClass("bg-red-100", "text-red-800");
// rerender(<Badge variant="info">Info</Badge>);
// badge = screen.getByText("Info");
// expect(badge).toHaveClass("bg-slate-100", "text-slate-800");
// });
rerender(<Badge variant="info">Info</Badge>);
badge = screen.getByText("Info");
expect(badge).toHaveClass("bg-slate-100", "text-slate-800");
});
// it("applies custom className", () => {
// render(
// <Badge variant="success" className="custom-class">
// Success
// </Badge>,
// );
it("applies custom className", () => {
render(
<Badge variant="success" className="custom-class">
Success
</Badge>,
);
// const badge = screen.getByText("Success");
// expect(badge).toHaveClass("custom-class");
// });
const badge = screen.getByText("Success");
expect(badge).toHaveClass("custom-class");
});
// it("renders as span element", () => {
// render(<Badge variant="success">Success</Badge>);
it("renders as span element", () => {
render(<Badge variant="success">Success</Badge>);
// const badge = screen.getByText("Success");
// expect(badge.tagName).toBe("SPAN");
// });
const badge = screen.getByText("Success");
expect(badge.tagName).toBe("SPAN");
});
// it("renders children correctly", () => {
// render(
// <Badge variant="success">
// <span>Custom</span> Content
// </Badge>,
// );
it("renders children correctly", () => {
render(
<Badge variant="success">
<span>Custom</span> Content
</Badge>,
);
// expect(screen.getByText("Custom")).toBeInTheDocument();
// expect(screen.getByText("Content")).toBeInTheDocument();
// });
expect(screen.getByText("Custom")).toBeInTheDocument();
expect(screen.getByText("Content")).toBeInTheDocument();
});
// it("supports all badge variants", () => {
// const variants = ["success", "error", "info"] as const;
it("supports all badge variants", () => {
const variants = ["success", "error", "info"] as const;
// variants.forEach((variant) => {
// const { unmount } = render(
// <Badge variant={variant} data-testid={`badge-${variant}`}>
// {variant}
// </Badge>,
// );
variants.forEach((variant) => {
const { unmount } = render(
<Badge variant={variant} data-testid={`badge-${variant}`}>
{variant}
</Badge>,
);
// expect(screen.getByTestId(`badge-${variant}`)).toBeInTheDocument();
// unmount();
// });
// });
expect(screen.getByTestId(`badge-${variant}`)).toBeInTheDocument();
unmount();
});
});
// it("handles long text content", () => {
// render(
// <Badge variant="info">
// Very long text that should be handled properly by the component
// </Badge>,
// );
it("handles long text content", () => {
render(
<Badge variant="info">
Very long text that should be handled properly by the component
</Badge>,
);
// const badge = screen.getByText(/Very long text/);
// expect(badge).toBeInTheDocument();
// expect(badge).toHaveClass("overflow-hidden", "text-ellipsis");
// });
// });
const badge = screen.getByText(/Very long text/);
expect(badge).toBeInTheDocument();
expect(badge).toHaveClass("overflow-hidden", "text-ellipsis");
});
});

View File

@@ -2,10 +2,9 @@ import { cn } from "@/lib/utils";
type BadgeVariant = "success" | "error" | "info";
interface BadgeProps {
interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
variant: BadgeVariant;
children: React.ReactNode;
className?: string;
}
const badgeVariants: Record<BadgeVariant, string> = {
@@ -14,7 +13,7 @@ const badgeVariants: Record<BadgeVariant, string> = {
info: "bg-slate-100 text-slate-800",
};
export function Badge({ variant, children, className }: BadgeProps) {
export function Badge({ variant, children, className, ...props }: BadgeProps) {
return (
<span
className={cn(
@@ -28,6 +27,7 @@ export function Badge({ variant, children, className }: BadgeProps) {
badgeVariants[variant],
className,
)}
{...props}
>
{children}
</span>

View File

@@ -0,0 +1,138 @@
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Button } from "./Button";
describe("Button Component", () => {
it("renders button with text", () => {
render(<Button>Click me</Button>);
expect(
screen.getByRole("button", { name: "Click me" }),
).toBeInTheDocument();
});
it("calls onClick when clicked", async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole("button"));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it("is disabled when disabled prop is true", () => {
render(<Button disabled>Disabled button</Button>);
expect(screen.getByRole("button")).toBeDisabled();
});
it("does not call onClick when disabled", async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(
<Button disabled onClick={handleClick}>
Disabled button
</Button>,
);
// Try to click the disabled button
await user.click(screen.getByRole("button"));
expect(handleClick).not.toHaveBeenCalled();
});
it("renders with different variants", () => {
const { rerender } = render(<Button variant="primary">Primary</Button>);
expect(screen.getByRole("button")).toHaveClass("bg-zinc-800");
rerender(<Button variant="secondary">Secondary</Button>);
expect(screen.getByRole("button")).toHaveClass("bg-zinc-100");
rerender(<Button variant="destructive">Destructive</Button>);
expect(screen.getByRole("button")).toHaveClass("bg-red-500");
rerender(<Button variant="outline">Outline</Button>);
expect(screen.getByRole("button")).toHaveClass("border-zinc-700");
rerender(<Button variant="ghost">Ghost</Button>);
expect(screen.getByRole("button")).toHaveClass("bg-transparent");
});
// Sizes and variants styling are covered by Chromatic via Storybook
it("shows loading state", () => {
render(<Button loading>Loading button</Button>);
const button = screen.getByRole("button");
expect(button).toHaveClass("pointer-events-none");
// Check for loading spinner (svg element)
const spinner = button.querySelector("svg");
expect(spinner).toBeInTheDocument();
expect(spinner).toHaveClass("animate-spin");
});
it("renders with left icon", () => {
const TestIcon = () => <span data-testid="test-icon">Icon</span>;
render(<Button leftIcon={<TestIcon />}>Button with left icon</Button>);
expect(screen.getByTestId("test-icon")).toBeInTheDocument();
expect(screen.getByText("Button with left icon")).toBeInTheDocument();
});
it("renders with right icon", () => {
const TestIcon = () => <span data-testid="test-icon">Icon</span>;
render(<Button rightIcon={<TestIcon />}>Button with right icon</Button>);
expect(screen.getByTestId("test-icon")).toBeInTheDocument();
expect(screen.getByText("Button with right icon")).toBeInTheDocument();
});
it("supports keyboard navigation", async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Keyboard button</Button>);
const button = screen.getByRole("button");
// Focus the button
await user.tab();
expect(button).toHaveFocus();
// Press Enter
await user.keyboard("{Enter}");
expect(handleClick).toHaveBeenCalledTimes(1);
// Press Space
await user.keyboard(" ");
expect(handleClick).toHaveBeenCalledTimes(2);
});
it("applies custom className", () => {
render(<Button className="custom-class">Custom button</Button>);
expect(screen.getByRole("button")).toHaveClass("custom-class");
});
it("handles double click", async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Double click me</Button>);
await user.dblClick(screen.getByRole("button"));
expect(handleClick).toHaveBeenCalledTimes(2);
});
it("maintains focus after click", async () => {
const user = userEvent.setup();
render(<Button>Focus test</Button>);
const button = screen.getByRole("button");
await user.click(button);
expect(button).toHaveFocus();
});
});

View File

@@ -0,0 +1,158 @@
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Input } from "./Input";
describe("Input Component", () => {
it("renders input with label", () => {
render(<Input label="Username" />);
expect(screen.getByLabelText("Username")).toBeInTheDocument();
expect(screen.getByText("Username")).toBeInTheDocument();
});
it("renders input with hidden label", () => {
render(<Input label="Username" hideLabel />);
const input = screen.getByRole("textbox");
expect(input).toHaveAttribute("aria-label", "Username");
expect(screen.queryByText("Username")).not.toBeInTheDocument();
});
it("calls onChange when typing", async () => {
const handleChange = vi.fn();
const user = userEvent.setup();
render(<Input label="Username" onChange={handleChange} />);
const input = screen.getByLabelText("Username");
await user.type(input, "test");
expect(handleChange).toHaveBeenCalled();
expect(input).toHaveValue("test");
});
it("displays placeholder text", () => {
render(<Input label="Username" placeholder="Enter your username" />);
const input = screen.getByLabelText("Username");
expect(input).toHaveAttribute("placeholder", "Enter your username");
});
it("uses label as placeholder when no placeholder provided", () => {
render(<Input label="Username" />);
const input = screen.getByLabelText("Username");
expect(input).toHaveAttribute("placeholder", "Username");
});
it("displays error message", () => {
render(<Input label="Username" error="Username is required" />);
expect(screen.getByText("Username is required")).toBeInTheDocument();
const input = screen.getByRole("textbox");
expect(input).toBeInTheDocument();
});
it("is disabled when disabled prop is true", () => {
render(<Input label="Username" disabled />);
const input = screen.getByLabelText("Username");
expect(input).toBeDisabled();
});
it("does not call onChange when disabled", async () => {
const handleChange = vi.fn();
const user = userEvent.setup();
render(<Input label="Username" disabled onChange={handleChange} />);
const input = screen.getByLabelText("Username");
await user.type(input, "test");
expect(handleChange).not.toHaveBeenCalled();
expect(input).toHaveValue("");
});
it("supports different input types", () => {
const { rerender } = render(<Input label="Email" type="email" />);
expect(screen.getByLabelText("Email")).toHaveAttribute("type", "email");
rerender(<Input label="Password" type="password" />);
expect(screen.getByLabelText("Password")).toHaveAttribute(
"type",
"password",
);
rerender(<Input label="Number" type="number" />);
expect(screen.getByLabelText("Number")).toHaveAttribute("type", "number");
});
it("handles focus and blur events", async () => {
const handleFocus = vi.fn();
const handleBlur = vi.fn();
const user = userEvent.setup();
render(
<Input label="Username" onFocus={handleFocus} onBlur={handleBlur} />,
);
const input = screen.getByLabelText("Username");
await user.click(input);
expect(handleFocus).toHaveBeenCalledTimes(1);
await user.tab();
expect(handleBlur).toHaveBeenCalledTimes(1);
});
it("accepts a default value", () => {
render(<Input label="Username" defaultValue="john_doe" />);
const input = screen.getByLabelText("Username");
expect(input).toHaveValue("john_doe");
});
it("can be controlled with value prop", async () => {
const handleChange = vi.fn();
const user = userEvent.setup();
const { rerender } = render(
<Input label="Username" value="initial" onChange={handleChange} />,
);
const input = screen.getByLabelText("Username");
expect(input).toHaveValue("initial");
// Try typing - should call onChange but value stays controlled
await user.type(input, "x");
expect(handleChange).toHaveBeenCalled();
// Update with new controlled value
rerender(
<Input label="Username" value="updated" onChange={handleChange} />,
);
expect(input).toHaveValue("updated");
});
it("supports keyboard navigation", async () => {
const user = userEvent.setup();
render(<Input label="Username" />);
const input = screen.getByLabelText("Username");
// Tab to focus
await user.tab();
expect(input).toHaveFocus();
// Type some text
await user.keyboard("test");
expect(input).toHaveValue("test");
// Navigate within text
await user.keyboard("{Home}");
await user.keyboard("start");
expect(input).toHaveValue("starttest");
});
});

View File

@@ -0,0 +1,144 @@
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { forwardRef } from "react";
import { describe, expect, it, vi } from "vitest";
import { Link } from "./Link";
// Mock Next.js Link with proper ref forwarding
vi.mock("next/link", () => ({
default: forwardRef(function MockNextLink(
{ href, children, ...props }: any,
ref: any,
) {
return (
<a ref={ref} href={href} {...props}>
{children}
</a>
);
}),
}));
describe("Link Component", () => {
it("renders internal link with correct href", () => {
render(<Link href="/dashboard">Dashboard</Link>);
const link = screen.getByRole("link", { name: "Dashboard" });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", "/dashboard");
});
it("renders external link with target blank", () => {
render(
<Link href="https://example.com" isExternal>
External Link
</Link>,
);
const link = screen.getByRole("link", { name: "External Link" });
expect(link).toHaveAttribute("href", "https://example.com");
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
it("handles click events", async () => {
const handleClick = vi.fn((e) => e.preventDefault());
const user = userEvent.setup();
render(
<Link href="/dashboard" onClick={handleClick}>
Dashboard
</Link>,
);
const link = screen.getByRole("link", { name: "Dashboard" });
await user.click(link);
expect(handleClick).toHaveBeenCalledTimes(1);
});
it("supports keyboard navigation", async () => {
const handleKeyDown = vi.fn((e) => {
if (e.key === "Enter") e.preventDefault();
});
const user = userEvent.setup();
render(
<Link href="/dashboard" onKeyDown={handleKeyDown}>
Dashboard
</Link>,
);
const link = screen.getByRole("link", { name: "Dashboard" });
await user.tab();
expect(link).toHaveFocus();
await user.keyboard("{Enter}");
expect(handleKeyDown).toHaveBeenCalled();
});
it("applies custom className", () => {
render(
<Link href="/dashboard" className="custom-class">
Dashboard
</Link>,
);
const link = screen.getByRole("link", { name: "Dashboard" });
expect(link).toHaveClass("custom-class");
});
it("forwards ref correctly", () => {
const ref = { current: null };
render(
<Link href="/dashboard" ref={ref}>
Dashboard
</Link>,
);
// Check that ref is populated
expect(ref.current).toBeTruthy();
});
it("passes through additional props", () => {
render(
<Link href="/dashboard" data-testid="custom-link">
Dashboard
</Link>,
);
const link = screen.getByRole("link", { name: "Dashboard" });
expect(link).toHaveAttribute("data-testid", "custom-link");
});
it("renders children correctly", () => {
render(
<Link href="/dashboard">
<span>Dashboard</span>
<span>Icon</span>
</Link>,
);
expect(screen.getByText("Dashboard")).toBeInTheDocument();
expect(screen.getByText("Icon")).toBeInTheDocument();
});
it("distinguishes between internal and external links", () => {
const { rerender } = render(<Link href="/internal">Internal</Link>);
let link = screen.getByRole("link", { name: "Internal" });
expect(link).not.toHaveAttribute("target");
expect(link).not.toHaveAttribute("rel");
rerender(
<Link href="https://external.com" isExternal>
External
</Link>,
);
link = screen.getByRole("link", { name: "External" });
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
});

View File

@@ -2,12 +2,13 @@ import { cn } from "@/lib/utils";
import NextLink from "next/link";
import { forwardRef } from "react";
interface LinkProps {
interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
href: string;
children: React.ReactNode;
className?: string;
isExternal?: boolean;
variant?: "primary" | "secondary";
title?: string;
}
const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
@@ -16,6 +17,7 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
children,
className,
isExternal = false,
title,
variant = "primary",
...props
},
@@ -40,6 +42,7 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
target="_blank"
rel="noopener noreferrer"
className={linkClasses}
title={title}
{...props}
>
{children}

View File

@@ -0,0 +1,75 @@
import { Skeleton } from "@/components/ui/skeleton";
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
describe("Skeleton Component", () => {
it("renders skeleton element", () => {
render(<Skeleton data-testid="skeleton" />);
const skeleton = screen.getByTestId("skeleton");
expect(skeleton).toBeInTheDocument();
});
it("applies custom className", () => {
render(<Skeleton className="custom-skeleton" data-testid="skeleton" />);
const skeleton = screen.getByTestId("skeleton");
expect(skeleton).toHaveClass("custom-skeleton");
});
it("passes through HTML attributes", () => {
render(
<Skeleton
data-testid="skeleton"
role="progressbar"
aria-label="Loading content"
/>,
);
const skeleton = screen.getByTestId("skeleton");
expect(skeleton).toHaveAttribute("role", "progressbar");
expect(skeleton).toHaveAttribute("aria-label", "Loading content");
});
it("renders as a div element", () => {
render(<Skeleton data-testid="skeleton" />);
const skeleton = screen.getByTestId("skeleton");
expect(skeleton.tagName).toBe("DIV");
});
it("can contain children", () => {
render(
<Skeleton data-testid="skeleton">
<span>Loading...</span>
</Skeleton>,
);
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
it("supports style prop", () => {
render(
<Skeleton
data-testid="skeleton"
style={{ width: "100px", height: "20px" }}
/>,
);
const skeleton = screen.getByTestId("skeleton");
expect(skeleton).toHaveStyle({ width: "100px", height: "20px" });
});
it("supports onClick handler", async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Skeleton data-testid="skeleton" onClick={handleClick} />);
const skeleton = screen.getByTestId("skeleton");
await user.click(skeleton);
expect(handleClick).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1 @@
export * from "@/components/ui/skeleton";

View File

@@ -0,0 +1,142 @@
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Text } from "./Text";
describe("Text Component", () => {
it("renders text content", () => {
render(<Text variant="body">Hello World</Text>);
expect(screen.getByText("Hello World")).toBeInTheDocument();
});
it("renders with different variants", () => {
const { rerender } = render(<Text variant="body">Body Text</Text>);
expect(screen.getByText("Body Text")).toBeInTheDocument();
rerender(<Text variant="body-medium">Medium Text</Text>);
expect(screen.getByText("Medium Text")).toBeInTheDocument();
rerender(<Text variant="small">Small Text</Text>);
expect(screen.getByText("Small Text")).toBeInTheDocument();
});
it("renders with custom element using as prop", () => {
render(
<Text variant="body" as="h1">
Heading Text
</Text>,
);
const element = screen.getByText("Heading Text");
expect(element.tagName).toBe("H1");
});
it("applies custom className", () => {
render(
<Text variant="body" className="custom-text">
Styled Text
</Text>,
);
const element = screen.getByText("Styled Text");
expect(element).toHaveClass("custom-text");
});
it("passes through HTML attributes", () => {
render(
<Text variant="body" data-testid="text-element" title="Tooltip text">
Text with attributes
</Text>,
);
const element = screen.getByTestId("text-element");
expect(element).toHaveAttribute("title", "Tooltip text");
});
it("supports onClick handler", async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(
<Text variant="body" onClick={handleClick}>
Clickable Text
</Text>,
);
const element = screen.getByText("Clickable Text");
await user.click(element);
expect(handleClick).toHaveBeenCalledTimes(1);
});
it("renders children correctly", () => {
render(
<Text variant="body">
<span>Child 1</span>
<span>Child 2</span>
</Text>,
);
expect(screen.getByText("Child 1")).toBeInTheDocument();
expect(screen.getByText("Child 2")).toBeInTheDocument();
});
it("supports different text variants", () => {
const variants = [
"h1",
"h2",
"h3",
"h4",
"lead",
"large",
"large-medium",
"large-semibold",
"body",
"body-medium",
"small",
"small-medium",
"subtle",
];
variants.forEach((variant) => {
const { unmount } = render(
<Text variant={variant as any} data-testid={`text-${variant}`}>
{variant} text
</Text>,
);
expect(screen.getByTestId(`text-${variant}`)).toBeInTheDocument();
unmount();
});
});
it("handles empty children", () => {
render(<Text variant="body" data-testid="empty-text"></Text>);
const element = screen.getByTestId("empty-text");
expect(element).toBeInTheDocument();
expect(element).toBeEmptyDOMElement();
});
it("supports keyboard navigation when interactive", async () => {
const handleKeyDown = vi.fn();
const user = userEvent.setup();
render(
<Text variant="body" tabIndex={0} onKeyDown={handleKeyDown}>
Interactive Text
</Text>,
);
const element = screen.getByText("Interactive Text");
// Focus with tab
await user.tab();
expect(element).toHaveFocus();
// Press key
await user.keyboard("{Enter}");
expect(handleKeyDown).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,25 @@
import "@testing-library/jest-dom";
import { vi } from "vitest";
// Mock matchMedia for jsdom
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock IntersectionObserver
(global as any).IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {}
observe() {}
unobserve() {}
};

View File

@@ -0,0 +1,29 @@
import path from "path";
import { fileURLToPath } from "url";
import { defineConfig } from "vitest/config";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
esbuild: {
jsx: "automatic",
},
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
globals: true,
include: ["**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
exclude: [
"**/node_modules/**",
"**/dist/**",
"**/cypress/**",
"**/.{idea,git,cache,output,temp}/**",
"**/src/tests/**", // Exclude Playwright tests
],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});

View File

@@ -0,0 +1,21 @@
/// <reference types="vitest" />
import path from "path";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
browser: {
enabled: true,
name: "chromium",
provider: "playwright",
headless: true,
},
setupFiles: ["./src/test/setup.ts"],
globals: true,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});

View File

@@ -19,10 +19,10 @@ pnpm test
If you want to run the tests in a UI where you can identify each locator used you can use the following command:
```bash
pnpm test-ui
pnpm test:ui
```
You can also pass `--debug` to the test command to open the browsers in view mode rather than headless. This works with both the `pnpm test` and `pnpm test-ui` commands.
You can also pass `--debug` to the test command to open the browsers in view mode rather than headless. This works with both the `pnpm test` and `pnpm test:ui` commands.
```bash
pnpm test --debug